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

python-control / python-control / 12266904606

11 Dec 2024 12:03AM UTC coverage: 94.73% (+0.009%) from 94.721%
12266904606

Pull #1078

github

web-flow
Merge 0b9be7a84 into e2c0ff85f
Pull Request #1078: fix issue with multiplying MIMO LTI system by scalar

9330 of 9849 relevant lines covered (94.73%)

8.27 hits per line

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

96.34
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
            # Set the order of the fit
265
            if self.omega.size < 2:
9✔
266
                raise ValueError("can't smooth with only 1 frequency")
×
267
            degree = 3 if self.omega.size > 3 else self.omega.size - 1
9✔
268

269
            self.ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]),
9✔
270
                               dtype=tuple)
271
            for i in range(self.fresp.shape[0]):
9✔
272
                for j in range(self.fresp.shape[1]):
9✔
273
                    self.ifunc[i, j], u = splprep(
9✔
274
                        u=self.omega, x=[real(self.fresp[i, j, :]),
275
                                         imag(self.fresp[i, j, :])],
276
                        w=1.0/(absolute(self.fresp[i, j, :]) + 0.001),
277
                        s=0.0, k=degree)
278
        else:
279
            self.ifunc = None
9✔
280

281
    #
282
    # Frequency response properties
283
    #
284
    # Different properties of the frequency response that can be used for
285
    # analysis and characterization.
286
    #
287

288
    @property
9✔
289
    def magnitude(self):
9✔
290
        """Magnitude of the frequency response.
291

292
        Magnitude of the frequency response, indexed by either the output
293
        and frequency (if only a single input is given) or the output,
294
        input, and frequency (for multi-input systems).  See
295
        :attr:`FrequencyResponseData.squeeze` for a description of how this
296
        can be modified using the `squeeze` keyword.
297

298
        Input and output signal names can be used to index the data in
299
        place of integer offsets.
300

301
        :type: 1D, 2D, or 3D array
302

303
        """
304
        return NamedSignal(
9✔
305
            np.abs(self.fresp), self.output_labels, self.input_labels)
306

307
    @property
9✔
308
    def phase(self):
9✔
309
        """Phase of the frequency response.
310

311
        Phase of the frequency response in radians/sec, indexed by either
312
        the output and frequency (if only a single input is given) or the
313
        output, input, and frequency (for multi-input systems).  See
314
        :attr:`FrequencyResponseData.squeeze` for a description of how this
315
        can be modified using the `squeeze` keyword.
316

317
        Input and output signal names can be used to index the data in
318
        place of integer offsets.
319

320
        :type: 1D, 2D, or 3D array
321

322
        """
323
        return NamedSignal(
9✔
324
            np.angle(self.fresp), self.output_labels, self.input_labels)
325

326
    @property
9✔
327
    def frequency(self):
9✔
328
        """Frequencies at which the response is evaluated.
329

330
        :type: 1D array
331

332
        """
333
        return self.omega
×
334

335
    @property
9✔
336
    def response(self):
9✔
337
        """Complex value of the frequency response.
338

339
        Value of the frequency response as a complex number, indexed by
340
        either the output and frequency (if only a single input is given)
341
        or the output, input, and frequency (for multi-input systems).  See
342
        :attr:`FrequencyResponseData.squeeze` for a description of how this
343
        can be modified using the `squeeze` keyword.
344

345
        Input and output signal names can be used to index the data in
346
        place of integer offsets.
347

348
        :type: 1D, 2D, or 3D array
349

350
        """
351
        return NamedSignal(
9✔
352
            self.fresp, self.output_labels, self.input_labels)
353

354
    def __str__(self):
9✔
355

356
        """String representation of the transfer function."""
357

358
        mimo = self.ninputs > 1 or self.noutputs > 1
9✔
359
        outstr = [f"{InputOutputSystem.__str__(self)}"]
9✔
360

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

373
        return '\n'.join(outstr)
9✔
374

375
    def __repr__(self):
9✔
376
        """Loadable string representation,
377

378
        limited for number of data points.
379
        """
380
        return "FrequencyResponseData({d}, {w}{smooth})".format(
9✔
381
            d=repr(self.fresp), w=repr(self.omega),
382
            smooth=(self.ifunc and ", smooth=True") or "")
383

384
    def __neg__(self):
9✔
385
        """Negate a transfer function."""
386

387
        return FRD(-self.fresp, self.omega)
9✔
388

389
    def __add__(self, other):
9✔
390
        """Add two LTI objects (parallel connection)."""
391

392
        if isinstance(other, FRD):
9✔
393
            # verify that the frequencies match
394
            if len(other.omega) != len(self.omega) or \
9✔
395
               (other.omega != self.omega).any():
396
                warn("Frequency points do not match; expect "
9✔
397
                     "truncation and interpolation.")
398

399
        # Convert the second argument to a frequency response function.
400
        # or re-base the frd to the current omega (if needed)
401
        if isinstance(other, (int, float, complex, np.number)):
9✔
402
            other = _convert_to_frd(
9✔
403
                other, omega=self.omega,
404
                inputs=self.ninputs, outputs=self.noutputs)
405
        else:
406
            other = _convert_to_frd(other, omega=self.omega)
9✔
407

408
        # Check that the input-output sizes are consistent.
409
        if self.ninputs != other.ninputs:
9✔
410
            raise ValueError(
9✔
411
                "The first summand has %i input(s), but the " \
412
                "second has %i." % (self.ninputs, other.ninputs))
413
        if self.noutputs != other.noutputs:
9✔
414
            raise ValueError(
9✔
415
                "The first summand has %i output(s), but the " \
416
                "second has %i." % (self.noutputs, other.noutputs))
417

418
        return FRD(self.fresp + other.fresp, other.omega)
9✔
419

420
    def __radd__(self, other):
9✔
421
        """Right add two LTI objects (parallel connection)."""
422

423
        return self + other
9✔
424

425
    def __sub__(self, other):
9✔
426
        """Subtract two LTI objects."""
427

428
        return self + (-other)
9✔
429

430
    def __rsub__(self, other):
9✔
431
        """Right subtract two LTI objects."""
432

433
        return other + (-self)
9✔
434

435
    def __mul__(self, other):
9✔
436
        """Multiply two LTI objects (serial connection)."""
437

438
        # Convert the second argument to a transfer function.
439
        if isinstance(other, (int, float, complex, np.number)):
9✔
440
            return FRD(self.fresp * other, self.omega,
9✔
441
                       smooth=(self.ifunc is not None))
442
        else:
443
            other = _convert_to_frd(other, omega=self.omega)
9✔
444

445
        # Check that the input-output sizes are consistent.
446
        if self.ninputs != other.noutputs:
9✔
447
            raise ValueError(
9✔
448
                "H = G1*G2: input-output size mismatch: "
449
                "G1 has %i input(s), G2 has %i output(s)." %
450
                (self.ninputs, other.noutputs))
451

452
        inputs = other.ninputs
9✔
453
        outputs = self.noutputs
9✔
454
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
455
                      dtype=self.fresp.dtype)
456
        for i in range(len(self.omega)):
9✔
457
            fresp[:, :, i] = self.fresp[:, :, i] @ other.fresp[:, :, i]
9✔
458
        return FRD(fresp, self.omega,
9✔
459
                   smooth=(self.ifunc is not None) and
460
                          (other.ifunc is not None))
461

462
    def __rmul__(self, other):
9✔
463
        """Right Multiply two LTI objects (serial connection)."""
464

465
        # Convert the second argument to an frd function.
466
        if isinstance(other, (int, float, complex, np.number)):
9✔
467
            return FRD(self.fresp * other, self.omega,
9✔
468
                       smooth=(self.ifunc is not None))
