Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 43 additions & 26 deletions mzannotate/src/mzspeclib/spectrum_description.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Handle converting [`Attribute`]s to and from [`SpectrumDescription`]
use std::borrow::Cow;
use std::{borrow::Cow, hint::black_box};

use context_error::{BoxedError, Context, CreateError};
use mzcv::{AccessionCode, Curie, Term, curie, term};
Expand Down Expand Up @@ -287,49 +287,66 @@ pub(crate) fn populate_spectrum_description_from_attributes<'a>(

if let Some((Attribute { value, .. }, context)) =
group.iter().find(|a| a.0.name.accession == curie!(MS:1000894))
&& group.len() == 2
&& group.len() >= 2
{
description.acquisition.scans[0].start_time =
f64::from(value.scalar().to_f32().map_err(|v| {
BoxedError::new(
MzSpecLibErrorKind::Attribute,
"Invalid attribute",
v.to_string(),
context.clone(),
)
})?) / match unit.ok_or_else(|| {
BoxedError::new(
MzSpecLibErrorKind::MissingUnit,
"Invalid attribute",
"MS:1000894|retention time needs a unit",
context.clone(),
)
})? {
Unit::Minute => 60.0,
_ => 1.0, // Assume seconds for anything else
};
let enumerator = f64::from(value.scalar().to_f32().map_err(|v| {
BoxedError::new(
MzSpecLibErrorKind::Attribute,
"Invalid attribute",
v.to_string(),
context.clone(),
)
})?);
let divisor = match unit.ok_or_else(|| {
BoxedError::new(
MzSpecLibErrorKind::MissingUnit,
"Invalid attribute",
"MS:1000894|retention time needs a unit",
context.clone(),
)
})? {
Unit::Minute => 1.0,
Unit::Millisecond => 60.0 * (1000.0),
Unit::Second => 60.0,
_ => {
black_box(()); // We should probably warn here
1.0 // Assume minutes for anything else
}
};

// Start time is meant to be represented in "minutes"
description.acquisition.scans[0].start_time = enumerator / divisor;
} else if let Some((Attribute { value, .. }, context)) =
group.iter().find(|a| a.0.name.accession == curie!(MS:1000896))
&& group.len() == 2
&& group.len() >= 2
{
let rt = f64::from(value.scalar().to_f32().map_err(|v| {
let enumerator = f64::from(value.scalar().to_f32().map_err(|v| {
BoxedError::new(
MzSpecLibErrorKind::Attribute,
"Invalid attribute",
v.to_string(),
context.clone(),
)
})?) / match unit.ok_or_else(|| {
})?);

let denominator = match unit.ok_or_else(|| {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I am struggling a bit on how to represent this in my libraries ... if my library is in "biognosys iRT scale"... it feels wrong to set minutes, when the values can be negative.

BoxedError::new(
MzSpecLibErrorKind::MissingUnit,
"Invalid attribute",
"MS:1000896|normalized retention time needs a unit",
context.clone(),
)
})? {
Unit::Minute => 60.0,
_ => 1.0, // Assume seconds for anything else
Unit::Minute => 1.0,
Unit::Millisecond => 60.0 * (1000.0),
Unit::Second => 60.0,
_ => {
black_box(()); // We should probably warn here
1.0 // Assume minutes for anything else
}
};
let rt = enumerator / denominator;

// This is normalised time so if normal time is already set ignore this param
if description.acquisition.scans[0].start_time == 0.0 {

@jspaezp jspaezp Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This "sentinel value conditional" could introduce a bug if a library has both indexed and non-indexed retention times. since the indexed time CAN have values of 0.0.

Imagine spectrum 1 defines iRT= -5, RT = 5 -> assigns -5 (bc the second pass does not over-write the 0.0) as the RT. then spectrum 2 defined iRT=0.0 and RT = 10 -> assigns 10 (because the second pass does over-write it)

description.acquisition.scans[0].start_time = rt;
Expand Down
63 changes: 63 additions & 0 deletions mzannotate/tests/mzspeclib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,66 @@ fn read_all_files() {
// "Some interpretation attributes unused"
// );
}

#[test]
fn a_second_and_a_minute_declaration_of_one_instant_agree() {
/// The same instant: 1719.50946 s == 28.658491 min.
const LIBRARY: &str = "<mzSpecLib>
MS:1003186|library format version=1.0
<Spectrum=1>
[1]MS:1000894|retention time=1719.509460
[1]UO:0000000|unit=UO:0000010|second
<Peaks>

<Spectrum=2>
[1]MS:1000894|retention time=28.658491
[1]UO:0000000|unit=UO:0000031|minute
<Peaks>

<Spectrum=3>
[1]MS:1000894|retention time=1719509.460
[1]UO:0000000|unit=UO:0000028|millisecond
<Peaks>

# JSP: This is silently replaced to 0.0
# which I am not sure is the correct behavior
# <Spectrum=4>
# [1]MS:1000894|retention time=28.658491
# <Peaks>

<Spectrum=5>
[1]MS:1000894|retention time=1719.509460
[1]UO:0000000|unit=UO:0000010|second
[1]MS:1003174|attribute maximum=1800.0
[1]MS:1003175|attribute minimum=1600.0
<Peaks>
";

let spectra: Vec<_> = MzSpecLibTextParser::open(
LIBRARY.as_bytes(),
None,
&mzcore::ontology::STATIC_ONTOLOGIES,
)
.unwrap()
.map(|s| s.unwrap())
.collect();

let times: Vec<f64> = spectra
.iter()
.map(|x| x.description.acquisition.scans[0].start_time)
.collect();

fn assert_approx_eq(a: f64, b: f64, msg: &str) {
if (a - b).abs() > 1e-4 {
panic!("no aprox eq {a} {b}; {msg}");
}
}

assert_approx_eq(times[0], times[1], "1719.50946 s is 28.658491 min");
assert_approx_eq(times[0], times[2], "1719.50946 s is 1719509.46 ms");
// mzdata documents start_time as minutes (mzdata::/src/spectrum/scan_properties.rs:138). The
// tolerance is for the to_f32 the parser applies on the way in.
for (idx, x) in times.iter().enumerate() {
assert_approx_eq(*x, 28.658_491, &format!("got {x} at idx {idx}"));
}
}