Skip to content

API Reference

In plain words

Change how Thyra converts shows the common settings from Python. This page documents every function.

Thyra's Python API centres on two functions: convert_msi, which does the work, and preview_msi, which tells you what an input is without converting it. For most use cases those are all you need. The remaining sections document configuration types, metadata objects, and base classes for advanced users who want to inspect results or extend Thyra with new formats.


Converting Data

The primary entry point. Detects the input format, reads metadata, and writes a SpatialData/Zarr directory.

Basic usage

from thyra import convert_msi

# Minimal -- auto-detects format and pixel size
success = convert_msi("input.imzML", "output.zarr")

# With explicit parameters
success = convert_msi(
    "data/experiment.d",
    "output/experiment.zarr",
    dataset_id="hippocampus",
    pixel_size_um=10.0,
)

The Python API does not resample by default

resampling_config defaults to None, and nothing builds one for you, so the call above keeps the original mass axis. The thyra command-line tool is the opposite -- it resamples unless you pass --no-resample, because the default is applied in the CLI layer rather than in convert_msi. Pass a resampling_config explicitly to get the CLI's behaviour from Python.

With resampling configuration

success = convert_msi(
    "input.imzML",
    "output.zarr",
    resampling_config={
        "method": "nearest_neighbor",
        "axis_type": "orbitrap",
        "target_bins": 50000,
    },
)

Multi-region dataset (select one region)

region takes either a .mis Area Name or a DB RegionNumber. A string is matched against the area names first and only parsed as a number if no name matches, so the two forms below are not interchangeable -- area '03' need not be RegionNumber 3.

# By the name flexImaging gives the area
success = convert_msi(
    "data/slide.d",
    "output/tissue_only.zarr",
    region="03",
)

# By the database's own region number, which starts at 0
success = convert_msi(
    "data/slide.d",
    "output/tissue_only.zarr",
    region=0,
)

Thyra logs the RegionNumber-to-Area-Name mapping at INFO when it opens a multi-region dataset. See --region for the detail. Only the timsTOF reader can select a region. Every other format refuses region, and the call returns False.

Large datasets

Nothing to switch on: every conversion makes two passes over the source and writes each table from memory-mapped arrays, so the matrix is never held in RAM. The streaming keyword older code passes is accepted and selects nothing.

Full signature

convert_msi(input_path: Union[str, Path], output_path: Union[str, Path], format_type: str = 'spatialdata', dataset_id: str = 'msi_dataset', pixel_size_um: Optional[float] = None, handle_3d: bool = False, z_spacing_um: Optional[float] = None, resampling_config: Optional[Union[Dict[str, Any], ResamplingConfig, MassAxisPlan]] = None, reader_options: Optional[Dict[str, Any]] = None, include_optical: bool = True, apply_optical_alignment: bool = True, streaming: Union[bool, Literal['auto']] = 'auto', region: Optional[Union[int, str]] = None, sample_id: Optional[str] = None, sample_name: Optional[str] = None, **kwargs: Any) -> bool

Convert MSI data to the specified format.

Provides automatic pixel size detection from metadata or accepts user-specified values.

Parameters:

Name Type Description Default
input_path Union[str, Path]

Path to input MSI data file or directory

required
output_path Union[str, Path]

Path for output file

required
format_type str

Output format type (default: "spatialdata")

'spatialdata'
dataset_id str

Identifier for the dataset

'msi_dataset'
pixel_size_um Optional[float]

In-plane pixel size in micrometers (None for auto)

None
handle_3d bool

Whether to process as 3D data (default: False)

False
z_spacing_um Optional[float]

Distance between consecutive slices in micrometers. Only meaningful together with handle_3d=True; ignored (with a warning) otherwise. None (default) means nothing supplied one, in which case the in-plane pitch is reused and the store records z_spacing_source="assumed_isotropic" so the guess is not mistaken for a measurement. Supply it whenever the section thickness is known -- the in-plane pitch matches it only by coincidence.

None
resampling_config Optional[Union[Dict[str, Any], ResamplingConfig, MassAxisPlan]]

Optional resampling configuration: a dict, a ResamplingConfig, or a MassAxisPlan from :func:thyra.plan_mass_axis. With a plan the conversion builds the planned axis and refuses (returns False, nothing written) when its own axis hashes differently.

None
reader_options Optional[Dict[str, Any]]

Optional format-specific reader options: - intensity_threshold: float - Minimum intensity to include. Default: None (no filtering). - use_recalibrated_state: bool - For Bruker data, use active/recalibrated calibration (default True). - tdf_spectrum: "scan_sum" | "vendor_centroid" - For Bruker TDF (TIMS) data, how a frame's mobility scans are collapsed into one spectrum per pixel (default "scan_sum"). - use_centroid: bool - For Waters .raw, whether MassLynx hands back the vendor centroid (True) or the profile trace (False). Default None: the profile trace on a SELECT SERIES MRT, the vendor centroid on every other Waters instrument. The CLI spells it --waters-spectrum.

None
**kwargs Any

