├── .gitignore ├── README.md ├── animation.gif ├── batch.png ├── crystalweb.py ├── fixture.jpg ├── readme.py └── topten.png /.gitignore: -------------------------------------------------------------------------------- 1 | res/ 2 | makefile 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Measure Crystal Characteristics Automatically Using a NanoVNA 3 | 4 | ![](animation.gif) 5 | 6 | ## Introduction 7 | 8 | Use this script to automatically characterize your crystals 9 | using a test fixture and a NanoVNA. The script will search for 10 | the series resonant point of the crystal and from there 11 | make measurements. It will also search, if the --stray option is given, 12 | for the parallel resonant frequency point of the crystal. 13 | This point is used to find the crystal's "holder" 14 | capacitance Co. 15 | 16 | The script operates by drilling the NanoVNA down to the series resonance point 17 | of the crystal and from there measuring its Cm, Lm, and Rm values using the phase shift 18 | method. To find the crystal's parallel resonant frequency it looks for the 19 | point of highest loss. When the measurement is finished the NanoVNA's 20 | sweep frequency range will be restored to what it was before the script was run. 21 | 22 | ## How to use 23 | 24 | First set the start and stop "stimulus" values of the Nanovna to encompass the range 25 | of frequencies you expect to see from your batch of crystals. The 26 | frequency span should be large enough to capture 27 | both the series and parallel resonant points of your crystals. I 28 | have been using a span of 100KHz. 29 | 30 | Next calibrate the thru port of the Nanovna using the "calib" menu. 31 | Now you can run the script to make the measurement. Pass the --stray option 32 | if you want to measure the holder capacitance. 33 | To tell crystalweb explicitly what the start and stop frequency of the span, 34 | pass the options --start and --stop to the program, otherwise it 35 | will query the range from the NanoVNA UI. 36 | 37 | The arguments to the python script are as follows: 38 | 39 | 40 | ``` 41 | $ crystalweb.py --help 42 | usage: crystalweb.py [-h] [--fixture] [--loss] [--batch] [--theta THETA] 43 | [--stray STRAY] [--repeat REPEAT] [--load LOAD] 44 | [--part-name PART_NAME] [--part-number PART_NUMBER] 45 | [--device DEVICE] [--start START] [--stop STOP] 46 | [--capture] 47 | 48 | optional arguments: 49 | -h, --help show this help message and exit 50 | --fixture measure test fixture stray capacitance (default: 51 | False) 52 | --loss measure test fixture loss (default: False) 53 | --batch perform a batch measurement of crystals (default: 54 | False) 55 | --theta THETA phase angle for measuring bandwidth (default: 45) 56 | --stray STRAY test fixture stray capacitance in pF, affects Co 57 | (default: None) 58 | --repeat REPEAT number of times to repeat measurements (default: 10) 59 | --load LOAD test fixture source and load resistance (default: 50) 60 | --part-name PART_NAME 61 | name of part (default: X) 62 | --part-number PART_NUMBER 63 | number of part (default: 1) 64 | --device DEVICE name of serial port device (default: None) 65 | --start START starting frequency of initial sweep (default: None) 66 | --stop STOP stopping frequency of initial sweep (default: None) 67 | --capture capture screenshots of the measurements as 68 | movie_xx.png (default: False) 69 | ``` 70 | 71 | 72 | ## Measuring the Fixture Capacitance 73 | 74 | Besides measuring crystals, the script can also measure the 75 | stray capacitance of the test fixture using the "--fixture" measurement option. 76 | When measuring the crystal, pass this stray capacitance value to crystalweb 77 | for finding the crystal's holder capacitance. 78 | 79 | The steps to find the stray capacitance of your fixture are as follows: 80 | 81 | 1. First calibrate the thru of the NanoVNA. 82 | 2. Next leave the fixture open and run "crystalweb.py --fixture". The program will measure and output 83 | the fixture's stray capacitance. 84 | 85 | To find the dB loss through the fixture instead of the stray capacitance use the "--loss" option. 86 | 87 | ## An Example 88 | 89 | The script when it measures the crystal writes the results it finds 90 | immediately to stderr. At the conclusion of the measurement, the script 91 | will write a final comma separated 92 | formatted line, listing all the measurements previously found, to stdout. 93 | The CSV header for this line is 'PART,FS,CM,LM,RM,QU,CO'. 94 | 95 | For example, I executed the following to measure a 7.03 Mhz crystal: 96 | 97 | ``` 98 | $ crystalweb.py --stray 1.1 99 | PART: X1 100 | RL = 50.0 ohm 101 | fs = 7027674 Hz 102 | Rm = 18.39 ohm 103 | Cm = 0.0149 pF 104 | Lm = 34.3163 mH 105 | Qu = 82401 106 | fp = 7039743 Hz 107 | Co = 3.25138 pF 108 | X1,7027674,1.4946e-14,0.034316,18.39,82401,3.2514e-12 109 | ``` 110 | 111 | ### My test fixture 112 | 113 | ![](fixture.jpg) 114 | 115 | ### Quantifying A Batch of 100 Crystals. 116 | 117 | You can use the --batch option to quickly 118 | measure a batch of crystals. It will repeatedly run 119 | the measurement after prompting you to change the crystal. 120 | 121 | Below are the results from crystalweb, put in 122 | graph form using Pandas, after measuring 123 | 100 crystals from a 11.0570 Mhz batch. 124 | 125 | ![](batch.png) 126 | 127 | The top ten crystals, the ones with the highest unloaded Q, 128 | have the following characteristics: 129 | 130 | ![](topten.png) 131 | 132 | 133 | -------------------------------------------------------------------------------- /animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roseengineering/crystalweb/49402b2afa44f065149ffe4eb6615b6d8b9cfae5/animation.gif -------------------------------------------------------------------------------- /batch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roseengineering/crystalweb/49402b2afa44f065149ffe4eb6615b6d8b9cfae5/batch.png -------------------------------------------------------------------------------- /crystalweb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse, serial, time, sys 3 | import numpy as np 4 | from serial.tools import list_ports 5 | 6 | 7 | def capture(): 8 | global capture_num 9 | from PIL import Image 10 | import struct 11 | send_command("capture") 12 | b = _dev.read(320 * 240 * 2) 13 | x = struct.unpack(">76800H", b) 14 | arr = np.array(x, dtype=np.uint32) 15 | arr = 0xFF000000 + ((arr & 0xF800) >> 8) + ((arr & 0x07E0) << 5) + ((arr & 0x001F) << 19) 16 | img = Image.frombuffer('RGBA', (320, 240), arr, 'raw', 'RGBA', 0, 1) 17 | img.save("movie_%02d.png" % capture_num) 18 | capture_num += 1 19 | 20 | ### NANOVNA FUNCTIONS 21 | 22 | def getport(): 23 | VID = 0x0483 24 | PID = 0x5740 25 | device_list = list_ports.comports() 26 | for device in device_list: 27 | if device.vid == VID and device.pid == PID: 28 | return device.device 29 | raise OSError("device not found") 30 | 31 | def send_command(cmd): 32 | cmd += "\r" 33 | _dev.write(cmd.encode()) 34 | _dev.readline() 35 | 36 | def marker_command(number, index): 37 | send_command("marker {:d} {:s}".format(number, str(index))) 38 | 39 | def fetch_data(): 40 | result = [] 41 | line = '' 42 | while True: 43 | c = _dev.read().decode() 44 | if c == chr(13): 45 | pass 46 | elif c == chr(10): 47 | result.append(line.split(' ')) 48 | line = '' 49 | else: 50 | line += c 51 | if line.endswith('ch>'): 52 | break 53 | return result 54 | 55 | def sweep(start=None, stop=None): 56 | if start and stop: 57 | if stop - start < 100: 58 | fs = (start + stop) /2 59 | start, stop = fs - 50, fs + 50 60 | send_command("sweep {:d} {:d}".format(int(start), int(stop))) 61 | send_command("sweep") 62 | start, stop, _ = [ int(x) for x in fetch_data()[0] ] 63 | if stop < 0: 64 | start = start + stop / 2 65 | stop = start - stop 66 | return start, stop 67 | 68 | def thru(): 69 | send_command("data 1") 70 | data = fetch_data() 71 | return np.array([ np.complex(float(re), float(im)) for re,im in data ]) 72 | 73 | def frequencies(): 74 | send_command("frequencies") 75 | return np.array([ int(x[0]) for x in fetch_data() ]) 76 | 77 | def open_port(filename, start=None, stop=None): 78 | global _dev, _start, _stop 79 | _dev = serial.Serial(filename) 80 | _start, _stop = sweep(start=start, stop=stop) 81 | 82 | def restore_sweep(): 83 | sweep(start=_start, stop=_stop) 84 | 85 | 86 | #### CRYSTAL CHARACTERIZATION FUNCTIONS 87 | 88 | def motational_resistance(loss, rl): 89 | RM = 2 * rl * (10**(loss/20) - 1) 90 | return RM 91 | 92 | def phase_shift_method(fs, bw, rm, rl): 93 | REFF = rm + 2 * rl 94 | CM = bw / (2 * np.pi * REFF * fs**2) 95 | LM = 1 / (2 * np.pi * fs)**2 / CM 96 | QU = 2 * np.pi * fs * LM / rm 97 | return CM, LM, QU 98 | 99 | def holder_parallel(fs, fp, cm, stray): 100 | co = cm / (fp / fs - 1) / 2 - stray 101 | return co 102 | 103 | def stray_fixture(freq, loss, rl): 104 | xc = 2 * rl * (10**(loss/20) - 1) 105 | stray = 1 / (2 * np.pi * freq * xc) 106 | return stray 107 | 108 | 109 | #### MAIN CODE 110 | 111 | def measure(N, theta=45): 112 | time.sleep(1) 113 | freq = frequencies() 114 | i = 0 115 | data = [] 116 | last = None 117 | while i < N: 118 | d = thru() 119 | if np.all(last == d): continue 120 | data.append(d) 121 | last = d 122 | i += 1 123 | data = np.array(data) 124 | mag = np.median(20 * np.log10(np.abs(data)), axis=0) 125 | phi = np.median(np.angle(data) * 180 / np.pi, axis=0) 126 | i = np.where(np.diff(np.sign(phi)) != 0)[0] 127 | zeros = freq[i] 128 | gain = mag[i] 129 | fmin = freq[np.argmin(mag)] 130 | fmax = freq[np.argmax(mag)] 131 | 132 | bw = np.nan 133 | if phi[0] > theta and phi[-1] < -theta: 134 | span = np.interp([theta, -theta], phi[::-1], freq[::-1]) 135 | bw = (span[1] - span[0]) / np.tan(theta * np.pi / 180) 136 | if "capture_num" in globals(): capture() 137 | return (zeros, gain, bw), (fmin, fmax), (freq, mag) 138 | 139 | def analyze_loss(N, rl): 140 | _, mag = measure(N=N)[2] 141 | print('Test fixture loss') 142 | print('maximum = {:.2f} dB'.format(np.max(mag)), file=sys.stderr) 143 | print('median = {:.2f} dB'.format(np.median(mag)), file=sys.stderr) 144 | print('minimum = {:.2f} dB'.format(np.min(mag)), file=sys.stderr) 145 | 146 | def analyze_stray(N, rl): 147 | freq, mag = measure(N=N)[2] 148 | stray = stray_fixture(freq=freq, loss=-mag, rl=rl) 149 | print('Test fixture capacitance') 150 | print('maximum = {:.2f} pF'.format(np.max(stray) / 1e-12), file=sys.stderr) 151 | print('median = {:.2f} pF'.format(np.median(stray) / 1e-12), file=sys.stderr) 152 | print('minimum = {:.2f} pF'.format(np.min(stray) / 1e-12), file=sys.stderr) 153 | 154 | def analyze_crystal(N, rl, theta, stray, partname, partnum): 155 | tol = 2 156 | alpha = .7 157 | 158 | # get initial measurement 159 | 160 | marker_command(1, "on") 161 | marker_command(1, 0) 162 | print("PART: {}{}".format(partname, partnum), file=sys.stderr) 163 | print("RL = {:.1f} ohm".format(rl), file=sys.stderr) 164 | fp, fs = measure(N=1)[1] 165 | 166 | # measure fs 167 | 168 | df = fp - fs 169 | bw_df = None 170 | while df > 100: 171 | df = alpha * df 172 | sweep(fs - df/2, fs + df/2) 173 | marker_command(1, 50) 174 | zeros, gain, bw = measure(N=1 if df > 100 else N, theta=theta + tol)[0] 175 | if not np.isnan(bw): bw_df = df 176 | fs = zeros[0] 177 | loss = -gain[0] 178 | if bw_df is None: 179 | print('Could not find series resonant frequency', file=sys.stderr) 180 | return 181 | print("fs = {:.0f} Hz".format(fs), file=sys.stderr) 182 | rm = motational_resistance(loss, rl) 183 | print("Rm = {:.2f} ohm".format(rm), file=sys.stderr) 184 | 185 | # measure bandwidth 186 | 187 | sweep(fs - bw_df / 2, fs + bw_df / 2) 188 | _, _, bw = measure(N=N, theta=theta)[0] 189 | cm, lm, qu = phase_shift_method(fs=fs, bw=bw, rm=rm, rl=rl) 190 | print("Cm = {:.4f} pF".format(cm / 1e-12), file=sys.stderr) 191 | print("Lm = {:.4f} mH".format(lm / 1e-3), file=sys.stderr) 192 | print("Qu = {:.0f}".format(qu), file=sys.stderr) 193 | 194 | # drill down fp 195 | 196 | if stray is None: 197 | co = 0 198 | else: 199 | stray = stray * 1e-12 200 | df = fp - fs 201 | while df > 100: 202 | df = alpha * df 203 | sweep(fp - df / 2, fp + df / 2) 204 | marker_command(1, 50) 205 | zeros, _, _ = measure(N=1)[0] 206 | if len(zeros) > 2: break 207 | fp = zeros[0] 208 | print("fp = {:.0f} Hz".format(fp), file=sys.stderr) 209 | co = holder_parallel(fs=fs, fp=fp, cm=cm, stray=stray) 210 | print('Co = {:.5f} pF'.format(co / 1e-12), file=sys.stderr) 211 | 212 | print("{partname}{partnum},{fs:.0f},{cm:.5g},{lm:.5g},{rm:.2f},{qu:.0f},{co:.5g}".format( 213 | partname=partname, partnum=partnum, fs=fs, cm=cm, lm=lm, rm=rm, co=co, qu=qu)) 214 | sys.stdout.flush() 215 | 216 | def main(): 217 | global capture_num 218 | parser = argparse.ArgumentParser( 219 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 220 | parser.add_argument("--fixture", action="store_true", 221 | help="measure test fixture stray capacitance") 222 | parser.add_argument("--loss", action="store_true", 223 | help="measure test fixture loss") 224 | parser.add_argument("--batch", action="store_true", 225 | help="perform a batch measurement of crystals") 226 | parser.add_argument("--theta", type=float, default=45, 227 | help="phase angle for measuring bandwidth") 228 | parser.add_argument("--stray", type=float, 229 | help="test fixture stray capacitance in pF, affects Co") 230 | parser.add_argument("--repeat", type=int, default=10, 231 | help="number of times to repeat measurements") 232 | parser.add_argument("--load", type=int, default=50, 233 | help="test fixture source and load resistance") 234 | parser.add_argument("--part-name", type=str, default='X', 235 | help="name of part") 236 | parser.add_argument("--part-number", type=int, default=1, 237 | help="number of part") 238 | parser.add_argument("--device", 239 | help="name of serial port device") 240 | parser.add_argument("--start", type=float, 241 | help="starting frequency of initial sweep") 242 | parser.add_argument("--stop", type=float, 243 | help="stopping frequency of initial sweep") 244 | parser.add_argument("--capture", action="store_true", 245 | help="capture screenshots of the measurements as movie_xx.png") 246 | 247 | args = parser.parse_args() 248 | if args.capture: capture_num = 0 249 | N = args.repeat 250 | rl = args.load 251 | theta = args.theta 252 | partname = args.part_name 253 | partnum = args.part_number 254 | stray = args.stray 255 | open_port(args.device or getport(), start=args.start, stop=args.stop) 256 | if args.batch: 257 | print('PART,FS,CM,LM,RM,QU,CO') 258 | 259 | while True: 260 | try: 261 | if args.fixture: 262 | analyze_stray(N=N, rl=rl) 263 | elif args.loss: 264 | analyze_loss(N=N, rl=rl) 265 | else: 266 | analyze_crystal(N=N, rl=rl, theta=theta, 267 | stray=stray, partname=partname, partnum=partnum) 268 | except KeyboardInterrupt: 269 | print("Bye", file=sys.stderr) 270 | except Exception: 271 | import traceback 272 | traceback.print_exc() 273 | finally: 274 | restore_sweep() 275 | 276 | if not args.batch: break 277 | print("Press return to measure another part.", file=sys.stderr) 278 | sys.stdin.readline() 279 | partnum += 1 280 | 281 | if __name__ == "__main__": 282 | main() 283 | 284 | 285 | -------------------------------------------------------------------------------- /fixture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roseengineering/crystalweb/49402b2afa44f065149ffe4eb6615b6d8b9cfae5/fixture.jpg -------------------------------------------------------------------------------- /readme.py: -------------------------------------------------------------------------------- 1 | import os, subprocess 2 | 3 | def run(command): 4 | proc = subprocess.Popen("PYTHONPATH=. python3 " + command, shell=True, 5 | stdout=subprocess.PIPE) 6 | buf = proc.stdout.read().decode() 7 | proc.wait() 8 | return f""" 9 | ``` 10 | $ {command} 11 | {buf}\ 12 | ``` 13 | """ 14 | 15 | print(f""" 16 | # Measure Crystal Characteristics Automatically Using a NanoVNA 17 | 18 | ![](animation.gif) 19 | 20 | ## Introduction 21 | 22 | Use this script to automatically characterize your crystals 23 | using a test fixture and a NanoVNA. The script will search for 24 | the series resonant point of the crystal and from there 25 | make measurements. It will also search, if the --stray option is given, 26 | for the parallel resonant frequency point of the crystal. 27 | This point is used to find the crystal's "holder" 28 | capacitance Co. 29 | 30 | The script operates by drilling the NanoVNA down to the series resonance point 31 | of the crystal and from there measuring its Cm, Lm, and Rm values using the phase shift 32 | method. To find the crystal's parallel resonant frequency it looks for the 33 | point of highest loss. When the measurement is finished the NanoVNA's 34 | sweep frequency range will be restored to what it was before the script was run. 35 | 36 | ## How to use 37 | 38 | First set the start and stop "stimulus" values of the Nanovna to encompass the range 39 | of frequencies you expect to see from your batch of crystals. The 40 | frequency span should be large enough to capture 41 | both the series and parallel resonant points of your crystals. I 42 | have been using a span of 100KHz. 43 | 44 | Next calibrate the thru port of the Nanovna using the "calib" menu. 45 | Now you can run the script to make the measurement. Pass the --stray option 46 | if you want to measure the holder capacitance. 47 | To tell crystalweb explicitly what the start and stop frequency of the span, 48 | pass the options --start and --stop to the program, otherwise it 49 | will query the range from the NanoVNA UI. 50 | 51 | The arguments to the python script are as follows: 52 | 53 | { run("crystalweb.py --help") } 54 | 55 | ## Measuring the Fixture Capacitance 56 | 57 | Besides measuring crystals, the script can also measure the 58 | stray capacitance of the test fixture using the "--fixture" measurement option. 59 | When measuring the crystal, pass this stray capacitance value to crystalweb 60 | for finding the crystal's holder capacitance. 61 | 62 | The steps to find the stray capacitance of your fixture are as follows: 63 | 64 | 1. First calibrate the thru of the NanoVNA. 65 | 2. Next leave the fixture open and run "crystalweb.py --fixture". The program will measure and output 66 | the fixture's stray capacitance. 67 | 68 | To find the dB loss through the fixture instead of the stray capacitance use the "--loss" option. 69 | 70 | ## An Example 71 | 72 | The script when it measures the crystal writes the results it finds 73 | immediately to stderr. At the conclusion of the measurement, the script 74 | will write a final comma separated 75 | formatted line, listing all the measurements previously found, to stdout. 76 | The CSV header for this line is 'PART,FS,CM,LM,RM,QU,CO'. 77 | 78 | For example, I executed the following to measure a 7.03 Mhz crystal: 79 | 80 | ``` 81 | $ crystalweb.py --stray 1.1 82 | PART: X1 83 | RL = 50.0 ohm 84 | fs = 7027674 Hz 85 | Rm = 18.39 ohm 86 | Cm = 0.0149 pF 87 | Lm = 34.3163 mH 88 | Qu = 82401 89 | fp = 7039743 Hz 90 | Co = 3.25138 pF 91 | X1,7027674,1.4946e-14,0.034316,18.39,82401,3.2514e-12 92 | ``` 93 | 94 | ### My test fixture 95 | 96 | ![](fixture.jpg) 97 | 98 | ### Quantifying A Batch of 100 Crystals. 99 | 100 | You can use the --batch option to quickly 101 | measure a batch of crystals. It will repeatedly run 102 | the measurement after prompting you to change the crystal. 103 | 104 | Below are the results from crystalweb, put in 105 | graph form using Pandas, after measuring 106 | 100 crystals from a 11.0570 Mhz batch. 107 | 108 | ![](batch.png) 109 | 110 | The top ten crystals, the ones with the highest unloaded Q, 111 | have the following characteristics: 112 | 113 | ![](topten.png) 114 | 115 | """) 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /topten.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roseengineering/crystalweb/49402b2afa44f065149ffe4eb6615b6d8b9cfae5/topten.png --------------------------------------------------------------------------------