diff --git a/brian2/core/variables.py b/brian2/core/variables.py index f5e5e7f91..6d9d8680b 100644 --- a/brian2/core/variables.py +++ b/brian2/core/variables.py @@ -767,6 +767,12 @@ class LinkedVariable: """ def __init__(self, group, name, variable, index=None): + if isinstance(variable, DynamicArrayVariable): + raise NotImplementedError( + f"Linking to variable {variable.name} is " + "not supported, can only link to " + "state variables of fixed size." + ) self.group = group self.name = name self.variable = variable diff --git a/brian2/groups/group.py b/brian2/groups/group.py index 9a867e471..4e13b1a9e 100644 --- a/brian2/groups/group.py +++ b/brian2/groups/group.py @@ -30,6 +30,7 @@ AuxiliaryVariable, Constant, DynamicArrayVariable, + LinkedVariable, Subexpression, Variable, Variables, @@ -38,6 +39,7 @@ from brian2.importexport.importexport import ImportExport from brian2.units.fundamentalunits import ( DIMENSIONLESS, + DimensionMismatchError, fail_for_dimension_mismatch, get_unit, ) @@ -420,85 +422,215 @@ def __getattr__(self, name): except KeyError: raise AttributeError(f"No attribute with name {name}") - def __setattr__(self, name, val, level=0): + def __setattr__(self, key, value, level=0): # attribute access is switched off until this attribute is created by # _enable_group_attributes - if not hasattr(self, "_group_attribute_access_active") or name in self.__dict__: - object.__setattr__(self, name, val) - elif ( - name in self.__getattribute__("__dict__") - or name in self.__getattribute__("__class__").__dict__ - ): - # Makes sure that classes can override the "variables" mechanism - # with instance/class attributes and properties - return object.__setattr__(self, name, val) - elif name in self.variables: - var = self.variables[name] - if not isinstance(val, str): - if var.dim is DIMENSIONLESS: - fail_for_dimension_mismatch( - val, - var.dim, - "%s should be set with a dimensionless value, but got {value}" - % name, - value=val, + if not hasattr(self, "_group_attribute_access_active") or key in self.__dict__: + object.__setattr__(self, key, value) + elif key in getattr(self, "_linked_variables", set()): + if not isinstance(value, LinkedVariable): + raise ValueError( + "Cannot set a linked variable directly, link " + "it to another variable using 'linked_var'." + ) + linked_var = value.variable + + eq = self.equations[key] + if eq.dim is not linked_var.dim: + raise DimensionMismatchError( + f"Unit of variable '{key}' does not " + "match its link target " + f"'{linked_var.name}'" + ) + + if not isinstance(linked_var, Subexpression): + var_length = len(linked_var) + else: + var_length = len(linked_var.owner) + + if value.index is not None: + index = self._linked_var_index(key, value, var_length) + else: + index = self._linked_var_automatic_index(key, value, var_length) + self.variables.add_reference(key, value.group, value.name, index=index) + source = (value.variable.owner.name,) + sourcevar = value.variable.name + log_msg = f"Setting {self.name}.{key} as a link to {source}.{sourcevar}" + if index is not None: + log_msg += f'(using "{index}" as index variable)' + logger.diagnostic(log_msg) + else: + if isinstance(value, LinkedVariable): + raise TypeError( + f"Cannot link variable '{key}', it has to be marked " + "as a linked variable with '(linked)' in the model " + "equations." + ) + else: + if ( + key in self.__getattribute__("__dict__") + or key in self.__getattribute__("__class__").__dict__ + ): + # Makes sure that classes can override the "variables" mechanism + # with instance/class attributes and properties + return object.__setattr__(self, key, value) + elif key in self.variables: + var = self.variables[key] + if not isinstance(value, str): + if var.dim is DIMENSIONLESS: + fail_for_dimension_mismatch( + value, + var.dim, + "%s should be set with a dimensionless value, but got {value}" + % key, + value=value, + ) + else: + fail_for_dimension_mismatch( + value, + var.dim, + "%s should be set with a value with units %r, but got {value}" + % (key, get_unit(var.dim)), + value=value, + ) + if var.read_only: + raise TypeError(f"Variable {key} is read-only.") + # Make the call X.var = ... equivalent to X.var[:] = ... + var.get_addressable_value_with_unit(key, self).set_item( + slice(None), value, level=level + 1 + ) + elif len(key) and key[-1] == "_" and key[:-1] in self.variables: + # no unit checking + var = self.variables[key[:-1]] + if var.read_only: + raise TypeError(f"Variable {key[:-1]} is read-only.") + # Make the call X.var = ... equivalent to X.var[:] = ... + var.get_addressable_value(key[:-1], self).set_item( + slice(None), value, level=level + 1 ) + elif hasattr(self, key) or key.startswith("_"): + object.__setattr__(self, key, value) else: - fail_for_dimension_mismatch( - val, - var.dim, - "%s should be set with a value with units %r, but got {value}" - % (name, get_unit(var.dim)), - value=val, + # Try to suggest the correct name in case of a typo + checker = SpellChecker( + [ + varname + for varname, var in self.variables.items() + if not (varname.startswith("_") or var.read_only) + ] ) - if var.read_only: - raise TypeError(f"Variable {name} is read-only.") - # Make the call X.var = ... equivalent to X.var[:] = ... - var.get_addressable_value_with_unit(name, self).set_item( - slice(None), val, level=level + 1 - ) - elif len(name) and name[-1] == "_" and name[:-1] in self.variables: - # no unit checking - var = self.variables[name[:-1]] - if var.read_only: - raise TypeError(f"Variable {name[:-1]} is read-only.") - # Make the call X.var = ... equivalent to X.var[:] = ... - var.get_addressable_value(name[:-1], self).set_item( - slice(None), val, level=level + 1 - ) - elif hasattr(self, name) or name.startswith("_"): - object.__setattr__(self, name, val) + if key.endswith("_"): + suffix = "_" + key = key[:-1] + else: + suffix = "" + error_msg = f'Could not find a state variable with name "{key}".' + suggestions = checker.suggest(key) + if len(suggestions) == 1: + (suggestion,) = suggestions + error_msg += f' Did you mean to write "{suggestion}{suffix}"?' + elif len(suggestions) > 1: + suggestion_str = ", ".join( + [f"'{suggestion}{suffix}'" for suggestion in suggestions] + ) + error_msg += f" Did you mean to write any of the following: {suggestion_str} ?" + error_msg += ( + " Use the add_attribute method if you intend to add " + "a new attribute to the object." + ) + raise AttributeError(error_msg) + + def _linked_var_automatic_index(self, var_name, linked_var, var_length): + # The check at the end is to avoid the case that a size 1 NeuronGroup + # links to another NeuronGroup of size 1 and cannot do certain operations + # since the linked variable is considered scalar. + if linked_var.variable.scalar or ( + var_length == 1 and getattr(self, "_N", 0) != 1 + ): + index = "0" else: - # Try to suggest the correct name in case of a typo - checker = SpellChecker( - [ - varname - for varname, var in self.variables.items() - if not (varname.startswith("_") or var.read_only) - ] - ) - if name.endswith("_"): - suffix = "_" - name = name[:-1] + index = linked_var.group.variables.indices[linked_var.name] + if index == "_idx": + target_length = var_length else: - suffix = "" - error_msg = f'Could not find a state variable with name "{name}".' - suggestions = checker.suggest(name) - if len(suggestions) == 1: - (suggestion,) = suggestions - error_msg += f' Did you mean to write "{suggestion}{suffix}"?' - elif len(suggestions) > 1: - suggestion_str = ", ".join( - [f"'{suggestion}{suffix}'" for suggestion in suggestions] + target_length = len(linked_var.group.variables[index]) + # we need a name for the index that does not clash with + # other names and a reference to the index + new_index = f"_{linked_var.name}_index_{index}" + self.variables.add_reference(new_index, linked_var.group, index) + index = new_index + + if len(self) != target_length: + raise ValueError( + f"Cannot link variable '{var_name}' to " + f"'{linked_var.variable.name}', the size of the " + "target group does not match " + f"({len(self)} != {target_length}). You can " + "provide an indexing scheme with the " + "'index' keyword to link groups with " + "different sizes" + ) + return index + + def _linked_var_index(self, var_name, linked_var, target_size): + if isinstance(linked_var.index, str): + if linked_var.index not in self.variables: + raise ValueError(f"Index variable '{linked_var.index}' not found.") + if self.variables.indices[linked_var.index] != self.variables.default_index: + raise ValueError( + f"Index variable '{linked_var.index}' should use the default index itself." + ) + if not np.issubdtype(self.variables[linked_var.index].dtype, np.integer): + raise TypeError( + f"Index variable '{linked_var.index}' should be an integer parameter." + ) + index = linked_var.index + else: + # Index arrays are not allowed for classes with dynamic size (Synapses) + if not isinstance(self.variables["N"], Constant): + raise TypeError( + "Cannot link a variable with an index array for a class with dynamic size – use a variable name instead." + ) + try: + index_array = np.asarray(linked_var.index) + if not np.issubdtype(index_array.dtype, int): + raise TypeError() + except TypeError: + raise TypeError( + "The index for a linked variable has to be an integer array" ) - error_msg += ( - f" Did you mean to write any of the following: {suggestion_str} ?" + size = len(index_array) + source_index = linked_var.group.variables.indices[linked_var.name] + if source_index not in ("_idx", "0"): + # we are indexing into an already indexed variable, + # calculate the indexing into the target variable + index_array = linked_var.group.variables[source_index].get_value()[ + index_array + ] + + if not index_array.ndim == 1 or size != len(self): + raise TypeError( + f"Index array for linked variable '{var_name}' " + "has to be a one-dimensional array of " + f"length {len(self)}, but has shape " + f"{index_array.shape!s}" + ) + if min(index_array) < 0 or max(index_array) >= target_size: + raise ValueError( + f"Index array for linked variable {var_name} " + "contains values outside of the valid " + f"range [0, {target_size}[" ) - error_msg += ( - " Use the add_attribute method if you intend to add " - "a new attribute to the object." + self.variables.add_array( + f"_{var_name}_indices", + size=size, + dtype=index_array.dtype, + constant=True, + read_only=True, + values=index_array, ) - raise AttributeError(error_msg) + index = f"_{var_name}_indices" + return index def add_attribute(self, name): """ diff --git a/brian2/groups/neurongroup.py b/brian2/groups/neurongroup.py index 4e3b27491..b8f3e7f6e 100644 --- a/brian2/groups/neurongroup.py +++ b/brian2/groups/neurongroup.py @@ -13,12 +13,7 @@ from brian2.codegen.translation import analyse_identifiers from brian2.core.preferences import prefs from brian2.core.spikesource import SpikeSource -from brian2.core.variables import ( - DynamicArrayVariable, - LinkedVariable, - Subexpression, - Variables, -) +from brian2.core.variables import Variables from brian2.equations.equations import ( DIFFERENTIAL_EQUATION, PARAMETER, @@ -36,7 +31,6 @@ from brian2.units.allunits import second from brian2.units.fundamentalunits import ( DIMENSIONLESS, - DimensionMismatchError, Quantity, fail_for_dimension_mismatch, ) @@ -785,122 +779,6 @@ def set_event_schedule(self, event, when="after_thresholds", order=None): self.thresholder[event].when = when self.thresholder[event].order = order - def __setattr__(self, key, value): - # attribute access is switched off until this attribute is created by - # _enable_group_attributes - if not hasattr(self, "_group_attribute_access_active") or key in self.__dict__: - object.__setattr__(self, key, value) - elif key in self._linked_variables: - if not isinstance(value, LinkedVariable): - raise ValueError( - "Cannot set a linked variable directly, link " - "it to another variable using 'linked_var'." - ) - linked_var = value.variable - - if isinstance(linked_var, DynamicArrayVariable): - raise NotImplementedError( - f"Linking to variable {linked_var.name} is " - "not supported, can only link to " - "state variables of fixed size." - ) - - eq = self.equations[key] - if eq.dim is not linked_var.dim: - raise DimensionMismatchError( - f"Unit of variable '{key}' does not " - "match its link target " - f"'{linked_var.name}'" - ) - - if not isinstance(linked_var, Subexpression): - var_length = len(linked_var) - else: - var_length = len(linked_var.owner) - - if value.index is not None: - try: - index_array = np.asarray(value.index) - if not np.issubdtype(index_array.dtype, int): - raise TypeError() - except TypeError: - raise TypeError( - "The index for a linked variable has to be an integer array" - ) - size = len(index_array) - source_index = value.group.variables.indices[value.name] - if source_index not in ("_idx", "0"): - # we are indexing into an already indexed variable, - # calculate the indexing into the target variable - index_array = value.group.variables[source_index].get_value()[ - index_array - ] - - if not index_array.ndim == 1 or size != len(self): - raise TypeError( - f"Index array for linked variable '{key}' " - "has to be a one-dimensional array of " - f"length {len(self)}, but has shape " - f"{index_array.shape!s}" - ) - if min(index_array) < 0 or max(index_array) >= var_length: - raise ValueError( - f"Index array for linked variable {key} " - "contains values outside of the valid " - f"range [0, {var_length}[" - ) - self.variables.add_array( - f"_{key}_indices", - size=size, - dtype=index_array.dtype, - constant=True, - read_only=True, - values=index_array, - ) - index = f"_{key}_indices" - else: - if linked_var.scalar or (var_length == 1 and self._N != 1): - index = "0" - else: - index = value.group.variables.indices[value.name] - if index == "_idx": - target_length = var_length - else: - target_length = len(value.group.variables[index]) - # we need a name for the index that does not clash with - # other names and a reference to the index - new_index = f"_{value.name}_index_{index}" - self.variables.add_reference(new_index, value.group, index) - index = new_index - - if len(self) != target_length: - raise ValueError( - f"Cannot link variable '{key}' to " - f"'{linked_var.name}', the size of the " - "target group does not match " - f"({len(self)} != {target_length}). You can " - "provide an indexing scheme with the " - "'index' keyword to link groups with " - "different sizes" - ) - - self.variables.add_reference(key, value.group, value.name, index=index) - source = (value.variable.owner.name,) - sourcevar = value.variable.name - log_msg = f"Setting {self.name}.{key} as a link to {source}.{sourcevar}" - if index is not None: - log_msg += f'(using "{index}" as index variable)' - logger.diagnostic(log_msg) - else: - if isinstance(value, LinkedVariable): - raise TypeError( - f"Cannot link variable '{key}', it has to be marked " - "as a linked variable with '(linked)' in the model " - "equations." - ) - else: - Group.__setattr__(self, key, value, level=1) - def __getitem__(self, item): start, stop = to_start_stop(item, self._N) diff --git a/brian2/synapses/synapses.py b/brian2/synapses/synapses.py index 0ff46b8bb..57b9e33bd 100644 --- a/brian2/synapses/synapses.py +++ b/brian2/synapses/synapses.py @@ -854,7 +854,7 @@ def __init__( { DIFFERENTIAL_EQUATION: ["event-driven", "clock-driven"], SUBEXPRESSION: ["summed", "shared", "constant over dt"], - PARAMETER: ["constant", "shared"], + PARAMETER: ["constant", "shared", "linked"], }, incompatible_flags=[ ("event-driven", "clock-driven"), @@ -944,6 +944,8 @@ def __init__( else: self.event_driven = None + self._linked_variables = set() + self._create_variables(model, user_dtype=dtype) self.equations = Equations(continuous) @@ -1372,7 +1374,10 @@ def _create_variables(self, equations, user_dtype=None): check_identifier_pre_post(eq.varname) constant = "constant" in eq.flags shared = "shared" in eq.flags - if shared: + linked = "linked" in eq.flags + if linked: + self._linked_variables.add(eq.varname) + elif shared: self.variables.add_array( eq.varname, size=1, diff --git a/brian2/tests/test_neurongroup.py b/brian2/tests/test_neurongroup.py index 28b094f11..6796a14d3 100644 --- a/brian2/tests/test_neurongroup.py +++ b/brian2/tests/test_neurongroup.py @@ -462,6 +462,30 @@ def test_linked_variable_indexed(): assert_allclose(G.y[:], np.arange(10)[::-1] * 0.1) +@pytest.mark.codegen_independent +def test_linked_variable_index_variable(): + """ + Test linking a variable with an index specified as an array + """ + G = NeuronGroup( + 10, + """ + x : 1 + index_var : integer + not_an_index_var : 1 + y : 1 (linked) + """, + ) + + G.x = np.arange(10) * 0.1 + with pytest.raises(TypeError): + G.y = linked_var(G.x, index="not_an_index_var") + G.y = linked_var(G.x, index="index_var") + G.index_var = np.arange(10)[::-1] + # G.y should refer to an inverted version of G.x + assert_allclose(G.y[:], np.arange(10)[::-1] * 0.1) + + @pytest.mark.codegen_independent def test_linked_variable_repeat(): """ diff --git a/brian2/tests/test_synapses.py b/brian2/tests/test_synapses.py index 7e00063f9..4e4813693 100644 --- a/brian2/tests/test_synapses.py +++ b/brian2/tests/test_synapses.py @@ -1626,6 +1626,63 @@ def test_summed_variables_linked_variables(): net.run(0 * ms) +@pytest.mark.codegen_independent +def test_linked_to_shared_variables(): + source1 = NeuronGroup(1, "dx/dt = -x / (10*ms) : 1") + source1.x = "rand()" + source2 = NeuronGroup(10, "x : 1 (shared)") + source2.x = "rand()" + mon = StateMonitor(source1, "x", record=True) + group = NeuronGroup(10, "") + syn = Synapses( + group, + group, + """x1 : 1 (linked) + x2 : 1 (linked) + """, + ) + syn.x1 = linked_var(source1.x) + syn.x2 = linked_var(source2.x) + syn.connect(i=[0, 5], j=[3, 6]) + mon_syn = StateMonitor(syn, ["x1", "x2"], record=True) + run(2 * defaultclock.dt) + assert_allclose(mon.x[0], mon_syn.x1[0]) + assert_allclose(mon.x[0], mon_syn.x1[1]) + assert_allclose(source2.x, mon_syn.x2[0]) + assert_allclose(source2.x, mon_syn.x2[1]) + + +@pytest.mark.codegen_independent +def test_linked_to_non_shared_variables(): + source = NeuronGroup(10, "dx/dt = -x / (10*ms) : 1") + source.x = "rand()" + mon = StateMonitor(source, "x", record=True) + group = NeuronGroup(10, "y: 1") + syn = Synapses( + group, + group, + """x : 1 (linked) + x_ind: integer + not_an_index : 1 + """, + ) + syn.connect(i=[0, 5], j=[3, 6]) + with pytest.raises(TypeError): + syn.x = linked_var(source.x, index=[0, 1]) + with pytest.raises(ValueError): + syn.x = linked_var(source.x, index="does_not_exist") + with pytest.raises(ValueError): + syn.x = linked_var(source.x, index="y_post") + with pytest.raises(TypeError): + syn.x = linked_var(source.x, index="not_an_index") + syn.x = linked_var(source.x, index="x_ind") + syn.x_ind = [3, 5] + mon_syn = StateMonitor(syn, "x", record=True) + run(2 * defaultclock.dt) + assert_allclose(mon.x[3], mon_syn.x[0]) + assert_allclose(mon.x[5], mon_syn.x[1]) + + def test_scalar_parameter_access(): G = NeuronGroup( 10, diff --git a/docs_sphinx/introduction/release_notes.rst b/docs_sphinx/introduction/release_notes.rst index 036d3e89c..b0f30d9ab 100644 --- a/docs_sphinx/introduction/release_notes.rst +++ b/docs_sphinx/introduction/release_notes.rst @@ -1,6 +1,27 @@ Release notes ============= +Next release +------------ + +Selected improvements and bug fixes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- A more powerful :ref:`linked_variables` mechanism, now also supporting linked variables that use another variable for indexing, and linked variables in + `Synapses` (:issue:`1584`). + +Infrastructure and documentation improvements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- A new example :doc:`../examples/synapses.homeostatic_stdp_at_inhibitory_synapes`, demonstrating a homoestatic modulation of STDP, based on a population firing rate. + Thanks to Paul Brodersen for contributing this example (:issue:`1581`). + +Contributions +~~~~~~~~~~~~~ +GitHub code, documentation, and issue contributions (ordered by the number of +contributions): + +TODO + + Brian 2.8.0.1-2.8.0.4 --------------------- Several patch-level releases, correcting metadata files about authors and contributors, updating release scripts, and fixing issues in a test and in the diff --git a/docs_sphinx/user/equations.rst b/docs_sphinx/user/equations.rst index e1460b008..0ad1dbe39 100644 --- a/docs_sphinx/user/equations.rst +++ b/docs_sphinx/user/equations.rst @@ -180,8 +180,8 @@ qualifies the equations. There are several keywords: but rather a single value for the whole `NeuronGroup` or `Synapses`. A shared subexpression can only refer to other shared variables. *linked* - this means that a parameter refers to a parameter in another `NeuronGroup`. - See :ref:`linked_variables` for more details. + this means that a parameter refers to a variable in another `NeuronGroup` + or `SpatialNeuron`. See :ref:`linked_variables` for more details. Multiple flags may be specified as follows:: diff --git a/docs_sphinx/user/models.rst b/docs_sphinx/user/models.rst index 3d6007e80..877251103 100644 --- a/docs_sphinx/user/models.rst +++ b/docs_sphinx/user/models.rst @@ -293,9 +293,9 @@ The data (without physical units) can also be exported/imported to/from Linked variables ---------------- -A `NeuronGroup` can define parameters that are not stored in this group, but are -instead a reference to a state variable in another group. For this, a group -defines a parameter as ``linked`` and then uses `linked_var` to +A `NeuronGroup`, `Synapses`, or `SpatialNeuron` can define parameters that are +not stored in this group, but are instead a reference to a variable in another group. +For this, a group defines a parameter as ``linked`` and then uses `linked_var` to specify the linking. This can for example be useful to model shared noise between cells:: @@ -319,8 +319,23 @@ linking explicitly:: neurons = NeuronGroup(100, '''inp : volt (linked) dv/dt = (-v + inp) / tau : volt''') # Half of the cells get the first input, other half gets the second - neurons.inp = linked_var(inp, 'x', index=repeat([0, 1], 50)) + neurons.inp = linked_var(inp, 'x', index=np.repeat([0, 1], 50)) +Note that this linking does not work for `Synapses`, since the number of synapses +is not known in advance. However, all groups supported linking with an index variable. +The above example could also be written as:: + + # two inputs with different phases + inp = NeuronGroup(2, '''phase : 1 + dx/dt = 1*mV/ms*sin(2*pi*100*Hz*t-phase) : volt''') + inp.phase = [0, pi/2] + + neurons = NeuronGroup(100, '''inp : volt (linked) + index_var : integer (constant) + dv/dt = (-v + inp) / tau : volt''') + # Half of the cells get the first input, other half gets the second + neurons.inp = linked_var(inp, 'x', index='index_var') + neurons.index_var = np.repeat([0, 1], 50) .. _time_scaling_of_noise: diff --git a/examples/synapses/homeostatic_stdp_at_inhibitory_synapes.py b/examples/synapses/homeostatic_stdp_at_inhibitory_synapes.py index 8ccf7a1d1..018325669 100755 --- a/examples/synapses/homeostatic_stdp_at_inhibitory_synapes.py +++ b/examples/synapses/homeostatic_stdp_at_inhibitory_synapes.py @@ -72,6 +72,7 @@ tau_stdp = 20. * b2.msecond eta = 1. * b2.nsiemens * b2.second synapse_model = """ +G : Hz (linked) wij : siemens dzi/dt = -zi / tau_stdp : 1 (event-driven) dzj/dt = -zj / tau_stdp : 1 (event-driven) @@ -89,13 +90,7 @@ model=synapse_model, on_pre=on_pre, on_post=on_post ) synapse.connect(i=0, j=0) -# Synapses currently don't support linked variables. -# The following syntax hence results in an error: -# synapses.G = b2.linked_var(global_factor, "G") -# Instead we add the reference G as shown below. -# See also: -# https://brian.discourse.group/t/valueerror-equations-of-type-parameter-cannot-have-a-flag-linked-only-the-following-flags-are-allowed-constant-shared/1373/2 -synapse.variables.add_reference("G", group=global_factor, index='0') +synapse.G = b2.linked_var(global_factor, "G") synapse.wij = 0 * b2.nsiemens net.add(synapse)