Skip to content

feat(io): propagate retention time from input spectra to mzTab output#657

Open
wsnoble wants to merge 2 commits into
devfrom
propogate-retention-time-rebased
Open

feat(io): propagate retention time from input spectra to mzTab output#657
wsnoble wants to merge 2 commits into
devfrom
propogate-retention-time-rebased

Conversation

@wsnoble

@wsnoble wsnoble commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #427.

  • Added _get_retention_time() helper in dataloaders.py that extracts retention time (in seconds) from mzML, mzXML, and MGF spectrum dicts, returning NaN when the field is absent.
  • The helper is registered as a CustomField on every dataset (both annotated and unannotated), so retention_time is always present in batches.
  • Added retention_time: float = float("nan") field to PepSpecMatch.
  • Both Spec2Pep.predict_step and DbSpec2Pep.predict_step now extract retention_time from the batch and pass it to PepSpecMatch.
  • MztabWriter.save() now writes psm.retention_time to the retention_time mzTab column instead of the hardcoded "null" placeholder.

Test plan

  • Run pytest tests/unit_tests/test_unit.py — 9 new test_get_retention_time cases cover all supported formats (flat mzML/mzXML, nested mzML scanList, MGF params, and missing-key fallback to NaN); no previously-passing tests regress.
  • Run Casanovo on an mzML or MGF file with known retention times and confirm the retention_time column in the output mzTab is populated correctly.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Retention time from MS spectra is now captured, propagated into PSM outputs, and included in mzTab exports.
  • Bug Fixes

    • Improved mzTab export behavior so unknown retention time values are written as null instead of being emitted incorrectly.
  • Tests

    • Added unit tests to validate retention-time extraction across common spectrum metadata formats and to verify mzTab retention-time output.

