diff --git a/.github/workflows/full-test.yml b/.github/workflows/full-test.yml index 832d01e7..9ebcd9b1 100644 --- a/.github/workflows/full-test.yml +++ b/.github/workflows/full-test.yml @@ -65,7 +65,7 @@ jobs: - name: Install Arbor if: startsWith(matrix.os, 'ubuntu') run: | - python -m pip install arbor==0.9.0 libNeuroML morphio + python -m pip install arbor==0.10.0 libNeuroML morphio - name: Install NESTML if: startsWith(matrix.os, 'ubuntu') run: | @@ -102,6 +102,35 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.test-name }} COVERALLS_PARALLEL: true + arbor-versions: + # Check that the Arbor backend works across the supported version range + name: Arbor ${{ matrix.arbor-version }} (Ubuntu, Python 3.12) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arbor-version: ["0.10.0", "0.12.2"] + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install Arbor ${{ matrix.arbor-version }} and dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest scipy + python -m pip install "arbor==${{ matrix.arbor-version }}" libNeuroML morphio + - name: Install PyNN itself + run: | + python -m pip install -e ".[test]" + - name: Test that the Arbor backend imports (builds the catalogue) + run: | + python -c "import arbor; print('arbor', arbor.__version__)" + python -c "import pyNN.arbor" + - name: Run Arbor unit and scenario tests + run: | + pytest -v test/unittests/test_arbor.py test/system/scenarios -k arbor coveralls: name: Indicate completion to coveralls.io needs: test diff --git a/Makefile b/Makefile index a98014c8..7fd700c0 100644 --- a/Makefile +++ b/Makefile @@ -97,7 +97,7 @@ $(NEST_STAMP): $(NEST_VENV_STAMP) $(NEST_SRC_UNPACKED) $(CURDIR)/pyNN/nest/extensions cd $(NEST_BUILD_DIR)/pynn_extensions && make install $(NEST_PIP) install \ - "neuron>=9.0.0" nrnutils "arbor==0.9.0" \ + "neuron>=9.0.0" nrnutils "arbor==0.10.0" \ brian2 libNeuroML scipy matplotlib Cheetah3 h5py Jinja2 \ pytest pytest-xdist pytest-cov flake8 morphio nestml $(NEST_PIP) install -e . diff --git a/pyNN/arbor/_compat.py b/pyNN/arbor/_compat.py new file mode 100644 index 00000000..897304e3 --- /dev/null +++ b/pyNN/arbor/_compat.py @@ -0,0 +1,84 @@ +""" +Compatibility shims for supporting a range of Arbor versions from a single codebase. + +:copyright: Copyright 2006-2026 by the PyNN team, see AUTHORS. +:license: CeCILL, see LICENSE for details. +""" + +import arbor +from arbor import units as U + + +# --- iclamp was renamed to i_clamp in Arbor 0.12 ----------------------------- + +_MECHANISM_RENAMES = {"iclamp": "i_clamp"} + + +def get_electrode_mechanism(name): + """Return the Arbor current-source mechanism class for the given PyNN model + name, accounting for renames across Arbor versions (e.g. iclamp -> i_clamp).""" + for candidate in (name, _MECHANISM_RENAMES.get(name)): + if candidate is not None and hasattr(arbor, candidate): + return getattr(arbor, candidate) + raise AttributeError(f"Arbor has no current-source mechanism {name!r}") + + +# --- decor.place() dropped the label arg for current stimuli in Arbor 0.12 ---- + +def _current_stimulus_place_takes_label(): + decor = arbor.decor() + clamp = get_electrode_mechanism("iclamp")(0.0 * U.ms, 0.0 * U.ms, 0.0 * U.nA) + try: + decor.place("(root)", clamp, "_probe") + return True + except TypeError: + return False + + +_CURRENT_PLACE_TAKES_LABEL = _current_stimulus_place_takes_label() + + +def place_current_source(decor, locset, mechanism, label): + """Place a current-clamp stimulus on a decor. Arbor 0.12 dropped the label + argument from the current-stimulus ``place()`` overload that 0.10 accepted + (synapse/junction/detector placements keep their label in all versions).""" + if _CURRENT_PLACE_TAKES_LABEL: + decor.place(locset, mechanism, label) + else: + decor.place(locset, mechanism) + + +# --- cv_policy_max_extent gained units in Arbor 0.12 ------------------------- + +def _cv_policy_wants_units(): + try: + arbor.cv_policy_max_extent(1.0 * U.um) + return True + except TypeError: + return False + + +_CV_POLICY_WANTS_UNITS = _cv_policy_wants_units() + + +def max_extent_policy(length_um): + """Build a max-extent cv_policy from a length in micrometres, handling the + 0.12 change that requires a unit-typed quantity.""" + if _CV_POLICY_WANTS_UNITS: + return arbor.cv_policy_max_extent(length_um * U.um) + return arbor.cv_policy_max_extent(length_um) + + +# --- discretisation moved from decor to cable_cell in Arbor 0.11 ------------- + +_DECOR_HAS_DISCRETIZATION = hasattr(arbor.decor(), "discretization") + + +def make_cable_cell(tree, decor, labels, discretization): + """Construct an ``arbor.cable_cell``, applying the discretisation cv_policy in + the way the installed Arbor version expects: as a ``decor`` method in 0.10, or + as a ``cable_cell`` constructor argument in 0.11+.""" + if _DECOR_HAS_DISCRETIZATION: + decor.discretization(discretization) + return arbor.cable_cell(tree, decor, labels) + return arbor.cable_cell(tree, decor, labels, discretization=discretization) diff --git a/pyNN/arbor/cells.py b/pyNN/arbor/cells.py index 54c0173d..6cbbfb33 100644 --- a/pyNN/arbor/cells.py +++ b/pyNN/arbor/cells.py @@ -2,12 +2,22 @@ from collections import defaultdict from lazyarray import larray import arbor +from arbor import units as U from neuroml import Point3DWithDiam +from . import _compat from ..morphology import Morphology, NeuroMLMorphology, MorphIOMorphology, IonChannelDistribution from ..models import BaseCellType from ..parameters import ParameterSpace +# Units for the parameters of current-source mechanisms (e.g. iclamp). +ELECTRODE_PARAM_UNITS = { + "tstart": U.ms, + "duration": U.ms, + "current": U.nA, +} + + def convert_point(p3d: Point3DWithDiam) -> arbor.mpoint: return arbor.mpoint(p3d.x, p3d.y, p3d.z, p3d.diameter/2) @@ -64,7 +74,7 @@ def _build_tree(self, i): if segment.name not in self.labels: self.labels[segment.name] = f"(segment {i})" elif isinstance(std_morphology, MorphIOMorphology): - tree = arbor.load_swc_neuron(std_morphology.morphology_file, raw=True) + tree = arbor.load_swc_neuron(std_morphology.morphology_file).segment_tree else: raise ValueError("{} not supported as a neuron morphology".format(type(std_morphology))) @@ -89,18 +99,22 @@ def _build_decor(self, i): decor = arbor.decor() # Set the default properties of the cell (this overrides the model defaults). decor.set_property( - cm=self.parameters["cm"][i] * 0.01, # µF/cm² -> F/m² - rL=self.parameters["Ra"][i] * 1, # Ω·cm - Vm=self.initial_values["v"][i] + cm=self.parameters["cm"][i] * U.uF / U.cm2, + rL=self.parameters["Ra"][i] * U.Ohm * U.cm, + Vm=self.initial_values["v"][i] * U.mV ) if not self.parameters["ionic_species"]._evaluated: self.parameters["ionic_species"].evaluate(simplify=True) for ion_name, ionic_species in self.parameters["ionic_species"].items(): assert ion_name == ionic_species.ion_name - decor.set_ion(ion_name, - int_con=ionic_species.internal_concentration, - ext_con=ionic_species.external_concentration, - rev_pot=ionic_species.reversal_potential) # method="nernst/na") + ion_kwargs = {} + if ionic_species.internal_concentration is not None: + ion_kwargs["int_con"] = ionic_species.internal_concentration * U.mM + if ionic_species.external_concentration is not None: + ion_kwargs["ext_con"] = ionic_species.external_concentration * U.mM + if ionic_species.reversal_potential is not None: + ion_kwargs["rev_pot"] = ionic_species.reversal_potential * U.mV + decor.set_ion(ion_name, **ion_kwargs) # method="nernst/na") for native_name, region_params in mechanism_parameters.items(): for region, params in region_params.items(): if native_name == "hh": @@ -122,20 +136,20 @@ def _build_decor(self, i): # insert current sources for current_source in self.current_sources[i]: location_generator = current_source["location_generator"] - mechanism = getattr(arbor, current_source["model_name"]) + mechanism = _compat.get_electrode_mechanism(current_source["model_name"]) for locset, label in location_generator.generate_locations(morph, label=f"{current_source['model_name']}_label"): - params = current_source["parameters"].evaluate(simplify=True) + params = current_source["parameters"].evaluate(simplify=True).as_dict() + params = { + name: value * ELECTRODE_PARAM_UNITS[name] if name in ELECTRODE_PARAM_UNITS else value + for name, value in params.items() + } mech = mechanism(**params) - decor.place(locset, mech, label) + _compat.place_current_source(decor, locset, mech, label) # add spike source - decor.place('"root"', arbor.threshold_detector(-10), "detector") + decor.place('"root"', arbor.threshold_detector(-10 * U.mV), "detector") # todo: allow user to choose location and threshold value - policy = arbor.cv_policy_max_extent(10.0) - # to do: allow user to specify this value and/or the policy more generally - decor.discretization(policy) - return decor def set_initial_values(self, variable, initial_values): @@ -158,10 +172,14 @@ def set_shape(self, value): pse.parameter_space.shape = value def __call__(self, i): + # The discretisation cv_policy is applied by _compat.make_cable_cell at + # cell-construction time (on the decor in Arbor 0.10, or as a cable_cell + # argument in 0.11+). to do: allow the user to specify this value/policy. return { "tree": self._build_tree(i), "decor": self._build_decor(i), - "labels": arbor.label_dict(self.labels) + "labels": arbor.label_dict(self.labels), + "discretization": _compat.max_extent_policy(10.0) } diff --git a/pyNN/arbor/morphology.py b/pyNN/arbor/morphology.py index b3f00793..01a477d8 100644 --- a/pyNN/arbor/morphology.py +++ b/pyNN/arbor/morphology.py @@ -89,9 +89,12 @@ def generate_locations(self, morphology, label): for location in self.labels: if location == "soma": # todo: proper location mapping - locsets.append(('"root"', f"{label}-{location}")) + # Use the resolved locset expression rather than a label reference + # ('"root"'): Arbor 0.12's label resolution does not resolve label + # references in probe locsets against the cell's label_dict. + locsets.append(('(root)', f"{label}-{location}")) elif location == "dendrite": - locsets.append(('"mid-dend"', f"{label}-{location}")) + locsets.append(('(location 0 0.5)', f"{label}-{location}")) elif isinstance(location, str): locsets.append(( f'(on-components 0.5 (region "{location}"))', diff --git a/pyNN/arbor/populations.py b/pyNN/arbor/populations.py index f6aee0bf..02237271 100644 --- a/pyNN/arbor/populations.py +++ b/pyNN/arbor/populations.py @@ -10,6 +10,7 @@ from ..standardmodels import StandardCellType from ..parameters import ParameterSpace, simplify, Sequence from . import simulator +from . import _compat from .recording import Recorder @@ -87,11 +88,22 @@ def arbor_cell_description(self, gid): for key, value in params.items(): if isinstance(value, Sequence): params[key] = value.value - schedule = self.celltype.arbor_schedule(**params) + schedule_units = getattr(self.celltype, "arbor_schedule_units", {}) + schedule_params = {} + for key, value in params.items(): + unit = schedule_units.get(key) + if unit is None or value is None: + schedule_params[key] = value + elif isinstance(value, (list, tuple, np.ndarray)): + schedule_params[key] = [float(v) * unit for v in value] + else: + schedule_params[key] = value * unit + schedule = self.celltype.arbor_schedule(**schedule_params) return arbor.spike_source_cell("spike-source", schedule) else: args = self._arbor_cell_description[index] - return arbor.cable_cell(args["tree"], args["decor"], args["labels"]) + return _compat.make_cable_cell( + args["tree"], args["decor"], args["labels"], args["discretization"]) def _create_cells(self): # for now, we create all cells and store them in memory, diff --git a/pyNN/arbor/projections.py b/pyNN/arbor/projections.py index dadb2338..9a3e995d 100644 --- a/pyNN/arbor/projections.py +++ b/pyNN/arbor/projections.py @@ -6,6 +6,7 @@ from itertools import repeat import arbor +from arbor import units as U from .. import common from ..core import ezip @@ -89,7 +90,7 @@ def arbor_connections(self, gid): (self.pre[cg.presynaptic_index], source), target, cg.weight, - cg.delay + cg.delay * U.ms ) ) else: diff --git a/pyNN/arbor/recording.py b/pyNN/arbor/recording.py index c85ca7e9..92f31cb2 100644 --- a/pyNN/arbor/recording.py +++ b/pyNN/arbor/recording.py @@ -5,6 +5,7 @@ from collections import defaultdict import numpy as np import arbor +from arbor import units as U from .. import recording from . import simulator @@ -58,18 +59,24 @@ def _localize_variables(self, variables, locations): return resolved_variables def _set_arbor_sim(self, arbor_sim): + # Since Arbor 0.10.0, probes are addressed by (gid, tag) rather than by + # a positional cell_member index. The tag assigned here (a per-gid probe + # counter, in the same iteration order as _get_arbor_probes) must match + # the tag given to the corresponding probe there. self.handles = defaultdict(list) probe_indices = defaultdict(int) for variable in self.recorded: if variable.name != "spikes": for cell in self.recorded[variable]: - probeset_id = arbor.cell_member(cell.gid, probe_indices[cell.gid]) + tag = str(probe_indices[cell.gid]) probe_indices[cell.gid] += 1 - handle = arbor_sim.sample(probeset_id, arbor.regular_schedule(self.sampling_interval)) + handle = arbor_sim.sample( + cell.gid, tag, arbor.regular_schedule(self.sampling_interval * U.ms)) self.handles[variable].append(handle) def _get_arbor_probes(self, gid): probes = [] + probe_index = 0 for variable in self.recorded: if variable.location is None: pass @@ -79,12 +86,15 @@ def _get_arbor_probes(self, gid): if gid in [cell.gid for cell in self.recorded[variable]]: if variable.name == "spikes": continue - elif variable.name == "v": - probe = arbor.cable_probe_membrane_voltage(locset) + # Tag must match the one assigned in _set_arbor_sim (per-gid index). + tag = str(probe_index) + probe_index += 1 + if variable.name == "v": + probe = arbor.cable_probe_membrane_voltage(locset, tag) else: mech_name, state_name = variable.name.split(".") arbor_model = mech_name # to do: find_arbor_model(mech_name) - probe = arbor.cable_probe_density_state(locset, arbor_model, state_name) + probe = arbor.cable_probe_density_state(locset, arbor_model, state_name, tag) probes.append(probe) return probes diff --git a/pyNN/arbor/simulator.py b/pyNN/arbor/simulator.py index 43ec0f69..a32f7d18 100644 --- a/pyNN/arbor/simulator.py +++ b/pyNN/arbor/simulator.py @@ -10,9 +10,14 @@ import numpy as np try: from mpi4py import MPI -except ImportError: - pass +except (ImportError, RuntimeError): + # ImportError: mpi4py not installed. + # RuntimeError: mpi4py is installed but no MPI runtime is available (recent + # mpi4py loads the MPI library at import time), e.g. `pip install pyNN[arbor]` + # without MPI. Fall back to non-MPI operation in both cases. + MPI = None import arbor +from arbor import units as U from .. import common from ..core import find @@ -20,10 +25,20 @@ name = "Arbor" +def catalogue_path(): + # The compiled catalogue is ABI-specific to the Arbor version, so it is keyed + # on arbor.__version__ to allow switching Arbor versions in the same install + # without loading a stale, incompatible .so. + return os.path.join( + os.path.dirname(__file__), "nmodl", f"PyNN-catalogue-{arbor.__version__}.so" + ) + + def build_mechanisms(): # run `arbor-build-catalogue ` mech_path = os.path.join(os.path.dirname(__file__), "nmodl") - if not os.path.exists(os.path.join(mech_path, "PyNN-catalogue.so")): + target = catalogue_path() + if not os.path.exists(target): cat_builder = find("arbor-build-catalogue") if not cat_builder: raise Exception("Unable to find arbor-build-catalogue. Please ensure Arbor is correctly installed.") @@ -34,9 +49,10 @@ def build_mechanisms(): proc = subprocess.run([cat_builder, "PyNN", mech_path], cwd=mech_path, env=env) if proc.returncode != 0: raise Exception("Unable to compile Arbor mechanisms (arbor-build-catalogue returned non-zero exit status).") - else: - logger.info("Successfully compiled Arbor mechanisms.") - return mech_path + # arbor-build-catalogue writes PyNN-catalogue.so; move it to the versioned name. + os.replace(os.path.join(mech_path, "PyNN-catalogue.so"), target) + logger.info("Successfully compiled Arbor mechanisms.") + return mech_path class Cell(int, common.IDMixin): @@ -148,10 +164,7 @@ def global_properties(self, kind): """ if kind == arbor.cell_kind.cable: props = arbor.neuron_cable_properties() - catalogue_path = os.path.join( - os.path.dirname(__file__), "nmodl", "PyNN-catalogue.so" - ) - props.catalogue = arbor.load_catalogue(catalogue_path) + props.catalogue = arbor.load_catalogue(catalogue_path()) return props # Spike source cells have nothing to report. return None @@ -167,15 +180,11 @@ def __init__(self): self.dt = 0.1 alloc = arbor.proc_allocation(threads=self.num_threads) config = arbor.config() - if config["mpi4py"]: + if config["mpi4py"] and MPI is not None: comm = arbor.mpi_comm(MPI.COMM_WORLD) else: comm = None self.arbor_context = arbor.context(alloc, mpi=comm) - # unclear if we can create the recipe now, or if we have to - # construct it only when we've assembled the whole network - self.network = NetworkRecipe() - self.arbor_sim = None # for debugging @property def mpi_rank(self): @@ -191,12 +200,14 @@ def run(self, simtime): hints = {} decomp = arbor.partition_load_balance(recipe, self.arbor_context, hints) self.arbor_sim = arbor.simulation(recipe, self.arbor_context, decomp, self.rng_seed) - self.arbor_sim.record(arbor.spike_recording.all) + # Use local (per-rank) spike recording rather than the global mode, + # which matches PyNN's rank-aware recorders. + self.arbor_sim.record(arbor.spike_recording.local) # todo: for now record all, but should be controlled by population.record() for recorder in self.recorders: recorder._set_arbor_sim(self.arbor_sim) self.t += simtime - self.arbor_sim.run(self.t, self.dt) + self.arbor_sim.run(self.t * U.ms, self.dt * U.ms) self.running = True def run_until(self, tstop): @@ -206,6 +217,11 @@ def clear(self): self.recorders = set([]) self.id_counter = 0 self.segment_counter = -1 + # Start each simulation with a fresh recipe, otherwise populations from a + # previous setup()/run() leak into the network and gid lookups break when + # several simulations run in one process. + self.network = NetworkRecipe() + self.arbor_sim = None self.reset() def reset(self): diff --git a/pyNN/arbor/standardmodels.py b/pyNN/arbor/standardmodels.py index cc4c3cc1..94c22e34 100644 --- a/pyNN/arbor/standardmodels.py +++ b/pyNN/arbor/standardmodels.py @@ -10,6 +10,7 @@ import numpy as np import arbor +from arbor import units as U from ..standardmodels import cells, ion_channels, synapses, electrodes, receptors, build_translations from ..parameters import ParameterSpace, IonicSpecies @@ -32,6 +33,7 @@ class SpikeSourcePoisson(cells.SpikeSourcePoisson): # todo: manage "seed" arbor_cell_kind = arbor.cell_kind.spike_source arbor_schedule = arbor.poisson_schedule + arbor_schedule_units = {"tstart": U.ms, "freq": U.Hz, "tstop": U.ms} class SpikeSourceArray(cells.SpikeSourceArray): @@ -42,6 +44,8 @@ class SpikeSourceArray(cells.SpikeSourceArray): ) arbor_cell_kind = arbor.cell_kind.spike_source arbor_schedule = arbor.explicit_schedule + # Since Arbor 0.10.0, schedule parameters must be unit-typed. + arbor_schedule_units = {"times": U.ms} class BaseCurrentSource(object): @@ -225,12 +229,20 @@ class MultiCompartmentNeuron(cells.MultiCompartmentNeuron): variable_map = {"v": "Vm"} def __init__(self, **parameters): - # replace ion channel classes with instantiated ion channel objects - for name, ion_channel in self.ion_channels.items(): - self.ion_channels[name] = ion_channel(**parameters.pop(name, {})) - # ditto for post synaptic responses - for name, pse in self.post_synaptic_entities.items(): - self.post_synaptic_entities[name] = pse(**parameters.pop(name, {})) + # Instantiate the ion-channel and post-synaptic classes into new, + # instance-level dicts. These are declared as class attributes holding + # the classes; building fresh dicts here (rather than assigning into the + # class-level dict) avoids mutating that shared state -- otherwise a + # second instantiation would try to call an already-instantiated object + # ("'PassiveLeak' object is not callable"). + self.ion_channels = { + name: ion_channel(**parameters.pop(name, {})) + for name, ion_channel in self.ion_channels.items() + } + self.post_synaptic_entities = { + name: pse(**parameters.pop(name, {})) + for name, pse in self.post_synaptic_entities.items() + } super(MultiCompartmentNeuron, self).__init__(**parameters) for name, ion_channel in self.ion_channels.items(): self.parameter_space[name] = ion_channel.parameter_space diff --git a/pyproject.toml b/pyproject.toml index dcd3dced..bd0a8ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ sonata = ["h5py"] morphologies = ["morphio"] neuron = ["neuron", "nrnutils"] brian2 = ["brian2"] -arbor = ["arbor==0.9.0", "libNeuroML", "morphio"] +arbor = ["arbor>=0.10.0,<0.13", "libNeuroML", "morphio"] spiNNaker = ["spyNNaker"] neuroml = ["libNeuroML"] nestml = ["nestml"] diff --git a/test/system/scenarios/test_cell_types.py b/test/system/scenarios/test_cell_types.py index f7849555..96e3cb8e 100644 --- a/test/system/scenarios/test_cell_types.py +++ b/test/system/scenarios/test_cell_types.py @@ -5,7 +5,7 @@ have_scipy = True except ImportError: have_scipy = False -from numpy.testing import assert_array_equal +from numpy.testing import assert_array_equal, assert_allclose import quantities as pq from pyNN.parameters import Sequence from pyNN.errors import InvalidParameterValueError @@ -103,7 +103,7 @@ def issue367(sim, plot_figure=False): return data -@run_with_simulators("nest", "neuron", "brian2") +@run_with_simulators("nest", "neuron", "brian2", "arbor") def test_SpikeSourcePoisson(sim, plot_figure=False): try: from scipy.stats import kstest @@ -297,6 +297,21 @@ def test_update_SpikeSourceArray(sim, plot_figure=False): assert_array_equal(data[0].magnitude, np.array([12, 15, 18, 22, 25])) +@run_with_simulators("nest", "neuron", "brian2", "arbor") +def test_SpikeSourceArray_delivers_spike_times(sim): + """A SpikeSourceArray emits exactly its specified spike times.""" + sim.setup(timestep=0.1) + spike_times = [10.0, 25.0, 40.0, 55.0] + sources = sim.Population(2, sim.SpikeSourceArray(spike_times=spike_times)) + sources.record('spikes') + sim.run(70.0) + spiketrains = sources.get_data().segments[0].spiketrains + assert len(spiketrains) == 2 + for st in spiketrains: + assert_allclose(np.array(st.magnitude), spike_times, atol=0.2) + sim.end() + + @run_with_simulators("nest", "brian2") def test_IF_curr_delta_voltage_step(sim): """A single delta-synapse input steps V by the weight (mV). diff --git a/test/system/scenarios/test_mc.py b/test/system/scenarios/test_mc.py new file mode 100644 index 00000000..f9256964 --- /dev/null +++ b/test/system/scenarios/test_mc.py @@ -0,0 +1,135 @@ +""" +System tests for multicompartment (MC) neurons. + +These run on the backends with MC support (Arbor and NEURON) and fill gaps left +by test_scenario5 (which records spikes only): recording analog signals at named +locations and from density-mechanism state variables, and sub-threshold current +injection. Several assertions are regression guards for bugs found while +supporting Arbor 0.10 - 0.12 (spike recording returning nothing, and +probe-location label resolution). +""" + +try: + import morphio # noqa: F401 + have_morphio = True +except ImportError: + have_morphio = False + +try: + from neuroml import Morphology, Segment, Point3DWithDiam as P + have_neuroml = True +except ImportError: + have_neuroml = False + +from pyNN.morphology import NeuroMLMorphology +from pyNN.parameters import IonicSpecies + +import pytest + +from .fixtures import run_with_simulators + + +def _build_mc_cell_type(sim): + """A two-compartment (soma + dendrite) multicompartment cell type.""" + soma = Segment(proximal=P(x=18.8, y=0, z=0, diameter=18.8), + distal=P(x=0, y=0, z=0, diameter=18.8), name="soma", id=0) + dend = Segment(proximal=P(x=0, y=0, z=0, diameter=2), + distal=P(x=-500, y=0, z=0, diameter=2), name="dendrite", + parent=soma, id=1) + cell_class = sim.MultiCompartmentNeuron + cell_class.label = "MCTestNeuron" + cell_class.ion_channels = {'pas': sim.PassiveLeak, 'na': sim.NaChannel, + 'kdr': sim.KdrChannel} + return cell_class( + morphology=NeuroMLMorphology(Morphology(segments=(soma, dend))), + cm=1.0, Ra=500.0, + ionic_species={"na": IonicSpecies("na", reversal_potential=50.0), + "k": IonicSpecies("k", reversal_potential=-77.0)}, + pas={"conductance_density": sim.morphology.uniform('all', 0.0003), + "e_rev": -54.3}, + na={"conductance_density": sim.morphology.uniform('soma', 0.120)}, + kdr={"conductance_density": sim.morphology.uniform('soma', 0.036)}, + ) + + +@run_with_simulators("arbor", "neuron") +def test_mc_recording_at_locations(sim): + """Record v at two locations, density-mechanism states, and spikes. + + Guards: probe-tag matching, probe-location label resolution, and spike + recording actually returning data (all had silent bugs across Arbor versions). + """ + if not have_morphio: + pytest.skip("morphio not available") + if not have_neuroml: + pytest.skip("libNeuroML not available") + + sim.setup(timestep=0.025) + cell_type = _build_mc_cell_type(sim) + cells = sim.Population(2, cell_type, initial_values={'v': -60.0}) + + # inject a supra-threshold current into cell 0 only + step = sim.DCSource(amplitude=0.5, start=20.0, stop=180.0) + step.inject_into(cells[0:1], location="soma") + + cells.record('spikes') + cells.record(['na.m', 'na.h', 'kdr.n'], locations="soma") + cells.record('v', locations=("soma", "dendrite")) + + sim.run(200.0) + data = cells.get_data().segments[0] + + # analog signals exist at both locations, one column per cell + soma_v = data.filter(name='soma.v')[0] + dend_v = data.filter(name='dendrite.v')[0] + assert soma_v.shape[1] == 2 + assert dend_v.shape[1] == 2 + assert soma_v.shape[0] > 1 + + soma_v = soma_v.magnitude + # injected cell 0 fires (crosses 0 mV); uninjected cell 1 stays sub-threshold + assert soma_v[:, 0].max() > 0.0 + assert soma_v[:, 1].max() < 0.0 + + # density-mechanism state variables are recorded and physically plausible (0..1) + for name in ('soma.na.m', 'soma.na.h', 'soma.kdr.n'): + sig = data.filter(name=name)[0] + assert sig.shape[1] == 2 + assert sig.magnitude.min() >= -1e-6 + assert sig.magnitude.max() <= 1.0 + 1e-6 + + # spikes are recorded: injected cell fires, uninjected cell is silent + spiketrains = data.spiketrains + assert len(spiketrains) == 2 + counts = sorted(len(st) for st in spiketrains) + assert counts[0] == 0 + assert counts[1] >= 1 + + sim.end() + + +@run_with_simulators("arbor", "neuron") +def test_mc_subthreshold_dc(sim): + """A small current depolarises the cell without triggering a spike.""" + if not have_morphio: + pytest.skip("morphio not available") + if not have_neuroml: + pytest.skip("libNeuroML not available") + + sim.setup(timestep=0.01) + cell_type = _build_mc_cell_type(sim) + cells = sim.Population(1, cell_type, initial_values={'v': -60.0}) + step = sim.DCSource(amplitude=0.04, start=50.0, stop=150.0) + step.inject_into(cells[0:1], location="soma") + cells.record('v', locations=("soma",)) + cells.record('spikes') + + sim.run(200.0) + data = cells.get_data().segments[0] + soma_v = data.filter(name='soma.v')[0].magnitude[:, 0] + + assert len(data.spiketrains[0]) == 0 # no spike + assert soma_v.max() < -40.0 # stayed sub-threshold + assert soma_v.max() > -60.0 # but did depolarise + + sim.end() diff --git a/test/unittests/test_arbor.py b/test/unittests/test_arbor.py new file mode 100644 index 00000000..5bc81e05 --- /dev/null +++ b/test/unittests/test_arbor.py @@ -0,0 +1,170 @@ +""" +Unit tests for the Arbor backend. + +These require Arbor to be installed (they exercise the backend's translation, +compatibility-shim, morphology and catalogue logic against the real ``arbor`` +package), but do not run a full simulation. They are skipped when Arbor is not +available. + +Several of these are regression guards for bugs found while adding support for +Arbor 0.10.0 - 0.12.2 (see pyNN/arbor/_compat.py). +""" + +import importlib +import unittest + +try: + import arbor + from arbor import units as U + import pyNN.arbor # noqa: F401 (ensures the backend imports / catalogue builds) + have_arbor = True +except ImportError: + have_arbor = False + +# The submodule names `cells` and `morphology` are shadowed by re-exports in +# pyNN/arbor/__init__.py, so import the real submodules explicitly. +if have_arbor: + _compat = importlib.import_module("pyNN.arbor._compat") + arbor_cells = importlib.import_module("pyNN.arbor.cells") + arbor_morphology = importlib.import_module("pyNN.arbor.morphology") + arbor_simulator = importlib.import_module("pyNN.arbor.simulator") + arbor_standardmodels = importlib.import_module("pyNN.arbor.standardmodels") + + +@unittest.skipUnless(have_arbor, "Requires Arbor") +class TestCompatShims(unittest.TestCase): + """Capability-based shims that let one codebase support Arbor 0.10 - 0.12.""" + + def test_get_electrode_mechanism_handles_rename(self): + # iclamp was renamed to i_clamp in Arbor 0.12. + mech = _compat.get_electrode_mechanism("iclamp") + self.assertIn(mech.__name__, ("iclamp", "i_clamp")) + # constructible with unit-typed args (tstart, duration, current) + mech(0.0 * U.ms, 1.0 * U.ms, 0.1 * U.nA) + + def test_get_electrode_mechanism_unknown(self): + with self.assertRaises(AttributeError): + _compat.get_electrode_mechanism("not_a_mechanism") + + def test_max_extent_policy(self): + # returns a valid cv_policy whether or not the installed version wants units + policy = _compat.max_extent_policy(10.0) + self.assertIsInstance(policy, arbor.cv_policy) + + def test_make_cable_cell(self): + tree = arbor.segment_tree() + tree.append(arbor.mnpos, arbor.mpoint(0, 0, 0, 5), + arbor.mpoint(10, 0, 0, 5), tag=1) + decor = arbor.decor() + decor.set_property(Vm=-60 * U.mV) + labels = arbor.label_dict({}) + policy = _compat.max_extent_policy(10.0) + cell = _compat.make_cable_cell(tree, decor, labels, policy) + self.assertIsInstance(cell, arbor.cable_cell) + + def test_place_current_source(self): + # 0.12 dropped the label arg from the current-stimulus place() overload. + decor = arbor.decor() + mech = _compat.get_electrode_mechanism("iclamp")( + 0.0 * U.ms, 1.0 * U.ms, 0.1 * U.nA) + # should not raise on any supported version + _compat.place_current_source(decor, "(root)", mech, "stim_label") + + +@unittest.skipUnless(have_arbor, "Requires Arbor") +class TestUnitMaps(unittest.TestCase): + """Regression guards for the unit maps used when translating to Arbor.""" + + def test_poisson_schedule_freq_is_Hz_not_kHz(self): + # PyNN's rate is in Hz; tagging it as kHz gave 1000x too many spikes. + units = arbor_standardmodels.SpikeSourcePoisson.arbor_schedule_units + self.assertIs(units["freq"], U.Hz) + self.assertIs(units["tstart"], U.ms) + self.assertIs(units["tstop"], U.ms) + + def test_spike_source_array_units(self): + units = arbor_standardmodels.SpikeSourceArray.arbor_schedule_units + self.assertEqual(list(units.keys()), ["times"]) + self.assertIs(units["times"], U.ms) + + def test_electrode_param_units(self): + units = arbor_cells.ELECTRODE_PARAM_UNITS + self.assertIs(units["tstart"], U.ms) + self.assertIs(units["duration"], U.ms) + self.assertIs(units["current"], U.nA) + + +@unittest.skipUnless(have_arbor, "Requires Arbor") +class TestTranslations(unittest.TestCase): + """Standard-model parameter translation (PyNN name/units -> Arbor).""" + + def test_dcsource_translation(self): + m = arbor_standardmodels.DCSource(amplitude=0.5, start=10.0, stop=20.0) + native = m.native_parameters + native.shape = (1,) + native.evaluate(simplify=True) + d = native.as_dict() + self.assertAlmostEqual(d["current"], 0.5) + self.assertAlmostEqual(d["tstart"], 10.0) + self.assertAlmostEqual(d["duration"], 10.0) # stop - start + + def test_spike_source_poisson_translation(self): + t = arbor_standardmodels.SpikeSourcePoisson.translations + self.assertEqual(t["rate"]["translated_name"], "freq") + self.assertEqual(t["start"]["translated_name"], "tstart") + self.assertEqual(t["duration"]["translated_name"], "tstop") + # duration -> tstop = start + duration (stored as a string expression) + self.assertEqual(t["duration"]["forward_transform"], "start + duration") + + def test_spike_source_array_translation(self): + t = arbor_standardmodels.SpikeSourceArray.translations + self.assertEqual(t["spike_times"]["translated_name"], "times") + + def test_multicompartment_schema(self): + schema = arbor_standardmodels.MultiCompartmentNeuron().get_schema() + for key in ("morphology", "cm", "Ra", "ionic_species"): + self.assertIn(key, schema) + + +@unittest.skipUnless(have_arbor, "Requires Arbor") +class TestMorphologyLocsets(unittest.TestCase): + """Locset generation for recording/placement locations.""" + + def test_labelled_locations_use_direct_locsets(self): + # Regression guard: Arbor 0.12's probe-label resolution does not resolve + # label references in probe locsets, so we must emit resolved expressions. + gen = arbor_morphology.LabelledLocations("soma", "dendrite") + locsets = gen.generate_locations(morphology=None, label="rec") + by_name = {label.rsplit("-", 1)[-1]: locset for locset, label in locsets} + self.assertEqual(by_name["soma"], "(root)") + self.assertEqual(by_name["dendrite"], "(location 0 0.5)") + # must be resolved expressions, not label references + for locset, _ in locsets: + self.assertFalse(locset.startswith('"')) + + +@unittest.skipUnless(have_arbor, "Requires Arbor") +class TestSimulatorHelpers(unittest.TestCase): + + def test_catalogue_path_is_version_keyed(self): + path = arbor_simulator.catalogue_path() + self.assertTrue(path.endswith(f"PyNN-catalogue-{arbor.__version__}.so"), path) + + def test_convert_point(self): + from neuroml import Point3DWithDiam + p = Point3DWithDiam(x=1.0, y=2.0, z=3.0, diameter=4.0) + mp = arbor_cells.convert_point(p) + self.assertAlmostEqual(mp.x, 1.0) + self.assertAlmostEqual(mp.y, 2.0) + self.assertAlmostEqual(mp.z, 3.0) + self.assertAlmostEqual(mp.radius, 2.0) # diameter / 2 + + def test_region_name_to_tag(self): + self.assertEqual(arbor_cells.region_name_to_tag("soma"), 1) + self.assertEqual(arbor_cells.region_name_to_tag("axon"), 2) + self.assertEqual(arbor_cells.region_name_to_tag("dendrite"), 3) + self.assertEqual(arbor_cells.region_name_to_tag("unknown"), -1) + + +if __name__ == "__main__": + unittest.main()