Repository: python-control/python-control

Issue:
Response Functions as Member Functions?
Hi,
are there some ideological reasons why the response functions are not available as member functions for the respective system classes like they are in sicpy.signal? I feel like this would make the library a bit nicer to use.
I am willing to implement this myself, I just want to ensure that it would actually have a change of getting merged before putting in the effort.
Just to be clear, I mean forwarding the calls or aliasing the methods, not refactoring the code.

Hints from issue comments:
@murrayrm: Seems easy enough to implement.  Note that `frequency_response` is already a method for all `LTI` systems, so the following works to generate a Bode plot:
```
import numpy as np
import control as ct
sys = ct.rss(4, 1, 1)
sys.frequency_response(np.linspace(-3, 3)).plot()
```
One thing that is missing from the `sys.frequency_response()` method versus the `ct.frequency_response()` function is automatic computation of the frequency range.  So that would be something to fix/update as well.

Time respones (`forced_response`, `impulse_response`, `initial_response`, `input_output_response`, `step_response`) could be implemented fairly simply, as you suggest.

If you or someone else wants to take a pass at this, it would be a nice contribution.  It will require some refactoring, at least for `frequency_response` since right now `ct.frequency_response` computes the default frequency range and then calls the class method.  Also, I think that `initial_response` should only be allowed on systems with an explicit state (so not transfer functions), since there is no well-defined notion of the initial state.@lkies: Thank you for quick response and the hints regarding `frequency_response`. I will take a closer look and get back to you / this thread.@lkies: Ok, i have finally found some time for this.

Refactoring it so that `LTI.frequency_respose = frequency_response` (the free one) and moving the old LTI.frequency_response into the body of frequency_response _almost_ works. the only difference in behavior seems to be, that the free function interprets `omega=[a, b]` as the range from a to b where the member function interprets it as just the frequencies `[a, b]`. I don't see a reasonable way of handling this without breaking changes, so I would just leave it for now.

I think it would be a good idea to deprecate passing limits in `omega` as opposed to `omega_limits` (at least for everything that is not a tuple) so this could be implemented in some future version.

I implemented the automatic frequency range determination for the `frequency_response` member function but i do not think implementing the other features that require adding extra arguments is a good idea since this would make the two function very similar except for this specific edge case which would make them awkward / confusing to use. Maybe implementing automatic frequency range determination is already a step too far, but since the signature allows not passing `omega` which will lead to an error somewhere in the body it has some justification to actually make it optional.

Other than that I added every function that seemed sensible to me and that can take a system as its first argument as a member to `LTI`. But it turned out more complicated than anticipated because of cyclical imports. Since the modules that implement these functions all directly and indirectly depend on `lti.py` they can not simply be imported in `lti.py`. It should technically be possible to refactor them all to only import things from `lti.py` in the function bodies but that would require a lot of changes across a lot of files. The only other work around that I could come up with is forward declaring the names and then patching them in the `__init__.py`. I am not too happy about that but VSCode (and probably other editors/IDEs) seem to be able to show the docstring.

Solution patch:
diff --git a/control/__init__.py b/control/__init__.py
index 40f3a783b..1aaaa42e8 100644
--- a/control/__init__.py
+++ b/control/__init__.py
@@ -118,5 +118,14 @@
 except ImportError:
     __version__ = "dev"
 
+# patch the LTI class with function aliases for convenience functions
+# this needs to be done after the fact since the modules that contain the functions
+# all heavily depend on the LTI class
+LTI.to_ss = ss
+LTI.to_tf = tf
+LTI.bode_plot = bode_plot
+LTI.nyquist_plot = nyquist_plot
+LTI.nichols_plot = nichols_plot
+
 # Initialize default parameter values
 reset_defaults()
diff --git a/control/lti.py b/control/lti.py
index 4b354f3f4..b7139f608 100644
--- a/control/lti.py
+++ b/control/lti.py
@@ -11,6 +11,8 @@
 from warnings import warn
 from . import config
 from .iosys import InputOutputSystem
+import control
+from typing import Callable
 
 __all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response',
            'freqresp', 'dcgain', 'bandwidth', 'LTI']
@@ -76,9 +78,10 @@ def frequency_response(self, omega=None, squeeze=None):
 
         Parameters
         ----------
-        omega : float or 1D array_like
+        omega : float or 1D array_like, optional
             A list, tuple, array, or scalar value of frequencies in
-            radians/sec at which the system will be evaluated.
+            radians/sec at which the system will be evaluated.  If None (default),
+            a set of frequencies is computed based on the system dynamics.
         squeeze : bool, optional
             If squeeze=True, remove single-dimensional entries from the shape
             of the output even if the system is not SISO. If squeeze=False,
