pub mod error;
pub mod qc;
pub mod stats;
#[cfg(feature = "gpu")]
pub mod gpu;
pub mod fcs;
pub use error::{PeacoQCError, Result};
pub use qc::{
DoubletConfig, DoubletResult, MarginConfig, MarginResult, PeacoQCConfig, PeacoQCResult,
QCExportFormat, QCExportOptions, QCMode, QCPlotConfig, create_qc_plots, export_csv_boolean,
export_csv_numeric, export_json_metadata, peacoqc, remove_doublets, remove_margins,
};
#[cfg(feature = "flow-fcs")]
pub use crate::flow_fcs_impl::preprocess_fcs;
pub trait PeacoQCData {
fn n_events(&self) -> usize;
fn channel_names(&self) -> Vec<String>;
fn get_channel_range(&self, channel: &str) -> Option<(f64, f64)>;
fn get_channel_f64(&self, channel: &str) -> Result<Vec<f64>>;
fn get_fluorescence_channels(&self) -> Vec<String> {
self.channel_names()
.into_iter()
.filter(|name| {
let upper = name.to_uppercase();
!upper.contains("FSC") && !upper.contains("SSC") && !upper.contains("TIME")
})
.collect()
}
}
pub trait FcsFilter: Sized {
fn filter(&self, mask: &[bool]) -> Result<Self>;
}
#[cfg(feature = "flow-fcs")]
mod flow_fcs_impl {
use super::*;
use flow_fcs::{file::Fcs, keyword::FloatableKeyword};
use polars::prelude::*;
use std::sync::Arc;
impl PeacoQCData for Fcs {
fn n_events(&self) -> usize {
self.get_event_count_from_dataframe()
}
fn channel_names(&self) -> Vec<String> {
self.parameters
.values()
.map(|p| p.channel_name.to_string())
.collect()
}
fn get_channel_range(&self, channel: &str) -> Option<(f64, f64)> {
let param = self
.parameters
.values()
.find(|p| p.channel_name.as_ref() == channel)?;
let key = format!("$P{}R", param.parameter_number);
let max_range = self.metadata.get_float_keyword(&key).ok()?.get_f32();
Some((0.0, *max_range as f64))
}
fn get_channel_f64(&self, channel: &str) -> Result<Vec<f64>> {
let series = self
.data_frame
.column(channel)
.map_err(|_| PeacoQCError::ChannelNotFound(channel.to_string()))?;
let values = if let Ok(f64_vals) = series.f64() {
f64_vals.into_iter().filter_map(|x| x).collect()
} else if let Ok(f32_vals) = series.f32() {
f32_vals
.into_iter()
.filter_map(|x| x.map(|v| v as f64))
.collect()
} else {
return Err(PeacoQCError::InvalidChannel(format!(
"Channel {} is not numeric (dtype: {:?})",
channel,
series.dtype()
)));
};
Ok(values)
}
fn get_fluorescence_channels(&self) -> Vec<String> {
self.parameters
.values()
.filter(|p| p.is_fluorescence())
.map(|p| p.channel_name.to_string())
.collect()
}
}
impl FcsFilter for Fcs {
fn filter(&self, mask: &[bool]) -> Result<Self> {
let n_events = self.get_event_count_from_dataframe();
if mask.len() != n_events {
return Err(PeacoQCError::StatsError(format!(
"Mask length {} doesn't match event count {}",
mask.len(),
n_events
)));
}
let mask_vec: Vec<bool> = mask.to_vec();
let mask_series = Series::new("mask".into(), mask_vec);
let mask_ca = mask_series.bool().map_err(|e| {
PeacoQCError::StatsError(format!("Failed to convert mask to boolean array: {}", e))
})?;
let filtered_df = self
.data_frame
.filter(&mask_ca)
.map_err(|e| PeacoQCError::PolarsError(e))?;
let mut filtered_fcs = self.clone();
filtered_fcs.data_frame = Arc::new(filtered_df);
Ok(filtered_fcs)
}
}
pub fn preprocess_fcs(
mut fcs: Fcs,
apply_compensation: bool,
apply_transformation: bool,
_transform_cofactor: f32, ) -> anyhow::Result<Fcs> {
use tracing::info;
if apply_compensation && fcs.has_compensation() {
let compensated_df = fcs
.apply_file_compensation()
.map_err(|e| anyhow::anyhow!("Failed to apply compensation: {}", e))?;
fcs.data_frame = compensated_df;
info!("Applied compensation from $SPILLOVER keyword");
} else if apply_compensation && !fcs.has_compensation() {
info!("No $SPILLOVER keyword found; skipping compensation (will use arcsinh transform)");
}
if apply_transformation {
let has_comp = fcs.has_compensation();
let transformed_df = if has_comp {
fcs.apply_default_biexponential_transform().map_err(|e| {
anyhow::anyhow!("Failed to apply biexponential transformation: {}", e)
})?
} else {
fcs.apply_default_arcsinh_transform()
.map_err(|e| anyhow::anyhow!("Failed to apply arcsinh transformation: {}", e))?
};
fcs.data_frame = transformed_df;
if has_comp {
info!(
"Applied biexponential (logicle) transformation to fluorescence channels (matching R's estimateLogicle)"
);
} else {
info!(
"Applied arcsinh transformation to fluorescence channels with cofactor=2000 (matching R's fallback)"
);
}
}
Ok(fcs)
}
}