Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 25 additions & 19 deletions .github/workflows/build-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ jobs:
os: [ubuntu-latest, macos-latest]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Build wheels
uses: pypa/cibuildwheel@v2.21
env:
# Only build for Python 3.12+
CIBW_BUILD: "cp312-*"
# Build for Python 3.12 and 3.13
CIBW_BUILD: "cp312-* cp313-*"

# Skip 32-bit builds and musl (Alpine) on Linux
CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_*"

# Install system dependencies before building (Linux)
# openblas-devel needed if scipy builds from source
# Install system dependencies before building (Linux).
# On manylinux_2_28 scipy installs from a prebuilt wheel, so the
# openblas-devel that the old source-build fallback needed (and which
# lives in the PowerTools repo on AlmaLinux 8) is no longer required.
CIBW_BEFORE_ALL_LINUX: |
yum install -y openmpi-devel openblas-devel
yum install -y openmpi-devel

# Install system dependencies before building (macOS)
CIBW_BEFORE_ALL_MACOS: |
Expand All @@ -57,8 +59,9 @@ jobs:
pip install neuron==8.2.7
cd myogen/simulator/nmodl_files && nrnivmodl .

# Use manylinux2014 (manylinux_2_17)
CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
# manylinux_2_28 (glibc >= 2.28) so scipy >= 1.17 prebuilt wheels are
# available in the build container (scipy dropped manylinux2014).
CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28

# Custom repair to preserve NEURON mechanisms while adding proper platform tags
# Standard tools (auditwheel/delocate) strip the architecture directories
Expand All @@ -69,7 +72,7 @@ jobs:
wheel = Path(sys.argv[1])
dest = Path(sys.argv[2])
# Rename to manylinux without modifying contents
new_name = wheel.name.replace('linux_', 'manylinux_2_17_').replace('manylinux2014_', 'manylinux_2_17_')
new_name = wheel.name.replace('linux_', 'manylinux_2_28_').replace('manylinux2014_', 'manylinux_2_28_')
shutil.copy2(wheel, dest / new_name)
" {wheel} {dest_dir}

Expand All @@ -80,7 +83,7 @@ jobs:
run: ls -lh wheelhouse/

