Skip to content

fix(autotune): closed-loop simulation and stability margins - #56

Merged
bresch merged 6 commits into
masterfrom
fix/autotune-arx-numerator-padding
Sep 29, 2026
Merged

bresch merged 6 commits into
masterfrom
fix/autotune-arx-numerator-padding

Conversation

@bresch

@bresch bresch commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes to the closed-loop simulation and the stability margins, which could be very optimistic and report comfortable margins for an unstable design, plus warning/crash fixes.

Simulation and margins

  • Pad the ARX numerator when building the transfer function. The coefficients are in powers of z⁻¹ but ctrl.TransferFunction reads them in powers of z, so models with more poles than zeros had n − m extra samples of delay.
  • Put the identified delay inside the loop. It was only applied to the reference, and not at all in the disturbance simulation, so the step response was only time-shifted and didn't show the loss of damping.
  • Compute the margins on the actual loop gain sign · Kp(1 + I + D) · G · z^-(d+1). The "open loop" was built by removing the error junction, but D (and P with PI no-zero) act on the feedback and stayed in the loop. The margins then covered only part of the controller.

Example on a synthetic plant, K = (0.8, 0.3, 0.02):

delay before after
d = 1 GM 9.1 dB, PM 132° GM 4.8 dB, PM 55°
d = 3 GM 7.2 dB, PM 125° GM 1.1 dB, PM 19°

With K = (1.5, 0.5, 0.08) the old code reported PM 113°. The closed loop actually has a pole at |z| = 1.19, and the new margins are negative.

For all combinations of delay, PI no-zero and negated output, the closed-loop poles contain the roots of 1 + L.

Warnings

  • Pass 1-D signals to the identification. The column-vector reshape made every filter assignment convert a 1-element array to a scalar. That is deprecated in NumPy 1.25 and crashes on NumPy 2.x. Fixes Fix crash due to new Numpy 2.x version #48; supersedes the draft fix(autotune): keep u and y in array (row) shape #49, which only covered one of the two call sites.
  • Keep the Bode grid below Nyquist. It went up to 1000 rad/s whatever the sample time.
  • Always compute the margins from the frequency response, on a dense grid up to Nyquist. python-control fell back to that method, with a warning, for nearly every identified loop. The results match the polynomial method to 4 decimals where that method is accurate.

Testing

  • Ran the full pipeline headlessly on the three sample logs in logs/: no warnings, and the identification fit is unchanged.

The identified coefficients are in powers of z^-1 but
ctrl.TransferFunction interprets them in powers of z, so models with
more poles than zeros were simulated with n - m extra samples of delay.
This skewed the parameter search fit, the replay and the closed-loop
PID simulation.
The ARX model delay belongs to the plant, but the closed-loop simulation
applied it to the reference only, and the disturbance simulation ignored
it entirely. The step and disturbance responses were therefore only
time-shifted and did not show the loss of damping the delay causes,
making the tuning look better than it is.
The "open loop" was obtained by removing the error summing junction, but
the D term (and the P term with PI no-zero) acts on the feedback signal
and stayed in the loop. The margins were then those of a partially
closed loop covering only part of the controller, and could be very
optimistic, even reporting comfortable margins for an unstable design.

Build the loop gain directly as sign * Kp * (1 + I + D) * G * z^-(d+1):
where P and D are applied only changes the reference path, not the loop.
The signals were reshaped into column vectors before being passed to the
fit, so every sample assignment in the filters converted a 1-element
array to a scalar. NumPy deprecates this (and will make it an error),
and it emitted two warnings per sample.
The grid went up to 1000 rad/s regardless of the sample time, above the
Nyquist frequency for any dt > 3.1 ms, where the discrete-time response
has no meaning and python-control warns.
python-control's default method switches from the polynomial to the
frequency-response method when it expects numerical trouble, which is
the case for most identified loops, and warns every time. Use the
frequency-response method directly on a dense grid up to Nyquist: the
margins match the polynomial method where that one is accurate.
@bresch
bresch merged commit 4ddb51a into master Sep 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant