Repository: python-control/python-control
Pull request #1118 (2025-02-02 09:18:49)

Lint library code only with `ruff check`
Make library code `ruff check` clean for pyflakes ("F" code) warnings, and checks this in a Github Action.

"Library code" is all non-test code in `control/`, so excludes tests, examples, and benchmarks.  Making all of those ruff-check-clean too will take some time, and be a big change, so it seems worth getting this in first.  This also gives an opportunity for reviewing the ruff configuration, and the overall approach, e.g., disabling import checks in `__init__.py`.

Besides making the other parts of the code pass lint, `ruff` can do more, and we can look at adding more checks later.

Partially addresses gh-1101.

You can see an example of a failing Github Action ruff check [here](https://github.com/roryyorke/python-control/actions/runs/13088422392).


Patch:
diff --git a/.github/workflows/ruff-check.yml b/.github/workflows/ruff-check.yml
new file mode 100644
index 00000000..e056204b
--- /dev/null
+++ b/.github/workflows/ruff-check.yml
@@ -0,0 +1,29 @@
+# run ruff check on library source
+# TODO: extend to tests, examples, benchmarks
+
+name: ruff-check
+
+on: [push, pull_request]
+
+jobs:
+  ruff-check-linux:
+    # ruff *shouldn't* be sensitive to platform
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Checkout python-control
+      uses: actions/checkout@v3
+
+    - name: Setup environment
+      uses: actions/setup-python@v4
+      with:
+        python-version: 3.13 # todo: latest?
+
+    - name: Install ruff
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install ruff
+
+    - name: Run ruff check
+      run: |
+        ruff check
diff --git a/control/__init__.py b/control/__init__.py
index 1aaaa42e..3f78452f 100644
--- a/control/__init__.py
+++ b/control/__init__.py
@@ -70,6 +70,11 @@ functionality:
 # Import functions from within the control system library
 # Note: the functions we use are specified as __all__ variables in the modules
 
+# don't warn about `import *`
+# ruff: noqa: F403
+# don't warn about unknown names; they come via `import *`
+# ruff: noqa: F405
+
 # Input/output system modules
 from .iosys import *
 from .nlsys import *
@@ -106,8 +111,8 @@ from .passivity import *
 from .sysnorm import *
 
 # Allow access to phase_plane functions as ct.phaseplot.fcn or ct.pp.fcn
-from . import phaseplot
-from . import phaseplot as pp
+from . import phaseplot as phaseplot
+pp = phaseplot
 
 # Exceptions
 from .exception import *
diff --git a/control/canonical.py b/control/canonical.py
index 7be7f88a..67d3127a 100644
--- a/control/canonical.py
+++ b/control/canonical.py
@@ -8,9 +8,8 @@ from .statefbk import ctrb, obsv
 
 import numpy as np
 
-from numpy import zeros, zeros_like, shape, poly, iscomplex, vstack, hstack, \
-    transpose, empty, finfo, float64
-from numpy.linalg import solve, matrix_rank, eig
+from numpy import zeros_like, poly, transpose
+from numpy.linalg import solve, matrix_rank
 
 from scipy.linalg import schur
 
diff --git a/control/dtime.py b/control/dtime.py
index 39b207e0..6d1545fc 100644
--- a/control/dtime.py
+++ b/control/dtime.py
@@ -48,7 +48,6 @@ $Id: dtime.py 185 2012-08-30 05:44:32Z murrayrm $
 """
 
 from .iosys import isctime
-from .statesp import StateSpace
 
 __all__ = ['sample_system', 'c2d']
 
diff --git a/control/exception.py b/control/exception.py
index add5d01a..feaf1f0a 100644
--- a/control/exception.py
+++ b/control/exception.py
@@ -71,7 +71,7 @@ def slycot_check():
     global slycot_installed
     if slycot_installed is None:
         try:
-            import slycot
+            import slycot # noqa: F401
             slycot_installed = True
         except:
             slycot_installed = False
@@ -85,7 +85,7 @@ def pandas_check():
     global pandas_installed
     if pandas_installed is None:
         try:
-            import pandas
+            import pandas # noqa: F401
             pandas_installed = True
         except:
             pandas_installed = False
@@ -98,7 +98,7 @@ def cvxopt_check():
     global cvxopt_installed
     if cvxopt_installed is None:
         try:
-            import cvxopt
+            import cvxopt # noqa: F401
             cvxopt_installed = True
         except:
             cvxopt_installed = False
diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py
index c6934d82..16374b58 100644
--- a/control/flatsys/__init__.py
+++ b/control/flatsys/__init__.py
@@ -61,15 +61,17 @@ The docstring examples assume that the following import commands::
 """
 
 # Basis function families
-from .basis import BasisFamily
-from .poly import PolyFamily
-from .bezier import BezierFamily
-from .bspline import BSplineFamily
+from .basis import BasisFamily as BasisFamily
+from .poly import PolyFamily as PolyFamily
+from .bezier import BezierFamily as BezierFamily
+from .bspline import BSplineFamily as BSplineFamily
 
 # Classes
-from .systraj import SystemTrajectory
-from .flatsys import FlatSystem, flatsys
-from .linflat import LinearFlatSystem
+from .systraj import SystemTrajectory as SystemTrajectory
+from .flatsys import FlatSystem as FlatSystem
+from .flatsys import flatsys as flatsys
+from .linflat import LinearFlatSystem as LinearFlatSystem
 
 # Package functions
-from .flatsys import point_to_point, solve_flat_ocp
+from .flatsys import point_to_point as point_to_point
+from .flatsys import solve_flat_ocp as solve_flat_ocp
diff --git a/control/flatsys/bspline.py b/control/flatsys/bspline.py
index c771beb5..d42cb407 100644
--- a/control/flatsys/bspline.py
+++ b/control/flatsys/bspline.py
@@ -8,7 +8,7 @@
 
 import numpy as np
 from .basis import BasisFamily
-from scipy.interpolate import BSpline, splev
+from scipy.interpolate import BSpline
 
 class BSplineFamily(BasisFamily):
     """B-spline basis functions.
diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py
index 5818d118..d00c2b31 100644
--- a/control/flatsys/flatsys.py
+++ b/control/flatsys/flatsys.py
@@ -8,6 +8,7 @@ import scipy.optimize
 import warnings
 from .poly import PolyFamily
 from .systraj import SystemTrajectory
+from ..exception import ControlArgument
 from ..nlsys import NonlinearIOSystem
 from ..timeresp import _check_convert_array
 
@@ -245,7 +246,6 @@ def flatsys(*args, updfcn=None, outfcn=None, **kwargs):
     """
     from .linflat import LinearFlatSystem
     from ..statesp import StateSpace
-    from ..iosys import _process_iosys_keywords
 
     if len(args) == 1 and isinstance(args[0], StateSpace):
         # We were passed a linear system, so call linflat
diff --git a/control/frdata.py b/control/frdata.py
index 1200bfff..195d73bf 100644
--- a/control/frdata.py
+++ b/control/frdata.py
@@ -15,8 +15,8 @@ from copy import copy
 from warnings import warn
 
 import numpy as np
-from numpy import absolute, angle, array, empty, eye, imag, linalg, ones, \
-    real, sort, where
+from numpy import absolute, array, empty, eye, imag, linalg, ones, \
+    real, sort
 from scipy.interpolate import splev, splprep
 
 from . import config
diff --git a/control/freqplot.py b/control/freqplot.py
index 456431f3..db06da0c 100644
--- a/control/freqplot.py
+++ b/control/freqplot.py
@@ -11,7 +11,6 @@
 import itertools
 import math
 import warnings
-from os.path import commonprefix
 
 import matplotlib as mpl
 import matplotlib.pyplot as plt
@@ -760,13 +759,11 @@ def bode_plot(
             if plot_magnitude:
                 ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
                                zorder=-20)
-                mag_ylim = ax_mag.get_ylim()
 
             if plot_phase:
                 ax_phase.axhline(y=phase_limit if deg else
                                  math.radians(phase_limit),
                                  color='k', linestyle=':', zorder=-20)
-                phase_ylim = ax_phase.get_ylim()
 
             # Annotate the phase margin (if it exists)
             if plot_phase and pm != float('inf') and Wcp != float('nan'):
@@ -2222,7 +2219,7 @@ def gangof4_plot(
         See :class:`ControlPlot` for more detailed information.
 
     """
-    if len(args) == 1 and isinstance(arg, FrequencyResponseData):
+    if len(args) == 1 and isinstance(args[0], FrequencyResponseData):
         if any([kw is not None
                 for kw in [omega, omega_limits, omega_num, Hz]]):
             raise ValueError(
diff --git a/control/iosys.py b/control/iosys.py
index 373bc211..29365731 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -8,7 +8,6 @@
 
 import re
 from copy import deepcopy
-from warnings import warn
 
 import numpy as np
 
diff --git a/control/lti.py b/control/lti.py
index cb785ca5..b5f63416 100644
--- a/control/lti.py
+++ b/control/lti.py
@@ -7,7 +7,8 @@ and TransferFunction.  It is designed for use in the python-control library.
 import numpy as np
 import math
 
-from numpy import real, angle, abs
+# todo: override built-in abs
+from numpy import real, abs
 from warnings import warn
 from . import config
 from .iosys import InputOutputSystem
diff --git a/control/margins.py b/control/margins.py
index 019c866b..80edc12c 100644
--- a/control/margins.py
+++ b/control/margins.py
@@ -52,7 +52,6 @@ from warnings import warn
 import numpy as np
 import scipy as sp
 from . import xferfcn
-from .lti import evalfr
 from .iosys import issiso
 from . import frdata
 from . import freqplot
diff --git a/control/mateqn.py b/control/mateqn.py
index 52b69e2b..9100f567 100644
--- a/control/mateqn.py
+++ b/control/mateqn.py
@@ -37,7 +37,7 @@
 
 import warnings
 import numpy as np
-from numpy import copy, eye, dot, finfo, inexact, atleast_2d
+from numpy import eye, finfo, inexact
 
 import scipy as sp
 from scipy.linalg import eigvals, solve
diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py
index 98e6babc..dca522ec 100644
--- a/control/matlab/__init__.py
+++ b/control/matlab/__init__.py
@@ -50,6 +50,9 @@ $Id$
 
 """
 
+# Silence unused imports (F401), * imports (F403), unknown symbols (F405)
+# ruff: noqa: F401, F403, F405
+
 # Import MATLAB-like functions that are defined in other packages
 from scipy.signal import zpk2ss, ss2zpk, tf2zpk, zpk2tf
 from numpy import linspace, logspace
diff --git a/control/modelsimp.py b/control/modelsimp.py
index fe519b82..96800305 100644
--- a/control/modelsimp.py
+++ b/control/modelsimp.py
@@ -340,7 +340,7 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None):
 
     # check if orders is a list or a scalar
     try:
-        order = iter(orders)
+        iter(orders)
     except TypeError:           # if orders is a scalar
         orders = [orders]
 
diff --git a/control/nichols.py b/control/nichols.py
index ac42c9c3..933bf750 100644
--- a/control/nichols.py
+++ b/control/nichols.py
@@ -21,7 +21,6 @@ from . import config
 from .ctrlplot import ControlPlot, _get_line_labels, _process_ax_keyword, \
     _process_legend_keywords, _process_line_labels, _update_plot_title
 from .ctrlutil import unwrap
-from .freqplot import _default_frequency_range, _freqplot_defaults
 from .lti import frequency_response
 from .statesp import StateSpace
 from .xferfcn import TransferFunction
@@ -137,7 +136,7 @@ def nichols_plot(
 
         # Decide on the system name and label
         sysname = response.sysname if response.sysname is not None \
-            else f"Unknown-{idx_sys}"
+            else f"Unknown-sys_{idx}"
         label_ = sysname if label is None else label[idx]
 
         # Generate the plot
diff --git a/control/nlsys.py b/control/nlsys.py
index 7683d338..39d61c4e 100644
--- a/control/nlsys.py
+++ b/control/nlsys.py
@@ -18,7 +18,6 @@ points and linearizations.
 
 """
 
-import copy
 from warnings import warn
 
 import numpy as np
@@ -26,7 +25,7 @@ import scipy as sp
 
 from . import config
 from .iosys import InputOutputSystem, _parse_spec, _process_iosys_keywords, \
-    _process_signal_list, common_timebase, iosys_repr, isctime, isdtime
+    common_timebase, iosys_repr, isctime, isdtime
 from .timeresp import _check_convert_array, _process_time_response, \
     TimeResponseData, TimeResponseList
 
@@ -211,7 +210,7 @@ class NonlinearIOSystem(InputOutputSystem):
                 "can't multiply systems with incompatible inputs and outputs")
 
         # Make sure timebase are compatible
-        dt = common_timebase(other.dt, self.dt)
+        common_timebase(other.dt, self.dt)
 
         # Create a new system to handle the composition
         inplist = [(0, i) for i in range(other.ninputs)]
@@ -243,7 +242,7 @@ class NonlinearIOSystem(InputOutputSystem):
                              "inputs and outputs")
 
         # Make sure timebase are compatible
-        dt = common_timebase(self.dt, other.dt)
+        common_timebase(self.dt, other.dt)
 
         # Create a new system to handle the composition
         inplist = [(0, i) for i in range(self.ninputs)]
@@ -811,7 +810,7 @@ class InterconnectedSystem(NonlinearIOSystem):
                 return (" - " if not first else "-") + \
                     f"{abs(gain)} * {signal}"
 
-        out += f"\nConnections:\n"
+        out += "\nConnections:\n"
         for i in range(len(input_list)):
             first = True
             cxn = f"{input_list[i]} <- "
@@ -831,7 +830,7 @@ class InterconnectedSystem(NonlinearIOSystem):
                 cxn, width=78, initial_indent=" * ",
                 subsequent_indent="     ")) + "\n"
 
-        out += f"\nOutputs:\n"
+        out += "\nOutputs:\n"
         for i in range(len(self.output_labels)):
             first = True
             cxn = f"{self.output_labels[i]} <- "
@@ -2474,8 +2473,7 @@ def interconnect(
     `outputs`, for more natural naming of SISO systems.
 
     """
-    from .statesp import LinearICSystem, StateSpace, _convert_to_statespace
-    from .xferfcn import TransferFunction
+    from .statesp import LinearICSystem, StateSpace
 
     dt = kwargs.pop('dt', None)         # bypass normal 'dt' processing
     name, inputs, outputs, states, _ = _process_iosys_keywords(kwargs)
@@ -2537,7 +2535,7 @@ def interconnect(
     # This includes signal lists such as ('sysname', ['sig1', 'sig2', ...])
     # as well as slice-based specifications such as 'sysname.signal[i:j]'.
     #
-    dprint(f"Pre-processing connections:")
+    dprint("Pre-processing connections:")
     new_connections = []
     for connection in connections:
         dprint(f"  parsing {connection=}")
@@ -2576,7 +2574,7 @@ def interconnect(
     #
     dprint(f"Pre-processing input connections: {inplist}")
     if not isinstance(inplist, list):
-        dprint(f"  converting inplist to list")
+        dprint("  converting inplist to list")
         inplist = [inplist]
     new_inplist, new_inputs = [], [] if inplist_none else inputs
 
@@ -2639,7 +2637,7 @@ def interconnect(
         else:
             if isinstance(connection, list):
                 # Passed a list => create input map
-                dprint(f"  detected input list")
+                dprint("  detected input list")
                 signal_list = []
                 for spec in connection:
                     isys, indices, gain = _parse_spec(syslist, spec, 'input')
@@ -2665,7 +2663,7 @@ def interconnect(
     #
     dprint(f"Pre-processing output connections: {outlist}")
     if not isinstance(outlist, list):
-        dprint(f"  converting outlist to list")
+        dprint("  converting outlist to list")
         outlist = [outlist]
     new_outlist, new_outputs = [], [] if outlist_none else outputs
     for iout, connection in enumerate(outlist):
@@ -2742,7 +2740,7 @@ def interconnect(
 
             if isinstance(connection, list):
                 # Passed a list => create input map
-                dprint(f"  detected output list")
+                dprint("  detected output list")
                 signal_list = []
                 for spec in connection:
                     signal_list += _find_output_or_input_signal(spec)
diff --git a/control/optimal.py b/control/optimal.py
index 77cfd370..10384fce 100644
--- a/control/optimal.py
+++ b/control/optimal.py
@@ -23,8 +23,7 @@ import logging
 import time
 
 from . import config
-from .exception import ControlNotImplemented
-from .iosys import _process_indices, _process_labels, \
+from .iosys import _process_labels, \
     _process_control_disturbance_indices
 
 
@@ -163,7 +162,7 @@ class OptimalControlProblem():
         if trajectory_method is None:
             trajectory_method = 'collocation' if sys.isctime() else 'shooting'
         elif trajectory_method not in _optimal_trajectory_methods:
-            raise NotImplementedError(f"Unkown method {method}")
+            raise NotImplementedError(f"Unknown method {trajectory_method}")
 
         self.shooting = trajectory_method in {'shooting'}
         self.collocation = trajectory_method in {'collocation'}
@@ -1106,7 +1105,7 @@ def solve_ocp(
     # Process (legacy) method keyword
     if kwargs.get('method'):
         method = kwargs.pop('method')
-        if method not in optimal_methods:
+        if method not in _optimal_trajectory_methods:
             if kwargs.get('minimize_method'):
                 raise ValueError("'minimize_method' specified more than once")
             warnings.warn(
diff --git a/control/phaseplot.py b/control/phaseplot.py
index e6123bc0..f5621253 100644
--- a/control/phaseplot.py
+++ b/control/phaseplot.py
@@ -38,7 +38,7 @@ from scipy.integrate import odeint
 from . import config
 from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \
     _process_ax_keyword, _update_plot_title
-from .exception import ControlNotImplemented
+from .exception import ControlArgument
 from .nlsys import NonlinearIOSystem, find_operating_point, \
     input_output_response
 
@@ -162,7 +162,6 @@ def phase_plane_plot(
 
     # Create copy of kwargs for later checking to find unused arguments
     initial_kwargs = dict(kwargs)
-    passed_kwargs = False
 
     # Utility function to create keyword arguments
     def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs):
@@ -631,7 +630,7 @@ def separatrices(
         case (stable_color, unstable_color) | [stable_color, unstable_color]:
             pass
         case single_color:
-            stable_color = unstable_color = color
+            stable_color = unstable_color = single_color
 
     # Make sure all keyword arguments were processed
     if _check_kwargs and kwargs:
@@ -1091,9 +1090,9 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None,
     # Get parameters to pass to function
     if parms:
         warnings.warn(
-            f"keyword 'parms' is deprecated; use 'params'", FutureWarning)
+            "keyword 'parms' is deprecated; use 'params'", FutureWarning)
         if params:
-            raise ControlArgument(f"duplicate keywords 'parms' and 'params'")
+            raise ControlArgument("duplicate keywords 'parms' and 'params'")
         else:
             params = parms
 
@@ -1144,10 +1143,11 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None,
         if scale is None:
             plt.quiver(x1, x2, dx[:,:,1], dx[:,:,2], angles='xy')
         elif (scale != 0):
+            plt.quiver(x1, x2, dx[:,:,0]*np.abs(scale),
+                       dx[:,:,1]*np.abs(scale), angles='xy')
             #! TODO: optimize parameters for arrows
             #! TODO: figure out arguments to make arrows show up correctly
-            xy = plt.quiver(x1, x2, dx[:,:,0]*np.abs(scale),
-                            dx[:,:,1]*np.abs(scale), angles='xy')
+            # xy = plt.quiver(...)
             # set(xy, 'LineWidth', PP_arrow_linewidth, 'Color', 'b')
 
         #! TODO: Tweak the shape of the plot
@@ -1257,15 +1257,17 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None,
         #! TODO: figure out arguments to make arrows show up correctly
         plt.quiver(x1, x2, dx[:,:,0], dx[:,:,1], angles='xy')
     elif scale != 0 and Narrows > 0:
+        plt.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale),
+                   angles='xy')
         #! TODO: figure out arguments to make arrows show up correctly
-        xy = plt.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale),
-                        angles='xy')
+        # xy = plt.quiver(...)
         # set(xy, 'LineWidth', PP_arrow_linewidth)
         # set(xy, 'AutoScale', 'off')
         # set(xy, 'AutoScaleFactor', 0)
 
     if scale < 0:
-        bp = plt.plot(x1, x2, 'b.');        # add dots at base
+        plt.plot(x1, x2, 'b.');        # add dots at base
+        # bp = plt.plot(...)
         # set(bp, 'MarkerSize', PP_arrow_markersize)
 
 
diff --git a/control/pzmap.py b/control/pzmap.py
index f1d0ecae..b62e0dcf 100644
--- a/control/pzmap.py
+++ b/control/pzmap.py
@@ -11,21 +11,18 @@
 
 import itertools
 import warnings
-from math import pi
 
 import matplotlib.pyplot as plt
 import numpy as np
-from numpy import cos, exp, imag, linspace, real, sin, sqrt
+from numpy import imag, real
 
 from . import config
 from .config import _process_legacy_keyword
 from .ctrlplot import ControlPlot, _get_color, _get_color_offset, \
     _get_line_labels, _process_ax_keyword, _process_legend_keywords, \
     _process_line_labels, _update_plot_title
-from .freqplot import _freqplot_defaults
 from .grid import nogrid, sgrid, zgrid
 from .iosys import isctime, isdtime
-from .lti import LTI
 from .statesp import StateSpace
 from .xferfcn import TransferFunction
 
diff --git a/control/rlocus.py b/control/rlocus.py
index e5f61f91..065fbc10 100644
--- a/control/rlocus.py
+++ b/control/rlocus.py
@@ -16,17 +16,14 @@
 #
 
 import warnings
-from functools import partial
 
-import matplotlib.pyplot as plt
 import numpy as np
 import scipy.signal  # signal processing toolbox
-from numpy import array, imag, poly1d, real, vstack, zeros_like
+from numpy import poly1d, vstack, zeros_like
 
 from . import config
 from .ctrlplot import ControlPlot
 from .exception import ControlMIMONotImplemented
-from .iosys import isdtime
 from .lti import LTI
 from .xferfcn import _convert_to_transfer_function
 
@@ -455,20 +452,18 @@ def _RLSortRoots(roots):
     one branch to another."""
 
     sorted = zeros_like(roots)
-    for n, row in enumerate(roots):
-        if n == 0:
-            sorted[n, :] = row
-        else:
-            # sort the current row by finding the element with the
-            # smallest absolute distance to each root in the
-            # previous row
-            available = list(range(len(prevrow)))
-            for elem in row:
-                evect = elem - prevrow[available]
-                ind1 = abs(evect).argmin()
-                ind = available.pop(ind1)
-                sorted[n, ind] = elem
-        prevrow = sorted[n, :]
+    sorted[0] = roots[0]
+    for n, row in enumerate(roots[1:], start=1):
+        # sort the current row by finding the element with the
+        # smallest absolute distance to each root in the
+        # previous row
+        prevrow = sorted[n-1]
+        available = list(range(len(prevrow)))
+        for elem in row:
+            evect = elem - prevrow[available]
+            ind1 = abs(evect).argmin()
+            ind = available.pop(ind1)
+            sorted[n, ind] = elem
     return sorted
 
 
diff --git a/control/robust.py b/control/robust.py
index f9283af4..3e44c1bb 100644
--- a/control/robust.py
+++ b/control/robust.py
@@ -42,9 +42,8 @@
 # External packages and modules
 import numpy as np
 import warnings
-from .exception import *
+from .exception import ControlSlycot
 from .statesp import StateSpace
-from .statefbk import *
 
 
 def h2syn(P, nmeas, ncon):
@@ -98,12 +97,6 @@ def h2syn(P, nmeas, ncon):
     # Check for ss system object, need a utility for this?
 
     # TODO: Check for continous or discrete, only continuous supported right now
-    # if isCont():
-    #    dico = 'C'
-    # elif isDisc():
-    #    dico = 'D'
-    # else:
-    dico = 'C'
 
     try:
         from slycot import sb10hd
@@ -186,12 +179,6 @@ def hinfsyn(P, nmeas, ncon):
     # Check for ss system object, need a utility for this?
 
     # TODO: Check for continous or discrete, only continuous supported right now
-    # if isCont():
-    #    dico = 'C'
-    # elif isDisc():
-    #    dico = 'D'
-    # else:
-    dico = 'C'
 
     try:
         from slycot import sb10ad
diff --git a/control/statefbk.py b/control/statefbk.py
index 87b12da8..5d635196 100644
--- a/control/statefbk.py
+++ b/control/statefbk.py
@@ -46,8 +46,7 @@ import scipy as sp
 
 from . import statesp
 from .config import _process_legacy_keyword
-from .exception import ControlArgument, ControlDimension, \
-    ControlSlycot
+from .exception import ControlArgument, ControlSlycot
 from .iosys import _process_indices, _process_labels, isctime, isdtime
 from .lti import LTI
 from .mateqn import care, dare
diff --git a/control/statesp.py b/control/statesp.py
index c0e8e786..a5b5f9fd 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -16,14 +16,15 @@ python-control library.
 import math
 import sys
 from collections.abc import Iterable
-from copy import deepcopy
 from warnings import warn
 
 import numpy as np
 import scipy as sp
 import scipy.linalg
-from numpy import any, array, asarray, concatenate, cos, delete, empty, \
-    exp, eye, isinf, ones, pad, sin, squeeze, zeros
+# array needed in eval() call
+from numpy import array # noqa: F401
+from numpy import any, asarray, concatenate, cos, delete, empty, \
+    exp, eye, isinf, pad, sin, squeeze, zeros
 from numpy.linalg import LinAlgError, eigvals, matrix_rank, solve
 from numpy.random import rand, randn
 from scipy.signal import StateSpace as signalStateSpace
@@ -34,9 +35,9 @@ from . import bdalg
 from .exception import ControlDimension, ControlMIMONotImplemented, \
     ControlSlycot, slycot_check
 from .frdata import FrequencyResponseData
-from .iosys import InputOutputSystem, NamedSignal, _process_dt_keyword, \
+from .iosys import InputOutputSystem, NamedSignal, \
     _process_iosys_keywords, _process_signal_list, _process_subsys_index, \
-    common_timebase, iosys_repr, isdtime, issiso
+    common_timebase, issiso
 from .lti import LTI, _process_frequency_response
 from .mateqn import _check_shape
 from .nlsys import InterconnectedSystem, NonlinearIOSystem
diff --git a/control/stochsys.py b/control/stochsys.py
index 5aaa2941..1dca7c44 100644
--- a/control/stochsys.py
+++ b/control/stochsys.py
@@ -16,16 +16,18 @@ __license__ = "BSD"
 __maintainer__ = "Richard Murray"
 __email__ = "murray@cds.caltech.edu"
 
+import warnings
+
 import numpy as np
 import scipy as sp
 from math import sqrt
 
-from .statesp import StateSpace
 from .lti import LTI
-from .iosys import InputOutputSystem, isctime, isdtime, _process_indices, \
-    _process_labels, _process_control_disturbance_indices
+from .iosys import (isctime, isdtime, _process_labels,
+                    _process_control_disturbance_indices)
 from .nlsys import NonlinearIOSystem
 from .mateqn import care, dare, _check_shape
+from .statesp import StateSpace
 from .exception import ControlArgument, ControlNotImplemented
 from .config import _process_legacy_keyword
 
@@ -164,12 +166,14 @@ def lqe(*args, **kwargs):
 
     # Get the cross-covariance matrix, if given
     if (len(args) > index + 2):
-        NN = np.array(args[index+2], ndmin=2, dtype=float)
+        # NN = np.array(args[index+2], ndmin=2, dtype=float)
         raise ControlNotImplemented("cross-covariance not implemented")
 
     else:
+        pass
         # For future use (not currently used below)
-        NN = np.zeros((QN.shape[0], RN.shape[1]))
+        # NN = np.zeros((QN.shape[0], RN.shape[1]))
+
 
     # Check dimensions of G (needed before calling care())
     _check_shape(QN, G.shape[1], G.shape[1], name="QN")
@@ -288,7 +292,7 @@ def dlqe(*args, **kwargs):
     #    NN = np.zeros(QN.size(0),RN.size(1))
     # NG = G @ NN
     if len(args) > index + 2:
-        NN = np.array(args[index+2], ndmin=2, dtype=float)
+        # NN = np.array(args[index+2], ndmin=2, dtype=float)
         raise ControlNotImplemented("cross-covariance not yet implememented")
 
     # Check dimensions of G (needed before calling care())
@@ -459,7 +463,7 @@ def create_estimator_iosystem(
     # Set the input and direct matrices
     B = sys.B[:, ctrl_idx]
     if not np.allclose(sys.D, 0):
-        raise NotImplemented("nonzero 'D' matrix not yet implemented")
+        raise NotImplementedError("nonzero 'D' matrix not yet implemented")
 
     # Set the output matrices
     if C is not None:
diff --git a/control/sysnorm.py b/control/sysnorm.py
index 6737dc5c..680bb4b1 100644
--- a/control/sysnorm.py
+++ b/control/sysnorm.py
@@ -12,7 +12,6 @@ Author: Henrik Sandberg
 """
 
 import numpy as np
-import scipy as sp
 import numpy.linalg as la
 import warnings
 
diff --git a/control/timeplot.py b/control/timeplot.py
index 1c7efe89..6fde8c67 100644
--- a/control/timeplot.py
+++ b/control/timeplot.py
@@ -11,7 +11,6 @@
 import itertools
 from warnings import warn
 
-import matplotlib as mpl
 import matplotlib.pyplot as plt
 import numpy as np
 
@@ -178,8 +177,6 @@ def time_response_plot(
 
     """
     from .ctrlplot import _process_ax_keyword, _process_line_labels
-    from .iosys import InputOutputSystem
-    from .timeresp import TimeResponseData
 
     #
     # Process keywords and set defaults
diff --git a/control/timeresp.py b/control/timeresp.py
index 67641d23..e6281263 100644
--- a/control/timeresp.py
+++ b/control/timeresp.py
@@ -1838,8 +1838,6 @@ def initial_response(
     >>> T, yout = ct.initial_response(G)
 
     """
-    from .lti import LTI
-
     # Create the time and input vectors
     if T is None or np.asarray(T).size == 1:
         T = _default_time_vector(sysdata, N=T_num, tfinal=T, is_step=False)
diff --git a/control/xferfcn.py b/control/xferfcn.py
index 4a8fd4a1..1f8686c2 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -22,9 +22,11 @@ from warnings import warn
 
 import numpy as np
 import scipy as sp
-from numpy import angle, array, delete, empty, exp, finfo, float64, ndarray, \
-    nonzero, ones, pi, poly, polyadd, polymul, polyval, real, roots, sqrt, \
-    squeeze, where, zeros
+# float64 needed in eval() call
+from numpy import float64 # noqa: F401
+from numpy import array, delete, empty, exp, finfo, ndarray, \
+    nonzero, ones, poly, polyadd, polymul, polyval, real, roots, sqrt, \
+    where, zeros
 from scipy.signal import TransferFunction as signalTransferFunction
 from scipy.signal import cont2discrete, tf2zpk, zpk2tf
 
@@ -33,7 +35,7 @@ from . import bdalg
 from .exception import ControlMIMONotImplemented
 from .frdata import FrequencyResponseData
 from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \
-    _process_subsys_index, common_timebase, isdtime
+    _process_subsys_index, common_timebase
 from .lti import LTI, _process_frequency_response
 
 __all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata']
@@ -219,8 +221,8 @@ class TransferFunction(LTI):
         # Determine if the transfer function is static (needed for dt)
         static = True
         for arr in [num, den]:
-            for poly in np.nditer(arr, flags=['refs_ok']):
-                if poly.item().size > 1:
+            for poly_ in np.nditer(arr, flags=['refs_ok']):
+                if poly_.item().size > 1:
                     static = False
                     break
             if not static:
@@ -1281,8 +1283,8 @@ class TransferFunction(LTI):
         that is, if the system has no dynamics. """
         for list_of_polys in self.num, self.den:
             for row in list_of_polys:
-                for poly in row:
-                    if len(poly) > 1:
+                for poly_ in row:
+                    if len(poly_) > 1:
                         return False
         return True
 
@@ -1501,7 +1503,6 @@ def _convert_to_transfer_function(
 
     """
     from .statesp import StateSpace
-    kwargs = {}
 
     if isinstance(sys, TransferFunction):
         return sys
diff --git a/pyproject.toml b/pyproject.toml
index 649dcad5..46d41fbf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,8 +57,14 @@ filterwarnings = [
     "error:.*matrix subclass:PendingDeprecationWarning",
 ]
 
-[tool.ruff.lint]
-select = ['D', 'E', 'W', 'DOC']
+[tool.ruff]
+
+# TODO: expand to cover all code
+include = ['control/**.py']
+exclude = ['control/tests/*.py']
 
-[tool.ruff.lint.pydocstyle]
-convention = 'numpy'
+[tool.ruff.lint]
+select = [
+       'F', # pyflakes
+       # todo: add more as needed
+]


Test patch:
diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py
index 4dbf52ee..44963cd7 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:                       'f2e52e35692cf5ffe911684d41d284c9',
+    control.dlqe:                       'e1e9479310e4e5a6f50f5459fb3d2dfb',
     control.dlqr:                       '56d7f3a452bc8d7a7256a52d9d1dcb37',
-    control.lqe:                        'f0ba6cde8191cbc10f052096ffc3fcbb',
+    control.lqe:                        '0447235d11b685b9dfaf485dd01fdb9a',
     control.lqr:                        'a3e0a85f781fc9c0f69a4b7da4f0bd22',
     control.margin:                     'f02b3034f5f1d44ce26f916cc3e51600',
     control.parallel:                   'bfc470aef75dbb923f9c6fb8bf3c9b43',
@@ -40,7 +40,7 @@ function_docstring_hash = {
     control.ss2tf:                      'e779b8d70205bc1218cc2a4556a66e4b',
     control.tf2ss:                      '086a3692659b7321c2af126f79f4bc11',
     control.markov:                     'a4199c54cb50f07c0163d3790739eafe',
-    control.gangof4:                    '0e52eb6cf7ce024f9a41f3ae3ebf04f7',
+    control.gangof4:                    'f9673ae4c6d26c202060ed4b9ef54800',
 }
 
 # List of keywords that we can skip testing (special cases)
diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py
index 5b66edaf..df30d709 100644
--- a/control/tests/flatsys_test.py
+++ b/control/tests/flatsys_test.py
@@ -618,6 +618,11 @@ class TestFlatSys:
                 flat_sys, timepts, x0, u0, xf, uf,
                 constraints=[(None, 0, 0, 0)], basis=fs.PolyFamily(8))
 
+        # too few timepoints
+        with pytest.raises(ct.ControlArgument, match="at least three time points"):
+            fs.point_to_point(
+                flat_sys, timepts[:2], x0, u0, xf, uf, basis=fs.PolyFamily(10), cost=cost_fcn)
+
         # Unsolvable optimization
         constraint = [opt.input_range_constraint(flat_sys, -0.01, 0.01)]
         with pytest.warns(UserWarning, match="unable to solve"):