"""Constant calculation."""
import numpy as np
import pyfar as pf
from typing import Literal
[docs]
def air_attenuation(
temperature, frequencies, relative_humidity,
atmospheric_pressure=None):
r"""Calculate the pure tone attenuation of sound in air according to
ISO 9613-1.
Calculation is in accordance with ISO 9613-1 [#]_. The shape of the
outputs is broadcasted from the shapes of the ``temperature``,
``relative_humidity``, and ``atmospheric_pressure``.
The frequency bins represents the last dimension.
Parameters
----------
temperature : float, array_like
Temperature in degree Celsius.
Must be in the range of -20°C to 50°C for accuracy of +/-10% or
must be greater than -70°C for accuracy of +/-50%.
frequencies : float, array_like
Frequency in Hz. Must be greater than 50 Hz.
Just one dimensional array is allowed.
relative_humidity : float, array_like
Relative humidity in the range from 0 to 1.
atmospheric_pressure : float, array_like, optional
Atmospheric pressure in Pascal, by default
:py:attr:`reference_atmospheric_pressure`.
Returns
-------
alpha : np.ndarray[float]
Pure tone air attenuation coefficient in decibels per meter for
atmospheric absorption.
m : :py:class:`~pyfar.FrequencyData`
Pure tone air attenuation coefficient per meter for
atmospheric absorption. The parameter ``m`` is calculated as
:math:`m = \alpha / (10 \cdot \log_{10}(e))`.
accuracy : :py:class:`~pyfar.FrequencyData`
accuracy of the results according to the standard:
``10``, +/- 10% accuracy
- molar concentration of water vapour: 0.05% to 5 %.
- air temperature: 253.15 K to 323.15 (-20 °C to +50°C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 0.0004 Hz/Pa to 10 Hz/Pa.
``20``, +/- 20% accuracy
- molar concentration of water vapour: 0.005 % to 0.05 %,
and greater than 5%
- air temperature: 253.15 K to 323.15 (-20 °C to +50°C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 0.0004 Hz/Pa to 10 Hz/Pa.
``50``, +/- 50% accuracy
- molar concentration of water vapour: less than 0.005%
- air temperature: greater than 200 K (- 73 °C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 0.0004 Hz/Pa to 10 Hz/Pa.
``-1``, no valid result
else.
References
----------
.. [#] ISO 9613-1:1993, Acoustics -- Attenuation of sound during
propagation outdoors -- Part 1: Calculation of the absorption of
sound by the atmosphere.
"""
if atmospheric_pressure is None:
atmospheric_pressure = pf.constants.reference_atmospheric_pressure
# check inputs
if not isinstance(temperature, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'temperature must be a number or array of numbers')
if not isinstance(frequencies, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'frequencies must be a number or array of numbers')
if not isinstance(
relative_humidity, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'relative_humidity must be a number or array of numbers')
if np.array(frequencies).ndim > 1:
raise ValueError('frequencies must be one dimensional.')
if not isinstance(
atmospheric_pressure, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'atmospheric_pressure must be a number or array of numbers')
# check if broadcastable
try:
_ = np.broadcast_shapes(
np.atleast_1d(temperature).shape,
np.atleast_1d(relative_humidity).shape,
np.atleast_1d(atmospheric_pressure).shape)
except ValueError as e:
raise ValueError(
'temperature, relative_humidity, and atmospheric_pressure must '
'have the same shape or be broadcastable.') from e
# check limits
if np.any(np.array(temperature) < -73):
raise ValueError("Temperature must be greater than -73°C.")
if np.any(np.array(frequencies) < 50):
raise ValueError("frequencies must be greater than 50 Hz.")
if np.any(np.array(relative_humidity) < 0) or np.any(
np.array(relative_humidity) > 1):
raise ValueError("Relative humidity must be between 0 and 1.")
if np.any(np.array(atmospheric_pressure) > 200000):
raise ValueError("Atmospheric pressure must be less than 200 kPa.")
# convert arrays
temperature = np.array(
temperature, dtype=float)[..., np.newaxis]
relative_humidity = np.array(
relative_humidity, dtype=float)[..., np.newaxis]
atmospheric_pressure = np.array(
atmospheric_pressure, dtype=float)[..., np.newaxis]
frequencies = np.array(frequencies, dtype=float)
# calculate air attenuation
p_atmospheric_ref = pf.constants.reference_atmospheric_pressure
t_degree_ref = pf.constants.reference_air_temperature_celsius
h_r = relative_humidity*100
p_a = atmospheric_pressure
p_r = p_atmospheric_ref
f = frequencies
T = temperature - pf.constants.absolute_zero_celsius
T_0 = t_degree_ref - pf.constants.absolute_zero_celsius
# saturation vapour pressure
p_sat = _saturation_vapour_pressure_iso(temperature)
# molar concentration of water vapor as a percentage (Equation B.1)
h = h_r * (p_sat / p_r) * (p_a / p_r)
# Oxygen relaxation frequency (Eq. 3)
f_rO = (p_a/p_r)*(24+4.04e4*h*(0.02+h)/(0.391+h))
# Nitrogen relaxation frequency (Eq. 4)
f_rN = (p_a/p_r)*(T/T_0)**(-1/2)*(9+280*h*np.exp(
-4.17*((T/T_0)**(-1/3)-1)))
# air attenuation (Eq. 5)
alpha = 8.686*f**2*((1.84e-11*p_r/p_a*(T/T_0)**(1/2)) + \
(T/T_0)**(-5/2)*(0.01275*np.exp(-2239.1/T)*(f_rO + (f**2/f_rO))**(-1)
+0.1068*np.exp(-3352/T) * (f_rN + (f**2/f_rN))**(-1)))
# Equation 3: ISO 17497-1:2004
m = pf.FrequencyData(
alpha / (10*np.log10(np.exp(1))), frequencies=frequencies)
# calculate accuracy
accuracy = _air_attenuation_accuracy(
h, temperature, atmospheric_pressure, frequencies, m.freq.shape)
return alpha, m, accuracy
def _air_attenuation_accuracy(
concentration_water_vapour, temperature, atmospheric_pressure,
frequencies, shape):
"""Calculate the accuracy of the air attenuation calculation.
Calculation is in accordance with ISO 9613-1 [#]_. This method is used in
:py:func:`~pyfar.constants.air_attenuation` to calculate the accuracy of
the air attenuation calculation.
Parameters
----------
concentration_water_vapour : float, array_like
Molar concentration of water vapor as a percentage.
Must be between 0% and 100%.
temperature : float, array_like
Temperature in degree Celsius.
Must be above -273.15°C.
atmospheric_pressure : float, array_like
Atmospheric pressure in Pascal.
Must be above 0Pa.
frequencies : float, array_like
Frequency in Hz.
Must be larger than 0 Hz.
shape : tuple
Shape of the output.
Returns
-------
accuracy : :py:class:`~pyfar.classes.FrequencyData`
accuracy of the results according to the standard:
``10``, +/- 10% accuracy
- molar concentration of water vapour: 0.05% to 5 %.
- air temperature: 253.15 K to 323.15 (-20 °C to +50°C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 4 x 10-4 Hz/Pa to 10 Hz/Pa.
``20``, +/- 20% accuracy
- molar concentration of water vapour: 0.005 % to 0.05 %,
and greater than 5%
- air temperature: 253.15 K to 323.15 (-20 °C to +50°C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 4 x 10-4 Hz/Pa to 10 Hz/Pa.
``50``, +/- 50% accuracy
- molar concentration of water vapour: less than 0.005%
- air temperature: greater than 200 K (- 73 °C)
- atmospheric pressure: less than 200 000 Pa (2 atm)
- frequency-to-pressure ratio: 4 x 10-4 Hz/Pa to 10 Hz/Pa.
``-1``, no valid result
else.
References
----------
.. [#] ISO 9613-1:1993, Acoustics -- Attenuation of sound during
propagation outdoors -- Part 1: Calculation of the absorption of
sound by the atmosphere.
"""
if np.any(np.array(concentration_water_vapour) < 0) or np.any(
np.array(concentration_water_vapour) > 100):
raise ValueError(
r"Concentration of water vapour must be between 0% and 100%.")
if np.any(np.array(temperature) < -273.15):
raise ValueError(
"Temperature must be greater than -273.15°C.")
if np.any(np.array(atmospheric_pressure) < 0):
raise ValueError(
"Atmospheric pressure must be greater than 0 Pa.")
if np.any(np.array(frequencies) < 0):
raise ValueError(
"Frequencies must be positive.")
# broadcast inputs
atmospheric_pressure = np.broadcast_to(atmospheric_pressure, shape)
h_water_vapor = np.broadcast_to(concentration_water_vapour, shape)
frequency_pressure_ratio = frequencies/atmospheric_pressure
accuracy = np.zeros(shape) - 1
# atmospheric pressure < 200 kPa
atm_mask = atmospheric_pressure < 200000
# frequency-to-pressure ratio: 4 x 10-4 Hz/Pa to 10 Hz/Pa
frequency_pressure_ratio_mask = (4e-4 <= frequency_pressure_ratio) & (
frequency_pressure_ratio <= 10)
common_mask = atm_mask & frequency_pressure_ratio_mask
# molar concentration of water vapour: 0.05% to 5 %
vapor_10_mask = (0.05 <= h_water_vapor) & (h_water_vapor <= 5)
# molar concentration of water vapour: 0.005% to 0.05 % and greater than 5%
vapor_20_mask = (5 < h_water_vapor) | (
(0.005 <= h_water_vapor) & (h_water_vapor < 0.05))
# molar concentration of water vapour: less than 0.005%
vapor_50_mask = (0.005 > h_water_vapor)
# air temperature: 253,15 K to 323,15 (-20 °C to +50°C)
temp_20_mask = (-20 <= temperature) & (temperature <= 50)
# air temperature: greater than 200 K (- 73 °C)
temp_50_mask = (-73 <= temperature)
# apply masks
accuracy_50 = common_mask & temp_50_mask & (
vapor_10_mask | vapor_20_mask | vapor_50_mask)
accuracy[accuracy_50] = 50
accuracy_20 = common_mask & temp_20_mask & (
vapor_10_mask | vapor_20_mask)
accuracy[accuracy_20] = 20
accuracy_10 = vapor_10_mask & common_mask & temp_20_mask
accuracy[accuracy_10] = 10
# return FrequencyData object
return pf.FrequencyData(accuracy, frequencies=frequencies)
def _saturation_vapour_pressure_iso(temperature):
"""Calculates the saturation vapour pressure after ISO 9613-1:1993.
This method is used in the :py:func:`air_attenuation` function to calculate
the saturation vapour pressure of water vapour in air as a close
approximation to
those calculated by the World Meteorological Organization.
The method is described in Equation B.2 and B.3 in [#]_.
Parameters
----------
temperature : float, array_like
Temperature in degree Celsius.
Must be in the range of -20°C to 50°C for accuracy of +/-10% or
must be greater than -70°C for accuracy of +/-50%.
Returns
-------
p_sat : float, array_like
Saturation vapour pressure in Pascal.
References
----------
.. [#] ISO 9613-1:1993, Acoustics -- Attenuation of sound during
propagation outdoors -- Part 1: Calculation of the absorption of
sound by the atmosphere.
"""
T = temperature - pf.constants.absolute_zero_celsius
p_r = pf.constants.reference_atmospheric_pressure
# triple-point isotherm temperature of 273.16 K
T_01 = 273.16
# saturation vapour pressure (Equation B.2 and B.3)
C = -6.8346*(T_01/T)**1.261+4.6151
return 10**C * p_r
[docs]
def saturation_vapor_pressure_magnus(temperature):
r"""
Calculate the saturation vapor pressure of water in Pascal using the
Magnus formula.
The Magnus formula is valid for temperatures between -45°C and 60°C [#]_.
.. math::
e_s = 610.94 \cdot \exp\left(\frac{17.625 \cdot T}{T + 243.04}\right)
Parameters
----------
temperature : float, array_like
Temperature in degrees Celsius (°C).
Returns
-------
p_sat : float, array_like
Saturation vapor pressure in Pa.
References
----------
.. [#] O. A. Alduchov and R. E. Eskridge, “Improved Magnus Form
Approximation of Saturation Vapor Pressure,” Journal of Applied
Meteorology and Climatology, vol. 35, no. 4, pp. 60-609, Apr. 1996
"""
if not isinstance(temperature, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'temperature must be a number or array of numbers')
if np.any(np.array(
temperature) < -45) or np.any(np.array(temperature) > 60):
raise ValueError("Temperature must be in the range of -45°C and 60°C.")
if isinstance(temperature, (np.ndarray, list, tuple)):
temperature = np.asarray(temperature, dtype=float)
# Eq. (21)
e_s = 6.1094 * np.exp((17.625 * temperature) / (temperature + 243.04))
return 100 * e_s
[docs]
def density_of_air(
temperature, relative_humidity, atmospheric_pressure=None,
saturation_vapor_pressure=None):
r"""
Calculate the density of air in kg/m³ based on the temperature,
relative humidity, and atmospheric pressure.
The density of air is calculated based on chapter 6.3 in [#]_.
All input parameters must be broadcastable to the same shape.
Parameters
----------
temperature : float, array_like
Temperature in degrees Celsius (°C).
relative_humidity : float, array_like
Relative humidity in the range from 0 to 1.
atmospheric_pressure : float, array_like, optional
Atmospheric pressure in Pascal (Pa), by default
:py:attr:`reference_atmospheric_pressure`.
saturation_vapor_pressure : float, array_like, optional
Saturation vapor pressure in Pascal (Pa).
The default uses the value and valid temperature range from
:py:func:`~pyfar.constants.saturation_vapor_pressure_magnus`.
Returns
-------
density : float, array_like
Density of air in kg/m³.
References
----------
.. [#] V. E. Ostashev and D. K. Wilson, Acoustics in Moving Inhomogeneous
Media, 2nd ed. London: CRC Press, 2015. doi: 10.1201/b18922.
"""
if atmospheric_pressure is None:
atmospheric_pressure = pf.constants.reference_atmospheric_pressure
# check inputs
if not isinstance(temperature, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'Temperature must be a number or array of numbers')
if not isinstance(
relative_humidity, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'Relative humidity must be a number or array of numbers')
if not isinstance(
atmospheric_pressure, (int, float, np.ndarray, list, tuple)):
raise TypeError(
'Atmospheric pressure must be a number or array of numbers')
temperature = np.array(temperature, dtype=float)
if np.any(np.array(relative_humidity) < 0) or np.any(
np.array(relative_humidity) > 1):
raise ValueError("Relative humidity must be between 0 and 1.")
if np.any(np.array(atmospheric_pressure) <= 0):
raise ValueError("Atmospheric pressure must be larger than 0 Pa.")
P = np.array(atmospheric_pressure, dtype=float) # Pa
relative_humidity = np.array(relative_humidity, dtype=float)
# Constants according to 6.3.2
R = 8.314 # J/(K mol)
mu_a = 28.97*1e-3 # kg/mol
mu_w = 18.02*1e-3 # kg/mol
# next to Equation 6.69
R_a = R / mu_a
alpha = mu_a / mu_w
# partial pressure of water vapor in Pa
if saturation_vapor_pressure is None:
p = saturation_vapor_pressure_magnus(temperature) # Pa
else:
p = np.array(saturation_vapor_pressure, dtype=float) # Pa
e = relative_humidity * p # Pa
# Equation 6.70
C = (e/P) / (alpha * (1 - e/P))
# Equation 6.71
return P / (R_a * (temperature + 273.15)) * (1+C)/(1+alpha*C)
[docs]
def fractional_octave_filter_tolerance(
exact_center_frequency: float,
num_fractions: Literal[1, 3],
tolerance_class : Literal[1, 2]):
r"""
Calculate the tolerance limits for fractional octave band filters.
Calculation is in accordance with IEC 61260-1:2014 [#]_ (Section 5.10 and
Table 1).
.. note ::
The standard defines some lower tolerance limits as :math:`-\infty`,
which is inconvenient for plotting. The returned tolerance is -60000 dB
in these cases, which is below the smallest possible value of
``20*np.log10(np.finfo(float).tiny``
:math:`\approx`-6000 dB.
Parameters
----------
exact_center_frequency : float
The exact center frequency of the band filter in Hz (see
:py:func:`~pyfar.dsp.filter.fractional_octave_frequencies`).
num_fractions : Literal[1, 3]
The number of bands an octave is divided into. ``1`` for octave bands
and ``3`` for third octave bands.
tolerance_class : Literal[1, 2]
The tolerance class as defined in the standard. Must be ``1`` or ``2``.
Returns
-------
lower_tolerance : numpy array
Lower tolerance limits in dB of shape (19, ).
upper_tolerance : numpy array
Upper tolerance limits in dB of shape (19, ).
frequencies : numpy array
The frequencies in Hz at which the tolerance is given of shape (19, ).
References
----------
.. [#] IEC61260-1:2014 Octave-band and fractional-octave-band filters.
Part 1: Specifications.
Examples
--------
Class 1 tolerance region and filter for the 1000 Hz octave band.
.. plot::
>>> import pyfar as pf
>>> import matplotlib.pyplot as plt
>>>
>>> lower, upper, frequencies = \
... pf.constants.fractional_octave_filter_tolerance(
... exact_center_frequency=1000, num_fractions=1,
... tolerance_class=1)
>>>
>>> octave_filter = pf.dsp.filter.fractional_octave_bands(
... pf.signals.impulse(2**12), num_fractions=1,
... frequency_range=(1000, 1000))
>>>
>>> ax = pf.plot.freq(octave_filter, color='k', label='Octave filter')
>>> plt.fill_between(
... frequencies, lower, upper,
... facecolor='g', alpha=.25, label='Class 1 Tolerance')
>>> ax.set_ylim(-70, 5)
>>> ax.set_xlim(63, 15_850)
>>> ax.legend()
"""
if not isinstance(exact_center_frequency, (int, float)):
raise ValueError('The exact center frequency must be a float '
'or integer number')
if num_fractions not in [1, 3]:
raise ValueError(
f"num_fractions is {num_fractions} but must be 1 or 3")
if tolerance_class not in [1, 2]:
raise ValueError(
f"tolerance_class is {tolerance_class} but must be 1 or 2")
# constants from DIN EN 61260-1:2014-10, Sec. 5.10 and Tab. 1
# (define only first half due to symmetry)
G = 10**(3/10) # octave ratio according to Eq. (1)
freq_offset = 1e-6 # small frequency offset to model discontinuities in
# tolerance curve
min_dB = -60e3 # dB value to be used instead of minus infinity
# (number required for plots)
relative_frequencies = [-4, -3, -2, -1, -0.5-freq_offset, -0.5+freq_offset,
-3/8, -1/4, -1/8, 0]
if tolerance_class == 1:
upper_tolerance = [
-70, -60, -40.5, -16.6, -1.2, 0.4, 0.4, 0.4, 0.4, 0.4]
lower_tolerance = [min_dB, min_dB, min_dB, min_dB, min_dB, -5.3,
-1.4, -0.7, -0.5, -0.4]
else:
upper_tolerance = [
-60, -54, -39.5, -15.6, -0.8, 0.6, 0.6, 0.6, 0.6, 0.6]
lower_tolerance = [min_dB, min_dB, min_dB, min_dB, min_dB, -5.8,
-1.7, -0.9, -0.7, -0.6]
relative_frequencies = np.hstack((
relative_frequencies, -np.flip(relative_frequencies[:-1])))
upper_tolerance = np.hstack((
upper_tolerance, np.flip(upper_tolerance[:-1])))
lower_tolerance = np.hstack((
lower_tolerance, np.flip(lower_tolerance[:-1])))
# scale frequencies to bandwidth
relative_frequencies /= num_fractions
frequencies = exact_center_frequency * G**relative_frequencies
return lower_tolerance, upper_tolerance, frequencies