---
title: "Introduction to quantMSImageR"
author: "Matthew J. Smith"
date: "`r Sys.Date()`"
package: quantMSImageR
output:
  BiocStyle::html_document:
    toc_float: true
vignette: >
  %\VignetteIndexEntry{1. Introduction to quantMSImageR}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", warning = FALSE,
                      message = FALSE)
```

# Introduction

A targeted DESI-MRM imaging run produces one intensity per transition per pixel,
across tens of thousands of pixels and several acquisitions. Turning that into a
biological result runs into several challenges.

A tissue section occupies part of the scanned area, and the surrounding slide
still returns signal: chemical background, termed background pixels. Left in,
those pixels drag down every summary statistic and make images look more uniform
than they are. Which pixels are tissue has to be decided per acquisition, and
the decision has to survive being re-applied to a different MRM panel of the
same sample. Additionally, these pixels aid determining a pseudo noise level for
filtering purposes.

Targeted panels are limited in number, so often multiple scans of the same area
need to be acquired and thus merged.

Response depends on ionisation efficiency and local matrix suppression, so raw
responses should not be interpreted as analyte amount, and their comparability
between analytes, sections or instruments should not be assumed without
appropriate normalisation and calibration.

## What this package does

`quantMSImageR` addresses each of those: signal-to-noise (SNR) filtering against
background pixels, tissue/background separation, per-feature ion images
including `.txt` image outputs for overlaying with other imaging (H&E),
quantile heatmaps -- and quantification against calibration standards for
the third.

## Scope

The package is currently validated for Waters DESI-MRM data. The software is
extendable to targeted imaging data, which may be analysed after conversion to a
compatible `MSImagingExperiment`. However, accessing raw data from other
targeted imaging methods is not yet validated.

Within that scope it is designed to handle:

- background-referenced filtering against non-tissue pixels;
- merging of targeted panels;
- targeted ion-image visualisation and `.txt` export;
- cross-sample summaries such as quantile heatmaps;
- calibration-based amount estimation.

It does not do the following, by design:

| Not provided | Use instead |
|---|---|
| Untargeted peak detection | a general MSI processing package |
| Feature annotation / molecular identification | dedicated annotation tools |
| Vendor-neutral raw-data conversion | vendor or community converters |
| Histological registration | [TissUUmaps](https://tissuumaps.github.io/); our own helper tools are still in development ([PMID 42438167](https://pubmed.ncbi.nlm.nih.gov/42438167/)) |
| General-purpose image segmentation | image-analysis packages |
| Complex spatial / colocalisation analysis | [corrMSI](https://github.com/MJS-708/corrMSI/tree/dev) |

## The workflow

Functions are grouped into families, and each section below opens with the
family it belongs to. The whole workflow is:

```
  .raw acquisition            ion library CSV
         |                          |
         +--------> readMRM() <----+
                        |  MSImagingExperiment, fData joined to library
                        v
        selectTissuePixels() --> tissue_pixels.csv  (x, y, tissue/background)
                        |
    +-------------------+-------------------+
    |  ASSEMBLE   alignFeatures -> combineMSIs / bindPanels   |
    |             trimMSI                                       |
    +-------------------+-------------------+
                        v   quant_MSImagingExperiment
    +-------------------+-------------------+
    |  FILTER     int2response  (IS-normalise)   |
    |             zero2NA, removeBlankMzs      |
    |             int2SNR -> applySNR, back2NA   |
    +-------------------+-------------------+
                        v
         +--------------+--------------+
         v                             v
   VISUALISE                     QUANTIFY  (needs a standards acquisition)
   imageR                        summariseCalLevels
   quantileHm                          v
                                 createCalCurve
                                        v
                                 int2conc -> "pg_pixel", "pg_mm2"
                                        v
                                 plotCalCoverage   (does calibration cover the data?)

  WHOLE STUDY:  runStudy() (YAML)  .  generateTxtImages()  .  runExample()
