R/rational_wl2.R

Defines functions wl2_operator_type wl2_cgeneric_table update_rational_coefficients wl2_nodal_correction wl2_diag_inverse wl2_roots wl2_polymul rspde.xmin wl2_lambda_max wl2_domain_diameter wl2_interp_coefficients wl2_clear_cache wl2_coefficient_table wl2_restart_starts wl2_theta_canonical wl2_shipped_table wl2_table_attrs wl2_mesh_dim rspde.wl2.table wl2_cache_write wl2_cache_read wl2_cache_match wl2_cache_paths wl2_xmin_decode wl2_xmin_encode wl2_cache_dir rspde.cache wl2_check_table wl2_alpha_grid wl2_symbol wl2_fit wl2_insert_pole rational.coefficients.wl2 wl2_starts wl2_basis wl2_block_builder wl2_operator_powers wl2_mass_powers wl2_block_poly wl2_q wl2_lm_run wl2_have_cpp wl2_lm wl2_resjac wl2_nnls wl2_ls wl2_weyl_cells

Documented in rational.coefficients.wl2 rspde.cache rspde.wl2.table rspde.xmin update_rational_coefficients

## Weighted-L2 rational coefficients with spectral interval.
##
## Rational approximations of x^target on [x_min, 1] (x = 1/lambda, lambda the
## eigenvalues of L_h = kappa^{-2}(kappa^2 C + G) relative to C, so lambda >= 1)
## that minimise the weighted L2 error with the Weyl weight
##     w_d(x) = x^(-1-d/2) (1-x)^(d/2-1).
##
## The fit is done by variable projection: the poles are the outer parameters
## (optimised by Levenberg-Marquardt) and the residues are obtained by
## non-negative least squares, so that every returned set of coefficients
## defines a valid (positive definite) model.
#

#' @name wl2_weyl_cells
#' @title Nodes and cell integrals of the Weyl weight
#' @description Log-equispaced nodes on \[x_min, 1\] together with the integrals
#' of the Weyl density over the corresponding cells, computed with a
#' sub-midpoint rule in u = log(x). The weight is never evaluated at x = 1,
#' where it is singular for d = 1.
#' @param d Dimension.
#' @param x_min Lower end of the interval.
#' @param n_grid Number of nodes.
#' @param n_sub Number of sub-intervals used for each cell integral.
#' @return A list with elements `x` (nodes) and `w` (cell integrals).
#' @noRd
wl2_weyl_cells <- function(d, x_min, n_grid = 300, n_sub = 40, s = 0) {
  lx <- seq(log(x_min), 0, length.out = n_grid)
  ed <- c(lx[1], (lx[-1] + lx[-n_grid]) / 2, 0)
  w <- numeric(n_grid)
  for (i in seq_len(n_grid)) {
    e <- seq(ed[i], ed[i + 1], length.out = n_sub + 1)
    xm <- exp((e[-1] + e[-(n_sub + 1)]) / 2)
    ## The integral is taken in log x, so the Weyl density w_d(x) appears
    ## multiplied by x; the data weight x^s shifts the same exponent.
    w[i] <- sum(xm^(s - d / 2) * (1 - xm)^(d / 2 - 1)) * (e[2] - e[1])
  }
  list(x = exp(lx), w = w)
}

#' @name wl2_ls
#' @title Least squares solution tolerating rank deficiency
#' @description Aliased columns get coefficient zero instead of `NA`. The rank
#' tolerance has to be far tighter than the default of `.lm.fit()`: the columns
#' span poles over six orders of magnitude and are strongly correlated, and at
#' the default 1e-7 the last columns are declared aliased and their residues
#' set to zero, which silently throws away terms of the approximation.
#'
#' @param A Design matrix.
#' @param b Right hand side.
#' @return The least squares coefficients.
#' @noRd
wl2_ls <- function(A, b) {
  cf <- .lm.fit(A, b, tol = 1e-14)$coefficients
  cf[!is.finite(cf)] <- 0
  cf
}

#' @name wl2_nnls
#' @title Non-negative least squares 
#' @description Minimises `||A x - b||` subject to `x >= 0`. Written for the
#' small problems (at most eight columns) arising in the variable projection,
#' so that no external NNLS dependency is needed.
#' @param A Design matrix.
#' @param b Right hand side.
#' @return The non-negative least squares solution.
#' @noRd
wl2_nnls <- function(A, b) {
  n <- ncol(A)
  if (n == 0) {
    return(numeric(0))
  }
  ## The columns span several orders of magnitude, because the poles do: for
  ## m = 6 the norm of the last column is a millionth of the first. The
  ## optimality test compares gradients across columns, so it is only
  ## meaningful once the columns are on a common scale. Without this the
  ## algorithm stops before the last columns are brought in, and silently
  ## drops terms of the approximation. The scaling is positive and diagonal,
  ## so the non-negativity constraints are unchanged.
  scl <- sqrt(base::colSums(A^2))
  scl[scl <= 0] <- 1
  A <- A / rep(scl, each = nrow(A))
  x <- numeric(n)
  P <- logical(n)
  w <- drop(base::crossprod(A, b))
  tol <- 10 * .Machine$double.eps * max(dim(A)) * max(abs(w))
  it <- 0
  maxit <- 10 * n
  while (any(!P) && max(w[!P]) > tol && it < maxit) {
    it <- it + 1
    idx <- which(!P)
    P[idx[which.max(w[idx])]] <- TRUE
    inner <- 0
    repeat {
      inner <- inner + 1
      s <- numeric(n)
      s[P] <- wl2_ls(A[, P, drop = FALSE], b)
      if (min(s[P]) >= 0) {
        x <- s
        break
      }
      neg <- P & (s < 0)
      ratios <- x[neg] / (x[neg] - s[neg])
      step <- min(ratios[is.finite(ratios)], 1)
      x <- x + step * (s - x)
      P <- P & (x > 10 * .Machine$double.eps * max(abs(x)))
      x[!P] <- 0
      if (!any(P) || inner > maxit) {
        break
      }
    }
    w <- drop(base::crossprod(A, b - base::`%*%`(A, x)))
  }
  x / scl
}

#' @name wl2_resjac
#' @title Residual and Golub-Pereyra Jacobian of the variable projection
#' @description The residual is `r(theta) = A(theta) c - f` with `c` the
#' non-negative least squares solution. On the passive set P the coefficients
#' are `c_P = A_P^+ f`, so `r(theta) = -(I - P_A) f` and
#' \deqn{\partial r/\partial\theta_k = (I - P_A) (\partial A_P/\partial\theta_k) c_P
#'   + (A_P^+)^T (\partial A_P/\partial\theta_k)^T (-r),}
#' the Golub-Pereyra derivative. With `u_j = x/(1 + (b_j - 1)x)` and
#' `b_j = exp(theta_j)` the column derivative is `-b_j u_j^2`, and for the
#' shifted classes the integer factor `g^q` with
#' `g = x/(1 + (b_0 - 1)x)` and `theta_1 = logit(b_0)` moves every column
#' through `d(g^q)/dtheta_1 = -q g^(q+1) b_0 (1 - b_0)`. Columns whose residue
#' is zero do not enter, so their derivative is zero.
#' @param kind `"plain"`, `"shifted"` or `"shifted2"`; see `wl2_q()`.
#' @param x,sw Quadrature nodes and the square roots of the cell integrals.
#' @param f The weighted target.
#' @param theta The outer parameters.
#' @param need_jac Compute the Jacobian, or only the residual?
#' @return A list with `r`, `c` and, if requested, `J`.
#' @noRd
wl2_resjac <- function(kind, x, sw, f, theta, need_jac = TRUE,
                       k_term = FALSE) {
  mm <- base::`%*%`
  q <- wl2_q(kind)
  shifted <- q > 0
  b <- exp(pmin(pmax(if (shifted) theta[-1] else theta, -5), 60))
  n <- length(x)
  M <- length(b)
  U <- matrix(x / (1 + rep(b - 1, each = n) * x), n, M)
  if (shifted) {
    b0 <- 1 / (1 + exp(-theta[1]))
    g <- x / (1 + (b0 - 1) * x)
    A <- (g^q * U) * sw
  } else {
    A <- U * sw
  }
  if (k_term) {
    A <- cbind(A, sw)
  }
  cc <- wl2_nnls(A, f)
  r <- drop(mm(A, cc)) - f
  if (!need_jac) {
    return(list(r = r, c = cc))
  }
  keep <- cc > 0
  if (!any(keep)) {
    return(list(r = r, c = cc, J = matrix(0, n, length(theta))))
  }
  ## base::qr, not the S4 generic of Matrix: these are plain matrices and the
  ## dispatch costs more than the factorisation. Q is formed once and reused
  ## for every parameter; going through qr.resid()/qr.qy() per parameter
  ## instead is 2.7 times slower, since each does a full pass over the
  ## factorisation.
  qrA <- base::qr(A[, keep, drop = FALSE])
  Q <- base::qr.Q(qrA)
  R <- base::qr.R(qrA)
  J <- matrix(0, n, length(theta))
  for (k in seq_along(theta)) {
    D <- matrix(0, n, ncol(A))
    if (shifted && k == 1) {
      ## d(g^q)/dtheta_0 = q g^(q-1) dg/dtheta_0 = -q g^(q+1) b_0 (1 - b_0)
      D[, seq_len(M)] <- ((-q * g^(q + 1) * b0 * (1 - b0)) * U) * sw
    } else {
      j <- if (shifted) k - 1 else k
      D[, j] <- (if (shifted) g^q else 1) * (-b[j] * U[, j]^2) * sw
    }
    v <- drop(mm(D, cc))
    t1 <- v - mm(Q, base::crossprod(Q, v))
    z <- drop(base::crossprod(D[, keep, drop = FALSE], -r))
    J[, k] <- t1 + mm(Q, backsolve(R, z, transpose = TRUE))
  }
  list(r = r, c = cc, J = J)
}

