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

python-control / python-control / 16081041622

04 Jul 2025 09:18PM UTC coverage: 94.733% (-0.01%) from 94.745%
16081041622

push

github

web-flow
Merge pull request #1155 from murrayrm/fix_nyquist_rescaling-24Mar2025

Update Nyquist rescaling + other improvements

9946 of 10499 relevant lines covered (94.73%)

8.28 hits per line

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

97.99
control/descfcn.py
1
# descfcn.py - describing function analysis
2
# RMM, 23 Jan 2021
3

4
"""This module contains functions for performing closed loop analysis of
5
systems with memoryless nonlinearities using describing function analysis.
6

7
"""
8

9
import math
9✔
10
from warnings import warn
9✔
11

12
import numpy as np
9✔
13
import scipy
9✔
14

15
from . import config
9✔
16
from .ctrlplot import ControlPlot
9✔
17
from .freqplot import nyquist_response
9✔
18

19
__all__ = ['describing_function', 'describing_function_plot',
9✔
20
           'describing_function_response', 'DescribingFunctionResponse',
21
           'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity',
22
           'relay_hysteresis_nonlinearity', 'saturation_nonlinearity']
23

24
# Class for nonlinearities with a built-in describing function
25
class DescribingFunctionNonlinearity():
9✔
26
    """Base class for nonlinear systems with a describing function.
27

28
    This class is intended to be used as a base class for nonlinear functions
29
    that have an analytically defined describing function.  Subclasses should
30
    override the `__call__` and `describing_function` methods and (optionally)
31
    the `_isstatic` method (should be False if `__call__` updates the
32
    instance state).
33

34
    """
35
    def __init__(self):
9✔
36
        """Initialize a describing function nonlinearity (optional)."""
37
        pass
9✔
38

39
    def __call__(self, A):
9✔
40
        """Evaluate the nonlinearity at a (scalar) input value."""
41
        raise NotImplementedError(
42
            "__call__() not implemented for this function (internal error)")
43

44
    def describing_function(self, A):
9✔
45
        """Return the describing function for a nonlinearity.
46

47
        This method is used to allow analytical representations of the
48
        describing function for a nonlinearity.  It turns the (complex) value
49
        of the describing function for sinusoidal input of amplitude `A`.
50

51
        Parameters
52
        ----------
53
        A : float
54
            Amplitude of the sinusoidal input to the nonlinearity.
55

56
        Returns
57
        -------
58
        float
59
            Value of the describing function at the given amplitude.
60

61
        """
62
        raise NotImplementedError(
63
            "describing function not implemented for this function")
64

65
    def _isstatic(self):
9✔
66
        """Return True if the function has no internal state (memoryless).
67

68
        This internal function is used to optimize numerical computation of
69
        the describing function.  It can be set to True if the instance
70
        maintains no internal memory of the instance state.  Assumed False by
71
        default.
72

73
        """
74
        return False
9✔
75

76
    # Utility function used to compute common describing functions
77
    def _f(self, x):
9✔
78
        return math.copysign(1, x) if abs(x) > 1 else \
9✔
79
            (math.asin(x) + x * math.sqrt(1 - x**2)) * 2 / math.pi
80

81

82
def describing_function(
9✔
83
        F, A, num_points=100, zero_check=True, try_method=True):
84
    """Numerically compute describing function of a nonlinear function.
85

86
    The describing function of a nonlinearity is given by magnitude and phase
87
    of the first harmonic of the function when evaluated along a sinusoidal
88
    input :math:`A \\sin \\omega t`.  This function returns the magnitude and
89
    phase of the describing function at amplitude :math:`A`.
90

91
    Parameters
92
    ----------
93
    F : callable
94
        The function F() should accept a scalar number as an argument and
95
        return a scalar number.  For compatibility with (static) nonlinear
96
        input/output systems, the output can also return a 1D array with a
97
        single element.
98

99
        If the function is an object with a method `describing_function`
100
        then this method will be used to computing the describing function
101
        instead of a nonlinear computation.  Some common nonlinearities
102
        use the `DescribingFunctionNonlinearity` class,
103
        which provides this functionality.
104

105
    A : array_like
106
        The amplitude(s) at which the describing function should be calculated.
107

108
    num_points : int, optional
109
        Number of points to use in computing describing function (default =
110
        100).
111

112
    zero_check : bool, optional
113
        If True (default) then `A` is zero, the function will be evaluated
114
        and checked to make sure it is zero.  If not, a `TypeError` exception
115
        is raised.  If zero_check is False, no check is made on the value of
116
        the function at zero.
117

118
    try_method : bool, optional
119
        If True (default), check the `F` argument to see if it is an object
120
        with a `describing_function` method and use this to compute the
121
        describing function.  More information in the `describing_function`
122
        method for the `DescribingFunctionNonlinearity` class.
123

124
    Returns
125
    -------
126
    df : ndarray of complex
127
        The (complex) value of the describing function at the given amplitudes.
128

129
    Raises
130
    ------
131
    TypeError
132
        If A[i] < 0 or if A[i] = 0 and the function F(0) is non-zero.
133

134
    Examples
135
    --------
136
    >>> F = lambda x: np.exp(-x)      # Basic diode description
137
    >>> A = np.logspace(-1, 1, 20)    # Amplitudes from 0.1 to 10.0
138
    >>> df_values = ct.describing_function(F, A)
139
    >>> len(df_values)
140
    20
141

142
    """
143
    # If there is an analytical solution, trying using that first
144
    if try_method and hasattr(F, 'describing_function'):
9✔
145
        try:
9✔
146
            return np.vectorize(F.describing_function, otypes=[complex])(A)
9✔
147
        except NotImplementedError:
9✔
148
            # Drop through and do the numerical computation
149
            pass
9✔
150

151
    #
152
    # The describing function of a nonlinear function F() can be computed by
153
    # evaluating the nonlinearity over a sinusoid.  The Fourier series for a
154
    # nonlinear function evaluated on a sinusoid can be written as
155
    #
156
    # F(A\sin\omega t) = \sum_{k=1}^\infty M_k(A) \sin(k\omega t + \phi_k(A))
157
    #
158
    # The describing function is given by the complex number
159
    #
160
    #    N(A) = M_1(A) e^{j \phi_1(A)} / A
161
    #
162
    # To compute this, we compute F(A \sin\theta) for \theta between 0 and 2
163
    # \pi, use the identities
164
    #
165
    #   \sin(\theta + \phi) = \sin\theta \cos\phi + \cos\theta \sin\phi
166
    #   \int_0^{2\pi} \sin^2 \theta d\theta = \pi
167
    #   \int_0^{2\pi} \cos^2 \theta d\theta = \pi
168
    #
169
    # and then integrate the product against \sin\theta and \cos\theta to obtain
170
    #
171
    #   \int_0^{2\pi} F(A\sin\theta) \sin\theta d\theta = M_1 \pi \cos\phi
172
    #   \int_0^{2\pi} F(A\sin\theta) \cos\theta d\theta = M_1 \pi \sin\phi
173
    #
174
    # From these we can compute M1 and \phi.
175
    #
176

177
    # Evaluate over a full range of angles (leave off endpoint a la DFT)
178
    theta, dtheta = np.linspace(
9✔
179
        0, 2*np.pi, num_points, endpoint=False, retstep=True)
180
    sin_theta = np.sin(theta)
9✔
181
    cos_theta = np.cos(theta)
9✔
182

183
    # See if this is a static nonlinearity (assume not, just in case)
184
    if not hasattr(F, '_isstatic') or not F._isstatic():
9✔
185
        # Initialize any internal state by going through an initial cycle
186
        for x in np.atleast_1d(A).min() * sin_theta:
9✔
187
            F(x)                # ignore the result
9✔
188

189
    # Go through all of the amplitudes we were given
190
    retdf = np.empty(np.shape(A), dtype=complex)
9✔
191
    df = retdf                  # Access to the return array
9✔
192
    df.shape = (-1, )           # as a 1D array
9✔
193
    for i, a in enumerate(np.atleast_1d(A)):
9✔
194
        # Make sure we got a valid argument
195
        if a == 0:
