Skip to content

Commit 9de85b0

Browse files
authored
Merge pull request #842 from apdavison/update-arbor
Update the Arbor backend to support Arbor 0.10.0 – 0.12.2
2 parents 17a3c38 + 1542bb1 commit 9de85b0

14 files changed

Lines changed: 560 additions & 55 deletions

File tree

.github/workflows/full-test.yml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
- name: Install Arbor
6666
if: startsWith(matrix.os, 'ubuntu')
6767
run: |
68-
python -m pip install arbor==0.9.0 libNeuroML morphio
68+
python -m pip install arbor==0.10.0 libNeuroML morphio
6969
- name: Install NESTML
7070
if: startsWith(matrix.os, 'ubuntu')
7171
run: |
@@ -102,6 +102,35 @@ jobs:
102102
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103103
COVERALLS_FLAG_NAME: ${{ matrix.test-name }}
104104
COVERALLS_PARALLEL: true
105+
arbor-versions:
106+
# Check that the Arbor backend works across the supported version range
107+
name: Arbor ${{ matrix.arbor-version }} (Ubuntu, Python 3.12)
108+
runs-on: ubuntu-latest
109+
strategy:
110+
fail-fast: false
111+
matrix:
112+
arbor-version: ["0.10.0", "0.12.2"]
113+
steps:
114+
- uses: actions/checkout@v6
115+
- name: Set up Python 3.12
116+
uses: actions/setup-python@v6
117+
with:
118+
python-version: "3.12"
119+
- name: Install Arbor ${{ matrix.arbor-version }} and dependencies
120+
run: |
121+
python -m pip install --upgrade pip
122+
python -m pip install pytest scipy
123+
python -m pip install "arbor==${{ matrix.arbor-version }}" libNeuroML morphio
124+
- name: Install PyNN itself
125+
run: |
126+
python -m pip install -e ".[test]"
127+
- name: Test that the Arbor backend imports (builds the catalogue)
128+
run: |
129+
python -c "import arbor; print('arbor', arbor.__version__)"
130+
python -c "import pyNN.arbor"
131+
- name: Run Arbor unit and scenario tests
132+
run: |
133+
pytest -v test/unittests/test_arbor.py test/system/scenarios -k arbor
105134
coveralls:
106135
name: Indicate completion to coveralls.io
107136
needs: test

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ $(NEST_STAMP): $(NEST_VENV_STAMP) $(NEST_SRC_UNPACKED)
9797
$(CURDIR)/pyNN/nest/extensions
9898
cd $(NEST_BUILD_DIR)/pynn_extensions && make install
9999
$(NEST_PIP) install \
100-
"neuron>=9.0.0" nrnutils "arbor==0.9.0" \
100+
"neuron>=9.0.0" nrnutils "arbor==0.10.0" \
101101
brian2 libNeuroML scipy matplotlib Cheetah3 h5py Jinja2 \
102102
pytest pytest-xdist pytest-cov flake8 morphio nestml
103103
$(NEST_PIP) install -e .

