use std::collections::BTreeMap;
use std::path::Path;
const M_PROTON_KG: f64 = 1.672_621_9e-27;
const E_COULOMBS: f64 = 1.602_176_6e-19;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Polarity {
Positive,
Negative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FunctionMode {
Ms,
MseParent,
Msms,
Daughter,
Reference,
Unknown,
}
impl FunctionMode {
pub fn ms_level(self) -> u32 {
match self {
Self::Ms | Self::Reference | Self::Unknown => 1,
Self::MseParent | Self::Msms | Self::Daughter => 2,
}
}
fn from_section_tail(tail: &str) -> Self {
let t = tail.to_ascii_uppercase();
if t.contains("REFERENCE") {
Self::Reference
} else if t.contains("DAUGHTER") {
Self::Daughter
} else if t.contains("MSMS") {
Self::Msms
} else if t.contains("PARENT") {
Self::MseParent
} else if t.contains("TOF MS") {
Self::Ms
} else {
Self::Unknown
}
}
}
#[derive(Debug, Clone)]
pub struct ExternFunction {
pub index: u32,
pub start_mass_da: f64,
pub end_mass_da: f64,
pub pusher_interval_us: Option<f64>,
pub mode: FunctionMode,
}
#[derive(Debug, Clone)]
pub struct ExternInf {
pub lteff_mm: f64,
pub veff_v: f64,
pub pusher_interval_us: f64,
pub polarity: Option<Polarity>,
pub functions: BTreeMap<u32, ExternFunction>,
}
impl ExternInf {
pub fn from_path(path: &Path) -> crate::Result<Self> {
let bytes = std::fs::read(path)?;
let text = String::from_utf8_lossy(&bytes);
text.parse()
}
pub fn a_us(&self) -> f64 {
let lteff_m = self.lteff_mm * 1e-3;
lteff_m * (M_PROTON_KG / (2.0 * E_COULOMBS * self.veff_v)).sqrt() * 1e6
}
pub fn pusher_interval_for(&self, func: u32) -> f64 {
self.functions
.get(&func)
.and_then(|f| f.pusher_interval_us)
.unwrap_or(self.pusher_interval_us)
}
}
impl std::str::FromStr for ExternInf {
type Err = crate::Error;
fn from_str(s: &str) -> crate::Result<Self> {
let mut lteff_mm: Option<f64> = None;
let mut veff_v: Option<f64> = None;
let mut pusher_from_interval: Option<f64> = None;
let mut pusher_from_cycle: Option<f64> = None;
let mut current_func: Option<u32> = None;
let mut functions: BTreeMap<u32, ExternFunction> = BTreeMap::new();
let mut polarity: Option<Polarity> = None;
for line in s.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("Function Parameters - Function ") {
let mut parts = rest.splitn(2, '-');
let n_str = parts.next().unwrap_or("").trim();
let tail = parts.next().unwrap_or("").trim();
if let Ok(n) = n_str.parse::<u32>() {
current_func = Some(n);
let mode = FunctionMode::from_section_tail(tail);
functions.entry(n).or_insert(ExternFunction {
index: n,
start_mass_da: 0.0,
end_mass_da: 0.0,
pusher_interval_us: None,
mode,
});
}
continue;
}
if trimmed.ends_with(':') {
if trimmed.starts_with("Instrument") || !trimmed.contains("Function") {
current_func = None;
}
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.len() < 2 {
continue;
}
let value_str = tokens[tokens.len() - 1];
let key: String = tokens[..tokens.len() - 1].join(" ");
let key_base = key.split('(').next().unwrap_or(key.as_str()).trim_end();
match key_base {
"Lteff" => {
if let Ok(v) = value_str.parse::<f64>() {
lteff_mm.get_or_insert(v);
}
}
"Veff" => {
if let Ok(v) = value_str.parse::<f64>() {
veff_v.get_or_insert(v);
}
}
"PusherInterval" => {
if let Ok(v) = value_str.parse::<f64>() {
pusher_from_interval.get_or_insert(v);
}
}
"Pusher Cycle Time" => {
if let Ok(v) = value_str.parse::<f64>() {
if value_str != "Automatic" {
pusher_from_cycle.get_or_insert(v);
}
}
}
"Start Mass" => {
if let (Some(n), Ok(v)) = (current_func, value_str.parse::<f64>()) {
if let Some(f) = functions.get_mut(&n) {
f.start_mass_da = v;
}
}
}
"End Mass" => {
if let (Some(n), Ok(v)) = (current_func, value_str.parse::<f64>()) {
if let Some(f) = functions.get_mut(&n) {
f.end_mass_da = v;
}
}
}
"ADC Pusher Frequency" => {
if let (Some(n), Ok(v)) = (current_func, value_str.parse::<f64>()) {
if let Some(f) = functions.get_mut(&n) {
f.pusher_interval_us = Some(v);
}
}
}
"Polarity" => {
let v = value_str.to_ascii_uppercase();
if v.starts_with("ES+") || v.starts_with("POS") {
polarity.get_or_insert(Polarity::Positive);
} else if v.starts_with("ES-") || v.starts_with("NEG") {
polarity.get_or_insert(Polarity::Negative);
}
}
_ => {}
}
}
let lteff_mm = lteff_mm
.ok_or_else(|| crate::Error::Parse("_extern.inf: Lteff field not found".to_owned()))?;
let veff_v = veff_v
.ok_or_else(|| crate::Error::Parse("_extern.inf: Veff field not found".to_owned()))?;
let pusher_interval_us = pusher_from_interval.or(pusher_from_cycle).ok_or_else(|| {
crate::Error::Parse(
"_extern.inf: neither PusherInterval nor Pusher Cycle Time found".to_owned(),
)
})?;
Ok(ExternInf {
lteff_mm,
veff_v,
pusher_interval_us,
polarity,
functions,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXTERN_PXD058812: &str = "\
Parameters for C:\\MassLynx\\Qtof\\tuneexp.exp\r\n\
\r\n\
Instrument Parameters - Function 1:\r\n\
Lteff\t1997.9400\r\n\
Veff\t9100.0000\r\n\
Pusher Cycle Time (µs) 62
\
\r\n\
Function Parameters - Function 1 - TOF MS FUNCTION\r\n\
Start Mass 100.0\r\n\
End Mass 2000.0\r\n\
Scan Time (sec) 1.0\r\n\
";
const EXTERN_PXD075602: &str = "\
Parameters for D:\\Projects\\method.EXP\r\n\
Created by MassLynx v4.2 SCN966\r\n\
\r\n\
Instrument Configuration:\r\n\
Lteff 1800.0\r\n\
Veff 6328.24\r\n\
PusherInterval 60.250000\r\n\
\r\n\
Function Parameters - Function 1 - TOF PARENT FUNCTION\r\n\
Start Mass 50.0\r\n\
End Mass 1200.0\r\n\
Survey Scan Time 0.5\r\n\
ADC Pusher Frequency (µs) 60.3\r\n\
\r\n\
Function Parameters - Function 2 - TOF PARENT FUNCTION\r\n\
Start Mass 50.0\r\n\
End Mass 1200.0\r\n\
Survey Scan Time 0.5\r\n\
ADC Pusher Frequency (µs) 60.3\r\n\
\r\n\
Function Parameters - Function 3 - TOF PRODUCT FUNCTION\r\n\
Start Mass 50.0\r\n\
End Mass 1200.0\r\n\
Survey Scan Time 0.5\r\n\
ADC Pusher Frequency (µs) 60.3\r\n\
";
const EXTERN_PXD068881: &str = "\
Parameters for method.EXP\r\n\
Created by 4.1 SCN 965\r\n\
\r\n\
Instrument Configuration:\r\n\
Lteff 1800.0\r\n\
Veff 7198.65\r\n\
PusherInterval 69.000000\r\n\
\r\n\
Function Parameters - Function 1 - TOF PARENT FUNCTION\r\n\
Start Mass 50.0\r\n\
End Mass 2000.0\r\n\
";
#[test]
fn parse_older_format_pusher_cycle_time() {
let ext: ExternInf = EXTERN_PXD058812.parse().unwrap();
assert!((ext.lteff_mm - 1997.94).abs() < 1e-3);
assert!((ext.veff_v - 9100.0).abs() < 1e-3);
assert!((ext.pusher_interval_us - 62.0).abs() < 1e-6);
}
#[test]
fn parse_newer_format_pusher_interval() {
let ext: ExternInf = EXTERN_PXD075602.parse().unwrap();
assert!((ext.lteff_mm - 1800.0).abs() < 1e-3);
assert!((ext.veff_v - 6328.24).abs() < 1e-3);
assert!((ext.pusher_interval_us - 60.25).abs() < 1e-6);
}
#[test]
fn parse_ims_instrument() {
let ext: ExternInf = EXTERN_PXD068881.parse().unwrap();
assert!((ext.lteff_mm - 1800.0).abs() < 1e-3);
assert!((ext.veff_v - 7198.65).abs() < 1e-3);
assert!((ext.pusher_interval_us - 69.0).abs() < 1e-6);
}
#[test]
fn parse_function_mass_range() {
let ext: ExternInf = EXTERN_PXD058812.parse().unwrap();
let f1 = ext.functions.get(&1).expect("Function 1 missing");
assert!((f1.start_mass_da - 100.0).abs() < 1e-6);
assert!((f1.end_mass_da - 2000.0).abs() < 1e-6);
assert!(f1.pusher_interval_us.is_none());
}
#[test]
fn parse_per_function_pusher_override() {
let ext: ExternInf = EXTERN_PXD075602.parse().unwrap();
assert_eq!(ext.functions.len(), 3);
for n in 1..=3u32 {
let f = ext
.functions
.get(&n)
.unwrap_or_else(|| panic!("Function {n} missing"));
let ov = f.pusher_interval_us.expect("ADC Pusher Frequency missing");
assert!((ov - 60.3).abs() < 1e-6);
}
}
#[test]
fn pusher_interval_for_falls_back_to_global() {
let ext: ExternInf = EXTERN_PXD068881.parse().unwrap();
assert!((ext.pusher_interval_for(1) - 69.0).abs() < 1e-6);
}
#[test]
fn pusher_interval_for_uses_per_function_override() {
let ext: ExternInf = EXTERN_PXD075602.parse().unwrap();
assert!((ext.pusher_interval_for(1) - 60.3).abs() < 1e-6);
}
#[test]
fn a_us_plausible_range() {
let ext_58812: ExternInf = EXTERN_PXD058812.parse().unwrap();
let ext_75602: ExternInf = EXTERN_PXD075602.parse().unwrap();
let ext_68881: ExternInf = EXTERN_PXD068881.parse().unwrap();
for (name, ext) in [
("PXD058812", &ext_58812),
("PXD075602", &ext_75602),
("PXD068881", &ext_68881),
] {
let a = ext.a_us();
assert!(
(1.0..3.0).contains(&a),
"{name}: A_us={a} outside expected range [1.0, 3.0]"
);
}
}
#[test]
fn a_us_formula_pxd058812() {
let ext: ExternInf = EXTERN_PXD058812.parse().unwrap();
let a = ext.a_us();
assert!(
(a - 1.5129).abs() < 1e-3,
"A_us={a}, expected ≈1.5129 µs/sqrt(Da)"
);
}
#[test]
fn missing_lteff_is_error() {
let src = "Veff 9100.0\r\nPusherInterval 88.0\r\n";
let err = src.parse::<ExternInf>().unwrap_err();
assert!(err.to_string().contains("Lteff"));
}
#[test]
fn missing_pusher_is_error() {
let src = "Lteff 1997.94\r\nVeff 9100.0\r\n";
let err = src.parse::<ExternInf>().unwrap_err();
assert!(err.to_string().contains("PusherInterval"));
}
}