9✔
196
            # Check to make sure the function has zero output with zero input
197
            if zero_check and np.squeeze(F(0.)) != 0:
9✔
198
                raise ValueError("function must evaluate to zero at zero")
9✔
199
            df[i] = 1.
9✔
200
            continue
9✔
201
        elif a < 0:
9✔
202
            raise ValueError("cannot evaluate describing function for A < 0")
9✔
203

204
        # Save the scaling factor to make the formulas simpler
205
        scale = dtheta / np.pi / a
9✔
206

207
        # Evaluate the function along a sinusoid
208
        F_eval = np.array([F(x) for x in a*sin_theta]).squeeze()
9✔
209

210
        # Compute the projections onto sine and cosine
211
        df_real = (F_eval @ sin_theta) * scale     # = M_1 \cos\phi / a
9✔
212
        df_imag = (F_eval @ cos_theta) * scale     # = M_1 \sin\phi / a
9✔
213

214
        df[i] = df_real + 1j * df_imag
9✔
215

216
    # Return the values in the same shape as they were requested
217
    return retdf
9✔
218

219
#
220
# Describing function response/plot
221
#
222

223
# Simple class to store the describing function response
224
class DescribingFunctionResponse:
9✔
225
    """Results of describing function analysis.
226

227
    Describing functions allow analysis of a linear I/O systems with a
228
    nonlinear feedback function.  The DescribingFunctionResponse class
229
    is used by the `describing_function_response` function to return
230
    the results of a describing function analysis.  The response
231
    object can be used to obtain information about the describing
232
    function analysis or generate a Nyquist plot showing the frequency
233
    response of the linear systems and the describing function for the
234
    nonlinear element.
235

236
    Parameters
237
    ----------
238
    response : `FrequencyResponseData`
239
        Frequency response of the linear system component of the system.
240
    intersections : 1D array of 2-tuples or None
241
        A list of all amplitudes and frequencies in which
242
        :math:`H(j\\omega) N(A) = -1`, where :math:`N(A)` is the describing
243
        function associated with `F`, or None if there are no such
244
        points.  Each pair represents a potential limit cycle for the
245
        closed loop system with amplitude given by the first value of the
246
        tuple and frequency given by the second value.
247
    N_vals : complex array
248
        Complex value of the describing function, indexed by amplitude.
249
    positions : list of complex
250
        Location of the intersections in the complex plane.
251

252
    """
253
    def __init__(self, response, N_vals, positions, intersections):
9✔
254
        """Create a describing function response data object."""
255
        self.response = response
9✔
256
        self.N_vals = N_vals
9✔
257
        self.positions = positions
9✔
258
        self.intersections = intersections
9✔
259

260
    def plot(self, **kwargs):
9✔
261
        """Plot the results of a describing function analysis.
262

263
        See `describing_function_plot` for details.
264
        """
265
        return describing_function_plot(self, **kwargs)
9✔
266

267
    # Implement iter, getitem, len to allow recovering the intersections
268
    def __iter__(self):
9✔
269
        return iter(self.intersections)
9✔
270

271
    def __getitem__(self, index):
9✔
272
        return list(self.__iter__())[index]
×
273

274
    def __len__(self):
9✔
275
        return len(self.intersections)
9✔
276

277

278
# Compute the describing function response + intersections
279
def describing_function_response(
9✔
280
        H, F, A, omega=None, refine=True, warn_nyquist=None,
281
        _check_kwargs=True, **kwargs):
