This vignette is aimed at developers who want to understand how the mizerSeasonal package is implemented, contribute to it, or write their own extensions. It describes the source layout, explains how the package hooks into mizer’s extension mechanism, and highlights the subtle or surprising aspects of the implementation.
Source layout
The package has seven R source files, listed here in the order they
are collated (as specified by the Collate field in
DESCRIPTION):
| File | Contents |
|---|---|
mizerSeasonal-class.R |
S4 marker class definitions: mizerSeasonal and
mizerSeasonalSim
|
animate.R |
animateGonadSpectra() and two private helpers |
data.R |
Documentation for the datta_params dataset |
mizerSeasonal-package.R |
Package-level documentation, @import directives, and
.onLoad hook |
plots.R |
getTimeseries(), plotRDI(),
plotRDD(), plotGonadsVsTime()
|
release_functions.R |
The four release-rate functions |
seasonal.R |
Core machinery: setSeasonalReproduction(),
gonadDynamics(), projectRDI.mizerSeasonal(),
projectEncounter.mizerSeasonal(),
seasonalBevertonHoltRDD(),
seasonalVonMisesRDD(),
seasonal_resource_semichemostat(), and two private
helpers |
mizerSeasonal-class.R is collated first so that the S4
class definitions are available before any code that references them.
animate.R uses unexported mizer functions
(get_time_elements) and dplyr verbs imported at the package
level in mizerSeasonal-package.R.
How the package hooks into mizer
Mizer ships a formal extension API. The key functions used by this package are:
-
registerExtension(pkgname, ...)— called once in.onLoadto register the package as a mizer extension for the current R session. -
setComponent(params, component, initial_value, dynamics_fun)— registers a new dynamical state variable (here, the gonadic mass matrix). -
setRateFunction(params, rate, fun)— replaces one of mizer’s standard rate calculators with a custom one. Used here only for the RDD function; RDI and Encounter are handled via S3 dispatch instead (see below). -
coerceToExtensionClass(params)— promotes aMizerParamsobject to the registered S4 extension class (here,mizerSeasonal), enabling S3 method dispatch onproject*generics. -
other_params(params)/other_params(params) <-— a named list for arbitrary configuration attached to aMizerParamsobject.
The .onLoad hook
.onLoad <- function(libname, pkgname) {
mizer::registerExtension(pkgname, requirement = "sizespectrum/mizerSeasonal")
}This runs when the package is loaded and tells mizer about the
extension. It is what makes coerceToExtensionClass() know
which S4 class to promote to.
S4 marker classes
Two S4 classes are defined in mizerSeasonal-class.R:
setClass("mizerSeasonal", contains = "MizerParams")
setClass("mizerSeasonalSim", contains = "MizerSim")These are marker classes: they carry no additional slots.
Their sole purpose is to enable S3 method dispatch on the
project* generics. When mizer calls
e.g. projectRDI(params, ...), it dispatches to
projectRDI.mizerSeasonal because params has
class mizerSeasonal. Similarly, project()
returns a mizerSeasonalSim because MizerSim
objects are coerced to the registered extension sim class.
setSeasonalReproduction()
setSeasonalReproduction() wires everything together:
setSeasonalReproduction <- function(params,
release_func = "seasonalVonMisesRelease",
RDD = "seasonalBevertonHoltRDD",
include_gonads = TRUE) {
initial <- initialN(params)
initial[] <- 0
other_params(params)$release_func <- release_func # <-- config
other_params(params)$include_gonads <- include_gonads
p <- setComponent(params, # <-- new state variable
component = "gonads",
initial_value = initial,
dynamics_fun = "gonadDynamics") |>
setRateFunction("RDD", RDD) # <-- replace RDD only
p@extensions <- mizer::getRegisteredExtensions()
p <- mizer::coerceToExtensionClass(p) # <-- promote to mizerSeasonal
return(p)
}Note that setRateFunction("RDI", ...) and
setRateFunction("Encounter", ...) are not
called. Instead, those rates are intercepted via the S3 methods
projectRDI.mizerSeasonal and
projectEncounter.mizerSeasonal described in the next two
sections. The RDD function is still set via setRateFunction
because projectRDD.MizerParams already dispatches
internally through params@rates_funcs$RDD; no S3 override
is needed there.
Where the release function name is stored
The choice of release function is stored as a character string inside
other_params():
other_params(params)$release_func # e.g. "seasonalVonMisesRelease"Internally mizer stores this in
params@other_params$other$release_func, but you should
always use the other_params() accessor rather than the
@ slot directly, because the slot layout may change across
mizer versions.
At runtime, gonadDynamics() and
projectRDI.mizerSeasonal() retrieve the function with:
release_func <- get0(other_params(params)$release_func)get0() is used instead of get() because it
returns NULL rather than throwing an error when the name is
not found. If release_func is NULL the code
will fail on the next line, which gives a slightly clearer error than
the one that get() would raise.
Where the initial gonadic mass lives
The initial gonad matrix (all zeros) is stored in:
params@initial_n_other$gonads # matrix (species x sizes)This is mizer’s standard storage for the initial state of
other components. It is set by setComponent()
and is what project() uses to initialise the
simulation.
The gonadic dynamics solver
gonadDynamics() solves the PDE for the per-capita
gonadic mass \(q(w,t)\) at each time
step. The derivation of the PDE is given in the
gonadic_mass_dynamics vignette; the result is a tridiagonal
linear system of the form
\[a_{ij}\, q_{j-1} + b_{ij}\, q_j = s_{ij}\]
where the coefficients are:
# a_{ij} = -g_i(w_j) * dt / dw_j (sub-diagonal: growth advection)
a <- sweep(-rates$e_growth * dt, 2, params@dw, "/")
# b_{ij} = 1 + g_i(w_j) * dt / dw_j + r_i * dt (diagonal: advection + release)
b <- 1 + sweep(rates$e_growth * dt, 2, params@dw, "/") + r * dt
# s_{ij} = q_{ij} + e_repro_{ij} * dt (right-hand side: old state + investment)
s <- n_other$gonads + rates$e_repro * dtThe system is then solved by calling mizer’s internal C++ routine:
mizer:::inner_project_loop(no_sp = no_sp, no_w = no_w,
n = n_other$gonads,
A = a, B = b, S = s,
w_min_idx = params@w_min_idx)The ::: call is intentional:
inner_project_loop is the same forward-sweep solver that
mizer uses for the fish number density, and there is no public wrapper
for it. This creates a hard dependency on mizer’s internal layout, so
any future mizer refactor that renames or moves this function will break
gonadDynamics. The dependency is accepted because (a) the
physics of the two PDEs is identical, so reusing the solver is correct,
and (b) the alternative would be to duplicate several hundred lines of
compiled C++.
Release functions
All four release functions share the same signature:
and return a numeric vector of length equal to the number of species, giving the mass-specific gonad release rate \(r_i(t)\) for each species \(i\) at time \(t\).
Periodicity mechanisms
All release functions produce a rate that is periodic with a period of exactly one year. They achieve this in slightly different ways:
| Function | Periodicity mechanism |
|---|---|
seasonalVonMisesRelease |
\(\cos(2\pi(t-\mu))\) is inherently \(1\)-periodic |
seasonalBetaHazardRelease |
t - floor(t) maps any \(t\) to \([0,1)\)
|
seasonalBetaRelease |
t - floor(t) |
seasonalGaussianRelease |
t - trunc(t) |
trunc() and floor() agree for positive
arguments (both truncate towards zero), so the last two are equivalent
in practice. The inconsistency between them is a historical artefact
rather than a deliberate design choice.
The von Mises rate at the seasonal peak and trough
The von Mises release rate is
\[r(t) = r_0 \frac{e^{\kappa \cos(2\pi(t-\mu))}}{2\pi I_0(\kappa)}\]
The cosine reaches its maximum of \(+1\) at \(t = \mu\) and its minimum of \(-1\) at \(t = \mu + 0.5\), so:
\[r(\mu) = \frac{r_0\, e^{\kappa}}{2\pi I_0(\kappa)}, \qquad r(\mu + 0.5) = \frac{r_0\, e^{-\kappa}}{2\pi I_0(\kappa)}\]
The ratio of peak to trough rate is \(e^{2\kappa}\), so a concentration of \(\kappa = 2\) gives a ratio of about \(54\).
Density-dependent reproduction:
seasonalBevertonHoltRDD
This function is a time-varying version of mizer’s built-in
BevertonHoltRDD. It applies the formula
\[R_{dd}(t) = \frac{R_{di}(t)\, R_{\max}(t)}{R_{di}(t) + R_{\max}(t)}\]
where \(R_{\max}(t)\) is looked up
from a 2-D array stored in other_params(params)$r_max. That
array must have a time dimension in its
dimnames:
# Required structure of other_params(params)$r_max:
times <- seq(0, 0.9, by = 0.1)
r_max <- matrix(
values,
nrow = length(times), ncol = no_sp,
dimnames = list(time = as.character(round(times, 5)),
species = sp_names)
)
other_params(params)$r_max <- r_maxThe time lookup converts the fractional year to a character key:
t_name <- as.character(round(t - floor(t), 5))
r_max <- other_params(params)$r_max[t_name, ]Two practical consequences follow from this design:
- The
r_maxarray must be pre-populated for every time step at which the simulation will be evaluated (i.e., every multiple ofdtwithin[0, 1)). Missing entries cause an explicitstop(). - Floating-point rounding in
as.character(round(x, 5))can produce surprising keys. For example,as.character(round(0.1, 5))is"0.1"on most platforms, but very small floating-point errors in the simulation clock could pusht - floor(t)to something like0.09999999999which rounds to"0.1". This is typically safe, but worth being aware of ifdtis very small (e.g.,dt = 0.001).
seasonalVonMisesRDD: an unusual signature
This function calculates a von Mises-distributed reproduction rate that is independent of the actual egg production. Its intended use is as a placeholder during model calibration (before a proper \(R_{\max}\) has been estimated). Its signature is:
Mizer calls all RDD functions as:
Because rdi is not in seasonalVonMisesRDD’s
formal argument list, it is silently absorbed by ... and
ignored. This works, but means that:
- The function cannot be used for a calibrated simulation: it ignores egg production entirely and can produce densities that are inconsistent with the gonadic dynamics.
- The documentation explicitly warns that it “should be replaced by another reproduction rate function before simulating the dynamics.”
projectRDI.mizerSeasonal: gonadic-release egg
production
This S3 method replaces mizer’s standard energy-based RDI calculation
entirely. It does not call NextMethod().
Instead it computes egg production directly from the gonadic
release:
projectRDI.mizerSeasonal <- function(params, n, n_pp, n_other, t = 0,
e_growth, mort, e_repro, ...) {
release_func <- get0(other_params(params)$release_func)
r <- release_func(t, params) # mass-specific release rate (species vector)
total <- drop((sweep(n_other$gonads, 1, r, "*") * n) %*% params@dw)
0.5 * (total * params@species_params$erepro) /
params@w[params@w_min_idx]
}r * gonads * n * dw integrates to give the total gonadic
mass released per unit time per species. Dividing by the egg weight and
multiplying by erepro / 2 (the female fraction ×
reproductive efficiency) converts mass to egg numbers. The
e_growth, mort, and e_repro
arguments are present only for generic compatibility and are not
used.
projectEncounter.mizerSeasonal: prey mass includes
gonads
This S3 method is additive: it calls
NextMethod() to get the standard encounter rate and then
appends the extra encounter arising from the gonadic mass carried by
prey fish. The approach is mathematically exact because the encounter
integral is linear in prey biomass:
\[E(w_\text{somatic} + q) = E(w_\text{somatic}) + E(q)\]
so the two contributions can be computed separately and summed.
The gonad contribution is only added when
include_gonads = TRUE (set in
setSeasonalReproduction). When it is FALSE,
the method returns NextMethod() unchanged.
Note that the gonad contribution is zero if the predator–prey
interaction matrix entry for that predator–prey species pair is zero. In
a single-species model built with newSingleSpeciesParams()
the interaction matrix has interaction[1,1] = 0 by default
(no self-predation), so the gonad contribution is exactly zero and
projectEncounter.mizerSeasonal returns the same result as
mizerEncounter. To observe a difference you need at least
one nonzero fish-on-fish interaction.
The method contains two code paths — one using the FFT convolution
(the fast default) and one using direct matrix multiplication (used when
the user has supplied a custom pred_kernel). In the FFT
path, prey_gonad is swept by dw_full only (not
w_full * dw_full) because gonads already
represents absolute mass rather than the mass density \(w \cdot N(w)\) used for somatic
biomass.
seasonal_resource_semichemostat: configuration via
resource_params$rp
This function modulates the resource carrying capacity with a von
Mises seasonal signal. It reads its parameters from a named sub-list
rp of the resource_params slot:
params@resource_params$rp <- list(kappa = 2, mu = 0.25, maxR = 0.5)| Key | Meaning |
|---|---|
kappa |
Concentration of the von Mises peak (higher = sharper season) |
mu |
Time of peak resource abundance within the year |
maxR |
Fractional amplitude: capacity is multiplied by \((1 + \text{maxR} \cdot \text{vonMises})\) |
This differs from how the release functions receive their parameters
(via species_params columns). The choice of
resource_params$rp is because the resource has no species
dimension, so species_params is not appropriate.
Data extraction: getTimeseries
getTimeseries(sim, func) loops over the saved time steps
of a MizerSim object and calls func at each
step, reconstructing the per-time-step state vectors that mizer does not
store directly. The reconstruction follows the same pattern that mizer
uses internally:
for (i in time_indices) {
n <- sim@n[i, , ]
dim(n) <- dim(params@initial_n) # restore matrix shape
n_other <- sim@n_other[i, ] # named list of other components
names(n_other) <- dimnames(sim@n_other)[[2]]
value[i, ] <- func(params,
n = n,
n_pp = sim@n_pp[i, ],
n_other = n_other,
t = times[i])
}The func argument defaults to
mizer::getRDI, so getTimeseries(sim) returns
the RDI time series. Pass mizer::getRDD to get the RDD time
series, which is what plotRDD does.
Plotting functions
plotRDI and plotRDD delegate the data
extraction to getTimeseries and then rely on mizer’s
internal plotDataFrame helper for the actual rendering. The
data frame passed to plotDataFrame must have its columns in
the specific order (Year, Value, Species), which is why
both functions do an explicit column reorder:
plot_dat <- plot_dat[, c(1, 3, 2)] # reorder to (Year, Value, Species)plotGonadsVsTime is a simpler base-graphics function
that plots the per-capita gonadic mass at the species’ maximum body size
through time. When time_range is supplied as a two-element
vector it is interpreted as (t_min, t_max) and passed to
mizer’s get_time_elements(), which returns a logical index
vector over all saved times — not a count. The number of selected time
steps is therefore sum(time_elements), not
length(time_elements), and the code must use
length(qf) (the number of selected gonad matrices) as the
row dimension of the output matrix:
Test suite
The tests live in tests/testthat/ and are organised as
follows:
| File | What it tests |
|---|---|
helper-setup.R |
Shared fixtures: base_params,
seasonal_params, seasonal_sim
|
test-extension.R |
Extension mechanism: class promotion, S3 dispatch,
project() integration |
test-release_functions.R |
Mathematical properties of all four release functions |
test-seasonal.R |
setSeasonalReproduction, gonadDynamics,
projectRDI.mizerSeasonal,
seasonalBevertonHoltRDD, seasonalVonMisesRDD,
projectEncounter.mizerSeasonal,
seasonal_resource_semichemostat
|
test-plots.R |
getTimeseries, plotRDI,
plotRDD, plotGonadsVsTime,
animateGonadSpectra
|
test-extension.R uses its own
local_params() helper (built on
NS_species_params) rather than the shared
seasonal_params fixture. This keeps the extension-mechanism
tests self-contained and avoids depending on the single-species model
that the shared fixture uses.
The fixture seasonal_params uses
seasonalVonMisesRDD as the RDD function (rather than the
default seasonalBevertonHoltRDD) to avoid having to
construct the r_max time-series array that
seasonalBevertonHoltRDD requires. Tests of
seasonalBevertonHoltRDD itself construct a minimal
r_max inline.
The fixture seasonal_sim is produced by
project(seasonal_params, t_max = 2, dt = 0.1, t_save = 0.5),
giving five saved time points (0, 0.5, 1, 1.5, 2). Keeping
t_max small and t_save coarse makes the helper
fast to load without sacrificing coverage of the plot functions that
need a MizerSim object.