1#!/usr/local/bin/python3.8
2
3# python3 status: compatible
4
5import sys, os
6
7# system libraries : test, then import as local symbols
8from afnipy import module_test_lib
9testlibs = ['signal', 'time']
10if module_test_lib.num_import_failures(testlibs): sys.exit(1)
11import signal, time
12
13# AFNI libraries (besides module_test_lib)
14from afnipy import option_list as OL
15from afnipy import lib_realtime as RT
16from afnipy import afni_util as UTIL
17
18# ----------------------------------------------------------------------
19# globals
20
21g_help_string = """
22=============================================================================
23realtime_receiver.py - program to receive and display real-time plugin data
24
25   This program receives motion parameters and optionally ROI averages
26   or voxel data each TR from the real-time plugin to afni.  Which data
27   will get sent is controlled by the real-time plugin.  All data is
28   sent as floats.
29
30   Motion parameters: 6 values per TR
31   ROI averages:      N values per TR, where N is the number of ROIs
32   All voxel data:    8 values per voxel per TR (might be a lot of data!)
33                        The 8 values include voxel index, 3 ijk indices,
34                        the 3 xyz coordinates, and oh yes, the data
35
36   Examples:
37
38     1a. Run in test mode to display verbose data on the terminal window.
39
40        realtime_receiver.py -show_data yes
41
42     1b. Run in test mode to just display motion to the terminal.
43
44        realtime_receiver.py -write_text_data stdout
45
46     1c. Write all 'extra' parameters to file my_data.txt, one set
47         per line.
48
49        realtime_receiver.py -write_text_data my_data.txt \\
50                             -data_choice all_extras
51
52     2. Provide a serial port, sending the Euclidean norm of the motion params.
53
54        realtime_receiver.py -show_data yes -serial_port /dev/ttyS0  \\
55                             -data_choice motion_norm
56
57     3. Run a feedback demo.  Assume that the realtime plugin will send 2
58        values per TR.  Request the receiver to plot (a-b)/(a+b), scaled
59        to some small integral range.
60
61        realtime_receiver.py -show_demo_gui yes -data_choice diff_ratio
62
63     4. Adjust the defaults of the -data_choice diff_ratio parameters from
64        those for AFNI_data6/realtime.demos/demo.2.fback.1.receiver, to those
65        for the s620 demo:
66
67        realtime_receiver.py -show_demo_gui yes -data_choice diff_ratio \
68                             -dc_params 0.008 43.5
69
70   TESTING NOTE:
71
72        This following setup can be tested off-line using Dimon, afni and this
73        realtime_receiver.py program.  Note that while data passes from Dimon
74        to afni to realtime_receiver.py, the programs essentially should be
75        started in the reverse order (so that the listener is always ready for
76        the talker, say).
77
78        See the sample scripts:
79
80             AFNI_data6/realtime.demos/demo.2.fback.*
81
82        step 1. start the receiver: demo.2.fback.1.receiver
83
84             realtime_receiver.py -show_data yes -show_demo_gui yes \\
85                                  -data_choice diff_ratio
86
87        step 2. start realtime afni: demo.2.fback.2.afni
88
89             Note: func_slim+orig is only loaded to ensure a multiple
90                   volume overlay dataset, so that the rtfeedme command
91                   "DRIVE_AFNI SET_SUBBRICKS 0 1 1" finds sub-brick 1.
92
93             # set many REALTIME env vars or in afni's realtime plugin
94             setenv AFNI_REALTIME_Registration  3D:_realtime
95             setenv AFNI_REALTIME_Base_Image    2
96             setenv AFNI_REALTIME_Graph         Realtime
97             setenv AFNI_REALTIME_MP_HOST_PORT  localhost:53214
98             setenv AFNI_REALTIME_SEND_VER      YES
99             setenv AFNI_REALTIME_SHOW_TIMES    YES
100             setenv AFNI_REALTIME_Mask_Vals     ROI_means
101             setenv AFNI_REALTIME_Function      FIM
102
103             cd ../afni
104             afni -rt -yesplugouts                     \\
105                  -com "SWITCH_UNDERLAY epi_r1+orig"   \\
106                  -com "SWITCH_OVERLAY func_slim+orig" &
107
108             # at this point, the user should open a graph window and:
109             #    FIM->Ignore->2
110             #    FIM->Pick Ideal->epi_r1_ideal.1D
111
112        step 3. feed data to afni (can be repeated): demo.2.fback.3.feedme
113
114             cd ../afni
115             set episet  = epi_r1+orig
116             set maskset = mask.left.vis.aud+orig
117
118             plugout_drive -com "SETENV AFNI_REALTIME_Mask_Dset $maskset" -quit
119
120             rtfeedme                                                        \\
121               -drive 'DRIVE_AFNI OPEN_WINDOW axialimage geom=285x285+3+533' \\
122               -drive 'DRIVE_AFNI OPEN_WINDOW axialgraph keypress=A'         \\
123               -drive 'DRIVE_AFNI SET_SUBBRICKS 0 1 1'                       \\
124               -drive 'DRIVE_AFNI SET_DICOM_XYZ 52 4 12'                     \\
125               -drive 'DRIVE_AFNI SET_FUNC_RANGE 0.9'                        \\
126               -drive 'DRIVE_AFNI SET_THRESHNEW 0.4'                         \\
127               -dt 200 -3D $episet
128
129
130   COMMUNICATION NOTE:
131
132        This program listens for connections at TCP port 53214, unless an
133        alternate port is specified.  The real-time plugin (or some other
134        program) connects at that point, opening a new data socket.  There
135        is a "handshake" on the data socket, and then data is recieved until
136        a termination signal is received (or the socket goes bad).
137
138        Data is sent per run, meaning the connection should be terminated
139        and restarted at the end of each run.
140
141        The handshake should be the first data on the data socket (per run).
142        The real-time plugin (or other program) will send the hello bytes:
143        0xabcdefab, where the final byte may be incremented by 0, 1 or 2
144        to set the version number, e.g. use 0xabcdefac for version 1.
145
146           Version 0: only motion will be sent
147           Version 1: motion plus N ROI averages will be sent
148           Version 2: motion plus all voxel data for N voxels will be sent
149                      - this is dense - 8 values per voxel
150                      - 1Dindex,  i, j, k,  x, y, z,  value
151           Version 3: motion plus voxel data for N voxels will be sent
152                      - "light" version of 2, only send one 'value' per voxel
153           Version 4: mix of 1 and 3: motion, N ROI aves, M voxel values
154
155        If the version is 1, 2 or 3, the 4-byte handshake should be followed
156        by a 4-byte integer, specifying the value of N.  Hence, the
157        combination of the version number and any received N will determine
158        how much data will be sent to the program each TR.
159
160        For version 4, the 4-byte handshake should be followed by 2 4-byte
161        integers, one to specify N (# ROI aves), one to specify M (# vox).
162
163        At the end of the run, the sending program should send the 4-byte
164        good-bye sequence: 0xdeaddead.
165
166   This program is based on the structure of serial_helper, but because
167   it is meant as a replacement, it will have different options.
168
169   ------------------------------------------
170   Options:
171
172   terminal options:
173
174      -help                     : show this help
175      -hist                     : show module history
176      -show_valid_opts          : list valid options
177      -ver                      : show current version
178
179   other options
180      -data_choice CHOICE       : pick which data to send as feedback
181                   motion       : send the 6 motion parameters
182                   motion_norm  : send the Euclidean norm of them
183                   all_extras   : send all 'extra' values (ROI or voxel values)
184                   diff_ratio   :  (a-b)/(abs(a)+abs(b)) for 2 'extra' values
185         * To add additional CHOICE methods, see the function compute_TR_data().
186      -dc_params P1 P2 ...      : set data_choice parameters
187                                  e.g. for diff_ratio, parmas P1 P2
188                                     P1 = dr low limit, P2 = scalar -> [0,1]
189                                     result is (dr-P1)*P2  {applied in [0,1]}
190      -serial_port PORT         : specify serial port file for feedback data
191      -show_comm_times          : display communication times
192      -show_data yes/no         : display incoming data in terminal window
193      -show_demo_data           : display feedback data in terminal window
194      -show_demo_gui            : demonstrate a feedback GUI
195      -swap                     : swap bytes incoming data
196      -tcp_port PORT            : specify TCP port for incoming connections
197      -verb LEVEL               : set the verbosity level
198      -write_text_data FNAME    : write data to text file 'FNAME'
199
200-----------------------------------------------------------------------------
201R Reynolds    July 2009
202=============================================================================
203"""
204g_history = """
205   realtime_receiver.py history:
206
207   0.0  Jul 06, 2009 : initial version (show data, no serial, little help)
208   0.1  Jul 16, 2009 : includes optional serial connection
209   0.2  Aug 04, 2009 : added basic demo interface and itemized exception traps
210   0.3  Sep 08, 2009 : bind to open host (so /etc/hosts entry is not required)
211   0.4  Jul 26, 2012 : added -show_comm_times
212   0.5  Jan 16, 2013 : added -dc_params
213   0.6  Sep 16, 2016 : proceed even if requested GUI fails to load
214   1.0  Jan 01, 2018 : python3 compatible, added -write_text_data
215   1.1  Jan 22, 2020 : added handling of magic version 3 (all data light)
216   1.2  Jan 23, 2020 : added handling of magic version 4 (ROIs and data)
217"""
218
219g_version = "realtime_receiver.py version 1.2, January 23, 2020"
220
221g_RTinterface = None      # global reference to main class (for signal handler)
222
223# ----------------------------------------------------------------------
224# In this module, handing signals and options.  Try to keep other
225# operations in separate libraries (e.g. lib_realtime.py).
226# ----------------------------------------------------------------------
227
228class ReceiverInterface:
229   """main interface for realtime_receiver.py"""
230   def __init__(self):
231
232      self.valid_opts      = None
233      self.user_opts       = None
234      self.data_choice     = 'motion'
235      self.TR_data         = []            # store computed TR data
236      self.verb            = 1
237      self.serial_port     = None          # serial port (filename)
238
239      # lib_realtime.py class instances
240      self.RTI             = None          # real-time interface RTInterface
241      self.SER             = None          # serial port interface Serial
242      self.TEXT            = None          # text file interface
243
244      # data choice parameters
245      self.dc_params       = []
246
247      # demo attributes
248      self.show_demo_data  = 0
249      self.demo_frame      = None          # for demo plot
250      self.wx_app          = None          # wx App for demo plot
251
252      self.valid_opts = self.init_options()
253
254   def init_options(self):
255      """return an option list instance"""
256
257      valid_opts = OL.OptionList('valid opts')
258
259      # short, terminal arguments
260      valid_opts.add_opt('-help', 0, [],
261                      helpstr='display program help')
262      valid_opts.add_opt('-hist', 0, [],
263                      helpstr='display the modification history')
264      valid_opts.add_opt('-show_valid_opts', 0, [],
265                      helpstr='display all valid options')
266      valid_opts.add_opt('-ver', 0, [],
267                      helpstr='display the current version number')
268
269      # general options
270      valid_opts.add_opt('-verb', 1, [],
271                      helpstr='set the verbose level (default is 1)')
272
273      valid_opts.add_opt('-data_choice', 1, [],
274                      helpstr='which data to send (motion, motion_norm,...)')
275      valid_opts.add_opt('-dc_params', -2, [],
276                      helpstr='set parameters for data_choice processing')
277      valid_opts.add_opt('-serial_port', 1, [],
278                      helpstr='serial port filename (e.g. /dev/ttyS0 or COM1)')
279      valid_opts.add_opt('-show_data', 1, [],
280                      acplist=['no', 'yes'],
281                      helpstr='whether to display received data in terminal')
282      valid_opts.add_opt('-show_comm_times', 0, [],
283                      helpstr='display communication times')
284      valid_opts.add_opt('-write_text_data', 1, [],
285                      helpstr='write data to text file')
286
287      # demo options
288      valid_opts.add_opt('-show_demo_data', 1, [],
289                      acplist=['no', 'yes'],
290                      helpstr='whether to display demo data in terminal')
291      valid_opts.add_opt('-show_demo_gui', 1, [],
292                      acplist=['no', 'yes'],
293                      helpstr='whether to display demo data in a GUI')
294
295      valid_opts.add_opt('-swap', 0, [],
296                      helpstr='byte-swap numerical reads')
297      valid_opts.add_opt('-tcp_port', 1, [],
298                      helpstr='TCP port for incoming connections')
299
300      return valid_opts
301
302
303   def check_terminal_opts(self):
304      """check argv for terminal options, start with a global library
305         call to check_special_opts"""
306
307      self.valid_opts.check_special_opts(sys.argv)
308
309      # if no arguments are given, apply -help
310      if len(sys.argv) < 2 or '-help' in sys.argv:
311         print(g_help_string)
312         return 1
313
314      if '-hist' in sys.argv:
315         print(g_history)
316         return 1
317
318      if '-show_valid_opts' in sys.argv:
319         self.valid_opts.show('', 1)
320         return 1
321
322      if '-ver' in sys.argv:
323         print(g_version)
324         return 1
325
326      return 0
327
328   def process_options(self):
329      """process all options, applying to interfaces where appropriate"""
330
331      # ==================================================
332      # first fire up the TCP interface
333
334      self.RTI = RT.RTInterface()
335      if not self.RTI: return None
336
337      # and store globally
338      global g_RTinterface
339      g_RTinterface = self.RTI      # global for signal handler
340
341      # ==================================================
342      # gather and process the user options
343      self.user_opts = OL.read_options(sys.argv, self.valid_opts)
344      uopts = self.user_opts            # for convenience
345      if not uopts: return 1
346
347      # process -verb first
348      val, err = uopts.get_type_opt(int, '-verb')
349      if val != None and not err:
350         self.verb = val
351         self.RTI.verb = val
352
353      # ==================================================
354      # --- serial options ---
355
356      # port first: if set, create SerialInterface
357      val, err = uopts.get_string_opt('-serial_port')
358      if val != None and not err:
359         self.SER = RT.SerialInterface(val, verb=self.verb)
360         if not self.SER: return 1
361
362      # ==================================================
363      # --- feedback options ---
364
365      val, err = uopts.get_string_opt('-data_choice')
366      if val != None and not err: self.data_choice = val
367
368      val, err = uopts.get_type_list(float, '-dc_params')
369      if val != None and not err: self.dc_params = val
370
371      # ==================================================
372      # --- tcp options ---
373
374      val, err = uopts.get_string_opt('-show_data')
375      if val != None and not err:
376         if val == 'no': self.RTI.show_data = 0
377         else:           self.RTI.show_data = 1
378
379      if uopts.find_opt('-show_comm_times'): self.RTI.show_times = 1
380      if uopts.find_opt('-swap'): self.RTI.swap = 1
381
382      val, err = uopts.get_type_opt(int, '-tcp_port')
383      if val != None and not err: self.RTI.server_port = val
384
385      # ==================================================
386      # --- text file writing options ---
387
388      # open text file
389      val, err = uopts.get_string_opt('-write_text_data')
390      if val != None and not err:
391         self.TEXT = RT.TextFileInterface(val, verb=self.verb)
392         if not self.TEXT: return 1
393
394      # ==================================================
395      # --- demo options ---
396
397      val, err = uopts.get_string_opt('-show_demo_data')
398      if val != None and not err:
399         if val == 'no': self.show_demo_data = 0
400         else:           self.show_demo_data = 1
401
402      val, err = uopts.get_string_opt('-show_demo_gui')
403      if val != None and not err:
404         if val == 'yes':
405            if self.set_demo_gui():
406               print('\n** GUI demo failed, proceeding without GUI...\n')
407
408      return 0  # so continue and listen
409
410   def set_demo_gui(self):
411      """create the GUI for display of the demo data"""
412      testlibs = ['numpy', 'wx']
413      if module_test_lib.num_import_failures(testlibs):
414         return 1
415      try:
416         import numpy as N, wx
417         from afnipy import lib_RR_plot as LPLOT
418      except:
419         return 1
420
421      self.wx_app = wx.App()
422      self.demo_frame = LPLOT.CanvasFrame(title='receiver demo')
423      self.demo_frame.EnableCloseButton(True)
424      self.demo_frame.Show(True)
425      self.demo_frame.style  = 'bar'
426      self.demo_frame.xlabel = 'most recent 10 TRs'
427      self.demo_frame.ylabel = 'scaled diff_ratio'
428
429      # for the current demo, set an ranges for 10 numbers in [0,10]
430      if self.demo_frame.style == 'graph':
431         self.demo_frame.set_limits(0,9.1,-0.1,10.1)
432      elif self.demo_frame.style == 'bar':
433         self.demo_frame.set_limits(0,10.1,-0.1,10.1)
434
435   def set_signal_handlers(self):
436      """capture common termination signals, to properly close ports"""
437
438      if self.verb > 1: print('++ setting signals')
439
440      slist = [ signal.SIGHUP, signal.SIGINT, signal.SIGQUIT, signal.SIGTERM ]
441      if self.verb > 2: print('   signals are %s' % slist)
442
443      for sig in slist: signal.signal(sig, clean_n_exit)
444
445      return
446
447   def close_data_ports(self):
448      """close TCP and socket ports, except for server port"""
449
450      if self.RTI:  self.RTI.close_data_ports()
451      if self.SER:  self.SER.close_data_ports()
452      if self.TEXT: self.TEXT.close_text_file()
453
454   def process_demo_data(self):
455
456      length = len(self.TR_data)
457      if length == 0: return
458
459      if self.show_demo_data:
460         print('-- TR %d, demo value: ' % length, self.TR_data[length-1][0])
461      if self.demo_frame:
462         if length > 10: bot = length-10
463         else: bot = 0
464         pdata = [self.TR_data[ind][0] for ind in range(bot,length)]
465         self.demo_frame.plot_data(pdata)
466
467   def process_one_TR(self):
468      """return 0 to continue, 1 on valid termination, -1 on error"""
469
470      if self.verb>2:
471         print('-- process_one_TR, show_demo_data = %d,' % self.show_demo_data)
472
473      rv = self.RTI.read_TR_data()
474      if rv:
475         if self.verb > 3: print('** process 1 TR: read data failure')
476         return rv
477
478      rv, data = compute_TR_data(self)  # PROCESS DATA HERE
479      if rv or len(data) == 0: return rv
480      self.TR_data.append(data)
481
482      if self.SER: self.SER.write_4byte_data(data)
483      if self.TEXT: self.TEXT.write_data_line(data)
484      if self.show_demo_data or self.demo_frame: self.process_demo_data()
485
486      return rv
487
488   def process_one_run(self):
489      """repeatedly: process all incoming data for a single run
490         return  0 on success and 1 on error
491      """
492
493      # clear any old data
494      if len(self.TR_data) > 0:
495         del(self.TR_data)
496         self.TR_data = []
497
498      # wait for the real-time plugin to talk to us
499      if self.RTI.wait_for_new_run(): return 1
500
501      # possibly open a serial port
502      if self.SER:
503         if self.SER.open_data_port(): return 1
504
505      # possibly open a text file
506      if self.TEXT:
507         if self.TEXT.open_text_file(): return 1
508
509      # process one TR at a time until
510      if self.verb > 1:
511         print('-- incoming data, data_choice = %s' % self.data_choice)
512
513      rv = self.process_one_TR()
514      while rv == 0:
515         rv = self.process_one_TR()
516         sys.stdout.flush()
517
518      if self.verb > 1:
519         if rv > 0: vstr = '(terminating on success)'
520         else:      vstr = '(terminating on error)'
521         print('-- processed %d TRs of data %s' % (self.RTI.nread, vstr))
522      if self.verb > 0: print('-'*60)
523      sys.stdout.flush()
524
525      if rv > 0: return 0               # success for one run
526      else:      return 1               # some error
527
528def clean_n_exit(signum, frame):
529
530   verb = g_RTinterface.verb
531
532   if verb > 1: print('++ signal handler called with signal', signum)
533
534   g_RTinterface.close_data_ports()
535   try: sys.stdout.flush()
536   except: pass
537
538   # at last, close server port
539   if g_RTinterface.server_sock:
540      if g_RTinterface.verb > 1: print('closing server port...')
541      try: g_RTinterface.server_sock.close()
542      except (RT.socket.error, RT.socket.timeout): pass
543
544   if g_RTinterface.verb > 0: print('-- exiting on signal %d...' % signum)
545   sys.exit(signum)
546
547def compute_TR_data(rec):
548   """If writing to the serial port, this is the main function to compute
549      results from rec.motion and/or rec.extras for the current TR and
550      return it as an array of floats.
551
552      Note that motion and extras are lists of time series of length nread,
553      so processing a time series is easy, but a single TR requires extracting
554      the data from the end of each list.
555
556      The possible computations is based on data_choice, specified by the user
557      option -data_choice.  If you want to send data that is not listed, just
558      add a condition.
559
560   ** Please add each data_choice to the -help.  Search for motion_norm to
561      find all places to edit.
562
563      return 2 items:
564        error code:     0 on success, -1 on error
565        data array:     (possibly empty) array of data to send
566   """
567
568   rti = rec.RTI       # for convenience
569   if not rec.data_choice: return 0, []
570
571   # case 'motion': send all motion
572   if rec.data_choice == 'motion':
573      if rti.nread > 0:
574         return 0, [rti.motion[ind][rti.nread-1] for ind in range(6)]
575      else: return -1, []
576
577   # case 'motion_norm': send Euclidean norm of motion params
578   #                     --> sqrt(sum of squared motion params)
579   elif rec.data_choice == 'motion_norm':
580      if rti.nread > 0:
581         motion = [rti.motion[ind][rti.nread-1] for ind in range(6)]
582         return 0, [UTIL.euclidean_norm(motion)]
583      else: return -1, []
584
585   # case 'all_extras': send all extra data
586   elif rec.data_choice == 'all_extras':
587      if rti.nextra > 0:
588         return 0, [rti.extras[i][rti.nread-1] for i in range(rti.nextra)]
589      else: return -1, []
590
591   # case 'diff_ratio': (a-b)/(abs(a)+abs(b))
592   elif rec.data_choice == 'diff_ratio':
593      npairs = rti.nextra//2
594      if npairs > 0:
595         vals = [rti.extras[i][rti.nread-1] for i in range(rti.nextra)]
596         # modify vals array, setting the first half to diff_ratio
597         for ind in range(npairs):
598            a = vals[2*ind]
599            b = vals[2*ind+1]
600            if a == 0 and b == 0: newval = 0.0
601            else: newval = (a-b)/float(abs(a)+abs(b))
602
603            # --------------------------------------------------------------
604            # VERY data dependent: convert from diff_ratio to int in {0..10}
605            # assume AFNI_data6 demo                             15 Jan 2013
606
607            # now scale [bot,inf) to {0..10}, where val>=top -> 10
608            # AD6: min = -0.1717, mean = -0.1605, max = -0.1490
609
610            bot = -0.17         # s620: bot = 0.008, scale = 43.5
611            scale = 55.0        # =~ 1.0/(0.1717-0.149), rounded up
612            if len(rec.dc_params) == 2:
613               bot = rec.dc_params[0]
614               scale = rec.dc_params[1]
615
616            val = newval-bot
617            if val < 0.0: val = 0.0
618            ival = int(10*val*scale)
619            if ival > 10: ival = 10
620
621            vals[ind] = ival
622
623            if rti.verb > 1:
624               if rti.verb > 2: pstr = ', (params = %s)' % rec.dc_params
625               else:            pstr = ''
626               print('++ diff_ratio: ival = %d (from %s)%s'%(ival,newval,pstr))
627
628            return 0, vals[0:npairs]    # return the partial list
629
630      else:
631         if rti.verb > 0 and rti.nread < 2:
632            print('** no pairs to compute diff_ratio from...')
633         return 0, []
634
635   # failure!
636   else:
637      print("** invalid data_choice '%s', shutting down ..." % rec.data_choice)
638      return -1, []
639
640def main():
641
642   # create main interface
643   receiver = ReceiverInterface()
644   if receiver == None: return 1
645
646   # set options and look for early termination
647   if receiver.check_terminal_opts(): return 0  # then exit
648
649   # read and process user options
650   if receiver.process_options(): return 1
651
652   # ----------------------------------------------------------------------
653   # ready to rock: set signal handlers and look for data
654
655   receiver.set_signal_handlers()                # require signal to exit
656
657   # prepare for incoming connections
658   if receiver.RTI.open_incoming_socket(): return 1
659
660   # repeatedly: process all incoming data for a single run
661   while 1:
662      rv = receiver.process_one_run()
663      if rv: time.sleep(1)              # on error, ponder life briefly
664      receiver.close_data_ports()
665
666   return -1                            # should not be reached
667
668if __name__ == '__main__':
669   sys.exit(main())
670
671