282
    """Compute the describing function response of a system.
283

284
    This function uses describing function analysis to analyze a closed
285
    loop system consisting of a linear system with a nonlinear function in
286
    the feedback path.
287

288
    Parameters
289
    ----------
290
    H : LTI system
291
        Linear time-invariant (LTI) system (state space, transfer function,
292
        or FRD).
293
    F : nonlinear function
294
        Feedback nonlinearity, either a scalar function or a single-input,
295
        single-output, static input/output system.
296
    A : list
297
        List of amplitudes to be used for the describing function plot.
298
    omega : list, optional
299
        List of frequencies to be used for the linear system Nyquist curve.
300
    warn_nyquist : bool, optional
301
        Set to True to turn on warnings generated by `nyquist_plot` or
302
        False to turn off warnings.  If not set (or set to None),
303
        warnings are turned off if omega is specified, otherwise they are
304
        turned on.
305
    refine : bool, optional
306
        If True, `scipy.optimize.minimize` to refine the estimate
307
        of the intersection of the frequency response and the describing
308
        function.
309

310
    Returns
311
    -------
312
    response : `DescribingFunctionResponse` object
313
        Response object that contains the result of the describing function
314
        analysis.  The results can plotted using the
315
        `~DescribingFunctionResponse.plot` method.
316
    response.intersections : 1D ndarray of 2-tuples or None
317
        A list of all amplitudes and frequencies in which
318
        :math:`H(j\\omega) N(a) = -1`, where :math:`N(a)` is the describing
319
        function associated with `F`, or None if there are no such
320
        points.  Each pair represents a potential limit cycle for the
321
        closed loop system with amplitude given by the first value of the
322
        tuple and frequency given by the second value.
323
    response.Nvals : complex ndarray
324
        Complex value of the describing function, indexed by amplitude.
325

326
    See Also
327
    --------
328
    DescribingFunctionResponse, describing_function_plot
329

330
    Examples
331
    --------
332
    >>> H_simple = ct.tf([8], [1, 2, 2, 1])
333
    >>> F_saturation = ct.saturation_nonlinearity(1)
334
    >>> amp = np.linspace(1, 4, 10)
335
    >>> response = ct.describing_function_response(H_simple, F_saturation, amp)
336
    >>> response.intersections  # doctest: +SKIP
337
    [(3.343844998258643, 1.4142293090899216)]
338
    >>> cplt = response.plot()
339

340
    """
341
    # Decide whether to turn on warnings or not
342
    if warn_nyquist is None:
9✔
343
        # Turn warnings on unless omega was specified
344
        warn_nyquist = omega is None
9✔
345

346
    # Start by drawing a Nyquist curve
347
    response = nyquist_response(
9✔
348
        H, omega, warn_encirclements=warn_nyquist, warn_nyquist=warn_nyquist,
349
        _check_kwargs=_check_kwargs, **kwargs)
350
    H_omega, H_vals = response.contour.imag, H(response.contour)
9✔
351

352
    # Compute the describing function
353
    df = describing_function(F, A)
9✔
354
    N_vals = -1/df
9✔
355

356
    # Look for intersection points
357
    positions, intersections = [], []
9✔
358
    for i in range(N_vals.size - 1):
9✔
359
        for j in range(H_vals.size - 1):
9✔
360
            intersect = _find_intersection(
9✔
361
                N_vals[i], N_vals[i+1], H_vals[j], H_vals[j+1])
362
            if intersect == None:
9✔
363
                continue
9✔
364

365
            # Found an intersection, compute a and omega
366
            s_amp, s_omega = intersect
9✔
367
            a_guess = (1 - s_amp) * A[i] + s_amp * A[i+1]
9✔
368
            omega_guess = (1 - s_omega) * H_omega[j] + s_omega * H_omega[j+1]
9✔
369

370
            # Refine the coarse estimate to get better intersection point
371
            a_final, omega_final = a_guess, omega_guess
9✔
372
            if refine:
9✔
373
                # Refine the answer to get more accuracy
374
                def _cost(x):
9✔
375
                    # If arguments are invalid, return a "large" value
376
                    # Note: imposing bounds messed up the optimization (?)
377
                    if x[0] < 0 or x[1] < 0:
9✔
378
                        return 1
9✔
379
                    return abs(1 + H(1j * x[1]) *
9✔
380
                               describing_function(F, x[0]))**2
381
                res = scipy.optimize.minimize(
9✔
382
                    _cost, [a_guess, omega_guess])
383
                # bounds=[(A[i], A[i+1]), (H_omega[j], H_omega[j+1])])
384

385
                if not res.success:
9✔
386
                    warn("not able to refine result; returning estimate")
×
387
                else:
388
                    a_final, omega_final = res.x[0], res.x[1]
9✔
389

390
            pos = H(1j * omega_final)
9✔
391

