BCI2000Tools 1.0.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
BCI2000Tools/Chain.py ADDED
@@ -0,0 +1,775 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # ///////////////////////////////////////////////////////////////////////////
4
+ # $Id: Chain.py 9041 2025-09-09 20:29:54Z jhill $
5
+ # Author: jeremy.hill@neurotechcenter.org
6
+ # Description: wrapper around BCI2000 command-line tool processing
7
+ #
8
+ # $BEGIN_BCI2000_LICENSE$
9
+ #
10
+ # This file is part of BCI2000, a platform for real-time bio-signal research.
11
+ # [ Copyright (C) 2000-2022: BCI2000 team and many external contributors ]
12
+ #
13
+ # BCI2000 is free software: you can redistribute it and/or modify it under the
14
+ # terms of the GNU General Public License as published by the Free Software
15
+ # Foundation, either version 3 of the License, or (at your option) any later
16
+ # version.
17
+ #
18
+ # BCI2000 is distributed in the hope that it will be useful, but
19
+ # WITHOUT ANY WARRANTY
20
+ # - without even the implied warranty of MERCHANTABILITY or FITNESS FOR
21
+ # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
22
+ #
23
+ # You should have received a copy of the GNU General Public License along with
24
+ # this program. If not, see <http://www.gnu.org/licenses/>.
25
+ #
26
+ # $END_BCI2000_LICENSE$
27
+ # ///////////////////////////////////////////////////////////////////////////
28
+
29
+ """
30
+ This module provides the `bci2000chain()` function and
31
+ supporting tools, the purpose of which is to wrap calls
32
+ to chains of `tools/cmdline/*` BCI2000 binaries.
33
+ """
34
+
35
+ __all__ = [
36
+ 'bci2000chain',
37
+ 'bci2000root', 'bci2000path',
38
+ 'CallbackObject', 'TrapFilterCallbackObject',
39
+ 'BCI2000ChainError', 'TerminateChain',
40
+ ]
41
+
42
+ import os
43
+ import sys
44
+ import ast
45
+ import time
46
+ import struct
47
+ import select
48
+ import weakref
49
+ import warnings
50
+ import tempfile
51
+ import threading
52
+ import subprocess
53
+
54
+ import numpy
55
+
56
+ from . import Bootstrapping; from .Bootstrapping import bci2000root, bci2000path, bci2000_exe_extension
57
+ from . import Parameters; from .Parameters import make_bciprm, read_bcidate
58
+ from . import Container; from .Container import Bunch as CONTAINER
59
+ from . import LoadStream2Mat; from .LoadStream2Mat import ReadMatFile
60
+ from . import Numerics; from .Numerics import pdiff
61
+
62
+ try: from . import Electrodes; from .Electrodes import ChannelSet # TODO: Electrodes, ElectrodeGrids, ElectrodePositions and Plotting don't really belong in this package
63
+ except ImportError: ChannelSet = None # in this case, the optional .ChannelSet attribute will simply not be added to the output, at the end of bci2000chain()
64
+
65
+ if sys.version < '3': bytes = str
66
+ else: unicode = str; basestring = ( unicode, bytes )
67
+
68
+ DAT2STREAM_HAS_P_FLAG = True # new-style bci_dat2stream has a -p flag allowing it to read a .prm file to override parameter values that came from the .dat file. Without this, some source parameters like SampleBlockSize and SourceChGain cannot be overridden.
69
+ STREAM2MAT_SAVES_PARMS = True # new-style bci_stream2mat saves a string representation of the collated output parameters in the mat file, so we don't have to rely on a separate parameter file.
70
+ USE_INPUT_AND_OUTPUT_FLAGS = True # using ' "--input=$DATFILE"' yields an order-of-magnitude speedup relative to ' < "$DATFILE"'. With `callback=None` (so that `bci_stream2mat` gets used) I see no great corresponding speedup for ' "--output=$MATFILE"' relative to ' > "$MATFILE"'
71
+
72
+ class BCI2000ChainError( OSError ):
73
+ pass
74
+
75
+ class TerminateChain( Exception ):
76
+ def __init__( self, keepBlock=False ):
77
+ self.keepBlock = keepBlock
78
+
79
+ def bci2000chain(datfile, chain='SpectralSignalProcessing', parms=(), dims='auto', start=None, duration=None, verbose=False, keep=False, binpath=None, callback='default', **kwargs):
80
+ """
81
+ This function wraps the processing of a BCI2000 `.dat` file through
82
+ `bci_dat2stream.exe` followed by a specified series of command-line
83
+ filter tools, as described at
84
+ https://www.bci2000.org/mediawiki/index.php/User_Reference:Command_Line_Processing
85
+
86
+ See also the equivalent Matlab wrapper, described at
87
+ https://www.bci2000.org/mediawiki/index.php/User_Reference:Matlab_Tools
88
+ and in the Matlab documentation of `bci2000chain.m`.
89
+
90
+ Args:
91
+ datfile (str):
92
+ a path to a BCI2000 `.dat` file.
93
+
94
+ chain (str):
95
+ a list of BCI2000 filter tool names, or a pipe-delimited string
96
+ composed of filter tool names. The default value,
97
+ `'SpectralSignalProcessing'`, is a shortcut for::
98
+
99
+ chain = [ 'TransmissionFilter', 'SpatialFilter',
100
+ 'SpectralEstimator', 'LinearClassifier', 'LPFilter'
101
+ 'ExpressionFilter', 'Normalizer' ]
102
+
103
+ parms (str, tuple, list):
104
+ By default the parameter values used in the processing chain are
105
+ the ones saved in the `.dat` file (where relevant) or the
106
+ filter defaults (where the `.dat` file does not specify the
107
+ parameter). You can override parameter values by using this
108
+ argument, which can take the form of:
109
+
110
+ - A name of, or path to, a BCI2000 `.prm` or `.dat` file.
111
+ - A sequence of such filenames/paths.
112
+ - A `dict` of `{ paramName1: paramValue1, paramName2: paramValue2, ... }`
113
+ - A sequence of `[ paramName1, paramValue1, paramName2, paramValue2, ... ]`.
114
+ - Sequence of name + value pairs and/or filenames and/or dicts
115
+ (different formats may be interspersed).
116
+
117
+ Sequences are read from left to right, with later parameter
118
+ settings overwriting earlier ones. Any additional `**kwargs`
119
+ are also interpreted as parameter specifications, and are
120
+ applied last.
121
+
122
+ dims (int, str):
123
+ This should be 2, 3 or 'auto':
124
+
125
+ - `dims=2` means the signal should be two-dimensional:
126
+ elements (concatenated across sample-blocks) by channels.
127
+ - `dims=3` means the signal should be three-dimensional:
128
+ sample-blocks by channels by elements
129
+ - `dims='auto'` means the choice of 2- or 3-dimensional
130
+ output should be made automatically.
131
+
132
+ start (int, float, str):
133
+ Passed through to `bci_dat2stream` as the `--start=` value:
134
+ hence, specifies the number of blocks to skip at the beginning
135
+ (or number of seconds or milliseconds, if expressed as a string
136
+ with `'s'` or `'ms'` appended).
137
+
138
+ duration (int, float, str):
139
+ Passed through to `bci_dat2stream` as the `--duration=` value:
140
+ hence, specifies the number of blocks to process (or number of
141
+ seconds or milliseconds, if expressed as a string with `'s'`
142
+ or `'ms'` appended).
143
+
144
+ verbose (bool):
145
+ If `True`, output diagnostic/debugging messages to the console.
146
+
147
+ keep (bool):
148
+ Whether or not to keep the temporary files created by the chain.
149
+ By default, the files are deleted if there were no errors.
150
+
151
+ binpath (str):
152
+ Optionally specifies the path (or partial path relative to a
153
+ previously-established `bci2000root()`) to the directory in
154
+ which the filter-tool executables reside.
155
+
156
+ callback (None, 'default', or a callable):
157
+ Optionally specifies a function that will be called on every
158
+ sampleblock. The callback should take one argument: a dict
159
+ (let's call it `d`) and should return a boolean value that
160
+ dictates whether the sampleblock should be kept. This decision
161
+ might be made based on `d['NumberOfBlocksProcessed']` and the
162
+ contents of `d['States']` and `d['Changed']`. The callback
163
+ will also be called one additional final time when processing
164
+ is completed. On this call, `d['Changed']` will be empty (on
165
+ all previous calls, it will contain `'States.SourceTime'` at
166
+ the very least, and usually also `'Signal'`).
167
+
168
+ If this is `None`, `bci_stream2mat` is used at the end of the
169
+ chain. If not, `bci_stream2hybrid` is used.
170
+
171
+
172
+ Examples:
173
+
174
+ Excuse the relative paths - this example works if you're currently
175
+ working in the root directory of the BCI2000 distro::
176
+
177
+ bci2000chain( datfile='data/samplefiles/eeg3_2.dat',
178
+ chain='TransmissionFilter|SpatialFilter|ARFilter',
179
+ binpath='tools/cmdline',
180
+ parms=[ 'tools/matlab/ExampleParameters.prm' ],
181
+ SpatialFilterType=3 )
182
+
183
+ Or for more portable operation, you can use `bci2000root()` and
184
+ `bci2000path()`::
185
+
186
+ bci2000root( '/PATH/TO/BCI2000' )
187
+
188
+ bci2000chain( datfile=bci2000path( 'data/samplefiles/eeg3_2.dat' ),
189
+ chain='TransmissionFilter|SpatialFilter|ARFilter',
190
+ parms=[ bci2000path( 'tools/matlab/ExampleParameters.prm' ) ],
191
+ SpatialFilterType=3 )
192
+
193
+ """
194
+
195
+ if verbose: verbosityFlag = '-v'
196
+ else: verbosityFlag = '-q'
197
+
198
+ if isinstance(chain, basestring):
199
+ if chain.lower() == 'SpectralSignalProcessing'.lower():
200
+ chain = 'TransmissionFilter|SpatialFilter|SpectralEstimator|LinearClassifier|LPFilter|ExpressionFilter|Normalizer'
201
+ elif chain.lower() == 'ARSignalProcessing'.lower():
202
+ chain = 'TransmissionFilter|SpatialFilter|ARFilter|LinearClassifier|LPFilter|ExpressionFilter|Normalizer'
203
+ elif chain.lower() == 'P3SignalProcessing'.lower():
204
+ chain = 'TransmissionFilter|SpatialFilter|P3TemporalFilter|LinearClassifier'
205
+ chain = chain.split('|')
206
+ chain = [c.strip() for c in chain if len(c.strip())]
207
+
208
+ if len(chain) == 0: print('WARNING: chain is empty')
209
+
210
+ if start is not None and len(str(start).strip()): start = ' --start=' + str(start).replace(' ', '')
211
+ else: start = ''
212
+ if duration is not None and len(str(duration).strip()): duration = ' --duration=' + str(duration).replace(' ', '')
213
+ else: duration = ''
214
+
215
+ if dims is None or str(dims).lower() == 'auto': dims = 0
216
+ if dims not in (0, 2, 3): raise ValueError("dims must be 2, 3 or 'auto'")
217
+
218
+ out = CONTAINER()
219
+ out._summarize = 60
220
+ err = ''
221
+
222
+ cmd = ''
223
+ binaries = []
224
+ tmpdir = tempfile.mkdtemp(prefix='bci2000chain_')
225
+
226
+ tmpdatfile = os.path.join(tmpdir, 'in.dat')
227
+ prmfile_in = os.path.join(tmpdir, 'in.prm')
228
+ prmfile_out = os.path.join(tmpdir, 'out.prm')
229
+ matfile = os.path.join(tmpdir, 'out.mat')
230
+ bcifile = os.path.join(tmpdir, 'out.bci')
231
+ shfile = os.path.join(tmpdir, 'go.bat')
232
+ logfile = os.path.join(tmpdir, 'log.txt')
233
+
234
+ if not isinstance(datfile, basestring):
235
+ raise ValueError('datfile must be a filename') # TODO: if datfile contains the appropriate info, use some create_bcidat equivalent and do datfile = tmpdatfile
236
+ datfile = os.path.realpath( os.path.expanduser( datfile ) )
237
+ if not os.path.isfile(datfile): raise IOError('file not found: %s' % datfile)
238
+
239
+ mappings = {
240
+ '$DATFILE': datfile,
241
+ '$PRMFILE_IN': prmfile_in,
242
+ '$PRMFILE_OUT': prmfile_out,
243
+ '$MATFILE': matfile,
244
+ '$BCIFILE': bcifile,
245
+ '$SHFILE': shfile,
246
+ '$LOGFILE': logfile,
247
+ }
248
+
249
+ if binpath is None and bci2000root() is not None: binpath = 'tools/cmdline'
250
+ if binpath is None: raise ValueError( 'must either pre-configure `bci2000root()` or supply `binpath` explicitly' )
251
+ binpath = bci2000path( binpath )
252
+ exeExtension = bci2000_exe_extension('tools/cmdline')
253
+ def exe(name):
254
+ fullName = name + exeExtension
255
+ if exeExtension == '.app': fullName += '/Contents/MacOS/' + name
256
+ if binpath: return '"' + os.path.join(binpath, fullName) + '"'
257
+ else: return fullName
258
+
259
+ if parms is None: parms = []
260
+ if isinstance(parms, tuple): parms = list(parms)
261
+ if not isinstance(parms, list): parms = [parms]
262
+ else: parms = list(parms)
263
+ if len(kwargs): parms.append(kwargs)
264
+
265
+ def StartChain( target ): return ( ' "--input=%s"' if USE_INPUT_AND_OUTPUT_FLAGS else ' < "%s"' ) % target
266
+ def FinishChain( target ): return ( ' "--output=%s"' if USE_INPUT_AND_OUTPUT_FLAGS else ' > "%s"' ) % target
267
+
268
+ if parms is None or len(parms) == 0:
269
+ cmd += exe('bci_dat2stream') + start + duration + StartChain( '$DATFILE' )
270
+ binaries.append('bci_dat2stream')
271
+ else:
272
+ if verbose: print( '# writing custom parameter file %s' % prmfile_in )
273
+ try: parms = make_bciprm(verbosityFlag, datfile, parms, '>', prmfile_in)
274
+ except:
275
+ try: make_bciprm( verbosityFlag, datfile )
276
+ except: raise BCI2000ChainError( 'failed to decode BCI2000 parameters from ' + datfile )
277
+ raise
278
+
279
+ if DAT2STREAM_HAS_P_FLAG:
280
+ cmd += exe('bci_dat2stream') + ' "-p$PRMFILE_IN"' + start + duration + StartChain( '$DATFILE' ) # new-style bci_dat2stream with -p option
281
+ binaries.append('bci_dat2stream')
282
+ else:
283
+ if len(start) or len(duration):
284
+ raise ValueError('old versions of bci_dat2stream have no --start or --duration option')
285
+ cmd += '(' # old-style bci_dat2stream with no -p option
286
+ cmd += exe('bci_prm2stream') + ' < "$PRMFILE_IN"'
287
+ cmd += '&& ' + exe('bci_dat2stream') + ' --transmit-sd' + StartChain( '$DATFILE' )
288
+ cmd += ')'
289
+ binaries.append('bci_dat2stream')
290
+ binaries.append('bci_prm2stream')
291
+
292
+ for c in chain:
293
+ cmd += ' | ' + exe(c)
294
+ binaries.append(c)
295
+
296
+ if callback == 'default': callback = CallbackObject()
297
+ if callback:
298
+ exe_name = 'bci_stream2hybrid'
299
+ cmd += ' | ' + exe(exe_name)
300
+ binaries.append(exe_name)
301
+ elif STREAM2MAT_SAVES_PARMS:
302
+ cmd += ' | ' + exe('bci_stream2mat') + FinishChain( '$MATFILE' ) # new-style bci_stream2mat with Parms output
303
+ binaries.append('bci_stream2mat')
304
+ else:
305
+ cmd += FinishChain( '$BCIFILE' )
306
+ cmd += ' && ' + exe('bci_stream2mat') + StartChain( '$BCIFILE' ) + FinishChain( '$MATFILE' )
307
+ cmd += ' && ' + exe('bci_stream2prm') + StartChain( '$BCIFILE' ) + FinishChain( '$PRMFILE_OUT' ) # old-style bci_stream2mat without Parms output
308
+ binaries.append('bci_stream2mat')
309
+ binaries.append('bci_stream2prm')
310
+
311
+ for k,v in mappings.items(): cmd = cmd.replace(k, v)
312
+
313
+ win = sys.platform.lower().startswith('win')
314
+ if win: preamble, postscript = '@', ' %*'
315
+ else: preamble, postscript = '#!/bin/bash\n\n', ' "$@"'
316
+ fh = open(shfile, 'wt')
317
+ fh.write( preamble + cmd + postscript + '\n' )
318
+ fh.close()
319
+ if not win: os.chmod(shfile, 484) # rwxr--r--
320
+
321
+ failsig = 'Configuration Error: '
322
+ failsig = ' Error: ' # TODO: this used to be 'Configuration Error:' but that misses some errors.
323
+ # Hopefully the more general signature doesn't give us false positives...
324
+ # There has to be a better way of detecting failure...
325
+ def tidytext(x):
326
+ return x.strip().replace('\r\n', '\n').replace('\r', '\n')
327
+ def getoutput(cmd):
328
+ return getstatusoutput(cmd)[1]
329
+ def getstatusoutput(cmd): # simpler case, older implementation, used for chains that have no callback and use bci_stream2mat
330
+ try: import fcntl; SYS_STDOUT_FILENO = sys.stdout.fileno(); SYS_STDOUT_FL = fcntl.fcntl( SYS_STDOUT_FILENO, fcntl.F_GETFL ) # see %%%, below
331
+ except: SYS_STDOUT_FL = None
332
+ try:
333
+ pipe = os.popen(cmd + ' 2>&1', 'r')
334
+ output = pipe.read()
335
+ status = pipe.close()
336
+ if status is None: status = 0
337
+ return status,tidytext(output)
338
+ finally:
339
+ # os.popen() can put the file descriptor behind the pipe into non-blocking mode so it can poll the child’s output without
340
+ # hanging the UI. Unfortunately, on POSIX systems the O_NONBLOCK flag is inherited by every duplicate
341
+ # of that descriptor, including the original stdout that the REPL prints to. This means that os.popen() can break your stdout!
342
+ # It will work for short outputs, but will hang (or be caught by IPython which then throws a BlockingIOError) for longer outputs.
343
+ # Here is a workaround: (see also %%%, above)
344
+ if SYS_STDOUT_FL is not None: fcntl.fcntl( SYS_STDOUT_FILENO, fcntl.F_SETFL, SYS_STDOUT_FL ) # %%%
345
+
346
+ def process(cmd, callback, logfile): # block-by-block processing with a callback
347
+ def bytes2str(s): return s if str is bytes else s.decode( 'utf-8' )
348
+ def str2bytes(s): return s if str is bytes else s.encode( 'utf-8' )
349
+ emptyBytes = str2bytes( '' )
350
+ d = CONTAINER( Changed=[], Parameters=[], CollatedSignal=[], CollatedStates=CONTAINER(), CollatedEvents=CONTAINER(), RetainedBlockNumbers=[] )
351
+ try:
352
+ #pipe = os.popen( cmd + ' 2>"%s"' % logfile, 'r' ) # the problem with this is that it opens the output in text mode. unsure how to switch it to binary, so instead we'll use subprocess:
353
+ sp = subprocess.Popen( [ cmd ], shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE ); pipe = sp.stdout # text=False option does not exist in Python 2 but it seems to be fine in both if omitted
354
+ shape = None
355
+ class StandardErrorWatcher( object ):
356
+ def __init__( self, sp ):
357
+ self.sp = weakref.ref( sp )
358
+ self.stderr = sp.stderr
359
+ self.configFailed = False
360
+ self.configPassed = False
361
+ self.text = ''
362
+ self.keepgoing = True
363
+ self.thread = threading.Thread( target=self )
364
+ try: self.thread.daemon = True # last resort - even if the thread hangs around forever zombified, at least don't prevent Python from terminating
365
+ except: self.thread.setDaemon( True ) # older (now deprecated) syntax
366
+ self.thread.start()
367
+ def __call__( self ):
368
+ while self.keepgoing:
369
+ try: ready, _, _ = select.select( [ self.stderr ], [], [], 0.010 )
370
+ except: ready = [ self.stderr ] # fails on Python 2.7 under Windows with error message "An operation was attempted on something that is not a socket" - fall back on .setDaemon( True ) above
371
+ if not ready: continue # timed out: so, take a moment to check whether self.keepgoing has been unset, and if it hasn't then try again
372
+ try: line = self.stderr.read() if self.configFailed else self.stderr.readline()
373
+ except: break
374
+ if not line: break
375
+ if isinstance( line, bytes ): line = bytes2str( line )
376
+ #sys.stderr.write( 'STDERR: ' + line ); sys.stderr.flush()
377
+ if 'bci_stream2hybrid: CONFIGURATION ACCEPTED' in line: self.configPassed = True; continue
378
+ self.text += line
379
+ if failsig in line:
380
+ self.configFailed = True
381
+ try: self.sp().kill()
382
+ except: pass
383
+ break # without this, the thread will never end even after the chain has returned; unfortunately it means only the first error message is preserved
384
+ stderr = StandardErrorWatcher( sp )
385
+ try:
386
+ while not stderr.configPassed and not stderr.configFailed:
387
+ if sp.poll() is not None: stderr.configFailed = True
388
+ time.sleep( 0.001 )
389
+ terminator = None
390
+ while not stderr.configFailed and sp.poll() is None:
391
+ line = pipe.readline()
392
+ if not line: break
393
+ #sys.stderr.write( 'STDOUT: ' + bytes2str( line ) ); sys.stderr.flush()
394
+ line = line.strip()
395
+ if not line: continue
396
+ line = bytes2str( line )
397
+ if line == 'Parameter':
398
+ d[ 'Parameters' ].append( bytes2str( pipe.readline().strip() ) )
399
+ continue
400
+ words = line.split()
401
+ valueType = words.pop( -1 )
402
+ if valueType == ':': fmt = words.pop( -1 )
403
+ container = d
404
+ for key in words[ :-1 ]: container = container[ key ]
405
+ lastKey = words[ -1 ]
406
+ fullKey = '.'.join( words )
407
+ if valueType == ':':
408
+ raw = pipe.read( struct.calcsize( fmt ) )
409
+ #value = struct.unpack( fmt, raw ) # non-numpy-dependent version, decodes values as tuple
410
+ #value = numpy.fromfile( pipe, dtype=fmt[ -1 ], count=int( fmt[ 1:-1 ] ) ) # this looked like it might be a good way of going straight to numpy array from the filehandle - but it results in OSError: seeking file failed
411
+ value = numpy.frombuffer( raw, dtype=fmt[ -1 ] )
412
+ elif valueType == '=':
413
+ arg = bytes2str( pipe.readline().strip() )
414
+ value = ast.literal_eval( arg )
415
+ if isinstance( value, dict ) and isinstance( CONTAINER(), dict ): value = CONTAINER( value )
416
+ else:
417
+ raise RuntimeError( 'unrecognized valueType %r' % valueType )
418
+ if fullKey == 'Signal':
419
+ if shape is None: shape = ( d[ 'NumberOfElements' ], d[ 'NumberOfChannels' ] )
420
+ value = value.reshape( shape )
421
+ container[ lastKey ] = value
422
+ if fullKey == 'NumberOfBlocksProcessed' and value:
423
+ try:
424
+ keepBlock = callback( d )
425
+ except TerminateChain as exc:
426
+ keepBlock = exc.keepBlock
427
+ terminator = exc
428
+ if keepBlock:
429
+ d[ 'RetainedBlockNumbers' ].append( value - 1 )
430
+ d[ 'CollatedSignal' ].append( d[ 'Signal' ] ) # NB: inefficient for very large files, unless you write your callback such that it clears this field
431
+ for name, value in d[ 'States' ].items(): d[ 'CollatedStates' ].setdefault( name, [] ).append( value )
432
+ for name, value in d[ 'Events' ].items(): d[ 'CollatedEvents' ].setdefault( name, [] ).append( value.astype( 'int64' ) )
433
+ d[ 'Changed' ][ : ] = []
434
+ if terminator: raise terminator
435
+ else:
436
+ d[ 'Changed' ].append( fullKey )
437
+ #status = pipe.close()
438
+ status = sp.wait()
439
+ except KeyboardInterrupt:
440
+ pass
441
+ except TerminateChain:
442
+ pass
443
+ finally:
444
+ try: sp.kill()
445
+ except: pass
446
+ status = sp.returncode
447
+ if status == 0 or terminator: # only call the callback again if everything's OK
448
+ try: callback( d ) # d['Changed'] will be empty, to signal to the callback that it should wrap things up, if necessary
449
+ except TerminateChain: pass
450
+ stderr.keepgoing = False # some implementations don't need this; some do
451
+ return d, status, tidytext( stderr.text )
452
+ finally: # suppress ResourceWarning messages
453
+ try: sp.stderr.close()
454
+ except: pass
455
+ try: sp.stdout.close()
456
+ except: pass
457
+
458
+
459
+ notfound = [exe(binary) for binary in binaries if not os.path.isfile(exe(binary).strip('"'))]
460
+ if notfound: raise BCI2000ChainError( '\n'.join( [ 'failed to find the following command-line binaries:' ] + [ ' ' + binary for binary in notfound ] ) )
461
+ if verbose: print( '# querying version information' )
462
+ binaries = CONTAINER([(binary, getoutput(exe(binary) + ' --version').replace('\n', ' ') ) for binary in binaries])
463
+
464
+ if verbose: print( cmd )
465
+ t0 = time.time()
466
+ if callback: callbackResult, failed, output = process(shfile, callback, logfile)
467
+ else: failed, output = getstatusoutput(shfile)
468
+ chaintime = time.time() - t0
469
+
470
+ if failsig in output: failed = 1
471
+ printable_output = output
472
+ printable_lines = output.split('\n')
473
+ maxlines = 10
474
+ if len(printable_lines) > maxlines:
475
+ printable_output = '\n'.join(printable_lines[:maxlines] + ['[%d more lines omitted]' % (len(printable_lines) - maxlines)])
476
+ def AddContextToError( message ):
477
+ if verbose: return 'system call failed:\n' + message # cmd has already been printed, so don't clutter things further
478
+ else: return 'system call failed:\n\n%s\n\n%s' % ( cmd, message )
479
+ if failed: err = AddContextToError( printable_output )
480
+
481
+ if not err and not callback:
482
+ if verbose: print( '# loading %s' % matfile )
483
+ try:
484
+ mat = ReadMatFile(matfile, containerClass=CONTAINER)
485
+ except:
486
+ err = "The chain must have failed: could not load %s\nShell output was as follows:\n%s" % (matfile, printable_output)
487
+ else:
488
+ if 'Data' not in mat: err = "The chain must have failed: no 'Data' variable found in %s\nShell output was as follows:\n%s" % (matfile, printable_output)
489
+ if 'Index' not in mat: err = "The chain must have failed: no 'Index' variable found in %s\nShell output was as follows:\n%s" % (matfile, printable_output)
490
+
491
+ if not err:
492
+ out.FileName = datfile
493
+ if callback:
494
+ if verbose: print( '# decoding parameters loaded from callback result' )
495
+ parms = make_bciprm( verbosityFlag, callbackResult.get( 'Parameters', [] ) )
496
+ if not parms: err = AddContextToError( output )
497
+ elif STREAM2MAT_SAVES_PARMS:
498
+ if verbose: print( '# decoding parameters loaded from the mat-file' )
499
+ parms = make_bciprm( verbosityFlag, mat[ 'Parms' ][ 0 ] )
500
+ else:
501
+ if verbose: print( '# reading output parameter file' + prmfile_out )
502
+ parms = ParmList(prmfile_out) # if you get an error that prmfile_out does not exist, recompile your bci_dat2stream and bci_stream2mat binaries from up-to-date sources, and ensure that DAT2STREAM_HAS_P_FLAG and STREAM2MAT_SAVES_PARMS, at the top of this file, are both set to 1
503
+
504
+ if not err:
505
+ out.DateStr = read_bcidate(parms, 'ISO')
506
+ out.DateNum = read_bcidate(parms)
507
+ out.FilterChain = chain
508
+ out.ToolVersions = binaries
509
+ out.ShellInput = cmd
510
+ out.ShellOutput = output
511
+ out.ChainTime = chaintime
512
+ out.ChainSpeedFactor = None
513
+ out.Megabytes = None
514
+ out.Parms = parms
515
+ out.Parms.sort( key=lambda p: p.Name )
516
+
517
+ if callback:
518
+ nChannels = callbackResult.get( 'NumberOfChannels', None )
519
+ nElements = callbackResult.get( 'NumberOfElements', None )
520
+ nBlocksProcessed = callbackResult.get( 'NumberOfBlocksProcessed', None )
521
+ nBlocksRetained = len( callbackResult.get( 'CollatedSignal', [] ) )
522
+ mat = callbackResult
523
+ else:
524
+ mat['Index'] = mat['Index'][0,0]
525
+ sigind = mat['Index'].Signal - 1 # indices vary across channels fastest, then elements
526
+ nChannels,nElements = sigind.shape
527
+ nBlocksRetained = nBlocksProcessed = mat['Data'].shape[1]
528
+
529
+ out.BlocksProcessed = nBlocksProcessed
530
+ out.BlocksRetained = nBlocksRetained
531
+ out.BlocksPerSecond = float(parms.SamplingRate.ScaledValue) / float(parms.SampleBlockSize.ScaledValue)
532
+ out.SecondsPerBlock = float(parms.SampleBlockSize.ScaledValue) / float(parms.SamplingRate.ScaledValue)
533
+ out.ChainSpeedFactor = float(out.BlocksProcessed * out.SecondsPerBlock) / float(out.ChainTime)
534
+
535
+ def unnumpify(x):
536
+ while isinstance(x, numpy.ndarray) and x.size == 1: x = x[0]
537
+ if isinstance(x, (numpy.ndarray,tuple,list)): x = [unnumpify(xi) for xi in x]
538
+ return x
539
+
540
+ out.Channels = nChannels
541
+ out.ChannelLabels = unnumpify(mat.get('ChannelLabels', []))
542
+ if ChannelSet: out.ChannelSet = ChannelSet(out.ChannelLabels)
543
+ else: out.ChannelSet = None
544
+
545
+ out.Elements = nElements
546
+ out.ElementLabels = unnumpify(mat.get('ElementLabels', []))
547
+ out.ElementValues = numpy.ravel(mat.get('ElementValues', []))
548
+ out.ElementUnit = unnumpify(mat.get('ElementUnit', None))
549
+ out.ElementRate = out.BlocksPerSecond * out.Elements
550
+
551
+ out.Time = out.SecondsPerBlock * ( numpy.array( callbackResult[ 'RetainedBlockNumbers' ] ) if callback else numpy.arange(0, nBlocksProcessed) )
552
+ out.FullTime = out.Time
553
+ out.FullElementValues = out.ElementValues
554
+
555
+ def seconds(s): # -1 means "no information", 0 means "not units of time", >0 means the scaling factor
556
+ if getattr(s, 'ElementUnit', None) in ('',None,()) or s.ElementUnit == []: return -1
557
+ s = s.ElementUnit
558
+ if s.endswith('seconds'): s = s[:-6]
559
+ elif s.endswith('second'): s = s[:-5]
560
+ elif s.endswith('sec'): s = s[:-2]
561
+ if s.endswith('s'): return { 'ps': 1e-12, 'ns': 1e-9, 'us': 1e-6, 'mus': 1e-6, 'ms': 1e-3, 's' : 1e+0, 'ks': 1e+3, 'Ms': 1e+6, 'Gs': 1e+9, 'Ts': 1e+12,}.get(s, 0)
562
+ else: return 0
563
+
564
+ if dims == 0: # dimensionality has not been specified explicitly: so guess, based on ElementUnit and/or filter name
565
+ # 3-dimensional output makes more sense than continuous 2-D whenever "elements" can't just be concatenated into an unbroken time-stream
566
+ if len(chain): lastfilter = chain[-1].lower()
567
+ else: lastfilter = ''
568
+ if nBlocksRetained != nBlocksProcessed:
569
+ dims = 3
570
+ elif lastfilter in [ 'p3temporalfilter', 'trapfilter' ]:
571
+ dims = 3
572
+ else:
573
+ factor = seconds(out)
574
+ if factor > 0: # units of time. TODO: could detect whether the out.ElementValues*factor are (close enough to) contiguous from block to block; then p3temporalfilter and trapfilter wouldn't have to be special cases above
575
+ dims = 2
576
+ elif factor == 0: # not units of time: use 3D by default
577
+ dims = 3
578
+ elif lastfilter in ['trapfilter', 'p3temporalfilter', 'arfilter', 'fftfilter', 'coherencefilter', 'coherencefftfilter']: # no ElementUnit info? guess based on filter name
579
+ dims = 3
580
+ else:
581
+ dims = 2
582
+
583
+ if dims == 2:
584
+ out.FullTime = numpy.repeat(out.Time, nElements) # but this means time is standing still during a block...
585
+ factor = seconds(out)
586
+ if len(out.ElementValues):
587
+ out.FullElementValues = numpy.tile(out.ElementValues, nBlocksRetained)
588
+ if factor > 0: out.FullTime = out.FullTime + out.FullElementValues * factor # ...so here we fix that if we can
589
+
590
+ if callback:
591
+ collatedSignal = callbackResult.get( 'CollatedSignal', [] )
592
+ collatedStates = CONTAINER( { name : numpy.array( values ) for name, values in callbackResult.get( 'CollatedStates', {} ).items() } )
593
+ collatedEvents = CONTAINER( { name : numpy.concatenate( values, axis=0 ) for name, values in callbackResult.get( 'CollatedEvents', {} ).items() } )
594
+ if nBlocksRetained == 0:
595
+ out.Signal = numpy.zeros( ( 0, nChannels, nElements ) if dims == 3 else ( 0, nChannels ), dtype='f' )
596
+ out.States = CONTAINER( { name : numpy.array( [], dtype='L' ) for name in callbackResult.get( 'States', {} ) } )
597
+ out.Events = CONTAINER( { name : numpy.array( [], dtype='L' ) for name in callbackResult.get( 'Events', {} ) } )
598
+ elif dims == 3:
599
+ out.Signal = numpy.array( collatedSignal ) # nBlocksRetained - by - nElements - by - nChannels
600
+ out.Signal = numpy.transpose(out.Signal, (0,2,1)) # nBlocksRetained - by - nChannels - by - nElements
601
+ out.Signal = out.Signal + 0.0 # make a contiguous copy
602
+ out.States = collatedStates
603
+ out.Events = collatedEvents
604
+ elif dims == 2:
605
+ out.Signal = numpy.concatenate( collatedSignal, axis=0 ) # nBlocksRetained*nElements - by - nChannels
606
+ # out.States = CONTAINER( { name : value.repeat( nElements ) for name, value in collatedStates.items() } )
607
+ # The line above is what we *would* do if the bci_dat2stream protocol was able to distinguish between event- and non-event states.
608
+ # Unfortunately neither the .dat file nor the protocol is able to make this distinction, for backward compatibility reasons.
609
+ # So bci_stream2hybrid transfers *both* scalar and vector-covering-entire-sampleblock representations of *all* states
610
+ # (see the comments marked @@@ in bci_stream2hybrid.cpp). Therefore, the right thing to do is simply:
611
+ out.States = collatedEvents # <- because the "event" representation has lost less information (or no information, if the sampleblock size hasn't changed during the chain)
612
+ out.Events = collatedEvents # <- this we would do in any case
613
+ else:
614
+ raise RuntimeError('internal error')
615
+ # TODO: events might need their own (possibly original-sampleclock-based) timebase, and it will have discontinuities if some blocks were not retained
616
+
617
+ else:
618
+ # Why does sigind have to be transposed before vectorizing to achieve the same result as sigind(:) WITHOUT a transpose in Matlab?
619
+ # This will probably forever be one of the many deep mysteries of numpy dimensionality handling
620
+ out.Signal = mat['Data'][sigind.T.ravel(), :] # nChannels*nElements - by - nBlocks
621
+ out.Signal = out.Signal + 0.0 # make a contiguous copy
622
+
623
+ if dims == 3:
624
+ out.Signal = numpy.reshape(out.Signal, (nChannels, nElements, nBlocksRetained), order='F') # nChannels - by - nElements - by - nBlocksRetained
625
+ out.Signal = numpy.transpose(out.Signal, (2,0,1)) # nBlocksRetained - by - nChannels - by - nElements
626
+ out.Signal = out.Signal + 0.0 # make a contiguous copy
627
+ elif dims == 2:
628
+ out.Signal = numpy.reshape(out.Signal, (nChannels, nElements*nBlocksRetained), order='F') # nChannels - by - nSamples
629
+ out.Signal = numpy.transpose(out.Signal, (1,0)) # nSamples - by - nChannels
630
+ out.Signal = out.Signal + 0.0 # make a contiguous copy
631
+ else:
632
+ raise RuntimeError('internal error')
633
+
634
+ out.States = CONTAINER()
635
+ try: fieldnames = mat['Index']._fieldnames
636
+ except AttributeError: items = mat['Index'].items()
637
+ else: items = [ ( k, getattr(mat['Index'], k) ) for k in fieldnames ]
638
+ states = [(k,int(v)-1) for k, v in items if k != 'Signal']
639
+ for k,v in states: setattr(out.States, k, mat['Data'][v,:])
640
+ # bci_stream2mat.cpp does not handle Event states
641
+
642
+ try: out.Megabytes = megs(out)
643
+ except: out.Megabytes = sys.exc_info()
644
+ def NotifyCleanup( message ): print( message )
645
+ else: # non-empty err
646
+ #print( err ); print( '' )
647
+ out = [ err + '\n\n' ]
648
+ keep = True
649
+ def NotifyCleanup( message ): out[ 0 ] += message + '\n'
650
+
651
+ if os.path.isdir(tmpdir):
652
+ files = sorted([os.path.join(tmpdir, file) for file in os.listdir(tmpdir) if file not in ['.','..']])
653
+ if keep: NotifyCleanup( 'The following commands should be executed to clean up temporary files:' )
654
+ elif verbose: print( '# removing temp files and directory ' + tmpdir )
655
+
656
+ for file in files:
657
+ if keep: NotifyCleanup( "os.remove(r'%s')" % file )
658
+ else:
659
+ try: os.remove(file)
660
+ except Exception as err: sys.stderr.write( "failed to remove %s:\n %s\n" % ( file, str( err ) ) )
661
+
662
+ if keep: NotifyCleanup( "os.rmdir(r'%s')" % tmpdir )
663
+ else:
664
+ try: os.rmdir(tmpdir)
665
+ except Exception as err: sys.stderr.write( "failed to remove %s:\n %s" % ( tmpdir, str( err ) ) )
666
+ if keep: NotifyCleanup( "" )
667
+
668
+ if isinstance( out, list ): raise BCI2000ChainError( out[ 0 ].strip() )
669
+ return out
670
+
671
+ def megs(x, rec=0):
672
+ """
673
+ Very rough calculation of the number of megabytes occupied by an object and its children.
674
+ Note that some of the children (and hence the memory) may be shared by other objects.
675
+ Note also that mutual references by objects to each other will screw things up.
676
+ """
677
+ rec += 1
678
+ if rec > 100: raise ValueError( 'megs failed (too much recursion)' )
679
+ denom = 1024.0 * 1024.0
680
+ def siz(x, rec):
681
+ if hasattr(sys, 'getsizeof'): m = sys.getsizeof(x) / denom # only the container overhead
682
+ else: # Python versions <2.6 do not have sys.getsizeof. Container overhead will be underestimated.
683
+ try: m = numpy.dtype( x.__class__ ).itemsize
684
+ except: m = 0
685
+ if hasattr(x, '__dict__'): m += megs(x.__dict__, rec) # works for sstruct and Param objects very nicely; should work for most old- and new-type objects
686
+ return m
687
+ if isinstance(x, dict): return siz(x, rec) + sum( megs( key, rec ) + megs( value, rec ) for key, value in x.items() )
688
+ if isinstance(x, numpy.ndarray):
689
+ m = siz( x, rec )
690
+ if m < x.nbytes / denom: m += x.nbytes / denom # depending on Python and/or numpy version, x.nbytes (size of raw packed content) may or may not be included in the unadjusted siz() output
691
+ return m
692
+ if isinstance(x, basestring ): return siz(x, rec)
693
+ try: # try treating it as a sequence of any kind
694
+ for xi in x: break
695
+ except: pass
696
+ else: return siz( x, rec ) + sum( [ megs( xi, rec ) for xi in x ] )
697
+ return siz(x, rec) # other esoteric types may not be covered - numpy.ndarray, for example, has no __dict__ and requires special handling, and perhaps there are others like it out there
698
+
699
+ class sys_dot_stderr: pass
700
+
701
+ class CallbackObject( object ):
702
+ def __init__( self, stream=sys_dot_stderr, announcementPeriodInSeconds=2, debug=False, **counters ):
703
+ if stream is sys_dot_stderr: stream = sys.stderr # defer the actual dereferencing until now, in case someone has been messing with it
704
+ self.counters = { 'StimulusCode' : 'StimulusCode events', 'TrialsCompleted' : 'trials' }
705
+ self.counters.update( counters )
706
+ self.counters = { k : v for k, v in self.counters.items() if v is not None }
707
+ self.counts = {}
708
+ self.lastValues = {}
709
+ self.stream = stream
710
+ self.announcementPeriodInSeconds = announcementPeriodInSeconds
711
+ self.debug = debug
712
+ def set( self, **kwargs ):
713
+ for k, v in kwargs.items(): setattr( self, k, v )
714
+ return self
715
+ def write( self, x ):
716
+ self.stream.write( x )
717
+ try: self.stream.flush()
718
+ except: pass
719
+ def __call__( self, d ):
720
+ if self.debug: sys._debug_bci2000chain = d
721
+ changed = d.get( 'Changed', [] )
722
+ numberOfBlocksProcessed = d.get( 'NumberOfBlocksProcessed', 0 )
723
+ if changed and numberOfBlocksProcessed > 1:
724
+ for stateName in self.counters:
725
+ values = d[ 'Events' ].get( stateName, None )
726
+ if values is None: continue
727
+ if values.size == 0: values = d[ 'States' ].get( stateName, None )
728
+ if values is None: continue
729
+ values = numpy.asarray( values ).ravel()
730
+ nEvents = numpy.logical_and( pdiff( values ) != 0, values != 0 ).sum()
731
+ if values[ 0 ] not in [ 0, self.lastValues.get( stateName, 0 ) ]: nEvents += 1
732
+ if nEvents: self.counts[ stateName ] = self.counts.setdefault( stateName, 0 ) + nEvents
733
+ self.lastValues[ stateName ] = values[ -1 ]
734
+ t = time.time()
735
+ secondsElapsed = t - d.setdefault( 'ProcessingStartTime', t )
736
+ if self.debug: d.setdefault( 'SecondsElapsedAtCalls', [] ).append( secondsElapsed )
737
+ #self.write( '%s %s %s\n' % ( numberOfBlocksProcessed, bool(changed), self.announcementPeriodInSeconds + d.setdefault( 'LastAnnouncementTime', t ) - t ) )
738
+ if numberOfBlocksProcessed == 0: return False
739
+ if not changed or t >= self.announcementPeriodInSeconds + d.setdefault( 'LastAnnouncementTime', t ):
740
+ secondsProcessed = numberOfBlocksProcessed * d[ 'SamplesPerBlock' ] / float( d[ 'SamplesPerSecond' ] )
741
+ eventsString = ', '.join( self.counters[ stateName ] + ': ' + str( nEvents ) for stateName, nEvents in self.counts.items() )
742
+ if eventsString: eventsString = ' - ' + eventsString
743
+ self.write( 'processed %5.1f seconds of signal in %4.1fs%s\n' % ( secondsProcessed, secondsElapsed, eventsString ) )
744
+ d[ 'LastAnnouncementTime' ] = t
745
+ return self.KeepBlock( d )
746
+ def KeepBlock( self, d ):
747
+ # to terminate the chain early: raise TerminateChain(keepBlock=...)
748
+ return True
749
+
750
+ class TrapFilterCallbackObject( CallbackObject ):
751
+ def KeepBlock( self, d ):
752
+ # to terminate the chain early: raise TerminateChain(keepBlock=...)
753
+ isNewTrial = d.NumberOfBlocksProcessed > 1 and 'States.TrialsCompleted' in d.Changed and d.States.TrialsCompleted > 0
754
+ return isNewTrial
755
+
756
+ exeExtension = bci2000_exe_extension('tools/cmdline')
757
+ if not os.path.isdir(bci2000path('tools/cmdline')):
758
+ bci2000_tools_cmdline_not_found = \
759
+ 'Found no directory %s, so the %s module will not work unless you explicitly set `bci2000root()` ' \
760
+ '(NB: %s is being imported from %s - this warning can be avoided if the BCI2000Tools package is ' \
761
+ 'installed as "editable", with `python -m pip install -e`, from its original location as part of ' \
762
+ 'a full BCI2000 distribution).' % ( bci2000path('tools/cmdline'), __name__, __name__, __file__ )
763
+ warnings.warn(bci2000_tools_cmdline_not_found)
764
+ elif not os.path.exists(bci2000path('tools/cmdline/bci_dat2stream' + exeExtension)):
765
+ bci_dat2stream_not_found = \
766
+ '%s found directory %s, but did not find the bci_dat2stream%s executable inside it. ' \
767
+ 'You may need to set a different `bci2000root()`, or you may need to compile the ' \
768
+ 'BCI2000 command-line binaries in the current one.' % ( __name__, bci2000path('tools/cmdline'), exeExtension )
769
+ warnings.warn(bci_dat2stream_not_found)
770
+ elif not os.path.exists(bci2000path('tools/cmdline/bci_stream2hybrid' + exeExtension)):
771
+ bci_stream2hybrid_not_found = \
772
+ '%s found directory %s, but did not find the bci_stream2hybrid%s executable inside it. ' \
773
+ 'You may need to set a different `bci2000root()`, or you may need to compile the ' \
774
+ 'BCI2000 command-line binaries in the current one.' % ( __name__, bci2000path('tools/cmdline'), exeExtension )
775
+ warnings.warn(bci_stream2hybrid_not_found)