@@ -107,6 +110,11 @@ def frequency_response(self, omega=None, squeeze=None):
         """
         from .frdata import FrequencyResponseData
 
+        if omega is None:
+            # Use default frequency range
+            from .freqplot import _default_frequency_range
+            omega = _default_frequency_range(self)
+
         omega = np.sort(np.array(omega, ndmin=1))
         if self.isdtime(strict=True):
             # Convert the frequency to discrete time
@@ -202,6 +210,34 @@ def ispassive(self):
         # importing here prevents circular dependancy
         from control.passivity import ispassive
         return ispassive(self)
+    
+    def feedback(self, other=1, sign=-1):
+        raise NotImplementedError(f"feedback not implemented for base {self.__class__.__name__} objects")
+
+    # convenience aliases
+    # most function are only forward declaraed and patched in the __init__.py to avoid circular imports
+
+    # conversions
+    #: Convert to :class:`StateSpace` representation; see :func:`ss`
+    to_ss: Callable
+    #: Convert to :class:`TransferFunction` representation; see :func:`tf`
+    to_tf: Callable
+
+    # freq domain plotting
+    #: Bode plot; see :func:`bode_plot`
+    bode_plot: Callable
+    #: Nyquist plot; see :func:`nyquist_plot`
+    nyquist_plot: Callable
+    #: Nichols plot; see :func:`nichols_plot`
+    nichols_plot: Callable
+
+    # time domain simulation
+    #: Forced response; see :func:`forced_response`
+    forced_response = control.timeresp.forced_response
+    #: Impulse response; see :func:`impulse_response`
+    impulse_response = control.timeresp.impulse_response
+    #: Step response; see :func:`step_response`
+    step_response = control.timeresp.step_response
 
 
 def poles(sys):
diff --git a/control/statesp.py b/control/statesp.py
index 3f53777e5..070be2e15 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -36,6 +36,7 @@
     common_timebase, isdtime, issiso
 from .lti import LTI, _process_frequency_response
 from .nlsys import InterconnectedSystem, NonlinearIOSystem
+import control
 
 try:
     from slycot import ab13dd
@@ -1427,6 +1428,9 @@ def output(self, t, x, u=None, params=None):
                 raise ValueError("len(u) must be equal to number of inputs")
             return (self.C @ x).reshape((-1,)) \
                 + (self.D @ u).reshape((-1,))  # return as row vector
+        
+    # convenience aliase, import needs to go over the submodule to avoid circular imports
+    initial_response = control.timeresp.initial_response
 
 
 class LinearICSystem(InterconnectedSystem, StateSpace):


Test patch:
diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py
index b98509d65..95450da08 100644
--- a/control/tests/kwargs_test.py
+++ b/control/tests/kwargs_test.py
@@ -244,6 +244,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
     'append': test_unrecognized_kwargs,
     'bode': test_response_plot_kwargs,
     'bode_plot': test_response_plot_kwargs,
+    'LTI.bode_plot': test_response_plot_kwargs, # alias for bode_plot and tested via bode_plot
     'create_estimator_iosystem': stochsys_test.test_estimator_errors,
     'create_statefbk_iosystem': statefbk_test.TestStatefbk.test_statefbk_errors,
     'describing_function_plot': test_matplotlib_kwargs,
@@ -267,11 +268,13 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
     'lqr': test_unrecognized_kwargs,
     'negate': test_unrecognized_kwargs,
     'nichols_plot': test_matplotlib_kwargs,
+    'LTI.nichols_plot': test_matplotlib_kwargs, # alias for nichols_plot and tested via nichols_plot
     'nichols': test_matplotlib_kwargs,
     'nlsys': test_unrecognized_kwargs,
     'nyquist': test_matplotlib_kwargs,
     'nyquist_response': test_response_plot_kwargs,
     'nyquist_plot': test_matplotlib_kwargs,
+    'LTI.nyquist_plot': test_matplotlib_kwargs, # alias for nyquist_plot and tested via nyquist_plot
     'phase_plane_plot': test_matplotlib_kwargs,
     'parallel': test_unrecognized_kwargs,
     'pole_zero_plot': test_unrecognized_kwargs,
diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py
index 2bb0badc5..647db4567 100644
--- a/control/tests/statesp_test.py
+++ b/control/tests/statesp_test.py
@@ -1281,3 +1281,31 @@ def test_tf2ss_mimo():
     else:
         with pytest.raises(ct.ControlMIMONotImplemented):
             sys_ss = ct.ss(sys_tf)
+
+def test_convenience_aliases():
+    sys = ct.StateSpace(1, 1, 1, 1)
+
+    # test that all the aliases point to the correct function
+    # .__funct__ a bound methods underlying function
+    assert sys.to_ss.__func__ == ct.ss
+    assert sys.to_tf.__func__ == ct.tf
+    assert sys.bode_plot.__func__ == ct.bode_plot
+    assert sys.nyquist_plot.__func__ == ct.nyquist_plot
+    assert sys.nichols_plot.__func__ == ct.nichols_plot
+    assert sys.forced_response.__func__ == ct.forced_response
+    assert sys.impulse_response.__func__ == ct.impulse_response
+    assert sys.step_response.__func__ == ct.step_response
+    assert sys.initial_response.__func__ == ct.initial_response
+
+    # make sure the functions can be used as member function ie they support
+    # an instance of StateSpace as the first argument and that they at least return
+    # the correct type
+    assert isinstance(sys.to_ss(), StateSpace)
+    assert isinstance(sys.to_tf(), TransferFunction)
+    assert isinstance(sys.bode_plot(), ct.ControlPlot)
+    assert isinstance(sys.nyquist_plot(), ct.ControlPlot)
+    assert isinstance(sys.nichols_plot(), ct.ControlPlot)
+    assert isinstance(sys.forced_response([0, 1], [1, 1]), (ct.TimeResponseData, ct.TimeResponseList))
+    assert isinstance(sys.impulse_response(), (ct.TimeResponseData, ct.TimeResponseList))
+    assert isinstance(sys.step_response(), (ct.TimeResponseData, ct.TimeResponseList))
+    assert isinstance(sys.initial_response(X0=1), (ct.TimeResponseData, ct.TimeResponseList))
\ No newline at end of file