469
        else:
470
            other = _convert_to_frd(other, omega=self.omega)
9✔
471

472
        # Check that the input-output sizes are consistent.
473
        if self.noutputs != other.ninputs:
9✔
474
            raise ValueError(
9✔
475
                "H = G1*G2: input-output size mismatch: "
476
                "G1 has %i input(s), G2 has %i output(s)." %
477
                (other.ninputs, self.noutputs))
478

479
        inputs = self.ninputs
9✔
480
        outputs = other.noutputs
9✔
481

482
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
483
                      dtype=self.fresp.dtype)
484
        for i in range(len(self.omega)):
9✔
485
            fresp[:, :, i] = other.fresp[:, :, i] @ self.fresp[:, :, i]
9✔
486
        return FRD(fresp, self.omega,
9✔
487
                   smooth=(self.ifunc is not None) and
488
                          (other.ifunc is not None))
489

490
    # TODO: Division of MIMO transfer function objects is not written yet.
491
    def __truediv__(self, other):
9✔
492
        """Divide two LTI objects."""
493

494
        if isinstance(other, (int, float, complex, np.number)):
9✔
495
            return FRD(self.fresp * (1/other), self.omega,
9✔
496
                       smooth=(self.ifunc is not None))
497
        else:
498
            other = _convert_to_frd(other, omega=self.omega)
9✔
499

500
        if (self.ninputs > 1 or self.noutputs > 1 or
9✔
501
            other.ninputs > 1 or other.noutputs > 1):
502
            raise NotImplementedError(
503
                "FRD.__truediv__ is currently only implemented for SISO "
504
                "systems.")
505

506
        return FRD(self.fresp/other.fresp, self.omega,
9✔
507
                   smooth=(self.ifunc is not None) and
508
                          (other.ifunc is not None))
509

510
    # TODO: Division of MIMO transfer function objects is not written yet.
511
    def __rtruediv__(self, other):
9✔
512
        """Right divide two LTI objects."""
513
        if isinstance(other, (int, float, complex, np.number)):
9✔
514
            return FRD(other / self.fresp, self.omega,
9✔
515
                       smooth=(self.ifunc is not None))
516
        else:
517
            other = _convert_to_frd(other, omega=self.omega)
9✔
518

519
        if (self.ninputs > 1 or self.noutputs > 1 or
9✔
520
            other.ninputs > 1 or other.noutputs > 1):
521
            raise NotImplementedError(
522
                "FRD.__rtruediv__ is currently only implemented for "
523
                "SISO systems.")
524

525
        return other / self
9✔
526

527
    def __pow__(self, other):
9✔
528
        if not type(other) == int:
9✔
529
            raise ValueError("Exponent must be an integer")
9✔
530
        if other == 0:
9✔
531
            return FRD(ones(self.fresp.shape), self.omega,
9✔
532
                       smooth=(self.ifunc is not None))  # unity
533
        if other > 0:
9✔
534
            return self * (self**(other-1))
9✔
535
        if other < 0:
9✔
536
            return (FRD(ones(self.fresp.shape), self.omega) / self) * \
9✔
537
                (self**(other+1))
538

539
    # Define the `eval` function to evaluate an FRD at a given (real)
540
    # frequency.  Note that we choose to use `eval` instead of `evalfr` to
541
    # avoid confusion with :func:`evalfr`, which takes a complex number as its
542
    # argument.  Similarly, we don't use `__call__` to avoid confusion between
543
    # G(s) for a transfer function and G(omega) for an FRD object.
544
    # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform
545
    # interface to systems in general and the lti.frequency_response method
546
    def eval(self, omega, squeeze=None):
