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

python-control / python-control / 12837529821

17 Jan 2025 10:01PM UTC coverage: 94.645% (-0.04%) from 94.687%
12837529821

Pull #1081

github

web-flow
Merge ccf9ce154 into 0ff045263
Pull Request #1081: Support for binary operations between MIMO and SISO LTI systems

9667 of 10214 relevant lines covered (94.64%)

8.26 hits per line

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

96.44
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
"""Frequency response data representation and functions.
7

8
This module contains the FrequencyResponseData (FRD) class and also
9
functions that operate on FRD data.
10

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 . import bdalg
9✔
24
from .exception import pandas_check
9✔
25
from .iosys import InputOutputSystem, NamedSignal, _extended_system_name, \
9✔
26
    _process_iosys_keywords, _process_subsys_index, common_timebase
27
from .lti import LTI, _process_frequency_response
9✔
28

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

31

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

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

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

42
    Parameters
43
    ----------
44
    response : 1D or 3D complex array_like
45
        The frequency response at each frequency point.  If 1D, the system is
46
        assumed to be SISO.  If 3D, the system is MIMO, with the first
47
        dimension corresponding to the output index of the FRD, the second
48
        dimension corresponding to the input index, and the 3rd dimension
49
        corresponding to the frequency points in omega
50
    omega : iterable of real frequencies
51
        List of frequency points for which data are available.
52
    smooth : bool, optional
53
        If ``True``, create an interpolation function that allows the
54
        frequency response to be computed at any frequency within the range of
55
        frequencies give in ``w``.  If ``False`` (default), frequency response
56
        can only be obtained at the frequencies specified in ``w``.
57
    dt : None, True or float, optional
58
        System timebase. 0 (default) indicates continuous time, True
59
        indicates discrete time with unspecified sampling time, positive
60
        number is discrete time with specified sampling time, None
61
        indicates unspecified timebase (either continuous or discrete time).
62
    squeeze : bool
63
        By default, if a system is single-input, single-output (SISO) then
64
        the outputs (and inputs) are returned as a 1D array (indexed by
65
        frequency) and if a system is multi-input or multi-output, then the
66
        outputs are returned as a 2D array (indexed by output and
67
        frequency) or a 3D array (indexed by output, trace, and frequency).
68
        If ``squeeze=True``, access to the output response will remove
69
        single-dimensional entries from the shape of the inputs and outputs
70
        even if the system is not SISO. If ``squeeze=False``, the output is
71
        returned as a 3D array (indexed by the output, input, and
72
        frequency) even if the system is SISO. The default value can be set
73
        using config.defaults['control.squeeze_frequency_response'].
74
    sysname : str or None
75
        Name of the system that generated the data.
76

77
    Attributes
78
    ----------
79
    fresp : 3D array
80
        Frequency response, indexed by output index, input index, and
81
        frequency point.
82
    frequency : 1D array
83
        Array of frequency points for which data are available.
84
    ninputs, noutputs : int
85
        Number of input and output signals.
86
    shape : tuple
87
        2-tuple of I/O system dimension, (noutputs, ninputs).
88
    input_labels, output_labels : array of str
89
        Names for the input and output signals.
90
    name : str
91
        System name.  For data generated using
92
        :func:`~control.frequency_response`, stores the name of the
93
        system that created the data.
94
    magnitude : array
95
        Magnitude of the frequency response, indexed by frequency.
96
    phase : array
97
        Phase of the frequency response, indexed by frequency.
98

99
    Other Parameters
100
    ----------------
101
    plot_type : str, optional
102
        Set the type of plot to generate with ``plot()`` ('bode', 'nichols').
103
    title : str, optional
104
        Set the title to use when plotting.
105
    plot_magnitude, plot_phase : bool, optional
106
        If set to `False`, don't plot the magnitude or phase, respectively.
107
    return_magphase : bool, optional
108
        If True, then a frequency response data object will enumerate as a
109
        tuple of the form (mag, phase, omega) where where ``mag`` is the
110
        magnitude (absolute value, not dB or log10) of the system
111
        frequency response, ``phase`` is the wrapped phase in radians of
112
        the system frequency response, and ``omega`` is the (sorted)
113
        frequencies at which the response was evaluated.
114

115
    See Also
116
    --------
117
    frd
118

119
    Notes
120
    -----
121
    The main data members are 'omega' and 'fresp', where 'omega' is a 1D array
122
    of frequency points and and 'fresp' is a 3D array of frequency responses,
123
    with the first dimension corresponding to the output index of the FRD, the
124
    second dimension corresponding to the input index, and the 3rd dimension
125
    corresponding to the frequency points in omega.  For example,
126

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

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

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

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

142
    Subsystem response corresponding to selected input/output pairs can be
143
    created by indexing the frequency response data object::
144

145
        subsys = sys[output_spec, input_spec]
146

147
    The input and output specifications can be single integers, lists of
148
    integers, or slices.  In addition, the strings representing the names
149
    of the signals can be used and will be replaced with the equivalent
150
    signal offsets.
151

152
    """
153
    #
154
    # Class attributes
155
    #
156
    # These attributes are defined as class attributes so that they are
157
    # documented properly.  They are "overwritten" in __init__.
