Repository: python-control/python-control

Issue:
Unexpected and unwanted behaviour of control.ctrb
I have found some unexplainable and unwanted behavior of the `control.ctrb` functions, which returns a 'controllability matrix' with incorrect dimensions rather than returning an error/warning that the $B$ matrix is not dimensioned properly.

**MWE:**

```
import numpy as np
import control as ct

A = np.array([[1, 1], [0, 1]])
b = np.array([1, 1])
C = ct.ctrb(A, b)  # Rather then 1) returning an error that b is not properly dimensioned, or 2) assuming that b is a columns vector, it instead returns a matrix with shape 2 x 4, which makes no sense for a controllability matrix

C_T = ct.ctrb(A, np.atleast_2d(b).T)  # Explicitly specifying that b is a column vector fixes this and C_T is now a 2 x 2 matrix
```

I believe the behavior is incorrect, and it took me quite some time to understand what was going wrong; any (dis)agreement would be appreciated, thanks.


Discussion:
the arguments for `ctrb` and `obsv` aren't checked for size.

```
In [42]: ct.ctrb(np.eye(2), 0.5)
Out[42]: 
array([[0.5, 0.5],
       [0.5, 0.5]])

In [43]: ct.ctrb(0.3*np.eye(2), 0.5)
Out[43]: 
array([[0.5 , 0.15],
       [0.5 , 0.15]])

In [44]: ct.ctrb(0.1*np.eye(2), 1)
Out[44]: 
array([[1. , 0.1],
       [1. , 0.1]])

In [45]: ct.ctrb(0.1*np.eye(2), np.array([1,1]))
Out[45]: 
array([[1. , 1. , 0.1, 0.1],
       [1. , 1. , 0.1, 0.1]])

In [46]: ct.obsv(0.1*np.eye(2), 1)
Out[46]: 
array([[1. , 1. ],
       [0.1, 0.1]])

In [47]: ct.obsv(0.1*np.eye(2), [1, 1])
Out[47]: 
array([[1. , 1. ],
       [0.1, 0.1]])

In [48]: ct.obsv(0.1*np.eye(2), [1, 1, 1])
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[48], line 1
----> 1 ct.obsv(0.1*np.eye(2), [1, 1, 1])

File ~/miniforge3/envs/work/lib/python3.12/site-packages/control/statefbk.py:1070, in obsv(A, C, t)
   1068 # Construct the observability matrix
   1069 obsv = np.zeros((t * p, n))
-> 1070 obsv[:p, :] = cmat
   1072 for k in range(1, t):
   1073     obsv[k * p:(k + 1) * p, :] = np.dot(obsv[(k - 1) * p:k * p, :], amat)

ValueError: could not broadcast input array from shape (1,3) into shape (1,2)
```

The fix seems straightforward -- adapt the `_ssmatrix` calls and size checks from the `statesp.StateSpace.__init__`.



Patch:
diff --git a/control/statefbk.py b/control/statefbk.py
index c5c5a8030..57df28d45 100644
--- a/control/statefbk.py
+++ b/control/statefbk.py
@@ -47,12 +47,12 @@
 from . import statesp
 from .config import _process_legacy_keyword
 from .exception import ControlArgument, ControlDimension, \
-    ControlNotImplemented, ControlSlycot
+    ControlSlycot
 from .iosys import _process_indices, _process_labels, isctime, isdtime
 from .lti import LTI
 from .mateqn import _check_shape, care, dare
 from .nlsys import NonlinearIOSystem, interconnect
-from .statesp import StateSpace, _convert_to_statespace, _ssmatrix, ss
+from .statesp import StateSpace, _ssmatrix, ss
 
 # Make sure we have access to the right slycot routines
 try:
@@ -1098,19 +1098,26 @@ def ctrb(A, B, t=None):
     """
 
     # Convert input parameters to matrices (if they aren't already)
-    amat = _ssmatrix(A)
-    bmat = _ssmatrix(B)
-    n = np.shape(amat)[0]
-    m = np.shape(bmat)[1]
+    A = _ssmatrix(A)
+    if np.asarray(B).ndim == 1 and len(B) == A.shape[0]:
+        B = _ssmatrix(B, axis=0)
+    else:
+        B = _ssmatrix(B)
+
+    n = A.shape[0]
+    m = B.shape[1]
+
+    _check_shape('A', A, n, n, square=True)
+    _check_shape('B', B, n, m)
 
     if t is None or t > n:
         t = n
 
     # Construct the controllability matrix
     ctrb = np.zeros((n, t * m))
-    ctrb[:, :m] = bmat
+    ctrb[:, :m] = B
     for k in range(1, t):
-        ctrb[:, k * m:(k + 1) * m] = np.dot(amat, ctrb[:, (k - 1) * m:k * m])
+        ctrb[:, k * m:(k + 1) * m] = np.dot(A, ctrb[:, (k - 1) * m:k * m])
 
     return _ssmatrix(ctrb)
 
@@ -1140,20 +1147,24 @@ def obsv(A, C, t=None):
     """
 
     # Convert input parameters to matrices (if they aren't already)
