Skip to content

Add getStability() for steady-state dynamic stability analysis#452

Open
gustavdelius wants to merge 15 commits into
masterfrom
stability-analysis
Open

Add getStability() for steady-state dynamic stability analysis#452
gustavdelius wants to merge 15 commits into
masterfrom
stability-analysis

Conversation

@gustavdelius

Copy link
Copy Markdown
Member

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_n by numerically differentiating the one-step-ahead map G(N) = A(N)⁻¹ S(N) at the fixed point N*. Uses the same project_n_loop() C++ Thomas solver as the regular dynamics (centred finite differences with relative step h).

Returns a named list with:

  • eigenvalues: complex vector of eigenvalues of L = dG/dN|_{N*}, sorted by decreasing modulus
  • spectral_radius: max|λᵢ|; the steady state is stable iff < 1
  • stable: logical
  • dominant_period: period of the dominant eigenvalue (in time steps)
  • hopf_period: period 2π/|Arg(λ)| of the complex eigenvalue closest to the unit circle — the expected limit-cycle period near a Hopf bifurcation; NULL if no complex eigenvalues exist
  • n_active: dimension of the Jacobian

Two 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 full resource_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, calls getStability() after finding the steady state and attaches the result as attr(params, "stability").

Usage

params <- steadyNewton(NS_params, stability = TRUE)
stab <- attr(params, "stability")
stab$stable          # TRUE/FALSE
stab$spectral_radius # dominant eigenvalue modulus
stab$hopf_period     # period in years near Hopf bifurcation, or NULL

# Or call separately:
stab_red  <- getStability(params)                        # quasi-static resource
stab_full <- getStability(params, include_resource = TRUE) # full coupled system

Implementation notes

The key mathematical point is that the Newton residual Jacobian J_F is not equal to dG/dN − I when A depends on N (through predation/growth rates). An earlier implementation using J_F + I gave a spurious spectral radius of ~147. Direct numerical differentiation of G(N) is required.

Tests

7 new tests in test-steadyNewton.R:

  • Well-formed list structure
  • Stable = TRUE for the NS model at its steady state
  • Eigenvalue/spectral_radius consistency
  • stability = TRUE argument attaches result correctly
  • hopf_period validity
  • include_resource = TRUE structural checks
  • Full and reduced analyses agree on spectral radius (tolerance 5%)

All 64 tests pass (57 pre-existing + 7 new).

@gustavdelius

Copy link
Copy Markdown
Member Author

Pushed an additional commit (01ee290e) that extends this work to steady()/projectToSteady():

Detect and report limit cycles in steady()/projectToSteady()

These functions previously 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.

  • Fine sampling: each t_per block is now sub-stepped at a new t_save resolution (default dt), recording a per-species biomass summary. This is numerically bit-identical to the old single-block step (a test asserts max abs diff == 0).
  • Cycle detection: from that series a limit cycle is identified via the first autocorrelation peak (period) plus a three-period amplitude-stability test with a no-net-decay condition (rejects decaying spirals). Detection no longer requires the cycle period to be commensurate with t_per.
  • Structured reporting: both functions attach attr(result, "convergence") = list(type, converged, distance, years, period, amplitude), type ∈ {steady, cycle, not_converged, extinction} — mirroring steadyNewton()'s "stability" attribute. steady() re-stamps it after setBevertonHolt()/setResource(), which would otherwise drop it.

Verification: full NS model at effort 2 → type="cycle", period 5.1 yr, amplitude 0.436 (matching a direct project() measurement); synthetic unit tests confirm correct period/amplitude and no false positives on decaying/flat series. No regressions in test-steady.R/test-steadyNewton.R/test-getLimitCycleSim.R/test-steadySingleSpecies.R; new test-cycle_detection.R added (slow full-NS cycle test is skip_on_cran()).

gustavdelius and others added 15 commits July 15, 2026 10:30
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant