Skip to content

LTI System Models

base

Base classes for Linear Time-Invariant (LTI) systems.

LTI module-attribute

LTI = LinearTimeInvariant

LinearTimeInvariant

Bases: ABC

Abstract base class representing a Linear Time-Invariant (LTI) system.

This class defines the common interface and properties for all LTI models, such as transfer functions and state-space models.

Source code in src/ctrlpy/models/base.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
class LinearTimeInvariant(ABC):
    """Abstract base class representing a Linear Time-Invariant (LTI) system.

    This class defines the common interface and properties for all LTI models,
    such as transfer functions and state-space models.
    """

    @property
    @abstractmethod
    def inputs(self) -> int:
        """Number of inputs to the system.

        Returns
        -------
        int
            The number of input channels.
        """
        ...

    @property
    @abstractmethod
    def outputs(self) -> int:
        """Number of outputs from the system.

        Returns
        -------
        int
            The number of output channels.
        """
        ...

    @property
    def is_siso(self) -> bool:
        """Whether the system is Single-Input Single-Output (SISO).

        Returns
        -------
        bool
            True if inputs == 1 and outputs == 1, False otherwise.
        """
        return self.inputs == 1 and self.outputs == 1

    @property
    def is_discrete(self) -> bool:
        """Whether the system is discrete-time (always False for continuous LTI).

        Returns
        -------
        bool
            False for continuous-time systems.
        """
        return False

    def c2d(
        self,
        dt: float,
        method: Literal[
            "zoh",
            "zero_order_hold",
            "foh",
            "first_order_hold",
            "tustin",
            "bilinear",
            "prewarping",
            "tustin_prewarp",
            "bilinear_prewarp",
            "matched",
        ] = "zoh",
        prewarp_frequency: float | None = None,
        w_warp: float | None = None,
    ) -> DiscreteTransferFunction:
        """Discretize the continuous-time LTI system into a discrete-time TransferFunction.

        Parameters
        ----------
        dt : float
            Sampling time Ts in seconds (must be strictly positive).
        method : {"zoh", "foh", "tustin", "bilinear", "prewarping", "matched"}, optional
            Discretization method, default is "zoh".
        prewarp_frequency : float | None, optional
            Pre-warping frequency in rad/s for Tustin pre-warping.
        w_warp : float | None, optional
            Alias for prewarp_frequency.

        Returns
        -------
        DiscreteTransferFunction
            Discretized transfer function H(z).
        """
        from ctrlpy.core.discrete import c2d

        return c2d(self, dt=dt, method=method, prewarp_frequency=prewarp_frequency, w_warp=w_warp)

    def to_discrete(
        self,
        dt: float,
        method: Literal[
            "zoh",
            "zero_order_hold",
            "foh",
            "first_order_hold",
            "tustin",
            "bilinear",
            "prewarping",
            "tustin_prewarp",
            "bilinear_prewarp",
            "matched",
        ] = "zoh",
        prewarp_frequency: float | None = None,
        w_warp: float | None = None,
    ) -> DiscreteTransferFunction:
        """Alias for c2d(). Discretize the continuous-time LTI system.

        Parameters
        ----------
        dt : float
            Sampling time Ts in seconds.
        method : {"zoh", "foh", "tustin", "bilinear", "prewarping", "matched"}, optional
            Discretization method, default is "zoh".
        prewarp_frequency : float | None, optional
            Pre-warping frequency in rad/s.
        w_warp : float | None, optional
            Alias for prewarp_frequency.

        Returns
        -------
        DiscreteTransferFunction
            Discretized transfer function H(z).
        """
        return self.c2d(dt=dt, method=method, prewarp_frequency=prewarp_frequency, w_warp=w_warp)

    @abstractmethod
    def poles(self) -> NDArray[np.complex128]:
        """Compute the poles of the system.

        Returns
        -------
        NDArray[np.complex128]
            1D array of system poles.
        """
        ...

    @abstractmethod
    def zeros(self) -> NDArray[np.complex128]:
        """Compute the zeros of the system.

        Returns
        -------
        NDArray[np.complex128]
            1D array of system zeros.
        """
        ...

    @abstractmethod
    def __add__(self, other: Any) -> Any:
        """Parallel interconnection."""
        ...

    @abstractmethod
    def __radd__(self, other: Any) -> Any:
        """Parallel interconnection with left operand."""
        ...

    @abstractmethod
    def __sub__(self, other: Any) -> Any:
        """Subtraction."""
        ...

    @abstractmethod
    def __rsub__(self, other: Any) -> Any:
        """Subtraction with left operand."""
        ...

    @abstractmethod
    def __mul__(self, other: Any) -> Any:
        """Series (cascade) interconnection."""
        ...

    @abstractmethod
    def __rmul__(self, other: Any) -> Any:
        """Series interconnection with left operand."""
        ...

    @abstractmethod
    def __neg__(self) -> Any:
        """Negation."""
        ...

    @abstractmethod
    def feedback(self, other: Any = 1, sign: float = -1) -> Any:
        """Closed-loop feedback interconnection."""
        ...

    def step(
        self,
        T: Sequence[float] | NDArray[np.floating] | float | None = None,
        X0: Sequence[float] | NDArray[np.floating] | None = None,
        input_index: int = 0,
    ) -> TimeResponseData:
        """Compute the step response of the system.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or duration. If None, automatically determined.
        X0 : Sequence[float] | NDArray[np.floating] | None, optional
            Initial state vector (for StateSpace systems).
        input_index : int, optional
            Input channel index, default is 0.

        Returns
        -------
        TimeResponseData
            Simulation response object.
        """
        from ctrlpy.time_domain import step_response

        return step_response(self, T=T, X0=X0, input_index=input_index)

    def impulse(
        self,
        T: Sequence[float] | NDArray[np.floating] | float | None = None,
        X0: Sequence[float] | NDArray[np.floating] | None = None,
        input_index: int = 0,
    ) -> TimeResponseData:
        """Compute the impulse response of the system.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or duration. If None, automatically determined.
        X0 : Sequence[float] | NDArray[np.floating] | None, optional
            Initial state vector (for StateSpace systems).
        input_index : int, optional
            Input channel index, default is 0.

        Returns
        -------
        TimeResponseData
            Simulation response object.
        """
        from ctrlpy.time_domain import impulse_response

        return impulse_response(self, T=T, X0=X0, input_index=input_index)

    def bode(
        self,
        omega: Sequence[float] | NDArray[np.floating] | None = None,
    ) -> BodeData:
        """Compute the frequency response (Bode) data for the system.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.

        Returns
        -------
        BodeData
            Bode response object.
        """
        from ctrlpy.freq_domain import bode_data

        return bode_data(self, omega=omega)

    def nyquist(
        self,
        omega: Sequence[float] | NDArray[np.floating] | None = None,
    ) -> NyquistData:
        """Compute the Nyquist frequency response data for the system.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.

        Returns
        -------
        NyquistData
            Nyquist response object.
        """
        from ctrlpy.freq_domain import nyquist_data

        return nyquist_data(self, omega=omega)

    def margin(self) -> StabilityMargins:
        """Compute the stability margins of the system.

        Returns
        -------
        StabilityMargins
            Dataclass with GM, PM, Wcg, and Wcp.
        """
        from ctrlpy.freq_domain import margin

        return margin(self)

    def root_locus(
        self,
        gains: Sequence[float] | NDArray[np.floating] | None = None,
    ) -> RootLocusData:
        """Compute closed-loop root locus trajectories for varying gains.

        Parameters
        ----------
        gains : Sequence[float] | NDArray[np.floating] | None, optional
            Gain values k >= 0.

        Returns
        -------
        RootLocusData
            Root locus trajectory data.
        """
        from ctrlpy.freq_domain import root_locus_data

        return root_locus_data(self, gains=gains)

    def plot_step(
        self,
        T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
        backend: Literal["matplotlib", "plotly"] = "matplotlib",
        ax: Axes | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes] | go.Figure:
        """Plot the step response using Matplotlib or Plotly.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or simulation duration.
        backend : {"matplotlib", "plotly"}, optional
            Plotting backend, default is "matplotlib".
        ax : Axes | None, optional
            Existing Matplotlib Axes (for backend="matplotlib").
        **kwargs : Any
            Additional keyword arguments passed to the plotting routine.

        Returns
        -------
        tuple[Figure, Axes] | go.Figure
            Matplotlib (fig, ax) tuple or Plotly Figure.
        """
        if backend == "plotly":
            from ctrlpy.plotting_plotly import iplot_step

            return iplot_step(self, T=T, **kwargs)
        elif backend == "matplotlib":
            from ctrlpy.plotting import plot_step

            return plot_step(self, T=T, ax=ax, **kwargs)
        else:
            raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

    def iplot_step(
        self,
        T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Plot interactive step response using Plotly.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or simulation duration.
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        go.Figure
            Interactive Plotly Figure.
        """
        from ctrlpy.plotting_plotly import iplot_step

        return iplot_step(self, T=T, **kwargs)

    def plot_impulse(
        self,
        T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
        backend: Literal["matplotlib", "plotly"] = "matplotlib",
        ax: Axes | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes] | go.Figure:
        """Plot the impulse response using Matplotlib or Plotly.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or simulation duration.
        backend : {"matplotlib", "plotly"}, optional
            Plotting backend, default is "matplotlib".
        ax : Axes | None, optional
            Existing Matplotlib Axes (for backend="matplotlib").
        **kwargs : Any
            Additional keyword arguments passed to the plotting routine.

        Returns
        -------
        tuple[Figure, Axes] | go.Figure
            Matplotlib (fig, ax) tuple or Plotly Figure.
        """
        if backend == "plotly":
            from ctrlpy.plotting_plotly import iplot_impulse

            return iplot_impulse(self, T=T, **kwargs)
        elif backend == "matplotlib":
            from ctrlpy.plotting import plot_impulse

            return plot_impulse(self, T=T, ax=ax, **kwargs)
        else:
            raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

    def iplot_impulse(
        self,
        T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Plot interactive impulse response using Plotly.

        Parameters
        ----------
        T : Sequence[float] | NDArray[np.floating] | float | None, optional
            Time vector or simulation duration.
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        go.Figure
            Interactive Plotly Figure.
        """
        from ctrlpy.plotting_plotly import iplot_impulse

        return iplot_impulse(self, T=T, **kwargs)

    def plot_bode(
        self,
        omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        margins: bool = True,
        backend: Literal["matplotlib", "plotly"] = "matplotlib",
        ax: Sequence[Axes] | NDArray[Any] | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, tuple[Axes, Axes]] | go.Figure:
        """Plot Bode diagram using Matplotlib or Plotly.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.
        margins : bool, optional
            Whether to calculate and annotate stability margins, default is True.
        backend : {"matplotlib", "plotly"}, optional
            Plotting backend, default is "matplotlib".
        ax : Sequence[Axes] | NDArray[Any] | None, optional
            Sequence of 2 Matplotlib Axes (for backend="matplotlib").
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        tuple[Figure, tuple[Axes, Axes]] | go.Figure
            Matplotlib (fig, (ax_mag, ax_phase)) or Plotly Figure.
        """
        if backend == "plotly":
            from ctrlpy.plotting_plotly import iplot_bode

            return iplot_bode(self, omega=omega, margins=margins, **kwargs)
        elif backend == "matplotlib":
            from ctrlpy.plotting import plot_bode

            return plot_bode(self, omega=omega, margins=margins, ax=ax, **kwargs)
        else:
            raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

    def iplot_bode(
        self,
        omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        margins: bool = True,
        **kwargs: Any,
    ) -> go.Figure:
        """Plot interactive Bode diagram using Plotly.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.
        margins : bool, optional
            Whether to calculate and annotate stability margins, default is True.
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        go.Figure
            Interactive Plotly Figure.
        """
        from ctrlpy.plotting_plotly import iplot_bode

        return iplot_bode(self, omega=omega, margins=margins, **kwargs)

    def plot_nyquist(
        self,
        omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        backend: Literal["matplotlib", "plotly"] = "matplotlib",
        ax: Axes | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes] | go.Figure:
        """Plot Nyquist diagram using Matplotlib or Plotly.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.
        backend : {"matplotlib", "plotly"}, optional
            Plotting backend, default is "matplotlib".
        ax : Axes | None, optional
            Matplotlib Axes (for backend="matplotlib").
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        tuple[Figure, Axes] | go.Figure
            Matplotlib (fig, ax) or Plotly Figure.
        """
        if backend == "plotly":
            from ctrlpy.plotting_plotly import iplot_nyquist

            return iplot_nyquist(self, omega=omega, **kwargs)
        elif backend == "matplotlib":
            from ctrlpy.plotting import plot_nyquist

            return plot_nyquist(self, omega=omega, ax=ax, **kwargs)
        else:
            raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

    def iplot_nyquist(
        self,
        omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Plot interactive Nyquist diagram using Plotly.

        Parameters
        ----------
        omega : Sequence[float] | NDArray[np.floating] | None, optional
            Frequency vector in rad/s.
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        go.Figure
            Interactive Plotly Figure.
        """
        from ctrlpy.plotting_plotly import iplot_nyquist

        return iplot_nyquist(self, omega=omega, **kwargs)

    def plot_root_locus(
        self,
        gains: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        backend: Literal["matplotlib", "plotly"] = "matplotlib",
        ax: Axes | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes] | go.Figure:
        """Plot Root Locus diagram using Matplotlib or Plotly.

        Parameters
        ----------
        gains : Sequence[float] | NDArray[np.floating] | None, optional
            Gain values k >= 0.
        backend : {"matplotlib", "plotly"}, optional
            Plotting backend, default is "matplotlib".
        ax : Axes | None, optional
            Matplotlib Axes (for backend="matplotlib").
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        tuple[Figure, Axes] | go.Figure
            Matplotlib (fig, ax) or Plotly Figure.
        """
        if backend == "plotly":
            from ctrlpy.plotting_plotly import iplot_root_locus

            return iplot_root_locus(self, gains=gains, **kwargs)
        elif backend == "matplotlib":
            from ctrlpy.plotting import plot_root_locus

            return plot_root_locus(self, gains=gains, ax=ax, **kwargs)
        else:
            raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

    def iplot_root_locus(
        self,
        gains: Sequence[float] | NDArray[np.floating[Any]] | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Plot interactive Root Locus diagram using Plotly.

        Parameters
        ----------
        gains : Sequence[float] | NDArray[np.floating] | None, optional
            Gain values k >= 0.
        **kwargs : Any
            Additional keyword arguments.

        Returns
        -------
        go.Figure
            Interactive Plotly Figure.
        """
        from ctrlpy.plotting_plotly import iplot_root_locus

        return iplot_root_locus(self, gains=gains, **kwargs)

inputs abstractmethod property

inputs

Number of inputs to the system.

Returns:

Type Description
int

The number of input channels.

outputs abstractmethod property

outputs

Number of outputs from the system.

Returns:

Type Description
int

The number of output channels.

is_siso property

is_siso

Whether the system is Single-Input Single-Output (SISO).

Returns:

Type Description
bool

True if inputs == 1 and outputs == 1, False otherwise.

is_discrete property

is_discrete

Whether the system is discrete-time (always False for continuous LTI).

Returns:

Type Description
bool

False for continuous-time systems.

c2d

c2d(dt, method='zoh', prewarp_frequency=None, w_warp=None)

Discretize the continuous-time LTI system into a discrete-time TransferFunction.

Parameters:

Name Type Description Default
dt float

Sampling time Ts in seconds (must be strictly positive).

required
method ('zoh', 'foh', 'tustin', 'bilinear', 'prewarping', 'matched')

Discretization method, default is "zoh".

"zoh"
prewarp_frequency float | None

Pre-warping frequency in rad/s for Tustin pre-warping.

None
w_warp float | None

Alias for prewarp_frequency.

None

Returns:

Type Description
DiscreteTransferFunction

Discretized transfer function H(z).

Source code in src/ctrlpy/models/base.py
def c2d(
    self,
    dt: float,
    method: Literal[
        "zoh",
        "zero_order_hold",
        "foh",
        "first_order_hold",
        "tustin",
        "bilinear",
        "prewarping",
        "tustin_prewarp",
        "bilinear_prewarp",
        "matched",
    ] = "zoh",
    prewarp_frequency: float | None = None,
    w_warp: float | None = None,
) -> DiscreteTransferFunction:
    """Discretize the continuous-time LTI system into a discrete-time TransferFunction.

    Parameters
    ----------
    dt : float
        Sampling time Ts in seconds (must be strictly positive).
    method : {"zoh", "foh", "tustin", "bilinear", "prewarping", "matched"}, optional
        Discretization method, default is "zoh".
    prewarp_frequency : float | None, optional
        Pre-warping frequency in rad/s for Tustin pre-warping.
    w_warp : float | None, optional
        Alias for prewarp_frequency.

    Returns
    -------
    DiscreteTransferFunction
        Discretized transfer function H(z).
    """
    from ctrlpy.core.discrete import c2d

    return c2d(self, dt=dt, method=method, prewarp_frequency=prewarp_frequency, w_warp=w_warp)

to_discrete

to_discrete(
    dt, method="zoh", prewarp_frequency=None, w_warp=None
)

Alias for c2d(). Discretize the continuous-time LTI system.

Parameters:

Name Type Description Default
dt float

Sampling time Ts in seconds.

required
method ('zoh', 'foh', 'tustin', 'bilinear', 'prewarping', 'matched')

Discretization method, default is "zoh".

"zoh"
prewarp_frequency float | None

Pre-warping frequency in rad/s.

None
w_warp float | None

Alias for prewarp_frequency.

None

Returns:

Type Description
DiscreteTransferFunction

Discretized transfer function H(z).

Source code in src/ctrlpy/models/base.py
def to_discrete(
    self,
    dt: float,
    method: Literal[
        "zoh",
        "zero_order_hold",
        "foh",
        "first_order_hold",
        "tustin",
        "bilinear",
        "prewarping",
        "tustin_prewarp",
        "bilinear_prewarp",
        "matched",
    ] = "zoh",
    prewarp_frequency: float | None = None,
    w_warp: float | None = None,
) -> DiscreteTransferFunction:
    """Alias for c2d(). Discretize the continuous-time LTI system.

    Parameters
    ----------
    dt : float
        Sampling time Ts in seconds.
    method : {"zoh", "foh", "tustin", "bilinear", "prewarping", "matched"}, optional
        Discretization method, default is "zoh".
    prewarp_frequency : float | None, optional
        Pre-warping frequency in rad/s.
    w_warp : float | None, optional
        Alias for prewarp_frequency.

    Returns
    -------
    DiscreteTransferFunction
        Discretized transfer function H(z).
    """
    return self.c2d(dt=dt, method=method, prewarp_frequency=prewarp_frequency, w_warp=w_warp)

poles abstractmethod

poles()

Compute the poles of the system.

Returns:

Type Description
NDArray[complex128]

1D array of system poles.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def poles(self) -> NDArray[np.complex128]:
    """Compute the poles of the system.

    Returns
    -------
    NDArray[np.complex128]
        1D array of system poles.
    """
    ...

zeros abstractmethod

zeros()

Compute the zeros of the system.

Returns:

Type Description
NDArray[complex128]

1D array of system zeros.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def zeros(self) -> NDArray[np.complex128]:
    """Compute the zeros of the system.

    Returns
    -------
    NDArray[np.complex128]
        1D array of system zeros.
    """
    ...

__add__ abstractmethod

__add__(other)

Parallel interconnection.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __add__(self, other: Any) -> Any:
    """Parallel interconnection."""
    ...

__radd__ abstractmethod

__radd__(other)

Parallel interconnection with left operand.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __radd__(self, other: Any) -> Any:
    """Parallel interconnection with left operand."""
    ...

__sub__ abstractmethod

__sub__(other)

Subtraction.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __sub__(self, other: Any) -> Any:
    """Subtraction."""
    ...

__rsub__ abstractmethod

__rsub__(other)

Subtraction with left operand.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __rsub__(self, other: Any) -> Any:
    """Subtraction with left operand."""
    ...

__mul__ abstractmethod

__mul__(other)

Series (cascade) interconnection.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __mul__(self, other: Any) -> Any:
    """Series (cascade) interconnection."""
    ...

__rmul__ abstractmethod

__rmul__(other)

Series interconnection with left operand.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __rmul__(self, other: Any) -> Any:
    """Series interconnection with left operand."""
    ...

__neg__ abstractmethod

__neg__()

Negation.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def __neg__(self) -> Any:
    """Negation."""
    ...

feedback abstractmethod

feedback(other=1, sign=-1)

Closed-loop feedback interconnection.

Source code in src/ctrlpy/models/base.py
@abstractmethod
def feedback(self, other: Any = 1, sign: float = -1) -> Any:
    """Closed-loop feedback interconnection."""
    ...

step

step(T=None, X0=None, input_index=0)

Compute the step response of the system.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or duration. If None, automatically determined.

None
X0 Sequence[float] | NDArray[floating] | None

Initial state vector (for StateSpace systems).

None
input_index int

Input channel index, default is 0.

0

Returns:

Type Description
TimeResponseData

Simulation response object.

Source code in src/ctrlpy/models/base.py
def step(
    self,
    T: Sequence[float] | NDArray[np.floating] | float | None = None,
    X0: Sequence[float] | NDArray[np.floating] | None = None,
    input_index: int = 0,
) -> TimeResponseData:
    """Compute the step response of the system.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or duration. If None, automatically determined.
    X0 : Sequence[float] | NDArray[np.floating] | None, optional
        Initial state vector (for StateSpace systems).
    input_index : int, optional
        Input channel index, default is 0.

    Returns
    -------
    TimeResponseData
        Simulation response object.
    """
    from ctrlpy.time_domain import step_response

    return step_response(self, T=T, X0=X0, input_index=input_index)

impulse

impulse(T=None, X0=None, input_index=0)

Compute the impulse response of the system.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or duration. If None, automatically determined.

None
X0 Sequence[float] | NDArray[floating] | None

Initial state vector (for StateSpace systems).

None
input_index int

Input channel index, default is 0.

0

Returns:

Type Description
TimeResponseData

Simulation response object.

Source code in src/ctrlpy/models/base.py
def impulse(
    self,
    T: Sequence[float] | NDArray[np.floating] | float | None = None,
    X0: Sequence[float] | NDArray[np.floating] | None = None,
    input_index: int = 0,
) -> TimeResponseData:
    """Compute the impulse response of the system.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or duration. If None, automatically determined.
    X0 : Sequence[float] | NDArray[np.floating] | None, optional
        Initial state vector (for StateSpace systems).
    input_index : int, optional
        Input channel index, default is 0.

    Returns
    -------
    TimeResponseData
        Simulation response object.
    """
    from ctrlpy.time_domain import impulse_response

    return impulse_response(self, T=T, X0=X0, input_index=input_index)

bode

bode(omega=None)

Compute the frequency response (Bode) data for the system.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None

Returns:

Type Description
BodeData

Bode response object.

Source code in src/ctrlpy/models/base.py
def bode(
    self,
    omega: Sequence[float] | NDArray[np.floating] | None = None,
) -> BodeData:
    """Compute the frequency response (Bode) data for the system.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.

    Returns
    -------
    BodeData
        Bode response object.
    """
    from ctrlpy.freq_domain import bode_data

    return bode_data(self, omega=omega)

nyquist

nyquist(omega=None)

Compute the Nyquist frequency response data for the system.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None

Returns:

Type Description
NyquistData

Nyquist response object.

Source code in src/ctrlpy/models/base.py
def nyquist(
    self,
    omega: Sequence[float] | NDArray[np.floating] | None = None,
) -> NyquistData:
    """Compute the Nyquist frequency response data for the system.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.

    Returns
    -------
    NyquistData
        Nyquist response object.
    """
    from ctrlpy.freq_domain import nyquist_data

    return nyquist_data(self, omega=omega)

margin

margin()

Compute the stability margins of the system.

Returns:

Type Description
StabilityMargins

Dataclass with GM, PM, Wcg, and Wcp.

Source code in src/ctrlpy/models/base.py
def margin(self) -> StabilityMargins:
    """Compute the stability margins of the system.

    Returns
    -------
    StabilityMargins
        Dataclass with GM, PM, Wcg, and Wcp.
    """
    from ctrlpy.freq_domain import margin

    return margin(self)

root_locus

root_locus(gains=None)

Compute closed-loop root locus trajectories for varying gains.

Parameters:

Name Type Description Default
gains Sequence[float] | NDArray[floating] | None

Gain values k >= 0.

None

Returns:

Type Description
RootLocusData

Root locus trajectory data.

Source code in src/ctrlpy/models/base.py
def root_locus(
    self,
    gains: Sequence[float] | NDArray[np.floating] | None = None,
) -> RootLocusData:
    """Compute closed-loop root locus trajectories for varying gains.

    Parameters
    ----------
    gains : Sequence[float] | NDArray[np.floating] | None, optional
        Gain values k >= 0.

    Returns
    -------
    RootLocusData
        Root locus trajectory data.
    """
    from ctrlpy.freq_domain import root_locus_data

    return root_locus_data(self, gains=gains)

plot_step

plot_step(T=None, backend='matplotlib', ax=None, **kwargs)

Plot the step response using Matplotlib or Plotly.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration.

None
backend ('matplotlib', 'plotly')

Plotting backend, default is "matplotlib".

"matplotlib"
ax Axes | None

Existing Matplotlib Axes (for backend="matplotlib").

None
**kwargs Any

Additional keyword arguments passed to the plotting routine.

{}

Returns:

Type Description
tuple[Figure, Axes] | Figure

Matplotlib (fig, ax) tuple or Plotly Figure.

Source code in src/ctrlpy/models/base.py
def plot_step(
    self,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    ax: Axes | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes] | go.Figure:
    """Plot the step response using Matplotlib or Plotly.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration.
    backend : {"matplotlib", "plotly"}, optional
        Plotting backend, default is "matplotlib".
    ax : Axes | None, optional
        Existing Matplotlib Axes (for backend="matplotlib").
    **kwargs : Any
        Additional keyword arguments passed to the plotting routine.

    Returns
    -------
    tuple[Figure, Axes] | go.Figure
        Matplotlib (fig, ax) tuple or Plotly Figure.
    """
    if backend == "plotly":
        from ctrlpy.plotting_plotly import iplot_step

        return iplot_step(self, T=T, **kwargs)
    elif backend == "matplotlib":
        from ctrlpy.plotting import plot_step

        return plot_step(self, T=T, ax=ax, **kwargs)
    else:
        raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

iplot_step

iplot_step(T=None, **kwargs)

Plot interactive step response using Plotly.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration.

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Interactive Plotly Figure.

Source code in src/ctrlpy/models/base.py
def iplot_step(
    self,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Plot interactive step response using Plotly.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration.
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure.
    """
    from ctrlpy.plotting_plotly import iplot_step

    return iplot_step(self, T=T, **kwargs)

plot_impulse

plot_impulse(
    T=None, backend="matplotlib", ax=None, **kwargs
)

Plot the impulse response using Matplotlib or Plotly.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration.

None
backend ('matplotlib', 'plotly')

Plotting backend, default is "matplotlib".

"matplotlib"
ax Axes | None

Existing Matplotlib Axes (for backend="matplotlib").

None
**kwargs Any

Additional keyword arguments passed to the plotting routine.

{}

Returns:

Type Description
tuple[Figure, Axes] | Figure

Matplotlib (fig, ax) tuple or Plotly Figure.

Source code in src/ctrlpy/models/base.py
def plot_impulse(
    self,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    ax: Axes | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes] | go.Figure:
    """Plot the impulse response using Matplotlib or Plotly.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration.
    backend : {"matplotlib", "plotly"}, optional
        Plotting backend, default is "matplotlib".
    ax : Axes | None, optional
        Existing Matplotlib Axes (for backend="matplotlib").
    **kwargs : Any
        Additional keyword arguments passed to the plotting routine.

    Returns
    -------
    tuple[Figure, Axes] | go.Figure
        Matplotlib (fig, ax) tuple or Plotly Figure.
    """
    if backend == "plotly":
        from ctrlpy.plotting_plotly import iplot_impulse

        return iplot_impulse(self, T=T, **kwargs)
    elif backend == "matplotlib":
        from ctrlpy.plotting import plot_impulse

        return plot_impulse(self, T=T, ax=ax, **kwargs)
    else:
        raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

iplot_impulse

iplot_impulse(T=None, **kwargs)

Plot interactive impulse response using Plotly.

Parameters:

Name Type Description Default
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration.

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Interactive Plotly Figure.

Source code in src/ctrlpy/models/base.py
def iplot_impulse(
    self,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Plot interactive impulse response using Plotly.

    Parameters
    ----------
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration.
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure.
    """
    from ctrlpy.plotting_plotly import iplot_impulse

    return iplot_impulse(self, T=T, **kwargs)

plot_bode

plot_bode(
    omega=None,
    margins=True,
    backend="matplotlib",
    ax=None,
    **kwargs,
)

Plot Bode diagram using Matplotlib or Plotly.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None
margins bool

Whether to calculate and annotate stability margins, default is True.

True
backend ('matplotlib', 'plotly')

Plotting backend, default is "matplotlib".

"matplotlib"
ax Sequence[Axes] | NDArray[Any] | None

Sequence of 2 Matplotlib Axes (for backend="matplotlib").

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
tuple[Figure, tuple[Axes, Axes]] | Figure

Matplotlib (fig, (ax_mag, ax_phase)) or Plotly Figure.

Source code in src/ctrlpy/models/base.py
def plot_bode(
    self,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    margins: bool = True,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    ax: Sequence[Axes] | NDArray[Any] | None = None,
    **kwargs: Any,
) -> tuple[Figure, tuple[Axes, Axes]] | go.Figure:
    """Plot Bode diagram using Matplotlib or Plotly.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.
    margins : bool, optional
        Whether to calculate and annotate stability margins, default is True.
    backend : {"matplotlib", "plotly"}, optional
        Plotting backend, default is "matplotlib".
    ax : Sequence[Axes] | NDArray[Any] | None, optional
        Sequence of 2 Matplotlib Axes (for backend="matplotlib").
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    tuple[Figure, tuple[Axes, Axes]] | go.Figure
        Matplotlib (fig, (ax_mag, ax_phase)) or Plotly Figure.
    """
    if backend == "plotly":
        from ctrlpy.plotting_plotly import iplot_bode

        return iplot_bode(self, omega=omega, margins=margins, **kwargs)
    elif backend == "matplotlib":
        from ctrlpy.plotting import plot_bode

        return plot_bode(self, omega=omega, margins=margins, ax=ax, **kwargs)
    else:
        raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

iplot_bode

iplot_bode(omega=None, margins=True, **kwargs)

Plot interactive Bode diagram using Plotly.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None
margins bool

Whether to calculate and annotate stability margins, default is True.

True
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Interactive Plotly Figure.

Source code in src/ctrlpy/models/base.py
def iplot_bode(
    self,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    margins: bool = True,
    **kwargs: Any,
) -> go.Figure:
    """Plot interactive Bode diagram using Plotly.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.
    margins : bool, optional
        Whether to calculate and annotate stability margins, default is True.
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure.
    """
    from ctrlpy.plotting_plotly import iplot_bode

    return iplot_bode(self, omega=omega, margins=margins, **kwargs)

plot_nyquist

plot_nyquist(
    omega=None, backend="matplotlib", ax=None, **kwargs
)

Plot Nyquist diagram using Matplotlib or Plotly.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None
backend ('matplotlib', 'plotly')

Plotting backend, default is "matplotlib".

"matplotlib"
ax Axes | None

Matplotlib Axes (for backend="matplotlib").

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
tuple[Figure, Axes] | Figure

Matplotlib (fig, ax) or Plotly Figure.

Source code in src/ctrlpy/models/base.py
def plot_nyquist(
    self,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    ax: Axes | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes] | go.Figure:
    """Plot Nyquist diagram using Matplotlib or Plotly.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.
    backend : {"matplotlib", "plotly"}, optional
        Plotting backend, default is "matplotlib".
    ax : Axes | None, optional
        Matplotlib Axes (for backend="matplotlib").
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    tuple[Figure, Axes] | go.Figure
        Matplotlib (fig, ax) or Plotly Figure.
    """
    if backend == "plotly":
        from ctrlpy.plotting_plotly import iplot_nyquist

        return iplot_nyquist(self, omega=omega, **kwargs)
    elif backend == "matplotlib":
        from ctrlpy.plotting import plot_nyquist

        return plot_nyquist(self, omega=omega, ax=ax, **kwargs)
    else:
        raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

iplot_nyquist

iplot_nyquist(omega=None, **kwargs)

Plot interactive Nyquist diagram using Plotly.

Parameters:

Name Type Description Default
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s.

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Interactive Plotly Figure.

Source code in src/ctrlpy/models/base.py
def iplot_nyquist(
    self,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Plot interactive Nyquist diagram using Plotly.

    Parameters
    ----------
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s.
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure.
    """
    from ctrlpy.plotting_plotly import iplot_nyquist

    return iplot_nyquist(self, omega=omega, **kwargs)

plot_root_locus

plot_root_locus(
    gains=None, backend="matplotlib", ax=None, **kwargs
)

Plot Root Locus diagram using Matplotlib or Plotly.

Parameters:

Name Type Description Default
gains Sequence[float] | NDArray[floating] | None

Gain values k >= 0.

None
backend ('matplotlib', 'plotly')

Plotting backend, default is "matplotlib".

"matplotlib"
ax Axes | None

Matplotlib Axes (for backend="matplotlib").

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
tuple[Figure, Axes] | Figure

Matplotlib (fig, ax) or Plotly Figure.

Source code in src/ctrlpy/models/base.py
def plot_root_locus(
    self,
    gains: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    ax: Axes | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes] | go.Figure:
    """Plot Root Locus diagram using Matplotlib or Plotly.

    Parameters
    ----------
    gains : Sequence[float] | NDArray[np.floating] | None, optional
        Gain values k >= 0.
    backend : {"matplotlib", "plotly"}, optional
        Plotting backend, default is "matplotlib".
    ax : Axes | None, optional
        Matplotlib Axes (for backend="matplotlib").
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    tuple[Figure, Axes] | go.Figure
        Matplotlib (fig, ax) or Plotly Figure.
    """
    if backend == "plotly":
        from ctrlpy.plotting_plotly import iplot_root_locus

        return iplot_root_locus(self, gains=gains, **kwargs)
    elif backend == "matplotlib":
        from ctrlpy.plotting import plot_root_locus

        return plot_root_locus(self, gains=gains, ax=ax, **kwargs)
    else:
        raise ValueError(f"Unknown backend '{backend}'. Supported: 'matplotlib', 'plotly'.")

iplot_root_locus

iplot_root_locus(gains=None, **kwargs)

Plot interactive Root Locus diagram using Plotly.

Parameters:

Name Type Description Default
gains Sequence[float] | NDArray[floating] | None

Gain values k >= 0.

None
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Interactive Plotly Figure.

Source code in src/ctrlpy/models/base.py
def iplot_root_locus(
    self,
    gains: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Plot interactive Root Locus diagram using Plotly.

    Parameters
    ----------
    gains : Sequence[float] | NDArray[np.floating] | None, optional
        Gain values k >= 0.
    **kwargs : Any
        Additional keyword arguments.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure.
    """
    from ctrlpy.plotting_plotly import iplot_root_locus

    return iplot_root_locus(self, gains=gains, **kwargs)

transfer_function

Transfer Function representation for Linear Time-Invariant (LTI) systems.

tf module-attribute

tf = TransferFunction

TransferFunction

Bases: LinearTimeInvariant

Continuous-time Linear Time-Invariant Transfer Function representation.

Represents a rational polynomial transfer function in Laplace variable \(s\):

.. math::

G(s) = \frac{N(s)}{D(s)} = \frac{b_m s^m + b_{m-1} s^{m-1} + \cdots + b_1 s + b_0}{a_n s^n + a_{n-1} s^{n-1} + \cdots + a_1 s + a_0}

Parameters:

Name Type Description Default
num Sequence[float] | NDArray[floating] | float

Numerator polynomial coefficients \(N(s)\) in descending powers of \(s\).

required
den Sequence[float] | NDArray[floating] | float

Denominator polynomial coefficients \(D(s)\) in descending powers of \(s\). Defaults to (1.0,).

(1.0,)

Raises:

Type Description
ValueError

If numerator or denominator is empty, or denominator is identically zero.

Source code in src/ctrlpy/models/transfer_function.py
class TransferFunction(LinearTimeInvariant):
    r"""Continuous-time Linear Time-Invariant Transfer Function representation.

    Represents a rational polynomial transfer function in Laplace variable $s$:

    .. math::

        G(s) = \frac{N(s)}{D(s)} = \frac{b_m s^m + b_{m-1} s^{m-1} + \cdots + b_1 s + b_0}{a_n s^n + a_{n-1} s^{n-1} + \cdots + a_1 s + a_0}

    Parameters
    ----------
    num : Sequence[float] | NDArray[np.floating] | float
        Numerator polynomial coefficients $N(s)$ in descending powers of $s$.
    den : Sequence[float] | NDArray[np.floating] | float, optional
        Denominator polynomial coefficients $D(s)$ in descending powers of $s$.
        Defaults to `(1.0,)`.

    Raises
    ------
    ValueError
        If numerator or denominator is empty, or denominator is identically zero.
    """

    def __init__(
        self,
        num: Sequence[float] | NDArray[np.floating] | float,
        den: Sequence[float] | NDArray[np.floating] | float = (1.0,),
    ) -> None:
        num_arr = np.asarray(num, dtype=np.float64).ravel()
        den_arr = np.asarray(den, dtype=np.float64).ravel()

        if num_arr.size == 0:
            raise ValueError("Numerator cannot be empty.")
        if den_arr.size == 0:
            raise ValueError("Denominator cannot be empty.")

        # Trim leading zeros from denominator
        den_nonzero = np.flatnonzero(den_arr)
        if den_nonzero.size == 0:
            raise ValueError("Denominator cannot be identically zero.")
        den_arr = den_arr[den_nonzero[0] :]

        # Trim leading zeros from numerator
        num_nonzero = np.flatnonzero(num_arr)
        if num_nonzero.size == 0:
            num_arr = np.array([0.0], dtype=np.float64)
        else:
            num_arr = num_arr[num_nonzero[0] :]

        # Normalize by leading coefficient of denominator
        lead = den_arr[0]
        self._num: NDArray[np.float64] = num_arr / lead
        self._den: NDArray[np.float64] = den_arr / lead

    @property
    def num(self) -> NDArray[np.float64]:
        """Numerator polynomial coefficients."""
        return self._num

    @property
    def den(self) -> NDArray[np.float64]:
        """Denominator polynomial coefficients."""
        return self._den

    @property
    def inputs(self) -> int:
        """Number of inputs (1 for SISO transfer function)."""
        return 1

    @property
    def outputs(self) -> int:
        """Number of outputs (1 for SISO transfer function)."""
        return 1

    def poles(self) -> NDArray[np.complex128]:
        """Compute the poles of the transfer function.

        Returns
        -------
        NDArray[np.complex128]
            Array of pole locations in the complex s-plane.
        """
        if len(self._den) <= 1:
            return np.array([], dtype=np.complex128)
        return np.roots(self._den).astype(np.complex128)

    def zeros(self) -> NDArray[np.complex128]:
        """Compute the zeros of the transfer function.

        Returns
        -------
        NDArray[np.complex128]
            Array of zero locations in the complex s-plane.
        """
        if len(self._num) <= 1 or np.all(np.isclose(self._num, 0.0)):
            return np.array([], dtype=np.complex128)
        return np.roots(self._num).astype(np.complex128)

    def to_ss(self) -> StateSpace:
        """Convert transfer function to StateSpace representation.

        Returns
        -------
        StateSpace
            Equivalent state-space model.

        Raises
        ------
        ValueError
            If the transfer function is improper (degree of num > degree of den).
        """
        from ctrlpy.models.state_space import StateSpace

        if len(self._num) > len(self._den):
            raise ValueError(
                f"Improper transfer function (num degree {len(self._num) - 1} > "
                f"den degree {len(self._den) - 1}) cannot be converted to standard state-space."
            )

        a, b, c, d = signal.tf2ss(self._num, self._den)
        return StateSpace(a, b, c, d)

    def __add__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Parallel interconnection (self + other)."""
        from ctrlpy.models.state_space import StateSpace

        if isinstance(other, (int, float, np.number)):
            k = float(other)
            num = np.polyadd(self._num, k * self._den)
            return TransferFunction(num, self._den)

        if isinstance(other, TransferFunction):
            num = np.polyadd(
                np.polymul(self._num, other._den),
                np.polymul(other._num, self._den),
            )
            den = np.polymul(self._den, other._den)
            return TransferFunction(num, den)

        if isinstance(other, StateSpace):
            return self.to_ss() + other

        return NotImplemented

    def __radd__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Parallel interconnection with left operand (other + self)."""
        return self.__add__(other)

    def __neg__(self) -> TransferFunction:
        """Negate the transfer function (-self)."""
        return TransferFunction(-self._num, self._den)

    def __pos__(self) -> TransferFunction:
        """Positive representation (+self)."""
        return TransferFunction(self._num.copy(), self._den.copy())

    def __sub__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Subtract systems (self - other)."""
        from ctrlpy.models.state_space import StateSpace

        if isinstance(other, (int, float, np.number)):
            return self.__add__(-float(other))

        if isinstance(other, TransferFunction):
            return self.__add__(-other)

        if isinstance(other, StateSpace):
            return self.to_ss() - other

        return NotImplemented

    def __rsub__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Subtract from other system (other - self)."""
        from ctrlpy.models.state_space import StateSpace

        if isinstance(other, (int, float, np.number)):
            return TransferFunction(float(other), 1.0) - self

        if isinstance(other, StateSpace):
            return other - self.to_ss()

        return NotImplemented

    def __mul__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Series (cascade) interconnection (self * other)."""
        from ctrlpy.models.state_space import StateSpace

        if isinstance(other, (int, float, np.number)):
            return TransferFunction(float(other) * self._num, self._den)

        if isinstance(other, TransferFunction):
            num = np.polymul(self._num, other._num)
            den = np.polymul(self._den, other._den)
            return TransferFunction(num, den)

        if isinstance(other, StateSpace):
            return self.to_ss() * other

        return NotImplemented

    def __rmul__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
        """Series (cascade) interconnection with left operand (other * self)."""
        return self.__mul__(other)

    def __truediv__(self, other: TransferFunction | float | np.number[Any]) -> TransferFunction:
        """Divide transfer functions (self / other)."""
        if isinstance(other, (int, float, np.number)):
            k = float(other)
            if np.isclose(k, 0.0):
                raise ZeroDivisionError("Cannot divide TransferFunction by zero.")
            return TransferFunction(self._num, self._den * k)

        if isinstance(other, TransferFunction):
            num = np.polymul(self._num, other._den)
            den = np.polymul(self._den, other._num)
            return TransferFunction(num, den)

        return NotImplemented

    def __rtruediv__(self, other: TransferFunction | float | np.number[Any]) -> TransferFunction:
        """Divide other by self (other / self)."""
        if isinstance(other, (int, float, np.number)):
            return TransferFunction(float(other), 1.0) / self

        return NotImplemented

    def feedback(
        self,
        other: TransferFunction | StateSpace | float | np.number[Any] = 1,
        sign: float = -1,
    ) -> Any:
        """Compute the closed-loop feedback interconnection.

        T(s) = self / (1 - sign * self * other)

        Parameters
        ----------
        other : TransferFunction | StateSpace | float | int, optional
            Feedback path transfer function or gain H(s), defaults to 1 (unity feedback).
        sign : int | float, optional
            Sign of feedback summation. Defaults to -1 for standard negative feedback.

        Returns
        -------
        TransferFunction | StateSpace
            Closed-loop system representation.
        """
        from ctrlpy.models.state_space import StateSpace

        if isinstance(other, StateSpace):
            return self.to_ss().feedback(other, sign=sign)

        if isinstance(other, (int, float, np.number)):
            num_h = np.array([float(other)], dtype=np.float64)
            den_h = np.array([1.0], dtype=np.float64)
        elif isinstance(other, TransferFunction):
            num_h = other._num
            den_h = other._den
        else:
            raise TypeError(f"Unsupported feedback block type: {type(other).__name__}")

        num = np.polymul(self._num, den_h)
        den = np.polyadd(
            np.polymul(self._den, den_h),
            -float(sign) * np.polymul(self._num, num_h),
        )
        return TransferFunction(num, den)

    def __repr__(self) -> str:
        """Return a string representation suitable for reproduction."""
        return f"TransferFunction(num={self._num.tolist()}, den={self._den.tolist()})"

    def __str__(self) -> str:
        """Return a formatted string representing the rational function in 's'."""
        num_str = _format_poly_str(self._num)
        den_str = _format_poly_str(self._den)
        width = max(len(num_str), len(den_str)) + 2
        dash_line = "-" * width
        return f"{num_str.center(width)}\n{dash_line}\n{den_str.center(width)}"

    def _repr_latex_(self) -> str:
        """Return a LaTeX representation for Jupyter environments."""
        num_latex = _format_poly_latex(self._num)
        den_latex = _format_poly_latex(self._den)
        return f"$$\\frac{{{num_latex}}}{{{den_latex}}}$$"

    def _repr_markdown_(self) -> str:
        """Return a Markdown representation for Jupyter environments."""
        return self._repr_latex_()

num property

num

Numerator polynomial coefficients.

den property

den

Denominator polynomial coefficients.

inputs property

inputs

Number of inputs (1 for SISO transfer function).

outputs property

outputs

Number of outputs (1 for SISO transfer function).

poles

poles()

Compute the poles of the transfer function.

Returns:

Type Description
NDArray[complex128]

Array of pole locations in the complex s-plane.

Source code in src/ctrlpy/models/transfer_function.py
def poles(self) -> NDArray[np.complex128]:
    """Compute the poles of the transfer function.

    Returns
    -------
    NDArray[np.complex128]
        Array of pole locations in the complex s-plane.
    """
    if len(self._den) <= 1:
        return np.array([], dtype=np.complex128)
    return np.roots(self._den).astype(np.complex128)

zeros

zeros()

Compute the zeros of the transfer function.

Returns:

Type Description
NDArray[complex128]

Array of zero locations in the complex s-plane.

Source code in src/ctrlpy/models/transfer_function.py
def zeros(self) -> NDArray[np.complex128]:
    """Compute the zeros of the transfer function.

    Returns
    -------
    NDArray[np.complex128]
        Array of zero locations in the complex s-plane.
    """
    if len(self._num) <= 1 or np.all(np.isclose(self._num, 0.0)):
        return np.array([], dtype=np.complex128)
    return np.roots(self._num).astype(np.complex128)

to_ss

to_ss()

Convert transfer function to StateSpace representation.

Returns:

Type Description
StateSpace

Equivalent state-space model.

Raises:

Type Description
ValueError

If the transfer function is improper (degree of num > degree of den).

Source code in src/ctrlpy/models/transfer_function.py
def to_ss(self) -> StateSpace:
    """Convert transfer function to StateSpace representation.

    Returns
    -------
    StateSpace
        Equivalent state-space model.

    Raises
    ------
    ValueError
        If the transfer function is improper (degree of num > degree of den).
    """
    from ctrlpy.models.state_space import StateSpace

    if len(self._num) > len(self._den):
        raise ValueError(
            f"Improper transfer function (num degree {len(self._num) - 1} > "
            f"den degree {len(self._den) - 1}) cannot be converted to standard state-space."
        )

    a, b, c, d = signal.tf2ss(self._num, self._den)
    return StateSpace(a, b, c, d)

__add__

__add__(other)

Parallel interconnection (self + other).

Source code in src/ctrlpy/models/transfer_function.py
def __add__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Parallel interconnection (self + other)."""
    from ctrlpy.models.state_space import StateSpace

    if isinstance(other, (int, float, np.number)):
        k = float(other)
        num = np.polyadd(self._num, k * self._den)
        return TransferFunction(num, self._den)

    if isinstance(other, TransferFunction):
        num = np.polyadd(
            np.polymul(self._num, other._den),
            np.polymul(other._num, self._den),
        )
        den = np.polymul(self._den, other._den)
        return TransferFunction(num, den)

    if isinstance(other, StateSpace):
        return self.to_ss() + other

    return NotImplemented

__radd__

__radd__(other)

Parallel interconnection with left operand (other + self).

Source code in src/ctrlpy/models/transfer_function.py
def __radd__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Parallel interconnection with left operand (other + self)."""
    return self.__add__(other)

__neg__

__neg__()

Negate the transfer function (-self).

Source code in src/ctrlpy/models/transfer_function.py
def __neg__(self) -> TransferFunction:
    """Negate the transfer function (-self)."""
    return TransferFunction(-self._num, self._den)

__pos__

__pos__()

Positive representation (+self).

Source code in src/ctrlpy/models/transfer_function.py
def __pos__(self) -> TransferFunction:
    """Positive representation (+self)."""
    return TransferFunction(self._num.copy(), self._den.copy())

__sub__

__sub__(other)

Subtract systems (self - other).

Source code in src/ctrlpy/models/transfer_function.py
def __sub__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Subtract systems (self - other)."""
    from ctrlpy.models.state_space import StateSpace

    if isinstance(other, (int, float, np.number)):
        return self.__add__(-float(other))

    if isinstance(other, TransferFunction):
        return self.__add__(-other)

    if isinstance(other, StateSpace):
        return self.to_ss() - other

    return NotImplemented

__rsub__

__rsub__(other)

Subtract from other system (other - self).

Source code in src/ctrlpy/models/transfer_function.py
def __rsub__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Subtract from other system (other - self)."""
    from ctrlpy.models.state_space import StateSpace

    if isinstance(other, (int, float, np.number)):
        return TransferFunction(float(other), 1.0) - self

    if isinstance(other, StateSpace):
        return other - self.to_ss()

    return NotImplemented

__mul__

__mul__(other)

Series (cascade) interconnection (self * other).

Source code in src/ctrlpy/models/transfer_function.py
def __mul__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Series (cascade) interconnection (self * other)."""
    from ctrlpy.models.state_space import StateSpace

    if isinstance(other, (int, float, np.number)):
        return TransferFunction(float(other) * self._num, self._den)

    if isinstance(other, TransferFunction):
        num = np.polymul(self._num, other._num)
        den = np.polymul(self._den, other._den)
        return TransferFunction(num, den)

    if isinstance(other, StateSpace):
        return self.to_ss() * other

    return NotImplemented

__rmul__

__rmul__(other)

Series (cascade) interconnection with left operand (other * self).

Source code in src/ctrlpy/models/transfer_function.py
def __rmul__(self, other: TransferFunction | StateSpace | float | np.number[Any]) -> Any:
    """Series (cascade) interconnection with left operand (other * self)."""
    return self.__mul__(other)

__truediv__

__truediv__(other)

Divide transfer functions (self / other).

Source code in src/ctrlpy/models/transfer_function.py
def __truediv__(self, other: TransferFunction | float | np.number[Any]) -> TransferFunction:
    """Divide transfer functions (self / other)."""
    if isinstance(other, (int, float, np.number)):
        k = float(other)
        if np.isclose(k, 0.0):
            raise ZeroDivisionError("Cannot divide TransferFunction by zero.")
        return TransferFunction(self._num, self._den * k)

    if isinstance(other, TransferFunction):
        num = np.polymul(self._num, other._den)
        den = np.polymul(self._den, other._num)
        return TransferFunction(num, den)

    return NotImplemented

__rtruediv__

__rtruediv__(other)

Divide other by self (other / self).

Source code in src/ctrlpy/models/transfer_function.py
def __rtruediv__(self, other: TransferFunction | float | np.number[Any]) -> TransferFunction:
    """Divide other by self (other / self)."""
    if isinstance(other, (int, float, np.number)):
        return TransferFunction(float(other), 1.0) / self

    return NotImplemented

feedback

feedback(other=1, sign=-1)

Compute the closed-loop feedback interconnection.

T(s) = self / (1 - sign * self * other)

Parameters:

Name Type Description Default
other TransferFunction | StateSpace | float | int

Feedback path transfer function or gain H(s), defaults to 1 (unity feedback).

1
sign int | float

Sign of feedback summation. Defaults to -1 for standard negative feedback.

-1

Returns:

Type Description
TransferFunction | StateSpace

Closed-loop system representation.

Source code in src/ctrlpy/models/transfer_function.py
def feedback(
    self,
    other: TransferFunction | StateSpace | float | np.number[Any] = 1,
    sign: float = -1,
) -> Any:
    """Compute the closed-loop feedback interconnection.

    T(s) = self / (1 - sign * self * other)

    Parameters
    ----------
    other : TransferFunction | StateSpace | float | int, optional
        Feedback path transfer function or gain H(s), defaults to 1 (unity feedback).
    sign : int | float, optional
        Sign of feedback summation. Defaults to -1 for standard negative feedback.

    Returns
    -------
    TransferFunction | StateSpace
        Closed-loop system representation.
    """
    from ctrlpy.models.state_space import StateSpace

    if isinstance(other, StateSpace):
        return self.to_ss().feedback(other, sign=sign)

    if isinstance(other, (int, float, np.number)):
        num_h = np.array([float(other)], dtype=np.float64)
        den_h = np.array([1.0], dtype=np.float64)
    elif isinstance(other, TransferFunction):
        num_h = other._num
        den_h = other._den
    else:
        raise TypeError(f"Unsupported feedback block type: {type(other).__name__}")

    num = np.polymul(self._num, den_h)
    den = np.polyadd(
        np.polymul(self._den, den_h),
        -float(sign) * np.polymul(self._num, num_h),
    )
    return TransferFunction(num, den)

__repr__

__repr__()

Return a string representation suitable for reproduction.

Source code in src/ctrlpy/models/transfer_function.py
def __repr__(self) -> str:
    """Return a string representation suitable for reproduction."""
    return f"TransferFunction(num={self._num.tolist()}, den={self._den.tolist()})"

__str__

__str__()

Return a formatted string representing the rational function in 's'.

Source code in src/ctrlpy/models/transfer_function.py
def __str__(self) -> str:
    """Return a formatted string representing the rational function in 's'."""
    num_str = _format_poly_str(self._num)
    den_str = _format_poly_str(self._den)
    width = max(len(num_str), len(den_str)) + 2
    dash_line = "-" * width
    return f"{num_str.center(width)}\n{dash_line}\n{den_str.center(width)}"

state_space

State-Space representation for Linear Time-Invariant (LTI) systems.

ss module-attribute

ss = StateSpace

StateSpace

Bases: LinearTimeInvariant

Continuous-time Linear Time-Invariant State-Space representation.

Represents dynamic systems in continuous-time state-space form:

.. math::

\begin{aligned}
\dot{x}(t) &= A x(t) + B u(t) \\
y(t) &= C x(t) + D u(t)
\end{aligned}

where \(x(t) \in \mathbb{R}^n\) is the state vector, \(u(t) \in \mathbb{R}^m\) is the input vector, and \(y(t) \in \mathbb{R}^p\) is the output vector.

Parameters:

Name Type Description Default
A ArrayLike

State transition matrix \(A \in \mathbb{R}^{n \times n}\).

required
B ArrayLike

Input matrix \(B \in \mathbb{R}^{n \times m}\).

required
C ArrayLike

Output matrix \(C \in \mathbb{R}^{p \times n}\).

required
D ArrayLike

Direct feedthrough matrix \(D \in \mathbb{R}^{p \times m}\).

required

Raises:

Type Description
DimensionMismatchError

If matrix dimensions are incompatible.

Source code in src/ctrlpy/models/state_space.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
class StateSpace(LinearTimeInvariant):
    r"""Continuous-time Linear Time-Invariant State-Space representation.

    Represents dynamic systems in continuous-time state-space form:

    .. math::

        \begin{aligned}
        \dot{x}(t) &= A x(t) + B u(t) \\
        y(t) &= C x(t) + D u(t)
        \end{aligned}

    where $x(t) \in \mathbb{R}^n$ is the state vector, $u(t) \in \mathbb{R}^m$ is the input vector,
    and $y(t) \in \mathbb{R}^p$ is the output vector.

    Parameters
    ----------
    A : ArrayLike
        State transition matrix $A \in \mathbb{R}^{n \times n}$.
    B : ArrayLike
        Input matrix $B \in \mathbb{R}^{n \times m}$.
    C : ArrayLike
        Output matrix $C \in \mathbb{R}^{p \times n}$.
    D : ArrayLike
        Direct feedthrough matrix $D \in \mathbb{R}^{p \times m}$.

    Raises
    ------
    DimensionMismatchError
        If matrix dimensions are incompatible.
    """

    def __init__(
        self,
        A: ArrayLike,
        B: ArrayLike,
        C: ArrayLike,
        D: ArrayLike,
    ) -> None:
        self._A, self._B, self._C, self._D = self._validate_and_convert(A, B, C, D)

    @staticmethod
    def _validate_and_convert(
        A: ArrayLike,
        B: ArrayLike,
        C: ArrayLike,
        D: ArrayLike,
    ) -> tuple[
        NDArray[np.float64],
        NDArray[np.float64],
        NDArray[np.float64],
        NDArray[np.float64],
    ]:
        a_arr = np.asarray(A, dtype=np.float64)
        b_arr = np.asarray(B, dtype=np.float64)
        c_arr = np.asarray(C, dtype=np.float64)
        d_arr = np.asarray(D, dtype=np.float64)

        if a_arr.ndim == 0 or (a_arr.ndim == 1 and a_arr.size == 1):
            a_arr = a_arr.reshape((1, 1))

        if a_arr.ndim != 2 or a_arr.shape[0] != a_arr.shape[1]:
            raise DimensionMismatchError(
                f"Matrix A must be a square 2D array, got shape {a_arr.shape}."
            )

        n = a_arr.shape[0]

        if b_arr.ndim == 0:
            if n == 1:
                b_arr = b_arr.reshape((1, 1))
            else:
                raise DimensionMismatchError(
                    f"Scalar B is incompatible with A of shape {a_arr.shape}."
                )
        elif b_arr.ndim == 1:
            if b_arr.shape[0] == n:
                b_arr = b_arr.reshape((n, 1))
            elif n == 1:
                b_arr = b_arr.reshape((1, -1))
            else:
                raise DimensionMismatchError(
                    f"1D array B of length {b_arr.shape[0]} is incompatible "
                    f"with A of shape {a_arr.shape}."
                )

        if b_arr.ndim != 2 or b_arr.shape[0] != n:
            raise DimensionMismatchError(
                f"Matrix B must have {n} rows matching A, got shape {b_arr.shape}."
            )

        m = b_arr.shape[1]

        if c_arr.ndim == 0:
            if n == 1:
                c_arr = c_arr.reshape((1, 1))
            else:
                raise DimensionMismatchError(
                    f"Scalar C is incompatible with A of shape {a_arr.shape}."
                )
        elif c_arr.ndim == 1:
            if c_arr.shape[0] == n:
                c_arr = c_arr.reshape((1, n))
            elif n == 1:
                c_arr = c_arr.reshape((-1, 1))
            else:
                raise DimensionMismatchError(
                    f"1D array C of length {c_arr.shape[0]} is incompatible "
                    f"with A of shape {a_arr.shape}."
                )

        if c_arr.ndim != 2 or c_arr.shape[1] != n:
            raise DimensionMismatchError(
                f"Matrix C must have {n} columns matching A, got shape {c_arr.shape}."
            )

        p = c_arr.shape[0]

        if d_arr.ndim == 0:
            d_arr = np.full((p, m), d_arr.item(), dtype=np.float64)
        elif d_arr.ndim == 1:
            if d_arr.size == p * m:
                d_arr = d_arr.reshape((p, m))
            elif d_arr.size == 1:
                d_arr = np.full((p, m), d_arr[0], dtype=np.float64)
            else:
                raise DimensionMismatchError(
                    f"1D array D of size {d_arr.size} is incompatible with "
                    f"expected shape ({p}, {m})."
                )

        if d_arr.ndim != 2 or d_arr.shape != (p, m):
            raise DimensionMismatchError(
                f"Matrix D must have shape ({p}, {m}), got shape {d_arr.shape}."
            )

        return a_arr, b_arr, c_arr, d_arr

    @property
    def A(self) -> NDArray[np.float64]:
        """State transition matrix (n x n)."""
        return self._A

    @property
    def B(self) -> NDArray[np.float64]:
        """Input matrix (n x m)."""
        return self._B

    @property
    def C(self) -> NDArray[np.float64]:
        """Output matrix (p x n)."""
        return self._C

    @property
    def D(self) -> NDArray[np.float64]:
        """Direct feedthrough matrix (p x m)."""
        return self._D

    @property
    def inputs(self) -> int:
        """Number of inputs (m)."""
        return int(self._B.shape[1])

    @property
    def outputs(self) -> int:
        """Number of outputs (p)."""
        return int(self._C.shape[0])

    @property
    def n_states(self) -> int:
        """Number of state variables (n)."""
        return int(self._A.shape[0])

    def poles(self) -> NDArray[np.complex128]:
        """Compute the poles (eigenvalues of A) of the state-space system.

        Returns
        -------
        NDArray[np.complex128]
            1D array of system poles.
        """
        return np.linalg.eigvals(self._A).astype(np.complex128)

    def zeros(self) -> NDArray[np.complex128]:
        """Compute the zeros of the state-space system.

        Returns
        -------
        NDArray[np.complex128]
            1D array of system zeros.

        Raises
        ------
        NotImplementedError
            If the system is not SISO.
        """
        if not self.is_siso:
            raise NotImplementedError("zeros() is currently supported for SISO systems.")
        return self.to_tf().zeros()

    def to_tf(self, input_index: int = 0, output_index: int = 0) -> TransferFunction:
        """Convert state-space model to TransferFunction representation.

        Parameters
        ----------
        input_index : int, optional
            Index of the input channel for multi-input systems, by default 0.
        output_index : int, optional
            Index of the output channel for multi-output systems, by default 0.

        Returns
        -------
        TransferFunction
            Equivalent transfer function representation for the specified input/output channel.

        Raises
        ------
        IndexError
            If input_index or output_index is out of range.
        """
        from ctrlpy.models.transfer_function import TransferFunction

        if not (0 <= input_index < self.inputs):
            raise IndexError(f"input_index {input_index} out of range for {self.inputs} inputs.")
        if not (0 <= output_index < self.outputs):
            raise IndexError(
                f"output_index {output_index} out of range for {self.outputs} outputs."
            )

        num, den = signal.ss2tf(self._A, self._B, self._C, self._D, input=input_index)
        num_channel = num[output_index]

        # Clean numerical precision artifacts
        max_coeff = max(
            float(np.max(np.abs(num_channel))),
            float(np.max(np.abs(den))),
            1.0,
        )
        tol = 1e-12 * max_coeff
        num_channel = np.where(np.isclose(num_channel, 0.0, atol=tol), 0.0, num_channel)
        den = np.where(np.isclose(den, 0.0, atol=tol), 0.0, den)

        return TransferFunction(num_channel, den)

    def __add__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Parallel interconnection of StateSpace systems (self + other)."""
        from ctrlpy.models.transfer_function import TransferFunction

        if isinstance(other, (int, float, np.number)):
            k = float(other)
            if self.is_siso:
                d_add = np.array([[k]], dtype=np.float64)
            elif self.inputs == self.outputs:
                d_add = k * np.eye(self.inputs, dtype=np.float64)
            else:
                raise ValueError("Cannot add scalar to non-square MIMO StateSpace system.")
            return StateSpace(self._A.copy(), self._B.copy(), self._C.copy(), self._D + d_add)

        if isinstance(other, TransferFunction):
            return self.__add__(other.to_ss())

        if isinstance(other, StateSpace):
            if self.inputs != other.inputs or self.outputs != other.outputs:
                raise ValueError(
                    "Cannot add StateSpace systems with incompatible inputs/outputs: "
                    f"({self.inputs}, {self.outputs}) vs ({other.inputs}, {other.outputs})."
                )
            n1 = self.n_states
            n2 = other.n_states
            a_block = np.block(
                [
                    [self._A, np.zeros((n1, n2), dtype=np.float64)],
                    [np.zeros((n2, n1), dtype=np.float64), other._A],
                ]
            )
            b_block = np.vstack([self._B, other._B])
            c_block = np.hstack([self._C, other._C])
            d_block = self._D + other._D
            return StateSpace(a_block, b_block, c_block, d_block)

        return NotImplemented

    def __radd__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Parallel interconnection with left operand (other + self)."""
        return self.__add__(other)

    def __neg__(self) -> StateSpace:
        """Negate the StateSpace system (-self)."""
        return StateSpace(self._A.copy(), self._B.copy(), -self._C, -self._D)

    def __pos__(self) -> StateSpace:
        """Positive representation (+self)."""
        return StateSpace(self._A.copy(), self._B.copy(), self._C.copy(), self._D.copy())

    def __sub__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Subtract systems (self - other)."""
        if isinstance(other, (int, float, np.number)):
            return self.__add__(-float(other))

        if isinstance(other, (StateSpace, LinearTimeInvariant)):
            return self.__add__(-other)

        return NotImplemented

    def __rsub__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Subtract StateSpace system from other (other - self)."""
        if isinstance(other, (int, float, np.number, StateSpace, LinearTimeInvariant)):
            return (-self).__add__(other)

        return NotImplemented

    def __mul__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Series (cascade) interconnection: u -> other -> self -> y."""
        from ctrlpy.models.transfer_function import TransferFunction

        if isinstance(other, (int, float, np.number)):
            k = float(other)
            return StateSpace(self._A.copy(), self._B.copy(), k * self._C, k * self._D)

        if isinstance(other, TransferFunction):
            return self.__mul__(other.to_ss())

        if isinstance(other, StateSpace):
            if other.outputs != self.inputs:
                raise ValueError(
                    "Cannot cascade StateSpace systems: output dimension of "
                    f"first system ({other.outputs}) must match input dimension "
                    f"of second system ({self.inputs})."
                )
            a1, b1, c1, d1 = self._A, self._B, self._C, self._D
            a2, b2, c2, d2 = other._A, other._B, other._C, other._D
            n1 = self.n_states
            n2 = other.n_states

            a_block = np.block(
                [
                    [a1, b1 @ c2],
                    [np.zeros((n2, n1), dtype=np.float64), a2],
                ]
            )
            b_block = np.vstack([b1 @ d2, b2])
            c_block = np.hstack([c1, d1 @ c2])
            d_block = d1 @ d2
            return StateSpace(a_block, b_block, c_block, d_block)

        return NotImplemented

    def __rmul__(
        self,
        other: StateSpace | TransferFunction | float | np.number[Any],
    ) -> StateSpace:
        """Series (cascade) interconnection with left operand."""
        from ctrlpy.models.transfer_function import TransferFunction

        if isinstance(other, (int, float, np.number)):
            k = float(other)
            return StateSpace(self._A.copy(), self._B.copy(), k * self._C, k * self._D)

        if isinstance(other, TransferFunction):
            return other.to_ss() * self

        return NotImplemented

    def feedback(
        self,
        other: (StateSpace | TransferFunction | float | np.number[Any] | None) = None,
        sign: float = -1,
    ) -> StateSpace:
        """Compute the closed-loop feedback interconnection in state-space.

        Parameters
        ----------
        other : StateSpace | TransferFunction | float | int | None, optional
            Feedback path system H, defaults to None (unity feedback identity gain).
        sign : int | float, optional
            Sign of feedback summation. Defaults to -1 for standard negative feedback.

        Returns
        -------
        StateSpace
            Closed-loop state-space system.

        Raises
        ------
        ValueError
            If matrix dimensions are incompatible or if (I - sign*D1*D2) is singular.
        """
        from ctrlpy.models.transfer_function import TransferFunction

        if isinstance(other, TransferFunction):
            return self.feedback(other.to_ss(), sign=sign)

        s = float(sign)
        p = self.outputs
        m = self.inputs
        a1, b1, c1, d1 = self._A, self._B, self._C, self._D

        if other is None or isinstance(other, (int, float, np.number)):
            k = 1.0 if other is None else float(other)
            if self.is_siso:
                d2 = np.array([[k]], dtype=np.float64)
            else:
                d2 = k * np.eye(m, p, dtype=np.float64)

            i_p = np.eye(p, dtype=np.float64)
            i_m = np.eye(m, dtype=np.float64)
            m_p = i_p - s * (d1 @ d2)
            m_m = i_m - s * (d2 @ d1)

            try:
                e = np.linalg.inv(m_p)
                e_tilde = np.linalg.inv(m_m)
            except np.linalg.LinAlgError as err:
                raise ValueError("Well-posedness error: (I - sign*D1*D2) is singular.") from err

            a_cl = a1 + s * (b1 @ e_tilde @ d2 @ c1)
            b_cl = b1 @ e_tilde
            c_cl = e @ c1
            d_cl = e @ d1
            return StateSpace(a_cl, b_cl, c_cl, d_cl)

        if isinstance(other, StateSpace):
            if other.inputs != self.outputs or other.outputs != self.inputs:
                raise ValueError(
                    "Feedback connection dimension mismatch: forward system shape "
                    f"(outputs={self.outputs}, inputs={self.inputs}) vs feedback system "
                    f"shape (inputs={other.inputs}, outputs={other.outputs})."
                )

            a2, b2, c2, d2 = other._A, other._B, other._C, other._D
            i_p = np.eye(p, dtype=np.float64)
            i_m = np.eye(m, dtype=np.float64)
            m_p = i_p - s * (d1 @ d2)
            m_m = i_m - s * (d2 @ d1)

            try:
                e = np.linalg.inv(m_p)
                e_tilde = np.linalg.inv(m_m)
            except np.linalg.LinAlgError as err:
                raise ValueError("Well-posedness error: (I - sign*D1*D2) is singular.") from err

            a_cl = np.block(
                [
                    [a1 + s * (b1 @ e_tilde @ d2 @ c1), s * (b1 @ e_tilde @ c2)],
                    [b2 @ e @ c1, a2 + s * (b2 @ e @ d1 @ c2)],
                ]
            )
            b_cl = np.vstack([b1 @ e_tilde, b2 @ e @ d1])
            c_cl = np.hstack([e @ c1, s * (e @ d1 @ c2)])
            d_cl = e @ d1
            return StateSpace(a_cl, b_cl, c_cl, d_cl)

        raise TypeError(f"Unsupported feedback block type: {type(other).__name__}")

    def __repr__(self) -> str:
        """Return a string representation of the StateSpace object."""
        return (
            f"StateSpace(\n"
            f"  A={self._A.tolist()},\n"
            f"  B={self._B.tolist()},\n"
            f"  C={self._C.tolist()},\n"
            f"  D={self._D.tolist()}\n"
            f")"
        )

    def __str__(self) -> str:
        """Return a formatted string representing the state-space matrices."""
        return f"StateSpace:\nA =\n{self._A}\n\nB =\n{self._B}\n\nC =\n{self._C}\n\nD =\n{self._D}"

    def _repr_latex_(self) -> str:
        """Return a LaTeX representation for Jupyter environments."""
        a_tex = _matrix_to_latex(self._A)
        b_tex = _matrix_to_latex(self._B)
        c_tex = _matrix_to_latex(self._C)
        d_tex = _matrix_to_latex(self._D)
        return (
            r"$$\begin{aligned} "
            rf"\dot{{x}} &= {a_tex} x + {b_tex} u \\ "
            rf"y &= {c_tex} x + {d_tex} u "
            r"\end{aligned}$$"
        )

    def _repr_markdown_(self) -> str:
        """Return a Markdown representation for Jupyter environments."""
        return self._repr_latex_()

A property

A

State transition matrix (n x n).

B property

B

Input matrix (n x m).

C property

C

Output matrix (p x n).

D property

D

Direct feedthrough matrix (p x m).

inputs property

inputs

Number of inputs (m).

outputs property

outputs

Number of outputs (p).

n_states property

n_states

Number of state variables (n).

poles

poles()

Compute the poles (eigenvalues of A) of the state-space system.

Returns:

Type Description
NDArray[complex128]

1D array of system poles.

Source code in src/ctrlpy/models/state_space.py
def poles(self) -> NDArray[np.complex128]:
    """Compute the poles (eigenvalues of A) of the state-space system.

    Returns
    -------
    NDArray[np.complex128]
        1D array of system poles.
    """
    return np.linalg.eigvals(self._A).astype(np.complex128)

zeros

zeros()

Compute the zeros of the state-space system.

Returns:

Type Description
NDArray[complex128]

1D array of system zeros.

Raises:

Type Description
NotImplementedError

If the system is not SISO.

Source code in src/ctrlpy/models/state_space.py
def zeros(self) -> NDArray[np.complex128]:
    """Compute the zeros of the state-space system.

    Returns
    -------
    NDArray[np.complex128]
        1D array of system zeros.

    Raises
    ------
    NotImplementedError
        If the system is not SISO.
    """
    if not self.is_siso:
        raise NotImplementedError("zeros() is currently supported for SISO systems.")
    return self.to_tf().zeros()

to_tf

to_tf(input_index=0, output_index=0)

Convert state-space model to TransferFunction representation.

Parameters:

Name Type Description Default
input_index int

Index of the input channel for multi-input systems, by default 0.

0
output_index int

Index of the output channel for multi-output systems, by default 0.

0

Returns:

Type Description
TransferFunction

Equivalent transfer function representation for the specified input/output channel.

Raises:

Type Description
IndexError

If input_index or output_index is out of range.

Source code in src/ctrlpy/models/state_space.py
def to_tf(self, input_index: int = 0, output_index: int = 0) -> TransferFunction:
    """Convert state-space model to TransferFunction representation.

    Parameters
    ----------
    input_index : int, optional
        Index of the input channel for multi-input systems, by default 0.
    output_index : int, optional
        Index of the output channel for multi-output systems, by default 0.

    Returns
    -------
    TransferFunction
        Equivalent transfer function representation for the specified input/output channel.

    Raises
    ------
    IndexError
        If input_index or output_index is out of range.
    """
    from ctrlpy.models.transfer_function import TransferFunction

    if not (0 <= input_index < self.inputs):
        raise IndexError(f"input_index {input_index} out of range for {self.inputs} inputs.")
    if not (0 <= output_index < self.outputs):
        raise IndexError(
            f"output_index {output_index} out of range for {self.outputs} outputs."
        )

    num, den = signal.ss2tf(self._A, self._B, self._C, self._D, input=input_index)
    num_channel = num[output_index]

    # Clean numerical precision artifacts
    max_coeff = max(
        float(np.max(np.abs(num_channel))),
        float(np.max(np.abs(den))),
        1.0,
    )
    tol = 1e-12 * max_coeff
    num_channel = np.where(np.isclose(num_channel, 0.0, atol=tol), 0.0, num_channel)
    den = np.where(np.isclose(den, 0.0, atol=tol), 0.0, den)

    return TransferFunction(num_channel, den)

__add__

__add__(other)

Parallel interconnection of StateSpace systems (self + other).

Source code in src/ctrlpy/models/state_space.py
def __add__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Parallel interconnection of StateSpace systems (self + other)."""
    from ctrlpy.models.transfer_function import TransferFunction

    if isinstance(other, (int, float, np.number)):
        k = float(other)
        if self.is_siso:
            d_add = np.array([[k]], dtype=np.float64)
        elif self.inputs == self.outputs:
            d_add = k * np.eye(self.inputs, dtype=np.float64)
        else:
            raise ValueError("Cannot add scalar to non-square MIMO StateSpace system.")
        return StateSpace(self._A.copy(), self._B.copy(), self._C.copy(), self._D + d_add)

    if isinstance(other, TransferFunction):
        return self.__add__(other.to_ss())

    if isinstance(other, StateSpace):
        if self.inputs != other.inputs or self.outputs != other.outputs:
            raise ValueError(
                "Cannot add StateSpace systems with incompatible inputs/outputs: "
                f"({self.inputs}, {self.outputs}) vs ({other.inputs}, {other.outputs})."
            )
        n1 = self.n_states
        n2 = other.n_states
        a_block = np.block(
            [
                [self._A, np.zeros((n1, n2), dtype=np.float64)],
                [np.zeros((n2, n1), dtype=np.float64), other._A],
            ]
        )
        b_block = np.vstack([self._B, other._B])
        c_block = np.hstack([self._C, other._C])
        d_block = self._D + other._D
        return StateSpace(a_block, b_block, c_block, d_block)

    return NotImplemented

__radd__

__radd__(other)

Parallel interconnection with left operand (other + self).

Source code in src/ctrlpy/models/state_space.py
def __radd__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Parallel interconnection with left operand (other + self)."""
    return self.__add__(other)

__neg__

__neg__()

Negate the StateSpace system (-self).

Source code in src/ctrlpy/models/state_space.py
def __neg__(self) -> StateSpace:
    """Negate the StateSpace system (-self)."""
    return StateSpace(self._A.copy(), self._B.copy(), -self._C, -self._D)

__pos__

__pos__()

Positive representation (+self).

Source code in src/ctrlpy/models/state_space.py
def __pos__(self) -> StateSpace:
    """Positive representation (+self)."""
    return StateSpace(self._A.copy(), self._B.copy(), self._C.copy(), self._D.copy())

__sub__

__sub__(other)

Subtract systems (self - other).

Source code in src/ctrlpy/models/state_space.py
def __sub__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Subtract systems (self - other)."""
    if isinstance(other, (int, float, np.number)):
        return self.__add__(-float(other))

    if isinstance(other, (StateSpace, LinearTimeInvariant)):
        return self.__add__(-other)

    return NotImplemented

__rsub__

__rsub__(other)

Subtract StateSpace system from other (other - self).

Source code in src/ctrlpy/models/state_space.py
def __rsub__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Subtract StateSpace system from other (other - self)."""
    if isinstance(other, (int, float, np.number, StateSpace, LinearTimeInvariant)):
        return (-self).__add__(other)

    return NotImplemented

__mul__

__mul__(other)

Series (cascade) interconnection: u -> other -> self -> y.

Source code in src/ctrlpy/models/state_space.py
def __mul__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Series (cascade) interconnection: u -> other -> self -> y."""
    from ctrlpy.models.transfer_function import TransferFunction

    if isinstance(other, (int, float, np.number)):
        k = float(other)
        return StateSpace(self._A.copy(), self._B.copy(), k * self._C, k * self._D)

    if isinstance(other, TransferFunction):
        return self.__mul__(other.to_ss())

    if isinstance(other, StateSpace):
        if other.outputs != self.inputs:
            raise ValueError(
                "Cannot cascade StateSpace systems: output dimension of "
                f"first system ({other.outputs}) must match input dimension "
                f"of second system ({self.inputs})."
            )
        a1, b1, c1, d1 = self._A, self._B, self._C, self._D
        a2, b2, c2, d2 = other._A, other._B, other._C, other._D
        n1 = self.n_states
        n2 = other.n_states

        a_block = np.block(
            [
                [a1, b1 @ c2],
                [np.zeros((n2, n1), dtype=np.float64), a2],
            ]
        )
        b_block = np.vstack([b1 @ d2, b2])
        c_block = np.hstack([c1, d1 @ c2])
        d_block = d1 @ d2
        return StateSpace(a_block, b_block, c_block, d_block)

    return NotImplemented

__rmul__

__rmul__(other)

Series (cascade) interconnection with left operand.

Source code in src/ctrlpy/models/state_space.py
def __rmul__(
    self,
    other: StateSpace | TransferFunction | float | np.number[Any],
) -> StateSpace:
    """Series (cascade) interconnection with left operand."""
    from ctrlpy.models.transfer_function import TransferFunction

    if isinstance(other, (int, float, np.number)):
        k = float(other)
        return StateSpace(self._A.copy(), self._B.copy(), k * self._C, k * self._D)

    if isinstance(other, TransferFunction):
        return other.to_ss() * self

    return NotImplemented

feedback

feedback(other=None, sign=-1)

Compute the closed-loop feedback interconnection in state-space.

Parameters:

Name Type Description Default
other StateSpace | TransferFunction | float | int | None

Feedback path system H, defaults to None (unity feedback identity gain).

None
sign int | float

Sign of feedback summation. Defaults to -1 for standard negative feedback.

-1

Returns:

Type Description
StateSpace

Closed-loop state-space system.

Raises:

Type Description
ValueError

If matrix dimensions are incompatible or if (I - signD1D2) is singular.

Source code in src/ctrlpy/models/state_space.py
def feedback(
    self,
    other: (StateSpace | TransferFunction | float | np.number[Any] | None) = None,
    sign: float = -1,
) -> StateSpace:
    """Compute the closed-loop feedback interconnection in state-space.

    Parameters
    ----------
    other : StateSpace | TransferFunction | float | int | None, optional
        Feedback path system H, defaults to None (unity feedback identity gain).
    sign : int | float, optional
        Sign of feedback summation. Defaults to -1 for standard negative feedback.

    Returns
    -------
    StateSpace
        Closed-loop state-space system.

    Raises
    ------
    ValueError
        If matrix dimensions are incompatible or if (I - sign*D1*D2) is singular.
    """
    from ctrlpy.models.transfer_function import TransferFunction

    if isinstance(other, TransferFunction):
        return self.feedback(other.to_ss(), sign=sign)

    s = float(sign)
    p = self.outputs
    m = self.inputs
    a1, b1, c1, d1 = self._A, self._B, self._C, self._D

    if other is None or isinstance(other, (int, float, np.number)):
        k = 1.0 if other is None else float(other)
        if self.is_siso:
            d2 = np.array([[k]], dtype=np.float64)
        else:
            d2 = k * np.eye(m, p, dtype=np.float64)

        i_p = np.eye(p, dtype=np.float64)
        i_m = np.eye(m, dtype=np.float64)
        m_p = i_p - s * (d1 @ d2)
        m_m = i_m - s * (d2 @ d1)

        try:
            e = np.linalg.inv(m_p)
            e_tilde = np.linalg.inv(m_m)
        except np.linalg.LinAlgError as err:
            raise ValueError("Well-posedness error: (I - sign*D1*D2) is singular.") from err

        a_cl = a1 + s * (b1 @ e_tilde @ d2 @ c1)
        b_cl = b1 @ e_tilde
        c_cl = e @ c1
        d_cl = e @ d1
        return StateSpace(a_cl, b_cl, c_cl, d_cl)

    if isinstance(other, StateSpace):
        if other.inputs != self.outputs or other.outputs != self.inputs:
            raise ValueError(
                "Feedback connection dimension mismatch: forward system shape "
                f"(outputs={self.outputs}, inputs={self.inputs}) vs feedback system "
                f"shape (inputs={other.inputs}, outputs={other.outputs})."
            )

        a2, b2, c2, d2 = other._A, other._B, other._C, other._D
        i_p = np.eye(p, dtype=np.float64)
        i_m = np.eye(m, dtype=np.float64)
        m_p = i_p - s * (d1 @ d2)
        m_m = i_m - s * (d2 @ d1)

        try:
            e = np.linalg.inv(m_p)
            e_tilde = np.linalg.inv(m_m)
        except np.linalg.LinAlgError as err:
            raise ValueError("Well-posedness error: (I - sign*D1*D2) is singular.") from err

        a_cl = np.block(
            [
                [a1 + s * (b1 @ e_tilde @ d2 @ c1), s * (b1 @ e_tilde @ c2)],
                [b2 @ e @ c1, a2 + s * (b2 @ e @ d1 @ c2)],
            ]
        )
        b_cl = np.vstack([b1 @ e_tilde, b2 @ e @ d1])
        c_cl = np.hstack([e @ c1, s * (e @ d1 @ c2)])
        d_cl = e @ d1
        return StateSpace(a_cl, b_cl, c_cl, d_cl)

    raise TypeError(f"Unsupported feedback block type: {type(other).__name__}")

__repr__

__repr__()

Return a string representation of the StateSpace object.

Source code in src/ctrlpy/models/state_space.py
def __repr__(self) -> str:
    """Return a string representation of the StateSpace object."""
    return (
        f"StateSpace(\n"
        f"  A={self._A.tolist()},\n"
        f"  B={self._B.tolist()},\n"
        f"  C={self._C.tolist()},\n"
        f"  D={self._D.tolist()}\n"
        f")"
    )

__str__

__str__()

Return a formatted string representing the state-space matrices.

Source code in src/ctrlpy/models/state_space.py
def __str__(self) -> str:
    """Return a formatted string representing the state-space matrices."""
    return f"StateSpace:\nA =\n{self._A}\n\nB =\n{self._B}\n\nC =\n{self._C}\n\nD =\n{self._D}"