Initial commit
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
//! High-level Brain-Computer Interface API.
|
||||
//!
|
||||
//! Provides a simple interface for BCI applications with source-level
|
||||
//! feedback and control signal generation.
|
||||
|
||||
use crate::error::RealtimeResult;
|
||||
use crate::gpu_beamformer::{GpuBeamformer, GpuBeamformerConfig};
|
||||
use crate::latency::LatencyStats;
|
||||
use crate::pipeline::{PipelineConfig, PipelineState, RealtimePipeline, SourceOutput};
|
||||
|
||||
use nalgebra::DMatrix;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Callback type for source updates
|
||||
pub type SourceCallback = Box<dyn Fn(&ControlSignal) + Send + Sync>;
|
||||
|
||||
/// Control signal derived from source activity
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlSignal {
|
||||
/// Source indices being monitored
|
||||
pub source_indices: Vec<usize>,
|
||||
/// Current power values for each source
|
||||
pub power: Vec<f64>,
|
||||
/// Normalized control values (0.0 - 1.0)
|
||||
pub control_values: Vec<f64>,
|
||||
/// Combined control signal (weighted average)
|
||||
pub combined_signal: f64,
|
||||
/// Threshold crossing (true if any source exceeds threshold)
|
||||
pub threshold_crossed: bool,
|
||||
/// Timestamp
|
||||
pub timestamp: f64,
|
||||
/// Latency in milliseconds
|
||||
pub latency_ms: f64,
|
||||
}
|
||||
|
||||
impl Default for ControlSignal {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source_indices: vec![],
|
||||
power: vec![],
|
||||
control_values: vec![],
|
||||
combined_signal: 0.0,
|
||||
threshold_crossed: false,
|
||||
timestamp: 0.0,
|
||||
latency_ms: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BCI configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BciConfig {
|
||||
/// Number of channels
|
||||
pub n_channels: usize,
|
||||
/// Sample rate in Hz
|
||||
pub sample_rate: f64,
|
||||
/// Buffer duration in milliseconds
|
||||
pub buffer_duration_ms: f64,
|
||||
/// Processing window size in milliseconds
|
||||
pub window_size_ms: f64,
|
||||
/// Window stride in milliseconds
|
||||
pub stride_ms: f64,
|
||||
/// Filter low cutoff (Hz)
|
||||
pub filter_low: f64,
|
||||
/// Filter high cutoff (Hz)
|
||||
pub filter_high: f64,
|
||||
/// Source indices to monitor
|
||||
pub monitor_sources: Vec<usize>,
|
||||
/// Threshold for activation detection (0.0 - 1.0)
|
||||
pub threshold: f64,
|
||||
/// Source weights for combined signal
|
||||
pub source_weights: Vec<f64>,
|
||||
/// Baseline period in seconds (for normalization)
|
||||
pub baseline_duration: f64,
|
||||
/// Smoothing factor (0.0 = no smoothing, 1.0 = full smoothing)
|
||||
pub smoothing: f64,
|
||||
}
|
||||
|
||||
impl Default for BciConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
n_channels: 64,
|
||||
sample_rate: 1000.0,
|
||||
buffer_duration_ms: 500.0,
|
||||
window_size_ms: 100.0,
|
||||
stride_ms: 50.0,
|
||||
filter_low: 8.0,
|
||||
filter_high: 30.0,
|
||||
monitor_sources: vec![0],
|
||||
threshold: 0.5,
|
||||
source_weights: vec![1.0],
|
||||
baseline_duration: 2.0,
|
||||
smoothing: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level BCI pipeline
|
||||
pub struct BciPipeline {
|
||||
/// Configuration
|
||||
config: BciConfig,
|
||||
/// Underlying real-time pipeline
|
||||
pipeline: RealtimePipeline,
|
||||
/// Baseline power estimates [n_sources]
|
||||
baseline: Arc<RwLock<Vec<f64>>>,
|
||||
/// Current smoothed values [n_sources]
|
||||
smoothed_values: Arc<RwLock<Vec<f64>>>,
|
||||
/// Callback for control signal updates
|
||||
callback: Arc<RwLock<Option<SourceCallback>>>,
|
||||
/// Whether baseline calibration is complete
|
||||
baseline_calibrated: Arc<RwLock<bool>>,
|
||||
/// Baseline samples collected
|
||||
baseline_samples: Arc<RwLock<Vec<Vec<f64>>>>,
|
||||
}
|
||||
|
||||
impl BciPipeline {
|
||||
/// Create a new BCI pipeline
|
||||
pub fn new(config: BciConfig) -> RealtimeResult<Self> {
|
||||
// Build the underlying pipeline
|
||||
let pipeline_config = PipelineConfig {
|
||||
n_channels: config.n_channels,
|
||||
sample_rate: config.sample_rate,
|
||||
buffer_duration_ms: config.buffer_duration_ms,
|
||||
window_size_ms: config.window_size_ms,
|
||||
window_stride_ms: config.stride_ms,
|
||||
filter_low: config.filter_low,
|
||||
filter_high: config.filter_high,
|
||||
enable_filtering: true,
|
||||
enable_beamforming: false, // Will set beamformer separately
|
||||
target_latency_ms: 10.0,
|
||||
output_queue_size: 100,
|
||||
};
|
||||
|
||||
let pipeline = RealtimePipeline::new(pipeline_config)?;
|
||||
|
||||
let n_sources = config.monitor_sources.len().max(1);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
pipeline,
|
||||
baseline: Arc::new(RwLock::new(vec![1.0; n_sources])),
|
||||
smoothed_values: Arc::new(RwLock::new(vec![0.0; n_sources])),
|
||||
callback: Arc::new(RwLock::new(None)),
|
||||
baseline_calibrated: Arc::new(RwLock::new(false)),
|
||||
baseline_samples: Arc::new(RwLock::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the beamformer
|
||||
pub fn set_beamformer(&mut self, beamformer: GpuBeamformer) {
|
||||
self.pipeline.set_beamformer(beamformer);
|
||||
}
|
||||
|
||||
/// Create beamformer from gain and covariance matrices
|
||||
pub fn create_beamformer(
|
||||
&mut self,
|
||||
gain: &DMatrix<f64>,
|
||||
data_cov: &DMatrix<f64>,
|
||||
) -> RealtimeResult<()> {
|
||||
let n_sources = gain.ncols();
|
||||
|
||||
let bf_config = GpuBeamformerConfig {
|
||||
n_channels: self.config.n_channels,
|
||||
n_sources,
|
||||
free_orientation: false,
|
||||
regularization: 0.05,
|
||||
normalize_power: true,
|
||||
};
|
||||
|
||||
let beamformer = GpuBeamformer::from_forward_model(bf_config, gain, data_cov)?;
|
||||
self.pipeline.set_beamformer(beamformer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set callback for control signal updates
|
||||
pub fn on_source_update<F>(&self, callback: F)
|
||||
where
|
||||
F: Fn(&ControlSignal) + Send + Sync + 'static,
|
||||
{
|
||||
*self.callback.write() = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Clear the callback
|
||||
pub fn clear_callback(&self) {
|
||||
*self.callback.write() = None;
|
||||
}
|
||||
|
||||
/// Push a sample into the pipeline
|
||||
pub fn push_sample(&self, data: Vec<f64>, timestamp: f64) {
|
||||
self.pipeline.push_sample(data, timestamp);
|
||||
}
|
||||
|
||||
/// Push a batch of samples
|
||||
pub fn push_batch(&self, data: &[Vec<f64>], timestamps: &[f64]) {
|
||||
self.pipeline.push_batch(data, timestamps);
|
||||
}
|
||||
|
||||
/// Start the BCI pipeline
|
||||
pub fn start(&mut self) -> RealtimeResult<()> {
|
||||
self.pipeline.start()
|
||||
}
|
||||
|
||||
/// Stop the pipeline
|
||||
pub fn stop(&mut self) -> RealtimeResult<()> {
|
||||
self.pipeline.stop()
|
||||
}
|
||||
|
||||
/// Start baseline calibration
|
||||
pub fn start_baseline_calibration(&self) {
|
||||
*self.baseline_calibrated.write() = false;
|
||||
self.baseline_samples.write().clear();
|
||||
}
|
||||
|
||||
/// Check if baseline is calibrated
|
||||
pub fn is_baseline_calibrated(&self) -> bool {
|
||||
*self.baseline_calibrated.read()
|
||||
}
|
||||
|
||||
/// Process pending outputs and generate control signals
|
||||
pub fn process(&self) -> Option<ControlSignal> {
|
||||
let output = self.pipeline.try_recv()?;
|
||||
Some(self.compute_control_signal(&output))
|
||||
}
|
||||
|
||||
/// Process with blocking wait
|
||||
pub fn process_blocking(&self) -> Option<ControlSignal> {
|
||||
let output = self.pipeline.recv()?;
|
||||
Some(self.compute_control_signal(&output))
|
||||
}
|
||||
|
||||
/// Process with timeout
|
||||
pub fn process_timeout(&self, timeout: Duration) -> Option<ControlSignal> {
|
||||
let output = self.pipeline.recv_timeout(timeout)?;
|
||||
Some(self.compute_control_signal(&output))
|
||||
}
|
||||
|
||||
/// Compute control signal from source output
|
||||
fn compute_control_signal(&self, output: &SourceOutput) -> ControlSignal {
|
||||
let n_sources = output.sources.len();
|
||||
let monitor_sources = &self.config.monitor_sources;
|
||||
|
||||
// Compute power for each monitored source
|
||||
let mut power = Vec::with_capacity(monitor_sources.len());
|
||||
for &src_idx in monitor_sources {
|
||||
if src_idx < n_sources && !output.sources[src_idx].is_empty() {
|
||||
let src_power: f64 = output.sources[src_idx]
|
||||
.iter()
|
||||
.map(|x| x.powi(2))
|
||||
.sum::<f64>()
|
||||
/ output.sources[src_idx].len() as f64;
|
||||
power.push(src_power.sqrt());
|
||||
} else {
|
||||
power.push(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Update baseline if calibrating
|
||||
if !*self.baseline_calibrated.read() {
|
||||
let mut baseline_samples = self.baseline_samples.write();
|
||||
baseline_samples.push(power.clone());
|
||||
|
||||
let required_samples =
|
||||
(self.config.baseline_duration * 1000.0 / self.config.stride_ms) as usize;
|
||||
|
||||
if baseline_samples.len() >= required_samples {
|
||||
// Compute baseline as mean power
|
||||
let n_samples = baseline_samples.len() as f64;
|
||||
let baseline: Vec<f64> = (0..power.len())
|
||||
.map(|i| baseline_samples.iter().map(|s| s[i]).sum::<f64>() / n_samples)
|
||||
.collect();
|
||||
|
||||
*self.baseline.write() = baseline;
|
||||
*self.baseline_calibrated.write() = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by baseline
|
||||
let baseline = self.baseline.read();
|
||||
let control_values: Vec<f64> = power
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| {
|
||||
if baseline[i] > 1e-10 {
|
||||
(p / baseline[i]).min(2.0) // Clamp to 2x baseline
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Apply smoothing
|
||||
let mut smoothed = self.smoothed_values.write();
|
||||
for i in 0..control_values.len().min(smoothed.len()) {
|
||||
smoothed[i] = self.config.smoothing * smoothed[i]
|
||||
+ (1.0 - self.config.smoothing) * control_values[i];
|
||||
}
|
||||
let smoothed_values = smoothed.clone();
|
||||
drop(smoothed);
|
||||
|
||||
// Compute combined signal
|
||||
let weights = &self.config.source_weights;
|
||||
let combined_signal = if !weights.is_empty() {
|
||||
let weight_sum: f64 = weights.iter().sum();
|
||||
smoothed_values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| {
|
||||
let w = weights.get(i).copied().unwrap_or(1.0);
|
||||
v * w
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ weight_sum.max(1.0)
|
||||
} else {
|
||||
smoothed_values.iter().sum::<f64>() / smoothed_values.len().max(1) as f64
|
||||
};
|
||||
|
||||
// Check threshold
|
||||
let threshold_crossed = smoothed_values.iter().any(|&v| v > self.config.threshold);
|
||||
|
||||
// Get timestamp
|
||||
let timestamp = output.timestamps.last().copied().unwrap_or(0.0);
|
||||
let latency_ms = output.latency_us as f64 / 1000.0;
|
||||
|
||||
let signal = ControlSignal {
|
||||
source_indices: monitor_sources.clone(),
|
||||
power,
|
||||
control_values: smoothed_values,
|
||||
combined_signal,
|
||||
threshold_crossed,
|
||||
timestamp,
|
||||
latency_ms,
|
||||
};
|
||||
|
||||
// Call callback if set
|
||||
if let Some(ref callback) = *self.callback.read() {
|
||||
callback(&signal);
|
||||
}
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
/// Get current pipeline state
|
||||
pub fn state(&self) -> PipelineState {
|
||||
self.pipeline.state()
|
||||
}
|
||||
|
||||
/// Get latency statistics
|
||||
pub fn latency_stats(&self) -> Vec<LatencyStats> {
|
||||
self.pipeline.latency_stats()
|
||||
}
|
||||
|
||||
/// Get latency report
|
||||
pub fn latency_report(&self) -> String {
|
||||
self.pipeline.latency_report()
|
||||
}
|
||||
|
||||
/// Check if latency targets are met
|
||||
pub fn meets_latency_targets(&self) -> bool {
|
||||
self.pipeline.meets_latency_targets()
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &BciConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Set threshold
|
||||
pub fn set_threshold(&mut self, threshold: f64) {
|
||||
self.config.threshold = threshold;
|
||||
}
|
||||
|
||||
/// Set smoothing factor
|
||||
pub fn set_smoothing(&mut self, smoothing: f64) {
|
||||
self.config.smoothing = smoothing.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Set source weights
|
||||
pub fn set_source_weights(&mut self, weights: Vec<f64>) {
|
||||
self.config.source_weights = weights;
|
||||
}
|
||||
|
||||
/// Get current baseline values
|
||||
pub fn baseline(&self) -> Vec<f64> {
|
||||
self.baseline.read().clone()
|
||||
}
|
||||
|
||||
/// Set manual baseline values
|
||||
pub fn set_baseline(&self, baseline: Vec<f64>) {
|
||||
*self.baseline.write() = baseline;
|
||||
*self.baseline_calibrated.write() = true;
|
||||
}
|
||||
|
||||
/// Reset baseline calibration
|
||||
pub fn reset_baseline(&self) {
|
||||
*self.baseline_calibrated.write() = false;
|
||||
self.baseline_samples.write().clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Motor imagery BCI configuration preset
|
||||
pub fn motor_imagery_config(n_channels: usize, sample_rate: f64) -> BciConfig {
|
||||
BciConfig {
|
||||
n_channels,
|
||||
sample_rate,
|
||||
buffer_duration_ms: 500.0,
|
||||
window_size_ms: 200.0,
|
||||
stride_ms: 50.0,
|
||||
filter_low: 8.0, // Mu/beta band
|
||||
filter_high: 30.0,
|
||||
monitor_sources: vec![0, 1], // Left/right motor cortex
|
||||
threshold: 0.6,
|
||||
source_weights: vec![1.0, -1.0], // Lateralized response
|
||||
baseline_duration: 3.0,
|
||||
smoothing: 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
/// SSVEP BCI configuration preset
|
||||
pub fn ssvep_config(n_channels: usize, sample_rate: f64) -> BciConfig {
|
||||
BciConfig {
|
||||
n_channels,
|
||||
sample_rate,
|
||||
buffer_duration_ms: 500.0,
|
||||
window_size_ms: 500.0, // Longer window for frequency resolution
|
||||
stride_ms: 100.0,
|
||||
filter_low: 5.0, // SSVEP typically 5-20 Hz
|
||||
filter_high: 25.0,
|
||||
monitor_sources: vec![0], // Visual cortex
|
||||
threshold: 0.7,
|
||||
source_weights: vec![1.0],
|
||||
baseline_duration: 2.0,
|
||||
smoothing: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// P300 speller BCI configuration preset
|
||||
pub fn p300_config(n_channels: usize, sample_rate: f64) -> BciConfig {
|
||||
BciConfig {
|
||||
n_channels,
|
||||
sample_rate,
|
||||
buffer_duration_ms: 800.0,
|
||||
window_size_ms: 600.0, // Capture P300 component
|
||||
stride_ms: 100.0,
|
||||
filter_low: 0.5, // Include slow components
|
||||
filter_high: 15.0,
|
||||
monitor_sources: vec![0], // Parietal source
|
||||
threshold: 0.5,
|
||||
source_weights: vec![1.0],
|
||||
baseline_duration: 1.0,
|
||||
smoothing: 0.2, // Less smoothing for transient detection
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bci_config_default() {
|
||||
let config = BciConfig::default();
|
||||
assert_eq!(config.n_channels, 64);
|
||||
assert_eq!(config.sample_rate, 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bci_pipeline_creation() {
|
||||
let config = BciConfig {
|
||||
n_channels: 32,
|
||||
sample_rate: 500.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let pipeline = BciPipeline::new(config).unwrap();
|
||||
assert_eq!(pipeline.state(), PipelineState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_motor_imagery_preset() {
|
||||
let config = motor_imagery_config(64, 1000.0);
|
||||
assert_eq!(config.monitor_sources, vec![0, 1]);
|
||||
assert_eq!(config.source_weights, vec![1.0, -1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssvep_preset() {
|
||||
let config = ssvep_config(32, 500.0);
|
||||
assert_eq!(config.window_size_ms, 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p300_preset() {
|
||||
let config = p300_config(16, 250.0);
|
||||
assert_eq!(config.filter_low, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_control_signal_default() {
|
||||
let signal = ControlSignal::default();
|
||||
assert_eq!(signal.combined_signal, 0.0);
|
||||
assert!(!signal.threshold_crossed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_baseline_calibration() {
|
||||
let config = BciConfig {
|
||||
n_channels: 4,
|
||||
sample_rate: 100.0,
|
||||
baseline_duration: 0.1, // Short for testing
|
||||
stride_ms: 10.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let pipeline = BciPipeline::new(config).unwrap();
|
||||
|
||||
assert!(!pipeline.is_baseline_calibrated());
|
||||
pipeline.start_baseline_calibration();
|
||||
assert!(!pipeline.is_baseline_calibrated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_threshold() {
|
||||
let config = BciConfig::default();
|
||||
let mut pipeline = BciPipeline::new(config).unwrap();
|
||||
|
||||
pipeline.set_threshold(0.8);
|
||||
assert_eq!(pipeline.config().threshold, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_smoothing() {
|
||||
let config = BciConfig::default();
|
||||
let mut pipeline = BciPipeline::new(config).unwrap();
|
||||
|
||||
pipeline.set_smoothing(0.5);
|
||||
assert_eq!(pipeline.config().smoothing, 0.5);
|
||||
|
||||
// Test clamping
|
||||
pipeline.set_smoothing(1.5);
|
||||
assert_eq!(pipeline.config().smoothing, 1.0);
|
||||
|
||||
pipeline.set_smoothing(-0.5);
|
||||
assert_eq!(pipeline.config().smoothing, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manual_baseline() {
|
||||
let config = BciConfig {
|
||||
monitor_sources: vec![0, 1],
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = BciPipeline::new(config).unwrap();
|
||||
|
||||
pipeline.set_baseline(vec![1.5, 2.0]);
|
||||
assert!(pipeline.is_baseline_calibrated());
|
||||
assert_eq!(pipeline.baseline(), vec![1.5, 2.0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user