#' @name wl2_lm
#' @title Levenberg-Marquardt minimisation of a residual vector
#' @description Minimises `sum(res(theta)^2)` with a forward-difference
#' Jacobian. Used for the outer (pole) problem, which has at most eight
#' parameters.
#' @param res Function returning the residual vector.
#' @param theta Starting value.
#' @param ftol,xtol Relative tolerances on the objective and on the step.
#' @param maxfev Maximum number of evaluations of `res`.
#' @param maxiter Maximum number of Levenberg-Marquardt iterations.
#' @return A list with elements `theta`, `cost` (half the sum of squares) and
#' `nfev`.
#' @noRd
wl2_lm <- function(res, theta, ftol = wl2_tol, xtol = wl2_tol,
                   maxfev = 3000, maxiter = 400) {
  np <- length(theta)
  ev <- res(theta, TRUE)
  analytic <- !is.null(ev$J)
  r <- ev$r
  f <- sum(r^2)
  nfev <- 1
  lambda <- 1e-3
  J <- matrix(0, length(r), np)
  for (iter in seq_len(maxiter)) {
    if (analytic) {
      J <- ev$J
    } else {
      h <- sqrt(.Machine$double.eps) * pmax(abs(theta), 1)
      for (j in seq_len(np)) {
        tj <- theta
        tj[j] <- tj[j] + h[j]
        J[, j] <- (res(tj, FALSE)$r - r) / h[j]
        nfev <- nfev + 1
      }
    }
    if (nfev > maxfev) {
      break
    }
    JtJ <- crossprod(J)
    Jtr <- as.vector(crossprod(J, r))
    dg <- diag(JtJ)
    dg <- pmax(dg, 1e-10 * max(dg, 1e-300))
    accepted <- FALSE
    while (lambda < 1e14) {
      step <- tryCatch(
        as.vector(solve(JtJ + lambda * diag(dg, np), -Jtr)),
        error = function(e) NULL
      )
      if (is.null(step) || any(!is.finite(step))) {
        lambda <- lambda * 10
        next
      }
      theta_new <- theta + step
      ev_new <- res(theta_new, analytic)
      r_new <- ev_new$r
      f_new <- sum(r_new^2)
      nfev <- nfev + 1
      if (is.finite(f_new) && f_new < f) {
        df <- f - f_new
        dx <- sqrt(sum(step^2))
        theta <- theta_new
        ev <- ev_new
        r <- r_new
        f <- f_new
        lambda <- max(lambda / 10, 1e-12)
        accepted <- TRUE
        if (df <= ftol * f || dx <= xtol * (sqrt(sum(theta^2)) + xtol)) {
          return(list(theta = theta, cost = f / 2, nfev = nfev))
        }
        break
      }
      lambda <- lambda * 10
      if (nfev > maxfev) {
        break
      }
    }
    if (!accepted || nfev > maxfev) {
      break
    }
  }
  list(theta = theta, cost = f / 2, nfev = nfev)
}

#' @name wl2_tol
#' @title Convergence tolerance of the outer minimisation
#' @description Relative tolerance on the objective and on the step in the
#' Levenberg-Marquardt loop. The objective is flat near its minimum, so the
#' last digits of the poles are worth nothing in the error they produce:
#' loosening this from 1e-13 to 1e-10 leaves the weighted-L2 error of every
#' fit in the tables unchanged to five significant digits, and the reference
#' coefficients unchanged to six, while cutting the cost of building a table
#' by a third.
#' @noRd
wl2_tol <- 1e-10

#' @name wl2_have_cpp
#' @title Is the compiled weighted-L2 solver available?
#' @description The Levenberg-Marquardt loop, the variable projection and the
#' non-negative least squares are also implemented in C (`src/wl2_fit.cpp`),
#' which is part of every build. The R implementation is kept as a reference
#' and as a fallback, and is selected by setting the option
#' `rSPDE.wl2.use.cpp` to `FALSE`.
#' @return `TRUE` if the compiled routine should be used.
#' @noRd
wl2_have_cpp <- function() {
  if (!isTRUE(getOption("rSPDE.wl2.use.cpp", TRUE))) {
    return(FALSE)
  }
  if (is.null(wl2_cache$have_cpp)) {
    wl2_cache$have_cpp <- is.character(tryCatch(
      getNativeSymbolInfo("rspde_wl2_lm", PACKAGE = "rSPDE")$name,
      error = function(e) NULL
    ))
  }
  wl2_cache$have_cpp
}

#' @name wl2_lm_run
#' @title Run the outer minimisation, in C if possible
#' @description Dispatches to the compiled solver, falling back to the R
#' implementation if it is not available or if it fails. The two give the same
#' answer to roundoff; see the tests.
#' @param kind One of "plain", "shifted" or "shifted2"; see `wl2_q()`.
#' @param x,sw,f Nodes, square roots of the cell integrals, weighted target.
#' @param theta0 Starting value.
#' @param tol Relative tolerance on the objective and on the step; see
#' `wl2_tol`.
#' @return A list with `theta` and `cost`, as `wl2_lm()`.
#' @noRd
wl2_lm_run <- function(kind, x, sw, f, theta0, tol = wl2_tol,
                       k_term = FALSE) {
  if (wl2_have_cpp()) {
    o <- tryCatch(
      .Call("rspde_wl2_lm", as.double(x), as.double(sw), as.double(f),
        as.double(theta0), as.integer(wl2_q(kind)), k_term,
        as.double(c(tol, tol, 3000, 400)),
        PACKAGE = "rSPDE"
      ),
      error = function(e) NULL
    )
    if (!is.null(o) && all(is.finite(o$theta)) && is.finite(o$cost)) {
      return(o)
    }
  }
  wl2_lm(function(theta, need_jac = TRUE) {
    wl2_resjac(kind, x, sw, f, theta, need_jac, k_term)
  }, theta0, ftol = tol, xtol = tol)
}

#' @name wl2_q
#' @title Power of the integer factor of a class
#' @description The covariance classes are
#' \eqn{r(x) = (x/(1-p_0x))^q \sum_j r_j x/(1-p_jx)} with `q` equal to
#' \eqn{\lfloor\alpha\rfloor}. `q = 0` is the plain class, which has no
#' shift parameter at all; `q = 1` and `q = 2` share one shift `p_0` across the
#' whole factor. A separate shift per factor was tried and is not worth it: at
#' `q = 2` two free shifts match one shared shift to between 0.98 and 1.00 of
#' the error over the cases tested, the shared one being a stationary point of
#' the larger problem.
#' @param kind `"plain"`, `"shifted"` or `"shifted2"`.
#' @return 0, 1 or 2.
#' @noRd
wl2_q <- function(kind) {
  switch(kind, plain = 0L, shifted = 1L, shifted2 = 2L,
    stop("unknown class: ", kind)
  )
}

#' @name wl2_block_poly
#' @title Polynomial of a weighted-L2 precision block
#' @description The block of the precision matrix belonging to the pole
#' \eqn{p_i} is
#' \eqn{(L - p_0C)C^{-1}\cdots(L - p_0C)C^{-1}(L - p_iC)/r_i}, with the
#' shifted factor repeated `q` times. Writing \eqn{T = C^{-1}L} this is
#' \eqn{C(T-p_0)^q(T-p_i)/r_i}, so the block is a linear combination of the
#' matrices \eqn{P_j = CT^j}, `j` from 0 to `q + 1`. This returns the
#' coefficients of that combination, before the division by \eqn{r_i}.
#' @param q The power of the integer factor; see `wl2_q()`.
#' @param p0 The shared shift, unused when `q` is 0.
#' @param p_i The pole of the block.
#' @return A numeric vector of length `q + 2`, the coefficient of \eqn{P_j}
#' in position `j + 1`.
#' @noRd
wl2_block_poly <- function(q, p0, p_i) {
  cf <- 1
  ## Multiply by (T - p_0) q times, then once by (T - p_i). Coefficients are
  ## in increasing order of the power, so multiplying by (T - a) shifts and
  ## subtracts.
  for (l in seq_len(q)) {
    cf <- c(0, cf) - p0 * c(cf, 0)
  }
  c(0, cf) - p_i * c(cf, 0)
}

#' @name wl2_mass_powers
#' @title The matrices \eqn{CT^j} of a finite element discretisation
#' @description With \eqn{L = C + G/\kappa^2} and \eqn{T = C^{-1}L},
#' \eqn{P_j = CT^j = \sum_{l=0}^j \binom{j}{l} G_l/\kappa^{2l}}, where
#' \eqn{G_0 = C}, \eqn{G_1 = G} and \eqn{G_l = GC^{-1}G_{l-1}}. These are
#' exactly the matrices the finite element assembly provides, so the blocks
#' need no inverse of the mass matrix.
#' @param q The power of the integer factor; powers up to `q + 1` are returned.
#' @param kappa The range parameter.
#' @param Gl A function of one argument returning \eqn{G_l}; `l = 0` must give
#' the mass matrix.
#' @return A list of length `q + 2`, with \eqn{P_j} in position `j + 1`.
#' @noRd
wl2_mass_powers <- function(q, kappa, Gl) {
  lapply(0:(q + 1L), function(j) {
    out <- Gl(0L)
    for (l in seq_len(j)) {
      out <- out + choose(j, l) * Gl(l) / kappa^(2 * l)
    }
    out
  })
}

#' @name wl2_operator_powers
#' @title The matrices \eqn{CT^j} from the operator matrices themselves
#' @description As `wl2_mass_powers()`, but forming
#' \eqn{P_j = L(C^{-1}L)^{j-1}} by explicit products, for the paths where the
#' higher finite element matrices \eqn{G_2, G_3, \ldots} are not assembled.
#' @param q The power of the integer factor; powers up to `q + 1` are returned.
#' @param L The shifted stiffness matrix \eqn{C + G/\kappa^2}.
#' @param C0 The mass matrix.
#' @param Ci The inverse of the mass matrix, usually the lumped one.
#' @return A list of length `q + 2`, with \eqn{P_j} in position `j + 1`.
#' @noRd
wl2_operator_powers <- function(q, L, C0, Ci) {
  P <- vector("list", q + 2L)
  P[[1]] <- C0
  P[[2]] <- L
  for (j in seq_len(q)) {
    P[[j + 2L]] <- P[[j + 1L]] %*% Ci %*% L
  }
  P
}

#' @name wl2_block_builder
#' @title Precision blocks of a weighted-L2 approximation
#' @description Combines the powers \eqn{P_j} of `wl2_mass_powers()` or
#' `wl2_operator_powers()` with the coefficients of `wl2_block_poly()` into a
#' function of the block index.
#' @param q The power of the integer factor; see `wl2_q()`.
#' @param P The powers, as returned by `wl2_mass_powers()`.
#' @param r,p The residues and poles.
#' @param p0 The shared shift, `NULL` when `q` is 0.
#' @return A function of `i` returning the `i`th block.
#' @noRd
wl2_block_builder <- function(q, P, r, p, p0) {
  function(i) {
    cf <- wl2_block_poly(q, p0, p[i])
    out <- cf[1] * P[[1]]
    for (j in seq_along(cf)[-1]) {
      out <- out + cf[j] * P[[j]]
    }
    out / r[i]
  }
}

#' @name wl2_basis
#' @title Basis matrix of the rational class
#' @param kind `"plain"`, `"shifted"` or `"shifted2"`; see `wl2_q()`.
#' @param x Nodes.
#' @param theta Outer parameters: log(b) for "plain", and logit(b_0) followed by
#' log(b) for the shifted classes.
#' @return The basis matrix, with one column per term.
#' @noRd
wl2_basis <- function(kind, x, theta, k_term = FALSE) {
  q <- wl2_q(kind)
  b <- exp(pmin(pmax(if (q > 0) theta[-1] else theta, -5), 60))
  n <- length(x)
  A <- matrix(x / (1 + rep(b - 1, each = n) * x), n, length(b))
  if (q > 0) {
    b0 <- 1 / (1 + exp(-theta[1]))
    A <- (x / (1 + (b0 - 1) * x))^q * A
  }
  if (k_term) {
    ## The constant term is the last column, so that the residues keep their
    ## positions; it is non-negative like the rest.
    A <- cbind(A, rep(1, n))
  }
  A
}

#' @name wl2_starts
#' @title Starting values for the outer problem
#' @noRd
wl2_starts <- function(M, x_min, n_starts, kind, start, seed) {
  Lp <- min(log(1 / x_min), 2.2 * M + 6)
  inits <- list()
  if (!is.null(start)) {
    inits[[length(inits) + 1]] <- as.numeric(start)
  }
  inits[[length(inits) + 1]] <- seq(0.05 * Lp, Lp, length.out = M)
  if (n_starts > 1) {
    ## A local RNG stream, so that the coefficients are reproducible and the
    ## user's random number stream is left untouched.
    if (!exists(".Random.seed", envir = globalenv(), inherits = FALSE)) {
      stats::runif(1)
    }
    old_seed <- get(".Random.seed", envir = globalenv(), inherits = FALSE)
    on.exit(assign(".Random.seed", old_seed, envir = globalenv()), add = TRUE)
    set.seed(seed)
    for (i in seq_len(n_starts - 1)) {
      inits[[length(inits) + 1]] <- sort(stats::runif(M, 0, Lp + 1))
    }
  }
  if (wl2_q(kind) > 0) {
    inits <- lapply(inits, function(t) if (length(t) == M) c(1.5, t) else t)
  }
  inits
}

#' Weighted-L2 rational coefficients
#'
#' Computes the coefficients of a rational approximation of \eqn{x^{\alpha}}
#' (covariance type) or \eqn{x^{\alpha/2}} (operator type) on the interval
#' \eqn{[x_{\min}, 1]}, minimising the \eqn{L_2} error weighted by the Weyl
#' density \eqn{w_d(x) = x^{-1-d/2}(1-x)^{d/2-1}}.
#'
#' Here \eqn{x = 1/\lambda}, where \eqn{\lambda} are the eigenvalues of
#' \eqn{L_h = \kappa^{-2}(\kappa^2 C + G)} relative to \eqn{C}, so that
#' \eqn{\lambda \ge 1} and \eqn{x \in (0, 1]}. The value \eqn{x_{\min}}
#' corresponds to the largest eigenvalue, i.e. to the resolution of the mesh;
#' `x_min = NULL` gives the mesh-free fit on \eqn{(0,1]}.
#'
#' The classes are parameterised so that the resulting model is guaranteed to
#' be valid (all residues are non-negative and all poles are smaller than one).
#' For the covariance type the class carries an integer factor of order
#' \eqn{q = \lfloor\alpha\rfloor}, with one shift \eqn{p_0} shared by its
#' factors:
#' \describe{
#' \item{covariance}{\eqn{r(x) = (x/(1-p_0 x))^q\sum_{j=1}^m r_j x/(1-p_j x)},
#' for \eqn{q = 0}, 1 and 2, the factor being absent when \eqn{q = 0}.}
#' \item{operator}{\eqn{r(x) = \sum_{i=1}^{m+1} r_i x/(1-p_i x)}, approximating
#' \eqn{x^{\alpha/2}}.}
#' }
#' In contrast to the tabulated `"brasil"`, `"chebfun"` and `"chebfunLB"`
#' coefficients, there is no constant term, so that the covariance-based models
#' built from these coefficients have \eqn{m} instead of \eqn{m+1} blocks.
#'
#' The fit is a variable projection: the poles are found by
#' Levenberg-Marquardt, with the residues eliminated by non-negative least
#' squares at every step. This inner loop is compiled (`src/wl2_fit.cpp`) and
#' is a few times faster than the equivalent R code, which is kept as a
#' reference implementation and is used instead when the option
#' `rSPDE.wl2.use.cpp` is set to `FALSE`.
#'
#' @param alpha The exponent, \eqn{\alpha = \nu + d/2}. For the covariance type,
#' \eqn{\lfloor \alpha\rfloor} must be 0, 1 or 2; for the operator type,
#' \eqn{\alpha} must be smaller than 2.
#' @param d The dimension of the domain.
#' @param m The order of the rational approximation.
#' @param x_min Lower end of the spectral interval, or `NULL` for the mesh-free
#' fit on \eqn{(0,1]}. See [rspde.xmin()].
#' @param type Either `"covariance"` or `"operator"`.
#' @param n_starts Number of starting values for the outer optimisation. Eight
#' starts are needed for `m = 4`; for `m` at most 3 a single start suffices.
#' @param n_grid Number of quadrature nodes.
#' @param start Optional starting value for the outer parameters, for instance
#' the `theta` element returned by a fit for a neighbouring value of `alpha`.
#' @param seed Seed of the local random number stream used for the random
#' starting values. The user's random number stream is not affected.
#' @param s Exponent of an extra factor \eqn{x^s} in the weight, on top of the
#' Weyl density. `s = 0`, the default, is the weight for which the objective is
#' the \eqn{L_2} error of the covariance. Larger `s` de-emphasises the small
#' eigenvalues, and corresponds to measuring the error of the solution operator
#' against data in \eqn{H^s} rather than in \eqn{L_2}.
#' @param k_term Should a constant term be included, as in the tabulated
#' coefficients? It is fitted, non-negative, and returned as `k`. The default
#' `FALSE` is what the covariance-based models need, since a constant term
#' makes the trace infinite. It only makes sense together with a finite
#' `x_min`: on \eqn{(0,1]} with `s` below \eqn{d/2} the objective is infinite
#' for any non-zero constant, and the fit drives it to zero.
#' @param continuation Should the fit use continuation in `m`, solving for
#' `1, ..., m` and starting each order from the poles of the previous one with
#' one pole inserted? This is needed beyond `m = 4`, where a plain multistart
#' tends to settle at the optimum of the next smaller order. Ignored when
#' `start` is supplied.
#'
#' @return A list with elements
#' \item{r}{The residues, all non-negative.}
#' \item{p}{The poles, all smaller than one.}
#' \item{p0}{The shift of the integer factor, or `NULL` if
#' \eqn{\lfloor\alpha\rfloor = 0}.}
#' \item{q}{The power of the integer factor, \eqn{\lfloor\alpha\rfloor} for
#' the covariance type and 0 for the operator type.}
#' \item{k}{The constant term: 0 unless `k_term` is `TRUE`.}
#' \item{rel_err}{The relative weighted \eqn{L_2} error of the fit.}
#' \item{x_min}{The lower end of the interval that was used.}
#' \item{theta}{The internal parameters, for warm starts.}
#' \item{kind}{`"plain"`, `"shifted"` or `"shifted2"`, for `q` equal to 0, 1
#' and 2.}
#' @export
#' @seealso [rspde.xmin()], [matern.operators()]
#' @examples
#' cf <- rational.coefficients.wl2(alpha = 0.75, d = 1, m = 2)
#' cf$rel_err
rational.coefficients.wl2 <- function(alpha, d, m, x_min = NULL,
                                      type = c("covariance", "operator"),
                                      n_starts = 8, n_grid = 300,
                                      start = NULL, seed = 1L,
                                      continuation = TRUE,
                                      s = 0, k_term = FALSE) {
  type <- match.arg(type)
  if (length(s) != 1 || !is.finite(s)) {
    stop("s must be a single finite number.")
  }
  k_term <- isTRUE(k_term)
  if (continuation && is.null(start) && m > 1) {
    ## Continuation in m. A plain multistart is not reliable beyond m = 4: the
    ## optimiser then tends to settle at the optimum of the next smaller order,
    ## with one residue exactly zero, which wastes a block. Starting from the
    ## poles of the (m-1)-fit with one pole inserted at each gap finds the
    ## global optimum in those cases, and reproduces the multistart result
    ## where that already found it.
    prev <- NULL
    for (mm in seq_len(m)) {
      best <- wl2_fit(
        alpha, d, mm, x_min, type, n_starts, n_grid, NULL, seed, s, k_term
      )
      if (!is.null(prev)) {
        for (st in wl2_insert_pole(prev$theta, !is.null(prev$p0))) {
          cf <- wl2_fit(
            alpha, d, mm, x_min, type, 1L, n_grid, st, seed, s, k_term
          )
          if (cf$rel_err < best$rel_err) {
            best <- cf
          }
        }
      }
      prev <- best
    }
    return(prev)
  }
  wl2_fit(alpha, d, m, x_min, type, n_starts, n_grid, start, seed, s, k_term)
}

#' @name wl2_insert_pole
#' @title Starting values obtained by adding one pole to a fit
#' @param theta The internal parameters of a fit of order m - 1.
#' @param shifted Does the class have a shift, that is, is `q` positive?
#' @return A list of starting values of order m, one per insertion point.
#' @noRd
wl2_insert_pole <- function(theta, shifted) {
  lb <- sort(if (shifted) theta[-1] else theta)
  n <- length(lb)
  gaps <- c(lb[1] - 2, if (n > 1) (lb[-1] + lb[-n]) / 2, lb[n] + 2)
  lapply(gaps, function(g) {
    nb <- sort(c(lb, g))
    if (shifted) c(theta[1], nb) else nb
  })
}

#' @name wl2_fit
#' @title A single weighted-L2 fit
#' @description The fit itself; [rational.coefficients.wl2()] adds the
#' continuation in m.
#' @noRd
wl2_fit <- function(alpha, d, m, x_min, type, n_starts, n_grid, start, seed,
                    s = 0, k_term = FALSE) {
  if (m < 1) {
    stop("m must be a positive integer!")
  }
  if (type == "operator") {
    target <- alpha / 2
    M <- m + 1
    kind <- "plain"
    if (target >= 1) {
      stop("For type = 'operator', alpha must be smaller than 2.")
    }
  } else {
    m_alpha <- floor(alpha)
    if (!(m_alpha %in% c(0, 1, 2))) {
      stop(paste0(
        "The weighted-L2 coefficients are only implemented for ",
        "floor(alpha) equal to 0, 1 or 2, but floor(alpha) = ", m_alpha, "."
      ))
    }
    target <- alpha
    M <- m
    kind <- c("plain", "shifted", "shifted2")[m_alpha + 1]
  }
  ## Near x = 0 the integrand is e(x)^2 x^(s - 1 - d/2) with e(x) ~ x^target,
  ## so the objective is finite when 2 * target + s > d / 2. With the data
  ## weight x^s this is weaker than the finite-trace condition s = 0.
  if (2 * target + s <= d / 2) {
    stop(paste0(
      "The fit requires 2 * target + s > d / 2, i.e. a finite objective; ",
      "here 2 * ", signif(target, 4), " + ", signif(s, 4), " <= ", d / 2, "."
    ))
  }

  if (is.null(x_min)) {
    x_min <- 1e-26
    n_grid <- max(n_grid, 900)
  }

  cells <- wl2_weyl_cells(d, x_min, n_grid, s = s)
  x <- cells$x
  sw <- sqrt(cells$w)
  f <- x^target * sw

  best <- NULL
  for (theta0 in wl2_starts(M, x_min, n_starts, kind, start, seed)) {
    o <- tryCatch(wl2_lm_run(kind, x, sw, f, theta0, k_term = k_term),
      error = function(e) NULL
    )
    if (!is.null(o) && (is.null(best) || o$cost < best$cost)) {
      best <- o
    }
  }
  if (is.null(best)) {
    stop("The rational approximation could not be computed.")
  }

  theta <- best$theta
  A <- wl2_basis(kind, x, theta, k_term) * sw
  cc <- wl2_nnls(A, f)
  k <- if (k_term) cc[length(cc)] else 0
  r <- if (k_term) cc[-length(cc)] else cc
  ## A zero residue would give an infinite precision for that block.
  r <- pmax(r, .Machine$double.eps * max(r))
  qq <- wl2_q(kind)
  b0 <- if (qq == 0) NULL else 1 / (1 + exp(-theta[1]))
  b <- exp(pmin(pmax(if (qq == 0) theta else theta[-1], -5), 60))
  o <- order(b)

  list(
    r = r[o], p = 1 - b[o], p0 = if (is.null(b0)) NULL else 1 - b0,
    q = qq, k = k,
    rel_err = sqrt(2 * best$cost / sum(cells$w * x^(2 * target))),
    x_min = x_min, theta = theta, kind = kind,
    alpha = alpha, d = d, m = m, type = type, s = s, k_term = k_term
  )
}