```

Per-feature images can also be exported as plain `.txt` intensity matrices,
which load directly into ImageJ and similar tools. Useful for downstream
multimodal analysis such as co-registration with histology or other imaging
modalities.

Quantification against calibration standards is covered separately in the
*Quantification with calibration standards* vignette
(`vignette("quantification", package = "quantMSImageR")`).

The workflow implemented here is described and applied in
[Smith *et al.*, *Analytical Chemistry* (2024)](https://pubs.acs.org/doi/10.1021/acs.analchem.4c02350).

# Installation

```{r install, eval = FALSE}
if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")
BiocManager::install("quantMSImageR")
```

# Quick start

The fastest way to see the whole workflow is `runExample()`, which runs the
imaging workflow on two synthetic groups (A, circular tissue, n = 3;
B, square tissue, n = 3), renders the HTML report, and also demonstrates
quantification on the bundled calibration standards:

```{r quickstart, eval = FALSE}
library(quantMSImageR)
res <- runExample()          # both groups; imaging report + calibration demo
res$calibrated                # the calibrated object
```

The rest of this vignette reproduces the key steps by hand.

# The data model

Every acquisition is a `Cardinal::MSImagingExperiment`. `quantMSImageR` adds the
`quant_MSImagingExperiment` subclass, which carries two extra slots for
calibration and tissue metadata, so most functions coerce with `as()` first.

The object is features x pixels: one row per MRM transition, one column per
pixel. Calculated quantities -- `response`, `snr`, calibrated amount -- are
stored as *additional* spectra layers. Masking functions are the exception:
`applySNR()`, `back2NA()` and `zero2NA()` modify the selected `val_slot` in
place, so keep a copy of the object when the unmodified values are needed
afterwards.

| Component | Contains | Accessor |
|---|---|---|
| spectra slots | `intensity` (raw), `response` (IS-normalised), `snr`, `pg_pixel` / `pg_mm2` (calibrated) | `spectra(x, "name")`, `spectraData(x)` |
| feature metadata | `mz`, `name`, transition m/z, analyte type, ion-library columns | `fData(x)` |
| pixel metadata | `x`, `y`, `run`, tissue/background label | `pData(x)` |
| calibration | fitted models, calibration levels, fit diagnostics | `calibrationModels(x)`, `calibrationLevels(x)`, `calibrationDiagnostics(x)` |
| tissue summaries | pixel and ROI matrices | `tissueMatrix(x)`, `tissueData(x)` |

Multiple acquisitions are held in one object, distinguished by the `run`
column of `pData()` -- that is what `combineMSIs()` produces and what every
per-sample summary groups on.

Pixel metadata uses consistent vocabulary: `run` identifies the acquisition,
`x`/`y` are grid coordinates, and the tissue mask lives in `sample_name` as
`tissue_pixels` / `background_pixels`. The calibration path uses
`sample_type` with `Cal` / `Tissue` / `Background`.

```{r data-model}
library(quantMSImageR)

p   <- system.file("extdata", "example.raw", "section01.RDS",
                   package = "quantMSImageR")
obj <- as(readRDS(p), "quant_MSImagingExperiment")

dim(obj)                        # features x pixels
names(spectraData(obj))         # available layers
```

```{r data-model-fdata}
DT::datatable(as.data.frame(fData(obj)), rownames = FALSE,
              options = list(pageLength = 5, scrollX = TRUE))
```

**Table 1.** Feature metadata: one row per MRM transition.

```{r data-model-pdata}
DT::datatable(head(as.data.frame(pData(obj)), 100), rownames = FALSE,
              options = list(pageLength = 5, scrollX = TRUE))
```

**Table 2.** Pixel metadata, first 100 pixels of one section.

# Reading your own data

The bundled example ships as `.RDS` so the vignette is reproducible, but real
acquisitions enter through `readMRM()`, which reads a Waters DESI-MRM `.raw`
folder and joins the ion library to the features:

```{r read-mrm, eval = FALSE}
obj <- readMRM(
  name         = "my_acquisition",        # .raw folder name, without the suffix
  folder       = "path/to/raw",           # directory containing my_acquisition.raw
  lib_ion_path = "path/to/ion_library.csv"
)
```

The `.raw` folder must contain the exported per-transition text files (an
`imaging/` subdirectory) or a previously cached `MSImagingExperiment.rds`. The
ion library is a CSV with one row per transition; the columns used for matching
are the precursor and product m/z, plus `transition_id` for the display name and
`Type` to mark internal standards. Any additional columns -- pathway class,
polarity, collision energy -- are carried through to `fData()` and become
available for grouping, as shown in *Joining ion-library metadata* below.

The result is a features x pixels object exactly like the one inspected above,
ready for `selectTissuePixels()` and the rest of the workflow.

# Loading the acquisitions

*Families: `acquisition`, `combining acquisitions`.*

| Function | Takes | Produces |
|---|---|---|
| `readMRM()` | `.raw` folder + ion-library CSV | `MSImagingExperiment`, `fData` joined to the library |
| `trimMSI()` | object with a tissue mask | object with pure-background border rows/columns dropped |
| `removeBlankMzs()` | object | object with features that have no data removed |
| `alignFeatures()` | two objects | both reduced to common features, m/z-keyed |
| `combineMSIs()` | two or more aligned objects | one object, each acquisition its own `run` |
| `bindPanels()` | two objects on a shared `(x, y)` grid | features of both on the pixel-grid intersection |

Note the difference between the last two. `combineMSIs()` stacks different
sections side by side, adding pixels. `bindPanels()` merges two
acquisitions of the same physical area -- typically a positive- and a
negative-mode run of one section, or two MRM panels -- adding features to the
same pixels:

```{r bind-panels, eval = FALSE}
pos <- readMRM("acq_section1_pos", folder = "path/to/raw",
                lib_ion_path = "path/to/ion_library.csv")
neg <- readMRM("acq_section1_neg", folder = "path/to/raw",
                lib_ion_path = "path/to/ion_library.csv")

# Pixels are matched on (x, y), so the two runs need not have identical grids;
# pixels present in only one are dropped.
both <- bindPanels(pos, neg, label = "S1")
```

The bundled example is six sections: three of group A (circular tissue) and
three of group B (square tissue). They ship as RDS, so `readMRM()` is not
needed here. `combineMSIs()` binds them into a single object, keeping each
section as its own `run`:

```{r load}
library(quantMSImageR)
library(ggplot2)

sections <- sprintf("section%02d", 1:6)
objs <- lapply(sections, function(s)
  readRDS(system.file("extdata", "example.raw", paste0(s, ".RDS"),
                      package = "quantMSImageR")))
combined <- do.call(combineMSIs, objs)

groups <- c("A", "A", "A", "B", "B", "B")   # sections 01-03 = A, 04-06 = B
combined
```

Each section contributed its pixels as a separate `run`, on the same feature
axis:

```{r load-check}
dim(combined)                       # features x (pixels from all six sections)
table(pData(combined)$run)
```

Everything below operates on this one `combined` object.

# Selecting tissue pixels

*Family: `acquisition`.*

| Function | Takes | Produces |
|---|---|---|
| `selectTissuePixels()` | acquisition name, data path, ion library | `tissue_pixels.csv` (`x`, `y`, `tissue_pixels`, `background_pixels`) plus a preview of the saved mask |

Before SNR filtering, each acquisition needs a tissue mask. `selectTissuePixels()`
displays every feature's ion image so you can pick the one that best separates
tissue from background, then opens an interactive ROI selector:

```{r select, eval = FALSE}
selectTissuePixels(
  name         = "my_acquisition",          # .raw folder name (no suffix)
  data_path    = "path/to/raw",
  lib_ion_path = "path/to/ion_library.csv"
)
```

It writes the resulting mask as `tissue_pixels.csv` inside the acquisition's
`.raw` folder, where `int2SNR()` and `generateTxtImages()` read it. The mask
is simply a per-pixel label -- tissue versus background -- which the bundled
sections already carry in their `sample_name` column:

```{r mask-table}
mask_tbl <- as.data.frame(pData(combined))[, c("run", "x", "y", "sample_name")]

DT::datatable(head(mask_tbl, 500), rownames = FALSE,
              options = list(pageLength = 5, scrollX = TRUE))
```

**Table 3.** Per-pixel tissue/background labelling used by `int2SNR()` (first 500
pixels).

Plotting that column over the pixel coordinates shows the mask itself, which is
the quickest way to confirm a selection before running the workflow -- the
circular group A and square group B tissue should stand out cleanly from the
surrounding background:

```{r mask-plot, fig.wide = TRUE, fig.cap = "Tissue masks for all six sections. Green pixels are labelled tissue_pixels and contribute to every downstream summary; grey pixels are background_pixels and are used to estimate the noise level in int2SNR()."}
ggplot(mask_tbl, aes(x, -y, fill = sample_name)) +
  geom_tile() +
  facet_wrap(~ run, ncol = 3) +
  coord_equal() +
  scale_fill_manual(values = c(tissue_pixels     = "#1b7837",
                               background_pixels = "grey85")) +
  labs(x = NULL, y = NULL, fill = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), panel.grid = element_blank(),
        strip.text = element_text(size = 7),
        legend.text = element_text(size = 7))
```

# Ion images

*Family: `visualisation`.*

| Function | Takes | Produces |
|---|---|---|
| `imageR()` | object, `feat_ind`, `val_slot`, `sample_lab`, `scale` | a `ggplot` ion image |
| `quantileHm()` | object, `quant_val`, sample order/labels, `feature_split` | a `ComplexHeatmap::Heatmap` (rows = samples, columns = features) |
| `contributionHm()` | the same arguments | the same `ComplexHeatmap::Heatmap`, with group mean as hue and per-sample contribution as opacity |
| `quantPalettes()` | palette name, optional `n` | the hex colours used by both, for reuse in your own plots |

Colours are consistent across the package and selectable per plot. `imageR()`
takes `palette = "heatmap0"` (the default) or `"viridis"`; `quantileHm()` takes a diverging
`palette` plus `group_palette` / `feature_palette` for the metadata bars, and
`cell_border` for the hairline that keeps neighbouring cells of the same colour
from merging into one block. In a YAML-driven study these are set once in the
`colours:` block:

```yaml
colours:
  ion_image:   "heatmap0"  # heatmap0 | viridis
  heatmap:     "heatmap2"  # heatmap2 | heatmap0
  group:       "hat"       # hat | reading | heatmap0
  feature:     "reading"   # reading | hat | heatmap0
  cell_border: "white"     # any colour, or "none"
```

```{r palettes}
names(quantPalettes())
quantPalettes("heatmap0")
```

`imageR()` returns a `ggplot` ion image for a chosen feature. Because it is a
plain `ggplot`, the layout can be adjusted -- here into a 3 x 2 grid:

```{r image, fig.wide = TRUE, fig.cap = "Ion image of the first feature across all six sections, before SNR filtering. Sections 01-03 are group A (circular tissue), 04-06 are group B (square)."}
imageR(combined, feat_ind = 1, sample_lab = "run", scale = "suppress",
       palette = "heatmap0") +
  facet_wrap(~ sample, ncol = 3) +
  theme(strip.text = element_text(size = 7),
        legend.text = element_text(size = 7))