158
    #
159

160
    #: Number of system inputs.
161
    #:
162
    #: :meta hide-value:
163
    ninputs = 1
9✔
164

165
    #: Number of system outputs.
166
    #:
167
    #: :meta hide-value:
168
    noutputs = 1
9✔
169

170
    _epsw = 1e-8                #: Bound for exact frequency match
9✔
171

172
    def __init__(self, *args, **kwargs):
9✔
173
        """FrequencyResponseData(d, w[, dt])
174

175
        Construct a frequency response data (FRD) object.
176

177
        The default constructor is FrequencyResponseData(d, w), where w is
178
        an iterable of frequency points, and d is the matching frequency
179
        data.  If d is a single list, 1D array, or tuple, a SISO system
180
        description is assumed. d can also be a 2D array, in which case a
181
        MIMO response is created.  To call the copy constructor, call
182
        FrequencyResponseData(sys), where sys is a FRD object.  The
183
        timebase for the frequency response can be provided using an
184
        optional third argument or the 'dt' keyword.
185

186
        To construct frequency response data for an existing LTI object,
187
        other than an FRD, call FrequencyResponseData(sys, omega).  This
188
        functionality can also be obtained using :func:`frequency_response`
189
        (which has additional options available).
190

191
        See :class:`FrequencyResponseData` and :func:`frd` for more
192
        information.
193

194
        """
195
        smooth = kwargs.pop('smooth', False)
9✔
196

197
        #
198
        # Process positional arguments
199
        #
200
        if len(args) == 3:
9✔
201
            # Discrete time transfer function
202
            dt = args[-1]
9✔
203
            if 'dt' in kwargs:
9✔
204
                warn("received multiple dt arguments, "
9✔
205
                     "using positional arg dt = %s" % dt)
206
            kwargs['dt'] = dt
9✔
207
            args = args[:-1]
9✔
208

209
        if len(args) == 2:
9✔
210
            if not isinstance(args[0], FRD) and isinstance(args[0], LTI):
9✔
211
                # not an FRD, but still an LTI system, second argument
212
                # should be the frequency range
213
                otherlti = args[0]
9✔
214
                self.omega = sort(np.asarray(args[1], dtype=float))
9✔
215

216
                # calculate frequency response at specified points
217
                if otherlti.isctime():
9✔
218
                    s = 1j * self.omega
9✔
219
                    self.fresp = otherlti(s, squeeze=False)
9✔
220
                else:
221
                    z = np.exp(1j * self.omega * otherlti.dt)
9✔
222
                    self.fresp = otherlti(z, squeeze=False)
9✔
223
                arg_dt = otherlti.dt
9✔
224

225
                # Copy over signal and system names, if not specified
226
                kwargs['inputs'] = kwargs.get('inputs', otherlti.input_labels)
9✔
227
                kwargs['outputs'] = kwargs.get(
9✔
228
                    'outputs', otherlti.output_labels)
229
                if not otherlti._generic_name_check():
9✔
230
                    kwargs['name'] = kwargs.get('name', _extended_system_name(
9✔
231
                        otherlti.name, prefix_suffix_name='sampled'))
232

233
            else:
234
                # The user provided a response and a freq vector
235
                self.fresp = array(args[0], dtype=complex, ndmin=1)
9✔
236
                if self.fresp.ndim == 1:
9✔
237
                    self.fresp = self.fresp.reshape(1, 1, -1)
9✔
238
                self.omega = array(args[1], dtype=float, ndmin=1)
9✔
239
                if self.fresp.ndim != 3 or self.omega.ndim != 1 or \
9✔
240
                        self.fresp.shape[-1] != self.omega.shape[-1]:
241
                    raise TypeError(
9✔
242
                        "The frequency data constructor needs a 1-d or 3-d"
243
                        " response data array and a matching frequency vector"
244
                        " size")
245
                arg_dt = None
9✔
246

247
        elif len(args) == 1:
9✔
248
            # Use the copy constructor.
249
            if not isinstance(args[0], FRD):
9✔
250
                raise TypeError(
9✔
251
                    "The one-argument constructor can only take in"
252
                    " an FRD object.  Received %s." % type(args[0]))
253
            self.omega = args[0].omega
9✔
254
            self.fresp = args[0].fresp
9✔
255
            arg_dt = args[0].dt
9✔
256

257
            # Copy over signal and system names, if not specified
258
            kwargs['inputs'] = kwargs.get('inputs', args[0].input_labels)
9✔
259
            kwargs['outputs'] = kwargs.get('outputs', args[0].output_labels)
9✔
260

261
        else:
262
            raise ValueError(
9✔
263
                "Needs 1 or 2 arguments; received %i." % len(args))
264

265
        #
266
        # Process keyword arguments
267
        #
268

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

273
        # Keep track of default properties for plotting
274
        self.plot_phase = kwargs.pop('plot_phase', None)
9✔
275
        self.title = kwargs.pop('title', None)
9✔
276
        self.plot_type = kwargs.pop('plot_type', 'bode')
9✔
277

278
        # Keep track of return type
279
        self.return_magphase=kwargs.pop('return_magphase', False)
9✔
280
        if self.return_magphase not in (True, False):
