示例#1
0
class DataAcquisitionClient:
    """Data Acquisition client. The client sets up a separate thread for
    acquisition, writes incoming data to a queue, and processes the data from
    the queue.

    Parameters
    ----------
        connector: Connector instance
            Object with device-specific implementations for connecting,
            initializing, and reading a packet.
        processor : Processor; optional
            A data Processor that does something with the streaming data (ex.
            writes to a file.)
        buffer_name : str, optional
            Name of the sql database archive; default is buffer.db.
        raw_data_file_name: str,
            Name of the raw data csv file to output; if not present raw data
                is not written.
        clock : Clock, optional
            Clock instance used to timestamp each acquisition record
        delete_archive: boolean, optional
            Flag indicating whether to delete the database archive on exit.
            Default is False.
    """
    def __init__(self,
                 connector,
                 buffer_name='raw_data.db',
                 raw_data_file_name='raw_data.csv',
                 clock=CountClock(),
                 delete_archive=True):

        self._connector = connector
        self._buffer_name = buffer_name
        self._raw_data_file_name = raw_data_file_name
        self._clock = clock

        # boolean; set to false to retain the sqlite db.
        self.delete_archive = delete_archive

        self._device_info = None  # set on start.
        self._is_streaming = False

        # Offset in seconds from the start of acquisition to calibration
        # trigger. Calculated once, then cached.
        self._cached_offset = None
        self._record_at_calib = None
        self._max_wait = 0.1  # for process loop

        self.marker_writer = NullMarkerWriter()

        # column in the acquisition data used to calculate offset.
        self.trigger_column = TRIGGER_COLUMN_NAME
        self._acq_process = None
        self._buf = None

    # @override ; context manager
    def __enter__(self):
        self.start_acquisition()
        return self

    # @override ; context manager
    def __exit__(self, _exc_type, _exc_value, _traceback):
        self.stop_acquisition()

    def start_acquisition(self):
        """Run the initialization code and start the loop to acquire data from
        the server.

        We use multiprocessing to achieve best performance during our sessions.

        Some references:
            Stopping processes and other great multiprocessing examples:
                https://pymotw.com/2/multiprocessing/communication.html
            Windows vs. Unix Process Differences:
                https://docs.python.org/2.7/library/multiprocessing.html#windows

        """

        if not self._is_streaming:
            log.debug("Starting Acquisition")

            msg_queue = Queue()

            self.configure_connector()

            # Clock is copied, so reset should happen in the main thread.
            self._clock.reset()

            # Used to communicate with the database from both the main thread
            # as well as the acquisition thread.
            self._buf = buffer_server.new_mailbox()

            self._acq_process = AcquisitionProcess(connector=self._connector,
                                                   clock=self._clock,
                                                   buf=self._buf,
                                                   msg_queue=msg_queue)
            self._acq_process.start()

            # Block thread until device connects and returns device_info.
            msg_type, msg = msg_queue.get()
            if msg_type == MSG_DEVICE_INFO:
                self._device_info = msg
                log.info("Connected to device")
                log.info(msg)
            elif msg_type == MSG_ERROR:
                raise Exception("Error connecting to device")
            else:
                raise Exception("Message not understood: " + str(msg))

            # Start up the database server
            buffer_server.start_server(self._buf, self._device_info.channels,
                                       self._buffer_name)
            # Inform acquisition process that database server is ready
            msg_queue.put(True)
            msg_queue = None
            self._is_streaming = True

    def configure_connector(self) -> None:
        """Steps to configure the device connector before starting acquisition."""

        if self._connector.__class__.supports(
                self._connector.device_spec, ConnectionMethod.LSL
        ) and self._connector.device_spec.content_type == 'EEG':
            log.debug("Initializing LSL Marker Writer.")
            self._connector.include_marker_streams = True
            # If there are any device channels with the same name as the offset_column ('TRG'),
            # rename these to avoid conflicts.
            self._connector.rename_rules[
                self.trigger_column] = f"{self.trigger_column}_device_stream"
            # Initialize the marker streams before the device connection (name it 'TRG').
            self.marker_writer = LslMarkerWriter(
                stream_name=self.trigger_column)

    def stop_acquisition(self):
        """Stop acquiring data; perform cleanup."""
        log.debug("Stopping Acquisition Process")

        self._is_streaming = False

        self._acq_process.stop()
        self._acq_process.join()

        self.marker_writer.cleanup()
        self.marker_writer = NullMarkerWriter()

        if self._raw_data_file_name and self._buf:
            buffer_server.dump_data(self._buf, self._raw_data_file_name,
                                    self.device_info.name, self.device_info.fs)

    def get_data(self, start=None, end=None, field='_rowid_'):
        """Queries the buffer by field.

        Parameters
        ----------
            start : number, optional
                start of time slice; units are those of the acquisition clock.
            end : float, optional
                end of time slice; units are those of the acquisition clock.
            field: str, optional
                field on which to query; default value is the row id.
        Returns
        -------
            list of Records
        """

        if self._buf is None:
            return []

        return buffer_server.get_data(self._buf, start, end, field)

    def get_data_for_clock(self, calib_time: float, start_time: float,
                           end_time: float):
        """Queries the database, using start and end values relative to a
        clock different than the acquisition clock.

        Parameters
        ----------
            calib_time: float
                experiment_clock time (in seconds) at calibration.
            start_time : float, optional
                start of time slice; units are those of the experiment clock.
            end_time : float, optional
                end of time slice; units are those of the experiment clock.
        Returns
        -------
            list of Records
        """

        sample_rate = self._device_info.fs

        if self._record_at_calib is None:
            rownum_at_calib = 1
        else:
            rownum_at_calib = self._record_at_calib.rownum

        # Calculate number of samples since experiment_clock calibration;
        # multiplying by the fs converts from seconds to samples.
        start_offset = (start_time - calib_time) * sample_rate
        start = rownum_at_calib + start_offset

        end = None
        if end_time:
            end_offset = (end_time - calib_time) * sample_rate
            end = rownum_at_calib + end_offset

        return self.get_data(start=start, end=end, field='_rowid_')

    def get_data_len(self):
        """Efficient way to calculate the amount of data cached."""
        if self._buf is None:
            return 0
        return buffer_server.count(self._buf)

    @property
    def device_info(self):
        """Get the latest device_info."""
        return self._device_info

    @property
    def is_calibrated(self):
        """Returns boolean indicating whether or not acquisition has been
        calibrated (an offset calculated based on a trigger)."""
        return self.offset is not None

    @is_calibrated.setter
    def is_calibrated(self, bool_val):
        """Setter for the is_calibrated property that allows the user to
        override the calculated value and use a 0 offset.

        Parameters
        ----------
            bool_val: boolean
                if True, uses a 0 offset; if False forces the calculation.
        """
        self._cached_offset = 0.0 if bool_val else None

    @property
    def offset(self):
        """Offset in seconds from the start of acquisition to calibration
        trigger.

        Returns
        -------
            float or None if values in offset_column are all 0.
        """

        # cached value if previously queried; only needs to be computed once.
        if self._cached_offset is not None:
            return self._cached_offset

        if self._buf is None or self._device_info is None:
            log.debug("Buffer or device has not been initialized")
            return None

        log.debug("Querying database for offset")
        # Assumes that the offset_column is present and used for calibration, and
        # that non-calibration values are all 0.
        rows = buffer_server.query(self._buf,
                                   filters=[(self.trigger_column, ">", 0)],
                                   ordering=("timestamp", "asc"),
                                   max_results=1)
        if not rows:
            log.debug(f"No rows have a {self.trigger_column} value.")
            return None

        log.debug(rows[0])
        # Calculate offset from the number of samples recorded by the time
        # of calibration.
        self._record_at_calib = rows[0]
        self._cached_offset = rows[0].rownum / self._device_info.fs
        log.debug("Cached offset: %s", str(self._cached_offset))
        return self._cached_offset

    @property
    def record_at_calib(self):
        """Data record at the calibration trigger"""
        return self._record_at_calib

    def cleanup(self):
        """Performs cleanup tasks, such as deleting the buffer archive. Note
        that data will be unavailable after calling this method."""
        if self._buf:
            buffer_server.stop(self._buf, delete_archive=self.delete_archive)
            self._buf = None
