feat(io): propagate retention time from input spectra to mzTab output#657
feat(io): propagate retention time from input spectra to mzTab output#657wsnoble wants to merge 2 commits into
Conversation
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>
📝 WalkthroughWalkthroughAdds end-to-end retention time propagation: a ChangesRetention Time Propagation Pipeline
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
casanovo/data/ms_io.pycasanovo/data/psm.pycasanovo/denovo/dataloaders.pycasanovo/denovo/model.pytests/unit_tests/test_unit.py
There was a problem hiding this comment.
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 datasetCustomFieldso batches includeretention_time. - Extended
PepSpecMatchto storeretention_time, and passed it through both de novo and DB prediction paths. - Updated
MztabWriter.save()to emit numeric RT values (ornullwhen 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.
| ( | ||
| "null" | ||
| if math.isnan(psm.retention_time) | ||
| else psm.retention_time | ||
| ), # retention_time |
- 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>
There was a problem hiding this comment.
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 | 🟠 MajorHandle 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 | 🔵 TrivialAdd 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
unitfloatobjects withunit_infoattributes (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
📒 Files selected for processing (3)
casanovo/denovo/dataloaders.pycasanovo/denovo/model.pytests/unit_tests/test_unit.py
| "null", # retention_time | ||
| ( | ||
| "null" | ||
| if math.isnan(psm.retention_time) |
There was a problem hiding this comment.
suggestion: Move this into the PepSpecMatch class using a property.
| ( | ||
| "null" | ||
| if math.isnan(psm.retention_time) | ||
| else psm.retention_time | ||
| ), # retention_time |
| The retention time in seconds, or NaN if unavailable. | ||
| """ | ||
| # mzML / mzXML top-level keys | ||
| for key in ("scan start time", "retention time", "retentionTime"): |
There was a problem hiding this comment.
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.)
Summary
Closes #427.
_get_retention_time()helper indataloaders.pythat extracts retention time (in seconds) from mzML, mzXML, and MGF spectrum dicts, returning NaN when the field is absent.CustomFieldon every dataset (both annotated and unannotated), soretention_timeis always present in batches.retention_time: float = float("nan")field toPepSpecMatch.Spec2Pep.predict_stepandDbSpec2Pep.predict_stepnow extractretention_timefrom the batch and pass it toPepSpecMatch.MztabWriter.save()now writespsm.retention_timeto theretention_timemzTab column instead of the hardcoded"null"placeholder.Test plan
pytest tests/unit_tests/test_unit.py— 9 newtest_get_retention_timecases cover all supported formats (flat mzML/mzXML, nested mzML scanList, MGF params, and missing-key fallback to NaN); no previously-passing tests regress.retention_timecolumn in the output mzTab is populated correctly.🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
nullinstead of being emitted incorrectly.Tests