Skip to content

Frequency plot improvements #1011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Copyright (c) 2009-2016 by California Institute of Technology
Copyright (c) 2016-2023 by python-control developers
Copyright (c) 2012 by Delft University of Technology
Copyright (c) 2016-2024 by python-control developers
All rights reserved.

Redistribution and use in source and binary forms, with or without
Expand Down
1 change: 1 addition & 0 deletions control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from .timeplot import *

from .bdalg import *
from .ctrlplot import *
from .delay import *
from .descfcn import *
from .dtime import *
Expand Down
4 changes: 2 additions & 2 deletions control/bdalg.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ def feedback(sys1, sys2=1, sign=-1):
if isinstance(sys2, (int, float, complex, np.number, np.ndarray,
tf.TransferFunction)):
sys1 = tf._convert_to_transfer_function(sys1)
elif isinstance(sys2, frd.FRD):
sys1 = frd._convert_to_FRD(sys1, sys2.omega)
elif isinstance(sys2, frd.FrequencyResponseData):
sys1 = frd._convert_to_frd(sys1, sys2.omega)
else:
sys1 = ss._convert_to_statespace(sys1)

Expand Down
74 changes: 74 additions & 0 deletions control/ctrlplot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ctrlplot.py - utility functions for plotting
# Richard M. Murray, 14 Jun 2024
#
# Collection of functions that are used by various plotting functions.

import matplotlib.pyplot as plt
import numpy as np

from . import config

__all__ = ['suptitle']


def suptitle(
title, fig=None, frame='axes', **kwargs):
"""Add a centered title to a figure.

This is a wrapper for the matplotlib `suptitle` function, but by
setting ``frame`` to 'axes' (default) then the title is centered on the
midpoint of the axes in the figure, rather than the center of the
figure. This usually looks better (particularly with multi-panel
plots), though it takes longer to render.

Parameters
----------
title : str
Title text.
fig : Figure, optional
Matplotlib figure. Defaults to current figure.
frame : str, optional
Coordinate frame to use for centering: 'axes' (default) or 'figure'.
**kwargs : :func:`matplotlib.pyplot.suptitle` keywords, optional
Additional keywords (passed to matplotlib).

"""
rcParams = config._get_param('freqplot', 'rcParams', kwargs, pop=True)

if fig is None:
fig = plt.gcf()

if frame == 'figure':
with plt.rc_context(rcParams):
fig.suptitle(title, **kwargs)

elif frame == 'axes':
# TODO: move common plotting params to 'ctrlplot'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This same TODO comment is already freqplot.py, and it is not clear what the comment means here (in ctrlplot). Should the TODO comment be deleted here in favor of the one in freqplot.py ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading it again, I think this TODO comment is to be able to

rcParams = config._get_param('ctrlplot', 'rcParams', rcParams)

If so, then I understand the motivation, and OK to keep both TODO comments.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got another PR coming that will address both TODOs. Doing things in stages so that everything is in (slightly) smaller chunks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. I am almost done reviewing this one.

rcParams = config._get_param('freqplot', 'rcParams', rcParams)
with plt.rc_context(rcParams):
plt.tight_layout() # Put the figure into proper layout
xc, _ = _find_axes_center(fig, fig.get_axes())

fig.suptitle(title, x=xc, **kwargs)
plt.tight_layout() # Update the layout

else:
raise ValueError(f"unknown frame '{frame}'")


def _find_axes_center(fig, axs):
"""Find the midpoint between axes in display coordinates.

This function finds the middle of a plot as defined by a set of axes.

"""
inv_transform = fig.transFigure.inverted()
xlim = ylim = [1, 0]
for ax in axs:
ll = inv_transform.transform(ax.transAxes.transform((0, 0)))
ur = inv_transform.transform(ax.transAxes.transform((1, 1)))

xlim = [min(ll[0], xlim[0]), max(ur[0], xlim[1])]
ylim = [min(ll[1], ylim[0]), max(ur[1], ylim[1])]

