From 43af132da35f9c38a533cd9f1ecea6f60f4b2f65 Mon Sep 17 00:00:00 2001 From: Antoine Minard <165933065+ansaminard@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:59:47 +0100 Subject: [PATCH] feat: Tonality ISO1996-2 C over time (#210) --- doc/changelog.d/210.added.md | 1 + doc/source/api/psychoacoustics.rst | 1 + .../sound/core/psychoacoustics/__init__.py | 2 + .../tonality_iso_1996_2_over_time.py | 394 ++++++++++++++++++ ...acoustics_tonality_iso_1996_2_over_time.py | 366 ++++++++++++++++ 5 files changed, 764 insertions(+) create mode 100644 doc/changelog.d/210.added.md create mode 100644 src/ansys/sound/core/psychoacoustics/tonality_iso_1996_2_over_time.py create mode 100644 tests/tests_psychoacoustics/test_psychoacoustics_tonality_iso_1996_2_over_time.py diff --git a/doc/changelog.d/210.added.md b/doc/changelog.d/210.added.md new file mode 100644 index 00000000..91fb0d90 --- /dev/null +++ b/doc/changelog.d/210.added.md @@ -0,0 +1 @@ +feat: Tonality ISO1996-2 C over time \ No newline at end of file diff --git a/doc/source/api/psychoacoustics.rst b/doc/source/api/psychoacoustics.rst index 12b69427..86601095 100644 --- a/doc/source/api/psychoacoustics.rst +++ b/doc/source/api/psychoacoustics.rst @@ -18,4 +18,5 @@ Psychoacoustics ToneToNoiseRatioForOrdersOverTime TonalityDIN45681 TonalityISO1996_2 + TonalityISO1996_2_OverTime TonalityAures diff --git a/src/ansys/sound/core/psychoacoustics/__init__.py b/src/ansys/sound/core/psychoacoustics/__init__.py index 23118dac..c5e30370 100644 --- a/src/ansys/sound/core/psychoacoustics/__init__.py +++ b/src/ansys/sound/core/psychoacoustics/__init__.py @@ -37,6 +37,7 @@ from .tonality_aures import TonalityAures from .tonality_din_45681 import TonalityDIN45681 from .tonality_iso_1996_2 import TonalityISO1996_2 +from .tonality_iso_1996_2_over_time import TonalityISO1996_2_OverTime from .tone_to_noise_ratio import ToneToNoiseRatio from .tone_to_noise_ratio_for_orders_over_time import ToneToNoiseRatioForOrdersOverTime @@ -50,6 +51,7 @@ "Roughness", "FluctuationStrength", "TonalityDIN45681", + "TonalityISO1996_2_OverTime", "TonalityAures", "SpectralCentroid", "ToneToNoiseRatioForOrdersOverTime", diff --git a/src/ansys/sound/core/psychoacoustics/tonality_iso_1996_2_over_time.py b/src/ansys/sound/core/psychoacoustics/tonality_iso_1996_2_over_time.py new file mode 100644 index 00000000..642bc97c --- /dev/null +++ b/src/ansys/sound/core/psychoacoustics/tonality_iso_1996_2_over_time.py @@ -0,0 +1,394 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Computes the tonality according to the standard ISO 1996-2:2007, annex C, over time.""" +import warnings + +from ansys.dpf.core import ( + Field, + GenericDataContainer, + GenericDataContainersCollection, + Operator, + types, +) +from ansys.dpf.core.collection import Collection +import matplotlib.pyplot as plt +import numpy as np + +from . import PsychoacousticsParent +from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning + +# Name of the DPF Sound operator used in this module. +ID_COMPUTE_TONALITY_ISO_1996_2_OVER_TIME = "compute_tonality_iso1996_2_over_time" + +# List of segment details identifiers. +LIST_SEGMENT_DETAILS_KEYS = [ + "segment_start_time_s", + "segment_end_time_s", + "lower_critical_band_limit_Hz", + "higher_critical_band_limit_Hz", + "total_tonal_level_dBA", + "total_noise_level_dBA", +] + + +class TonalityISO1996_2_OverTime(PsychoacousticsParent): + """Computes the tonality according to the standard ISO 1996-2:2007, annex C, over time. + + .. note:: + The standard ISO 1996-2:2007, annex C, does not include a method for non-stationary sounds. + The computation of the present indicator is thus not entirely covered by the standard. The + method used here splits the input signal into overlapping windows (segments), and then + computes the tonality, for each segment, following the standard. + """ + + def __init__( + self, + signal: Field = None, + window_length: float = 1000.0, + overlap: float = 75.0, + noise_pause_threshold: float = 1.0, + effective_analysis_bandwidth: float = 5.0, + noise_bandwidth_ratio: float = 0.75, + ): + """Class instantiation takes the following parameters. + + Parameters + ---------- + signal : Field, default: None + Input signal, as a DPF field. + window_length : float, default: 1000.0 + Integration window length in ms. + overlap : float, default: 75.0 + Overlap between successive windows in %. + noise_pause_threshold : float, default: 1.0 + Noise pause detection threshold ("level excess") in dB. + effective_analysis_bandwidth : float, default: 5.0 + Effective analysis bandwidth in Hz. + noise_critical_bandwidth_ratio : float, default: 0.75 + Noise bandwidth, in proportion to the critical bandwidth, that is taken into account + for the calculation of the masking noise level (the default value `0.75` means that the + masking noise level is estimated in a band delimited by 75 % of the critical bandwidth + on each side of the tone). Value must be between `0.75` and `2`. + + For more information about the parameters, please refer to Ansys Sound SAS' user guide. + """ + super().__init__() + self.signal = signal + self.window_length = window_length + self.overlap = overlap + self.noise_pause_threshold = noise_pause_threshold + self.effective_analysis_bandwidth = effective_analysis_bandwidth + self.noise_bandwidth_ratio = noise_bandwidth_ratio + self.__operator = Operator(ID_COMPUTE_TONALITY_ISO_1996_2_OVER_TIME) + + def __str__(self) -> str: + """Return the string representation of the object.""" + if self._output is None: + str_tonality = ( + f"Number of segments: Not processed\n" + f"Maximum tonal audibility: Not processed\n" + f"Maximum tonal adjustment: Not processed" + ) + else: + str_tonality = ( + f"Number of segments: {self.get_segment_count()}\n" + f"Maximum tonal audibility: {max(self.get_tonal_audibility_over_time()):.1f} dB\n" + f"Maximum tonal adjustment: {max(self.get_tonal_adjustment_over_time()):.1f} dB" + ) + + return ( + f"{__class__.__name__} object\n" + "Data:\n" + f'\tSignal name: {f'"{self.signal.name}"' if self.signal is not None else "Not set"}\n' + f"\tIntegration window length: {self.window_length} ms\n" + f"\tOverlap: {self.overlap} %\n" + f"\tNoise pause detection threshold: {self.noise_pause_threshold} dB\n" + f"\tEffective analysis bandwidth: {self.effective_analysis_bandwidth} Hz\n" + "\tNoise bandwidth in proportion to critical bandwidth: " + f"{self.noise_bandwidth_ratio}\n" + f"{str_tonality}" + ) + + @property + def signal(self) -> Field: + """Input signal, as a DPF field.""" + return self.__signal + + @signal.setter + def signal(self, signal: Field): + """Set the signal.""" + if not (isinstance(signal, Field) or signal is None): + raise PyAnsysSoundException("Signal must be specified as a DPF field.") + self.__signal = signal + + @property + def window_length(self) -> int | float: + """Length of the integration window in ms.""" + return self.__window_length + + @window_length.setter + def window_length(self, window_length: int | float): + """Set the integration window length.""" + if window_length <= 0.0: + raise PyAnsysSoundException("Integration window length must be greater than 0 ms.") + self.__window_length = window_length + + @property + def overlap(self) -> int | float: + """Overlap between successive windows in %.""" + return self.__overlap + + @overlap.setter + def overlap(self, overlap: int | float): + """Set the overlap.""" + if overlap < 0.0 or overlap >= 100.0: + raise PyAnsysSoundException( + "Overlap must be greater than or equal to 0 %, and strictly smaller than 100 %." + ) + self.__overlap = overlap + + @property + def noise_pause_threshold(self) -> int | float: + """Noise pause detection threshold (level excess) in dB.""" + return self.__noise_pause_threshold + + @noise_pause_threshold.setter + def noise_pause_threshold(self, noise_pause_threshold: int | float): + """Set the noise pause detection threshold.""" + if noise_pause_threshold <= 0.0: + raise PyAnsysSoundException( + "Noise pause detection threshold must be greater than 0 dB." + ) + self.__noise_pause_threshold = noise_pause_threshold + + @property + def effective_analysis_bandwidth(self) -> int | float: + """Effective analysis bandwidth in Hz.""" + return self.__effective_analysis_bandwidth + + @effective_analysis_bandwidth.setter + def effective_analysis_bandwidth(self, effective_analysis_bandwidth: int | float): + """Set the effective analysis bandwidth.""" + if effective_analysis_bandwidth <= 0.0: + raise PyAnsysSoundException("Effective analysis bandwidth must be greater than 0 Hz.") + self.__effective_analysis_bandwidth = effective_analysis_bandwidth + + @property + def noise_bandwidth_ratio(self) -> int | float: + """Noise bandwidth in proportion to the critical bandwidth. + + Noise bandwidth, in proportion to the critical bandwidth, that is taken into account for + the calculation of the masking noise level (the default value `0.75` means that the masking + noise level is estimated in a band delimited by 75 % of the critical bandwidth on each side + of the tone). Value must be between `0.75` and `2`. + """ + return self.__noise_bandwidth_ratio + + @noise_bandwidth_ratio.setter + def noise_bandwidth_ratio(self, ratio: int | float): + """Set the noise bandwidth in proportion of the critical bandwidth.""" + if ratio < 0.75 or ratio > 2.0: + raise PyAnsysSoundException("Noise bandwidth ratio must be between 0.75 and 2.") + self.__noise_bandwidth_ratio = ratio + + def process(self): + """Compute the tonal audibility and adjustment according to ISO1996-2 annex C.""" + if self.signal is None: + raise PyAnsysSoundException( + f"No input signal is set. Use ``{__class__.__name__}.signal``." + ) + + self.__operator.connect(0, self.signal) + self.__operator.connect(1, float(self.window_length)) + self.__operator.connect(2, float(self.overlap)) + self.__operator.connect(3, float(self.noise_pause_threshold)) + self.__operator.connect(4, float(self.effective_analysis_bandwidth)) + self.__operator.connect(5, float(self.noise_bandwidth_ratio)) + + # Run the operator + self.__operator.run() + + # Stores output + self._output = ( + self.__operator.get_output(0, types.field), + self.__operator.get_output(1, types.field), + self.__operator.get_output(2, GenericDataContainersCollection), + ) + + def get_output(self) -> tuple[Field, Field, Collection[GenericDataContainer]]: + """Get the ISO 1996-2 tonality data. + + Returns + ------- + tuple[Field, Field, Collection[GenericDataContainer]] + + - First element is the tonal audibility over time, in dB. + + - Second element is the tonal adjustment over time, in dB. + + - Third element contains the computation details, that is, the segment start and end + times, the main tone's critical band boundary frequencies, and the total tone and + noise levels in dBA, for each successive window (segment) in the input signal. + """ + if self._output == None: + warnings.warn( + PyAnsysSoundWarning( + f"Output is not processed yet. Use the ``{__class__.__name__}.process()`` " + "method." + ) + ) + + return self._output + + def get_output_as_nparray(self) -> tuple[np.ndarray]: + """Get the ISO 1996-2 tonality data as NumPy arrays. + + Returns + ------- + tuple[numpy.ndarray] + + - First element is the tonal audibility over time, in dB. + + - Second element is the tonal adjustment over time, in dB. + + - Third element is the time scale in s. + """ + output = self.get_output() + + if output == None: + return np.array([]), np.array([]), np.array([]) + + return ( + np.array(output[0].data), + np.array(output[1].data), + np.array(output[0].time_freq_support.time_frequencies.data), + ) + + def get_tonal_audibility_over_time(self) -> np.ndarray: + """Get the ISO 1996-2 tonal audibility over time. + + Returns + ------- + numpy.ndarray + ISO 1996-2 tonal audibility over time, in dB. + """ + return self.get_output_as_nparray()[0] + + def get_tonal_adjustment_over_time(self) -> np.ndarray: + """Get the ISO 1996-2 tonal adjustment over time. + + Returns + ------- + numpy.ndarray + ISO 1996-2 tonal adjustment over time, in dB. + """ + return self.get_output_as_nparray()[1] + + def get_time_scale(self) -> np.ndarray: + """Get the time scale. + + Returns + ------- + numpy.ndarray + Time scale in s. + """ + return self.get_output_as_nparray()[2] + + def get_segment_count(self) -> int: + """Get the number of segments. + + Returns the number of overlapping windows (segments) on which the ISO 1996-2 tonality was + computed. + + Returns + ------- + int + Number of segments. + """ + return len(self.get_output_as_nparray()[0]) + + def get_segment_details(self, segment_index: int) -> dict[str, float]: + """Get the ISO 1996-2 tonality details in the specified segment of the input signal. + + Parameters + ---------- + segment_index : int + Index of the segment. + + Returns + ------- + dict[str, float] + Dictionary containing the ISO 1996-2 tonality details for the specified segment, namely: + + - Segment start time in s (`"segment_start_time_s"`), + + - Segment end time in s (`"segment_end_time_s"`), + + - Main tone's critical band lower frequency in Hz (`"lower_critical_band_limit_Hz"`), + + - Main tone's critical band higher frequency in Hz + (`"higher_critical_band_limit_Hz"`), + + - Total tone level in dBA (`"total_tonal_level_dBA"`), + + - Total noise level in dBA (`"total_noise_level_dBA"`). + """ + segment_count = self.get_segment_count() + if segment_count == 0: + return {key: np.nan for key in LIST_SEGMENT_DETAILS_KEYS} + + if segment_index < 0 or segment_index >= segment_count: + raise PyAnsysSoundException( + f"Segment index {segment_index} is out of range. It must be between 0 and " + f"{segment_count - 1}." + ) + + segment_details = self.get_output()[2].get_entry({"spectrum_number": segment_index}) + return {key: segment_details.get_property(key) for key in LIST_SEGMENT_DETAILS_KEYS} + + def plot(self): + """Plot the ISO 1996-2 tonal audibility and tonal adjustment over time.""" + if self._output is None: + raise PyAnsysSoundException( + f"Output is not processed yet. Use the ``{__class__.__name__}.process()`` method." + ) + + tonal_audibility = self.get_tonal_audibility_over_time() + tonal_adjustment = self.get_tonal_adjustment_over_time() + time_scale = self.get_time_scale() + + _, axes = plt.subplots(2, 1, sharex=True) + + axes[0].plot(time_scale, tonal_audibility) + axes[0].set_title("ISO 1996-2 tonal audibility over time") + axes[0].set_ylabel(r"$\mathregular{\Delta L_{ta}}$ (dB)") + axes[0].grid() + + axes[1].plot(time_scale, tonal_adjustment) + axes[1].set_title("ISO 1996-2 tonal adjustment over time") + axes[1].set_ylabel(r"$\mathregular{K_t}$ (dB)") + axes[1].set_xlabel("Time (s)") + axes[1].grid() + + plt.tight_layout() + plt.show() diff --git a/tests/tests_psychoacoustics/test_psychoacoustics_tonality_iso_1996_2_over_time.py b/tests/tests_psychoacoustics/test_psychoacoustics_tonality_iso_1996_2_over_time.py new file mode 100644 index 00000000..46c8cbd9 --- /dev/null +++ b/tests/tests_psychoacoustics/test_psychoacoustics_tonality_iso_1996_2_over_time.py @@ -0,0 +1,366 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANtaBILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from unittest.mock import patch + +from ansys.dpf.core import Field +from ansys.dpf.core.collection import Collection +import numpy as np +import pytest + +from ansys.sound.core._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ansys.sound.core.psychoacoustics import TonalityISO1996_2_OverTime +from ansys.sound.core.signal_utilities import LoadWav + +EXP_TIME3 = 1.312472 +EXP_SEGMENT_COUNT = 96 +EXP_SEGMENT3_TA = 15.31328 +EXP_SEGMENT3_KT = 6.0 +EXP_SEGMENT3_START = 0.787483 +EXP_SEGMENT3_END = 1.837483 +EXP_SEGMENT3_CB_L = 750.0 +EXP_SEGMENT3_CB_U = 916.6667 +EXP_SEGMENT3_LPT = 66.82337 +EXP_SEGMENT3_LPN = 54.16815 +EXP_STR_UNPROCESSED = ( + "TonalityISO1996_2_OverTime object\n" + "Data:\n" + f"\tSignal name: Not set\n" + f"\tIntegration window length: 1000.0 ms\n" + f"\tOverlap: 75.0 %\n" + f"\tNoise pause detection threshold: 1.0 dB\n" + f"\tEffective analysis bandwidth: 5.0 Hz\n" + f"\tNoise bandwidth in proportion to critical bandwidth: 0.75\n" + f"Number of segments: Not processed\n" + f"Maximum tonal audibility: Not processed\n" + f"Maximum tonal adjustment: Not processed" +) +EXP_STR_PROCESSED = ( + "TonalityISO1996_2_OverTime object\n" + "Data:\n" + f'\tSignal name: "Aircraft-App2"\n' + f"\tIntegration window length: 1000.0 ms\n" + f"\tOverlap: 75.0 %\n" + f"\tNoise pause detection threshold: 1.0 dB\n" + f"\tEffective analysis bandwidth: 5.0 Hz\n" + f"\tNoise bandwidth in proportion to critical bandwidth: 0.75\n" + f"Number of segments: 96\n" + f"Maximum tonal audibility: 25.7 dB\n" + f"Maximum tonal adjustment: 6.0 dB" +) + + +def test_tonality_iso_1996_2_over_time_instantiation(): + """Test instantiation.""" + tonality = TonalityISO1996_2_OverTime() + assert tonality.signal == None + assert tonality.window_length == pytest.approx(1000.0) + assert tonality.overlap == pytest.approx(75.0) + assert tonality.noise_pause_threshold == pytest.approx(1.0) + assert tonality.effective_analysis_bandwidth == pytest.approx(5.0) + assert tonality.noise_bandwidth_ratio == pytest.approx(0.75) + + +def test_tonality_iso_1996_2_over_time___str__(): + """Test __str__ method.""" + tonality = TonalityISO1996_2_OverTime() + assert tonality.__str__() == EXP_STR_UNPROCESSED + + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality.signal = fc[0] + tonality.process() + assert tonality.__str__() == EXP_STR_PROCESSED + + +def test_tonality_iso_1996_2_over_time_properties(): + """Test properties.""" + tonality = TonalityISO1996_2_OverTime() + tonality.signal = Field() + assert type(tonality.signal) == Field + + tonality.window_length = 500.0 + assert tonality.window_length == 500.0 + + tonality.overlap = 15.6 + assert tonality.overlap == 15.6 + + tonality.noise_pause_threshold = 2.0 + assert tonality.noise_pause_threshold == 2.0 + + tonality.effective_analysis_bandwidth = 10.0 + assert tonality.effective_analysis_bandwidth == 10.0 + + tonality.noise_bandwidth_ratio = 1.5 + assert tonality.noise_bandwidth_ratio == 1.5 + + +def test_tonality_iso_1996_2_over_time_setters_exceptions(): + """Test setters' exceptions.""" + tonality = TonalityISO1996_2_OverTime() + with pytest.raises( + PyAnsysSoundException, + match="Signal must be specified as a DPF field.", + ): + tonality.signal = "Invalid" + + with pytest.raises( + PyAnsysSoundException, match="Integration window length must be greater than 0 ms." + ): + tonality.window_length = 0.0 + + with pytest.raises( + PyAnsysSoundException, + match="Overlap must be greater than or equal to 0 %, and strictly smaller than 100 %", + ): + tonality.overlap = 100.0 + + with pytest.raises( + PyAnsysSoundException, + match="Noise pause detection threshold must be greater than 0 dB.", + ): + tonality.noise_pause_threshold = 0.0 + + with pytest.raises( + PyAnsysSoundException, + match="Effective analysis bandwidth must be greater than 0 Hz.", + ): + tonality.effective_analysis_bandwidth = 0.0 + + with pytest.raises( + PyAnsysSoundException, + match="Noise bandwidth ratio must be between 0.75 and 2.", + ): + tonality.noise_bandwidth_ratio = 0.0 + + +def test_tonality_iso_1996_2_over_time_process(): + """Test process method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + +def test_tonality_iso_1996_2_over_time_process_exception(): + """Test process method's exception.""" + tonality = TonalityISO1996_2_OverTime() + + with pytest.raises( + PyAnsysSoundException, + match="No input signal is set. Use ``TonalityISO1996_2_OverTime.signal``.", + ): + tonality.process() + + +def test_tonality_iso_1996_2_over_time_get_output(): + """Test get_output method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + output = tonality.get_output() + assert isinstance(output[0], Field) + assert isinstance(output[1], Field) + assert isinstance(output[2], Collection) + + +def test_tonality_iso_1996_2_over_time_get_output_unprocessed(): + """Test get_output method's warning.""" + tonality = TonalityISO1996_2_OverTime() + + with pytest.warns( + PyAnsysSoundWarning, + match=( + "Output is not processed yet. Use the ``TonalityISO1996_2_OverTime.process\\(\\)`` " + "method." + ), + ): + output = tonality.get_output() + assert output is None + + +def test_tonality_iso_1996_2_over_time_get_output_as_nparray(): + """Test get_output_as_nparray method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + ta, Kt, time = tonality.get_output_as_nparray() + assert len(ta) == len(Kt) == len(time) == EXP_SEGMENT_COUNT + assert ta[3] == pytest.approx(EXP_SEGMENT3_TA) + assert Kt[3] == pytest.approx(EXP_SEGMENT3_KT) + assert time[3] == pytest.approx(EXP_TIME3) + + +def test_tonality_iso_1996_2_over_time_get_output_as_nparray_unprocessed(): + """Test get_output_as_nparray method's warning.""" + tonality = TonalityISO1996_2_OverTime() + + with pytest.warns( + PyAnsysSoundWarning, + match=( + "Output is not processed yet. Use the ``TonalityISO1996_2_OverTime.process\\(\\)`` " + "method." + ), + ): + ta, Kt, time = tonality.get_output_as_nparray() + assert len(ta) == len(Kt) == len(time) == 0 + + +def test_tonality_iso_1996_2_over_time_get_tonal_audibility_over_time(): + """Test get_tonal_audibility_over_time method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + assert tonality.get_tonal_audibility_over_time()[3] == pytest.approx(EXP_SEGMENT3_TA) + + +def test_tonality_iso_1996_2_over_time_get_tonal_adjustment_over_time(): + """Test get_tonal_adjustment_over_time method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + assert tonality.get_tonal_adjustment_over_time()[3] == pytest.approx(EXP_SEGMENT3_KT) + + +def test_tonality_iso_1996_2_over_time_get_time_scale(): + """Test get_time_scale method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + assert tonality.get_time_scale()[3] == pytest.approx(EXP_TIME3) + + +def test_tonality_iso_1996_2_over_time_get_segment_count(): + """Test get_segment_count method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + assert tonality.get_segment_count() == EXP_SEGMENT_COUNT + + +def test_tonality_iso_1996_2_over_time_get_segment_details(): + """Test get_segment_details method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + dict_details = tonality.get_segment_details(segment_index=3) + assert dict_details["segment_start_time_s"] == pytest.approx(EXP_SEGMENT3_START) + assert dict_details["segment_end_time_s"] == pytest.approx(EXP_SEGMENT3_END) + assert dict_details["lower_critical_band_limit_Hz"] == pytest.approx(EXP_SEGMENT3_CB_L) + assert dict_details["higher_critical_band_limit_Hz"] == pytest.approx(EXP_SEGMENT3_CB_U) + assert dict_details["total_tonal_level_dBA"] == pytest.approx(EXP_SEGMENT3_LPT) + assert dict_details["total_noise_level_dBA"] == pytest.approx(EXP_SEGMENT3_LPN) + + +def test_tonality_iso_1996_2_over_time_get_segment_details_exceptions(): + """Test get_segment_details method's exception.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + + with pytest.warns( + PyAnsysSoundWarning, + match=( + "Output is not processed yet. Use the ``TonalityISO1996_2_OverTime.process\\(\\)`` " + "method." + ), + ): + dict_details = tonality.get_segment_details(segment_index=0) + assert np.isnan(dict_details["segment_start_time_s"]) + assert np.isnan(dict_details["segment_end_time_s"]) + assert np.isnan(dict_details["lower_critical_band_limit_Hz"]) + assert np.isnan(dict_details["higher_critical_band_limit_Hz"]) + assert np.isnan(dict_details["total_tonal_level_dBA"]) + assert np.isnan(dict_details["total_noise_level_dBA"]) + + tonality.process() + + with pytest.raises( + PyAnsysSoundException, + match="Segment index 96 is out of range. It must be between 0 and 95.", + ): + tonality.get_segment_details(segment_index=96) + + +@patch("matplotlib.pyplot.show") +def test_tonality_iso_1996_2_over_time_plot(mock_show): + """Test plot method.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + tonality.process() + + tonality.plot() + + +def test_tonality_iso_1996_2_over_time_plot_exception(): + """Test plot method's exception.""" + wav_loader = LoadWav(pytest.data_path_aircraft_nonUnitaryCalib_in_container) + wav_loader.process() + fc = wav_loader.get_output() + + tonality = TonalityISO1996_2_OverTime(signal=fc[0]) + + with pytest.raises( + PyAnsysSoundException, + match=( + "Output is not processed yet. Use the ``TonalityISO1996_2_OverTime.process\\(\\)`` " + "method." + ), + ): + tonality.plot()