Add getStability() for steady-state dynamic stability analysis#452
Add getStability() for steady-state dynamic stability analysis#452gustavdelius wants to merge 15 commits into
Conversation
|
Pushed an additional commit ( Detect and report limit cycles in These functions previously had a single convergence test — whether two states
Verification: full NS model at effort 2 → |
Adds two new experimental features:
New exported function that analyses the dynamic stability of a mizer
steady state by numerically differentiating the one-step-ahead map
G(N) = A(N)^{-1} S(N) at the fixed point N*. Uses the same
project_n_loop() C++ Thomas solver as the regular dynamics.
Returns:
- eigenvalues of the linearised map L = dG/dN|_{N*}
- spectral_radius (max|lambda_i|); stable iff < 1
- dominant_period (period of dominant eigenvalue in time steps)
- hopf_period: period of limit cycle near a Hopf bifurcation
(2*pi / |Arg(lambda)| for the complex eigenvalue closest to the
unit circle)
- n_active: dimension of the Jacobian
The include_resource = FALSE default treats the resource as a
quasi-static fast variable (valid for semichemostat dynamics), giving
a fish-only Jacobian. Setting include_resource = TRUE perturbs both
fish and resource cells independently and steps the resource with the
full resource_semichemostat() exact solver, yielding the complete
coupled Jacobian. The dominant eigenvalues agree closely for typical
mizer models where the resource dynamics are fast.
When stability = TRUE, calls getStability() after finding the steady
state and attaches the result as attr(params, 'stability').
Seven new tests in test-steadyNewton.R covering list structure,
spectral radius < 1 for the NS model, eigenvalue/spectral_radius
consistency, stability attribute attachment, hopf_period validity,
and agreement between full and reduced analyses.
- pkgdown/_pkgdown.yml: getStability added to Steady state tuning
- NEWS.md: entry for both new features
…) arrays - eigen(L, only.values=TRUE) -> eigen(L) to capture eigenvectors - Eigenvectors sorted alongside eigenvalues by decreasing modulus - Top 2 eigenvectors reshaped to complex array (n_sp x n_w x 2) with same dimnames as params@initial_n; normalised to max Mod = 1 - For include_resource=TRUE: list with $fish array and $resource matrix (n_w_full x 2) for the resource component - Docs and tests updated
Takes the output of steadyNewton() (with stability analysis attached) and constructs a MizerSim covering one period of the limit cycle in the linear approximation: N(t) = max(N* + A * Re[exp(i*theta*t) * v], 0) where: - v is the leading complex eigenvector from getStability() - theta = 2*pi/T from the dominant complex eigenvalue - A is scaled so max(|delta N| / N*) = amplitude (default 10%) - resource is placed at its quasi-static semichemostat value at each t The returned MizerSim can be passed directly to plotBiomass(), plotSpectra() and other standard mizer plotting functions. Also: - DESCRIPTION Collate: add getLimitCycleSim.R - pkgdown/_pkgdown.yml: add getLimitCycleSim to steady-state section - NEWS.md: entry for both getLimitCycleSim and leading_eigenvectors - 6 new tests in test-getLimitCycleSim.R
…mplex eigenvalue.
Previously these functions had a single convergence test - whether two states t_per years apart are close - which never fires on a limit cycle, so a run that settled onto a cycle went to t_max and returned an arbitrary-phase snapshot as if it were a steady state, with no signal to the caller. They now record a per-species biomass summary at a new fine `t_save` resolution (default dt) by sub-stepping each block, which is numerically identical to the old single-block step. From that series a limit cycle is detected via autocorrelation (period) and a three-period amplitude-stability test that rejects decaying spirals. The nature of the solution is exposed through an attribute attr(result, "convergence") = list(type, converged, distance, years, period, amplitude), where type is "steady", "cycle", "not_converged" or "extinction" - mirroring how steadyNewton() attaches a "stability" attribute. steady() re-stamps the attribute after its setBevertonHolt()/setResource() calls, which would otherwise drop it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the stable-steady-state walkthrough with the North Sea model at fishing effort 2, which sits past a Hopf bifurcation and settles onto a sustained limit cycle. The new flow finds the unstable steady state with steadyNewton(), diagnoses the instability with getStability(include_resource = TRUE), watches the cycle emerge under project(), and demonstrates the new projectToSteady() "convergence" attribute detecting and characterising the cycle (type, period, amplitude). getLimitCycleSim() is retained to visualise the linear mode shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T98LXny6oRNBMy9kHubpo4
New experimental plotBifurcation() draws a bifurcation diagram with fishing effort on the x-axis. For each effort value it settles onto the attractor with projectToSteady() (which stops early on a stable steady state and detects limit cycles), then samples the settled attractor over one detected period with project() and records the per-species min-max envelope of a summary quantity (biomass by default, or yield or ssb). A stable steady state appears as a single line and a limit cycle as a band, so a Hopf bifurcation shows up as the effort at which the band opens. The sweep uses continuation by default, warm-starting each effort value from the previous attractor. return_data = TRUE returns the underlying data frame, including the attractor type reported by projectToSteady(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T98LXny6oRNBMy9kHubpo4
…lative projectToSteady() and steady() gain an amplitude_tol argument (default 0.01) for the relative biomass amplitude above which a persistent oscillation is reported as a limit cycle. Previously the fixed-point convergence tolerance `tol` was reused for this, conflating two quantities on different scales (a sum of squared log-differences versus a fraction of mean biomass), which meant small cycles near a Hopf bifurcation were not detected. The extinction check is now relative: a species is treated as going extinct once its reproduction rate falls below the extinction_threshold fraction (default 1e-6) of its value at the start of the run, replacing the hard-coded absolute 1e-20 floor. Under steady()'s frozen reproduction a healthy species is never flagged, and a zero-reproduction species is flagged immediately, so existing behaviour is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T98LXny6oRNBMy9kHubpo4
plotBifurcation() forwards tol, amplitude_tol and extinction_threshold to the projectToSteady() settling stage. The default tol is set tighter (0.01) than projectToSteady()'s own default so the stable branch settles more fully, giving clean single lines below the bifurcation instead of residual-transient bands, while the lower amplitude_tol resolves the cycle onset earlier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T98LXny6oRNBMy9kHubpo4
When calculating steady states with dynamic reproduction, steadyNewton() previously flagged a species as extinct if ANY of its size classes dropped below the numerical floor. This caused false positives for healthy species whose large-size tails naturally shortened. Furthermore, species that did trigger the warning were incorrectly left at their numerical abundance floor rather than being fully removed. This commit updates the extinction check to only inspect the recruit (egg) abundance, mirroring the behavior of the time-stepping steady() function. If the recruit abundance collapses, the species is correctly flagged as extinct and its abundance vector is entirely zeroed out. Tail-class shortening for healthy species is still safely truncated without triggering the warning. Tests are updated to assert the new zeroed-out behaviour.
Align getLimitCycleSim() with other functions that return a MizerSim by taking a t_save time-step interval (default 0.1) instead of a n_points count. The time grid now spans one full period at the requested spacing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rebase onto master (which removed steadyNewton) left the branch's generated NAMESPACE without the steadyNewton export and stripped the steadyNewton cross-references from helpers.R, transport.R and the numerical_details / cheatsheet vignettes. Restore them so the branch remains a complete, self-consistent feature branch for the dynamic-stability work, and regenerate the man pages (which also corrects a stale include_resource default in getStability.Rd). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T98LXny6oRNBMy9kHubpo4
a1ae619 to
02e5eab
Compare
Summary
Adds two new experimental features for analysing the dynamic stability of a mizer steady state.
New function:
getStability()Analyses the dynamic stability of the steady state stored in
params@initial_nby numerically differentiating the one-step-ahead mapG(N) = A(N)⁻¹ S(N)at the fixed point N*. Uses the sameproject_n_loop()C++ Thomas solver as the regular dynamics (centred finite differences with relative steph).Returns a named list with:
eigenvalues: complex vector of eigenvalues ofL = dG/dN|_{N*}, sorted by decreasing modulusspectral_radius: max|λᵢ|; the steady state is stable iff < 1stable: logicaldominant_period: period of the dominant eigenvalue (in time steps)hopf_period: period2π/|Arg(λ)|of the complex eigenvalue closest to the unit circle — the expected limit-cycle period near a Hopf bifurcation;NULLif no complex eigenvalues existn_active: dimension of the JacobianTwo analysis modes via
include_resource = FALSE(default):FALSE: resource treated as a quasi-static fast variable (valid for semichemostat). For each perturbed N, n_pp is set to its analytic steady-state value. Jacobian dimension = number of active fish cells. This is the slow-manifold reduction.TRUE: both fish and resource cells perturbed independently; resource stepped with the fullresource_semichemostat()exact solver. Jacobian dimension = fish cells + all resource cells. Useful for verifying the quasi-static approximation makes little difference.Updated:
steadyNewton(..., stability = FALSE)When
stability = TRUE, callsgetStability()after finding the steady state and attaches the result asattr(params, "stability").Usage
Implementation notes
The key mathematical point is that the Newton residual Jacobian
J_Fis not equal todG/dN − IwhenAdepends onN(through predation/growth rates). An earlier implementation usingJ_F + Igave a spurious spectral radius of ~147. Direct numerical differentiation ofG(N)is required.Tests
7 new tests in
test-steadyNewton.R:stability = TRUEargument attaches result correctlyhopf_periodvalidityinclude_resource = TRUEstructural checksAll 64 tests pass (57 pre-existing + 7 new).