---
title: "Analytic Test"
output:
  html_document:
    toc: yes
    fig_width: 5
    fig_height: 5
vignette: >
  %\VignetteIndexEntry{Analytic Test}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

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

This vignette describes an analytical test for the transport equation solver used in mizer.

## The transport equation

The time evolution of the size spectrum $N(w)$ is described by the McKendrick-von Foerster equation with an added diffusion term:

\begin{equation}
  \frac{\partial N}{\partial t} + \frac{\partial}{\partial w} \left( g N - \frac{1}{2}\frac{\partial(D N)}{\partial w} \right) = -\mu N,
\end{equation}

where $g(w)$ is the growth rate, $\mu(w)$ is the mortality rate and $D(w)$ is the diffusion rate.

We will look for a solutions when the rates are power laws of the form:

\begin{align}
g(w) &= A w^p, \\
\mu(w) &= B w^{p-1}, \\
D(w) &= K w^{p+1}.
\end{align}


## Analytical steady-state solution

We try a power-law ansatz for the solution:
$$ N(w) = C w^{-\lambda}. $$
Substituting these forms into the transport equation at steady state ($\partial N / \partial t = 0$):

$$ \frac{\partial}{\partial w} \left( A w^p C w^{-\lambda} - \frac{1}{2}\frac{\partial}{\partial w}(K w^{p+1} C w^{-\lambda}) \right) = - B w^{p-1} C w^{-\lambda}. $$

We simplify the term inside the derivative:
$$ D N = K C w^{p+1-\lambda}, $$
$$ \frac{\partial(D N)}{\partial w} = K C (p+1-\lambda) w^{p-\lambda}, $$
$$ g N - \frac{1}{2}\frac{\partial(D N)}{\partial w} = \left( A - \frac{1}{2} K (p+1-\lambda) \right) C w^{p-\lambda}.$$
Thus the flux is $J = J_0 w^{p-\lambda}$ with $J_0 = C \left( A - \frac{1}{2} K (p+1-\lambda) \right)$.

Differentiating the flux with respect to $w$ gives
$$ \frac{\partial J}{\partial w} = J_0 (p-\lambda) w^{p-\lambda-1}. $$

The RHS is
$$ -\mu N = - B C w^{p-1-\lambda}. $$

Equating LHS and RHS gives
$$ C \left( A - \frac{1}{2} K (p+1-\lambda) \right) (p-\lambda) w^{p-\lambda-1} = - B C w^{p-\lambda-1}. $$

Dividing by $C w^{p-\lambda-1}$ (assuming $C \neq 0$ and $w \neq 0$):
$$ \left( A - \frac{1}{2} K (p+1-\lambda) \right) (p-\lambda) + B = 0. $$

Let $x = p - \lambda$. Then $p + 1 - \lambda = x + 1$. The equation becomes:
$$ \left( A - \frac{1}{2} K (x+1) \right) x + B = 0. $$
or, equivalently,
$$ Ax - \frac{1}{2} K x^2 - \frac{1}{2} K x + B = 0. $$
We multiply by -2:
$$ K x^2 - (2A - K) x - 2B = 0. $$

This is a quadratic equation for $x = p - \lambda$. The solutions are:
$$ x = \frac{(2A - K) \pm \sqrt{(2A - K)^2 + 8KB}}{2K}.$$

We are interested in the solution that corresponds to the limit of small diffusion $K \to 0$.
In that limit, $g N \sim C A w^{p-\lambda}$ and $\frac{\partial (gN)}{\partial w} \sim C A (p-\lambda) w^{p-\lambda-1}$.
The transport equation without diffusion is $\frac{\partial (gN)}{\partial w} = -\mu N$.
$$ A (p-\lambda) = -B \implies x = p-\lambda = -B/A. $$
Since $A, B > 0$, $x$ should be negative.
Let's check the roots. $(2A-K)^2 + 8KB > (2A-K)^2$, so the square root is larger than $|2A-K|$.
The term $(2A-K)$ is positive for small $K$.
The positive root is $\frac{(2A-K) + \text{larger}}{2K} > 0$.
The negative root is $\frac{(2A-K) - \text{larger}}{2K} < 0$.
So we need the negative root:

$$
\begin{split}
\lambda &= p + \frac{(2A - K) - \sqrt{(2A - K)^2 + 8KB}}{2K}\\
&=p - \frac{4B}{(2A - K) + \sqrt{(2A - K)^2 + 8KB}}.
\end{split}
$$
The second expression above is better for numerical evaluation because it avoids the subtraction of two similarly sized numbers.

