# Universal functions¶

Standard mathematical functions can be calculated on any scalar, scalar-valued iterable (ranges, lists, tuples containing numbers), and on `ndarray`s without having to change the call signature. In all cases the functions return a new `ndarray` of typecode `float` (since these functions usually generate float values, anyway). The functions execute faster with `ndarray` arguments than with iterables, because the values of the input vector can be extracted faster.

At present, the following functions are supported:

`acos`, `acosh`, `arctan2`, `around`, `asin`, `asinh`, `atan`, `arctan2`, `atanh`, `ceil`, `cos`, `degrees`, `exp`, `expm1`, `floor`, `log`, `log10`, `log2`, `radians`, `sin`, `sinh`, `sqrt`, `tan`, `tanh`.

These functions are applied element-wise to the arguments, thus, e.g., the exponential of a matrix cannot be calculated in this way.

```# code to be run in micropython

from ulab import numpy as np

a = range(9)
b = np.array(a)

# works with ranges, lists, tuples etc.
print('a:\t', a)
print('exp(a):\t', np.exp(a))

# with 1D arrays
print('\n=============\nb:\n', b)
print('exp(b):\n', np.exp(b))

# as well as with matrices
c = np.array(range(9)).reshape((3, 3))
print('\n=============\nc:\n', c)
print('exp(c):\n', np.exp(c))
```
```a:   range(0, 9)
exp(a):      array([1.0, 2.718281828459045, 7.38905609893065, 20.08553692318767, 54.59815003314424, 148.4131591025766, 403.4287934927351, 1096.633158428459, 2980.957987041728], dtype=float64)

=============
b:
array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], dtype=float64)
exp(b):
array([1.0, 2.718281828459045, 7.38905609893065, 20.08553692318767, 54.59815003314424, 148.4131591025766, 403.4287934927351, 1096.633158428459, 2980.957987041728], dtype=float64)

=============
c:
array([[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]], dtype=float64)
exp(c):
array([[1.0, 2.718281828459045, 7.38905609893065],
[20.08553692318767, 54.59815003314424, 148.4131591025766],
[403.4287934927351, 1096.633158428459, 2980.957987041728]], dtype=float64)
```

## Computation expenses¶

The overhead for calculating with micropython iterables is quite significant: for the 1000 samples below, the difference is more than 800 microseconds, because internally the function has to create the `ndarray` for the output, has to fetch the iterable’s items of unknown type, and then convert them to floats. All these steps are skipped for `ndarray`s, because these pieces of information are already known.

Doing the same with `list` comprehension requires 30 times more time than with the `ndarray`, which would become even more, if we converted the resulting list to an `ndarray`.

```# code to be run in micropython

from ulab import numpy as np
import math

a = *1000
b = np.array(a)

@timeit
def timed_vector(iterable):
return np.exp(iterable)

@timeit
def timed_list(iterable):
return [math.exp(i) for i in iterable]

print('iterating over ndarray in ulab')
timed_vector(b)

print('\niterating over list in ulab')
timed_vector(a)

print('\niterating over list in python')
timed_list(a)
```
```iterating over ndarray in ulab
execution time:  441  us

iterating over list in ulab
execution time:  1266  us

iterating over list in python
execution time:  11379  us
```

## arctan2¶

The two-argument inverse tangent function is also part of the `vector` sub-module. The function implements broadcasting as discussed in the section on `ndarray`s. Scalars (`micropython` integers or floats) are also allowed.

```# code to be run in micropython

from ulab import numpy as np

a = np.array([1, 2.2, 33.33, 444.444])
print('a:\n', a)
print('\narctan2(a, 1.0)\n', np.arctan2(a, 1.0))
print('\narctan2(1.0, a)\n', np.arctan2(1.0, a))
print('\narctan2(a, a): \n', np.arctan2(a, a))
```
```a:
array([1.0, 2.2, 33.33, 444.444], dtype=float64)

arctan2(a, 1.0)
array([0.7853981633974483, 1.14416883366802, 1.5408023243361, 1.568546328341769], dtype=float64)

arctan2(1.0, a)
array([0.7853981633974483, 0.426627493126876, 0.02999400245879636, 0.002249998453127392], dtype=float64)

arctan2(a, a):
array([0.7853981633974483, 0.7853981633974483, 0.7853981633974483, 0.7853981633974483], dtype=float64)
```

## around¶

`numpy`’s `around` function can also be found in the `vector` sub-module. The function implements the `decimals` keyword argument with default value `0`. The first argument must be an `ndarray`. If this is not the case, the function raises a `TypeError` exception. Note that `numpy` accepts general iterables. The `out` keyword argument known from `numpy` is not accepted. The function always returns an ndarray of type `mp_float_t`.

```# code to be run in micropython

from ulab import numpy as np

a = np.array([1, 2.2, 33.33, 444.444])
print('a:\t\t', a)
print('\ndecimals = 0\t', np.around(a, decimals=0))
print('\ndecimals = 1\t', np.around(a, decimals=1))
print('\ndecimals = -1\t', np.around(a, decimals=-1))
```
```a:           array([1.0, 2.2, 33.33, 444.444], dtype=float64)

decimals = 0         array([1.0, 2.0, 33.0, 444.0], dtype=float64)

decimals = 1         array([1.0, 2.2, 33.3, 444.4], dtype=float64)

decimals = -1        array([0.0, 0.0, 30.0, 440.0], dtype=float64)
```

