diff --git a/control/freqplot.py b/control/freqplot.py index 475467147..35a5ee503 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1312,6 +1312,8 @@ def nyquist_response( # Use existing dictionary, to keep track of processed keywords _kwargs |= kwargs + indent_direction_arg = 'indent_direction' in _kwargs + # Get values for params omega_num_given = omega_num is not None omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) @@ -1469,8 +1471,21 @@ def nyquist_response( - (s - p).real # Figure out which way to offset the contour point - if p.real < 0 or (p.real == 0 and - indent_direction == 'right'): + if indent_direction_arg: + if indent_direction == 'right': + # Indent to the right + splane_contour[i] += offset + + elif indent_direction == 'left': + # Indent to the left + splane_contour[i] -= offset + + else: + raise ValueError( + "unknown value for indent_direction") + + elif p.real < 0 or (p.real == 0 and + indent_direction == 'right'): # Indent to the right splane_contour[i] += offset diff --git a/control/statesp.py b/control/statesp.py index 65529b99d..16981e62b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -2295,7 +2295,7 @@ def _ssmatrix(data, axis=1, square=None, rows=None, cols=None, name=None): Returns ------- - arr : 2D array, with shape (0, 0) if a is empty + arr : 2D array, with shape (0, 0) for empty vectors or matrices """ # Process the name of the object, if available @@ -2310,9 +2310,9 @@ def _ssmatrix(data, axis=1, square=None, rows=None, cols=None, name=None): if (ndim > 2): raise ValueError(f"state-space matrix{name} must be 2-dimensional") - elif (ndim == 2 and shape == (1, 0)) or \ - (ndim == 1 and shape == (0, )): - # Passed an empty matrix or empty vector; change shape to (0, 0) + elif (ndim == 2 and shape == (0, 0)) or \ + (ndim == 1 and shape == (0, )): + # Passed a 0-by-0 matrix or empty vector; change shape to (0, 0) shape = (0, 0) elif ndim == 1: diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py index ccce76f34..e725b29f6 100644 --- a/control/tests/interconnect_test.py +++ b/control/tests/interconnect_test.py @@ -432,6 +432,36 @@ def test_linear_interconnect(): outlist=['plant.y'], outputs='y') assert clsys.syslist[0].name == 'ctrl' + +def test_interconnect_zero_input_one_output(): + plant = ct.ss( + [[1, 1], [0, -2]], + [[0], [1]], + [[1, 0]], + [[0]], + inputs=['u'], + outputs=['y'], + name='plant', + dt=True, + ) + controller = ct.ss( + [[2.25, 1], [-5, -0.5]], + [[-1.25], [4.5]], + [[-0.5, 1.5]], + [[0]], + inputs=['y'], + outputs=['u'], + name='controller', + dt=True, + ) + + sys = ct.interconnect([plant, controller], inplist=None, outlist=['y']) + + assert sys.ninputs == 0 + assert sys.noutputs == 1 + assert sys.D.shape == (1, 0) + + @pytest.mark.parametrize( "connections, inplist, outlist, inputs, outputs", [ pytest.param( diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 243a291d2..8f42e7430 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -372,6 +372,29 @@ def test_nyquist_indent_im(): assert _Z(sys) == response.count + _P(sys) +def test_nyquist_indent_near_imaginary_axis(): + """Test indent direction for poles near the imaginary axis.""" + sys = ct.tf([1, 11, 10], [0.01, 1, 0.01, 1]) + omega = np.linspace(0, 2, 21) + + _, contour_default = ct.nyquist_response( + sys, omega, indent_radius=0.1, return_contour=True, + warn_encirclements=False) + _, contour_right = ct.nyquist_response( + sys, omega, indent_radius=0.1, indent_direction='right', + return_contour=True, warn_encirclements=False) + _, contour_left = ct.nyquist_response( + sys, omega, indent_radius=0.1, indent_direction='left', + return_contour=True, warn_encirclements=False) + + # The pole near +1j has a small positive real part, so the default + # behavior indents to the left. Explicit directions override this. + pole_index = np.argmin(np.abs(contour_default.imag - 1)) + assert contour_default[pole_index].real < 0 + assert contour_right[pole_index].real > 0 + assert contour_left[pole_index].real < 0 + + def test_nyquist_exceptions(): # MIMO not implemented sys = ct.rss(2, 2, 2) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index 16ee01a3d..ba76c2b91 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -2,6 +2,7 @@ from copy import copy from math import isclose +import warnings import numpy as np import pytest @@ -827,6 +828,14 @@ def test_auto_generated_time_vector_tfinal(self, tfsys, tfinal): T = _default_time_vector(tfsys) np.testing.assert_allclose(T[-1], tfinal, atol=0.5*ideal_dt) + def test_discrete_time_negative_one_settling(self): + #system with -1 pole + TF = TransferFunction([1,3,0],[1,3,2], dt=True) + with warnings.catch_warnings(): + warnings.simplefilter("error") + impulse_response(TF) + + @pytest.mark.parametrize("wn, zeta", [(10, 0), (100, 0), (100, .1)]) def test_auto_generated_time_vector_dt_cont1(self, wn, zeta): """Confirm a TF with a natural frequency of wn rad/s gets a diff --git a/control/timeresp.py b/control/timeresp.py index 3c49d213e..1d252b270 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -2172,7 +2172,7 @@ def _ideal_tfinal_and_dt(sys, is_step=True): m_z = np.abs(p) < sqrt_eps p = p[~m_z] # Negative reals- treated as oscillatory mode - m_nr = (p.real < 0) & (np.abs(p.imag) < sqrt_eps) + m_nr = (p.real < 0) & (np.abs(p.imag) < sqrt_eps) & (np.abs(p.real+1) > sqrt_eps) p_nr, p = p[m_nr], p[~m_nr] if p_nr.size > 0: t_emp = np.max(log_decay_percent / np.abs((np.log(p_nr)/dt).real))