pyNN/arbor/_compat.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Compatibility shims for supporting a range of Arbor versions from a single codebase.
3+
4+
:copyright: Copyright 2006-2026 by the PyNN team, see AUTHORS.
5+
:license: CeCILL, see LICENSE for details.
6+
"""
7+
8+
import arbor
9+
from arbor import units as U
10+
11+
12+
# --- iclamp was renamed to i_clamp in Arbor 0.12 -----------------------------
13+
14+
_MECHANISM_RENAMES = {"iclamp": "i_clamp"}
15+
16+
17+
def get_electrode_mechanism(name):
18+
"""Return the Arbor current-source mechanism class for the given PyNN model
19+
name, accounting for renames across Arbor versions (e.g. iclamp -> i_clamp)."""
20+
for candidate in (name, _MECHANISM_RENAMES.get(name)):
21+
if candidate is not None and hasattr(arbor, candidate):
22+
return getattr(arbor, candidate)
23+
raise AttributeError(f"Arbor has no current-source mechanism {name!r}")
24+
25+
26+
# --- decor.place() dropped the label arg for current stimuli in Arbor 0.12 ----
27+
28+
def _current_stimulus_place_takes_label():
29+
decor = arbor.decor()
30+
clamp = get_electrode_mechanism("iclamp")(0.0 * U.ms, 0.0 * U.ms, 0.0 * U.nA)
31+
try:
32+
decor.place("(root)", clamp, "_probe")
33+
return True
34+
except TypeError:
35+
return False
36+
37+
38+
_CURRENT_PLACE_TAKES_LABEL = _current_stimulus_place_takes_label()
39+
40+
41+
def place_current_source(decor, locset, mechanism, label):
42+
"""Place a current-clamp stimulus on a decor. Arbor 0.12 dropped the label
43+
argument from the current-stimulus ``place()`` overload that 0.10 accepted
44+
(synapse/junction/detector placements keep their label in all versions)."""
45+
if _CURRENT_PLACE_TAKES_LABEL:
46+
decor.place(locset, mechanism, label)
47+
else:
48+
decor.place(locset, mechanism)
49+
50+
51+
# --- cv_policy_max_extent gained units in Arbor 0.12 -------------------------
52+
53+
def _cv_policy_wants_units():
54+
try:
55+
arbor.cv_policy_max_extent(1.0 * U.um)
56+
return True
57+
except TypeError:
58+
return False
59+
60+
61+
_CV_POLICY_WANTS_UNITS = _cv_policy_wants_units()
62+
63+
64+
def max_extent_policy(length_um):
65+
"""Build a max-extent cv_policy from a length in micrometres, handling the
66+
0.12 change that requires a unit-typed quantity."""
67+
if _CV_POLICY_WANTS_UNITS:
68+
return arbor.cv_policy_max_extent(length_um * U.um)
69+
return arbor.cv_policy_max_extent(length_um)
70+
71+
72+
# --- discretisation moved from decor to cable_cell in Arbor 0.11 -------------
73+
74+
_DECOR_HAS_DISCRETIZATION = hasattr(arbor.decor(), "discretization")
75+
76+
77+
def make_cable_cell(tree, decor, labels, discretization):
78+
"""Construct an ``arbor.cable_cell``, applying the discretisation cv_policy in
79+
the way the installed Arbor version expects: as a ``decor`` method in 0.10, or
80+
as a ``cable_cell`` constructor argument in 0.11+."""
81+
if _DECOR_HAS_DISCRETIZATION:
82+
decor.discretization(discretization)
83+
return arbor.cable_cell(tree, decor, labels)
84+
return arbor.cable_cell(tree, decor, labels, discretization=discretization)

pyNN/arbor/cells.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@
22
from collections import defaultdict
33
from lazyarray import larray
44
import arbor
5+
from arbor import units as U
56
from neuroml import Point3DWithDiam
7+
from . import _compat
68
from ..morphology import Morphology, NeuroMLMorphology, MorphIOMorphology, IonChannelDistribution
79
from ..models import BaseCellType
810
from ..parameters import ParameterSpace
911

1012

13+
# Units for the parameters of current-source mechanisms (e.g. iclamp).
14+
ELECTRODE_PARAM_UNITS = {
15+
"tstart": U.ms,
16+
"duration": U.ms,
17+
"current": U.nA,
18+
}
19+
20+
1121
def convert_point(p3d: Point3DWithDiam) -> arbor.mpoint:
1222
return arbor.mpoint(p3d.x, p3d.y, p3d.z, p3d.diameter/2)
1323

@@ -64,7 +74,7 @@ def _build_tree(self, i):
6474
if segment.name not in self.labels:
6575
self.labels[segment.name] = f"(segment {i})"
6676
elif isinstance(std_morphology, MorphIOMorphology):
67-
tree = arbor.load_swc_neuron(std_morphology.morphology_file, raw=True)
77+
tree = arbor.load_swc_neuron(std_morphology.morphology_file).segment_tree
6878
else:
6979
raise ValueError("{} not supported as a neuron morphology".format(type(std_morphology)))
7080

@@ -89,18 +99,22 @@ def _build_decor(self, i):
8999
decor = arbor.decor()
90100
# Set the default properties of the cell (this overrides the model defaults).
91101
decor.set_property(
92-
cm=self.parameters["cm"][i] * 0.01, # µF/cm² -> F/m²
93-
rL=self.parameters["Ra"][i] * 1, # Ω·cm
94-
Vm=self.initial_values["v"][i]
102+
cm=self.parameters["cm"][i] * U.uF / U.cm2,
103+
rL=self.parameters["Ra"][i] * U.Ohm * U.cm,
104+
Vm=self.initial_values["v"][i] * U.mV
95105
)
96106
if not self.parameters["ionic_species"]._evaluated:
97107
self.parameters["ionic_species"].evaluate(simplify=True)
98108
for ion_name, ionic_species in self.parameters["ionic_species"].items():
99109
assert ion_name == ionic_species.ion_name
100-
decor.set_ion(ion_name,
101-
int_con=ionic_species.internal_concentration,
102-
ext_con=ionic_species.external_concentration,
103-
rev_pot=ionic_species.reversal_potential) # method="nernst/na")
110+
ion_kwargs = {}
111+
if ionic_species.internal_concentration is not None:
112+
ion_kwargs["int_con"] = ionic_species.internal_concentration * U.mM
113+
if ionic_species.external_concentration is not None:
114+
ion_kwargs["ext_con"] = ionic_species.external_concentration * U.mM
115+
if ionic_species.reversal_potential is not None:
116+
ion_kwargs["rev_pot"] = ionic_species.reversal_potential * U.mV
117+
decor.set_ion(ion_name, **ion_kwargs) # method="nernst/na")
104118
for native_name, region_params in mechanism_parameters.items():
105119
for region, params in region_params.items():
106120
if native_name == "hh":
@@ -122,20 +136,20 @@ def _build_decor(self, i):
122136
# insert current sources
123137
for current_source in self.current_sources[i]:
124138
location_generator = current_source["location_generator"]
125-
mechanism = getattr(arbor, current_source["model_name"])
139+
mechanism = _compat.get_electrode_mechanism(current_source["model_name"])
126140
for locset, label in location_generator.generate_locations(morph, label=f"{current_source['model_name']}_label"):
127-
params = current_source["parameters"].evaluate(simplify=True)
141+
params = current_source["parameters"].evaluate(simplify=True).as_dict()
142+
params = {
143+
name: value * ELECTRODE_PARAM_UNITS[name] if name in ELECTRODE_PARAM_UNITS else value
144+
for name, value in params.items()
145+
}
128146
mech = mechanism(**params)
129-
decor.place(locset, mech, label)
147+
_compat.place_current_source(decor, locset, mech, label)
130148

131149
# add spike source
132-
decor.place('"root"', arbor.threshold_detector(-10), "detector")
150+
decor.place('"root"', arbor.threshold_detector(-10 * U.mV), "detector")
133151
# todo: allow user to choose location and threshold value
134152

135-
policy = arbor.cv_policy_max_extent(10.0)
136-
# to do: allow user to specify this value and/or the policy more generally
137-
decor.discretization(policy)
138-
139153
return decor
140154

141155
def set_initial_values(self, variable, initial_values):
@@ -158,10 +172,14 @@ def set_shape(self, value):
158172
pse.parameter_space.shape = value
159173

160174
def __call__(self, i):
175+
# The discretisation cv_policy is applied by _compat.make_cable_cell at
176+
# cell-construction time (on the decor in Arbor 0.10, or as a cable_cell
177+
# argument in 0.11+). to do: allow the user to specify this value/policy.
161178
return {
162179
"tree": self._build_tree(i),
163180
"decor": self._build_decor(i),
164-
"labels": arbor.label_dict(self.labels)
181+
"labels": arbor.label_dict(self.labels),
182+
"discretization": _compat.max_extent_policy(10.0)
165183
}
166184

167185

pyNN/arbor/morphology.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,12 @@ def generate_locations(self, morphology, label):
8989
for location in self.labels:
9090
if location == "soma":
9191
# todo: proper location mapping
92-
locsets.append(('"root"', f"{label}-{location}"))
92+
# Use the resolved locset expression rather than a label reference
93+
# ('"root"'): Arbor 0.12's label resolution does not resolve label
94+
# references in probe locsets against the cell's label_dict.
95+
locsets.append(('(root)', f"{label}-{location}"))
9396
elif location == "dendrite":
94-
locsets.append(('"mid-dend"', f"{label}-{location}"))
97+
locsets.append(('(location 0 0.5)', f"{label}-{location}"))
9598
elif isinstance(location, str):
9699
locsets.append((
97100
f'(on-components 0.5 (region "{location}"))',

pyNN/arbor/populations.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ..standardmodels import StandardCellType
1111
from ..parameters import ParameterSpace, simplify, Sequence
1212
from . import simulator
13+
from . import _compat
1314
from .recording import Recorder
1415

1516

@@ -87,11 +88,22 @@ def arbor_cell_description(self, gid):
8788
for key, value in params.items():
8889
if isinstance(value, Sequence):
8990
params[key] = value.value
90-
schedule = self.celltype.arbor_schedule(**params)
91+
schedule_units = getattr(self.celltype, "arbor_schedule_units", {})
92+
schedule_params = {}
93+
for key, value in params.items():
94+
unit = schedule_units.get(key)
95+
if unit is None or value is None:
96+
schedule_params[key] = value
97+
elif isinstance(value, (list, tuple, np.ndarray)):
98+
schedule_params[key] = [float(v) * unit for v in value]
99+
else:
100+
schedule_params[key] = value * unit
101+
schedule = self.celltype.arbor_schedule(**schedule_params)
91102
return arbor.spike_source_cell("spike-source", schedule)
92103
else:
93104
args = self._arbor_cell_description[index]
94-
return arbor.cable_cell(args["tree"], args["decor"], args["labels"])
105+
return _compat.make_cable_cell(
106+
args["tree"], args["decor"], args["labels"], args["discretization"])
95107

96108
def _create_cells(self):
97109
# for now, we create all cells and store them in memory,

pyNN/arbor/projections.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from itertools import repeat
77

88
import arbor
9+
from arbor import units as U
910

1011
from .. import common
1112
from ..core import ezip
@@ -89,7 +90,7 @@ def arbor_connections(self, gid):
8990
(self.pre[cg.presynaptic_index], source),
9091
target,
9192
cg.weight,
92-
cg.delay
93+
cg.delay * U.ms
9394
)
9495
)
9596
else:

pyNN/arbor/recording.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections import defaultdict
66
import numpy as np
77
import arbor
8+
from arbor import units as U
89

910
from .. import recording
1011
from . import simulator
@@ -58,18 +59,24 @@ def _localize_variables(self, variables, locations):
5859
return resolved_variables
5960

6061
def _set_arbor_sim(self, arbor_sim):
62+
# Since Arbor 0.10.0, probes are addressed by (gid, tag) rather than by
63+
# a positional cell_member index. The tag assigned here (a per-gid probe
64+
# counter, in the same iteration order as _get_arbor_probes) must match
65+
# the tag given to the corresponding probe there.
6166
self.handles = defaultdict(list)
6267
probe_indices = defaultdict(int)
6368
for variable in self.recorded:
6469
if variable.name != "spikes":
6570
for cell in self.recorded[variable]:
66-
probeset_id = arbor.cell_member(cell.gid, probe_indices[cell.gid])
71+
tag = str(probe_indices[cell.gid])
6772
probe_indices[cell.gid] += 1
68-
handle = arbor_sim.sample(probeset_id, arbor.regular_schedule(self.sampling_interval))
73+
handle = arbor_sim.sample(
74+
cell.gid, tag, arbor.regular_schedule(self.sampling_interval * U.ms))
6975
self.handles[variable].append(handle)
7076

7177
def _get_arbor_probes(self, gid):
7278
probes = []
79+
probe_index = 0
7380
for variable in self.recorded:
7481
if variable.location is None:
7582
pass
@@ -79,12 +86,15 @@ def _get_arbor_probes(self, gid):
7986
if gid in [cell.gid for cell in self.recorded[variable]]:
8087
if variable.name == "spikes":
8188
continue
82-
elif variable.name == "v":
83-
probe = arbor.cable_probe_membrane_voltage(locset)
89+
# Tag must match the one assigned in _set_arbor_sim (per-gid index).
90+
tag = str(probe_index)
91+
probe_index += 1
92+
if variable.name == "v":
93+
probe = arbor.cable_probe_membrane_voltage(locset, tag)
8494
else:
8595
mech_name, state_name = variable.name.split(".")
8696
arbor_model = mech_name # to do: find_arbor_model(mech_name)
87-
probe = arbor.cable_probe_density_state(locset, arbor_model, state_name)
97+
probe = arbor.cable_probe_density_state(locset, arbor_model, state_name, tag)
8898
probes.append(probe)
8999
return probes
90100

0 commit comments

Comments
 (0)