Nothing
#' @title Multi-exponential CW-OSL curve fitting
#'
#' @description Fitting function for multi-exponentially decaying CW-OSL measurements,
#' based on the algorithm described by Bluszcz & Adamiec (2006).
#'
#' The function assumes multiple exponentially decaying signal components with first-order kinetics:
#'
#' \deqn{I(t) = n_1 \lambda_1 exp(-\lambda_1 t) + n_2 \lambda_2 exp(-\lambda_2 t) + ... + n_K \lambda_K exp(-\lambda_K t)}
#'
#' with \eqn{I(t)} the CW-OSL signal, \eqn{n} the signal component intensity,
#' \eqn{\lambda} the signal component decay constant and \eqn{K} the number of signal components.
#' For actual fitting, the integrated version of this formula is used, see Mittelstraß et al. (2021) for details.
#'
#' The fitting algorithm is an implementation of the *hybrid evolutionary-linear algorithm* (HELA)
#' by Bluszcz & Adamiec (2006). See there or Mittelstraß et al. (in preparation) for details.
#' differential evolution part of HELA is performed by [DEoptim::DEoptim].
#' The linear regression part of HELA is performed by [decompose_OSLcurve].
#' The parameter refinement by Levenberg-Marquardt fitting is performed by [minpack.lm::nlsLM].
#'
#' **F-test**
#'
#' Bluszcz & Adamiec (2006) suggest the use of an F-test to determine the correct number of signal components.
#' This function compares the residual square sum (*RSS_K*) value of each
#' fitting with the \eqn{RSS_{K-1}} value of the previous fitting and calculates
#' an *Improvement-in-fitting-quality* criterion:
#'
#' \deqn{F_K = {(RSS_{K-1} - RSS_K)/2} / {RSS_K(N - 2K)}}
#'
#' Here, *N* is the number data points (channels) of the measurement and *K* is the number of OSL components
#' in the fitting model. If *F_K* falls below the threshold value (`F.threshold`), the fitting model
#' with *K* components is apparently not significantly better than the *K* - 1 model of the previous fitting cycle.
#' Thus, the *K* - 1 model will be recommended as fitting solution.
#'
#'
#' **Photoionisation cross sections**
#'
#' While the function is suited for the analysis of a wide variety of multi-exponential decay problems,
#' it is targeted to CW-OSL measurements of quartz under SAR protocol conditions (470 nm stimulation at 125 °C).
#' To compare the calculated OSL components with OSL components reported in published literature,
#' photoionisation cross sections are calculated using the `stimulation.wavelength` \eqn{\lambda_{stim}} and
#' `stimulation.intensity` \eqn{\Phi_{stim}}:
#'
#' \deqn{\sigma_k=\lambda_k {hc / \Phi_{stim}\lambda_{stim}}}
#'
#' Here \eqn{\sigma_k} is the photoionisation cross section of component *k* in cm^2,
#' \eqn{\lambda_k} the CW-OSL decay constant in s^-1, *h* the Planck constant and *c* the speed of light.
#'
#' If a `stimulation.intensity` between 460 nm and 485 nm is defined,
#' the components are named automatically in accordance to the
#' cross-sections published by Durcan and Duller (2011), Jain et al. (2003) and Singarayer and Bailey (2003).
#' For the Ultrafast and the Slow4 component, no consistent literature values could be found, so their range
#' is tentatively assigned:
#'
#' \tabular{lll}{
#' **Component** \tab **Lower limit (cm^2)** \tab **Upper limit (cm^2)**\cr
#' Ultrafast \tab 1e-16 \tab 1e-15 \cr
#' Fast \tab 1.9e-17 \tab 3.1e-17 \cr
#' Medium \tab 3e-18 \tab 9e-18 \cr
#' Slow1 \tab 1e-18 \tab 1.85e-18 \cr
#' Slow2 \tab 1.1e-19 \tab 4e-19 \cr
#' Slow3 \tab 1e-20 \tab 4.67e-20 \cr
#' Slow4 \tab 1e-21 \tab 1e-20
#' }
#'
#' @param curve [Luminescence::RLum.Data.Curve-class] or [data.frame] or [matrix] (**required**):
#' CW-OSL record or average CW-OSL curve created by [sum_OSLcurves]. If no column `$time` exists, the first column is defined
#' as measurement time (x-axis). Time intervals must be constant. If no column `$signal` exists, the second column is defined
#' as signal values (y-axis). Further columns will be ignored
#'
#' @param K.max [numeric] (*with default*):
#' Maximum number of components *K*. The computing time increases exponentially with the component number.
#' *K* < 7 is recommended
#'
#' @param F.threshold [numeric] (*with default*):
#' Fitting stop criterion. If the F-value is lower than this threshold, the fitting procedure stops and the K - 1 fit is returned
#'
#' @param stimulation.intensity [numeric] (*with default*):
#' Intensity of optical stimulation in *mW / cm²*. Used to calculate photoionisation cross sections.
#'
#' @param stimulation.wavelength [numeric] (*with default*):
#' Wavelength of optical stimulation in *nm*. Used to calculate photoionisation cross sections.
#' If a wavelength between 465 and 480 nm is chosen, the cross sections are set into
#' relation with literature values to name the signal components automatically.
#'
#' @param verbose [logical] (*with default*):
#' Enables console text output.
#'
#' @param output.complex [logical] (*with default*):
#' If `TRUE`, the function returns a list of objects, see section **Value** for further information.
#' If `FALSE`, the function returns a [data.frame] with the CW-OSL model parameters of the fitting chosen by the F-test.
#' Setting the parameter to `FALSE` is not recommended when fitting a global average curve created by [sum_OSLcurves] as over-fitting is likely in such cases.
#'
#' @param parallel.computing [logical] (*with default*):
#' Enables the use of multiple CPU cores. This increases the execution speed significantly
#' but may need administrator rights and/or a firewall exception.
#' See [DEoptim::DEoptim.control] for further information.
#'
#' @section Last update:
#'
#' 2022-07-27, DM: Moved residual sum of squares (RSS) calculation during DE-optimization cycle to decompose_OSLcurve() to improve computing time by factor 3 to 4
#'
#' @author
#' Dirk Mittelstraß, \email{dirk.mittelstrass@@luminescence.de}
#'
#' Please cite the package the following way:
#'
#' Mittelstraß, D., Schmidt, C., Beyer, J., Heitmann, J. and Straessner, A.:
#' R package OSLdecomposition: Automated identification and separation of quartz CW-OSL signal components, *in preparation*.
#'
#' @seealso [RLum.OSL_decomposition], [sum_OSLcurves], [decompose_OSLcurve], [plot_OSLcurve],
#' [plot_PhotoCrosssections], [minpack.lm::nlsLM], [DEoptim::DEoptim]
#'
#' @references
#'
#' Bluszcz, A., Adamiec, G., 2006. Application of differential evolution to fitting OSL
#' decay curves. Radiation Measurements 41, 886–891.
#'
#' Durcan, J.A., Duller, G.A.T., 2011. The fast ratio: A rapid measure for testing the dominance of the fast component in the initial OSL signal from quartz. Radiation Measurements 46, 1065–1072.
#'
#' Jain, M., Murray, A.S., Bøtter-Jensen, L., 2003. Characterisation of blue-light stimulated luminescence components in different quartz samples: implications for dose measurement. Radiation Measurements 37, 441–449.
#'
#' Mittelstraß, D., 2019. Decomposition of weak optically stimulated luminescence signals and
#' its application in retrospective dosimetry at quartz (Master thesis). TU Dresden, Dresden.
#'
#' Singarayer, J.S., Bailey, R.M., 2003. Further investigations of the quartz optically stimulated luminescence components using linear modulation.
#' Radiation Measurements, Proceedings of the 10th international Conference on Luminescence and Electron-Spin Resonance Dating (LED 2002) 37, 451–458.
#'
#'
#' @return
#'
#' If `output.complex = FALSE`, a [data.frame] is returned. It contains the signal decay rates
#' and signal intensities of the best fit. The best fit was either chosen by the F-test or
#' the last successful fitting iteration.
#'
#' If `output.complex = TRUE`, a [list] of objects is returned:
#'
#' \tabular{lll}{
#' **Element** \tab **Type** \tab **Description**\cr
#' `decay.rates` \tab `numeric` \tab [vector] of the best suiting decay rates \cr
#' `K.selected` \tab `numeric` \tab number of components of the best fit \cr
#' `F.test` \tab `data.frame` \tab table containing the F-test parameter and the decay rates of each fitting model \cr
#' `F.test.print` \tab `data.frame` \tab the same table as above, but formated for pretty console and report output \cr
#' `info.text` \tab `character` \tab collected messages from the algorithms \cr
#' `component.tables` \tab `list` \tab result [data.frame]s for all tested models \cr
#' `curve` \tab `data.frame` \tab fitted time-signal-curve \cr
#' `components` \tab `data.frame` \tab best fit; same object as `output.complex = FALSE` returns \cr
#' `fit.results` \tab `list` \tab [list] of [nls] objects for all tested models \cr
#' `plot.data` \tab `data.frame` \tab factorized results for overview plotting with [plot_PhotoCrosssections] \cr
#' `parameters` \tab `list` \tab function arguments and the needed computing time
#' }
#'
#'
#' @examples
#'
#' # Create a simple curve with just one component
#' curve <- data.frame(
#' X = c(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
#' Y = c(377, 244, 163, 93, 59, 28, 17, 13, 10, 8, 9, 5))
#' # Perform fitting
#' components <- fit_OSLcurve(curve, F.threshold = 3)
#'
#' # Display results
#' plot_OSLcurve(curve, components)
#'
#' @md
#' @export
fit_OSLcurve <- function(
curve,
K.max = 5,
F.threshold = 150,
stimulation.intensity = 30,
stimulation.wavelength = 470,
verbose = TRUE,
output.complex = FALSE,
parallel.computing = FALSE
){
# Changelog:
# * 2019-02-14, DM: First version
# * 2019-03-15, DM: Separated 'decompose_OSLalternatively()'
# * 2019-04-29, DM: Added Blucszs & Adamiec-like approach using numOSL::decomp (Peng et al. 2014)
# * 2019-05-14, DM: Added "fit_OSLLifeTimes" approach from Luminescence package 0.9; Corrected and improved numOSL approach; Deleted nls.default approach
# * 2019-06-28, DM: Deleted "fit_OSLLifeTimes" approach. Added stretched exponentials for testing. Added overview plot
# * 2019-10-07, DM: Streamlined function; added optional background fitting
# * 2019-10-08, DM: Separated plotting to plot_PhotoCrosssections()
# * 2020-04-04, DM: Extended output list (curve & arguments)
# * 2020-04-06, DM: Extended print output and made some tweaks. Replaced 'SAR.compatible' with 'fully.bleached'
# * 2020-05-05, DM: Replaced boolean 'fully.bleached' with numeric 'bleaching.grade'
# * 2020-08-05, DM: Added DEoptim + nlsLM algorithm
# * 2020-08-10, DM: Optional parallel computing enabled
# * 2020-10-26, DM: Roxygen documentation
# * 2020-11-25, DM: Reworked console output
# * 2022-07-27, DM: Moved residual sum of squares (RSS) calculation during DE-optimization cycle to decompose_OSLcurve() to improve computing time by factor 3 to 4
#
# ToDo:
# * Enhance documentation with more algorithm info and some F.threshold recommendation
# * Reactivate optional background level fitting
# * Introduce 'fit_OSLcurve.control' which forwards algorithm parameters to DEoptim.control and nls.lm.control
# * Enable optional weighted fitting and give out reduced Chi²
# Internal parameter (for later use in fit_OSLcurve.control)
silent <- TRUE # don't display warnings or not-fatal errors
LM <- TRUE
################### Prepare input data ###########################
if(!inherits(curve, c("RLum.Data.Curve", "data.frame", "matrix"))){
stop("[fit_OSLcurve()] Error: Input object 'curve' is not of type 'RLum.Data.Curve' or 'data.frame' or 'matrix'!")}
if(inherits(curve, "RLum.Data.Curve")) curve <- as.data.frame(Luminescence::get_RLum(curve))
if (!("time" %in% colnames(curve)) |
!("signal" %in% colnames(curve))) {
curve <- data.frame(time = curve[,1],
signal = curve[,2])}
channel_width <- curve$time[2] - curve$time[1]
# check if curve$time beginns with zero and add channel_width if the case
if (curve$time[1] == 0) {
if (verbose) cat("Time axis begins with t_1 = 0, which is not allowed. All time values increased by", channel_width,"\n")
curve$time <- curve$time + channel_width}
# check if any signal = 0 or smaller and set them to 0.1
if (any(curve$signal <= 0)) {
if (verbose) cat("One or more signal values are equal or smaller than zero: Replaced with 0.1\n")
curve$signal[curve$signal <= 0] <- 0.1}
# Some presets ...
K_selected <- 0
X <- c(1:K.max)
RSS <- NA
RSS_old <- Inf
fittings <- list(NULL)
component_tables <- list(NULL)
F_table <- data.frame(NULL)
F_table_print <- data.frame(NULL)
plot_data <- data.frame(NULL)
lambda <- c(NULL)
info_text <- ""
# prepare printed table
if (verbose) cat("\nDecay rates (s^-1):\n")
if (verbose) cat("Cycle \t", paste(paste0(" Comp. ", X), collapse = " "),
" RSS F-value\n")
###################### Subfunction for DE minimisation ###########################################
calc_RSS <- function(lambda_vector, RSScurve = curve){
# The linear part of HELA (see Bluszcz & Adamiec 2006) is performed by decompose_OSLcurve()
RSScomponents <- decompose_OSLcurve(RSScurve,
lambda_vector,
algorithm = "det",
error.estimation = "only.bin.RSS",
verbose = FALSE)
# ... and calculate the residual sum of squares (RSS)
RSS <- sum(RSScomponents$bin.RSS)
if (is.na(RSS) || (RSS <= 0)) RSS <- Inf
return(RSS)
}
###################### Reduced Chi² ###############################################################
# calc_Chi2 <- function(components, CHIcurve = curve, N_curves = M, detec_noise = 0, K = K){
# CHIcurve <- simulate_OSLcomponents(components,
# curve = CHIcurve,
# simulate.curve = FALSE)
# RS <- CHIcurve$residual^2 * N_curves / (abs(curve$sum) + detec_noise)
# Chi2 <- sum(RS) / (length(RS) - K * 2)
# return(Chi2)}
###################### Photo-Ionisation Crosssections #############################################
build_component_table <- function(lambda_vector, lambda_err, BCTcurve = curve){
Y <- 1:length(lambda_vector)
# Calc photon energy: E = h*v [W*s^2 * s^-1 = W*s = J]
E <-6.62606957e-34 * 299792458 * 10^9 / stimulation.wavelength
# Calc photon flux of stimulation light: Flux = I / E [W/cm^2 / W*s = 1/s*cm^2]
Flux <- stimulation.intensity / (E * 1000)
# Calc cross-sections: Sigma = lambda / Flux [s^-1 / 1/s*cm^2 = cm^2]
cross.section <- lambda_vector / Flux
cross.relative <- round(cross.section / cross.section[1], digits=4)
### NAME COMPONENTS ###
# default names:
name <- paste0("Component ", Y)
if ((stimulation.wavelength >= 460) && (stimulation.wavelength <= 485) ) {
# Rename components according to literature
for (i in Y) {
c <- cross.section[i]
# Autonaming uses Table 1 in Durcan & Duller (2011)
# Minimum = lowest value - 2-sigma
# Maximum = highest value + 2-sigma
# Exception are Ultrafast and Slow4 which are not well defined in literature and guessed freely
if ((c > 1e-16) && (c < 1e-15)) name[i] <- "Ultrafast"
if ((c > 1.9e-17) && (c < 3.1e-17)) name[i] <- "Fast"
if ((c > 3e-18) && (c < 9e-18)) name[i] <- "Medium"
if ((c > 1e-18) && (c < 1.85e-18)) name[i] <- "Slow1"
if ((c > 1.1e-19) && (c < 4e-19)) name[i] <- "Slow2"
if ((c > 1e-20) && (c < 4.67e-20)) name[i] <- "Slow3"
if ((c > 1e-21) && (c < 1e-20)) name[i] <- "Slow4"}}
# Check for double-naming
name[duplicated(name)] <- paste0(name[duplicated(name)], ".a")
# Check again
name[duplicated(name)] <- paste0(substr(name[duplicated(name)], 1, nchar(name[duplicated(name)]) - 2), ".b")
# And again
name[duplicated(name)] <- paste0(substr(name[duplicated(name)], 1, nchar(name[duplicated(name)]) - 2), ".c")
# How much is the component bleached during stimulation?
bleaching.grade <- round(1 - exp(- lambda_vector * max(BCTcurve$time)), digits = 4)
# Decay with zero or negative error had no correct error estimation
lambda_err[lambda_err <= 0] <- NA
##### Build result table #####
components <- data.frame(name = name,
lambda = lambda_vector,
lambda.error = lambda_err,
cross.section = cross.section,
cross.relative = cross.relative,
bleaching.grade = bleaching.grade)
row.names(components) <- Y
# Go the easy way to extract additional information from the fitting
components <- decompose_OSLcurve(curve = BCTcurve,
components = components,
verbose = FALSE)
} ### END building tables for the various cases ###
#----------------------------------------------------------------------------------------------------#
#------------------------------------- K = K + 1 cycle ----------------------------------------------#
#----------------------------------------------------------------------------------------------------#
computing_time <- Sys.time()
for (K in X) {
lambda_error <- rep(0, K)
fit <- list()
########################################### DEoptim ##############################################
# Divide the DE parameter space a the decay values of the previous cycle
# Additional constraints:
# - no negative values (decay >= 0)
# - no superfast decays, that the channel frequency couldn't resolve it (decay <= 2 / channel_width)
lower_lambda <- c(lambda, 0)
upper_lambda <- c(2 / channel_width, lambda)
# Perform differential evolution (DE). As minimisation function, use calc_RSS()
DE_min <- try(DEoptim::DEoptim(
fn = calc_RSS,
lower = lower_lambda,
upper = upper_lambda,
control = DEoptim::DEoptim.control(
NP = K * 15,
strategy = 2,
itermax = 80 + K * 10,
c = 0.2,
reltol = 1e-4,
steptol = 10,
parallelType = parallel.computing,
packages = c("OSLdecomposition"),
parVar = c("curve"),
trace = FALSE)),
silent = silent)
# Did the DE algorithm break?
if (methods::is(DE_min)[1] == "try-error"){
if (verbose) cat("-> Differential evolution failed at K =", K, ". Algorithm stopped.\n")
# Discard the current component number
K <- K - 1
# leave loop
break}
# Otherwise, extract results
fit[["DE"]] <- DE_min
lambda <- DE_min$optim$bestmem
RSS <- DE_min$optim$bestval
########################################### nlsLM ##############################################
if (LM) {
# We need the signal intensities of the components as start values for the LM fitting
DE_components <- decompose_OSLcurve(curve,
lambda,
algorithm = "det",
error.estimation = "none",
verbose = FALSE)
n <- DE_components$n
### Create fit formula ###
n.names <- paste0("n.",1:K)
lambda.names <- paste0("lambda.",1:K)
# now create the optimization formula
fit.formula <- stats::formula(paste0("signal ~ ",
paste(n.names," * (exp(-", lambda.names," * (time - ", channel_width,")) - exp(-", lambda.names," * time))",
collapse=" + ")))
# Name the vectors to allow the correct value allocation
names(n) <- n.names
names(lambda) <- lambda.names
### Apply LM algorithm ###
LM_fit <- try(minpack.lm::nlsLM(fit.formula,
data = curve,
start = c(n, lambda),
control = minpack.lm::nls.lm.control(
maxiter = 50 + K * 20)),
silent = silent)
if (methods::is(LM_fit)[1] == "try-error") {
#if (verbose) cat(LM_fit[1])
if (verbose) cat("Levenberg-Marquardt fitting failed at K =", K, ". Differential evolution result:\n")
} else {
fit[["LM"]] <- LM_fit
lambda <- summary(LM_fit)$parameters[paste0("lambda.", 1:K),"Estimate"]
RSS <- LM_fit$m$deviance()
lambda_error <- summary(LM_fit)$parameters[paste0("lambda.", 1:K),"Std. Error"]}}
# save the raw fitting objects
fittings[[K]] <- fit
# identify the components and build the Component table
component_table <- try(build_component_table(lambda, lambda_error),
silent = silent)
if (methods::is(component_table)[1] == "try-error") {
if (verbose) cat(component_table[1])
break
} else {
component_tables[[K]] <- component_table}
# Add values to [plot_Photocrosssections()] ploting table
plot_data <- rbind(plot_data,
data.frame(lambda = lambda,
lambda.low = lambda - lambda_error,
lambda.up = lambda + lambda_error,
name = factor(paste0("Best fit with K = ", K)),
x = K))
#x <- x + 1
### F-test ###
F_value <- 0.5*(RSS_old - RSS) / (RSS / (length(curve$signal) - 2 * K))
RSS_old <- RSS
# Create live console output
table_row <- c(rep(" ", K.max),
formatC(RSS, digits = 4, width = 10),
formatC(F_value, digits = 4, width = 10))
table_row[1:length(lambda)] <- formatC(lambda, digits = 4, width = 10)
if (verbose) cat("K =", K, "\t", paste(table_row, collapse = " "), "\n")
# Build output F-test table for [report_Step2.rmd] script
F_table_print <- rbind(F_table_print, table_row, stringsAsFactors = FALSE)
F_table <- rbind(F_table, (c(lambda[X], RSS, F_value)))
# Stop fitting if K - 1 was the correct model
if (F_value <= F.threshold) {
info_line <- paste0("Left loop because F-test value (F = ", formatC(F_value),
") fell below threshold value (F = ", F.threshold,")\n")
info_text <- paste0(info_text, info_line)
if (verbose) cat(info_line)
break}
# If the current iteration cycle succeed until this point, it must be the best fit so far. Therefore:
K_selected <- K
if (K == K.max) {
info_line <- paste0("Left loop because maximum number of allowed components K is reached\n")
info_text <- paste0(info_text, info_line)
if (verbose) cat(info_line)}
} #---------------------------------------- End cycle -----------------------------------------------
if ((K_selected == 0) | (nrow(F_table_print) == 0)) stop("[fit_OSLcurve] No sucessful fit")
# How long did the algorithm need?
computing_time <- as.numeric(difftime(Sys.time(), computing_time, units = "s"))
# Give F tables approbiate headers
colnames(F_table) <- c(paste0("f_", X),"RSS","F_value")
colnames(F_table_print) <- c(paste0("f_", X),"RSS","F_value")
# Delete unused columns
if (nrow(F_table) < K.max) {
# F_table <- F_table[,-c((nrow(F_table) + 1):K.max)]
F_table_print <- F_table_print[,-c((nrow(F_table) + 1):K.max)]}
# Standard set of components is the on chosen by the F-test
components <- component_tables[[K_selected]]
######### Further console output ######
if (verbose) {
cat(paste0("-> The F-test suggests the K = ", K_selected," model"), "\n")
# Build a nove looking photoionisation cross section table for the console output
cat("\nPhotoionisation cross sections (cm^2):\n")
cat("Cycle \t", sprintf(" %-19s", paste0("Comp. ", X)), "\n")
most_identified <- NULL
for (k in 1:K) {
cross_section <- prettyNum(component_tables[[k]]$cross.section, digits = 3)
name <- component_tables[[k]]$name
identified <- !grepl("ompo", name, fixed = TRUE)
cross_section[identified] <- paste0(cross_section[identified], " (", name[identified], ")")
cat("K =", k, "\t", sprintf("%-20s", cross_section), "\n")
most_identified <- c(most_identified, sum(identified) - k/100)}
if (sum(most_identified) > 0) {
cat(paste0("-> Most known quartz OSL components found in the K = ",
which(max(most_identified) == most_identified)," model"), "\n")}
}
######################### Return values #######################
if (output.complex) {
algorithm <- "DE"
if (LM) algorithm <- "DE+LM"
output <- list(decay.rates = components$lambda,
K.selected = K_selected,
F.test = F_table,
F.test.print = F_table_print,
info.text = info_text,
component.tables = component_tables,
curve = curve,
components = components,
fit.results = fittings,
plot.data = plot_data,
parameters = list(K.max = K.max,
F.threshold = F.threshold,
stimulation.intensity = stimulation.intensity,
stimulation.wavelength = stimulation.wavelength,
algorithm = algorithm,
computing.time.s = computing_time))
invisible(output)
} else {
invisible(components)}
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.