9✔
547
        """Evaluate a transfer function at angular frequency omega.
548

549
        Note that a "normal" FRD only returns values for which there is an
550
        entry in the omega vector. An interpolating FRD can return
551
        intermediate values.
552

553
        Parameters
554
        ----------
555
        omega : float or 1D array_like
556
            Frequencies in radians per second
557
        squeeze : bool, optional
558
            If squeeze=True, remove single-dimensional entries from the shape
559
            of the output even if the system is not SISO. If squeeze=False,
560
            keep all indices (output, input and, if omega is array_like,
561
            frequency) even if the system is SISO. The default value can be
562
            set using config.defaults['control.squeeze_frequency_response'].
563

564
        Returns
565
        -------
566
        fresp : complex ndarray
567
            The frequency response of the system.  If the system is SISO and
568
            squeeze is not True, the shape of the array matches the shape of
569
            omega.  If the system is not SISO or squeeze is False, the first
570
            two dimensions of the array are indices for the output and input
571
            and the remaining dimensions match omega.  If ``squeeze`` is True
572
            then single-dimensional axes are removed.
573

574
        """
575
        omega_array = np.array(omega, ndmin=1)  # array-like version of omega
9✔
576

577
        # Make sure that we are operating on a simple list
578
        if len(omega_array.shape) > 1:
9✔
579
            raise ValueError("input list must be 1D")
×
580

581
        # Make sure that frequencies are all real-valued
582
        if any(omega_array.imag > 0):
9✔
583
            raise ValueError("FRD.eval can only accept real-valued omega")
9✔
584

585
        if self.ifunc is None:
9✔
586
            elements = np.isin(self.omega, omega)  # binary array
9✔
587
            if sum(elements) < len(omega_array):
9✔
588
                raise ValueError(
9✔
589
                    "not all frequencies omega are in frequency list of FRD "
590
                    "system. Try an interpolating FRD for additional points.")
591
            else:
592
                out = self.fresp[:, :, elements]
9✔
593
        else:
594
            out = empty((self.noutputs, self.ninputs, len(omega_array)),
9✔
595
                        dtype=complex)
596
            for i in range(self.noutputs):
9✔
597
                for j in range(self.ninputs):
9✔
598
                    for k, w in enumerate(omega_array):
9✔
599
                        frraw = splev(w, self.ifunc[i, j], der=0)
9✔
600
                        out[i, j, k] = frraw[0] + 1.0j * frraw[1]
9✔
601

602
        return _process_frequency_response(self, omega, out, squeeze=squeeze)
9✔
603

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

607
        Returns the complex frequency response `sys(s)` of system `sys` with
