Skip to main content

opentfraw/
reader.rs

1use std::collections::HashMap;
2use std::io::{Read, Seek, SeekFrom};
3
4use crate::error::{Error, Result};
5use crate::error_log::ErrorEntry;
6use crate::generic_data::{GenericDataHeader, GenericRecord, GenericValue};
7use crate::header::FileHeader;
8use crate::raw_file_info::RawFileInfo;
9use crate::run_header::RunHeader;
10use crate::scan_data::{
11    read_flat_peaks, read_scan_srm_v66, search_v63_transition, Peak, ScanDataPacket,
12};
13use crate::scan_event::{ScanEvent, ScanEventPreamble};
14use crate::scan_index::ScanIndexEntry;
15use crate::seq_row::SeqRow;
16
17/// Low-level binary reading helpers.
18pub(crate) struct BinaryReader<R> {
19    inner: R,
20    pos: u64,
21}
22
23impl<R: Read + Seek> BinaryReader<R> {
24    pub fn new(inner: R) -> Self {
25        Self { inner, pos: 0 }
26    }
27
28    pub fn into_inner(self) -> R {
29        self.inner
30    }
31
32    #[allow(dead_code)]
33    pub(crate) fn position(&self) -> u64 {
34        self.pos
35    }
36
37    pub fn seek_to(&mut self, offset: u64) -> Result<()> {
38        self.inner.seek(SeekFrom::Start(offset))?;
39        self.pos = offset;
40        Ok(())
41    }
42
43    pub fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>> {
44        let mut buf = vec![0u8; n];
45        self.inner.read_exact(&mut buf).map_err(|e| {
46            if e.kind() == std::io::ErrorKind::UnexpectedEof {
47                Error::UnexpectedEof {
48                    offset: self.pos,
49                    needed: n,
50                }
51            } else {
52                Error::Io(e)
53            }
54        })?;
55        self.pos += n as u64;
56        Ok(buf)
57    }
58
59    pub fn read_bytes_into(&mut self, buf: &mut [u8]) -> Result<()> {
60        let n = buf.len();
61        self.inner.read_exact(buf).map_err(|e| {
62            if e.kind() == std::io::ErrorKind::UnexpectedEof {
63                Error::UnexpectedEof {
64                    offset: self.pos,
65                    needed: n,
66                }
67            } else {
68                Error::Io(e)
69            }
70        })?;
71        self.pos += n as u64;
72        Ok(())
73    }
74
75    pub fn skip(&mut self, n: usize) -> Result<()> {
76        self.inner.seek(SeekFrom::Current(n as i64))?;
77        self.pos += n as u64;
78        Ok(())
79    }
80
81    pub fn length(&mut self) -> Result<u64> {
82        let cur = self.pos;
83        let end = self.inner.seek(SeekFrom::End(0))?;
84        self.inner.seek(SeekFrom::Start(cur))?;
85        self.pos = cur;
86        Ok(end)
87    }
88
89    pub fn read_u8(&mut self) -> Result<u8> {
90        let mut buf = [0u8; 1];
91        self.read_bytes_into(&mut buf)?;
92        Ok(buf[0])
93    }
94
95    pub fn read_u16(&mut self) -> Result<u16> {
96        let mut buf = [0u8; 2];
97        self.read_bytes_into(&mut buf)?;
98        Ok(u16::from_le_bytes(buf))
99    }
100
101    pub fn read_i16(&mut self) -> Result<i16> {
102        let mut buf = [0u8; 2];
103        self.read_bytes_into(&mut buf)?;
104        Ok(i16::from_le_bytes(buf))
105    }
106
107    pub fn read_u32(&mut self) -> Result<u32> {
108        let mut buf = [0u8; 4];
109        self.read_bytes_into(&mut buf)?;
110        Ok(u32::from_le_bytes(buf))
111    }
112
113    pub fn read_i32(&mut self) -> Result<i32> {
114        let mut buf = [0u8; 4];
115        self.read_bytes_into(&mut buf)?;
116        Ok(i32::from_le_bytes(buf))
117    }
118
119    pub fn read_u64(&mut self) -> Result<u64> {
120        let mut buf = [0u8; 8];
121        self.read_bytes_into(&mut buf)?;
122        Ok(u64::from_le_bytes(buf))
123    }
124
125    pub fn read_f32(&mut self) -> Result<f32> {
126        let mut buf = [0u8; 4];
127        self.read_bytes_into(&mut buf)?;
128        Ok(f32::from_le_bytes(buf))
129    }
130
131    pub fn read_f64(&mut self) -> Result<f64> {
132        let mut buf = [0u8; 8];
133        self.read_bytes_into(&mut buf)?;
134        Ok(f64::from_le_bytes(buf))
135    }
136
137    pub fn read_i8(&mut self) -> Result<i8> {
138        let mut buf = [0u8; 1];
139        self.read_bytes_into(&mut buf)?;
140        Ok(buf[0] as i8)
141    }
142
143    /// Read a fixed-width UTF-16-LE string of `byte_len` bytes, stripping null padding.
144    pub fn read_utf16_fixed(&mut self, byte_len: usize) -> Result<String> {
145        let pos = self.pos;
146        let raw = self.read_bytes(byte_len)?;
147        if byte_len % 2 != 0 {
148            return Err(Error::InvalidUtf16(pos));
149        }
150        let units: Vec<u16> = raw
151            .chunks_exact(2)
152            .map(|c| u16::from_le_bytes([c[0], c[1]]))
153            .collect();
154        // Find null terminator
155        let end = units.iter().position(|&u| u == 0).unwrap_or(units.len());
156        String::from_utf16(&units[..end]).map_err(|_| Error::InvalidUtf16(pos))
157    }
158
159    /// Read a PascalStringWin32: UInt32 char count, then that many UTF-16-LE code units.
160    pub fn read_pascal_string(&mut self) -> Result<String> {
161        let pos = self.pos;
162        let char_count = self.read_u32()? as usize;
163        if char_count == 0 {
164            return Ok(String::new());
165        }
166        let byte_len = char_count.checked_mul(2).ok_or(Error::InvalidUtf16(pos))?;
167        let raw = self.read_bytes(byte_len)?;
168        let units: Vec<u16> = raw
169            .chunks_exact(2)
170            .map(|c| u16::from_le_bytes([c[0], c[1]]))
171            .collect();
172        // Strip trailing nulls
173        let end = units.iter().position(|&u| u == 0).unwrap_or(units.len());
174        String::from_utf16(&units[..end]).map_err(|_| Error::InvalidUtf16(pos))
175    }
176
177    /// Read a Windows FILETIME and return Unix timestamp as f64 seconds.
178    pub fn read_windows_filetime(&mut self) -> Result<f64> {
179        let ft = self.read_u64()?;
180        if ft == 0 {
181            return Ok(0.0);
182        }
183        Ok((ft as f64 / 10_000_000.0) - 11_644_473_600.0)
184    }
185}
186
187/// A parsed Thermo Fisher RAW file.
188pub struct RawFileReader {
189    pub header: FileHeader,
190    pub seq_row: SeqRow,
191    pub raw_file_info: RawFileInfo,
192    pub run_header: RunHeader,
193    pub scan_index: Vec<ScanIndexEntry>,
194    pub scan_events: Vec<ScanEvent>,
195    pub scan_parameters_header: GenericDataHeader,
196    pub scan_parameters: Vec<GenericRecord>,
197    pub error_log: Vec<ErrorEntry>,
198    // Instrument log uses same structure
199    pub inst_log_header: GenericDataHeader,
200    pub inst_log: Vec<GenericRecord>,
201    /// Raw file version from the header.
202    pub version: u32,
203    /// Number of scans.
204    pub num_scans: u32,
205    /// Data stream base address (for computing absolute scan offsets).
206    pub data_addr: u64,
207    /// True if scan data uses flat-peak format (TSQ/SRM) instead of PacketHeader.
208    pub flat_peaks: bool,
209    /// Detected scan-data encoding (the format used by [`Self::read_scan_peaks`]).
210    pub scan_format: crate::scan_format::ScanDataFormat,
211    /// Detected device family (informational).
212    pub device_family: crate::device::DeviceFamily,
213    /// Canonical instrument model name if one was detected in the file's
214    /// metadata region (e.g. `"Orbitrap Fusion Lumos"`). `None` means only
215    /// the coarse family could be inferred.
216    pub instrument_model: Option<&'static str>,
217    /// For SRM (flat-peak) files: maps scan_event index → Q1 precursor mass (m/z).
218    ///
219    /// Populated at open time by scanning the method/transition table stored
220    /// in the pre-scan-data header region. Empty for non-SRM instruments.
221    pub srm_q1_by_event: HashMap<u16, f64>,
222    /// For SRM (flat-peak) files: maps scan_event index → Q3 isolation window pairs (lo, hi) in m/z.
223    ///
224    /// Populated at open time by reading the Q3 window table from the first scan record
225    /// of each unique scan event class. Empty for non-SRM instruments.
226    pub srm_q3_windows: HashMap<u16, Vec<(f32, f32)>>,
227    /// For SRM (flat-peak) files: maps scan_event index → collision energy (eV).
228    ///
229    /// Populated from the v63 transition table at open time.  For v66 files the
230    /// collision energy is read from per-scan parameters instead, so this map is
231    /// empty for v66/TSQ Altis files.
232    pub srm_ce_by_event: HashMap<u16, f64>,
233}
234
235// -- Multi-controller metadata --
236
237/// Controller type codes as used in Thermo RAW files.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum ControllerType {
240    Ms,
241    Analog,
242    Adc,
243    Pda,
244    Uv,
245    Other,
246}
247
248impl ControllerType {
249    fn from_nsegs_ntrailer(ntrailer: u32, nsegs: u32) -> Self {
250        // Heuristic: MS controller always has ntrailer > 0 (v64+) or nsegs > 0.
251        // Non-MS controllers (UV, analog, PDA) have ntrailer == 0 and nsegs == 1.
252        // We can't reliably distinguish between non-MS types without parsing
253        // the InstID/method block, so we fall back to Other for those.
254        if ntrailer > 0 || nsegs > 1 {
255            Self::Ms
256        } else {
257            Self::Other
258        }
259    }
260}
261
262/// Minimal metadata about one controller in a multi-controller RAW file.
263#[derive(Debug, Clone)]
264pub struct ControllerInfo {
265    /// Zero-based controller index (position in `run_header_addrs`).
266    pub index: usize,
267    /// File offset to this controller's RunHeader.
268    pub run_header_addr: u64,
269    /// Whether this controller is the primary MS controller.
270    pub is_ms_controller: bool,
271    /// Inferred controller type.
272    pub controller_type: ControllerType,
273    /// First scan number.
274    pub first_scan: u32,
275    /// Last scan number.
276    pub last_scan: u32,
277    /// Acquisition start time (minutes).
278    pub start_time: f64,
279    /// Acquisition end time (minutes).
280    pub end_time: f64,
281}
282
283impl RawFileReader {
284    /// Open and parse a RAW file from a reader.
285    pub fn open<R: Read + Seek>(source: R) -> Result<Self> {
286        let mut r = BinaryReader::new(source);
287
288        // 1. FileHeader
289        let header = FileHeader::read(&mut r)?;
290        let version = header.version;
291
292        // 2. SeqRow
293        let seq_row = SeqRow::read(&mut r, version)?;
294
295        // 3. ASInfo (read and discard preamble + string)
296        let _as_preamble = r.read_bytes(24)?; // ASInfoPreamble: 24 bytes
297        let _as_text = r.read_pascal_string()?;
298
299        // 4. RawFileInfo
300        let raw_file_info = RawFileInfo::read(&mut r, version)?;
301
302        // 5. Extract addresses
303        let data_addr = raw_file_info.preamble.data_addr;
304
305        // 6. Select the MS controller RunHeader.
306        // Multi-controller files (e.g. UV + MS) have one RunHeader per controller.
307        // The MS controller has ntrailer > 0 (v64+) or first_scan <= last_scan with
308        // nsegs > 0 (v63 and earlier). We iterate all addresses and pick the best.
309        let run_header = {
310            let addrs = &raw_file_info.preamble.run_header_addrs;
311            let mut chosen = None;
312            for &addr in addrs {
313                if addr == 0 {
314                    continue;
315                }
316                r.seek_to(addr)?;
317                let rh = RunHeader::read(&mut r, version)?;
318                // Heuristic for identifying the MS controller:
319                // 1. For v64+: ntrailer > 0 (scan events present) - catches most instruments.
320                // 2. For all versions: RunHeader.data_addr == preamble.data_addr - the MS
321                //    controller's scan data begins at the same address the preamble declares.
322                //    This catches TSQ/triple-quad instruments where ntrailer=0 (no scan events).
323                // 3. Pre-v64 fallback: valid scan range with nsegs > 0.
324                let is_ms = if version >= 64 {
325                    rh.ntrailer > 0 || rh.data_addr == data_addr
326                } else {
327                    rh.sample_info.last_scan_number >= rh.sample_info.first_scan_number
328                        && rh.nsegs > 0
329                };
330                if is_ms {
331                    chosen = Some(rh);
332                    break;
333                }
334            }
335            // Fall back to first address if no MS controller found
336            match chosen {
337                Some(rh) => rh,
338                None => {
339                    r.seek_to(addrs[0])?;
340                    RunHeader::read(&mut r, version)?
341                }
342            }
343        };
344
345        let first_scan = run_header.sample_info.first_scan_number;
346        let last_scan = run_header.sample_info.last_scan_number;
347
348        let num_scans = if last_scan >= first_scan {
349            last_scan - first_scan + 1
350        } else {
351            0
352        };
353
354        // 7. Scan index
355        r.seek_to(run_header.scan_index_addr)?;
356        let mut scan_index = Vec::with_capacity(num_scans as usize);
357        for _ in 0..num_scans {
358            scan_index.push(ScanIndexEntry::read(&mut r, version)?);
359        }
360
361        // 8. Scan event trailer
362        r.seek_to(run_header.scan_trailer_addr)?;
363        let n_events = if version >= 64 {
364            // v64+: first u32 is a preamble (not count); use ntrailer from RunHeader
365            let _preamble = r.read_u32()?;
366            run_header.ntrailer
367        } else {
368            r.read_u32()?
369        };
370        // For v66, compute per-event body sizes from the stream's address range.
371        // The scan event stream spans [scan_trailer_addr+4 .. scan_params_addr).
372        // Each event = preamble (136 bytes) + body.
373        //
374        // Simple instruments (Q Exactive, Exploris): all events are identical in
375        // size so stream_bytes divides evenly by n_events.
376        //
377        // Tribrid instruments (Eclipse, Fusion Lumos): primary (MS1) scans and
378        // dependent (MS2+) scans have different body layouts:
379        //   Primary event:   232 bytes total (preamble 136 + body 96)
380        //   Dependent event: 344 bytes total (preamble 136 + body 208)
381        // Confirmed empirically across Orbitrap Eclipse (EThcD) and Fusion Lumos
382        // (DIA, MS3) files.
383        let preamble_size = ScanEventPreamble::size_for_version(version);
384        let (v66_body_primary, v66_body_dependent): (usize, usize) =
385            if version >= 66 && n_events > 0 {
386                let stream_bytes = run_header
387                    .scan_params_addr
388                    .saturating_sub(run_header.scan_trailer_addr)
389                    .saturating_sub(4);
390                let remainder = stream_bytes % n_events as u64;
391                if remainder == 0 {
392                    // Uniform event size (Q Exactive, Exploris, etc.)
393                    let body = (stream_bytes / n_events as u64) as usize;
394                    let body = body.saturating_sub(preamble_size);
395                    (body, body)
396                } else {
397                    // Variable-length events: tribrid Orbitrap instruments.
398                    // Known sizes: primary=232, dependent=344 (body 96 and 208).
399                    const PRIMARY_EVENT: u64 = 232;
400                    const DEPENDENT_EVENT: u64 = 344;
401                    let gap = DEPENDENT_EVENT - PRIMARY_EVENT;
402                    let n = n_events as u64;
403                    // n_primary * PRIMARY_EVENT + n_dependent * DEPENDENT_EVENT = stream_bytes
404                    // n_primary + n_dependent = n
405                    // => n_primary = (n * DEPENDENT_EVENT - stream_bytes) / gap
406                    let n_primary_numerator = n
407                        .saturating_mul(DEPENDENT_EVENT)
408                        .saturating_sub(stream_bytes);
409                    if n_primary_numerator % gap == 0 {
410                        let n_primary = n_primary_numerator / gap;
411                        let n_dependent = n.saturating_sub(n_primary);
412                        let total_check = n_primary * PRIMARY_EVENT + n_dependent * DEPENDENT_EVENT;
413                        if total_check == stream_bytes {
414                            // Verified: use the tribrid sizes.
415                            (
416                                (PRIMARY_EVENT as usize).saturating_sub(preamble_size),
417                                (DEPENDENT_EVENT as usize).saturating_sub(preamble_size),
418                            )
419                        } else {
420                            // Fallback: use floor-average uniform body
421                            let body = ((stream_bytes / n) as usize).saturating_sub(preamble_size);
422                            (body, body)
423                        }
424                    } else {
425                        // Fallback: use floor-average uniform body
426                        let body = ((stream_bytes / n) as usize).saturating_sub(preamble_size);
427                        (body, body)
428                    }
429                }
430            } else {
431                (0, 0)
432            };
433        let mut scan_events = Vec::with_capacity(n_events as usize);
434        for _ in 0..n_events {
435            scan_events.push(ScanEvent::read(
436                &mut r,
437                version,
438                v66_body_primary,
439                v66_body_dependent,
440            )?);
441        }
442
443        // 9. Error log
444        let n_errors = run_header.sample_info.error_log_length;
445        let error_log = if n_errors > 0 {
446            r.seek_to(run_header.error_log_addr)?;
447            if version >= 64 {
448                let _preamble = r.read_u32()?;
449            }
450            let mut log = Vec::with_capacity(n_errors as usize);
451            for _ in 0..n_errors {
452                log.push(ErrorEntry::read(&mut r)?);
453            }
454            log
455        } else {
456            // Ensure reader is positioned at error_log_addr even when empty.
457            r.seek_to(run_header.error_log_addr)?;
458            Vec::new()
459        };
460        // The GDH for scan parameters immediately follows the error-log entries.
461        // Do NOT seek back to error_log_addr - doing so would cause find_forward
462        // to scan over the scan_index (which may sit between error_log and
463        // scan_trailer in some file layouts), creating a CPU-spinning O(n) search
464        // through megabytes of binary scan data.
465        let after_error_log = r.position();
466
467        // 10. Scan parameters (trailer extra) - GenericData format in v64+.
468        //     The schema (GDH) is written just after the error-log entries;
469        //     the records are written at `scan_params_addr` (tail of file)
470        //     with NO stream preamble - records begin directly at
471        //     scan_params_addr. Any bytes after the last record are trailing
472        //     padding and can be ignored.
473        let (scan_parameters_header, scan_parameters) = if version >= 64 {
474            // Search from after the error log entries up to scan_trailer.
475            // This skips any scan_index data that may sit in between.
476            let scan_distance = run_header.scan_trailer_addr.saturating_sub(after_error_log);
477            // Estimate per-record size from the tail of the file using integer
478            // division. Any remainder bytes are trailing data, not a preamble.
479            let file_size = r.length()?;
480            let tail = file_size.saturating_sub(run_header.scan_params_addr);
481            let expected_record_size = if num_scans > 0 && tail > 0 {
482                let per_scan = tail / num_scans as u64;
483                if per_scan >= 4 {
484                    Some(per_scan as usize)
485                } else {
486                    None
487                }
488            } else {
489                None
490            };
491            match GenericDataHeader::find_forward(&mut r, scan_distance, expected_record_size)? {
492                Some(hdr) => {
493                    // Records start directly at scan_params_addr - no stream preamble.
494                    r.seek_to(run_header.scan_params_addr)?;
495                    let mut params = Vec::with_capacity(num_scans as usize);
496                    for _ in 0..num_scans {
497                        params.push(GenericRecord::read(&mut r, &hdr)?);
498                    }
499                    (hdr, params)
500                }
501                None => (GenericDataHeader { fields: Vec::new() }, Vec::new()),
502            }
503        } else {
504            (GenericDataHeader { fields: Vec::new() }, Vec::new())
505        };
506
507        // 11. Instrument log - GenericData format in v64+
508        let (inst_log_header, inst_log) = if version >= 64 {
509            r.seek_to(run_header.inst_log_addr)?;
510            match GenericDataHeader::try_read(&mut r)? {
511                Some(hdr) => {
512                    let n_inst = run_header.sample_info.inst_log_length;
513                    let mut log = Vec::with_capacity(n_inst as usize);
514                    for _ in 0..n_inst {
515                        log.push(GenericRecord::read(&mut r, &hdr)?);
516                    }
517                    (hdr, log)
518                }
519                None => (GenericDataHeader { fields: Vec::new() }, Vec::new()),
520            }
521        } else {
522            (GenericDataHeader { fields: Vec::new() }, Vec::new())
523        };
524
525        // Detect flat-peak (TSQ/SRM) format.
526        // Reliable indicator: ntrailer == 0 means no scan event trailer was written, which
527        // is the case for all TSQ/triple-quad SRM instruments.
528        // Fallback: first scan data_size < 100 (catches edge cases with tiny SRM windows).
529        // In the flat format, data_size is the number of MRM peaks, not bytes.
530        let flat_peaks = run_header.ntrailer == 0
531            || scan_index
532                .first()
533                .map(|e| e.data_size < 100)
534                .unwrap_or(false);
535
536        // Classify scan format and device family.
537        let scan_format = crate::scan_format::ScanDataFormat::detect(version, flat_peaks);
538        let first_analyzer = scan_events.first().and_then(|e| e.preamble.analyzer());
539
540        // For SRM (flat-peak) files, read the entire pre-scan-data region so that
541        // we can extract Q1 values from the method/transition table stored there.
542        // For other instruments, read only 64 KB for instrument model detection.
543        let scan_window_cap = if flat_peaks { data_addr } else { 64 * 1024u64 };
544        let window_len = scan_window_cap.min(data_addr);
545        let metadata_window = if window_len > 0 {
546            r.seek_to(0)?;
547            r.read_bytes(window_len as usize).unwrap_or_default()
548        } else {
549            Vec::new()
550        };
551        // All BinaryReader operations are complete; reclaim the underlying source so
552        // it can be used for on-demand reads (e.g. Q3 window table from scan records).
553        let mut source = r.into_inner();
554        let detected = crate::device::DeviceFamily::detect_instrument(
555            &metadata_window,
556            &header.audit_start.tag2,
557            &seq_row.inst_method,
558            first_analyzer,
559        );
560        let device_family = detected.family;
561        let instrument_model = detected.model;
562
563        // For SRM files: extract Q1 masses, Q3 window pairs, and (for v63) collision energies
564        // from the pre-scan-data header region and/or the scan data records.
565        //
566        // v66 (TSQ Quantiva / TSQ Altis, FlatV66):
567        //   Transition table layout: [Q1: f64][Q3_lo: f64][Q3_hi: f64] per channel.
568        //   Anchor: scan_index.high_mz equals the Q3_hi of the highest-Q3 channel for each
569        //   event class.  Q3 window pairs come from the per-scan record header.
570        //
571        // v63 (TSQ Quantum / TSQ Vantage, FlatV63):
572        //   Transition table layout: 72-byte records; Q1 at [+16], Q3_center at [+24],
573        //   Q3_width at [+32], CE at [+48].  scan_index.low_mz/high_mz hold the instrument
574        //   scan range (not per-transition values), so the high_mz anchor does not apply.
575        //   Q3 centers come from the first scan's peak list; Q3 windows are computed as
576        //   Q3_center ± Q3_width/2.
577        let (srm_q1_by_event, srm_q3_windows, srm_ce_by_event) = {
578            use crate::scan_format::ScanDataFormat;
579            match (flat_peaks, scan_format) {
580                (true, ScanDataFormat::FlatV66) if metadata_window.len() >= 24 => {
581                    // --- v66 Q1 extraction: anchor on scan_index.high_mz ---
582                    let mut event_q3_hi: HashMap<u16, f64> = HashMap::new();
583                    for entry in &scan_index {
584                        if entry.high_mz > 50.0 && entry.high_mz < 2000.0 {
585                            event_q3_hi.entry(entry.scan_event).or_insert(entry.high_mz);
586                        }
587                    }
588                    let data = &metadata_window;
589                    let mut q1_map: HashMap<u16, f64> = HashMap::new();
590                    'outer_v66: for (&event, &q3_hi_target) in &event_q3_hi {
591                        let end = data.len().saturating_sub(8);
592                        for i in 16..end {
593                            let hi = crate::bytes::read_f64_le(data, i)?;
594                            if (hi - q3_hi_target).abs() < 0.002 {
595                                let lo = crate::bytes::read_f64_le(data, i - 8)?;
596                                if hi > lo && (hi - lo) < 0.1 {
597                                    let q1 = crate::bytes::read_f64_le(data, i - 16)?;
598                                    if q1 > 50.0 && q1 < 3000.0 {
599                                        q1_map.insert(event, q1);
600                                        continue 'outer_v66;
601                                    }
602                                }
603                            }
604                        }
605                    }
606                    // --- v66 Q3 window extraction: read per-scan record header ---
607                    let mut seen: HashMap<u16, bool> = HashMap::new();
608                    let mut q3_map: HashMap<u16, Vec<(f32, f32)>> = HashMap::new();
609                    for entry in &scan_index {
610                        if seen.contains_key(&entry.scan_event) {
611                            continue;
612                        }
613                        seen.insert(entry.scan_event, true);
614                        if let Ok(windows) = crate::scan_data::read_scan_srm_v66_windows(
615                            &mut source,
616                            data_addr,
617                            entry.offset,
618                        ) {
619                            if !windows.is_empty() {
620                                q3_map.insert(entry.scan_event, windows);
621                            }
622                        }
623                    }
624                    (q1_map, q3_map, HashMap::new())
625                }
626                (true, ScanDataFormat::FlatV63) => {
627                    // --- v63 Q1 + Q3 window + CE extraction ---
628                    // Read peaks from the first scan of each event class; each peak's mz
629                    // is the Q3 center for that channel.  Search the pre-data region for
630                    // the Q3_center value to find Q1, Q3_width, and CE from the transition
631                    // table.  Q3 windows are computed as (Q3_center - width/2, Q3_center + width/2).
632                    let mut seen: HashMap<u16, bool> = HashMap::new();
633                    let mut q1_map: HashMap<u16, f64> = HashMap::new();
634                    let mut q3_map: HashMap<u16, Vec<(f32, f32)>> = HashMap::new();
635                    let mut ce_map: HashMap<u16, f64> = HashMap::new();
636                    let data = &metadata_window;
637                    for entry in &scan_index {
638                        let ev = entry.scan_event;
639                        if seen.contains_key(&ev) {
640                            continue;
641                        }
642                        seen.insert(ev, true);
643                        let peaks = match read_flat_peaks(
644                            &mut source,
645                            data_addr,
646                            entry.offset,
647                            entry.data_size,
648                        ) {
649                            Ok(p) if !p.is_empty() => p,
650                            _ => continue,
651                        };
652                        // Use the first peak's mz as Q3_center anchor.
653                        if let Some((q1, q3w, ce)) = search_v63_transition(data, peaks[0].mz) {
654                            q1_map.insert(ev, q1);
655                            ce_map.insert(ev, ce);
656                            let half = (q3w / 2.0) as f32;
657                            let windows: Vec<(f32, f32)> = peaks
658                                .iter()
659                                .map(|p| (p.mz as f32 - half, p.mz as f32 + half))
660                                .collect();
661                            q3_map.insert(ev, windows);
662                        }
663                    }
664                    (q1_map, q3_map, ce_map)
665                }
666                _ => (HashMap::new(), HashMap::new(), HashMap::new()),
667            }
668        };
669
670        Ok(Self {
671            header,
672            seq_row,
673            raw_file_info,
674            run_header,
675            scan_index,
676            scan_events,
677            scan_parameters_header,
678            scan_parameters,
679            error_log,
680            inst_log_header,
681            inst_log,
682            version,
683            num_scans,
684            data_addr,
685            flat_peaks,
686            scan_format,
687            device_family,
688            instrument_model,
689            srm_q1_by_event,
690            srm_q3_windows,
691            srm_ce_by_event,
692        })
693    }
694
695    /// Open a RAW file from a path.
696    pub fn open_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
697        let file = std::fs::File::open(path)?;
698        let reader = std::io::BufReader::new(file);
699        Self::open(reader)
700    }
701
702    /// Enumerate all controllers in this RAW file.
703    ///
704    /// Multi-detector acquisition systems write one [`RunHeader`] per
705    /// controller (MS, UV, PDA, Analog). This method parses all controller
706    /// headers and returns a `Vec<ControllerInfo>` with basic metadata for
707    /// each. The primary MS controller can be identified via
708    /// [`ControllerInfo::is_ms_controller`].
709    ///
710    /// For single-controller files (the common case), this returns a
711    /// one-element vec with the MS controller.
712    pub fn controllers<R: Read + Seek>(&self, source: &mut R) -> Result<Vec<ControllerInfo>> {
713        let mut r = BinaryReader::new(source);
714        let addrs = &self.raw_file_info.preamble.run_header_addrs;
715        let mut infos = Vec::with_capacity(addrs.len());
716        for (i, &addr) in addrs.iter().enumerate() {
717            if addr == 0 {
718                continue;
719            }
720            r.seek_to(addr)?;
721            let rh = RunHeader::read(&mut r, self.version)?;
722            let is_ms = if self.version >= 64 {
723                rh.ntrailer > 0 || rh.data_addr == self.data_addr
724            } else {
725                rh.nsegs > 0
726            };
727            let ct = if is_ms {
728                ControllerType::Ms
729            } else {
730                ControllerType::from_nsegs_ntrailer(rh.ntrailer, rh.nsegs)
731            };
732            infos.push(ControllerInfo {
733                index: i,
734                run_header_addr: addr,
735                is_ms_controller: is_ms,
736                controller_type: ct,
737                first_scan: rh.sample_info.first_scan_number,
738                last_scan: rh.sample_info.last_scan_number,
739                start_time: rh.sample_info.start_time,
740                end_time: rh.sample_info.end_time,
741            });
742        }
743        Ok(infos)
744    }
745
746    /// Read a single scan data packet (PacketHeader format).
747    pub fn read_scan<R: Read + Seek>(
748        &self,
749        source: &mut R,
750        scan_number: u32,
751    ) -> Result<ScanDataPacket> {
752        let idx = (scan_number - self.run_header.sample_info.first_scan_number) as usize;
753        if idx >= self.scan_index.len() {
754            return Err(Error::AddressOutOfRange(scan_number as u64));
755        }
756        let entry = &self.scan_index[idx];
757        let abs_offset = self.data_addr + entry.offset;
758        source.seek(SeekFrom::Start(abs_offset))?;
759        let mut r = BinaryReader::new(source);
760        ScanDataPacket::read(&mut r)
761    }
762
763    /// Read a single scan packet's centroid peaks and FT label data
764    /// (resolution / noise / baseline), skipping the profile signal for speed.
765    ///
766    /// Only valid for PacketHeader-format files; TSQ/SRM scans carry no FT
767    /// label data.
768    pub fn read_scan_labels<R: Read + Seek>(
769        &self,
770        source: &mut R,
771        scan_number: u32,
772    ) -> Result<ScanDataPacket> {
773        use crate::scan_format::ScanDataFormat;
774        if self.scan_format != ScanDataFormat::PacketHeader {
775            return Err(Error::UnsupportedOperation(
776                "centroid_labels / read_scan_labels requires a PacketHeader file (Orbitrap/ion-trap); TSQ/SRM files carry no FT label data",
777            ));
778        }
779        let idx = (scan_number - self.run_header.sample_info.first_scan_number) as usize;
780        if idx >= self.scan_index.len() {
781            return Err(Error::AddressOutOfRange(scan_number as u64));
782        }
783        let entry = &self.scan_index[idx];
784        let abs_offset = self.data_addr + entry.offset;
785        source.seek(SeekFrom::Start(abs_offset))?;
786        let mut r = BinaryReader::new(source);
787        ScanDataPacket::read_skip_profile(&mut r)
788    }
789
790    /// Read a single scan as flat peaks (TSQ/SRM format).
791    ///
792    /// In this format, `entry.offset` is the cumulative end byte offset within
793    /// the data stream. Peaks are (f32, f32) pairs at the end of each record.
794    pub fn read_scan_flat<R: Read + Seek>(
795        &self,
796        source: &mut R,
797        scan_number: u32,
798    ) -> Result<Vec<Peak>> {
799        let idx = (scan_number - self.run_header.sample_info.first_scan_number) as usize;
800        if idx >= self.scan_index.len() {
801            return Err(Error::AddressOutOfRange(scan_number as u64));
802        }
803        let entry = &self.scan_index[idx];
804        read_flat_peaks(source, self.data_addr, entry.offset, entry.data_size)
805    }
806
807    /// Read a single scan in v66 SRM format (TSQ Quantiva / TSQ Altis).
808    ///
809    /// `entry.offset` is the START byte offset within the data stream.
810    /// The record is fixed-size (`entry.data_size` bytes) and contains:
811    ///   n_peaks (u32), header, m/z window table, then peak triplets.
812    pub fn read_scan_srm_v66<R: Read + Seek>(
813        &self,
814        source: &mut R,
815        scan_number: u32,
816    ) -> Result<Vec<Peak>> {
817        let idx = (scan_number - self.run_header.sample_info.first_scan_number) as usize;
818        if idx >= self.scan_index.len() {
819            return Err(Error::AddressOutOfRange(scan_number as u64));
820        }
821        let entry = &self.scan_index[idx];
822        read_scan_srm_v66(source, self.data_addr, entry.offset, entry.data_size)
823    }
824
825    /// Read a single scan's peaks using whichever decoder matches this file's
826    /// scan-data format.
827    ///
828    /// This is the recommended high-level entry point. It dispatches on
829    /// [`Self::scan_format`] so callers do not have to know whether a file is
830    /// a TSQ SRM run (flat peaks) or an Orbitrap/ion-trap acquisition
831    /// (PacketHeader records).
832    ///
833    /// The returned `Vec<Peak>` contains centroided peaks regardless of the
834    /// underlying format. For PacketHeader files that also contain a profile
835    /// signal, use [`Self::read_scan`] to access both.
836    pub fn read_scan_peaks<R: Read + Seek>(
837        &self,
838        source: &mut R,
839        scan_number: u32,
840    ) -> Result<Vec<Peak>> {
841        use crate::scan_format::ScanDataFormat;
842        match self.scan_format {
843            ScanDataFormat::PacketHeader => {
844                let pkt = self.read_scan(source, scan_number)?;
845                Ok(pkt.peaks)
846            }
847            ScanDataFormat::FlatV63 => self.read_scan_flat(source, scan_number),
848            ScanDataFormat::FlatV66 => self.read_scan_srm_v66(source, scan_number),
849        }
850    }
851
852    /// Read centroided peaks only, skipping profile data.
853    ///
854    /// For PacketHeader files (Orbitrap / ion-trap), this skips the large
855    /// profile-data section, making it 2-10× faster than
856    /// [`Self::read_scan_peaks`] when only centroided m/z and intensity values
857    /// are needed (e.g. mzML export, peak area queries).
858    ///
859    /// For TSQ/SRM files this is identical to [`Self::read_scan_peaks`].
860    pub fn read_peaks_only<R: Read + Seek>(
861        &self,
862        source: &mut R,
863        scan_number: u32,
864    ) -> Result<Vec<Peak>> {
865        use crate::scan_format::ScanDataFormat;
866        match self.scan_format {
867            ScanDataFormat::PacketHeader => {
868                let idx = (scan_number - self.run_header.sample_info.first_scan_number) as usize;
869                if idx >= self.scan_index.len() {
870                    return Err(Error::AddressOutOfRange(scan_number as u64));
871                }
872                let entry = &self.scan_index[idx];
873                let abs_offset = self.data_addr + entry.offset;
874                source.seek(SeekFrom::Start(abs_offset))?;
875                let mut r = BinaryReader::new(source);
876                ScanDataPacket::read_peaks_only(&mut r)
877            }
878            ScanDataFormat::FlatV63 => self.read_scan_flat(source, scan_number),
879            ScanDataFormat::FlatV66 => self.read_scan_srm_v66(source, scan_number),
880        }
881    }
882
883    /// Return the scan-parameter record for a given 1-based scan number.
884    ///
885    /// Returns `None` if the file has no scan-parameter stream or if
886    /// `scan_number` is outside the valid scan range.
887    pub fn scan_parameters(&self, scan_number: u32) -> Option<&GenericRecord> {
888        let first = self.run_header.sample_info.first_scan_number;
889        let idx = scan_number.checked_sub(first)? as usize;
890        self.scan_parameters.get(idx)
891    }
892
893    /// Return a typed view of the scan-parameter record for a given scan.
894    ///
895    /// This wraps [`Self::scan_parameters`] in a [`ScanParams`] accessor that
896    /// provides named, type-safe fields and handles label-name variations
897    /// across instrument families.
898    pub fn scan_params(&self, scan_number: u32) -> Option<ScanParams<'_>> {
899        self.scan_parameters(scan_number).map(ScanParams)
900    }
901
902    /// Return the raw instrument-log record for a given scan number, or
903    /// `None` if the scan is out of range or no instrument log was found.
904    ///
905    /// The instrument log contains per-scan instrument-state values:
906    /// temperatures, voltages, pressures, ion counts, etc.
907    pub fn inst_log_record(&self, scan_number: u32) -> Option<&GenericRecord> {
908        let first = self.run_header.sample_info.first_scan_number;
909        let idx = scan_number.checked_sub(first)? as usize;
910        self.inst_log.get(idx)
911    }
912
913    /// Return a typed [`StatusLogEntry`] view for the given scan number.
914    ///
915    /// This wraps [`Self::inst_log_record`] and provides named, type-safe
916    /// accessors for common instrument-status fields.
917    pub fn status_log_entry(&self, scan_number: u32) -> Option<StatusLogEntry<'_>> {
918        self.inst_log_record(scan_number).map(StatusLogEntry)
919    }
920
921    /// Return the canonical Thermo scan filter string for a given scan
922    /// (1-based scan number), or `None` if the scan is out of range.
923    ///
924    /// Example output: `"FTMS + p NSI Full ms [350.0000-1500.0000]"`.
925    ///
926    /// See [`crate::scan_filter`] for grammar details.
927    pub fn scan_filter(&self, scan_number: u32) -> Option<String> {
928        let first = self.run_header.sample_info.first_scan_number;
929        let idx = scan_number.checked_sub(first)? as usize;
930        let entry = self.scan_index.get(idx)?;
931
932        // SRM files have no scan events; build the filter string from
933        // the pre-loaded Q1 and Q3 window maps.
934        if self.flat_peaks {
935            let q1 = self.srm_q1_by_event.get(&entry.scan_event).copied()?;
936            let windows = self.srm_q3_windows.get(&entry.scan_event)?;
937            // v63 (TSQ Quantum/Vantage): NSI ionization, @cid{CE:.2} after Q1.
938            // v66 (TSQ Quantiva/Altis): ESI ionization, no CE in filter.
939            use crate::scan_format::ScanDataFormat;
940            let ionization = match self.scan_format {
941                ScanDataFormat::FlatV63 => "NSI",
942                _ => "ESI",
943            };
944            let ce_part = if self.scan_format == ScanDataFormat::FlatV63 {
945                self.srm_ce_by_event
946                    .get(&entry.scan_event)
947                    .map(|&ce| format!("@cid{:.2}", ce))
948                    .unwrap_or_default()
949            } else {
950                String::new()
951            };
952            // Format: "+ c {ION} SRM ms2 {Q1:.3}{@cidCE} [{lo1:.3}-{hi1:.3}, ...]"
953            let mut s = format!("+ c {} SRM ms2 {:.3}{}", ionization, q1, ce_part);
954            if !windows.is_empty() {
955                s.push(' ');
956                s.push('[');
957                for (i, (lo, hi)) in windows.iter().enumerate() {
958                    if i > 0 {
959                        s.push_str(", ");
960                    }
961                    s.push_str(&format!("{:.3}-{:.3}", lo, hi));
962                }
963                s.push(']');
964            }
965            return Some(s);
966        }
967
968        let event = self.scan_events.get(idx)?;
969        // Precursor m/z and activation energy come from the per-scan params
970        // table (not the event body) for v66+. Fall back silently if missing.
971        let params = self.scan_params(scan_number);
972        let precursor = params.as_ref().and_then(|p| p.monoisotopic_mz());
973        let energy = params.as_ref().and_then(|p| p.activation_energy());
974        let supplemental = params
975            .as_ref()
976            .and_then(|p| p.supplemental_activation_energy());
977        Some(crate::scan_filter::build_filter(
978            event,
979            entry,
980            precursor,
981            energy,
982            supplemental,
983        ))
984    }
985
986    /// Return all scan retention times (minutes) in scan order (1-based scan numbers).
987    ///
988    /// This is equivalent to collecting `scan_index[i].start_time` for every scan.
989    /// The returned `Vec` is indexed by `scan_number - first_scan_number`.
990    pub fn retention_times(&self) -> Vec<f64> {
991        self.scan_index.iter().map(|e| e.start_time).collect()
992    }
993
994    /// Return a per-scan chromatogram as `(retention_time_min, tic)` pairs.
995    pub fn tic_chromatogram(&self) -> Vec<(f64, f64)> {
996        self.scan_index
997            .iter()
998            .map(|e| (e.start_time, e.total_current))
999            .collect()
1000    }
1001
1002    /// Return a per-scan base-peak chromatogram as `(retention_time_min, bpi, base_mz)` triples.
1003    pub fn bpc_chromatogram(&self) -> Vec<(f64, f64, f64)> {
1004        self.scan_index
1005            .iter()
1006            .map(|e| (e.start_time, e.base_intensity, e.base_mz))
1007            .collect()
1008    }
1009
1010    /// Return the instrument method file path or name as stored in the
1011    /// sequence row. This is the name of the method used during acquisition
1012    /// (e.g. `"Standard_HCD.meth"`), not the embedded method text.
1013    ///
1014    /// See also [`Self::instrument_method_text`] for extracting the embedded
1015    /// XML/text method body from the file.
1016    pub fn instrument_method_name(&self) -> &str {
1017        &self.seq_row.inst_method
1018    }
1019
1020    /// Attempt to extract the embedded instrument method text from the RAW file.
1021    ///
1022    /// Thermo RAW files embed the acquisition method as a UTF-16LE text or
1023    /// XML blob in the metadata region. This method scans the bytes between
1024    /// the start of the file and the scan data for the longest contiguous
1025    /// block of valid UTF-16LE text (at least 256 characters long) and returns
1026    /// it as a `String`.
1027    ///
1028    /// Returns `None` if no suitable text block is found or if the method was
1029    /// not embedded (`method_file_present == false`).
1030    ///
1031    /// Note: This is a best-effort extraction. The result is the raw text
1032    /// content; callers may wish to trim or parse it further.
1033    pub fn instrument_method_text<R: Read + Seek>(&self, source: &mut R) -> Option<String> {
1034        if !self.raw_file_info.preamble.method_file_present {
1035            return None;
1036        }
1037        // Read metadata region: from byte 0 up to (but not including) scan data.
1038        // Cap at 512 KB to avoid reading very large files entirely.
1039        const MAX_WINDOW: u64 = 512 * 1024;
1040        let window_len = MAX_WINDOW.min(self.data_addr) as usize;
1041        if window_len < 4 {
1042            return None;
1043        }
1044        source.seek(std::io::SeekFrom::Start(0)).ok()?;
1045        let mut buf = vec![0u8; window_len];
1046        source.read_exact(&mut buf).ok()?;
1047
1048        // Scan for the longest valid UTF-16LE text block (min 256 chars = 512 bytes).
1049        // Strategy: find aligned 2-byte sequences where every pair decodes to a
1050        // printable/whitespace Unicode scalar (U+0020..U+FFFD).
1051        extract_utf16le_text(&buf, 256)
1052    }
1053}
1054
1055/// Scan `buf` for the longest contiguous UTF-16LE text block of at least
1056/// `min_chars` characters and return it as a String. Returns `None` if no
1057/// such block exists.
1058fn extract_utf16le_text(buf: &[u8], min_chars: usize) -> Option<String> {
1059    if buf.len() < 2 {
1060        return None;
1061    }
1062    let mut best: Option<String> = None;
1063    let mut best_len = 0usize;
1064
1065    // Try each even alignment (0 or 1 byte offset from start).
1066    for alignment in 0..2usize {
1067        let start = alignment;
1068        let usable = buf.len().saturating_sub(start);
1069        let n_units = usable / 2;
1070        if n_units < min_chars {
1071            continue;
1072        }
1073
1074        let mut run_start = 0usize;
1075        let mut run_chars: Vec<u16> = Vec::with_capacity(min_chars);
1076
1077        let flush = |run_chars: &Vec<u16>,
1078                     run_start: usize,
1079                     best: &mut Option<String>,
1080                     best_len: &mut usize| {
1081            if run_chars.len() >= min_chars {
1082                if let Ok(s) = String::from_utf16(run_chars) {
1083                    let _ = run_start; // suppress unused warning
1084                    if run_chars.len() > *best_len {
1085                        *best_len = run_chars.len();
1086                        *best = Some(s);
1087                    }
1088                }
1089            }
1090        };
1091
1092        for i in 0..n_units {
1093            let off = start + i * 2;
1094            let u = u16::from_le_bytes([buf[off], buf[off + 1]]);
1095            let is_ok = matches!(u, 0x0009 | 0x000A | 0x000D | 0x0020..=0xFFFD);
1096            if is_ok {
1097                run_chars.push(u);
1098            } else {
1099                flush(&run_chars, run_start, &mut best, &mut best_len);
1100                run_start = i + 1;
1101                run_chars.clear();
1102            }
1103        }
1104        flush(&run_chars, run_start, &mut best, &mut best_len);
1105    }
1106    best
1107}
1108
1109// -- High-level typed accessor for scan parameters --
1110
1111/// Typed accessor for a scan's extra parameters (`ScanParams` stream).
1112///
1113/// The underlying [`GenericRecord`] stores named fields whose labels vary
1114/// slightly across Thermo instrument families. This wrapper normalises the
1115/// most common labels so callers do not need to hard-code instrument-specific
1116/// strings.
1117///
1118/// # Example
1119/// ```no_run
1120/// use opentfraw::RawFileReader;
1121/// let raw = RawFileReader::open_path("experiment.raw").unwrap();
1122/// if let Some(p) = raw.scan_params(1) {
1123///     println!("Injection time: {:?} ms", p.ion_injection_time_ms());
1124///     println!("Charge state:   {:?}", p.charge_state());
1125/// }
1126/// ```
1127pub struct ScanParams<'a>(pub &'a GenericRecord);
1128
1129impl<'a> ScanParams<'a> {
1130    /// Return the raw `GenericRecord` for direct field access.
1131    #[inline]
1132    pub fn record(&self) -> &GenericRecord {
1133        self.0
1134    }
1135
1136    /// Ion injection / fill time in milliseconds.
1137    ///
1138    /// Label varies: `"Ion Injection Time (ms):"` (Orbitrap family) vs
1139    /// `"Ion Inject Time (ms):"` (older LTQ variants).
1140    pub fn ion_injection_time_ms(&self) -> Option<f64> {
1141        // Try canonical label first; fall back to legacy label.
1142        self.0
1143            .get_f64("Ion Injection Time (ms):")
1144            .or_else(|| self.0.get_f64("Ion Inject Time (ms):"))
1145    }
1146
1147    /// Precursor charge state (0 = unknown / MS1 scan).
1148    pub fn charge_state(&self) -> Option<i32> {
1149        self.0
1150            .get_i32("Charge State:")
1151            // Some LCQ files use UInt8 for charge state.
1152            .or_else(|| {
1153                self.0.get("Charge State:").and_then(|v| match v {
1154                    GenericValue::UInt8(n) => Some(*n as i32),
1155                    _ => None,
1156                })
1157            })
1158    }
1159
1160    /// Monoisotopic precursor m/z (0 = not determined).
1161    ///
1162    /// Tries multiple label variants for compatibility across instrument families:
1163    ///
1164    /// - `"Monoisotopic M/Z:"` - most common (Q Exactive, Orbitrap Fusion)
1165    /// - `"MS2 Isolation M/Z:"` - some older LTQ firmware
1166    ///
1167    /// Returns `None` when the value is absent or zero (not determined).
1168    pub fn monoisotopic_mz(&self) -> Option<f64> {
1169        let v = self
1170            .0
1171            .get_f64("Monoisotopic M/Z:")
1172            .or_else(|| self.0.get_f64("MS2 Isolation M/Z:"))
1173            .or_else(|| self.0.get_f64("Isolation Center M/Z:"))
1174            .or_else(|| self.0.get_f64("Precursor M/Z:"))?;
1175        if v > 0.0 {
1176            Some(v)
1177        } else {
1178            None
1179        }
1180    }
1181
1182    /// Number of micro-scans averaged into this scan.
1183    pub fn micro_scan_count(&self) -> Option<i32> {
1184        self.0.get_i32("Micro Scan Count:")
1185    }
1186
1187    /// Scan number of the master (MS1) scan that triggered this dependent scan.
1188    /// Returns `None` if this is not a dependent scan.
1189    pub fn master_scan_number(&self) -> Option<i32> {
1190        self.0
1191            .get_i32("Master Scan Number:")
1192            .or_else(|| self.0.get_i32("Master Index:"))
1193    }
1194
1195    /// Orbitrap / FT resolving power (e.g. 60000, 120000).
1196    pub fn ft_resolution(&self) -> Option<i32> {
1197        self.orbitrap_resolution()
1198    }
1199
1200    /// Number of lock masses found / matched.
1201    pub fn number_of_lm_found(&self) -> Option<i32> {
1202        self.number_of_lock_masses()
1203    }
1204
1205    /// Lock-mass m/z correction applied (ppm).
1206    pub fn lm_correction_ppm(&self) -> Option<f64> {
1207        self.lock_mass_correction_ppm()
1208    }
1209
1210    /// AGC target fill value (ion count).
1211    pub fn agc_target(&self) -> Option<i32> {
1212        self.0.get_i32("AGC Target:")
1213    }
1214
1215    /// Whether automated gain control (AGC) was active.
1216    pub fn agc_enabled(&self) -> Option<bool> {
1217        match self.0.get("AGC:")? {
1218            GenericValue::Bool(b) => Some(*b),
1219            GenericValue::String(s) => Some(s.to_ascii_lowercase().contains("on")),
1220            _ => None,
1221        }
1222    }
1223
1224    /// Elapsed scan time in seconds (Orbitrap instruments only).
1225    pub fn elapsed_scan_time_s(&self) -> Option<f64> {
1226        self.0.get_f64("Elapsed Scan Time (sec):")
1227    }
1228
1229    /// Maximum allowed ion injection time in milliseconds.
1230    pub fn max_ion_time_ms(&self) -> Option<f64> {
1231        self.0.get_f64("Max. Ion Time (ms):")
1232    }
1233
1234    /// MSn isolation window width in m/z.
1235    ///
1236    /// Label varies: `"MS2 Isolation Width:"` (most common), `"MSn Isolation Width:"`,
1237    /// or `"Isolation Width (M/Z):"` on some firmware.
1238    pub fn isolation_width_mz(&self) -> Option<f64> {
1239        self.0
1240            .get_f64("MS2 Isolation Width:")
1241            .or_else(|| self.0.get_f64("MSn Isolation Width:"))
1242            .or_else(|| self.0.get_f64("Isolation Width (M/Z):"))
1243            .or_else(|| self.0.get_f64("MS2 Isolation Width (M/Z):"))
1244    }
1245
1246    /// MSn isolation window target m/z (the center of the isolation window).
1247    ///
1248    /// Some instruments write this separately from the precursor m/z; when
1249    /// absent, callers should fall back to [`Self::monoisotopic_mz`] or to
1250    /// the event's first reaction `precursor_mz`.
1251    pub fn isolation_target_mz(&self) -> Option<f64> {
1252        self.0
1253            .get_f64("MS2 Isolation Offset:")
1254            .or_else(|| self.0.get_f64("Target M/Z:"))
1255    }
1256
1257    /// Activation energy (eV or %) for the primary activation step.
1258    ///
1259    /// Tries several label variants present across instrument families.
1260    /// NCE (normalized collision energy) labels are checked first because
1261    /// they reflect the user-set method value and are what reference tools
1262    /// (ThermoRawFileParser, Proteome Discoverer) report.  eV labels are
1263    /// used as a fallback when no NCE label is present.
1264    ///
1265    /// Label priority:
1266    /// 1. `"HCD Energy:"` / `"HCD Energy V:"` / `"CE:"` - NCE string form
1267    /// 2. `"Normalized Collision Energy:"` - ion-trap CID NCE
1268    /// 3. `"HCD Energy (eV):"` - explicit eV label (Q Exactive HF-X, Exploris)
1269    /// 4. `"HCD Energy eV:"` - eV variant
1270    /// 5. `"Collision Energy (eV):"` - ITMS CID eV
1271    pub fn activation_energy(&self) -> Option<f64> {
1272        // NCE labels: preferred because they match the user-set method value.
1273        // Skip 0.0 (sentinel for "not set").
1274        for label in &["HCD Energy:", "HCD Energy V:", "CE:"] {
1275            if let Some(s) = self.0.get_string(label) {
1276                if let Ok(v) = s.trim().trim_end_matches('%').parse::<f64>() {
1277                    if v > 0.0 {
1278                        return Some(v);
1279                    }
1280                }
1281            }
1282        }
1283        if let Some(v) = self
1284            .0
1285            .get_f64("Normalized Collision Energy:")
1286            .filter(|&v| v > 0.0)
1287        {
1288            return Some(v);
1289        }
1290        // eV labels: used when no NCE label is available.
1291        if let Some(v) = self.0.get_f64("HCD Energy (eV):").filter(|&v| v > 0.0) {
1292            return Some(v);
1293        }
1294        if let Some(v) = self.0.get_f64("HCD Energy eV:").filter(|&v| v > 0.0) {
1295            return Some(v);
1296        }
1297        self.0
1298            .get_f64("Collision Energy (eV):")
1299            .filter(|&v| v > 0.0)
1300    }
1301
1302    /// Whether the value returned by [`activation_energy`] is a normalized
1303    /// collision energy (NCE, dimensionless %) rather than an absolute eV value.
1304    ///
1305    /// Returns `true` when `activation_energy` found a value from an NCE label
1306    /// (`HCD Energy:`, `HCD Energy V:`, `CE:`, or `Normalized Collision Energy:`).
1307    /// Returns `false` when only eV labels were present or no energy was found.
1308    pub fn activation_energy_is_nce(&self) -> bool {
1309        // Returns true if activation_energy() took the NCE path.
1310        for label in &["HCD Energy:", "HCD Energy V:", "CE:"] {
1311            if let Some(s) = self.0.get_string(label) {
1312                if let Ok(v) = s.trim().trim_end_matches('%').parse::<f64>() {
1313                    if v > 0.0 {
1314                        return true;
1315                    }
1316                }
1317            }
1318        }
1319        self.0
1320            .get_f64("Normalized Collision Energy:")
1321            .filter(|&v| v > 0.0)
1322            .is_some()
1323    }
1324
1325    /// Supplemental activation energy for EThcD scans (the HCD component).
1326    ///
1327    /// Returns `None` for non-EThcD scans.
1328    pub fn supplemental_activation_energy(&self) -> Option<f64> {
1329        if let Some(v) = self.0.get_f64("Supplemental Activation CE:") {
1330            return Some(v);
1331        }
1332        if let Some(s) = self.0.get_string("Supplemental Activation:") {
1333            return s.trim().trim_end_matches('%').parse::<f64>().ok();
1334        }
1335        None
1336    }
1337
1338    /// All possible charge states reported by the precursor selection algorithm.
1339    ///
1340    /// Returns `None` when the instrument did not report possible charges.
1341    /// Some firmware stores them as a space-delimited string (e.g. `"2 3"`);
1342    /// others use a typed integer for the single selected charge.
1343    pub fn possible_charge_states(&self) -> Option<Vec<u32>> {
1344        // String variant: "2 3 4"
1345        if let Some(s) = self.0.get_string("Possible Charge States:") {
1346            let v: Vec<u32> = s
1347                .split_whitespace()
1348                .filter_map(|t| t.parse::<u32>().ok())
1349                .collect();
1350            if !v.is_empty() {
1351                return Some(v);
1352            }
1353        }
1354        // Integer variant (single charge)
1355        if let Some(c) = self.charge_state() {
1356            if c > 0 {
1357                return Some(vec![c as u32]);
1358            }
1359        }
1360        None
1361    }
1362
1363    /// FAIMS compensation voltage in V (Orbitrap Fusion/Lumos with FAIMS Pro).
1364    pub fn faims_cv(&self) -> Option<f64> {
1365        self.0
1366            .get_f64("FAIMS CV:")
1367            .or_else(|| self.0.get_f32("FAIMS CV:").map(f64::from))
1368    }
1369
1370    /// Whether FAIMS voltage was active for this scan.
1371    pub fn faims_voltage_on(&self) -> Option<bool> {
1372        match self.0.get("FAIMS Voltage On:")? {
1373            GenericValue::Bool(b) => Some(*b),
1374            GenericValue::String(s) => Some(s.to_ascii_lowercase().contains("on")),
1375            _ => None,
1376        }
1377    }
1378
1379    /// S-Lens RF level (V), typically reported on Q Exactive family.
1380    pub fn s_lens_rf_level(&self) -> Option<f64> {
1381        self.0.get_f64("S-Lens RF Level:")
1382    }
1383
1384    /// AGC fill percentage (0.0-1.0), reported on Q Exactive HF family.
1385    pub fn agc_fill(&self) -> Option<f64> {
1386        self.0.get_f64("AGC Fill:")
1387    }
1388
1389    /// Orbitrap analyzer temperature (°C), where available.
1390    pub fn analyzer_temperature(&self) -> Option<f64> {
1391        self.0.get_f64("Analyzer Temperature:")
1392    }
1393
1394    /// PS injection time in milliseconds (pre-scan injection for Q Exactive).
1395    pub fn ps_injection_time_ms(&self) -> Option<f64> {
1396        self.0.get_f64("PS Inj. Time (ms):")
1397    }
1398
1399    /// Reagent ion injection time in milliseconds (ETD reagent).
1400    pub fn reagent_ion_injection_time_ms(&self) -> Option<f64> {
1401        self.0
1402            .get_f32("Reagent Ion Injection Time (ms):")
1403            .map(f64::from)
1404    }
1405
1406    /// Whether the reagent AGC was active.
1407    pub fn reagent_ion_agc(&self) -> Option<bool> {
1408        match self.0.get("Reagent Ion AGC:")? {
1409            GenericValue::Bool(b) => Some(*b),
1410            _ => None,
1411        }
1412    }
1413
1414    /// Source CID energy applied in the ion source (eV).
1415    pub fn source_cid_energy_ev(&self) -> Option<f64> {
1416        self.0
1417            .get_f64("Source CID eV:")
1418            .or_else(|| self.0.get_f32("API Source CID Energy:").map(f64::from))
1419    }
1420
1421    /// Dynamic retention time shift in minutes (Q Exactive HF-X AutoQC).
1422    pub fn dynamic_rt_shift_min(&self) -> Option<f64> {
1423        self.0.get_f64("Dynamic RT Shift (min):")
1424    }
1425
1426    /// Lock mass correction applied (ppm) - tries several label variants.
1427    pub fn lock_mass_correction_ppm(&self) -> Option<f64> {
1428        self.0
1429            .get_f64("LM Correction (ppm):")
1430            .or_else(|| self.0.get_f64("LM m/z-Correction (ppm):"))
1431    }
1432
1433    /// Number of lock masses found.
1434    pub fn number_of_lock_masses(&self) -> Option<i32> {
1435        self.0
1436            .get_i32("Number of LM Found:")
1437            .or_else(|| self.0.get_i32("Number of Lock Masses:"))
1438    }
1439
1440    /// Orbitrap resolution setting (not measured, but requested).
1441    pub fn orbitrap_resolution(&self) -> Option<i32> {
1442        self.0
1443            .get_i32("Orbitrap Resolution:")
1444            .or_else(|| self.0.get_i32("FT Resolution:"))
1445    }
1446
1447    /// SPS (Synchronous Precursor Selection) mass for MS3 channel N (0-based index).
1448    ///
1449    /// SPS masses are stored as `"SPS Mass 1:"`, `"SPS Mass 2:"`, ... (1-based).
1450    pub fn sps_mass(&self, channel: usize) -> Option<f32> {
1451        let label = format!("SPS Mass {}:", channel + 1);
1452        self.0.get_f32(&label)
1453    }
1454
1455    /// Conversion parameter A (Orbitrap m/z conversion polynomial).
1456    pub fn conversion_parameter_a(&self) -> Option<f64> {
1457        self.0.get_f64("Conversion Parameter A:")
1458    }
1459
1460    /// Conversion parameter B.
1461    pub fn conversion_parameter_b(&self) -> Option<f64> {
1462        self.0.get_f64("Conversion Parameter B:")
1463    }
1464
1465    /// Conversion parameter C.
1466    pub fn conversion_parameter_c(&self) -> Option<f64> {
1467        self.0.get_f64("Conversion Parameter C:")
1468    }
1469
1470    /// Raw over-fill time T (used for AGC computation).
1471    pub fn raw_ovft(&self) -> Option<f64> {
1472        self.0.get_f64("RawOvFtT:")
1473    }
1474
1475    /// Error in the isotopic envelope fit (used for charge-state scoring).
1476    pub fn isotopic_fit_error(&self) -> Option<f64> {
1477        self.0.get_f64("Error in isotopic envelope fit:")
1478    }
1479
1480    /// Scan description string (arbitrary text, set by method or real-time software).
1481    pub fn scan_description(&self) -> Option<&str> {
1482        self.0.get_string("Scan Description:")
1483    }
1484
1485    /// Multi-inject info string (e.g. `"IT=45 "` for ion-trap fill time).
1486    pub fn multi_inject_info(&self) -> Option<&str> {
1487        self.0.get_string("Multi Inject Info:")
1488    }
1489
1490    /// HCD energy string - raw value as stored (may be `"28.00"`, `"28%"`, or `"N/A"`).
1491    pub fn hcd_energy(&self) -> Option<&str> {
1492        self.0
1493            .get_string("HCD Energy:")
1494            .or_else(|| self.0.get_string("HCD Energy V:"))
1495    }
1496}
1497
1498// -- Status log (instrument log) typed accessor --
1499
1500/// Typed accessor for a per-scan instrument-status log entry.
1501///
1502/// The instrument log records instrument-state values (temperatures, voltages,
1503/// pressures, etc.) at the time each scan was acquired. The schema varies
1504/// across instrument models.
1505pub struct StatusLogEntry<'a>(pub &'a GenericRecord);
1506
1507impl<'a> StatusLogEntry<'a> {
1508    /// Return the raw record for direct field access.
1509    #[inline]
1510    pub fn record(&self) -> &GenericRecord {
1511        self.0
1512    }
1513
1514    /// Ion injection time in milliseconds (present on Orbitrap family).
1515    pub fn ion_injection_time_ms(&self) -> Option<f64> {
1516        self.0
1517            .get_f64("Ion Injection Time (ms):")
1518            .or_else(|| self.0.get_f64("Ion Inject Time (ms):"))
1519    }
1520
1521    /// Orbitrap / FT resolving power setting.
1522    pub fn ft_resolution(&self) -> Option<i32> {
1523        self.0
1524            .get_i32("Orbitrap Resolution:")
1525            .or_else(|| self.0.get_i32("FT Resolution:"))
1526    }
1527
1528    /// FAIMS compensation voltage (V).
1529    pub fn faims_cv(&self) -> Option<f64> {
1530        self.0
1531            .get_f64("FAIMS CV:")
1532            .or_else(|| self.0.get_f32("FAIMS CV:").map(f64::from))
1533    }
1534
1535    /// S-Lens RF level (V).
1536    pub fn s_lens_rf_level(&self) -> Option<f64> {
1537        self.0.get_f64("S-Lens RF Level:")
1538    }
1539
1540    /// Orbitrap / analyzer temperature (°C).
1541    pub fn analyzer_temperature(&self) -> Option<f64> {
1542        self.0
1543            .get_f64("Analyzer Temperature:")
1544            .or_else(|| self.0.get_f32("Analyzer Temperature:").map(f64::from))
1545    }
1546
1547    /// API (spray) source voltage (V).
1548    pub fn spray_voltage(&self) -> Option<f64> {
1549        self.0
1550            .get_f64("Spray Voltage (V):")
1551            .or_else(|| self.0.get_f64("Spray Voltage:"))
1552            .or_else(|| self.0.get_f32("Spray Voltage:").map(f64::from))
1553    }
1554
1555    /// Lock mass reference correction (ppm).
1556    pub fn lock_mass_correction_ppm(&self) -> Option<f64> {
1557        self.0
1558            .get_f64("LM Correction (ppm):")
1559            .or_else(|| self.0.get_f64("LM m/z-Correction (ppm):"))
1560    }
1561
1562    /// Capillary temperature (°C).
1563    pub fn capillary_temperature(&self) -> Option<f64> {
1564        self.0
1565            .get_f64("Capillary Temp (°C):")
1566            .or_else(|| self.0.get_f64("Capillary Temp:"))
1567            .or_else(|| self.0.get_f32("Capillary Temp:").map(f64::from))
1568    }
1569
1570    /// Number of lock masses found.
1571    pub fn number_of_lock_masses(&self) -> Option<i32> {
1572        self.0
1573            .get_i32("Number of LM Found:")
1574            .or_else(|| self.0.get_i32("Number of Lock Masses:"))
1575    }
1576
1577    /// Get any field by name (pass-through to the underlying record).
1578    pub fn get(&self, label: &str) -> Option<&GenericValue> {
1579        self.0.get(label)
1580    }
1581
1582    /// Get a float64 field by name.
1583    pub fn get_f64(&self, label: &str) -> Option<f64> {
1584        self.0.get_f64(label)
1585    }
1586
1587    /// Get an int32 field by name.
1588    pub fn get_i32(&self, label: &str) -> Option<i32> {
1589        self.0.get_i32(label)
1590    }
1591
1592    /// Get a string field by name.
1593    pub fn get_string(&self, label: &str) -> Option<&str> {
1594        self.0.get_string(label)
1595    }
1596}