#' @name wl2_symbol
#' @title Evaluate the rational symbol of a weighted-L2 fit
#' @param cf Output of [rational.coefficients.wl2()].
#' @param x Points in (0, 1] at which to evaluate the symbol.
#' @return The values of the rational function.
#' @noRd
wl2_symbol <- function(cf, x) {
  s <- rowSums(outer(x, seq_along(cf$r), function(xx, i) {
    cf$r[i] * xx / (1 - cf$p[i] * xx)
  }))
  if (!is.null(cf$p0)) {
    q <- if (is.null(cf$q)) 1L else cf$q
    s <- (x / (1 - cf$p0 * x))^q * s
  }
  k <- if (is.null(cf$k)) 0 else cf$k
  s + k
}

#' @name wl2_alpha_grid
#' @title Grid of alpha values used for the weighted-L2 coefficient tables
#' @param m_alpha The integer part of alpha (covariance type only).
#' @param d Dimension.
#' @param type "covariance" or "operator".
#' @param by Resolution of the grid.
#' @return A vector of alpha values.
#' @noRd
wl2_alpha_grid <- function(m_alpha, d, type, by = 0.01) {
  alpha <- if (type == "operator") {
    seq(by, 2 - by, by = by)
  } else {
    m_alpha + seq(by, 1 - by, by = by)
  }
  ## alpha = nu + d / 2 with nu > 0, and the fit needs a finite trace
  alpha[alpha > d / 2 + by / 2]
}

#' @name wl2_check_table
#' @title Is a supplied table the right one for this model?
#' @description A table is fitted for one dimension, one order and one unit
#' interval of `alpha`, and using the wrong one would silently give a model
#' that is not the one asked for.
#' @param tab The table.
#' @param type,d,m,alpha What the model needs.
#' @return No return value; called for the error it raises.
#' @noRd
wl2_check_table <- function(tab, type, d, m, alpha) {
  if (!is.data.frame(tab) || is.null(attr(tab, "type"))) {
    stop("wl2_table is not a table produced by rspde.wl2.table().")
  }
  if (isTRUE(attr(tab, "k_term"))) {
    stop(paste0(
      "wl2_table was fitted with a constant term, which the covariance-based ",
      "construction has no block for. Such tables are for studying the ",
      "approximation, not for building a model."
    ))
  }
  got <- c(
    type = attr(tab, "type"), d = attr(tab, "d"), m = attr(tab, "m")
  )
  want <- c(type = type, d = d, m = m)
  bad <- names(want)[unlist(got[names(want)]) != unlist(want)]
  if (length(bad)) {
    stop(sprintf(
      "wl2_table was built for %s, but the model needs %s.",
      paste(sprintf("%s = %s", bad, unlist(got[bad])), collapse = ", "),
      paste(sprintf("%s = %s", bad, unlist(want[bad])), collapse = ", ")
    ))
  }
  rng <- range(tab$alpha)
  if (alpha < floor(rng[1]) || alpha > ceiling(rng[2])) {
    stop(sprintf(
      paste0(
        "wl2_table covers alpha in [%.2f, %.2f], but the model has ",
        "alpha = %.4f."
      ),
      rng[1], rng[2], alpha
    ))
  }
  invisible(NULL)
}

#' @name rspde.cache
#' @title Where generated coefficient tables are kept between sessions
#' @description The mesh-free weighted-L2 tables are shipped with the package,
#' but a table for a particular spectral interval (see [rspde.wl2.table()] and
#' the `kappa_ref` argument of [matern.operators()]) has to be computed, which
#' takes of the order of a second. Within a session such tables are cached in
#' memory. Enabling this cache stores them on disk as well, so that a second
#' session, or a script run again, finds them instead of refitting.
#'
#' The cache is off by default: a package should not write outside the session
#' temporary directory unless asked to. Calling `rspde.cache(TRUE)` is that
#' request, and creates the directory. The environment variable
#' `RSPDE_CACHE_DIR` sets it for non-interactive use, for instance from
#' `.Renviron`.
#'
#' Cached files are organised by package version, so upgrading the package does
#' not read tables produced by an older fit. Within a development version,
#' clear the cache after changing anything that affects the coefficients.
#'
#' @param dir `TRUE` to enable the cache at the standard location for this
#' package, `tools::R_user_dir("rSPDE", "cache")`; `FALSE` to disable it; a
#' path to use that directory instead; or missing to query the current setting.
#' @param clear Delete the tables that are currently stored?
#' @return The cache directory, or `NULL` if the cache is off, invisibly when
#' called for its effect.
#' @export
#' @seealso [rspde.wl2.table()], [matern.operators()]
#' @examples
#' rspde.cache()
#' \dontrun{
#' rspde.cache(TRUE) # store generated tables between sessions
#' rspde.cache(clear = TRUE) # throw away what is stored
#' rspde.cache(FALSE)
#' }
rspde.cache <- function(dir, clear = FALSE) {
  if (!missing(dir)) {
    if (isTRUE(dir)) {
      d <- tools::R_user_dir("rSPDE", "cache")
      dir.create(d, recursive = TRUE, showWarnings = FALSE)
      options(rSPDE.cache.dir = d)
    } else if (isFALSE(dir) || is.null(dir)) {
      options(rSPDE.cache.dir = NULL)
    } else {
      d <- path.expand(as.character(dir)[[1]])
      dir.create(d, recursive = TRUE, showWarnings = FALSE)
      options(rSPDE.cache.dir = d)
    }
  }
  d <- wl2_cache_dir()
  if (clear && !is.null(d)) {
    unlink(file.path(d, "wl2"), recursive = TRUE)
  }
  if (missing(dir) && !clear) {
    return(d)
  }
  invisible(d)
}

#' @name wl2_cache_dir
#' @title The directory tables are cached in, if any
#' @description The option set by [rspde.cache()] takes precedence, then the
#' environment variable `RSPDE_CACHE_DIR`. No default: the cache is off unless
#' the user turns it on.
#' @return A path, or `NULL`.
#' @noRd
wl2_cache_dir <- function() {
  d <- getOption("rSPDE.cache.dir", NULL)
  if (is.null(d)) {
    d <- Sys.getenv("RSPDE_CACHE_DIR", "")
    if (!nzchar(d)) {
      return(NULL)
    }
    d <- path.expand(d)
  }
  d
}

#' @name wl2_xmin_encode
#' @title Encode and decode `x_min` as a file name
#' @description A reversible encoding using only characters that are safe in a
#' path, so that the cached tables for one configuration can be listed and
#' their `x_min` read back without opening them.
#' @param x A value of `x_min`, or `NULL` for the mesh-free fit.
#' @return A string, or the value it encodes.
#' @noRd
wl2_xmin_encode <- function(x) {
  if (is.null(x)) {
    return("free")
  }
  e <- sprintf("%.12e", x)
  e <- gsub(".", "d", e, fixed = TRUE)
  e <- gsub("-", "m", e, fixed = TRUE)
  gsub("+", "p", e, fixed = TRUE)
}

#' @rdname wl2_xmin_encode
#' @noRd
wl2_xmin_decode <- function(s) {
  if (identical(s, "free")) {
    return(NULL)
  }
  e <- gsub("d", ".", s, fixed = TRUE)
  e <- gsub("m", "-", e, fixed = TRUE)
  e <- gsub("p", "+", e, fixed = TRUE)
  suppressWarnings(as.numeric(e))
}

#' @name wl2_cache_paths
#' @title Where a table is cached
#' @description One directory per configuration and one file per `x_min`
#' inside it, so that `wl2_cache_read()` can find a table fitted on a slightly
#' different mesh without opening every file. Cached files are separated by
#' package version, so that an upgrade never reads tables produced by an older
#' fit.
#' @param type,d,m,m_alpha,x_min,n_starts,n_grid,by,s,k_term The configuration.
#' @return A list with `dir` and `file`, or `NULL` if the cache is off.
#' @noRd
wl2_cache_paths <- function(type, d, m, m_alpha, x_min, n_starts, n_grid, by,
                            s = 0, k_term = FALSE) {
  root <- wl2_cache_dir()
  if (is.null(root)) {
    return(NULL)
  }
  dir <- file.path(
    root, "wl2", as.character(utils::packageVersion("rSPDE")),
    sprintf(
      "%s-d%s-m%s-a%s-n%s-g%s-b%s-w%s-k%s", type, d, m, m_alpha, n_starts,
      n_grid, gsub(".", "d", format(by), fixed = TRUE),
      gsub(".", "d", format(s), fixed = TRUE), if (k_term) 1 else 0
    )
  )
  list(dir = dir, file = file.path(dir, paste0(wl2_xmin_encode(x_min), ".rds")))
}

#' @name wl2_cache_match
#' @title A cached table that is valid for this spectral interval
#' @description The fit is optimal on \eqn{(x_{\min}, 1]}, so a table fitted
#' with a *smaller* `x_min` still covers the whole spectrum and remains a valid
#' model; one fitted with a larger `x_min` does not, and is never used. Among
#' the valid ones the largest `x_min` is the sharpest. Two meshes of the same
#' domain give values of `x_min` that differ by a fraction of a percent, and a
#' table half a decade too wide costs about a fifth of the error, so the search
#' stops at half the requested value; below that it is worth refitting.
#' @param dir The configuration's cache directory.
#' @param x_min The value the model needs.
#' @return A path, or `NULL`.
#' @noRd
wl2_cache_match <- function(dir, x_min) {
  if (is.null(x_min) || !dir.exists(dir)) {
    return(NULL)
  }
  files <- list.files(dir, pattern = "\\.rds$")
  if (length(files) == 0) {
    return(NULL)
  }
  xs <- vapply(sub("\\.rds$", "", files), function(f) {
    v <- wl2_xmin_decode(f)
    if (is.null(v) || length(v) != 1 || is.na(v)) NA_real_ else v
  }, numeric(1))
  ok <- which(!is.na(xs) & xs <= x_min & xs >= x_min / 2)
  if (length(ok) == 0) {
    return(NULL)
  }
  file.path(dir, files[ok[which.max(xs[ok])]])
}

#' @name wl2_cache_read
#' @title Read a cached table, if one is stored
#' @description Looks for a table fitted on exactly this interval, then for one
#' fitted on a slightly wider interval, which is still a valid model; see
#' `wl2_cache_match()`. Only the columns are stored, as for the tables shipped
#' with the package, so the attributes are reattached here, with the `x_min`
#' the table was actually fitted on rather than the one that was asked for.
#' @return The table, or `NULL`.
#' @noRd
wl2_cache_read <- function(type, d, m, m_alpha, x_min, n_starts, n_grid, by,
                           n_grid_quad, s = 0, k_term = FALSE) {
  paths <- wl2_cache_paths(type, d, m, m_alpha, x_min, n_starts, n_grid, by,
    s, k_term)
  if (is.null(paths)) {
    return(NULL)
  }
  f <- if (file.exists(paths$file)) {
    paths$file
  } else {
    wl2_cache_match(paths$dir, x_min)
  }
  if (is.null(f)) {
    return(NULL)
  }
  tab <- tryCatch(readRDS(f), error = function(e) NULL)
  if (!is.data.frame(tab) || is.null(tab$rel_err)) {
    return(NULL)
  }
  got <- wl2_xmin_decode(sub("\\.rds$", "", basename(f)))
  wl2_table_attrs(tab, type, d, m, m_alpha, got, n_grid_quad, s, k_term)
}

