diff --git a/doc/changelog.d/248.added.md b/doc/changelog.d/248.added.md new file mode 100644 index 000000000..f56333aca --- /dev/null +++ b/doc/changelog.d/248.added.md @@ -0,0 +1 @@ +feat: save sound composer project \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fcd3a1da6..c18198815 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "matplotlib>=3.8.2,<4", "platformdirs>=3.6.0", "requests>=2.30.0", + "scipy>=1.15.2", ] [project.optional-dependencies] diff --git a/src/ansys/sound/core/signal_processing/filter.py b/src/ansys/sound/core/signal_processing/filter.py index d27167476..e70b8af9f 100644 --- a/src/ansys/sound/core/signal_processing/filter.py +++ b/src/ansys/sound/core/signal_processing/filter.py @@ -24,9 +24,10 @@ import warnings -from ansys.dpf.core import Field, Operator +from ansys.dpf.core import Field, Operator, TimeFreqSupport, fields_factory, locations import matplotlib.pyplot as plt import numpy as np +import scipy from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning from ..signal_processing import SignalProcessingParent @@ -42,10 +43,14 @@ class Filter(SignalProcessingParent): This class allows designing, loading, and applying a digital filter to a signal. The filter coefficients can be provided directly, using the attributes :attr:`b_coefficients` and :attr:`a_coefficients`, or computed from a specific frequency response function (FRF), using - the methods :meth:`design_FIR_from_FRF` or :meth:`design_FIR_from_FRF_file`. In this latter - case, the filter is designed as a minimum-phase FIR filter, and the filter denominator + the attribute :attr:`frf` or the method :meth:`design_FIR_from_FRF_file`. In this latter case, + the filter is designed as a minimum-phase FIR filter, and the filter denominator (:attr:`a_coefficients`) is set to 1 as a consequence. + Note that only one filter definition source (coefficients, FRF, or FRF file) can be provided + when instantiating the class. After class instantiation, anytime the coefficients are changed, + the FRF is updated accordingly, and vice versa. + Filtering a signal consists in applying the filter coefficients :math:`b[k]` and :math:`a[k]` in the following difference equation, with :math:`x[n]` the input signal, and :math:`y[n]` the output signal: @@ -66,6 +71,7 @@ def __init__( b_coefficients: list[float] = None, a_coefficients: list[float] = None, sampling_frequency: float = 44100.0, + frf: Field = None, file: str = "", signal: Field = None, ): @@ -74,16 +80,21 @@ def __init__( Parameters ---------- a_coefficients : list[float], default: None - Denominator coefficients of the filter. + Denominator coefficients of the filter. This is mutually exclusive with parameters + ``frf`` and ``file``. b_coefficients : list[float], default: None - Numerator coefficients of the filter. + Numerator coefficients of the filter. This is mutually exclusive with parameters ``frf`` + and ``file``. sampling_frequency : float, default: 44100.0 Sampling frequency associated with the filter coefficients, in Hz. + frf : Field, default: None + Frequency response function (FRF) of the filter, in dB. This is mutually exclusive with + parameters ``a_coefficients``, ``b_coefficients``, and ``file``. file : str, default: "" Path to the file containing the frequency response function (FRF) to load. The text file shall have the same text format (with the header `AnsysSound_FRF`), as supported - by Ansys Sound SAS. If ``file`` is specified, parameters ``a_coefficients`` and - ``b_coefficients`` are ignored. + by Ansys Sound SAS. This is mutually exclusive with parameters ``a_coefficients``, + ``b_coefficients``, and ``frf``. signal : Field, default: None Signal to filter. """ @@ -95,18 +106,28 @@ def __init__( self.__sampling_frequency = sampling_frequency - if file != "": - if a_coefficients is not None or b_coefficients is not None: - warnings.warn( - PyAnsysSoundWarning( - "Specified parameters a_coefficients and b_coefficients are ignored " - "because FRF file is also specified." - ) - ) - self.design_FIR_from_FRF_file(file) - else: + # Initialize attributes before processing arguments (because of their mutual dependencies). + self.__a_coefficients = None + self.__b_coefficients = None + self.__frf = None + + # Check which filter definition source (coefficients, FRF, or FRF file) is provided (there + # should be less than 2). + is_coefficients_specified = not (a_coefficients is None and b_coefficients is None) + is_frf_specified = frf is not None + is_frf_file_specified = file != "" + if (is_coefficients_specified + is_frf_specified + is_frf_file_specified) > 1: + raise PyAnsysSoundException( + "Only one filter definition source (coefficients, FRF, or FRF file) must be " + "provided. Specify either `a_coefficients` and `b_coefficients`, `frf`, or `file`." + ) + elif a_coefficients is not None or b_coefficients is not None: self.a_coefficients = a_coefficients self.b_coefficients = b_coefficients + elif frf is not None: + self.frf = frf + elif file != "": + self.design_FIR_from_FRF_file(file) self.signal = signal @@ -142,6 +163,9 @@ def a_coefficients(self, coefficients: list[float]): """Set filter's denominator coefficients.""" self.__a_coefficients = coefficients + # Update the FRF to match the new coefficients (if both are set). + self.__compute_FRF_from_coefficients() + @property def b_coefficients(self) -> list[float]: """Numerator coefficients of the filter's transfer function.""" @@ -152,6 +176,34 @@ def b_coefficients(self, coefficients: list[float]): """Set filter's numerator coefficients.""" self.__b_coefficients = coefficients + # Update the FRF to match the new coefficients (if both are set). + self.__compute_FRF_from_coefficients() + + @property + def frf(self) -> Field: + """Frequency response function (FRF) of the filter. + + Contains the response magnitude in dB of the filter as a function of frequency. + """ + return self.__frf + + @frf.setter + def frf(self, frf: Field): + """Set frequency response function.""" + if frf is not None: + if not (isinstance(frf, Field)): + raise PyAnsysSoundException("Specified FRF must be provided as a DPF field.") + + freq_data = frf.time_freq_support.time_frequencies.data + if len(frf.data) < 2 or len(freq_data) < 2: + raise PyAnsysSoundException( + "Specified FRF must have at least two frequency points." + ) + self.__frf = frf + + # Update coefficients to match the FRF. + self.__compute_coefficients_from_FRF() + @property def signal(self) -> Field: """Input signal.""" @@ -213,61 +265,30 @@ def design_FIR_from_FRF_file(self, file: str): self.__operator_load.run() # Get the output. - frf = self.__operator_load.get_output(0, "field") - - # Compute the filter coefficients. - self.design_FIR_from_FRF(frf) - - def design_FIR_from_FRF(self, frf: Field): - """Design a minimum-phase FIR filter from a frequency response function (FRF). - - Computes the filter coefficients according to the filter sampling frequency and the - provided FRF data. - - .. note:: - If the maximum frequency specified in the FRF extends beyond half the filter sampling - frequency, the FRF data is truncated to this frequency. If, on the contrary, the FRF - maximum frequency is lower than half the filter sampling frequency, the FRF is - zero-padded between the two. - - Parameters - ---------- - frf : Field - Frequency response function (FRF). - """ - # Set operator inputs. - self.__operator_design.connect(0, frf) - self.__operator_design.connect(1, self.__sampling_frequency) - - # Run the operator. - self.__operator_design.run() - - # Get the output. - self.b_coefficients = list(map(float, self.__operator_design.get_output(0, "vec_double"))) - self.a_coefficients = list(map(float, self.__operator_design.get_output(1, "vec_double"))) + self.frf = self.__operator_load.get_output(0, "field") def process(self): """Filter the signal with the current coefficients.""" # Check input signal. if self.signal is None: raise PyAnsysSoundException( - f"Input signal is not set. Use {__class__.__name__}.signal." + f"Input signal is not set. Use `{__class__.__name__}.signal`." ) if self.a_coefficients is None or len(self.a_coefficients) == 0: raise PyAnsysSoundException( "Filter's denominator coefficients (a_coefficients) must be defined and cannot be " - f"empty. Use {__class__.__name__}.a_coefficients, or the methods " - f"{__class__.__name__}.design_FIR_from_FRF() or " - f"{__class__.__name__}.design_FIR_from_FRF_file()." + f"empty. Use `{__class__.__name__}.a_coefficients`, " + f"`{__class__.__name__}.frf`, or the " + f"`{__class__.__name__}.design_FIR_from_FRF_file()` method." ) if self.b_coefficients is None or len(self.b_coefficients) == 0: raise PyAnsysSoundException( "Filter's numerator coefficients (b_coefficients) must be defined and cannot be " - f"empty. Use {__class__.__name__}.b_coefficients, or the methods " - f"{__class__.__name__}.design_FIR_from_FRF() or " - f"{__class__.__name__}.design_FIR_from_FRF_file()." + f"empty. Use `{__class__.__name__}.b_coefficients`, " + f"`{__class__.__name__}.frf`, or the " + f"`{__class__.__name__}.design_FIR_from_FRF_file()` method." ) # Set operator inputs. @@ -293,7 +314,7 @@ def get_output(self) -> Field: warnings.warn( PyAnsysSoundWarning( "Output is not processed yet. " - f"Use the {__class__.__name__}.process() method." + f"Use the `{__class__.__name__}.process()` method." ) ) return self._output @@ -317,7 +338,7 @@ def plot(self): """Plot the filtered signal in a figure.""" if self._output == None: raise PyAnsysSoundException( - f"Output is not processed yet. Use the {__class__.__name__}.process() method." + f"Output is not processed yet. Use the `{__class__.__name__}.process()` method." ) output = self.get_output() @@ -329,3 +350,93 @@ def plot(self): plt.ylabel("Amplitude") plt.grid(True) plt.show() + + def plot_FRF(self): + """Plot the frequency response function (FRF) of the filter.""" + if self.frf is None: + raise PyAnsysSoundException( + "Filter's frequency response function (FRF) is not set. Use " + f"`{__class__.__name__}.frf`, or `{__class__.__name__}.a_coefficients` and " + f"`{__class__.__name__}.b_coefficients`, or the " + f"`{__class__.__name__}.design_FIR_from_FRF_file()` method." + ) + + plt.plot(self.frf.time_freq_support.time_frequencies.data, self.frf.data) + plt.title("Frequency response function (FRF) of the filter") + plt.xlabel("Frequency (Hz)") + plt.ylabel("Magnitude (dB)") + plt.grid(True) + plt.show() + + def __compute_coefficients_from_FRF(self): + """Design a minimum-phase FIR filter from the frequency response function (FRF). + + Computes the filter coefficients according to the filter sampling frequency and the + currently set FRF. + + .. note:: + If the maximum frequency in the FRF extends beyond half the filter sampling frequency, + the FRF data is truncated to this frequency to compute the coefficients. If, on the + contrary, the FRF maximum frequency is lower than half the filter sampling frequency, + the FRF data is zero-padded between the two. + """ + if self.frf is None: + self.__a_coefficients = None + self.__b_coefficients = None + else: + self.__operator_design.connect(0, self.frf) + self.__operator_design.connect(1, self.__sampling_frequency) + + self.__operator_design.run() + + # Bypass the coefficients setters to avoid infinite loops. + self.__b_coefficients = list( + map(float, self.__operator_design.get_output(0, "vec_double")) + ) + self.__a_coefficients = list( + map(float, self.__operator_design.get_output(1, "vec_double")) + ) + + def __compute_FRF_from_coefficients(self): + """Compute the frequency response function (FRF) from the filter coefficients. + + Computes the FRF from the filter coefficients, using the function ``scipy.signal.freqz()``. + If either the numerator or denominator coefficients are empty or not set, the FRF is set to + ``None``. + + .. note:: + The computed FRF length is equal to the number of coefficients in the filter's + numerator. + """ + if ( + self.b_coefficients is None + or self.a_coefficients is None + or len(self.b_coefficients) == 0 + or len(self.a_coefficients) == 0 + ): + self.__frf = None + else: + freq, complex_response = scipy.signal.freqz( + b=self.b_coefficients, + a=self.a_coefficients, + worN=len(self.b_coefficients), + whole=False, + plot=None, + fs=self.__sampling_frequency, + include_nyquist=True, + ) + + f_freq = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_freq.append(freq, 1) + + frf_support = TimeFreqSupport() + frf_support.time_frequencies = f_freq + + # Bypass the FRF setter to avoid infinite loops. + self.__frf = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + self.__frf.append(20 * np.log10(abs(complex_response)), 1) + self.__frf.time_freq_support = frf_support diff --git a/src/ansys/sound/core/sound_composer/sound_composer.py b/src/ansys/sound/core/sound_composer/sound_composer.py index be98512ab..42d2a4113 100644 --- a/src/ansys/sound/core/sound_composer/sound_composer.py +++ b/src/ansys/sound/core/sound_composer/sound_composer.py @@ -21,6 +21,7 @@ # SOFTWARE. """Sound Composer project class.""" +import os import warnings from ansys.dpf.core import Field, FieldsContainer, GenericDataContainersCollection, Operator @@ -58,8 +59,7 @@ def __init__( """ super().__init__() self.__operator_load = Operator(ID_OPERATOR_LOAD) - # Save operator is not implemented yet, because the FRF is not stored in the Filter class. - # self.__operator_save = Operator(ID_OPERATOR_SAVE) + self.__operator_save = Operator(ID_OPERATOR_SAVE) self.tracks = [] @@ -124,30 +124,48 @@ def load(self, project_path: str): track_collection = self.__operator_load.get_output(0, GenericDataContainersCollection) + if len(track_collection) == 0: + warnings.warn( + PyAnsysSoundWarning( + f"The project file `{os.path.basename(project_path)}` does not contain any " + "track." + ) + ) + + self.tracks = [] for i in range(len(track_collection)): track = Track() track.set_from_generic_data_containers(track_collection.get_entry({"track_index": i})) self.add_track(track) - # TODO: Save cannot work for now because the FRF is not stored in the Filter class. - # def save(self, project_path: str): - # """Save the Sound Composer project. + def save(self, project_path: str): + """Save the Sound Composer project. + + Parameters + ---------- + project_path : str + Path and file name (.scn) where the Sound Composer project shall be saved. + """ + if len(self.tracks) == 0: + warnings.warn( + PyAnsysSoundWarning( + "There are no tracks to save. The saved project will be empty. To add tracks " + f"before saving the project, use `{__class__.__name__}.tracks`, " + f"`{__class__.__name__}.add_track()` or `{__class__.__name__}.load()`." + ) + ) - # Parameters - # ---------- - # project_path : str - # Path and file (.scn) name where the Sound Composer project shall be saved. - # """ - # track_collection = GenericDataContainersCollection() + track_collection = GenericDataContainersCollection() + track_collection.add_label("track_index") - # for i, track in enumerate(self.tracks): - # track_collection.add_entry({"track_index": i}, track.get_as_generic_data_container()) + for i, track in enumerate(self.tracks): + track_collection.add_entry({"track_index": i}, track.get_as_generic_data_containers()) - # # Save the Sound Composer project. - # self.__operator_save.connect(0, project_path) - # self.__operator_save.connect(1, track_collection) + # Save the Sound Composer project. + self.__operator_save.connect(0, project_path) + self.__operator_save.connect(1, track_collection) - # self.__operator_save.run() + self.__operator_save.run() def process(self, sampling_frequency: float = 44100.0): """Generate the signal of the current Sound Composer project. @@ -162,7 +180,7 @@ def process(self, sampling_frequency: float = 44100.0): if len(self.tracks) == 0: warnings.warn( PyAnsysSoundWarning( - f"There are no track to process. Use `{__class__.__name__}.tracks`, " + f"There are no tracks to process. Use `{__class__.__name__}.tracks`, " f"`{__class__.__name__}.add_track()` or `{__class__.__name__}.load()`." ) ) diff --git a/src/ansys/sound/core/sound_composer/source_audio.py b/src/ansys/sound/core/sound_composer/source_audio.py index 186e08f86..c08c75f5f 100644 --- a/src/ansys/sound/core/sound_composer/source_audio.py +++ b/src/ansys/sound/core/sound_composer/source_audio.py @@ -164,7 +164,8 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- diff --git a/src/ansys/sound/core/sound_composer/source_broadband_noise.py b/src/ansys/sound/core/sound_composer/source_broadband_noise.py index d840dba28..b9f647247 100644 --- a/src/ansys/sound/core/sound_composer/source_broadband_noise.py +++ b/src/ansys/sound/core/sound_composer/source_broadband_noise.py @@ -242,12 +242,16 @@ def set_from_generic_data_containers( self.source_control = SourceControlTime() control = source_control_data.get_property("sound_composer_source_control_one_parameter") self.source_control.control = control + self.source_control.description = source_control_data.get_property( + "sound_composer_source_control_one_parameter_displayed_string" + ) def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source and source control data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- @@ -278,6 +282,10 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: source_control_data.set_property( "sound_composer_source_control_one_parameter", self.source_control.control ) + source_control_data.set_property( + "sound_composer_source_control_one_parameter_displayed_string", + self.source_control.description, + ) return (source_data, source_control_data) diff --git a/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py b/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py index 04dfc2e1f..ae07ba8d5 100644 --- a/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py +++ b/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py @@ -296,15 +296,22 @@ def set_from_generic_data_containers( control = source_control_data.get_property("sound_composer_source_control_parameter_1") self.source_control1 = SourceControlTime() self.source_control1.control = control + self.source_control1.description = source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string1" + ) control = source_control_data.get_property("sound_composer_source_control_parameter_2") self.source_control2 = SourceControlTime() self.source_control2.control = control + self.source_control2.description = source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string2" + ) def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source and source control data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- @@ -338,6 +345,14 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: source_control_data.set_property( "sound_composer_source_control_parameter_2", self.source_control2.control ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string1", + self.source_control1.description, + ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string2", + self.source_control2.description, + ) return (source_data, source_control_data) diff --git a/src/ansys/sound/core/sound_composer/source_control_time.py b/src/ansys/sound/core/sound_composer/source_control_time.py index 9ee3bbc3d..01a993a50 100644 --- a/src/ansys/sound/core/sound_composer/source_control_time.py +++ b/src/ansys/sound/core/sound_composer/source_control_time.py @@ -92,6 +92,29 @@ def control(self, control: Field): self.__control = control + # Reset the description to store when saving a Sound Composer project (.scn file). + self.description = "Profile created in PyAnsys Sound." + + @property + def description(self) -> str: + """Description of the control profile. + + This description is used when saving a Sound Composer project (.scn file). When loading the + project file in the Sound Composer module of SAS, this description is displayed in the + track's source control tab. + + .. note:: + The description is reset every time the attribute :attr:`control` is modified. + """ + return self.__description + + @description.setter + def description(self, description: str): + """Set the description.""" + if not isinstance(description, str): + raise PyAnsysSoundException("Description must be a string.") + self.__description = description + def load_from_wave_file(self, file_str: str): """Load control data from a WAV file. diff --git a/src/ansys/sound/core/sound_composer/source_harmonics.py b/src/ansys/sound/core/sound_composer/source_harmonics.py index 9e414e9c8..35d6af550 100644 --- a/src/ansys/sound/core/sound_composer/source_harmonics.py +++ b/src/ansys/sound/core/sound_composer/source_harmonics.py @@ -254,12 +254,16 @@ def set_from_generic_data_containers( control = source_control_data.get_property("sound_composer_source_control_one_parameter") self.source_control = SourceControlTime() self.source_control.control = control + self.source_control.description = source_control_data.get_property( + "sound_composer_source_control_one_parameter_displayed_string" + ) def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source and source control data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- @@ -290,6 +294,10 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: source_control_data.set_property( "sound_composer_source_control_one_parameter", self.source_control.control ) + source_control_data.set_property( + "sound_composer_source_control_one_parameter_displayed_string", + self.source_control.description, + ) return (source_data, source_control_data) diff --git a/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py b/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py index 8348b568f..73cf0482f 100644 --- a/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py +++ b/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py @@ -308,15 +308,22 @@ def set_from_generic_data_containers( control = source_control_data.get_property("sound_composer_source_control_parameter_1") self.source_control_rpm = SourceControlTime() self.source_control_rpm.control = control + self.source_control_rpm.description = source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string1" + ) control = source_control_data.get_property("sound_composer_source_control_parameter_2") self.source_control2 = SourceControlTime() self.source_control2.control = control + self.source_control2.description = source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string2" + ) def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source and source control data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- @@ -350,6 +357,14 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: source_control_data.set_property( "sound_composer_source_control_parameter_2", self.source_control2.control ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string1", + self.source_control_rpm.description, + ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string2", + self.source_control2.description, + ) return (source_data, source_control_data) diff --git a/src/ansys/sound/core/sound_composer/source_spectrum.py b/src/ansys/sound/core/sound_composer/source_spectrum.py index d3d1e7bfc..f69018e8b 100644 --- a/src/ansys/sound/core/sound_composer/source_spectrum.py +++ b/src/ansys/sound/core/sound_composer/source_spectrum.py @@ -202,7 +202,8 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: """Get the source and source control data as generic data containers. This method is meant to return the source data as generic data containers, in the format - needed to save a Sound Composer project file (.scn). + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. Returns ------- diff --git a/src/ansys/sound/core/sound_composer/track.py b/src/ansys/sound/core/sound_composer/track.py index 68f1276c6..6196e89c6 100644 --- a/src/ansys/sound/core/sound_composer/track.py +++ b/src/ansys/sound/core/sound_composer/track.py @@ -193,52 +193,55 @@ def set_from_generic_data_containers( if track_data.get_property("track_is_filter") == 1: frequency_response_function = track_data.get_property("track_filter") self.filter = Filter(sampling_frequency=sampling_frequency) - self.filter.design_FIR_from_FRF(frequency_response_function) + self.filter.frf = frequency_response_function else: self.filter = None - # TODO: Save cannot work for now because the FRF is not stored in the Filter class. - # def get_as_generic_data_containers(self) -> GenericDataContainer: - # """Get the track data as a generic data container. - - # This method is meant to return the track data as a generic data container, in the format - # needed to save a Sound Composer project file (.scn). - - # Returns - # ------- - # GenericDataContainer - # Track data as a generic data container. - # """ - # if self.source is None: - # warnings.warn( - # PyAnsysSoundWarning( - # "Cannot create track generic data container because there is no source." - # ) - # ) - # return None - # else: - # # Get source and source control as generic data containers. - # source_data, source_control_data = self.source.get_as_generic_data_containers() - - # # Create a generic data container for the track. - # track_data = GenericDataContainer() - - # # Set track generic data container properties. - # track_data.set_property( - # "track_type", - # [i for i in DICT_SOURCE_TYPE if isinstance(self.source, DICT_SOURCE_TYPE[i])][0], - # ) - # track_data.set_property("track_source", source_data) - # track_data.set_property("track_source_control", source_control_data) - - # if self.filter is not None: - # track_data.set_property("track_is_filter", 1) - # # TODO: sort out the fact that FRF is not stored in the filter object - # track_data.set_property("track_filter", self.filter.FRF) - # else: - # track_data.set_property("track_is_filter", 0) - - # return track_data + def get_as_generic_data_containers(self) -> GenericDataContainer: + """Get the track data as a generic data container. + + This method is meant to return the track data as a generic data container, in the format + needed to save a Sound Composer project file (.scn) with the method + :meth:`SoundComposer.save()`. + + Returns + ------- + GenericDataContainer + Track data as a generic data container. + """ + if self.source is None: + warnings.warn( + PyAnsysSoundWarning( + "Cannot create track generic data container because there is no source." + ) + ) + return None + else: + # Get source and source control as generic data containers. + source_data, source_control_data = self.source.get_as_generic_data_containers() + + # Create a generic data container for the track. + track_data = GenericDataContainer() + + # Set track generic data container properties. + track_data.set_property("track_name", self.name) + track_data.set_property("track_gain", self.gain) + track_data.set_property( + "track_type", + [i for i in DICT_SOURCE_TYPE if isinstance(self.source, DICT_SOURCE_TYPE[i])][0], + ) + if source_data is not None: + track_data.set_property("track_source", source_data) + if source_control_data is not None: + track_data.set_property("track_source_control", source_control_data) + + if self.filter is not None and self.filter.frf is not None: + track_data.set_property("track_is_filter", 1) + track_data.set_property("track_filter", self.filter.frf) + else: + track_data.set_property("track_is_filter", 0) + + return track_data def process(self, sampling_frequency: float = 44100.0): """Generate the signal of the track, using the source and filter currently set. diff --git a/tests/tests_signal_processing/test_signal_processing_filter.py b/tests/tests_signal_processing/test_signal_processing_filter.py index d6b80a060..45b586a9f 100644 --- a/tests/tests_signal_processing/test_signal_processing_filter.py +++ b/tests/tests_signal_processing/test_signal_processing_filter.py @@ -54,6 +54,8 @@ EXP_OUTPUT13536 = -20.87648773 EXP_OUTPUT24189 = 51.00528336 EXP_OUTPUT43544 = -17.25708771 +EXP_FRF1 = 3.521825 +EXP_FRF2 = -6.020600 def test_filter_instantiation_no_arg(): @@ -62,13 +64,14 @@ def test_filter_instantiation_no_arg(): assert isinstance(filter, Filter) assert filter.a_coefficients is None assert filter.b_coefficients is None + assert filter.frf is None assert filter.signal is None def test_filter_instantiation_args(): - """Test Filter instantiation without arguments.""" - # Create a field to use in a suitable Field object (signal). + """Test Filter instantiation with arguments.""" fs = 44100.0 + support = TimeFreqSupport() f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) f_time.append([0, 1 / fs, 2 / fs, 3 / fs], 1) @@ -77,25 +80,117 @@ def test_filter_instantiation_args(): f_signal.append([1, 2, 3, 4], 1) f_signal.time_freq_support = support - # Test instantiation. - with pytest.warns( - PyAnsysSoundWarning, + support = TimeFreqSupport() + f_freq = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_freq.append([0, 11025, 22050], 1) + support.time_frequencies = f_freq + f_frf = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_frf.append([0.1, 1.0, 0.5], 1) + f_frf.time_freq_support = support + + # Instantiation with coefficients. + filter = Filter( + a_coefficients=[1, 2, 3], + b_coefficients=[4, 5, 6], + sampling_frequency=fs, + signal=f_signal, + ) + assert isinstance(filter, Filter) + assert filter.a_coefficients is not None + assert filter.b_coefficients is not None + assert filter.frf is not None + assert filter.signal is not None + + # Instantiation with FRF. + filter = Filter( + sampling_frequency=fs, + file=pytest.data_path_filter_frf, + signal=f_signal, + ) + assert isinstance(filter, Filter) + assert filter.a_coefficients is not None + assert filter.b_coefficients is not None + assert filter.frf is not None + assert filter.signal is not None + + # Instantiation with file. + filter = Filter( + sampling_frequency=fs, + frf=f_frf, + signal=f_signal, + ) + assert isinstance(filter, Filter) + assert filter.a_coefficients is not None + assert filter.b_coefficients is not None + assert filter.frf is not None + assert filter.signal is not None + + +def test_filter_instantiation_exception(): + """Test Filter instantiation's exception.""" + fs = 44100.0 + + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1 / fs, 2 / fs, 3 / fs], 1) + support.time_frequencies = f_time + f_signal = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_signal.append([1, 2, 3, 4], 1) + f_signal.time_freq_support = support + + support = TimeFreqSupport() + f_freq = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_freq.append([0, 11025, 22050], 1) + support.time_frequencies = f_freq + f_frf = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_frf.append([0.1, 1.0, 0.5], 1) + f_frf.time_freq_support = support + + with pytest.raises( + PyAnsysSoundException, match=( - "Specified parameters a_coefficients and b_coefficients are ignored because FRF file " - "is also specified." + "Only one filter definition source \\(coefficients, FRF, or FRF file\\) must be " + "provided. Specify either `a_coefficients` and `b_coefficients`, `frf`, or `file`." ), ): - filter = Filter( + Filter( a_coefficients=[1, 2, 3], b_coefficients=[4, 5, 6], sampling_frequency=fs, file=pytest.data_path_filter_frf, signal=f_signal, ) - assert isinstance(filter, Filter) - assert filter.a_coefficients is not [] - assert filter.b_coefficients is not [] - assert filter.signal is not None + + with pytest.raises( + PyAnsysSoundException, + match=( + "Only one filter definition source \\(coefficients, FRF, or FRF file\\) must be " + "provided. Specify either `a_coefficients` and `b_coefficients`, `frf`, or `file`." + ), + ): + Filter( + a_coefficients=[1, 2, 3], + b_coefficients=[4, 5, 6], + sampling_frequency=fs, + frf=f_frf, + signal=f_signal, + ) + + with pytest.raises( + PyAnsysSoundException, + match=( + "Only one filter definition source \\(coefficients, FRF, or FRF file\\) must be " + "provided. Specify either `a_coefficients` and `b_coefficients`, `frf`, or `file`." + ), + ): + Filter( + a_coefficients=[1, 2, 3], + b_coefficients=[4, 5, 6], + sampling_frequency=fs, + frf=f_frf, + file=pytest.data_path_filter_frf, + signal=f_signal, + ) def test_filter___str__(): @@ -126,6 +221,20 @@ def test_filter_properties(): filter.b_coefficients = [4, 5, 6] assert filter.b_coefficients == [4, 5, 6] + # Test property frf. + support = TimeFreqSupport() + f_freq = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_freq.append([0, 11025, 22050], 1) + support.time_frequencies = f_freq + f_frf = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_frf.append([0.1, 1.0, 0.5], 1) + f_frf.time_freq_support = support + filter.frf = f_frf + assert isinstance(filter.frf, Field) + assert filter.frf.data[0] == pytest.approx(0.1) + assert filter.frf.data[1] == pytest.approx(1.0) + assert filter.frf.data[2] == pytest.approx(0.5) + # Test property signal. support = TimeFreqSupport() f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) @@ -142,6 +251,27 @@ def test_filter_properties_exceptions(): """Test Filter properties' exceptions.""" filter = Filter() + # Test property frf's exception 1 (wrong type). + with pytest.raises( + PyAnsysSoundException, + match="Specified FRF must be provided as a DPF field.", + ): + filter.frf = "WrongType" + + # Test property frf's exception 2 (wrong number of samples). + support = TimeFreqSupport() + f_freq = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_freq.append([0], 1) + support.time_frequencies = f_freq + f_frf = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_frf.append([0], 1) + f_frf.time_freq_support = support + with pytest.raises( + PyAnsysSoundException, + match="Specified FRF must have at least two frequency points.", + ): + filter.frf = f_frf + # Test property signal's exception 1 (wrong type). with pytest.raises( PyAnsysSoundException, @@ -200,22 +330,6 @@ def test_filter_design_FIR_from_FRF_file(): filter.design_FIR_from_FRF_file(file=pytest.data_path_filter_frf_wrong_header) -def test_filter_design_FIR_from_FRF(): - """Test Filter design_FIR_from_FRF method.""" - op = Operator("load_FRF_from_txt") - op.connect(0, pytest.data_path_filter_frf) - op.run() - frf = op.get_output(0, "field") - - filter = Filter() - filter.design_FIR_from_FRF(frf=frf) - assert len(filter.a_coefficients) == 1 - assert filter.a_coefficients[0] == pytest.approx(1.0) - assert filter.b_coefficients[0] == pytest.approx(EXP_B0) - assert filter.b_coefficients[2] == pytest.approx(EXP_B2) - assert filter.b_coefficients[13] == pytest.approx(EXP_B13) - - def test_filter_process(): """Test Filter process method.""" wav_loader = LoadWav(pytest.data_path_flute_nonUnitaryCalib_in_container) @@ -239,7 +353,7 @@ def test_filter_process_exceptions(): # Test process method exception1 (missing signal). with pytest.raises( PyAnsysSoundException, - match="Input signal is not set. Use Filter.signal.", + match="Input signal is not set. Use `Filter.signal`.", ): filter.process() @@ -255,8 +369,8 @@ def test_filter_process_exceptions(): PyAnsysSoundException, match=( "Filter's denominator coefficients \\(a_coefficients\\) must be defined and cannot be " - "empty. Use Filter.a_coefficients, or the methods Filter.design_FIR_from_FRF\\(\\) or " - "Filter.design_FIR_from_FRF_file\\(\\)." + "empty. Use `Filter.a_coefficients`, `Filter.frf`, or the " + "`Filter.design_FIR_from_FRF_file\\(\\)` method." ), ): filter.process() @@ -268,8 +382,8 @@ def test_filter_process_exceptions(): PyAnsysSoundException, match=( "Filter's numerator coefficients \\(b_coefficients\\) must be defined and cannot be " - "empty. Use Filter.b_coefficients, or the methods Filter.design_FIR_from_FRF\\(\\) or " - "Filter.design_FIR_from_FRF_file\\(\\)." + "empty. Use `Filter.b_coefficients`, `Filter.frf`, or the " + "`Filter.design_FIR_from_FRF_file\\(\\)` method." ), ): filter.process() @@ -280,7 +394,7 @@ def test_filter_get_output(): filter = Filter(file=pytest.data_path_filter_frf) with pytest.warns( PyAnsysSoundWarning, - match="Output is not processed yet. Use the Filter.process\\(\\) method.", + match="Output is not processed yet. Use the `Filter.process\\(\\)` method.", ): output = filter.get_output() assert output is None @@ -304,7 +418,7 @@ def test_filter_get_output_as_nparray(): filter = Filter(file=pytest.data_path_filter_frf) with pytest.warns( PyAnsysSoundWarning, - match="Output is not processed yet. Use the Filter.process\\(\\) method.", + match="Output is not processed yet. Use the `Filter.process\\(\\)` method.", ): output = filter.get_output_as_nparray() assert len(output) == 0 @@ -325,7 +439,7 @@ def test_filter_get_output_as_nparray(): @patch("matplotlib.pyplot.show") def test_filter_plot(mock_show): - """Test SourceAudio plot method.""" + """Test Filter plot method.""" wav_loader = LoadWav(pytest.data_path_flute_nonUnitaryCalib_in_container) wav_loader.process() fc_signal = wav_loader.get_output() @@ -336,7 +450,7 @@ def test_filter_plot(mock_show): def test_filter_plot_exception(): - """Test SourceAudio plot method's exception.""" + """Test Filter plot method's exception.""" wav_loader = LoadWav(pytest.data_path_flute_nonUnitaryCalib_in_container) wav_loader.process() fc_signal = wav_loader.get_output() @@ -345,6 +459,65 @@ def test_filter_plot_exception(): with pytest.raises( PyAnsysSoundException, - match="Output is not processed yet. Use the Filter.process\\(\\) method.", + match="Output is not processed yet. Use the `Filter.process\\(\\)` method.", ): filter.plot() + + +@patch("matplotlib.pyplot.show") +def test_filter_plot_FRF(mock_show): + """Test Filter plot_FRF method.""" + filter = Filter(file=pytest.data_path_filter_frf) + filter.plot_FRF() + + +def test_filter_plot_FRF_exception(): + """Test Filter plot method's exception.""" + filter = Filter() + + with pytest.raises( + PyAnsysSoundException, + match=( + "Filter's frequency response function \\(FRF\\) is not set. Use `Filter.frf`, or " + "`Filter.a_coefficients` and `Filter.b_coefficients`, or the " + "`Filter.design_FIR_from_FRF_file\\(\\)` method." + ), + ): + filter.plot_FRF() + + +def test_filter___compute_coefficients_from_FRF(): + """Test Filter's __compute_coefficients_from_FRF method.""" + filter = Filter() + + filter._Filter__compute_coefficients_from_FRF() + assert filter.a_coefficients is None + assert filter.b_coefficients is None + + op = Operator("load_FRF_from_txt") + op.connect(0, pytest.data_path_filter_frf) + op.run() + filter.frf = op.get_output(0, "field") + + filter._Filter__compute_coefficients_from_FRF() + assert len(filter.a_coefficients) == 1 + assert filter.a_coefficients[0] == pytest.approx(1.0) + assert filter.b_coefficients[0] == pytest.approx(EXP_B0) + assert filter.b_coefficients[2] == pytest.approx(EXP_B2) + assert filter.b_coefficients[13] == pytest.approx(EXP_B13) + + +def test_filter___compute_FRF_from_coefficients(): + """Test Filter's __compute_FRF_from_coefficients method.""" + filter = Filter() + + filter._Filter__compute_FRF_from_coefficients() + assert filter.frf is None + + filter.a_coefficients = [1.0] + filter.b_coefficients = [1.0, 0.5] + + filter._Filter__compute_FRF_from_coefficients() + assert len(filter.frf.data) == 2 + assert filter.frf.data[0] == pytest.approx(EXP_FRF1) + assert filter.frf.data[1] == pytest.approx(EXP_FRF2) diff --git a/tests/tests_sound_composer/test_sound_composer_sound_composer.py b/tests/tests_sound_composer/test_sound_composer_sound_composer.py index 28af68bd5..fc7313e9d 100644 --- a/tests/tests_sound_composer/test_sound_composer_sound_composer.py +++ b/tests/tests_sound_composer/test_sound_composer_sound_composer.py @@ -188,6 +188,43 @@ def test_sound_composer_load(): assert sound_composer.tracks[6].filter is None +def test_sound_composer_save(): + """Test SoundComposer save method.""" + sound_composer = SoundComposer( + project_path=pytest.data_path_sound_composer_project_in_container + ) + path_to_save = pytest.temporary_folder + "/test_sound_composer_save.scn" + sound_composer.save(project_path=path_to_save) + + # Check saved project's content against original project. + sound_composer_check = SoundComposer(project_path=path_to_save) + assert len(sound_composer_check.tracks) == len(sound_composer.tracks) + assert isinstance(sound_composer_check.tracks[0], Track) + assert isinstance(sound_composer_check.tracks[0].source, type(sound_composer.tracks[0].source)) + + +def test_sound_composer_save_load_warnings(): + """Test SoundComposer load & save methods' warnings.""" + sound_composer = SoundComposer() + + with pytest.warns( + PyAnsysSoundWarning, + match=( + "There are no tracks to save. The saved project will be empty. To add tracks before " + "saving the project, use `SoundComposer.tracks`, `SoundComposer.add_track\\(\\)` or " + "`SoundComposer.load\\(\\)`." + ), + ): + sound_composer.save(project_path=pytest.temporary_folder + "/test_sound_composer_save.scn") + + with pytest.warns( + PyAnsysSoundWarning, + match="The project file `test_sound_composer_save.scn` does not contain any track.", + ): + sound_composer.load(project_path=pytest.temporary_folder + "/test_sound_composer_save.scn") + assert len(sound_composer.tracks) == 0 + + def test_sound_composer_process(): """Test SoundComposer process method (resample needed).""" sound_composer = SoundComposer( @@ -203,7 +240,7 @@ def test_sound_composer_process_warning(): with pytest.warns( PyAnsysSoundWarning, match=( - "There are no track to process. Use `SoundComposer.tracks`, " + "There are no tracks to process. Use `SoundComposer.tracks`, " "`SoundComposer.add_track\\(\\)` or `SoundComposer.load\\(\\)`." ), ): diff --git a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py index d2cd357b1..d25b343ae 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py +++ b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py @@ -243,6 +243,9 @@ def test_source_broadband_noise_set_from_generic_data_containers(): source_control_data.set_property( "sound_composer_source_control_one_parameter", f_source_control ) + source_control_data.set_property( + "sound_composer_source_control_one_parameter_displayed_string", "test" + ) source_bbn_obj = SourceBroadbandNoise() source_bbn_obj.set_from_generic_data_containers(source_data, source_control_data) @@ -250,6 +253,7 @@ def test_source_broadband_noise_set_from_generic_data_containers(): assert len(source_bbn_obj.source_bbn) == len(fc_data) assert isinstance(source_bbn_obj.source_control, SourceControlTime) assert len(source_bbn_obj.source_control.control.data) == 5 + assert source_bbn_obj.source_control.description == "test" def test_source_broadband_noise_get_as_generic_data_containers(): @@ -276,6 +280,7 @@ def test_source_broadband_noise_get_as_generic_data_containers(): f_source_control.append([1.0, 2.0, 3.0, 4.0, 5.0], 1) source_bbn_obj.source_control = SourceControlTime() source_bbn_obj.source_control.control = f_source_control + source_bbn_obj.source_control.description = "test" with pytest.warns( PyAnsysSoundWarning, match="Cannot create source generic data container because there is no source data.", @@ -295,6 +300,12 @@ def test_source_broadband_noise_get_as_generic_data_containers(): assert isinstance( source_control_data.get_property("sound_composer_source_control_one_parameter"), Field ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_one_parameter_displayed_string" + ) + == "test" + ) def test_source_broadband_noise_process(): diff --git a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py index 4b1140173..43b1e10fc 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py +++ b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py @@ -303,6 +303,12 @@ def test_source_broadband_noise_two_parameters_set_from_generic_data_containers( source_control_data = GenericDataContainer() source_control_data.set_property("sound_composer_source_control_parameter_1", f_source_control) source_control_data.set_property("sound_composer_source_control_parameter_2", f_source_control) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string1", "test1" + ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string2", "test2" + ) source_bbn_2p_obj = SourceBroadbandNoiseTwoParameters() source_bbn_2p_obj.set_from_generic_data_containers(source_data, source_control_data) @@ -312,6 +318,8 @@ def test_source_broadband_noise_two_parameters_set_from_generic_data_containers( assert len(source_bbn_2p_obj.source_control1.control.data) == 5 assert isinstance(source_bbn_2p_obj.source_control2, SourceControlTime) assert len(source_bbn_2p_obj.source_control2.control.data) == 5 + assert source_bbn_2p_obj.source_control1.description == "test1" + assert source_bbn_2p_obj.source_control2.description == "test2" def test_source_broadband_noise_two_parameters_get_as_generic_data_containers(): @@ -339,7 +347,11 @@ def test_source_broadband_noise_two_parameters_get_as_generic_data_containers(): source_control_obj = SourceControlTime() source_control_obj.control = f_source_control source_bbn_2p_obj.source_control1 = source_control_obj + source_bbn_2p_obj.source_control1.description = "test1" + source_control_obj = SourceControlTime() + source_control_obj.control = f_source_control source_bbn_2p_obj.source_control2 = source_control_obj + source_bbn_2p_obj.source_control2.description = "test2" with pytest.warns( PyAnsysSoundWarning, match="Cannot create source generic data container because there is no source data.", @@ -362,6 +374,18 @@ def test_source_broadband_noise_two_parameters_get_as_generic_data_containers(): assert isinstance( source_control_data.get_property("sound_composer_source_control_parameter_2"), Field ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string1" + ) + == "test1" + ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string2" + ) + == "test2" + ) def test_source_broadband_noise_two_parameters_process(): diff --git a/tests/tests_sound_composer/test_sound_composer_source_control_time.py b/tests/tests_sound_composer/test_sound_composer_source_control_time.py index 01ddff1ea..495e6ec60 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_control_time.py +++ b/tests/tests_sound_composer/test_sound_composer_source_control_time.py @@ -61,10 +61,15 @@ def test_source_control_time_properties(): loader.process() control = loader.get_output()[0] - # Test control setter. + # Test control property. control_time.control = control assert isinstance(control_time.control, Field) assert len(control_time.control.data) > 0 + assert control_time.description == "Profile created in PyAnsys Sound." + + # Test description property. + control_time.description = "Test description." + assert control_time.description == "Test description." def test_source_control_time_properties_exceptions(): @@ -78,6 +83,10 @@ def test_source_control_time_properties_exceptions(): ): control_time.control = "WrongType" + # Test description setter exception (wrong description type). + with pytest.raises(PyAnsysSoundException, match="Description must be a string."): + control_time.description = 1 + def test_source_control_time___str__(): """Test SourceControlTime __str__ method.""" diff --git a/tests/tests_sound_composer/test_sound_composer_source_harmonics.py b/tests/tests_sound_composer/test_sound_composer_source_harmonics.py index 3c40a4957..9ce9b629d 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_harmonics.py +++ b/tests/tests_sound_composer/test_sound_composer_source_harmonics.py @@ -314,6 +314,9 @@ def test_source_harmonics_set_from_generic_data_containers(): source_control_data.set_property( "sound_composer_source_control_one_parameter", f_source_control ) + source_control_data.set_property( + "sound_composer_source_control_one_parameter_displayed_string", "test" + ) source_harmo_obj = SourceHarmonics() source_harmo_obj.set_from_generic_data_containers(source_data, source_control_data) @@ -321,6 +324,7 @@ def test_source_harmonics_set_from_generic_data_containers(): assert len(source_harmo_obj.source_harmonics) == len(fc_data) assert isinstance(source_harmo_obj.source_control, SourceControlTime) assert len(source_harmo_obj.source_control.control.data) == 5 + assert source_harmo_obj.source_control.description == "test" def test_source_harmonics_get_as_generic_data_containers(): @@ -348,6 +352,7 @@ def test_source_harmonics_get_as_generic_data_containers(): source_control_obj = SourceControlTime() source_control_obj.control = f_source_control source_harmo_obj.source_control = source_control_obj + source_harmo_obj.source_control.description = "test" with pytest.warns( PyAnsysSoundWarning, match="Cannot create source generic data container because there is no source data.", @@ -367,6 +372,12 @@ def test_source_harmonics_get_as_generic_data_containers(): assert isinstance( source_control_data.get_property("sound_composer_source_control_one_parameter"), Field ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_one_parameter_displayed_string" + ) + == "test" + ) def test_source_harmonics_process(): diff --git a/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py b/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py index 5d1251ce1..4a6c51f35 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py +++ b/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py @@ -349,6 +349,12 @@ def test_source_harmonics_two_parameters_set_from_generic_data_containers(): source_control_data = GenericDataContainer() source_control_data.set_property("sound_composer_source_control_parameter_1", f_source_control) source_control_data.set_property("sound_composer_source_control_parameter_2", f_source_control) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string1", "test1" + ) + source_control_data.set_property( + "sound_composer_source_control_two_parameter_displayed_string2", "test2" + ) source_harmo_2p_obj = SourceHarmonicsTwoParameters() source_harmo_2p_obj.set_from_generic_data_containers(source_data, source_control_data) @@ -358,6 +364,8 @@ def test_source_harmonics_two_parameters_set_from_generic_data_containers(): assert len(source_harmo_2p_obj.source_control_rpm.control.data) == 5 assert isinstance(source_harmo_2p_obj.source_control2, SourceControlTime) assert len(source_harmo_2p_obj.source_control2.control.data) == 5 + assert source_harmo_2p_obj.source_control_rpm.description == "test1" + assert source_harmo_2p_obj.source_control2.description == "test2" def test_source_harmonics_two_parameters_get_as_generic_data_containers(): @@ -385,7 +393,11 @@ def test_source_harmonics_two_parameters_get_as_generic_data_containers(): source_control_obj = SourceControlTime() source_control_obj.control = f_source_control source_harmo_2p_obj.source_control_rpm = source_control_obj + source_harmo_2p_obj.source_control_rpm.description = "test1" + source_control_obj = SourceControlTime() + source_control_obj.control = f_source_control source_harmo_2p_obj.source_control2 = source_control_obj + source_harmo_2p_obj.source_control2.description = "test2" with pytest.warns( PyAnsysSoundWarning, match="Cannot create source generic data container because there is no source data.", @@ -408,6 +420,18 @@ def test_source_harmonics_two_parameters_get_as_generic_data_containers(): assert isinstance( source_control_data.get_property("sound_composer_source_control_parameter_2"), Field ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string1" + ) + == "test1" + ) + assert ( + source_control_data.get_property( + "sound_composer_source_control_two_parameter_displayed_string2" + ) + == "test2" + ) def test_source_harmonics_two_parameters_process(): diff --git a/tests/tests_sound_composer/test_sound_composer_track.py b/tests/tests_sound_composer/test_sound_composer_track.py index fcb95123c..a212fd761 100644 --- a/tests/tests_sound_composer/test_sound_composer_track.py +++ b/tests/tests_sound_composer/test_sound_composer_track.py @@ -180,6 +180,76 @@ def test_track_set_from_generic_data_containers(): assert isinstance(track.filter, Filter) +def test_track_get_as_generic_data_containers(): + """Test Track get_as_generic_data_containers method.""" + # Create a source and a source control. + source_control = SourceControlSpectrum(duration=3.0, method=1) + source = SourceSpectrum( + file_source=pytest.data_path_sound_composer_spectrum_source_in_container, + source_control=source_control, + ) + + # Create a track (no filter). + track = Track( + name="My track", + gain=15.6, + source=source, + ) + + # Test method get_as_generic_data_containers. + track_data = track.get_as_generic_data_containers() + assert set(track_data.get_property_description()) == { + "track_name", + "track_gain", + "track_type", + "track_source", + "track_source_control", + "track_is_filter", + } + assert track_data.get_property("track_name") == "My track" + assert track_data.get_property("track_gain") == 15.6 + assert track_data.get_property("track_type") == 5 + assert isinstance(track_data.get_property("track_source"), GenericDataContainer) + assert isinstance(track_data.get_property("track_source_control"), GenericDataContainer) + assert track_data.get_property("track_is_filter") == 0 + + # Add a filter to the track. + track.filter = Filter(a_coefficients=[1.0], b_coefficients=[1.0, 0.5]) + + # Test method get_as_generic_data_containers again. + track_data = track.get_as_generic_data_containers() + assert set(track_data.get_property_description()) == { + "track_name", + "track_gain", + "track_type", + "track_source", + "track_source_control", + "track_is_filter", + "track_filter", + } + assert track_data.get_property("track_name") == "My track" + assert track_data.get_property("track_gain") == 15.6 + assert track_data.get_property("track_type") == 5 + assert isinstance(track_data.get_property("track_source"), GenericDataContainer) + assert isinstance(track_data.get_property("track_source_control"), GenericDataContainer) + assert track_data.get_property("track_is_filter") == 1 + assert isinstance(track_data.get_property("track_filter"), Field) + + +def test_track_get_as_generic_data_containers_warning(): + """Test Track get_as_generic_data_containers method's warning.""" + # Create a track. + track = Track() + + # Test method get_as_generic_data_containers. + with pytest.warns( + PyAnsysSoundWarning, + match="Cannot create track generic data container because there is no source.", + ): + track_data = track.get_as_generic_data_containers() + assert track_data is None + + def test_track_process(): """Test Track process method (no resample needed).""" track = Track(