## Vectorising generic python functions¶

The examples above use factory functions. In fact, they are nothing but the vectorised versions of the standard mathematical functions. User-defined `python` functions can also be vectorised by help of `vectorize`. This function takes a positional argument, namely, the `python` function that you want to vectorise, and a non-mandatory keyword argument, `otypes`, which determines the `dtype` of the output array. The `otypes` must be `None` (default), or any of the `dtypes` defined in `ulab`. With `None`, the output is automatically turned into a float array.

The return value of `vectorize` is a `micropython` object that can be called as a standard function, but which now accepts either a scalar, an `ndarray`, or a generic `micropython` iterable as its sole argument. Note that the function that is to be vectorised must have a single argument.

```# code to be run in micropython

from ulab import numpy as np

def f(x):
return x*x

vf = np.vectorize(f)

# calling with a scalar
print('{:20}'.format('f on a scalar: '), vf(44.0))

# calling with an ndarray
a = np.array([1, 2, 3, 4])
print('{:20}'.format('f on an ndarray: '), vf(a))

# calling with a list
print('{:20}'.format('f on a list: '), vf([2, 3, 4]))
```
```f on a scalar:       array([1936.0], dtype=float64)
f on an ndarray:     array([1.0, 4.0, 9.0, 16.0], dtype=float64)
f on a list:         array([4.0, 9.0, 16.0], dtype=float64)
```

As mentioned, the `dtype` of the resulting `ndarray` can be specified via the `otypes` keyword. The value is bound to the function object that `vectorize` returns, therefore, if the same function is to be vectorised with different output types, then for each type a new function object must be created.

```# code to be run in micropython

from ulab import numpy as np

l = [1, 2, 3, 4]
def f(x):
return x*x

vf1 = np.vectorize(f, otypes=np.uint8)
vf2 = np.vectorize(f, otypes=np.float)

print('{:20}'.format('output is uint8: '), vf1(l))
print('{:20}'.format('output is float: '), vf2(l))
```
```output is uint8:     array([1, 4, 9, 16], dtype=uint8)
output is float:     array([1.0, 4.0, 9.0, 16.0], dtype=float64)
```

The `otypes` keyword argument cannot be used for type coercion: if the function evaluates to a float, but `otypes` would dictate an integer type, an exception will be raised:

```# code to be run in micropython

from ulab import numpy as np

int_list = [1, 2, 3, 4]
float_list = [1.0, 2.0, 3.0, 4.0]
def f(x):
return x*x

vf = np.vectorize(f, otypes=np.uint8)

print('{:20}'.format('integer list: '), vf(int_list))
# this will raise a TypeError exception
print(vf(float_list))
```
```integer list:        array([1, 4, 9, 16], dtype=uint8)

Traceback (most recent call last):
File "/dev/shm/micropython.py", line 14, in <module>
TypeError: can't convert float to int
```

### Benchmarks¶

It should be pointed out that the `vectorize` function produces the pseudo-vectorised version of the `python` function that is fed into it, i.e., on the C level, the same `python` function is called, with the all-encompassing `mp_obj_t` type arguments, and all that happens is that the `for` loop in `[f(i) for i in iterable]` runs purely in C. Since type checking and type conversion in `f()` is expensive, the speed-up is not so spectacular as when iterating over an `ndarray` with a factory function: a gain of approximately 30% can be expected, when a native `python` type (e.g., `list`) is returned by the function, and this becomes around 50% (a factor of 2), if conversion to an `ndarray` is also counted.

The following code snippet calculates the square of a 1000 numbers with the vectorised function (which returns an `ndarray`), with `list` comprehension, and with `list` comprehension followed by conversion to an `ndarray`. For comparison, the execution time is measured also for the case, when the square is calculated entirely in `ulab`.

```# code to be run in micropython

from ulab import numpy as np

def f(x):
return x*x

vf = np.vectorize(f)

@timeit
def timed_vectorised_square(iterable):
return vf(iterable)

@timeit
def timed_python_square(iterable):
return [f(i) for i in iterable]

@timeit
def timed_ndarray_square(iterable):
return np.array([f(i) for i in iterable])

@timeit
def timed_ulab_square(ndarray):
return ndarray**2

print('vectorised function')
squares = timed_vectorised_square(range(1000))

print('\nlist comprehension')
squares = timed_python_square(range(1000))

print('\nlist comprehension + ndarray conversion')
squares = timed_ndarray_square(range(1000))

print('\nsquaring an ndarray entirely in ulab')
a = np.array(range(1000))
squares = timed_ulab_square(a)
```
```vectorised function
execution time:  7237  us

list comprehension
execution time:  10248  us

list comprehension + ndarray conversion
execution time:  12562  us

squaring an ndarray entirely in ulab
execution time:  560  us
```

From the comparisons above, it is obvious that `python` functions should only be vectorised, when the same effect cannot be gotten in `ulab` only. However, although the time savings are not significant, there is still a good reason for caring about vectorised functions. Namely, user-defined `python` functions become universal, i.e., they can accept generic iterables as well as `ndarray`s as their arguments. A vectorised function is still a one-liner, resulting in transparent and elegant code.

A final comment on this subject: the `f(x)` that we defined is a generic `python` function. This means that it is not required that it just crunches some numbers. It has to return a number object, but it can still access the hardware in the meantime. So, e.g.,

```led = pyb.LED(2)

def f(x):
if x < 100:
led.toggle()
return x*x
```

is perfectly valid code.