---
title: "1. Fitting emission lines"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{1. Fitting emission lines}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7,
  fig.height = 4,
  out.width = "100%",
  dpi = 96
)
```

A typical LIBS analysis goes through four stages, each covered by a
vignette:

1.  **Fitting emission lines** (this vignette): identify the lines, correct
    the wavelength axis and measure line areas.
2.  **Preprocessing** with recipe steps: `vignette("preprocessing")`.
3.  **Calibration** and figures of merit: `vignette("calibration")`.
4.  **Plasma diagnostics**: `vignette("plasma-diagnostics")`.

All four use `forageLIBS`: 368 spectra of dried forage samples, with
laboratory concentrations of 12 elements.

```{r requirements, include = FALSE}
if (!requireNamespace("purrr", quietly = TRUE)) {
  knitr::knit_exit("This vignette needs the purrr package, which is not installed.")
}
```

```{r setup, message = FALSE}
library(specProc)
library(dplyr)
library(purrr)
library(ggplot2)

data("forageLIBS")
spectra_id <- forageLIBS |> select(1:2) |> names()
minerals <- forageLIBS |> select(3:14) |> names()
spectra <- forageLIBS |> select(-all_of(c(spectra_id, minerals)))
wl <- as.numeric(names(spectra))
```

Each row of `spectra` is one spectrum, the mean of 8 laser shots, and the
column names are the wavelengths (nm):

```{r spectra, fig.height = 3.5}
forageLIBS |>
  slice(1:3) |>
  select(Measurement, all_of(names(spectra)[wl > 400 & wl < 410])) |>
  plot_spectra(id = Measurement)
```

## Identifying the lines

`libs_lines()` lists the strongest lines of given species in a wavelength
range, from the NIST Atomic Spectra Database, and `plot_lines()` marks them
on a spectrum. `line_finder()` opens an interactive app that does both.
These functions need an internet connection:

```{r identify, eval = FALSE}
candidates <- libs_lines(c("K I", "Ca I", "Fe I", "Mn I"), wavelength = c(400, 410))
plot_lines(colMeans(spectra), candidates)
line_finder(spectra)
```

## Correcting the wavelength axis

The measured lines are shifted from their tabulated wavelengths.
`wavelength_calibration()` locates known lines in the mean spectrum and
fits a correction of the axis. The instrument joins several detectors (the
wavelengths step back at 766 nm), and each detector has its own
calibration: we correct the first one, from 199 to 766 nm, which holds the
lines used below.

```{r calibration}
first_detector <- spectra[seq_len(which(diff(wl) < 0)[1])]

reference <- c(`C I` = 247.856, `Mg I` = 285.213, `Ca II` = 317.933, `Ca II` = 370.603,
               `Ca II` = 373.690, `K I` = 404.414, `Ca I` = 430.253, `Ca I` = 445.478,
               `Mg I` = 516.732, `Mg I` = 517.268, `Mg I` = 518.360, `Na I` = 588.995,
               `Na I` = 589.592, `Ca I` = 616.217, `Ca I` = 643.907)
wavecal <- wavelength_calibration(first_detector, reference)
wavecal$segments
```

```{r calibration-plot, fig.height = 3.5}
plot_wavelength_calibration(wavecal)
```

The offset grows from 0.05 nm in the UV to 0.12 nm in the red, about one
channel, and the residuals of the linear correction are close to 0.01 nm.
`apply_calibration()` relabels the columns with the corrected wavelengths.
We also remove the continuum with `baseline_arpls()`, and average the
spectra for a clean example:

```{r corrected}
calibrated <- apply_calibration(first_detector, wavecal)
corrected <- baseline_arpls(calibrated, lambda = 1e5, max.iter = 20)$correction
wl_corrected <- as.numeric(names(corrected))
mean_spectrum <- colMeans(corrected)

# The channels between two wavelengths, as a one-row table
window <- function(from, to, x = mean_spectrum) {
  keep <- wl_corrected > from & wl_corrected < to
  if (is.null(dim(x))) as_tibble(as.list(x[keep])) else x[keep]
}
```

## Line profiles

An emission line of a laser-induced plasma is broadened by several
mechanisms: the instrument and the Doppler effect give a Gaussian profile,
the Stark effect a Lorentzian one, and both together a Voigt profile.
specProc provides the four profiles, parametrized by their full widths at
half maximum:

| Function | Profile | Widths |
|---|---|---|
| `gaussian_profile()` | Gaussian | `wG` |
| `lorentzian_profile()` | Lorentzian | `wL` |
| `voigt_profile()` | exact Voigt (Faddeeva function, C++) | `wG`, `wL` |
| `pseudo_voigt_profile()` | Thompson–Cox–Hastings pseudo-Voigt | `wG`, `wL` |

## Fitting a single line

`peak_fit()` fits \(y = y_0 + A \cdot f(x; x_c, w)\) to each spectrum (one
per row, wavelengths as column names) by nonlinear least squares, with
starting values estimated from the data. The hydrogen H\(\alpha\) line at
656.28 nm comes from the water of the samples. Its width is dominated by
the Stark effect, and the choice of profile matters:

```{r halpha}
halpha <- window(653.5, 659.5)
fits <- c("gaussian", "lorentzian", "pseudo_voigt", "voigt") |>
  set_names() |>
  map(\(p) peak_fit(halpha, profile = p))

tibble(
  profile = names(fits),
  AIC = map_dbl(fits, \(f) AIC(f$fit[[1]])),
  area = map_dbl(fits, \(f) coef(f$fit[[1]])[["A"]])
) |>
  mutate(across(c(AIC, area), round))
```

The Gaussian misses the wings of the line: its AIC is 80 units higher, and
its area 40% smaller. The Lorentzian, pseudo-Voigt and Voigt profiles fit
equally well. `tidied` holds the parameters, with their standard errors:

```{r halpha-voigt}
fits$voigt$tidied[[1]]
```

The Gaussian width of the Voigt fit is close to zero, with a standard error
far larger than its value: the line is purely Lorentzian, and the Voigt
profile has one parameter too many. `plot_fit()` shows the Lorentzian fit
and its residuals:

```{r halpha-plot, fig.height = 5}
plot_fit(fits$lorentzian, title = "H-alpha 656.28 nm, Lorentzian profile")
```

The width of H\(\alpha\) gives the electron density of the plasma (see
`vignette("plasma-diagnostics")`); for a Voigt fit, `voigt_fwhm()`
combines the two widths into the total width.

## Overlapping lines

The potassium doublet at 404.41 and 404.72 nm is weak and its lines
overlap. `multipeak_fit()` fits them together, on a common baseline; the
parameters of line \(i\) carry the suffix `_i`:

```{r doublet}
doublet <- window(404.1, 405.0)
k_voigt <- multipeak_fit(doublet, peaks = c(404.414, 404.721), profiles = "voigt")
k_voigt$tidied[[1]]
```

With about ten channels for 9 parameters, the Gaussian and Lorentzian
widths of each line cannot be separated: their standard errors are larger
than the estimates, and the areas are poorly determined. A Gaussian
profile, with one width per line, is enough for lines only a few channels
wide:

```{r doublet-gaussian}
k_gauss <- multipeak_fit(doublet, peaks = c(404.414, 404.721), profiles = "gaussian")
k_gauss$tidied[[1]]
c(voigt = AIC(k_voigt$fit[[1]]), gaussian = AIC(k_gauss$fit[[1]]))
```

```{r doublet-plot, fig.height = 5}
plot_fit(k_gauss, title = "K I 404.41 and 404.72 nm, Gaussian profiles")
```

The areas are now determined, and the AIC is lower: prefer the simplest
profile that describes the data.

## Fitting every spectrum

With an `id` column, `peak_fit()` and `multipeak_fit()` fit each row, so
the same line is measured in every spectrum. A fit that fails (here on a
few spectra where the doublet is lost in the noise) gives `NULL` with a
warning, and the others continue:

```{r batch, warning = FALSE}
doublets <- bind_cols(
  select(forageLIBS, Measurement),
  window(404.1, 405.0, x = corrected)
)
k_fits <- multipeak_fit(doublets, peaks = c(404.414, 404.721), profiles = "gaussian",
                        id = "Measurement")

k_areas <- k_fits |>
  mutate(area = map_dbl(tidied, \(t) if (is.null(t)) NA else t$estimate[t$term == "A_1"])) |>
  select(Measurement, area) |>
  left_join(select(forageLIBS, Measurement, K), by = "Measurement")
sum(is.na(k_areas$area))
```

```{r batch-plot, fig.height = 3.5, fig.width = 5, out.width = "70%", warning = FALSE}
ggplot(k_areas, aes(K, area)) +
  geom_point(alpha = 0.5) +
  labs(x = "K (%)", y = "Area of K I 404.41 nm") +
  theme_bw()
```

The area follows the potassium content, with much scatter: the intensity
of a line also depends on the ablated mass and the plasma, which the next
vignettes correct by normalization and calibration.

## Integrating instead of fitting

`line_intensities()` measures many lines at once: it finds the peak of
each line near its tabulated wavelength (`search`) and integrates it over
a window (`half_width`), or fits a Voigt profile with `method = "voigt"`.
It needs no starting values and is fast, but it does not separate
overlapping lines:

```{r intensities}
lines <- c(`K I 404.41` = 404.414, `Mg I 518.36` = 518.360, `Fe II 259.94` = 259.940)
line_intensities(corrected[1:2, ], lines, half_width = 0.1)
```

The recipe step `step_line_intensities()` does the same within a
preprocessing pipeline (`vignette("preprocessing")`).

## Summary

-   **Correct the wavelength axis first** with `wavelength_calibration()`, so
    that lines can be found and identified at their tabulated wavelengths.
-   **Choose the profile from the data**: compare the AIC and the residuals.
    Strongly Stark-broadened lines such as H\(\alpha\) need a Lorentzian or
    Voigt profile; lines a few channels wide are often better fitted by a
    Gaussian.
-   **Fit overlapping lines together** with `multipeak_fit()`, and check that
    each parameter is determined (its standard error well below its value).
-   **Keep the same profile and window for every spectrum** of a study, and
    fit them all with `id`.