return (np.sum(xlim)/2, np.sum(ylim)/2)
148 changes: 67 additions & 81 deletions control/frdata.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,27 @@
# Copyright (c) 2010 by California Institute of Technology
# Copyright (c) 2012 by Delft University of Technology
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# 3. Neither the names of the California Institute of Technology nor
# the Delft University of Technology nor
# the names of its contributors may be used to endorse or promote
# products derived from this software without specific prior
# written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH
# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
# frdata.py - frequency response data representation and functions
#
# Author: M.M. (Rene) van Paassen (using xferfcn.py as basis)
# Date: 02 Oct 12


"""
Frequency response data representation and functions.

This module contains the FRD class and also functions that operate on
FRD data.
"""

# External function declarations
from copy import copy
from warnings import warn

import numpy as np
from numpy import angle, array, empty, ones, \
real, imag, absolute, eye, linalg, where, sort
from scipy.interpolate import splprep, splev
from numpy import absolute, angle, array, empty, eye, imag, linalg, ones, \
real, sort, where
from scipy.interpolate import splev, splprep

from .lti import LTI, _process_frequency_response
from . import config
from .exception import pandas_check
from .iosys import InputOutputSystem, _process_iosys_keywords, common_timebase
from . import config
from .lti import LTI, _process_frequency_response

__all__ = ['FrequencyResponseData', 'FRD', 'frd']

Expand Down Expand Up @@ -100,6 +66,10 @@ class constructor, using the :func:~~control.frd` factory function
dt : float, True, or None
System timebase.

See Also
--------
frd

Notes
-----
The main data members are 'omega' and 'fresp', where 'omega' is a 1D array
Expand All @@ -120,7 +90,6 @@ class constructor, using the :func:~~control.frd` factory function
for a more detailed description.

"""

#
# Class attributes
#
Expand Down Expand Up @@ -206,11 +175,12 @@ def __init__(self, *args, **kwargs):
"Needs 1 or 2 arguments; received %i." % len(args))

#
# Process key word arguments
# Process keyword arguments
#

# If data was generated by a system, keep track of that
self.sysname = kwargs.pop('sysname', None)
# If data was generated by a system, keep track of that (used when
# plotting data). Otherwise, use the system name, if given.
self.sysname = kwargs.pop('sysname', kwargs.get('name', None))

# Keep track of default properties for plotting
self.plot_phase = kwargs.pop('plot_phase', None)
Expand Down Expand Up @@ -280,7 +250,7 @@ def __str__(self):
"""String representation of the transfer function."""

mimo = self.ninputs > 1 or self.noutputs > 1
outstr = ['Frequency response data']
outstr = [f"{InputOutputSystem.__str__(self)}"]

for i in range(self.ninputs):
for j in range(self.noutputs):
Expand Down Expand Up @@ -322,7 +292,7 @@ def __add__(self, other):

# Convert the second argument to a frequency response function.
# or re-base the frd to the current omega (if needed)
other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

# Check that the input-output sizes are consistent.
if self.ninputs != other.ninputs:
Expand Down Expand Up @@ -359,7 +329,7 @@ def __mul__(self, other):
return FRD(self.fresp * other, self.omega,
smooth=(self.ifunc is not None))
else:
other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

# Check that the input-output sizes are consistent.
if self.ninputs != other.noutputs:
Expand All @@ -386,7 +356,7 @@ def __rmul__(self, other):
return FRD(self.fresp * other, self.omega,
smooth=(self.ifunc is not None))
else:
other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

# Check that the input-output sizes are consistent.
if self.noutputs != other.ninputs:
Expand Down Expand Up @@ -414,7 +384,7 @@ def __truediv__(self, other):
return FRD(self.fresp * (1/other), self.omega,
smooth=(self.ifunc is not None))
else:
other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

if (self.ninputs > 1 or self.noutputs > 1 or
other.ninputs > 1 or other.noutputs > 1):
Expand All @@ -433,7 +403,7 @@ def __rtruediv__(self, other):
return FRD(other / self.fresp, self.omega,
smooth=(self.ifunc is not None))
else:
other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

if (self.ninputs > 1 or self.noutputs > 1 or
other.ninputs > 1 or other.noutputs > 1):
Expand Down Expand Up @@ -572,8 +542,8 @@ def __call__(self, s=None, squeeze=None, return_magphase=None):
------
ValueError
If `s` is not purely imaginary, because
:class:`FrequencyDomainData` systems are only defined at imaginary
frequency values.
:class:`FrequencyResponseData` systems are only defined at
imaginary values (corresponding to real frequencies).

"""
if s is None:
Expand Down Expand Up @@ -638,7 +608,7 @@ def freqresp(self, omega):
def feedback(self, other=1, sign=-1):
"""Feedback interconnection between two FRD objects."""