#' @name wl2_cache_write
#' @title Store a table for later sessions
#' @description Failures are ignored: a cache that cannot be written is not a
#' reason for a model to fail.
#' @return No return value, called for the side effect.
#' @noRd
wl2_cache_write <- function(tab, type, d, m, m_alpha, x_min, n_starts, n_grid,
                            by, s = 0, k_term = FALSE) {
  paths <- wl2_cache_paths(type, d, m, m_alpha, x_min, n_starts, n_grid, by,
    s, k_term)
  if (is.null(paths)) {
    return(invisible(NULL))
  }
  bare <- tab
  attributes(bare) <- attributes(bare)[c("names", "row.names", "class")]
  try(
    {
      dir.create(paths$dir, recursive = TRUE, showWarnings = FALSE)
      saveRDS(bare, paths$file, compress = "xz")
    },
    silent = TRUE
  )
  invisible(NULL)
}

#' @name rspde.wl2.table
#' @title Weighted-L2 coefficients for a given spectral interval
#' @description Builds a table of weighted-L2 rational coefficients for a
#' specific lower end of the spectral interval, so that the approximation is
#' optimal on the interval the discrete operator actually has rather than on
#' \eqn{(0,1]}.
#'
#' The mesh-free tables that [matern.operators()] uses by default do not depend
#' on the mesh or on \eqn{\kappa} and are stored in the package, which is why
#' they cost nothing at set-up. They are, however, fitted on all of
#' \eqn{(0,1]}, while a discretised operator only ever sees
#' \eqn{x \in (x_{\min}, 1]} with \eqn{x_{\min}} set by the mesh resolution
#' and by \eqn{\kappa}. Fitting on that shorter interval spends the same
#' number of terms where they matter and is appreciably more accurate; the
#' price is that the table depends on the mesh and therefore has to be
#' computed, which takes of the order of a second.
#'
#' With the cache turned on (see [rspde.cache()]) there is usually nothing to
#' do by hand: a model asked for with `kappa_ref` computes its table once and
#' finds it again in later sessions without being told where it is. The two
#' routes share one store, so a table built here is what a later
#' `matern.operators(kappa_ref = ...)` picks up, and the other way round.
#'
#' This function is for what the cache does not cover: holding \eqn{x_{\min}}
#' fixed across several meshes or models rather than letting each derive its
#' own, inspecting the coefficients and the errors of the fit, or working
#' without writing to a cache at all. Pass the result to [matern.operators()]
#' as `wl2_table`; supplying it overrides `x_min` and `kappa_ref`. The result
#' is an ordinary data frame with attributes, so `saveRDS()` is also an option
#' if you would rather manage it yourself than enable the cache.
#'
#' Since \eqn{x_{\min}} grows with \eqn{\kappa}, a *lower bound* for
#' \eqn{\kappa} gives the shortest interval that is certainly long enough,
#' and hence a table that stays valid while \eqn{\kappa} is estimated. That
#' is what `kappa_ref` is.
#'
#' @param m The order of the rational approximation.
#' @param d The dimension of the domain. Taken from `mesh` when possible.
#' @param nu,alpha The smoothness. Give one of them; `alpha = nu + d/2`. Only
#' the integer part of `alpha` is used, since one table covers a whole unit
#' interval of `alpha`.
#' @param kappa_ref A lower bound for `kappa`, from which `x_min` is computed.
#' @param x_min The lower end of the spectral interval, if it is known; then
#' neither `kappa_ref` nor the mesh is needed.
#' @param C,G,mesh,loc_mesh The finite element matrices or the mesh, used with
#' `kappa_ref` to find `x_min`. See [rspde.xmin()].
#' @param type Either `"covariance"` or `"operator"`, matching the model the
#' table is for.
#' @param eigenvalue Passed to [rspde.xmin()].
#' @param s,k_term The weight exponent and the constant term of the fit; see
#' [rational.coefficients.wl2()]. These change what is being approximated and
#' are for studying the approximation rather than for building a model: a
#' constant term has no block in the covariance-based construction, and a
#' table carrying one is refused by [matern.operators()].
#' @param ... Passed to the fit, for instance `n_starts` or `by`.
#' @return A table of coefficients, with the attributes `type`, `d`, `m`,
#' `m_alpha`, `x_min` and `kind`, for use as the `wl2_table` argument of
#' [matern.operators()].
#' @export
#' @seealso [rspde.xmin()], [matern.operators()],
#' [rational.coefficients.wl2()]
#' @examples
#' \donttest{
#' mesh <- fmesher::fm_mesh_1d(seq(0, 1, length.out = 101))
#' tab <- rspde.wl2.table(m = 2, d = 1, nu = 0.8, kappa_ref = 5, mesh = mesh)
#' attr(tab, "x_min")
#' }
rspde.wl2.table <- function(m, d = NULL, nu = NULL, alpha = NULL,
                            kappa_ref = NULL, x_min = NULL,
                            C = NULL, G = NULL, mesh = NULL, loc_mesh = NULL,
                            type = c("covariance", "operator"),
                            eigenvalue = c("bound", "exact"),
                            s = 0, k_term = FALSE, ...) {
  type <- match.arg(type)
  if (is.null(d)) {
    d <- wl2_mesh_dim(mesh)
    if (is.null(d)) {
      stop("d could not be determined from the mesh; supply d.")
    }
  }
  if (is.null(alpha)) {
    if (is.null(nu)) {
      stop("Give either alpha or nu.")
    }
    alpha <- nu + d / 2
  }
  if (alpha <= 0) {
    stop("alpha must be positive.")
  }
  m_alpha <- floor(alpha)
  if (type == "covariance" && !(m_alpha %in% c(0, 1, 2))) {
    stop(paste0(
      "The weighted-L2 coefficients are only implemented for floor(alpha) ",
      "equal to 0, 1 or 2, but floor(alpha) = ", m_alpha, "."
    ))
  }
  if (is.null(x_min)) {
    if (is.null(kappa_ref) && is.null(mesh) && is.null(C)) {
      stop(paste0(
        "Give x_min, or kappa_ref together with a mesh (or C and G), so that ",
        "the spectral interval can be determined."
      ))
    }
    x_min <- rspde.xmin(
      C = C, G = G, mesh = mesh, kappa_ref = kappa_ref, nu = alpha - d / 2,
      loc_mesh = loc_mesh, eigenvalue = eigenvalue
    )
  }
  if (x_min <= 0 || x_min >= 1) {
    stop("x_min must lie in (0, 1).")
  }
  tab <- wl2_coefficient_table(
    d = d, m = m, m_alpha = if (type == "operator") floor(alpha) else m_alpha,
    x_min = x_min, type = type, s = s, k_term = k_term, ...
  )
  attr(tab, "kappa_ref") <- kappa_ref
  tab
}

#' @name wl2_mesh_dim
#' @title Dimension of the domain a mesh lives on
#' @param mesh An `fm_mesh_1d` or `fm_mesh_2d`, or `NULL`.
#' @return 1, 2, or `NULL` if it cannot be told.
#' @noRd
wl2_mesh_dim <- function(mesh) {
  if (is.null(mesh)) {
    return(NULL)
  }
  if (inherits(mesh, "fm_mesh_1d")) {
    return(1)
  }
  if (inherits(mesh, "fm_mesh_2d") || inherits(mesh, "inla.mesh")) {
    return(2)
  }
  NULL
}

#' @name wl2_table_attrs
#' @title Attach the descriptive attributes to a coefficient table
#' @description Everything about a table except its columns: what it is a table
#' of, and the quadrature the fits used. The quadrature is kept with the table
#' so that `wl2_interp_coefficients()` can re-solve the residues without
#' rebuilding it, and is reconstructed here rather than stored, since it
#' follows from `d`, `x_min` and `n_grid`.
#' @param tab The columns of the table.
#' @param type,d,m,m_alpha,x_min,n_grid,s,k_term The configuration it was built
#' for.
#' @return `tab`, with its attributes set.
#' @noRd
wl2_table_attrs <- function(tab, type, d, m, m_alpha, x_min, n_grid,
                            s = 0, k_term = FALSE) {
  attr(tab, "type") <- type
  attr(tab, "d") <- d
  attr(tab, "m") <- m
  attr(tab, "m_alpha") <- m_alpha
  attr(tab, "x_min") <- x_min
  attr(tab, "s") <- s
  attr(tab, "k_term") <- k_term
  attr(tab, "kind") <- if (type == "covariance") {
    c("plain", "shifted", "shifted2")[m_alpha + 1]
  } else {
    "plain"
  }
  cells <- wl2_weyl_cells(d, if (is.null(x_min)) 1e-26 else x_min,
    if (is.null(x_min)) max(n_grid, 900) else n_grid, s = s
  )
  attr(tab, "quad") <- list(x = cells$x, w = cells$w, sw = sqrt(cells$w))
  tab
}

#' @name wl2_shipped_table
#' @title A precomputed mesh-free coefficient table, if there is one
#' @description The mesh-free covariance tables do not depend on the mesh or on
#' `kappa`, and are the default for `type = "covariance"`, so they are built
#' once by `data-raw/wl2_tables.R` and shipped with the package. Rebuilding one
#' takes up to four seconds; looking it up costs the few milliseconds of the
#' quadrature. Tables for other configurations, or for non-default settings of
#' the fit, are not stored and are computed on demand.
#' @param d,m,m_alpha,n_grid The configuration.
#' @return The table, or `NULL` if none is stored for this configuration.
#' @noRd
wl2_shipped_table <- function(d, m, m_alpha, n_grid) {
  ## get0(), not the object itself: data-raw/wl2_tables.R runs against a
  ## namespace in which it does not exist yet.
  tabs <- get0("wl2_meshfree_tables",
    envir = environment(wl2_shipped_table), ifnotfound = NULL
  )
  tab <- tabs[[paste("covariance", d, m, m_alpha, sep = "_")]]
  if (is.null(tab)) {
    return(NULL)
  }
  wl2_table_attrs(tab, "covariance", d, m, m_alpha, NULL, n_grid, 0, FALSE)
}

#' @name wl2_theta_canonical
#' @title Outer parameters of a fit, in sorted order
#' @description The poles enter the basis symmetrically, so a fit's parameters
#' are only defined up to a permutation. Sorting them gives a representation
#' that varies smoothly along the continuation in `alpha`, which is what makes
#' it possible to extrapolate from one grid point to the next.
#' @param cf A fit, as returned by [rational.coefficients.wl2()].
#' @return The outer parameters: `logit(b_0)` (shifted classes only) followed by
#' `log(b)` in increasing order.
#' @noRd
wl2_theta_canonical <- function(cf) {
  v <- log(1 - cf$p)
  if (!is.null(cf$p0)) {
    v <- c(stats::qlogis(1 - cf$p0), v)
  }
  pmin(pmax(v, -5), 60)
}