Forwarded to the converter. write_mobility_table (default True) writes the mobility-resolved sibling table when the source shares one set of (m/z, mobility) features across pixels, e.g. an imzML export with a mobility array. mobility_heatmap (default True) stores the mean mass-mobility frame on the summed table as uns["mobility_heatmap"] whenever the source has an ion mobility dimension (Bruker TDF, imzML with a mobility array, Waters drift bins). mobility_grid (default False) fills that same sibling for a source that carries mobility per pixel rather than as a shared feature axis (Bruker TDF, processed imzML, Waters) by binning the point cloud onto a common mobility grid; mobility_bins (default: the heatmap's own channels, 256 or one per Waters drift bin), mobility_min and mobility_max size that grid. On a Bruker TDF its marginal reproduces the summed table exactly under the default tdf_spectrum="scan_sum"; an explicit "vendor_centroid" is kept, with a warning, and the mismatch recorded. msms_table (default True) writes the demultiplexed MS/MS sibling table when the source isolates several precursors per pixel in disjoint mobility slices (Bruker PASEF); refused with a reason, and nothing written, on any other source. - max_mass_axis_length: int - For processed-mode imzML converted with --no-resample, give up once the raw mass axis exceeds this many unique m/z values. This matters when the peak lists share no m/z values, where the raw axis grows to roughly one column per peak in the whole dataset. Default: 10,000,000, which is the limit SCiLS Lab places on the same quantity (2026b User Guide, p.76). Pass None for no limit. - spectrum_type: str - For imzML, declare the spectrum representation explicitly: 'profile' or 'centroid' (the full CV names are accepted too). Outranks the file's own MS:1000127/MS:1000128, so it can correct a file that declares the wrong thing; contradicting a declaration is logged as a warning. SCiLS Lab spells this --rep_type (2026b User Guide, p.81). Default: None, meaning detect. Changes stored values for files where it disagrees with what detection would have chosen, because the representation feeds instrument and axis-type selection.

{}
include_optical bool

Include optical images (default: True)

True
apply_optical_alignment bool

If True (default) and the MSI source carries FlexImaging Area metadata, MSI elements are placed in optical-image pixel space at "global". Set to False to keep MSI in pure micrometer coordinates -- needed when a downstream tool (e.g. Ousia's wizard) computes its own MSI-to-target registration.

True
streaming Union[bool, Literal['auto']]

Kept for compatibility; selects nothing. Every conversion streams -- two passes over the source into memory-mapped arrays, the matrix never held in RAM -- since the in-memory converter was folded in (v3.23). False is accepted with a warning. Only True, False and "auto" are accepted at all: a no-op argument still has to say when it was misspelled, and 0 -- which reads as False but is not it -- used to convert in silence.

'auto'
region Optional[Union[int, str]]

Select one region of a multi-region Bruker timsTOF .d. Accepts an int (DB RegionNumber) or a str (matched against .mis Area Name, falling back to integer parse). None (default) converts all regions. Every other format refuses it: no other reader can select a region. :func:thyra.list_regions lists the regions of any input. Passed to the reader as reader_options["region"].

None
sample_id Optional[str]

Your identifier for the sample, written to msi_metadata.sample.sample_id. Never read from the source, because a vendor sample name can identify a person. One conversion stamps the same value on every region it converts. At most 256 printable characters. Default: None (nothing is recorded).

None
sample_name Optional[str]

Your name for the sample, written to msi_metadata.sample.sample_name under the same rules as sample_id. Default: None.

None

Returns:

Type Description
bool

True if the conversion completed and the store was written, False

bool

otherwise. Every failure comes back this way, refusals included:

bool

a refusal Thyra planned for is logged once at ERROR with the

bool

whole explanation and its traceback kept for DEBUG (issue

bool

234), so a caller that wants the exception itself has to use the

bool

converter classes directly.

bool

On failure the destination is cleared. Anything written to

bool

output_path by this call is renamed to a sibling

bool

<name>.failed (.failed2 and so on if one is already

bool

there), so the same call can be retried without the caller

bool

deleting a directory first, and a half-written store is not left

bool

where a finished one is expected -- it would not open, but it

bool

looks like one. When the rename itself fails -- a live handle on

bool

the directory is the usual reason on Windows -- the partial store

bool

stays put and the failure says so at ERROR, naming the path

bool

to delete; a retry is then refused until it is gone. An

bool

output_path that already existed when the call started is

bool

refused untouched, never renamed. See

bool

func:quarantine_partial_output (issue #293).


Previewing an Input

preview_msi answers "what is this file?" without converting it. It detects the format, builds the reader, and reads metadata only -- no spectra are decoded and no store is written -- so it is cheap enough to call while a user waits. It is the entry point behind the Ousia import wizard's per-sample preview card.

Two properties make it usable directly from UI code:

  • It never raises. Any failure -- a path that does not exist, an unrecognised format, a truncated file -- comes back as an MsiPreview with readable=False and error set to the message. Check readable before reading the numeric fields; they hold zeroes when it is False.
  • It is fast. The design budget is under 500 ms for inputs up to about 50 GB, because it never touches the spectra. Each format answers from its header: a Bruker .d from analysis.tdf, an imzML from the block before <run> -- 0.4 ms whether that document is 29 MB or 2.0 GiB. One step is bounded by the file rather than by its header, and it is worth knowing about: a processed-mode imzML records its mass range per spectrum, so reading it costs a pass over the XML (3.9 s on a 2.0 GiB document, 53 ms on a 29 MB one). A continuous-mode one shares a single m/z array across every spectrum, so its first spectrum states the range and the pass stops there. Neither pass opens the .ibd.
from pathlib import Path

from thyra import preview_msi

p = preview_msi(Path("example_data/synthetic_brain.imzML"))

if p.readable:
    print(p.grid_dims)        # (48, 36)
    print(p.n_pixels)         # 1728
    print(p.mz_range)         # (250.0, 1200.0)
    print(p.pixel_size_um)    # 25.0
    print(p.instrument_type)  # AxisType.CONSTANT
else:
    print("cannot read:", p.error)

Failure looks the same shape, which is the point:

p = preview_msi(Path("no/such/file.imzML"))
print(p.readable, p.error)
# False Path does not exist: no\such\file.imzML

Two fields can come back as None rather than a number, and both mean "the file does not say, and a preview will not decode spectra to find out": n_pixels for a PHI acquisition, whose occupied pixels are a property of the ion-event stream; and mz_range for an imzML written without the observed-m/z cvParams MS:1000528 / MS:1000527, which IONTOF SurfaceLab omits. Neither is filled in with a plausible substitute -- taking an imzML's range from its first spectrum was measured 28 Da narrow on a real file.

instrument_type is the AxisType the resampling decision tree would pick for this input, so a caller can show the default before the user commits to it. tof_law is the (A, B) width-law pair the instrument declares, or None. Pass one pair as tof_a/tof_b to every conversion that should share a tof axis. has_escdat_folder reports whether <path>/EscDat/ exists, which is how a downstream tool decides whether EscDat-derived registration is available.

preview_msi(path: Path) -> MsiPreview

Return a metadata-only snapshot of an MSI input.

This is the entry point used by the Ousia Import Wizard to drive the per-sample preview card in step 2. It detects the format, constructs the appropriate reader, and calls :meth:BaseMSIReader.get_essential_metadata (plus :meth:get_comprehensive_metadata for the instrument-type guess). No spectra are decoded.

Parameters:

Name Type Description Default
path Path

Filesystem path to an .imzML file or a Bruker .d directory. May or may not exist; nonexistent paths are returned as readable=False rather than raising.

required

Returns:

Type Description
MsiPreview

class:MsiPreview -- always returns; never raises. Check

MsiPreview

attr:MsiPreview.readable before using the numeric fields.

Example

from thyra import preview_msi p = preview_msi(Path("slice_01.imzML")) if p.readable: ... print(p.grid_dims, p.mz_range)

MsiPreview(mz_range: Optional[Tuple[float, float]], n_pixels: Optional[int], grid_dims: Tuple[int, int], instrument_type: Optional[AxisType], pixel_size_um: Optional[float], has_escdat_folder: bool, readable: bool, error: Optional[str] = None, resampling_method: Optional[ResamplingMethod] = None, regions: Optional[List[Dict[str, Any]]] = None, tof_law: Optional[Tuple[float, float]] = None) dataclass

Metadata-only snapshot of an MSI input.

Attributes:

Name Type Description
mz_range Optional[Tuple[float, float]]

(min_mz, max_mz) of the source mass axis, in Da. (0.0, 0.0) when readable=False. None when the file states no range and finding one would mean decoding spectra, which a preview will not do: an imzML written without the observed-m/z cvParams (IONTOF SurfaceLab writes none) records its extrema only in the .ibd. Deliberately not filled in from the first spectrum -- measured 28 Da narrow on a real file, which is a range a reader would believe (issue #360).

n_pixels Optional[int]

Number of spectra (pixels) the dataset actually holds -- the positions that carry a measurement, not the extent of the raster, which is :attr:grid_dims. 0 when readable=False. None when the format cannot report it without decoding spectra, which a preview will not do: PHI SmartSoft-TOF stores a stream of ion events rather than a list of spectra, so counting occupied pixels means reading the whole file, and previewing a multi-gigabyte acquisition cost what converting it costs (issue #240). None is deliberately not filled in with n_x * n_y: that number is the raster, already in :attr:grid_dims, and putting it here would make this field mean one thing for PHI and another for every other format.

grid_dims Tuple[int, int]

Grid dimensions as (width, height) in pixels -- i.e. (x, y) from :attr:EssentialMetadata.dimensions. (0, 0) when readable=False.

instrument_type Optional[AxisType]

The :class:AxisType the :class:ResamplingDecisionTree picks for this input, or None if metadata could not be extracted. The wizard uses this as a default in step 4; the user may always override.

pixel_size_um Optional[float]

Pixel pitch in micrometres. Thyra stores a (x_um, y_um) tuple internally; preview returns the x component and logs a debug message when x != y (real MSI rasters are essentially always isotropic). None when the source metadata does not declare a pixel size.

has_escdat_folder bool

True iff <path>/EscDat/ exists and is a directory. Used by step 3 to decide whether the EscDat-derived registration is available for this sample. For non-Bruker inputs this is virtually always False, which is the intended behaviour.

readable bool

True iff format detection succeeded, a reader was built, and get_essential_metadata returned without raising.

error Optional[str]

The exception's str() when readable=False; None otherwise.

resampling_method Optional[ResamplingMethod]

The :class:ResamplingMethod the :class:ResamplingDecisionTree picks for this input, or None if metadata could not be extracted. Report this rather than re-deriving a method from :attr:instrument_type: the two are separate answers from the detector, and inferring one from the other gets it wrong wherever a detector pairs them unconventionally. A caller that assumed constant implied profile MALDI -- and so tic_preserving -- reached exactly that bug on PHI ToF-SIMS, which the catch-all default reports as constant while asking for nearest_neighbor.

regions Optional[List[Dict[str, Any]]]

One entry per acquisition region for a MULTI-region input -- a Bruker .d holding several tissue sections -- and None for everything else, which is the common case. Each is {"region_number": int, "n_spectra": int} plus, where the format reports them, "bounds" ((x_min, y_min, x_max, y_max), 0-based, in the frame of :attr:grid_dims) and "name" (the .mis Area label).

Read this before trusting :attr:grid_dims or :attr:n_pixels of a multi-region input: both describe the whole acquisition, so the grid is the bounding box of every region together and is mostly empty. A three-section slide previews as 1170 x 400 for 26,087 spectra.

None means "one region, or the format has no such concept", never "could not tell": a reader that raises is logged and leaves this None, exactly as an unavailable :attr:instrument_type does. Sorted by region_number -- readers are not required to be, and the timsTOF one answers in frame-count order.

tof_law Optional[Tuple[float, float]]

The (A, B) of the two-term TOF width law FWHM(m) = sqrt(A m + B m^2) (A in mDa^2/Da, B dimensionless) that the detected instrument declares, or None when it declares none or metadata could not be read. A conversion with axis_type="tof" and no tof_a/tof_b takes this pair from each source's own detector, so a caller putting several sources on one tof axis reads it here and passes one pair to every conversion. Reported whatever :attr:instrument_type is: an instrument may declare a law without its detector picking tof.


Planning One Mass Axis

plan_mass_axis reads the header of each source (no spectra) and decides one mass axis for all of them. The returned MassAxisPlan is passed to convert_msi as resampling_config, and can be saved to a JSON file for the --mass-axis-plan command-line option. See One axis for several data sets.

plan_mass_axis(sources: Union[str, Path, Sequence[Union[str, Path]]], *, axis_type: Optional[Union[str, AxisType]] = None, min_mz: Optional[float] = None, max_mz: Optional[float] = None, width_at_mz: Optional[float] = None, reference_mz: Optional[float] = None, tof_a: Optional[float] = None, tof_b: Optional[float] = None, bins_per_fwhm: Optional[float] = None, method: Optional[Union[str, ResamplingMethod]] = None) -> MassAxisPlan

Plan one mass axis for several sources.

Each source is read the way :func:thyra.preview_msi reads it: its header only, no spectra. Every setting you pass is used as given. The rest are decided from the sources, and the plan records where each came from in origins and what it costs a source in warnings.

  • Axis type: every source's detector agrees, else refused.
  • Range: the intersection of the sources' ranges. An imzML source's range is its header text moved out by one unit of the last digit it states (100.123457 by 1e-6, 1000 by 1), because the header rounds it and would otherwise cut the source's edge samples.
  • Width (non-tof axes): the widest width any source declares, each first stated at reference_mz under the axis law, so that on the shared axis no source's peaks scatter over bins finer than they are. The narrower sources lose resolution and are named in the warnings.
  • tof axes: the width law every declaring source agrees on.

The plan fixes the axis only. Each conversion keeps its own detector's resampling method unless you force method. Pass the plan as resampling_config to :func:thyra.convert_msi; the conversion refuses when the axis it builds hashes differently from the plan's.

Parameters:

Name Type Description Default
sources Union[str, Path, Sequence[Union[str, Path]]]

Paths of the sources, or one path.

required
axis_type Optional[Union[str, AxisType]]

Force the axis type (a name or an AxisType).

None
min_mz Optional[float]

Force the lower bound of the range.

None
max_mz Optional[float]

Force the upper bound of the range.

None
width_at_mz Optional[float]

Force the bin width in Da at reference_mz.

None
reference_mz Optional[float]

The m/z the width is stated at. Default: 300 for a linear_tof axis, 1000 otherwise.

None
tof_a Optional[float]

Force A of the TOF width law (needs tof_b).

None
tof_b Optional[float]

Force B of the TOF width law.

None
bins_per_fwhm Optional[float]

Force the bins per peak width of a tof axis.

None
method Optional[Union[str, ResamplingMethod]]

Force one resampling method for every conversion.

None

Returns:

Type Description
MassAxisPlan

The plan.

Raises:

Type Description
ConversionRefused

If a source cannot be read or states no m/z range (and min_mz and max_mz were not both given), if the ranges have no m/z in common, if the detectors want different axis types or TOF laws and none was forced, or if the axis cannot be built (including when its bin count would not fit in memory).

MassAxisPlan(axis_type: AxisType, min_mz: float, max_mz: float, reference_mz: float, n_bins: int, sha256: str, width_at_mz: Optional[float] = None, tof_a: Optional[float] = None, tof_b: Optional[float] = None, bins_per_fwhm: Optional[float] = None, method: Optional[ResamplingMethod] = None, origins: Dict[str, str] = dict(), warnings: Tuple[str, ...] = (), sources: Tuple[str, ...] = ()) dataclass

One mass axis, fixed for several sources.

Attributes:

Name Type Description
axis_type AxisType

The spacing law of the bins.

min_mz float

Lower end of the range the bins span.

max_mz float

Upper end of it.

reference_mz float

The m/z at which the bin width is stated.

n_bins int

How many bins the axis has.

sha256 str

The fingerprint of the axis (D42). A conversion given this plan refuses when the axis it builds hashes differently.

width_at_mz Optional[float]

Bin width in Da at reference_mz. None on a tof axis, whose width follows from its law.

tof_a Optional[float]

A of the TOF width law (tof axis only).

tof_b Optional[float]

B of the TOF width law (tof axis only).

bins_per_fwhm Optional[float]

Bins per peak width (tof axis only).

method Optional[ResamplingMethod]

The resampling method, only when the caller forced one. None leaves each source to its own detector.

origins Dict[str, str]

One plain line per setting saying where the value came from.

warnings Tuple[str, ...]

What the plan costs a source, each naming its source.

sources Tuple[str, ...]

The names of the sources the plan was made from.

from_dict(data: Dict[str, Any]) -> MassAxisPlan classmethod

Rebuild a plan from :meth:to_dict output.

A plan file is edited by hand, so what would be ignored is refused: a key :meth:to_dict does not write, a version that is not the whole number 1, an inverted range, and an n_bins that the settings do not lay (it is a result, not a setting).

Raises:

Type Description
ConversionRefused

If data is not a plan of this layout, lacks a setting every plan holds, holds a key no plan has, or is inconsistent in the ways above.

load(path: Union[str, Path]) -> MassAxisPlan classmethod

Read a plan written by :meth:save.

The bytes go to json.loads, which tells UTF-8, UTF-16 and UTF-32 apart and skips a byte-order mark, as a plan saved through Windows PowerShell 5.1 may carry. Text in any other encoding is refused.

Raises:

Type Description
ConversionRefused

If the file cannot be read, is not JSON in one of those encodings, or is not a plan.

save(path: Union[str, Path]) -> None

Write the plan as UTF-8 JSON.

to_dict() -> Dict[str, Any]

A JSON-safe dict: enums as their values, tuples as lists.

to_resampling_config() -> Dict[str, Any]

The explicit request that rebuilds this axis, as a config dict.

Never holds both target_bins and width_at_mz: the bin count follows from the settings, and a conversion refuses the pair. The fingerprint travels as mass_axis_sha256.


Listing Regions

list_regions names the regions of an input without converting it: each region's number, .mis Area name, spectrum count and box in the store's pixel grid. An input with one region, or a format without regions, gives one entry for the whole raster. To write one store per region of a timsTOF slide:

import thyra

for region in thyra.list_regions("data/slide.d"):
    number = region["region_number"]
    thyra.convert_msi("data/slide.d", f"output/region_{number}.zarr", region=number)

list_regions(path: Union[str, Path]) -> List[Dict[str, Any]]

List the acquisition regions of an MSI input without converting it.

Each entry has four keys:

  • region_number: the value the store writes to obs["region_number"] for that region. On a multi-region timsTOF .d it is the DB RegionNumber, which region= accepts.
  • name: the .mis Area name of a region of a multi-region input, or None when the source names none. The whole-run entry of a single-region input has no name.
  • n_spectra: how many spectra the region holds. A position measured twice counts twice. None when the format cannot count without reading spectra.
  • bounds: (x_min, y_min, x_max, y_max), 0-based and inclusive, in the pixel grid of the converted store. None when the reader could not read a region's box.

An input with one region, or a format without regions, gives one entry that covers the whole raster.

Parameters:

Name Type Description Default
path Union[str, Path]

The input, as :func:thyra.convert_msi takes it.

required

Returns:

Type Description
List[Dict[str, Any]]

One dict per region, sorted by region_number.

Raises:

Type Description
ConversionRefused

When the input cannot be read.


Resampling Configuration

When you pass resampling_config to convert_msi, the dictionary keys map to the fields of ResamplingConfig. You can pass a plain dict (as shown in the examples above) or construct the dataclass directly:

from thyra.resampling.types import ResamplingConfig, ResamplingMethod, AxisType

config = ResamplingConfig(
    method=ResamplingMethod.NEAREST_NEIGHBOR,
    axis_type=AxisType.ORBITRAP,
    target_bins=50000,
)

success = convert_msi("input.imzML", "output.zarr", resampling_config=config)

ResamplingConfig(method: Optional[ResamplingMethod] = None, axis_type: Optional[AxisType] = None, target_bins: Optional[int] = None, mass_width_da: Optional[float] = None, reference_mz: float = DEFAULT_REFERENCE_MZ, min_mz: Optional[float] = None, max_mz: Optional[float] = None, gap_tolerance_da: Optional[float] = None, tof_a: Optional[float] = None, tof_b: Optional[float] = None, bins_per_fwhm: Optional[float] = None, mass_axis_sha256: Optional[str] = None) dataclass

Configuration for resampling operations.

All fields default to None (auto-detect from instrument metadata). You can override individual fields while leaving the rest automatic.

Attributes:

Name Type Description
method Optional[ResamplingMethod]

Resampling algorithm. None auto-selects based on the instrument type.

axis_type Optional[AxisType]

Mass axis spacing model. None auto-detects from the instrument metadata.

target_bins Optional[int]

Number of bins in the resampled axis. None derives the count from mass_width_da at reference_mz across the mass range. Note that this is a fixed target width, not a measurement: the source spectra's own point spacing is never consulted, so the resulting axis can be finer or coarser than the data it will hold.

mass_width_da Optional[float]

Bin width in Daltons at reference_mz. Alternative to target_bins: passing both is refused.

reference_mz float

Reference m/z for mass_width_da. Default 1000.0 Da, matching the --resample-reference-mz CLI default.

min_mz Optional[float]

Override the lower bound of the mass range.

max_mz Optional[float]

Override the upper bound of the mass range.

gap_tolerance_da Optional[float]

How far, in Daltons, a target bin may sit from the nearest source m/z before tic_preserving discards its interpolated value instead of trusting it. None -- the default -- means no check, which is how np.interp behaves: straight lines are drawn across regions where nothing was measured. Only affects tic_preserving; nearest_neighbor never invents a bin, and a bin tic_preserving rebins is never discarded. See :mod:thyra.resampling.gaps.

tof_a Optional[float]

A of the two-term TOF width law FWHM(m) = sqrt(A m + B m^2) (mDa^2/Da), for AxisType.TOF. None takes the pair the detected instrument declares, if any. Give both or neither: half a law is refused.

tof_b Optional[float]

B of the same law (dimensionless).

bins_per_fwhm Optional[float]

Bins per peak width for AxisType.TOF. None means 3, or -- when mass_width_da is set -- whatever puts a bin of that width at reference_mz. Refused beside mass_width_da or target_bins, which each fix the bin count too, and on any other axis type (D56).

mass_axis_sha256 Optional[str]

The SHA-256 of the mass axis this conversion is expected to build (:func:thyra.metadata.schema.mass_axis_sha256, design decision D42). None -- the default -- expects nothing. Set by a :class:~thyra.resampling.plan.MassAxisPlan: the conversion refuses when the axis it builds hashes differently, before any spectrum is read.

ResamplingMethod

Bases: Enum

Available resampling methods.

Attributes:

Name Type Description
NONE

No resampling -- keep the original mass axis.

NEAREST_NEIGHBOR

Snap each peak to the nearest target bin.

TIC_PRESERVING

Redistribute intensity so the total ion count is preserved after rebinning (recommended for quantitative work).

NEAREST_NEIGHBOR = 'nearest_neighbor' class-attribute instance-attribute

NONE = 'none' class-attribute instance-attribute

TIC_PRESERVING = 'tic_preserving' class-attribute instance-attribute

AxisType

Bases: Enum

Mass axis spacing model, determined by the analyser physics.

The axis type controls how target bins are distributed across the mass range. When set to None in :class:ResamplingConfig, the type is auto-detected from instrument metadata.

Attributes:

Name Type Description
CONSTANT

Equidistant spacing (constant Da per bin).

LINEAR_TOF

Linear TOF -- spacing proportional to sqrt(m/z).

REFLECTOR_TOF

Reflector TOF -- spacing proportional to m/z.

TOF

General time-of-flight -- spacing proportional to a measured peak width sqrt(A m + B m^2), of which LINEAR_TOF (B = 0) and REFLECTOR_TOF (A = 0) are the limits. Needs the two coefficients; see :mod:thyra.resampling.mass_axis.tof_generator.

ORBITRAP

Orbitrap -- spacing proportional to m/z^(3/2).

FTICR

FTICR -- spacing proportional to m/z^2.

UNKNOWN

No analyser identified. Nothing in Thyra ever produces this member -- an undetected analyser is left as None, which means "auto-detect" and does fall back to constant spacing. Reaching it therefore takes a caller who names it, and what happens then depends on how it is named. As a string in a dict, {"axis_type": "unknown"} is refused during normalisation. As the enum member on a ResamplingConfig, it is NOT refused there: that class is a plain dataclass with no validation, and normalisation returns a ResamplingConfig unchanged. It is stopped later, by :meth:~thyra.resampling.common_axis.CommonAxisBuilder.build_physics_axis, because there is no spacing model to build an axis from.

CONSTANT = 'constant' class-attribute instance-attribute

FTICR = 'fticr' class-attribute instance-attribute

LINEAR_TOF = 'linear_tof' class-attribute instance-attribute

ORBITRAP = 'orbitrap' class-attribute instance-attribute

REFLECTOR_TOF = 'reflector_tof' class-attribute instance-attribute

TOF = 'tof' class-attribute instance-attribute

UNKNOWN = 'unknown' class-attribute instance-attribute


Metadata Types

Readers expose metadata through two dataclasses. EssentialMetadata contains everything needed for conversion decisions (grid size, mass range, memory estimate). ComprehensiveMetadata wraps essential metadata and adds vendor-specific details for provenance and QC.

from thyra.readers.imzml import ImzMLReader

with ImzMLReader("sample.imzML") as reader:
    meta = reader.get_essential_metadata()
    print(f"Grid: {meta.dimensions}")
    print(f"m/z range: {meta.mass_range}")
    print(f"Spectra: {meta.n_spectra}")
    print(f"Peaks: {meta.total_peaks}")

EssentialMetadata(dimensions: Tuple[int, int, int], coordinate_bounds: Tuple[float, float, float, float], mass_range: Tuple[float, float], pixel_size: Optional[Tuple[float, float]], n_spectra: int, total_peaks: int, source_path: str, coordinate_offsets: Optional[Tuple[int, int, int]] = None, spectrum_type: Optional[str] = None, z_spacing_um: Optional[float] = None, n_spectra_counted: bool = True, mass_range_known: bool = True, spectrum_type_guessed: bool = False, stage_origin_um: Optional[Tuple[float, float]] = None) dataclass

Critical metadata for processing decisions and interpolation setup.

Attributes:

Name Type Description
dimensions Tuple[int, int, int]

Grid dimensions as (x, y, z).

coordinate_bounds Tuple[float, float, float, float]

Spatial extent as (min_x, max_x, min_y, max_y).

mass_range Tuple[float, float]

Mass-to-charge range as (min_mz, max_mz).

pixel_size Optional[Tuple[float, float]]

In-plane pixel dimensions as (x_um, y_um) in micrometres, or None when not detected. This is the raster pitch and says nothing about z; see z_spacing_um.

n_spectra int

Total number of spectra in the dataset -- the ones actually present, not the positions the raster covers. Meaningless unless n_spectra_counted is True.

n_spectra_counted bool

Whether n_spectra was counted at all. False only on the metadata-only path of a format that cannot count spectra without decoding them: PHI stores a stream of ion events rather than a list of spectra, so which pixels carry one is a property of the data, not of the header (issue #240). When False, n_spectra and total_peaks are 0 meaning "not counted", which is not the same as "none present" -- callers that display a count must say so rather than print the zero.

total_peaks int

Total number of peaks across all spectra (used for sparse matrix pre-allocation).

mass_range_known bool

Whether mass_range was read at all. False only on the metadata-only path of a file that records no range of its own: an imzML written without the observed-m/z cvParams (MS:1000528/MS:1000527) states its extrema nowhere but in the binary, and a preview does not decode spectra to find them (issue #360). When False, mass_range is (0.0, 0.0) meaning "not read", which is not a range any acquisition has -- callers must say "unknown" rather than print it.

source_path str

Absolute path to the source data.

coordinate_offsets Optional[Tuple[int, int, int]]

Raw coordinate offsets (x, y, z) used to normalise coordinates to 0-based indexing.

stage_origin_um Optional[Tuple[float, float]]

Where grid index (0, 0) sits on the stage, as (x_um, y_um), for a source that re-zeroes on a measured stage position rather than on raster indices (Waters, whose origin is fitted, not an integer index). None for every other source. A store's "acquisition" coordinate system translates by it (D48).

spectrum_type Optional[str]

Spectrum type string (e.g. "centroid spectrum"), used to guide resampling decisions.

z_spacing_um Optional[float]

Distance between consecutive slices in micrometres, or None when the source cannot report it. This is a physical distance set by how the sections were cut, and is unrelated to the in-plane pitch in pixel_size -- the two agree only by coincidence. No reader populates this today: imzML has no standard term for it, and 3D acquisitions are frequently non-consecutive sections, so the value usually has to be supplied by the user. The field exists so a format that does record it has somewhere to report it, and so the converter's precedence chain has something to consult.

spectrum_type_guessed bool

True when the reader inferred the spectrum type rather than reading it from the file or the user. The value is still used for resampling, but it is not stated in msi_metadata.

has_pixel_size: bool property

Check if pixel size information is available.

is_3d: bool property

Check if dataset is 3D (z > 1).

ComprehensiveMetadata(essential: EssentialMetadata, format_specific: Dict[str, Any], acquisition_params: Dict[str, Any], instrument_info: Dict[str, Any], raw_metadata: Dict[str, Any]) dataclass

Complete metadata including format-specific details.

Wraps :class:EssentialMetadata and adds vendor-specific information that is not needed for conversion but useful for provenance and QC.

Attributes:

Name Type Description
essential EssentialMetadata

Core metadata required for conversion.

format_specific Dict[str, Any]

Vendor-specific metadata (e.g. ImzML CV params, Bruker property tables).

acquisition_params Dict[str, Any]

Acquisition parameters such as polarity, scan range, and laser settings.

instrument_info Dict[str, Any]

Instrument model, serial number, and software version.

raw_metadata Dict[str, Any]

Unprocessed metadata as read from the source file, preserved for round-trip fidelity -- less the fields that name a person, and with a path the vendor recorded reduced to its file name, as in every vendor dictionary here (see :mod:thyra.metadata.personal_data).

coordinate_bounds: Tuple[float, float, float, float] property

Convenience access to coordinate bounds from essential metadata.

dimensions: Tuple[int, int, int] property

Convenience access to dimensions from essential metadata.

pixel_size: Optional[Tuple[float, float]] property

Convenience access to pixel size from essential metadata.


Metadata Schema

The versioned, ontology-mapped uns["msi_metadata"] block every store carries. See Metadata Schema for the storage contract, the CLI (thyra metadata, thyra validate, thyra export-metaspace), and the versioning rules.

read_metadata_document builds the block from a raw source instead of reading it back out of a store, so the same document is available before a conversion and for a source that will never be converted at all.

from thyra import read_metadata_document
from thyra.metadata.schema import (
    read_msi_metadata_blocks,
    to_metaspace,
    validate_document,
)

blocks = read_msi_metadata_blocks("output.zarr")
meta, issues = validate_document(blocks["msi_dataset_z0"])
submission, warnings = to_metaspace(meta)

# The same document, from the raw file rather than the store
document = read_metadata_document("raw_data.d")

MSIMetadata

Bases: _SchemaModel

The versioned MSI metadata document.

sample and preparation cannot be auto-populated from raw files and default to empty; Thyra fills sample.sample_id and sample.sample_name only when the conversion was given them; ms_analysis and provenance are written by the converter for every store; acquisition, calibration and alignment are each written when the reader reports at least one of their facts and are absent -- not empty -- otherwise.

The emitted JSON Schema carries $id and $schema so that the committed artifact names its own published address; they are added here, on the root model, rather than pasted into the file, so the sync test that compares the file with model_json_schema() keeps holding.

to_uns_dict() -> Dict[str, Any]

Serialise for storage in table.uns.

None fields are dropped (which is what leaves an unset acquisition, calibration or alignment section out), and the sample / preparation / processing sections are omitted entirely when empty -- following the store convention that a section the source has nothing for is omitted rather than written empty, so consumers can tell "not available" from "available and empty".

processing is stored as a JSON string: it is a list of objects, which AnnData/zarr cannot round-trip (the same reason uns["regions"] is JSON). read_msi_metadata_blocks and validate_document both decode it transparently. The lists in :data:PACKED_OBJECT_LISTS -- the isolation windows and the teaching points -- are lists of objects too and get the same treatment.

validate_document(doc: Any) -> Tuple[Optional[MSIMetadata], List[ValidationIssue]]

Validate one metadata document.

Parameters:

Name Type Description Default
doc Any

The parsed document (normally a dict read from a store's uns["msi_metadata"] or from a JSON file).

required

Returns:

Type Description
Optional[MSIMetadata]

(model, issues). The model is None when structural

List[ValidationIssue]

validation failed; issues carry everything found, errors first

Tuple[Optional[MSIMetadata], List[ValidationIssue]]

is not guaranteed -- filter on severity.

read_msi_metadata_blocks(store_path: Union[str, Path]) -> Dict[str, Dict[str, Any]]

Read every table's msi_metadata block from a SpatialData store.

Parameters:

Name Type Description Default
store_path Union[str, Path]

Path to a converted .zarr store.

required

Returns:

Type Description
Dict[str, Dict[str, Any]]

Mapping of table name to the block as a plain dict. Tables

Dict[str, Dict[str, Any]]

without a block are skipped; a store written by a Thyra version

Dict[str, Dict[str, Any]]

that predates the schema therefore returns an empty mapping.

Raises:

Type Description
ValueError

If the path is not a SpatialData store (no tables group).

read_metadata_document(path: Path | str) -> Dict[str, Any]

Build the msi_metadata document for a raw MSI source.

Parameters:

Name Type Description Default
path Path | str

Path to an input in any format Thyra reads -- the same paths :func:thyra.convert.convert_msi accepts.

required

Returns:

Type Description
Dict[str, Any]

The document as a plain dict, in the shape

Dict[str, Any]

func:~thyra.metadata.schema.store_io.read_msi_metadata_blocks

Dict[str, Any]

returns for a converted store. processing is empty: nothing

Dict[str, Any]

has been done to the data. ms_analysis.pixel_size_um is

Dict[str, Any]

absent when the source states no raster pitch, which makes the

Dict[str, Any]

document invalid against schema 0.6.0 -- the caller reports that

Dict[str, Any]

rather than this function hiding it.

Raises:

Type Description
ValueError

If the format cannot be detected or has no reader. The registry's own message names what Thyra does read.

Exception

Whatever the reader raises for a file it cannot open. Deliberately not swallowed, unlike :func:thyra.preview_msi, whose contract is to never fail: a preview that cannot read a folder still has a card to draw, while a metadata document that cannot read one has nothing to write.

to_metaspace(meta: MSIMetadata) -> Tuple[Dict[str, Any], List[str]]

Render the METASPACE submission metadata document.

Parameters:

Name Type Description Default
meta MSIMetadata

A validated MSI metadata document.

required

Returns:

Type Description
Dict[str, Any]

(document, warnings) -- the submission JSON structure, and

List[str]

one warning per required field that had to be left empty.

build_msi_metadata(comprehensive: Optional[ComprehensiveMetadata], *, pixel_size_um: Tuple[float, float], pixel_size_source: Optional[str] = None, source_format: Optional[str] = None, processing: Optional[List[ProcessingStep]] = None, mobility_resolved_table: Optional[str] = None, mobility_grid: Optional[Dict[str, Any]] = None, fragmentation: Any = None, msms_resolved_table: Optional[str] = None, mass_axis: Optional[Dict[str, Any]] = None, sample_id: Optional[str] = None, sample_name: Optional[str] = None) -> MSIMetadata

Build an :class:MSIMetadata document from extracted metadata.

Parameters:

Name Type Description Default
comprehensive Optional[ComprehensiveMetadata]

The reader's comprehensive metadata, or None when unavailable -- the document is still built, carrying the pixel size and provenance.

required
pixel_size_um Tuple[float, float]

Resolved in-plane pixel pitch (x_um, y_um). Required: conversion refuses to run without one, so a block without it describes no store Thyra ever wrote.

required
pixel_size_source Optional[str]

How the pixel size was determined ("automatic" / "manual" / "default").

None
source_format Optional[str]

Detected input format name ("imzml", "bruker", ...), when known.

None
processing Optional[List[ProcessingStep]]

Ordered processing steps performed so far, oldest first (see :class:ProcessingStep).

None
mobility_resolved_table Optional[str]

Element key of the mobility-resolved sibling table written beside the summed table, when one was.

None
mobility_grid Optional[Dict[str, Any]]

The common mobility grid that table was binned onto, as :meth:thyra.resampling.mobility_grid.MobilityGrid.to_schema_report renders it. None for a table read off a shared feature axis, which was binned onto nothing.

None
fragmentation Any

What the reader reported about fragmentation, as :meth:thyra.core.msms.FragmentationSchedule.to_extractor_report renders it. None means the reader did not say, which is not the same as "MS1" and leaves the block unset.

None
msms_resolved_table Optional[str]

Element key of the demultiplexed MS/MS sibling table written beside the summed table, when one was.

None
mass_axis Optional[Dict[str, Any]]

The store's mass axis fingerprint, {"sha256": ..., "n_bins": ...} (see :func:thyra.metadata.schema.axis_fingerprint.mass_axis_sha256). None for a standalone document, which has no axis.

None
sample_id Optional[str]

The user's identifier for the sample, or None. Never read from the source: a sample name can identify a person.

None
sample_name Optional[str]

The user's name for the sample, or None; same rule.

None

Returns:

Type Description
MSIMetadata

The populated document. Fields the source does not report are

MSIMetadata

left unset, and the acquisition, calibration and

MSIMetadata

alignment sections are each absent when the reader reported

MSIMetadata

none of their facts.


Reader Base Class

All format readers (ImzML, Bruker, Waters, PHI) inherit from this base class. If you are writing a custom reader for a new format, subclass BaseMSIReader and implement the abstract methods below. See Supported Formats for which optional methods are worth implementing and what each one buys you.

BaseMSIReader(data_path: Path, intensity_threshold: Optional[float] = None, **kwargs: object)

Bases: ABC

Abstract base class for reading MSI data formats.

Initialize the reader with the path to the data.

Parameters:

Name Type Description Default
data_path Path

Path to the data file or directory

required
intensity_threshold Optional[float]

Minimum intensity value to include. Values below this threshold are filtered out during iteration. Useful for removing detector noise in continuous mode data. Default: None (no filtering, include all values).

None
**kwargs object

Additional reader-specific parameters. max_mass_axis_length caps how many unique m/z values a raw axis may reach before the build gives up. It lives here rather than on one reader because five of them build such an axis through the same accumulator (issue #294); it defaults to None, no cap, everywhere except imzML, which sets its own documented default. A reader that does not build a raw axis simply never consults it. region is refused here: only the timsTOF reader can select a region, and it takes the option as a named parameter, so it never arrives in these kwargs.

{}

Raises:

Type Description
ConversionRefused

When region is given. Dropping it would convert every region of an input that has several.

has_acquisition_order: bool property

Whether the source says in which order its spectra were acquired.

has_shared_mass_axis: bool property

Check if all spectra share the same m/z axis.

For continuous ImzML data, all spectra have identical m/z values, so get_common_mass_axis() only needs to read the first spectrum. For processed/centroid data, each spectrum may have different m/z values, requiring iteration through all spectra.

Returns:

Type Description
bool

True if all spectra share the same m/z axis (continuous mode),

bool

False if each spectrum has different m/z values (processed mode).

has_ion_mobility: bool property

Whether the source carries a mobility dimension at all.

has_frame_scans: bool property

Whether :meth:iter_frame_scans is available on this source.

get_essential_metadata() -> EssentialMetadata

Get essential metadata for processing.

get_comprehensive_metadata() -> ComprehensiveMetadata

Get complete metadata.

get_common_mass_axis() -> NDArray[np.float64] abstractmethod

Return the common mass axis for all spectra.

This method must always return a valid array. If no common mass axis can be created, implementations should raise an exception.

get_mass_axis_annotations() -> Optional[dict]

Get extra per-channel columns to store alongside the m/z axis.

Returns a mapping of column name to an array with one entry per entry of :meth:get_common_mass_axis. These are written into the table's var next to mz.

This exists so a format whose native axis is not m/z can keep that axis in the output. Time-of-flight instruments measure flight time and derive m/z from a calibration, so storing the flight time makes the conversion reversible without the reader: a later recalibration can be applied to the stored times directly.

Annotations are dropped if their length does not match the axis the converter actually writes, which is what happens when resampling is enabled and the axis is rebuilt.

Returns:

Type Description
Optional[dict]

Mapping of column name to per-channel values, or None.

get_optical_image_paths() -> List[Path]

Get paths to optical/microscopy images associated with this data.

Returns the image files that hold optical images of the sample -- TIFF, JPEG, PNG or BMP. These images can be stored alongside MSI data in SpatialData output for multimodal analysis.

Default implementation returns empty list. Subclasses should override to return paths to optical images specific to their format.

Returns:

Type Description
List[Path]

Paths to optical images, empty if none are available. When one of

List[Path]

them is the alignment image (see

List[Path]

meth:get_primary_optical_image_path) it comes first.

get_primary_optical_image_path() -> Optional[Path]

Get the optical image the source designates as the alignment image.

Some formats say which of an acquisition's images the registration is stated against -- FlexImaging names it in the .mis <ImageFile> element -- and that one gets the identity transform while the others are scaled into its pixel space. Without it the converter has no way to tell an alignment scan from a slide overview that happens to sit in the same folder.

Default implementation returns None, which leaves every optical image equal. Override alongside :meth:get_optical_image_paths when the format designates one; it must be a member of that list.

Returns:

Type Description
Optional[Path]

Path to the designated image, or None if the format does not

Optional[Path]

designate one or the designated file is not present.

iter_spectra() -> Generator[Tuple[Tuple[int, int, int], NDArray[np.float64], NDArray[np.float64]], None, None] abstractmethod

Iterate through spectra with optional batch processing.

Yields:

Type Description
Tuple[Tuple[int, int, int], NDArray[float64], NDArray[float64]]

Tuple containing:

  • Coordinates (x, y, z) using 0-based indexing
  • m/z values array

  • Intensity values array

Note

Subclasses should apply intensity threshold filtering by calling _apply_intensity_filter() on the intensities before yielding.

iter_spectra_with_acquisition_order() -> Generator[Tuple[Tuple[int, int, int], int, NDArray[np.float64], NDArray[np.float64]], None, None]

:meth:iter_spectra, with each spectrum's acquisition order.

Yields ((x, y, z), order, mzs, intensities): the spectra :meth:iter_spectra yields, in its order, with its coordinates and arrays. order grows with acquisition time and no two spectra share one. It is the source's own number for the spectrum where the source numbers them (a Bruker Frames.Id), else the spectrum's 0-based position in the sequence the source lists them in. So it need not start at 0 or be contiguous: sort by it, never index with it.

The converter reads the spectra through this method whenever :attr:has_acquisition_order is True, so a subclass that changes what :meth:iter_spectra yields has to change this as well.

Raises:

Type Description
NotImplementedError

When :attr:has_acquisition_order is False.

get_region_map() -> Optional[dict]

Get per-pixel region mapping for multi-region datasets.

Returns a dictionary mapping normalized (0-based) (x, y) coordinate tuples to integer region numbers. This enables the converter to annotate each pixel with its acquisition region in obs["region_number"].

Default implementation returns None (single-region or no region info). Subclasses should override when region information is available.

Returns:

Type Description
Optional[dict]

Dict mapping (x, y) tuples to region numbers, or None if

Optional[dict]

region information is not available.

get_region_info() -> Optional[list]

Get summary information about acquisition regions.

Returns a list of dictionaries, each describing one region with at minimum: {"region_number": int, "n_spectra": int}. Additional keys (e.g. "name") are format-specific and optional.

Default implementation returns None (single-region or no region info). Subclasses should override when region information is available.

Returns:

Type Description
Optional[list]

List of region summary dicts, or None if region information

Optional[list]

is not available.

get_mobility_axis() -> Optional[MobilityAxis]

Describe the mobility dimension, or None when there is none.

iter_mobility_spectra() -> Generator[Tuple[Tuple[int, int, int], NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]], None, None]

Iterate through spectra with their per-point mobility values.

Yields ((x, y, z), mzs, mobilities, intensities) per spectrum: the raw (m/z, mobility) point cloud, unbinned, with the three arrays parallel, at the same coordinates :meth:iter_spectra gives it. A source may place two spectra on one position. Readers whose source has no mobility dimension do not implement this.

Raises:

Type Description
NotImplementedError

If the reader exposes no mobility dimension.

get_fragmentation() -> Optional[FragmentationSchedule]

Describe the fragmentation, or None when the reader cannot.

None exactly when :attr:has_fragmentation is False.

iter_precursor_spectra() -> Generator[Tuple[Tuple[int, int, int], int, NDArray[np.float64], NDArray[np.float64]], None, None]

Iterate the fragment spectra of each precursor separately.

Yields ((x, y, z), window_index, mzs, intensities): one spectrum per pixel per precursor, where window_index is the position of that precursor in :meth:get_fragmentation's windows. Only a source that separates its precursors within a pixel implements this -- Bruker PASEF gives each isolation window its own slice of the mobility ramp, so the split is a filter on the scan number rather than a deconvolution.

A pixel-precursor pair with no ion current is not yielded. :meth:iter_spectra is unaffected and keeps yielding the summed spectrum.

Raises:

Type Description
NotImplementedError

If the reader cannot separate precursors.

iter_frame_scans() -> Generator[FrameScans, None, None]

Iterate the frames as records that derive every view of a pixel.

Yields one :class:~thyra.core.frames.FrameScans per frame, in the same order and with the same coordinates as :meth:iter_spectra, including frames whose summed spectrum is empty (their :meth:~thyra.core.frames.FrameScans.spectrum is None), so a consumer can still count them for the sinks that do not depend on the summed spectrum.

Raises:

Type Description
NotImplementedError

When :attr:has_frame_scans is False.

close() -> None abstractmethod

Close all open file handles.

A reader whose summed spectrum, mobility point cloud and precursor spectra all derive from one raw read per pixel can hand that read over as a record, so a conversion that writes several tables reads the source once per pass (design decision D5). The Bruker TDF reader does; the record's contract is:

FrameScans

Bases: Protocol

One pixel's raw read, with every view of it derived on demand.

Each method answers exactly as the corresponding reader iterator would have yielded for this frame -- the same arrays, the same filtering, the same "nothing" -- so a consumer fed from records is fed what its own pass would have read.

acquisition_order: Optional[int] property

The frame's order as iter_spectra_with_acquisition_order yields it.

None when the reader does not know the order.

mobility_points() -> Optional[MobilityPoints]

(mzs, mobility, intensities) as iter_mobility_spectra yields, or None.

mobility_points_indexed() -> Optional[IndexedMobilityPoints]

The same points with the m/z left factored, or None for no points.

(unique_mz, inverse, mobility, intensities) where unique_mz[inverse] is exactly the mzs of :meth:mobility_points, element for element, and the three other arrays are that method's own. A digitizer reports the points of one frame at far fewer distinct m/z values than it reports points, so a consumer that has to map m/z onto an axis can map unique_mz once and gather by inverse for the same bins.

None exactly where :meth:mobility_points returns None: the frame holds no points. A record whose points do not factor this way does not offer the method at all -- consumers reach for it with :func:getattr and fall back to the flat view -- so None never means "ask the other way".

precursor_spectra() -> List[PrecursorSpectrum]

[(window_index, mzs, intensities), ...] as iter_precursor_spectra yields.

Empty when the acquisition has no separable precursor schedule or the frame holds no isolated points.

spectrum() -> Optional[Spectrum]

(mzs, intensities) as :meth:BaseMSIReader.iter_spectra yields, or None.

None where the iterator would have skipped the frame (an empty spectrum, or one emptied by the intensity threshold).


Converter Base Class

All output converters inherit from this base class. Currently only SpatialData output is supported, but the architecture allows adding new output formats by subclassing BaseMSIConverter.

BaseMSIConverter(reader: BaseMSIReader, output_path: Union[str, Path, PathLike[str]], dataset_id: str = 'msi_dataset', pixel_size_um: float = 1.0, pixel_size_source: PixelSizeSource = PixelSizeSource.DEFAULT, compression_level: int = 5, handle_3d: bool = False, z_spacing_um: Optional[float] = None, **kwargs: Any)

Bases: ABC

Base class for MSI data converters with shared functionality.

Implements common processing steps while allowing format-specific customization.

Initialize the MSI converter.

Parameters:

Name Type Description Default
reader BaseMSIReader

MSI data reader instance

required
output_path Union[str, Path, PathLike[str]]

Path for output file

required
dataset_id str

Identifier for the dataset

'msi_dataset'
pixel_size_um float

In-plane pixel pitch in micrometers along x. The y pitch starts equal to it and is replaced by the detected one when the source declares an anisotropic raster -- see :attr:pixel_size_y_um.

1.0
pixel_size_source PixelSizeSource

How pixel size was determined

DEFAULT
compression_level int

Compression level for output

5
handle_3d bool

Whether to process as 3D data

False
z_spacing_um Optional[float]

Distance between consecutive slices in micrometers. Only meaningful with handle_3d=True. None (default) means "nobody said", which falls back to the in-plane pitch and records that it was assumed -- see :meth:_resolve_z_spacing.

None
**kwargs Any

Additional keyword arguments

{}

Raises:

Type Description
ConversionRefused

If z_spacing_um is given and is not positive, a non-number included.

pixel_size_um = pixel_size_um instance-attribute

pixel_size_source = pixel_size_source instance-attribute

dataset_id = dataset_id instance-attribute

handle_3d = handle_3d instance-attribute

convert() -> bool

Template method defining the conversion workflow.

An interrupt is a failed conversion, not a separate kind of exit. KeyboardInterrupt is caught here so the caller gets False and the CLI's _handle_post_conversion runs, renaming the partial store to .failed and leaving the output path free for a retry -- which is what docs/cli.md promises of any failed conversion. Before this it propagated past except Exception, click printed Aborted!, and an interrupted run left an unopenable store where a finished one belongs, a blocked retry, and (on a whole-slide mobility grid) 18 GB of scratch memmaps nothing came back to remove (issue #245).

The one exception is an interrupt that lands after the store is complete (:attr:store_complete): the store holds every row and opens, so it is kept and the conversion reports success (issue #500).

Returns:

Type Description
bool

True if the conversion succeeded, False otherwise.


Format Detection and Plugin Registry

Thyra uses a registry to map file extensions and directory structures to the correct reader and converter classes. The public functions below let you detect formats programmatically or register your own reader/converter.

Detecting a format

from pathlib import Path
from thyra.core.registry import detect_format

fmt = detect_format(Path("experiment.imzML"))  # "imzml"
fmt = detect_format(Path("data.d"))            # "bruker" or "rapiflex"
fmt = detect_format(Path("data.raw"))          # "waters"

Registering a custom reader

from thyra.core.registry import register_reader
from thyra.core.base_reader import BaseMSIReader

@register_reader("my_format")
class MyFormatReader(BaseMSIReader):
    ...

The decorator runs when your module is imported, and the format is available from that point on. Thyra's own readers work the same way, but nothing imports them up front: the registry holds a table of format -> module and imports a format's module the first time that format is looked up or detected. So import thyra loads no reader, no converter and no spatialdata, and importing the metadata schema on its own costs a pydantic model rather than the whole imaging stack.

detect_format(input_path: Path) -> str

Detect MSI format from input path.

Parameters:

Name Type Description Default
input_path Path

Path to MSI data file or directory

required

Returns:

Type Description
str

Format name ('imzml', 'bruker', 'rapiflex', 'solarix', 'waters',

str

or 'phi')

register_reader(format_name: str) -> Callable[[Type[_ReaderT]], Type[_ReaderT]]

Decorator for reader registration.

register_converter(format_name: str) -> Callable[[Type[_ConverterT]], Type[_ConverterT]]

Decorator for converter registration.