micropython-ulab/docs/manual/source/ulab-utils.rst
Zoltán Vörös c0b3262be4
Add keyword arguments to spectrogram (#657)
* re-work spectrogram method, so that RAM can be re-used

* update docs with spectrogram changes
2024-09-14 12:18:14 +02:00

274 lines
9 KiB
ReStructuredText

ulab utilities
==============
There might be cases, when the format of your data does not conform to
``ulab``, i.e., there is no obvious way to map the data to any of the
five supported ``dtype``\ s. A trivial example is an ADC or microphone
signal with 32-bit resolution. For such cases, ``ulab`` defines the
``utils`` module, which, at the moment, has four functions that are not
``numpy`` compatible, but which should ease interfacing ``ndarray``\ s
to peripheral devices.
The ``utils`` module can be enabled by setting the
``ULAB_HAS_UTILS_MODULE`` constant to 1 in
`ulab.h <https://github.com/v923z/micropython-ulab/blob/master/code/ulab.h>`__:
.. code:: c
#ifndef ULAB_HAS_UTILS_MODULE
#define ULAB_HAS_UTILS_MODULE (1)
#endif
This still does not compile any functions into the firmware. You can add
a function by setting the corresponding pre-processor constant to 1.
E.g.,
.. code:: c
#ifndef ULAB_UTILS_HAS_FROM_INT16_BUFFER
#define ULAB_UTILS_HAS_FROM_INT16_BUFFER (1)
#endif
from_int32_buffer, from_uint32_buffer
-------------------------------------
With the help of ``utils.from_int32_buffer``, and
``utils.from_uint32_buffer``, it is possible to convert 32-bit integer
buffers to ``ndarrays`` of float type. These functions have a syntax
similar to ``numpy.frombuffer``; they support the ``count=-1``, and
``offset=0`` keyword arguments. However, in addition, they also accept
``out=None``, and ``byteswap=False``.
Here is an example without keyword arguments
.. code::
# code to be run in micropython
from ulab import numpy as np
from ulab import utils
a = bytearray([1, 1, 0, 0, 0, 0, 0, 255])
print('a: ', a)
print()
print('unsigned integers: ', utils.from_uint32_buffe
print('original vector:\n', y)
print('\nspectrum:\n', a)r(a))
b = bytearray([1, 1, 0, 0, 0, 0, 0, 255])
print('\nb: ', b)
print()
print('signed integers: ', utils.from_int32_buffer(b))
.. parsed-literal::
a: bytearray(b'\x01\x01\x00\x00\x00\x00\x00\xff')
unsigned integers: array([257.0, 4278190080.000001], dtype=float64)
b: bytearray(b'\x01\x01\x00\x00\x00\x00\x00\xff')
signed integers: array([257.0, -16777216.0], dtype=float64)
The meaning of ``count``, and ``offset`` is similar to that in
``numpy.frombuffer``. ``count`` is the number of floats that will be
converted, while ``offset`` would discard the first ``offset`` number of
bytes from the buffer before the conversion.
In the example above, repeated calls to either of the functions returns
a new ``ndarray``. You can save RAM by supplying the ``out`` keyword
argument with a pre-defined ``ndarray`` of sufficient size, in which
case the results will be inserted into the ``ndarray``. If the ``dtype``
of ``out`` is not ``float``, a ``TypeError`` exception will be raised.
.. code::
# code to be run in micropython
from ulab import numpy as np
from ulab import utils
a = np.array([1, 2], dtype=np.float)
b = bytearray([1, 0, 1, 0, 0, 1, 0, 1])
print('b: ', b)
utils.from_uint32_buffer(b, out=a)
print('a: ', a)
.. parsed-literal::
b: bytearray(b'\x01\x00\x01\x00\x00\x01\x00\x01')
a: array([65537.0, 16777472.0], dtype=float64)
Finally, since there is no guarantee that the endianness of a particular
peripheral device supplying the buffer is the same as that of the
microcontroller, ``from_(u)intbuffer`` allows a conversion via the
``byteswap`` keyword argument.
.. code::
# code to be run in micropython
from ulab import numpy as np
from ulab import utils
a = bytearray([1, 0, 0, 0, 0, 0, 0, 1])
print('a: ', a)
print('buffer without byteswapping: ', utils.from_uint32_buffer(a))
print('buffer with byteswapping: ', utils.from_uint32_buffer(a, byteswap=True))
.. parsed-literal::
a: bytearray(b'\x01\x00\x00\x00\x00\x00\x00\x01')
buffer without byteswapping: array([1.0, 16777216.0], dtype=float64)
buffer with byteswapping: array([16777216.0, 1.0], dtype=float64)
from_int16_buffer, from_uint16_buffer
-------------------------------------
These two functions are identical to ``utils.from_int32_buffer``, and
``utils.from_uint32_buffer``, with the exception that they convert
16-bit integers to floating point ``ndarray``\ s.
spectrogram
-----------
In addition to the Fourier transform and its inverse, ``ulab`` also
sports a function called ``spectrogram``, which returns the absolute
value of the Fourier transform, also known as the power spectrum. This
could be used to find the dominant spectral component in a time series.
The positional arguments are treated in the same way as in ``fft``, and
``ifft``. This means that, if the firmware was compiled with complex
support and ``ULAB_FFT_IS_NUMPY_COMPATIBLE`` is defined to be 1 in
``ulab.h``, the input can also be a complex array.
And easy way to find out if the FFT is ``numpy``-compatible is to check
the number of values ``fft.fft`` returns, when called with a single real
argument of length other than 2:
.. code::
# code to be run in micropython
from ulab import numpy as np
if len(np.fft.fft(np.zeros(4))) == 2:
print('FFT is NOT numpy compatible (real and imaginary parts are treated separately)')
else:
print('FFT is numpy compatible (complex inputs/outputs)')
.. parsed-literal::
FFT is numpy compatible (complex inputs/outputs)
Depending on the ``numpy``-compatibility of the FFT, the ``spectrogram``
function takes one or two positional arguments, and three keyword
arguments. If the FFT is ``numpy`` compatible, one positional argument
is allowed, and it is a 1D real or complex ``ndarray``. If the FFT is
not ``numpy``-compatible, if a single argument is supplied, it will be
treated as the real part of the input, and if two positional arguments
are supplied, they are treated as the real and imaginary parts of the
signal.
The keyword arguments are as follows:
1. ``scratchpad = None``: must be a 1D, dense, floating point array,
twice as long as the input array; the ``scratchpad`` will be used as
a temporary internal buffer to perform the Fourier transform; the
``scratchpad`` can repeatedly be re-used.
2. ``out = None``: must be a 1D, not necessarily dense, floating point
array that will store the results
3. ``log = False``: must be either ``True``, or ``False``; if ``True``,
the ``spectrogram`` returns the logarithm of the absolute values of
the Fourier transform.
.. code::
# code to be run in micropython
from ulab import numpy as np
from ulab import utils as utils
x = np.linspace(0, 10, num=1024)
y = np.sin(x)
a = utils.spectrogram(y)
print('original vector:\n', y)
print('\nspectrum:\n', a)
.. parsed-literal::
original vector:
array([0.0, 0.009775015390171337, 0.01954909674625918, ..., -0.5275140569487312, -0.5357931822978732, -0.5440211108893697], dtype=float64)
spectrum:
array([187.8635087634578, 315.3112063607119, 347.8814873399375, ..., 84.45888934298905, 347.8814873399374, 315.3112063607118], dtype=float64)
As such, ``spectrogram`` is really just a shorthand for
``np.abs(np.fft.fft(signal))``, if the FFT is ``numpy``-compatible, or
``np.sqrt(a*a + b*b)`` if the FFT returns the real (``a``) and imaginary
(``b``) parts separately. However, ``spectrogram`` saves significant
amounts of RAM: the expression ``a*a + b*b`` has to allocate memory for
``a*a``, ``b*b``, and finally, their sum. Similarly, ``np.abs`` returns
a new array. This issue is compounded even more, if ``np.log()`` is used
on the absolute value.
In contrast, ``spectrogram`` handles all calculations in the same
internal arrays, and allows one to re-use previously reserved RAM. This
can be especially useful in cases, when ``spectogram`` is called
repeatedly, as in the snippet below.
.. code::
# code to be run in micropython
from ulab import numpy as np
from ulab import utils as utils
n = 1024
t = np.linspace(0, 2 * np.pi, num=1024)
scratchpad = np.zeros(2 * n)
for _ in range(10):
signal = np.sin(t)
utils.spectrogram(signal, out=signal, scratchpad=scratchpad, log=True)
print('signal: ', signal)
for _ in range(10):
signal = np.sin(t)
out = np.log(utils.spectrogram(signal))
print('out: ', out)
.. parsed-literal::
signal: array([-27.38260169844543, 6.237834411021073, -0.4038327279002965, ..., -0.9795967096969854, -0.4038327279002969, 6.237834411021073], dtype=float64)
out: array([-27.38260169844543, 6.237834411021073, -0.4038327279002965, ..., -0.9795967096969854, -0.4038327279002969, 6.237834411021073], dtype=float64)
Note that ``scratchpad`` is reserved only once, and then is re-used in
the first loop. By assigning ``signal`` to the output, we save
additional RAM. This approach avoids the usual problem of memory
fragmentation, which would happen in the second loop, where both
``spectrogram``, and ``np.log`` must reserve RAM in each iteration.