Skip to content

feat: implement iter_chromatograms (TIC + BPC) for mzML export - #26

Merged
Nathan-D-R merged 3 commits into
Sigilweaver:mainfrom
Nabejo:iter-chromatograms-opentimstdf
Jul 26, 2026
Merged

feat: implement iter_chromatograms (TIC + BPC) for mzML export#26
Nathan-D-R merged 3 commits into
Sigilweaver:mainfrom
Nabejo:iter-chromatograms-opentimstdf

Conversation

@Nabejo

@Nabejo Nabejo commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Related to #25 (TIC/BPC portion; SRM/PRM chromatograms remain open in that issue, retitled and scoped accordingly - see the issue comment).

Summary

TdfSource / OwnedTdfSource previously used SpectrumSource::iter_chromatograms's default empty implementation, so OpenTimsTDF's mzML output had no <chromatogramList> (the openmassspec-core writer only emits it when the source yields something). This wires up the two chromatogram traces that can be built from data Reader::frames already reads, following the scope/format of the referenced OpenWRaw#9 precedent (map existing decoded data into openmassspec_core::ChromatogramRecord, omit channels that can't be confidently mapped rather than guessing).

Both traces are whole-run, one point per frame in acquisition order, in seconds. No extra SQL beyond a single added column, no peak decode.

TIC (decoded-but-unused data)

  • "total ion current chromatogram" (MS:1000235) from Frame::summed_intensities (Frames.SummedIntensities), the accumulation-normalized per-frame intensity sum documented in docs/docs/format/01-tdf-sqlite-schema.md and already cross-checked against the decoded peak sum by tests/roundtrip.rs. This column was previously decoded but read nowhere outside a test mock.

BPC (small additive decode)

  • "basepeak chromatogram" (MS:1000628) from Frames.MaxIntensity. This adds MaxIntensity to both Frames SELECTs, a new Frame::max_intensity field, and its mapping in frame_from_row. The column is already documented in the public schema's observed-columns block; a detailed table row for it was added here.

A trace whose source column is absent on every frame is omitted rather than emitted empty, matching the writer's contract of only emitting a <chromatogramList> when the source yields something.

SRM/PRM: not included (suggested follow-up)

SRM/PRM transition chromatograms are intentionally left out. As the issue notes, producing an SRM-shaped ChromatogramRecord per PrmTarget means walking msms_type == 10 frames, decoding the peak payload restricted to each target's scan range, and summing intensity per frame - genuine per-target cross-frame aggregation, materially larger than wiring existing columns. It would fit well as its own follow-up issue/PR. This is distinct from #13 (PRM-PASEF spectra skipped in the spectrum projection): that is about spectra, this would be about the chromatogram trace; neither fixes the other.

Also

  • Exposed max_intensity on the Python Frame (kept symmetric with summed_intensities), and documented it in docs/docs/reference/python-api.md.

Testing

  • cargo fmt --all -- --check: clean.
  • cargo clippy --all-targets -- -D warnings: clean.
  • cargo test --all: all pass, including 4 new unit tests in mzml.rs covering TIC+BPC construction, seconds-preserving retention time, skip-frames-missing-a-column (arrays stay parallel), omit-empty-trace, and all-absent -> no records. The real-.d-bundle roundtrip/conformance tests are cache-gated and skip when the PRIDE cache is absent; correctness of the added MaxIntensity SELECT is otherwise argued from the public schema.

Clean-room

Per CONTRIBUTING.md: correctness is argued from the schema doc, the existing roundtrip/self-consistency invariants, and the PSI-MS accessions the openmassspec-core writer expects. No vendor software was used.

Contributed by @Nabejo.

Wire SpectrumSource::iter_chromatograms on TdfSource / OwnedTdfSource,
which previously fell back to the trait's default empty iterator, so
OpenTimsTDF's mzML output now carries a <chromatogramList>.

Two whole-run traces are emitted, one point per frame in acquisition
order, built purely from columns Reader::frames already reads (no extra
SQL, no peak decode):

- TIC ("total ion current chromatogram", MS:1000235) from
  Frames.SummedIntensities (Frame::summed_intensities), the
  accumulation-normalized per-frame intensity sum.
- BPC ("basepeak chromatogram", MS:1000628) from Frames.MaxIntensity,
  a new additive column added to both Frames SELECTs and mapped onto a
  new Frame::max_intensity field (also exposed on the Python Frame).

A trace whose source column is absent on every frame is omitted rather
than emitted empty, matching the writer's contract of only emitting a
chromatogramList when the source yields something.

SRM/PRM transition chromatograms are intentionally left out: they need
per-target cross-frame aggregation of the decoded peak stream, not just
column wiring. Distinct from Sigilweaver#13 (PRM spectra), which this does not
touch.

Closes Sigilweaver#25.

Claude-Session: https://claude.ai/code/session_01TLSifJpmaN7RvW3xi2uWgo
@Nathan-D-R
Nathan-D-R merged commit 47ca1bb into Sigilweaver:main Jul 26, 2026
4 checks passed
Nathan-D-R pushed a commit that referenced this pull request Jul 28, 2026
Wire the remaining piece of iter_chromatograms scoped out of #26: one
SRM-shaped ChromatogramRecord ("selected reaction monitoring
chromatogram", MS:1001473) per scheduled PrmTarget, built on top of
the TIC/BPC traces #26 already wired up.

For each PrmTarget, walk every msms_type == 10 (prm-PASEF) frame,
decode its peak payload via the same per-frame decode path
iter_spectra already uses (Reader::decode_peaks), sum the decoded
intensity restricted to that target's PrmFrameMsMsInfo scan-number
range, and append one point per frame. A target with a genuine
zero-intensity frame keeps that point (real data, unlike a frame
missing a Frames column); a target absent from every
PrmFrameMsMsInfo row is omitted rather than emitted with an empty
trace, matching the TIC/BPC no-empty-chromatogram contract.

precursor_mz is the target's scheduled PrmTargets.MonoisotopicMz.
product_mz is left unset: prm-PASEF records a full fragment-ion scan
per precursor rather than a discrete triple-quadrupole product-ion
transition, and the TDF schema has no product-ion m/z field to
report - populating one would mean inventing a value with no schema
backing, which the clean-room policy in CONTRIBUTING.md rules out.

New pure helpers (sum_intensity_in_range, build_srm_record) are unit
tested directly; the frame-walking orchestration
(srm_chromatograms_for) follows the same shape as the existing
spectra_for_frame and isn't unit tested in isolation for the same
reason that one isn't - it's thin glue over already-tested pieces and
a live Reader/bundle. The existing cache-gated prm_pasef_pxd028279
test in tests/roundtrip.rs already exercises the metadata this relies
on (PrmFrameMsMsInfo/PrmTargets shape) and continues to pass.

Distinct from #13 (prm-PASEF frames skipped in the mzML *spectrum*
projection): that issue is about spectra, this is about the
chromatogram trace.

Addresses the remaining SRM/PRM scope of #25 (TIC/BPC portion done in
#26).

Claude-Session: https://claude.ai/code/session_018ijUinjq3SKNZXFNWqTTmV
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.

2 participants