other = _convert_to_FRD(other, omega=self.omega)
other = _convert_to_frd(other, omega=self.omega)

if (self.noutputs != other.ninputs or self.ninputs != other.noutputs):
raise ValueError(
Expand Down Expand Up @@ -710,7 +680,7 @@ def to_pandas(self):
FRD = FrequencyResponseData


def _convert_to_FRD(sys, omega, inputs=1, outputs=1):
def _convert_to_frd(sys, omega, inputs=1, outputs=1):
"""Convert a system to frequency response data form (if needed).

If sys is already an frd, and its frequency range matches or
Expand All @@ -721,14 +691,14 @@ def _convert_to_FRD(sys, omega, inputs=1, outputs=1):
manually, as in:

>>> import numpy as np
>>> from control.frdata import _convert_to_FRD
>>> from control.frdata import _convert_to_frd

>>> omega = np.logspace(-1, 1)
>>> frd = _convert_to_FRD(3., omega) # Assumes inputs = outputs = 1
>>> frd = _convert_to_frd(3., omega) # Assumes inputs = outputs = 1
>>> frd.ninputs, frd.noutputs
(1, 1)

>>> frd = _convert_to_FRD(1., omega, inputs=3, outputs=2)
>>> frd = _convert_to_frd(1., omega, inputs=3, outputs=2)
>>> frd.ninputs, frd.noutputs
(3, 2)

Expand Down Expand Up @@ -777,51 +747,67 @@ def _convert_to_FRD(sys, omega, inputs=1, outputs=1):
sys.__class__)


def frd(*args):
"""frd(d, w)

Construct a frequency response data model.
def frd(*args, **kwargs):
"""frd(response, omega[, dt])

frd models store the (measured) frequency response of a system.
Construct a frequency response data (FRD) model.

This function can be called in different ways:
A frequency response data model stores the (measured) frequency response
of a system. This factory function can be called in different ways:

``frd(response, freqs)``
``frd(response, omega)``
Create an frd model with the given response data, in the form of
complex response vector, at matching frequency freqs [in rad/s]
complex response vector, at matching frequencies ``omega`` [in rad/s].

``frd(sys, freqs)``
``frd(sys, omega)``
Convert an LTI system into an frd model with data at frequencies
freqs.
``omega``.

Parameters
----------
response: array_like, or list
complex vector with the system response
freq: array_lik or lis
vector with frequencies
sys: LTI (StateSpace or TransferFunction)
A linear system
response : array_like or LTI system
Complex vector with the system response or an LTI system that can
be used to copmute the frequency response at a list of frequencies.
omega : array_like
Vector of frequencies at which the response is evaluated.
dt : float, True, or None
System timebase.
smooth : bool, optional
If ``True``, create an interpolation function that allows the
frequency response to be computed at any frequency within the range
of frequencies give in ``omega``. If ``False`` (default),
frequency response can only be obtained at the frequencies
specified in ``omega``.

Returns
-------
sys: FRD
New frequency response system
sys : :class:`FrequencyResponseData`
New frequency response data system.

Other Parameters
----------------
inputs, outputs : str, or list of str, optional
List of strings that name the individual signals of the transformed
system. If not given, the inputs and outputs are the same as the
original system.
name : string, optional
System name. If unspecified, a generic name <sys[id]> is generated
with a unique integer id.

See Also
--------
FRD, ss, tf
FrequencyResponseData, frequency_response, ss, tf

Examples
--------
>>> # Create from measurements
>>> response = [1.0, 1.0, 0.5]
>>> freqs = [1, 10, 100]
>>> F = ct.frd(response, freqs)
>>> omega = [1, 10, 100]
>>> F = ct.frd(response, omega)

>>> G = ct.tf([1], [1, 1])
>>> freqs = [1, 10, 100]
>>> F = ct.frd(G, freqs)
>>> omega = [1, 10, 100]
>>> F = ct.frd(G, omega)

"""
return FRD(*args)
return FrequencyResponseData(*args, **kwargs)
Loading
Loading