• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

python-control / python-control / 12153036402

04 Dec 2024 04:28AM UTC coverage: 94.721% (+0.02%) from 94.697%
12153036402

Pull #1069

github

web-flow
Merge 7ef09a0ed into 69efbbed2
Pull Request #1069: Allow signal names to be used for time/freq responses and subsystem indexing

9222 of 9736 relevant lines covered (94.72%)

8.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.64
control/frdata.py
1
# frdata.py - frequency response data representation and functions
2
#
3
# Author: M.M. (Rene) van Paassen (using xferfcn.py as basis)
4
# Date: 02 Oct 12
5

6
"""
7
Frequency response data representation and functions.
8

9
This module contains the FRD class and also functions that operate on
10
FRD data.
11
"""
12

13
from collections.abc import Iterable
9✔
14
from copy import copy
9✔
15
from warnings import warn
9✔
16

17
import numpy as np
9✔
18
from numpy import absolute, angle, array, empty, eye, imag, linalg, ones, \
9✔
19
    real, sort, where
20
from scipy.interpolate import splev, splprep
9✔
21

22
from . import config
9✔
23
from .exception import pandas_check
9✔
24
from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \
9✔
25
    _process_subsys_index, common_timebase
26
from .lti import LTI, _process_frequency_response
9✔
27

28
__all__ = ['FrequencyResponseData', 'FRD', 'frd']
9✔
29

30

31
class FrequencyResponseData(LTI):
9✔
32
    """FrequencyResponseData(d, w[, smooth])
33

34
    A class for models defined by frequency response data (FRD).
35

36
    The FrequencyResponseData (FRD) class is used to represent systems in
37
    frequency response data form.  It can be created manually using the
38
    class constructor, using the :func:`~control.frd` factory function or
39
    via the :func:`~control.frequency_response` function.
40

41
    Parameters
42
    ----------
43
    d : 1D or 3D complex array_like
44
        The frequency response at each frequency point.  If 1D, the system is
45
        assumed to be SISO.  If 3D, the system is MIMO, with the first
46
        dimension corresponding to the output index of the FRD, the second
47
        dimension corresponding to the input index, and the 3rd dimension
48
        corresponding to the frequency points in omega
49
    w : iterable of real frequencies
50
        List of frequency points for which data are available.
51
    sysname : str or None
52
        Name of the system that generated the data.
53
    smooth : bool, optional
54
        If ``True``, create an interpolation function that allows the
55
        frequency response to be computed at any frequency within the range of
56
        frequencies give in ``w``.  If ``False`` (default), frequency response
57
        can only be obtained at the frequencies specified in ``w``.
58

59
    Attributes
60
    ----------
61
    ninputs, noutputs : int
62
        Number of input and output variables.
63
    omega : 1D array
64
        Frequency points of the response.
65
    fresp : 3D array
66
        Frequency response, indexed by output index, input index, and
67
        frequency point.
68
    dt : float, True, or None
69
        System timebase.
70
    squeeze : bool
71
        By default, if a system is single-input, single-output (SISO) then
72
        the outputs (and inputs) are returned as a 1D array (indexed by
73
        frequency) and if a system is multi-input or multi-output, then the
74
        outputs are returned as a 2D array (indexed by output and
75
        frequency) or a 3D array (indexed by output, trace, and frequency).
76
        If ``squeeze=True``, access to the output response will remove
77
        single-dimensional entries from the shape of the inputs and outputs
78
        even if the system is not SISO. If ``squeeze=False``, the output is
79
        returned as a 3D array (indexed by the output, input, and
80
        frequency) even if the system is SISO. The default value can be set
81
        using config.defaults['control.squeeze_frequency_response'].
82
    ninputs, noutputs, nstates : int
83
        Number of inputs, outputs, and states of the underlying system.
84
    input_labels, output_labels : array of str
85
        Names for the input and output variables.
86
    sysname : str, optional
87
        Name of the system.  For data generated using
88
        :func:`~control.frequency_response`, stores the name of the system
89
        that created the data.
90
    title : str, optional
91
        Set the title to use when plotting.
92

93
    See Also
94
    --------
95
    frd
96

97
    Notes
98
    -----
99
    The main data members are 'omega' and 'fresp', where 'omega' is a 1D array
100
    of frequency points and and 'fresp' is a 3D array of frequency responses,
101
    with the first dimension corresponding to the output index of the FRD, the
102
    second dimension corresponding to the input index, and the 3rd dimension
103
    corresponding to the frequency points in omega.  For example,
104

105
    >>> frdata[2,5,:] = numpy.array([1., 0.8-0.2j, 0.2-0.8j])   # doctest: +SKIP
106

107
    means that the frequency response from the 6th input to the 3rd output at
108
    the frequencies defined in omega is set to the array above, i.e. the rows
109
    represent the outputs and the columns represent the inputs.
110

111
    A frequency response data object is callable and returns the value of the
112
    transfer function evaluated at a point in the complex plane (must be on
113
    the imaginary access).  See :meth:`~control.FrequencyResponseData.__call__`
114
    for a more detailed description.
115

116
    A state space system is callable and returns the value of the transfer
117
    function evaluated at a point in the complex plane.  See
118
    :meth:`~control.StateSpace.__call__` for a more detailed description.
119

120
    Subsystem response corresponding to selected input/output pairs can be
121
    created by indexing the frequency response data object::
122

123
        subsys = sys[output_spec, input_spec]
124

125
    The input and output specifications can be single integers, lists of
126
    integers, or slices.  In addition, the strings representing the names
127
    of the signals can be used and will be replaced with the equivalent
128
    signal offsets.
129

130
    """
131
    #
132
    # Class attributes
133
    #
134
    # These attributes are defined as class attributes so that they are
135
    # documented properly.  They are "overwritten" in __init__.
136
    #
137

138
    #: Number of system inputs.
139
    #:
140
    #: :meta hide-value:
141
    ninputs = 1
9✔
142

143
    #: Number of system outputs.
144
    #:
145
    #: :meta hide-value:
146
    noutputs = 1
9✔
147

148
    _epsw = 1e-8                #: Bound for exact frequency match
9✔
149

150
    def __init__(self, *args, **kwargs):
9✔
151
        """Construct an FRD object.
152

153
        The default constructor is FRD(d, w), where w is an iterable of
154
        frequency points, and d is the matching frequency data.
155

156
        If d is a single list, 1D array, or tuple, a SISO system description
157
        is assumed. d can also be
158

159
        To call the copy constructor, call FRD(sys), where sys is a
160
        FRD object.
161

162
        To construct frequency response data for an existing LTI
163
        object, other than an FRD, call FRD(sys, omega).
164

165
        The timebase for the frequency response can be provided using an
166
        optional third argument or the 'dt' keyword.
167

168
        """
169
        smooth = kwargs.pop('smooth', False)
9✔
170

171
        #
172
        # Process positional arguments
173
        #
174
        if len(args) == 3:
9✔
175
            # Discrete time transfer function
176
            dt = args[-1]
9✔
177
            if 'dt' in kwargs:
9✔
178
                warn("received multiple dt arguments, "
9✔
179
                     "using positional arg dt = %s" % dt)
180
            kwargs['dt'] = dt
9✔
181
            args = args[:-1]
9✔
182

183
        if len(args) == 2:
9✔
184
            if not isinstance(args[0], FRD) and isinstance(args[0], LTI):
9✔
185
                # not an FRD, but still a system, second argument should be
186
                # the frequency range
187
                otherlti = args[0]
9✔
188
                self.omega = sort(np.asarray(args[1], dtype=float))
9✔
189
                # calculate frequency response at my points
190
                if otherlti.isctime():
9✔
191
                    s = 1j * self.omega
9✔
192
                    self.fresp = otherlti(s, squeeze=False)
9✔
193
                else:
194
                    z = np.exp(1j * self.omega * otherlti.dt)
9✔
195
                    self.fresp = otherlti(z, squeeze=False)
9✔
196
                arg_dt = otherlti.dt
9✔
197

198
            else:
199
                # The user provided a response and a freq vector
200
                self.fresp = array(args[0], dtype=complex, ndmin=1)
9✔
201
                if self.fresp.ndim == 1:
9✔
202
                    self.fresp = self.fresp.reshape(1, 1, -1)
9✔
203
                self.omega = array(args[1], dtype=float, ndmin=1)
9✔
204
                if self.fresp.ndim != 3 or self.omega.ndim != 1 or \
9✔
205
                        self.fresp.shape[-1] != self.omega.shape[-1]:
206
                    raise TypeError(
9✔
207
                        "The frequency data constructor needs a 1-d or 3-d"
208
                        " response data array and a matching frequency vector"
209
                        " size")
210
                arg_dt = None
9✔
211

212
        elif len(args) == 1:
9✔
213
            # Use the copy constructor.
214
            if not isinstance(args[0], FRD):
9✔
215
                raise TypeError(
9✔
216
                    "The one-argument constructor can only take in"
217
                    " an FRD object.  Received %s." % type(args[0]))
218
            self.omega = args[0].omega
9✔
219
            self.fresp = args[0].fresp
9✔
220
            arg_dt = args[0].dt
9✔
221

222
        else:
223
            raise ValueError(
9✔
224
                "Needs 1 or 2 arguments; received %i." % len(args))
225

226
        #
227
        # Process keyword arguments
228
        #
229

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

234
        # Keep track of default properties for plotting
235
        self.plot_phase = kwargs.pop('plot_phase', None)
9✔
236
        self.title = kwargs.pop('title', None)
9✔
237
        self.plot_type = kwargs.pop('plot_type', 'bode')
9✔
238

239
        # Keep track of return type
240
        self.return_magphase=kwargs.pop('return_magphase', False)
9✔
241
        if self.return_magphase not in (True, False):
9✔
242
            raise ValueError("unknown return_magphase value")
×
243
        self._return_singvals=kwargs.pop('_return_singvals', False)
9✔
244

245
        # Determine whether to squeeze the output
246
        self.squeeze=kwargs.pop('squeeze', None)
9✔
247
        if self.squeeze not in (None, True, False):
9✔
248
            raise ValueError("unknown squeeze value")
9✔
249

250
        # Process iosys keywords
251
        defaults = {
9✔
252
            'inputs': self.fresp.shape[1], 'outputs': self.fresp.shape[0]}
253
        if arg_dt is not None:
9✔
254
            defaults['dt'] = arg_dt             # choose compatible timebase
9✔
255
        name, inputs, outputs, states, dt = _process_iosys_keywords(
9✔
256
                kwargs, defaults, end=True)
257

258
        # Process signal names
259
        InputOutputSystem.__init__(
9✔
260
            self, name=name, inputs=inputs, outputs=outputs, dt=dt)
261

262
        # create interpolation functions
263
        if smooth:
9✔
264
            self.ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]),
9✔
265
                               dtype=tuple)
266
            for i in range(self.fresp.shape[0]):
9✔
267
                for j in range(self.fresp.shape[1]):
9✔
268
                    self.ifunc[i, j], u = splprep(
9✔
269
                        u=self.omega, x=[real(self.fresp[i, j, :]),
270
                                         imag(self.fresp[i, j, :])],
271
                        w=1.0/(absolute(self.fresp[i, j, :]) + 0.001), s=0.0)
272
        else:
273
            self.ifunc = None
9✔
274

275
    #
276
    # Frequency response properties
277
    #
278
    # Different properties of the frequency response that can be used for
279
    # analysis and characterization.
280
    #
281

282
    @property
9✔
283
    def magnitude(self):
9✔
284
        """Magnitude of the frequency response.
285

286
        Magnitude of the frequency response, indexed by either the output
287
        and frequency (if only a single input is given) or the output,
288
        input, and frequency (for multi-input systems).  See
289
        :attr:`FrequencyResponseData.squeeze` for a description of how this
290
        can be modified using the `squeeze` keyword.
291

292
        Input and output signal names can be used to index the data in
293
        place of integer offsets.
294

295
        :type: 1D, 2D, or 3D array
296

297
        """
298
        return NamedSignal(
9✔
299
            np.abs(self.fresp), self.output_labels, self.input_labels)
300

301
    @property
9✔
302
    def phase(self):
9✔
303
        """Phase of the frequency response.
304

305
        Phase of the frequency response in radians/sec, indexed by either
306
        the output and frequency (if only a single input is given) or the
307
        output, input, and frequency (for multi-input systems).  See
308
        :attr:`FrequencyResponseData.squeeze` for a description of how this
309
        can be modified using the `squeeze` keyword.
310

311
        Input and output signal names can be used to index the data in
312
        place of integer offsets.
313

314
        :type: 1D, 2D, or 3D array
315

316
        """
317
        return NamedSignal(
9✔
318
            np.angle(self.fresp), self.output_labels, self.input_labels)
319

320
    @property
9✔
321
    def frequency(self):
9✔
322
        """Frequencies at which the response is evaluated.
323

324
        :type: 1D array
325

326
        """
327
        return self.omega
×
328

329
    @property
9✔
330
    def response(self):
9✔
331
        """Complex value of the frequency response.
332

333
        Value of the frequency response as a complex number, indexed by
334
        either the output and frequency (if only a single input is given)
335
        or the output, input, and frequency (for multi-input systems).  See
336
        :attr:`FrequencyResponseData.squeeze` for a description of how this
337
        can be modified using the `squeeze` keyword.
338

339
        Input and output signal names can be used to index the data in
340
        place of integer offsets.
341

342
        :type: 1D, 2D, or 3D array
343

344
        """
345
        return NamedSignal(
9✔
346
            self.fresp, self.output_labels, self.input_labels)
347

348
    def __str__(self):
9✔
349

350
        """String representation of the transfer function."""
351

352
        mimo = self.ninputs > 1 or self.noutputs > 1
9✔
353
        outstr = [f"{InputOutputSystem.__str__(self)}"]
9✔
354

355
        for i in range(self.ninputs):
9✔
356
            for j in range(self.noutputs):
9✔
357
                if mimo:
9✔
358
                    outstr.append("Input %i to output %i:" % (i + 1, j + 1))
9✔
359
                outstr.append('Freq [rad/s]  Response')
9✔
360
                outstr.append('------------  ---------------------')
9✔
361
                outstr.extend(
9✔
362
                    ['%12.3f  %10.4g%+10.4gj' % (w, re, im)
363
                     for w, re, im in zip(self.omega,
364
                                          real(self.fresp[j, i, :]),
365
                                          imag(self.fresp[j, i, :]))])
366

367
        return '\n'.join(outstr)
9✔
368

369
    def __repr__(self):
9✔
370
        """Loadable string representation,
371

372
        limited for number of data points.
373
        """
374
        return "FrequencyResponseData({d}, {w}{smooth})".format(
9✔
375
            d=repr(self.fresp), w=repr(self.omega),
376
            smooth=(self.ifunc and ", smooth=True") or "")
377

378
    def __neg__(self):
9✔
379
        """Negate a transfer function."""
380

381
        return FRD(-self.fresp, self.omega)
9✔
382

383
    def __add__(self, other):
9✔
384
        """Add two LTI objects (parallel connection)."""
385

386
        if isinstance(other, FRD):
9✔
387
            # verify that the frequencies match
388
            if len(other.omega) != len(self.omega) or \
9✔
389
               (other.omega != self.omega).any():
390
                warn("Frequency points do not match; expect "
9✔
391
                     "truncation and interpolation.")
392

393
        # Convert the second argument to a frequency response function.
394
        # or re-base the frd to the current omega (if needed)
395
        other = _convert_to_frd(other, omega=self.omega)
9✔
396

397
        # Check that the input-output sizes are consistent.
398
        if self.ninputs != other.ninputs:
9✔
399
            raise ValueError(
9✔
400
                "The first summand has %i input(s), but the " \
401
                "second has %i." % (self.ninputs, other.ninputs))
402
        if self.noutputs != other.noutputs:
9✔
403
            raise ValueError(
9✔
404
                "The first summand has %i output(s), but the " \
405
                "second has %i." % (self.noutputs, other.noutputs))
406

407
        return FRD(self.fresp + other.fresp, other.omega)
9✔
408

409
    def __radd__(self, other):
9✔
410
        """Right add two LTI objects (parallel connection)."""
411

412
        return self + other
9✔
413

414
    def __sub__(self, other):
9✔
415
        """Subtract two LTI objects."""
416

417
        return self + (-other)
9✔
418

419
    def __rsub__(self, other):
9✔
420
        """Right subtract two LTI objects."""
421

422
        return other + (-self)
9✔
423

424
    def __mul__(self, other):
9✔
425
        """Multiply two LTI objects (serial connection)."""
426

427
        # Convert the second argument to a transfer function.
428
        if isinstance(other, (int, float, complex, np.number)):
9✔
429
            return FRD(self.fresp * other, self.omega,
9✔
430
                       smooth=(self.ifunc is not None))
431
        else:
432
            other = _convert_to_frd(other, omega=self.omega)
9✔
433

434
        # Check that the input-output sizes are consistent.
435
        if self.ninputs != other.noutputs:
9✔
436
            raise ValueError(
9✔
437
                "H = G1*G2: input-output size mismatch: "
438
                "G1 has %i input(s), G2 has %i output(s)." %
439
                (self.ninputs, other.noutputs))
440

441
        inputs = other.ninputs
9✔
442
        outputs = self.noutputs
9✔
443
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
444
                      dtype=self.fresp.dtype)
445
        for i in range(len(self.omega)):
9✔
446
            fresp[:, :, i] = self.fresp[:, :, i] @ other.fresp[:, :, i]
9✔
447
        return FRD(fresp, self.omega,
9✔
448
                   smooth=(self.ifunc is not None) and
449
                          (other.ifunc is not None))
450

451
    def __rmul__(self, other):
9✔
452
        """Right Multiply two LTI objects (serial connection)."""
453

454
        # Convert the second argument to an frd function.
455
        if isinstance(other, (int, float, complex, np.number)):
9✔
456
            return FRD(self.fresp * other, self.omega,
9✔
457
                       smooth=(self.ifunc is not None))
458
        else:
459
            other = _convert_to_frd(other, omega=self.omega)
9✔
460

461
        # Check that the input-output sizes are consistent.
462
        if self.noutputs != other.ninputs:
9✔
463
            raise ValueError(
9✔
464
                "H = G1*G2: input-output size mismatch: "
465
                "G1 has %i input(s), G2 has %i output(s)." %
466
                (other.ninputs, self.noutputs))
467

468
        inputs = self.ninputs
9✔
469
        outputs = other.noutputs
9✔
470

471
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
472
                      dtype=self.fresp.dtype)
473
        for i in range(len(self.omega)):
9✔
474
            fresp[:, :, i] = other.fresp[:, :, i] @ self.fresp[:, :, i]