#' @name wl2_restart_starts
#' @title Warm restarts for a continuation step that went wrong
#' @description A fit whose smallest residue has been driven to zero is really
#' a solution of order `m - 1`. The natural repair is the move the continuation
#' in `m` already uses: drop the dead term and put a pole back into each gap of
#' the remaining ones. The poles enter the basis symmetrically, so they can be
#' taken in the sorted order reported by the fit.
#' @param cf A fit, as returned by [rational.coefficients.wl2()].
#' @param shifted Does the class have a shift, that is, is `q` positive?
#' @return A list of starting values for the outer parameters.
#' @noRd
wl2_restart_starts <- function(cf, shifted) {
  lb <- log(1 - cf$p)[-which.min(cf$r)]
  lb <- pmin(pmax(lb, -5), 60)
  wl2_insert_pole(
    if (shifted) c(stats::qlogis(1 - cf$p0), lb) else lb, shifted
  )
}

#' @name wl2_coefficient_table
#' @title Table of weighted-L2 rational coefficients
#' @description Computes the coefficients on a grid of `alpha` values, using
#' continuation in `alpha` (the fit for one grid point is used as starting
#' value for the next) so that a full multistart is only needed for the first
#' point. Results are cached, so that repeatedly creating models with the same
#' configuration is cheap.
#' @param d Dimension of the domain.
#' @param m Order of the rational approximation.
#' @param m_alpha The integer part of alpha. Ignored for `type = "operator"`.
#' @param x_min Lower end of the spectral interval, or `NULL` for the mesh-free
#' fit.
#' @param type "covariance" or "operator".
#' @param n_starts Number of starts used for the first grid point.
#' @param n_grid Number of quadrature nodes.
#' @param by Resolution of the alpha grid.
#' @param cache Should the result be cached?
#' @param s,k_term The weight exponent and the constant term of the fit; see
#' [rational.coefficients.wl2()]. Tables with either set are never taken from
#' the package's stored tables, which are for `s = 0` and no constant term.
#' @param shipped May an already computed table be used: one stored in the
#' package, where there is one for exactly this configuration, or one written
#' to the persistent cache by an earlier session (see [rspde.cache()])?
#' `FALSE` forces the fits to be run, which is what `data-raw/wl2_tables.R` and
#' the test that checks the stored tables do.
#' @return A data frame with columns `alpha`, `r1`, ..., `p1`, ..., `k`, and
#' `p0` for the shifted classes, together with the attributes `type`, `d`, `m`,
#' `m_alpha`, `x_min` and `kind`.
#' @noRd
wl2_coefficient_table <- function(d, m, m_alpha, x_min = NULL,
                                  type = "covariance", n_starts = 8,
                                  n_grid = 300, by = 0.01, cache = TRUE,
                                  shipped = TRUE, s = 0, k_term = FALSE) {
  k_term <- isTRUE(k_term)
  key <- paste(type, d, m, m_alpha, if (is.null(x_min)) "free" else
    signif(x_min, 12), n_starts, n_grid, by, s, k_term, sep = "_")
  if (cache && !is.null(wl2_cache[[key]])) {
    return(wl2_cache[[key]])
  }

  ## A stored table, where there is one for exactly this fit.
  ## The stored tables are for the default weight and no constant term.
  if (shipped && is.null(x_min) && type == "covariance" &&
    n_starts == 8 && n_grid == 300 && by == 0.01 && s == 0 && !k_term) {
    tab <- wl2_shipped_table(d, m, m_alpha, n_grid)
    if (!is.null(tab)) {
      if (cache) {
        wl2_cache[[key]] <- tab
      }
      return(tab)
    }
  }

  ## A table this or an earlier session generated, if the user has turned the
  ## persistent cache on. This is what makes a mesh-specific table a one-off
  ## rather than a per-session cost.
  if (cache && shipped) {
    tab <- wl2_cache_read(
      type, d, m, m_alpha, x_min, n_starts, n_grid, by, n_grid, s, k_term
    )
    if (!is.null(tab)) {
      wl2_cache[[key]] <- tab
      return(tab)
    }
  }

  alphas <- wl2_alpha_grid(m_alpha, d, type, by)
  if (length(alphas) == 0) {
    stop("No valid alpha values for the requested weighted-L2 table.")
  }
  n_col <- if (type == "operator") m + 1 else m

  r_mat <- matrix(NA_real_, length(alphas), n_col)
  p_mat <- matrix(NA_real_, length(alphas), n_col)
  p0_vec <- rep(NA_real_, length(alphas))
  k_vec <- rep(0, length(alphas))
  err_vec <- rep(NA_real_, length(alphas))
  theta_list <- vector("list", length(alphas))

  kind_shifted <- type == "covariance" && m_alpha > 0
  is_collapsed <- function(cf) min(cf$r) < 1e-8 * max(cf$r)

  prev <- NULL
  ## Was the collapse at the previous grid point confirmed to be the optimum?
  ## Near an end of the alpha range the best fit of order m genuinely has fewer
  ## than m active terms, and then every repair returns the same solution;
  ## re-deriving that at each of the following grid points is the single most
  ## expensive thing the sweep can do.
  settled <- FALSE
  ## The optima move smoothly with alpha, so the previous two give a linearly
  ## extrapolated start that is centred on the new point rather than one grid
  ## step behind it. Interpolating a coarse first pass onto the intermediate
  ## points is equivalent and costs the same; both save about a tenth of the
  ## sweep, which is all there is to save once the start is this good.
  prev2 <- NULL
  for (i in seq_along(alphas)) {
    start_i <- if (is.null(prev)) {
      NULL
    } else if (is.null(prev2)) {
      wl2_theta_canonical(prev)
    } else {
      2 * wl2_theta_canonical(prev) - wl2_theta_canonical(prev2)
    }
    cf <- rational.coefficients.wl2(
      alpha = alphas[i], d = d, m = m, x_min = x_min, type = type,
      n_starts = if (is.null(prev)) n_starts else 1L,
      n_grid = n_grid, start = start_i, s = s, k_term = k_term
    )
    ## Guard against the continuation ending up in a worse local minimum, or
    ## collapsing to the optimum of a smaller order (a residue driven to zero,
    ## which wastes a block and then propagates through the continuation).
    collapsed <- is_collapsed(cf)
    worse <- !is.null(prev) && cf$rel_err > 2 * prev$rel_err
    if (!is.null(prev) && (worse || (collapsed && !settled))) {
      ## Re-inserting the dead pole is some five times cheaper than a cold
      ## multistart and has found the same optimum in every case tried.
      best <- NULL
      for (st in wl2_restart_starts(cf, kind_shifted)) {
        cf2 <- tryCatch(
          rational.coefficients.wl2(
            alpha = alphas[i], d = d, m = m, x_min = x_min, type = type,
            n_starts = 1L, n_grid = n_grid, start = st, s = s, k_term = k_term
          ),
          error = function(e) NULL
        )
        if (!is.null(cf2) && (is.null(best) || cf2$rel_err < best$rel_err)) {
          best <- cf2
        }
      }
      ## A cold multistart is kept as the last resort, but only for a point
      ## that is actually anomalous: a collapse that the restarts cannot undo
      ## is a property of the problem, not a failure of the continuation.
      if (worse && (is.null(best) || best$rel_err >= cf$rel_err)) {
        best <- rational.coefficients.wl2(
          alpha = alphas[i], d = d, m = m, x_min = x_min, type = type,
          n_starts = n_starts, n_grid = n_grid, s = s, k_term = k_term
        )
      }
      if (!is.null(best) && (best$rel_err < cf$rel_err ||
        (collapsed && !is_collapsed(best)))) {
        cf <- best
      }
      settled <- is_collapsed(cf)
    } else {
      settled <- collapsed && settled
    }
    r_mat[i, ] <- cf$r
    p_mat[i, ] <- cf$p
    k_vec[i] <- cf$k
    if (!is.null(cf$p0)) {
      p0_vec[i] <- cf$p0
    }
    err_vec[i] <- cf$rel_err
    theta_list[[i]] <- cf$theta
    prev2 <- prev
    prev <- cf
  }

  ## Polish: with a monotone trend in alpha the error at a grid point cannot
  ## exceed both of its neighbours, so a point that does is a local minimum of
  ## the outer problem. Refit it warm-started from either neighbour.
  for (pass in 1:2) {
    interior <- seq_along(alphas)[-c(1, length(alphas))]
    spikes <- interior[err_vec[interior] >
      1.5 * pmax(err_vec[interior - 1], err_vec[interior + 1])]
    if (length(spikes) == 0) {
      break
    }
    for (i in spikes) {
      for (j in c(i - 1, i + 1)) {
        cf <- rational.coefficients.wl2(
          alpha = alphas[i], d = d, m = m, x_min = x_min, type = type,
          n_starts = 1L, n_grid = n_grid, start = theta_list[[j]],
          s = s, k_term = k_term
        )
        if (cf$rel_err < err_vec[i]) {
          r_mat[i, ] <- cf$r
          p_mat[i, ] <- cf$p
          k_vec[i] <- cf$k
          if (!is.null(cf$p0)) {
            p0_vec[i] <- cf$p0
          }
          err_vec[i] <- cf$rel_err
          theta_list[[i]] <- cf$theta
        }
      }
    }
  }

  tab <- data.frame(alpha = alphas)
  for (j in seq_len(n_col)) {
    tab[[paste0("r", j)]] <- r_mat[, j]
  }
  for (j in seq_len(n_col)) {
    tab[[paste0("p", j)]] <- p_mat[, j]
  }
  tab$k <- k_vec
  if (all(!is.na(p0_vec))) {
    tab$p0 <- p0_vec
  }
  tab$rel_err <- err_vec
  tab <- wl2_table_attrs(tab, type, d, m, m_alpha, x_min, n_grid, s, k_term)
  if (cache) {
    wl2_cache[[key]] <- tab
    wl2_cache_write(tab, type, d, m, m_alpha, x_min, n_starts, n_grid, by,
      s, k_term)
  }
  tab
}

## Cache of the coefficient tables, keyed by the configuration.
wl2_cache <- new.env(parent = emptyenv())

#' @name wl2_clear_cache
#' @title Empty the cache of weighted-L2 coefficient tables
#' @return No return value, called for the side effect.
#' @noRd
wl2_clear_cache <- function() {
  rm(list = ls(wl2_cache), envir = wl2_cache)
  invisible(NULL)
}

