Skip to content

Centroiding

timsTOF raw data is profile-like: the binary file stores one intensity value per scan per m/z index, spread across hundreds of mobility bins. Centroiding collapses that cloud of raw measurements into a compact list of peaks — each with a single m/z, intensity, and ion mobility value.

tdfpy provides these convenience functions:

  • get_centroided_spectrum: high-level. Reads a full frame from disk, applies optional region exclusion, smoothing and noise filtering, and returns centroided peaks in one call.
  • get_raw_peaks: the same pipeline without the centroider. Returns every raw peak.
  • get_mobility_collapsed_spectrum: sums intensities per TOF index across one or more scan ranges (collapsing ion mobility) and merges them by m/z. This is how DDA precursor MS2 spectra (Precursor.merged_peaks()) are built.
  • merge_peaks: low-level. Centroids pre-assembled NumPy arrays of m/z, intensity, and ion mobility values. Use this when you already have the raw arrays or need fine-grained control.

In practice, most workflows should call .centroid() directly on a Frame, DiaWindow, or PrmTransition object — that method delegates to get_centroided_spectrum internally.

Numba JIT backend

When Numba is installed (it is included in the default tdfpy dependencies), the core clustering loop runs as a JIT-compiled native function (_merge_peaks_numba_kernel). This is typically 5–20× faster than the pure-Python fallback for large frames. The backend is selected automatically:

from tdfpy import merge_peaks

# Numba used if available (default)
peaks = merge_peaks(mz, intensity, im)

# Force the Python fallback (useful for debugging or environments without Numba)
peaks = merge_peaks(mz, intensity, im, use_numba=False)

The first call after import triggers Numba's JIT compilation — expect a few seconds of overhead. Subsequent calls use the cached compiled kernel.


get_centroided_spectrum

Reads frame frame_id from the open TimsData connection, converts m/z indices to m/z values, assembles the raw peak arrays, optionally filters noise, and runs centroiding.

from tdfpy import timsdata_connect, get_centroided_spectrum, MergePeaksCentroider

with timsdata_connect("experiment.d") as td:
    # Default: 1/K0 ion mobility, MergePeaksCentroider with 8 ppm m/z tolerance
    peaks = get_centroided_spectrum(td, frame_id=1)
    print(peaks.shape)   # (N, 3) — columns: [m/z, intensity, 1/K0]

    # Tighter tolerances (set on the centroider), CCS instead of 1/K0
    peaks = get_centroided_spectrum(
        td,
        frame_id=1,
        mobility_type="ccs",
        centroid=MergePeaksCentroider(mz_tolerance=5.0, im_tolerance=0.03),
    )

    # Noise filtering before centroiding (string shorthand)
    peaks = get_centroided_spectrum(td, frame_id=1, noise="mad")

    # Hard intensity threshold
    peaks = get_centroided_spectrum(td, frame_id=1, noise=500.0)

    # Composed pipeline + region exclusion + tuned filter
    from tdfpy import ChargeStateRegion, MadThreshold, VerticalNoiseFilter
    peaks = get_centroided_spectrum(
        td, frame_id=1,
        exclude=ChargeStateRegion(),
        noise=[VerticalNoiseFilter(min_streak_scans=5), MadThreshold(k=3)],
    )

    # Watershed centroider (integer-index space, no float-m/z binning)
    from tdfpy import WatershedCentroider
    peaks = get_centroided_spectrum(
        td, frame_id=1,
        centroid=WatershedCentroider(attach_scan_half_width=10, attach_mz_idx_half_width=3),
    )

The noise= parameter accepts the string shorthand ("mad", "percentile", "histogram", "baseline", "iterative_median"), a numeric absolute threshold, or any NoiseFilter instance / list — see Noise filters for the full hierarchy. The exclude= parameter accepts a ChargeStateRegion. The centroid= parameter swaps the centroiding algorithm — see Pipeline → Centroiders.

tdfpy.get_centroided_spectrum

get_centroided_spectrum(
    td: TimsData,
    frame_id: int,
    *,
    scan_range: tuple[int, int] | None = None,
    exclude: ChargeStateRegion | None = None,
    smooth: Smooth | None = None,
    noise: NoiseSpec = None,
    mobility_type: Literal[
        "ook0", "ccs", "voltage"
    ] = "ook0",
    centroid: Centroider | None = None
) -> np.ndarray

Extract a centroided spectrum for a single frame.

Thin orchestrator over the :mod:tdfpy.pipeline ops. Threads a :class:~tdfpy.pipeline.RawSpectrum through optional scan-range restriction, region exclusion, intensity smoothing, and noise filtering, then hands it to the centroider — which decides whether to operate in integer index space (e.g. :class:~tdfpy.pipeline.WatershedCentroider) or after float conversion (e.g. :class:~tdfpy.pipeline.MergePeaksCentroider).

Default centroider is :class:~tdfpy.pipeline.MergePeaksCentroider. Pass smooth=Smooth(...) for a position-preserving box-sum/mean smoothing pre-step; the :class:~tdfpy.pipeline.WatershedCentroider additionally has its own seed-stabilising smoother via its smooth_*_half_width fields.

Returns an (N, 3) array of [mz, intensity, ion_mobility] centroids.

Source code in src/tdfpy/centroiding.py
def get_centroided_spectrum(
    td: TimsData,
    frame_id: int,
    *,
    scan_range: tuple[int, int] | None = None,
    exclude: ChargeStateRegion | None = None,
    smooth: Smooth | None = None,
    noise: NoiseSpec = None,
    mobility_type: Literal["ook0", "ccs", "voltage"] = "ook0",
    centroid: Centroider | None = None,
) -> np.ndarray:
    """Extract a centroided spectrum for a single frame.

    Thin orchestrator over the :mod:`tdfpy.pipeline` ops. Threads a
    :class:`~tdfpy.pipeline.RawSpectrum` through optional scan-range
    restriction, region exclusion, intensity smoothing, and noise filtering,
    then hands it to the centroider — which decides whether to operate in
    integer index space (e.g. :class:`~tdfpy.pipeline.WatershedCentroider`) or
    after float conversion (e.g. :class:`~tdfpy.pipeline.MergePeaksCentroider`).

    Default centroider is :class:`~tdfpy.pipeline.MergePeaksCentroider`. Pass
    ``smooth=Smooth(...)`` for a position-preserving box-sum/mean smoothing
    pre-step; the :class:`~tdfpy.pipeline.WatershedCentroider` additionally
    has its own seed-stabilising smoother via its ``smooth_*_half_width`` fields.

    Returns an ``(N, 3)`` array of ``[mz, intensity, ion_mobility]`` centroids.
    """
    spectrum = _prepare_spectrum(
        read_spectrum(td, frame_id),
        td,
        frame_id,
        scan_range=scan_range,
        exclude=exclude,
        smoothing=smooth,
        noise=noise,
        mobility_type=mobility_type,
    )
    if spectrum.empty:
        # An empty frame is common on sparse acquisitions, so this is INFO, not
        # a warning. If a *noise filter* emptied a non-empty frame, apply_noise
        # has already logged a warning naming the responsible filter.
        logger.info(
            "Frame %d has 0 peaks after read/smooth/noise; returning empty spectrum.",
            frame_id,
        )
        return np.empty((0, 3), dtype=np.float64)

    centroider = centroid if centroid is not None else MergePeaksCentroider()
    centroids = centroider(spectrum, td, frame_id, mobility_type=mobility_type)
    logger.info(
        "Centroided frame %d: %d raw → %d centroids",
        frame_id,
        len(spectrum),
        len(centroids),
    )
    return centroids

get_raw_peaks

Runs the same read, exclusion, smoothing and noise steps as get_centroided_spectrum but returns the raw peaks without centroiding.

tdfpy.get_raw_peaks

get_raw_peaks(
    td: TimsData,
    frame_id: int,
    *,
    scan_range: tuple[int, int] | None = None,
    exclude: ChargeStateRegion | None = None,
    smooth: Smooth | None = None,
    noise: NoiseSpec = None,
    mobility_type: Literal[
        "ook0", "ccs", "voltage"
    ] = "ook0"
) -> np.ndarray