### Numerical verification

We verify this analytical solution by considering a single species in mizer and checking if the `project()` function keeps the system in this steady state. First we set up a mizer model with the power law rates:

```{r}
library(mizer)

# Parameters
p <- 0.7
A <- 1
B <- 0.5
K <- 0.1

# Calculate lambda
# coefficients for K x^2 - (2A - K) x - 2B = 0
a_quad <- K
b_quad <- -(2*A - K)
c_quad <- -2*B

det <- b_quad^2 - 4 * a_quad * c_quad
x <- 4 * B / (-b_quad + sqrt(det))
lambda <- p + x

# Set up mizer params
# We create a dummy species
sp <- data.frame(species = "Test",
                 w_max = 1000,
                 w_mat = 100)
params <- newMultispeciesParams(sp, no_w = 1000, min_w = 1e-3,
                                info_level = 0)

# Set initial N to analytical solution
initialN(params) <- matrix(w(params)^(-lambda), nrow = 1, byrow = TRUE)

# Define custom rate functions
# Growth
start_growth <- function(params, ...) {
    matrix(A * params@w^p, nrow = 1, byrow = TRUE)
}
params <- setRateFunction(params, "EGrowth", "start_growth")

# Mort
start_mort <- function(params, ...) {
    matrix(B * params@w^(p - 1), nrow = 1, byrow = TRUE)
}
params <- setRateFunction(params, "Mort", "start_mort")

# Diffusion
ext_diffusion(params)[1, ] <- K * w(params)^(p + 1)

# RDD (Constant Flux)
params@species_params$constant_reproduction <- getRequiredRDD(params)
params <- setRateFunction(params, "RDD", "constantRDD")
```
We can now project this forward in time and check that the result stays the same.
```{r}
# Run project
# We verify that N stays constant.
sim <- project(params, t_max = 1, dt = 0.1, method = "predictor-corrector")

# Compare final N with initial N
n0 <- initialN(params)[1, ]
n1 <- finalN(sim)[1, ]

# Plot
plot(w(params), n0, log="xy", type="l", col="blue", lwd=2, 
     main="Comparison of numerical and analytical solution",
     xlab="Size", ylab="Density")
lines(w(params), n1, col="red", lty=2, lwd=2)
legend("topright", legend=c("Analytical", "Numerical"), 
       col=c("blue", "red"), lty=c(1, 2))

# Calculate relative error
rel_err <- abs(n1 - n0) / n0
# ignore last 100 bins because they are affected by the right boundary
max_rel_err <- max(rel_err[1:980])
print(paste("Maximum relative error:", max_rel_err))

if (max_rel_err < 0.01) {
  print("Test passed: Numerical solution stays close to analytical steady state.")
} else {
  print("Test failed: Numerical solution deviates from analytical steady state.")
}
```

## Time-dependent analytical solution

To facilitate an analytical solution for time-dependent problems, we first transform the size variable $w$ to a new variable $x$:
$$ x = \frac{w^{1-p}}{1-p}. $$
Assuming $p \neq 1$. Then $w = ((1-p)x)^{\frac{1}{1-p}}$ and $\frac{dx}{dw} = w^{-p}$.

We define the density in $x$-space, $\tilde{N}(x, t)$, such that $\tilde{N}(x, t) dx = N(w, t) dw$. Thus
$$ \tilde{N}(x, t) = N(w, t) \frac{dw}{dx} = N(w, t) w^p. $$

Substituting this into the transport equation and simplifying leads to a PDE of the form:
$$ \frac{\partial \tilde{N}}{\partial t} = V x \frac{\partial^2 \tilde{N}}{\partial x^2} + (V - U) \frac{\partial \tilde{N}}{\partial x} - \frac{b}{x} \tilde{N} ,$$
where $U = A - \frac{1}{2}K$, $V = \frac{1}{2} K (1-p)$, $b = \frac{B}{1-p}$.