608
        `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of
609
        outputs.
610

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

614
        For a frequency response data object, the argument must be an
615
        imaginary number (since only the frequency response is defined).
616

617
        If ``s`` is not given, this function creates a copy of a frequency
618
        response data object with a different set of output settings.
619

620
        Parameters
621
        ----------
622
        s : complex scalar or 1D array_like
623
            Complex frequencies.  If not specified, return a copy of the
624
            frequency response data object with updated settings for output
625
            processing (``squeeze``, ``return_magphase``).
626

627
        squeeze : bool, optional
628
            If squeeze=True, remove single-dimensional entries from the shape
629
            of the output even if the system is not SISO. If squeeze=False,
630
            keep all indices (output, input and, if omega is array_like,
631
            frequency) even if the system is SISO. The default value can be
632
            set using config.defaults['control.squeeze_frequency_response'].
633

634
        return_magphase : bool, optional
635
            If True, then a frequency response data object will enumerate as a
636
            tuple of the form (mag, phase, omega) where where ``mag`` is the
637
            magnitude (absolute value, not dB or log10) of the system
638
            frequency response, ``phase`` is the wrapped phase in radians of
639
            the system frequency response, and ``omega`` is the (sorted)
640
            frequencies at which the response was evaluated.
641

642
        Returns
643
        -------
644
        fresp : complex ndarray
645
            The frequency response of the system.  If the system is SISO and
646
            squeeze is not True, the shape of the array matches the shape of
647
            omega.  If the system is not SISO or squeeze is False, the first
648
            two dimensions of the array are indices for the output and input
649
            and the remaining dimensions match omega.  If ``squeeze`` is True
650
            then single-dimensional axes are removed.
651

652
        Raises
653
        ------
654
        ValueError
655
            If `s` is not purely imaginary, because
656
            :class:`FrequencyResponseData` systems are only defined at
657
            imaginary values (corresponding to real frequencies).
658

659
        """
660
        if s is None:
9✔
661
            # Create a copy of the response with new keywords
662
            response = copy(self)
9✔
663

664
            # Update any keywords that we were passed
665
            response.squeeze = self.squeeze if squeeze is None else squeeze
9✔
666
            response.return_magphase = self.return_magphase \
9✔
667
                if return_magphase is None else return_magphase
668

669
            return response
9✔
670

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

675
        if any(abs(np.atleast_1d(s).real) > 0):
9✔
676
            raise ValueError("__call__: FRD systems can only accept "
9✔
677
                             "purely imaginary frequencies")
678

679
        # need to preserve array or scalar status
680
        if hasattr(s, '__len__'):
9✔
681
            return self.eval(np.asarray(s).imag, squeeze=squeeze)
9✔
682
        else:
683
            return self.eval(complex(s).imag, squeeze=squeeze)
9✔
684

685
    # Implement iter to allow assigning to a tuple
686
    def __iter__(self):
9✔
687
        fresp = _process_frequency_response(
9✔
688
            self, self.omega, self.fresp, squeeze=self.squeeze)
689
        if self._return_singvals:
9✔
690
            # Legacy processing for singular values
691
            return iter((self.fresp[:, 0, :], self.omega))
×
692
        elif not self.return_magphase:
9✔
693
            return iter((self.omega, fresp))
×
694
        return iter((np.abs(fresp), np.angle(fresp), self.omega))
9✔
695

696
    def __getitem__(self, key):
9✔
697
        if not isinstance(key, Iterable) or len(key) != 2:
9✔
698
            # Implement (thin) getitem to allow access via legacy indexing
699
            return list(self.__iter__())[key]
9✔
700

701
        # Convert signal names to integer offsets (via NamedSignal object)
702
        iomap = NamedSignal(
5✔
703
            self.fresp[:, :, 0], self.output_labels, self.input_labels)
704
        indices = iomap._parse_key(key, level=1)  # ignore index checks
5✔
705
        outdx, outputs = _process_subsys_index(indices[0], self.output_labels)
5✔
706
        inpdx, inputs = _process_subsys_index(indices[1], self.input_labels)
5✔
707

708
        # Create the system name
709
        sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
5✔
710
            self.name + config.defaults['iosys.indexed_system_name_suffix']
711

712
        return FrequencyResponseData(
5✔
713
            self.fresp[outdx, :][:, inpdx], self.omega, self.dt,
714
            inputs=inputs, outputs=outputs, name=sysname)
715

716
    # Implement (thin) len to emulate legacy testing interface
717
    def __len__(self):
9✔
718
        return 3 if self.return_magphase else 2
×
719

720
    def freqresp(self, omega):
9✔
721
        """(deprecated) Evaluate transfer function at complex frequencies.
722

723
        .. deprecated::0.9.0
724
            Method has been given the more pythonic name
725
            :meth:`FrequencyResponseData.frequency_response`. Or use
726
            :func:`freqresp` in the MATLAB compatibility module.
727

728
        """
729
        warn("FrequencyResponseData.freqresp(omega) will be removed in a "
9✔
730
             "future release of python-control; use "
731
             "FrequencyResponseData.frequency_response(omega), or "
732
             "freqresp(sys, omega) in the MATLAB compatibility module "
733
             "instead", FutureWarning)
734
        return self.frequency_response(omega)
9✔
735

736
    def feedback(self, other=1, sign=-1):
9✔
737
        """Feedback interconnection between two FRD objects."""
738

739
        other = _convert_to_frd(other, omega=self.omega)
9✔
740

741
        if (self.noutputs != other.ninputs or self.ninputs != other.noutputs):
9✔
742
            raise ValueError(
9✔
743
                "FRD.feedback, inputs/outputs mismatch")
744

745
        # TODO: handle omega re-mapping
746

747
        # reorder array axes in order to leverage numpy broadcasting
748
        myfresp = np.moveaxis(self.fresp, 2, 0)
9✔
749
        otherfresp = np.moveaxis(other.fresp, 2, 0)
9✔
750
        I_AB = eye(self.ninputs)[np.newaxis, :, :] + otherfresp @ myfresp
9✔
751
        resfresp = (myfresp @ linalg.inv(I_AB))
9✔
752
        fresp = np.moveaxis(resfresp, 0, 2)
9✔
753

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

756
    # Plotting interface
757
    def plot(self, plot_type=None, *args, **kwargs):
9✔
758
        """Plot the frequency response using a Bode plot.
759

760
        Plot the frequency response using either a standard Bode plot
761
        (default) or using a singular values plot (by setting `plot_type`
762
        to 'svplot').  See :func:`~control.bode_plot` and
763
        :func:`~control.singular_values_plot` for more detailed
764
        descriptions.
765

766
        """
767
        from .freqplot import bode_plot, singular_values_plot
9✔
768
        from .nichols import nichols_plot
9✔
769

770
        if plot_type is None:
9✔
771
            plot_type = self.plot_type
9✔
772

773
        if plot_type == 'bode':
9✔
774
            return bode_plot(self, *args, **kwargs)
9✔
775
        elif plot_type == 'nichols':
9✔
776
            return nichols_plot(self, *args, **kwargs)
9✔
777
        elif plot_type == 'svplot':
9✔
778
            return singular_values_plot(self, *args, **kwargs)
9✔
779
        else:
780
            raise ValueError(f"unknown plot type '{plot_type}'")
×
781

782
    # Convert to pandas
783
    def to_pandas(self):
9✔
784
        """Convert response data to pandas data frame.
785

786
        Creates a pandas data frame for the value of the frequency
787
        response at each `omega`.  The frequency response values are
788
        labeled in the form "H_{<out>, <in>}" where "<out>" and "<in>"
789
        are replaced with the output and input labels for the system.
790

791
        """
792
        if not pandas_check():
1✔
793
            ImportError('pandas not installed')
×
794
        import pandas
1✔
795

796
        # Create a dict for setting up the data frame
797
        data = {'omega': self.omega}
1✔
798
        data.update(
1✔
799
            {'H_{%s, %s}' % (out, inp): self.fresp[i, j] \
800
             for i, out in enumerate(self.output_labels) \
801
             for j, inp in enumerate(self.input_labels)})
802

803
        return pandas.DataFrame(data)
1✔
804

805

806
#
807
# Allow FRD as an alias for the FrequencyResponseData class
808
#
809
# Note: This class was initially given the name "FRD", but this caused
810
# problems with documentation on MacOS platforms, since files were generated
811
# for control.frd and control.FRD, which are not differentiated on most MacOS
812
# filesystems, which are case insensitive.  Renaming the FRD class to be
813
# FrequenceResponseData and then assigning FRD to point to the same object
814
# fixes this problem.
815
#
816
FRD = FrequencyResponseData
9✔
817

818

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

822
    If sys is already an frd, and its frequency range matches or
823
    overlaps the range given in omega then it is returned.  If sys is
824
    another LTI object or a transfer function, then it is converted to
825
    a frequency response data at the specified omega. If sys is a
826
    scalar, then the number of inputs and outputs can be specified
827
    manually, as in:
828

829
    >>> import numpy as np
830
    >>> from control.frdata import _convert_to_frd
831

832
    >>> omega = np.logspace(-1, 1)
833
    >>> frd = _convert_to_frd(3., omega) # Assumes inputs = outputs = 1
834
    >>> frd.ninputs, frd.noutputs
835
    (1, 1)
836

837
    >>> frd = _convert_to_frd(1., omega, inputs=3, outputs=2)
838
    >>> frd.ninputs, frd.noutputs
839
    (3, 2)
840

841
    In the latter example, sys's matrix transfer function is [[1., 1., 1.]
842
                                                              [1., 1., 1.]].
843

844
    """
845

846
    if isinstance(sys, FRD):
9✔
847
        omega.sort()
9✔
848
        if len(omega) == len(sys.omega) and \
9✔
849
           (abs(omega - sys.omega) < FRD._epsw).all():
850
            # frequencies match, and system was already frd; simply use
851
            return sys
9✔
852

853
        raise NotImplementedError(
854
            "Frequency ranges of FRD do not match, conversion not implemented")
855

856
    elif isinstance(sys, LTI):
9✔
857
        omega = np.sort(omega)
9✔
858
        if sys.isctime():
9✔
859
            fresp = sys(1j * omega)
9✔
860
        else:
861
            fresp = sys(np.exp(1j * omega * sys.dt))
×
862
        if len(fresp.shape) == 1:
9✔
863
            fresp = fresp[np.newaxis, np.newaxis, :]
9✔
864
        return FRD(fresp, omega, smooth=True)
9✔
865

866
    elif isinstance(sys, (int, float, complex, np.number)):
9✔
867
        fresp = ones((outputs, inputs, len(omega)), dtype=float)*sys
9✔
868
        return FRD(fresp, omega, smooth=True)
9✔
869

870
    # try converting constant matrices
871
    try:
9✔
872
        sys = array(sys)
9✔
873
        outputs, inputs = sys.shape
9✔
874
        fresp = empty((outputs, inputs, len(omega)), dtype=float)
9✔
875
        for i in range(outputs):
9✔
876
            for j in range(inputs):
9✔
877
                fresp[i, j, :] = sys[i, j]
9✔
878
        return FRD(fresp, omega, smooth=True)
9✔
879
    except Exception:
9✔
880
        pass
9✔
881

882
    raise TypeError('''Can't convert given type "%s" to FRD system.''' %
9✔
883
                    sys.__class__)
884

885

886
def frd(*args, **kwargs):
9✔
887
    """frd(response, omega[, dt])
888

889
    Construct a frequency response data (FRD) model.
890

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

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

898
    ``frd(sys, omega)``
899
        Convert an LTI system into an frd model with data at frequencies
900
        ``omega``.
901

902
    Parameters
903
    ----------
904
    response : array_like or LTI system
905
        Complex vector with the system response or an LTI system that can
906
        be used to copmute the frequency response at a list of frequencies.
907
    omega : array_like
908
        Vector of frequencies at which the response is evaluated.
909
    dt : float, True, or None
910
        System timebase.
911
    smooth : bool, optional
912
        If ``True``, create an interpolation function that allows the
913
        frequency response to be computed at any frequency within the range
914
        of frequencies give in ``omega``.  If ``False`` (default),
915
        frequency response can only be obtained at the frequencies
916
        specified in ``omega``.
917

918
    Returns
919
    -------
920
    sys : :class:`FrequencyResponseData`
921
        New frequency response data system.
922

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

933
    See Also
934
    --------
935
    FrequencyResponseData, frequency_response, ss, tf
936

937
    Examples
938
    --------
939
    >>> # Create from measurements
940
    >>> response = [1.0, 1.0, 0.5]
941
    >>> omega = [1, 10, 100]
942
    >>> F = ct.frd(response, omega)
943

944
    >>> G = ct.tf([1], [1, 1])
945
    >>> omega = [1, 10, 100]
946
    >>> F = ct.frd(G, omega)
947

948
    """
949
    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