Source code for spharpy.samplings.samplings

"""
Collection of sampling schemes for the sphere.
"""
import os
from urllib3.exceptions import InsecureRequestWarning
import numpy as np
from pyfar import Coordinates
import spharpy
import warnings
import scipy.io as sio
import requests
from multiprocessing.pool import ThreadPool

from ._eqsp import point_set as eq_point_set
from ._eqsp import lebedev_sphere


[docs] def equidistant_cuboid(n_points, flatten_output=True): """ Create a cubic sampling with equidistant spacings in x, y, and z. The cuboid spans from -1 m to 1 m along each axis and is centered at the origin. Parameters ---------- n_points : int, tuple Number of points in the sampling. If a single value is given, the number of sampling positions will be the same in every axis. If a tuple is given, the number of points will be set as ``(n_x, n_y, n_z)``. flatten_output : bool, optional Whether to flatten the output Coordinates object or not. The default is ``False``. Returns ------- sampling : :py:class:`pyfar.Coordinates` Sampling positions as Coordinate object with cshape ``(n_x, n_y, n_z)`` if `flatten_output` is ``False``, otherwise with cshape ``(n_x*n_y*n_z, )``. Does not contain sampling weights. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.equidistant_cuboid(3) >>> spharpy.plot.scatter(coords) """ if not isinstance(flatten_output, bool): raise ValueError("flatten_output must be a boolean.") try: n_points = np.asarray(n_points) assert (n_points > 0).all() assert (n_points % 1 == 0).all() n_points = n_points.astype(int) except Exception as e: raise ValueError( "The number of points needs to be either an integer " "or a tuple with 3 elements.") from e if np.size(n_points) == 1: n_x = n_points n_y = n_points n_z = n_points elif np.size(n_points) == 3: n_x = n_points[0] n_y = n_points[1] n_z = n_points[2] else: raise ValueError("The number of points needs to be either an integer \ or a tuple with 3 elements.") x = np.linspace(-1, 1, n_x) y = np.linspace(-1, 1, n_y) z = np.linspace(-1, 1, n_z) x_grid, y_grid, z_grid = np.meshgrid(x, y, z, indexing='ij') if flatten_output: x_grid = x_grid.flatten() y_grid = y_grid.flatten() z_grid = z_grid.flatten() return Coordinates(x_grid, y_grid, z_grid)
[docs] def hyperinterpolation(n_max, radius=1.): r""" Return a Hyperinterpolation sampling grid. After Sloan and Womersley [#]_. The samplings are available for spherical harmonics orders :math:`1 \leq n_\text{max} \leq 200`. The number of points in the sampling grid equals :math:`(n_\text{max} + 1)^2`. Parameters ---------- n_max : int Maximum applicable spherical harmonic order. It must be between ``1`` and ``200``. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions including sampling weights, with ``(n_max + 1)**2`` points. Notes ----- This implementation uses precalculated sets of points from [#]_. The data up to ``n_max = 20`` are loaded the first time this function is called. The remaining data is loaded upon request. References ---------- .. [#] I. H. Sloan and R. S. Womersley, “Extremal Systems of Points and Numerical Integration on the Sphere,” Advances in Computational Mathematics, vol. 21, no. 1/2, pp. 107-125, 2004. .. [#] https://web.maths.unsw.edu.au/~rsw/Sphere/MaxDet/ Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.hyperinterpolation(n_max=3) >>> spharpy.plot.scatter(coords) """ # check inputs if not isinstance(n_max, int) or n_max < 1 or n_max > 200: raise ValueError('n_max must be an integer between 1 and 200') if not isinstance( radius, (int, float)) or np.size(radius) > 1 or (radius <= 0): raise ValueError('radius must be a single positive value') # calculate number of points n_points = (n_max + 1)**2 # download data if necessary filename = "samplings_extremal_md%03d.%05d" % (n_max, n_points) filename = os.path.join(os.path.dirname(__file__), "_eqsp", filename) if not os.path.exists(filename): if n_max < 21: _sph_extremal_load_data(np.arange(1, 21)) else: _sph_extremal_load_data(n_max) # open data with open(filename, 'rb') as f: file_data = f.read() # format data file_data = file_data.decode() file_data = np.fromstring( file_data, dtype=float, count=int(n_points)*4, sep=' ').reshape((int(n_points), 4)) # normalize weights weights = file_data[:, 3] # generate Coordinates object sampling = spharpy.SamplingSphere( file_data[:, 0] * radius, file_data[:, 1] * radius, file_data[:, 2] * radius, n_max=n_max, weights=weights, comment='extremal spherical sampling grid') return sampling
[docs] def t_design(n_max, criterion='const_energy', radius=1.): r""" Return spherical t-design sampling grid. For detailed information, see [#]_. For a spherical harmonic order :math:`n_{sh}`, a t-Design of degree :math:`t=2n_{sh}` for constant energy or :math:`t=2n_{sh}+1` additionally ensuring a constant angular spread of energy is required [#]_. For a given degree t .. math:: L = \lceil \frac{(t+1)^2}{2} \rceil+1, points will be generated, except for t = 3, 5, 7, 9, 11, 13, and 15. T-designs allow for an inverse spherical harmonic transform matrix calculated as :math:`D = \frac{4\pi}{L} \mathbf{Y}^\mathrm{H}` with :math:`\mathbf{Y}^\mathrm{H}` being the hermitian transpose of the spherical harmonics matrix. Parameters ---------- n_max : int Maximum applicable spherical harmonic order. Related to the degree by ``degree = 2 * n_max`` (``const_energy``) and ``degree = 2 * n_max + 1`` (``const_angular_spread``). Must be between ``1`` and ``90`` for ``const_energy`` and between ``1`` and ``89`` for ``const_angular_spread``. criterion : ``const_energy``, ``const_angular_spread`` Design criterion ensuring only a constant energy or additionally constant angular spread of energy. The default is ``const_energy``. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions. Sampling weights can be obtained from :py:func:`calculate_sampling_weights`. Notes ----- This function downloads a pre-calculated set of points from [#]_ . The data up to ``degree = 20`` are loaded the first time this function is called. The remaining data is loaded upon request. References ---------- .. [#] C. An, X. Chen, I. H. Sloan, and R. S. Womersley, “Well Conditioned Spherical Designs for Integration and Interpolation on the Two-Sphere,” SIAM Journal on Numerical Analysis, vol. 48, no. 6, pp. 2135-2157, Jan. 2010. .. [#] F. Zotter, M. Frank, and A. Sontacchi, “The Virtual T-Design Ambisonics-Rig Using VBAP,” in Proceedings on the Congress on Sound and Vibration, 2010. .. [#] http://web.maths.unsw.edu.au/~rsw/Sphere/EffSphDes/sf.html Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.t_design(n_max=3) >>> spharpy.plot.scatter(coords) """ # check inputs if criterion not in ['const_energy', 'const_angular_spread']: raise ValueError("Invalid design criterion. Must be 'const_energy' \ or 'const_angular_spread'.") if not isinstance(n_max, (int)): raise ValueError("n_max must be an integer.") if not isinstance(radius, (int, float)) or radius <= 0: raise ValueError("radius must be a positive number.") # get the degree if criterion == 'const_energy': if n_max < 1 or n_max > 90: raise ValueError( 'n_max must be between 1 and 90 for const_energy criterion.') degree = 2 * n_max else: if n_max < 1 or n_max > 89: raise ValueError( 'n_max must be between 1 and 89 for const_angular_spread ' 'criterion.') degree = 2 * n_max + 1 if degree < 1 or degree > 180: raise ValueError('degree must be between 1 and 180.') # get the number of points n_points = int(np.ceil((degree + 1)**2 / 2) + 1) n_points_exceptions = {3: 8, 5: 18, 7: 32, 9: 50, 11: 72, 13: 98, 15: 128} if degree in n_points_exceptions: n_points = n_points_exceptions[degree] # download data if necessary filename = "samplings_t_design_sf%03d.%05d" % (degree, n_points) filename = os.path.join(os.path.dirname(__file__), "_eqsp", filename) if not os.path.exists(filename): if degree < 21: _sph_t_design_load_data(np.arange(1, 21)) else: _sph_t_design_load_data(degree) # open data with open(filename, 'rb') as f: file_data = f.read() # format data file_data = file_data.decode() points = np.fromstring( file_data, dtype=np.double, sep=' ').reshape((n_points, 3)) # generate Coordinates object sampling = spharpy.SamplingSphere( points[..., 0] * radius, points[..., 1] * radius, points[..., 2] * radius, n_max=n_max) return sampling
[docs] def dodecahedron(radius=1.): """ Generate a sampling based on the center points of the twelve dodecahedron faces. Parameters ---------- radius : number, optional Radius of the sampling grid. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions. Sampling weights can be obtained from :py:func:`calculate_sampling_weights`. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.dodecahedron() >>> spharpy.plot.scatter(coords) """ dihedral = 2 * np.arcsin(np.cos(np.pi / 3) / np.sin(np.pi / 5)) R = np.tan(np.pi / 3) * np.tan(dihedral / 2) rho = np.cos(np.pi / 5) / np.sin(np.pi / 10) theta1 = np.arccos((np.cos(np.pi / 5) / np.sin(np.pi / 5)) / np.tan(np.pi / 3)) a2 = 2 * np.arccos(rho / R) theta2 = theta1 + a2 theta3 = np.pi - theta2 theta4 = np.pi - theta1 phi1 = 0 phi2 = 2 * np.pi / 3 phi3 = 4 * np.pi / 3 theta = np.concatenate(( np.tile(theta1, 3), np.tile(theta2, 3), np.tile(theta3, 3), np.tile(theta4, 3))) phi = np.tile(np.array([ phi1, phi2, phi3, phi1 + np.pi / 3, phi2 + np.pi / 3, phi3 + np.pi / 3]), 2) rad = radius * np.ones(np.size(theta)) return spharpy.SamplingSphere.from_spherical_colatitude( phi, theta, rad)
[docs] def icosahedron(radius=1.): """ Generate a sampling from the center points of the twenty icosahedron faces. Parameters ---------- radius : number, optional Radius of the sampling grid. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions. Sampling weights can be obtained from :py:func:`calculate_sampling_weights`. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.icosahedron() >>> spharpy.plot.scatter(coords) """ gamma_R_r = np.arccos(np.cos(np.pi/3) / np.sin(np.pi/5)) gamma_R_rho = np.arccos(1/(np.tan(np.pi/5) * np.tan(np.pi/3))) theta = np.tile(np.array([np.pi - gamma_R_rho, np.pi - gamma_R_rho - 2 * gamma_R_r, 2 * gamma_R_r + gamma_R_rho, gamma_R_rho]), 5) theta = np.sort(theta) phi = np.arange(0, 2 * np.pi, 2 * np.pi / 5) phi = np.concatenate((np.tile(phi, 2), np.tile(phi + np.pi / 5, 2))) return spharpy.SamplingSphere.from_spherical_colatitude( phi, theta, radius)
[docs] def equiangular(n_max=None, n_points=None, radius=1.): r""" Generate an equiangular sampling of the sphere. For detailed information, see [#]_, Chapter 3.2. This is a quadrature sampling with the sum of the sampling weights in `sampling.weights` being :math:`4\pi` if the number of rings is even and the number of points in azimuth and elevation are equal. This condition is always fulfilled if the number of points is chosen through ``n_max``. This sampling does not contain points at the North and South Pole and is typically used for spherical harmonics processing. See :py:func:`equal_angle` and :py:func:`great_circle` for samplings containing points at the poles. Parameters ---------- n_max : int Maximum applicable spherical harmonic order. If this is provided, 'n_points' is set to ``2 * n_max + 1``. Either `n_points` or `n_max` must be provided. The default is ``None``. n_points : int, tuple of two ints Number of sampling points in azimuth and elevation. Either `n_points` or `n_max` must be provided. The default is ``None``. radius : number, optional Radius of the sampling grid. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions including sampling weights. References ---------- .. [#] B. Rafaely, Fundamentals of spherical array processing, 1st ed. Berlin, Heidelberg, Germany: Springer, 2015. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.equiangular(n_max=3) >>> spharpy.plot.scatter(coords) """ if (n_points is None) and (n_max is None): raise ValueError( "Either the n_points or n_max needs to be specified.") # get number of points from required spherical harmonic order # ([#], equation 3.4) if n_max is not None: n_points = 2 * (int(n_max) + 1) # get the angles n_points = np.asarray(n_points) if n_points.size == 2: n_phi = n_points[0] n_theta = n_points[1] else: n_phi = n_points n_theta = n_points theta_angles = np.arange(np.pi / (n_theta * 2), np.pi, np.pi / n_theta) phi_angles = np.arange(0, 2 * np.pi, 2 * np.pi / n_phi) # construct the sampling grid theta, phi = np.meshgrid(theta_angles, phi_angles) rad = radius * np.ones(theta.size) # compute maximum applicable spherical harmonic order if n_max is None: n_max = int(np.min([n_phi / 2 - 1, n_theta / 2 - 1])) else: n_max = int(n_max) # compute sampling weights ([1], equation 3.11) # Note that this is only valid if the number of points in ring is even # and the number of points in azimuth and elevation are equal if (n_theta != n_phi) or np.any(np.mod(n_points, 2) > 0): weights = None else: q = 2 * np.arange(0, n_max + 1) + 1 weights_theta = np.sin(theta_angles) * ( 1/q @ np.sin(q[np.newaxis].T @ theta_angles[np.newaxis])) weights = np.tile(weights_theta*2*np.pi / (n_max+1)**2, n_phi) # make Coordinates object sampling = spharpy.SamplingSphere.from_spherical_colatitude( phi.reshape(-1), theta.reshape(-1), rad, weights=weights, n_max=n_max) return sampling
[docs] def gaussian(n_max, radius=1.): r""" Generate sampling of the sphere based on the Gaussian quadrature. For detailed information, see [#]_ (Section 3.3). This is a quadrature sampling with the sum of the sampling weights in `sampling.weights` being :math:`4\pi`. This sampling does not contain points at the North and South Pole and is typically used for spherical harmonics processing. See :py:func:`equal_angle` and :py:func:`great_circle` for samplings containing points at the poles. Parameters ---------- n_max : int Maximum applicable spherical harmonic order. Results in ``2 * (n_max + 1)`` in azimuth and ``n_max + 1`` points in elevation. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions including sampling weights. References ---------- .. [#] B. Rafaely, Fundamentals of spherical array processing, 1st ed. Berlin, Heidelberg, Germany: Springer, 2015. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.gaussian(n_max=3) >>> spharpy.plot.scatter(coords) """ # get number of points from required spherical harmonic order # ([1], chapter 3.3) n_max = int(n_max) n_phi = (n_max+1)*2 # construct the sampling grid legendre, weights = np.polynomial.legendre.leggauss(n_max+1) theta_angles = np.arccos(legendre) phi_angles = np.linspace(0, 2 * np.pi - (2 * np.pi / n_phi), n_phi) theta, phi = np.meshgrid(theta_angles, phi_angles) # compute the sampling weights weights = np.tile(weights*np.pi/(n_max+1), 2*(n_max+1)) # make Coordinates object sampling = spharpy.SamplingSphere.from_spherical_colatitude( phi.reshape(-1), theta.reshape(-1), radius, weights=weights, n_max=n_max) return sampling
[docs] def eigenmike_em32(): """Microphone positions of the Eigenmike em32 by mhacoustics. The data are according to the Eigenstudio user manual on the homepage [#]_. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` SamplingSphere object containing all sampling points References ---------- .. [#] Eigenstudio User Manual, https://eigenmike.com/sites/default/files/documentation-2023-10/EigenStudio%20User%20Manual%20R02D.pdf Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.eigenmike_em32() >>> spharpy.plot.scatter(coords) """ # noqa: E501 rad = np.ones(32) theta = np.array([69.0, 90.0, 111.0, 90.0, 32.0, 55.0, 90.0, 125.0, 148.0, 125.0, 90.0, 55.0, 21.0, 58.0, 121.0, 159.0, 69.0, 90.0, 111.0, 90.0, 32.0, 55.0, 90.0, 125.0, 148.0, 125.0, 90.0, 55.0, 21.0, 58.0, 122.0, 159.0]) * np.pi / 180 phi = np.array([0.0, 32.0, 0.0, 328.0, 0.0, 45.0, 69.0, 45.0, 0, 315.0, 291.0, 315.0, 91.0, 90.0, 90.0, 89.0, 180.0, 212.0, 180.0, 148.0, 180.0, 225.0, 249.0, 225.0, 180.0, 135.0, 111.0, 135.0, 269.0, 270.0, 270.0, 271.0]) * np.pi / 180 return spharpy.SamplingSphere.from_spherical_colatitude(phi, theta, rad)
[docs] def eigenmike_em64(): """Microphone positions of the Eigenmike em64 by mhacoustics. The data are according to the Eigenmike user manual on the homepage [#]_. Returns ------- sampling : SamplingSphere SamplingSphere object containing all sampling points References ---------- .. [#] Eigenmike em64 Getting Started Guide, https://eigenmike.com/sites/default/files/documentation-2024-09/getting%20started%20Guide%20to%20em64%20and%20ES3%20R01H.pdf Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.eigenmike_em64() >>> spharpy.plot.scatter(coords) """ # noqa: E501 rad = np.ones(64)*0.042 theta = np.deg2rad(np.array([ 16.7656, 21.9677, 42.3941, 13.2817, 22.6728, 52.6925, 37.806, 43.3944, 43.9386, 70.3132, 33.2231, 60.0257, 56.4763, 67.4936, 93.2735, 48.423, 78.0793, 62.0685, 38.7171, 63.8004, 70.1946, 96.246, 81.0992, 106.094, 67.7533, 91.7061, 39.9985, 68.7726, 60.8869, 82.2833, 63.0247, 89.794, 137.5166, 139.7604, 135.2133, 160.3628, 162.577, 142.0685, 161.1987, 162.577, 115.536, 86.2594, 116.0164, 95.3313, 90.0637, 111.4549, 85.8671, 130.8398, 102.5775, 142.6375, 117.032, 117.5631, 115.8884, 89.69, 118.4478, 93.9338, 106.3875, 81.0511, 135.9764, 142.6771, 120.6556, 133.8834, 116.3591, 107.464, ])) phi = np.deg2rad(np.array([ 197.4561, 115.734, 81.911, 313.3592, 43.1785, 46.7324, 335.9958, 14.5398, 204.4547, 206.542, 247.3219, 233.817, 264.5437, 99.6669, 104.6842, 120.9227, 126.513, 148.2368, 162.6381, 178.5498, 21.2715, 25.7834, 47.8607, 55.9075, 71.4285, 78.4921, 293.221, 290.5683, 318.1354, 334.0042, 352.0227, 0, 174.0335, 212.7205, 251.9179, 150.6471, 240.8266, 293.0625, 331.0098, 60.8266, 226.9135, 233.9255, 193.6382, 209.6696, 183.169, 163.7105, 156.9524, 139.4318, 135.9729, 102.3273, 112.5511, 83.1464, 307.7078, 309.1392, 278.2519, 282.9735, 253.147, 260.0688, 59.7394, 14.2241, 32.4901, 334.0753, 2.0842, 335.0677, ])) weights = np.array([ 0.954, 0.9738, 1.0029, 1.0426, 1.0426, 1.0024, 0.9738, 0.954, 1.009, 0.9932, 1.0024, 1.0324, 0.954, 1.0024, 1.0079, 1.0268, 1.0151, 0.9463, 1.012, 1.0253, 1.009, 0.9932, 1.0324, 1.0151, 0.954, 1.0079, 1.0029, 1.0024, 1.0268, 0.9463, 1.012, 1.0253, 0.954, 0.9738, 1.0029, 1.0426, 1.0426, 1.0024, 0.954, 0.9738, 1.0268, 1.0151, 1.012, 0.9463, 1.0253, 1.009, 0.9932, 1.0024, 1.0324, 1.0029, 0.954, 1.0024, 1.0324, 1.0151, 0.954, 1.0079, 1.0024, 1.0079, 1.0268, 1.012, 0.9463, 1.009, 1.0253, 0.9932, ]) # weights from the Eigenmike em64 user manual are not sufficiently # precise, so we need to re-normalize here weights = weights / np.sum(weights) * 4 * np.pi sampling = spharpy.SamplingSphere.from_spherical_colatitude( phi, theta, rad, n_max=6) sampling.weights = weights return sampling
[docs] def icosahedron_ke4(): """Microphone positions of IHTA's KE4 spherical microphone array. The microphone marked as "1" defines the positive x-axis. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` SamplingSphere object containing all sampling points Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.icosahedron_ke4() >>> spharpy.plot.scatter(coords) """ theta = np.array([1.565269801525254, 2.294997457752220, 1.226592351686568, 1.226592351686568, 1.696605844149543, 2.409088979442091, 2.409088979442090, 1.696605844149543, 0.506378251408914, 0.506378251408914, 2.635214402180879, 1.444986809440250, 0.732503674147703, 0.732503674147703, 1.444986809440250, 2.635214402180879, 1.915000301903226, 0.846595195837573, 1.915000301903225, 1.576322852064539]) phi = np.array([0.000000000000000, 6.283185307179586, 0.660263824203969, 5.622921482975618, 5.055791576545843, 5.241315660913181, 1.041869646266405, 1.227393730633743, 0.826693168093822, 5.456492139085764, 3.968285821683615, 4.368986384223536, 4.183462299856198, 2.099723007323388, 1.914198922956050, 2.314899485495971, 2.481328829385824, 3.141592653589793, 3.801856477793762, 3.141592653589793]) rad = np.ones(20) * 0.065 return spharpy.SamplingSphere.from_spherical_colatitude( phi, theta, rad)
[docs] def equal_area(n_max, condition_num=2.5, n_points=None): """Sampling based on partitioning into faces with equal area. The implementation is based on [#]_ and Leopardi's MATLAB implementation. Parameters ---------- n_max : int Spherical harmonic order condition_num : double Desired maximum condition number of the spherical harmonic basis matrix. Default is ``2.5``. n_points : int, optional Number of points to start the condition number optimization. If set to ``None`` the value ``(n_max+1)**2`` will be used as start. Default is ``None``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` SamplingSphere object containing all sampling points References ---------- .. [#] P. Leopardi, “A partition of the unit sphere into regions of equal area and small diameter,” Electronic Transactions on Numerical Analysis, vol. 25, no. 12, pp. 309-327, 2006. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.equal_area(n_max=3) >>> spharpy.plot.scatter(coords) """ if not n_points: n_points = (n_max+1)**2 while True: point_data = eq_point_set(2, n_points) sampling = spharpy.SamplingSphere( point_data[0], point_data[1], point_data[2]) if condition_num == np.inf: break Y = spharpy.spherical.spherical_harmonic_basis(n_max, sampling) cond = np.linalg.cond(Y) if cond < condition_num: break n_points += 1 sampling.n_max = n_max return sampling
[docs] def spiral_points(n_max, condition_num=2.5, n_points=None): """Sampling based on a spiral distribution of points on a sphere. The implementation is based on [#]_. Parameters ---------- n_max : int Spherical harmonic order condition_num : double Desired maximum condition number of the spherical harmonic basis matrix. Default is ``2.5``. n_points : int, optional Number of points to start the condition number optimization. If set to ``None`` the value ``(n_max+1)**2`` will be used as start. Default is ``None``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` SamplingSphere object containing all sampling points References ---------- .. [#] E. a. Rakhmanov, E. B. Saff, and Y. M. Zhou, “Minimal Discrete Energy on the Sphere,” Mathematical Research Letters, vol. 1, no. 6, pp. 647-662, 1994. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.spiral_points(n_max=3) >>> spharpy.plot.scatter(coords) """ if n_points is None: n_points = (n_max+1)**2 def _spiral_points(n_points): """Helper function doing the actual calculation of the points.""" r = np.zeros(n_points) h = np.zeros(n_points) theta = np.zeros(n_points) phi = np.zeros(n_points) p = 1/2 a = 1 - 2*p/(n_points-3) b = p*(n_points+1)/(n_points-3) r[0] = 0 theta[0] = np.pi phi[0] = 0 # Then for k stepping by 1 from 2 to n-1: for k in range(1, n_points-1): kStrich = a*k + b h[k] = -1 + 2*(kStrich-1)/(n_points-1) r[k] = np.sqrt(1-h[k]**2) theta[k] = np.arccos(h[k]) phi[k] = np.mod( (phi[k-1]) + 3.6/np.sqrt(n_points)*2/(r[k-1]+r[k]), 2*np.pi) # Finally: theta[n_points-1] = 0 phi[n_points-1] = 0 return theta, phi while True: theta, phi = _spiral_points(n_points) sampling = spharpy.SamplingSphere.from_spherical_colatitude( phi, theta, np.ones(n_points)) if condition_num == np.inf: break Y = spharpy.spherical.spherical_harmonic_basis(n_max, sampling) cond = np.linalg.cond(Y) if cond < condition_num: break n_points += 1 sampling.n_max = n_max return sampling
[docs] def equal_angle(delta_angles, radius=1.): """ Generate sampling of the sphere with equally spaced angles. This sampling contain points at the North and South Pole. See :py:func:`equiangular`, :py:func:`gaussian`, and :py:func:`great_circle` for samplings that do not contain points at the poles. Parameters ---------- delta_angles : tuple, number Tuple that gives the angular spacing in azimuth and colatitude in degrees. If a number is provided, the same spacing is applied in both dimensions. radius : number, optional Radius of the sampling grid. The default is ``1``. Returns ------- sampling : :py:class:`pyfar.Coordinates` Sampling positions. Sampling weights can be obtained from :py:func:`calculate_sampling_weights`. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.equal_angle(delta_angles=45) >>> spharpy.plot.scatter(coords) """ # get the angles delta_angles = np.asarray(delta_angles) if delta_angles.size == 2: delta_phi = delta_angles[0] delta_theta = delta_angles[1] else: delta_phi = delta_angles delta_theta = delta_angles # check if the angles can be distributed eps = np.finfo('float').eps if not (np.abs(360 % delta_phi) < 2*eps): raise ValueError("delta_phi must be an integer divisor of 360") if not (np.abs(180 % delta_theta) < 2*eps): raise ValueError("delta_theta must be an integer divisor of 180") # get the angles phi_angles = np.arange(0, 360, delta_phi) theta_angles = np.arange(delta_theta, 180, delta_theta) # stack the angles phi = np.tile(phi_angles, theta_angles.size) theta = np.repeat(theta_angles, phi_angles.size) # add North and South Pole phi = np.concatenate(([0], phi, [0])) theta = np.concatenate(([0], theta, [180])) # make Coordinates object sampling = spharpy.SamplingSphere.from_spherical_colatitude( phi/180*np.pi, theta/180*np.pi, radius, comment='equal angle spherical sampling grid') return sampling
[docs] def great_circle( elevation=np.linspace(-90, 90, 19), gcd=10, radius=1, azimuth_res=1, match=360): r""" Spherical sampling grid according to the great circle distance criterion. Sampling grid where neighboring points of the same elevation have approx. the same great circle distance across elevations [#]_. Parameters ---------- elevation : array like, optional Contains the elevation from wich the sampling grid is generated, with :math:`-90^\circ\leq elevation \leq 90^\circ` (:math:`90^\circ`: North Pole, :math:`-90^\circ`: South Pole). The default is ``np.linspace(-90, 90, 19)``. gcd : number, optional Desired great circle distance (GCD). Note that the actual GCD of the sampling grid is equal or smaller then the desired GCD and that the GCD may vary across elevations. The default is ``10``. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. azimuth_res : number, optional Minimum resolution of the azimuth angle in degree. The default is ``1``. match : number, optional Forces azimuth entries to appear with a period of match degrees. E.g., if ``match=90``, the grid includes the azimuth angles 0, 90, 180, and 270 degrees. The default is ``360``. Returns ------- sampling : :py:class:`pyfar.Coordinates` Sampling positions. Sampling weights can be obtained from :py:func:`calculate_sampling_weights`. References ---------- .. [#] B. P. Bovbjerg, F. Christensen, P. Minnaar, and X. Chen, “Measuring the head-related transfer functions of an artificial head with a high directional resolution,” 109th AES Convention, Los Angeles, USA, Sep. 2000. Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.great_circle() >>> spharpy.plot.scatter(coords) """ # check input assert 1 / azimuth_res % 1 == 0, "1/azimuth_res must be an integer." assert not 360 % match, "360/match must be an integer." assert match / azimuth_res % 1 == 0, "match/azimuth_res must be an \ integer." elevation = np.atleast_1d(np.asarray(elevation)) # calculate delta azimuth to meet the desired great circle distance. # (according to Bovbjerg et al. 2000: Measuring the head related transfer # functions of an artificial head with a high directional azimuth_res) d_az = 2 * np.arcsin(np.clip( np.sin(gcd / 360 * np.pi) / np.cos(elevation / 180 * np.pi), -1, 1)) d_az = d_az / np.pi * 180 # correct values at the poles d_az[np.abs(elevation) == 90] = 360 # next smallest value in desired angular azimuth_res d_az = d_az // azimuth_res * azimuth_res # adjust phi to make sure that: match // d_az == 0 for nn in range(d_az.size): if abs(elevation[nn]) != 90: while match % d_az[nn] > 1e-15: # round to precision of azimuth_res to avoid numerical errors d_az[nn] = np.round((d_az[nn] - azimuth_res)/azimuth_res) \ * azimuth_res # construct the full sampling grid azim = np.empty(0) elev = np.empty(0) for nn in range(elevation.size): azim = np.append(azim, np.arange(0, 360, d_az[nn])) elev = np.append(elev, np.full(int(360 / d_az[nn]), elevation[nn])) # round to precision of azimuth_res to avoid numerical errors azim = np.round(azim/azimuth_res) * azimuth_res # make Coordinates object sampling = spharpy.SamplingSphere.from_spherical_elevation( azim/180*np.pi, elev/180*np.pi, radius, comment='spherical great circle sampling grid') return sampling
[docs] def lebedev(n_max=None, radius=1.): r""" Return Lebedev spherical sampling grid. For the Lebedev grid [#]_, the number of points :math:`Q` can be approximated by the spherical harmonic order :math:`N` (parameter `n_max`) :math:`Q \approx 1.3 \, (N+1)^2` For a list of available orders `n_max` and the exact number of points call :py:func:`lebedev` without any arguments. This will print available orders and number of points to the console. .. note:: This is intended to be a quadrature sampling with positive sampling weights summing to :math:`4\pi`. For unknown reasons, this is not the case in the original sources [#]_ and the Matlab code [#]_ on which this Python version is based for orders 6, 12, and 13. For this reason, the respective weights are not returned regardless of the order. Parameters ---------- n_max : int, optional Maximum applicable spherical harmonic order between 1 and 65. Because not all orders are available, the default ``None`` prints possible orders to the console. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` Sampling positions without sampling weights (see note above). References ---------- .. [#] V.I. Lebedev, and D.N. Laikov "A quadrature formula for the sphere of the 131st algebraic order of accuracy" Doklady Mathematics, Vol. 59, No. 3, 1999, pp. 477-481. .. [#] https://people.sc.fsu.edu/~jburkardt/datasets/sphere_lebedev_rule/sphere_lebedev_rule.html .. [#] https://de.mathworks.com/matlabcentral/fileexchange/27097-getlebedevsphere Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.lebedev(n_max=3) >>> spharpy.plot.scatter(coords) """ # noqa: E501 # possible degrees degrees = np.array([6, 14, 26, 38, 50, 74, 86, 110, 146, 170, 194, 230, 266, 302, 350, 434, 590, 770, 974, 1202, 1454, 1730, 2030, 2354, 2702, 3074, 3470, 3890, 4334, 4802, 5294, 5810], dtype=int) # corresponding spherical harmonic orders orders = np.array((np.floor(np.sqrt(degrees / 1.3) - 1)), dtype=int) # list possible sh orders and degrees if n_max is None: print('Possible input values:') for o, d in zip(orders, degrees, strict=True): print(f"SH order {o}, number of points {d}") return None # check if the order is available if n_max not in orders: str_orders = [f"{o}" for o in orders] raise ValueError("Invalid spherical harmonic order 'n_max'. \ Valid orders are: {}.".format(', '.join(str_orders))) # get the samlpling leb = lebedev_sphere(degrees[n_max == orders]) # generate Coordinates object sampling = spharpy.SamplingSphere( leb["x"] * radius, leb["y"] * radius, leb["z"] * radius, n_max=n_max, comment='spherical Lebedev sampling grid') return sampling
[docs] def fliege(n_max, radius=1.): r""" Return Fliege-Maier spherical sampling grid. For detailed information, see [#]_. .. note:: This is intended to be a quadrature sampling with positive sampling weights summing to :math:`4\pi`. For unknown reasons, this is not the case for orders 10, 12, 18, 20, 24, 26, 27, and 29. For this reason, the respective weights are not returned regardless of the order. Parameters ---------- n_max : int Maximum applicable spherical harmonic order. It must be between 1 and 29. radius : number, optional Radius of the sampling grid in meters. The default is ``1``. Returns ------- sampling : :py:class:`spharpy.SamplingSphere` ``(n_max + 1)**2`` sampling positions without sampling weights (see note above). Notes ----- This implementation uses pre-calculated points from the SOFiA toolbox [#]_. References ---------- .. [#] J. Fliege and U. Maier, "The distribution of points on the sphere and corresponding cubature formulae,” IMA J. Numerical Analysis, Vol. 19, pp. 317-334, Apr. 1999, doi: 10.1093/imanum/19.2.317. .. [#] https://audiogroup.web.th-koeln.de/SOFiA_wiki/DOWNLOAD.html Examples -------- .. plot:: >>> import spharpy >>> coords = spharpy.samplings.fliege(n_max=3) >>> spharpy.plot.scatter(coords) """ if not isinstance(n_max, int): raise ValueError("n_max must be an integer.") if n_max < 1 or n_max > 29: raise ValueError("n_max must be between 1 and 29.") if not isinstance(radius, (int, float)) or radius <= 0: raise ValueError("radius must be a positive number.") n_points = (n_max + 1)**2 # get the sampling points fliege = sio.loadmat(os.path.join( os.path.dirname(__file__), "_eqsp", "samplings_fliege.mat"), variable_names=f"Fliege_{int(n_points)}") fliege = fliege[f"Fliege_{int(n_points)}"] # generate Coordinates object sampling = spharpy.SamplingSphere.from_spherical_colatitude( fliege[:, 0], fliege[:, 1], radius, n_max=n_max) # switch and invert Coordinates in Cartesian representation to be # consistent with [1] sampling.z = -sampling.z return sampling
def _sph_t_design_load_data(degrees='all'): """Download t-design sampling grids. Note: Samplings are downloaded form https://web.maths.unsw.edu.au/~rsw/Sphere/EffSphDes/sf.html Parameters ---------- degrees : str or list of int, optional list of int load sampling of specified degree, `all` load all samplings up to degree 180, by default 'all'. """ # set the degrees to be read if isinstance(degrees, int): degrees = [degrees] elif isinstance(degrees, str): degrees = range(1, 181) prefix = 'samplings_t_design_' n_points_exceptions = {3: 8, 5: 18, 7: 32, 9: 50, 11: 72, 13: 98, 15: 128} entries = [] for degree in degrees: # number of sampling points n_points = int(np.ceil((degree + 1)**2 / 2) + 1) if degree in n_points_exceptions: n_points = n_points_exceptions[degree] # load the data filename = "sf%03d.%05d" % (degree, n_points) url = "http://web.maths.unsw.edu.au/~rsw/Sphere/Points/SF/"\ "SF29-Nov-2012/" fileurl = url + filename path_save = os.path.join( os.path.dirname(__file__), "_eqsp", prefix + filename) entries.append((path_save, fileurl)) pool = ThreadPool(50) pool.imap_unordered(_fetch_url, entries) pool.close() pool.join() def _sph_extremal_load_data(orders='all'): """Download extremal sampling grids. Note: Samplings are downloaded form https://web.maths.unsw.edu.au/~rsw/Sphere/MaxDet/MaxDet1.html Parameters ---------- orders : str or list of int, optional list of int load sampling of specified orders, `all` load all samplings up to orders 200, by default 'all'. """ # set the SH orders to be read if isinstance(orders, int): orders = [orders] elif isinstance(orders, str): orders = range(1, 201) prefix = 'samplings_extremal_' entries = [] for n_max in orders: # number of sampling points n_points = (n_max + 1)**2 # load the data filename = "md%03d.%05d" % (n_max, n_points) url = "https://web.maths.unsw.edu.au/~rsw/Sphere/S2Pts/MD/" fileurl = url + filename path_save = os.path.join( os.path.dirname(__file__), "_eqsp", prefix + filename) entries.append((path_save, fileurl)) # download on parallel pool = ThreadPool(50) pool.imap_unordered(_fetch_url, entries) pool.close() pool.join() def _fetch_url(entry): path, uri = entry if not os.path.exists(path): # Kontrolle ist gut, Vertrauen ist besser with warnings.catch_warnings(): warnings.simplefilter("ignore", InsecureRequestWarning) r = requests.get(uri, stream=True, verify=False) if r.status_code == 200: with open(path, 'wb') as f: for chunk in r: f.write(chunk) return path