Simple 2 MHz sine generator & Python Bode-plotting script for Picoscope 2204a - Page 1
Forum section: electronics / projects

author: jasonRF
author_rank: Regular Contributor
timestamp: November 04, 2022, 01:17:07 am
content: This beginner-friendly project includes a basic sine-wave generator and a Python script that controls it to make Bode plots with a Picoscope 2204a. The approach is to use a single frequency per waveform capture, which is slow but maximizes the dynamic range. Yes, the 2204a already has a built-in signal generator and nice Bode plotting software, but it is limited to 100 kHz while this project is usable up to 5 MHz.Both the hardware and software are about as bare-bones as I could make them while still maintaining the required capability to do Bode plots. I am sharing this in the hopes of getting some feedback before I start soldering, so other 2204a (and probably 2205a) users can play with it, and because I think some other folks might be able to modify the script to work with other scopes (perhaps SCPI-capable ones).This first post will describe the current hardware, and a second post will describe the Python script.The sine-wave generator is yet another Arduino-controlled AD9833-based project. One of my self-imposed constraints is that I wanted a design that is easy to keep stable when implemented on a solderless breadboard. So no cranky or high-speed op amps in general. Here are the approximate specs for the breadboarded version in the photo:* Sine waves only (is trivial to add triangles, but adding square waves is another story)* AC voltages: 6V peak-to-peak into high-Z; 3V peak-to-peak into 50 Ohms* DC offset voltages: +/- 4V into high-Z; +/- 2V into 50 Ohms* 1.6Hz - 2.4 MHz (-3 dB) bandwidth; -8 dB at 5 MHz; not usable beyond 5 MHz* 50 Ohm output impedanceThe schematic is almost exactly what is on the breadboard. Some notes:* Power is from a wall-wart 15V AC transformer, leading into the half-wave rectifiers and LM7812/LM1912.I just threw in capacitors that I grabbed from a drawer, and haven't yet calculated what is really needed.* I am using a Seeeduino Nano board that has a maximum power input of 12V, which is why the rails are 12V.* A simple common-emitter is used for all of the voltage gain. Many other small-signal BJTscould also work for Q1 and Q2, although the biasing is really designed for devices with hfe > 200 or so.* The TLE2082cp op amp (10 MHz, 40 V/us, 40 mA/channel output, $4.27 from Digikey) used as an output buffer is approaching bothslew-rate and current limiting with this design. The op amp dominates the distortion when driving low-impedanceloads at high frequencies. At 2 MHz into 50 Ohms, crossover distortion is easy to see on the waveform.* I use an audio-taper 10k pot for amplitude control, and a linear-taper 100k pot with center detent for the DC offset control.The SPDT switch enables/disables the DC offset control.* the lack of a serious low-pass filter to handle DDS artifacts will scream at some of you. I did add C2 toprovide some extra roll-off. The spectra belowshow that the DDS artifacts near 25MHz are far enough down that they shouldn't interfere with Bode plotting.Finally, here is the code for the Arduino. I am using the AD9833 library you can download athttps://github.com/Billwilliams1952/AD9833-Library-ArduinoCode:#include <AD9833.h>#define FNC_PIN 10// simple code to control AD9833 on breakouto board with// 25 MHz closk. Accepts desired frequencies in Hz over serial,// prints the actual frequency to serial. Only includes// sine waves for this example.//// On startup the arduino will produce a 1 kHz sine//AD9833 gen(FNC_PIN);double freq = 1000.00;double newFreq = 0;double actFreq = 0.0;long dig = 0;double postDec = 0;long wavetype = SINE_WAVE;void setup() {// start serial and generatorSerial.begin(9600);gen.Begin();// have generator output the initial frequency freqgen.ApplySignal(wavetype,REG0,freq);gen.EnableOutput(true);delay(10);}void loop() {Serial.flush();while (Serial.available() == 0){}// parse serial inputs to get new desired frequencywhile (Serial.available() > 0){ // read one digit at a timedig = Serial.read() - '0';if (dig == -2) {postDec = 10;}else if ((dig >=0 ) && (dig <= 9)){if (postDec != 0){newFreq = newFreq + dig/postDec;postDec = postDec*10;}else if (postDec==0){newFreq = newFreq * 10;newFreq = newFreq + dig;}delay(10);}}// apply new frequencyfreq = newFreq;gen.ApplySignal(wavetype,REG0,freq);// read and print the actual frequency, which will be different// in general. The printed actual frequency will be read// by the Python Bode plotting scriptactFreq = gen.GetActualProgrammedFrequency(REG0);Serial.println(actFreq);// reset variables for the next frequencynewFreq = 0;dig = 0;postDec = 0;}You will see that at startup it outputs a 1 kHz sine-wave. After that, it reads the desired frequency (decimal format - no scientific notation) from serial over the usb cable, then writes the actual frequency it is generating back to the serial port. It is easy to control it using the serial monitor tool built into the Arduino IDE.This is the first time I have ever used an Ardino, and since I am just using a downloaded library I would not claim that I know anything at all about them (or any other microcontroller for that matter) beyond the basic idea. All suggestions or hints for better ways implement this task would be much appreciated!Finally, I have included some output waveforms and spectra as measured by a picoscope 5244B. All measurements use the full 200 MHz bandwidth with 12-bit resolution. The image filenames indicate the frequency (100 kHz or 1 MHz) and the load (51 Ohms or 10 kOhms).Jason

