flow_fcs/parameter.rs
1use derive_builder::Builder;
2use polars::prelude::*;
3use rustc_hash::FxHashMap;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6
7use crate::TransformType;
8
9// New Polars-based types for columnar storage
10/// Event data stored as a Polars DataFrame for efficient columnar access
11/// Each column represents one parameter (e.g., FSC-A, SSC-A, FL1-A)
12/// Benefits:
13/// - Zero-copy column access
14/// - Built-in SIMD operations
15/// - Lazy evaluation for complex queries
16/// - Apache Arrow interop
17pub type EventDataFrame = Arc<DataFrame>;
18pub type EventDatum = f32;
19pub type ChannelName = Arc<str>;
20pub type LabelName = Arc<str>;
21pub type ParameterMap = FxHashMap<ChannelName, Parameter>;
22
23/// Instructions for parameter processing transformations
24///
25/// These variants indicate what transformations should be applied to the data,
26/// not the current state of the data (which may already be processed).
27/// This is used to track the processing pipeline for parameters (compensation, unmixing, etc.)
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
30#[cfg_attr(feature = "typescript", ts(export))]
31pub enum ParameterProcessing {
32 /// Raw, unprocessed data from FCS file
33 Raw,
34 /// Compensated for spectral overlap
35 Compensated,
36 /// Spectrally unmixed
37 Unmixed,
38 /// Both compensated and spectrally unmixed
39 UnmixedCompensated,
40}
41
42impl Default for ParameterProcessing {
43 fn default() -> Self {
44 ParameterProcessing::Raw
45 }
46}
47
48/// Category for grouping parameters in user interfaces
49///
50/// This enum helps organize parameters by their processing state and type,
51/// making it easier to present options to users in plotting or analysis interfaces.
52#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
53pub enum ParameterCategory {
54 /// Raw parameters (FSC, SSC, Time, custom params)
55 Raw,
56 /// Fluorescence channels (raw)
57 Fluorescence,
58 /// Compensated fluorescence
59 Compensated,
60 /// Transformed parameters (arcsinh applied)
61 Transformed,
62 /// Both compensated and transformed
63 CompensatedTransformed,
64 /// Spectrally unmixed
65 Unmixed,
66}
67
68/// A parameter option for plotting that includes display information
69///
70/// This struct combines a `Parameter` with UI-specific metadata like display labels
71/// and categories, making it easy to present parameter options to users in plotting interfaces.
72#[derive(Serialize, Debug, Clone)]
73pub struct ParameterOption {
74 /// Unique identifier for this option (e.g., "comp_trans::UV379-A")
75 pub id: String,
76 /// Display label for UI (e.g., "Comp::UV379-A::CD8[T]")
77 pub display_label: String,
78 /// The actual parameter to use
79 pub parameter: Parameter,
80 /// Category for UI grouping
81 pub category: ParameterCategory,
82}
83
84#[derive(Serialize, Debug, Clone, Builder, Hash)]
85#[builder(setter(into))]
86pub struct Parameter {
87 /// The offset of the parameter in the FCS file's event data (1-based index)
88 pub parameter_number: usize,
89 /// The name of the channel ($PnN keyword)
90 pub channel_name: ChannelName,
91 /// The label name of the parameter ($PnS keyword)
92 pub label_name: LabelName,
93 /// The default transform to apply to the parameter
94 pub transform: TransformType,
95
96 /// Instructions for parameter processing (compensation, unmixing, etc.)
97 /// This enum indicates what transformations should be applied.
98 #[builder(default)]
99 #[serde(default)]
100 pub state: ParameterProcessing,
101
102 /// Excitation wavelength in nanometers (from $PnL keyword, if available)
103 #[builder(default)]
104 // #[serde(skip_serializing_if = "Option::is_none")]
105 pub excitation_wavelength: Option<usize>,
106}
107impl Parameter {
108 /// Creates a new `Parameter` with the specified properties
109 ///
110 /// # Arguments
111 /// * `parameter_number` - The 1-based index of the parameter in the FCS file
112 /// * `channel_name` - The channel name from the `$PnN` keyword (e.g., "FSC-A", "FL1-A")
113 /// * `label_name` - The label name from the `$PnS` keyword (e.g., "CD8", "CD4")
114 /// * `transform` - The default transformation type to apply
115 ///
116 /// # Returns
117 /// A new `Parameter` with `Raw` processing state and no excitation wavelength
118 #[must_use]
119 pub fn new(
120 parameter_number: &usize,
121 channel_name: &str,
122 label_name: &str,
123 transform: &TransformType,
124 ) -> Self {
125 Self {
126 parameter_number: *parameter_number,
127 channel_name: channel_name.into(),
128 label_name: label_name.into(),
129 transform: transform.clone(),
130 state: ParameterProcessing::default(),
131 excitation_wavelength: None,
132 }
133 }
134
135 /// Check if this parameter is fluorescence (should be transformed by default)
136 /// Excludes FSC (forward scatter), SSC (side scatter), and Time
137 #[must_use]
138 pub fn is_fluorescence(&self) -> bool {
139 let upper = self.channel_name.to_uppercase();
140 !upper.contains("FSC") && !upper.contains("SSC") && !upper.contains("TIME")
141 }
142
143 /// Get the display label for this parameter
144 /// Format examples:
145 /// - Raw: "UV379-A::CD8" or just "FSC-A"
146 /// - Compensated: "Comp::UV379-A::CD8"
147 /// - Unmixed: "Unmix::UV379-A::CD8"
148 /// - UnmixedCompensated: "Comp+Unmix::UV379-A::CD8"
149 #[must_use]
150 pub fn get_display_label(&self) -> String {
151 let prefix = match self.state {
152 ParameterProcessing::Raw => "",
153 ParameterProcessing::Compensated => "Comp::",
154 ParameterProcessing::Unmixed => "Unmix::",
155 ParameterProcessing::UnmixedCompensated => "Comp+Unmix::",
156 };
157
158 // If label_name is empty or same as channel, just use channel
159 if self.label_name.is_empty() || self.label_name.as_ref() == self.channel_name.as_ref() {
160 format!("{}{}", prefix, self.channel_name)
161 } else {
162 format!("{}{}::{}", prefix, self.channel_name, self.label_name)
163 }
164 }
165
166 /// Get the short label (without state prefix)
167 #[must_use]
168 pub fn get_short_label(&self) -> String {
169 if self.label_name.is_empty() || self.label_name.as_ref() == self.channel_name.as_ref() {
170 self.channel_name.to_string()
171 } else {
172 format!("{}::{}", self.channel_name, self.label_name)
173 }
174 }
175
176 /// Create a new parameter with updated state
177 #[must_use]
178 pub fn with_state(&self, state: ParameterProcessing) -> Self {
179 Self {
180 state,
181 ..self.clone()
182 }
183 }
184
185 /// Create a new parameter with updated transform
186 #[must_use]
187 pub fn with_transform(&self, transform: TransformType) -> Self {
188 Self {
189 transform,
190 ..self.clone()
191 }
192 }
193
194 /// Generate parameter options for plotting interfaces
195 ///
196 /// Creates a list of `ParameterOption` structs representing different processing
197 /// states of this parameter that can be used for plotting.
198 ///
199 /// **For fluorescence parameters:**
200 /// - Always returns transformed versions (arcsinh applied)
201 /// - If `include_compensated` is true, also includes compensated+transformed versions
202 /// - Includes unmixed versions if compensation is available
203 ///
204 /// **For non-fluorescence parameters (FSC, SSC, Time):**
205 /// - Returns raw (untransformed) versions only
206 ///
207 /// # Arguments
208 /// * `include_compensated` - Whether to include compensated and unmixed variants
209 ///
210 /// # Returns
211 /// A vector of `ParameterOption` structs ready for use in plotting UIs
212 pub fn generate_plot_options(&self, include_compensated: bool) -> Vec<ParameterOption> {
213 let mut options = Vec::new();
214
215 if self.is_fluorescence() {
216 // For fluorescence: always return transformed version only
217 let transformed = self.with_transform(TransformType::default());
218 let transformed_label = self.get_short_label();
219 options.push(ParameterOption {
220 id: format!("transformed::{}", self.channel_name),
221 display_label: transformed_label,
222 parameter: transformed,
223 category: ParameterCategory::Fluorescence,
224 });
225
226 // If compensated, add compensated transformed version
227 if include_compensated {
228 let comp_trans = self
229 .with_state(ParameterProcessing::Compensated)
230 .with_transform(TransformType::default());
231 let comp_trans_label = comp_trans.get_display_label();
232 options.push(ParameterOption {
233 id: format!("comp_trans::{}", self.channel_name),
234 display_label: comp_trans_label,
235 parameter: comp_trans,
236 category: ParameterCategory::CompensatedTransformed,
237 });
238
239 // Add unmixed versions (always transformed)
240 let unmix_trans = self
241 .with_state(ParameterProcessing::Unmixed)
242 .with_transform(TransformType::default());
243 let unmix_trans_label = unmix_trans.get_display_label();
244 options.push(ParameterOption {
245 id: format!("unmix_trans::{}", self.channel_name),
246 display_label: unmix_trans_label,
247 parameter: unmix_trans,
248 category: ParameterCategory::Unmixed,
249 });
250
251 // Add combined compensated+unmixed versions (always transformed)
252 let comp_unmix_trans = self
253 .with_state(ParameterProcessing::UnmixedCompensated)
254 .with_transform(TransformType::default());
255 let comp_unmix_trans_label = comp_unmix_trans.get_display_label();
256 options.push(ParameterOption {
257 id: format!("comp_unmix_trans::{}", self.channel_name),
258 display_label: comp_unmix_trans_label,
259 parameter: comp_unmix_trans,
260 category: ParameterCategory::Unmixed,
261 });
262 }
263 } else {
264 // For non-fluorescence (scatter/time): include raw parameter
265 options.push(ParameterOption {
266 id: format!("raw::{}", self.channel_name),
267 display_label: self.get_short_label(),
268 parameter: self.clone(),
269 category: ParameterCategory::Raw,
270 });
271 }
272
273 options
274 }
275}