The fundamental solution (Green's function) for this equation, describing the evolution of an initial Dirac delta distribution $\tilde{N}(x, 0) = \delta(x - x_0)$, is given by
$$ G(x, t; x_0) = \frac{1}{Vt} \left( \frac{x}{x_0} \right)^{\frac{U}{2V}} \exp\left( -\frac{x+x_0}{Vt} \right) I_\nu \left( \frac{2\sqrt{xx_0}}{Vt} \right), $$
where $I_\nu$ is the modified Bessel function of the first kind of order $\nu$, given by
$$ \nu = \frac{1}{V} \sqrt{U^2 + 4Vb} .$$

The solution in terms of the original size distribution $N(w, t)$ is then
$$ N(w, t) = G(x(w), t; x(w_0)) w^{-p} .$$

### Numerical verification

We verify this time-dependent solution by starting the simulation with the analytical distribution at a small time $t_{start} > 0$ (to avoid the singularity at $t=0$) and projecting it to a later time $t_{end}$.

```{r}
# Function to calculate N analytic
N_analytic <- function(w, t, w0, t0, K_eff = 0.1) {
  # Parameters
  p <- 0.7
  A <- 1
  B <- 0.5
  
  # Transformed parameters
  U <- A - 0.5 * K_eff
  V <- 0.5 * K_eff * (1 - p)
  b <- B / (1 - p)
  nu <- sqrt((U/V)^2 + 4 * b / V)

  # Time elapsed
  dt <- t - t0
  if (dt <= 0) stop("t must be greater than t0")
  
  # Transform to x
  x <- w^(1 - p) / (1 - p)
  x0 <- w0^(1 - p) / (1 - p)
  
  # Argument for Bessel
  z <- 2 * sqrt(x * x0) / (V * dt)
  
  # Logarithm of N_tilde using scaled Bessel to avoid overflow
  bessel_scaled <- besselI(z, nu, expon.scaled = TRUE)
  
  log_N_tilde <- -log(V * dt) + 
                 (U / (2 * V)) * log(x / x0) - 
                 (x + x0) / (V * dt) + 
                 z + 
                 log(bessel_scaled)
  
  N_tilde <- exp(log_N_tilde)
  
  # Transform back to N(w)
  N <- N_tilde * w^(-p)
  
  return(N)
}
```

```{r}
# Initial Condition
w0 <- 1e-2
t_start <- 0.1
t_end <- 2

# Set initial N from analytical solution
initial_n <- N_analytic(w(params), t_start, w0, 0)
initialN(params) <- matrix(initial_n, nrow = 1, byrow = TRUE)
```

We can simplify the left boundary condition because it is far enough away from
the Gaussian for us to set the solution to 0 there.
```{r}
# Set RDD to 0
params@species_params$constant_reproduction <- 0
params <- setRateFunction(params, "RDD", "constantRDD")
```

Now we project forward in time.
```{r}
# Run project
sim <- project(params, t_max = t_end - t_start, dt = 0.05,
               t_save = t_end - t_start,
               method = "predictor-corrector")

# Compare
final_n_num <- finalN(sim)[1, ]
final_n_ana <- N_analytic(w(params), t_end, w0, 0)

# Plot
plot(w(params), final_n_num, log = "x", type = "l", col = "red", lwd = 2, 
     main = "Time Dependent Check", xlab = "Size", ylab = "Density")

lines(w(params), final_n_ana, col = "blue", lty = 2, lwd = 2)
legend("topright", legend = c("Numerical", "Analytical"),
       col = c("red", "blue"), lty = c(1, 2))
```

This looks good but let us also look at the agreement quantitatively:
```{r}
# Robust comparison metrics
# 1. Total Abundance (Conservation)
total_n_num <- sum(final_n_num * params@dw)
total_n_ana <- sum(final_n_ana * params@dw)
rel_err_total <- abs(total_n_num - total_n_ana) / total_n_ana

# 2. Peak Location
peak_idx_num <- which.max(final_n_num)
peak_idx_ana <- which.max(final_n_ana)
peak_w_num <- w(params)[peak_idx_num]
peak_w_ana <- w(params)[peak_idx_ana]
rel_err_peak_loc <- abs(peak_w_num - peak_w_ana) / peak_w_ana

# 3. Peak Height
peak_val_num <- max(final_n_num)
peak_val_ana <- max(final_n_ana)
rel_err_peak_val <- abs(peak_val_num - peak_val_ana) / peak_val_ana

print(paste("Total Abundance Error:", rel_err_total))
print(paste("Peak Location Error:", rel_err_peak_loc))
print(paste("Peak Height Error:", rel_err_peak_val))

# Pass conditions: 
#   < 0.5% abundance error, 
#   < 5% location shift, 
#   < 4% height difference (diffusive flattening)
if (rel_err_total < 0.005 && 
    rel_err_peak_loc < 0.05 && 
    rel_err_peak_val < 0.04) {
  print("Time-dependent test passed.")
} else {
  print("Time-dependent test failed.")
}
```


## Numerical diffusion

### In the Euler method

The Euler method uses a first-order upwind treatment of the advective growth
term. This introduces numerical diffusion. For locally constant growth rate and
grid spacing, the expected numerical diffusion is
$$ D_\mathrm{num} \approx g(w) \Delta w + g(w)^2 \Delta t. $$

On the logarithmic mizer grid we have approximately
$\Delta w = (\beta_\mathrm{grid} - 1)w$. To keep the analytical solution in the
same power-law family, we use the special case $p = 1$. Then
$g(w) = A w$ and both numerical diffusion terms scale like $w^2$:
$$
D_\mathrm{num}(w) \approx
\left(A(\beta_\mathrm{grid} - 1) + A^2 \Delta t\right)w^2.
$$
So the Euler method with physical diffusion $K w^2$ should behave like the
continuous equation with
$$ K_\mathrm{eff} = K + A(\beta_\mathrm{grid} - 1) + A^2 \Delta t. $$

For $p = 1$ the transformed variable is $x = \log w$. The density
$\tilde N(x, t) = w N(w, t)$ satisfies an advection-diffusion equation with
constant diffusion and constant mortality. The Green's function is therefore a
lognormal density:
$$
N(w,t) = \frac{\exp[-B(t-t_0)]}{w}
  \phi\left(\log w;\,
            \log w_0 + \left(A - \frac{K_\mathrm{eff}}{2}\right)(t-t_0),
            K_\mathrm{eff}(t-t_0)\right),
$$
where $\phi(x; m, v)$ is the normal density with mean $m$ and variance $v$.

```{r}
# Parameters for the Euler numerical diffusion check
p_euler <- 1
A_euler <- 1
B_euler <- 0.5
K_euler <- 0.1
dt_euler <- 0.01

N_lognormal <- function(w, t, w0, t0, K_eff) {
    dt <- t - t0
    if (dt <= 0) stop("t must be greater than t0")
    
    x <- log(w)
    mean_x <- log(w0) + (A_euler - 0.5 * K_eff) * dt
    
    exp(-B_euler * dt) *
        dnorm(x, mean = mean_x, sd = sqrt(K_eff * dt)) / w
}

start_growth_euler <- function(params, ...) {
    matrix(A_euler * params@w^p_euler, nrow = 1, byrow = TRUE)
}

start_mort_euler <- function(params, ...) {
    matrix(B_euler * params@w^(p_euler - 1), nrow = 1, byrow = TRUE)
}

params_euler <- newMultispeciesParams(sp, no_w = 1000, min_w = 1e-3,
                                      info_level = 0)
params_euler <- setRateFunction(params_euler, "EGrowth",
                                "start_growth_euler")
params_euler <- setRateFunction(params_euler, "Mort", "start_mort_euler")
ext_diffusion(params_euler)[1, ] <- K_euler * w(params_euler)^2

# The pulse stays well away from the left boundary, so no recruitment enters.
params_euler@species_params$constant_reproduction <- 0
params_euler <- setRateFunction(params_euler, "RDD", "constantRDD")

beta_grid <- w(params_euler)[2] / w(params_euler)[1]
K_num <- A_euler * (beta_grid - 1) + A_euler^2 * dt_euler
K_eff <- K_euler + K_num

w0_euler <- 1e-2
t_start_euler <- 0.1
t_end_euler <- 1

initial_n_euler <- N_lognormal(w(params_euler), t_start_euler, w0_euler,
                               0, K_eff)
initialN(params_euler) <- matrix(initial_n_euler, nrow = 1, byrow = TRUE)
```

We project with the Euler method and compare against the exact solution with
the effective diffusion coefficient.

```{r}
sim_euler <- project(params_euler,
                     t_max = t_end_euler - t_start_euler,
                     dt = dt_euler,
                     t_save = t_end_euler - t_start_euler,
                     t_start = t_start_euler,
                     method = "euler",
                     progress_bar = FALSE)

final_n_euler <- finalN(sim_euler)[1, ]
final_n_euler_ana <- N_lognormal(w(params_euler), t_end_euler, w0_euler,
                                 0, K_eff)

plot(w(params_euler), final_n_euler, log = "x", type = "l", col = "red",
     lwd = 2, main = "Euler Check with Numerical Diffusion",
     xlab = "Size", ylab = "Density")
lines(w(params_euler), final_n_euler_ana, col = "blue", lty = 2, lwd = 2)
legend("topright", legend = c("Euler", "Analytical with K_eff"),
       col = c("red", "blue"), lty = c(1, 2))
```

```{r}
total_euler <- sum(final_n_euler * params_euler@dw)
total_euler_ana <- sum(final_n_euler_ana * params_euler@dw)
rel_err_total_euler <- abs(total_euler - total_euler_ana) / total_euler_ana

peak_idx_euler <- which.max(final_n_euler)
peak_idx_euler_ana <- which.max(final_n_euler_ana)
peak_w_euler <- w(params_euler)[peak_idx_euler]
peak_w_euler_ana <- w(params_euler)[peak_idx_euler_ana]
rel_err_peak_loc_euler <- abs(peak_w_euler - peak_w_euler_ana) /
    peak_w_euler_ana

peak_val_euler <- max(final_n_euler)
peak_val_euler_ana <- max(final_n_euler_ana)
rel_err_peak_val_euler <- abs(peak_val_euler - peak_val_euler_ana) /
    peak_val_euler_ana

print(paste("Expected numerical diffusion coefficient:", K_num))
print(paste("Effective diffusion coefficient:", K_eff))
print(paste("Total Abundance Error:", rel_err_total_euler))
print(paste("Peak Location Error:", rel_err_peak_loc_euler))
print(paste("Peak Height Error:", rel_err_peak_val_euler))

if (rel_err_total_euler < 0.005 &&
    rel_err_peak_loc_euler < 0.005 &&
    rel_err_peak_val_euler < 0.05) {
  print("Euler numerical diffusion test passed.")
} else {
  print("Euler numerical diffusion test failed.")
}
```

### In the predictor-corrector method

The first-order upwind discretisation of the growth term introduces numerical
diffusion even when the predictor-corrector time step is used. For this method
the leading-order time discretisation diffusion is removed, so the expected
additional diffusion is only the spatial contribution
$$D_\mathrm{num}(w) \approx g(w) \Delta w.$$
On the logarithmic mizer grid this has the same power-law form as the physical
diffusion:
$$
D_\mathrm{num}(w) \approx A(\beta_\mathrm{grid} - 1) w^{p+1}.
$$
We can therefore check whether the agreement improves if the exact solution is
evaluated with
$$K_\mathrm{eff} = K + A(\beta_\mathrm{grid} - 1).$$

```{r}
beta_grid <- w(params)[2] / w(params)[1]
K_num_pc <- A * (beta_grid - 1)
K_eff_pc <- K + K_num_pc

final_n_ana_pc <- N_analytic(w(params), t_end, w0, 0, K_eff_pc)

total_n_ana_pc <- sum(final_n_ana_pc * params@dw)
rel_err_total_pc <- abs(total_n_num - total_n_ana_pc) / total_n_ana_pc

peak_idx_ana_pc <- which.max(final_n_ana_pc)
peak_w_ana_pc <- w(params)[peak_idx_ana_pc]
rel_err_peak_loc_pc <- abs(peak_w_num - peak_w_ana_pc) / peak_w_ana_pc

peak_val_ana_pc <- max(final_n_ana_pc)
rel_err_peak_val_pc <- abs(peak_val_num - peak_val_ana_pc) / peak_val_ana_pc

print(paste("Predictor-corrector numerical diffusion coefficient:", K_num_pc))
print(paste("Predictor-corrector effective diffusion coefficient:", K_eff_pc))
print(paste("Adjusted Total Abundance Error:", rel_err_total_pc))
print(paste("Adjusted Peak Location Error:", rel_err_peak_loc_pc))
print(paste("Adjusted Peak Height Error:", rel_err_peak_val_pc))

if (rel_err_total_pc < rel_err_total &&
    rel_err_peak_loc_pc <= rel_err_peak_loc &&
    rel_err_peak_val_pc < rel_err_peak_val) {
  print("Accounting for numerical diffusion improves the agreement.")
} else {
  print("Accounting for numerical diffusion does not improve all metrics.")
}

# Pass conditions: 
#   < 0.5% abundance error, 
#   < 5% location shift, 
#   < 4% height difference (diffusive flattening)
if (rel_err_total_pc < 0.001 && 
    rel_err_peak_loc_pc < 1e-6 && 
    rel_err_peak_val_pc < 0.03) {
  print("Numerical diffusion test passed.")
} else {
  print("Numerical diffusion test failed.")
}
```
