Sensing Matrix Diagonalization

In this tutorial, we will demonstration the use of kontrol.SensingMatrix class to diagonalize a pair of coupled sensors.

Here, suppose we have two displacements \(x_1\) and \(x_2\), and we have sensing readouts \(s_1\) and \(s_2\). We kicked the system and let it resonates. \(x_1\) is a damped oscillation at \(1\ \mathrm{Hz}\) and \(x_2\) is a damped oscillation at \(3\ \mathrm{Hz}\). We hard code sensing coupling \(s_1 = x_1 + 0.1x_2\) and \(s_2 = -0.2x_1 + x_2\). For simplicity, let’s assume that these sesning readouts are obtained using an initial sensing matrix of \(\mathbf{C}_\mathrm{sensing, initial}=\begin{bmatrix}1&0\\0&1\end{bmatrix}\).

We will estimate the coupling ratios from the spectra of \(s_1\) and \(s_2\), and let’s see if we can recover a sensing matrix \(\mathbf{C}_\mathrm{sensing}\) such that \(\left[x_1,\,x_2\right]^T=\mathbf{C}_\mathrm{sensing}\left[s_1,\,s_2\right]^T\).

[1]:
import numpy as np
import matplotlib.pyplot as plt

fs = 1024
t_ini = 0
t_end = 100
t = np.linspace(0, 100, (t_end-t_ini)*fs)
np.random.seed(123)
x_1_phase = np.random.uniform(0, np.pi)
x_2_phase = np.random.uniform(0, np.pi)
x_1 = np.real(1.5 * np.exp((-0.1+(2*np.pi*1)*1j) * t + x_1_phase*1j))
x_2 = np.real(3 * np.exp((-0.2+(2*np.pi*3)*1j) * t + x_2_phase*1j))
s_1 = x_1 + 0.1*x_2
s_2 = -0.2*x_1 + x_2
[2]:
plt.rcParams.update({"font.size": 14})
plt.figure(figsize=(15,5))

plt.subplot(121)
plt.plot(t, x_1, label="$x_1$")
plt.plot(t,x_2, label="$x_2$")
plt.ylabel("Amplitude")
plt.xlabel("time [s]")
plt.legend(loc=0)

plt.subplot(122)
plt.plot(t, s_1, label="$s_1$")
plt.plot(t, s_2, label="$s_2$")
plt.ylabel("Amplitude")
plt.xlabel("time [s]")
plt.legend(loc=0)
[2]:
<matplotlib.legend.Legend at 0x7f08f60b56d0>
../_images/tutorials_sensing_matrix_diagonalization_2_1.png

Now, let’s obtain various spectra of the sensor readouts, like how we would use diaggui to obtain spectral densities and transfer functions.

[3]:
import scipy.signal
fs = 1/(t[1]-t[0])
f, psd_s_1 = scipy.signal.welch(s_1, fs=fs, nperseg=int(len(s_1)/5))
f, psd_s_2 = scipy.signal.welch(s_2, fs=fs, nperseg=int(len(s_2)/5))
f, csd_s_12 = scipy.signal.csd(s_1, s_2, fs=fs, nperseg=int(len(s_1)/5))

mask = f>0
f = f[mask]
psd_s_1 = psd_s_1[mask]
psd_s_2 = psd_s_2[mask]
csd_s_12 = csd_s_12[mask]
[4]:
plt.figure(figsize=(15, 10))

plt.subplot(221)
plt.loglog(f, abs(csd_s_12/psd_s_2), label="Transfer function $|s_1/s_2|$")
plt.loglog(f, abs(csd_s_12/psd_s_1), label="Transfer function $|s_2/s_1|$")
plt.ylabel("Amplitude")
plt.xlabel("Frequency [Hz]")
plt.legend(loc=0)
plt.grid(which="both")

plt.subplot(222)
plt.loglog(f, psd_s_1, label="$s_1$")
plt.loglog(f, psd_s_2, label="$s_2$")
plt.ylabel("Power spectral density [1/Hz]")
plt.xlabel("Frequency [Hz]")
plt.legend(loc=0)
plt.grid(which="both")

plt.subplot(223)
plt.semilogx(f, np.angle(csd_s_12/psd_s_2), label=r"Transfer function $\angle\left(s_1/s_2\right)$")
plt.semilogx(f, np.angle(csd_s_12/psd_s_1), label=r"Transfer function $\angle\left(s_2/s_1\right)$")
plt.ylabel("Phase [rad]")
plt.xlabel("Frequency [Hz]")
plt.legend(loc=0)
plt.grid(which="both")
../_images/tutorials_sensing_matrix_diagonalization_5_0.png

Now, we know that the resonance frequencies are at 1 Hz and 3 Hz, so we can safely assume that these frequencies are purely \(x_1\) and \(x_2\) motion respectively. We see that the transfer functions \(s_1/s_2\) and \(s_2/s_1\) have flat response at these frequencies. These correspond to coupling ratios \(s_1/x_2\) (at 3 Hz) and \(s_2/x_1\) (at 3 Hz). From the phase response, we see that the phase between \(x_2\) and \(s_1\) is 0, and that between \(x_1\) and \(s_2\) is at \(-\pi\), this correspond to a minus sign in the coupling ratio. Let’s inspect further.

