In this post I describe the Python script. I do not really know Python at all, so it is pretty kludgy. It requires numpy, picoSDK and the Picotech python wrappers for picoSDk you can download from
https://github.com/picotech/picosdk-python-wrappersI run it from Spyder (Anaconda) python 3.9; I don't know what modifications would be required to run it under different versions of Python.
# Simple Bode plotting script that uses
# an arduino-controlled AD9833 sine-wave generator
# and a picoscope 2204a. Will probably work with
# Picoscope 2205a as well.
#
# The approach is to step through each desired frequency.
# At each frequency, it adjusts the vertical setting,
# then does the measurements.
#
# Version 0: no bells and whistles.
#
#
# This script is a modification of the ps2000blockExample.py
# script provided by Picotech. Hence the following
# copyright and licensing information:
#
#
# Copyright © 2018-2019 Pico Technology Ltd.
#
# Permission to use, copy, modify, and/or distribute this
# software for any purpose with or without fee is hereby
# granted, provided that the above copyright notice and
# this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR
# ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA
# OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
#
# import stuff
import ctypes
import numpy as np
from picosdk.ps2000 import ps2000 as ps
import matplotlib.pyplot as plt
from picosdk.functions import adc2mV, assert_pico2000_ok
import time
import serial
# USER INPUTS
# =====================================================
# setup picoscope channels
# Note that the trigger level is set to 0V in the
# code, so if using DC coupling you may want to
# modify the trigger level. I have only really
# tested with AC coupling
chAprobe = 1 # 10 for x10 probe, 1 for x1 probe
chA_DCcouple = 0 # 1 for DCvcoupled, 0 for AC coupled
chBprobe = 1 # 10 for x10 probe, 1 for x1 probe
chB_DCcouple = 0 # 1 for DCvcoupled, 0 for AC coupled
# setup input/output. Set chAoutput to 1 if chA is
# connected to the output of the DUT; set to 0
# if chA is connected to the input of the DUT
chAoutput = 1;
# Set frequencies we want for Bode plot.
# For not setup for log-spaced frequencies, but
# is easy to add option for linear-spaced
minfreq = 1
maxfreq = 5e6
freqPerDecade = 7
# specify the COM port for the sine generator
sinePort = 'COM5'
# specify nominal number of samples per period desired
# in scope measurements. Obviously the 2204a is limited
# to 50 MS/s with both channels running, so at the higher
# frequencies you may get fewer than this.
sampsPerPeriod = 100;
# Processing
# =====================================================
# make vector of frequencies and pre-allocate gain and phase arrays
numFreqs = round(1 + freqPerDecade*np.log10(maxfreq/minfreq))
allfreq = np.round(100*np.logspace(np.log10(minfreq), np.log10(maxfreq), numFreqs))/100
allgain_dB = np.zeros(len(allfreq))
allphase = np.zeros(len(allfreq))
allvA = 0.0j+np.zeros(len(allfreq)) #peak voltage of chA in Volts
allvB = 0.0j+np.zeros(len(allfreq)) #peak voltage of chB in Volts
actualFreq = np.zeros(len(allfreq))
# startup function gen
# On my computer it is COM5; you may need to
# modify that
ser = serial.Serial()
ser.baudrate = 9600
ser.port = sinePort
ser.open()
ser.flush()
# Create picoscope status ready for use
status = {}
# Open 2000 series PicoScope
# Returns handle to chandle for use in future API functions
status["openUnit"] = ps.ps2000_open_unit()
assert_pico2000_ok(status["openUnit"])
# Create chandle for use
chandle = ctypes.c_int16(status["openUnit"])
# Set up channels A and B
# handle = chandle
# channel: PS2000_CHANNEL_A = 0 and PS2000_CHANNEL_B=1
# enabled = 1
# coupling type = PS2000_DC = 1
# range = PS2000_5V = 8
# analogue offset = 0 V
#
# voltage ranges:1=20mV, 2=50mV, 3=100mV, 4=200mV,
# 5=500mV, 6=1V, 7=2V, 8=5V, 9=10V, 10=20V
chARange = 8
status["setChA"] = ps.ps2000_set_channel(chandle, 0, 1, chA_DCcouple, chARange)
assert_pico2000_ok(status["setChA"])
chBRange = 8
status["setChB"] = ps.ps2000_set_channel(chandle, 1, 1, chB_DCcouple, chBRange)
assert_pico2000_ok(status["setChB"])
# Set up edge trigger with threshold at 0 volts
# handle = chandle
# source = 0 for channel A or 1 for channel B
# threshold = 0 (needs to be between -32767 and +32767)
# direction = PS2000_RISING = 0
# delay = 0 s
# auto Trigger = 1000 ms
triggerThresh = 0
if chAoutput==1:
triggerChannel = 1 # trigger on B
else:
triggerChannel = 0 # trigger on A
status["trigger"] = ps.ps2000_set_trigger(chandle, triggerChannel, triggerThresh, 0, 0, 1000)
assert_pico2000_ok(status["trigger"])
# Set number of pre and post trigger samples to be collected
# hard-coded to use 2000 samples. For some reason get
# errors when trying to use 4000 samples...
preTriggerSamples = 1000
postTriggerSamples = 1000
maxSamples = preTriggerSamples + postTriggerSamples
# define simple function used in my crude vertical 'autoset'
def checkVertical(minBuffer, maxBuffer, lowThresh, highThresh, chRange):
if maxBuffer<highThresh and minBuffer>-highThresh:
highVoltage = False
else:
highVoltage = True
if maxBuffer<lowThresh and minBuffer>-lowThresh:
underflow = True
else:
underflow = False
if highVoltage and chRange == 10:
highVoltage = False
underflow = False
print("Signal is Big")
elif highVoltage and chRange < 10:
print("increasing vertical scale")
chRange = chRange + 1
elif underflow and chRange > 2:
print("decreasing vertical scale")
chRange = chRange - 1
elif underflow and chRange == 2:
print("signal is small")
highVoltage = False
underflow = False
return underflow, highVoltage, chRange
# main loop over allfreq. Does high freq first
# since collection times are shorter and so it
# takes much less time to 'autoset' the amplitude.
# Unless there is a narrow resonance or something,
# the amplitude change frequency-to-frequency
# is often small.
for k in range(len(allfreq)-1, -1, -1):
approxFreq = allfreq[k] # frequency
# set function generator to the desired frequency
ser.write(str(approxFreq).encode('ascii'))
time.sleep(0.5) #to give time for frequency change
tmpActFreq = str(ser.readline())
actualFreq[k] = np.double(tmpActFreq[2:][:-5])
print('processing frequency '+str(k+1)+' of '+str(len(allfreq))+' (ask for '+str(approxFreq)+' Hz, get '+str(actualFreq[k])+' Hz)')
# set timebase. 2204a sample rate = 100 MS/s / 2^timebease
tmptimebase = np.log2(100e6 / (sampsPerPeriod*approxFreq))
timebase = int(np.floor(tmptimebase))
if timebase<1: # if asking for > 50 MS/s sample rate
timebase = 1 # set sample rate to 50 MS/s
# Get timebase information
# handle = chandle
# timebase
# no_of_samples = maxSamples
# pointer to time_interval = ctypes.byref(timeInterval)
# pointer to time_units = ctypes.byref(timeUnits)
# oversample = 1 = oversample
# pointer to max_samples = ctypes.byref(maxSamplesReturn)
timeInterval = ctypes.c_int32()
timeUnits = ctypes.c_int32() #pointer
oversample = ctypes.c_int16(1)
maxSamplesReturn = ctypes.c_int32()
status["getTimebase"] = ps.ps2000_get_timebase(chandle, timebase, maxSamples, ctypes.byref(timeInterval), ctypes.byref(timeUnits), oversample, ctypes.byref(maxSamplesReturn))
assert_pico2000_ok(status["getTimebase"])
# Run block capture
# handle = chandle
# no_of_samples = maxSamples
# timebase = timebase
# oversample = oversample
# pointer to time_indisposed_ms = ctypes.byref(timeIndisposedms)
timeIndisposedms = ctypes.c_int32()
status["runBlock"] = ps.ps2000_run_block(chandle, maxSamples, timebase, oversample, ctypes.byref(timeIndisposedms))
assert_pico2000_ok(status["runBlock"])
# Check for data collection to finish using ps2000_ready
ready = ctypes.c_int16(0)
check = ctypes.c_int16(0)
while ready.value == check.value:
status["isReady"] = ps.ps2000_ready(chandle)
ready = ctypes.c_int16(status["isReady"])
# Create buffers ready for data
bufferA = (ctypes.c_int16 * maxSamples)()
bufferB = (ctypes.c_int16 * maxSamples)()
# Get data from scope
# handle = chandle
# pointer to buffer_a = ctypes.byref(bufferA)
# pointer to buffer_b = ctypes.byref(bufferB)
# poiner to overflow = ctypes.byref(oversample)
# no_of_values = cmaxSamples
cmaxSamples = ctypes.c_int32(maxSamples)
status["getValues"] = ps.ps2000_get_values(chandle, ctypes.byref(bufferA), ctypes.byref(bufferB), None, None, ctypes.byref(oversample), cmaxSamples)
assert_pico2000_ok(status["getValues"])
# find maximum ADC count value
maxADC = ctypes.c_int16(32767)
madc = 32767
# check to see if voltages fit nicely into vertical scale
underflowA, highVoltageA, chARange = checkVertical(np.min(bufferA), np.max(bufferA), .2*madc, .8*madc, chARange)
underflowB, highVoltageB, chBRange = checkVertical(np.min(bufferB), np.max(bufferB), .2*madc, .8*madc, chBRange)
# iterate until have best vertical scale setting that works for the
# these signals
while highVoltageA or underflowA or highVoltageB or underflowB:
# setup channels to new vertical scales
status["setChA"] = ps.ps2000_set_channel(chandle, 0, 1, chA_DCcouple, chARange)
assert_pico2000_ok(status["setChA"])
status["setChB"] = ps.ps2000_set_channel(chandle, 1, 1, chB_DCcouple, chBRange)
assert_pico2000_ok(status["setChB"])
status["runBlock"] = ps.ps2000_run_block(chandle, maxSamples, timebase, oversample, ctypes.byref(timeIndisposedms))
assert_pico2000_ok(status["runBlock"])
ready = ctypes.c_int16(0)
check = ctypes.c_int16(0)
while ready.value == check.value:
status["isReady"] = ps.ps2000_ready(chandle)
ready = ctypes.c_int16(status["isReady"])
# get picoscope data
status["getValues"] = ps.ps2000_get_values(chandle, ctypes.byref(bufferA), ctypes.byref(bufferB), None, None, ctypes.byref(oversample), cmaxSamples)
assert_pico2000_ok(status["getValues"])
# check to see if voltages fit nicely into vertical scale
underflowA, highVoltageA, chARange = checkVertical(np.min(bufferA), np.max(bufferA), .2*madc, .8*madc, chARange)
underflowB, highVoltageB, chBRange = checkVertical(np.min(bufferB), np.max(bufferB), .2*madc, .8*madc, chBRange)
# at this point should have data with best vertical scale setting!
# convert ADC counts data to mV
adc2mVChA = adc2mV(bufferA, chARange, maxADC)
adc2mVChB = adc2mV(bufferB, chBRange, maxADC)
# scale according to channel probe setting
datA = chAprobe * np.array(adc2mVChA)
datB = chBprobe * np.array(adc2mVChB)
# Create time data
timetag = np.linspace(0, (cmaxSamples.value -1) * timeInterval.value, cmaxSamples.value)
# Use single Fourier filter to compute complex voltage estimates
fwin = np.blackman(maxSamples) #window
ee = np.exp(-2j*np.pi*actualFreq[k]*timetag*1e-9)/np.sum(fwin)
vA = 2*np.sum(fwin*ee*(datA-np.mean(datA))) #peak amplitude in mV
vB = 2*np.sum(fwin*ee*(datB-np.mean(datB))) #peak amplitude in mV
allvA[k] = vA/1000 # /1000 to get Volts peak
allvB[k] = vB/1000 # /1000 to get Volts peak
# compute gain and phase for this frequency
pA = abs(vA)*abs(vA)
pB = abs(vB)*abs(vB)
pAB = vA * vB.conjugate();
if chAoutput==1:
sgn = 1
else:
sgn = -1
allgain_dB[k] = sgn*10*np.log10(pA/pB)
allphase[k] = sgn*np.angle(pAB)*180/np.pi
# plot voltages
fig, cc = plt.subplots(2)
fig.suptitle('Voltages')
cc[0].loglog(actualFreq, abs(allvB)*2)
cc[0].set_ylabel('CH B Vpp')
cc[0].grid()
cc[1].loglog(actualFreq, abs(allvA)*2)
cc[1].set_ylabel('CH A Vpp')
cc[1].grid()
cc[1].set_xlabel('Frequency (Hz)')
# plot Bode plot
minplotgain = np.min(np.floor(allgain_dB/10)*10)
maxplotgain = np.max(np.ceil(allgain_dB/10)*10)
minplotphase = np.min(np.floor(allphase/15)*15)
maxplotphase = np.max(np.ceil(allphase/15)*15)
ng = np.round(1 + (maxplotgain-minplotgain)/10)
nph = np.round(1 + (maxplotphase-minplotphase)/15)
fig, aa = plt.subplots(2)
fig.suptitle('Bode Plot from 2204a and Arduino+AD9833FG')
aa[0].semilogx(actualFreq, allgain_dB)
aa[0].set_ylabel('Gain (dB)')
aa[0].set_yticks(np.linspace(minplotgain, maxplotgain, int(ng)))
aa[0].grid()
aa[1].semilogx(actualFreq, allphase)
aa[1].set_ylabel('Phase (deg)')
aa[1].set_xlabel('Frequency (Hz)')
aa[1].set_yticks(np.linspace(minplotphase, maxplotphase, int(nph)))
aa[1].grid()
fig
# Stop the scope
# handle = chandle
status["stop"] = ps.ps2000_stop(chandle)
assert_pico2000_ok(status["stop"])
# Close unitDisconnect the scope
# handle = chandle
status["close"] = ps.ps2000_close_unit(chandle)
assert_pico2000_ok(status["close"])
# display status returns
print(status)
# close connection to function gen
ser.close()
The script has 4 main parts:
1. parameter input and instrument initialization. In particular, you will need to setup the
probe settings (x1 or x10), the desired frequency range, number of frequencies to
use, and which COM port the sine-wave generator is connected to.
2. The main loop over the different frequencies of interest. For each frequency
a. command the function generator to produce that frequency
b. read the actual frequency the function generator is trying to produce. Especially at the lower
frequencies this can be somewhat different that what you ask for (AD9833 with 25 MHz
oscillator has about 0.09 Hz resolution).
c. setup the time-base of the scope channels, which is easy because we know the frequency.
The script nominally selects the time-base so that there are 100 samples per period,
although above 500 kHz there will be fewer (2204a max sample rate is 50 MS/s with both channels running)
d. adjust the vertical settings of each channel if needed. My initial approach is crude but seems to work.
e. Collect the measurements from the two channels.
f. Estimate gain and phase offset, using Fourier analysis to just extract the frequency of interest
3. Make 2 plots: the first is of the voltages of the two scope channels, and hte second is the Bode plot.
In general you will need to muck with the plots further to make them look pretty.
4. close the connections to the instruments.
Since the 2205a and 2204a use the same picoSDK drivers, I suspect the code will work for the 2205a as-is, although the time-base setup may need to be changed to take advantage of the higher sample rate that the 2205 supports.
Below there are Bode plots for two different DUTs: a simple low-pass filter and a portable discrete headphone amplifier I made 10+ years ago (before I owned a scope) that has much wider bandwidth than I expected. For the low-pass filter I include both the channel voltage plot and the Bode plot using this project. For the headphone amplifier I include the Bode plot generated using this project, as well as one generated using the 5244B and FRA4Picoscope; since both scopes/methods yield about the same results I think I didn't screw anything up too badly!
In case it is helpful, I am also attaching a Python script that I use for easy control of the frequency when I am using the Picoscope
software for the scope
# script to send frequencies to arduino ad9833
import serial
ser = serial.Serial()
ser.baudrate = 9600
ser.port = 'COM5'
ser.timeout = 5 # timeout in seconds
ser.open()
# oldlines = ser.readlines();
# print(oldlines)
newfreq = 1000
lastline = ser.readline();
# 2020print(lastline)
# ser.close()
newfreq = input("enter a new frequency in Hz (or 0 to stop): ")
while newfreq != 0:
ser.write(str(newfreq).encode('ascii'))
time.sleep(0.5) #to give time for frequency change
tmpActFreq = str(ser.readline())
print(' Generating '+str(tmpActFreq)+' Hz')
newfreq = input("enter a new frequency (or 0 to stop): ")
print("done")
ser.close()
Finally, in case some noob readers aren't familiar with how these measurements are setup, here is how you would use this:
1. connect the ouptut of the function generator to the input of the device under test (DUT)
2. If needed, add an output load to the DUT
3. connect one scope channel to the input of the DUT, and the other scope channel to the output
of the DUT. The scripts let you specify which channel is input and which is output
4. Make sure your probes are set to where you want (x1, x10) and modify the
script accordingly.
5. You might want to do a few basic tests using the scope's original software to ensure that the
voltage level you are using from the signal generator is fine with the DUT
(doesn't saturate it somehow, or isn't a lot smaller than it needs to be, or doesn't draw too much
current from the sine-wave generator and make it distorted, etc.)
For this, I use either the second Python script above or the serial port monitor tool that is in the
Arduino IDE to control the frequency.
of the sine generator and spot check over the frequency range of interest to make sure that
the amplitude and DC offset controls are set so that everything looks okay. Once everything
is set, either have the picoscope software disconnect from the scope, or close the software
altogether.
6. run the python script
Jason