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
.rawis a directory containing_FUNC001.DAT,_FUNC002.DAT, … - PHI
.rawis a single file whose header begins with the ASCII magicSOFH
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
.misfile next to the.d(same stem). Without it, pass--pixel-sizeexplicitly -- Thyra never guesses. - A solariX-family
.dthat carries raw transients but nopeaks.sqlitecannot 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_functionsin 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_accessionMS:1002476, unitUO:0000028(millisecond),valuesthe drift time of each bin,source"waters_raw".uns["mobility_heatmap"]and, with--mobility-grid, the{table}_mobilitysibling 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'scurrent_ratiosays by how much;--waters-spectrum profilemakes the two agree.
Two things are not read:
- The mobility functions of a run that does not declare
Acquisition Algorithm: ADC Modein_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.CDTdrift 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.
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 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:1000050andIMS: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:1000047are missing the CLI falls back to--pixel-sizeexactly 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 inimaging.position_offset, asmzpeak-convertdoes for a Bruker run, the shift is added back intocoordinate_offsets_px. Position z, whichmzpeak-convertwrites 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'sparameterslist (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.--regionis refused for mzPeak: the whole archive is converted, andobs["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_0000forimages/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.svsis 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.