9✔
475
        return FRD(fresp, self.omega,
9✔
476
                   smooth=(self.ifunc is not None) and
477
                          (other.ifunc is not None))
478

479
    # TODO: Division of MIMO transfer function objects is not written yet.
480
    def __truediv__(self, other):
9✔
481
        """Divide two LTI objects."""
482

483
        if isinstance(other, (int, float, complex, np.number)):
9✔
484
            return FRD(self.fresp * (1/other), self.omega,
9✔
485
                       smooth=(self.ifunc is not None))
486
        else:
487
            other = _convert_to_frd(other, omega=self.omega)
9✔
488

489
        if (self.ninputs > 1 or self.noutputs > 1 or
9✔
490
            other.ninputs > 1 or other.noutputs > 1):
491
            raise NotImplementedError(
492
                "FRD.__truediv__ is currently only implemented for SISO "
493
                "systems.")
494

495
        return FRD(self.fresp/other.fresp, self.omega,
9✔
496
                   smooth=(self.ifunc is not None) and
497
                          (other.ifunc is not None))
498

499
    # TODO: Division of MIMO transfer function objects is not written yet.
500
    def __rtruediv__(self, other):
9✔
501
        """Right divide two LTI objects."""
502
        if isinstance(other, (int, float, complex, np.number)):
9✔
503
            return FRD(other / self.fresp, self.omega,
9✔
504
                       smooth=(self.ifunc is not None))
505
        else:
506
            other = _convert_to_frd(other, omega=self.omega)
9✔
507

508
        if (self.ninputs > 1 or self.noutputs > 1 or
9✔
509
            other.ninputs > 1 or other.noutputs > 1):
510
            raise NotImplementedError(
511
                "FRD.__rtruediv__ is currently only implemented for "
512
                "SISO systems.")
513

514
        return other / self
9✔
515

516
    def __pow__(self, other):
9✔
517
        if not type(other) == int:
9✔
518
            raise ValueError("Exponent must be an integer")
9✔
519
        if other == 0:
9✔
520
            return FRD(ones(self.fresp.shape), self.omega,
9✔
521
                       smooth=(self.ifunc is not None))  # unity
522
        if other > 0:
9✔
523
            return self * (self**(other-1))
9✔
524
        if other < 0:
9✔
525
            return (FRD(ones(self.fresp.shape), self.omega) / self) * \
9✔
526
                (self**(other+1))
527

528
    # Define the `eval` function to evaluate an FRD at a given (real)
529
    # frequency.  Note that we choose to use `eval` instead of `evalfr` to
530
    # avoid confusion with :func:`evalfr`, which takes a complex number as its
531
    # argument.  Similarly, we don't use `__call__` to avoid confusion between
532
    # G(s) for a transfer function and G(omega) for an FRD object.
533
    # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform
534
    # interface to systems in general and the lti.frequency_response method
535
    def eval(self, omega, squeeze=None):
9✔
536
        """Evaluate a transfer function at angular frequency omega.
537

538
        Note that a "normal" FRD only returns values for which there is an
539
        entry in the omega vector. An interpolating FRD can return
540
        intermediate values.
541

542
        Parameters
543
        ----------
544
        omega : float or 1D array_like
545
            Frequencies in radians per second
546
        squeeze : bool, optional
547
            If squeeze=True, remove single-dimensional entries from the shape
548
            of the output even if the system is not SISO. If squeeze=False,
549
            keep all indices (output, input and, if omega is array_like,
550
            frequency) even if the system is SISO. The default value can be
551
            set using config.defaults['control.squeeze_frequency_response'].
552

553
        Returns
554
        -------
555
        fresp : complex ndarray
556
            The frequency response of the system.  If the system is SISO and
557
            squeeze is not True, the shape of the array matches the shape of
558
            omega.  If the system is not SISO or squeeze is False, the first
559
            two dimensions of the array are indices for the output and input
560
            and the remaining dimensions match omega.  If ``squeeze`` is True
561
            then single-dimensional axes are removed.
562

563
        """
564
        omega_array = np.array(omega, ndmin=1)  # array-like version of omega
9✔
565

566
        # Make sure that we are operating on a simple list
567
        if len(omega_array.shape) > 1:
9✔
568
            raise ValueError("input list must be 1D")
×
569

570
        # Make sure that frequencies are all real-valued
571
        if any(omega_array.imag > 0):
9✔
572
            raise ValueError("FRD.eval can only accept real-valued omega")
9✔
573

574
        if self.ifunc is None:
9✔
575
            elements = np.isin(self.omega, omega)  # binary array
9✔
576
            if sum(elements) < len(omega_array):
9✔
577
                raise ValueError(
9✔
578
                    "not all frequencies omega are in frequency list of FRD "
579
                    "system. Try an interpolating FRD for additional points.")
580
            else:
581
                out = self.fresp[:, :, elements]
9✔
582
        else:
583
            out = empty((self.noutputs, self.ninputs, len(omega_array)),
9✔
584
                        dtype=complex)
585
            for i in range(self.noutputs):
9✔
586
                for j in range(self.ninputs):
9✔
587
                    for k, w in enumerate(omega_array):
9✔
588
                        frraw = splev(w, self.ifunc[i, j], der=0)
9✔
589
                        out[i, j, k] = frraw[0] + 1.0j * frraw[1]
9✔
590

591
        return _process_frequency_response(self, omega, out, squeeze=squeeze)
9✔
592

593
    def __call__(self, s=None, squeeze=None, return_magphase=None):
9✔
594
        """Evaluate system's transfer function at complex frequencies.
595

596
        Returns the complex frequency response `sys(s)` of system `sys` with
597
        `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of
598
        outputs.
599

600
        To evaluate at a frequency omega in radians per second, enter
601
        ``s = omega * 1j`` or use ``sys.eval(omega)``
602

603
        For a frequency response data object, the argument must be an
604
        imaginary number (since only the frequency response is defined).
605

606
        If ``s`` is not given, this function creates a copy of a frequency
607
        response data object with a different set of output settings.
608

609
        Parameters
610
        ----------
611
        s : complex scalar or 1D array_like
612
            Complex frequencies.  If not specified, return a copy of the
613
            frequency response data object with updated settings for output
614
            processing (``squeeze``, ``return_magphase``).
615

616
        squeeze : bool, optional
617
            If squeeze=True, remove single-dimensional entries from the shape
618
            of the output even if the system is not SISO. If squeeze=False,
619
            keep all indices (output, input and, if omega is array_like,
620
            frequency) even if the system is SISO. The default value can be
621
            set using config.defaults['control.squeeze_frequency_response'].
622

623
        return_magphase : bool, optional
624
            If True, then a frequency response data object will enumerate as a
625
            tuple of the form (mag, phase, omega) where where ``mag`` is the
626
            magnitude (absolute value, not dB or log10) of the system
627
            frequency response, ``phase`` is the wrapped phase in radians of
628
            the system frequency response, and ``omega`` is the (sorted)
629
            frequencies at which the response was evaluated.
630

631
        Returns
632
        -------
633
        fresp : complex ndarray
634
            The frequency response of the system.  If the system is SISO and
635
            squeeze is not True, the shape of the array matches the shape of
636
            omega.  If the system is not SISO or squeeze is False, the first
637
            two dimensions of the array are indices for the output and input
638
            and the remaining dimensions match omega.  If ``squeeze`` is True
639
            then single-dimensional axes are removed.
640

641
        Raises
642
        ------
643
        ValueError
644
            If `s` is not purely imaginary, because
645
            :class:`FrequencyResponseData` systems are only defined at
646
            imaginary values (corresponding to real frequencies).
647

648
        """
649
        if s is None:
9✔
650
            # Create a copy of the response with new keywords
651
            response = copy(self)
9✔
652

653
            # Update any keywords that we were passed
654
            response.squeeze = self.squeeze if squeeze is None else squeeze
9✔
655
            response.return_magphase = self.return_magphase \
9✔
656
                if return_magphase is None else return_magphase
657

658
            return response
9✔
659

660
        # Make sure that we are operating on a simple list
661
        if len(np.atleast_1d(s).shape) > 1:
9✔
662
            raise ValueError("input list must be 1D")
9✔
663

664
        if any(abs(np.atleast_1d(s).real) > 0):
9✔
665
            raise ValueError("__call__: FRD systems can only accept "
9✔
666
                             "purely imaginary frequencies")
667

668
        # need to preserve array or scalar status
669
        if hasattr(s, '__len__'):
9✔
670
            return self.eval(np.asarray(s).imag, squeeze=squeeze)
9✔
671
        else:
672
            return self.eval(complex(s).imag, squeeze=squeeze)
9✔
673

674
    # Implement iter to allow assigning to a tuple
675
    def __iter__(self):
9✔
676
        fresp = _process_frequency_response(
9✔
677
            self, self.omega, self.fresp, squeeze=self.squeeze)
678
        if self._return_singvals:
9✔
679
            # Legacy processing for singular values
680
            return iter((self.fresp[:, 0, :], self.omega))
×
681
        elif not self.return_magphase:
9✔
682
            return iter((self.omega, fresp))
×
683
        return iter((np.abs(fresp), np.angle(fresp), self.omega))
9✔
684

685
    def __getitem__(self, key):
9✔
686
        if not isinstance(key, Iterable) or len(key) != 2:
9✔
687
            # Implement (thin) getitem to allow access via legacy indexing
688
            return list(self.__iter__())[key]
9✔
689

690
        # Convert signal names to integer offsets (via NamedSignal object)
691
        iomap = NamedSignal(
5✔
692
            self.fresp[:, :, 0], self.output_labels, self.input_labels)
693
        indices = iomap._parse_key(key, level=1)  # ignore index checks
5✔
694
        outdx, outputs = _process_subsys_index(indices[0], self.output_labels)
5✔
695
        inpdx, inputs = _process_subsys_index(indices[1], self.input_labels)
5✔
696

697
        # Create the system name
698
        sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
5✔
699
            self.name + config.defaults['iosys.indexed_system_name_suffix']
700

701
        return FrequencyResponseData(
5✔
702
            self.fresp[outdx, :][:, inpdx], self.omega, self.dt,
703
            inputs=inputs, outputs=outputs, name=sysname)
704

705
    # Implement (thin) len to emulate legacy testing interface
706
    def __len__(self):
9✔
707
        return 3 if self.return_magphase else 2
×
708

709
    def freqresp(self, omega):
9✔
710
        """(deprecated) Evaluate transfer function at complex frequencies.
711

712
        .. deprecated::0.9.0
713
            Method has been given the more pythonic name
714
            :meth:`FrequencyResponseData.frequency_response`. Or use
715
            :func:`freqresp` in the MATLAB compatibility module.
716

717
        """
718
        warn("FrequencyResponseData.freqresp(omega) will be removed in a "
9✔
719
             "future release of python-control; use "
720
             "FrequencyResponseData.frequency_response(omega), or "
721
             "freqresp(sys, omega) in the MATLAB compatibility module "
722
             "instead", FutureWarning)
723
        return self.frequency_response(omega)
9✔
724

725
    def feedback(self, other=1, sign=-1):
9✔
726
        """Feedback interconnection between two FRD objects."""
727

728
        other = _convert_to_frd(other, omega=self.omega)
9✔
729

730
        if (self.noutputs != other.ninputs or self.ninputs != other.noutputs):
9✔
731
            raise ValueError(
9✔
732
                "FRD.feedback, inputs/outputs mismatch")
733

734
        # TODO: handle omega re-mapping
735

736
        # reorder array axes in order to leverage numpy broadcasting
737
        myfresp = np.moveaxis(self.fresp, 2, 0)
9✔
738
        otherfresp = np.moveaxis(other.fresp, 2, 0)
9✔
739
        I_AB = eye(self.ninputs)[np.newaxis, :, :] + otherfresp @ myfresp
9✔
740
        resfresp = (myfresp @ linalg.inv(I_AB))
9✔
741
        fresp = np.moveaxis(resfresp, 0, 2)
9✔
742

743
        return FRD(fresp, other.omega, smooth=(self.ifunc is not None))
9✔
744

745
    # Plotting interface
746
    def plot(self, plot_type=None, *args, **kwargs):
9✔
747
        """Plot the frequency response using a Bode plot.
748

749
        Plot the frequency response using either a standard Bode plot
750
        (default) or using a singular values plot (by setting `plot_type`
751
        to 'svplot').  See :func:`~control.bode_plot` and
752
        :func:`~control.singular_values_plot` for more detailed
753
        descriptions.
754

755
        """
756
        from .freqplot import bode_plot, singular_values_plot
9✔
757
        from .nichols import nichols_plot
9✔
758

759
        if plot_type is None:
9✔
760
            plot_type = self.plot_type
9✔
761

762
        if plot_type == 'bode':
9✔
763
            return bode_plot(self, *args, **kwargs)
9✔
764
        elif plot_type == 'nichols':
9✔
765
            return nichols_plot(self, *args, **kwargs)
9✔
766
        elif plot_type == 'svplot':
9✔
767
            return singular_values_plot(self, *args, **kwargs)
9✔
768
        else:
769
            raise ValueError(f"unknown plot type '{plot_type}'")
×
770

771
    # Convert to pandas
772
    def to_pandas(self):
9✔
773
        """Convert response data to pandas data frame.
774

775
        Creates a pandas data frame for the value of the frequency
776
        response at each `omega`.  The frequency response values are
777
        labeled in the form "H_{<out>, <in>}" where "<out>" and "<in>"
778
        are replaced with the output and input labels for the system.
779

780
        """
781
        if not pandas_check():
1✔
782
            ImportError('pandas not installed')
×
783
        import pandas
1✔
784

785
        # Create a dict for setting up the data frame
786
        data = {'omega': self.omega}
1✔
787
        data.update(
1✔
788
            {'H_{%s, %s}' % (out, inp): self.fresp[i, j] \
789
             for i, out in enumerate(self.output_labels) \
790
             for j, inp in enumerate(self.input_labels)})
791

792
        return pandas.DataFrame(data)
1✔
793

794