9✔
281
            raise ValueError("unknown return_magphase value")
×
282
        self._return_singvals=kwargs.pop('_return_singvals', False)
9✔
283

284
        # Determine whether to squeeze the output
285
        self.squeeze=kwargs.pop('squeeze', None)
9✔
286
        if self.squeeze not in (None, True, False):
9✔
287
            raise ValueError("unknown squeeze value")
9✔
288

289
        defaults = {
9✔
290
            'inputs': self.fresp.shape[1] if not getattr(
291
                self, 'input_index', None) else self.input_labels,
292
            'outputs': self.fresp.shape[0] if not getattr(
293
                self, 'output_index', None) else self.output_labels,
294
            'name': getattr(self, 'name', None)}
295
        if arg_dt is not None:
9✔
296
            if isinstance(args[0], LTI):
9✔
297
                arg_dt = common_timebase(args[0].dt, arg_dt)
9✔
298
            kwargs['dt'] = arg_dt
9✔
299

300
        # Process signal names
301
        name, inputs, outputs, states, dt = _process_iosys_keywords(
9✔
302
                kwargs, defaults)
303
        InputOutputSystem.__init__(
9✔
304
            self, name=name, inputs=inputs, outputs=outputs, dt=dt, **kwargs)
305

306
        # create interpolation functions
307
        if smooth:
9✔
308
            # Set the order of the fit
309
            if self.omega.size < 2:
9✔
310
                raise ValueError("can't smooth with only 1 frequency")
×
311
            degree = 3 if self.omega.size > 3 else self.omega.size - 1
9✔
312

313
            self._ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]),
9✔
314
                               dtype=tuple)
315
            for i in range(self.fresp.shape[0]):
9✔
316
                for j in range(self.fresp.shape[1]):
9✔
317
                    self._ifunc[i, j], u = splprep(
9✔
318
                        u=self.omega, x=[real(self.fresp[i, j, :]),
319
                                         imag(self.fresp[i, j, :])],
320
                        w=1.0/(absolute(self.fresp[i, j, :]) + 0.001),
321
                        s=0.0, k=degree)
322
        else:
323
            self._ifunc = None
9✔
324

325
    #
326
    # Frequency response properties
327
    #
328
    # Different properties of the frequency response that can be used for
329
    # analysis and characterization.
330
    #
331

332
    @property
9✔
333
    def magnitude(self):
9✔
334
        """Magnitude of the frequency response.
335

336
        Magnitude of the frequency response, indexed by either the output
337
        and frequency (if only a single input is given) or the output,
338
        input, and frequency (for multi-input systems).  See
339
        :attr:`FrequencyResponseData.squeeze` for a description of how this
340
        can be modified using the `squeeze` keyword.
341

342
        Input and output signal names can be used to index the data in
343
        place of integer offsets.
344

345
        :type: 1D, 2D, or 3D array
346

347
        """
348
        return NamedSignal(
9✔
349
            np.abs(self.fresp), self.output_labels, self.input_labels)
350

351
    @property
9✔
352
    def phase(self):
9✔
353
        """Phase of the frequency response.
354

355
        Phase of the frequency response in radians/sec, indexed by either
356
        the output and frequency (if only a single input is given) or the
357
        output, input, and frequency (for multi-input systems).  See
358
        :attr:`FrequencyResponseData.squeeze` for a description of how this
359
        can be modified using the `squeeze` keyword.
360

361
        Input and output signal names can be used to index the data in
362
        place of integer offsets.
363

364
        :type: 1D, 2D, or 3D array
365

366
        """
367
        return NamedSignal(
9✔
368
            np.angle(self.fresp), self.output_labels, self.input_labels)
369

370
    @property
9✔
371
    def frequency(self):
9✔
372
        """Frequencies at which the response is evaluated.
373

374
        :type: 1D array
375

376
        """
377
        return self.omega
9✔
378

379
    @property
9✔
380
    def response(self):
9✔
381
        """Complex value of the frequency response.
382

383
        Value of the frequency response as a complex number, indexed by
384
        either the output and frequency (if only a single input is given)
385
        or the output, input, and frequency (for multi-input systems).  See
386
        :attr:`FrequencyResponseData.squeeze` for a description of how this
387
        can be modified using the `squeeze` keyword.
388

389
        Input and output signal names can be used to index the data in
390
        place of integer offsets.
391

392
        :type: 1D, 2D, or 3D array
393

394
        """
395
        return NamedSignal(
9✔
396
            self.fresp, self.output_labels, self.input_labels)
397

398
    def __str__(self):
9✔
399

400
        """String representation of the transfer function."""
401

402
        mimo = self.ninputs > 1 or self.noutputs > 1
9✔
403
        outstr = [f"{InputOutputSystem.__str__(self)}"]
9✔
404
        nl = "\n  " if mimo else "\n"
9✔
405
        sp = "  " if mimo else ""
9✔
406

407
        for i in range(self.ninputs):
9✔
408
            for j in range(self.noutputs):
9✔
409
                if mimo:
9✔
410
                    outstr.append(
9✔
411
                        "\nInput %i to output %i:" % (i + 1, j + 1))
412
                outstr.append(nl + 'Freq [rad/s]  Response')