author: jasonRF
author_rank: Regular Contributor
timestamp: November 04, 2022, 01:26:19 am
content: 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 fromhttps://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.Code:# 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 stuffimport ctypesimport numpy as npfrom picosdk.ps2000 import ps2000 as psimport matplotlib.pyplot as pltfrom picosdk.functions import adc2mV, assert_pico2000_okimport timeimport 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 couplingchAprobe = 1 # 10 for x10 probe, 1 for x1 probechA_DCcouple = 0 # 1 for DCvcoupled, 0 for AC coupledchBprobe = 1 # 10 for x10 probe, 1 for x1 probechB_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 DUTchAoutput = 1;# Set frequencies we want for Bode plot.# For not setup for log-spaced frequencies, but# is easy to add option for linear-spacedminfreq = 1maxfreq = 5e6freqPerDecade = 7# specify the COM port for the sine generatorsinePort = '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 arraysnumFreqs = round(1 + freqPerDecade*np.log10(maxfreq/minfreq))allfreq = np.round(100*np.logspace(np.log10(minfreq), np.log10(maxfreq), numFreqs))/100allgain_dB = np.zeros(len(allfreq))allphase = np.zeros(len(allfreq))allvA = 0.0j+np.zeros(len(allfreq)) #peak voltage of chA in VoltsallvB = 0.0j+np.zeros(len(allfreq)) #peak voltage of chB in VoltsactualFreq = np.zeros(len(allfreq))# startup function gen# On my computer it is COM5; you may need to# modify thatser = serial.Serial()ser.baudrate = 9600ser.port = sinePortser.open()ser.flush()# Create picoscope status ready for usestatus = {}# Open 2000 series PicoScope# Returns handle to chandle for use in future API functionsstatus["openUnit"] = ps.ps2000_open_unit()assert_pico2000_ok(status["openUnit"])# Create chandle for usechandle = 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=20VchARange = 8status["setChA"] = ps.ps2000_set_channel(chandle, 0, 1, chA_DCcouple, chARange)assert_pico2000_ok(status["setChA"])chBRange = 8status["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 mstriggerThresh = 0if chAoutput==1:triggerChannel = 1 # trigger on Belse:triggerChannel = 0 # trigger on Astatus["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 = 1000postTriggerSamples = 1000maxSamples = 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 = Falseelse:highVoltage = Trueif maxBuffer<lowThresh and minBuffer>-lowThresh:underflow = Trueelse:underflow = Falseif highVoltage and chRange == 10:highVoltage = Falseunderflow = Falseprint("Signal is Big")elif highVoltage and chRange < 10:print("increasing vertical scale")chRange = chRange + 1elif underflow and chRange > 2:print("decreasing vertical scale")chRange = chRange - 1elif underflow and chRange == 2:print("signal is small")highVoltage = Falseunderflow = Falsereturn 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 # frequency# set function generator to the desired frequencyser.write(str(approxFreq).encode('ascii'))time.sleep(0.5) #to give time for frequency changetmpActFreq = str(ser.readline())actualFreq = np.double(tmpActFreq[2:][:-5])print('processing frequency '+str(k+1)+' of '+str(len(allfreq))+' (ask for '+str(approxFreq)+' Hz, get '+str(actualFreq)+' Hz)')# set timebase. 2204a sample rate = 100 MS/s / 2^timebeasetmptimebase = np.log2(100e6 / (sampsPerPeriod*approxFreq))timebase = int(np.floor(tmptimebase))if timebase<1: # if asking for > 50 MS/s sample ratetimebase = 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() #pointeroversample = 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_readyready = 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 databufferA = (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 = cmaxSamplescmaxSamples = 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 valuemaxADC = ctypes.c_int16(32767)madc = 32767# check to see if voltages fit nicely into vertical scaleunderflowA, 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 signalswhile highVoltageA or underflowA or highVoltageB or underflowB:# setup channels to new vertical scalesstatus["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 datastatus["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 scaleunderflowA, 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 mVadc2mVChA = adc2mV(bufferA, chARange, maxADC)adc2mVChB = adc2mV(bufferB, chBRange, maxADC)# scale according to channel probe settingdatA = chAprobe * np.array(adc2mVChA)datB = chBprobe * np.array(adc2mVChB)# Create time datatimetag = np.linspace(0, (cmaxSamples.value -1) * timeInterval.value, cmaxSamples.value)# Use single Fourier filter to compute complex voltage estimatesfwin = np.blackman(maxSamples) #windowee = np.exp(-2j*np.pi*actualFreq*timetag*1e-9)/np.sum(fwin)vA = 2*np.sum(fwin*ee*(datA-np.mean(datA))) #peak amplitude in mVvB = 2*np.sum(fwin*ee*(datB-np.mean(datB))) #peak amplitude in mVallvA = vA/1000 # /1000 to get Volts peakallvB = vB/1000 # /1000 to get Volts peak# compute gain and phase for this frequencypA = abs(vA)*abs(vA)pB = abs(vB)*abs(vB)pAB = vA * vB.conjugate();if chAoutput==1:sgn = 1else:sgn = -1allgain_dB = sgn*10*np.log10(pA/pB)allphase = sgn*np.angle(pAB)*180/np.pi# plot voltagesfig, cc = plt.subplots(2)fig.suptitle('Voltages')cc.loglog(actualFreq, abs(allvB)*2)cc.set_ylabel('CH B Vpp')cc.grid()cc.loglog(actualFreq, abs(allvA)*2)cc.set_ylabel('CH A Vpp')cc.grid()cc.set_xlabel('Frequency (Hz)')# plot Bode plotminplotgain = 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.semilogx(actualFreq, allgain_dB)aa.set_ylabel('Gain (dB)')aa.set_yticks(np.linspace(minplotgain, maxplotgain, int(ng)))aa.grid()aa.semilogx(actualFreq, allphase)aa.set_ylabel('Phase (deg)')aa.set_xlabel('Frequency (Hz)')aa.set_yticks(np.linspace(minplotphase, maxplotphase, int(nph)))aa.grid()fig# Stop the scope# handle = chandlestatus["stop"] = ps.ps2000_stop(chandle)assert_pico2000_ok(status["stop"])# Close unitDisconnect the scope# handle = chandlestatus["close"] = ps.ps2000_close_unit(chandle)assert_pico2000_ok(status["close"])# display status returnsprint(status)# close connection to function genser.close()The script has 4 main parts:1. parameter input and instrument initialization. In particular, you will need to setup theprobe settings (x1 or x10), the desired frequency range, number of frequencies touse, and which COM port the sine-wave generator is connected to.2. The main loop over the different frequencies of interest. For each frequencya. command the function generator to produce that frequencyb. read the actual frequency the function generator is trying to produce. Especially at the lowerfrequencies this can be somewhat different that what you ask for (AD9833 with 25 MHzoscillator 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 interest3. 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 Picoscopesoftware for the scopeCode:# script to send frequencies to arduino ad9833import serialser = serial.Serial()ser.baudrate = 9600ser.port = 'COM5'ser.timeout = 5 # timeout in secondsser.open()# oldlines = ser.readlines();# print(oldlines)newfreq = 1000lastline = 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 changetmpActFreq = 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 DUT3. connect one scope channel to the input of the DUT, and the other scope channel to the outputof the DUT. The scripts let you specify which channel is input and which is output4. Make sure your probes are set to where you want (x1, x10) and modify thescript accordingly.5. You might want to do a few basic tests using the scope's original software to ensure that thevoltage 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 muchcurrent 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 theArduino IDE to control the frequency.of the sine generator and spot check over the frequency range of interest to make sure thatthe amplitude and DC offset controls are set so that everything looks okay. Once everythingis set, either have the picoscope software disconnect from the scope, or close the softwarealtogether.6. run the python scriptJason

author: balnazzar
author_rank: Frequent Contributor
timestamp: November 04, 2022, 03:12:42 pm
content: Thanks Jason. Interesting stuff!