1use anyhow::Result;
4use regex::Regex;
5use std::sync::OnceLock;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ControlRole {
10 Unstained,
11 SingleStain,
12 Sample,
13 Unassigned,
14}
15
16#[derive(Debug, Clone)]
18pub struct FileInfo {
19 pub guid: String,
20 pub filename: String,
21}
22
23#[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#[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
51pub 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
67pub 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 n.split_whitespace().any(|tok| tok == "reference")
78}
79
80fn fluor_alt() -> &'static str {
83 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 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
106pub 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
125pub 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
153pub 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 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 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 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 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
203pub 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 (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 };
224 (ControlRole::SingleStain, conf)
225 } else if extract_marker_and_fluor(name).is_some() {
226 (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
249pub 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}