392
            # Save the final estimate
393
            positions.append(pos)
9✔
394
            intersections.append((a_final, omega_final))
9✔
395

396
    return DescribingFunctionResponse(
9✔
397
        response, N_vals, positions, intersections)
398

399

400
def describing_function_plot(
9✔
401
        *sysdata, point_label="%5.2g @ %-5.2g", label=None, **kwargs):
402
    """describing_function_plot(data, *args, **kwargs)
403

404
    Nyquist plot with describing function for a nonlinear system.
405

406
    This function generates a Nyquist plot for a closed loop system
407
    consisting of a linear system with a nonlinearity in the feedback path.
408

409
    The function may be called in one of two forms:
410

411
        describing_function_plot(response[, options])
412

413
        describing_function_plot(H, F, A[, omega[, options]])
414

415
    In the first form, the response should be generated using the
416
    `describing_function_response` function.  In the second
417
    form, that function is called internally, with the listed arguments.
418

419
    Parameters
420
    ----------
421
    data : `DescribingFunctionResponse`
422
        A describing function response data object created by
423
        `describing_function_response`.
424
    H : LTI system
425
        Linear time-invariant (LTI) system (state space, transfer function,
426
        or FRD).
427
    F : nonlinear function
428
        Nonlinearity in the feedback path, either a scalar function or a
429
        single-input, single-output, static input/output system.
430
    A : list
431
        List of amplitudes to be used for the describing function plot.
432
    omega : list, optional
433
        List of frequencies to be used for the linear system Nyquist
434
        curve. If not specified (or None), frequencies are computed
435
        automatically based on the properties of the linear system.
436
    refine : bool, optional
437
        If True (default), refine the location of the intersection of the
438
        Nyquist curve for the linear system and the describing function to
439
        determine the intersection point.
440
    label : str or array_like of str, optional
441
        If present, replace automatically generated label with the given label.
442
    point_label : str, optional
443
        Formatting string used to label intersection points on the Nyquist
444
        plot.  Defaults to "%5.2g @ %-5.2g".  Set to None to omit labels.
445
    ax : `matplotlib.axes.Axes`, optional
446
        The matplotlib axes to draw the figure on.  If not specified and
447
        the current figure has a single axes, that axes is used.
448
        Otherwise, a new figure is created.
449
    title : str, optional
450
        Set the title of the plot.  Defaults to plot type and system name(s).
451
    warn_nyquist : bool, optional
452
        Set to True to turn on warnings generated by `nyquist_plot` or
453
        False to turn off warnings.  If not set (or set to None),
454
        warnings are turned off if omega is specified, otherwise they are
455
        turned on.
456
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
457
        Additional keywords passed to `matplotlib` to specify line properties
458
        for Nyquist curve.
459

460
    Returns
461
    -------
462
    cplt : `ControlPlot` object
463
        Object containing the data that were plotted.  See `ControlPlot`
464
        for more detailed information.
465
    cplt.lines : array of `matplotlib.lines.Line2D`
466
        Array containing information on each line in the plot.  The first
467
        element of the array is a list of lines (typically only one) for
468
        the Nyquist plot of the linear I/O system.  The second element of
469
        the array is a list of lines (typically only one) for the
470
        describing function curve.
471
    cplt.axes : 2D array of `matplotlib.axes.Axes`
472
        Axes for each subplot.
473
    cplt.figure : `matplotlib.figure.Figure`
474
        Figure containing the plot.
475

476
    See Also
477
    --------
478
    DescribingFunctionResponse, describing_function_response
479

480
    Examples
481
    --------
482
    >>> H_simple = ct.tf([8], [1, 2, 2, 1])
483
    >>> F_saturation = ct.saturation_nonlinearity(1)
484
    >>> amp = np.linspace(1, 4, 10)
485
    >>> cplt = ct.describing_function_plot(H_simple, F_saturation, amp)
486

487
    """
488
    # Process keywords
489
    warn_nyquist = config._process_legacy_keyword(
9✔
490
        kwargs, 'warn', 'warn_nyquist', kwargs.pop('warn_nyquist', None))
