diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..6260b94e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: tests + +on: + pull_request: + branches: [main, improved-semg] + push: + branches: [main, improved-semg] + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12.8" + + - name: System packages for NEURON / MPI + run: | + sudo apt-get update + sudo apt-get install -y libopenmpi-dev openmpi-bin libgtk2.0-dev + + - name: Install uv + run: | + python -m pip install --upgrade pip + python -m pip install uv + + - name: Install project with dev group + run: | + uv sync --group dev + + - name: Compile NEURON mechanisms + run: uv run poe setup_myogen + + - name: Run pytest + run: uv run pytest tests/ -v diff --git a/.gitignore b/.gitignore index 805e3729..5e58b558 100644 --- a/.gitignore +++ b/.gitignore @@ -175,10 +175,14 @@ test_*.png myogen/simulator/neuron/_cython/*.c myogen/simulator/nmodl_files/x86_64/ +myogen/simulator/nmodl_files/arm64/ myogen/simulator/x86_64/ +myogen/simulator/arm64/ myogen/simulator/nmodl_files/*.c myogen/simulator/nmodl_files/*.o myogen/simulator/nmodl_files/*.dll +myogen/simulator/nmodl_files/*.dylib +myogen/simulator/nmodl_files/*.so data/ # Note: examples/data/ is NOT ignored - it contains pre-generated static data for docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7f419..ef062604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **RNG accessors**: new public API for reproducible random draws + - `myogen.get_random_generator()` — always returns the current global RNG (tracks the latest `set_random_seed` call) + - `myogen.get_random_seed()` — returns the seed currently in effect + - `myogen.derive_subseed(*labels)` — deterministic sub-seed helper for seeding non-NumPy generators (Cython spike generators, sklearn `random_state`, etc.) so they also track `set_random_seed` +- **Optional `elephant` extra**: install via `pip install myogen[elephant]`. The extra bundles both `elephant>=1.1.1` and `viziphant>=0.4.0` (viziphant was moved out of core dependencies because its only transitive import chain in core came from `elephant`). Core install is now genuinely elephant-free — verified with `importlib.util.find_spec('elephant') is None` on a fresh `uv sync`. The five modules that import elephant (`utils/helper.py`, `surface_emg.py`, `intramuscular_emg.py`, `force_model.py`, `force_model_vectorized.py`) were already guarded with `try/except ImportError` and now emit an accurate install hint when the extra is missing. `neo>=0.14.0` is now an explicit core dependency — it was previously only reached transitively through `elephant`/`viziphant`, so removing those would otherwise break `import myogen` +- **Test suite** under `tests/` + - `test_determinism.py` — 10 regressions covering seed propagation, sub-seed collision avoidance, and deprecation warnings for the legacy RNG names + - `test_firing_rate_statistics.py` — 4 regressions covering `FR_std` and `CV_ISI` NaN edge cases + - Added `pytest>=8.0` to the `dev` dependency-group and a `[tool.pytest.ini_options]` section +- **Per-muscle ISI/CV figure**: new `plot_cv_vs_fr_per_muscle()` in `examples/02_finetune/05_plot_isi_cv_multi_muscle_comparison.py` emits a 3-panel VL / VM / FDI breakdown alongside the existing pooled plot + +### Changed +- **RNG architecture** (internal refactor): the six internal modules and eight example scripts that previously did `from myogen import RANDOM_GENERATOR, SEED` at module scope now call the accessor at each site, eliminating the stale-reference bug where `set_random_seed` didn't reach downstream modules or seed-derived generators +- **Per-cell seed derivation**: the three sites in `myogen/simulator/neuron/cells.py` that built a per-cell seed as `SEED + (class_id+1)*(global_id+1)` — a formula that collided on swapped factors, e.g. `(0, 5)` and `(1, 2)` both resolved to `+6` — now use `derive_subseed(class_id, global_id)` (collision-free with respect to label order, built on `numpy.random.SeedSequence`). The `motor_unit_sim.py` `KMeans` `random_state` uses the same helper. **Consequence**: for a given global seed, RNG output differs from earlier releases; byte-identical reproduction of pre-`Unreleased` outputs is not possible without matching code +- **Example figures**: bar graphs in `02_finetune/01_optimize_dd_for_target_firing_rate.py`, `02_finetune/02_compute_force_from_optimized_dd.py` and `03_papers/watanabe/01_compute_baseline_force.py` replaced with dumbbell / violin + box + jittered-scatter plots that show the underlying distribution (all points when n<10, violin + box when n≥10) +- **NEURON version alignment**: `README.md`, `docs/source/README.md`, `docs/source/index.md`, and `setup.py` Windows-install messaging now reference NEURON **8.2.7** (matching the Linux/macOS pip pin in `pyproject.toml` and the CI workflow) with the correct installer filename (`py-39-310-311-312-313`) +- **Docs build**: pinned `sphinx<9` in the `docs` dependency-group to work around a `sphinx-hoverxref` regression on Sphinx 9, and moved `[tool.pytest.ini_options]` in `pyproject.toml` so it no longer re-parents the `docs` dependency-group + +### Deprecated +- **`myogen.RANDOM_GENERATOR`** and **`myogen.SEED`** module attributes. They remain accessible via a module-level `__getattr__` that emits a `DeprecationWarning` and returns the current RNG / current seed. External code should migrate to `get_random_generator()` / `get_random_seed()`; module-level `from myogen import RANDOM_GENERATOR` captures a stale reference and does not reflect later `set_random_seed` calls + +### Fixed +- **`FR_std = NaN` with a single active unit**: `myogen/utils/helper.py::calculate_firing_rate_statistics` now returns `FR_std = 0.0` when fewer than two units pass the firing-rate filter, instead of propagating the `np.std(..., ddof=1)` NaN into downstream ensemble statistics +- **`CV_ISI = NaN` for n=1 ISI**: same function's per-neuron branch now returns `0.0` when `min_spikes_for_cv` is set to 2 and a neuron has exactly two spikes +- **`set_random_seed` did not propagate**: module-level `from myogen import RANDOM_GENERATOR, SEED` imports in the internal modules captured a stale reference at import time; reseeding the package rebinding did not affect them. The accessor refactor above is the fix +- **Typos / placeholder docstrings**: `extandable` → `extensible` in README and docs; `"as determined by no one"` docstring comments in `myogen/simulator/core/muscle/muscle.py` replaced with concrete physiological rationale + ## [0.8.5] - 2026-01-15 ### Fixed diff --git a/README.md b/README.md index 1762a49c..c77a8a50 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MyoGen Logo -

The modular and extandable simulation toolkit for neurophysiology

+

The modular and extensible simulation toolkit for neurophysiology

[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://nsquaredlab.github.io/MyoGen/) [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) @@ -47,7 +47,7 @@ MyoGen is designed for algorithm validation, hypothesis-driven research, and edu | Platform | Before Installing MyoGen | |----------|--------------------------| -| **Windows** | [NEURON 8.2.6](https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe) - Download, run installer, select "Add to PATH" | +| **Windows** | [NEURON 8.2.7](https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe) - Download, run installer, select "Add to PATH" | | **Linux** | `sudo apt install libopenmpi-dev` (Ubuntu/Debian) or `sudo dnf install openmpi-devel` (Fedora) | | **macOS** | `brew install open-mpi` | @@ -70,7 +70,7 @@ MyoGen is designed for algorithm validation, hypothesis-driven research, and edu > > ### 2. NEURON Simulator > -> 1. **Download**: [NEURON 8.2.6 Installer](https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe) +> 1. **Download**: [NEURON 8.2.7 Installer](https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe) > 2. **Run the installer** and select **"Add to PATH"** when prompted > 3. **Restart your terminal** (close and reopen) > 4. Then continue with the installation below diff --git a/docs/source/README.md b/docs/source/README.md index c9bfb3d2..3460ae3f 100644 --- a/docs/source/README.md +++ b/docs/source/README.md @@ -13,7 +13,7 @@ [Paper](#citation) -# MyoGen - The modular and extandable simulation toolkit for neurophysiology +# MyoGen - The modular and extensible simulation toolkit for neurophysiology MyoGen is a **modular and extensible neuromuscular simulation framework** for generating physiologically grounded motor-unit activity, muscle force, and surface EMG signals. @@ -39,7 +39,7 @@ MyoGen is designed for algorithm validation, hypothesis-driven research, and edu **Prerequisites**: Python ≥3.12, Linux/Windows/macOS > [!IMPORTANT] -> **Windows users**: Install [NEURON 8.2.6](https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe) before running `uv sync` +> **Windows users**: Install [NEURON 8.2.7](https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe) before running `uv sync` ```bash # Clone and install diff --git a/docs/source/index.md b/docs/source/index.md index 45dea341..2096d77b 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -4,7 +4,7 @@ MyoGen Logo -

The modular and extandable simulation toolkit for neurophysiology

+

The modular and extensible simulation toolkit for neurophysiology

[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://nsquaredlab.github.io/MyoGen/) [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) @@ -47,7 +47,7 @@ MyoGen is designed for algorithm validation, hypothesis-driven research, and edu | Platform | Before Installing MyoGen | |----------|--------------------------| -| **Windows** | [NEURON 8.2.6](https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe) - Download, run installer, select "Add to PATH" | +| **Windows** | [NEURON 8.2.7](https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe) - Download, run installer, select "Add to PATH" | | **Linux** | `sudo apt install libopenmpi-dev` (Ubuntu/Debian) or `sudo dnf install openmpi-devel` (Fedora) | | **macOS** | `brew install open-mpi` | @@ -70,7 +70,7 @@ MyoGen is designed for algorithm validation, hypothesis-driven research, and edu > > ### 2. NEURON Simulator > -> 1. **Download**: [NEURON 8.2.6 Installer](https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe) +> 1. **Download**: [NEURON 8.2.7 Installer](https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe) > 2. **Run the installer** and select **"Add to PATH"** when prompted > 3. **Restart your terminal** (close and reopen) > 4. Then continue with the installation below diff --git a/examples/01_basic/02_simulate_spike_trains_current_injection.py b/examples/01_basic/02_simulate_spike_trains_current_injection.py index a03e0dd8..32560daa 100644 --- a/examples/01_basic/02_simulate_spike_trains_current_injection.py +++ b/examples/01_basic/02_simulate_spike_trains_current_injection.py @@ -20,17 +20,17 @@ # ---------------- # # .. important:: -# In **MyoGen** all **random number generation** is handled by the :data:`~myogen.RANDOM_GENERATOR` object. +# In **MyoGen** all **random number generation** is handled by the RNG returned from +# :func:`~myogen.get_random_generator`, a thin wrapper around :mod:`numpy.random`. # -# This object is a wrapper around the :mod:`numpy.random` module and is used to generate random numbers. -# -# It is intended to be used with the following API: +# Always fetch the generator at the call site so the current seed is honored: # # .. code-block:: python # -# from myogen import simulator, RANDOM_GENERATOR +# from myogen import simulator, get_random_generator +# get_random_generator().normal(0, 1) # -# To change the default seed, use :func:`~myogen.set_random_seed`: +# To change the seed, use :func:`~myogen.set_random_seed`: # # .. code-block:: python # @@ -50,7 +50,7 @@ from neuron import h from viziphant.rasterplot import rasterplot_rates -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator.neuron.populations import AlphaMN__Pool from myogen.utils.currents import create_trapezoid_current from myogen.utils.neuron.inject_currents_into_populations import ( @@ -107,9 +107,9 @@ timestep = 0.05 * pq.ms simulation_time = 4000 * pq.ms -rise_time_ms = list(RANDOM_GENERATOR.uniform(100, 500, size=n_pools)) * pq.ms -plateau_time_ms = list(RANDOM_GENERATOR.uniform(1000, 2000, size=n_pools)) * pq.ms -fall_time_ms = list(RANDOM_GENERATOR.uniform(1000, 2000, size=n_pools)) * pq.ms +rise_time_ms = list(get_random_generator().uniform(100, 500, size=n_pools)) * pq.ms +plateau_time_ms = list(get_random_generator().uniform(1000, 2000, size=n_pools)) * pq.ms +fall_time_ms = list(get_random_generator().uniform(1000, 2000, size=n_pools)) * pq.ms input_current__AnalogSignal = create_trapezoid_current( n_pools, diff --git a/examples/01_basic/03_simulate_spike_trains_descending_drive.py b/examples/01_basic/03_simulate_spike_trains_descending_drive.py index 49b0a6a3..5ab2a9a4 100644 --- a/examples/01_basic/03_simulate_spike_trains_descending_drive.py +++ b/examples/01_basic/03_simulate_spike_trains_descending_drive.py @@ -28,17 +28,17 @@ # ---------------- # # .. important:: -# In **MyoGen** all **random number generation** is handled by the ``RANDOM_GENERATOR`` object. +# In **MyoGen** all **random number generation** is handled by the RNG returned from +# ``get_random_generator()``, a thin wrapper around ``numpy.random``. # -# This object is a wrapper around the ``numpy.random`` module and is used to generate random numbers. -# -# It is intended to be used with the following API: +# Always fetch the generator at the call site so the current seed is honored: # # .. code-block:: python # -# from myogen import simulator, RANDOM_GENERATOR +# from myogen import simulator, get_random_generator +# get_random_generator().normal(0, 1) # -# To change the default seed, use ``set_random_seed``: +# To change the seed, use ``set_random_seed``: # # .. code-block:: python # @@ -59,7 +59,7 @@ from neuron import h from tqdm import tqdm -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator.neuron import Network from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool from myogen.utils.nmodl import load_nmodl_mechanisms @@ -161,7 +161,7 @@ # Add small noise for realism trapezoid_drive = ( - trapezoid_drive + np.clip(RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None) * pps + trapezoid_drive + np.clip(get_random_generator().normal(0, 1.0, size=time_points), 0, None) * pps ) # Create AnalogSignal diff --git a/examples/02_finetune/01_optimize_dd_for_target_firing_rate.py b/examples/02_finetune/01_optimize_dd_for_target_firing_rate.py index 2a9ffbbe..8bd36b8a 100644 --- a/examples/02_finetune/01_optimize_dd_for_target_firing_rate.py +++ b/examples/02_finetune/01_optimize_dd_for_target_firing_rate.py @@ -56,7 +56,7 @@ from neo import Segment, SpikeTrain from neuron import h -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator import RecruitmentThresholds from myogen.simulator.neuron import Network from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool @@ -224,7 +224,7 @@ def objective(trial): # Generate constant drive signal with small noise time_points = int(SIMULATION_TIME_MS / TIMESTEP_MS) drive_signal = np.ones(time_points) * dd_drive__Hz + np.clip( - RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None + get_random_generator().normal(0, 1.0, size=time_points), 0, None ) # Run NEURON simulation @@ -504,7 +504,9 @@ def trial_to_dict(t): fig, ax = plt.subplots(1, 1, figsize=(10, 6)) -# Bar plot comparing target vs achieved +# Dumbbell plot comparing target vs achieved (two scalar endpoints per +# category — not a distribution, so Editor 8's "show all data points" +# rule is honoured by marking each value as an explicit point). categories = ["Mean FR (Hz)", "Std FR (Hz)"] targets = [TARGET_FR_MEAN__HZ, TARGET_FR_STD__HZ] achieved = [ @@ -513,22 +515,16 @@ def trial_to_dict(t): ] x = np.arange(len(categories)) -width = 0.35 - -bars1 = ax.bar(x - width / 2, targets, width, label="Target") -bars2 = ax.bar(x + width / 2, achieved, width, label="Achieved") - -# Add value labels on bars -for bars in [bars1, bars2]: - for bar in bars: - height = bar.get_height() - ax.text( - bar.get_x() + bar.get_width() / 2.0, - height, - f"{height:.2f}", - ha="center", - va="bottom", - ) + +for xi, t, a in zip(x, targets, achieved): + ax.plot([xi, xi], [t, a], color="gray", linestyle="--", alpha=0.6, zorder=1) + +ax.scatter(x, targets, s=120, marker="o", label="Target", zorder=3) +ax.scatter(x, achieved, s=120, marker="D", label="Achieved", zorder=3) + +for xi, t, a in zip(x, targets, achieved): + ax.text(xi + 0.05, t, f"{t:.2f}", va="center", ha="left") + ax.text(xi + 0.05, a, f"{a:.2f}", va="center", ha="left") # Calculate percent errors mean_error = abs(achieved[0] - targets[0]) / targets[0] * 100 diff --git a/examples/02_finetune/02_compute_force_from_optimized_dd.py b/examples/02_finetune/02_compute_force_from_optimized_dd.py index 254878ba..e2473825 100644 --- a/examples/02_finetune/02_compute_force_from_optimized_dd.py +++ b/examples/02_finetune/02_compute_force_from_optimized_dd.py @@ -58,7 +58,7 @@ from neo import Block, Segment, SpikeTrain from neuron import h -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator import RecruitmentThresholds from myogen.simulator.core.force.force_model import ForceModel from myogen.simulator.neuron import Network @@ -215,7 +215,7 @@ time_points = int(SIMULATION_TIME_MS / TIMESTEP_MS) drive_signal = np.ones(time_points) * dd_drive__Hz + np.clip( - RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None + get_random_generator().normal(0, 1.0, size=time_points), 0, None ) ############################################################################## @@ -405,15 +405,33 @@ mu_ids.append(i) if firing_rates: - axes[2].bar(mu_ids, firing_rates, alpha=0.7) - axes[2].axhline(fr_mean, linestyle="--", label=f"Mean = {fr_mean:.1f} Hz") - axes[2].set_xlabel("Motor Unit ID") + # Distribution plot with all data points (Editor 8): violin shell for + # the density, box-and-whisker for the quartiles, jittered points for + # every active motor unit. + fr_array = np.asarray(firing_rates) + fr_sd = float(np.std(fr_array, ddof=1)) if fr_array.size > 1 else 0.0 + if fr_array.size >= 10: + axes[2].violinplot( + fr_array, positions=[0], widths=0.7, showmeans=False, showmedians=False + ) + axes[2].boxplot( + fr_array, positions=[0], widths=0.3, showfliers=False + ) + jitter = np.random.default_rng(0).uniform(-0.15, 0.15, size=fr_array.size) + axes[2].scatter(jitter, fr_array, alpha=0.6, s=18, zorder=3) + axes[2].axhline( + fr_mean, linestyle="--", label=f"Mean = {fr_mean:.1f} Hz (SD = {fr_sd:.1f} Hz)" + ) + axes[2].set_xticks([0]) + axes[2].set_xticklabels([f"n = {fr_array.size}"]) + axes[2].set_xlabel("Motor Units") axes[2].set_ylabel("Firing Rate (Hz)") axes[2].set_title("Firing Rate Distribution") axes[2].legend(framealpha=1.0, edgecolor="none") -# Format all axes -for ax in axes: +# Format time-series axes (subplot 2 is the MU firing-rate distribution, +# whose x-axis is categorical, not time) +for ax in axes[:2]: ax.set_xlim(0, SIMULATION_TIME_MS / 1000) plt.tight_layout() diff --git a/examples/02_finetune/03_optimize_dd_for_target_force.py b/examples/02_finetune/03_optimize_dd_for_target_force.py index e53e96ea..5b57273c 100644 --- a/examples/02_finetune/03_optimize_dd_for_target_force.py +++ b/examples/02_finetune/03_optimize_dd_for_target_force.py @@ -50,7 +50,7 @@ from neo import Block, Segment, SpikeTrain from neuron import h -from myogen import RANDOM_GENERATOR, set_random_seed +from myogen import get_random_generator, set_random_seed from myogen.simulator import RecruitmentThresholds from myogen.simulator.core.force.force_model import ForceModel from myogen.simulator.neuron import Network @@ -212,7 +212,7 @@ def run_simulation_and_compute_force(dd_drive__Hz, gamma_shape, recruitment_thre # Create constant drive signal time_points = int(SIMULATION_TIME_MS / TIMESTEP_MS) drive_signal = np.ones(time_points) * dd_drive__Hz + np.clip( - RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None + get_random_generator().normal(0, 1.0, size=time_points), 0, None ) # Initialize simulation diff --git a/examples/02_finetune/05_plot_isi_cv_multi_muscle_comparison.py b/examples/02_finetune/05_plot_isi_cv_multi_muscle_comparison.py index f7c605b5..b1c8c128 100644 --- a/examples/02_finetune/05_plot_isi_cv_multi_muscle_comparison.py +++ b/examples/02_finetune/05_plot_isi_cv_multi_muscle_comparison.py @@ -526,6 +526,161 @@ def plot_cv_vs_fr_multi_muscle(all_muscle_data, exp_data): return fig, ax +def plot_cv_vs_fr_per_muscle(all_muscle_data, exp_data, muscles=("VL", "VM", "FDI")): + """ + Per-muscle supplementary figure in response to R3 Major 4. + + The main Figure 4B pools experimental data across VL, VM and FDI into + a single axis so the simulation overlay can be compared against the + envelope of all three muscles at once. The reviewer asked for a + companion view that separates the three muscles, enabling a fair + per-muscle comparison against the simulation. Each panel therefore + shows a single muscle's experimental envelope (convex hull + scatter) + and repeats the full simulation overlay so readers can judge how well + the simulation matches each individual muscle. + + Parameters + ---------- + all_muscle_data : dict + Nested ``{simulated_muscle_type: {force_level: DataFrame}}`` — the + same structure consumed by :func:`plot_cv_vs_fr_multi_muscle`. + exp_data : pd.DataFrame + Experimental ISI statistics with at least ``Muscle``, ``ISI CV`` + and ``FR mean`` columns. + muscles : sequence of str, optional + Experimental muscles to plot, one per panel. + + Returns + ------- + tuple + ``(fig, axes)`` where ``axes`` is a 1-D array of Axes, one per muscle. + """ + n_panels = len(muscles) + fig, axes = plt.subplots( + 1, n_panels, figsize=(5.5 * n_panels, 6), sharex=True, sharey=True + ) + if n_panels == 1: + axes = np.array([axes]) + + # Same force markers across all panels so they can be compared directly. + all_force_levels = set() + for muscle_data in all_muscle_data.values(): + all_force_levels.update(muscle_data.keys()) + force_markers = generate_force_markers(all_force_levels) + + for ax, muscle in zip(axes, muscles): + # 1. Experimental envelope for this muscle only. + if exp_data is not None: + muscle_rows = exp_data[exp_data["Muscle"] == muscle] + cv_data = muscle_rows["ISI CV"].values + fr_data = muscle_rows["FR mean"].values + + if len(cv_data) > 2: + points = np.column_stack([cv_data, fr_data]) + try: + hull = ConvexHull(points) + polygon = Polygon( + points[hull.vertices], + facecolor=EXP_COLORS.get(muscle, "#808080"), + alpha=0.25, + edgecolor=EXP_COLORS.get(muscle, "#808080"), + linewidth=1.5, + linestyle="-", + zorder=0, + ) + ax.add_patch(polygon) + except Exception: + pass + + ax.scatter( + cv_data, + fr_data, + s=20, + alpha=1.0, + color=EXP_COLORS.get(muscle, "#808080"), + edgecolors="white", + linewidth=0.5, + marker="x", + zorder=1, + label=f"Experimental {muscle}", + ) + + # 2. Full simulation overlay repeated on every panel so the + # simulation-vs-muscle comparison is panel-local. + for muscle_type in sorted(all_muscle_data.keys()): + muscle_data = all_muscle_data[muscle_type] + short_muscle = muscle_type.split("_")[0] + colormap_name = MUSCLE_COLORMAPS.get(short_muscle, "Greys") + + for force_level in sorted(muscle_data.keys()): + df = muscle_data[force_level] + recruitment_order = ( + df["MU_ID"].values if "MU_ID" in df.columns else np.arange(len(df)) + ) + colors = get_muscle_colors(recruitment_order, colormap_name) + marker = force_markers.get(force_level, "o") + ax.scatter( + df["CV_ISI"], + df["mean_firing_rate_Hz"], + s=40, + alpha=0.8, + c=colors, + edgecolors="black", + linewidth=0.5, + marker=marker, + zorder=2, + ) + + ax.set_xlabel("Coefficient of Variation (CV)", fontsize=12) + ax.set_title(muscle, fontsize=14) + ax.set_xlim(0, 0.5) + ax.set_ylim(4, 25) + ax.tick_params(axis="both", labelsize=10) + + axes[0].set_ylabel("Mean Firing Rate (pps)", fontsize=12) + + # Shared legend at the figure level so the force-level markers are + # decoded for every panel without repeating a per-axis legend. + legend_handles = [ + Patch( + facecolor=MUSCLE_LEGEND_COLORS.get(m.split("_")[0], "#000000"), + edgecolor="black", + label=m.split("_")[0], + ) + for m in sorted(all_muscle_data.keys()) + ] + for force in sorted(all_force_levels): + legend_handles.append( + Line2D( + [0], + [0], + marker=force_markers[force], + color="none", + markerfacecolor="gray", + markeredgecolor="black", + markersize=8, + label=f"{force}% MVC", + linewidth=0, + ) + ) + fig.legend( + handles=legend_handles, + loc="lower center", + ncol=min(len(legend_handles), 6), + frameon=False, + fontsize=10, + bbox_to_anchor=(0.5, -0.02), + ) + + fig.suptitle( + "ISI Statistics Comparison — per-muscle split (supplementary to Fig 4B)", + fontsize=14, + ) + fig.tight_layout(rect=(0, 0.05, 1, 1)) + + return fig, axes + + ############################################################################## # Load Simulation Data # --------------------- @@ -596,6 +751,26 @@ def plot_cv_vs_fr_multi_muscle(all_muscle_data, exp_data): plt.show() print(f"\nPlot saved to: {output_file}") +############################################################################## +# Per-Muscle Supplementary Figure (R3 Major 4) +# -------------------------------------------- +# +# Companion to the pooled Fig 4B above: splits the experimental envelope +# into per-muscle panels (VL, VM, FDI) so the simulation overlay can be +# compared against each muscle individually. + +print("\nCreating per-muscle supplementary figure...") +supp_fig, _ = plot_cv_vs_fr_per_muscle(all_muscle_data, exp_data) +supp_output_file = RESULTS_PATH / f"isi_cv_per_muscle_supplement.{OUTPUT_FORMAT}" +if OUTPUT_FORMAT in ["jpg", "jpeg"]: + supp_fig.savefig( + supp_output_file, dpi=300, bbox_inches="tight", pil_kwargs={"quality": 95} + ) +else: + supp_fig.savefig(supp_output_file, dpi=300, bbox_inches="tight", transparent=True) +plt.show() +print(f"Supplementary plot saved to: {supp_output_file}") + ############################################################################## # Summary Statistics # ------------------ diff --git a/examples/03_papers/watanabe/01_compute_baseline_force.py b/examples/03_papers/watanabe/01_compute_baseline_force.py index 4fadb4da..3fe2e72a 100644 --- a/examples/03_papers/watanabe/01_compute_baseline_force.py +++ b/examples/03_papers/watanabe/01_compute_baseline_force.py @@ -68,7 +68,7 @@ from neo import Block, Segment, SpikeTrain from neuron import h -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator import RecruitmentThresholds from myogen.simulator.core.force.force_model import ForceModel from myogen.simulator import Network @@ -231,7 +231,7 @@ time_points = int(SIMULATION_TIME_MS / TIMESTEP_MS) drive_signal = np.ones(time_points) * DD_DRIVE_HZ + np.clip( - RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None + get_random_generator().normal(0, 1.0, size=time_points), 0, None ) ############################################################################## @@ -428,14 +428,31 @@ mu_ids.append(i) if firing_rates: - axes[2].bar(mu_ids, firing_rates, alpha=0.7) - axes[2].axhline(fr_mean, linestyle="--", label=f"Mean = {fr_mean:.1f} Hz") - axes[2].set_xlabel("Motor Unit ID") + # Distribution plot with all data points (Editor 8): violin shell for + # the density, box-and-whisker for the quartiles, jittered points for + # every active motor unit. + fr_array = np.asarray(firing_rates) + fr_sd = float(np.std(fr_array, ddof=1)) if fr_array.size > 1 else 0.0 + if fr_array.size >= 10: + axes[2].violinplot( + fr_array, positions=[0], widths=0.7, showmeans=False, showmedians=False + ) + axes[2].boxplot( + fr_array, positions=[0], widths=0.3, showfliers=False + ) + jitter = np.random.default_rng(0).uniform(-0.15, 0.15, size=fr_array.size) + axes[2].scatter(jitter, fr_array, alpha=0.6, s=18, zorder=3) + axes[2].axhline( + fr_mean, linestyle="--", label=f"Mean = {fr_mean:.1f} Hz (SD = {fr_sd:.1f} Hz)" + ) + axes[2].set_xticks([0]) + axes[2].set_xticklabels([f"n = {fr_array.size}"]) + axes[2].set_xlabel("Motor Units") axes[2].set_ylabel("Firing Rate (Hz)") axes[2].set_title("Firing Rate Distribution") axes[2].legend(framealpha=1.0, edgecolor="none") -for ax in axes: +for ax in axes[:2]: ax.set_xlim(0, SIMULATION_TIME_MS / 1000) plt.tight_layout() diff --git a/examples/03_papers/watanabe/02_optimize_oscillating_dc.py b/examples/03_papers/watanabe/02_optimize_oscillating_dc.py index b58be0fa..6197a600 100644 --- a/examples/03_papers/watanabe/02_optimize_oscillating_dc.py +++ b/examples/03_papers/watanabe/02_optimize_oscillating_dc.py @@ -72,7 +72,7 @@ from neo import Block, Segment, SpikeTrain from neuron import h -from myogen import RANDOM_GENERATOR, set_random_seed +from myogen import get_random_generator, set_random_seed from myogen.simulator import RecruitmentThresholds from myogen.simulator.core.force.force_model import ForceModel from myogen.simulator.neuron import Network @@ -226,7 +226,7 @@ def run_simulation_with_oscillating_drive(dc_offset, recruitment_thresholds): drive_signal = np.clip(drive_signal, 0, None) # Add small noise - drive_signal += np.clip(RANDOM_GENERATOR.normal(0, 1.0, size=time_points), 0, None) + drive_signal += np.clip(get_random_generator().normal(0, 1.0, size=time_points), 0, None) # Initialize simulation h.load_file("stdrun.hoc") diff --git a/examples/03_papers/watanabe/03_10pct_mvc_simulation.py b/examples/03_papers/watanabe/03_10pct_mvc_simulation.py index 7348ec6c..534bd9e5 100644 --- a/examples/03_papers/watanabe/03_10pct_mvc_simulation.py +++ b/examples/03_papers/watanabe/03_10pct_mvc_simulation.py @@ -77,7 +77,7 @@ import quantities as pq from neuron import h -from myogen import RANDOM_GENERATOR, load_nmodl_mechanisms +from myogen import get_random_generator, load_nmodl_mechanisms from myogen.simulator import RecruitmentThresholds from myogen.simulator.neuron.network import Network from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool @@ -204,7 +204,7 @@ DDdrive[phase3_mask] = DC_OFFSET_OPTIMIZED + 20 * np.sin(2 * np.pi * 20 * time_s[phase3_mask]) # Independent noise (IN) - 125 Hz constant (Watanabe specification) -INdrive = 125.0 + RANDOM_GENERATOR.normal(0, 5.0, len(time)) +INdrive = 125.0 + get_random_generator().normal(0, 5.0, len(time)) plt.figure(figsize=(12, 6)) plt.subplot(2, 1, 1) diff --git a/myogen/__init__.py b/myogen/__init__.py index d0954f24..c71d3369 100644 --- a/myogen/__init__.py +++ b/myogen/__init__.py @@ -1,24 +1,104 @@ +import warnings + import numpy as np from numpy.random import Generator -SEED: int = 180319 # Seed for reproducibility -RANDOM_GENERATOR: Generator = np.random.default_rng(SEED) +_DEFAULT_SEED: int = 180319 +_current_seed: int = _DEFAULT_SEED +_random_generator: Generator = np.random.default_rng(_DEFAULT_SEED) + + +def get_random_generator() -> Generator: + """ + Return the current global RNG. + + Always reflects the most recent ``set_random_seed`` call. Prefer this + accessor over importing ``RANDOM_GENERATOR`` directly — a direct import + captures a stale reference that will not update when the seed changes. + """ + return _random_generator + + +def get_random_seed() -> int: + """Return the seed currently in effect.""" + return _current_seed + +def derive_subseed(*labels: int) -> int: + """ + Derive a deterministic, seed-tracking sub-seed from the current seed and a tuple of integer labels. + + Intended for seeding non-NumPy generators (Cython Mersenne spike + generators, sklearn ``random_state``, etc.) so that a call to + :func:`set_random_seed` propagates to them. Label order matters: + ``derive_subseed(a, b)`` and ``derive_subseed(b, a)`` yield different + sub-seeds. Each label must be a **non-negative** integer; callers with + signed identifiers should offset them beforehand (NumPy's + :class:`~numpy.random.SeedSequence`, which backs this helper, rejects + negatives). + + This replaces the pre-existing ``SEED + (class_id+1)*(global_id+1)`` + derivation, which collided on swapped factors — e.g. ``(0, 5)`` and + ``(1, 2)`` both produced ``+6``. The present mixing function uses + :class:`numpy.random.SeedSequence` to fold the inputs into a 32-bit + integer; collisions remain possible in principle (birthday-paradox + probability ≈ ``N² / 2³³``) but are negligible for realistic motor-unit + pool sizes (≲ 10⁻⁷ at 1000 cells). -def set_random_seed(seed: int = SEED) -> None: + Returns + ------- + int + A non-negative 32-bit integer suitable for passing as a seed to + NumPy, sklearn, or the bundled Cython RNG wrappers. + """ + seq = np.random.SeedSequence(entropy=(_current_seed, *labels)) + return int(seq.generate_state(1, dtype=np.uint32)[0]) + + +def set_random_seed(seed: int = _DEFAULT_SEED) -> None: """ Set the random seed for reproducibility. + Rebuilds the global NumPy ``Generator``. All modules that read the RNG + through :func:`get_random_generator` will observe the new state on their + next draw; this includes seeds derived for non-NumPy RNGs (e.g. sklearn + ``random_state`` arguments or Cython Mersenne generators), which are now + drawn from the global RNG rather than read from a frozen module constant. + Parameters ---------- seed : int, optional - Seed value to set, by default SEED + Seed value to set, by default 180319. """ - global RANDOM_GENERATOR - RANDOM_GENERATOR = np.random.default_rng(seed) + global _random_generator, _current_seed + _current_seed = seed + _random_generator = np.random.default_rng(seed) print(f"Random seed set to {seed}.") +def __getattr__(name: str): + """Backwards-compatible access for the deprecated ``RANDOM_GENERATOR`` and ``SEED`` module attributes.""" + if name == "RANDOM_GENERATOR": + warnings.warn( + "myogen.RANDOM_GENERATOR is deprecated; use myogen.get_random_generator() " + "to always retrieve the current RNG. Module-level imports of " + "RANDOM_GENERATOR capture a stale reference that does not update when " + "set_random_seed() is called.", + DeprecationWarning, + stacklevel=2, + ) + return _random_generator + if name == "SEED": + warnings.warn( + "myogen.SEED is deprecated; use myogen.get_random_seed() to retrieve " + "the seed currently in effect.", + DeprecationWarning, + stacklevel=2, + ) + return _current_seed + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + class MyoGenSetupError(Exception): """Exception raised when MyoGen setup fails.""" @@ -217,8 +297,9 @@ def error(msg): _nmodl_loaded = load_nmodl_mechanisms(quiet=True, strict=False) __all__ = [ - "RANDOM_GENERATOR", - "SEED", + "get_random_generator", + "get_random_seed", + "derive_subseed", "set_random_seed", "load_nmodl_mechanisms", "NMODLLoadError", diff --git a/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py b/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py index 456c3b55..44a1afac 100644 --- a/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py +++ b/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py @@ -26,7 +26,7 @@ from neo import AnalogSignal, Block, Segment from tqdm import tqdm -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator.core.emg.electrodes import IntramuscularElectrodeArray from myogen.simulator.core.muscle import Muscle from myogen.utils.decorators import beartowertype @@ -825,7 +825,7 @@ def add_noise(self, snr__dB: float, noise_type: str = "gaussian") -> INTRAMUSCUL # Generate noise if noise_type.lower() == "gaussian": # Generate standard normal noise, then scale per channel - noise = RANDOM_GENERATOR.normal(loc=0.0, scale=1.0, size=emg_array.shape) + noise = get_random_generator().normal(loc=0.0, scale=1.0, size=emg_array.shape) # Broadcast noise_std_per_channel along time axis # noise shape: (time, n_electrodes) # noise_std_per_channel shape: (n_electrodes,) diff --git a/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py b/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py index bc65b54d..387fa851 100644 --- a/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py +++ b/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py @@ -15,7 +15,7 @@ from sklearn.cluster import KMeans from tqdm import tqdm -from myogen import RANDOM_GENERATOR, SEED +from myogen import derive_subseed, get_random_generator from myogen.utils.decorators import beartowertype from .bioelectric import ( get_current_density, @@ -131,14 +131,14 @@ def sim_nmj_branches_two_layers( arborization_z_std : float Standard deviation of secondary arborization in mm """ - rng = RANDOM_GENERATOR + rng = get_random_generator() self.nerve_paths = np.zeros( (self._number_of_muscle_fibers, 2) ) # Point coordinates kmeans = KMeans( - n_clusters=n_branches, init="k-means++", max_iter=100, random_state=SEED + n_clusters=n_branches, init="k-means++", max_iter=100, random_state=derive_subseed(n_branches) ) idx = kmeans.fit_predict(self.muscle_fiber_centers__mm) c = kmeans.cluster_centers_ @@ -197,7 +197,7 @@ def sim_nmj_branches_gaussian(self, endplate_center: float, branches_z_std: floa branches_z_std : float Standard deviation of NMJ distribution in mm """ - rng = RANDOM_GENERATOR + rng = get_random_generator() self.nmj_z = rng.normal(endplate_center, branches_z_std, self.Nmf) # Simplified nerve paths (single segment) @@ -408,7 +408,7 @@ def calc_muap(self, jitter_std: float = 0.0) -> np.ndarray: raise ValueError("_dt not set - call calc_sfaps() first") if jitter_std != 0: - delays = jitter_std * RANDOM_GENERATOR.standard_normal( + delays = jitter_std * get_random_generator().standard_normal( size=(self._number_of_muscle_fibers, 1) ) jittered_sfaps = np.zeros_like(self.sfaps) diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index 3d6084b8..e9cf7efe 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -28,7 +28,7 @@ from scipy.signal import resample from tqdm import tqdm -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator.core.emg.electrodes import SurfaceElectrodeArray from myogen.simulator.core.emg.surface.simulate_fiber import simulate_fiber_v2, _simulate_fiber_v2_python from myogen.simulator.core.muscle import Muscle @@ -267,7 +267,7 @@ def simulate_muaps(self, n_jobs: int = -2, verbose: bool = True) -> SURFACE_MUAP n_motor_units = len(number_of_fibers_per_MUs) # Pre-calculate innervation zones for all MUs - innervation_zones = RANDOM_GENERATOR.uniform( + innervation_zones = get_random_generator().uniform( low=-innervation_zone_variance / 2, high=innervation_zone_variance / 2, size=n_motor_units, @@ -338,7 +338,7 @@ def _process_single_mu( innervation_zone = innervation_zones[MU_index] # Batch generate random fiber lengths (optimization: single RNG call) - fiber_length_variations = RANDOM_GENERATOR.uniform( + fiber_length_variations = get_random_generator().uniform( low=-self._var_fiber_length__mm, high=self._var_fiber_length__mm, size=number_of_fibers, @@ -859,7 +859,7 @@ def add_noise(self, snr__dB: float, noise_type: str = "gaussian") -> SURFACE_EMG # Generate noise if noise_type.lower() == "gaussian": # Generate standard normal noise, then scale per channel - noise = RANDOM_GENERATOR.normal(loc=0.0, scale=1.0, size=emg_array.shape) + noise = get_random_generator().normal(loc=0.0, scale=1.0, size=emg_array.shape) # Broadcast noise_std_per_channel along time axis # noise shape: (time, rows, cols) # noise_std_per_channel shape: (rows, cols) diff --git a/myogen/simulator/core/muscle/muscle.py b/myogen/simulator/core/muscle/muscle.py index b821200f..9e04140f 100644 --- a/myogen/simulator/core/muscle/muscle.py +++ b/myogen/simulator/core/muscle/muscle.py @@ -12,7 +12,7 @@ from tqdm import tqdm import quantities as pq -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.utils.types import ( RECRUITMENT_THRESHOLDS__ARRAY, Quantity__S_per_m, @@ -101,12 +101,12 @@ class Muscle: radius__mm : float, default=6.91 Radius of the muscle cross-section in millimeters. Default is set to 6.91 mm as determined by Jacobson et al. 1992 [3]_. length__mm : float, default=30.0 - Length of the muscle in millimeters. Default is set to 30.0 mm as determined by no one. + Length of the muscle in millimeters. Default is 30.0 mm, a nominal value chosen to match the order of magnitude of the FDI muscle; adjust to match the muscle under study. fiber_density__fibers_per_mm2 : float, default=350 Density of muscle fibers per square millimeter. Default is set to 350 fibers/mm² as determined by Bettelho et al. 2019 [7]_. max_innervation_area_to_total_muscle_area__ratio : float, default=0.25 Ratio defining the maximum territory size relative to total muscle area. - Default is set to 0.25 as determined by no one but it is a good starting point. + Default is 0.25 as a pragmatic upper bound for the FDI, with no single published source; revisit for larger muscles. A value of 0.25 means the largest motor unit can innervate up to 25% of the total muscle cross-sectional area. Must be in range (0, 1]. @@ -317,7 +317,7 @@ def _generate_fiber_properties(self) -> None: std_diameter__mm = 15e-3 # mm (15 um) self._muscle_fiber_diameters__mm = ( - RANDOM_GENERATOR.lognormal(mean=np.log(mean_diameter__mm), sigma=0.3, size=n_fibers) + get_random_generator().lognormal(mean=np.log(mean_diameter__mm), sigma=0.3, size=n_fibers) * pq.mm ) @@ -334,7 +334,7 @@ def _generate_fiber_properties(self) -> None: # Add some biological variability cv_base = k * self._muscle_fiber_diameters__mm + c - cv_noise = RANDOM_GENERATOR.normal(0, 0.2, n_fibers) * pq.m / pq.s # 20% CV variation + cv_noise = get_random_generator().normal(0, 0.2, n_fibers) * pq.m / pq.s # 20% CV variation self._muscle_fiber_conduction_velocities__mm_per_s = cv_base + cv_noise @@ -717,7 +717,7 @@ def probfun(y, x): # Assignment procedure self._assignment = np.full(self._number_of_muscle_fibers, np.nan) - randomized_mf = RANDOM_GENERATOR.permutation(self._number_of_muscle_fibers) + randomized_mf = get_random_generator().permutation(self._number_of_muscle_fibers) for mf in tqdm(randomized_mf, desc="Assigning muscle fibers to motor neurons", unit="MF", disable=not verbose): # Vectorized computation of likelihoods for all motor units @@ -753,7 +753,7 @@ def probfun(y, x): probs = np.ones(self._number_of_neurons) / self._number_of_neurons # Sample from the probability distribution (equivalent to MATLAB's randsample) - self._assignment[mf] = RANDOM_GENERATOR.choice(self._number_of_neurons, p=probs) + self._assignment[mf] = get_random_generator().choice(self._number_of_neurons, p=probs) if verbose: print(f"Assignment completed. {self._number_of_muscle_fibers} muscle fibers assigned.") diff --git a/myogen/simulator/neuron/cells.py b/myogen/simulator/neuron/cells.py index 11d2117b..1b84dfc3 100644 --- a/myogen/simulator/neuron/cells.py +++ b/myogen/simulator/neuron/cells.py @@ -9,7 +9,7 @@ import quantities as pq from neuron import h -from myogen import RANDOM_GENERATOR, SEED +from myogen import derive_subseed, get_random_generator from myogen.simulator.neuron._cython._gamma_process_generator import ( _GammaProcessGenerator__Cython, ) @@ -409,7 +409,7 @@ def __init__(self, N, dt, pool__ID: int | None = None): self.ns = h.DUMMY() # Dummy cell _Cell.__init__(self, next(self._ids2), pool__ID) _PoissonProcessGenerator__Cython.__init__( - self, SEED + (self.class__ID + 1) * (self.global__ID + 1), N, dt + self, derive_subseed(self.class__ID, self.global__ID), N, dt ) def __repr__(self) -> str: @@ -494,7 +494,7 @@ def __init__( _Cell.__init__(self, next(self._ids2), pool__ID) _GammaProcessGenerator__Cython.__init__( self, - SEED + (self.class__ID + 1) * (self.global__ID + 1), + derive_subseed(self.class__ID, self.global__ID), shape, timestep__ms.magnitude, ) @@ -584,12 +584,12 @@ def __init__( self.ns = h.DUMMY() # Dummy cell self.RT = RT # Recruitment Threshold - self.IFR = RANDOM_GENERATOR.normal(5, 2.5) # Individual variability + self.IFR = get_random_generator().normal(5, 2.5) # Individual variability _Cell.__init__(self, class__ID if class__ID is not None else next(self._ids2), pool__ID) _GammaProcessGenerator__Cython.__init__( self, - seed=SEED + (self.class__ID + 1) * (self.global__ID + 1), + seed=derive_subseed(self.class__ID, self.global__ID), shape=N, # Shape parameter controls ISI CV = 1/sqrt(N) dt=timestep__ms.magnitude, ) diff --git a/myogen/simulator/neuron/network.py b/myogen/simulator/neuron/network.py index 59afa3e1..c7a111c1 100644 --- a/myogen/simulator/neuron/network.py +++ b/myogen/simulator/neuron/network.py @@ -10,7 +10,7 @@ import quantities as pq from neuron import h -from myogen import RANDOM_GENERATOR +from myogen import get_random_generator from myogen.simulator.neuron.populations import ( AffIa__Pool, AffII__Pool, @@ -79,10 +79,10 @@ def _select_synapse(target_neuron, inhibitory: bool = False): ] if matching_synapses: - return RANDOM_GENERATOR.choice(matching_synapses) + return get_random_generator().choice(matching_synapses) else: # Fallback to random selection if no matching synapses found - return RANDOM_GENERATOR.choice(synapse_list) + return get_random_generator().choice(synapse_list) # Helper functions for create_netcon @@ -263,7 +263,7 @@ def _connect_population_to_population( for source_neuron in populations[source_pop]: # Randomly select exactly n_connections target neurons - selected_targets = RANDOM_GENERATOR.choice( + selected_targets = get_random_generator().choice( target_neurons, size=n_connections, replace=False ) @@ -289,7 +289,7 @@ def _connect_population_to_population( # Probabilistic connectivity: each pair has probability of connecting for source_neuron in populations[source_pop]: for target_neuron in target_neurons: - if RANDOM_GENERATOR.uniform() < connection_probability: + if get_random_generator().uniform() < connection_probability: target_synapse = _select_synapse(target_neuron, inhibitory=inhibitory) netcon = _create_netcon( source_neuron, @@ -431,7 +431,7 @@ def _connect_one_to_one( connections = [] for source_neuron, target_neuron in zip(source_neurons, target_neurons): # Check if this pair should be connected - if RANDOM_GENERATOR.uniform() < connection_probability: + if get_random_generator().uniform() < connection_probability: target_synapse = _select_synapse(target_neuron, inhibitory=inhibitory) netcon = _create_netcon( source_neuron, diff --git a/myogen/utils/helper.py b/myogen/utils/helper.py index 83f29e99..47389ba0 100644 --- a/myogen/utils/helper.py +++ b/myogen/utils/helper.py @@ -144,8 +144,14 @@ def calculate_firing_rate_statistics( firing_rates.append(neuron_fr) if return_per_neuron: - # Compute CV of inter-spike intervals - cv = np.std(isis_array, ddof=1) / np.mean(isis_array) + # Compute CV of inter-spike intervals. Guard against + # the n=1 NaN case when a caller lowers + # min_spikes_for_cv to 2 (only one ISI is observable). + cv = ( + np.std(isis_array, ddof=1) / np.mean(isis_array) + if len(isis_array) > 1 + else 0.0 + ) per_neuron_results.append( { "MU_ID": mu_id, @@ -169,10 +175,13 @@ def calculate_firing_rate_statistics( "firing_rates": np.array([]), } - # Ensemble statistics: mean across neurons for both FR and SD_FR + # Ensemble statistics: mean across neurons for both FR and SD_FR. + # np.std with ddof=1 is undefined for n<2, so report 0.0 rather than + # NaN when only one unit is active. + fr_std = np.std(firing_rates, ddof=1) if len(firing_rates) > 1 else 0.0 return { "FR_mean": np.mean(firing_rates), - "FR_std": np.std(firing_rates, ddof=1), + "FR_std": fr_std, "n_active": len(firing_rates), "firing_rates": np.array(firing_rates), } diff --git a/pyproject.toml b/pyproject.toml index 14ac2fa4..38c15e41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,13 @@ classifiers = [ dependencies = [ "beartype>=0.21.0", "cython>=3.1.3", - "elephant>=1.1.1", "impi-rt>=2021.15.0 ; sys_platform == 'win32'", "matplotlib>=3.10.1", "mpi4py>=4.0.3", "neuron==8.2.7 ; sys_platform == 'linux' or sys_platform == 'darwin'", #"neuron-gpu-nightly>=9.0a0", #"neuron-nightly>=9.0a1.dev1492", + "neo>=0.14.0", "numba>=0.61.2", "numpy>=1.26,<2.0", "optuna>=4.5.0", @@ -47,10 +47,13 @@ dependencies = [ "seaborn>=0.13.2", "setuptools>=80.9.0", "tqdm>=4.67.1", - "viziphant>=0.4.0", ] [project.optional-dependencies] +elephant = [ + "elephant>=1.1.1", + "viziphant>=0.4.0", +] nwb = [ "pynwb>=2.8.0", "nwbinspector>=0.5.0", @@ -71,6 +74,7 @@ possibly-unbound-import = "ignore" dev = [ "pandas-stubs>=2.3.0.250703", "poethepoet>=0.37.0", + "pytest>=8.0", "scipy-stubs>=1.16.1.0", ] docs = [ @@ -81,7 +85,7 @@ docs = [ "pygments>=2.19.2", "rinohtype>=0.5.5", "roman>=5.2", - "sphinx>=8.1.3", + "sphinx>=8.1.3,<9", "sphinx-autodoc-typehints>=2.5.0", "sphinx-design>=0.6.1", "sphinx-gallery>=0.19.0", @@ -91,8 +95,16 @@ docs = [ # NWB support for examples "pynwb>=2.8.0", "nwbinspector>=0.5.0", + # Spike-train analysis + visualization needed by several examples + # (elephant is also declared as an optional extra; the docs build + # needs both for sphinx-gallery to render those examples). + "elephant>=1.1.1", + "viziphant>=0.4.0", ] +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.poe.tasks] setup_myogen = "python -c 'from myogen import _setup_myogen; _setup_myogen()'" diff --git a/setup.py b/setup.py index 5ca35d25..1bb0932f 100644 --- a/setup.py +++ b/setup.py @@ -201,11 +201,11 @@ def compile_nmodl(self): MyoGen Installation Failed - NEURON Required ================================================================================ -MyoGen requires NEURON 8.2.6 to be installed BEFORE installing MyoGen. +MyoGen requires NEURON 8.2.7 to be installed BEFORE installing MyoGen. STEP 1: Download and install NEURON ----------------------------------- - https://github.com/neuronsimulator/nrn/releases/download/8.2.6/nrn-8.2.6.w64-mingw-py-38-39-310-311-312-setup.exe + https://github.com/neuronsimulator/nrn/releases/download/8.2.7/nrn-8.2.7.w64-mingw-py-39-310-311-312-313-setup.exe During installation, select "Add to PATH" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 00000000..ca4967b6 --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,139 @@ +"""Regression tests for the RNG architecture (R1 Code Review 1 and 2). + +These tests verify that ``set_random_seed`` propagates correctly through the +accessor pattern and that no module captures a stale reference to the global +RNG or to the deprecated module-level ``SEED`` constant. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +import myogen +from myogen import get_random_generator, get_random_seed, set_random_seed + + +def _draw(n: int = 8) -> np.ndarray: + return get_random_generator().integers(0, 10**9, n) + + +def test_same_seed_yields_same_draws(): + set_random_seed(12345) + first = _draw() + set_random_seed(12345) + second = _draw() + assert np.array_equal(first, second) + + +def test_different_seed_yields_different_draws(): + set_random_seed(1) + first = _draw() + set_random_seed(2) + second = _draw() + assert not np.array_equal(first, second) + + +def test_get_random_seed_reflects_latest_set_call(): + set_random_seed(777) + assert get_random_seed() == 777 + set_random_seed(999) + assert get_random_seed() == 999 + + +def test_accessor_returns_current_generator_after_rebind(): + """A caller that holds ``get_random_generator`` (the function, not the generator) must see the new RNG after each ``set_random_seed`` call — this is the regression for CR1.""" + set_random_seed(100) + before = _draw() + set_random_seed(200) + after = _draw() + assert not np.array_equal(before, after) + + # Restoring the original seed must reproduce the first sequence. + set_random_seed(100) + restored = _draw() + assert np.array_equal(before, restored) + + +def test_derived_seed_pattern_respects_set_random_seed(): + """Regression for CR2: code that derives sub-seeds via ``get_random_seed() + offset`` must observe new values after ``set_random_seed``.""" + set_random_seed(10) + derived_a = get_random_seed() + 3 * 7 + set_random_seed(20) + derived_b = get_random_seed() + 3 * 7 + assert derived_a != derived_b + + +def test_deprecated_RANDOM_GENERATOR_emits_warning_and_returns_current(): + set_random_seed(42) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + gen_via_deprecated = myogen.RANDOM_GENERATOR + assert any( + issubclass(w.category, DeprecationWarning) for w in caught + ), "accessing myogen.RANDOM_GENERATOR must emit a DeprecationWarning" + assert gen_via_deprecated is get_random_generator(), ( + "deprecated attribute must return the current global generator" + ) + + # Confirm it reflects later ``set_random_seed`` calls. + set_random_seed(43) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + gen_again = myogen.RANDOM_GENERATOR + assert gen_again is get_random_generator() + assert gen_again is not gen_via_deprecated + + +def test_deprecated_SEED_emits_warning_and_returns_current_seed(): + set_random_seed(5150) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + seed_via_deprecated = myogen.SEED + assert any( + issubclass(w.category, DeprecationWarning) for w in caught + ), "accessing myogen.SEED must emit a DeprecationWarning" + assert seed_via_deprecated == 5150 + + +def test_derive_subseed_avoids_old_collision_pattern(): + """Regression for the earlier ``SEED + (a+1)*(b+1)`` collision: (0, 5) and (1, 2) both produced +6. The new SeedSequence-based mixing is uint32 and therefore not literally collision-free, but it must not reproduce the swapped-factor collision, and a 16x16 grid must be collision-free in practice.""" + set_random_seed(42) + assert myogen.derive_subseed(0, 5) != myogen.derive_subseed(1, 2) + assert myogen.derive_subseed(1, 2) != myogen.derive_subseed(2, 1) + + subseeds = { + (a, b): myogen.derive_subseed(a, b) + for a in range(16) + for b in range(16) + } + assert len(set(subseeds.values())) == len(subseeds), ( + "derive_subseed must yield a unique value across a 16x16 label grid" + ) + + +def test_derive_subseed_tracks_set_random_seed(): + set_random_seed(1) + sub_a = myogen.derive_subseed(3, 7) + set_random_seed(2) + sub_b = myogen.derive_subseed(3, 7) + assert sub_a != sub_b, ( + "derive_subseed must change when the global seed changes" + ) + + +def test_modules_that_use_accessor_are_not_stale_after_seed_change(): + """End-to-end: reimport a module that calls ``get_random_generator()`` and confirm its RNG draws track the current seed.""" + from myogen.simulator.core.muscle import muscle as muscle_module + + # The muscle module imports ``get_random_generator`` at top level; after + # ``set_random_seed``, any call it makes to ``get_random_generator()`` must + # return the new generator. Verify by calling the function directly. + set_random_seed(11) + gen_before = muscle_module.get_random_generator() + set_random_seed(22) + gen_after = muscle_module.get_random_generator() + assert gen_before is not gen_after + assert gen_after is get_random_generator() diff --git a/tests/test_firing_rate_statistics.py b/tests/test_firing_rate_statistics.py new file mode 100644 index 00000000..f95aac98 --- /dev/null +++ b/tests/test_firing_rate_statistics.py @@ -0,0 +1,79 @@ +"""Regression test for R1 Code Review 3: FR_std must not be NaN when only a single unit is active.""" + +from __future__ import annotations + +import math + +import neo +import numpy as np +import pytest +import quantities as pq + +from myogen.utils.helper import calculate_firing_rate_statistics + + +def _build_spiketrain(times_ms, t_stop_ms=2000.0): + """Construct a ``neo.SpikeTrain`` in milliseconds.""" + return neo.SpikeTrain(np.asarray(times_ms) * pq.ms, t_stop=t_stop_ms * pq.ms) + + +def test_fr_std_is_zero_not_nan_for_single_active_unit(): + """When only one unit passes the firing-rate filter, ``FR_std`` must be 0.0, never NaN — otherwise downstream analyses silently corrupt (CR3).""" + active = _build_spiketrain(np.linspace(100.0, 1900.0, num=30)) + silent = _build_spiketrain([]) + + stats = calculate_firing_rate_statistics( + [active, silent], + plateau_start_ms=100.0, + plateau_end_ms=1900.0, + ) + + assert stats["n_active"] == 1 + assert not math.isnan(stats["FR_std"]), "FR_std must not be NaN when n_active == 1" + assert stats["FR_std"] == 0.0 + + +def test_fr_std_is_zero_for_empty_population(): + """Empty-population branch already returned 0.0 and must keep doing so.""" + silent = _build_spiketrain([]) + stats = calculate_firing_rate_statistics( + [silent, silent], + plateau_start_ms=0.0, + plateau_end_ms=2000.0, + ) + assert stats["n_active"] == 0 + assert stats["FR_std"] == 0.0 + + +def test_per_neuron_cv_is_finite_with_min_spikes_for_cv_2(): + """When a caller lowers ``min_spikes_for_cv`` to 2, a neuron with exactly two spikes produces one ISI — ``np.std(..., ddof=1)`` would otherwise be NaN. The guard at the sample-std call site must return a finite CV.""" + two_spikes = _build_spiketrain([200.0, 1000.0]) + silent = _build_spiketrain([]) + + df = calculate_firing_rate_statistics( + [two_spikes, silent], + plateau_start_ms=100.0, + plateau_end_ms=1900.0, + return_per_neuron=True, + min_spikes_for_cv=2, + ) + + assert len(df) == 1 + cv = df["CV_ISI"].iloc[0] + assert math.isfinite(cv), "CV_ISI must not be NaN for n=1 ISI" + + +def test_fr_std_is_finite_and_positive_for_multiple_active_units(): + """Two active units with distinct rates must produce a positive, finite FR_std.""" + fast = _build_spiketrain(np.linspace(100.0, 1900.0, num=40)) # ~22 Hz + slow = _build_spiketrain(np.linspace(100.0, 1900.0, num=15)) # ~7.5 Hz + + stats = calculate_firing_rate_statistics( + [fast, slow], + plateau_start_ms=100.0, + plateau_end_ms=1900.0, + ) + + assert stats["n_active"] == 2 + assert math.isfinite(stats["FR_std"]) + assert stats["FR_std"] > 0.0 diff --git a/uv.lock b/uv.lock index 2472378e..bb0cbc26 100644 --- a/uv.lock +++ b/uv.lock @@ -202,23 +202,23 @@ wheels = [ [[package]] name = "autodocsumm" -version = "0.2.14" +version = "0.2.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/96/92afe8a7912b327c01f0a8b6408c9556ee13b1aba5b98d587ac7327ff32d/autodocsumm-0.2.14.tar.gz", hash = "sha256:2839a9d4facc3c4eccd306c08695540911042b46eeafcdc3203e6d0bab40bc77", size = 46357, upload-time = "2024-10-23T18:51:47.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/f28dea12fae1d1ad1e706f5cf6d16e8d735f305ebee86fd9390e099bd27d/autodocsumm-0.2.15.tar.gz", hash = "sha256:eaf431e7a5a39e41a215311173c8b95e83859059df1ccf3b79c64bf3d5582b3c", size = 46674, upload-time = "2026-03-26T20:44:07.074Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/bc/3f66af9beb683728e06ca08797e4e9d3e44f432f339718cae3ba856a9cad/autodocsumm-0.2.14-py3-none-any.whl", hash = "sha256:3bad8717fc5190802c60392a7ab04b9f3c97aa9efa8b3780b3d81d615bfe5dc0", size = 14640, upload-time = "2024-10-23T18:51:45.115Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3d/4357a0f685c0a2ae7132ac91905bec565e64f9ba63b079f7ec5da46e3597/autodocsumm-0.2.15-py3-none-any.whl", hash = "sha256:dbe6fabcaeae4540748ea9b3443eb76c2692e063d44f004f67c424610a5aca9a", size = 14852, upload-time = "2026-03-26T20:44:05.273Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] @@ -616,11 +616,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.1" +version = "3.28.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474, upload-time = "2026-04-14T22:54:33.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, + { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" }, ] [[package]] @@ -780,7 +780,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -788,7 +787,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -796,7 +794,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -804,7 +801,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -923,11 +919,11 @@ wheels = [ [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] @@ -938,6 +934,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a8/31b67dfecd27656045900ad63292d62a6679bdeb2fdf9b6d16dcd6f90e67/impi_rt-2021.17.1-py2.py3-none-win_amd64.whl", hash = "sha256:614fdd114caf4323eb9d7b5e68c7101b8af1e0d8fb4e546fd0c2a4787f142f93", size = 17439430, upload-time = "2025-12-04T09:51:31.144Z" }, ] +[[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 = "isodate" version = "0.7.2" @@ -1122,14 +1127,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -1284,11 +1289,11 @@ wheels = [ [[package]] name = "more-itertools" -version = "10.8.0" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] [[package]] @@ -1474,15 +1479,15 @@ wheels = [ [[package]] name = "myogen" -version = "0.8.3" +version = "0.8.5" source = { editable = "." } dependencies = [ { name = "beartype" }, { name = "cython" }, - { name = "elephant" }, { name = "impi-rt", marker = "sys_platform == 'win32'" }, { name = "matplotlib" }, { name = "mpi4py" }, + { name = "neo" }, { name = "neuron", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "numba" }, { name = "numpy" }, @@ -1495,10 +1500,13 @@ dependencies = [ { name = "seaborn" }, { name = "setuptools" }, { name = "tqdm" }, - { name = "viziphant" }, ] [package.optional-dependencies] +elephant = [ + { name = "elephant" }, + { name = "viziphant" }, +] nwb = [ { name = "nwbinspector" }, { name = "pynwb" }, @@ -1508,9 +1516,11 @@ nwb = [ dev = [ { name = "pandas-stubs" }, { name = "poethepoet" }, + { name = "pytest" }, { name = "scipy-stubs" }, ] docs = [ + { name = "elephant" }, { name = "enum-tools", extra = ["sphinx"] }, { name = "linkify-it-py" }, { name = "memory-profiler" }, @@ -1527,16 +1537,18 @@ docs = [ { name = "sphinx-hoverxref" }, { name = "sphinxcontrib-mermaid" }, { name = "toml" }, + { name = "viziphant" }, ] [package.metadata] requires-dist = [ { name = "beartype", specifier = ">=0.21.0" }, { name = "cython", specifier = ">=3.1.3" }, - { name = "elephant", specifier = ">=1.1.1" }, + { name = "elephant", marker = "extra == 'elephant'", specifier = ">=1.1.1" }, { name = "impi-rt", marker = "sys_platform == 'win32'", specifier = ">=2021.15.0" }, { name = "matplotlib", specifier = ">=3.10.1" }, { name = "mpi4py", specifier = ">=4.0.3" }, + { name = "neo", specifier = ">=0.14.0" }, { name = "neuron", marker = "sys_platform == 'darwin' or sys_platform == 'linux'", specifier = "==8.2.7" }, { name = "numba", specifier = ">=0.61.2" }, { name = "numpy", specifier = ">=1.26,<2.0" }, @@ -1551,17 +1563,19 @@ requires-dist = [ { name = "seaborn", specifier = ">=0.13.2" }, { name = "setuptools", specifier = ">=80.9.0" }, { name = "tqdm", specifier = ">=4.67.1" }, - { name = "viziphant", specifier = ">=0.4.0" }, + { name = "viziphant", marker = "extra == 'elephant'", specifier = ">=0.4.0" }, ] -provides-extras = ["nwb"] +provides-extras = ["elephant", "nwb"] [package.metadata.requires-dev] dev = [ { name = "pandas-stubs", specifier = ">=2.3.0.250703" }, { name = "poethepoet", specifier = ">=0.37.0" }, + { name = "pytest", specifier = ">=8.0" }, { name = "scipy-stubs", specifier = ">=1.16.1.0" }, ] docs = [ + { name = "elephant", specifier = ">=1.1.1" }, { name = "enum-tools", extras = ["sphinx"], specifier = ">=0.12.0" }, { name = "linkify-it-py", specifier = ">=2.0.3" }, { name = "memory-profiler", specifier = ">=0.61.0" }, @@ -1571,18 +1585,19 @@ docs = [ { name = "pynwb", specifier = ">=2.8.0" }, { name = "rinohtype", specifier = ">=0.5.5" }, { name = "roman", specifier = ">=5.2" }, - { name = "sphinx", specifier = ">=8.1.3" }, + { name = "sphinx", specifier = ">=8.1.3,<9" }, { name = "sphinx-autodoc-typehints", specifier = ">=2.5.0" }, { name = "sphinx-design", specifier = ">=0.6.1" }, { name = "sphinx-gallery", specifier = ">=0.19.0" }, { name = "sphinx-hoverxref", specifier = ">=1.4.1" }, { name = "sphinxcontrib-mermaid", specifier = ">=1.0.0" }, { name = "toml", specifier = ">=0.10.2" }, + { name = "viziphant", specifier = ">=0.4.0" }, ] [[package]] name = "myst-parser" -version = "4.0.1" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -1592,9 +1607,9 @@ dependencies = [ { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] @@ -1978,6 +1993,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[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 = "poethepoet" version = "0.39.0" @@ -2077,35 +2101,35 @@ wheels = [ [[package]] name = "psutil" -version = "7.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, - { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, - { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, - { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, - { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, +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 = "pydata-sphinx-theme" -version = "0.16.1" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, @@ -2116,9 +2140,9 @@ dependencies = [ { name = "sphinx" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bb/4a97aaa840b26601d6d04deca1389c35025336428706a4a732051187fbd3/pydata_sphinx_theme-0.17.0.tar.gz", hash = "sha256:529c5631582cb3328cf4814fb9eb80611d1704c854406d282a75c9c86e3a1955", size = 4990605, upload-time = "2026-04-03T13:02:20.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b7/a2bae25aae3568fe9f17040b31f9c190b4c5d86856d8869d0a30364a2567/pydata_sphinx_theme-0.17.0-py3-none-any.whl", hash = "sha256:cec5c92f41f4a11541b6df8210c446b4aa9c3badb7fcf2db7893405b786d5c99", size = 6820685, upload-time = "2026-04-03T13:02:18.09Z" }, ] [[package]] @@ -2156,6 +2180,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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" @@ -2456,14 +2496,14 @@ wheels = [ [[package]] name = "ruamel-yaml" -version = "0.18.17" +version = "0.18.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/c7/ee630b29e04a672ecfc9b63227c87fd7a37eb67c1bf30fe95376437f897c/ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a", size = 147269, upload-time = "2025-10-22T17:54:02.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, + { url = "https://files.pythonhosted.org/packages/0f/73/bb1bc2529f852e7bf64a2dec885e89ff9f5cc7bbf6c9340eed30ff2c69c5/ruamel.yaml-0.18.16-py3-none-any.whl", hash = "sha256:048f26d64245bae57a4f9ef6feb5b552a386830ef7a826f235ffb804c59efbba", size = 119858, upload-time = "2025-10-22T17:53:59.012Z" }, ] [[package]] @@ -2686,11 +2726,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.1" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] @@ -2735,14 +2775,14 @@ wheels = [ [[package]] name = "sphinx-design" -version = "0.6.1" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, ] [[package]] @@ -2820,7 +2860,7 @@ wheels = [ [[package]] name = "sphinx-toolbox" -version = "4.1.0" +version = "4.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apeye" }, @@ -2842,9 +2882,9 @@ dependencies = [ { name = "tabulate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/57/d9f2cfa638864eac5565c263e994d117d577898a9eca91800f806a225b71/sphinx_toolbox-4.1.0.tar.gz", hash = "sha256:5da890f4bb0cacea4f1cf6cef182c5be480340d0ead43c905f51f7e5aacfc19c", size = 113632, upload-time = "2025-12-05T23:23:53.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/16/769454374719569f6165149a16d06b8da27cda8330fe5f932c2c65a77a04/sphinx_toolbox-4.1.2.tar.gz", hash = "sha256:c30a4f86c4c29e97adb0eb9337d35f5093cb96a44f49caffcf7d5bc58a88b781", size = 114911, upload-time = "2026-01-14T17:33:02.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/6e/e6a9f56972c971eb1556b604c82bb957c2286c68d50f39f64a7526907216/sphinx_toolbox-4.1.0-py3-none-any.whl", hash = "sha256:9024a7482b92ecf4572f83940c87ae26c2eca3ca49ff3df5f59806e88da958f6", size = 196115, upload-time = "2025-12-05T23:23:51.713Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/6763a5a981770a21a18b66d3593a8bbb801884d9828c126866efdd01d41a/sphinx_toolbox-4.1.2-py3-none-any.whl", hash = "sha256:0438f8342ba1c6c0d6e47207f4eac167adba61742e8c2b1dc9624ff955b7bc89", size = 196812, upload-time = "2026-01-14T17:33:00.51Z" }, ] [[package]] @@ -2897,15 +2937,16 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "1.2.3" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/49/c6ddfe709a4ab76ac6e5a00e696f73626b2c189dc1e1965a361ec102e6cc/sphinxcontrib_mermaid-1.2.3.tar.gz", hash = "sha256:358699d0ec924ef679b41873d9edd97d0773446daf9760c75e18dc0adfd91371", size = 18885, upload-time = "2025-11-26T04:18:32.43Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/39/8b54299ffa00e597d3b0b4d042241a0a0b22cb429ad007ccfb9c1745b4d1/sphinxcontrib_mermaid-1.2.3-py3-none-any.whl", hash = "sha256:5be782b27026bef97bfb15ccb2f7868b674a1afc0982b54cb149702cfc25aa02", size = 13413, upload-time = "2025-11-26T04:18:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, ] [[package]] @@ -2972,11 +3013,11 @@ wheels = [ [[package]] name = "tabulate" -version = "0.9.0" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] [[package]]