9✔
413
                outstr.append(sp + '------------  ---------------------')
9✔
414
                outstr.extend(
9✔
415
                    [sp + '%12.3f  %10.4g%+10.4gj' % (w, re, im)
416
                     for w, re, im in zip(self.omega,
417
                                          real(self.fresp[j, i, :]),
418
                                          imag(self.fresp[j, i, :]))])
419

420
        return '\n'.join(outstr)
9✔
421

422
    def _repr_eval_(self):
9✔
423
        # Loadable format
424
        out = "FrequencyResponseData(\n{d},\n{w}{smooth}".format(
9✔
425
            d=repr(self.fresp), w=repr(self.omega),
426
            smooth=(self._ifunc and ", smooth=True") or "")
427

428
        out += self._dt_repr()
9✔
429
        if len(labels := self._label_repr()) > 0:
9✔
430
            out += ",\n" + labels
9✔
431

432
        out += ")"
9✔
433
        return out
9✔
434

435
    def __neg__(self):
9✔
436
        """Negate a transfer function."""
437

438
        return FRD(-self.fresp, self.omega)
9✔
439

440
    def __add__(self, other):
9✔
441
        """Add two LTI objects (parallel connection)."""
442

443
        if isinstance(other, FRD):
9✔
444
            # verify that the frequencies match
445
            if len(other.omega) != len(self.omega) or \
9✔
446
               (other.omega != self.omega).any():
447
                warn("Frequency points do not match; expect "
9✔
448
                     "truncation and interpolation.")
449

450
        # Convert the second argument to a frequency response function.
451
        # or re-base the frd to the current omega (if needed)
452
        if isinstance(other, (int, float, complex, np.number)):
9✔
453
            other = _convert_to_frd(
9✔
454
                other, omega=self.omega,
455
                inputs=self.ninputs, outputs=self.noutputs)
456
        else:
457
            other = _convert_to_frd(other, omega=self.omega)
9✔
458

459
        # Promote SISO object to compatible dimension
460
        if self.issiso() and not other.issiso():
9✔
461
            self = np.ones((other.noutputs, other.ninputs)) * self
9✔
462
        elif not self.issiso() and other.issiso():
9✔
463
            other = np.ones((self.noutputs, self.ninputs)) * other
9✔
464

465
        # Check that the input-output sizes are consistent.
466
        if self.ninputs != other.ninputs:
9✔
467
            raise ValueError(
9✔
468
                "The first summand has %i input(s), but the " \
469
                "second has %i." % (self.ninputs, other.ninputs))
470
        if self.noutputs != other.noutputs:
9✔
471
            raise ValueError(
9✔
472
                "The first summand has %i output(s), but the " \
473
                "second has %i." % (self.noutputs, other.noutputs))
474

475
        return FRD(self.fresp + other.fresp, other.omega)
9✔
476

477
    def __radd__(self, other):
9✔
478
        """Right add two LTI objects (parallel connection)."""
479

480
        return self + other
9✔
481

482
    def __sub__(self, other):
9✔
483
        """Subtract two LTI objects."""
484

485
        return self + (-other)
9✔
486

487
    def __rsub__(self, other):
9✔
488
        """Right subtract two LTI objects."""
489

490
        return other + (-self)
9✔
491

492
    def __mul__(self, other):
9✔
493
        """Multiply two LTI objects (serial connection)."""
494

495
        # Convert the second argument to a transfer function.
496
        if isinstance(other, (int, float, complex, np.number)):
9✔
497
            return FRD(self.fresp * other, self.omega,
9✔
498
                       smooth=(self._ifunc is not None))
499
        else:
500
            other = _convert_to_frd(other, omega=self.omega)
9✔
501

502
        # Promote SISO object to compatible dimension
503
        if self.issiso() and not other.issiso():
9✔
504
            self = bdalg.append(*([self] * other.noutputs))
9✔
505
        elif not self.issiso() and other.issiso():
9✔
506
            other = bdalg.append(*([other] * self.ninputs))
9✔
507

508
        # Check that the input-output sizes are consistent.
509
        if self.ninputs != other.noutputs:
9✔
510
            raise ValueError(
9✔
511
                "H = G1*G2: input-output size mismatch: "
512
                "G1 has %i input(s), G2 has %i output(s)." %
513
                (self.ninputs, other.noutputs))
514

515
        inputs = other.ninputs
9✔
516
        outputs = self.noutputs
9✔
517
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
518
                      dtype=self.fresp.dtype)
519
        for i in range(len(self.omega)):
9✔
520
            fresp[:, :, i] = self.fresp[:, :, i] @ other.fresp[:, :, i]
9✔
521
        return FRD(fresp, self.omega,
9✔
522
                   smooth=(self._ifunc is not None) and
523
                          (other._ifunc is not None))
524

525
    def __rmul__(self, other):
9✔
526
        """Right Multiply two LTI objects (serial connection)."""
527

528
        # Convert the second argument to an frd function.
529
        if isinstance(other, (int, float, complex, np.number)):
9✔
530
            return FRD(self.fresp * other, self.omega,
9✔
531
                       smooth=(self._ifunc is not None))
532
        else:
533
            other = _convert_to_frd(other, omega=self.omega)
9✔
534