491
    point_label = config._process_legacy_keyword(
9✔
492
        kwargs, 'label', 'point_label', point_label)
493

494
    # TODO: update to be consistent with ctrlplot use of `label`
495
    if label not in (False, None) and not isinstance(label, str):
9✔
496
        raise ValueError("label must be formatting string, False, or None")
9✔
497

498
    # Get the describing function response
499
    if len(sysdata) == 3:
9✔
500
        sysdata = sysdata + (None, )    # set omega to default value
9✔
501
    if len(sysdata) == 4:
9✔
502
        dfresp = describing_function_response(
9✔
503
            *sysdata, refine=kwargs.pop('refine', True),
504
            warn_nyquist=warn_nyquist)
505
    elif len(sysdata) == 1:
9✔
506
        if not isinstance(sysdata[0], DescribingFunctionResponse):
9✔
507
            raise TypeError("data must be DescribingFunctionResponse")
9✔
508
        else:
509
            dfresp = sysdata[0]
9✔
510
    else:
511
        raise TypeError("1, 3, or 4 position arguments required")
×
512

513
    # Don't allow legend keyword arguments
514
    for kw in ['legend_loc', 'legend_map', 'show_legend']:
9✔
515
        if kw in kwargs:
9✔
516
            raise TypeError(f"unexpected keyword argument '{kw}'")
9✔
517

518
    # Create a list of lines for the output
519
    lines = np.empty(2, dtype=object)
9✔
520

521
    # Plot the Nyquist response
522
    cplt = dfresp.response.plot(**kwargs)
9✔
523
    ax = cplt.axes[0, 0]        # Get the axes where the plot was made
9✔
524
    lines[0] = np.concatenate(  # Return Nyquist lines for first system
9✔
525
        cplt.lines.flatten()).tolist()
526

527
    # Add the describing function curve to the plot
528
    lines[1] = ax.plot(dfresp.N_vals.real, dfresp.N_vals.imag)
9✔
529

530
    # Label the intersection points
531
    if point_label:
9✔
532
        for pos, (a, omega) in zip(dfresp.positions, dfresp.intersections):
9✔
533
            # Add labels to the intersection points
534
            ax.text(pos.real, pos.imag, point_label % (a, omega))
9✔
535

536
    return ControlPlot(lines, cplt.axes, cplt.figure)
9✔
537

538

539
# Utility function to figure out whether two line segments intersection
540
def _find_intersection(L1a, L1b, L2a, L2b):
9✔
541
    # Compute the tangents for the segments
542
    L1t = L1b - L1a
9✔
543
    L2t = L2b - L2a
9✔
544

545
    # Set up components of the solution: b = M s
546
    b = L1a - L2a
9✔
547
    detM = L1t.imag * L2t.real - L1t.real * L2t.imag
9✔
548
    if abs(detM) < 1e-8:        # TODO: fix magic number
9✔
549
        return None
9✔
550

551
    # Solve for the intersection points on each line segment
552
    s1 = (L2t.imag * b.real - L2t.real * b.imag) / detM
9✔
553
    if s1 < 0 or s1 > 1:
9✔
554
        return None
9✔
555

556
    s2 = (L1t.imag * b.real - L1t.real * b.imag) / detM
9✔
557
    if s2 < 0 or s2 > 1:
9✔
558
        return None
9✔
559

560
    # Debugging test
561
    # np.testing.assert_almost_equal(L1a + s1 * L1t, L2a + s2 * L2t)
562

563
    # Intersection is within segments; return proportional distance
564
    return (s1, s2)
9✔
565

566

567
# Saturation nonlinearity
568
class saturation_nonlinearity(DescribingFunctionNonlinearity):
9✔
569
    """Saturation nonlinearity for describing function analysis.
570

571
    This class creates a nonlinear function representing a saturation with
572
    given upper and lower bounds, including the describing function for the
573
    nonlinearity.  The following call creates a nonlinear function suitable
574
    for describing function analysis:
575

576
        F = saturation_nonlinearity(ub[, lb])
577

578
    By default, the lower bound is set to the negative of the upper bound.
579
    Asymmetric saturation functions can be created, but note that these
580
    functions will not have zero bias and hence care must be taken in using
581
    the nonlinearity for analysis.
582

583
    Parameters
584
    ----------
585
    lb, ub : float
586
        Upper and lower saturation bounds.
587

588
    Examples
589
    --------
590
    >>> nl = ct.saturation_nonlinearity(5)
591
    >>> nl(1)
592
    np.int64(1)
593
    >>> nl(10)
594
    np.int64(5)
595
    >>> nl(-10)
596
    np.int64(-5)
597

598
    """
