Consider a plant with a transfer function equal to
$$H(s) = \frac{250000}{s^2 \left(s^2 + 1000 s + 250000 \right)} \tag 1$$
For some good reason (when I've done this it's for low-frequency disturbance rejection) we want to wrap it with a full PID controller:
$$G(s) = 250 + 50 \frac{1}{s} + 25 \frac{s}{0.005 s + 1} \tag 2$$
The closed-loop response of this combination is

So, all-in-all a nice closed-loop response, if your goal was a loop that closes at around 7Hz.
The open loop response is:
Note that the phase plot crosses -180 degrees at two frequencies, and two gains: a little over 0.2Hz and a gain of +40dB, and around 30Hz and a gain of maybe -20dB.
What this tells you is that you don't just have a unidirectional gain margin. If your gain were to rise by 20dB, then your system would go unstable. But the story doesn't end there -- if your gain were to fall by 40dB, then your system would also go unstable. So -- you have two gain margins, both of which must be observed.
(Note that in real life these margins can be worse than this -- I just whipped something up on the fly -- margins of $\pm$ 6dB aren't unheard of).
As a test, I calculated the pole positions for the closed-loop system as given by (1) and (2), the system with the gain reduced by 43dB, and the system with the gain increased by 23dB. You can see that both of the cases where the gain margin is violated have unstable poles.
as designed |
43dB down |
23dB up |
$s = -520 \pm j85$ |
$s = -500 \pm j7.7 $ |
$s = -603 \pm j225$ |
$s = -132$ |
$s = -200$ |
$s = -9.6$ |
$s = -16 \pm j9.0$ |
$s = 0.0016 \pm j1.33$ |
$s = 7.8 \pm j208$ |
$s = -0.20$ |
$s = -0.20$ |
$s = -0.20 |
For reference, here's the Python script I used to do this analysis:
#! /usr/bin/env python3
import control
import matplotlib.pyplot as plt
import numpy as np
omega_0 = 500
plant = control.TransferFunction([1], [1, 0, 0]) * \
control.TransferFunction([omega_0 ** 2], [1, 2 * omega_0, omega_0 ** 2])
tau = 5e-3
k_i = 50
k_p = 250
k_d = 25
freq = np.geomspace(0.01, 100)
controller = k_p + k_i * control.TransferFunction([1], [1, 0]) + \
k_d * control.TransferFunction([1, 0], [tau, 1])
print(f'{controller = }')
print(f'{plant = }')
open_loop = plant * controller
closed_loop = 1 / (1 + 1 / open_loop)
print(f'{control.poles(open_loop) = }')
print(f'{control.poles(closed_loop) = }')
print(f'{control.poles(1 / (1 + 1 / (0.007 * open_loop))) = }')
print(f'{control.poles(1 / (1 + 1 / (14 * open_loop))) = }')
mag, phase, _ = control.frequency_response(open_loop, omega=2 * np.pi * freq)
fig, ax = plt.subplots(nrows=2)
fig.suptitle('Open-loop Bode plot')
ax[0].semilogx(freq, 20 * np.log10(mag))
ax[0].grid(True)
ax[0].set_ylabel('mag, dB')
ax[1].semilogx(freq, np.unwrap(phase) * 180 / np.pi - 360)
ax[1].hlines(-180, freq[0], freq[-1], 'r')
ax[1].grid(True)
ax[1].set_ylabel('phase')
ax[1].set_xlabel('frequency, Hz')
mag, phase, _ = control.frequency_response(closed_loop, omega=2 * np.pi * freq)
fig, ax = plt.subplots(nrows=2)
fig.suptitle('Closed-loop Bode plot')
ax[0].semilogx(freq, 20 * np.log10(mag))
ax[0].grid(True)
ax[0].set_ylabel('mag, dB')
ax[1].semilogx(freq, np.unwrap(phase) * 180 / np.pi)
ax[1].grid(True)
ax[1].set_ylabel('phase')
ax[1].set_xlabel('frequency, Hz')
# plt.figure()
# control.root_locus(plant * controller, kvect=np.geomspace(0.01, 1))
plt.show()