535
        # Promote SISO object to compatible dimension
536
        if self.issiso() and not other.issiso():
9✔
537
            self = bdalg.append(*([self] * other.ninputs))
9✔
538
        elif not self.issiso() and other.issiso():
9✔
539
            other = bdalg.append(*([other] * self.noutputs))
9✔
540

541
        # Check that the input-output sizes are consistent.
542
        if self.noutputs != other.ninputs:
9✔
543
            raise ValueError(
9✔
544
                "H = G1*G2: input-output size mismatch: "
545
                "G1 has %i input(s), G2 has %i output(s)." %
546
                (other.ninputs, self.noutputs))
547

548
        inputs = self.ninputs
9✔
549
        outputs = other.noutputs
9✔
550

551
        fresp = empty((outputs, inputs, len(self.omega)),
9✔
552
                      dtype=self.fresp.dtype)
553
        for i in range(len(self.omega)):
9✔
554
            fresp[:, :, i] = other.fresp[:, :, i] @ self.fresp[:, :, i]
9✔
555
        return FRD(fresp, self.omega,
9✔
556
                   smooth=(self._ifunc is not None) and
557
                          (other._ifunc is not None))
558

559
    # TODO: Division of MIMO transfer function objects is not written yet.
560
    def __truediv__(self, other):
9✔
561
        """Divide two LTI objects."""
562

563
        if isinstance(other, (int, float, complex, np.number)):
9✔
564
            return FRD(self.fresp * (1/other), self.omega,
9✔
565
                       smooth=(self._ifunc is not None))
566
        else:
567
            other = _convert_to_frd(other, omega=self.omega)
9✔
568

569
        if (other.ninputs > 1 or other.noutputs > 1):
9✔
570
            # FRD.__truediv__ is currently only implemented for SISO systems
571
            return NotImplemented
×
572

573
        return FRD(self.fresp/other.fresp, self.omega,
9✔
574
                   smooth=(self._ifunc is not None) and
575
                          (other._ifunc is not None))
576

577
    # TODO: Division of MIMO transfer function objects is not written yet.
578
    def __rtruediv__(self, other):
9✔
579
        """Right divide two LTI objects."""
580
        if isinstance(other, (int, float, complex, np.number)):
9✔
581
            return FRD(other / self.fresp, self.omega,
9✔
582
                       smooth=(self._ifunc is not None))
583
        else:
584
            other = _convert_to_frd(other, omega=self.omega)
9✔
585

586
        if (self.ninputs > 1 or self.noutputs > 1):
9✔
587
            # FRD.__rtruediv__ is currently only implemented for SISO systems
588
            return NotImplemented
×
589

590
        return other / self
9✔
591

592
    def __pow__(self, other):
9✔
593
        if not type(other) == int:
9✔
594
            raise ValueError("Exponent must be an integer")
9✔
595
        if other == 0:
9✔
596
            return FRD(ones(self.fresp.shape), self.omega,
9✔
597
                       smooth=(self._ifunc is not None))  # unity
598
        if other > 0:
9✔
599
            return self * (self**(other-1))
9✔
600
        if other < 0:
9✔
601
            return (FRD(ones(self.fresp.shape), self.omega) / self) * \
9✔
602
                (self**(other+1))
603

604
    # Define the `eval` function to evaluate an FRD at a given (real)
605
    # frequency.  Note that we choose to use `eval` instead of `evalfr` to
606
    # avoid confusion with :func:`evalfr`, which takes a complex number as its
607
    # argument.  Similarly, we don't use `__call__` to avoid confusion between
608
    # G(s) for a transfer function and G(omega) for an FRD object.
609
    # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform
610
    # interface to systems in general and the lti.frequency_response method
611
    def eval(self, omega, squeeze=None):
9✔
612
        """Evaluate a transfer function at angular frequency omega.
613

614
        Note that a "normal" FRD only returns values for which there is an
615
        entry in the omega vector. An interpolating FRD can return
616
        intermediate values.
617

618
        Parameters
619
        ----------
620
        omega : float or 1D array_like
621
            Frequencies in radians per second
622
        squeeze : bool, optional
623
            If squeeze=True, remove single-dimensional entries from the shape
624
            of the output even if the system is not SISO. If squeeze=False,
625
            keep all indices (output, input and, if omega is array_like,
626
            frequency) even if the system is SISO. The default value can be
627
            set using config.defaults['control.squeeze_frequency_response'].
628

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

639
        """
640
        omega_array = np.array(omega, ndmin=1)  # array-like version of omega
9✔
641

642
        # Make sure that we are operating on a simple list
643
        if len(omega_array.shape) > 1:
9✔
644
            raise ValueError("input list must be 1D")
×
645

646
        # Make sure that frequencies are all real-valued
647
        if any(omega_array.imag > 0):
9✔
648
            raise ValueError("FRD.eval can only accept real-valued omega")
9✔
649

650
        if self._ifunc is None:
9✔
651
            elements = np.isin(self.omega, omega)  # binary array
9✔
652
            if sum(elements) < len(omega_array):
9✔
653
                raise ValueError(
9✔
654
                    "not all frequencies omega are in frequency list of FRD "
655
                    "system. Try an interpolating FRD for additional points.")
