Skip to content

Supported Formats

In plain words

Which files can I convert? says what to point Thyra at for each instrument, in plain words. This page has every detail.

Thyra reads seven MSI formats and writes all of them into the same SpatialData/Zarr layout. The input format is detected from the path and no flag selects it. The --format option on the CLI and format_type in the Python API both name the output format, not the input; spatialdata is its only value, and --format is deprecated and hidden from --help while still being accepted (see the CLI Reference).

Format Path shape Detected by Vendor SDK
imzML .imzML file + .ibd extension, .ibd must exist none
Bruker timsTOF .d directory analysis.tsf or analysis.tdf bundled DLL
Bruker solariX .d directory peaks.sqlite + ImagingInfo.xml none
Bruker Rapiflex directory *.dat + *_poslog.txt + *_info.txt none
Waters MassLynx .raw directory _FUNC*.DAT files inside bundled DLL
PHI SmartSoft-TOF .raw file SOFH magic in first 4 bytes none
mzPeak .mzpeak file ZIP magic + mzpeak_index.json member none
thyra sample.imzML       out.zarr   # imzML
thyra sample.d           out.zarr   # Bruker timsTOF or solariX (see below)
thyra rapiflex_folder/   out.zarr   # Bruker Rapiflex
thyra waters_run.raw/    out.zarr   # Waters (a directory)
thyra tofsims_run.raw    out.zarr   # PHI (a file)
thyra sample.mzpeak      out.zarr   # mzPeak (experimental)

Two Bruker instrument families share the .d extension and are told apart by what the directory contains: timsTOF writes analysis.tsf/analysis.tdf, solariX (FT-ICR / MRMS) writes peaks.sqlite alongside ImagingInfo.xml.

Shimadzu .imdx and .kbd files are recognised but not read yet. Thyra stops and names the workaround: export the dataset as imzML from IMAGEREVEAL MS and convert that.


The .raw collision

Two vendors claim .raw, and they are told apart by shape, not extension:

  • Waters .raw is a directory containing _FUNC001.DAT, _FUNC002.DAT, …
  • PHI .raw is a single file whose header begins with the ASCII magic SOFH

Detection checks the directory case first, then the file magic. A .raw file that is neither -- a Thermo .raw, for instance -- raises an error naming both possibilities, rather than a confusing Waters-specific complaint (a .raw folder with no _FUNC files is reported as an incomplete Waters acquisition):

Unrecognised .raw file: <path>. Expected either a Waters directory containing
_FUNC*.DAT files, or a PHI SmartSoft-TOF file beginning with the SOFH magic.

What each format provides

Not every source carries every kind of metadata. This is what Thyra can actually populate from each.

imzML timsTOF solariX Rapiflex Waters PHI mzPeak
Pixel size from metadata when declared yes yes (.mis) yes (_info.txt) yes yes when declared
Optical image -- yes yes yes -- -- --
Optical alignment -- yes (.mis) -- yes (.mis) -- -- --
Multi-region -- yes recorded recorded -- mosaic tiles recorded
3D / multi-slice yes -- -- -- -- -- --
Native non-m/z axis kept -- -- -- -- -- flight time, with --no-resample --

Anything a format does not supply is simply absent from the output rather than guessed at. That includes the pixel size: when a source cannot report it, the conversion stops before writing anything and asks for --pixel-size.

"Optical image" means TIFF, JPEG, PNG or BMP, matched case-insensitively on the suffix -- FlexImaging exports more than TIFF, and a Rapiflex .mis regularly names a .jpg. Which of a folder's images is the alignment image is decided by the .mis, not by the search: <ImageFile> names it, and Thyra resolves that name against the acquisition folder. (<OriginalImage> holds an absolute path on the machine that acquired the data, drive letter and all. It is never opened, and a store keeps only its file name, as it does for every path a vendor recorded -- see Output Format.)


imzML

The open interchange format, read through pyimzML. Both storage modes work:

  • continuous -- every spectrum shares one m/z array, so the common mass axis is read once
  • processed -- each spectrum carries its own m/z values, so building the common axis requires a pass over every spectrum

An .imzML without its .ibd beside it is rejected up front, because the XML holds only offsets and the binary holds the data.

Pixel numbering. The specification numbers x and y from 1, and Thyra subtracts that to reach the 0-based indices the store uses. Exports numbered from 0 exist too, and a fixed subtraction would put their first column at x = -1, so the base is measured: a file whose smallest coordinate is 0 is rebased on 0, and a file starting at 1 -- or at 5, because it is a crop of a larger slide -- keeps the base of 1 and does not move. z is separate and rebases on the smallest plane present, because z has no origin to preserve. Whatever was subtracted is recorded in coordinate_systems.global.coordinate_offsets_px, and the "acquisition" coordinate system puts it back (see Coordinate Systems).