599
    def __init__(self, ub=1, lb=None):
9✔
600
        # Create the describing function nonlinearity object
601
        super(saturation_nonlinearity, self).__init__()
9✔
602

603
        # Process arguments
604
        if lb == None:
9✔
605
            # Only received one argument; assume symmetric around zero
606
            lb, ub = -abs(ub), abs(ub)
9✔
607

608
        # Make sure the bounds are sensible
609
        if lb > 0 or ub < 0 or lb + ub != 0:
9✔
610
            warn("asymmetric saturation; ignoring non-zero bias term")
9✔
611

612
        self.lb = lb
9✔
613
        self.ub = ub
9✔
614

615
    def __call__(self, x):
9✔
616
        return np.clip(x, self.lb, self.ub)
9✔
617

618
    def _isstatic(self):
9✔
619
        return True
9✔
620

621
    def describing_function(self, A):
9✔
622
        """Return the describing function for a saturation nonlinearity.
623

624
        Parameters
625
        ----------
626
        A : float
627
            Amplitude of the sinusoidal input to the nonlinearity.
628

629
        Returns
630
        -------
631
        float
632
            Value of the describing function at the given amplitude.
633

634
        """
635
        # Check to make sure the amplitude is positive
636
        if A < 0:
9✔
637
            raise ValueError("cannot evaluate describing function for A < 0")
9✔
638

639
        if self.lb <= A and A <= self.ub:
9✔
640
            return 1.
9✔
641
        else:
642
            alpha, beta = math.asin(self.ub/A), math.asin(-self.lb/A)
9✔
643
            return (math.sin(alpha + beta) * math.cos(alpha - beta) +
9✔
644
                    (alpha + beta)) / math.pi
645

646

647
# Relay with hysteresis (FBS2e, Example 10.12)
648
class relay_hysteresis_nonlinearity(DescribingFunctionNonlinearity):
9✔
649
    """Relay w/ hysteresis for describing function analysis.
650

651
    This class creates a nonlinear function representing a a relay with
652
    symmetric upper and lower bounds of magnitude `b` and a hysteretic region
653
    of width `c` (using the notation from [FBS2e](https://fbsbook.org),
654
    Example 10.12, including the describing function for the nonlinearity.
655
    The following call creates a nonlinear function suitable for describing
656
    function analysis::
657

658
        F = relay_hysteresis_nonlinearity(b, c)
659

660
    The output of this function is b if x > c and -b if x < -c.  For -c <=
661
    x <= c, the value depends on the branch of the hysteresis loop (as
662
    illustrated in Figure 10.20 of FBS2e).
663

664
    Parameters
665
    ----------
666
    b : float
667
        Hysteresis bound.
668
    c : float
669
        Width of hysteresis region.
670

671
    Examples
672
    --------
673
    >>> nl = ct.relay_hysteresis_nonlinearity(1, 2)
674
    >>> nl(0)
675
    -1
676
    >>> nl(1)  # not enough for switching on
677
    -1
678
    >>> nl(5)
679
    1
680
    >>> nl(-1)  # not enough for switching off
681
    1
682
    >>> nl(-5)
683
    -1
684

685
    """
686
    def __init__(self, b, c):
9✔
687
        # Create the describing function nonlinearity object
688
        super(relay_hysteresis_nonlinearity, self).__init__()
9✔
689

690
        # Initialize the state to bottom branch
691
        self.branch = -1        # lower branch
9✔
692
        self.b = b              # relay output value
9✔
693
        self.c = c              # size of hysteresis region
9✔
694

695
    def __call__(self, x):
9✔
696
        if x > self.c:
9✔
697
            y = self.b
