Part of the MuMDIA developer documentation (see docs/README.md).
convert is Stage 0 of the pipeline (PLAN.md Stage 0). It is the single point in
the engine that touches the vendor mass-spectrometry format. It reads one mzML
run through the mzdata crate and writes a normalized, self-describing spectra
artifact set (four Parquet files) that every downstream stage consumes instead of
the raw file. Everything after this stage (search-seed, rt-im-train,
extract, features, quant) reads spectra only through
crates/mumdia/src/spectra.rs, never through mzdata. Consequences: the
vendor-format dependency is isolated here, and any format quirk (profile vs
centroid, AIF/all-ion windows, missing precursor) must be resolved at this stage
because later stages assume the normalized shape.
The MVP is mzML-only and 3D. Ion-mobility columns are therefore absent from the
artifacts. convert writes no IM columns at all; the in-memory Peak/
IsolationWindow types carry Option IM fields (Peak.ion_mobility at
crates/mumdia-core/src/types.rs:12; IsolationWindow.im_lower/im_upper at
:21/:22), and the read side (spectra.rs) fills them with None
(spectra.rs:74, :87-88).
| Path | Role |
|---|---|
rust/mumdia/crates/mumdia/src/stages/convert.rs |
The whole stage: mzML reading, centroiding, peak capping, window synthesis, artifact writing. |
rust/mumdia/crates/mumdia/src/main.rs (Cmd::Convert, lines 22-41, 390-414) |
CLI subcommand; builds the provenance config_hash and calls convert::run. |
rust/mumdia/crates/mumdia/src/stages/run.rs (lines 196-203) |
The run orchestrator's call into convert (top_peaks_ms1 hardcoded to 0). |
rust/mumdia/crates/mumdia/src/spectra.rs |
The read-back side: load_ms1 / load_ms2 turn the artifacts back into in-memory scans for downstream stages. |
rust/mumdia/crates/mumdia-core/src/schema.rs (lines 7-10) |
Frozen (logical name, version) identifiers for the four output artifacts. |
rust/mumdia/crates/mumdia-io/src/table.rs |
Col / write_table typed Parquet writer used to emit the artifacts. |
rust/mumdia/crates/mumdia-io/src/report.rs |
ArtifactReport, the <artifact>.report.json sidecar written per output. |
rust/mumdia/crates/mumdia-core/src/types.rs |
Peak, Ms2Scan, IsolationWindow (used on the read-back side). |
Input: one mzML file path (--mzml). mzdata is pinned at version 0.65 with
default-features = false, features = ["mzml", "miniz_oxide"]
(rust/mumdia/Cargo.toml:26), so only mzML is compiled in; miniz_oxide gives
pure-Rust gzip for compressed mzML. No other input is read. The stage takes no
config file (see Configuration).
Outputs: four Parquet artifacts written to --out-dir, each with a sibling
<file>.report.json. All are written with SNAPPY compression via
write_table (table.rs:151). List columns (mz, intensity) are Arrow
List<Float32> (the Col::ListF32 variant); both the outer list column and its
inner item element are marked nullable in the Arrow schema (table.rs:84
builds the nullable inner item field, table.rs:98 the nullable list column),
but convert writes neither as null. An empty scan is a non-null empty list
(ListBuilder::append(true) at table.rs:124).
| Column | Type | Meaning |
|---|---|---|
scan_index |
u32 |
Global monotonic index over all spectra in the run. |
rt_seconds |
f64 |
Retention time in seconds (mzdata minutes x 60). |
mz |
List<f32> |
Centroided, m/z-ascending peak m/z (widened to f64 on read). |
intensity |
List<f32> |
Peak intensities, aligned to mz. |
| Column | Type | Meaning |
|---|---|---|
scan_index |
u32 |
Global monotonic index (shares the same counter as MS1). |
id |
Utf8 |
Native mzML spectrum id string (spec.id()), kept for traceability/USI. |
rt_seconds |
f64 |
Retention time in seconds. |
window_id |
u32 |
Index into isolation_windows.parquet, dedup by (lower, upper). |
window_target |
f64 |
Isolation window center m/z (0.0 for AIF/all-ion). |
window_lower |
f64 |
Isolation window lower bound m/z. |
window_upper |
f64 |
Isolation window upper bound m/z (1.0e6 for AIF/all-ion). |
precursor_mz |
f64? (nullable) |
Selected precursor m/z, or null when absent. |
precursor_charge |
i32? (nullable) |
Precursor charge, or null when absent. |
mz |
List<f32> |
Centroided, m/z-ascending fragment m/z. |
intensity |
List<f32> |
Fragment intensities, aligned to mz. |
| Column | Type | Meaning |
|---|---|---|
window_id |
u32 |
First-seen id (0-based) of a distinct (lower, upper) window. |
target |
f64 |
Window center m/z. |
lower |
f64 |
Window lower bound m/z. |
upper |
f64 |
Window upper bound m/z. |
| Column | Type | Meaning |
|---|---|---|
ms2_scan_index |
u32 |
MS2 scan_index. |
ms1_scan_index |
i32 |
scan_index of the most recent preceding MS1, or -1 if none seen yet. |
The -1 sentinel (not null) is the "no preceding MS1" marker; it occurs for MS2
scans acquired before the first MS1 in the run (convert.rs:160).
Control flow is run (convert.rs:103-267), a single linear pass over the mzML
reader plus four table writes.
-
Open the run.
mzdata::MZReader::open_path(p.mzml)(convert.rs:107) returns an iterator over spectra in acquisition order. The out directory is created first (convert.rs:105). -
Iterate spectra. The loop zips the reader with an infinite
(0_u32..)range and.enumerate()(convert.rs:120), soscan_index(the range value) andcount(the enumerate index) advance together for every spectrum the reader yields, before the MS-level dispatch. Ifmax_spectra > 0andcountreached it, break (convert.rs:121-123). Becausescan_indexis drawn from the range regardless of MS level, it is a run-global monotonic counter. Retention time isspec.start_time() * 60.0because mzdata returns start time in minutes and the artifact stores seconds (convert.rs:124). -
Dispatch on MS level (
convert.rs:125):- MS1 (
convert.rs:126-133): centroid+cap peaks withtop_peaks_ms1, push into the MS1 accumulators, and recordlast_ms1_index = scan_indexso subsequent MS2 scans can point back to it. - MS2 (
convert.rs:134-161): centroid+cap peaks withtop_peaks_ms2, then resolve the isolation window and precursor (details below), and append the(ms2_scan_index, ms1_scan_index)mapping row. - Any other level (MS3, etc.): ignored by the
_ => {}arm (convert.rs:162), but it still consumed ascan_index, so the per-level tables have globally unique but non-contiguous indices.
- MS1 (
-
Centroiding happens inside
peaks_of(convert.rs:56, generic overSpectrumLike). It first pulls the raw arrays viaspec.raw_arrays()->mzs()(f64) andintensities()(f32) (convert.rs:57-64). Each access is.map(|c| c.to_vec()).unwrap_or_default(), so a spectrum whoseraw_arrays()isNone, or that is missing either the m/z or intensity array, degrades to empty vectors and therefore an empty peak list rather than an error. Ifspec.signal_continuity() == SignalContinuity::Profile(convert.rs:65) it callscentroid(convert.rs:19); already-centroided spectra pass through unchanged.centroiddoes simple local-maxima detection with 3-point parabolic m/z refinement:- If fewer than 3 samples, return the input as-is (
convert.rs:21-23). - Compute a relative noise floor
floor = max_intensity * 1e-4(convert.rs:24-25), i.e. 0.01% of the base peak. This threshold is hardcoded, not a config field. - For each interior sample
iin1..n-1with neighborsy0, y1, y2(convert.rs:28-34): keep it only ify1 > floorand it is a local maximum under the asymmetric testy1 >= y0 && y1 > y2(left inclusive, right strict, so a flat-topped pair keeps the left sample once). Otherwise skip. - Parabolic apex refinement on m/z (
convert.rs:35-43): withdenom = y0 - 2*y1 + y2, the sub-sample offset isdelta = 0.5 * (y0 - y2) / denomwhen|denom| > 1e-12, else 0. The local m/z spacing isspacing = (mz[i+1] - mz[i-1]) * 0.5, and the refined center iscm = mz[i] + delta * spacing. Only the m/z is refined; the emitted intensity is the raw apex sampley1, not a parabola-interpolated height (convert.rs:44-45). - If no local maximum survived,
centroidreturns the original profile arrays as a fallback (convert.rs:47-51). This is a safety net; a pathological profile scan can therefore leak raw profile samples downstream.
- If fewer than 3 samples, return the input as-is (
-
Filter, cap, sort (still in
peaks_of,convert.rs:70-83): the m/z and intensity vectors are joined withmz.into_iter().zip(inten)(convert.rs:71-73), which stops at the shorter of the two, so a length mismatch silently drops the tail of the longer array rather than erroring. Drop peaks with intensity<= 0. Iftop_n > 0and there are more thantop_npeaks, sort descending by intensity and truncate totop_n(convert.rs:76-79). Then always sort ascending by m/z (convert.rs:80). Finally, cast m/z tof32for output (*m as f32,convert.rs:81) while intensity staysf32. Storing observed m/z as f32 halves peak storage; the read side widens back to f64 (spectra.rs:72,spectra.rs:137). At the ppm tolerances used in DIA matching, f32 m/z (~7 significant digits) is adequate for observed peaks; library/theoretical m/z stay f64. -
Isolation window and precursor resolution (
convert.rs:136-148): readspec.precursor()and clone itsisolation_window. If a real window is present (not both bounds zero), use(target, lower_bound, upper_bound)(convert.rs:139-141); mzdata exposes these three window fields asf32, and each is widened withas f64before storage, so the stored window columns are f64 even though the source precision is f32. Otherwise, the AIF / all-ion path synthesizes a full-range window(target=0.0, lower=0.0, upper=1.0e6)(convert.rs:142-143). This_arm fires both when the quadrupole reported a zero-width window (AIF/all-ion acquisition) and when there is no precursor at all, so any MS2 with no usable window is treated as covering the entire m/z range. The downstreamIsolationWindow::covers(inclusive on both bounds) then returns true for every fragment (types.rs:27). The precursor m/z and charge come from the first precursor ion or areNone(convert.rs:145-148). Window synthesis and precursor extraction are independent code paths: a scan that reports a zero-width window but still carries a precursor ion gets the synthesized full-range window (window_target = 0.0) together with a non-nullprecursor_mz/precursor_charge, sowindow_target = 0.0does not imply a null precursor. -
Distinct isolation windows (
convert.rs:181-196): aHashMapkeyed by the raw bit patterns of(window_lower, window_upper)viaf64::to_bits(convert.rs:188) assigns a first-seenwindow_id. The key uses bits, not float equality, so identical window bounds always collapse to the same id and the id order is the acquisition order of first appearance. Each MS2 row records itswindow_idinwin_id_col. -
Write the four tables (
convert.rs:171-234) withwrite_table, thenwrite_reports(convert.rs:269-290) emits oneArtifactReportper file:logical_nameandschema_nameboth set to the artifact'sschema.0name (convert.rs:276-277),schema_version=schema.1,stage = "convert", row count, a blake3 content hash of the written file (convert.rs:281), the same resolved params for all four (mzml,max_spectra,top_peaks_ms2,top_peaks_ms1,config_hash), andelapsed_ms(shared across the four reports, measured once for the whole stage). convert leaves the report'sstatsempty (Default::default(), an emptyBTreeMap) andmodel_identityNone(convert.rs:283-284), since it applies no model and computes no summary distributions. The report is written next to the artifact as<artifact>.report.json(report.rs:28-31). The function returnsConvertOutputswith the four paths for chaining (convert.rs:261-266).
Note the artifacts are written in acquisition order. RT-sorting is deferred to the
read side: spectra::load_ms2 / load_ms1 sort by rt_seconds after loading
(spectra.rs:95, spectra.rs:158).
| Name | file:line | What it does |
|---|---|---|
centroid |
convert.rs:19 |
Local-maxima centroiding with parabolic m/z refinement and a relative noise floor. |
peaks_of |
convert.rs:56 |
Profile-detect + centroid, drop non-positive intensity, top-N cap, m/z sort, cast m/z to f32. |
ConvertParams |
convert.rs:86 |
Inputs: mzml, out_dir, max_spectra, top_peaks_ms2, top_peaks_ms1, config_hash. |
ConvertOutputs |
convert.rs:96 |
Returned paths: ms1, ms2, isolation_windows, ms2_to_ms1. |
run |
convert.rs:103 |
The stage entry point; single pass over mzML, then four table writes. |
write_reports |
convert.rs:269 |
Writes the per-artifact report.json sidecars. |
artifact::SPECTRA_MS1/_MS2/ISOLATION_WINDOWS/MS2_TO_MS1 |
schema.rs:7-10 |
Frozen (name, version) schema identifiers, all v1. |
Col / write_table |
table.rs:23 / table.rs:151 |
Typed columns and the SNAPPY Parquet writer; validates equal lengths and rejects duplicate names. |
ArtifactReport |
report.rs:11 |
The report struct written next to each artifact. |
load_ms2 / load_ms1 |
spectra.rs:20 / spectra.rs:100 |
Read-back into Ms2Scan / Ms1Scan, RT-sorted, m/z widened to f64; per-scan peak count is mf.len().min(iff.len()) (spectra.rs:68), tolerant of an m/z vs intensity length mismatch. |
Ms1Scan / Ms2Scan |
spectra.rs:12 / types.rs:55 |
Read-back structs. Ms1Scan (scan_index, rt_seconds, mz, intensity) is defined in spectra.rs, not types.rs; Ms2Scan (adds id, window, peaks) is in types.rs. |
convert reads no Config fields, and its subcommand has no --config flag
(main.rs:22-41). The stage function signature does not take a Config; the
config_hash it receives is only recorded for provenance. The CLI wrapper still
loads the default config via load_config(&None) (main.rs:397) purely to seed
that hash: config_hash = blake3(cfg.canonical_json() + separators + caps)
(main.rs:402-405), so the default config's canonical JSON is embedded in the
hash even though no config field alters the output.
| CLI flag | Default | Effect |
|---|---|---|
--mzml |
(required) | Input mzML path. |
--out-dir |
(required) | Output directory for the four artifacts. |
--max-spectra |
0 (all) |
Read at most N spectra, for fast iteration. Counts all MS levels. |
--top-peaks-ms2 |
0 (uncapped) |
Keep at most N most-intense MS2 peaks per scan. Irreversible conversion-time cap. |
--top-peaks-ms1 |
0 (uncapped) |
Keep at most N most-intense MS1 peaks per scan. Irreversible. |
Defaults are asserted by conversion_caps_default_to_uncapped (main.rs:750) and
the explicit-cap case by explicit_conversion_cap_is_preserved (main.rs:790).
The run orchestrator exposes --max-spectra and --top-peaks-ms2 but not
--top-peaks-ms1; it hardcodes top_peaks_ms1: 0 when calling convert
(run.rs:201). The MS2 cap is documented as "irreversible" because it discards
peaks before they reach extraction, features, and quantification; for a seed-only
peak limit use search_seed.top_n_peaks instead (main.rs:31-35,
config.rs:298).
Provenance handling of the caps: because the caps change the spectra output but
are not part of the Config, main.rs:402-405 folds them into the blake3
config_hash with a unit-separator (\u{1f}) so two different caps do not
collapse to an identical hash. The same values are recorded in the convert report
params (convert.rs:245-251).
The centroid noise floor (1e-4 relative, convert.rs:25) and the full-range AIF
window value (1.0e6, convert.rs:143) are hardcoded constants, not config
fields. There is no ScanWindowMode or centroiding strategy knob for this stage;
the project config was recently pruned of dead fields, and convert exposes no
strategy enum. Any change to centroiding or window synthesis is a code change
here, not a config toggle.
- Determinism. The output is a deterministic function of the input file and
the CLI caps.
scan_indexis assigned in reader order;window_idis assigned in first-appearance order via a bit-keyed map, not float equality. The intensity-descending truncation sort (convert.rs:77) uses Rust's stablesort_by, so ties preserve the incoming (m/z-ascending) order; the final m/z-ascending sort (convert.rs:80) fixes output order regardless. scan_indexis run-global, not per-level. MS3 and other unhandled levels still advance the range-derived counter (convert.rs:120), so the MS1 and MS2 tables have unique but non-contiguous indices. Do not assume contiguity; usems2_to_ms1.parquetto relate MS2 to its parent MS1.- AIF and no-precursor collapse to the same full-range window. The
_arm atconvert.rs:143handles both a zero-width reported window (AIF/all-ion) and a missing precursor. If a future non-AIF format reports a genuinely absent window, it will be silently treated as full-range.window_target = 0.0is the marker for a synthesized window. - Observed m/z is f32 on disk.
convert.rs:81casts to f32;spectra.rswidens back to f64. This is intentional (storage) and fine at DIA ppm tolerances, but do not round-trip observed m/z through convert expecting f64 precision. - Intensity is the raw apex sample. Parabolic refinement adjusts m/z only; the
reported intensity is
y1(convert.rs:45), not an interpolated peak height. - Centroid fallback can leak profile samples. If no local maximum clears the
floor,
centroidreturns the original profile arrays (convert.rs:47-51). Rare, but a downstream stage could then see profile-shaped data for that scan. - List columns are nullable in the Arrow schema but never null in practice.
An empty scan is written as a non-null empty list; the read side treats null and
empty identically (
spectra.rs:55,table.rs:419). partial_cmp().unwrap()on sorts (convert.rs:77,:80,spectra.rssorts) would panic on NaN. Convert filters intensity<= 0before sorting and does not sort on m/z NaN in practice, so this is safe for real mzML but is a latent trap if malformed data ever reaches it.- Out-dir creation errors are swallowed.
std::fs::create_dir_all(p.out_dir) .ok()(convert.rs:105) discards a creation failure; a genuinely unwritable out-dir does not fail here but surfaces later as a file-create error fromwrite_table(table.rs:188). - Observability. The stage is otherwise side-effect-free apart from its file
writes; it emits two
tracing::info!records, one on open (convert.rs:106) and one on completion carrying the MS1/MS2/window counts andelapsed_ms(convert.rs:254-260). elapsed_msis shared, not per-artifact. A singleInstantstarted atconvert.rs:104times the whole stage; the same value is written into all fourreport.jsonsidecars, so per-artifact timing cannot be read from them.- Test coverage. Only the two CLI-parsing tests above exercise this area. The centroiding math, window synthesis, and artifact writing have no stage-level unit test (see CLAUDE.md "test gaps"). MS1 extraction and mass-calibration paths that depend on convert output are exercised only in full runs.
- Add a vendor format (Thermo
.raw, Bruker.d/TDF): this stage is the only place to touch. Either extendmzdatafeatures or add a reader that yields the same per-spectrum interface, and keep the four output schemas byte-compatible so no downstream stage changes. Convert must stay the sole vendor-format touch point. - Ion mobility / 4D (diaPASEF). The artifact schemas here are 3D. Adding IM
means new nullable columns on
spectra_ms2(and the isolation-window IM bounds already modeled asOptionintypes.rs:21), plus populatingPeak.ion_mobilityon the read side. Bump the affected schema versions inschema.rswhen columns change, since the version guards downstream model/schema matching. - Change centroiding. Edit
centroid(convert.rs:19). If the choice should be user-selectable, add a config field and strategy enum inmumdia-core(per the project convention that every algorithmic choice is a typed config field) rather than a second hardcoded branch, and thread it throughConvertParams. Remember the noise floor and parabolic step are currently hardcoded. - Change the AIF window sentinel. The full-range bound
1.0e6(convert.rs:143) andwindow_target = 0.0marker are relied on by extraction'sIsolationWindow::covers. Changing either requires auditing the extract stage. - Add an artifact column. Add the column to the relevant
write_tablecall, add a matching getter/reader inspectra.rs, and bump the schema version inschema.rs.write_tablerejects duplicate names and mismatched lengths, so every new column vector must match the row count. - Preserve provenance semantics. Any new conversion-time parameter that
changes the output but is not part of
Configmust be folded into theconfig_hashkey inmain.rs(as the caps are), or two different settings will produce artifacts with an identical hash.