#' @name wl2_interp_coefficients
#' @title Interpolate the weighted-L2 coefficients
#' @description Interpolates a table produced by `wl2_coefficient_table()` at a
#' given value of `alpha`. The poles are obtained by spline interpolation of the
#' internal parameters (`log(b)`, and `logit(b_0)` for the shifted class), which
#' are the smooth ones, and the residues are then re-solved exactly by
#' non-negative least squares for the interpolated poles. This is as accurate as
#' a direct fit, also close to integer `alpha` where the residues and poles of
#' the table vary over many orders of magnitude.
#' @param tab The table.
#' @param alpha The value of alpha.
#' @return A list with elements `r`, `p`, `p0`, `k` and `rel_err`.
#' @noRd
wl2_interp_coefficients <- function(tab, alpha) {
  alpha <- min(max(alpha, min(tab$alpha)), max(tab$alpha))
  n_col <- sum(grepl("^r[0-9]+$", names(tab)))
  kind <- attr(tab, "kind")
  sp <- function(y) stats::spline(tab$alpha, y, xout = alpha)$y

  theta <- vapply(seq_len(n_col), function(i) {
    sp(log(1 - tab[[paste0("p", i)]]))
  }, numeric(1))
  qq <- wl2_q(kind)
  if (qq > 0) {
    b0 <- pmin(pmax(1 - tab$p0, 1e-12), 1 - 1e-12)
    theta <- c(sp(log(b0 / (1 - b0))), theta)
  }

  quad <- attr(tab, "quad")
  k_term <- isTRUE(attr(tab, "k_term"))
  target <- if (identical(attr(tab, "type"), "operator")) alpha / 2 else alpha
  f <- quad$x^target * quad$sw
  A <- wl2_basis(kind, quad$x, theta, k_term) * quad$sw
  ## The residues, and the constant term with them, are re-solved rather than
  ## interpolated: given the poles this is the exact optimum, and it keeps the
  ## interpolated coefficients a genuine non-negative fit.
  cc <- wl2_nnls(A, f)
  res <- as.vector(A %*% cc) - f
  k <- if (k_term) cc[length(cc)] else 0
  r <- if (k_term) cc[-length(cc)] else cc
  ## A zero residue would give an infinite precision for that block.
  r <- pmax(r, .Machine$double.eps * max(r))

  b <- exp(pmin(pmax(if (qq > 0) theta[-1] else theta, -5), 60))
  list(
    r = r, p = 1 - b,
    p0 = if (qq > 0) 1 - 1 / (1 + exp(-theta[1])) else NULL,
    q = qq, k = k, theta = theta,
    rel_err = sqrt(sum(res^2) / sum(quad$w * quad$x^(2 * target)))
  )
}

#' @name wl2_domain_diameter
#' @title Diameter of the domain of a mesh
#' @description The diagonal of the bounding box of the mesh nodes.
#' @param mesh An `fmesher` mesh, a `metric_graph` object, or `NULL`.
#' @param loc_mesh Mesh locations, used if `mesh` is `NULL`.
#' @return The diameter, or `NULL` if it cannot be determined.
#' @noRd
wl2_domain_diameter <- function(mesh = NULL, loc_mesh = NULL) {
  loc <- NULL
  if (!is.null(mesh)) {
    if (inherits(mesh, "metric_graph")) {
      loc <- mesh$get_vertices()
    } else if (inherits(mesh, "fm_mesh_1d")) {
      loc <- matrix(mesh$loc, ncol = 1)
    } else if (!is.null(mesh$loc)) {
      loc <- as.matrix(mesh$loc)
    }
  } else if (!is.null(loc_mesh)) {
    loc <- as.matrix(loc_mesh)
  }
  if (is.null(loc) || length(loc) == 0) {
    return(NULL)
  }
  sqrt(sum((apply(loc, 2, max) - apply(loc, 2, min))^2))
}

#' @name wl2_lambda_max
#' @title Largest eigenvalue of a sparse symmetric matrix
#' @description Lanczos with full reorthogonalisation. How fast this converges
#' depends on how separated the top of the spectrum is. Ritz values are interior 
#' to the spectrum, so this converges from *below*, which is the unsafe direction: 
#' too small a \eqn{\lambda_{\max}} gives too large an \eqn{x_{\min}} and leaves 
#' the top of the spectrum outside the fitted interval. The caller therefore 
#' allows a one per cent margin and caps the result at the Gershgorin bound, so 
#' that the value used is never below the true largest eigenvalue and never more 
#' than one per cent above it.
#' @param S A sparse symmetric matrix.
#' @param k Number of Lanczos steps.
#' @return The largest eigenvalue, or `NA_real_` if the iteration breaks down.
#' @noRd
wl2_lambda_max <- function(S, k = 20) {
  n <- dim(S)[1]
  if (n <= k + 2) {
    return(max(eigen(as.matrix((S + Matrix::t(S)) / 2),
      symmetric = TRUE, only.values = TRUE
    )$values))
  }
  ## A fixed starting vector, so that x_min does not depend on the state of the
  ## user's random number generator.
  q <- rep_len(c(1, -1, 1, 1, -1), n) + seq_len(n) / n
  q <- q / sqrt(sum(q^2))
  Q <- matrix(0, n, k)
  al <- be <- numeric(k)
  qm <- numeric(n)
  b <- 0
  kk <- k
  for (j in seq_len(k)) {
    Q[, j] <- q
    v <- as.vector(S %*% q)
    al[j] <- sum(q * v)
    v <- v - al[j] * q - b * qm
    ## Full reorthogonalisation: k is small, and without it the Ritz values
    ## acquire spurious copies of the dominant eigenvalue. Projecting with the
    ## whole of Q rather than its first j columns gives the same result, since
    ## the remaining columns are still zero. It does more arithmetic but copies
    ## nothing, and copying is what costs: 2.3 times faster at 10^4 nodes and
    ## above, against about 25% slower below 5000, which is the right trade for
    ## a routine whose absolute cost only matters on large meshes.
    v <- v - Q %*% crossprod(Q, v)
    b <- sqrt(sum(v^2))
    if (!is.finite(b) || b < 1e-12) {
      kk <- j
      break
    }
    be[j] <- b
    qm <- q
    q <- as.vector(v) / b
  }
  Tm <- diag(al[seq_len(kk)], kk)
  if (kk > 1) {
    i <- seq_len(kk - 1)
    Tm[cbind(i, i + 1)] <- be[i]
    Tm[cbind(i + 1, i)] <- be[i]
  }
  ev <- tryCatch(max(eigen(Tm, symmetric = TRUE, only.values = TRUE)$values),
    error = function(e) NA_real_
  )
  if (length(ev) != 1 || !is.finite(ev)) NA_real_ else ev
}


#' Lower end of the spectral interval of a rational approximation
#'
#' Computes the value \eqn{x_{\min} = 1/(1 + \mu_{\max}/\kappa_{\mathrm{lo}}^2)}
#' used by the weighted-L2 rational coefficients, where \eqn{\mu_{\max}} is the
#' largest generalised eigenvalue of \eqn{(G, C)} and
#' \eqn{\kappa_{\mathrm{lo}}} is a lower bound for the values that \eqn{\kappa}
#' can take.
#'
#' The reference \eqn{\kappa_{\mathrm{lo}}} must be a *lower* bound: a reference
#' above the true \eqn{\kappa} leaves part of the spectrum outside the fitted
#' interval, and the error then grows by one to two orders of magnitude. A
#' reference below the true \eqn{\kappa} is safe; the approximation then
#' degrades gracefully towards the mesh-free fit. The default,
#' \eqn{\kappa_{\mathrm{lo}} = \sqrt{8\nu}/\mathrm{diam}}, corresponds to a
#' range equal to the diameter of the domain.
#'
#' @param C The mass matrix of the finite element discretisation.
#' @param G The stiffness matrix of the finite element discretisation.
#' @param mesh An optional mesh; `C` and `G` are computed from it if they are
#' not given, and it is used for the diameter of the domain.
#' @param kappa_ref The reference value \eqn{\kappa_{\mathrm{lo}}}. If `NULL`,
#' it is taken to be `sqrt(8 * nu) / diameter`.
#' @param nu The smoothness parameter, used for the default `kappa_ref`.
#' @param diameter The diameter of the domain, used for the default
#' `kappa_ref`. Computed from `mesh` if not given.
#' @param loc_mesh Mesh locations, an alternative to `mesh` for the diameter.
#' @param eigenvalue Either `"bound"` (the default), which uses the Gershgorin
#' bound `max(rowSums(abs(G)) / diag(C))` for \eqn{\mu_{\max}}, or `"exact"`,
#' which computes the eigenvalue by Lanczos iteration. The bound is an
#' over-estimate of \eqn{\mu_{\max}}, and therefore gives a conservative (too
#' small) \eqn{x_{\min}}, which is the safe direction.
#' @return The value of \eqn{x_{\min}}.
#' @export
#' @seealso [rational.coefficients.wl2()]
#' @examples
#' x <- seq(from = 0, to = 1, length.out = 201)
#' fem <- rSPDE.fem1d(x)
#' # reference kappa corresponding to a range equal to the domain diameter
#' rspde.xmin(C = fem$C, G = fem$G, loc_mesh = x, nu = 0.5)
#' # a user-supplied lower bound for kappa
#' rspde.xmin(C = fem$C, G = fem$G, kappa_ref = 10)
rspde.xmin <- function(C = NULL, G = NULL, mesh = NULL, kappa_ref = NULL,
                       nu = NULL, diameter = NULL, loc_mesh = NULL,
                       eigenvalue = c("bound", "exact")) {
  eigenvalue <- match.arg(eigenvalue)
  if ((is.null(C) || is.null(G))) {
    if (is.null(mesh)) {
      stop("Either C and G, or mesh, must be provided.")
    }
    fem <- fm_fem(mesh)
    C <- fem$c0
    G <- fem$g1
  }
  cdiag <- Matrix::rowSums(C)
  if (is.null(kappa_ref)) {
    if (is.null(diameter)) {
      diameter <- wl2_domain_diameter(mesh, loc_mesh)
    }
    if (is.null(diameter) || is.null(nu) || diameter <= 0) {
      stop(paste0(
        "kappa_ref could not be determined; supply kappa_ref, or a mesh ",
        "(or loc_mesh or diameter) together with nu."
      ))
    }
    kappa_ref <- sqrt(8 * max(nu, 1e-6)) / diameter
  }
  kappa_ref <- min(kappa_ref)
  if (kappa_ref <= 0) {
    stop("kappa_ref must be positive.")
  }
  ## Gershgorin: always an upper bound for the largest eigenvalue, so it always
  ## gives an x_min that is safe, if in two dimensions about 70% too small.
  mu_bound <- max(Matrix::rowSums(abs(G)) / cdiag)
  mu_max <- mu_bound
  if (eigenvalue == "exact") {
    Ci2 <- Matrix::Diagonal(length(cdiag), 1 / sqrt(cdiag))
    mu_lanczos <- wl2_lambda_max(Ci2 %*% G %*% Ci2)
    if (is.finite(mu_lanczos) && mu_lanczos > 0) {
      ## Lanczos converges from below; the margin and the cap keep the result
      ## between the true eigenvalue and one per cent above it.
      mu_max <- min(mu_bound, 1.01 * mu_lanczos)
    } else {
      warning(paste0(
        "The largest eigenvalue could not be computed; using the Gershgorin ",
        "bound instead."
      ))
    }
  }
  1 / (1 + mu_max / kappa_ref^2)
}

#' @name wl2_polymul
#' @title Multiply two polynomials given by their coefficients
#' @param a,b Coefficient vectors in ascending order.
#' @return The coefficients of the product, in ascending order.
#' @noRd
wl2_polymul <- function(a, b) {
  out <- numeric(length(a) + length(b) - 1)
  for (i in seq_along(a)) {
    out[i + seq_along(b) - 1] <- out[i + seq_along(b) - 1] + a[i] * b
  }
  out
}