Pixel size. Read from IMS:1000046 and IMS:1000047, and both have to be there. A value with no unit is read as micrometres. Until 2017 IMS:1000046 was named "pixel size" and gave the area of a pixel. Under that name it is not read: the log says why, and the conversion asks for --pixel-size. See imzML Parser Notes.

Previewing reads the head, not the document. An imzML states its raster, its pitch, its instrument and its spectrum count in the block before <run>, and preview_msi answers from there -- 0.4 ms whether the document is 29 MB or 2.0 GiB. The mass range is the one thing it does not hold, and it is read from the per-spectrum MS:1000528 / MS:1000527 terms in the XML -- the same extrema, to the six decimals the file records, without opening the binary. On a continuous-mode file every spectrum shares one m/z array, so the first spectrum states the range and the read stops there (checked against the file's own first spectra, not taken on trust); a processed-mode file is read to the end. A writer that omits those terms, as IONTOF SurfaceLab does, leaves the range genuinely unknown and a preview reports it as None rather than guessing from one spectrum. Two files still take the old route, because their head cannot settle the raster: one that declares no IMS:1000042 / IMS:1000043, and one numbered from 0, where that declaration is one short of the acquisition. A conversion is unaffected -- it reads every coordinate as before.

Ion mobility. imzML defines two binary arrays, but TIMSCONVERT and TIMSImaging add a third for mobility, declared through a param group bound to MS:1003006 (mean inverse reduced ion mobility array, unit MS:1002814). Thyra reads it. The MSI table is summed over mobility as always -- a shared m/z block that repeats a value where mobility splits a feature collapses to one column -- and, for a continuous export (one shared feature list), the (m/z, mobility) pairs are also written as a mobility-resolved sibling table; see Output Format. A processed export (per-pixel point cloud, as TIMSCONVERT writes) gets the summed table and the mass-mobility heatmap, and with --mobility-grid the mobility-resolved table too, binned onto the heatmap's channels as for Bruker TDF. A mobility array declared with zlib compression is refused like the other two. When the array states 1/K0 in MS:1002814, the sibling table also carries each feature's collision cross section, var["ccs"], for an assumed charge of 1; see Output Format.

See imzML Parser Notes for the hazards Thyra works around in the underlying library.

Bruker timsTOF

.d directories containing analysis.tsf (TOF only) or analysis.tdf (TIMS engaged). Reading goes through Bruker's timsdata library, which is bundled for Windows and Linux. This is the richest source Thyra handles: it carries optical microscopy images, FlexImaging .mis teaching points for MSI-to-optical registration, and per-pixel region annotations for multi-region slides.

Not every .d is an image. The tables that give a frame a raster position, MaldiFrameInfo and MaldiFrameLaserInfo, are absent from a run that imaged nothing -- an electrospray acquisition on the same instrument family, for instance -- and Thyra refuses to convert one, by name: there are no pixels to place and no pixel size would make any. Its metadata is another matter, and is read in full by thyra metadata (see CLI Reference).

A TDF frame is one pixel whose scans are the ion mobility dimension. Thyra reads every scan of the ramp and collapses them into the one spectrum per pixel the MSI table holds; --mobility-grid writes what was collapsed as a separate table (below), and --msms-table slices the ramp by precursor instead (below). Two collapses are available through --tdf-spectrum:

  • scan_sum (default): every scan summed per digitizer index. Lossless, 1.5 to 3 times as many points per frame as the centroid, exactly the mobility marginal of the per-scan data, and exactly Bruker's own per-frame total (Frames.SummedIntensities) and its quasi-profile export. This is the instrument's record, which is why it is the default; see Design Decisions.
  • vendor_centroid: Bruker's frame-level peak picker over the full ramp, the same one behind the TSF line spectrum and SCiLS Lab's import. It reports peak areas, merges neighbouring digitizer bins, and drops every index bin its picker assigns to no peak, which on measured imaging acquisitions keeps 79 to 99 percent of the raw ion current. Choose it to reproduce the vendor software's numbers.

The choice is recorded in the store's processing provenance (msi_metadata.processing[0].parameters.tdf_spectrum), and the acquisition's mobility range and ramp length are recorded in msi_metadata.ms_analysis.ion_mobility, so a consumer can tell a summed-over- mobility spectrum from one that never had a mobility dimension.

The mobility dimension itself is not thrown away. The summed table carries uns["mobility_axis"] -- the 1/K0 of every scan from the vendor calibration, the declared range and the TimsCalibration row -- and uns["mobility_heatmap"], the dataset's mean mass-mobility frame (about 4,000 m/z bins by 256 mobility channels) accumulated from the raw scan read of every frame. The heatmap is where to look to see whether mobility separates anything; it is fed from the same frame read that builds the summed table, so it costs the mapping of every point onto the mass axis and no extra read, and --no-mobility-heatmap skips it. Under scan_sum the heatmap summed over mobility is exactly the stored mean spectrum; under vendor_centroid the two differ by what the centroid discards. See Output Format. A TSF file has no mobility dimension and gets none of this.

--mobility-grid writes the mobility-resolved table. A TDF pixel is its own point cloud, so there is no feature list shared across pixels to write directly the way an imzML mobility export has; the flag bins every pixel's (m/z, 1/K0, intensity) points onto one set of mobility channels shared across the conversion and writes the result as {table}_mobility -- the same element, columns and sort a shared-axis source produces. The default 256 channels over the axis' own value range are the heatmap's, so a box on the heatmap indexes the table's channels. It is fed from the summed table's own two passes -- one raw read per frame per pass serves every table, so the grid adds no read of the source -- and it is a table with 1.3 to 4 times the summed table's non-zeros, built out of core, so a whole acquisition converts whatever its size. Under the default --tdf-spectrum scan_sum the table's marginal over channels reproduces the summed table exactly; an explicit vendor_centroid is accepted with a WARNING and the mismatch recorded. The table also carries each feature's collision cross section, var["ccs"], for an assumed charge of 1 (see Output Format). See Output Format.

MS/MS acquisitions convert, and say so. Frames.MsMsType tells a survey frame from a fragment one. The precursor detail comes from PasefFrameMsMsInfo (PASEF frames, type 8), from DiaFrameMsMsWindows (DIA frames, type 9, as diaPASEF writes them) or from FrameMsMsInfo (single-precursor frames). What is read lands in uns["msms_schedule"] and in msi_metadata.ms_analysis.fragmentation: the MS level, each isolation window's target m/z and offsets, the collision energy, and -- for PASEF and DIA frames -- the mobility scan range each window occupies.

The MSI table is still the whole frame summed, so on a scheduled PASEF method that isolates several precursors at every pixel it holds fragments of all of them at once, and conversion says so at WARNING. --msms-table splits them apart into a {table}_msms sibling of (precursor, fragment) features: each window owns a disjoint slice of the mobility ramp, so every recorded point belongs to exactly one precursor and the split is a filter on the scan number, not a deconvolution -- the per-precursor columns of a pixel add back up to what the summed table holds there. This is the shape a targeted MALDI MS/MS imaging run has: a scheduled precursor list, fragmented at every pixel. The MS/MS images measured so far store it as PASEF frames. Bruker's iprm-PASEF is reported to use type-9 frames instead. Thyra splits those too when they share one DIA window group, but no MALDI iprm-PASEF run has been measured yet: that split is tested on a synthetic file only. A type-9 run that also keeps survey frames is refused for now. A spot's survey and fragment frames are then summed into one pixel, and conversion says so at WARNING. Thyra refuses, by name and without writing a table, when the schedule varies per pixel, when the windows overlap or carry no scan range, or when there is only one precursor to begin with. TDF is the only source that reports what the split needs; an imzML export of the same acquisition does not. See Output Format.

Bruker solariX

.d directories from solariX / MRMS (FT-ICR) instruments running ftmsControl, detected by peaks.sqlite together with ImagingInfo.xml. Thyra reads the processed peak store the acquisition software writes into every imaging .d: centroided, calibrated per-pixel peak lists with stage-raster coordinates and the instrument identity. No vendor SDK and no FT processing are involved -- the raw transient block (ser) and the .mcf containers are never touched. See solariX Notes for the verified layout, what the reader refuses, and the pixel-size story.

Two things to know up front:

  • The pixel size lives only in the flexImaging .mis file next to the .d (same stem). Without it, pass --pixel-size explicitly -- Thyra never guesses.
  • A solariX-family .d that carries raw transients but no peaks.sqlite cannot be read natively; the error says so and names the imzML export fallback (DataAnalysis, SCiLS Lab, or flexImaging).

Bruker Rapiflex

A folder of *.dat files with *_poslog.txt and *_info.txt alongside; all three are needed for the folder to be recognised, and only the first .dat is read. The .dat header gives the raster size and its offset table places each spectrum on it. The position log names each spectrum's region. No SDK required.

The raster step comes from the Raster: line in *_info.txt. Without it, Thyra refuses and asks for --pixel-size, the same way the solariX reader does, rather than guessing a value.

The .dat header's raster origin reaches the store as coordinate_offsets_px, with stage_offset_um beside it, so a Rapiflex store can be placed against its optical image the way a solariX or timsTOF one can. The stored pixel coordinates themselves stay 0-based; the "acquisition" coordinate system carries the origin.

The optical image is whichever file the .mis <ImageFile> element names, resolved against the acquisition folder. Rapiflex slides commonly hold several images -- a slide overview and one or two more beside the alignment scan -- and they are frequently .jpg rather than .tif; all of them are carried into the store, but only the named one gets the teaching points' coordinate space.

The alignment image is cropped to the section it aligns. This applies to Rapiflex and timsTOF acquisitions alike. The scan the .mis names is the whole slide, and every acquisition cut from that slide names the same one, so without the crop a project of many sections would hold as many copies of one photo, each right for its own section and centimetres off for the others. When a conversion covers exactly one region -- a single-region acquisition, or one region of a multi-region timsTOF file selected with --region -- the store holds only that region's Area box plus a 10% margin, pyramided from the crop's size, and the element carries a translation by the crop's origin in front of the transform the whole scan would have had. Nothing on the MSI side changes: the raster-to-image affine, the pixel polygons and coordinate_systems keep describing the whole scan's pixel grid. The optical_images root attr records the crop's origin and size and the scan's full size under crop, so the whole slide can be found again without re-reading the .d. A multi-region file converted whole is embedded whole, as before.

Waters MassLynx

A .raw directory of _FUNC*.DAT files, read through the MassLynxRaw and MLReader native libraries (bundled for Windows and Linux). The pixel grid is reconstructed from the laser X/Y position recorded on each scan; MRM functions are classified and skipped, a function acquired with ion mobility on is read summed over its drift bins and keeps its drift times (below), and which functions hold the image is decided from those same positions.

The grid is a lattice fitted to those positions -- an origin, a pitch and a count per axis -- not a ranking of the distinct ones. The pitch is the neighbour interval, so a raster missing an interior row keeps its true pitch and leaves that row empty, rather than inflating the pitch and shifting every row past the gap up by one. Positions that do not lie on the fitted raster are refused, naming the axis and how far off the worst reading sits, instead of becoming a grid with a pixel per stage wobble. A scan reporting a non-finite position is unpositioned; two scans reporting the same position are summed into one pixel and the log names it.

The fitted origin, the smallest stage reading on each axis in micrometres, is where pixel 0 sits. It is stored as stage_offset_um and is the translation of the "acquisition" coordinate system, so two runs of one slide can be placed side by side. It is a stage position, not a raster index, so a Waters store has no coordinate_offsets_px.

A .raw directory is refused when the library declares more functions than it has _FUNC*.DAT files. MassLynx keeps reporting a function whose data file is gone, with 0 scans and no error, so a chunk lost from a split raster would otherwise drop its rows of the image in silence.

Which representation is read

MassLynx can hand back either of two things for a profile-acquired pixel: the vendor centroid list, computed on demand by its peak picker, or the profile trace behind it -- the digitiser's samples, zero-suppressed to the clusters around each peak. Thyra reads _extern.inf and _header.txt to decide which one is the default, and logs the field it decided on:

Instrument Identified by Default Method Axis Default width
SELECT SERIES MRT OpticMode = MRT, or $$ Instrument: MRT#, or Resolution above 100,000 profile trace tic_preserving linear_tof 1.3 mDa at m/z 1000
SELECT SERIES MRT, --waters-spectrum centroid as above vendor centroid nearest_neighbor tof (measured width law, see Resampling) 3 bins per peak width
every other Waters instrument none of the above vendor centroid nearest_neighbor reflector_tof 2 mDa at m/z 1000

--waters-spectrum centroid|profile overrides the default in either direction. A run that is not an MRT but is asked for its profile gets the profile treatment with a bin width taken from its own digitiser: 1.14 times the sample spacing predicted from Lteff, Veff and the ADC clock in _extern.inf (15.8 mDa on a Synapt G2-Si), so the store is not oversampled.

Why the MRT defaults to the profile. Its multi-reflecting flight path gives a measured resolving power of 130,000 at m/z 300 rising to 190,000 at m/z 1000 (median 168,000), and at that resolution the vendor peak picker, not the analyser, is what limits the data. On a 13,398-pixel MALDI brain section, four separate 8-9 mDa doublets between m/z 760 and 830 -- including the 13C2 isotopologue of PC 34:1 [M+K]+ at 800.5477 against PC 34:0 [M+K]+ at 800.5566 -- were resolved as two maxima in the profile in 19,227 pixels between them. The vendor returned exactly one centroid in 96-100% of those pixels, never two, landing 4-8 ppm from either true mass. No centroid-side setting recovers that: a finer axis just places the merged centroid more precisely.

Why the others do not. The same test on a Synapt G2-Si MALDI imaging run (7,007 pixels, resolving power about 26,000) found the vendor centroider merging none of the pairs its profile resolves: it reported three or more peaks in 99.4-99.7% of the pixels where the profile showed two. At that resolving power the analyser is the limit and the peak picker keeps everything the trace has, so the profile would cost 2-3x the store for nothing. Both instruments sample the trace on the same sqrt(m/z) grid (measured (m/z)^0.494 on the MRT, (m/z)^0.498 on the Synapt), which is why the profile route uses linear_tof whichever instrument it is asked for on.

What the profile default costs. On the reference run (13,398 pixels) the store is 289 MB against 52 MB for the centroid default, and it converts in 15 s against 96 s, because MassLynx centroids on demand and a profile read skips that work. Two things make the profile store that size: it holds 4.5x the non-zeros (31.3M against 6.9M), and interpolated values are not the integer ADC counts the raw samples are, so they compress about half as well -- nearest-neighbour binning of the same trace onto the same axis gives 138 MB. The profile is stored on a fixed, generated axis at about 1.14 times the digitiser's own sample spacing (1.3 mDa at m/z 1000 against 1.14 mDa per sample), so every MRT run with the same acquisition mass range lands on the same bins -- the axis is built over the acquisition setting, not the span of stored values, exactly as the timsTOF route does. The accepted loss is one bin of smoothing: intensity is interpolated between samples, so a peak apex in the stored mean spectrum sits within about 2 ppm of the raw apex before any peak picking, and a centroider run on the stored profile recovers it. The raw digitiser grid itself is still available with --no-resample, where binning is the identity and the apex error is zero, at the cost of an axis that differs from run to run.

uns["essential_metadata"]["spectrum_type"] records what was stored (profile spectrum or centroid spectrum), format_specific.spectrum_source says which MassLynx representation it came from, and format_specific.is_mrt with instrument_decided_by record the instrument decision.

Which functions hold the image

A Waters function is not necessarily one acquisition function. MassLynx caps a _FUNC*.DAT file at about 1.6 GB and opens a new function when a long imaging run reaches it, so one raster commonly arrives as several functions that tile the stage. It reports MS level 1 for the first of them, 2 for the middle ones and 0 for the last, and getLockmassFunction names that last one as the file's lockmass function -- none of which is true. Nine real imaging runs from two instruments and five users were checked and every multi-function one was a chunked single-function raster; see the D7 entry.

Thyra therefore decides from the laser positions, the same measurement the pixel grid is built from:

  • A function landing on pixels no earlier function covers extends the raster and is converted, whatever level MassLynx reports and whether or not MassLynx calls it the lockmass function -- provided its positions lie on the raster. The tail of a split raster continues it, so it shares the fitted lattice; a reference or calibration spot parked off the sample also covers pixels nothing else covers but does not, and is excluded and named rather than converted as an image pixel.
  • The lattice itself is fitted to the MS functions, then re-fitted to whatever is finally converted, so a function that contributes no pixel never leaves its pitch behind in the grid.
  • Functions competing for the same pixels were acquired in parallel: MSe low and high energy, a data-dependent run, a co-acquired lockmass reference. Only one of them can be the pixel's spectrum, and summing an intact-ion and a fragment spectrum into one pixel would make a spectrum of nothing, so the MS1 ones win where the file has any. The rest are listed, with their MS level and precursor m/z, under excluded_functions in the Waters-specific metadata block.

format_specific.function_types keeps MassLynx's own classification next to format_specific.ms_functions, so a rescued chunk is visible in the store.

One catch. The library will not centroid the function it names the lockmass function -- the request is honoured for every other function and ignored for that one, which returns its profile trace either way. Putting it into a store of centroids would lay a band of profile rows across the top of the image at about 3x the neighbouring TIC, so while a run is read as centroids that chunk stays out, and excluded_functions records its scan count, the pixels it would have added and the reason. --waters-spectrum profile converts every chunk, because then they all come back the same way. The log names the cost and the flags:

Function(s) 2 hold 1274 pixels (16.6% of the image) that no other function
covers, but MassLynx names them the lockmass function and will not centroid
them. They stay out rather than put profile rows in a table of centroids:
pass --waters-spectrum profile to convert the whole image.

Budget the disk for it: the profile store for that run is estimated at 74 GB against 241 MB for the centroid one. Memory is not the constraint, since every conversion streams.

A file whose converted functions carry a precursor m/z holds fragment spectra and reports them through ms_analysis.fragmentation, exactly as a Bruker MS/MS acquisition does. A reported MS level with no precursor behind it does not: that is the chunk artefact above.

Ion mobility (drift time)

A Synapt run acquired with ion mobility on stores each scan as a set of drift bins: 200 on every run measured, equal steps of drift time from 0 ms, one pusher period apart (0.045 to 0.216 ms; 0.06625 ms on the public MassIVE MSV000092638 Synapt XS image). Each bin is a spectrum of its own. Thyra reads them the way it reads a TIMS frame:

  • The MSI table holds each scan's spectrum summed over its bins, as MassLynx reports it.
  • uns["mobility_axis"] is the drift-time axis: type_accession MS:1002476, unit UO:0000028 (millisecond), values the drift time of each bin, source "waters_raw". uns["mobility_heatmap"] and, with --mobility-grid, the {table}_mobility sibling are built from the bins exactly as for a TDF (see Output Format).
  • The bins are read raw (getRawMobilityScanDataPoints): their non-zero points, whatever representation the MSI table was read in. Summed over the bins they reproduce the profile spectrum exactly. Under the default vendor centroid they do not, since centroiding the sum is not summing centroids, and the store's current_ratio says by how much; --waters-spectrum profile makes the two agree.

Two things are not read:

  • The mobility functions of a run that does not declare Acquisition Algorithm: ADC Mode in _extern.inf. Over 91 Synapt runs with mobility on, the 56 that declare it read cleanly; the 35 that do not (older Synapt G1 acquisitions) crashed the native library with an access violation (32) or returned drift bins that do not add up to the spectrum (3). A crash inside the library ends the process with no message, so this is decided from the side file before any spectrum is read. Their other functions convert as before; a run with nothing else is refused, as it was before drift time was read.
  • A converted mobility function without its _FUNCnnn.CDT drift file: the run is refused.

A mobility function recorded in parallel with plain MS functions, on the same pixels, stays out as it did before, and is listed under excluded_functions.

A raster whose chunks mix functions with and without drift bins converts every chunk and records no mobility: the bins would not cover the image. The cost is one library call per bin, about 13 ms per scan for 200 bins on a Synapt G2-Si; there is no single-read record as there is for TDF, so the heatmap and a grid table each take their own pass. --no-mobility-heatmap skips the heatmap's.

PHI SmartSoft-TOF (ToF-SIMS)

A single .raw file from PHI (Physical Electronics) nanoTOF instruments. Unlike Waters and Bruker this needs no vendor SDK -- the format is parsed directly, so it behaves identically on every platform.

It differs from the other formats in one important way: it records individual ion arrivals, not per-pixel spectra. Thyra aggregates those events into sparse spectra on the detector's time-channel grid. Because flight time is what the instrument actually measures, a conversion with --no-resample also stores it as var["tof_us"], so the mass calibration stays reversible. A resampled conversion, the default, has no channel grid left to label.

thyra tofsims_run.raw out.zarr
from thyra.readers.phi import PhiReader

with PhiReader("tofsims_run.raw") as reader:
    print(reader.dimensions)          # (512, 512, 1)
    print(reader.pixel_size_um)       # 1.0
    print(reader.calibration_source)  # 'appended' or 'header'

Verified against the instrument software's own exports: the total ion image is reconstructed bit-exactly, and the exported peak images to 99.7%. Mosaic, MS/MS and depth-profiling acquisitions are implemented but have only been tested against synthetic files -- if you have real data in one of those modes, please open an issue.

Previewing costs nothing. Recording events rather than spectra means the header cannot say which pixels carry one; counting them would mean decoding the whole stream, as slowly as a conversion. So a preview answers from the header and the block chain alone, and reports n_pixels as None: unknown, rather than quietly filled in with the raster size that grid_dims already carries. A conversion is unaffected and still counts every pixel exactly.

See PHI ToF-SIMS Notes for the file layout, the calibration behaviour, and why the time axis is binned the way it is.


mzPeak (experimental)

A single .mzpeak file: a ZIP of Parquet members plus an mzpeak_index.json that maps each member to a role. mzPeak is the HUPO-PSI working group's intended successor to mzML/imzML at the raw/archival layer.

Thyra treats it as an input only and never writes one. Support is marked experimental because the container is a v0.9 draft -- column names, the index vocabulary and the placement of file-level metadata have all moved between prototype revisions and are expected to move again before v1.0. The reader validates what it depends on, so a drifted archive raises a named error rather than converting to something plausible but wrong.

thyra sample.mzpeak out.zarr

Thyra reads both layouts of the signal members. The point layout holds one point per row. The chunked layout holds a run of points per row, and it is what mzpeak-convert writes unless it is given --layout point. Both come out of the reader as the same spectra, so nothing after the reader differs.

Every spectrum has its own axis. The resampling decision tree therefore treats these files exactly as it treats processed imzML, and Thyra never reports a shared mass axis for mzPeak.

Chunk encodings. A chunk names how its m/z values are stored. Thyra decodes four encodings:

Term Encoding m/z against the point layout
MS:1000576 no compression the same
MS:1003089 delta encoding within 7e-15
MS:1002312 MS-Numpress linear prediction within 7e-7, 0.005 ppm (lossy)
MS:1003826 grid encoding, model MS:1003824 (linear) or MS:1003825 (square root) within 1e-7 (lossy)
MS:1003826 grid encoding, model MS:9999002 (timsTOF m/z) within 7e-16 of the converter's own values

The last column was measured on archives that mzpeak-convert 0.14.0 wrote from one input in both layouts. Intensities, pixels and point counts were the same in every case. The timsTOF model is the one mzpeak-convert writes for a timsTOF TDF run by default. Its row was measured against the m/z the converter states for the ends of each chunk.

Converter versions read. Archives written by mzpeak-convert 0.12.0 (the eight public examples), 0.14.0, 0.16.0 and 0.17.2 have been converted. From 0.17.2, archives made from imzML and from Bruker TSF and TDF runs were read. Its Waters lane was not tried.

The first and last m/z of each chunk are exact in every encoding. The chunk states them as plain numbers, and Thyra takes them from there.

The store names the encodings it was read from, in format_specific.chunk_encodings.

Two things follow from a lossy encoding:

  • Convert with resampling, which is the default. Without it the mass axis holds every distinct m/z. A lossy encoding returns one m/z a little differently from pixel to pixel, so that axis grows far beyond the point layout's: 48 million values against 332 thousand, on one centroid image. The log warns of it.
  • A few points land in the neighbouring bin. On a centroid image of 60 million points, 287 did. The total ion current of every pixel was the same.

MS-Numpress decodes at about 2 million points per second, delta encoding at about 19 million. See design decision D28.

Which member is read. mzPeak keeps profile and centroid signal in two separate members. Thyra reads one of them per archive:

The archive holds Member read Spectrum type reported
profile data only data_arrays (spectra_data.parquet) as the archive declares
centroid data only peaks (spectra_peaks.parquet) centroid
both data_arrays profile

The member counts as empty when it has no rows or is not in the archive. The reference converter writes both members every time and leaves one with no rows.

When both hold rows, the two are never added together, and the log says that the centroid member was left unread. A spectrum stored only in the centroid member of such an archive is not converted: its pixel is left out of the store, as an unacquired pixel is.

Point counts come from the column that belongs to the member: number_of_data_points for data_arrays, number_of_peaks for peaks. The layout and the padding correction below are those of the same member. The two members of one archive may differ in layout.

These are refused rather than guessed at, before any spectrum is read:

  • A chunk encoding that is not in the table above. The message gives the CV term. The same holds for a grid model other than the three named there.
  • Intensities stored under a transform: MS-Numpress short logged float (MS:1002314) or positive integer (MS:1002313).
  • Chunks cut along another axis than m/z.
  • A chunk whose m/z and intensity counts differ. This one is found while reading.
  • Non-imaging archives are rejected. Positions are optional in mzPeak -- the reference converter only writes them when it happens to see imaging input -- and an archive without them has no pixels for Thyra to place.
  • A scan with one position and not the other. A scan on a pixel has both IMS:1000050 and IMS:1000051, and a scan on no pixel has neither.
  • Spectra on more than one plane. Thyra reads one plane of an mzPeak archive. So position z (IMS:1000052) may take one value, and is stated for every spectrum on a pixel or for none.
  • Unrecognised layouts fail with the schema they actually carry.

Two behaviours worth knowing:

  • Pixel size is often absent. When IMS:1000046/IMS:1000047 are missing the CLI falls back to --pixel-size exactly as it does for imzML. Terms are matched on accession rather than name, because the controlled vocabulary spells the two axes inconsistently.
  • One pixel size is tested before it is used. See the table below.
  • A scan with no position is left out. The imaging profile gives a scan that belongs to no pixel, a calibration scan for instance, a null in both position columns. Thyra skips its spectrum, as it skips an unacquired pixel, and logs how many it left out. The grid is built from the scans that have a position.
  • Null-pair padding is dropped. mzPeak compresses profile spectra by removing interior runs of zero intensity and marking each gap with two rows whose m/z and intensity are both null; the reference reader regenerates the missing m/z from a per-spectrum polynomial. Those regenerated values are extrapolations that carry zero intensity, so in a sparse matrix they would only add mass-axis channels that can never hold a value. Thyra omits them and logs how many it dropped. Recorded point counts include the padding, so peak totals are corrected against it. The chunked layout marks padding the same way. MS-Numpress has no null, so there a stored m/z of 0 is padding.

Pixel size. Until 2017 IMS:1000046 was named "pixel size" and gave the area of a pixel. Files of that form are still published, and the reference converter copies the term into the archive unchanged. So Thyra reads:

The archive gives Pixel size taken
IMS:1000046 and IMS:1000047 both, as written
IMS:1000046 alone, or named "pixel size", and value x pixel count is the extent the value, on both axes
the same, and sqrt(value) x pixel count is the extent sqrt(value), on both axes
the same, with no pixel count or extent, or fitting neither none: pass --pixel-size
IMS:1000047 alone none: pass --pixel-size

The pixel count is IMS:1000042/IMS:1000043 and the extent is IMS:1000044/IMS:1000045. The two sides may differ by 1%. The log says at WARNING when a value was read as an area and when no pixel size was taken.

A value with no unit is read as micrometres. Millimetre, micrometre and nanometre are converted. Any other unit is refused, centimetre included: the public files that declare centimetre name it "micrometer" beside it. See design decision D27.

Missing pixels are ordinary and are left missing rather than densified.

Read as its source is read. Where an archive states what its source file states, the store comes out as the store of that file. See design decision D29.

  • Where the image sits. Positions count from the base in imaging.coordinate_base, 1 when none is given, as imzML positions do. A cropped image keeps its place on the grid. When a writer shifted the positions and kept the shift in imaging.position_offset, as mzpeak-convert does for a Bruker run, the shift is added back into coordinate_offsets_px. Position z, which mzpeak-convert writes when its source states z, is the z offset; an archive without it records 0 (D30). It is read from a z column (0.16.0) or, failing that, from each scan's parameters list (0.12.0 and 0.14.0).
  • The instrument. The instrument configurations are read as in imzML. An Orbitrap or FT-ICR analyzer or model picks its own mass axis, and a timsTOF model is known by its name.
  • Regions. From 0.17, each scan of a Bruker archive names its region, and that name is read. An older archive only lists its regions under bruker_maldi.regions, each with a box of raster indices and a frame count. Then each pixel is given the region whose box holds it, when the boxes place every pixel once and the counts match. Otherwise the log says why and the store has one region. --region is refused for mzPeak: the whole archive is converted, and obs["region_number"] tells the regions apart.
  • The mass range of a Bruker archive is the run's acquisition range, as for the .d. Any other archive uses the range of its spectra.
  • TDF intensities. A TDF archive holds the raw counts of the run. Bruker's library, which reads the .d, gives each count times 100 over the frame's accumulation time in ms. Thyra applies the same rule, so both routes give the same values. See design decision D33.
  • MSn spectra are left out when MS1 spectra sit on the pixels too, and the log counts them. Summed into the MS1 pixels, they would give spectra the instrument never measured. A spectrum with no stated level is kept. A spectrum that is left out, for this reason or for having no position, adds nothing to the mass axis or the mass range either.
  • Several spectra on one pixel are summed into one row. The log says how many spectra sit on how many pixels.
  • Ion mobility is not read. When scans carry a mobility value or the signal member a mobility array, the log says so.
  • Embedded images are carried into the store as optical images. From 0.17, an archive of a FlexImaging run fits its image to the spots through the teaching points, and marks that affine teach_points. The pixels are then placed on that image, as for the .d. Any other affine stretches the image over the whole acquisition, which is not a registration: such an image is carried over unaligned, and the affine is kept in the raw metadata. An image is named after its member, <dataset_id>_optical_image_0000 for images/image_0000.svs, and the store records that member as its source. The media type the archive declares picks the decoder, so a slide scanner's TIFF named .svs is read as a TIFF. A format other than TIFF, JPEG, PNG and BMP is left out with a warning.

Adding another format

A reader subclasses BaseMSIReader and implements four methods -- _create_metadata_extractor, get_common_mass_axis, iter_spectra and close -- then registers itself with @register_reader("name"). The converter is format-agnostic, so 2D/3D handling, resampling, chunking and the whole SpatialData output path come for free.

Optional overrides that are worth implementing when the format allows it:

Method Buys you
has_shared_mass_axis skips a full pass when the axis is fixed
get_mass_axis_annotations keeps a native non-m/z axis in var
get_region_map / get_region_info per-pixel region annotation
has_acquisition_order / iter_spectra_with_acquisition_order obs["acquisition_order"], the order the pixels were acquired in
get_optical_image_paths optical images carried into the output
get_primary_optical_image_path says which of them the alignment is stated against
get_optical_image_label names an image the reader copied out of a container

See Contributing.