- name: Upload wheels
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: wheels-${{ matrix.os }}
path: wheelhouse/*.whl
Expand All @@ -90,10 +93,10 @@ jobs:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"

Expand All @@ -106,7 +109,7 @@ jobs:
run: python -m build --sdist --outdir dist/

- name: Upload sdist
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: sdist
path: dist/*.tar.gz
Expand All @@ -119,16 +122,16 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest] # Match build_wheels matrix
python-version: ["3.12"]
python-version: ["3.12", "3.13"]

steps:
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Download wheels
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: wheels-${{ matrix.os }}
path: dist/
Expand All @@ -151,7 +154,10 @@ jobs:

- name: Install wheel
run: |
pip install dist/*.whl
# dist/ now holds both cp312 and cp313 wheels; install only the one
# matching this runner's Python so pip doesn't reject the other ABI.
tag="cp$(echo '${{ matrix.python-version }}' | tr -d .)"
pip install dist/*"${tag}-${tag}"*.whl

- name: Test import
run: |
Expand All @@ -174,14 +180,14 @@ jobs:

steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: dist/
merge-multiple: true

- name: Upload artifacts to GitHub Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
with:
files: dist/*

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/sphinx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ jobs:
deploy-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python 3.12
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: 3.12.8

Expand All @@ -36,7 +36,7 @@ jobs:
cd ..

- name: Deploy
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/build/html
9 changes: 5 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python 3.12
uses: actions/setup-python@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: "3.12.8"
python-version: ${{ matrix.python-version }}

- name: System packages for NEURON / MPI (Linux)
if: runner.os == 'Linux'
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **Python 3.13 support.** `requires-python` is widened to `>=3.12,<3.14`, a `Python :: 3.13` classifier is added, and the CI test and wheel matrices now cover 3.13 (`cp312-* cp313-*`). NEURON 8.2.7 ships cp313 wheels, and the Cython extensions build and import under Python 3.13 against NumPy 2.x (verified locally).
- **NumPy 2.x support.** With `elephant` gone (the reason for the `numpy<2.0` pin), the runtime requirement is relaxed to `numpy>=1.26` and the build-system now compiles the Cython extensions against `numpy>=2.0`. Wheels built against NumPy 2.0 remain backward-compatible with NumPy 1.x at runtime (verified: the compiled extensions import under both NumPy 2.4 and 1.26). The Linux wheel build base is bumped from manylinux2014 to **manylinux_2_28** (glibc ≥ 2.28, which drops EOL CentOS/RHEL 7) so the `scipy<1.17` build cap can be lifted (scipy ≥ 1.17 only ships manylinux_2_28 wheels).

### Removed
- **`elephant` (and `viziphant`) dependency dropped entirely.** Spike binning via `elephant.conversion.BinnedSpikeTrain` is replaced by a dependency-free `myogen.utils.bin_spike_trains` helper (an exact reimplementation, verified bit-identical across grid/edge/fractional/sparse cases), and `elephant.statistics.isi` in `utils/helper.py` is replaced by `numpy.diff` (identical for sorted spike trains). The example scripts now compute firing rates, PSTHs and rasters natively (the `viziphant` raster is replaced by a small matplotlib helper). The `[elephant]` optional extra and the `elephant`/`viziphant` dev/docs dependencies are removed. `import myogen`, the test suite, and the docs build no longer require `elephant`.

## [0.9.0] - 2026-04-19

### Added
Expand Down
5 changes: 2 additions & 3 deletions docs/neo_blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,8 @@ windowed = spiketrain.time_slice(t_start, t_stop)
# Calculate RMS
rms = np.sqrt(np.mean(signal.magnitude ** 2))

# Firing rate (requires elephant)
import elephant.statistics
rate = elephant.statistics.mean_firing_rate(spiketrain)
# Mean firing rate (spikes over the active duration)
rate = (len(spiketrain) / (spiketrain.t_stop - spiketrain.t_start)).rescale("Hz")
```

## Iteration Examples
Expand Down
4 changes: 1 addition & 3 deletions docs/source/neo_blocks_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,12 @@ The ``SPIKE_TRAIN__Block`` stores neural firing patterns from motor neuron pools

.. code-block:: python

import elephant.statistics

motor_pool = spike_train__Block.segments[0]
firing_rates = []

for spiketrain in motor_pool.spiketrains:
if len(spiketrain) > 0:
rate = elephant.statistics.mean_firing_rate(spiketrain)
rate = (len(spiketrain) / (spiketrain.t_stop - spiketrain.t_start)).rescale("Hz")
firing_rates.append(rate.magnitude)

print(f"Mean firing rate: {np.mean(firing_rates):.2f} Hz")
Expand Down
65 changes: 51 additions & 14 deletions examples/01_basic/02_simulate_spike_trains_current_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@

from pathlib import Path

import elephant
import joblib
import neuron
import numpy as np
Expand All @@ -48,7 +47,7 @@
from matplotlib import pyplot as plt
from neo import Block, Segment, SpikeTrain
from neuron import h
from viziphant.rasterplot import rasterplot_rates
from scipy.ndimage import gaussian_filter1d

from myogen import get_random_generator
from myogen.simulator.neuron.populations import AlphaMN__Pool
Expand All @@ -61,6 +60,41 @@

plt.style.use("fivethirtyeight")


def mean_firing_rate(spiketrain):
"""Mean firing rate of a neo.SpikeTrain (replaces elephant.statistics.mean_firing_rate)."""
return (len(spiketrain) / (spiketrain.t_stop - spiketrain.t_start)).rescale(pq.Hz)


def rasterplot_rates(spiketrains, filter_function=None):
"""Native spike raster with top/right marginal axes.

Lightweight stand-in for ``viziphant.rasterplot.rasterplot_rates``: draws a
spike raster (one row per train), a right marginal showing each train's mean
firing rate, and an (initially empty) top marginal that the caller fills with
the smoothed population rate. Returns ``(ax, axhistx, axhisty)`` as
absolutely-positioned axes so manual position tweaks downstream still work.
"""
if filter_function is not None:
spiketrains = [st for st in spiketrains if filter_function(st)]

fig = plt.figure()
ax = fig.add_axes((0.10, 0.10, 0.62, 0.62))
axhistx = fig.add_axes((0.10, 0.74, 0.62, 0.16), sharex=ax)
axhisty = fig.add_axes((0.74, 0.10, 0.16, 0.62), sharey=ax)

ax.eventplot(
[st.rescale(pq.s).magnitude for st in spiketrains],
lineoffsets=np.arange(len(spiketrains)),
colors="black",
linelengths=0.8,
linewidths=0.7,
)
rates = [float(mean_firing_rate(st).magnitude) for st in spiketrains]
axhisty.barh(np.arange(len(spiketrains)), rates, height=0.85, color="C0")
ax.set_ylim(-1, max(len(spiketrains), 1))
return ax, axhistx, axhisty

##############################################################################
# Create Motor Neuron Populations (Pools)
# ---------------------------------------
Expand Down Expand Up @@ -267,7 +301,7 @@
firing_rates = [
np.array(
[
elephant.statistics.mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
for st__s in spike_train__segment.spiketrains
if len(st__s) > 1 # Need at least 2 spikes to compute rate over spike range
]
Expand Down Expand Up @@ -312,19 +346,22 @@


if len(active_spiketrains) > 0:
from elephant.kernels import GaussianKernel

rate = elephant.statistics.instantaneous_rate(
active_spiketrains,
sampling_period=(h.dt * pq.ms).rescale(pq.s),
kernel=GaussianKernel(sigma=15 * pq.ms), # type: ignore
# Population firing rate over time, Gaussian-smoothed (sigma = 15 ms).
# Native replacement for elephant.statistics.instantaneous_rate.
sampling_period_s = (h.dt * pq.ms).rescale(pq.s).magnitude
t_start = min(st.t_start for st in active_spiketrains).rescale(pq.s).magnitude
t_stop = max(st.t_stop for st in active_spiketrains).rescale(pq.s).magnitude
n_bins = int(round((t_stop - t_start) / sampling_period_s))
edges = t_start + np.arange(n_bins + 1) * sampling_period_s
all_spikes = np.concatenate(
[st.rescale(pq.s).magnitude for st in active_spiketrains]
)
all_spikes = all_spikes[(all_spikes >= edges[0]) & (all_spikes < edges[-1])]
counts, _ = np.histogram(all_spikes, bins=edges)
rate_hz = counts / sampling_period_s / len(active_spiketrains)
rate_hz = gaussian_filter1d(rate_hz, sigma=(15e-3) / sampling_period_s, mode="constant")

axhistx.plot(
rate.times.rescale(pq.s).magnitude,
rate.magnitude.mean(axis=1).flatten(),
linewidth=2,
)
axhistx.plot(edges[:-1] + sampling_period_s / 2, rate_hz, linewidth=2)
axhistx.set_ylabel("FR (pps)")
axhistx.set_xlim(ax.get_xlim()) # Match x-axis with raster plot

Expand Down
38 changes: 27 additions & 11 deletions examples/01_basic/03_simulate_spike_trains_descending_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
import itertools
from pathlib import Path

import elephant
import joblib
import numpy as np
import quantities as pq
Expand All @@ -67,6 +66,27 @@

plt.style.use("fivethirtyeight")


def mean_firing_rate(spiketrain):
"""Mean firing rate of a neo.SpikeTrain (replaces elephant.statistics.mean_firing_rate)."""
return (len(spiketrain) / (spiketrain.t_stop - spiketrain.t_start)).rescale(pq.Hz)


def population_psth(spiketrains, bin_size):
"""Total spike counts per bin across spiketrains, plus bin left edges (s).

Native replacement for elephant.statistics.time_histogram (output="counts").
"""
t_start = min(st.t_start for st in spiketrains).rescale(pq.s).magnitude
t_stop = max(st.t_stop for st in spiketrains).rescale(pq.s).magnitude
bs = (bin_size).rescale(pq.s).magnitude
n_bins = int((t_stop - t_start) / bs)
edges = t_start + np.arange(n_bins + 1) * bs
spikes = np.concatenate([st.rescale(pq.s).magnitude for st in spiketrains])
spikes = spikes[(spikes >= edges[0]) & (spikes < edges[-1])] # drop right-edge spikes
counts, _ = np.histogram(spikes, bins=edges)
return counts, edges[:-1]

##############################################################################
# Create Populations
# ------------------------
Expand Down Expand Up @@ -322,7 +342,7 @@
# Calculate DD firing rates
dd_firing_rates = np.array(
[
elephant.statistics.mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
for st__s in dd_segment.spiketrains
if len(st__s) > 0
]
Expand All @@ -331,7 +351,7 @@
# Calculate MN firing rates
mn_firing_rates = np.array(
[
elephant.statistics.mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max()))
for st__s in mn_segment.spiketrains
if len(st__s) > 0
]
Expand Down Expand Up @@ -401,15 +421,11 @@
# 4. Population firing rates over time (binned)
bin_size_ms = 100

dd_psth = elephant.statistics.time_histogram(dd_segment.spiketrains, bin_size_ms * pq.ms)
dd_rates_binned = (
(dd_psth / (bin_size_ms * pq.ms) / descending_drive_pool.n).rescale(pq.Hz).magnitude
)

mn_psth = elephant.statistics.time_histogram(mn_segment.spiketrains, bin_size_ms * pq.ms)
mn_rates_binned = (mn_psth / (bin_size_ms * pq.ms) / motor_neuron_pool.n).rescale(pq.Hz).magnitude
dd_counts, bin_centers_s = population_psth(dd_segment.spiketrains, bin_size_ms * pq.ms)
dd_rates_binned = dd_counts / (bin_size_ms / 1000.0) / descending_drive_pool.n

bin_centers_s = dd_psth.times.rescale(pq.s).magnitude
mn_counts, _ = population_psth(mn_segment.spiketrains, bin_size_ms * pq.ms)
mn_rates_binned = mn_counts / (bin_size_ms / 1000.0) / motor_neuron_pool.n
axes[3].plot(bin_centers_s, dd_rates_binned, "b-", linewidth=2, label="DD Population", alpha=0.8)
axes[3].plot(bin_centers_s, mn_rates_binned, "r-", linewidth=2, label="MN Population", alpha=0.8)

Expand Down
Loading
Loading