[5]:
# f_1hz = f[(f>0.9) & (f<1.1)]
# f_3hz = f[(f>2.9) & (f<3.1)]
print(r"Coupling ratio $s_1/x_2$", np.mean(abs(csd_s_12/psd_s_2)[(f>2.9) & (f<3.1)]))
print(r"Coupling ratio $s_2/x_1$", np.mean(abs(csd_s_12/psd_s_1)[(f>0.9) & (f<1.1)]))
print(r"Phase $s_1/x_2$", np.angle(csd_s_12/psd_s_2)[(f>2.9) & (f<3.1)])
print(r"Phase $s_2/x_1$", np.angle(csd_s_12/psd_s_1)[(f>0.9) & (f<1.1)])
Coupling ratio $s_1/x_2$ 0.10001284705931585
Coupling ratio $s_2/x_1$ 0.19997798568604752
Phase $s_1/x_2$ [-8.05189656e-05 -8.56070819e-06  8.71342862e-05 -1.62118769e-04]
Phase $s_2/x_1$ [ 3.14151356 -3.14157211 -3.14157723  3.14037366]

Indeed, we find coupling ratios 0.100013 and -0.199978.

Now, we assume the follow:

\(\mathbf{C}_\mathrm{coupling}\left[x_1,\,x_2\right]^T=\mathbf{C}_\mathrm{sensing, initial}\left[s_1,\,s_2\right]^T\), so the coupling matrix \(\mathbf{C}_\mathrm{coupling}\) is \(\begin{bmatrix}1&0.100013\\-0.199978&1\end{bmatrix}\).

And now let’s use kontrol.SensingMatrix to compute a new sensing matrix.

[6]:
import kontrol

c_sensing_initial = np.array([[1, 0], [0, 1]])
c_coupling = np.array([[1, 0.100013], [-0.199978, 1]])


sensing_matrix = kontrol.SensingMatrix(matrix=c_sensing_initial, coupling_matrix=c_coupling)
## Alternatively,
## sensing_matrix = kontrol.SensingMatrix(matrix=c_sensing_initial)
## sensing_matrix.coupling_matrix = c_coupling

## Now diagonalize
c_sensing = sensing_matrix.diagonalize()
## Alternatively
## c_sensing = sensing_matrix.diagonalize(coupling_matrix=c_coupling)

print(c_sensing)
[[ 0.98039177 -0.09805192]
 [ 0.19605679  0.98039177]]

Now let’s test the new matrix.

We compute the new sensing readout \(\left[s_{1,\mathrm{new}},\,s_{2,\mathrm{new}}\right]^T = \mathbf{C}_\mathrm{sensing}\left[s_1,\,s_2\right]^T\), and then compute the power spectral densities and compare it with the old ones.

[7]:
s_new = c_sensing @ np.array([s_1, s_2])
s_1_new = s_new[0]
s_2_new = s_new[1]

f, psd_s_1_new = scipy.signal.welch(s_1_new, fs=fs, nperseg=int(len(s_1_new)/5))
f, psd_s_2_new = scipy.signal.welch(s_2_new, fs=fs, nperseg=int(len(s_2_new)/5))
f, csd_s_12_new = scipy.signal.csd(s_1_new, s_2_new, fs=fs, nperseg=int(len(s_1_new)/5))

mask = f>0
f = f[mask]
psd_s_1_new = psd_s_1_new[mask]
psd_s_2_new = psd_s_2_new[mask]
csd_s_12_new = csd_s_12_new[mask]
[8]:
plt.figure(figsize=(15, 5))

plt.subplot(121)
plt.loglog(f, psd_s_1, label="$s_1$ before")
plt.loglog(f, psd_s_1_new, label="$s_1$ diagonalized")
plt.ylabel("Power spectral density [1/Hz]")
plt.xlabel("Frequency [Hz]")
plt.legend(loc=0)
plt.grid(which="both")

plt.subplot(122)
plt.loglog(f, psd_s_2, label="$s_2$ before")
plt.loglog(f, psd_s_2_new, label="$s_2$ diagonalized")
plt.ylabel("Power spectral density [1/Hz]")
plt.xlabel("Frequency [Hz]")
plt.legend(loc=0)
plt.grid(which="both")
../_images/tutorials_sensing_matrix_diagonalization_12_0.png

As we can see, the couplings have been reduced by many many orders of magnitudes, while the diagonal readout remains the same.

By the way. kontrol.SensingMatrix class inherit numpy.ndarray, so you can do any numpy array operation on it. For example,

[9]:
sensing_matrix + np.random.random(np.shape(sensing_matrix))
[9]:
SensingMatrix([[1.22685145, 0.55131477],
               [0.71946897, 1.42310646]])