From bc967b1895fc83e56faf86037198941617b8fa29 Mon Sep 17 00:00:00 2001 From: Roderick Date: Wed, 10 Jul 2024 20:28:48 -0700 Subject: [PATCH] Add parser for Chemstation 179 file type. I don't have any 181 files to test with, but it should be easy to adapt the new code to them too. I rewrote how metadata is extracted from these files too. Closes #42. --- entab-cli/src/lib.rs | 2 +- entab-js/src/lib.rs | 8 +- entab/fuzz/Cargo.lock | 24 +- entab/src/filetype.rs | 32 ++- entab/src/parsers/agilent/chemstation.rs | 184 +------------- entab/src/parsers/agilent/chemstation_new.rs | 247 +++++++++++------- entab/src/parsers/agilent/metadata.rs | 248 +++++++++++++++++++ entab/src/parsers/agilent/mod.rs | 2 + entab/src/parsers/flow.rs | 6 +- entab/src/readers.rs | 3 + entab/tests/DATA_SOURCES.txt | 1 + entab/tests/data/test_179_fid.ch | Bin 0 -> 102144 bytes 12 files changed, 457 insertions(+), 300 deletions(-) create mode 100644 entab/src/parsers/agilent/metadata.rs create mode 100644 entab/tests/data/test_179_fid.ch diff --git a/entab-cli/src/lib.rs b/entab-cli/src/lib.rs index f0b76d5..99562ed 100644 --- a/entab-cli/src/lib.rs +++ b/entab-cli/src/lib.rs @@ -165,7 +165,7 @@ mod tests { run( ["entab", "--metadata"], &b">test\nACGT"[..], - io::Cursor::new(&mut out) + io::Cursor::new(&mut out), )?; assert_eq!(&out[..], b"key\tvalue\n"); Ok(()) diff --git a/entab-js/src/lib.rs b/entab-js/src/lib.rs index fdc9242..657027e 100644 --- a/entab-js/src/lib.rs +++ b/entab-js/src/lib.rs @@ -76,12 +76,8 @@ impl Reader { #[wasm_bindgen] pub fn next(&mut self) -> Result { if let Some(value) = self.reader.next_record().map_err(to_js)? { - let obj: BTreeMap<&str, Value> = self - .headers - .iter() - .map(AsRef::as_ref) - .zip(value) - .collect(); + let obj: BTreeMap<&str, Value> = + self.headers.iter().map(AsRef::as_ref).zip(value).collect(); serde_wasm_bindgen::to_value(&NextRecord { value: Some(obj), done: false, diff --git a/entab/fuzz/Cargo.lock b/entab/fuzz/Cargo.lock index 2691854..4796b11 100644 --- a/entab/fuzz/Cargo.lock +++ b/entab/fuzz/Cargo.lock @@ -28,9 +28,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bytecount" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" [[package]] name = "bzip2" @@ -154,7 +154,7 @@ checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" [[package]] name = "entab" -version = "0.3.1" +version = "0.3.3" dependencies = [ "bytecount", "bzip2", @@ -224,9 +224,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "miniz_oxide" @@ -324,30 +324,28 @@ dependencies = [ [[package]] name = "zstd" -version = "0.12.3+zstd.1.5.2" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "6.0.5+zstd.1.5.4" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d9e60b4b1758206c238a10165fbcae3ca37b01744e394c463463f6529d23b" +checksum = "fa556e971e7b568dc775c136fc9de8c779b1c2fc3a63defaafadffdbd3181afa" dependencies = [ - "libc", "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.8+zstd.1.5.5" +version = "2.0.12+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" dependencies = [ "cc", - "libc", "pkg-config", ] diff --git a/entab/src/filetype.rs b/entab/src/filetype.rs index 6033eb1..d4db89b 100644 --- a/entab/src/filetype.rs +++ b/entab/src/filetype.rs @@ -32,6 +32,8 @@ pub enum FileType { // chemoinformatics /// Agilent format used for MS-MS trace data AgilentMsMsScan, // bin 0x01, 0x01 + /// Agilent format used for flame ionization data (array-based) + AgilentChemstationArray, /// Agilent format used for UV-visible array data AgilentChemstationDad, /// Agilent format used for flame ionization trace data @@ -123,9 +125,9 @@ impl FileType { b"\x02\x33\x31\x00" => return FileType::AgilentChemstationDad, b"\x02\x38\x31\x00" => return FileType::AgilentChemstationFid, b"\x03\x02\x00\x00" => return FileType::AgilentMasshunterDad, - b"\x03\x31\x33\x30" => return FileType::AgilentChemstationUv, + b"\x03\x31\x33\x30" => return FileType::AgilentChemstationMwd, b"\x03\x31\x33\x31" => return FileType::AgilentChemstationUv, - b"\x03\x31\x37\x39" => return FileType::AgilentChemstationUv, + b"\x03\x31\x37\x39" => return FileType::AgilentChemstationArray, b"\x28\xB5\x2F\xFD" => return FileType::Zstd, b"\x4F\x62\x6A\x01" => return FileType::ApacheAvro, b"\xFF\xD8\xFF\xDB" | b"\xFF\xD8\xFF\xE0" | b"\xFF\xD8\xFF\xE1" @@ -140,7 +142,14 @@ impl FileType { } } if magic.len() < 2 { - return FileType::Unknown(Some(magic.iter().take(8).map(|x| format!("{:x}", x)).collect::>().join(""))); + return FileType::Unknown(Some( + magic + .iter() + .take(8) + .map(|x| format!("{:x}", x)) + .collect::>() + .join(""), + )); } match &magic[..2] { [0x0F | 0x1F, 0x8B] => return FileType::Gzip, @@ -154,7 +163,12 @@ impl FileType { b">" => FileType::Fasta, b"@" => FileType::Fastq, _ => FileType::Unknown(Some( - magic.iter().take(8).map(|x| format!("{:x}", x)).collect::>().join("") + magic + .iter() + .take(8) + .map(|x| format!("{:x}", x)) + .collect::>() + .join(""), )), } } @@ -172,6 +186,7 @@ impl FileType { "cdf" => &[FileType::NetCdf], "cf" => &[FileType::ThermoCf], "ch" => &[ + FileType::AgilentChemstationArray, FileType::AgilentChemstationFid, FileType::AgilentChemstationMwd, ], @@ -213,6 +228,7 @@ impl FileType { /// If a file is unsupported, an error will be returned. pub fn to_parser_name<'a>(&self, hint: Option<&'a str>) -> Result<&'a str, EtError> { Ok(match (self, hint) { + (FileType::AgilentChemstationArray, None) => "chemstation_array", (FileType::AgilentChemstationDad, None) => "chemstation_dad", (FileType::AgilentChemstationFid, None) => "chemstation_fid", (FileType::AgilentChemstationMs, None) => "chemstation_ms", @@ -246,6 +262,7 @@ mod tests { #[test] fn test_parser_names() { let filetypes = [ + (FileType::AgilentChemstationArray, "chemstation_array"), (FileType::AgilentChemstationFid, "chemstation_fid"), (FileType::AgilentChemstationMs, "chemstation_ms"), (FileType::AgilentChemstationMwd, "chemstation_mwd"), @@ -273,8 +290,9 @@ mod tests { let unknown_type = FileType::from_magic(b"\x00\x00\x00\x00"); assert_eq!(unknown_type, FileType::Unknown(Some("0000".to_string()))); - assert_eq!(unknown_type.to_parser_name(None).unwrap_err().msg, "File starting with #0000# has no parser"); - - + assert_eq!( + unknown_type.to_parser_name(None).unwrap_err().msg, + "File starting with #0000# has no parser" + ); } } diff --git a/entab/src/parsers/agilent/chemstation.rs b/entab/src/parsers/agilent/chemstation.rs index f837275..0b5b656 100644 --- a/entab/src/parsers/agilent/chemstation.rs +++ b/entab/src/parsers/agilent/chemstation.rs @@ -1,12 +1,11 @@ use alloc::collections::BTreeMap; use alloc::str; -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::marker::Copy; -use chrono::NaiveDateTime; - +use crate::parsers::agilent::metadata::ChemstationMetadata; use crate::parsers::agilent::read_agilent_header; use crate::parsers::{extract, Endian, FromSlice}; use crate::record::{StateMetadata, Value}; @@ -15,177 +14,6 @@ use crate::{impl_reader, impl_record}; const CHEMSTATION_TIME_STEP: f64 = 0.2; -#[derive(Clone, Debug, Default)] -/// Metadata consistly found in Chemstation file formats -pub struct ChemstationMetadata { - /// Time the run started (minutes) - pub start_time: f64, - /// Time the ended started (minutes) - pub end_time: f64, - /// Name of the signal record (specifically used for e.g. MWD traces) - pub signal_name: String, - /// Absolute correction to be applied to all data points - pub offset_correction: f64, - /// Scaling correction to be applied to all data points - pub mult_correction: f64, - /// In what order this run was performed - pub sequence: u16, - /// The vial number this run was performed from - pub vial: u16, - /// The replicate number of this run - pub replicate: u16, - /// The name of the sample - pub sample: String, - /// The description of the sample - pub description: String, - /// The name of the operator - pub operator: String, - /// The date the sample was run - pub run_date: Option, - /// The instrument the sample was run on - pub instrument: String, - /// The method the instrument ran - pub method: String, -} - -impl<'r> From<&ChemstationMetadata> for BTreeMap> { - fn from(metadata: &ChemstationMetadata) -> Self { - let mut map = BTreeMap::new(); - drop(map.insert("start_time".to_string(), metadata.start_time.into())); - drop(map.insert("end_time".to_string(), metadata.end_time.into())); - drop(map.insert( - "signal_name".to_string(), - metadata.signal_name.clone().into(), - )); - drop(map.insert( - "offset_correction".to_string(), - metadata.offset_correction.into(), - )); - drop(map.insert( - "mult_correction".to_string(), - metadata.mult_correction.into(), - )); - drop(map.insert("sequence".to_string(), metadata.sequence.into())); - drop(map.insert("vial".to_string(), metadata.vial.into())); - drop(map.insert("replicate".to_string(), metadata.replicate.into())); - drop(map.insert("sample".to_string(), metadata.sample.clone().into())); - drop(map.insert( - "description".to_string(), - metadata.description.clone().into(), - )); - drop(map.insert("operator".to_string(), metadata.operator.clone().into())); - drop(map.insert("run_date".to_string(), metadata.run_date.into())); - drop(map.insert("instrument".to_string(), metadata.instrument.clone().into())); - drop(map.insert("method".to_string(), metadata.method.clone().into())); - map - } -} - -fn get_metadata(header: &[u8], has_signal: bool) -> Result { - if has_signal && header.len() < 652 { - return Err( - EtError::from("Chemstation header needs to be at least 648 bytes long").incomplete(), - ); - } else if !has_signal && header.len() < 512 { - return Err( - EtError::from("Chemstation header needs to be at least 512 bytes long").incomplete(), - ); - } - let start_time = f64::from(i32::extract(&header[282..], &Endian::Big)?) / 60000.; - let end_time = f64::from(i32::extract(&header[286..], &Endian::Big)?) / 60000.; - - let mut offset_correction = 0.; - let mut mult_correction = 1.; - let mut signal_name = ""; - if has_signal { - offset_correction = f64::extract(&header[636..], &Endian::Big)?; - mult_correction = f64::extract(&header[644..], &Endian::Big)?; - - let signal_name_len = usize::from(header[596]); - if signal_name_len > 40 { - return Err("Invalid signal name length".into()); - } - signal_name = str::from_utf8(&header[597..597 + signal_name_len])?.trim(); - } - - let sample_len = usize::from(header[24]); - if sample_len > 60 { - return Err("Invalid sample length".into()); - } - let sample = str::from_utf8(&header[25..25 + sample_len])? - .trim() - .to_string(); - let description_len = usize::from(header[86]); - if description_len > 60 { - return Err("Invalid sample length".into()); - } - let description = str::from_utf8(&header[87..87 + description_len])? - .trim() - .to_string(); - let operator_len = usize::from(header[148]); - if operator_len > 28 { - return Err("Invalid sample length".into()); - } - let operator = str::from_utf8(&header[149..149 + operator_len])? - .trim() - .to_string(); - let run_date_len = usize::from(header[178]); - if run_date_len > 60 { - return Err("Invalid sample length".into()); - } - // We need to detect the date format before we can convert into a - // NaiveDateTime; not sure the format even maps to the file type - // (it may be computer-dependent?) - let raw_run_date = str::from_utf8(&header[179..179 + run_date_len])?.trim(); - let run_date = if let Ok(d) = NaiveDateTime::parse_from_str(raw_run_date, "%d-%b-%y, %H:%M:%S") - { - // format in MWD - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(raw_run_date, "%d %b %y %l:%M %P") { - // format in MS - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(raw_run_date, "%d %b %y %l:%M %P %z") { - // format in MS with timezone - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(raw_run_date, "%m/%d/%y %I:%M:%S %p") { - // format in FID - Some(d) - } else { - None - }; - - let instrument_len = usize::from(header[208]); - let instrument = str::from_utf8(&header[209..209 + instrument_len])? - .trim() - .to_string(); - let method_len = usize::from(header[228]); - let method = str::from_utf8(&header[229..229 + method_len])? - .trim() - .to_string(); - - // not sure how robust the following are - let sequence = u16::extract(&header[252..], &Endian::Big)?; - let vial = u16::extract(&header[254..], &Endian::Big)?; - let replicate = u16::extract(&header[256..], &Endian::Big)?; - - Ok(ChemstationMetadata { - start_time, - end_time, - signal_name: signal_name.to_string(), - offset_correction, - mult_correction, - sequence, - vial, - replicate, - sample, - description, - operator, - run_date, - instrument, - method, - }) -} - #[derive(Clone, Debug, Default)] /// Internal state for the `ChemstationFidRecord` parser pub struct ChemstationFidState { @@ -220,7 +48,7 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationFidState { } fn get(&mut self, rb: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { - let metadata = get_metadata(rb, true)?; + let metadata = ChemstationMetadata::from_header(rb)?; // offset the current time back one step so it'll be right after the first time that parse self.cur_time = metadata.start_time - CHEMSTATION_TIME_STEP; self.cur_intensity = 0.; @@ -319,7 +147,7 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationMsState { } fn get(&mut self, buffer: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { - let metadata = get_metadata(buffer, true)?; + let metadata = ChemstationMetadata::from_header(buffer)?; let n_scans = u32::extract(&buffer[278..], &Endian::Big)? as usize; self.n_scans_left = n_scans; @@ -437,7 +265,7 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationMwdState { } fn get(&mut self, buf: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { - let metadata = get_metadata(buf, true)?; + let metadata = ChemstationMetadata::from_header(buf)?; self.n_wvs_left = 0; // offset the current time back one step so it'll be right after the first time that parse @@ -557,7 +385,7 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationDadState { } fn get(&mut self, buf: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { - let metadata = get_metadata(buf, false)?; + let metadata = ChemstationMetadata::from_header(buf)?; let n_scans = u32::extract(&buf[278..], &Endian::Big)? as usize; self.n_scans_left = n_scans; diff --git a/entab/src/parsers/agilent/chemstation_new.rs b/entab/src/parsers/agilent/chemstation_new.rs index db9f231..cf5ee0e 100644 --- a/entab/src/parsers/agilent/chemstation_new.rs +++ b/entab/src/parsers/agilent/chemstation_new.rs @@ -1,110 +1,21 @@ use alloc::collections::BTreeMap; use alloc::str; -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec; use alloc::vec::Vec; -use core::char::{decode_utf16, REPLACEMENT_CHARACTER}; use core::marker::Copy; -use chrono::NaiveDateTime; - +use crate::parsers::agilent::metadata::ChemstationMetadata; use crate::parsers::agilent::read_agilent_header; use crate::parsers::{extract, Endian, FromSlice}; use crate::record::{StateMetadata, Value}; use crate::EtError; use crate::{impl_reader, impl_record}; -#[derive(Clone, Debug, Default)] -/// Metadata consistly found in new Chemstation file formats -pub struct ChemstationNewMetadata { - /// Scaling correction to be applied to all data points - pub mult_correction: f64, - /// The name of the sample - pub sample: String, - /// The name of the operator - pub operator: String, - /// The date the sample was run - pub run_date: Option, - /// The instrument the sample was run on - pub instrument: String, - /// The method the instrument ran - pub method: String, -} - -impl<'r> From<&ChemstationNewMetadata> for BTreeMap> { - fn from(metadata: &ChemstationNewMetadata) -> Self { - let mut map = BTreeMap::new(); - drop(map.insert( - "mult_correction".to_string(), - metadata.mult_correction.into(), - )); - drop(map.insert("sample".to_string(), metadata.sample.clone().into())); - drop(map.insert("operator".to_string(), metadata.operator.clone().into())); - drop(map.insert("run_date".to_string(), metadata.run_date.into())); - drop(map.insert("instrument".to_string(), metadata.instrument.clone().into())); - drop(map.insert("method".to_string(), metadata.method.clone().into())); - map - } -} - -fn get_utf16_pascal(data: &[u8]) -> String { - let iter = (1..=2 * usize::from(data[0])) - .step_by(2) - .map(|i| u16::from_le_bytes([data[i], data[i + 1]])); - decode_utf16(iter) - .map(|r| r.unwrap_or(REPLACEMENT_CHARACTER)) - .collect::() -} - -fn get_new_metadata(header: &[u8]) -> Result { - if header.len() < 4000 { - return Err( - EtError::from("New chemstation header needs to be at least 4000 bytes long") - .incomplete(), - ); - } - // Also, @ 3093 - Units? - let sample = get_utf16_pascal(&header[858..]); - let operator = get_utf16_pascal(&header[1880..]); - let instrument = get_utf16_pascal(&header[2492..]); - let method = get_utf16_pascal(&header[2574..]); - let mult_correction = f64::extract(&header[3085..3093], &Endian::Big)?; - - // We need to detect the date format before we can convert into a - // NaiveDateTime; not sure the format even maps to the file type - // (it may be computer-dependent?) - let raw_run_date = get_utf16_pascal(&header[2391..]); - let run_date = if let Ok(d) = NaiveDateTime::parse_from_str(&raw_run_date, "%d-%b-%y, %H:%M:%S") - { - // format in MWD - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(&raw_run_date, "%d %b %y %l:%M %P") { - // format in MS - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(&raw_run_date, "%d %b %y %l:%M %P %z") { - // format in MS with timezone - Some(d) - } else if let Ok(d) = NaiveDateTime::parse_from_str(&raw_run_date, "%m/%d/%y %I:%M:%S %p") { - // format in FID - Some(d) - } else { - None - }; - - Ok(ChemstationNewMetadata { - mult_correction, - sample, - operator, - run_date, - instrument, - method, - }) -} - #[derive(Clone, Debug, Default)] /// Internal state for the `ChemstationUvRecord` parser pub struct ChemstationUvState { - metadata: ChemstationNewMetadata, + metadata: ChemstationMetadata, n_scans_left: usize, n_wvs_left: usize, cur_time: f64, @@ -139,7 +50,7 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationUvState { fn get(&mut self, rb: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { let n_scans = u32::extract(&rb[278..], &Endian::Big)? as usize; - self.metadata = get_new_metadata(rb)?; + self.metadata = ChemstationMetadata::from_header(rb)?; self.n_scans_left = n_scans; self.n_wvs_left = 0; self.cur_time = 0.; @@ -179,9 +90,9 @@ impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationUvRecord { let con = &mut 0; // refill case let mut n_wvs_left = state.n_wvs_left; + // if n_wvs_left == 0 { let _ = extract::<&[u8]>(rb, con, &mut 4)?; // 67, 624/224 - // let next_pos = usize::from(rb.extract::(Endian::Little)?); state.cur_time = f64::from(extract::(rb, con, &mut Endian::Little)?) / 60000.; let wv_start: u16 = extract(rb, con, &mut Endian::Little)?; let wv_end: u16 = extract(rb, con, &mut Endian::Little)?; @@ -232,6 +143,135 @@ impl_reader!( () ); +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +/// The type of the records in the array. +pub enum ChemstationArrayRecordType { + #[default] + /// All of the values are stored as f32 + Float32Array, + /// All of the values are stored as f64 + Float64Array, +} + +#[derive(Clone, Debug, Default)] +/// Internal state for the `ChemstationArrayRecord` parser +pub struct ChemstationArrayState { + metadata: ChemstationMetadata, + record_type: ChemstationArrayRecordType, + n_scans_left: usize, + cur_time: f64, + time_step: f64, +} + +impl StateMetadata for ChemstationArrayState { + fn metadata(&self) -> BTreeMap { + (&self.metadata).into() + } + + fn header(&self) -> Vec<&str> { + vec!["time", "intensity"] + } +} + +impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationArrayState { + type State = (); + + fn parse( + rb: &[u8], + _eof: bool, + consumed: &mut usize, + _state: &mut Self::State, + ) -> Result { + *consumed += read_agilent_header(rb, false)?; + Ok(true) + } + + fn get(&mut self, rb: &'b [u8], _state: &'s Self::State) -> Result<(), EtError> { + self.metadata = ChemstationMetadata::from_header(rb)?; + + let record_type = if &rb[348..352] == b"G\x00C\x00" + || &rb[3090..3104] == b"M\x00u\x00s\x00t\x00a\x00n\x00g\x00" + { + ChemstationArrayRecordType::Float64Array + } else { + ChemstationArrayRecordType::Float32Array + }; + + let tstep_num = u16::extract(&rb[4122..], &Endian::Big)? as f64; + let tstep_denom = u16::extract(&rb[4124..], &Endian::Big)? as f64; + let tstep = (tstep_num / tstep_denom) / 60.; + + // The file from issue #42 has 12000 scans, but the field at 278 only says 197? + // The other file I have is correct so maybe that's corrupt, but we're using + // the time step to figure this out for now. + // let n_scans = u32::extract(&rb[278..], &Endian::Big)? as usize; + let n_scans = 1 + ((self.metadata.end_time - self.metadata.start_time) / tstep) as usize; + + self.n_scans_left = dbg!(n_scans); + self.record_type = dbg!(record_type); + self.cur_time = self.metadata.start_time; + self.time_step = dbg!(tstep); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default)] +/// A record from a Chemstation UV file +pub struct ChemstationArrayRecord { + /// The time recorded at + pub time: f64, + /// The intensity recorded + pub intensity: f64, +} + +impl_record!(ChemstationArrayRecord: time, intensity); + +impl<'b: 's, 's> FromSlice<'b, 's> for ChemstationArrayRecord { + type State = ChemstationArrayState; + + fn parse( + _rb: &[u8], + _eof: bool, + consumed: &mut usize, + state: &mut Self::State, + ) -> Result { + if state.n_scans_left == 0 { + return Ok(false); + } + *consumed += match state.record_type { + ChemstationArrayRecordType::Float32Array => 4, + ChemstationArrayRecordType::Float64Array => 8, + }; + state.n_scans_left -= 1; + state.cur_time += state.time_step; + Ok(true) + } + + fn get(&mut self, rb: &'b [u8], state: &'s Self::State) -> Result<(), EtError> { + let con = &mut 0; + let intensity = match state.record_type { + ChemstationArrayRecordType::Float32Array => { + extract::(rb, con, &mut Endian::Little)? as f64 + } + ChemstationArrayRecordType::Float64Array => { + extract::(rb, con, &mut Endian::Little)? + } + }; + + self.time = state.cur_time; + self.intensity = intensity * state.metadata.mult_correction; + Ok(()) + } +} + +impl_reader!( + ChemstationArrayReader, + ChemstationArrayRecord, + ChemstationArrayRecord, + ChemstationArrayState, + () +); + #[cfg(test)] mod tests { use super::*; @@ -269,4 +309,23 @@ mod tests { assert_eq!(n_mzs, 6744 * 301); Ok(()) } + + #[test] + fn test_array_chemstation_reader() -> Result<(), EtError> { + let data: &[u8] = include_bytes!("../../../tests/data/test_179_fid.ch"); + let mut reader = ChemstationArrayReader::new(data, None)?; + let _ = reader.metadata(); + assert_eq!(reader.headers(), ["time", "intensity"]); + + let ChemstationArrayRecord { time, intensity } = dbg!(reader.next()?.unwrap()); + assert!((time - 0.00166095).abs() < 0.000001); + assert_eq!(intensity, 7.7457031249999995); + + let mut n_mzs = 1; + while reader.next()?.is_some() { + n_mzs += 1; + } + assert_eq!(n_mzs, 12000); + Ok(()) + } } diff --git a/entab/src/parsers/agilent/metadata.rs b/entab/src/parsers/agilent/metadata.rs new file mode 100644 index 0000000..928c917 --- /dev/null +++ b/entab/src/parsers/agilent/metadata.rs @@ -0,0 +1,248 @@ +use alloc::collections::BTreeMap; +use alloc::str; +use alloc::string::{String, ToString}; +use core::char::{decode_utf16, REPLACEMENT_CHARACTER}; + +use chrono::NaiveDateTime; + +use crate::parsers::{Endian, FromSlice}; +use crate::record::Value; +use crate::EtError; + +#[derive(Clone, Debug, Default)] +/// Metadata consistly found in Chemstation file formats +pub struct ChemstationMetadata { + /// The time the run started collecting at in minutes + pub start_time: f64, + /// The time the run stopped collecting at in minutes + pub end_time: f64, + /// Name of the signal record (specifically used for e.g. MWD traces) + pub signal_name: String, + /// Absolute correction to be applied to all data points + pub offset_correction: f64, + /// Scaling correction to be applied to all data points + pub mult_correction: f64, + /// In what order this run was performed + pub sequence: u16, + /// The vial number this run was performed from + pub vial: u16, + /// The replicate number of this run + pub replicate: u16, + /// The name of the sample + pub sample: String, + /// The description of the sample + pub description: String, + /// The name of the operator + pub operator: String, + /// The date the sample was run + pub run_date: Option, + /// The instrument the sample was run on + pub instrument: String, + /// The method the instrument ran + pub method: String, + /// The units of the y scale. + pub y_units: String, +} + +impl ChemstationMetadata { + /// Parse the header to extract the metadata + pub fn from_header(header: &[u8]) -> Result { + if header.len() < 256 { + return Err(EtError::from( + "All Chemstation header needs to be at least 256 bytes long", + ) + .incomplete()); + } + let version = u32::extract(&header[248..], &Endian::Big)?; + + let required_length = match version { + 2 | 102 => 512, + 30 | 31 | 81 => 652, + 131 => 4000, + 130 | 179 => 4800, + _ => usize::MAX, + }; + if header.len() < required_length { + return Err(EtError::from(format!( + "Chemstation {} header needs to be at least {} bytes long", + version, required_length + )) + .incomplete()); + } + + // 258..260 - 0 or 1 + // 260..264 - 0 or large int (/60000?) + // 254..268 - 9 or 13 + // only in 179 and 130 + // 290..294 - 63429.0 - f32 / 930051 - i32 + // 294..298 - 0 / -22385 + // 298..302 - repeat of 290 + // 302..306 - repeat of 294 + + // There's another data section at 4100 that + // has duplicates of some of these values? + + let sequence = u16::extract(&header[252..], &Endian::Big)?; + let vial = u16::extract(&header[254..], &Endian::Big)?; + let replicate = u16::extract(&header[256..], &Endian::Big)?; + + let sample = match version { + 0..=102 => get_pascal(&header[24..24 + 60], "sample")?, + _ => get_utf16_pascal(&header[858..]), + }; + let description = match version { + 0..=102 => get_pascal(&header[86..86 + 60], "description")?, + _ => "".to_string(), + }; + let operator = match version { + 0..=102 => get_pascal(&header[148..148 + 28], "operator")?, + _ => get_utf16_pascal(&header[1880..]), + }; + let instrument = match version { + 0..=102 => get_pascal(&header[208..228], "instrument")?, + _ => get_utf16_pascal(&header[2492..]), + }; + let method = match version { + 0..=102 => get_pascal(&header[228..], "method")?, + _ => get_utf16_pascal(&header[2574..]), + }; + + let signal_name = match version { + 30 | 31 | 81 => get_pascal(&header[596..596 + 40], "signal_name")?, + 130 | 179 => get_utf16_pascal(&header[4213..]), + _ => "".to_string(), + }; + + let offset_correction = match version { + 30 | 31 | 81 => f64::extract(&header[636..], &Endian::Big)?, + _ => 0., + }; + let mult_correction = match version { + 30 | 31 | 81 => f64::extract(&header[644..], &Endian::Big)?, + 131 => f64::extract(&header[3085..3093], &Endian::Big)?, + 130 | 179 => f64::extract(&header[4732..4770], &Endian::Big)?, + _ => 1., + }; + let start_time = match version { + 2 | 30 | 31 | 81 | 102 | 130 | 131 => { + i32::extract(&header[282..], &Endian::Big)? as f64 / 60000. + } + 179 => f32::extract(&header[282..], &Endian::Big)? as f64 / 60000., + _ => 0., + }; + let end_time = match version { + 2 | 30 | 31 | 81 | 102 | 130 | 131 => { + i32::extract(&header[286..], &Endian::Big)? as f64 / 60000. + } + 179 => f32::extract(&header[286..], &Endian::Big)? as f64 / 60000., + _ => 0., + }; + let y_units = match version { + 81 => get_pascal(&header[244..244 + 64], "y_units")?, + 131 => get_utf16_pascal(&header[3093..]), + 130 | 179 => get_utf16_pascal(&header[4172..]), + _ => "".to_string(), + }; + + // We need to detect the date format before we can convert into a + // NaiveDateTime; not sure the format even maps to the file type + // (it may be computer-dependent?) + let raw_run_date = match version { + 0..=102 => get_pascal(&header[178..178 + 60], "run_date")?, + 130 | 131 | 179 => get_utf16_pascal(&header[2391..]), + _ => "".to_string(), + }; + let run_date = if let Ok(d) = + NaiveDateTime::parse_from_str(raw_run_date.as_ref(), "%d-%b-%y, %H:%M:%S") + { + // format in MWD + Some(d) + } else if let Ok(d) = + NaiveDateTime::parse_from_str(raw_run_date.as_ref(), "%d %b %y %l:%M %P") + { + // format in MS + Some(d) + } else if let Ok(d) = + NaiveDateTime::parse_from_str(raw_run_date.as_ref(), "%d %b %y %l:%M %P %z") + { + // format in MS with timezone + Some(d) + } else if let Ok(d) = + NaiveDateTime::parse_from_str(raw_run_date.as_ref(), "%m/%d/%y %I:%M:%S %p") + { + // format in FID + Some(d) + } else { + None + }; + + Ok(Self { + start_time, + end_time, + signal_name, + offset_correction, + mult_correction, + sequence, + vial, + replicate, + sample, + description, + operator, + run_date, + instrument, + method, + y_units, + }) + } +} + +impl<'r> From<&ChemstationMetadata> for BTreeMap> { + fn from(metadata: &ChemstationMetadata) -> Self { + let mut map = BTreeMap::new(); + drop(map.insert("start_time".to_string(), metadata.start_time.into())); + drop(map.insert("end_time".to_string(), metadata.end_time.into())); + drop(map.insert( + "signal_name".to_string(), + metadata.signal_name.clone().into(), + )); + drop(map.insert( + "offset_correction".to_string(), + metadata.offset_correction.into(), + )); + drop(map.insert( + "mult_correction".to_string(), + metadata.mult_correction.into(), + )); + drop(map.insert("sequence".to_string(), metadata.sequence.into())); + drop(map.insert("vial".to_string(), metadata.vial.into())); + drop(map.insert("replicate".to_string(), metadata.replicate.into())); + drop(map.insert("sample".to_string(), metadata.sample.clone().into())); + drop(map.insert( + "description".to_string(), + metadata.description.clone().into(), + )); + drop(map.insert("operator".to_string(), metadata.operator.clone().into())); + drop(map.insert("run_date".to_string(), metadata.run_date.into())); + drop(map.insert("instrument".to_string(), metadata.instrument.clone().into())); + drop(map.insert("method".to_string(), metadata.method.clone().into())); + drop(map.insert("y_units".to_string(), metadata.y_units.clone().into())); + map + } +} + +fn get_utf16_pascal(data: &[u8]) -> String { + let iter = (1..=2 * usize::from(data[0])) + .step_by(2) + .map(|i| u16::from_le_bytes([data[i], data[i + 1]])); + decode_utf16(iter) + .map(|r| r.unwrap_or(REPLACEMENT_CHARACTER)) + .collect::() +} + +fn get_pascal(data: &[u8], field_name: &'static str) -> Result { + let string_len = usize::from(data[0]); + if string_len > data.len() { + return Err(EtError::from(format!("Invalid {} length", field_name)).incomplete()); + } + Ok(str::from_utf8(&data[1..1 + string_len])?.trim().to_string()) +} diff --git a/entab/src/parsers/agilent/mod.rs b/entab/src/parsers/agilent/mod.rs index 3af3637..129142c 100644 --- a/entab/src/parsers/agilent/mod.rs +++ b/entab/src/parsers/agilent/mod.rs @@ -8,6 +8,8 @@ pub mod chemstation_new; /// Readers for formats generated by the GC/LC control software Masshunter #[cfg(feature = "std")] pub mod masshunter; +/// Read the common metadata format at the top of Chemstation files +pub mod metadata; use crate::error::EtError; use crate::parsers::common::Skip; diff --git a/entab/src/parsers/flow.rs b/entab/src/parsers/flow.rs index f384386..9d53fc9 100644 --- a/entab/src/parsers/flow.rs +++ b/entab/src/parsers/flow.rs @@ -457,7 +457,11 @@ mod tests { assert_eq!(metadata["specimen_source"], "Specimen_001".into()); assert_eq!( metadata["date"], - NaiveDate::from_ymd_opt(2012, 10, 26).unwrap().and_hms_opt(18, 8, 10).unwrap().into() + NaiveDate::from_ymd_opt(2012, 10, 26) + .unwrap() + .and_hms_opt(18, 8, 10) + .unwrap() + .into() ); Ok(()) } diff --git a/entab/src/readers.rs b/entab/src/readers.rs index 9148b9f..d699f27 100644 --- a/entab/src/readers.rs +++ b/entab/src/readers.rs @@ -41,6 +41,9 @@ fn _get_reader<'n, 'p, 'r>( ) -> Result<(Box, &'n str), EtError> { let reader: Box = match parser_name { "bam" => Box::new(parsers::sam::BamReader::new(rb, None)?), + "chemstation_array" => Box::new(parsers::agilent::chemstation_new::ChemstationArrayReader::new( + rb, None, + )?), "chemstation_dad" => Box::new(parsers::agilent::chemstation::ChemstationDadReader::new( rb, None, )?), diff --git a/entab/tests/DATA_SOURCES.txt b/entab/tests/DATA_SOURCES.txt index be5049c..de74fd2 100644 --- a/entab/tests/DATA_SOURCES.txt +++ b/entab/tests/DATA_SOURCES.txt @@ -13,5 +13,6 @@ test-0000.cf, collected by Roderick, test.bam, generated from test.sam, test.fastq, downloaded from NCBI, test_fid.ch, collected by Roderick, +test_179_fid.ch, from issue #32 test.sam, generated from aligning sequence.fasta against test.fastq, small.RAW, https://github.com/galaxyproteomics/tools-galaxyp/blob/master/tools/msconvert/test-data/small.RAW, CC0 diff --git a/entab/tests/data/test_179_fid.ch b/entab/tests/data/test_179_fid.ch new file mode 100644 index 0000000000000000000000000000000000000000..8c235ded2c27ca72f87c1e92121fb2d15505d395 GIT binary patch literal 102144 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 literal 0 HcmV?d00001