Repository: scipy/scipy
File: scipy/signal/_signaltools.py (Python)

Code before:
        Patterns, pp. 25-32. Berlin, Heidelberg: Springer Berlin Heidelberg, 1999.
        `PDF <https://www.informatik.uni-kiel.de/inf/Sommer/doc/Publications/tbl/caip99.pdf>`__
    """
    xp = array_namespace(x)
    x = xpx.atleast_nd(xp.asarray(x), ndim=2, xp=xp)
    if xp.isdtype(x.dtype, 'complex floating'):
        raise ValueError("x must be real.")
    if len(axes) != 2:
        raise ValueError("axes must be a tuple of length 2")
    if axes[0] == axes[1]:
        raise ValueError("axes must contain 2 distinct axes")

    if N is None:
        N = (x.shape[axes[0]], x.shape[axes[1]])
    elif isinstance(N, int):
        if N <= 0:
            raise ValueError("N must be positive.")
        N = (N, N)
    elif len(N) != 2 or xp.any(xp.asarray(N) <= 0):
        raise ValueError("When given as a tuple, N must hold exactly "
                         "two positive integers")

    Xf = sp_fft.fft2(x, N, axes=axes)
    Xf = xp.moveaxis(Xf, axes, (-2, -1))
    end = [N[i] // 2 if N[i] % 2 == 0 else (N[i] + 1) // 2 for i in [0, 1]]
    start = [N[i] // 2 + 1 if N[i] % 2 == 0 else (N[i] + 1) // 2 for i in [0, 1]]

    Xf[..., 1:end[0], :] *= 2.0
    Xf[..., 1:end[1]] *= 2.0
    Xf[..., start[0]:N[0], :] = 0.0
    Xf[..., start[1]:N[1]] = 0.0

    Xf = xp.moveaxis(Xf, (-2, -1), axes)
    x = sp_fft.ifft2(Xf, axes=axes)
    return x


def envelope(z, bp_in: tuple[int | None, int | None] = (1, None), *,
             n_out: int | None = None, squared: bool = False,
             residual: Literal['lowpass', 'all', None] = 'lowpass',
             axis: int = -1):
    r"""Compute the envelope of a real- or complex-valued signal.

    Parameters
    ----------
    z : ndarray
        Real- or complex-valued input signal, which is assumed to be made up of ``n``
        samples and having sampling interval ``T``. `z` may also be a multidimensional
        array with the time axis being defined by `axis`.
    bp_in : tuple[int | None, int | None], optional
        2-tuple defining the frequency band ``bp_in[0]:bp_in[1]`` of the input filter.

Review comment (suggestion):
Couldn't this be simplified to:
```python
nn = np.array(N)
end, start = (nn + 1) // 2, nn // 2 + 1
```

A cosmetic note of personal taste: Having the variable `N` not denote a single value but a tuple feels off to me.

Code after:
    >>> x[0, 0] += 1
    ...
    >>> X = fftshift(fft2(x))  # Zero frequency bin is at center
    >>> print(X)
    [[1.-0.j 1.-0.j 1.-0.j 1.+0.j 1.+0.j]
     [1.-0.j 1.-0.j 1.-0.j 1.+0.j 1.+0.j]
     [1.-0.j 1.-0.j 6.-0.j 1.+0.j 1.+0.j]
     [1.-0.j 1.-0.j 1.+0.j 1.+0.j 1.+0.j]
     [1.-0.j 1.-0.j 1.+0.j 1.+0.j 1.+0.j]]
    >>> x_a = hilbert2(x)
    >>> X_a = fftshift(fft2(x_a))
    >>> print(np.round(X_a, 3))
    [[ 0.+0.j  0.+0.j -0.+0.j  0.+0.j  0.+0.j]
     [ 0.+0.j  0.+0.j -0.+0.j  0.+0.j  0.+0.j]
     [ 0.+0.j  0.+0.j  6.+0.j  2.+0.j  2.+0.j]
     [ 0.+0.j  0.+0.j  2.+0.j  4.+0.j  4.+0.j]
     [ 0.+0.j  0.+0.j  2.+0.j  4.+0.j  4.+0.j]]

    The FFT of the result illustrates that the values of the lower right quadrant (or
    orthant) with purely positive frequency bins have been quadrupled. The values at its
    borders, where only one frequency component is zero, are doubled. The zero frequency
    bin ``(0, 0)`` has not been altered. All other quadrants have been set to zero.

    This second example illustrates a problem with the "single-orthant" convention. A
    purely real signal can produce an analytic signal which is completely zero:

    >>> from scipy.fft import fft2, fftshift, ifft2, ifftshift
    >>> from scipy.signal import hilbert2
    ...
    >>> # Create a real signal by ensuring `Z[-p,-q] == np.conj(Z[p,q])` holds:
    >>> Z = np.array([[0, 0, 0, 0, 0],
    ...               [0, 0, 0, 1, 0],
    ...               [0, 0, 0, 0, 0],
    ...               [0, 1, 0, 0, 0],
    ...               [0, 0, 0, 0, 0]]) * 25
    >>> z = ifft2(ifftshift(Z))
    >>> np.allclose(z.imag, 0)  # z is a real signal
    True
    >>> np.sum(z.real**2)  # z.real is non-zero
    np.float64(50.0)
    >>> z_a = hilbert2(z.real)
    >>> np.allclose(z_a, 0)  # analytic signal is zero
    True

    """
    xp = array_namespace(x)
    x = xpx.atleast_nd(xp.asarray(x), ndim=2, xp=xp)
    if xp.isdtype(x.dtype, 'complex floating'):
        raise ValueError("x must be real.")
    if len(axes) != 2:
        raise ValueError("axes must be a tuple of length 2")