"""Operators represent calculations that will occur in the simulation.
This code adapted from sigops/operator.py and sigops/operators.py
(https://github.com/jaberg/sigops).
This modified code is included under the terms of their license:
Copyright (c) 2014, James Bergstra
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the
distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import numpy as np
from nengo.exceptions import BuildError, SimulationError
from nengo.utils.functions import function_name
import nengo.utils.numpy as npext
[docs]class Operator:
"""Base class for operator instances understood by Nengo.
During one simulator timestep, a `.Signal` can experience
1. at most one set operator (optional)
2. any number of increments
3. any number of reads
4. at most one update
in this specific order.
A ``set`` defines the state of the signal at time :math:`t`, the start
of the simulation timestep. That state can then be modified by
``increment`` operations. A signal's state will only be ``read`` after
all increments are complete. The state is then finalized by an ``update``,
which denotes the state that the signal should be at time :math:`t + dt`.
Each operator must keep track of the signals that it manipulates,
and which of these four types of manipulations is done to each signal
so that the simulator can order all of the operators properly.
.. note:: There are intentionally no valid default values for the
`~.Operator.reads`, `~.Operator.sets`, `~.Operator.incs`,
and `~.Operator.updates` properties to ensure that subclasses
explicitly set these values.
Parameters
----------
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
tag : str or None
A label associated with the operator, for debugging purposes.
"""
def __init__(self, tag=None):
self.tag = tag
self._sets = None
self._incs = None
self._reads = None
self._updates = None
def __repr__(self):
return "<%s %s at 0x%x>" % (type(self).__name__, self._tagstr(), id(self))
def __str__(self):
strs = (s for s in (self._descstr(), self._tagstr()) if s)
return "%s{%s}" % (type(self).__name__, " ".join(strs))
def _descstr(self):
return ""
def _tagstr(self):
return (' "%s"' % self.tag) if self.tag is not None else ""
@property
def all_signals(self):
return self.reads + self.sets + self.incs + self.updates
@property
def incs(self):
"""Signals incremented by this operator.
Increments will be applied after sets (if it is set), and before reads.
"""
return self._incs
@incs.setter
def incs(self, val):
self._incs = val
@property
def reads(self):
"""Signals that are read and not modified by this operator.
Reads occur after increments, and before updates.
"""
return self._reads
@reads.setter
def reads(self, val):
self._reads = val
@property
def sets(self):
"""Signals set by this operator.
Sets occur first, before increments. A signal that is set here cannot
be set or updated by any other operator.
"""
return self._sets
@sets.setter
def sets(self, val):
self._sets = val
@property
def updates(self):
"""Signals updated by this operator.
Updates are the last operation to occur to a signal.
"""
return self._updates
@updates.setter
def updates(self, val):
self._updates = val
[docs] def init_signals(self, signals):
"""Initialize the signals associated with this operator.
The signals will be initialized into ``signals``.
Operator subclasses that use extra buffers should create them here.
Parameters
----------
signals : SignalDict
A mapping from signals to their associated live ndarrays.
"""
for sig in self.all_signals:
if sig not in signals:
signals.init(sig)
[docs] def make_step(self, signals, dt, rng):
"""Returns a callable that performs the desired computation.
This method must be implemented by subclasses. To fully understand what
an operator does, look at its implementation of ``make_step``.
Parameters
----------
signals : SignalDict
A mapping from signals to their associated live ndarrays.
dt : float
Length of each simulation timestep, in seconds.
rng : `numpy.random.mtrand.RandomState`
Random number generator for stochastic operators.
"""
raise NotImplementedError("subclasses must implement this method.")
[docs]class TimeUpdate(Operator):
"""Updates the simulation step and time.
Implements ``step[...] += 1`` and ``time[...] = step * dt``.
A separate operator is used (rather than a combination of `.Copy` and
`.DotInc`) so that other backends can manage these important parts of the
simulation state separately from other signals.
Parameters
----------
step : Signal
The signal associated with the integer step counter.
time : Signal
The signal associated with the time (a float, in seconds).
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
step : Signal
The signal associated with the integer step counter.
tag : str or None
A label associated with the operator, for debugging purposes.
time : Signal
The signal associated with the time (a float, in seconds).
Notes
-----
1. sets ``[step, time]``
2. incs ``[]``
3. reads ``[]``
4. updates ``[]``
"""
def __init__(self, step, time, tag=None):
super().__init__(tag=tag)
self.sets = [step, time]
self.incs = []
self.reads = []
self.updates = []
@property
def step(self):
return self.sets[0]
@property
def time(self):
return self.sets[1]
[docs] def make_step(self, signals, dt, rng):
step = signals[self.step]
time = signals[self.time]
def step_timeupdate():
step[...] += 1
time[...] = step * dt
return step_timeupdate
[docs]class Reset(Operator):
"""Assign a constant value to a Signal.
Implements ``dst[...] = value``.
Parameters
----------
dst : Signal
The Signal to reset.
value : float, optional
The constant value to which ``dst`` is set.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
dst : Signal
The Signal to reset.
tag : str or None
A label associated with the operator, for debugging purposes.
value : float
The constant value to which ``dst`` is set.
Notes
-----
1. sets ``[dst]``
2. incs ``[]``
3. reads ``[]``
4. updates ``[]``
"""
def __init__(self, dst, value=0, tag=None):
super().__init__(tag=tag)
self.value = float(value)
self.sets = [dst]
self.incs = []
self.reads = []
self.updates = []
@property
def dst(self):
return self.sets[0]
def _descstr(self):
return str(self.dst)
[docs] def make_step(self, signals, dt, rng):
target = signals[self.dst]
value = self.value
def step_reset():
target[...] = value
return step_reset
[docs]class Copy(Operator):
"""Assign the value of one signal to another, with optional slicing.
Implements:
- ``dst[:] = src``
- ``dst[dst_slice] = src[src_slice]``
(when ``dst_slice`` or ``src_slice`` is not None)
- ``dst[dst_slice] += src[src_slice]`` (when ``inc=True``)
Parameters
----------
dst : Signal
The signal that will be assigned to (set).
src : Signal
The signal that will be copied (read).
dst_slice : slice or list, optional
Slice or list of indices associated with ``dst``.
src_slice : slice or list, optional
Slice or list of indices associated with ``src``
inc : bool, optional
Whether this should be an increment rather than a copy.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
dst : Signal
The signal that will be assigned to (set).
dst_slice : list or None
Indices associated with ``dst``.
src : Signal
The signal that will be copied (read).
src_slice : list or None
Indices associated with ``src``.
tag : str or None
A label associated with the operator, for debugging purposes.
Notes
-----
1. sets ``[] if inc else [dst]``
2. incs ``[dst] if inc else []``
3. reads ``[src]``
4. updates ``[]``
"""
def __init__(self, src, dst, src_slice=None, dst_slice=None, inc=False, tag=None):
super().__init__(tag=tag)
if isinstance(src_slice, slice):
src = src[src_slice]
src_slice = None
if isinstance(dst_slice, slice):
dst = dst[dst_slice]
dst_slice = None
# ^ src_slice and dst_slice are now either lists of indices or None
self.src_slice = src_slice
self.dst_slice = dst_slice
self.inc = inc
self.sets = [] if inc else [dst]
self.incs = [dst] if inc else []
self.reads = [src]
self.updates = []
@property
def dst(self):
return self.incs[0] if self.inc else self.sets[0]
@property
def src(self):
return self.reads[0]
def _descstr(self):
def sigstring(sig, sl):
return "%s%s" % (sig, ("[%s]" % (sl,)) if sl is not None else "")
return "%s -> %s, inc=%s" % (
sigstring(self.src, self.src_slice),
sigstring(self.dst, self.dst_slice),
self.inc,
)
[docs] def make_step(self, signals, dt, rng):
src = signals[self.src]
dst = signals[self.dst]
src_slice = self.src_slice if self.src_slice is not None else Ellipsis
dst_slice = self.dst_slice if self.dst_slice is not None else Ellipsis
inc = self.inc
# If there are repeated indices in dst_slice, special handling needed.
repeats = False
if npext.is_array_like(dst_slice):
# copy because we might modify it
dst_slice = np.array(dst_slice)
if dst_slice.dtype.kind != "b":
# get canonical, positive indices first
dst_slice[dst_slice < 0] += len(dst)
repeats = len(np.unique(dst_slice)) < len(dst_slice)
if inc and repeats:
def step_copy():
np.add.at(dst, dst_slice, src[src_slice])
elif inc:
def step_copy():
dst[dst_slice] += src[src_slice]
elif repeats:
raise BuildError(
"%s: Cannot have repeated indices in "
"``dst_slice`` when copy is not an increment" % self
)
else:
def step_copy():
dst[dst_slice] = src[src_slice]
return step_copy
[docs]class ElementwiseInc(Operator):
"""Increment signal ``Y`` by ``A * X`` (with broadcasting).
Implements ``Y[...] += A * X``.
Parameters
----------
A : Signal
The first signal to be multiplied.
X : Signal
The second signal to be multiplied.
Y : Signal
The signal to be incremented.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
A : Signal
The first signal to be multiplied.
tag : str or None
A label associated with the operator, for debugging purposes.
X : Signal
The second signal to be multiplied.
Y : Signal
The signal to be incremented.
Notes
-----
1. sets ``[]``
2. incs ``[Y]``
3. reads ``[A, X]``
4. updates ``[]``
"""
def __init__(self, A, X, Y, tag=None):
super().__init__(tag=tag)
self.sets = []
self.incs = [Y]
self.reads = [A, X]
self.updates = []
@property
def A(self):
return self.reads[0]
@property
def X(self):
return self.reads[1]
@property
def Y(self):
return self.incs[0]
def _descstr(self):
return "%s, %s -> %s" % (self.A, self.X, self.Y)
[docs] def make_step(self, signals, dt, rng):
A = signals[self.A]
X = signals[self.X]
Y = signals[self.Y]
# check broadcasting shapes
Ashape = npext.broadcast_shape(A.shape, 2)
Xshape = npext.broadcast_shape(X.shape, 2)
Yshape = npext.broadcast_shape(Y.shape, 2)
assert all(len(s) == 2 for s in [Ashape, Xshape, Yshape])
for da, dx, dy in zip(Ashape, Xshape, Yshape):
if not (da in [1, dy] and dx in [1, dy] and max(da, dx) == dy):
raise BuildError(
"Incompatible shapes in ElementwiseInc: "
"Trying to do %s += %s * %s" % (Yshape, Ashape, Xshape)
)
def step_elementwiseinc():
Y[...] += A * X
return step_elementwiseinc
[docs]def reshape_dot(A, X, Y, tag=None):
"""Checks if the dot product needs to be reshaped.
Also does a bunch of error checking based on the shapes of A and X.
"""
badshape = False
ashape = (1,) if A.shape == () else A.shape
xshape = (1,) if X.shape == () else X.shape
if A.shape == ():
incshape = X.shape
elif X.shape == ():
incshape = A.shape
elif X.ndim == 1:
badshape = ashape[-1] != xshape[0]
incshape = ashape[:-1]
else:
badshape = ashape[-1] != xshape[-2]
incshape = ashape[:-1] + xshape[:-2] + xshape[-1:]
if (badshape or incshape != Y.shape) and incshape != ():
raise BuildError(
"shape mismatch in %s: %s x %s -> %s" % (tag, A.shape, X.shape, Y.shape)
)
# Reshape to handle case when np.dot(A, X) and Y are both scalars
return A.dot(X).size == Y.size == 1
[docs]class DotInc(Operator):
"""Increment signal ``Y`` by ``dot(A, X)``.
Implements ``Y[...] += np.dot(A, X)``.
.. note:: Currently, this only supports matrix-vector multiplies
for compatibility with Nengo OCL.
Parameters
----------
A : Signal
The first signal to be multiplied (a matrix).
X : Signal
The second signal to be multiplied (a vector).
Y : Signal
The signal to be incremented.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
A : Signal
The first signal to be multiplied.
tag : str or None
A label associated with the operator, for debugging purposes.
X : Signal
The second signal to be multiplied.
Y : Signal
The signal to be incremented.
Notes
-----
1. sets ``[]``
2. incs ``[Y]``
3. reads ``[A, X]``
4. updates ``[]``
"""
def __init__(self, A, X, Y, reshape=None, tag=None):
super().__init__(tag=tag)
if X.ndim >= 2 and any(d > 1 for d in X.shape[1:]):
raise BuildError("X must be a column vector")
if Y.ndim >= 2 and any(d > 1 for d in Y.shape[1:]):
raise BuildError("Y must be a column vector")
self.reshape = reshape
if self.reshape is None:
self.reshape = reshape_dot(
A.initial_value, X.initial_value, Y.initial_value, self.tag
)
self.sets = []
self.incs = [Y]
self.reads = [A, X]
self.updates = []
@property
def A(self):
return self.reads[0]
@property
def X(self):
return self.reads[1]
@property
def Y(self):
return self.incs[0]
def _descstr(self):
return "%s, %s -> %s" % (self.A, self.X, self.Y)
[docs] def make_step(self, signals, dt, rng):
X = signals[self.X]
A = signals[self.A]
Y = signals[self.Y]
if self.reshape:
def step_dotinc_reshape():
Y[...] += A.dot(X).reshape(Y.shape)
return step_dotinc_reshape
else:
def step_dotinc():
Y[...] += A.dot(X)
return step_dotinc
[docs]class SparseDotInc(DotInc):
"""Like `.DotInc` but ``A`` is a sparse matrix.
.. versionadded:: 3.0.0
"""
def __init__(self, A, X, Y, tag=None):
if not A.sparse:
raise BuildError("%s: A must be a sparse Signal")
# Disallow reshaping
super().__init__(A, X, Y, reshape=False, tag=tag)
[docs]class BsrDotInc(DotInc):
"""Increment signal Y by dot(A, X) using block sparse row format.
Implements ``Y[...] += np.dot(A, X)``, where ``A`` is an instance
of `scipy.sparse.bsr_matrix`.
.. note:: Requires SciPy.
.. note:: Currently, this only supports matrix-vector multiplies
for compatibility with Nengo OCL.
Parameters
----------
A : (k, r, c) Signal
The signal providing the k data blocks with r rows and c columns.
X : (k * c) Signal
The signal providing the k column vectors to multiply with.
Y : (k * r) Signal
The signal providing the k column vectors to update.
indices : ndarray
Column indices, see `scipy.sparse.bsr_matrix` for details.
indptr : ndarray
Column index pointers, see `scipy.sparse.bsr_matrix` for details.
reshape : bool
Whether to reshape the result.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
A : (k, r, c) Signal
The signal providing the k data blocks with r rows and c columns.
indices : ndarray
Column indices, see `scipy.sparse.bsr_matrix` for details.
indptr : ndarray
Column index pointers, see `scipy.sparse.bsr_matrix` for details.
reshape : bool
Whether to reshape the result.
tag : str or None
A label associated with the operator, for debugging purposes.
X : (k * c) Signal
The signal providing the k column vectors to multiply with.
Y : (k * r) Signal
The signal providing the k column vectors to update.
Notes
-----
1. sets ``[]``
2. incs ``[Y]``
3. reads ``[A, X]``
4. updates ``[]``
"""
def __init__(self, A, X, Y, indices, indptr, reshape=None, tag=None):
from scipy.sparse import bsr_matrix # pylint: disable=import-outside-toplevel
self.bsr_matrix = bsr_matrix
super().__init__(A, X, Y, reshape=reshape, tag=tag)
self.indices = indices
self.indptr = indptr
[docs] def make_step(self, signals, dt, rng):
X = signals[self.X]
A = signals[self.A]
Y = signals[self.Y]
def step_dotinc():
mat_A = self.bsr_matrix((A, self.indices, self.indptr))
inc = mat_A.dot(X)
if self.reshape:
inc = inc.reshape(Y.shape)
Y[...] += inc
return step_dotinc
[docs]class SimPyFunc(Operator):
"""Apply a Python function to a signal, with optional arguments.
Implements ``output[...] = fn(*args)`` where ``args`` can
include the current simulation time ``t`` and an input signal ``x``.
Note that ``output`` may also be None, in which case the function is
called but no output is captured.
Parameters
----------
output : Signal or None
The signal to be set. If None, the function is still called.
fn : callable
The function to call.
t : Signal or None
The signal associated with the time (a float, in seconds).
If None, the time will not be passed to ``fn``.
x : Signal or None
An input signal to pass to ``fn``.
If None, an input signal will not be passed to ``fn``.
tag : str, optional
A label associated with the operator, for debugging purposes.
Attributes
----------
fn : callable
The function to call.
output : Signal or None
The signal to be set. If None, the function is still called.
t : Signal or None
The signal associated with the time (a float, in seconds).
If None, the time will not be passed to ``fn``.
tag : str or None
A label associated with the operator, for debugging purposes.
x : Signal or None
An input signal to pass to ``fn``.
If None, an input signal will not be passed to ``fn``.
Notes
-----
1. sets ``[] if output is None else [output]``
2. incs ``[]``
3. reads ``([] if t is None else [t]) + ([] if x is None else [x])``
4. updates ``[]``
"""
def __init__(self, output, fn, t, x, tag=None):
super().__init__(tag=tag)
self.fn = fn
self.t_passed = t is not None
self.x_passed = x is not None
self.sets = [] if output is None else [output]
self.incs = []
self.reads = ([] if t is None else [t]) + ([] if x is None else [x])
self.updates = []
@property
def output(self):
if len(self.sets) == 1:
return self.sets[0]
return None
@property
def t(self):
return self.reads[0] if self.t_passed else None
@property
def x(self):
return self.reads[-1] if self.x_passed else None
def _descstr(self):
return "%s -> %s, fn=%r" % (self.x, self.output, function_name(self.fn))
[docs] def make_step(self, signals, dt, rng):
fn = self.fn
output = signals[self.output] if self.output is not None else None
t = signals[self.t] if self.t is not None else None
x = signals[self.x] if self.x is not None else None
def step_simpyfunc():
args = (np.copy(x),) if x is not None else ()
y = fn(t.item(), *args) if t is not None else fn(*args)
if output is not None:
try:
# required since Numpy turns None into NaN
if y is None or not np.all(np.isfinite(y)):
raise SimulationError(
"Function %r returned non-finite value"
% function_name(self.fn)
)
output[...] = y
except (TypeError, ValueError):
raise SimulationError(
"Function %r returned a value "
"%r of invalid type %r" % (function_name(self.fn), y, type(y))
)
return step_simpyfunc