9✔
698
            self.branch = 1
9✔
699
        elif x < -self.c:
9✔
700
            y = -self.b
9✔
701
            self.branch = -1
9✔
702
        elif self.branch == -1:
9✔
703
            y = -self.b
9✔
704
        elif self.branch == 1:
9✔
705
            y = self.b
9✔
706
        return y
9✔
707

708
    def _isstatic(self):
9✔
709
        return False
9✔
710

711
    def describing_function(self, A):
9✔
712
        """Return the describing function for a hysteresis nonlinearity.
713

714
        Parameters
715
        ----------
716
        A : float
717
            Amplitude of the sinusoidal input to the nonlinearity.
718

719
        Returns
720
        -------
721
        float
722
            Value of the describing function at the given amplitude.
723

724
        """
725
        # Check to make sure the amplitude is positive
726
        if A < 0:
9✔
727
            raise ValueError("cannot evaluate describing function for A < 0")
9✔
728

729
        if A < self.c:
9✔
730
            return np.nan
×
731

732
        df_real = 4 * self.b * math.sqrt(1 - (self.c/A)**2) / (A * math.pi)
9✔
733
        df_imag = -4 * self.b * self.c / (math.pi * A**2)
9✔
734
        return df_real + 1j * df_imag
9✔
735

736

737
# Friction-dominated backlash nonlinearity (#48 in Gelb and Vander Velde, 1968)
738
class friction_backlash_nonlinearity(DescribingFunctionNonlinearity):
9✔
739
    """Backlash nonlinearity for describing function analysis.
740

741
    This class creates a nonlinear function representing a friction-dominated
742
    backlash nonlinearity ,including the describing function for the
743
    nonlinearity.  The following call creates a nonlinear function suitable
744
    for describing function analysis::
745

746
        F = friction_backlash_nonlinearity(b)
747

748
    This function maintains an internal state representing the 'center' of
749
    a mechanism with backlash.  If the new input is within b/2 of the
750
    current center, the output is unchanged.  Otherwise, the output is
751
    given by the input shifted by b/2.
752

753
    Parameters
754
    ----------
755
    b : float
756
        Backlash amount.
757

758
    Examples
759
    --------
760
    >>> nl = ct.friction_backlash_nonlinearity(2)  # backlash of +/- 1
761
    >>> nl(0)
762
    0
763
    >>> nl(1)  # not enough to overcome backlash
764
    0
765
    >>> nl(2)
766
    1.0
767
    >>> nl(1)
768
    1.0
769
    >>> nl(0)  # not enough to overcome backlash
770
    1.0
771
    >>> nl(-1)
772
    0.0
773

774
    """
775

776
    def __init__(self, b):
9✔
777
        # Create the describing function nonlinearity object
778
        super(friction_backlash_nonlinearity, self).__init__()
9✔
779

780
        self.b = b              # backlash distance
9✔
781
        self.center = 0         # current center position
9✔
782

783
    def __call__(self, x):
9✔
784
        # If we are outside the backlash, move and shift the center
785
        if x - self.center > self.b/2:
9✔
786
            self.center = x - self.b/2
9✔
787
        elif x - self.center < -self.b/2:
9✔
788
            self.center = x + self.b/2
9✔
789
        return self.center
9✔
790

791
    def _isstatic(self):
9✔
792
        return False
9✔
793

794
    def describing_function(self, A):
9✔
795
        """Return the describing function for a backlash nonlinearity.
796

797
        Parameters
798
        ----------
799
        A : float
800
            Amplitude of the sinusoidal input to the nonlinearity.
801

802
        Returns
803
        -------
804
        float
805
            Value of the describing function at the given amplitude.
806

807
        """
808
        # Check to make sure the amplitude is positive
809
        if A < 0:
9✔
810
            raise ValueError("cannot evaluate describing function for A < 0")
9✔
811

812
        if A <= self.b/2:
9✔
813
            return 0
9✔
814

815
        df_real = (1 + self._f(1 - self.b/A)) / 2
9✔
816
        df_imag = -(2 * self.b/A - (self.b/A)**2) / math.pi
9✔
817
        return df_real + 1j * df_imag
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