diff --git a/.github/workflows/run_dataset_generation.yaml b/.github/workflows/run_dataset_generation.yaml
index 88ddbbf5..3c95dfc9 100644
--- a/.github/workflows/run_dataset_generation.yaml
+++ b/.github/workflows/run_dataset_generation.yaml
@@ -1,35 +1,35 @@
name: Generate benchmarking dataset tables
-on:
+on:
workflow_dispatch:
jobs:
- test-ubuntu:
+ generate:
runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.9"]
+ # Regenerating the baselines runs the full calculator many times over.
+ timeout-minutes: 360
steps:
- uses: actions/checkout@v4
- - name: Setup python ${{ matrix.python-version }}
+
+ - name: Setup python 3.12
uses: actions/setup-python@v5
with:
- python-version: ${{ matrix.python-version }}
+ python-version: "3.12"
cache: 'pip'
- - name: Install octave
- run: |
- sudo apt-get update
- sudo apt-get install -y build-essential octave
- - name: Install pyspi dependencies
+
+ - name: Install pyspi
run: |
python -m pip install --upgrade pip
- pip install -r requirements.txt
pip install .
+
- name: Run data generation
- run: |
- python tests/generate_benchmark_tables.py
+ run: python tests/tools/generate_benchmark_tables.py --dataset all
+
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: benchmark-tables
- path: tests/CML7_benchmark_tables_new.pkl
+ # Glob rather than a fixed filename so this keeps working regardless of
+ # which baselines the script writes and what it names them.
+ path: tests/data/baselines/*.npz
+ if-no-files-found: error
diff --git a/.github/workflows/run_package_tests.yaml b/.github/workflows/run_package_tests.yaml
new file mode 100644
index 00000000..2532a4a4
--- /dev/null
+++ b/.github/workflows/run_package_tests.yaml
@@ -0,0 +1,108 @@
+name: Packaging Pipeline
+
+# The unit-test job installs pyspi as an editable checkout, so it exercises the
+# repository, not the artefact users receive. Everything that can only break in
+# a built wheel -- a data file left out of package-data, a config or dataset
+# resolved relative to the source tree, a console entry point that is not
+# wired up -- is invisible to it. This job builds the wheel, installs it into a
+# clean environment, and runs from a directory that contains no pyspi source.
+
+on: [push, pull_request]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ wheel:
+ name: wheel / python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.12"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Build the wheel
+ run: |
+ python -m pip install --upgrade pip build
+ python -m build --wheel --outdir dist/
+
+ - name: Install it into a clean environment
+ run: |
+ python -m venv /tmp/clean
+ /tmp/clean/bin/pip install --upgrade pip
+ /tmp/clean/bin/pip install dist/*.whl
+
+ # Run from an empty directory: from the repository root, `import pyspi`
+ # finds the source tree and the installed package is never touched.
+ - name: Import, resolve every bundled config and dataset, run fabfour
+ run: |
+ mkdir -p /tmp/run && cd /tmp/run
+ /tmp/clean/bin/python - <<'PY'
+ import os, tempfile
+ import numpy as np
+ import pyspi
+ from pyspi.calculator import (Calculator, bundled_configs, load_table,
+ load_spis_from_yaml, resolve_config)
+ from pyspi.data import available_datasets, load_dataset
+
+ for name in bundled_configs():
+ path = resolve_config(name)
+ assert os.path.exists(path), f"{name} -> {path}"
+ assert load_spis_from_yaml(path, quiet=True), name
+ for name in available_datasets():
+ load_dataset(name)
+
+ rng = np.random.default_rng(0)
+ calc = Calculator(dataset=rng.standard_normal((3, 100)), config="fabfour")
+ calc.compute()
+ assert not calc.errors, calc.errors
+
+ out = os.path.join(tempfile.mkdtemp(), "results.npz")
+ calc.save(out)
+ table = load_table(out)
+ assert np.allclose(table.to_numpy(), calc.table.to_numpy(), equal_nan=True)
+ assert table.attrs["run_digest"] == calc.run_digest
+ print("package smoke test OK")
+ PY
+
+ - name: Exercise the CLI
+ run: |
+ mkdir -p /tmp/cli && cd /tmp/cli
+ /tmp/clean/bin/python -c "import numpy as np; np.save('d.npy', np.random.default_rng(0).standard_normal((3, 100)))"
+ /tmp/clean/bin/python -m pyspi compute --data d.npy --config fabfour --output r.npz --quiet
+ /tmp/clean/bin/python -m pyspi --help
+ /tmp/clean/bin/python -c "
+ from pyspi.calculator import load_table
+ t = load_table('r.npz')
+ assert t.shape[0] == 3, t.shape
+ print('CLI round-trip OK')
+ "
+
+ lock:
+ name: locked resolution (uv.lock)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v5
+
+ # The matrix above floats to the latest compatible dependencies, which is
+ # what catches upstream breakage. This pins to the committed lock, which
+ # is what makes a failure there attributable: if both jobs fail the change
+ # is ours, if only the floating one does it is a dependency's.
+ - name: Check the lock is current
+ run: uv lock --check
+
+ - name: Run the fast suite against the locked resolution
+ run: |
+ uv sync --locked --extra testing
+ uv run pytest -q
diff --git a/.github/workflows/run_slow_tests.yaml b/.github/workflows/run_slow_tests.yaml
new file mode 100644
index 00000000..de604a79
--- /dev/null
+++ b/.github/workflows/run_slow_tests.yaml
@@ -0,0 +1,37 @@
+name: Slow Regression Suite
+
+# The slow suite compares every SPI against frozen baseline tables and takes
+# a couple of minutes plus three full-config computations, so it is not run
+# per-push. Pull requests, weekly, and on demand.
+on:
+ pull_request:
+ schedule:
+ # 04:00 UTC every Monday.
+ - cron: '0 4 * * 1'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ slow-test:
+ name: regression (ubuntu / python 3.12)
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: 'pip'
+
+ - name: Install pyspi
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e '.[testing]'
+
+ - name: Run regression tests
+ run: pytest -m slow
diff --git a/.github/workflows/run_unit_tests.yaml b/.github/workflows/run_unit_tests.yaml
index f645de1b..db55b486 100644
--- a/.github/workflows/run_unit_tests.yaml
+++ b/.github/workflows/run_unit_tests.yaml
@@ -1,35 +1,37 @@
name: Unit Testing Pipeline
-on:
- push:
+on: [push, pull_request]
+
+# Supersede in-flight runs for the same ref; keep runs on different refs independent.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
- test-ubuntu:
- runs-on: ubuntu-latest
+ test:
+ name: ${{ matrix.os }} / python ${{ matrix.python-version }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
strategy:
+ fail-fast: false
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+ os: [ubuntu-latest, macos-latest]
+ python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
+
- name: Setup python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- - name: Install octave
- run: |
- sudo apt-get update
- sudo apt-get install -y build-essential octave
- - name: Install pyspi dependencies
+
+ - name: Install pyspi
run: |
python -m pip install --upgrade pip
- pip install setuptools
- pip install -r requirements.txt
- pip install .
- - name: Run pyspi calculator/utils unit tests
- run: |
- pytest -v ./tests/test_calc.py
- pytest -v ./tests/test_utils.py
- - name: Run pyspi SPI unit tests
- run: |
- pytest -v ./tests/test_SPIs.py
+ pip install -e '.[testing]'
+
+ - name: Run unit tests
+ # pyproject.toml sets addopts = "-m 'not slow'", so the baseline-drift
+ # suite is excluded here by design. See run_slow_tests.yaml for that.
+ run: pytest -q
diff --git a/.gitignore b/.gitignore
index 082ecaa4..11a8dc0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,35 @@
-pyspi.egg-info
-pyspi/__pycache__
+# Build artefacts
+build/
+dist/
+*.egg-info/
**/__pycache__/**
-dist
+
+# Environments and tool caches
+.venv/
+.pytest_cache/
+.ruff_cache/
+
+# Data / logs. Regression baselines under tests/ are tracked deliberately.
*.pkl
!tests/*.pkl
*.log
*.bkp
+
+# Editor / OS
.DS_Store
-.vscode
-build
-octave-workspace
-readme.html
+.vscode/
+
+# Exploratory notebooks: regenerable from bench/results/cells + analyse_cells,
+# and they carry large non-rendering outputs. report.md is the tracked summary.
+bench/results/analysis/*.ipynb
+
+# Raw benchmark cells are large, machine-specific inputs to the tracked summaries.
+bench/results/cells/physics_config*.json
+
+# Local assistant instructions
+CLAUDE.md
+AGENTS.md
+.claude/
+
+# Review/critique artifacts (not part of the package)
+critiques.md
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 4e29bfd1..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "pyspi/lib/tigramite"]
- path = pyspi/lib/tigramite
- url = https://github.com/jakobrunge/tigramite.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..e0ccff74
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,290 @@
+# Changelog
+
+## 3.0.0
+
+A major overhaul. The Java/JIDT dependency is gone, the `Calculator` API is simplified, and several long-standing correctness bugs are fixed. **This release contains breaking changes** — see [Migrating from 2.x](#migrating-from-2x).
+
+### Removed: Java and JIDT
+
+Every information-theoretic estimator is now pure NumPy. `infodynamics.jar`, `jpype` and the JVM startup path are gone, so installing pyspi no longer requires a Java runtime.
+
+The port was validated against JIDT 1.6.1 before the dependency was dropped. That work caught four bugs in the port (kernel-entropy normalisation, Theiler-windowed KSG neighbour counting, Gaussian auto-embed bias, and KSG auto-embed estimator consistency), all fixed. Mean absolute error against JIDT at T=1600:
+
+| estimator | MI | TE | entropy |
+|:----------|---:|---:|--------:|
+| gaussian | 5.9e-17 | 4.7e-16 | 5.0e-09 |
+| kernel | 6.8e-16 | 5.0e-05 | 3.1e-15 |
+| symbolic | — | 2.4e-16 | — |
+| kozachenko | — | — | 4.2e-05 |
+| kraskov | 1.8e-03 | 2.5e-03 | — |
+
+Gaussian/kernel MI, kernel entropy and symbolic TE agree to machine precision. The gaussian-entropy offset is a deterministic ridge term (the analogue of JIDT's stochastic `NOISE_LEVEL_TO_ADD`).
+
+The k-NN rows are historical measurements under different input policies, not a parity claim: the harness used JIDT's default normalisation and random `1e-8` dither, whereas pyspi standardises continuous inputs and refuses tied coordinates. No convergence rate is inferred from this comparison; KSG bias depends on k, dimensionality and density smoothness (Gao, Oh & Viswanath 2018, *Demystifying fixed k-nearest neighbor information estimators*). The harness was removed with the JIDT dependency.
+
+### Fixed: state, identity, and estimator contracts
+
+A second pass, driven by a suite of red tests, closed a set of defects that produced valid-looking but wrong results. Full details in each commit; the scientifically material ones:
+
+- **Stale caches survived data mutation.** Statistics cache results directly on the `Data` instance keyed by their own parameters, never by the data. Nothing invalidated them, so `set_data`/`add_process`/`remove_process` left every cached statistic serving the *previous* dataset's numbers, silently. All 15 cache attributes are now registered and dropped on mutation, and `Data` copies and freezes its input so nothing can mutate the series behind a cache.
+
+- **Checkpoints were not bound to a run.** Resume validated only the SPI identifier and an `(M, M)` shape, so a different dataset, config, preprocessing setting, or process order silently inherited the earlier run's results. Checkpoints now carry a `run.json` manifest bound to `Calculator.run_digest` (config *contents*, dataset bytes in native dtype, and a computation-version token). Failed or non-finite checkpoints are retried by default, workers load the parent's config snapshot rather than rereading a path that may have changed, and a directory belonging to another run is refused rather than emptied.
+
+- **Spectral caches ignored `fs`,** and were written under a `str` key but read under a `tuple` key — so the first write was unreachable and staleness only appeared from the *third* call, which is why a two-call probe found nothing.
+
+- **Six measures advertised `kraskov` and ran Gaussian.** Joint/conditional/ crossmap/causal entropy, directed info and stochastic interaction are composed from marginal entropies and have no KSG estimator; the argument is now rejected. No bundled config used it, so no shipped result changed.
+
+- **Cointegration `aeg` was forcibly symmetric.** It is not symmetric in its arguments (~0.8 mean absolute difference between orientations, up to ~1.6), yet the cache wrote each value to both `(i,j)` and `(j,i)`, so the reported value depended on process order. `aeg` is now `directed`; `johansen`, which is symmetric to ~3e-14, is unchanged.
+
+- **KSG accepted inputs it cannot estimate from.** `k=30` on `N=20` returned 0.414 and `k=100` returned 1.63; negative Theiler windows were accepted. The effective-sample and window checks now guard the MI, TE and auto-embedding paths, and the auto-embedding search skips candidates it cannot support instead of ranking them and failing on the winner. Tied and quantised coordinates are explicitly rejected and directed to an external discrete estimator; pyspi has no general discrete MI/TLMI/DI plug-in estimator.
+
+- **KSG lost JIDT's normalisation and tie policy.** Every KSG-family calculator in JIDT enables `normalise` and a small random dither by default. pyspi 2.x ran with both active, while the pure-NumPy port initially implemented neither. That is not a rounding difference:
+
+ - **Ties.** Independent binary marginals (N=400, k=4) returned MI = **−3.35**; four-level, −1.96; one-decimal-rounded Gaussians, **+0.90** — a confident false positive on independent data. Every kth-nearest-neighbour radius is zero, so the digamma counts saturate on the tie structure and the estimate measures quantisation. Every process in the bundled `forex` dataset is tied: 24–212 distinct values in 250 samples.
+ - **Scale.** KSG's L∞ radius is not invariant to per-coordinate rescaling. With `zscore=False`, scaling one of a correlated Gaussian pair (true MI 0.50) by 1e−3 or 1e3 collapsed the estimate from 0.49 to 0.05 and 0.06.
+
+ Per-coordinate sample standardisation is restored for MI, TLMI, TE, CMI, AIS auto-embedding and directed information. Random dither is not: an unexposed noise draw changes a finite-sample estimate, while content-derived pseudo-random dither was found to depend on observation order. The continuous KSG estimator now requires every coordinate to be tie-free and directs quantised/discrete data to an external discrete plug-in estimator. This means every process in bundled `forex` is invalid for the `kraskov` variants. **Every `kraskov` SPI's values can change.**
+
+ Kozachenko entropy likewise keeps its explicit tied-data error: `H(X + εξ) → −∞` as `ε → 0` for discrete `X`, so a dithered differential entropy on quantised data reports the dither level.
+
+- **Symbolic TE packed symbols into an integer that overflowed** at `k_history=10` (reaching `(k!)^3`). Now counts distinct rows directly, validated against a tuple-keyed reference. `k_history=1` is rejected: a length-1 ordinal pattern has one symbol, so TE is identically zero. Those were the only two symbolic variants shipped, so **symbolic TE now has no bundled representation** — reintroducing it needs a defensible `k` with benchmark support. `k_history=10` remains constructible for long series, where the undersampling argument does not apply.
+
+- **Results tables no longer use pickle.** Names are stored as `dtype='U'` and loaded with `allow_pickle=False`; files carry a schema version, run spec, digest and errors. Tables written by pyspi < 3.0.0 will not load — re-save them from a `Calculator`.
+
+- **`load_table()` dropped everything `save()` wrote except the numbers.** The run spec, digest and error map were written and never read, so a loaded table could not be asked which SPIs failed — and a NaN column is otherwise indistinguishable from a legitimately undefined statistic. They now arrive in `DataFrame.attrs` (`schema`, `run_spec`, `run_digest`, `errors`). Validation also covered only `ndim` and the SPI axis; a file whose matrices were the wrong width reached `MultiIndex.from_product` and failed there with a reshape error rather than a statement about the file. The full `(n_spis, M, M)` shape is now checked.
+
+- **`run_digest` did not bind to the algorithm.** It hashed the run spec, the config contents and the dataset bytes, so identical inputs computed by two different estimator implementations produced the same digest and a checkpoint written by one could be resumed by the other. It now covers the computation version too.
+
+- **A successful recomputation left a stale error behind.** `compute(retry_failed=True)` on a resumed run kept the old `calc.errors` entry next to the good column it had just written, and `save()` froze that contradiction into the file.
+
+- **Process names were not validated.** Duplicates surfaced as pandas' "Columns with duplicate values are not supported in stack" from four frames away, with nothing pointing at the names; non-string names were written to the NPZ as a `U` array and came back as their `str()`, so `procnames=[1, 2]` loaded as `["1", "2"]` and the file did not round-trip. Names are now coerced to `str` and required to be unique, on the internal constructor the parallel workers use as well as the public one.
+
+- **`DirectedInfo` did not implement directed information.** It summed `H(Y^i)/i` minus causal entropy, so with a source statistically independent of the target it returned 0.007 at target autocorrelation 0 and 1.53 at 0.95 -- it measured target self-predictability. It now implements Massey's `sum_i [H(Y_i|Y^{i-1}) - H(Y_i|Y^{i-1},X^i)]`, validated against the closed form `0.5*ln(1+c^2)`.
+
+ The kernel and kozachenko variants are dropped. Composing DI from four separately-estimated entropies leaves each with its own dimension-dependent bias, and those do not cancel: on independent data kernel sat at 3.8-4.4 for every `T` from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not improve with sample size), and kozachenko returned negatives. In their place `di_kraskov` estimates each `I(X^i; Y_i | Y^{i-1})` term *directly* with the KSG/Frenzel-Pompe conditional-MI estimator, which fixes one neighbour radius in the joint space and reuses it across marginals so the biases cancel by construction. It matches the closed form as closely as the Gaussian variant. `n` now reaches the identifier for `DirectedInfo` and `CausalEntropy`.
+
+- **Wavelet phase-slope index lost its direction.** `mne_connectivity` returns a lower-triangular matrix and pyspi filled the upper triangle *without* negating, so `psi[i,j] == psi[j,i]` — the sign is PSI's entire lead/lag content. The fill must also happen per frequency, *before* the band statistic: only a statistic commuting with negation may be applied first, and `max_f(-v) = -min_f(v)`, not `-max_f(v)`. `mean` is antisymmetric, `max` asymmetric. `fmin=0` also asked for an unbounded period, giving an ~11.1-million-sample Morlet wavelet at `T=100`; `fmin` is now resolved against the data-supported floor *and* the cycle count capped so the wavelet always fits the signal.
+
+- **Kozachenko entropy returned `-inf` on tied data.** A duplicated observation puts a nearest neighbour at distance zero, and `log(0)` sends the estimate to `-inf`. Quantised series do this readily -- the bundled `forex` dataset has a process with 24 distinct values in 250 samples -- so several kozachenko SPIs silently produced infinities there. They now fail with the cause named.
+
+- **Conditional mutual information did not reduce to MI on an empty conditioning set.** `_ksg_cmi` faked the conditioning count as a constant `N-(2w+1)`, which coincides with the MI estimator only at `w=0` and drifted with the Theiler window (0.005 at `w=1`, 0.051 at `w=10`). It now delegates to the MI estimator. Reachable only from `DirectedInfo`'s first term, whose history is empty; transfer entropy always has at least one history column, so it never took this branch. No bundled SPI is affected — `di_kraskov` ships without a Theiler window — but a hand-configured `dyn_corr_excl` would have hit it.
+
+- **`ConditionalEntropy` is `directed`.** On `var1_M3_T100`, `max|A - Aᵀ|` is 0.115 (kozachenko) and 0.039 (kernel) under z-scoring, and the Gaussian form is asymmetric (0.73) with `zscore=False`. Structural labels describe the measure rather than one estimator under one preprocessing choice.
+
+- **Importing pyspi reseeded NumPy's global RNG.** `pyspi.lib.ids` called `np.random.seed(1717)` at import, silently overriding the caller's seed -- stochastic SPIs looked reproducible but ignored it. Removed.
+
+- **`filter_spis` matched raw YAML family labels**, so per-variant traits set in `__init__` were invisible (`filter_spis(["antisymmetric"])` returned nothing despite 18 matching SPIs) and a matching family selected all of its configs. It now resolves each config and matches on the labels the SPI actually carries.
+
+- **Gaussian joint/conditional entropy used two different regularisations.** The vectorised multivariate path clipped `r^2` while the scalar path applied a ridge, so `bivariate()` and `multivariate()` disagreed by 8.4 nats on singular data. Both now share one primitive.
+
+New: `Calculator.errors`, `Calculator.run_spec`, `Calculator.run_digest`, `Calculator.to_frame()` (long-form results, one row per `(spi, source, target)`) and `Calculator.summary()`; an `antisymmetric` structural label for measures satisfying `A[i,j] == -A[j,i]`; and a useful `repr` — a computed `Calculator` previously displayed as ``.
+
+
+Three group-delay SPIs that shipped as silent all-NaN columns are now recorded failures (values unchanged).
+
+### Fixed
+
+- **Directed spectral SPIs were transposed.** `SpectralGrangerCausality` (both methods), `DirectedCoherence`, `PartialDirectedCoherence`, `GeneralizedPartialDirectedCoherence`, `DirectedTransferFunction` and `DirectDirectedTransferFunction` reported `A[i, j]` as the influence *of j on i*, the opposite of every other directed SPI in the library. The spectral backends follow the DTF/PDC literature convention; their output was passed through unchanged. pyspi's convention is **row = source, column = target**, as set by `base.Directed.multivariate`, and all directed SPIs now follow it. **Results computed with 2.x for these six SPIs need transposing.** Undirected spectral SPIs are unaffected and bit-identical.
+- **`MutualInfo`, `TimeLaggedMutualInfo` and `TransferEntropy` silently returned NaN** when given `estimator="kozachenko"`; there is no Kozachenko-Leonenko path for these measures. They now raise `NotImplementedError` at construction. Use `estimator="kraskov"` instead.
+- **`ccm_E-None_*` never inferred an embedding.** The auto-embedding path read the winning dimension as `pyEDM.EmbedDimension(...).max()["E"]`. `DataFrame.max()` reduces column-wise, so that is the largest *candidate* E — pyEDM's `maxE` default of 10 — for every process on every dataset, whatever the skill curve says. The three shipped `ccm_E-None_{mean,max,diff}` SPIs were therefore **bit-identical to `ccm_E-10_*` on all three frozen fixtures** (verified: max|difference| exactly 0) while their identifiers advertised an inferred embedding. Selection is now `argmax(rho)`, ties to the smaller E; on the fixtures it picks E ∈ {1, 2, 5, 10} depending on the process. **`ccm_E-None_*` values change** (3 SPIs); `ccm_E-1_*` and `ccm_E-10_*` are unaffected.
+- **`ConvergentCrossMapping` broke whenever pyspi was driven from an unguarded script.** pyEDM 2.5's `_get_mp_context` documents that *"fork is never used"* — it takes forkserver, else spawn — and both re-import the caller's `__main__` in every child. Run from a plain `python analysis.py` with no `if __name__ == "__main__":` guard, which is how the README shows pyspi being used, each child re-executed the caller's script; the parent raised `RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase`, pyspi caught it, and all nine `ccm_*` SPIs came back as an **all-NaN column** — after the child had already re-run whatever preceded `compute()`. It looked fine from a REPL, a notebook, or a guarded script, which is why the baseline generator and `python -m pyspi` never saw it. pyEDM's nested pools are now off unconditionally: `parallel=False` for `CCM`, and `EmbedDimension` (which has no serial path and starts a child even at `numProcess=1`) replaced by a serial loop over the public `pyEDM.Simplex`, matching its per-E skill exactly. There is no speed cost — measured on an idle machine, `kuramoto_M7_T100`, 21 pairs at E=1, `parallel=True` took 24.8s against 6.8s serial, a **3.6× speedup** from switching it off. Parallelism belongs at the SPI level, where `compute(n_jobs=...)` already provides it.
+- **A failed spectral factorisation was reported to nobody.** Wilson's algorithm is iterative; on hitting its iteration cap it logs `"Maximum iterations reached. N of M converged"` through `logging` and returns the unconverged factor anyway. Every Wilson-derived measure (`dcoh`, `dtf`, `ddtf`, `pdcoh`, `gpdcoh`, nonparametric `sgc`) is built from that factor. pyspi collects per-SPI diagnostics from the `warnings` channel only, so those numbers reached the results table with nothing recorded against them — including on the bundled `kuramoto_M7_T100` fixture, where 2 of 21 pairs fail to converge and the relative factorisation residual `max|S - GGᴴ| / max|S|` runs 0.14–6.0 across pairs. The backend's log warnings are now bridged into the `warnings` channel for the duration of each backend call, so they land in the per-SPI record. No value changes; the estimate is the user's to improve (longer series, a parametric fit), but it is no longer silent.
+- **`python -m pyspi compute --quiet` reported success over a failed run.** Failed SPIs were named only in the computation summary, which `--quiet` suppresses, and the exit status was 0 unconditionally. Failures and empty (all-NaN) columns are now always reported on stderr, and the exit status is **1 whenever any SPI failed** — a results table with failed columns is one an automated pipeline must not ingest silently, and a NaN column is indistinguishable from a legitimately undefined statistic once the process has exited. `--allow-partial` opts back into exiting 0; the partial table is written either way, and a run in which *no* SPI produced a finite value exits 1 regardless.
+- **All three `gd_*` (group delay) SPIs returned no finite value, on any input.** Not a power problem: `spectral_connectivity.statistics.coherence_fisher_z_transform` divides by `sqrt(coherence_bias(n_obs1) + coherence_bias(n_obs2))`, and the one-sample call passes `n_obs2 = 0`, for which `coherence_bias` returns `1/(2·0 − 2) = −0.5`. The radicand is negative for every `n_obs1`, so every p-value is NaN, nothing is ever significant, and the phase regression runs on a fully masked array. Reproduced at 5, 11, 19 and 39 tapers, T up to 4000, on a pair with median coherence 0.998. pyspi now computes the statistic itself with the standard one-sample form `(arctanh|C| − b)/sqrt(b)`, `b = 1/(2n − 2)` (Enochson & Goodman 1965; Bokil et al. 2007), then Benjamini–Hochberg over the band, the largest contiguous significant run thinned to independent points, and a least-squares fit of the unwrapped coherence phase against frequency. On `y(t) = x(t − L)` it recovers L to within 0.005 samples for L ∈ {1, 3, 5, 8}. Partial NaN remains correct — group delay is defined only where the coherence is significant. **`gd_*` values change** (3 SPIs, from nothing to something).
+
+- **`MAX_CORR_AIS` searched the destination embedding only.** It selected `(k, k_tau)` from the destination and hard-coded the source to `(1, 1)`, while the method name denotes selection for both. It now selects `(l, l_tau)` from the source independently, by the same criterion and the same estimator, and passes all four to the estimator; `MAX_CORR_AIS_DEST_ONLY` is the previous behaviour under a name that describes it, and does accept a fixed source embedding. The three shipped auto-embedding SPIs are renamed to carry the method and change value. Auto-embedding and the search bounds are now **refused** by the kernel and symbolic estimators, which have no active-information-storage criterion and used to accept the argument and run a fixed embedding; a fixed embedding that the selected method would override is refused rather than ignored; and search bounds are refused when no auto method is set. Descriptions conflating this with Ragwitz local-prediction selection are corrected — that criterion is not implemented.
+
+- **KSG's deterministic dither was observation-order dependent and introduced an arbitrary 12-decimal boundary.** On independently generated binary marginals at N=120/seed=0, jointly permuting aligned observations at Theiler window zero moved MI from **0.000276 to 0.048716**; complete reversal with `w=3` moved it from **0.032949 to 0.068347**, even though all values and `|i-j|` exclusions were unchanged. A four-level fixture moved by **0.047032** under permutation, and CMI moved as well. Separately, the affine-equivalent binary coordinate `x` versus `0.1*x + 0.3` straddled the 12-decimal rounding boundary and moved its paired MI from **0.030391 to −0.002863**. No deterministic procedure can assign distinct pseudo-random values to otherwise indistinguishable tied observations while remaining covariant under every observation permutation. The dither, digest keys and rounding are therefore removed: continuous KSG now rejects any coordinate with repeated values and directs the caller to an external discrete estimator. Valid tie-free inputs are standardised without rounding or random state and are sample-order, process-order, reflection and nonzero-affine covariant to floating-point tolerance. No finite-sample invariance under arbitrary nonlinear transforms is claimed.
+
+- **KSG's strict marginal counts used a relative tolerance instead of `< ε`.** Every scalar MI, general MI and CMI branch queried at `ε*(1−1e−10)`. On tie-free N=8/k=1 data with `y=x+1e−10·noise`, this discarded genuine interior neighbours and returned **1.7178571429** instead of the exact all-pairs KSG1 value **1.5928571429**. Theiler-window paths had the same defect. Radius queries now use `nextafter(ε, −∞)`, the immediately smaller representable radius, and match independent quadratic-time references. `COMPUTATION_VERSION` is bumped to `3.0.0.r6`. All ten frozen KSG arrays were compared individually and remain bit-exact; their fixture geometries do not hit the corrected boundary.
+
+- **`CorrelationFrame` reported invalid inferential p-values.** It correlated SPI edge vectors but used each source time series' length `T` as the F-test sample size. The observations are edges, not time samples, and substituting the edge count is still invalid because edges sharing a node are dependent. `get_pvalues()`, `compute_significant_values()` and `get_average_correlation(remove_insig=True)` now raise with that explanation and direct users to an independently validated network-preserving permutation/QAP analysis.
+
+- **Coherence phase used linear statistics on wrapped angles.** The bundled spectral mean violated its antisymmetric label by **0.2463994238** on `var1_M3_T100`; its full-band maximum was exactly π for every off-diagonal entry, a branch-cut artefact rather than a useful pairwise summary. Spectral and nonbundled wavelet phase now use the circular mean `arg(mean(exp(i·phase)))` and construct the opposite orientation explicitly by negation. A resultant indistinguishable from zero under a count-scaled `8·eps·n` floating-point bound returns NaN, as does an indistinguishably antipodal mean: ±π is one valid circular location but has no unique signed ordinary-float orientation. Both classes refuse `statistic="max"`; the three `phase_multitaper_max_*` identifiers are removed from every bundled config, while the three mean identifiers remain and change value. `COMPUTATION_VERSION` is `3.0.0.r7`, so no r6 checkpoint can be reused for the final phase policy.
+
+- **Group delay was reported in seconds, and its `rvalue` flipped under process permutation.** `C.frequencies` is in Hz, so `slope/(2π)` is a delay in seconds: a true 4-sample lag came back as 4.0, 2.0 and 1.0 at fs = 1, 2, 4 while the API and every other lagged SPI count samples. The delay is scaled by `fs` (a no-op at the shipped fs=1). `rvalue` stored the *signed* regression r symmetrically, but the fit is of the phase of `C_ij` and `phase(C_ji) = −phase(C_ij)`, so reversing the process order turned +0.99997 into −0.99997 at the mirrored position; it is now `|r|`, and that variant is labelled undirected and unsigned.
+
+- **The KSG auto-embedding boundary overcounted by one.** The scorer used `N_eff = T − (dim−1)·delay`, but `_ksg_ais` aligns a one-step-ahead future against the embedding and so spends a sample on the shift as well: the aligned arrays have `T − 1 − (dim−1)·delay` rows. At T=5, kNN=4, dimension 1 the guard passed on a claimed N=5 while the real N is 4 — and `_ksg_mi_general` had no guard of its own, so the invalid candidate scored a finite **0.0**. The scorer now uses the aligned count, `_ksg_mi_general` validates the arrays it is given, and the search **raises** when no candidate is scorable rather than falling back to (1, 1) — an embedding it had just rejected. No shipped value moves: at T=100 the smallest aligned N is 63 against k=4.
+
+- **IGCI was misnamed and mislabelled.** It is Information-Geometric Causal *Inference* and tests no conditional independence; `InformationGeometricCausalInference` is the accurate name, with the old one kept as a deprecated alias. Its score is a difference of two entropies and therefore exactly antisymmetric, so reporting it `Unsigned` let `Calculator._rmmin` shift both orientations equally and destroy the sign. It stays disabled in the bundled configs. `AdditiveNoiseModel` was labelled `linear` while fitting a Gaussian process — now `nonlinear`.
+
+- **Structural traits now win over every competing config-declared trait.** The rule removed only `directed`/`undirected`, so an intrinsic `undirected` SPI plus YAML `asymmetric` retained both. The instance's one trait now displaces all of `directed`, `undirected`, `antisymmetric` and `asymmetric`; custom-YAML conflicts are tested in all four directions.
+
+- **Integer parameters no longer advertise values they do not compute.** `ConvergentCrossMapping(embedding_dimension=2.7)` advertised `E-2.7` and later executed `E=2`; lagged-correlation config expansion similarly truncated fractional `max_tau` and accepted booleans/numeric strings. Both now require genuine integers and canonicalise NumPy integer scalars before identifiers and expansion.
+
+- **`run_digest` hashed the config's absolute path.** Identical config contents over identical data therefore digested differently in a source checkout, an installed wheel and a temporary directory, so a reusable checkpoint was refused. Location-only fields are excluded from the digest and stay in `run_spec` as provenance; the config *contents* are hashed. A false negative rather than unsafe reuse, but it defeated the point of a content hash.
+
+- **`Data.add_process()` could create a duplicate process name.** It appended `proc-` unconditionally, so a caller who named their processes `["a", "proc-2"]` and appended twice produced a second `proc-2` — which `to_frame()` cannot stack, and which the constructor rejects.
+
+- **Antisymmetric SPIs reported themselves as unsigned.** `issigned()` is not metadata: `Calculator._rmmin` subtracts the minimum from every SPI reporting unsigned — which on an antisymmetric matrix shifts `A[i,j]` and `A[j,i]` equally and destroys the lead/lag its sign carries — and `set_group` correlates unsigned SPIs through `abs()`. `phase`, `pli`, `wpli`, `psi` (multitaper and wavelet), `gd` and `ccm_*_diff` all declared `unsigned` over antisymmetric output. `coint_aeg_tstat` likewise returns a signed Engle–Granger t-statistic (more negative is stronger evidence; positive means none). All are now signed, and the merged label follows `issigned()` so a config cannot reintroduce the contradiction. `CrossPairwiseDistance` had no `issigned` at all, so `_rmmin()` raised `AttributeError` on any config containing it.
+
+- **Cross-correlation was wrong four ways.** `correlate(x, y) / x.std() / y.std() / (T − 1)` is neither the biased (÷T) nor the unbiased (÷(T−|l|)) normalisation and mixed `std()`'s 1/T with a 1/(T−1) divisor, so a series against itself returned T/(T−1) — exactly **1.1111** at T = 10, for a quantity bounded by 1. The correlate call used the *raw* series while the divisor demeaned, giving **3.8384** for `arange(10)` against itself with `zscore=False`. The lag window was centred on index T rather than T−1, i.e. on lag +1, and the opposite orientation was cached unreversed although r_yx(l) = r_xy(−l) — both breaking the symmetry an *undirected* SPI must have. The significance band was `1.96/sqrt(len(r)//2)`, the half-width of the lag window, so it was twice too wide and scaled with the lag cut rather than the sample size; and the truncation walked a contiguous run outwards from lag 0, which on a pair where i leads j by one sample gave 0.9957 one way and −0.0202 the other. Now: biased normalisation (zero lag = Pearson's r), demeaned, a symmetric window, a `1.96/sqrt(T)` amplitude cut, and `sigonly` reducing over the lags above the cut — a set, hence invariant under l → −l — returning 0 when none clears it, rather than the largest of ~T/2 sample correlations under the null. **`xcorr_*` values change** (6 SPIs). `sigonly` is documented as what it is: a pointwise amplitude threshold `|r(l)| > 1.96/sqrt(T)`, kept under a historical name. It is *not* a significance test — the band is not inflated for the series' own autocorrelation and is not corrected for being applied at every lag, so under the null it keeps something almost surely (no seed in 400 gave an empty set at T=600).
+
+- **Spectral Granger causality ignored `fs` and mis-oriented its NaN mask.** The parametric branch built `TimeSeries(..., sampling_interval=1)` unconditionally although `fs` is in the identifier *and* the cache key, so two SPIs advertising different sampling rates computed the same numbers. And the result was transposed into pyspi's (source, target) orientation while the NaN mask was left in the backend's, so with a directionally asymmetric NaN pattern the genuinely unestimable cell was already NaN and the mask blanked its mirror — a good estimate destroyed. Both fixed; no value change at the shipped `fs=1`.
+
+- **Twelve `sgc_*` identifiers named a frequency band they did not use.** Spectral GC is undefined at zero frequency, so `fmin=0` is overridden to 1e-5 — but the identifier was built from the argument. **`sgc_*_fmin-0_*` is renamed to `sgc_*_fmin-1e-05_*`**; values unchanged.
+
+- **Symbolic and kernel transfer entropy accepted embedding parameters they ignore.** `SymbolicTECalculator` reads only `k_HISTORY` and applies that one ordinal-pattern length to source and destination alike at unit delay — which is how Staniek & Lehnertz (2008) define it — yet `k_tau`, `l_history` and `l_tau` were accepted, stored, never read, and written into the identifier: `te_symbolic_k-3_kt-1_l-1_lt-1` advertised a destination history of 3 against a source history of 1 while computing 3 for both. The kernel calculator has the same contract and accepted them just as silently. They are now refused, symbolic identifiers become `te_symbolic_k-`, and non-positive histories and delays are rejected at construction. Implementing separately aligned histories was the alternative and is the wrong call here: there is no oracle left to validate `k != l` against, and every symbolic variant is already commented out of every shipped config.
+
+- **`dyn_corr_excl` named three different Theiler windows with one identifier.** The suffix was a bare `_DCE`, so 5, 10 and `"AUTO"` collided; a config setting two of them produced two identical identifiers. Now `_DCE-`, which reaches `_getkey()` as well. **Shipped SPIs are renamed `..._DCE` → `..._DCE-AUTO`**; values unchanged.
+
+- **Itakura-constrained DTW normalised inconsistently.** The `itakura` branch of `multivariate` skipped the `sqrt(T)` division that both the bivariate path and the dtaidistance path apply, so the two disagreed by `sqrt(T)` under `normalise=True`. Not shipped in any config.
+
+- **Kozachenko SPIs were labelled `linear`.** They are k-nearest-neighbour estimators and are now labelled `nonlinear`, so `filter_spis(["linear"])` no longer returns them.
+- **`Data(procnames=...)` was silently discarded.** The length was validated and then never assigned, so custom process names never reached the results table.
+- **`CalculatorFrame.compute()` accepted no arguments**, raising `TypeError` for any keyword. It now forwards to `Calculator.compute`.
+- The default config, and every bundled config, was **missing from built wheels** — an installed pyspi could not construct a `Calculator`. Package data now uses globs.
+- `pyspi/lib/ids/LICENSE.txt` was not shipped, despite MIT requiring it.
+- `LICENSE.txt` had been corrupted by a global find-and-replace, altering the verbatim GPLv3 text ("technological *statistics*"). Restored.
+
+### Changed
+
+- **`Calculator(subset=..., configfile=...)` collapsed into `config=`**, which accepts either a bundled name or a path to your own YAML.
+- **`normalise=` renamed to `zscore=`** on `Calculator` and `Data`. Behaviour is unchanged (per-process z-score along time); the old name collided with `utils.normalise`, which was min-max, and with the per-SPI `normalise` arguments in `statistics/distance.py`.
+- **Configs renamed and moved to `pyspi/configs/`.** The filename stem is now the lookup key, so the cost-pruned sets are reachable by name for the first time.
+- `load_dataset()` exposes only the three demo datasets (`forex`, `cml`, `standard_normal`); `available_datasets()` lists them. The regression fixtures moved to `tests/` and no longer ship in the wheel.
+- Per-SPI timings are printed after `compute()` (total and slowest five). `calc.timings` was always populated but never surfaced.
+- **New `Calculator.save()` and `pyspi.load_table()`.** Results had no documented persistence path from the Python API at all -- only the CLI wrote files. `.npz` is now the canonical format: it stores the results in their natural `(n_spis, M, M)` shape plus names, round-trips exactly, and needs nothing beyond numpy. `.csv` remains as a one-way human-readable export.
+- **Dropped pickle and parquet output.** Pickle is version-fragile and executes arbitrary code on load, which is wrong for an archival scientific artifact. Parquet is columnar and built for heterogeneous tabular data; for a dense float tensor it bought nothing over `.npz` while costing a ~40 MB pyarrow dependency. The `parquet` extra is gone.
+- A config that keeps only part of a shared-cache group now warns, since the cache is built regardless and the remaining members are close to free. Only applies to user-written configs and to caches expensive enough to matter.
+- **Every information-theoretic measure now reports nats.** The kernel and symbolic calculators reported bits while the Gaussian, KSG and Kozachenko ones reported nats — JIDT's split between base 2 for its box-kernel/discrete estimators and base e for the rest, carried into a single results table. `mi_kernel_W-0-5` and `mi_gaussian` therefore sat on axes differing by a factor of ln 2 with nothing in either identifier to say so, so any comparison of *magnitudes* across that boundary — a shared threshold, a "which SPI found the most information" ranking across estimators, a difference or ratio of two columns — was off by that factor. (Pearson and Spearman correlations *between* columns are invariant to a positive rescaling and were never affected; the earlier note overstated this.) Divide by ln 2 for the JIDT-comparable value. **All `kernel` and `symbolic` SPI values change by that factor.**
+- **One singularity policy for every Gaussian quantity.** Gaussian MI clipped `r²` at `1 − 1e-15` while the entropy path ridged the covariance: on a pair of identical N=100 series the direct MI was **17.2698** nats and the same quantity assembled from entropies **8.8638**. Each variable is now treated as observed with independent noise of variance `1e-8 × its own variance`, expressed as one shared log-determinant primitive and its closed form, and used by MI, TLMI, entropy, joint/conditional entropy, TE and AIS alike. Both readings give 8.8638, the chain rule closes to 2e-16 on non-degenerate data, and the ridge is equivariant to per-variable rescaling rather than keyed to the loudest process. Gaussian MI acquires a bias of `ridge·r²/(1−r²)` — 5.6e-9 at ρ=0.6.
+- **Parallel scheduling buckets on the cache SPIs actually share.** `build_tasks` grouped by `_cache_namespace` alone, so SPIs sharing no cache were serialised into one task: on `full` that produced a single 84-member `spectral_mv` task spanning 16 independent caches, and the longest task bounds the makespan. There is now one `cache_bucket()` definition, shared with the config advisory and the benchmark tooling; `full` goes from 149 tasks with a largest of 84 to 186 with a largest of 24, ordered by measured amortized cost rather than member count.
+- `JIDTBase` renamed to `InfoTheoryBase`. Config files are unaffected.
+
+### Dependencies
+
+- **Requires Python 3.10+.**
+- Dropped five unused runtime dependencies: `h5py`, `seaborn`, `plotly`, `matplotlib`, `nbformat`. Plotting and notebook packages moved to a `bench` extra.
+- Dropped the `setuptools>=68,<80` pin. It existed because pyEDM imported `pkg_resources`; pyEDM 2.5 no longer does, so the floor is now `pyEDM>=2.5` and modern setuptools is usable.
+- `pandas>=2.1` for `DataFrame.stack(future_stack=True)`, the pandas 3 semantics.
+- **`spectral-connectivity` is now upper-bounded: `>=1.1,<3`.** `DirectedCoherence` reads two *private* `Connectivity` properties (`_transfer_function`, `_noise_covariance`) because the public `directed_coherence()` is wrong twice over (above), so an open-ended floor was a promise pyspi cannot keep. The range is verified end to end against the oldest published 1.1 (1.1.0) and the locked current release: both expose every symbol pyspi uses. 1.1.x additionally lacks `transforms.prepare_time_series` (there is a fallback) and returns a 400- rather than 401-point frequency grid, so band statistics differ marginally across the supported range. `tests/test_directionality.py` fails loudly if a private property disappears, rather than the measure silently changing meaning.
+- **`cdt` and `torch` are gone.** pyspi used four functions from `cdt.causality.pairwise` — the ANM independence score, the conditional distribution similarity statistic, the RECI regression-error score and IGCI — now transcribed in `pyspi/lib/pairwise_causal.py` on NumPy/SciPy/scikit-learn, with cdt's MIT licence retained at `pyspi/lib/LICENSE-cdt.txt`. **No value changes**: verified bit-identical against cdt 0.6 over 60 mixed random pairs, with five frozen into `tests/data/fixtures/cdt_pairwise_reference.npz` so the evidence outlives the dependency. torch drove no pyspi computation — `InterDependenceScore` is NumPy — and entered only because cdt eagerly imports its Torch-backed models at package load. Measured: installed size **1.1 GB → 567 MB**, and the 1.5–1.7 s `import cdt.causality.pairwise` (paid once per worker under `compute(n_jobs=…)`) is gone. Per-call runtime is unchanged; the win is install size and start-up, not throughput.
+- Build requires `setuptools>=77`, matching the PEP 639 licence metadata already in use; older setuptools would build a wheel with no licence metadata.
+- New extra: `bench`. `testing` is unchanged.
+
+### Testing
+
+- The test suite previously **did not run at all** — collection aborted on an undeclared `dill` dependency. Fixed.
+- New `tests/test_infotheory_analytic.py`: closed-form checks against `-0.5*ln(1-rho^2)`, `0.5*ln(2*pi*e*sigma^2)`, analytic Gaussian TE on a known AR(1), independence, and information-theoretic identities. Previously the suite contained three assertions comparing a computed value to an independently-known one, and five estimator classes were constructed but never computed.
+- New `tests/test_directionality.py` pins the row=source convention for every directed SPI family.
+- **Drift tolerances are per-SPI, not per-module.** The suite applied a 1e-2 relative band to every SPI in `causal` and `misc` on the assumption that cdt's optimisers, GP restarts and randomised independence tests made them irreproducible. Measured, that is false: computing the full config twice per fixture under the suite's own protocol reproduces **322 of 322 SPIs bit-exactly** on all three fixtures — comparing the non-finite masks as well as the finite values, so an SPI whose NaN pattern moved between the two runs could not be scored as identical — including every `anm`/`cds`/`reci`/`ccm`, every `coint_*`, `gpfit_*` (`GaussianProcessRegressor` defaults to `n_restarts_optimizer=0`, so there are no random restarts), `lmfit_*` (`random_state` pinned) and `ids`. A module-wide band over 62 SPIs, ~50 of them deterministic, is slack wide enough to hide the regressions this suite exists to catch. The map of loosened SPIs is now keyed by identifier and is empty; `tests/tools/measure_reproducibility.py` regenerates the evidence.
+- New CLI exit-status tests, and a test that the spectral factorisation's non-convergence warning is not swallowed.
+- **The drift suite reported violations and never failed.** Tolerance breaches went to a session-end banner, which made every tolerance in the file decorative: a deterministic SPI could move by any amount and the run still exited 0. It also returned early when a baseline had no finite entries, so an SPI frozen as an all-NaN column agreed with itself forever — which is how three `gd_*` SPIs stayed broken. Violations now fail, an empty baseline is rejected, and two new gates require that no SPI raises and that none produces an entirely non-finite column. The generator refuses to freeze a run with either condition. One documented exception, shared between generator and suite as `KNOWN_UNESTIMABLE`: on `kuramoto_M7_T100` nitime's automatic AR order search never turns over below `max_order=50`, because at 100 observations of a smooth oscillatory process BIC keeps improving with lag — the fixed-order variants are estimated normally on the same fixture and the automatic variant on the other two. The suite fails if a listed exception starts succeeding.
+- `test_whether_calculator_computes` ran the default config and asserted nothing; it now requires an empty `calc.errors` and a finite value in every column, on a coupled VAR(1) rather than the particular white-noise fixture on which `gd_*` was observed to be empty.
+- New `test_cache_sharing_never_changes_a_value`: computing the whole config against one Data, where every cache is shared, must equal computing each SPI against its own. This is the automatic coverage check for cache-key omissions — a per-SPI "different identifier implies different cache key" rule is the wrong invariant, since several classes cache a shared intermediate on purpose and apply the differing parameters after the lookup.
+- New CI job builds the wheel, installs it into a clean environment, and from a directory containing no pyspi source resolves every bundled config and dataset, computes `fabfour`, and round-trips the NPZ through the CLI. A second job pins to the committed `uv.lock` alongside the floating matrix. The slow regression workflow also runs on pull requests, because a new workflow cannot be manually dispatched until it exists on the default branch.
+- Frozen baselines regenerated from this fork as `.npz` (they were upstream 2.0.1 pickles, the wrong oracle for deliberately-changed estimators), and the drift suite now fails hard on a NaN-pattern change or a baseline/current SPI set mismatch, with tolerances split by estimator family.
+
+### References
+
+Definitions the corrected measures are checked against:
+
+- Massey, J. (1990). Causality, feedback and directed information. *Proc. ISITA*. — the `sum_i I(X^i; Y_i | Y^{i-1})` form now implemented by `DirectedInfo`.
+- Frenzel, S. & Pompe, B. (2007). Partial mutual information for coupling analysis of multivariate time series. *Phys. Rev. Lett.* 99, 204101. — the conditional-MI estimator behind `di_kraskov` and kraskov transfer entropy.
+- Kraskov, A., Stögbauer, H. & Grassberger, P. (2004). Estimating mutual information. *Phys. Rev. E* 69, 066138. — KSG estimator and its effective-sample conditions.
+- Kozachenko, L. & Leonenko, N. (1987). Sample estimate of the entropy of a random vector. *Probl. Inf. Transm.* 23, 95–101. — the k-NN entropy that is undefined on tied data.
+- Baccalá, L., Sameshima, K., Ballester, G., Do Valle, A. & Timo-Iaria, C. (1998). Studying the interaction between brain structures via directed coherence and Granger causality. *Appl. Sig. Process.* 5, 40–48. — `DC_ij = sqrt(σ_jj)|H_ij| / sqrt(Σ_k σ_kk|H_ik|²)`, the bounded form now computed.
+- Kamiński, M. & Blinowska, K. (1991). A new method of the description of the information flow in the brain structures. *Biol. Cybern.* 65, 203–210. — DTF, and the `[target, source]` convention that pyspi transposes to `row = source`.
+- Enochson, L. & Goodman, N. (1965). *Gaussian approximations to the distribution of sample coherence.* — the one-sample coherence significance test the group-delay backend gets wrong.
+- Bokil, H., Purpura, K., Schoffelen, J.-M., Thomson, D. & Mitra, P. (2007). Comparing spectra and coherences for groups of unequal size. *J. Neurosci. Methods* 159, 337–345. — the same z-transform, with its bias and variance both `1/(2n − 2)`.
+- Gotman, J. (1983). Measurement of small time differences between EEG channels. *Electroencephalogr. Clin. Neurophysiol.* 56, 501–514. — group delay as the slope of coherence phase against frequency.
+- Staniek, M. & Lehnertz, K. (2008). Symbolic transfer entropy. *Phys. Rev. Lett.* 100, 158101. — one ordinal-pattern length at unit delay, source and destination alike.
+- Hoyer, P., Janzing, D., Mooij, J., Peters, J. & Schölkopf, B. (2009). Nonlinear causal discovery with additive noise models. *NIPS*. — `anm`.
+- Fonollosa, J. A. R. (2016). Conditional distribution variability measures for causality detection. — `cds`.
+- Blöbaum, P., Janzing, D., Washio, T., Shimizu, S. & Schölkopf, B. (2018). Cause-effect inference by comparing regression errors. *AISTATS*. — `reci`.
+- Daniušis, P. et al. (2010). Inferring deterministic causal relations. *UAI*. — `igci`.
+- Wibral, M., Vicente, R. & Lindner, M. (2014). Transfer entropy in neuroscience. — the maximum corrected AIS embedding criterion, as distinct from Ragwitz local-prediction selection (Ragwitz & Kantz 2002).
+- Gao, W., Oh, S. & Viswanath, P. (2018). Demystifying fixed k-nearest neighbor information estimators. *IEEE Trans. Inf. Theory* 64, 5629–5661. — why the KSG error is not O(1/√N) in general.
+- Lizier, J. (2014). JIDT: an information-theoretic toolkit. *Front. Robot. AI* 1, 11. — the reference implementation the NumPy port was validated against.
+
+### Migrating from 2.x
+
+| 2.x | 3.0 |
+|:----|:----|
+| `Calculator(subset="all")` | `Calculator(config="full")` |
+| `Calculator(subset="fast")` | `Calculator(config="fast")` |
+| `Calculator(configfile="my.yaml")` | `Calculator(config="my.yaml")` |
+| `Calculator(normalise=False)` | `Calculator(zscore=False)` |
+| `Data(..., normalise=False)` | `Data(..., zscore=False)` |
+| `pyspi/config.yaml` | `pyspi/configs/full.yaml` |
+| `pyspi/fast_config.yaml` | `pyspi/configs/fast.yaml` |
+| `pyspi/sonnet_config.yaml` | `pyspi/configs/sonnet.yaml` |
+| `pyspi/fabfour_config.yaml` | `pyspi/configs/fabfour.yaml` |
+| `pyspi/benchmarked90_amortized_config.yaml` | `pyspi/configs/benchmarked_p90.yaml` |
+| `load_dataset("cml7" \| "var1" \| "kuramoto")` | removed (test fixtures) |
+| `utils.normalise` | removed (min-max; use `scipy.stats.zscore`) |
+| `utils.standardise`, `utils.strshort` | removed (unused) |
+| `utils.check_optional_deps` | removed (no optional runtime deps remain) |
+| `JIDTBase` | `InfoTheoryBase` |
+| `MutualInfo(estimator="kozachenko")` | raises; use `estimator="kraskov"` |
+| `phase_multitaper_max_*` | removed; `CoherencePhase(statistic="max")` raises because ordinary maxima of wrapped angles are branch-cut dependent |
+
+### SPI set changes
+
+`full` goes from **328 SPIs to 322**. Every change below is deliberate; nothing else moved on the frozen test fixtures.
+
+The unsupported coherence-phase maxima are removed from shipped configs. Other disabled variants remain commented with the evidence for switching them off and the condition that would justify switching them back on.
+
+**Flagged, not removed.** `dspli_multitaper_max_*` and `dswpli_multitaper_max_*` saturate: the band maximum reaches exactly 1 as soon as the sign of the imaginary coherency is consistent across tapers at any *one* frequency. On the frozen fixtures the share of pairs at exactly 1 runs **29-100%** depending on the data, with 1 to 16 distinct values; the `mean` variants are graded normally. This is empirical, not a law - saturation is **not** monotone in `T`, since changing `T` recomputes the tapers and Fourier coefficients rather than adding to them (measured non-monotone in 7 of 36 seed/band combinations). They stay **enabled**; the statistic is behaving as defined.
+
+**Directed coherence corrected (twice).** `spectral_connectivity.directed_coherence` is wrong in two independent ways.
+
+1. It puts `|H|²` in the numerator while its denominator stays on the magnitude scale, making the ratio unbounded: baselines reached 3.27 (VAR), 1.84 (CML) and **1139.47** (Kuramoto).
+2. Its `_get_noise_variance` reshapes `diag(Σ)` to `(…, 1, n, 1)`, which broadcasts the innovation variance along the **row** (target) axis of `H`. Baccalá's weight is indexed by the **source**. A row-indexed weight is constant across the summation index, so it factors out of numerator and denominator alike and cancels exactly — the innovation variances have no effect at all and the measure degenerates to `sqrt(directed_transfer_function())` for *every* noise covariance.
+
+pyspi now recomputes `DC_ij = sqrt(σ_jj)|H_ij| / sqrt(Σ_k σ_kk|H_ik|²)` (Baccalá et al. 1998) from the same transfer function, with the variance on the source axis.
+
+The previous release note claimed verification "under an identity noise covariance it reproduces `sqrt(DTF)` to 4e-16, the identity DC must satisfy when noise variances are equal". That check was **vacuous**: defect 2 makes the identity hold unconditionally. Measured with innovation standard deviations (1, 3, 0.2), the old form still reproduced `sqrt(DTF)` to 4e-16. Correctness is now pinned by an algebraic test against an explicit-loop transcription of the published formula at unequal variances, by `Σ_j DC_ij² == 1`, and by the `sqrt(DTF)` identity in *both* directions — it must hold at equal variances and must fail at unequal ones — driven from an exact analytic VAR(1) spectrum rather than a sampled estimate.
+
+`dcoh_*` values (6 SPIs) differ from 2.x.
+
+**Correlated innovations.** Baccalá's formula uses only `diag(Σ)`. Boundedness in [0,1] and `Σ_j DC_ij² == 1` hold regardless; what needs diagonal `Σ` is the reading of `DC_ij²` as the fraction of process *i*'s spectral power arriving from *j*. The Wilson-estimated innovation correlation on the bundled fixtures reaches 0.14 (VAR), 0.64 (CML) and 1.00 (Kuramoto), so the caveat is not academic. pyspi does **not** whiten: the minimum-phase factor `G = H·g₀` would give an exactly power-decomposing variant, but `g₀` is triangular and therefore order-dependent — recomputing the same pair as `[j, i]` yields a different `g₀`, so the result would depend on process order, the defect that made `coint_aeg` wrong. The published order-free form is computed, with the assumption documented on the class rather than hidden.
+
+**Removed (7)**
+
+| SPI | Why |
+|:----|:----|
+| `te_symbolic_k-1` | A length-1 ordinal pattern has one symbol, so TE is identically zero. |
+| `te_symbolic_k-10` | Severely undersampled and unvalidated at `T=100`: `10!` symbols against ~91 usable samples. On var1 and cml every joint count is 1, so the value tracks sample size rather than dependence; that does not hold universally (kuramoto: 3 of 42 pairs). Still constructible, and defensible for long series. |
+| `di_kernel_W-0.5` | ~3.8-4.4 on independent data at every `T` from 100 to 8000. |
+| `di_kozachenko` | Negative values, for a nonnegative quantity. |
+| `phase_multitaper_max_*` (3) | Ordinary maxima of wrapped phase depend on the branch cut and were exactly π off-diagonal on the VAR fixture. The constructors now refuse this statistic. |
+
+**Added (1)**
+
+| SPI | Why |
+|:----|:----|
+| `di_kraskov_NN-4_n-5` | Direct KSG/Frenzel-Pompe conditional-MI estimate of directed information; the validated nonlinear replacement for the two dropped variants. Not in the `benchmarked_p*` sets until it has been timed. |
+
+**Values changed** — the comparison below is between final 3.0.0 and 2.0.1 on the frozen fixtures at rtol 1e-9. The three phase means change; the strict KSG fix does not alter a frozen array.
+
+| SPIs | Cause |
+|:-----|:------|
+| `coint_aeg_*` (3) | No longer forced symmetric; each orientation is reported as computed. |
+| `psi_wavelet_*` (6) | Sign restored, negation moved before the band statistic, wavelet length bounded. |
+| `bary_sgddtw_*`, `bary-sq_sgddtw_*` (4) | Stochastic; they now honour the caller's seed instead of the import-time `seed(1717)`. |
+| `dcoh_*` (6) | Directed coherence weights by the *source* innovation variance, which the backend's helper cancelled out. |
+| `ccm_E-None_*` (3) | The auto-embedding search returns `argmax(rho)` instead of `max(E)`, which was pinning every process at E=10. |
+| `gd_*` (3) | Group delay is computed rather than returned all-NaN by a backend whose one-sample significance test divides by the square root of a negative number. |
+| `xcorr_*`, `xcorr-sq_*` (6) | Biased normalisation, demeaning, a symmetric lag window, a `1.96/sqrt(T)` amplitude cut, and a `sigonly` rule invariant under l → −l that returns 0 when nothing clears it. On `kuramoto_M7_T100` that last change alone moves 16 of 42 pairs, each from a value the old code had itself judged below the cut (0.109–0.195) to 0. |
+| `sgc_*_fmin-0-25_*` (6) | The NaN mask is now transformed into the same orientation as the values it masks. |
+| `mi_kraskov_*`, `tlmi_kraskov_*`, `te_kraskov_*`, `di_kraskov_*` (10) | Continuous coordinates are standardised; tied coordinates are refused instead of deterministically dithered. |
+| `*_kernel_*` (9) | Reported in nats rather than bits. |
+| `mi_gaussian`, `tlmi_gaussian`, `gc_gaussian_*`, `je_gaussian`, `ce_gaussian`, `cce_gaussian_*`, `xme_gaussian_*`, `si_gaussian`, `di_gaussian_n-5` (~14, ≲1e-8 relative) | One shared Gaussian ridge across every path, proportional to each variable's own variance. |
+| `te_kraskov_NN-4_DCE-AUTO_MAX-CORR-AIS_*`, `te_kraskov_NN-4_MAX-CORR-AIS_*`, `gc_gaussian_MAX-CORR-AIS_*` (3) | `MAX_CORR_AIS` now selects the source embedding as well as the destination, instead of hard-coding the source to (1, 1). Auto-embedding also skips embeddings the estimator cannot support. |
+| every `kraskov` SPI (10) | Content-derived dither and 12-decimal rounding are removed; valid continuous inputs can move only where that rounding changed neighbour geometry. |
+| `phase_multitaper_mean_*` (3) | Circular rather than arithmetic phase mean, with the opposite orientation set by negation. Maximum absolute frozen-fixture changes by band are 1.0957–1.4568 (VAR), 0.5345–2.4907 (CML), and 3.0032–4.6345 (Kuramoto). |
+
+**Renamed (23)** — no value change. In every case a parameter that changes the measure was absent from, or misreported by, the identifier.
+
+| Was | Is | Why |
+|:----|:---|:----|
+| `sgc_*_fmin-0_*` (12) | `sgc_*_fmin-1e-05_*` | Spectral GC is undefined at zero frequency, so `fmin=0` is overridden — but the identifier was built from the argument. |
+| `*_DCE` (4) | `*_DCE-AUTO` | The Theiler window's value, not just its presence: 5, 10 and `"AUTO"` shared one name. |
+| `cce_gaussian`, `cce_kernel_W-0.5`, `cce_kozachenko`, `di_gaussian` (4) | `..._n-5` | `n` changes the measure. |
+| `te_kraskov_*_k-max-*`, `gc_gaussian_k-max-*` (3) | `..._MAX-CORR-AIS_k-max-*` | The auto-embedding method changes what is computed, and `MAX_CORR_AIS` no longer means what it did. These three also change value — see below. |
+
+Symbolic transfer entropy identifiers also lose the three embedding parameters the estimator never applied: `te_symbolic_k-_kt-1_l-1_lt-1` → `te_symbolic_k-`. No symbolic variant is shipped, so no bundled SPI is affected.
+
+Values for the six directed spectral SPIs listed under **Fixed** are also transposed relative to 2.x.
diff --git a/Dockerfile b/Dockerfile
index 12e9a08a..cf19e7e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,24 +1,28 @@
-# Debian linux distribution with python 3.9
-FROM --platform=linux/amd64 python:3.9-slim-bookworm
+# Reproducible pyspi environment built from the committed uv.lock.
+#
+# linux/amd64 is pinned deliberately: dtaidistance publishes no linux-aarch64
+# wheel, so an arm64 build would fall back to its sdist and require a C
+# toolchain. Every dependency in uv.lock has a manylinux x86_64 wheel for
+# CPython 3.12, so no compiler is installed here.
+FROM --platform=linux/amd64 python:3.12-slim-bookworm
-# Set the working directory
-WORKDIR /pyspi_project
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
-# Copy the current directory contents into the container
-COPY . .
+# Keep the environment outside the workdir so it can never be shadowed by a
+# host .venv, and so the image works with `python` straight off PATH.
+ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
+ UV_COMPILE_BYTECODE=1 \
+ PATH="/opt/venv/bin:$PATH"
-# Update the package index and install essential packages
-RUN apt-get update && apt-get install -y build-essential octave
+WORKDIR /pyspi
-# Upgrade pip and setuptools
-RUN pip install --upgrade pip setuptools
+# Dependencies first: this layer is only invalidated when the lock or the
+# project metadata changes, not when library source changes.
+COPY pyproject.toml uv.lock README.md ./
+RUN uv sync --frozen --no-install-project
-# Install any needed packages specified in requirements.txt
-RUN pip install --no-cache-dir -r requirements.txt && \
- python setup.py install
+# Then the package itself.
+COPY pyspi ./pyspi
+RUN uv sync --frozen
-# Make port 80 available to communicate with other containters if needed
-# EXPOSE 80
-
-# Run app.py when the container launches
CMD ["python"]
diff --git a/LICENSE.txt b/LICENSE.txt
index 53739ac7..e62ec04c 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -179,18 +179,18 @@ makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
-statistic under any applicable law fulfilling obligations under article
+measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
-statistics.
+measures.
When you convey a covered work, you waive any legal power to forbid
-circumvention of technological statistics to the extent such circumvention
+circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
-technological statistics.
+technological measures.
4. Conveying Verbatim Copies.
diff --git a/README.md b/README.md
index 71378d54..3527a785 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -29,6 +29,8 @@ Feedback is much appreciated through [issues](https://github.com/DynamicsAndNeur
|:--------------|:----------------------|
| [Installation](#installation-) | Installing _pyspi_ and its dependencies |
| [Getting Started](#getting-started-) | A quick introduction on how to get started with _pyspi_ |
+| [Choosing an SPI set](#choosing-an-spi-set) | The bundled configs, and how to pick one |
+| [Running at scale](#running-at-scale) | Parallelism, checkpointing, and HPC clusters |
| [SPI Descriptions](#spi-descriptions-) | A link to the full table of SPIs and detailed descriptions |
| [Documentation](#documentation) | A link to our API reference and full documentation on GitBooks |
| [Contributing to _pyspi_](#contributing-to-pyspi-) | A guide for community members willing to contribute to _pyspi_ |
@@ -37,40 +39,154 @@ Feedback is much appreciated through [issues](https://github.com/DynamicsAndNeur
## Installation 📥
-The simplest way to get the _pyspi_ package up and running is to install the package using `pip install`.
-#### 1. Create a conda environment (Optional, Recommended)
-While you can also install _pyspi_ outside of a conda environment, it depends on a lot of user packages that may make managing dependencies quite difficult.
-So, we would also recommend installing pyspi in a conda environment. Firstly, create a fresh conda environment:
-```
-conda create -n pyspi python=3.9.0
-```
-Once you have created the environment, activate it using `conda activate pyspi`.
+_pyspi_ requires **Python 3.10 or newer**.
+
+> **Upgrading from 2.x?** Version 3.0 removes the Java/JIDT dependency and
+> changes the `Calculator` API. See [CHANGELOG.md](CHANGELOG.md) for the
+> migration table.
-#### 2. Install with _pip_
-Using `pip` for [`pyspi`](https://pypi.org/project/pyspi/):
+```bash
+pip install pyspi
```
+
+_pyspi_ depends on a large scientific stack, so installing into a dedicated
+environment is strongly recommended:
+
+```bash
+python -m venv .venv && source .venv/bin/activate
pip install pyspi
```
-For a more detailed guide on how to install _pyspi_, please see the [full documentation](https://time-series-features.gitbook.io/pyspi/installation/installing-pyspi).
-Additionally, we provide a comprehensive [troubleshooting guide](https://time-series-features.gitbook.io/pyspi/installation/troubleshooting) for users who encounter issues installing _pyspi_ on their system,
-as well as [alternative installation options](https://time-series-features.gitbook.io/pyspi/installation/alternative-installation-options).
+Developing on _pyspi_ itself, with [uv](https://docs.astral.sh/uv/):
+
+```bash
+git clone https://github.com/DynamicsAndNeuralSystems/pyspi.git
+cd pyspi
+uv sync # runtime + dev dependencies from uv.lock
+uv run pytest -q # fast test suite
+```
+
+Optional extras: `.[tsfile]` to load sktime/aeon `.ts` files, `.[bench]` for the
+compute-cost benchmark suite in [`bench/`](bench/).
+
+For a more detailed guide, see the [full documentation](https://time-series-features.gitbook.io/pyspi/installation/installing-pyspi),
+the [troubleshooting guide](https://time-series-features.gitbook.io/pyspi/installation/troubleshooting),
+and [alternative installation options](https://time-series-features.gitbook.io/pyspi/installation/alternative-installation-options).
## Getting Started 🚀
-Once you have installed _pyspi_, you can learn how to apply the package by checking out the [walkthrough tutorials](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials) in our documentation. Click any of the examples below to access the tutorials in our full documentation:
+```python
+import numpy as np
+import pyspi
+from pyspi.calculator import Calculator
+
+dataset = np.random.randn(5, 500) # 5 processes, 500 observations
+calc = Calculator(dataset=dataset) # z-scores each process by default
+calc.compute() # compute every SPI
+
+calc.table # rows = processes, columns = (SPI, process)
+calc.table["cov_EmpiricalCovariance"] # one SPI's 5x5 matrix
-- [Simple demonstration](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/getting-started-a-simple-demonstration)
+calc.save("results.npz") # canonical on-disk format
+pyspi.load_table("results.npz") # round-trips exactly
+```
+
+Results are stored as an `(n_spis, M, M)` array plus the SPI and process names,
+which is the data's natural shape. `.csv` is also accepted for eyeballing small
+results, but it is one-way and impractical for the full SPI set.
+
+Or from the command line, writing a results table next to your data:
+
+```bash
+python -m pyspi compute --data ts.npy --config fast --n-jobs 4 --output results.npz
+```
+
+Try it on a bundled example dataset:
-- [Finance: stock price time series](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/finance-stock-price-time-series)
+```python
+from pyspi.data import load_dataset, available_datasets
+
+available_datasets() # forex, cml, standard_normal
+calc = Calculator(dataset=load_dataset("forex"), config="fabfour")
+calc.compute()
+```
+
+`forex` is quantised: every one of its seven processes contains repeated
+values (24–212 distinct values across 250 observations). Consequently, all
+KSG/`kraskov` variants refuse it under pyspi's continuous, tie-free input
+contract. pyspi does not provide a general discrete MI, TLMI or DI plug-in
+estimator; its symbolic support is TE-specific. Use an external discrete
+estimator when the missing quantities are needed.
+
+Walkthrough tutorials in the full documentation:
+[simple demonstration](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/getting-started-a-simple-demonstration) ·
+[finance](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/finance-stock-price-time-series) ·
+[neuroimaging](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/neuroimaging-fmri-time-series)
+
+## Choosing an SPI set
+
+Computing all 322 SPIs is expensive, and cost grows steeply in both the number
+of processes *M* and the series length *T*. `config=` takes either a bundled
+name or a path to your own YAML:
+
+| `config=` | SPIs | Use when |
+|:----------|-----:|:---------|
+| `"full"` (default) | 322 | You want everything and can afford it. |
+| `"fast"` | 213 | General use; drops the slowest SPIs. |
+| `"benchmarked_p99"` | 318 | Near-complete, with only the worst cost outliers removed. |
+| `"benchmarked_p95"` | 305 | Good coverage/cost trade-off. |
+| `"benchmarked_p90"` | 290 | Recommended default for large batches. |
+| `"benchmarked_p80"` | 261 | Cost-constrained sweeps. |
+| `"sonnet"` | 14 | One representative SPI per module (M01-M14). |
+| `"fabfour"` | 4 | Smoke tests and quick sanity checks. |
+
+The `benchmarked_p` sets keep the fastest *N*% of SPIs by **measured**
+amortized compute cost, so the cut point is the *N*th percentile of the cost
+distribution. They were derived from a benchmark grid spanning *M* ∈ {4..64} and
+*T* ∈ {200..3200}; see [`bench/README.md`](bench/README.md) for the methodology
+and [`bench/results/analysis/report.md`](bench/results/analysis/report.md) for
+the measurements.
+
+To build your own subset by keyword:
+
+```python
+from pyspi.utils import filter_spis
+filter_spis(["nonlinear", "directed"], output_name="my_spis")
+calc = Calculator(config="my_spis.yaml")
+```
+
+## Running at scale
+
+**Prefer one dataset per process.** `Calculator.compute(n_jobs=...)` parallelises
+*within* a single dataset, but SPIs sharing a cache are grouped into one task
+that runs serially in one worker, so the makespan is floored by the longest
+single task. Measured on the benchmark grid at *M*=64, *T*=1600, that ceiling is
+**2.3-4.5x regardless of `n_jobs`**, on every config. If you have more datasets
+than cores — the usual case on a cluster — run each dataset in its own
+single-core process instead. That scales close to linearly, isolates failures to
+one dataset, and schedules far faster as an array job.
+
+Use `n_jobs > 1` when you have fewer datasets than cores, when one dataset must
+finish under a walltime limit, or when memory forces you to share a single copy
+of a large dataset across workers.
+
+```bash
+# PBS array job: one dataset per task, one core each
+#PBS -J 0-499
+#PBS -l ncpus=1,mem=8GB
+python -m pyspi compute --data "datasets/${PBS_ARRAY_INDEX}.npy" \
+ --config benchmarked_p90 --checkpoint-dir "results/${PBS_ARRAY_INDEX}/"
+```
-- [Neuroimaging: fMRI time series](https://time-series-features.gitbook.io/pyspi/usage/walkthrough-tutorials/neuroimaging-fmri-time-series)
-
-- [Motion tracking](https://time-series-features.gitbook.io/pyspi/installing-and-using-pyspi/usage/walkthrough-tutorials/motion-tracking)
+`--checkpoint-dir` writes each SPI to `/.npy` as it finishes and
+resumes from those on a re-run, so a job killed at the walltime limit picks up
+where it stopped. `PYSPI_N_JOBS` sets `n_jobs` from the environment.
-### Advanced Usage
-For advanced users, we offer several additional guides in the [full documentation](https://time-series-features.gitbook.io/pyspi/usage/advanced-usage) on how you can distribute your _pyspi_ jobs across PBS clusters, as well as how you can construct your own subsets of SPIs.
+When `n_jobs > 1`, workers pin their nested BLAS/OpenMP thread pools to one
+thread each to avoid oversubscription. macOS is
+an exception: its Accelerate BLAS cannot be pinned this way, so prefer `n_jobs=1`
+there for a single dataset.
## SPI Descriptions 📋
To access a table with a high-level overview of the _pyspi_ library of SPIs, including their associated identifiers, see the [table of SPIs](https://time-series-features.gitbook.io/pyspi/spis/table-of-spis) in the full documentation.
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
index 00000000..a40e1c3b
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,174 @@
+# pyspi benchmark suite
+
+Reproducible timing for `Calculator.compute()`, and the cost model behind the
+shipped `pyspi/configs/benchmarked_p{80,90,95,99}.yaml` subsets.
+
+## Install
+
+```bash
+pip install -e '.[bench]'
+```
+
+The `bench` extra adds `psutil` (per-cell RSS), `matplotlib`/`seaborn`/`plotly`
+(analysis plots) and `nbformat` — none of which are runtime dependencies of
+pyspi itself. Run everything from the repo root so `bench.*` is importable.
+
+## Run
+
+```bash
+# Per-SPI walltime sweep over an M x T grid (n_jobs=1) — feeds the cost model
+python -m bench.bench_compute --preset scaling --config full
+
+# Parallel speedup curve at M=16, T=800 (n_jobs = 1,2,4,8,16)
+python -m bench.bench_compute --preset parallel --config benchmarked_p90
+
+# Custom grid
+python -m bench.bench_compute --m 8,16,32 --t 200,800 --n-jobs 1,4,8 --config fast
+```
+
+`--config` takes a bundled config name (`full`, `fast`, `sonnet`, `fabfour`,
+`benchmarked_p80/p90/p95/p99`) or a path to your own YAML — it is passed
+straight to `pyspi.calculator.resolve_config`, so the two forms behave exactly
+as they do for `Calculator(config=...)`.
+
+## Presets
+
+| preset | grid | purpose |
+|-------------|-----------------------------------------------|---------|
+| `headline` | (M=10,T=500), (M=20,T=1000), n_jobs=1 | quick reference points |
+| `scaling` | M={4,8,16,32} x T={200,400,800,1600}, n_jobs=1| per-SPI walltime for cutting `benchmarked_p*.yaml` |
+| `parallel` | M=16, T=800, n_jobs={1,2,4,8,16} | parallel speedup curve |
+| `amortized` | M={8,16}, T=800, n_jobs=1 | per-SPI walltime, minimal grid |
+
+## Output
+
+One JSON file per cell, written atomically to `bench/results/cells/` (the
+default `--output-dir`) as `_M_T_n.json`. `--resume` skips
+cells whose JSON exists with `repeats >= --repeats`. Each file is
+self-contained, with an `environment` block (pyspi git sha, dependency versions
++ fingerprint, platform) pinning results to an exact environment. Per-cell
+fields:
+
+- `cell_wall_seconds {mean, std, values}`, `n_spis`, `n_spis_failed`, `failed_spis: [...]`
+- `rss_mb_end`, `rss_mb_delta` (per-cell, via `psutil.Process().memory_info().rss`)
+- `spi_seconds: {identifier: {mean, std, values, category, labels}}`
+ - `category` is one of `basic | distance | causal | infotheory | spectral | wavelet | misc`
+ - `labels` is the SPI's merged label list (includes `Mxx` size tags + stat-type tags)
+
+`--repeats` defaults to **2**. The 22-cell reference campaign used to derive the
+tracked summaries was measured with `--repeats 1` — at M=64, T=3200 a single
+repeat of the full config is already a multi-day job — so its
+`cell_wall_seconds.std` is 0 by construction, and cross-cell consistency (see
+`report.md`) stands in for a within-cell error bar. Raw per-cell JSON is
+machine-specific and intentionally untracked; retain it locally or archive it
+externally if the campaign may need to be reanalysed.
+
+## Cut a benchmarked config
+
+`cut_config.py` turns a single per-cell JSON into `pyspi/configs/benchmarked_p.yaml`.
+
+```bash
+python -m bench.bench_compute --preset amortized --config full
+python -m bench.cut_config --bench-json bench/results/cells/.json --keep 90
+```
+
+Cost model (`--mode`):
+
+- `amortized` (default) — SPIs sharing a `_cache_namespace` (Covariance/Precision,
+ the multitaper spectral pairs, Cointegration, Barycenter, CCM, ...) split the
+ group's total cost evenly: `cost = sum(group walltimes) / group size`. This is
+ the true per-variant budget impact — the shared computation is built once.
+- `raw` — each SPI's own measured walltime. Written to
+ `benchmarked_p_raw.yaml` so it never overwrites the shipped amortized cut.
+
+Output goes to `pyspi/configs/benchmarked_p.yaml`; by default dropped SPIs
+are commented out (not deleted) so the YAML carries the full provenance of the
+cut. Pass `--no-preserve-dropped` to delete instead.
+
+**`benchmarked_p90.yaml` carries a hand edit**: the two `te_kraskov_..._DCE_k-{1,2}`
+variants were added back after the cut for methodological reasons. Re-running
+`cut_config` overwrites it. The rationale lives in the config's own header
+(`pyspi/configs/benchmarked_p90.yaml`, lines 6-16) — read it before regenerating.
+
+## Analyse
+
+```bash
+# Cross-cell analysis (anchor stability, scaling fits, cumulative cost).
+# With no arguments this analyses every local cell against the full config.
+python -m bench.analyse_cells
+
+# Explicit form
+python -m bench.analyse_cells \
+ --results-glob 'bench/results/cells/physics_config_M*_T*_n1.json' \
+ --config full --percentiles 80,90,95,99 \
+ --output-dir bench/results/analysis
+
+# Predict cell wall time at a target (M, T) from the scaling fits
+```
+
+`analyse_cells` writes into `bench/results/analysis/`. Only the small
+human-readable summaries are committed — `report.md`, `scaling.csv`,
+`cell_summary.csv` (and `dropped_spi_comparison.md`). The bulk artefacts
+(`long_costs.csv`, `jaccard_p*.csv`, `plot_*.png`)
+are gitignored and regenerate in seconds; the Jaccard matrices are also
+read `long_costs.csv`, so run `analyse_cells` once before either.
+
+## Cluster (PBS)
+
+One generic PBS Pro script, `bench/run_benchmark.pbs`, plus a worked site
+example. To run it anywhere:
+
+1. Clone the repo on the cluster and create a venv with pyspi installed
+ editable (default location `/.venv`, override with `-v VENV=/path`;
+ set `VENV=` empty if python already comes from a module or conda).
+2. Edit the two `#PBS -l` lines at the top of `run_benchmark.pbs` to size
+ walltime/cpus/mem for the largest `(M,T)` cell you plan to run. They are
+ the only site-specific values in the file.
+3. Submit from the repo root, passing your account/queue/storage/mail on the
+ qsub command line — `#PBS` directives are never shell-expanded, so nothing
+ site-specific can come from an env var there.
+
+```bash
+# one (M,T) cell per array task, n_jobs=1 (the config-cutting regime):
+M=32 T=200,400,800,1600,3200 CONFIG=full REPEATS=1 \
+ qsub -J 1-5 -v M,T,CONFIG,REPEATS bench/run_benchmark.pbs
+
+# a single cell, on a named project/queue, with mail and a longer walltime:
+M=64 T=3200 CONFIG=full \
+ qsub -P myproj -q normal -l storage=scratch/myproj -l walltime=168:00:00 \
+ -m bea -M you@example.org -v M,T,CONFIG bench/run_benchmark.pbs
+
+# a bundled preset, walked sequentially in one job:
+qsub -v PRESET=parallel,CONFIG=benchmarked_p90 bench/run_benchmark.pbs
+```
+
+`qsub -v` splits on commas, so comma-valued vars (`M`, `T`) must be exported in
+the shell and passed by name, as above. Other env vars: `NJOBS`, `PRESET`,
+`CONFIG`, `REPEATS`, `LABEL`, `PYSPI_DIR`, `VENV`, `MODULES` (space-separated
+modules to load) — all documented in the script header. Every run is
+`--resume`, so resubmitting skips cells that already have a JSON.
+
+**`bench/physics/run_bench.pbs`** is a worked example to copy and adapt: the
+USYD Physics queue configuration that produced the committed
+`physics_config_M*_T*_n1.json` cells (full config, `repeats=1`, `n_jobs=1`,
+M={4,8,16,32,64} x T={200,400,800,1600,3200}, one cell per array task). It
+pins those defaults and delegates to `run_benchmark.pbs`.
+
+## Notes
+
+- **n_jobs and amortized configs**: per-SPI cost is invariant to `n_jobs` under
+ the cache-aware scheduler (each cache group runs sequentially within one
+ worker). Use `n_jobs=1` for config-cutting measurements; `n_jobs` only
+ changes makespan, which the `parallel` preset measures.
+- **Start method**: `Calculator.compute()` defaults to `fork` on Linux,
+ `spawn` on macOS/Windows. fork is ~2x faster (workers inherit imported
+ state via copy-on-write). Override with `--mp-context`.
+- This suite measures **inner** parallelism (SPIs within one dataset). Outer
+ parallelism (many datasets) belongs to the job scheduler — e.g. a PBS array.
+- **JIDT parity harness (removed)**: `bench/jidt_parity/` validated the pure-NumPy
+ information-theory estimators against JIDT 1.6.1 before the Java dependency was
+ dropped. Gaussian/kernel MI and symbolic TE matched to machine precision
+ (~1e-16), and Kozachenko entropy to ~1e-5. The KSG comparison used different
+ normalisation and tie policies and is historical evidence rather than a parity
+ oracle. The harness required `jpype` and the removed `infodynamics.jar`, so it
+ is not part of 3.0.0.
diff --git a/bench/__init__.py b/bench/__init__.py
new file mode 100644
index 00000000..ab56790e
--- /dev/null
+++ b/bench/__init__.py
@@ -0,0 +1,5 @@
+"""Benchmark suite for pyspi.
+
+Runs reproducible, non-notebook timing sweeps over Calculator.compute().
+See bench/bench_compute.py.
+"""
diff --git a/bench/_config_walk.py b/bench/_config_walk.py
new file mode 100644
index 00000000..9dbd16e2
--- /dev/null
+++ b/bench/_config_walk.py
@@ -0,0 +1,47 @@
+"""Shared config walker for the bench scripts.
+
+``cut_config``, ``forecast_cell`` and ``analyse_cells`` all need to enumerate the
+SPIs a config yaml would instantiate. This mirrors
+:func:`pyspi.calculator.load_spis_from_yaml` — including the ``LaggedCorrelation``
+``max_tau`` expansion and the stripping of config-level ``labels:`` annotations
+that are not constructor kwargs — and is the single place that mirroring lives.
+"""
+
+from __future__ import annotations
+
+import importlib
+from pathlib import Path
+
+import yaml
+
+# cache_bucket is re-exported, not redefined: the benchmark tooling, the
+# scheduler and the config advisory must not drift apart on what "shares a
+# cache" means.
+from pyspi._parallel import cache_bucket # noqa: F401
+from pyspi.calculator import _expand_lagged_correlation_configs, _split_config_params
+
+
+def walk_spis(configfile):
+ """Yield ``(module_name, class_name, params, identifier, spi)`` for every SPI.
+
+ ``params`` is the *raw* config variant (still carrying any ``labels:``
+ annotation) so that callers re-emitting YAML can round-trip it verbatim; the
+ SPI itself is constructed from the constructor-only subset.
+ """
+ source = yaml.safe_load(Path(configfile).read_text())
+ for module_name, module_spis in source.items():
+ module = importlib.import_module(module_name, "pyspi")
+ for class_name, entry in (module_spis or {}).items():
+ if entry is None:
+ continue
+ configs = entry.get("configs")
+ if class_name == "LaggedCorrelation" and configs is not None:
+ configs = _expand_lagged_correlation_configs(configs)
+ cls = getattr(module, class_name)
+ for params in ([None] if configs is None else configs):
+ if params is None:
+ spi = cls()
+ else:
+ ctor_params, _ = _split_config_params(params)
+ spi = cls(**ctor_params)
+ yield module_name, class_name, params, spi.identifier, spi
diff --git a/bench/analyse_cells.py b/bench/analyse_cells.py
new file mode 100644
index 00000000..e2767e7b
--- /dev/null
+++ b/bench/analyse_cells.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python
+"""Analyse a directory of per-cell bench JSONs to choose the anchor + percentile
+recipe for the pyspi/configs/benchmarked_p.yaml cuts.
+
+Writes into --output-dir. Committed (small, human-readable):
+ cell_summary.csv (M, T, n_spis, n_failed, cell_wall_s, sum_amortized_s)
+ scaling.csv per-SPI fit log t = a + p*log M + q*log T (amortized)
+ report.md human-readable summary with elbow / drift / recommendation
+Gitignored (bulk / derived, regenerate in seconds):
+ long_costs.csv (M, T, identifier, raw_s, amortized_s, cache_namespace)
+ jaccard_p{N}.csv kept-set Jaccard between cells at percentile N (also in report.md)
+ plot_cumulative.png cumulative amortized cost vs kept-fraction, one line per cell
+ plot_kept_drift_p{N}.png binary heatmap: SPI x cell (1 = kept @ percentile, 0 = dropped)
+
+Usage:
+ python -m bench.analyse_cells # defaults: all committed cells
+ python -m bench.analyse_cells \
+ --results-glob 'bench/results/cells/physics_config_M*_T*_n1.json' \
+ --config full \
+ --percentiles 80,90,95 \
+ --output-dir bench/results/analysis
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt # noqa: E402
+import numpy as np # noqa: E402
+import pandas as pd # noqa: E402
+
+from bench._config_walk import cache_bucket, walk_spis # noqa: E402
+from pyspi.calculator import bundled_configs, resolve_config # noqa: E402
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_RESULTS_GLOB = "bench/results/cells/*.json"
+
+
+def cache_namespace_map(configfile) -> dict[str, tuple | None]:
+ """Return {identifier: bucket_key} for every SPI in the config.
+
+ The bucket key is ``(namespace, *cache_subkey_values)`` if the class
+ declares ``_cache_namespace``, else ``None``.
+ """
+ return {ident: cache_bucket(spi)
+ for _, _, _, ident, spi in walk_spis(configfile)}
+
+
+def amortized(raw: dict[str, float], ns_map: dict[str, str | None]) -> dict[str, float]:
+ groups: dict[str | None, list[str]] = defaultdict(list)
+ for ident in raw:
+ groups[ns_map.get(ident)].append(ident)
+ cost: dict[str, float] = {}
+ for ns, ids in groups.items():
+ if ns is None:
+ for i in ids:
+ cost[i] = raw[i]
+ else:
+ share = sum(raw[i] for i in ids) / len(ids)
+ for i in ids:
+ cost[i] = share
+ return cost
+
+
+def load_cells(paths: list[Path]) -> list[dict]:
+ cells = []
+ for p in sorted(paths):
+ d = json.loads(p.read_text())
+ if "spi_seconds" not in d or "error" in d:
+ print(f"[skip] {p.name}: error or empty", file=sys.stderr)
+ continue
+ d["__path__"] = str(p)
+ cells.append(d)
+ return cells
+
+
+def long_df(cells: list[dict], ns_map: dict[str, tuple | None]) -> pd.DataFrame:
+ rows = []
+ for c in cells:
+ raw = {k: v["mean"] for k, v in c["spi_seconds"].items()}
+ amo = amortized(raw, ns_map)
+ for ident, r in raw.items():
+ bucket = ns_map.get(ident)
+ rows.append({
+ "M": c["M"], "T": c["T"],
+ "identifier": ident,
+ "raw_s": r,
+ "amortized_s": amo[ident],
+ "cache_namespace": bucket[0] if bucket else None,
+ "cache_subkey": str(bucket[1:]) if bucket and len(bucket) > 1 else None,
+ })
+ return pd.DataFrame(rows)
+
+
+def cell_summary(cells: list[dict], df: pd.DataFrame) -> pd.DataFrame:
+ rows = []
+ for c in cells:
+ sub = df[(df["M"] == c["M"]) & (df["T"] == c["T"])]
+ rows.append({
+ "M": c["M"], "T": c["T"],
+ "n_spis": c["n_spis"],
+ "n_failed": c["n_spis_failed"],
+ "cell_wall_s": c["cell_wall_seconds"]["mean"],
+ "sum_amortized_s": float(sub.amortized_s.sum()),
+ "median_amortized_s": float(sub.amortized_s.median()),
+ "max_amortized_s": float(sub.amortized_s.max()),
+ })
+ return pd.DataFrame(rows).sort_values(["M", "T"]).reset_index(drop=True)
+
+
+def kept_set(df_cell: pd.DataFrame, pct: int) -> set[str]:
+ """The fastest pct% of SPIs by amortized cost at this cell."""
+ n_keep = round(pct / 100 * len(df_cell))
+ return set(df_cell.nsmallest(n_keep, "amortized_s").identifier)
+
+
+def jaccard_matrix(df: pd.DataFrame, pct: int) -> pd.DataFrame:
+ cells = df[["M", "T"]].drop_duplicates().sort_values(["M", "T"]).itertuples(index=False)
+ cells = list(cells)
+ labels = [f"M{m}T{t}" for m, t in cells]
+ keeps = [kept_set(df[(df["M"] == m) & (df["T"] == t)], pct) for m, t in cells]
+ n = len(cells)
+ J = np.zeros((n, n))
+ for i in range(n):
+ for j in range(n):
+ a, b = keeps[i], keeps[j]
+ J[i, j] = len(a & b) / len(a | b) if (a | b) else 1.0
+ return pd.DataFrame(J, index=labels, columns=labels)
+
+
+def fit_scaling(df: pd.DataFrame, min_seconds: float = 0.01) -> pd.DataFrame:
+ """Fit log t = a + p log M + q log T per SPI on amortized cost.
+
+ Returns rows with intercept, p_M, q_T, residual std on log scale, n_points.
+ Skips SPIs whose amortized cost is below min_seconds in *all* cells (noise floor).
+ """
+ rows = []
+ for ident, sub in df.groupby("identifier"):
+ if (sub.amortized_s < min_seconds).all():
+ continue
+ sub = sub[sub.amortized_s > 0]
+ if len(sub) < 4:
+ continue
+ X = np.column_stack([np.ones(len(sub)),
+ np.log(sub["M"].values), np.log(sub["T"].values)])
+ y = np.log(sub.amortized_s.values)
+ # least squares
+ beta, *_ = np.linalg.lstsq(X, y, rcond=None)
+ resid = y - X @ beta
+ rows.append({
+ "identifier": ident,
+ "intercept": float(beta[0]),
+ "p_M": float(beta[1]),
+ "q_T": float(beta[2]),
+ "log_resid_std": float(resid.std(ddof=0)),
+ "n_points": int(len(sub)),
+ "amortized_at_largest": float(sub.sort_values(["M", "T"]).amortized_s.iloc[-1]),
+ })
+ return pd.DataFrame(rows).sort_values("amortized_at_largest", ascending=False).reset_index(drop=True)
+
+
+def plot_cumulative(df: pd.DataFrame, percentiles: list[int], out: Path) -> None:
+ fig, ax = plt.subplots(figsize=(8, 5))
+ for (M, T), sub in df.groupby(["M", "T"]):
+ costs = np.sort(sub.amortized_s.values)
+ frac = np.arange(1, len(costs) + 1) / len(costs)
+ ax.plot(frac * 100, np.cumsum(costs) / costs.sum(), label=f"M={M} T={T}",
+ lw=1.2, alpha=0.75)
+ for p in percentiles:
+ ax.axvline(p, color="grey", lw=0.5, ls="--")
+ ax.set_xlabel("Kept-fraction (%)")
+ ax.set_ylabel("Cumulative amortized cost / total")
+ ax.set_title("Cumulative amortized cost vs kept-fraction (elbow ≈ cut point)")
+ ax.legend(fontsize=6, ncol=2, loc="upper left")
+ ax.grid(alpha=0.3)
+ fig.tight_layout()
+ fig.savefig(out, dpi=130)
+ plt.close(fig)
+
+
+def plot_kept_drift(df: pd.DataFrame, pct: int, out: Path) -> None:
+ cells = df[["M", "T"]].drop_duplicates().sort_values(["M", "T"]).itertuples(index=False)
+ cells = list(cells)
+ keeps = [kept_set(df[(df["M"] == m) & (df["T"] == t)], pct) for m, t in cells]
+ # only show SPIs that are NOT kept in at least one cell (i.e. drift exists)
+ union_drop = set.union(*[set(df.identifier.unique()) - k for k in keeps])
+ if not union_drop:
+ return
+ sorted_drop = sorted(union_drop)
+ mat = np.array([[int(ident in k) for k in keeps] for ident in sorted_drop])
+ fig, ax = plt.subplots(figsize=(max(6, 0.45 * len(cells)), max(4, 0.18 * len(sorted_drop))))
+ ax.imshow(mat, aspect="auto", cmap="Greys_r", interpolation="nearest")
+ ax.set_xticks(range(len(cells)))
+ ax.set_xticklabels([f"M{m}T{t}" for m, t in cells], rotation=45, ha="right", fontsize=7)
+ ax.set_yticks(range(len(sorted_drop)))
+ ax.set_yticklabels(sorted_drop, fontsize=6)
+ ax.set_title(f"Kept-set drift @ p{pct} (white = kept, black = dropped)")
+ fig.tight_layout()
+ fig.savefig(out, dpi=130)
+ plt.close(fig)
+
+
+def _md_table(df: pd.DataFrame, floatfmt: str = ".3f", index: bool = False) -> str:
+ """Render a DataFrame as a GitHub-flavoured markdown table without tabulate."""
+ work = df.copy()
+ if index:
+ work = work.reset_index().rename(columns={work.index.name or "index": "idx"})
+ cols = [str(c) for c in work.columns]
+ body = []
+ for _, r in work.iterrows():
+ row = []
+ for v in r.values:
+ if isinstance(v, float):
+ row.append(format(v, floatfmt))
+ else:
+ row.append(str(v))
+ body.append(row)
+ widths = [max(len(cols[i]), *(len(b[i]) for b in body)) if body else len(cols[i])
+ for i in range(len(cols))]
+ sep = "|".join("-" * (w + 2) for w in widths)
+ head = "|".join(f" {cols[i]:<{widths[i]}} " for i in range(len(cols)))
+ rows = ["|".join(f" {body[r][i]:<{widths[i]}} " for i in range(len(cols)))
+ for r in range(len(body))]
+ return "\n".join([f"|{head}|", f"|{sep}|"] + [f"|{r}|" for r in rows])
+
+
+def write_report(out: Path, summary: pd.DataFrame, jac: dict[int, pd.DataFrame],
+ scaling: pd.DataFrame, df: pd.DataFrame,
+ percentiles: list[int]) -> None:
+ lines = ["# Bench-cell analysis", ""]
+ lines.append(f"Cells analysed: {len(summary)}. Per cell: {int(summary.n_spis.iloc[0])} SPIs.")
+ lines.append("")
+ lines.append("## Cell summary (sorted by M, T)")
+ lines.append(_md_table(summary, ".2f"))
+ lines.append("")
+
+ for p in percentiles:
+ J = jac[p]
+ offdiag = J.values[~np.eye(len(J), dtype=bool)]
+ lines.append(f"## Jaccard kept-set similarity @ p{p}")
+ lines.append(f"Off-diagonal: mean={offdiag.mean():.3f} min={offdiag.min():.3f} "
+ f"max={offdiag.max():.3f}")
+ lines.append("")
+ lines.append(_md_table(J.round(3), ".3f", index=True))
+ lines.append("")
+
+ # Elbow heuristic: where does cumulative cost cross 90% of total?
+ lines.append("## Where does cumulative cost cross 50%, 80%, 90%, 95% of total?")
+ rows = []
+ for (M, T), sub in df.groupby(["M", "T"]):
+ costs = np.sort(sub.amortized_s.values)
+ frac_cost = np.cumsum(costs) / costs.sum()
+ frac_kept = np.arange(1, len(costs) + 1) / len(costs)
+ row = {"M": M, "T": T}
+ for target in [0.50, 0.80, 0.90, 0.95]:
+ i = int(np.searchsorted(frac_cost, target))
+ row[f"keep_for_{int(target*100)}pct_cost"] = round(frac_kept[i] * 100, 1)
+ rows.append(row)
+ lines.append(_md_table(pd.DataFrame(rows), ".2f"))
+ lines.append("")
+
+ lines.append("## Top 25 SPIs by amortized cost at the largest cell")
+ lines.append(_md_table(scaling.head(25).round(3), ".3f"))
+ lines.append("")
+
+ out.write_text("\n".join(lines))
+
+
+def parse_args(argv=None):
+ p = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--results-glob", default=DEFAULT_RESULTS_GLOB,
+ help=f"Glob for per-cell bench JSONs, relative to repo root "
+ f"(default: {DEFAULT_RESULTS_GLOB}).")
+ p.add_argument("--config", default="full",
+ help=f"Source config for the cache-namespace map and SPI list: "
+ f"a bundled name ({'/'.join(bundled_configs())}) or a path "
+ f"(default: full).")
+ p.add_argument("--percentiles", default="80,90,95",
+ help="Comma-separated percentiles to evaluate kept-set Jaccard at.")
+ p.add_argument("--output-dir", type=Path, default=REPO_ROOT / "bench" / "results" / "analysis")
+ return p.parse_args(argv)
+
+
+def main(argv=None) -> int:
+ args = parse_args(argv)
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+ percentiles = [int(x) for x in args.percentiles.split(",") if x.strip()]
+
+ paths = sorted(Path(REPO_ROOT).glob(args.results_glob))
+ if not paths:
+ raise SystemExit(f"no JSONs match {args.results_glob}")
+ print(f"[analyse] {len(paths)} cells matched", file=sys.stderr)
+
+ ns_map = cache_namespace_map(resolve_config(args.config))
+ print(f"[analyse] cache-namespace map: {len(ns_map)} SPIs", file=sys.stderr)
+
+ cells = load_cells(paths)
+ df = long_df(cells, ns_map)
+ summary = cell_summary(cells, df)
+
+ df.to_csv(args.output_dir / "long_costs.csv", index=False)
+ summary.to_csv(args.output_dir / "cell_summary.csv", index=False)
+
+ jac: dict[int, pd.DataFrame] = {}
+ for p in percentiles:
+ J = jaccard_matrix(df, p)
+ J.to_csv(args.output_dir / f"jaccard_p{p}.csv")
+ jac[p] = J
+
+ sf = fit_scaling(df)
+ sf.to_csv(args.output_dir / "scaling.csv", index=False)
+
+ plot_cumulative(df, percentiles, args.output_dir / "plot_cumulative.png")
+ for p in percentiles:
+ plot_kept_drift(df, p, args.output_dir / f"plot_kept_drift_p{p}.png")
+
+ write_report(args.output_dir / "report.md", summary, jac, sf, df, percentiles)
+
+ print(f"[analyse] wrote artefacts -> {args.output_dir}", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/bench/bench_compute.py b/bench/bench_compute.py
new file mode 100644
index 00000000..e4c8deea
--- /dev/null
+++ b/bench/bench_compute.py
@@ -0,0 +1,382 @@
+#!/usr/bin/env python
+"""Benchmark Calculator.compute() across an (M, T, n_jobs) grid.
+
+Reproducible, non-notebook timing suite. Each grid cell is run ``--repeats``
+times on freshly generated synthetic data and written to its **own** JSON
+file named ``_M_T_n.json`` — one cell per file, with
+M, T, n_jobs recorded at the top level (not just in the filename). Each
+file is self-contained: environment metadata (pyspi git sha, dependency
+versions + fingerprint, platform) lives in every cell file. ``--resume``
+skips cells whose JSON already exists with ``repeats >= --repeats``.
+
+Usage:
+ python -m bench.bench_compute --m 8,16 --t 200,800 --n-jobs 1,4 --config fast
+ python -m bench.bench_compute --preset amortized --config full
+ python -m bench.bench_compute --preset parallel --array-index $PBS_ARRAY_INDEX
+
+Presets (each fixes an M/T/n_jobs grid; --config still applies):
+ headline (M=10,T=500), (M=20,T=1000), n_jobs=1.
+ scaling M={4,8,16,32} x T={200,400,800,1600}, n_jobs=1.
+ parallel M=16, T=800, n_jobs={1,2,4,8,16}.
+ amortized M={8,16}, T=800, n_jobs=1.
+
+Two-axis parallelism: this script benchmarks INNER parallelism
+(Calculator.compute(n_jobs=)). OUTER parallelism (many datasets at once)
+belongs to the job scheduler — e.g. a PBS array over --array-index.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import importlib.metadata as im
+import json
+import os
+import platform
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+os.environ.setdefault("OMP_NUM_THREADS", "1")
+
+import numpy as np
+import psutil
+
+from pyspi._parallel import COMPUTATION_VERSION
+from pyspi.calculator import Calculator, bundled_configs, resolve_config
+
+CATEGORY_PREFIX = ".statistics." # python module suffix becomes the category field
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_OUTPUT_DIR = REPO_ROOT / "bench" / "results" / "cells"
+
+PRESETS = {
+ "headline": {"points": [(10, 500), (20, 1000)], "n_jobs": [1]},
+ "scaling": {"M": [4, 8, 16, 32], "T": [200, 400, 800, 1600], "n_jobs": [1]},
+ "parallel": {"M": [16], "T": [800], "n_jobs": [1, 2, 4, 8, 16]},
+ "amortized": {"M": [8, 16], "T": [800], "n_jobs": [1]},
+}
+
+TRACKED_DEPS = (
+ "pyspi", "numpy", "scipy", "pandas", "scikit-learn", "statsmodels",
+ "mne", "mne-connectivity", "spectral-connectivity", "nitime",
+ "hyppo", "tslearn", "dtaidistance", "pyEDM",
+ "h5py", "pyyaml", "tqdm",
+)
+
+
+def _parse_int_list(s: str) -> list[int]:
+ return [int(x.strip()) for x in s.split(",") if x.strip()]
+
+
+def parse_args(argv=None):
+ p = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--m", type=_parse_int_list, default=[8],
+ help="Comma-separated process counts (ignored if --preset is set).")
+ p.add_argument("--t", type=_parse_int_list, default=[200],
+ help="Comma-separated observation counts (ignored if --preset is set).")
+ p.add_argument("--n-jobs", dest="n_jobs", type=_parse_int_list, default=[1],
+ help="Comma-separated worker counts (ignored if --preset is set).")
+ p.add_argument("--preset", choices=list(PRESETS), default=None,
+ help="Predefined M/T/n_jobs grid; overrides --m/--t/--n-jobs.")
+ p.add_argument("--config", default="fabfour",
+ help=f"Bundled config name ({'/'.join(bundled_configs())}) "
+ "or a path to your own YAML.")
+ p.add_argument("--mp-context", choices=["spawn", "fork", "forkserver"], default="spawn",
+ help="Multiprocessing start method for n_jobs>1 (default: spawn).")
+ p.add_argument("--repeats", type=int, default=2, help="Repeats per cell (default: 2).")
+ p.add_argument("--seed", type=int, default=0, help="Base RNG seed (default: 0).")
+ p.add_argument("--output-dir", type=Path, default=None,
+ help="Directory for per-cell result JSONs (default: bench/results/cells/).")
+ p.add_argument("--label", default=None,
+ help="Filename prefix; default = stem of --config.")
+ p.add_argument("--resume", action="store_true",
+ help="Skip cells whose JSON already exists with repeats >= --repeats.")
+ p.add_argument("--array-index", type=int, default=None,
+ help="Run only the Nth (1-indexed) cell of the resolved grid. For PBS arrays.")
+ return p.parse_args(argv)
+
+
+def make_calculator(config: str, dataset: np.ndarray) -> Calculator:
+ return Calculator(dataset=dataset, config=config, zscore=False, verbose=False)
+
+
+def spi_metadata(calc: Calculator) -> dict[str, dict]:
+ """Return {identifier: {"category": , "labels": [...]}}.
+
+ ``category`` is the python module suffix (``.statistics.basic`` -> ``basic``);
+ ``labels`` is the SPI's merged label list (class labels + per-config overrides),
+ which includes the ``Mxx`` size-applicability tags alongside stat-type tags.
+ """
+ out: dict[str, dict] = {}
+ for ident, spi in calc._spis.items():
+ mod = type(spi).__module__
+ cat = mod.split(CATEGORY_PREFIX, 1)[1] if CATEGORY_PREFIX in mod else mod
+ labels = list(getattr(spi, "labels", []) or [])
+ out[ident] = {"category": cat, "labels": labels}
+ return out
+
+
+_PROC = psutil.Process()
+
+
+def rss_mb() -> float:
+ """Process current RSS in MB (NOT the high-watermark — that monotonically
+ accumulates across cells in the same process and gives misleading deltas)."""
+ return _PROC.memory_info().rss / (1024.0 * 1024.0)
+
+
+def git_sha() -> str | None:
+ try:
+ out = subprocess.run(
+ ["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"],
+ capture_output=True, text=True, check=True, timeout=5)
+ return out.stdout.strip()
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
+ return None
+
+
+def dep_versions() -> dict[str, str]:
+ out = {}
+ for name in TRACKED_DEPS:
+ try:
+ out[name] = im.version(name)
+ except im.PackageNotFoundError:
+ continue
+ return out
+
+
+def build_environment() -> dict:
+ versions = dep_versions()
+ payload = ";".join(f"{k}=={v}" for k, v in sorted(versions.items()))
+ return {
+ "datetime": datetime.now().isoformat(timespec="seconds"),
+ "pyspi_git_sha": git_sha(),
+ "python_version": platform.python_version(),
+ "platform": f"{platform.system()}-{platform.release()}-{platform.machine()}",
+ "dep_versions": versions,
+ "dep_fingerprint": "sha256:" + hashlib.sha256(payload.encode()).hexdigest()[:16],
+ "env": {k: os.environ.get(k, "") for k in
+ ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
+ "VECLIB_MAXIMUM_THREADS", "PYSPI_N_JOBS")},
+ }
+
+
+def cell_seed(base_seed: int, M: int, T: int) -> int:
+ """RNG seed for a cell's *data* -- a pure function of (base seed, M, T).
+
+ Deliberately not a function of ``n_jobs``. The whole point of the n_jobs
+ sweep is to time the same problem at different worker counts, and seeding on
+ n_jobs handed each column of that sweep a different dataset, so a scaling
+ curve compared runs on data that were never the same.
+
+ Also not a function of position. It was ``args.seed + i`` with ``i`` the
+ index in the *selected* cell list, and under ``--array-index k`` that list
+ has one element -- so every array task used ``seed + 1`` while a sequential
+ run gave cell k ``seed + k``. Hashing the cell instead survives adding a
+ point to the grid, which a positional seed does not.
+ """
+ digest = hashlib.blake2b(f"{base_seed}|{M}|{T}".encode(),
+ digest_size=8).digest()
+ return int.from_bytes(digest, "little")
+
+
+def cell_identity(base_seed, M, T, n_jobs, config_path, mp_context,
+ environment) -> str:
+ """What a stored cell must match for ``--resume`` to reuse it.
+
+ Resume previously checked only that the file existed, had at least
+ ``repeats`` repeats and carried no ``"error"`` -- so a cell measured under a
+ different config, seed, multiprocessing context or dependency set was
+ silently reused, and the grid mixed measurements that were never comparable.
+
+ ``n_jobs`` is part of the identity (it is what the cell measures) but not of
+ the data seed. ``repeats`` is deliberately *absent*: reuse is already
+ allowed whenever the stored run has at least as many repeats as requested,
+ so folding the requested count in here would discard a perfectly good
+ 10-repeat cell the moment someone asked for 5. ``COMPUTATION_VERSION``
+ covers the estimator implementations, so a cell measured before an
+ output-changing change is not reused after it.
+ """
+ payload = json.dumps({
+ "cell": [M, T, n_jobs],
+ "config": Path(config_path).read_text(),
+ "seed": cell_seed(base_seed, M, T),
+ "mp_context": mp_context,
+ "computation": COMPUTATION_VERSION,
+ "pyspi_git_sha": environment["pyspi_git_sha"],
+ "python": environment["python_version"],
+ "platform": environment["platform"],
+ "deps": environment["dep_fingerprint"],
+ }, sort_keys=True)
+ return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()[:16]
+
+
+def summarise(values: list[float]) -> dict:
+ arr = np.asarray(values, dtype=float)
+ return {
+ "mean": round(float(arr.mean()), 6),
+ "std": round(float(arr.std(ddof=0)), 6),
+ "values": [round(float(v), 6) for v in arr],
+ }
+
+
+def resolve_grid(args) -> list[tuple[int, int, int]]:
+ if args.preset is not None:
+ spec = PRESETS[args.preset]
+ n_jobs = spec["n_jobs"]
+ if "points" in spec:
+ mt = list(spec["points"])
+ else:
+ mt = [(m, t) for m in spec["M"] for t in spec["T"]]
+ else:
+ mt = [(m, t) for m in args.m for t in args.t]
+ n_jobs = args.n_jobs
+ return [(m, t, nj) for (m, t) in mt for nj in n_jobs]
+
+
+def run_cell(M, T, n_jobs, config, mp_context, repeats, seed) -> dict:
+ """Run one (M, T, n_jobs) cell ``repeats`` times. Returns a self-contained entry dict."""
+ rss_before = rss_mb()
+ rng = np.random.default_rng(seed)
+ totals: list[float] = []
+ per_spi: dict[str, list[float]] = {}
+ failed_ids: set[str] = set()
+ n_spis = 0
+ error = None
+ meta: dict[str, dict] = {}
+
+ for _ in range(repeats):
+ arr = rng.standard_normal((M, T)).astype(np.float64)
+ try:
+ calc = make_calculator(config, arr)
+ if not meta:
+ meta = spi_metadata(calc)
+ t0 = time.perf_counter()
+ calc.compute(n_jobs=n_jobs, mp_context=mp_context, progress=False)
+ totals.append(time.perf_counter() - t0)
+ n_spis = len(calc.spis)
+ for k, v in calc.timings.items():
+ per_spi.setdefault(k, []).append(float(v))
+ tbl = calc.table
+ for s in calc.spis:
+ if bool(np.all(np.isnan(np.asarray(tbl[s])[~np.eye(M, dtype=bool)]))):
+ failed_ids.add(s)
+ except Exception as exc: # noqa: BLE001
+ error = f"{type(exc).__name__}: {exc}"
+ break
+
+ spi_seconds = {}
+ for k, v in per_spi.items():
+ entry_v = summarise(v)
+ if k in meta:
+ entry_v["category"] = meta[k]["category"]
+ entry_v["labels"] = meta[k]["labels"]
+ spi_seconds[k] = entry_v
+
+ entry = {
+ "M": M, "T": T, "n_jobs": n_jobs, "repeats": len(totals),
+ "cell_wall_seconds": summarise(totals) if totals else None,
+ "n_spis": n_spis,
+ "n_spis_failed": len(failed_ids),
+ "failed_spis": sorted(failed_ids),
+ "rss_mb_end": round(rss_mb(), 1),
+ "rss_mb_delta": round(rss_mb() - rss_before, 1),
+ "spi_seconds": spi_seconds,
+ }
+ if error is not None:
+ entry["error"] = error
+ return entry
+
+
+def cell_filename(label: str, M: int, T: int, n_jobs: int) -> str:
+ return f"{label}_M{M}_T{T}_n{n_jobs}.json"
+
+
+def _atomic_write_json(path: Path, payload: dict) -> None:
+ tmp = path.with_suffix(".json.tmp")
+ tmp.write_text(json.dumps(payload, indent=2))
+ os.replace(tmp, path)
+
+
+def main(argv=None) -> int:
+ args = parse_args(argv)
+ config = resolve_config(args.config)
+ cfg_label = args.config
+
+ cells = resolve_grid(args)
+ if args.array_index is not None:
+ if not 1 <= args.array_index <= len(cells):
+ raise SystemExit(
+ f"--array-index {args.array_index} out of range [1, {len(cells)}].")
+ cells = [cells[args.array_index - 1]]
+
+ output_dir = (args.output_dir or DEFAULT_OUTPUT_DIR).resolve()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ label = args.label or Path(cfg_label).stem
+
+ env = build_environment()
+ print(f"[bench] config={cfg_label} cells={len(cells)} repeats={args.repeats} "
+ f"mp={args.mp_context}", file=sys.stderr)
+ print(f"[bench] output_dir={output_dir} label={label}", file=sys.stderr)
+
+ t_total = time.perf_counter()
+ for i, (M, T, n_jobs) in enumerate(cells, 1):
+ path = output_dir / cell_filename(label, M, T, n_jobs)
+ identity = cell_identity(args.seed, M, T, n_jobs, config,
+ args.mp_context, env)
+ if args.resume and path.exists():
+ try:
+ existing = json.loads(path.read_text())
+ reusable = (existing.get("repeats", 0) >= args.repeats
+ and "error" not in existing
+ and existing.get("cell_identity") == identity)
+ if reusable:
+ print(f"[bench] [{i}/{len(cells)}] M={M} T={T} n_jobs={n_jobs}"
+ f" — skipped (resume: {path.name})", file=sys.stderr)
+ continue
+ if existing.get("cell_identity") != identity:
+ print(f"[bench] [{i}/{len(cells)}] {path.name} was measured "
+ f"under different conditions; recomputing.",
+ file=sys.stderr)
+ except Exception:
+ pass
+
+ print(f"[bench] [{i}/{len(cells)}] M={M} T={T} n_jobs={n_jobs} x{args.repeats}"
+ f" -> {path.name}", file=sys.stderr, flush=True)
+ t0 = time.perf_counter()
+ seed = cell_seed(args.seed, M, T)
+ entry = run_cell(M, T, n_jobs, config, args.mp_context, args.repeats,
+ seed)
+ wall = time.perf_counter() - t0
+ # Self-contained per-cell file: include run metadata + environment.
+ entry["config"] = cfg_label
+ entry["mp_context"] = args.mp_context
+ # Both: the base is what was asked for, the effective seed is what the
+ # data was actually generated from and is the one a rerun must match.
+ entry["seed_base"] = args.seed
+ entry["seed"] = seed
+ entry["cell_identity"] = identity
+ entry["environment"] = env
+
+ if "error" in entry:
+ print(f"[bench] ERROR after {wall:.1f}s: {entry['error']}", file=sys.stderr)
+ else:
+ cw = entry["cell_wall_seconds"]
+ print(f"[bench] {wall:.1f}s wall (cell mean {cw['mean']:.2f}s "
+ f"+/- {cw['std']:.2f}s, {entry['n_spis']} SPIs, "
+ f"{entry['n_spis_failed']} failed, "
+ f"RSS {entry['rss_mb_end']:.0f} MB (+{entry['rss_mb_delta']:+.0f}))",
+ file=sys.stderr)
+ _atomic_write_json(path, entry)
+
+ print(f"[bench] done in {time.perf_counter() - t_total:.1f}s -> {output_dir}",
+ file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/bench/cut_config.py b/bench/cut_config.py
new file mode 100644
index 00000000..e0f21872
--- /dev/null
+++ b/bench/cut_config.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python
+"""Cut a benchmarked SPI subset config from a bench_compute.py per-cell JSON.
+
+Reads ONE per-cell JSON (``_M_T_n.json``), ranks SPIs by cost,
+and emits a ``pyspi/configs/benchmarked_p.yaml`` containing only the
+fastest ``--keep`` percent. (M, T, n_jobs) are read from the JSON itself, not
+parsed from the filename.
+
+Cost model — two modes:
+ raw each SPI's own measured wall time at this (M, T).
+ amortized (default) SPIs sharing a within-class cache (``_cache_namespace``
+ — Covariance/Precision, multitaper spectral pairs, CCM,
+ Cointegration, Barycenter, ...) split the group's total cost
+ evenly:
+ cost(spi) = sum(group wall times) / (group size)
+ The shared computation is built once and reused, so blaming its
+ full cost to one variant overcounts. Ungrouped SPIs use raw time.
+
+Usage:
+ python -m bench.cut_config --bench-json bench/results/cells/physics_config_M16_T800_n1.json --keep 90
+ python -m bench.cut_config --bench-json --keep 80 --mode raw
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import yaml
+
+from bench._config_walk import cache_bucket, walk_spis
+from pyspi.calculator import CONFIG_DIR, bundled_configs, resolve_config
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+def _rel(path: Path) -> str:
+ """Render a path relative to the repo root when possible.
+
+ Generated config headers are committed, so they must not carry the
+ absolute path of whichever checkout produced them.
+ """
+ path = Path(path)
+ try:
+ return str(path.resolve().relative_to(REPO_ROOT))
+ except ValueError:
+ return str(path)
+
+
+def parse_args(argv=None):
+ p = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--bench-json", type=Path, required=True,
+ help="Per-cell timing JSON from bench_compute.py.")
+ p.add_argument("--config", default="full",
+ help=f"Source config to cut from: a bundled name "
+ f"({'/'.join(bundled_configs())}) or a path (default: full).")
+ p.add_argument("--keep", type=int, default=90,
+ help="Percent of SPIs to keep, fastest-first (default: 90).")
+ p.add_argument("--mode", choices=["amortized", "raw"], default="amortized",
+ help="Cost model (default: amortized).")
+ p.add_argument("-o", "--output", type=Path, default=None,
+ help="Output config path (default: pyspi/configs/benchmarked_p.yaml, "
+ "suffixed _raw under --mode raw).")
+ p.add_argument("--no-preserve-dropped", action="store_true",
+ help="Delete dropped variants/classes from the output instead of "
+ "commenting them out (default: preserve as comments).")
+ return p.parse_args(argv)
+
+
+def _cache_buckets(records: list) -> dict[tuple, list[str]]:
+ """Return {(namespace, *subkey): [identifiers]} for all cache-grouped SPIs."""
+ buckets: dict = defaultdict(list)
+ for _, _, _, identifier, spi in records:
+ bucket = cache_bucket(spi)
+ if bucket is None:
+ continue
+ buckets[bucket].append(identifier)
+ return buckets
+
+
+def snap_to_cache_buckets(records: list, kept: set[str]) -> tuple[set[str], list[str]]:
+ """Promote partially-kept cache buckets to fully kept.
+
+ Once the cache for a bucket is built (because ANY member is kept), the
+ remaining members are essentially free at compute time — they're just
+ cheap transforms of the same cached value. Dropping a strict subset of a
+ bucket therefore pays the full cache cost for fewer SPIs, which is
+ strictly worse than keeping the whole bucket.
+
+ Returns ``(snapped_kept, promoted_ids)``. The kept count may exceed the
+ original percentile target by the size of the promoted partial buckets.
+ """
+ snapped = set(kept)
+ promoted: list[str] = []
+ for ids in _cache_buckets(records).values():
+ in_kept = [i for i in ids if i in snapped]
+ if not in_kept or len(in_kept) == len(ids):
+ continue # whole bucket kept or whole bucket dropped — nothing to do
+ for i in ids:
+ if i not in snapped:
+ snapped.add(i)
+ promoted.append(i)
+ return snapped, sorted(promoted)
+
+
+def amortized_costs(records: list, raw: dict[str, float]) -> dict[str, float]:
+ """Amortize cost within each shared-cache bucket.
+
+ A bucket is identified by ``(_cache_namespace, *_cache_subkey)``. The
+ subkey (a per-instance tuple, default ``()``) lets a class declare which
+ constructor params split its namespace into independent caches — e.g.
+ Barycenter caches per ``mode``, so its variants amortize per mode rather
+ than across all 16 of them.
+ """
+ groups: dict = defaultdict(list)
+ for _, _, _, identifier, spi in records:
+ groups[cache_bucket(spi)].append(identifier)
+ cost: dict[str, float] = {}
+ for key, ids in groups.items():
+ if key is None:
+ for i in ids:
+ cost[i] = raw[i]
+ else:
+ share = sum(raw[i] for i in ids) / len(ids)
+ for i in ids:
+ cost[i] = share
+ return cost
+
+
+def _dump_variant(params: dict | None) -> list[str]:
+ """Render one config variant (a param dict, or None for no-args) as YAML lines.
+
+ Returns a list of lines like ``["- estimator: kraskov", " prop_k: 4"]``. For
+ None (no-args SPI), returns ``["- {}"]`` — but that case shouldn't reach here
+ in normal use (it's handled at the class level via ``configs: null``).
+ """
+ if params is None or params == {}:
+ return ["- {}"]
+ text = yaml.dump([params], sort_keys=False, default_flow_style=False, indent=2).rstrip()
+ return text.splitlines()
+
+
+def _render_class_block(class_name: str, src_entry: dict,
+ kept: list, dropped: list) -> str:
+ """Render a class block: labels, dependencies, configs (kept), then commented dropped.
+
+ The class header is at the LEFT MARGIN (the caller indents under the module).
+ Returns a string with NO trailing newline.
+
+ Special cases:
+ - single no-args SPI (configs: null in source): kept=[None] -> configs: null
+ kept; kept=[] -> the whole class is fully dropped (caller comments it out).
+ - some kept, some dropped: kept variants emitted normally, dropped appended as
+ `` # - estimator: ...`` comments under the configs: list.
+ """
+ lines = [f"{class_name}:"]
+ labels = src_entry.get("labels")
+ if labels is not None:
+ lines.append(" labels:")
+ for lab in labels:
+ lines.append(f" - {lab}")
+ if "dependencies" in src_entry:
+ deps = src_entry["dependencies"]
+ if deps is None:
+ lines.append(" dependencies:")
+ else:
+ lines.append(" dependencies:")
+ for d in deps:
+ lines.append(f" - {d}")
+
+ # configs handling
+ if kept == [None]:
+ lines.append(" configs:")
+ else:
+ lines.append(" configs:")
+ for p in kept:
+ if p is None:
+ continue
+ for vl in _dump_variant(p):
+ lines.append(" " + vl)
+ for p in dropped:
+ if p is None:
+ # commented no-args means the class is fully dropped — handled by caller
+ lines.append(" # (no-args variant dropped)")
+ continue
+ for vl in _dump_variant(p):
+ lines.append(" # " + vl)
+ return "\n".join(lines)
+
+
+def emit_config(source_path: Path, records: list, kept_ids: set[str], header: str,
+ preserve_dropped: bool = True) -> str:
+ """Emit the cut YAML. If preserve_dropped, dropped variants/classes are kept
+ as commented blocks instead of being removed."""
+ source = yaml.safe_load(source_path.read_text())
+
+ # Group records by (module, class), preserving source order.
+ grouped: dict[tuple[str, str], list[tuple]] = {}
+ order: list[tuple[str, str]] = []
+ for module, cls, params, ident, _ in records:
+ key = (module, cls)
+ if key not in grouped:
+ grouped[key] = []
+ order.append(key)
+ grouped[key].append((params, ident))
+
+ out_lines: list[str] = [header.rstrip()]
+ current_module: str | None = None
+
+ for (module, cls) in order:
+ variants = grouped[(module, cls)]
+ kept = [p for p, i in variants if i in kept_ids]
+ dropped = [p for p, i in variants if i not in kept_ids]
+
+ if not preserve_dropped and not kept:
+ continue # drop the class entirely
+
+ if module != current_module:
+ if current_module is not None:
+ out_lines.append("")
+ out_lines.append(f"{module}:")
+ current_module = module
+
+ src_entry = source[module][cls]
+ if kept or not preserve_dropped:
+ # Some variants kept: render normally with kept + commented dropped.
+ block = _render_class_block(cls, src_entry, kept, dropped if preserve_dropped else [])
+ out_lines.append("\n".join(" " + l for l in block.splitlines()))
+ else:
+ # All variants dropped: render the full class block and comment every line.
+ block = _render_class_block(cls, src_entry, dropped, [])
+ out_lines.append("\n".join(" # " + l for l in block.splitlines()))
+ out_lines.append("") # blank line between class blocks
+
+ return "\n".join(out_lines) + "\n"
+
+
+def main(argv=None) -> int:
+ args = parse_args(argv)
+ cell = json.loads(args.bench_json.read_text())
+ if "spi_seconds" not in cell:
+ raise SystemExit(
+ f"{args.bench_json} is not a per-cell bench JSON "
+ "(no 'spi_seconds' at top level).")
+ if "error" in cell:
+ raise SystemExit(f"{args.bench_json} has an error entry: {cell['error']}")
+ raw_cell = {spi: v["mean"] for spi, v in cell["spi_seconds"].items()}
+ M, T, n_jobs = cell.get("M"), cell.get("T"), cell.get("n_jobs")
+
+ source_config = Path(resolve_config(args.config))
+ records = list(walk_spis(source_config))
+ ids = [r[3] for r in records]
+ missing = sorted(i for i in ids if i not in raw_cell)
+ if missing:
+ print(f"[cut] WARNING: {len(missing)} SPI(s) in config but not in bench JSON "
+ f"— kept unconditionally (cost 0): {', '.join(missing[:5])}"
+ f"{' ...' if len(missing) > 5 else ''}", file=sys.stderr)
+ raw = {i: raw_cell.get(i, 0.0) for i in ids}
+
+ cost = raw if args.mode == "raw" else amortized_costs(records, raw)
+ ranked = sorted(ids, key=lambda i: cost[i])
+ n_target = round(args.keep / 100 * len(ranked))
+ kept = set(ranked[:n_target])
+ kept_max = cost[ranked[n_target - 1]] if n_target else 0.0
+ drop_min = cost[ranked[n_target]] if n_target < len(ranked) else float("inf")
+
+ # Cache-aware snap: promote partial buckets to fully-kept. The kept count
+ # may exceed n_target by the size of the promoted partial buckets.
+ if args.mode == "amortized":
+ kept, promoted = snap_to_cache_buckets(records, kept)
+ else:
+ promoted = []
+ dropped = [i for i in ranked if i not in kept]
+
+ output = args.output or (
+ CONFIG_DIR / f"benchmarked_p{args.keep}{'_raw' if args.mode == 'raw' else ''}.yaml")
+
+ env = cell.get("environment") or {}
+ sha = env.get("pyspi_git_sha") or "?"
+ snap_note = (f"; +{len(promoted)} snapped from partial cache buckets"
+ if promoted else "")
+ header = (
+ f"# {Path(output).name}\n"
+ f"# Generated by bench/cut_config.py on {datetime.now():%Y-%m-%d}.\n"
+ f"# Source config : {_rel(source_config)}\n"
+ f"# Bench JSON : {args.bench_json.name} (pyspi {sha[:12]}, "
+ f"M={M} T={T} n_jobs={n_jobs})\n"
+ f"# Cost model : {args.mode}\n"
+ f"# Keep {args.keep}% : kept {len(kept)} / {len(ranked)} SPIs (target {n_target}{snap_note}), "
+ f"dropped {len(dropped)}.\n"
+ f"# Cutoff : fastest kept <= {kept_max:.3f}s ; slowest dropped >= "
+ f"{drop_min:.3f}s.\n#\n"
+ )
+ text = emit_config(source_config, records, kept, header,
+ preserve_dropped=not args.no_preserve_dropped)
+ if dropped:
+ text += "\n# --- DROPPED (slowest %d, %s cost) ---\n" % (len(dropped), args.mode)
+ text += "".join(f"# {cost[i]:9.3f}s {i}\n" for i in reversed(dropped))
+
+ Path(output).write_text(text)
+ print(f"[cut] {len(kept)}/{len(ranked)} SPIs kept ({args.mode}, M={M} T={T}) -> {output}",
+ file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/bench/physics/run_bench.pbs b/bench/physics/run_bench.pbs
new file mode 100644
index 00000000..225cd2ae
--- /dev/null
+++ b/bench/physics/run_bench.pbs
@@ -0,0 +1,42 @@
+#!/bin/bash
+#PBS -N bench_physics
+#PBS -j oe
+#PBS -l select=1:ncpus=16:mem=64gb
+#PBS -l walltime=168:00:00
+#PBS -J 1-5
+
+# WORKED EXAMPLE — copy this file, rename it, and adapt the #PBS lines to your
+# own cluster. It is the concrete configuration used on the USYD Physics PBS
+# Pro queue to produce the committed `physics_config_M*_T*_n1.json` cells:
+# the full config, repeats=1, n_jobs=1, one (M,T) cell per array task.
+#
+# Everything below just pins env vars and hands off to ../run_benchmark.pbs,
+# which holds the actual logic (see its header for the full option list).
+#
+# Submit from the repo root. Defaults here reproduce the M=64 row; the two
+# other committed rows are one override away:
+#
+# qsub bench/physics/run_bench.pbs # M=64 x 5 T, 168 h
+# M=32 qsub -l walltime=72:00:00 -v M bench/physics/run_bench.pbs
+# M=4,8,16 qsub -J 1-15 -l select=1:ncpus=12:mem=48gb -l walltime=24:00:00 \
+# -v M bench/physics/run_bench.pbs
+#
+# Mail is a qsub-line option, since #PBS directives are not shell-expanded:
+# qsub -m bea -M you@example.org bench/physics/run_bench.pbs
+
+set -euo pipefail
+
+export M="${M:-64}"
+export T="${T:-200,400,800,1600,3200}"
+export NJOBS="${NJOBS:-1}"
+export CONFIG="${CONFIG:-full}"
+export REPEATS="${REPEATS:-1}"
+export LABEL="${LABEL:-physics_config}" # prefix of the committed cell JSONs
+export PYSPI_DIR="${PYSPI_DIR:-${PBS_O_WORKDIR:-}}"
+if [[ -z "$PYSPI_DIR" ]]; then
+ echo "[ERROR] set PYSPI_DIR, or submit with qsub from the repo root." >&2
+ exit 1
+fi
+export VENV="${VENV-${PYSPI_DIR}/.venv}" # python comes from the venv, no modules
+
+exec bash "${PYSPI_DIR}/bench/run_benchmark.pbs"
diff --git a/bench/results/analysis/.gitignore b/bench/results/analysis/.gitignore
new file mode 100644
index 00000000..1c24e367
--- /dev/null
+++ b/bench/results/analysis/.gitignore
@@ -0,0 +1,6 @@
+# Derived artefacts of `python -m bench.analyse_cells` — regenerate in seconds
+# from the committed per-cell JSONs, so they are not tracked. The small,
+# human-readable summaries (report.md, scaling.csv, cell_summary.csv) are.
+long_costs.csv
+jaccard_p*.csv
+plot_*.png
diff --git a/bench/results/analysis/cell_summary.csv b/bench/results/analysis/cell_summary.csv
new file mode 100644
index 00000000..68203d52
--- /dev/null
+++ b/bench/results/analysis/cell_summary.csv
@@ -0,0 +1,23 @@
+M,T,n_spis,n_failed,cell_wall_s,sum_amortized_s,median_amortized_s,max_amortized_s
+4,200,328,3,150.254213,150.25362800000002,0.0019561666666666664,18.765107
+4,400,328,3,175.625577,175.625194,0.0027171666666666668,18.899627
+4,800,328,3,286.05051,286.050024,0.00448225,25.494713
+4,1600,328,3,858.66662,858.666058,0.007553000000000001,200.342171
+4,3200,328,3,4034.361641,4034.360906,0.013809,1568.648087
+8,200,328,3,579.375212,579.374793,0.007857041666666667,62.205317
+8,400,328,3,691.284194,691.283615,0.011526958333333332,64.05716699999999
+8,800,328,3,1239.040446,1239.039915,0.018125791666666665,119.496422
+8,1600,328,3,3741.294728,3741.2941419999997,0.0296045,938.484149
+8,3200,328,3,18764.37191,18764.371221999998,0.0519655,7362.72654
+16,200,328,3,2194.923346,2194.922808,0.03188370833333334,223.65483266666664
+16,400,328,3,2738.418311,2738.417848,0.04775854166666667,258.8238533333333
+16,800,328,3,4921.076783,4921.076363,0.066323875,505.800367
+16,1600,328,3,15792.320319,15792.319682999998,0.09021225,4082.481762
+32,200,328,3,6596.798325,6596.797766,0.13190958333333333,669.7466969999999
+32,400,328,3,9110.052897,9110.052458,0.18988479166666666,825.8840083333333
+32,800,328,3,18079.860344,18079.859751,0.1825265,2101.921963
+32,1600,328,3,61722.23588,61722.235310000004,0.298286,16607.164304
+64,200,328,3,28431.706236,28431.705638,0.5276671458333333,2949.597692
+64,400,328,3,35066.405799,35066.405214,0.762836,3249.333650666667
+64,800,328,3,70719.416278,70719.415622,0.7328950000000001,8541.198162
+64,1600,328,3,252318.376205,252318.37552099998,1.2468405,67401.352476
diff --git a/bench/results/analysis/dropped_spi_comparison.md b/bench/results/analysis/dropped_spi_comparison.md
new file mode 100644
index 00000000..b682fb92
--- /dev/null
+++ b/bench/results/analysis/dropped_spi_comparison.md
@@ -0,0 +1,199 @@
+# Dropped-SPI comparison across percentiles and anchors
+
+Cost = amortized walltime (group cache-amortized, per `cut_config.py --mode amortized`).
+
+Layout: for each percentile, three lists —
+(a) dropped at **both** anchors (= robust drop),
+(b) dropped **only at M=16,T=800** (cheap at M=32,T=1600),
+(c) dropped **only at M=32,T=1600** (cheap at M=16,T=800).
+
+## p80 (keep top 80% fastest — drop 66 SPIs)
+
+### dropped at BOTH anchors (65)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `hhg` | 505.80 | 16607.16 |
+| `gpfit_RBF` | 411.13 | 9505.94 |
+| `gpfit_DotProduct` | 257.96 | 6083.66 |
+| `mgcx_maxlag-10` | 342.69 | 5998.78 |
+| `dcorrx_maxlag-10` | 77.37 | 1881.63 |
+| `mgcx_maxlag-1` | 105.55 | 1810.04 |
+| `ccm_E-None_mean` | 292.74 | 1445.05 |
+| `ccm_E-1_mean` | 292.74 | 1445.05 |
+| `ccm_E-10_mean` | 292.74 | 1445.05 |
+| `ccm_E-None_max` | 292.74 | 1445.05 |
+| `ccm_E-1_max` | 292.74 | 1445.05 |
+| `ccm_E-None_diff` | 292.74 | 1445.05 |
+| `ccm_E-10_max` | 292.74 | 1445.05 |
+| `ccm_E-10_diff` | 292.74 | 1445.05 |
+| `ccm_E-1_diff` | 292.74 | 1445.05 |
+| `te_kraskov_NN-4_DCE_k-max-10_tau-max-4` | 31.35 | 428.69 |
+| `te_kraskov_NN-4_k-max-10_tau-max-4` | 28.30 | 403.06 |
+| `mgc` | 23.17 | 365.21 |
+| `dcorrx_maxlag-1` | 12.68 | 290.20 |
+| `cce_kernel_W-0.5` | 16.11 | 190.27 |
+| `bary-sq_dtw_mean` | 11.14 | 181.23 |
+| `bary_softdtw_max` | 11.14 | 181.23 |
+| `bary-sq_sgddtw_mean` | 11.14 | 181.23 |
+| `bary-sq_softdtw_max` | 11.14 | 181.23 |
+| `bary-sq_euclidean_mean` | 11.14 | 181.23 |
+| `bary_softdtw_mean` | 11.14 | 181.23 |
+| `bary_euclidean_max` | 11.14 | 181.23 |
+| `bary_dtw_mean` | 11.14 | 181.23 |
+| `bary_dtw_max` | 11.14 | 181.23 |
+| `bary-sq_euclidean_max` | 11.14 | 181.23 |
+| `bary-sq_sgddtw_max` | 11.14 | 181.23 |
+| `bary_sgddtw_mean` | 11.14 | 181.23 |
+| `bary-sq_softdtw_mean` | 11.14 | 181.23 |
+| `bary-sq_dtw_max` | 11.14 | 181.23 |
+| `bary_sgddtw_max` | 11.14 | 181.23 |
+| `bary_euclidean_mean` | 11.14 | 181.23 |
+| `hsic` | 6.79 | 129.78 |
+| `hsic_biased` | 6.84 | 126.02 |
+| `anm` | 5.52 | 122.34 |
+| `softdtw` | 7.92 | 118.82 |
+| `softdtw_constraint-itakura` | 7.75 | 118.80 |
+| `psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean` | 62.26 | 118.63 |
+| `psi_wavelet_mean_fs-1_fmin-0_fmax-0-5_mean` | 62.24 | 118.61 |
+| `softdtw_constraint-sakoe-chiba` | 7.93 | 118.13 |
+| `dcorr` | 3.56 | 87.11 |
+| `dcorr_biased` | 3.64 | 82.55 |
+| `te_kraskov_NN-4_DCE_k-1_kt-1_l-1_lt-1` | 7.30 | 73.19 |
+| `te_kraskov_NN-4_DCE_k-2_kt-1_l-1_lt-1` | 7.67 | 72.97 |
+| `xme_kernel_W-0.5_k10` | 5.07 | 60.11 |
+| `di_kernel_W-0.5` | 5.04 | 59.87 |
+| `si_kernel_W-0.5_k-1` | 4.34 | 50.31 |
+| `cce_kozachenko` | 4.04 | 48.52 |
+| `te_kraskov_NN-4_k-1_kt-1_l-1_lt-1` | 3.60 | 41.53 |
+| `lcss_constraint-itakura` | 1.91 | 25.66 |
+| `lcss_constraint-sakoe-chiba` | 1.92 | 23.56 |
+| `lcss` | 1.79 | 23.41 |
+| `xme_kozachenko_k10` | 1.75 | 23.34 |
+| `te_kernel_W-0.25_k-1` | 2.00 | 21.40 |
+| `cds` | 3.49 | 20.55 |
+| `tlmi_kraskov_NN-4` | 1.88 | 19.03 |
+| `tlmi_kraskov_NN-4_DCE` | 1.71 | 18.89 |
+| `ddtf_multitaper_max_fs-1_fmin-0_fmax-0-5` | 1.59 | 12.17 |
+| `ddtf_multitaper_max_fs-1_fmin-0-25_fmax-0-5` | 1.59 | 12.17 |
+| `ddtf_multitaper_mean_fs-1_fmin-0-25_fmax-0-5` | 1.59 | 12.17 |
+| `ddtf_multitaper_max_fs-1_fmin-0_fmax-0-25` | 1.59 | 12.17 |
+
+### dropped ONLY at M=16,T=800 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `ddtf_multitaper_mean_fs-1_fmin-0_fmax-0-25` | 1.59 | 12.17 |
+
+### dropped ONLY at M=32,T=1600 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `xme_kernel_W-0.5_k1` | 1.11 | 12.38 |
+
+## p90 (keep top 90% fastest — drop 33 SPIs)
+
+### dropped at BOTH anchors (31)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `hhg` | 505.80 | 16607.16 |
+| `gpfit_RBF` | 411.13 | 9505.94 |
+| `gpfit_DotProduct` | 257.96 | 6083.66 |
+| `mgcx_maxlag-10` | 342.69 | 5998.78 |
+| `dcorrx_maxlag-10` | 77.37 | 1881.63 |
+| `mgcx_maxlag-1` | 105.55 | 1810.04 |
+| `ccm_E-None_mean` | 292.74 | 1445.05 |
+| `ccm_E-1_mean` | 292.74 | 1445.05 |
+| `ccm_E-10_mean` | 292.74 | 1445.05 |
+| `ccm_E-None_max` | 292.74 | 1445.05 |
+| `ccm_E-1_max` | 292.74 | 1445.05 |
+| `ccm_E-None_diff` | 292.74 | 1445.05 |
+| `ccm_E-10_max` | 292.74 | 1445.05 |
+| `ccm_E-10_diff` | 292.74 | 1445.05 |
+| `ccm_E-1_diff` | 292.74 | 1445.05 |
+| `te_kraskov_NN-4_DCE_k-max-10_tau-max-4` | 31.35 | 428.69 |
+| `te_kraskov_NN-4_k-max-10_tau-max-4` | 28.30 | 403.06 |
+| `mgc` | 23.17 | 365.21 |
+| `dcorrx_maxlag-1` | 12.68 | 290.20 |
+| `cce_kernel_W-0.5` | 16.11 | 190.27 |
+| `bary_softdtw_max` | 11.14 | 181.23 |
+| `bary-sq_dtw_mean` | 11.14 | 181.23 |
+| `bary-sq_sgddtw_mean` | 11.14 | 181.23 |
+| `bary-sq_softdtw_max` | 11.14 | 181.23 |
+| `bary-sq_euclidean_mean` | 11.14 | 181.23 |
+| `bary_softdtw_mean` | 11.14 | 181.23 |
+| `bary-sq_euclidean_max` | 11.14 | 181.23 |
+| `bary-sq_sgddtw_max` | 11.14 | 181.23 |
+| `bary-sq_softdtw_mean` | 11.14 | 181.23 |
+| `bary-sq_dtw_max` | 11.14 | 181.23 |
+| `bary_sgddtw_max` | 11.14 | 181.23 |
+
+### dropped ONLY at M=16,T=800 (2)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean` | 62.26 | 118.63 |
+| `psi_wavelet_mean_fs-1_fmin-0_fmax-0-5_mean` | 62.24 | 118.61 |
+
+### dropped ONLY at M=32,T=1600 (2)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `bary_dtw_max` | 11.14 | 181.23 |
+| `bary_sgddtw_mean` | 11.14 | 181.23 |
+
+## p95 (keep top 95% fastest — drop 16 SPIs)
+
+### dropped at BOTH anchors (15)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `hhg` | 505.80 | 16607.16 |
+| `gpfit_RBF` | 411.13 | 9505.94 |
+| `gpfit_DotProduct` | 257.96 | 6083.66 |
+| `mgcx_maxlag-10` | 342.69 | 5998.78 |
+| `dcorrx_maxlag-10` | 77.37 | 1881.63 |
+| `mgcx_maxlag-1` | 105.55 | 1810.04 |
+| `ccm_E-None_mean` | 292.74 | 1445.05 |
+| `ccm_E-None_diff` | 292.74 | 1445.05 |
+| `ccm_E-10_max` | 292.74 | 1445.05 |
+| `ccm_E-1_diff` | 292.74 | 1445.05 |
+| `ccm_E-10_diff` | 292.74 | 1445.05 |
+| `ccm_E-1_mean` | 292.74 | 1445.05 |
+| `ccm_E-None_max` | 292.74 | 1445.05 |
+| `ccm_E-1_max` | 292.74 | 1445.05 |
+| `ccm_E-10_mean` | 292.74 | 1445.05 |
+
+### dropped ONLY at M=16,T=800 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean` | 62.26 | 118.63 |
+
+### dropped ONLY at M=32,T=1600 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `te_kraskov_NN-4_DCE_k-max-10_tau-max-4` | 31.35 | 428.69 |
+
+## p99 (keep top 99% fastest — drop 3 SPIs)
+
+### dropped at BOTH anchors (2)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `hhg` | 505.80 | 16607.16 |
+| `gpfit_RBF` | 411.13 | 9505.94 |
+
+### dropped ONLY at M=16,T=800 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `mgcx_maxlag-10` | 342.69 | 5998.78 |
+
+### dropped ONLY at M=32,T=1600 (1)
+
+| identifier | cost@M16T800 (s) | cost@M32T1600 (s) |
+|---|---:|---:|
+| `gpfit_DotProduct` | 257.96 | 6083.66 |
diff --git a/bench/results/analysis/report.md b/bench/results/analysis/report.md
new file mode 100644
index 00000000..76543bdf
--- /dev/null
+++ b/bench/results/analysis/report.md
@@ -0,0 +1,168 @@
+# Bench-cell analysis
+
+Cells analysed: 22. Per cell: 328 SPIs.
+
+## Cell summary (sorted by M, T)
+| M | T | n_spis | n_failed | cell_wall_s | sum_amortized_s | median_amortized_s | max_amortized_s |
+|-------|---------|--------|----------|-------------|-----------------|--------------------|-----------------|
+| 4.00 | 200.00 | 328.00 | 3.00 | 150.25 | 150.25 | 0.00 | 18.77 |
+| 4.00 | 400.00 | 328.00 | 3.00 | 175.63 | 175.63 | 0.00 | 18.90 |
+| 4.00 | 800.00 | 328.00 | 3.00 | 286.05 | 286.05 | 0.00 | 25.49 |
+| 4.00 | 1600.00 | 328.00 | 3.00 | 858.67 | 858.67 | 0.01 | 200.34 |
+| 4.00 | 3200.00 | 328.00 | 3.00 | 4034.36 | 4034.36 | 0.01 | 1568.65 |
+| 8.00 | 200.00 | 328.00 | 3.00 | 579.38 | 579.37 | 0.01 | 62.21 |
+| 8.00 | 400.00 | 328.00 | 3.00 | 691.28 | 691.28 | 0.01 | 64.06 |
+| 8.00 | 800.00 | 328.00 | 3.00 | 1239.04 | 1239.04 | 0.02 | 119.50 |
+| 8.00 | 1600.00 | 328.00 | 3.00 | 3741.29 | 3741.29 | 0.03 | 938.48 |
+| 8.00 | 3200.00 | 328.00 | 3.00 | 18764.37 | 18764.37 | 0.05 | 7362.73 |
+| 16.00 | 200.00 | 328.00 | 3.00 | 2194.92 | 2194.92 | 0.03 | 223.65 |
+| 16.00 | 400.00 | 328.00 | 3.00 | 2738.42 | 2738.42 | 0.05 | 258.82 |
+| 16.00 | 800.00 | 328.00 | 3.00 | 4921.08 | 4921.08 | 0.07 | 505.80 |
+| 16.00 | 1600.00 | 328.00 | 3.00 | 15792.32 | 15792.32 | 0.09 | 4082.48 |
+| 32.00 | 200.00 | 328.00 | 3.00 | 6596.80 | 6596.80 | 0.13 | 669.75 |
+| 32.00 | 400.00 | 328.00 | 3.00 | 9110.05 | 9110.05 | 0.19 | 825.88 |
+| 32.00 | 800.00 | 328.00 | 3.00 | 18079.86 | 18079.86 | 0.18 | 2101.92 |
+| 32.00 | 1600.00 | 328.00 | 3.00 | 61722.24 | 61722.24 | 0.30 | 16607.16 |
+| 64.00 | 200.00 | 328.00 | 3.00 | 28431.71 | 28431.71 | 0.53 | 2949.60 |
+| 64.00 | 400.00 | 328.00 | 3.00 | 35066.41 | 35066.41 | 0.76 | 3249.33 |
+| 64.00 | 800.00 | 328.00 | 3.00 | 70719.42 | 70719.42 | 0.73 | 8541.20 |
+| 64.00 | 1600.00 | 328.00 | 3.00 | 252318.38 | 252318.38 | 1.25 | 67401.35 |
+
+## Jaccard kept-set similarity @ p80
+Off-diagonal: mean=0.930 min=0.832 max=1.000
+
+| idx | M4T200 | M4T400 | M4T800 | M4T1600 | M4T3200 | M8T200 | M8T400 | M8T800 | M8T1600 | M8T3200 | M16T200 | M16T400 | M16T800 | M16T1600 | M32T200 | M32T400 | M32T800 | M32T1600 | M64T200 | M64T400 | M64T800 | M64T1600 |
+|----------|--------|--------|--------|---------|---------|--------|--------|--------|---------|---------|---------|---------|---------|----------|---------|---------|---------|----------|---------|---------|---------|----------|
+| M4T200 | 1.000 | 0.941 | 0.926 | 0.885 | 0.858 | 0.941 | 0.941 | 0.926 | 0.899 | 0.871 | 0.941 | 0.941 | 0.926 | 0.892 | 0.948 | 0.948 | 0.899 | 0.899 | 0.941 | 0.941 | 0.926 | 0.899 |
+| M4T400 | 0.941 | 1.000 | 0.948 | 0.878 | 0.852 | 0.985 | 1.000 | 0.948 | 0.892 | 0.865 | 0.985 | 1.000 | 0.948 | 0.885 | 0.977 | 0.992 | 0.892 | 0.892 | 0.985 | 1.000 | 0.948 | 0.892 |
+| M4T800 | 0.926 | 0.948 | 1.000 | 0.926 | 0.899 | 0.934 | 0.948 | 1.000 | 0.941 | 0.912 | 0.934 | 0.948 | 1.000 | 0.934 | 0.926 | 0.948 | 0.941 | 0.941 | 0.934 | 0.948 | 1.000 | 0.941 |
+| M4T1600 | 0.885 | 0.878 | 0.926 | 1.000 | 0.970 | 0.865 | 0.878 | 0.926 | 0.985 | 0.977 | 0.865 | 0.878 | 0.926 | 0.992 | 0.858 | 0.878 | 0.985 | 0.985 | 0.865 | 0.878 | 0.926 | 0.985 |
+| M4T3200 | 0.858 | 0.852 | 0.899 | 0.970 | 1.000 | 0.839 | 0.852 | 0.899 | 0.955 | 0.977 | 0.839 | 0.852 | 0.899 | 0.963 | 0.832 | 0.852 | 0.955 | 0.955 | 0.839 | 0.852 | 0.899 | 0.955 |
+| M8T200 | 0.941 | 0.985 | 0.934 | 0.865 | 0.839 | 1.000 | 0.985 | 0.934 | 0.878 | 0.852 | 1.000 | 0.985 | 0.934 | 0.871 | 0.992 | 0.977 | 0.878 | 0.878 | 1.000 | 0.985 | 0.934 | 0.878 |
+| M8T400 | 0.941 | 1.000 | 0.948 | 0.878 | 0.852 | 0.985 | 1.000 | 0.948 | 0.892 | 0.865 | 0.985 | 1.000 | 0.948 | 0.885 | 0.977 | 0.992 | 0.892 | 0.892 | 0.985 | 1.000 | 0.948 | 0.892 |
+| M8T800 | 0.926 | 0.948 | 1.000 | 0.926 | 0.899 | 0.934 | 0.948 | 1.000 | 0.941 | 0.912 | 0.934 | 0.948 | 1.000 | 0.934 | 0.926 | 0.948 | 0.941 | 0.941 | 0.934 | 0.948 | 1.000 | 0.941 |
+| M8T1600 | 0.899 | 0.892 | 0.941 | 0.985 | 0.955 | 0.878 | 0.892 | 0.941 | 1.000 | 0.970 | 0.878 | 0.892 | 0.941 | 0.992 | 0.871 | 0.892 | 1.000 | 1.000 | 0.878 | 0.892 | 0.941 | 1.000 |
+| M8T3200 | 0.871 | 0.865 | 0.912 | 0.977 | 0.977 | 0.852 | 0.865 | 0.912 | 0.970 | 1.000 | 0.852 | 0.865 | 0.912 | 0.977 | 0.845 | 0.865 | 0.970 | 0.970 | 0.852 | 0.865 | 0.912 | 0.970 |
+| M16T200 | 0.941 | 0.985 | 0.934 | 0.865 | 0.839 | 1.000 | 0.985 | 0.934 | 0.878 | 0.852 | 1.000 | 0.985 | 0.934 | 0.871 | 0.992 | 0.977 | 0.878 | 0.878 | 1.000 | 0.985 | 0.934 | 0.878 |
+| M16T400 | 0.941 | 1.000 | 0.948 | 0.878 | 0.852 | 0.985 | 1.000 | 0.948 | 0.892 | 0.865 | 0.985 | 1.000 | 0.948 | 0.885 | 0.977 | 0.992 | 0.892 | 0.892 | 0.985 | 1.000 | 0.948 | 0.892 |
+| M16T800 | 0.926 | 0.948 | 1.000 | 0.926 | 0.899 | 0.934 | 0.948 | 1.000 | 0.941 | 0.912 | 0.934 | 0.948 | 1.000 | 0.934 | 0.926 | 0.948 | 0.941 | 0.941 | 0.934 | 0.948 | 1.000 | 0.941 |
+| M16T1600 | 0.892 | 0.885 | 0.934 | 0.992 | 0.963 | 0.871 | 0.885 | 0.934 | 0.992 | 0.977 | 0.871 | 0.885 | 0.934 | 1.000 | 0.865 | 0.885 | 0.992 | 0.992 | 0.871 | 0.885 | 0.934 | 0.992 |
+| M32T200 | 0.948 | 0.977 | 0.926 | 0.858 | 0.832 | 0.992 | 0.977 | 0.926 | 0.871 | 0.845 | 0.992 | 0.977 | 0.926 | 0.865 | 1.000 | 0.977 | 0.871 | 0.871 | 0.992 | 0.977 | 0.926 | 0.871 |
+| M32T400 | 0.948 | 0.992 | 0.948 | 0.878 | 0.852 | 0.977 | 0.992 | 0.948 | 0.892 | 0.865 | 0.977 | 0.992 | 0.948 | 0.885 | 0.977 | 1.000 | 0.892 | 0.892 | 0.977 | 0.992 | 0.948 | 0.892 |
+| M32T800 | 0.899 | 0.892 | 0.941 | 0.985 | 0.955 | 0.878 | 0.892 | 0.941 | 1.000 | 0.970 | 0.878 | 0.892 | 0.941 | 0.992 | 0.871 | 0.892 | 1.000 | 1.000 | 0.878 | 0.892 | 0.941 | 1.000 |
+| M32T1600 | 0.899 | 0.892 | 0.941 | 0.985 | 0.955 | 0.878 | 0.892 | 0.941 | 1.000 | 0.970 | 0.878 | 0.892 | 0.941 | 0.992 | 0.871 | 0.892 | 1.000 | 1.000 | 0.878 | 0.892 | 0.941 | 1.000 |
+| M64T200 | 0.941 | 0.985 | 0.934 | 0.865 | 0.839 | 1.000 | 0.985 | 0.934 | 0.878 | 0.852 | 1.000 | 0.985 | 0.934 | 0.871 | 0.992 | 0.977 | 0.878 | 0.878 | 1.000 | 0.985 | 0.934 | 0.878 |
+| M64T400 | 0.941 | 1.000 | 0.948 | 0.878 | 0.852 | 0.985 | 1.000 | 0.948 | 0.892 | 0.865 | 0.985 | 1.000 | 0.948 | 0.885 | 0.977 | 0.992 | 0.892 | 0.892 | 0.985 | 1.000 | 0.948 | 0.892 |
+| M64T800 | 0.926 | 0.948 | 1.000 | 0.926 | 0.899 | 0.934 | 0.948 | 1.000 | 0.941 | 0.912 | 0.934 | 0.948 | 1.000 | 0.934 | 0.926 | 0.948 | 0.941 | 0.941 | 0.934 | 0.948 | 1.000 | 0.941 |
+| M64T1600 | 0.899 | 0.892 | 0.941 | 0.985 | 0.955 | 0.878 | 0.892 | 0.941 | 1.000 | 0.970 | 0.878 | 0.892 | 0.941 | 0.992 | 0.871 | 0.892 | 1.000 | 1.000 | 0.878 | 0.892 | 0.941 | 1.000 |
+
+## Jaccard kept-set similarity @ p90
+Off-diagonal: mean=0.976 min=0.941 max=1.000
+
+| idx | M4T200 | M4T400 | M4T800 | M4T1600 | M4T3200 | M8T200 | M8T400 | M8T800 | M8T1600 | M8T3200 | M16T200 | M16T400 | M16T800 | M16T1600 | M32T200 | M32T400 | M32T800 | M32T1600 | M64T200 | M64T400 | M64T800 | M64T1600 |
+|----------|--------|--------|--------|---------|---------|--------|--------|--------|---------|---------|---------|---------|---------|----------|---------|---------|---------|----------|---------|---------|---------|----------|
+| M4T200 | 1.000 | 0.954 | 0.947 | 0.954 | 0.954 | 0.947 | 0.954 | 0.947 | 0.954 | 0.954 | 0.947 | 0.954 | 0.947 | 0.954 | 0.954 | 0.954 | 0.947 | 0.954 | 0.947 | 0.954 | 0.947 | 0.941 |
+| M4T400 | 0.954 | 1.000 | 0.987 | 0.980 | 0.980 | 0.967 | 0.993 | 0.993 | 0.980 | 0.980 | 0.967 | 0.993 | 0.993 | 0.980 | 0.967 | 1.000 | 0.993 | 0.980 | 0.967 | 0.993 | 0.993 | 0.973 |
+| M4T800 | 0.947 | 0.987 | 1.000 | 0.993 | 0.993 | 0.960 | 0.980 | 0.993 | 0.993 | 0.993 | 0.960 | 0.980 | 0.993 | 0.993 | 0.960 | 0.987 | 0.993 | 0.993 | 0.960 | 0.980 | 0.993 | 0.980 |
+| M4T1600 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M4T3200 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M8T200 | 0.947 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.993 | 0.967 | 0.960 | 0.954 | 1.000 | 0.967 | 0.960 | 0.947 |
+| M8T400 | 0.954 | 0.993 | 0.980 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.967 | 0.993 | 0.987 | 0.973 | 0.967 | 1.000 | 0.987 | 0.967 |
+| M8T800 | 0.947 | 0.993 | 0.993 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.960 | 0.993 | 1.000 | 0.987 | 0.960 | 0.987 | 1.000 | 0.980 |
+| M8T1600 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M8T3200 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M16T200 | 0.947 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.993 | 0.967 | 0.960 | 0.954 | 1.000 | 0.967 | 0.960 | 0.947 |
+| M16T400 | 0.954 | 0.993 | 0.980 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.967 | 0.993 | 0.987 | 0.973 | 0.967 | 1.000 | 0.987 | 0.967 |
+| M16T800 | 0.947 | 0.993 | 0.993 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.960 | 0.993 | 1.000 | 0.987 | 0.960 | 0.987 | 1.000 | 0.980 |
+| M16T1600 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M32T200 | 0.954 | 0.967 | 0.960 | 0.954 | 0.954 | 0.993 | 0.967 | 0.960 | 0.954 | 0.954 | 0.993 | 0.967 | 0.960 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.993 | 0.967 | 0.960 | 0.947 |
+| M32T400 | 0.954 | 1.000 | 0.987 | 0.980 | 0.980 | 0.967 | 0.993 | 0.993 | 0.980 | 0.980 | 0.967 | 0.993 | 0.993 | 0.980 | 0.967 | 1.000 | 0.993 | 0.980 | 0.967 | 0.993 | 0.993 | 0.973 |
+| M32T800 | 0.947 | 0.993 | 0.993 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.960 | 0.993 | 1.000 | 0.987 | 0.960 | 0.987 | 1.000 | 0.980 |
+| M32T1600 | 0.954 | 0.980 | 0.993 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 1.000 | 0.954 | 0.973 | 0.987 | 1.000 | 0.954 | 0.980 | 0.987 | 1.000 | 0.954 | 0.973 | 0.987 | 0.987 |
+| M64T200 | 0.947 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.954 | 1.000 | 0.967 | 0.960 | 0.954 | 0.993 | 0.967 | 0.960 | 0.954 | 1.000 | 0.967 | 0.960 | 0.947 |
+| M64T400 | 0.954 | 0.993 | 0.980 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.973 | 0.967 | 1.000 | 0.987 | 0.973 | 0.967 | 0.993 | 0.987 | 0.973 | 0.967 | 1.000 | 0.987 | 0.967 |
+| M64T800 | 0.947 | 0.993 | 0.993 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.987 | 0.960 | 0.987 | 1.000 | 0.987 | 0.960 | 0.993 | 1.000 | 0.987 | 0.960 | 0.987 | 1.000 | 0.980 |
+| M64T1600 | 0.941 | 0.973 | 0.980 | 0.987 | 0.987 | 0.947 | 0.967 | 0.980 | 0.987 | 0.987 | 0.947 | 0.967 | 0.980 | 0.987 | 0.947 | 0.973 | 0.980 | 0.987 | 0.947 | 0.967 | 0.980 | 1.000 |
+
+## Jaccard kept-set similarity @ p95
+Off-diagonal: mean=0.989 min=0.956 max=1.000
+
+| idx | M4T200 | M4T400 | M4T800 | M4T1600 | M4T3200 | M8T200 | M8T400 | M8T800 | M8T1600 | M8T3200 | M16T200 | M16T400 | M16T800 | M16T1600 | M32T200 | M32T400 | M32T800 | M32T1600 | M64T200 | M64T400 | M64T800 | M64T1600 |
+|----------|--------|--------|--------|---------|---------|--------|--------|--------|---------|---------|---------|---------|---------|----------|---------|---------|---------|----------|---------|---------|---------|----------|
+| M4T200 | 1.000 | 0.987 | 0.987 | 0.981 | 0.956 | 0.987 | 0.987 | 0.987 | 0.975 | 0.956 | 0.987 | 0.987 | 0.981 | 0.975 | 0.987 | 0.987 | 0.975 | 0.975 | 0.987 | 0.987 | 0.975 | 0.975 |
+| M4T400 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M4T800 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M4T1600 | 0.981 | 0.994 | 0.994 | 1.000 | 0.975 | 0.994 | 0.994 | 0.994 | 0.987 | 0.975 | 0.994 | 0.994 | 0.994 | 0.987 | 0.994 | 0.994 | 0.987 | 0.987 | 0.994 | 0.994 | 0.987 | 0.987 |
+| M4T3200 | 0.956 | 0.968 | 0.968 | 0.975 | 1.000 | 0.968 | 0.968 | 0.968 | 0.981 | 1.000 | 0.968 | 0.968 | 0.975 | 0.981 | 0.968 | 0.968 | 0.981 | 0.981 | 0.968 | 0.968 | 0.981 | 0.981 |
+| M8T200 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M8T400 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M8T800 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M8T1600 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+| M8T3200 | 0.956 | 0.968 | 0.968 | 0.975 | 1.000 | 0.968 | 0.968 | 0.968 | 0.981 | 1.000 | 0.968 | 0.968 | 0.975 | 0.981 | 0.968 | 0.968 | 0.981 | 0.981 | 0.968 | 0.968 | 0.981 | 0.981 |
+| M16T200 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M16T400 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M16T800 | 0.981 | 0.994 | 0.994 | 0.994 | 0.975 | 0.994 | 0.994 | 0.994 | 0.994 | 0.975 | 0.994 | 0.994 | 1.000 | 0.994 | 0.994 | 0.994 | 0.994 | 0.994 | 0.994 | 0.994 | 0.994 | 0.994 |
+| M16T1600 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+| M32T200 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M32T400 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M32T800 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+| M32T1600 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+| M64T200 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M64T400 | 0.987 | 1.000 | 1.000 | 0.994 | 0.968 | 1.000 | 1.000 | 1.000 | 0.987 | 0.968 | 1.000 | 1.000 | 0.994 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 |
+| M64T800 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+| M64T1600 | 0.975 | 0.987 | 0.987 | 0.987 | 0.981 | 0.987 | 0.987 | 0.987 | 1.000 | 0.981 | 0.987 | 0.987 | 0.994 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 | 0.987 | 0.987 | 1.000 | 1.000 |
+
+## Where does cumulative cost cross 50%, 80%, 90%, 95% of total?
+| M | T | keep_for_50pct_cost | keep_for_80pct_cost | keep_for_90pct_cost | keep_for_95pct_cost |
+|-------|---------|---------------------|---------------------|---------------------|---------------------|
+| 4.00 | 200.00 | 98.50 | 99.70 | 100.00 | 100.00 |
+| 4.00 | 400.00 | 98.50 | 99.70 | 100.00 | 100.00 |
+| 4.00 | 800.00 | 97.90 | 99.40 | 99.70 | 100.00 |
+| 4.00 | 1600.00 | 99.10 | 100.00 | 100.00 | 100.00 |
+| 4.00 | 3200.00 | 99.70 | 100.00 | 100.00 | 100.00 |
+| 8.00 | 200.00 | 98.80 | 99.70 | 100.00 | 100.00 |
+| 8.00 | 400.00 | 98.50 | 99.40 | 99.70 | 100.00 |
+| 8.00 | 800.00 | 98.20 | 99.40 | 99.70 | 100.00 |
+| 8.00 | 1600.00 | 99.10 | 100.00 | 100.00 | 100.00 |
+| 8.00 | 3200.00 | 99.70 | 100.00 | 100.00 | 100.00 |
+| 16.00 | 200.00 | 98.80 | 99.70 | 100.00 | 100.00 |
+| 16.00 | 400.00 | 98.50 | 99.40 | 99.70 | 100.00 |
+| 16.00 | 800.00 | 98.20 | 99.40 | 100.00 | 100.00 |
+| 16.00 | 1600.00 | 99.10 | 100.00 | 100.00 | 100.00 |
+| 32.00 | 200.00 | 98.80 | 99.70 | 100.00 | 100.00 |
+| 32.00 | 400.00 | 98.50 | 99.40 | 99.70 | 100.00 |
+| 32.00 | 800.00 | 98.20 | 99.70 | 100.00 | 100.00 |
+| 32.00 | 1600.00 | 99.40 | 100.00 | 100.00 | 100.00 |
+| 64.00 | 200.00 | 98.80 | 99.70 | 100.00 | 100.00 |
+| 64.00 | 400.00 | 98.50 | 99.40 | 99.70 | 100.00 |
+| 64.00 | 800.00 | 98.20 | 99.70 | 100.00 | 100.00 |
+| 64.00 | 1600.00 | 99.40 | 100.00 | 100.00 | 100.00 |
+
+## Top 25 SPIs by amortized cost at the largest cell
+| identifier | intercept | p_M | q_T | log_resid_std | n_points | amortized_at_largest |
+|----------------------------------------|-----------|-------|-------|---------------|----------|----------------------|
+| hhg | -19.124 | 2.090 | 2.924 | 0.048 | 22 | 67401.352 |
+| gpfit_RBF | -15.510 | 2.069 | 2.366 | 0.051 | 22 | 39346.129 |
+| gpfit_DotProduct | -16.126 | 2.051 | 2.398 | 0.058 | 22 | 25267.746 |
+| mgcx_maxlag-10 | -13.163 | 2.033 | 2.005 | 0.118 | 22 | 24608.105 |
+| dcorrx_maxlag-10 | -17.479 | 2.043 | 2.425 | 0.101 | 22 | 7606.890 |
+| mgcx_maxlag-1 | -14.167 | 2.035 | 1.979 | 0.117 | 22 | 7460.018 |
+| ccm_E-10_max | -2.970 | 1.924 | 0.522 | 0.194 | 22 | 7325.626 |
+| ccm_E-10_diff | -2.970 | 1.924 | 0.522 | 0.194 | 22 | 7325.626 |
+| ccm_E-10_mean | -2.970 | 1.924 | 0.522 | 0.194 | 22 | 7325.626 |
+| ccm_E-None_max | -3.075 | 1.929 | 0.536 | 0.186 | 22 | 7266.258 |
+| ccm_E-None_diff | -3.075 | 1.929 | 0.536 | 0.186 | 22 | 7266.258 |
+| ccm_E-None_mean | -3.075 | 1.929 | 0.536 | 0.186 | 22 | 7266.258 |
+| ccm_E-1_mean | -0.994 | 1.993 | 0.109 | 0.087 | 22 | 3181.737 |
+| ccm_E-1_max | -0.994 | 1.993 | 0.109 | 0.087 | 22 | 3181.737 |
+| ccm_E-1_diff | -0.994 | 1.993 | 0.109 | 0.087 | 22 | 3181.737 |
+| bary-sq_softdtw_mean | -13.567 | 1.982 | 1.776 | 0.181 | 22 | 2742.749 |
+| bary-sq_softdtw_max | -13.567 | 1.982 | 1.776 | 0.181 | 22 | 2742.749 |
+| bary_softdtw_max | -13.567 | 1.982 | 1.776 | 0.181 | 22 | 2742.749 |
+| bary_softdtw_mean | -13.567 | 1.982 | 1.776 | 0.181 | 22 | 2742.749 |
+| te_kraskov_NN-4_DCE_k-max-10_tau-max-4 | -13.356 | 2.094 | 1.643 | 0.051 | 22 | 1728.907 |
+| te_kraskov_NN-4_k-max-10_tau-max-4 | -13.909 | 2.098 | 1.708 | 0.051 | 22 | 1623.883 |
+| mgc | -15.412 | 2.039 | 1.933 | 0.117 | 22 | 1460.734 |
+| dcorrx_maxlag-1 | -18.291 | 2.025 | 2.288 | 0.108 | 22 | 1158.861 |
+| cce_kernel_W-0.5 | -13.152 | 2.095 | 1.508 | 0.045 | 22 | 775.154 |
+| anm | -15.777 | 2.004 | 1.837 | 0.310 | 22 | 561.959 |
diff --git a/bench/results/analysis/scaling.csv b/bench/results/analysis/scaling.csv
new file mode 100644
index 00000000..7e38b802
--- /dev/null
+++ b/bench/results/analysis/scaling.csv
@@ -0,0 +1,281 @@
+identifier,intercept,p_M,q_T,log_resid_std,n_points,amortized_at_largest
+hhg,-19.124209199103625,2.089972336358151,2.9243898314194587,0.048104060761493636,22,67401.352476
+gpfit_RBF,-15.509774423071581,2.0694943111872095,2.3658984806714227,0.05100191625895968,22,39346.129334
+gpfit_DotProduct,-16.12613341943782,2.0512453458003814,2.3980530627180507,0.05846570669369852,22,25267.746139
+mgcx_maxlag-10,-13.162895445495055,2.033189431954407,2.0049301575569953,0.1178101505871712,22,24608.104863
+dcorrx_maxlag-10,-17.478775948828638,2.0427727579462793,2.4246308697496324,0.10111058735852087,22,7606.89045
+mgcx_maxlag-1,-14.166654791186568,2.035270006477159,1.9794220884382472,0.11709922623215285,22,7460.018333
+ccm_E-10_max,-2.9703025044573876,1.9237008348113185,0.5223123229498481,0.19401342493287738,22,7325.626230999999
+ccm_E-10_diff,-2.9703025044573876,1.9237008348113185,0.5223123229498481,0.19401342493287738,22,7325.626230999999
+ccm_E-10_mean,-2.9703025044573876,1.9237008348113185,0.5223123229498481,0.19401342493287738,22,7325.626230999999
+ccm_E-None_max,-3.0749831995338366,1.9286163513921084,0.5357625130148309,0.18554006629440517,22,7266.258055333333
+ccm_E-None_diff,-3.0749831995338366,1.9286163513921084,0.5357625130148309,0.18554006629440517,22,7266.258055333333
+ccm_E-None_mean,-3.0749831995338366,1.9286163513921084,0.5357625130148309,0.18554006629440517,22,7266.258055333333
+ccm_E-1_mean,-0.9943346093522715,1.9926139051936334,0.10940077021691867,0.08696469405555503,22,3181.7368600000004
+ccm_E-1_max,-0.9943346093522715,1.9926139051936334,0.10940077021691867,0.08696469405555503,22,3181.7368600000004
+ccm_E-1_diff,-0.9943346093522715,1.9926139051936334,0.10940077021691867,0.08696469405555503,22,3181.7368600000004
+bary-sq_softdtw_mean,-13.566615232931929,1.9821445742424209,1.7764406584022405,0.18065912899432682,22,2742.74929275
+bary-sq_softdtw_max,-13.566615232931929,1.9821445742424209,1.7764406584022405,0.18065912899432682,22,2742.74929275
+bary_softdtw_max,-13.566615232931929,1.9821445742424209,1.7764406584022405,0.18065912899432682,22,2742.74929275
+bary_softdtw_mean,-13.566615232931929,1.9821445742424209,1.7764406584022405,0.18065912899432682,22,2742.74929275
+te_kraskov_NN-4_DCE_k-max-10_tau-max-4,-13.356290643624794,2.094457621670547,1.6430830753325947,0.050941378626326195,22,1728.907096
+te_kraskov_NN-4_k-max-10_tau-max-4,-13.909051579433731,2.0983536784469026,1.7076476176683109,0.05089424641068705,22,1623.882694
+mgc,-15.411705343180516,2.0393383503319074,1.933190092886124,0.1170318451317858,22,1460.734278
+dcorrx_maxlag-1,-18.29113596896621,2.025494009520204,2.288156046551573,0.1075161019410464,22,1158.8609
+cce_kernel_W-0.5,-13.152202254580668,2.0950637270369934,1.5080293002801084,0.044662462555476845,22,775.154042
+anm,-15.777299004787317,2.0035186258753224,1.8369742978926717,0.3104965216031806,22,561.959162
+hsic,-17.52074162711904,2.0333645711935002,2.078868985647538,0.09706967343294719,22,524.311783
+hsic_biased,-17.537959758499046,2.0359218549369675,2.0781881788339454,0.09544193892273278,22,507.338614
+softdtw_constraint-sakoe-chiba,-16.107479527362702,2.0692567636502237,1.8598678309962384,0.048427740057491925,22,477.888664
+softdtw,-14.598777149725068,1.935702890443243,1.697255109115219,0.3402844113639285,22,477.670956
+softdtw_constraint-itakura,-16.06329879709289,2.06547407785759,1.8549498415954024,0.047268176778443145,22,477.441504
+dcorr,-16.45502207569237,1.8383955239792882,1.9282381951449383,0.5522126201783978,22,347.146549
+dcorr_biased,-18.892428335373136,2.033110098031736,2.1954856948598875,0.09964886168606811,22,329.362058
+te_kraskov_NN-4_DCE_k-1_kt-1_l-1_lt-1,-12.529116346648266,2.085142075442464,1.3014761805005681,0.039623834404085276,22,301.639
+te_kraskov_NN-4_DCE_k-2_kt-1_l-1_lt-1,-12.107433121889706,2.0917377858745403,1.2401142503313585,0.04245285468233705,22,298.544122
+xme_kernel_W-0.5_k10,-14.490378499491248,2.098332933932502,1.5320247887500673,0.0464418985146788,22,244.866493
+di_kernel_W-0.5,-14.232011295339007,2.091594963678418,1.4990111561161394,0.042779450066720216,22,243.482622
+psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean,1.6194964827549128,0.9128341615918442,0.00022833390547209866,0.034280228999207435,22,231.60313
+psi_wavelet_mean_fs-1_fmin-0_fmax-0-5_mean,1.6335134284973982,0.9116018746686609,-0.0013786008334792854,0.034242525306628545,22,231.540183
+si_kernel_W-0.5_k-1,-14.115384952897184,2.0901862245457314,1.4603302501549063,0.042893778433287365,22,204.613105
+cce_kozachenko,-14.027689109477604,2.0849532314591785,1.4423434724790383,0.05625103212704867,22,191.619124
+te_kraskov_NN-4_k-1_kt-1_l-1_lt-1,-14.535246812787612,2.075358099459449,1.5026613969162057,0.051792786623442784,22,170.80914
+bary-sq_dtw_max,-16.70970734590575,1.825252451567083,1.8215025761659978,0.640171630675076,22,121.2435715
+bary_dtw_max,-16.70970734590575,1.825252451567083,1.8215025761659978,0.640171630675076,22,121.2435715
+bary_dtw_mean,-16.70970734590575,1.825252451567083,1.8215025761659978,0.640171630675076,22,121.2435715
+bary-sq_dtw_mean,-16.70970734590575,1.825252451567083,1.8215025761659978,0.640171630675076,22,121.2435715
+bary_sgddtw_max,-18.95251088519663,2.104771762720636,2.0141988477621284,0.07338882354335387,22,107.4308465
+bary-sq_sgddtw_max,-18.95251088519663,2.104771762720636,2.0141988477621284,0.07338882354335387,22,107.4308465
+bary_sgddtw_mean,-18.95251088519663,2.104771762720636,2.0141988477621284,0.07338882354335387,22,107.4308465
+bary-sq_sgddtw_mean,-18.95251088519663,2.104771762720636,2.0141988477621284,0.07338882354335387,22,107.4308465
+lcss_constraint-itakura,-16.548897073508293,2.057047129862752,1.72232817030385,0.12374824223131997,22,102.222853
+xme_kozachenko_k10,-16.68223427003803,2.089913190142716,1.7025747222791852,0.0528225291389273,22,94.597828
+lcss,-12.849968484799533,1.7891730216880586,1.2889599707748125,0.8192678881925144,22,94.390592
+lcss_constraint-sakoe-chiba,-16.547791640993058,2.0687466767400995,1.703865937941858,0.09635074935990735,22,93.776617
+te_kernel_W-0.25_k-1,-14.17791576940653,2.0849411582571484,1.355282180182738,0.04213543389472146,22,86.879938
+cds,-7.330869216625478,2.0826641353459845,0.41896884140745866,0.0447373441951759,22,81.56513
+tlmi_kraskov_NN-4_DCE,-15.130182881250795,2.077482383765294,1.4803959347352276,0.05339681829778978,22,79.179715
+tlmi_kraskov_NN-4,-15.121829485787696,2.074876653906388,1.4807851809415113,0.05742820830882892,22,79.033156
+ddtf_multitaper_mean_fs-1_fmin-0_fmax-0-25,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+ddtf_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+ddtf_multitaper_max_fs-1_fmin-0_fmax-0-25,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+ddtf_multitaper_max_fs-1_fmin-0_fmax-0-5,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+ddtf_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+ddtf_multitaper_mean_fs-1_fmin-0_fmax-0-5,-10.615832305692521,2.084888021249901,0.838181290259535,0.057777520843240435,22,67.606215
+gpdcoh_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+gpdcoh_multitaper_max_fs-1_fmin-0_fmax-0-25,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+gpdcoh_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+gpdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-25,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+gpdcoh_multitaper_max_fs-1_fmin-0_fmax-0-5,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+gpdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-5,-10.901636017619534,2.0851122770123993,0.8373909708678688,0.05732960604160229,22,50.526111833333324
+xme_kernel_W-0.5_k1,-15.088681943869902,2.085560945741045,1.404057615594758,0.044118882132928555,22,50.293133
+dcoh_multitaper_max_fs-1_fmin-0_fmax-0-5,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+dcoh_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+dcoh_multitaper_mean_fs-1_fmin-0_fmax-0-25,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+dcoh_multitaper_mean_fs-1_fmin-0_fmax-0-5,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+dcoh_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+dcoh_multitaper_max_fs-1_fmin-0_fmax-0-25,-10.913464003636609,2.084669011445066,0.8384906106298974,0.05729865221062596,22,50.21914816666666
+te_symbolic_k-10_kt-1_l-1_lt-1,-12.092913585548489,2.088619926888446,0.9923526071970215,0.046611560036893304,22,48.346877
+dtf_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+dtf_multitaper_max_fs-1_fmin-0_fmax-0-25,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+dtf_multitaper_max_fs-1_fmin-0_fmax-0-5,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+dtf_multitaper_mean_fs-1_fmin-0_fmax-0-25,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+dtf_multitaper_mean_fs-1_fmin-0_fmax-0-5,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+dtf_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-11.130645276441864,2.0795610308773833,0.855225663176371,0.05477571472678756,22,45.192060166666664
+mi_kernel_W-0.25,-14.395418033770886,2.080160379803438,1.294961118333301,0.0380328673873507,22,43.945338
+tlmi_kernel_W-0.25,-14.419860787699044,2.0847227828783113,1.2960801353986116,0.04069833519365279,22,43.926038
+mi_kraskov_NN-4,-15.664516252109637,2.0615208451361626,1.4696957205984365,0.11415201516112543,22,39.660842
+mi_kraskov_NN-4_DCE,-15.789628253035303,2.0800342961226868,1.4754293812740635,0.06025079027867503,22,39.604271
+pdcoh_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+pdcoh_multitaper_max_fs-1_fmin-0_fmax-0-25,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+pdcoh_multitaper_max_fs-1_fmin-0_fmax-0-5,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+pdcoh_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+pdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-25,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+pdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-5,-11.279975246088371,2.0841621606524527,0.8353028152586839,0.05734635745318706,22,34.011649
+di_kozachenko,-13.270231599148095,2.068101430620012,1.0905021356802385,0.048684913031175454,22,29.364246
+si_kozachenko_k-1,-12.982190384764094,2.0601537699567967,1.0383100890897838,0.0661376062258753,22,25.121063
+dtw,-17.5573786181818,1.8397151255603852,1.7116251501088664,0.6061786985105707,22,23.193056
+je_kernel_W-0.5,-16.427064245901718,2.0690058754787506,1.46423198725343,0.042404167871083705,22,19.710386
+dtw_constraint-itakura,-9.027830345071894,1.895082162043611,0.5353423550746599,1.3391066010398913,22,19.243657
+gc_gaussian_k-max-10_tau-max-2,-9.539694191492005,2.0650220289551986,0.5018648578839575,0.09812773329990797,22,15.764662
+te_symbolic_k-1_kt-1_l-1_lt-1,-13.064421735299444,2.084802790997361,0.9465491661376116,0.04018564157210931,22,12.846377
+psi_multitaper_mean_fs-1_fmin-0_fmax-0-5,-17.056574928618627,1.45057900601991,1.7678397520730518,0.26034303007776133,22,12.066516
+psi_multitaper_mean_fs-1_fmin-0_fmax-0-25,-17.609706206078627,1.497412777539137,1.8257221190228048,0.2510392043359716,22,11.822261
+psi_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-17.694114879727223,1.5050286084294677,1.8339370389130238,0.24732899543281087,22,11.695567
+coint_aeg_tstat_trend-ct_autolag-aic_maxlag-10,-9.68131656470292,2.064205870571707,0.4765188967307299,0.10159964039633153,22,10.957145
+coint_aeg_tstat_trend-c_autolag-aic_maxlag-10,-9.660845243923879,2.0557635933782774,0.4760031540701321,0.1031578252925099,22,10.876092
+coint_aeg_tstat_trend-ct_autolag-bic_maxlag-10,-9.650923090563957,2.067142754039023,0.4687078958276603,0.10094825836365819,22,10.792505
+phi_Geo_t-1_norm-1,-6.376680188232007,2.0710366141582868,-0.02960069521348939,0.05106868474713762,22,7.295577
+phi_Geo_t-1_norm-0,-6.113833037603502,2.046010041311747,-0.05865650468561823,0.07525852474346299,22,7.166739
+xme_kozachenko_k1,-13.918456956794984,2.075146931201418,0.9853809456552207,0.044343305498309775,22,7.142614
+reci,-7.378685664503044,2.0539053027411187,0.04461406365876633,0.032928186036363834,22,4.380826
+cce_gaussian,-10.337412487823059,2.0346785552121975,0.42741515675434977,0.09403827066649414,22,3.740957
+lmfit_SGDRegressor,-9.021508445490396,2.0545122060434244,0.23681976876411687,0.06467041780864166,22,3.515834
+corr_kendall_tau-1,-9.803697167816006,1.9957238278690277,0.34476253675483476,0.07370126735230835,22,2.893344
+corr_kendall_tau-2,-9.872625084584072,2.006681990296434,0.34895185080240226,0.07239832162437092,22,2.88561
+corr_kendall_tau-1-sq,-9.871993212345444,2.0072467629330393,0.3484145187758534,0.07340212876038119,22,2.88499
+corr_kendall_tau-3,-9.85435288157062,2.0050015688729035,0.34680610525091377,0.07411951356231046,22,2.881043
+corr_kendall_tau-4,-9.841217545777225,2.002750901745354,0.3456783540820474,0.07252649467306913,22,2.879022
+corr_kendall_tau-2-sq,-9.85036610684027,2.006348791275483,0.3456863693372079,0.07233816597809903,22,2.878211
+corr_kendall_tau-3-sq,-9.851987350158177,2.004368278340214,0.34694678150421343,0.0733961345858803,22,2.875516
+corr_kendall_tau-4-sq,-9.860073054817041,2.003830107937798,0.34823081211083706,0.07503270823776503,22,2.873297
+corr_kendall_tau-5-sq,-9.87770486355008,2.006792340609679,0.3494232390056762,0.07400286620471622,22,2.871798
+corr_kendall_tau-5,-9.841567284357419,2.003298133705918,0.34529778397724,0.07478894957020735,22,2.871658
+corr_kendall_tau-10,-9.863262276267575,2.0066154569222996,0.3471525617504402,0.07454184962385198,22,2.868142
+corr_kendall_tau-10-sq,-9.871463645602011,2.0082266901985872,0.34783078980662974,0.07431749595593036,22,2.867015
+corr_kendall_tau-20,-9.883990458429677,2.0077416898828995,0.34941498496549517,0.07485261767874986,22,2.864374
+corr_kendall_tau-20-sq,-9.88376647743189,2.007306801361731,0.34937914798896624,0.07494946120303835,22,2.860636
+phi_star_t-1_norm-1,-7.178035699000205,2.0569330926482055,-0.05102144787617217,0.09869274634210702,22,2.620446
+phi_star_t-1_norm-0,-6.872497711265394,2.004250685241633,-0.0763972565135396,0.12727677488757735,22,2.506569
+lmfit_BayesianRidge,-7.710630909193838,2.052304977405421,0.006088714473153772,0.032555394325163724,22,2.381697
+lmfit_ElasticNet,-7.8533653611619165,2.06769084678332,0.01568491497026846,0.029693136248150943,22,2.312181
+lmfit_Lasso,-7.8101962920974906,2.054429363034878,0.015384484968740316,0.023166825734196057,22,2.310174
+sgc_parametric_mean_fs-1_fmin-0_fmax-0-5_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-1e-05_fmax-0-5_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-1e-05_fmax-0-5_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0_fmax-0-25_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_max_fs-1_fmin-0-25_fmax-0-5,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0_fmax-0-25_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0-25_fmax-0-5_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0_fmax-0-25_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-1e-05_fmax-0-5_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_mean_fs-1_fmin-0_fmax-0-25,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_mean_fs-1_fmin-0-25_fmax-0-5,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0-25_fmax-0-5_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_max_fs-1_fmin-0_fmax-0-5,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_max_fs-1_fmin-0_fmax-0-25,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0_fmax-0-25_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0_fmax-0-25_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0-25_fmax-0-5_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0-25_fmax-0-5_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-0_fmax-0-25_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-1e-05_fmax-0-5_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_max_fs-1_fmin-1e-05_fmax-0-5_order-None,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0-25_fmax-0-5_order-1,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_parametric_mean_fs-1_fmin-0-25_fmax-0-5_order-20,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+sgc_nonparametric_mean_fs-1_fmin-0_fmax-0-5,-12.932123034698847,2.0328037806298025,0.7161582898929929,0.0651519111414964,22,2.2715955416666667
+coint_johansen_trace_stat_order-1_ardiff-10,-11.13480420195159,2.0472768075771226,0.4539293731485989,0.08665585127487088,22,2.0575385
+coint_johansen_max_eig_stat_order-1_ardiff-10,-11.13480420195159,2.0472768075771226,0.4539293731485989,0.08665585127487088,22,2.0575385
+je_kozachenko,-14.649089545578413,1.9896721551520875,0.9461897449980602,0.06725149129203781,22,2.014379
+lmfit_Ridge,-7.838070663283806,2.0125650597330105,0.017925495868833376,0.017625169530569397,22,1.945212
+coint_johansen_trace_stat_order-0_ardiff-10,-10.618188180053036,1.9786780653584035,0.4036270965971703,0.12860564955711692,22,1.9450034999999999
+coint_johansen_max_eig_stat_order-0_ardiff-10,-10.618188180053036,1.9786780653584035,0.4036270965971703,0.12860564955711692,22,1.9450034999999999
+dtw_constraint-sakoe-chiba_radius-auto,-18.6204306964569,1.9103340729496132,1.470956362033286,0.2632419174140795,22,1.535796
+psi_wavelet_mean_fs-1_fmin-0-25_fmax-0-5_mean,-13.27469233925253,1.6889605364259657,0.8460279651736774,0.2821685616949369,22,1.369913
+di_gaussian,-10.587880033354153,2.019278298435567,0.325966153388771,0.0741554243055712,22,1.266499
+xme_gaussian_k10,-11.954014200959614,2.020056421414802,0.5012262512643251,0.08851910538599278,22,1.227182
+si_gaussian_k-1,-10.512712239282662,2.0161377316712974,0.3101207058655751,0.06965067423535617,22,1.192795
+coint_johansen_max_eig_stat_order-1_ardiff-1,-10.162186044658544,2.0173617927349485,0.21259030768634332,0.05582898358688486,22,0.8321440000000001
+coint_johansen_trace_stat_order-1_ardiff-1,-10.162186044658544,2.0173617927349485,0.21259030768634332,0.05582898358688486,22,0.8321440000000001
+gc_gaussian_k-1_kt-1_l-1_lt-1,-11.481066842982171,2.023512497882524,0.3774359610502575,0.08081695885377699,22,0.760915
+coint_johansen_trace_stat_order-0_ardiff-1,-9.972697095171618,2.0104140194105864,0.17157763518043767,0.04831186578594365,22,0.7211974999999999
+coint_johansen_max_eig_stat_order-0_ardiff-1,-9.972697095171618,2.0104140194105864,0.17157763518043767,0.04831186578594365,22,0.7211974999999999
+kendalltau-sq,-10.828536356063502,1.9643162020144245,0.3060779317715166,0.077606643974287,22,0.717773
+kendalltau,-11.081185963737028,2.012188659165887,0.317565251734143,0.07570388481049915,22,0.716624
+gd_multitaper_delay_fs-1_fmin-0_fmax-0-5,-10.524942058042495,1.3164252958223432,0.6097549581961303,0.3135383937930448,22,0.712568
+gd_multitaper_delay_fs-1_fmin-0_fmax-0-25,-11.029548492101757,1.3514163659163403,0.6585703841641508,0.3172236747183257,22,0.710238
+gd_multitaper_delay_fs-1_fmin-0-25_fmax-0-5,-11.08720322863624,1.3557672420368452,0.6647994230083927,0.318379697757202,22,0.708835
+gwtau,-12.915506332820062,1.9284245812560306,0.5189217987402499,0.11268282689559438,22,0.391657
+prec-sq_EllipticEnvelope,-14.839724776665772,0.6348758710836446,1.4867777909814166,0.5364612558615923,22,0.38845675
+cov-sq_EllipticEnvelope,-14.839724776665772,0.6348758710836446,1.4867777909814166,0.5364612558615923,22,0.38845675
+cov_EllipticEnvelope,-14.839724776665772,0.6348758710836446,1.4867777909814166,0.5364612558615923,22,0.38845675
+prec_EllipticEnvelope,-14.839724776665772,0.6348758710836446,1.4867777909814166,0.5364612558615923,22,0.38845675
+prec_MinCovDet,-15.01621962733214,0.6305728054016103,1.5106190332413965,0.5453959080562104,22,0.37829625
+prec-sq_MinCovDet,-15.01621962733214,0.6305728054016103,1.5106190332413965,0.5453959080562104,22,0.37829625
+cov_MinCovDet,-15.01621962733214,0.6305728054016103,1.5106190332413965,0.5453959080562104,22,0.37829625
+cov-sq_MinCovDet,-15.01621962733214,0.6305728054016103,1.5106190332413965,0.5453959080562104,22,0.37829625
+xme_gaussian_k1,-10.841768715515899,1.9446037836701322,0.23145455925203048,0.06185593147823205,22,0.367991
+xcorr_max_sig-True,-10.455797503835925,1.902577686809001,0.17507537749656993,0.11266221685039048,22,0.323022
+ce_kernel_W-0.5,-14.326157405858108,0.9986489745288498,1.1432387403142052,0.04974128370012664,22,0.176998
+cov_GraphicalLassoCV,-5.743384417240393,0.9414878586306491,-0.05750278801229772,0.18421928016837266,22,0.11231100000000001
+prec_GraphicalLassoCV,-5.743384417240393,0.9414878586306491,-0.05750278801229772,0.18421928016837266,22,0.11231100000000001
+prec-sq_GraphicalLassoCV,-5.743384417240393,0.9414878586306491,-0.05750278801229772,0.18421928016837266,22,0.11231100000000001
+cov-sq_GraphicalLassoCV,-5.743384417240393,0.9414878586306491,-0.05750278801229772,0.18421928016837266,22,0.11231100000000001
+dswpli_multitaper_max_fs-1_fmin-0_fmax-0-25,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+dswpli_multitaper_max_fs-1_fmin-0_fmax-0-5,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+dswpli_multitaper_mean_fs-1_fmin-0_fmax-0-25,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+dswpli_multitaper_mean_fs-1_fmin-0_fmax-0-5,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+dswpli_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+dswpli_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-15.036522232204307,1.2069234301432292,1.000388828524041,0.37878101065597797,22,0.11177566666666668
+bary_euclidean_max,-3.3399955076774526,1.9164888117531442,-0.923303256764526,0.6999515039317472,22,0.1021765
+bary-sq_euclidean_max,-3.3399955076774526,1.9164888117531442,-0.923303256764526,0.6999515039317472,22,0.1021765
+bary-sq_euclidean_mean,-3.3399955076774526,1.9164888117531442,-0.923303256764526,0.6999515039317472,22,0.1021765
+bary_euclidean_mean,-3.3399955076774526,1.9164888117531442,-0.923303256764526,0.6999515039317472,22,0.1021765
+phase_multitaper_mean_fs-1_fmin-0_fmax-0-25,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+phase_multitaper_mean_fs-1_fmin-0_fmax-0-5,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+phase_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+phase_multitaper_max_fs-1_fmin-0_fmax-0-5,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+phase_multitaper_max_fs-1_fmin-0_fmax-0-25,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+phase_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-13.139339034885348,0.9094700551820428,0.8956445386156624,0.29589022826823297,22,0.10072516666666669
+ppc_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+ppc_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+ppc_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+ppc_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+ppc_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+ppc_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.678465180489653,1.0944005624775628,0.9663999269740757,0.3385091810786069,22,0.07788716666666666
+plv_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+plv_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+plv_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+plv_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+plv_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+plv_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.298943896432068,1.0681571219663686,0.9267340216130114,0.3051275445272582,22,0.07639449999999999
+wpli_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+wpli_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+wpli_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+wpli_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+wpli_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+wpli_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.800894936451202,1.0592421468497044,0.9898533676428932,0.3737439812616365,22,0.07256016666666666
+ce_kozachenko,-13.197709621666313,0.9808651156117486,0.8364463609044448,0.10168392136489442,22,0.060178
+pec_orth_log_abs,-11.26835496102319,1.0288470225499464,0.4462909373947966,0.31995000224021125,22,0.053906
+pec_orth_log,-11.301774632387348,1.0346511632411202,0.4505999485793405,0.30530974724807,22,0.053798
+cohmag_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+cohmag_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+cohmag_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+cohmag_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+cohmag_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+cohmag_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.325020088477071,0.9044486336581268,0.9608254874549688,0.3267219916000252,22,0.05119633333333334
+icoh_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+icoh_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+icoh_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+icoh_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+icoh_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+icoh_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.41893037013281,0.8982510313318417,0.9737708277962493,0.3339019827986912,22,0.049157
+dspli_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+dspli_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+dspli_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+dspli_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+dspli_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+dspli_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.36738390887161,0.8793550086426394,0.9664723505285201,0.3694658234505491,22,0.04653133333333334
+xpdist_euclidean_tau-1_mean_rmse,-11.884467718808239,1.868291856389213,0.12327908794351569,0.10249844935295917,22,0.045806
+pli_multitaper_mean_fs-1_fmin-0_fmax-0-25,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+pli_multitaper_mean_fs-1_fmin-0_fmax-0-5,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+pli_multitaper_mean_fs-1_fmin-0-25_fmax-0-5,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+pli_multitaper_max_fs-1_fmin-0-25_fmax-0-5,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+pli_multitaper_max_fs-1_fmin-0_fmax-0-25,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+pli_multitaper_max_fs-1_fmin-0_fmax-0-5,-14.33045516727065,0.8991507480336408,0.9532570274388751,0.3359319136493578,22,0.045240333333333334
+xpdist_euclidean_tau-1_min_rmse,-11.827020497937067,1.8260459412064751,0.12463036588682933,0.12687510710052258,22,0.042265
+pec_orth,-10.729722524566382,0.8842638146551809,0.3959107737718783,0.31566131488982896,22,0.03667
+pec_orth_abs,-10.63599002494023,0.8769116203100575,0.37950721140936183,0.33813595905685,22,0.03606
+xcorr-sq_mean_sig-True,-11.68088418605335,1.8279553987165633,0.07260808312855817,0.11942529739213713,22,0.032789
+xcorr_mean_sig-True,-11.636823869368804,1.8042346861794547,0.07127902673662391,0.12425810838381705,22,0.030875
+xcorr-sq_max_sig-True,-11.717520195135545,1.7960038061484198,0.08267912728581932,0.13356548883370092,22,0.030276
+ids,-13.18502323511945,0.9006803710537669,0.7568239507656273,0.14122100047210248,22,0.025392
+corr_spearman_tau-1,-11.00836273663258,0.927098616576216,0.44508428233570907,0.11136278249173119,22,0.022519
+corr_spearman_tau-5,-11.27755322780606,0.9548782165095044,0.46685365134476997,0.1033831805908251,22,0.021837
+corr_spearman_tau-1-sq,-11.341604247103849,0.9579706612260342,0.47589053792847086,0.10229985318522747,22,0.021663
+corr_spearman_tau-2-sq,-11.219434227041093,0.9503162110047128,0.4610532149368875,0.10888724025837908,22,0.021622
+corr_spearman_tau-20-sq,-11.28861938875518,0.95938948090109,0.4654190621418844,0.10170399210891637,22,0.021622
+corr_spearman_tau-3-sq,-11.26185182939079,0.9640202758904478,0.4621753046980173,0.10685553474132348,22,0.021615
+corr_spearman_tau-4,-11.263522549712988,0.9527936798904332,0.46620269530546243,0.10408752343372144,22,0.021598
+corr_spearman_tau-4-sq,-11.302936000957573,0.9597255402219472,0.4681207853912648,0.10210940178476806,22,0.021578
+corr_spearman_tau-10-sq,-11.332410068992582,0.9573768774108323,0.47317170225301197,0.09954045992978076,22,0.021573
+corr_spearman_tau-2,-11.271163366721163,0.9506938376241989,0.4691433244714281,0.10054549699535172,22,0.021545
+corr_spearman_tau-3,-11.247622610737997,0.9555156674748406,0.4622146455802436,0.10614567421655366,22,0.021536
+corr_spearman_tau-10,-11.321131371993838,0.9604444871002956,0.4700198060041429,0.10604413864148991,22,0.021533
+corr_spearman_tau-5-sq,-11.298854196666063,0.9601599415491682,0.46738956028336787,0.10482119191433542,22,0.021533
+corr_spearman_tau-20,-11.327177248277259,0.9565181066545634,0.47168713669692586,0.10635502519532458,22,0.021308
+xcorr-sq_mean_sig-False,-11.957125200289447,1.6847794136112908,0.0690771867275792,0.1790851253947647,22,0.014598
+xcorr_mean_sig-False,-11.767284343332364,1.6296258208264045,0.042990002461878306,0.2108848900633966,22,0.011978
+spearmanr-sq,-10.293160292839326,0.7763389695235288,0.3283481610338186,0.1881232331731858,22,0.010873
+spearmanr,-11.192089999437583,0.9069344259286081,0.38425281812310236,0.1999246256148611,22,0.010695
+pec,-7.83120240618995,0.33779333819508756,0.1835782546506992,0.18433480057786433,22,0.010659
diff --git a/bench/run_benchmark.pbs b/bench/run_benchmark.pbs
new file mode 100644
index 00000000..5e32dc57
--- /dev/null
+++ b/bench/run_benchmark.pbs
@@ -0,0 +1,117 @@
+#!/bin/bash
+#PBS -N pyspi_bench
+#PBS -j oe
+# ---- EDIT FOR YOUR SITE: sizing defaults (largest (M,T) cell you will run) ----
+#PBS -l select=1:ncpus=16:mem=64gb
+#PBS -l walltime=24:00:00
+# ------------------------------------------------------------------------------
+
+# Benchmark pyspi Calculator.compute() over an (M,T,n_jobs) grid on PBS Pro.
+# Writes one JSON per cell to bench/results/cells/_M_T_n.json
+# and one log per job/array task to bench/logs/.
+#
+# SITE SETUP (qsub does NOT expand shell variables inside #PBS directives):
+# * Resources: edit the two `#PBS -l` lines above. A command-line `-l` of the
+# SAME resource name overrides them (`qsub -l walltime=72:00:00 ...`); a
+# different form does not (e.g. Gadi wants `-l ncpus=16 -l mem=64GB`
+# instead of `-l select=...` — edit the line rather than adding a flag).
+# * Account / queue / storage / mail live only on the qsub command line:
+# qsub -P -q -l storage=<...> -m bea -M you@example.org
+# * Software: MODULES + VENV below (env vars, so -v works for them).
+#
+# Submit from the repo root. `qsub -v` splits on commas, so pass comma-valued
+# vars by NAME after exporting them in the shell (as in the examples).
+#
+# # one (M,T) cell per array task (5 tasks, in parallel):
+# M=32 T=200,400,800,1600,3200 CONFIG=full REPEATS=1 \
+# qsub -J 1-5 -v M,T,CONFIG,REPEATS bench/run_benchmark.pbs
+#
+# # a single cell, bigger walltime, on a named project/queue:
+# M=64 T=3200 CONFIG=full \
+# qsub -P myproj -q normal -l walltime=168:00:00 -v M,T,CONFIG bench/run_benchmark.pbs
+#
+# # a bundled preset, sequentially, in one job:
+# qsub -v PRESET=parallel,CONFIG=benchmarked_p90 bench/run_benchmark.pbs
+#
+# Env vars (-v), with defaults:
+# M, T comma-separated grid axes; if BOTH set they override PRESET
+# NJOBS Calculator.compute(n_jobs=) — keep 1 for config-cutting [1]
+# PRESET bundled grid: headline | scaling | parallel | amortized [amortized]
+# CONFIG bundled config name (full/fast/benchmarked_p90/...) or YAML path [full]
+# REPEATS repeats per cell [2]
+# LABEL output filename prefix [bench_]
+# PYSPI_DIR repo checkout [$PBS_O_WORKDIR]
+# VENV venv with pyspi installed editable [$PYSPI_DIR/.venv];
+# set VENV= (empty) if python is already on PATH via MODULES/conda
+# MODULES space-separated modules to load, e.g. "python3/3.12.1" [none]
+#
+# Array jobs: `-J 1-N` runs the Nth cell of the resolved grid per task
+# (--array-index). Without -J the job walks every cell sequentially. Both are
+# --resume, so a resubmit skips cells whose JSON already exists.
+
+set -euo pipefail
+
+PYSPI_DIR="${PYSPI_DIR:-${PBS_O_WORKDIR:-}}"
+if [[ -z "$PYSPI_DIR" ]]; then
+ echo "[ERROR] set PYSPI_DIR, or submit with qsub from the repo root." >&2
+ exit 1
+fi
+cd "$PYSPI_DIR"
+
+PRESET="${PRESET:-amortized}"
+CONFIG="${CONFIG:-full}"
+REPEATS="${REPEATS:-2}"
+M="${M:-}"
+T="${T:-}"
+NJOBS="${NJOBS:-1}"
+VENV="${VENV-${PYSPI_DIR}/.venv}"
+LABEL="${LABEL:-bench_$(basename "${CONFIG}" .yaml)}"
+
+# One log per job, or per array task.
+LOG_DIR="${PYSPI_DIR}/bench/logs"
+mkdir -p "$LOG_DIR"
+JOBID="${PBS_JOBID:-local}"
+ARRAY_ID="${PBS_ARRAY_INDEX:-}"
+exec >"$LOG_DIR/bench_${JOBID%%.*}${ARRAY_ID:+_${ARRAY_ID}}.log" 2>&1
+
+if command -v module >/dev/null 2>&1; then
+ module purge
+ for _mod in ${MODULES:-}; do module load "$_mod"; done
+fi
+[[ -n "$VENV" ]] && source "${VENV}/bin/activate"
+
+# BLAS single-threaded — matches the production regime (1 dataset / core).
+export OMP_NUM_THREADS=1
+export OPENBLAS_NUM_THREADS=1
+export MKL_NUM_THREADS=1
+export VECLIB_MAXIMUM_THREADS=1
+export NUMEXPR_NUM_THREADS=1
+export KMP_DUPLICATE_LIB_OK=TRUE
+
+# Some GPU-aware transitive imports do ast.literal_eval() on this if it is set,
+# and the physics queue sets it empty. Kept because the failure it prevents is
+# an import-time crash, not a pyspi one.
+unset CUDA_VISIBLE_DEVICES
+
+# Explicit M and T override the preset. One array, never empty, so `set -u`
+# stays happy on older bash.
+if [[ -n "$M" && -n "$T" ]]; then
+ ARGS=(--m "$M" --t "$T" --n-jobs "$NJOBS")
+else
+ ARGS=(--preset "$PRESET")
+fi
+[[ -n "$ARRAY_ID" ]] && ARGS+=(--array-index "$ARRAY_ID")
+
+echo "[bench] host=$(hostname) start=$(date)"
+echo "[bench] config=${CONFIG} grid=${ARGS[*]} repeats=${REPEATS}"
+echo "[bench] label=${LABEL} output_dir=bench/results/cells/"
+
+python -m bench.bench_compute \
+ "${ARGS[@]}" \
+ --config "${CONFIG}" \
+ --repeats "${REPEATS}" \
+ --output-dir bench/results/cells \
+ --label "${LABEL}" \
+ --resume
+
+echo "[bench] finished at $(date)"
diff --git a/demos/README.md b/demos/README.md
new file mode 100644
index 00000000..2da50d69
--- /dev/null
+++ b/demos/README.md
@@ -0,0 +1,7 @@
+# demos
+
+`pyspi_tutorial.ipynb` — load data, run a `Calculator`, read the results, and
+budget the cost. Everything you need to use the package, in one pass.
+
+Plotting is not part of it and pyspi has no plotting dependency: `calc.to_frame()`
+gives long-form results that go straight into whatever you already use.
diff --git a/demos/pyspi_tutorial.ipynb b/demos/pyspi_tutorial.ipynb
new file mode 100644
index 00000000..c68eb4a1
--- /dev/null
+++ b/demos/pyspi_tutorial.ipynb
@@ -0,0 +1,324 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# pyspi\n",
+ "\n",
+ "pyspi computes **statistics of pairwise interactions** (SPIs) between the processes of a multivariate time series. Each SPI is one answer to \"how are these two processes related?\" — a correlation, a spectral coherence, a transfer entropy, a causal score. For `M` processes each returns an `M x M` matrix, and pyspi stacks them so hundreds of notions of \"related\" can be compared side by side."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. Data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "246872cf",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "from pyspi.calculator import Calculator, bundled_configs\n",
+ "from pyspi.data import load_dataset, available_datasets\n",
+ "\n",
+ "available_datasets()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e51e624c",
+ "metadata": {},
+ "source": [
+ "`Data` holds an `M x T` array, z-scored per process by default. Your own data goes in the same way — `Calculator(dataset=my_array)` accepts a NumPy array, and `dim_order` controls whether rows are processes or observations."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "196cfd60",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "data = load_dataset(\"cml\") # coupled map lattice: 10 processes, 500 observations\n",
+ "print(data.n_processes, \"processes x\", data.n_observations, \"observations\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "42a8b2e1",
+ "metadata": {},
+ "source": [
+ "## 2. Compute\n",
+ "\n",
+ "`config` selects the SPI set. `fabfour` is the smallest (4 SPIs, one per family); `full` is all 322. See `bundled_configs()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8f5e5a19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "calc = Calculator(dataset=data, \n",
+ " config=\"benchmarked_p90\", \n",
+ " verbose=True)\n",
+ "calc.compute(progress=False)\n",
+ "\n",
+ "calc.table"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dcfa0b9c",
+ "metadata": {},
+ "source": [
+ "## 3. Reading the results\n",
+ "\n",
+ "`calc` itself shows what happened. `calc.table` is wide: rows are processes, columns a `(spi, process)` MultiIndex, shape `M x (n_spis * M)`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "df60db92",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "calc.summary()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c70134b8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print(calc.table.shape, calc.table.columns.names)\n",
+ "calc.table.iloc[:3, :6].round(3)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0156d157",
+ "metadata": {},
+ "source": [
+ "### One SPI\n",
+ "\n",
+ "Indexing by identifier returns that SPI's `M x M` matrix. Entry `[i, j]` is computed with `i` as **source**, `j` as **target** — irrelevant for a symmetric SPI, the whole point for a directed one. The diagonal is NaN: self-pairs are not computed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c6b17d77",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "cov = calc.table[\"cov_EmpiricalCovariance\"]\n",
+ "cov.round(3)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "633c7db6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# a single pair, and the strongest pairs overall\n",
+ "print(\"proc-0 / proc-3:\", round(cov.iloc[0, 3], 4))\n",
+ "\n",
+ "upper = cov.where(np.triu(np.ones(cov.shape), k=1).astype(bool))\n",
+ "upper.stack().abs().sort_values(ascending=False).head(5).round(3)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "48e3f79b",
+ "metadata": {},
+ "source": [
+ "### Direction\n",
+ "\n",
+ "The data is z-scored, so covariance *is* Pearson correlation and is symmetric. Directed information is not — the two triangles are separate estimates."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8fd163b8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "di = calc.table[\"di_gaussian_n-5\"]\n",
+ "print(\"0 -> 1:\", round(di.iloc[0, 1], 3), \" | 1 -> 0:\", round(di.iloc[1, 0], 3))\n",
+ "print(\"di symmetric?\", np.allclose(di.values, di.values.T, equal_nan=True),\n",
+ " \"| cov symmetric?\", np.allclose(cov.values, cov.values.T, equal_nan=True))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1f65f48e",
+ "metadata": {},
+ "source": [
+ "### Long form\n",
+ "\n",
+ "`to_frame()` gives one row per `(spi, source, target)` — the shape you want for plotting, `groupby`, or joining against SPI labels."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9f68cba8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "long = calc.to_frame()\n",
+ "display(long.head(3))\n",
+ "long.groupby(\"spi\")[\"value\"].agg([\"mean\", \"std\", \"count\"]).round(3)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "475fcbff",
+ "metadata": {},
+ "source": [
+ "## 4. Cost\n",
+ "\n",
+ "Cost is heavily skewed: a few SPIs dominate, most are effectively free. `summary()` reports the total and the slowest five, which is how to budget a run.\n",
+ "\n",
+ "The `benchmarked_p80/p90/p95/p99` configs exploit that skew — they keep the fastest N% by measured cost. To build your own subset by keyword, `filter_spis([\"directed\", \"nonlinear\"], output_name=\"mine\")` writes a config you pass as `Calculator(config=\"mine.yaml\")`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ced71da6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import time\n",
+ "\n",
+ "t0 = time.perf_counter()\n",
+ "sonnet = Calculator(dataset=data, config=\"sonnet\", verbose=False) # 14 SPIs, one per family\n",
+ "sonnet.compute(progress=False)\n",
+ "print(f\"{sonnet.n_spis} SPIs in {time.perf_counter() - t0:.1f}s\")\n",
+ "\n",
+ "sonnet.summary()[\"slowest\"]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "de9b790d",
+ "metadata": {},
+ "source": [
+ "## 5. Failures\n",
+ "\n",
+ "A failed SPI still occupies its column, filled with NaN. Check `calc.errors` before reading a table: a NaN column is otherwise indistinguishable from a legitimately undefined statistic."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c9e24b64",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "sonnet.errors or \"no failures\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "41530cad",
+ "metadata": {},
+ "source": [
+ "## 6. Inspecting the calculator\n",
+ "\n",
+ "What was run, what it cost, and what is available."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e6ebdfd4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print(\"SPIs:\", calc.n_spis)\n",
+ "print(\"first five:\", list(calc.spis)[:5])\n",
+ "\n",
+ "# every parameter that defines this run -- what checkpoints are bound to\n",
+ "{k: v for k, v in calc.run_spec.items() if k != \"spi_identifiers\"}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5c7e0593",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# labels drive filtering; each SPI carries the traits it actually has\n",
+ "spi = calc.spis[\"di_gaussian_n-5\"]\n",
+ "print(spi.identifier, \"->\", sorted(spi.labels))\n",
+ "\n",
+ "# build your own subset by keyword\n",
+ "from pyspi.utils import filter_spis\n",
+ "# filter_spis([\"directed\", \"nonlinear\"], output_name=\"mine\") # -> mine.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 7. Saving, scale, CLI\n",
+ "\n",
+ "`.npz` round-trips exactly and needs nothing but numpy; `.csv` is a one-way export.\n",
+ "\n",
+ "```python\n",
+ "calc.save(\"results.npz\")\n",
+ "from pyspi.calculator import load_table\n",
+ "table = load_table(\"results.npz\")\n",
+ "```\n",
+ "\n",
+ "**Many datasets: run one per process** (a cluster array job, or GNU parallel) rather than raising `n_jobs`. SPIs sharing a cache are grouped into one sequential task, so within-dataset speedup is floored by the longest group — measured at 2.3–4.5x whatever `n_jobs` you pass. One dataset per process scales close to linearly and confines a failure to one dataset.\n",
+ "\n",
+ "Everything above is available without writing Python:\n",
+ "\n",
+ "```bash\n",
+ "python -m pyspi compute --data ts.npy --config fabfour --checkpoint-dir results/\n",
+ "```\n",
+ "\n",
+ "`--checkpoint-dir` writes each SPI as it finishes, so an interrupted run resumes instead of restarting."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "pyspi-fork (3.12.12.final.0)",
+ "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.12.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 820f60ce..ddec2fad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,36 +1,137 @@
[build-system]
-requires = ["setuptools>=61.0.0", "wheel"]
+# >=77 for PEP 639: the `license = "GPL-3.0-or-later"` SPDX expression and
+# `license-files` below are only understood from that release, and older
+# setuptools would build a wheel with no licence metadata at all.
+requires = ["setuptools>=77", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pyspi"
-version = "2.0.1"
+version = "3.0.0"
+description = "Library for pairwise analysis of time series data."
+readme = "README.md"
+# PEP 639 SPDX expression. The `{ text = ... }` table is deprecated by
+# setuptools >= 77 and emits a build warning that will become an error.
+license = "GPL-3.0-or-later"
+# The two vendored MIT notices go in the wheel's licence metadata as well as
+# in package data: `pyspi/lib/ids/` and the four pairwise causal scores in
+# `pyspi/lib/pairwise_causal.py` carry their own terms, and a redistributor
+# reading only the dist-info should see them.
+license-files = ["LICENSE.txt", "pyspi/lib/LICENSE-cdt.txt",
+ "pyspi/lib/ids/LICENSE.txt"]
+requires-python = ">=3.10"
authors = [
- { name ="Oliver M. Cliff", email="oliver.m.cliff@gmail.com"},
+ { name = "Oliver M. Cliff", email = "oliver.m.cliff@gmail.com" },
]
maintainers = [
- {name = "Joshua B. Moore"},
- {email = "joshua.moore@sydney.edu.au"},
+ { name = "Joshua B. Moore", email = "joshua.moore@sydney.edu.au" },
]
-description = "Library for pairwise analysis of time series data."
-readme = "README.md"
-license = {text = "GNU General Public License v3 (GPLv3)"}
-requires-python = ">=3.8"
classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Development Status :: 1 - Planning",
+ "Development Status :: 4 - Beta",
"Operating System :: POSIX :: Linux",
+ "Operating System :: MacOS :: MacOS X",
"Intended Audience :: Science/Research",
"Environment :: Console",
- "Environment :: Other Environment",
"Topic :: Scientific/Engineering :: Physics",
"Topic :: Scientific/Engineering :: Bio-Informatics",
"Topic :: Scientific/Engineering :: Information Analysis",
"Topic :: Scientific/Engineering :: Medical Science Apps.",
]
-dynamic = ["dependencies", "optional-dependencies"]
+dependencies = [
+ "numpy>=2.0",
+ "scipy>=1.11",
+ # >=2.1 for DataFrame.stack(future_stack=True), the pandas 3 stack semantics.
+ "pandas>=2.1",
+ "scikit-learn>=1.3",
+ "statsmodels>=0.14",
+ "mne>=1.6",
+ "mne-connectivity>=0.5",
+ # Upper-bounded on purpose. DirectedCoherence reads two *private*
+ # Connectivity properties (_transfer_function, _noise_covariance) because
+ # the public directed_coherence() is wrong twice over (see
+ # statistics/spectral.py). Supported range is verified end to end against
+ # the oldest published 1.1 (1.1.0) and the locked current release; both
+ # expose the whole surface pyspi uses. 1.1.x lacks transforms.
+ # prepare_time_series (there is a fallback) and returns a 400-point rather
+ # than 401-point frequency grid, so band statistics differ marginally
+ # across the range. A major bump is where a private property may vanish;
+ # tests/test_directionality.py fails loudly if one does.
+ "spectral-connectivity>=1.1,<3",
+ "nitime>=0.10",
+ "hyppo>=0.4",
+ # cdt and torch are gone. pyspi used four functions from
+ # cdt.causality.pairwise (the ANM independence score, CDS, RECI, IGCI);
+ # they are transcribed in pyspi/lib/pairwise_causal.py and produce
+ # bit-identical values (verified against cdt 0.6 before removal, and frozen
+ # in tests/data/fixtures/cdt_pairwise_reference.npz). torch was never used
+ # for any pyspi computation -- it entered only because cdt eagerly imports
+ # its Torch-backed models at package load.
+ "tslearn>=0.6",
+ "dtaidistance>=2.3",
+ # >=2.5 dropped the `import pkg_resources` that previously forced a
+ # setuptools<80 pin on every install (pkg_resources was removed in
+ # setuptools 80). Verified: nothing in the dependency tree imports it.
+ "pyEDM>=2.5",
+ "pyyaml>=6.0",
+ "tqdm>=4.65",
+ "colorama>=0.4",
+]
+
+[project.optional-dependencies]
+testing = [
+ "pytest>=7",
+]
+# Optional: only required for loading the sktime/aeon .ts dataset format.
+tsfile = [
+ "aeon>=1.0",
+]
+# Optional: the compute-cost benchmark suite and its analysis notebook (bench/).
+# Not needed to use pyspi.
+bench = [
+ "psutil>=5.9",
+ "matplotlib>=3.8",
+ "seaborn>=0.13",
+ "plotly>=5.20",
+ "nbformat>=5.10",
+ "ipykernel>=6.29",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=7",
+ "psutil>=5.9",
+ "matplotlib>=3.8",
+ "seaborn>=0.13",
+ "plotly>=5.20",
+ "nbformat>=5.10",
+ "ipykernel>=6.29",
+]
[project.urls]
Homepage = "https://github.com/DynamicsAndNeuralSystems/pyspi"
Documentation = "https://time-series-features.gitbook.io/pyspi/"
+
+[tool.pytest.ini_options]
+# Default: skip the slow suite (test_baseline_drift.py plus the cache-sharing
+# sweep in test_cache_keys.py; ~2 min on a 2024 laptop).
+# Run everything with: pytest -m ''
+# Run only regression: pytest -m slow
+addopts = "-m 'not slow'"
+markers = [
+ "slow: regression suite against frozen baseline SPI tables (run with -m slow)",
+]
+
+[tool.setuptools.packages.find]
+include = ["pyspi*"]
+
+[tool.setuptools.package-data]
+# Globs, not an enumeration: hand-listing these is what previously let four
+# shipped configs and two datasets go missing from the wheel unnoticed.
+pyspi = [
+ "configs/*.yaml",
+ "data/*.npy",
+]
+"pyspi.lib" = ["LICENSE-cdt.txt"]
+"pyspi.lib.ids" = ["LICENSE.txt"]
diff --git a/pyspi/__init__.py b/pyspi/__init__.py
index feaf3d03..ba5f5747 100644
--- a/pyspi/__init__.py
+++ b/pyspi/__init__.py
@@ -1,17 +1,23 @@
-# For some reason the JVM causes a segfault with OpenBLAS (numpy's linalg sovler). Need to halt multithreading before starting JVM:
-import os, logging, sys
+# BLAS threading: default to single-threaded BLAS so that parallel workers
+# (see Calculator.compute) don't oversubscribe (n_jobs workers x full BLAS).
+# Workers pin BLAS to 1 thread in _parallel._worker_init; this line ensures
+# the main process (and any serial run) does the same by default.
+import os
+import logging
+import numpy as np
-os.environ['OMP_NUM_THREADS'] = '1'
+os.environ.setdefault('OMP_NUM_THREADS', '1')
-# formatter = logging.Formatter('[%(levelname)s: %(asctime)s]: %(message)s')
+# Standard library practice: a library should not emit log output unless the
+# application configures a handler. Calculator(verbose=...) attaches a real
+# handler on demand via pyspi._logging.configure().
+logging.getLogger("pyspi").addHandler(logging.NullHandler())
-# ch = logging.StreamHandler()
-# ch.setFormatter(formatter)
-# ch.setLevel(logging.DEBUG)
+# NumPy 2 removed np.NaN; some legacy code paths still reference it.
+if not hasattr(np, "NaN"):
+ np.NaN = np.nan
-# logger = logging.getLogger()
-# logger.addHandler(ch)
-# logger.setLevel(logging.INFO)
-# logging.captureWarnings(True)
-# logging.basicConfig(stream=, level=logging.INFO)
\ No newline at end of file
+from .calculator import Calculator, load_table # noqa: E402
+
+__all__ = ["Calculator", "load_table"]
diff --git a/pyspi/__main__.py b/pyspi/__main__.py
new file mode 100644
index 00000000..b90ad98b
--- /dev/null
+++ b/pyspi/__main__.py
@@ -0,0 +1,126 @@
+"""Thin CLI for pyspi: compute all SPIs on a saved dataset.
+
+ python -m pyspi compute \
+ --data ts.npy \
+ --config benchmarked_p90 \
+ --output table.npz \
+ --n-jobs 4 \
+ --checkpoint-dir results/
+
+If ``--config`` is omitted, the bundled ``full`` config is used. If
+``--output`` is omitted, the result table is written next to the data file as
+``.spi.npz``. The format follows the extension: ``.npz``
+(round-trips via ``pyspi.load_table``) or ``.csv`` (human-readable, one-way).
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import numpy as np
+
+from .calculator import Calculator, bundled_configs
+
+
+def _load_array(path: Path) -> np.ndarray:
+ if path.suffix == ".npy":
+ return np.load(path)
+ if path.suffix == ".csv":
+ return np.genfromtxt(path, delimiter=",")
+ if path.suffix == ".txt":
+ return np.genfromtxt(path)
+ raise ValueError(f"Unsupported data extension: {path.suffix} (use .npy, .csv, or .txt)")
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(prog="pyspi", description=__doc__)
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ cp = sub.add_parser("compute", help="Compute SPIs on a saved dataset.")
+ cp.add_argument("--data", type=Path, required=True,
+ help="Path to time series array (.npy/.csv/.txt). Shape (processes, observations).")
+ cp.add_argument("--config", default="full",
+ help="Bundled config name or path to your own YAML (default: full). "
+ "Bundled: " + ", ".join(bundled_configs()) + ".")
+ cp.add_argument("--output", type=Path, default=None,
+ help="Where to write results; format follows the extension "
+ "(.npz, .csv). Default: .spi.npz.")
+ cp.add_argument("--n-jobs", type=int, default=1,
+ help="Worker process count. 1 = serial (default).")
+ cp.add_argument("--checkpoint-dir", type=Path, default=None,
+ help="Directory for per-SPI .npy checkpoints. Enables resume.")
+ cp.add_argument("--no-resume", action="store_true",
+ help="Ignore existing checkpoints; recompute every SPI.")
+ cp.add_argument("--mp-context", choices=["spawn", "fork", "forkserver"], default=None,
+ help="Multiprocessing start method. Default: fork on Linux "
+ "(measured ~2x faster end to end), spawn elsewhere "
+ "-- fork is unsafe on macOS and absent on Windows.")
+ cp.add_argument("--no-zscore", action="store_true",
+ help="Skip z-scoring each time series before computing.")
+ cp.add_argument("--quiet", action="store_true",
+ help="Suppress INFO logging; show warnings/errors only.")
+ cp.add_argument("--allow-partial", action="store_true",
+ help="Exit 0 even if some SPIs failed. The default is to "
+ "exit 1 whenever calc.errors is non-empty: a results "
+ "table with failed columns is one an automated "
+ "pipeline must not ingest silently, and a NaN column "
+ "is indistinguishable from a legitimately undefined "
+ "statistic once the process has exited. The failed "
+ "identifiers are printed on stderr and stored in the "
+ "NPZ either way. A run in which *nothing* succeeded "
+ "exits 1 regardless of this flag.")
+
+ args = parser.parse_args(argv)
+
+ arr = _load_array(args.data)
+ if arr.ndim != 2:
+ raise SystemExit(f"Data must be 2D (processes x observations); got shape {arr.shape}")
+
+ calc = Calculator(
+ dataset=arr,
+ config=args.config,
+ zscore=not args.no_zscore,
+ verbose=not args.quiet,
+ )
+ calc.compute(
+ n_jobs=args.n_jobs,
+ checkpoint_dir=args.checkpoint_dir,
+ resume=not args.no_resume,
+ mp_context=args.mp_context,
+ )
+
+ out = args.output or args.data.with_suffix(".spi.npz")
+ calc.save(out)
+ print(f"Wrote results table -> {out}")
+
+ # Report failures on the way out, unconditionally. `--quiet` suppresses the
+ # computation summary, which used to be the only place a failed SPI was
+ # mentioned -- so `pyspi compute --quiet` printed "Wrote results table" and
+ # exited 0 over a table that could be entirely NaN.
+ n_failed = len(calc.errors)
+ if n_failed:
+ print(f"{n_failed} of {calc.n_spis} SPI(s) failed: "
+ f"{', '.join(sorted(calc.errors))}", file=sys.stderr)
+
+ values = np.stack([calc.table[k].to_numpy(dtype=float) for k in calc.spis])
+ off_diagonal = ~np.eye(calc.dataset.n_processes, dtype=bool)
+ n_empty = int(sum(not np.isfinite(v[off_diagonal]).any() for v in values))
+ if n_empty:
+ print(f"{n_empty} of {calc.n_spis} SPI(s) produced no finite value.",
+ file=sys.stderr)
+
+ if n_empty == calc.n_spis:
+ print("Every SPI is empty; the results table carries no information.",
+ file=sys.stderr)
+ return 1
+ if n_failed and not args.allow_partial:
+ print("Exiting 1 because SPIs failed; pass --allow-partial to accept "
+ "a partial table.", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/pyspi/_logging.py b/pyspi/_logging.py
new file mode 100644
index 00000000..9e844ecf
--- /dev/null
+++ b/pyspi/_logging.py
@@ -0,0 +1,72 @@
+"""Logging setup for pyspi.
+
+pyspi emits informational output (config loading, per-SPI init, compute
+progress) through the standard ``logging`` module under the ``pyspi`` logger.
+
+Behaviour:
+- ``pyspi/__init__.py`` attaches a ``NullHandler`` so importing pyspi never
+ prints anything on its own (standard library practice).
+- ``Calculator(verbose=...)`` calls :func:`configure` as a convenience for
+ interactive/script users: ``verbose=True`` (default) attaches a colored
+ ``StreamHandler`` at INFO; ``verbose=False`` raises the level to WARNING so
+ only problems surface.
+- Power users who attach their own handler before constructing a Calculator
+ are respected — :func:`configure` will not add a second StreamHandler, and
+ ``verbose`` then only adjusts the level.
+
+Note: ``verbose`` maps to a *process-global* logger level. Two Calculators
+with different ``verbose`` in the same process share one level (last wins).
+For per-call control of the parallel progress bar use ``compute(progress=...)``.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from colorama import Fore, Style
+from colorama import init as _colorama_init
+
+_colorama_init(autoreset=True)
+
+_LEVEL_COLORS = {
+ logging.DEBUG: Fore.CYAN,
+ logging.INFO: Fore.GREEN,
+ logging.WARNING: Fore.YELLOW,
+ logging.ERROR: Fore.RED,
+ logging.CRITICAL: Fore.RED + Style.BRIGHT,
+}
+
+_LOGGER_NAME = "pyspi"
+
+
+class ColorFormatter(logging.Formatter):
+ """Formatter that tints the whole record by level (via colorama)."""
+
+ def format(self, record):
+ msg = super().format(record)
+ color = _LEVEL_COLORS.get(record.levelno, "")
+ return f"{color}{msg}{Style.RESET_ALL}" if color else msg
+
+
+def get_logger(name: str = _LOGGER_NAME) -> logging.Logger:
+ """Return a pyspi logger. ``name`` may be a dotted sub-name (e.g. 'pyspi.calculator')."""
+ return logging.getLogger(name)
+
+
+def configure(verbose: bool = True) -> None:
+ """Idempotently attach a colored StreamHandler to the ``pyspi`` logger.
+
+ Args:
+ verbose: True -> level INFO (chatty); False -> level WARNING (problems only).
+ """
+ logger = logging.getLogger(_LOGGER_NAME)
+ has_stream = any(
+ isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler)
+ for h in logger.handlers
+ )
+ if not has_stream:
+ handler = logging.StreamHandler()
+ handler.setFormatter(ColorFormatter("%(message)s"))
+ logger.addHandler(handler)
+ logger.propagate = False
+ logger.setLevel(logging.INFO if verbose else logging.WARNING)
diff --git a/pyspi/_parallel.py b/pyspi/_parallel.py
new file mode 100644
index 00000000..45b3519a
--- /dev/null
+++ b/pyspi/_parallel.py
@@ -0,0 +1,609 @@
+"""Parallel SPI execution backend for Calculator.compute().
+
+Design:
+- Dataset bytes are placed in a single multiprocessing.shared_memory block.
+ Workers attach read-only; no per-worker pickling of the array.
+- SPIs are bucketed by their ``_cache_namespace`` class attribute. Each bucket
+ becomes one task assigned to a single worker, so a cache populated lazily on
+ the Data object (e.g. data.spectral_bv, data.covariance) is reused across
+ every variant in that bucket. SPIs without a tag run as single-SPI tasks.
+- Per-SPI failures yield a NaN matrix; the rest of the run continues.
+- If ``checkpoint_dir`` is set, each finished SPI is atomically written to
+ ``/.npy`` (and ``.error`` on failure). A
+ subsequent run with ``resume=True`` will load these and skip the SPIs.
+- Progress is per-SPI, not per-bucket: workers post a lightweight event to a
+ shared queue after each SPI so the tqdm bar advances one tick per SPI and
+ a stuck SPI is visible (the bar stalls). Results themselves still travel
+ back via the futures, which also surfaces a hard worker crash.
+"""
+
+from __future__ import annotations
+
+import multiprocessing as mp
+import multiprocessing.shared_memory as shm
+import concurrent.futures as cf
+import os
+import queue as _queue
+import sys
+import time
+import warnings
+import json
+from collections import defaultdict
+from pathlib import Path
+from typing import Optional
+
+import numpy as np
+
+from ._logging import get_logger
+
+logger = get_logger("pyspi.parallel")
+
+
+def default_mp_context() -> str:
+ """Best start method for the current platform.
+
+ fork on Linux: workers inherit the parent's already-imported modules and
+ instantiated SPIs via copy-on-write, so worker startup is near-instant.
+ Measured ~2x faster end-to-end than spawn for a 252-SPI run.
+
+ spawn elsewhere: fork is unsafe on macOS (Accelerate/CoreFoundation after
+ init) and absent on Windows; spawn re-imports per worker but is correct.
+ """
+ return "fork" if sys.platform.startswith("linux") else "spawn"
+
+# Worker-local state populated by _worker_init. Module globals are safe here
+# because each worker process has its own independent copy.
+_WORKER_STATE: dict = {}
+
+
+def _attach_data(shm_name, shape, dtype_str, procnames, name):
+ """Build a Data object that views an existing shared-memory block.
+
+ The shared array is the parent's already-normalised/detrended ``_dataset._data``,
+ so we bypass Data.__init__ to avoid re-applying those transforms.
+ """
+ from pyspi.data import Data
+
+ shared = shm.SharedMemory(name=shm_name)
+ arr = np.ndarray(shape, dtype=np.dtype(dtype_str), buffer=shared.buf)
+ data = Data._from_prepared_array(arr, procnames=procnames, name=name)
+ return data, shared
+
+
+_BLAS_ENV_VARS = (
+ "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
+ "VECLIB_MAXIMUM_THREADS", "NUMEXPR_NUM_THREADS", "NUMBA_NUM_THREADS",
+)
+
+
+def _pin_blas_env() -> None:
+ """Force BLAS/threading env vars to 1 in the parent, before workers spawn.
+
+ threadpool_limits (see _pin_worker_thread_pools) pins OpenBLAS/MKL/OpenMP
+ at runtime; these env vars additionally cover numba and — for spawn
+ workers, which import numpy before _worker_init runs — make BLAS start
+ single-threaded from process start. Caveat: macOS Accelerate only partly
+ honours these (its vDSP/FFT path threads independently of any documented
+ env var), so on macOS n_jobs>1 can still oversubscribe FFT-heavy SPIs;
+ Linux (OpenBLAS/MKL) is fully covered.
+ """
+ for var in _BLAS_ENV_VARS:
+ os.environ[var] = "1"
+
+
+def available_cores() -> int:
+ """Cores this process may actually use.
+
+ ``sched_getaffinity`` honours cgroup/cpuset pinning, so under PBS or Slurm
+ this returns the cores the scheduler actually granted -- not the machine's
+ physical core count. That distinction is the whole point of the check in
+ :func:`guard_oversubscription`.
+ """
+ try:
+ return len(os.sched_getaffinity(0)) # Linux
+ except AttributeError:
+ return os.cpu_count() or 1
+
+
+def _requested_threads() -> int:
+ """Largest thread count any BLAS/OpenMP backend has been told to use."""
+ counts = [1]
+ for var in _BLAS_ENV_VARS:
+ try:
+ counts.append(int(os.environ.get(var, "1") or 1))
+ except ValueError:
+ pass
+ return max(counts)
+
+
+def guard_oversubscription(n_jobs: int) -> None:
+ """Warn -- or intervene -- when threads x processes exceeds the cores we hold.
+
+ The dangerous case is dataset-level parallelism on a cluster: many
+ single-core pyspi processes, each inheriting a site-wide
+ ``OMP_NUM_THREADS=8``, so a 48-core node runs 384 threads and thrashes.
+ ``pyspi/__init__`` only *defaults* the variable to 1, so an inherited value
+ survives by design -- a user who sets it deliberately should keep it.
+
+ When the scheduler granted exactly one core, more than one thread is never
+ right, so that case is pinned outright. Anything else only warns, since
+ pyspi cannot see how many sibling processes the scheduler started.
+ """
+ threads = _requested_threads()
+ cores = available_cores()
+ requested = n_jobs * threads
+
+ # pyEDM (ConvergentCrossMapping) self-parallelises over *processes*, so no
+ # BLAS thread count would cover it. It no longer needs covering here: its
+ # pools are unconditionally off at the call site, because pyEDM 2.5 starts
+ # them with forkserver/spawn and so re-imports the caller's __main__ (see
+ # statistics/causal.py).
+
+ if requested <= cores:
+ return
+
+ if cores == 1 and threads > 1:
+ _pin_blas_env()
+ try:
+ from threadpoolctl import threadpool_limits
+ global _THREADPOOL_LIMITER
+ _THREADPOOL_LIMITER = threadpool_limits(limits=1)
+ except ImportError:
+ pass
+ logger.warning(
+ "Only 1 core is available to this process but the BLAS thread count "
+ "is %d; pinned it to 1. This is the usual symptom of a scheduler "
+ "array job inheriting a site-wide OMP_NUM_THREADS -- set "
+ "OMP_NUM_THREADS=1 in your job script to silence this.",
+ threads,
+ )
+ return
+
+ logger.warning(
+ "Oversubscription: n_jobs=%d x %d BLAS thread(s) = %d workers for %d "
+ "available core(s). If you are running one dataset per process, set "
+ "OMP_NUM_THREADS=1; if you meant to parallelise within this dataset, "
+ "lower n_jobs.",
+ n_jobs, threads, requested, cores,
+ )
+
+
+_THREADPOOL_LIMITER = None # module-global so the limiter is never GC'd
+
+
+def _pin_worker_thread_pools():
+ """Pin every nested thread/process pool to 1 so process workers don't oversubscribe.
+
+ n_jobs workers each running a library that itself spawns cpu_count() threads
+ = quadratic blow-up. Pinning BLAS alone is not enough. The pools:
+ - BLAS + OpenMP (numpy/scipy/sklearn): threadpool_limits, all user APIs.
+ That is now the whole list. cdt used to be here (it autoset SETTINGS.NJOBS
+ to cpu_count() at import) and torch with it, but both are gone: the four
+ pairwise causal scores are in pyspi/lib/pairwise_causal.py, and torch never
+ drove any pyspi computation -- InterDependenceScore is NumPy, and torch
+ entered only because cdt eagerly imports its Torch-backed models.
+
+ pyEDM (drives ConvergentCrossMapping) is process-based, not thread-based, so
+ it can't be pinned here — it is instead switched off unconditionally at the
+ call site in statistics/causal.py, for correctness rather than scheduling.
+ """
+ global _THREADPOOL_LIMITER
+ try:
+ from threadpoolctl import threadpool_limits
+ _THREADPOOL_LIMITER = threadpool_limits(limits=1) # blas + openmp
+ except ImportError:
+ pass
+
+
+def _worker_init(shm_name, shape, dtype_str, procnames, ds_name, configfile, progress_q,
+ config_bytes=None):
+ """ProcessPoolExecutor initializer. Runs once per worker.
+
+ Re-instantiates SPIs from the configfile (some SPI classes use closures in
+ ``__init__`` that aren't picklable, so we can't ship instances across the
+ process boundary).
+ """
+ data, shared = _attach_data(shm_name, shape, dtype_str, procnames, ds_name)
+
+ # Direct call to the shared loader — no throwaway Calculator instantiation,
+ # no stdout suppression needed.
+ from pyspi.calculator import load_spis_from_yaml
+ # Load from the parent's snapshot, not the path: the file on disk may have
+ # changed since the parent instantiated its SPIs, which would bind results
+ # to parameters that did not produce them.
+ if config_bytes is not None:
+ import tempfile, os as _os
+ fd, tmp = tempfile.mkstemp(suffix=".yaml")
+ try:
+ with _os.fdopen(fd, "wb") as fh:
+ fh.write(config_bytes)
+ spis = load_spis_from_yaml(tmp)
+ finally:
+ _os.unlink(tmp)
+ else:
+ spis = load_spis_from_yaml(configfile)
+
+ # Pin nested thread pools AFTER the SPI modules import: a library that
+ # sizes its pool at import time has to be pinned once it exists.
+ _pin_worker_thread_pools()
+
+ _WORKER_STATE["data"] = data
+ _WORKER_STATE["shm"] = shared
+ _WORKER_STATE["spis"] = spis
+ _WORKER_STATE["progress_q"] = progress_q
+
+
+def _run_task(spi_keys, checkpoint_dir):
+ """Compute a bucket of SPIs sequentially in this worker.
+
+ Posts ``(key, failed)`` to the progress queue after each SPI, and returns
+ the list of ``(key, matrix, error_str_or_None, warnings, elapsed)`` tuples.
+ """
+ data = _WORKER_STATE["data"]
+ spis = _WORKER_STATE["spis"]
+ progress_q = _WORKER_STATE["progress_q"]
+ M = data.n_processes
+ out = []
+ for key in spi_keys:
+ S, err, warns, elapsed = run_spi(spis[key], data, key, M)
+ write_checkpoint(checkpoint_dir, key, S, err)
+ # Warnings travel back to the parent rather than being emitted (and
+ # lost) here in the worker process.
+ out.append((key, S, err, warns, elapsed))
+ if progress_q is not None:
+ progress_q.put((key, err is not None))
+ return out
+
+
+def run_spi(spi, data, key, M):
+ """Compute one SPI, validate it, and capture failures and warnings.
+
+ The single execution primitive shared by the serial and parallel paths.
+ Previously each path had its own copy: only the parallel one validated the
+ returned shape, and only the parallel one suppressed warnings, so the two
+ modes disagreed about what counted as a failure and about what the caller
+ got to see.
+
+ Returns ``(S, err, warns, elapsed)`` where ``err`` is ``None`` on success
+ and ``warns`` is a list of formatted warning strings raised during the
+ computation (returned rather than emitted, so the parallel path can
+ re-emit them in the parent process).
+
+ A result is a failure if it raises, has the wrong shape, contains an
+ infinity, or is entirely NaN off the diagonal. The last case previously
+ passed silently, which is how three group-delay SPIs shipped as all-NaN
+ columns with no warning attached.
+ """
+ t0 = time.perf_counter()
+ err = None
+ warns: list[str] = []
+ try:
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ S = spi.multivariate(data)
+ warns = [f"{w.category.__name__}: {w.message}" for w in caught]
+
+ S = np.array(S, dtype=float, copy=True)
+ if S.shape != (M, M):
+ raise ValueError(f"SPI returned shape {S.shape}, expected ({M},{M})")
+ np.fill_diagonal(S, np.nan)
+
+ offdiag = S[~np.eye(M, dtype=bool)]
+ if offdiag.size:
+ if np.isinf(offdiag).any():
+ raise ValueError("SPI returned infinite value(s)")
+ if not np.isfinite(offdiag).any():
+ raise ValueError("SPI returned no finite off-diagonal values")
+ except Exception as e:
+ S = np.full((M, M), np.nan)
+ err = f"{type(e).__name__}: {e}"
+
+ return S, err, warns, time.perf_counter() - t0
+
+
+def write_checkpoint(checkpoint_dir, key, S, err):
+ """Persist one SPI result plus its error sidecar, atomically."""
+ if checkpoint_dir is None:
+ return
+ d = Path(checkpoint_dir)
+ _atomic_npy_write(d / f"{key}.npy", S)
+ err_path = d / f"{key}.error"
+ if err is not None:
+ err_path.write_text(err)
+ elif err_path.exists():
+ err_path.unlink()
+
+
+def _atomic_npy_write(path: Path, arr: np.ndarray) -> None:
+ """Atomic write: numpy.save then os.replace. POSIX rename is atomic.
+
+ Note: ``np.save(path, arr)`` auto-appends ``.npy`` if absent — that
+ rewrites our ``.npy.tmp`` to ``.npy.tmp.npy`` and breaks the rename.
+ Passing a file handle bypasses that behaviour.
+ """
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ with open(tmp, "wb") as fh:
+ np.save(fh, arr)
+ os.replace(tmp, path)
+
+
+def cache_bucket(spi):
+ """``(namespace, *_cache_subkey)`` -- the key that actually shares a cache.
+
+ The one definition of "these SPIs share work", used by the scheduler here,
+ by ``calculator.warn_partial_cache_buckets`` and by ``bench/cut_config.py``.
+ ``None`` for an SPI that caches nothing.
+
+ Namespace alone is too coarse. ``_cache_subkey`` splits a namespace into
+ independent caches -- ``Barycenter`` caches per mode, so ``bary_dtw`` and
+ ``bary_softdtw`` share nothing, and the multitaper spectral SPIs cache per
+ class and sampling frequency. On the ``full`` config the namespaces divide
+ as: spectral_mv 84 SPIs across 16 independent caches, covariance 32 across
+ 8, spectral_bv 30 across 5, barycenter 16 across 4, coint 11 across 7,
+ ccm 9 across 3.
+ """
+ ns = getattr(type(spi), "_cache_namespace", None)
+ if ns is None:
+ return None
+ return (ns, *tuple(getattr(spi, "_cache_subkey", ())))
+
+
+# Amortized cost per SPI at the M=16, T=800 anchor cell
+# (bench/results/analysis/report.md). Used only to decide which task a worker
+# picks up first, so a stale or missing entry costs some makespan and nothing
+# else -- it cannot change a computed value. Anything unlisted is treated as
+# cheap.
+_NAMESPACE_COST = {"ccm": 292.7, "barycenter": 11.1, "spectral_bv": 1.6,
+ "spectral_mv": 0.3, "coint": 0.3, "covariance": 0.3}
+
+
+def build_tasks(spi_keys, spis) -> list[list[str]]:
+ """Bucket SPI keys by the cache they actually share.
+
+ SPIs in one bucket form a multi-SPI task, so the cached intermediate is
+ built once on the worker's Data and reused; SPIs that cache nothing become
+ single-SPI tasks.
+
+ Bucketing by ``_cache_namespace`` alone -- as this did -- serialises SPIs
+ that share no cache at all. On ``full`` it produced one 84-member
+ ``spectral_mv`` task covering 16 independent caches, and the longest task
+ is what bounds the makespan: no amount of parallelism could split it.
+ Bucketing on ``cache_bucket`` gives 43 shareable groups whose largest has
+ 24 members.
+
+ Ordering is by estimated cost (members times the namespace's measured
+ amortized cost) rather than member count, so a 3-member ``ccm`` bucket
+ starts before a 24-member ``covariance`` one. This affects scheduling only;
+ every task is computed identically whichever order it runs in.
+ """
+ cacheless: list[list[str]] = []
+ grouped: dict[tuple, list[str]] = defaultdict(list)
+ for key in spi_keys:
+ bucket = cache_bucket(spis[key])
+ if bucket is None:
+ cacheless.append([key])
+ else:
+ grouped[bucket].append(key)
+
+ def cost(item):
+ bucket, keys = item
+ return len(keys) * _NAMESPACE_COST.get(bucket[0], 0.1)
+
+ grouped_tasks = [keys for _, keys in
+ sorted(grouped.items(), key=cost, reverse=True)]
+ return grouped_tasks + cacheless
+
+
+MANIFEST_NAME = "run.json"
+SCHEMA_VERSION = 1
+# Bumped when a change alters computed values, so checkpoints cannot outlive the
+# algorithm that produced them, and so `Calculator.run_digest` separates results
+# that differ only by implementation. `.r`: the revision is a
+# counter within a release, not a version of anything -- only equality is ever
+# tested.
+# r2 -- the KSG per-occurrence dither, full MAX_CORR_AIS, the group-delay
+# sample scaling and |r|, and the cross-correlation threshold returning
+# zero when nothing clears it.
+# r3 -- the KSG dither key is taken from the normalised column rounded to 12
+# decimals rather than its exact bytes. On tie-free data that changes
+# nothing (only the integer neighbour counts enter the estimate), but on
+# tied or quantised data a different dither separates the ties
+# differently, so values there can move.
+# r4 -- KSG uses the rounded coordinate in its geometry, canonicalises
+# reflections, and assigns numerical key collisions by content rather
+# than coordinate order. Tied/quantised and tolerance-boundary values
+# can move.
+# r5 -- KSG refuses tied coordinates and no longer rounds or dithers valid
+# continuous inputs. This removes arbitrary order-dependent tie
+# breaking and the artificial 12-decimal conditioning boundary.
+# r6 -- KSG strict marginal counts use the immediately preceding
+# representable radius instead of shrinking epsilon by 1e-10.
+# r7 -- coherence phase uses a circular mean and refuses zero-resultant and
+# antipodal locations that have no unique signed float orientation.
+COMPUTATION_VERSION = "3.0.0.r7"
+
+
+def read_manifest(checkpoint_dir: Path):
+ """Return the manifest dict for a checkpoint directory, or None."""
+ path = Path(checkpoint_dir) / MANIFEST_NAME
+ if not path.exists():
+ return None
+ try:
+ return json.loads(path.read_text())
+ except (json.JSONDecodeError, OSError):
+ return None
+
+
+def write_manifest(checkpoint_dir: Path, digest: str, spec: dict) -> None:
+ """Record which run owns this checkpoint directory."""
+ path = Path(checkpoint_dir) / MANIFEST_NAME
+ payload = {"schema": SCHEMA_VERSION, "computation": COMPUTATION_VERSION,
+ "digest": digest, "spec": spec}
+ tmp = path.with_suffix(".json.tmp")
+ tmp.write_text(json.dumps(payload, indent=1, sort_keys=True, default=str))
+ os.replace(tmp, path)
+
+
+def checkpoint_owner_matches(checkpoint_dir: Path, digest: str):
+ """Return (matches, reason). A directory with no manifest is unowned.
+
+ An unowned directory is treated as a mismatch rather than a match: it was
+ written by a version that did not record provenance, and there is no way to
+ tell whether it belongs to this run.
+ """
+ manifest = read_manifest(checkpoint_dir)
+ if manifest is None:
+ # Nothing written yet is fine; a populated directory without a manifest
+ # is not.
+ existing = any(Path(checkpoint_dir).glob("*.npy"))
+ if not existing:
+ return True, None
+ return False, "checkpoint directory has results but no run manifest"
+ if manifest.get("schema") != SCHEMA_VERSION:
+ return False, (
+ f"manifest schema {manifest.get('schema')!r} != {SCHEMA_VERSION}"
+ )
+ if manifest.get("computation") != COMPUTATION_VERSION:
+ return False, (f"checkpoint computed by pyspi {manifest.get('computation')!r}, "
+ f"not {COMPUTATION_VERSION!r}")
+ if manifest.get("digest") != digest:
+ return False, "checkpoint was written by a different run"
+ return True, None
+
+
+def load_checkpoints(checkpoint_dir: Path, spi_keys, M: int, retry_failed: bool = True):
+ """Return (done_results, remaining_keys).
+
+ done_results: dict[key] -> (matrix, error_or_None, warns, 0.0).
+ A key is considered done if ``.npy`` exists and has shape (M, M).
+
+ A checkpoint carrying an ``.error`` sidecar records a *failed* SPI. By
+ default those are retried rather than resumed: a failure is usually caused
+ by something transient or since-fixed, and silently inheriting a NaN column
+ from a previous run is the outcome resume is least likely to be wanted for.
+ Pass ``retry_failed=False`` to resume them as-is.
+ """
+ done: dict = {}
+ remaining: list = []
+ for key in spi_keys:
+ npy = checkpoint_dir / f"{key}.npy"
+ if not npy.exists():
+ remaining.append(key)
+ continue
+ try:
+ arr = np.load(npy)
+ except Exception:
+ remaining.append(key)
+ continue
+ if arr.shape != (M, M):
+ remaining.append(key)
+ continue
+ err_path = checkpoint_dir / f"{key}.error"
+ err = err_path.read_text() if err_path.exists() else None
+ # Validate *before* the retry decision, so an invalid matrix is retried
+ # rather than being marked failed and then kept.
+ off = arr[~np.eye(M, dtype=bool)] if M > 1 else arr.ravel()
+ if off.size and (np.isinf(off).any() or not np.isfinite(off).any()):
+ err = err or "ValueError: checkpoint contains no finite values"
+ if err is not None and retry_failed:
+ remaining.append(key)
+ continue
+ done[key] = (arr, err, [], 0.0)
+ return done, remaining
+
+
+def run_parallel(
+ spis: dict,
+ dataset,
+ spi_keys: list[str],
+ n_jobs: int,
+ mp_context: str,
+ checkpoint_dir: Optional[Path],
+ progress: bool,
+ configfile: str,
+ config_bytes: bytes | None = None,
+) -> dict:
+ """Execute ``spi_keys`` across ``n_jobs`` workers; return dict[key] -> (S, err, elapsed)."""
+ from tqdm import tqdm
+
+ # Pin BLAS env before any worker spawns — workers inherit single-threaded
+ # BLAS from process start (the only lever for macOS Accelerate).
+ _pin_blas_env()
+
+ arr = np.ascontiguousarray(dataset._data)
+ M = arr.shape[0]
+ tasks = build_tasks(spi_keys, spis)
+ cp_str = str(checkpoint_dir) if checkpoint_dir is not None else None
+
+ # Create resources inside the try so a failure constructing either one
+ # (e.g. mp.Manager() raising) still runs the cleanup in finally.
+ shared = None
+ manager = None
+ try:
+ shared = shm.SharedMemory(create=True, size=arr.nbytes)
+ manager = mp.Manager()
+ progress_q = manager.Queue()
+ shared_view = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shared.buf)
+ shared_view[:] = arr
+
+ ctx = mp.get_context(mp_context)
+ results: dict = {}
+ pbar = tqdm(total=len(spi_keys), desc="SPIs", disable=not progress)
+ with cf.ProcessPoolExecutor(
+ max_workers=n_jobs,
+ mp_context=ctx,
+ initializer=_worker_init,
+ initargs=(
+ shared.name, arr.shape, str(arr.dtype),
+ list(dataset.procnames), getattr(dataset, "_name", None),
+ configfile, progress_q, config_bytes,
+ ),
+ ) as ex:
+ future_to_task = {ex.submit(_run_task, task, cp_str): task for task in tasks}
+ pending = set(future_to_task)
+ while pending:
+ # Per-SPI progress ticks (cosmetic; bar stalls on a stuck SPI).
+ while True:
+ try:
+ key, _failed = progress_q.get_nowait()
+ pbar.update(1)
+ pbar.set_postfix_str(key[:32])
+ except _queue.Empty:
+ break
+ # Harvest finished futures (source of truth for results).
+ done = {f for f in pending if f.done()}
+ for fut in done:
+ task = future_to_task[fut]
+ try:
+ for key, S, err, warns, elapsed in fut.result():
+ results[key] = (S, err, warns, elapsed)
+ except Exception as exc: # worker process died (segfault/OOM)
+ for key in task:
+ results.setdefault(
+ key,
+ (np.full((M, M), np.nan), f"worker died: {exc}", [], 0.0),
+ )
+ pending -= done
+ if pending:
+ time.sleep(0.05)
+ # Drain any progress events that arrived after the last poll, then
+ # hard-sync the bar (a dead worker can leave it a few ticks short).
+ while True:
+ try:
+ progress_q.get_nowait()
+ pbar.update(1)
+ except _queue.Empty:
+ break
+ pbar.n = len(results)
+ pbar.refresh()
+ pbar.close()
+ return results
+ finally:
+ if manager is not None:
+ manager.shutdown()
+ if shared is not None:
+ shared.close()
+ try:
+ shared.unlink()
+ except FileNotFoundError:
+ pass
diff --git a/pyspi/base.py b/pyspi/base.py
index d6e299ef..324e509c 100644
--- a/pyspi/base.py
+++ b/pyspi/base.py
@@ -40,9 +40,33 @@ def parsed_function(self,data,data2=None,i=None,j=None,inplace=True):
if i is None and j is None:
if data.n_processes == 2:
- i,j = 0,1
+ i, j = 0, 1
else:
- Warning('i and j not set.')
+ raise ValueError(
+ f'Require arguments i and j to be set: the dataset has '
+ f'{data.n_processes} processes, so there is no default '
+ f'pair. (Use multivariate() for the whole matrix.)'
+ )
+ elif i is None or j is None:
+ # One index alone was a warning-free path straight into
+ # `z[None]`, which numpy reads as `np.newaxis`: the "pair" became
+ # the whole (1, M, T) block and the SPI computed something with no
+ # relation to what was asked for. The signature is
+ # `(data, data2, i, j)`, so `bivariate(data, 0, 3)` -- the obvious
+ # way to write it -- lands here with i=3 and j unset.
+ raise ValueError(
+ f'Both i and j must be given (got i={i!r}, j={j!r}). Note the '
+ f'signature is bivariate(data, data2=None, i=None, j=None), so '
+ f'positional indices must be passed as keywords.'
+ )
+ for name, idx in (("i", i), ("j", j)):
+ if not (isinstance(idx, (int, np.integer)) and not isinstance(idx, bool)):
+ raise TypeError(f'{name} must be an integer process index, '
+ f'got {idx!r}.')
+ if not 0 <= idx < data.n_processes:
+ raise IndexError(
+ f'{name}={idx} is out of range for {data.n_processes} '
+ f'process(es).')
return function(self,data,i=i,j=j)
diff --git a/pyspi/calculator.py b/pyspi/calculator.py
index e8c1e7c6..827b13dd 100644
--- a/pyspi/calculator.py
+++ b/pyspi/calculator.py
@@ -2,15 +2,361 @@
import numpy as np
import pandas as pd
import copy, yaml, importlib, time, warnings, os
+import functools
+import hashlib, json
+from pathlib import Path
from tqdm import tqdm
-from collections import Counter
-from scipy import stats
-from colorama import init, Fore
-init(autoreset=True)
# From this package
from .data import Data
-from .utils import convert_mdf_to_ddf, check_optional_deps, inspect_calc_results
+from .utils import convert_mdf_to_ddf, inspect_calc_results, require_int
+from . import _parallel
+from ._logging import get_logger, configure as _configure_logging
+
+logger = get_logger("pyspi.calculator")
+
+
+# ---------------------------------------------------------------------------
+# LaggedCorrelation config expansion: max_tau -> tau=1..max_tau
+# ---------------------------------------------------------------------------
+
+def _as_label_list(labels):
+ if labels is None:
+ return []
+ if isinstance(labels, str):
+ return [labels]
+ return list(labels)
+
+
+def _is_module_label(label):
+ return (
+ isinstance(label, str)
+ and len(label) == 3
+ and label[0] == "M"
+ and (label[1:].isdigit() or label[1:] == "XX")
+ )
+
+
+def _merge_spi_labels(spi, family_labels=None, config_labels=None):
+ labels = list(getattr(spi, "labels", []))
+ family_labels = _as_label_list(family_labels)
+ config_labels = _as_label_list(config_labels)
+
+ if any(_is_module_label(label) for label in config_labels):
+ labels = [label for label in labels if not _is_module_label(label)]
+ family_labels = [
+ label for label in family_labels if not _is_module_label(label)
+ ]
+
+ merged = []
+ for label in labels + family_labels + config_labels:
+ if label not in merged:
+ merged.append(label)
+
+ # The SPI's own `issigned()` is authoritative over any declared
+ # signed/unsigned label. It is not metadata: `Calculator._rmmin` subtracts
+ # the minimum from every SPI reporting unsigned, and `set_group` correlates
+ # unsigned SPIs through `abs()`. A config that declares `unsigned` over an
+ # antisymmetric measure (which the shipped configs do for `phase`, `pli`,
+ # `wpli`, `psi`, `gd` and `ccm_*_diff`) does not merely mislabel it -- it
+ # asks for a transform that destroys the lead/lag its sign carries. The
+ # label follows the implementation, not the other way round.
+ # The SPI's *own* structural trait is authoritative over any structural
+ # trait the config declares. `labels` here is the instance's list, before the
+ # family and per-config labels are folded in, so it is what the class and
+ # `__init__` decided from the shape of the matrix they produce.
+ #
+ # Exactly one of the four traits survives. Without this, `gd_*` carried the
+ # class's `antisymmetric` and the config's `directed` at once, and
+ # `gd_*_rvalue` -- symmetric by construction, since it stores |r| -- came
+ # back both `undirected` and `directed` when loaded through YAML, so
+ # `filter_spis` answered either way for the same SPI.
+ _DIRECTEDNESS = ("antisymmetric", "asymmetric", "undirected", "directed")
+ own = [label for label in _DIRECTEDNESS if label in labels]
+ if own:
+ trait = own[0]
+ merged = [
+ label for label in merged
+ if label not in _DIRECTEDNESS or label == trait
+ ]
+
+ issigned = getattr(spi, "issigned", None)
+ if issigned is not None:
+ actual = "signed" if issigned() else "unsigned"
+ merged = [label for label in merged
+ if label not in ("signed", "unsigned")] + [actual]
+ spi.labels = merged
+
+
+def _split_config_params(params):
+ params = dict(params or {})
+ config_labels = params.pop("labels", None)
+ return params, config_labels
+
+CONFIG_DIR = Path(__file__).parent / "configs"
+
+
+def bundled_configs():
+ """Names of the bundled configs, i.e. the valid non-path values of ``config``."""
+ return sorted(p.stem for p in CONFIG_DIR.glob("*.yaml"))
+
+
+def resolve_config(config):
+ """Resolve ``config`` to a config yaml path.
+
+ Accepts either the name of a bundled config (``"full"``, ``"fast"``,
+ ``"benchmarked_p90"``, ...) or a path to a user-written yaml. A value is
+ treated as a path if it carries a directory component or a ``.yaml``/
+ ``.yml`` suffix; otherwise it is looked up in :data:`CONFIG_DIR`.
+ """
+ text = str(config)
+ looks_like_path = (
+ os.sep in text
+ or (os.altsep is not None and os.altsep in text)
+ or text.endswith((".yaml", ".yml"))
+ )
+ if looks_like_path:
+ path = Path(text).expanduser()
+ if not path.is_file():
+ raise FileNotFoundError(f"Config file not found: {path}")
+ return str(path)
+
+ path = CONFIG_DIR / f"{text}.yaml"
+ if not path.is_file():
+ raise ValueError(
+ f"Unknown config '{text}'. Bundled configs are: "
+ f"{', '.join(bundled_configs())}. "
+ f"To use your own, pass a path to a .yaml file."
+ )
+ return str(path)
+
+
+@functools.lru_cache(maxsize=1)
+def _full_config_cache_buckets():
+ """``{cache_bucket: {identifiers}}`` for the `full` config, built once.
+
+ Cached because the caller is an advisory check that runs on every
+ `Calculator` construction, and the answer is a property of the shipped
+ config rather than of the run.
+ """
+ from collections import defaultdict
+
+ available = defaultdict(set)
+ for key, spi in load_spis_from_yaml(resolve_config("full"), quiet=True).items():
+ bucket = _parallel.cache_bucket(spi)
+ if bucket is not None:
+ available[bucket].add(key)
+ return available
+
+
+def warn_partial_cache_buckets(spis):
+ """Warn when a config keeps only part of a shared-cache group.
+
+ Several SPI families build one expensive cached intermediate on the Data
+ object and then derive cheap variants from it (``ccm``, ``spectral_mv``,
+ ``barycenter``, ...). The cache is built as soon as *any* member runs, so
+ keeping a strict subset pays the full cache cost for fewer SPIs -- the
+ remaining members are close to free. ``bench/cut_config.py`` already snaps
+ generated configs up to whole buckets; this gives a hand-written config
+ the same nudge.
+ """
+ # Only caches expensive enough for the advice to matter. Amortized cost per
+ # SPI at the M=16, T=800 anchor cell (bench/results/analysis/report.md):
+ # ccm 292.7s | barycenter 11.1s | spectral_bv 1.6s | spectral_mv, coint,
+ # covariance all <0.3s.
+ # Warning on the cheap ones is noise: fabfour deliberately keeps 1 of 32
+ # covariance SPIs, and the whole covariance cache is 0.6s.
+ EXPENSIVE = {"ccm", "barycenter"}
+
+ from collections import defaultdict
+
+ # The same definition the scheduler buckets on; see _parallel.cache_bucket.
+ bucket = _parallel.cache_bucket
+
+ # Keyed by identifier, not class: a bucket is a set of *variants* (ccm is
+ # one class with nine configs), and it is the variants that share the cache.
+ kept = defaultdict(set)
+ for key, spi in spis.items():
+ b = bucket(spi)
+ if b is not None:
+ kept[b].add(key)
+ if not any(bkey[0] in EXPENSIVE for bkey in kept):
+ # Nothing this advisory could say anything about. Checked before the
+ # `full` config is touched: building it instantiates 322 SPIs and pulls
+ # a heavy dependency tree, which is real import time for a check that
+ # only ever comments on `ccm` and `barycenter`.
+ return
+ try:
+ available = _full_config_cache_buckets()
+ except Exception: # never let an advisory check break a run
+ return
+ for bkey, have in sorted(kept.items(), key=lambda kv: str(kv[0])):
+ ns = bkey[0]
+ if ns not in EXPENSIVE:
+ continue
+ missing = sorted(available.get(bkey, set()) - have)
+ if not missing:
+ continue
+ shown = ", ".join(missing[:3]) + (f", +{len(missing) - 3} more" if len(missing) > 3 else "")
+ logger.warning(
+ "Config keeps %d of %d SPIs sharing the '%s' cache. That cache is "
+ "built regardless, so the other %d (%s) are close to free -- "
+ "keeping a strict subset pays the full cache cost for fewer SPIs.",
+ len(have), len(have) + len(missing), ns, len(missing), shown,
+ )
+
+
+# Fields of `run_spec` that say *where* the run came from rather than *what* it
+# computes. They are recorded for provenance and deliberately kept out of
+# `run_digest`, which is a content hash: the same config contents and the same
+# data must digest the same from a checkout, a wheel or a temporary directory.
+_DIGEST_EXCLUDED_SPEC_FIELDS = frozenset({"config", "configfile", "dataset_name"})
+
+
+# Bumped whenever the .npz layout changes incompatibly. Schema 0 means "written
+# before the field existed", i.e. a pre-3.0.0 pickled file.
+_NPZ_SCHEMA = 1
+
+
+def load_table(path):
+ """Load a results table written by :meth:`Calculator.save`.
+
+ Returns the same DataFrame ``Calculator.table`` returns: rows are
+ processes, columns are a ``(spi, process)`` MultiIndex. Only ``.npz``
+ round-trips exactly -- ``.csv`` is a one-way human-readable export.
+ """
+ path = Path(path)
+ if path.suffix != ".npz":
+ raise ValueError(
+ f"Can only load '.npz' (got '{path.suffix}'). CSV export is one-way; "
+ f"re-run the calculation or save as .npz."
+ )
+ # allow_pickle=False: loading a results table must never be able to execute
+ # code. Files written by pyspi < 3.0.0 stored names as object arrays and
+ # will fail here; re-save them from a Calculator.
+ with np.load(path, allow_pickle=False) as f:
+ missing = {"values", "spis", "processes"} - set(f.files)
+ if missing:
+ raise ValueError(
+ f"{path} is not a pyspi results table (missing {sorted(missing)})."
+ )
+ schema = int(f["schema"]) if "schema" in f.files else 0
+ if schema > _NPZ_SCHEMA:
+ raise ValueError(
+ f"{path} was written with schema {schema}, but this pyspi "
+ f"understands up to {_NPZ_SCHEMA}. Upgrade pyspi."
+ )
+ values = f["values"]
+ spis = [str(s) for s in f["spis"]]
+ procs = [str(p) for p in f["processes"]]
+ # Provenance. `save()` has always written these three and `load_table`
+ # has never read them, so a loaded table could not be asked which SPIs
+ # failed, what produced it, or whether it matched a rerun.
+ meta = {name: str(f[name]) for name in ("run_spec", "run_digest", "errors")
+ if name in f.files}
+
+ # The whole shape, not just `ndim` and axis 0. A file whose matrices were
+ # the wrong width reached `MultiIndex.from_product` and failed there, with
+ # a reshape error rather than a statement about the file.
+ if values.shape != (len(spis), len(procs), len(procs)):
+ raise ValueError(
+ f"{path} is malformed: values has shape {values.shape}, expected "
+ f"({len(spis)}, {len(procs)}, {len(procs)})."
+ )
+ table = pd.DataFrame(
+ data=np.concatenate(list(values), axis=1),
+ columns=pd.MultiIndex.from_product([spis, procs], names=["spi", "process"]),
+ index=procs,
+ )
+ table.columns.name = "process"
+ # `DataFrame.attrs` rather than a wrapper type: it is pandas' documented
+ # place for exactly this, and it keeps `load_table` returning the same
+ # object `Calculator.table` does.
+ table.attrs["schema"] = schema
+ for name in ("run_spec", "errors"):
+ if name in meta:
+ try:
+ table.attrs[name] = json.loads(meta[name])
+ except json.JSONDecodeError:
+ table.attrs[name] = meta[name]
+ if "run_digest" in meta:
+ table.attrs["run_digest"] = meta["run_digest"]
+ return table
+
+
+def load_spis_from_yaml(configfile, quiet=False):
+ """Instantiate all SPIs from a configfile.
+
+ Returns a dict mapping identifier to SPI instance.
+
+ Shared between :class:`Calculator` and the parallel worker initializer
+ (see :func:`pyspi._parallel._worker_init`) so workers don't need to
+ instantiate a throwaway Calculator just to rebuild ``_spis``. Progress is
+ emitted via the ``pyspi.calculator`` logger at INFO level.
+ """
+ spis = {}
+ log = (lambda *a, **k: None) if quiet else logger.info
+ log("Loading configuration file: %s", configfile)
+ with open(configfile) as f:
+ yf = yaml.load(f, Loader=yaml.FullLoader)
+ for module_name, module_spis in yf.items():
+ log("Importing module %s", module_name)
+ module = importlib.import_module(module_name, __package__)
+ for fcn, entry in (module_spis or {}).items():
+ family_labels = entry.get("labels")
+ configs = entry.get("configs")
+ if fcn == "LaggedCorrelation" and configs is not None:
+ configs = _expand_lagged_correlation_configs(configs)
+ if configs is None:
+ spi = getattr(module, fcn)()
+ _merge_spi_labels(spi, family_labels)
+ _insert_spi(spis, spi, module_name, fcn, None)
+ log('[%d] %s.%s(x,y) -> "%s"', len(spis), module_name, fcn, spi.identifier)
+ continue
+ for params in configs:
+ params, config_labels = _split_config_params(params)
+ spi = getattr(module, fcn)(**params)
+ _merge_spi_labels(spi, family_labels, config_labels)
+ _insert_spi(spis, spi, module_name, fcn, params)
+ log('[%d] %s.%s(x,y,%s) -> "%s"', len(spis), module_name, fcn, params, spi.identifier)
+ return spis
+
+
+def _insert_spi(spis, spi, module_name, fcn, params):
+ """Insert an SPI, rejecting identifier collisions.
+
+ The identifier is the primary key: it names the table column and the
+ checkpoint file. Detecting duplicates *after* building the dict could never
+ work, because dict insertion has already discarded the loser -- the old
+ ``Counter(self._spis.keys())`` check could not return a count above 1.
+ """
+ existing = spis.get(spi.identifier)
+ if existing is not None:
+ raise ValueError(
+ f"Duplicate SPI identifier {spi.identifier!r}: "
+ f"{type(existing).__name__} and {module_name}.{fcn}"
+ f"{f'({params})' if params else ''} both produce it. "
+ "Two configs of the same class must differ in a parameter that "
+ "reaches the identifier."
+ )
+ spis[spi.identifier] = spi
+
+
+def _expand_lagged_correlation_configs(configs):
+ expanded = []
+ for params in configs or []:
+ if "max_tau" in params:
+ if "tau" in params:
+ raise ValueError("LaggedCorrelation config cannot set both tau and max_tau.")
+ max_tau = require_int("max_tau", params["max_tau"], minimum=1)
+ base = {key: value for key, value in params.items() if key != "max_tau"}
+ for tau in range(1, max_tau + 1):
+ entry = dict(base)
+ entry["tau"] = tau
+ expanded.append(entry)
+ else:
+ expanded.append(dict(params))
+ return expanded
class Calculator:
@@ -32,99 +378,65 @@ class Calculator:
The name of the calculator. Mainly used for printing the results but can be useful if you have multiple instances, default=None.
labels (array_like, optional):
Any set of strings by which you want to label the calculator. This can be useful later for classification purposes, default=None.
- subset (str, optional):
- A pre-configured subset of SPIs to use. Options are "all", "fast", "sonnet", or "fabfour", default="all".
- configfile (str, optional):
- The location of the YAML configuration file for a user-defined subset. See :ref:`Using a reduced SPI set`, defaults to :code:`' /pyspi/config.yaml'`
+ config (str, optional):
+ Which SPIs to compute. Either the name of a bundled config or a path
+ to your own YAML file, default="full". Bundled configs are:
+
+ - ``"full"`` -- every SPI (322).
+ - ``"fast"`` -- drops the slowest SPIs.
+ - ``"sonnet"`` -- 14 representative SPIs, one per module (M01-M14).
+ - ``"fabfour"`` -- 4 SPIs: covariance, Spearman, directed information,
+ power-envelope correlation.
+ - ``"benchmarked_p80"`` / ``"_p90"`` / ``"_p95"`` / ``"_p99"`` -- keep
+ the fastest N% of SPIs by measured amortized compute cost, so
+ ``benchmarked_p80`` is the cheapest and ``benchmarked_p99`` the most
+ complete. See ``bench/README.md`` for how these were derived.
detrend (bool, optional):
If True, detrend each time series in the MTS dataset individually along the time axis, default=False.
- normalise (bool, optional):
- If True, z-score normalise each time series in the MTS dataset individually along the time axis, default=True.
+ zscore (bool, optional):
+ If True, z-score each time series in the MTS dataset individually along
+ the time axis, default=True. Per-process (rather than whole-dataset)
+ standardisation is deliberate: it removes each process's arbitrary
+ gain/units without letting the choice of the other processes in the
+ dataset influence any pairwise statistic.
"""
- _optional_dependencies = None
-
def __init__(
- self, dataset=None, name=None, labels=None, subset="all", configfile=None,
- detrend=False, normalise=True
+ self, dataset=None, name=None, labels=None, config="full",
+ detrend=False, zscore=True, verbose=True,
):
self._spis = {}
- self._excluded_spis = list()
- self._normalise = normalise
+ self._zscore = zscore
self._detrend = detrend
+ self._timings = {}
+ self._errors = {}
+ self._verbose = verbose
- # Define configfile by subset if it was not specified
- if configfile is None:
- if subset == "fast":
- configfile = (
- os.path.dirname(os.path.abspath(__file__)) + "/fast_config.yaml"
- )
- elif subset == "sonnet":
- configfile = (
- os.path.dirname(os.path.abspath(__file__)) + "/sonnet_config.yaml"
- )
- elif subset == "fabfour":
- configfile = (
- os.path.dirname(os.path.abspath(__file__)) + "/fabfour_config.yaml"
- )
- # If no configfile was provided but the subset was not one of the above (or the default 'all'), raise an error
- elif subset != "all":
- raise ValueError(
- f"Subset '{subset}' does not exist. Try 'all' (default), 'fast', 'sonnet', or 'fabfour'."
- )
- else:
- configfile = os.path.dirname(os.path.abspath(__file__)) + "/config.yaml"
+ # verbose maps to a process-global pyspi logger level (INFO vs WARNING).
+ _configure_logging(verbose)
- # add dependency checks here if the calculator is being instantiated for the first time
- if not Calculator._optional_dependencies:
- # check if optional dependencies exist
- print("Checking if optional dependencies exist...")
- Calculator._optional_dependencies = check_optional_deps()
+ configfile = resolve_config(config)
- self._load_yaml(configfile)
-
- duplicates = [
- name for name, count in Counter(self._spis.keys()).items() if count > 1
- ]
- if len(duplicates) > 0:
- raise ValueError(
- f"Duplicate SPI identifiers: {duplicates}.\n Check the config file for duplicates."
- )
+ self._configfile = configfile # stored so parallel workers can re-instantiate SPIs
+ # Snapshot at construction: the SPIs were built from *these* bytes, so
+ # the digest must reflect them even if the file changes afterwards.
+ try:
+ self._config_bytes = Path(configfile).read_bytes()
+ except OSError:
+ self._config_bytes = b""
+ self._config = config
+ # Duplicates are rejected at insertion inside load_spis_from_yaml; a
+ # post-hoc Counter over dict keys can never see a count above 1.
+ self._spis = load_spis_from_yaml(configfile)
self._name = name
self._labels = labels
- print(f"="*100)
- print(Fore.GREEN + f"{len(self.spis)} SPI(s) were successfully initialised.\n")
- if len(self._excluded_spis) > 0:
- missing_deps = [dep for dep, is_met in self._optional_dependencies.items() if not is_met]
- print(Fore.YELLOW + "**** SPI Initialisation Warning ****")
- print(Fore.YELLOW + "\nSome dependencies were not detected, which has led to the exclusion of certain SPIs:")
- print("\nMissing Dependencies:")
-
- for dep in missing_deps:
- print(f"- {dep}")
-
- print(f"\nAs a result, a total of {len(self._excluded_spis)} SPI(s) have been excluded:\n")
-
- dependency_groups = {}
- for spi in self._excluded_spis:
- for dep in spi[1]:
- if dep not in dependency_groups:
- dependency_groups[dep] = []
- dependency_groups[dep].append(spi[0])
-
- for dep, spis in dependency_groups.items():
- print(f"\nDependency - {dep} - affects {len(spis)} SPI(s)")
- print("Excluded SPIs:")
- for spi in spis:
- print(f" - {spi}")
-
- print(f"\n" + "="*100)
- print(Fore.YELLOW + "\nOPTIONS TO PROCEED:\n")
- print(f" 1) Install the following dependencies to access all SPIs: [{', '.join(missing_deps)}]")
- callable_name = "{Calculator/CalculatorFrame}"
- print(f" 2) Continue with a reduced set of {self.n_spis} SPIs by calling {callable_name}.compute(). \n")
- print(f"="*100 + "\n")
+ logger.info("%d SPI(s) were successfully initialised.", len(self.spis))
+ # Bundled configs are curated deliberately -- sonnet, for instance, is
+ # one representative SPI per module, not a cost-optimised set -- so the
+ # advice only applies to configs the user wrote.
+ if Path(configfile).parent != CONFIG_DIR:
+ warn_partial_cache_buckets(self._spis)
if dataset is not None:
self.load_dataset(dataset)
@@ -141,6 +453,89 @@ def spis(self):
def spis(self, s):
raise Exception("Do not set this property externally.")
+ @property
+ def errors(self):
+ """``{identifier: "ExcType: message"}`` for every SPI that failed.
+
+ A failed SPI still occupies its column in :attr:`table`, filled with
+ NaN. Consult this before interpreting a table: a NaN column is
+ otherwise indistinguishable from a legitimately undefined statistic.
+ """
+ return dict(self._errors)
+
+ @property
+ def run_digest(self):
+ """Content hash of the resolved run: spec plus the input data itself.
+
+ This is what a checkpoint is bound to. Resume previously validated only
+ the SPI identifier and an ``(M, M)`` shape, so any other run of the same
+ width silently inherited the earlier run's numbers -- a different
+ dataset, different preprocessing, a different config, or a permuted
+ process order all resumed clean.
+
+ The dataset bytes are hashed, not just its shape and name: two datasets
+ of the same width with the same name are exactly the case that needs
+ separating.
+ """
+ # Location-only fields are excluded. `config` and `configfile` are the
+ # name and the *absolute resolved path*, so hashing the spec verbatim
+ # made an identical config and dataset digest differently in a source
+ # checkout and in an installed wheel -- and differently again in a
+ # temporary directory. That is a false negative rather than an unsafe
+ # reuse (a checkpoint is refused when it should have been accepted), but
+ # it defeats the point of a content hash. The path stays in `run_spec`
+ # as provenance; the *contents* are hashed below.
+ spec = {k: v for k, v in self.run_spec.items()
+ if k not in _DIGEST_EXCLUDED_SPEC_FIELDS}
+ h = hashlib.sha256()
+ h.update(json.dumps(spec, sort_keys=True, default=str).encode())
+ # The algorithm, not only its inputs. Identical data and an identical
+ # config computed by two different estimator implementations are two
+ # different results, and a digest that cannot tell them apart lets a
+ # checkpoint from one be resumed by the other. `COMPUTATION_VERSION` is
+ # bumped whenever a change alters computed values.
+ h.update(_parallel.COMPUTATION_VERSION.encode())
+ # Hash the config *contents*, not just its path and the identifiers it
+ # produces. A config edited in place otherwise produced an identical
+ # digest and silently resumed the previous parameterisation's results.
+ h.update(self._config_bytes)
+ dataset = getattr(self, "_dataset", None)
+ if dataset is not None:
+ arr = np.ascontiguousarray(dataset.to_numpy())
+ # Native dtype: coercing to float64 first made distinct large-integer
+ # datasets collide.
+ h.update(f"{arr.dtype.str}{arr.shape}".encode())
+ h.update(arr.tobytes())
+ return h.hexdigest()
+
+ @property
+ def run_spec(self):
+ """The resolved specification of what this Calculator computes.
+
+ One canonical description of the run — the config actually resolved to,
+ the preprocessing actually applied, the dataset shape and process names
+ actually loaded, and the SPI set actually instantiated. Recorded so a
+ result can be tied back to the run that produced it rather than being
+ identified by SPI name and matrix width alone.
+ """
+ dataset = getattr(self, "_dataset", None)
+ return {
+ "config": str(self._config),
+ "configfile": str(self._configfile),
+ # The dataset's own flags: a prepared Data supplied by the caller
+ # carries its own preprocessing, and the Calculator's flags were
+ # never applied to it.
+ "zscore": bool(dataset.zscore) if dataset is not None else bool(self._zscore),
+ "detrend": bool(dataset.detrend) if dataset is not None else bool(self._detrend),
+ "n_processes": int(dataset.n_processes) if dataset is not None else None,
+ "n_observations": (
+ int(dataset.n_observations) if dataset is not None else None
+ ),
+ "procnames": list(dataset.procnames) if dataset is not None else None,
+ "dataset_name": dataset.name if dataset is not None else None,
+ "spi_identifiers": sorted(self._spis),
+ }
+
@property
def n_spis(self):
"""Number of SPIs in the calculator."""
@@ -175,6 +570,11 @@ def labels(self):
def labels(self, ls):
self._labels = ls
+ @property
+ def timings(self):
+ """Per-SPI wall-clock times (seconds) from the last compute() call."""
+ return dict(self._timings)
+
@property
def table(self):
"""Results table for all pairwise interactions."""
@@ -186,6 +586,83 @@ def table(self, a):
"Do not set this property externally. Use the compute() method."
)
+ def __repr__(self):
+ ds = getattr(self, "_dataset", None)
+ shape = f"{ds.n_processes}x{ds.n_observations}" if ds is not None else "no dataset"
+ done = len(self._timings)
+ state = "not computed" if not done else f"{done}/{self.n_spis} computed"
+ failed = f", {len(self._errors)} failed" if self._errors else ""
+ return f""
+
+ def _repr_html_(self):
+ """Rendered by Jupyter in place of the default object repr.
+
+ The bare `` told you nothing about whether the run
+ had happened or how it went, which is the first thing you want after
+ calling compute().
+ """
+ s = self.summary()
+ rows = [
+ ("config", s["config"]),
+ ("dataset", f'{s["n_processes"]} x {s["n_observations"]}'),
+ ("SPIs", f'{s["n_computed"]}/{s["n_spis"]} computed'),
+ ("failed", ", ".join(s["failed"]) if s["failed"] else "none"),
+ ("time", f'{s["total_seconds"]}s'),
+ ("slowest", ", ".join(f"{k} ({v}s)" for k, v in s["slowest"][:3]) or "-"),
+ ]
+ body = "".join(
+ f'{k} {v} '
+ for k, v in rows
+ )
+ return f''
+
+ def to_frame(self, dropna=True):
+ """Results in long form: one row per ``(spi, source, target)``.
+
+ :attr:`table` is wide -- an ``M x (n_spis * M)`` frame with a
+ ``(spi, process)`` column MultiIndex -- which is the right shape for
+ storage but awkward for plotting, ``groupby``, or joining against SPI
+ labels. This is the same data with one value per row.
+
+ Self-pairs are dropped (their diagonal is NaN by construction); with
+ ``dropna=False`` failed SPIs keep their NaN rows.
+ """
+ long = (
+ self.table.rename_axis(index="source")
+ .stack(level=["spi", "process"], future_stack=True)
+ .rename("value")
+ .reset_index()
+ .rename(columns={"process": "target"})
+ )
+ long = long[long["source"] != long["target"]]
+ if dropna:
+ long = long.dropna(subset=["value"])
+ return long[["spi", "source", "target", "value"]].reset_index(drop=True)
+
+ def summary(self):
+ """One-line-per-fact overview of the last :meth:`compute` call.
+
+ Human-facing convenience over :attr:`timings`, :attr:`errors` and
+ :attr:`n_spis`, which stay as they are: those are the programmatic
+ handles you filter and assert on, this is the thing you print.
+ """
+ timings = self._timings
+ total = sum(timings.values())
+ slowest = sorted(timings.items(), key=lambda kv: -kv[1])[:5]
+ return {
+ "dataset": self.dataset.name if hasattr(self, "_dataset") else None,
+ "n_processes": self.dataset.n_processes if hasattr(self, "_dataset") else None,
+ "n_observations": self.dataset.n_observations if hasattr(self, "_dataset") else None,
+ "config": str(self._config),
+ "n_spis": self.n_spis,
+ "n_computed": len(timings),
+ "n_failed": len(self._errors),
+ "failed": sorted(self._errors),
+ "total_seconds": round(total, 3),
+ "slowest": [(k, round(v, 3)) for k, v in slowest],
+ }
+
@property
def group(self):
"""The numerical group assigned during :meth:`~pyspi.Calculator.calculator.set_group`."""
@@ -214,45 +691,6 @@ def group_name(self):
def group_name(self, g):
raise Exception("Do not set this property externally. Use the group() method.")
- def _load_yaml(self, document):
- print("Loading configuration file: {}".format(document))
-
- with open(document) as f:
- yf = yaml.load(f, Loader=yaml.FullLoader)
-
- # Instantiate the SPIs
- for module_name in yf:
- print("*** Importing module {}".format(module_name))
- module = importlib.import_module(module_name, __package__)
- for fcn in yf[module_name]:
- deps = yf[module_name][fcn].get('dependencies')
- if deps is not None:
- all_deps_met = all(Calculator._optional_dependencies.get(dep, False) for dep in deps)
- if not all_deps_met:
- current_base_spi = yf[module_name][fcn]
- print(f"Optional dependencies: {deps} not met. Skipping {len(current_base_spi.get('configs'))} SPI(s):")
- for params in current_base_spi.get('configs'):
- print(f"*SKIPPING SPI: {module_name}.{fcn}(x,y,{params})...")
- self._excluded_spis.append([f"{fcn}(x,y,{params})", deps])
- continue
- try:
- for params in yf[module_name][fcn].get('configs'):
- print(
- f"[{self.n_spis}] Adding SPI {module_name}.{fcn}(x,y,{params})"
- )
- spi = getattr(module, fcn)(**params)
- self._spis[spi.identifier] = spi
- print(
- f'Succesfully initialised SPI with identifier "{spi.identifier}" and labels {spi.labels}'
- )
- except TypeError:
- print(f"[{self.n_spis}] Adding SPI {module_name}.{fcn}(x,y)...")
- spi = getattr(module, fcn)()
- self._spis[spi.identifier] = spi
- print(
- f'Succesfully initialised SPI with identifier "{spi.identifier}" and labels {spi.labels}'
- )
-
def load_dataset(self, dataset):
"""Load new dataset into existing instance.
@@ -261,9 +699,29 @@ def load_dataset(self, dataset):
New dataset to attach to calculator.
"""
if not isinstance(dataset, Data):
- self._dataset = Data(Data.convert_to_numpy(dataset), normalise=self._normalise, detrend=self._detrend)
+ self._dataset = Data(
+ Data.convert_to_numpy(dataset),
+ zscore=self._zscore,
+ detrend=self._detrend,
+ )
else:
- self._dataset = dataset
+ # Snapshot rather than alias. A caller-owned Data could be mutated
+ # after construction -- changing its width left the table at the old
+ # shape and computation failed; a same-width change silently kept
+ # stale process labels. The snapshot also records the preprocessing
+ # the data *actually* carries, not the Calculator flags that were
+ # bypassed when a prepared Data was supplied.
+ self._dataset = Data._from_prepared_array(
+ np.array(dataset.to_numpy(), copy=True),
+ procnames=dataset.procnames,
+ name=dataset.name,
+ )
+ self._dataset.zscore = dataset.zscore
+ self._dataset.detrend = dataset.detrend
+
+ # Results belong to the dataset that produced them.
+ self._errors = {}
+ self._timings = {}
columns = pd.MultiIndex.from_product(
[self.spis.keys(), self._dataset.procnames], names=["spi", "process"]
@@ -278,33 +736,182 @@ def load_dataset(self, dataset):
)
self._table.columns.name = "process"
- def compute(self):
- """Compute the SPIs on the MVTS dataset."""
+ def save(self, path):
+ """Write the results table to ``path``; the format follows the suffix.
+
+ ``.npz`` (recommended) stores the results in their natural shape -- an
+ ``(n_spis, M, M)`` float array plus the SPI and process names -- and
+ round-trips exactly through :func:`pyspi.load_table`. ``.csv`` is for
+ eyeballing small results; it is impractical for the full SPI set.
+ """
+ path = Path(path)
+ M = self.dataset.n_processes
+ if path.suffix == ".csv":
+ self.table.to_csv(path)
+ elif path.suffix == ".npz":
+ keys = list(self.spis)
+ values = np.stack([self.table[k].to_numpy(dtype=float) for k in keys])
+ np.savez_compressed(
+ path, values=values,
+ # dtype='U', not object: object arrays are only loadable with
+ # allow_pickle=True, which reintroduces arbitrary code execution
+ # on load -- the exact hazard that motivated dropping pickle.
+ spis=np.array(keys, dtype="U"),
+ processes=np.array(self.dataset.procnames, dtype="U"),
+ schema=np.array(_NPZ_SCHEMA),
+ run_spec=np.array(
+ json.dumps(self.run_spec, sort_keys=True, default=str), dtype="U"
+ ),
+ run_digest=np.array(self.run_digest, dtype="U"),
+ errors=np.array(
+ json.dumps(self.errors, sort_keys=True, default=str), dtype="U"
+ ),
+ )
+ else:
+ raise ValueError(
+ f"Unsupported suffix '{path.suffix}'. Use '.npz' (recommended) "
+ f"or '.csv'."
+ )
+ logger.info("Wrote %d SPI(s) x %dx%d -> %s", self.n_spis, M, M, path)
+ return path
+
+ def compute(
+ self,
+ n_jobs=None,
+ checkpoint_dir=None,
+ resume=True,
+ retry_failed=True,
+ mp_context=None,
+ progress=True,
+ ):
+ """Compute every SPI on the loaded dataset.
+
+ Args:
+ n_jobs (int, optional): Number of worker processes. ``None`` (default)
+ falls back to the ``PYSPI_N_JOBS`` environment variable, or 1 if
+ unset. ``1`` runs serially in this process.
+ checkpoint_dir (str | Path, optional): If set, each finished SPI is
+ written to ``/.npy`` atomically. Enables resume.
+ resume (bool): If True (default) and ``checkpoint_dir`` contains
+ results from a prior run, those SPIs are loaded and skipped.
+ retry_failed (bool): If True (default), checkpoints carrying an
+ ``.error`` sidecar are recomputed rather than resumed. Set
+ False to inherit a prior run's failures as-is.
+ mp_context (str, optional): Multiprocessing start method when
+ ``n_jobs>1``. Default (``None``): ``"fork"`` on Linux (workers
+ inherit imported state via copy-on-write — ~2x faster startup),
+ ``"spawn"`` on macOS/Windows (fork is unsafe/absent there).
+ progress (bool): Show a tqdm progress bar (default True).
+
+ Backend threading: when ``n_jobs>1`` each worker pins its nested pools
+ (OpenMP/OpenBLAS/MKL, pyEDM) to one thread/process so the
+ workers don't oversubscribe the cores. macOS is an exception — its
+ Accelerate BLAS cannot be thread-pinned by threadpoolctl, so on macOS
+ ``n_jobs>1`` can oversubscribe BLAS-heavy SPIs; prefer ``n_jobs=1``
+ there for a single dataset. ``n_jobs=1`` always leaves the backends
+ free to self-parallelise.
+ """
if not hasattr(self, "_dataset"):
raise AttributeError(
"Dataset not loaded yet. Please initialise with load_dataset."
)
- pbar = tqdm(self.spis.keys())
- for spi in pbar:
- pbar.set_description(f"Processing [{self._name}: {spi}]")
- start_time = time.time()
- try:
- # Get the MPI from the dataset
- S = self._spis[spi].multivariate(self.dataset)
-
- # Ensure the diagonal is NaN (sometimes set within the functions)
- np.fill_diagonal(S, np.nan)
-
- # Save results
- self._table[spi] = S
- except Exception as err:
- warnings.warn(f'Caught {type(err)} for SPI "{spi}": {err}')
- self._table[spi] = np.nan
- pbar.close()
- print(Fore.GREEN + f"\nCalculation complete. Time taken: {pbar.format_dict['elapsed']:.4f}s")
- inspect_calc_results(self)
-
+ if n_jobs is None:
+ n_jobs = int(os.getenv("PYSPI_N_JOBS", "1"))
+ _parallel.guard_oversubscription(n_jobs)
+
+ spi_keys = list(self.spis.keys())
+ M = self.dataset.n_processes
+ cp_dir = Path(checkpoint_dir) if checkpoint_dir is not None else None
+ self._resume_rejected = False
+ if cp_dir is not None:
+ cp_dir.mkdir(parents=True, exist_ok=True)
+ digest = self.run_digest
+ owned, reason = _parallel.checkpoint_owner_matches(cp_dir, digest)
+ if not owned:
+ # Refuse to inherit another run's results. Resuming here is how
+ # a different dataset of the same width silently returned the
+ # previous run's numbers.
+ self._resume_rejected = True
+ # Refuse rather than delete. Deleting another run's results to
+ # make room is destructive and, if interrupted midway, relabels
+ # whatever survives as this run.
+ raise ValueError(
+ f"Checkpoint directory {cp_dir} belongs to a different run "
+ f"({reason}). Use a separate directory per run, or remove "
+ f"it yourself if the old results are no longer wanted."
+ )
+ _parallel.write_manifest(cp_dir, digest, self.run_spec)
+
+ # Resume: skip SPIs whose checkpoint exists.
+ if cp_dir is not None and resume:
+ done, spi_keys = _parallel.load_checkpoints(
+ cp_dir, spi_keys, M, retry_failed=retry_failed
+ )
+ for key, (S, err, warns, _t) in done.items():
+ self._record(key, S, err, warns, 0.0)
+ if done:
+ logger.info("Resumed %d SPI(s) from %s", len(done), cp_dir)
+
+ if not spi_keys:
+ logger.info("All SPIs already cached; nothing to compute.")
+ if self._verbose:
+ inspect_calc_results(self)
+ return
+
+ t_start = time.perf_counter()
+
+ if n_jobs <= 1:
+ self._compute_serial(spi_keys, M, cp_dir, progress)
+ else:
+ n_workers = min(int(n_jobs), len(spi_keys))
+ ctx = mp_context or _parallel.default_mp_context()
+ logger.info("Parallel compute: %d SPI(s) via %d workers (mp=%s)",
+ len(spi_keys), n_workers, ctx)
+ results = _parallel.run_parallel(
+ self._spis, self._dataset, spi_keys,
+ n_jobs=n_workers, mp_context=ctx,
+ checkpoint_dir=cp_dir, progress=progress,
+ configfile=self._configfile,
+ config_bytes=self._config_bytes,
+ )
+ for key, (S, err, warns, elapsed) in results.items():
+ self._record(key, S, err, warns, elapsed)
+
+ elapsed = time.perf_counter() - t_start
+ logger.info("Calculation complete. Time taken: %.4fs", elapsed)
+ if self._verbose:
+ inspect_calc_results(self)
+
+ def _compute_serial(self, spi_keys, M, cp_dir, progress):
+ iterable = tqdm(spi_keys) if progress else spi_keys
+ for key in iterable:
+ if progress:
+ iterable.set_description(f"Processing [{self._name}: {key}]")
+ # Same primitive the workers use, so both paths agree on what
+ # counts as a failure and on what the caller is shown.
+ S, err, warns, elapsed = _parallel.run_spi(
+ self._spis[key], self.dataset, key, M
+ )
+ self._record(key, S, err, warns, elapsed)
+ _parallel.write_checkpoint(cp_dir, key, S, err)
+
+ def _record(self, key, S, err, warns, elapsed):
+ """Commit one SPI result, its error, and its warnings."""
+ self._table[key] = S
+ self._timings[key] = elapsed
+ for w in warns:
+ warnings.warn(f'SPI "{key}": {w}')
+ if err is not None:
+ self._errors[key] = err
+ warnings.warn(f'Caught error for SPI "{key}": {err}')
+ else:
+ # A recomputation that succeeds clears the previous failure. Without
+ # this, `compute(retry_failed=True)` on a resumed run left the old
+ # entry in `calc.errors` next to the good column it had just
+ # written, and `save()` froze that contradiction into the file.
+ self._errors.pop(key, None)
+
def _rmmin(self):
"""Iterate through all spis and remove the minimum (fixes absolute value errors when correlating)"""
for spi in self.spis:
@@ -376,8 +983,11 @@ def _get_correlation_df(self, with_labels=False, rmmin=False):
if rmmin:
self._rmmin()
- # Flatten (get Edge-by-SPI matrix)
- edges = self.table.stack()
+ # Flatten (get Edge-by-SPI matrix). future_stack=True is the pandas 3
+ # behaviour; unlike the legacy default it keeps all-NaN rows, so the
+ # explicit dropna reproduces the old semantics (self-pairs are all-NaN
+ # because the SPI diagonals are NaN). Verified equivalent on pandas 2.3.
+ edges = self.table.stack(future_stack=True).dropna(how="all")
# Correlate the edge matrix (using pearson and/or spearman correlation)
cf = pd.DataFrame(
@@ -402,7 +1012,7 @@ def _get_correlation_df(self, with_labels=False, rmmin=False):
cf.columns.name = "SPI-2"
if with_labels:
- return cf, self.getstatlabels()
+ return cf, self.get_stat_labels()
else:
return cf
@@ -465,8 +1075,8 @@ def from_calculator(calculator):
return cf
def set_calculator(self, calculators):
- if hasattr(self, "_dataset"):
- Warning("Overwriting dataset without explicitly deleting.")
+ if hasattr(self, "_calculators"):
+ warnings.warn("Overwriting existing calculators without explicitly deleting.")
del self._calculators
if isinstance(calculators, Calculator):
@@ -484,10 +1094,16 @@ def add_calculator(self, calc):
self._calculators = pd.DataFrame()
if isinstance(calc, CalculatorFrame):
- self._calculators = pd.concat([self._calculators.values, calc])
+ self._calculators = pd.concat(
+ [self._calculators, calc._calculators], ignore_index=True
+ )
elif isinstance(calc, Calculator):
+ # Keep one Calculator per row in a stable, explicitly named column.
+ # Concatenating a named Series into an empty DataFrame happened to
+ # produce column 0 in pandas 2, but pandas 3 uses the Series name as
+ # the column label and successive calculators form a sparse frame.
self._calculators = pd.concat(
- [self._calculators, pd.Series(data=calc, name=calc.name)],
+ [self._calculators, pd.DataFrame({0: [calc]})],
ignore_index=True,
)
elif isinstance(calc, pd.DataFrame):
@@ -510,7 +1126,7 @@ def init_from_list(self, datasets, names, labels, **kwargs):
self.add_calculator(calc)
def init_from_yaml(
- self, document, detrend=False, normalise=True, n_processes=None, n_observations=None, **kwargs
+ self, document, detrend=False, zscore=True, n_processes=None, n_observations=None, **kwargs
):
datasets = []
names = []
@@ -530,7 +1146,7 @@ def init_from_yaml(
dim_order=dim_order,
name=names[-1],
detrend=detrend,
- normalise=normalise,
+ zscore=zscore,
n_processes=n_processes,
n_observations=n_observations,
)
@@ -571,9 +1187,23 @@ def merge(self, other):
except AttributeError:
self._calculators = other._calculators
- @forall
- def compute(calc):
- calc.compute()
+ def compute(self, **kwargs):
+ """Compute every calculator in the frame, one dataset after another.
+
+ Keyword arguments are forwarded to :meth:`Calculator.compute`, so
+ ``frame.compute(n_jobs=4)`` parallelises *within* each dataset.
+
+ Note that for many datasets on many cores, running one dataset per
+ process (e.g. a scheduler array job) beats ``n_jobs>1`` here: SPIs
+ sharing a cache run serially inside a single worker, which floors the
+ achievable speedup at roughly 2-4x irrespective of ``n_jobs``. See
+ "Running at scale" in the README.
+ """
+ if not hasattr(self, "_calculators"):
+ raise AttributeError("No calculators in frame yet. Initialise before computing.")
+ for i in self._calculators.index:
+ for calc in self._calculators.loc[i]:
+ calc.compute(**kwargs)
@property
def groups(self):
@@ -735,46 +1365,36 @@ def merge(self, other):
self._dlabels.update(other.dlabels)
def get_pvalues(self):
- if not hasattr(self, "_pvalues"):
- n = self.shapes["n_observations"]
- nstats = self.mdf.shape[1]
- ns = np.repeat(n.values, nstats**2).reshape(
- self.mdf.shape[0], self.mdf.shape[1]
- )
- rsq = self.mdf.values**2
- fval = ns * rsq / (1 - rsq)
- self._pvalues = stats.f.sf(fval, 1, ns - 1)
- return pd.DataFrame(
- data=self._pvalues, index=self.mdf.index, columns=self.mdf.columns
+ raise NotImplementedError(
+ "CorrelationFrame.get_pvalues() is disabled: its observations "
+ "are process edges, not independent time samples. The previous "
+ "F test incorrectly used time-series length T as its sample size; "
+ "using the edge count would still ignore dependence between edges "
+ "that share nodes. Use an independently validated "
+ "network-preserving permutation/QAP analysis instead."
)
def compute_significant_values(self):
- pvals = self.get_pvalues()
- nstats = self.mdf.shape[1]
- self._insig_ind = pvals > 0.05 / nstats / (nstats - 1) / 2
-
- if not hasattr(self, "_insig_group"):
- pvals = pvals.droplevel(["Dataset", "Type"])
- group_pvalue = pd.DataFrame(
- data=np.full([pvals.columns.size] * 2, np.nan),
- columns=pvals.columns,
- index=pvals.columns,
- )
- for f1 in pvals.columns:
- print(f"Computing significance for {f1}...")
- for f2 in [
- f
- for f in pvals.columns
- if f is not f1 and np.isnan(group_pvalue[f1][f])
- ]:
- cp = pvals[f1][f2]
- group_pvalue[f1][f2] = stats.combine_pvalues(cp[~cp.isna()])[1]
- group_pvalue[f2][f1] = group_pvalue[f1][f2]
- self._insig_group = group_pvalue > 0.05
+ raise NotImplementedError(
+ "CorrelationFrame.compute_significant_values() is disabled "
+ "because it depends on invalid edge-correlation p-values. Edges "
+ "sharing nodes are dependent, so neither time-series length nor "
+ "raw edge count is a valid sample size. Use an independently "
+ "validated network-preserving permutation/QAP analysis instead."
+ )
def get_average_correlation(
self, thresh=0.2, absolute=True, summary="mean", remove_insig=False
):
+ if remove_insig:
+ raise NotImplementedError(
+ "CorrelationFrame.get_average_correlation(remove_insig=True) "
+ "is disabled because it depends on invalid edge-correlation "
+ "p-values. Edges sharing nodes are dependent, so neither "
+ "time-series length nor raw edge count is a valid sample "
+ "size. Use an independently validated network-preserving "
+ "permutation/QAP analysis instead."
+ )
mdf = copy.deepcopy(self.mdf)
if absolute:
@@ -786,9 +1406,6 @@ def get_average_correlation(
.dropna(thresh=ss_adj.shape[1] * thresh, axis=1)
.sort_index(axis=1)
)
- if remove_insig:
- ss_adj[self._insig_group.sort_index()] = np.nan
-
return ss_adj
def get_feature_matrix(self, sthresh=0.8, dthresh=0.2, dropduplicates=True):
@@ -829,8 +1446,9 @@ def _get_group(labels, classes, instance, verbose=False):
# Iterate through all
if np.count_nonzero(matches) > 1:
if verbose:
- print(
- f"More than one match in for {instance} whilst searching for {classes} within {labels}). Choosing first one."
+ logger.warning(
+ "More than one match for %s whilst searching for %s within "
+ "%s. Choosing the first.", instance, classes, labels,
)
try:
@@ -838,7 +1456,8 @@ def _get_group(labels, classes, instance, verbose=False):
return myid
except (TypeError, IndexError):
if verbose:
- print(f"{instance} has no match in {classes}. Options are {labels}")
+ logger.warning("%s has no match in %s. Options are %s",
+ instance, classes, labels)
return -1
@staticmethod
diff --git a/pyspi/configs/benchmarked_p80.yaml b/pyspi/configs/benchmarked_p80.yaml
new file mode 100644
index 00000000..6607f74c
--- /dev/null
+++ b/pyspi/configs/benchmarked_p80.yaml
@@ -0,0 +1,1542 @@
+# benchmarked_p80.yaml
+# Generated by bench/cut_config.py on 2026-05-27.
+# Source config : pyspi/configs/full.yaml
+# Bench JSON : physics_config_M16_T800_n1.json (pyspi badfb4ebdb6b, M=16 T=800 n_jobs=1)
+# Cost model : amortized
+# Current set: 261 / 322 SPIs. The original 2026-05-27 cut was 267 / 328;
+# three subsequently removed full-config variants were also present here.
+# Cutoff : fastest kept <= 1.615s ; slowest dropped >= 1.615s.
+#
+.statistics.basic:
+ Covariance:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ Precision:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ SpearmanR:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ KendallTau:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ LaggedCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - estimator: pearson
+ squared: false
+ tau: 1
+ - estimator: pearson
+ squared: false
+ tau: 2
+ - estimator: pearson
+ squared: false
+ tau: 3
+ - estimator: pearson
+ squared: false
+ tau: 4
+ - estimator: pearson
+ squared: false
+ tau: 5
+ - estimator: pearson
+ squared: true
+ tau: 1
+ - estimator: pearson
+ squared: true
+ tau: 2
+ - estimator: pearson
+ squared: true
+ tau: 3
+ - estimator: pearson
+ squared: true
+ tau: 4
+ - estimator: pearson
+ squared: true
+ tau: 5
+ - estimator: pearson
+ tau: 10
+ squared: false
+ - estimator: pearson
+ tau: 10
+ squared: true
+ - estimator: pearson
+ tau: 20
+ squared: false
+ - estimator: pearson
+ tau: 20
+ squared: true
+ - estimator: spearman
+ squared: false
+ tau: 1
+ - estimator: spearman
+ squared: false
+ tau: 2
+ - estimator: spearman
+ squared: false
+ tau: 3
+ - estimator: spearman
+ squared: false
+ tau: 4
+ - estimator: spearman
+ squared: false
+ tau: 5
+ - estimator: spearman
+ squared: true
+ tau: 1
+ - estimator: spearman
+ squared: true
+ tau: 2
+ - estimator: spearman
+ squared: true
+ tau: 3
+ - estimator: spearman
+ squared: true
+ tau: 4
+ - estimator: spearman
+ squared: true
+ tau: 5
+ - estimator: spearman
+ tau: 10
+ squared: false
+ - estimator: spearman
+ tau: 10
+ squared: true
+ - estimator: spearman
+ tau: 20
+ squared: false
+ - estimator: spearman
+ tau: 20
+ squared: true
+ - estimator: kendall
+ squared: false
+ tau: 1
+ - estimator: kendall
+ squared: false
+ tau: 2
+ - estimator: kendall
+ squared: false
+ tau: 3
+ - estimator: kendall
+ squared: false
+ tau: 4
+ - estimator: kendall
+ squared: false
+ tau: 5
+ - estimator: kendall
+ squared: true
+ tau: 1
+ - estimator: kendall
+ squared: true
+ tau: 2
+ - estimator: kendall
+ squared: true
+ tau: 3
+ - estimator: kendall
+ squared: true
+ tau: 4
+ - estimator: kendall
+ squared: true
+ tau: 5
+ - estimator: kendall
+ tau: 10
+ squared: false
+ - estimator: kendall
+ tau: 10
+ squared: true
+ - estimator: kendall
+ tau: 20
+ squared: false
+ - estimator: kendall
+ tau: 20
+ squared: true
+
+ CrossCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - statistic: max
+ labels:
+ - M10
+ - statistic: max
+ squared: true
+ - statistic: mean
+ - statistic: mean
+ squared: true
+ - statistic: mean
+ sigonly: false
+ - statistic: mean
+ squared: true
+ sigonly: false
+
+
+.statistics.distance:
+ PairwiseDistance:
+ labels:
+ - unsigned
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ - metric: cityblock
+ - metric: cosine
+ - metric: chebyshev
+ - metric: canberra
+ - metric: braycurtis
+
+ # DistanceCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - biased: false
+ # - biased: true
+
+ # MultiscaleGraphCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ # HilbertSchmidtIndependenceCriterion:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - biased: false
+ # - biased: true
+
+ # HellerHellerGorfine:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ # CrossMultiscaleGraphCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - max_lag: 1
+ # - max_lag: 10
+
+ # CrossDistanceCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - max_lag: 1
+ # - max_lag: 10
+
+ DynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ CrossPairwiseDistance:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ tau: 1
+ statistic: min
+ - metric: euclidean
+ tau: 1
+ statistic: mean
+
+ # SoftDynamicTimeWarping:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M10
+ # dependencies:
+ # configs:
+ # - global_constraint: null
+ # - global_constraint: itakura
+ # - global_constraint: sakoe_chiba
+
+ # LongestCommonSubsequence:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M10
+ # dependencies:
+ # configs:
+ # - global_constraint: null
+ # - global_constraint: itakura
+ # - global_constraint: sakoe_chiba
+
+ Barycenter:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - mode: euclidean
+ statistic: mean
+ - mode: euclidean
+ statistic: max
+ - mode: dtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ - mode: sgddtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ - mode: euclidean
+ statistic: mean
+ squared: true
+ - mode: euclidean
+ statistic: max
+ squared: true
+ - mode: dtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ squared: true
+ - mode: sgddtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ squared: true
+ # - mode: softdtw
+ # statistic: mean
+ # labels:
+ # - M09
+ # - mode: softdtw
+ # statistic: max
+ # - mode: softdtw
+ # statistic: mean
+ # squared: true
+ # labels:
+ # - M09
+ # - mode: softdtw
+ # statistic: max
+ # squared: true
+
+ GromovWasserstainTau:
+ labels:
+ - unsigned
+ - distance
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+
+
+.statistics.causal:
+ # AdditiveNoiseModel:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M02
+ # dependencies:
+ # configs:
+
+ # ConditionalDistributionSimilarity:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M02
+ # dependencies:
+ # configs:
+
+ RegressionErrorCausalInference:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ # ConvergentCrossMapping:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - statistic: mean
+ # - statistic: max
+ # - statistic: diff
+ # - statistic: mean
+ # embedding_dimension: 1
+ # - statistic: max
+ # embedding_dimension: 1
+ # - statistic: diff
+ # embedding_dimension: 1
+ # - statistic: mean
+ # embedding_dimension: 10
+ # - statistic: max
+ # embedding_dimension: 10
+ # - statistic: diff
+ # embedding_dimension: 10
+
+
+.statistics.infotheory:
+ JointEntropy:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ ConditionalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CausalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+ # - estimator: kozachenko
+ # - estimator: kernel
+
+ CrossmapEntropy:
+ labels:
+ - unsigned
+ - directed
+ - time-dependent
+ - bivariate
+ - MXX
+ configs:
+ - estimator: gaussian
+ history_length: 1
+ - estimator: kozachenko
+ history_length: 1
+ - estimator: kernel
+ history_length: 1
+ - estimator: gaussian
+ history_length: 10
+ # - estimator: kozachenko
+ # history_length: 10
+ # - estimator: kernel
+ # history_length: 10
+
+ DirectedInfo:
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+ # - estimator: kernel
+
+ StochasticInteraction:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ labels:
+ - M05
+ - estimator: kozachenko
+ # - estimator: kernel
+
+ MutualInfo:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TimeLaggedMutualInfo:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kernel
+ kernel_width: 0.25
+ # - estimator: kraskov
+ # prop_k: 4
+ # - estimator: kraskov
+ # prop_k: 4
+ # dyn_corr_excl: AUTO
+
+ TransferEntropy:
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M04
+ configs:
+ - estimator: gaussian
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 2
+ labels:
+ - M05
+ - estimator: gaussian
+ k_history: 1
+ l_history: 1
+ labels:
+ - M05
+ # - estimator: kraskov
+ # prop_k: 4
+ # auto_embed_method: MAX_CORR_AIS
+ # k_search_max: 10
+ # tau_search_max: 4
+ # - estimator: kraskov
+ # prop_k: 4
+ # auto_embed_method: MAX_CORR_AIS
+ # k_search_max: 10
+ # tau_search_max: 4
+ # dyn_corr_excl: AUTO
+ # - estimator: kraskov
+ # prop_k: 4
+ # k_history: 2
+ # l_history: 1
+ # dyn_corr_excl: AUTO
+ # - estimator: kraskov
+ # prop_k: 4
+ # k_history: 1
+ # l_history: 1
+ # dyn_corr_excl: AUTO
+ # - estimator: kraskov
+ # prop_k: 4
+ # k_history: 1
+ # l_history: 1
+ # - estimator: kernel
+ # kernel_width: 0.25
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M14
+
+ IntegratedInformation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M05
+ dependencies:
+ configs:
+ - phitype: star
+ - phitype: star
+ normalization: 1
+ - phitype: Geo
+ - phitype: Geo
+ normalization: 1
+
+
+.statistics.spectral:
+ CoherencePhase:
+ labels:
+ - linear
+ - signed
+ - antisymmetric
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ CoherenceMagnitude:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ ImaginaryCoherence:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseSlopeIndex:
+ labels:
+ - linear/nonlinear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+
+ PhaseLockingValue:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M10
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ WeightedPhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PairwisePhaseConsistency:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PartialDirectedCoherence:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ # GeneralizedPartialDirectedCoherence:
+ # labels:
+ # - unsigned
+ # - directed
+ # - linear
+ # - bivariate
+ # - frequency-dependent
+ # - M06
+ # dependencies:
+ # configs:
+ # - fs: 1
+ # - fmin: 0
+ # fmax: 0.25
+ # - fmin: 0.25
+ # fmax: 0.5
+ # - fs: 1
+ # statistic: max
+ # - fmin: 0
+ # fmax: 0.25
+ # statistic: max
+ # - fmin: 0.25
+ # fmax: 0.5
+ # statistic: max
+
+ # DirectDirectedTransferFunction:
+ # labels:
+ # - directed
+ # - linear
+ # - unsigned
+ # - bivariate
+ # - frequency-dependent
+ # - M06
+ # dependencies:
+ # configs:
+ # - fs: 1
+ # - fmin: 0
+ # fmax: 0.25
+ # - fmin: 0.25
+ # fmax: 0.5
+ # - fs: 1
+ # statistic: max
+ # - fmin: 0
+ # fmax: 0.25
+ # statistic: max
+ # - fmin: 0.25
+ # fmax: 0.5
+ # statistic: max
+
+ GroupDelay:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ statistic: delay
+ - fmin: 0
+ fmax: 0.25
+ statistic: delay
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: delay
+
+ SpectralGrangerCausality:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: max
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: max
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ - method: parametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ labels:
+ - M05
+ - method: parametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: mean
+ - fs: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: max
+
+
+.statistics.wavelet:
+ PhaseSlopeIndex:
+ labels:
+ - unsigned
+ - time/frequency dependent
+ - bivariate
+ - M08
+ dependencies:
+ configs:
+ - fmin: 0.25
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.5
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ # - fs: 1
+ # - fmin: 0
+ # fmax: 0.25
+
+
+.statistics.misc:
+ LinearModel:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - model: Ridge
+ - model: Lasso
+ - model: SGDRegressor
+ - model: ElasticNet
+ - model: BayesianRidge
+
+ # GPModel:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - kernel: DotProduct
+ # - kernel: RBF
+
+ Cointegration:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M13
+ dependencies:
+ configs:
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: c
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: ct
+ - method: aeg
+ statistic: tstat
+ autolag: bic
+ maxlag: 10
+ trend: ct
+
+ PowerEnvelopeCorrelation:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M11
+ dependencies:
+ configs:
+ - orth: false
+ log: false
+ absolute: false
+ - orth: true
+ log: false
+ absolute: false
+ - orth: false
+ log: true
+ absolute: false
+ - orth: true
+ log: true
+ absolute: false
+ - orth: true
+ log: false
+ absolute: true
+ - orth: true
+ log: true
+ absolute: true
+
+ InterDependenceScore:
+ labels:
+ - unsigned
+ - undirected
+ - nonlinear
+ dependencies:
+ configs:
+ - terms: 6
+ pnorm: max
+ bandwidth: 0.5
+
+
+# --- DROPPED (slowest 61, amortized cost) ---
+# 505.800s hhg
+# 411.135s gpfit_RBF
+# 342.692s mgcx_maxlag-10
+# 332.132s ccm_E-None_diff
+# 332.132s ccm_E-None_max
+# 332.132s ccm_E-None_mean
+# 332.019s ccm_E-10_diff
+# 332.019s ccm_E-10_max
+# 332.019s ccm_E-10_mean
+# 257.963s gpfit_DotProduct
+# 214.054s ccm_E-1_diff
+# 214.054s ccm_E-1_max
+# 214.054s ccm_E-1_mean
+# 105.554s mgcx_maxlag-1
+# 77.367s dcorrx_maxlag-10
+# 62.259s psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean
+# 62.239s psi_wavelet_mean_fs-1_fmin-0_fmax-0-5_mean
+# 41.811s bary-sq_softdtw_max
+# 41.811s bary-sq_softdtw_mean
+# 41.811s bary_softdtw_max
+# 41.811s bary_softdtw_mean
+# 31.350s te_kraskov_NN-4_DCE_k-max-10_tau-max-4
+# 28.300s te_kraskov_NN-4_k-max-10_tau-max-4
+# 23.171s mgc
+# 16.110s cce_kernel_W-0.5
+# 12.680s dcorrx_maxlag-1
+# 7.926s softdtw_constraint-sakoe-chiba
+# 7.920s softdtw
+# 7.746s softdtw_constraint-itakura
+# 7.670s te_kraskov_NN-4_DCE_k-2_kt-1_l-1_lt-1
+# 7.305s te_kraskov_NN-4_DCE_k-1_kt-1_l-1_lt-1
+# 6.837s hsic_biased
+# 6.790s hsic
+# 5.520s anm
+# 5.067s xme_kernel_W-0.5_k10
+# 5.040s di_kernel_W-0.5
+# 4.342s si_kernel_W-0.5_k-1
+# 4.045s cce_kozachenko
+# 3.638s dcorr_biased
+# 3.599s te_kraskov_NN-4_k-1_kt-1_l-1_lt-1
+# 3.563s dcorr
+# 3.487s cds
+# 2.172s ddtf_multitaper_max_fs-1_fmin-0-25_fmax-0-5
+# 2.172s ddtf_multitaper_max_fs-1_fmin-0_fmax-0-25
+# 2.172s ddtf_multitaper_max_fs-1_fmin-0_fmax-0-5
+# 2.172s ddtf_multitaper_mean_fs-1_fmin-0-25_fmax-0-5
+# 2.172s ddtf_multitaper_mean_fs-1_fmin-0_fmax-0-25
+# 2.172s ddtf_multitaper_mean_fs-1_fmin-0_fmax-0-5
+# 2.000s te_kernel_W-0.25_k-1
+# 1.923s lcss_constraint-sakoe-chiba
+# 1.909s lcss_constraint-itakura
+# 1.875s tlmi_kraskov_NN-4
+# 1.792s lcss
+# 1.745s xme_kozachenko_k10
+# 1.709s tlmi_kraskov_NN-4_DCE
+# 1.625s gpdcoh_multitaper_max_fs-1_fmin-0-25_fmax-0-5
+# 1.625s gpdcoh_multitaper_max_fs-1_fmin-0_fmax-0-25
+# 1.625s gpdcoh_multitaper_max_fs-1_fmin-0_fmax-0-5
+# 1.625s gpdcoh_multitaper_mean_fs-1_fmin-0-25_fmax-0-5
+# 1.625s gpdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-25
+# 1.625s gpdcoh_multitaper_mean_fs-1_fmin-0_fmax-0-5
diff --git a/pyspi/configs/benchmarked_p90.yaml b/pyspi/configs/benchmarked_p90.yaml
new file mode 100644
index 00000000..e167f381
--- /dev/null
+++ b/pyspi/configs/benchmarked_p90.yaml
@@ -0,0 +1,1522 @@
+# benchmarked_p90.yaml
+# Generated by bench/cut_config.py on 2026-05-27.
+# Source config : pyspi/configs/full.yaml
+# Bench JSON : physics_config_M16_T800_n1.json (pyspi badfb4ebdb6b, M=16 T=800 n_jobs=1)
+# Cost model : amortized
+# Current set: 289 / 322 SPIs (including two manual te_kraskov DCE variants).
+# The original 2026-05-27 cut was 297 / 328 before later removals.
+# Cutoff : fastest kept <= 5.520s ; slowest dropped >= 6.790s.
+#
+# Manual edit: the two te_kraskov_NN-4_DCE_k-{1,2}_kt-1_l-1_lt-1 variants are
+# uncommented (added back to the kept set) for methodological coverage. Dynamic
+# Correlation Exclusion (DCE) corrects the kraskov nearest-neighbour estimator
+# for temporal autocorrelation; without it, TE on autocorrelated signals is
+# biased upward. The amortized-cost cut sees only their cost (7.3s, 7.7s) and
+# dropped them at p90; the methodological correctness argument adds them back.
+# Re-running bench/cut_config will overwrite this edit — re-apply by hand.
+#
+.statistics.basic:
+ Covariance:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ Precision:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ SpearmanR:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ KendallTau:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ LaggedCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - estimator: pearson
+ squared: false
+ tau: 1
+ - estimator: pearson
+ squared: false
+ tau: 2
+ - estimator: pearson
+ squared: false
+ tau: 3
+ - estimator: pearson
+ squared: false
+ tau: 4
+ - estimator: pearson
+ squared: false
+ tau: 5
+ - estimator: pearson
+ squared: true
+ tau: 1
+ - estimator: pearson
+ squared: true
+ tau: 2
+ - estimator: pearson
+ squared: true
+ tau: 3
+ - estimator: pearson
+ squared: true
+ tau: 4
+ - estimator: pearson
+ squared: true
+ tau: 5
+ - estimator: pearson
+ tau: 10
+ squared: false
+ - estimator: pearson
+ tau: 10
+ squared: true
+ - estimator: pearson
+ tau: 20
+ squared: false
+ - estimator: pearson
+ tau: 20
+ squared: true
+ - estimator: spearman
+ squared: false
+ tau: 1
+ - estimator: spearman
+ squared: false
+ tau: 2
+ - estimator: spearman
+ squared: false
+ tau: 3
+ - estimator: spearman
+ squared: false
+ tau: 4
+ - estimator: spearman
+ squared: false
+ tau: 5
+ - estimator: spearman
+ squared: true
+ tau: 1
+ - estimator: spearman
+ squared: true
+ tau: 2
+ - estimator: spearman
+ squared: true
+ tau: 3
+ - estimator: spearman
+ squared: true
+ tau: 4
+ - estimator: spearman
+ squared: true
+ tau: 5
+ - estimator: spearman
+ tau: 10
+ squared: false
+ - estimator: spearman
+ tau: 10
+ squared: true
+ - estimator: spearman
+ tau: 20
+ squared: false
+ - estimator: spearman
+ tau: 20
+ squared: true
+ - estimator: kendall
+ squared: false
+ tau: 1
+ - estimator: kendall
+ squared: false
+ tau: 2
+ - estimator: kendall
+ squared: false
+ tau: 3
+ - estimator: kendall
+ squared: false
+ tau: 4
+ - estimator: kendall
+ squared: false
+ tau: 5
+ - estimator: kendall
+ squared: true
+ tau: 1
+ - estimator: kendall
+ squared: true
+ tau: 2
+ - estimator: kendall
+ squared: true
+ tau: 3
+ - estimator: kendall
+ squared: true
+ tau: 4
+ - estimator: kendall
+ squared: true
+ tau: 5
+ - estimator: kendall
+ tau: 10
+ squared: false
+ - estimator: kendall
+ tau: 10
+ squared: true
+ - estimator: kendall
+ tau: 20
+ squared: false
+ - estimator: kendall
+ tau: 20
+ squared: true
+
+ CrossCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - statistic: max
+ labels:
+ - M10
+ - statistic: max
+ squared: true
+ - statistic: mean
+ - statistic: mean
+ squared: true
+ - statistic: mean
+ sigonly: false
+ - statistic: mean
+ squared: true
+ sigonly: false
+
+
+.statistics.distance:
+ PairwiseDistance:
+ labels:
+ - unsigned
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ - metric: cityblock
+ - metric: cosine
+ - metric: chebyshev
+ - metric: canberra
+ - metric: braycurtis
+
+ DistanceCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - biased: false
+ - biased: true
+
+ # MultiscaleGraphCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ # HilbertSchmidtIndependenceCriterion:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - biased: false
+ # - biased: true
+
+ # HellerHellerGorfine:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ # CrossMultiscaleGraphCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - max_lag: 1
+ # - max_lag: 10
+
+ # CrossDistanceCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - max_lag: 1
+ # - max_lag: 10
+
+ DynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ CrossPairwiseDistance:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ tau: 1
+ statistic: min
+ - metric: euclidean
+ tau: 1
+ statistic: mean
+
+ # SoftDynamicTimeWarping:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M10
+ # dependencies:
+ # configs:
+ # - global_constraint: null
+ # - global_constraint: itakura
+ # - global_constraint: sakoe_chiba
+
+ LongestCommonSubsequence:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ Barycenter:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - mode: euclidean
+ statistic: mean
+ - mode: euclidean
+ statistic: max
+ - mode: dtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ - mode: sgddtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ - mode: euclidean
+ statistic: mean
+ squared: true
+ - mode: euclidean
+ statistic: max
+ squared: true
+ - mode: dtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ squared: true
+ - mode: sgddtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ squared: true
+ # - mode: softdtw
+ # statistic: mean
+ # labels:
+ # - M09
+ # - mode: softdtw
+ # statistic: max
+ # - mode: softdtw
+ # statistic: mean
+ # squared: true
+ # labels:
+ # - M09
+ # - mode: softdtw
+ # statistic: max
+ # squared: true
+
+ GromovWasserstainTau:
+ labels:
+ - unsigned
+ - distance
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+
+
+.statistics.causal:
+ # Disabled 2026-08-22: in Gadi pilot 177018028, all six M=20,T=1000
+ # datasets remained inside ANM for >18 CPU-minutes each after reaching it;
+ # no dataset completed. Its exact GP fit scales cubically in T for every
+ # directed channel pair, so the old p90 benchmark does not extrapolate here.
+ # AdditiveNoiseModel:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M02
+ # dependencies:
+ # configs:
+
+ ConditionalDistributionSimilarity:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M02
+ dependencies:
+ configs:
+
+ RegressionErrorCausalInference:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ # ConvergentCrossMapping:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - statistic: mean
+ # - statistic: max
+ # - statistic: diff
+ # - statistic: mean
+ # embedding_dimension: 1
+ # - statistic: max
+ # embedding_dimension: 1
+ # - statistic: diff
+ # embedding_dimension: 1
+ # - statistic: mean
+ # embedding_dimension: 10
+ # - statistic: max
+ # embedding_dimension: 10
+ # - statistic: diff
+ # embedding_dimension: 10
+
+
+.statistics.infotheory:
+ JointEntropy:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ ConditionalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CausalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ # - estimator: kernel
+
+ CrossmapEntropy:
+ labels:
+ - unsigned
+ - directed
+ - time-dependent
+ - bivariate
+ - MXX
+ configs:
+ - estimator: gaussian
+ history_length: 1
+ - estimator: kozachenko
+ history_length: 1
+ - estimator: kernel
+ history_length: 1
+ - estimator: gaussian
+ history_length: 10
+ - estimator: kozachenko
+ history_length: 10
+ - estimator: kernel
+ history_length: 10
+
+ DirectedInfo:
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+
+ StochasticInteraction:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ labels:
+ - M05
+ - estimator: kozachenko
+ - estimator: kernel
+
+ MutualInfo:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TimeLaggedMutualInfo:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TransferEntropy:
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M04
+ configs:
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ - estimator: kernel
+ kernel_width: 0.25
+ k_history: 1
+ labels:
+ - M14
+ - estimator: gaussian
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 2
+ labels:
+ - M05
+ - estimator: gaussian
+ k_history: 1
+ l_history: 1
+ labels:
+ - M05
+ # - estimator: kraskov
+ # prop_k: 4
+ # auto_embed_method: MAX_CORR_AIS
+ # k_search_max: 10
+ # tau_search_max: 4
+ # - estimator: kraskov
+ # prop_k: 4
+ # auto_embed_method: MAX_CORR_AIS
+ # k_search_max: 10
+ # tau_search_max: 4
+ # dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 2
+ l_history: 1
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ dyn_corr_excl: AUTO
+
+ IntegratedInformation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M05
+ dependencies:
+ configs:
+ - phitype: star
+ - phitype: star
+ normalization: 1
+ - phitype: Geo
+ - phitype: Geo
+ normalization: 1
+
+
+.statistics.spectral:
+ CoherencePhase:
+ labels:
+ - linear
+ - signed
+ - antisymmetric
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ CoherenceMagnitude:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ ImaginaryCoherence:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseSlopeIndex:
+ labels:
+ - linear/nonlinear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+
+ PhaseLockingValue:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M10
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ WeightedPhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PairwisePhaseConsistency:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PartialDirectedCoherence:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GeneralizedPartialDirectedCoherence:
+ labels:
+ - unsigned
+ - directed
+ - linear
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectDirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GroupDelay:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ statistic: delay
+ - fmin: 0
+ fmax: 0.25
+ statistic: delay
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: delay
+
+ SpectralGrangerCausality:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: max
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: max
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ - method: parametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ labels:
+ - M05
+ - method: parametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: mean
+ - fs: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: max
+
+
+.statistics.wavelet:
+ PhaseSlopeIndex:
+ labels:
+ - unsigned
+ - time/frequency dependent
+ - bivariate
+ - M08
+ dependencies:
+ configs:
+ - fmin: 0.25
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.5
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ # - fs: 1
+ # - fmin: 0
+ # fmax: 0.25
+
+
+.statistics.misc:
+ LinearModel:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - model: Ridge
+ - model: Lasso
+ - model: SGDRegressor
+ - model: ElasticNet
+ - model: BayesianRidge
+
+ # GPModel:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - kernel: DotProduct
+ # - kernel: RBF
+
+ Cointegration:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M13
+ dependencies:
+ configs:
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: c
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: ct
+ - method: aeg
+ statistic: tstat
+ autolag: bic
+ maxlag: 10
+ trend: ct
+
+ PowerEnvelopeCorrelation:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M11
+ dependencies:
+ configs:
+ - orth: false
+ log: false
+ absolute: false
+ - orth: true
+ log: false
+ absolute: false
+ - orth: false
+ log: true
+ absolute: false
+ - orth: true
+ log: true
+ absolute: false
+ - orth: true
+ log: false
+ absolute: true
+ - orth: true
+ log: true
+ absolute: true
+
+ InterDependenceScore:
+ labels:
+ - unsigned
+ - undirected
+ - nonlinear
+ dependencies:
+ configs:
+ - terms: 6
+ pnorm: max
+ bandwidth: 0.5
+
+
+# --- DROPPED (slowest 31, amortized cost; te_kraskov DCE k=1,2 manually kept) ---
+# 505.800s hhg
+# 411.135s gpfit_RBF
+# 342.692s mgcx_maxlag-10
+# 332.132s ccm_E-None_diff
+# 332.132s ccm_E-None_max
+# 332.132s ccm_E-None_mean
+# 332.019s ccm_E-10_diff
+# 332.019s ccm_E-10_max
+# 332.019s ccm_E-10_mean
+# 257.963s gpfit_DotProduct
+# 214.054s ccm_E-1_diff
+# 214.054s ccm_E-1_max
+# 214.054s ccm_E-1_mean
+# 105.554s mgcx_maxlag-1
+# 77.367s dcorrx_maxlag-10
+# 62.259s psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean
+# 62.239s psi_wavelet_mean_fs-1_fmin-0_fmax-0-5_mean
+# 41.811s bary-sq_softdtw_max
+# 41.811s bary-sq_softdtw_mean
+# 41.811s bary_softdtw_max
+# 41.811s bary_softdtw_mean
+# 31.350s te_kraskov_NN-4_DCE_k-max-10_tau-max-4
+# 28.300s te_kraskov_NN-4_k-max-10_tau-max-4
+# 23.171s mgc
+# 16.110s cce_kernel_W-0.5
+# 12.680s dcorrx_maxlag-1
+# 7.926s softdtw_constraint-sakoe-chiba
+# 7.920s softdtw
+# 7.746s softdtw_constraint-itakura
+# 6.837s hsic_biased
+# 6.790s hsic
diff --git a/pyspi/configs/benchmarked_p95.yaml b/pyspi/configs/benchmarked_p95.yaml
new file mode 100644
index 00000000..51c38feb
--- /dev/null
+++ b/pyspi/configs/benchmarked_p95.yaml
@@ -0,0 +1,1495 @@
+# benchmarked_p95.yaml
+# Generated by bench/cut_config.py on 2026-05-27.
+# Source config : pyspi/configs/full.yaml
+# Bench JSON : physics_config_M16_T800_n1.json (pyspi badfb4ebdb6b, M=16 T=800 n_jobs=1)
+# Cost model : amortized
+# Current set: 305 / 322 SPIs. The original 2026-05-27 cut was 312 / 328
+# before later removals.
+# Cutoff : fastest kept <= 62.239s ; slowest dropped >= 62.259s.
+#
+.statistics.basic:
+ Covariance:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ Precision:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ SpearmanR:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ KendallTau:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ LaggedCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - estimator: pearson
+ squared: false
+ tau: 1
+ - estimator: pearson
+ squared: false
+ tau: 2
+ - estimator: pearson
+ squared: false
+ tau: 3
+ - estimator: pearson
+ squared: false
+ tau: 4
+ - estimator: pearson
+ squared: false
+ tau: 5
+ - estimator: pearson
+ squared: true
+ tau: 1
+ - estimator: pearson
+ squared: true
+ tau: 2
+ - estimator: pearson
+ squared: true
+ tau: 3
+ - estimator: pearson
+ squared: true
+ tau: 4
+ - estimator: pearson
+ squared: true
+ tau: 5
+ - estimator: pearson
+ tau: 10
+ squared: false
+ - estimator: pearson
+ tau: 10
+ squared: true
+ - estimator: pearson
+ tau: 20
+ squared: false
+ - estimator: pearson
+ tau: 20
+ squared: true
+ - estimator: spearman
+ squared: false
+ tau: 1
+ - estimator: spearman
+ squared: false
+ tau: 2
+ - estimator: spearman
+ squared: false
+ tau: 3
+ - estimator: spearman
+ squared: false
+ tau: 4
+ - estimator: spearman
+ squared: false
+ tau: 5
+ - estimator: spearman
+ squared: true
+ tau: 1
+ - estimator: spearman
+ squared: true
+ tau: 2
+ - estimator: spearman
+ squared: true
+ tau: 3
+ - estimator: spearman
+ squared: true
+ tau: 4
+ - estimator: spearman
+ squared: true
+ tau: 5
+ - estimator: spearman
+ tau: 10
+ squared: false
+ - estimator: spearman
+ tau: 10
+ squared: true
+ - estimator: spearman
+ tau: 20
+ squared: false
+ - estimator: spearman
+ tau: 20
+ squared: true
+ - estimator: kendall
+ squared: false
+ tau: 1
+ - estimator: kendall
+ squared: false
+ tau: 2
+ - estimator: kendall
+ squared: false
+ tau: 3
+ - estimator: kendall
+ squared: false
+ tau: 4
+ - estimator: kendall
+ squared: false
+ tau: 5
+ - estimator: kendall
+ squared: true
+ tau: 1
+ - estimator: kendall
+ squared: true
+ tau: 2
+ - estimator: kendall
+ squared: true
+ tau: 3
+ - estimator: kendall
+ squared: true
+ tau: 4
+ - estimator: kendall
+ squared: true
+ tau: 5
+ - estimator: kendall
+ tau: 10
+ squared: false
+ - estimator: kendall
+ tau: 10
+ squared: true
+ - estimator: kendall
+ tau: 20
+ squared: false
+ - estimator: kendall
+ tau: 20
+ squared: true
+
+ CrossCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - statistic: max
+ labels:
+ - M10
+ - statistic: max
+ squared: true
+ - statistic: mean
+ - statistic: mean
+ squared: true
+ - statistic: mean
+ sigonly: false
+ - statistic: mean
+ squared: true
+ sigonly: false
+
+
+.statistics.distance:
+ PairwiseDistance:
+ labels:
+ - unsigned
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ - metric: cityblock
+ - metric: cosine
+ - metric: chebyshev
+ - metric: canberra
+ - metric: braycurtis
+
+ DistanceCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - biased: false
+ - biased: true
+
+ MultiscaleGraphCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ HilbertSchmidtIndependenceCriterion:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - biased: false
+ - biased: true
+
+ # HellerHellerGorfine:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ # CrossMultiscaleGraphCorrelation:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - max_lag: 1
+ # - max_lag: 10
+
+ CrossDistanceCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - max_lag: 1
+ # - max_lag: 10
+
+ DynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ CrossPairwiseDistance:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ tau: 1
+ statistic: min
+ - metric: euclidean
+ tau: 1
+ statistic: mean
+
+ SoftDynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ LongestCommonSubsequence:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ Barycenter:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - mode: euclidean
+ statistic: mean
+ - mode: euclidean
+ statistic: max
+ - mode: dtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ - mode: sgddtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ - mode: softdtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: softdtw
+ statistic: max
+ - mode: euclidean
+ statistic: mean
+ squared: true
+ - mode: euclidean
+ statistic: max
+ squared: true
+ - mode: dtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ squared: true
+ - mode: sgddtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ squared: true
+ - mode: softdtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: softdtw
+ statistic: max
+ squared: true
+
+ GromovWasserstainTau:
+ labels:
+ - unsigned
+ - distance
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+
+
+.statistics.causal:
+ AdditiveNoiseModel:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M02
+ dependencies:
+ configs:
+
+ ConditionalDistributionSimilarity:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M02
+ dependencies:
+ configs:
+
+ RegressionErrorCausalInference:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ # ConvergentCrossMapping:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - time-dependent
+ # - M14
+ # dependencies:
+ # configs:
+ # - statistic: mean
+ # - statistic: max
+ # - statistic: diff
+ # - statistic: mean
+ # embedding_dimension: 1
+ # - statistic: max
+ # embedding_dimension: 1
+ # - statistic: diff
+ # embedding_dimension: 1
+ # - statistic: mean
+ # embedding_dimension: 10
+ # - statistic: max
+ # embedding_dimension: 10
+ # - statistic: diff
+ # embedding_dimension: 10
+
+
+.statistics.infotheory:
+ JointEntropy:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ ConditionalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CausalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CrossmapEntropy:
+ labels:
+ - unsigned
+ - directed
+ - time-dependent
+ - bivariate
+ - MXX
+ configs:
+ - estimator: gaussian
+ history_length: 1
+ - estimator: kozachenko
+ history_length: 1
+ - estimator: kernel
+ history_length: 1
+ - estimator: gaussian
+ history_length: 10
+ - estimator: kozachenko
+ history_length: 10
+ - estimator: kernel
+ history_length: 10
+
+ DirectedInfo:
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+
+ StochasticInteraction:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ labels:
+ - M05
+ - estimator: kozachenko
+ - estimator: kernel
+
+ MutualInfo:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TimeLaggedMutualInfo:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TransferEntropy:
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M04
+ configs:
+ - estimator: kraskov
+ prop_k: 4
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 4
+ - estimator: kraskov
+ prop_k: 4
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 4
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 2
+ l_history: 1
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ - estimator: kernel
+ kernel_width: 0.25
+ k_history: 1
+ labels:
+ - M14
+ - estimator: gaussian
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 2
+ labels:
+ - M05
+ - estimator: gaussian
+ k_history: 1
+ l_history: 1
+ labels:
+ - M05
+
+ IntegratedInformation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M05
+ dependencies:
+ configs:
+ - phitype: star
+ - phitype: star
+ normalization: 1
+ - phitype: Geo
+ - phitype: Geo
+ normalization: 1
+
+
+.statistics.spectral:
+ CoherencePhase:
+ labels:
+ - linear
+ - signed
+ - antisymmetric
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ CoherenceMagnitude:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ ImaginaryCoherence:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseSlopeIndex:
+ labels:
+ - linear/nonlinear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+
+ PhaseLockingValue:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M10
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ WeightedPhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PairwisePhaseConsistency:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PartialDirectedCoherence:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GeneralizedPartialDirectedCoherence:
+ labels:
+ - unsigned
+ - directed
+ - linear
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectDirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GroupDelay:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ statistic: delay
+ - fmin: 0
+ fmax: 0.25
+ statistic: delay
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: delay
+
+ SpectralGrangerCausality:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: max
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: max
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ - method: parametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ labels:
+ - M05
+ - method: parametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: mean
+ - fs: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: max
+
+
+.statistics.wavelet:
+ PhaseSlopeIndex:
+ labels:
+ - unsigned
+ - time/frequency dependent
+ - bivariate
+ - M08
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0.25
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.5
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ # - fmin: 0
+ # fmax: 0.25
+
+
+.statistics.misc:
+ LinearModel:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - model: Ridge
+ - model: Lasso
+ - model: SGDRegressor
+ - model: ElasticNet
+ - model: BayesianRidge
+
+ # GPModel:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+ # - kernel: DotProduct
+ # - kernel: RBF
+
+ Cointegration:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M13
+ dependencies:
+ configs:
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: c
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: ct
+ - method: aeg
+ statistic: tstat
+ autolag: bic
+ maxlag: 10
+ trend: ct
+
+ PowerEnvelopeCorrelation:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M11
+ dependencies:
+ configs:
+ - orth: false
+ log: false
+ absolute: false
+ - orth: true
+ log: false
+ absolute: false
+ - orth: false
+ log: true
+ absolute: false
+ - orth: true
+ log: true
+ absolute: false
+ - orth: true
+ log: false
+ absolute: true
+ - orth: true
+ log: true
+ absolute: true
+
+ InterDependenceScore:
+ labels:
+ - unsigned
+ - undirected
+ - nonlinear
+ dependencies:
+ configs:
+ - terms: 6
+ pnorm: max
+ bandwidth: 0.5
+
+
+# --- DROPPED (slowest 16, amortized cost) ---
+# 505.800s hhg
+# 411.135s gpfit_RBF
+# 342.692s mgcx_maxlag-10
+# 332.132s ccm_E-None_diff
+# 332.132s ccm_E-None_max
+# 332.132s ccm_E-None_mean
+# 332.019s ccm_E-10_diff
+# 332.019s ccm_E-10_max
+# 332.019s ccm_E-10_mean
+# 257.963s gpfit_DotProduct
+# 214.054s ccm_E-1_diff
+# 214.054s ccm_E-1_max
+# 214.054s ccm_E-1_mean
+# 105.554s mgcx_maxlag-1
+# 77.367s dcorrx_maxlag-10
+# 62.259s psi_wavelet_mean_fs-1_fmin-0_fmax-0-25_mean
diff --git a/pyspi/configs/benchmarked_p99.yaml b/pyspi/configs/benchmarked_p99.yaml
new file mode 100644
index 00000000..c314ef5d
--- /dev/null
+++ b/pyspi/configs/benchmarked_p99.yaml
@@ -0,0 +1,1482 @@
+# benchmarked_p99.yaml
+# Generated by bench/cut_config.py on 2026-05-27.
+# Source config : pyspi/configs/full.yaml
+# Bench JSON : physics_config_M16_T800_n1.json (pyspi badfb4ebdb6b, M=16 T=800 n_jobs=1)
+# Cost model : amortized
+# Current set: 318 / 322 SPIs. The original 2026-05-27 cut was 325 / 328
+# before later removals.
+# Cutoff : fastest kept <= 332.132s ; slowest dropped >= 342.692s.
+#
+.statistics.basic:
+ Covariance:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ Precision:
+ labels:
+ - undirected
+ - linear
+ - signed
+ - multivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EllipticEnvelope
+ - estimator: GraphicalLasso
+ - estimator: GraphicalLassoCV
+ - estimator: LedoitWolf
+ - estimator: MinCovDet
+ - estimator: OAS
+ - estimator: ShrunkCovariance
+ - estimator: EmpiricalCovariance
+ squared: true
+ - estimator: EllipticEnvelope
+ squared: true
+ - estimator: GraphicalLasso
+ squared: true
+ - estimator: GraphicalLassoCV
+ squared: true
+ - estimator: LedoitWolf
+ squared: true
+ - estimator: MinCovDet
+ squared: true
+ - estimator: OAS
+ squared: true
+ - estimator: ShrunkCovariance
+ squared: true
+
+ SpearmanR:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ KendallTau:
+ labels:
+ - undirected
+ - nonlinear
+ - signed
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - squared: true
+ - squared: false
+
+ LaggedCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - estimator: pearson
+ squared: false
+ tau: 1
+ - estimator: pearson
+ squared: false
+ tau: 2
+ - estimator: pearson
+ squared: false
+ tau: 3
+ - estimator: pearson
+ squared: false
+ tau: 4
+ - estimator: pearson
+ squared: false
+ tau: 5
+ - estimator: pearson
+ squared: true
+ tau: 1
+ - estimator: pearson
+ squared: true
+ tau: 2
+ - estimator: pearson
+ squared: true
+ tau: 3
+ - estimator: pearson
+ squared: true
+ tau: 4
+ - estimator: pearson
+ squared: true
+ tau: 5
+ - estimator: pearson
+ tau: 10
+ squared: false
+ - estimator: pearson
+ tau: 10
+ squared: true
+ - estimator: pearson
+ tau: 20
+ squared: false
+ - estimator: pearson
+ tau: 20
+ squared: true
+ - estimator: spearman
+ squared: false
+ tau: 1
+ - estimator: spearman
+ squared: false
+ tau: 2
+ - estimator: spearman
+ squared: false
+ tau: 3
+ - estimator: spearman
+ squared: false
+ tau: 4
+ - estimator: spearman
+ squared: false
+ tau: 5
+ - estimator: spearman
+ squared: true
+ tau: 1
+ - estimator: spearman
+ squared: true
+ tau: 2
+ - estimator: spearman
+ squared: true
+ tau: 3
+ - estimator: spearman
+ squared: true
+ tau: 4
+ - estimator: spearman
+ squared: true
+ tau: 5
+ - estimator: spearman
+ tau: 10
+ squared: false
+ - estimator: spearman
+ tau: 10
+ squared: true
+ - estimator: spearman
+ tau: 20
+ squared: false
+ - estimator: spearman
+ tau: 20
+ squared: true
+ - estimator: kendall
+ squared: false
+ tau: 1
+ - estimator: kendall
+ squared: false
+ tau: 2
+ - estimator: kendall
+ squared: false
+ tau: 3
+ - estimator: kendall
+ squared: false
+ tau: 4
+ - estimator: kendall
+ squared: false
+ tau: 5
+ - estimator: kendall
+ squared: true
+ tau: 1
+ - estimator: kendall
+ squared: true
+ tau: 2
+ - estimator: kendall
+ squared: true
+ tau: 3
+ - estimator: kendall
+ squared: true
+ tau: 4
+ - estimator: kendall
+ squared: true
+ tau: 5
+ - estimator: kendall
+ tau: 10
+ squared: false
+ - estimator: kendall
+ tau: 10
+ squared: true
+ - estimator: kendall
+ tau: 20
+ squared: false
+ - estimator: kendall
+ tau: 20
+ squared: true
+
+ CrossCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - statistic: max
+ labels:
+ - M10
+ - statistic: max
+ squared: true
+ - statistic: mean
+ - statistic: mean
+ squared: true
+ - statistic: mean
+ sigonly: false
+ - statistic: mean
+ squared: true
+ sigonly: false
+
+
+.statistics.distance:
+ PairwiseDistance:
+ labels:
+ - unsigned
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ - metric: cityblock
+ - metric: cosine
+ - metric: chebyshev
+ - metric: canberra
+ - metric: braycurtis
+
+ DistanceCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - biased: false
+ - biased: true
+
+ MultiscaleGraphCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ HilbertSchmidtIndependenceCriterion:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - biased: false
+ - biased: true
+
+ # HellerHellerGorfine:
+ # labels:
+ # - undirected
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # - M14
+ # dependencies:
+ # configs:
+
+ CrossMultiscaleGraphCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - max_lag: 1
+ # - max_lag: 10
+
+ CrossDistanceCorrelation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - max_lag: 1
+ - max_lag: 10
+
+ DynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ CrossPairwiseDistance:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - metric: euclidean
+ tau: 1
+ statistic: min
+ - metric: euclidean
+ tau: 1
+ statistic: mean
+
+ SoftDynamicTimeWarping:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ LongestCommonSubsequence:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M10
+ dependencies:
+ configs:
+ - global_constraint: null
+ - global_constraint: itakura
+ - global_constraint: sakoe_chiba
+
+ Barycenter:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - mode: euclidean
+ statistic: mean
+ - mode: euclidean
+ statistic: max
+ - mode: dtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ - mode: sgddtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ - mode: softdtw
+ statistic: mean
+ labels:
+ - M09
+ - mode: softdtw
+ statistic: max
+ - mode: euclidean
+ statistic: mean
+ squared: true
+ - mode: euclidean
+ statistic: max
+ squared: true
+ - mode: dtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: dtw
+ statistic: max
+ squared: true
+ - mode: sgddtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: sgddtw
+ statistic: max
+ squared: true
+ - mode: softdtw
+ statistic: mean
+ squared: true
+ labels:
+ - M09
+ - mode: softdtw
+ statistic: max
+ squared: true
+
+ GromovWasserstainTau:
+ labels:
+ - unsigned
+ - distance
+ - unordered
+ - nonlinear
+ - undirected
+ - MXX
+ dependencies:
+ configs:
+
+
+.statistics.causal:
+ AdditiveNoiseModel:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M02
+ dependencies:
+ configs:
+
+ ConditionalDistributionSimilarity:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M02
+ dependencies:
+ configs:
+
+ RegressionErrorCausalInference:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+
+ ConvergentCrossMapping:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ dependencies:
+ configs:
+ - statistic: mean
+ - statistic: max
+ - statistic: diff
+ - statistic: mean
+ embedding_dimension: 1
+ - statistic: max
+ embedding_dimension: 1
+ - statistic: diff
+ embedding_dimension: 1
+ - statistic: mean
+ embedding_dimension: 10
+ - statistic: max
+ embedding_dimension: 10
+ - statistic: diff
+ embedding_dimension: 10
+
+
+.statistics.infotheory:
+ JointEntropy:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ ConditionalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CausalEntropy:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+ - estimator: kozachenko
+ - estimator: kernel
+
+ CrossmapEntropy:
+ labels:
+ - unsigned
+ - directed
+ - time-dependent
+ - bivariate
+ - MXX
+ configs:
+ - estimator: gaussian
+ history_length: 1
+ - estimator: kozachenko
+ history_length: 1
+ - estimator: kernel
+ history_length: 1
+ - estimator: gaussian
+ history_length: 10
+ - estimator: kozachenko
+ history_length: 10
+ - estimator: kernel
+ history_length: 10
+
+ DirectedInfo:
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M03
+ configs:
+ - estimator: gaussian
+
+ StochasticInteraction:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ labels:
+ - M05
+ - estimator: kozachenko
+ - estimator: kernel
+
+ MutualInfo:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TimeLaggedMutualInfo:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M14
+ configs:
+ - estimator: gaussian
+ - estimator: kraskov
+ prop_k: 4
+ - estimator: kraskov
+ prop_k: 4
+ dyn_corr_excl: AUTO
+ - estimator: kernel
+ kernel_width: 0.25
+
+ TransferEntropy:
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
+
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M04
+ configs:
+ - estimator: kraskov
+ prop_k: 4
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 4
+ - estimator: kraskov
+ prop_k: 4
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 4
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 2
+ l_history: 1
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ dyn_corr_excl: AUTO
+ - estimator: kraskov
+ prop_k: 4
+ k_history: 1
+ l_history: 1
+ - estimator: kernel
+ kernel_width: 0.25
+ k_history: 1
+ labels:
+ - M14
+ - estimator: gaussian
+ auto_embed_method: MAX_CORR_AIS
+ k_search_max: 10
+ tau_search_max: 2
+ labels:
+ - M05
+ - estimator: gaussian
+ k_history: 1
+ l_history: 1
+ labels:
+ - M05
+
+ IntegratedInformation:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M05
+ dependencies:
+ configs:
+ - phitype: star
+ - phitype: star
+ normalization: 1
+ - phitype: Geo
+ - phitype: Geo
+ normalization: 1
+
+
+.statistics.spectral:
+ CoherencePhase:
+ labels:
+ - linear
+ - signed
+ - antisymmetric
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ CoherenceMagnitude:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ ImaginaryCoherence:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseSlopeIndex:
+ labels:
+ - linear/nonlinear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+
+ PhaseLockingValue:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M10
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ WeightedPhaseLagIndex:
+ labels:
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M14
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M01
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M01
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M07
+ dependencies:
+ configs:
+ - fs: 1
+ labels:
+ - M12
+ - fmin: 0
+ fmax: 0.25
+ labels:
+ - M12
+ - fmin: 0.25
+ fmax: 0.5
+ labels:
+ - M12
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PairwisePhaseConsistency:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M12
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ PartialDirectedCoherence:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GeneralizedPartialDirectedCoherence:
+ labels:
+ - unsigned
+ - directed
+ - linear
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ DirectDirectedTransferFunction:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fs: 1
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+ GroupDelay:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M01
+ dependencies:
+ configs:
+ - fmin: 0
+ fmax: 0.5
+ statistic: delay
+ - fmin: 0
+ fmax: 0.25
+ statistic: delay
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: delay
+
+ SpectralGrangerCausality:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - frequency-dependent
+ - M06
+ dependencies:
+ configs:
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: mean
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.5
+ statistic: max
+ - method: nonparametric
+ fmin: 0
+ fmax: 0.25
+ statistic: max
+ - method: nonparametric
+ fmin: 0.25
+ fmax: 0.5
+ statistic: max
+ - method: parametric
+ fmin: 0
+ fmax: 0.5
+ statistic: mean
+ labels:
+ - M05
+ - method: parametric
+ fmin: 0
+ fmax: 0.25
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: mean
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: mean
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: mean
+ - fs: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0
+ fmax: 0.25
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fmin: 0.25
+ fmax: 0.5
+ order: 1
+ method: parametric
+ statistic: max
+ labels:
+ - M05
+ - fs: 1
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ order: 20
+ method: parametric
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ order: 20
+ method: parametric
+ statistic: max
+
+
+.statistics.wavelet:
+ PhaseSlopeIndex:
+ labels:
+ - unsigned
+ - time/frequency dependent
+ - bivariate
+ - M08
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+ - fmin: 0
+ fmax: 0.5
+ statistic: max
+ - fmin: 0
+ fmax: 0.25
+ statistic: max
+ - fmin: 0.25
+ fmax: 0.5
+ statistic: max
+
+
+.statistics.misc:
+ LinearModel:
+ labels:
+ - directed
+ - linear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - model: Ridge
+ - model: Lasso
+ - model: SGDRegressor
+ - model: ElasticNet
+ - model: BayesianRidge
+
+ GPModel:
+ labels:
+ - directed
+ - nonlinear
+ - unsigned
+ - bivariate
+ - contemporaneous
+ - M14
+ dependencies:
+ configs:
+ - kernel: DotProduct
+ # - kernel: RBF
+
+ Cointegration:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M13
+ dependencies:
+ configs:
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 0
+ k_ar_diff: 1
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 10
+ - method: johansen
+ statistic: max_eig_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: johansen
+ statistic: trace_stat
+ det_order: 1
+ k_ar_diff: 1
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: c
+ - method: aeg
+ statistic: tstat
+ autolag: aic
+ maxlag: 10
+ trend: ct
+ - method: aeg
+ statistic: tstat
+ autolag: bic
+ maxlag: 10
+ trend: ct
+
+ PowerEnvelopeCorrelation:
+ labels:
+ - undirected
+ - linear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - M11
+ dependencies:
+ configs:
+ - orth: false
+ log: false
+ absolute: false
+ - orth: true
+ log: false
+ absolute: false
+ - orth: false
+ log: true
+ absolute: false
+ - orth: true
+ log: true
+ absolute: false
+ - orth: true
+ log: false
+ absolute: true
+ - orth: true
+ log: true
+ absolute: true
+
+ InterDependenceScore:
+ labels:
+ - unsigned
+ - undirected
+ - nonlinear
+ dependencies:
+ configs:
+ - terms: 6
+ pnorm: max
+ bandwidth: 0.5
+
+
+# --- DROPPED (slowest 3, amortized cost) ---
+# 505.800s hhg
+# 411.135s gpfit_RBF
+# 342.692s mgcx_maxlag-10
diff --git a/pyspi/fabfour_config.yaml b/pyspi/configs/fabfour.yaml
similarity index 61%
rename from pyspi/fabfour_config.yaml
rename to pyspi/configs/fabfour.yaml
index e0c8b009..d9a50e19 100644
--- a/pyspi/fabfour_config.yaml
+++ b/pyspi/configs/fabfour.yaml
@@ -8,6 +8,7 @@
- unisgned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -20,6 +21,7 @@
- signed
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- squared: True
@@ -33,10 +35,18 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M03
configs:
- estimator: gaussian
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
# Power envelope correlation
.statistics.misc:
@@ -47,6 +57,7 @@
- unsigned
- bivariate
- time-dependent
+ - M11
dependencies:
configs:
- orth: False
diff --git a/pyspi/fast_config.yaml b/pyspi/configs/fast.yaml
similarity index 84%
rename from pyspi/fast_config.yaml
rename to pyspi/configs/fast.yaml
index 9fbe31ee..eb9724c1 100644
--- a/pyspi/fast_config.yaml
+++ b/pyspi/configs/fast.yaml
@@ -8,6 +8,7 @@
- signed
- multivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -35,6 +36,7 @@
- signed
- multivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -61,6 +63,7 @@
- signed
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- squared: True
@@ -74,6 +77,7 @@
- signed
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- squared: True
@@ -87,9 +91,12 @@
- signed/unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- statistic: "max"
+ labels:
+ - M10
- statistic: "max"
squared: True
@@ -113,6 +120,7 @@
- unordered
- nonlinear
- undirected
+ - MXX
dependencies:
configs:
- metric: "euclidean"
@@ -130,6 +138,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- biased: False
@@ -143,6 +152,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- biased: False
@@ -155,6 +165,7 @@
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- mode: euclidean
@@ -178,6 +189,7 @@
- unordered
- nonlinear
- undirected
+ - MXX
dependencies:
configs:
@@ -190,6 +202,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M02
dependencies:
configs:
@@ -201,6 +214,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M02
dependencies:
configs:
@@ -212,19 +226,20 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- # Information-geometric conditional independence
- InformationGeometricConditionalIndependence:
- labels:
- - directed
- - nonlinear
- - unsigned
- - bivariate
- - contemporaneous
- dependencies:
- configs:
+ # Information-geometric causal inference (disabled heuristic)
+ # InformationGeometricCausalInference:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # dependencies:
+ # configs:
# Information-theoretic statistics
.statistics.infotheory:
@@ -235,8 +250,7 @@
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
- estimator: kozachenko
@@ -244,13 +258,12 @@
ConditionalEntropy: # No theiler window yet
labels:
- - undirected
+ - directed
- nonlinear
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
- estimator: kernel
@@ -261,8 +274,7 @@
- directed
- time-dependent
- bivariate
- dependencies:
- - java
+ - MXX
configs:
- estimator: gaussian
history_length: 1
@@ -289,10 +301,11 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
+ labels:
+ - M05
- estimator: kozachenko
- estimator: kernel
@@ -304,8 +317,7 @@
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
@@ -327,8 +339,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
@@ -351,8 +362,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M04
configs:
- estimator: kraskov
prop_k: 4
@@ -363,16 +373,38 @@
- estimator: gaussian
k_history: 1
l_history: 1
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
# statistics that analyse in the frequency-domain (see Schoegl and Supp, 2006)
+ labels:
+ - M05
.statistics.spectral:
CoherencePhase:
labels:
- - undirected
- linear
- - unsigned
+ - signed
+ - antisymmetric
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fs: 1
@@ -383,17 +415,6 @@
- fmin: 0.25
fmax: 0.5
- - fs: 1
- statistic: max
-
- - fmin: 0
- fmax: 0.25
- statistic: max
-
- - fmin: 0.25
- fmax: 0.5
- statistic: max
-
CoherenceMagnitude:
labels:
- undirected
@@ -401,6 +422,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -430,6 +452,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -453,11 +476,11 @@
PhaseSlopeIndex:
labels:
- - directed
- linear/nonlinear
- unsigned
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fmin: 0
@@ -476,6 +499,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M10
dependencies:
configs:
- fs: 1
@@ -488,10 +512,14 @@
- fs: 1
statistic: max
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
statistic: max
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
@@ -499,20 +527,26 @@
PhaseLagIndex:
labels:
- - undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M14
- fmin: 0
fmax: 0.25
+ labels:
+ - M01
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M01
- fs: 1
statistic: max
@@ -527,20 +561,26 @@
WeightedPhaseLagIndex:
labels:
- - undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M14
- fmin: 0
fmax: 0.25
+ labels:
+ - M01
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M01
- fs: 1
statistic: max
@@ -554,21 +594,39 @@
statistic: max
DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
labels:
- undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M12
- fs: 1
statistic: max
@@ -582,21 +640,30 @@
statistic: max
DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
labels:
- undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M12
- fs: 1
statistic: max
@@ -616,6 +683,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -644,6 +712,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -686,12 +755,19 @@
# statistic: max
DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
labels:
- directed
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -720,6 +796,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -748,6 +825,7 @@
- linear
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -776,6 +854,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -804,6 +883,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fmin: 0
@@ -826,6 +906,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- method: nonparametric
@@ -865,11 +946,15 @@
fmin: 0
fmax: 0.5
statistic: mean
+ labels:
+ - M05
- method: parametric
fmin: 0
fmax: 0.25
statistic: mean
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
@@ -877,16 +962,22 @@
statistic: mean
# AR order 1
+ labels:
+ - M05
- fs: 1
order: 1
method: parametric
statistic: mean
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
order: 1
method: parametric
statistic: mean
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
@@ -895,6 +986,8 @@
statistic: mean
# AR order 20
+ labels:
+ - M05
- fs: 1
order: 20
method: parametric
@@ -916,11 +1009,15 @@
- fs: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
@@ -928,16 +1025,22 @@
statistic: max
# AR order 1
+ labels:
+ - M05
- fs: 1
order: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
order: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
@@ -946,6 +1049,8 @@
statistic: max
# AR order 20
+ labels:
+ - M05
- fs: 1
order: 20
method: parametric
@@ -994,17 +1099,6 @@
# - fmin: 0.25
# fmax: 0.5
- # - fs: 1
- # statistic: max
-
- # - fmin: 0
- # fmax: 0.25
- # statistic: max
-
- # - fmin: 0.25
- # fmax: 0.5
- # statistic: max
-
# # Coherence (ordinal or squared? imaginary components of the coherence)
# ImaginaryCoherence:
# - fs: 1
@@ -1148,10 +1242,10 @@
PhaseSlopeIndex:
labels:
- - undirected
- unsigned
- time/frequency dependent
- bivariate
+ - M08
dependencies:
configs:
- fs: 1
@@ -1182,6 +1276,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- model: Ridge
@@ -1193,11 +1288,11 @@
# Cointegration
Cointegration:
labels:
- - undirected
- linear
- unsigned
- bivariate
- time-dependent
+ - M13
dependencies:
configs:
- method: johansen
@@ -1266,6 +1361,7 @@
- unsigned
- bivariate
- time-dependent
+ - M11
dependencies:
configs:
- orth: False
@@ -1303,4 +1399,3 @@
- terms: 6
pnorm: 'max'
bandwidth: 0.5
-
\ No newline at end of file
diff --git a/pyspi/config.yaml b/pyspi/configs/full.yaml
similarity index 77%
rename from pyspi/config.yaml
rename to pyspi/configs/full.yaml
index 72c85efc..beccde45 100644
--- a/pyspi/config.yaml
+++ b/pyspi/configs/full.yaml
@@ -8,6 +8,7 @@
- signed
- multivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -43,6 +44,7 @@
- signed
- multivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -78,6 +80,7 @@
- signed
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- squared: True
@@ -91,11 +94,78 @@
- signed
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- squared: True
- squared: False
+ # Lagged correlation coefficient (fork-added; class in pyspi/statistics/basic.py)
+ LaggedCorrelation:
+ labels:
+ - undirected
+ - linear
+ - signed/unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - estimator: pearson
+ max_tau: 5
+ squared: False
+ - estimator: pearson
+ max_tau: 5
+ squared: True
+ - estimator: pearson
+ tau: 10
+ squared: False
+ - estimator: pearson
+ tau: 10
+ squared: True
+ - estimator: pearson
+ tau: 20
+ squared: False
+ - estimator: pearson
+ tau: 20
+ squared: True
+ - estimator: spearman
+ max_tau: 5
+ squared: False
+ - estimator: spearman
+ max_tau: 5
+ squared: True
+ - estimator: spearman
+ tau: 10
+ squared: False
+ - estimator: spearman
+ tau: 10
+ squared: True
+ - estimator: spearman
+ tau: 20
+ squared: False
+ - estimator: spearman
+ tau: 20
+ squared: True
+ - estimator: kendall
+ max_tau: 5
+ squared: False
+ - estimator: kendall
+ max_tau: 5
+ squared: True
+ - estimator: kendall
+ tau: 10
+ squared: False
+ - estimator: kendall
+ tau: 10
+ squared: True
+ - estimator: kendall
+ tau: 20
+ squared: False
+ - estimator: kendall
+ tau: 20
+ squared: True
+
# statistics based on cross-correlation (squared means we square the xcorr, not the output)
CrossCorrelation:
labels:
@@ -104,9 +174,12 @@
- signed/unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- statistic: "max"
+ labels:
+ - M10
- statistic: "max"
squared: True
@@ -130,6 +203,7 @@
- unordered
- nonlinear
- undirected
+ - MXX
dependencies:
configs:
- metric: "euclidean"
@@ -147,6 +221,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- biased: False
@@ -160,6 +235,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
@@ -171,6 +247,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- biased: False
@@ -184,17 +261,19 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
# Multi-scale graph correlation for time series
CrossMultiscaleGraphCorrelation:
labels:
- - undirected
+ - directed
- nonlinear
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- max_lag: 1
@@ -203,11 +282,12 @@
# Distance correlation for time series
CrossDistanceCorrelation:
labels:
- - undirected
+ - directed
- nonlinear
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- max_lag: 1
@@ -220,12 +300,32 @@
- unsigned
- bivariate
- time-dependent
+ - M10
dependencies:
configs:
- global_constraint: null
- global_constraint: itakura
- global_constraint: sakoe_chiba
+ # Cross pairwise distance (fork-added; class in pyspi/statistics/distance.py)
+ # Shifted-path Euclidean upper bound on DTW.
+ CrossPairwiseDistance:
+ labels:
+ - undirected
+ - nonlinear
+ - unsigned
+ - bivariate
+ - time-dependent
+ - MXX
+ dependencies:
+ configs:
+ - metric: "euclidean"
+ tau: 1
+ statistic: "min"
+ - metric: "euclidean"
+ tau: 1
+ statistic: "mean"
+
SoftDynamicTimeWarping:
labels:
- undirected
@@ -233,6 +333,7 @@
- unsigned
- bivariate
- time-dependent
+ - M10
dependencies:
configs:
- global_constraint: null
@@ -246,6 +347,7 @@
- unsigned
- bivariate
- time-dependent
+ - M10
dependencies:
configs:
- global_constraint: null
@@ -259,6 +361,7 @@
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- mode: euclidean
@@ -267,14 +370,20 @@
statistic: max
- mode: dtw
statistic: mean
+ labels:
+ - M09
- mode: dtw
statistic: max
- mode: sgddtw
statistic: mean
+ labels:
+ - M09
- mode: sgddtw
statistic: max
- mode: softdtw
statistic: mean
+ labels:
+ - M09
- mode: softdtw
statistic: max
@@ -289,6 +398,8 @@
- mode: dtw
statistic: mean
squared: True
+ labels:
+ - M09
- mode: dtw
statistic: max
@@ -297,6 +408,8 @@
- mode: sgddtw
statistic: mean
squared: True
+ labels:
+ - M09
- mode: sgddtw
statistic: max
@@ -305,6 +418,8 @@
- mode: softdtw
statistic: mean
squared: True
+ labels:
+ - M09
- mode: softdtw
statistic: max
@@ -318,6 +433,7 @@
- unordered
- nonlinear
- undirected
+ - MXX
dependencies:
configs:
@@ -330,6 +446,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M02
dependencies:
configs:
@@ -341,6 +458,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M02
dependencies:
configs:
@@ -352,19 +470,20 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- # Information-geometric conditional independence
- InformationGeometricConditionalIndependence:
- labels:
- - directed
- - nonlinear
- - unsigned
- - bivariate
- - contemporaneous
- dependencies:
- configs:
+ # Information-geometric causal inference (disabled heuristic)
+ # InformationGeometricCausalInference:
+ # labels:
+ # - directed
+ # - nonlinear
+ # - unsigned
+ # - bivariate
+ # - contemporaneous
+ # dependencies:
+ # configs:
# Convergent-cross mapping
ConvergentCrossMapping:
@@ -374,6 +493,7 @@
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- statistic: mean
@@ -401,8 +521,7 @@
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
- estimator: kozachenko
@@ -410,13 +529,12 @@
ConditionalEntropy: # No theiler window yet
labels:
- - undirected
+ - directed
- nonlinear
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
- estimator: kozachenko
@@ -429,8 +547,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M03
configs:
- estimator: gaussian
- estimator: kozachenko
@@ -442,8 +559,7 @@
- directed
- time-dependent
- bivariate
- dependencies:
- - java
+ - MXX
configs:
- estimator: gaussian
history_length: 1
@@ -464,18 +580,32 @@
history_length: 10
DirectedInfo: # No theiler window yet
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
+
labels:
- directed
- nonlinear
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M03
configs:
- estimator: gaussian
- - estimator: kozachenko
- - estimator: kernel
+ # Direct KSG/Frenzel-Pompe conditional-MI estimate. The kernel and
+ # kozachenko variants were dropped because composing DI from separate
+ # entropies leaves each with its own dimension-dependent bias; this
+ # estimates each I(X^i; Y_i | Y^{i-1}) term directly, so the biases
+ # cancel. Not added to the benchmarked_p* sets until it has been timed.
+ - estimator: kraskov
+ prop_k: 4
StochasticInteraction: # No theiler window
labels:
@@ -484,10 +614,11 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
+ labels:
+ - M05
- estimator: kozachenko
- estimator: kernel
@@ -499,8 +630,7 @@
- unsigned
- bivariate
- contemporaneous
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
@@ -522,8 +652,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M14
configs:
- estimator: gaussian
@@ -545,8 +674,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M04
configs:
# Kraskov estimator with auto-embedding on source/target and DCE
- estimator: kraskov
@@ -593,25 +721,41 @@
- estimator: kernel
kernel_width: 0.25
k_history: 1
- l_history: 1
+ labels:
+ - M14
- # Gaussian estimator doesn't have DCE (aka Bartlett corrections) yet
+ # Gaussian estimation has no Theiler/dynamic-correlation exclusion option
- estimator: gaussian
auto_embed_method: MAX_CORR_AIS
k_search_max: 10
tau_search_max: 2
+ labels:
+ - M05
- estimator: gaussian
k_history: 1
l_history: 1
-
- - estimator: symbolic
- k_history: 1
- l_history: 1
-
- - estimator: symbolic
- k_history: 10
- l_history: 1
+ labels:
+ - M05
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
IntegratedInformation:
labels:
@@ -620,6 +764,7 @@
- unsigned
- bivariate
- time-dependent
+ - M05
dependencies:
configs:
- phitype: "star"
@@ -636,11 +781,12 @@
.statistics.spectral:
CoherencePhase:
labels:
- - undirected
- linear
- - unsigned
+ - signed
+ - antisymmetric
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fs: 1
@@ -651,17 +797,6 @@
- fmin: 0.25
fmax: 0.5
- - fs: 1
- statistic: max
-
- - fmin: 0
- fmax: 0.25
- statistic: max
-
- - fmin: 0.25
- fmax: 0.5
- statistic: max
-
CoherenceMagnitude:
labels:
- undirected
@@ -669,6 +804,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -698,6 +834,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -721,11 +858,11 @@
PhaseSlopeIndex:
labels:
- - directed
- linear/nonlinear
- unsigned
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fmin: 0
@@ -744,6 +881,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M10
dependencies:
configs:
- fs: 1
@@ -756,10 +894,14 @@
- fs: 1
statistic: max
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
statistic: max
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
@@ -767,20 +909,26 @@
PhaseLagIndex:
labels:
- - undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M14
- fmin: 0
fmax: 0.25
+ labels:
+ - M01
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M01
- fs: 1
statistic: max
@@ -795,20 +943,26 @@
WeightedPhaseLagIndex:
labels:
- - undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M14
- fmin: 0
fmax: 0.25
+ labels:
+ - M01
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M01
- fs: 1
statistic: max
@@ -822,21 +976,39 @@
statistic: max
DebiasedSquaredPhaseLagIndex:
+ # NOTE: the `max` band statistic saturates on the data tested. The band
+ # maximum reaches exactly 1 as soon as the sign of the imaginary coherency
+ # is consistent across tapers at any ONE frequency. On the frozen fixtures
+ # the share of pairs sitting at exactly 1 runs from 29% to 100% depending on
+ # the data, with 1 to 16 distinct values; the `mean` variants are graded
+ # normally. This is an empirical observation, not a law: saturation is NOT
+ # monotone in T -- changing T recomputes the tapers and Fourier
+ # coefficients, so the values at a longer series are not a superset of the
+ # shorter one (measured non-monotone in 7 of 36 seed/band combinations).
+ # Kept enabled: the statistic is behaving as defined. Read `max` values
+ # near 1 with that in mind.
labels:
- undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M12
- fs: 1
statistic: max
@@ -850,21 +1022,30 @@
statistic: max
DebiasedSquaredWeightedPhaseLagIndex:
+ # NOTE: same saturation behaviour as DebiasedSquaredPhaseLagIndex above.
+ # See that note.
labels:
- undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
+ labels:
+ - M12
- fmin: 0
fmax: 0.25
+ labels:
+ - M12
- fmin: 0.25
fmax: 0.5
+ labels:
+ - M12
- fs: 1
statistic: max
@@ -884,6 +1065,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
@@ -912,6 +1094,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -954,12 +1137,19 @@
# statistic: max
DirectedCoherence:
+ # Directed coherence is recomputed in pyspi (see DirectedCoherence in
+ # statistics/spectral.py): the backend puts |H|^2 in the numerator while
+ # the denominator is on the magnitude scale, which made it unbounded
+ # (baselines reached 3.27 / 1.84 / 1139.47). The corrected form is bounded
+ # in [0,1] and reproduces sqrt(directed_transfer_function()) to 4e-16 under
+ # an identity noise covariance, which is the identity DC must satisfy.
labels:
- directed
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -988,6 +1178,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -1016,6 +1207,7 @@
- linear
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -1044,6 +1236,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- fs: 1
@@ -1072,6 +1265,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M01
dependencies:
configs:
- fmin: 0
@@ -1094,6 +1288,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- method: nonparametric
@@ -1133,34 +1328,46 @@
fmin: 0
fmax: 0.5
statistic: mean
+ labels:
+ - M05
- method: parametric
fmin: 0
fmax: 0.25
statistic: mean
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
method: parametric
statistic: mean
+ labels:
+ - M05
# AR order 1
- fs: 1
order: 1
method: parametric
statistic: mean
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
order: 1
method: parametric
statistic: mean
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
order: 1
method: parametric
statistic: mean
+ labels:
+ - M05
# AR order 20
- fs: 1
@@ -1184,34 +1391,46 @@
- fs: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
method: parametric
statistic: max
+ labels:
+ - M05
# AR order 1
- fs: 1
order: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0
fmax: 0.25
order: 1
method: parametric
statistic: max
+ labels:
+ - M05
- fmin: 0.25
fmax: 0.5
order: 1
method: parametric
statistic: max
+ labels:
+ - M05
# AR order 20
- fs: 1
@@ -1235,10 +1454,10 @@
.statistics.wavelet:
PhaseSlopeIndex:
labels:
- - undirected
- unsigned
- time/frequency dependent
- bivariate
+ - M08
dependencies:
configs:
- fs: 1
@@ -1269,6 +1488,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- model: Ridge
@@ -1284,6 +1504,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- kernel: DotProduct
@@ -1292,11 +1513,11 @@
# Cointegration
Cointegration:
labels:
- - undirected
- linear
- unsigned
- bivariate
- time-dependent
+ - M13
dependencies:
configs:
- method: johansen
@@ -1365,6 +1586,7 @@
- unsigned
- bivariate
- time-dependent
+ - M11
dependencies:
configs:
- orth: False
diff --git a/pyspi/sonnet_config.yaml b/pyspi/configs/sonnet.yaml
similarity index 68%
rename from pyspi/sonnet_config.yaml
rename to pyspi/configs/sonnet.yaml
index 2b667249..e9927d43 100644
--- a/pyspi/sonnet_config.yaml
+++ b/pyspi/configs/sonnet.yaml
@@ -8,6 +8,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M14
dependencies:
configs:
- estimator: EmpiricalCovariance
@@ -20,6 +21,7 @@
- unsigned
- bivariate
- time-dependent
+ - M10
dependencies:
configs:
- global_constraint: itakura
@@ -31,10 +33,13 @@
- unsigned
- bivariate
- time-dependent
+ - M14
dependencies:
configs:
- mode: dtw
statistic: mean
+ labels:
+ - M09
.statistics.causal:
@@ -46,6 +51,7 @@
- unsigned
- bivariate
- contemporaneous
+ - M02
dependencies:
configs:
@@ -59,10 +65,18 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M03
configs:
- estimator: gaussian
+ # Disabled: composing directed information from four separately-estimated
+ # entropies leaves each with its own dimension-dependent bias, and they do
+ # not cancel. On independent data the kernel variant sat at 3.8-4.4 for
+ # every T from 100 to 8000 (a fixed bandwidth in ~11 dimensions does not
+ # improve with sample size) and kozachenko returned negatives, for a
+ # quantity bounded below by zero. Use estimator: kraskov, which estimates
+ # each conditional-MI term directly.
+ # - estimator: kernel
+ # - estimator: kozachenko
# Transfer entropy
TransferEntropy:
@@ -72,8 +86,7 @@
- unsigned
- bivariate
- time-dependent
- dependencies:
- - java
+ - M04
configs:
- estimator: kraskov
prop_k: 4
@@ -82,6 +95,26 @@
tau_search_max: 4
dyn_corr_excl: AUTO
+ # Disabled: k_history=1 gives a single ordinal symbol, so the transfer
+ # entropy is identically zero. Rejected at construction; kept here as a
+ # record of what was dropped.
+ # - estimator: symbolic
+ # k_history: 1
+ # l_history: 1
+ # labels:
+ # - M05
+ #
+ # Disabled: severely undersampled and unvalidated at T=100. 10! symbols
+ # against ~91 usable samples: on var1 and cml every joint count is 1, so
+ # the value tracks sample size rather than dependence, but that does not
+ # hold universally (kuramoto: 3 of 42 pairs). Still constructible, and
+ # defensible for long series where the symbol space is populated.
+ # - estimator: symbolic
+ # k_history: 10
+ # l_history: 1
+ # labels:
+ # - M05
+
# Integrated information
IntegratedInformation:
labels:
@@ -90,6 +123,7 @@
- unsigned
- bivariate
- time-dependent
+ - M05
dependencies:
configs:
- phitype: 'star'
@@ -104,18 +138,19 @@
- unsigned
- bivariate
- frequency-dependent
+ - M12
dependencies:
configs:
- fs: 1
PhaseSlopeIndex:
labels:
- - directed
- linear/nonlinear
- unsigned
- bivariate
- frequency-dependent
- time-frequency dependent
+ - M01
dependencies:
configs:
- fmin: 0
@@ -123,11 +158,11 @@
PhaseLagIndex:
labels:
- - undirected
- linear
- unsigned
- bivariate
- frequency-dependent
+ - M07
dependencies:
configs:
- fs: 1
@@ -141,6 +176,7 @@
- unsigned
- bivariate
- frequency-dependent
+ - M06
dependencies:
configs:
- method: nonparametric
@@ -152,12 +188,12 @@
.statistics.wavelet:
PhaseSlopeIndex:
labels:
- - directed
- linear/nonlinear
- unsigned
- bivariate
- frequency-dependent
- time-frequency dependent
+ - M08
dependencies:
configs:
- fs: 1
@@ -166,11 +202,11 @@
# Cointegration
Cointegration:
labels:
- - undirected
- linear
- unsigned
- bivariate
- time-dependent
+ - M13
dependencies:
configs:
- method: aeg
@@ -187,6 +223,7 @@
- unsigned
- bivariate
- time-dependent
+ - M11
dependencies:
configs:
- orth: False
diff --git a/pyspi/data.py b/pyspi/data.py
index ad50e7e7..230078e8 100644
--- a/pyspi/data.py
+++ b/pyspi/data.py
@@ -7,11 +7,41 @@
from pyspi import utils
from scipy.stats import zscore
from scipy.signal import detrend
-from colorama import init, Fore
import os
+from ._logging import get_logger
+
+logger = get_logger("pyspi.data")
+
VERBOSE = False
-init(autoreset=True) # automatically reset coloured outputs
+
+
+def _validate_procnames(procnames, n_processes):
+ """Coerce process names to unique strings, or say why they cannot be.
+
+ Names are the column and row labels of the results table, and
+ ``Calculator.to_frame()`` stacks on them: duplicates raised pandas'
+ "Columns with duplicate values are not supported in stack" from four frames
+ away, with nothing pointing at the names. They are also written to the NPZ
+ as a ``U`` array, so a non-string name came back as its ``str()`` and the
+ file did not round-trip -- ``procnames=[1, 2]`` loaded as ``["1", "2"]``.
+ Coercing here makes the object and the file agree from the start.
+ """
+ names = [str(p) for p in procnames]
+ if len(names) != n_processes:
+ raise ValueError(
+ f"procnames length ({len(names)}) does not match "
+ f"n_processes ({n_processes})."
+ )
+ duplicates = sorted({n for n in names if names.count(n) > 1})
+ if duplicates:
+ raise ValueError(
+ f"Process names must be unique; {duplicates} appear(s) more than "
+ f"once. They label the rows and columns of the results table, and "
+ f"`Calculator.to_frame()` cannot stack duplicated labels."
+ )
+ return names
+
class Data:
"""Store data for dependency analysis.
@@ -43,8 +73,12 @@ class Data:
Order of dimensions, accepts two combinations of the characters 'p', and 's' for processes and observations, default='ps'.
detrend (bool, optional):
If True, detrend each time series in the MTS dataset individually along the time axis, default=False.
- normalise (bool, optional):
- If True, z-score normalise each time series in the MTS dataset individually along the time axis, default=True.
+ zscore (bool, optional):
+ If True, z-score each time series in the MTS dataset individually
+ along the time axis, default=True. Standardisation is per-process,
+ not whole-dataset: it removes each process's arbitrary gain/units
+ without letting the choice of the other processes in the dataset
+ influence any pairwise statistic.
name (str, optional):
Name of the dataset
procnames (list, optional):
@@ -56,19 +90,55 @@ class Data:
"""
+ # Every attribute a statistic may cache directly on a Data instance. This is
+ # the authoritative list: anything that writes `data. = ...` from
+ # pyspi/statistics/ must appear here, or its cache will survive a mutation
+ # of the underlying series and silently serve results from the old data.
+ #
+ # Derived mechanically from the statistics package:
+ # grep -rhoE "\bdata\.[a-z_][a-z0-9_]*\s*=" pyspi/statistics/*.py
+ _CACHE_ATTRS = (
+ "_spectral_bv_conn",
+ "ais_embedding",
+ "barycenter",
+ "causal_entropy",
+ "ccm",
+ "coint",
+ "covariance",
+ "entropy",
+ "joint_entropy",
+ "mne",
+ "mne_psi",
+ "spectral_bv",
+ "spectral_gc",
+ "spectral_mv",
+ "theiler",
+ "xcorr",
+ )
+
def __init__(
self,
data=None,
dim_order="ps",
detrend=False,
- normalise=True,
+ zscore=True,
name=None,
procnames=None,
n_processes=None,
n_observations=None,
):
- self.normalise = normalise
+ self.zscore = zscore
self.detrend = detrend
+ # Explicit empty state so attribute access is consistent before set_data
+ # has run (Data() with no args is a legitimate builder-pattern entry point
+ # used by add_process()).
+ self._data = None
+ self.n_processes = 0
+ self.n_observations = 0
+ self.n_replications = 0
+ # _name and _procnames are only set when data is actually loaded — keeps
+ # the existing contract that name="N/A" until data exists.
+
if data is not None:
dat = self.convert_to_numpy(data)
self.set_data(
@@ -78,9 +148,44 @@ def __init__(
n_processes=n_processes,
n_observations=n_observations,
)
-
+ if procnames is not None:
+ self._procnames = _validate_procnames(procnames, self.n_processes)
+
+ @classmethod
+ def _from_prepared_array(cls, arr, procnames=None, name=None):
+ """Build a Data around an already-preprocessed array, without copying.
+
+ Internal constructor for the parallel workers, which attach to a
+ shared-memory block holding the parent's already-detrended/z-scored
+ series. Copying would defeat the point of sharing, so ownership is
+ waived here and the array is exposed read-only instead — workers only
+ ever read it.
+
+ This replaces the previous ``Data.__new__`` + manual attribute
+ assignment in ``pyspi._parallel._attach_data``, which had to be kept in
+ sync with ``__init__`` by hand and silently skipped anything added
+ there.
+ """
+ if arr.ndim != 3:
+ raise ValueError(
+ f"Prepared array must be (processes, observations, replications); "
+ f"got shape {arr.shape}."
+ )
+ self = cls.__new__(cls)
+ self.zscore = False
+ self.detrend = False
+ self._data = arr
+ self.data_type = arr.dtype.type
+ self._name = name or "N/A"
+ self._reset_data_size()
if procnames is not None:
- assert len(procnames) == self.n_processes
+ # Validated on the internal constructor too. The parallel workers
+ # reach Data only through this path, so skipping the check here
+ # would mean a name set that `Calculator.to_frame()` cannot stack
+ # is caught in a serial run and not in a parallel one.
+ self._procnames = _validate_procnames(procnames, self.n_processes)
+ self._sync_procnames()
+ return self
@property
def name(self):
@@ -99,23 +204,90 @@ def name(self, n):
@property
def procnames(self):
- """List of process names."""
+ """List of process names (a copy; mutating it does not affect the Data)."""
if hasattr(self, "_procnames"):
- return self._procnames
+ return list(self._procnames)
else:
return [f"proc-{i}" for i in range(self.n_processes)]
+ def _apply_preprocessing(self, data, log=False):
+ """Detrend and/or z-score along the time axis of a (p, s, r) array.
+
+ The single preprocessing path. ``add_process`` previously appended its
+ argument raw while ``set_data`` transformed it, so building a dataset
+ with ``Data().add_process(x).add_process(y)`` z-scored the first process
+ and left the second on its original scale -- every pairwise statistic
+ then compared a standardised series against an unstandardised one.
+
+ Both are per-process along time, so applying them to one appended
+ process gives exactly the same result as applying them to the whole
+ array at once.
+ """
+ if self.detrend:
+ if log:
+ logger.info("[1/2] Detrending time series in the dataset...")
+ try:
+ data = detrend(data, axis=1)
+ except ValueError as err:
+ logger.warning("Could not detrend data: %s", err)
+ elif log:
+ logger.info("[1/2] Skipping detrending of time series in the dataset.")
+
+ if self.zscore:
+ if log:
+ logger.info("[2/2] Normalising (z-scoring) each time series in the dataset...")
+ data = zscore(data, axis=1, nan_policy="omit", ddof=1)
+ elif log:
+ logger.info("[2/2] Skipping normalisation of time series in the dataset.")
+
+ return data
+
+ def _invalidate_caches(self):
+ """Drop every statistic cache held on this instance.
+
+ Called whenever the underlying series change. Statistics cache results
+ keyed by parameters but *not* by the data, so a cache that outlives a
+ mutation returns the previous dataset's numbers with no error and no
+ warning — the most dangerous failure mode in the package.
+ """
+ for attr in self._CACHE_ATTRS:
+ self.__dict__.pop(attr, None)
+
+ def _set_internal(self, arr):
+ """Install ``arr`` as the backing store, owned and frozen.
+
+ Data takes ownership: the array is copied if it is not already private,
+ then marked read-only so neither the caller nor a statistic can mutate
+ the series behind the caches.
+ """
+ arr = np.array(arr, dtype=arr.dtype, copy=True, order="C")
+ arr.setflags(write=False)
+ self._data = arr
+ self._invalidate_caches()
+
def to_numpy(self, realisation=None, squeeze=False):
- """Return the numpy array."""
+ """Return the numpy array.
+
+ The result is a **read-only** view of the internal store. Copy it if you
+ need to modify it; writing through it would desynchronise the statistic
+ caches from the data they were computed on.
+ """
if realisation is not None:
dat = self._data[:, :, realisation]
else:
dat = self._data
if squeeze:
- return np.squeeze(dat)
- else:
- return dat
+ dat = np.squeeze(dat)
+
+ # Always freeze, never conditionally. Guarding on `owndata` was exactly
+ # backwards: the shared-memory worker path is precisely where owndata is
+ # False, so the one case that most needed protecting was the one case
+ # left writable -- a worker could mutate the block every other worker
+ # was reading.
+ dat = dat.view()
+ dat.setflags(write=False)
+ return dat
@staticmethod
def convert_to_numpy(data):
@@ -134,11 +306,14 @@ def convert_to_numpy(data):
elif ext == ".csv":
npdat = np.genfromtxt(data, ",")
elif ext == ".ts":
- from sktime.utils.data_io import load_from_tsfile_to_dataframe
- from sktime.datatypes._panel._convert import from_nested_to_3d_numpy
-
- tsdat, _ = load_from_tsfile_to_dataframe(data)
- npdat = from_nested_to_3d_numpy(tsdat)
+ try:
+ from aeon.datasets import load_from_ts_file
+ except ImportError as e:
+ raise ImportError(
+ "Loading .ts files requires aeon. Install with "
+ "`pip install aeon` or `uv pip install aeon`."
+ ) from e
+ npdat, _ = load_from_ts_file(data)
else:
raise TypeError(f"Unknown filename extension: {ext}")
else:
@@ -172,6 +347,17 @@ def set_data(
"Data array dimension ({0}) and length of "
"dim_order ({1}) are not equal.".format(data.ndim, len(dim_order))
)
+ # Unknown or repeated symbols previously slipped through and produced
+ # 4-D/5-D internal states (e.g. 'xx', 'pp') that fail far from here.
+ unknown = set(dim_order) - set("psr")
+ if unknown:
+ raise ValueError(
+ f"dim_order contains unknown symbol(s) {sorted(unknown)}; "
+ "valid symbols are 'p' (processes), 's' (observations), "
+ "'r' (replications)."
+ )
+ if len(set(dim_order)) != len(dim_order):
+ raise ValueError(f"dim_order has repeated symbols: {dim_order!r}.")
# Bring data into the order processes x observations in a pandas dataframe.
data = self._reorder_data(data, dim_order)
@@ -181,39 +367,32 @@ def set_data(
if n_observations is not None:
data = data[:, :n_observations]
- if self.detrend:
- print(Fore.GREEN + "[1/2] Detrending time series in the dataset...")
- try:
- data = detrend(data, axis=1)
- except ValueError as err:
- print(f"Could not detrend data: {err}")
- else:
- print(Fore.RED + "[1/2] Skipping detrending of time series in the dataset...")
-
- if self.normalise:
- print(Fore.GREEN + "[2/2] Normalising (z-scoring) each time series in the dataset...\n")
- data = zscore(data, axis=1, nan_policy="omit", ddof=1)
- else:
- print(Fore.RED + "[2/2] Skipping normalisation of time series in the dataset...\n")
+ data = self._apply_preprocessing(data, log=True)
- nans = np.isnan(data)
- if nans.any():
+ # Check all non-finite values, not just NaNs: with zscore=False an inf
+ # passes straight through to the estimators, where it surfaces as an
+ # unrelated failure much later.
+ bad = ~np.isfinite(data)
+ if bad.any():
raise ValueError(
- f"Dataset {name} contains non-numerics (NaNs) in processes: {np.unique(np.where(nans)[0])}."
+ f"Dataset {name} contains non-finite values (NaN/inf) in "
+ f"processes: {np.unique(np.where(bad)[0])}."
)
- self._data = data
- self.data_type = type(data[0, 00, 0])
+ self._set_internal(data)
+ self.data_type = self._data.dtype.type
self._reset_data_size()
+ # Process names are positional, so any change in width invalidates them.
+ self._sync_procnames()
if name is not None:
self._name = name
if verbose:
- print(
- f'Dataset "{name}" now has properties: {self.n_processes} processes, {self.n_observations} observations, {self.n_replications} '
- "replications"
+ logger.info(
+ 'Dataset "%s" now has properties: %d processes, %d observations, %d replications',
+ name, self.n_processes, self.n_observations, self.n_replications,
)
def add_process(self, proc, verbose=False):
@@ -227,28 +406,66 @@ def add_process(self, proc, verbose=False):
if not isinstance(proc, np.ndarray) or proc.ndim != 1:
raise TypeError("Process must be a 1D numpy array")
- if hasattr(self, "_data"):
- try:
- self._data = np.append(
- self._data, np.reshape(proc, (1, self.n_observations, 1)), axis=0
- )
- except IndexError:
- raise IndexError()
- else:
+ # Guard on the value, not on hasattr: _data is now always present (set
+ # to None in __init__), so hasattr is True even for an empty Data and
+ # the builder path would reshape into a zero-width array.
+ if self._data is None:
self.set_data(proc, dim_order="s", verbose=verbose)
+ return
+ if proc.size != self.n_observations:
+ raise ValueError(
+ f"Process has {proc.size} observations but the dataset has "
+ f"{self.n_observations}."
+ )
+ # Preprocess the incoming process the same way set_data would, so the
+ # builder path produces a dataset with uniform preprocessing.
+ block = self._apply_preprocessing(
+ np.reshape(np.asarray(proc, dtype=float), (1, self.n_observations, 1))
+ )
+ appended = np.append(self._data, block, axis=0)
+ self._set_internal(appended)
self._reset_data_size()
+ if hasattr(self, "_procnames"):
+ # `proc-` can already be taken: a caller who named their
+ # processes ["a", "proc-2"] and then appended twice would produce a
+ # second "proc-2", and `Calculator.to_frame()` cannot stack
+ # duplicated labels. Fall past any name already in use rather than
+ # silently creating a collision the constructor would have rejected.
+ index = self.n_processes - 1
+ taken = set(self._procnames)
+ name = f"proc-{index}"
+ while name in taken:
+ index += 1
+ name = f"proc-{index}"
+ self._procnames.append(name)
def remove_process(self, procs):
try:
- self._data = np.delete(self._data, procs, axis=0)
+ reduced = np.delete(self._data, procs, axis=0)
except IndexError:
- print(
- f"Process {procs} is out of bounds of multivariate"
- f" time-series data with size {self.data.n_processes}"
+ logger.error(
+ "Process %s is out of bounds of multivariate time-series data "
+ "with %d process(es)", procs, self.n_processes,
)
+ return
+ keep = np.delete(np.arange(self.n_processes), procs)
+ self._set_internal(reduced)
self._reset_data_size()
+ if hasattr(self, "_procnames"):
+ self._procnames = [self._procnames[i] for i in keep]
+
+ def _sync_procnames(self):
+ """Drop stale process names after a change in width.
+
+ Names are positional; once the number of processes changes under them
+ they no longer identify anything, so falling back to the generated
+ ``proc-i`` names is the only honest option.
+ """
+ names = self.__dict__.get("_procnames")
+ if names is not None and len(names) != self.n_processes:
+ del self._procnames
def _reorder_data(self, data, dim_order):
"""Reorder data dimensions to processes x observations x realisations."""
@@ -277,14 +494,30 @@ def _reset_data_size(self):
self.n_replications = self._data.shape[2]
+# name -> (filename, dim_order, description). Every bundled .npy is stored
+# (observations, processes), hence dim_order 'sp' throughout.
+_DATASETS = {
+ "forex": ("forex.npy", "sp", "Foreign-exchange rates (250 obs, 7 processes)."),
+ "cml": ("cml.npy", "sp", "Coupled map lattice (500 obs, 10 processes)."),
+ "standard_normal": ("standard_normal.npy", "sp", "i.i.d. standard normal null model (200 obs, 5 processes)."),
+}
+
+
+def available_datasets():
+ """Return ``{name: description}`` for every bundled dataset."""
+ return {name: desc for name, (_f, _d, desc) in _DATASETS.items()}
+
+
def load_dataset(name):
+ """Load a bundled example dataset by name.
+
+ See :func:`available_datasets` for the full list.
+ """
+ try:
+ filename, dim_order, _desc = _DATASETS[name]
+ except KeyError:
+ raise NameError(
+ f"Unknown dataset: {name}. Available: {', '.join(sorted(_DATASETS))}."
+ ) from None
basedir = os.path.join(os.path.dirname(__file__), "data")
- if name == "forex":
- filename = "forex.npy"
- dim_order = "sp"
- elif name == "cml":
- filename = "cml.npy"
- dim_order = "sp"
- else:
- raise NameError(f"Unknown dataset: {name}.")
return Data(data=os.path.join(basedir, filename), dim_order=dim_order)
diff --git a/pyspi/data/cml7.npy b/pyspi/data/cml7.npy
deleted file mode 100644
index 99d7362c..00000000
Binary files a/pyspi/data/cml7.npy and /dev/null differ
diff --git a/pyspi/lib/LICENSE-cdt.txt b/pyspi/lib/LICENSE-cdt.txt
new file mode 100644
index 00000000..6d569e0e
--- /dev/null
+++ b/pyspi/lib/LICENSE-cdt.txt
@@ -0,0 +1,26 @@
+pyspi/lib/pairwise_causal.py is derived from the Causal Discovery Toolbox
+(https://github.com/FenTechSolutions/CausalDiscoveryToolbox), from which pyspi
+previously imported the ANM independence score, the conditional distribution
+similarity statistic, the RECI regression-error score and IGCI.
+
+MIT License
+
+Copyright (c) 2018 Diviyan Kalainathan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/pyspi/lib/ids/dependence.py b/pyspi/lib/ids/dependence.py
index 7402132c..bd775204 100644
--- a/pyspi/lib/ids/dependence.py
+++ b/pyspi/lib/ids/dependence.py
@@ -17,8 +17,8 @@ def compute_IDS(X, Y=None, num_terms=6, p_norm='max',
License: MIT (see LICENSE.txt)
Parameters:
- X: np.ndarray or torch.Tensor
- Y: np.ndarray or torch.Tensor (optional)
+ X: np.ndarray
+ Y: np.ndarray (optional)
num_terms: Number of terms for Taylor series approximation (optional)
p_norm: String 'max' if using IDS-max. 1 or 2 for IDS-1, IDS-2, respectively. (optional)
p_val: Boolean. Indicates whether to compute p-values using permutation tests
diff --git a/pyspi/lib/ids/numpy_dependence.py b/pyspi/lib/ids/numpy_dependence.py
index 09f3e5d7..c852d644 100644
--- a/pyspi/lib/ids/numpy_dependence.py
+++ b/pyspi/lib/ids/numpy_dependence.py
@@ -8,8 +8,11 @@
import sys
from tqdm import tqdm
+# NOTE: the upstream module called np.random.seed(1717) here, at import time.
+# Importing pyspi therefore reseeded the caller's global NumPy RNG, silently
+# changing the results of any surrounding analysis. Seeding is the caller's
+# decision, so it is not done here.
SEED = 1717
-np.random.seed(SEED)
EPSILON = sys.float_info.epsilon
diff --git a/pyspi/lib/jidt/__init__.py b/pyspi/lib/jidt/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/pyspi/lib/jidt/build.xml b/pyspi/lib/jidt/build.xml
deleted file mode 100644
index a75538b9..00000000
--- a/pyspi/lib/jidt/build.xml
+++ /dev/null
@@ -1,324 +0,0 @@
-
-
-
- Build file for the Java Information Dynamics Toolkit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- .block ul li {list-style:disc; margin-left: 20px;}
- .block ol li {list-style:decimal; margin-left: 40px;}
- .block ol li ol li {list-style:lower-alpha; margin-left: 40px;}
- .block ol li ul li {list-style:disc; margin-left: 40px;}
- .block ol li ul li ol li {list-style:lower-alpha; margin-left: 40px;}
- .block ol li ol li ol li {list-style:lower-roman; margin-left: 40px;}
- .block ul li ol li {list-style:decimal; margin-left: 20px;}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/pyspi/lib/jidt/infodynamics.jar b/pyspi/lib/jidt/infodynamics.jar
deleted file mode 100755
index 25b55af6..00000000
Binary files a/pyspi/lib/jidt/infodynamics.jar and /dev/null differ
diff --git a/pyspi/lib/jidt/license-gplv3.txt b/pyspi/lib/jidt/license-gplv3.txt
deleted file mode 100644
index 94a9ed02..00000000
--- a/pyspi/lib/jidt/license-gplv3.txt
+++ /dev/null
@@ -1,674 +0,0 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
- .
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/pyspi/lib/jidt/readme.txt b/pyspi/lib/jidt/readme.txt
deleted file mode 100644
index d93f411c..00000000
--- a/pyspi/lib/jidt/readme.txt
+++ /dev/null
@@ -1,286 +0,0 @@
-Java Information Dynamics Toolkit (JIDT)
-Copyright (C) 2012-2014 Joseph T. Lizier
-Copyright (C) 2014-2016 Joseph T. Lizier and Ipek Özdemir
-Copyright (C) 2016-2019 Joseph T. Lizier, Ipek Özdemir and Pedro Mediano
-Copyright (C) 2019-2022 Joseph T. Lizier, Ipek Özdemir, Pedro Mediano, Emanuele Crosato, Sooraj Sekhar and Oscar Huaigu Xu
-Copyright (C) 2022- Joseph T. Lizier, Ipek Özdemir, Pedro Mediano, Emanuele Crosato, Sooraj Sekhar, Oscar Huaigu Xu and David Shorten
-
-Version 1.6.1 (see release notes below)
-
-JIDT provides a standalone, open source code Java implementation (usable in Matlab, Octave and Python) of information-theoretic measures of distributed computation in complex systems: i.e. information storage, transfer and modification.
-
-This includes implementations for:
-- both discrete and continuous-valued variables, principally for the measures transfer entropy, mutual information and active information storage;
-- using various types of estimators (e.g. Kraskov-Stögbauer-Grassberger estimators, kernel estimation, linear-Gaussian).
-
-=============
- License
-=============
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program. If not, see .
-
-=============
- Website
-=============
-
-Full information on the JIDT (usage, etc) is provided at the project page and wiki on github:
-
-https://github.com/jlizier/jidt/
-https://github.com/jlizier/jidt/wiki
-
-=============
-Installation
-=============
-
-"Full" description of any required installation is at: https://github.com/jlizier/jidt/wiki/Installation
-
-However, if you are reading this file, you've downloaded a distribution and you're halfway there!
-
-There are no dependencies to download; unless:
- a. You don't have java installed - download it from http://www.java.com/
- b. You wish to build the project using the build.xml script - this requires ant: http://ant.apache.org/
- c. You wish to run the JUnit test cases - this requires JUnit: http://www.junit.org/ - for how to run JUnit with our ant script see https://github.com/jlizier/jidt/wiki/JUnitTestCases
-
-Then just put the jar in a relevant location in your file structure.
-
-That's it.
-
-=============
-Documentation
-=============
-
-A research paper describing the toolkit is included in the top level directory -- "InfoDynamicsToolkit.pdf".
-
-A tutorial, providing background to the information-theoretic measures, various estimators, and then to the JIDT toolkit itself is included in the tutorial folder (see "JIDT-TutorialSlides.pdf" for the tutorial slides, and "README-TutorialAndExercise.pdf" for further description of the tutorial exercises).
-
-Javadocs for the toolkit are included in the full distribution at javadocs.
-They can also be generated using "ant javadocs" (useful if you are on a git clone).
-Further, they will are posted on the web via links at https://github.com/jlizier/jidt/wiki/Documentation
-
-The project wiki also contains further information on various aspects; see https://github.com/jlizier/jidt/wiki to start.
-
-Further documentation is provided by the Usage demo examples below.
-
-You can also join our email discussion group jidt-discuss at http://groups.google.com/d/forum/jidt-discuss
-
-=============
- Usage
-=============
-
-Several sets of demonstration code are distributed with the toolkit:
-
- a. demos/AutoAnalyser -- a GUI tool to compute the information-theoretic measures on a chosen data set with the toolkit, and also automatically generate code in Java, Python and Matlab to show how to do this calculation with the toolkit. See description at https://github.com/jlizier/jidt/wiki/AutoAnalyser
-
- b. demos/java -- basic examples on easily using the Java toolkit -- run these from the shell scripts in this directory -- see description at https://github.com/jlizier/jidt/wiki/SimpleJavaExamples
-
- c. Several demo sets mirror the SimpleJavaExamples to demonstrate the use of the toolkit in non-Java environments:
-
- i. demos/octave -- basic examples on easily using the Java toolkit from Octave or Matlab environments -- see description at https://github.com/jlizier/jidt/wiki/OctaveMatlabExamples
-
- ii. demos/python -- basic examples on easily using the Java toolkit from Python -- see description at https://github.com/jlizier/jidt/wiki/PythonExamples
-
- iii. demos/r -- basic examples on easily using the Java toolkit from R -- see description at https://github.com/jlizier/jidt/wiki/R_Examples
-
- iv. demos/julia -- basic examples on easily using the Java toolkit from Julia -- see description at https://github.com/jlizier/jidt/wiki/JuliaExamples
-
- v. demos/clojure -- basic examples on easily using the Java toolkit from Clojure -- see description at https://github.com/jlizier/jidt/wiki/Clojure_Examples
-
- d. demos/octave/CellularAutomata -- using the Java toolkit to plot local information dynamics profiles in cellular automata; the toolkit is run under Octave or Matlab -- see description at https://github.com/jlizier/jidt/wiki/CellularAutomataDemos
-
- e. demos/octave/SchreiberTransferEntropyExamples -- recreates the transfer entropy examples in Schreiber's original paper presenting this measure; shows the correct parameter settings to reproduce these results -- see description at https://github.com/jlizier/jidt/wiki/SchreiberTeDemos
-
- f. demos/octave/DetectingInteractionLags -- demonstration of using the transfer entropy with source-destination lags; the demo is run under Octave or Matlab -- see description at https://github.com/jlizier/jidt/wiki/DetectingInteractionLags
-
- g. demos/java/InterregionalTransfer -- higher level example using collective transfer entropy to infer effective connections between "regions" of data -- see description at https://github.com/jlizier/jidt/wiki/InterregionalTransfer
-
- h. demos/octave/NullDistributions -- investigating the correspondence between analytic and bootstrapped distributions for TE and MI under null hypotheses of no relationship; the demo is run under Octave or Matlab -- see description at https://github.com/jlizier/jidt/wiki/NullDistributions
-
- i. java/unittests -- the JUnit test cases for the Java toolkit are included in the distribution -- these case also be browsed to see simple use cases for the various calculators in the toolkit -- see description at https://github.com/jlizier/jidt/wiki/JUnitTestCases
-
-=============
- Citation
-=============
-
-Please cite your use of this toolkit as:
-
-Joseph T. Lizier, "JIDT: An information-theoretic toolkit for studying the dynamics of complex systems", Frontiers in Robotics and AI 1:11, 2014; doi:10.3389/frobt.2014.00011
-
-A pre-print of this paper is distributed with this toolkit (InfoDynamicsToolkit.pdf) and is available at arXiv:1408.3270 (https://arxiv.org/abs/1408.3270)
-
-=============
- Notices
-=============
-
-This project includes modified files from the Apache Commons Math library -- http://commons.apache.org/proper/commons-math/
-This Apache 2 software is now included as a derivative work in this GPLv3 licensed JIDT project, as per: http://www.apache.org/licenses/GPL-compatibility.html
-Notices and license for this software are found in the notices/commons-math directory.
-
-The project includes adapted code from the JAMA project -- http://math.nist.gov/javanumerics/jama/
-Notices and license for this software are found in the notices/JAMA directory.
-
-The project includes adapted code from the octave-java package of the Octave-Forge project -- http://octave.sourceforge.net/java/
-Notices for this software are found in the notices/JAMA directory.
-
-===============
- Release notes
-===============
-
-v1.6.1 22/8/2023
--------------
-(after 909 commits recorded by github, repository as at https://github.com/jlizier/jidt/tree/90baf68ee7332e15030447b44d262a0fc54773f6 save for this file update)
-Minor updates to supporting use in Python, including virtual environments;
-Minor tweaks to fish schooling examples (mostly comments)
-
-v1.6 5/9/2022
--------------
-(after 889 commits recorded by github, repository as at https://github.com/jlizier/jidt/tree/d750a737bea2a8b1f33b7cd0ad167ec999d907ef save for this file update)
-Adding Flocking/Schooling/Swarming demo;
-Included Pedro's code on IIT and O-/S-Information measures;
-Spiking TE estimator added from David;
-Fixed up AutoAnalyser to work well for Python3 and numpy;
-Links to lecture videos included in the beta wiki for the course;
-Added rudimentary effective network inference (simplified version of the IDTxl full algorithm) in demos/octave/EffectiveNetworkInference;
-
-
-v1.5 26/11/2018
----------------
-(after 753 commits recorded by github, repository as at https://github.com/jlizier/jidt/tree/603445651cc0bf155a42c9ba336141bc7f29bccd save for this file update)
-Added GPU (cuda) capability for KSG Conditional Mutual Information calculator (proper documentation to come), including unit tests and brief wiki page;
-Added auto-embedding for TE/AIS with multivariate KSG, and univariate and multivariate Gaussian estimator (plus unit tests), for Ragwitz criteria and Maximum bias-corrected AIS, and also added Maximum bias corrected AIS and TE to handle source embedding as well;
-Kozachenko entropy estimator adds noise to data by default;
-Added bias-correction property to Gaussian and Kernel estimators for MI and conditional MI, including with surrogates (only option for kernel);
-Enabled use of different bases for different variables in MI discrete estimator;
-All new above features enabled in AutoAnalyser;
-Added drop-down menus for parameters in AutoAnalyser;
-Included long-form lecture slides in course folder;
-
-v1.4 26/11/2017
----------------
-(after 638 commits recorded by github, repository as at https://github.com/jlizier/jidt/tree/589d51674e6a9cfb569432679e515bea17092876 save for this file update)
-Major expansion of functionality for AutoAnalysers: adding Launcher applet and capability to double click jar to launch, added Entropy, CMI, CTE and AIS AutoAnalysers, also added binned estimator type, added all variables/pairs analysis, added statistical significance analysis, and ensured functionality of generated Python code with Python3;
-Added GPU (cuda) capability for KSG Mutual Information calculator (proper documentation and wiki page to come), including unit tests;
-Added fast neighbour search implementations for mixed discrete-continuous KSG MI estimators;
-Expanded Gaussian estimator for multi-information (integration);
-Made all demo/data files readable by Matlab.
-
-
-v1.3.1 21/10/2016
------------------
-(after 385 commits recorded by github, repository as at https://github.com/jlizier/jidt/tree/269e263a84998807c5c02f36397b585a19205938 save for this file update)
-Major update to TransferEntropyCalculatorDiscrete so as to implement arbirtray source and dest embeddings and source-dest delay;
-Conditional TE calculators (continuous) handle empty conditional variables;
-Added auto-embedding method for AIS and TE which maximises bias corrected AIS;
-Added getNumSeparateObservations() method to TE calculators to make reconstructing/separating local values easier after multiple addObservations() calls;
-Fixed kernel estimator classes to return proper densities, not probabilities;
-Bug fix in mixed discrete-continuous MI (Kraskov) implementation;
-Added simple interface for adding joint observations for MultiInfoCalculatorDiscrete
-Including compiled class files for the AutoAnalyser demo in distribution;
-Updated Python demo 1 to show use of numpy arrays with ints;
-Added Python demo 7 and 9 for TE Kraskov with ensemble method and auto-embedding respectively;
-Added Matlab/Octave example 10 for conditional TE via Kraskov (KSG) algorithm;
-Added utilities to prepare for enhancing surrogate calculations with fast nearest neighbour search;
-Minor bug patch to Python readFloatsFile utility;
-
-
-v1.3 10/7/2015 at r691
-----------------------
-Added AutoAnalyser (Code Generator) GUI demo for MI and TE;
-Added auto-embedding capability via Ragwitz criteria for AIS and TE calculators (KSG estimators);
-Added Java demo 9 for showcasing use of Ragwitz auto-embedding;
-Adding small amount of noise to data in all KSG estimators now by default (may be disabled via setProperty());
-Added getProperty() methods for all conditional MI and TE calculators;
-Upgraded Python demos for Python 3 compatibility;
-Fixed bias correction on mixed discrete-continuous KSG calculators;
-Updated the tutorial slides to those in use for ECAL 2015 JIDT tutorial;
-
-v1.2.1 12/2/2015 at r621
-------------------------
-Added tutorial slides, description of exercises and sample exercise solutions;
-Made jar target Java 1.6;
-Added Schreiber TE heart-breath rate with KSG estimator demo code for Python.
-
-v1.2 28/1/2015 at r601
------------------------
-Dynamic correlation exclusion, or Theiler window, added to all Kraskov estimators;
-Added univariate MI calculation to simple demo 6;
-Added Java code for Schreiber TE heart-breath rate with KSG estimator, ready for use as a template in Tutorial;
-Patch for crashes in KSG conditional MI algorithm 2;
-
-v1.1 14/11/2014 at r576
------------------------
-Implemented Fast Nearest Neighbour Search for Kraskov-Stögbauer-Grassberger (KSG) estimators for MI, conditional MI, TE, conditional TE, AIS, Predictive info, and multi-information. This includes a general (multivariate) k-d tree implementation;
-Added multi-threading (using all available processors by default) for the KSG estimators -- code contributed by Ipek Özdemir;
-Added Predictive information / Excess entropy implementations for KSG, kernel and Gaussian estimators;
-Added R, Julia, and Clojure demos;
-Added Windows batch files for the Simple Java Demos;
-Added property for adding a small amount of noise to data in all KSG estimators;
-
-
-v1.0 14/8/2014 at r434
-----------------------
-Added the draft of the paper on the toolkit to the release;
-Javadocs made ready for release;
-Switched source->destination arguments for discrete TE calculators to be with source first in line with continuous calculators;
-Renamed all discrete calculators to have Discrete suffix -- TE and conditional TE calculators also renamed to remove "Apparent" prefix and change "Complete" to "Conditional";
-Kraskov estimators now using 4 nearest neighbours by default;
-Unit test for Gaussian TE against ChaLearn Granger causality measurement;
-Added Schreiber TE demos; Interregional transfer demos; documentation for Interaction lag demos; added examples 7 and 8 to Simple Java demos;
-Added property to add noise to data for Kraskov MI;
-Added derivation of Apache Commons Math code for chi square distribution, and included relevant notices in our release;
-Inserted translation class for arrays between Octave and Java;
-Added analytic statistical significance calculation to Gaussian calculators and discrete TE;
-Corrected Kraskov algorithm 2 for conditional MI to follow equation in Wibral et al. 2014.
-
-
-v0.2.0 20/4/2014 at r284
-------------------------
-Rearchitected (most) Transfer Entropy and Multivariate TE calculators to use an underlying conditional mutual information calculator, and have arbitrary embedding delay, source-dest delay;
-this includes moving Kraskov-Grassberger Transfer Entropy calculator to use a single conditional mutual information estimator instead of two mutual information estimators;
-Rearchitected (most) Active Information Storage calculators to use an underlying mutual information calculator;
-Added Conditional Transfer Entropy calculators using underlying conditional mutual information calculators;
-Moved mixed discrete-continuous calculators to a new "mixed" package;
-bug fixes.
-
-v0.1.4 11/9/2013 at r241
-------------------------
-added scripts to generate CA figures for 2013 book chapters;
-added general Java demo code;
-added Python demo code;
-made Octave/Matlab demos and CA demos properly compatible for Matlab;
-added extra Octave/Matlab general demos;
-added more unit tests for MI and conditional MI calculators, including against results from Wibral's TRENTOOL;
-bug fixes.
-
-v0.1.3 13/1/2013 at r151
-------------------------
-existing Octave/Matlab demo code made compatible with Matlab;
-several bug fixes, including using max norm by default in Kraskov calculator (instead of requiring this to be set explicitly);
-more unit tests (including against results from Kraskov's own MI implementation)
-
-v0.1.2 19/11/2012 at r116
--------------------------
-Includes demo code for two newly submitted papers
-
-v0.1.1 31/10/2012 at r104
-------------------------
-No notes
-
-v0.1 24/10/2012 at r65?
-------------------------
-First distribution
-
-=============
-
-Joseph T. Lizier, 22/08/2023
-
diff --git a/pyspi/lib/jidt/version-1.6.1.txt b/pyspi/lib/jidt/version-1.6.1.txt
deleted file mode 100644
index 90c94501..00000000
--- a/pyspi/lib/jidt/version-1.6.1.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-Java Information Dynamics Toolkit (JIDT)
-Copyright (C) 2012-2014 Joseph T. Lizier
-Copyright (C) 2014-2016 Joseph T. Lizier and Ipek Özdemir
-Copyright (C) 2016-2019 Joseph T. Lizier, Ipek Özdemir and Pedro Mediano
-Copyright (C) 2019-2022 Joseph T. Lizier, Ipek Özdemir, Pedro Mediano, Emanuele Crosato, Sooraj Sekhar and Oscar Huaigu Xu
-Copyright (C) 2022- Joseph T. Lizier, Ipek Özdemir, Pedro Mediano, Emanuele Crosato, Sooraj Sekhar, Oscar Huaigu Xu and David Shorten
-
-Version 1.6.1
-
-22/08/2023
-
diff --git a/pyspi/lib/pairwise_causal.py b/pyspi/lib/pairwise_causal.py
new file mode 100644
index 00000000..1ae030bc
--- /dev/null
+++ b/pyspi/lib/pairwise_causal.py
@@ -0,0 +1,244 @@
+"""Pairwise causal-direction scores, in NumPy/SciPy/scikit-learn.
+
+These replace the four functions pyspi used from the Causal Discovery Toolbox
+(`cdt.causality.pairwise`): the ANM independence score, the conditional
+distribution similarity statistic, the RECI regression-error score, and IGCI.
+Nothing else in cdt was ever used, and cdt eagerly imports its Torch-backed
+models at package load, so a ~2 GB dependency and a multi-second import were
+being paid for four functions that need none of it.
+
+Each function below is a transcription of the corresponding cdt 0.6
+implementation, kept close enough that the numbers are bit-identical on
+continuous data (see `tests/test_pairwise_causal.py`, which checks against
+values frozen from cdt 0.6 before the dependency was dropped). Where cdt's code
+was itself a transcription of the original author's, the original attribution is
+kept below.
+
+Provenance and licence
+----------------------
+Derived from the Causal Discovery Toolbox, Copyright (c) 2018 Diviyan
+Kalainathan, MIT licence (a copy of which is retained at
+`pyspi/lib/LICENSE-cdt.txt`).
+
+Algorithms:
+
+* HSIC with a Gamma approximation -- Gretton, A., Fukumizu, K., Teo, C.,
+ Song, L., Schölkopf, B. & Smola, A. (2007). A kernel statistical test of
+ independence. *NIPS*.
+* ANM -- Hoyer, P., Janzing, D., Mooij, J., Peters, J. & Schölkopf, B. (2009).
+ Nonlinear causal discovery with additive noise models. *NIPS*.
+* CDS -- Fonollosa, J. A. R. (2016). Conditional distribution variability
+ measures for causality detection. (cdt's implementation is Fonollosa's.)
+* RECI -- Blöbaum, P., Janzing, D., Washio, T., Shimizu, S. & Schölkopf, B.
+ (2018). Cause-effect inference by comparing regression errors. *AISTATS*.
+* IGCI -- Daniušis, P., Janzing, D., Mooij, J., Zscheischler, J., Steudel, B.,
+ Zhang, K. & Schölkopf, B. (2010). Inferring deterministic causal relations.
+ *UAI*.
+"""
+
+from collections import Counter
+
+import numpy as np
+import pandas as pd
+from scipy.special import psi
+from sklearn.linear_model import LinearRegression
+from sklearn.metrics import mean_squared_error
+from sklearn.preprocessing import PolynomialFeatures, minmax_scale
+
+__all__ = ["normalized_hsic", "cds_score", "reci_score", "igci_score"]
+
+
+# ---------------------------------------------------------------------------
+# HSIC (drives the additive-noise-model score)
+# ---------------------------------------------------------------------------
+
+def _rbf_dot(X, deg):
+ """Gaussian kernel matrix; bandwidth from the median pairwise distance."""
+ X = np.asarray(X, dtype=float)
+ if X.ndim == 1:
+ X = X[:, np.newaxis]
+ m = X.shape[0]
+ G = np.sum(X * X, axis=1)[:, np.newaxis]
+ Q = np.tile(G, (1, m))
+ H = Q + Q.T - 2.0 * np.dot(X, X.T)
+ if deg == -1:
+ dists = (H - np.tril(H)).flatten()
+ deg = np.sqrt(0.5 * np.median(dists[dists > 0]))
+ return np.exp(-H / 2.0 / (deg ** 2))
+
+
+def _fast_hsic_test_gamma(X, Y, sig=(-1, -1), maxpnt=200):
+ """Biased HSIC statistic, on at most `maxpnt` evenly-spaced samples.
+
+ The subsampling is part of the estimator as pyspi has always used it, not an
+ optimisation: it fixes the statistic's scale, so removing it would change
+ every `anm` value.
+ """
+ X = np.asarray(X)
+ Y = np.asarray(Y)
+ m = X.shape[0]
+ if m > maxpnt:
+ indx = np.floor(np.r_[0:m:float(m - 1) / (maxpnt - 1)]).astype(int)
+ Xm = X[indx].astype(float)
+ Ym = Y[indx].astype(float)
+ m = Xm.shape[0]
+ else:
+ Xm = X.astype(float)
+ Ym = Y.astype(float)
+
+ H = np.eye(m) - 1.0 / m * np.ones((m, m))
+ Kc = np.dot(H, np.dot(_rbf_dot(Xm, sig[0]), H))
+ Lc = np.dot(H, np.dot(_rbf_dot(Ym, sig[1]), H))
+
+ stat = (1.0 / m) * (Kc.T * Lc).sum()
+ return 0 if not np.isfinite(stat) else stat
+
+
+def normalized_hsic(x, y):
+ """HSIC between standardised `x` and `y`. Lower means more independent."""
+ x = (x - np.mean(x)) / np.std(x)
+ y = (y - np.mean(y)) / np.std(y)
+ return _fast_hsic_test_gamma(x, y)
+
+
+# ---------------------------------------------------------------------------
+# CDS -- conditional distribution similarity (Fonollosa 2016)
+# ---------------------------------------------------------------------------
+
+def _count_unique(x):
+ return len(np.unique(x)) if isinstance(x, np.ndarray) else len(set(x))
+
+
+def _discretized_values(x, ffactor, maxdev):
+ if _count_unique(x) > (2 * ffactor * maxdev + 1):
+ return range(-ffactor * maxdev, ffactor * maxdev + 1)
+ return sorted(set(x))
+
+
+def _discretized_sequence(x, ffactor, maxdev, norm=True):
+ if not norm or _count_unique(x) > len(_discretized_values(x, ffactor, maxdev)):
+ if norm:
+ x = (x - np.mean(x)) / np.std(x)
+ xf = x[abs(x) < maxdev]
+ x = (x - np.mean(xf)) / np.std(xf)
+ x = np.round(x * ffactor)
+ vmax = ffactor * maxdev
+ x[x > vmax] = vmax
+ x[x < -vmax] = -vmax
+ return x
+
+
+def cds_score(x_te, y_te, ffactor=2, maxdev=3, minc=12):
+ """Std. of the rescaled y values after binning in x; lower favours x -> y."""
+ if isinstance(x_te, np.ndarray):
+ x_te = pd.Series(np.asarray(x_te).reshape(-1))
+ y_te = pd.Series(np.asarray(y_te).reshape(-1))
+
+ xd = _discretized_sequence(x_te, ffactor, maxdev)
+ yd = _discretized_sequence(y_te, ffactor, maxdev)
+ cx, cy = Counter(xd), Counter(yd)
+ yrange = sorted(cy.keys())
+ ny = len(yrange)
+ py = np.array([cy[i] for i in yrange], dtype=float)
+ py = py / py.sum()
+
+ pyx = []
+ for a in cx:
+ if cx[a] <= minc:
+ continue
+ yx = y_te[xd == a]
+ if _count_unique(y_te) > len(_discretized_values(y_te, ffactor, maxdev)):
+ yx = (yx - np.mean(yx)) / np.std(y_te)
+ yx = _discretized_sequence(yx, ffactor, maxdev, norm=False)
+ cyx = Counter(yx.astype(int))
+ pyxa = np.array(
+ [cyx[i] for i in _discretized_values(y_te, ffactor, maxdev)],
+ dtype=float)
+ else:
+ # Discrete y: align the conditional histogram to the marginal by
+ # the shift that maximises their cross-correlation, so a pure
+ # location shift between bins is not read as a shape difference.
+ cyx = Counter(yx)
+ pyxa = [cyx[i] for i in yrange]
+ padded = np.array([0] * (ny - 1) + pyxa + [0] * (ny - 1), dtype=float)
+ xcorr = [sum(py * padded[i:i + ny]) for i in range(2 * ny - 1)]
+ imax = xcorr.index(max(xcorr))
+ pyxa = np.array([0] * (2 * ny - 2 - imax) + pyxa + [0] * imax,
+ dtype=float)
+ pyx.append(pyxa / pyxa.sum())
+
+ if not pyx:
+ return 0
+ pyx = np.array(pyx)
+ return float(np.std(pyx - pyx.mean(axis=0)))
+
+
+# ---------------------------------------------------------------------------
+# RECI -- regression error causal inference (Bloebaum et al. 2018)
+# ---------------------------------------------------------------------------
+
+def reci_score(x, y, degree=3):
+ """Mean squared error of a monomial fit of y on x, both min-max scaled.
+
+ The first two polynomial columns (the intercept and the linear term) are
+ zeroed, which is cdt's implementation of the paper's monomial regressor: the
+ fit is over x**2..x**degree only.
+ """
+ x = np.reshape(minmax_scale(np.asarray(x, dtype=float).reshape(-1, 1)), (-1, 1))
+ y = np.reshape(minmax_scale(np.asarray(y, dtype=float).reshape(-1, 1)), (-1, 1))
+ poly_x = PolynomialFeatures(degree=degree).fit_transform(x)
+ poly_x[:, 1] = 0
+ poly_x[:, 2] = 0
+ y_predict = LinearRegression().fit(poly_x, y).predict(poly_x)
+ return mean_squared_error(y_predict, y)
+
+
+# ---------------------------------------------------------------------------
+# IGCI -- information-geometric causal inference (Daniusis et al. 2010)
+# ---------------------------------------------------------------------------
+
+def _eval_entropy(x):
+ """Kozachenko-Leonenko 1-D entropy from consecutive order-statistic gaps."""
+ hx = 0.0
+ sx = sorted(x)
+ for i, j in zip(sx[:-1], sx[1:]):
+ delta = j - i
+ if bool(delta):
+ hx += np.log(np.abs(delta))
+ return hx / (len(x) - 1) + psi(len(x)) - psi(1)
+
+
+def _integral_approx_estimator(x, y):
+ a = b = 0.0
+ x = np.asarray(x).ravel()
+ y = np.asarray(y).ravel()
+ idx, idy = np.argsort(x), np.argsort(y)
+ for x1, x2, y1, y2 in zip(x[idx][:-1], x[idx][1:], y[idx][:-1], y[idx][1:]):
+ if x1 != x2 and y1 != y2:
+ a += np.log(np.abs((y2 - y1) / (x2 - x1)))
+ for x1, x2, y1, y2 in zip(x[idy][:-1], x[idy][1:], y[idy][:-1], y[idy][1:]):
+ if x1 != x2 and y1 != y2:
+ b += np.log(np.abs((x2 - x1) / (y2 - y1)))
+ return (a - b) / len(x)
+
+
+def igci_score(x, y, ref_measure="gaussian", estimator="entropy"):
+ """Entropy (or integral-approximation) asymmetry; > 0 favours x -> y."""
+ x = np.asarray(x, dtype=float).reshape(-1)
+ y = np.asarray(y, dtype=float).reshape(-1)
+
+ if ref_measure == "gaussian":
+ scale = lambda v: (v - v.mean()) / v.std()
+ elif ref_measure == "uniform":
+ scale = lambda v: (v - v.min()) / (v.max() - v.min())
+ elif ref_measure == "None":
+ scale = lambda v: v
+ else:
+ raise ValueError(f"Unknown ref_measure {ref_measure!r}.")
+
+ a, b = scale(x), scale(y)
+ if estimator == "entropy":
+ return _eval_entropy(a) - _eval_entropy(b)
+ if estimator == "integral":
+ return _integral_approx_estimator(a, b)
+ raise ValueError(f"Unknown estimator {estimator!r}.")
diff --git a/pyspi/phiconf.yaml b/pyspi/phiconf.yaml
deleted file mode 100644
index 607e4a5a..00000000
--- a/pyspi/phiconf.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-.statistics.infotheory:
- IntegratedInformation:
- labels:
- - undirected
- - nonlinear
- - unsigned
- - bivariate
- - time-dependent
- dependencies:
- configs:
- - phitype: "star"
-
- - phitype: "star"
- normalization: 1
-
- - phitype: "Geo"
-
- - phitype: "Geo"
- normalization: 1
\ No newline at end of file
diff --git a/pyspi/statistics/basic.py b/pyspi/statistics/basic.py
index 0dd5417e..41b73ab6 100644
--- a/pyspi/statistics/basic.py
+++ b/pyspi/statistics/basic.py
@@ -2,8 +2,10 @@
import sklearn.covariance as cov
from scipy import stats, signal
import numpy as np
+import pandas as pd
from pyspi.base import Undirected, Signed, parse_bivariate, parse_multivariate
+from pyspi.utils import require_int
class Estimators(Undirected, Signed):
@@ -14,6 +16,12 @@ class Estimators(Undirected, Signed):
name = "Covariance"
labels = ["basic", "unordered", "linear", "undirected"]
+ _cache_namespace = "covariance"
+
+ @property
+ def _cache_subkey(self):
+ # Cache is data.covariance[estimator]; (kind, squared) are post-lookup.
+ return (self._estimator,)
def __init__(self, kind, estimator="EmpiricalCovariance", squared=False):
paramstr = f"_{estimator}"
@@ -77,6 +85,16 @@ class CrossCorrelation(Undirected, Signed):
name = "Cross correlation"
labels = ["basic", "linear", "undirected", "temporal"]
+ """Sample cross-correlation over lags in [-T//4, +T//4].
+
+ ``sigonly`` is a historical misnomer kept for compatibility. It is a
+ pointwise amplitude threshold -- keep the lags with
+ ``|r(l)| > 1.96/sqrt(T)`` -- and not a significance test: the band is not
+ inflated for the series' own autocorrelation, and it is not corrected for
+ being applied at every lag in the window. When no lag clears it the
+ statistic is 0.
+ """
+
def __init__(self, squared=False, statistic="max", sigonly=True):
self.identifier = "xcorr"
self._squared = squared
@@ -91,44 +109,111 @@ def __init__(self, squared=False, statistic="max", sigonly=True):
self.labels = CrossCorrelation.labels + ["signed"]
self.identifier += f"_{statistic}_sig-{sigonly}"
+ # Lags are examined out to +/- T // 4. Beyond a quarter of the record the
+ # sample cross-correlation is estimated from fewer than 3T/4 overlapping
+ # points and its variance grows without bound under the biased
+ # normalisation used here; the quarter cut is the convention pyspi shipped
+ # and is kept.
+ _MAX_LAG_FRACTION = 4
+
@parse_bivariate
def bivariate(self, data, i=None, j=None):
-
T = data.n_observations
try:
r_ij = data.xcorr[(i, j)]
except (KeyError, AttributeError):
- x, y = data.to_numpy()[[i, j]]
-
- r_ij = np.squeeze(signal.correlate(x, y, "full"))
- r_ij = r_ij / x.std() / y.std() / (T - 1)
-
- # Truncate to T/4
- r_ij = r_ij[T - T // 4 : T + T // 4]
+ x, y = data.to_numpy(squeeze=True)[[i, j]]
+
+ # Demeaned, and normalised by T rather than T-1. Three separate
+ # problems with the previous line
+ # `correlate(x, y) / x.std() / y.std() / (T - 1)`:
+ #
+ # * `signal.correlate` was given the *raw* series while the
+ # divisor used `std()`, which demeans. The two halves of the
+ # ratio therefore described different quantities: on
+ # `arange(10)` against itself with zscore=False it returned
+ # 3.8384 for a correlation.
+ # * The lag-l sum has T - |l| terms, not T - 1. Dividing by T - 1
+ # is neither the biased (T) nor the unbiased (T - |l|)
+ # normalisation, and it put the zero lag of a series with
+ # itself at T/(T-1): exactly 1.1111 for T = 10.
+ # * `std()` is the sample standard deviation (ddof=0 in numpy,
+ # so 1/T) while the divisor was T-1 -- mismatched conventions
+ # in the same expression.
+ #
+ # Biased (divide by T) rather than unbiased (divide by T - |l|):
+ # the result is a *correlation*, so it must stay in [-1, 1], the
+ # zero lag must equal Pearson's r, and the `max` statistic must not
+ # be dominated by the high-variance tail that the unbiased
+ # normalisation produces at large lags. This is the same choice
+ # statsmodels' `ccf(adjusted=False)` and matplotlib's `xcorr` make.
+ x = x - x.mean()
+ y = y - y.mean()
+ scale = T * np.sqrt(np.mean(x ** 2) * np.mean(y ** 2))
+
+ # Force FFT method: O(N log N) vs O(N^2) direct for short signals.
+ r_full = signal.correlate(x, y, "full", method="fft") / scale
+
+ # correlate(x, y, "full")[T - 1 + l] == sum_t x[t + l] * y[t], so
+ # the zero lag sits at T - 1 and the window must be centred there.
+ # `r_full[T - T//4 : T + T//4]` was centred on T, i.e. on lag +1:
+ # the lag window was asymmetric, which makes `max` over it depend
+ # on the order of the pair for a measure declared undirected.
+ lag_max = T // self._MAX_LAG_FRACTION
+ r_ij = r_full[T - 1 - lag_max: T + lag_max]
try:
data.xcorr[(i, j)] = r_ij
except AttributeError:
data.xcorr = {(i, j): r_ij}
- data.xcorr[(j, i)] = data.xcorr[(i, j)]
-
- # Truncate at first statistically significant zero (i.e., |r| <= 1.96/sqrt(T))
- if self._sigonly:
- N = len(r_ij) // 2
- fzf = np.where(np.abs(r_ij[len(r_ij) // 2 :]) <= 1.96 / np.sqrt(N))[0][0]
- fzr = np.where(np.abs(r_ij[: len(r_ij) // 2]) <= 1.96 / np.sqrt(N))[0][-1]
- r_ij = r_ij[N - fzr : N + fzf]
+ # r_yx(l) == r_xy(-l), so the opposite orientation is the reversed
+ # sequence, not the same one. Aliasing the two made `bivariate(j,i)`
+ # return the lag profile of (i,j).
+ data.xcorr[(j, i)] = r_ij[::-1]
+
+ # `sigonly` is a pointwise amplitude threshold, not an inferential
+ # test. The name is historical and kept for compatibility: it keeps
+ # only the lags whose sample cross-correlation exceeds
+ # 1.96/sqrt(T) in magnitude. That cut is the two-sided 5% band for a
+ # *single* correlation between two independent white series, and
+ # neither of the two things that would make it a significance test is
+ # done here -- the band is not inflated for the series'
+ # autocorrelation, and it is
+ # not corrected for having been applied at every one of the ~T/2 lags
+ # in the window. Read it as "drop the small lags", not as "these lags
+ # are significant".
+ if getattr(self, "_sigonly", False):
+ # The previous threshold was `1.96/sqrt(len(r_ij)//2)` -- the
+ # half-width of the lag *window*, T//4 -- so it was twice too wide
+ # and moved with the lag cut rather than with the sample size.
+ threshold = 1.96 / np.sqrt(T)
+ above = np.abs(r_ij) > threshold
+ if not above.any():
+ # Nothing clears the cut, so the thresholded association is
+ # zero. Falling back to the unfiltered window instead reported
+ # the largest of ~T/2 sample correlations under the null: on
+ # two independent length-400 series that is about 0.135 for the
+ # `max` statistic, which is the opposite of what a threshold is
+ # supposed to do at the null.
+ return 0.0
+ # Selecting *the lags above the cut*, rather than the contiguous
+ # run of them around lag zero. The previous code walked outwards
+ # from the centre, which is only right when the peak is at lag 0:
+ # for a pair where i leads j by one sample, r(0) is already below
+ # the cut, so the lobe extended one way and not the other and the
+ # two orientations of an SPI declared *undirected* disagreed --
+ # measured 0.9957 against -0.0202 on a lag-1 pair. A set of lags is
+ # invariant under the l -> -l reversal; a one-sided run is not.
+ # (Its slice was independently wrong: `r_ij[N - fzr : N + fzf]`
+ # mirrored `fzr`, which was already an absolute index.)
+ r_ij = r_ij[above]
+ if self._squared:
+ r_ij = r_ij ** 2
if self._statistic == "max":
- if self._squared:
- return np.max(r_ij**2)
- else:
- return np.max(r_ij)
+ return float(np.max(r_ij))
elif self._statistic == "mean":
- if self._squared:
- return np.mean(r_ij**2)
- else:
- return np.mean(r_ij)
+ return float(np.mean(r_ij))
else:
raise TypeError(f"Unknown statistic: {self._statistic}")
@@ -144,9 +229,9 @@ def __init__(self, squared=False):
if squared:
self.issigned = lambda: False
self.identifier = self.identifier + "-sq"
- self.labels += ["unsigned"]
+ self.labels = self.labels + ["unsigned"]
else:
- self.labels += ["signed"]
+ self.labels = self.labels + ["signed"]
@parse_bivariate
def bivariate(self, data, i=None, j=None):
@@ -156,6 +241,18 @@ def bivariate(self, data, i=None, j=None):
else:
return stats.spearmanr(x, y).correlation
+ @parse_multivariate
+ def multivariate(self, data):
+ """Vectorized: scipy.stats.spearmanr on full (M, T) matrix at once."""
+ Z = data.to_numpy(squeeze=True) # (M, T)
+ rho, _ = stats.spearmanr(Z, axis=1)
+ if Z.shape[0] == 2:
+ rho = np.array([[1.0, rho], [rho, 1.0]])
+ if self._squared:
+ rho = rho ** 2
+ np.fill_diagonal(rho, np.nan)
+ return rho
+
class KendallTau(Undirected, Signed):
@@ -168,9 +265,9 @@ def __init__(self, squared=False):
if squared:
self.issigned = lambda: False
self.identifier = self.identifier + "-sq"
- self.labels += ["unsigned"]
+ self.labels = self.labels + ["unsigned"]
else:
- self.labels += ["signed"]
+ self.labels = self.labels + ["signed"]
@parse_bivariate
def bivariate(self, data, i=None, j=None):
@@ -179,3 +276,137 @@ def bivariate(self, data, i=None, j=None):
return stats.kendalltau(x, y).correlation ** 2
else:
return stats.kendalltau(x, y).correlation
+
+ @parse_multivariate
+ def multivariate(self, data):
+ """Vectorized: pandas .corr(method='kendall') on (T, M) DataFrame."""
+ Z = data.to_numpy(squeeze=True) # (M, T)
+ df = pd.DataFrame(Z.T)
+ tau = np.asarray(df.corr(method="kendall").values, dtype=float).copy()
+ if self._squared:
+ tau = tau ** 2
+ np.fill_diagonal(tau, np.nan)
+ return tau
+
+
+class LaggedCorrelation(Undirected, Signed):
+ """Lagged correlation SPI.
+
+ Computes symmetric lagged correlation: 0.5 * (corr(x[tau:], y[:-tau]) + corr(y[tau:], x[:-tau])).
+ Supports Pearson, Spearman, and Kendall estimators.
+ """
+ name = "Lagged correlation"
+ labels = ["basic", "linear", "undirected", "temporal"]
+
+ def __init__(self, estimator="pearson", tau=None, max_tau=None, squared=False):
+ est = str(estimator).lower()
+ if est not in {"pearson", "spearman", "kendall"}:
+ raise ValueError(f"Unknown estimator: {estimator}")
+ if max_tau is not None:
+ raise ValueError("max_tau is only supported in config expansion; use tau.")
+ if tau is None:
+ raise ValueError("LaggedCorrelation requires tau.")
+
+ self._estimator = est
+ self._squared = bool(squared)
+ if self._squared:
+ self.issigned = lambda: False
+ self.labels = LaggedCorrelation.labels + ["unsigned"]
+ suffix = "-sq"
+ else:
+ self.labels = LaggedCorrelation.labels + ["signed"]
+ suffix = ""
+ self._tau = require_int("tau", tau, minimum=0)
+ self.identifier = f"corr_{est}_tau-{self._tau}{suffix}"
+
+ def _corr(self, x, y):
+ x = np.asarray(x).reshape(-1)
+ y = np.asarray(y).reshape(-1)
+ if x.size < 2 or y.size < 2:
+ return np.nan
+ if self._estimator == "pearson":
+ return stats.pearsonr(x, y).correlation
+ if self._estimator == "spearman":
+ return stats.spearmanr(x, y).correlation
+ if self._estimator == "kendall":
+ return stats.kendalltau(x, y).correlation
+ raise ValueError(f"Unknown estimator: {self._estimator}")
+
+ def _lagged_corr(self, x, y, tau):
+ if tau == 0:
+ return self._corr(x, y)
+ if tau >= x.size:
+ return np.nan
+ return self._corr(x[tau:], y[:-tau])
+
+ def _symmetric_lagged_corr(self, x, y, tau):
+ forward = self._lagged_corr(x, y, tau)
+ backward = self._lagged_corr(y, x, tau)
+ if np.isnan(forward):
+ return backward
+ if np.isnan(backward):
+ return forward
+ return 0.5 * (forward + backward)
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ x, y = data.to_numpy()[[i, j]]
+ value = self._symmetric_lagged_corr(x, y, self._tau)
+ return value**2 if self._squared else value
+
+ @parse_multivariate
+ def multivariate(self, data):
+ """Vectorized multivariate lagged correlation."""
+ Z = data.to_numpy(squeeze=True) # (M, T)
+ M, T = Z.shape
+ tau = self._tau
+
+ if tau == 0 or tau >= T:
+ if tau >= T:
+ return np.full((M, M), np.nan)
+ if self._estimator == "pearson":
+ C = np.corrcoef(Z)
+ elif self._estimator == "spearman":
+ C, _ = stats.spearmanr(Z, axis=1)
+ if M == 2:
+ C = np.array([[1.0, C], [C, 1.0]])
+ elif self._estimator == "kendall":
+ C = np.asarray(
+ pd.DataFrame(Z.T).corr(method="kendall").values, dtype=float
+ ).copy()
+ else:
+ raise ValueError(f"Unknown estimator: {self._estimator}")
+ if self._squared:
+ C = C ** 2
+ np.fill_diagonal(C, np.nan)
+ return C
+
+ Z_lead = Z[:, tau:]
+ Z_lag = Z[:, :-tau]
+
+ if self._estimator == "pearson":
+ stacked = np.vstack([Z_lead, Z_lag])
+ C_full = np.corrcoef(stacked)
+ forward = C_full[:M, M:]
+ backward = C_full[M:, :M]
+ elif self._estimator == "spearman":
+ stacked = np.vstack([Z_lead, Z_lag])
+ rho, _ = stats.spearmanr(stacked, axis=1)
+ if stacked.shape[0] == 2:
+ rho = np.array([[1.0, rho], [rho, 1.0]])
+ forward = rho[:M, M:]
+ backward = rho[M:, :M]
+ elif self._estimator == "kendall":
+ stacked = np.vstack([Z_lead, Z_lag])
+ df = pd.DataFrame(stacked.T)
+ C_full = df.corr(method="kendall").values
+ forward = C_full[:M, M:]
+ backward = C_full[M:, :M]
+ else:
+ raise ValueError(f"Unknown estimator: {self._estimator}")
+
+ C = 0.5 * (forward + backward)
+ if self._squared:
+ C = C ** 2
+ np.fill_diagonal(C, np.nan)
+ return C
diff --git a/pyspi/statistics/causal.py b/pyspi/statistics/causal.py
index 714a118f..4b68edf7 100644
--- a/pyspi/statistics/causal.py
+++ b/pyspi/statistics/causal.py
@@ -1,9 +1,14 @@
+import warnings
+
import numpy as np
import pandas as pd
-from cdt.causality.pairwise import ANM, CDS, IGCI, RECI
import pyEDM
from sklearn.gaussian_process import GaussianProcessRegressor
-from cdt.causality.pairwise.ANM import normalized_hsic
+
+from pyspi.lib.pairwise_causal import (
+ cds_score, igci_score, normalized_hsic, reci_score,
+)
+from pyspi.utils import require_int
from pyspi.base import Directed, Unsigned, Signed, parse_bivariate, parse_multivariate
@@ -12,21 +17,23 @@ class AdditiveNoiseModel(Directed, Unsigned):
name = "Additive noise model"
identifier = "anm"
- labels = ["unsigned", "causal", "unordered", "linear", "directed"]
-
- # monkey-patch the anm_score function, see cdt PR #155
- def corrected_anm_score(self, x, y):
- gp = GaussianProcessRegressor(random_state=42).fit(x, y)
- y_predict = gp.predict(x).reshape(-1, 1)
- indepscore = normalized_hsic(y_predict - y, x)
- return indepscore
-
- ANM.anm_score = corrected_anm_score
+ # Not `linear`: the fit is a Gaussian-process regression and the
+ # independence test an RBF-kernel HSIC. Nothing about it is linear.
+ labels = ["unsigned", "causal", "unordered", "nonlinear", "directed"]
@parse_bivariate
def bivariate(self, data, i=None, j=None):
+ """HSIC between x and the residual of a GP fit of y on x.
+
+ This was already pyspi's own scoring function, monkey-patched over
+ cdt's (which regressed the wrong way round -- cdt PR #155); only the
+ HSIC came from cdt, and it is now `pyspi.lib.pairwise_causal`.
+ """
z = data.to_numpy()
- return ANM().anm_score(z[i], z[j])
+ x, y = z[i], z[j]
+ gp = GaussianProcessRegressor(random_state=42).fit(x, y)
+ y_predict = gp.predict(x).reshape(-1, 1)
+ return normalized_hsic(y_predict - y, x)
class ConditionalDistributionSimilarity(Directed, Unsigned):
@@ -38,7 +45,7 @@ class ConditionalDistributionSimilarity(Directed, Unsigned):
@parse_bivariate
def bivariate(self, data, i=None, j=None):
z = data.to_numpy()
- return CDS().cds_score(z[i], z[j])
+ return cds_score(z[i], z[j])
class RegressionErrorCausalInference(Directed, Unsigned):
@@ -50,19 +57,92 @@ class RegressionErrorCausalInference(Directed, Unsigned):
@parse_bivariate
def bivariate(self, data, i=None, j=None):
z = data.to_numpy()
- return RECI().b_fit_score(z[i], z[j])
+ return reci_score(z[i], z[j])
+
+class InformationGeometricCausalInference(Directed, Signed):
+ """IGCI: Information-Geometric Causal Inference (Daniusis et al. 2010).
-class InformationGeometricConditionalIndependence(Directed, Unsigned):
+ Not "conditional independence" -- IGCI tests neither. It compares the two
+ directions' entropies under a reference measure and leverages the asymmetry
+ of a deterministic invertible map.
- name = "Information-geometric conditional independence"
+ The score is a *difference* of two entropies, hence exactly antisymmetric:
+ `A[i, j] == -A[j, i]`, verified on the fixtures. Reporting it as unsigned
+ was not cosmetic -- `Calculator._rmmin` shifts every column it believes
+ unsigned by that column's minimum, which on an antisymmetric matrix moves
+ `A[i,j]` and `A[j,i]` by the same amount and destroys the lead/lag the sign
+ carries, and `set_group` folds the two directions together through `abs()`.
+
+ It stays commented out of the bundled configs; this is a metadata and API
+ correction, not a claim that the heuristic is reliable.
+ """
+
+ name = "Information-geometric causal inference"
identifier = "igci"
- labels = ["causal", "directed", "nonlinear", "unsigned", "unordered"]
+ labels = ["causal", "antisymmetric", "signed", "nonlinear", "unordered"]
@parse_bivariate
def bivariate(self, data, i=None, j=None):
z = data.to_numpy()
- return IGCI().predict_proba((z[i], z[j]))
+ return igci_score(z[i], z[j])
+
+
+class InformationGeometricConditionalIndependence(InformationGeometricCausalInference):
+ """Deprecated alias for :class:`InformationGeometricCausalInference`.
+
+ The old name misdescribed the method. Kept so existing configs and scripts
+ keep working; the identifier (`igci`) and the computation are unchanged.
+ """
+
+ def __init__(self, *args, **kwargs):
+ warnings.warn(
+ "InformationGeometricConditionalIndependence is deprecated and "
+ "will be removed in a future release: IGCI is Information-"
+ "Geometric Causal *Inference*, and tests no conditional "
+ "independence. Use InformationGeometricCausalInference; the "
+ "identifier and the computed values are identical.",
+ DeprecationWarning, stacklevel=2,
+ )
+ super().__init__(*args, **kwargs)
+
+
+def _optimal_embedding_dimension(df, column, lib_pred, max_e=10):
+ """E in [1, max_e] maximising simplex-projection skill, computed serially.
+
+ Replaces ``pyEDM.EmbedDimension``, for two independent reasons.
+
+ **It was not selecting anything.** The call site read the winner as
+ ``embed_df.max()["E"]``. ``DataFrame.max()`` reduces column-wise, so that is
+ the largest *candidate* E, not the E at the largest rho -- ``max_e``, every
+ time, for every process, whatever the data says. The three shipped
+ ``ccm_E-None_*`` SPIs were therefore bit-identical to ``ccm_E-10_*`` on all
+ three frozen fixtures (verified: max|difference| exactly 0), while their
+ identifiers advertised an inferred embedding. ``rho.idxmax()`` is the
+ selection that was intended.
+
+ **It cannot be called safely.** ``EmbedDimension`` always builds a
+ ``multiprocessing.Pool``; there is no serial path, and ``numProcess=1``
+ still starts a child. pyEDM 2.5 starts pools with forkserver/spawn, never
+ fork, so each child re-imports the caller's ``__main__`` -- see the note in
+ ``ConvergentCrossMapping._from_cache``. Looping over ``pyEDM.Simplex``
+ (public API, and exactly what ``PoolFunc.EmbedDimSimplexFunc`` calls in each
+ child) gives the same rho per E with no pool at all.
+
+ Ties go to the smallest E: a lower-dimensional embedding that predicts as
+ well is the better model, and ``argmax`` returns the first maximum. Ties are
+ not rare -- ``pyEDM.ComputeError`` rounds rho to 6 digits.
+ """
+ rho = np.empty(max_e)
+ for k, E in enumerate(range(1, max_e + 1)):
+ pred_df = pyEDM.Simplex(
+ dataFrame=df, columns=column, target=column,
+ lib=lib_pred, pred=lib_pred, E=E,
+ )
+ rho[k] = pyEDM.ComputeError(
+ pred_df["Observations"], pred_df["Predictions"]
+ )["rho"]
+ return int(np.nanargmax(rho) + 1)
class ConvergentCrossMapping(Directed, Signed):
@@ -70,12 +150,30 @@ class ConvergentCrossMapping(Directed, Signed):
name = "Convergent cross-mapping"
identifier = "ccm"
labels = ["causal", "directed", "nonlinear", "temporal", "signed"]
+ # Variants (mean/max/diff x embedding dimension) share data.ccm[self.key],
+ # keyed by embedding dimension — bucket them onto one parallel worker.
+ _cache_namespace = "ccm"
+
+ @property
+ def _cache_subkey(self):
+ # Cache is data.ccm[E]; statistic is post-lookup.
+ return (self._E,)
def __init__(self, statistic="mean", embedding_dimension=None):
self._statistic = statistic
- self._E = embedding_dimension
+ self._E = (
+ None if embedding_dimension is None
+ else require_int("embedding_dimension", embedding_dimension, minimum=1)
+ )
- self.identifier += f"_E-{embedding_dimension}_{statistic}"
+ # The "diff" statistic is ccm(i->j) - ccm(j->i), so A[i,j] == -A[j,i]
+ # by construction. That is antisymmetric, not merely directed: the two
+ # orientations are one quantity and its negation, not independent
+ # values. "mean"/"max" remain plainly directed.
+ if statistic == "diff":
+ self.labels = [l for l in self.labels if l != "directed"] + ["antisymmetric"]
+
+ self.identifier += f"_E-{self._E}_{statistic}"
@property
def key(self):
@@ -85,6 +183,29 @@ def _from_cache(self, data):
try:
ccmf = data.ccm[self.key]
except (AttributeError, KeyError):
+ # pyEDM 2.5 self-parallelises (EmbedDimension over processes, CCM
+ # over samples), and its pools are single-process here on purpose.
+ #
+ # pyEDM's own `_get_mp_context` documents "**fork is never used**":
+ # it takes forkserver, else spawn. Both re-import the caller's
+ # `__main__` in every child. pyspi is normally driven from a plain
+ # script, and an unguarded script re-executed by a child raises
+ # `RuntimeError: An attempt has been made to start a new process
+ # before the current process has finished its bootstrapping phase`
+ # -- which pyspi catches, so all nine `ccm_*` SPIs come back as an
+ # all-NaN column, with the child having already re-run whatever ran
+ # before `compute()`. It only looks fine from a REPL, a notebook,
+ # or a `if __name__ == "__main__":`-guarded script (which is why
+ # the baseline generator and `python -m pyspi` never saw it).
+ # pyspi cannot know whether its caller is import-safe, so it does
+ # not gamble on it.
+ #
+ # There is no speed argument on the other side either: at pyspi's
+ # sizes the pool costs far more than it saves. Measured on an idle
+ # machine, kuramoto_M7_T100, 21 pairs at E=1: 24.8s with
+ # `parallel=True` against 6.8s with `parallel=False`, a 3.6x
+ # *speedup* from turning it off. Parallelism belongs at the SPI
+ # level, where `Calculator.compute(n_jobs=...)` already provides it.
z = data.to_numpy(squeeze=True)
M = data.n_processes
@@ -101,14 +222,8 @@ def _from_cache(self, data):
# Infer optimal embedding from simplex projection
for _i in range(M):
pred = str(10) + " " + str(N - 10)
- embed_df = pyEDM.EmbedDimension(
- dataFrame=df,
- lib=pred,
- pred=pred,
- columns=df.columns.values[_i + 1],
- showPlot=False,
- )
- embedding[_i] = embed_df.max()["E"]
+ col = df.columns.values[_i + 1]
+ embedding[_i] = _optimal_embedding_dimension(df, col, pred)
else:
embedding = np.array([self._E] * M)
@@ -118,7 +233,7 @@ def _from_cache(self, data):
for _i in range(M):
for _j in range(_i + 1, M):
try:
- E = int(max(embedding[[_i, _j]]))
+ E = int(np.max(embedding[[_i, _j]]))
except NameError:
E = int(self._E)
@@ -137,6 +252,7 @@ def _from_cache(self, data):
libSizes=lib_sizes,
sample=100,
seed=42,
+ parallel=False,
)
ccmf[_i, _j] = ccm_df.iloc[:, 1].values[: (nlibs + 1)]
ccmf[_j, _i] = ccm_df.iloc[:, 2].values[: (nlibs + 1)]
diff --git a/pyspi/statistics/distance.py b/pyspi/statistics/distance.py
index d3904815..7900d4d7 100644
--- a/pyspi/statistics/distance.py
+++ b/pyspi/statistics/distance.py
@@ -1,3 +1,5 @@
+import os
+import warnings
import numpy as np
from sklearn.metrics import pairwise_distances
import tslearn.metrics
@@ -23,21 +25,61 @@
parse_bivariate,
parse_multivariate,
)
+from pyspi.utils import fmt_param, require_int, require_positive_float
+
+
+# ---------------------------------------------------------------------------
+# DTW Sakoe-Chiba auto-radius configuration (env-var tunable)
+# ---------------------------------------------------------------------------
+_DTW_SAKOE_LINEAR_FRAC = float(os.getenv("PYSPI_DTW_SAKOE_LINEAR_FRAC", "0.10"))
+_DTW_SAKOE_SQRT_COEFF = float(os.getenv("PYSPI_DTW_SAKOE_SQRT_COEFF", "1.5"))
+_DTW_SAKOE_MIN_RADIUS = int(os.getenv("PYSPI_DTW_SAKOE_MIN_RADIUS", "10"))
+
+
+def _auto_sakoe_radius(length):
+ if length <= 1:
+ return 1
+ linear = int(np.ceil(max(0.0, _DTW_SAKOE_LINEAR_FRAC) * length))
+ sqrt_scaled = int(np.ceil(max(0.0, _DTW_SAKOE_SQRT_COEFF) * np.sqrt(length)))
+ radius = min(linear, sqrt_scaled)
+ radius = max(1, _DTW_SAKOE_MIN_RADIUS, radius)
+ return min(radius, length - 1)
class PairwiseDistance(Undirected, Unsigned):
+ """``metric`` distance between each pair of processes, optionally / sqrt(T).
+
+ ``normalise=True`` divides by ``sqrt(T)`` and adds ``_rmse`` to the
+ identifier. For ``metric="euclidean"`` that is exactly the root mean square
+ difference, ``sqrt(mean((x - y)**2))``, and the name is literal. For the
+ other metrics -- cityblock, cosine, chebyshev, canberra, braycurtis -- the
+ suffix is pyspi's house label for "divided by sqrt(T)" and nothing more.
+ Those quantities are not root *mean* squares, and dividing them by sqrt(T)
+ does **not** make them comparable across record lengths either -- only the
+ Euclidean norm grows as sqrt(T) under i.i.d. differences, so only for
+ `euclidean` is the division the right power. Read `_rmse` as the name of the
+ normalisation that was applied, not as a claim about the metric.
+ """
name = "Pairwise distance"
identifier = "pdist"
labels = ["unsigned", "distance", "unordered", "nonlinear", "undirected"]
- def __init__(self, metric="euclidean", **kwargs):
+ def __init__(self, metric="euclidean", normalise=False, **kwargs):
self._metric = metric
+ self._normalise = normalise
self.identifier += f"_{metric}"
+ if normalise:
+ self.identifier += "_rmse"
@parse_multivariate
def multivariate(self, data):
- return pairwise_distances(data.to_numpy(squeeze=True), metric=self._metric)
+ Z = data.to_numpy(squeeze=True)
+ D = pairwise_distances(Z, metric=self._metric)
+ if self._normalise:
+ T = Z.shape[1]
+ D = D / np.sqrt(T)
+ return D
""" TODO: include optional kernels in each method
@@ -63,12 +105,13 @@ def bivariate(self, data, i=None, j=None):
return stat
-class HellerHellerGorfine(Directed, Unsigned):
+class HellerHellerGorfine(Undirected, Unsigned):
"""Heller-Heller-Gorfine independence criterion"""
name = "Heller-Heller-Gorfine Independence Criterion"
identifier = "hhg"
- labels = ["unsigned", "distance", "unordered", "nonlinear", "directed"]
+ # Symmetric by construction: hhg(x, y) == hhg(y, x) exactly.
+ labels = ["unsigned", "distance", "unordered", "nonlinear", "undirected"]
@parse_bivariate
def bivariate(self, data, i=None, j=None):
@@ -178,13 +221,178 @@ def bivariate(self, data, i=None, j=None):
class DynamicTimeWarping(TimeWarping):
+ """DTW with optional dtaidistance C backend and Sakoe-Chiba band constraint.
+
+ Falls back to tslearn if dtaidistance is not available or for itakura constraint.
+ """
name = "Dynamic time warping"
identifier = "dtw"
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
+ def __init__(
+ self,
+ global_constraint=None,
+ sakoe_chiba_radius=None,
+ sakoe_chiba_ratio=None,
+ normalise=False,
+ **kwargs,
+ ):
+ if sakoe_chiba_radius is not None and sakoe_chiba_ratio is not None:
+ raise ValueError("Set only one of sakoe_chiba_radius or sakoe_chiba_ratio.")
+ # Validated, not coerced: `int(2.7)` silently becomes a band of 2 and
+ # `float(True)` a ratio of 1.0, both of which look like the caller's
+ # intent and are not.
+ if sakoe_chiba_radius is not None:
+ sakoe_chiba_radius = require_int("sakoe_chiba_radius",
+ sakoe_chiba_radius, minimum=1)
+ if sakoe_chiba_ratio is not None:
+ sakoe_chiba_ratio = require_positive_float("sakoe_chiba_ratio",
+ sakoe_chiba_ratio)
+
+ super().__init__(global_constraint=global_constraint, **kwargs)
self._simfn = tslearn.metrics.dtw
+ self._sakoe_chiba_radius = sakoe_chiba_radius
+ self._sakoe_chiba_ratio = sakoe_chiba_ratio
+ self._normalise = normalise
+ self._warned_itakura_fallback = False
+ self._warned_c_fallback = False
+
+ if global_constraint == "sakoe_chiba":
+ if sakoe_chiba_radius is not None:
+ self.identifier += f"_radius-{sakoe_chiba_radius}"
+ elif sakoe_chiba_ratio is not None:
+ self.identifier += f"_ratio-{fmt_param(sakoe_chiba_ratio)}"
+ else:
+ self.identifier += "_radius-auto"
+ if normalise:
+ self.identifier += "_rmse"
+
+ def _resolve_radius(self, n):
+ if self._sakoe_chiba_radius is not None:
+ return min(self._sakoe_chiba_radius, max(1, n - 1))
+ if self._sakoe_chiba_ratio is not None:
+ ratio_radius = int(np.ceil(self._sakoe_chiba_ratio * n))
+ return min(max(1, ratio_radius), max(1, n - 1))
+ return _auto_sakoe_radius(n)
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ z = data.to_numpy(squeeze=True)
+ x = np.ascontiguousarray(z[i], dtype=np.double)
+ y = np.ascontiguousarray(z[j], dtype=np.double)
+ constraint = self._global_constraint
+ n = min(len(x), len(y))
+
+ if constraint == "itakura":
+ if not self._warned_itakura_fallback:
+ warnings.warn(
+ "DynamicTimeWarping(itakura) falls back to tslearn; "
+ "dtaidistance C backend does not support itakura.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ self._warned_itakura_fallback = True
+ d = tslearn.metrics.dtw(x, y, global_constraint="itakura")
+ return d / np.sqrt(n) if self._normalise else d
+
+ radius = None
+ window = None
+ if constraint == "sakoe_chiba":
+ radius = self._resolve_radius(n)
+ window = radius + 1
+
+ try:
+ from dtaidistance import dtw as _dtw_c
+ from dtaidistance.exceptions import CythonException
+ kwargs = {"use_c": True}
+ if window is not None:
+ kwargs["window"] = window
+ d = _dtw_c.distance(x, y, **kwargs)
+ except ImportError:
+ # dtaidistance not installed — use tslearn
+ if constraint == "sakoe_chiba":
+ d = tslearn.metrics.dtw(
+ x, y, global_constraint="sakoe_chiba", sakoe_chiba_radius=radius,
+ )
+ else:
+ d = tslearn.metrics.dtw(x, y, global_constraint=constraint)
+ except (CythonException, ValueError):
+ if not self._warned_c_fallback:
+ warnings.warn(
+ "dtaidistance C backend unavailable for DynamicTimeWarping; "
+ "falling back to tslearn.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ self._warned_c_fallback = True
+ if constraint == "sakoe_chiba":
+ d = tslearn.metrics.dtw(
+ x, y, global_constraint="sakoe_chiba", sakoe_chiba_radius=radius,
+ )
+ else:
+ d = tslearn.metrics.dtw(x, y)
+ return d / np.sqrt(n) if self._normalise else d
+
+ @parse_multivariate
+ def multivariate(self, data):
+ """Batch DTW via dtaidistance.dtw.distance_matrix when available."""
+ Z = data.to_numpy(squeeze=True) # (M, T)
+ M = Z.shape[0]
+ series = [np.ascontiguousarray(Z[i], dtype=np.double) for i in range(M)]
+
+ constraint = self._global_constraint
+
+ if constraint == "itakura":
+ # dtaidistance doesn't support itakura; fall back to bivariate loop
+ A = np.full((M, M), np.nan)
+ for i in range(M):
+ for j in range(i + 1, M):
+ d = tslearn.metrics.dtw(
+ series[i], series[j], global_constraint="itakura"
+ )
+ A[i, j] = d
+ A[j, i] = d
+ # The bivariate path and the dtaidistance path both divide by
+ # sqrt(T) under `normalise`; this branch did not, so
+ # `bivariate(data, i, j)` and `multivariate(data)[i, j]` differed by
+ # a factor of sqrt(T) for the itakura constraint alone.
+ if self._normalise:
+ A = A / np.sqrt(Z.shape[1])
+ return A
+
+ try:
+ from dtaidistance import dtw as _dtw_c
+
+ kwargs = {"use_c": True, "compact": False}
+ if constraint == "sakoe_chiba":
+ n = min(len(s) for s in series)
+ radius = self._resolve_radius(n)
+ kwargs["window"] = radius + 1
+
+ try:
+ dm = _dtw_c.distance_matrix(series, **kwargs)
+ except Exception:
+ kwargs["use_c"] = False
+ dm = _dtw_c.distance_matrix(series, **kwargs)
+
+ dm = np.array(dm)
+ mask_upper = np.triu(np.ones((M, M), dtype=bool), k=1)
+ dm_sym = np.where(mask_upper, dm, dm.T)
+ np.fill_diagonal(dm_sym, np.nan)
+ if self._normalise:
+ T = Z.shape[1]
+ dm_sym = dm_sym / np.sqrt(T)
+ return dm_sym
+
+ except ImportError:
+ # Fall back to bivariate loop with tslearn
+ A = np.full((M, M), np.nan)
+ for i in range(M):
+ for j in range(i + 1, M):
+ d = self.bivariate(data, i=i, j=j)
+ A[i, j] = d
+ A[j, i] = d
+ return A
class LongestCommonSubsequence(TimeWarping):
@@ -213,6 +421,14 @@ class Barycenter(Directed, Signed):
name = "Barycenter"
identifier = "bary"
labels = ["distance", "signed", "undirected", "temporal", "nonlinear"]
+ _cache_namespace = "barycenter"
+
+ @property
+ def _cache_subkey(self):
+ # Actual cache is keyed (mode, pair); statistic/squared are post-lookup
+ # transforms. Sub-bucket amortization by mode so 4 separate caches don't
+ # get lumped into one and mis-cost the cheap modes.
+ return (self._mode,)
def __init__(self, mode="euclidean", squared=False, statistic="mean"):
if mode == "euclidean":
@@ -275,11 +491,11 @@ def vec_geo_dist(x):
diffs = np.diff(x, axis=0)
distances = np.linalg.norm(diffs, axis=1)
return np.cumsum(distances)
-
+
@staticmethod
def wass_sorted(x1, x2):
x1 = np.sort(x1)[::-1] # sort in descending order
- x2 = np.sort(x2)[::-1]
+ x2 = np.sort(x2)[::-1]
if len(x1) == len(x2):
res = np.sqrt(np.mean((x1 - x2) ** 2))
@@ -287,20 +503,20 @@ def wass_sorted(x1, x2):
N, M = len(x1), len(x2)
i_ratios = np.arange(1, N + 1) / N
j_ratios = np.arange(1, M + 1) / M
-
-
+
+
min_values = np.minimum.outer(i_ratios, j_ratios)
max_values = np.maximum.outer(i_ratios - 1/N, j_ratios - 1/M)
-
+
lam = np.where(min_values > max_values, min_values - max_values, 0)
-
+
diffs_squared = (x1[:, None] - x2) ** 2
my_sum = np.sum(lam * diffs_squared)
-
+
res = np.sqrt(my_sum)
return res
-
+
@staticmethod
def gwtau(xi, xj):
timei = np.arange(len(xi))
@@ -311,12 +527,117 @@ def gwtau(xi, xj):
vi = GromovWasserstainTau.vec_geo_dist(traji)
vj = GromovWasserstainTau.vec_geo_dist(trajj)
gw = GromovWasserstainTau.wass_sorted(vi, vj)
-
+
return gw
@parse_bivariate
def bivariate(self, data, i=None, j=None):
x, y = data.to_numpy()[[i, j]]
- # insert compute SPI code here (computes on x and y)
stat = self.gwtau(x, y)
return stat
+
+
+# ---------------------------------------------------------------------------
+# CrossPairwiseDistance: lagged Euclidean distance
+# ---------------------------------------------------------------------------
+
+class CrossPairwiseDistance(Undirected, Unsigned):
+ """Root-T-normalised cost of a lag-shifted alignment path, over lags 0..tau.
+
+ At each lag ``t`` the two series are paired with an offset of ``t``, the
+ ``t`` samples that fall off each end are stuttered against the opposite
+ series' boundary sample, and the cost is
+ ``sqrt(sum(differences**2) / T)``. The forward and backward offsets are
+ minimised over, so the result is symmetric in the pair; ``min`` or ``mean``
+ then reduces over ``t``.
+
+ The ``_rmse`` in the identifier is the same house label
+ `PairwiseDistance` uses: divided by ``sqrt(T)``. It is **not** the
+ conventional DTW "path-length RMSE", which divides by the number of steps
+ in the path -- here the divisor is the record length, and the boundary
+ stutter means the path has ``T + t`` steps rather than ``T``. The point of
+ the stutter is that the alignment is a *valid* DTW path (monotone,
+ continuous, matched endpoints), and DTW minimises over all such paths, so
+ ``dtw_rmse <= xpdist`` holds by construction rather than empirically.
+
+ At ``tau=0`` the path is the identity alignment and the value is exactly
+ ``pdist_euclidean`` with ``normalise=True``.
+ """
+ name = "Cross pairwise distance"
+ labels = ["distance", "nonlinear", "undirected", "temporal"]
+
+ def __init__(self, metric="euclidean", tau=1, statistic="min"):
+ if metric != "euclidean":
+ raise ValueError(f"Unsupported metric: {metric!r}. Only 'euclidean' supported.")
+ # `int(tau) < 0` accepted `tau=1.7` (truncated to 1), `tau=True`
+ # (silently 1) and raised an opaque conversion error on nan/inf. tau
+ # counts samples, so it must be an integer.
+ tau = require_int("tau", tau, minimum=0)
+ stat = str(statistic).lower()
+ if stat not in {"min", "mean"}:
+ raise ValueError(f"statistic must be 'min' or 'mean', got: {statistic!r}")
+ self._metric = metric
+ self._tau = tau
+ self._statistic = stat
+ # `_rmse` is the house label for the sqrt(T) normalisation, shared with
+ # PairwiseDistance and DynamicTimeWarping(normalise=True). See the class
+ # docstring for what it does and does not claim.
+ self.identifier = f"xpdist_{metric}_tau-{self._tau}_{stat}_rmse"
+
+ @staticmethod
+ def _shifted_dist_rmse(x, y, s):
+ # Realises one specific DTW path: pair x[k+s] with y[k] over the overlap,
+ # then stutter the dropped boundary samples against the corner of the
+ # opposite series. Since the result is the cost of a valid DTW path and
+ # DTW minimises over all paths, the resulting xpdist satisfies
+ # dtw_rmse <= xpdist by construction. Normalise by sqrt(T) to match
+ # DynamicTimeWarping(normalise=True).
+ T = len(x)
+ if s == 0:
+ diff = x - y
+ elif s > 0:
+ left = x[:s] - y[0]
+ mid = x[s:] - y[: T - s]
+ right = x[T - 1] - y[T - s :]
+ diff = np.concatenate([left, mid, right])
+ else:
+ sa = -s
+ left = y[:sa] - x[0]
+ mid = y[sa:] - x[: T - sa]
+ right = y[T - 1] - x[T - sa :]
+ diff = np.concatenate([left, mid, right])
+ return float(np.sqrt(np.sum(diff ** 2) / T))
+
+ def _cross_dist(self, x, y):
+ tau = self._tau
+ T = len(x)
+ per_lag = np.empty(tau + 1)
+ per_lag[0] = self._shifted_dist_rmse(x, y, 0)
+ for t in range(1, tau + 1):
+ if t >= T:
+ per_lag[t] = np.inf
+ continue
+ fwd = self._shifted_dist_rmse(x, y, +t)
+ bwd = self._shifted_dist_rmse(x, y, -t)
+ per_lag[t] = min(fwd, bwd)
+ if self._statistic == "min":
+ return float(np.min(per_lag))
+ finite = per_lag[np.isfinite(per_lag)]
+ return float(np.mean(finite)) if finite.size > 0 else np.nan
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ Z = data.to_numpy(squeeze=True)
+ return self._cross_dist(Z[i], Z[j])
+
+ @parse_multivariate
+ def multivariate(self, data):
+ Z = data.to_numpy(squeeze=True) # (M, T)
+ M = Z.shape[0]
+ A = np.full((M, M), np.nan)
+ for i in range(M):
+ for j in range(i + 1, M):
+ d = self._cross_dist(Z[i], Z[j])
+ A[i, j] = d
+ A[j, i] = d
+ return A
diff --git a/pyspi/statistics/infotheory.py b/pyspi/statistics/infotheory.py
index d4084587..dbfe1171 100644
--- a/pyspi/statistics/infotheory.py
+++ b/pyspi/statistics/infotheory.py
@@ -1,46 +1,1233 @@
-import jpype as jp
+"""Information-theoretic statistics, implemented in pure NumPy.
+
+The estimators here were ported from JIDT (Lizier, 2014, "JIDT: An
+information-theoretic toolkit for studying the dynamics of complex systems",
+Frontiers in Robotics and AI), which served as the reference implementation the
+port was validated against; the version used for validation was JIDT 1.6.1.
+"""
+
+import math
import numpy as np
from pyspi import utils
import copy
import logging
+import warnings
+
+from scipy.spatial import cKDTree
+from scipy.special import digamma, gammaln
+
+from pyspi.base import Undirected, Directed, Unsigned, parse_univariate, parse_bivariate, parse_multivariate
+
+# ---------------------------------------------------------------------------
+# Pure-numpy entropy calculators (drop-in replacements for JIDT)
+# ---------------------------------------------------------------------------
+
+# One singularity policy for every Gaussian quantity in this module: each
+# variable is treated as observed with independent additive noise of variance
+# GAUSSIAN_RIDGE times its own variance, so
+#
+# Sigma -> Sigma + GAUSSIAN_RIDGE * diag(diag(Sigma)).
+#
+# Two properties make this the version worth having, as opposed to the
+# isotropic eps = ridge * mean(diag(Sigma)) it replaces:
+#
+# * It commutes with the compositions. log|.| of a 1-D block is
+# log(V(1+ridge)), of a 2-D block log(V_i V_j((1+ridge)^2 - r^2)), so
+# H(X) + H(Y) - H(X,Y) is exactly -0.5*log(1 - r^2/(1+ridge)^2) -- the
+# direct MI formula below, with no residual. An isotropic ridge does not
+# have this property, because eps then depends on which block it is in.
+# * It is equivariant to per-variable rescaling, so the regularisation does
+# not quietly depend on the units of the loudest process.
+#
+# The previous mismatch was not subtle. Gaussian MI clipped r^2 at 1 - 1e-15,
+# giving -0.5*log(1e-15) = 17.2698 nats on a pair of identical N=100 series,
+# while the entropy path's ridge gave 8.8638 for the same quantity on the same
+# data. Both now return 8.8638.
+GAUSSIAN_RIDGE = 1e-8
+
+
+def _gaussian_mi_from_r(r, ridge_rel=GAUSSIAN_RIDGE):
+ """I(X;Y) = -0.5*log(1 - r^2) for jointly Gaussian X, Y, ridged.
+
+ The ridge is what the entropy path applies, expressed on the correlation
+ scale: adding independent noise of variance ridge*V to each variable
+ attenuates the correlation by exactly (1 + ridge). The bound is therefore
+ -0.5*log(1 - 1/(1+ridge)^2) ~ 8.86 nats rather than an unreachable
+ infinity, and it is the *same* bound the entropy composition reaches.
+ """
+ r2 = np.clip(np.asarray(r, dtype=np.float64) ** 2, 0.0, 1.0)
+ return -0.5 * np.log(1.0 - r2 / (1.0 + ridge_rel) ** 2)
+
+
+def _gaussian_pairwise_joint_entropy(Z, ridge_rel=GAUSSIAN_RIDGE):
+ """Vectorised pairwise Gaussian joint entropy, matching the scalar path.
+
+ Same ridge as _gaussian_log_det, so bivariate() and multivariate() cannot
+ disagree; before either was regularised consistently they differed by 8.406
+ nats on singular data.
+ """
+ R = np.corrcoef(Z)
+ V = np.var(Z, axis=1, ddof=1)
+ # Factored rather than (1+ridge)^2 - R^2: on a singular pair those two
+ # terms agree to ~1e-8 and the subtraction loses most of the digits.
+ det = (V[:, None] * V[None, :]) * (
+ (1.0 + ridge_rel - np.abs(R)) * (1.0 + ridge_rel + np.abs(R)))
+ with np.errstate(invalid="ignore", divide="ignore"):
+ JE = np.log(2 * np.pi * np.e) + 0.5 * np.log(det)
+ return np.where(det > 0, JE, np.nan)
+
+
+def _gaussian_log_det(cov, ridge_rel=GAUSSIAN_RIDGE):
+ """log|Σ + ridge*diag(diag(Σ))| -- the shared Gaussian log-determinant.
+
+ Every Gaussian entropy, joint entropy, conditional entropy, MI, TLMI, TE
+ and AIS in this module goes through this or through `_gaussian_mi_from_r`,
+ which is its closed form on a 2x2 block. Returns NaN, never -inf, on input
+ that is still degenerate after the ridge -- a variable with zero variance
+ has no scale for a *proportional* ridge to act on, and its differential
+ entropy really is -inf, so NaN reports "undefined" rather than propagating
+ an infinity into a difference of entropies.
+
+ Rationale for regularising at all: for rank-deficient Σ the unregularised
+ log|Σ| is -inf (the data lives on a lower-dimensional subspace). JIDT adds
+ NOISE_LEVEL_TO_ADD=1e-8 Gaussian noise to the observations for the same
+ reason; ridging Σ is that operation in expectation (cov(x + η) =
+ Σ + cov(η)) and is deterministic.
+ """
+ if np.ndim(cov) == 0:
+ var = float(cov)
+ if not np.isfinite(var) or var <= 0:
+ return np.nan
+ return float(np.log(var * (1.0 + ridge_rel)))
+ cov = np.asarray(cov, dtype=np.float64)
+ d = cov.shape[0]
+ diag = np.diag(cov)
+ if not np.all(np.isfinite(diag)) or np.any(diag <= 0):
+ return np.nan
+ try:
+ sign, log_det = np.linalg.slogdet(cov + ridge_rel * np.diag(diag))
+ except np.linalg.LinAlgError:
+ return np.nan
+ if sign <= 0 or not np.isfinite(log_det):
+ return np.nan
+ return float(log_det)
+
+
+class GaussianEntropyCalculator:
+ """Drop-in for JIDT's EntropyCalculatorMultiVariateGaussian.
+
+ H = 0.5 * d * log(2*pi*e) + 0.5 * log|Sigma|
+ """
+ def __init__(self):
+ self._d = None
+ self._obs = None
+
+ def initialise(self, d):
+ self._d = int(d)
+ self._obs = None
+
+ def setObservations(self, data):
+ self._obs = np.asarray(data, dtype=np.float64)
+ if self._obs.ndim == 1:
+ self._obs = self._obs.reshape(-1, 1)
+
+ def setProperty(self, key, value):
+ pass
+
+ def computeAverageLocalOfObservations(self):
+ X = self._obs
+ N, d = X.shape
+ cov = np.cov(X, rowvar=False, ddof=1)
+ log_det = _gaussian_log_det(cov)
+ if not np.isfinite(log_det):
+ return float('nan')
+ return float(0.5 * d * np.log(2 * np.pi * np.e) + 0.5 * log_det)
+
+
+class KLEntropyCalculator:
+ """Drop-in for JIDT's EntropyCalculatorMultiVariateKozachenko.
+
+ Uses L2 norm with k=1, matching JIDT.
+ H = psi(N) - psi(1) + log(c_d) + (d/N) * sum(log(eps_i))
+ """
+ def __init__(self):
+ self._d = None
+ self._obs = None
+
+ def initialise(self, d):
+ self._d = int(d)
+ self._obs = None
+
+ def setObservations(self, data):
+ self._obs = np.asarray(data, dtype=np.float64)
+ if self._obs.ndim == 1:
+ self._obs = self._obs.reshape(-1, 1)
+
+ def setProperty(self, key, value):
+ pass
+
+ def computeAverageLocalOfObservations(self):
+ X = self._obs
+ N, d = X.shape
+ tree = cKDTree(X)
+ dists, _ = tree.query(X, k=2, p=2) # k=1 NN (index 0 = self)
+ eps = dists[:, 1]
+ # Duplicated observations put a neighbour at distance 0, and log(0)
+ # sends the entropy to -inf. Quantised data does this readily: the
+ # bundled `forex` series has one process with 24 distinct values in 250
+ # samples. Fail with the cause named rather than emitting an infinity.
+ if not np.all(eps > 0):
+ n_tied = int(np.sum(eps == 0))
+ raise ValueError(
+ f"Kozachenko entropy is undefined for tied observations: "
+ f"{n_tied} of {len(eps)} points have a duplicate (zero "
+ f"nearest-neighbour distance). The data is quantised or has "
+ f"repeated values; dither it or use estimator='gaussian'."
+ )
+ log_cd = (d / 2.0) * np.log(np.pi) - gammaln(d / 2.0 + 1)
+ return float(
+ digamma(N) - digamma(1) + log_cd + (d / N) * np.sum(np.log(eps))
+ )
-from pyspi.base import Undirected, Directed, Unsigned, parse_univariate, parse_bivariate
-class JIDTBase(Unsigned):
+def _gaussian_entropy_from_data(data_2d):
+ """Compute Gaussian entropy from (N, d) array.
+
+ Returns NaN (not -inf) on singular covariance after ridge regularisation,
+ so that downstream differences (e.g. H(X,Y)-H(Y)) never cascade to
+ +/-inf and get clipped to float-max by np.nan_to_num.
+ """
+ N, d = data_2d.shape
+ cov = np.cov(data_2d, rowvar=False, ddof=1)
+ log_det = _gaussian_log_det(cov)
+ if not np.isfinite(log_det):
+ return float('nan')
+ return 0.5 * d * np.log(2 * np.pi * np.e) + 0.5 * log_det
+
+
+# ---------------------------------------------------------------------------
+# Box-kernel KDE entropy/MI/TE calculators (replace JIDT kernel estimators)
+# ---------------------------------------------------------------------------
+
+class KernelEntropyCalculator:
+ """Drop-in for JIDT's EntropyCalculatorMultiVariateKernel.
+
+ Box kernel (Heaviside) with L-infinity norm, matching JIDT exactly:
+ H = mean(log(N) - log(count_i)) [nats]
+ where count_i = #{j : |x_j - x_i|_inf <= kernel_width} (includes self).
+
+ When NORMALISE=true (JIDT default), data is normalised by std before
+ counting; kernel_width is then in units of std dev.
+ """
+
+ def __init__(self):
+ self._d = None
+ self._obs = None
+ self._kernel_width = 0.25
+ self._normalise = True
+
+ def initialise(self, d):
+ self._d = int(d)
+ self._obs = None
+
+ def setProperty(self, key, value):
+ if key == "KERNEL_WIDTH":
+ self._kernel_width = float(value)
+ elif key == "NORMALISE":
+ self._normalise = str(value).lower() == "true"
+
+ def setObservations(self, data):
+ self._obs = np.asarray(data, dtype=np.float64)
+ if self._obs.ndim == 1:
+ self._obs = self._obs.reshape(-1, 1)
+
+ def computeAverageLocalOfObservations(self):
+ X = self._obs
+ N, d = X.shape
+ w = self._kernel_width
+ # NORMALISE: JIDT scales the bandwidth by std (kernelWidthsInUse = w*std)
+ # rather than standardising data. Equivalent operation here is to
+ # standardise X *and* add log(prod(std)) to the entropy — otherwise
+ # we'd be reporting H(X/std) = H(X) - log(std), missing the scale
+ # term and giving a constant downward bias of d*log(std) nats.
+ log_std_total = 0.0
+ if self._normalise:
+ stds = np.std(X, axis=0, ddof=1)
+ stds = np.where(stds > 0, stds, 1.0)
+ X = X / stds[None, :]
+ log_std_total = float(np.sum(np.log(stds)))
+
+ tree = cKDTree(X)
+ # JIDT half-width = kernel_width (not kernel_width/2)
+ counts = tree.query_ball_point(X, r=w, p=np.inf,
+ return_length=True)
+ counts = np.asarray(counts, dtype=np.float64)
+ # H = mean(log(N) - log(count)) + d*log(2*w) + sum_d log(std_d) [nats]
+ return float(np.mean(np.log(N) - np.log(counts))
+ + d * np.log(2.0 * w) + log_std_total)
+
+
+class KernelMICalculator:
+ """Drop-in for JIDT's MutualInfoCalculatorMultiVariateKernel.
+
+ MI = mean(log(n_xy * N / (n_x * n_y))) [nats]
+ where n_x, n_y, n_xy are counts within L∞ ball of radius kernel_width.
+ Matches JIDT exactly.
+ """
+
+ def __init__(self):
+ self._kernel_width = 0.25
+ self._normalise = True
+ self._d1 = 1
+ self._d2 = 1
+ self._obs1 = None
+ self._obs2 = None
+
+ def initialise(self, d1, d2):
+ self._d1 = int(d1)
+ self._d2 = int(d2)
+
+ def setProperty(self, key, value):
+ if key == "KERNEL_WIDTH":
+ self._kernel_width = float(value)
+ elif key == "NORMALISE":
+ self._normalise = str(value).lower() == "true"
+
+ def setObservations(self, src, targ):
+ self._obs1 = np.asarray(src, dtype=np.float64)
+ self._obs2 = np.asarray(targ, dtype=np.float64)
+ if self._obs1.ndim == 1:
+ self._obs1 = self._obs1.reshape(-1, 1)
+ if self._obs2.ndim == 1:
+ self._obs2 = self._obs2.reshape(-1, 1)
+
+ def computeAverageLocalOfObservations(self):
+ X = self._obs1
+ Y = self._obs2
+ N = X.shape[0]
+ w = self._kernel_width
+
+ if self._normalise:
+ def _norm(data):
+ stds = np.std(data, axis=0, ddof=1)
+ stds = np.where(stds > 0, stds, 1.0)
+ return data / stds[None, :]
+ X = _norm(X)
+ Y = _norm(Y)
+
+ XY = np.column_stack([X, Y])
+ tree_x = cKDTree(X)
+ tree_y = cKDTree(Y)
+ tree_xy = cKDTree(XY)
+
+ # JIDT half-width = kernel_width
+ n_x = np.asarray(tree_x.query_ball_point(X, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ n_y = np.asarray(tree_y.query_ball_point(Y, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ n_xy = np.asarray(tree_xy.query_ball_point(XY, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+
+ # MI = mean(log(n_xy * N / (n_x * n_y))) [nats]
+ mi = np.mean(np.log(n_xy) + np.log(N) - np.log(n_x) - np.log(n_y))
+ return float(mi)
+
+
+class KernelTECalculator:
+ """Drop-in for JIDT's TransferEntropyCalculatorKernel.
+
+ TE(X→Y) = mean(log(n_yn_yp_x * n_yp / (n_yp_x * n_yn_yp))) [nats]
+ Uses box kernel with L∞ norm, half-width = kernel_width.
+ Matches JIDT exactly.
+ """
+
+ def __init__(self):
+ self._kernel_width = 0.25
+ self._normalise = True
+ self._k_history = 1
+ self._dyn_corr_excl = None
+ self._props = {}
+
+ def initialise(self):
+ pass
+
+ def setProperty(self, key, value):
+ self._props[key] = value
+ if key == "KERNEL_WIDTH":
+ self._kernel_width = float(value)
+ elif key == "NORMALISE":
+ self._normalise = str(value).lower() == "true"
+ elif key == "k_HISTORY":
+ self._k_history = int(value)
+ elif key == "DYN_CORR_EXCL":
+ self._dyn_corr_excl = int(value)
+
+ def setObservations(self, src, targ):
+ self._src = np.asarray(src, dtype=np.float64).ravel()
+ self._targ = np.asarray(targ, dtype=np.float64).ravel()
+
+ def computeAverageLocalOfObservations(self):
+ src = self._src
+ targ = self._targ
+ k = self._k_history
+ T = len(src)
+ w = self._kernel_width
+
+ if T <= k:
+ return np.nan
+
+ # Build vectors
+ n_pts = T - k
+ y_past = np.column_stack([targ[k - 1 - lag: T - 1 - lag] for lag in range(k)])
+ y_next = targ[k:].reshape(-1, 1)
+ x_t = src[k - 1: T - 1].reshape(-1, 1)
+
+ if self._normalise:
+ def _norm(data):
+ stds = np.std(data, axis=0, ddof=1)
+ stds = np.where(stds > 0, stds, 1.0)
+ return data / stds[None, :]
+ y_past = _norm(y_past)
+ y_next = _norm(y_next)
+ x_t = _norm(x_t)
+
+ # Joint spaces
+ yn_yp = np.column_stack([y_next, y_past])
+ yp_x = np.column_stack([y_past, x_t])
+ yn_yp_x = np.column_stack([y_next, y_past, x_t])
+
+ tree_yp = cKDTree(y_past)
+ tree_yn_yp = cKDTree(yn_yp)
+ tree_yp_x = cKDTree(yp_x)
+ tree_yn_yp_x = cKDTree(yn_yp_x)
+
+ # JIDT half-width = kernel_width
+ dce = self._dyn_corr_excl
+ if dce is None or dce <= 0:
+ n_yp = np.asarray(tree_yp.query_ball_point(y_past, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ n_yn_yp = np.asarray(tree_yn_yp.query_ball_point(yn_yp, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ n_yp_x = np.asarray(tree_yp_x.query_ball_point(yp_x, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ n_yn_yp_x = np.asarray(tree_yn_yp_x.query_ball_point(yn_yp_x, r=w, p=np.inf,
+ return_length=True), dtype=np.float64)
+ else:
+ # Theiler window: exclude neighbours j with |j - i| <= dce, matching
+ # JIDT's DYN_CORR_EXCL convention (excludes 2*dce+1 points centered on i,
+ # including self). We query index lists then filter by temporal separation.
+ def _counts_with_theiler(tree, pts):
+ idx_lists = tree.query_ball_point(pts, r=w, p=np.inf)
+ out = np.empty(len(idx_lists), dtype=np.float64)
+ for i, nbrs in enumerate(idx_lists):
+ nbrs_arr = np.asarray(nbrs, dtype=np.int64)
+ out[i] = np.sum(np.abs(nbrs_arr - i) > dce)
+ return out
+ n_yp = _counts_with_theiler(tree_yp, y_past)
+ n_yn_yp = _counts_with_theiler(tree_yn_yp, yn_yp)
+ n_yp_x = _counts_with_theiler(tree_yp_x, yp_x)
+ n_yn_yp_x = _counts_with_theiler(tree_yn_yp_x, yn_yp_x)
+
+ # Drop samples where any bin is empty (log(0) = -inf; JIDT skips these).
+ valid = (n_yp > 0) & (n_yn_yp > 0) & (n_yp_x > 0) & (n_yn_yp_x > 0)
+ if not np.any(valid):
+ return float('nan')
+ n_yp, n_yn_yp = n_yp[valid], n_yn_yp[valid]
+ n_yp_x, n_yn_yp_x = n_yp_x[valid], n_yn_yp_x[valid]
+
+ # TE = mean(log(n_yn_yp_x * n_yp / (n_yp_x * n_yn_yp))) [nats]
+ te = np.mean(np.log(n_yn_yp_x) + np.log(n_yp) - np.log(n_yp_x) - np.log(n_yn_yp))
+ return float(te)
+
+
+# ---------------------------------------------------------------------------
+# Symbolic Transfer Entropy (ordinal patterns)
+# ---------------------------------------------------------------------------
+
+def _ordinal_pattern_id(vec):
+ """Convert a vector to its ordinal pattern ID.
+
+ The ordinal pattern is the rank ordering. Maps to an integer in [0, d!).
+ Uses the factorial number system (Lehmer code).
+ """
+ d = len(vec)
+ # Get the rank order (argsort of argsort)
+ order = np.argsort(vec)
+ # Lehmer code
+ code = 0
+ remaining = list(range(d))
+ factorial = 1
+ for i in range(1, d):
+ factorial *= i
+ for i in range(d - 1):
+ pos = remaining.index(order[i])
+ code += pos * factorial
+ remaining.pop(pos)
+ if i < d - 2:
+ factorial //= (d - 1 - i)
+ return code
+
+
+def _series_to_ordinal_symbols(x, k):
+ """Convert a 1D time series to a sequence of ordinal pattern symbols.
+
+ For each t, the embedding vector is [x[t], x[t-1], ..., x[t-k+1]].
+ Returns integer array of symbol IDs, length T-k+1.
+ """
+ x = np.asarray(x).ravel()
+ T = len(x)
+ if T < k:
+ return np.array([], dtype=int)
+
+ n_pts = T - k + 1
+ symbols = np.empty(n_pts, dtype=int)
+
+ # Build embedding matrix
+ embedding = np.column_stack([x[k - 1 - lag: T - lag] for lag in range(k)])
+
+ for t in range(n_pts):
+ symbols[t] = _ordinal_pattern_id(embedding[t])
+
+ return symbols
+
+
+class SymbolicTECalculator:
+ """Drop-in for JIDT's TransferEntropyCalculatorSymbolic.
+
+ Converts source and target to ordinal patterns of length k,
+ then computes TE from joint symbol histograms.
+
+ One pattern length, applied to source and destination alike, at unit
+ delay -- Staniek & Lehnertz (2008) define it this way and JIDT's
+ TransferEntropyCalculatorSymbolic does the same. There is no separate
+ source history or embedding delay here, so `TransferEntropy` refuses
+ `k_tau`, `l_history` and `l_tau` under this estimator rather than
+ accepting them, writing them into the identifier and ignoring them.
+
+ TE = H(Y_next | Y_past) - H(Y_next | Y_past, X)
+ = H(Y_next, Y_past) - H(Y_past) - H(Y_next, Y_past, X) + H(Y_past, X)
+ where all entropies are discrete (histogram-based).
+ """
+
+ def __init__(self):
+ self._k_history = 1
+ self._props = {}
+
+ def initialise(self):
+ pass
+
+ def setProperty(self, key, value):
+ self._props[key] = value
+ if key == "k_HISTORY":
+ self._k_history = int(value)
+
+ def setObservations(self, src, targ):
+ self._src = np.asarray(src, dtype=np.float64).ravel()
+ self._targ = np.asarray(targ, dtype=np.float64).ravel()
+
+ def computeAverageLocalOfObservations(self):
+ src = self._src
+ targ = self._targ
+ k = self._k_history
+ T = len(src)
+
+ if T <= k:
+ return np.nan
+
+ # Convert to ordinal patterns
+ src_symbols = _series_to_ordinal_symbols(src, k)
+ targ_symbols = _series_to_ordinal_symbols(targ, k)
+
+ # Align: targ_next starts at index 1 of the symbol sequence
+ # targ_past = targ_symbols[:-1], targ_next = targ_symbols[1:]
+ # src_current = src_symbols[:-1] (concurrent with targ_past)
+ n = min(len(src_symbols), len(targ_symbols)) - 1
+ if n <= 0:
+ return np.nan
+
+ targ_next = targ_symbols[1:n + 1]
+ targ_past = targ_symbols[:n]
+ src_curr = src_symbols[:n]
+
+ def _discrete_entropy(*arrs):
+ """Joint entropy of integer-valued arrays using histograms.
+
+ Counts distinct rows directly rather than packing the symbols into
+ a single integer. The previous encoding multiplied by a multiplier
+ that squared at each step, so the packed value reached (k!)^3 and
+ exceeded int64 for k >= 10, wrapping silently. Wrapping is not the
+ same as colliding -- no collisions occur on the shipped fixtures --
+ but the encoding gave no guarantee, and correctness should not rest
+ on the arithmetic happening to stay injective.
+ """
+ if len(arrs) == 1:
+ _, counts = np.unique(arrs[0], return_counts=True)
+ else:
+ stacked = np.column_stack(arrs)
+ _, counts = np.unique(stacked, axis=0, return_counts=True)
+ probs = counts / counts.sum()
+ return -np.sum(probs * np.log(probs))
+
+ # TE = H(yn, yp) - H(yp) - H(yn, yp, x) + H(yp, x)
+ H_yn_yp = _discrete_entropy(targ_next, targ_past)
+ H_yp = _discrete_entropy(targ_past)
+ H_yn_yp_x = _discrete_entropy(targ_next, targ_past, src_curr)
+ H_yp_x = _discrete_entropy(targ_past, src_curr)
+
+ te = H_yn_yp - H_yp - H_yn_yp_x + H_yp_x
+ return float(te)
+
+
+def _numpy_delay_embedding(x, dim):
+ """Numpy equivalent of JIDT's MatrixUtils.makeDelayEmbeddingVector."""
+ x = np.asarray(x).ravel()
+ T = len(x)
+ if dim == 0:
+ return np.empty((T + 1, 0))
+ return np.column_stack([x[dim - 1 - lag: T - lag] for lag in range(dim)])
+
+
+# ---------------------------------------------------------------------------
+# k-NN input conditioning
+# ---------------------------------------------------------------------------
+
+# JIDT switches both of these on by default for every KSG-family calculator:
+# MutualInfoCalculatorMultiVariateKraskov, ConditionalMutualInfoCalculator-
+# MultiVariateKraskov and EntropyCalculatorMultiVariateKozachenko all set
+# `addNoise = true; noiseLevel = 1e-8` in their constructors ("to match the
+# noise order in MILCA toolkit"); `normalise = true` is inherited from their
+# common base. pyspi retains normalisation but intentionally does not copy the
+# random-noise policy.
+# KSG estimator 1 is a continuous-density estimator. Exact coordinate ties
+# make the kth-neighbour radius/counting convention ambiguous; deterministic
+# jitter merely replaces that ambiguity with dependence on an arbitrary noise
+# realisation, and content-derived jitter additionally depends on sample order.
+# pyspi therefore refuses tied coordinates instead of pretending to recover a
+# discrete information measure. Per-coordinate standardisation remains: the
+# joint L-infinity geometry is then covariant under nonzero affine marginal
+# changes, including reflections, without rounding or random state.
+
+
+def _knn_condition(X):
+ """Validate continuous KSG input and standardise each coordinate.
+
+ Every coordinate must be tie-free. Quantised/discrete data requires an
+ external discrete plug-in information estimator; pyspi has no general
+ discrete MI/TLMI/DI estimator. Adding deterministic jitter does not turn
+ KSG into one and can make the result depend on sample order.
+
+ Standardisation uses an observed origin before mean/std calculation to
+ reduce cancellation under affine offsets. No rounding, dither, seed, or
+ global RNG state participates in the estimator.
+ """
+ X = np.asarray(X, dtype=np.float64)
+ reshaped = X.ndim == 1
+ if reshaped:
+ X = X.reshape(-1, 1)
+
+ out = np.empty_like(X)
+ for c in range(X.shape[1]):
+ col = X[:, c]
+ n_unique = np.unique(col).size
+ if n_unique != col.size:
+ raise ValueError(
+ f"KSG requires continuous, tie-free coordinates: coordinate "
+ f"{c} has {n_unique} unique value(s) among {col.size} "
+ f"observations. Deterministic jitter would make the estimate "
+ f"depend on arbitrary noise. Use a discrete plug-in "
+ f"information estimator outside pyspi for quantised/discrete "
+ f"data, or "
+ f"remove measurement rounding only when scientifically "
+ f"justified."
+ )
+ # Subtract an observed origin before taking the mean/std. This is
+ # algebraically identical, but avoids needless cancellation when an
+ # affine transform adds an offset large relative to the variation.
+ shifted = col - col[0]
+ sd = shifted.std(ddof=1) # JIDT MatrixUtils.stdDevs: sample std
+ out[:, c] = (shifted - shifted.mean()) / sd
+ return out.reshape(-1) if reshaped else out
+
+
+# ---------------------------------------------------------------------------
+# KSG MI estimator
+# ---------------------------------------------------------------------------
+
+def _validate_ksg_sample(N, k, w, context=""):
+ """Reject KSG settings that cannot produce a meaningful estimate.
+
+ The estimator needs k neighbours drawn from the points that survive the
+ Theiler exclusion. Without this check, k >= N silently returned a finite
+ number that grows with k: k=30 on N=20 gave 0.414 and k=100 gave 1.63,
+ neither of which is an estimate of anything.
+ """
+ effective = N - (2 * w + 1) if w else N - 1
+ where = f" ({context})" if context else ""
+ if N < 2:
+ raise ValueError(f"KSG needs at least 2 observations, got {N}{where}.")
+ if k < 1:
+ raise ValueError(f"KSG needs k >= 1, got k={k}{where}.")
+ if w < 0:
+ raise ValueError(f"Theiler window must be >= 0, got w={w}{where}.")
+ if k > effective:
+ raise ValueError(
+ f"KSG k={k} exceeds the {effective} usable neighbour(s) for "
+ f"N={N} observations with Theiler window w={w}{where}. Reduce k, "
+ f"lengthen the series, or narrow the Theiler window."
+ )
+
+
+def _strict_radius(epsilon):
+ """Largest representable radius strictly smaller than ``epsilon``.
+
+ ``cKDTree.query_ball_point`` includes the radius boundary. KSG estimator 1
+ requires marginal distances strictly below the joint kth-neighbour radius,
+ so moving by one floating-point step implements that inequality without a
+ scale-dependent tolerance that can discard genuine interior neighbours.
+ """
+ return np.nextafter(epsilon, -np.inf)
+
+
+def _ksg_mi_pair(x, y, k, w):
+ """KSG Estimator 1 MI for a single pair of scalar series."""
+ N = len(x)
+ _validate_ksg_sample(N, k, w, context="mutual information")
+ # A constant marginal carries no information, but it is almost always a
+ # broken input rather than a result the caller wants. Checked before
+ # conditioning so the error can name the marginal directly.
+ for name, v in (("x", x), ("y", y)):
+ if np.ptp(v) == 0:
+ raise ValueError(
+ f"KSG cannot estimate mutual information: marginal {name} is "
+ f"constant, so all neighbour distances are zero."
+ )
+ x, y = _knn_condition(np.column_stack([x, y])).T
+ tree_x = cKDTree(x.reshape(-1, 1))
+ tree_y = cKDTree(y.reshape(-1, 1))
+ xy = np.column_stack([x, y])
+ tree_xy = cKDTree(xy)
+
+ if w == 0:
+ dists, _ = tree_xy.query(xy, k=k + 1, p=np.inf)
+ eps = dists[:, k]
+ eps_strict = _strict_radius(eps)
+ nx_lists = tree_x.query_ball_point(x.reshape(-1, 1), eps_strict, p=np.inf)
+ ny_lists = tree_y.query_ball_point(y.reshape(-1, 1), eps_strict, p=np.inf)
+ n_x = np.array([len(lst) - 1 for lst in nx_lists], dtype=np.float64)
+ n_y = np.array([len(lst) - 1 for lst in ny_lists], dtype=np.float64)
+ else:
+ n_query = min(k + 2 * w + 2, N)
+ dists_all, idx_all = tree_xy.query(xy, k=n_query, p=np.inf)
+
+ eps = np.empty(N)
+ n_x = np.empty(N)
+ n_y = np.empty(N)
+
+ for i in range(N):
+ valid = np.abs(idx_all[i] - i) > w
+ valid[0] = False
+ d_valid = dists_all[i][valid]
+
+ if len(d_valid) < k:
+ all_dists = np.max(np.abs(xy - xy[i]), axis=1)
+ all_dists[max(0, i - w): i + w + 1] = np.inf
+ all_dists[i] = np.inf
+ d_valid = np.sort(all_dists)
+ d_valid = d_valid[np.isfinite(d_valid)]
+
+ e = d_valid[k - 1] if len(d_valid) >= k else np.inf
+ eps[i] = e
+
+ # Count marginal neighbours STRICTLY within eps (< eps), matching
+ # KSG1 and the w==0 branch above. Inclusive (<= eps) counting wrongly
+ # admits the k-th neighbour at the boundary, inflating n_x/n_y and
+ # flipping the sign of the Theiler-window effect on MI.
+ e_strict = _strict_radius(e)
+ ix = tree_x.query_ball_point([[x[i]]], e_strict, p=np.inf)[0]
+ iy = tree_y.query_ball_point([[y[i]]], e_strict, p=np.inf)[0]
+ n_x[i] = sum(1 for j in ix if abs(j - i) > w and j != i)
+ n_y[i] = sum(1 for j in iy if abs(j - i) > w and j != i)
+
+ # psi(N) uses the full, unreduced N (KSG1 convention; only the neighbour
+ # set is window-restricted, not the normalisation). See JIDT
+ # MutualInfoCalculatorMultiVariateKraskov.
+ mi = digamma(k) - np.mean(digamma(n_x + 1) + digamma(n_y + 1)) + digamma(N)
+ return float(mi)
+
+
+def _ksg_mi_general(A, B, k_nn, w=0, condition=True):
+ """KSG Estimator 1 MI(A; B) for multivariate A (N,dA), B (N,dB), L-inf norm.
+
+ Generalisation of _ksg_mi_pair to arbitrary marginal dimensions.
+ Marginal neighbours are counted strictly (< eps); psi(N) uses full N. An
+ optional Theiler window w excludes |j-i| <= w from the neighbour set.
+ """
+ A = np.asarray(A, dtype=np.float64)
+ B = np.asarray(B, dtype=np.float64)
+ if A.ndim == 1:
+ A = A[:, None]
+ if B.ndim == 1:
+ B = B[:, None]
+ if condition:
+ A, B = np.split(_knn_condition(np.column_stack([A, B])), [A.shape[1]], axis=1)
+ N = A.shape[0]
+ # Validated here, on the aligned arrays, not only by whatever built them.
+ # This is the estimator's own precondition: with fewer usable neighbours
+ # than k, `tree.query(..., k=k_nn+1)` pads with infinities and the digamma
+ # assembly returns a finite number that is not an estimate of anything.
+ _validate_ksg_sample(N, k_nn, w, context="mutual information")
+ AB = np.column_stack([A, B])
+ tree_ab = cKDTree(AB)
+ tree_a = cKDTree(A)
+ tree_b = cKDTree(B)
+
+ if w == 0:
+ dists, _ = tree_ab.query(AB, k=k_nn + 1, p=np.inf)
+ eps = _strict_radius(dists[:, k_nn])
+ n_a = np.array([len(lst) - 1 for lst in
+ tree_a.query_ball_point(A, eps, p=np.inf)], dtype=np.float64)
+ n_b = np.array([len(lst) - 1 for lst in
+ tree_b.query_ball_point(B, eps, p=np.inf)], dtype=np.float64)
+ else:
+ n_query = min(k_nn + 2 * w + 2, N)
+ dists_all, idx_all = tree_ab.query(AB, k=n_query, p=np.inf)
+ n_a = np.empty(N)
+ n_b = np.empty(N)
+ for i in range(N):
+ valid = np.abs(idx_all[i] - i) > w
+ valid[0] = False
+ d_valid = dists_all[i][valid]
+ if len(d_valid) < k_nn:
+ all_d = np.max(np.abs(AB - AB[i]), axis=1)
+ all_d[max(0, i - w): i + w + 1] = np.inf
+ all_d[i] = np.inf
+ d_valid = np.sort(all_d)
+ d_valid = d_valid[np.isfinite(d_valid)]
+ e = d_valid[k_nn - 1] if len(d_valid) >= k_nn else np.inf
+ e_strict = _strict_radius(e)
+ ia = tree_a.query_ball_point(A[i], e_strict, p=np.inf)
+ ib = tree_b.query_ball_point(B[i], e_strict, p=np.inf)
+ n_a[i] = sum(1 for j in ia if abs(j - i) > w and j != i)
+ n_b[i] = sum(1 for j in ib if abs(j - i) > w and j != i)
+
+ return float(digamma(k_nn) + digamma(N)
+ - np.mean(digamma(n_a + 1) + digamma(n_b + 1)))
+
+
+# ---------------------------------------------------------------------------
+# Transfer Entropy helpers
+# ---------------------------------------------------------------------------
+
+class _DummyTECalculator:
+ """Dummy TE calculator for gaussian/kraskov fixed-embedding."""
+ def __init__(self):
+ self._props = {}
+ def setProperty(self, key, value):
+ self._props[key] = value
+ def initialise(self):
+ pass
+ def setObservations(self, src, targ):
+ pass
+ def computeAverageLocalOfObservations(self):
+ return np.nan
+
+
+def _te_build_embeddings(src, targ, k_history, k_tau, l_history, l_tau):
+ """Build delay embeddings for transfer entropy computation."""
+ T = len(src)
+ max_lookback = max((k_history - 1) * k_tau, (l_history - 1) * l_tau)
+ start = max_lookback
+ end = T - 1
+
+ if start >= end:
+ return None, None, None
+
+ Y_future = targ[start + 1: end + 1].reshape(-1, 1)
+
+ Y_past_cols = []
+ for lag_idx in range(k_history):
+ lag = lag_idx * k_tau
+ Y_past_cols.append(targ[start - lag: end - lag])
+ Y_past = np.column_stack(Y_past_cols)
+
+ X_past_cols = []
+ for lag_idx in range(l_history):
+ lag = lag_idx * l_tau
+ X_past_cols.append(src[start - lag: end - lag])
+ X_past = np.column_stack(X_past_cols)
+
+ return Y_future, Y_past, X_past
+
+
+def _gaussian_ais(targ, k, tau):
+ """Bias-corrected Gaussian AIS criterion: MI(Y_future; Y_past_embedding(k, tau)).
+
+ Used for AIS-criterion auto-embedding: select (k, tau) = argmax AIS.
+
+ The raw in-sample log-det multiinformation is biased upward by the mean of
+ its chi-squared null, E[MI_null] = df / (2N) with df = dim(Y_f)*k = k (the
+ future is 1-D). Without this correction the criterion increases monotonically
+ in k and saturates at k_max. Subtracting k/(2N) gives an interior maximum and
+ matches JIDT's ActiveInfoStorageCalculatorGaussian.computeAdditionalBiasToRemove.
+
+ This is the *maximum corrected AIS* criterion (Wibral et al. 2014; JIDT's
+ AUTO_EMBED_METHOD_MAX_CORR_AIS). It is not the Ragwitz criterion, which
+ selects by local-prediction error (Ragwitz & Kantz 2002) and is not
+ implemented here.
+ """
+ T = len(targ)
+ start = (k - 1) * tau
+ end = T - 1
+ if start >= end:
+ return -np.inf
+ Y_f = targ[start + 1: end + 1].reshape(-1, 1)
+ Y_p = np.column_stack([targ[start - i * tau: end - i * tau] for i in range(k)])
+ YfYp = np.concatenate([Y_f, Y_p], axis=1)
+ N = Y_f.shape[0]
+
+ def _slogdet(arr):
+ return _gaussian_log_det(np.cov(arr, rowvar=False, ddof=1))
+
+ ais_raw = 0.5 * (_slogdet(Y_f) + _slogdet(Y_p) - _slogdet(YfYp))
+ return ais_raw - k / (2.0 * N)
+
+
+def _ksg_ais(targ, k, tau, k_nn, w=0):
+ """KSG (Kraskov) AIS = MI(Y_future; Y_past_embedding(k, tau)), for embedding
+ selection of the *kraskov* TE estimator.
+
+ Estimator-consistent counterpart of _gaussian_ais: the KSG estimator is
+ approximately bias-free, so max-KSG-AIS over k has a genuine interior peak
+ without an explicit bias term (cf. JIDT MAX_CORR_AIS, which returns 0 extra
+ bias for KSG; Wibral et al. 2014). Selecting the embedding with the same
+ estimator used for the final TE avoids the linear/nonlinear mismatch of
+ using Gaussian AIS to embed a nonlinear estimator.
+ """
+ T = len(targ)
+ start = (k - 1) * tau
+ end = T - 1
+ if start >= end:
+ return -np.inf
+ Y_f = targ[start + 1: end + 1].reshape(-1, 1)
+ Y_p = np.column_stack([targ[start - i * tau: end - i * tau] for i in range(k)])
+ return _ksg_mi_general(Y_f, Y_p, k_nn, w)
+
+
+def _ais_scorer(estimator, k_nn=None, w=0):
+ """``f(series, dim, delay) -> score`` for the estimator that will run the TE.
+
+ Factored out so the source and the destination embeddings are chosen by the
+ same code, with the same estimator, and so a test can substitute a scorer
+ that deliberately picks different values for the two and check that all four
+ reach the final estimate.
+
+ Selecting with the estimator that will do the work is the point: scoring a
+ KSG transfer entropy's embedding by a Gaussian AIS picks the embedding with
+ the best *linear* predictability, which is not what the final estimator
+ measures.
+ """
+ if estimator == "gaussian":
+ return lambda series, dim, delay: _gaussian_ais(series, dim, delay)
+ if estimator == "kraskov":
+ def score(series, dim, delay):
+ # `T - 1 - (dim-1)*delay`, not `T - (dim-1)*delay`. `_ksg_ais`
+ # aligns a one-step-ahead future against the embedding, so it
+ # spends a sample on the shift as well as on the lookback. The
+ # off-by-one let a candidate that the estimator cannot support be
+ # scored: at T=5, k_nn=4, dim=1 the guard saw N=5 (usable
+ # neighbours 4, exactly k) and passed, while the aligned arrays
+ # have N=4 (usable 3) -- and `_ksg_mi_general` had no guard of its
+ # own, so it returned a finite 0.0 for a candidate with fewer
+ # neighbours than k.
+ n_eff = len(series) - 1 - (dim - 1) * delay
+ try:
+ _validate_ksg_sample(n_eff, k_nn, w)
+ except ValueError:
+ # Skip candidates the estimator cannot support rather than
+ # ranking them and failing on the winner: on kuramoto M7/T100
+ # an invalid (k=10, tau=4, w=33) candidate won and the whole
+ # SPI then failed, though valid smaller embeddings existed.
+ return -np.inf
+ return _ksg_ais(series, dim, delay, k_nn, w)
+ return score
+ raise NotImplementedError(
+ f"No active-information-storage criterion for estimator {estimator!r}."
+ )
+
+
+def _select_embedding(series, scorer, dim_max, tau_max):
+ """``(dimension, delay, score)`` maximising the scorer.
+
+ Ties go to the smaller dimension and then the smaller delay: a
+ lower-dimensional embedding that stores as much information is the better
+ model, and the finite-sample AIS objective plateaus often enough that ties
+ are not rare.
+
+ Raises when *no* candidate is scorable. Returning ``(1, 1)`` there would
+ hand the estimator an embedding that had itself been rejected, and the
+ caller would get a number rather than the reason there isn't one -- which
+ on a short series is the difference between "transfer entropy at the
+ selected embedding" and "there is not enough data to select an embedding".
+ A non-finite score counts as unscorable, so a candidate whose covariance is
+ degenerate cannot win by default either.
+ """
+ best = (None, None, -np.inf)
+ for dim in range(1, dim_max + 1):
+ for delay in range(1, tau_max + 1):
+ score = scorer(series, dim, delay)
+ if np.isfinite(score) and score > best[2]:
+ best = (dim, delay, score)
+ if best[0] is None:
+ raise ValueError(
+ f"No (dimension, delay) in 1..{dim_max} x 1..{tau_max} can be "
+ f"scored on a series of {len(series)} observations: every "
+ f"candidate leaves too few aligned samples for the estimator. "
+ f"Lower the search bounds, or use a fixed embedding."
+ )
+ return best
+
+
+def _gaussian_te_bivariate(src, targ, k_history, k_tau, l_history, l_tau):
+ """Gaussian TE via log-determinant ratio."""
+ Y_f, Y_p, X_p = _te_build_embeddings(src, targ, k_history, k_tau, l_history, l_tau)
+ if Y_f is None:
+ return np.nan
+
+ def _slogdet(data):
+ return _gaussian_log_det(np.cov(data, rowvar=False, ddof=1))
+
+ YfYp = np.concatenate([Y_f, Y_p], axis=1)
+ YfYpXp = np.concatenate([Y_f, Y_p, X_p], axis=1)
+ YpXp = np.concatenate([Y_p, X_p], axis=1)
+
+ te = 0.5 * (_slogdet(YfYp) - _slogdet(Y_p) - _slogdet(YfYpXp) + _slogdet(YpXp))
+ return float(te)
+
+
+def _ksg_cmi(A, B, C, k_nn, w=0, condition=True):
+ """KSG conditional mutual information I(A; B | C), Frenzel-Pompe estimator.
+
+ Estimates the CMI *directly* rather than as a sum of four separately
+ estimated entropies. That distinction is the whole point: the neighbour
+ radius is fixed once in the joint space [A,B,C] and reused in every
+ marginal count, so the dimension-dependent biases cancel by construction.
+ Composing the same quantity from marginal entropies leaves each one with
+ its own bias in its own dimensionality, and those do not cancel.
+
+ ``C`` may have zero columns, in which case this delegates to the MI
+ estimator -- conditioning on nothing is mutual information.
+ """
+ A = np.atleast_2d(A)
+ B = np.atleast_2d(B)
+ N = A.shape[0]
+ # Constant columns have no continuous density. Other ties are rejected by
+ # the shared conditioning primitive below.
+ for name, arr in (("A", A), ("B", B)):
+ if np.ptp(arr, axis=0).min() == 0:
+ raise ValueError(f"KSG cannot estimate: {name} has a constant column.")
+ has_C = C is not None and np.asarray(C).size and np.asarray(C).shape[1] > 0
+ if condition:
+ if has_C:
+ C = np.atleast_2d(C)
+ A, B, C = np.split(
+ _knn_condition(np.column_stack([A, B, C])),
+ [A.shape[1], A.shape[1] + B.shape[1]], axis=1,
+ )
+ else:
+ A, B = np.split(
+ _knn_condition(np.column_stack([A, B])), [A.shape[1]], axis=1)
+ if not has_C:
+ # With nothing to condition on this *is* mutual information, so use the
+ # MI estimator rather than emulating it. Faking the conditioning count
+ # as a constant N-(2w+1) matched _ksg_mi_general only at w=0 and drifted
+ # with the Theiler window (0.005 at w=1, 0.051 at w=10 on a probe).
+ return _ksg_mi_general(A, B, k_nn, w, condition=False)
+
+ joint = np.concatenate([A, B, C], axis=1)
+ AC = np.concatenate([A, C], axis=1)
+ BC = np.concatenate([B, C], axis=1)
+
+ tree_joint = cKDTree(joint)
+ tree_AC = cKDTree(AC)
+ tree_BC = cKDTree(BC)
+ tree_C = cKDTree(C)
+
+ if w == 0:
+ dists, _ = tree_joint.query(joint, k=k_nn + 1, p=np.inf)
+ eps = dists[:, k_nn]
+ eps_strict = _strict_radius(eps)
+
+ n_AC = np.array([len(l) - 1 for l in
+ tree_AC.query_ball_point(AC, eps_strict, p=np.inf)], dtype=np.float64)
+ n_BC = np.array([len(l) - 1 for l in
+ tree_BC.query_ball_point(BC, eps_strict, p=np.inf)], dtype=np.float64)
+ n_C = np.array([len(l) - 1 for l in
+ tree_C.query_ball_point(C, eps_strict, p=np.inf)], dtype=np.float64)
+ else:
+ n_query = min(k_nn + 2 * w + 2, N)
+ dists_all, idx_all = tree_joint.query(joint, k=n_query, p=np.inf)
+ n_AC = np.empty(N); n_BC = np.empty(N); n_C = np.empty(N)
+ for i in range(N):
+ valid = np.abs(idx_all[i] - i) > w
+ valid[0] = False
+ d_valid = dists_all[i][valid]
+ e = d_valid[k_nn - 1] if len(d_valid) >= k_nn else np.inf
+ e_strict = _strict_radius(e)
+ n_AC[i] = sum(1 for j in tree_AC.query_ball_point(AC[i], e_strict, p=np.inf)
+ if abs(j - i) > w and j != i)
+ n_BC[i] = sum(1 for j in tree_BC.query_ball_point(BC[i], e_strict, p=np.inf)
+ if abs(j - i) > w and j != i)
+ n_C[i] = sum(1 for j in tree_C.query_ball_point(C[i], e_strict, p=np.inf)
+ if abs(j - i) > w and j != i)
+
+ return float(digamma(k_nn) + np.mean(
+ digamma(n_C + 1) - digamma(n_AC + 1) - digamma(n_BC + 1)
+ ))
+
+
+def _kraskov_te_bivariate(src, targ, k_history, k_tau, l_history, l_tau, k_nn, w):
+ """Kraskov TE via the Frenzel-Pompe CMI estimator: I(Y_f; X_p | Y_p)."""
+ Y_f, Y_p, X_p = _te_build_embeddings(src, targ, k_history, k_tau, l_history, l_tau)
+ if Y_f is None:
+ return np.nan
+ # Same precondition as the MI path, applied to the *embedded* sample count
+ # rather than the raw series length: embedding consumes the lookback, so
+ # the usable N here is smaller than len(targ).
+ _validate_ksg_sample(Y_f.shape[0], k_nn, w, context="transfer entropy")
+ return _ksg_cmi(Y_f, X_p, Y_p, k_nn, w)
+
+
+# ---------------------------------------------------------------------------
+# Information-theory base class — with estimator dispatch
+# ---------------------------------------------------------------------------
+
+_ESTIMATORS = frozenset({"gaussian", "kraskov", "kernel", "kozachenko", "symbolic"})
+
+
+def _require_positive_int(name, value):
+ """Thin alias for `utils.require_int(..., minimum=1)`; see it for the rules."""
+ return utils.require_int(name, value, minimum=1)
+
+# Auto-embedding selection criteria that are actually implemented. The search
+# maximises active information storage under the destination's own estimator.
+# Auto-embedding selection criteria that are actually implemented, and what
+# each one selects. Both maximise the bias-corrected active information storage
+# of a series over (dimension, delay); they differ only in which series.
+# MAX_CORR_AIS -- destination *and* source, chosen independently.
+# MAX_CORR_AIS_DEST_ONLY -- destination only; the source embedding is the
+# caller's fixed l_history/l_tau.
+# Ragwitz local-prediction selection is a different criterion and is not
+# implemented.
+_AUTO_EMBED_METHODS = frozenset({"MAX_CORR_AIS", "MAX_CORR_AIS_DEST_ONLY"})
+_AUTO_EMBED_DEST_ONLY = "MAX_CORR_AIS_DEST_ONLY"
+
+
+class InfoTheoryBase(Unsigned):
+ """Base for the information-theoretic SPIs.
+
+ **Every measure in this module is reported in nats.** The kernel and
+ symbolic calculators used to report bits, inherited from JIDT, which uses
+ base 2 for its box-kernel and discrete estimators and base e for its
+ Gaussian and k-nearest-neighbour ones. Carrying that split into a single
+ results table means `mi_kernel_W-0-5` and `mi_gaussian` are on axes
+ differing by a factor of ln 2 with nothing in the identifier to say so, so
+ anything comparing *magnitudes* across that boundary -- one threshold over
+ several estimators, a difference or ratio of two columns, a "which found
+ the most information" ranking across estimators -- is off by that factor.
+ (A Pearson or Spearman correlation between two columns is invariant to a
+ positive rescaling and was never affected.) Divide by ln 2 to recover the
+ JIDT-comparable value.
+ """
- # List of (currently) modifiable parameters
- _NNK_PROP_NAME = "k"
_AUTO_EMBED_METHOD_PROP_NAME = "AUTO_EMBED_METHOD"
- _DYN_CORR_EXCL_PROP_NAME = "DYN_CORR_EXCL"
- _KERNEL_WIDTH_PROP_NAME = "KERNEL_WIDTH"
_K_HISTORY_PROP_NAME = "k_HISTORY"
_K_TAU_PROP_NAME = "k_TAU"
_L_HISTORY_PROP_NAME = "l_HISTORY"
_L_TAU_PROP_NAME = "l_TAU"
_K_SEARCH_MAX_PROP_NAME = "AUTO_EMBED_K_SEARCH_MAX"
_TAU_SEARCH_MAX_PROP_NAME = "AUTO_EMBED_TAU_SEARCH_MAX"
- _BIAS_CORRECTION = "BIAS_CORRECTION"
- _NORMALISE = "NORMALISE"
- _SEED = "NOISE_SEED"
- _base_class = jp.JPackage("infodynamics.measures.continuous")
+ # Which estimator each optional parameter belongs to. A parameter supplied
+ # to an estimator that ignores it is rejected rather than silently dropped:
+ # accepting kernel_width under estimator="gaussian" told the caller a
+ # kernel width had been applied when nothing used it.
+ _PARAM_OWNER = {
+ "kernel_width": ("kernel",),
+ "prop_k": ("kraskov",),
+ "dyn_corr_excl": ("kraskov",),
+ }
def __init__(
- self, estimator="gaussian", kernel_width=0.5, prop_k=4, dyn_corr_excl=None
+ self, estimator="gaussian", kernel_width=None, prop_k=None, dyn_corr_excl=None
):
+ if estimator not in _ESTIMATORS:
+ raise ValueError(
+ f"Unknown estimator {estimator!r}; expected one of "
+ f"{sorted(_ESTIMATORS)}."
+ )
+
+ supplied = {
+ "kernel_width": kernel_width,
+ "prop_k": prop_k,
+ "dyn_corr_excl": dyn_corr_excl,
+ }
+ for name, value in supplied.items():
+ owners = self._PARAM_OWNER[name]
+ if value is not None and estimator not in owners:
+ raise ValueError(
+ f"{name}={value!r} is not used by estimator={estimator!r} "
+ f"(it applies to {'/'.join(owners)}). Remove it, or select "
+ f"the estimator it belongs to."
+ )
self._estimator = estimator
- self._kernel_width = kernel_width
- self._prop_k = prop_k
+ # Defaults applied after validation so "not supplied" stays
+ # distinguishable from "supplied with the default value".
+ # Validated, not coerced. `float(True)` is 1.0 and `int(2.7)` is 2, so
+ # a permissive cast turns a plainly wrong argument into a plausible one:
+ # a non-positive box-kernel half-width counts only the point itself, so
+ # every log ratio is log(N) and the "estimate" is a constant, and k < 1
+ # has no kth neighbour at all.
+ self._kernel_width = (0.5 if kernel_width is None
+ else utils.require_positive_float("kernel_width",
+ kernel_width))
+ self._prop_k = (4 if prop_k is None
+ else utils.require_int("prop_k", prop_k, minimum=1))
+ if dyn_corr_excl is not None and not (
+ isinstance(dyn_corr_excl, str) and dyn_corr_excl == "AUTO"):
+ # `None` and the exact string "AUTO" are the only non-integer
+ # values; everything else is a Theiler window in samples.
+ if isinstance(dyn_corr_excl, str):
+ raise ValueError(
+ f"dyn_corr_excl must be an integer >= 0, None, or the "
+ f"string 'AUTO'; got {dyn_corr_excl!r}.")
+ dyn_corr_excl = utils.require_int("dyn_corr_excl", dyn_corr_excl,
+ minimum=0)
self._dyn_corr_excl = dyn_corr_excl
self._entropy_calc = self._getcalc("entropy")
self.identifier = self.identifier + "_" + estimator
if estimator == "kraskov":
- self.identifier = self.identifier + "_NN-{}".format(prop_k)
+ # Only the measures with a genuine KSG implementation may accept it.
+ # The composed measures (joint/conditional/crossmap/causal entropy,
+ # directed info, stochastic interaction) are built from marginal
+ # entropies, and _getcalc hands them GaussianEntropyCalculator for
+ # "kraskov" -- so they returned exactly the Gaussian result while
+ # advertising kraskov_NN- in the identifier. Reporting a k-NN
+ # estimate that was never computed is worse than refusing.
+ if not isinstance(self, (MutualInfo, TimeLaggedMutualInfo,
+ TransferEntropy, DirectedInfo)):
+ raise NotImplementedError(
+ f"The kraskov estimator is not implemented for "
+ f"{type(self).__name__}: it is composed from marginal "
+ f"entropies, and no KSG estimator exists for that "
+ f"composition. Use estimator='kozachenko' for a "
+ f"k-nearest-neighbour entropy, or 'gaussian'."
+ )
+ self.identifier = self.identifier + "_NN-{}".format(self._prop_k)
self.labels = self.labels + ["nonlinear"]
elif estimator == "kernel":
- self.identifier = self.identifier + "_W-{}".format(kernel_width)
+ self.identifier = self.identifier + "_W-{}".format(self._kernel_width)
self.labels = self.labels + ["nonlinear"]
elif estimator == "symbolic":
if not isinstance(self, TransferEntropy):
@@ -50,27 +1237,43 @@ def __init__(
self.labels = self.labels + ["symbolic"]
self._dyn_corr_excl = None
return
+ elif estimator == "kozachenko":
+ # Kozachenko-Leonenko estimates *entropy* from k-NN distances. The
+ # measures below are computed directly rather than as a sum of
+ # marginal entropies, and there is no KL path for them: composing
+ # them from separate KL entropies is biased, since the per-space
+ # biases do not cancel (avoiding exactly that is why KSG couples
+ # its radii across spaces -- use estimator="kraskov" instead).
+ # Fail here rather than returning NaN at compute time.
+ if isinstance(self, (MutualInfo, TimeLaggedMutualInfo, TransferEntropy)):
+ raise NotImplementedError(
+ f"The kozachenko estimator is not available for "
+ f"{type(self).__name__}; use estimator='kraskov' for a "
+ f"k-nearest-neighbour estimate of this measure."
+ )
+ # k-NN based, so nonlinear -- not "linear" as gaussian is.
+ self.labels = self.labels + ["nonlinear"]
+ self._dyn_corr_excl = None
else:
self.labels = self.labels + ["linear"]
self._dyn_corr_excl = None
if self._dyn_corr_excl:
- self.identifier = self.identifier + "_DCE"
+ # The *value*, not just the flag. `_DCE` alone gave
+ # dyn_corr_excl=5, =10 and ="AUTO" one identifier between them --
+ # three different Theiler windows, three different numbers, one
+ # name -- so a config setting two of them collided silently.
+ self.identifier = self.identifier + f"_DCE-{self._dyn_corr_excl}"
def __getstate__(self):
state = dict(self.__dict__)
-
unserializable_objects = ["_entropy_calc", "_calc"]
-
for k in unserializable_objects:
if k in state.keys():
del state[k]
-
return state
def __setstate__(self, state):
- """Re-initialise the calculator"""
- # Re-initialise
self.__dict__.update(state)
self._entropy_calc = self._getcalc("entropy")
@@ -81,59 +1284,56 @@ def __deepcopy__(self, memo):
setattr(newone, attr, copy.deepcopy(getattr(self, attr), memo))
return newone
- def _setup(self, calc):
- if self._estimator == "kernel":
- calc.setProperty(self._KERNEL_WIDTH_PROP_NAME, str(self._kernel_width))
- elif self._estimator == "kraskov":
- calc.setProperty(self._NNK_PROP_NAME, str(self._prop_k))
-
- calc.setProperty(self._BIAS_CORRECTION, "false")
- calc.setProperty(self._SEED, "42")
-
- return calc
-
def _getkey(self):
if self._estimator == "kernel":
return (self._estimator, self._kernel_width)
elif self._estimator == "kraskov":
- return (self._estimator, self._prop_k)
+ # dyn_corr_excl is in the key even though the entropy caches this
+ # keys are not consumed by the KSG paths today: the rule is that
+ # everything reaching the identifier reaches the cache key, and an
+ # exception maintained by argument is an exception that stops being
+ # true.
+ return (self._estimator, self._prop_k, self._dyn_corr_excl)
else:
return (self._estimator,)
def _getcalc(self, measure):
- if measure == "entropy":
- if self._estimator == "kernel":
- calc = self._base_class.kernel.EntropyCalculatorMultiVariateKernel()
- elif self._estimator == "kozachenko":
- calc = (
- self._base_class.kozachenko.EntropyCalculatorMultiVariateKozachenko()
- )
- else:
- calc = self._base_class.gaussian.EntropyCalculatorMultiVariateGaussian()
- elif measure == "MutualInfo":
- if self._estimator == "kernel":
- calc = self._base_class.kernel.MutualInfoCalculatorMultiVariateKernel()
- elif self._estimator == "kraskov":
- calc = (
- self._base_class.kraskov.MutualInfoCalculatorMultiVariateKraskov1()
- )
- else:
- calc = (
- self._base_class.gaussian.MutualInfoCalculatorMultiVariateGaussian()
- )
- elif measure == "TransferEntropy":
- if self._estimator == "kernel":
- calc = self._base_class.kernel.TransferEntropyCalculatorKernel()
- elif self._estimator == "kraskov":
- calc = self._base_class.kraskov.TransferEntropyCalculatorKraskov()
- else:
- calc = self._base_class.gaussian.TransferEntropyCalculatorGaussian()
- else:
- raise TypeError(f"Unknown measure: {measure}")
+ est = self._estimator
+
+ # --- Pure-numpy calculators (no JIDT/JVM needed) ---
- return self._setup(calc)
+ if measure == "entropy":
+ if est == 'kozachenko':
+ return KLEntropyCalculator()
+ if est in ('gaussian', 'kraskov'):
+ return GaussianEntropyCalculator()
+ if est == 'kernel':
+ calc = KernelEntropyCalculator()
+ calc.setProperty("KERNEL_WIDTH", str(self._kernel_width))
+ return calc
+ if est == 'symbolic':
+ return None # symbolic TE doesn't use entropy calculator
+
+ if measure == "MutualInfo":
+ if est in ('gaussian', 'kraskov', 'kozachenko'):
+ return GaussianEntropyCalculator() # dummy; multivariate bypasses
+ if est == 'kernel':
+ calc = KernelMICalculator()
+ calc.setProperty("KERNEL_WIDTH", str(self._kernel_width))
+ return calc
+
+ if measure == "TransferEntropy":
+ if est in ('gaussian', 'kraskov', 'kozachenko'):
+ return _DummyTECalculator()
+ if est == 'kernel':
+ calc = KernelTECalculator()
+ calc.setProperty("KERNEL_WIDTH", str(self._kernel_width))
+ return calc
+ if est == 'symbolic':
+ return SymbolicTECalculator()
+
+ raise TypeError(f"Unknown measure/estimator: {measure}/{est}")
- # No Theiler window yet (can it be done?)
@parse_univariate
def _compute_entropy(self, data, i=None):
if not hasattr(data, "entropy"):
@@ -145,17 +1345,18 @@ def _compute_entropy(self, data, i=None):
if data.entropy[key][i] == -np.inf:
x = np.squeeze(data.to_numpy()[i])
+ est = self._estimator
- self._entropy_calc.initialise(1)
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, 1)(x))
-
- data.entropy[key][
- i
- ] = self._entropy_calc.computeAverageLocalOfObservations()
+ if est in ('gaussian', 'kraskov'):
+ data.entropy[key][i] = _gaussian_entropy_from_data(x.reshape(-1, 1))
+ else:
+ # kozachenko, kernel — all have numpy calculators
+ self._entropy_calc.initialise(1)
+ self._entropy_calc.setObservations(x)
+ data.entropy[key][i] = self._entropy_calc.computeAverageLocalOfObservations()
return data.entropy[key][i]
- # No Theiler window is available in the JIDT estimator
@parse_bivariate
def _compute_joint_entropy(self, data, i, j):
if not hasattr(data, "joint_entropy"):
@@ -167,60 +1368,65 @@ def _compute_joint_entropy(self, data, i, j):
if data.joint_entropy[key][i, j] == -np.inf:
x, y = data.to_numpy()[[i, j]]
+ joint = np.concatenate([x, y], axis=1)
+ est = self._estimator
- self._entropy_calc.initialise(2)
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, 2)(np.concatenate([x, y], axis=1)))
+ if est in ('gaussian', 'kraskov'):
+ val = _gaussian_entropy_from_data(joint)
+ else:
+ # kozachenko, kernel — all have numpy calculators
+ self._entropy_calc.initialise(2)
+ self._entropy_calc.setObservations(joint)
+ val = self._entropy_calc.computeAverageLocalOfObservations()
- data.joint_entropy[key][i, j] = self._entropy_calc.computeAverageLocalOfObservations()
- data.joint_entropy[key][j, i] = data.joint_entropy[key][i, j]
+ data.joint_entropy[key][i, j] = val
+ data.joint_entropy[key][j, i] = val
return data.joint_entropy[key][i, j]
- # No Theiler window is available in the JIDT estimator
def _compute_conditional_entropy(self, X, Y):
- XY = np.concatenate([X, Y], axis=1)
-
- self._entropy_calc.initialise(XY.shape[1])
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, XY.ndim)(XY))
-
- H_XY = self._entropy_calc.computeAverageLocalOfObservations()
-
- self._entropy_calc.initialise(Y.shape[1])
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, Y.ndim)(Y))
-
- H_Y = self._entropy_calc.computeAverageLocalOfObservations()
-
- return H_XY - H_Y
-
- def _set_theiler_window(self, data, i, j):
- if self._dyn_corr_excl == "AUTO":
- if not hasattr(data, "theiler"):
+ est = self._estimator
+ if est in ('gaussian', 'kraskov'):
+ XY = np.concatenate([X, Y], axis=1)
+ return _gaussian_entropy_from_data(XY) - _gaussian_entropy_from_data(Y)
+ else:
+ # kozachenko, kernel — all have numpy calculators
+ XY = np.concatenate([X, Y], axis=1)
+ self._entropy_calc.initialise(XY.shape[1])
+ self._entropy_calc.setObservations(XY)
+ H_XY = self._entropy_calc.computeAverageLocalOfObservations()
+ self._entropy_calc.initialise(Y.shape[1])
+ self._entropy_calc.setObservations(Y)
+ H_Y = self._entropy_calc.computeAverageLocalOfObservations()
+ return H_XY - H_Y
+
+ def _resolve_theiler(self, data, i, j):
+ """Theiler/dynamic-correlation-exclusion window for pair (i, j).
+
+ None -> 0 (no window); an integer -> that window; "AUTO" -> the
+ autocorrelation time 2*, cached per dataset.
+ Shared by MI/TLMI/TE; only the kNN (kraskov) paths consume it.
+ """
+ raw_w = getattr(self, '_dyn_corr_excl', None)
+ if raw_w is None:
+ return 0
+ if raw_w == "AUTO":
+ if not hasattr(data, 'theiler'):
z = data.to_numpy()
- theiler_window = -np.ones((data.n_processes, data.n_processes))
-
- # Compute effective sample size for each pair
- for _i in range(data.n_processes):
- targ = z[_i]
- for _j in range(_i + 1, data.n_processes):
- src = z[_j]
-
- # Initialize the Theiler window using Bartlett's formula
- theiler_window[_i, _j] = 2 * np.dot(
- utils.acf(src), utils.acf(targ)
+ M = data.n_processes
+ theiler = -np.ones((M, M))
+ for _i in range(M):
+ for _j in range(_i + 1, M):
+ theiler[_i, _j] = 2 * np.dot(
+ utils.acf(z[_i]), utils.acf(z[_j])
)
- theiler_window[_j, _i] = theiler_window[_i, _j]
- data.theiler = theiler_window
+ theiler[_j, _i] = theiler[_i, _j]
+ data.theiler = theiler
+ return int(data.theiler[i, j])
+ return int(raw_w)
- self._calc.setProperty(
- self._DYN_CORR_EXCL_PROP_NAME, str(int(data.theiler[i, j]))
- )
- elif self._dyn_corr_excl is not None:
- self._calc.setProperty(
- self._DYN_CORR_EXCL_PROP_NAME, str(int(self._dyn_corr_excl))
- )
-
-class JointEntropy(JIDTBase, Undirected):
+class JointEntropy(InfoTheoryBase, Undirected):
name = "Joint entropy"
identifier = "je"
@@ -233,11 +1439,25 @@ def __init__(self, **kwargs):
def bivariate(self, data, i=None, j=None):
return self._compute_joint_entropy(data, i=i, j=j)
+ @parse_multivariate
+ def multivariate(self, data):
+ if self._estimator == 'gaussian':
+ JE = _gaussian_pairwise_joint_entropy(data.to_numpy(squeeze=True))
+ np.fill_diagonal(JE, np.nan)
+ return JE
+ return super().multivariate(data)
+
-class ConditionalEntropy(JIDTBase, Directed):
+class ConditionalEntropy(InfoTheoryBase, Directed):
name = "Conditional entropy"
identifier = "ce"
+ # Directed: H(X|Y) != H(Y|X). Measured on var1_M3_T100 under the default
+ # z-scoring, max|A - A.T| is 0.115 for kozachenko and 0.039 for kernel; the
+ # Gaussian form is symmetric there only because equal marginal variances
+ # make it so, and it becomes asymmetric (0.73) with zscore=False. A
+ # structural label describes the measure, not one estimator under one
+ # preprocessing default.
labels = ["unsigned", "infotheory", "unordered", "directed"]
def __init__(self, **kwargs):
@@ -249,8 +1469,21 @@ def bivariate(self, data, i=None, j=None):
data, i=i
)
-
-class MutualInfo(JIDTBase, Undirected):
+ @parse_multivariate
+ def multivariate(self, data):
+ if self._estimator == 'gaussian':
+ Z = data.to_numpy(squeeze=True)
+ variances = np.var(Z, axis=1, ddof=1)
+ # Marginal entropy uses the same ridge as the scalar path.
+ H_marginal = 0.5 * np.log(2 * np.pi * np.e * (variances + 1e-8 * variances))
+ JE = _gaussian_pairwise_joint_entropy(Z)
+ CE = JE - H_marginal[:, None]
+ np.fill_diagonal(CE, np.nan)
+ return CE
+ return super().multivariate(data)
+
+
+class MutualInfo(InfoTheoryBase, Undirected):
name = "Mutual information"
identifier = "mi"
labels = ["unsigned", "infotheory", "unordered", "undirected"]
@@ -261,29 +1494,55 @@ def __init__(self, **kwargs):
def __setstate__(self, state):
super().__setstate__(state)
- self.__dict__.update(state)
self._calc = self._getcalc("MutualInfo")
@parse_bivariate
def bivariate(self, data, i=None, j=None, verbose=False):
"""Compute mutual information between Y and X"""
- self._set_theiler_window(data, i, j)
- self._calc.initialise(1, 1)
+ if self._estimator in ('gaussian', 'kraskov'):
+ # Handled by multivariate; bivariate fallback
+ z = data.to_numpy(squeeze=True)
+ if self._estimator == 'gaussian':
+ return float(_gaussian_mi_from_r(np.corrcoef(z[i], z[j])[0, 1]))
+ else:
+ # kraskov bivariate
+ k = int(self._prop_k)
+ w = self._resolve_theiler(data, i, j)
+ return _ksg_mi_pair(z[i], z[j], k, w)
- try:
+ # kernel estimator: use numpy KernelMICalculator
+ if self._estimator == 'kernel':
src, targ = data.to_numpy(squeeze=True)[[i, j]]
- self._calc.setObservations(
- jp.JArray(jp.JDouble)(src), jp.JArray(jp.JDouble)(targ)
- )
+ self._calc.initialise(1, 1)
+ self._calc.setObservations(src, targ)
return self._calc.computeAverageLocalOfObservations()
- except:
- logging.warning(
- "MI calcs failed. Maybe check input data for Cholesky factorisation?"
- )
- return np.nan
-
-class TimeLaggedMutualInfo(JIDTBase, Directed):
+ # Fallback should not be reached (all estimators handled above)
+ logging.warning(f"MI bivariate: unhandled estimator '{self._estimator}'")
+ return np.nan
+
+ @parse_multivariate
+ def multivariate(self, data):
+ if self._estimator == 'gaussian':
+ Z = data.to_numpy(squeeze=True)
+ MI = _gaussian_mi_from_r(np.corrcoef(Z))
+ np.fill_diagonal(MI, np.nan)
+ return MI
+ elif self._estimator == 'kraskov':
+ Z = data.to_numpy(squeeze=True)
+ M, N = Z.shape
+ k = int(self._prop_k)
+ result = np.full((M, M), np.nan)
+ for i in range(M):
+ for j in range(i + 1, M):
+ w = self._resolve_theiler(data, i, j)
+ mi = _ksg_mi_pair(Z[i], Z[j], k, w)
+ result[i, j] = result[j, i] = mi
+ return result
+ return super().multivariate(data)
+
+
+class TimeLaggedMutualInfo(InfoTheoryBase, Directed):
name = "Time-lagged mutual information"
identifier = "tlmi"
labels = ["unsigned", "infotheory", "temporal", "directed"]
@@ -293,31 +1552,65 @@ def __init__(self, **kwargs):
self._calc = self._getcalc("MutualInfo")
def __setstate__(self, state):
- """Re-initialise the calculator"""
super().__setstate__(state)
- self.__dict__.update(state)
self._calc = self._getcalc("MutualInfo")
@parse_bivariate
def bivariate(self, data, i=None, j=None, verbose=False):
- self._set_theiler_window(data, i, j)
- self._calc.initialise(1, 1)
- try:
+ if self._estimator in ('gaussian', 'kraskov'):
+ z = data.to_numpy(squeeze=True)
+ src = z[i][:-1]
+ tgt = z[j][1:]
+ if self._estimator == 'gaussian':
+ return float(_gaussian_mi_from_r(np.corrcoef(src, tgt)[0, 1]))
+ else:
+ k = int(self._prop_k)
+ w = self._resolve_theiler(data, i, j)
+ return _ksg_mi_pair(src, tgt, k, w)
+
+ # kernel estimator: use numpy KernelMICalculator
+ if self._estimator == 'kernel':
src, targ = data.to_numpy(squeeze=True)[[i, j]]
src = src[:-1]
targ = targ[1:]
- self._calc.setObservations(
- jp.JArray(jp.JDouble, 1)(src), jp.JArray(jp.JDouble, 1)(targ)
- )
+ self._calc.initialise(1, 1)
+ self._calc.setObservations(src, targ)
return self._calc.computeAverageLocalOfObservations()
- except:
- logging.warning(
- "Time-lagged MI calcs failed. Maybe check input data for Cholesky factorisation?"
- )
- return np.nan
-
-class TransferEntropy(JIDTBase, Directed):
+ logging.warning(f"TLMI bivariate: unhandled estimator '{self._estimator}'")
+ return np.nan
+
+ @parse_multivariate
+ def multivariate(self, data):
+ if self._estimator == 'gaussian':
+ Z = data.to_numpy(squeeze=True)
+ M, T = Z.shape
+ Z_src = Z[:, :-1]
+ Z_tgt = Z[:, 1:]
+ stacked = np.vstack([Z_src, Z_tgt])
+ R = np.corrcoef(stacked)
+ TLMI = _gaussian_mi_from_r(R[:M, M:])
+ np.fill_diagonal(TLMI, np.nan)
+ return TLMI
+ elif self._estimator == 'kraskov':
+ Z = data.to_numpy(squeeze=True)
+ M, T = Z.shape
+ k = int(self._prop_k)
+ Z_src = Z[:, :-1]
+ Z_tgt = Z[:, 1:]
+ result = np.full((M, M), np.nan)
+ for i in range(M):
+ for j in range(M):
+ if i == j:
+ continue
+ w = self._resolve_theiler(data, i, j)
+ mi = _ksg_mi_pair(Z_src[i], Z_tgt[j], k, w)
+ result[i, j] = mi
+ return result
+ return super().multivariate(data)
+
+
+class TransferEntropy(InfoTheoryBase, Directed):
name = "Transfer entropy"
identifier = "te"
@@ -328,35 +1621,133 @@ def __init__(
auto_embed_method=None,
k_search_max=None,
tau_search_max=None,
- k_history=1,
- k_tau=1,
- l_history=1,
- l_tau=1,
+ k_history=None,
+ k_tau=None,
+ l_history=None,
+ l_tau=None,
**kwargs,
):
-
if "estimator" not in kwargs.keys() or kwargs["estimator"] == "gaussian":
self.identifier = "gc"
super().__init__(**kwargs)
+
+ if auto_embed_method is not None and auto_embed_method not in _AUTO_EMBED_METHODS:
+ # The value was previously never inspected: any non-None string
+ # took the auto-embed branch, so a typo silently ran MAX_CORR_AIS.
+ raise ValueError(
+ f"Unknown auto_embed_method {auto_embed_method!r}; implemented: "
+ f"{sorted(_AUTO_EMBED_METHODS)}. Ragwitz local-prediction "
+ f"selection is a different criterion and is not implemented."
+ )
+
+ # Only the two estimators with a genuine embedding implement any of
+ # this. The symbolic and kernel calculators read one history length at
+ # unit delay and nothing else, so every embedding and search argument
+ # is refused rather than accepted and dropped.
+ _EMBEDDING_ONLY = ("gaussian", "kraskov")
+ supplied = {"k_tau": k_tau, "l_history": l_history, "l_tau": l_tau}
+ if self._estimator not in _EMBEDDING_ONLY:
+ for name, value in supplied.items():
+ if value is not None:
+ raise ValueError(
+ f"{name}={value!r} is not used by "
+ f"estimator={self._estimator!r}: it computes a single "
+ f"history length at unit delay, applied to source and "
+ f"destination alike. Set k_history, or use "
+ f"estimator='gaussian'/'kraskov' for independently "
+ f"aligned source and destination embeddings."
+ )
+ for name, value in (("auto_embed_method", auto_embed_method),
+ ("k_search_max", k_search_max),
+ ("tau_search_max", tau_search_max)):
+ if value is not None:
+ raise ValueError(
+ f"{name}={value!r} is not implemented for "
+ f"estimator={self._estimator!r}: there is no active-"
+ f"information-storage criterion for it, so no embedding "
+ f"can be selected. Use a fixed k_history, or "
+ f"estimator='gaussian'/'kraskov'."
+ )
+
+ if auto_embed_method is None:
+ # Search bounds with nothing to search are not a harmless default:
+ # they reached the identifier under the auto branch only, so here
+ # they were accepted and silently discarded.
+ for name, value in (("k_search_max", k_search_max),
+ ("tau_search_max", tau_search_max)):
+ if value is not None:
+ raise ValueError(
+ f"{name}={value!r} requires auto_embed_method to be set; "
+ f"with a fixed embedding there is nothing to search."
+ )
+ else:
+ # A selected embedding and a supplied one cannot both be honoured.
+ # Silently ignoring the supplied value is how the identifier came to
+ # advertise parameters the estimator never used.
+ fixed = {"k_history": k_history, "k_tau": k_tau}
+ if auto_embed_method != _AUTO_EMBED_DEST_ONLY:
+ fixed.update({"l_history": l_history, "l_tau": l_tau})
+ for name, value in fixed.items():
+ if value is not None:
+ raise ValueError(
+ f"{name}={value!r} conflicts with "
+ f"auto_embed_method={auto_embed_method!r}, which selects "
+ f"it. Drop {name}, or drop auto_embed_method."
+ + ("" if auto_embed_method == _AUTO_EMBED_DEST_ONLY else
+ f" ({_AUTO_EMBED_DEST_ONLY} selects the destination "
+ f"embedding only and does accept a fixed source one.)")
+ )
+
+ # Defaults resolved before anything is validated or named, so the
+ # identifier reports what is computed rather than what was typed.
+ k_history = 1 if k_history is None else k_history
+ k_tau = 1 if k_tau is None else k_tau
+ l_history = 1 if l_history is None else l_history
+ l_tau = 1 if l_tau is None else l_tau
+ k_search_max = 10 if k_search_max is None else k_search_max
+ tau_search_max = 4 if tau_search_max is None else tau_search_max
+
+ for name, value in (("k_history", k_history), ("k_tau", k_tau),
+ ("l_history", l_history), ("l_tau", l_tau),
+ ("k_search_max", k_search_max),
+ ("tau_search_max", tau_search_max)):
+ _require_positive_int(name, value)
+
+ if self._estimator == "symbolic" and k_history < 2:
+ # An ordinal pattern of length 1 has exactly one possible symbol, so
+ # every entropy term is zero and TE is identically zero. It is not a
+ # degenerate edge case, it is a guaranteed-null statistic.
+ raise ValueError(
+ "estimator='symbolic' requires k_history >= 2: a length-1 "
+ "ordinal pattern has a single symbol, so the transfer entropy "
+ "is identically zero."
+ )
+
self._calc = self._getcalc("TransferEntropy")
- # Auto-embedding
+ # Store embedding params for numpy path
+ self._auto_embed_method = auto_embed_method
+ self._k_search_max = k_search_max
+ self._tau_search_max = tau_search_max
+ self._k_history = k_history
+ self._k_tau = k_tau
+ self._l_history = l_history
+ self._l_tau = l_tau
+
if auto_embed_method is not None:
self._calc.setProperty(self._AUTO_EMBED_METHOD_PROP_NAME, auto_embed_method)
self._calc.setProperty(self._K_SEARCH_MAX_PROP_NAME, str(k_search_max))
- if self._estimator != "kernel":
- self.identifier = self.identifier + "_k-max-{}_tau-max-{}".format(
- k_search_max, tau_search_max
- )
- self._calc.setProperty(
- self._TAU_SEARCH_MAX_PROP_NAME, str(tau_search_max)
- )
- else:
- self.identifier = self.identifier + "_k-max-{}".format(k_search_max)
- # Set up calculator
+ self._calc.setProperty(self._TAU_SEARCH_MAX_PROP_NAME, str(tau_search_max))
+ # The method is in the identifier because it changes what is
+ # computed: MAX_CORR_AIS selects the source embedding too, and used
+ # to name a search that only ever touched the destination.
+ self.identifier += "_{}_k-max-{}_tau-max-{}".format(
+ auto_embed_method.replace("_", "-"), k_search_max, tau_search_max)
+ if auto_embed_method == _AUTO_EMBED_DEST_ONLY:
+ self.identifier += "_l-{}_lt-{}".format(l_history, l_tau)
else:
self._calc.setProperty(self._K_HISTORY_PROP_NAME, str(k_history))
- if self._estimator != "kernel":
+ if self._estimator in _EMBEDDING_ONLY:
self._calc.setProperty(self._K_TAU_PROP_NAME, str(k_tau))
self._calc.setProperty(self._L_HISTORY_PROP_NAME, str(l_history))
self._calc.setProperty(self._L_TAU_PROP_NAME, str(l_tau))
@@ -364,34 +1755,110 @@ def __init__(
k_history, k_tau, l_history, l_tau
)
else:
+ # One history length, unit delay: the identifier says only what
+ # is computed.
self.identifier = self.identifier + "_k-{}".format(k_history)
def __setstate__(self, state):
- """Re-initialise the calculator"""
- # Re-initialise
super().__setstate__(state)
- self.__dict__.update(state)
self._calc = self._getcalc("TransferEntropy")
+ # Overridable so a test can inject a scorer that deliberately picks
+ # different source and destination embeddings and check that all four
+ # selected values reach the final estimator.
+ _embedding_scorer = staticmethod(_ais_scorer)
+
+ def _selected_embedding(self, data, process, series, scorer, w):
+ """``(dimension, delay)`` for one series, cached on the Data object.
+
+ Selection depends on the series, the estimator, the search bounds and
+ the Theiler window -- not on which pair the series appears in, nor on
+ whether it is the source or the destination there: the criterion is the
+ series' own active information storage either way. With
+ ``dyn_corr_excl="AUTO"`` the window *is* pair-dependent, so it is part
+ of the key; otherwise one search per process serves every pair in both
+ roles, instead of 2*(M-1) identical searches.
+ """
+ key = (process, self._estimator, self._prop_k, w,
+ self._k_search_max, self._tau_search_max)
+ cache = getattr(data, "ais_embedding", None)
+ if cache is None:
+ cache = data.ais_embedding = {}
+ if key not in cache:
+ dim, delay, _ = _select_embedding(
+ series, scorer, self._k_search_max, self._tau_search_max)
+ cache[key] = (dim, delay)
+ return cache[key]
+
@parse_bivariate
def bivariate(self, data, i=None, j=None, verbose=False):
- """
- Compute transfer entropy from i->j
- """
- self._set_theiler_window(data, i, j)
- self._calc.initialise()
+ est = self._estimator
+ auto = self._auto_embed_method
+
src, targ = data.to_numpy(squeeze=True)[[i, j]]
- try:
- self._calc.setObservations(
- jp.JArray(jp.JDouble, 1)(src), jp.JArray(jp.JDouble, 1)(targ)
- )
+
+ # Pure-numpy path for gaussian/kraskov with fixed embedding
+ if est in ('gaussian', 'kraskov') and auto is None:
+ if est == 'gaussian':
+ return _gaussian_te_bivariate(
+ src, targ, self._k_history, self._k_tau,
+ self._l_history, self._l_tau
+ )
+ else:
+ k_nn = int(self._prop_k)
+ w = self._resolve_theiler(data, i, j)
+ return _kraskov_te_bivariate(
+ src, targ, self._k_history, self._k_tau,
+ self._l_history, self._l_tau, k_nn, w
+ )
+
+ # Auto-embedding by the maximum corrected AIS criterion.
+ if est in ('gaussian', 'kraskov') and auto is not None:
+ w = self._resolve_theiler(data, i, j)
+ k_nn = int(self._prop_k) if est == 'kraskov' else None
+ scorer = self._embedding_scorer(est, k_nn, w)
+
+ k_history, k_tau = self._selected_embedding(data, j, targ, scorer, w)
+ if auto == _AUTO_EMBED_DEST_ONLY:
+ l_history, l_tau = self._l_history, self._l_tau
+ else:
+ # Selected from the *source*, independently and by the same
+ # criterion. The previous implementation hard-coded (1, 1) here
+ # while the method name claimed selection for both.
+ l_history, l_tau = self._selected_embedding(data, i, src, scorer, w)
+
+ if est == 'gaussian':
+ return _gaussian_te_bivariate(src, targ, k_history, k_tau,
+ l_history, l_tau)
+ return _kraskov_te_bivariate(src, targ, k_history, k_tau,
+ l_history, l_tau, k_nn, w)
+
+ # kernel/symbolic path: numpy calculators
+ if est in ('kernel', 'symbolic'):
+ self._calc.initialise()
+ self._calc.setObservations(src, targ)
return self._calc.computeAverageLocalOfObservations()
- except Exception as err:
- logging.warning(f"TE calcs failed: {err}.")
- return np.nan
+ logging.warning(f"TE bivariate: unhandled estimator '{est}'")
+ return np.nan
-class CrossmapEntropy(JIDTBase, Directed):
+
+class CrossmapEntropy(InfoTheoryBase, Directed):
+ """H(Y_t | X_{t-1}, ..., X_{t-k+1}) -- a *k*-dimensional joint embedding.
+
+ ``history_length=k`` gives ``k - 1`` source lags, not ``k``: the loop runs
+ ``range(2, k)``, so the joint space [source past, target future] has
+ exactly ``k`` columns. Both readings of the parameter are internally
+ consistent -- "k source lags" would need ``range(2, k + 1)`` -- and the
+ implementation has always been the second one.
+
+ It is documented here rather than changed. Cross-map entropy has no
+ canonical published definition to arbitrate between the two conventions,
+ and silently re-picking one would change every ``xme_*`` value on a guess
+ about intent. The parameter name is the misleading part; the arithmetic is
+ self-consistent, and `test_crossmap_entropy_embedding_dimension` pins it so
+ a future change has to be deliberate.
+ """
name = "Cross-map entropy"
identifier = "xme"
@@ -408,25 +1875,26 @@ def bivariate(self, data, i=None, j=None):
k = self._history_length
targ_future = targ[k:]
src_past = np.expand_dims(src[k - 1 : -1], axis=1)
- for i in range(2, k):
+ for idx in range(2, k):
src_past = np.append(
- src_past, np.expand_dims(src[k - i : -i], axis=1), axis=1
+ src_past, np.expand_dims(src[k - idx : -idx], axis=1), axis=1
)
joint = np.concatenate([src_past, np.expand_dims(targ_future, axis=1)], axis=1)
+ # All estimators now have numpy entropy calculators
self._entropy_calc.initialise(joint.shape[1])
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, 2)(joint))
+ self._entropy_calc.setObservations(joint)
H_xy = self._entropy_calc.computeAverageLocalOfObservations()
self._entropy_calc.initialise(src_past.shape[1])
- self._entropy_calc.setObservations(jp.JArray(jp.JDouble, 2)(src_past))
+ self._entropy_calc.setObservations(src_past)
H_y = self._entropy_calc.computeAverageLocalOfObservations()
return H_xy - H_y
-class CausalEntropy(JIDTBase, Directed):
+class CausalEntropy(InfoTheoryBase, Directed):
name = "Causally conditioned entropy"
identifier = "cce"
@@ -434,23 +1902,21 @@ class CausalEntropy(JIDTBase, Directed):
def __init__(self, n=5, **kwargs):
super().__init__(**kwargs)
- self._n = n
+ self._n = utils.require_int("n", n, minimum=1)
+ # n changes the measure, so it must reach the identifier.
+ self.identifier += f"_n-{self._n}"
def _compute_causal_entropy(self, src, targ):
-
src = np.squeeze(src)
targ = np.squeeze(targ)
- m_utils = jp.JPackage("infodynamics.utils").MatrixUtils
-
causal_entropy = 0
for i in range(1, self._n + 1):
- Yp = m_utils.makeDelayEmbeddingVector(jp.JArray(jp.JDouble, 1)(targ), i - 1)[:-1]
- Xp = m_utils.makeDelayEmbeddingVector(jp.JArray(jp.JDouble, 1)(src), i)
+ Yp = _numpy_delay_embedding(targ, i - 1)[:-1]
+ Xp = _numpy_delay_embedding(src, i)
XYp = np.concatenate([Yp, Xp], axis=1)
-
- Yf = np.expand_dims(targ[i - 1 :], 1)
- causal_entropy = causal_entropy + self._compute_conditional_entropy(Yf, XYp)
+ Yf = np.expand_dims(targ[i - 1:], 1)
+ causal_entropy += self._compute_conditional_entropy(Yf, XYp)
return causal_entropy
def _getkey(self):
@@ -481,36 +1947,87 @@ class DirectedInfo(CausalEntropy, Directed):
labels = ["unsigned", "infotheory", "temporal", "directed"]
def __init__(self, n=5, **kwargs):
- super().__init__(**kwargs)
- self._n = n
-
- def _compute_entropy_rates(self, targ):
+ # n is handled by CausalEntropy; re-appending here doubled the suffix.
+ super().__init__(n=n, **kwargs)
+
+ def _entropy_of(self, M):
+ """Joint entropy of the columns of ``M``; 0 for a zero-column matrix."""
+ if M.shape[1] == 0:
+ return 0.0
+ if self._estimator == "gaussian":
+ return _gaussian_entropy_from_data(M)
+ self._entropy_calc.initialise(M.shape[1])
+ self._entropy_calc.setObservations(M)
+ return self._entropy_calc.computeAverageLocalOfObservations()
- targ = np.squeeze(targ)
- m_utils = jp.JPackage("infodynamics.utils").MatrixUtils
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ r"""Directed information from process ``i`` to process ``j``.
- entropy_rate_sum = 0
- for i in range(1, self._n + 1):
- # Compute entropy for an i-dimensional embedding
- self._entropy_calc.initialise(i)
+ Massey's finite-horizon definition:
- Yi = m_utils.makeDelayEmbeddingVector(jp.JArray(jp.JDouble, 1)(targ), i)
- self._entropy_calc.setObservations(Yi)
- entropy_rate_sum = entropy_rate_sum + self._entropy_calc.computeAverageLocalOfObservations() / i
+ .. math::
+ I(X^n \to Y^n) = \sum_{i=1}^{n} I(X^i; Y_i \mid Y^{i-1})
+ = \sum_{i=1}^{n} \left[ H(Y_i \mid Y^{i-1})
+ - H(Y_i \mid Y^{i-1}, X^i) \right]
- return entropy_rate_sum
+ The previous implementation summed :math:`H(Y^i)/i` and subtracted the
+ causal entropy. That is not the above and is not a dependence measure:
+ with a source statistically independent of the target it returned 0.007
+ at target autocorrelation 0, rising to 1.53 at 0.95 -- it grew with how
+ predictable the *target* was from its own past, with no source coupling
+ present at all.
- @parse_bivariate
- def bivariate(self, data, i=None, j=None):
- """Compute directed information from i to j"""
+ Each conditional entropy is expanded as a difference of joint entropies
+ over one common row window, so the terms telescope correctly and every
+ entropy is estimated on identically aligned samples.
+ """
+ z = data.to_numpy(squeeze=True)
+ src, targ = np.asarray(z[i], float), np.asarray(z[j], float)
+ n = self._n
+ T = targ.size
+ if T <= n + 1:
+ return np.nan
- entropy_rates = self._compute_entropy_rates(data.to_numpy(squeeze=True)[j])
- causal_entropy = super().bivariate(data, i=i, j=j)
+ # One aligned window for every term: rows are t = n .. T-1.
+ y_now = targ[n:].reshape(-1, 1)
+ y_lags = [targ[n - k: T - k].reshape(-1, 1) for k in range(1, n + 1)]
+ # X^i includes the current source sample (same time index as y_i).
+ x_lags = [src[n - k: T - k].reshape(-1, 1) for k in range(0, n)]
+
+ total = 0.0
+ for order in range(1, n + 1):
+ Ypast = np.hstack(y_lags[: order - 1]) if order > 1 else y_now[:, :0]
+ Xpast = np.hstack(x_lags[:order])
+
+ if self._estimator == "kraskov":
+ # Estimate I(X^i; Y_i | Y^{i-1}) directly. The KSG/Frenzel-Pompe
+ # estimator fixes one neighbour radius in the joint space and
+ # reuses it in every marginal count, so the dimension-dependent
+ # biases cancel. Composing the same term from four separate
+ # entropies does not: each is biased in its own dimensionality,
+ # which is why the kernel and kozachenko compositions are unusable
+ # here (kernel sat near +4 on independent data at every T).
+ w = self._resolve_theiler(data, i, j) if self._dyn_corr_excl else 0
+ _validate_ksg_sample(y_now.shape[0], int(self._prop_k), w,
+ context="directed information")
+ total += _ksg_cmi(y_now, Xpast, Ypast, int(self._prop_k), w)
+ continue
+
+ # H(Y_i | Y^{i-1}) - H(Y_i | Y^{i-1}, X^i)
+ h_y_given_ypast = (
+ self._entropy_of(np.hstack([y_now, Ypast])) - self._entropy_of(Ypast)
+ )
+ h_y_given_ypast_x = (
+ self._entropy_of(np.hstack([y_now, Ypast, Xpast]))
+ - self._entropy_of(np.hstack([Ypast, Xpast]))
+ )
+ total += h_y_given_ypast - h_y_given_ypast_x
- return entropy_rates - causal_entropy
+ return total
-class StochasticInteraction(JIDTBase, Undirected):
+class StochasticInteraction(InfoTheoryBase, Undirected):
name = "Stochastic interaction"
identifier = "si"
@@ -548,17 +2065,12 @@ def __init__(self, phitype="star", delay=1, normalization=0):
@parse_bivariate
def bivariate(self, data, i=None, j=None):
- """
- Compute integrated information using a native Python implementation.
- """
try:
from pyspi.lib.phi_native import phi_comp
- # Prepare partition for bivariate analysis
P = np.array([1, 2])
X = data.to_numpy(squeeze=True)[[i, j]]
- # Set up parameters for native implementation
params = {"tau": self._delay}
options = {
"type_of_phi": self._phitype,
diff --git a/pyspi/statistics/misc.py b/pyspi/statistics/misc.py
index b885b49a..ca21fa3c 100644
--- a/pyspi/statistics/misc.py
+++ b/pyspi/statistics/misc.py
@@ -1,13 +1,13 @@
import warnings
-import numpy as np
import inspect
+import numpy as np
from statsmodels.tsa import stattools
from statsmodels.tsa.vector_ar.vecm import coint_johansen
from sklearn.gaussian_process import kernels, GaussianProcessRegressor
from sklearn.metrics import mean_squared_error
from sklearn import linear_model
-import mne.connectivity as mnec
+from mne_connectivity import envelope_correlation
from pyspi.lib.ids.dependence import compute_IDS
from pyspi.base import (
@@ -19,11 +19,45 @@
)
-class Cointegration(Undirected, Unsigned):
+class Cointegration(Directed, Unsigned):
+ """Cointegration test statistics.
+
+ The two methods differ in a way the class previously hid.
+
+ ``johansen`` is symmetric by construction: it tests the rank of a VECM
+ fitted to the pair, and swapping the columns leaves the trace and maximum
+ eigenvalue statistics unchanged (verified to ~3e-14).
+
+ ``aeg`` (augmented Engle-Granger) is *not* symmetric: it regresses the first
+ series on the second and unit-root-tests the residuals, so swapping the
+ arguments changes the residual series and hence the statistic. Measured on
+ random walks the two orientations differ by ~0.8 on average and up to ~1.6,
+ against a statistic that typically ranges from -1 to -3. That is a genuine
+ orientation dependence, not numerical noise.
+
+ Previously the class declared itself ``Undirected`` and the cache wrote each
+ computed value to both ``(i, j)`` and ``(j, i)``, so an asymmetric statistic
+ was reported symmetrically and *which* of the two orientations you got
+ depended on the order in which the pairs happened to be visited.
+
+ The base is now ``Directed``: each orientation is reported as computed. No
+ symmetrisation rule is invented here, because choosing one (min, max, or
+ mean over orientations) is a scientific decision with no settled convention,
+ and silently picking one is what caused the original problem. ``johansen``
+ keeps the cache alias, since for it the two orientations are provably equal.
+ """
name = "Cointegration"
identifier = "coint"
labels = ["misc", "unsigned", "temporal", "undirected", "nonlinear"]
+ _cache_namespace = "coint"
+
+ @property
+ def _cache_subkey(self):
+ # Cache key matches self.key (per cache lookup in _from_cache).
+ if self._method == "johansen":
+ return (self._method, self._det_order, self._k_ar_diff)
+ return (self._method, self._autolag, self._maxlag, self._trend)
def __init__(
self,
@@ -37,6 +71,19 @@ def __init__(
):
self._method = method
self._statistic = statistic
+ # Structural label follows the estimator, not the class. See the class
+ # docstring: johansen is symmetric, aeg is not.
+ if method == "aeg":
+ self.labels = [l for l in self.labels if l != "undirected"] + ["directed"]
+ if statistic == "tstat":
+ # A *signed* Engle-Granger t-statistic: more negative is
+ # stronger evidence of cointegration, and a positive value
+ # means none at all. Reporting it as unsigned made
+ # `Calculator._rmmin` shift the whole column by its minimum and
+ # `set_group` correlate it through `abs()`, both of which treat
+ # the sign as noise when it is the entire finding. The
+ # identifier already says `tstat`; the class now agrees.
+ self.issigned = lambda: True
if method == "johansen":
self.identifier += (
f"_{method}_{statistic}_order-{det_order}_ardiff-{k_ar_diff}"
@@ -92,11 +139,14 @@ def _from_cache(self, data, i, j):
data.coint = {self.key: {idx: ci}}
except KeyError:
data.coint[self.key] = {idx: ci}
- data.coint[self.key][(j, i)] = ci
+ if self._method == "johansen":
+ # Provably orientation-independent, so serving (j, i) from the
+ # same computation is an optimisation, not an assumption. For
+ # aeg it would be exactly the aliasing bug this class had.
+ data.coint[self.key][(j, i)] = ci
return ci
- # Return the negative t-statistic (proxy for how co-integrated they are)
@parse_bivariate
def bivariate(self, data, i=None, j=None, verbose=False):
ci = self._from_cache(data, i, j)
@@ -111,14 +161,15 @@ class LinearModel(Directed, Unsigned):
def __init__(self, model):
self.identifier += f"_{model}"
self._model = getattr(linear_model, model)
+ # Cache whether model accepts random_state (avoids inspect.signature per pair)
+ self._has_random_state = "random_state" in inspect.signature(self._model).parameters
@parse_bivariate
def bivariate(self, data, i=None, j=None):
z = data.to_numpy()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- model_params = inspect.signature(self._model).parameters
- if "random_state" in model_params:
+ if self._has_random_state:
mdl = self._model(random_state=42).fit(z[i], np.ravel(z[j]))
else:
mdl = self._model().fit(z[i], np.ravel(z[j]))
@@ -167,11 +218,10 @@ def __init__(self, orth=False, log=False, absolute=False):
@parse_multivariate
def multivariate(self, data):
z = np.moveaxis(data.to_numpy(), 2, 0)
- adj = np.squeeze(
- mnec.envelope_correlation(
- z, orthogonalize=self._orth, log=self._log, absolute=self._absolute
- )
+ ec = envelope_correlation(
+ z, orthogonalize=self._orth, log=self._log, absolute=self._absolute
)
+ adj = np.squeeze(ec.get_data(output="dense"))
np.fill_diagonal(adj, np.nan)
return adj
@@ -195,7 +245,6 @@ def __init__(
def multivariate(self, data):
# reshape for the compute_IDS function which expects shape (obs, proc)
z = np.squeeze(data.to_numpy(), axis=2).T
- ids = compute_IDS(z, num_terms=self._num_terms, p_norm=self._p_norm,
+ ids = compute_IDS(z, num_terms=self._num_terms, p_norm=self._p_norm,
bandwidth_term=self._bandwidth_term)
return ids
-
\ No newline at end of file
diff --git a/pyspi/statistics/spectral.py b/pyspi/statistics/spectral.py
index a762065d..b6a830fe 100644
--- a/pyspi/statistics/spectral.py
+++ b/pyspi/statistics/spectral.py
@@ -1,4 +1,6 @@
+import logging
import numpy as np
+from contextlib import contextmanager
from copy import deepcopy
import spectral_connectivity as sc # For directed spectral statistics (excl. spectral GC)
@@ -12,6 +14,95 @@
import nitime.analysis as nta
import nitime.timeseries as ts
import warnings
+from pyspi.utils import fmt_param
+
+try:
+ from spectral_connectivity.transforms import prepare_time_series
+except ImportError:
+ def prepare_time_series(time_series, axis="signals"):
+ if axis != "signals":
+ raise ImportError(
+ "spectral_connectivity is missing prepare_time_series; "
+ "upgrade the package or ensure axis='signals'."
+ )
+ return time_series[:, np.newaxis, :]
+
+
+@contextmanager
+def _surface_backend_log_warnings():
+ """Re-emit ``spectral_connectivity``'s log warnings as Python warnings.
+
+ Wilson's factorisation is iterative. When it hits its iteration cap it
+ reports that through ``logging.Logger.warning`` -- "Maximum iterations
+ reached. 0 of 1 converged" -- and then *returns the unconverged factor
+ anyway*. Every Wilson-derived measure (directed coherence, DTF, dDTF, PDC,
+ gPDC, nonparametric spectral GC) is computed from that factor.
+
+ pyspi records per-SPI diagnostics from the ``warnings`` channel only
+ (``_parallel.run_spi``), and logging is a different channel, so an
+ unconverged factorisation reached the results table with nothing recorded
+ against it. On the bundled ``kuramoto_M7_T100`` fixture that is 2 of 21
+ pairs; the relative factorisation residual ``max|S - GG^H| / max|S|`` there
+ runs 0.14-6.0 across pairs, so these are not marginal numbers.
+
+ This bridges the two channels for the duration of one backend call. It does
+ not change any value: the point is that a caller inspecting
+ ``calc.errors``/warnings can now see which pairs the factorisation failed
+ on. Improving the estimate itself (longer series, a parametric VAR fit, or
+ a tighter multitaper configuration) is the user's call, not something pyspi
+ can do behind their back.
+ """
+ records = []
+
+ class _Collect(logging.Handler):
+ def emit(self, record):
+ records.append(record.getMessage())
+
+ handler = _Collect(level=logging.WARNING)
+ backend = logging.getLogger("spectral_connectivity")
+ backend.addHandler(handler)
+ try:
+ yield
+ finally:
+ backend.removeHandler(handler)
+ # dict.fromkeys: one warning per distinct message, not per frequency bin.
+ for message in dict.fromkeys(records):
+ warnings.warn(f"spectral_connectivity: {message}", RuntimeWarning)
+
+
+def _ensure_time_series_3d(z):
+ """Ensure time-series array follows (n_time, n_trials, n_signals)."""
+ if z.ndim == 2:
+ return prepare_time_series(z, axis="signals")
+ return z
+
+
+def _circular_nanmean(angles, axis):
+ """Circular mean angle, omitting NaNs along ``axis``.
+
+ Wrapped phase is averaged through its unit phasors, not as real numbers;
+ e.g. values just below +pi and just above -pi average near pi rather than
+ zero. A zero resultant has no circular mean. An antipodal mean (+/-pi) has
+ a circular location but no unique sign in pyspi's ordinary-float,
+ numerically antisymmetric representation, so both cases return NaN.
+
+ For ``n`` unit phasors, the normalised error of their floating-point sum is
+ O(n * eps). Eight times that bound covers the complex exponential plus the
+ real and imaginary reductions without treating a resolvable resultant as
+ zero. The same bound decides whether an antipodal resultant's imaginary
+ component is distinguishable from round-off.
+ """
+ phasors = np.exp(1j * angles)
+ count = np.sum(~np.isnan(angles), axis=axis)
+ total = np.nansum(phasors, axis=axis)
+ mean = np.full(np.shape(total), np.nan + 0j, dtype=complex)
+ np.divide(total, count, out=mean, where=count > 0)
+
+ roundoff = 8.0 * np.finfo(float).eps * np.maximum(count, 1)
+ zero_resultant = np.abs(mean) <= roundoff
+ antipodal = (mean.real < 0) & (np.abs(mean.imag) <= roundoff)
+ undefined = (count == 0) | zero_resultant | antipodal
+ return np.where(undefined, np.nan, np.angle(mean))
class NonparametricSpectral(Unsigned):
@@ -35,8 +126,37 @@ def __init__(self, fs=1, fmin=0, fmax=None, statistic="mean"):
else:
self._statfn = None
self._statistic = statistic
+
+ # Structural trait, derived from the implementation rather than assumed.
+ # PLI, wPLI, PSI and coherence phase carry a sign that encodes lead/lag:
+ # A[i,j] == -A[j,i]. That is a third category, neither "undirected"
+ # (which implies symmetry) nor "directed" (which implies the two
+ # orientations are independent quantities). Labelling them undirected
+ # misdescribes them for any downstream filtering.
+ #
+ # Only band statistics that are linear in the spectrum preserve
+ # antisymmetry: mean does, max does not (max(-x) != -max(x)), so a
+ # 'max' variant of an antisymmetric measure is genuinely neither and is
+ # left unlabelled here.
+ if getattr(self, "_antisymmetric_spectrum", False):
+ trait = "antisymmetric" if statistic == "mean" else "asymmetric"
+ self.labels = [
+ l for l in self.labels
+ if l not in ("undirected", "directed", "unsigned")
+ ] + [trait, "signed"]
+ # And genuinely signed, not just labelled so. `issigned()` is not
+ # cosmetic: `Calculator._rmmin` subtracts the minimum from every
+ # SPI it reports as unsigned, which on an antisymmetric matrix
+ # shifts A[i,j] and A[j,i] by the same amount and destroys the
+ # antisymmetry that carries the lead/lag; and `set_group`
+ # correlates unsigned SPIs through `abs()`, folding lead onto lag.
+ # True for the `max` variants too: the maximum of a signed
+ # spectrum is still signed.
+ self.issigned = lambda: True
+
paramstr = (
- f"_multitaper_{statistic}_fs-{fs}_fmin-{fmin:.3g}_fmax-{fmax:.3g}".replace(
+ f"_multitaper_{statistic}_fs-{fmt_param(fs)}_fmin-{fmt_param(fmin)}"
+ f"_fmax-{fmt_param(fmax)}".replace(
".", "-"
)
)
@@ -44,10 +164,24 @@ def __init__(self, fs=1, fmin=0, fmax=None, statistic="mean"):
@property
def key(self):
- if isinstance(self, GroupDelay) or isinstance(self, PhaseSlopeIndex):
- return (self.measure, self._fmin, self._fmax)
- else:
- return (self.measure,)
+ """Cache key: every parameter that changes the cached result.
+
+ ``fs`` is part of the key because it is passed to ``sc.Multitaper`` and
+ therefore changes both the connectivity estimate and the frequency grid.
+ Omitting it let two SPIs differing only in sampling frequency collide.
+
+ GroupDelay and PhaseSlopeIndex additionally cache a band-dependent
+ statistic, so their key carries fmin/fmax as well.
+ """
+ base = (self.measure, self._fs)
+ if isinstance(self, (GroupDelay, PhaseSlopeIndex)):
+ return base + (self._fmin, self._fmax)
+ return base
+
+ @property
+ def _freq_key(self):
+ """Frequency grid depends on fs, so it cannot live under a bare 'freq'."""
+ return ("freq", self._fs)
@property
def measure(self):
@@ -60,26 +194,76 @@ def _get_statistic(self, C):
raise NotImplementedError
+def _to_source_target(spi, adj):
+ """Put a spectral adjacency matrix into pyspi's (source, target) convention.
+
+ pyspi's convention is set by ``base.Directed.multivariate``, which fills
+ ``A[i, j] = bivariate(i, j)`` -- row is the source, column the target. The
+ spectral backends (spectral_connectivity, nitime) instead follow the
+ DTF/PDC literature, where element ``[i, j]`` is the flow *into* i *from* j
+ (Kaminski & Blinowska 1991; the library normalises "by inflow"). Passing
+ their output through unchanged left every directed spectral SPI transposed
+ relative to every other directed SPI in the library.
+
+ Only directed SPIs are transposed. The undirected spectral measures share
+ this code path, and several of them (PhaseLagIndex, WeightedPhaseLagIndex,
+ PhaseSlopeIndex) are antisymmetric rather than symmetric, so transposing
+ them would silently negate their values.
+
+ Note the test is ``not isinstance(spi, Undirected)``, not
+ ``isinstance(spi, Directed)``: ``Undirected`` subclasses ``Directed`` (it
+ reuses its multivariate loop and then mirrors), so the latter is true for
+ every SPI here.
+ """
+ return adj if isinstance(spi, Undirected) else adj.T
+
+
class NonparametricSpectralMultivariate(NonparametricSpectral):
- def _get_cache(self, data):
- try:
- res = data.spectral_mv[self.key]
- freq = data.spectral_mv["freq"]
- except (AttributeError, KeyError):
- z = np.transpose(data.to_numpy(squeeze=True))
- m = sc.Multitaper(z, sampling_frequency=self._fs)
- conn = sc.Connectivity.from_multitaper(m)
- try:
- res = getattr(conn, self.measure)()
- except TypeError:
- res = self._get_statistic(conn)
+ _cache_namespace = "spectral_mv"
- freq = conn.frequencies
- try:
- data.spectral_mv[self.key] = res
- except AttributeError:
- data.spectral_mv = {"freq": freq, self.measure: res}
+ @property
+ def _cache_subkey(self):
+ # Default: one cache entry per (class, fs). GroupDelay and
+ # PhaseSlopeIndex override this — their cache also keys on fmin/fmax.
+ # fs mirrors `key`: SPIs at different sampling frequencies do not share
+ # a cache entry, so they must not share an amortization bucket either.
+ return (type(self).__name__, self._fs)
+ def _get_cache(self, data):
+ # One key type throughout. The previous version created the dict with a
+ # *string* key (self.measure) but read with a *tuple* key (self.key), so
+ # the first write was unreachable, the second call recomputed and stored
+ # under the tuple, and only from the third call did the cache hit --
+ # which is why a two-call probe showed no problem.
+ cache = getattr(data, "spectral_mv", None)
+ if cache is None:
+ cache = data.spectral_mv = {}
+
+ if self.key in cache:
+ return cache[self.key], cache[self._freq_key]
+
+ z = np.transpose(data.to_numpy(squeeze=True))
+ z = _ensure_time_series_3d(z)
+ m = sc.Multitaper(z, sampling_frequency=self._fs)
+ conn = sc.Connectivity.from_multitaper(m)
+ # `_recompute` lets a subclass override a backend measure outright, not
+ # just when the backend raises. DirectedCoherence needs this: the
+ # backend's method returns fine, it is simply unbounded.
+ # The Connectivity object is lazy, so the factorisation happens inside
+ # this block, not at construction -- which is why the bridge wraps the
+ # measure extraction rather than `from_multitaper`.
+ with _surface_backend_log_warnings():
+ if getattr(self, "_recompute", False):
+ res = self._get_statistic(conn)
+ else:
+ try:
+ res = getattr(conn, self.measure)()
+ except TypeError:
+ res = self._get_statistic(conn)
+
+ freq = conn.frequencies
+ cache[self.key] = res
+ cache[self._freq_key] = freq
return res, freq
@parse_multivariate
@@ -98,40 +282,78 @@ def multivariate(self, data):
if self._statistic == s
][0]
adj = adj_freq[stat_id][0]
+ adj = _to_source_target(self, adj)
np.fill_diagonal(adj, np.nan)
return adj
class NonparametricSpectralBivariate(NonparametricSpectral):
+ _cache_namespace = "spectral_bv"
+
+ @property
+ def _cache_subkey(self):
+ # Per-pair Connectivity is shared across classes, but the per-class
+ # measure extraction (DTF, dDTF, dCoh, etc.) dominates in practice
+ # (~10s per class at M=16,T=800 vs <1s for the shared Multitaper).
+ # Bucket amortization by class so variants of one measure share, but
+ # different measures don't get cross-amortized. fs mirrors `key`.
+ return (type(self).__name__, self._fs)
+
def _get_cache(self, data, i, j):
- key = (self.measure, i, j)
- try:
- res = data.spectral_bv[key]
- freq = data.spectral_bv["freq"]
- except (KeyError, AttributeError):
+ """Cache Connectivity object per (i,j) pair, not per (measure,i,j).
+
+ Multiple directed spectral SPIs (DirectedCoherence, PartialDirectedCoherence,
+ etc.) share the same Multitaper+Connectivity for a given (i,j). The expensive
+ part is building the Multitaper — each measure extraction is cheap.
+ """
+ # fs belongs in both keys: it is passed to Multitaper, so it changes the
+ # Connectivity object and the frequency grid as well as the measure.
+ measure_key = (self.measure, self._fs, i, j)
+ cache = getattr(data, "spectral_bv", None)
+ if cache is None:
+ cache = data.spectral_bv = {}
+
+ if measure_key in cache:
+ return cache[measure_key], cache[self._freq_key]
+
+ conn_key = (self._fs, i, j)
+ conns = getattr(data, "_spectral_bv_conn", None)
+ if conns is None:
+ conns = data._spectral_bv_conn = {}
+ conn = conns.get(conn_key)
+ if conn is None:
z = np.transpose(data.to_numpy(squeeze=True)[[i, j]])
+ z = _ensure_time_series_3d(z)
m = sc.Multitaper(z, sampling_frequency=self._fs)
conn = sc.Connectivity.from_multitaper(m)
- try:
- res = getattr(conn, self.measure)()
- except TypeError:
- res = self._get_statistic(conn)
-
- freq = conn.frequencies
- try:
- data.spectral_bv[key] = res
- except AttributeError:
- data.spectral_bv = {"freq": freq, key: res}
+ conns[conn_key] = conn
+ # `_recompute` lets a subclass override a backend measure outright, not
+ # just when the backend raises. DirectedCoherence needs this: the
+ # backend's method returns fine, it is simply unbounded.
+ with _surface_backend_log_warnings():
+ if getattr(self, "_recompute", False):
+ res = self._get_statistic(conn)
+ else:
+ try:
+ res = getattr(conn, self.measure)()
+ except TypeError:
+ res = self._get_statistic(conn)
+
+ freq = conn.frequencies
+ cache[measure_key] = res
+ cache[self._freq_key] = freq
return res, freq
@parse_bivariate
def bivariate(self, data, i=None, j=None):
- """TODO: cache this result"""
bv_freq, freq = self._get_cache(data, i, j)
freq_id = np.where((freq > self._fmin) * (freq < self._fmax))[0]
- return self._statfn(bv_freq[0, freq_id, 0, 1])
+ # [1, 0] not [0, 1]: the sub-system is built as [i, j] (local 0 = i,
+ # local 1 = j) and the backend indexes [target, source], so the i->j
+ # flow is at [1, 0]. See _to_source_target.
+ return self._statfn(bv_freq[0, freq_id, 1, 0])
class CoherenceMagnitude(NonparametricSpectralMultivariate, Undirected):
@@ -145,14 +367,37 @@ def __init__(self, **kwargs):
class CoherencePhase(NonparametricSpectralMultivariate, Undirected):
+ # Antisymmetric in (i, j): phase difference: phi(i,j) = -phi(j,i).
+ _antisymmetric_spectrum = True
name = "Coherence phase"
- labels = ["unsigned", "spectral", "undirected"]
+ labels = ["signed", "spectral", "antisymmetric"]
def __init__(self, **kwargs):
+ if kwargs.get("statistic", "mean") != "mean":
+ raise ValueError(
+ "CoherencePhase supports only statistic='mean'. Wrapped phase "
+ "has no branch-cut-independent ordinary maximum; use a "
+ "different circular summary with an explicit interpretation."
+ )
self.identifier = "phase"
super().__init__(**kwargs)
+ self.labels = ["signed", "spectral", "antisymmetric"]
self._measure = "coherence_phase"
+ @parse_multivariate
+ def multivariate(self, data):
+ adj_freq, freq = self._get_cache(data)
+ freq_id = np.where((freq >= self._fmin) * (freq <= self._fmax))[0]
+ adj = _circular_nanmean(adj_freq[0, freq_id, :, :], axis=0)
+
+ # Coherency phase is antisymmetric. Select one computed orientation and
+ # construct its opposite explicitly so branch-cut representations at
+ # +/-pi and backend round-off cannot violate the declared structure.
+ ui = np.triu_indices(data.n_processes, 1)
+ adj[ui] = -adj.T[ui]
+ np.fill_diagonal(adj, np.nan)
+ return adj
+
class ImaginaryCoherence(NonparametricSpectralMultivariate, Undirected):
name = "Imaginary coherence"
@@ -178,6 +423,8 @@ def __init__(self, **kwargs):
class PhaseLagIndex(NonparametricSpectralMultivariate, Undirected):
+ # Antisymmetric in (i, j): sign of the imaginary coherency.
+ _antisymmetric_spectrum = True
name = "Phase lag index"
labels = ["unsigned", "spectral", "undirected"]
@@ -188,6 +435,8 @@ def __init__(self, **kwargs):
class WeightedPhaseLagIndex(NonparametricSpectralMultivariate, Undirected):
+ # Antisymmetric in (i, j): imaginary-coherency weighted sign.
+ _antisymmetric_spectrum = True
name = "Weighted phase lag index"
labels = ["unsigned", "spectral", "undirected"]
@@ -236,6 +485,55 @@ def __init__(self, **kwargs):
class DirectedCoherence(NonparametricSpectralBivariate, Directed):
+ """Directed coherence (Baccala et al. 1998).
+
+ ``DC_ij(f) = sqrt(sigma_jj) |H_ij(f)| / sqrt(sum_k sigma_kk |H_ik(f)|^2)``,
+ where ``H`` is the transfer function in the backend's ``[target, source]``
+ convention and ``sigma_kk`` is the innovation variance of process ``k``.
+ Bounded in [0, 1], with ``sum_j DC_ij^2 == 1`` by construction.
+
+ Two things are wrong with the backend's ``directed_coherence()``:
+
+ 1. It puts the *squared* magnitude in the numerator while the denominator
+ stays on the magnitude scale, so the ratio is dimensionally |H|^2 / |H|
+ and unbounded -- the shipped baselines reached 3.27 (VAR), 1.84 (CML)
+ and 1139.47 (Kuramoto).
+ 2. ``_get_noise_variance`` reshapes ``diag(Sigma)`` to ``(..., 1, n, 1)``,
+ which broadcasts the variance along the *row* (target) axis of ``H``.
+ Baccala's weight is indexed by the *source*. With the weight on the row
+ it factors out of numerator and denominator alike and cancels exactly,
+ so the innovation variances have no effect at all: the measure silently
+ degenerates to ``sqrt(directed_transfer_function())`` for *every* noise
+ covariance, not just the equal-variance case. Verified: with innovation
+ standard deviations (1, 3, 0.2) the row-indexed form still reproduces
+ sqrt(DTF) to 4e-16.
+
+ Both are corrected here by recomputing from the same transfer function with
+ ``|H|`` in the numerator and the variance broadcast along the source axis.
+ The equal-variance identity ``DC == sqrt(DTF)`` now *discriminates*: it
+ holds only when the innovation variances are in fact equal.
+
+ Correlated innovations
+ ----------------------
+ Baccala's formula uses only ``diag(Sigma)``. Two properties are unaffected
+ by off-diagonal innovation covariance: the value stays in [0, 1] (the
+ denominator contains the numerator's term), and ``sum_j DC_ij^2 == 1``
+ holds identically. What does *not* survive is the reading of ``DC_ij^2`` as
+ the fraction of process ``i``'s spectral power arriving from ``j``: that
+ requires ``S_ii = sum_j sigma_jj |H_ij|^2``, which holds only for diagonal
+ ``Sigma``. This is not academic -- the Wilson-estimated innovation
+ correlation on the bundled fixtures reaches 0.14 (VAR), 0.64 (CML) and
+ 1.00 (Kuramoto).
+
+ Whitening is *not* applied. The minimum-phase factor ``G = H L`` with
+ ``L = g_0`` triangular would give an exactly power-decomposing variant, but
+ a triangular factor is order-dependent: recomputing the same pair as
+ ``[j, i]`` yields a different ``L`` (measured, not permutation-related), so
+ a pairwise SPI built on it would depend on process order -- the defect that
+ made ``coint_aeg`` wrong. Baccala's published diagonal form is order-free,
+ so it is what is computed, with the assumption stated rather than hidden.
+ """
+
name = "Directed coherence"
labels = ["unsigned", "spectral", "directed"]
@@ -243,6 +541,19 @@ def __init__(self, **kwargs):
self.identifier = "dcoh"
super().__init__(**kwargs)
self._measure = "directed_coherence"
+ self._recompute = True
+
+ def _get_statistic(self, C):
+ # Deliberately does not use the backend's _get_noise_variance /
+ # _total_inflow helpers: the first carries the source/target axis bug
+ # described above, and doing the algebra here keeps the private-API
+ # surface down to the two properties (see test_backend_private_api).
+ H = C._transfer_function # (..., f, target, source)
+ sigma = np.diagonal(C._noise_covariance, axis1=-1, axis2=-2) # (..., n)
+ sigma = sigma[..., np.newaxis, np.newaxis, :] # broadcast along `source`
+ mag2 = np.abs(H) ** 2
+ inflow = np.sqrt(np.sum(sigma * mag2, axis=-1, keepdims=True))
+ return np.sqrt(sigma) * np.abs(H) / inflow
class PartialDirectedCoherence(NonparametricSpectralBivariate, Directed):
@@ -286,6 +597,8 @@ def __init__(self, **kwargs):
class PhaseSlopeIndex(NonparametricSpectralMultivariate, Undirected):
+ # Antisymmetric in (i, j): slope of the phase spectrum.
+ _antisymmetric_spectrum = True
name = "Phase slope index"
labels = ["unsigned", "spectral", "undirected"]
@@ -294,6 +607,11 @@ def __init__(self, **kwargs):
super().__init__(**kwargs)
self._measure = "phase_slope_index"
+ @property
+ def _cache_subkey(self):
+ # Narrower cache: (class, fs, fmin, fmax) per the key property override.
+ return (type(self).__name__, self._fs, self._fmin, self._fmax)
+
def _get_statistic(self, C):
return C.phase_slope_index(
frequencies_of_interest=[self._fmin, self._fmax],
@@ -301,6 +619,36 @@ def _get_statistic(self, C):
)
+def _independent_significant_frequencies(p_values, step, alpha=0.05,
+ min_group_size=3):
+ """Indices of the largest significant coherence cluster, thinned to `step`.
+
+ Benjamini-Hochberg over the in-band frequencies, then the longest
+ contiguous run of significant points, then every `step`-th point of that
+ run so the regression is not fitted to correlated estimates. Returns None
+ if fewer than `min_group_size` independent points survive -- the phase
+ slope is not identifiable from one or two points, and a two-point "fit" has
+ an r-value of exactly 1 whatever the data.
+ """
+ n = p_values.size
+ order = np.argsort(p_values)
+ ranked = p_values[order]
+ passed = ranked <= alpha * np.arange(1, n + 1) / n
+ significant = np.zeros(n, dtype=bool)
+ if passed.any():
+ significant[order[: np.flatnonzero(passed)[-1] + 1]] = True
+ if not significant.any():
+ return None
+
+ # Longest contiguous run of True.
+ padded = np.concatenate([[False], significant, [False]])
+ edges = np.flatnonzero(padded[1:] != padded[:-1])
+ starts, ends = edges[::2], edges[1::2]
+ longest = np.argmax(ends - starts)
+ keep = np.arange(starts[longest], ends[longest], step)
+ return keep if keep.size >= min_group_size else None
+
+
class GroupDelay(NonparametricSpectralMultivariate, Directed):
name = "Group delay"
labels = ["unsigned", "spectral", "directed", "lagged"]
@@ -309,12 +657,120 @@ def __init__(self, **kwargs):
self.identifier = "gd"
super().__init__(**kwargs)
self._measure = "group_delay"
+ # `delay` and `slope` are antisymmetric by construction (the phase of
+ # C_ji is the negative of the phase of C_ij, so the fitted slopes are
+ # exact negatives) and signed -- a negative delay means the row lags
+ # the column. `rvalue` is the fit quality and is symmetric.
+ if self._statistic in ("delay", "slope"):
+ self.labels = [
+ l for l in self.labels
+ if l not in ("undirected", "directed", "unsigned")
+ ] + ["antisymmetric", "signed"]
+ self.issigned = lambda: True
+ else:
+ # |r| is symmetric by construction and non-negative, so it is
+ # neither directed nor signed.
+ self.labels = [
+ l for l in self.labels if l not in ("directed", "antisymmetric")
+ ] + ["undirected"]
+ # Always use `_get_statistic`. The dispatcher's fallback is
+ # `except TypeError`, and `Connectivity.group_delay()` accepts a
+ # no-argument call, so the backend's (all-NaN, see below) result was
+ # returned without the band arguments ever being passed.
+ self._recompute = True
+
+ @property
+ def _cache_subkey(self):
+ # Narrower cache: (class, fs, fmin, fmax) per the key property override.
+ return (type(self).__name__, self._fs, self._fmin, self._fmax)
def _get_statistic(self, C):
- return C.group_delay(
- frequencies_of_interest=[self._fmin, self._fmax],
- frequency_resolution=(self._fmax - self._fmin) / 50,
- )
+ """Gotman (1983) group delay, computed here rather than in the backend.
+
+ ``spectral_connectivity.Connectivity.group_delay`` returns all-NaN for
+ every input, on every dataset, at every length. Not a power problem --
+ the defect is one line in its significance test.
+ ``coherence_fisher_z_transform`` divides by
+ ``sqrt(coherence_bias(n_obs1) + coherence_bias(n_obs2))`` and, in the
+ one-sample case, is called with ``n_obs2 = 0``. ``coherence_bias(0)`` is
+ ``1 / (2*0 - 2) = -0.5``, so the argument of the square root is
+ ``1/(2n-2) - 0.5``, negative for every ``n``: every p-value is NaN,
+ nothing is ever significant, the phase regression runs on a fully
+ masked array, and the result is NaN. Confirmed at 5, 11, 19 and 39
+ tapers, T up to 4000, on a pair with median coherence 0.998.
+
+ The one-sample test is the standard Enochson-Goodman/Bokil form: for
+ coherence estimated from ``n`` independent spectral estimates,
+ ``arctanh|C|`` is approximately normal with mean ``arctanh|Gamma|`` plus
+ a bias ``b = 1/(2n - 2)`` and *variance* ``b``, so the null z-score is
+ ``(arctanh|C| - b) / sqrt(b)``. That is what the second ``coherence_bias``
+ term was meant to be and is what is used here.
+
+ The rest follows the backend's own recipe: Benjamini-Hochberg over the
+ in-band frequencies, keep the largest contiguous significant run,
+ subsample it to statistically independent points, require at least
+ three, and regress the unwrapped coherence phase on frequency.
+ ``fs * slope / (2*pi)`` is the delay in samples; the unscaled
+ ``slope/(2*pi)`` would be in seconds, because the regression runs
+ against physical frequency. ``rvalue`` is |r|, which is orientation-
+ free; the signed r is not, since phase(C_ji) = -phase(C_ij).
+ """
+ from scipy import stats
+
+ coherency = np.asarray(C.coherency())
+ freqs = np.asarray(C.frequencies)
+ in_band = (freqs >= self._fmin) & (freqs <= self._fmax)
+ f = freqs[in_band]
+ coh = coherency[:, in_band]
+ n_trials, _, M, _ = coh.shape
+
+ # Independent frequency spacing, as the backend defines it: the band is
+ # resolved into 50 pieces and points closer than that are not
+ # independent estimates.
+ df = float(freqs[1] - freqs[0])
+ resolution = (self._fmax - self._fmin) / 50
+ step = max(1, int(np.ceil(resolution / df)))
+
+ bias = 1.0 / (2 * C.n_observations - 2)
+ magnitude = np.minimum(np.abs(coh), 1 - np.finfo(float).eps)
+ z = (np.arctanh(magnitude) - bias) / np.sqrt(bias)
+ p_values = stats.norm.sf(z)
+ phase = np.unwrap(np.angle(coh), axis=1)
+
+ slope = np.full((n_trials, M, M), np.nan)
+ r_value = np.full((n_trials, M, M), np.nan)
+ for t in range(n_trials):
+ for i in range(M):
+ for j in range(i + 1, M):
+ keep = _independent_significant_frequencies(
+ p_values[t, :, i, j], step
+ )
+ if keep is None:
+ continue
+ fit = stats.linregress(f[keep], phase[t, keep, i, j])
+ # Sign fixed by measurement, not by assumption: with
+ # y(t) = x(t - L), this orientation makes the delay
+ # +L at [source, target] after `_to_source_target`, i.e.
+ # positive means the row leads the column. Verified for
+ # L in {1, 3, 5, 8} to within 0.005 samples.
+ slope[t, i, j] = -fit.slope
+ slope[t, j, i] = fit.slope
+ # |r|, not r. The fit is of the phase of C_ij, and
+ # phase(C_ji) = -phase(C_ij), so the signed correlation
+ # flips with the orientation -- while this matrix is
+ # written symmetrically. Reversing the process order
+ # therefore turned +0.99997 into -0.99997 at the mirrored
+ # position, for a statistic declared symmetric. The
+ # magnitude is what "fit quality" means here, and it is
+ # orientation-free.
+ r_value[t, i, j] = r_value[t, j, i] = abs(fit.rvalue)
+
+ # Samples, not seconds. `C.frequencies` is in Hz, so the fitted slope
+ # is radians per Hz and slope/(2*pi) is a delay in *seconds*: at fs=4 a
+ # true 4-sample lag came back as 0.9999. The API and every other lagged
+ # SPI in pyspi count samples, so the delay is scaled by fs. No change at
+ # the shipped fs=1.
+ return self._fs * slope / (2 * np.pi), slope, r_value
class SpectralGrangerCausality(NonparametricSpectralMultivariate, Directed, Unsigned):
@@ -358,25 +814,38 @@ def __init__(
raise NameError(f"Unknown statistic {statistic}")
self._method = method
+ # `self._fmin`, not the `fmin` argument. Spectral GC is undefined at
+ # zero frequency, so `fmin=0` is overridden to 1e-5 above -- but the
+ # identifier was built from the argument, so twelve shipped SPIs said
+ # `fmin-0` while integrating from 1e-5, i.e. excluding the DC bin. Two
+ # configs differing only in `fmin: 0` versus `fmin: 1e-5` would have
+ # produced two identifiers for one computation.
if self._method == "nonparametric":
self._measure = "pairwise_spectral_granger_prediction"
- paramstr = f"_nonparametric_{statistic}_fs-{fs}_fmin-{fmin:.3g}_fmax-{fmax:.3g}".replace(
+ paramstr = (f"_nonparametric_{statistic}_fs-{fmt_param(fs)}"
+ f"_fmin-{fmt_param(self._fmin)}"
+ f"_fmax-{fmt_param(fmax)}").replace(
".", "-"
)
else:
self._order = order
self._max_order = max_order
- paramstr = f"_parametric_{statistic}_fs-{fs}_fmin-{fmin:.3g}_fmax-{fmax:.3g}_order-{order}".replace(
+ paramstr = (f"_parametric_{statistic}_fs-{fmt_param(fs)}"
+ f"_fmin-{fmt_param(self._fmin)}"
+ f"_fmax-{fmt_param(fmax)}_order-{order}").replace(
".", "-"
)
self.identifier = self.identifier + paramstr
def _getkey(self):
+ # fs is passed to the spectral transform, so it must key the cache.
+ # Without it, reusing one Data at another sampling frequency returned
+ # the first computation (0.2037 for fs=1 vs 0.2497 fresh at fs=4).
if self._method == "nonparametric":
- return (self._method, -1, -1)
+ return (self._method, self._fs, -1, -1)
else:
- return (self._method, self._order, self._max_order)
+ return (self._method, self._fs, self._order, self._max_order)
def _get_cache(self, data):
key = self._getkey()
@@ -390,18 +859,51 @@ def _get_cache(self, data):
F, freq = super()._get_cache(data)
else:
z = data.to_numpy(squeeze=True)
- time_series = ts.TimeSeries(z, sampling_interval=1)
+ # 1/fs, not 1. `fs` is in this SPI's identifier and in its
+ # cache key, but the parametric branch hard-coded a unit
+ # sampling interval, so `GA.frequencies` came back in units of
+ # 1/1 whatever `fs` said and the [fmin, fmax] band was applied
+ # on the wrong axis. Two SPIs differing only in `fs` therefore
+ # advertised different sampling rates and computed the same
+ # numbers. No effect at the shipped fs=1.
+ time_series = ts.TimeSeries(z, sampling_interval=1.0 / self._fs)
GA = nta.GrangerAnalyzer(
time_series, order=self._order, max_order=self._max_order
)
triu_id = np.triu_indices(data.n_processes)
- F = np.full(GA.causality_xy.shape, np.nan)
- F[triu_id[0], triu_id[1], :] = GA.causality_xy[
+ try:
+ causality_xy = GA.causality_xy
+ causality_yx = GA.causality_yx
+ except ValueError as err:
+ # nitime's order search walks the lag up to `max_order` and
+ # raises if the information criterion never turns over.
+ # That is the signature of over-fitting at this record
+ # length, not of a transient numerical problem, and its own
+ # message ("Model estimation order did not converge at
+ # max_order = 50") says nothing about the data. Relaying it
+ # as an all-NaN return buried the cause under pyspi's
+ # generic "returned no finite off-diagonal values".
+ if "did not converge" not in str(err):
+ raise
+ raise ValueError(
+ f"Parametric spectral Granger causality: automatic AR "
+ f"order selection did not converge at "
+ f"max_order={self._max_order} on "
+ f"{data.n_observations} observations -- the "
+ f"information criterion improved all the way to the "
+ f"cap, which at this length means the model is "
+ f"over-fitting rather than that the true order is "
+ f"high. Set an explicit `order`, reduce `max_order`, "
+ f"or use a longer series."
+ ) from err
+
+ F = np.full(causality_xy.shape, np.nan)
+ F[triu_id[0], triu_id[1], :] = causality_xy[
triu_id[0], triu_id[1], :
]
- F[triu_id[1], triu_id[0], :] = GA.causality_yx[
+ F[triu_id[1], triu_id[0], :] = causality_yx[
triu_id[0], triu_id[1], :
]
@@ -420,9 +922,16 @@ def multivariate(self, data):
cache, freq = self._get_cache(data)
freq_id = np.where((freq >= self._fmin) * (freq <= self._fmax))[0]
- result = self._statfn(cache[0, freq_id, :, :], axis=0)
+ result = _to_source_target(self, self._statfn(cache[0, freq_id, :, :], axis=0))
- nan_pct = np.isnan(cache[0, freq_id, :, :]).mean(axis=0)
+ # Transformed the same way as the values it masks. The result is
+ # put into pyspi's (source, target) orientation while the mask was
+ # left in the backend's, so a directionally asymmetric NaN pattern
+ # blanked the *mirror* of the affected pair: the cell that was
+ # actually unestimable was already NaN, and a perfectly good one
+ # next to it was destroyed.
+ nan_pct = _to_source_target(
+ self, np.isnan(cache[0, freq_id, :, :]).mean(axis=0))
np.fill_diagonal(nan_pct, 0.0)
isna = nan_pct > self.nan_threshold
@@ -436,5 +945,8 @@ def multivariate(self, data):
return result
except ValueError as err:
- warnings.warn(err)
- return np.full((data.n_processes, data.n_processes), np.nan)
+ # Not swallowed into an all-NaN table: a ValueError here means the
+ # model could not be fitted, which the caller needs in
+ # `Calculator.errors` with its cause attached, not as a silent
+ # empty column plus a warning.
+ raise
diff --git a/pyspi/statistics/wavelet.py b/pyspi/statistics/wavelet.py
index b73eb819..796f32ef 100644
--- a/pyspi/statistics/wavelet.py
+++ b/pyspi/statistics/wavelet.py
@@ -1,4 +1,4 @@
-import mne.connectivity as mnec
+from mne_connectivity import spectral_connectivity_epochs, phase_slope_index
from pyspi.base import (
Directed,
Undirected,
@@ -8,7 +8,8 @@
)
import numpy as np
import warnings
-from functools import partial
+from pyspi.utils import fmt_param
+from pyspi.statistics.spectral import _circular_nanmean
class mne(Unsigned):
@@ -31,7 +32,8 @@ def __init__(self, fs=1, fmin=0, fmax=None, statistic="mean"):
self._statistic = statistic
paramstr = (
- f"_wavelet_{statistic}_fs-{fs}_fmin-{fmin:.3g}_fmax-{fmax:.3g}".replace(
+ f"_wavelet_{statistic}_fs-{fmt_param(fs)}_fmin-{fmt_param(fmin)}"
+ f"_fmax-{fmt_param(fmax)}".replace(
".", "-"
)
)
@@ -46,13 +48,13 @@ def measure(self):
def _get_cache(self, data):
try:
- conn, freq = data.mne[self.measure]
+ conn, freq = data.mne[(self.measure, self._fs)]
except (KeyError, AttributeError):
z = np.moveaxis(data.to_numpy(), 2, 0)
cwt_freqs = np.linspace(0.2, 0.5, 125)
cwt_n_cycles = cwt_freqs / 7.0
- conn, freq, _, _, _ = mnec.spectral_connectivity(
+ con = spectral_connectivity_epochs(
data=z,
method=self.measure,
mode="cwt_morlet",
@@ -62,13 +64,15 @@ def _get_cache(self, data):
fmax=self._fs / 2,
cwt_freqs=cwt_freqs,
cwt_n_cycles=cwt_n_cycles,
- verbose="WARNING",
+ verbose=False,
)
+ conn = con.get_data(output="dense")
+ freq = np.asarray(con.freqs)
try:
- data.mne[self.measure] = (conn, freq)
+ data.mne[(self.measure, self._fs)] = (conn, freq)
except AttributeError:
- data.mne = {self.measure: (conn, freq)}
+ data.mne = {(self.measure, self._fs): (conn, freq)}
freq_id = np.where((freq >= self._fmin) * (freq <= self._fmax))[0]
@@ -87,13 +91,6 @@ def multivariate(self, data):
return adj
-def modify_stats(statfn, modifier):
- def parsed_stats(stats, statfn, modifier, **kwargs):
- return statfn(modifier(stats), **kwargs)
-
- return partial(parsed_stats, statfn=statfn, modifier=modifier)
-
-
class CoherenceMagnitude(mne, Undirected):
name = "Coherence magnitude (wavelet)"
labels = ["unsigned", "wavelet", "undirected"]
@@ -104,17 +101,49 @@ def __init__(self, **kwargs):
super().__init__(**kwargs)
-class CoherencePhase(mne, Undirected):
+class CoherencePhase(mne, Directed):
name = "Coherence phase (wavelet)"
- labels = ["unsigned", "wavelet", "undirected"]
+ labels = ["signed", "wavelet", "antisymmetric"]
def __init__(self, **kwargs):
+ if kwargs.get("statistic", "mean") != "mean":
+ raise ValueError(
+ "CoherencePhase supports only statistic='mean'. Wrapped phase "
+ "has no branch-cut-independent ordinary maximum; use a "
+ "different circular summary with an explicit interpretation."
+ )
self.identifier = "phase"
self._measure = "cohy"
super().__init__(**kwargs)
+ self.labels = ["signed", "wavelet", "antisymmetric"]
+
+ def issigned(self):
+ return True
+
+ @parse_multivariate
+ def multivariate(self, data):
+ """Reduce the complete signed phase spectrum over the selected band.
- # Take the angle before computing the statistic
- self._statfn = modify_stats(self._statfn, np.angle)
+ MNE returns one triangle. Coherency phase obeys
+ ``phase(i, j) = -phase(j, i)``, so reconstruct that triangle before
+ reducing. Wrapped angles are combined with a circular mean.
+ """
+ adj_freq, freq_id = self._get_cache(data)
+ phase = np.angle(adj_freq).copy()
+ ui = np.triu_indices(data.n_processes, 1)
+ phase[ui[0], ui[1], ...] = -phase[ui[1], ui[0], ...]
+ if phase.ndim == 4:
+ adj = _circular_nanmean(phase[..., freq_id, :], axis=(2, 3))
+ elif phase.ndim == 3:
+ adj = _circular_nanmean(phase[..., freq_id], axis=2)
+ else:
+ raise ValueError(
+ f"Expected a 3D or 4D wavelet connectivity tensor, got "
+ f"shape {phase.shape}."
+ )
+ adj[ui] = -adj.T[ui]
+ np.fill_diagonal(adj, np.nan)
+ return adj
class ImaginaryCoherence(mne, Undirected):
@@ -189,43 +218,100 @@ def __init__(self, **kwargs):
class PhaseSlopeIndex(mne, Undirected):
name = "Phase slope index (wavelet)"
- labels = ["unsigned", "wavelet", "undirected"]
+ labels = ["unsigned", "wavelet"]
def __init__(self, **kwargs):
self.identifier = "psi"
super().__init__(**kwargs)
+ # The per-frequency tensor is antisymmetric, but only a statistic that
+ # commutes with negation keeps the matrix antisymmetric. mean does;
+ # max does not (max of the negated band is -min, not -max), so the max
+ # variants are genuinely asymmetric.
+ trait = "antisymmetric" if self._statistic == "mean" else "asymmetric"
+ self.labels = [l for l in self.labels
+ if l not in ("undirected", "directed", "unsigned")
+ ] + [trait, "signed"]
+ # Signed in behaviour, not only in label: see the same note on
+ # statistics/spectral.py's NonparametricSpectral -- `_rmmin` shifts
+ # every "unsigned" SPI by its minimum, which destroys antisymmetry.
+ self.issigned = lambda: True
self.identifier += f"_{self._statistic}"
- def _get_cache(self, data):
- try:
- psi = data.mne_psi["psi"]
- freq = data.mne_psi["freq"]
- except AttributeError:
- z = np.moveaxis(data.to_numpy(), 2, 0)
+ def _get_psi(self, data):
+ """Compute PSI over this class's [fmin, fmax] band.
- freqs = np.linspace(0.2, 0.5, 10)
- psi, freq, _, _, _ = mnec.phase_slope_index(
- data=z,
- mode="cwt_morlet",
- sfreq=self._fs,
- mt_adaptive=True,
- cwt_freqs=freqs,
- verbose="WARNING",
- )
- freq = freq[0]
- data.mne_psi = dict(psi=psi, freq=freq)
-
- # freq = conn.frequencies
- freq_id = np.where((freq >= self._fmin) * (freq <= self._fmax))[0]
+ mne_connectivity's phase_slope_index integrates cwt_freqs that fall
+ inside [fmin, fmax], so each band needs its own call. Cached per band
+ on the dataset to share across PSI variants with the same band.
+ """
+ # Key on the resolved fmin (below), not the requested one, so two
+ # bands that collapse onto the same floor share correctly.
+ key = (self._fs, max(self._fmin, 5.0 / data.n_observations), self._fmax)
+ try:
+ return data.mne_psi[key]
+ except (AttributeError, KeyError):
+ pass
+
+ z = np.moveaxis(data.to_numpy(), 2, 0)
+ # Resolve fmin against the frequency the data can actually support.
+ # fmin=0 asks for an unbounded period: MNE reports an unreliable
+ # spectrum and builds an ~11.1-million-sample Morlet wavelet for T=100.
+ # The five-cycle floor is the same criterion the sibling wavelet path
+ # already applies (see mne._get_cache), so this makes the two
+ # consistent rather than inventing a new policy.
+ fmin = max(self._fmin, 5.0 / data.n_observations)
+ cwt_freqs = np.linspace(max(fmin, 1e-6), self._fmax, 10)
+ # A Morlet wavelet spans n_cycles/f seconds. At MNE's default of 7
+ # cycles the low-frequency wavelets are longer than the data (223
+ # samples for T=100), which MNE warns about and which makes those bands
+ # meaningless. Shorten the wavelet at low frequencies so it always fits:
+ # the usual time-frequency trade, resolution given up to stay estimable.
+ # MNE's Morlet length is ~1.59 * n_cycles / f samples (measured: 159
+ # samples at f=0.05, n_cycles=5). Cap n_cycles so the wavelet never
+ # exceeds the signal, with a small margin.
+ n_obs = data.n_observations
+ max_cycles = 0.95 * n_obs * cwt_freqs / (1.59 * self._fs)
+ cwt_n_cycles = np.clip(max_cycles, 1.0, 7.0)
+ psi_obj = phase_slope_index(
+ data=z,
+ mode="cwt_morlet",
+ sfreq=self._fs,
+ mt_adaptive=True,
+ fmin=fmin,
+ fmax=self._fmax,
+ cwt_freqs=cwt_freqs,
+ cwt_n_cycles=cwt_n_cycles,
+ verbose=False,
+ )
+ psi = psi_obj.get_data(output="dense")
- return psi, freq_id
+ try:
+ data.mne_psi[key] = psi
+ except AttributeError:
+ data.mne_psi = {key: psi}
+ return psi
@parse_multivariate
def multivariate(self, data):
- adj_freq, freq_id = self._get_cache(data)
- adj = self._statfn(np.real(adj_freq[..., freq_id]), axis=(2, 3))
-
- ui = np.triu_indices(data.n_processes, 1)
- adj[ui] = adj.T[ui]
+ psi = np.real(self._get_psi(data))
+
+ # mne_connectivity returns a *lower-triangular* dense tensor: the upper
+ # triangle is zero and must be filled in. PSI is antisymmetric per
+ # frequency -- psi[i,j,f] = -psi[j,i,f], the sign being the entire
+ # lead/lag content -- so the fill must negate, and it must happen
+ # BEFORE the band statistic is applied.
+ #
+ # Negating after reduction is only valid for a statistic that commutes
+ # with negation. mean does; max does not:
+ # max_f psi[i,j,f] = max_f(-psi[j,i,f]) = -min_f psi[j,i,f]
+ # which is not -max_f psi[j,i,f]. Reducing first and negating second
+ # made the max variants fail a process-permutation test by up to 11.5.
+ #
+ # Subtracting the transpose fills both triangles in one step, since the
+ # upper triangle is zero: lower keeps psi[i,j,f], upper becomes
+ # -psi[j,i,f], diagonal cancels to zero.
+ psi = psi - np.swapaxes(psi, 0, 1)
+
+ adj = self._statfn(psi, axis=(2, 3))
np.fill_diagonal(adj, np.nan)
return adj
diff --git a/pyspi/utils.py b/pyspi/utils.py
index 39ca62b7..2fe24efc 100644
--- a/pyspi/utils.py
+++ b/pyspi/utils.py
@@ -3,61 +3,85 @@
import warnings
import pandas as pd
import os
-import yaml
-from colorama import Fore, init
-init(autoreset=True)
-
-def _contains_nan(a, nan_policy='propagate'):
- policies = ['propagate', 'raise', 'omit']
- if nan_policy not in policies:
- raise ValueError("nan_policy must be one of {%s}" %
- ', '.join("'%s'" % s for s in policies))
- try:
- # Calling np.sum to avoid creating a huge array into memory
- # e.g. np.isnan(a).any()
- with np.errstate(invalid='ignore'):
- contains_nan = np.isnan(np.sum(a))
- except TypeError:
- # If the check cannot be properly performed we fallback to omitting
- # nan values and raising a warning. This can happen when attempting to
- # sum things that are not numbers (e.g. as in the function `mode`).
- contains_nan = False
- nan_policy = 'omit'
- warnings.warn("The input array could not be properly checked for nan "
- "values. nan values will be ignored.", RuntimeWarning)
-
- if contains_nan and nan_policy == 'raise':
- raise ValueError("The input contains nan values")
-
- return (contains_nan, nan_policy)
-
-
-def strshort(instr,mlength):
- """Shorten a string using ellipsis
- """
- if isinstance(instr,list):
- outstr = []
- for i in range(len(instr)):
- cstr = instr[i]
- outstr.append((cstr[:mlength-6] + '...' + cstr[-3:]) if len(cstr) > mlength else cstr)
- else:
- outstr = (instr[:mlength-6] + '...' + instr[-3:]) if len(instr) > mlength else instr
- return outstr
+import yaml
+
+def require_int(name, value, minimum=1):
+ """Return ``value`` as an ``int``, or say precisely why it is not one.
+
+ Strict on three things that a bare ``int(value) < minimum`` check lets past:
+
+ * ``bool`` is a subclass of ``int``, so ``k_history=True`` would arrive as 1.
+ * ``int(2.7)`` truncates, so a parameter the caller plainly meant as
+ something else is silently changed rather than rejected.
+ * ``float('nan')`` and ``float('inf')`` raise from ``int()`` with a message
+ about the conversion rather than about the parameter.
-def acf(x,mode='positive'):
- """Return the autocorrelation function
+ Shared by every public numeric parameter that indexes samples -- histories,
+ delays, neighbour counts, search bounds, lag windows -- so the rule is one
+ rule rather than a per-class habit.
+ """
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
+ raise TypeError(
+ f"{name} must be an integer >= {minimum}, got {value!r} "
+ f"({type(value).__name__})."
+ )
+ if value < minimum:
+ raise ValueError(f"{name} must be >= {minimum}, got {value!r}.")
+ return int(value)
+
+
+def require_positive_float(name, value):
+ """Return ``value`` as a strictly positive finite ``float``.
+
+ The companion to :func:`require_int` for the genuinely continuous
+ parameters -- kernel bandwidths, band fractions. Rejects ``bool`` (which
+ ``float()`` happily turns into 1.0), NaN and the infinities, and anything
+ that is not a real number at all.
+ """
+ if isinstance(value, bool) or isinstance(value, (str, bytes)):
+ raise TypeError(
+ f"{name} must be a positive real number, got {value!r} "
+ f"({type(value).__name__})."
+ )
+ try:
+ value = float(value)
+ except (TypeError, ValueError):
+ raise TypeError(
+ f"{name} must be a positive real number, got {value!r} "
+ f"({type(value).__name__})."
+ ) from None
+ if not np.isfinite(value):
+ raise ValueError(f"{name} must be finite, got {value!r}.")
+ if value <= 0:
+ raise ValueError(f"{name} must be > 0, got {value!r}.")
+ return value
+
+
+def acf(x, mode='positive'):
+ """Return the autocorrelation function using FFT-based computation.
+
+ O(N log N) via FFT, replacing the original O(N^2) np.correlate approach.
"""
if x.ndim > 1:
x = np.squeeze(x)
- x = zscore(x)
- acf = np.correlate(x,x,mode='full')
- acf = acf / acf[acf.size//2]
+ x = x - x.mean()
+ s = x.std()
+ if s == 0:
+ n = len(x)
+ return np.zeros(n) if mode == 'positive' else np.zeros(2 * n - 1)
+ x = x / s
+
+ n = len(x)
+ fft_size = 2 * n
+ X = np.fft.rfft(x, n=fft_size)
+ acf_full = np.fft.irfft(X * np.conj(X), n=fft_size)[:n]
+ acf_full = acf_full / acf_full[0] # normalize so acf[0] = 1
if mode == 'positive':
- return acf[acf.size//2:]
- else:
- return acf
+ return acf_full
+ # full symmetric ACF
+ return np.concatenate([acf_full[::-1], acf_full[1:]])
def swap_chars(s, i_1, i_2):
"""Swap to characters in a string.
@@ -70,67 +94,10 @@ def swap_chars(s, i_1, i_2):
i_1, i_2 = i_2, i_1
return ''.join([s[0:i_1], s[i_2], s[i_1+1:i_2], s[i_1], s[i_2+1:]])
-def normalise(a, axis=0, nan_policy='propogate'):
-
- contains_nan, nan_policy = _contains_nan(a, nan_policy)
-
- if contains_nan and nan_policy == 'omit':
- return (a - np.nanmin(a,axis=axis)) / (np.nanmax(a,axis=axis) - np.nanmin(a,axis=axis))
- else:
- return (a - np.min(a,axis=axis)) / (np.max(a,axis=axis) - np.min(a,axis=axis))
-
-def standardise(a, dimension=0, df=1):
- """Z-standardise a numpy array along a given dimension.
-
- Standardise array along the axis defined in dimension using the denominator
- (N - df) for the calculation of the standard deviation.
-
- Args:
- a : numpy array
- data to be standardised
- dimension : int [optional]
- dimension along which array should be standardised
- df : int [optional]
- degrees of freedom for the denominator of the standard derivation
-
- Returns:
- numpy array
- standardised data
- """
- # Avoid division by standard deviation if the process is constant.
- a_sd = a.std(axis=dimension, ddof=df)
-
- if np.isclose(a_sd, 0):
- return a - a.mean(axis=dimension)
- else:
- return (a - a.mean(axis=dimension)) / a_sd
-
def convert_mdf_to_ddf(df):
- ddf = pd.pivot_table(data=df.stack(dropna=False).reset_index(),index='Dataset',columns=['SPI-1', 'SPI-2'],dropna=False).T.droplevel(0)
+ ddf = pd.pivot_table(data=df.stack(future_stack=True).reset_index(),index='Dataset',columns=['SPI-1', 'SPI-2'],dropna=False).T.droplevel(0)
return ddf
-def is_jpype_jvm_available():
- """Check whether a JVM is accessible via Jpype"""
- try:
- import jpype as jp
- if not jp.isJVMStarted():
- jarloc = (os.path.dirname(os.path.abspath(__file__)) + "/lib/jidt/infodynamics.jar")
- # if JVM not started, start a session
- print(f"Starting JVM with java class {jarloc}.")
- jp.startJVM(jp.getDefaultJVMPath(), "-ea", "-Djava.class.path=" + jarloc)
- return True
- except Exception as e:
- print(f"Jpype JVM not available: {e}")
- return False
-
-def check_optional_deps():
- """Bundle all of the optional
- dependency checks together."""
- isAvailable = {}
- isAvailable['java'] = is_jpype_jvm_available()
-
- return isAvailable
-
def filter_spis(keywords, output_name = None, configfile= None):
"""
Filter a YAML using a list of keywords, and save the reduced set as a new
@@ -139,8 +106,8 @@ def filter_spis(keywords, output_name = None, configfile= None):
Args:
keywords (list): A list of keywords (as strings) to filter the YAML.
- output_name (str, optional): The desired name for the output file. Defaults to a random name.
- configfile (str, optional): The path to the input YAML file. Defaults to the `config.yaml' in the pyspi dir.
+ output_name (str, optional): The desired name for the output file. Defaults to a random name.
+ configfile (str, optional): The path to the input YAML file. Defaults to the `config.yaml' in the pyspi dir.
Raises:
ValueError: If `keywords` is not a list or if no SPIs match the keywords.
@@ -155,15 +122,13 @@ def filter_spis(keywords, output_name = None, configfile= None):
if not isinstance(keywords, list):
raise ValueError("Keywords must be provided as a list of strings.")
- # if no configfile and no keywords are provided, use the default 'config.yaml' in pyspi location
+ # Default to the full bundled config; otherwise accept a bundled name or path.
+ from pyspi.calculator import resolve_config
if configfile is None:
- script_dir = os.path.dirname(os.path.abspath(__file__))
- default_config = os.path.join(script_dir, 'config.yaml')
- if not os.path.isfile(default_config):
- raise FileNotFoundError(f"Default 'config.yaml' file not found in {script_dir}.")
- configfile = default_config
- source_file_info = f"Default 'config.yaml' file from {script_dir} was used as the source file."
+ configfile = resolve_config("full")
+ source_file_info = f"Default bundled config '{configfile}' was used as the source file."
else:
+ configfile = resolve_config(configfile)
source_file_info = f"User-specified config file '{configfile}' was used as the source file."
# load in user-specified yaml
@@ -176,35 +141,70 @@ def filter_spis(keywords, output_name = None, configfile= None):
# handle all other exceptions
raise IOError(f"An error occurred while trying to read '{configfile}': {e}")
- # new dictionary to be converted to final YAML
+ # Filter on the labels each SPI *actually* carries once instantiated, not on
+ # the family labels written in the YAML. Several traits are set per variant
+ # in __init__ -- 'antisymmetric' for phase measures whose band statistic is
+ # the mean, 'directed' for cointegration's aeg method -- and are invisible
+ # in the raw file. Matching families also selected every config in them,
+ # so a family could not be filtered down to the variants that qualified.
+ import importlib
+ from pyspi.calculator import (
+ _merge_spi_labels,
+ _split_config_params,
+ _expand_lagged_correlation_configs,
+ )
+
filtered_subset = {}
spis_found = 0
-
- for module in yf:
+ keywords = set(keywords)
+
+ for module_name in yf:
+ module = importlib.import_module(module_name, "pyspi")
module_spis = {}
- for spi in yf[module]:
- spi_labels = yf[module][spi].get('labels')
- if all(keyword in spi_labels for keyword in keywords):
- module_spis[spi] = yf[module][spi]
- if yf[module][spi].get('configs'):
- spis_found += len(yf[module][spi].get('configs'))
- else:
+ for spi_name, entry in (yf[module_name] or {}).items():
+ entry = dict(entry or {})
+ family_labels = entry.get("labels")
+ configs = entry.get("configs")
+ # Same expansion the loader applies, so max_tau reaches the
+ # constructor as the tau values it stands for.
+ if spi_name == "LaggedCorrelation" and configs is not None:
+ configs = _expand_lagged_correlation_configs(configs)
+
+ if configs is None:
+ spi = getattr(module, spi_name)()
+ _merge_spi_labels(spi, family_labels)
+ if keywords <= set(spi.labels or []):
+ module_spis[spi_name] = entry
spis_found += 1
-
+ continue
+
+ kept = []
+ for params in configs:
+ clean, config_labels = _split_config_params(params)
+ spi = getattr(module, spi_name)(**clean)
+ _merge_spi_labels(spi, family_labels, config_labels)
+ if keywords <= set(spi.labels or []):
+ kept.append(params)
+ if kept:
+ kept_entry = dict(entry)
+ kept_entry["configs"] = kept
+ module_spis[spi_name] = kept_entry
+ spis_found += len(kept)
+
if module_spis:
- filtered_subset[module] = module_spis
-
+ filtered_subset[module_name] = module_spis
+
# check that > 0 SPIs found
if spis_found == 0:
raise ValueError(f"0 SPIs were found with the specific keywords: {keywords}.")
-
+
# construct output file path
if output_name is None:
# use a unique name
output_name = "config_" + os.urandom(4).hex()
output_file = os.path.join(os.getcwd(), f"{output_name}.yaml")
-
+
# write to YAML
with open(output_file, "w") as outfile:
yaml.dump(filtered_subset, outfile, default_flow_style=False, sort_keys=False)
@@ -216,12 +216,33 @@ def filter_spis(keywords, output_name = None, configfile= None):
- Total SPIs Matched: {spis_found} SPI(s) were found with the specific keywords: {keywords}.
- New File Created: A YAML file named `{output_name}.yaml` has been saved in the current directory: `{output_file}'
- Next Steps: To utilise the filtered set of SPIs, please initialise a new Calculator instance with the following command:
-`Calculator(configfile='{output_file}')`
+`Calculator(config='{output_file}')`
""")
+def _print_timing_summary(calc, n_slowest=5):
+ """Print total compute time and the slowest SPIs.
+
+ Per-SPI timings are always available as ``calc.timings``; this surfaces the
+ part that actually informs a decision -- which SPIs dominate the run, and
+ are therefore what a cheaper ``config=`` drops.
+ """
+ timings = {k: v for k, v in calc.timings.items() if v}
+ if not timings:
+ # Every SPI was restored from a checkpoint, so nothing was timed.
+ return
+ total = sum(timings.values())
+ print(f"Total compute time: {total:.2f}s across {len(timings)} timed SPI(s)")
+ slowest = sorted(timings.items(), key=lambda kv: kv[1], reverse=True)[:n_slowest]
+ width = max(len(k) for k, _ in slowest)
+ print(f"Slowest {len(slowest)}:")
+ for key, secs in slowest:
+ print(f" {key:<{width}} {secs:7.2f}s ({secs / total * 100:5.1f}%)")
+ print("-" * 60)
+
+
def inspect_calc_results(calc):
"""
- Display a summary of the computed SPI results, including counts of successful computations,
+ Display a summary of the computed SPI results, including counts of successful computations,
outputs with NaNs, and partially computed results.
"""
total_num_spis = calc.n_spis
@@ -236,7 +257,7 @@ def inspect_calc_results(calc):
else:
# returned numeric values (i.e., not NaN)
spi_results['Successful'].append(key)
-
+
# print summary
double_line_60 = "="*60
single_line_60 = "-"*60
@@ -245,6 +266,7 @@ def inspect_calc_results(calc):
print(f"\nTotal number of SPIs attempted: {total_num_spis}")
print(f"Number of SPIs successfully computed: {len(spi_results['Successful'])} ({len(spi_results['Successful']) / total_num_spis * 100:.2f}%)")
print(single_line_60)
+ _print_timing_summary(calc)
print("Category | Count | Percentage")
print(single_line_60)
for category, spis in spi_results.items():
@@ -265,4 +287,24 @@ def inspect_calc_results(calc):
for i, spi in enumerate(spi_results['Partial NaNs']):
print(f"{i+1}. {spi}")
print(single_line_60 + "\n")
-
\ No newline at end of file
+
+
+def fmt_param(x):
+ """Format a parameter value for use inside an SPI identifier, losslessly.
+
+ Identifiers are the package's primary key: they name table columns, name
+ checkpoint files, and are compared when resuming. Formatting floats with
+ ``.3g``/``.4g`` made them lossy, so two genuinely different
+ parameterisations could render to the same identifier and silently overwrite
+ one another at dict insertion.
+
+ ``repr`` of a Python float is the shortest string that round-trips, so it is
+ both lossless and stable across platforms. Values that already print
+ identically under ``.3g`` are unaffected, which is why no bundled config's
+ identifiers change.
+ """
+ if isinstance(x, float):
+ if x != x or x in (float("inf"), float("-inf")):
+ return str(x)
+ return repr(x)
+ return str(x)
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index bbd00af1..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-pytest
-h5py
-scikit-learn
-scipy
-numpy<2.0.0
-pandas
-statsmodels
-pyyaml
-tqdm
-nitime
-hyppo
-pyEDM==1.15.2.0
-jpype1
-sktime
-dill
-spectral-connectivity
-torch
-cdt==0.5.23
-tslearn
-mne==0.23.0
-seaborn
-future
-colorama
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 44bfc3d0..00000000
--- a/setup.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from setuptools import setup, find_packages
-
-# http://www.diveintopython3.net/packaging.html
-# https://pypi.python.org/pypi?:action=list_classifiers
-
-with open('README.md', 'r', encoding='utf-8') as file:
- long_description = file.read()
-
-
-install_requires = [
- 'h5py',
- 'scikit-learn',
- 'scipy',
- 'numpy<2.0.0',
- 'pandas',
- 'statsmodels',
- 'pyyaml',
- 'tqdm',
- 'nitime',
- 'hyppo',
- 'pyEDM==1.15.2.0',
- 'jpype1',
- 'sktime',
- 'dill',
- 'spectral-connectivity',
- 'torch',
- 'cdt==0.5.23',
- 'tslearn',
- 'mne==0.23.0',
- 'seaborn',
- 'future',
- 'colorama'
-]
-
-testing_extras = [
- 'pytest==5.4.2', # unittest.TestCase funkyness, see commit 77c1505ab
-]
-
-
-setup(
- name='pyspi',
- packages=find_packages(),
- package_data={'': ['config.yaml',
- 'sonnet_config.yaml',
- 'fast_config.yaml',
- 'fabfour_config.yaml',
- 'lib/jidt/infodynamics.jar',
- 'data/cml.npy',
- 'data/forex.npy',
- 'data/standard_normal.npy',
- 'data/cml7.npy']},
- include_package_data=True,
- version='2.0.1',
- description='Library for pairwise analysis of time series data.',
- author='Oliver M. Cliff',
- author_email='oliver.m.cliff@gmail.com',
- url='https://github.com/DynamicsAndNeuralSystems/pyspi',
- long_description=long_description,
- classifiers=[
- "Programming Language :: Python",
- "Programming Language :: Python :: 3",
- "Development Status :: 1 - Planning",
- "Operating System :: POSIX :: Linux",
- "Intended Audience :: Science/Research",
- "Environment :: Console",
- "Environment :: Other Environment",
- "Topic :: Scientific/Engineering :: Physics",
- "Topic :: Scientific/Engineering :: Bio-Informatics",
- "Topic :: Scientific/Engineering :: Information Analysis",
- "Topic :: Scientific/Engineering :: Medical Science Apps.",
- ],
- install_requires=install_requires,
- extras_require={'testing': testing_extras}
-)
diff --git a/tests/CML7_benchmark_tables.pkl b/tests/CML7_benchmark_tables.pkl
deleted file mode 100644
index 23d5a475..00000000
Binary files a/tests/CML7_benchmark_tables.pkl and /dev/null differ
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..516c6c9f
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,134 @@
+# pyspi test suite
+
+## Running
+
+```bash
+pytest # fast suite (default: -m 'not slow'), ~2 min
+pytest -m slow # baseline drift suite only, ~3.5 min
+pytest -m '' # everything
+```
+
+`addopts = "-m 'not slow'"` is set in `pyproject.toml`, so the slow marker is
+opt-in. Nothing outside `tests/tools/` performs work at import time: a missing or
+corrupt baseline can never break collection of unrelated tests.
+
+## What each file covers
+
+| File | Covers |
+| --- | --- |
+| `test_infotheory_analytic.py` | **Closed-form correctness** of the pure-NumPy info-theory estimators: bivariate-Gaussian MI vs `-0.5*ln(1-rho^2)` for all four estimators, differential entropy of `N(0, sigma^2)`, Gaussian TE on an analytic AR(1), independence collapsing to ~0, and the entropy identities. The only place a `return np.zeros(...)` stub would be caught. |
+| `test_calculator.py` | `Calculator` / `Data` API: `config=` resolution, `zscore=`, labels, grouping, dataset loading. |
+| `test_utils.py` | Utility helpers. |
+| `test_smoke.py` | Cheap shape / finiteness / sign checks across SPI families. Sanity, not correctness. |
+| `test_parallel.py` | `Calculator.compute()` parallel path, checkpointing, per-SPI failure isolation. |
+| `test_phi_native.py` | Native (non-JIDT) integrated-information implementation. |
+| `test_baseline_drift.py` | `slow`. Every SPI on three frozen fixtures (M=3/5/7) vs a stored baseline. |
+| `test_state_integrity.py` | `Data` ownership, read-only exposure, cache invalidation on mutation, builder path, process-name lifecycle, `dim_order` validation. |
+| `test_cache_keys.py` | Parameterised statistic caches must key on every parameter that reaches the identifier. |
+| `test_run_identity.py` | Checkpoints must identify the run that produced them; identifier collisions must be rejected at insertion. |
+| `test_execution_parity.py` | Serial and parallel must agree on *failure* semantics, not only on numbers. Uses `failing_spis.py` + `parity_failure_config.yaml`. |
+| `test_estimator_contracts.py` | An SPI must compute the estimator it advertises, or refuse. Symbolic/KSG preconditions. |
+| `test_structural_traits.py` | Declared symmetry labels vs observed baseline matrices; AEG process-order dependence. |
+
+### Contract tests (originally red)
+
+These files began as *red* tests: assertions for behaviour the package did not
+yet have. All but one are now green. The single remaining marker is
+`test_structural_traits.py::test_no_bundled_spi_returns_a_constant_matrix`,
+`@pytest.mark.xfail(strict=True)`, and it records a **fixture/low-data finding,
+not a proven universal defect**: the six `dspli_*_max` and `dswpli_*_max`
+variants return a constant matrix on `var1_M3_T100` (M=3, T=100),
+which carries no pairwise information *on that fixture*. Whether it holds at
+larger M or T has not been established, and no SPI should be removed on this
+evidence alone.
+
+`strict=True` turns an *unexpected pass* into a failure, so when the question is
+settled the test fails until the marker is deleted -- a marker cannot silently
+outlive the finding it describes.
+
+Two rules when working on these:
+
+1. **Do not relax an assertion to make one pass.** Fix the code, or leave the
+ marker.
+2. **Check the failure reason, not just the xfail count.** Several of these
+ initially "failed" for reasons unrelated to the bug under test -- a vacuous
+ comparison, a wrong keyword, a config name passed where a path was wanted. An
+ xfail proves nothing until you have seen the message. Run with `--runxfail`
+ to see it.
+
+One trap worth naming: `parse_bivariate`'s signature is
+`(self, data, data2=None, i=None, j=None)`, so `spi.bivariate(data, 0, 1)` binds
+`data2=0, i=1`. That is now rejected with a message naming the signature (it
+used to reach `z[None]`, which numpy reads as `np.newaxis`, and compute
+something unrelated). Always pass `i=`/`j=` by keyword.
+
+Log base: every information-theoretic estimator reports **nats**. `kernel` and
+`symbolic` used to report bits, inherited from JIDT's base-2 box-kernel and
+discrete estimators; divide by ln 2 for the JIDT-comparable value.
+
+## Baselines
+
+`tests/data/baselines/{var1_M3_T100,cml_M5_T100,kuramoto_M7_T100}.npz` — one
+`MxM` matrix per SPI identifier, plus `__dataset__` / `__config__` / `__seed__`
+provenance entries. Regenerate with:
+
+```bash
+python tests/tools/generate_benchmark_tables.py # all three
+python tests/tools/generate_benchmark_tables.py -d cml_M5_T100
+```
+
+The frozen `.npy` fixtures themselves live in `tests/data/fixtures/` and come
+from `tests/tools/generate_fixtures.py`. They are **test inputs, not shipped
+data**: they are deliberately outside `pyspi/data/`, so they are not in the
+wheel and not reachable via `pyspi.data.load_dataset` (which now exposes only
+the three demo datasets `forex`, `cml`, `standard_normal`). Three generating
+processes at three widths — VAR(1) at `M=3`, coupled map lattice at `M=5`,
+Kuramoto at `M=7`, all `T=100` — so the SPI set is exercised across a range of
+`M` rather than at a single width.
+
+These baselines are generated from **this fork's current code**, not from
+upstream pyspi 2.0.1. The fork deliberately rewrote every information-theoretic
+estimator, so upstream values are the wrong oracle for exactly the code that
+most needs one. The baselines are therefore a *forward-looking change detector*:
+they tell you that something moved, not that it was right before. Independent
+correctness lives in `test_infotheory_analytic.py`.
+
+A single seeded pass is stored per dataset — not a mean over trials. The
+datasets are frozen fixtures and nearly every SPI is a deterministic function of
+them, so an exact oracle is more useful than an average that no individual run
+reproduces.
+
+## Drift is enforced
+
+`test_baseline_drift.py` **fails** on:
+
+1. the baseline SPI set differing from the current `Calculator`'s SPI set, so a
+ newly-broken or renamed SPI cannot escape by having no baseline;
+2. a matrix shape change;
+3. a change in the **NaN pattern** — an SPI going from finite to all-NaN (or
+ back) is a categorical regression, not drift;
+4. a baseline with no finite off-diagonal value at all, which cannot detect a
+ regression however closely the current run reproduces it;
+5. any numerical difference outside the SPI's tolerance. This used to be
+ *reported* to a session-end summary table and not fail the run, which made
+ every tolerance in the file decorative. The summary table is still printed;
+ it is now a description of the failures rather than the whole response to
+ them.
+
+Two further gates run per fixture: no SPI may raise (`calc.errors` must be
+empty) and none may produce an entirely non-finite column. The single
+documented exception lives in `KNOWN_UNESTIMABLE`, shared with the baseline
+generator so the two cannot disagree, and the suite fails if a listed exception
+starts succeeding.
+
+Tolerances are `1e-9` relative / `1e-12` absolute for every SPI. The previous
+split — `1e-2` for `causal` and `misc`, on the assumption that their optimisers
+and permutation tests were not bit-reproducible — was measured and found false;
+see `LOOSE_SPIS` in the module and `tools/measure_reproducibility.py`.
+
+## Open finding
+
+One `xfail(strict=True)` marker remains. Six `max`-statistic SPIs return a
+constant matrix on `var1_M3_T100`; the marker records that fixture observation
+without claiming a universal defect. Positional `bivariate(data, 0, 1)` is no
+longer xfailed: it is rejected with a message explaining the signature.
diff --git a/tests/conftest.py b/tests/conftest.py
index 2cd591ed..19ffb426 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,50 +1,49 @@
import pytest
+
@pytest.fixture(scope="session")
def spi_warning_logger(request):
+ """Collect (dataset, SPI) drift records for the session-end summary table."""
warnings_log = list()
- def add_warning(spi, module_name, max_z, num_exceed, num_iteractions):
- warnings_log.append((spi, module_name, max_z, num_exceed, num_iteractions))
-
+ def add_warning(spi, module_name, max_abs, max_rel, num_exceed, num_interactions):
+ warnings_log.append((spi, module_name, max_abs, max_rel, num_exceed, num_interactions))
+
request.session.spi_warnings = warnings_log
return add_warning
+
def pytest_sessionfinish(session, exitstatus):
- # retrieve the spi warnings from the session object
- spi_warnings = getattr(session, 'spi_warnings', [])
-
- # styling
- header_line = "=" * 80
- content_line = "-" * 80
- footer_line = "=" * 80
- header = " SPI BENCHMARKING SUMMARY"
+ # Only print when the drift suite actually ran and produced records. The
+ # fixture is session-scoped and lazily instantiated, so `spi_warnings` is
+ # absent for --collect-only and for the default (non-slow) suite, and empty
+ # when the drift suite ran clean — in both cases the banner is noise.
+ spi_warnings = getattr(session, "spi_warnings", None)
+ if not spi_warnings:
+ return
+
+ header_line = "=" * 90
+ content_line = "-" * 90
+ footer_line = "=" * 90
+ header = " SPI DRIFT SUMMARY (abs/rel tolerance vs frozen baseline) "
footer = f" Session completed with exit status: {exitstatus} "
- padded_header = f"{header:^80}"
- padded_footer = f"{footer:^80}"
print("\n")
print(header_line)
- print(padded_header)
+ print(f"{header:^90}")
print(header_line)
- # print problematic SPIs in table format
- if spi_warnings:
- print(f"\nDetected {len(spi_warnings)} SPI(s) with outputs exceeding the specified 1 sigma threshold.\n")
-
- # table header
- print(f"{'SPI':<25}{'Cat':<10}{'Max ZSc.':>10}{'# Exceed. Pairs':>20}{'Unq. Pairs':>15}")
- print(content_line)
+ print(f"\nDetected {len(spi_warnings)} (dataset, SPI) pair(s) exceeding their "
+ f"family's drift tolerance.\n")
+ print(f"{'Dataset:SPI':<40}{'Cat':<10}{'Max |Δ|':>12}{'Max rel':>12}"
+ f"{'# Exceed':>10}{'Unq Pairs':>12}")
+ print(content_line)
- # table content
- for est, module_name, max_z, num_exceed, num_iteractions in spi_warnings:
- # add special character for v.large zscores
- error = ""
- if max_z > 10:
- error = " **"
- print(f"{est+error:<25}{module_name:<10}{max_z:>10.4g}{num_exceed:>15}{num_iteractions:>20}")
- else:
- print("\n\nNo SPIs exceeded the 1 sigma threshold.\n")
+ for est, module_name, max_abs, max_rel, num_exceed, num_interactions in spi_warnings:
+ marker = " **" if max_rel > 0.1 or max_abs > 0.1 else ""
+ rel_str = f"{max_rel:>12.4g}" if max_rel == max_rel else f"{'n/a':>12}"
+ print(f"{est + marker:<40}{module_name:<10}{max_abs:>12.4g}{rel_str}"
+ f"{num_exceed:>10}{num_interactions:>12}")
print(footer_line)
- print(padded_footer)
+ print(f"{footer:^90}")
diff --git a/tests/data/baselines/cml_M5_T100.npz b/tests/data/baselines/cml_M5_T100.npz
new file mode 100644
index 00000000..341316f8
Binary files /dev/null and b/tests/data/baselines/cml_M5_T100.npz differ
diff --git a/tests/data/baselines/kuramoto_M7_T100.npz b/tests/data/baselines/kuramoto_M7_T100.npz
new file mode 100644
index 00000000..32c9705b
Binary files /dev/null and b/tests/data/baselines/kuramoto_M7_T100.npz differ
diff --git a/tests/data/baselines/var1_M3_T100.npz b/tests/data/baselines/var1_M3_T100.npz
new file mode 100644
index 00000000..e8bdd7dc
Binary files /dev/null and b/tests/data/baselines/var1_M3_T100.npz differ
diff --git a/tests/data/fixtures/cdt_pairwise_reference.npz b/tests/data/fixtures/cdt_pairwise_reference.npz
new file mode 100644
index 00000000..ed4a4048
Binary files /dev/null and b/tests/data/fixtures/cdt_pairwise_reference.npz differ
diff --git a/tests/data/fixtures/cml_M5_T100.npy b/tests/data/fixtures/cml_M5_T100.npy
new file mode 100644
index 00000000..409f477a
Binary files /dev/null and b/tests/data/fixtures/cml_M5_T100.npy differ
diff --git a/tests/data/fixtures/kuramoto_M7_T100.npy b/tests/data/fixtures/kuramoto_M7_T100.npy
new file mode 100644
index 00000000..6ad6f64d
Binary files /dev/null and b/tests/data/fixtures/kuramoto_M7_T100.npy differ
diff --git a/tests/data/fixtures/var1_M3_T100.npy b/tests/data/fixtures/var1_M3_T100.npy
new file mode 100644
index 00000000..0310edc4
Binary files /dev/null and b/tests/data/fixtures/var1_M3_T100.npy differ
diff --git a/tests/failing_spis.py b/tests/failing_spis.py
new file mode 100644
index 00000000..8555d8ab
--- /dev/null
+++ b/tests/failing_spis.py
@@ -0,0 +1,65 @@
+"""Deliberately misbehaving SPIs, used to test failure semantics.
+
+Kept as a top-level module (not a package submodule) so a YAML config can name
+it directly and a spawned worker can import it, provided ``tests/`` is on
+``PYTHONPATH`` -- see ``tests/test_execution_parity.py``, which sets that.
+
+These exist to pin how the two execution paths report failure. They are not
+part of the shipped SPI set.
+"""
+import numpy as np
+
+from pyspi.base import Directed, Undirected, Unsigned, parse_bivariate
+
+
+class AlwaysRaises(Undirected, Unsigned):
+ """Raises a distinctive exception from every pair."""
+
+ name = "AlwaysRaises"
+ identifier = "always_raises"
+ labels = ["test"]
+
+ def __init__(self, message="deliberate test failure"):
+ self._message = message
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ raise RuntimeError(self._message)
+
+
+class AlwaysWarns(Undirected, Unsigned):
+ """Emits a distinctive warning, then returns a finite value."""
+
+ name = "AlwaysWarns"
+ identifier = "always_warns"
+ labels = ["test"]
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ import warnings
+
+ warnings.warn("deliberate test warning", UserWarning)
+ return 0.5
+
+
+class WrongShape(Undirected, Unsigned):
+ """Returns a matrix of the wrong shape from multivariate()."""
+
+ name = "WrongShape"
+ identifier = "wrong_shape"
+ labels = ["test"]
+
+ def multivariate(self, data):
+ return np.zeros((2, 5))
+
+
+class NonFinite(Undirected, Unsigned):
+ """Returns infinities rather than raising."""
+
+ name = "NonFinite"
+ identifier = "non_finite"
+ labels = ["test"]
+
+ @parse_bivariate
+ def bivariate(self, data, i=None, j=None):
+ return np.inf
diff --git a/tests/generate_benchmark_tables.py b/tests/generate_benchmark_tables.py
deleted file mode 100644
index b8383c08..00000000
--- a/tests/generate_benchmark_tables.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import numpy as np
-import dill
-from pyspi.calculator import Calculator
-
-""""Script to generate benchmarking dataset"""
-def get_benchmark_tables(calc_list):
- # get the spis from the first calculator
- spis = list(calc_list[1].spis.keys())
- num_procs = calc_list[1].dataset.n_processes
- # create a dict to store the mean and std for each spi
- benchmarks = {key : {'mean': None, 'std': None} for key in spis}
- num_trials = len(calc_list)
- for spi in spis:
- mpi_tensor = np.zeros(shape=(num_trials, num_procs, num_procs))
- for (index, calc) in enumerate(calc_list):
- mpi_tensor[index, :, :] = calc.table[spi].to_numpy()
- mean_matrix = np.mean(mpi_tensor, axis=0) # compute element-wise mean across the first dimension
- std_matrix = np.std(mpi_tensor, axis=0) # compute element-wise std across the first dimension
- benchmarks[spi]['mean'] = mean_matrix
- benchmarks[spi]['std'] = std_matrix
-
- return benchmarks
-
-# load and transpose dataset
-dataset = np.load("pyspi/data/cml7.npy").T
-
-# create list to store the calculator objects
-store_calcs = list()
-
-for i in range(10):
- np.random.seed(42)
- calc = Calculator(dataset=dataset)
- calc.compute()
- store_calcs.append(calc)
-
-mpi_benchmarks = get_benchmark_tables(store_calcs)
-
-# save data
-with open("tests/CML7_benchmark_tables_new.pkl", "wb") as f:
- dill.dump(mpi_benchmarks, f)
diff --git a/tests/parallel_test_config.yaml b/tests/parallel_test_config.yaml
new file mode 100644
index 00000000..9f203b6a
--- /dev/null
+++ b/tests/parallel_test_config.yaml
@@ -0,0 +1,67 @@
+# Small config for tests/test_parallel.py
+# Covers:
+# - covariance cache namespace (Covariance + Precision, multiple estimators)
+# - spectral_mv cache namespace (CoherenceMagnitude, multiple freq bands)
+# - ccm cache namespace (ConvergentCrossMapping) — also exercises the pyEDM
+# call site, whose nested pools are off unconditionally
+# - cacheless basic SPIs (SpearmanR, KendallTau)
+# - cacheless misc SPI (PowerEnvelopeCorrelation)
+# All SPIs here are deterministic (CCM is seeded) so serial == parallel exactly.
+.statistics.basic:
+ Covariance:
+ labels: [undirected, linear, signed, multivariate, contemporaneous, M14]
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: LedoitWolf
+ - estimator: OAS
+
+ Precision:
+ labels: [undirected, linear, signed, multivariate, contemporaneous, M14]
+ dependencies:
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: LedoitWolf
+
+ SpearmanR:
+ labels: [undirected, nonlinear, signed, bivariate, contemporaneous, M14]
+ dependencies:
+ configs:
+ - squared: True
+ - squared: False
+
+ KendallTau:
+ labels: [undirected, nonlinear, signed, bivariate, contemporaneous, M14]
+ dependencies:
+ configs:
+ - squared: False
+
+.statistics.spectral:
+ CoherenceMagnitude:
+ labels: [undirected, linear, unsigned, bivariate, frequency-dependent, M12]
+ dependencies:
+ configs:
+ - fs: 1
+ - fmin: 0
+ fmax: 0.25
+ - fmin: 0.25
+ fmax: 0.5
+
+.statistics.causal:
+ ConvergentCrossMapping:
+ labels: [causal, directed, nonlinear, temporal, signed, M14]
+ dependencies:
+ configs:
+ - statistic: mean
+ embedding_dimension: 1
+ - statistic: max
+ embedding_dimension: 1
+
+.statistics.misc:
+ PowerEnvelopeCorrelation:
+ labels: [undirected, linear, unsigned, bivariate, time-dependent, M11]
+ dependencies:
+ configs:
+ - orth: False
+ log: False
+ absolute: False
diff --git a/tests/parity_failure_config.yaml b/tests/parity_failure_config.yaml
new file mode 100644
index 00000000..271440da
--- /dev/null
+++ b/tests/parity_failure_config.yaml
@@ -0,0 +1,26 @@
+# Config of deliberately misbehaving SPIs, plus one healthy SPI so the run
+# still produces a valid column. Used by tests/test_execution_parity.py to pin
+# that serial and parallel report failure identically.
+failing_spis:
+ AlwaysRaises:
+ labels: [test]
+ configs:
+ - message: deliberate test failure
+
+ AlwaysWarns:
+ labels: [test]
+ configs:
+
+ WrongShape:
+ labels: [test]
+ configs:
+
+ NonFinite:
+ labels: [test]
+ configs:
+
+.statistics.basic:
+ Covariance:
+ labels: [undirected, linear, signed]
+ configs:
+ - estimator: EmpiricalCovariance
diff --git a/tests/phi_demo.ipynb b/tests/phi_demo.ipynb
deleted file mode 100644
index 2574e3be..00000000
--- a/tests/phi_demo.ipynb
+++ /dev/null
@@ -1,416 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Phi Native Implementation Demo\n",
- "\n",
- "This notebook demonstrates the native Python implementation of integrated information (phi) using pyspi functions directly."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Libraries imported successfully!\n"
- ]
- }
- ],
- "source": [
- "import numpy as np\n",
- "import matplotlib.pyplot as plt\n",
- "from pyspi.calculator import Calculator\n",
- "from pyspi.lib.phi_native import phi_comp\n",
- "import tempfile\n",
- "import yaml\n",
- "import time\n",
- "\n",
- "print(\"Libraries imported successfully!\")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## 1. Direct phi_native Function Usage"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Direct phi_native function results:\n",
- "==================================================\n",
- "Config 1: phi_star (norm=0)\n",
- " Value: 0.012398\n",
- " Time: 2.40 ms\n",
- "\n",
- "Config 2: phi_star (norm=1)\n",
- " Value: 0.009339\n",
- " Time: 0.99 ms\n",
- "\n",
- "Config 3: phi_Geo (norm=0)\n",
- " Value: 0.010218\n",
- " Time: 1.88 ms\n",
- "\n",
- "Config 4: phi_Geo (norm=1)\n",
- " Value: 0.007697\n",
- " Time: 2.51 ms\n",
- "\n"
- ]
- }
- ],
- "source": [
- "# Generate test data\n",
- "np.random.seed(42)\n",
- "data = np.random.randn(2, 100) # 2 variables, 100 time points\n",
- "\n",
- "# Set up parameters for phi computation\n",
- "Z = np.array([1, 2]) # Partition\n",
- "params = {\"tau\": 1}\n",
- "\n",
- "# Test different phi configurations\n",
- "configs = [\n",
- " {\"type_of_phi\": \"star\", \"normalization\": 0},\n",
- " {\"type_of_phi\": \"star\", \"normalization\": 1},\n",
- " {\"type_of_phi\": \"Geo\", \"normalization\": 0},\n",
- " {\"type_of_phi\": \"Geo\", \"normalization\": 1}\n",
- "]\n",
- "\n",
- "print(\"Direct phi_native function results:\")\n",
- "print(\"=\" * 50)\n",
- "\n",
- "for i, config in enumerate(configs):\n",
- " options = {**config, \"type_of_dist\": \"Gauss\"}\n",
- " \n",
- " start_time = time.time()\n",
- " phi_value = phi_comp(data, Z, params, options)\n",
- " end_time = time.time()\n",
- " \n",
- " print(f\"Config {i+1}: phi_{config['type_of_phi']} (norm={config['normalization']})\")\n",
- " print(f\" Value: {phi_value:.6f}\")\n",
- " print(f\" Time: {(end_time-start_time)*1000:.2f} ms\")\n",
- " print()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## 2. Calculator with Configuration"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Creating Calculator with phi configuration...\n",
- "Checking if optional dependencies exist...\n",
- "Starting JVM with java class /Users/jmoo2880/Documents/native-phi/pyspi/pyspi/lib/jidt/infodynamics.jar.\n",
- "Loading configuration file: /var/folders/pt/9v0934pn6s1g4klqjbcz65p40000gp/T/tmpnq5m4mj7.yaml\n",
- "*** Importing module .statistics.infotheory\n",
- "[0] Adding SPI .statistics.infotheory.IntegratedInformation(x,y,{'phitype': 'star'})\n",
- "Succesfully initialised SPI with identifier \"phi_star_t-1_norm-0\" and labels ['linear', 'unsigned', 'infotheory', 'temporal', 'undirected']\n",
- "[1] Adding SPI .statistics.infotheory.IntegratedInformation(x,y,{'normalization': 1, 'phitype': 'star'})\n",
- "Succesfully initialised SPI with identifier \"phi_star_t-1_norm-1\" and labels ['linear', 'unsigned', 'infotheory', 'temporal', 'undirected']\n",
- "[2] Adding SPI .statistics.infotheory.IntegratedInformation(x,y,{'phitype': 'Geo'})\n",
- "Succesfully initialised SPI with identifier \"phi_Geo_t-1_norm-0\" and labels ['linear', 'unsigned', 'infotheory', 'temporal', 'undirected']\n",
- "[3] Adding SPI .statistics.infotheory.IntegratedInformation(x,y,{'normalization': 1, 'phitype': 'Geo'})\n",
- "Succesfully initialised SPI with identifier \"phi_Geo_t-1_norm-1\" and labels ['linear', 'unsigned', 'infotheory', 'temporal', 'undirected']\n",
- "====================================================================================================\n",
- "4 SPI(s) were successfully initialised.\n",
- "\n",
- "[1/2] Skipping detrending of time series in the dataset...\n",
- "[2/2] Normalising (z-scoring) each time series in the dataset...\n",
- "\n",
- "Calculator initialized with 4 SPI configurations\n",
- "\n",
- "Computing phi values...\n"
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "Processing [None: phi_Geo_t-1_norm-1]: 100%|██████████| 4/4 [00:00<00:00, 43.44it/s]\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\n",
- "Calculation complete. Time taken: 0.1072s\n",
- "\n",
- "SPI Computation Results Summary\n",
- "============================================================\n",
- "\n",
- "Total number of SPIs attempted: 4\n",
- "Number of SPIs successfully computed: 4 (100.00%)\n",
- "------------------------------------------------------------\n",
- "Category | Count | Percentage\n",
- "------------------------------------------------------------\n",
- "Successful | 4 | 100.00%\n",
- "NaNs | 0 | 0.00%\n",
- "Partial NaNs | 0 | 0.00%\n",
- "------------------------------------------------------------\n",
- "Computation completed in 0.1477 seconds\n",
- "Number of result tables: 3\n"
- ]
- }
- ],
- "source": [
- "# Create phi configuration\n",
- "config = {\n",
- " '.statistics.infotheory': {\n",
- " 'IntegratedInformation': {\n",
- " 'labels': ['undirected', 'nonlinear', 'unsigned', 'bivariate', 'time-dependent'],\n",
- " 'configs': [\n",
- " {'phitype': 'star'},\n",
- " {'phitype': 'star', 'normalization': 1},\n",
- " {'phitype': 'Geo'},\n",
- " {'phitype': 'Geo', 'normalization': 1}\n",
- " ]\n",
- " }\n",
- " }\n",
- "}\n",
- "\n",
- "# Create temporary config file\n",
- "with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:\n",
- " yaml.dump(config, f)\n",
- " config_path = f.name\n",
- "\n",
- "# Use Calculator with configuration\n",
- "np.random.seed(42)\n",
- "test_data = np.random.randn(3, 200) # 3 variables, 200 time points\n",
- "\n",
- "print(\"Creating Calculator with phi configuration...\")\n",
- "calc = Calculator(test_data, configfile=config_path)\n",
- "\n",
- "print(f\"Calculator initialized with {len(calc.spis)} SPI configurations\")\n",
- "\n",
- "# Compute phi values\n",
- "print(\"\\nComputing phi values...\")\n",
- "start_time = time.time()\n",
- "calc.compute()\n",
- "end_time = time.time()\n",
- "\n",
- "print(f\"Computation completed in {end_time - start_time:.4f} seconds\")\n",
- "print(f\"Number of result tables: {len(calc.table)}\")\n",
- "\n",
- "# Clean up\n",
- "import os\n",
- "os.unlink(config_path)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## 3. Results Visualization"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Phi computation results:\n",
- "============================================================\n",
- "\n",
- "('phi_star_t-1_norm-0', 'proc-0'):\n",
- "----------------------------------------\n",
- "proc-0 NaN\n",
- "proc-1 0.003826\n",
- "proc-2 0.010770\n",
- "Name: (phi_star_t-1_norm-0, proc-0), dtype: float64\n",
- " Range: [0.003826, 0.010770]\n",
- " Mean: 0.007298\n",
- " Std: 0.003472\n",
- "\n",
- "('phi_star_t-1_norm-0', 'proc-1'):\n",
- "----------------------------------------\n",
- "proc-0 0.003826\n",
- "proc-1 NaN\n",
- "proc-2 0.005747\n",
- "Name: (phi_star_t-1_norm-0, proc-1), dtype: float64\n",
- " Range: [0.003826, 0.005747]\n",
- " Mean: 0.004787\n",
- " Std: 0.000960\n",
- "\n",
- "('phi_star_t-1_norm-0', 'proc-2'):\n",
- "----------------------------------------\n",
- "proc-0 0.010770\n",
- "proc-1 0.005747\n",
- "proc-2 NaN\n",
- "Name: (phi_star_t-1_norm-0, proc-2), dtype: float64\n",
- " Range: [0.005747, 0.010770]\n",
- " Mean: 0.008259\n",
- " Std: 0.002512\n",
- "\n",
- "('phi_star_t-1_norm-1', 'proc-0'):\n",
- "----------------------------------------\n",
- "proc-0 NaN\n",
- "proc-1 0.002698\n",
- "proc-2 0.007596\n",
- "Name: (phi_star_t-1_norm-1, proc-0), dtype: float64\n",
- " Range: [0.002698, 0.007596]\n",
- " Mean: 0.005147\n",
- " Std: 0.002449\n",
- "\n",
- "('phi_star_t-1_norm-1', 'proc-1'):\n",
- "----------------------------------------\n",
- "proc-0 0.002698\n",
- "proc-1 NaN\n",
- "proc-2 0.004053\n",
- "Name: (phi_star_t-1_norm-1, proc-1), dtype: float64\n",
- " Range: [0.002698, 0.004053]\n",
- " Mean: 0.003376\n",
- " Std: 0.000677\n",
- "\n",
- "('phi_star_t-1_norm-1', 'proc-2'):\n",
- "----------------------------------------\n",
- "proc-0 0.007596\n",
- "proc-1 0.004053\n",
- "proc-2 NaN\n",
- "Name: (phi_star_t-1_norm-1, proc-2), dtype: float64\n",
- " Range: [0.004053, 0.007596]\n",
- " Mean: 0.005824\n",
- " Std: 0.001771\n",
- "\n",
- "('phi_Geo_t-1_norm-0', 'proc-0'):\n",
- "----------------------------------------\n",
- "proc-0 NaN\n",
- "proc-1 0.003210\n",
- "proc-2 0.010399\n",
- "Name: (phi_Geo_t-1_norm-0, proc-0), dtype: float64\n",
- " Range: [0.003210, 0.010399]\n",
- " Mean: 0.006804\n",
- " Std: 0.003595\n",
- "\n",
- "('phi_Geo_t-1_norm-0', 'proc-1'):\n",
- "----------------------------------------\n",
- "proc-0 0.003210\n",
- "proc-1 NaN\n",
- "proc-2 0.005975\n",
- "Name: (phi_Geo_t-1_norm-0, proc-1), dtype: float64\n",
- " Range: [0.003210, 0.005975]\n",
- " Mean: 0.004592\n",
- " Std: 0.001383\n",
- "\n",
- "('phi_Geo_t-1_norm-0', 'proc-2'):\n",
- "----------------------------------------\n",
- "proc-0 0.010399\n",
- "proc-1 0.005975\n",
- "proc-2 NaN\n",
- "Name: (phi_Geo_t-1_norm-0, proc-2), dtype: float64\n",
- " Range: [0.005975, 0.010399]\n",
- " Mean: 0.008187\n",
- " Std: 0.002212\n",
- "\n",
- "('phi_Geo_t-1_norm-1', 'proc-0'):\n",
- "----------------------------------------\n",
- "proc-0 NaN\n",
- "proc-1 0.002264\n",
- "proc-2 0.007334\n",
- "Name: (phi_Geo_t-1_norm-1, proc-0), dtype: float64\n",
- " Range: [0.002264, 0.007334]\n",
- " Mean: 0.004799\n",
- " Std: 0.002535\n",
- "\n",
- "('phi_Geo_t-1_norm-1', 'proc-1'):\n",
- "----------------------------------------\n",
- "proc-0 0.002264\n",
- "proc-1 NaN\n",
- "proc-2 0.004214\n",
- "Name: (phi_Geo_t-1_norm-1, proc-1), dtype: float64\n",
- " Range: [0.002264, 0.004214]\n",
- " Mean: 0.003239\n",
- " Std: 0.000975\n",
- "\n",
- "('phi_Geo_t-1_norm-1', 'proc-2'):\n",
- "----------------------------------------\n",
- "proc-0 0.007334\n",
- "proc-1 0.004214\n",
- "proc-2 NaN\n",
- "Name: (phi_Geo_t-1_norm-1, proc-2), dtype: float64\n",
- " Range: [0.004214, 0.007334]\n",
- " Mean: 0.005774\n",
- " Std: 0.001560\n"
- ]
- }
- ],
- "source": [
- "# Display results\n",
- "print(\"Phi computation results:\")\n",
- "print(\"=\" * 60)\n",
- "\n",
- "for table_name, table_data in calc.table.items():\n",
- " print(f\"\\n{table_name}:\")\n",
- " print(\"-\" * 40)\n",
- " print(table_data)\n",
- "\n",
- " # Show statistics - handle both Series and DataFrame\n",
- " if hasattr(table_data, 'select_dtypes'):\n",
- " # DataFrame case\n",
- " numeric_data = table_data.select_dtypes(include=[np.number])\n",
- " if not numeric_data.empty:\n",
- " finite_values = numeric_data.values[np.isfinite(numeric_data.values)]\n",
- " else:\n",
- " finite_values = []\n",
- " else:\n",
- " # Series case\n",
- " if np.issubdtype(table_data.dtype, np.number):\n",
- " finite_values = table_data.values[np.isfinite(table_data.values)]\n",
- " else:\n",
- " finite_values = []\n",
- "\n",
- " if len(finite_values) > 0:\n",
- " print(f\" Range: [{finite_values.min():.6f}, {finite_values.max():.6f}]\")\n",
- " print(f\" Mean: {finite_values.mean():.6f}\")\n",
- " print(f\" Std: {finite_values.std():.6f}\")"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "pyspi-dev4",
- "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.12.11"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 4
-}
diff --git a/tests/test_SPIs.py b/tests/test_SPIs.py
deleted file mode 100644
index 30eed2e9..00000000
--- a/tests/test_SPIs.py
+++ /dev/null
@@ -1,102 +0,0 @@
-from pyspi.calculator import Calculator
-import pytest
-import dill
-import pyspi
-import numpy as np
-
-############# Fixtures and helper functions #########
-
-def load_benchmark_tables():
- """Function to load the mean and standard deviation tables for each MPI."""
- table_fname = 'CML7_benchmark_tables.pkl'
- with open(f"tests/{table_fname}", "rb") as f:
- loaded_tables = dill.load(f)
-
- return loaded_tables
-
-def load_benchmark_dataset():
- dataset_fname = 'cml7.npy'
- dataset = np.load(f"pyspi/data/{dataset_fname}").T
- return dataset
-
-def compute_new_tables():
- """Compute new tables using the same benchmark dataset(s)."""
- benchmark_dataset = load_benchmark_dataset()
- # Compute new tables on the benchmark dataset
- np.random.seed(42)
- calc = Calculator(dataset=benchmark_dataset)
- calc.compute()
- table_dict = dict()
- for spi in calc.spis:
- table_dict[spi] = calc.table[spi]
-
- return table_dict
-
-def generate_SPI_test_params():
- """Function to generate combinations of benchmark table,
- new table for each MPI"""
- benchmark_tables = load_benchmark_tables()
- new_tables = compute_new_tables()
- params = []
- calc = Calculator()
- spis = list(calc.spis.keys())
- spi_ob = list(calc.spis.values())
- for spi_est, spi_ob in zip(spis, spi_ob):
- params.append((spi_est, spi_ob, benchmark_tables[spi_est], new_tables[spi_est].to_numpy()))
-
- return params
-
-params = generate_SPI_test_params()
-def pytest_generate_tests(metafunc):
- """Create a hook to generate parameter combinations for parameterised test"""
- if "est" in metafunc.fixturenames:
- metafunc.parametrize("est, est_ob, mpi_benchmark,mpi_new", params)
-
-
-def test_mpi(est, est_ob, mpi_benchmark, mpi_new, spi_warning_logger):
- """Run the benchmarking tests."""
- zscore_threshold = 1 # 2 sigma
-
- # separate the the mean and std. dev tables for the benchmark
- mean_table = mpi_benchmark['mean']
- std_table = mpi_benchmark['std']
-
- # check std table for zeros and impute with smallest non-zero value
- std_table = np.where(std_table == 0, 1e-10, std_table)
-
- # check that the shapes are equal
- assert mean_table.shape == mpi_new.shape, f"SPI: {est}| Different table shapes. "
-
- # convert NaNs to zeros before proeceeding - this will take care of diagonal and any null outputs
- mpi_new = np.nan_to_num(mpi_new)
- mpi_mean = np.nan_to_num(mean_table)
-
- # check if matrix is symmetric (undirected SPI) for num exceed correction
- isSymmetric = "undirected" in est_ob.labels
-
- # get the module name for easy reference
- module_name = est_ob.__module__.split(".")[-1]
-
- if not np.allclose(mpi_new, mpi_mean):
- # tables are not equivalent, quantify the difference by z-scoring.
- diff = abs(mpi_new - mpi_mean)
- zscores = diff/std_table
-
- idxs_greater_than_thresh = np.argwhere(zscores > zscore_threshold)
-
- if len(idxs_greater_than_thresh) > 0:
- sigs = zscores[idxs_greater_than_thresh[:, 0], idxs_greater_than_thresh[:, 1]]
- # get the max
- max_z = max(sigs)
-
- # number of interactions
- num_interactions = mpi_new.size - mpi_new.shape[0]
- # count exceedances
- num_exceed = len(sigs)
-
- if isSymmetric:
- # number of unique exceedences is half
- num_exceed //= 2
- num_interactions //= 2
-
- spi_warning_logger(est, module_name, max_z, int(num_exceed), int(num_interactions))
diff --git a/tests/test_baseline_drift.py b/tests/test_baseline_drift.py
new file mode 100644
index 00000000..c64c507e
--- /dev/null
+++ b/tests/test_baseline_drift.py
@@ -0,0 +1,349 @@
+"""Baseline drift detector for the full SPI set.
+
+For each (dataset, SPI) pair this recomputes the SPI on a frozen dataset and
+compares it element-wise against a stored baseline matrix.
+
+What is ENFORCED (hard assertion failure):
+ * the baseline SPI set and the current Calculator's SPI set are identical,
+ so a newly-broken or renamed SPI cannot disappear by having no baseline;
+ * the shape of each matrix;
+ * the NaN *pattern*. A SPI going from finite to all-NaN (or back) is a
+ categorical regression, not drift — this fork changed NaN-on-failure
+ semantics, so that is precisely the signal worth failing on.
+
+What is also ENFORCED:
+ * numerical drift in the finite entries. Exceedances are routed to a
+ session-end summary table via ``spi_warning_logger`` (see conftest.py) and
+ hard-fail this test.
+
+Baselines live in ``tests/data/baselines/.npz`` (one MxM array per
+SPI identifier) and are regenerated from the *current* fork by
+``tests/tools/generate_benchmark_tables.py``. They are a forward-looking
+change detector, not an independent oracle: the fork deliberately rewrote the
+information-theoretic estimators, so upstream pyspi 2.0.1 values are the wrong
+reference for exactly the code that most needs one. Independent correctness
+lives in analytic and structural contract tests, including
+``test_infotheory_analytic.py`` and ``test_structural_traits.py``.
+
+Frozen fixtures live in ``tests/data/fixtures/`` (not in ``pyspi/data/``: they
+are test inputs, not shipped demo data) and are built by
+``tests/tools/generate_fixtures.py``. Three generating processes at three widths
+— VAR(1) at M=3, coupled map lattice at M=5, Kuramoto at M=7, all T=100 — so the
+SPI set is exercised across a range of M.
+"""
+import os
+
+import sys
+
+import numpy as np
+import pytest
+
+from pyspi.calculator import Calculator
+from pyspi.data import Data
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
+# Single source of truth for the documented per-fixture exceptions, shared with
+# the generator so the two cannot disagree about what is expected to fail.
+from generate_benchmark_tables import KNOWN_UNESTIMABLE # noqa: E402
+
+# Whole-file marker: this suite takes ~3.5 minutes. Skipped by default; run with
+# pytest -m slow tests/test_baseline_drift.py
+# or
+# pytest -m '' tests/
+pytestmark = pytest.mark.slow
+
+DATASETS = ("var1_M3_T100", "cml_M5_T100", "kuramoto_M7_T100")
+
+_DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
+BASELINE_DIR = os.path.join(_DATA_DIR, "baselines")
+FIXTURE_DIR = os.path.join(_DATA_DIR, "fixtures")
+
+# Seed used when the baselines were generated; must match the generator's
+# default so the RNG-consuming SPIs land in the same place.
+SEED = 42
+
+# Drift thresholds. A value is OK if EITHER the absolute or the relative test
+# passes (the absolute one protects near-zero references).
+#
+# TIGHT is the default: the benchmark datasets are frozen and every SPI
+# measured so far is a deterministic function of them, so re-running the same
+# code on the same machine reproduces the baseline to float round-off. 1e-9
+# sits well above that (~1e-12) while still catching genuine sub-percent
+# regressions that the old blanket RTOL=1e-2 would have hidden.
+TIGHT = (1e-12, 1e-9) # (atol, rtol)
+# LOOSE is for SPIs that are genuinely not a pure function of the data under
+# this suite's protocol (seed the global RNG, then compute).
+LOOSE = (1e-6, 1e-2)
+
+# Per-SPI, not per-module. The previous version applied LOOSE to every SPI in
+# the `causal` and `misc` modules on the assumption that the causal-discovery
+# optimisers, GP
+# restarts and randomised independence tests made them irreproducible. Measured,
+# that is false: `tests/tools/measure_reproducibility.py` computes the full
+# config twice per fixture and, on all three fixtures, **322 of 322 SPIs
+# reproduce bit-exactly** -- max |difference| identically 0, including every
+# `anm`/`cds`/`reci`/`ccm`, every `coint_*`, `gpfit_*` (GaussianProcessRegressor
+# defaults to n_restarts_optimizer=0, so there are no random restarts),
+# `lmfit_*` (random_state pinned to 42) and `ids` (consumes the global RNG,
+# which the suite seeds). A module-wide 1e-2 band over 62 SPIs, ~50 of them
+# deterministic, is slack that hides deterministic regressions -- exactly the
+# failure mode this suite exists to catch.
+#
+# So the map is empty, and that is a measurement, not an assumption. Re-measure
+# with:
+# python tests/tools/measure_reproducibility.py
+# and add an entry here -- keyed by SPI identifier, valued (atol, rtol) -- for
+# anything that comes back non-zero, with the mechanism named. The tier stays
+# defined because a genuinely stochastic SPI (an unpinned permutation test, a
+# GPU-backed estimator) is a plausible future addition, and it needs a home
+# that is not "the whole module it happens to live in".
+LOOSE_SPIS = {} # e.g. {"some_stochastic_spi": LOOSE}
+
+
+def _baseline_path(dataset_name):
+ return os.path.join(BASELINE_DIR, f"{dataset_name}.npz")
+
+
+def _load_fixture(dataset_name):
+ """Load a frozen fixture; stored (observations, processes) -> 'sp'.
+
+ Must stay in step with ``tests/tools/generate_benchmark_tables.load_fixture``,
+ or the baselines and the test would be reading different data.
+ """
+ return Data(data=os.path.join(FIXTURE_DIR, f"{dataset_name}.npy"),
+ dim_order="sp", name=dataset_name)
+
+
+def _baseline_keys(dataset_name):
+ """SPI identifiers stored in a baseline archive, or None if unreadable.
+
+ Reads only the zip central directory, so this is cheap enough to run at
+ collection time. Returning None instead of raising is deliberate: a missing
+ or corrupt baseline must never break collection of unrelated tests.
+ """
+ try:
+ with np.load(_baseline_path(dataset_name)) as archive:
+ return sorted(k for k in archive.files if not k.startswith("__"))
+ except Exception:
+ return None
+
+
+def pytest_generate_tests(metafunc):
+ """Parametrise over (dataset, SPI) using only the baseline archives.
+
+ Nothing here constructs a Calculator or decompresses a matrix; the actual
+ tables come from session-scoped fixtures, so importing this module costs
+ nothing.
+ """
+ if "spi_key" not in metafunc.fixturenames:
+ return
+ params = []
+ for dataset_name in DATASETS:
+ keys = _baseline_keys(dataset_name)
+ if keys is None:
+ params.append(pytest.param(
+ dataset_name, None,
+ marks=pytest.mark.skip(
+ reason=f"missing/unreadable baseline {_baseline_path(dataset_name)}"
+ ),
+ id=f"{dataset_name}:",
+ ))
+ continue
+ params.extend(pytest.param(dataset_name, k, id=f"{dataset_name}:{k}")
+ for k in keys)
+ metafunc.parametrize("dataset_name, spi_key", params)
+
+
+@pytest.fixture(scope="session")
+def baseline_tables():
+ """dataset -> {spi_key: matrix}, loaded once per session on first use."""
+ cache = {}
+
+ def get(dataset_name):
+ if dataset_name not in cache:
+ with np.load(_baseline_path(dataset_name)) as archive:
+ cache[dataset_name] = {
+ k: archive[k] for k in archive.files if not k.startswith("__")
+ }
+ return cache[dataset_name]
+
+ return get
+
+
+@pytest.fixture(scope="session")
+def current_tables():
+ """dataset -> (tables, spi_objects); one full Calculator run per dataset."""
+ cache = {}
+
+ def get(dataset_name):
+ if dataset_name not in cache:
+ np.random.seed(SEED)
+ calc = Calculator(dataset=_load_fixture(dataset_name))
+ calc.compute()
+ cache[dataset_name] = (
+ {spi: calc.table[spi].to_numpy() for spi in calc.spis},
+ dict(calc.spis),
+ dict(calc.errors),
+ )
+ return cache[dataset_name]
+
+ return get
+
+
+@pytest.mark.parametrize("dataset_name", DATASETS)
+def test_baseline_covers_every_spi(dataset_name, baseline_tables, current_tables):
+ """The baseline and the current Calculator must expose the same SPI set.
+
+ Without this, an SPI that is renamed or newly added is silently untested,
+ which is how ~45-50 SPIs per dataset escaped the old suite.
+ """
+ baseline = baseline_tables(dataset_name)
+ _, spis, _ = current_tables(dataset_name)
+ missing_from_baseline = sorted(set(spis) - set(baseline))
+ missing_from_current = sorted(set(baseline) - set(spis))
+ assert not missing_from_baseline and not missing_from_current, (
+ f"[{dataset_name}] SPI set mismatch. "
+ f"No baseline for: {missing_from_baseline}. "
+ f"Baseline-only: {missing_from_current}. "
+ f"Regenerate with tests/tools/generate_benchmark_tables.py."
+ )
+
+
+def test_baseline_drift(dataset_name, spi_key, baseline_tables, current_tables,
+ spi_warning_logger):
+ """Hard-fail on shape, NaN-pattern, or excessive numerical drift."""
+ ref = baseline_tables(dataset_name)[spi_key]
+ tables, spis, _ = current_tables(dataset_name)
+ assert spi_key in tables, (
+ f"[{dataset_name}] {spi_key}: present in baseline but not in the current "
+ f"Calculator (see test_baseline_covers_every_spi)."
+ )
+ new = tables[spi_key]
+
+ assert ref.shape == new.shape, (
+ f"[{dataset_name}] {spi_key}: shape mismatch "
+ f"baseline={ref.shape} new={new.shape}"
+ )
+
+ # --- Enforced: NaN pattern -------------------------------------------
+ # Compared as masks, never coerced to 0. Folding NaN into 0 on both sides
+ # (the old behaviour) turns "this SPI now fails everywhere" into a small
+ # numeric drift entry, hiding the one regression class that matters most.
+ ref_nan = ~np.isfinite(ref)
+ new_nan = ~np.isfinite(new)
+ if not np.array_equal(ref_nan, new_nan):
+ gained = int(np.sum(new_nan & ~ref_nan))
+ lost = int(np.sum(ref_nan & ~new_nan))
+ pytest.fail(
+ f"[{dataset_name}] {spi_key}: non-finite pattern changed "
+ f"({gained} entries became NaN/inf, {lost} became finite). "
+ f"baseline non-finite={int(ref_nan.sum())}/{ref.size}, "
+ f"current non-finite={int(new_nan.sum())}/{new.size}."
+ )
+
+ # --- Enforced: a baseline with nothing in it is not an oracle ---------
+ # `if not finite.any(): return` used to pass here, so an SPI that produced
+ # an all-NaN column at freeze time was recorded as such and then agreed
+ # with itself forever. All three `gd_*` SPIs sat in that state.
+ off_diagonal = ~np.eye(ref.shape[0], dtype=bool)
+ if spi_key in KNOWN_UNESTIMABLE.get(dataset_name, {}):
+ pytest.skip(KNOWN_UNESTIMABLE[dataset_name][spi_key])
+ assert np.isfinite(ref[off_diagonal]).any(), (
+ f"[{dataset_name}] {spi_key}: the frozen baseline has no finite "
+ f"off-diagonal value. An empty column cannot detect a regression; "
+ f"either the SPI is broken or it does not belong in the config."
+ )
+
+ finite = ~ref_nan
+
+ module_name = spis[spi_key].__module__.split(".")[-1]
+ atol, rtol = LOOSE_SPIS.get(spi_key, TIGHT)
+
+ abs_diff = np.zeros_like(ref, dtype=np.float64)
+ abs_diff[finite] = np.abs(new[finite] - ref[finite])
+ ok = ~finite | (abs_diff <= atol) | (abs_diff <= rtol * np.abs(np.nan_to_num(ref)))
+ if np.all(ok):
+ return
+
+ max_abs = float(abs_diff[~ok].max())
+ with np.errstate(divide="ignore", invalid="ignore"):
+ rel = np.where(np.abs(ref) > 0, abs_diff / np.abs(ref), np.nan)
+ bad_rel = rel[~ok]
+ bad_rel = bad_rel[np.isfinite(bad_rel)]
+ max_rel = float(bad_rel.max()) if bad_rel.size else float("nan")
+
+ num_interactions = new.size - new.shape[0]
+ num_exceed = int(np.count_nonzero(~ok))
+ if "undirected" in spis[spi_key].labels:
+ num_exceed //= 2
+ num_interactions //= 2
+
+ spi_warning_logger(
+ f"{dataset_name}:{spi_key}",
+ module_name,
+ max_abs,
+ max_rel,
+ num_exceed,
+ num_interactions,
+ )
+ # Reported *and* failed. Logging alone made every tolerance in this file
+ # decorative: a deterministic SPI could move by any amount and the suite
+ # still exited 0, with the evidence in a summary banner nobody gates on.
+ pytest.fail(
+ f"[{dataset_name}] {spi_key}: {num_exceed} of {num_interactions} "
+ f"interaction(s) exceed the drift tolerance "
+ f"(atol={atol:g}, rtol={rtol:g}); max |delta|={max_abs:.4g}, "
+ f"max relative={max_rel:.4g}. If the change is intended, say why in "
+ f"CHANGELOG.md and regenerate with "
+ f"tests/tools/generate_benchmark_tables.py."
+ )
+
+
+@pytest.mark.parametrize("dataset_name", DATASETS)
+def test_no_spi_raises_on_the_frozen_fixtures(dataset_name, current_tables):
+ """A completed computation is not the same as a clean one.
+
+ `Calculator.compute()` catches per-SPI exceptions and records them in
+ `calc.errors`, so the suite could run the whole config to completion over a
+ table with failed columns in it and report nothing. Nothing in the config
+ is expected to fail on these fixtures; if something legitimately cannot be
+ estimated on data this small, the exception belongs in an explicit
+ allow-list here with the statistical reason, not in silence.
+ """
+ _, _, errors = current_tables(dataset_name)
+ expected = KNOWN_UNESTIMABLE.get(dataset_name, {})
+ unexpected = {k: v for k, v in errors.items() if k not in expected}
+ assert not unexpected, (
+ f"[{dataset_name}] {len(unexpected)} SPI(s) raised:\n "
+ + "\n ".join(f"{k}: {v}" for k, v in sorted(unexpected.items()))
+ )
+ still_failing = sorted(set(expected) - set(errors))
+ assert not still_failing, (
+ f"[{dataset_name}] these are listed as unestimable but now succeed; "
+ f"remove them from KNOWN_UNESTIMABLE:\n " + "\n ".join(still_failing)
+ )
+
+
+@pytest.mark.parametrize("dataset_name", DATASETS)
+def test_every_spi_produces_a_finite_value_on_the_frozen_fixtures(
+ dataset_name, current_tables):
+ """No shipped SPI may be an entirely empty column.
+
+ Partial NaN is legitimate and common -- `gd_*` is defined only where the
+ coherence is significant, `sgc_*` where the factorisation converges. An
+ SPI with *no* finite off-diagonal value anywhere is not a measurement.
+ """
+ tables, _, _ = current_tables(dataset_name)
+ expected = KNOWN_UNESTIMABLE.get(dataset_name, {})
+ empty = []
+ for key, matrix in tables.items():
+ if key in expected:
+ continue
+ matrix = np.asarray(matrix, dtype=float)
+ off_diagonal = ~np.eye(matrix.shape[0], dtype=bool)
+ if not np.isfinite(matrix[off_diagonal]).any():
+ empty.append(key)
+ assert not empty, (
+ f"[{dataset_name}] {len(empty)} SPI(s) produced no finite value:\n "
+ + "\n ".join(sorted(empty))
+ )
diff --git a/tests/test_cache_keys.py b/tests/test_cache_keys.py
new file mode 100644
index 00000000..cd920d3b
--- /dev/null
+++ b/tests/test_cache_keys.py
@@ -0,0 +1,157 @@
+"""Parameterised statistic caches must key on every parameter.
+
+These began as red tests and are now green. The spectral cache had two
+defects that compounded:
+
+1. ``NonparametricSpectral.key`` omitted ``fs``, so two SPIs differing only in
+ sampling frequency collided in the cache.
+2. ``_get_cache`` *wrote* the first result under ``self.measure`` (a plain
+ string) but *read* under ``self.key`` (a tuple). The first write was
+ therefore unreachable, and staleness only surfaced from the third call
+ onward, once a tuple-keyed entry finally existed.
+
+Defect 2 is why a naive two-call probe reports no problem. The sequence below
+(fs=1 -> 4 -> 1 -> 4) is the minimum that exposes it, and every value is
+compared against a freshly-constructed Data rather than against its
+predecessor.
+
+Keep these as regression tests: both defects were invisible to the obvious
+probe, and the second would return silently wrong numbers if reintroduced.
+"""
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+from pyspi.statistics.spectral import CoherenceMagnitude
+
+
+def _fixture_data():
+ rng = np.random.default_rng(0)
+ return Data(data=rng.standard_normal((3, 128)), dim_order="ps", zscore=False)
+
+
+# An explicit band is required for fs to matter: with the default fmax=fs/2 the
+# whole spectrum is selected whatever fs is, so both settings legitimately agree
+# and the test would be vacuous. With a fixed [0, 0.25] band, fs changes which
+# frequency bins fall inside it.
+BAND = dict(fmin=0.0, fmax=0.25)
+
+
+def _spi(fs):
+ return CoherenceMagnitude(fs=fs, **BAND)
+
+
+def _fresh_value(fs):
+ """Ground truth: a brand-new Data can never serve a stale cache entry."""
+ return _spi(fs).multivariate(_fixture_data())[0, 1]
+
+
+def test_spectral_cache_distinguishes_sampling_frequency():
+ """Alternating fs on one Data must match a fresh Data at every step."""
+ data = _fixture_data()
+ truth = {1: _fresh_value(1), 4: _fresh_value(4)}
+
+ assert not np.isclose(truth[1], truth[4]), (
+ "Test is vacuous: fs=1 and fs=4 give the same value on this fixture."
+ )
+
+ observed = []
+ for step, fs in enumerate((1, 4, 1, 4), start=1):
+ got = _spi(fs).multivariate(data)[0, 1]
+ observed.append((step, fs, got, truth[fs]))
+
+ bad = [o for o in observed if not np.isclose(o[2], o[3], equal_nan=True)]
+ assert not bad, "Stale cache hits at " + ", ".join(
+ f"call {s} (fs={f}): got {g:.6g}, fresh Data gives {t:.6g}" for s, f, g, t in bad
+ )
+
+
+def test_spectral_cache_uses_one_key_type():
+ """The written and read cache keys must be the same type."""
+ data = _fixture_data()
+ # Two calls: the first creates the dict with a *string* key, the second
+ # misses on the tuple lookup and inserts a *tuple* key alongside it.
+ _spi(1).multivariate(data)
+ _spi(1).multivariate(data)
+
+ keys = list(data.spectral_mv.keys())
+ stat_keys = [k for k in keys if k != "freq"]
+ kinds = {type(k).__name__ for k in stat_keys}
+
+ assert len(kinds) == 1, (
+ f"Cache holds mixed key types {kinds} ({stat_keys!r}); the first write is "
+ "unreachable by the reader."
+ )
+
+
+def test_cache_key_covers_every_identifier_parameter():
+ """Any parameter that changes the identifier must change the cache key."""
+ a, b = _spi(1), _spi(4)
+ assert a.identifier != b.identifier, "Precondition: fs must reach the identifier."
+ assert a.key != b.key, (
+ f"fs changes the identifier ({a.identifier!r} vs {b.identifier!r}) but not "
+ f"the cache key ({a.key!r} == {b.key!r})."
+ )
+
+
+@pytest.mark.slow
+def test_cache_sharing_never_changes_a_value():
+ """The automatic coverage check: caching must be an optimisation only.
+
+ A per-SPI check that "different identifier implies different cache key" is
+ the wrong invariant -- several classes cache a shared intermediate on
+ purpose and apply the differing parameters *after* the lookup
+ (`CoherenceMagnitude` caches one connectivity per (measure, fs) and takes
+ the band statistic from it; `Cointegration` caches one Johansen fit and
+ reads two statistics off it). What must hold is the consequence: computing
+ the whole config against one Data, where every cache is shared, must give
+ exactly what computing each SPI against its own Data gives.
+
+ That is mechanical, needs no per-class knowledge, and fails precisely when
+ a parameter that changes the cached value is missing from the key -- the
+ second SPI would be served the first one's intermediate. `dyn_corr_excl`
+ and the spectral `fs` were both of that shape.
+ """
+ import os
+
+ from pyspi.calculator import Calculator, load_spis_from_yaml, resolve_config
+
+ fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "var1_M3_T100.npy")
+
+ np.random.seed(42)
+ shared = Calculator(dataset=Data(data=fixture, dim_order="sp"))
+ shared.compute()
+
+ # Soft-DTW barycentres are fitted by gradient descent seeded from the
+ # global RNG, so isolating one moves the RNG position rather than the
+ # cache; `tests/tools/measure_reproducibility.py` shows they reproduce
+ # exactly when the config is computed twice in the same order.
+ RNG_DEPENDENT = {"bary_sgddtw_mean", "bary_sgddtw_max",
+ "bary-sq_sgddtw_mean", "bary-sq_sgddtw_max"}
+
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ mismatched = []
+ for identifier, spi in spis.items():
+ if getattr(type(spi), "_cache_namespace", None) is None:
+ continue
+ if identifier in RNG_DEPENDENT:
+ continue
+ np.random.seed(42)
+ isolated = np.asarray(spi.multivariate(Data(data=fixture, dim_order="sp")),
+ dtype=float)
+ got = shared.table[identifier].to_numpy(dtype=float)
+ # Off-diagonal only: `_parallel.run_spi` NaNs the diagonal on the way
+ # into the table, and several `multivariate` implementations do not.
+ off = ~np.eye(got.shape[0], dtype=bool)
+ if not np.allclose(isolated[off], got[off], rtol=1e-9, atol=1e-12,
+ equal_nan=True):
+ mismatched.append(
+ f"{identifier} (max|diff|="
+ f"{np.nanmax(np.abs(isolated[off] - got[off])):.3g})")
+
+ assert not mismatched, (
+ "these SPIs differ depending on whether their cache was shared, so a "
+ "parameter that changes the cached value is missing from the cache "
+ "key:\n " + "\n ".join(sorted(mismatched))
+ )
diff --git a/tests/test_calc.py b/tests/test_calc.py
deleted file mode 100644
index 4efb2fa8..00000000
--- a/tests/test_calc.py
+++ /dev/null
@@ -1,308 +0,0 @@
-from pyspi.calculator import Calculator, Data, CalculatorFrame
-from pyspi.data import load_dataset
-import numpy as np
-import os
-import pytest
-
-############################# Test Calculator Object ########################
-def test_whether_calculator_instantiates():
- """Basic test to check whether or not the calculator will instantiate."""
- calc = Calculator()
- assert isinstance(calc, Calculator), "Calculator failed to instantiate."
-
-def test_whether_calculator_computes():
- # check whether the calculator runs
- data = np.random.randn(3, 100)
- calc = Calculator(dataset=data)
- calc.compute()
-
-def test_whether_calc_instantiates_without_octave():
- # set octave to false to emulate a system without octave (i.e., fails the check)
- Calculator._optional_dependencies['octave'] = False
- calc = Calculator()
- is_initialised = isinstance(calc, Calculator)
- Calculator._optional_dependencies = {}
- assert is_initialised, "Calculator failed to instantiate without Octave."
-
-def test_whether_calc_instantiates_without_java():
- # set java to false and all other deps to true
- Calculator._optional_dependencies['java'] = False
- Calculator._optional_dependencies['octave'] = True
- calc = Calculator()
- is_initialised = isinstance(calc, Calculator)
- Calculator._optional_dependencies = {}
- assert is_initialised, "Calculator failed to instantiate without Java."
-
-def test_whether_calc_instantiates_wo_optional_deps():
- # set all optional deps to false
- Calculator._optional_dependencies['java'] = False
- Calculator._optional_dependencies['octave'] = False
- calc = Calculator()
- is_initialised = isinstance(calc, Calculator)
- Calculator._optional_dependencies = {}
- assert is_initialised, "Calculator failed to instantiate without optional dependencies."
-
-@pytest.mark.parametrize("subset", [
- 'fabfour',
- 'fast',
- 'sonnet'
-])
-def test_whether_calculator_instantiates_with_subsets(subset):
- """Test whether the calculator instantiates with each of the available subsets"""
- calc = Calculator(subset=subset)
- assert isinstance(calc, Calculator), "Calculator failed to instantiate"
-
-def test_whether_invalid_subset_throws_error():
- """Test whether the calculator fails to instantiate with an invalid subset"""
- with pytest.raises(ValueError) as excinfo:
- calc = Calculator(subset='nviutw')
- assert "Subset 'nviutw' does not exist" in str(excinfo.value), "Subset not found error not displaying."
-
-def test_whether_calculator_compute_fails_with_no_dataset():
- """Test whether the calculator fails to compute SPIs when no dataset is provided."""
- calc = Calculator()
- with pytest.raises(AttributeError) as excinfo:
- calc.compute()
- assert "Dataset not loaded yet" in str(excinfo.value), "Dataset not loaded yet error not displaying."
-
-def test_calculator_name():
- """Test whether the calculator name is retrieved correctly."""
- calc = Calculator(name="test name")
- assert calc.name == "test name", "Calculator name property did not return the expected string 'test name'"
-
-def test_calculator_labels():
- """Test whether the calculator labels are retreived correctly, when provided."""
- test_labels = ['label1', 'label2']
- calc = Calculator(labels = test_labels)
- assert calc.labels == ['label1', 'label2'], f"Calculator labels property did not return the expected list: {test_labels} "
-
-def test_pass_single_integer_as_dataset():
- """Test whether correct error is thrown when incorrect data type passed into calculator."""
- with pytest.raises(TypeError) as excinfo:
- calc = Calculator(dataset=42)
- assert "Unknown data type" in str(excinfo.value), "Incorrect data type error not displaying for integer dataset."
-
-def test_pass_incorrect_shape_dataset_into_calculator():
- """Test whether an error is thrown when incorrect dataset shape is passed into calculator."""
- dataset_with_wrong_dim = np.random.randn(3, 5, 10)
- with pytest.raises(RuntimeError) as excinfo:
- calc = Calculator(dataset=dataset_with_wrong_dim)
- assert "Data array dimension (3)" in str(excinfo.value), "Incorrect dimension error message not displaying for incorrect shape dataset."
-
-@pytest.mark.parametrize("nan_loc, expected_output", [
- ([1], "[1]"),
- ([1, 2], "[1 2]"),
- ([0, 2, 3], "[0 2 3]")
- ])
-def test_pass_dataset_with_nan_into_calculator(nan_loc, expected_output):
- """Check whether ValueError is raised when a dataset containing a NaN is passed into the calculator object"""
- base_dataset = np.random.randn(5, 100)
- for loc in nan_loc:
- base_dataset[loc, 0] = np.nan
- with pytest.raises(ValueError) as excinfo:
- calc = Calculator(dataset=base_dataset)
- assert f"non-numerics (NaNs) in processes: {expected_output}" in str(excinfo), "NaNs not detected in dataset when loading into Calculator!"
-
-def test_pass_dataset_with_inf_into_calculator():
- """Check whether ValueError is raised when a dataset containing an inf/-inf value is passed into the calculator object"""
- base_dataset = np.random.randn(5, 100)
- base_dataset[0, 1] = np.inf
- base_dataset[2, 2] = -np.inf
- with pytest.raises(ValueError) as excinfo:
- calc = Calculator(dataset=base_dataset)
- assert f"non-numerics (NaNs) in processes: [0 2]" in str(excinfo), "NaNs not detected in dataset when loading into Calculator!"
-
-@pytest.mark.parametrize("shape, n_procs_expected, n_obs_expected", [
- ((2, 23), 2, 23),
- ((5, 4), 5, 4),
- ((100, 32), 100, 32)
-])
-def test_data_object_process_and_observations(shape, n_procs_expected, n_obs_expected):
- """Test whether the number of processes and observations for a given dataset is correct"""
- dat = np.random.randn(shape[0], shape[1])
- calc = Calculator(dataset=dat)
- assert calc.dataset.n_observations == n_obs_expected, f"Number of observations returned by Calculator ({calc.dataset.n_observations}) does not match exepected: {n_obs_expected}"
- assert calc.dataset.n_processes == n_procs_expected, f"Number of processes returned by Calculator ({calc.dataset.n_processes}) does not match exepected: {n_procs_expected}"
-
-@pytest.mark.parametrize("yaml_filename", [
- 'fabfour_config',
- 'fast_config',
- 'sonnet_config'])
-def test_whether_config_files_exist(yaml_filename):
- """Check whether the config, fabfour, fast, sonnet_config files exist"""
- expected_file = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'pyspi', f'{yaml_filename}.yaml'))
- assert os.path.isfile(expected_file), f"{yaml_filename}.yaml file was not found."
-
-@pytest.mark.parametrize("subset, procs, obs", [
- ("all", 2, 100),
- ("all", 5, 100),
- ("fabfour", 8, 100),
- ("fast", 10, 100),
- ("sonnet", 3, 100)
-])
-def test_whether_table_shape_correct_before_compute(subset, procs, obs):
- """Test whether the pre-configured table is the correct shape prior to computing SPIs."""
- dat = np.random.randn(procs, obs)
- calc = Calculator(dataset=dat, subset=subset)
- num_spis = calc.n_spis
- expected_table_shape = (procs, num_spis*procs)
- assert calc.table.shape == expected_table_shape, f"Calculator table ({subset}) shape: ({calc.table.shape}) does not match expected shape: {expected_table_shape}"
-
-############################# Test Data Object ########################
-def test_data_object_has_been_converted_to_numpyfloat64():
- """Test whether the data object converts passed dataset to numpy array by default."""
- dat = np.random.randn(5, 10)
- calc = Calculator(dataset=dat)
- assert calc.dataset.data_type == np.float64, "Dataset was not converted into a numpy array when loaded into Calculator."
-
-def test_whether_data_instantiates():
- """Test whether the data object instantiates without issue."""
- data_obj = Data()
- assert isinstance(data_obj, Data), "Data object failed to instantiate!"
-
-def test_whether_data_throws_error_when_retrieving_nonexistent_dataset():
- """Test whether the data object throws correct message when trying to access a non-existent dataset."""
- data_obj = Data()
- with pytest.raises(AttributeError) as excinfo:
- dataset = data_obj.data
- assert "'Data' object has no attribute 'data'" in str(excinfo), "Unexpected error message when trying to retrieve non-existent dataset!"
-
-def test_whether_data_throws_error_when_incorrect_dataset_type():
- """Test if correct message is shown when passing invalid dataset data type into data object."""
- with pytest.raises(TypeError) as excinfo:
- d = Data(data=3)
- assert f"Unknown data type" in str(excinfo), "Incorrect error message thrown when invalid dataset loaded into data object."
-
-@pytest.mark.parametrize("order, shape, n_procs_expected, n_obs_expected", [
- ("ps", (3, 100), 3, 100),
- ("sp", (100, 3), 3, 100)
-])
-def test_whether_dim_order_works(order, shape, n_procs_expected, n_obs_expected):
- """Check that ps and sp correctly specify order of process/obseravtions"""
- dataset = np.random.randn(shape[0], shape[1])
- d = Data(data=dataset, dim_order=order)
- assert d.n_processes == n_procs_expected, f"Number of processes does not match expected for specified dim order: {order}"
- assert d.n_observations == n_obs_expected, f"Number of observations does not match expected for specified dim order: {order}"
-
-def test_whether_data_name_assigned_only_with_dataset():
- """If no dataset is provided, there is no name for the data object (N/A)"""
- d = Data(name='test')
- assert d.name == 'N/A', "Data object name is not N/A when no dataset provided."
-
-def test_whether_data_object_has_name_with_dataset():
- """If dataset is provided, the name will be returned"""
- dataset = np.random.randn(4, 100)
- d = Data(data=dataset, name='test')
- assert d.name == "test", f"Data object name 'test' is not being returned. Instead, {d.name} is returned."
-
-def test_whether_data_normalise_works():
- """Check whether the data is being normalised by default when loading into data object"""
- dataset = 4 * np.random.randn(10, 500)
- d = Data(data=dataset, normalise=True)
- returned_dataset = d.to_numpy(squeeze=True)
- assert returned_dataset.mean() == pytest.approx(0, 1e-8), f"Returned dataset mean is not close to zero: {returned_dataset.mean()}"
- assert returned_dataset.std() == pytest.approx(1, 0.01), f"Returned dataset std is not close to one: {returned_dataset.std()}"
-
-def test_whether_set_data_works():
- """Check whether existing dataset is overwritten by new dataset"""
- old_dataset = np.random.randn(1, 100)
- d = Data(data=old_dataset) # start with empty data object
- new_dataset = np.random.randn(5, 100)
- d.set_data(data=new_dataset)
- # just check the shapes since new datast will be normalised and not equal to the dataset passed in
- assert d.to_numpy(squeeze=True).shape[0] == 5, "Unexpected dataset returned when overwriting existing dataset!"
-
-def test_add_univariate_process_to_existing_data_object():
- # start with initial data object
- dataset = np.random.randn(5, 100)
- orig_data_object = Data(data=dataset)
- # now add additional proc to existing data object
- new_univariate_proc = np.random.randn(1, 100)
- orig_data_object.add_process(proc=new_univariate_proc)
- assert orig_data_object.n_processes == 6, "New dataset number of processes not equal to expected number of processes."
-
-def test_add_multivariate_process_to_existing_data_object():
- """Should not work, can only add univariate process with add_process function"""
- dataset = np.random.randn(5, 100)
- orig_data_object = Data(data=dataset)
- # now add additional procs to existing data object
- new_multivariate_proc = np.random.randn(2, 100)
- with pytest.raises(TypeError) as excinfo:
- orig_data_object.add_process(proc=new_multivariate_proc)
- assert "Process must be a 1D numpy array" in str(excinfo.value), "Expected 1D array error NOT thrown."
-
-# @pytest.mark.parametrize("index",
-# [[1], [1, 3], [1, 2, 3]])
-# def test_remove_valid_process_from_existing_dataset(index):
-# """Try to remove valid processes from existing dataset by specifying one or more indices.
-# Check if correct indices are being used."""
-# dataset = np.random.randn(5, 100)
-# d = Data(data=dataset, normalise=False)
-# rows_to_remove = index
-# expected_dataset = np.delete(dataset, rows_to_remove, axis=0)
-# d.remove_process(index)
-# out = d.to_numpy(squeeze=True)
-# assert out.shape[0] == (5 - len(index)), f"Dataset shape after removing {len(index)} proc(s) not equal to {(5 - len(index))}"
-# assert np.array_equal(expected_dataset, out), f"Expected dataset after removing proc(s): {index} not equal to dataset returned."
-
-@pytest.mark.parametrize("dataset_name", ["forex", "cml"])
-def test_load_valid_dataset(dataset_name):
- """Test whether the load_dataset function will load all available datasets."""
- dataset = load_dataset(dataset_name)
- assert isinstance(dataset, Data), f"Could not load dataset: {dataset_name}"
-
-def test_load_invalid_dataset():
- """Test whether the load_dataset function throws the correct error/message when trying to load non-existent dataset."""
- with pytest.raises(NameError) as excinfo:
- dataset = load_dataset(name="test")
- assert "Unknown dataset: test" in str(excinfo.value), "Did not get expected error when loading invalid dataset."
-
-def test_calculator_frame_normal_operation():
- """Test whether the calculator frame instantiates as expected."""
- datasets = [np.random.randn(3, 100) for _ in range(3)]
- dataset_names = ['d1', 'd2', 'd3']
- dataset_labels = ['label1', 'label2', 'label3']
-
- # create calculator frame
- calc_frame = CalculatorFrame(name="MyCalcFrame", datasets=[Data(data=data, dim_order='ps') for data in datasets],
- names=dataset_names, labels=dataset_labels, subset='fabfour')
- assert(isinstance(calc_frame, CalculatorFrame)), "CalculatorFrame failed to instantiate."
-
- # check the properties of the frame
- # check expected number of calcs in frame - 3 for 3 datasets
- num_calcs_in_frame = calc_frame.n_calculators
- assert num_calcs_in_frame == 3, f"Unexpected number ({num_calcs_in_frame}) of calculators in the frame. Expected 3."
-
- # get the frame name
- assert calc_frame.name == "MyCalcFrame", "Calculator frame has unexpected name."
-
- # ensure dataset names, labels passed along to inidividual calculators
- for (index, calc) in enumerate(calc_frame.calculators[0]):
- assert calc.name == dataset_names[index], "Indiviudal calculator has unexpected name."
- assert calc.labels == dataset_labels[index], "Indiviudal calculator has unexpected label."
-
- # check that compute runs
- calc_frame.compute()
-
-def test_correlation_frame_normal_operation():
- """Test whether the correlation frame instantiates as expected."""
- datasets = [np.random.randn(3, 100) for _ in range(3)]
- dataset_names = ['d1', 'd2', 'd3']
- dataset_labels = ['label1', 'label2', 'label3']
- calc_frame = CalculatorFrame(name="MyCalcFrame", datasets=[Data(data=data, dim_order='ps') for data in datasets],
- names=dataset_names, labels=dataset_labels, subset='fabfour')
-
- calc_frame.compute()
- cf = calc_frame.get_correlation_df()
-
- assert not(cf[0].empty), "Correlation frame is empty."
-
-def test_normalisation_flag():
- """Test whether the normalisation flag when instantiating
- the calculator works as expected."""
- data = np.random.randn(3, 100)
- calc = Calculator(dataset=data, normalise=False, detrend=False)
- calc_loaded_dataset = calc.dataset.to_numpy().squeeze()
-
- assert (calc_loaded_dataset == data).all(), f"Calculator normalise=False not producing the correct output."
-
\ No newline at end of file
diff --git a/tests/test_calculator.py b/tests/test_calculator.py
new file mode 100644
index 00000000..df0b0a65
--- /dev/null
+++ b/tests/test_calculator.py
@@ -0,0 +1,595 @@
+from pyspi.calculator import (Calculator, Data, CalculatorFrame, CorrelationFrame,
+ load_spis_from_yaml, resolve_config, bundled_configs)
+from pyspi.data import available_datasets, load_dataset
+import numpy as np
+import os
+import pytest
+
+############################# Test Calculator Object ########################
+def test_whether_calculator_instantiates():
+ """Basic test to check whether or not the calculator will instantiate."""
+ calc = Calculator()
+ assert isinstance(calc, Calculator), "Calculator failed to instantiate."
+
+def test_whether_calculator_computes():
+ """The default config must run clean, not merely run.
+
+ `compute()` catches per-SPI exceptions into `calc.errors` and carries on, so
+ this test used to pass over a table with failed and entirely-NaN columns in
+ it -- which is how three all-NaN `gd_*` SPIs shipped. Partial NaN is fine
+ and expected (several spectral SPIs are defined only where an estimate
+ converges); a column with no finite value anywhere is not a measurement.
+ """
+ # A coupled VAR(1), not i.i.d. noise. Some SPIs are *defined* only where
+ # there is structure to measure -- `gd_*` needs a significant coherence
+ # band and yielded nothing on the tested independent-noise fixture (not a
+ # universal guarantee) -- so white noise is a poor input on which to assert
+ # that every configured SPI is nonempty.
+ rng = np.random.default_rng(0)
+ A = np.array([[0.5, 0.0, 0.0], [0.7, 0.4, 0.0], [0.0, 0.6, 0.3]])
+ data = np.zeros((3, 200))
+ for t in range(1, data.shape[1]):
+ data[:, t] = A @ data[:, t - 1] + rng.standard_normal(3)
+ calc = Calculator(dataset=data)
+ calc.compute()
+
+ assert not calc.errors, (
+ f"{len(calc.errors)} SPI(s) raised on the default config:\n "
+ + "\n ".join(f"{k}: {v}" for k, v in sorted(calc.errors.items()))
+ )
+ off_diagonal = ~np.eye(calc.dataset.n_processes, dtype=bool)
+ empty = sorted(
+ key for key in calc.spis
+ if not np.isfinite(
+ calc.table[key].to_numpy(dtype=float)[off_diagonal]).any()
+ )
+ assert not empty, (
+ f"{len(empty)} SPI(s) produced no finite value:\n " + "\n ".join(empty)
+ )
+
+@pytest.mark.parametrize("config", [
+ 'fabfour',
+ 'fast',
+ 'sonnet'
+])
+def test_whether_calculator_instantiates_with_bundled_configs(config):
+ """Test whether the calculator instantiates with each of the bundled configs"""
+ calc = Calculator(config=config)
+ assert isinstance(calc, Calculator), "Calculator failed to instantiate"
+
+def test_whether_invalid_config_throws_error():
+ """Test whether the calculator fails to instantiate with an unknown config name."""
+ with pytest.raises(ValueError) as excinfo:
+ Calculator(config='nviutw')
+ assert "Unknown config 'nviutw'" in str(excinfo.value), "Unknown-config error not displaying."
+
+def test_whether_calculator_compute_fails_with_no_dataset():
+ """Test whether the calculator fails to compute SPIs when no dataset is provided."""
+ calc = Calculator()
+ with pytest.raises(AttributeError) as excinfo:
+ calc.compute()
+ assert "Dataset not loaded yet" in str(excinfo.value), "Dataset not loaded yet error not displaying."
+
+def test_calculator_name():
+ """Test whether the calculator name is retrieved correctly."""
+ calc = Calculator(name="test name")
+ assert calc.name == "test name", "Calculator name property did not return the expected string 'test name'"
+
+def test_calculator_labels():
+ """Test whether the calculator labels are retreived correctly, when provided."""
+ test_labels = ['label1', 'label2']
+ calc = Calculator(labels = test_labels)
+ assert calc.labels == ['label1', 'label2'], f"Calculator labels property did not return the expected list: {test_labels} "
+
+def test_yaml_spi_labels_inherit_and_override(tmp_path):
+ """YAML family labels apply to SPIs; config labels override module labels."""
+ configfile = tmp_path / "labels_config.yaml"
+ configfile.write_text(
+ """
+.statistics.basic:
+ CrossCorrelation:
+ labels:
+ - family-label
+ - M14
+ dependencies:
+ configs:
+ - statistic: "max"
+ labels:
+ - override-label
+ - M10
+ - statistic: "mean"
+ KendallTau:
+ labels:
+ - MXX
+ dependencies:
+ configs:
+ - squared: False
+ labels:
+ - M14
+""",
+ encoding="utf-8",
+ )
+
+ spis = load_spis_from_yaml(str(configfile))
+
+ inherited = spis["xcorr_mean_sig-True"].labels
+ assert "family-label" in inherited
+ assert "M14" in inherited
+ assert "M10" not in inherited
+
+ overridden = spis["xcorr_max_sig-True"].labels
+ assert "family-label" in overridden
+ assert "override-label" in overridden
+ assert "M10" in overridden
+ assert "M14" not in overridden
+
+ mxx_overridden = spis["kendalltau"].labels
+ assert "M14" in mxx_overridden
+ assert "MXX" not in mxx_overridden
+
+def test_pass_single_integer_as_dataset():
+ """Test whether correct error is thrown when incorrect data type passed into calculator."""
+ with pytest.raises(TypeError) as excinfo:
+ calc = Calculator(dataset=42)
+ assert "Unknown data type" in str(excinfo.value), "Incorrect data type error not displaying for integer dataset."
+
+def test_pass_incorrect_shape_dataset_into_calculator():
+ """Test whether an error is thrown when incorrect dataset shape is passed into calculator."""
+ dataset_with_wrong_dim = np.random.randn(3, 5, 10)
+ with pytest.raises(RuntimeError) as excinfo:
+ calc = Calculator(dataset=dataset_with_wrong_dim)
+ assert "Data array dimension (3)" in str(excinfo.value), "Incorrect dimension error message not displaying for incorrect shape dataset."
+
+@pytest.mark.parametrize("nan_loc, expected_output", [
+ ([1], "[1]"),
+ ([1, 2], "[1 2]"),
+ ([0, 2, 3], "[0 2 3]")
+ ])
+def test_pass_dataset_with_nan_into_calculator(nan_loc, expected_output):
+ """Check whether ValueError is raised when a dataset containing a NaN is passed into the calculator object"""
+ base_dataset = np.random.randn(5, 100)
+ for loc in nan_loc:
+ base_dataset[loc, 0] = np.nan
+ with pytest.raises(ValueError) as excinfo:
+ calc = Calculator(dataset=base_dataset)
+ assert f"non-finite values (NaN/inf) in processes: {expected_output}" in str(excinfo), "NaNs not detected in dataset when loading into Calculator!"
+
+def test_pass_dataset_with_inf_into_calculator():
+ """Check whether ValueError is raised when a dataset containing an inf/-inf value is passed into the calculator object"""
+ base_dataset = np.random.randn(5, 100)
+ base_dataset[0, 1] = np.inf
+ base_dataset[2, 2] = -np.inf
+ with pytest.raises(ValueError) as excinfo:
+ calc = Calculator(dataset=base_dataset)
+ assert f"non-finite values (NaN/inf) in processes: [0 2]" in str(excinfo), "NaNs not detected in dataset when loading into Calculator!"
+
+@pytest.mark.parametrize("shape, n_procs_expected, n_obs_expected", [
+ ((2, 23), 2, 23),
+ ((5, 4), 5, 4),
+ ((100, 32), 100, 32)
+])
+def test_data_object_process_and_observations(shape, n_procs_expected, n_obs_expected):
+ """Test whether the number of processes and observations for a given dataset is correct"""
+ dat = np.random.randn(shape[0], shape[1])
+ calc = Calculator(dataset=dat)
+ assert calc.dataset.n_observations == n_obs_expected, f"Number of observations returned by Calculator ({calc.dataset.n_observations}) does not match exepected: {n_obs_expected}"
+ assert calc.dataset.n_processes == n_procs_expected, f"Number of processes returned by Calculator ({calc.dataset.n_processes}) does not match exepected: {n_procs_expected}"
+
+EXPECTED_CONFIGS = [
+ "full", "fast", "sonnet", "fabfour",
+ "benchmarked_p80", "benchmarked_p90", "benchmarked_p95", "benchmarked_p99",
+]
+
+@pytest.mark.parametrize("name", EXPECTED_CONFIGS)
+def test_bundled_config_resolves(name):
+ """Every advertised config name resolves to a file that exists."""
+ assert os.path.isfile(resolve_config(name)), f"config '{name}' did not resolve to a file."
+
+def test_bundled_configs_matches_shipped_set():
+ """bundled_configs() is exactly the advertised set - catches a stray or missing yaml."""
+ assert sorted(bundled_configs()) == sorted(EXPECTED_CONFIGS)
+
+def test_unknown_config_name_raises():
+ with pytest.raises(ValueError, match="Unknown config"):
+ Calculator(config="does_not_exist")
+
+def test_config_accepts_a_path(tmp_path):
+ """A path is resolved as a path, not looked up as a bundled name."""
+ cfg = tmp_path / "mine.yaml"
+ cfg.write_text(".statistics.basic:\n Covariance:\n configs:\n - squared: False\n")
+ calc = Calculator(config=str(cfg))
+ assert calc.n_spis == 1
+
+def test_missing_config_path_raises(tmp_path):
+ with pytest.raises(FileNotFoundError):
+ Calculator(config=str(tmp_path / "nope.yaml"))
+
+@pytest.mark.parametrize("config, procs, obs", [
+ ("full", 2, 100),
+ ("full", 5, 100),
+ ("fabfour", 8, 100),
+ ("fast", 10, 100),
+ ("sonnet", 3, 100)
+])
+def test_whether_table_shape_correct_before_compute(config, procs, obs):
+ """Test whether the pre-configured table is the correct shape prior to computing SPIs."""
+ dat = np.random.randn(procs, obs)
+ calc = Calculator(dataset=dat, config=config)
+ num_spis = calc.n_spis
+ expected_table_shape = (procs, num_spis*procs)
+ assert calc.table.shape == expected_table_shape, f"Calculator table ({subset}) shape: ({calc.table.shape}) does not match expected shape: {expected_table_shape}"
+
+############################# Test Data Object ########################
+def test_data_object_has_been_converted_to_numpyfloat64():
+ """Test whether the data object converts passed dataset to numpy array by default."""
+ dat = np.random.randn(5, 10)
+ calc = Calculator(dataset=dat)
+ assert calc.dataset.data_type == np.float64, "Dataset was not converted into a numpy array when loaded into Calculator."
+
+def test_whether_data_instantiates():
+ """Test whether the data object instantiates without issue."""
+ data_obj = Data()
+ assert isinstance(data_obj, Data), "Data object failed to instantiate!"
+
+def test_whether_data_throws_error_when_retrieving_nonexistent_dataset():
+ """Test whether the data object throws correct message when trying to access a non-existent dataset."""
+ data_obj = Data()
+ with pytest.raises(AttributeError) as excinfo:
+ dataset = data_obj.data
+ assert "'Data' object has no attribute 'data'" in str(excinfo), "Unexpected error message when trying to retrieve non-existent dataset!"
+
+def test_whether_data_throws_error_when_incorrect_dataset_type():
+ """Test if correct message is shown when passing invalid dataset data type into data object."""
+ with pytest.raises(TypeError) as excinfo:
+ d = Data(data=3)
+ assert f"Unknown data type" in str(excinfo), "Incorrect error message thrown when invalid dataset loaded into data object."
+
+@pytest.mark.parametrize("order, shape, n_procs_expected, n_obs_expected", [
+ ("ps", (3, 100), 3, 100),
+ ("sp", (100, 3), 3, 100)
+])
+def test_whether_dim_order_works(order, shape, n_procs_expected, n_obs_expected):
+ """Check that ps and sp correctly specify order of process/obseravtions"""
+ dataset = np.random.randn(shape[0], shape[1])
+ d = Data(data=dataset, dim_order=order)
+ assert d.n_processes == n_procs_expected, f"Number of processes does not match expected for specified dim order: {order}"
+ assert d.n_observations == n_obs_expected, f"Number of observations does not match expected for specified dim order: {order}"
+
+def test_whether_data_name_assigned_only_with_dataset():
+ """If no dataset is provided, there is no name for the data object (N/A)"""
+ d = Data(name='test')
+ assert d.name == 'N/A', "Data object name is not N/A when no dataset provided."
+
+def test_whether_data_object_has_name_with_dataset():
+ """If dataset is provided, the name will be returned"""
+ dataset = np.random.randn(4, 100)
+ d = Data(data=dataset, name='test')
+ assert d.name == "test", f"Data object name 'test' is not being returned. Instead, {d.name} is returned."
+
+def test_whether_data_normalise_works():
+ """Check whether the data is being normalised by default when loading into data object"""
+ dataset = 4 * np.random.randn(10, 500)
+ d = Data(data=dataset, zscore=True)
+ returned_dataset = d.to_numpy(squeeze=True)
+ assert returned_dataset.mean() == pytest.approx(0, 1e-8), f"Returned dataset mean is not close to zero: {returned_dataset.mean()}"
+ assert returned_dataset.std() == pytest.approx(1, 0.01), f"Returned dataset std is not close to one: {returned_dataset.std()}"
+
+def test_whether_set_data_works():
+ """Check whether existing dataset is overwritten by new dataset"""
+ old_dataset = np.random.randn(1, 100)
+ d = Data(data=old_dataset) # start with empty data object
+ new_dataset = np.random.randn(5, 100)
+ d.set_data(data=new_dataset)
+ # just check the shapes since new datast will be normalised and not equal to the dataset passed in
+ assert d.to_numpy(squeeze=True).shape[0] == 5, "Unexpected dataset returned when overwriting existing dataset!"
+
+def test_add_univariate_process_to_existing_data_object():
+ # start with initial data object
+ dataset = np.random.randn(5, 100)
+ orig_data_object = Data(data=dataset)
+ # now add additional proc to existing data object
+ new_univariate_proc = np.random.randn(1, 100)
+ orig_data_object.add_process(proc=new_univariate_proc)
+ assert orig_data_object.n_processes == 6, "New dataset number of processes not equal to expected number of processes."
+
+def test_add_multivariate_process_to_existing_data_object():
+ """Should not work, can only add univariate process with add_process function"""
+ dataset = np.random.randn(5, 100)
+ orig_data_object = Data(data=dataset)
+ # now add additional procs to existing data object
+ new_multivariate_proc = np.random.randn(2, 100)
+ with pytest.raises(TypeError) as excinfo:
+ orig_data_object.add_process(proc=new_multivariate_proc)
+ assert "Process must be a 1D numpy array" in str(excinfo.value), "Expected 1D array error NOT thrown."
+
+@pytest.mark.parametrize("dataset_name", sorted(available_datasets()))
+def test_load_valid_dataset(dataset_name):
+ """Every dataset advertised by available_datasets() must actually load."""
+ dataset = load_dataset(dataset_name)
+ assert isinstance(dataset, Data), f"Could not load dataset: {dataset_name}"
+
+def test_load_invalid_dataset():
+ """Test whether the load_dataset function throws the correct error/message when trying to load non-existent dataset."""
+ with pytest.raises(NameError) as excinfo:
+ dataset = load_dataset(name="test")
+ assert "Unknown dataset: test" in str(excinfo.value), "Did not get expected error when loading invalid dataset."
+
+def test_calculator_frame_normal_operation():
+ """Test whether the calculator frame instantiates as expected."""
+ datasets = [np.random.randn(3, 100) for _ in range(3)]
+ dataset_names = ['d1', 'd2', 'd3']
+ dataset_labels = ['label1', 'label2', 'label3']
+
+ # create calculator frame
+ calc_frame = CalculatorFrame(name="MyCalcFrame", datasets=[Data(data=data, dim_order='ps') for data in datasets],
+ names=dataset_names, labels=dataset_labels, config='fabfour')
+ assert(isinstance(calc_frame, CalculatorFrame)), "CalculatorFrame failed to instantiate."
+
+ # check the properties of the frame
+ # check expected number of calcs in frame - 3 for 3 datasets
+ num_calcs_in_frame = calc_frame.n_calculators
+ assert num_calcs_in_frame == 3, f"Unexpected number ({num_calcs_in_frame}) of calculators in the frame. Expected 3."
+
+ # get the frame name
+ assert calc_frame.name == "MyCalcFrame", "Calculator frame has unexpected name."
+
+ # ensure dataset names, labels passed along to inidividual calculators
+ for (index, calc) in enumerate(calc_frame.calculators[0]):
+ assert calc.name == dataset_names[index], "Indiviudal calculator has unexpected name."
+ assert calc.labels == dataset_labels[index], "Indiviudal calculator has unexpected label."
+
+ # check that compute runs
+ calc_frame.compute()
+
+def test_correlation_frame_normal_operation():
+ """Test whether the correlation frame instantiates as expected."""
+ datasets = [np.random.randn(3, 100) for _ in range(3)]
+ dataset_names = ['d1', 'd2', 'd3']
+ dataset_labels = ['label1', 'label2', 'label3']
+ calc_frame = CalculatorFrame(name="MyCalcFrame", datasets=[Data(data=data, dim_order='ps') for data in datasets],
+ names=dataset_names, labels=dataset_labels, config='fabfour')
+
+ calc_frame.compute()
+ cf = calc_frame.get_correlation_df()
+
+ assert not(cf[0].empty), "Correlation frame is empty."
+
+
+def test_correlation_frame_with_labels():
+ """The with_labels=True path was silently broken by a stale method name.
+
+ It called Calculator.getstatlabels(), which does not exist (the method is
+ get_stat_labels). Nothing exercised it, so it never surfaced.
+ """
+ datasets = [Data(data=np.random.randn(3, 100)) for _ in range(3)]
+ frame = CalculatorFrame(datasets=datasets, names=['d1', 'd2', 'd3'],
+ labels=['a', 'b', 'c'], config='fabfour')
+ frame.compute()
+ mdf, shapes, mlabels, dlabels = frame.get_correlation_df(with_labels=True)
+ assert not mdf.empty
+ assert mlabels and dlabels
+
+
+def test_correlation_frame_constructs():
+ """CorrelationFrame itself, which no test previously instantiated."""
+ datasets = [Data(data=np.random.randn(3, 100)) for _ in range(3)]
+ frame = CalculatorFrame(datasets=datasets, names=['d1', 'd2', 'd3'],
+ labels=['a', 'b', 'c'], config='fabfour')
+ frame.compute()
+ corr = CorrelationFrame(frame)
+ assert corr.n_datasets == 3
+ assert corr.n_spis == 4
+ assert not corr.mdf.empty
+ with pytest.raises(NotImplementedError, match="edges, not independent time samples"):
+ corr.get_pvalues()
+ with pytest.raises(NotImplementedError, match="Edges sharing nodes are dependent"):
+ corr.compute_significant_values()
+ with pytest.raises(NotImplementedError, match="invalid edge-correlation p-values"):
+ corr.get_average_correlation(remove_insig=True)
+
+def test_normalisation_flag():
+ """Test whether the normalisation flag when instantiating
+ the calculator works as expected."""
+ data = np.random.randn(3, 100)
+ calc = Calculator(dataset=data, zscore=False, detrend=False)
+ calc_loaded_dataset = calc.dataset.to_numpy().squeeze()
+
+ assert (calc_loaded_dataset == data).all(), f"Calculator zscore=False not producing the correct output."
+
+
+
+def test_save_load_npz_roundtrip(tmp_path):
+ """.npz is the canonical on-disk format and must round-trip exactly."""
+ import pyspi
+ d = Data(np.random.randn(4, 120), procnames=['w', 'x', 'y', 'z'])
+ calc = Calculator(dataset=d, config='fabfour')
+ calc.compute()
+
+ path = calc.save(tmp_path / "r.npz")
+ back = pyspi.load_table(path)
+ assert back.equals(calc.table)
+ assert list(back.index) == ['w', 'x', 'y', 'z']
+
+
+def test_save_csv_and_rejects_unknown_suffix(tmp_path):
+ d = Data(np.random.randn(3, 100))
+ calc = Calculator(dataset=d, config='fabfour')
+ calc.compute()
+
+ assert calc.save(tmp_path / "r.csv").exists()
+ with pytest.raises(ValueError, match="Unsupported suffix"):
+ calc.save(tmp_path / "r.pkl")
+
+
+def test_load_table_rejects_non_npz(tmp_path):
+ import pyspi
+ p = tmp_path / "r.csv"
+ p.write_text("not npz")
+ with pytest.raises(ValueError, match="Can only load"):
+ pyspi.load_table(p)
+
+
+def test_load_table_exposes_the_metadata_save_writes(tmp_path):
+ """`save()` has always written run_spec, run_digest and errors.
+
+ `load_table()` read none of them, so a loaded table could not be asked
+ which SPIs failed, what produced it, or whether it matched a rerun --
+ and a NaN column is otherwise indistinguishable from a legitimately
+ undefined statistic.
+ """
+ from pyspi.calculator import load_table
+
+ rng = np.random.default_rng(0)
+ calc = Calculator(dataset=Data(data=rng.standard_normal((3, 80)),
+ dim_order="ps", procnames=["a", "b", "c"]),
+ config="fabfour")
+ calc.compute()
+ path = tmp_path / "t.npz"
+ calc.save(path)
+
+ table = load_table(path)
+ assert table.attrs["run_digest"] == calc.run_digest
+ assert table.attrs["errors"] == calc.errors
+ assert table.attrs["run_spec"]["config"] == calc.run_spec["config"]
+ assert list(table.index) == ["a", "b", "c"]
+
+
+def test_load_table_validates_the_whole_shape(tmp_path):
+ """`ndim` and axis 0 only; a wrong width reached MultiIndex.from_product."""
+ from pyspi.calculator import load_table
+
+ path = tmp_path / "bad.npz"
+ np.savez_compressed(
+ path,
+ values=np.zeros((2, 3, 4)), # 3x4, not 3x3
+ spis=np.array(["a", "b"], dtype="U"),
+ processes=np.array(["p0", "p1", "p2"], dtype="U"),
+ )
+ with pytest.raises(ValueError, match="malformed"):
+ load_table(path)
+
+
+def test_run_digest_binds_to_the_computation_version(monkeypatch):
+ """Identical data and config computed by two implementations are two results.
+
+ A digest that cannot tell them apart lets a checkpoint written by one be
+ resumed by the other.
+ """
+ from pyspi import _parallel
+
+ calc = Calculator(dataset=np.zeros((2, 20)) + np.arange(20), config="fabfour")
+ before = calc.run_digest
+ monkeypatch.setattr(_parallel, "COMPUTATION_VERSION", "0.0.0-test")
+ assert calc.run_digest != before
+
+
+def test_process_names_must_be_unique_and_round_trip_exactly(tmp_path):
+ """They label the rows and columns, and `to_frame()` stacks on them.
+
+ Duplicates surfaced as pandas' "Columns with duplicate values are not
+ supported in stack" from four frames away, with nothing pointing at the
+ names. Non-string names were written to the NPZ as a `U` array and came
+ back as their `str()`, so the file did not round-trip.
+ """
+ from pyspi.calculator import load_table
+
+ with pytest.raises(ValueError, match="must be unique"):
+ Data(data=np.zeros((3, 20)) + np.arange(20), dim_order="ps",
+ procnames=["a", "a", "b"])
+
+ data = Data(data=np.zeros((2, 40)) + np.arange(40), dim_order="ps",
+ procnames=[1, 2], zscore=False)
+ assert data.procnames == ["1", "2"]
+
+ calc = Calculator(dataset=data, config="fabfour")
+ calc.compute()
+ path = tmp_path / "t.npz"
+ calc.save(path)
+ assert list(load_table(path).index) == data.procnames
+
+
+def test_a_successful_recomputation_clears_a_stale_error():
+ """`compute(retry_failed=True)` left the old entry beside the good column.
+
+ `save()` then froze the contradiction into the file: an SPI recorded as
+ failed whose column is populated.
+ """
+ calc = Calculator(dataset=np.zeros((2, 40)) + np.arange(40), config="fabfour")
+ calc.compute()
+ key = next(iter(calc.spis))
+ calc._errors[key] = "ValueError: stale"
+ calc._record(key, calc.table[key].to_numpy(), None, [], 0.0)
+ assert key not in calc.errors
+
+
+# --------------------------------------------------------------------------
+# run_digest is a content hash
+# --------------------------------------------------------------------------
+
+def _digest_calculator(config_path, data=None, **data_kwargs):
+ from pyspi.data import Data
+
+ if data is None:
+ rng = np.random.default_rng(0)
+ data = rng.standard_normal((3, 40))
+ return Calculator(dataset=Data(data=data, dim_order="ps", **data_kwargs),
+ config=str(config_path))
+
+
+@pytest.fixture
+def two_copies_of_one_config(tmp_path):
+ """The same config bytes, at two different paths with two different names."""
+ import shutil
+
+ from pyspi.calculator import resolve_config
+
+ source = resolve_config("fabfour")
+ first = tmp_path / "a" / "config.yaml"
+ second = tmp_path / "b" / "differently-named.yaml"
+ for path in (first, second):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(source, path)
+ return first, second
+
+
+def test_run_digest_ignores_where_the_config_lives(two_copies_of_one_config):
+ """It hashed the absolute resolved path, so the same config and the same
+ data digested differently in a source checkout and an installed wheel.
+
+ A false negative -- a checkpoint refused when it should have been accepted
+ -- rather than unsafe reuse, but it defeats the point of a content hash.
+ """
+ first, second = two_copies_of_one_config
+ assert _digest_calculator(first).run_digest == _digest_calculator(second).run_digest
+ # ... and the path is still recorded, as provenance.
+ assert str(first) in _digest_calculator(first).run_spec["configfile"]
+
+
+def test_run_digest_tracks_the_config_contents(two_copies_of_one_config):
+ first, second = two_copies_of_one_config
+ second.write_text(second.read_text() + "\n# an extra byte\n")
+ assert _digest_calculator(first).run_digest != _digest_calculator(second).run_digest
+
+
+def test_run_digest_tracks_data_order_preprocessing_and_computation_version(
+ two_copies_of_one_config):
+ from pyspi import _parallel
+
+ config, _ = two_copies_of_one_config
+ rng = np.random.default_rng(0)
+ Z = rng.standard_normal((3, 40))
+ base = _digest_calculator(config, data=Z)
+ reference = base.run_digest
+
+ assert _digest_calculator(config, data=Z + rng.standard_normal((3, 40))
+ ).run_digest != reference
+ assert _digest_calculator(config, data=Z[::-1]).run_digest != reference
+ assert _digest_calculator(config, data=Z, zscore=False).run_digest != reference
+
+ original = _parallel.COMPUTATION_VERSION
+ try:
+ _parallel.COMPUTATION_VERSION = original + "-probe"
+ assert base.run_digest != reference
+ finally:
+ _parallel.COMPUTATION_VERSION = original
+ assert base.run_digest == reference
diff --git a/tests/test_directionality.py b/tests/test_directionality.py
new file mode 100644
index 00000000..7b3a29f7
--- /dev/null
+++ b/tests/test_directionality.py
@@ -0,0 +1,574 @@
+"""Every directed SPI must agree on what a row and a column mean.
+
+pyspi's convention is set by ``base.Directed.multivariate``, which fills
+``A[i, j] = bivariate(i, j)``: **row is the source, column is the target**.
+
+This matters because the spectral backends (spectral_connectivity, nitime)
+follow the opposite DTF/PDC convention -- element ``[i, j]`` is the flow *into*
+i *from* j. Their output used to be passed through unchanged, which left every
+directed spectral SPI transposed relative to every directed information-theory
+SPI in the same results table. See ``spectral._to_source_target``.
+
+The baseline-drift suite cannot be relied on to catch a regression here: on the
+bundled fixtures the directed spectral matrices are close to symmetric, so a
+transpose barely moves the numbers. These tests use a deliberately asymmetric
+process instead.
+"""
+
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+
+# Process 0 drives process 1 at lag 1, with no feedback. Long enough that the
+# estimators resolve the asymmetry well clear of their noise floor.
+T = 2000
+SEED = 0
+
+
+@pytest.fixture(scope="module")
+def driven_pair():
+ """Data where process 0 unambiguously drives process 1."""
+ rng = np.random.default_rng(SEED)
+ x = np.zeros(T)
+ y = np.zeros(T)
+ ex, ey = rng.standard_normal(T), rng.standard_normal(T)
+ for t in range(1, T):
+ x[t] = 0.5 * x[t - 1] + ex[t]
+ y[t] = 0.5 * y[t - 1] + 0.8 * x[t - 1] + ey[t]
+ return Data(np.vstack([x, y]))
+
+
+def _spi(module, cls_name, **kwargs):
+ import importlib
+ mod = importlib.import_module(f"pyspi.statistics.{module}")
+ return getattr(mod, cls_name)(**kwargs)
+
+
+# (module, class, kwargs). One per directed family that reaches a backend whose
+# native convention differs from pyspi's, plus information-theory references.
+DIRECTED = [
+ ("infotheory", "TransferEntropy", {"estimator": "gaussian"}),
+ ("infotheory", "TimeLaggedMutualInfo", {"estimator": "gaussian"}),
+ ("spectral", "SpectralGrangerCausality", {}),
+ ("spectral", "SpectralGrangerCausality", {"method": "parametric"}),
+ ("spectral", "DirectedCoherence", {}),
+ ("spectral", "PartialDirectedCoherence", {}),
+ ("spectral", "GeneralizedPartialDirectedCoherence", {}),
+ ("spectral", "DirectedTransferFunction", {}),
+ ("spectral", "DirectDirectedTransferFunction", {}),
+]
+
+
+@pytest.mark.parametrize("module, cls_name, kwargs", DIRECTED,
+ ids=[f"{c}{'-' + str(k.get('method')) if k.get('method') else ''}"
+ for _, c, k in DIRECTED])
+def test_directed_spi_is_source_by_target(module, cls_name, kwargs, driven_pair):
+ """A[0, 1] (0 -> 1, the true direction) must exceed A[1, 0]."""
+ spi = _spi(module, cls_name, **kwargs)
+ A = np.asarray(spi.multivariate(driven_pair), dtype=float)
+
+ assert A.shape == (2, 2)
+ if np.isnan(A[0, 1]) or np.isnan(A[1, 0]):
+ pytest.skip(f"{cls_name} returned NaN off-diagonals on this process")
+
+ assert A[0, 1] > A[1, 0], (
+ f"{cls_name}{kwargs}: process 0 drives process 1, so A[0,1] must be the "
+ f"larger entry (row=source, column=target). Got A[0,1]={A[0, 1]:.4f}, "
+ f"A[1,0]={A[1, 0]:.4f} -- this SPI is transposed relative to the rest "
+ f"of the library."
+ )
+
+
+UNDIRECTED_SYMMETRIC = [
+ "CoherenceMagnitude", "ImaginaryCoherence", "PhaseLockingValue",
+ "PairwisePhaseConsistency",
+]
+
+
+@pytest.mark.parametrize("cls_name", UNDIRECTED_SYMMETRIC)
+def test_undirected_spectral_stays_symmetric(cls_name, driven_pair):
+ """Undirected spectral SPIs must not be touched by the directed transpose."""
+ A = np.asarray(_spi("spectral", cls_name).multivariate(driven_pair), dtype=float)
+ assert A[0, 1] == pytest.approx(A[1, 0], abs=1e-12), f"{cls_name} is not symmetric"
+
+
+# These are labelled undirected but are antisymmetric (they encode a direction
+# in their sign). Transposing them would silently negate every value, so the
+# sign relationship is pinned here.
+UNDIRECTED_ANTISYMMETRIC = ["PhaseLagIndex", "WeightedPhaseLagIndex", "PhaseSlopeIndex"]
+
+
+@pytest.mark.parametrize("cls_name", UNDIRECTED_ANTISYMMETRIC)
+def test_antisymmetric_spectral_sign_preserved(cls_name, driven_pair):
+ A = np.asarray(_spi("spectral", cls_name).multivariate(driven_pair), dtype=float)
+ assert A[0, 1] == pytest.approx(-A[1, 0], rel=1e-9), (
+ f"{cls_name} should be antisymmetric; got A[0,1]={A[0, 1]}, A[1,0]={A[1, 0]}"
+ )
+
+
+# --------------------------------------------------------------------------
+# Wilson-derived spectral measures, against an exact analytic spectrum
+# --------------------------------------------------------------------------
+
+def test_wilson_factorisation_recovers_a_known_transfer_function():
+ """Factorise an *exact* VAR(1) spectrum, bypassing sample estimation.
+
+ For x_t = A x_{t-1} + e_t with noise covariance Sigma, the cross-spectral
+ matrix is S(f) = H(f) Sigma H(f)^H with H(f) = (I - A e^{-2 pi i f})^{-1}.
+ Feeding that exact S to the Wilson decomposition isolates the factorisation
+ from every source of finite-sample error, so any discrepancy is the
+ algorithm's own.
+
+ An earlier version of this test compared pyspi's DTF against the *full*
+ three-process transfer function. That was invalid: pyspi computes
+ NonparametricSpectralBivariate measures on two-process subsystems
+ (`z[[i, j]]`), and a subsystem of a larger VAR legitimately shows flow in
+ both directions because the omitted processes induce correlation. The
+ 0.10-0.16 floor that comparison produced was the test's error, not the
+ estimator's.
+ """
+ from spectral_connectivity.minimum_phase_decomposition import (
+ minimum_phase_decomposition,
+ )
+
+ A = np.array([[0.5, 0.0], [0.7, 0.4]])
+ M = A.shape[0]
+ Sigma = np.eye(M)
+ # The FULL two-sided grid over [0, 1): the algorithm takes an inverse FFT
+ # internally to impose causality, so a half-spectrum silently gives a
+ # factor unrelated to H even though S = G G^H still holds.
+ n = 256
+ freqs = np.arange(n) / n
+
+ H = np.stack([np.linalg.inv(np.eye(M) - A * np.exp(-2j * np.pi * f)) for f in freqs])
+ S = H @ Sigma @ np.conj(np.transpose(H, (0, 2, 1)))
+
+ G = minimum_phase_decomposition(S[np.newaxis, ...])[0]
+
+ # S = G G^H is the contract. The bound is the algorithm's own convergence
+ # tolerance (default 1e-8), not machine precision -- this is an iterative
+ # method, so ~1e-8 is the expected floor rather than a discrepancy.
+ residual = np.abs(S - G @ np.conj(np.transpose(G, (0, 2, 1)))).max()
+ assert residual < 1e-6, f"Wilson reconstruction residual {residual:.3g}"
+
+ # G(f) = H(f) Sigma^{1/2}; the zeroth Fourier coefficient of G is Sigma^{1/2}.
+ g0 = np.fft.ifft(G, axis=0)[0]
+ H_hat = G @ np.linalg.inv(g0)
+ err = np.abs(H_hat - H).max()
+ assert err < 1e-9, f"recovered transfer function differs by {err:.3g}"
+
+ num = np.abs(H_hat) ** 2
+ dtf_hat = num / num.sum(axis=-1, keepdims=True)
+ num = np.abs(H) ** 2
+ dtf = num / num.sum(axis=-1, keepdims=True)
+ assert np.abs(dtf_hat - dtf).max() < 1e-9
+
+
+def test_directed_transfer_function_orientation_on_a_two_process_var():
+ """On a 2-process VAR the subsystem *is* the system, so DTF is comparable.
+
+ Bounded, and the driving direction dominates. Absolute calibration is not
+ asserted: DTF as implemented is the squared form, and the multitaper
+ estimate carries finite-sample bias at these lengths.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.spectral import DirectedTransferFunction
+
+ A = np.array([[0.5, 0.0], [0.7, 0.4]]) # 0 -> 1 only
+ rng = np.random.default_rng(0)
+ T = 500
+ X = np.zeros((2, T))
+ for t in range(1, T):
+ X[:, t] = A @ X[:, t - 1] + rng.standard_normal(2)
+
+ got = DirectedTransferFunction(statistic="mean", fmin=0, fmax=0.5).multivariate(
+ Data(data=X, dim_order="ps", zscore=False)
+ )
+ finite = got[np.isfinite(got)]
+ assert finite.min() >= 0.0 and finite.max() <= 1.0, (
+ f"DTF outside [0,1]: [{finite.min():.4f}, {finite.max():.4f}]"
+ )
+ assert got[0, 1] > got[1, 0], (
+ f"DTF did not favour the driving direction: 0->1={got[0,1]:.4f}, "
+ f"1->0={got[1,0]:.4f}"
+ )
+
+
+def test_directed_coherence_is_bounded():
+ """DC is defined on [0,1]; the backend's version is not.
+
+ ``spectral_connectivity.directed_coherence`` puts |H|^2 in the numerator
+ while ``_total_inflow`` is on the magnitude scale, so the ratio is
+ dimensionally |H|^2/|H| and unbounded -- the shipped baselines reached 3.27
+ (VAR), 1.84 (CML) and 1139.47 (Kuramoto). pyspi recomputes it with |H|.
+ """
+ import os
+ from pyspi.data import Data
+ from pyspi.calculator import load_spis_from_yaml, resolve_config
+
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ dcoh = [k for k in spis if k.startswith("dcoh_")]
+ assert dcoh, "no directed-coherence SPIs in the full config"
+
+ fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "kuramoto_M7_T100.npy")
+ data = Data(data=fixture, dim_order="sp")
+ for k in dcoh:
+ A = np.asarray(spis[k].multivariate(data), dtype=float)
+ finite = A[np.isfinite(A)]
+ assert finite.min() >= 0.0 and finite.max() <= 1.0 + 1e-9, (
+ f"{k} outside [0,1]: [{finite.min():.4f}, {finite.max():.4f}]"
+ )
+
+
+class _StubConnectivity:
+ """Minimal stand-in exposing only the two private properties DC reads."""
+
+ def __init__(self, transfer_function, noise_covariance):
+ self._transfer_function = transfer_function
+ self._noise_covariance = noise_covariance
+
+
+def _baccala_dc(H, sigma):
+ """Baccala et al. (1998) DC, written out with explicit loops."""
+ n_f, n = H.shape[-3], H.shape[-1]
+ out = np.zeros((n_f, n, n))
+ for k in range(n_f):
+ for i in range(n):
+ den = np.sqrt(sum(sigma[c] * abs(H[0, k, i, c]) ** 2 for c in range(n)))
+ for j in range(n):
+ out[k, i, j] = np.sqrt(sigma[j]) * abs(H[0, k, i, j]) / den
+ return out
+
+
+def test_directed_coherence_weights_by_the_source_innovation_variance():
+ """The sigma_jj weight is indexed by the *source*, and must not cancel.
+
+ Regression test for a second backend defect, distinct from the |H|^2
+ numerator. ``_get_noise_variance`` reshapes ``diag(Sigma)`` to
+ ``(..., 1, n, 1)``, which broadcasts along the *row* (target) axis of H.
+ A row-indexed weight is constant across the summation index, so it factors
+ out of numerator and denominator and cancels exactly -- the measure
+ degenerates to ``sqrt(DTF)`` for *every* noise covariance. The previous
+ equal-variance test could not see this, because it asserted precisely the
+ identity the bug makes unconditionally true.
+ """
+ from pyspi.statistics.spectral import DirectedCoherence
+
+ rng = np.random.default_rng(0)
+ n_f, n = 7, 3
+ H = (rng.standard_normal((1, n_f, n, n))
+ + 1j * rng.standard_normal((1, n_f, n, n)))
+ sigma = np.array([0.5, 4.0, 0.1]) # unequal *diagonal* variances
+ C = _StubConnectivity(H, np.diag(sigma)[np.newaxis])
+
+ got = DirectedCoherence.__new__(DirectedCoherence)._get_statistic(C)[0]
+ assert np.abs(got - _baccala_dc(H, sigma)).max() < 1e-12
+
+ # Bounded, and exactly row-normalised: sum_j DC_ij^2 == 1.
+ assert got.min() >= 0.0 and got.max() <= 1.0
+ assert np.abs((got ** 2).sum(axis=-1) - 1.0).max() < 1e-12
+
+ # The weight must actually bite. sqrt(DTF) is what the row-indexed form
+ # returns; if this were still close, the variances would be cancelling.
+ mag2 = np.abs(H[0]) ** 2
+ sqrt_dtf = np.sqrt(mag2 / mag2.sum(axis=-1, keepdims=True))
+ assert np.abs(got - sqrt_dtf).max() > 0.1, (
+ "DC collapsed onto sqrt(DTF) despite unequal innovation variances"
+ )
+
+
+def test_directed_coherence_equals_sqrt_dtf_iff_variances_are_equal():
+ """Both halves of the identity, on an *exact* VAR(1) spectrum.
+
+ Equal innovation variances make sigma cancel legitimately, so DC reduces to
+ sqrt(DTF); unequal ones must not. Driving this from the analytic
+ cross-spectrum rather than a sampled estimate keeps the assertion about the
+ factorisation and the algebra, not about finite-sample calibration.
+ """
+ from spectral_connectivity.minimum_phase_decomposition import (
+ minimum_phase_decomposition,
+ )
+ from pyspi.statistics.spectral import DirectedCoherence
+
+ A = np.array([[0.5, 0.0], [0.7, 0.4]])
+ M, n = A.shape[0], 256
+ freqs = np.arange(n) / n
+ H = np.stack([np.linalg.inv(np.eye(M) - A * np.exp(-2j * np.pi * f))
+ for f in freqs])
+
+ dc = DirectedCoherence.__new__(DirectedCoherence)
+ for sigma, equal in ((np.array([1.0, 1.0]), True),
+ (np.array([0.25, 4.0]), False)):
+ Sigma = np.diag(sigma)
+ S = H @ Sigma @ np.conj(np.transpose(H, (0, 2, 1)))
+ G = minimum_phase_decomposition(S[np.newaxis, ...])
+ g0 = np.fft.ifft(G, axis=-3).real[..., 0, :, :]
+ H_hat = G @ np.linalg.inv(g0)[:, np.newaxis]
+ Sigma_hat = g0 @ np.transpose(g0, (0, 2, 1))
+
+ # Sigma is recovered even though G is unique only up to a real
+ # orthogonal factor U: g0 = Sigma^{1/2} U, so g0 g0^T = Sigma.
+ assert np.abs(Sigma_hat[0] - Sigma).max() < 1e-6
+
+ got = dc._get_statistic(_StubConnectivity(H_hat, Sigma_hat))[0]
+ assert np.abs(got - _baccala_dc(H_hat, sigma)).max() < 1e-9
+
+ mag2 = np.abs(H_hat[0]) ** 2
+ sqrt_dtf = np.sqrt(mag2 / mag2.sum(axis=-1, keepdims=True))
+ gap = np.abs(got - sqrt_dtf).max()
+ if equal:
+ assert gap < 1e-9, f"DC != sqrt(DTF) at equal variances: {gap:.3g}"
+ else:
+ assert gap > 0.1, f"DC collapsed onto sqrt(DTF): gap {gap:.3g}"
+
+
+def test_directed_coherence_uses_only_the_documented_backend_privates():
+ """Pin the private-API surface DC depends on.
+
+ ``_transfer_function`` and ``_noise_covariance`` are the whole contract;
+ the arithmetic is done in pyspi. Losing either is an import-time-visible
+ break rather than a silently wrong number, which is what the supported
+ version range in pyproject.toml is anchored on.
+ """
+ import spectral_connectivity as sc
+
+ for attr in ("_transfer_function", "_noise_covariance"):
+ assert isinstance(getattr(sc.Connectivity, attr, None), property), (
+ f"spectral_connectivity.Connectivity.{attr} is gone; "
+ f"DirectedCoherence cannot be computed. Check the supported "
+ f"spectral-connectivity range in pyproject.toml."
+ )
+
+
+# --------------------------------------------------------------------------
+# Group delay
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("lag", [1, 3, 5, 8])
+def test_group_delay_recovers_a_known_lag(lag):
+ """An independent oracle: a pure delay has a known group delay.
+
+ All three shipped ``gd_*`` SPIs returned no finite off-diagonal value on
+ any input -- Gaussian noise, one-way coupled AR, every frozen fixture, at
+ 5/11/19/39 tapers and T up to 4000, on a pair with median coherence 0.998.
+ The cause is one line in the backend, not the data:
+ ``coherence_fisher_z_transform`` divides by
+ ``sqrt(coherence_bias(n_obs1) + coherence_bias(n_obs2))`` and the
+ one-sample call passes ``n_obs2 = 0``, for which ``coherence_bias`` returns
+ ``1/(2*0 - 2) = -0.5``. The radicand is negative for every ``n_obs1``, so
+ every p-value is NaN and nothing is ever significant.
+
+ pyspi computes the statistic itself with the standard one-sample form,
+ ``(arctanh|C| - b) / sqrt(b)`` with ``b = 1/(2n - 2)``. With
+ ``y(t) = x(t - lag)`` the answer is known in advance, which is what makes
+ this a test rather than a re-run of the implementation.
+ """
+ from pyspi.statistics.spectral import GroupDelay
+
+ rng = np.random.default_rng(SEED)
+ T_ = 2000
+ x = rng.standard_normal(T_ + lag)
+ y = x[:-lag] + 0.1 * rng.standard_normal(T_)
+ data = Data(data=np.vstack([x[lag:], y]), dim_order="ps")
+
+ delay = GroupDelay(statistic="delay", fmin=0, fmax=0.5).multivariate(data)
+ # Row is the source: process 0 leads process 1 by `lag` samples.
+ assert delay[0, 1] == pytest.approx(lag, abs=0.05)
+ assert delay[1, 0] == pytest.approx(-lag, abs=0.05)
+
+ r = GroupDelay(statistic="rvalue", fmin=0, fmax=0.5).multivariate(data)
+ assert r[0, 1] > 0.99 and r[0, 1] == pytest.approx(r[1, 0])
+
+
+def test_group_delay_is_estimable_on_the_bundled_fixtures():
+ """Not a repeat of the lag test: it pins that real data now produces values.
+
+ Partial NaN is correct here and is not the defect being guarded against --
+ group delay is defined only where the coherence is significant, so pairs
+ without a significant cluster have none. An *entirely* NaN column is the
+ defect.
+ """
+ import os
+
+ from pyspi.statistics.spectral import GroupDelay
+
+ fixtures = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures")
+ for name in ("var1_M3_T100.npy", "cml_M5_T100.npy", "kuramoto_M7_T100.npy"):
+ data = Data(data=os.path.join(fixtures, name), dim_order="sp")
+ table = GroupDelay(statistic="delay", fmin=0, fmax=0.5).multivariate(data)
+ off = ~np.eye(table.shape[0], dtype=bool)
+ assert np.isfinite(table[off]).any(), f"{name}: gd is entirely NaN"
+
+
+def test_group_delay_of_independent_processes_is_undefined():
+ """The significance gate must still gate. Independent noise has no delay."""
+ from pyspi.statistics.spectral import GroupDelay
+
+ rng = np.random.default_rng(SEED)
+ data = Data(data=rng.standard_normal((3, 500)), dim_order="ps")
+ table = GroupDelay(statistic="delay", fmin=0, fmax=0.5).multivariate(data)
+ off = ~np.eye(3, dtype=bool)
+ assert not np.isfinite(table[off]).any()
+
+
+# --------------------------------------------------------------------------
+# Spectral Granger causality
+# --------------------------------------------------------------------------
+
+def test_spectral_gc_nan_mask_is_in_the_same_orientation_as_the_values():
+ """The mask must be transformed with the matrix it masks.
+
+ `multivariate` puts the backend's matrix into pyspi's (source, target)
+ orientation by transposing it, then applied a NaN mask computed in the
+ backend's orientation. With a directionally asymmetric NaN pattern the cell
+ that was genuinely unestimable is already NaN, and the *mirror* cell -- a
+ perfectly good estimate -- is the one that gets blanked.
+ """
+ from pyspi.statistics.spectral import SpectralGrangerCausality
+
+ M, n_freq = 3, 20
+ F = np.ones((1, n_freq, M, M))
+ F[:, :, 0, 2] = np.nan # unestimable in one direction only
+ freq = np.linspace(0.0, 0.5, n_freq)
+
+ spi = SpectralGrangerCausality(fmin=0, fmax=0.5, nan_threshold=0.5)
+ spi._get_cache = lambda data: (F, freq)
+
+ with pytest.warns(UserWarning, match="NaN values"):
+ result = spi.multivariate(Data(data=np.zeros((M, 8)), dim_order="ps",
+ zscore=False))
+
+ # Backend [0, 2] is pyspi [2, 0].
+ assert np.isnan(result[2, 0])
+ assert np.isfinite(result[0, 2]), "the mirror cell was blanked instead"
+
+
+def test_spectral_gc_parametric_honours_the_sampling_frequency():
+ """`fs` is in the identifier and the cache key, so it must reach the model.
+
+ The parametric branch built `TimeSeries(..., sampling_interval=1)`
+ unconditionally, so `GA.frequencies` came back on a unit-rate axis whatever
+ `fs` said and the [fmin, fmax] band was applied to the wrong frequencies.
+ Two SPIs differing only in `fs` advertised different sampling rates and
+ returned the same numbers.
+ """
+ from pyspi.statistics.spectral import SpectralGrangerCausality
+
+ rng = np.random.default_rng(SEED)
+ T_ = 400
+ X = np.zeros((2, T_))
+ A = np.array([[0.5, 0.0], [0.7, 0.4]])
+ for t in range(1, T_):
+ X[:, t] = A @ X[:, t - 1] + rng.standard_normal(2)
+
+ # The same physical band, expressed at two sampling rates: [0, 0.25] cycles
+ # per sample is [0, 0.5] Hz at fs=2 and [0, 0.25] Hz at fs=1.
+ base = SpectralGrangerCausality(method="parametric", order=2,
+ fmin=1e-5, fmax=0.25)
+ scaled = SpectralGrangerCausality(method="parametric", order=2, fs=2,
+ fmin=1e-5, fmax=0.5)
+ a = base.multivariate(Data(data=X, dim_order="ps"))
+ b = scaled.multivariate(Data(data=X, dim_order="ps"))
+ assert np.allclose(a, b, atol=1e-8, equal_nan=True), (
+ f"fs did not reach the model:\n{a}\n{b}"
+ )
+
+
+def test_spectral_gc_names_the_cause_when_order_selection_does_not_converge():
+ """The documented exception in KNOWN_UNESTIMABLE, tested rather than assumed.
+
+ nitime's own message -- "Model estimation order did not converge at
+ max_order = 50" -- says nothing about the data, and pyspi used to swallow it
+ into an all-NaN return plus a warning, so `Calculator.errors` recorded only
+ the generic "returned no finite off-diagonal values".
+ """
+ import os
+
+ from pyspi.statistics.spectral import SpectralGrangerCausality
+
+ fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "kuramoto_M7_T100.npy")
+ spi = SpectralGrangerCausality(method="parametric", order=None, max_order=50)
+ with pytest.raises(ValueError, match="order selection did not converge"):
+ spi.multivariate(Data(data=fixture, dim_order="sp"))
+
+
+@pytest.mark.parametrize("fs", [1, 2, 4])
+def test_group_delay_is_reported_in_samples_at_every_sampling_frequency(fs):
+ """The regression runs against physical frequency, so its slope is seconds.
+
+ `C.frequencies` is in Hz, so `slope/(2*pi)` is a delay in seconds: a true
+ 4-sample lag came back as 4.0, 2.0 and 1.0 at fs = 1, 2, 4 while the API and
+ every other lagged SPI in pyspi count samples. Scaling by fs makes the
+ number mean what the identifier says at any rate.
+ """
+ import warnings
+
+ from pyspi.statistics.spectral import GroupDelay
+
+ rng = np.random.default_rng(SEED)
+ lag, T_ = 4, 2000
+ x = rng.standard_normal(T_ + lag)
+ y = x[:-lag] + 0.1 * rng.standard_normal(T_)
+ data = Data(data=np.vstack([x[lag:], y]), dim_order="ps")
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ table = GroupDelay(statistic="delay", fs=fs, fmin=0,
+ fmax=fs / 2).multivariate(data)
+ assert table[0, 1] == pytest.approx(lag, abs=0.05)
+ assert table[1, 0] == pytest.approx(-lag, abs=0.05)
+
+
+@pytest.mark.parametrize("statistic", ["delay", "slope", "rvalue"])
+def test_group_delay_is_covariant_under_process_permutation(statistic):
+ """Reversing the process order must move a value, not change it.
+
+ `rvalue` stored the *signed* regression r symmetrically. The fit is of the
+ phase of C_ij and phase(C_ji) = -phase(C_ij), so reversing the order turned
+ +0.99997 into -0.99997 at the mirrored position -- for a statistic declared
+ symmetric. It is now |r|, which is orientation-free.
+ """
+ from pyspi.statistics.spectral import GroupDelay
+
+ rng = np.random.default_rng(SEED)
+ lag, T_ = 4, 2000
+ x = rng.standard_normal(T_ + lag)
+ y = x[:-lag] + 0.1 * rng.standard_normal(T_)
+ Z = np.vstack([x[lag:], y])
+
+ spi = lambda: GroupDelay(statistic=statistic, fmin=0, fmax=0.5)
+ forward = spi().multivariate(Data(data=Z, dim_order="ps"))
+ reversed_ = spi().multivariate(Data(data=Z[::-1], dim_order="ps"))
+ assert forward[0, 1] == pytest.approx(reversed_[1, 0], rel=1e-9)
+ assert forward[1, 0] == pytest.approx(reversed_[0, 1], rel=1e-9)
+
+
+def test_group_delay_structural_labels_match_the_matrices():
+ """delay/slope are antisymmetric and signed; rvalue is symmetric and not.
+
+ And a structural trait replaces `directed`/`undirected` rather than sitting
+ beside a stale one: `gd_*` carried the class's `antisymmetric` and the
+ config's `directed` at the same time, so `filter_spis` answered both ways
+ for the same SPI.
+ """
+ from pyspi.calculator import load_spis_from_yaml, resolve_config
+ from pyspi.statistics.spectral import GroupDelay
+
+ for statistic in ("delay", "slope"):
+ spi = GroupDelay(statistic=statistic, fmin=0, fmax=0.5)
+ assert "antisymmetric" in spi.labels and spi.issigned()
+ assert not {"directed", "undirected", "unsigned"} & set(spi.labels)
+
+ r = GroupDelay(statistic="rvalue", fmin=0, fmax=0.5)
+ assert "undirected" in r.labels and not r.issigned()
+ assert "antisymmetric" not in r.labels
+
+ shipped = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ for identifier, spi in shipped.items():
+ labels = set(spi.labels)
+ if {"antisymmetric", "asymmetric"} & labels:
+ assert not {"directed", "undirected"} & labels, identifier
diff --git a/tests/test_estimator_contracts.py b/tests/test_estimator_contracts.py
new file mode 100644
index 00000000..10f3126b
--- /dev/null
+++ b/tests/test_estimator_contracts.py
@@ -0,0 +1,1527 @@
+"""An SPI must compute the estimator it advertises, or refuse.
+
+Six classes accept ``estimator="kraskov"``, embed ``kraskov_NN-4`` in their
+identifier, and then run the Gaussian estimator. Nothing in the result records
+that substitution, so a table can report a k-NN estimate that was never
+computed.
+
+Scope note: none of these six appear in any bundled config, so no shipped
+result or stored baseline is affected. This is a latent API defect — it bites
+anyone hand-writing a config — not a corruption of the current numbers. The
+tests are still blockers, because the failure is silent and scientific.
+
+The rejecting constructors are asserted directly; no xfail remains here.
+"""
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+from pyspi.statistics import infotheory as it
+
+# Classes that advertise kraskov but dispatch to the Gaussian estimator.
+FALSE_KRASKOV = [
+ "JointEntropy",
+ "ConditionalEntropy",
+ "CrossmapEntropy",
+ "CausalEntropy",
+ "DirectedInfo",
+ "StochasticInteraction",
+]
+
+
+def _data(seed=0, m=3, t=200):
+ rng = np.random.default_rng(seed)
+ return Data(data=rng.standard_normal((m, t)), dim_order="ps", zscore=True)
+
+
+# --------------------------------------------------------------------------
+# Estimator honesty
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("cls_name", FALSE_KRASKOV)
+def test_kraskov_is_not_silently_gaussian(cls_name):
+ """Either compute a genuine k-NN estimate, or reject the argument."""
+ cls = getattr(it, cls_name)
+ data = _data()
+
+ try:
+ kraskov = cls(estimator="kraskov")
+ except (ValueError, NotImplementedError):
+ return # Rejecting the unimplemented estimator is an acceptable fix.
+
+ gaussian = cls(estimator="gaussian")
+ kv = kraskov.multivariate(data)
+ gv = gaussian.multivariate(data)
+
+ assert not np.allclose(kv, gv, equal_nan=True), (
+ f"{cls_name}(estimator='kraskov') returned exactly the Gaussian result "
+ f"while advertising itself as {kraskov.identifier!r}."
+ )
+
+
+def test_invalid_auto_embed_method_is_rejected():
+ with pytest.raises((ValueError, KeyError, NotImplementedError)):
+ it.TransferEntropy(auto_embed_method="NOT_A_METHOD").multivariate(_data())
+
+
+def test_unsupported_parameters_are_rejected():
+ """A parameter that the chosen estimator ignores must not be accepted silently."""
+ with pytest.raises((ValueError, TypeError)):
+ # kernel_width is meaningless for the gaussian estimator.
+ it.MutualInfo(estimator="gaussian", kernel_width=0.5)
+
+
+# --------------------------------------------------------------------------
+# Symbolic transfer entropy
+# --------------------------------------------------------------------------
+
+def test_symbolic_k_history_1_is_rejected():
+ with pytest.raises(ValueError):
+ it.TransferEntropy(estimator="symbolic", k_history=1)
+
+
+def test_bundled_configs_exclude_degenerate_symbolic_variants():
+ from pyspi.calculator import bundled_configs, load_spis_from_yaml, resolve_config
+
+ offenders = []
+ for name in bundled_configs():
+ for ident in load_spis_from_yaml(resolve_config(name), quiet=True):
+ if "symbolic" in ident and ("_k-1_" in ident or ident.endswith("_k-1")):
+ offenders.append(f"{name}:{ident}")
+ if "symbolic" in ident and "_k-10" in ident:
+ offenders.append(f"{name}:{ident}")
+
+ assert not offenders, (
+ "Degenerate symbolic TE variants in bundled configs: " + ", ".join(offenders)
+ )
+
+
+def _reference_symbolic_te(src, targ, k):
+ """Independent symbolic TE using Python tuples as histogram keys.
+
+ Deliberately avoids any integer packing, so it cannot share the overflow
+ failure mode of the implementation it checks.
+ """
+ from collections import Counter
+ from math import log
+
+ from pyspi.statistics.infotheory import _series_to_ordinal_symbols
+
+ s = _series_to_ordinal_symbols(np.asarray(src, dtype=float), k)
+ t = _series_to_ordinal_symbols(np.asarray(targ, dtype=float), k)
+ n = min(len(s), len(t)) - 1
+ tn, tp, sc = t[1:n + 1], t[:n], s[:n]
+
+ def H(*cols):
+ counts = Counter(zip(*(list(map(int, c)) for c in cols)))
+ total = sum(counts.values())
+ # nats, matching the module-wide convention (see InfoTheoryBase).
+ return -sum((c / total) * log(c / total) for c in counts.values())
+
+ return H(tn, tp) - H(tp) - H(tn, tp, sc) + H(tp, sc)
+
+
+@pytest.mark.parametrize("k", [2, 5, 10])
+def test_symbolic_encoding_is_collision_free(k):
+ """Joint symbol counting must be injective at every k.
+
+ The old encoding multiplied by a multiplier that squared at each step, so
+ the packed value reached (k!)^3 and exceeded int64 at k=10, wrapping
+ silently. Wrapping is not the same as colliding -- no collisions occur on
+ the shipped fixtures -- but the encoding gave no guarantee, so this checks
+ the implementation against a reference that cannot overflow.
+ """
+ from pyspi.statistics.infotheory import SymbolicTECalculator
+
+ rng = np.random.default_rng(0)
+ src = rng.standard_normal(600)
+ targ = np.roll(src, 1) + 0.5 * rng.standard_normal(600)
+
+ calc = SymbolicTECalculator()
+ calc.setProperty("k_HISTORY", str(k))
+ calc.setObservations(src, targ)
+ got = calc.computeAverageLocalOfObservations()
+
+ expected = _reference_symbolic_te(src, targ, k)
+ assert np.isclose(got, expected, rtol=1e-9, atol=1e-12), (
+ f"Symbolic TE at k={k} disagrees with a tuple-keyed reference: "
+ f"{got!r} vs {expected!r}."
+ )
+
+
+# --------------------------------------------------------------------------
+# KSG preconditions
+# --------------------------------------------------------------------------
+
+# NOTE: parse_bivariate's signature is (self, data, data2=None, i=None, j=None),
+# so bivariate(data, 0, 1) binds data2=0, i=1 and fails with an unrelated
+# dimension error. Always pass i/j by keyword here, or these tests pass for the
+# wrong reason. (The positional foot-gun is a usability issue in its own right.)
+
+@pytest.mark.parametrize("k", [30, 100])
+def test_ksg_rejects_k_at_or_above_sample_size(k):
+ small = _data(m=2, t=20)
+ spi = it.MutualInfo(estimator="kraskov", prop_k=k)
+ with pytest.raises(ValueError):
+ spi.bivariate(small, i=0, j=1)
+
+
+def test_ksg_rejects_degenerate_samples():
+ const = np.zeros((2, 200))
+ const[1] = np.arange(200)
+ data = Data(data=const, dim_order="ps", zscore=False)
+ with pytest.raises(ValueError):
+ it.MutualInfo(estimator="kraskov").bivariate(data, i=0, j=1)
+
+
+# --------------------------------------------------------------------------
+# Directed information
+# --------------------------------------------------------------------------
+
+def _di_system(phi, c, T=4000, seed=0):
+ """y_t = phi*y_{t-1} + c*x_{t-1} + e_t, with x i.i.d."""
+ r = np.random.default_rng(seed)
+ x = r.standard_normal(T)
+ e = r.standard_normal(T)
+ y = np.zeros(T)
+ for t in range(1, T):
+ y[t] = phi * y[t - 1] + c * x[t - 1] + e[t]
+ return Data(data=np.vstack([x, y]), dim_order="ps", zscore=True)
+
+
+@pytest.mark.parametrize("phi", [0.0, 0.6, 0.95])
+def test_directed_info_is_zero_for_an_independent_source(phi):
+ """DI(X->Y) must not grow with the target's own autocorrelation.
+
+ The previous implementation summed H(Y^i)/i and subtracted causal entropy,
+ which is not Massey's definition: with an independent source it returned
+ 0.007 at phi=0 and 1.53 at phi=0.95, i.e. it measured how predictable the
+ target was from its own past.
+ """
+ di = it.DirectedInfo(estimator="gaussian").bivariate(_di_system(phi, 0.0), i=0, j=1)
+ assert abs(di) < 0.02, (
+ f"DI with an independent source is {di:.5f} at phi={phi}; it must be ~0 "
+ f"regardless of the target's autocorrelation."
+ )
+
+
+def test_directed_info_matches_the_analytic_gaussian_value():
+ """With phi=0 and lag-1 coupling, DI over horizon n=2 is 0.5*ln(1+c^2)."""
+ for c in (0.5, 1.0):
+ r = np.random.default_rng(1)
+ T = 200_000
+ x = r.standard_normal(T)
+ e = r.standard_normal(T)
+ y = np.zeros(T)
+ y[1:] = c * x[:-1] + e[1:]
+ d = Data(data=np.vstack([x, y]), dim_order="ps", zscore=True)
+ got = it.DirectedInfo(estimator="gaussian", n=2).bivariate(d, i=0, j=1)
+ expected = 0.5 * np.log(1 + c ** 2)
+ assert abs(got - expected) < 5e-3, (
+ f"DI={got:.6f} vs analytic {expected:.6f} for c={c}."
+ )
+
+
+def test_directed_info_is_directional():
+ d = _di_system(0.5, 1.0)
+ fwd = it.DirectedInfo(estimator="gaussian").bivariate(d, i=0, j=1)
+ rev = it.DirectedInfo(estimator="gaussian").bivariate(d, i=1, j=0)
+ assert fwd > 20 * max(rev, 1e-6), f"DI(X->Y)={fwd:.5f} not >> DI(Y->X)={rev:.5f}"
+
+
+def test_ksg_validation_reaches_the_transfer_entropy_path():
+ """The TE path embeds first, so its usable N is smaller than len(targ)."""
+ small = _data(m=2, t=20)
+ with pytest.raises(ValueError):
+ it.TransferEntropy(estimator="kraskov", prop_k=30).bivariate(small, i=0, j=1)
+
+
+def test_ksg_rejects_negative_theiler_window():
+ from pyspi.statistics.infotheory import _validate_ksg_sample
+ with pytest.raises(ValueError, match="Theiler"):
+ _validate_ksg_sample(200, 4, -5)
+
+
+def test_ksg_rejects_tied_inputs():
+ """Quantised/constant inputs give a zero k-th radius and a bogus negative CMI.
+
+ Binary series previously returned TE = -2.36, for a quantity bounded below
+ by zero.
+ """
+ const = np.zeros((2, 200))
+ const[1] = np.arange(200)
+ data = Data(data=const, dim_order="ps", zscore=False)
+ with pytest.raises(ValueError):
+ it.TransferEntropy(estimator="kraskov").bivariate(data, i=0, j=1)
+
+
+def test_directed_info_kraskov_matches_the_analytic_value():
+ """The direct CMI estimator must hit the same closed form as Gaussian.
+
+ DI composed from separate entropies cannot: kernel sat near +4 on
+ independent data at every T tested (100 to 8000), because a fixed-bandwidth
+ estimator's bias in ~11 dimensions does not shrink with sample size.
+ """
+ c = 1.0
+ r = np.random.default_rng(1)
+ T = 4000
+ x = r.standard_normal(T)
+ e = r.standard_normal(T)
+ y = np.zeros(T)
+ y[1:] = c * x[:-1] + e[1:]
+ d = Data(data=np.vstack([x, y]), dim_order="ps", zscore=True)
+
+ got = it.DirectedInfo(estimator="kraskov", n=2).bivariate(d, i=0, j=1)
+ expected = 0.5 * np.log(1 + c ** 2)
+ assert abs(got - expected) < 0.05, f"kraskov DI={got:.4f} vs analytic {expected:.4f}"
+
+
+@pytest.mark.parametrize("T", [200, 1000])
+def test_directed_info_kraskov_is_zero_for_independent_source(T):
+ r = np.random.default_rng(0)
+ d = Data(data=r.standard_normal((2, T)), dim_order="ps", zscore=True)
+ di = it.DirectedInfo(estimator="kraskov").bivariate(d, i=0, j=1)
+ assert abs(di) < 0.15, f"kraskov DI={di:.4f} on independent data at T={T}"
+
+
+@pytest.mark.parametrize("w", [0, 1, 3, 10])
+def test_ksg_cmi_reduces_to_mi_when_conditioning_set_is_empty(w):
+ """I(A;B|nothing) is I(A;B), at every Theiler window.
+
+ The empty-C branch used to fake the conditioning count as a constant
+ N-(2w+1). That matched the MI estimator only at w=0 and drifted with the
+ window (0.005 at w=1, 0.051 at w=10). DirectedInfo's first term has an
+ empty history, so this is on the shipped path whenever a Theiler window is
+ configured.
+ """
+ from pyspi.statistics.infotheory import _ksg_cmi, _ksg_mi_general
+
+ r = np.random.default_rng(0)
+ n = 400
+ a = r.standard_normal((n, 1))
+ b = 0.6 * a + 0.8 * r.standard_normal((n, 1))
+ empty = np.empty((n, 0))
+
+ assert _ksg_cmi(a, b, empty, 4, w) == pytest.approx(
+ _ksg_mi_general(a, b, 4, w), abs=1e-12
+ )
+
+
+def test_conditional_entropy_is_directed():
+ """H(X|Y) != H(Y|X): the label describes the measure, not one estimator.
+
+ The Gaussian form is symmetric under the default z-scoring only because
+ equal marginal variances make it so; kozachenko and kernel are asymmetric
+ even there, and Gaussian becomes asymmetric with zscore=False.
+ """
+ data = _data(m=3, t=200)
+ asym = {}
+ for est in ("gaussian", "kozachenko", "kernel"):
+ A = np.asarray(it.ConditionalEntropy(estimator=est).multivariate(data))
+ off = ~np.eye(3, dtype=bool)
+ asym[est] = float(np.nanmax(np.abs(A - A.T)[off]))
+
+ assert max(asym.values()) > 1e-6, f"no estimator is asymmetric: {asym}"
+ for est in ("gaussian", "kozachenko", "kernel"):
+ spi = it.ConditionalEntropy(estimator=est)
+ assert "directed" in spi.labels, f"{est} lost the directed label"
+ assert "undirected" not in spi.labels
+
+
+def test_wilson_non_convergence_is_reported_not_swallowed():
+ """A failed spectral factorisation must reach the caller's warnings.
+
+ Wilson's algorithm is iterative and, on hitting its iteration cap, reports
+ "Maximum iterations reached. N of M converged" through
+ ``logging.Logger.warning`` and returns the unconverged factor anyway. Every
+ Wilson-derived measure (DC, DTF, dDTF, PDC, gPDC, nonparametric spectral
+ GC) is built from that factor.
+
+ pyspi collects per-SPI diagnostics from the ``warnings`` channel only, so
+ before the bridge in ``statistics/spectral.py`` those numbers reached the
+ results table with nothing recorded against them. This is not hypothetical:
+ it fires on a *bundled* fixture. Same class of defect as the six SPIs above
+ -- a value that is quietly not what it claims to be.
+ """
+ import os
+ import warnings
+
+ from pyspi.data import Data
+ from pyspi.statistics.spectral import DirectedCoherence
+
+ fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "kuramoto_M7_T100.npy")
+ data = Data(data=fixture, dim_order="sp")
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ DirectedCoherence(statistic="mean", fmin=0, fmax=0.5).multivariate(data)
+
+ messages = [str(w.message) for w in caught]
+ assert any("Maximum iterations reached" in m for m in messages), (
+ "the backend's factorisation-convergence warning was swallowed; "
+ f"caught instead: {messages}"
+ )
+def test_ccm_auto_embedding_maximises_skill_rather_than_returning_max_e():
+ """``E=None`` must select an embedding, not return the largest candidate.
+
+ The call site read the winner as ``pyEDM.EmbedDimension(...).max()["E"]``.
+ ``DataFrame.max()`` reduces column-wise, so that is the largest *candidate*
+ E -- pyEDM's ``maxE`` default of 10 -- for every process on every dataset.
+ The three shipped ``ccm_E-None_*`` SPIs were consequently bit-identical to
+ ``ccm_E-10_*`` on all three frozen fixtures while advertising an inferred
+ embedding: the identifier said one thing and the number was another.
+
+ This pins the replacement against pyEDM's own per-E skill, and pins that
+ the answer is data-dependent rather than the constant it used to be.
+ """
+ import os
+
+ import pandas as pd
+ import pyEDM
+
+ from pyspi.data import Data
+ from pyspi.statistics.causal import _optimal_embedding_dimension
+
+ fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "var1_M3_T100.npy")
+ z = Data(data=fixture, dim_order="sp").to_numpy(squeeze=True)
+ M, N = z.shape
+ df = pd.DataFrame(
+ np.concatenate([np.atleast_2d(np.arange(N)), z]).T,
+ columns=["index"] + [f"proc{p}" for p in range(M)],
+ )
+ lib_pred = f"10 {N - 10}"
+
+ chosen = []
+ for i in range(M):
+ col = df.columns.values[i + 1]
+ reference = pyEDM.EmbedDimension(dataFrame=df, lib=lib_pred, pred=lib_pred,
+ columns=col, target=col, showPlot=False,
+ numProcess=1)
+ expected = int(reference.loc[reference["rho"].idxmax(), "E"])
+ got = _optimal_embedding_dimension(df, col, lib_pred)
+ assert got == expected, (
+ f"{col}: chose E={got}, pyEDM's skill curve peaks at E={expected}"
+ )
+ chosen.append(got)
+
+ assert any(E != 10 for E in chosen), (
+ f"every process selected the maximum candidate E ({chosen}); that is "
+ f"the symptom of reading max(E) instead of argmax(rho)"
+ )
+
+
+# ---------------------------------------------------------------------------
+# KSG input conditioning: standardisation and an explicit no-ties policy
+# ---------------------------------------------------------------------------
+
+def _correlated_pair(n=2000, rho=0.8, seed=0):
+ rng = np.random.default_rng(seed)
+ z = rng.standard_normal(n)
+ return z, rho * z + np.sqrt(1 - rho ** 2) * rng.standard_normal(n)
+
+
+@pytest.mark.parametrize("scale", [1e-3, 1.0, 1e3])
+def test_ksg_mi_is_invariant_to_per_coordinate_rescaling(scale):
+ """MI is invariant under nonzero affine marginal transformations.
+
+ The KSG estimator's L-infinity neighbour radius is not, which is why JIDT
+ normalises each column by default (``normalise = true`` on
+ MutualInfoMultiVariateCommon). Without it, scaling one member of a
+ correlated Gaussian pair by 1e-3 or 1e3 collapsed the estimate from 0.49 to
+ 0.05 and 0.06 against a true MI of 0.51.
+ """
+ from pyspi.statistics.infotheory import _ksg_mi_pair
+
+ x, y = _correlated_pair()
+ analytic = -0.5 * np.log(1 - np.corrcoef(x, y)[0, 1] ** 2)
+ got = _ksg_mi_pair(x, scale * y, 4, 0)
+ assert abs(got - analytic) < 0.05, (
+ f"MI = {got:.4f} at scale {scale:g}; analytic {analytic:.4f}"
+ )
+
+
+@pytest.mark.parametrize("w", [0, 5])
+@pytest.mark.parametrize("levels", [2, 4, None])
+def test_ksg_mi_refuses_quantised_marginals(levels, w):
+ """Continuous KSG must not turn arbitrary tie-breaking into a result."""
+ from pyspi.statistics.infotheory import _ksg_mi_pair
+
+ rng = np.random.default_rng(0)
+ def draw():
+ if levels is None:
+ return np.round(rng.standard_normal(400), 1)
+ return rng.integers(0, levels, 400).astype(float)
+
+ with pytest.raises(ValueError, match="continuous, tie-free coordinates"):
+ _ksg_mi_pair(draw(), draw(), 4, w)
+
+
+def test_ksg_refusal_directs_discrete_data_to_an_external_estimator():
+ from pyspi.statistics.infotheory import _ksg_mi_pair
+
+ rng = np.random.default_rng(1)
+ x = (rng.random(4000) < 0.5)
+ y = np.where(rng.random(4000) < 0.8, x, ~x)
+
+ with pytest.raises(ValueError, match="estimator outside pyspi"):
+ _ksg_mi_pair(x.astype(float), y.astype(float), 4, 0)
+
+
+def test_ksg_is_deterministic_and_independent_of_call_context():
+ """No random state or surrounding processes participate in conditioning."""
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import Data
+
+ rng = np.random.default_rng(2)
+ Z = rng.standard_normal((4, 300))
+ data = Data(data=Z, dim_order="ps", zscore=False)
+
+ for cls in (it.MutualInfo, it.TimeLaggedMutualInfo):
+ spi = cls(estimator="kraskov")
+ table = spi.multivariate(data)
+ assert spi.bivariate(data, i=0, j=2) == table[0, 2]
+ assert np.array_equal(spi.multivariate(data), table, equal_nan=True)
+
+
+def test_kraskov_spis_refuse_the_quantised_bundled_dataset():
+ """Every `forex` process is tied: 24--212 values in 250 samples.
+
+ It ships with the package, so the no-ties contract must be explicit rather
+ than an accidental low-level neighbour-count failure.
+ """
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import load_dataset
+
+ data = load_dataset("forex")
+ Z = data.to_numpy(squeeze=True)
+ assert [np.unique(x).size for x in Z] == [212, 212, 24, 198, 209, 197, 207]
+ for spi in (it.MutualInfo(estimator="kraskov"),
+ it.TimeLaggedMutualInfo(estimator="kraskov"),
+ it.TransferEntropy(estimator="kraskov"),
+ it.DirectedInfo(estimator="kraskov")):
+ with pytest.raises(ValueError, match="estimator outside pyspi"):
+ spi.bivariate(data, i=0, j=1)
+
+
+def test_kozachenko_entropy_still_refuses_tied_data_rather_than_dithering():
+ """A deliberate divergence from JIDT, and the reason is not stylistic.
+
+ JIDT dithers its Kozachenko calculator. For differential entropy,
+ H(X + e*xi) -> -inf as e -> 0 for discrete X, so a dithered estimate on
+ quantised data reports the dither level. pyspi names the problem instead
+ of returning a number set by an implementation constant.
+ """
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import load_dataset
+
+ with pytest.raises(ValueError, match="tied observations"):
+ it.JointEntropy(estimator="kozachenko").multivariate(load_dataset("forex"))
+
+
+# ---------------------------------------------------------------------------
+# TransferEntropy embedding parameters
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("estimator,extra", [
+ ("symbolic", {"k_tau": 2}),
+ ("symbolic", {"l_history": 2}),
+ ("symbolic", {"l_tau": 2}),
+ ("kernel", {"l_history": 1}),
+ ("kernel", {"k_tau": 1}),
+])
+def test_transfer_entropy_refuses_embedding_parameters_it_does_not_implement(
+ estimator, extra):
+ """The identifier must not advertise an embedding that was never applied.
+
+ `SymbolicTECalculator` reads only `k_HISTORY` and uses that one ordinal
+ pattern length for source *and* destination, at unit delay -- which is how
+ Staniek & Lehnertz (2008) define it. It nonetheless accepted `k_tau`,
+ `l_history` and `l_tau`, stored them, ignored them, and wrote them into the
+ identifier: `te_symbolic_k-3_kt-1_l-1_lt-1` claimed a destination history of
+ 3 against a source history of 1 while computing 3 for both. The kernel
+ calculator has the same single-history contract and accepted them too,
+ silently. Symbolic identifiers are now `te_symbolic_k-`, as the kernel
+ ones already were.
+ """
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match="not used by estimator"):
+ it.TransferEntropy(estimator=estimator, k_history=2, **extra)
+
+
+@pytest.mark.parametrize("bad", [
+ {"k_history": 0}, {"k_history": -1},
+ {"k_tau": 0}, {"l_history": 0}, {"l_tau": -2},
+])
+def test_transfer_entropy_rejects_non_positive_embedding_parameters(bad):
+ """Validated at the API boundary, not discovered as an empty embedding."""
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match=">= 1"):
+ it.TransferEntropy(estimator="gaussian", **({"k_history": 1} | bad))
+
+
+def test_symbolic_transfer_entropy_identifier_matches_what_is_computed():
+ import pyspi.statistics.infotheory as it
+
+ assert it.TransferEntropy(estimator="symbolic",
+ k_history=3).identifier == "te_symbolic_k-3"
+
+
+# ---------------------------------------------------------------------------
+# CrossCorrelation
+# ---------------------------------------------------------------------------
+
+def _xcorr_data(zscore=True, seed=0, T=500):
+ rng = np.random.default_rng(seed)
+ a = rng.standard_normal(T)
+ b = np.r_[0.0, a[:-1]] + 0.1 * rng.standard_normal(T) # b lags a by 1
+ from pyspi.data import Data
+ return Data(data=np.vstack([a, b]), dim_order="ps", zscore=zscore)
+
+
+def test_cross_correlation_of_a_series_with_itself_is_one():
+ """A correlation, so the self-pair must be exactly 1 and nothing exceeds it.
+
+ `correlate(x, y) / x.std() / y.std() / (T - 1)` is neither the biased
+ (divide by T) nor the unbiased (divide by T - |l|) normalisation, and it
+ put the zero lag of a series against itself at T/(T-1): exactly 1.1111 for
+ T = 10.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ x = np.arange(10.0)
+ for zscore in (True, False):
+ data = Data(data=np.vstack([x, x.copy()]), dim_order="ps", zscore=zscore)
+ got = CrossCorrelation(statistic="max", sigonly=False).bivariate(
+ data, i=0, j=1)
+ assert got == pytest.approx(1.0, abs=1e-12), f"zscore={zscore}: {got}"
+
+
+def test_cross_correlation_demeans_and_stays_bounded():
+ """The correlate call used the raw series while the divisor demeaned.
+
+ On `arange(10)` against itself with zscore=False that mismatch returned
+ 3.8384 -- for a quantity whose range is [-1, 1].
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(1)
+ u = rng.standard_normal(300) + 50.0 # large offset, small variance
+ v = 0.5 * u + rng.standard_normal(300)
+ data = Data(data=np.vstack([u, v]), dim_order="ps", zscore=False)
+
+ spi = CrossCorrelation(statistic="max", sigonly=False)
+ assert abs(spi.bivariate(data, i=0, j=1)) <= 1.0
+ lags = data.xcorr[(0, 1)]
+ assert np.abs(lags).max() <= 1.0
+ # Zero lag is Pearson's r by construction.
+ assert lags[len(lags) // 2] == pytest.approx(np.corrcoef(u, v)[0, 1], abs=1e-12)
+
+
+@pytest.mark.parametrize("statistic", ["max", "mean"])
+@pytest.mark.parametrize("sigonly", [True, False])
+def test_cross_correlation_is_symmetric_in_its_arguments(statistic, sigonly):
+ """It is declared undirected, so both orientations must agree.
+
+ Two independent reasons they did not. The lag window
+ `r_full[T - T//4 : T + T//4]` was centred on index T, but zero lag sits at
+ T - 1, so the window was asymmetric by one lag; and the cached opposite
+ orientation was `data.xcorr[(j,i)] = data.xcorr[(i,j)]` rather than its
+ reverse, since r_yx(l) = r_xy(-l). The `sigonly` truncation then walked
+ outwards from the centre, which on a pair where i leads j by one sample
+ (r(0) already insignificant) extended one way and not the other: measured
+ 0.9957 against -0.0202.
+ """
+ from pyspi.statistics.basic import CrossCorrelation
+
+ data = _xcorr_data()
+ spi = CrossCorrelation(statistic=statistic, sigonly=sigonly)
+ assert spi.bivariate(data, i=0, j=1) == pytest.approx(
+ spi.bivariate(data, i=1, j=0), abs=1e-12)
+
+
+def test_cross_correlation_cache_stores_the_reversed_lag_profile():
+ from pyspi.statistics.basic import CrossCorrelation
+
+ data = _xcorr_data()
+ CrossCorrelation(sigonly=False).bivariate(data, i=0, j=1)
+ assert np.array_equal(data.xcorr[(0, 1)], data.xcorr[(1, 0)][::-1])
+ # Odd length, so the centre index is exactly the zero lag.
+ assert len(data.xcorr[(0, 1)]) % 2 == 1
+
+
+def test_cross_correlation_significance_band_scales_with_the_sample_size():
+ """1.96/sqrt(T), not 1.96/sqrt(T//4).
+
+ The old threshold was computed from the half-width of the lag *window*, so
+ it was twice too wide and moved if the lag cut changed rather than if the
+ record length did.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(2)
+ T = 4000
+ data = Data(data=rng.standard_normal((2, T)), dim_order="ps")
+ spi = CrossCorrelation(statistic="max", sigonly=True)
+ spi.bivariate(data, i=0, j=1)
+ lags = data.xcorr[(0, 1)]
+ # Under independence essentially nothing should clear 1.96/sqrt(T); the old
+ # band (1.96/sqrt(T//4) = 2x wider) let through even fewer, masking that the
+ # nominal 5% level was never being applied.
+ assert (np.abs(lags) > 1.96 / np.sqrt(T)).mean() < 0.15
+
+
+# ---------------------------------------------------------------------------
+# Parameter/identifier/cache-key coverage
+# ---------------------------------------------------------------------------
+
+def test_dyn_corr_excl_value_reaches_the_identifier_and_the_cache_key():
+ """`_DCE` alone named three different Theiler windows.
+
+ dyn_corr_excl=5, =10 and ="AUTO" all produced `mi_kraskov_NN-4_DCE` and the
+ same `_getkey()`, so a config setting two of them collided silently.
+ """
+ import pyspi.statistics.infotheory as it
+
+ spis = [it.MutualInfo(estimator="kraskov", dyn_corr_excl=v)
+ for v in (5, 10, "AUTO")]
+ assert len({s.identifier for s in spis}) == 3, [s.identifier for s in spis]
+ assert len({s._getkey() for s in spis}) == 3
+
+
+def test_crossmap_entropy_embedding_dimension():
+ """`history_length=k` gives k-1 source lags and a k-column joint space.
+
+ Pinned rather than corrected: both readings of the parameter are internally
+ consistent, cross-map entropy has no canonical published definition to
+ arbitrate between them, and re-picking one would change every `xme_*` value
+ on a guess about intent. See the class docstring.
+ """
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import Data
+
+ k = 6
+ rng = np.random.default_rng(0)
+ data = Data(data=rng.standard_normal((2, 200)), dim_order="ps")
+
+ seen = {}
+ spi = it.CrossmapEntropy(history_length=k, estimator="gaussian")
+ real_initialise = spi._entropy_calc.initialise
+
+ def record(d):
+ seen.setdefault("dims", []).append(d)
+ return real_initialise(d)
+
+ spi._entropy_calc.initialise = record
+ spi.bivariate(data, i=0, j=1)
+ assert seen["dims"] == [k, k - 1], seen["dims"]
+
+
+def test_cointegration_aeg_tstat_is_signed_and_johansen_is_not():
+ """The Engle-Granger t-statistic's sign is the finding, not noise.
+
+ Reported as unsigned it went through `Calculator._rmmin`, which shifts the
+ column by its minimum, and through `set_group`'s `abs()`. Johansen's trace
+ and maximum-eigenvalue statistics are non-negative and stay unsigned.
+ """
+ from pyspi.statistics.misc import Cointegration
+
+ assert Cointegration(method="aeg", statistic="tstat").issigned()
+ assert not Cointegration(method="johansen", statistic="trace_stat").issigned()
+ assert not Cointegration(method="johansen", statistic="max_eig_stat").issigned()
+
+
+def test_itakura_dtw_normalisation_agrees_between_bivariate_and_multivariate():
+ """The itakura branch of `multivariate` skipped the sqrt(T) division.
+
+ The bivariate path and the dtaidistance path both apply it under
+ `normalise=True`, so the two disagreed by a factor of sqrt(T) for that one
+ constraint.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.distance import DynamicTimeWarping
+
+ rng = np.random.default_rng(0)
+ data = Data(data=rng.standard_normal((3, 120)), dim_order="ps")
+ spi = DynamicTimeWarping(global_constraint="itakura", normalise=True)
+ assert spi.bivariate(data, i=0, j=1) == pytest.approx(
+ spi.multivariate(data)[0, 1], rel=1e-12)
+
+
+
+@pytest.mark.parametrize("kwargs,exc", [
+ ({"i": 0}, ValueError), # j omitted
+ ({"j": 1}, ValueError), # i omitted
+ ({"i": 0, "j": 9}, IndexError), # out of range
+ ({"i": 0, "j": 1.5}, TypeError),
+])
+def test_bivariate_rejects_incomplete_or_invalid_indices(kwargs, exc):
+ """`z[None]` is `np.newaxis`, not an error.
+
+ A single index reached the SPI with the other left as None, and indexing
+ the process array with None turned the "pair" into the whole (1, M, T)
+ block -- so the SPI computed something with no relation to what was asked
+ for, silently. The signature is `(data, data2, i, j)`, so
+ `bivariate(data, 0, 3)` -- the obvious way to write it -- binds 0 to
+ `data2` and 3 to `i`, and lands in exactly that state.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(0)
+ data = Data(data=rng.standard_normal((4, 100)), dim_order="ps")
+ with pytest.raises(exc):
+ CrossCorrelation(sigonly=False).bivariate(data, **kwargs)
+
+
+@pytest.mark.parametrize("kwargs", [
+ {"estimator": "kernel", "kernel_width": 0},
+ {"estimator": "kernel", "kernel_width": -1},
+ {"estimator": "kraskov", "prop_k": 0},
+ {"estimator": "kraskov", "dyn_corr_excl": -3},
+])
+def test_infotheory_parameters_are_validated_at_construction(kwargs):
+ """A non-positive box-kernel half-width counts only the point itself.
+
+ Every log ratio is then log(N) and the "estimate" is a constant; k < 1 has
+ no kth neighbour at all. Both used to be discovered downstream, as a
+ degenerate number rather than a rejected argument.
+ """
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match=">"):
+ it.MutualInfo(**kwargs)
+
+
+# ---------------------------------------------------------------------------
+# KSG conditioning and independent continuous reference
+# ---------------------------------------------------------------------------
+
+def _brute_force_ksg_mi(x, y, k, w=0):
+ """O(N^2) transcription of KSG estimator 1, written from the paper.
+
+ Independent of pyspi's cKDTree machinery: full pairwise L-infinity
+ distances, the k-th smallest excluding self, strict marginal counts, and
+ psi(k) - + psi(N). Slow, so it is only used on
+ small continuous fixtures -- but it shares no code with the implementation
+ it checks.
+ """
+ from scipy.special import digamma
+
+ x = np.asarray(x, float)
+ y = np.asarray(y, float)
+ n = x.size
+ dx = np.abs(x[:, None] - x[None, :])
+ dy = np.abs(y[:, None] - y[None, :])
+ dz = np.maximum(dx, dy)
+ allowed = np.abs(np.arange(n)[:, None] - np.arange(n)) > w
+ dz[~allowed] = np.inf
+ eps = np.sort(dz, axis=1)[:, k - 1]
+ n_x = ((dx < eps[:, None]) & allowed).sum(axis=1)
+ n_y = ((dy < eps[:, None]) & allowed).sum(axis=1)
+ return float(digamma(k) - np.mean(digamma(n_x + 1) + digamma(n_y + 1))
+ + digamma(n))
+
+
+def test_ksg_matches_an_independent_brute_force_reference():
+ """Tie-free continuous data, where both implementations are unambiguous.
+
+ This pins the neighbour counting and digamma assembly independently of the
+ implementation's cKDTree path.
+ """
+ from pyspi.statistics.infotheory import _knn_condition, _ksg_mi_pair
+
+ rng = np.random.default_rng(7)
+ n = 300
+ z = rng.standard_normal(n)
+ x, y = z, 0.7 * z + np.sqrt(1 - 0.49) * rng.standard_normal(n)
+
+ for k in (1, 4, 10):
+ conditioned = _knn_condition(np.column_stack([x, y]))
+ expected = _brute_force_ksg_mi(conditioned[:, 0], conditioned[:, 1], k)
+ assert _ksg_mi_pair(x, y, k, 0) == pytest.approx(expected, abs=1e-12), k
+
+
+@pytest.mark.parametrize("w", [0, 2])
+def test_ksg_strict_radius_matches_exact_pairwise_counting_near_boundary(w):
+ """A relative epsilon shrink must not remove genuine interior points."""
+ from pyspi.statistics.infotheory import _knn_condition, _ksg_mi_pair
+
+ rng = np.random.default_rng(0)
+ x = rng.standard_normal(8)
+ y = x + 1e-10 * rng.standard_normal(8)
+ conditioned = _knn_condition(np.column_stack([x, y]))
+ expected = _brute_force_ksg_mi(
+ conditioned[:, 0], conditioned[:, 1], 1, w
+ )
+ assert _ksg_mi_pair(x, y, 1, w) == pytest.approx(expected, abs=1e-12)
+ if w == 0:
+ assert expected == pytest.approx(1.5928571428571427, abs=1e-15)
+
+
+@pytest.mark.parametrize("seed", [0, 42, 53])
+def test_knn_condition_is_affine_and_process_permutation_covariant(seed):
+ """Exercise valid conditioning before any KSG formula."""
+ from pyspi.statistics.infotheory import _knn_condition
+
+ rng = np.random.default_rng(seed)
+ X = rng.standard_normal((100, 3))
+
+ base = _knn_condition(X)
+ order = [2, 1, 0]
+ assert np.array_equal(_knn_condition(X[:, order]), base[:, order])
+
+ scales = np.array([-3.0, 7.0, -0.25])
+ offsets = np.array([2.0, -5.0, 9.0])
+ moved = _knn_condition(X * scales + offsets)
+ assert np.allclose(moved, base * np.sign(scales), atol=5e-15, rtol=0)
+
+
+def test_knn_condition_refuses_duplicates_and_affine_rounding_boundary():
+ """There is no numerical key/tolerance boundary because no key is used."""
+ from pyspi.statistics.infotheory import _knn_condition
+
+ x = np.r_[np.zeros(4), np.ones(22)]
+ for moved in (x, 0.1 * x + 0.3, -7.0 * x + 2.0):
+ with pytest.raises(ValueError, match="continuous, tie-free coordinates"):
+ _knn_condition(moved)
+
+
+@pytest.mark.parametrize("seed", [0, 53])
+@pytest.mark.parametrize("kind", ["one_duplicate", "binary", "four_level"])
+def test_all_ksg_paths_consistently_refuse_tied_data(seed, kind):
+ """MI, TLMI, AIS, TE and DI share the explicit no-ties contract."""
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import Data
+
+ rng = np.random.default_rng(seed)
+ if kind == "one_duplicate":
+ x = rng.standard_normal(160)
+ y = rng.standard_normal(160)
+ x[10] = x[9]
+ y[10] = y[9]
+ else:
+ levels = 2 if kind == "binary" else 4
+ x = rng.integers(0, levels, 160).astype(float)
+ y = rng.integers(0, levels, 160).astype(float)
+ data = Data(data=np.vstack([x, y, rng.standard_normal(160)]),
+ dim_order="ps", zscore=False)
+
+ for spi in (
+ it.MutualInfo(estimator="kraskov"),
+ it.TimeLaggedMutualInfo(estimator="kraskov"),
+ it.TransferEntropy(estimator="kraskov"),
+ it.DirectedInfo(estimator="kraskov", n=2),
+ ):
+ with pytest.raises(ValueError, match="continuous, tie-free coordinates"):
+ spi.bivariate(data, i=0, j=1)
+ with pytest.raises(ValueError, match="continuous, tie-free coordinates"):
+ it._ksg_ais(x, 2, 1, 4)
+
+
+@pytest.mark.parametrize("scale", [1e-3, 1.0, 1e3])
+def test_ksg_conditional_mi_is_invariant_to_per_coordinate_rescaling(scale):
+ """The normalisation has to reach the conditioning set too, not just A and B."""
+ from pyspi.statistics.infotheory import _ksg_cmi
+
+ rng = np.random.default_rng(5)
+ A = rng.standard_normal((500, 1))
+ C = rng.standard_normal((500, 2))
+ B = 0.6 * A + 0.4 * C[:, :1] + 0.5 * rng.standard_normal((500, 1))
+
+ base = _ksg_cmi(A, B, C, 4, 0)
+ assert _ksg_cmi(A * scale, B, C / scale, 4, 0) == pytest.approx(base, abs=1e-12)
+
+
+def test_ksg_results_survive_the_parallel_boundary(tmp_path):
+ """Serial and parallel must agree bit-for-bit on valid continuous data."""
+ from pyspi.calculator import Calculator
+ from pyspi.data import Data
+
+ config = tmp_path / "ksg.yaml"
+ config.write_text(
+ ".statistics.infotheory:\n"
+ " MutualInfo:\n"
+ " labels: [infotheory]\n"
+ " configs:\n"
+ " - estimator: kraskov\n"
+ " prop_k: 4\n"
+ " TransferEntropy:\n"
+ " labels: [infotheory]\n"
+ " configs:\n"
+ " - estimator: kraskov\n"
+ " prop_k: 4\n"
+ " DirectedInfo:\n"
+ " labels: [infotheory]\n"
+ " configs:\n"
+ " - estimator: kraskov\n"
+ " prop_k: 4\n"
+ )
+
+ rng = np.random.default_rng(0)
+ x = rng.standard_normal(200)
+ dataset = np.vstack([
+ x,
+ 0.6 * np.roll(x, 1) + rng.standard_normal(200),
+ rng.standard_normal(200),
+ ])
+
+ tables = []
+ for kwargs in ({"n_jobs": 1}, {"n_jobs": 2, "mp_context": "spawn"}):
+ calc = Calculator(dataset=Data(data=dataset, dim_order="ps"),
+ config=str(config))
+ calc.compute(**kwargs)
+ tables.append({k: calc.table[k].to_numpy() for k in calc.spis})
+ for key in tables[0]:
+ assert np.array_equal(tables[0][key], tables[1][key], equal_nan=True), key
+
+
+# ---------------------------------------------------------------------------
+# Transfer-entropy auto-embedding: the support matrix
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("estimator", ["kernel", "symbolic"])
+@pytest.mark.parametrize("kwargs", [
+ {"auto_embed_method": "MAX_CORR_AIS"},
+ {"k_search_max": 5},
+ {"tau_search_max": 3},
+])
+def test_auto_embedding_is_refused_by_the_estimators_that_cannot_do_it(
+ estimator, kwargs):
+ """No AIS criterion exists for the box-kernel or ordinal estimators.
+
+ Both accepted `auto_embed_method` and the search bounds and then ran a fixed
+ embedding, so the argument said an embedding had been selected and none had.
+ """
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match="not implemented for estimator"):
+ it.TransferEntropy(estimator=estimator, k_history=2, **kwargs)
+
+
+@pytest.mark.parametrize("fixed", ["k_history", "k_tau", "l_history", "l_tau"])
+def test_full_max_corr_ais_refuses_a_fixed_embedding(fixed):
+ """It selects all four, so accepting a fixed one would mean ignoring it."""
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match="conflicts with auto_embed_method"):
+ it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS", **{fixed: 2})
+
+
+@pytest.mark.parametrize("fixed,allowed", [
+ ("k_history", False), ("k_tau", False), ("l_history", True), ("l_tau", True),
+])
+def test_dest_only_refuses_a_fixed_destination_and_accepts_a_fixed_source(
+ fixed, allowed):
+ import pyspi.statistics.infotheory as it
+
+ build = lambda: it.TransferEntropy(
+ estimator="gaussian", auto_embed_method="MAX_CORR_AIS_DEST_ONLY",
+ **{fixed: 2})
+ if allowed:
+ assert f"_{'l' if fixed == 'l_history' else 'lt'}-2" in build().identifier
+ else:
+ with pytest.raises(ValueError, match="conflicts with auto_embed_method"):
+ build()
+
+
+@pytest.mark.parametrize("bound", ["k_search_max", "tau_search_max"])
+def test_search_bounds_are_refused_without_an_auto_method(bound):
+ """They reached the identifier only under the auto branch, so with a fixed
+ embedding they were accepted and silently discarded."""
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(ValueError, match="requires auto_embed_method"):
+ it.TransferEntropy(estimator="gaussian", **{bound: 5})
+
+
+def test_auto_embedding_identifiers_name_the_method_and_resolved_bounds():
+ """`MAX_CORR_AIS` changed meaning, so the identifier has to say which it is.
+
+ It previously searched the destination only while carrying a name that
+ denotes selection for both, and the identifier recorded neither. Defaults
+ are resolved before the identifier is built, so an omitted bound still
+ appears with the value actually used.
+ """
+ import pyspi.statistics.infotheory as it
+
+ assert it.TransferEntropy(
+ estimator="kraskov", auto_embed_method="MAX_CORR_AIS",
+ k_search_max=10, tau_search_max=4
+ ).identifier == "te_kraskov_NN-4_MAX-CORR-AIS_k-max-10_tau-max-4"
+ # Omitted bounds resolve to 10 and 4 and are still named.
+ assert it.TransferEntropy(
+ estimator="kraskov", auto_embed_method="MAX_CORR_AIS"
+ ).identifier == "te_kraskov_NN-4_MAX-CORR-AIS_k-max-10_tau-max-4"
+ assert it.TransferEntropy(
+ estimator="gaussian", auto_embed_method="MAX_CORR_AIS_DEST_ONLY",
+ k_search_max=6, tau_search_max=2, l_history=3
+ ).identifier == "gc_gaussian_MAX-CORR-AIS-DEST-ONLY_k-max-6_tau-max-2_l-3_lt-1"
+
+
+@pytest.mark.parametrize("bad", [
+ {"k_history": 2.0}, {"k_history": True}, {"k_tau": np.float64(1)},
+ {"k_search_max": 1.5, "auto_embed_method": "MAX_CORR_AIS"},
+])
+def test_embedding_parameters_must_be_integral_and_not_boolean(bad):
+ """`bool` subclasses `int`, so `k_history=True` would pass as 1, and
+ `int(2.7)` silently truncates a parameter the caller meant otherwise."""
+ import pyspi.statistics.infotheory as it
+
+ with pytest.raises(TypeError, match="must be an integer"):
+ it.TransferEntropy(estimator="gaussian", **bad)
+
+
+def test_auto_embedding_selection_is_cached_per_process():
+ """One search per (process, estimator, bounds, Theiler window), not per pair."""
+ import pyspi.statistics.infotheory as it
+ from pyspi.data import Data
+
+ rng = np.random.default_rng(0)
+ data = Data(data=rng.standard_normal((4, 400)), dim_order="ps", zscore=False)
+ spi = it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS",
+ k_search_max=4, tau_search_max=2)
+
+ calls = []
+ real = it._select_embedding
+ it._select_embedding = lambda *a, **k: (calls.append(1), real(*a, **k))[1]
+ try:
+ spi.multivariate(data)
+ finally:
+ it._select_embedding = real
+
+ # 4 processes, 12 ordered pairs, 24 selections without caching.
+ assert len(calls) == 4, calls
+ assert len(data.ais_embedding) == 4
+
+
+# ---------------------------------------------------------------------------
+# CrossCorrelation: `sigonly` is a threshold, not a test
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("squared", [False, True])
+@pytest.mark.parametrize("statistic", ["max", "mean"])
+def test_sigonly_returns_zero_when_no_lag_clears_the_threshold(statistic, squared):
+ """An empty thresholded set is an association of zero, not of the window.
+
+ Falling back to the unfiltered window reported the largest of ~T/2 sample
+ correlations under the null -- for this fixture 0.128 (max), -0.105 (mean),
+ 0.191 and 0.056 squared -- which is the opposite of what a threshold is for.
+
+ T is small on purpose. The cut is applied pointwise at every lag in a window
+ of about T/2, so at a nominal 5% per lag the null keeps something almost
+ surely for any moderate T: no seed in 400 produced an empty set at T=600.
+ That is itself why `sigonly` is not an inferential test.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(0)
+ Z = rng.standard_normal((2, 12))
+ data = Data(data=Z, dim_order="ps")
+ assert np.abs(np.corrcoef(Z)[0, 1]) < 1.96 / np.sqrt(12), "fixture drifted"
+
+ spi = CrossCorrelation(squared=squared, statistic=statistic, sigonly=True)
+ assert spi.bivariate(data, i=0, j=1) == 0.0
+ # ... and the unfiltered variant is emphatically not zero.
+ unfiltered = CrossCorrelation(squared=squared, statistic=statistic,
+ sigonly=False)
+ assert abs(unfiltered.bivariate(Data(data=Z, dim_order="ps"), i=0, j=1)) > 0.05
+
+
+@pytest.mark.parametrize("sign", [+1, -1])
+def test_cross_correlation_reports_the_sign_of_the_association(sign):
+ """What each reduction actually reports.
+
+ `max` is the largest *retained positive* correlation, not the strongest
+ association: on an anticorrelated pair whose true r(0) is -1, `xcorr_max`
+ returns a small positive number, because the maximum of a signed profile is
+ a maximum. `mean` carries the sign. The squared variants carry the strength
+ without it. That is the shipped semantics and this pass does not redesign
+ it; the test records it so it cannot be mistaken for a defect later.
+
+ Asserted on the returned SPI values rather than on the cached lag profile,
+ since the reduction and the thresholding are where the bugs were.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(1)
+ a = rng.standard_normal(600)
+ b = sign * a + 0.05 * rng.standard_normal(600)
+ data = lambda: Data(data=np.vstack([a, b]), dim_order="ps")
+
+ mean = CrossCorrelation(statistic="mean", sigonly=True).bivariate(
+ data(), i=0, j=1)
+ sq_max = CrossCorrelation(squared=True, statistic="max",
+ sigonly=True).bivariate(data(), i=0, j=1)
+ assert np.sign(mean) == sign
+ assert sq_max == pytest.approx(1.0, abs=0.01)
+ if sign > 0:
+ assert CrossCorrelation(statistic="max", sigonly=True).bivariate(
+ data(), i=0, j=1) == pytest.approx(1.0, abs=0.01)
+
+
+def test_sigonly_threshold_is_the_documented_pointwise_cut():
+ """1.96/sqrt(T) on |r(l)|, applied lag by lag -- nothing more.
+
+ Checked by reconstructing the surviving set from the cached profile and
+ reducing it independently, so the test pins the rule rather than restating
+ the implementation's own filter.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.basic import CrossCorrelation
+
+ rng = np.random.default_rng(4)
+ T = 500
+ a = rng.standard_normal(T)
+ b = 0.4 * np.r_[0.0, a[:-1]] + rng.standard_normal(T)
+
+ data = Data(data=np.vstack([a, b]), dim_order="ps")
+ got = CrossCorrelation(statistic="mean", sigonly=True).bivariate(
+ data, i=0, j=1)
+ profile = data.xcorr[(0, 1)]
+ kept = profile[np.abs(profile) > 1.96 / np.sqrt(T)]
+ assert kept.size
+ assert got == pytest.approx(float(np.mean(kept)), rel=1e-12)
+
+
+# ---------------------------------------------------------------------------
+# Pairwise and cross-pairwise distances
+# ---------------------------------------------------------------------------
+
+def _xpdist_by_hand(x, y, tau, statistic):
+ """The definition written out: pair with an offset, stutter the ends."""
+ T = len(x)
+
+ def cost(s):
+ if s == 0:
+ diff = x - y
+ elif s > 0:
+ diff = np.concatenate([x[:s] - y[0], x[s:] - y[:T - s], x[T - 1] - y[T - s:]])
+ else:
+ a = -s
+ diff = np.concatenate([y[:a] - x[0], y[a:] - x[:T - a], y[T - 1] - x[T - a:]])
+ return float(np.sqrt(np.sum(diff ** 2) / T))
+
+ per_lag = [cost(0)] + [min(cost(t), cost(-t)) for t in range(1, tau + 1)]
+ return min(per_lag) if statistic == "min" else float(np.mean(per_lag))
+
+
+@pytest.mark.parametrize("statistic", ["min", "mean"])
+def test_cross_pairwise_distance_matches_the_written_out_definition(statistic):
+ from pyspi.data import Data
+ from pyspi.statistics.distance import CrossPairwiseDistance
+
+ rng = np.random.default_rng(0)
+ Z = rng.standard_normal((2, 120))
+ data = Data(data=Z, dim_order="ps", zscore=False)
+ got = CrossPairwiseDistance(tau=3, statistic=statistic).bivariate(
+ data, i=0, j=1)
+ assert got == pytest.approx(_xpdist_by_hand(Z[0], Z[1], 3, statistic),
+ rel=1e-12)
+
+
+def test_cross_pairwise_distance_at_tau_zero_is_pairwise_euclidean_rmse():
+ """The tau=0 path is the identity alignment, so the two must coincide."""
+ from pyspi.data import Data
+ from pyspi.statistics.distance import (CrossPairwiseDistance,
+ PairwiseDistance)
+
+ rng = np.random.default_rng(1)
+ Z = rng.standard_normal((4, 150))
+ data = lambda: Data(data=Z, dim_order="ps", zscore=False)
+ off = ~np.eye(4, dtype=bool)
+ a = CrossPairwiseDistance(tau=0).multivariate(data())
+ b = PairwiseDistance(metric="euclidean", normalise=True).multivariate(data())
+ assert np.allclose(a[off], b[off], rtol=1e-12)
+
+
+def test_cross_pairwise_distance_is_symmetric_and_agrees_across_entry_points():
+ from pyspi.data import Data
+ from pyspi.statistics.distance import CrossPairwiseDistance
+
+ rng = np.random.default_rng(2)
+ Z = rng.standard_normal((4, 150))
+ data = lambda: Data(data=Z, dim_order="ps", zscore=False)
+ spi = CrossPairwiseDistance(tau=4, statistic="mean")
+ table = spi.multivariate(data())
+ assert np.allclose(table, table.T, equal_nan=True)
+ assert spi.bivariate(data(), i=1, j=3) == pytest.approx(table[1, 3], rel=1e-12)
+ # Permuting the processes permutes the matrix and nothing else.
+ permuted = spi.multivariate(Data(data=Z[::-1], dim_order="ps", zscore=False))
+ assert np.allclose(table, permuted[::-1, ::-1], equal_nan=True)
+
+
+@pytest.mark.parametrize("seed", range(5))
+def test_cross_pairwise_distance_upper_bounds_normalised_dtw(seed):
+ """The stuttered alignment is a valid DTW path, and DTW minimises over them.
+
+ So `dtw_rmse <= xpdist` holds by construction, not by coincidence -- which
+ is the whole reason for stuttering the boundary rather than truncating.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.distance import (CrossPairwiseDistance,
+ DynamicTimeWarping)
+
+ rng = np.random.default_rng(seed)
+ Z = np.cumsum(rng.standard_normal((3, 120)), axis=1)
+ data = lambda: Data(data=Z, dim_order="ps", zscore=False)
+ off = ~np.eye(3, dtype=bool)
+ xpdist = CrossPairwiseDistance(tau=5, statistic="min").multivariate(data())
+ dtw = DynamicTimeWarping(normalise=True).multivariate(data())
+ assert np.all(dtw[off] <= xpdist[off] + 1e-9)
+
+
+@pytest.mark.parametrize("bad", [1.7, True, -1, float("nan"), float("inf")])
+def test_cross_pairwise_distance_rejects_non_integral_tau(bad):
+ """`int(tau) < 0` accepted 1.7 (truncated to 1) and True (silently 1)."""
+ from pyspi.statistics.distance import CrossPairwiseDistance
+
+ with pytest.raises((TypeError, ValueError)):
+ CrossPairwiseDistance(tau=bad)
+
+
+def test_rmse_suffix_is_the_normalisation_label_not_a_metric_claim():
+ """Kept as the house label for `/sqrt(T)`, documented rather than renamed.
+
+ An earlier pass renamed it to `_norm-rootT` for non-Euclidean metrics. That
+ introduced a third suffix convention for a quantity no bundled config
+ computes, against `xpdist`'s and `dtw`'s existing use of `_rmse` for the
+ same normalisation.
+ """
+ from pyspi.statistics.distance import PairwiseDistance
+
+ for metric in ("euclidean", "cityblock", "cosine", "canberra", "braycurtis"):
+ assert PairwiseDistance(metric=metric,
+ normalise=True).identifier.endswith("_rmse")
+
+
+# ---------------------------------------------------------------------------
+# Strict validation of the public numeric surface
+# ---------------------------------------------------------------------------
+
+def _integral_constructors():
+ """(label, ctor, minimum) for every public parameter contracted as integral."""
+ import pyspi.statistics.infotheory as it
+ from pyspi.statistics.basic import LaggedCorrelation
+ from pyspi.statistics.causal import ConvergentCrossMapping
+ from pyspi.statistics.distance import (CrossPairwiseDistance,
+ DynamicTimeWarping)
+
+ return [
+ ("prop_k", lambda v: it.MutualInfo(estimator="kraskov", prop_k=v), 1),
+ ("dyn_corr_excl",
+ lambda v: it.MutualInfo(estimator="kraskov", dyn_corr_excl=v), 0),
+ ("n (CausalEntropy)", lambda v: it.CausalEntropy(n=v), 1),
+ ("n (DirectedInfo)", lambda v: it.DirectedInfo(n=v), 1),
+ ("k_history", lambda v: it.TransferEntropy(estimator="gaussian",
+ k_history=v), 1),
+ ("k_search_max",
+ lambda v: it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS",
+ k_search_max=v), 1),
+ ("LaggedCorrelation.tau", lambda v: LaggedCorrelation(tau=v), 0),
+ ("sakoe_chiba_radius",
+ lambda v: DynamicTimeWarping(global_constraint="sakoe_chiba",
+ sakoe_chiba_radius=v), 1),
+ ("CrossPairwiseDistance.tau", lambda v: CrossPairwiseDistance(tau=v), 0),
+ ("embedding_dimension",
+ lambda v: ConvergentCrossMapping(embedding_dimension=v), 1),
+ ]
+
+
+@pytest.mark.parametrize("bad", [2.7, 1.0, True, False, "3", float("nan"),
+ float("inf"), -float("inf")])
+def test_lagged_correlation_max_tau_rejects_coercion(bad):
+ from pyspi.calculator import _expand_lagged_correlation_configs
+
+ with pytest.raises((TypeError, ValueError), match="max_tau"):
+ _expand_lagged_correlation_configs([{"max_tau": bad}])
+
+
+def test_valid_integer_max_tau_and_ccm_dimension_are_canonicalised():
+ from pyspi.calculator import _expand_lagged_correlation_configs
+ from pyspi.statistics.causal import ConvergentCrossMapping
+
+ assert _expand_lagged_correlation_configs([{"max_tau": np.int64(2)}]) == [
+ {"tau": 1}, {"tau": 2}
+ ]
+ spi = ConvergentCrossMapping(embedding_dimension=np.int64(2))
+ assert spi._E == 2 and type(spi._E) is int
+ assert spi.identifier == "ccm_E-2_mean"
+
+
+@pytest.mark.parametrize("bad", [2.7, 1.0, True, False, "3", float("nan"),
+ float("inf"), -float("inf"), None.__class__])
+def test_integral_parameters_reject_non_integral_values(bad):
+ """`int(2.7)` is 2 and `int(True)` is 1, so a permissive cast turns a
+ plainly wrong argument into a plausible one."""
+ for label, ctor, _ in _integral_constructors():
+ with pytest.raises((TypeError, ValueError)):
+ ctor(bad)
+
+
+@pytest.mark.parametrize("value", [0, -1, -5])
+def test_integral_parameters_reject_values_below_their_minimum(value):
+ for label, ctor, minimum in _integral_constructors():
+ if value >= minimum:
+ assert ctor(value) is not None, label # legitimately accepted
+ continue
+ with pytest.raises(ValueError, match=">="):
+ ctor(value)
+
+
+@pytest.mark.parametrize("bad", [True, False, "0.5", float("nan"),
+ float("inf"), 0, -0.5])
+def test_continuous_parameters_reject_non_finite_and_non_positive(bad):
+ """`kernel_width` and `sakoe_chiba_ratio` are genuinely continuous, so a
+ fractional value is legitimate -- but bool, NaN, infinity and <= 0 are not.
+ """
+ import pyspi.statistics.infotheory as it
+ from pyspi.statistics.distance import DynamicTimeWarping
+
+ for ctor in (lambda v: it.MutualInfo(estimator="kernel", kernel_width=v),
+ lambda v: DynamicTimeWarping(global_constraint="sakoe_chiba",
+ sakoe_chiba_ratio=v)):
+ with pytest.raises((TypeError, ValueError)):
+ ctor(bad)
+
+
+def test_continuous_parameters_accept_a_fractional_value():
+ """The converse: strictness must not have broken the legitimate case."""
+ import pyspi.statistics.infotheory as it
+ from pyspi.statistics.distance import DynamicTimeWarping
+
+ assert "W-0.25" in it.MutualInfo(estimator="kernel",
+ kernel_width=0.25).identifier
+ assert "ratio-0.1" in DynamicTimeWarping(
+ global_constraint="sakoe_chiba", sakoe_chiba_ratio=0.1).identifier
+
+
+def test_dyn_corr_excl_accepts_only_none_integers_and_exact_auto():
+ import pyspi.statistics.infotheory as it
+
+ assert it.MutualInfo(estimator="kraskov",
+ dyn_corr_excl="AUTO").identifier.endswith("_DCE-AUTO")
+ assert it.MutualInfo(estimator="kraskov",
+ dyn_corr_excl=7).identifier.endswith("_DCE-7")
+ # 0 is "no window", which is what None already means, so it stays unnamed.
+ assert it.MutualInfo(estimator="kraskov",
+ dyn_corr_excl=0).identifier == "mi_kraskov_NN-4"
+ for bad in ("auto", "Auto", "AUTO ", "10"):
+ with pytest.raises(ValueError, match="AUTO"):
+ it.MutualInfo(estimator="kraskov", dyn_corr_excl=bad)
+
+
+def test_validated_value_is_the_one_used_in_identifier_and_cache_key():
+ """A numpy integer must canonicalise, not leak its type into the name."""
+ import pyspi.statistics.infotheory as it
+
+ spi = it.MutualInfo(estimator="kraskov", prop_k=np.int64(6),
+ dyn_corr_excl=np.int32(3))
+ assert spi.identifier == "mi_kraskov_NN-6_DCE-3"
+ assert spi._getkey() == ("kraskov", 6, 3)
+ assert all(type(v) is int for v in spi._getkey()[1:])
+
+
+@pytest.mark.parametrize("seed", [0, 53])
+@pytest.mark.parametrize("w", [0, 3])
+def test_ksg_mi_and_cmi_preserve_complete_time_reversal(seed, w):
+ """Reversal preserves values and every |i-j| Theiler exclusion."""
+ from pyspi.statistics.infotheory import (
+ TimeLaggedMutualInfo, _ksg_ais, _ksg_cmi, _ksg_mi_pair,
+ )
+
+ rng = np.random.default_rng(seed)
+ A = rng.standard_normal((160, 2))
+ B = 0.5 * A[:, :1] + rng.standard_normal((160, 1))
+ C = rng.standard_normal((160, 2))
+ rev = np.arange(A.shape[0] - 1, -1, -1)
+ assert _ksg_mi_pair(A[:, 0], B[:, 0], 4, w) == pytest.approx(
+ _ksg_mi_pair(A[rev, 0], B[rev, 0], 4, w), abs=1e-12)
+ assert _ksg_cmi(A, B, C, 4, w) == pytest.approx(
+ _ksg_cmi(A[rev], B[rev], C[rev], 4, w), abs=1e-12)
+ assert _ksg_mi_pair(B[:, 0], A[:, 0], 4, w) == pytest.approx(
+ _ksg_mi_pair(A[:, 0], B[:, 0], 4, w), abs=1e-12)
+ assert _ksg_cmi(B, A, C, 4, w) == pytest.approx(
+ _ksg_cmi(A, B, C, 4, w), abs=1e-12)
+
+ Z = np.vstack([A[:, 0], B[:, 0], C[:, 0]])
+ tlmi = TimeLaggedMutualInfo(estimator="kraskov", dyn_corr_excl=w)
+ base = np.asarray(tlmi.multivariate(Data(data=Z, dim_order="ps",
+ zscore=False)), float)
+ reversed_ = np.asarray(tlmi.multivariate(Data(data=Z[:, ::-1],
+ dim_order="ps",
+ zscore=False)), float)
+ assert np.allclose(base, reversed_.T, equal_nan=True, atol=1e-12, rtol=0)
+ assert _ksg_ais(A[:, 0], 1, 1, 4, w) == pytest.approx(
+ _ksg_ais(A[::-1, 0], 1, 1, 4, w), abs=1e-12)
+
+
+@pytest.mark.parametrize("seed", [0, 53])
+def test_ksg_mi_and_cmi_preserve_joint_sample_permutations_at_w_zero(seed):
+ """At w=0 observation labels have no role in the KSG geometry."""
+ from pyspi.statistics.infotheory import _ksg_cmi, _ksg_mi_pair
+
+ rng = np.random.default_rng(seed)
+ A = rng.standard_normal((160, 2))
+ B = 0.5 * A[:, :1] + rng.standard_normal((160, 1))
+ C = rng.standard_normal((160, 2))
+ order = rng.permutation(A.shape[0])
+ assert _ksg_mi_pair(A[:, 0], B[:, 0], 4, 0) == pytest.approx(
+ _ksg_mi_pair(A[order, 0], B[order, 0], 4, 0), abs=1e-12)
+ assert _ksg_cmi(A, B, C, 4, 0) == pytest.approx(
+ _ksg_cmi(A[order], B[order], C[order], 4, 0), abs=1e-12)
+
+
+@pytest.mark.parametrize("seed", [0, 53])
+def test_ksg_paths_are_affine_and_process_permutation_covariant(seed):
+ """The shared conditioning contract reaches MI, TLMI, AIS, TE and DI.
+
+ Valid continuous coordinates are tested under positive/negative affine
+ marginal transformations and process relabelling.
+ """
+ from pyspi.statistics.infotheory import (
+ DirectedInfo, MutualInfo, TimeLaggedMutualInfo, TransferEntropy,
+ _ksg_ais,
+ )
+
+ rng = np.random.default_rng(seed)
+ n = 128
+ x = rng.standard_normal(n)
+ y = 0.5 * np.roll(x, 1) + rng.standard_normal(n)
+ other = rng.standard_normal(n)
+ Z = np.vstack([x, y, other])
+ scales = np.array([-3.0, 7.0, 0.25])[:, None]
+ offsets = np.array([2.0, -5.0, 9.0])[:, None]
+ order = [2, 0, 1]
+
+ spis = (
+ MutualInfo(estimator="kraskov"),
+ TimeLaggedMutualInfo(estimator="kraskov"),
+ TransferEntropy(estimator="kraskov", k_history=2, l_history=2),
+ DirectedInfo(estimator="kraskov", n=2),
+ )
+ base_data = Data(data=Z, dim_order="ps", zscore=False)
+ moved_data = Data(data=scales * Z + offsets, dim_order="ps", zscore=False)
+ permuted_data = Data(data=Z[order], dim_order="ps", zscore=False)
+ for spi in spis:
+ base = np.asarray(spi.multivariate(base_data), float)
+ moved = np.asarray(spi.multivariate(moved_data), float)
+ permuted = np.asarray(spi.multivariate(permuted_data), float)
+ assert np.allclose(base, moved, equal_nan=True, atol=1e-12,
+ rtol=0), spi.identifier
+ assert np.allclose(base[np.ix_(order, order)], permuted,
+ equal_nan=True, atol=1e-12, rtol=0), spi.identifier
+
+ assert _ksg_ais(y, 2, 1, 4) == pytest.approx(
+ _ksg_ais(-3.0 * y + 2.0, 2, 1, 4), abs=1e-12)
diff --git a/tests/test_execution_parity.py b/tests/test_execution_parity.py
new file mode 100644
index 00000000..cc078f4c
--- /dev/null
+++ b/tests/test_execution_parity.py
@@ -0,0 +1,131 @@
+"""Serial and parallel must agree on more than the numbers.
+
+``test_parallel.py`` already pins numerical parity. What is unpinned is
+*failure* parity: whether an exception, a warning, a wrong-shaped return, or a
+non-finite value is reported the same way in both paths, and whether the
+failure survives into a structured, inspectable place rather than only a
+transient ``warnings.warn``.
+
+These began as red tests and are now green: both paths route through
+``_parallel.run_spi``, failures land in ``calc.errors``, worker-side warnings
+are returned to the parent and re-emitted there, and ``calc.run_spec`` records
+the resolved run.
+
+The misbehaving SPIs live in ``tests/failing_spis.py``; ``tests/`` is put on
+PYTHONPATH so spawned workers can import them too.
+"""
+import os
+import sys
+import warnings
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from pyspi.calculator import Calculator
+
+CONFIG = str(Path(__file__).parent / "parity_failure_config.yaml")
+TESTS_DIR = str(Path(__file__).parent)
+
+MODES = [("serial", 1), ("parallel", 2)]
+
+
+@pytest.fixture(autouse=True)
+def _tests_on_path(monkeypatch):
+ """Make tests/ importable here and in spawned workers."""
+ monkeypatch.syspath_prepend(TESTS_DIR)
+ existing = os.environ.get("PYTHONPATH", "")
+ monkeypatch.setenv(
+ "PYTHONPATH", TESTS_DIR + (os.pathsep + existing if existing else "")
+ )
+
+
+def _dataset():
+ rng = np.random.default_rng(0)
+ return rng.standard_normal((3, 120))
+
+
+def _run(n_jobs):
+ calc = Calculator(dataset=_dataset(), config=CONFIG, zscore=False, verbose=False)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ calc.compute(n_jobs=n_jobs, mp_context="spawn", progress=False)
+ return calc, [str(w.message) for w in caught]
+
+
+# --------------------------------------------------------------------------
+# Structured error reporting
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("mode,n_jobs", MODES)
+def test_failures_are_recorded_structurally(mode, n_jobs):
+ """A failed SPI must be inspectable after the run, not just warned about."""
+ calc, _ = _run(n_jobs)
+
+ errors = getattr(calc, "errors", None)
+ assert errors is not None, "Calculator exposes no `errors` mapping."
+ assert "always_raises" in errors, (
+ f"[{mode}] the failing SPI is absent from calc.errors: {sorted(errors)}"
+ )
+ assert "deliberate test failure" in str(errors["always_raises"])
+
+
+def test_serial_and_parallel_report_the_same_failures():
+ serial, _ = _run(1)
+ parallel, _ = _run(2)
+
+ s_err = set(getattr(serial, "errors", {}) or {})
+ p_err = set(getattr(parallel, "errors", {}) or {})
+ assert s_err == p_err, (
+ f"Failure sets diverge: serial-only={s_err - p_err}, parallel-only={p_err - s_err}"
+ )
+
+
+def test_warnings_survive_the_parallel_boundary():
+ """A warning raised inside an SPI must reach the caller in both paths."""
+ _, serial_warns = _run(1)
+ _, parallel_warns = _run(2)
+
+ assert any("deliberate test warning" in w for w in serial_warns), (
+ "Precondition failed: serial path did not surface the SPI's warning."
+ )
+ assert any("deliberate test warning" in w for w in parallel_warns), (
+ "Warning raised in a worker never reached the parent process."
+ )
+
+
+# --------------------------------------------------------------------------
+# Output validation
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("mode,n_jobs", MODES)
+def test_wrong_shape_is_a_recorded_failure(mode, n_jobs):
+ calc, _ = _run(n_jobs)
+ errors = getattr(calc, "errors", {}) or {}
+ assert "wrong_shape" in errors, (
+ f"[{mode}] an SPI returning a (2,5) matrix for a 3-process dataset was "
+ "not recorded as a failure."
+ )
+
+
+@pytest.mark.parametrize("mode,n_jobs", MODES)
+def test_non_finite_output_is_flagged(mode, n_jobs):
+ calc, _ = _run(n_jobs)
+ errors = getattr(calc, "errors", {}) or {}
+ assert "non_finite" in errors, (
+ f"[{mode}] an SPI returning +inf was not flagged."
+ )
+
+
+# --------------------------------------------------------------------------
+# Config snapshot
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("mode,n_jobs", MODES)
+def test_run_records_its_resolved_specification(mode, n_jobs):
+ """One canonical, immutable description of what was actually run."""
+ calc, _ = _run(n_jobs)
+ spec = getattr(calc, "run_spec", None)
+ assert spec is not None, "Calculator records no resolved run specification."
+ for field in ("config", "zscore", "detrend", "n_processes", "spi_identifiers"):
+ assert field in spec, f"[{mode}] run_spec is missing {field!r}."
diff --git a/tests/test_infotheory_analytic.py b/tests/test_infotheory_analytic.py
new file mode 100644
index 00000000..cf412b8e
--- /dev/null
+++ b/tests/test_infotheory_analytic.py
@@ -0,0 +1,811 @@
+"""Closed-form correctness tests for the pure-NumPy information-theoretic estimators.
+
+Every assertion here compares a computed number against a value known
+*independently* of the implementation (an analytic formula for the generating
+process, or an information-theoretic inequality). This is the only place in the
+suite where a ``return np.zeros(...)`` stub would be caught: the rest of the
+tests check shape/finiteness or compare against a baseline generated by the same
+code.
+
+LOG BASE: every estimator in the module reports **nats**. The kernel and
+symbolic calculators used to report bits, inherited from JIDT's split between
+base 2 for its box-kernel/discrete estimators and base e for its Gaussian and
+k-nearest-neighbour ones. `_expected` and `BITS_ESTIMATORS` survive as the
+place a future base-2 estimator would have to declare itself, rather than
+silently landing on an axis differing by a factor of ln 2.
+
+Sampling: the Gaussian fixtures are whitened so the *sample* covariance equals
+the target covariance exactly. Expectations are therefore computed from the
+known rho of the generating process, never from ``np.corrcoef`` of the sample —
+comparing against the sample correlation would reduce the Gaussian test to an
+implementation identity that cannot detect a wrong rho or a wrong log base.
+Whitening also removes the O(1/sqrt(N)) sampling noise in rho, which is what
+lets the Gaussian tolerance be 1e-7 rather than 1e-2. Not tighter:
+the Gaussian path ridge-regularises the covariance at 1e-8 relative
+(the deterministic analogue of JIDT's NOISE_LEVEL_TO_ADD), so identities
+assembled from several entropies cannot cancel below that order. The
+previous 1e-9 passed only because the vectorised multivariate path
+skipped the ridge, which is exactly the inconsistency that made
+bivariate() and multivariate() disagree by 8.4 nats on singular data.
+"""
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+from pyspi.statistics.infotheory import (
+ CausalEntropy,
+ ConditionalEntropy,
+ CrossmapEntropy,
+ DirectedInfo,
+ GaussianEntropyCalculator,
+ JointEntropy,
+ KLEntropyCalculator,
+ KernelEntropyCalculator,
+ MutualInfo,
+ StochasticInteraction,
+ TimeLaggedMutualInfo,
+ TransferEntropy,
+)
+
+# Every estimator in the module reports nats; see InfoTheoryBase.
+BITS_ESTIMATORS = frozenset()
+
+N_LARGE = 20000
+N_SMALL = 3000
+
+# Per-estimator absolute tolerances against the analytic MI, in that
+# estimator's own units. Gaussian is a closed form on an exactly-specified
+# covariance, so it is held to round-off. The kNN and box-kernel estimators
+# carry genuine O(1e-2) finite-sample bias at N=20000 (KSG k=4; box kernel at
+# fixed bandwidth 0.25) — these are estimator properties, not slack chosen to
+# make the test pass; the measured errors are ~0.009.
+# 1e-7, not 1e-9: the Gaussian MI path now goes through the same ridge as
+# the entropy path, which biases it by ridge*r^2/(1-r^2) ~ 5.6e-9 at
+# rho=0.6. That is the price of the two paths agreeing at r -> 1, where
+# they used to differ by 8.4 nats.
+MI_ATOL = {"gaussian": 1e-7, "kraskov": 0.02, "kernel": 0.02, "kozachenko": 0.02}
+
+_ESTIMATOR_KWARGS = {"kraskov": {"prop_k": 4}, "kernel": {"kernel_width": 0.25}}
+
+# kozachenko is deliberately absent: MutualInfo and TimeLaggedMutualInfo are
+# estimated directly rather than from marginal entropies, and there is no
+# Kozachenko-Leonenko path for them. The constructor now raises, so the
+# combination is rejected rather than tested -- see
+# test_smoke.test_kozachenko_rejected_for_non_entropy.
+MI_ESTIMATORS = [
+ "gaussian",
+ "kraskov",
+ "kernel",
+ pytest.param("_removed_kozachenko", marks=pytest.mark.skip(
+ reason="rejected at construction; see test_kozachenko_rejected_for_non_entropy",
+ )),
+]
+
+
+def _expected(value_in_nats, estimator):
+ """Analytic values need no conversion: the whole module reports nats.
+
+ Kept as a function, and `BITS_ESTIMATORS` kept as an (empty) set, so that
+ reintroducing a base-2 estimator has an obvious place to declare itself
+ rather than silently landing on an axis differing by ln 2.
+ """
+ return value_in_nats / np.log(2) if estimator in BITS_ESTIMATORS else value_in_nats
+
+
+def _spi(cls, estimator, **kwargs):
+ return cls(estimator=estimator, **_ESTIMATOR_KWARGS.get(estimator, {}), **kwargs)
+
+
+def _exact_gaussian_pair(rho, n=N_LARGE, seed=0):
+ """Bivariate normal whose *sample* covariance is exactly [[1, rho], [rho, 1]]."""
+ rng = np.random.default_rng(seed)
+ Z = rng.standard_normal((2, n))
+ Z = Z - Z.mean(axis=1, keepdims=True)
+ Z = np.linalg.inv(np.linalg.cholesky(np.cov(Z, ddof=1))) @ Z
+ return np.linalg.cholesky(np.array([[1.0, rho], [rho, 1.0]])) @ Z
+
+
+def _ar1_with_drive(a, b, sigma=1.0, n=N_LARGE, seed=0):
+ """y_t = a*y_{t-1} + b*x_{t-1} + sigma*eps_t, with x iid N(0, 1)."""
+ rng = np.random.default_rng(seed)
+ x = rng.standard_normal(n)
+ eps = rng.standard_normal(n)
+ y = np.zeros(n)
+ for t in range(1, n):
+ y[t] = a * y[t - 1] + b * x[t - 1] + sigma * eps[t]
+ return np.vstack([x, y])
+
+
+def _coupled_pair(coupling, n=N_SMALL, seed=0):
+ """X -> Y at lag 1 with strength ``coupling`` (0 gives independent series)."""
+ rng = np.random.default_rng(seed)
+ x = rng.standard_normal(n)
+ y = np.zeros(n)
+ for t in range(1, n):
+ y[t] = 0.5 * y[t - 1] + coupling * x[t - 1] + rng.standard_normal()
+ return np.vstack([x, y])
+
+
+# ---------------------------------------------------------------------------
+# Mutual information against the exact bivariate-Gaussian value
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("estimator", MI_ESTIMATORS)
+@pytest.mark.parametrize("rho", [0.0, 0.3, 0.6, 0.9])
+def test_bivariate_gaussian_mi(estimator, rho):
+ """MI of a bivariate normal is exactly -0.5*ln(1 - rho^2) nats."""
+ data = Data(_exact_gaussian_pair(rho), zscore=False)
+ result = _spi(MutualInfo, estimator).multivariate(data)
+
+ assert result.shape == (2, 2)
+ assert np.all(np.isnan(np.diag(result))), "diagonal must be NaN"
+
+ expected = _expected(-0.5 * np.log(1.0 - rho ** 2), estimator)
+ assert result[0, 1] == pytest.approx(result[1, 0], abs=1e-12), "MI must be symmetric"
+ assert result[0, 1] == pytest.approx(expected, abs=MI_ATOL[estimator]), (
+ f"{estimator} MI at rho={rho}: got {result[0, 1]}, expected {expected}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Differential entropy of N(0, sigma^2)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("sigma", [1.0, 2.0])
+@pytest.mark.parametrize("estimator", ["gaussian", "kozachenko", "kernel"])
+def test_gaussian_differential_entropy(estimator, sigma):
+ """H[N(0, sigma^2)] = 0.5*ln(2*pi*e*sigma^2) nats.
+
+ Exercises the entropy calculators directly rather than through a SPI,
+ because Data() z-scores by default and would destroy sigma.
+ """
+ rng = np.random.default_rng(7)
+ x = sigma * rng.standard_normal(N_LARGE)
+
+ calc = {
+ "gaussian": GaussianEntropyCalculator,
+ "kozachenko": KLEntropyCalculator,
+ "kernel": KernelEntropyCalculator,
+ }[estimator]()
+ calc.initialise(1)
+ if estimator == "kernel":
+ calc.setProperty("KERNEL_WIDTH", "0.25")
+ calc.setObservations(x)
+ got = calc.computeAverageLocalOfObservations()
+
+ expected = _expected(0.5 * np.log(2 * np.pi * np.e * sigma ** 2), estimator)
+ # Absolute, in the estimator's own units. All three are consistent (mean
+ # error -> 0 with N) but noisy at finite N: the Kozachenko-Leonenko k=1
+ # estimator has sd ~0.013 nats at N=20000, measured over 15 seeds, and the
+ # box kernel carries a small bandwidth-dependent bias. 0.05 is ~4 sd, and
+ # still 14x tighter than ln(2)=0.693 — the smallest error a wrong log base
+ # or a dropped normalisation constant could produce.
+ assert got == pytest.approx(expected, abs=0.05), (
+ f"{estimator} H[N(0,{sigma}^2)]: got {got}, expected {expected}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Gaussian transfer entropy on a known AR(1)-with-drive process
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("a, b", [(0.5, 0.6), (0.2, 0.9), (0.7, 0.3)])
+def test_gaussian_te_ar1_analytic(a, b):
+ """TE(X->Y) = 0.5*ln(var_reduced / var_full) for y_t = a y_{t-1} + b x_{t-1} + eps.
+
+ x is iid and independent of y_{t-1}, so conditioning on y_{t-1} alone leaves
+ residual variance b^2 var(x) + sigma^2, while conditioning on (y_{t-1},
+ x_{t-1}) leaves sigma^2. TE(Y->X) is 0 because x has no history at all.
+ """
+ sigma = 1.0
+ data = Data(_ar1_with_drive(a, b, sigma), zscore=True)
+ result = TransferEntropy(estimator="gaussian").multivariate(data)
+
+ expected = 0.5 * np.log((b ** 2 * 1.0 + sigma ** 2) / sigma ** 2)
+ assert result[0, 1] == pytest.approx(expected, abs=0.01), (
+ f"TE(X->Y): got {result[0, 1]}, expected {expected}"
+ )
+ assert abs(result[1, 0]) < 0.01, f"TE(Y->X) should vanish, got {result[1, 0]}"
+
+
+# ---------------------------------------------------------------------------
+# Independence: everything must collapse to ~0
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("cls", [MutualInfo, TimeLaggedMutualInfo, TransferEntropy])
+@pytest.mark.parametrize("estimator", ["gaussian", "kraskov", "kernel"])
+def test_independent_series_give_near_zero(cls, estimator):
+ """X independent of Y => MI, TLMI and TE are ~0 for every estimator.
+
+ This is the assertion a constant-returning stub cannot survive; `isfinite`
+ checks elsewhere in the suite would pass one.
+
+ The box-kernel TE is the one exception: at fixed bandwidth in the 3-D
+ (y_next, y_past, x_past) space it carries a large positive finite-sample
+ bias (~0.06 nats at N=10000, decaying with N). That is an estimator
+ property, documented rather than hidden — the bias is still an order of
+ magnitude below the coupled value, which is what
+ ``test_coupling_increases_directed_measures`` checks.
+ """
+ data = Data(_coupled_pair(0.0, n=10000), zscore=True)
+ result = _spi(cls, estimator).multivariate(data)
+ off_diagonal = result[~np.isnan(result)]
+
+ limit = 0.15 if (cls is TransferEntropy and estimator == "kernel") else 0.05
+ assert np.max(np.abs(off_diagonal)) < limit, (
+ f"{cls.__name__}/{estimator} on independent series: {result}"
+ )
+
+
+def test_symbolic_te_independent_series():
+ """Symbolic TE (histogram-based) also collapses on independent data."""
+ data = Data(_coupled_pair(0.0, n=10000), zscore=True)
+ result = TransferEntropy(estimator="symbolic", k_history=3).multivariate(data)
+ assert np.max(np.abs(result[~np.isnan(result)])) < 0.05, result
+
+
+# ---------------------------------------------------------------------------
+# Information-theoretic identities and inequalities
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("estimator", ["gaussian", "kraskov", "kernel"])
+def test_nonnegativity_of_mi_and_te(estimator):
+ """MI >= 0 and TE >= 0 on real data (small negative estimator noise allowed)."""
+ data = Data(_coupled_pair(0.8), zscore=True)
+ for cls in (MutualInfo, TimeLaggedMutualInfo, TransferEntropy):
+ result = _spi(cls, estimator).multivariate(data)
+ worst = np.nanmin(result)
+ assert worst > -0.05, f"{cls.__name__}/{estimator} strongly negative: {worst}"
+
+
+# Tolerance for the entropy-derived MI identity, in each estimator's units.
+# gaussian is a closed form (exact). The others are held to a bias/variance
+# budget measured over seeds: KL k=1 marginal+joint errors compound to ~0.04
+# nats at N=20000; the box kernel to ~0.02 nats.
+ENTROPY_MI_ATOL = {"gaussian": 1e-7, "kozachenko": 0.08, "kernel": 0.05}
+
+
+@pytest.mark.parametrize("estimator", ["gaussian", "kozachenko", "kernel"])
+@pytest.mark.parametrize("rho", [0.0, 0.6])
+def test_joint_entropy_subadditivity(estimator, rho):
+ """H(X) + H(Y) - H(X,Y) must equal the analytic MI, hence be >= 0.
+
+ Marginals are recovered from the public API alone:
+ ``ConditionalEntropy[i, j] = H(X_i, X_j) - H(X_i)``, so ``H(X_i) = JE - CE[i, j]``.
+ This is the only test that pins JointEntropy and ConditionalEntropy to a
+ closed form; everything else only checks that they are finite.
+
+ NOTE: the textbook bound H(X,Y) >= max(H(X), H(Y)) is a *discrete*-entropy
+ property and is false for differential entropy — H(Y|X) = 0.5*ln(2*pi*e*
+ (1-r^2)) goes negative for |r| large — so it is asserted only at rho=0,
+ where it does hold. Subadditivity (equivalently MI >= 0) is always true and
+ is asserted at both rho.
+ """
+ data = Data(_exact_gaussian_pair(rho), zscore=False)
+ je = _spi(JointEntropy, estimator).multivariate(data)[0, 1]
+ ce = _spi(ConditionalEntropy, estimator).multivariate(data)
+ h_x = je - ce[0, 1]
+ h_y = je - ce[1, 0]
+ mi_from_entropies = h_x + h_y - je
+
+ expected = _expected(-0.5 * np.log(1.0 - rho ** 2), estimator)
+ atol = ENTROPY_MI_ATOL[estimator]
+ assert mi_from_entropies == pytest.approx(expected, abs=atol), (
+ f"{estimator} at rho={rho}: H(X)+H(Y)-H(X,Y)={mi_from_entropies}, "
+ f"expected {expected} (H(X)={h_x}, H(Y)={h_y}, H(X,Y)={je})"
+ )
+ assert mi_from_entropies > -atol, "subadditivity violated beyond estimator noise"
+ if rho == 0.0:
+ assert je >= max(h_x, h_y) - atol, (
+ f"{estimator}: H(X,Y)={je} below max marginal {max(h_x, h_y)}"
+ )
+
+
+def test_mutual_info_chain_rule_gaussian():
+ """I(X;Y) = H(X) - H(X|Y), cross-checking MutualInfo against JE/CE.
+
+ Using the identity above for the marginals this becomes
+ ``MI = JE - CE[i, j] - CE[j, i]``. Restricted to the gaussian estimator: it
+ is the only one where MI and the entropies come from the same closed form,
+ so the identity must hold to round-off. The kernel MI uses a different
+ (count-ratio) estimator than the kernel entropies, so no exact identity is
+ expected there.
+ """
+ data = Data(_exact_gaussian_pair(0.6), zscore=False)
+ mi = MutualInfo(estimator="gaussian").multivariate(data)[0, 1]
+ je = JointEntropy(estimator="gaussian").multivariate(data)[0, 1]
+ ce = ConditionalEntropy(estimator="gaussian").multivariate(data)
+
+ assert mi == pytest.approx(je - ce[0, 1] - ce[1, 0], abs=1e-7)
+ assert mi == pytest.approx(-0.5 * np.log(1 - 0.6 ** 2), abs=1e-7)
+
+
+# ---------------------------------------------------------------------------
+# The five SPI classes that the rest of the suite instantiates but never computes
+# ---------------------------------------------------------------------------
+
+# (class, kwargs, sign) — sign is +1 when coupling should *increase* the
+# statistic and -1 when it should decrease it. CrossmapEntropy and CausalEntropy
+# are conditional entropies: coupling makes the target more predictable, so they
+# go DOWN. Asserting "coupled > independent" for them would be backwards.
+NEVER_COMPUTED = [
+ (TimeLaggedMutualInfo, {}, +1),
+ (CrossmapEntropy, {"history_length": 3}, -1),
+ (CausalEntropy, {"n": 3}, -1),
+ (DirectedInfo, {"n": 3}, +1),
+ (StochasticInteraction, {}, +1),
+]
+
+
+@pytest.mark.parametrize("cls, kwargs, sign",
+ NEVER_COMPUTED,
+ ids=[c.__name__ for c, _, _ in NEVER_COMPUTED])
+@pytest.mark.parametrize("estimator", ["gaussian", "kozachenko", "kernel"])
+def test_coupling_increases_directed_measures(cls, kwargs, sign, estimator):
+ """Each measure must separate a coupled pair from an independent one."""
+ # TimeLaggedMutualInfo is estimated directly, not from marginal entropies,
+ # so it has no Kozachenko-Leonenko path and rejects the estimator at
+ # construction (pinned in test_smoke).
+ if estimator == "kozachenko" and cls.__name__ == "TimeLaggedMutualInfo":
+ pytest.skip("rejected at construction; see test_kozachenko_rejected_for_non_entropy")
+
+ coupled = _spi(cls, estimator, **kwargs).multivariate(
+ Data(_coupled_pair(0.8, seed=0), zscore=True))
+ independent = _spi(cls, estimator, **kwargs).multivariate(
+ Data(_coupled_pair(0.0, seed=0), zscore=True))
+
+ assert np.isfinite(coupled[0, 1]) and np.isfinite(independent[0, 1]), (
+ f"{cls.__name__}/{estimator} returned non-finite values"
+ )
+ delta = sign * (coupled[0, 1] - independent[0, 1])
+ assert delta > 0.05, (
+ f"{cls.__name__}/{estimator}: coupling did not move the statistic in the "
+ f"expected direction (coupled={coupled[0, 1]}, "
+ f"independent={independent[0, 1]}, expected sign={sign:+d})"
+ )
+
+
+def test_gaussian_singularity_policy_is_shared_by_every_path():
+ """One ridge, reachable from either direction, on exactly singular input.
+
+ Gaussian MI used to clip r^2 at 1 - 1e-15 while the entropy path ridged the
+ covariance at 1e-8 relative. On a pair of identical N=100 series the direct
+ MI was 17.2698 nats and the same quantity assembled from entropies was
+ 8.8638 -- a factor of two apart, from two regularisation policies in one
+ module. Both now return the ridge's bound, -0.5*log(1 - 1/(1+1e-8)^2).
+ """
+ from pyspi.statistics.infotheory import GAUSSIAN_RIDGE
+
+ rng = np.random.default_rng(0)
+ x = rng.standard_normal(100)
+ data = Data(data=np.vstack([x, x.copy()]), dim_order="ps", zscore=False)
+
+ mi = MutualInfo(estimator="gaussian")
+ je = JointEntropy(estimator="gaussian")
+
+ bound = -0.5 * np.log(1 - 1 / (1 + GAUSSIAN_RIDGE) ** 2)
+ direct = mi.multivariate(data)[0, 1]
+ composed = (mi._compute_entropy(data, i=0) + mi._compute_entropy(data, i=1)
+ - je.multivariate(data)[0, 1])
+
+ assert direct == pytest.approx(bound, rel=1e-12)
+ assert composed == pytest.approx(bound, abs=1e-7)
+ assert je.bivariate(data, i=0, j=1) == pytest.approx(
+ je.multivariate(data)[0, 1], abs=1e-7)
+
+
+def test_gaussian_ridge_is_equivariant_to_per_variable_rescaling():
+ """The ridge is proportional to each variable's own variance.
+
+ An isotropic ridge eps = 1e-8 * mean(diag(Sigma)) makes the regularisation
+ of a quiet variable depend on the units of a loud one, so `zscore=False`
+ results move when an unrelated process is rescaled.
+ """
+ rng = np.random.default_rng(3)
+ Z = rng.standard_normal((3, 500))
+ Z[1] += 0.7 * Z[0]
+ Z[2] += 0.5 * Z[1]
+
+ mi = MutualInfo(estimator="gaussian")
+ base = mi.multivariate(Data(data=Z, dim_order="ps", zscore=False))
+ Z_scaled = Z.copy()
+ Z_scaled[0] *= 1e6
+ scaled = mi.multivariate(Data(data=Z_scaled, dim_order="ps", zscore=False))
+ assert np.allclose(base, scaled, atol=1e-12, equal_nan=True)
+
+
+# ---------------------------------------------------------------------------
+# Transfer-entropy auto-embedding
+# ---------------------------------------------------------------------------
+
+def _ols_residual_variance(y, X):
+ """Residual variance of an OLS fit with an intercept, computed directly."""
+ design = np.column_stack([np.ones(len(y)), X]) if X.size else np.ones((len(y), 1))
+ beta, *_ = np.linalg.lstsq(design, y, rcond=None)
+ resid = y - design @ beta
+ return float(resid @ resid / len(y))
+
+
+def _lagged(series, dim, delay, end):
+ """Columns [x_{t}, x_{t-delay}, ..., x_{t-(dim-1)delay}] ending at `end`."""
+ start = (dim - 1) * delay
+ return np.column_stack([series[start - i * delay: end - i * delay]
+ for i in range(dim)])
+
+
+def _ols_ais(series, dim, delay):
+ """Bias-corrected Gaussian AIS from OLS residual variances.
+
+ Independent of pyspi's log-determinant machinery: AIS is
+ 0.5*log(var(Y) / var(Y | Y_past)), and each variance comes from an explicit
+ least-squares fit. The k/(2N) correction is the mean of the chi-squared null
+ of the in-sample estimate, the same term JIDT subtracts.
+ """
+ T = len(series)
+ start, end = (dim - 1) * delay, T - 1
+ if start >= end:
+ return -np.inf
+ future = series[start + 1: end + 1]
+ past = _lagged(series, dim, delay, end)
+ n = future.size
+ return 0.5 * np.log(_ols_residual_variance(future, np.empty((n, 0)))
+ / _ols_residual_variance(future, past)) - dim / (2.0 * n)
+
+
+def test_gaussian_ais_matches_an_ols_residual_variance_oracle():
+ """Same quantity, assembled from least squares instead of log-determinants."""
+ from pyspi.statistics.infotheory import _gaussian_ais
+
+ rng = np.random.default_rng(0)
+ T = 800
+ x = np.zeros(T)
+ for t in range(2, T):
+ x[t] = 0.6 * x[t - 1] - 0.3 * x[t - 2] + rng.standard_normal()
+
+ for dim in (1, 2, 3, 5):
+ for delay in (1, 2):
+ assert _gaussian_ais(x, dim, delay) == pytest.approx(
+ _ols_ais(x, dim, delay), abs=1e-7), (dim, delay)
+
+
+def test_embedding_search_is_a_brute_force_argmax_of_the_scorer():
+ """Selection, separately from the criterion it maximises.
+
+ Ties go to the smaller dimension and then the smaller delay -- the AIS
+ objective plateaus often enough at finite sample that this matters. No
+ assertion here that a particular AR order is recovered: it need not be.
+ """
+ from pyspi.statistics.infotheory import _select_embedding
+
+ rng = np.random.default_rng(1)
+ series = rng.standard_normal(400)
+ scores = {}
+
+ def scorer(_series, dim, delay):
+ scores[(dim, delay)] = float(rng.standard_normal())
+ return scores[(dim, delay)]
+
+ dim, delay, score = _select_embedding(series, scorer, 5, 3)
+ assert (dim, delay) == max(scores, key=lambda kd: (scores[kd], -kd[0], -kd[1]))
+ assert score == scores[(dim, delay)]
+
+ flat = _select_embedding(series, lambda *_: 1.0, 5, 3)
+ assert flat[:2] == (1, 1), "a flat objective must not drift to the maximum"
+
+
+def test_max_corr_ais_selects_source_and_destination_independently():
+ """All four selected values must reach the estimator.
+
+ `MAX_CORR_AIS` searched the destination only and hard-coded the source
+ embedding to (1, 1), while its name denotes selection for both. An injected
+ scorer that deliberately prefers different embeddings for the two series
+ makes the difference observable.
+ """
+ import pyspi.statistics.infotheory as it
+
+ rng = np.random.default_rng(2)
+ src = rng.standard_normal(600)
+ targ = rng.standard_normal(600)
+ data = Data(data=np.vstack([src, targ]), dim_order="ps", zscore=False)
+
+ # Scores the source's preferred embedding at (3, 2) and the destination's
+ # at (2, 1); the two series are told apart by their first sample.
+ def injected(_estimator, _k_nn=None, _w=0):
+ def score(series, dim, delay):
+ want = (3, 2) if series[0] == pytest.approx(data.to_numpy(
+ squeeze=True)[0][0]) else (2, 1)
+ return 1.0 if (dim, delay) == want else 0.0
+ return score
+
+ captured = {}
+ real = it._gaussian_te_bivariate
+
+ def spy(s, t, k_history, k_tau, l_history, l_tau):
+ captured.update(k_history=k_history, k_tau=k_tau,
+ l_history=l_history, l_tau=l_tau)
+ return real(s, t, k_history, k_tau, l_history, l_tau)
+
+ spi = it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS",
+ k_search_max=4, tau_search_max=3)
+ spi._embedding_scorer = staticmethod(injected)
+ it._gaussian_te_bivariate = spy
+ try:
+ spi.bivariate(data, i=0, j=1)
+ finally:
+ it._gaussian_te_bivariate = real
+
+ assert captured == {"k_history": 2, "k_tau": 1, "l_history": 3, "l_tau": 2}
+
+
+def test_max_corr_ais_dest_only_keeps_the_supplied_source_embedding():
+ import pyspi.statistics.infotheory as it
+
+ rng = np.random.default_rng(3)
+ data = Data(data=rng.standard_normal((2, 400)), dim_order="ps", zscore=False)
+
+ captured = {}
+ real = it._gaussian_te_bivariate
+
+ def spy(s, t, k_history, k_tau, l_history, l_tau):
+ captured.update(l_history=l_history, l_tau=l_tau)
+ return real(s, t, k_history, k_tau, l_history, l_tau)
+
+ spi = it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS_DEST_ONLY",
+ k_search_max=4, tau_search_max=2,
+ l_history=3, l_tau=2)
+ it._gaussian_te_bivariate = spy
+ try:
+ spi.bivariate(data, i=0, j=1)
+ finally:
+ it._gaussian_te_bivariate = real
+ assert captured == {"l_history": 3, "l_tau": 2}
+
+
+def _te_design(src, targ, k, k_tau, l, l_tau):
+ """Independent transcription of `_te_build_embeddings`' declared alignment.
+
+ lookback = max((k-1)*k_tau, (l-1)*l_tau); for each t in [lookback, T-1):
+
+ target future targ[t+1]
+ target past targ[t], targ[t - k_tau], ... (k columns, lag 0 first)
+ source past src[t], src[t - l_tau], ... (l columns, lag 0 first)
+
+ The source past starts at lag **0**, contemporaneous with the newest target
+ history sample. An earlier version of this oracle used lags 1..l and a
+ lookback of `l*l_tau`, i.e. source columns one sample older throughout. It
+ still passed at rel=2e-3, because on a smoothly autocorrelated source the
+ two lag sets carry nearly the same predictive information -- which is
+ exactly why the fixture below is chosen to make the difference large.
+ """
+ T = len(src)
+ lookback = max((k - 1) * k_tau, (l - 1) * l_tau)
+ end = T - 1
+ t = np.arange(lookback, end)
+ future = targ[t + 1]
+ t_past = np.column_stack([targ[t - i * k_tau] for i in range(k)])
+ s_past = np.column_stack([src[t - i * l_tau] for i in range(l)])
+ return future, t_past, s_past
+
+
+def test_gaussian_transfer_entropy_matches_an_ols_oracle_at_a_fixed_embedding():
+ """TE is the log ratio of two residual variances, so OLS gives it directly.
+
+ The fixture is deliberately discriminative: the source is white noise, so
+ src[t] and src[t-1] are independent, and the target is driven by src[t-1]
+ only. Using lags 1..l instead of the declared 0..l-1 therefore moves TE by a
+ large factor rather than a rounding, and the tolerance can be tight.
+
+ The residual is the Gaussian ridge -- `_gaussian_log_det` adds 1e-8 of each
+ variable's own variance -- which is why the comparison is at 1e-6 relative
+ rather than machine precision.
+ """
+ from pyspi.statistics.infotheory import _gaussian_te_bivariate
+
+ rng = np.random.default_rng(4)
+ T = 4000
+ s = rng.standard_normal(T) # white: src[t] tells nothing of src[t-1]
+ t_ = np.zeros(T)
+ for n in range(2, T):
+ t_[n] = 0.4 * t_[n - 1] + 0.9 * s[n - 1] + 0.3 * rng.standard_normal()
+
+ k, k_tau, l, l_tau = 2, 1, 2, 1
+ future, t_past, s_past = _te_design(s, t_, k, k_tau, l, l_tau)
+ expected = 0.5 * np.log(
+ _ols_residual_variance(future, t_past)
+ / _ols_residual_variance(future, np.column_stack([t_past, s_past])))
+
+ got = _gaussian_te_bivariate(s, t_, k, k_tau, l, l_tau)
+ assert got == pytest.approx(expected, rel=1e-6), f"{got} vs {expected}"
+
+ # The misaligned design the previous oracle used, kept as a live
+ # demonstration that this fixture can tell them apart.
+ t = np.arange(max((k - 1) * k_tau, l * l_tau), T - 1)
+ shifted = np.column_stack([s[t - i * l_tau] for i in range(1, l + 1)])
+ misaligned = 0.5 * np.log(
+ _ols_residual_variance(t_[t + 1], np.column_stack([t_[t - i] for i in range(k)]))
+ / _ols_residual_variance(t_[t + 1], np.column_stack(
+ [np.column_stack([t_[t - i] for i in range(k)]), shifted])))
+ assert abs(misaligned - expected) > 0.2 * abs(expected), (
+ f"fixture is not discriminative: aligned {expected:.4f} vs "
+ f"misaligned {misaligned:.4f}")
+
+
+def _brute_force_ksg_mi_general(A, B, k, w=0):
+ """O(N^2) multivariate KSG estimator 1, written from the paper.
+
+ Marginals may have any number of columns, so this can score an AIS
+ candidate `MI(Y_future; Y_past_embedding)` directly. Shares no code with
+ the cKDTree implementation it checks.
+ """
+ from scipy.special import digamma
+
+ A = np.atleast_2d(np.asarray(A, float))
+ B = np.atleast_2d(np.asarray(B, float))
+ if A.shape[0] == 1 and A.shape[1] != B.shape[0]:
+ A = A.T
+ n = A.shape[0]
+
+ def chebyshev(M):
+ return np.abs(M[:, None, :] - M[None, :, :]).max(axis=-1)
+
+ dA, dB = chebyshev(A), chebyshev(B)
+ dJ = np.maximum(dA, dB)
+ allowed = np.abs(np.arange(n)[:, None] - np.arange(n)) > w
+ dJ[~allowed] = np.inf
+ eps = np.sort(dJ, axis=1)[:, k - 1]
+ n_a = ((dA < eps[:, None]) & allowed).sum(axis=1)
+ n_b = ((dB < eps[:, None]) & allowed).sum(axis=1)
+ return float(digamma(k) + digamma(n)
+ - np.mean(digamma(n_a + 1) + digamma(n_b + 1)))
+
+
+def _brute_force_ksg_cmi(A, B, C, k, w=0):
+ """O(N^2) Frenzel-Pompe CMI with exact strict comparisons."""
+ from scipy.special import digamma
+
+ A = np.atleast_2d(np.asarray(A, float))
+ B = np.atleast_2d(np.asarray(B, float))
+ C = np.atleast_2d(np.asarray(C, float))
+ n = A.shape[0]
+
+ def chebyshev(M):
+ return np.abs(M[:, None, :] - M[None, :, :]).max(axis=-1)
+
+ d_joint = chebyshev(np.column_stack([A, B, C]))
+ d_ac = chebyshev(np.column_stack([A, C]))
+ d_bc = chebyshev(np.column_stack([B, C]))
+ d_c = chebyshev(C)
+ allowed = np.abs(np.arange(n)[:, None] - np.arange(n)) > w
+ d_joint[~allowed] = np.inf
+ eps = np.sort(d_joint, axis=1)[:, k - 1]
+ n_ac = ((d_ac < eps[:, None]) & allowed).sum(axis=1)
+ n_bc = ((d_bc < eps[:, None]) & allowed).sum(axis=1)
+ n_c = ((d_c < eps[:, None]) & allowed).sum(axis=1)
+ return float(digamma(k) + np.mean(
+ digamma(n_c + 1) - digamma(n_ac + 1) - digamma(n_bc + 1)
+ ))
+
+
+@pytest.mark.parametrize("near_deterministic", [False, True])
+@pytest.mark.parametrize("w", [0, 2])
+def test_general_ksg_mi_matches_exact_pairwise_strict_counts(
+ w, near_deterministic):
+ from pyspi.statistics.infotheory import _knn_condition, _ksg_mi_general
+
+ rng = np.random.default_rng(0)
+ A = rng.standard_normal((24, 2))
+ if near_deterministic:
+ B = A[:, :1] + 1e-10 * rng.standard_normal((24, 1))
+ else:
+ B = rng.standard_normal((24, 1))
+ conditioned = _knn_condition(np.column_stack([A, B]))
+ A_c, B_c = conditioned[:, :2], conditioned[:, 2:]
+ expected = _brute_force_ksg_mi_general(A_c, B_c, 1, w)
+ assert _ksg_mi_general(A, B, 1, w) == pytest.approx(expected, abs=1e-12)
+
+
+@pytest.mark.parametrize("near_deterministic", [False, True])
+@pytest.mark.parametrize("w", [0, 2])
+def test_ksg_cmi_matches_exact_pairwise_strict_counts(w, near_deterministic):
+ from pyspi.statistics.infotheory import _knn_condition, _ksg_cmi
+
+ rng = np.random.default_rng(0)
+ A = rng.standard_normal((24, 2))
+ if near_deterministic:
+ B = A[:, :1] + 1e-10 * rng.standard_normal((24, 1))
+ else:
+ B = rng.standard_normal((24, 1))
+ C = rng.standard_normal((24, 2))
+ conditioned = _knn_condition(np.column_stack([A, B, C]))
+ A_c, B_c, C_c = conditioned[:, :2], conditioned[:, 2:3], conditioned[:, 3:]
+ expected = _brute_force_ksg_cmi(A_c, B_c, C_c, 1, w)
+ assert _ksg_cmi(A, B, C, 1, w) == pytest.approx(expected, abs=1e-12)
+
+
+@pytest.mark.parametrize("dim,delay", [(1, 1), (2, 1), (3, 2), (4, 1)])
+def test_ksg_ais_matches_an_independent_multivariate_reference(dim, delay):
+ """The AIS candidate score, against a brute-force multivariate KSG.
+
+ Continuous and tie-free, so both implementations see the same unambiguous
+ neighbour geometry.
+ """
+ from pyspi.statistics.infotheory import _knn_condition, _ksg_ais
+
+ rng = np.random.default_rng(11)
+ T = 260
+ x = rng.standard_normal(T)
+ for t in range(2, T):
+ x[t] = 0.6 * x[t - 1] - 0.3 * x[t - 2] + rng.standard_normal()
+
+ start, end = (dim - 1) * delay, T - 1
+ future = x[start + 1:end + 1].reshape(-1, 1)
+ past = np.column_stack([x[start - i * delay:end - i * delay]
+ for i in range(dim)])
+ conditioned = _knn_condition(np.column_stack([future, past]))
+ expected = _brute_force_ksg_mi_general(conditioned[:, :1], conditioned[:, 1:], 4)
+
+ assert _ksg_ais(x, dim, delay, 4, 0) == pytest.approx(expected, abs=1e-12)
+
+
+def test_ksg_embedding_selection_matches_the_reference_scores():
+ """The winner is the argmax of independently computed candidate scores.
+
+ No assertion that a particular AR order is recovered -- the finite-sample
+ AIS objective plateaus, and asserting an order would pin luck.
+ """
+ from pyspi.statistics.infotheory import (_ais_scorer, _knn_condition,
+ _select_embedding)
+
+ rng = np.random.default_rng(12)
+ T = 300
+ x = rng.standard_normal(T)
+ for t in range(3, T):
+ x[t] = 0.5 * x[t - 1] + 0.4 * x[t - 3] + rng.standard_normal()
+
+ scores = {}
+ for dim in range(1, 5):
+ for delay in range(1, 3):
+ start, end = (dim - 1) * delay, T - 1
+ future = x[start + 1:end + 1].reshape(-1, 1)
+ past = np.column_stack([x[start - i * delay:end - i * delay]
+ for i in range(dim)])
+ c = _knn_condition(np.column_stack([future, past]))
+ scores[(dim, delay)] = _brute_force_ksg_mi_general(c[:, :1], c[:, 1:], 4)
+
+ dim, delay, _ = _select_embedding(x, _ais_scorer("kraskov", 4, 0), 4, 2)
+ assert (dim, delay) == max(scores, key=lambda kd: (scores[kd], -kd[0], -kd[1]))
+
+
+def test_ais_scorer_rejects_a_candidate_that_leaves_too_few_samples():
+ """`T - 1 - (dim-1)*delay`, not `T - (dim-1)*delay`.
+
+ `_ksg_ais` aligns a one-step-ahead future against the embedding, so it
+ spends a sample on the shift as well as on the lookback. With the
+ off-by-one, T=5 / kNN=4 / dimension 1 passed the guard on a claimed N=5
+ while the aligned arrays have N=4 -- and `_ksg_mi_general` had no guard of
+ its own, so `tree.query(..., k=5)` padded with infinities and the candidate
+ scored a finite 0.0.
+ """
+ from pyspi.statistics.infotheory import _ais_scorer, _ksg_mi_general
+
+ rng = np.random.default_rng(13)
+ series = rng.standard_normal(5)
+ assert _ais_scorer("kraskov", 4, 0)(series, 1, 1) == -np.inf
+
+ # The general path refuses the same input on its own account.
+ with pytest.raises(ValueError, match="usable neighbour"):
+ _ksg_mi_general(rng.standard_normal((4, 1)), rng.standard_normal((4, 1)),
+ 4, 0)
+
+
+def test_embedding_selection_raises_when_no_candidate_is_scorable():
+ """Not a silent fall back to (1, 1) -- that is an embedding the scorer just
+ rejected, and the caller would get a number instead of the reason."""
+ from pyspi.statistics.infotheory import _ais_scorer, _select_embedding
+
+ rng = np.random.default_rng(14)
+ with pytest.raises(ValueError, match="can be scored"):
+ _select_embedding(rng.standard_normal(5), _ais_scorer("kraskov", 4, 0),
+ 3, 2)
diff --git a/tests/test_low_data_stress.py b/tests/test_low_data_stress.py
new file mode 100644
index 00000000..4b85d0ea
--- /dev/null
+++ b/tests/test_low_data_stress.py
@@ -0,0 +1,565 @@
+"""Representative SPIs against small, deliberately awkward fixtures.
+
+The contract this file enforces:
+
+ A statistic either produces a defensible result, or fails explicitly for a
+ documented reason. It must not silently produce a misleading value.
+
+Every fixture is M=3 at T=64 and T=256, from two fixed seeds -- short enough
+that the estimators are working near their limits, which is where the failures
+this branch fixed all lived, and small enough that the whole file runs in
+seconds. Each fixture is routed to the SPIs it can actually say something
+about; nothing here runs the full config.
+
+What is asserted, by kind:
+
+* **Analytic or independent references** where one exists (Gaussian MI from
+ rho, continuous KSG references, a known lag).
+* **Structure**: symmetry, antisymmetry, orientation, permutation covariance.
+* **Invariance** where it is mathematically required (per-coordinate rescaling
+ for KSG, affine rescaling for the causal scores).
+* **Serial/parallel equality.**
+* **Direction only for identifiable synthetic models** -- and only where the
+ estimator claims identifiability, which CDS and IGCI do not.
+* **Explicit rejection** on input a statistic cannot support, rather than a
+ number.
+"""
+import re
+
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+
+T_SHORT, T_LONG = 64, 256
+SEEDS = (0, 7)
+M = 3
+
+
+# ---------------------------------------------------------------------------
+# Fixtures: (M, T) arrays, deterministic in (seed, T)
+# ---------------------------------------------------------------------------
+
+def _independent_gaussian(rng, T):
+ return rng.standard_normal((M, T))
+
+
+def _correlated_gaussian(rng, T, rho=0.7):
+ z = rng.standard_normal(T)
+ return np.vstack([z,
+ rho * z + np.sqrt(1 - rho ** 2) * rng.standard_normal(T),
+ rng.standard_normal(T)])
+
+
+def _coupled_var(rng, T):
+ """Process 0 -> 1 at lag 1, 1 -> 2 at lag 1; no feedback."""
+ X = np.zeros((M, T))
+ for t in range(1, T):
+ X[0, t] = 0.5 * X[0, t - 1] + rng.standard_normal()
+ X[1, t] = 0.3 * X[1, t - 1] + 0.8 * X[0, t - 1] + rng.standard_normal()
+ X[2, t] = 0.3 * X[2, t - 1] + 0.8 * X[1, t - 1] + rng.standard_normal()
+ return X
+
+
+def _nonlinear_coupled(rng, T):
+ x = rng.standard_normal(T)
+ return np.vstack([x, np.tanh(2 * x) + 0.3 * rng.standard_normal(T),
+ rng.standard_normal(T)])
+
+
+def _quantised(rng, T, levels=2):
+ return rng.integers(0, levels, (M, T)).astype(float)
+
+
+def _delayed_oscillation(rng, T, lag=4):
+ t = np.arange(T + lag)
+ base = np.sin(2 * np.pi * t / 9.0) + 0.3 * rng.standard_normal(T + lag)
+ return np.vstack([base[lag:], base[:-lag] + 0.2 * rng.standard_normal(T),
+ rng.standard_normal(T)])
+
+
+def _heavy_tailed(rng, T):
+ X = rng.standard_t(df=2, size=(M, T))
+ X[0, T // 3] = 40.0 # deterministic outliers, not drawn
+ X[1, 2 * T // 3] = -35.0
+ return X
+
+
+def _duplicate_and_collinear(rng, T):
+ x = rng.standard_normal(T)
+ return np.vstack([x, x.copy(), 3.0 * x + 1e-9 * rng.standard_normal(T)])
+
+
+FIXTURES = {
+ "independent_gaussian": _independent_gaussian,
+ "correlated_gaussian": _correlated_gaussian,
+ "coupled_var": _coupled_var,
+ "nonlinear_coupled": _nonlinear_coupled,
+ "binary": lambda rng, T: _quantised(rng, T, 2),
+ "four_level": lambda rng, T: _quantised(rng, T, 4),
+ "delayed_oscillation": _delayed_oscillation,
+ "heavy_tailed": _heavy_tailed,
+ "duplicate_and_collinear": _duplicate_and_collinear,
+}
+
+ALL_CASES = [(name, seed, T) for name in FIXTURES for seed in SEEDS
+ for T in (T_SHORT, T_LONG)]
+
+
+def make(name, seed, T, **data_kwargs):
+ return Data(data=FIXTURES[name](np.random.default_rng(seed), T),
+ dim_order="ps", **data_kwargs)
+
+
+def _off(matrix):
+ return np.asarray(matrix, float)[~np.eye(M, dtype=bool)]
+
+
+# ---------------------------------------------------------------------------
+# The low-data contract: a number or an explicit refusal, never a silent lie
+# ---------------------------------------------------------------------------
+
+def _representative_spis():
+ """(label, factory) -- one per family the audit touched, not 322 SPIs."""
+ import pyspi.statistics.basic as basic
+ import pyspi.statistics.causal as causal
+ import pyspi.statistics.distance as distance
+ import pyspi.statistics.infotheory as it
+ import pyspi.statistics.spectral as spectral
+
+ return [
+ ("mi_gaussian", lambda: it.MutualInfo(estimator="gaussian")),
+ ("mi_kraskov", lambda: it.MutualInfo(estimator="kraskov")),
+ ("tlmi_gaussian", lambda: it.TimeLaggedMutualInfo(estimator="gaussian")),
+ ("tlmi_kraskov", lambda: it.TimeLaggedMutualInfo(estimator="kraskov")),
+ ("te_gaussian_fixed", lambda: it.TransferEntropy(estimator="gaussian")),
+ ("te_kraskov_fixed", lambda: it.TransferEntropy(estimator="kraskov")),
+ ("te_gaussian_auto",
+ lambda: it.TransferEntropy(estimator="gaussian",
+ auto_embed_method="MAX_CORR_AIS",
+ k_search_max=3, tau_search_max=2)),
+ ("te_kraskov_auto",
+ lambda: it.TransferEntropy(estimator="kraskov",
+ auto_embed_method="MAX_CORR_AIS",
+ k_search_max=3, tau_search_max=2)),
+ ("di_gaussian", lambda: it.DirectedInfo(estimator="gaussian", n=3)),
+ ("di_kraskov", lambda: it.DirectedInfo(estimator="kraskov", n=3)),
+ ("xcorr_max_sig", lambda: basic.CrossCorrelation(statistic="max",
+ sigonly=True)),
+ ("xcorr_mean", lambda: basic.CrossCorrelation(statistic="mean",
+ sigonly=False)),
+ ("gd_delay", lambda: spectral.GroupDelay(statistic="delay", fmin=0,
+ fmax=0.5)),
+ ("dcoh_mean", lambda: spectral.DirectedCoherence(statistic="mean",
+ fmin=0, fmax=0.5)),
+ ("anm", causal.AdditiveNoiseModel),
+ ("cds", causal.ConditionalDistributionSimilarity),
+ ("reci", causal.RegressionErrorCausalInference),
+ ("igci", causal.InformationGeometricCausalInference),
+ ("pdist", lambda: distance.PairwiseDistance(metric="euclidean")),
+ ("xpdist", lambda: distance.CrossPairwiseDistance(tau=2)),
+ ]
+
+
+# Frozen observations, not claims that these input families must always make
+# GroupDelay empty. On precisely these deterministic fixtures the significance
+# gate keeps no frequency cluster. Pinning the full case makes any future change
+# visible instead of treating every sample from the family as guaranteed-empty.
+OBSERVED_EMPTY_CASES = {
+ ("gd_delay", name, seed, T)
+ for name in ("independent_gaussian", "binary", "four_level", "heavy_tailed")
+ for seed in SEEDS
+ for T in (T_SHORT, T_LONG)
+}
+
+# (SPI label, fixture, seed, T) -> (exception class, message regex).
+# DirectedCoherence may explicitly fail its spectral factorisation on these
+# singular duplicate-process cases (the backend's random fallback sometimes
+# converges, so success is also accepted). No other refusal is accepted.
+EXPECTED_REFUSALS = {
+ ("dcoh_mean", "duplicate_and_collinear", seed, T):
+ (np.linalg.LinAlgError, r"^Singular matrix$")
+ for seed in SEEDS
+ for T in (T_SHORT, T_LONG)
+}
+EXPECTED_REFUSALS.update({
+ (label, fixture, seed, T):
+ (ValueError, r"^KSG requires continuous, tie-free coordinates:")
+ for label in ("mi_kraskov", "tlmi_kraskov", "te_kraskov_fixed",
+ "te_kraskov_auto", "di_kraskov")
+ for fixture in ("binary", "four_level")
+ for seed in SEEDS
+ for T in (T_SHORT, T_LONG)
+})
+
+
+@pytest.mark.parametrize("name,seed,T", ALL_CASES)
+def test_representative_spis_give_a_result_or_an_explicit_refusal(name, seed, T):
+ """No unexplained exception, infinity, or entirely empty output.
+
+ A refusal is accepted only when its exact case, exception type and message
+ pattern appear in `EXPECTED_REFUSALS`. An arbitrary nonempty exception is
+ not evidence that the estimator declined for the intended reason.
+ """
+ import warnings
+
+ data = make(name, seed, T)
+ problems = []
+ for label, factory in _representative_spis():
+ try:
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ table = np.asarray(factory().multivariate(data), dtype=float)
+ except Exception as err: # noqa: BLE001
+ expected = EXPECTED_REFUSALS.get((label, name, seed, T))
+ if expected is None:
+ problems.append(f"{label}: {type(err).__name__}: {err}")
+ else:
+ error_type, message = expected
+ if type(err) is not error_type or re.search(message, str(err)) is None:
+ problems.append(
+ f"{label}: expected {error_type.__name__} /{message}/, "
+ f"got {type(err).__name__}: {err}"
+ )
+ continue
+
+ off = _off(table)
+ if np.isinf(off).any():
+ problems.append(f"{label}: infinite values")
+ elif not np.isfinite(off).any():
+ if (label, name, seed, T) not in OBSERVED_EMPTY_CASES:
+ problems.append(f"{label}: no finite off-diagonal value")
+ elif (label, name, seed, T) in OBSERVED_EMPTY_CASES:
+ problems.append(f"{label}: frozen empty observation now produced a "
+ f"value; review the observation table")
+ assert not problems, f"[{name} seed={seed} T={T}]\n " + "\n ".join(problems)
+
+
+# ---------------------------------------------------------------------------
+# Analytic and independent references
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_gaussian_mi_tracks_the_sample_correlation_on_short_records(seed):
+ """-0.5*log(1 - r^2) from the sample r, so this is exact at any T."""
+ import pyspi.statistics.infotheory as it
+
+ for T in (T_SHORT, T_LONG):
+ data = make("correlated_gaussian", seed, T)
+ Z = data.to_numpy(squeeze=True)
+ table = it.MutualInfo(estimator="gaussian").multivariate(data)
+ for i in range(M):
+ for j in range(i + 1, M):
+ r = np.corrcoef(Z[i], Z[j])[0, 1]
+ assert table[i, j] == pytest.approx(
+ -0.5 * np.log(1 - r ** 2), abs=1e-6), (T, i, j)
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+@pytest.mark.parametrize("levels", [2, 4])
+def test_ksg_mi_explicitly_refuses_a_duplicated_quantised_process(seed, levels):
+ """Tied coordinates require an external discrete estimator, not jitter."""
+ import pyspi.statistics.infotheory as it
+
+ rng = np.random.default_rng(seed)
+ x = rng.integers(0, levels, T_LONG).astype(float)
+ data = Data(data=np.vstack([x, x.copy(), rng.standard_normal(T_LONG)]),
+ dim_order="ps", zscore=False)
+ with pytest.raises(ValueError, match="continuous, tie-free coordinates"):
+ it.MutualInfo(estimator="kraskov").bivariate(data, i=0, j=1)
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_group_delay_recovers_the_oscillation_lag(seed):
+ """A known lag on a genuinely narrowband signal, at T=256.
+
+ On the T=64 instances used here the significance gate keeps no cluster;
+ that is a fixture observation covered by the contract test, not a theorem
+ that short records must return NaN.
+ """
+ from pyspi.statistics.spectral import GroupDelay
+
+ data = make("delayed_oscillation", seed, T_LONG)
+ table = GroupDelay(statistic="delay", fmin=0, fmax=0.5).multivariate(data)
+ assert table[0, 1] == pytest.approx(4, abs=1.0)
+ assert table[1, 0] == pytest.approx(-table[0, 1], rel=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# Structure: symmetry, antisymmetry, orientation, permutation
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("name,seed,T", ALL_CASES)
+def test_declared_structure_holds_on_every_fixture(name, seed, T):
+ """Symmetric statistics are symmetric; antisymmetric ones antisymmetric."""
+ import warnings
+
+ import pyspi.statistics.basic as basic
+ import pyspi.statistics.distance as distance
+ import pyspi.statistics.infotheory as it
+ from pyspi.statistics.causal import InformationGeometricCausalInference
+
+ symmetric = [("mi_gaussian", it.MutualInfo(estimator="gaussian")),
+ ("xcorr_mean", basic.CrossCorrelation(statistic="mean",
+ sigonly=False)),
+ ("pdist", distance.PairwiseDistance(metric="euclidean")),
+ ("xpdist", distance.CrossPairwiseDistance(tau=2))]
+ antisymmetric = [("igci", InformationGeometricCausalInference())]
+
+ data = make(name, seed, T)
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ for label, spi in symmetric:
+ A = np.asarray(spi.multivariate(data), float)
+ assert np.allclose(A, A.T, equal_nan=True, rtol=1e-9), f"{label}"
+ for label, spi in antisymmetric:
+ A = np.asarray(spi.multivariate(data), float)
+ assert np.allclose(A, -A.T, equal_nan=True, rtol=1e-9), f"{label}"
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_permuting_processes_permutes_the_matrix(seed):
+ """Nothing may depend on the order the processes were handed over."""
+ import warnings
+
+ import pyspi.statistics.causal as causal
+ import pyspi.statistics.infotheory as it
+
+ order = [2, 0, 1]
+ for T in (T_SHORT, T_LONG):
+ Z = FIXTURES["coupled_var"](np.random.default_rng(seed), T)
+ for spi in (it.TransferEntropy(estimator="gaussian"),
+ it.MutualInfo(estimator="kraskov"),
+ causal.RegressionErrorCausalInference()):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ base = np.asarray(spi.multivariate(
+ Data(data=Z, dim_order="ps")), float)
+ moved = np.asarray(spi.multivariate(
+ Data(data=Z[order], dim_order="ps")), float)
+ assert np.allclose(base[np.ix_(order, order)], moved,
+ equal_nan=True, rtol=1e-9), (T, spi.identifier)
+
+
+# ---------------------------------------------------------------------------
+# Invariance where it is mathematically required
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("name,seed,T", [
+ case for case in ALL_CASES if case[0] not in {"binary", "four_level"}
+])
+def test_ksg_measures_are_invariant_to_per_process_rescaling(name, seed, T):
+ """Per-coordinate standardisation provides affine marginal covariance.
+
+ Exact duplicate processes are excluded from contemporaneous MI: their
+ joint law is supported on a diagonal, has no two-dimensional density, and
+ has infinite continuous MI. A finite-sample KSG value on that singular
+ pair has no finite affine-invariance target. The fixture's near-collinear
+ third process remains covered.
+ """
+ import warnings
+
+ import pyspi.statistics.infotheory as it
+
+ scale = np.array([1e-3, 1.0, 1e3])[:, None]
+ Z = FIXTURES[name](np.random.default_rng(seed), T)
+ for spi in (it.MutualInfo(estimator="kraskov"),
+ it.TimeLaggedMutualInfo(estimator="kraskov")):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ base = spi.multivariate(Data(data=Z, dim_order="ps", zscore=False))
+ moved = spi.multivariate(Data(data=Z * scale, dim_order="ps",
+ zscore=False))
+ valid = ~np.eye(M, dtype=bool)
+ if spi.identifier.startswith("mi_"):
+ for i in range(M):
+ for j in range(i):
+ if np.array_equal(Z[i], Z[j]):
+ valid[i, j] = valid[j, i] = False
+ assert np.allclose(np.asarray(base, float)[valid],
+ np.asarray(moved, float)[valid],
+ equal_nan=True, atol=1e-12), spi.identifier
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_causal_scores_are_invariant_to_affine_rescaling(seed):
+ """CDS, RECI and IGCI scale their inputs internally, so units must not matter.
+
+ ANM is deliberately excluded because this implementation is scale-
+ dependent: scikit-learn's default `GaussianProcessRegressor` uses a fixed
+ unit ConstantKernel*RBF kernel when no kernel is supplied (both bounds are
+ "fixed"). The fit sees raw values while the later independence score
+ standardises its arguments. This is a pyspi/CDT implementation choice, not
+ a scale-dependence theorem about additive-noise models. pyspi's default
+ z-scoring removes the material dependence in the shipped pipeline.
+ """
+ import pyspi.statistics.causal as causal
+
+ for T in (T_SHORT, T_LONG):
+ Z = FIXTURES["nonlinear_coupled"](np.random.default_rng(seed), T)
+ moved = 4.0 * Z + 3.0
+ for spi in (causal.ConditionalDistributionSimilarity(),
+ causal.RegressionErrorCausalInference(),
+ causal.InformationGeometricCausalInference()):
+ a = np.asarray(spi.multivariate(Data(data=Z, dim_order="ps",
+ zscore=False)), float)
+ b = np.asarray(spi.multivariate(Data(data=moved, dim_order="ps",
+ zscore=False)), float)
+ assert np.allclose(a, b, equal_nan=True, atol=1e-12), spi.identifier
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_anm_is_scale_dependent_and_z_scoring_is_what_fixes_it(seed):
+ """Recorded, not asserted away.
+
+ A future change that made ANM scale-free would fail this test, which is the
+ point: the property should change deliberately, not drift.
+ """
+ import pyspi.statistics.causal as causal
+
+ Z = FIXTURES["nonlinear_coupled"](np.random.default_rng(seed), T_LONG)
+ anm = causal.AdditiveNoiseModel()
+ raw = np.asarray(anm.multivariate(Data(data=Z, dim_order="ps",
+ zscore=False)), float)
+ moved = np.asarray(anm.multivariate(Data(data=4.0 * Z + 3.0, dim_order="ps",
+ zscore=False)), float)
+ assert np.nanmax(np.abs(raw - moved)) > 0.01, "ANM became scale-free"
+
+ # With pyspi's default z-scoring the difference collapses from ~0.27 to
+ # ~4e-5. Not to zero: z-scoring `Z` and `4Z + 3` agrees only to float
+ # precision. The point is the four orders of magnitude, which makes the
+ # implementation's scale dependence irrelevant in the default pipeline.
+ a = np.asarray(anm.multivariate(Data(data=Z, dim_order="ps")), float)
+ b = np.asarray(anm.multivariate(Data(data=4.0 * Z + 3.0, dim_order="ps")),
+ float)
+ assert np.nanmax(np.abs(a - b)) < 1e-3
+
+
+# ---------------------------------------------------------------------------
+# Direction, only for identifiable models
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_transfer_entropy_finds_the_coupling_direction_at_the_longer_length(seed):
+ """0 -> 1 -> 2 with no feedback, so TE must be larger in the true direction.
+
+ Asserted at T=256 only. At T=64 the estimators are within their own noise on
+ a coupling this weak, and asserting direction there would pin luck rather
+ than behaviour -- the contract test above still requires a defensible number.
+ """
+ import pyspi.statistics.infotheory as it
+
+ data = make("coupled_var", seed, T_LONG)
+ for spi in (it.TransferEntropy(estimator="gaussian"),
+ it.TransferEntropy(estimator="kraskov")):
+ table = np.asarray(spi.multivariate(data), float)
+ assert table[0, 1] > table[1, 0], spi.identifier
+ assert table[1, 2] > table[2, 1], spi.identifier
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_anm_prefers_the_true_direction_on_the_nonlinear_fixture(seed):
+ """ANM is identifiable for a nonlinear map with independent additive noise.
+
+ RECI is **not** asserted here, and that is a finding rather than an
+ omission: on this saturating `tanh` pair it prefers the wrong direction at
+ both seeds (0.0225 vs 0.0087, 0.0155 vs 0.0095). On the cubic pair in
+ `tests/test_pairwise_causal.py` it gets the direction right. RECI is a
+ regression-error heuristic whose
+ published argument needs assumptions about the input distribution,
+ mechanism and regression; it does not impose a simple "near-uniform cause"
+ precondition. CDS and IGCI are absent for the same reason -- this fixture
+ supplies no general direction oracle for them.
+ """
+ import pyspi.statistics.causal as causal
+
+ data = make("nonlinear_coupled", seed, T_LONG)
+ table = np.asarray(causal.AdditiveNoiseModel().multivariate(data), float)
+ # Lower score = more independent residual, so the true direction is lower.
+ assert table[0, 1] < table[1, 0]
+
+
+# ---------------------------------------------------------------------------
+# Explicit refusal, and serial/parallel equality
+# ---------------------------------------------------------------------------
+
+def test_short_records_are_refused_with_a_reason_not_a_number():
+ """The estimators must say why, not return something plausible.
+
+ T=8 with kNN=4 leaves fewer usable neighbours than k once the embedding is
+ aligned; the auto-embedding search has no scorable candidate at all.
+ """
+ import pyspi.statistics.infotheory as it
+
+ # T=5 is the boundary: the aligned sample count is T - 1 - (dim-1)*delay,
+ # so dimension 1 leaves 4 points and 3 usable neighbours against k=4. T=6
+ # already succeeds, which is why the fixture is this small.
+ tiny = Data(data=np.random.default_rng(0).standard_normal((3, 5)),
+ dim_order="ps")
+ with pytest.raises(ValueError, match="usable neighbour|can be scored"):
+ it.TransferEntropy(estimator="kraskov", auto_embed_method="MAX_CORR_AIS",
+ k_search_max=3, tau_search_max=2).bivariate(
+ tiny, i=0, j=1)
+
+
+def test_constant_process_is_refused_by_the_ksg_estimators():
+ """A constant marginal carries no information and is almost always a broken
+ input for a continuous-density estimator."""
+ import pyspi.statistics.infotheory as it
+
+ Z = np.random.default_rng(0).standard_normal((2, 128))
+ Z[1] = 3.0
+ data = Data(data=Z, dim_order="ps", zscore=False)
+ with pytest.raises(ValueError, match="constant"):
+ it.MutualInfo(estimator="kraskov").bivariate(data, i=0, j=1)
+
+
+@pytest.mark.parametrize("name", ["heavy_tailed", "coupled_var"])
+def test_serial_and_parallel_agree_on_the_awkward_fixtures(name, tmp_path):
+ """Bit-for-bit, on exactly the inputs where the estimators branch."""
+ from pyspi.calculator import Calculator
+
+ config = tmp_path / "stress.yaml"
+ config.write_text(
+ ".statistics.infotheory:\n"
+ " MutualInfo:\n labels: [x]\n configs:\n"
+ " - {estimator: kraskov, prop_k: 4}\n"
+ " - {estimator: gaussian}\n"
+ " DirectedInfo:\n labels: [x]\n configs:\n"
+ " - {estimator: kraskov, prop_k: 4, n: 3}\n"
+ ".statistics.basic:\n"
+ " CrossCorrelation:\n labels: [x]\n configs:\n"
+ " - {statistic: max, sigonly: true}\n"
+ )
+ Z = FIXTURES[name](np.random.default_rng(0), T_LONG)
+
+ tables = []
+ for kwargs in ({"n_jobs": 1}, {"n_jobs": 2, "mp_context": "spawn"}):
+ calc = Calculator(dataset=Data(data=Z, dim_order="ps"),
+ config=str(config))
+ calc.compute(**kwargs)
+ assert not calc.errors, calc.errors
+ tables.append({k: calc.table[k].to_numpy() for k in calc.spis})
+ for key in tables[0]:
+ assert np.array_equal(tables[0][key], tables[1][key], equal_nan=True), key
+
+
+@pytest.mark.slow
+@pytest.mark.parametrize("seed", SEEDS)
+def test_convergent_cross_mapping_on_the_coupled_fixtures(seed):
+ """CCM is the one genuinely expensive representative, so it is marked slow.
+
+ One fixed embedding and one automatic, checked for a defensible bounded
+ result rather than a direction: CCM's convergence criterion needs far more
+ than 256 samples to separate coupling from shared driving.
+ """
+ from pyspi.statistics.causal import ConvergentCrossMapping
+
+ data = make("coupled_var", seed, T_LONG)
+ for spi in (ConvergentCrossMapping(statistic="mean", embedding_dimension=2),
+ ConvergentCrossMapping(statistic="mean",
+ embedding_dimension=None)):
+ table = np.asarray(spi.multivariate(data), float)
+ off = _off(table)
+ assert np.isfinite(off).all(), spi.identifier
+ assert np.abs(off).max() <= 1.0 + 1e-9, spi.identifier
diff --git a/tests/test_pairwise_causal.py b/tests/test_pairwise_causal.py
new file mode 100644
index 00000000..4e2515aa
--- /dev/null
+++ b/tests/test_pairwise_causal.py
@@ -0,0 +1,242 @@
+"""The four pairwise causal scores, after cdt and torch were removed.
+
+pyspi used exactly four functions from `cdt.causality.pairwise`: the ANM
+independence score, the conditional distribution similarity statistic, the RECI
+regression-error score and IGCI. They are now transcribed in
+`pyspi/lib/pairwise_causal.py`.
+
+Three kinds of evidence here, deliberately separated:
+
+* **Independent formulas.** Each score recomputed from its definition with
+ different machinery -- explicit double sums for HSIC, `lstsq` for RECI, a
+ direct order-statistic sum for the IGCI entropy. These would catch a
+ transcription error that the differential fixture below cannot, because they
+ do not descend from cdt's code at all.
+* **Invariants and known directions.** Scale/translation behaviour, reverse-pair
+ antisymmetry, determinism, and synthetic pairs whose causal direction is known
+ by construction.
+* **A frozen cdt 0.6 fixture**, `tests/data/fixtures/cdt_pairwise_reference.npz`,
+ recorded from cdt before the dependency was dropped. Regression evidence, not
+ ground truth: it pins that removing cdt changed nothing, and it is the reason
+ no baseline moved.
+"""
+import os
+
+import numpy as np
+import pytest
+
+from pyspi.lib.pairwise_causal import (cds_score, igci_score, normalized_hsic,
+ reci_score)
+
+FIXTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "data", "fixtures", "cdt_pairwise_reference.npz")
+
+
+@pytest.fixture(scope="module")
+def cdt_reference():
+ """[(name, x, y, {score: value_from_cdt_0_6})]"""
+ with np.load(FIXTURE, allow_pickle=False) as archive:
+ names = [str(n) for n in archive["names"]]
+ lengths = archive["lengths"]
+ xs = np.split(archive["x"], np.cumsum(lengths)[:-1])
+ ys = np.split(archive["y"], np.cumsum(lengths)[:-1])
+ expected = {k: archive[k] for k in ("hsic", "cds", "reci", "igci")}
+ return [(names[i], xs[i], ys[i], {k: v[i] for k, v in expected.items()})
+ for i in range(len(names))]
+
+
+# ---------------------------------------------------------------------------
+# Differential regression against the dependency that was removed
+# ---------------------------------------------------------------------------
+
+def test_local_scores_reproduce_cdt_0_6_exactly(cdt_reference):
+ """Bit-identical, so no bundled SPI value changed when cdt was dropped.
+
+ Five fixtures spanning the input shapes the estimators behave differently
+ on: a nonlinear additive-noise pair, a random walk, a quantised source (CDS
+ takes its discrete branch there), an independent pair, and a short record.
+ """
+ for name, x, y, expected in cdt_reference:
+ X, Y = x.reshape(-1, 1), y.reshape(-1, 1)
+ for key, got in (("hsic", normalized_hsic(X, Y)),
+ ("cds", cds_score(X, Y)),
+ ("reci", reci_score(X, Y)),
+ ("igci", igci_score(X, Y))):
+ assert float(got) == pytest.approx(float(expected[key]), rel=1e-12,
+ abs=1e-15), f"{name}/{key}"
+
+
+# ---------------------------------------------------------------------------
+# Independent formulas
+# ---------------------------------------------------------------------------
+
+def test_hsic_matches_an_explicit_double_sum():
+ """HSIC = (1/m) * trace(Kc Lc) with Kc = HKH, written out elementwise.
+
+ Small enough that the maxpnt=200 subsampling does not engage, so the two
+ computations see the same points.
+ """
+ rng = np.random.default_rng(0)
+ n = 120
+ x = rng.standard_normal((n, 1))
+ y = np.tanh(x) + 0.4 * rng.standard_normal((n, 1))
+
+ xs = (x - x.mean()) / x.std()
+ ys = (y - y.mean()) / y.std()
+
+ def kernel(v):
+ sq = (v - v.T) ** 2
+ lower = (sq - np.tril(sq)).flatten()
+ width = np.sqrt(0.5 * np.median(lower[lower > 0]))
+ return np.exp(-sq / (2.0 * width ** 2))
+
+ K, L = kernel(xs), kernel(ys)
+ H = np.eye(n) - np.ones((n, n)) / n
+ expected = np.trace(H @ K @ H @ (H @ L @ H)) / n
+
+ assert normalized_hsic(x, y) == pytest.approx(expected, rel=1e-10)
+
+
+def test_reci_matches_an_explicit_least_squares_fit():
+ """Min-max scale both, fit y on [1, 0, 0, x^3..x^d], take the MSE.
+
+ The two zeroed columns are cdt's rendering of the paper's monomial
+ regressor: only the cubic-and-above terms carry any signal, alongside the
+ intercept that `LinearRegression` fits separately.
+ """
+ rng = np.random.default_rng(1)
+ n = 300
+ x = rng.standard_normal(n)
+ y = x ** 3 + 0.3 * rng.standard_normal(n)
+
+ scale = lambda v: (v - v.min()) / (v.max() - v.min())
+ xs, ys = scale(x), scale(y)
+ design = np.column_stack([np.ones(n), np.zeros(n), np.zeros(n), xs ** 3])
+ beta, *_ = np.linalg.lstsq(design, ys, rcond=None)
+ expected = float(np.mean((design @ beta - ys) ** 2))
+
+ assert reci_score(x, y, degree=3) == pytest.approx(expected, rel=1e-9)
+
+
+def test_igci_entropy_matches_a_direct_order_statistic_sum():
+ """h(x) = mean(log(gap)) + psi(n) - psi(1) over consecutive sorted values."""
+ from scipy.special import psi
+
+ rng = np.random.default_rng(2)
+ n = 400
+ x = rng.standard_normal(n)
+ y = np.exp(x)
+
+ def entropy(v):
+ gaps = np.diff(np.sort(v))
+ gaps = gaps[gaps != 0]
+ return np.sum(np.log(np.abs(gaps))) / (len(v) - 1) + psi(len(v)) - psi(1)
+
+ standard = lambda v: (v - v.mean()) / v.std()
+ expected = entropy(standard(x)) - entropy(standard(y))
+ assert igci_score(x, y) == pytest.approx(expected, rel=1e-12)
+
+
+def test_cds_prefers_the_cause_as_the_conditioning_variable_where_it_can():
+ """CDS bins on its first argument, and a lower score favours that direction.
+
+ Asserted on a saturating additive-noise pair, where it works: 8 of 8 seeds.
+ It is *not* asserted universally, because CDS does not hold universally --
+ it is one heuristic feature of the Jarfo model rather than a consistent
+ estimator, and on a cubic pair `y = x**3 + noise` the same comparison goes
+ the wrong way in 8 of 8 seeds. Pinning a universal direction here would
+ encode a property the statistic does not have.
+ """
+ rng = np.random.default_rng(6)
+ for _ in range(4):
+ x = rng.standard_normal(600)
+ y = np.tanh(2 * x) + 0.3 * rng.standard_normal(600)
+ X, Y = x.reshape(-1, 1), y.reshape(-1, 1)
+ assert cds_score(X, Y) < cds_score(Y, X)
+
+
+# ---------------------------------------------------------------------------
+# Invariants, known directions, determinism
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("score", [normalized_hsic, cds_score, reci_score,
+ igci_score])
+@pytest.mark.parametrize("a,b", [(3.0, 5.0), (0.25, -2.0)])
+def test_scores_are_invariant_to_positive_affine_rescaling(score, a, b):
+ """Each standardises or min-max scales its inputs first, so an affine change
+ of units must not move the score.
+
+ RECI and CDS scale to a fixed range, so they are invariant to `a` of either
+ sign; HSIC and IGCI standardise, which is invariant only up to the sign of
+ the scale. Only positive `a` is asserted, which is what all four share.
+ """
+ rng = np.random.default_rng(4)
+ n = 300
+ x = rng.standard_normal(n)
+ y = np.tanh(x) + 0.3 * rng.standard_normal(n)
+ scaled_a, scaled_b = abs(a) * x + b, abs(a) * y - b
+
+ base = float(score(x.reshape(-1, 1), y.reshape(-1, 1)))
+ moved = float(score(scaled_a.reshape(-1, 1), scaled_b.reshape(-1, 1)))
+ assert moved == pytest.approx(base, rel=1e-6, abs=1e-9)
+
+
+def test_igci_is_antisymmetric_in_its_arguments():
+ """It is a difference of two entropies, so reversing the pair negates it."""
+ rng = np.random.default_rng(5)
+ x = rng.standard_normal(400)
+ y = np.exp(x) + 0.1 * rng.standard_normal(400)
+ assert igci_score(x, y) == pytest.approx(-igci_score(y, x), rel=1e-12)
+
+
+def test_anm_and_reci_prefer_the_true_direction_on_a_nonlinear_pair():
+ """y = f(x) + noise with f nonlinear and the noise independent of x.
+
+ The additive-noise model is identifiable here, so the residual of a fit in
+ the true direction is independent of the cause (low HSIC) while the reverse
+ fit leaves a dependent residual. RECI's regression error is likewise smaller
+ in the true direction under the paper's scaling assumptions.
+ """
+ from sklearn.gaussian_process import GaussianProcessRegressor
+
+ rng = np.random.default_rng(6)
+ n = 300
+ x = rng.uniform(-2.5, 2.5, n)
+ y = x ** 3 + 0.5 * rng.standard_normal(n)
+ X, Y = x.reshape(-1, 1), y.reshape(-1, 1)
+
+ def anm(cause, effect):
+ gp = GaussianProcessRegressor(random_state=42).fit(cause, effect)
+ return normalized_hsic(gp.predict(cause).reshape(-1, 1) - effect, cause)
+
+ assert anm(X, Y) < anm(Y, X)
+ assert reci_score(X, Y) < reci_score(Y, X)
+
+
+@pytest.mark.parametrize("score", [normalized_hsic, cds_score, reci_score,
+ igci_score])
+def test_scores_are_deterministic(score):
+ rng = np.random.default_rng(7)
+ x = rng.standard_normal((250, 1))
+ y = (np.tanh(x) + 0.3 * rng.standard_normal((250, 1)))
+ assert float(score(x, y)) == float(score(x, y))
+
+
+def test_cdt_and_torch_are_not_imported_by_pyspi():
+ """The point of the exercise: neither is a dependency any more.
+
+ `cdt` eagerly imports its Torch-backed models at package load, so importing
+ it pulled in torch -- roughly 2 GB installed -- for four functions that
+ need neither.
+ """
+ import subprocess
+ import sys
+
+ result = subprocess.run(
+ [sys.executable, "-c",
+ "import sys, pyspi.calculator, pyspi.statistics.causal;"
+ "leaked = sorted(m for m in sys.modules if m.split('.')[0] in "
+ "{'cdt', 'torch'});"
+ "print(leaked)"],
+ capture_output=True, text=True, check=True)
+ assert result.stdout.strip() == "[]", result.stdout
diff --git a/tests/test_parallel.py b/tests/test_parallel.py
new file mode 100644
index 00000000..a06dc26c
--- /dev/null
+++ b/tests/test_parallel.py
@@ -0,0 +1,292 @@
+"""Tests for Calculator.compute() parallel path, checkpoint, and failure isolation.
+
+Uses a small handcrafted config (parallel_test_config.yaml) covering:
+ - covariance cache namespace (Covariance + Precision, multiple estimators)
+ - spectral_mv cache namespace (CoherenceMagnitude, multiple freq bands)
+ - ccm cache namespace (ConvergentCrossMapping) — exercises namespace bucketing
+ and the pyEDM call-site pinning path under the worker pool
+ - cacheless SPIs (SpearmanR, KendallTau, PowerEnvelopeCorrelation)
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pyspi.calculator import Calculator
+
+CONFIG = Path(__file__).parent / "parallel_test_config.yaml"
+
+# fork is the Linux production default; spawn is the only safe method elsewhere.
+MP_CONTEXTS = ["spawn"] + (["fork"] if sys.platform.startswith("linux") else [])
+
+
+@pytest.fixture(scope="module")
+def dataset():
+ rng = np.random.default_rng(0)
+ return rng.standard_normal((5, 300)).astype(np.float64)
+
+
+@pytest.fixture(scope="module")
+def serial_table(dataset):
+ calc = Calculator(dataset=dataset, config=str(CONFIG), zscore=False)
+ calc.compute(n_jobs=1, progress=False)
+ return calc.table.copy()
+
+
+@pytest.mark.parametrize("mp_context", MP_CONTEXTS)
+@pytest.mark.parametrize("n_jobs", [2, 3])
+def test_parallel_matches_serial(dataset, serial_table, n_jobs, mp_context):
+ """Parallel n_jobs>1 must produce numerically identical tables to serial,
+ for every start method and across all cache namespaces (incl. CCM)."""
+ calc = Calculator(dataset=dataset, config=str(CONFIG), zscore=False)
+ calc.compute(n_jobs=n_jobs, mp_context=mp_context, progress=False)
+ parallel_table = calc.table
+
+ assert list(parallel_table.columns) == list(serial_table.columns), \
+ "Column order/identity diverged between serial and parallel."
+
+ # Every SPI here is deterministic (CCM is seeded); tight tolerance.
+ np.testing.assert_allclose(
+ parallel_table.to_numpy(),
+ serial_table.to_numpy(),
+ rtol=1e-10, atol=1e-12, equal_nan=True,
+ err_msg=f"Parallel (n_jobs={n_jobs}, mp={mp_context}) diverged from serial.",
+ )
+
+
+def test_checkpoint_resume_matches_full_run(dataset, serial_table, tmp_path):
+ """Partial run -> delete some checkpoints -> resume; final table equals full serial."""
+ cp_dir = tmp_path / "ckpt"
+
+ # First pass: run serial with checkpoint_dir to populate .npy files.
+ calc1 = Calculator(dataset=dataset, config=str(CONFIG), zscore=False)
+ calc1.compute(n_jobs=1, checkpoint_dir=cp_dir, progress=False)
+ saved = sorted(cp_dir.glob("*.npy"))
+ assert len(saved) == len(calc1.spis), "Checkpoint dir missing files after first run."
+
+ # Simulate partial failure by removing half the checkpoints.
+ to_remove = saved[: len(saved) // 2]
+ for f in to_remove:
+ f.unlink()
+ assert len(list(cp_dir.glob("*.npy"))) < len(calc1.spis)
+
+ # Second pass: parallel resume. Should re-compute only the removed SPIs.
+ calc2 = Calculator(dataset=dataset, config=str(CONFIG), zscore=False)
+ calc2.compute(n_jobs=2, checkpoint_dir=cp_dir, resume=True,
+ mp_context="spawn", progress=False)
+
+ assert list(calc2.table.columns) == list(serial_table.columns)
+ np.testing.assert_allclose(
+ calc2.table.to_numpy(),
+ serial_table.to_numpy(),
+ rtol=1e-10, atol=1e-12, equal_nan=True,
+ err_msg="Resumed run diverged from full serial.",
+ )
+
+
+def test_failure_isolation(dataset):
+ """A poisoned SPI must not break siblings: that SPI returns NaN, others OK.
+
+ Run at n_jobs=1: an instance-level monkeypatch can't survive into a worker
+ (workers re-instantiate SPIs from the config), so the failure must be raised
+ in-process. The per-SPI try/except -> NaN-fill contract is the same in both
+ paths (_compute_serial and _parallel._run_task); test_parallel_matches_serial
+ covers that the parallel path completes the full table.
+ """
+ calc = Calculator(dataset=dataset, config=str(CONFIG), zscore=False)
+
+ # Poison one Covariance instance's multivariate() at the instance level so
+ # other Covariance/Precision siblings (which share the class method via the
+ # Estimators base) are unaffected — tests that one failure inside a cache
+ # bucket doesn't poison the rest of the bucket.
+ victim_key = next(
+ k for k, spi in calc.spis.items()
+ if getattr(type(spi), "_cache_namespace", None) == "covariance"
+ )
+ siblings = [k for k in calc.spis.keys() if k != victim_key]
+
+ def boom(*args, **kwargs):
+ raise RuntimeError("intentional failure for test_failure_isolation")
+ calc.spis[victim_key].multivariate = boom
+
+ with pytest.warns(UserWarning):
+ calc.compute(n_jobs=1, progress=False)
+
+ M = calc.dataset.n_processes
+ victim_mat = np.asarray(calc.table[victim_key])
+ assert np.all(np.isnan(victim_mat)), "Poisoned SPI should be all-NaN."
+
+ for sibling in siblings:
+ mat = np.asarray(calc.table[sibling])
+ # Diagonal is always NaN by convention; off-diagonal should be finite.
+ offdiag = mat[~np.eye(M, dtype=bool)]
+ assert np.isfinite(offdiag).all(), \
+ f"Sibling SPI '{sibling}' has unexpected NaNs after isolated failure."
+
+
+def test_pin_worker_thread_pools_installs_the_blas_limiter():
+ """Worker pinning must reach the BLAS/OpenMP pools.
+
+ It used to also pin cdt's NJOBS and torch's thread counts; both
+ dependencies are gone, and threadpoolctl is what is left to pin. n_jobs
+ workers each fanning out to cpu_count() BLAS threads is the quadratic
+ blow-up this exists to prevent.
+
+ The limiter object is what is asserted, not `threadpool_info()`: that comes
+ back empty where no threadpoolctl-visible BLAS is loaded (macOS Accelerate,
+ for one), so keying the test on it would make it pass vacuously on some
+ machines and fail on others.
+ """
+ from threadpoolctl import threadpool_info
+
+ from pyspi import _parallel
+
+ _parallel._pin_worker_thread_pools()
+ try:
+ assert _parallel._THREADPOOL_LIMITER is not None
+ assert all(pool["num_threads"] == 1 for pool in threadpool_info())
+ finally:
+ _parallel._THREADPOOL_LIMITER.unregister()
+ _parallel._THREADPOOL_LIMITER = None
+
+
+def test_cli_module_importable():
+ """The CLI module must import cleanly; smoke test against argparse plumbing."""
+ import importlib
+ mod = importlib.import_module("pyspi.__main__")
+ assert hasattr(mod, "main")
+
+
+# --------------------------------------------------------------------------
+# CLI exit status
+# --------------------------------------------------------------------------
+
+def _cli_dataset(tmp_path):
+ """A small VAR(1); enough for the SPIs in parity_failure_config to run."""
+ rng = np.random.default_rng(0)
+ A = np.array([[0.5, 0.0], [0.7, 0.4]])
+ X = np.zeros((2, 120))
+ for t in range(1, X.shape[1]):
+ X[:, t] = A @ X[:, t - 1] + rng.standard_normal(2)
+ path = tmp_path / "cli_data.npy"
+ np.save(path, X)
+ return path
+
+
+@pytest.fixture
+def cli_env(monkeypatch):
+ """`failing_spis` importable by the CLI and by any worker it spawns."""
+ tests_dir = str(Path(__file__).parent)
+ existing = os.environ.get("PYTHONPATH", "")
+ monkeypatch.setenv(
+ "PYTHONPATH", tests_dir + (os.pathsep + existing if existing else "")
+ )
+ if tests_dir not in sys.path:
+ monkeypatch.syspath_prepend(tests_dir)
+
+
+def test_cli_exits_nonzero_on_a_partial_failure_by_default(tmp_path, capsys, cli_env):
+ """A table with failed columns is one a pipeline must not ingest silently.
+
+ `--quiet` suppresses the computation summary, which used to be the only
+ place a failed SPI was named, and the exit status was 0 unconditionally --
+ so `pyspi compute --quiet` printed "Wrote results table" and exited 0 over a
+ table that could be entirely NaN. The default is now 1, with the failed
+ identifiers on stderr, and the results file is still written so the partial
+ table is available for inspection.
+ """
+ from pyspi.__main__ import main
+
+ data = _cli_dataset(tmp_path)
+ out = tmp_path / "res.npz"
+ code = main(["compute", "--data", str(data), "--quiet",
+ "--config", str(Path(__file__).parent / "parity_failure_config.yaml"),
+ "--output", str(out)])
+ assert code == 1
+ err = capsys.readouterr().err
+ assert "always_raises" in err, f"failure not reported on stderr: {err!r}"
+ assert out.exists(), "the partial table must still be written"
+
+
+def test_cli_allow_partial_exits_zero(tmp_path, capsys, cli_env):
+ """The documented opt-out, for callers that expect some SPIs to fail."""
+ from pyspi.__main__ import main
+
+ data = _cli_dataset(tmp_path)
+ code = main(["compute", "--data", str(data), "--quiet", "--allow-partial",
+ "--config", str(Path(__file__).parent / "parity_failure_config.yaml"),
+ "--output", str(tmp_path / "res.npz")])
+ assert code == 0
+ assert "always_raises" in capsys.readouterr().err
+
+
+def test_cli_exits_nonzero_when_every_spi_is_empty(tmp_path, cli_env):
+ """A table with no finite value anywhere is a failed run, not a result."""
+ from pyspi.__main__ import main
+
+ config = tmp_path / "all_failing.yaml"
+ config.write_text(
+ "failing_spis:\n"
+ " AlwaysRaises:\n"
+ " labels: [test]\n"
+ " configs:\n"
+ " - message: deliberate test failure\n"
+ )
+ data = _cli_dataset(tmp_path)
+ code = main(["compute", "--data", str(data), "--quiet",
+ "--config", str(config), "--output", str(tmp_path / "res.npz")])
+ assert code == 1
+
+
+# --------------------------------------------------------------------------
+# Task decomposition
+# --------------------------------------------------------------------------
+
+def test_build_tasks_splits_namespaces_into_the_caches_they_actually_share():
+ """The longest task bounds the makespan, so it must not be a fiction.
+
+ `build_tasks` bucketed on `_cache_namespace` alone, which serialises SPIs
+ that share no cache at all: on `full` that produced one 84-member
+ `spectral_mv` task covering 16 independent caches, and no amount of
+ parallelism could split it. `_cache_subkey` is what separates them, and
+ `calculator.warn_partial_cache_buckets` and `bench/cut_config.py` were
+ already using it -- the scheduler was the odd one out.
+ """
+ from pyspi._parallel import build_tasks, cache_bucket
+ from pyspi.calculator import load_spis_from_yaml, resolve_config
+
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ tasks = build_tasks(list(spis), spis)
+
+ assert sorted(k for t in tasks for k in t) == sorted(spis), "keys lost or duplicated"
+
+ # Every task is exactly one cache bucket (or one cacheless SPI).
+ for task in tasks:
+ buckets = {cache_bucket(spis[k]) for k in task}
+ assert len(buckets) == 1, f"task mixes caches: {sorted(buckets)}"
+ if buckets == {None}:
+ assert len(task) == 1
+
+ largest = max(len(t) for t in tasks)
+ assert largest <= 30, (
+ f"largest task has {largest} members; namespace-only bucketing gave 84"
+ )
+
+
+def test_build_tasks_starts_with_the_expensive_buckets():
+ """Ordering is by estimated cost, not member count.
+
+ A 3-member `ccm` bucket (292.7s amortized per SPI at the M=16, T=800 anchor)
+ must be picked up before a 24-member `covariance` one (<0.3s). Scheduling
+ only -- it cannot change a computed value.
+ """
+ from pyspi._parallel import build_tasks
+ from pyspi.calculator import load_spis_from_yaml, resolve_config
+
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ tasks = build_tasks(list(spis), spis)
+ assert tasks[0][0].startswith("ccm_"), f"first task is {tasks[0][0]}"
diff --git a/tests/test_phi_native.py b/tests/test_phi_native.py
index ec859325..f5e9a58b 100644
--- a/tests/test_phi_native.py
+++ b/tests/test_phi_native.py
@@ -35,7 +35,7 @@ def test_phi_native_basic():
try:
# Create calculator with phi config
- calc = Calculator(dt, configfile=config_path)
+ calc = Calculator(dt, config=config_path)
# Compute phi values
calc.compute()
@@ -79,7 +79,7 @@ def test_phi_types():
config_path = f.name
try:
- calc = Calculator(dt, configfile=config_path)
+ calc = Calculator(dt, config=config_path)
calc.compute()
# Should have at least one table with multiple phi configurations
@@ -134,7 +134,3 @@ def test_phi_comp_direct():
result = phi_comp(X, Z, params, options)
assert np.isfinite(result), "phi_comp should return finite value"
assert isinstance(result, (int, float, np.number)), "phi_comp should return numeric value"
-
-
-if __name__ == "__main__":
- pytest.main([__file__])
\ No newline at end of file
diff --git a/tests/test_run_identity.py b/tests/test_run_identity.py
new file mode 100644
index 00000000..c0cd9631
--- /dev/null
+++ b/tests/test_run_identity.py
@@ -0,0 +1,244 @@
+"""A checkpoint must identify the run that produced it.
+
+These began as red tests and are now green. Resume used to validate only the
+SPI identifier and an ``(M, M)`` shape, so any other run of the same width
+silently inherited the previous run's numbers -- valid-looking results with no
+warning and no trace. Checkpoints are now bound to ``Calculator.run_digest``
+via a ``run.json`` manifest.
+
+Also covered: identifier collisions. Identifiers were formatted with ``.3g``/
+``.4g``, so distinct parameterisations could render to the same string, and
+dictionary insertion overwrote the loser before any duplicate check ran.
+"""
+import numpy as np
+import pytest
+
+from pyspi.calculator import Calculator
+from pyspi.data import Data
+
+CONFIG = "fabfour"
+
+
+def _data(seed, m=3, t=80, name=None):
+ rng = np.random.default_rng(seed)
+ return Data(data=rng.standard_normal((m, t)), dim_order="ps",
+ zscore=False, name=name)
+
+
+def _run(dataset, cp_dir, config=CONFIG, **kw):
+ calc = Calculator(dataset=dataset, config=config, verbose=False)
+ calc.compute(checkpoint_dir=cp_dir, progress=False, **kw)
+ return calc
+
+
+def _assert_foreign_directory_refused(dataset, cp_dir, config=CONFIG):
+ """A checkpoint directory owned by another run must be refused, not reused.
+
+ Deleting its contents to make room would be destructive, and if interrupted
+ partway would relabel whatever survived as the new run.
+ """
+ with pytest.raises(ValueError, match="different run"):
+ _run(dataset, cp_dir, config=config)
+
+
+def _first_spi_values(calc):
+ key = sorted(calc.spis)[0]
+ return key, np.asarray(calc.table[key].to_numpy(dtype=float))
+
+
+# --------------------------------------------------------------------------
+# Checkpoint identity
+# --------------------------------------------------------------------------
+
+def test_checkpoint_rejects_a_different_dataset(tmp_path):
+ """A different dataset of the same width must not reuse checkpoints."""
+ _run(_data(seed=1), tmp_path)
+ _assert_foreign_directory_refused(_data(seed=2), tmp_path)
+
+
+def test_checkpoint_rejects_a_different_config(tmp_path):
+ dataset = _data(seed=3)
+ _run(dataset, tmp_path, config="fabfour")
+ _assert_foreign_directory_refused(dataset, tmp_path, config="fast")
+
+
+def test_checkpoint_rejects_permuted_processes(tmp_path):
+ rng = np.random.default_rng(7)
+ arr = rng.standard_normal((3, 80))
+
+ _run(Data(data=arr, dim_order="ps", zscore=False, procnames=["a", "b", "c"]),
+ tmp_path)
+
+ perm = [2, 0, 1]
+ _assert_foreign_directory_refused(
+ Data(data=arr[perm], dim_order="ps", zscore=False,
+ procnames=["c", "a", "b"]),
+ tmp_path,
+ )
+
+
+def test_failed_checkpoints_are_retried_by_default(tmp_path):
+ """An SPI that failed previously must be recomputed, not resumed as NaN."""
+ dataset = _data(seed=4)
+ calc = Calculator(dataset=dataset, config=CONFIG, verbose=False)
+ key = sorted(calc.spis)[0]
+
+ # Simulate a prior run of *this* calculator in which `key` failed, manifest
+ # included -- otherwise the directory reads as foreign and is refused.
+ tmp_path.mkdir(parents=True, exist_ok=True)
+ from pyspi import _parallel
+ _parallel.write_manifest(tmp_path, calc.run_digest, calc.run_spec)
+ np.save(tmp_path / f"{key}.npy", np.full((3, 3), np.nan))
+ (tmp_path / f"{key}.error").write_text("RuntimeError: simulated prior failure")
+
+ calc.compute(checkpoint_dir=tmp_path, progress=False)
+ got = np.asarray(calc.table[key].to_numpy(dtype=float))
+
+ assert np.isfinite(got[~np.eye(3, dtype=bool)]).any(), (
+ f"'{key}' was resumed from a failed checkpoint instead of being retried."
+ )
+
+
+# --------------------------------------------------------------------------
+# Identifier collisions
+# --------------------------------------------------------------------------
+
+def test_duplicate_identifiers_are_rejected_at_insertion():
+ """Two SPIs with the same identifier must raise, not silently overwrite."""
+ from pyspi.calculator import load_spis_from_yaml
+ import tempfile, textwrap, os
+
+ yaml_text = textwrap.dedent("""
+ .statistics.basic:
+ Covariance:
+ labels: [undirected]
+ configs:
+ - estimator: EmpiricalCovariance
+ - estimator: EmpiricalCovariance
+ """)
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fh:
+ fh.write(yaml_text)
+ path = fh.name
+ try:
+ with pytest.raises(ValueError, match="[Dd]uplicate"):
+ load_spis_from_yaml(path)
+ finally:
+ os.unlink(path)
+
+
+def test_identifier_does_not_collide_under_float_rounding():
+ """Parameterisations that differ numerically must differ in identifier."""
+ from pyspi.statistics.spectral import CoherenceMagnitude
+
+ a = CoherenceMagnitude(fmin=0.123456, fmax=0.5)
+ b = CoherenceMagnitude(fmin=0.123499, fmax=0.5)
+
+ assert a.identifier != b.identifier, (
+ f"Distinct fmin values collide after .3g rounding: {a.identifier!r}."
+ )
+
+
+# --------------------------------------------------------------------------
+# Persistence
+# --------------------------------------------------------------------------
+
+def test_npz_round_trips_without_pickle(tmp_path):
+ """Saved tables must load with allow_pickle=False and match exactly."""
+ from pyspi.calculator import load_table
+
+ calc = Calculator(dataset=_data(seed=11), config=CONFIG, verbose=False)
+ calc.compute(progress=False)
+ out = calc.save(tmp_path / "t.npz")
+
+ # The load path must not need pickle; assert it directly as well as via
+ # load_table, so a future regression to dtype=object is caught here.
+ with np.load(out, allow_pickle=False) as f:
+ assert set(f.files) >= {"values", "spis", "processes", "schema"}
+ assert f["spis"].dtype.kind == "U", "SPI names stored as pickled objects"
+ assert f["processes"].dtype.kind == "U"
+
+ reloaded = load_table(out)
+ for key in calc.spis:
+ np.testing.assert_allclose(
+ reloaded[key].to_numpy(dtype=float),
+ calc.table[key].to_numpy(dtype=float),
+ equal_nan=True,
+ )
+
+
+def test_npz_records_its_provenance(tmp_path):
+ from pyspi.calculator import load_table # noqa: F401 (import parity)
+
+ calc = Calculator(dataset=_data(seed=12), config=CONFIG, verbose=False)
+ calc.compute(progress=False)
+ out = calc.save(tmp_path / "t.npz")
+
+ import json
+ with np.load(out, allow_pickle=False) as f:
+ spec = json.loads(str(f["run_spec"]))
+ assert spec["config"] == CONFIG
+ assert spec["n_processes"] == 3
+ assert str(f["run_digest"]) == calc.run_digest
+
+
+def test_malformed_npz_is_rejected(tmp_path):
+ from pyspi.calculator import load_table
+
+ bad = tmp_path / "bad.npz"
+ np.savez_compressed(bad, values=np.zeros((2, 3, 3)),
+ spis=np.array(["a"], dtype="U"),
+ processes=np.array(["p0", "p1", "p2"], dtype="U"))
+ with pytest.raises(ValueError, match="malformed"):
+ load_table(bad)
+
+
+def test_non_pyspi_npz_is_rejected(tmp_path):
+ from pyspi.calculator import load_table
+
+ bad = tmp_path / "other.npz"
+ np.savez_compressed(bad, something_else=np.zeros(3))
+ with pytest.raises(ValueError, match="not a pyspi results table"):
+ load_table(bad)
+
+
+def test_invalid_checkpoint_is_retried_not_kept(tmp_path):
+ """A non-finite checkpoint must be recomputed, not resumed.
+
+ The validation used to run *after* the retry decision, so an inf-filled
+ matrix was marked failed and then kept.
+ """
+ from pyspi import _parallel
+
+ dataset = _data(seed=21)
+ calc = Calculator(dataset=dataset, config=CONFIG, verbose=False)
+ key = sorted(calc.spis)[0]
+
+ tmp_path.mkdir(parents=True, exist_ok=True)
+ _parallel.write_manifest(tmp_path, calc.run_digest, calc.run_spec)
+ np.save(tmp_path / f"{key}.npy", np.full((3, 3), np.inf))
+
+ calc.compute(checkpoint_dir=tmp_path, progress=False)
+ got = np.asarray(calc.table[key].to_numpy(dtype=float))
+ assert np.isfinite(got[~np.eye(3, dtype=bool)]).any(), (
+ f"'{key}' kept an infinite checkpoint instead of recomputing it."
+ )
+
+
+def test_checkpoint_rejects_a_different_computation_version(tmp_path):
+ """Checkpoints must not outlive the algorithm that produced them."""
+ from pyspi import _parallel
+
+ dataset = _data(seed=22)
+ calc = Calculator(dataset=dataset, config=CONFIG, verbose=False)
+ tmp_path.mkdir(parents=True, exist_ok=True)
+ _parallel.write_manifest(tmp_path, calc.run_digest, calc.run_spec)
+
+ manifest = tmp_path / _parallel.MANIFEST_NAME
+ import json
+ payload = json.loads(manifest.read_text())
+ payload["computation"] = "0.0.0-ancient"
+ manifest.write_text(json.dumps(payload))
+ np.save(tmp_path / f"{sorted(calc.spis)[0]}.npy", np.zeros((3, 3)))
+
+ with pytest.raises(ValueError, match="different run"):
+ calc.compute(checkpoint_dir=tmp_path, progress=False)
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
new file mode 100644
index 00000000..a3eb08cc
--- /dev/null
+++ b/tests/test_smoke.py
@@ -0,0 +1,265 @@
+"""Smoke test for pyspi fork — shape / finiteness / sign checks.
+
+Runs a small set of SPIs on known synthetic data (coupled AR(1)) and checks
+that every estimator instantiates without a JVM, that bivariate and
+multivariate methods return correctly-shaped finite matrices, and that
+dependence measures are positive on coupled signals.
+
+These are cheap sanity checks, not correctness checks. Closed-form validation
+of the information-theoretic estimators lives in test_infotheory_analytic.py.
+"""
+import pytest
+import numpy as np
+
+np.random.seed(42)
+
+
+def generate_coupled_ar1(M=5, T=500, coupling=0.4, noise_std=0.3):
+ """Generate M coupled AR(1) processes.
+
+ X_i(t) = coupling * X_{i-1}(t-1) + noise
+ Process 0 is independent AR(1).
+ """
+ X = np.zeros((M, T))
+ X[:, 0] = np.random.randn(M)
+ for t in range(1, T):
+ X[0, t] = 0.8 * X[0, t - 1] + noise_std * np.random.randn()
+ for i in range(1, M):
+ X[i, t] = (0.5 * X[i, t - 1]
+ + coupling * X[i - 1, t - 1]
+ + noise_std * np.random.randn())
+ return X
+
+
+def test_imports():
+ """All estimators instantiate without JIDT/JVM."""
+ from pyspi.statistics.infotheory import (
+ MutualInfo, TimeLaggedMutualInfo, TransferEntropy,
+ JointEntropy, ConditionalEntropy, CrossmapEntropy,
+ CausalEntropy, DirectedInfo, StochasticInteraction,
+ IntegratedInformation,
+ )
+ from pyspi.statistics.basic import (
+ Covariance, Precision, CrossCorrelation,
+ SpearmanR, KendallTau, LaggedCorrelation,
+ )
+ from pyspi.statistics.distance import DynamicTimeWarping, CrossPairwiseDistance
+ from pyspi.statistics.spectral import CoherenceMagnitude, DirectedCoherence
+ from pyspi.statistics.misc import LinearModel, GPModel
+
+ # Info-theoretic: all estimators. Two exclusions, in opposite directions.
+ #
+ # kozachenko is entropy-only: MutualInfo, TimeLaggedMutualInfo and
+ # TransferEntropy are computed directly rather than from marginal
+ # entropies, so those combinations raise NotImplementedError (they used
+ # to return NaN silently). See test_kozachenko_rejected_for_non_entropy.
+ #
+ # kraskov is the converse: only those same three have a genuine KSG
+ # implementation. The composed measures used to accept it and run the
+ # Gaussian estimator while advertising kraskov_NN-.
+ # See tests/test_estimator_contracts.py.
+ # DirectedInfo estimates each I(X^i; Y_i | Y^{i-1}) term with a direct
+ # KSG/Frenzel-Pompe CMI, so it supports kraskov; the others are composed
+ # from marginal entropies and do not.
+ composed = (JointEntropy, ConditionalEntropy, CrossmapEntropy,
+ CausalEntropy, StochasticInteraction)
+ direct = (MutualInfo, TimeLaggedMutualInfo, TransferEntropy, DirectedInfo)
+
+ for est in ('gaussian', 'kraskov', 'kernel', 'kozachenko'):
+ if est != 'kraskov':
+ for cls in composed:
+ cls(estimator=est)
+ if est != 'kozachenko':
+ for cls in direct:
+ cls(estimator=est)
+
+ TransferEntropy(estimator='symbolic', k_history=3)
+ TransferEntropy(estimator='kernel', kernel_width=0.25)
+
+
+@pytest.mark.parametrize("cls_name", [
+ "JointEntropy", "ConditionalEntropy", "CrossmapEntropy",
+ "CausalEntropy", "StochasticInteraction",
+])
+def test_kraskov_rejected_for_composed_measures(cls_name):
+ """kraskov must fail loudly where no KSG estimator exists.
+
+ These six composed the measure from marginal entropies and were handed
+ GaussianEntropyCalculator for "kraskov", so they returned exactly the
+ Gaussian result while their identifier claimed kraskov_NN-.
+ """
+ import pyspi.statistics.infotheory as it
+ with pytest.raises(NotImplementedError, match="kraskov"):
+ getattr(it, cls_name)(estimator="kraskov")
+
+
+@pytest.mark.parametrize("cls_name", ["MutualInfo", "TimeLaggedMutualInfo", "TransferEntropy"])
+def test_kozachenko_rejected_for_non_entropy(cls_name):
+ """kozachenko must fail loudly for measures with no Kozachenko-Leonenko path.
+
+ These three previously fell through to a logging.warning and returned NaN,
+ so an invalid config produced a silently all-NaN SPI rather than an error.
+ """
+ import pyspi.statistics.infotheory as it
+ with pytest.raises(NotImplementedError, match="kozachenko"):
+ getattr(it, cls_name)(estimator="kozachenko")
+
+
+def test_gaussian_mi_analytical():
+ """Gaussian MI matches analytical formula: MI = -0.5 * ln(1 - r^2)."""
+ from pyspi.data import Data
+ from pyspi.statistics.infotheory import MutualInfo
+
+ X = generate_coupled_ar1(M=3, T=1000)
+ data = Data(X, zscore=True)
+
+ mi = MutualInfo(estimator='gaussian')
+ result = mi.multivariate(data)
+
+ # Check shape and finiteness
+ assert result.shape == (3, 3), f"Shape mismatch: {result.shape}"
+ assert np.all(np.isfinite(result[~np.isnan(result)])), "Non-finite MI values"
+ assert np.all(np.isnan(np.diag(result))), "Diagonal should be NaN"
+
+ # NOTE: this compares against the *sample* correlation, so it is an
+ # implementation-identity check, not independent validation. Real closed-form
+ # validation against the true rho lives in test_infotheory_analytic.py.
+ Z = data.to_numpy(squeeze=True)
+ R = np.corrcoef(Z)
+ r2 = np.clip(R ** 2, 0, 1 - 1e-15)
+ expected = -0.5 * np.log(1 - r2)
+ np.fill_diagonal(expected, np.nan)
+
+ off_diag = ~np.isnan(result)
+ assert np.allclose(result[off_diag], expected[off_diag], atol=1e-10), \
+ f"Gaussian MI mismatch: max diff={np.max(np.abs(result[off_diag] - expected[off_diag]))}"
+
+ # Coupled processes should have positive MI
+ assert result[0, 1] > 0.01, f"MI(0,1) should be positive: {result[0, 1]}"
+
+
+def test_kraskov_mi():
+ """KSG MI is positive for correlated signals."""
+ from pyspi.data import Data
+ from pyspi.statistics.infotheory import MutualInfo
+
+ X = generate_coupled_ar1(M=3, T=500)
+ data = Data(X, zscore=True)
+
+ mi = MutualInfo(estimator='kraskov', prop_k=4)
+ result = mi.multivariate(data)
+
+ assert result.shape == (3, 3)
+ off_diag = ~np.isnan(result)
+ assert np.all(np.isfinite(result[off_diag])), "Non-finite KSG MI"
+ assert result[0, 1] > 0, f"KSG MI(0,1) should be positive: {result[0, 1]}"
+
+
+def test_kernel_mi():
+ """Kernel MI is positive for correlated signals."""
+ from pyspi.data import Data
+ from pyspi.statistics.infotheory import MutualInfo
+
+ X = generate_coupled_ar1(M=3, T=500)
+ data = Data(X, zscore=True)
+
+ mi = MutualInfo(estimator='kernel', kernel_width=0.25)
+ result = mi.multivariate(data)
+
+ assert result.shape == (3, 3)
+ off_diag = ~np.isnan(result)
+ assert np.all(np.isfinite(result[off_diag])), "Non-finite kernel MI"
+ assert result[0, 1] > 0, f"Kernel MI(0,1) should be positive: {result[0, 1]}"
+
+
+def test_transfer_entropy():
+ """TE is positive for causally coupled signals (all estimators)."""
+ from pyspi.data import Data
+ from pyspi.statistics.infotheory import TransferEntropy
+
+ X = generate_coupled_ar1(M=3, T=500, coupling=0.5)
+ data = Data(X, zscore=True)
+
+ for est in ('gaussian', 'kraskov', 'kernel', 'symbolic'):
+ if est == 'kernel':
+ te = TransferEntropy(estimator=est, kernel_width=0.25)
+ elif est == 'symbolic':
+ te = TransferEntropy(estimator=est, k_history=3)
+ else:
+ te = TransferEntropy(estimator=est)
+
+ result = te.multivariate(data)
+ assert result.shape == (3, 3), f"{est} TE shape: {result.shape}"
+
+ # TE(0→1) should be positive (0 causes 1)
+ te_01 = result[0, 1]
+ assert np.isfinite(te_01), f"{est} TE(0→1) not finite: {te_01}"
+
+ if est == 'gaussian':
+ # Gaussian TE = Granger causality, should be clearly positive
+ assert te_01 > 0.01, f"Gaussian TE(0→1) too small: {te_01}"
+
+
+def test_joint_conditional_entropy():
+ """JE and CE produce finite values for kernel estimator."""
+ from pyspi.data import Data
+ from pyspi.statistics.infotheory import JointEntropy, ConditionalEntropy
+
+ X = generate_coupled_ar1(M=3, T=300)
+ data = Data(X, zscore=True)
+
+ for est in ('gaussian', 'kernel', 'kozachenko'):
+ je = JointEntropy(estimator=est)
+ result_je = je.multivariate(data)
+ assert result_je.shape == (3, 3)
+ off = ~np.isnan(result_je)
+ assert np.all(np.isfinite(result_je[off])), f"{est} JE has non-finite values"
+
+ ce = ConditionalEntropy(estimator=est)
+ result_ce = ce.multivariate(data)
+ assert result_ce.shape == (3, 3)
+ off = ~np.isnan(result_ce)
+ assert np.all(np.isfinite(result_ce[off])), f"{est} CE has non-finite values"
+
+
+def test_basic_spis():
+ """Basic SPIs (correlation, DTW, etc.) produce finite values."""
+ from pyspi.data import Data
+ from pyspi.statistics.basic import (
+ Covariance, SpearmanR, KendallTau, CrossCorrelation, LaggedCorrelation,
+ )
+ from pyspi.statistics.distance import DynamicTimeWarping
+
+ X = generate_coupled_ar1(M=3, T=200)
+ data = Data(X, zscore=True)
+
+ for SPI, kwargs in [
+ (Covariance, {}),
+ (SpearmanR, {}),
+ (KendallTau, {}),
+ (CrossCorrelation, {}),
+ (LaggedCorrelation, {"tau": 1}),
+ (LaggedCorrelation, {"tau": 3, "estimator": "spearman"}),
+ (DynamicTimeWarping, {}),
+ ]:
+ name = SPI.__name__ + str(kwargs)
+ spi = SPI(**kwargs)
+ result = spi.multivariate(data)
+ assert result.shape == (3, 3), f"{name} shape: {result.shape}"
+ off = ~np.isnan(result)
+ assert np.all(np.isfinite(result[off])), f"{name} has non-finite values"
+
+
+def test_spectral_spis():
+ """Spectral SPIs produce finite values."""
+ from pyspi.data import Data
+ from pyspi.statistics.spectral import CoherenceMagnitude
+
+ X = generate_coupled_ar1(M=3, T=200)
+ data = Data(X, zscore=True)
+
+ spi = CoherenceMagnitude()
+ result = spi.multivariate(data)
+ assert result.shape == (3, 3)
+ off = ~np.isnan(result)
+ assert np.all(np.isfinite(result[off])), "CoherenceMagnitude has non-finite values"
diff --git a/tests/test_state_integrity.py b/tests/test_state_integrity.py
new file mode 100644
index 00000000..46a46625
--- /dev/null
+++ b/tests/test_state_integrity.py
@@ -0,0 +1,178 @@
+"""Data ownership, cache lifecycle, and process-name consistency.
+
+These began as red tests. Most are now green: ``Data`` copies and freezes its
+input, ``to_numpy()`` hands out a read-only view, every statistic cache listed
+in ``Data._CACHE_ATTRS`` is dropped when the series change, the builder path
+works, process names track add/remove, and ``dim_order``/non-finite inputs are
+validated.
+
+Nothing here is red or xfailed any more -- the last outstanding one, positional
+``bivariate(data, i, j)`` binding ``i`` to ``data2``, is now rejected at the
+decorator.
+
+Do not relax an assertion to make one of these pass. Fix the code.
+"""
+import numpy as np
+import pytest
+
+from pyspi.data import Data
+from pyspi.statistics.basic import Covariance
+
+
+def _mts(seed=0, m=3, t=100):
+ rng = np.random.default_rng(seed)
+ return rng.standard_normal((m, t))
+
+
+# --------------------------------------------------------------------------
+# Input ownership and read-only exposure
+# --------------------------------------------------------------------------
+
+def test_data_owns_its_input_array():
+ """Mutating the caller's array after construction must not change the Data."""
+ arr = _mts()
+ data = Data(data=arr.copy(), dim_order="ps", zscore=False)
+ before = data.to_numpy(squeeze=True).copy()
+
+ # The caller mutates the array they passed in.
+ passed = arr
+ data2 = Data(data=passed, dim_order="ps", zscore=False)
+ snapshot = data2.to_numpy(squeeze=True).copy()
+ passed[0, 0] = 1e6
+
+ assert np.allclose(data2.to_numpy(squeeze=True), snapshot), (
+ "Data aliases the caller's array; mutating the input changed the dataset."
+ )
+ assert np.allclose(data.to_numpy(squeeze=True), before)
+
+
+def test_to_numpy_does_not_expose_mutable_internals():
+ """to_numpy() must not hand out a writable view of internal storage."""
+ data = Data(data=_mts(), dim_order="ps", zscore=False)
+ view = data.to_numpy()
+ snapshot = np.array(view, copy=True)
+
+ if view.flags.writeable:
+ view[0, 0, 0] = 1e6
+
+ assert np.allclose(data.to_numpy(), snapshot), (
+ "Mutating the array returned by to_numpy() altered the Data's internal state."
+ )
+
+
+# --------------------------------------------------------------------------
+# Cache invalidation on mutation
+# --------------------------------------------------------------------------
+
+def test_set_data_invalidates_caches():
+ """Replacing the dataset must invalidate caches computed from the old one."""
+ spi = Covariance()
+ data = Data(data=_mts(seed=1), dim_order="ps", zscore=False)
+ spi.multivariate(data) # populates the cache on `data`
+
+ fresh_arr = _mts(seed=2)
+ data.set_data(fresh_arr, dim_order="ps")
+ after_mutation = spi.multivariate(data)
+
+ reference = spi.multivariate(Data(data=fresh_arr, dim_order="ps", zscore=False))
+ assert np.allclose(after_mutation, reference, equal_nan=True), (
+ "Stale cache survived set_data(): got the previous dataset's statistic."
+ )
+
+
+def test_add_and_remove_process_invalidate_caches():
+ data = Data(data=_mts(seed=3), dim_order="ps", zscore=False)
+ spi = Covariance()
+ spi.multivariate(data)
+
+ data.remove_process(2)
+ after = spi.multivariate(data)
+
+ reference = spi.multivariate(
+ Data(data=_mts(seed=3)[:2], dim_order="ps", zscore=False)
+ )
+ assert after.shape == reference.shape, "Cache retained the pre-removal width."
+ assert np.allclose(after, reference, equal_nan=True), (
+ "Stale cache survived remove_process()."
+ )
+
+
+# --------------------------------------------------------------------------
+# Builder path and raw-array API
+# --------------------------------------------------------------------------
+
+def test_builder_path_add_process_on_empty_data():
+ """Data().add_process(x) is the documented builder entry point."""
+ data = Data()
+ x = _mts(m=1)[0]
+ data.add_process(x)
+ data.add_process(_mts(seed=9, m=1)[0])
+
+ assert data.n_processes == 2
+ assert data.n_observations == x.size
+
+
+def test_bivariate_accepts_raw_arrays():
+ """The two-array form routes through Data()+add_process(), i.e. the builder.
+
+ Covariance is deliberately not used here: it is multivariate-only and
+ raises NotImplementedError from bivariate() regardless of the builder, which
+ would make this pass or fail for the wrong reason.
+ """
+ from pyspi.statistics.basic import SpearmanR
+
+ x, y = _mts(m=2)
+ val = SpearmanR().bivariate(x, y)
+ assert np.isfinite(val)
+
+
+def test_bivariate_rejects_indices_passed_positionally():
+ """``bivariate(data, 0, 1)`` reads as (data, i, j) but binds data2=0, i=1.
+
+ It used to produce an obscure dimension error from deep inside an estimator
+ -- or, worse, no error at all: ``j=None`` reached ``z[None]``, which numpy
+ reads as ``np.newaxis``, so the "pair" became the whole (1, M, T) block and
+ the SPI computed something unrelated to what was asked for. Now rejected at
+ the decorator, with a message that names the signature.
+ """
+ from pyspi.statistics import infotheory as it
+
+ data = Data(data=_mts(m=2, t=200), dim_order="ps")
+ with pytest.raises(ValueError, match="Both i and j must be given"):
+ it.MutualInfo(estimator="kraskov").bivariate(data, 0, 1)
+
+
+# --------------------------------------------------------------------------
+# Process-name lifecycle
+# --------------------------------------------------------------------------
+
+def test_procnames_track_process_mutations():
+ data = Data(data=_mts(), dim_order="ps", zscore=False,
+ procnames=["a", "b", "c"])
+ assert data.procnames == ["a", "b", "c"]
+
+ data.remove_process(1)
+ assert len(data.procnames) == data.n_processes, (
+ "procnames desynchronised from n_processes after remove_process()."
+ )
+ assert data.procnames == ["a", "c"]
+
+ data.add_process(_mts(seed=5, m=1)[0])
+ assert len(data.procnames) == data.n_processes
+
+
+# --------------------------------------------------------------------------
+# dim_order validation
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("bad", ["xx", "pp", "ss", "zz"])
+def test_dim_order_rejects_invalid_symbols(bad):
+ with pytest.raises((ValueError, RuntimeError)):
+ Data(data=_mts(), dim_order=bad, zscore=False)
+
+
+def test_non_finite_input_rejected_without_zscore():
+ arr = _mts()
+ arr[0, 0] = np.inf
+ with pytest.raises(ValueError):
+ Data(data=arr, dim_order="ps", zscore=False)
diff --git a/tests/test_structural_traits.py b/tests/test_structural_traits.py
new file mode 100644
index 00000000..3bd141b3
--- /dev/null
+++ b/tests/test_structural_traits.py
@@ -0,0 +1,604 @@
+"""Declared structural traits must match observed behaviour.
+
+Symmetry is three-valued, not two. A binary directed/undirected vocabulary has
+no word for measures satisfying ``A[i,j] == -A[j,i]`` -- PLI, wPLI, PSI, and
+CCM's "diff" statistic -- and labelling them ``undirected`` (which implies
+symmetry) misdescribes them for any downstream filtering or grouping.
+
+Resolved here:
+
+* ``Cointegration`` declared ``Undirected`` while ``aeg`` computes
+ ``stattools.coint(z[i], z[j])``, which is not symmetric in its arguments
+ (measured: ~0.8 mean absolute difference between orientations, up to ~1.6).
+ The cache then wrote the one computed value to both ``(i, j)`` and
+ ``(j, i)``, so which orientation you got depended on visit order. ``aeg`` is
+ now ``directed`` and reports what it computes; ``johansen``, which is
+ symmetric to ~3e-14, keeps the alias.
+* Wavelet ``PhaseSlopeIndex`` filled its upper triangle from the lower one
+ *without negating*, inverting the lead/lag sign for half of every matrix.
+* ``hhg`` was declared directed but is exactly symmetric; ``ce``, ``dcorrx``
+ and ``mgcx`` were labelled undirected in configs but are directed.
+
+One open finding remains, marked ``xfail(strict=True)`` with its reasoning in
+the marker. It is recorded rather than silently patched because it needs a
+scientific decision, not a code change.
+
+The audit reads the committed baseline matrices, so it costs no computation.
+"""
+import os
+
+import numpy as np
+import pytest
+
+from pyspi.calculator import load_spis_from_yaml, resolve_config
+from pyspi.data import Data
+from pyspi.statistics.misc import Cointegration
+
+BASELINE = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "data", "baselines", "var1_M3_T100.npz"
+)
+
+
+def _offdiag(a):
+ m = a.shape[0]
+ return a[~np.eye(m, dtype=bool)]
+
+
+# --------------------------------------------------------------------------
+# AEG semantics
+# --------------------------------------------------------------------------
+
+def test_aeg_value_is_independent_of_process_order():
+ """Permuting the input processes must not change a pair's AEG value."""
+ rng = np.random.default_rng(0)
+ arr = rng.standard_normal((3, 200)).cumsum(axis=1) # integrated series
+
+ spi = Cointegration(method="aeg", statistic="tstat")
+
+ forward = spi.multivariate(Data(data=arr, dim_order="ps", zscore=False))
+
+ perm = [2, 1, 0]
+ inv = np.argsort(perm)
+ permuted = Cointegration(method="aeg", statistic="tstat").multivariate(
+ Data(data=arr[perm], dim_order="ps", zscore=False)
+ )
+ restored = permuted[np.ix_(inv, inv)]
+
+ assert np.allclose(_offdiag(forward), _offdiag(restored), equal_nan=True), (
+ "AEG changed under a permutation of the processes: the cached value "
+ "depends on which orientation was computed first."
+ )
+
+
+def test_aeg_declared_symmetry_matches_the_statistic():
+ """If AEG is labelled undirected, the underlying statistic must be symmetric."""
+ from statsmodels.tsa import stattools
+
+ rng = np.random.default_rng(1)
+ x = rng.standard_normal(200).cumsum()
+ y = rng.standard_normal(200).cumsum()
+
+ fwd = stattools.coint(x, y, autolag="aic", maxlag=10, trend="c")[0]
+ rev = stattools.coint(y, x, autolag="aic", maxlag=10, trend="c")[0]
+
+ spi = Cointegration(method="aeg")
+ declared_undirected = "undirected" in getattr(spi, "labels", [])
+
+ if declared_undirected:
+ assert np.isclose(fwd, rev), (
+ f"Cointegration(method='aeg') is labelled undirected, but the AEG "
+ f"t-statistic is asymmetric: coint(x,y)={fwd:.6g} vs coint(y,x)={rev:.6g}."
+ )
+
+
+# --------------------------------------------------------------------------
+# Declared labels vs observed matrices
+# --------------------------------------------------------------------------
+
+def _classify(mat):
+ """Classify an MxM matrix as symmetric / antisymmetric / asymmetric.
+
+ Antisymmetry (``A[i,j] == -A[j,i]``) is a genuine third category, not a
+ broken form of either other one: phase-based measures such as PLI, wPLI and
+ PSI carry a sign that encodes lead/lag. The label vocabulary currently has
+ no word for it, which is why they show up as "undirected but asymmetric".
+ """
+ finite = np.isfinite(mat)
+ pairwise = finite & finite.T & ~np.eye(mat.shape[0], dtype=bool)
+ if not pairwise.any():
+ return "undetermined"
+ a, at = mat[pairwise], mat.T[pairwise]
+ if np.allclose(a, at, rtol=1e-9, atol=1e-12):
+ # A constant (e.g. identically zero) matrix is vacuously symmetric;
+ # calling it "symmetric" would mask a degenerate estimator.
+ return "degenerate" if np.ptp(a) == 0 else "symmetric"
+ if np.allclose(a, -at, rtol=1e-9, atol=1e-12):
+ return "antisymmetric"
+ return "asymmetric"
+
+
+def _label_symmetry_audit():
+ """Return {identifier: (declared, observed)} disagreements from baselines."""
+ z = np.load(BASELINE, allow_pickle=False)
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+
+ disagreements = {}
+ for ident, spi in spis.items():
+ if ident not in z.files:
+ continue
+ mat = np.asarray(z[ident], dtype=float)
+ if mat.ndim != 2 or mat.shape[0] != mat.shape[1]:
+ continue
+
+ observed = _classify(mat)
+ if observed in ("undetermined", "degenerate"):
+ continue # covered by the degeneracy tests, not by this one
+
+ # Only undirected -> asymmetric is a contradiction. A *directed*
+ # measure may legitimately produce a symmetric matrix on a particular
+ # dataset: lmfit_* and gpfit_DotProduct are symmetric on z-scored data
+ # because a linear model's R^2 is, yet with zscore=False their measured
+ # asymmetry is ~22.7-23.3. Flagging that direction produced false
+ # positives, not findings.
+ labels = set(getattr(spi, "labels", []) or [])
+ if "undirected" in labels and observed == "asymmetric":
+ disagreements[ident] = ("undirected", observed)
+ return disagreements
+
+
+def test_declared_symmetry_matches_observed_matrices():
+ bad = _label_symmetry_audit()
+ assert not bad, "Declared/observed symmetry disagreements:\n" + "\n".join(
+ f" {k}: declared {v[0]}, observed {v[1]}" for k, v in sorted(bad.items())
+ )
+
+
+def test_antisymmetric_measures_are_labelled_as_such():
+ """PLI/wPLI/PSI encode lead-lag in their sign; 'undirected' misdescribes them."""
+ z = np.load(BASELINE, allow_pickle=False)
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+
+ mislabelled = []
+ for ident, spi in spis.items():
+ if ident not in z.files:
+ continue
+ if _classify(np.asarray(z[ident], dtype=float)) != "antisymmetric":
+ continue
+ labels = set(getattr(spi, "labels", []) or [])
+ if "antisymmetric" not in labels:
+ mislabelled.append(f"{ident} (labelled: {sorted(labels & {'directed', 'undirected'})})")
+
+ assert not mislabelled, (
+ "Antisymmetric SPIs carry no 'antisymmetric' label:\n "
+ + "\n ".join(sorted(mislabelled))
+ )
+
+
+@pytest.mark.xfail(
+ strict=True,
+ reason=(
+ "Fixture/low-data finding, not a proven universal defect. "
+ "dspli_*_max and dswpli_*_max variants return a "
+ "constant matrix on var1_M3_T100 (M=3, T=100), so on that fixture "
+ "they carry no pairwise information. Whether it holds at larger M or "
+ "T has not been established, and none should be removed from "
+ "the shipped set on this evidence alone."
+ ),
+)
+def test_no_bundled_spi_returns_a_constant_matrix():
+ """A constant matrix carries no pairwise information."""
+ z = np.load(BASELINE, allow_pickle=False)
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+
+ degenerate = [
+ ident for ident in spis
+ if ident in z.files
+ and _classify(np.asarray(z[ident], dtype=float)) == "degenerate"
+ ]
+ assert not degenerate, (
+ "SPIs returning a constant matrix on var1_M3_T100: " + ", ".join(sorted(degenerate))
+ )
+
+
+def test_conditional_entropy_label_matches_implementation():
+ """Under the default z-scoring the Gaussian form is symmetric, so the
+ bundled variants are labelled undirected; the kernel and kozachenko forms
+ remain asymmetric and that is checked by the symmetry audit above."""
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ ce = {k: v for k, v in spis.items() if k.startswith("ce_")}
+ assert ce, "Precondition: full config must contain ConditionalEntropy variants."
+
+ contradictory = [
+ k for k, v in ce.items()
+ if {"directed", "undirected"} <= set(getattr(v, "labels", []) or [])
+ ]
+ assert not contradictory, (
+ f"ConditionalEntropy variants carry both labels: {sorted(contradictory)}"
+ )
+
+
+# --------------------------------------------------------------------------
+# Wavelet PSI band statistics
+# --------------------------------------------------------------------------
+
+def test_circular_nanmean_refuses_unresolved_resultants_and_preserves_wraparound():
+ from pyspi.statistics.spectral import _circular_nanmean
+
+ # Exact cancellation leaves only sin(pi)'s floating-point residue. Tiling
+ # it exercises the count-scaled error bound rather than one special pair.
+ assert np.isnan(_circular_nanmean(np.array([0.0, np.pi]), axis=0))
+ cancellation = np.tile([0.0, np.pi], 64)
+ assert np.isnan(_circular_nanmean(cancellation, axis=0))
+ assert np.isnan(_circular_nanmean(np.array([np.nan, np.nan]), axis=0))
+
+ # +/-pi denote the same circular location and therefore cannot supply a
+ # unique signed orientation in an ordinary-float antisymmetric matrix.
+ assert np.isnan(_circular_nanmean(np.array([np.pi, np.pi]), axis=0))
+ assert np.isnan(_circular_nanmean(np.array([-np.pi, -np.pi]), axis=0))
+
+ wrapped = np.array([np.pi - 0.1, -np.pi + 0.1, np.pi - 0.05])
+ expected = np.angle(np.mean(np.exp(1j * wrapped)))
+ got = _circular_nanmean(wrapped, axis=0)
+ assert np.isfinite(got)
+ assert got == pytest.approx(expected)
+
+
+def test_wavelet_coherence_phase_uses_a_circular_mean_and_negates_orientation():
+ from pyspi.statistics.wavelet import CoherencePhase
+
+ phase = np.zeros((3, 3, 3))
+ phase[1, 0] = [np.pi - 0.1, -np.pi + 0.1, np.pi - 0.05]
+ phase[2, 0] = [0.1, 0.3, 0.5]
+ phase[2, 1] = [-0.4, -0.2, 0.6]
+ lower = np.exp(1j * phase)
+ lower[np.triu_indices(3, 1)] = 0
+ freq = np.array([0.1, 0.2, 0.3])
+
+ spi = CoherencePhase(statistic="mean", fmin=0, fmax=0.5)
+ spi._get_cache = lambda data: (lower, np.arange(freq.size))
+ got = spi.multivariate(Data(data=np.ones((3, 8)), dim_order="ps",
+ zscore=False))
+
+ for i, j in ((1, 0), (2, 0), (2, 1)):
+ expected = np.angle(np.mean(np.exp(1j * phase[i, j])))
+ assert got[i, j] == pytest.approx(expected)
+ assert got[j, i] == pytest.approx(-expected)
+ assert set(spi.labels) & {"directed", "undirected", "antisymmetric",
+ "asymmetric"} == {"antisymmetric"}
+ assert "signed" in spi.labels and spi.issigned()
+
+
+def test_wavelet_coherence_phase_is_process_permutation_covariant():
+ from pyspi.statistics.wavelet import CoherencePhase
+
+ full = np.zeros((3, 3, 3))
+ full[1, 0] = [-0.7, 0.2, 0.4]
+ full[2, 0] = [0.1, 0.3, 0.5]
+ full[2, 1] = [-0.4, -0.2, 0.6]
+ full = full - full.transpose(1, 0, 2)
+ data = Data(data=np.ones((3, 8)), dim_order="ps", zscore=False)
+
+ def calculate(order):
+ phase = full[np.ix_(order, order, np.arange(3))]
+ lower = np.exp(1j * phase)
+ lower[np.triu_indices(3, 1)] = 0
+ spi = CoherencePhase(statistic="mean", fmin=0, fmax=0.5)
+ spi._get_cache = lambda unused: (lower, np.arange(3))
+ return spi.multivariate(data)
+
+ order = [2, 0, 1]
+ base = calculate([0, 1, 2])
+ moved = calculate(order)
+ assert np.allclose(base[np.ix_(order, order)], moved, equal_nan=True)
+
+
+def test_wavelet_coherence_phase_refuses_exact_antiphase():
+ from pyspi.statistics.wavelet import CoherencePhase
+
+ phase = np.zeros((2, 2, 3))
+ phase[1, 0] = np.pi
+ lower = np.exp(1j * phase)
+ lower[np.triu_indices(2, 1)] = 0
+ spi = CoherencePhase(statistic="mean", fmin=0, fmax=0.5)
+ spi._get_cache = lambda unused: (lower, np.arange(3))
+ got = spi.multivariate(Data(data=np.ones((2, 8)), dim_order="ps",
+ zscore=False))
+ assert np.isnan(got[0, 1]) and np.isnan(got[1, 0])
+
+
+def test_spectral_coherence_phase_uses_a_circular_mean_and_is_permutation_covariant():
+ from pyspi.statistics.spectral import CoherencePhase
+
+ full = np.zeros((3, 3, 3))
+ full[:, 1, 0] = [np.pi - 0.1, -np.pi + 0.1, np.pi - 0.05]
+ full[:, 2, 0] = [0.1, 0.3, 0.5]
+ full[:, 2, 1] = [-0.4, -0.2, 0.6]
+ full = full - full.transpose(0, 2, 1)
+ freq = np.array([0.1, 0.2, 0.3])
+ data = Data(data=np.ones((3, 8)), dim_order="ps", zscore=False)
+
+ def calculate(order):
+ phase = full[:, order][:, :, order]
+ spi = CoherencePhase(statistic="mean", fmin=0, fmax=0.5)
+ spi._get_cache = lambda unused: (phase[None, ...], freq)
+ return spi.multivariate(data)
+
+ base = calculate([0, 1, 2])
+ spi = CoherencePhase(statistic="mean", fmin=0, fmax=0.5)
+ assert "signed" in spi.labels and "antisymmetric" in spi.labels
+ assert "unsigned" not in spi.labels and "undirected" not in spi.labels
+ assert spi.issigned()
+ expected = np.angle(np.mean(np.exp(1j * full[:, 1, 0])))
+ assert base[1, 0] == pytest.approx(expected)
+ assert base[0, 1] == pytest.approx(-expected)
+ assert np.allclose(base, -base.T, equal_nan=True)
+
+ order = [2, 0, 1]
+ moved = calculate(order)
+ assert np.allclose(base[np.ix_(order, order)], moved, equal_nan=True)
+
+
+def test_spectral_coherence_phase_var_fixture_is_exactly_antisymmetric_and_covariant():
+ from pyspi.statistics.spectral import CoherencePhase
+
+ raw = np.load(os.path.join(
+ os.path.dirname(BASELINE), "..", "fixtures", "var1_M3_T100.npy"
+ ))
+
+ def calculate(values):
+ data = Data(data=values, dim_order="sp", zscore=False)
+ return CoherencePhase(
+ statistic="mean", fs=1, fmin=0, fmax=0.5
+ ).multivariate(data)
+
+ base = calculate(raw)
+ assert np.nanmax(np.abs(base + base.T)) == 0.0
+ order = [2, 0, 1]
+ moved = calculate(raw[:, order])
+ assert np.allclose(base[np.ix_(order, order)], moved, equal_nan=True)
+
+
+def test_spectral_coherence_phase_actual_backend_refuses_exact_antiphase():
+ from pyspi.statistics.spectral import CoherencePhase
+
+ x = np.random.default_rng(0).standard_normal(256)
+
+ def calculate(values):
+ return CoherencePhase(
+ statistic="mean", fs=1, fmin=0, fmax=0.5
+ ).multivariate(Data(data=values, dim_order="ps", zscore=False))
+
+ base = calculate(np.vstack([x, -x]))
+ moved = calculate(np.vstack([-x, x]))
+ assert np.isnan(base[0, 1]) and np.isnan(base[1, 0])
+ assert np.allclose(base[::-1, ::-1], moved, equal_nan=True)
+
+
+def test_coherence_phase_refuses_branch_dependent_maximum():
+ from pyspi.statistics.spectral import CoherencePhase as SpectralPhase
+ from pyspi.statistics.wavelet import CoherencePhase as WaveletPhase
+
+ for cls in (SpectralPhase, WaveletPhase):
+ with pytest.raises(ValueError, match="branch-cut-independent ordinary maximum"):
+ cls(statistic="max")
+
+@pytest.mark.parametrize("statistic", ["mean", "max"])
+def test_wavelet_psi_is_permutation_invariant(statistic):
+ """Both band statistics must survive a permutation of the processes.
+
+ mne_connectivity returns a lower-triangular tensor and the upper triangle
+ is filled by negating. That fill must happen *before* the band statistic:
+ negating after reduction is only valid for a statistic commuting with
+ negation. mean commutes, max does not --
+ ``max_f(-v) = -min_f(v) != -max_f(v)`` -- so reducing first made the max
+ variants permutation-dependent by up to 11.5.
+ """
+ from pyspi.statistics.wavelet import PhaseSlopeIndex
+
+ rng = np.random.default_rng(0)
+ x = np.cumsum(rng.standard_normal(400))
+ arr = np.vstack([x, np.roll(x, 4) + 0.1 * rng.standard_normal(400),
+ rng.standard_normal(400)])
+ perm = [2, 0, 1]
+ inv = np.argsort(perm)
+
+ fwd = PhaseSlopeIndex(statistic=statistic).multivariate(
+ Data(data=arr, dim_order="ps", zscore=True))
+ permuted = PhaseSlopeIndex(statistic=statistic).multivariate(
+ Data(data=arr[perm], dim_order="ps", zscore=True))
+ restored = permuted[np.ix_(inv, inv)]
+
+ off = ~np.eye(3, dtype=bool)
+ assert np.allclose(fwd[off], restored[off], equal_nan=True), (
+ f"psi_wavelet statistic={statistic} is not permutation-invariant; "
+ f"max|d|={np.nanmax(np.abs(fwd[off] - restored[off])):.6g}"
+ )
+
+
+def test_antisymmetric_spis_report_themselves_as_signed():
+ """`issigned()` drives a transform, so it cannot disagree with the values.
+
+ `Calculator._rmmin` subtracts the minimum from every SPI that reports
+ unsigned. On an antisymmetric matrix that shifts A[i,j] and A[j,i] by the
+ same amount, destroying the antisymmetry that carries the lead/lag;
+ `set_group` separately correlates unsigned SPIs through `abs()`, folding
+ lead onto lag. The shipped configs declared `unsigned` for `phase`, `pli`,
+ `wpli`, `psi` (both the multitaper and wavelet families), `gd` and
+ `ccm_*_diff`.
+ """
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ bad = [
+ ident for ident, spi in spis.items()
+ if ({"antisymmetric", "asymmetric"} & set(spi.labels)) and not spi.issigned()
+ ]
+ assert not bad, "antisymmetric SPIs reporting unsigned:\n " + "\n ".join(sorted(bad))
+
+
+def test_no_spi_declares_both_signed_and_unsigned():
+ """One authority. `_merge_spi_labels` resolves the label from `issigned()`."""
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ bad = [i for i, s in spis.items() if {"signed", "unsigned"} <= set(s.labels)]
+ assert not bad, "contradictory signedness labels:\n " + "\n ".join(sorted(bad))
+
+
+def test_every_bundled_spi_declares_a_signedness():
+ """`_rmmin` calls `issigned()` unguarded, so a missing one is an exception.
+
+ `CrossPairwiseDistance` subclassed only `Undirected`, which supplies no
+ `issigned`, so `Calculator._rmmin()` raised AttributeError on any config
+ containing it.
+ """
+ spis = load_spis_from_yaml(resolve_config("full"), quiet=True)
+ missing = [i for i, s in spis.items() if not hasattr(s, "issigned")]
+ assert not missing, "SPIs with no issigned():\n " + "\n ".join(sorted(missing))
+
+
+# --------------------------------------------------------------------------
+# Structural traits are authoritative over config-declared directedness
+# --------------------------------------------------------------------------
+
+_GROUP_DELAY_YAML = """.statistics.spectral:
+ GroupDelay:
+ labels: [directed, linear, unsigned, bivariate, M01]
+ configs:
+ - {fmin: 0, fmax: 0.5, statistic: delay}
+ - {fmin: 0, fmax: 0.5, statistic: slope}
+ - {fmin: 0, fmax: 0.5, statistic: rvalue}
+"""
+
+_EXPECTED_GD_TRAITS = {
+ "delay": ({"antisymmetric", "signed"}, True),
+ "slope": ({"antisymmetric", "signed"}, True),
+ "rvalue": ({"undirected", "unsigned"}, False),
+}
+_TRAITS = {"directed", "undirected", "antisymmetric", "asymmetric",
+ "signed", "unsigned"}
+
+
+@pytest.mark.parametrize("statistic", ["delay", "slope", "rvalue"])
+def test_group_delay_traits_survive_direct_construction(statistic):
+ from pyspi.statistics.spectral import GroupDelay
+
+ spi = GroupDelay(statistic=statistic, fmin=0, fmax=0.5)
+ expected, signed = _EXPECTED_GD_TRAITS[statistic]
+ assert set(spi.labels) & _TRAITS == expected
+ assert spi.issigned() is signed
+
+
+def test_group_delay_traits_survive_a_yaml_family_label(tmp_path):
+ """A family-level `directed` must not displace the SPI's own trait.
+
+ `gd_*_rvalue` is symmetric by construction -- it stores |r| -- and declares
+ itself undirected, but the family label put `directed` back alongside it, so
+ `filter_spis(["directed"])` and `filter_spis(["undirected"])` both returned
+ it. `delay` and `slope` are antisymmetric, which displaces both.
+ """
+ from pyspi.calculator import load_spis_from_yaml
+
+ config = tmp_path / "gd.yaml"
+ config.write_text(_GROUP_DELAY_YAML)
+ for identifier, spi in load_spis_from_yaml(str(config), quiet=True).items():
+ statistic = identifier.split("_")[2]
+ expected, signed = _EXPECTED_GD_TRAITS[statistic]
+ assert set(spi.labels) & _TRAITS == expected, identifier
+ assert spi.issigned() is signed, identifier
+
+
+def test_yaml_cannot_add_a_competing_structural_trait(tmp_path):
+ """The instance's one structural trait displaces all YAML competitors."""
+ from pyspi.calculator import load_spis_from_yaml
+
+ config = tmp_path / "conflicting_traits.yaml"
+ config.write_text(
+ ".statistics.infotheory:\n"
+ " MutualInfo:\n"
+ " configs:\n"
+ " - {estimator: gaussian, labels: [directed, antisymmetric, asymmetric]}\n"
+ " TransferEntropy:\n"
+ " configs:\n"
+ " - {estimator: gaussian, labels: [undirected, antisymmetric, asymmetric]}\n"
+ ".statistics.spectral:\n"
+ " CoherencePhase:\n"
+ " configs:\n"
+ " - {statistic: mean, fmin: 0, fmax: 0.5, labels: [directed, undirected, asymmetric]}\n"
+ )
+ expected = {
+ "mi_gaussian": "undirected",
+ "gc_gaussian_k-1_kt-1_l-1_lt-1": "directed",
+ "phase_multitaper_mean_fs-1_fmin-0_fmax-0-5": "antisymmetric",
+ }
+ spis = load_spis_from_yaml(str(config), quiet=True)
+ assert set(spis) == set(expected)
+ for identifier, trait in expected.items():
+ assert set(spis[identifier].labels) & set(_TRAITS) == {
+ trait,
+ "signed" if trait in {"antisymmetric", "asymmetric"} else "unsigned",
+ }
+
+
+def test_no_spi_in_any_bundled_config_declares_two_directedness_traits():
+ from pyspi.calculator import bundled_configs, load_spis_from_yaml, resolve_config
+
+ for name in bundled_configs():
+ for identifier, spi in load_spis_from_yaml(resolve_config(name),
+ quiet=True).items():
+ labels = set(spi.labels)
+ structural = labels & {
+ "directed", "undirected", "antisymmetric", "asymmetric"
+ }
+ assert len(structural) == 1, f"{name}/{identifier}: {structural}"
+
+
+# --------------------------------------------------------------------------
+# Causal-statistic metadata
+# --------------------------------------------------------------------------
+
+def test_igci_is_named_and_labelled_for_what_it_computes():
+ """It is Information-Geometric Causal *Inference*, and its score is signed.
+
+ The score is a difference of two entropies, hence exactly antisymmetric.
+ Reporting it unsigned was not cosmetic: `Calculator._rmmin` shifts every
+ column it believes unsigned by that column's minimum, which on an
+ antisymmetric matrix moves both orientations equally and destroys the sign,
+ and `set_group` folds the directions together through `abs()`.
+ """
+ from pyspi.data import Data
+ from pyspi.statistics.causal import InformationGeometricCausalInference
+
+ spi = InformationGeometricCausalInference()
+ assert "causal inference" in spi.name.lower()
+ assert "conditional independence" not in spi.name.lower()
+ assert set(spi.labels) & _TRAITS == {"antisymmetric", "signed"}
+ assert spi.issigned()
+
+ rng = np.random.default_rng(0)
+ x = rng.standard_normal(300)
+ y = np.exp(x) + 0.1 * rng.standard_normal(300)
+ table = spi.multivariate(Data(data=np.vstack([x, y]), dim_order="ps"))
+ assert table[0, 1] == pytest.approx(-table[1, 0], rel=1e-12)
+
+
+def test_the_old_igci_name_still_works_and_warns():
+ """Compatibility alias, so existing configs and scripts keep running."""
+ import pyspi.statistics.causal as causal
+
+ with pytest.warns(DeprecationWarning, match="Causal"):
+ old = causal.InformationGeometricConditionalIndependence()
+ assert isinstance(old, causal.InformationGeometricCausalInference)
+ assert old.identifier == causal.InformationGeometricCausalInference().identifier
+
+
+def test_additive_noise_model_is_not_labelled_linear():
+ """It fits a Gaussian process and tests independence with an RBF HSIC."""
+ from pyspi.statistics.causal import AdditiveNoiseModel
+
+ labels = set(AdditiveNoiseModel().labels)
+ assert "nonlinear" in labels and "linear" not in labels
+
+
+def test_igci_stays_out_of_the_bundled_configs():
+ """A metadata correction, not a claim that the heuristic is reliable."""
+ from pyspi.calculator import bundled_configs, load_spis_from_yaml, resolve_config
+
+ for name in bundled_configs():
+ assert "igci" not in load_spis_from_yaml(resolve_config(name), quiet=True)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 598b38f6..598755b1 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,29 +3,54 @@
import yaml
from unittest.mock import mock_open, patch
+@pytest.fixture
+def config_file(tmp_path, mock_yaml_content):
+ """A real config yaml on disk, so filter_spis exercises real path resolution."""
+ path = tmp_path / "mock_config.yaml"
+ path.write_text(yaml.dump(mock_yaml_content))
+ return path
+
+
@pytest.fixture
def mock_yaml_content():
+ """Real modules, classes and config params.
+
+ filter_spis now instantiates each config and matches on the labels the SPI
+ actually carries, because several traits are only set in __init__ and are
+ invisible in the raw YAML. That means the fixture has to be loadable: fake
+ module names and integer configs cannot be instantiated.
+ """
return {
- "module1": {
- "spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
- "spi2": {"labels": ["keyword1"], "configs": [3]},
+ ".statistics.basic": {
+ "Covariance": {
+ "labels": ["keyword1", "keyword2"],
+ "configs": [{"estimator": "EmpiricalCovariance"},
+ {"estimator": "LedoitWolf"}],
+ },
+ "SpearmanR": {
+ "labels": ["keyword1"],
+ "configs": [{"squared": True}],
+ },
},
- "module2": {
- "spi3": {"labels": ["keyword3"], "configs": [1, 2, 3]},
+ ".statistics.misc": {
+ "PowerEnvelopeCorrelation": {
+ "labels": ["keyword3"],
+ "configs": [{"orth": False, "log": False, "absolute": False}],
+ },
},
}
def test_filter_spis_invalid_keywords():
"""Pass in a dataype other than a list for the keywords"""
with pytest.raises(ValueError) as excinfo:
- filter_spis(keywords="linear", configfile="pyspi/config.yaml")
+ filter_spis(keywords="linear", configfile="full")
assert "Keywords must be provided as a list of strings" in str(excinfo.value)
# check for passing in an empty list
with pytest.raises(ValueError) as excinfo:
- filter_spis(keywords=[], configfile="pyspi/config.yaml")
+ filter_spis(keywords=[], configfile="full")
assert "At least one keyword must be provided" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
- filter_spis(keywords=[4], configfile="pyspi/config.yaml")
+ filter_spis(keywords=[4], configfile="full")
assert "All keywords must be strings" in str(excinfo.value)
def test_filter_spis_with_invalid_config():
@@ -33,42 +58,31 @@ def test_filter_spis_with_invalid_config():
with pytest.raises(FileNotFoundError):
filter_spis(keywords=["test"], configfile="invalid_config.yaml")
-def test_filter_spis_no_matches(mock_yaml_content):
- """Pass in keywords that return no spis and check for ValuError"""
- m = mock_open()
- m().read.return_value = yaml.dump(mock_yaml_content)
- keywords = ["random_keyword"]
-
- with patch("builtins.open", m), \
- patch("os.path.isfile", return_value=True), \
- patch("yaml.load", return_value=mock_yaml_content):
- with pytest.raises(ValueError) as excinfo:
- filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")
-
+def test_filter_spis_no_matches(config_file, tmp_path, monkeypatch):
+ """Pass in keywords that return no spis and check for ValueError"""
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(ValueError) as excinfo:
+ filter_spis(keywords=["random_keyword"], output_name="mock_filtered_config",
+ configfile=str(config_file))
assert "0 SPIs were found" in str(excinfo.value), "Incorrect error message returned when no keywords match found."
-def test_filter_spis_normal_operation(mock_yaml_content):
- """Test whether the filter spis function works as expected"""
- m = mock_open()
- m().read_return_value = yaml.dump(mock_yaml_content)
- keywords = ["keyword1", "keyword2"] # filter keys
- expected_output_yaml = {
- "module1": {
- "spi1": {"labels": ["keyword1", "keyword2"], "configs": [1,2]}
+def test_filter_spis_normal_operation(config_file, tmp_path, monkeypatch):
+ """Filter a config down to the SPIs carrying every keyword."""
+ monkeypatch.chdir(tmp_path)
+ filter_spis(keywords=["keyword1", "keyword2"], output_name="mock_filtered_config",
+ configfile=str(config_file))
+
+ written = yaml.safe_load((tmp_path / "mock_filtered_config.yaml").read_text())
+ assert written == {
+ ".statistics.basic": {
+ "Covariance": {
+ "labels": ["keyword1", "keyword2"],
+ "configs": [{"estimator": "EmpiricalCovariance"},
+ {"estimator": "LedoitWolf"}],
+ }
}
- }
+ }, "Expected filtered YAML does not match actual filtered YAML."
- with patch("builtins.open", m), patch("os.path.isfile", return_value=True), \
- patch("yaml.load", return_value=mock_yaml_content), \
- patch("yaml.dump") as mock_dump:
-
- filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")
-
- mock_dump.assert_called_once()
- args, _ = mock_dump.call_args # get call args for dump and intercept
- actual_output = args[0] # the first argument to yaml.dump should be the yaml
-
- assert actual_output == expected_output_yaml, "Expected filtered YAML does not match actual filtered YAML."
def test_filter_spis_io_error_on_read():
# check to see whether io error is raised when trying to access the configfile
@@ -101,19 +115,21 @@ def test_filter_spis_saves_with_random_name_if_no_name_provided(mock_yaml_conten
assert found_expected_call, f"no file with the expected name {expected_file_name_pattern} was saved."
-def test_loads_default_config_if_no_config_specified(mock_yaml_content):
- script_dir = "/fake/script/directory"
- default_config_path = f"{script_dir}/config.yaml"
-
- with patch("builtins.open", mock_open()) as mocked_open, \
- patch("os.path.isfile", return_value=True), \
- patch("yaml.load", return_value=mock_yaml_content), \
- patch("os.path.dirname", return_value=script_dir), \
- patch("os.path.abspath", return_value=script_dir):
-
- # run filter func without specifying a config file
- filter_spis(["keyword1"])
-
- # ensure the mock_open was called with the expected path
- assert any(call.args[0] == default_config_path for call in mocked_open.mock_calls), \
- "Expected default config file to be opened."
+def test_loads_default_config_if_no_config_specified(tmp_path, monkeypatch):
+ """With no configfile, filter_spis falls back to the bundled 'full' config."""
+ monkeypatch.chdir(tmp_path)
+ filter_spis(["nonlinear"], output_name="from_default")
+
+ written = yaml.safe_load((tmp_path / "from_default.yaml").read_text())
+ assert written, "Filtering the default config produced an empty result."
+
+ # Assert on the labels the SPIs actually carry, not on the family block in
+ # the YAML: filtering resolves each config, and several labels are only
+ # added in __init__ (estimator-dependent 'nonlinear', 'antisymmetric' for
+ # mean-reduced phase measures), so a matching SPI's family labels need not
+ # list the keyword.
+ from pyspi.calculator import load_spis_from_yaml
+ spis = load_spis_from_yaml(str(tmp_path / "from_default.yaml"), quiet=True)
+ assert spis, "Filtered config instantiated no SPIs."
+ missing = [k for k, v in spis.items() if "nonlinear" not in (v.labels or [])]
+ assert not missing, f"Filtered config contains SPIs without the label: {missing}"
diff --git a/tests/tools/generate_benchmark_tables.py b/tests/tools/generate_benchmark_tables.py
new file mode 100644
index 00000000..96ed6e18
--- /dev/null
+++ b/tests/tools/generate_benchmark_tables.py
@@ -0,0 +1,154 @@
+"""Regenerate the baseline SPI tables used by ``tests/test_baseline_drift.py``.
+
+For each frozen test fixture in ``tests/data/fixtures/`` this runs the full
+Calculator (all 322 SPIs)
+once and stores the resulting MxM matrix per SPI in a single compressed
+``.npz`` file under ``tests/data/baselines/``.
+
+Why a single pass rather than repeated trials
+---------------------------------------------
+The previous generator ran ten "trials" and stored mean/std, but reseeded numpy
+to the same value inside the loop, so all ten draws were identical and every
+std matrix was zero by construction. Fixing that by varying the seed would
+produce a baseline that *no* individual run reproduces, which defeats the
+purpose of the drift test: we want an exact oracle so genuine sub-percent
+regressions in deterministic SPIs are visible. The benchmark datasets are
+frozen fixtures and the overwhelming majority of SPIs are deterministic given
+the data, so a single seeded pass is both honest and strictly more useful. The
+handful of estimator-based SPIs that consume the global RNG are pinned by
+``--seed`` (default 42) and are compared under a looser tolerance by the test.
+
+Usage
+-----
+ python tests/tools/generate_benchmark_tables.py # all three
+ python tests/tools/generate_benchmark_tables.py -d cml_M5_T100
+ python tests/tools/generate_benchmark_tables.py --out /tmp/baselines
+"""
+import argparse
+import os
+import time
+
+import numpy as np
+
+from pyspi.calculator import Calculator
+from pyspi.data import Data
+
+# Datasets that the drift suite tracks. These are test fixtures, not shipped
+# data: they live under tests/data/fixtures/ and are built by
+# tests/tools/generate_fixtures.py. Their names encode (M processes, T obs).
+DATASETS = ("var1_M3_T100", "cml_M5_T100", "kuramoto_M7_T100")
+
+# /tests/tools/this_file.py -> /tests/data/{baselines,fixtures}
+_HERE = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_OUT = os.path.join(os.path.dirname(_HERE), "data", "baselines")
+FIXTURE_DIR = os.path.join(os.path.dirname(_HERE), "data", "fixtures")
+
+
+def load_fixture(dataset_name):
+ """Load a frozen test fixture; stored (observations, processes) -> 'sp'."""
+ return Data(data=os.path.join(FIXTURE_DIR, f"{dataset_name}.npy"),
+ dim_order="sp", name=dataset_name)
+
+# Reserved npz keys for provenance; the loader ignores anything dunder-wrapped.
+META_PREFIX = "__"
+
+# SPIs that legitimately cannot be estimated on a given fixture, with the
+# statistical reason. Everything else must produce a finite value, and a run
+# with any other failure is not frozen. Imported by tests/test_baseline_drift.py
+# so the test suite and the generator cannot drift apart on what is expected.
+#
+# `sgc_parametric_*_order-None`: nitime's automatic AR order search walks the
+# lag up to `max_order` and raises if the information criterion never turns
+# over. On kuramoto_M7_T100 -- 100 observations, a smooth oscillatory process
+# -- BIC improves all the way to lag 49, which at that length is over-fitting
+# rather than a genuinely high order. The fixed-order variants (order-1,
+# order-20) are estimated normally on the same fixture, and the automatic
+# variant is estimated normally on var1_M3_T100 and cml_M5_T100, so this is a
+# property of the record, not of the estimator.
+KNOWN_UNESTIMABLE = {
+ "kuramoto_M7_T100": {
+ key: "automatic AR order selection does not converge at T=100"
+ for key in (
+ "sgc_parametric_mean_fs-1_fmin-1e-05_fmax-0-5_order-None",
+ "sgc_parametric_mean_fs-1_fmin-1e-05_fmax-0-25_order-None",
+ "sgc_parametric_mean_fs-1_fmin-0-25_fmax-0-5_order-None",
+ "sgc_parametric_max_fs-1_fmin-1e-05_fmax-0-5_order-None",
+ "sgc_parametric_max_fs-1_fmin-1e-05_fmax-0-25_order-None",
+ "sgc_parametric_max_fs-1_fmin-0-25_fmax-0-5_order-None",
+ )
+ },
+}
+
+
+def build_tables(dataset_name, config="full", seed=42):
+ """Compute every SPI on ``dataset_name`` and return ``{spi_key: MxM array}``."""
+ np.random.seed(seed)
+ calc = Calculator(dataset=load_fixture(dataset_name), config=config)
+ calc.compute()
+ expected = KNOWN_UNESTIMABLE.get(dataset_name, {})
+ unexpected = {k: v for k, v in calc.errors.items() if k not in expected}
+ if unexpected:
+ # Refusing rather than freezing. A baseline written from a run with
+ # failed SPIs records the failure as the expected answer, and the drift
+ # suite then agrees with it forever -- which is how three all-NaN
+ # `gd_*` columns stayed in the baselines unnoticed.
+ raise RuntimeError(
+ f"[{dataset_name}] {len(unexpected)} SPI(s) raised; refusing to "
+ f"freeze a baseline over them:\n "
+ + "\n ".join(f"{k}: {v}" for k, v in sorted(unexpected.items()))
+ )
+ tables = {spi: calc.table[spi].to_numpy() for spi in calc.spis}
+ off_diagonal = ~np.eye(calc.dataset.n_processes, dtype=bool)
+ empty = sorted(k for k, v in tables.items()
+ if k not in expected
+ and not np.isfinite(np.asarray(v, dtype=float)[off_diagonal]).any())
+ if empty:
+ raise RuntimeError(
+ f"[{dataset_name}] {len(empty)} SPI(s) produced no finite value; "
+ f"an empty column cannot serve as an oracle:\n " + "\n ".join(empty)
+ )
+ return tables
+
+
+def write_npz(tables, path, dataset_name, config, seed):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ payload = dict(tables)
+ payload[META_PREFIX + "dataset" + META_PREFIX] = np.array(dataset_name)
+ payload[META_PREFIX + "config" + META_PREFIX] = np.array(config)
+ payload[META_PREFIX + "seed" + META_PREFIX] = np.array(seed)
+ np.savez_compressed(path, **payload)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "-d", "--dataset", choices=DATASETS + ("all",), default="all",
+ help="Bundled dataset to regenerate (default: all).",
+ )
+ parser.add_argument(
+ "-o", "--out", default=DEFAULT_OUT,
+ help=f"Output directory (default: {DEFAULT_OUT}).",
+ )
+ parser.add_argument(
+ "-c", "--config", default="full",
+ help="Calculator config name or path (default: full).",
+ )
+ parser.add_argument(
+ "-s", "--seed", type=int, default=42,
+ help="Global numpy seed set once before compute (default: 42).",
+ )
+ args = parser.parse_args()
+
+ names = DATASETS if args.dataset == "all" else (args.dataset,)
+ for name in names:
+ t0 = time.time()
+ tables = build_tables(name, config=args.config, seed=args.seed)
+ path = os.path.join(args.out, f"{name}.npz")
+ write_npz(tables, path, name, args.config, args.seed)
+ size_kb = os.path.getsize(path) / 1024
+ print(f"[{name}] {len(tables)} SPIs -> {path} "
+ f"({size_kb:.0f} KB, {time.time() - t0:.0f} s)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/tools/generate_fixtures.py b/tests/tools/generate_fixtures.py
new file mode 100644
index 00000000..7aedb8c7
--- /dev/null
+++ b/tests/tools/generate_fixtures.py
@@ -0,0 +1,141 @@
+"""Generate the frozen synthetic datasets used by ``tests/test_baseline_drift.py``.
+
+Three qualitatively different generating processes, at three different process
+counts, so the SPI set is exercised across a range of ``M`` rather than at a
+single width:
+
+ * ``var1_M3_T100`` -- linear VAR(1), stable, sparse coupling (M=3)
+ * ``cml_M5_T100`` -- coupled map lattice, chaotic logistic map (M=5)
+ * ``kuramoto_M7_T100`` -- coupled phase oscillators, sin(phase) observed (M=7)
+
+Conventions
+-----------
+Arrays are saved as ``(observations, processes)`` — i.e. ``dim_order='sp'`` —
+matching the ``.npy`` files bundled in ``pyspi/data/``. The generators build
+``(processes, observations)`` internally and transpose on write.
+
+``T = 100`` throughout. That is short enough to keep the 322-SPI drift suite
+fast (SPI cost is at worst quadratic in ``M`` and roughly linear-to-quadratic in
+``T``) while still leaving enough samples for the embedding- and
+spectrum-based estimators, which need a few dozen effective observations after
+lagging. It also matches the ``T`` of the fixtures these replace, so baseline
+run times are directly comparable.
+
+These are test fixtures, not shipped data: they live under ``tests/data/fixtures/``
+and are deliberately *not* reachable via ``pyspi.data.load_dataset``.
+
+Usage
+-----
+ python tests/tools/generate_fixtures.py # write all three
+ python tests/tools/generate_fixtures.py -f var1_M3_T100
+ python tests/tools/generate_fixtures.py --out /tmp/fixtures
+"""
+import argparse
+import os
+
+import numpy as np
+
+# /tests/tools/this_file.py -> /tests/data/fixtures
+_HERE = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_OUT = os.path.join(os.path.dirname(_HERE), "data", "fixtures")
+
+
+def generate_var1(M=3, T=100, seed=0):
+ """Stable VAR(1) with sparse off-diagonal coupling and Gaussian noise."""
+ rng = np.random.default_rng(seed)
+
+ A = rng.uniform(-0.4, 0.4, size=(M, M))
+ A *= rng.random((M, M)) < 0.5 # sparsify the coupling
+ A[np.diag_indices(M)] = 0.5 # keep every process autocorrelated
+ # Rescale below unit spectral radius so the process is stationary.
+ A *= 0.85 / np.max(np.abs(np.linalg.eigvals(A)))
+
+ Y = np.zeros((M, T))
+ Y[:, 0] = rng.standard_normal(M)
+ for t in range(1, T):
+ Y[:, t] = A @ Y[:, t - 1] + 0.5 * rng.standard_normal(M)
+ return Y
+
+
+def generate_cml(M=5, T=100, coupling=0.2, r=4.0, burn_in=500, seed=0):
+ """Diffusively coupled logistic maps on a ring (fully chaotic at r=4)."""
+ rng = np.random.default_rng(seed)
+ x = rng.uniform(0.1, 0.9, size=M)
+
+ Y = np.zeros((M, T))
+ for t in range(burn_in + T):
+ fx = r * x * (1.0 - x)
+ x = (1.0 - coupling) * fx + 0.5 * coupling * (np.roll(fx, 1) + np.roll(fx, -1))
+ if t >= burn_in:
+ Y[:, t - burn_in] = x
+ return Y
+
+
+def generate_kuramoto(M=7, T=100, dt=0.2, K=0.8, omega_spread=2.0, seed=0):
+ """Kuramoto phase oscillators with uniform all-to-all coupling.
+
+ ``K`` and the natural-frequency spread are set for *partial* synchronisation.
+ Strong coupling drives the lattice to a single locked phase, at which point
+ every process is a copy of every other and most pairwise statistics become
+ degenerate (that was the failure mode of the fixture this replaces).
+ """
+ rng = np.random.default_rng(seed)
+ omega = rng.uniform(-omega_spread, omega_spread, size=M)
+ theta = rng.uniform(0.0, 2.0 * np.pi, size=M)
+
+ Y = np.zeros((M, T))
+ for t in range(T):
+ dtheta = omega + (K / M) * np.sin(theta[None, :] - theta[:, None]).sum(axis=1)
+ theta = theta + dt * dtheta
+ Y[:, t] = np.sin(theta)
+ return Y
+
+
+# name -> zero-argument generator returning a (processes, observations) array.
+FIXTURES = {
+ "var1_M3_T100": lambda: generate_var1(M=3, T=100, seed=0),
+ "cml_M5_T100": lambda: generate_cml(M=5, T=100, seed=0),
+ "kuramoto_M7_T100": lambda: generate_kuramoto(M=7, T=100, seed=0),
+}
+
+
+def check(name, Y):
+ """Fail loudly on a degenerate fixture; return a one-line summary."""
+ assert np.all(np.isfinite(Y)), f"{name}: non-finite entries"
+ sd = Y.std(axis=1)
+ assert np.all(sd > 1e-3), f"{name}: near-constant process(es), std={sd}"
+
+ # No two processes may be (anti-)identical up to scale, or every pairwise
+ # statistic between them degenerates.
+ corr = np.corrcoef(Y)
+ off = np.abs(corr[~np.eye(len(Y), dtype=bool)])
+ assert off.max() < 0.999, f"{name}: duplicated process(es), max |corr|={off.max():.4f}"
+
+ return (f"{name:<18} shape={Y.T.shape} (obs, proc) "
+ f"range=[{Y.min():+.3f}, {Y.max():+.3f}] "
+ f"std=[{sd.min():.3f}, {sd.max():.3f}] max|corr|={off.max():.3f}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "-f", "--fixture", choices=tuple(FIXTURES) + ("all",), default="all",
+ help="Fixture to regenerate (default: all).",
+ )
+ parser.add_argument(
+ "-o", "--out", default=DEFAULT_OUT,
+ help=f"Output directory (default: {DEFAULT_OUT}).",
+ )
+ args = parser.parse_args()
+
+ os.makedirs(args.out, exist_ok=True)
+ names = tuple(FIXTURES) if args.fixture == "all" else (args.fixture,)
+ for name in names:
+ Y = FIXTURES[name]()
+ summary = check(name, Y)
+ np.save(os.path.join(args.out, f"{name}.npy"), Y.T) # -> (obs, processes)
+ print(summary)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/tools/measure_reproducibility.py b/tests/tools/measure_reproducibility.py
new file mode 100644
index 00000000..91d6bdd8
--- /dev/null
+++ b/tests/tools/measure_reproducibility.py
@@ -0,0 +1,105 @@
+"""Measure which SPIs are bit-reproducible under the drift suite's protocol.
+
+``tests/test_baseline_drift.py`` compares a fresh computation against a frozen
+baseline. How tight that comparison may be is a property of each SPI, not of
+the module it lives in: a deterministic estimator must reproduce its baseline
+to float round-off, while one consuming unpinned randomness cannot. Guessing
+which is which is how a blanket 1e-2 band ended up covering ~50 deterministic
+SPIs, wide enough to hide a real regression in any of them.
+
+This computes the full config twice per fixture under exactly the suite's
+protocol -- ``np.random.seed(SEED)``, then a fresh ``Calculator`` -- and reports
+the largest disagreement between the two passes for every SPI. Anything
+non-zero belongs in ``LOOSE_SPIS`` in the drift suite, with its mechanism named.
+
+ python tests/tools/measure_reproducibility.py
+ python tests/tools/measure_reproducibility.py -d var1_M3_T100 --json out.json
+
+Two full passes per fixture: budget ~15 minutes for all three.
+"""
+import argparse
+import json
+import os
+
+import numpy as np
+
+from pyspi.calculator import Calculator
+from pyspi.data import Data
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+FIXTURE_DIR = os.path.join(os.path.dirname(_HERE), "data", "fixtures")
+
+# Must match tests/test_baseline_drift.py.
+DATASETS = ("var1_M3_T100", "cml_M5_T100", "kuramoto_M7_T100")
+SEED = 42
+
+
+def _one_pass(dataset_name, config, seed):
+ np.random.seed(seed)
+ calc = Calculator(
+ dataset=Data(data=os.path.join(FIXTURE_DIR, f"{dataset_name}.npy"),
+ dim_order="sp", name=dataset_name),
+ config=config,
+ )
+ calc.compute()
+ return ({s: calc.table[s].to_numpy() for s in calc.spis},
+ {s: calc.spis[s].__module__.split(".")[-1] for s in calc.spis})
+
+
+def measure(datasets, config="full", seed=SEED):
+ """spi -> {module, abs, rel}, maximised over datasets."""
+ out = {}
+ for dataset_name in datasets:
+ first, modules = _one_pass(dataset_name, config, seed)
+ second, _ = _one_pass(dataset_name, config, seed)
+ for key, a in first.items():
+ b = second[key]
+ # Compared as masks first. Restricting to entries finite in both
+ # and reporting the numeric difference there cannot see an SPI
+ # whose NaN *pattern* moved between the two runs -- it would report
+ # a difference of 0 for a column that had gone from finite to NaN.
+ rec = out.setdefault(key, {"module": modules[key], "abs": 0.0,
+ "rel": 0.0})
+ if not np.array_equal(np.isfinite(a), np.isfinite(b)):
+ rec["abs"] = rec["rel"] = float("inf")
+ continue
+ finite = np.isfinite(a) & np.isfinite(b)
+ abs_diff = np.abs(a[finite] - b[finite])
+ worst_abs = float(abs_diff.max()) if abs_diff.size else 0.0
+ nonzero = np.abs(a[finite]) > 0
+ worst_rel = (float((abs_diff[nonzero] / np.abs(a[finite][nonzero])).max())
+ if nonzero.any() else 0.0)
+ rec["abs"] = max(rec["abs"], worst_abs)
+ rec["rel"] = max(rec["rel"], worst_rel)
+ return out
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("-d", "--datasets", nargs="+", default=list(DATASETS),
+ choices=list(DATASETS))
+ ap.add_argument("--config", default="full")
+ ap.add_argument("--seed", type=int, default=SEED)
+ ap.add_argument("--json", default=None, help="Also write the raw table here.")
+ args = ap.parse_args(argv)
+
+ results = measure(args.datasets, config=args.config, seed=args.seed)
+ if args.json:
+ with open(args.json, "w") as fh:
+ json.dump(results, fh, indent=1, sort_keys=True)
+
+ drifting = {k: v for k, v in results.items() if v["abs"] > 0.0}
+ print(f"\n{len(results) - len(drifting)}/{len(results)} SPIs reproduce bit-exactly "
+ f"on {', '.join(args.datasets)}")
+ if not drifting:
+ print("LOOSE_SPIS in tests/test_baseline_drift.py should stay empty.")
+ return 0
+ print("\nNot bit-reproducible -- add to LOOSE_SPIS with the mechanism named:")
+ for key, rec in sorted(drifting.items(), key=lambda kv: -kv[1]["rel"]):
+ print(f" {rec['module']:10s} {key:58s} abs={rec['abs']:.3e} rel={rec['rel']:.3e}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..0a6ee86e
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,3294 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+
+[[package]]
+name = "aeon"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "deprecated", marker = "python_full_version < '3.11'" },
+ { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "packaging", marker = "python_full_version < '3.11'" },
+ { name = "pandas", marker = "python_full_version < '3.11'" },
+ { name = "scikit-learn", marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/9c/82e3e0c29b0a095ec2dec6574e4701dafa64ee0bac0c8b21e20ca146ddcf/aeon-1.3.0.tar.gz", hash = "sha256:4892cd7446ce2c436d6312ee2ed4cf30bd232739bff8d9aab5acd37032b91031", size = 5884260, upload-time = "2025-09-09T12:46:41.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/cd/ae5e94e68efeb6191ef57dccd9ebd7fb14f8c76375822870bf4e831579cc/aeon-1.3.0-py3-none-any.whl", hash = "sha256:90e69e7dce01035395468f9afd83f15e34d66513e709456e4dd7763c9aa3cda8", size = 6525776, upload-time = "2025-09-09T12:46:39.521Z" },
+]
+
+[[package]]
+name = "aeon"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "deprecated", marker = "python_full_version >= '3.11'" },
+ { name = "numba", version = "0.63.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging", marker = "python_full_version >= '3.11'" },
+ { name = "pandas", marker = "python_full_version >= '3.11'" },
+ { name = "scikit-learn", marker = "python_full_version >= '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/4e/87f522c4e34c3459ccb107935c79449ec4611ed55364fef95eb97d1014ad/aeon-1.4.0.tar.gz", hash = "sha256:bf75b144a157397dd48ea4d2f96b3ca618c561269246e5d5d8dee156e6c18188", size = 5943237, upload-time = "2026-03-24T11:34:30.178Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/e2/2ad6604b0fcaf2a17b219c1fa031a7be229679d85c4c49dfa6b4aac8cd4a/aeon-1.4.0-py3-none-any.whl", hash = "sha256:c3c063c70b836645b34104839c3a10b2554f7405ea6a23f5763e0dbf694616e3", size = 6603061, upload-time = "2026-03-24T11:34:28.034Z" },
+]
+
+[[package]]
+name = "appnope"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "autograd"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/ea/e16f0c423f7d83cf8b79cae9452040fb7b2e020c7439a167ee7c317de448/autograd-1.8.0-py3-none-any.whl", hash = "sha256:4ab9084294f814cf56c280adbe19612546a35574d67c574b04933c7d2ecb7d78", size = 51478, upload-time = "2025-05-05T12:49:00.585Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
+ { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
+ { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
+ { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
+[[package]]
+name = "cftime"
+version = "1.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/45/dcc38d7b293107d3e33b3d94b2619687eb414a4f16880e2e841cdb6ac49a/cftime-1.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ad81e8cb0eb873b33c3d1e22c6168163fdc64daa8f7aeb4da8092f272575f4d", size = 510221, upload-time = "2025-10-13T18:55:52.976Z" },
+ { url = "https://files.pythonhosted.org/packages/68/63/2875341516fcfe80f1a16f86b420aec9441223ab5381d554441c9fdae56e/cftime-1.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12d95c6af852114a13301c5a61e41afdbd1542e72939c1083796f8418b9b8b0e", size = 490684, upload-time = "2025-10-13T18:55:54.685Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7f/85f2c4c7ae8300b7871af7d7d144ad06f71dc0dd6258f0d18fd966067d1b/cftime-1.6.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2659b7df700e27d9e3671f686ce474dfb5fc274966961edf996acc148dfa094a", size = 1592268, upload-time = "2025-10-13T19:39:10.992Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9a/72dbd72498e958edf41a770bbd05e68141774325a945092059f4eb9c653d/cftime-1.6.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:94cebdfcda6a985b8e69aed22d00d6b8aa1f421495adbdcff1d59b3e896d81e2", size = 1624716, upload-time = "2025-10-13T18:55:55.848Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/2c4c720ad8bbe87994ca62a0e3c09d3786b984af664a91a6f3a668aa0b13/cftime-1.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:179681b023349a2fe277ceccc89d4fc52c0dd105cb59b7187b5bc5d442875133", size = 1705927, upload-time = "2025-10-13T18:55:57.711Z" },
+ { url = "https://files.pythonhosted.org/packages/da/77/66484061dee5fbcb2fdcfa6a491d4efb880725117f4a339d20a5323105df/cftime-1.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:d8b9fdecb466879cfe8ca4472b229b6f8d0bb65e4ffd44266ae17484bac2cf38", size = 472435, upload-time = "2025-10-13T18:55:59.092Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/f6/9da7aba9548ede62d25936b8b448acd7e53e5dcc710896f66863dcc9a318/cftime-1.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:474e728f5a387299418f8d7cb9c52248dcd5d977b2a01de7ec06bba572e26b02", size = 512733, upload-time = "2025-10-13T18:56:00.189Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d5/d86ad95fc1fd89947c34b495ff6487b6d361cf77500217423b4ebcb1f0c2/cftime-1.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9e80d4de815cac2e2d88a2335231254980e545d0196eb34ee8f7ed612645f1", size = 492946, upload-time = "2025-10-13T18:56:01.262Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/93/d7e8dd76b03a9d5be41a3b3185feffc7ea5359228bdffe7aa43ac772a75b/cftime-1.6.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad24a563784e4795cb3d04bd985895b5db49ace2cbb71fcf1321fd80141f9a52", size = 1689856, upload-time = "2025-10-13T19:39:12.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/8d/86586c0d75110f774e46e2bd6d134e2d1cca1dedc9bb08c388fa3df76acd/cftime-1.6.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3cda6fd12c7fb25eff40a6a857a2bf4d03e8cc71f80485d8ddc65ccbd80f16a", size = 1718573, upload-time = "2025-10-13T18:56:02.788Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/fe/7956914cfc135992e89098ebbc67d683c51ace5366ba4b114fef1de89b21/cftime-1.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:28cda78d685397ba23d06273b9c916c3938d8d9e6872a537e76b8408a321369b", size = 1788563, upload-time = "2025-10-13T18:56:04.075Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/c7/6669708fcfe1bb7b2a7ce693b8cc67165eac00d3ac5a5e8f6ce1be551ff9/cftime-1.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:93ead088e3a216bdeb9368733a0ef89a7451dfc1d2de310c1c0366a56ad60dc8", size = 473631, upload-time = "2025-10-13T18:56:05.159Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c5/d70cb1ab533ca790d7c9b69f98215fa4fead17f05547e928c8f2b8f96e54/cftime-1.6.5-cp311-cp311-win_arm64.whl", hash = "sha256:3384d69a0a7f3d45bded21a8cbcce66c8ba06c13498eac26c2de41b1b9b6e890", size = 459383, upload-time = "2026-01-02T21:16:47.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/c1/e8cb7f78a3f87295450e7300ebaecf83076d96a99a76190593d4e1d2be40/cftime-1.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eef25caed5ebd003a38719bd3ff8847cd52ef2ea56c3ebdb2c9345ba131fc7c5", size = 504175, upload-time = "2025-10-13T18:56:06.398Z" },
+ { url = "https://files.pythonhosted.org/packages/50/1a/86e1072b09b2f9049bb7378869f64b6747f96a4f3008142afed8955b52a4/cftime-1.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87d2f3b949e45463e559233c69e6a9cf691b2b378c1f7556166adfabbd1c6b0", size = 485980, upload-time = "2025-10-13T18:56:08.669Z" },
+ { url = "https://files.pythonhosted.org/packages/35/28/d3177b60da3f308b60dee2aef2eb69997acfab1e863f0bf0d2a418396ce5/cftime-1.6.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:82cb413973cc51b55642b3a1ca5b28db5b93a294edbef7dc049c074b478b4647", size = 1591166, upload-time = "2025-10-13T19:39:14.109Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/fd/a7266970312df65e68b5641b86e0540a739182f5e9c62eec6dbd29f18055/cftime-1.6.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85ba8e7356d239cfe56ef7707ac30feaf67964642ac760a82e507ee3c5db4ac4", size = 1642614, upload-time = "2025-10-13T18:56:09.815Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/73/f0035a4bc2df8885bb7bd5fe63659686ea1ec7d0cc74b4e3d50e447402e5/cftime-1.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:456039af7907a3146689bb80bfd8edabd074c7f3b4eca61f91b9c2670addd7ad", size = 1688090, upload-time = "2025-10-13T18:56:11.442Z" },
+ { url = "https://files.pythonhosted.org/packages/88/15/8856a0ab76708553ff597dd2e617b088c734ba87dc3fd395e2b2f3efffe8/cftime-1.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:da84534c43699960dc980a9a765c33433c5de1a719a4916748c2d0e97a071e44", size = 464840, upload-time = "2025-10-13T18:56:12.506Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/85/451009a986d9273d2208fc0898aa00262275b5773259bf3f942f6716a9e7/cftime-1.6.5-cp312-cp312-win_arm64.whl", hash = "sha256:c62cd8db9ea40131eea7d4523691c5d806d3265d31279e4a58574a42c28acd77", size = 450534, upload-time = "2026-01-02T21:16:48.784Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/60/74ea344b3b003fada346ed98a6899085d6fd4c777df608992d90c458fda6/cftime-1.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4aba66fd6497711a47c656f3a732c2d1755ad15f80e323c44a8716ebde39ddd5", size = 502453, upload-time = "2025-10-13T18:56:13.545Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/14/adb293ac6127079b49ff11c05cf3d5ce5c1f17d097f326dc02d74ddfcb6e/cftime-1.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89e7cba699242366e67d6fb5aee579440e791063f92a93853610c91647167c0d", size = 484541, upload-time = "2025-10-13T18:56:14.612Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/74/bb8a4566af8d0ef3f045d56c462a9115da4f04b07c7fbbf2b4875223eebd/cftime-1.6.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f1eb43d7a7b919ec99aee709fb62ef87ef1cf0679829ef93d37cc1c725781e9", size = 1591014, upload-time = "2025-10-13T19:39:15.346Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/08/52f06ff2f04d376f9cd2c211aefcf2b37f1978e43289341f362fc99f6a0e/cftime-1.6.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e02a1d80ffc33fe469c7db68aa24c4a87f01da0c0c621373e5edadc92964900b", size = 1633625, upload-time = "2025-10-13T18:56:15.745Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/33/03e0b23d58ea8fab94ecb4f7c5b721e844a0800c13694876149d98830a73/cftime-1.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18ab754805233cdd889614b2b3b86a642f6d51a57a1ec327c48053f3414f87d8", size = 1684269, upload-time = "2025-10-13T18:56:17.04Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/60/a0cfba63847b43599ef1cdbbf682e61894994c22b9a79fd9e1e8c7e9de41/cftime-1.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:6c27add8f907f4a4cd400e89438f2ea33e2eb5072541a157a4d013b7dbe93f9c", size = 465364, upload-time = "2025-10-13T18:56:18.05Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e8/ec32f2aef22c15604e6fda39ff8d581a00b5469349f8fba61640d5358d2c/cftime-1.6.5-cp313-cp313-win_arm64.whl", hash = "sha256:31d1ff8f6bbd4ca209099d24459ec16dea4fb4c9ab740fbb66dd057ccbd9b1b9", size = 450468, upload-time = "2026-01-02T21:16:50.193Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/6c/a9618f589688358e279720f5c0fe67ef0077fba07334ce26895403ebc260/cftime-1.6.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c69ce3bdae6a322cbb44e9ebc20770d47748002fb9d68846a1e934f1bd5daf0b", size = 502725, upload-time = "2025-10-13T18:56:19.424Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/e3/da3c36398bfb730b96248d006cabaceed87e401ff56edafb2a978293e228/cftime-1.6.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e62e9f2943e014c5ef583245bf2e878398af131c97e64f8cd47c1d7baef5c4e2", size = 485445, upload-time = "2025-10-13T18:56:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/32/93/b05939e5abd14bd1ab69538bbe374b4ee2a15467b189ff895e9a8cdaddf6/cftime-1.6.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da5fdaa4360d8cb89b71b8ded9314f2246aa34581e8105c94ad58d6102d9e4f", size = 1584434, upload-time = "2025-10-13T19:39:17.084Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bff865b4ea4304f2744a1ad2b8149b8328b321dd7a2b9746ef926d229bd7cd49", size = 1609812, upload-time = "2025-10-13T18:56:21.971Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/0f/901b4835aa67ad3e915605d4e01d0af80a44b114eefab74ae33de6d36933/cftime-1.6.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e552c5d1c8a58f25af7521e49237db7ca52ed2953e974fe9f7c4491e95fdd36c", size = 1669768, upload-time = "2025-10-13T18:56:24.027Z" },
+ { url = "https://files.pythonhosted.org/packages/22/d5/e605e4b28363e7a9ae98ed12cabbda5b155b6009270e6a231d8f10182a17/cftime-1.6.5-cp314-cp314-win_amd64.whl", hash = "sha256:e645b095dc50a38ac454b7e7f0742f639e7d7f6b108ad329358544a6ff8c9ba2", size = 463818, upload-time = "2025-10-13T18:56:25.376Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/89/a8f85ae697ff10206ec401c2621f5ca9f327554f586d62f244739ceeb347/cftime-1.6.5-cp314-cp314-win_arm64.whl", hash = "sha256:b9044d7ac82d3d8af189df1032fdc871bbd3f3dd41a6ec79edceb5029b71e6e0", size = 459862, upload-time = "2026-01-02T20:45:02.625Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/05/7410e12fd03a0c52717e74e6a1b49958810807dda212e23b65d43ea99676/cftime-1.6.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ef56460cb0576e1a9161e1428c9e1a633f809a23fa9d598f313748c1ae5064e", size = 533781, upload-time = "2026-01-02T20:45:04.818Z" },
+ { url = "https://files.pythonhosted.org/packages/44/ba/10e3546426d3ed9f9cc82e4a99836bb6fac1642c7830f7bdd0ac1c3f0805/cftime-1.6.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f4873d38b10032f9f3111c547a1d485519ae64eee6a7a2d091f1f8b08e1ba50", size = 515218, upload-time = "2026-01-02T20:45:06.788Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/68/efa11eae867749e921bfec6a865afdba8166e96188112dde70bb8bb49254/cftime-1.6.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccce0f4c9d3f38dd948a117e578b50d0e0db11e2ca9435fb358fd524813e4b61", size = 1579932, upload-time = "2026-01-02T20:45:11.194Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6c/0971e602c1390a423e6621dfbad9f1d375186bdaf9c9c7f75e06f1fbf355/cftime-1.6.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19cbfc5152fb0b34ce03acf9668229af388d7baa63a78f936239cb011ccbe6b1", size = 1555894, upload-time = "2026-01-02T20:45:16.351Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/fc/8475a15b7c3209a4a68b563dfc5e01ce74f2d8b9822372c3d30c68ab7f39/cftime-1.6.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4470cd5ef3c2514566f53efbcbb64dd924fa0584637d90285b2f983bd4ee7d97", size = 513027, upload-time = "2026-01-02T20:45:20.023Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
+ { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
+ { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
+ { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
+ { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
+ { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
+ { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+ { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[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 = "comm"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" },
+ { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" },
+ { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" },
+ { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" },
+ { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" },
+ { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" },
+ { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" },
+ { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" },
+ { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" },
+ { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" },
+ { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" },
+ { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" },
+ { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" },
+ { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" },
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
+]
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
+]
+
+[[package]]
+name = "debugpy"
+version = "1.8.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" },
+ { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" },
+ { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" },
+ { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" },
+ { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" },
+ { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" },
+ { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
+ { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[package]]
+name = "deprecated"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
+]
+
+[[package]]
+name = "dtaidistance"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/01/aa26cc97b64d397ff03b9576b0a04cc79d0e3bae512eb087cfab7d98f4ec/dtaidistance-2.4.0.tar.gz", hash = "sha256:bd4066800254fbd5b620e6462bb759c9d85b79ac2080b354cedc901f446b6c82", size = 1316462, upload-time = "2026-02-12T22:23:56.35Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/ec/fa410cb539ce29bc324140ecc6079890b9d7def5056d4595318988314054/dtaidistance-2.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b7e054baadcd4ae54ec87b0ecb0d9aa0d41682ccc376ffd9b57ee29ff5e615f4", size = 2108362, upload-time = "2026-02-12T22:23:33.206Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/f3/91ecf5ae5321ee14236c394fb673db5d64bccfa643c17ec889e72b5b75fa/dtaidistance-2.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3838dbcc0a9b5f513aa5f1a158ac82f924651a163801cb63f5dc6c1999e6e6b6", size = 1657675, upload-time = "2026-02-13T08:14:42.994Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ff/e9f7ce427d45171a78104e785ba25ddc1d112d7a695741ef6609d8c51d99/dtaidistance-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc756c0b305f72357aae2c48d52f6c80a651c43b06dfb740bfaf76a3fd97a114", size = 4356748, upload-time = "2026-02-12T22:23:35.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/94/2fa6f8c637685369a5b9c4b9efe3c414207f74c6fa02525f58a1b6369a1c/dtaidistance-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:b17f853aa274bf02e00f9461013ec50882d7ea093587c126c74f38b119f5a1dc", size = 1445182, upload-time = "2026-02-12T22:23:37.87Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/63/c1546dc5a4a98f77ca044206e8d8b7604349d36d0b76d5c03ab393a55e60/dtaidistance-2.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:64d54f910b53cd7a56b215e06d2b24b22090af836102d48558d3e9569ded2b66", size = 2124723, upload-time = "2026-02-12T22:23:39.482Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/9a/4c0cb726c3c93436c993f55fc59d5fd2142c1a0fe6fe9ec06cc7bf25ab15/dtaidistance-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3afb229f4524f8bbf835a5dc3e07abcee9b6b9c6af4f14436cad19639102243c", size = 1549051, upload-time = "2026-02-13T08:14:46.866Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8e/ccdd057e4ff71cf0b6fe34220cbd214d469f831b45acbbb4366fdfef6330/dtaidistance-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:349d6765e10ddbb5e22e937cf1bc42394f5f8d36bc127f8af24a0cd0259f4804", size = 4361729, upload-time = "2026-02-12T22:23:41.184Z" },
+ { url = "https://files.pythonhosted.org/packages/00/cf/ef215e8864c21eb14872f98987d9736ebbbe5049d429039e2a93adcacad4/dtaidistance-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6ab9431a5b66aafd37ab4dfcfe563b66694ed192019c1632d2de7a431a883bcd", size = 1443363, upload-time = "2026-02-12T22:23:43.706Z" },
+ { url = "https://files.pythonhosted.org/packages/87/89/c64eea692eae3b269719ee5173bf5008b5c165280248e3fad1948c765a2b/dtaidistance-2.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4cf41f3edcc4c1b94ebbc1de029ee9b58da28f33f7bf3af89212cc05e35ec8f1", size = 2117805, upload-time = "2026-02-12T22:23:45.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/7f/06ce3d5ce51a959be0534584ad2556e6c8be966ef1218a866c6c3d62e3c5/dtaidistance-2.4.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:94b841d6575e3ad715b4e213f0f04de25e23c2da3ac21ee9c6775b38f5bdfecf", size = 1738478, upload-time = "2026-02-13T08:14:51.715Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8e/6c8a5c7710f9f5e3805281974ce8fea4ad0334c00a1e0f977977c045a594/dtaidistance-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0f2a65628aea82175e7f8c5e96faf5372c933ed40e2e39a84957d8fe305158d", size = 4341606, upload-time = "2026-02-12T22:23:46.977Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b6/7f77c6773380742660d09f379d43814b448296fc24c3fb1de15a3d813311/dtaidistance-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8c9ef4c7270d1a192e8f1b481c2e10e63c33c6e7edfc507acac7f3fdc19949f", size = 1441578, upload-time = "2026-02-12T22:23:48.897Z" },
+ { url = "https://files.pythonhosted.org/packages/05/72/ac72a2e196c66c627c1f51684ffa2fd782e9cb042baa29898206f79b0d86/dtaidistance-2.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bda9849cd22800cd8b3c6abf29574db241458823814cd37302d01df99474175a", size = 2136045, upload-time = "2026-02-12T22:23:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/30/30/60f941b3fed3d8b94e7315a71b8f87294f2e053c1f3c19e53f7b6cc33689/dtaidistance-2.4.0-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:b5aa878c57f779cb9e141c0b4b1bc6b5be9c1721b349d153758112854a1b24cf", size = 1676822, upload-time = "2026-02-13T08:14:56.305Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/82/b805e66d3b05e2cfc4e209b7d7f62ac31fb7e72615c3532c7edb0bf1943a/dtaidistance-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2d7d26b9023788f62e5f245db5b78b68aacde67e5aeaa75906ce7ddb251da7f", size = 4328390, upload-time = "2026-02-12T22:23:52.115Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/89/da956340797c0ea022a35b6b0df9a118cc4427401d9a8119dc104d4b48de/dtaidistance-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a35f2957bbf50b068b0b90f5cc9b442bf9b2a90b855e713811b8badf89a668d", size = 1462169, upload-time = "2026-02-12T22:23:53.598Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/02/16088a7bd17340a4e600f49bf4da16a9741ddbb737202a91363407e993b2/dtaidistance-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e083a5163c780a5b711d970c190d3eca83ebc0ec86e453e6f56d63b1d6d78139", size = 4332943, upload-time = "2026-02-12T22:23:55.009Z" },
+]
+
+[[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 = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "fastjsonschema"
+version = "2.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.62.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" },
+ { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" },
+ { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" },
+ { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" },
+ { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" },
+ { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" },
+ { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" },
+ { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" },
+ { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" },
+ { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
+ { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
+ { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
+ { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
+ { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
+ { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
+ { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
+]
+
+[[package]]
+name = "future"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
+]
+
+[[package]]
+name = "hyppo"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "autograd" },
+ { name = "future" },
+ { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numba", version = "0.63.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+ { name = "patsy" },
+ { name = "scikit-learn" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "statsmodels" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/c4/d46858cfac3c0aad314a1fc378beae5c8cac499b677650a34b5a6a3d4328/hyppo-0.5.2-py3-none-any.whl", hash = "sha256:5cc18f9e158fe2cf1804c9a1e979e807118ee89a303f29dc5cb8891d92d44ef3", size = 192272, upload-time = "2025-05-24T18:33:25.904Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+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 = "ipykernel"
+version = "7.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "appnope", marker = "sys_platform == 'darwin'" },
+ { name = "comm" },
+ { name = "debugpy" },
+ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "ipython", version = "9.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "matplotlib-inline" },
+ { name = "nest-asyncio2" },
+ { name = "packaging" },
+ { name = "psutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "8.39.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version < '3.11'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "jedi", marker = "python_full_version < '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
+ { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
+ { name = "pygments", marker = "python_full_version < '3.11'" },
+ { name = "stack-data", marker = "python_full_version < '3.11'" },
+ { name = "traitlets", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.16.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
+ { name = "jedi", marker = "python_full_version >= '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
+ { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
+ { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
+ { name = "stack-data", marker = "python_full_version >= '3.11'" },
+ { name = "traitlets", marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "jupyter-client"
+version = "8.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-core" },
+ { name = "python-dateutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" },
+]
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
+]
+
+[[package]]
+name = "kiwisolver"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" },
+ { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" },
+ { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" },
+ { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" },
+ { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" },
+ { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" },
+ { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" },
+ { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" },
+ { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" },
+ { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" },
+ { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" },
+ { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" },
+ { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" },
+ { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" },
+ { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" },
+ { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" },
+ { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" },
+ { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
+ { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
+ { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
+ { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
+ { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
+ { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" },
+ { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" },
+ { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
+]
+
+[[package]]
+name = "lazy-loader"
+version = "0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" },
+]
+
+[[package]]
+name = "llvmlite"
+version = "0.44.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306, upload-time = "2025-01-20T11:12:18.634Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096, upload-time = "2025-01-20T11:12:24.544Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859, upload-time = "2025-01-20T11:12:31.839Z" },
+ { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199, upload-time = "2025-01-20T11:12:40.049Z" },
+ { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381, upload-time = "2025-01-20T11:12:47.054Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e2/86b245397052386595ad726f9742e5223d7aea999b18c518a50e96c3aca4/llvmlite-0.44.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:eed7d5f29136bda63b6d7804c279e2b72e08c952b7c5df61f45db408e0ee52f3", size = 28132305, upload-time = "2025-01-20T11:12:53.936Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ec/506902dc6870249fbe2466d9cf66d531265d0f3a1157213c8f986250c033/llvmlite-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ace564d9fa44bb91eb6e6d8e7754977783c68e90a471ea7ce913bff30bd62427", size = 26201090, upload-time = "2025-01-20T11:12:59.847Z" },
+ { url = "https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d22c3bfc842668168a786af4205ec8e3ad29fb1bc03fd11fd48460d0df64c1", size = 42361858, upload-time = "2025-01-20T11:13:07.623Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/7a/ce6174664b9077fc673d172e4c888cb0b128e707e306bc33fff8c2035f0d/llvmlite-0.44.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f01a394e9c9b7b1d4e63c327b096d10f6f0ed149ef53d38a09b3749dcf8c9610", size = 41184200, upload-time = "2025-01-20T11:13:20.058Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/c6/258801143975a6d09a373f2641237992496e15567b907a4d401839d671b8/llvmlite-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8489634d43c20cd0ad71330dde1d5bc7b9966937a263ff1ec1cebb90dc50955", size = 30331193, upload-time = "2025-01-20T11:13:26.976Z" },
+ { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" },
+ { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" },
+ { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" },
+]
+
+[[package]]
+name = "llvmlite"
+version = "0.46.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/a4/3959e1c61c5ca9db7921e5fd115b344c29b9d57a5dadd87bef97963ca1a5/llvmlite-0.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4323177e936d61ae0f73e653e2e614284d97d14d5dd12579adc92b6c2b0597b0", size = 37232766, upload-time = "2025-12-08T18:14:34.765Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a5/a4d916f1015106e1da876028606a8e87fd5d5c840f98c87bc2d5153b6a2f/llvmlite-0.46.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a2d461cb89537b7c20feb04c46c32e12d5ad4f0896c9dfc0f60336219ff248e", size = 56275176, upload-time = "2025-12-08T18:14:37.944Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7f/a7f2028805dac8c1a6fae7bda4e739b7ebbcd45b29e15bf6d21556fcd3d5/llvmlite-0.46.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1f6595a35b7b39c3518b85a28bf18f45e075264e4b2dce3f0c2a4f232b4a910", size = 55128629, upload-time = "2025-12-08T18:14:41.674Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/bc/4689e1ba0c073c196b594471eb21be0aa51d9e64b911728aa13cd85ef0ae/llvmlite-0.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:e7a34d4aa6f9a97ee006b504be6d2b8cb7f755b80ab2f344dda1ef992f828559", size = 38138651, upload-time = "2025-12-08T18:14:45.845Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a1/2ad4b2367915faeebe8447f0a057861f646dbf5fbbb3561db42c65659cf3/llvmlite-0.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82f3d39b16f19aa1a56d5fe625883a6ab600d5cc9ea8906cca70ce94cabba067", size = 37232766, upload-time = "2025-12-08T18:14:48.836Z" },
+ { url = "https://files.pythonhosted.org/packages/12/b5/99cf8772fdd846c07da4fd70f07812a3c8fd17ea2409522c946bb0f2b277/llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3df43900119803bbc52720e758c76f316a9a0f34612a886862dfe0a5591a17e", size = 56275175, upload-time = "2025-12-08T18:14:51.604Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f2/ed806f9c003563732da156139c45d970ee435bd0bfa5ed8de87ba972b452/llvmlite-0.46.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de183fefc8022d21b0aa37fc3e90410bc3524aed8617f0ff76732fc6c3af5361", size = 55128630, upload-time = "2025-12-08T18:14:55.107Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0c/8f5a37a65fc9b7b17408508145edd5f86263ad69c19d3574e818f533a0eb/llvmlite-0.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8b10bc585c58bdffec9e0c309bb7d51be1f2f15e169a4b4d42f2389e431eb93", size = 38138652, upload-time = "2025-12-08T18:14:58.171Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/91/14f32e1d70905c1c0aa4e6609ab5d705c3183116ca02ac6df2091868413a/llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12", size = 55128629, upload-time = "2025-12-08T18:15:19.493Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a7/d526ae86708cea531935ae777b6dbcabe7db52718e6401e0fb9c5edea80e/llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35", size = 38138941, upload-time = "2025-12-08T18:15:22.536Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
+ { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
+ { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
+ { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.10.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" },
+ { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" },
+ { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" },
+ { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" },
+ { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" },
+ { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" },
+ { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" },
+ { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" },
+ { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" },
+ { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" },
+ { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" },
+ { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" },
+ { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" },
+]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
+]
+
+[[package]]
+name = "mne"
+version = "1.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "decorator" },
+ { name = "jinja2" },
+ { name = "lazy-loader" },
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging" },
+ { name = "pooch" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/72/24cd8137df5a185fe0856ff9b6f8a8f9d387d6783c51961a1c6300a4efe8/mne-1.12.1.tar.gz", hash = "sha256:244f844057f28a4da2509039dba637832ffb65f678ca76fc667312c493b12044", size = 7211821, upload-time = "2026-04-20T17:16:57.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/da/a3280dbd8f0024b287b625ef97f8ef79ae853ed852e7732641d0ec3c2160/mne-1.12.1-py3-none-any.whl", hash = "sha256:7823bd276d570e9bed2e63e8d86fdbe74d5ee7817b6d01a8e4dc9510ef9e3a91", size = 7509566, upload-time = "2026-04-20T17:16:54.447Z" },
+]
+
+[[package]]
+name = "mne-connectivity"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mne" },
+ { name = "netcdf4", version = "1.7.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" },
+ { name = "netcdf4", version = "1.7.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or platform_machine != 'ARM64' or sys_platform != 'win32'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+ { name = "scikit-learn" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "tqdm" },
+ { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "xarray", version = "2026.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/f2/cab59b9b156fb77b4704b78a38e10ff551a04452423af4ec8605caf99de7/mne_connectivity-0.8.1.tar.gz", hash = "sha256:bb267d063043c3a8ba66c621ec34cede6a445381e49d386f9c6df35487da4db5", size = 224002, upload-time = "2026-04-11T10:53:46.15Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/f7/0b87a3b9757e2ceb9788626cf36743923d381187851e9cb4d01d98b9b074/mne_connectivity-0.8.1-py3-none-any.whl", hash = "sha256:b8b01fcfcb7cb34e07bb787411915c3c51ebdc490edc855816e198a9bdbadb76", size = 167210, upload-time = "2026-04-11T10:53:44.294Z" },
+]
+
+[[package]]
+name = "narwhals"
+version = "2.22.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/c80cb7719721a44846c6301ef118434bae30a423924bfad3a47f16bdc064/narwhals-2.22.0.tar.gz", hash = "sha256:6486282bb7e4b4ab55963efbd8be1451b764cc4874b74d1fd625eba9dc60b86f", size = 417565, upload-time = "2026-06-01T13:34:36.249Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/b6/e7cdde7b8e90d5dff25b622f95833ef26567ad184c977278b93a1cbd5717/narwhals-2.22.0-py3-none-any.whl", hash = "sha256:1421797ede01789cc1537619dbc3f36f840737240f748fdb24a60a0225fc80be", size = 453815, upload-time = "2026-06-01T13:34:34.127Z" },
+]
+
+[[package]]
+name = "nbformat"
+version = "5.10.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastjsonschema" },
+ { name = "jsonschema" },
+ { name = "jupyter-core" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
+]
+
+[[package]]
+name = "nest-asyncio2"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" },
+]
+
+[[package]]
+name = "netcdf4"
+version = "1.7.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+]
+dependencies = [
+ { name = "certifi", marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" },
+ { name = "cftime", marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" }
+
+[[package]]
+name = "netcdf4"
+version = "1.7.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "certifi", marker = "python_full_version >= '3.11' or platform_machine != 'ARM64' or sys_platform != 'win32'" },
+ { name = "cftime", marker = "python_full_version >= '3.11' or platform_machine != 'ARM64' or sys_platform != 'win32'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/07/dfdd017641e82fadaf4e043d91fa179d34940c7d69175a3034dea877df9c/netcdf4-1.7.4-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b1c1a7ea3678db76bf33d14f7e202385d634db38c5e70d8cf4895971023eebb9", size = 23499427, upload-time = "2026-01-05T02:26:54.13Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6c/8cd98d166f30d378488c5235457d6af7df09f9925ab5ad03d6840543f42e/netcdf4-1.7.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:d3f9497873454207f9480847d02b1b19a4bc81ad6e9166e1c17d4e2f8f3555d1", size = 22886591, upload-time = "2026-01-05T02:26:57.113Z" },
+ { url = "https://files.pythonhosted.org/packages/08/1c/ab31713a95160ebc6b4ec495cd4f03f38b235188a7e955bf33703c5039ca/netcdf4-1.7.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8e18294af803e80f8c0339f791901942e268c334c099bbd5f7ea8325a49801a", size = 10336881, upload-time = "2026-01-05T02:26:59.382Z" },
+ { url = "https://files.pythonhosted.org/packages/26/d7/bb16993af267acda23fe3de4ead2528cbe49043e391f732a1a4a15beec20/netcdf4-1.7.4-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b06c0b93fd0ecc1ec67a582f3ba98b7db9da1fa843c8f83fd75990e3701771e", size = 10182772, upload-time = "2026-01-05T02:27:01.545Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/a6/e6fca338488a896c5e1f661ba3007e83f46700e1a59552b05013d501bc45/netcdf4-1.7.4-cp310-cp310-win_amd64.whl", hash = "sha256:889ba77f084504aebaba9c6f9a88ac213431fef0e897f887cd35aef351ff7740", size = 21363337, upload-time = "2026-01-05T02:27:04.21Z" },
+ { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" },
+ { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3e/1e83534ba68459bc5ae39df46fa71003984df58aabf31f7dcd6e22ecddb0/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56688c03444fffe0d0c7512cb45245e650389cd841c955b30e4552fa681c4cd9", size = 10519821, upload-time = "2026-01-05T02:27:15.413Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/8c/a15d6fe97f81d6d5202b17838a9a298b5955b3e9971e20609195112829b5/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ecf471ba8a6ddb2200121949bedfa0095db228822f38227d5da680694a38358", size = 10371133, upload-time = "2026-01-05T02:27:17.224Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/2b/684b15dd4791f8be295b2f6fa97377bbc07a768478a63b7d3c4951712e36/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5841de0735e8e4875b367c668e81d334287858d64dd9f3e3e2261e808c84922", size = 10395635, upload-time = "2026-01-05T02:27:19.655Z" },
+ { url = "https://files.pythonhosted.org/packages/37/dc/44d21524cf1b1c64254f92e22395a7a10f70c18f3a13a18ac9db258760f7/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fac03a8c5b250d57866e7d98918a64742e4b0de1681c5c86bac5726bab8aee", size = 10237725, upload-time = "2026-01-05T02:27:22.298Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/9d/c3ddf54296ad8f18f02f77f23452bdb0971aece1b87e84bab9d734bf72cc/netcdf4-1.7.4-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ad083d260301b5add74b1669c75ab0df03bdf986decfcc092cb45eec2615b5f1", size = 23515258, upload-time = "2026-01-05T02:27:24.837Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/bc0346e995d436d03fab682b7fbd2a9adcf0db6a05790b8f24853bf08170/netcdf4-1.7.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f22014092cc9da3f056b0368e2e38c42afd5725c87ad4843eb2f467e16dd4f6", size = 22910171, upload-time = "2026-01-05T02:27:27.166Z" },
+ { url = "https://files.pythonhosted.org/packages/30/6b/f9bc3f43c55e2dac72ee9f98d77860789bdd5d50c29adf164a6bdb303078/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224a15434c165a5e0225e5831f591edf62533044b1ce62fdfee815195bbd077d", size = 10567579, upload-time = "2026-01-05T02:27:29.382Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/e7685c66b7f011c73cd746127f986358a26c642a4e4a1aa5ab51481b6586/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31a2318305de6831a18df25ad0df9f03b6d68666af0356d4f6057d66c02ffeb6", size = 10255032, upload-time = "2026-01-05T02:27:31.744Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/14/7506738bb6c8bc373b01e5af8f3b727f83f4f496c6b108490ea2609dc2cf/netcdf4-1.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:6c4a0aa9446c3a616ef3be015b629dc6173643f8b09546de26a4e40e272cd1ed", size = 22289653, upload-time = "2026-01-05T02:27:34.294Z" },
+ { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" },
+]
+
+[[package]]
+name = "nitime"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/50/66149902758d3175e6243baa04cb864333a4a1cf91685b3016e0a16d4797/nitime-0.12.1.tar.gz", hash = "sha256:12cbf488b0655dc05aa18a0ca5982de970302604e461fc92e87ebd9b7539b6ff", size = 6240292, upload-time = "2025-11-06T19:38:51.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/ec/26ac08b00584a93c54adbd485547b4841d56ce89e6a549a46f3e21fd815f/nitime-0.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba6244f66a4864d188d1d34c45982f003135b93251211c7fa0d3b9cec81c1a4f", size = 4063123, upload-time = "2025-11-06T19:38:32.611Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2e/4463ee4433caa2b5447ef0d670a5ec1d195684fb0247d49f5b6b81a5b438/nitime-0.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa9353bc68d59b0d0c39f8f46e3b108cb29637bc104d6c1dabf810e8e8dd086f", size = 4063625, upload-time = "2025-11-06T19:38:34.168Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ee/944f81244f0b429850f4fee85d81ad7cc02572597029cdf6d00ea1467874/nitime-0.12.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:82b5965d98fd136bc4d32ed2aec71da34bb89c86c302b04f8f1087d2c8a3338f", size = 4141029, upload-time = "2025-11-06T19:38:35.66Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b3/e34c06c5e9848ad194c4459f05212f6d10e2bd4d596bbc1b29ef977f33d7/nitime-0.12.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca4eb915a5a0b752621994e152f215ca41e9aba38d23eafb3bf43e060ab2fbe3", size = 4140818, upload-time = "2025-11-06T19:38:36.835Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d7/775b7f0710465c41e6ca83f0b6fd72292f25bf392fdb2764574d6a27bb10/nitime-0.12.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37d7a406d505f0a35c083ce8f8f3c6f1dc6ae2f63610dafda38fccdd537762a", size = 4140981, upload-time = "2025-11-06T19:38:38.814Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/df/e1ba8bcb9783f724e50515d47ad27619341819a0c74392fb31cdbd26ed3a/nitime-0.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:d60259426d6e0becd48570882eaa755dd5d2d05563586b5a7861320e20486e14", size = 4062609, upload-time = "2025-11-06T19:38:39.947Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0b/cfdfe0377f1f3358ce1a856d077ec01321469181d9e4b9d75a2c26fb8bbc/nitime-0.12.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5eca85c522c270b52ec664b6a82c10b1a2799379c86f9095987cd9f3974f2365", size = 4062457, upload-time = "2025-11-06T19:38:41.456Z" },
+ { url = "https://files.pythonhosted.org/packages/70/1c/c7d22397e927c52b2a92c16f0f21abe19f7c9d5f5fba7dcc3c80918c4fdf/nitime-0.12.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ed4f45daa97a9476fc3fb2db55ba86a758f9d896f0f8386d0026a38d49181987", size = 4063991, upload-time = "2025-11-06T19:38:42.648Z" },
+ { url = "https://files.pythonhosted.org/packages/88/5d/ae275feedaa6590cf6e185cfeb7c4d28b5e1a10fcb492fd5340b07921675/nitime-0.12.1-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19442111829b94fa9aa9849bdfb6aa92bc5467ba74194b18450728e432086882", size = 4136941, upload-time = "2025-11-06T19:38:45.739Z" },
+ { url = "https://files.pythonhosted.org/packages/04/ef/9db6224bea5d984e6a454bba599c05f7e2b8582bda1b127a1a9e65c9c934/nitime-0.12.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e056996455faf667ff251fc654b1d2e4963442273edb06b3d72b2b9beaf3d64a", size = 4136895, upload-time = "2025-11-06T19:38:47.277Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c0/3ca98f86ebd98b023a25d92dea076e8b055c19d5b5f9affb7136b607f723/nitime-0.12.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:43bf5b400939b0b940240b1d48a863d25541c28860bf1f07ce655c95577d1f67", size = 4136159, upload-time = "2025-11-06T19:38:48.468Z" },
+ { url = "https://files.pythonhosted.org/packages/21/72/526b8a1a64f26ae9171b51587d3691f13afeb344942c28e371b0878d173a/nitime-0.12.1-cp311-abi3-win_amd64.whl", hash = "sha256:3b54531c0de10860dedcfba0dee2dd4e99d4cf70021ad0a38110e60a5be60552", size = 4062999, upload-time = "2025-11-06T19:38:49.904Z" },
+]
+
+[[package]]
+name = "numba"
+version = "0.61.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "llvmlite", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663, upload-time = "2025-04-09T02:57:34.143Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344, upload-time = "2025-04-09T02:57:36.609Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054, upload-time = "2025-04-09T02:57:38.162Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531, upload-time = "2025-04-09T02:57:39.709Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612, upload-time = "2025-04-09T02:57:41.559Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/97/c99d1056aed767503c228f7099dc11c402906b42a4757fec2819329abb98/numba-0.61.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:efd3db391df53aaa5cfbee189b6c910a5b471488749fd6606c3f33fc984c2ae2", size = 2775825, upload-time = "2025-04-09T02:57:43.442Z" },
+ { url = "https://files.pythonhosted.org/packages/95/9e/63c549f37136e892f006260c3e2613d09d5120672378191f2dc387ba65a2/numba-0.61.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49c980e4171948ffebf6b9a2520ea81feed113c1f4890747ba7f59e74be84b1b", size = 2778695, upload-time = "2025-04-09T02:57:44.968Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/8740616c8436c86c1b9a62e72cb891177d2c34c2d24ddcde4c390371bf4c/numba-0.61.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3945615cd73c2c7eba2a85ccc9c1730c21cd3958bfcf5a44302abae0fb07bb60", size = 3829227, upload-time = "2025-04-09T02:57:46.63Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/06/66e99ae06507c31d15ff3ecd1f108f2f59e18b6e08662cd5f8a5853fbd18/numba-0.61.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbfdf4eca202cebade0b7d43896978e146f39398909a42941c9303f82f403a18", size = 3523422, upload-time = "2025-04-09T02:57:48.222Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/a4/2b309a6a9f6d4d8cfba583401c7c2f9ff887adb5d54d8e2e130274c0973f/numba-0.61.2-cp311-cp311-win_amd64.whl", hash = "sha256:76bcec9f46259cedf888041b9886e257ae101c6268261b19fda8cfbc52bec9d1", size = 2831505, upload-time = "2025-04-09T02:57:50.108Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" },
+ { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" },
+ { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" },
+ { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" },
+ { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" },
+]
+
+[[package]]
+name = "numba"
+version = "0.63.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "llvmlite", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/ce/5283d4ffa568f795bb0fd61ee1f0efc0c6094b94209259167fc8d4276bde/numba-0.63.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6d6bf5bf00f7db629305caaec82a2ffb8abe2bf45eaad0d0738dc7de4113779", size = 2680810, upload-time = "2025-12-10T02:56:55.269Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/72/a8bda517e26d912633b32626333339b7c769ea73a5c688365ea5f88fd07e/numba-0.63.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08653d0dfc9cc9c4c9a8fba29ceb1f2d5340c3b86c4a7e5e07e42b643bc6a2f4", size = 3739735, upload-time = "2025-12-10T02:56:57.922Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/17/1913b7c1173b2db30fb7a9696892a7c4c59aeee777a9af6859e9e01bac51/numba-0.63.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09eebf5650246ce2a4e9a8d38270e2d4b0b0ae978103bafb38ed7adc5ea906e", size = 3446707, upload-time = "2025-12-10T02:56:59.837Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/77/703db56c3061e9fdad5e79c91452947fdeb2ec0bdfe4affe9b144e7025e0/numba-0.63.1-cp310-cp310-win_amd64.whl", hash = "sha256:f8bba17421d865d8c0f7be2142754ebce53e009daba41c44cf6909207d1a8d7d", size = 2747374, upload-time = "2025-12-10T02:57:07.908Z" },
+ { url = "https://files.pythonhosted.org/packages/70/90/5f8614c165d2e256fbc6c57028519db6f32e4982475a372bbe550ea0454c/numba-0.63.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b33db00f18ccc790ee9911ce03fcdfe9d5124637d1ecc266f5ae0df06e02fec3", size = 2680501, upload-time = "2025-12-10T02:57:09.797Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/9d/d0afc4cf915edd8eadd9b2ab5b696242886ee4f97720d9322650d66a88c6/numba-0.63.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d31ea186a78a7c0f6b1b2a3fe68057fdb291b045c52d86232b5383b6cf4fc25", size = 3744945, upload-time = "2025-12-10T02:57:11.697Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a9/d82f38f2ab73f3be6f838a826b545b80339762ee8969c16a8bf1d39395a8/numba-0.63.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed3bb2fbdb651d6aac394388130a7001aab6f4541837123a4b4ab8b02716530c", size = 3450827, upload-time = "2025-12-10T02:57:13.709Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3f/a9b106e93c5bd7434e65f044bae0d204e20aa7f7f85d72ceb872c7c04216/numba-0.63.1-cp311-cp311-win_amd64.whl", hash = "sha256:1ecbff7688f044b1601be70113e2fb1835367ee0b28ffa8f3adf3a05418c5c87", size = 2747262, upload-time = "2025-12-10T02:57:15.664Z" },
+ { url = "https://files.pythonhosted.org/packages/14/9c/c0974cd3d00ff70d30e8ff90522ba5fbb2bcee168a867d2321d8d0457676/numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71", size = 2680981, upload-time = "2025-12-10T02:57:17.579Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/70/ea2bc45205f206b7a24ee68a159f5097c9ca7e6466806e7c213587e0c2b1/numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f", size = 3801656, upload-time = "2025-12-10T02:57:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/82/4f4ba4fd0f99825cbf3cdefd682ca3678be1702b63362011de6e5f71f831/numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0", size = 3501857, upload-time = "2025-12-10T02:57:20.721Z" },
+ { url = "https://files.pythonhosted.org/packages/af/fd/6540456efa90b5f6604a86ff50dabefb187e43557e9081adcad3be44f048/numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3", size = 2750282, upload-time = "2025-12-10T02:57:22.474Z" },
+ { url = "https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc", size = 2680954, upload-time = "2025-12-10T02:57:24.145Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6c/1e222edba1e20e6b113912caa9b1665b5809433cbcb042dfd133c6f1fd38/numba-0.63.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53de693abe4be3bd4dee38e1c55f01c55ff644a6a3696a3670589e6e4c39cde2", size = 3809736, upload-time = "2025-12-10T02:57:25.836Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0a/590bad11a8b3feeac30a24d01198d46bdb76ad15c70d3a530691ce3cae58/numba-0.63.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81227821a72a763c3d4ac290abbb4371d855b59fdf85d5af22a47c0e86bf8c7e", size = 3508854, upload-time = "2025-12-10T02:57:27.438Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f5/3800384a24eed1e4d524669cdbc0b9b8a628800bb1e90d7bd676e5f22581/numba-0.63.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb227b07c2ac37b09432a9bda5142047a2d1055646e089d4a240a2643e508102", size = 2750228, upload-time = "2025-12-10T02:57:30.36Z" },
+ { url = "https://files.pythonhosted.org/packages/36/2f/53be2aa8a55ee2608ebe1231789cbb217f6ece7f5e1c685d2f0752e95a5b/numba-0.63.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f180883e5508940cc83de8a8bea37fc6dd20fbe4e5558d4659b8b9bef5ff4731", size = 2681153, upload-time = "2025-12-10T02:57:32.016Z" },
+ { url = "https://files.pythonhosted.org/packages/13/91/53e59c86759a0648282368d42ba732c29524a745fd555ed1fb1df83febbe/numba-0.63.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0938764afa82a47c0e895637a6c55547a42c9e1d35cac42285b1fa60a8b02bb", size = 3778718, upload-time = "2025-12-10T02:57:33.764Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/0c/2be19eba50b0b7636f6d1f69dfb2825530537708a234ba1ff34afc640138/numba-0.63.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f90a929fa5094e062d4e0368ede1f4497d5e40f800e80aa5222c4734236a2894", size = 3478712, upload-time = "2025-12-10T02:57:35.518Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/5f/4d0c9e756732577a52211f31da13a3d943d185f7fb90723f56d79c696caa/numba-0.63.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d6d5ce85f572ed4e1a135dbb8c0114538f9dd0e3657eeb0bb64ab204cbe2a8f", size = 2752161, upload-time = "2025-12-10T02:57:37.12Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.2.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+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.3.5"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" },
+ { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" },
+ { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" },
+ { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" },
+ { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" },
+ { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" },
+ { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" },
+ { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" },
+ { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" },
+ { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" },
+ { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" },
+ { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" },
+ { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" },
+ { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" },
+ { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" },
+ { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" },
+ { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" },
+ { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" },
+ { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" },
+ { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" },
+ { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" },
+ { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" },
+ { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" },
+ { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" },
+ { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" },
+ { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
+]
+
+[[package]]
+name = "pandas"
+version = "2.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
+]
+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" },
+]
+
+[[package]]
+name = "parso"
+version = "0.8.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
+]
+
+[[package]]
+name = "patsy"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
+ { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
+ { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[package]]
+name = "plotly"
+version = "6.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "narwhals" },
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8", size = 9902055, upload-time = "2026-06-03T18:33:34.26Z" },
+]
+
+[[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 = "pooch"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pyedm"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+ { name = "scikit-learn" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/d3/1699ac3ccbb2cf4d5fc1c3af6f17a0ab317147a033489024c341a1b7463b/pyedm-2.5.0.tar.gz", hash = "sha256:6eb9d7724389154a3fcb037e55c79a0590eccf7fc3f0c72c6e344a10e2942a5f", size = 146555, upload-time = "2026-04-17T20:36:30.985Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/7c/6cc18c76113a16e76137f496f68c00563dad1ce7c0bc3dc8646e625feaf8/pyedm-2.5.0-py3-none-any.whl", hash = "sha256:cb6c22accc06ffd5f402c25800d87cbb33920babf8d495e1da5a88afbcdb110d", size = 170834, upload-time = "2026-04-17T20:36:29.554Z" },
+]
+
+[[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 = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
+name = "pyspi"
+version = "3.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "colorama" },
+ { name = "dtaidistance" },
+ { name = "hyppo" },
+ { name = "mne" },
+ { name = "mne-connectivity" },
+ { name = "nitime" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+ { name = "pyedm" },
+ { name = "pyyaml" },
+ { name = "scikit-learn" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "spectral-connectivity" },
+ { name = "statsmodels" },
+ { name = "tqdm" },
+ { name = "tslearn" },
+]
+
+[package.optional-dependencies]
+bench = [
+ { name = "ipykernel" },
+ { name = "matplotlib" },
+ { name = "nbformat" },
+ { name = "plotly" },
+ { name = "psutil" },
+ { name = "seaborn" },
+]
+testing = [
+ { name = "pytest" },
+]
+tsfile = [
+ { name = "aeon", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "aeon", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "ipykernel" },
+ { name = "matplotlib" },
+ { name = "nbformat" },
+ { name = "plotly" },
+ { name = "psutil" },
+ { name = "pytest" },
+ { name = "seaborn" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aeon", marker = "extra == 'tsfile'", specifier = ">=1.0" },
+ { name = "colorama", specifier = ">=0.4" },
+ { name = "dtaidistance", specifier = ">=2.3" },
+ { name = "hyppo", specifier = ">=0.4" },
+ { name = "ipykernel", marker = "extra == 'bench'", specifier = ">=6.29" },
+ { name = "matplotlib", marker = "extra == 'bench'", specifier = ">=3.8" },
+ { name = "mne", specifier = ">=1.6" },
+ { name = "mne-connectivity", specifier = ">=0.5" },
+ { name = "nbformat", marker = "extra == 'bench'", specifier = ">=5.10" },
+ { name = "nitime", specifier = ">=0.10" },
+ { name = "numpy", specifier = ">=2.0" },
+ { name = "pandas", specifier = ">=2.1" },
+ { name = "plotly", marker = "extra == 'bench'", specifier = ">=5.20" },
+ { name = "psutil", marker = "extra == 'bench'", specifier = ">=5.9" },
+ { name = "pyedm", specifier = ">=2.5" },
+ { name = "pytest", marker = "extra == 'testing'", specifier = ">=7" },
+ { name = "pyyaml", specifier = ">=6.0" },
+ { name = "scikit-learn", specifier = ">=1.3" },
+ { name = "scipy", specifier = ">=1.11" },
+ { name = "seaborn", marker = "extra == 'bench'", specifier = ">=0.13" },
+ { name = "spectral-connectivity", specifier = ">=1.1,<3" },
+ { name = "statsmodels", specifier = ">=0.14" },
+ { name = "tqdm", specifier = ">=4.65" },
+ { name = "tslearn", specifier = ">=0.6" },
+]
+provides-extras = ["testing", "tsfile", "bench"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "ipykernel", specifier = ">=6.29" },
+ { name = "matplotlib", specifier = ">=3.8" },
+ { name = "nbformat", specifier = ">=5.10" },
+ { name = "plotly", specifier = ">=5.20" },
+ { name = "psutil", specifier = ">=5.9" },
+ { name = "pytest", specifier = ">=7" },
+ { name = "seaborn", specifier = ">=0.13" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[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.1.post1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "pyzmq"
+version = "27.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "implementation_name == 'pypy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" },
+ { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" },
+ { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" },
+ { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" },
+ { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" },
+ { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" },
+ { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" },
+ { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" },
+ { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
+ { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
+ { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
+ { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
+ { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
+ { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
+ { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
+ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" },
+ { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" },
+ { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" },
+ { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" },
+ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" },
+ { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" },
+ { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "2026.5.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" },
+ { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" },
+ { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" },
+ { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" },
+ { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" },
+ { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" },
+ { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" },
+ { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" },
+ { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" },
+ { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" },
+ { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" },
+ { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" },
+ { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" },
+ { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" },
+ { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" },
+ { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" },
+ { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" },
+ { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" },
+ { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" },
+ { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" },
+ { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" },
+ { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" },
+ { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" },
+ { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" },
+ { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" },
+ { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" },
+ { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" },
+ { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" },
+ { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" },
+ { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" },
+ { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" },
+ { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" },
+ { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" },
+ { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" },
+ { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" },
+ { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" },
+ { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" },
+ { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" },
+ { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" },
+ { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" },
+ { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" },
+ { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "threadpoolctl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" },
+ { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" },
+ { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" },
+ { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" },
+ { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" },
+ { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" },
+ { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" },
+ { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" },
+ { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.15.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" },
+ { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" },
+ { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" },
+ { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" },
+ { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" },
+ { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" },
+ { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" },
+ { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" },
+ { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" },
+ { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" },
+ { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" },
+ { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" },
+ { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
+ { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
+ { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
+ { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
+ { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
+ { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
+ { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
+ { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
+ { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
+ { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
+ { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
+ { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
+ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
+]
+
+[[package]]
+name = "seaborn"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" },
+]
+
+[[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 = "spectral-connectivity"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "xarray", version = "2026.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/89/1005e90800b4e278e8ff659ddd5c3611a35e1e27c5131ec593aa1e383351/spectral_connectivity-2.0.0.tar.gz", hash = "sha256:521cd70ab01195c13e7ec1adeff96fee56e07dcd63974951420af9e7519bd45a", size = 5464032, upload-time = "2025-10-27T14:36:34.472Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/fe/9ba615a3035b470c1167cbf478a17e2c98789fb6537770397fe858a1d005/spectral_connectivity-2.0.0-py3-none-any.whl", hash = "sha256:d78c77818bbf715dbdaa09e425ba40071085b080ee6c51a0b4f3075ffc2626f7", size = 71622, upload-time = "2025-10-27T14:36:32.801Z" },
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
+[[package]]
+name = "statsmodels"
+version = "0.14.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging" },
+ { name = "pandas" },
+ { name = "patsy" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" },
+ { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" },
+ { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" },
+ { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" },
+ { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" },
+ { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" },
+ { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" },
+ { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" },
+ { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" },
+ { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" },
+ { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" },
+]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+]
+
+[[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 = "tornado"
+version = "6.5.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "traitlets"
+version = "5.15.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
+]
+
+[[package]]
+name = "tslearn"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numba", version = "0.63.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "scikit-learn" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/08/8447f48fe3547ca92abae6875c63a7374b14b78f3b46518e16de45be640d/tslearn-0.8.1.tar.gz", hash = "sha256:68107857404e9a3869ed452fc3b8f1f0b5fdee61a04438c7ee957ec7e928e594", size = 9602140, upload-time = "2026-03-13T13:38:30.425Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/da/969c0efec60dcf52fb7b9ed9244a4913b2a7f0a266a0be4700cc1141471e/tslearn-0.8.1-py3-none-any.whl", hash = "sha256:2a79e1447df8c0708cfb8b06a65826ecb9b3d62e1b5b98fa26abbc77766f782d", size = 387871, upload-time = "2026-03-13T13:38:28.525Z" },
+]
+
+[[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.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" },
+ { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" },
+ { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" },
+ { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
+ { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
+ { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
+ { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
+ { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
+ { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
+ { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
+ { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
+ { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
+ { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
+ { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
+ { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
+ { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
+]
+
+[[package]]
+name = "xarray"
+version = "2025.6.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "packaging", marker = "python_full_version < '3.11'" },
+ { name = "pandas", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/8a/6b50c1dd2260d407c1a499d47cf829f59f07007e0dcebafdabb24d1d26a5/xarray-2025.6.1-py3-none-any.whl", hash = "sha256:8b988b47f67a383bdc3b04c5db475cd165e580134c1f1943d52aee4a9c97651b", size = 1314739, upload-time = "2025-06-12T03:04:06.708Z" },
+]
+
+[[package]]
+name = "xarray"
+version = "2026.4.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and platform_machine != 'ARM64' 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.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'",
+ "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version == '3.11.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+dependencies = [
+ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging", marker = "python_full_version >= '3.11'" },
+ { name = "pandas", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/a6/6fe936a798a3a38a79c7422d1a31afd2e9a14690fcb0ccff96bc01f04bf2/xarray-2026.4.0.tar.gz", hash = "sha256:c4ac9a01a945d90d5b1628e2af045099a9d4943536d4f2ee3ae963c3b222d15b", size = 3132311, upload-time = "2026-04-13T19:45:36.688Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" },
+]