Skip to content

Commit 331cc66

Browse files
polvalentendrean
andauthored
docs: add FFT and plotting guide (#25)
Co-authored-by: Neven DREAN <[email protected]>
1 parent f0893ec commit 331cc66

File tree

1 file changed

+185
-0
lines changed

1 file changed

+185
-0
lines changed

guides/fft.livemd

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# The Discrete Fourier Transform (DFT)
2+
3+
```elixir
4+
Mix.install([
5+
{:nx_signal, "~> 0.2"},
6+
{:vega_lite, "~> 0.1"},
7+
{:kino_vega_lite, "~> 0.1"}
8+
])
9+
```
10+
11+
## What is the Discrete Fourier Transform (DFT)?
12+
13+
This livebook will show you how to use the Discrete Fourier Transform (DFT) to analyze a signal.
14+
15+
Suppose we have a periodic signal which we want to analyze.
16+
17+
We will run a Fast Fourrier Transform, which is a fast algorithm to compute the DFT.
18+
It transforms a time-domain function into the frequency domain.
19+
20+
<https://en.wikipedia.org/wiki/Discrete_Fourier_transform>
21+
22+
## Building the signal
23+
24+
Let's build a known signal that we will decompose and analyze later on.
25+
26+
The signal will be the sum of two sinusoidal signals, one at 5Hz, and one at 20Hz with the corresponding amplitudes (1, 0.5).
27+
28+
$f(t) = \sin(2\pi*5*t) + \frac{1}{2} \sin(2\pi*20*t)$
29+
30+
Suppose we can sample at `fs=50Hz` (meaning 50 samples per second) and our aquisition time is `duration = 1s`.
31+
32+
We build a time series of `t` equally spaced points with the given `duration` interval with `Nx.linspace`.
33+
34+
For each value of this serie (the discrete time $t$), we will synthesize the signal $f(t)$ through the module below.
35+
36+
```elixir
37+
defmodule Signal do
38+
import Nx.Defn
39+
import Nx.Constants, only: [pi: 0]
40+
41+
defn source(t) do
42+
f1 = 5
43+
f2 = 20
44+
Nx.sin(2 * pi() * f1 * t) + 1/2 * Nx.sin(2 * pi() * f2 * t)
45+
end
46+
47+
def sample(opts) do
48+
fs = opts[:fs]
49+
duration = opts[:duration]
50+
t = Nx.linspace(0, duration, n: trunc(duration * fs), endpoint: false, type: {:f, 32})
51+
source(t)
52+
end
53+
end
54+
```
55+
56+
We sample our signal at fs=50Hz during 1s:
57+
58+
```elixir
59+
fs = 50; duration= 1
60+
61+
sample = Signal.sample(fs: fs, duration: 1)
62+
```
63+
64+
## Analyzing the signal with the DFT
65+
66+
Because our signal contains many periods of the underlying function, the DFT results will contain some noise.
67+
This noise can stem both from the fact that we're likely cutting of the signal in the middle of a period
68+
and from the fact that we have a specific frequency resolution which ends up grouping our individual components into frequency bins.
69+
The latter isn't really a problem as we have chosen `fs` to be [fast enough](https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem).
70+
71+
> The number at the index $i$ of the DFT results gives an approximation of the amplitude and phase of the sampled signal at the frequency $i$.
72+
73+
In other words, doing `Nx.fft(sample)` returns a list of numbers indexed by the frequency.
74+
75+
```elixir
76+
dft = Nx.fft(sample)
77+
```
78+
79+
We will limit our study points to the first half of `dft` because it is symmetrical on the upper half.
80+
The phase doesn't really matter to us because we don't wish to reconstruct the signal, nor find possible discontinuities,
81+
so we'll use `Nx.abs` to obtain the absolute values at each point.
82+
83+
```elixir
84+
n = Nx.size(dft)
85+
86+
max_freq_index = div(n, 2)
87+
88+
amplitudes = Nx.abs(dft)[0..max_freq_index]
89+
90+
# the frequency bins, "n" of them spaced by fs/n=1 unit:
91+
frequencies = NxSignal.fft_frequencies(fs, fft_length: n)[0..max_freq_index]
92+
93+
data1 = %{
94+
frequencies: Nx.to_list(frequencies),
95+
amplitudes: Nx.to_list(amplitudes)
96+
}
97+
98+
VegaLite.new(width: 700, height: 300)
99+
|> VegaLite.data_from_values(data1)
100+
|> VegaLite.mark(:bar, tooltip: true)
101+
|> VegaLite.encode_field(:x, "frequencies",
102+
type: :quantitative,
103+
title: "frequency (Hz)",
104+
scale: [domain: [0, 50]]
105+
)
106+
|> VegaLite.encode_field(:y, "amplitudes",
107+
type: :quantitative,
108+
title: "amplitutde",
109+
scale: [domain: [0, 30]]
110+
)
111+
```
112+
113+
We see the peaks at 5Hz, 20Hz with their amplitudes (the second is half the first).
114+
115+
This is indeed our synthesized signal 🎉
116+
117+
We can confirm this visual inspection with a peek into our data. We use `Nx.top_k` function.
118+
119+
```elixir
120+
{values, indices} = Nx.top_k(amplitudes, k: 5)
121+
122+
{
123+
values,
124+
frequencies[indices]
125+
}
126+
```
127+
128+
### Visualizing the original signal and the Inverse Discrete Fourier Transform
129+
130+
Let's visualize our incoming signal over 400ms. This correspond to 2 periods of our 5Hz component and 8 periods of our 20Hz component.
131+
132+
We compute 200 points to have a smooth curve, thus every (400/200=) 2ms.
133+
134+
We also add the reconstructed signal via the **Inverse Discrete Fourier Transform** available as `Nx.ifft`.
135+
136+
This gives us 50 values spaced by 1000ms / 50 = 20ms.
137+
138+
Below, we display them as a bar chart under the line representing the ideal signal.
139+
140+
```elixir
141+
#----------- REAL SIGNAL
142+
# compute 200 points of the "real" signal during 2/5=400ms (twice the main period)
143+
144+
t = Nx.linspace(0, 0.4, n: trunc(0.4 * 500))
145+
sample = Signal.source(t)
146+
147+
#----------- RECONSTRUCTED IFFT
148+
yr = Nx.ifft(dft) |> Nx.real()
149+
fs = 50
150+
tr = Nx.linspace(0, 1, n: 1 * fs, endpoint: false)
151+
152+
idx = Nx.less_equal(tr, 0.4)
153+
xr = Nx.select(idx, tr, :nan)
154+
yr = Nx.select(idx, yr, :nan)
155+
#----------------
156+
157+
158+
data = %{
159+
x: Nx.to_list(t),
160+
y: Nx.to_list(sample)
161+
}
162+
163+
data_r = %{
164+
yr: Nx.to_list(yr),
165+
xr: Nx.to_list(xr)
166+
}
167+
168+
VegaLite.new(width: 600, height: 300)
169+
|> VegaLite.layers([
170+
VegaLite.new()
171+
|> VegaLite.data_from_values(data)
172+
|> VegaLite.mark(:line, tooltip: true)
173+
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: "time (ms)", scale: [domain: [0, 0.4]])
174+
|> VegaLite.encode_field(:y, "y", type: :quantitative, title: "signal")
175+
|> VegaLite.encode_field(:order, "x"),
176+
VegaLite.new()
177+
|> VegaLite.data_from_values(data_r)
178+
|> VegaLite.mark(:bar, tooltip: true)
179+
|> VegaLite.encode_field(:x, "xr", type: :quantitative, scale: [domain: [0, 0.4]])
180+
|> VegaLite.encode_field(:y, "yr", type: :quantitative, title: "reconstructed")
181+
|> VegaLite.encode_field(:order, "xr")
182+
])
183+
```
184+
185+
We see that during 400ms, we have 2 periods of a longer period signal, and 8 of a shorter and smaller perturbation period signal.

0 commit comments

Comments
 (0)