```

## Colour-scale treatment

A single ion image can look very different depending on how the colour scale is
built, and the right choice depends on what is being judged. `scale` offers
three treatments:

- `"suppress"` caps the scale at `percentile` (default 99), so a few hot
  pixels cannot flatten everything else. This is the default and the right
  starting point for comparing sections.
- `"histogram"` equalises the distribution, which reveals structure in a
  feature whose signal spans a narrow range -- at the cost of a colour scale
  that no longer maps linearly to response.
- `"sqrt"` compresses the top of the range more gently than histogram
  equalisation, keeping the scale monotonic in response.

```{r image-scales, fig.wide = TRUE, fig.height = 3, fig.cap = "The same feature and section under the three colour-scale treatments. Only `suppress` keeps the colour scale linear in response; the others trade that for visible structure."}
.one <- combined[, pData(combined)$run == "section01"]
.p <- lapply(c("suppress", "histogram", "sqrt"), function(sc)
  imageR(.one, feat_ind = 1, sample_lab = "run", scale = sc) +
    labs(title = sc) +
    theme(legend.position = "none",
          plot.title = element_text(size = 9, face = "bold"),
          strip.text = element_blank()))
patchwork::wrap_plots(.p, nrow = 1)
```

`perc_scale = TRUE` additionally relabels the scale as a percentage of each
image's maximum, which is useful when the absolute numbers are not comparable
anyway.

The HTML report produced by `runStudy()` shows a related set of three views per
feature, built with Cardinal's own `image()` rather than `imageR()`: capped at
the 95th quantile, histogram-normalised, and unscaled.

The same per-feature images can be written to disk as `.txt` intensity matrices
(`generateTxtImages(output_txt = TRUE)`, or `output_txt: true` in the YAML
config). Those files open directly in ImageJ, which is the usual route into
multimodal workflows.

# Joining ion-library metadata

`buildFeatureMeta()` joins ion-library annotation to features by rounded
`(precursor, product)` m/z, so extra columns -- such as the pathway class
`Met-1` -- become available for grouping. We do this before the heatmap because
the heatmap uses `Met-1` to split its rows.

The ion library is the CSV you supply as `lib_ion_path`, and it is worth looking
at before anything else: it defines the panel. `transition_id`,
`precursor_mz`, `product_mz`, `collision_eV`, `cone_V`, `Polarity` and `Type`
are required; anything else is yours to add. Here `Met-1` carries the pathway
class and `IS_norm` names the internal standard each analyte is normalised to
(see [int2response()]).

```{r ion-lib}
lib <- read.csv(system.file("extdata", "example_ion_library.csv",
                            package = "quantMSImageR"), check.names = FALSE)

DT::datatable(lib, rownames = FALSE,
              options = list(pageLength = 10, scrollX = TRUE))
```

**Table 4.** The example ion library, as supplied on disk.

```{r meta}
fm <- buildFeatureMeta(combined, lib)

DT::datatable(fm[, c("name", "precursor_mz", "product_mz", "Type", "Met-1",
                     "IS_norm")],
              rownames = FALSE,
              options = list(pageLength = 10, scrollX = TRUE))
```

**Table 5.** The same metadata after `buildFeatureMeta()` has joined it to
each measured feature by m/z.

# Quantile heatmaps across samples

`quantileHm()` summarises each feature to a per-sample quantile and returns a
`ComplexHeatmap::Heatmap`. Rows are samples, grouped by study group; columns
are features, split by the `Met-1` class joined above -- studies usually have
more samples than features, so the long dimension runs vertically. Cells are
drawn square (stretching to at most 1.5:1 when one dimension is much longer),
so the heatmap grows with the data rather than distorting to fill the device.

`cell_size` sets that size in millimetres and `fontsize` the label size, which
is what to reach for when long transition names crowd the panel: enlarge the
cells and shrink the text rather than letting the labels dictate the layout.

The `quant_val` argument chooses which quantile summarises each feature -- a
median (`0.5`) reflects the bulk of the tissue, while a high quantile (`0.95`)
emphasises hot-spots:

```{r heatmap-median, fig.wide = TRUE, fig.cap = "Median (50th percentile) feature intensity per section. Rows are sections grouped by study group; columns are features split by Met-1 class."}
feature_split <- fm[["Met-1"]]

quantileHm(combined, quant_val = 0.5,
            heatmap_order = sections, heatmap_labs = groups,
            feature_split = feature_split, feature_split_name = "Met-1",
            cell_size = 10, fontsize = 7)
```

```{r heatmap-95, fig.wide = TRUE, fig.cap = "The same summary at the 95th percentile, which emphasises hot-spots rather than the bulk of the tissue."}
quantileHm(combined, quant_val = 0.95,
            heatmap_order = sections, heatmap_labs = groups,
            feature_split = feature_split, feature_split_name = "Met-1",
            cell_size = 10, fontsize = 7)
```

## Seeing which sample carried a group effect

`contributionHm()` draws the same matrix but encodes two things per cell: the
**hue** is the sample's *group* mean for that feature, and the **opacity** is
that *sample's* contribution to it. A uniformly solid block is a real group
effect; one opaque tile among faded ones is a group mean resting on a single
replicate, which a plain group-mean heatmap cannot show.

It takes the same arguments as `quantileHm()` and returns the same kind of
object, laid out identically -- same group and feature colour bars, same
splits, same square cells. Only the cell fill differs, so the two can be read
against each other directly. The HTML report draws one or the other, never
both: `parameters: heatmap_style:` selects `per_sample`, `contribution`, or
`auto`, which picks the contribution view once any group has three or more
replicates -- the point at which a group mean can rest on one of them without
saying so -- and the per-sample view otherwise.

Two details matter for reading it. Values are z-scored per feature across
*all* samples rather than within group, so a cell means "high or low for this
feature". And the two channels get separate colour limits: a group mean of
\(n\) replicates is roughly \(\sqrt{n}\) smaller than a single sample's
z-score, so reusing one limit for both washes the panel out.

```{r heatmap-contribution, fig.wide = TRUE, fig.cap = "The same median summary as a group mean, with each sample's contribution to that mean drawn as opacity. A solid block is a group effect; a lone opaque tile is a mean resting on one section."}
contributionHm(combined, quant_val = 0.5,
                heatmap_order = sections, heatmap_labs = groups,
                feature_split = feature_split, feature_split_name = "Met-1",
                cell_size = 10, fontsize = 7)
```


# Signal-to-noise filtering

*Family: `filtering`.*

| Function | Takes | Produces |
|---|---|---|
| `int2response()` | object + internal-standard name | intensities normalised to the IS (`response`) |
| `int2SNR()` | object, `background`/`tissue` labels, `snr_thresh`, optional `snr_overrides` | adds an `snr` slot; below threshold and non-tissue become `NA` |
| `applySNR()` | object carrying an `snr` slot | sub-threshold pixels set to `NA` in `val_slot` |
| `back2NA()` | object + background label | background pixels set to `NA` |
| `zero2NA()` | object, `val_slot` | zeros replaced with `NA` |

`int2SNR()` computes a per-pixel signal-to-noise ratio for each feature against
the background pixels, and `applySNR()` then sets every sub-threshold pixel to
`NA` in the intensity slot.

```{r snr-apply}
combined <- int2SNR(combined, val_slot = "intensity", snr_thresh = 3)
combined <- applySNR(combined, val_slot = "intensity")
```

`int2SNR()` added an `snr` layer rather than replacing anything, and
`applySNR()` then set the sub-threshold pixels of `intensity` to `NA`:

```{r snr-check}
names(spectraData(combined))                            # snr layer added
mean(is.na(spectra(combined, "intensity"))) |> round(3) # fraction now masked
```

Compare the filtered image below with the unfiltered ion images in
*Ion images* above: the low-signal background has been removed, leaving the
tissue.

```{r snr-after, fig.wide = TRUE, fig.cap = "The same feature after applySNR(). Sub-threshold pixels are now NA, so the low-signal background has been removed and only tissue signal remains (compare to previous ion images)."}
imageR(combined, feat_ind = 1, sample_lab = "run", val_slot = "intensity",
       scale = "suppress") +
  facet_wrap(~ sample, ncol = 3) +
  theme(strip.text = element_text(size = 7),
        legend.text = element_text(size = 7))
