@@ -830,6 +830,9 @@ else: # if port found and connected, then complete task(s) and disconnect
830830
831831#### ** Example 4: Plot a Waterfall using SCAN and Calculated Frequencies**
832832
833+ The first part of this example is a static report of the measurements taken over time. The time will vary a bit from the resolution. Data is collected and then displayed with matplotlib.
834+
835+
833836``` python
834837# import tinySA library
835838# (NOTE : check library path relative to script path)
@@ -997,9 +1000,308 @@ else: # if port found and connected, then complete task(s) and disconnect
9971000 tsa.disconnect()
9981001```
9991002<p align =" center " >
1000- <img src="media/example4_waterfall_1.png" alt="Waterfall Plot for SCAN Data Over 50 Readings" height="350">
1003+ <img src="media/example4_waterfall_1.png" alt="Static Waterfall Plot for SCAN Data Over 50 Readings" height="350">
10011004</p >
1002- <p align =" center " >Waterfall Plot for SCAN Data Over 50 Readings</p >
1005+ <p align =" center " >Static Waterfall Plot for SCAN Data Over 50 Readings</p >
1006+
1007+
1008+
1009+
1010+
1011+
1012+ The second part of the example is a realtime waterfall plot with peak tracking and a sample of the last reading.
1013+
1014+ ``` python
1015+ # import tinySA library
1016+ # (NOTE : check library path relative to script path)
1017+ from src.tinySA_python import tinySA
1018+
1019+ # imports FOR THE EXAMPLE
1020+ import numpy as np
1021+ import matplotlib.pyplot as plt
1022+ import matplotlib.animation as animation
1023+ from collections import deque
1024+ import time
1025+ from datetime import datetime
1026+ import threading
1027+ import queue
1028+
1029+ def convert_data_to_arrays (start , stop , pts , data ):
1030+ # Convert the raw tinySA data to frequency and power arrays.
1031+ # using the start and stop frequencies, and the number of points,
1032+ freq_arr = np.linspace(start, stop, pts) # note that the decimals might go out to many places.
1033+ # you can truncate this because its only used
1034+ # for plotting in this example
1035+ # As of the Jan. 2024 build in some data returned with SWEEP or SCAN calls there is error data.
1036+ # https://groups.io/g/tinysa/topic/tinasa_ultra_sweep_command/104194367
1037+ # this shows up as "-:.000000e+01".
1038+ # TEMP fix - replace the colon character with a -10. This puts the 'filled in' points around the noise floor.
1039+ # more advanced filtering should be applied for actual analysis.
1040+
1041+ data1 = bytearray (data.replace(b " -:.0" , b " -10.0" ))
1042+
1043+ # Get first value in each returned row (power in dBm)
1044+ try :
1045+ data_arr = [float (line.split()[0 ]) for line in data1.decode(' utf-8' ).split(' \n ' ) if line.strip()]
1046+ except (ValueError , IndexError ):
1047+ # If parsing fails, return zeros
1048+ data_arr = [0.0 ] * pts
1049+
1050+ # Ensure data array matches frequency array length
1051+ if len (data_arr) != pts:
1052+ # Pad or truncate to match expected points
1053+ # We do this to visualize what might be going wrong rather than outright throwing an error
1054+ # -100 is a very low noise floor, especially for a hand held device, so it's not a normal reading
1055+ if len (data_arr) < pts:
1056+ data_arr.extend([data_arr[- 1 ] if data_arr else - 100.0 ] * (pts - len (data_arr)))
1057+ else :
1058+ data_arr = data_arr[:pts]
1059+
1060+ return freq_arr, np.array(data_arr)
1061+
1062+ class LiveSpectrumPlotter :
1063+ def __init__ (self , tsa , start , stop , pts , outmask , max_history = 50 ):
1064+ self .tsa = tsa
1065+ self .start = start
1066+ self .stop = stop
1067+ self .pts = pts
1068+ self .outmask = outmask
1069+ self .max_history = max_history
1070+
1071+ # Data storage
1072+ self .freq_arr = None
1073+ self .power_history = deque(maxlen = max_history)
1074+ self .timestamps = deque(maxlen = max_history)
1075+
1076+ # Threading for data acquisition
1077+ self .data_queue = queue.Queue()
1078+ self .running = False
1079+ self .data_thread = None
1080+
1081+ # Current data for single-trace plots
1082+ self .current_power = None
1083+
1084+ # Twin axis reference for proper cleanup
1085+ self .ax3_twin = None
1086+
1087+ def data_acquisition_thread (self ):
1088+ # Background thread for continuous data acquisition
1089+ while self .running:
1090+ try :
1091+ # Get scan data
1092+ data_bytes = self .tsa.scan(self .start, self .stop, self .pts, self .outmask)
1093+
1094+ # Convert to arrays
1095+ freq_arr, power_arr = convert_data_to_arrays(
1096+ self .start, self .stop, self .pts, data_bytes)
1097+
1098+ # Put data in queue for main thread
1099+ self .data_queue.put({
1100+ ' freq' : freq_arr,
1101+ ' power' : power_arr,
1102+ ' timestamp' : datetime.now()
1103+ })
1104+
1105+ time.sleep(0.2 ) # Small delay to prevent overwhelming the device
1106+
1107+ except Exception as e:
1108+ print (f " Data acquisition error: { e} " )
1109+ time.sleep(0.5 ) # Wait a bit before retrying
1110+ continue
1111+
1112+ def start_acquisition (self ):
1113+ # Start the data acquisition thread
1114+ self .running = True
1115+ self .data_thread = threading.Thread(target = self .data_acquisition_thread)
1116+ self .data_thread.daemon = True
1117+ self .data_thread.start()
1118+
1119+ def stop_acquisition (self ):
1120+ # Stop the data acquisition thread
1121+ self .running = False
1122+ if self .data_thread:
1123+ self .data_thread.join()
1124+
1125+ def update_plots (self , frame ):
1126+ # Update the matplotlib plots with new data
1127+
1128+ # Get all available data from queue
1129+ while not self .data_queue.empty():
1130+ try :
1131+ data = self .data_queue.get_nowait()
1132+
1133+ # Store frequency array (first time only)
1134+ if self .freq_arr is None :
1135+ self .freq_arr = data[' freq' ]
1136+
1137+ # Update current data
1138+ self .current_power = data[' power' ]
1139+
1140+ # Add to history
1141+ self .power_history.append(data[' power' ])
1142+ self .timestamps.append(data[' timestamp' ])
1143+
1144+ except queue.Empty:
1145+ break
1146+
1147+ # Clear plots
1148+ ax1.clear() # Waterfall
1149+ ax2.clear() # Live spectrum
1150+ ax3.clear() # Peak tracking
1151+
1152+ # Clear any existing twin axes completely
1153+ if hasattr (self , ' ax3_twin' ) and self .ax3_twin is not None :
1154+ self .ax3_twin.clear()
1155+ self .ax3_twin.remove()
1156+ self .ax3_twin = None
1157+
1158+ if self .freq_arr is not None and self .current_power is not None :
1159+ # Plot 1: Waterfall (left side - larger)
1160+ if len (self .power_history) > 1 :
1161+ waterfall_data = np.array(list (self .power_history))
1162+ # Create time array in reverse order so newest (highest index) appears at top
1163+ time_arr = np.arange(len (waterfall_data))
1164+ freq_mesh, time_mesh = np.meshgrid(self .freq_arr, time_arr)
1165+
1166+ im = ax1.pcolormesh(freq_mesh/ 1e9 , time_mesh, waterfall_data,
1167+ shading = ' nearest' , cmap = ' viridis' )
1168+ ax1.set_xlabel(' Frequency (GHz)' )
1169+ ax1.set_ylabel(' Scan Number (newest at top)' )
1170+ ax1.set_title(' Spectrum History (Waterfall)' )
1171+
1172+ # Add colorbar to waterfall plot
1173+ if not hasattr (self , ' colorbar_created' ):
1174+ self .colorbar = plt.colorbar(im, ax = ax1, shrink = 0.8 )
1175+ self .colorbar.set_label(' Power (dBm)' )
1176+ self .colorbar_created = True
1177+
1178+ # Plot 2: Current Spectrum (top right)
1179+ ax2.plot(self .freq_arr/ 1e9 , self .current_power, ' b-' , linewidth = 1.5 )
1180+ ax2.set_xlabel(' Frequency (GHz)' )
1181+ ax2.set_ylabel(' Power (dBm)' )
1182+ ax2.set_title(' Live Spectrum' )
1183+ ax2.grid(True , alpha = 0.3 )
1184+
1185+ # Set reasonable y-axis limits
1186+ if len (self .current_power) > 0 :
1187+ y_min = np.min(self .current_power) - 5
1188+ y_max = np.max(self .current_power) + 5
1189+ ax2.set_ylim(y_min, y_max)
1190+
1191+ # Plot 3: Peak tracking over time (bottom right)
1192+ if len (self .power_history) > 1 :
1193+ peak_powers = [np.max(scan) for scan in self .power_history]
1194+ peak_freqs = [self .freq_arr[np.argmax(scan)]/ 1e9 for scan in self .power_history]
1195+
1196+ # Plot peak power over time
1197+ scan_numbers = list (range (len (peak_powers)))
1198+
1199+ # Create fresh twin axis for frequency (store reference for proper cleanup)
1200+ self .ax3_twin = ax3.twinx()
1201+
1202+ ax3.plot(scan_numbers, peak_powers, ' r-o' , markersize = 2 ,
1203+ label = ' Peak Power' , linewidth = 1.5 )
1204+ self .ax3_twin.plot(scan_numbers, peak_freqs, ' g-s' , markersize = 2 ,
1205+ label = ' Peak Freq' , linewidth = 1.5 )
1206+
1207+ ax3.set_xlabel(' Scan Number' )
1208+ ax3.set_ylabel(' Peak Power (dBm)' , color = ' r' )
1209+ self .ax3_twin.set_ylabel(' Peak Freq (GHz)' , color = ' g' )
1210+ ax3.set_title(' Peak Tracking' )
1211+ ax3.grid(True , alpha = 0.3 )
1212+
1213+ # Color the y-axis labels to match the lines
1214+ ax3.tick_params(axis = ' y' , labelcolor = ' r' , labelsize = 8 )
1215+ self .ax3_twin.tick_params(axis = ' y' , labelcolor = ' g' , labelsize = 8 )
1216+
1217+ # Force immediate redraw of the twin axis
1218+ self .ax3_twin.relim()
1219+ self .ax3_twin.autoscale_view()
1220+
1221+ # Add timestamp and scan info
1222+ if self .timestamps:
1223+ scan_count = len (self .timestamps)
1224+ time_str = self .timestamps[- 1 ].strftime(" %H:%M:%S" )
1225+ fig.suptitle(f ' Live tinySA Spectrum - { time_str} (Scan # { scan_count} ) ' ,
1226+ fontsize = 14 )
1227+
1228+
1229+ if __name__ == " __main__" :
1230+ # create a new tinySA object
1231+ tsa = tinySA()
1232+ # set the return message preferences
1233+ tsa.set_verbose(True )
1234+ tsa.set_error_byte_return(True )
1235+
1236+ # attempt to autoconnect
1237+ found_bool, connected_bool = tsa.autoconnect()
1238+
1239+ if not connected_bool:
1240+ print (" ERROR: could not connect to port" )
1241+ else :
1242+ try :
1243+ print (" Starting live spectrum measurement..." )
1244+ print (" Close the plot window to stop measurement" )
1245+
1246+ # Scan parameters
1247+ start = int (1e9 ) # 1 GHz
1248+ stop = int (3e9 ) # 3 GHz
1249+ pts = 200 # Reduced points for faster updates
1250+ outmask = 2 # get measured data
1251+
1252+ # Create plotter
1253+ plotter = LiveSpectrumPlotter(tsa, start, stop, pts, outmask, max_history = 30 )
1254+
1255+ # Set up the plot - 2x2 layout with waterfall taking left column
1256+ fig = plt.figure(figsize = (14 , 10 ))
1257+
1258+ # Create grid layout: waterfall on left (spans 2 rows), two plots on right
1259+ gs = fig.add_gridspec(2 , 2 , width_ratios = [2 , 1 ], height_ratios = [1 , 1 ],
1260+ hspace = 0.3 , wspace = 0.3 )
1261+
1262+ ax1 = fig.add_subplot(gs[:, 0 ]) # Waterfall - spans both rows, left column
1263+ ax2 = fig.add_subplot(gs[0 , 1 ]) # Live spectrum - top right
1264+ ax3 = fig.add_subplot(gs[1 , 1 ]) # Peak tracking - bottom right
1265+
1266+ # Start data acquisition
1267+ plotter.start_acquisition()
1268+
1269+ # Create animation
1270+ ani = animation.FuncAnimation(fig, plotter.update_plots,
1271+ interval = 300 , blit = False )
1272+
1273+ # Show plot (this blocks until window is closed)
1274+ plt.show()
1275+
1276+ # Cleanup
1277+ plotter.stop_acquisition()
1278+ tsa.resume()
1279+ tsa.disconnect()
1280+
1281+ print (" Live measurement stopped" )
1282+
1283+ except KeyboardInterrupt :
1284+ print (" \n Measurement interrupted by user" )
1285+ tsa.resume()
1286+ tsa.disconnect()
1287+ except Exception as e:
1288+ print (f " Error occurred: { e} " )
1289+ tsa.resume()
1290+ tsa.disconnect()
1291+
1292+ ```
1293+
1294+
1295+ <p align =" center " >
1296+ <img src="media/example5_waterfall_realtime.png" alt="Realtime Waterfall Plot for SCAN Data" height="350">
1297+ </p >
1298+ <p align =" center " >Realtime Waterfall Plot for SCAN Data</p >
1299+
1300+
1301+
1302+
1303+
1304+
10031305
10041306
10051307### Saving SCAN Data to CSV
0 commit comments