Repository: scipy/scipy

Issue:
DOC: linalg.schur: unclear signature for `sort` callable when `output='real'`
### Describe your issue.

A real matrix can have complex (conjugate) eigenvalues.  scipy.linalg.schur provides the option to produce the **real** Schur decomposition of a real matrix and also the option for a user to provide a callable (a function of an eigenvalue) to be used in sorting the eigenvalues in a user-specified way.  The issue is that the callable receives only the real part of the eigenvalue from the schur routine, not the full complex-valued eigenvalue.  The accompanying code provides a small matrix that has complex conjugate eigenvalues; running it shows that the sort callable receives only the real part of the eigenvalues.  As a result, for this example the callable classifies the eigenvalues as inside the unit circle (because of the small real part) even though as shown in the output the eigenvalues have modulus of approximately 5.

### Reproducing Code Example

```python
import numpy as np
from scipy import linalg

def ooouc(eval):
    # Test whether an eigenvalue is on or outside the unit circle
    ooouctf = abs(eval) >= 1.0
    print(type(eval))
    print('In ooouc',eval,eval.imag,ooouctf)
    return ooouctf

a = np.array([[.15,5],[-5,.2]])
eigenvalues, eigenvectors = linalg.eig(a)
print("eigenvalues =",eigenvalues)
print("modulus of eigenvalues =",abs(eigenvalues))

R,U,nbigroots = linalg.schur(a,output='real',sort=ooouc)
```


### Error message

```shell
Running the code produces this output.

eigenvalues = [0.175+4.9999375j 0.175-4.9999375j]
modulus of eigenvalues = [5.0029991 5.0029991]
<class 'float'>
In ooouc 0.17500000000000004 0.0 False
<class 'float'>
In ooouc 0.17500000000000004 0.0 False
<class 'float'>
In ooouc 0.17500000000000004 0.0 False
<class 'float'>
In ooouc 0.17500000000000004 0.0 False
```


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

```shell
1.11.4 1.26.2 sys.version_info(major=3, minor=10, micro=12, releaselevel='final', serial=0)
Build Dependencies:
  blas:
    detection method: pkgconfig
    found: true
    include directory: /usr/local/include
    lib directory: /usr/local/lib
    name: openblas
    openblas configuration: USE_64BITINT= DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= HASWELL MAX_THREADS=2
    pc file directory: /usr/local/lib/pkgconfig
    version: 0.3.21.dev
  lapack:
    detection method: pkgconfig
    found: true
    include directory: /usr/local/include
    lib directory: /usr/local/lib
    name: openblas
    openblas configuration: USE_64BITINT= DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= HASWELL MAX_THREADS=2
    pc file directory: /usr/local/lib/pkgconfig
    version: 0.3.21.dev
  pybind11:
    detection method: config-tool
    include directory: unknown
    name: pybind11
    version: 2.11.0
Compilers:
  c:
    commands: cc
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  c++:
    commands: c++
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 0.29.36
  fortran:
    commands: gfortran
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  pythran:
    include directory: ../../tmp/pip-build-env-c6c8ru56/overlay/lib/python3.10/site-packages/pythran
    version: 0.14.0
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: true
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /opt/python/cp310-cp310/bin/python
  version: '3.10'
```



Discussion:
I now believe this issue is not a bug but arises because of unclear documentation.  For the linalg.schur function, when output='real' is specified, I now see that the callable apparently must take two arguments--the first being the real part of the eigenvalue and the second being the imaginary part.  (In contrast, when output='complex', the callable takes a single argument--the complex eigenvalue.)

The linalg.schur documentation says "A callable may be passed that, given a eigenvalue, returns a boolean denoting whether the eigenvalue should be sorted to the top-left (True)."  In the output='real' case, this is potentially misleading.  Since complex numbers are available as a data type in linalg, one might assume, as I did, that "given a eigenvalue" means "given an eigenvalue as a single complex number" rather than what it evidently actually means in the output='real' case:  "given the real part of the eigenvalue and the imaginary part as two separate arguments ..."  The explanation in the documentation of the built-in string parameters (e.g. that for 'lhp') also leaves the impression that there must be a single complex argument passed to the callable, since (it only now seems clear to me) there are no built-in string parameters for the output='real' case.
Detaching the milestone: unlikely to get fixed in time for https://github.com/scipy/scipy/pull/19880#issuecomment-1992010395
thanks for the report @bfm59, would you be interested in submitting a PR to update the docs? I could probably tackle it otherwise.
Lucas, thanks for the message.  Because I have never done this sort of
thing before (I don't even know what a PR is), it probably would be most
efficient if you did it.  I'd be happy to comment on a draft if that could
be helpful.

On Thu, Apr 11, 2024 at 3:39 PM Lucas Colley ***@***.***>
wrote:

> thanks for the report @bfm59 <https://github.com/bfm59>, would you be
> interested in submitting a PR to update the docs? I could probably tackle
> it otherwise.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/scipy/scipy/issues/19791#issuecomment-2050390037>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ALQHARBNKNLYG5KCBXXZAYDY43RFJAVCNFSM6AAAAABBJJ55VWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDANJQGM4TAMBTG4>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

Okay, all good. I've assigned myself to this just so that I don't forgot about it, but anyone else is more than welcome to submit a PR as per https://docs.scipy.org/doc/scipy/dev/contributor/development_workflow.html.

Resolving patch:
diff --git a/scipy/linalg/_decomp_schur.py b/scipy/linalg/_decomp_schur.py
--- a/scipy/linalg/_decomp_schur.py
+++ b/scipy/linalg/_decomp_schur.py
@@ -42,6 +42,9 @@ def schur(a, output='real', lwork=None, overwrite_a=False, sort=None,
         Specifies whether the upper eigenvalues should be sorted. A callable
         may be passed that, given a eigenvalue, returns a boolean denoting
         whether the eigenvalue should be sorted to the top-left (True).
+        If output='real', the callable should have two arguments, the first
+        one being the real part of the eigenvalue, the second one being
+        the imaginary part.
         Alternatively, string parameters may be used::
 
             'lhp'   Left-hand plane (x.real < 0.0)


Test patch: