Source code for bdsim.blocks.functions

"""
Function blocks:

- have inputs and outputs
- have no state variables
- are a subclass of ``FunctionBlock`` |rarr| ``Block``

"""

# The constructor of each class ``MyClass`` with a ``@block`` decorator becomes a method ``MYCLASS()`` of the BlockDiagram instance.

import numpy as np
import scipy.interpolate
import math
import inspect

from bdsim.components import FunctionBlock


# PID
# product
# saturation
# transform 3D points

        
[docs]class Sum(FunctionBlock): """ :blockname:`SUM` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | len(signs) | 1 | 0 | +------------+---------+---------+ | float, | float, | | | A(N,), | A(N,), | | | A(N,M) | A(N,M) | | +------------+---------+---------+ """ nin = -1 nout = 1
[docs] def __init__(self, signs='++', angles=False, **blockargs): """ Summing junction. :param signs: signs associated with input ports, accepted characters: + or -, defaults to '++' :type signs: str, optional :param angles: the signals are angles, wraps to [-pi,pi], defaults to False :type angles: bool, optional :param blockargs: common `Block options <https://petercorke.github.io/bdsim/bdsim.html?highlight=block.__init__#bdsim.components.Block.__init__`_ :type blockargs: dict :return: A SUM block :rtype: Sum instance Add or subtract input signals according to the `signs` string. The number of input ports is the length of this string. For example:: sum = bd.SUM('+-+') is a 3-input summing junction which computes port0 - port1 + port2. Implicit SUM blocks are created by:: sum = block1 + block2 which will create a summation block named "_sum.N". .. note:: The signals must be compatible, all scalars, or all arrays of the same shape. """ super().__init__(nin=len(signs), **blockargs) assert isinstance(signs, str), 'first argument must be signs string' assert all([x in '+-' for x in signs]), 'invalid sign' self.signs = signs self.angles = angles
def output(self, t=None): for i, input in enumerate(self.inputs): # code makes no assumption about types of inputs # NOTE: use sum = sum =/- input rather than sum +/-= input since # these are references if self.signs[i] == '-': if i == 0: sum = -input else: sum = sum - input else: if i == 0: sum = input else: sum = sum + input if self.angles: sum = np.mod(sum + math.pi, 2 * math.pi) - math.pi return [sum]
# ------------------------------------------------------------------------ #
[docs]class Prod(FunctionBlock): """ :blockname:`PROD` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | len(ops) | 1 | 0 | +------------+---------+---------+ | float, | float, | | | A(N,), | A(N,), | | | A(N,M) | A(N,M) | | +------------+---------+---------+ """ nin = -1 nout = 1
[docs] def __init__(self, ops='**', matrix=False, **blockargs): """ Product junction. :param ops: operations associated with input ports, accepted characters: * or /, defaults to '**' :type ops: str, optional :param inputs: Optional incoming connections :type inputs: Block or Plug :param matrix: Arguments are matrices, defaults to False :type matrix: bool, optional :param blockargs: |BlockOptions| :type blockargs: dict :return: A PROD block :rtype: Prod instance Multiply or divide input signals according to the `ops` string. The number of input ports is the length of this string. For example:: prod = PROD('*/*') is a 3-input product junction which computes port0 / port 1 * port2. Implicit PROD blocks are created by:: sum = block1 block2 which will create a summation block named "_prod.N". .. note:: - The inputs can be scalars or NumPy arrays. - By default the ``*`` and ``/`` operators are used. - The option ``matrix`` will instead use ``@`` and ``@ np.linalg.inv()``. - the shapes of matrices must conform. - only square matrices are supported. """ super().__init__(nin=len(ops), **blockargs) assert isinstance(ops, str), 'first argument must be signs string' assert all([x in '*/' for x in ops]), 'invalid op' self.ops = ops self.matrix = matrix
def output(self, t=None): for i, input in enumerate(self.inputs): if i == 0: if self.ops[i] == '*': prod = input else: if self.matrix: prod = numpy.linalg.inv(input) prod = 1.0 / input else: if self.ops[i] == '*': if self.matrix: prod = prod @ input else: prod = prod * input else: if self.matrix: prod = prod @ numpy.linalg.inv(input) else: prod = prod / input return [prod]
# ------------------------------------------------------------------------ #
[docs]class Gain(FunctionBlock): """ :blockname:`GAIN` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | 1 | 1 | 0 | +------------+---------+---------+ | float, | float, | | | A(N,), | A(N,), | | | A(N,M) | A(N,M) | | +------------+---------+---------+ """ nin = 1 nout = 1
[docs] def __init__(self, K=1, premul=False, **blockargs): """ Gain block. :param K: The gain value, defaults to 1 :type K: array_like :param premul: premultiply by constant, default is postmultiply, defaults to False :type premul: bool, optional :param blockargs: |BlockOptions| :type blockargs: dict :return: A GAIN block :rtype: Gain instance Scale the input signal. If the input is :math:`u` the output is :math:`u K`. Either or both the input and gain can be Numpy arrays and Numpy will compute the appropriate product :math:`u K`. If :math:`u` and ``K`` are both NumPy arrays the ``@`` operator is used and :math:`u` is postmultiplied by the gain. To premultiply by the gain, to compute :math:`K u` use the ``premul`` option. For example:: gain = bd.GAIN(constant) """ super().__init__(**blockargs) self.K = K self.premul = premul self.add_param('K')
def output(self, t=None): input = self.inputs[0] if isinstance(input, np.ndarray) and isinstance(self.K, np.ndarray): # array x array case if self.premul: # premultiply by gain return [self.K @ input] else: # postmultiply by gain return [input @ self.K] else: return [self.inputs[0] * self.K]
# ------------------------------------------------------------------------ #
[docs]class Clip(FunctionBlock): """ :blockname:`CLIP` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | 1 | 1 | 0 | +------------+---------+---------+ | float, | float, | | | A(N,) | A(N,) | | +------------+---------+---------+ """ nin = 1 nout = 1
[docs] def __init__(self, min=-math.inf, max=math.inf, **blockargs): """ Signal clipping. :param min: Minimum value, defaults to -math.inf :type min: float or array_like, optional :param max: Maximum value, defaults to math.inf :type max: float or array_like, optional :param blockargs: |BlockOptions| :type blockargs: dict :return: A CLIP block :rtype: Clip instance The input signal is clipped to the range from ``minimum`` to ``maximum`` inclusive. The signal can be a 1D-array in which case each element is clipped. The minimum and maximum values can be: - a scalar, in which case the same value applies to every element of the input vector , or - a 1D-array, of the same shape as the input vector that applies elementwise to the input vector. For example:: clip = bd.CLIP() """ super().__init__(**blockargs) self.min = min self.max = max
def output(self, t=None): input = self.inputs[0] if isinstance(input, np.ndarray): out = np.clip(input, self.min, self.max) else: out = min(self.max, max(input, self.min)) return [ out ]
# ------------------------------------------------------------------------ # # TODO can have multiple outputs: pass in a tuple of functions, return a tuple
[docs]class Function(FunctionBlock): """ :blockname:`FUNCTION` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | nin | nout | 0 | +------------+---------+---------+ | any | any | | +------------+---------+---------+ """ nin = -1 nout = -1
[docs] def __init__(self, func=None, nin=1, nout=1, persistent=False, args=None, kwargs=None, **blockargs): """ Python function. :param func: A function or lambda, or list thereof, defaults to None :type func: callable or sequence of callables, optional :param nin: number of inputs, defaults to 1 :type nin: int, optional :param nout: number of outputs, defaults to 1 :type nout: int, optional :param persistent: pass in a reference to a dictionary instance to hold persistent state, defaults to False :type persistent: bool, optional :param args: extra positional arguments passed to the function, defaults to [] :type args: list, optional :param kwargs: extra keyword arguments passed to the function, defaults to {} :type kwargs: dict, optional :param blockargs: |BlockOptions| :type blockargs: dict, optional :return: A FUNCTION block :rtype: A Function instance Inputs to the block are passed as separate arguments to the function. Programmatic ositional or keyword arguments can also be passed to the function. A block with one output port that sums its two input ports is:: FUNCTION(lambda u1, u2: u1+u2, nin=2) A block with a function that takes two inputs and has two additional arguments:: def myfun(u1, u2, param1, param2): pass FUNCTION(myfun, nin=2, args=(p1,p2)) If we need access to persistent (static) data, to keep some state:: def myfun(u1, u2, param1, param2, state): pass FUNCTION(myfun, nin=2, args=(p1,p2), persistent=True) where a dictionary is passed in as the last argument and which is kept from call to call. A block with a function that takes two inputs and additional keyword arguments:: def myfun(u1, u2, param1=1, param2=2, param3=3, param4=4): pass FUNCTION(myfun, nin=2, kwargs=dict(param2=7, param3=8)) A block with two inputs and two outputs, the outputs are defined by two lambda functions with the same inputs:: FUNCTION( [ lambda x, y: x_t, lambda x, y: x* y]) A block with two inputs and two outputs, the outputs are defined by a single function which returns a list:: def myfun(u1, u2): return [ u1+u2, u1*u2 ] FUNCTION( myfun, nin=2, nout=2) For example:: func = bd.FUNCTION(myfun, args) If inputs are specified then connections are automatically made and are assigned to sequential input ports:: func = bd.FUNCTION(myfun, block1, block2, args) is equivalent to:: func = bd.FUNCTION(myfun, args) bd.connect(block1, func[0]) bd.connect(block2, func[1]) """ super().__init__(nin=nin, nout=nout, **blockargs) if args is None: args = list() if kwargs is None: kwargs = dict() # TODO, don't know why this happens if len(args) > 0 and args[0] == {}: args = [] if isinstance(func, (list, tuple)): for f in func: assert callable(f), 'Function must be a callable' if kwargs is None: # we can check the number of arguments n = len(inspect.signature(func).parameters) if persistent: n -= 1 # discount dict if used if nin + len(args) != n: raise ValueError( f"argument count mismatch: function has {n} args, dict={dict}, nin={nin}" ) elif callable(func): if len(kwargs) == 0: # we can check the number of arguments n = len(inspect.signature(func).parameters) if persistent: n -= 1 # discount dict if used if nin + len(args) != n: raise ValueError( f"argument count mismatch: function has {n} args, dict={dict}, nin={nin}" ) # self.nout = nout self.func = func if persistent: self.userdata = dict() args += (self.userdata,) else: self.userdata = None self.args = args self.kwargs = kwargs
def start(self, state=None): super().start() if self.userdata is not None: self.userdata.clear() print('clearing user data') def output(self, t=None): if callable(self.func): # single function try: val = self.func(*self.inputs, *self.args, **self.kwargs) except TypeError: raise RuntimeError('Function invocation failed, check number of arguments') from None if isinstance(val, (list, tuple)): if len(val) != self.nout: raise RuntimeError('Function returns wrong number of arguments: ' + str(self)) return val else: if self.nout != 1: raise RuntimeError('Function returns wrong number of arguments: ' + str(self)) return [val] else: # list of functions out = [] for f in self.func: try: val = f(*self.inputs, *self.args, **self.kwargs) except TypeError: raise RuntimeError('Function invocation failed, check number of arguments') from None out.append(val) return out
# ------------------------------------------------------------------------ #
[docs]class Interpolate(FunctionBlock): """ :blockname:`INTERPOLATE` .. table:: :align: left +------------+---------+---------+ | inputs | outputs | states | +------------+---------+---------+ | 0 or 1 | 1 | 0 | +------------+---------+---------+ | float | any | | +------------+---------+---------+ """ nin = -1 nout = 1
[docs] def __init__(self, x=None, y=None, xy=None, time=False, kind='linear', **blockargs): """ Interpolate signal. :param x: x-values of function, defaults to None :type x: array_like, shape (N,) optional :param y: y-values of function, defaults to None :type y: array_like, optional :param xy: combined x- and y-values of function, defaults to None :type xy: array_like, optional :param time: x new is simulation time, defaults to False :type time: bool, optional :param kind: interpolation method, defaults to 'linear' :type kind: str, optional :param blockargs: |BlockOptions| :type blockargs: dict :return: An INTERPOLATE block :rtype: An Interpolate instance Interpolate the input signal using to a piecewise function. A simple triangle function with domain [0,10] and range [0,1] can be defined by:: INTERPOLATE(x=(0,5,10), y=(0,1,0)) We might also express this as a list of 2D-coordinats:: INTERPOLATE(xy=[(0,0), (5,1), (10,0)]) The data can also be expressed as Numpy arrays. If that is the case, the interpolation function can be vector valued. ``x`` has a shape of (N,1) and ``y`` has a shape of (N,M). Alternatively ``xy`` has a shape of (N,M+1) and the first column is the x-data. The input to the interpolator comes from: - Input port 0 - Simulation time, if ``time=True``. In this case the block has no input ports and is a ``Source`` not a ``Function``. """ self.time = time if time: nin = 0 self.blockclass = 'source' else: nin = 1 super().__init__(nin=nin, **blockargs) if xy is None: # process separate x and y vectors x = np.array(x) y = np.array(y) assert x.shape[0] == y.shape[0], 'x and y data must be same length' else: # process mixed xy data if isinstance(xy, (list, tuple)): x = [_[0] for _ in xy] y = [_[1] for _ in xy] # x = np.array(x).T # y = np.array(y).T print(x, y) elif isinstance(xy, np.ndarray): x = xy[:,0] y = xy[:,1:] self.f = scipy.interpolate.interp1d(x=x, y=y, kind=kind, axis=0) self.x = x
def start(self, state, **blockargs): if self.time: assert self.x[0] >= 0, 'interpolation not defined for t<0' assert self.x[-1] <= state.T, 'interpolation not defined for t>T' def output(self, t=None): if self.time: xnew = t else: xnew = self.inputs[0] return [self.f(xnew)]
if __name__ == "__main__": import pathlib import os.path exec(open(os.path.join(pathlib.Path(__file__).parent.absolute(), "test_functions.py")).read())