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)]
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 sinh_result = value.sinh();
106                eprintln!("🔧 [INVERSE_TRANSFORM] sinh({}) = {}", value, sinh_result);
107                let final_result = sinh_result * cofactor;
108                eprintln!(
109                    "🔧 [INVERSE_TRANSFORM] final result: {} * {} = {}",
110                    sinh_result, cofactor, 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: _ } => "arcsinh".hash(state),
166            TransformType::Biexponential { .. } => "biexponential".hash(state),
167        }
168    }
169}
170
171#[test]
172fn test_transform() {
173    let t = TransformType::Linear;
174    assert_eq!(t.transform(&1.0), 1.0);
175    assert_eq!(t.inverse_transform(&1.0), 1.0);
176
177    let t = TransformType::Arcsinh { cofactor: 200.0 };
178    // Use approximate equality for floating point comparisons
179    let transformed = t.transform(&1.0);
180    assert!(
181        (transformed - 0.005).abs() < 1e-6,
182        "Expected ~0.005, got {}",
183        transformed
184    );
185    let inverse = t.inverse_transform(&0.005);
186    // Use a slightly larger tolerance for inverse transform due to floating point precision
187    assert!(
188        (inverse - 1.0).abs() < 1e-5,
189        "Expected ~1.0, got {}",
190        inverse
191    );
192    // Assert that the transform results in a number
193    assert!(!t.transform(&-1.0).is_nan());
194    assert!(!t.transform(&0.0).is_nan());
195    assert!(!t.transform(&-200.0).is_nan());
196}