Repository: scipy/scipy

Issue:
signal.abcd_normalize doesn't handle missing arrays as expected (Trac #1448)
_Original ticket http://projects.scipy.org/scipy/ticket/1448 on 2011-05-27 by @WarrenWeckesser, assigned to unknown._

The function scipy.signal.abcd_normalize has the signature

```
def abcd_normalize(A=None, B=None, C=None, D=None):
```

and after reading the source, it appears that one intent of the function is to create an appropriately sized array of zeros for any array that is not give, if the size of the missing array can be inferred from the others.  However, this feature is broken:

```
In [21]: a = [[1.0, 2.0], [3.0, 4.0]]

In [22]: b = [[-1.0], [5.0]]

In [23]: c = [[4.0, 5.0]]

In [24]: d = [[2.5]]

In [25]: abcd_normalize(A=a, B=b, C=c)   # Expect fourth array to be [[0.0]]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)

/Users/warren/<ipython console> in <module>()

/Users/warren/local_tmp/lib/python2.7/site-packages/scipy/signal/ltisys.pyc in abcd_normalize(A, B, C, D)
    120         raise ValueError("C and D must have the same number of rows.")
    121     if ND != NB:
--> 122         raise ValueError("B and D must have the same number of columns.")
    123 
    124     return A, B, C, D

ValueError: B and D must have the same number of columns.

In [26]: abcd_normalize(A=a, B=b, D=d)   # Expect third array to be [[0.0, 0.0]]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)

/Users/warren/<ipython console> in <module>()

/Users/warren/local_tmp/lib/python2.7/site-packages/scipy/signal/ltisys.pyc in abcd_normalize(A, B, C, D)
    116         raise ValueError("A and B must have the same number of rows.")
    117     if NA != NC:
--> 118         raise ValueError("A and C must have the same number of columns.")
    119     if MD != MC:
    120         raise ValueError("C and D must have the same number of rows.")

ValueError: A and C must have the same number of columns.

In [27]: abcd_normalize(A=a, C=c, D=d)   # Expect second array to be [[0.0], [0.0]]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)

/Users/warren/<ipython console> in <module>()

/Users/warren/local_tmp/lib/python2.7/site-packages/scipy/signal/ltisys.pyc in abcd_normalize(A, B, C, D)
    114         raise ValueError("A must be square.")
    115     if MA != MB:
--> 116         raise ValueError("A and B must have the same number of rows.")
    117     if NA != NC:
    118         raise ValueError("A and C must have the same number of columns.")

ValueError: A and B must have the same number of rows.

In [28]: abcd_normalize(B=b, C=c, D=d)   # Expect first array to be [[0.0, 0.0], [0.0, 0.0]]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)

/Users/warren/<ipython console> in <module>()

/Users/warren/local_tmp/lib/python2.7/site-packages/scipy/signal/ltisys.pyc in abcd_normalize(A, B, C, D)
    112 
    113     if MA != NA:
--> 114         raise ValueError("A must be square.")
    115     if MA != MB:
    116         raise ValueError("A and B must have the same number of rows.")

ValueError: A must be square.
```



Discussion:
Milestone changed to `Unscheduled` by @rgommers on 2011-09-25

Take your first example:

```
In [32]: abcd_normalize(A=a, B=b, C=c)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-32-169912373716> in <module>()
----> 1 abcd_normalize(A=a, B=b, C=c)

/home/juanlu/Development/Python/scipy/scipy/signal/ltisys.py in abcd_normalize(A, B, C, D)
    126         raise ValueError("C and D must have the same number of rows.")
    127     if ND != NB:
--> 128         raise ValueError("B and D must have the same number of columns.")
    129 
    130     return A, B, C, D

ValueError: B and D must have the same number of columns.
> /home/juanlu/Development/Python/scipy/scipy/signal/ltisys.py(128)abcd_normalize()
    127     if ND != NB:
--> 128         raise ValueError("B and D must have the same number of columns.")
    129 

ipdb> MD
1
ipdb> ND
0
ipdb> 
```

In line 112 the condition `(MD == 0) and (ND == 0)` (which is meant to detect if D was None) is never met, because

```
In [33]: np.atleast_2d(_none_to_empty(None))
Out[33]: array([], shape=(1, 0), dtype=float64)

In [34]: _.shape
Out[34]: (1, 0)
```

