comparison fsp7_phasenoise.py @ 76:e2bb136bd2ed

Add script to use FSP7 to perform phasenoise measurements. Produces a list of measurements as well as optionally saving a set of (PNG) screenshots as well as wider sweeps.
author Daniel O'Connor <doconnor@gsoft.com.au>
date Fri, 27 Sep 2024 10:05:55 +0930
parents b6ebe05f250f
children 23c96322cfb6
comparison
equal deleted inserted replaced
75:576f112e0aba 76:e2bb136bd2ed
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 2
3 # Copyright (c) 2014 3 # Copyright (c) 2024
4 # Daniel O'Connor <darius@dons.net.au>. All rights reserved. 4 # Daniel O'Connor <darius@dons.net.au>. All rights reserved.
5 # 5 #
6 # Redistribution and use in source and binary forms, with or without 6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions 7 # modification, are permitted provided that the following conditions
8 # are met: 8 # are met:
26 # 26 #
27 27
28 import argparse 28 import argparse
29 import math 29 import math
30 import numpy 30 import numpy
31 import os
32 import PIL
31 import rsib 33 import rsib
32 import scipy 34 import scipy
33 import scpi 35 import scpi
34 import sys 36 import sys
35 37 import time
36 def setup2(r, centre, span, rbw, vbw): 38
37 r.write('SENSE1:FREQ:CENT %f Hz' % (centre)) 39 def sifmt(_v, dp = 3, unit = 'Hz', sp = ' '):
38 r.write('SENSE1:FREQ:SPAN %f Hz' % (span)) 40 '''Format a number using SI prefixes'''
39 r.write('SENS1:BAND:RES %f Hz' % (rbw)) 41 si_prefixes = ('T', 'G', 'M', 'k', '', 'm', 'ยต', 'n', 'p')
40 r.write('SENS1:BAND:VID %f Hz' % (vbw)) 42 scale = 10 ** 12
43 v = abs(_v)
44 if v == 0:
45 sip = ""
46 scale = 0
47 for i, sip in enumerate(si_prefixes):
48 if v >= scale:
49 break
50 scale /= 1e3
51 return ('%.' + str(dp) + 'f%s%s%s') % (_v / scale, sp, si_prefixes[i], unit)
52
53 def setupsweep(r, rbw, vbw, centre = None, span = None, start = None, stop = None):
54 '''Helper function to set various sweep parameters'''
55 if centre is not None:
56 r.write('SENSE1:FREQ:CENT %f Hz' % (centre))
57 if span is not None:
58 r.write('SENSE1:FREQ:SPAN %f Hz' % (span))
59 if start is not None:
60 r.write('SENSE1:FREQ:START %f Hz' % (start))
61 if stop is not None:
62 r.write('SENSE1:FREQ:STOP %f Hz' % (stop))
63 if rbw is not None:
64 r.write('SENS1:BAND:RES %f Hz' % (rbw))
65 if vbw is not None:
66 r.write('SENS1:BAND:VID %f Hz' % (vbw))
41 67
42 def dosweep(r, sweeps): 68 def dosweep(r, sweeps):
69 '''Helper function to trigger a sweep and wait for it to finish'''
43 swt = float(r.ask('SWE:TIME?')) 70 swt = float(r.ask('SWE:TIME?'))
44 tout = swt * 5 * sweeps 71 tout = swt * 5 * sweeps
45 if tout < 1: 72 if tout < 1:
46 tout = 1 73 tout = 1
47 #print('Sweep time', swt) 74 #print('Sweep time', swt)
51 78
52 # Wait for it to be done 79 # Wait for it to be done
53 opc = int(r.ask('*OPC?', timeout = tout)) 80 opc = int(r.ask('*OPC?', timeout = tout))
54 assert(opc == 1) 81 assert(opc == 1)
55 82
56 83 def findpeaks(r, maxpeak = 5, minpwr = None):
57 def setup(r, carrier, sweeps, rbw, vbw, ofs, atten): 84 '''Ask instrument to find maxpeaks peaks and return a list of frequencies and powers'''
85 peaks_f = []
86 peaks_pwr = []
87 r.write('CALC:MARK1:MAX')
88 for i in range(maxpeak):
89 frq = float(r.ask('CALC:MARK1:X?'))
90 pwr = float(r.ask('CALC:MARK1:Y?'))
91 if minpwr is not None and pwr < minpwr:
92 break
93 if i > 0:
94 if frq == peaks_f[i - 1]:
95 break
96 peaks_f.append(frq)
97 peaks_pwr.append(pwr)
98 r.write('CALC:MARK1:MAX:NEXT')
99
100 return peaks_f, peaks_pwr
101
102 def phasenoise(r, nominal, sweeps, atten, rlev, yscale, ssprefix):
103 '''Main function to find a signal, do some phase noise measurements and wideband sweeps'''
58 cpwrlim = -30 104 cpwrlim = -30
59 measurements = ( 105 measurements = (
60 { 'offset' : 10, 'rbw' : 10, 'vbw' : 10 }, 106 { 'offset' : 10, 'span' : 100, 'rbw' : 10, 'vbw' : 10 },
61 { 'offset' : 100, 'rbw' : 10, 'vbw' : 10 }, 107 { 'offset' : 100, 'span' : 250, 'rbw' : 10, 'vbw' : 10 },
62 { 'offset' : 1e3, 'rbw' : 300, 'vbw' : 300 }, 108 { 'offset' : 1e3, 'span' : 2.5e3, 'rbw' : 10, 'vbw' : 30 },
63 { 'offset' : 10e3, 'rbw' : 300, 'vbw' : 300 }, 109 { 'offset' : 10e3, 'span' : 25e3, 'rbw' : 100, 'vbw' : 300 },
64 { 'offset' : 100e3, 'rbw' : 300, 'vbw' : 300 }, 110 { 'offset' : 100e3, 'span' : 250e3, 'rbw' : 100, 'vbw' : 300 },
65 { 'offset' : 1e6, 'rbw' : 1e3, 'vbw' : 3e3 }, 111 { 'offset' : 1e6, 'span' : 2.1e6, 'rbw' : 1e3, 'vbw' : 3e3 },
66 ) 112 )
67 # Reset to defaults 113 # Reset to defaults
68 r.write('*RST') 114 r.write('*RST')
69 115
70 # Set to single sweep mode 116 # Set to single sweep mode
72 118
73 # Enable display updates 119 # Enable display updates
74 r.write('SYST:DISP:UPD ON') 120 r.write('SYST:DISP:UPD ON')
75 121
76 # Set attenuation 122 # Set attenuation
77 r.write('INP:ATT %f' % atten) 123 if atten is None:
124 r.write('INP:ATT AUTO')
125 else:
126 r.write('INP:ATT %s' % atten)
127
128 # Set Y scale
129 if yscale is not None:
130 r.write('DISP:TRAC:Y %s dB' % (yscale))
131
132 # Set reference level
133 if rlev is None:
134 r.write('DISP:WIND:TRAC:Y:RLEV AUTO')
135 else:
136 r.write('DISP:WIND:TRAC:Y:RLEV %f' % (rlev))
78 137
79 # 138 #
80 # Look for carrier 139 # Look for signal
81 # 140 #
82 print('Measuring carrier')
83 # Set frequency range etc 141 # Set frequency range etc
84 setup2(r, carrier, 1e3, 300, 300) 142 setupsweep(r, 10, 30, centre = nominal, span = 1e3)
85 143
86 # Switch marker 1 on in screen A 144 # Switch marker 1 on in screen A
87 r.write('CALC:MARK1 ON') 145 r.write('CALC:MARK1 ON')
146 print('Looking for signal (%.1f seconds)' % (float(r.ask('SWE:TIME?'))))
88 147
89 # Do the sweep 148 # Do the sweep
90 dosweep(r, 1) 149 dosweep(r, 1)
91 150
92 # Check the instrument is happy 151 # Check the instrument is happy
93 status = int(r.ask('STAT:QUES:COND?')) 152 status = int(r.ask('STAT:QUES:COND?'))
94 pwrstat = int(r.ask('STAT:QUES:POW:COND?')) 153 pwrstat = int(r.ask('STAT:QUES:POW:COND?'))
95 if status != 0 or pwrstat != 0: 154 if status != 0 or pwrstat != 0:
96 raise Exception('Instrument warning, status %s power status %s' % 155 print('Instrument warning, status %s power status %s' %
97 (bin(status), bin(pwrstat))) 156 (bin(status), bin(pwrstat)))
98 # Look for the carrier 157 # Find the peak
99 r.write('CALC:MARK1:MAX') 158 r.write('CALC:MARK1:MAX')
100 cpwr = float(r.ask('CALC:MARK1:Y?')) 159 cpwr = float(r.ask('CALC:MARK1:Y?'))
101 if cpwr < cpwrlim: 160 if cpwr < cpwrlim:
102 raise Exception('Carrier power too low / not found: %.1f dBm vs %.1f dBm' % (cpwr, cpwrlim)) 161 Exception('Power too low / not found: %.1f dBm vs %.1f dBm' % (cpwr, cpwrlim))
103 cmeas = float(r.ask('CALC:MARK1:X?')) 162 cmeas = float(r.ask('CALC:MARK1:X?'))
104 print('Found carrier at %.7f MHz with power %.1f dBm' % (cmeas / 1e6, cpwr)) 163 print('Found signal at %s with power %.1f dBm' % (sifmt(cmeas, dp = 6), cpwr))
105 164
106 # Turn averaging on 165 # Centre peak
107 r.write('AVER:STAT ON') 166 r.write('CALC:MARK1:FUNC:CENT')
108 167
109 # Set number of sweeps 168 # Set number of sweeps
110 r.write('SWE:COUN %d' % (sweeps)) 169 r.write('SWE:COUN %d' % (sweeps))
111 170
112 # Enable phase noise measurement 171 # Enable phase noise measurement
172 r.write('CALC:MARK:AOFF')
113 r.write('CALC:DELT1:FUNC:PNO') 173 r.write('CALC:DELT1:FUNC:PNO')
114 174
115 for idx, m in enumerate(measurements): 175 for idx, m in enumerate(measurements):
116 # Setup measurement 176 # Setup measurement
117 setup2(r, carrier, m['offset'] * 2.1, m['rbw'], m['vbw']) 177 setupsweep(r, m['rbw'], m['vbw'], span = m['span'])
118 178
119 # Do the sweep 179 # Do the sweep
120 dosweep(r, sweeps) 180 dosweep(r, sweeps)
121 181
122 # Set offset of phase noise measurement 182 # Set offset of phase noise measurement
123 r.write('CALC:DELT2:FUNC:FIX:RPO:X %f Hz' % (m['offset'])) 183 r.write('CALC:MARK1:MAX')
184 r.write('CALC:DELT1:MOD REL')
185 r.write('CALC:DELT1:X %f Hz' % (m['offset']))
124 meas = float(r.ask('CALC:DELT:FUNC:PNO:RES?')) 186 meas = float(r.ask('CALC:DELT:FUNC:PNO:RES?'))
125 print('Offset %.0fHz %.2f dBc/Hz' % (m['offset'], meas)) 187 print('Offset %7s %7.2f dBc/Hz' % (sifmt(m['offset'], dp = 0), meas))
126 return 188
127 189 # Take screen shot and save it
128 def getphnoise(r): 190 if ssprefix is not None:
129 # Trigger the sweep 191 screenshot(r, '%s-%d_%s_offset.png' % (
130 r.write('INIT;*WAI') 192 ssprefix, idx, sifmt(m['offset'], dp = 0, sp = '')))
131 193
132 # Wait for it to be done 194 # Do a wide band scan
133 opc = int(r.ask('*OPC?', timeout = 1000)) 195 if ssprefix is not None:
134 #print 'OPC - %d' % (opc) 196 start = 1e6
135 assert(opc == 1) 197 stop = 100e6
136 198 setupsweep(r, 1e3, 3e3, start = start, stop = stop)
137 # Set data format 199 print('Scanning %s - %s (%.1f seconds)' % (sifmt(start, dp = 0), sifmt(stop, dp = 0),
138 r.write('FORM:DATA ASC') 200 float(r.ask('SWE:TIME?'))))
139 201 dosweep(r, sweeps)
140 # Read phase noise value 202 # Show peaks
141 data = r.ask('CALC:MARK2:FUNC:NOIS:RES?') 203 r.write('CALC:MARK:AOFF')
142 #print 'Data - ' + data 204 for i, (f, p) in enumerate(zip(*findpeaks(r, 4))):
143 205 r.write('CALC:MARK%d:X %f Hz' % (i + 1, f))
144 return float(data) 206 screenshot(r, '%s-sweep-%.1f-%.1fMHz.png' % (
207 ssprefix, start / 1e6, stop / 1e6))
208
209 # Look from just before signal to just after second harmonic
210 start = nominal * 0.9
211 stop = 2 * nominal + nominal * 0.1
212 setupsweep(r, 1e3, 3e3, start = start, stop = stop)
213 print('Scanning %s - %s (%.1f seconds)' % (sifmt(start, dp = 0), sifmt(stop, dp = 0),
214 float(r.ask('SWE:TIME?'))))
215 dosweep(r, sweeps)
216 # Show peaks
217 r.write('CALC:MARK:AOFF')
218 for i, (f, p) in enumerate(zip(*findpeaks(r, 4))):
219 r.write('CALC:MARK%d:X %f Hz' % (i + 1, f))
220 screenshot(r, '%s-sweep-%.1f-%.1fMHz.png' % (
221 ssprefix, start / 1e6, stop / 1e6))
222
223 def screenshot(r, ssname, sapath = '\'c:\\temp\\pntmp.bmp\''):
224 # Setup & take screen shot
225 r.write('HCOPY:DEV:LANG1 BMP')
226 r.write('HCOPY:DEST1 MMEM')
227 r.write('HCOPY:CMAP:DEF2')
228 r.write('HCOPY:ITEM:ALL')
229 r.write('MMEM:NAME %s' % (sapath,))
230 r.write('HCOPY:IMM1')
231 time.sleep(5)
232
233 # Grab it
234 r.write('MMEM:DATA? %s' % (sapath,))
235 bmpdat = scpi.getbin(r.read())
236 bmpname = ssname[0:-4] + '.bmp'
237
238 # Convert to whatever format
239 open(bmpname, 'wb').write(bmpdat)
240 img = PIL.Image.open(bmpname)
241 img.save(ssname)
242 os.unlink(bmpname)
243 r.write('MMEM:DEL %s' % (sapath,))
145 244
146 if __name__ == '__main__': 245 if __name__ == '__main__':
147 parser = argparse.ArgumentParser(description = 'Configures a Rhode Schwartz FSP7 spectrum analyser to do a phase noise measurement') 246 parser = argparse.ArgumentParser(description = 'Configures a Rhode Schwartz FSP7 spectrum analyser to do a phase noise measurement')
148 parser.add_option('-s', '--span', dest = 'span', default = 1e6, help = 'Span frequency in Hz (default: %default)', type = float) 247 parser.add_argument('-a', '--atten', default = None, help = 'Attenuation level (default: autoamatic)')
149 parser.add_option('-w', '--sweeps', dest = 'sweeps', default = 20, help = 'Number of sweeps (default: %default)', type = int) 248 parser.add_argument('-r', '--rlevel', default = 10, help = 'Reference level (default: %default dBm)', type = float)
150 parser.add_option('-r', '--rbw', dest = 'rbw', default = 1000, help = 'Resolution bandwidth in Hz (default: %default)', type = float) 249 parser.add_argument('-s', '--ssprefix', default = None, help = 'Path name to save screenshots to (default: none)', type = str)
151 parser.add_option('-v', '--vbw', dest = 'vbw', default = 3000, help = 'Video bandwidth in Hz (default: %default)', type = float) 250 parser.add_argument('-w', '--sweeps', default = 1, help = 'Number of sweeps (default: %default)', type = int)
152 parser.add_option('address', '--address', dest = 'address', help = 'Address of analyser', type = str) 251 parser.add_argument('-y', '--yscale', default = 120, help = 'Y-scale extent (default: %default dB)', type = float)
153 parser.add_option('centre', '--centre', dest = 'centreq', help = 'Centre frequency of measurement', type = float) 252 parser.add_argument('address', help = 'Address of analyser', type = str)
154 parser.add_option('offset', '--offset', dest = 'offset', help = 'Offset frequency to take measurement at', type = float) 253 parser.add_argument('nominal', help = 'Nominal frequency of measurement (Hz)', type = float)
155 254
156 (options, args) = parser.parse_args() 255 args = parser.parse_args()
157 256
158 # Connect to the analyser 257 # Connect to the analyser
159 r = rsib.RSIBDevice(addr) 258 r = rsib.RSIBDevice(args.address)
160 259
161 # ID instrument 260 # ID instrument
162 print('ID is', r.ask('*IDN?').decode('ascii')) 261 print('Device ID is', r.ask('*IDN?').decode('ascii'))
163 262
164 # Setup parameters 263 # Do measurements
165 setup(r, options.centre, options.span, options.sweeps, options.rbw, options.vbw, options.offset) 264 phasenoise(r, args.nominal, args.sweeps, args.atten, args.rlevel, args.yscale, args.ssprefix)
166
167 while True:
168 print('Centre: %.1f Mhz, Span %.1f Mhz, RBW %.1f kHz, %d sweeps, Offset %.1fkHz: %.2f dBc/Hz' % (
169 options.centre / 1e6, options.span / 1e6, options.rbw / 1e3, options.sweeps,
170 options.offset / 1e3, getphnoise(r)))