Repository: scipy/scipy

Issue:
BUG: DST 2 and 3 with `norm="ortho"` are not orthogonal
### Describe your issue.

Hi,

It seems that even with the option `norm="ortho"`, the DST is not properly orthogonalized. It seems that the wrong element gets rescaled (the last element should be rescaled, instead of the first). Rescaling the last element is coherent with [wikipedia's definition](https://en.wikipedia.org/wiki/Discrete_sine_transform#DST-II). 

It is only an issue with the DST, the DCT works properly.

I've attached a snippet that shows that the DST is not orthogonal, manually reproduces scipy's orthogonalization strategy, and shows that the alternative strategy does yield an orthogonal transform. I've only tested it with version 1.10, but rescaling the first element instead of the last is documented as far back as [version 1.4](https://docs.scipy.org/doc/scipy-1.4.0/reference/generated/scipy.fft.dst.html#scipy.fft.dst).

I am surprised that this question has not come up before, so I may be missing something.


### Reproducing Code Example

```python
import numpy as np
from scipy.fft import dst


def make_mat_dst_scipy(n, type=2):
    """
    Build coefficients matrix
    """
    x = np.eye(n)
    y = dst(x, type=type, norm="ortho", axis=-1).T
    return y


def make_mat_dst_scipy_reproduce(n, type=2):
    """
    Reproduce Scipy's "ortho" normalization
    """
    x = np.eye(n)
    x /= (2 * n) ** 0.5
    if type == 3:
        x[:, 0] *= 2**0.5
    y = dst(x, type=type, norm=None, axis=-1).T
    if type == 2:
        y[0, :] /= 2**0.5
    return y


def make_mat_dst_scipy_fixed(n, type=2):
    """
    Fix it by normalizing the last element instead of the first
    """
    x = np.eye(n)
    x /= (2 * n) ** 0.5
    if type == 3:
        x[:, -1] *= 2**0.5
    y = dst(x, type=type, norm=None, axis=-1).T
    if type == 2:
        y[-1, :] /= 2**0.5
    return y


print('Scipy')
DST2 = make_mat_dst_scipy(4, type=2)
print(np.round((DST2 @ DST2.T), 3))
DST3 = make_mat_dst_scipy(4, type=3)
print(np.round((DST3 @ DST3.T), 3))


print('Scipy (reproduce)')
DST2 = make_mat_dst_scipy_reproduce(4, type=2)
print(np.round((DST2 @ DST2.T), 3))
DST3 = make_mat_dst_scipy_reproduce(4, type=3)
print(np.round((DST3 @ DST3.T), 3))


print('Fix')
DST2 = make_mat_dst_scipy_fixed(4, type=2)
print(np.round((DST2 @ DST2.T), 3))
DST3 = make_mat_dst_scipy_fixed(4, type=3)
print(np.round((DST3 @ DST3.T), 3))
```


### Error message

```shell
# This shows the result from the snippet

Scipy
[[ 0.5  0.  -0.   0. ]
 [ 0.   1.   0.   0. ]
 [-0.   0.   1.  -0. ]
 [ 0.   0.  -0.   2. ]]
[[0.948 0.302 0.052 0.198]
 [0.302 1.302 0.552 0.052]
 [0.052 0.552 1.302 0.302]
 [0.198 0.052 0.302 0.948]]

Scipy (reproduce)
[[ 0.5  0.  -0.  -0. ]
 [ 0.   1.   0.   0. ]
 [-0.   0.   1.  -0. ]
 [-0.   0.  -0.   2. ]]
[[0.948 0.302 0.052 0.198]
 [0.302 1.302 0.552 0.052]
 [0.052 0.552 1.302 0.302]
 [0.198 0.052 0.302 0.948]]

Fix
[[ 1.  0.  0. -0.]
 [ 0.  1.  0.  0.]
 [ 0.  0.  1. -0.]
 [-0.  0. -0.  1.]]
[[ 1. -0.  0. -0.]
 [-0.  1. -0.  0.]
 [ 0. -0.  1. -0.]
 [-0.  0. -0.  1.]]
```


### SciPy/NumPy/Python version and system information

```shell
1.10.1 1.24.2 sys.version_info(major=3, minor=8, micro=16, releaselevel='final', serial=0)
lapack_mkl_info:
  NOT AVAILABLE
openblas_lapack_info:
  NOT AVAILABLE
openblas_clapack_info:
  NOT AVAILABLE
flame_info:
  NOT AVAILABLE
atlas_3_10_threads_info:
  NOT AVAILABLE
atlas_3_10_info:
  NOT AVAILABLE
atlas_threads_info:
  NOT AVAILABLE
atlas_info:
  NOT AVAILABLE
lapack_info:
    libraries = ['lapack', 'blas', 'lapack', 'blas']
    library_dirs = ['/opt/miniconda3/envs/jitfields/lib']
    language = f77
blas_mkl_info:
  NOT AVAILABLE
blis_info:
  NOT AVAILABLE
openblas_info:
  NOT AVAILABLE
atlas_3_10_blas_threads_info:
  NOT AVAILABLE
atlas_3_10_blas_info:
  NOT AVAILABLE
atlas_blas_threads_info:
  NOT AVAILABLE
atlas_blas_info:
  NOT AVAILABLE
blas_info:
    libraries = ['cblas', 'blas', 'cblas', 'blas']
    library_dirs = ['/opt/miniconda3/envs/jitfields/lib']
    include_dirs = ['/opt/miniconda3/envs/jitfields/include']
    language = c
    define_macros = [('HAVE_CBLAS', None)]
blas_opt_info:
    define_macros = [('NO_ATLAS_INFO', 1), ('HAVE_CBLAS', None)]
    libraries = ['cblas', 'blas', 'cblas', 'blas']
    library_dirs = ['/opt/miniconda3/envs/jitfields/lib']
    include_dirs = ['/opt/miniconda3/envs/jitfields/include']
    language = c
lapack_opt_info:
    libraries = ['lapack', 'blas', 'lapack', 'blas', 'cblas', 'blas', 'cblas', 'blas']
    library_dirs = ['/opt/miniconda3/envs/jitfields/lib']
    language = c
    define_macros = [('NO_ATLAS_INFO', 1), ('HAVE_CBLAS', None)]
    include_dirs = ['/opt/miniconda3/envs/jitfields/include']
```



Discussion:
Sorry for noticing this so late! (I'm only looking over `numpy/scipy` issues every few weeks to see if any FFT problems have turned up...)

I remember that we added the `ortho` flag at a very late stage when `pocketfft` was integrated into `scipy`, and since I'm not using it myself, I relied on `scipy`'s own testing to find potential bugs. I'll try to provide a PR soon.

Resolving patch:
diff --git a/scipy/fft/_realtransforms.py b/scipy/fft/_realtransforms.py
--- a/scipy/fft/_realtransforms.py
+++ b/scipy/fft/_realtransforms.py
@@ -582,7 +582,7 @@ def dst(x, type=2, n=None, axis=-1, norm=None, overwrite_x=False, workers=None,
 
         y_k = 2 \sum_{n=0}^{N-1} x_n \sin\left(\frac{\pi(k+1)(2n+1)}{2N}\right)
 
-    If ``orthogonalize=True``, ``y[0]`` is divided :math:`\sqrt{2}` which, when
+    If ``orthogonalize=True``, ``y[-1]`` is divided :math:`\sqrt{2}` which, when
     combined with ``norm="ortho"``, makes the corresponding matrix of
     coefficients orthonormal (``O @ O.T = np.eye(N)``).
 
@@ -597,7 +597,7 @@ def dst(x, type=2, n=None, axis=-1, norm=None, overwrite_x=False, workers=None,
         y_k = (-1)^k x_{N-1} + 2 \sum_{n=0}^{N-2} x_n \sin\left(
         \frac{\pi(2k+1)(n+1)}{2N}\right)
 
-    If ``orthogonalize=True``, ``x[0]`` is multiplied by :math:`\sqrt{2}`
+    If ``orthogonalize=True``, ``x[-1]`` is multiplied by :math:`\sqrt{2}`
     which, when combined with ``norm="ortho"``, makes the corresponding matrix
     of coefficients orthonormal (``O @ O.T = np.eye(N)``).
 


Test patch:
diff --git a/scipy/fft/tests/test_real_transforms.py b/scipy/fft/tests/test_real_transforms.py
--- a/scipy/fft/tests/test_real_transforms.py
+++ b/scipy/fft/tests/test_real_transforms.py
@@ -199,7 +199,7 @@ def test_orthogonalize_dcst2(func, norm):
     y1 = func(x, type=2, norm=norm, orthogonalize=True)
     y2 = func(x, type=2, norm=norm, orthogonalize=False)
 
-    y2[0] /= SQRT_2
+    y2[0 if func == dct else -1] /= SQRT_2
     assert_allclose(y1, y2)
 
 
@@ -208,7 +208,7 @@ def test_orthogonalize_dcst2(func, norm):
 def test_orthogonalize_dcst3(func, norm):
     x = np.random.rand(100)
     x2 = x.copy()
-    x2[0] *= SQRT_2
+    x2[0 if func == dct else -1] *= SQRT_2
 
     y1 = func(x, type=3, norm=norm, orthogonalize=True)
     y2 = func(x2, type=3, norm=norm, orthogonalize=False)