Repository: pyfar/pyfar
Pull request #728 (2024-11-29 10:07:45)

Enhance head-related impulse responses
### Changes proposed in this pull request:

Technically not a bug, because it was mentioned in the docs - but unessesarily complicated.

- Make `pyfar.signals.files.head_related_impulse_responses` work with array likes and pyfar Coordinate objects


Patch:
diff --git a/pyfar/signals/files.py b/pyfar/signals/files.py
index 389161e9..d3f5b307 100644
--- a/pyfar/signals/files.py
+++ b/pyfar/signals/files.py
@@ -348,11 +348,14 @@ def head_related_impulse_responses(
             degrees.
         ``'median'``
             Return median plane HRIRs with an angular resolution of 2 degrees.
-        List of coordinates
-            Return HRIRs at specific positions defined by a list of azimuth
-            and elevation values in degrees. For example
+        Array like
+            Return HRIRs at azimuth and elevation in degrees. For example
             ``[[30, 0], [330, 0]]`` returns HRIRs on the horizontal plane
             (0 degree elevation) for azimuth angles of 30 and 330 degrees.
+        :py:class:`~pyfar.Coordinates`
+            Return HRIRs at positions defined by a pyfar Coordinates object.
+            Note that the HRIRs were measured at a fixed radius of 1.7 m.
+            Hence, the radius in `position` will be ignored.
 
         The default is ``[[0, 0]]``, which returns the HRIR for frontal sound
         incidence. A ValueError is raised if the requested position is not
@@ -389,33 +392,43 @@ def head_related_impulse_responses(
     tolerance_rad = 0.1 / 180 * np.pi
 
     # get indices of source positions
-    if position == "horizontal":
+    if isinstance(position, str) and position == "horizontal":
         idx = sources.elevation == 0
-    elif position == "median":
+    elif isinstance(position, str) and position == "median":
         idx = sources.lateral == 0
         idx = np.where(idx)[0]
         # sort positions according to polar angle
         polar = sources.polar[idx].flatten()
         idx = (idx[np.argsort(polar)], )
     else:
-        idx = []
-        for pos in position:
-            find = pf.Coordinates.from_spherical_elevation(
-                pos[0] / 180 * np.pi, pos[1] / 180 * np.pi, 1.7)
-            idx_current, distance = sources.find_nearest(
-                find, distance_measure='spherical_radians')
-            if distance < tolerance_rad:
-                idx.append(idx_current[0])
-            else:
-                raise ValueError((
-                    f"HRIR for azimuth={pos[0]} and elevation={pos[1]} degrees"
-                    " is not available. See documentation for more "
-                    "information."))
+
+        if isinstance(position, pf.Coordinates):
+            # force radius to be equal to those of HRIRs
+            position.radius = 1.7
+        else:
+            # convert to pyfar coordinates for convenience
+            position = np.atleast_2d(position) / 180 * np.pi
+            position = pf.Coordinates.from_spherical_elevation(
+                position[..., 0], position[..., 1], 1.7)
+
+        # find requested HRIRs
+        idx, distance = sources.find_nearest(
+            position, distance_measure='spherical_radians')
+
+        if np.any(distance > tolerance_rad):
+            raise ValueError((
+                "HRIRs for one or more requested source positions are not "
+                "available. See documentation for available positions."))
 
     # select data for desired source positions
     hrirs.time = hrirs.time[idx]
     sources = sources[idx]
 
+    # force cdim to be independent of number of requested source positions
+    # must be done due to indexing and numpy broadcasting above
+    if hrirs.cdim == 1:
+        hrirs.time = hrirs.time[None, ...]
+
     # diffuse field compensation
     if diffuse_field_compensation:
         inverse_ctf, *_ = pf.io.read_sofa(os.path.join(file_dir, files[1]))


Test patch:
diff --git a/tests/test_signals_files.py b/tests/test_signals_files.py
index 469f394f..3f33d88a 100644
--- a/tests/test_signals_files.py
+++ b/tests/test_signals_files.py
@@ -72,6 +72,29 @@ def test_hrirs_position(position, convention, first, second):
     npt.assert_allclose(second, sg[..., 1].flatten() / np.pi * 180, atol=1e-12)
 
 
+def test_hrirs_position_type():
+    """Test different types of the position argument."""
+
+    # position as list
+    position_list = [[10, 0], [20, 0]]
+    hrirs_list, _ = pf.signals.files.head_related_impulse_responses(
+        position_list)
+
+    # position as array
+    position_array = np.atleast_2d(position_list)
+    hrirs_array, _ = pf.signals.files.head_related_impulse_responses(
+        position_array)
+
+    # position as pyfar coordinates
+    position_coordinates = pf.Coordinates.from_spherical_elevation(
+        np.array([10, 20]) / 180 * np.pi, 0, 1)
+    hrirs_coordinates, _ = pf.signals.files.head_related_impulse_responses(
+        position_coordinates)
+
+    assert hrirs_list == hrirs_array
+    assert hrirs_list == hrirs_coordinates
+
+
 def test_hrirs_diffuse_field_compensation():
     """Test diffuse field compensation for HRIRs."""
     hrirs, _ = pf.signals.files.head_related_impulse_responses(
@@ -98,5 +121,5 @@ def test_hrirs_sampling_rate(sampling_rate):
 
 def test_hrirs_assertions():
     """Test assertions for getting HRIRs."""
-    with pytest.raises(ValueError, match="HRIR for azimuth=1"):
+    with pytest.raises(ValueError, match="HRIRs for one or more"):
         pf.signals.files.head_related_impulse_responses([[1, 0]])