```

# Running a full study from a YAML config

*Family: `workflow`.*

| Function | Takes | Produces |
|---|---|---|
| `runStudy()` | a YAML configuration file | runs the whole study: `.txt` images, optional calibration, one HTML report per SNR threshold |
| `validateConfig()` | the same file | a report of every problem found in it, before anything is read or written |
| `generateTxtImages()` | acquisition names, paths, ion library, SNR settings | list of combined/SNR-filtered objects; optional per-feature `.txt` images |
| `runExample()` | nothing (all paths resolved internally) | the same list plus `calibrated` and `report`, having rendered the HTML report |

Everything above runs inside `generateTxtImages()`, which `runStudy()` drives
from a YAML file. In practice a whole study -- multiple acquisitions, SNR
sweeps, ion ratios and optional calibration -- is configured in one place. A
minimal config looks like:

```yaml
study: "my_study"
paths:
  data_path:    "path/to/raw"
  out_path:     "path/to/results"
  image_dir:    "path/to/images"
  lib_ion_path: "path/to/ion_library.csv"
samples:
  - pos: "acq_ctrl"
    run_id: "S1"
    label:  "Ctrl"
  - pos: "acq_treated"
    run_id: "S2"
    label:  "Treatment"
parameters:
  snr_thresh: [3]
output:
  render_report: true
  output_txt:    true      # write per-feature .txt images for ImageJ
```

The shipped template documents every option (SNR overrides, ratios,
calibration, ...) -- open it to use as a starting point:

```{r yaml-template, eval = FALSE}
file.show(system.file("config_template.yaml", package = "quantMSImageR"))
```

Then point `runStudy()` at your filled-in config:

```{r run-study, eval = FALSE}
runStudy("path/to/study_config.yaml")
```

The same runner is available from the command line, which is the usual way to
run a large study unattended:

```sh
Rscript -e 'quantMSImageR::runStudy("study_config.yaml")'
```

## The configuration as a record of the analysis

Driving a study from a file rather than from a session means a complete
human-readable configuration file to document how the data were processed.
Every choice that affects the results (which acquisitions belong to which group,
the SNR thresholds and any per-analyte overrides, whether internal-standard
normalisation ran and against which standard, the calibration design and its
weighting, how much extrapolation was tolerated) is stored for future reference.

The file is small enough to deposit alongside the data, cite in a methods
section, or track in version control in line with FAIR principles
(Wilkinson *et al.*, 2016).

To ensure accuracy of this documentation `validateConfig()` runs every check
rather than stopping at the first failure, so one call reports every problem in
the file:

```{r validate, eval = FALSE}
validateConfig("path/to/study_config.yaml")
```

```
quantMSImageR validation: 2 error(s), 1 warning(s), 26 check(s)

Errors:
  - parameters$average_method = 'geometric'; use 'mean' or 'median'.
  - parameters$is_name = 'PGE2-d4' is not a value of the ion library's 'Type'
    column. Values present: Analyte, IS. Note this is a type label, not a
    transition name.
```

`runStudy()` calls it first and stops if there are errors, so a configuration
that cannot be trusted never produces an output. The checks are deliberately
cross-file for every enabled functionality (the ion library, the calibration
metadata and the config have to agree with each other, not merely be
individually well-formed).

# References

Smith MJ, Nie M, Adner M, Säfholm J, Wheelock CE. Development of a
Desorption Electrospray Ionization–Multiple-Reaction-Monitoring Mass
Spectrometry (DESI-MRM) Workflow for Spatially Mapping Oxylipins in
Pulmonary Tissue. *Analytical Chemistry* (2024).
<https://doi.org/10.1021/acs.analchem.4c02350>

Wilkinson MD, *et al.* The FAIR Guiding Principles for scientific data
management and stewardship. *Scientific Data* 3, 160018 (2016).
<https://doi.org/10.1038/sdata.2016.18>

# Session information

```{r session}
sessionInfo()
```
