Repository: pyfar/pyfar (python)

Issue:
dsp.normalize's reference_methods "power" and "energy" behave unexpectedly
## General

* pyfar version: 0.6.8 (issue persists in current code though)
* Python version: 3.12.4
* Operating System: windows
* Did you install pyfar via pip: yes

## Description

I tried to normalize several signals of different length to the same total energy using pyfar. I expected this to be possible using 
```
pyfar.dsp.normalize(signal, reference_method="energy", target=1)
```
such that each signal would end up with a total energy of 1. However, since the input is a pressure signal and the reference value is energetic, the output signals do not have energies of 1. In fact, they do not even have the same energies:

For instance, with an input signal with an energy of 20, normalize will output a signal with an energy of 0.05 (1/20), whereas an input energy of 5 will output an energy of 0.2 (1/5).
I talked about this with @f-brinkmann and we both think the function call above should return a signal with an energy of 1.
So, when the `reference_method` is set to "power" or "energy", we believe the signal should be divided by the root of the calculated reference value, not the value itself.

## Code Example
```py
shorter = pf.signals.noise(2000, rms=0.1)
longer = pf.signals.noise(4000, rms=0.1)

for input_signal, name in [(shorter, "shorter"), (longer, "longer")]:
    input_energy = pf.dsp.energy(input_signal)
    output_signal = pf.dsp.normalize(input_signal, reference_method="energy")
    output_energy = pf.dsp.energy(output_signal)
    
    print(f"{name}:")
    print(f"  in:  {input_energy}")
    print(f"  out: {output_energy}")
```

Outputs:
```
shorter:
  in:  [20.]
  out: [0.05]
longer:
  in:  [40.]
  out: [0.025]
```

Pull request description:
Normaliztation: Force energetic properties to match the target after normalization
### Which issue(s) are closed by this pull request?

Closes #721 

### Changes proposed in this pull request:

- use square root of a signals energy and power for normalization
- use square root of target as well.
- update docs and tests


Patch:
diff --git a/pyfar/dsp/dsp.py b/pyfar/dsp/dsp.py
index b44dbac7..c032e89e 100644
--- a/pyfar/dsp/dsp.py
+++ b/pyfar/dsp/dsp.py
@@ -2442,8 +2442,16 @@ def normalize(signal, reference_method='max', domain='auto',
             Compute the mean absolute values per channel.
         ``'energy'``
             Compute the energy per channel using :py:func:`~pyfar.dsp.energy`.
+            Note that the square root of the energy is used as `reference`,
+            after handling multi-channel signals (see *channel_handling*
+            below). This is required for the energy of the normalized signal to
+            match the `target`.
         ``'power'``
             Compute the power per channel using :py:func:`~pyfar.dsp.power`.
+            Note that the square root of the power is used as `reference`,
+            after handling multi-channel signals (see *channel_handling*
+            below). This is required for the power of the normalized signal to
+            match the `target`.
         ``'rms'``
             Compute the RMS per channel using :py:func:`~pyfar.dsp.rms`.
 
@@ -2661,6 +2669,13 @@ def normalize(signal, reference_method='max', domain='auto',
     else:
         raise ValueError(("channel_handling must be 'individual', 'max', "
                           "'min' or 'mean'."))
+
+    # scale normalization and target to achieve target energy and power. This
+    # must be done because they are energetic properties
+    if reference_method in ['energy', 'power']:
+        reference_norm = np.sqrt(reference_norm)
+        target = np.sqrt(target)
+
     # apply normalization
     normalized_signal = signal.copy() * target / reference_norm
     if return_reference:


Test patch:
diff --git a/tests/test_dsp_normalize.py b/tests/test_dsp_normalize.py
index 224e8c50..69fde669 100644
--- a/tests/test_dsp_normalize.py
+++ b/tests/test_dsp_normalize.py
@@ -12,23 +12,11 @@ import numpy as np
     ('mean', 'max', [0.6, 3.0]),
     ('mean', 'individual', [3.0, 3.0]),
     ('mean', 'min', [3.0, 15.0]),
-    ('mean', 'mean', [1.0, 5.0]),
-    ('rms', 'max', [1*np.sqrt(3/5**2), 5*np.sqrt(3/5**2)]),
-    ('rms', 'individual', [1*np.sqrt(3/1**2), 5*np.sqrt(3/5**2)]),
-    ('rms', 'min', [1*np.sqrt(3/1**2), 5*np.sqrt(3/1**2)]),
-    ('rms', 'mean', [2/(np.sqrt(1/3)+np.sqrt(25/3)),
-                     2*5/(np.sqrt(1/3)+np.sqrt(25/3))]),
-    ('power', 'max', [1*3/5**2, 5*3/5**2]),
-    ('power', 'individual', [1*3/1**2, 5*3/5**2]),
-    ('power', 'min', [1*3/1**2, 5*3/1**2]),
-    ('power', 'mean', [1*3/((1**2+5**2)/2), 5*3/((1**2+5**2)/2)]),
-    ('energy', 'max', [1/5**2, 5/5**2]),
-    ('energy', 'individual', [1/1**2, 5/5**2]),
-    ('energy', 'min', [1/1**2, 5/1**2]),
-    ('energy', 'mean', [1/((1**2+5**2)/2), 5/((1**2+5**2)/2)])])
-def test_normalization(reference_method, channel_handling, truth):
-    """Parametrized test for all combinations of reference_method and
-    channel_handling parameters using an impulse.
+    ('mean', 'mean', [1.0, 5.0])])
+def test_normalization_max_mean(reference_method, channel_handling, truth):
+    """
+    Parametrized test for all combinations of 'max' and 'mean'
+    reference_method and channel_handling parameters using impulse signals.
     """
     signal = pf.signals.impulse(3, amplitude=[1, 5])
     answer = pf.dsp.normalize(signal, domain='time',
@@ -37,6 +25,30 @@ def test_normalization(reference_method, channel_handling, truth):
     npt.assert_allclose(answer.time[..., 0], truth, rtol=1e-14)
 
 
+@pytest.mark.parametrize(('reference_method', 'reference_function'), [
+    ('energy', pf.dsp.energy), ('power', pf.dsp.power), ('rms', pf.dsp.rms)])
+@pytest.mark.parametrize('target', [.5, 1, 2])
+@pytest.mark.parametrize(('channel_handling', 'assert_function'), [
+    ('max', np.max),
+    ('individual', np.array),
+    ('min', np.min),
+    ('mean', np.mean)])
+def test_normalization_energy_power_rms(
+    reference_method, reference_function, target, channel_handling,
+    assert_function):
+    """
+    Parametrized test for all combinations of 'energy', 'power' and 'rms'
+    reference_method and channel_handling parameters using impulse signals.
+    """
+    signal = pf.signals.impulse(3, amplitude=[1, 5])
+    normalized = pf.dsp.normalize(signal, domain='time',
+                              reference_method=reference_method,
+                              channel_handling=channel_handling,
+                              target=target)
+    value = assert_function(reference_function(normalized))
+    npt.assert_almost_equal(value, target * np.ones(value.shape), 10)
+
+
 @pytest.mark.parametrize('is_complex', [False, True])
 def test_domains_normalization(is_complex):
     """Test for normalization in time and frequency domain."""