JuliaMSI/src/Precalculations.jl

1760 lines
70 KiB
Julia
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using StatsBase # For mean, std, median, quantile, mad
# =============================================================================
# 7) Spatial & Advanced Processing (Stubs & New Functions)
# =============================================================================
"""
find_ppm_error_by_region(msi_data::MSIData, region_masks::Dict, reference_peaks::Dict) -> Dict
Calculates and reports mass accuracy (PPM error) statistics for different spatial regions
defined by masks. This is useful for identifying spatial variations in calibration.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `region_masks::Dict{Symbol, BitMatrix}`: A dictionary mapping region names (e.g., `:tumor`, `:stroma`)
to `BitMatrix` masks. The dimensions of each mask must match `msi_data.image_dims`.
- `reference_peaks::Dict{Float64, String}`: A dictionary of known reference peaks, mapping
theoretical m/z to a name.
# Returns
- `Dict{Symbol, NamedTuple}`: A dictionary where keys are region names and values are `NamedTuple`s
containing the mass accuracy report for that region, as generated by `analyze_mass_accuracy`.
"""
function find_ppm_error_by_region(msi_data::MSIData, region_masks::Dict, reference_peaks::Dict)
regional_reports = Dict{Symbol, NamedTuple}()
width, height = msi_data.image_dims
for (region_name, mask) in region_masks
mask_height, mask_width = size(mask)
if mask_width != width || mask_height != height
@warn "Mask dimensions ($(mask_width)x$(mask_height)) for region '$region_name' do not match image dimensions ($(width)x$(height)). Skipping."
continue
end
indices = [
i for i in 1:length(msi_data.spectra_metadata)
if msi_data.spectra_metadata[i].x > 0 &&
msi_data.spectra_metadata[i].y > 0 &&
mask[msi_data.spectra_metadata[i].y, msi_data.spectra_metadata[i].x]
]
if isempty(indices) continue end
# Call analyze_mass_accuracy with the specific indices for the region
regional_reports[region_name] = analyze_mass_accuracy(msi_data, reference_peaks; spectrum_indices=indices)
end
return regional_reports
end
"""
analyze_mass_accuracy(msi_data, reference_peaks; ...) -> NamedTuple
Analyzes the mass accuracy for a given subset of spectra by comparing detected peaks
against a list of known reference masses.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `reference_peaks::Dict{Float64, String}`: A dictionary of known reference peaks, mapping
theoretical m/z to a name.
- `spectrum_indices::AbstractVector{Int}`: A vector of indices for the spectra to be analyzed.
- `peak_detection_snr_threshold::Float64`: The Signal-to-Noise ratio threshold to use for
detecting peaks within the spectra.
- `ppm_tolerance_for_matching::Float64`: The tolerance in Parts Per Million (PPM) used to match
a detected peak to a reference peak.
# Returns
- `NamedTuple`: A report containing summary statistics of the PPM errors found, including:
- `mean_ppm_error`, `median_ppm_error`, `std_ppm_error`, `min_ppm_error`, `max_ppm_error`
- `total_matched_peaks`: The total count of successful matches between detected and reference peaks.
- `total_spectra_analyzed`: The number of spectra processed.
"""
function analyze_mass_accuracy(
msi_data::MSIData,
reference_peaks::Dict{Float64, String}; # m/z => name
spectrum_indices::AbstractVector{Int},
peak_detection_snr_threshold::Float64 = 2.0,
ppm_tolerance_for_matching::Float64 = 50.0
)::NamedTuple
println("Analyzing mass accuracy for $(length(spectrum_indices)) spectra...")
all_ppm_errors = Float64[]
total_matched_peaks = 0
total_spectra_processed = 0
_iterate_spectra_fast(msi_data, spectrum_indices) do idx, mz, intensity
total_spectra_processed += 1
if !validate_spectrum(mz, intensity)
@warn "Spectrum $idx is invalid, skipping mass accuracy analysis for it."
return
end
detected_peaks = detect_peaks_profile_core(mz, intensity; snr_threshold=peak_detection_snr_threshold)
for ref_mz in keys(reference_peaks)
# Find the closest detected peak to this reference m/z within tolerance
min_ppm_error = Inf
best_matched_peak_mz = nothing
for p in detected_peaks
ppm_error = calculate_ppm_error(p.mz, ref_mz)
if ppm_error <= ppm_tolerance_for_matching && ppm_error < min_ppm_error
min_ppm_error = ppm_error
best_matched_peak_mz = p.mz
end
end
if best_matched_peak_mz !== nothing
push!(all_ppm_errors, min_ppm_error)
total_matched_peaks += 1
end
end
end
if isempty(all_ppm_errors)
@warn "No reference peaks matched in any of the $(total_spectra_processed) processed spectra."
return (
mean_ppm_error = NaN,
median_ppm_error = NaN,
std_ppm_error = NaN,
min_ppm_error = NaN,
max_ppm_error = NaN,
total_matched_peaks = 0,
total_spectra_analyzed = total_spectra_processed,
ppm_error_distribution = Float64[]
)
end
# Calculate summary statistics
mean_err = mean(all_ppm_errors)
median_err = median(all_ppm_errors)
std_err = std(all_ppm_errors)
min_err = minimum(all_ppm_errors)
max_err = maximum(all_ppm_errors)
println("Mass accuracy analysis complete for $(total_spectra_processed) spectra.")
return (
mean_ppm_error = mean_err,
median_ppm_error = median_err,
std_ppm_error = std_err,
min_ppm_error = min_err,
max_ppm_error = max_err,
total_matched_peaks = total_matched_peaks,
total_spectra_analyzed = total_spectra_processed,
ppm_error_distribution = all_ppm_errors
)
end
# =============================================================================
# 8) Advanced Peak Quality & Adaptive Parameters
# =============================================================================
"""
calculate_adaptive_bin_tolerance(ppm_error_distribution) -> Float64
Calculates an appropriate binning tolerance in PPM based on the observed mass accuracy
distribution. The strategy is to set the tolerance to capture the vast majority of
peaks from the same analyte, typically using `mean + 3 * standard_deviation`.
# Arguments
- `ppm_error_distribution::Vector{Float64}`: A vector of PPM error values from a mass
accuracy analysis.
# Returns
- `Float64`: The suggested binning tolerance in PPM, capped between 10.0 and 100.0.
"""
function calculate_adaptive_bin_tolerance(ppm_error_distribution::Vector{Float64})
if isempty(ppm_error_distribution)
return 20.0 # Default if no data
end
# Filter out NaN and infinite values
valid_errors = filter(x -> isfinite(x), ppm_error_distribution)
if isempty(valid_errors)
return 20.0 # Default if no valid data
end
# A robust strategy: mean + 3 * std deviation to capture ~99.7% of peaks
mean_err = mean(valid_errors)
std_err = std(valid_errors)
# Ensure we don't get NaN
if !isfinite(mean_err) || !isfinite(std_err)
return 20.0
end
tolerance = mean_err + 3 * std_err
# Cap at reasonable value and ensure finite
return min(max(tolerance, 10.0), 100.0)
end
"""
calculate_preprocessing_hints(data::MSIData; sample_indices)::Dict{Symbol, Any}
Analyzes a sample of spectra to determine initial "hints" for preprocessing parameters.
This function provides quick, data-driven defaults for noise level, SNR, and smoothing.
# Arguments
- `data::MSIData`: The main MSI data object.
- `sample_indices::AbstractVector{Int}`: The indices of spectra to sample for the analysis.
# Returns
- `Dict{Symbol, Any}`: A dictionary of hints, including:
- `:estimated_noise`: The mean noise level estimated using Median Absolute Deviation (MAD).
- `:suggested_snr`: A default SNR threshold (typically 3.0).
- `:suggested_smoothing_window`: A suggested window size for smoothing, based on instrument resolution if available.
"""
function calculate_preprocessing_hints(data::MSIData; sample_indices::AbstractVector{Int})::Dict{Symbol, Any}
println("Calculating preprocessing hints from a sample of $(length(sample_indices)) spectra...")
num_spectra = length(data.spectra_metadata)
if num_spectra == 0 || isempty(sample_indices)
@warn "No spectra in dataset/sample to calculate hints from."
return Dict{Symbol, Any}(
:estimated_noise => 1.0,
:suggested_snr => 3.0,
:suggested_smoothing_window => 9
)
end
# Initialize hints with defaults immediately
hints = Dict{Symbol, Any}(
:estimated_noise => 1.0,
:suggested_snr => 3.0,
:suggested_smoothing_window => 9
)
all_noise_levels = Float64[]
for idx in sample_indices
try
mz, intensity = GetSpectrum(data, idx)
if !isempty(intensity)
noise = mad(intensity, normalize=true)
if isfinite(noise) && noise > 0
push!(all_noise_levels, noise)
end
end
catch e
@warn "Could not access spectrum #$idx to calculate hints: $e. Skipping."
end
end
if !isempty(all_noise_levels)
estimated_noise = mean(all_noise_levels)
hints[:estimated_noise] = estimated_noise
println(" - Estimated Noise Level: $(round(estimated_noise, digits=4))")
println(" - Suggested SNR Threshold: 3.0")
else
@warn "Could not estimate noise from sample. Using default hints."
# Defaults already set in `hints` initialization
end
# Suggest smoothing window based on resolution (if available)
if data.instrument_metadata !== nothing && data.instrument_metadata.resolution !== nothing
res = data.instrument_metadata.resolution
# Update suggested smoothing window if resolution is known
if res > 40000
hints[:suggested_smoothing_window] = 5
elseif res > 10000
hints[:suggested_smoothing_window] = 7
else
hints[:suggested_smoothing_window] = 9
end
end
println(" - Suggested Smoothing Window: $(hints[:suggested_smoothing_window])")
println("Preprocessing hints calculated.")
return hints
end
"""
analyze_instrument_characteristics(msi_data::MSIData; sample_indices)::Dict
Analyzes instrument metadata and spectral data to infer key acquisition properties.
It combines information from the `msi_data.instrument_metadata` with direct analysis
of the spectra.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `sample_indices::AbstractVector{Int}`: The indices of spectra to sample for the analysis.
# Returns
- `Dict{Symbol, Any}`: A dictionary summarizing instrument characteristics:
- `:acquisition_mode`: Inferred as `:profile`, `:centroid`, or `:mixed`.
- `:mz_axis_type`: Inferred as `:regular` or `:irregular` based on m/z step consistency.
- `:dynamic_range`: An estimate of the intensity dynamic range in orders of magnitude.
- Other fields from `instrument_metadata` like `:resolution`, `:polarity`, etc.
"""
function analyze_instrument_characteristics(msi_data::MSIData; sample_indices::AbstractVector{Int})::Dict
results = Dict{Symbol, Any}()
# Use instrument metadata if available
if msi_data.instrument_metadata !== nothing
inst = msi_data.instrument_metadata
results[:resolution] = inst.resolution
results[:mass_accuracy_ppm] = inst.mass_accuracy_ppm
results[:instrument_model] = inst.instrument_model
results[:polarity] = inst.polarity
results[:calibration_status] = inst.calibration_status
results[:vendor_preprocessing] = inst.vendor_preprocessing_steps
end
# Analyze data characteristics from spectra
println(" Analyzing data characteristics from spectra...")
spectrum_modes = Set{SpectrumMode}()
mz_step_sizes = Float64[]
intensity_ranges = Tuple{Float64, Float64}[] # (min, max) per spectrum
if isempty(sample_indices)
@warn "No indices to sample for instrument characteristics analysis."
results[:acquisition_mode] = :unknown
results[:mz_axis_type] = :unknown
results[:dynamic_range] = 3.0
return results
end
_iterate_spectra_fast(msi_data, sample_indices) do idx, mz, intensity
# Record spectrum mode
push!(spectrum_modes, msi_data.spectra_metadata[idx].mode)
# Calculate m/z step statistics (for profile data)
if length(mz) > 1 && msi_data.spectra_metadata[idx].mode == PROFILE
steps = diff(mz)
if !isempty(steps)
push!(mz_step_sizes, mean(steps))
end
end
# Record intensity range
if !isempty(intensity)
push!(intensity_ranges, (minimum(intensity), maximum(intensity)))
end
end
# Determine acquisition mode
if length(spectrum_modes) == 1
results[:acquisition_mode] = first(spectrum_modes) == CENTROID ? :centroid : :profile
else
results[:acquisition_mode] = :mixed
end
# Determine m/z axis regularity
if !isempty(mz_step_sizes)
avg_step = mean(mz_step_sizes)
step_std = std(mz_step_sizes)
results[:mz_axis_type] = step_std / avg_step < 0.01 ? :regular : :irregular
results[:average_mz_step] = avg_step
else
results[:mz_axis_type] = :unknown
end
# Enhanced dynamic range calculation
if !isempty(intensity_ranges)
println("DEBUG: Found $(length(intensity_ranges)) intensity ranges")
# FIX: Handle cases where min intensities are zero
max_intensities = Float64[]
min_positive_intensities = Float64[]
for (min_val, max_val) in intensity_ranges
if max_val > 1e-6 # Valid maximum
push!(max_intensities, max_val)
# Find the smallest positive intensity in the spectrum
# For now, use a reasonable estimate: 1% of the noise level
# In practice, you'd want to sample the actual spectrum
if max_val > 0
# Estimate minimum detectable signal as ~3x noise level
estimated_min_signal = max_val * 1e-4 # Conservative estimate
push!(min_positive_intensities, estimated_min_signal)
end
end
end
println("DEBUG: Valid max intensities: $(length(max_intensities)), estimated min intensities: $(length(min_positive_intensities))")
if !isempty(max_intensities) && !isempty(min_positive_intensities)
avg_max = mean(max_intensities)
avg_min = mean(min_positive_intensities)
if avg_min > 0
dynamic_range = log10(avg_max / avg_min)
results[:dynamic_range] = dynamic_range
println("DEBUG: Dynamic range calculated: $dynamic_range (avg_max=$avg_max, avg_min=$avg_min)")
else
results[:dynamic_range] = 0.0
end
else
# Estimate based on typical values
results[:dynamic_range] = 3.0 # Typical for MS data
end
else
results[:dynamic_range] = 3.0 # Default estimate
end
println(" - Acquisition mode: $(results[:acquisition_mode])")
println(" - m/z axis type: $(results[:mz_axis_type])")
println(" - Dynamic range: $(round(get(results, :dynamic_range, 0), digits=2))")
return results
end
"""
analyze_signal_quality(msi_data::MSIData; sample_indices)::Dict
Analyzes a sample of spectra to assess signal quality, including noise levels,
Signal-to-Noise Ratio (SNR), and Total Ion Current (TIC) variation.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `sample_indices::AbstractVector{Int}`: The indices of spectra to sample for the analysis.
# Returns
- `Dict{Symbol, Any}`: A dictionary of signal quality metrics:
- `:noise_mean`, `:noise_std`, `:noise_cv`: Statistics of the noise level.
- `:snr_mean`, `:snr_median`, `:snr_95th`: Distribution statistics of the estimated SNR.
- `:tic_mean`, `:tic_std`, `:tic_cv`: Statistics of the Total Ion Current.
"""
function analyze_signal_quality(msi_data::MSIData; sample_indices::AbstractVector{Int})::Dict
results = Dict{Symbol, Any}()
println(" Analyzing signal quality from $(length(sample_indices)) sample spectra...")
# Use the existing function for basic noise estimation
hints_from_calc = calculate_preprocessing_hints(msi_data, sample_indices=sample_indices)
# Copy relevant hints to results, or handle cases where they might be missing
results[:estimated_noise] = get(hints_from_calc, :estimated_noise, 1.0)
results[:suggested_snr] = get(hints_from_calc, :suggested_snr, 3.0)
results[:suggested_smoothing_window] = get(hints_from_calc, :suggested_smoothing_window, 9)
# Enhanced noise analysis
noise_levels = Float64[]
snr_distribution = Float64[]
tic_values = Float64[]
if isempty(sample_indices)
@warn "No indices to sample for signal quality analysis."
return results
end
_iterate_spectra_fast(msi_data, sample_indices) do idx, mz, intensity
if !isempty(intensity)
# Noise estimation using MAD
noise = mad(intensity, normalize=true)
push!(noise_levels, noise)
# FIX: More robust SNR calculation
valid_intensity = intensity[intensity .> 0] # Remove zeros
if !isempty(valid_intensity)
# Use robust signal estimate (95th percentile instead of max)
signal_estimate = quantile(valid_intensity, 0.95)
noise_robust = max(noise, 1e-6) # Avoid division by zero
if noise_robust > 0 && isfinite(signal_estimate)
snr_val = signal_estimate / noise_robust
# Cap unrealistic SNR values
push!(snr_distribution, min(snr_val, 1e6))
end
end
# Total ion count
push!(tic_values, sum(intensity))
end
end
if !isempty(noise_levels)
results[:noise_mean] = mean(noise_levels)
results[:noise_std] = std(noise_levels)
results[:noise_cv] = results[:noise_std] / results[:noise_mean] # Coefficient of variation
end
if !isempty(snr_distribution)
results[:snr_mean] = mean(snr_distribution)
results[:snr_median] = median(snr_distribution)
results[:snr_95th] = quantile(snr_distribution, 0.95)
end
if !isempty(tic_values)
results[:tic_mean] = mean(tic_values)
results[:tic_std] = std(tic_values)
results[:tic_cv] = results[:tic_std] / results[:tic_mean]
end
println(" - Estimated noise level: $(round(get(results, :noise_mean, 0), digits=4))")
println(" - Average SNR: $(round(get(results, :snr_mean, 0), digits=2))")
println(" - TIC CV: $(round(get(results, :tic_cv, 0) * 100, digits=1))%")
return results
end
"""
analyze_mass_accuracy_global(msi_data, reference_peaks; spectrum_indices)::Dict
Performs a global mass accuracy analysis across a sample of spectra and suggests an
adaptive binning tolerance.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `reference_peaks::Dict`: A dictionary of known reference peaks.
- `spectrum_indices::AbstractVector{Int}`: The indices of spectra to sample for the analysis.
# Returns
- `Dict{Symbol, Any}`: A dictionary containing:
- `:global_accuracy`: The `NamedTuple` report from `analyze_mass_accuracy`.
- `:suggested_bin_tolerance`: An adaptive tolerance in PPM for peak binning, derived
from the mass accuracy results.
"""
function analyze_mass_accuracy_global(msi_data::MSIData, reference_peaks::Dict;
spectrum_indices::AbstractVector{Int})::Dict
results = Dict{Symbol, Any}()
println(" Analyzing mass accuracy using $(length(reference_peaks)) reference peaks on $(length(spectrum_indices)) spectra...")
if isempty(spectrum_indices)
@warn "No indices to sample for mass accuracy analysis."
# Return a structure indicating no analysis was performed
empty_report = (
mean_ppm_error = NaN, median_ppm_error = NaN, std_ppm_error = NaN,
min_ppm_error = NaN, max_ppm_error = NaN, total_matched_peaks = 0,
total_spectra_analyzed = 0, ppm_error_distribution = Float64[]
)
results[:global_accuracy] = empty_report
results[:suggested_bin_tolerance] = 20.0 # Default
return results
end
# Use the existing analyze_mass_accuracy function with the provided indices
accuracy_report = analyze_mass_accuracy(msi_data, reference_peaks;
spectrum_indices=spectrum_indices)
results[:global_accuracy] = accuracy_report
results[:suggested_bin_tolerance] = calculate_adaptive_bin_tolerance(
accuracy_report.ppm_error_distribution
)
println(" - Mean PPM error: $(round(accuracy_report.mean_ppm_error, digits=2))")
println(" - Suggested bin tolerance: $(round(results[:suggested_bin_tolerance], digits=2)) ppm")
return results
end
"""
analyze_spatial_regions(msi_data, region_masks, reference_peaks)::Dict
Analyzes different spatial regions for variations in mass accuracy. This function is a
wrapper around `find_ppm_error_by_region` and summarizes the results.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `region_masks::Dict`: A dictionary of named `BitMatrix` masks for each region.
- `reference_peaks::Dict`: A dictionary of known reference peaks.
# Returns
- `Dict{Symbol, Any}`: A dictionary containing:
- `:regional_ppm_errors`: A dictionary mapping each region name to its mass accuracy report.
- `:max_regional_ppm_difference`: The difference between the highest and lowest mean PPM
error across all analyzed regions.
"""
function analyze_spatial_regions(msi_data::MSIData, region_masks::Dict, reference_peaks::Dict)::Dict
results = Dict{Symbol, Any}()
println(" Analyzing $(length(region_masks)) spatial regions...")
# Use the existing function
regional_reports = find_ppm_error_by_region(msi_data, region_masks, reference_peaks)
results[:regional_ppm_errors] = regional_reports
# Calculate regional variations
if !isempty(regional_reports)
mean_errors = [report.mean_ppm_error for report in values(regional_reports) if isfinite(report.mean_ppm_error)]
if !isempty(mean_errors)
results[:max_regional_ppm_difference] = maximum(mean_errors) - minimum(mean_errors)
end
end
for (region, report) in regional_reports
println(" - $region: $(round(report.mean_ppm_error, digits=2)) ppm (n=$(report.total_spectra_analyzed))")
end
return results
end
"""
analyze_peak_characteristics(msi_data, instrument_analysis, mass_accuracy_results; spectrum_indices)::Dict
Analyzes peak shape, width, and quality from a sample of spectra. The behavior
adapts based on whether the data is in `:profile` or `:centroid` mode.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `instrument_analysis::Dict`: The output from `analyze_instrument_characteristics`.
- `mass_accuracy_results`: The output from `analyze_mass_accuracy_global`.
- `spectrum_indices::AbstractVector{Int}`: The indices of spectra to sample.
# Returns
- `Dict{Symbol, Any}`: A dictionary of peak metrics:
- `:mean_fwhm_ppm`, `:median_fwhm_ppm`: Statistics of Full Width at Half Maximum (FWHM).
For centroid data, this is estimated from mass accuracy.
- `:mean_gaussian_r2`: The average goodness-of-fit to a Gaussian shape (profile data only).
- `:peak_resolution_estimate`: An estimate of instrument resolution based on FWHM.
- `:mean_peaks_per_spectrum`: The average number of peaks detected per spectrum.
"""
function analyze_peak_characteristics(msi_data::MSIData, instrument_analysis::Dict, mass_accuracy_results;
spectrum_indices::AbstractVector{Int})::Dict
results = Dict{Symbol, Any}()
# Determine acquisition mode from the analysis results, not from metadata
acquisition_mode = get(instrument_analysis, :acquisition_mode, :unknown)
# Calculate estimated_mean_ppm_error for fallback logic
estimated_mean_ppm_error = 30.0 # Default if mass_accuracy_results is nothing or invalid
if mass_accuracy_results !== nothing && haskey(mass_accuracy_results, :global_accuracy) && isa(mass_accuracy_results[:global_accuracy], NamedTuple)
ppm_error_report = mass_accuracy_results[:global_accuracy]
mean_ppm_error_val = get(ppm_error_report, :mean_ppm_error, 30.0)
if isfinite(mean_ppm_error_val)
estimated_mean_ppm_error = mean_ppm_error_val
end
end
if isempty(spectrum_indices)
@warn "No indices to sample for peak characteristics analysis."
# Provide sensible defaults if no analysis can be run
if acquisition_mode == :centroid
# Much more permissive defaults for centroid data
results[:mean_fwhm_ppm] = 50.0
results[:median_fwhm_ppm] = 50.0
results[:mean_gaussian_r2] = 0.0 # Disable shape filtering for centroids
results[:peak_resolution_estimate] = 20000.0
results[:mean_peaks_per_spectrum] = 1000
else
estimated_fwhm = estimated_mean_ppm_error * (acquisition_mode == :profile ? 3 : 2)
results[:mean_fwhm_ppm] = estimated_fwhm
results[:median_fwhm_ppm] = estimated_fwhm
results[:mean_gaussian_r2] = acquisition_mode == :profile ? 0.7 : 0.9
results[:peak_resolution_estimate] = 1e6 / estimated_fwhm
results[:mean_peaks_per_spectrum] = 0
end
return results
end
if acquisition_mode == PROFILE
println(" Analyzing peak characteristics for PROFILE mode from $(length(spectrum_indices)) sample spectra...")
peak_widths_ppm = Float64[]
r_squared_values = Float64[]
peak_counts = Int[]
fwhm_values = Float64[]
spectra_analyzed = 0
peaks_analyzed = 0
_iterate_spectra_fast(msi_data, spectrum_indices) do idx, mz, intensity
if length(mz) < 10 # Skip spectra with too few points
return
end
spectra_analyzed += 1
meta = msi_data.spectra_metadata[idx]
# Detect peaks with lower SNR threshold to find more peaks
peaks = detect_peaks_profile_core(mz, intensity; snr_threshold=2.0)
push!(peak_counts, length(peaks))
if !isempty(peaks)
# Analyze the strongest 3 peaks per spectrum
sorted_peaks = sort(peaks, by=p->p.intensity, rev=true)
for peak in sorted_peaks[1:min(3, length(sorted_peaks))]
try
# Find the index of the peak in the original mz array
peak_idx = argmin(abs.(mz .- peak.mz))
fwhm_delta_m = calculate_robust_fwhm(mz, intensity, peak_idx)
if !isnan(fwhm_delta_m) && fwhm_delta_m > 0.001 && fwhm_delta_m < 0.5 # Reasonable range in Da
fwhm_ppm = 1e6 * fwhm_delta_m / peak.mz
if 5.0 < fwhm_ppm < 500.0 # Reasonable ppm range
push!(peak_widths_ppm, fwhm_ppm)
push!(fwhm_values, fwhm_delta_m)
r2 = _fit_gaussian_and_r2(mz, intensity, peak_idx, 5)
push!(r_squared_values, r2)
peaks_analyzed += 1
if peaks_analyzed <= 3
println("DEBUG: Peak at m/z $(peak.mz), FWHM = $(fwhm_ppm) ppm, R² = $r2")
end
end
end
catch e
continue
end
end
end
end
println("DEBUG: Analyzed $peaks_analyzed peaks from $spectra_analyzed spectra")
if !isempty(peak_widths_ppm)
results[:mean_fwhm_ppm] = mean(peak_widths_ppm)
results[:median_fwhm_ppm] = median(peak_widths_ppm)
results[:mean_gaussian_r2] = mean(r_squared_values)
results[:peak_resolution_estimate] = 1e6 / results[:mean_fwhm_ppm]
println(" - Actual FWHM measurement: $(round(results[:mean_fwhm_ppm], digits=2)) ppm")
else
# Fallback if no valid peaks found in PROFILE mode
estimated_fwhm = estimated_mean_ppm_error * 3 # FWHM typically wider than mass error
results[:mean_fwhm_ppm] = estimated_fwhm
results[:median_fwhm_ppm] = estimated_fwhm
results[:mean_gaussian_r2] = 0.7
results[:peak_resolution_estimate] = 1e6 / estimated_fwhm
println(" - Estimated FWHM (from mass accuracy): $estimated_fwhm ppm")
end
if !isempty(peak_counts)
results[:mean_peaks_per_spectrum] = mean(peak_counts)
else
results[:mean_peaks_per_spectrum] = 0
end
else # CENTROID mode
println(" Analyzing peak characteristics for CENTROID mode from $(length(spectrum_indices)) sample spectra...")
peak_counts = Int[]
_iterate_spectra_fast(msi_data, spectrum_indices) do idx, mz, intensity
if !isempty(mz)
push!(peak_counts, length(mz))
end
end
if !isempty(peak_counts)
results[:mean_peaks_per_spectrum] = mean(peak_counts)
results[:median_peaks_per_spectrum] = median(peak_counts)
results[:total_peaks_detected] = sum(peak_counts)
else
results[:mean_peaks_per_spectrum] = 0
results[:median_peaks_per_spectrum] = 0
results[:total_peaks_detected] = 0
end
# For centroid data, use much more permissive parameters
# Don't estimate FWHM from mass accuracy - use reasonable defaults
results[:mean_fwhm_ppm] = 50.0 # Reasonable default for centroid data
results[:median_fwhm_ppm] = 50.0
results[:mean_gaussian_r2] = 0.0 # Disable shape filtering for centroids
results[:peak_resolution_estimate] = 20000.0 # Reasonable estimate
println(" - Mean peaks per spectrum: $(round(results[:mean_peaks_per_spectrum], digits=1))")
println(" - Using permissive FWHM for centroid data: 50.0 ppm")
println(" - Shape filtering disabled for centroid data")
end
# Common prints
println(" - Mean Gaussian R²: $(round(get(results, :mean_gaussian_r2, 0.0), digits=3))")
println(" - Mean peaks per spectrum: $(round(get(results, :mean_peaks_per_spectrum, 0.0), digits=1))")
return results
end
"""
generate_preprocessing_recommendations(analysis_results::Dict)::Dict{Symbol, Any}
Generates intelligent preprocessing recommendations by synthesizing the results from
various analysis functions (`analyze_instrument_characteristics`, `analyze_signal_quality`, etc.).
# Arguments
- `analysis_results::Dict`: A dictionary containing the comprehensive analysis results from
the `run_preprocessing_analysis` pipeline.
# Returns
- `Dict{Symbol, Any}`: A dictionary where keys are preprocessing step names (e.g., `:smoothing`,
`:peak_picking`) and values are dictionaries of recommended parameters for that step.
"""
function generate_preprocessing_recommendations(analysis_results::Dict)::Dict{Symbol, Any}
recommendations = Dict{Symbol, Any}()
inst_analysis = get(analysis_results, :instrument_analysis, Dict())
signal_analysis = get(analysis_results, :signal_analysis, Dict())
mass_accuracy = get(analysis_results, :mass_accuracy, Dict())
peak_analysis = get(analysis_results, :peak_analysis, Dict())
# Stabilization Recommendations
recommendations[:stabilization] = generate_stabilization_recommendations(signal_analysis)
# Baseline Correction Recommendations
recommendations[:baseline_correction] = generate_baseline_recommendations(inst_analysis, signal_analysis)
# Smoothing Recommendations
recommendations[:smoothing] = generate_smoothing_recommendations(inst_analysis, peak_analysis)
# Peak Picking Recommendations - pass instrument analysis
recommendations[:peak_picking] = generate_peak_picking_recommendations(signal_analysis, peak_analysis, inst_analysis)
# Normalization Recommendations
recommendations[:normalization] = generate_normalization_recommendations(signal_analysis)
# Alignment Recommendations
recommendations[:alignment] = generate_alignment_recommendations(mass_accuracy, inst_analysis)
# Binning Recommendations
recommendations[:binning] = generate_binning_recommendations(peak_analysis, mass_accuracy)
return recommendations
end
"""Generate baseline correction recommendations based on data properties."""
function generate_baseline_recommendations(inst_analysis, signal_analysis)
recommendations = Dict{Symbol, Any}()
# Determine baseline correction method based on data characteristics
acquisition_mode = get(inst_analysis, :acquisition_mode, :unknown)
noise_level = get(signal_analysis, :noise_mean, 1.0)
if acquisition_mode == :profile
recommendations[:method] = "SNIP"
recommendations[:window_size] = 200 # Default, can be optimized
recommendations[:iterations] = 100
else
recommendations[:method] = "linear"
recommendations[:noise_threshold] = noise_level * 3
end
return recommendations
end
"""Generate smoothing recommendations based on peak width and m/z step."""
function generate_smoothing_recommendations(inst_analysis, peak_analysis)
recommendations = Dict{Symbol, Any}()
mz_step = get(inst_analysis, :average_mz_step, 0.01)
fwhm_ppm = get(peak_analysis, :mean_fwhm_ppm, 20.0)
# Convert FWHM from ppm to m/z units for typical m/z
typical_mz = 500.0
fwhm_mz = typical_mz * fwhm_ppm / 1e6
# Savitzky-Golay window should be ~FWHM in points
window_points = max(5, min(21, round(Int, fwhm_mz / mz_step)))
# Ensure odd number
window_points = isodd(window_points) ? window_points : window_points + 1
recommendations[:method] = "Savitzky-Golay"
recommendations[:window_size] = window_points
recommendations[:polynomial_order] = 3
return recommendations
end
"""Generate peak picking recommendations from signal and peak analyses."""
function generate_peak_picking_recommendations(signal_analysis, peak_analysis, inst_analysis)
recommendations = Dict{Symbol, Any}()
acquisition_mode = get(inst_analysis, :acquisition_mode, :profile)
if acquisition_mode == :centroid
# Much more permissive parameters for centroid data
recommendations[:snr_threshold] = 2.0 # Lower threshold for centroid
recommendations[:min_peak_width_ppm] = 0.0 # No minimum width for centroids
recommendations[:max_peak_width_ppm] = 200.0 # Very wide maximum for centroids
recommendations[:reason] = "Centroid data: using permissive parameters"
else
# Existing profile mode logic
snr_threshold = get(signal_analysis, :suggested_snr, 3.0)
fwhm_ppm = get(peak_analysis, :mean_fwhm_ppm, 20.0)
recommendations[:snr_threshold] = snr_threshold
recommendations[:min_peak_width_ppm] = fwhm_ppm * 0.5
recommendations[:max_peak_width_ppm] = fwhm_ppm * 3.0
recommendations[:reason] = "Profile data: using standard parameters"
end
return recommendations
end
"""Generate normalization recommendations based on TIC variation."""
function generate_normalization_recommendations(signal_analysis)
recommendations = Dict{Symbol, Any}()
tic_cv = get(signal_analysis, :tic_cv, 0.5)
if tic_cv < 0.3 # Low TIC variation
recommendations[:method] = "TIC"
recommendations[:reason] = "Low TIC variation across spectra"
else # High TIC variation
recommendations[:method] = "RMS"
recommendations[:reason] = "High TIC variation, using robust normalization"
end
return recommendations
end
"""Generate alignment recommendations based on calibration status and mass error."""
function generate_alignment_recommendations(mass_accuracy, inst_analysis)
recommendations = Dict{Symbol, Any}()
calibration_status = get(inst_analysis, :calibration_status, :uncalibrated)
mean_ppm_error_val = 10.0 # Default value
if mass_accuracy !== nothing && haskey(mass_accuracy, :global_accuracy) && isa(mass_accuracy[:global_accuracy], NamedTuple)
ppm_error_report = mass_accuracy[:global_accuracy]
mean_ppm_error_val = get(ppm_error_report, :mean_ppm_error, 10.0)
end
if calibration_status == :uncalibrated || mean_ppm_error_val > 20.0
recommendations[:method] = "reference_based"
recommendations[:max_ppm_shift] = 50.0
recommendations[:required] = true
else
recommendations[:method] = "none"
recommendations[:required] = false
end
return recommendations
end
"""Generate binning recommendations based on peak width and mass accuracy."""
function generate_binning_recommendations(peak_analysis, mass_accuracy)
recommendations = Dict{Symbol, Any}()
fwhm_ppm = get(peak_analysis, :mean_fwhm_ppm, 20.0)
# Handle the case when mass_accuracy is nothing or has NaN values
suggested_tolerance = 20.0 # Default value
if mass_accuracy !== nothing
tol = get(mass_accuracy, :suggested_bin_tolerance, 20.0)
if !isnan(tol) && isfinite(tol)
suggested_tolerance = tol
end
end
# Ensure fwhm_ppm is valid
if isnan(fwhm_ppm) || !isfinite(fwhm_ppm)
fwhm_ppm = 20.0
end
# Bin width should be ~FWHM/2 to preserve resolution while reducing data size
bin_width_ppm = max(fwhm_ppm * 0.5, suggested_tolerance)
recommendations[:method] = "adaptive"
recommendations[:bin_width_ppm] = bin_width_ppm
recommendations[:min_peaks_per_bin] = 3
return recommendations
end
"""Generate intensity stabilization recommendations."""
function generate_stabilization_recommendations(signal_analysis)
recommendations = Dict{Symbol, Any}()
# Default to sqrt, as it's a common and generally robust transformation.
# More advanced logic could analyze intensity distribution skewness if needed.
recommendations[:method] = :sqrt
return recommendations
end
# =============================================================================
# Pre-Analysis Pipeline for Auto Parameter Determination
# =============================================================================
"""
run_preprocessing_analysis(msi_data; ...)
Runs a comprehensive pre-analysis pipeline to determine optimal preprocessing parameters.
This function orchestrates a series of analysis steps on a sample of the dataset to
provide intelligent defaults for a full preprocessing workflow.
The pipeline consists of several phases:
1. **Instrument & Data Characteristics**: Infers acquisition mode, m/z axis type, etc.
2. **Noise & Signal Quality**: Estimates noise, SNR, and TIC variation.
3. **Mass Accuracy**: Calculates PPM error against reference peaks (if provided).
4. **Spatial Regions**: Analyzes regional variations (if masks are provided).
5. **Peak Characteristics**: Measures peak width, shape, and density.
6. **Recommendations**: Synthesizes all analysis results into actionable parameter suggestions.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `reference_peaks::Dict`: Optional. Known m/z values for mass accuracy analysis.
- `region_masks::Dict`: Optional. Named `BitMatrix` masks for regional analysis.
- `sample_size::Int`: The number of spectra to sample for the analysis.
- `mask_path::String`: Optional path to a PNG mask to restrict analysis to a specific ROI.
- `spectrum_indices::AbstractVector{Int}`: Optional vector of indices to restrict analysis to,
overriding `mask_path` and `sample_size` for selection.
# Returns
- `Dict{Symbol, Any}`: A nested dictionary containing the results of each analysis phase
and a final `:recommendations` dictionary. The recommendations are also stored in
`msi_data.preprocessing_hints`.
"""
function run_preprocessing_analysis(msi_data::MSIData;
reference_peaks::Dict{Float64, String}=Dict{Float64, String}(),
region_masks::Dict{Symbol, BitMatrix}=Dict{Symbol, BitMatrix}(),
sample_size::Int=100,
mask_path::Union{String, Nothing}=nothing,
spectrum_indices::Union{AbstractVector{Int}, Nothing}=nothing)::Dict{Symbol, Any}
println("="^60)
println("RUNNING PRE-ANALYSIS PIPELINE")
println("="^60)
analysis_results = Dict{Symbol, Any}()
local all_available_indices::AbstractVector{Int}
if spectrum_indices !== nothing
println("Using provided list of $(length(spectrum_indices)) spectrum indices.")
all_available_indices = spectrum_indices
elseif mask_path !== nothing
println("Applying mask from: $(mask_path)")
try
mask_matrix = load_and_prepare_mask(mask_path, msi_data.image_dims)
masked_indices_set = get_masked_spectrum_indices(msi_data, mask_matrix)
all_available_indices = collect(masked_indices_set)
println("Mask applied. $(length(all_available_indices)) spectra are within the masked region.")
catch e
@error "Failed to load or apply mask: $e. Proceeding without mask."
all_available_indices = 1:length(msi_data.spectra_metadata)
end
else
all_available_indices = 1:length(msi_data.spectra_metadata)
end
if isempty(all_available_indices)
@warn "No spectra available for analysis (after applying mask/filter). Returning empty results."
return analysis_results
end
# Create a single sample set from the available indices
num_available = length(all_available_indices)
indices_to_sample = if num_available > sample_size
# Use StatsBase.sample for sampling without replacement
sample(all_available_indices, sample_size, replace=false)
else
# Use all available indices if they are fewer than the sample size
collect(all_available_indices)
end
# Ensure analytics are precomputed
if !is_set(msi_data.analytics_ready)
println("Pre-computing basic analytics...")
precompute_analytics(msi_data)
end
# Phase 1: Instrument and Data Characteristics
println("\n--- Phase 1: Instrument & Data Characteristics ---")
instrument_analysis = analyze_instrument_characteristics(msi_data, sample_indices=indices_to_sample)
analysis_results[:instrument_analysis] = instrument_analysis
# Phase 2: Noise and Signal Quality Analysis
println("\n--- Phase 2: Noise & Signal Quality Analysis ---")
signal_analysis = analyze_signal_quality(msi_data, sample_indices=indices_to_sample)
analysis_results[:signal_analysis] = signal_analysis
# Phase 3: Mass Accuracy Analysis
println("\n--- Phase 3: Mass Accuracy Analysis ---")
if !isempty(reference_peaks)
mass_accuracy_analysis = analyze_mass_accuracy_global(msi_data, reference_peaks, spectrum_indices=indices_to_sample)
analysis_results[:mass_accuracy] = mass_accuracy_analysis
else
println("No reference peaks provided - skipping mass accuracy analysis")
analysis_results[:mass_accuracy] = nothing
end
# Phase 4: Spatial Region Analysis (if masks provided)
# This function is not affected by the global mask, as it analyzes specific, named regions.
println("\n--- Phase 4: Spatial Region Analysis ---")
if !isempty(region_masks)
regional_analysis = analyze_spatial_regions(msi_data, region_masks, reference_peaks)
analysis_results[:regional_analysis] = regional_analysis
else
println("No region masks provided - skipping regional analysis")
analysis_results[:regional_analysis] = nothing
end
# Phase 5: Peak Characteristics Analysis
println("\n--- Phase 5: Peak Characteristics Analysis ---")
peak_analysis = analyze_peak_characteristics(msi_data, instrument_analysis, analysis_results[:mass_accuracy], spectrum_indices=indices_to_sample)
analysis_results[:peak_analysis] = peak_analysis
# Phase 6: Generate Preprocessing Recommendations
println("\n--- Phase 6: Generating Preprocessing Recommendations ---")
recommendations = generate_preprocessing_recommendations(analysis_results)
analysis_results[:recommendations] = recommendations
# Store in MSIData object
msi_data.preprocessing_hints = recommendations
println("\n" * "="^60)
println("PRE-ANALYSIS COMPLETE")
println("="^60)
return analysis_results
end
"""
calculate_ppm_error(measured_mz::Real, theoretical_mz::Real) -> Float64
Calculates the mass accuracy error in parts-per-million (PPM) between a measured
and a theoretical m/z value.
# Arguments
- `measured_mz::Real`: The experimentally measured m/z value.
- `theoretical_mz::Real`: The known, theoretical m/z value of a compound.
# Returns
- `Float64`: The calculated PPM error. Returns `Inf` if `theoretical_mz` is zero.
# Formula
`PPM = 10^6 * |measured_mz - theoretical_mz| / theoretical_mz`
# Example
```julia
calculate_ppm_error(100.005, 100.0) # returns 50.0
```
"""
function calculate_ppm_error(measured_mz::Real, theoretical_mz::Real)
if theoretical_mz == 0
return Inf
end
return 1e6 * abs(Float64(measured_mz) - Float64(theoretical_mz)) / Float64(theoretical_mz)
end
"""
calculate_ppm_error_bulk(measured_mz::Vector{<:Real}, theoretical_mz::Vector{<:Real}) -> Vector{Float64}
Calculates PPM errors for multiple pairs of measured and theoretical mass values.
# Arguments
- `measured_mz::Vector{<:Real}`: A vector of experimentally measured m/z values.
- `theoretical_mz::Vector{<:Real}`: A vector of known, theoretical m/z values.
# Returns
- `Vector{Float64}`: A vector containing the calculated PPM error for each pair.
"""
function calculate_ppm_error_bulk(measured_mz::Vector{Real}, theoretical_mz::Vector{Real})
return [calculate_ppm_error(m, t) for (m, t) in zip(measured_mz, theoretical_mz)]
end
"""
calculate_resolution_fwhm(mz::Real, profile_mz::AbstractVector{<:Real}, profile_intensity::AbstractVector{<:Real}) -> Float64
Calculates the mass resolution of a peak in profile-mode data using the Full Width at
Half Maximum (FWHM) method. Resolution is a measure of an instrument's ability to
distinguish between two peaks of slightly different mass-to-charge ratios.
# Arguments
- `mz::Real`: The m/z value of the peak's centroid.
- `profile_mz::AbstractVector{<:Real}`: The full m/z array from the profile-mode spectrum.
- `profile_intensity::AbstractVector{<:Real}`: The full intensity array from the profile-mode spectrum.
# Returns
- `Float64`: The calculated resolution (`m / Δm`). Returns `NaN` if the FWHM cannot be determined (e.g., peak is at the edge of the spectrum).
# Formula
`Resolution = m / Δm`, where `Δm` is the FWHM.
"""
function calculate_resolution_fwhm(mz::Real, profile_mz::AbstractVector{<:Real},
profile_intensity::AbstractVector{<:Real})
# Find peak center index
peak_idx = argmin(abs.(profile_mz .- mz))
peak_height = Float64(profile_intensity[peak_idx])
half_max = peak_height / 2
# Find left half-maximum point (interpolate for accuracy)
left_idx = find_last_below(profile_intensity[1:peak_idx], half_max)
if left_idx == 0 || left_idx == length(profile_intensity[1:peak_idx])
return NaN
end
# Linear interpolation for left FWHM
x1, x2 = Float64(profile_mz[left_idx]), Float64(profile_mz[left_idx+1])
y1, y2 = Float64(profile_intensity[left_idx]), Float64(profile_intensity[left_idx+1])
denominator = y2 - y1
if denominator == 0
left_fwhm = NaN
else
left_fwhm = x1 + (x2 - x1) * (half_max - y1) / denominator
end
# Find right half-maximum point
right_slice = profile_intensity[peak_idx:end]
right_offset = find_first_below(right_slice, half_max)
if right_offset == 0 || right_offset == length(right_slice)
return NaN
end
right_idx = peak_idx + right_offset - 1
x1, x2 = Float64(profile_mz[right_idx-1]), Float64(profile_mz[right_idx])
y1, y2 = Float64(profile_intensity[right_idx-1]), Float64(profile_intensity[right_idx])
denominator = y2 - y1
if denominator == 0
right_fwhm = NaN
else
right_fwhm = x1 + (x2 - x1) * (half_max - y1) / denominator
end
fwhm = right_fwhm - left_fwhm
return fwhm > 0 ? Float64(mz) / fwhm : NaN
end
"""Helper to find the last index in a vector with a value below a threshold, used for FWHM."""
function find_last_below(v::AbstractVector{<:Real}, threshold::Real)
for i in length(v):-1:2
if v[i] >= threshold && v[i-1] < threshold
return i-1
end
end
return 0
end
"""Helper to find the first index in a vector with a value below a threshold, used for FWHM."""
function find_first_below(v::AbstractVector{<:Real}, threshold::Real)
for i in 1:(length(v)-1)
if v[i] >= threshold && v[i+1] < threshold
return i+1
end
end
return 0
end
"""
_calculate_fwhm_delta_m(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int) -> Float64
Calculates the Full Width at Half Maximum (FWHM) in m/z units (Δm) for a peak.
Returns `NaN` if FWHM cannot be determined.
"""
function _calculate_fwhm_delta_m(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int)
peak_height = Float64(intensity[peak_idx])
half_max = peak_height / 2
# Find left half-maximum point
left_idx = find_last_below(intensity[1:peak_idx], half_max)
if left_idx == 0 || left_idx == length(intensity[1:peak_idx])
return NaN
end
# Linear interpolation for left FWHM m/z
x1, x2 = Float64(mz[left_idx]), Float64(mz[left_idx+1])
y1, y2 = Float64(intensity[left_idx]), Float64(intensity[left_idx+1])
denominator = y2 - y1
if denominator == 0
left_fwhm_mz = NaN
else
left_fwhm_mz = x1 + (x2 - x1) * (half_max - y1) / denominator
end
# Find right half-maximum point
right_slice = intensity[peak_idx:end]
right_offset = find_first_below(right_slice, half_max)
if right_offset == 0 || right_offset == length(right_slice)
return NaN
end
right_idx = peak_idx + right_offset - 1
x1, x2 = Float64(mz[right_idx-1]), Float64(mz[right_idx])
y1, y2 = Float64(intensity[right_idx-1]), Float64(intensity[right_idx])
denominator = y2 - y1
if denominator == 0
right_fwhm_mz = NaN
else
right_fwhm_mz = x1 + (x2 - x1) * (half_max - y1) / denominator
end
return right_fwhm_mz - left_fwhm_mz
end
"""
_fit_gaussian_and_r2(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int, half_window::Int) -> Float64
Estimates Gaussian parameters for a peak and returns a pseudo R^2 value.
This is an approximation and not a full non-linear least squares fit.
"""
function _fit_gaussian_and_r2(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int, half_window::Int)
n = length(mz)
if n < 3 || peak_idx <= 0 || peak_idx > n
return 0.0
end
# Define the region around the peak
start_idx = max(1, peak_idx - half_window)
end_idx = min(n, peak_idx + half_window)
# Ensure there's enough data to fit
if (end_idx - start_idx + 1) < 3
return 0.0
end
x_data = mz[start_idx:end_idx]
y_data = intensity[start_idx:end_idx]
# Estimate Gaussian parameters
# Amplitude (A): peak intensity
A_est = intensity[peak_idx]
# Mean (μ): m/z at peak intensity
mu_est = mz[peak_idx]
# Standard deviation (σ): related to FWHM. FWHM = 2 * sqrt(2 * ln(2)) * σ ≈ 2.355 * σ
# So, σ ≈ FWHM / 2.355
fwhm_delta_m = _calculate_fwhm_delta_m(mz, intensity, peak_idx)
if isnan(fwhm_delta_m) || fwhm_delta_m <= 0
return 0.0 # Cannot estimate sigma without a valid FWHM
end
sigma_est = fwhm_delta_m / 2.355
# If sigma is too small, it might lead to division by zero or very sharp peaks
if sigma_est < eps(Float64)
return 0.0
end
# Gaussian function
gaussian(x, A, mu, sigma) = A * exp.(-(x .- mu).^2 ./ (2 * sigma^2))
# Generate estimated Gaussian curve
y_est = gaussian(x_data, A_est, mu_est, sigma_est)
# Calculate pseudo R-squared
# R^2 = 1 - (SS_res / SS_tot)
# SS_res = sum((y_data - y_est).^2)
# SS_tot = sum((y_data - mean(y_data)).^2)
SS_res = sum((y_data .- y_est).^2)
SS_tot = sum((y_data .- mean(y_data)).^2)
if SS_tot == 0
return 1.0 # Perfect fit if all y_data are the same
end
r_squared = 1.0 - (SS_res / SS_tot)
return max(0.0, r_squared) # R^2 can be negative if fit is worse than mean, cap at 0
end
"""
calculate_robust_fwhm(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int) -> Float64
A more robust FWHM calculation that handles edge cases better.
"""
function calculate_robust_fwhm(mz::AbstractVector{<:Real}, intensity::AbstractVector{<:Real}, peak_idx::Int)
n = length(mz)
if n < 5 || peak_idx < 3 || peak_idx > n-2
return NaN
end
peak_height = Float64(intensity[peak_idx])
half_max = peak_height / 2.0
# Find left half-maximum with bounds checking
left_idx = peak_idx
while left_idx > 1 && intensity[left_idx] >= half_max
left_idx -= 1
end
if left_idx == 1 || left_idx >= n-1
return NaN
end
# Linear interpolation for left FWHM
x1, x2 = Float64(mz[left_idx]), Float64(mz[left_idx+1])
y1, y2 = Float64(intensity[left_idx]), Float64(intensity[left_idx+1])
if y2 == y1 # Avoid division by zero
left_fwhm_mz = x1
else
left_fwhm_mz = x1 + (x2 - x1) * (half_max - y1) / (y2 - y1)
end
# Find right half-maximum
right_idx = peak_idx
while right_idx < n && intensity[right_idx] >= half_max
right_idx += 1
end
if right_idx == n || right_idx <= 2
return NaN
end
# Linear interpolation for right FWHM
x1, x2 = Float64(mz[right_idx-1]), Float64(mz[right_idx])
y1, y2 = Float64(intensity[right_idx-1]), Float64(intensity[right_idx])
if y2 == y1 # Avoid division by zero
right_fwhm_mz = x1
else
right_fwhm_mz = x1 + (x2 - x1) * (half_max - y1) / (y2 - y1)
end
fwhm = right_fwhm_mz - left_fwhm_mz
# Validate result
if fwhm <= 0 || !isfinite(fwhm) || fwhm > 1.0 # Unreasonably large
return NaN
end
return fwhm
end
"""
main_precalculation(msi_data::MSIData; ...)
Runs the non-verbose pre-analysis pipeline and returns a dictionary of recommended
preprocessing parameters based on data characteristics and heuristics.
This function serves as a quiet entry point to the analysis engine, translating the
analytical results into a concrete set of parameters for a preprocessing pipeline.
It also categorizes parameters that cannot be automatically determined.
# Arguments
- `msi_data::MSIData`: The main MSI data object.
- `reference_peaks::Dict`: Optional dictionary of reference m/z values for mass accuracy analysis.
- `region_masks::Dict`: Optional dictionary of named `BitMatrix` masks for regional analysis.
- `sample_size::Int`: The number of spectra to sample for statistical analysis.
- `mask_path::String`: Optional path to a PNG mask file to restrict the analysis to a specific ROI.
- `spectrum_indices::AbstractVector{Int}`: Optional vector of spectrum indices to restrict analysis to.
# Returns
- A `Dict` with two keys:
- `"recommended_parameters"`: A `Dict{Symbol, Any}` of suggested parameter values.
- `"unsupported_parameters"`: A `Dict` categorizing parameters that could not be determined.
"""
function main_precalculation(msi_data::MSIData;
reference_peaks::Dict{Float64, String}=Dict{Float64, String}(),
region_masks::Dict{Symbol, BitMatrix}=Dict{Symbol, BitMatrix}(),
sample_size::Int=100,
mask_path::Union{String, Nothing}=nothing,
spectrum_indices::Union{AbstractVector{Int}, Nothing}=nothing)::Dict
# --- Run analysis pipeline quietly ---
local analysis_results
original_stdout = stdout
# Redirect stdout to the system's null device to robustly silence output
null_stream = open(Sys.iswindows() ? "nul" : "/dev/null", "w")
redirect_stdout(null_stream)
try
analysis_results = run_preprocessing_analysis(msi_data,
reference_peaks=reference_peaks,
region_masks=region_masks,
sample_size=sample_size,
mask_path=mask_path,
spectrum_indices=spectrum_indices
)
finally
redirect_stdout(original_stdout)
close(null_stream)
end
if isempty(analysis_results)
@warn "Preprocessing analysis returned no results. Cannot generate recommendations."
return Dict() # Return an empty dictionary if no results
end
# --- Safely get nested dictionaries ---
recs = get(analysis_results, :recommendations, Dict())
signal_analysis = get(analysis_results, :signal_analysis, Dict())
peak_analysis = get(analysis_results, :peak_analysis, Dict())
mass_accuracy = get(analysis_results, :mass_accuracy, nothing) # Can be nothing
inst_analysis = get(analysis_results, :instrument_analysis, Dict())
# Initialize parameter dictionaries for each step
stab_params = Dict{Symbol, Any}()
cal_params = Dict{Symbol, Any}()
sm_params = Dict{Symbol, Any}()
bc_params = Dict{Symbol, Any}()
norm_params = Dict{Symbol, Any}()
pp_params = Dict{Symbol, Any}()
pa_params = Dict{Symbol, Any}()
ps_params = Dict{Symbol, Any}()
pb_params = Dict{Symbol, Any}()
# --- Populate Parameters for each step ---
# Stabilization
if !isempty(recs) && haskey(recs, :stabilization)
stab_rec = recs[:stabilization]
stab_params[:method] = get(stab_rec, :method, :sqrt)
else
stab_params[:method] = :sqrt # Default
end
# Calibration & Alignment (Note: These are intertwined in the current logic)
calibration_required = false
mean_ppm_error = NaN
suggested_bin_tol = NaN
if mass_accuracy !== nothing
mean_ppm_error = get(get(mass_accuracy, :global_accuracy, Dict()), :mean_ppm_error, NaN)
suggested_bin_tol = get(mass_accuracy, :suggested_bin_tolerance, NaN)
end
if !isempty(recs) && haskey(recs, :alignment) && get(recs[:alignment], :required, false)
calibration_required = true
cal_params[:method] = :internal_standards
cal_params[:fit_order] = 2 # Default from struct
if isfinite(suggested_bin_tol)
cal_params[:ppm_tolerance] = suggested_bin_tol
else
cal_params[:ppm_tolerance] = nothing
end
cal_params[:internal_standards] = nothing # User input dependent
cal_params[:base_peak_mz_references] = nothing # User input dependent
if isfinite(mean_ppm_error)
pa_params[:method] = mean_ppm_error > 30.0 ? :lowess : :linear
else
pa_params[:method] = :lowess # Default
end
tic_cv = get(signal_analysis, :tic_cv, NaN)
if isfinite(tic_cv) && get(pa_params, :method, :none) == :lowess
pa_params[:span] = round(max(0.3, min(0.8, 1.0 - tic_cv / 2)), digits=2)
else
pa_params[:span] = nothing
end
if isfinite(suggested_bin_tol)
pa_params[:tolerance] = suggested_bin_tol
pa_params[:tolerance_unit] = :ppm
else
pa_params[:tolerance] = nothing
pa_params[:tolerance_unit] = :ppm # Default
end
pa_params[:max_shift_ppm] = get(recs[:alignment], :max_ppm_shift, 50.0)
pa_params[:min_matched_peaks] = nothing # User input dependent
else
cal_params[:method] = :none
cal_params[:ppm_tolerance] = nothing
cal_params[:fit_order] = nothing
cal_params[:internal_standards] = nothing
cal_params[:base_peak_mz_references] = nothing
pa_params[:method] = :none
pa_params[:span] = nothing
pa_params[:tolerance] = nothing
pa_params[:tolerance_unit] = :ppm
pa_params[:max_shift_ppm] = nothing
pa_params[:min_matched_peaks] = nothing
end
# Smoothing
if !isempty(recs) && haskey(recs, :smoothing)
sm_rec = recs[:smoothing]
if get(sm_rec, :method, "") == "Savitzky-Golay"
sm_params[:method] = :savitzky_golay
sm_params[:window] = get(sm_rec, :window_size, nothing)
sm_params[:order] = get(sm_rec, :polynomial_order, nothing)
else
sm_params[:method] = :none
sm_params[:window] = nothing
sm_params[:order] = nothing
end
else
sm_params[:method] = :none
sm_params[:window] = nothing
sm_params[:order] = nothing
end
# Baseline Correction
if !isempty(recs) && haskey(recs, :baseline_correction)
bl_rec = recs[:baseline_correction]
if get(bl_rec, :method, "") == "SNIP"
bc_params[:method] = :snip
bc_params[:iterations] = get(bl_rec, :iterations, nothing)
else
bc_params[:method] = :none
bc_params[:iterations] = nothing
end
else
bc_params[:method] = :none
bc_params[:iterations] = nothing
end
if get(inst_analysis, :acquisition_mode, :unknown) == :profile
mean_fwhm_ppm = get(peak_analysis, :mean_fwhm_ppm, NaN)
avg_mz_step = get(inst_analysis, :average_mz_step, NaN)
if isfinite(mean_fwhm_ppm) && isfinite(avg_mz_step) && avg_mz_step > 0
fwhm_mz = 500.0 * mean_fwhm_ppm / 1e6 # At typical m/z 500
bc_params[:window] = ceil(Int, fwhm_mz / avg_mz_step * 2)
else
bc_params[:window] = nothing
end
else
bc_params[:window] = nothing
end
# Normalization
if !isempty(recs) && haskey(recs, :normalization)
method = get(recs[:normalization], :method, "")
if method == "TIC"
norm_params[:method] = :tic
elseif method == "RMS"
norm_params[:method] = :rms
else
norm_params[:method] = :none
end
else
norm_params[:method] = :none
end
# Peak Picking
# Robust method selection based on acquisition mode
acquisition_mode = get(inst_analysis, :acquisition_mode, :unknown)
pp_params[:method] = acquisition_mode == :profile ? :profile : :centroid
if acquisition_mode == :centroid
# Much more permissive parameters for centroid data
pp_params[:snr_threshold] = 2.0 # Lower for centroid
pp_params[:min_peak_width_ppm] = 0.0 # No minimum width
pp_params[:max_peak_width_ppm] = 200.0 # Very wide maximum
pp_params[:min_peak_shape_r2] = 0.0 # Disable shape filtering
# Much lower prominence threshold for centroid
estimated_noise = get(signal_analysis, :noise_mean, NaN)
if isfinite(estimated_noise)
pp_params[:min_peak_prominence] = max(estimated_noise * 0.5, 0.001) # Much lower
else
pp_params[:min_peak_prominence] = 0.001 # Very permissive
end
pp_params[:half_window] = 2 # Smaller window for centroid data
pp_params[:merge_peaks_tolerance] = 10.0 # More permissive merging
else # Profile mode logic
if !isempty(recs) && haskey(recs, :peak_picking)
pk_rec = recs[:peak_picking]
pp_params[:snr_threshold] = get(pk_rec, :snr_threshold, 2.0) # Default to 2.0
pp_params[:min_peak_width_ppm] = get(pk_rec, :min_peak_width_ppm, nothing)
pp_params[:max_peak_width_ppm] = get(pk_rec, :max_peak_width_ppm, nothing)
else
pp_params[:snr_threshold] = 2.0 # Default to 2.0
pp_params[:min_peak_width_ppm] = nothing
pp_params[:max_peak_width_ppm] = nothing
end
# Robust prominence calculation with safety cap for profile
estimated_noise = get(signal_analysis, :noise_mean, NaN)
if isfinite(estimated_noise)
calculated_prominence = round(estimated_noise * 2, digits=4)
pp_params[:min_peak_prominence] = min(calculated_prominence, 0.005)
else
pp_params[:min_peak_prominence] = 0.005
end
if isfinite(suggested_bin_tol)
pp_params[:merge_peaks_tolerance] = round(suggested_bin_tol / 2, digits=4)
else
pp_params[:merge_peaks_tolerance] = nothing
end
# Robust half_window calculation with safety floor for profile
mean_fwhm_ppm = get(peak_analysis, :mean_fwhm_ppm, NaN)
avg_mz_step = get(inst_analysis, :average_mz_step, NaN)
if isfinite(mean_fwhm_ppm) && isfinite(avg_mz_step) && avg_mz_step > 0
fwhm_mz = 500.0 * mean_fwhm_ppm / 1e6 # At typical m/z 500
window_points = fwhm_mz / avg_mz_step
calculated_half_window = ceil(Int, window_points / 2)
pp_params[:half_window] = max(calculated_half_window, 3)
else
pp_params[:half_window] = 5
end
# Robust R^2 calculation with floor for profile
mean_r2 = get(peak_analysis, :mean_gaussian_r2, NaN)
if isfinite(mean_r2)
pp_params[:min_peak_shape_r2] = round(max(0.0, mean_r2 * 0.8), digits=2)
else
pp_params[:min_peak_shape_r2] = 0.0
end
end
# Peak Selection
if acquisition_mode == :centroid
# Much more permissive parameters for centroid data
ps_params[:min_snr] = 1.5 # Even lower than picking threshold
ps_params[:min_fwhm_ppm] = 0.0 # No minimum
ps_params[:max_fwhm_ppm] = 500.0 # Very wide maximum
ps_params[:min_shape_r2] = 0.0 # Disable shape filtering
ps_params[:frequency_threshold] = nothing # User input dependent / Hard to determine
ps_params[:correlation_threshold] = nothing # Hard to determine
else # Profile mode logic
if haskey(pp_params, :snr_threshold) && pp_params[:snr_threshold] !== nothing
ps_params[:min_snr] = pp_params[:snr_threshold]
else
ps_params[:min_snr] = nothing
end
if !isempty(peak_analysis)
mean_fwhm = get(peak_analysis, :mean_fwhm_ppm, NaN)
if isfinite(mean_fwhm)
ps_params[:min_fwhm_ppm] = round(mean_fwhm * 0.5, digits=2)
ps_params[:max_fwhm_ppm] = round(mean_fwhm * 2.5, digits=2)
else
ps_params[:min_fwhm_ppm] = nothing
ps_params[:max_fwhm_ppm] = nothing
end
mean_r2 = get(peak_analysis, :mean_gaussian_r2, NaN)
if isfinite(mean_r2)
ps_params[:min_shape_r2] = round(max(0.0, mean_r2 * 0.8), digits=2)
else
ps_params[:min_shape_r2] = 0.0 # Adjusted fallback
end
else
ps_params[:min_fwhm_ppm] = nothing
ps_params[:max_fwhm_ppm] = nothing
ps_params[:min_shape_r2] = nothing
end
ps_params[:frequency_threshold] = nothing
ps_params[:correlation_threshold] = nothing
end
# Peak Binning
if !isempty(recs) && haskey(recs, :binning)
bin_rec = recs[:binning]
if get(bin_rec, :method, "") == "adaptive"
pb_params[:method] = :adaptive
bin_width = get(bin_rec, :bin_width_ppm, NaN)
if isfinite(bin_width)
pb_params[:tolerance] = bin_width
pb_params[:max_bin_width_ppm] = round(bin_width * 3, digits=2)
else
pb_params[:tolerance] = nothing
pb_params[:max_bin_width_ppm] = nothing
end
pb_params[:tolerance_unit] = :ppm
pb_params[:min_peak_per_bin] = get(bin_rec, :min_peaks_per_bin, nothing)
pb_params[:intensity_weighted_centers] = true # Default from struct
pb_params[:num_uniform_bins] = nothing # User input dependent
pb_params[:frequency_threshold] = nothing # Hard to determine
else
pb_params[:method] = :none
pb_params[:tolerance] = nothing
pb_params[:max_bin_width_ppm] = nothing
pb_params[:tolerance_unit] = nothing
pb_params[:min_peak_per_bin] = nothing
pb_params[:intensity_weighted_centers] = true
pb_params[:num_uniform_bins] = nothing
pb_params[:frequency_threshold] = nothing
end
else
pb_params[:method] = :none
pb_params[:tolerance] = nothing
pb_params[:max_bin_width_ppm] = nothing
pb_params[:tolerance_unit] = nothing
pb_params[:min_peak_per_bin] = nothing
pb_params[:intensity_weighted_centers] = true
pb_params[:num_uniform_bins] = nothing
pb_params[:frequency_threshold] = nothing
end
return Dict(
:Stabilization => stab_params,
:Calibration => cal_params,
:Smoothing => sm_params,
:BaselineCorrection => bc_params,
:Normalization => norm_params,
:PeakPicking => pp_params,
:PeakAlignment => pa_params,
:PeakSelection => ps_params,
:PeakBinning => pb_params
)
end