Skip to main content

flow_control_detection/
lib.rs

1//! Filename heuristics for classifying flow cytometry control files.
2
3use anyhow::Result;
4use regex::Regex;
5use std::sync::OnceLock;
6
7/// Suggested role for a loaded FCS file in an unmix / compensation workflow.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ControlRole {
10    Unstained,
11    SingleStain,
12    Sample,
13    Unassigned,
14}
15
16/// Lightweight file descriptor for classification (no FCS dependency).
17#[derive(Debug, Clone)]
18pub struct FileInfo {
19    pub guid: String,
20    pub filename: String,
21}
22
23/// Classification result for one file.
24#[derive(Debug, Clone)]
25pub struct ControlClassification {
26    pub guid: String,
27    pub suggested_role: ControlRole,
28    pub confidence: f32,
29    pub display_label: String,
30}
31
32/// Endmember ↔ control pairing suggestion.
33#[derive(Debug, Clone)]
34pub struct EndmemberMatch {
35    pub endmember_name: String,
36    pub control_guid: String,
37    pub detector_name: Option<String>,
38    pub confidence: f32,
39}
40
41fn unstained_re() -> &'static Regex {
42    static RE: OnceLock<Regex> = OnceLock::new();
43    RE.get_or_init(|| Regex::new(r"(?i)unstained|un[\s_-]?stain|blank|af[\s_-]?only").unwrap())
44}
45
46fn full_stain_re() -> &'static Regex {
47    static RE: OnceLock<Regex> = OnceLock::new();
48    RE.get_or_init(|| Regex::new(r"(?i)\bfull[\s_-]?stain").unwrap())
49}
50
51/// Lowercase alphanumerics only; other chars become spaces (so `Single-Stain` → `single stain`).
52pub fn normalize_control_filename(name: &str) -> String {
53    let mut out = String::with_capacity(name.len());
54    let mut prev_space = true;
55    for c in name.chars() {
56        if c.is_ascii_alphanumeric() {
57            out.push(c.to_ascii_lowercase());
58            prev_space = false;
59        } else if !prev_space {
60            out.push(' ');
61            prev_space = true;
62        }
63    }
64    out.trim().to_string()
65}
66
67/// Strong name signals for single-stain / reference controls (special chars ignored).
68pub fn is_named_single_stain_control(filename: &str) -> bool {
69    let n = normalize_control_filename(filename);
70    if full_stain_re().is_match(filename) {
71        return false;
72    }
73    if n.contains("reference control") || n.contains("single stain") {
74        return true;
75    }
76    // Whole-token "reference" (e.g. "Reference Group_A3 …")
77    n.split_whitespace().any(|tok| tok == "reference")
78}
79
80/// Fluorophore token patterns used for classification and extraction.
81/// Keep in sync with [`extract_marker_and_fluor`].
82fn fluor_alt() -> &'static str {
83    // Longest-first where needed (Brilliant Violet before BV; Near IR before IR).
84    r"(?:Brilliant\s*Violet|Super\s*Bright|eFluor|Near\s*IR|LIVE[\s/_-]?DEAD|BUV|BV|BB|RB|RY|RR|RV|Spark|Viability|PerCP(?:[\s/_-]?Cy\d+)?|PE(?:[\s/_-]?Cy\d+)?|APC(?:[\s/_-]?Cy\d+)?|FITC|AF\d+|LD)"
85}
86
87fn fluor_token_re() -> &'static Regex {
88    static RE: OnceLock<Regex> = OnceLock::new();
89    RE.get_or_init(|| {
90        Regex::new(&format!(r"(?i)\b{}", fluor_alt())).expect("fluor_token_re")
91    })
92}
93
94fn marker_fluor_re() -> &'static Regex {
95    static RE: OnceLock<Regex> = OnceLock::new();
96    RE.get_or_init(|| {
97        // Marker token then separator then known fluor (no empty alternatives).
98        Regex::new(&format!(
99            r"(?i)\b([A-Za-z][A-Za-z0-9]*(?:[-/][A-Za-z0-9]+)?)\s*[_\- ]\s*({}[\w\.]*)",
100            fluor_alt()
101        ))
102        .expect("marker_fluor_re")
103    })
104}
105
106/// Clean filename → human-readable endmember label.
107pub fn endmember_display_label(filename: &str) -> String {
108    let stem = PathStem::from(filename);
109    let mut s = stem.0;
110    for junk in [
111        ".fcs",
112        ".FCS",
113        "_compensated",
114        "-compensated",
115        "_unmixed",
116        "-unmixed",
117    ] {
118        s = s.replace(junk, "");
119    }
120    s = s.replace('_', " ").replace('-', " ");
121    let parts: Vec<_> = s.split_whitespace().filter(|p| !p.is_empty()).collect();
122    parts.join(" ")
123}
124
125/// Infer cells vs beads from filename tokens (SpectroFlo-style control type).
126pub fn infer_control_material(filename: &str) -> ControlMaterial {
127    let n = normalize_control_filename(filename);
128    if n.split_whitespace().any(|t| t == "beads" || t == "bead") {
129        ControlMaterial::Beads
130    } else if n.split_whitespace().any(|t| t == "cells" || t == "cell") {
131        ControlMaterial::Cells
132    } else {
133        ControlMaterial::Unknown
134    }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ControlMaterial {
139    Unknown,
140    Cells,
141    Beads,
142}
143
144struct PathStem(String);
145impl PathStem {
146    fn from(filename: &str) -> Self {
147        let name = filename.rsplit(['/', '\\']).next().unwrap_or(filename);
148        let stem = name.rsplit_once('.').map(|(a, _)| a).unwrap_or(name);
149        Self(stem.to_string())
150    }
151}
152
153/// Parse marker/fluorophore tokens from filename or $PnS-like text.
154///
155/// Returns `(marker, fluor)` e.g. `("CD14", "FITC")`, `("HLA-DR", "Spark")`.
156pub fn extract_marker_and_fluor(text: &str) -> Option<(String, String)> {
157    let caps = marker_fluor_re().captures(text)?;
158    let marker = caps.get(1)?.as_str().to_string();
159    let fluor_raw = caps.get(2)?.as_str();
160    // Strip trailing material / plate tokens stuck to fluor ("FITC_Cells").
161    let fluor = fluor_raw
162        .split(['_', ' '])
163        .next()
164        .unwrap_or(fluor_raw)
165        .trim_matches(|c: char| !c.is_ascii_alphanumeric())
166        .to_string();
167    if fluor.is_empty() {
168        return None;
169    }
170    // Reject false markers that are plate / group noise.
171    let marker_l = marker.to_ascii_lowercase();
172    if matches!(
173        marker_l.as_str(),
174        "group" | "reference" | "donor" | "plate" | "well" | "tube" | "sample"
175    ) {
176        // Prefer fluor-only: still return if we found a real fluor with a weak marker —
177        // try a second pass that finds marker immediately before fluor.
178        return find_marker_before_fluor(text, &fluor);
179    }
180    Some((marker, fluor))
181}
182
183fn find_marker_before_fluor(text: &str, fluor: &str) -> Option<(String, String)> {
184    let fluor_re = Regex::new(&format!(r"(?i)\b({})\b", regex::escape(fluor))).ok()?;
185    let m = fluor_re.find(text)?;
186    let before = &text[..m.start()];
187    // Last alphanumeric token before fluor (allow HLA-DR style).
188    let token_re = Regex::new(r"(?i)([A-Za-z][A-Za-z0-9]*(?:[-/][A-Za-z0-9]+)?)\s*[_\- ]*\s*$").ok()?;
189    let caps = token_re.captures(before)?;
190    let marker = caps[1].to_string();
191    let marker_l = marker.to_ascii_lowercase();
192    if matches!(
193        marker_l.as_str(),
194        "group" | "reference" | "donor" | "plate" | "well" | "tube" | "sample" | "a1"
195            | "a2" | "a3" | "b1" | "b2" | "c1" | "c2" | "d1" | "d9" | "e1" | "f1"
196    ) || marker_l.chars().all(|c| c.is_ascii_digit())
197    {
198        return None;
199    }
200    Some((marker, fluor.to_string()))
201}
202
203/// Classify files as unstained / single-stain / sample / unassigned from filenames.
204pub fn classify_controls(files: &[FileInfo]) -> Vec<ControlClassification> {
205    files
206        .iter()
207        .map(|f| {
208            let name = &f.filename;
209            let (role, confidence) = if unstained_re().is_match(name) {
210                (ControlRole::Unstained, 0.95)
211            } else if full_stain_re().is_match(name)
212                || name.to_ascii_lowercase().contains("sample")
213                || name.to_ascii_lowercase().contains("specimen")
214            {
215                // Fully stained panels / samples are never auto single-stains.
216                (ControlRole::Sample, 0.75)
217            } else if is_named_single_stain_control(name) {
218                let n = normalize_control_filename(name);
219                let conf = if n.contains("reference control") || n.contains("single stain") {
220                    0.92
221                } else {
222                    0.88 // token "reference"
223                };
224                (ControlRole::SingleStain, conf)
225            } else if extract_marker_and_fluor(name).is_some() {
226                // Real marker+fluor in filename (CD14_FITC, …) without "Reference".
227                (ControlRole::SingleStain, 0.72)
228            } else if fluor_token_re().is_match(name)
229                && !normalize_control_filename(name).contains("donor")
230            {
231                (ControlRole::SingleStain, 0.55)
232            } else if normalize_control_filename(name).contains("bead")
233                && is_named_single_stain_control(name)
234            {
235                (ControlRole::SingleStain, 0.7)
236            } else {
237                (ControlRole::Unassigned, 0.2)
238            };
239            ControlClassification {
240                guid: f.guid.clone(),
241                suggested_role: role,
242                confidence,
243                display_label: endmember_display_label(name),
244            }
245        })
246        .collect()
247}
248
249/// Fuzzy-match endmember / detector names to single-stain control files.
250pub fn match_endmembers(
251    controls: &[ControlClassification],
252    detector_names: &[String],
253) -> Result<Vec<EndmemberMatch>> {
254    let singles: Vec<_> = controls
255        .iter()
256        .filter(|c| c.suggested_role == ControlRole::SingleStain)
257        .collect();
258    let mut out = Vec::new();
259    for det in detector_names {
260        let det_l = det.to_ascii_lowercase();
261        let mut best: Option<(&ControlClassification, f32)> = None;
262        for c in &singles {
263            let label_l = c.display_label.to_ascii_lowercase();
264            let file_l = c.guid.to_ascii_lowercase();
265            let score = if label_l.contains(&det_l) || det_l.contains(&label_l) {
266                0.85
267            } else if det_l
268                .split(|ch: char| !ch.is_ascii_alphanumeric())
269                .filter(|s| s.len() >= 3)
270                .any(|tok| label_l.contains(tok))
271            {
272                0.65
273            } else if file_l.contains(&det_l) {
274                0.4
275            } else {
276                continue;
277            };
278            if best.is_none_or(|(_, s)| score > s) {
279                best = Some((c, score));
280            }
281        }
282        if let Some((c, confidence)) = best {
283            out.push(EndmemberMatch {
284                endmember_name: c.display_label.clone(),
285                control_guid: c.guid.clone(),
286                detector_name: Some(det.clone()),
287                confidence,
288            });
289        }
290    }
291    Ok(out)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn classifies_unstained_and_marker_fluor() {
300        let files = [
301            FileInfo {
302                guid: "1".into(),
303                filename: "Unstained_Cells.fcs".into(),
304            },
305            FileInfo {
306                guid: "2".into(),
307                filename: "CD4_BV421_Cells.fcs".into(),
308            },
309        ];
310        let c = classify_controls(&files);
311        assert_eq!(c[0].suggested_role, ControlRole::Unstained);
312        assert_eq!(c[1].suggested_role, ControlRole::SingleStain);
313    }
314
315    #[test]
316    fn reference_and_single_stain_phrases_normalize() {
317        assert!(is_named_single_stain_control(
318            "Reference Group_A3 CD4 BUV496 (Beads)_Plate.fcs"
319        ));
320        assert!(is_named_single_stain_control("Reference-Control_Tube.fcs"));
321        assert!(is_named_single_stain_control("Single_Stain_CD8.fcs"));
322        assert!(is_named_single_stain_control("Single-Stain CD4.fcs"));
323        assert!(!is_named_single_stain_control(
324            "Donor7_Full_Stain_panel.fcs"
325        ));
326    }
327
328    #[test]
329    fn full_stain_donors_are_samples_not_controls() {
330        let files = [
331            FileInfo {
332                guid: "d".into(),
333                filename: "Donor 9_F1 Full Stain_Plate.fcs".into(),
334            },
335            FileInfo {
336                guid: "r".into(),
337                filename: "Reference Group_A2 HLA-DR DQ Spark UV 387 (Beads).fcs".into(),
338            },
339        ];
340        let c = classify_controls(&files);
341        assert_eq!(c[0].suggested_role, ControlRole::Sample);
342        assert_eq!(c[1].suggested_role, ControlRole::SingleStain);
343        assert!(c[1].confidence >= 0.85);
344    }
345
346    #[test]
347    fn extract_known_fluors_not_group_or_donor() {
348        let (m, f) = extract_marker_and_fluor("CD14_FITC_Cells.fcs").expect("FITC");
349        assert_eq!(m, "CD14");
350        assert_eq!(f, "FITC");
351
352        let (m, f) = extract_marker_and_fluor("CD8_RB545_Beads.fcs").expect("RB");
353        assert_eq!(m, "CD8");
354        assert!(f.starts_with("RB"));
355
356        let (m, f) =
357            extract_marker_and_fluor("Reference Group_A2 HLA-DR DQ Spark UV 387 (Beads).fcs")
358                .expect("Spark");
359        assert!(m.contains("HLA") || m == "DQ" || m.contains("DR"));
360        assert!(f.to_ascii_lowercase().starts_with("spark"));
361
362        assert!(
363            extract_marker_and_fluor("Donor 9_F1 Full Stain.fcs").is_none()
364                || extract_marker_and_fluor("Donor 9_F1 Full Stain.fcs")
365                    .map(|(m, _)| m != "9" && m.to_ascii_lowercase() != "donor")
366                    .unwrap_or(true)
367        );
368    }
369
370    #[test]
371    fn classify_includes_all_reference_controls() {
372        let files = [
373            FileInfo {
374                guid: "a".into(),
375                filename: "Reference Group_A1.fcs".into(),
376            },
377            FileInfo {
378                guid: "b".into(),
379                filename: "Reference Control_unstained.fcs".into(),
380            },
381        ];
382        let c = classify_controls(&files);
383        assert_eq!(c[0].suggested_role, ControlRole::SingleStain);
384        assert!(c[0].confidence >= 0.85);
385        assert_eq!(c[1].suggested_role, ControlRole::Unstained);
386    }
387}