795
#
796
# Allow FRD as an alias for the FrequencyResponseData class
797
#
798
# Note: This class was initially given the name "FRD", but this caused
799
# problems with documentation on MacOS platforms, since files were generated
800
# for control.frd and control.FRD, which are not differentiated on most MacOS
801
# filesystems, which are case insensitive.  Renaming the FRD class to be
802
# FrequenceResponseData and then assigning FRD to point to the same object
803
# fixes this problem.
804
#
805
FRD = FrequencyResponseData
9✔
806

807

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

811
    If sys is already an frd, and its frequency range matches or
812
    overlaps the range given in omega then it is returned.  If sys is
813
    another LTI object or a transfer function, then it is converted to
814
    a frequency response data at the specified omega. If sys is a
815
    scalar, then the number of inputs and outputs can be specified
816
    manually, as in:
817

818
    >>> import numpy as np
819
    >>> from control.frdata import _convert_to_frd
820

821
    >>> omega = np.logspace(-1, 1)
822
    >>> frd = _convert_to_frd(3., omega) # Assumes inputs = outputs = 1
823
    >>> frd.ninputs, frd.noutputs
824
    (1, 1)
825

826
    >>> frd = _convert_to_frd(1., omega, inputs=3, outputs=2)
827
    >>> frd.ninputs, frd.noutputs
828
    (3, 2)
829

830
    In the latter example, sys's matrix transfer function is [[1., 1., 1.]
831
                                                              [1., 1., 1.]].
832

833
    """
834

835
    if isinstance(sys, FRD):
9✔
836
        omega.sort()
9✔
837
        if len(omega) == len(sys.omega) and \
9✔
838
           (abs(omega - sys.omega) < FRD._epsw).all():
839
            # frequencies match, and system was already frd; simply use
840
            return sys
9✔
841

842
        raise NotImplementedError(
843
            "Frequency ranges of FRD do not match, conversion not implemented")
844

845
    elif isinstance(sys, LTI):
9✔
846
        omega = np.sort(omega)
9✔
847
        if sys.isctime():
9✔
848
            fresp = sys(1j * omega)
9✔
849
        else:
850
            fresp = sys(np.exp(1j * omega * sys.dt))
×
851
        if len(fresp.shape) == 1:
9✔
852
            fresp = fresp[np.newaxis, np.newaxis, :]
9✔
853
        return FRD(fresp, omega, smooth=True)
9✔
854

855
    elif isinstance(sys, (int, float, complex, np.number)):
9✔
856
        fresp = ones((outputs, inputs, len(omega)), dtype=float)*sys
9✔
857
        return FRD(fresp, omega, smooth=True)
9✔
858

859
    # try converting constant matrices
860
    try:
9✔
861
        sys = array(sys)
9✔
862
        outputs, inputs = sys.shape
9✔
863
        fresp = empty((outputs, inputs, len(omega)), dtype=float)
9✔
864
        for i in range(outputs):
9✔
865
            for j in range(inputs):
9✔
866
                fresp[i, j, :] = sys[i, j]
9✔
867
        return FRD(fresp, omega, smooth=True)
9✔
868
    except Exception:
9✔
869
        pass
9✔
870

871
    raise TypeError('''Can't convert given type "%s" to FRD system.''' %
9✔
872
                    sys.__class__)
873

874

875
def frd(*args, **kwargs):
9✔
876
    """frd(response, omega[, dt])
877

878
    Construct a frequency response data (FRD) model.
879

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

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

887
    ``frd(sys, omega)``
888
        Convert an LTI system into an frd model with data at frequencies
889
        ``omega``.
890

891
    Parameters
892
    ----------
893
    response : array_like or LTI system
894
        Complex vector with the system response or an LTI system that can
895
        be used to copmute the frequency response at a list of frequencies.
896
    omega : array_like
897
        Vector of frequencies at which the response is evaluated.
898
    dt : float, True, or None
899
        System timebase.
900
    smooth : bool, optional
901
        If ``True``, create an interpolation function that allows the
902
        frequency response to be computed at any frequency within the range
903
        of frequencies give in ``omega``.  If ``False`` (default),
904
        frequency response can only be obtained at the frequencies
905
        specified in ``omega``.
906

907
    Returns
908
    -------
909
    sys : :class:`FrequencyResponseData`
910
        New frequency response data system.
911

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

922
    See Also
923
    --------
924
    FrequencyResponseData, frequency_response, ss, tf
925

926
    Examples
927
    --------
928
    >>> # Create from measurements
929
    >>> response = [1.0, 1.0, 0.5]
930
    >>> omega = [1, 10, 100]
931
    >>> F = ct.frd(response, omega)
932

933
    >>> G = ct.tf([1], [1, 1])
934
    >>> omega = [1, 10, 100]
935
    >>> F = ct.frd(G, omega)
936

937
    """
938
    return FrequencyResponseData(*args, **kwargs)
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc