Repository: python-control/python-control
Pull request #1116 (2025-02-02 05:58:36)

Update _ssmatrix and _check_shape for consistent usage
Prompted by issue #1100, this PR updates the `_ssmatrix` and `_check_shape` functions to make their usage more consistent and remove legacy calls that are no longer needed.

Changes:
* Updated `_ssmatrix` to allow checking for square matrices and row/col dimensions, with improved error messages that include the name of the matrix generating the error.
* Functions that accept state-space matrices now call `_ssmatrix` for uniform processing.
* Removed unneeded calls to `_ssmatrix` on outputs of functions in `mateqn.py` and other places.  These calls were used back when we supported the NumPy `matrix` class and are no longer required.
* Fixed `acker` to use the matrices generated by `_ssmatrix`.
* Updated `_check_shape` to have a call signature that is more conistent with `_ssmatrix`: the matrix and dimensions are first, followed by optional keywords, with a `name` keyword for the name of the matrix being checked (for error messages).
* Removed internal `_check_shape` in `statefbk.py` and replaced with a call to `mateqn._check_shape`.
* Updated unit tests to reflect the new wording of some error messages.


Patch:
diff --git a/control/mateqn.py b/control/mateqn.py
index b73abdfc..52b69e2b 100644
--- a/control/mateqn.py
+++ b/control/mateqn.py
@@ -44,7 +44,6 @@ from scipy.linalg import eigvals, solve
 
 from .exception import ControlSlycot, ControlArgument, ControlDimension, \
     slycot_check
-from .statesp import _ssmatrix
 
 # Make sure we have access to the right slycot routines
 try:
@@ -151,12 +150,12 @@ def lyap(A, Q, C=None, E=None, method=None):
     m = Q.shape[0]
 
     # Check to make sure input matrices are the right shape and type
-    _check_shape("A", A, n, n, square=True)
+    _check_shape(A, n, n, square=True, name="A")
 
     # Solve standard Lyapunov equation
     if C is None and E is None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, n, n, square=True, symmetric=True)
+        _check_shape(Q, n, n, square=True, symmetric=True, name="Q")
 
         if method == 'scipy':
             # Solve the Lyapunov equation using SciPy
@@ -171,8 +170,8 @@ def lyap(A, Q, C=None, E=None, method=None):
     # Solve the Sylvester equation
     elif C is not None and E is None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, m, m, square=True)
-        _check_shape("C", C, n, m)
+        _check_shape(Q, m, m, square=True, name="Q")
+        _check_shape(C, n, m, name="C")
 
         if method == 'scipy':
             # Solve the Sylvester equation using SciPy
@@ -184,8 +183,8 @@ def lyap(A, Q, C=None, E=None, method=None):
     # Solve the generalized Lyapunov equation
     elif C is None and E is not None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, n, n, square=True, symmetric=True)
-        _check_shape("E", E, n, n, square=True)
+        _check_shape(Q, n, n, square=True, symmetric=True, name="Q")
+        _check_shape(E, n, n, square=True, name="E")
 
         if method == 'scipy':
             raise ControlArgument(
@@ -210,7 +209,7 @@ def lyap(A, Q, C=None, E=None, method=None):
     else:
         raise ControlArgument("Invalid set of input parameters")
 
-    return _ssmatrix(X)
+    return X
 
 
 def dlyap(A, Q, C=None, E=None, method=None):
@@ -281,12 +280,12 @@ def dlyap(A, Q, C=None, E=None, method=None):
     m = Q.shape[0]
 
     # Check to make sure input matrices are the right shape and type
-    _check_shape("A", A, n, n, square=True)
+    _check_shape(A, n, n, square=True, name="A")
 
     # Solve standard Lyapunov equation
     if C is None and E is None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, n, n, square=True, symmetric=True)
+        _check_shape(Q, n, n, square=True, symmetric=True, name="Q")
 
         if method == 'scipy':
             # Solve the Lyapunov equation using SciPy
@@ -301,8 +300,8 @@ def dlyap(A, Q, C=None, E=None, method=None):
     # Solve the Sylvester equation
     elif C is not None and E is None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, m, m, square=True)
-        _check_shape("C", C, n, m)
+        _check_shape(Q, m, m, square=True, name="Q")
+        _check_shape(C, n, m, name="C")
 
         if method == 'scipy':
             raise ControlArgument(
@@ -314,8 +313,8 @@ def dlyap(A, Q, C=None, E=None, method=None):
     # Solve the generalized Lyapunov equation
     elif C is None and E is not None:
         # Check to make sure input matrices are the right shape and type
-        _check_shape("Q", Q, n, n, square=True, symmetric=True)
-        _check_shape("E", E, n, n, square=True)
+        _check_shape(Q, n, n, square=True, symmetric=True, name="Q")
+        _check_shape(E, n, n, square=True, name="E")
 
         if method == 'scipy':
             raise ControlArgument(
@@ -333,7 +332,7 @@ def dlyap(A, Q, C=None, E=None, method=None):
     else:
         raise ControlArgument("Invalid set of input parameters")
 
-    return _ssmatrix(X)
+    return X
 
 
 #
@@ -407,10 +406,10 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
     m = B.shape[1]
 
     # Check to make sure input matrices are the right shape and type
-    _check_shape(_As, A, n, n, square=True)
-    _check_shape(_Bs, B, n, m)
-    _check_shape(_Qs, Q, n, n, square=True, symmetric=True)
-    _check_shape(_Rs, R, m, m, square=True, symmetric=True)
+    _check_shape(A, n, n, square=True, name=_As)
+    _check_shape(B, n, m, name=_Bs)
+    _check_shape(Q, n, n, square=True, symmetric=True, name=_Qs)
+    _check_shape(R, m, m, square=True, symmetric=True, name=_Rs)
 
     # Solve the standard algebraic Riccati equation
     if S is None and E is None:
@@ -423,7 +422,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
             X = sp.linalg.solve_continuous_are(A, B, Q, R)
             K = np.linalg.solve(R, B.T @ X)
             E, _ = np.linalg.eig(A - B @ K)
-            return _ssmatrix(X), E, _ssmatrix(K)
+            return X, E, K
 
         # Make sure we can import required slycot routines
         try:
@@ -448,7 +447,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
 
         # Return the solution X, the closed-loop eigenvalues L and
         # the gain matrix G
-        return _ssmatrix(X), w[:n], _ssmatrix(G)
+        return X, w[:n], G
 
     # Solve the generalized algebraic Riccati equation
     else:
@@ -457,8 +456,8 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
         E = np.eye(A.shape[0]) if E is None else np.array(E, ndmin=2)
 
         # Check to make sure input matrices are the right shape and type
-        _check_shape(_Es, E, n, n, square=True)
-        _check_shape(_Ss, S, n, m)
+        _check_shape(E, n, n, square=True, name=_Es)
+        _check_shape(S, n, m, name=_Ss)
 
         # See if we should solve this using SciPy
         if method == 'scipy':
@@ -469,7 +468,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
             X = sp.linalg.solve_continuous_are(A, B, Q, R, s=S, e=E)
             K = np.linalg.solve(R, B.T @ X @ E + S.T)
             eigs, _ = sp.linalg.eig(A - B @ K, E)
-            return _ssmatrix(X), eigs, _ssmatrix(K)
+            return X, eigs, K
 
         # Make sure we can find the required slycot routine
         try:
@@ -494,7 +493,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
 
         # Return the solution X, the closed-loop eigenvalues L and
         # the gain matrix G
-        return _ssmatrix(X), L, _ssmatrix(G)
+        return X, L, G
 
 def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
          _As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E"):
@@ -564,14 +563,14 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
     m = B.shape[1]
 
     # Check to make sure input matrices are the right shape and type
-    _check_shape(_As, A, n, n, square=True)
-    _check_shape(_Bs, B, n, m)
-    _check_shape(_Qs, Q, n, n, square=True, symmetric=True)
-    _check_shape(_Rs, R, m, m, square=True, symmetric=True)
+    _check_shape(A, n, n, square=True, name=_As)
+    _check_shape(B, n, m, name=_Bs)
+    _check_shape(Q, n, n, square=True, symmetric=True, name=_Qs)
+    _check_shape(R, m, m, square=True, symmetric=True, name=_Rs)
     if E is not None:
-        _check_shape(_Es, E, n, n, square=True)
+        _check_shape(E, n, n, square=True, name=_Es)
     if S is not None:
-        _check_shape(_Ss, S, n, m)
+        _check_shape(S, n, m, name=_Ss)
 
     # Figure out how to solve the problem
     if method == 'scipy':
@@ -589,7 +588,7 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
         else:
             L, _ = sp.linalg.eig(A - B @ G, E)
 
-        return _ssmatrix(X), L, _ssmatrix(G)
+        return X, L, G
 
     # Make sure we can import required slycot routine
     try:
@@ -618,7 +617,7 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
 
     # Return the solution X, the closed-loop eigenvalues L and
     # the gain matrix G
-    return _ssmatrix(X), L, _ssmatrix(G)
+    return X, L, G
 
 
 # Utility function to decide on method to use
@@ -632,7 +631,7 @@ def _slycot_or_scipy(method):
 
 
 # Utility function to check matrix dimensions
-def _check_shape(name, M, n, m, square=False, symmetric=False):
+def _check_shape(M, n, m, square=False, symmetric=False, name="??"):
     if square and M.shape[0] != M.shape[1]:
         raise ControlDimension("%s must be a square matrix" % name)
 
@@ -640,7 +639,9 @@ def _check_shape(name, M, n, m, square=False, symmetric=False):
         raise ControlArgument("%s must be a symmetric matrix" % name)
 
     if M.shape[0] != n or M.shape[1] != m:
-        raise ControlDimension("Incompatible dimensions of %s matrix" % name)
+        raise ControlDimension(
+            f"Incompatible dimensions of {name} matrix; "
+            f"expected ({n}, {m}) but found {M.shape}")
 
 
 # Utility function to check if a matrix is symmetric
diff --git a/control/statefbk.py b/control/statefbk.py
index 7b96c801..87b12da8 100644
--- a/control/statefbk.py
+++ b/control/statefbk.py
@@ -50,7 +50,7 @@ from .exception import ControlArgument, ControlDimension, \
     ControlSlycot
 from .iosys import _process_indices, _process_labels, isctime, isdtime
 from .lti import LTI
-from .mateqn import _check_shape, care, dare
+from .mateqn import care, dare
 from .nlsys import NonlinearIOSystem, interconnect
 from .statesp import StateSpace, _ssmatrix, ss
 
@@ -130,21 +130,15 @@ def place(A, B, p):
     from scipy.signal import place_poles
 
     # Convert the system inputs to NumPy arrays
-    A_mat = np.array(A)
-    B_mat = np.array(B)
-    if (A_mat.shape[0] != A_mat.shape[1]):
-        raise ControlDimension("A must be a square matrix")
-
-    if (A_mat.shape[0] != B_mat.shape[0]):
-        err_str = "The number of rows of A must equal the number of rows in B"
-        raise ControlDimension(err_str)
+    A_mat = _ssmatrix(A, square=True, name="A")
+    B_mat = _ssmatrix(B, axis=0, rows=A_mat.shape[0])
 
     # Convert desired poles to numpy array
     placed_eigs = np.atleast_1d(np.squeeze(np.asarray(p)))
 
     result = place_poles(A_mat, B_mat, placed_eigs, method='YT')
     K = result.gain_matrix
-    return _ssmatrix(K)
+    return K
 
 
 def place_varga(A, B, p, dtime=False, alpha=None):
@@ -206,10 +200,8 @@ def place_varga(A, B, p, dtime=False, alpha=None):
         raise ControlSlycot("can't find slycot module 'sb01bd'")
 
     # Convert the system inputs to NumPy arrays
-    A_mat = np.array(A)
-    B_mat = np.array(B)
-    if (A_mat.shape[0] != A_mat.shape[1] or A_mat.shape[0] != B_mat.shape[0]):
-        raise ControlDimension("matrix dimensions are incorrect")
+    A_mat = _ssmatrix(A, square=True, name="A")
+    B_mat = _ssmatrix(B, axis=0, rows=A_mat.shape[0])
 
     # Compute the system eigenvalues and convert poles to numpy array
     system_eigs = np.linalg.eig(A_mat)[0]
@@ -246,7 +238,7 @@ def place_varga(A, B, p, dtime=False, alpha=None):
                A_mat, B_mat, placed_eigs, DICO)
 
     # Return the gain matrix, with MATLAB gain convention
-    return _ssmatrix(-F)
+    return -F
 
 
 # Contributed by Roberto Bucher <roberto.bucher@supsi.ch>
@@ -274,12 +266,12 @@ def acker(A, B, poles):
 
     """
     # Convert the inputs to matrices
-    a = _ssmatrix(A)
-    b = _ssmatrix(B)
+    A = _ssmatrix(A, square=True, name="A")
+    B = _ssmatrix(B, axis=0, rows=A.shape[0], name="B")
 
     # Make sure the system is controllable
     ct = ctrb(A, B)
-    if np.linalg.matrix_rank(ct) != a.shape[0]:
+    if np.linalg.matrix_rank(ct) != A.shape[0]:
         raise ValueError("System not reachable; pole placement invalid")
 
     # Compute the desired characteristic polynomial
@@ -288,13 +280,13 @@ def acker(A, B, poles):
     # Place the poles using Ackermann's method
     # TODO: compute pmat using Horner's method (O(n) instead of O(n^2))
     n = np.size(p)
-    pmat = p[n-1] * np.linalg.matrix_power(a, 0)
+    pmat = p[n-1] * np.linalg.matrix_power(A, 0)
     for i in np.arange(1, n):
-        pmat = pmat + p[n-i-1] * np.linalg.matrix_power(a, i)
+        pmat = pmat + p[n-i-1] * np.linalg.matrix_power(A, i)
     K = np.linalg.solve(ct, pmat)
 
-    K = K[-1][:]                # Extract the last row
-    return _ssmatrix(K)
+    K = K[-1, :]                # Extract the last row
+    return K
 
 
 def lqr(*args, **kwargs):
@@ -577,7 +569,7 @@ def dlqr(*args, **kwargs):
 
     # Compute the result (dimension and symmetry checking done in dare())
     S, E, K = dare(A, B, Q, R, N, method=method, _Ss="N")
-    return _ssmatrix(K), _ssmatrix(S), E
+    return K, S, E
 
 
 # Function to create an I/O sytems representing a state feedback controller
@@ -1098,17 +1090,11 @@ def ctrb(A, B, t=None):
     """
 
     # Convert input parameters to matrices (if they aren't already)
-    A = _ssmatrix(A)
-    if np.asarray(B).ndim == 1 and len(B) == A.shape[0]:
-        B = _ssmatrix(B, axis=0)
-    else:
-        B = _ssmatrix(B)
-
+    A = _ssmatrix(A, square=True, name="A")
     n = A.shape[0]
-    m = B.shape[1]
 
-    _check_shape('A', A, n, n, square=True)
-    _check_shape('B', B, n, m)
+    B = _ssmatrix(B, axis=0, rows=n, name="B")
+    m = B.shape[1]
 
     if t is None or t > n:
         t = n
@@ -1119,7 +1105,7 @@ def ctrb(A, B, t=None):
     for k in range(1, t):
         ctrb[:, k * m:(k + 1) * m] = np.dot(A, ctrb[:, (k - 1) * m:k * m])
 
-    return _ssmatrix(ctrb)
+    return ctrb
 
 
 def obsv(A, C, t=None):
@@ -1145,16 +1131,12 @@ def obsv(A, C, t=None):
     np.int64(2)
 
     """
-
     # Convert input parameters to matrices (if they aren't already)
-    A = _ssmatrix(A)
-    C = _ssmatrix(C)
-
-    n = np.shape(A)[0]
-    p = np.shape(C)[0]
+    A = _ssmatrix(A, square=True, name="A")
+    n = A.shape[0]
 
-    _check_shape('A', A, n, n, square=True)
-    _check_shape('C', C, p, n)
+    C = _ssmatrix(C, cols=n, name="C")
+    p = C.shape[0]
 
     if t is None or t > n:
         t = n
@@ -1166,7 +1148,7 @@ def obsv(A, C, t=None):
     for k in range(1, t):
         obsv[k * p:(k + 1) * p, :] = np.dot(obsv[(k - 1) * p:k * p, :], A)
 
-    return _ssmatrix(obsv)
+    return obsv
 
 
 def gram(sys, type):
@@ -1246,7 +1228,7 @@ def gram(sys, type):
         X, scale, sep, ferr, w = sb03md(
             n, C, A, U, dico, job='X', fact='N', trana=tra)
         gram = X
-        return _ssmatrix(gram)
+        return gram
 
     elif type == 'cf' or type == 'of':
         # Compute cholesky factored gramian from slycot routine sb03od
@@ -1269,4 +1251,4 @@ def gram(sys, type):
             X, scale, w = sb03od(
                 n, m, A, Q, C.transpose(), dico, fact='N', trans=tra)
         gram = X
-        return _ssmatrix(gram)
+        return gram
diff --git a/control/statesp.py b/control/statesp.py
index 44fe8b60..c0e8e786 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -31,12 +31,14 @@ from scipy.signal import cont2discrete
 
 from . import config
 from . import bdalg
-from .exception import ControlMIMONotImplemented, ControlSlycot, slycot_check
+from .exception import ControlDimension, ControlMIMONotImplemented, \
+    ControlSlycot, slycot_check
 from .frdata import FrequencyResponseData
 from .iosys import InputOutputSystem, NamedSignal, _process_dt_keyword, \
     _process_iosys_keywords, _process_signal_list, _process_subsys_index, \
     common_timebase, iosys_repr, isdtime, issiso
 from .lti import LTI, _process_frequency_response
+from .mateqn import _check_shape
 from .nlsys import InterconnectedSystem, NonlinearIOSystem
 import control
 
@@ -199,21 +201,18 @@ class StateSpace(NonlinearIOSystem, LTI):
             raise TypeError(
                 "Expected 1, 4, or 5 arguments; received %i." % len(args))
 
-        # Convert all matrices to standard form
-        A = _ssmatrix(A)
-        # if B is a 1D array, turn it into a column vector if it fits
-        if np.asarray(B).ndim == 1 and len(B) == A.shape[0]:
-            B = _ssmatrix(B, axis=0)
-        else:
-            B = _ssmatrix(B)
-        if np.asarray(C).ndim == 1 and len(C) == A.shape[0]:
-            C = _ssmatrix(C, axis=1)
-        else:
-            C = _ssmatrix(C, axis=0)    # if this doesn't work, error below
+        # Convert all matrices to standard form (sizes checked later)
+        A = _ssmatrix(A, square=True, name="A")
+        B = _ssmatrix(
+            B, axis=0 if np.asarray(B).ndim == 1 and len(B) == A.shape[0]
+            else 1, name="B")
+        C = _ssmatrix(
+            C, axis=1 if np.asarray(C).ndim == 1 and len(C) == A.shape[0]
+            else 0, name="C")
         if np.isscalar(D) and D == 0 and B.shape[1] > 0 and C.shape[0] > 0:
             # If D is a scalar zero, broadcast it to the proper size
             D = np.zeros((C.shape[0], B.shape[1]))
-        D = _ssmatrix(D)
+        D = _ssmatrix(D, name="D")
 
         # If only direct term is present, adjust sizes of C and D if needed
         if D.size > 0 and B.size == 0:
@@ -260,19 +259,11 @@ class StateSpace(NonlinearIOSystem, LTI):
             B.shape = (0, self.ninputs)
             C.shape = (self.noutputs, 0)
 
-        #
         # Check to make sure everything is consistent
-        #
-        # Check that the matrix sizes are consistent
-        def _check_shape(matrix, expected, name):
-            if matrix.shape != expected:
-                raise ValueError(
-                    f"{name} is the wrong shape; "
-                    f"expected {expected} instead of {matrix.shape}")
-        _check_shape(A, (self.nstates, self.nstates), "A")
-        _check_shape(B, (self.nstates, self.ninputs), "B")
-        _check_shape(C, (self.noutputs, self.nstates), "C")
-        _check_shape(D, (self.noutputs, self.ninputs), "D")
+        _check_shape(A, self.nstates, self.nstates, name="A")
+        _check_shape(B, self.nstates, self.ninputs, name="B")
+        _check_shape(C, self.noutputs, self.nstates, name="C")
+        _check_shape(D, self.noutputs, self.ninputs, name="D")
 
         #
         # Final processing
@@ -2230,11 +2221,11 @@ def summing_junction(
 # Utility functions
 #
 
-def _ssmatrix(data, axis=1):
+def _ssmatrix(data, axis=1, square=None, rows=None, cols=None, name=None):
     """Convert argument to a (possibly empty) 2D state space matrix.
 
-    The axis keyword argument makes it convenient to specify that if the input
-    is a vector, it is a row (axis=1) or column (axis=0) vector.
+    The axis keyword argument makes it convenient to specify that if the
+    input is a vector, it is a row (axis=1) or column (axis=0) vector.
 
     Parameters
     ----------
@@ -2243,20 +2234,31 @@ def _ssmatrix(data, axis=1):
     axis : 0 or 1
         If input data is 1D, which axis to use for return object.  The default
         is 1, corresponding to a row matrix.
+    square : bool, optional
+        If set to True, check that the input matrix is square.
+    rows : int, optional
+        If set, check that the input matrix has the given number of rows.
+    cols : int, optional
+        If set, check that the input matrix has the given number of columns.
+    name : str, optional
+        Name of the state-space matrix being checked (for error messages).
 
     Returns
     -------
     arr : 2D array, with shape (0, 0) if a is empty
 
     """
-    # Convert the data into an array
+    # Process the name of the object, if available
+    name = "" if name is None else " " + name
+
+    # Convert the data into an array (always making a copy)
     arr = np.array(data, dtype=float)
     ndim = arr.ndim
     shape = arr.shape
 
     # Change the shape of the array into a 2D array
     if (ndim > 2):
-        raise ValueError("state-space matrix must be 2-dimensional")
+        raise ValueError(f"state-space matrix{name} must be 2-dimensional")
 
     elif (ndim == 2 and shape == (1, 0)) or \
          (ndim == 1 and shape == (0, )):
@@ -2271,6 +2273,21 @@ def _ssmatrix(data, axis=1):
         # Passed a constant; turn into a matrix
         shape = (1, 1)
 
+    # Check to make sure any conditions are satisfied
+    if square and shape[0] != shape[1]:
+        raise ControlDimension(
+            f"state-space matrix{name} must be a square matrix")
+
+    if rows is not None and shape[0] != rows:
+        raise ControlDimension(
+            f"state-space matrix{name} has the wrong number of rows; "
+            f"expected {rows} instead of {shape[0]}")
+
+    if cols is not None and shape[1] != cols:
+        raise ControlDimension(
+            f"state-space matrix{name} has the wrong number of columns; "
+            f"expected {cols} instead of {shape[1]}")
+
     #  Create the actual object used to store the result
     return arr.reshape(shape)
 
@@ -2382,7 +2399,7 @@ def _convert_to_statespace(sys, use_prefix_suffix=False, method=None):
 
     # If this is a matrix, try to create a constant feedthrough
     try:
-        D = _ssmatrix(np.atleast_2d(sys))
+        D = _ssmatrix(np.atleast_2d(sys), name="D")
         return StateSpace([], [], [], D, dt=None)
 
     except Exception:
diff --git a/control/stochsys.py b/control/stochsys.py
index b31083f1..5aaa2941 100644
--- a/control/stochsys.py
+++ b/control/stochsys.py
@@ -26,7 +26,6 @@ from .iosys import InputOutputSystem, isctime, isdtime, _process_indices, \
     _process_labels, _process_control_disturbance_indices
 from .nlsys import NonlinearIOSystem
 from .mateqn import care, dare, _check_shape
-from .statesp import StateSpace, _ssmatrix
 from .exception import ControlArgument, ControlNotImplemented
 from .config import _process_legacy_keyword
 
@@ -173,12 +172,12 @@ def lqe(*args, **kwargs):
         NN = np.zeros((QN.shape[0], RN.shape[1]))
 
     # Check dimensions of G (needed before calling care())
-    _check_shape("QN", QN, G.shape[1], G.shape[1])
+    _check_shape(QN, G.shape[1], G.shape[1], name="QN")
 
     # Compute the result (dimension and symmetry checking done in care())
     P, E, LT = care(A.T, C.T, G @ QN @ G.T, RN, method=method,
                     _Bs="C", _Qs="QN", _Rs="RN", _Ss="NN")
-    return _ssmatrix(LT.T), _ssmatrix(P), E
+    return LT.T, P, E
 
 
 # contributed by Sawyer B. Fuller <minster@uw.edu>
@@ -293,12 +292,12 @@ def dlqe(*args, **kwargs):
         raise ControlNotImplemented("cross-covariance not yet implememented")
 
     # Check dimensions of G (needed before calling care())
-    _check_shape("QN", QN, G.shape[1], G.shape[1])
+    _check_shape(QN, G.shape[1], G.shape[1], name="QN")
 
     # Compute the result (dimension and symmetry checking done in dare())
     P, E, LT = dare(A.T, C.T, G @ QN @ G.T, RN, method=method,
                     _Bs="C", _Qs="QN", _Rs="RN", _Ss="NN")
-    return _ssmatrix(LT.T), _ssmatrix(P), E
+    return LT.T, P, E
 
 
 # Function to create an estimator


Test patch:
diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py
index b1fce53e..4dbf52ee 100644
--- a/control/tests/docstrings_test.py
+++ b/control/tests/docstrings_test.py
@@ -30,9 +30,9 @@ function_skiplist = [
 function_docstring_hash = {
     control.append:                     '1bddbac0fe932755c85e9fb0bfb97d88',
     control.describing_function_plot:   '95f894706b1d3eeb3b854934596af09f',
-    control.dlqe:                       '9db995ed95c2214ce97074b0616a3191',
-    control.dlqr:                       '896cfa651dbbd80e417635904d13c9d6',
-    control.lqe:                        '567bf657538935173f2e50700ba87168',
+    control.dlqe:                       'f2e52e35692cf5ffe911684d41d284c9',
+    control.dlqr:                       '56d7f3a452bc8d7a7256a52d9d1dcb37',
+    control.lqe:                        'f0ba6cde8191cbc10f052096ffc3fcbb',
     control.lqr:                        'a3e0a85f781fc9c0f69a4b7da4f0bd22',
     control.margin:                     'f02b3034f5f1d44ce26f916cc3e51600',
     control.parallel:                   'bfc470aef75dbb923f9c6fb8bf3c9b43',
diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py
index 110c8dbc..0714e6f3 100644
--- a/control/tests/statefbk_test.py
+++ b/control/tests/statefbk_test.py
@@ -67,11 +67,14 @@ class TestStatefbk:
 
     def testCtrbRejectMismatch(self):
         # gh-1097: check A, B for compatible shapes
-        with pytest.raises(ControlDimension, match='A must be a square matrix'):
+        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'):
+        with pytest.raises(
+                ControlDimension, match='B has the wrong number of rows'):
             ctrb([[1,2],[2,3]], 1)
-        with pytest.raises(ControlDimension, match='Incompatible dimensions of B matrix'):
+        with pytest.raises(
+                ControlDimension, match='B has the wrong number of rows'):
             ctrb([[1,2],[2,3]], [[1,2]])
 
     def testObsvSISO(self):
@@ -106,11 +109,14 @@ class TestStatefbk:
 
     def testObsvRejectMismatch(self):
         # gh-1097: check A, C for compatible shapes
-        with pytest.raises(ControlDimension, match='A must be a square matrix'):
+        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'):
+        with pytest.raises(
+                ControlDimension, match='C has the wrong number of columns'):
             obsv([[1,2],[2,3]], 1)
-        with pytest.raises(ControlDimension, match='Incompatible dimensions of C matrix'):
+        with pytest.raises(
+                ControlDimension, match='C has the wrong number of columns'):
             obsv([[1,2],[2,3]], [[1],[2]])
 
     def testCtrbObsvDuality(self):
diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py
index 6d57a38a..5325cb3c 100644
--- a/control/tests/statesp_test.py
+++ b/control/tests/statesp_test.py
@@ -127,19 +127,16 @@ class TestStateSpace:
          ((1, 2), TypeError, "1, 4, or 5 arguments"),
          ((np.ones((3, 2)), np.ones((3, 2)),
            np.ones((2, 2)), np.ones((2, 2))), ValueError,
-          r"A is the wrong shape; expected \(3, 3\)"),
+          r"A must be a square matrix"),
          ((np.ones((3, 3)), np.ones((2, 2)),
            np.ones((2, 3)), np.ones((2, 2))), ValueError,
-          r"B is the wrong shape; expected \(3, 2\)"),
+          r"Incompatible dimensions of B matrix; expected \(3, 2\)"),
          ((np.ones((3, 3)), np.ones((3, 2)),
            np.ones((2, 2)), np.ones((2, 2))), ValueError,
-          r"C is the wrong shape; expected \(2, 3\)"),
+          r"Incompatible dimensions of C matrix; expected \(2, 3\)"),
          ((np.ones((3, 3)), np.ones((3, 2)),
            np.ones((2, 3)), np.ones((2, 3))), ValueError,
-          r"D is the wrong shape; expected \(2, 2\)"),
-         ((np.ones((3, 3)), np.ones((3, 2)),
-           np.ones((2, 3)), np.ones((3, 2))), ValueError,
-          r"D is the wrong shape; expected \(2, 2\)"),
+          r"Incompatible dimensions of D matrix; expected \(2, 2\)"),
         ])
     def test_constructor_invalid(self, args, exc, errmsg):
         """Test invalid input to StateSpace() constructor"""