And I don't think checking for `(MD == 1) and (ND == 0)` is possible because a non empty array could meet the condition.

I'm going to try to refactor this.

The only meaningful commit (i.e. not only style changes and PEP8 pr Python 2/3 fixes) on this function is ce3b420d (apart from addition in 2001 d975391d), but it's not specified which two bugs fixes (clearly not this one). Saw it through inspecting `git blame -L 90,130 ltisys.py`.

Other than that, this function is untested:

```
$ [[ -z `grep "abcd" tests/*.py` ]] && echo "No tests"
No tests
```

so adding failing tests is the first thing I'm going to do. 

Here's my take on what is needed to infer the shape of an unspecified matrix:

```
Not given     Needed to infer the shape
    D            B and C
    C            D and (A or B)
    B            (A or C) and D
    A            B or C
```

For example, if `C` is not given, `D` must be given (to get the number of rows of `C`), and either `A` or `B` must be given (to get the number of columns of `C`).

Useful link:

http://en.wikibooks.org/wiki/Control_Systems/State-Space_Equations#Matrix_Dimensions


Resolving patch:
diff --git a/scipy/signal/ltisys.py b/scipy/signal/ltisys.py
--- a/scipy/signal/ltisys.py
+++ b/scipy/signal/ltisys.py
@@ -9,6 +9,8 @@
 #
 # Feb 2010: Warren Weckesser
 #   Rewrote lsim2 and added impulse2.
+# Aug 2013: Juan Luis Cano
+#   Rewrote abcd_normalize.
 #
 
 from .filter_design import tf2zpk, zpk2tf, normalize, freqs
@@ -80,52 +82,82 @@ def tf2ss(num, den):
     return A, B, C, D
 
 
-def _none_to_empty(arg):
+def _none_to_empty_2d(arg):
     if arg is None:
-        return []
+        return zeros((0, 0))
     else:
         return arg
 
 
+def _atleast_2d_or_none(arg):
+    if arg is not None:
+        return atleast_2d(arg)
+
+
+def _shape_or_none(M):
+    if M is not None:
+        return M.shape
+    else:
+        return (None,) * 2
+
+
+def _choice_not_none(*args):
+    for arg in args:
+        if arg is not None:
+            return arg
+
+
+def _restore(M, shape):
+    if M.shape == (0, 0):
+        return zeros(shape)
+    else:
+        if M.shape != shape:
+            raise ValueError("The input arrays have incompatible shapes.")
+        return M
+
+
 def abcd_normalize(A=None, B=None, C=None, D=None):
     """Check state-space matrices and ensure they are rank-2.
 
+    If enough information on the system is provided, that is, enough
+    properly-shaped arrays are passed to the function, the missing ones
+    are built from this information, ensuring the correct number of
+    rows and columns. Otherwise a ValueError is raised.
+
+    Parameters
+    ----------
+    A, B, C, D : array_like, optional
+        State-space matrices. All of them are None (missing) by default.
+
+    Returns
+    -------
+    A, B, C, D : array
+        Properly shaped state-space matrices.
+
+    Raises
+    ------
+    ValueError
+        If not enough information on the system was provided.
+
     """
-    A, B, C, D = map(_none_to_empty, (A, B, C, D))
-    A, B, C, D = map(atleast_2d, (A, B, C, D))
-
-    if ((len(A.shape) > 2) or (len(B.shape) > 2) or
-        (len(C.shape) > 2) or (len(D.shape) > 2)):
-        raise ValueError("A, B, C, D arrays can be no larger than rank-2.")
-
-    MA, NA = A.shape
-    MB, NB = B.shape
-    MC, NC = C.shape
-    MD, ND = D.shape
-
-    if (MC == 0) and (NC == 0) and (MD != 0) and (NA != 0):
-        MC, NC = MD, NA
-        C = zeros((MC, NC))
-    if (MB == 0) and (NB == 0) and (MA != 0) and (ND != 0):
-        MB, NB = MA, ND
-        B = zeros((MB, NB))
-    if (MD == 0) and (ND == 0) and (MC != 0) and (NB != 0):
-        MD, ND = MC, NB
-        D = zeros((MD, ND))
-    if (MA == 0) and (NA == 0) and (MB != 0) and (NC != 0):
-        MA, NA = MB, NC
-        A = zeros((MA, NA))
-
-    if MA != NA:
-        raise ValueError("A must be square.")
-    if MA != MB:
-        raise ValueError("A and B must have the same number of rows.")
-    if NA != NC:
-        raise ValueError("A and C must have the same number of columns.")
-    if MD != MC:
-        raise ValueError("C and D must have the same number of rows.")
-    if ND != NB:
-        raise ValueError("B and D must have the same number of columns.")
+    A, B, C, D = map(_atleast_2d_or_none, (A, B, C, D))
+
+    MA, NA = _shape_or_none(A)
+    MB, NB = _shape_or_none(B)
+    MC, NC = _shape_or_none(C)
+    MD, ND = _shape_or_none(D)
+
+    p = _choice_not_none(MA, MB, NC)
+    q = _choice_not_none(NB, ND)
+    r = _choice_not_none(MC, MD)
+    if p is None or q is None or r is None:
+        raise ValueError("Not enough information on the system.")
+
+    A, B, C, D = map(_none_to_empty_2d, (A, B, C, D))
+    A = _restore(A, (p, p))
+    B = _restore(B, (p, q))
+    C = _restore(C, (r, p))
+    D = _restore(D, (r, q))
 
     return A, B, C, D
 


Test patch:
diff --git a/scipy/signal/tests/test_ltisys.py b/scipy/signal/tests/test_ltisys.py
--- a/scipy/signal/tests/test_ltisys.py
+++ b/scipy/signal/tests/test_ltisys.py
@@ -3,10 +3,11 @@
 import warnings
 
 import numpy as np
-from numpy.testing import assert_almost_equal, assert_equal, run_module_suite
+from numpy.testing import assert_almost_equal, assert_equal, run_module_suite, \
+    assert_raises
 
 from scipy.signal.ltisys import ss2tf, lsim2, impulse2, step2, lti, bode, \
-    freqresp, impulse, step
+    freqresp, impulse, step, abcd_normalize
 from scipy.signal.filter_design import BadCoefficients
 import scipy.linalg as linalg
 
@@ -278,6 +279,140 @@ def test_lti_instantiation():
     s = lti([1], [-1], 1, 3)
 
 
+class Test_abcd_normalize(object):
+    def setup(self):
+        self.A = np.array([[1.0, 2.0], [3.0, 4.0]])
+        self.B = np.array([[-1.0], [5.0]])
+        self.C = np.array([[4.0, 5.0]])
+        self.D = np.array([[2.5]])
+
+    def test_no_matrix_fails(self):
+        assert_raises(ValueError, abcd_normalize)
+
+    def test_A_nosquare_fails(self):
+        assert_raises(ValueError, abcd_normalize, [1, -1],
+                      self.B, self.C, self.D)
+
+    def test_AB_mismatch_fails(self):
+        assert_raises(ValueError, abcd_normalize, self.A, [-1, 5],
+                      self.C, self.D)
+
+    def test_AC_mismatch_fails(self):
+        assert_raises(ValueError, abcd_normalize, self.A, self.B,
+                      [[4.0], [5.0]], self.D)
+
+    def test_CD_mismatch_fails(self):
+        assert_raises(ValueError, abcd_normalize, self.A, self.B,
+                      self.C, [2.5, 0])
+
+    def test_BD_mismatch_fails(self):
+        assert_raises(ValueError, abcd_normalize, self.A, [-1, 5],
+                      self.C, self.D)
+
+    def test_normalized_matrices_unchanged(self):
+        A, B, C, D = abcd_normalize(self.A, self.B, self.C, self.D)
+        assert_equal(A, self.A)
+        assert_equal(B, self.B)
+        assert_equal(C, self.C)
+        assert_equal(D, self.D)
+
+    def test_shapes(self):
+        A, B, C, D = abcd_normalize(self.A, self.B, [1, 0], 0)
+        assert_equal(A.shape[0], A.shape[1])
+        assert_equal(A.shape[0], B.shape[0])
+        assert_equal(A.shape[0], C.shape[1])
+        assert_equal(C.shape[0], D.shape[0])
+        assert_equal(B.shape[1], D.shape[1])
+
+    def test_zero_dimension_is_not_none1(self):
+        B_ = np.zeros((2, 0))
+        D_ = np.zeros((0, 0))
+        A, B, C, D = abcd_normalize(A=self.A, B=B_, D=D_)
+        assert_equal(A, self.A)
+        assert_equal(B, B_)
+        assert_equal(D, D_)
+        assert_equal(C.shape[0], D_.shape[0])
+        assert_equal(C.shape[1], self.A.shape[0])
+
+    def test_zero_dimension_is_not_none2(self):
+        B_ = np.zeros((2, 0))
+        C_ = np.zeros((0, 2))
+        A, B, C, D = abcd_normalize(A=self.A, B=B_, C=C_)
+        assert_equal(A, self.A)
+        assert_equal(B, B_)
+        assert_equal(C, C_)
+        assert_equal(D.shape[0], C_.shape[0])
+        assert_equal(D.shape[1], B_.shape[1])
+
+    def test_missing_A(self):
+        A, B, C, D = abcd_normalize(B=self.B, C=self.C, D=self.D)
+        assert_equal(A.shape[0], A.shape[1])
+        assert_equal(A.shape[0], B.shape[0])
+        assert_equal(A.shape, (self.B.shape[0], self.B.shape[0]))
+
+    def test_missing_B(self):
+        A, B, C, D = abcd_normalize(A=self.A, C=self.C, D=self.D)
+        assert_equal(B.shape[0], A.shape[0])
+        assert_equal(B.shape[1], D.shape[1])
+        assert_equal(B.shape, (self.A.shape[0], self.D.shape[1]))
+
+    def test_missing_C(self):
+        A, B, C, D = abcd_normalize(A=self.A, B=self.B, D=self.D)
+        assert_equal(C.shape[0], D.shape[0])
+        assert_equal(C.shape[1], A.shape[0])
+        assert_equal(C.shape, (self.D.shape[0], self.A.shape[0]))
+
+    def test_missing_D(self):
+        A, B, C, D = abcd_normalize(A=self.A, B=self.B, C=self.C)
+        assert_equal(D.shape[0], C.shape[0])
+        assert_equal(D.shape[1], B.shape[1])
+        assert_equal(D.shape, (self.C.shape[0], self.B.shape[1]))
+
+    def test_missing_AB(self):
+        A, B, C, D = abcd_normalize(C=self.C, D=self.D)
+        assert_equal(A.shape[0], A.shape[1])
+        assert_equal(A.shape[0], B.shape[0])
+        assert_equal(B.shape[1], D.shape[1])
+        assert_equal(A.shape, (self.C.shape[1], self.C.shape[1]))
+        assert_equal(B.shape, (self.C.shape[1], self.D.shape[1]))
+
+    def test_missing_AC(self):
+        A, B, C, D = abcd_normalize(B=self.B, D=self.D)
+        assert_equal(A.shape[0], A.shape[1])
+        assert_equal(A.shape[0], B.shape[0])
+        assert_equal(C.shape[0], D.shape[0])
+        assert_equal(C.shape[1], A.shape[0])
+        assert_equal(A.shape, (self.B.shape[0], self.B.shape[0]))
+        assert_equal(C.shape, (self.D.shape[0], self.B.shape[0]))
+
+    def test_missing_AD(self):
+        A, B, C, D = abcd_normalize(B=self.B, C=self.C)
+        assert_equal(A.shape[0], A.shape[1])
+        assert_equal(A.shape[0], B.shape[0])
+        assert_equal(D.shape[0], C.shape[0])
+        assert_equal(D.shape[1], B.shape[1])
+        assert_equal(A.shape, (self.B.shape[0], self.B.shape[0]))
+        assert_equal(D.shape, (self.C.shape[0], self.B.shape[1]))
+
+    def test_missing_BC(self):
+        A, B, C, D = abcd_normalize(A=self.A, D=self.D)
+        assert_equal(B.shape[0], A.shape[0])
+        assert_equal(B.shape[1], D.shape[1])
+        assert_equal(C.shape[0], D.shape[0])
+        assert_equal(C.shape[1], A.shape[0])
+        assert_equal(B.shape, (self.A.shape[0], self.D.shape[1]))
+        assert_equal(C.shape, (self.D.shape[0], self.A.shape[0]))
+
+    def test_missing_ABC_fails(self):
+        assert_raises(ValueError, abcd_normalize, D=self.D)
+
+    def test_missing_BD_fails(self):
+        assert_raises(ValueError, abcd_normalize, A=self.A, C=self.C)
+
+    def test_missing_CD_fails(self):
+        assert_raises(ValueError, abcd_normalize, A=self.A, B=self.B)
+
+
 class Test_bode(object):
 
     def test_01(self):