656
            else:
657
                out = self.fresp[:, :, elements]
9✔
658
        else:
659
            out = empty((self.noutputs, self.ninputs, len(omega_array)),
9✔
660
                        dtype=complex)
661
            for i in range(self.noutputs):
9✔
662
                for j in range(self.ninputs):
9✔
663
                    for k, w in enumerate(omega_array):
9✔
664
                        frraw = splev(w, self._ifunc[i, j], der=0)
9✔
665
                        out[i, j, k] = frraw[0] + 1.0j * frraw[1]
9✔
666

667
        return _process_frequency_response(self, omega, out, squeeze=squeeze)
9✔
668

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

672
        Returns the complex frequency response `sys(s)` of system `sys` with
673
        `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of
674
        outputs.
675

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

679
        For a frequency response data object, the argument must be an
680
        imaginary number (since only the frequency response is defined).
681

682
        If ``s`` is not given, this function creates a copy of a frequency
683
        response data object with a different set of output settings.
684

685
        Parameters
686
        ----------
687
        s : complex scalar or 1D array_like
688
            Complex frequencies.  If not specified, return a copy of the
689
            frequency response data object with updated settings for output
690
            processing (``squeeze``, ``return_magphase``).
691

692
        squeeze : bool, optional
693
            If squeeze=True, remove single-dimensional entries from the shape
694
            of the output even if the system is not SISO. If squeeze=False,
695
            keep all indices (output, input and, if omega is array_like,
696
            frequency) even if the system is SISO. The default value can be
697
            set using config.defaults['control.squeeze_frequency_response'].
698

699
        return_magphase : bool, optional
700
            If True, then a frequency response data object will enumerate as a
701
            tuple of the form (mag, phase, omega) where where ``mag`` is the
702
            magnitude (absolute value, not dB or log10) of the system
703
            frequency response, ``phase`` is the wrapped phase in radians of
704
            the system frequency response, and ``omega`` is the (sorted)
705
            frequencies at which the response was evaluated.
706

707
        Returns
708
        -------
709
        fresp : complex ndarray
710
            The frequency response of the system.  If the system is SISO and
711
            squeeze is not True, the shape of the array matches the shape of
712
            omega.  If the system is not SISO or squeeze is False, the first
713
            two dimensions of the array are indices for the output and input
714
            and the remaining dimensions match omega.  If ``squeeze`` is True
715
            then single-dimensional axes are removed.
716

717
        Raises
718
        ------
719
        ValueError
720
            If `s` is not purely imaginary, because
721
            :class:`FrequencyResponseData` systems are only defined at
722
            imaginary values (corresponding to real frequencies).
723

724
        """
725
        if s is None:
9✔
726
            # Create a copy of the response with new keywords
727
            response = copy(self)
9✔
728

729
            # Update any keywords that we were passed
730
            response.squeeze = self.squeeze if squeeze is None else squeeze
9✔
731
            response.return_magphase = self.return_magphase \
9✔
732
                if return_magphase is None else return_magphase
733

734
            return response
9✔
735

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

740
        if any(abs(np.atleast_1d(s).real) > 0):
9✔
741
            raise ValueError("__call__: FRD systems can only accept "
9✔
742
                             "purely imaginary frequencies")
743

744
        # need to preserve array or scalar status
745
        if hasattr(s, '__len__'):
9✔
746
            return self.eval(np.asarray(s).imag, squeeze=squeeze)
9✔
747
        else:
748
            return self.eval(complex(s).imag, squeeze=squeeze)
9✔
749

750
    # Implement iter to allow assigning to a tuple
751
    def __iter__(self):
9✔
752
        fresp = _process_frequency_response(
9✔
753
            self, self.omega, self.fresp, squeeze=self.squeeze)
754
        if self._return_singvals:
9✔
755
            # Legacy processing for singular values
756
            return iter((self.fresp[:, 0, :], self.omega))
×
757
        elif not self.return_magphase:
9✔
758
            return iter((self.omega, fresp))
×
759
        return iter((np.abs(fresp), np.angle(fresp), self.omega))
9✔
760

761
    def __getitem__(self, key):
9✔
762
        if not isinstance(key, Iterable) or len(key) != 2:
9✔
763
            # Implement (thin) getitem to allow access via legacy indexing
764
            return list(self.__iter__())[key]
9✔
765

766
        # Convert signal names to integer offsets (via NamedSignal object)
767
        iomap = NamedSignal(
5✔
768
            self.fresp[:, :, 0], self.output_labels, self.input_labels)
769
        indices = iomap._parse_key(key, level=1)  # ignore index checks
5✔
770
        outdx, outputs = _process_subsys_index(indices[0], self.output_labels)
5✔
771
        inpdx, inputs = _process_subsys_index(indices[1], self.input_labels)
5✔
772

773
        # Create the system name
774
        sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
5✔
775
            self.name + config.defaults['iosys.indexed_system_name_suffix']
776

777
        return FrequencyResponseData(
5✔
778
            self.fresp[outdx, :][:, inpdx], self.omega, self.dt,
779
            inputs=inputs, outputs=outputs, name=sysname)
780

781
    # Implement (thin) len to emulate legacy testing interface
782
    def __len__(self):
9✔
783
        return 3 if self.return_magphase else 2
×
784

785
    def freqresp(self, omega):
9✔
786
        """(deprecated) Evaluate transfer function at complex frequencies.
787

788
        .. deprecated::0.9.0
789
            Method has been given the more pythonic name
790
            :meth:`FrequencyResponseData.frequency_response`. Or use
791
            :func:`freqresp` in the MATLAB compatibility module.
792

793
        """
794
        warn("FrequencyResponseData.freqresp(omega) will be removed in a "
9✔
795
             "future release of python-control; use "
796
             "FrequencyResponseData.frequency_response(omega), or "
797
             "freqresp(sys, omega) in the MATLAB compatibility module "
798
             "instead", FutureWarning)
799
        return self.frequency_response(omega)
9✔
800

801
    def feedback(self, other=1, sign=-1):
9✔
802
        """Feedback interconnection between two FRD objects."""
803

804
        other = _convert_to_frd(other, omega=self.omega)
9✔
805

806
        if (self.noutputs != other.ninputs or self.ninputs != other.noutputs):
9✔
807
            raise ValueError(
9✔
808
                "FRD.feedback, inputs/outputs mismatch")
809

810
        # TODO: handle omega re-mapping
811

812
        # reorder array axes in order to leverage numpy broadcasting
813
        myfresp = np.moveaxis(self.fresp, 2, 0)
9✔
814
        otherfresp = np.moveaxis(other.fresp, 2, 0)
9✔
815
        I_AB = eye(self.ninputs)[np.newaxis, :, :] + otherfresp @ myfresp
9✔
816
        resfresp = (myfresp @ linalg.inv(I_AB))
9✔
817
        fresp = np.moveaxis(resfresp, 0, 2)
9✔
818

819
        return FRD(fresp, other.omega, smooth=(self._ifunc is not None))
9✔
820

821
    def append(self, other):
9✔
822
        """Append a second model to the present model.
823

824
        The second model is converted to FRD if necessary, inputs and
825
        outputs are appended and their order is preserved"""
826
        other = _convert_to_frd(other, omega=self.omega, inputs=other.ninputs,
9✔
827
                                outputs=other.noutputs)
828

829
        # TODO: handle omega re-mapping
830

831
        new_fresp = np.zeros(
9✔
832
            (self.noutputs + other.noutputs, self.ninputs + other.ninputs,
833
             self.omega.shape[-1]), dtype=complex)
834
        new_fresp[:self.noutputs, :self.ninputs, :] = np.reshape(
9✔
835
            self.fresp, (self.noutputs, self.ninputs, -1))
836
        new_fresp[self.noutputs:, self.ninputs:, :] = np.reshape(
9✔
837
            other.fresp, (other.noutputs, other.ninputs, -1))
838

839
        return FRD(new_fresp, self.omega, smooth=(self._ifunc is not None))
9✔
840

841
    # Plotting interface
842
    def plot(self, plot_type=None, *args, **kwargs):
9✔
843
        """Plot the frequency response using a Bode plot.
844

845
        Plot the frequency response using either a standard Bode plot
846
        (default) or using a singular values plot (by setting `plot_type`
847
        to 'svplot').  See :func:`~control.bode_plot` and
848
        :func:`~control.singular_values_plot` for more detailed
849
        descriptions.
850

851
        """
852
        from .freqplot import bode_plot, singular_values_plot
9✔
853
        from .nichols import nichols_plot
9✔
854

855
        if plot_type is None:
9✔
856
            plot_type = self.plot_type
9✔
857

858
        if plot_type == 'bode':
9✔
859
            return bode_plot(self, *args, **kwargs)
9✔
860
        elif plot_type == 'nichols':
9✔
861
            return nichols_plot(self, *args, **kwargs)
9✔
862
        elif plot_type == 'svplot':
9✔
863
            return singular_values_plot(self, *args, **kwargs)
9✔
864
        else:
865
            raise ValueError(f"unknown plot type '{plot_type}'")
×
866

867
    # Convert to pandas
868
    def to_pandas(self):
9✔
869
        """Convert response data to pandas data frame.
870

871
        Creates a pandas data frame for the value of the frequency
872
        response at each `omega`.  The frequency response values are
873
        labeled in the form "H_{<out>, <in>}" where "<out>" and "<in>"
874
        are replaced with the output and input labels for the system.
875

876
        """
877
        if not pandas_check():
1✔
878
            ImportError('pandas not installed')
×
879
        import pandas
1✔
880

881
        # Create a dict for setting up the data frame
882
        data = {'omega': self.omega}
1✔
883
        data.update(
1✔
884
            {'H_{%s, %s}' % (out, inp): self.fresp[i, j] \
885
             for i, out in enumerate(self.output_labels) \
886
             for j, inp in enumerate(self.input_labels)})
887

888
        return pandas.DataFrame(data)
1✔
889

890

891
#
892
# Allow FRD as an alias for the FrequencyResponseData class
893
#
894
# Note: This class was initially given the name "FRD", but this caused
895
# problems with documentation on MacOS platforms, since files were generated
896
# for control.frd and control.FRD, which are not differentiated on most MacOS
897
# filesystems, which are case insensitive.  Renaming the FRD class to be
898
# FrequenceResponseData and then assigning FRD to point to the same object
899
# fixes this problem.
900
#
901
FRD = FrequencyResponseData
9✔
902

903

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

907
    If sys is already an frd, and its frequency range matches or
908
    overlaps the range given in omega then it is returned.  If sys is
909
    another LTI object or a transfer function, then it is converted to
910
    a frequency response data at the specified omega. If sys is a
911
    scalar, then the number of inputs and outputs can be specified
912
    manually, as in:
913

914
    >>> import numpy as np
915
    >>> from control.frdata import _convert_to_frd
916

917
    >>> omega = np.logspace(-1, 1)
918
    >>> frd = _convert_to_frd(3., omega) # Assumes inputs = outputs = 1
919
    >>> frd.ninputs, frd.noutputs
920
    (1, 1)
921

922
    >>> frd = _convert_to_frd(1., omega, inputs=3, outputs=2)
923
    >>> frd.ninputs, frd.noutputs
924
    (3, 2)
925

926
    In the latter example, sys's matrix transfer function is [[1., 1., 1.]
927
                                                              [1., 1., 1.]].
928

929
    """
930

931
    if isinstance(sys, FRD):
9✔
932
        omega.sort()
9✔
933
        if len(omega) == len(sys.omega) and \
9✔
934
           (abs(omega - sys.omega) < FRD._epsw).all():
935
            # frequencies match, and system was already frd; simply use
936
            return sys
9✔
937

938
        raise NotImplementedError(
939
            "Frequency ranges of FRD do not match, conversion not implemented")
940

941
    elif isinstance(sys, LTI):
9✔
942
        omega = np.sort(omega)
9✔
943
        if sys.isctime():
9✔
944
            fresp = sys(1j * omega)
9✔
945
        else:
946
            fresp = sys(np.exp(1j * omega * sys.dt))
×
947
        if len(fresp.shape) == 1:
9✔
948
            fresp = fresp[np.newaxis, np.newaxis, :]
9✔
949
        return FRD(fresp, omega, smooth=True)
9✔
950

951
    elif isinstance(sys, (int, float, complex, np.number)):
9✔
952
        fresp = ones((outputs, inputs, len(omega)), dtype=float)*sys
9✔
953
        return FRD(fresp, omega, smooth=True)
9✔
954

955
    # try converting constant matrices
956
    try:
9✔
957
        sys = array(sys)
9✔
958
        outputs, inputs = sys.shape
9✔
959
        fresp = empty((outputs, inputs, len(omega)), dtype=float)
9✔
960
        for i in range(outputs):
9✔
961
            for j in range(inputs):
9✔
962
                fresp[i, j, :] = sys[i, j]
9✔
963
        return FRD(fresp, omega, smooth=True)
9✔
964
    except Exception:
9✔
965
        pass
9✔
966

967
    raise TypeError('''Can't convert given type "%s" to FRD system.''' %
9✔
968
                    sys.__class__)
969

970

971
def frd(*args, **kwargs):
9✔
972
    """frd(response, omega[, dt])
973

974
    Construct a frequency response data (FRD) model.
975

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

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

983
    ``frd(sys, omega)``
984
        Convert an LTI system into an frd model with data at frequencies
985
        ``omega``.
986

987
    Parameters
988
    ----------
989
    sys : LTI (StateSpace or TransferFunction)
990
        A linear system that will be evaluated for frequency response data.
991
    response : array_like or LTI system
992
        Complex vector with the system response or an LTI system that can
993
        be used to copmute the frequency response at a list of frequencies.
994
    omega : array_like
995
        Vector of frequencies at which the response is evaluated.
996
    dt : float, True, or None
997
        System timebase.
998
    smooth : bool, optional
999
        If ``True``, create an interpolation function that allows the
1000
        frequency response to be computed at any frequency within the range
1001
        of frequencies give in ``omega``.  If ``False`` (default),
1002
        frequency response can only be obtained at the frequencies
1003
        specified in ``omega``.
1004

1005
    Returns
1006
    -------
1007
    sys : FrequencyResponseData
1008
        New frequency response data system.
1009

1010
    Other Parameters
1011
    ----------------
1012
    inputs, outputs : str, or list of str, optional
1013
        List of strings that name the individual signals of the transformed
1014
        system.  If not given, the inputs and outputs are the same as the
1015
        original system.
1016
    input_prefix, output_prefix : string, optional
1017
        Set the prefix for input and output signals.  Defaults = 'u', 'y'.
1018
    name : string, optional
1019
        System name. If unspecified, a generic name <sys[id]> is generated
1020
        with a unique integer id.
1021

1022
    See Also
1023
    --------
1024
    FrequencyResponseData, frequency_response, ss, tf
1025

1026
    Examples
1027
    --------
1028
    >>> # Create from measurements
1029
    >>> response = [1.0, 1.0, 0.5]
1030
    >>> omega = [1, 10, 100]
1031
    >>> F = ct.frd(response, omega)
1032

1033
    >>> G = ct.tf([1], [1, 1])
1034
    >>> omega = [1, 10, 100]
1035
    >>> F = ct.frd(G, omega)
1036

1037
    """
1038
    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