Return raw peaks for a frame as a (N, 3) [mz, intensity, ion_mobility] array.

Thin orchestrator over the :mod:tdfpy.pipeline ops:

  1. :func:pipeline.read_spectrum — read raw integer-index peaks.
  2. :func:pipeline.subset_scans — restrict to scan range, if given.
  3. :func:pipeline.exclude_region — drop exclude region, if given.
  4. :func:pipeline.smooth — box-smooth intensities, if smooth given.
  5. :func:pipeline.apply_noise — apply the coerced noise filter pipeline.
  6. :func:pipeline.convert — convert to (mz, intensity, ion_mobility).

For full control over ordering or to plug in custom steps, call the pipeline ops directly — each takes and returns a :class:~tdfpy.pipeline.RawSpectrum.

Parameters:

Name Type Description Default
td TimsData

TimsData instance connected to the analysis directory.

required
frame_id int

Frame ID to read.

required
scan_range tuple[int, int] | None

Optional half-open (begin, end) scan range. Restricts the spectrum to peaks in that scan window — used by :class:~tdfpy.DiaWindow and :class:~tdfpy.PrmTransition to scope to their isolation window. None (default) keeps the whole frame.

None
exclude ChargeStateRegion | None

Optional region of (m/z, 1/K0) space to drop wholesale — for timsTOF MS1 use :class:~tdfpy.regions.ChargeStateRegion to drop singly-charged contamination. Applied in integer-index space, so there's no per-peak unit conversion.

None
smooth Smooth | None

Optional :class:~tdfpy.pipeline.Smooth config. Box-sums (or means) intensity over a small (scan, TOF index) window before noise filtering, amplifying genuine ion-mobility streaks. None (default) skips smoothing.

None
noise NoiseSpec

One or more noise filters. Accepts an instance, a list/tuple of instances, the string shorthand ("mad" / "percentile" / "histogram" / "baseline" / "iterative_median"), or a numeric absolute threshold — see :func:tdfpy.noise.coerce_filters. None (default) disables.

None
mobility_type Literal['ook0', 'ccs', 'voltage']

Ion mobility representation — "ook0" (1/K0), "ccs", or "voltage".

'ook0'
Source code in src/tdfpy/centroiding.py
def get_raw_peaks(
    td: TimsData,
    frame_id: int,
    *,
    scan_range: tuple[int, int] | None = None,
    exclude: ChargeStateRegion | None = None,
    smooth: Smooth | None = None,
    noise: NoiseSpec = None,
    mobility_type: Literal["ook0", "ccs", "voltage"] = "ook0",
) -> np.ndarray:
    """Return raw peaks for a frame as a ``(N, 3)`` ``[mz, intensity, ion_mobility]`` array.

    Thin orchestrator over the :mod:`tdfpy.pipeline` ops:

    1. :func:`pipeline.read_spectrum` — read raw integer-index peaks.
    2. :func:`pipeline.subset_scans` — restrict to scan range, if given.
    3. :func:`pipeline.exclude_region` — drop ``exclude`` region, if given.
    4. :func:`pipeline.smooth` — box-smooth intensities, if ``smooth`` given.
    5. :func:`pipeline.apply_noise` — apply the coerced noise filter pipeline.
    6. :func:`pipeline.convert` — convert to ``(mz, intensity, ion_mobility)``.

    For full control over ordering or to plug in custom steps, call the
    pipeline ops directly — each takes and returns a
    :class:`~tdfpy.pipeline.RawSpectrum`.

    Args:
        td: TimsData instance connected to the analysis directory.
        frame_id: Frame ID to read.
        scan_range: Optional half-open ``(begin, end)`` scan range. Restricts
            the spectrum to peaks in that scan window — used by
            :class:`~tdfpy.DiaWindow` and :class:`~tdfpy.PrmTransition` to
            scope to their isolation window. ``None`` (default) keeps the
            whole frame.
        exclude: Optional region of (m/z, 1/K0) space to drop wholesale — for
            timsTOF MS1 use :class:`~tdfpy.regions.ChargeStateRegion` to drop
            singly-charged contamination. Applied in integer-index space, so
            there's no per-peak unit conversion.
        smooth: Optional :class:`~tdfpy.pipeline.Smooth` config. Box-sums (or
            means) intensity over a small ``(scan, TOF index)`` window before
            noise filtering, amplifying genuine ion-mobility streaks. ``None``
            (default) skips smoothing.
        noise: One or more noise filters. Accepts an instance, a list/tuple
            of instances, the string shorthand (``"mad"`` / ``"percentile"``
            / ``"histogram"`` / ``"baseline"`` / ``"iterative_median"``), or
            a numeric absolute threshold — see
            :func:`tdfpy.noise.coerce_filters`. ``None`` (default) disables.
        mobility_type: Ion mobility representation — ``"ook0"`` (1/K0),
            ``"ccs"``, or ``"voltage"``.
    """
    spectrum = _prepare_spectrum(
        read_spectrum(td, frame_id),
        td,
        frame_id,
        scan_range=scan_range,
        exclude=exclude,
        smoothing=smooth,
        noise=noise,
        mobility_type=mobility_type,
    )
    return convert(spectrum, td, frame_id, mobility_type=mobility_type)

