Skip to main content

flow_fcs/
transform.rs

1use serde::{Deserialize, Serialize};
2use std::hash::Hash;
3
4/// Transformation type to apply to flow cytometry parameter data
5///
6/// Transformations are used to convert raw instrument values into display-friendly scales.
7/// The most common transformation for fluorescence data is arcsinh (inverse hyperbolic sine),
8/// which provides a log-like scale that handles both positive and negative values.
9#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
10pub enum TransformType {
11    /// Linear transformation (no scaling, identity function)
12    /// Used for scatter parameters (FSC, SSC) and time
13    Linear,
14    /// Arcsinh (inverse hyperbolic sine) transformation with configurable cofactor
15    /// Formula: `arcsinh(x / cofactor)`
16    /// Common cofactors: 150-200 for modern instruments
17    Arcsinh { cofactor: f32 },
18    /// Biexponential (logicle) transformation matching FlowJo's default behavior
19    /// Formula: `asinh(x * sinh(M * ln(10)) / T) + A * ln(10)`
20    /// where T = top of scale, M = positive decades, A = negative decades
21    /// Defaults match FlowJo: T=262144 (18-bit), M=4.5, A=0, W=0.5
22    Biexponential {
23        /// Top of scale value (typically 262144 for 18-bit or 1048576 for 20-bit data)
24        top_of_scale: f32,
25        /// Number of positive decades (typically 4.5)
26        positive_decades: f32,
27        /// Number of additional negative decades (typically 0)
28        negative_decades: f32,
29        /// Width basis parameter (typically 0.5)
30        width: f32,
31    },
32}
33
34impl TransformType {
35    /// Create a TransformType from a string. If no string is provided or the string is not matched, the default `arcsinh` transform is used.
36    pub fn create_from_str(s: Option<&str>) -> Self {
37        match s {
38            Some("linear") => TransformType::Linear,
39            Some("arcsinh") => TransformType::Arcsinh { cofactor: 200.0 },
40            Some("biexponential") | Some("logicle") => TransformType::Biexponential {
41                top_of_scale: 262144.0,
42                positive_decades: 4.5,
43                negative_decades: 0.0,
44                width: 0.5,
45            },
46            _ => TransformType::default(),
47        }
48    }
49}
50
51/// Trait for types that can transform values from raw to display scale
52///
53/// Transformations are typically applied when displaying data, not when storing it.
54/// This allows the raw data to remain unchanged while providing flexible visualization options.
55pub trait Transformable {
56    fn transform(&self, value: &f32) -> f32;
57    fn inverse_transform(&self, value: &f32) -> f32;
58}
59/// Trait for types that can format transformed values for display
60///
61/// Formatting converts numeric values into human-readable strings,
62/// typically using scientific notation for large numbers.
63#[allow(unused)]
64pub trait Formattable {
65    fn format(&self, value: &f32) -> String;
66}
67
68impl Transformable for TransformType {
69    fn transform(&self, value: &f32) -> f32 {
70        match self {
71            TransformType::Linear => *value,
72            TransformType::Arcsinh { cofactor } => (value / cofactor).asinh(),
73            TransformType::Biexponential {
74                top_of_scale,
75                positive_decades,
76                negative_decades,
77                width: _,
78            } => {
79                // Logicle/biexponential transformation formula
80                // f(x) = asinh(x * sinh(M * ln(10)) / T) + A * ln(10)
81                // where T = top_of_scale, M = positive_decades, A = negative_decades
82                let ln_10 = 10.0_f32.ln();
83                let m_ln10 = positive_decades * ln_10;
84                let sinh_m_ln10 = m_ln10.sinh();
85                let a_ln10 = negative_decades * ln_10;
86
87                // Handle division by zero and very small values
88                if *top_of_scale == 0.0 {
89                    return *value;
90                }
91
92                let scaled_x = value * sinh_m_ln10 / top_of_scale;
93                scaled_x.asinh() + a_ln10
94            }
95        }
96    }
97    fn inverse_transform(&self, value: &f32) -> f32 {
98        match self {
99            TransformType::Linear => *value,
100            TransformType::Arcsinh { cofactor } => {
101                eprintln!(
102                    "🔧 [INVERSE_TRANSFORM] Arcsinh inverse: value={}, cofactor={}",
103                    value, cofactor
104                );
105                let final_result = (*value).sinh() * *cofactor;
106                eprintln!(
107                    "🔧 [INVERSE_TRANSFORM] final result: {} * {} = {}",
108                    value.sinh(),
109                    cofactor,
110                    final_result
111                );
112                final_result
113            }
114            TransformType::Biexponential {
115                top_of_scale,
116                positive_decades,
117                negative_decades,
118                width: _,
119            } => {
120                // Inverse logicle/biexponential transformation
121                // x = T * sinh((y - A * ln(10))) / sinh(M * ln(10))
122                let ln_10 = 10.0_f32.ln();
123                let m_ln10 = positive_decades * ln_10;
124                let sinh_m_ln10 = m_ln10.sinh();
125                let a_ln10 = negative_decades * ln_10;
126
127                let y_minus_a = value - a_ln10;
128                let sinh_y_minus_a = y_minus_a.sinh();
129
130                top_of_scale * sinh_y_minus_a / sinh_m_ln10
131            }
132        }
133    }
134}
135impl Formattable for TransformType {
136    fn format(&self, value: &f32) -> String {
137        match self {
138            TransformType::Linear => format!("{:.1e}", value),
139            TransformType::Arcsinh { cofactor: _ } => {
140                // Convert from transformed space back to original space
141                let original_value = self.inverse_transform(value);
142
143                // Make nice rounded labels in original space
144                format!("{:.1e}", original_value)
145            }
146            TransformType::Biexponential { .. } => {
147                // Convert from transformed space back to original space
148                let original_value = self.inverse_transform(value);
149
150                // Make nice rounded labels in original space
151                format!("{:.1e}", original_value)
152            }
153        }
154    }
155}
156impl Default for TransformType {
157    fn default() -> Self {
158        TransformType::Arcsinh { cofactor: 200.0 }
159    }
160}
161impl Hash for TransformType {
162    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
163        match self {
164            TransformType::Linear => "linear".hash(state),
165            TransformType::Arcsinh { cofactor } => {
166                "arcsinh".hash(state);
167                cofactor.to_bits().hash(state);
168            }
169            TransformType::Biexponential {
170                top_of_scale,
171                positive_decades,
172                negative_decades,
173                width,
174            } => {
175                "biexponential".hash(state);
176                top_of_scale.to_bits().hash(state);
177                positive_decades.to_bits().hash(state);
178                negative_decades.to_bits().hash(state);
179                width.to_bits().hash(state);
180            }
181        }
182    }
183}
184
185#[test]
186fn test_transform() {
187    let t = TransformType::Linear;
188    assert_eq!(t.transform(&1.0), 1.0);
189    assert_eq!(t.inverse_transform(&1.0), 1.0);
190
191    let t = TransformType::Arcsinh { cofactor: 200.0 };
192    // Use approximate equality for floating point comparisons
193    let transformed = t.transform(&1.0);
194    assert!(
195        (transformed - 0.005).abs() < 1e-6,
196        "Expected ~0.005, got {}",
197        transformed
198    );
199    let inverse = t.inverse_transform(&0.005);
200    // Use a slightly larger tolerance for inverse transform due to floating point precision
201    assert!(
202        (inverse - 1.0).abs() < 1e-5,
203        "Expected ~1.0, got {}",
204        inverse
205    );
206    // Assert that the transform results in a number
207    assert!(!t.transform(&-1.0).is_nan());
208    assert!(!t.transform(&0.0).is_nan());
209    assert!(!t.transform(&-200.0).is_nan());
210}
211
212#[test]
213fn test_transform_type_partial_eq_and_hash_consistency() {
214    use std::hash::{Hash, Hasher};
215    let a = TransformType::Arcsinh { cofactor: 200.0 };
216    let b = TransformType::Arcsinh { cofactor: 200.0 };
217    let c = TransformType::Arcsinh { cofactor: 150.0 };
218    assert_eq!(a, b);
219    assert_ne!(a, c);
220    // Equal values must have the same hash (Hash + PartialEq consistency)
221    let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
222    let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
223    a.hash(&mut hasher_a);
224    b.hash(&mut hasher_b);
225    assert_eq!(hasher_a.finish(), hasher_b.finish());
226    c.hash(&mut hasher_a);
227    b.hash(&mut hasher_b);
228    assert_ne!(hasher_a.finish(), hasher_b.finish());
229}