#' @name wl2_roots
#' @title Convert weighted-L2 partial fractions to the operator-based form
#' @description Converts \eqn{\sum_i s_i/(\lambda - \lambda_i)} into the form
#' `factor` \eqn{\prod_i (1 - rc_i \lambda) / \prod_j (1 - rb_j \lambda)} used by
#' [fractional.operators()].
#' @param cf Output of [rational.coefficients.wl2()] or
#' `wl2_interp_coefficients()`, with `m + 1` terms.
#' @param tol Tolerance used when checking that the two forms agree.
#' @return A list with elements `rb`, `rc` and `factor`, as returned by
#' `get.roots()`.
#' @noRd
wl2_roots <- function(cf, tol = 1e-8) {
  if (!is.null(cf$k) && cf$k != 0) {
    stop(paste0(
      "The operator factorisation assumes no constant term, but these ",
      "coefficients have k = ", signif(cf$k, 4), ". Coefficients fitted with ",
      "k_term = TRUE are for evaluating the symbol, not for building an ",
      "operator-based model."
    ))
  }
  lambda <- cf$p
  s <- cf$r
  M <- length(lambda)
  ## N(lambda) = sum_i s_i prod_{j != i} (lambda - lambda_j), of degree M - 1
  Ncoef <- numeric(M)
  for (i in seq_len(M)) {
    pc <- 1
    for (j in seq_len(M)[-i]) {
      pc <- wl2_polymul(pc, c(-lambda[j], 1))
    }
    Ncoef <- Ncoef + s[i] * pc
  }
  rts <- polyroot(Ncoef)
  if (max(abs(Im(rts))) > 1e-6 * max(1, max(abs(Re(rts))))) {
    stop("The numerator of the rational approximation has complex roots.")
  }
  rts <- sort(Re(rts), decreasing = TRUE)
  if (any(rts >= 0)) {
    stop("The numerator of the rational approximation has non-negative roots.")
  }

  rb <- 1 / lambda
  rc <- 1 / rts
  factor <- Ncoef[1] / prod(-lambda)

  ## The two forms must agree; check on a grid of the spectral interval.
  lam <- exp(seq(0, log(1 / max(cf$x_min, 1e-20)), length.out = 50))
  lhs <- factor * apply(outer(lam, rc, function(l, r) 1 - r * l), 1, prod) /
    apply(outer(lam, rb, function(l, r) 1 - r * l), 1, prod)
  rhs <- rowSums(outer(lam, seq_along(s), function(l, i) s[i] / (l - lambda[i])))
  if (max(abs(lhs - rhs)) > tol * max(abs(rhs))) {
    stop("The conversion to the operator-based form failed.")
  }

  list(rb = rb, rc = rc, factor = factor)
}

#' @name wl2_diag_inverse
#' @title Diagonal of the inverse of a sparse SPD matrix
#' @description Uses `INLA::inla.qinv()` when INLA is available, and otherwise
#' a direct solve, which is only feasible for moderate sizes.
#' @param Q A sparse symmetric positive definite matrix.
#' @param max_direct Largest dimension for which the direct solve is attempted.
#' @return The diagonal of the inverse.
#' @noRd
wl2_diag_inverse <- function(Q, max_direct = 4000) {
  n <- dim(Q)[1]
  if (requireNamespace("INLA", quietly = TRUE)) {
    return(Matrix::diag(INLA::inla.qinv(Matrix::forceSymmetric(Q))))
  }
  if (n > max_direct) {
    stop(paste0(
      "The nodal variance correction needs the diagonal of the inverse of a ",
      n, " x ", n, " matrix. Install INLA for a sparse (Takahashi) ",
      "computation, or use variance_correction = 'none'."
    ))
  }
  Matrix::diag(Matrix::solve(Matrix::forceSymmetric(Q), Matrix::Diagonal(n)))
}

#' @name wl2_nodal_correction
#' @title Nodal variance correction for weighted-L2 covariance models
#' @description Without a constant term the nodal variance of the
#' approximation is not exactly \eqn{\sigma^2}; this returns the diagonal
#' \eqn{D = \sigma^2 - \mathrm{diag}(\Sigma_{\mathrm{approx}})} that restores
#' it. In observation models, adding `D` to the covariance is equivalent to
#' adding `A D A^T` to the covariance of the measurement noise.
#' @param Q The precision matrix of the latent blocks.
#' @param n_blocks The number of blocks.
#' @param sigma The target marginal standard deviation.
#' @return The correction vector, of length `nrow(Q) / n_blocks`.
#' @noRd
wl2_nodal_correction <- function(Q, n_blocks, sigma) {
  if (is.list(Q)) {
    dg <- Reduce(`+`, lapply(Q, wl2_diag_inverse))
    return(sigma^2 - dg)
  }
  n <- dim(Q)[1] / n_blocks
  dg <- numeric(n)
  for (j in seq_len(n_blocks)) {
    idx <- (j - 1) * n + seq_len(n)
    dg <- dg + wl2_diag_inverse(Q[idx, idx, drop = FALSE])
  }
  sigma^2 - dg
}

#' Recompute the weighted-L2 coefficients for an estimated kappa
#'
#' Two-stage use of the weighted-L2 rational coefficients. The coefficients
#' must not depend on `kappa` during estimation, since that would make the
#' likelihood non-smooth in `kappa`, so they are computed at set-up for a
#' conservative reference `kappa`. Once `kappa` has been estimated, this
#' function recomputes them once for a reference derived from the estimate, for
#' the final likelihood evaluation and for prediction.
#'
#' The reference must remain a lower bound for `kappa`, which is why the
#' default divides the estimate by `safety`. A reference above the true `kappa`
#' leaves part of the spectrum outside the fitted interval and the error grows
#' by one to two orders of magnitude, whereas a reference below it degrades
#' gracefully towards the mesh-free fit.
#'
#' @param object A model created by [matern.operators()] with
#' `type_rational_approximation = "wl2"`.
#' @param kappa_ref The new reference value. If `NULL`, the `kappa` of the
#' object divided by `safety` is used.
#' @param safety The safety factor applied to the `kappa` of the object when
#' `kappa_ref` is not given.
#' @param ... Further arguments passed to `update()`.
#' @return The updated model.
#' @export
#' @seealso [rational.coefficients.wl2()], [rspde.xmin()]
#' @examples
#' x <- seq(from = 0, to = 1, length.out = 101)
#' op <- matern.operators(
#'   loc_mesh = x, nu = 0.4, range = 0.2, sigma = 1, d = 1, m = 2,
#'   parameterization = "matern", type = "operator",
#'   type_rational_approximation = "wl2"
#' )
#' op <- update_rational_coefficients(op)
update_rational_coefficients <- function(object, kappa_ref = NULL,
                                         safety = 3, ...) {
  if (!identical(object$type_rational_approximation, "wl2")) {
    stop(paste0(
      "update_rational_coefficients() is only meaningful for models with ",
      "type_rational_approximation = 'wl2'."
    ))
  }
  if (is.null(kappa_ref)) {
    if (is.null(object$kappa)) {
      stop("The object does not contain kappa; supply kappa_ref.")
    }
    kappa_ref <- min(object$kappa) / safety
  }
  stats::update(object, kappa_ref = kappa_ref, x_min = NULL, ...)
}

#' @name wl2_cgeneric_table
#' @title A dense weighted-L2 table for the INLA cgeneric interface
#' @description The cgeneric model looks its coefficients up in a table rather
#' than fitting anything, so the weighted-L2 coefficients reach it the same way
#' the tabulated ones do: evaluated in R on a fine grid of `alpha` and passed
#' as a matrix.
#'
#' The tabulated types need only 999 rows, indexed by the fractional part of
#' `alpha`, because they approximate \eqn{x^{\{\alpha\}}} and the integer
#' factor \eqn{x^{\lfloor\alpha\rfloor}} is exact and built separately. The
#' weighted-L2 classes are fitted to \eqn{x^\alpha} as a whole, with the
#' integer factor carrying the shift \eqn{p_0}, so their coefficients depend on
#' all of `alpha`. The table therefore holds one block of 999 rows per
#' \eqn{\lfloor\alpha\rfloor} reachable from the prior on `nu`, and the row for
#' a given `alpha` is
#' `(floor(alpha) - m_alpha_min) * 999 + round(1000 * frac(alpha))`.
#'
#' The columns are those of `get_rational_coefficients()` with the constant
#' term replaced by the shift: `alpha`, `r1..rm`, `p1..pm`, `p0`. Each row
#' costs one interpolation, about 0.3 ms, so a block takes a third of a second.
#' @param d The dimension of the domain.
#' @param m The order of the rational approximation.
#' @param nu_upper_bound The upper bound of the prior on `nu`.
#' @param wl2_table An optional mesh-free table per band; by default the
#' shipped one.
#' @return A matrix, with the attribute `m_alpha_min`.
#' @noRd
wl2_cgeneric_table <- function(d, m, nu_upper_bound, wl2_table = NULL) {
  ## alpha = nu + d / 2 with nu in (0, nu_upper_bound)
  m_alpha_min <- floor(d / 2 + 1e-10)
  ## nu is strictly below its upper bound, so alpha is strictly below
  ## nu_upper_bound + d/2 and the top band is the one just under it.
  m_alpha_max <- floor(nu_upper_bound + d / 2 - 1e-10)
  if (m_alpha_max > 2) {
    stop(paste0(
      "type.rational.approx = 'wl2' is only implemented for floor(alpha) ",
      "equal to 0, 1 or 2, but nu.upper.bound = ", nu_upper_bound,
      " with d = ", d, " reaches floor(alpha) = ", m_alpha_max, "."
    ))
  }
  frac <- seq_len(999) / 1000
  out <- NULL
  for (ma in m_alpha_min:m_alpha_max) {
    tab <- if (is.null(wl2_table)) {
      wl2_coefficient_table(d = d, m = m, m_alpha = ma, type = "covariance")
    } else {
      wl2_table
    }
    rows <- vapply(ma + frac, function(a) {
      cf <- wl2_interp_coefficients(tab, a)
      c(a, cf$r, cf$p, if (is.null(cf$p0)) 0 else cf$p0)
    }, numeric(2 * m + 2))
    out <- rbind(out, t(rows))
  }
  colnames(out) <- c(
    "alpha", paste0("r", seq_len(m)), paste0("p", seq_len(m)), "p0"
  )
  attr(out, "m_alpha_min") <- m_alpha_min
  out
}

#' @name wl2_operator_type
#' @title The rational type to use for the operator-based construction
#' @description The operator-based factorisation has a single table of roots,
#' produced by the chebfun lower-bound method, so `"brasil"` and `"chebfun"`
#' are refused there rather than quietly given those roots. The default of
#' [matern.operators()] and [spde.matern.operators()] is `"brasil"`, which is
#' right for `type = "covariance"`; a caller who never named a type should not
#' be refused because of it, so an untouched default becomes `"chebfunLB"`
#' here. A type the caller did name is passed through and refused if it is one
#' of the two.
#' @param type The value of `type_rational_approximation`.
#' @param user_set Did the caller name a type?
#' @return A single type name.
#' @noRd
wl2_operator_type <- function(type, user_set) {
  if (!user_set) {
    return("chebfunLB")
  }
  type[[1]]
}

Try the rSPDE package in your browser

Any scripts or data that you put into this service are public.

rSPDE documentation built on Nov. 10, 2026, 5:08 p.m.