Source code for doatools.estimation.grid


from abc import ABC, abstractmethod
import numpy as np
from ..model.sources import FarField1DSourcePlacement, FarField2DSourcePlacement, NearField2DSourcePlacement
from ..utils.math import cartesian

[docs]class SearchGrid(ABC): """Base class for all search grids. Provides standard implementation. Args: axes: A tuple of 1D ndarrays representing the axes of this search grid. The source locations on this search grid will be generated from these axes. axis_names: A tuple of strings denoting the names of the axes. units (str): A tuple of strings representing the unit used for each axis. """ def __init__(self, axes, axis_names, units): if not isinstance(axes, tuple): raise ValueError('axes should be a tuple.') if not isinstance(axis_names, tuple): raise ValueError('axis_names should be a tuple.') if not isinstance(units, tuple): raise ValueError('units should be a tuple.') self._axes = axes self._shape = tuple(len(ax) for ax in axes) self._axis_names = axis_names self._units = units self._sources = None @property def ndim(self): """Retrieves the number of dimensions of this search grid.""" return len(self._axes) @property def size(self): """Retrieves the number of elements on this search grid.""" return np.prod(self.shape) @property def shape(self): """Retrieves the shape of this search grid. Returns: A tuple of integers representing the shape. """ return self._shape @property def source_placement(self): r"""Retrieves the source placement based on this grid. For a multi-dimensional search grid with shape :math:`(d_1, d_2, \ldots, d_n)`, the returned :class:`~doatools.model.sources.SourcePlacement` instance will contain :math:`d_1 \times d_2 \times \cdots \times d_n` elements, which are ordered in such a way that the first dimension changes the slowest, the second dimension changes the second slowest, and so on. For instance, the elements in the following 2x3 grid :: (1, 1) (1, 2) (1, 3) (2, 1) (2, 2) (2, 3) will be ordered as :: (1, 1) (1, 2) (1, 3) (2, 1) (2, 2) (2, 3) Do not **modify**. """ if self._sources is None: self._sources = self._create_source_placement() return self._sources @property def units(self): """Retrieves a tuple of strings representing the unit used for each axis.""" return self._units @property def axes(self): """Retrieves a tuple of 1D numpy vectors representing the axes. The sources locations can be recovered with the Cartesian product over ``(axes[0], axes[1], ...)``. Do **not** modify. """ return self._axes @property def axis_names(self): """Retrieves a tuple of strings representing the axis names.""" return self._axis_names @abstractmethod def _create_source_placement(self): """Creates the source placement instance for this grid. Notes: Implement this method in a subclass to create the source placement instance of the desired type. """ raise NotImplementedError()
[docs] def create_refined_axes_at(self, coord, density, span): """Creates a new set of axes by subdividing the grids around the input coordinate into finer grids. These new axes can then be used to create refined grids. For instance, suppose that the original grid is a 2D grid with the axes: ========== =================== Axis name Axis data ========== =================== Azimuth [0, 10, 20, 30, 40] Elevation [0, 20, 40] ========== =================== Suppose that ``coord`` is (3, 1), ``density`` is 4, and ``span`` is 1. Then the following set of axes will be created: Refined axes around the coordinate (3, 1) (or azimuth = 30, elevation = 20): ========= ============================================== Axis name Axis data ========= ============================================== Azimuth [20, 22.5, 25.0, 27.5, 30, 32.5, 35, 37.5, 40] Elevation [0, 5, 10, 15, 20, 25, 30, 35, 40] ========= ============================================== Args: coord: A tuple of integers representing a single coordinate within this grid. density (int): Controls number of new intervals between two adjacent points in the original grid. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coord`` when performing the refinement. Returns: A tuple of ndarrays representing the refined axes. """ if density < 1: raise ValueError('Density must be greater than or equal to 1.') if span < 1: raise ValueError('Span must be greater than or equal to 1.') if len(coord) != self.ndim: raise ValueError( 'Incorrect number of coordinate elements. Expecting {0}. Got {1}.' .format(self.ndim, len(coord)) ) axes = [] for j in range(self.ndim): # Lower bound and upper bound indices. i_lb = max(0, coord[j] - span) i_ub = min(self._shape[j] - 1, coord[j] + span) # Convert to actual values. lb = self._axes[j][i_lb] ub = self._axes[j][i_ub] axes.append(np.linspace(lb, ub, (i_ub - i_lb) * density + 1)) return tuple(axes)
[docs] def create_refined_grids_at(self, *coords, **kwargs): """Creates multiple new search grids around the given coordinates. Args: *coords: A sequence of list-like objects representing the coordinates of the grid points around which the refinement will be performed. The length of ``coords`` should be equal to the number of dimensions of this grid. The list-like objects in ``coords`` should share the same length. ``coords[j][i]`` denotes the j-th element of the i-th coordinate. density (int): Controls number of new intervals between two adjacent points in the original grid. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coords`` when performing the refinement. Returns: A list of refined grids. """ return [self.create_refined_grid_at(coord, **kwargs) for coord in zip(*coords)]
[docs] @abstractmethod def create_refined_grid_at(self, coord, density, span): """Creates a finer search grid around the given coordinate. Args: coord: A tuple of integers representing a single coordinate within this grid. density (int): Controls number of new intervals between two adjacent points in the original grid. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coord`` when performing the refinement. Returns: A refined grid. """ raise NotImplementedError()
[docs]class FarField1DSearchGrid(SearchGrid): r"""Creates a search grid for 1D far-field source localization. When both ``start`` and ``stop`` are scalars, the resulting search grid consists only one uniform grid. When both ``start`` and ``stop`` are lists the resulting search grid is a combination of multiple uniform grids specified by ``start[k]``, ``stop[k]``, and ``size[k]``. Args: start (float): A scalar of the starting angle or a list of starting angles. If not specified, the following default values will be used depending on ``unit``: * ``'rad'``: :math:`-\pi/2` * ``'deg'``: -90, * ``'sin'``: -1 stop (float): A scalar of the stopping angle or a list of stopping angles. This angle is not included in the grid. If not specified, the following default values will be used depending on ``unit``: * ``'rad'``: :math:`\pi/2` * ``'deg'``: 90 * ``'sin'``: 1 size (int): Specifies the grid size. If both ``start`` and ``stop`` are lists, `size` must also be a list such that 'size[k]' specifies the number of grid points between ``start[k]`` and ``stop[k]``. Default value is 180. unit (str): Can be ``'rad'`` (default), ``'deg'`` or ``'sin'``. axes: A tuple of 1D ndarrays representing the axes of the search grid. If specified, ``start``, ``stop``, and ``size`` will be ignored and the search grid will be generated based only on ``axes`` and ``units``. Default value is ``None``. Returns: A search grid for 1D far-field source localization. """ def __init__(self, start=None, stop=None, size=180, unit='rad', axes=None): if axes is not None: super().__init__(axes, ('DOA',), (unit,)) else: default_ranges = { 'rad': (-np.pi / 2, np.pi / 2), 'deg': (-90.0, 90.0), 'sin': (-1.0, 1.0) } if start is None: start = default_ranges[unit][0] if stop is None: stop = default_ranges[unit][1] if np.isscalar(start): locations = np.linspace(start, stop, size, endpoint=False) else: n_points = sum(size) locations = np.zeros((n_points, 1)) offset = 0 for k in range(len(start)): locations[offset:offset+size[k], 0] = np.linspace(start[k], stop[k], size[k], endpoint=False) super().__init__((locations,), ('DOA',), (unit,)) def _create_source_placement(self): return FarField1DSourcePlacement(self._axes[0], self._units[0])
[docs] def create_refined_grid_at(self, coord, density=10, span=1): """Creates a finer search grid for 1D far-field sources. Args: coord: A tuple of integers representing a single coordinate within this grid. density (int): Controls number of new intervals between two adjacent points in the original grid. Default value is 10. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coord`` when performing the refinement. Default value is 1. Returns: A refined 1D far-field search grid. """ axes = self.create_refined_axes_at(coord, density, span) return FarField1DSearchGrid(unit=self._units[0], axes=axes)
[docs]class FarField2DSearchGrid(SearchGrid): r"""Creates a search grid for 2D far-field source localization. The first dimension corresponds to the azimuth angle, and the second dimension corresponds to the elevation angle. Args: start: A two-element list-like object containing the starting azimuth and elevation angles. If not specified, the following default values will be used depending on ``unit``: * ``'rad'``: (:math:`-\pi`, 0) * ``'deg'``: (-180, 0) stop: A two-element list-like object containing the stopping azimuth and elevation angles. These two angles are not included in the search grid. If not specified, the following default values will be used depending on ``unit``: * ``'rad'``: (:math:`\pi`, :math:`\pi/2`) * ``'deg'``: (180, 90) size: A scalar or a two-element list-like object specifying the size of the search grid. If ``size`` is a scalar, a ``size`` by ``size`` grid will be created. If ``size`` is a two-element list-like object, a ``size[0]`` by ``size[1]`` grid will be created. Default value is ``(360, 90)``. unit (str): Can be ``'rad'`` (default) or ``'deg'``. axes: A tuple of 1D ndarrays representing the axes of the search grid. If specified, ``start``, ``stop``, and ``size`` will be ignored and the search grid will be generated based only on ``axes`` and ``units``. Default value is ``None``. Returns: A search grid for 2D far-field source localization. """ def __init__(self, start=None, stop=None, size=(360, 90), unit='rad', axes=None): axis_names = ('Azimuth', 'Elevation') if axes is not None: super().__init__(axes, axis_names, (unit, unit)) else: default_ranges = { 'rad': ((-np.pi, 0.0), (np.pi, np.pi/2)), 'deg': ((-180.0, 0.0), (180.0, 90.0)) } if start is None: start = default_ranges[unit][0] if stop is None: stop = default_ranges[unit][1] if np.isscalar(size): size = (size, size) az = np.linspace(start[0], stop[0], size[0], False) el = np.linspace(start[1], stop[1], size[1], False) super().__init__((az, el), axis_names, (unit, unit)) def _create_source_placement(self): return FarField2DSourcePlacement(cartesian(*self._axes), self._units[0])
[docs] def create_refined_grid_at(self, coord, density=10, span=1): """Creates a finer search grid for 2D far-field sources. Args: coord: A tuple of integers representing a single coordinate within this grid. density (int): Controls number of new intervals between two adjacent points in the original grid. Default value is 10. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coord`` when performing the refinement. Default value is 1. Returns: A refined 2D far-field search grid. """ axes = self.create_refined_axes_at(coord, density, span) return FarField2DSearchGrid(unit=self._units[0], axes=axes)
[docs]class NearField2DSearchGrid(SearchGrid): """Creates a search grid for 2D near-field source localization. The first dimension corresponds to the x coordinate, and the second dimension corresponds to the y coordinate. Args: start: A two-element list-like object containing the starting x and y coordinates. stop: A two-element list-like object containing the stopping x and y coordinates. These two coordinates are not included in the search grid. size: A scalar or a two-element list-like object specifying the size of the search grid. If ``size`` is a scalar, a ``size`` by ``size`` grid will be created. If ``size`` is a two-element list-like object, a ``size[0]`` by ``size[1]`` grid will be created. Default value is ``(360, 90)``. axes: A tuple of 1D ndarrays representing the axes of the search grid. If specified, ``start``, ``stop``, and ``size`` will be ignored and the search grid will be generated based only on ``axes`` and ``units``. Default value is ``None``. Returns: A search grid for 2D near-field source localization. """ def __init__(self, start=None, stop=None, size=None, axes=None): axis_names = ('x', 'y') if axes is not None: super().__init__(axes, axis_names, ('m', 'm')) else: if np.isscalar(size): size = (size, size) x = np.linspace(start[0], stop[0], size[0], False) y = np.linspace(start[1], stop[1], size[1], False) super().__init__((x, y), axis_names, ('m', 'm')) def _create_source_placement(self): return NearField2DSourcePlacement(cartesian(*self._axes))
[docs] def create_refined_grids_at(self, coord, density=10, span=1): """Creates a finer search grid for 2D near-field sources. Args: coord: A tuple of integers representing a single coordinate within this grid. density (int): Controls number of new intervals between two adjacent points in the original grid. Default value is 10. span (int): Controls how many adjacent intervals in the original grid will be considered around the point specified by ``coord`` when performing the refinement. Default value is 1. Returns: A refined 2D near-field search grid. """ axes = self.create_refined_axes_at(coord, density, span) return NearField2DSearchGrid(axes=axes)