-    amat = _ssmatrix(A)
-    cmat = _ssmatrix(C)
-    n = np.shape(amat)[0]
-    p = np.shape(cmat)[0]
+    A = _ssmatrix(A)
+    C = _ssmatrix(C)
+
+    n = np.shape(A)[0]
+    p = np.shape(C)[0]
+
+    _check_shape('A', A, n, n, square=True)
+    _check_shape('C', C, p, n)
 
     if t is None or t > n:
         t = n
 
     # Construct the observability matrix
     obsv = np.zeros((t * p, n))
-    obsv[:p, :] = cmat
+    obsv[:p, :] = C
 
     for k in range(1, t):
-        obsv[k * p:(k + 1) * p, :] = np.dot(obsv[(k - 1) * p:k * p, :], amat)
+        obsv[k * p:(k + 1) * p, :] = np.dot(obsv[(k - 1) * p:k * p, :], A)
 
     return _ssmatrix(obsv)
 


Test patch:
diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py
index 22a946fe3..110c8dbc7 100644
--- a/control/tests/statefbk_test.py
+++ b/control/tests/statefbk_test.py
@@ -7,10 +7,10 @@
 import pytest
 import itertools
 import warnings
-from math import pi, atan
+from math import pi
 
 import control as ct
-from control import lqe, dlqe, poles, rss, ss, tf
+from control import poles, rss, ss, tf
 from control.exception import ControlDimension, ControlSlycot, \
     ControlArgument, slycot_check
 from control.mateqn import care, dare
@@ -57,6 +57,23 @@ def testCtrbT(self):
         Wc = ctrb(A, B, t=t)
         np.testing.assert_array_almost_equal(Wc, Wctrue)
 
+    def testCtrbNdim1(self):
+        # gh-1097: treat 1-dim B as nx1
+        A = np.array([[1., 2.], [3., 4.]])
+        B = np.array([5., 7.])
+        Wctrue = np.array([[5., 19.], [7., 43.]])
+        Wc = ctrb(A, B)
+        np.testing.assert_array_almost_equal(Wc, Wctrue)
+
+    def testCtrbRejectMismatch(self):
+        # gh-1097: check A, B for compatible shapes
+        with pytest.raises(ControlDimension, match='A must be a square matrix'):
+            ctrb([[1,2]],[1])
+        with pytest.raises(ControlDimension, match='Incompatible dimensions of B matrix'):
+            ctrb([[1,2],[2,3]], 1)
+        with pytest.raises(ControlDimension, match='Incompatible dimensions of B matrix'):
+            ctrb([[1,2],[2,3]], [[1,2]])
+
     def testObsvSISO(self):
         A = np.array([[1., 2.], [3., 4.]])
         C = np.array([[5., 7.]])
@@ -79,6 +96,23 @@ def testObsvT(self):
         Wo = obsv(A, C, t=t)
         np.testing.assert_array_almost_equal(Wo, Wotrue)
 
+    def testObsvNdim1(self):
+        # gh-1097: treat 1-dim C as 1xn
+        A = np.array([[1., 2.], [3., 4.]])
+        C = np.array([5., 7.])
+        Wotrue = np.array([[5., 7.], [26., 38.]])
+        Wo = obsv(A, C)
+        np.testing.assert_array_almost_equal(Wo, Wotrue)
+
+    def testObsvRejectMismatch(self):
+        # gh-1097: check A, C for compatible shapes
+        with pytest.raises(ControlDimension, match='A must be a square matrix'):
+            obsv([[1,2]],[1])
+        with pytest.raises(ControlDimension, match='Incompatible dimensions of C matrix'):
+            obsv([[1,2],[2,3]], 1)
+        with pytest.raises(ControlDimension, match='Incompatible dimensions of C matrix'):
+            obsv([[1,2],[2,3]], [[1],[2]])
+
     def testCtrbObsvDuality(self):
         A = np.array([[1.2, -2.3], [3.4, -4.5]])
         B = np.array([[5.8, 6.9], [8., 9.1]])
@@ -935,7 +969,6 @@ def unicycle_update(t, x, u, params):
         states=['x_', 'y_', 'theta_'],
         params={'a': 1})        # only used for testing params
 
-from math import pi
 
 @pytest.mark.parametrize("method", ['nearest', 'linear', 'cubic'])
 def test_gainsched_unicycle(unicycle, method):