Skip to content

Commit 050dd1e

Browse files
authored
Refactor the parametric energy decay curve function (#74)
Changes to the function - uses reverbation time as input instead of geometry - returns pf.TimeData object - independent of sabine/eyring equations We should also remove support for Python < 3.11 in a different PR
1 parent e9602e7 commit 050dd1e

File tree

2 files changed

+142
-112
lines changed

2 files changed

+142
-112
lines changed

pyrato/parametric.py

Lines changed: 73 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,82 +5,95 @@
55
"""
66
import numpy as np
77
from typing import Union
8+
import pyfar as pf
89

910

10-
def energy_decay_curve_analytic(
11-
surfaces, alphas, volume, times, source=None,
12-
receiver=None, method='eyring', c=343.4, frequency=None,
13-
air_absorption=True):
14-
"""Calculate the energy decay curve analytically by using Eyring's or
15-
Sabine's equation.
11+
def energy_decay_curve(
12+
times : np.ndarray,
13+
reverberation_time : float | np.ndarray,
14+
energy : float | np.ndarray = 1,
15+
) -> pf.TimeData:
16+
r"""Calculate the energy decay curve for the reverberation time and energy.
1617
17-
Calculation according to [#]_.
18+
The energy decay curve is calculated as
19+
20+
.. math::
21+
E(t) = E_0 e^{-\frac{6 \ln(10)}{T_{60}} t}
22+
23+
where :math:`E_0` is the initial energy, :math:`T_{60}` the reverberation
24+
time, and :math:`t` the time [#]_.
1825
1926
Parameters
2027
----------
21-
surfaces : ndarray, double
22-
Surface areas of all surfaces in the room
23-
alphas : ndarray, double
24-
Absorption coefficients corresponding to each surface
25-
volume : double
26-
Room volume
27-
times : ndarray, double
28-
Time vector for which the decay curve is calculated
29-
source : Coordinates
30-
Coordinate object with the source coordinates
31-
receiver : Coordinates
32-
Coordinate object with the receiver coordinates
33-
method : 'eyring', 'sabine'
34-
Use either Eyring's or Sabine's equation
35-
c : double
36-
Speed of sound
37-
frequency : double, optional
38-
Center frequency of the respective octave band. This is only used for
39-
the air absorption calculation.
40-
air_absorption : bool, optional
41-
If True, the air absorption is included in the calculation.
42-
Default is True.
28+
times : numpy.ndarray[float]
29+
The times at which the energy decay curve is evaluated.
30+
reverberation_time : float | numpy.ndarray[float]
31+
The reverberation time in seconds. If an array is passed, an energy
32+
decay curve is calculated for each reverberation time.
33+
energy : float | numpy.ndarray[float], optional
34+
The initial energy of the sound field, by default 1. If
35+
`reverberation_time` is an array, the shape of `energy` is required
36+
to match the shape or be broadcastable to the shape of
37+
`reverberation_time`.
4338
4439
Returns
4540
-------
46-
energy_decay_curve : ndarray, double
47-
The energy decay curve
41+
energy_decay_curve : pyfar.TimeData
42+
The energy decay curve with a ``cshape`` equal to the shape of
43+
the passed ``reverberation_time``.
44+
45+
Example
46+
-------
47+
Calculate and plot an energy decay curve with a reverberation time of
48+
2 seconds.
49+
50+
.. plot::
51+
52+
>>> import numpy as np
53+
>>> import pyrato
54+
>>> import pyfar as pf
55+
>>>
56+
>>> times = np.linspace(0, 3, 50)
57+
>>> T_60 = [2, 1]
58+
>>> edc = pyrato.parametric.energy_decay_curve(times, T_60)
59+
>>> pf.plot.time(edc, log_prefix=10, dB=True)
60+
4861
4962
References
5063
----------
5164
.. [#] H. Kuttruff, Room acoustics, 4th Ed. Taylor & Francis, 2009.
5265
5366
"""
67+
reverberation_time = np.asarray(reverberation_time)
68+
energy = np.asarray(energy)
69+
times = np.asarray(times)
70+
71+
if np.any(reverberation_time <= 0):
72+
raise ValueError("Reverberation time must be greater than zero.")
73+
74+
if np.any(energy < 0):
75+
raise ValueError("Energy must be greater than or equal to zero.")
76+
77+
if reverberation_time.shape != energy.shape:
78+
try:
79+
energy = np.broadcast_to(energy, reverberation_time.shape)
80+
except ValueError as error:
81+
raise ValueError(
82+
"Reverberation time and energy must be broadcastable to the "
83+
"same shape.",
84+
) from error
85+
86+
matching_shape = reverberation_time.shape
87+
reverberation_time = reverberation_time.flatten()
88+
energy = energy.flatten()
89+
90+
reverberation_time = np.atleast_2d(reverberation_time)
91+
energy = np.atleast_2d(energy)
92+
93+
damping_term = (3*np.log(10) / reverberation_time).T
94+
edc = energy.T * np.exp(-2*damping_term*times)
5495

55-
alphas = np.asarray(alphas)
56-
surfaces = np.asarray(surfaces)
57-
surface_room = np.sum(surfaces)
58-
alpha_mean = np.sum(surfaces*alphas) / surface_room
59-
60-
if air_absorption:
61-
m = air_attenuation_coefficient(frequency)
62-
else:
63-
m = 0
64-
65-
if all([source, receiver]):
66-
dist_source_receiver = np.linalg.norm(
67-
source.cartesian - receiver.cartesian)
68-
delay_direct = dist_source_receiver / c
69-
else:
70-
delay_direct = 0
71-
72-
if method == 'eyring':
73-
energy_decay_curve = np.exp(
74-
-c*(times - delay_direct) *
75-
((-surface_room * np.log(1 - alpha_mean) / 4 / volume) + m))
76-
elif method == 'sabine':
77-
energy_decay_curve = np.exp(
78-
-c*(times - delay_direct) *
79-
((surface_room * alpha_mean / 4 / volume) + m))
80-
else:
81-
raise ValueError("The method has to be either 'eyring' or 'sabine'.")
82-
83-
return energy_decay_curve
96+
return pf.TimeData(np.reshape(edc, (*matching_shape, times.size)), times)
8497

8598

8699
def air_attenuation_coefficient(

tests/test_edc.py

Lines changed: 69 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -46,40 +46,49 @@ def test_schroeder_integration_multi_cdim(is_energy):
4646
npt.assert_almost_equal(energy_decay_curve.time[1, 0], truth)
4747

4848

49-
def test_edc_eyring():
50-
alphas = [0.9, 0.1]
51-
surfaces = [2, 5*2]
52-
volume = 2*2*2
49+
@pytest.mark.parametrize(
50+
"edc_function",
51+
[ra.edc.energy_decay_curve_chu,
52+
ra.edc.energy_decay_curve_lundeby,
53+
ra.edc.energy_decay_curve_chu_lundeby],
54+
)
55+
def test_multidim_edc(edc_function):
56+
"""
57+
Test if edcs from multichannel signal are equal to corresponding single
58+
channel edcs.
59+
"""
60+
rir = pf.signals.files.room_impulse_response()
61+
rir_oct = pf.dsp.filter.fractional_octave_bands(rir, 1)
62+
shape = rir_oct.time.shape
63+
edc = edc_function(rir_oct, channel_independent=True)
64+
65+
assert shape == edc.time.shape
66+
67+
edc = edc.flatten()
68+
rir_oct = rir_oct.flatten()
69+
70+
for i in range(edc.cshape[0]):
71+
baseline = edc_function(rir_oct[i])
72+
npt.assert_array_equal(edc[i].time, baseline.time)
73+
74+
75+
def test_edc_function():
76+
"""Test for the parametric energy decay curve based on previous
77+
implementation which also included RT prediction using Sabine's equation.
78+
"""
5379
times = np.linspace(0, 0.25, 50)
54-
edc = ra.parametric.energy_decay_curve_analytic(
55-
surfaces, alphas, volume, times, method='eyring', air_absorption=False)
5680

57-
truth = array([
58-
1.00000000e+00, 8.39817186e-01, 7.05292906e-01, 5.92317103e-01,
59-
4.97438083e-01, 4.17757051e-01, 3.50839551e-01, 2.94641084e-01,
60-
2.47444646e-01, 2.07808266e-01, 1.74520953e-01, 1.46565696e-01,
61-
1.23088390e-01, 1.03371746e-01, 8.68133684e-02, 7.29073588e-02,
62-
6.12288529e-02, 5.14210429e-02, 4.31842755e-02, 3.62668968e-02,
63-
3.04575632e-02, 2.55787850e-02, 2.14815032e-02, 1.80405356e-02,
64-
1.51507518e-02, 1.27238618e-02, 1.06857178e-02, 8.97404943e-03,
65-
7.53656094e-03, 6.32933340e-03, 5.31548296e-03, 4.46403394e-03,
66-
3.74897242e-03, 3.14845147e-03, 2.64412365e-03, 2.22058049e-03,
67-
1.86488165e-03, 1.56615966e-03, 1.31528780e-03, 1.10460130e-03,
68-
9.27663155e-04, 7.79067460e-04, 6.54274242e-04, 5.49470753e-04,
69-
4.61454981e-04, 3.87537824e-04, 3.25460924e-04, 2.73327678e-04,
70-
2.29545281e-04, 1.92776072e-04,
71-
])
72-
73-
npt.assert_almost_equal(edc, truth)
74-
75-
76-
def test_edc_sabine():
77-
alphas = [0.9, 0.1]
78-
surfaces = [2, 5*2]
81+
c = 343.4
82+
7983
volume = 2*2*2
80-
times = np.linspace(0, 0.25, 50)
81-
edc = ra.parametric.energy_decay_curve_analytic(
82-
surfaces, alphas, volume, times, method='sabine', air_absorption=False)
84+
alphas = np.asarray([0.9, 0.1])
85+
surfaces = np.asarray([2, 5*2])
86+
surface_room = np.sum(surfaces)
87+
alpha_mean = np.sum(surfaces*alphas) / surface_room
88+
89+
rt = 24*np.log(10)/c * volume / (surface_room*alpha_mean)
90+
energy = 1
91+
edc = ra.parametric.energy_decay_curve(times, rt, energy)
8392

8493
truth = array([
8594
1.00000000e+00, 8.57869258e-01, 7.35939663e-01, 6.31340013e-01,
@@ -96,30 +105,38 @@ def test_edc_sabine():
96105
1.17632849e-03, 1.00913605e-03, 8.65706791e-04, 7.42663242e-04,
97106
6.37107964e-04, 5.46555336e-04,
98107
])
99-
npt.assert_almost_equal(edc, truth)
108+
npt.assert_almost_equal(edc.time, np.atleast_2d(truth))
100109

101110

102-
@pytest.mark.parametrize(
103-
"edc_function",
104-
[ra.edc.energy_decay_curve_chu,
105-
ra.edc.energy_decay_curve_lundeby,
106-
ra.edc.energy_decay_curve_chu_lundeby],
107-
)
108-
def test_multidim_edc(edc_function):
109-
"""
110-
Test if edcs from multichannel signal are equal to corresponding single
111-
channel edcs.
111+
def test_parametric_edc():
112+
"""Test returned edc by the parametric edc function and shape broadcasting.
112113
"""
113-
rir = pf.signals.files.room_impulse_response()
114-
rir_oct = pf.dsp.filter.fractional_octave_bands(rir, 1)
115-
shape = rir_oct.time.shape
116-
edc = edc_function(rir_oct, channel_independent=True)
114+
times = np.linspace(0, 0.25, 50)
115+
T_60 = np.array([2, 1])
117116

118-
assert shape == edc.time.shape
117+
edc = ra.parametric.energy_decay_curve(times, T_60)
118+
assert edc.cshape == (2,)
119119

120-
edc = edc.flatten()
121-
rir_oct = rir_oct.flatten()
120+
e_0 = np.ones_like(T_60)
121+
edc = ra.parametric.energy_decay_curve(times, T_60, energy=e_0)
122122

123-
for i in range(edc.cshape[0]):
124-
baseline = edc_function(rir_oct[i])
125-
npt.assert_array_equal(edc[i].time, baseline.time)
123+
damping_term = 6*np.log(10) / T_60
124+
truth = np.atleast_2d(e_0).T * np.exp(-np.atleast_2d(damping_term).T*times)
125+
126+
npt.assert_almost_equal(edc.time, truth)
127+
assert edc.cshape == (2,)
128+
129+
edc = ra.parametric.energy_decay_curve(
130+
times, np.ones((3, 2)), energy=np.ones((3, 2)))
131+
132+
assert edc.cshape == (3, 2)
133+
134+
135+
def test_parametric_edc_wrong_shapes():
136+
"""Test error handling for wrong shapes."""
137+
times = np.linspace(0, 0.25, 50)
138+
T_60 = np.array([2, 1])
139+
energy = np.array([1, 1, 1])
140+
141+
with pytest.raises(ValueError, match="same shape."):
142+
ra.parametric.energy_decay_curve(times, T_60, energy=energy)

0 commit comments

Comments
 (0)