get_mobility_collapsed_spectrum

tdfpy.get_mobility_collapsed_spectrum

get_mobility_collapsed_spectrum(
    td: TimsData,
    scan_ranges: Sequence[tuple[int, int, int]],
    *,
    mz_tolerance: float = COLLAPSED_MZ_TOLERANCE_PPM,
    mz_tolerance_unit: ToleranceUnit = "ppm",
    use_numba: bool = True
) -> np.ndarray

Centroid a set of scan ranges with the mobility dimension summed away.

This is the shape of spectrum Bruker's tims_read_pasef_msms and tims_extract_centroided_spectrum_for_frame return: intensities are summed over the mobility axis, leaving a plain m/z spectrum. A single ion smears across roughly 8-10 adjacent TOF bins, so the collapsed profile is then centroided by greedy intensity-ordered merging.

Parameters:

Name Type Description Default
td TimsData

Open :class:~tdfpy.timsdata.TimsData.

required
scan_ranges Sequence[tuple[int, int, int]]

(frame_id, scan_begin, scan_end) triples, summed together. A PASEF precursor is typically spread over several frames.

required
mz_tolerance float

Merge tolerance for the greedy centroider.

COLLAPSED_MZ_TOLERANCE_PPM
mz_tolerance_unit ToleranceUnit

"ppm" or "da".

'ppm'
use_numba bool

Use the JIT-compiled merge kernel when available.

True

Returns:

Type Description
np.ndarray

An (N, 2) array of [mz, intensity] in the order

np.ndarray

func:merge_peaks emits centroids — descending seed intensity,

np.ndarray

i.e. ordered by the brightest raw point that anchored each centroid,

np.ndarray

not by the centroid's own summed intensity. Sort explicitly if you

np.ndarray

need descending centroid intensity.

Note

Results are close to Bruker's peak picker but not identical to it -- Bruker's algorithm is proprietary and appears to smooth before picking. On the bundled fixtures the strong peaks agree to ~0.5 ppm and the total ion current to within 0.1%, with ~4% more peaks reported.

Source code in src/tdfpy/centroiding.py
def get_mobility_collapsed_spectrum(
    td: TimsData,
    scan_ranges: Sequence[tuple[int, int, int]],
    *,
    mz_tolerance: float = COLLAPSED_MZ_TOLERANCE_PPM,
    mz_tolerance_unit: ToleranceUnit = "ppm",
    use_numba: bool = True,
) -> np.ndarray:
    """Centroid a set of scan ranges with the mobility dimension summed away.

    This is the shape of spectrum Bruker's ``tims_read_pasef_msms`` and
    ``tims_extract_centroided_spectrum_for_frame`` return: intensities are
    summed over the mobility axis, leaving a plain m/z spectrum. A single ion
    smears across roughly 8-10 adjacent TOF bins, so the collapsed profile is
    then centroided by greedy intensity-ordered merging.

    Args:
        td: Open :class:`~tdfpy.timsdata.TimsData`.
        scan_ranges: ``(frame_id, scan_begin, scan_end)`` triples, summed
            together. A PASEF precursor is typically spread over several frames.
        mz_tolerance: Merge tolerance for the greedy centroider.
        mz_tolerance_unit: ``"ppm"`` or ``"da"``.
        use_numba: Use the JIT-compiled merge kernel when available.

    Returns:
        An ``(N, 2)`` array of ``[mz, intensity]`` in the order
        :func:`merge_peaks` emits centroids — descending *seed* intensity,
        i.e. ordered by the brightest raw point that anchored each centroid,
        not by the centroid's own summed intensity. Sort explicitly if you
        need descending centroid intensity.

    Note:
        Results are close to Bruker's peak picker but not identical to it --
        Bruker's algorithm is proprietary and appears to smooth before picking.
        On the bundled fixtures the strong peaks agree to ~0.5 ppm and the total
        ion current to within 0.1%, with ~4% more peaks reported.
    """
    return _collapsed_spectrum(
        td,
        scan_ranges,
        lambda frame_id, begin, end: td.read_frame_arrays(frame_id, begin, end)[1:],
        mz_tolerance=mz_tolerance,
        mz_tolerance_unit=mz_tolerance_unit,
        use_numba=use_numba,
    )

merge_peaks

Centroids pre-assembled arrays. The algorithm is a greedy intensity-ordered scan: starting from the highest-intensity raw peak, every neighbouring peak within the m/z and ion mobility tolerances is merged into a single centroid via intensity-weighted averaging. Merged peaks are marked as used and skipped in subsequent iterations.

Parameter Default Notes
mz_tolerance 8.0 Width of the m/z matching window
mz_tolerance_unit "ppm" "ppm" or "da"
im_tolerance 0.1 Width of the ion mobility window
im_tolerance_unit "relative" "relative" (fraction of 1/K0) or "absolute"
min_peaks 3 Raw peaks required to form a centroid; set to 0 or 1 to keep all
max_peaks None Cap on output peaks by raw seed intensity, not final summed intensity
peak_noise_filter False Suppress weak satellite points around each centroid so they cannot seed their own centroids
peak_noise_window 0.1 Half-width in Da of the satellite window
peak_noise_end_fraction 0.1 Ramp end, as a fraction of the anchor intensity, at the window edge
use_numba True Set to False to force the Python fallback
import numpy as np
from tdfpy import merge_peaks

mz  = np.array([500.001, 500.002, 700.005, 700.006, 700.007])
inten = np.array([8000.0,  4000.0,  6000.0,  5000.0,  3000.0])
im  = np.array([0.85,     0.85,    0.92,    0.92,    0.92])

peaks = merge_peaks(mz, inten, im, mz_tolerance=10.0, min_peaks=2)
print(peaks)
# shape (2, 3): two centroided peaks, columns [m/z, intensity, 1/K0]

Noise filtering vs min_peaks

The noise= parameter (available on get_centroided_spectrum, .centroid(), and get_raw_peaks) chains noise filters before the centroider runs — intensity thresholds, the vertical-IM streak filter, or any combination. Intensity-based estimators have a fundamental limitation: they can't distinguish low-abundance real signal from electronic noise. Methods like MadThreshold are anchored to the median of the intensity distribution — if your sample has sparse signal, the threshold can rise above legitimate low-abundance peaks.

A more reliable strategy is to increase min_peaks instead:

from tdfpy import merge_peaks

# Prefer: raise min_peaks to filter noise without discarding low-abundance signal
peaks = merge_peaks(mz, intensity, im, min_peaks=5)

# Noise arises from single scans; real peaks appear across multiple scans.
# min_peaks=5 means a centroid must be supported by at least 5 raw measurements.

Because electronic noise typically manifests as a singleton in a single scan, requiring several supporting raw peaks is a structural filter — it targets the origin of noise rather than its intensity. The VerticalNoiseFilter extends this idea to the IM axis, requiring peaks to appear as vertical streaks across consecutive mobility scans.

Use intensity-based noise= filters only if you have a calibrated threshold or a method validated for your acquisition; always verify against noise=None first.

tdfpy.merge_peaks

merge_peaks(
    mz_array: np.ndarray,
    intensity_array: np.ndarray,
    ion_mobility_array: np.ndarray,
    mz_tolerance: float = 8.0,
    mz_tolerance_unit: ToleranceUnit = "ppm",
    im_tolerance: float = 0.1,
    im_tolerance_unit: Literal[
        "relative", "absolute"
    ] = "relative",
    min_peaks: int = 3,
    max_peaks: int | None = None,
    peak_noise_filter: bool = False,
    peak_noise_window: float = 0.1,
    peak_noise_end_fraction: float = 0.1,
    use_numba: bool = True,
) -> np.ndarray

Centroid profile-like peaks using m/z and ion mobility tolerances.

This function implements a greedy clustering algorithm that centroids raw peaks (similar to profile mode data) within specified m/z and ion mobility windows. Peaks are processed in descending order of intensity, and nearby peaks are combined using intensity-weighted averaging to produce centroided peaks.

Parameters:

Name Type Description Default
mz_array np.ndarray

Array of m/z values from raw/profile-like data

required
intensity_array np.ndarray

Array of intensity values

required
ion_mobility_array np.ndarray

Array of ion mobility values (1/K0 or CCS)

required
mz_tolerance float

Tolerance for m/z matching during centroiding

8.0
mz_tolerance_unit ToleranceUnit

Type of m/z tolerance - "ppm" or "da" (daltons)

'ppm'
im_tolerance float

Tolerance for ion mobility matching during centroiding

0.1
im_tolerance_unit Literal['relative', 'absolute']

Type of ion mobility tolerance - "relative" or "absolute"

'relative'
min_peaks int

Minimum number of nearby raw peaks required to form a centroid. Set to 0 or 1 to keep all peaks (no filtering).

3
max_peaks int | None

Maximum centroids in descending raw seed intensity order. This does not rank final summed intensities. Nonpositive means unlimited.

None
peak_noise_filter bool

If True, after each centroid is formed suppress raw points within ±peak_noise_window Da of the anchor m/z and inside the centroid's IM window whose intensity falls below a linear threshold that decays from the anchor point's raw intensity at zero distance to anchor * peak_noise_end_fraction at the window edge. This kills TOF satellite/ringing noise around bright peaks without eliminating nearby real peaks that exceed the ramp. Comparison is point-to-point against the raw anchor intensity (not the summed centroid). Defaults to False.

False
peak_noise_window float

Half-width in Da on each side of the anchor m/z over which the peak-noise ramp is applied. Defaults to 0.1 Da.

0.1
peak_noise_end_fraction float

Fraction of the anchor's raw intensity used as the suppression threshold at peak_noise_window distance. Defaults to 0.1 (10%).

0.1

Returns:

Type Description
np.ndarray

np.ndarray: Array of shape (N, 3) containing centroided peaks. Columns are: [mz, intensity, ion_mobility]

