992 lines
36 KiB
Julia
992 lines
36 KiB
Julia
# src/MSIData.jl
|
|
|
|
"""
|
|
This file defines the unified MSIData object and the associated data access layer,
|
|
including caching and iteration logic, for handling large mzML and imzML datasets
|
|
efficiently.
|
|
"""
|
|
|
|
using Base64, Libz, Serialization, Printf, DataFrames, Base.Threads # For reading binary data
|
|
|
|
# Abstract type for different data sources (e.g., mzML, imzML)
|
|
# This allows dispatching to the correct binary reading logic.
|
|
abstract type MSDataSource end
|
|
|
|
# Concrete type for imzML data sources
|
|
"""
|
|
ImzMLSource <: MSDataSource
|
|
|
|
A data source for `.imzML` files, holding a handle to the binary `.ibd` file
|
|
and the expected format for m/z and intensity arrays.
|
|
"""
|
|
struct ImzMLSource <: MSDataSource
|
|
ibd_handle::IO
|
|
mz_format::Type
|
|
intensity_format::Type
|
|
end
|
|
|
|
"""
|
|
MzMLSource <: MSDataSource
|
|
|
|
A data source for `.mzML` files, holding a handle to the `.mzML` file itself
|
|
(which contains the binary data encoded in Base64) and the expected data formats.
|
|
"""
|
|
struct MzMLSource <: MSDataSource
|
|
file_handle::IO
|
|
mz_format::Type
|
|
intensity_format::Type
|
|
end
|
|
|
|
"""
|
|
SpectrumAsset
|
|
|
|
Contains metadata for a single binary data array (m/z or intensity) within a spectrum.
|
|
|
|
# Fields
|
|
- `format`: The data type of the elements (e.g., `Float32`, `Int64`).
|
|
- `is_compressed`: A boolean flag indicating if the data is compressed (e.g., with zlib).
|
|
- `offset`: The byte offset of the data within the file (`.ibd` for imzML, `.mzML` for mzML).
|
|
- `encoded_length`: The length of the data. For uncompressed imzML, this is the number of
|
|
elements in the array. For compressed imzML, it is the number of bytes of the compressed
|
|
data. For mzML, this is the length of the Base64 encoded string.
|
|
- `axis_type`: A symbol (`:mz` or `:intensity`) indicating the type of data.
|
|
"""
|
|
struct SpectrumAsset
|
|
format::Type
|
|
is_compressed::Bool
|
|
offset::Int64
|
|
encoded_length::Int32
|
|
# For mzML, axis_type is needed to distinguish mz from intensity.
|
|
# For imzML, this can be ignored as the order is fixed.
|
|
axis_type::Symbol
|
|
end
|
|
|
|
@enum SpectrumMode CENTROID=1 PROFILE=2 UNKNOWN=3
|
|
|
|
"""
|
|
SpectrumMetadata
|
|
|
|
Contains all metadata for a single spectrum, common to both imzML and mzML formats.
|
|
|
|
# Fields
|
|
- `x`, `y`: The spatial coordinates of the spectrum (for imzML only).
|
|
- `id`: The unique identifier string for the spectrum (for mzML only).
|
|
- `mode`: The spectrum mode (`CENTROID` or `PROFILE`).
|
|
- `mz_asset`: A `SpectrumAsset` for the m/z array.
|
|
- `int_asset`: A `SpectrumAsset` for the intensity array.
|
|
"""
|
|
struct SpectrumMetadata
|
|
# For imzML
|
|
x::Int32
|
|
y::Int32
|
|
|
|
# For mzML
|
|
id::String
|
|
|
|
mode::SpectrumMode
|
|
# Common binary data info
|
|
mz_asset::SpectrumAsset
|
|
int_asset::SpectrumAsset
|
|
end
|
|
|
|
"""
|
|
MSIData
|
|
|
|
The primary object for interacting with mass spectrometry data. It provides a unified
|
|
interface for both `.mzML` and `.imzML` files and includes an LRU cache for
|
|
efficient repeated access to spectra.
|
|
|
|
# Fields
|
|
- `source`: The underlying `MSDataSource` (`ImzMLSource` or `MzMLSource`).
|
|
- `spectra_metadata`: A vector of `SpectrumMetadata` for all spectra in the file.
|
|
- `image_dims`: A tuple `(width, height)` of the spatial dimensions (for imzML).
|
|
- `coordinate_map`: A matrix mapping `(x, y)` coordinates to a linear spectrum index (for imzML).
|
|
- `cache`: A dictionary holding cached spectra, mapping index to `(mz, intensity)`.
|
|
- `cache_order`: A vector of indices tracking usage for the LRU cache policy.
|
|
- `cache_size`: The maximum number of spectra to store in the cache.
|
|
- `cache_lock`: A `ReentrantLock` to ensure thread-safe access to the cache.
|
|
- `global_min_mz`: Cached global minimum m/z value across all spectra.
|
|
- `global_max_mz`: Cached global maximum m/z value across all spectra.
|
|
- `spectrum_stats_df`: A `DataFrame` containing pre-computed per-spectrum analytics (e.g., TIC, BPI).
|
|
"""
|
|
mutable struct MSIData
|
|
source::MSDataSource
|
|
spectra_metadata::Vector{SpectrumMetadata}
|
|
image_dims::Tuple{Int, Int} # (width, height) for imaging data
|
|
coordinate_map::Union{Matrix{Int}, Nothing} # Maps (x,y) to linear index for imzML
|
|
|
|
# LRU Cache for GetSpectrum
|
|
cache::Dict{Int, Tuple{Vector, Vector}}
|
|
cache_order::Vector{Int} # Stores indices, with most recently used at the end
|
|
cache_size::Int # Max number of spectra in cache
|
|
cache_lock::ReentrantLock # To make cache access thread-safe
|
|
|
|
# Pre-computed analytics/metadata
|
|
global_min_mz::Union{Float64, Nothing}
|
|
global_max_mz::Union{Float64, Nothing}
|
|
spectrum_stats_df::Union{DataFrame, Nothing}
|
|
|
|
function MSIData(source, metadata, dims, coordinate_map, cache_size)
|
|
obj = new(source, metadata, dims, coordinate_map,
|
|
Dict(), [], cache_size, ReentrantLock(),
|
|
nothing, nothing, nothing) # Initialize new fields to nothing
|
|
|
|
# Ensure file handles are closed when the object is garbage collected
|
|
finalizer(obj) do o
|
|
if o.source isa ImzMLSource && isopen(o.source.ibd_handle)
|
|
close(o.source.ibd_handle)
|
|
elseif o.source isa MzMLSource && isopen(o.source.file_handle)
|
|
close(o.source.file_handle)
|
|
end
|
|
end
|
|
return obj
|
|
end
|
|
end
|
|
|
|
|
|
# --- Internal function for reading binary data --- #
|
|
|
|
"""
|
|
read_binary_vector(io::IO, asset::SpectrumAsset)
|
|
|
|
Reads and decodes a single binary data vector (e.g., m/z or intensity array)
|
|
from a `.mzML` file. The data is expected to be Base64-encoded and may be
|
|
compressed.
|
|
|
|
This internal function handles:
|
|
1. Reading the raw Base64 string from the file at the specified offset.
|
|
2. Decoding the Base64 string into bytes.
|
|
3. Decompressing the bytes using zlib if `asset.is_compressed` is true.
|
|
4. Interpreting the resulting bytes as a vector of the specified format.
|
|
|
|
Note: Byte order conversion (e.g., from little-endian to host) is not performed
|
|
by this function and is assumed to be handled by the caller if necessary.
|
|
|
|
# Arguments
|
|
- `io`: The IO stream of the `.mzML` file.
|
|
- `asset`: The `SpectrumAsset` containing metadata for the array.
|
|
|
|
# Returns
|
|
- A `Vector` of the appropriate type containing the decoded data.
|
|
"""
|
|
function read_binary_vector(io::IO, asset::SpectrumAsset)
|
|
seek(io, asset.offset)
|
|
raw_b64 = read(io, asset.encoded_length)
|
|
decoded_bytes = base64decode(strip(String(raw_b64)))
|
|
bytes_io = IOBuffer(asset.is_compressed ? Libz.inflate(decoded_bytes) : decoded_bytes)
|
|
|
|
n_elements = Int(bytes_io.size / sizeof(asset.format))
|
|
out_array = Array{asset.format}(undef, n_elements)
|
|
read!(bytes_io, out_array)
|
|
return out_array
|
|
end
|
|
|
|
# Overload for different source types
|
|
"""
|
|
read_spectrum_from_disk(source::ImzMLSource, meta::SpectrumMetadata)
|
|
|
|
Reads a single spectrum's m/z and intensity arrays directly from the `.ibd`
|
|
binary file for an `.imzML` dataset. It handles reading the raw binary data
|
|
and converting it from little-endian to the host's native byte order.
|
|
|
|
# Arguments
|
|
- `source`: An `ImzMLSource` containing the file handle and data formats.
|
|
- `meta`: The `SpectrumMetadata` for the spectrum to be read.
|
|
|
|
# Returns
|
|
- A tuple `(mz, intensity)` containing the two requested data arrays.
|
|
"""
|
|
function read_spectrum_from_disk(source::ImzMLSource, meta::SpectrumMetadata)
|
|
# For imzML, the binary data is raw, not base64 encoded.
|
|
# The `encoded_length` field in this case holds the number of points.
|
|
mz = Array{source.mz_format}(undef, meta.mz_asset.encoded_length)
|
|
intensity = Array{source.intensity_format}(undef, meta.int_asset.encoded_length)
|
|
|
|
seek(source.ibd_handle, meta.mz_asset.offset)
|
|
read!(source.ibd_handle, mz)
|
|
|
|
seek(source.ibd_handle, meta.int_asset.offset)
|
|
read!(source.ibd_handle, intensity)
|
|
|
|
# imzML data is little-endian. Convert to host byte order.
|
|
mz .= ltoh.(mz)
|
|
intensity .= ltoh.(intensity)
|
|
|
|
return mz, intensity
|
|
end
|
|
|
|
"""
|
|
read_spectrum_from_disk(source::MzMLSource, meta::SpectrumMetadata)
|
|
|
|
Reads a single spectrum's m/z and intensity arrays from a `.mzML` file.
|
|
This is a high-level function that orchestrates the reading process by calling
|
|
`read_binary_vector` for each of the m/z and intensity arrays.
|
|
|
|
# Arguments
|
|
- `source`: A `MzMLSource` containing the file handle.
|
|
- `meta`: The `SpectrumMetadata` for the spectrum to be read.
|
|
|
|
# Returns
|
|
- A tuple `(mz, intensity)` containing the two requested data arrays.
|
|
"""
|
|
function read_spectrum_from_disk(source::MzMLSource, meta::SpectrumMetadata)
|
|
# For mzML, data is Base64 encoded within the XML
|
|
mz = read_binary_vector(source.file_handle, meta.mz_asset)
|
|
intensity = read_binary_vector(source.file_handle, meta.int_asset)
|
|
return mz, intensity
|
|
end
|
|
|
|
# --- Public API --- #
|
|
|
|
"""
|
|
GetSpectrum(data::MSIData, index::Int)
|
|
|
|
Retrieves a single spectrum by its index, utilizing a thread-safe LRU cache for performance.
|
|
|
|
If the spectrum is not in the cache, it is read from disk, and the cache is updated.
|
|
This function is the core of the "Indexed" and "Cache" access patterns.
|
|
|
|
# Arguments
|
|
- `data`: The `MSIData` object.
|
|
- `index`: The linear index of the spectrum to retrieve.
|
|
|
|
# Returns
|
|
- A tuple `(mz, intensity)` containing the spectrum's data arrays.
|
|
"""
|
|
function GetSpectrum(data::MSIData, index::Int)
|
|
if index < 1 || index > length(data.spectra_metadata)
|
|
error("Spectrum index $index out of bounds.")
|
|
end
|
|
|
|
# Phase 1: Check the cache (with lock)
|
|
lock(data.cache_lock)
|
|
try
|
|
if haskey(data.cache, index)
|
|
# Cache Hit: Move item to the end of the LRU list and return from cache
|
|
filter!(x -> x != index, data.cache_order)
|
|
push!(data.cache_order, index)
|
|
return data.cache[index]
|
|
end
|
|
finally
|
|
unlock(data.cache_lock)
|
|
end
|
|
|
|
# Phase 2: Cache Miss - Read from disk (no lock)
|
|
meta = data.spectra_metadata[index]
|
|
spectrum = read_spectrum_from_disk(data.source, meta)
|
|
|
|
# Phase 3: Update cache (with lock) and get final value
|
|
return lock(data.cache_lock) do
|
|
if haskey(data.cache, index)
|
|
# Another thread got here first, use its result
|
|
return data.cache[index]
|
|
end
|
|
|
|
# This thread is first to update cache
|
|
if data.cache_size > 0
|
|
if length(data.cache) >= data.cache_size
|
|
# Evict the least recently used item (at the front of the list)
|
|
lru_index = popfirst!(data.cache_order)
|
|
delete!(data.cache, lru_index)
|
|
end
|
|
data.cache[index] = spectrum
|
|
push!(data.cache_order, index)
|
|
end
|
|
return spectrum
|
|
end
|
|
end
|
|
|
|
"""
|
|
GetSpectrum(data::MSIData, x::Int, y::Int)
|
|
|
|
Retrieves a single spectrum by its (x, y) coordinates for imaging data (`.imzML`).
|
|
This method uses the `coordinate_map` for efficient index lookup and then calls
|
|
the indexed `GetSpectrum` method, benefiting from caching.
|
|
|
|
# Arguments
|
|
- `data`: The `MSIData` object.
|
|
- `x`: The x-coordinate of the spectrum.
|
|
- `y`: The y-coordinate of the spectrum.
|
|
|
|
# Returns
|
|
- A tuple `(mz, intensity)` containing the spectrum's data arrays.
|
|
"""
|
|
function GetSpectrum(data::MSIData, x::Int, y::Int)
|
|
if data.coordinate_map === nothing
|
|
error("Coordinate map not available. This method is only for imaging data loaded from .imzML files.")
|
|
end
|
|
width, height = data.image_dims
|
|
if x < 1 || x > width || y < 1 || y > height
|
|
error("Coordinates ($x, $y) out of bounds for image dimensions ($width, $height).")
|
|
end
|
|
|
|
index = data.coordinate_map[x, y]
|
|
if index == 0
|
|
error("No spectrum found at coordinates ($x, $y).")
|
|
end
|
|
|
|
return GetSpectrum(data, index) # Call the existing method
|
|
end
|
|
|
|
"""
|
|
process_spectrum(f::Function, data::MSIData, index::Int)
|
|
|
|
A "function barrier" helper for safely processing a single spectrum.
|
|
|
|
This function retrieves a spectrum and then immediately calls the provided function `f`
|
|
with the resulting `(mz, intensity)` arrays. This pattern is crucial for performance.
|
|
While `GetSpectrum` itself is type-unstable (because the array data types are not
|
|
known at compile time), calling `f` with the result allows Julia's compiler to
|
|
generate specialized, fast code for `f` based on the *concrete* types it receives.
|
|
|
|
Use this function when you need to perform performance-critical operations on a
|
|
single spectrum. Your logic should be inside the function passed to this helper.
|
|
```
|
|
"""
|
|
function process_spectrum(f::Function, data::MSIData, index::Int)
|
|
# This call is type-unstable.
|
|
mz, intensity = GetSpectrum(data, index)
|
|
|
|
# By immediately calling `f`, we create a "function barrier".
|
|
# Julia will compile a specialized version of `f` for the
|
|
# concrete types of `mz` and `intensity`.
|
|
return f(mz, intensity)
|
|
end
|
|
|
|
"""
|
|
process_spectrum(f::Function, data::MSIData, x::Int, y::Int)
|
|
|
|
A coordinate-based version of the "function barrier" helper. See `process_spectrum`
|
|
for details on the performance pattern.
|
|
"""
|
|
function process_spectrum(f::Function, data::MSIData, x::Int, y::Int)
|
|
mz, intensity = GetSpectrum(data, x, y)
|
|
return f(mz, intensity)
|
|
end
|
|
|
|
"""
|
|
precompute_analytics(msi_data::MSIData)
|
|
|
|
Performs a single pass over the entire dataset to pre-compute and cache important
|
|
analytics. This function populates the `global_min_mz`, `global_max_mz`, and
|
|
`spectrum_stats_df` fields of the `MSIData` object.
|
|
|
|
The computed statistics include:
|
|
- Global minimum and maximum m/z values.
|
|
- Per-spectrum:
|
|
- Total Ion Count (TIC)
|
|
- Base Peak Intensity (BPI)
|
|
- m/z of the base peak
|
|
- Number of data points
|
|
- Minimum and maximum m/z
|
|
|
|
Subsequent calls to functions like `get_total_spectrum` will be much faster
|
|
as they can use this cached data. This function modifies the `MSIData` object in-place
|
|
and is idempotent.
|
|
"""
|
|
function precompute_analytics(msi_data::MSIData)
|
|
# Idempotency check
|
|
if msi_data.spectrum_stats_df !== nothing && hasproperty(msi_data.spectrum_stats_df, :MinMZ)
|
|
println("Analytics have already been pre-computed.")
|
|
return
|
|
end
|
|
|
|
println("Pre-computing analytics (single pass)...")
|
|
start_time = time_ns()
|
|
|
|
num_spectra = length(msi_data.spectra_metadata)
|
|
|
|
# DEBUG: Check the first spectrum's metadata
|
|
if num_spectra > 0
|
|
first_meta = msi_data.spectra_metadata[1]
|
|
println("DEBUG First spectrum metadata:")
|
|
println(" mz_asset: format=$(first_meta.mz_asset.format), compressed=$(first_meta.mz_asset.is_compressed)")
|
|
println(" mz_asset: offset=$(first_meta.mz_asset.offset), encoded_length=$(first_meta.mz_asset.encoded_length)")
|
|
println(" int_asset: format=$(first_meta.int_asset.format), compressed=$(first_meta.int_asset.is_compressed)")
|
|
println(" int_asset: offset=$(first_meta.int_asset.offset), encoded_length=$(first_meta.int_asset.encoded_length)")
|
|
println(" mode: $(first_meta.mode)")
|
|
end
|
|
|
|
# Initialize variables for global stats
|
|
g_min_mz = Inf
|
|
g_max_mz = -Inf
|
|
|
|
# Initialize vectors for per-spectrum stats
|
|
tics = Vector{Float64}(undef, num_spectra)
|
|
bpis = Vector{Float64}(undef, num_spectra)
|
|
bp_mzs = Vector{Float64}(undef, num_spectra)
|
|
num_points = Vector{Int}(undef, num_spectra)
|
|
min_mzs = Vector{Float64}(undef, num_spectra)
|
|
max_mzs = Vector{Float64}(undef, num_spectra)
|
|
modes = Vector{SpectrumMode}(undef, num_spectra)
|
|
is_compressed = Vector{Bool}(undef, num_spectra)
|
|
|
|
# DEBUG: Add counter to see how many spectra have data
|
|
spectra_with_data = 0
|
|
empty_spectra = 0
|
|
|
|
_iterate_spectra_fast(msi_data) do idx, mz, intensity
|
|
# Store metadata
|
|
modes[idx] = msi_data.spectra_metadata[idx].mode
|
|
is_compressed[idx] = msi_data.spectra_metadata[idx].mz_asset.is_compressed ||
|
|
msi_data.spectra_metadata[idx].int_asset.is_compressed
|
|
|
|
if isempty(mz)
|
|
empty_spectra += 1
|
|
tics[idx] = 0.0
|
|
bpis[idx] = 0.0
|
|
bp_mzs[idx] = 0.0
|
|
num_points[idx] = 0
|
|
min_mzs[idx] = Inf
|
|
max_mzs[idx] = -Inf
|
|
return
|
|
else
|
|
spectra_with_data += 1
|
|
end
|
|
|
|
# Update global m/z range
|
|
local_min, local_max = extrema(mz)
|
|
g_min_mz = min(g_min_mz, local_min)
|
|
g_max_mz = max(g_max_mz, local_max)
|
|
min_mzs[idx] = local_min
|
|
max_mzs[idx] = local_max
|
|
|
|
# Calculate per-spectrum stats
|
|
tics[idx] = sum(intensity)
|
|
max_int, max_idx = findmax(intensity)
|
|
bpis[idx] = max_int
|
|
bp_mzs[idx] = mz[max_idx]
|
|
num_points[idx] = length(mz)
|
|
end
|
|
|
|
# Add mode statistics
|
|
centroid_count = count(==(CENTROID), modes)
|
|
profile_count = count(==(PROFILE), modes)
|
|
unknown_count = count(==(UNKNOWN), modes)
|
|
|
|
println("DEBUG Mode Statistics:")
|
|
println(" Centroid spectra: $centroid_count")
|
|
println(" Profile spectra: $profile_count")
|
|
println(" Unknown mode: $unknown_count")
|
|
|
|
# DEBUG: Print summary
|
|
println("DEBUG Analytics Summary:")
|
|
println(" Total spectra: $num_spectra")
|
|
println(" Spectra with data: $spectra_with_data")
|
|
println(" Empty spectra: $empty_spectra")
|
|
println(" Global m/z range: [$g_min_mz, $g_max_mz]")
|
|
println(" Centroid spectra: $(count(==(CENTROID), modes))")
|
|
println(" Profile spectra: $(count(==(PROFILE), modes))")
|
|
println(" Compressed spectra: $(sum(is_compressed))")
|
|
println(" Average points per spectrum: $(mean(num_points))")
|
|
|
|
# Populate the MSIData object
|
|
msi_data.global_min_mz = g_min_mz
|
|
msi_data.global_max_mz = g_max_mz
|
|
msi_data.spectrum_stats_df = DataFrame(
|
|
SpectrumID = 1:num_spectra,
|
|
TIC = tics,
|
|
BPI = bpis,
|
|
BasePeakMZ = bp_mzs,
|
|
NumPoints = num_points,
|
|
MinMZ = min_mzs,
|
|
MaxMZ = max_mzs,
|
|
Mode = modes,
|
|
IsCompressed = is_compressed
|
|
)
|
|
|
|
duration = (time_ns() - start_time) / 1e9
|
|
@printf "Analytics pre-computation complete in %.2f seconds.\n" duration
|
|
|
|
return
|
|
end
|
|
|
|
"""
|
|
get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000)
|
|
|
|
Internal function to calculate the total spectrum for an `.imzML` file.
|
|
|
|
It uses a fast, two-pass approach:
|
|
1. The first pass finds the global m/z range across all spectra.
|
|
2. The second pass sums intensities into a pre-defined number of bins.
|
|
|
|
This function is highly optimized for `.imzML` by leveraging direct binary
|
|
reading and optimized binning logic. It is called by `get_total_spectrum`.
|
|
"""
|
|
function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000)
|
|
println("Calculating total spectrum for imzML (2-pass method)...")
|
|
total_start_time = time_ns()
|
|
|
|
local global_min_mz, global_max_mz
|
|
|
|
if msi_data.global_min_mz !== nothing
|
|
println(" Using pre-computed m/z range.")
|
|
global_min_mz = msi_data.global_min_mz
|
|
global_max_mz = msi_data.global_max_mz
|
|
else
|
|
# 1. First Pass: Find the global m/z range by reading the fast .ibd file
|
|
pass1_start_time = time_ns()
|
|
println(" Pass 1: Finding global m/z range...")
|
|
g_min_mz = Inf
|
|
g_max_mz = -Inf
|
|
_iterate_spectra_fast(msi_data) do idx, mz, _
|
|
if !isempty(mz)
|
|
local_min, local_max = extrema(mz)
|
|
g_min_mz = min(g_min_mz, local_min)
|
|
g_max_mz = max(g_max_mz, local_max)
|
|
end
|
|
end
|
|
pass1_duration = (time_ns() - pass1_start_time) / 1e9
|
|
if !isfinite(g_min_mz)
|
|
@warn "Could not determine a valid m/z range for imzML. All spectra might be empty."
|
|
return (Float64[], Float64[])
|
|
end
|
|
|
|
# Use and cache the result
|
|
global_min_mz = g_min_mz
|
|
global_max_mz = g_max_mz
|
|
msi_data.global_min_mz = global_min_mz
|
|
msi_data.global_max_mz = global_max_mz
|
|
println(" Global m/z range found and cached: [$(global_min_mz), $(global_max_mz)]")
|
|
@printf " (Pass 1 took %.2f seconds)\n" pass1_duration
|
|
end
|
|
# 2. Define Bins and precompute constants
|
|
mz_bins = range(global_min_mz, stop=global_max_mz, length=num_bins)
|
|
intensity_sum = zeros(Float64, num_bins)
|
|
bin_step = step(mz_bins)
|
|
inv_bin_step = 1.0 / bin_step # Precompute reciprocal to avoid division
|
|
min_mz = global_min_mz
|
|
|
|
# 3. Second Pass: Optimized binning
|
|
pass2_start_time = time_ns()
|
|
println(" Pass 2: Summing intensities into $num_bins bins...")
|
|
_iterate_spectra_fast(msi_data) do idx, mz, intensity
|
|
if isempty(mz)
|
|
return
|
|
end
|
|
|
|
# Use views to avoid bounds checks on the input arrays
|
|
mz_view = mz
|
|
intensity_view = intensity
|
|
|
|
# Manual binning without clamp/round for SIMD
|
|
@inbounds for i in eachindex(mz_view)
|
|
# Calculate raw bin index (much faster than round+clamp)
|
|
raw_index = (mz_view[i] - min_mz) * inv_bin_step + 1.0
|
|
bin_index = trunc(Int, raw_index)
|
|
|
|
# Manual bounds checking (faster than clamp)
|
|
if 1 <= bin_index <= num_bins
|
|
intensity_sum[bin_index] += intensity_view[i]
|
|
elseif bin_index < 1
|
|
intensity_sum[1] += intensity_view[i]
|
|
else # bin_index > num_bins
|
|
intensity_sum[num_bins] += intensity_view[i]
|
|
end
|
|
end
|
|
end
|
|
pass2_duration = (time_ns() - pass2_start_time) / 1e9
|
|
|
|
total_duration = (time_ns() - total_start_time) / 1e9
|
|
println("\n--- imzML Profiling (Post-Optimization) ---")
|
|
@printf " Pass 2 (I/O + Binning): %.2f seconds\n" pass2_duration
|
|
@printf " Total Function Time: %.2f seconds\n" total_duration
|
|
println("----------------------------------------\n")
|
|
|
|
println("Total spectrum calculation complete for imzML.")
|
|
return (collect(mz_bins), intensity_sum)
|
|
end
|
|
|
|
"""
|
|
get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000)
|
|
|
|
Internal function to calculate the total spectrum for an `.mzML` file.
|
|
|
|
It uses a two-pass approach analogous to the `imzML` implementation:
|
|
1. The first pass finds the global m/z range by iterating through all spectra.
|
|
2. The second pass sums intensities into a pre-defined number of bins.
|
|
|
|
This function is called by `get_total_spectrum`.
|
|
"""
|
|
function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000)
|
|
println("Calculating total spectrum for mzML (2-pass method)...")
|
|
total_start_time = time_ns()
|
|
|
|
local global_min_mz, global_max_mz
|
|
|
|
if msi_data.global_min_mz !== nothing
|
|
println(" Using pre-computed m/z range.")
|
|
global_min_mz = msi_data.global_min_mz
|
|
global_max_mz = msi_data.global_max_mz
|
|
else
|
|
# --- Pass 1: Find m/z range and cache it ---
|
|
pass1_start_time = time_ns()
|
|
println(" Pass 1: Finding global m/z range...")
|
|
g_min_mz = Inf
|
|
g_max_mz = -Inf
|
|
_iterate_spectra_fast(msi_data) do idx, mz, intensity
|
|
if !isempty(mz)
|
|
local_min, local_max = extrema(mz)
|
|
g_min_mz = min(g_min_mz, local_min)
|
|
g_max_mz = max(g_max_mz, local_max)
|
|
end
|
|
end
|
|
pass1_duration = (time_ns() - pass1_start_time) / 1e9
|
|
|
|
if !isfinite(g_min_mz)
|
|
@warn "Could not determine a valid m/z range for mzML. All spectra might be empty."
|
|
return (Float64[], Float64[])
|
|
end
|
|
|
|
# Use and cache the result
|
|
global_min_mz = g_min_mz
|
|
global_max_mz = g_max_mz
|
|
msi_data.global_min_mz = global_min_mz
|
|
msi_data.global_max_mz = global_max_mz
|
|
|
|
println(" Global m/z range found and cached: [$(global_min_mz), $(global_max_mz)]")
|
|
@printf " (Pass 1 took %.2f seconds)\n" pass1_duration
|
|
end
|
|
|
|
# --- Pass 2: Bin intensities ---
|
|
pass2_start_time = time_ns()
|
|
println(" Pass 2: Summing intensities into $num_bins bins...")
|
|
|
|
mz_bins = range(global_min_mz, stop=global_max_mz, length=num_bins)
|
|
intensity_sum = zeros(Float64, num_bins)
|
|
bin_step = step(mz_bins)
|
|
inv_bin_step = 1.0 / bin_step # Precompute reciprocal
|
|
min_mz = global_min_mz
|
|
|
|
_iterate_spectra_fast(msi_data) do idx, mz, intensity
|
|
if isempty(mz)
|
|
return
|
|
end
|
|
|
|
@inbounds for i in eachindex(mz)
|
|
# Calculate raw bin index
|
|
raw_index = (mz[i] - min_mz) * inv_bin_step + 1.0
|
|
bin_index = trunc(Int, raw_index)
|
|
|
|
# Manual bounds checking
|
|
if 1 <= bin_index <= num_bins
|
|
intensity_sum[bin_index] += intensity[i]
|
|
elseif bin_index < 1
|
|
intensity_sum[1] += intensity[i]
|
|
else # bin_index > num_bins
|
|
intensity_sum[num_bins] += intensity[i]
|
|
end
|
|
end
|
|
end
|
|
pass2_duration = (time_ns() - pass2_start_time) / 1e9
|
|
|
|
total_duration = (time_ns() - total_start_time) / 1e9
|
|
println("\n--- mzML Profiling (Post-Optimization) ---")
|
|
@printf " Pass 2 (I/O + Binning): %.2f seconds\n" pass2_duration
|
|
@printf " Total Function Time: %.2f seconds\n" total_duration
|
|
println("----------------------------------------\n")
|
|
|
|
println("Total spectrum calculation complete for mzML.")
|
|
return (collect(mz_bins), intensity_sum)
|
|
end
|
|
|
|
"""
|
|
get_total_spectrum(msi_data::MSIData; num_bins::Int=2000) -> Tuple{Vector{Float64}, Vector{Float64}}
|
|
|
|
Calculates the sum of all spectra in the dataset by binning.
|
|
This function dispatches to a specialized implementation based on the file type
|
|
(.imzML or .mzML) for optimal performance.
|
|
|
|
Returns a tuple containing two vectors: the binned m/z axis and the summed intensities.
|
|
"""
|
|
function get_total_spectrum(msi_data::MSIData; num_bins::Int=2000)
|
|
if msi_data.source isa ImzMLSource
|
|
return get_total_spectrum_imzml(msi_data, num_bins=num_bins)
|
|
else # MzMLSource
|
|
return get_total_spectrum_mzml(msi_data, num_bins=num_bins)
|
|
end
|
|
end
|
|
|
|
"""
|
|
get_average_spectrum(msi_data::MSIData; num_bins::Int=2000) -> Tuple{Vector{Float64}, Vector{Float64}}
|
|
|
|
Calculates the average of all spectra in the dataset by binning.
|
|
This is effectively the total spectrum divided by the number of spectra.
|
|
|
|
Returns a tuple containing two vectors: the binned m/z axis and the averaged intensities.
|
|
"""
|
|
function get_average_spectrum(msi_data::MSIData; num_bins::Int=2000)
|
|
mz_bins, intensity_sum = get_total_spectrum(msi_data, num_bins=num_bins)
|
|
|
|
if isempty(intensity_sum)
|
|
return (mz_bins, intensity_sum)
|
|
end
|
|
num_spectra = length(msi_data.spectra_metadata)
|
|
average_intensity = intensity_sum ./ num_spectra
|
|
|
|
return (mz_bins, average_intensity)
|
|
end
|
|
|
|
# --- Iterator Implementation --- #
|
|
|
|
"""
|
|
MSIDataIterator
|
|
|
|
A stateful iterator for sequentially accessing spectra from an `MSIData` object.
|
|
This is created by the `IterateSpectra` function.
|
|
"""
|
|
struct MSIDataIterator
|
|
data::MSIData
|
|
end
|
|
|
|
"""
|
|
IterateSpectra(data::MSIData)
|
|
|
|
Returns an iterator that yields each spectrum, processing the file sequentially.
|
|
This iterator is useful for processing all spectra in a loop and benefits from
|
|
the caching implemented in `GetSpectrum`.
|
|
|
|
This function is the core of the "Event-driven" access pattern.
|
|
"""
|
|
function IterateSpectra(data::MSIData)
|
|
return MSIDataIterator(data)
|
|
end
|
|
|
|
# Define the iteration interface for the custom iterator
|
|
Base.length(it::MSIDataIterator) = length(it.data.spectra_metadata)
|
|
Base.eltype(it::MSIDataIterator) = Tuple{Int, Tuple{Vector, Vector}} # Index, (mz, intensity)
|
|
|
|
"""
|
|
Base.iterate(it::MSIDataIterator, state=1)
|
|
|
|
Advances the iterator, returning the next spectrum in the sequence. It calls
|
|
`GetSpectrum`, which means the iteration process is cached according to the
|
|
`MSIData` object's cache settings.
|
|
|
|
# Arguments
|
|
- `it`: The `MSIDataIterator` instance.
|
|
- `state`: The index of the next spectrum to retrieve.
|
|
|
|
# Returns
|
|
- A tuple `((index, spectrum), next_state)` or `nothing` if iteration is complete.
|
|
"""
|
|
function Base.iterate(it::MSIDataIterator, state=1)
|
|
if state > length(it.data.spectra_metadata)
|
|
return nothing # End of iteration
|
|
end
|
|
|
|
# GetSpectrum uses the cache, so iteration is also cached
|
|
spectrum = GetSpectrum(it.data, state)
|
|
|
|
# Yield the index and the spectrum
|
|
return ((state, spectrum), state + 1)
|
|
end
|
|
|
|
|
|
# --- High-performance Internal Iterator --- #
|
|
|
|
"""
|
|
read_compressed_array(io::IO, asset::SpectrumAsset, format::Type)
|
|
|
|
Reads a single data array (m/z or intensity) from an `.ibd` file stream,
|
|
handling both compressed and uncompressed data.
|
|
|
|
- If `asset.is_compressed` is true, it reads the compressed bytes, inflates
|
|
them using zlib, and reinterprets the result as a vector of the given `format`.
|
|
- If false, it reads the uncompressed data directly into a vector.
|
|
|
|
# Arguments
|
|
- `io`: The IO stream of the `.ibd` file.
|
|
- `asset`: The `SpectrumAsset` for the array.
|
|
- `format`: The data type of the elements in the array.
|
|
|
|
# Returns
|
|
- A `Vector` containing the data.
|
|
"""
|
|
function read_compressed_array(io::IO, asset::SpectrumAsset, format::Type)
|
|
seek(io, asset.offset)
|
|
|
|
if asset.is_compressed
|
|
# Read compressed bytes - use encoded_length as the number of compressed bytes
|
|
compressed_bytes = read(io, asset.encoded_length)
|
|
|
|
println("DEBUG: Decompressing data - offset=$(asset.offset), compressed_bytes=$(length(compressed_bytes))")
|
|
|
|
local decompressed_bytes
|
|
try
|
|
decompressed_bytes = Libz.inflate(compressed_bytes)
|
|
println("DEBUG: Decompression successful - decompressed_bytes=$(length(decompressed_bytes))")
|
|
catch e
|
|
@error "ZLIB DECOMPRESSION FAILED. This is likely due to an incorrect offset or corrupt data in the .ibd file."
|
|
@error "Asset offset: $(asset.offset), Encoded length: $(asset.encoded_length)"
|
|
# Print first 16 bytes to stderr for diagnosis
|
|
bytes_to_print = min(16, length(compressed_bytes))
|
|
@error "First $bytes_to_print bytes of the data chunk we tried to decompress:"
|
|
println(stderr, view(compressed_bytes, 1:bytes_to_print))
|
|
rethrow(e)
|
|
end
|
|
|
|
# Use an IOBuffer to safely read the data
|
|
bytes_io = IOBuffer(decompressed_bytes)
|
|
n_elements = bytes_io.size ÷ sizeof(format)
|
|
array = Array{format}(undef, n_elements)
|
|
read!(bytes_io, array)
|
|
return array
|
|
else
|
|
# Read uncompressed data directly
|
|
# For uncompressed imzML, encoded_length is the number of elements
|
|
array = Vector{format}(undef, asset.encoded_length)
|
|
read!(io, array)
|
|
return array
|
|
end
|
|
end
|
|
|
|
"""
|
|
_iterate_uncompressed_fast(f::Function, data::MSIData, source::ImzMLSource)
|
|
|
|
A highly optimized iterator for uncompressed `.imzML` data.
|
|
|
|
It pre-allocates large buffers for m/z and intensity arrays and reuses them
|
|
for each spectrum by creating views. This minimizes memory allocations and
|
|
is significantly faster for bulk processing than reading spectra one by one.
|
|
|
|
# Arguments
|
|
- `f`: A function to execute for each spectrum, with the signature `f(index, mz_view, int_view)`.
|
|
- `data`: The `MSIData` object.
|
|
- `source`: The `ImzMLSource`.
|
|
"""
|
|
function _iterate_uncompressed_fast(f::Function, data::MSIData, source::ImzMLSource)
|
|
# Optimized path for uncompressed data using buffer reuse
|
|
max_points = maximum(meta -> meta.mz_asset.encoded_length, data.spectra_metadata)
|
|
mz_buffer = Vector{source.mz_format}(undef, max_points)
|
|
int_buffer = Vector{source.intensity_format}(undef, max_points)
|
|
|
|
for i in 1:length(data.spectra_metadata)
|
|
meta = data.spectra_metadata[i]
|
|
nPoints = meta.mz_asset.encoded_length
|
|
|
|
if nPoints == 0
|
|
f(i, view(mz_buffer, 0:-1), view(int_buffer, 0:-1))
|
|
continue
|
|
end
|
|
|
|
mz_view = view(mz_buffer, 1:nPoints)
|
|
int_view = view(int_buffer, 1:nPoints)
|
|
|
|
if meta.mz_asset.offset < meta.int_asset.offset
|
|
seek(source.ibd_handle, meta.mz_asset.offset)
|
|
read!(source.ibd_handle, mz_view)
|
|
read!(source.ibd_handle, int_view)
|
|
else
|
|
seek(source.ibd_handle, meta.int_asset.offset)
|
|
read!(source.ibd_handle, int_view)
|
|
read!(source.ibd_handle, mz_view)
|
|
end
|
|
|
|
mz_view .= ltoh.(mz_view)
|
|
int_view .= ltoh.(int_view)
|
|
f(i, mz_view, int_view)
|
|
end
|
|
end
|
|
|
|
"""
|
|
_iterate_compressed_fast(f::Function, data::MSIData, source::ImzMLSource)
|
|
|
|
An iterator for `.imzML` datasets that contain compressed spectra.
|
|
|
|
This function iterates through each spectrum and reads its data individually,
|
|
decompressing it on the fly if necessary. Because the size of decompressed
|
|
data is not known in advance, this path cannot use the buffer-reuse optimization
|
|
and will be slower and allocate more memory than `_iterate_uncompressed_fast`.
|
|
|
|
# Arguments
|
|
- `f`: A function to execute for each spectrum, with the signature `f(index, mz_array, int_array)`.
|
|
- `data`: The `MSIData` object.
|
|
- `source`: The `ImzMLSource`.
|
|
"""
|
|
function _iterate_compressed_fast(f::Function, data::MSIData, source::ImzMLSource)
|
|
# Path for datasets containing at least one compressed spectrum.
|
|
# This path reads and decompresses each spectrum individually.
|
|
for i in 1:length(data.spectra_metadata)
|
|
meta = data.spectra_metadata[i]
|
|
|
|
if meta.mz_asset.encoded_length == 0 && meta.int_asset.encoded_length == 0
|
|
f(i, source.mz_format[], source.intensity_format[])
|
|
continue
|
|
end
|
|
|
|
# Read and decompress each array
|
|
mz_array = read_compressed_array(source.ibd_handle, meta.mz_asset, source.mz_format)
|
|
intensity_array = read_compressed_array(source.ibd_handle, meta.int_asset, source.intensity_format)
|
|
|
|
mz_array .= ltoh.(mz_array)
|
|
intensity_array .= ltoh.(intensity_array)
|
|
|
|
f(i, mz_array, intensity_array)
|
|
end
|
|
end
|
|
|
|
"""
|
|
_iterate_spectra_fast_impl(f::Function, data::MSIData, source::ImzMLSource)
|
|
|
|
Internal implementation of the fast iterator for `.imzML` files. It reads
|
|
data directly from the `.ibd` file stream.
|
|
|
|
This function acts as a dispatcher:
|
|
- If any spectrum is compressed, it uses a slower path that decompresses each spectrum individually.
|
|
- If all spectra are uncompressed, it uses a highly optimized path that reuses pre-allocated
|
|
buffers to minimize memory allocations and overhead.
|
|
"""
|
|
function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::ImzMLSource)
|
|
if isempty(data.spectra_metadata)
|
|
return
|
|
end
|
|
|
|
# Check if ANY spectra are compressed and dispatch to the appropriate implementation
|
|
any_compressed = any(meta -> meta.mz_asset.is_compressed || meta.int_asset.is_compressed,
|
|
data.spectra_metadata)
|
|
|
|
if any_compressed
|
|
_iterate_compressed_fast(f, data, source)
|
|
else
|
|
_iterate_uncompressed_fast(f, data, source)
|
|
end
|
|
end
|
|
|
|
"""
|
|
_iterate_spectra_fast_impl(f::Function, data::MSIData, source::MzMLSource)
|
|
|
|
Internal implementation of the fast iterator for `.mzML` files. It iterates
|
|
through each spectrum, decodes the Base64 data on the fly, and calls the
|
|
provided function. It bypasses the `GetSpectrum` cache to avoid storing
|
|
all decoded spectra in memory.
|
|
"""
|
|
function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::MzMLSource)
|
|
# This implementation is for mzML. It iterates through each spectrum,
|
|
# decodes it, and then calls the function `f`. It's less performant than
|
|
# the imzML version because we cannot pre-allocate buffers of a known size,
|
|
# but it is correct and still faster than using GetSpectrum due to bypassing the cache.
|
|
for i in 1:length(data.spectra_metadata)
|
|
meta = data.spectra_metadata[i]
|
|
|
|
mz = read_binary_vector(source.file_handle, meta.mz_asset)
|
|
intensity = read_binary_vector(source.file_handle, meta.int_asset)
|
|
|
|
f(i, mz, intensity)
|
|
end
|
|
end
|
|
|
|
"""
|
|
_iterate_spectra_fast(f::Function, data::MSIData)
|
|
|
|
An internal, high-performance iterator for bulk processing that bypasses the cache.
|
|
It dispatches to a specialized implementation based on the data source type
|
|
(`ImzMLSource` or `MzMLSource`).
|
|
|
|
- `f`: A function to call for each spectrum, with signature `f(index, mz, intensity)`.
|
|
- `data`: The `MSIData` object.
|
|
"""
|
|
function _iterate_spectra_fast(f::Function, data::MSIData)
|
|
# Dispatch to the correct implementation based on the source type
|
|
_iterate_spectra_fast_impl(f, data, data.source)
|
|
end |