use crate::Analyzer;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceFamily {
LcqIonTrap,
LtqIonTrap,
LtqFt,
LtqOrbitrap,
QOrbitrap,
Tribrid,
ExplorisOrbitrap,
OrbitrapAstral,
TripleQuad,
Unknown,
}
impl DeviceFamily {
pub fn display_name(self) -> &'static str {
match self {
Self::LcqIonTrap => "LCQ (3D ion trap)",
Self::LtqIonTrap => "LTQ (linear ion trap)",
Self::LtqFt => "LTQ FT (ion trap + FTICR)",
Self::LtqOrbitrap => "LTQ Orbitrap hybrid",
Self::QOrbitrap => "Q-Orbitrap (Q Exactive)",
Self::Tribrid => "Tribrid Orbitrap",
Self::ExplorisOrbitrap => "Orbitrap Exploris",
Self::OrbitrapAstral => "Orbitrap Astral",
Self::TripleQuad => "Triple quadrupole",
Self::Unknown => "unknown",
}
}
pub fn uses_flat_peaks(self) -> bool {
matches!(self, Self::TripleQuad)
}
}
const MODEL_REGISTRY: &[(&str, DeviceFamily)] = &[
("Orbitrap Astral", DeviceFamily::OrbitrapAstral),
("Orbitrap Ascend", DeviceFamily::Tribrid),
("Orbitrap Fusion Lumos", DeviceFamily::Tribrid),
("Orbitrap Eclipse", DeviceFamily::Tribrid),
("Orbitrap Fusion", DeviceFamily::Tribrid),
("Orbitrap Exploris 480", DeviceFamily::ExplorisOrbitrap),
("Orbitrap Exploris 240", DeviceFamily::ExplorisOrbitrap),
("Orbitrap Exploris 120", DeviceFamily::ExplorisOrbitrap),
("Orbitrap Exploris MX", DeviceFamily::ExplorisOrbitrap),
("Orbitrap Exploris GC 240", DeviceFamily::ExplorisOrbitrap),
("Orbitrap Exploris", DeviceFamily::ExplorisOrbitrap),
("Q Exactive HF-X", DeviceFamily::QOrbitrap),
("Q Exactive UHMR", DeviceFamily::QOrbitrap),
("Q Exactive Plus", DeviceFamily::QOrbitrap),
("Q Exactive HF", DeviceFamily::QOrbitrap),
("Q Exactive GC", DeviceFamily::QOrbitrap),
("Q Exactive Focus", DeviceFamily::QOrbitrap),
("Q Exactive", DeviceFamily::QOrbitrap),
("LTQ Orbitrap Velos Pro", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap Velos ETD", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap Velos", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap Elite", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap Discovery", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap XL ETD", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap XL", DeviceFamily::LtqOrbitrap),
("LTQ Orbitrap", DeviceFamily::LtqOrbitrap),
("Orbitrap Elite", DeviceFamily::LtqOrbitrap),
("Orbitrap Velos Pro", DeviceFamily::LtqOrbitrap),
("Orbitrap Velos", DeviceFamily::LtqOrbitrap),
("Orbitrap Discovery", DeviceFamily::LtqOrbitrap),
("Orbitrap XL", DeviceFamily::LtqOrbitrap),
("LTQ FT Ultra", DeviceFamily::LtqFt),
("LTQ FT", DeviceFamily::LtqFt),
("LTQ Velos Pro", DeviceFamily::LtqIonTrap),
("LTQ Velos ETD", DeviceFamily::LtqIonTrap),
("LTQ Velos", DeviceFamily::LtqIonTrap),
("LTQ XL ETD", DeviceFamily::LtqIonTrap),
("LTQ XL", DeviceFamily::LtqIonTrap),
("LTQ", DeviceFamily::LtqIonTrap),
("LCQ Fleet", DeviceFamily::LcqIonTrap),
("LCQ Advantage", DeviceFamily::LcqIonTrap),
("LCQ Deca XP Plus", DeviceFamily::LcqIonTrap),
("LCQ Deca XP", DeviceFamily::LcqIonTrap),
("LCQ Deca", DeviceFamily::LcqIonTrap),
("LCQ Classic", DeviceFamily::LcqIonTrap),
("LCQ DUO", DeviceFamily::LcqIonTrap),
("LCQ", DeviceFamily::LcqIonTrap),
("TSQ Quantiva", DeviceFamily::TripleQuad),
("TSQ Quantum Ultra AM", DeviceFamily::TripleQuad),
("TSQ Quantum Ultra", DeviceFamily::TripleQuad),
("TSQ Quantum Access", DeviceFamily::TripleQuad),
("TSQ Quantum Discovery", DeviceFamily::TripleQuad),
("TSQ Quantum", DeviceFamily::TripleQuad),
("TSQ Vantage", DeviceFamily::TripleQuad),
("TSQ Endura", DeviceFamily::TripleQuad),
("TSQ Altis Plus", DeviceFamily::TripleQuad),
("TSQ Altis", DeviceFamily::TripleQuad),
("TSQ 8000 Evo", DeviceFamily::TripleQuad),
("TSQ 9000", DeviceFamily::TripleQuad),
("TSQ", DeviceFamily::TripleQuad),
];
fn utf16le(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 2);
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_le_bytes());
}
out
}
fn contains_word(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
let is_word_byte = |b: u8| b.is_ascii_alphanumeric();
let is_word_char_at = |pos: usize| -> bool {
if pos + 1 >= haystack.len() {
return false;
}
haystack[pos + 1] == 0 && is_word_byte(haystack[pos])
};
for start in 0..=haystack.len() - needle.len() {
if &haystack[start..start + needle.len()] != needle {
continue;
}
let left_ok = start < 2 || !is_word_char_at(start - 2);
let after = start + needle.len();
let right_ok = after >= haystack.len() || !is_word_char_at(after);
if left_ok && right_ok {
const HEADER_SUFFIX: &[u8] = b" \0H\0e\0a\0d\0e\0r\0";
if haystack.len() >= after + HEADER_SUFFIX.len()
&& &haystack[after..after + HEADER_SUFFIX.len()] == HEADER_SUFFIX
{
continue;
}
return true;
}
}
false
}
#[derive(Debug, Clone)]
pub struct DetectedInstrument {
pub model: Option<&'static str>,
pub family: DeviceFamily,
}
impl DetectedInstrument {
pub const fn unknown() -> Self {
Self {
model: None,
family: DeviceFamily::Unknown,
}
}
}
impl DeviceFamily {
pub fn detect_instrument(
metadata_bytes: &[u8],
tag2: &str,
inst_method: &str,
first_analyzer: Option<Analyzer>,
) -> DetectedInstrument {
for (name, family) in MODEL_REGISTRY {
let needle = utf16le(name);
if contains_word(metadata_bytes, &needle) {
return DetectedInstrument {
model: Some(name),
family: *family,
};
}
}
let family = Self::detect_heuristic(tag2, inst_method, first_analyzer);
DetectedInstrument {
model: None,
family,
}
}
pub fn detect_heuristic(
tag2: &str,
inst_method: &str,
first_analyzer: Option<Analyzer>,
) -> Self {
let hay = format!("{} {}", tag2, inst_method).to_lowercase();
let patterns: &[(&str, DeviceFamily)] = &[
("astral", Self::OrbitrapAstral),
("exploris", Self::ExplorisOrbitrap),
("ascend", Self::Tribrid),
("eclipse", Self::Tribrid),
("lumos", Self::Tribrid),
("fusion", Self::Tribrid),
("tribrid", Self::Tribrid),
("q exactive", Self::QOrbitrap),
("qexactive", Self::QOrbitrap),
("q-exactive", Self::QOrbitrap),
("exactive", Self::QOrbitrap),
("altis", Self::TripleQuad),
("quantiva", Self::TripleQuad),
("endura", Self::TripleQuad),
("vantage", Self::TripleQuad),
("tsq", Self::TripleQuad),
("ltq orbitrap", Self::LtqOrbitrap),
("ltq-orbitrap", Self::LtqOrbitrap),
("ltq_orbitrap", Self::LtqOrbitrap),
("orbitrap elite", Self::LtqOrbitrap),
("orbitrap velos", Self::LtqOrbitrap),
("orbitrap xl", Self::LtqOrbitrap),
("orbitrap discovery", Self::LtqOrbitrap),
("ltq ft", Self::LtqFt),
("ltq-ft", Self::LtqFt),
("ltqft", Self::LtqFt),
("ltq velos", Self::LtqIonTrap),
("ltq-velos", Self::LtqIonTrap),
("ltqvelos", Self::LtqIonTrap),
("ltq xl", Self::LtqIonTrap),
("ltqxl", Self::LtqIonTrap),
("ltq", Self::LtqIonTrap),
("lcq", Self::LcqIonTrap),
("orbitrap", Self::LtqOrbitrap),
];
for (needle, family) in patterns {
if hay.contains(needle) {
return *family;
}
}
match first_analyzer {
Some(Analyzer::FTMS) => Self::LtqOrbitrap,
Some(Analyzer::ITMS) => Self::LtqIonTrap,
Some(Analyzer::TQMS) => Self::TripleQuad,
Some(Analyzer::SQMS) => Self::TripleQuad,
_ => Self::Unknown,
}
}
#[deprecated(
note = "Use `DeviceFamily::detect_instrument` with the metadata byte window for reliable detection"
)]
pub fn detect(tag2: &str, inst_method: &str, first_analyzer: Option<Analyzer>) -> Self {
Self::detect_heuristic(tag2, inst_method, first_analyzer)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode(s: &str) -> Vec<u8> {
utf16le(s)
}
#[test]
fn prefers_longer_model_names() {
let mut hay = Vec::new();
hay.extend_from_slice(&encode("Orbitrap Fusion Lumos"));
hay.extend_from_slice(b"\x00\x00");
hay.extend_from_slice(&encode("Orbitrap Fusion"));
let det = DeviceFamily::detect_instrument(&hay, "", "", None);
assert_eq!(det.model, Some("Orbitrap Fusion Lumos"));
assert_eq!(det.family, DeviceFamily::Tribrid);
}
#[test]
fn astral_wins_over_orbitrap() {
let hay = encode("Orbitrap Astral");
let det = DeviceFamily::detect_instrument(&hay, "", "", None);
assert_eq!(det.model, Some("Orbitrap Astral"));
assert_eq!(det.family, DeviceFamily::OrbitrapAstral);
}
#[test]
fn falls_back_to_heuristic() {
let det = DeviceFamily::detect_instrument(b"", "Q Exactive HF", "", None);
assert_eq!(det.model, None);
assert_eq!(det.family, DeviceFamily::QOrbitrap);
}
#[test]
fn falls_back_to_analyzer() {
let det = DeviceFamily::detect_instrument(b"", "", "", Some(Analyzer::TQMS));
assert_eq!(det.family, DeviceFamily::TripleQuad);
}
#[test]
fn unknown_when_no_signal() {
let det = DeviceFamily::detect_instrument(b"", "", "", None);
assert_eq!(det.family, DeviceFamily::Unknown);
}
#[test]
fn rejects_lcq_header_section_marker() {
let hay = encode("LCQ Header");
let det = DeviceFamily::detect_instrument(&hay, "", "", None);
assert_eq!(det.model, None);
}
#[test]
fn rejects_lcq_inside_tmp_path() {
let hay = encode("C:\\ProgramData\\Thermo\\Temp\\LCQ0rpvfvu1.tmp");
let det = DeviceFamily::detect_instrument(&hay, "", "", None);
assert_eq!(det.model, None);
}
#[test]
fn accepts_lcq_classic_on_header_follow() {
let mut hay = Vec::new();
hay.extend_from_slice(&encode("LCQ Header\0"));
hay.extend_from_slice(&encode(" some text "));
hay.extend_from_slice(&encode("LCQ Classic"));
hay.extend_from_slice(b"\x00\x00");
let det = DeviceFamily::detect_instrument(&hay, "", "", None);
assert_eq!(det.model, Some("LCQ Classic"));
}
}