Reads retention time from mzML, mzXML, and MGF input files via a new
_get_retention_time() helper in the dataloader, stores it on
PepSpecMatch, and writes it to the retention_time column in the mzTab
output instead of the previous "null" placeholder (issue #427).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds end-to-end retention time propagation: a retention_time field is added to PepSpecMatch, a new _get_retention_time helper extracts it from mzML, mzXML, and MGF spectrum dicts in the dataloader, both predict_step methods populate it on output PSMs, and the mzTab writer emits the numeric value instead of always "null".

Changes

Retention Time Propagation Pipeline

Layer / File(s) Summary
PepSpecMatch field and mzTab output
casanovo/data/psm.py, casanovo/data/ms_io.py
PepSpecMatch gains a retention_time: float = float("nan") field. The mzTab writer imports math and conditionally writes the numeric RT or "null" based on math.isnan.
RT extraction helper and dataloader batch field
casanovo/denovo/dataloaders.py
_get_retention_time extracts retention time from mzML/mzXML top-level and nested scanList keys and MGF rtinseconds/rtinsec params, returning NaN when absent. The dataset custom field construction unconditionally adds a retention_time field to all spectrum entries.
predict_step RT propagation
casanovo/denovo/model.py
Both Spec2Pep.predict_step and DbSpec2Pep.predict_step unpack retention_time from the batch and pass it via .item() to each PepSpecMatch constructor.
Tests
tests/unit_tests/test_unit.py
Existing predict-step batch mocks gain a retention_time NaN tensor. New parametrized test_get_retention_time covers all extraction paths and the NaN fallback. New test_mztab_retention_time validates conditional mzTab serialization.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main objective: adding retention time propagation from input spectra to mzTab output.
Linked Issues check ✅ Passed All code changes directly implement the requirement from #427: extracting retention time from input spectra and writing it to mzTab output instead of hardcoded null.
Out of Scope Changes check ✅ Passed All changes are scoped to retention time propagation; no unrelated modifications to other features or systems are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch propogate-retention-time-rebased

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.46%. Comparing base (bfc0874) to head (0e9e068).

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #657      +/-   ##
==========================================
+ Coverage   95.41%   95.46%   +0.05%     
==========================================
  Files          14       14              
  Lines        1656     1677      +21     
==========================================
+ Hits         1580     1601      +21     
  Misses         76       76              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@casanovo/denovo/dataloaders.py`:
- Around line 298-302: The retention_time field is added through custom_fields
during parsing but is bypassed when loading from existing Lance files in the
from_lance branch, causing KeyError failures when predict_step methods in
Spec2Pep and DbSpec2Pep try to access batch["retention_time"]. Ensure
retention_time is available for all Lance-backed datasets by either validating
and enforcing the schema in the from_lance branch (Lines 329-333), or by adding
a fallback mechanism that computes or provides retention_time if it's missing
from older/external Lance files when they are loaded.
- Around line 433-435: The code attempts to access the first element of the scan
list using indexing [0] without verifying that the list is not empty, which
causes an IndexError when scanList.scan is present but empty. Add a guard
condition to check if the scan list has at least one element before attempting
to access it with [0]. Consider either checking the length of the scan list
before indexing, or use a safer approach like checking if the list exists and
has content, and return a default value or None if it does not.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: df356690-8ccf-40c1-adb3-7c79555443cb

📥 Commits

Reviewing files that changed from the base of the PR and between bfc0874 and ad5bcd1.

📒 Files selected for processing (5)
  • casanovo/data/ms_io.py
  • casanovo/data/psm.py
  • casanovo/denovo/dataloaders.py
  • casanovo/denovo/model.py
  • tests/unit_tests/test_unit.py

Comment thread casanovo/denovo/dataloaders.py
Comment thread casanovo/denovo/dataloaders.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR propagates spectrum retention time (RT) metadata from supported input formats (mzML/mzXML/MGF) through batching and PSM objects, and ultimately into the mzTab retention_time output column, addressing issue #427.

Changes:

  • Added _get_retention_time() and registered it as a dataset CustomField so batches include retention_time.
  • Extended PepSpecMatch to store retention_time, and passed it through both de novo and DB prediction paths.
  • Updated MztabWriter.save() to emit numeric RT values (or null when unknown) instead of a hardcoded placeholder.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/unit_tests/test_unit.py Adds unit tests for RT extraction helper and updates mock batches to include retention_time.
casanovo/denovo/model.py Threads retention_time from batch into PepSpecMatch for both Spec2Pep and DbSpec2Pep prediction.
casanovo/denovo/dataloaders.py Introduces _get_retention_time() and registers it as a CustomField on datasets.
casanovo/data/psm.py Adds a retention_time field to PepSpecMatch with NaN default.
casanovo/data/ms_io.py Writes psm.retention_time into mzTab output (or null for NaN).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread casanovo/denovo/model.py Outdated
Comment thread casanovo/denovo/model.py Outdated
Comment thread casanovo/denovo/model.py
Comment thread casanovo/denovo/model.py Outdated
Comment thread casanovo/data/ms_io.py
Comment on lines +297 to +301
(
"null"
if math.isnan(psm.retention_time)
else psm.retention_time
), # retention_time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

todo: Add this test.

- Guard against empty scanList.scan before [0] indexing in
  _get_retention_time (CodeRabbit)
- Fall back to NaN when retention_time is absent from batch so
  pre-existing .lance files don't cause a KeyError (CodeRabbit/Copilot)
- Use float() instead of .item() for robustness with non-tensor scalars
  (Copilot)
- Add test_mztab_retention_time asserting numeric RT is written and NaN
  becomes null in mzTab output (Copilot)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@wsnoble
wsnoble requested a review from Lilferrit June 24, 2026 03:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
casanovo/denovo/dataloaders.py (1)

427-441: 🎯 Functional Correctness | 🟠 Major

Handle unit-annotated retention times from pyteomics—convert minutes/hours to seconds.

The function contract specifies returning retention time in seconds, but float() on pyteomics unitfloat objects strips the unit_info without converting. For mzML/mzXML files where "scan start time" is stored in minutes (unit_info='minute'), this returns the raw numeric value instead of the required seconds. For example, 5.5 minutes becomes 5.5 instead of 330.0 seconds. MGF's "rtinseconds" is unaffected as it is already in seconds.

Suggested fix
 def _get_retention_time(spectrum: dict[str, Any]) -> float:
+    def _to_seconds(rt: Any) -> float:
+        value = float(rt)
+        unit = getattr(rt, "unit_info", None)
+        if unit == "minute":
+            return value * 60.0
+        if unit == "hour":
+            return value * 3600.0
+        return value
+
     # mzML / mzXML top-level keys
     for key in ("scan start time", "retention time", "retentionTime"):
         if key in spectrum:
-            return float(spectrum[key])
+            return _to_seconds(spectrum[key])
+
     # mzML nested scanList
     scans = spectrum.get("scanList", {}).get("scan") or []
     if scans and "scan start time" in scans[0]:
-        return float(scans[0]["scan start time"])
+        return _to_seconds(scans[0]["scan start time"])
+
     # MGF params block
     params = spectrum.get("params", {})
     for key in ("rtinseconds", "rtinsec"):
         if key in params:
-            return float(params[key])
+            return _to_seconds(params[key])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@casanovo/denovo/dataloaders.py` around lines 427 - 441, The function
retrieves retention time values from various sources but uses float() which
strips unit information without converting to the required seconds unit. For the
mzML/mzXML top-level "scan start time" and "retention time" keys, and the nested
"scan start time" within the scanList, check if the retrieved value has a
unit_info attribute indicating the unit is 'minute', and if so multiply by 60 to
convert to seconds before returning. For the mzML nested scanList scan start
time check in scans[0], apply the same unit conversion logic. The MGF params
block keys (rtinseconds and rtinsec) are already in seconds, so those require no
conversion. Ensure all return statements in the function account for proper unit
conversion.
tests/unit_tests/test_unit.py (1)

3220-3245: 🎯 Functional Correctness | 🔵 Trivial

Add unit-aware RT cases to lock the "seconds" contract.

The test set currently validates key presence and shape, but not unit conversion behavior. Since pyteomics can attach unitfloat objects with unit_info attributes (e.g., for minute or hour durations), adding test cases with unit-annotated inputs would prevent regressions if unit normalization logic is implemented.

🧪 Suggested extension
 `@pytest.mark.parametrize`(
     "spectrum, expected",
     [
+        # Unit-annotated times should be normalized to seconds
+        (
+            {"scan start time": pyteomics.auxiliary.structures.unitfloat(2.0, "minute")},
+            120.0,
+        ),
+        (
+            {"scanList": {"scan": [{"scan start time": pyteomics.auxiliary.structures.unitfloat(0.5, "hour")}]}},
+            1800.0,
+        ),
     ],
 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/test_unit.py` around lines 3220 - 3245, The
test_get_retention_time function parametrize decorator currently lacks test
cases with unit-annotated inputs that pyteomics can provide through unitfloat
objects with unit_info attributes. Add new parametrized test cases to the
decorator that include unitfloat objects with different time units (such as
minutes and hours) to verify that the _get_retention_time function correctly
normalizes all retention time values to seconds regardless of their input unit,
ensuring this unit conversion behavior is protected against future regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@casanovo/denovo/dataloaders.py`:
- Around line 427-441: The function retrieves retention time values from various
sources but uses float() which strips unit information without converting to the
required seconds unit. For the mzML/mzXML top-level "scan start time" and
"retention time" keys, and the nested "scan start time" within the scanList,
check if the retrieved value has a unit_info attribute indicating the unit is
'minute', and if so multiply by 60 to convert to seconds before returning. For
the mzML nested scanList scan start time check in scans[0], apply the same unit
conversion logic. The MGF params block keys (rtinseconds and rtinsec) are
already in seconds, so those require no conversion. Ensure all return statements
in the function account for proper unit conversion.

In `@tests/unit_tests/test_unit.py`:
- Around line 3220-3245: The test_get_retention_time function parametrize
decorator currently lacks test cases with unit-annotated inputs that pyteomics
can provide through unitfloat objects with unit_info attributes. Add new
parametrized test cases to the decorator that include unitfloat objects with
different time units (such as minutes and hours) to verify that the
_get_retention_time function correctly normalizes all retention time values to
seconds regardless of their input unit, ensuring this unit conversion behavior
is protected against future regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b3258b48-cd9d-40bf-8e3c-440b2799180b

📥 Commits

Reviewing files that changed from the base of the PR and between ad5bcd1 and 0e9e068.

📒 Files selected for processing (3)
  • casanovo/denovo/dataloaders.py
  • casanovo/denovo/model.py
  • tests/unit_tests/test_unit.py

Comment thread casanovo/data/ms_io.py
"null", # retention_time
(
"null"
if math.isnan(psm.retention_time)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Move this into the PepSpecMatch class using a property.

Comment thread casanovo/data/ms_io.py
Comment on lines +297 to +301
(
"null"
if math.isnan(psm.retention_time)
else psm.retention_time
), # retention_time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

todo: Add this test.

The retention time in seconds, or NaN if unavailable.
"""
# mzML / mzXML top-level keys
for key in ("scan start time", "retention time", "retentionTime"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

todo: This is incomplete. Retention time in mzML files is not guaranteed to be recorded in seconds, but rather, the unit is specified in the run preamble. Extracting that piece of information is the most complex part of including RT export, because it's not directly available at the spectrum level, where Casanovo operates.

AFAIK, retention times in mzXML and MGF files should always be recorded in seconds, so that's much easier. (Although that's not always the case, but that's due to a faulty file producer then and not something we need to worry about.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants