From 23e72a3d6928c5823b4d106e44d8a00efc8f3af0 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Fri, 6 Jun 2025 18:25:14 +0200 Subject: [PATCH 01/10] Implement sound class and use in LoadWav, CropSignal and ISO532-1 --- examples/XXX_test_sound_classes.py | 50 +++++ .../sound/core/data_management/__init__.py | 32 +++ src/ansys/sound/core/data_management/sound.py | 201 ++++++++++++++++++ .../loudness_iso_532_1_stationary.py | 18 +- .../core/signal_utilities/crop_signal.py | 20 +- .../sound/core/signal_utilities/load_wav.py | 7 +- 6 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 examples/XXX_test_sound_classes.py create mode 100644 src/ansys/sound/core/data_management/__init__.py create mode 100644 src/ansys/sound/core/data_management/sound.py diff --git a/examples/XXX_test_sound_classes.py b/examples/XXX_test_sound_classes.py new file mode 100644 index 000000000..8b9e132fb --- /dev/null +++ b/examples/XXX_test_sound_classes.py @@ -0,0 +1,50 @@ +# 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 ansys.sound.core.psychoacoustics.loudness_iso_532_1_stationary import ( + LoudnessISO532_1_Stationary, +) +from ansys.sound.core.server_helpers._connect_to_or_start_server import connect_to_or_start_server +from ansys.sound.core.signal_utilities import CropSignal, LoadWav + +server = connect_to_or_start_server(use_license_context=True) + +loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/Acceleration_stereo_84dBSPL.wav") +loader.process() +sound = loader.get_output() +print(sound) +loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/flute.wav") +loader.process() +sound = loader.get_output() +print(sound) +cropper = CropSignal(sound, start_time=0.0, end_time=1.0) +cropper.process() +cropped = cropper.get_output() +loudness = LoudnessISO532_1_Stationary(signal=cropped) +loudness.process() +print(f"Loudness: {loudness.get_loudness_sone()}") +loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/Acceleration_stereo_84dBSPL.wav") +loader.process() +sound = loader.get_output() +print(sound) +channels = sound.split_channels() +print(channels) diff --git a/src/ansys/sound/core/data_management/__init__.py b/src/ansys/sound/core/data_management/__init__.py new file mode 100644 index 000000000..b23c5e654 --- /dev/null +++ b/src/ansys/sound/core/data_management/__init__.py @@ -0,0 +1,32 @@ +# 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. + +"""PyAnsys Sound data classes.""" +from .sound import Sound, convert_to_sound + +# from .utilities import convert_type + +__all__ = ( + "Sound", + # "convert_type", + "convert_to_sound", +) diff --git a/src/ansys/sound/core/data_management/sound.py b/src/ansys/sound/core/data_management/sound.py new file mode 100644 index 000000000..34364ff3f --- /dev/null +++ b/src/ansys/sound/core/data_management/sound.py @@ -0,0 +1,201 @@ +# 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. + +"""PyAnsys Sound class to store sound data.""" + +from ansys.dpf.core import Field, FieldsContainer +import numpy as np + +from .._pyansys_sound import PyAnsysSoundException + + +class Sound(FieldsContainer): + """PyAnsys Sound class to store sound data.""" + + def __init__( + self, + ): + """TODO.""" + super().__init__() + + # def __getitem__(self, index: int) -> "Sound": + # """Get a channel from the sound data.""" + # if index < 0 or index >= len(self): + # raise PyAnsysSoundException("Channel index is out of range.") + # return Sound.create(super().__getitem__(index)) + + @property + def channel_count(self) -> int: + """Number of channels in the sound data.""" + return len(self) + + @property + def time(self) -> np.ndarray: + """Sampling frequency in Hz.""" + return np.ndarray(self[0].time_freq_support.time_frequencies.data) + + @property + def fs(self) -> Field: + """Sampling frequency in Hz.""" + return 1 / (self.time[1] - self.time[0]) + + def update(self) -> None: + """Update the sound data.""" + # Nothing to update here for now + # TODO: + # - check at least one field is present + # - check all fields are the same size and have the same time frequency support + # - check support is regularly spaced + pass + + def get_as_nparray(self) -> list[np.ndarray]: + """Get the sound data as a NumPy array.""" + return np.array(self.data) + + @staticmethod + def create(object: Field | FieldsContainer) -> "Sound": + """TODO.""" + if isinstance(object, FieldsContainer): + object.__class__ = Sound + object.update() + return object + elif isinstance(object, Field): + sound = Sound() + sound.labels = ["channel_number"] + sound.add_field({"channel_number": 0}, object) + return sound + + def split_channels(self) -> list["Sound"]: + """Split a multichannel Sound object into a list of Sound objects, one for each channel.""" + channels = [] + for channel in self: + channels.append(Sound.create(channel)) + + return channels + + +# class Sound_tmp(Field): +# """PyAnsys Sound class to store sound data. +# """ + +# def __init__( +# self, +# field: Field = None, +# sound_pressure: list[float] | np.ndarray = None, +# fs: float = None, +# time: list[float] | np.ndarray = None, +# ): +# """Class instantiation takes the following parameters. + +# Parameters +# ---------- +# field : Field, default: None +# DPF field containing the sound data. +# sound_pressure : list[float] | numpy.ndarray, default: None +# Sound pressure values in Pa. +# fs : float, default: None +# Sampling frequency in Hz. +# time : list[float] | numpy.ndarray, default: None +# Time values in seconds. +# """ +# super().__init__() +# if field is not None: +# if any(arg is not None for arg in (sound_pressure, fs, time)): +# warnings.warn( +# PyAnsysSoundWarning( +# "When a DPF field is specified as input, other inputs are ignored." +# ) +# ) +# self.data = field.data +# else: +# if sound_pressure is None: +# raise PyAnsysSoundException( +# "Either a DPF field or sound_pressure must be specified when creating a " +# "Sound object." +# ) +# self.data = sound_pressure +# self.time_freq_support = TimeFreqSupport() +# self.time_freq_support.time_frequencies = fields_factory.create_scalar_field( +# num_entities=1, location=locations.time_freq +# ) +# if time is not None: +# if fs is not None: +# warnings.warn( +# PyAnsysSoundWarning( +# "When time is specified, fs is ignored. " +# "The sampling frequency is inferred from the time values." +# ) +# ) +# self.time_freq_support.time_frequencies.append(time, 1) +# else: +# if fs is None: +# raise PyAnsysSoundException( +# "Either time or fs must be specified when creating a Sound object." +# ) +# self.time_freq_support.time_frequencies.append( +# np.arange(0, len(sound_pressure) / fs, 1 / fs), 1 +# ) + + +# @property +# def time(self) -> np.ndarray: +# """Sampling frequency in Hz.""" +# return np.ndarray(self.time_freq_support.time_frequencies.data) + +# @property +# def fs(self) -> Field: +# """Sampling frequency in Hz.""" +# return 1 / (self.time[1] - self.time[0]) + +# def update(self) -> None: +# """Update the sound data.""" +# # Nothing to update here +# pass + +# def get_as_nparray(self) -> list[np.ndarray]: +# """Get the sound data as a NumPy array.""" +# return np.array(self.data) + + +def convert_to_sound(object: Field | FieldsContainer) -> Sound: + """TODO.""" + if isinstance(object, FieldsContainer): + object.__class__ = Sound + object.update() + return object + elif isinstance(object, Field): + sound = Sound() + sound.labels = ["channel_number"] + sound.add_field({"channel_number": 0}, object) + return sound + + +def split_channels(sound: Sound) -> list[Sound]: + """Split a multichannel Sound object into a list of Sound objects, one for each channel.""" + if not isinstance(sound, Sound): + raise PyAnsysSoundException("Input must be a Sound object.") + + channels = [] + for channel in sound: + channels.append(convert_to_sound(channel)) + + return channels diff --git a/src/ansys/sound/core/psychoacoustics/loudness_iso_532_1_stationary.py b/src/ansys/sound/core/psychoacoustics/loudness_iso_532_1_stationary.py index ac67d009c..cdb10b1c3 100644 --- a/src/ansys/sound/core/psychoacoustics/loudness_iso_532_1_stationary.py +++ b/src/ansys/sound/core/psychoacoustics/loudness_iso_532_1_stationary.py @@ -23,12 +23,13 @@ """Computes ISO 532-1 loudness for stationary sounds.""" import warnings -from ansys.dpf.core import Field, Operator, types +from ansys.dpf.core import Field, FieldsContainer, Operator, types import matplotlib.pyplot as plt import numpy as np from . import FIELD_DIFFUSE, FIELD_FREE, PsychoacousticsParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import Sound # Name of the DPF Sound operator used in this module. ID_COMPUTE_LOUDNESS_ISO_STATIONARY = "compute_loudness_iso532_1" @@ -43,7 +44,7 @@ class LoudnessISO532_1_Stationary(PsychoacousticsParent): def __init__( self, - signal: Field = None, + signal: Field | Sound = None, field_type: str = FIELD_FREE, ): """Class instantiation takes the following parameters. @@ -61,15 +62,18 @@ def __init__( self.__operator = Operator(ID_COMPUTE_LOUDNESS_ISO_STATIONARY) @property - def signal(self) -> Field: + def signal(self) -> Sound: """Input signal in Pa.""" return self.__signal @signal.setter - def signal(self, signal: Field): + def signal(self, signal: Sound): """Set the signal.""" - if not (isinstance(signal, Field) or signal is None): - raise PyAnsysSoundException("Signal must be specified as a DPF field.") + if signal is not None: + if not isinstance(signal, FieldsContainer): + raise PyAnsysSoundException("Signal must be specified as Sound object.") + if len(signal) > 1: + raise PyAnsysSoundException("Signal must be mono.") self.__signal = signal @property @@ -101,7 +105,7 @@ def process(self): "Use `LoudnessISO532_1_Stationary.signal`." ) - self.__operator.connect(0, self.signal) + self.__operator.connect(0, self.signal[0]) self.__operator.connect(1, self.field_type) # Runs the operator diff --git a/src/ansys/sound/core/signal_utilities/crop_signal.py b/src/ansys/sound/core/signal_utilities/crop_signal.py index 9bbfced29..690e09fc1 100644 --- a/src/ansys/sound/core/signal_utilities/crop_signal.py +++ b/src/ansys/sound/core/signal_utilities/crop_signal.py @@ -26,15 +26,18 @@ from ansys.dpf.core import Field, FieldsContainer, Operator import numpy as np +from ansys.sound.core.data_management.sound import convert_to_sound + from . import SignalUtilitiesParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import Sound class CropSignal(SignalUtilitiesParent): """Crops a signal.""" def __init__( - self, signal: Field | FieldsContainer = None, start_time: float = 0.0, end_time: float = 0.0 + self, signal: Sound | FieldsContainer = None, start_time: float = 0.0, end_time: float = 0.0 ): """Class instantiation takes the following parameters. @@ -82,12 +85,12 @@ def end_time(self, new_end: float): self.__end_time = new_end @property - def signal(self) -> Field | FieldsContainer: + def signal(self) -> Sound | FieldsContainer: """Input signal as a DPF field or fields container.""" return self.__signal @signal.setter - def signal(self, signal: Field | FieldsContainer): + def signal(self, signal: Sound | FieldsContainer): """Set the signal.""" self.__signal = signal @@ -110,12 +113,13 @@ def process(self): self.__operator.run() # Stores output in the variable - if type(self.signal) == FieldsContainer: - self._output = self.__operator.get_output(0, "fields_container") - elif type(self.signal) == Field: + if isinstance(self.signal, FieldsContainer): + self._output = convert_to_sound(self.__operator.get_output(0, "fields_container")) + elif isinstance(self.signal, Field): self._output = self.__operator.get_output(0, "field") + self._output.__class__ = Sound - def get_output(self) -> FieldsContainer | Field: + def get_output(self) -> FieldsContainer | Sound: """Get the cropped signal as a DPF fields container. Returns @@ -144,7 +148,7 @@ def get_output_as_nparray(self) -> np.ndarray: """ output = self.get_output() - if type(output) == Field: + if isinstance(self.signal, Field): return output.data return self.convert_fields_container_to_np_array(output) diff --git a/src/ansys/sound/core/signal_utilities/load_wav.py b/src/ansys/sound/core/signal_utilities/load_wav.py index 0b48d40e6..4bc8b9d5d 100644 --- a/src/ansys/sound/core/signal_utilities/load_wav.py +++ b/src/ansys/sound/core/signal_utilities/load_wav.py @@ -29,6 +29,7 @@ from . import SignalUtilitiesParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import Sound, convert_to_sound class LoadWav(SignalUtilitiesParent): @@ -80,9 +81,11 @@ def process(self): self.__operator.run() # Store output in the variable - self._output = self.__operator.get_output(0, "fields_container") + tmp = self.__operator.get_output(0, "fields_container") + self._output = convert_to_sound(tmp) + # self._output = convert_to_sound(self.__operator.get_output(0, "fields_container")) - def get_output(self) -> FieldsContainer: + def get_output(self) -> FieldsContainer | Sound: """Get the signal loaded from the WAV file as a DPF fields container. Returns From f0b936cadde35fe79561143d280a58648868f9fb Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Thu, 12 Jun 2025 16:41:49 +0200 Subject: [PATCH 02/10] PSD & STFT --- examples/XXX_test_sound_classes.py | 34 ++++-- .../sound/core/data_management/__init__.py | 4 + src/ansys/sound/core/data_management/psd2.py | 100 ++++++++++++++++++ .../sound/core/data_management/sonagram.py | 95 +++++++++++++++++ src/ansys/sound/core/data_management/sound.py | 60 +++++++++-- .../sound/core/examples_helpers/download.py | 5 +- .../psychoacoustics/tone_to_noise_ratio.py | 3 +- .../_connect_to_or_start_server.py | 10 +- .../sound/core/signal_utilities/load_wav.py | 4 +- .../power_spectral_density.py | 3 +- .../sound/core/spectrogram_processing/stft.py | 31 +++--- 11 files changed, 306 insertions(+), 43 deletions(-) create mode 100644 src/ansys/sound/core/data_management/psd2.py create mode 100644 src/ansys/sound/core/data_management/sonagram.py diff --git a/examples/XXX_test_sound_classes.py b/examples/XXX_test_sound_classes.py index 8b9e132fb..969b0daf9 100644 --- a/examples/XXX_test_sound_classes.py +++ b/examples/XXX_test_sound_classes.py @@ -23,28 +23,48 @@ from ansys.sound.core.psychoacoustics.loudness_iso_532_1_stationary import ( LoudnessISO532_1_Stationary, ) +from ansys.sound.core.psychoacoustics.tone_to_noise_ratio import ToneToNoiseRatio from ansys.sound.core.server_helpers._connect_to_or_start_server import connect_to_or_start_server from ansys.sound.core.signal_utilities import CropSignal, LoadWav +from ansys.sound.core.spectral_processing.power_spectral_density import PowerSpectralDensity +from ansys.sound.core.spectrogram_processing.stft import Stft server = connect_to_or_start_server(use_license_context=True) -loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/Acceleration_stereo_84dBSPL.wav") +loader = LoadWav( + "C:/ANSYSDev/PyDev/pyansys-sound/tests/data/Acceleration_stereo_nonUnitaryCalib.wav" +) loader.process() sound = loader.get_output() print(sound) +# sound.plot() +channels = sound.split_channels() +print(channels) loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/flute.wav") loader.process() sound = loader.get_output() print(sound) +# sound.plot() cropper = CropSignal(sound, start_time=0.0, end_time=1.0) cropper.process() cropped = cropper.get_output() +# cropped.plot() loudness = LoudnessISO532_1_Stationary(signal=cropped) loudness.process() print(f"Loudness: {loudness.get_loudness_sone()}") -loader = LoadWav("C:/ANSYSDev/PyDev/pyansys-sound/tests/data/Acceleration_stereo_84dBSPL.wav") -loader.process() -sound = loader.get_output() -print(sound) -channels = sound.split_channels() -print(channels) + +psder = PowerSpectralDensity(input_signal=sound) +psder.process() +psd = psder.get_output() +print(type(psd)) +print(psd) + +tnrer = ToneToNoiseRatio(psd=psd) +tnrer.process() +# tnrer.plot() + +stfter = Stft(signal=sound, fft_size=1024, window_type="HANN", window_overlap=0.5) +stfter.process() +stft = stfter.get_output() +print(stft.time) +print(stft.frequencies) diff --git a/src/ansys/sound/core/data_management/__init__.py b/src/ansys/sound/core/data_management/__init__.py index b23c5e654..782c8db2e 100644 --- a/src/ansys/sound/core/data_management/__init__.py +++ b/src/ansys/sound/core/data_management/__init__.py @@ -21,12 +21,16 @@ # SOFTWARE. """PyAnsys Sound data classes.""" +from .psd2 import PSD +from .sonagram import Sonagram from .sound import Sound, convert_to_sound # from .utilities import convert_type __all__ = ( "Sound", + "PSD", + "Sonagram", # "convert_type", "convert_to_sound", ) diff --git a/src/ansys/sound/core/data_management/psd2.py b/src/ansys/sound/core/data_management/psd2.py new file mode 100644 index 000000000..21fca907b --- /dev/null +++ b/src/ansys/sound/core/data_management/psd2.py @@ -0,0 +1,100 @@ +# 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. + +"""PyAnsys Sound class to store sound data.""" +from ansys.dpf.core import Field +import matplotlib.pyplot as plt +import numpy as np + +from .._pyansys_sound import PyAnsysSoundException + + +class PSD(Field): + """PyAnsys Sound class to store sound data. + + Add Nfft, window type, and overlap as ppts, but then probably redundant with + PowerSpectralDensity. + """ + + def __init__( + self, + ): + """TODO.""" + super().__init__() + + def __str__(self): + """Return the string representation of the object.""" + if len(self.frequencies) > 0: + properties_str = ( + f":\n\tFrequency resolution: {self.delta_f:.2f} Hz" + f"\n\tMaximum frequency: {self.f_max:.1f} Hz" + ) + else: + properties_str = "" + return f"PSD object{properties_str}" + + @property + def frequencies(self) -> np.ndarray: + """Array of frequencies in Hz where the PSD is defined.""" + return np.array(self.time_freq_support.time_frequencies.data) + + @property + def delta_f(self) -> float: + """Frequency resolution in Hz of the PSD.""" + if len(self.frequencies) < 2: + raise PyAnsysSoundException( + "Not enough frequency points to determine frequency resolution." + ) + + return self.frequencies[1] - self.frequencies[0] + + @property + def f_max(self) -> float: + """Maximum frequency in Hz.""" + return self.frequencies[-1] + + def update(self) -> None: + """Update the sound data.""" + # Nothing to update here for now + # TODO: + # - check support is regularly spaced + pass + + def get_as_nparray(self) -> list[np.ndarray]: + """Get the sound data as a NumPy array.""" + return (np.array(self.data), self.frequencies) + + @classmethod + def create(cls, object: Field) -> "PSD": + """TODO.""" + object.__class__ = cls + object.update() + return object + + def plot(self): + """Plot the PSD data.""" + plt.plot(self.time_freq_support.time_frequencies.data, self.data) + + plt.xlabel("Frequency (Hz)") + plt.ylabel("Amplitude (dB/Hz)") + plt.title(self.name) + plt.show() diff --git a/src/ansys/sound/core/data_management/sonagram.py b/src/ansys/sound/core/data_management/sonagram.py new file mode 100644 index 000000000..9566f3553 --- /dev/null +++ b/src/ansys/sound/core/data_management/sonagram.py @@ -0,0 +1,95 @@ +# 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. + +"""PyAnsys Sound class to store sound data.""" +from ansys.dpf.core import FieldsContainer +import numpy as np + +from .._pyansys_sound import PyAnsysSoundException + + +class Sonagram(FieldsContainer): + """PyAnsys Sound class to store sound data. + + Probably redundant with Stft, maybe not worth it + add Nfft, window type, and overlap as ppts + add specific getters for magnitude & phase + """ + + def __init__( + self, + ): + """TODO.""" + super().__init__() + + def __str__(self): + """Return the string representation of the object.""" + return ( + f"Sonagram object:" + f"\n\tDuration: {self.duration:.2f} s" + f"\n\tMaximum frequency: {self.f_max:.1f} Hz" + ) + + @property + def frequencies(self) -> np.ndarray: + """Array of frequencies in Hz where the PSD is defined.""" + if len(self) == 0: + raise PyAnsysSoundException("Empty sonagram. Cannot retrieve frequency array.") + + return np.array(self[0].time_freq_support.time_frequencies.data) + + @property + def time(self) -> np.ndarray: + """Array of times in s where the sound is defined.""" + return np.array(self.time_freq_support.time_frequencies.data) + + @property + def fs(self) -> float: + """Sampling frequency in Hz.""" + if len(self.time) < 2: + raise PyAnsysSoundException("Not enough time points to determine sampling frequency.") + + return 1 / (self.time[1] - self.time[0]) + + def update(self) -> None: + """Update the sound data.""" + # Nothing to update here for now + # TODO: + # - check at least one field is present + # - check all fields are the same size and have the same time frequency support + # - check support is regularly spaced + pass + + def get_as_nparray(self) -> list[np.ndarray]: + """Get the sound data as a NumPy array.""" + return np.array(self.data) + + @classmethod + def create(cls, object: FieldsContainer) -> "Sonagram": + """TODO.""" + object.__class__ = cls + object.update() + return object + + def plot(self): + """Plot the sound data.""" + pass diff --git a/src/ansys/sound/core/data_management/sound.py b/src/ansys/sound/core/data_management/sound.py index 34364ff3f..aaf243e55 100644 --- a/src/ansys/sound/core/data_management/sound.py +++ b/src/ansys/sound/core/data_management/sound.py @@ -21,8 +21,8 @@ # SOFTWARE. """PyAnsys Sound class to store sound data.""" - from ansys.dpf.core import Field, FieldsContainer +import matplotlib.pyplot as plt import numpy as np from .._pyansys_sound import PyAnsysSoundException @@ -37,6 +37,18 @@ def __init__( """TODO.""" super().__init__() + def __str__(self): + """Return the string representation of the object.""" + if self.channel_count > 0: + properties_str = ( + f":\n\tsampling frequency: {self.fs:.1f} Hz" f"\n\tDuration: {self.duration:.2f} s" + ) + else: + properties_str = "" + return f"Sound object with {self.channel_count} channels{properties_str}" + + # This is actually not a good idea as other classes need Fields as input, while Sound will + # always be returned homgeneously to a FieldsContainer. # def __getitem__(self, index: int) -> "Sound": # """Get a channel from the sound data.""" # if index < 0 or index >= len(self): @@ -50,14 +62,29 @@ def channel_count(self) -> int: @property def time(self) -> np.ndarray: - """Sampling frequency in Hz.""" - return np.ndarray(self[0].time_freq_support.time_frequencies.data) + """Array of times in s where the sound is defined.""" + if self.channel_count == 0: + raise PyAnsysSoundException("No channels available in the Sound object.") + + return np.array(self[0].time_freq_support.time_frequencies.data) @property - def fs(self) -> Field: + def fs(self) -> float: """Sampling frequency in Hz.""" + if self.channel_count == 0: + raise PyAnsysSoundException("No channels available in the Sound object.") + if len(self.time) < 2: + raise PyAnsysSoundException("Not enough time points to determine sampling frequency.") + return 1 / (self.time[1] - self.time[0]) + @property + def duration(self) -> float: + """Duration in s.""" + if self.channel_count == 0: + raise PyAnsysSoundException("No channels available in the Sound object.") + return self.time[-1] - self.time[0] + def update(self) -> None: """Update the sound data.""" # Nothing to update here for now @@ -71,15 +98,15 @@ def get_as_nparray(self) -> list[np.ndarray]: """Get the sound data as a NumPy array.""" return np.array(self.data) - @staticmethod - def create(object: Field | FieldsContainer) -> "Sound": + @classmethod + def create(cls, object: Field | FieldsContainer) -> "Sound": """TODO.""" if isinstance(object, FieldsContainer): - object.__class__ = Sound + object.__class__ = cls object.update() return object elif isinstance(object, Field): - sound = Sound() + sound = cls() sound.labels = ["channel_number"] sound.add_field({"channel_number": 0}, object) return sound @@ -92,6 +119,23 @@ def split_channels(self) -> list["Sound"]: return channels + def plot(self): + """Plot the sound data.""" + if self.channel_count == 0: + raise PyAnsysSoundException("No channels available in the Sound object.") + + for i, channel in enumerate(self): + plt.plot( + channel.time_freq_support.time_frequencies.data, channel.data, label=f"Channel {i}" + ) + + plt.xlabel("Time (s)") + plt.ylabel("Amplitude (Pa)") + if self.channel_count > 1: + plt.legend() + plt.title(self.name) + plt.show() + # class Sound_tmp(Field): # """PyAnsys Sound class to store sound data. diff --git a/src/ansys/sound/core/examples_helpers/download.py b/src/ansys/sound/core/examples_helpers/download.py index 0f1861910..0272ac729 100644 --- a/src/ansys/sound/core/examples_helpers/download.py +++ b/src/ansys/sound/core/examples_helpers/download.py @@ -25,7 +25,6 @@ import os import shutil -from ansys.dpf.core import upload_file_in_tmp_folder import platformdirs import requests @@ -142,8 +141,8 @@ def _download_example_file_to_server_tmp_folder(filename, server=None): # pragm local_path = _download_file_in_local_tmp_folder(url, filename) # upload file to DPF server, # so that we are independent on the server configuration - server_path = upload_file_in_tmp_folder(file_path=local_path, server=server) - return server_path + # server_path = upload_file_in_tmp_folder(file_path=local_path, server=server) + return local_path except Exception as e: # Generate exception raise RuntimeError( diff --git a/src/ansys/sound/core/psychoacoustics/tone_to_noise_ratio.py b/src/ansys/sound/core/psychoacoustics/tone_to_noise_ratio.py index c6d0008a8..c62450a71 100644 --- a/src/ansys/sound/core/psychoacoustics/tone_to_noise_ratio.py +++ b/src/ansys/sound/core/psychoacoustics/tone_to_noise_ratio.py @@ -30,6 +30,7 @@ from . import PsychoacousticsParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import PSD class ToneToNoiseRatio(PsychoacousticsParent): @@ -39,7 +40,7 @@ class ToneToNoiseRatio(PsychoacousticsParent): and ISO 7779 standards. """ - def __init__(self, psd: Field = None, frequency_list: list = None): + def __init__(self, psd: PSD = None, frequency_list: list = None): """Class instantiation takes the following parameters. Parameters diff --git a/src/ansys/sound/core/server_helpers/_connect_to_or_start_server.py b/src/ansys/sound/core/server_helpers/_connect_to_or_start_server.py index fc3f173e8..3a27edb7f 100644 --- a/src/ansys/sound/core/server_helpers/_connect_to_or_start_server.py +++ b/src/ansys/sound/core/server_helpers/_connect_to_or_start_server.py @@ -27,10 +27,8 @@ from ansys.dpf.core import ( LicenseContextManager, - ServerConfig, connect_to_server, load_library, - server_factory, start_local_server, ) @@ -95,10 +93,10 @@ def connect_to_or_start_server( **connect_kwargs, ) else: # pragma: no cover - grpc_config = ServerConfig( - protocol=server_factory.CommunicationProtocols.gRPC, legacy=False - ) - server = start_local_server(config=grpc_config, ansys_path=ansys_path, as_global=True) + # grpc_config = ServerConfig( + # protocol=server_factory.CommunicationProtocols.gRPC, legacy=False + # ) + server = start_local_server(ansys_path=ansys_path, as_global=True) full_path_dll = os.path.join(server.ansys_path, "Acoustics\\SAS\\ads\\") required_version = "8.0" diff --git a/src/ansys/sound/core/signal_utilities/load_wav.py b/src/ansys/sound/core/signal_utilities/load_wav.py index 4bc8b9d5d..f4188f793 100644 --- a/src/ansys/sound/core/signal_utilities/load_wav.py +++ b/src/ansys/sound/core/signal_utilities/load_wav.py @@ -29,7 +29,7 @@ from . import SignalUtilitiesParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning -from ..data_management import Sound, convert_to_sound +from ..data_management import Sound class LoadWav(SignalUtilitiesParent): @@ -82,7 +82,7 @@ def process(self): # Store output in the variable tmp = self.__operator.get_output(0, "fields_container") - self._output = convert_to_sound(tmp) + self._output = Sound.create(tmp) # self._output = convert_to_sound(self.__operator.get_output(0, "fields_container")) def get_output(self) -> FieldsContainer | Sound: diff --git a/src/ansys/sound/core/spectral_processing/power_spectral_density.py b/src/ansys/sound/core/spectral_processing/power_spectral_density.py index b9fb35e17..61919a744 100644 --- a/src/ansys/sound/core/spectral_processing/power_spectral_density.py +++ b/src/ansys/sound/core/spectral_processing/power_spectral_density.py @@ -30,6 +30,7 @@ from . import SpectralProcessingParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import PSD ID_POWER_SPECTRAL_DENSITY = "compute_power_spectral_density" @@ -186,7 +187,7 @@ def process(self): self.__operator.run() # Get the output. - self._output = self.__operator.get_output(0, "field") + self._output = PSD.create(self.__operator.get_output(0, "field")) def get_output(self) -> Field: """Get the PSD data as a DPF field. diff --git a/src/ansys/sound/core/spectrogram_processing/stft.py b/src/ansys/sound/core/spectrogram_processing/stft.py index db35e3855..09c168d1b 100644 --- a/src/ansys/sound/core/spectrogram_processing/stft.py +++ b/src/ansys/sound/core/spectrogram_processing/stft.py @@ -28,6 +28,9 @@ import matplotlib.pyplot as plt import numpy as np +from ansys.sound.core.data_management.sonagram import Sonagram +from ansys.sound.core.data_management.sound import Sound + from . import SpectrogramProcessingParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning @@ -67,7 +70,7 @@ def __init__( self.__operator = Operator("compute_stft") @property - def signal(self) -> Field: + def signal(self) -> Sound: """Input signal. Can be provided as a DPF field or fields container, but will be stored as DPF field @@ -76,17 +79,14 @@ def signal(self) -> Field: return self.__signal @signal.setter - def signal(self, signal: Field | FieldsContainer): + def signal(self, signal: Sound): """Signal.""" - if type(signal) == FieldsContainer: - if len(signal) > 1: - raise PyAnsysSoundException( - "Input as a DPF fields container can only have one field (mono signal)." - ) - else: - self.__signal = signal[0] - else: - self.__signal = signal + if len(signal) > 1: + raise PyAnsysSoundException( + "Input as a DPF fields container can only have one field (mono signal)." + ) + + self.__signal = signal @property def fft_size(self) -> int: @@ -150,7 +150,7 @@ def process(self): if self.signal == None: raise PyAnsysSoundException("No signal found for STFT. Use 'Stft.signal'.") - self.__operator.connect(0, self.signal) + self.__operator.connect(0, self.signal[0]) self.__operator.connect(1, int(self.fft_size)) self.__operator.connect(2, str(self.window_type)) self.__operator.connect(3, float(self.window_overlap)) @@ -159,9 +159,10 @@ def process(self): self.__operator.run() # Stores output in the variable - self._output = self.__operator.get_output(0, "fields_container") + tmp = self.__operator.get_output(0, "fields_container") + self._output = Sonagram.create(tmp) - def get_output(self) -> FieldsContainer: + def get_output(self) -> Sonagram: """Get the STFT of the signal as a DPF fields container. Returns @@ -241,7 +242,7 @@ def plot(self): np.seterr(divide="warn") phase = self.get_stft_phase_as_nparray() phase = phase[0:half_nfft, :] - time_data = self.signal.time_freq_support.time_frequencies.data + time_data = self.signal[0].time_freq_support.time_frequencies.data time_step = time_data[1] - time_data[0] fs = 1.0 / time_step num_time_index = len(self.get_output().get_available_ids_for_label("time")) From 2f6e2d85da89d6feaa13b1e8a562f8447c7cdfa7 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Fri, 13 Jun 2025 17:16:13 +0200 Subject: [PATCH 03/10] created source data classes + implemented for harmo 2 param --- examples/XXX_test_sound_classes.py | 40 ++++ .../sound/core/data_management/__init__.py | 3 + src/ansys/sound/core/data_management/sound.py | 20 +- .../sound/core/data_management/sources.py | 195 ++++++++++++++++++ .../source_harmonics_two_parameters.py | 97 ++------- 5 files changed, 271 insertions(+), 84 deletions(-) create mode 100644 src/ansys/sound/core/data_management/sources.py diff --git a/examples/XXX_test_sound_classes.py b/examples/XXX_test_sound_classes.py index 969b0daf9..5bdd92bf4 100644 --- a/examples/XXX_test_sound_classes.py +++ b/examples/XXX_test_sound_classes.py @@ -19,6 +19,7 @@ # 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 ansys.dpf.core import FieldsContainer from ansys.sound.core.psychoacoustics.loudness_iso_532_1_stationary import ( LoudnessISO532_1_Stationary, @@ -26,6 +27,7 @@ from ansys.sound.core.psychoacoustics.tone_to_noise_ratio import ToneToNoiseRatio from ansys.sound.core.server_helpers._connect_to_or_start_server import connect_to_or_start_server from ansys.sound.core.signal_utilities import CropSignal, LoadWav +from ansys.sound.core.sound_composer.sound_composer import SoundComposer from ansys.sound.core.spectral_processing.power_spectral_density import PowerSpectralDensity from ansys.sound.core.spectrogram_processing.stft import Stft @@ -68,3 +70,41 @@ stft = stfter.get_output() print(stft.time) print(stft.frequencies) + + +scer = SoundComposer( + "C:/ANSYSDev/PyDev/pyansys-sound/tests/data/" + "20250130_SoundComposerProjectForDpfSoundTesting_valid.scn" +) +bbn: FieldsContainer = scer.tracks[0].source.source_bbn +bbn_support = bbn.get_support("control_parameter_1") +bbn_support_ppts = bbn_support.available_field_supported_properties() +bbn_control = bbn_support.field_support_by_property("kph") +bbn2 = scer.tracks[3].source.source_bbn_two_parameters +bbn2_field = bbn2.get_field({"control_parameter_1": 0, "control_parameter_2": 0}) +bbn2_support1 = bbn2.get_support("control_parameter_1") +bbn2_support1_ppts = bbn2_support1.available_field_supported_properties() +bbn2_control1 = bbn2_support1.field_support_by_property("celsius") +bbn2_support2 = bbn2.get_support("control_parameter_2") +bbn2_support2_ppts = bbn2_support2.available_field_supported_properties() +bbn2_control2 = bbn2_support2.field_support_by_property("%") +h = scer.tracks[4].source.source_harmonics +h_support = h.get_support("control_parameter_1") +h_support_ppts = h_support.available_field_supported_properties() +h_control = h_support.field_support_by_property("RPM") +h2 = scer.tracks[5].source.source_harmonics_two_parameters +h2_support1 = h2.get_support("control_parameter_1") +h2_support1_ppts = h2_support1.available_field_supported_properties() +h2_control1 = h2_support1.field_support_by_property("RPM") +h2_support2 = h2.get_support("control_parameter_2") +h2_support2_ppts = h2_support2.available_field_supported_properties() +h2_control2 = h2_support2.field_support_by_property("%") + +h2_2 = scer.tracks[6].source.source_harmonics_two_parameters +h2_2_support1 = h2_2.get_support("control_parameter_1") +h2_2_support1_ppts = h2_2_support1.available_field_supported_properties() +h2_2_control1 = h2_2_support1.field_support_by_property("RPM") +h2_2_support2 = h2_2.get_support("control_parameter_2") +h2_2_support2_ppts = h2_2_support2.available_field_supported_properties() +h2_2_control2 = h2_2_support2.field_support_by_property("%") +print() diff --git a/src/ansys/sound/core/data_management/__init__.py b/src/ansys/sound/core/data_management/__init__.py index 782c8db2e..69d884d86 100644 --- a/src/ansys/sound/core/data_management/__init__.py +++ b/src/ansys/sound/core/data_management/__init__.py @@ -24,6 +24,7 @@ from .psd2 import PSD from .sonagram import Sonagram from .sound import Sound, convert_to_sound +from .sources import BroadbandNoiseSource, HarmonicsSource # from .utilities import convert_type @@ -31,6 +32,8 @@ "Sound", "PSD", "Sonagram", + "BroadbandNoiseSource", + "HarmonicsSource", # "convert_type", "convert_to_sound", ) diff --git a/src/ansys/sound/core/data_management/sound.py b/src/ansys/sound/core/data_management/sound.py index aaf243e55..275b52a31 100644 --- a/src/ansys/sound/core/data_management/sound.py +++ b/src/ansys/sound/core/data_management/sound.py @@ -31,11 +31,10 @@ class Sound(FieldsContainer): """PyAnsys Sound class to store sound data.""" - def __init__( - self, - ): - """TODO.""" - super().__init__() + # ctor or not ctor? + # not ctor: class behaves exactly like a FieldsContainer, so it can be used in the same way (but + # their is no "sound_factory", the FieldsContainer's would still need to be used) + # ctor: allow building a sound object by passing np arrays or a list of Fields... def __str__(self): """Return the string representation of the object.""" @@ -96,7 +95,16 @@ def update(self) -> None: def get_as_nparray(self) -> list[np.ndarray]: """Get the sound data as a NumPy array.""" - return np.array(self.data) + # start by update to do required checks? + self.update() + + if self.channel_count == 1: + return [np.array(self[0].data)] + + arrays = [np.empty(len(self.time)) for _ in range(self.channel_count)] + for i, channel in enumerate(self): + arrays[i] = np.array(channel.data) + return arrays @classmethod def create(cls, object: Field | FieldsContainer) -> "Sound": diff --git a/src/ansys/sound/core/data_management/sources.py b/src/ansys/sound/core/data_management/sources.py new file mode 100644 index 000000000..7c5552d8b --- /dev/null +++ b/src/ansys/sound/core/data_management/sources.py @@ -0,0 +1,195 @@ +# 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. + +"""PyAnsys Sound class to store sound data.""" +from typing import Optional + +from ansys.dpf.core import FieldsContainer +import numpy as np + +from .._pyansys_sound import PyAnsysSoundException + + +class _SourceBase(FieldsContainer): + """PyAnsys Sound class to store sound data.""" + + # def __init__(self, source_type: str = "", control_count: int = 1): + # super().__init__() + # self.type = source_type + # self.control_count = control_count + + @classmethod + def create(cls, object: FieldsContainer) -> "BroadbandNoiseSource | HarmonicsSource": + """TODO.""" + object.__class__ = cls + object.update() + return object + + def __str__(self): + """Return the string representation of the object.""" + if self.channel_count > 0: + properties_str = ( + f":\n\tsampling frequency: {self.fs:.1f} Hz" f"\n\tDuration: {self.duration:.2f} s" + ) + else: + properties_str = "" + return f"Sound object with {self.channel_count} channels{properties_str}" + + @property + def shape(self) -> tuple[int]: + data_count = len(self._main_support) + return (data_count, len(self.control_points[0])) + # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING + # control_count = len(self.control_names) + # if control_count == 1: + # return (data_count, len(self.control_points[0])) + # return (data_count, len(self.control_points[0]), len(self.control_points[1])) + + @property + def control_names(self) -> list[str]: + return self.__control_names + + @property + def control_units(self) -> list[str]: + return self.__control_units + + @property + def control_mins(self) -> list[float]: + return self.__control_mins + + @property + def control_maxs(self) -> list[float]: + return self.__control_maxs + + @property + def control_points(self) -> list[np.ndarray]: + return self.__control_points + + def update(self) -> None: + """Update the sound data.""" + # Nothing to update here for now + # TODO: + # - check at least one field is present + # - check all fields are the same size and have the same time frequency support + # - check support is regularly spaced + self._main_support = np.array(self[0].time_freq_support.time_frequencies.data) + + name, unit, min, max, data = self.__get_control_info(1) + self.__control_names = [name] + self.__control_units = [unit] + self.__control_mins = [min] + self.__control_maxs = [max] + self.__control_points = [data] + # self.__control_points = [np.unique(data)] + + if len(self.labels) == 2: + name, unit, min, max, data = self.__get_control_info(2) + self.__control_names.append(name) + self.__control_units.append(unit) + self.__control_mins.append(min) + self.__control_maxs.append(max) + if len(data) != len(self.__control_points[0]): + raise PyAnsysSoundException( + f"Control parameter 2 has {len(data)} points, " + f"but control parameter 1 has {len(self.__control_points[0])} points." + ) + self.__control_points.append(data) + # self.__control_points.append(np.unique(data)) + + def get_as_nparray(self, i: int, j: Optional[int] = None) -> np.ndarray: + """Get the sound data as a NumPy array.""" + # start by update to do required checks? + self.update() + + # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING + + if i < 0 or i >= self.shape[1]: + raise PyAnsysSoundException( + f"Control index i ({i}) is out of range. " + f"This source control has {self.shape[1]} points." + ) + + if j is not None: + if len(self.shape) < 3: + raise PyAnsysSoundException( + f"Source data has 1 control only. Only one index must be provided." + ) + + if j < 0 or j >= self.shape[2]: + raise PyAnsysSoundException( + f"Control index j ({j}) is out of range. " + f"This source control has {self.shape[2]} points." + ) + + return np.array(self.get_field({self.labels[0]: i, self.labels[1]: j}).data) + + return np.array(self.get_field({self.labels[0]: i}).data) + + def __get_control_info(self, index) -> tuple[str, str, float, float, np.ndarray]: + if index > len(self.labels) or index < 1: + raise PyAnsysSoundException( + f"Control index ({index}) is out of range. " + f"This source has {len(self.labels)} controls." + ) + + match index: + case 1: + control_data = self.get_support("control_parameter_1") + case 2: + control_data = self.get_support("control_parameter_2") + + unit = control_data.available_field_supported_properties()[0] + support = control_data.field_support_by_property(unit) + data = support.data + return support.name, unit, float(data.min()), float(data.max()), np.array(data) + + +class BroadbandNoiseSource(_SourceBase): + """PyAnsys Sound class to store broadband noise data.""" + + @property + def spectrum_type(self) -> str: + """Get the spectrum type of the sound data.""" + return self[0].time_freq_support.time_frequencies.field_definition.quantity_types[0] + + @property + def delta_f(self) -> float: + """Get the frequency resolution of the sound data.""" + frequencies = self[0].time_freq_support.time_frequencies.data + if len(frequencies) > 1: + return frequencies[1] - frequencies[0] + else: + return 0.0 + + @property + def frequencies(self) -> np.ndarray: + """Get the frequencies of the sound data.""" + return self._main_support + + +class HarmonicsSource(_SourceBase): + """PyAnsys Sound class to store harmonics data.""" + + @property + def orders(self) -> np.ndarray: + """Get the frequencies of the sound data.""" + return self._main_support 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 73cf0482f..015600589 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 @@ -28,6 +28,7 @@ import numpy as np from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ..data_management import HarmonicsSource from ._source_parent import SourceParent from .source_control_time import SourceControlTime @@ -91,22 +92,13 @@ def __str__(self) -> str: """Return the string representation of the object.""" # Source info. if self.source_harmonics_two_parameters is not None: - ( - orders, - rpm_name, - rpm_min_max, - control_name, - control_unit, - control_min_max, - ) = self.__extract_harmonics_two_parameters_info() - # Source name. str_name = self.source_harmonics_two_parameters.name if str_name is None: str_name = "" # Orders. - orders = np.round(orders, 1) + orders = np.round(self.source_harmonics_two_parameters.orders, 1) if len(orders) > 10: str_order_values = f"{str(orders[:5])[:-1]} ... {str(orders[-5:])[1:]}" else: @@ -116,10 +108,13 @@ def __str__(self) -> str: f"'{str_name}'\n" f"\tNumber of orders: {len(orders)}\n" f"\t\t{str_order_values}\n" - f"\tControl parameter 1: {rpm_name}, " - f"{rpm_min_max[0]} - {rpm_min_max[1]} rpm\n" - f"\tControl parameter 2: {control_name}, " - f"{control_min_max[0]} - {control_min_max[1]} {control_unit}" + f"\tControl parameter 1: {self.source_harmonics_two_parameters.control_names[0]}, " + f"{self.source_harmonics_two_parameters.control_mins[0]} - " + f"{self.source_harmonics_two_parameters.control_maxs[0]} rpm\n" + f"\tControl parameter 2: {self.source_harmonics_two_parameters.control_names[1]}, " + f"{self.source_harmonics_two_parameters.control_mins[1]} - " + f"{self.source_harmonics_two_parameters.control_maxs[1]} " + f"{self.source_harmonics_two_parameters.control_units[1]}\n" ) else: str_source = "Not set" @@ -186,7 +181,7 @@ def source_control2(self, control: SourceControlTime): self.__control2 = control @property - def source_harmonics_two_parameters(self) -> FieldsContainer: + def source_harmonics_two_parameters(self) -> HarmonicsSource: """Source data for the harmonics source with two parameters. The harmonics source with two parameters data consists of a series of orders whose levels @@ -196,7 +191,7 @@ def source_harmonics_two_parameters(self) -> FieldsContainer: return self.__source_harmonics_two_parameters @source_harmonics_two_parameters.setter - def source_harmonics_two_parameters(self, source: FieldsContainer): + def source_harmonics_two_parameters(self, source: HarmonicsSource): """Set the harmonics source with two parameters data.""" if source is not None: if not isinstance(source, FieldsContainer): @@ -205,11 +200,7 @@ def source_harmonics_two_parameters(self, source: FieldsContainer): "fields container." ) - if ( - len(source) < 1 - or len(source[0].data) < 1 - or len(source[0].time_freq_support.time_frequencies.data) < 1 - ): + if len(source) < 1 or len(source[0].data) < 1 or len(source.orders) < 1: raise PyAnsysSoundException( "Specified harmonics source with two parameters must contain at least one " "order level (the provided DPF fields container must contain at least one " @@ -217,7 +208,7 @@ def source_harmonics_two_parameters(self, source: FieldsContainer): ) for field in source: - if len(field.data) != len(field.time_freq_support.time_frequencies.data): + if len(field.data) != len(source.orders): raise PyAnsysSoundException( "Each set of order levels in the specified harmonics source with two " "parameters must contain as many level values as the number of orders (in " @@ -233,13 +224,7 @@ def source_harmonics_two_parameters(self, source: FieldsContainer): "points)." ) - support_data = source.get_support("control_parameter_1") - support_properties = support_data.available_field_supported_properties() - support1_values = support_data.field_support_by_property(support_properties[0]) - support_data = source.get_support("control_parameter_2") - support_properties = support_data.available_field_supported_properties() - support2_values = support_data.field_support_by_property(support_properties[0]) - if len(support1_values) != len(source) or len(support2_values) != len(source): + if source.shape[1] != len(source): raise PyAnsysSoundException( "Specified harmonics source with two parameters must contain as many sets of " "order levels as the number of values in both associated control parameters " @@ -283,9 +268,8 @@ def load_source_harmonics_two_parameters(self, file: str): self.__operator_load.run() # Get the loaded sound power level parameters. - self.source_harmonics_two_parameters = self.__operator_load.get_output( - 0, "fields_container" - ) + tmp = self.__operator_load.get_output(0, "fields_container") + self.source_harmonics_two_parameters = HarmonicsSource.create(tmp) def set_from_generic_data_containers( self, @@ -304,7 +288,9 @@ def set_from_generic_data_containers( source_control_data : GenericDataContainer Source control data as a DPF generic data container. """ - self.source_harmonics_two_parameters = source_data.get_property("sound_composer_source") + self.source_harmonics_two_parameters = HarmonicsSource.create( + source_data.get_property("sound_composer_source") + ) control = source_control_data.get_property("sound_composer_source_control_parameter_1") self.source_control_rpm = SourceControlTime() self.source_control_rpm.control = control @@ -494,48 +480,3 @@ def plot_control(self): plt.tight_layout() plt.show() - - def __extract_harmonics_two_parameters_info( - self, - ) -> tuple[list[float], str, tuple[float], str, str, tuple[float]]: - """Extract the harmonics source with two parameters information. - - Returns - ------- - tuple[list[float], str, tuple[float], str, str, tuple[float]] - Harmonics source with two parameters information, consisting of the following elements: - First element: list of order values. - - Second element: name of the RPM control. - - Third element: min and max values of the RPM control. - - Fourth element: name of the second control parameter. - - Fifth element: unit of the second control parameter. - - Sixth element: min and max values of the second control parameter. - """ - if self.source_harmonics_two_parameters is None: - return ([], "", (), "", "", ()) - - # Orders (same values for each field). - orders = self.source_harmonics_two_parameters[0].time_freq_support.time_frequencies.data - - # RPM control info. - rpm_data = self.source_harmonics_two_parameters.get_support("control_parameter_1") - parameter_ids = rpm_data.available_field_supported_properties() - rpm_unit = parameter_ids[0] - rpm_name = rpm_data.field_support_by_property(rpm_unit).name - rpm_values = rpm_data.field_support_by_property(rpm_unit).data - rpm_min_max = (float(rpm_values.min()), float(rpm_values.max())) - - # Control parameter 2 info. - control_data = self.source_harmonics_two_parameters.get_support("control_parameter_2") - parameter_ids = control_data.available_field_supported_properties() - control_unit = parameter_ids[0] - control_name = control_data.field_support_by_property(control_unit).name - control_values = control_data.field_support_by_property(control_unit).data - control_min_max = (float(control_values.min()), float(control_values.max())) - - return list(orders), rpm_name, rpm_min_max, control_name, control_unit, control_min_max From 093620b384b608f84051469e02889ba430ace682 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 10:02:53 +0200 Subject: [PATCH 04/10] updated object creation in crop signals --- src/ansys/sound/core/signal_utilities/crop_signal.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ansys/sound/core/signal_utilities/crop_signal.py b/src/ansys/sound/core/signal_utilities/crop_signal.py index 690e09fc1..65bdd9ed6 100644 --- a/src/ansys/sound/core/signal_utilities/crop_signal.py +++ b/src/ansys/sound/core/signal_utilities/crop_signal.py @@ -113,11 +113,8 @@ def process(self): self.__operator.run() # Stores output in the variable - if isinstance(self.signal, FieldsContainer): - self._output = convert_to_sound(self.__operator.get_output(0, "fields_container")) - elif isinstance(self.signal, Field): - self._output = self.__operator.get_output(0, "field") - self._output.__class__ = Sound + tmp = convert_to_sound(self.__operator.get_output(0, "fields_container")) + self._output = Sound.create(tmp) def get_output(self) -> FieldsContainer | Sound: """Get the cropped signal as a DPF fields container. From aafdaa416ef68892fb17bd9b52e5d81f6d46c874 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 10:06:16 +0200 Subject: [PATCH 05/10] follow up --- src/ansys/sound/core/signal_utilities/crop_signal.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ansys/sound/core/signal_utilities/crop_signal.py b/src/ansys/sound/core/signal_utilities/crop_signal.py index 65bdd9ed6..d94273eba 100644 --- a/src/ansys/sound/core/signal_utilities/crop_signal.py +++ b/src/ansys/sound/core/signal_utilities/crop_signal.py @@ -23,7 +23,7 @@ """Crops a signal.""" import warnings -from ansys.dpf.core import Field, FieldsContainer, Operator +from ansys.dpf.core import FieldsContainer, Operator import numpy as np from ansys.sound.core.data_management.sound import convert_to_sound @@ -145,7 +145,4 @@ def get_output_as_nparray(self) -> np.ndarray: """ output = self.get_output() - if isinstance(self.signal, Field): - return output.data - return self.convert_fields_container_to_np_array(output) From e3154ac96204633cf63b80930f2a0b6f5a24d893 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 10:08:49 +0200 Subject: [PATCH 06/10] oversight --- src/ansys/sound/core/signal_utilities/crop_signal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ansys/sound/core/signal_utilities/crop_signal.py b/src/ansys/sound/core/signal_utilities/crop_signal.py index d94273eba..d89328a02 100644 --- a/src/ansys/sound/core/signal_utilities/crop_signal.py +++ b/src/ansys/sound/core/signal_utilities/crop_signal.py @@ -26,8 +26,6 @@ from ansys.dpf.core import FieldsContainer, Operator import numpy as np -from ansys.sound.core.data_management.sound import convert_to_sound - from . import SignalUtilitiesParent from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning from ..data_management import Sound @@ -113,7 +111,7 @@ def process(self): self.__operator.run() # Stores output in the variable - tmp = convert_to_sound(self.__operator.get_output(0, "fields_container")) + tmp = self.__operator.get_output(0, "fields_container") self._output = Sound.create(tmp) def get_output(self) -> FieldsContainer | Sound: From c6e78ded1ccc289b586d85599c29e0c6cc11cf7d Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 10:48:19 +0200 Subject: [PATCH 07/10] move sanity checks into source class --- .../sound/core/data_management/sources.py | 100 ++++++++++++------ .../source_harmonics_two_parameters.py | 32 +----- 2 files changed, 69 insertions(+), 63 deletions(-) diff --git a/src/ansys/sound/core/data_management/sources.py b/src/ansys/sound/core/data_management/sources.py index 7c5552d8b..e2dc88b43 100644 --- a/src/ansys/sound/core/data_management/sources.py +++ b/src/ansys/sound/core/data_management/sources.py @@ -41,6 +41,7 @@ class _SourceBase(FieldsContainer): def create(cls, object: FieldsContainer) -> "BroadbandNoiseSource | HarmonicsSource": """TODO.""" object.__class__ = cls + object.check() object.update() return object @@ -54,15 +55,15 @@ def __str__(self): properties_str = "" return f"Sound object with {self.channel_count} channels{properties_str}" - @property - def shape(self) -> tuple[int]: - data_count = len(self._main_support) - return (data_count, len(self.control_points[0])) - # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING - # control_count = len(self.control_names) - # if control_count == 1: - # return (data_count, len(self.control_points[0])) - # return (data_count, len(self.control_points[0]), len(self.control_points[1])) + # @property + # def shape(self) -> tuple[int]: + # data_count = len(self._main_support) + # return (data_count, len(self.control_points[0])) + # # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING + # # control_count = len(self.control_names) + # # if control_count == 1: + # # return (data_count, len(self.control_points[0])) + # # return (data_count, len(self.control_points[0]), len(self.control_points[1])) @property def control_names(self) -> list[str]: @@ -86,13 +87,38 @@ def control_points(self) -> list[np.ndarray]: def update(self) -> None: """Update the sound data.""" - # Nothing to update here for now - # TODO: - # - check at least one field is present - # - check all fields are the same size and have the same time frequency support - # - check support is regularly spaced + if ( + len(self) < 1 + or len(self[0].data) < 1 + or len(self[0].time_freq_support.time_frequencies.data) < 1 + ): + # NOTE: error message should be adjusted for each source type. Possibly use class + # attributes containing the messages + raise PyAnsysSoundException( + "Specified harmonics source with two parameters must contain at least one " + "order level (the provided DPF fields container must contain at least one " + "field with at least one data point)." + ) + self._main_support = np.array(self[0].time_freq_support.time_frequencies.data) + for field in self: + if len(field.data) != len(self._main_support): + raise PyAnsysSoundException( + "Each set of order levels in the specified harmonics source with two " + "parameters must contain as many level values as the number of orders (in " + "the provided DPF fields container, each field must contain the same " + "number of data points and support values)." + ) + + if len(field.data) != len(self[0].data): + raise PyAnsysSoundException( + "Each set of order levels in the specified harmonics source with two " + "parameters must contain the same number of level values (in the provided " + "DPF fields container, each field must contain the same number of data " + "points)." + ) + name, unit, min, max, data = self.__get_control_info(1) self.__control_names = [name] self.__control_units = [unit] @@ -115,34 +141,44 @@ def update(self) -> None: self.__control_points.append(data) # self.__control_points.append(np.unique(data)) + # test against 2nd control parameter (if relevant) is covered by the above check within the + # if statement (technically, by the combination of it and this one) + if self.__control_points[0] != len(self): + raise PyAnsysSoundException( + "Specified harmonics source with two parameters must contain as many sets of " + "order levels as the number of values in both associated control parameters " + "(in the provided DPF fields container, the number of fields should be the " + "same as the number of values in both fields container supports)." + ) + def get_as_nparray(self, i: int, j: Optional[int] = None) -> np.ndarray: """Get the sound data as a NumPy array.""" # start by update to do required checks? self.update() - # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING + # # THIS IS NOT WORKING BECAUSE SOME I-J COMBINATIONS MIGHT BE MISSING - if i < 0 or i >= self.shape[1]: - raise PyAnsysSoundException( - f"Control index i ({i}) is out of range. " - f"This source control has {self.shape[1]} points." - ) + # if i < 0 or i >= self.shape[1]: + # raise PyAnsysSoundException( + # f"Control index i ({i}) is out of range. " + # f"This source control has {self.shape[1]} points." + # ) - if j is not None: - if len(self.shape) < 3: - raise PyAnsysSoundException( - f"Source data has 1 control only. Only one index must be provided." - ) + # if j is not None: + # if len(self.shape) < 3: + # raise PyAnsysSoundException( + # f"Source data has 1 control only. Only one index must be provided." + # ) - if j < 0 or j >= self.shape[2]: - raise PyAnsysSoundException( - f"Control index j ({j}) is out of range. " - f"This source control has {self.shape[2]} points." - ) + # if j < 0 or j >= self.shape[2]: + # raise PyAnsysSoundException( + # f"Control index j ({j}) is out of range. " + # f"This source control has {self.shape[2]} points." + # ) - return np.array(self.get_field({self.labels[0]: i, self.labels[1]: j}).data) + # return np.array(self.get_field({self.labels[0]: i, self.labels[1]: j}).data) - return np.array(self.get_field({self.labels[0]: i}).data) + # return np.array(self.get_field({self.labels[0]: i}).data) def __get_control_info(self, index) -> tuple[str, str, float, float, np.ndarray]: if index > len(self.labels) or index < 1: 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 015600589..aea7e30d7 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 @@ -200,37 +200,7 @@ def source_harmonics_two_parameters(self, source: HarmonicsSource): "fields container." ) - if len(source) < 1 or len(source[0].data) < 1 or len(source.orders) < 1: - raise PyAnsysSoundException( - "Specified harmonics source with two parameters must contain at least one " - "order level (the provided DPF fields container must contain at least one " - "field with at least one data point)." - ) - - for field in source: - if len(field.data) != len(source.orders): - raise PyAnsysSoundException( - "Each set of order levels in the specified harmonics source with two " - "parameters must contain as many level values as the number of orders (in " - "the provided DPF fields container, each field must contain the same " - "number of data points and support values)." - ) - - if len(field.data) != len(source[0].data): - raise PyAnsysSoundException( - "Each set of order levels in the specified harmonics source with two " - "parameters must contain the same number of level values (in the provided " - "DPF fields container, each field must contain the same number of data " - "points)." - ) - - if source.shape[1] != len(source): - raise PyAnsysSoundException( - "Specified harmonics source with two parameters must contain as many sets of " - "order levels as the number of values in both associated control parameters " - "(in the provided DPF fields container, the number of fields should be the " - "same as the number of values in both fields container supports)." - ) + # shape tests npw handled in HarmonicsSource class self.__source_harmonics_two_parameters = source From d7656dce244cbc2da6f17632a1dcae9715f0c37c Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 11:11:25 +0200 Subject: [PATCH 08/10] get/set as gdc --- src/ansys/sound/core/data_management/sources.py | 15 ++++++++++++++- .../source_harmonics_two_parameters.py | 7 +++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/ansys/sound/core/data_management/sources.py b/src/ansys/sound/core/data_management/sources.py index e2dc88b43..a033c2418 100644 --- a/src/ansys/sound/core/data_management/sources.py +++ b/src/ansys/sound/core/data_management/sources.py @@ -23,7 +23,7 @@ """PyAnsys Sound class to store sound data.""" from typing import Optional -from ansys.dpf.core import FieldsContainer +from ansys.dpf.core import FieldsContainer, GenericDataContainer import numpy as np from .._pyansys_sound import PyAnsysSoundException @@ -45,6 +45,19 @@ def create(cls, object: FieldsContainer) -> "BroadbandNoiseSource | HarmonicsSou object.update() return object + @classmethod + def create_from_generic_data_container( + cls, data: GenericDataContainer + ) -> "BroadbandNoiseSource | HarmonicsSource": + """Create a sound source from a generic data container.""" + return cls.create(data.get_property("sound_composer_source")) + + def get_as_generic_data_containers(self) -> GenericDataContainer: + """Get the sound data as a generic data container.""" + container = GenericDataContainer() + container.set_property("sound_composer_source", self) + return container + def __str__(self): """Return the string representation of the object.""" if self.channel_count > 0: 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 aea7e30d7..b2dc46dc5 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 @@ -258,8 +258,8 @@ def set_from_generic_data_containers( source_control_data : GenericDataContainer Source control data as a DPF generic data container. """ - self.source_harmonics_two_parameters = HarmonicsSource.create( - source_data.get_property("sound_composer_source") + self.source_harmonics_two_parameters = HarmonicsSource.create_from_generic_data_container( + source_data ) control = source_control_data.get_property("sound_composer_source_control_parameter_1") self.source_control_rpm = SourceControlTime() @@ -294,8 +294,7 @@ def get_as_generic_data_containers(self) -> tuple[GenericDataContainer]: ) source_data = None else: - source_data = GenericDataContainer() - source_data.set_property("sound_composer_source", self.source_harmonics_two_parameters) + source_data = self.source_harmonics_two_parameters.get_as_generic_data_containers() if not self.is_source_control_valid(): warnings.warn( From 1987315dca733fc08d88ebcab7abb7dffacac336 Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Mon, 16 Jun 2025 12:34:53 +0200 Subject: [PATCH 09/10] adjusted tetss --- .../sound/core/data_management/sources.py | 7 +- .../source_harmonics_two_parameters.py | 5 +- tests/conftest.py | 108 ++++++++++-------- .../test_signal_utilities_crop_signal.py | 32 +++--- ...omposer_source_harmonics_two_parameters.py | 47 ++++---- 5 files changed, 108 insertions(+), 91 deletions(-) diff --git a/src/ansys/sound/core/data_management/sources.py b/src/ansys/sound/core/data_management/sources.py index a033c2418..5964e6dba 100644 --- a/src/ansys/sound/core/data_management/sources.py +++ b/src/ansys/sound/core/data_management/sources.py @@ -41,7 +41,6 @@ class _SourceBase(FieldsContainer): def create(cls, object: FieldsContainer) -> "BroadbandNoiseSource | HarmonicsSource": """TODO.""" object.__class__ = cls - object.check() object.update() return object @@ -116,7 +115,7 @@ def update(self) -> None: self._main_support = np.array(self[0].time_freq_support.time_frequencies.data) for field in self: - if len(field.data) != len(self._main_support): + if len(field.data) != len(field.time_freq_support.time_frequencies.data): raise PyAnsysSoundException( "Each set of order levels in the specified harmonics source with two " "parameters must contain as many level values as the number of orders (in " @@ -124,7 +123,7 @@ def update(self) -> None: "number of data points and support values)." ) - if len(field.data) != len(self[0].data): + if len(field.data) != len(self._main_support): raise PyAnsysSoundException( "Each set of order levels in the specified harmonics source with two " "parameters must contain the same number of level values (in the provided " @@ -156,7 +155,7 @@ def update(self) -> None: # test against 2nd control parameter (if relevant) is covered by the above check within the # if statement (technically, by the combination of it and this one) - if self.__control_points[0] != len(self): + if len(self.__control_points[0]) != len(self): raise PyAnsysSoundException( "Specified harmonics source with two parameters must contain as many sets of " "order levels as the number of values in both associated control parameters " 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 b2dc46dc5..706c01c5c 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 @@ -114,7 +114,7 @@ def __str__(self) -> str: f"\tControl parameter 2: {self.source_harmonics_two_parameters.control_names[1]}, " f"{self.source_harmonics_two_parameters.control_mins[1]} - " f"{self.source_harmonics_two_parameters.control_maxs[1]} " - f"{self.source_harmonics_two_parameters.control_units[1]}\n" + f"{self.source_harmonics_two_parameters.control_units[1]}" ) else: str_source = "Not set" @@ -199,6 +199,9 @@ def source_harmonics_two_parameters(self, source: HarmonicsSource): "Specified harmonics source with two parameters must be provided as a DPF " "fields container." ) + if type(source) is FieldsContainer: + # convert to HarmonicsSource to make sure necessary checks are done + source = HarmonicsSource.create(source) # shape tests npw handled in HarmonicsSource class diff --git a/tests/conftest.py b/tests/conftest.py index 4ebe91309..241ab8f0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,6 @@ import os -from ansys.dpf.core import upload_file_in_tmp_folder import pytest from ansys.sound.core.server_helpers import connect_to_or_start_server @@ -39,65 +38,68 @@ def pytest_configure(): ## Get the current directory of the conftest.py file base_dir = os.path.join(os.path.dirname(__file__), "data") + def upload_file_in_tmp_folder_tmp(file_path, server): + return file_path + ## Construct the paths of the different test files after uploading them on the server. # Audio samples - pytest.data_path_flute_in_container = upload_file_in_tmp_folder( + pytest.data_path_flute_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "flute.wav"), server=server ) - pytest.data_path_flute_nonUnitaryCalib_in_container = upload_file_in_tmp_folder( + pytest.data_path_flute_nonUnitaryCalib_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "flute_nonUnitaryCalib.wav"), server=server ) - pytest.data_path_flute_nonUnitaryCalib_as_txt_in_container = upload_file_in_tmp_folder( + pytest.data_path_flute_nonUnitaryCalib_as_txt_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "flute_nonUnitaryCalib_as_text_2024R2_20241125.txt"), server=server, ) - pytest.data_path_sharp_noise_in_container = upload_file_in_tmp_folder( + pytest.data_path_sharp_noise_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "sharp_noise.wav"), server=server ) - pytest.data_path_sharper_noise_in_container = upload_file_in_tmp_folder( + pytest.data_path_sharper_noise_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "sharper_noise.wav"), server=server ) - pytest.data_path_rough_noise_in_container = upload_file_in_tmp_folder( + pytest.data_path_rough_noise_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "rough_noise.wav"), server=server ) - pytest.data_path_rough_tone_in_container = upload_file_in_tmp_folder( + pytest.data_path_rough_tone_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "rough_tone.wav"), server=server ) - pytest.data_path_fluctuating_noise_in_container = upload_file_in_tmp_folder( + pytest.data_path_fluctuating_noise_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "fluctuating_noise.wav"), server=server ) - pytest.data_path_white_noise_in_container = upload_file_in_tmp_folder( + pytest.data_path_white_noise_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "white_noise.wav"), server=server ) - pytest.data_path_aircraft_nonUnitaryCalib_in_container = upload_file_in_tmp_folder( + pytest.data_path_aircraft_nonUnitaryCalib_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "Aircraft-App2_nonUnitaryCalib.wav"), server=server ) - pytest.data_path_Acceleration_stereo_nonUnitaryCalib = upload_file_in_tmp_folder( + pytest.data_path_Acceleration_stereo_nonUnitaryCalib = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "Acceleration_stereo_nonUnitaryCalib.wav"), server=server, ) - pytest.data_path_accel_with_rpm_in_container = upload_file_in_tmp_folder( + pytest.data_path_accel_with_rpm_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "accel_with_rpm.wav"), server=server ) - pytest.data_path_Acceleration_with_Tacho_nonUnitaryCalib = upload_file_in_tmp_folder( + pytest.data_path_Acceleration_with_Tacho_nonUnitaryCalib = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "Acceleration_with_Tacho_nonUnitaryCalib.wav"), server=server, ) # RPM profiles - pytest.data_path_rpm_profile_as_wav_in_container = upload_file_in_tmp_folder( + pytest.data_path_rpm_profile_as_wav_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "RPM_profile_2024R2_20241126.wav"), server=server ) - pytest.data_path_rpm_profile_as_txt_in_container = upload_file_in_tmp_folder( + pytest.data_path_rpm_profile_as_txt_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "RPM_profile_2024R2_20241126.txt"), server=server ) # Sound power level projects - pytest.data_path_swl_project_file_in_container = upload_file_in_tmp_folder( + pytest.data_path_swl_project_file_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "SoundPowerLevelProject_hemisphere_2025R1_20243008.spw"), server=server, ) - pytest.data_path_swl_project_file_with_calibration_in_container = upload_file_in_tmp_folder( + pytest.data_path_swl_project_file_with_calibration_in_container = upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "SoundPowerLevelProject_hemisphere_signalsWithCalibration_2025R1_20240919.spw", @@ -106,16 +108,20 @@ def pytest_configure(): ) # Sound composer files (including spectrum, harmonics, etc. data files) - pytest.data_path_sound_composer_spectrum_source_in_container = upload_file_in_tmp_folder( + pytest.data_path_sound_composer_spectrum_source_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_Spectrum_v3_-_nominal_-_dBSPLperHz_2024R2_20241121.txt"), server=server, ) - pytest.data_path_sound_composer_harmonics_source_2p_in_container = upload_file_in_tmp_folder( - os.path.join(base_dir, "AnsysSound_Orders_MultipleParameters dBSPL_2024R2_20241205.txt"), - server=server, + pytest.data_path_sound_composer_harmonics_source_2p_in_container = ( + upload_file_in_tmp_folder_tmp( + os.path.join( + base_dir, "AnsysSound_Orders_MultipleParameters dBSPL_2024R2_20241205.txt" + ), + server=server, + ) ) pytest.data_path_sound_composer_harmonics_source_2p_many_values_in_container = ( - upload_file_in_tmp_folder( + upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "AnsysSound_Orders_MultipleParameters dBSPL_many_values_2024R2_20241205.txt", @@ -123,11 +129,11 @@ def pytest_configure(): server=server, ) ) - pytest.data_path_sound_composer_harmonics_source_in_container = upload_file_in_tmp_folder( + pytest.data_path_sound_composer_harmonics_source_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_Orders dBSPL v1_2024R2_20241203.txt"), server=server ) pytest.data_path_sound_composer_harmonics_source_10rpm_40orders_in_container = ( - upload_file_in_tmp_folder( + upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "AnsysSound_Orders dBSPL v1_10_rpm_values_40_orders_2024R2_20241203.txt" ), @@ -135,7 +141,7 @@ def pytest_configure(): ) ) pytest.data_path_sound_composer_harmonics_source_2p_inverted_controls_in_container = ( - upload_file_in_tmp_folder( + upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "AnsysSound_Orders_MultipleParameters dBSPL - InvertedContols_2024R2_20241205.txt", @@ -144,7 +150,7 @@ def pytest_configure(): ) ) pytest.data_path_sound_composer_harmonics_source_2p_from_accel_in_container = ( - upload_file_in_tmp_folder( + upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "AnsysSound_Orders_MultipleParameters_FromAccelWithTacho_2024R2_20241205.txt", @@ -152,48 +158,56 @@ def pytest_configure(): server=server, ) ) - pytest.data_path_sound_composer_harmonics_source_Pa_in_container = upload_file_in_tmp_folder( - os.path.join(base_dir, "AnsysSound_Orders Pa v1_2024R2_20241203.txt"), server=server + pytest.data_path_sound_composer_harmonics_source_Pa_in_container = ( + upload_file_in_tmp_folder_tmp( + os.path.join(base_dir, "AnsysSound_Orders Pa v1_2024R2_20241203.txt"), server=server + ) ) pytest.data_path_sound_composer_harmonics_source_wrong_type_in_container = ( - upload_file_in_tmp_folder( + upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_Orders V2_2024R2_20241203.txt"), server=server ) ) - pytest.data_path_sound_composer_harmonics_source_xml_in_container = upload_file_in_tmp_folder( - os.path.join(base_dir, "VRX_Waterfall_2024R2_20241203.xml"), server=server + pytest.data_path_sound_composer_harmonics_source_xml_in_container = ( + upload_file_in_tmp_folder_tmp( + os.path.join(base_dir, "VRX_Waterfall_2024R2_20241203.xml"), server=server + ) ) - pytest.data_path_sound_composer_bbn_source_in_container = upload_file_in_tmp_folder( + pytest.data_path_sound_composer_bbn_source_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_BBN dBSPL OCTAVE Constants.txt"), server=server ) - pytest.data_path_sound_composer_bbn_source_40_values_in_container = upload_file_in_tmp_folder( - os.path.join( - base_dir, "AnsysSound_BBN dBSPLperHz NARROWBAND v2_40values_2024R2_20241128.txt" - ), - server=server, + pytest.data_path_sound_composer_bbn_source_40_values_in_container = ( + upload_file_in_tmp_folder_tmp( + os.path.join( + base_dir, "AnsysSound_BBN dBSPLperHz NARROWBAND v2_40values_2024R2_20241128.txt" + ), + server=server, + ) ) - pytest.data_path_sound_composer_bbn_source_2p_in_container = upload_file_in_tmp_folder( + pytest.data_path_sound_composer_bbn_source_2p_in_container = upload_file_in_tmp_folder_tmp( os.path.join( base_dir, "AnsysSound_BBN_MultipleParameters Pa2PerHz Narrowband v2_2024R2_20240418.txt" ), server=server, ) - pytest.data_path_sound_composer_bbn_source_2p_octave_in_container = upload_file_in_tmp_folder( - os.path.join( - base_dir, "AnsysSound_BBN_MultipleParameters dBSPL Octave v2_2024R2_20240418.txt" - ), - server=server, + pytest.data_path_sound_composer_bbn_source_2p_octave_in_container = ( + upload_file_in_tmp_folder_tmp( + os.path.join( + base_dir, "AnsysSound_BBN_MultipleParameters dBSPL Octave v2_2024R2_20240418.txt" + ), + server=server, + ) ) - pytest.data_path_sound_composer_project_in_container = upload_file_in_tmp_folder( + pytest.data_path_sound_composer_project_in_container = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "20250130_SoundComposerProjectForDpfSoundTesting_valid.scn"), server=server, ) # FRF files - pytest.data_path_filter_frf = upload_file_in_tmp_folder( + pytest.data_path_filter_frf = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_FRF_2024R2_20241206.txt"), server=server ) - pytest.data_path_filter_frf_wrong_header = upload_file_in_tmp_folder( + pytest.data_path_filter_frf_wrong_header = upload_file_in_tmp_folder_tmp( os.path.join(base_dir, "AnsysSound_FRF_bad_2024R2_20241206.txt"), server=server ) diff --git a/tests/tests_signal_utilities/test_signal_utilities_crop_signal.py b/tests/tests_signal_utilities/test_signal_utilities_crop_signal.py index 325e74aa3..352f9a4eb 100644 --- a/tests/tests_signal_utilities/test_signal_utilities_crop_signal.py +++ b/tests/tests_signal_utilities/test_signal_utilities_crop_signal.py @@ -53,9 +53,9 @@ def test_crop_signal_process(): signal_cropper.signal = fc signal_cropper.process() - # Testing input field (no error expected) - signal_cropper.signal = fc[0] - signal_cropper.process() + # # Testing input field (no error expected) + # signal_cropper.signal = fc[0] + # signal_cropper.process() def test_crop_signal_get_output(): @@ -76,10 +76,10 @@ def test_crop_signal_get_output(): assert len(fc_out) == 1 - signal_cropper.signal = fc_signal[0] - signal_cropper.process() - f_out = signal_cropper.get_output() - data = f_out.data + # signal_cropper.signal = fc_signal[0] + # signal_cropper.process() + # f_out = signal_cropper.get_output() + data = fc_out[0].data # Checking data size and some random samples assert len(data) == 44101 assert data[10] == 0.0 @@ -110,15 +110,15 @@ def test_crop_signal_get_output_as_np_array(): assert data[10000] == 0.0308837890625 assert data[44000] == 0.47772216796875 - signal_cropper.signal = fc_signal[0] - signal_cropper.process() - data = signal_cropper.get_output_as_nparray() - # Checking data size and some random samples - assert len(data) == 44101 - assert data[10] == 0.0 - assert data[1000] == 6.103515625e-05 - assert data[10000] == 0.0308837890625 - assert data[44000] == 0.47772216796875 + # signal_cropper.signal = fc_signal[0] + # signal_cropper.process() + # data = signal_cropper.get_output_as_nparray() + # # Checking data size and some random samples + # assert len(data) == 44101 + # assert data[10] == 0.0 + # assert data[1000] == 6.103515625e-05 + # assert data[10000] == 0.0308837890625 + # assert data[44000] == 0.47772216796875 def test_crop_signal_set_get_signal(): 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 4a6c51f35..d99329967 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 @@ -274,6 +274,7 @@ def test_source_harmonics_two_parameters_properties_exceptions(): ), ): source_obj.source_harmonics_two_parameters = fc_source + source_obj.source_harmonics_two_parameters.update() def test_source_harmonics_two_parameters_is_source_control_valid(): @@ -832,26 +833,26 @@ def test_source_harmonics_two_parameters_plot_control_exceptions(): source_obj.plot_control() -def test_source_harmonics_two_parameters___extract_harmonics_two_parameters_info(): - """Test SourceHarmonicsTwoParameters __extract_harmonics_two_parameters_info method.""" - source = SourceHarmonicsTwoParameters() - assert source._SourceHarmonicsTwoParameters__extract_harmonics_two_parameters_info() == ( - [], - "", - (), - "", - "", - (), - ) - - source.load_source_harmonics_two_parameters( - pytest.data_path_sound_composer_harmonics_source_2p_in_container - ) - assert source._SourceHarmonicsTwoParameters__extract_harmonics_two_parameters_info() == ( - [12.0, 24.0, 36.0, 48.0], - "RPM", - (500.0, 3000.0), - "charge", - "%", - (0.0, 10.0), - ) +# def test_source_harmonics_two_parameters___extract_harmonics_two_parameters_info(): +# """Test SourceHarmonicsTwoParameters __extract_harmonics_two_parameters_info method.""" +# source = SourceHarmonicsTwoParameters() +# assert source._SourceHarmonicsTwoParameters__extract_harmonics_two_parameters_info() == ( +# [], +# "", +# (), +# "", +# "", +# (), +# ) + +# source.load_source_harmonics_two_parameters( +# pytest.data_path_sound_composer_harmonics_source_2p_in_container +# ) +# assert source._SourceHarmonicsTwoParameters__extract_harmonics_two_parameters_info() == ( +# [12.0, 24.0, 36.0, 48.0], +# "RPM", +# (500.0, 3000.0), +# "charge", +# "%", +# (0.0, 10.0), +# ) From 63d4fa6989ff29e24397a838a8a4fb65b8f49a7d Mon Sep 17 00:00:00 2001 From: Antoine Minard Date: Fri, 20 Jun 2025 16:38:08 +0200 Subject: [PATCH 10/10] try (unsuccessfully) to restrict attribut creation. Does not work because FieldsContainer already creates new attributes within properties, which are not listed here --- .../_data_management_parent.py | 59 +++++++++++++++++++ src/ansys/sound/core/data_management/psd2.py | 1 + src/ansys/sound/core/data_management/sound.py | 1 + 3 files changed, 61 insertions(+) create mode 100644 src/ansys/sound/core/data_management/_data_management_parent.py diff --git a/src/ansys/sound/core/data_management/_data_management_parent.py b/src/ansys/sound/core/data_management/_data_management_parent.py new file mode 100644 index 000000000..7ee626df3 --- /dev/null +++ b/src/ansys/sound/core/data_management/_data_management_parent.py @@ -0,0 +1,59 @@ +# 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. + + +class NoExtraAttributesMeta(type): + """Metaclass restricting attribute creation.""" + + def __new__(mcs, name, bases, namespace): + """Create a new class with restricted attribute creation.""" + base = bases[0] # Base class. We assume each class inherits from one class only. + # Method Resolution Order (MRO) of the base class, that is, base + every other level of + # inheritance. + mro = base.__mro__ + # Store the list of the base class attribute names (in a variable called _allowed_attrs) + # It is stored in the namespace so that it can be accessed anywhere in the class to create. + _allowed_attrs = set() + for cls in mro: + _allowed_attrs.update(vars(cls).keys()) + namespace["_allowed_attrs"] = _allowed_attrs + # print(f"Allowed attributes for {name}: {namespace['_allowed_attrs']}") + + def __setattr__(self, key, value): + # Check if the created attribute is allowed, ie is in the list of attribute defined in + # the base class. + print(key) + if key not in self._allowed_attrs: + raise AttributeError( + f"Cannot create attribute {key}. Class {self.__class__.__name__} does not " + "allow creation of attributes that are not defined in " + f"{self.__class__.__bases__[0].__name__}." + ) + else: + super(cls, self).__setattr__(key, value) + + # Add the __setattr__ method to the namespace of the class to create. + namespace["__setattr__"] = __setattr__ + + # Create and return the new class. + cls = super().__new__(mcs, name, bases, namespace) + return cls diff --git a/src/ansys/sound/core/data_management/psd2.py b/src/ansys/sound/core/data_management/psd2.py index 21fca907b..69d04cf9c 100644 --- a/src/ansys/sound/core/data_management/psd2.py +++ b/src/ansys/sound/core/data_management/psd2.py @@ -28,6 +28,7 @@ from .._pyansys_sound import PyAnsysSoundException +# class PSD(Field, metaclass=NoExtraAttributesMeta): class PSD(Field): """PyAnsys Sound class to store sound data. diff --git a/src/ansys/sound/core/data_management/sound.py b/src/ansys/sound/core/data_management/sound.py index 275b52a31..06fb3177e 100644 --- a/src/ansys/sound/core/data_management/sound.py +++ b/src/ansys/sound/core/data_management/sound.py @@ -28,6 +28,7 @@ from .._pyansys_sound import PyAnsysSoundException +# class Sound(FieldsContainer, metaclass=NoExtraAttributesMeta): class Sound(FieldsContainer): """PyAnsys Sound class to store sound data."""