示例#2
0
class DataAcquisitionClient:
    """Data Acquisition client. The client sets up a separate thread for
    acquisition, writes incoming data to a queue, and processes the data from
    the queue.

    Parameters
    ----------
        device: Device instance
            Object with device-specific implementations for connecting,
            initializing, and reading a packet.
        processor : Processor; optional
            A data Processor that does something with the streaming data (ex.
            writes to a file.)
        buffer_name : str, optional
            Name of the sql database archive; default is buffer.db.
        clock : Clock, optional
            Clock instance used to timestamp each acquisition record
        delete_archive: boolean, optional
            Flag indicating whether to delete the database archive on exit.
            Default is True.
    """

    def __init__(self,
                 device,
                 processor=FileWriter(filename='rawdata.csv'),
                 buffer_name='buffer.db',
                 clock=CountClock(),
                 delete_archive=True):

        self._device = device
        self._processor = processor
        self._buffer_name = buffer_name
        self._clock = clock

        # boolean; set to false to retain the sqlite db.
        self.delete_archive = delete_archive

        self._device_info = None  # set on start.
        self._is_streaming = False

        # Offset in seconds from the start of acquisition to calibration
        # trigger. Calculated once, then cached.
        self._cached_offset = None
        self._record_at_calib = None
        self._max_wait = 0.1  # for process loop

        # Max number of records in queue before it blocks for processing.
        maxsize = 500

        self._process_queue = multiprocessing.JoinableQueue(maxsize=maxsize)
        self.marker_writer = NullMarkerWriter()
        self._acq_process = None
        self._data_processor = None
        self._buf = None

    # @override ; context manager
    def __enter__(self):
        self.start_acquisition()
        return self

    # @override ; context manager
    def __exit__(self, _exc_type, _exc_value, _traceback):
        self.stop_acquisition()

    def start_acquisition(self):
        """Run the initialization code and start the loop to acquire data from
        the server.

        We use multiprocessing to achieve best performance during our sessions.

        Some references:
            Stopping processes and other great multiprocessing examples:
                https://pymotw.com/2/multiprocessing/communication.html
            Windows vs. Unix Process Differences:
                https://docs.python.org/2.7/library/multiprocessing.html#windows

        """

        if not self._is_streaming:
            log.debug("Starting Acquisition")

            msg_queue = multiprocessing.Queue()

            # Initialize the marker streams before the device connection so the
            # device can start listening.
            # TODO: Should this be a property of the device?
            if self._device.name == 'LSL':
                self.marker_writer = LslMarkerWriter()

            # Clock is copied, so reset should happen in the main thread.
            self._clock.reset()

            self._acq_process = AcquisitionProcess(self._device, self._clock,
                                                   self._process_queue,
                                                   msg_queue)
            self._acq_process.start()

            # Block thread until device connects and returns device_info.
            msg_type, msg = msg_queue.get()
            if msg_type == MSG_DEVICE_INFO:
                self._device_info = msg
            elif msg_type == MSG_ERROR:
                raise Exception("Error connecting to device")
            else:
                raise Exception("Message not understood: " + str(msg))

            # Initialize the buffer and processor; this occurs after the
            # device initialization to ensure that any device parameters have
            # been updated as needed.
            self._processor.set_device_info(self._device_info)
            self._buf = buffer_server.start(self._device_info.channels,
                                            self._buffer_name)

            self._data_processor = DataProcessor(
                data_queue=self._process_queue,
                msg_queue = msg_queue,
                processor=self._processor,
                buf=self._buf,
                wait=self._max_wait)
            self._data_processor.start()

            # Block until processor has initialized.
            msg_type, msg = msg_queue.get()
            self._is_streaming = True

    def stop_acquisition(self):
        """Stop acquiring data; perform cleanup."""
        log.debug("Stopping Acquisition Process")

        self._is_streaming = False

        self._acq_process.stop()
        self._acq_process.join()

        log.debug("Stopping Processing Queue")

        # Blocks until all data in the queue is consumed.
        self._process_queue.join()
        self._data_processor.stop()
        self.marker_writer.cleanup()
        self.marker_writer = NullMarkerWriter()

    def get_data(self, start=None, end=None, field='_rowid_', win=None):
        """Queries the buffer by field.

        Parameters
        ----------
            start : number, optional
                start of time slice; units are those of the acquisition clock.
            end : float, optional
                end of time slice; units are those of the acquisition clock.
            field: str, optional
                field on which to query; default value is the row id.
            win : Window
                window to pass to server for reloading
        Returns
        -------
            list of Records
        """

        if self._buf is None:
            return []

        return buffer_server.get_data(self._buf, start, end, field, win)

    def get_data_for_clock(self, calib_time: float, start_time: float,
                           end_time: float):
        """Queries the database, using start and end values relative to a
        clock different than the acquisition clock.

        Parameters
        ----------
            calib_time: float
                experiment_clock time (in seconds) at calibration.
            start_time : float, optional
                start of time slice; units are those of the experiment clock.
            end_time : float, optional
                end of time slice; units are those of the experiment clock.
        Returns
        -------
            list of Records
        """

        sample_rate = self._device_info.fs

        if self._record_at_calib is None:
            rownum_at_calib = 1
        else:
            rownum_at_calib = self._record_at_calib.rownum

        # Calculate number of samples since experiment_clock calibration;
        # multiplying by the fs converts from seconds to samples.
        start_offset = (start_time - calib_time) * sample_rate
        start = rownum_at_calib + start_offset

        end = None
        if end_time:
            end_offset = (end_time - calib_time) * sample_rate
            end = rownum_at_calib + end_offset

        return self.get_data(start=start, end=end, field='_rowid_')

    def get_data_len(self):
        """Efficient way to calculate the amount of data cached."""
        if self._buf is None:
            return 0
        return buffer_server.count(self._buf)

    @property
    def device_info(self):
        """Get the latest device_info."""
        return self._device_info

    @property
    def is_calibrated(self):
        """Returns boolean indicating whether or not acquisition has been
        calibrated (an offset calculated based on a trigger)."""
        return self.offset is not None

    @is_calibrated.setter
    def is_calibrated(self, bool_val):
        """Setter for the is_calibrated property that allows the user to
        override the calculated value and use a 0 offset.

        Parameters
        ----------
            bool_val: boolean
                if True, uses a 0 offset; if False forces the calculation.
        """
        self._cached_offset = 0.0 if bool_val else None

    @property
    def offset(self):
        """Offset in seconds from the start of acquisition to calibration
        trigger.

        Returns
        -------
            float or None if TRG channel is all 0.
        TODO: Consider setting the trigger channel name in the device_info.
        """

        # cached value if previously queried; only needs to be computed once.
        if self._cached_offset is not None:
            return self._cached_offset

        if self._buf is None or self._device_info is None:
            log.debug("Buffer or device has not been initialized")
            return None

        log.debug("Querying database for offset")
        # Assumes that the TRG column is present and used for calibration, and
        # that non-calibration values are all 0.
        rows = buffer_server.query(self._buf,
                                   filters=[("TRG", ">", 0)],
                                   ordering=("timestamp", "asc"),
                                   max_results=1)
        if not rows:
            log.debug("No rows have a TRG value.")
            return None

        log.debug(rows[0])
        # Calculate offset from the number of samples recorded by the time
        # of calibration.
        self._record_at_calib = rows[0]
        self._cached_offset = rows[0].rownum / self._device_info.fs
        log.debug("Cached offset: %s", str(self._cached_offset))
        return self._cached_offset

    @property
    def record_at_calib(self):
        """Data record at the calibration trigger"""
        return self._record_at_calib

    def cleanup(self):
        """Performs cleanup tasks, such as deleting the buffer archive. Note
        that data will be unavailable after calling this method."""
        if self._buf:
            buffer_server.stop(self._buf, delete_archive=self.delete_archive)
            self._buf = None