Example
mz = np.array([100.0, 100.001, 200.0])
intensity = np.array([1000.0, 500.0, 2000.0])
im = np.array([0.8, 0.8, 0.9])
peaks = merge_peaks(mz, intensity, im, mz_tolerance=10, mz_tolerance_unit="ppm")
Source code in src/tdfpy/centroiding.py
def merge_peaks(
    mz_array: np.ndarray,
    intensity_array: np.ndarray,
    ion_mobility_array: np.ndarray,
    mz_tolerance: float = 8.0,
    mz_tolerance_unit: ToleranceUnit = "ppm",
    im_tolerance: float = 0.1,
    im_tolerance_unit: Literal["relative", "absolute"] = "relative",
    min_peaks: int = 3,
    max_peaks: int | None = None,
    peak_noise_filter: bool = False,
    peak_noise_window: float = 0.1,
    peak_noise_end_fraction: float = 0.1,
    use_numba: bool = True,
) -> np.ndarray:
    """Centroid profile-like peaks using m/z and ion mobility tolerances.

    This function implements a greedy clustering algorithm that centroids raw peaks
    (similar to profile mode data) within specified m/z and ion mobility windows.
    Peaks are processed in descending order of intensity, and nearby peaks are
    combined using intensity-weighted averaging to produce centroided peaks.

    Args:
        mz_array: Array of m/z values from raw/profile-like data
        intensity_array: Array of intensity values
        ion_mobility_array: Array of ion mobility values (1/K0 or CCS)
        mz_tolerance: Tolerance for m/z matching during centroiding
        mz_tolerance_unit: Type of m/z tolerance - "ppm" or "da" (daltons)
        im_tolerance: Tolerance for ion mobility matching during centroiding
        im_tolerance_unit: Type of ion mobility tolerance - "relative" or "absolute"
        min_peaks: Minimum number of nearby raw peaks required to form a centroid.
                  Set to 0 or 1 to keep all peaks (no filtering).
        max_peaks: Maximum centroids in descending raw seed intensity order.
            This does not rank final summed intensities. Nonpositive means unlimited.
        peak_noise_filter: If True, after each centroid is formed suppress raw
            points within ±``peak_noise_window`` Da of the anchor m/z and inside
            the centroid's IM window whose intensity falls below a linear
            threshold that decays from the **anchor point's raw intensity** at
            zero distance to ``anchor * peak_noise_end_fraction`` at the window
            edge. This kills TOF satellite/ringing noise around bright peaks
            without eliminating nearby real peaks that exceed the ramp.
            Comparison is point-to-point against the raw anchor intensity (not
            the summed centroid). Defaults to ``False``.
        peak_noise_window: Half-width in Da on each side of the anchor m/z over
            which the peak-noise ramp is applied. Defaults to ``0.1`` Da.
        peak_noise_end_fraction: Fraction of the anchor's raw intensity used as
            the suppression threshold at ``peak_noise_window`` distance.
            Defaults to ``0.1`` (10%).

    Returns:
        np.ndarray: Array of shape (N, 3) containing centroided peaks.
                   Columns are: [mz, intensity, ion_mobility]

    Example:
        ```python
        mz = np.array([100.0, 100.001, 200.0])
        intensity = np.array([1000.0, 500.0, 2000.0])
        im = np.array([0.8, 0.8, 0.9])
        peaks = merge_peaks(mz, intensity, im, mz_tolerance=10, mz_tolerance_unit="ppm")
        ```
    """
    merge_config(
        mz_tolerance,
        mz_tolerance_unit,
        im_tolerance,
        im_tolerance_unit,
        min_peaks,
        max_peaks,
        peak_noise_window,
        peak_noise_end_fraction,
    )
    arrays(mz_array, intensity_array, ion_mobility_array)
    # float64 up front: unsigned differences in the order check would wrap.
    mz_array = np.asarray(mz_array, dtype=np.float64)
    intensity_array = np.asarray(intensity_array, dtype=np.float64)
    ion_mobility_array = np.asarray(ion_mobility_array, dtype=np.float64)
    if np.any(mz_array < 0) or np.any(intensity_array < 0):
        raise TdfpyError("m/z and intensities must be nonnegative.")
    if _HAS_NUMBA and use_numba:
        try:
            return _merge_peaks_numba(
                mz_array,
                intensity_array,
                ion_mobility_array,
                mz_tolerance=mz_tolerance,
                mz_tolerance_unit=mz_tolerance_unit,
                im_tolerance=im_tolerance,
                im_tolerance_unit=im_tolerance_unit,
                min_peaks=min_peaks,
                max_peaks=max_peaks,
                peak_noise_filter=peak_noise_filter,
                peak_noise_window=peak_noise_window,
                peak_noise_end_fraction=peak_noise_end_fraction,
            )

        except NumbaError as exc:
            warnings.warn(
                f"Numba compilation failed. Using the Python merge kernel: {exc}",
                RuntimeWarning,
                stacklevel=2,
            )

    # Fallback to Python implementation
    return _merge_peaks_python(
        mz_array,
        intensity_array,
        ion_mobility_array,
        mz_tolerance,
        mz_tolerance_unit,
        im_tolerance,
        im_tolerance_unit,
        min_peaks,
        max_peaks,
        peak_noise_filter,
        peak_noise_window,
        peak_noise_end_fraction,
    )

Raw-spectrum CCS conversion assumes charge +1. Use a known precursor charge with the explicit CCS conversion function for charge-specific values.