A Julia package for classifying continuous-time dynamical systems into a hierarchy of structural classes: Gradient, Gradient-like, Morse-Smale, Structurally Stable, and General.
Many biological and physical systems are modelled as dynamical systems of the form:
Understanding which structural class a system belongs to has profound implications for its qualitative behaviour. For instance:
- Gradient systems cannot exhibit oscillations or chaos — trajectories always descend a potential
- Morse-Smale systems can have limit cycles but remain structurally stable
- General systems may exhibit chaotic dynamics or complex attractors
This package provides computational tools to test and classify dynamical systems based on their structural properties, with applications to Waddington's epigenetic landscape and cell fate decision models.
using Pkg
Pkg.add(url="https://github.com/Theosysbio/FlowClass.jl")Or for local development:
cd("path/to/FlowClass.jl")
using Pkg
Pkg.activate(".")
Pkg.instantiate()using FlowClass
# Define a dynamical system: dx/dt = F(x)
# Example: A gradient system with potential V(x) = x₁² + x₁x₂ + x₂²
F = x -> [-2x[1] - x[2], -x[1] - 2x[2]]
ds = DynamicalSystem(F, 2)
# Classify the system
bounds = ((-2.0, 2.0), (-2.0, 2.0))
result = classify_system(ds, bounds)
print_classification(result)From most restrictive to most general:
Gradient Systems
└── Gradient-like Systems
└── Morse-Smale Systems
└── Structurally Stable Systems
└── General Dynamical Systems
| Class | Jacobian | Curl | Periodic Orbits | Fixed Points | Lyapunov Function |
|---|---|---|---|---|---|
| Gradient | Symmetric | Zero | None | All hyperbolic | Global |
| Gradient-like | Nearly symmetric | Near zero | None | All hyperbolic | Global |
| Morse-Smale | No requirement | Any | Hyperbolic only | All hyperbolic | Local only |
| Structurally Stable | No requirement | Any | Hyperbolic only | All hyperbolic | Local |
| General | No requirement | Any | Any | Non-hyperbolic possible | None guaranteed |
@enum SystemClass begin
GRADIENT # Pure gradient system: F = -∇V
GRADIENT_LIKE # Has global Lyapunov function, no periodic orbits
MORSE_SMALE # Hyperbolic fixed points and orbits, transverse manifolds
STRUCTURALLY_STABLE # Robust to small perturbations
GENERAL # No special structure guaranteed
endRepresents a continuous-time dynamical system dx/dt = f(x).
# From function and dimension
ds = DynamicalSystem(x -> -x, 2)
# From function and sample point (infers dimension)
ds = DynamicalSystem(x -> -x, [1.0, 2.0])
# Evaluate the vector field
ds([1.0, 2.0]) # returns [-1.0, -2.0]
# Get dimension
dimension(ds) # returns 2Represents a fixed point with stability information.
struct FixedPoint
location::Vector{Float64} # Position in state space
eigenvalues::Vector{ComplexF64} # Eigenvalues of Jacobian
type::FixedPointType # Classification
end@enum FixedPointType begin
STABLE_NODE # All eigenvalues have negative real parts (no imaginary)
UNSTABLE_NODE # All eigenvalues have positive real parts (no imaginary)
SADDLE # Mixed signs of real parts
STABLE_FOCUS # Negative real parts with imaginary components
UNSTABLE_FOCUS # Positive real parts with imaginary components
CENTER # Pure imaginary eigenvalues
NON_HYPERBOLIC # At least one eigenvalue with zero real part
endRepresents a detected periodic orbit.
struct PeriodicOrbit
points::Vector{Vector{Float64}} # Sample points along orbit
period::Float64 # Estimated period
is_stable::Bool # Stability (via Floquet analysis)
endComplete result from system classification.
struct ClassificationResult
system_class::SystemClass
fixed_points::Vector{FixedPoint}
periodic_orbits::Vector{PeriodicOrbit}
jacobian_symmetry::Float64 # Mean relative symmetry error
curl_gradient_ratio::Float64 # ‖curl‖ / ‖gradient‖
has_transverse_manifolds::Union{Bool, Nothing}
confidence::Float64 # Classification confidence
details::Dict{String, Any} # Additional analysis data
endCompute the Jacobian matrix J[i,j] = ∂fᵢ/∂xⱼ at point x using automatic differentiation.
ds = DynamicalSystem(x -> [x[1]^2, x[1]*x[2]], 2)
J = compute_jacobian(ds, [2.0, 3.0])
# J = [4.0 0.0; 3.0 2.0]Test whether a Jacobian matrix is symmetric within tolerance.
J_sym = [-2.0 0.5; 0.5 -1.0]
is_jacobian_symmetric(J_sym) # true
J_nonsym = [-1.0 0.5; -0.5 -1.0]
is_jacobian_symmetric(J_nonsym) # falseCompute the Frobenius norm of the antisymmetric part: ‖(J − Jᵀ)/2‖.
Scale-independent symmetry error: ‖(J − Jᵀ)/2‖ / ‖J‖.
For a vector field F, the curl measures the rotational component of the dynamics. In the Helmholtz decomposition F = −∇U + F_curl, the curl component F_curl is orthogonal to the gradient and cannot be captured by any potential landscape.
Compute the magnitude of the curl at point x. For 2D systems, returns the scalar curl. For 3D, returns ‖∇ × F‖. For higher dimensions, returns ‖(J − Jᵀ)/2‖_F (Frobenius norm of antisymmetric part).
# Rotation system has high curl
rotation = DynamicalSystem(x -> [-x[2], x[1]], 2)
curl_magnitude(rotation, [1.0, 0.0]) # ≈ 2.0
# Gradient system has zero curl
gradient_sys = DynamicalSystem(x -> [-2x[1], -2x[2]], 2)
curl_magnitude(gradient_sys, [1.0, 1.0]) # ≈ 0.0Test if the curl is zero at a point or throughout a region.
Compute the ratio ‖curl‖ / ‖F‖, indicating the relative strength of rotational dynamics.
Find fixed points of the system within the specified bounds using multi-start optimisation.
# Toggle switch with two stable states
toggle = DynamicalSystem(x -> [
1/(1 + x[2]^2) - x[1],
1/(1 + x[1]^2) - x[2]
], 2)
bounds = ((0.0, 2.0), (0.0, 2.0))
fps = find_fixed_points(toggle, bounds)
for fp in fps
println("Fixed point at $(fp.location): $(fp.type)")
endDetermine the type of a fixed point from its Jacobian eigenvalues.
Check if a fixed point is hyperbolic (no eigenvalues with zero real part).
Search for periodic orbits by integrating trajectories and detecting recurrence.
# Van der Pol oscillator (has a limit cycle)
vdp = DynamicalSystem(x -> [x[2], (1 - x[1]^2)*x[2] - x[1]], 2)
bounds = ((-3.0, 3.0), (-3.0, 3.0))
orbits = find_periodic_orbits(vdp, bounds)
if !isempty(orbits)
println("Found orbit with period ≈ $(orbits[1].period)")
endQuick check for the existence of periodic orbits. Returns true if any are found.
Compute points along the stable manifold of a saddle point.
Compute points along the unstable manifold of a saddle point.
Check for homoclinic connections (orbits connecting a saddle to itself).
Verify that stable and unstable manifolds intersect transversally (required for Morse-Smale).
Perform full classification with detailed analysis.
ds = DynamicalSystem(x -> [-2x[1], -3x[2]], 2)
bounds = ((-2.0, 2.0), (-2.0, 2.0))
result = classify_system(ds, bounds)
print_classification(result)Keyword arguments:
n_samples::Int=100— Points sampled for Jacobian/curl analysisn_starts::Int=100— Starting points for fixed point searchcheck_manifolds::Bool=true— Whether to analyse manifold transversalityorbit_timeout::Float64=10.0— Max time for periodic orbit search
Fast classification with fewer samples and no manifold analysis.
Return only the SystemClass enum value.
result = classify_system(ds, bounds)
is_gradient(result) # true if GRADIENT
is_gradient_like(result) # true if GRADIENT or GRADIENT_LIKE
is_morse_smale(result) # true if Morse-Smale or more restrictive
allows_periodic_orbits(result) # false for gradient-like systems
# Get landscape interpretation
can_represent, landscape_type, description = has_landscape_representation(result)Print a formatted classification report.
result = classify_system(ds, bounds)
print_classification(result)Output:
╔══════════════════════════════════════════════════════════════╗
║ System Classification Report ║
╠══════════════════════════════════════════════════════════════╣
║ System Class: GRADIENT ║
║ Confidence: 0.95 ║
╠══════════════════════════════════════════════════════════════╣
║ Fixed Points: 1 ║
║ • Stable node at [0.0, 0.0] ║
║ Periodic Orbits: 0 ║
╠══════════════════════════════════════════════════════════════╣
║ Jacobian Symmetry Error: 1.2e-15 ║
║ Curl/Gradient Ratio: 0.0 ║
║ Manifolds Transverse: N/A (no saddles) ║
╠══════════════════════════════════════════════════════════════╣
║ Landscape: Global potential V(x) exists where F = -∇V ║
╚══════════════════════════════════════════════════════════════╝
A gradient system satisfies dx/dt = −∇V(x) for some scalar potential V. Its Jacobian is the negative Hessian of V, which is always symmetric.
using FlowClass
# Potential: V(x) = x₁² + x₂² (paraboloid)
# Gradient: ∇V = [2x₁, 2x₂]
# System: dx/dt = -∇V = [-2x₁, -2x₂]
ds = DynamicalSystem(x -> -2 .* x, 2)
bounds = ((-2.0, 2.0), (-2.0, 2.0))
result = classify_system(ds, bounds)
println("Class: ", result.system_class) # GRADIENT
println("Symmetry error: ", result.jacobian_symmetry) # ≈ 0
println("Curl ratio: ", result.curl_gradient_ratio) # ≈ 0Systems with rotational dynamics have antisymmetric components in their Jacobian and non-zero curl.
using FlowClass
# Damped oscillator with rotation
# dx₁/dt = -x₁ + ωx₂
# dx₂/dt = -ωx₁ - x₂
ω = 1.0
ds = DynamicalSystem(x -> [-x[1] + ω*x[2], -ω*x[1] - x[2]], 2)
J = compute_jacobian(ds, [0.0, 0.0])
# J = [-1 1; -1 -1]
is_jacobian_symmetric(J) # false
relative_jacobian_symmetry_error(J) # ≈ 0.5
curl_magnitude(ds, [0.0, 0.0]) # ≈ 2.0The Lorenz system is a classic example of a chaotic, non-gradient system.
using FlowClass
function lorenz(x; σ=10.0, ρ=28.0, β=8/3)
return [σ * (x[2] - x[1]),
x[1] * (ρ - x[3]) - x[2],
x[1] * x[2] - β * x[3]]
end
ds = DynamicalSystem(lorenz, 3)
bounds = ((-20.0, 20.0), (-30.0, 30.0), (0.0, 50.0))
result = classify_system(ds, bounds)
println("Class: ", result.system_class) # GENERAL
println("Fixed points found: ", length(result.fixed_points))This example implements the stem cell differentiation model from Brackston, Lakatos & Stumpf (2018), which describes the dynamics of pluripotency factors (Nanog, Oct4-Sox2, Fgf4) and differentiation marker (Gata6) under the influence of LIF signalling.
The model demonstrates non-gradient dynamics with curl components, multiple stable states (pluripotent and differentiated), and transition states — key features of Waddington's epigenetic landscape.
The developmental model consists of four molecular species: Nanog (
Production propensities:
Degradation propensities (first-order with rate
Stoichiometry matrix:
The system evolution is described by dx/dt = S · a(x), where the stoichiometry matrix is:
This yields the ODEs:
using FlowClass
# Parameters from Brackston et al. (2018) Table in Methods section
const k = (
k0 = 0.005, k1 = 0.01, k2 = 0.4, k3 = 1.0, k4 = 0.1,
k5 = 0.00135, k6 = 0.01, k7 = 0.01, k8 = 1.0, k9 = 1.0,
k10 = 0.01, k11 = 5.0, k12 = 1.0, k13 = 0.005, k14 = 1.0,
kd = 1.0
)
"""
Stem cell differentiation model (Brackston et al. 2018, Eqns. 8–16).
State vector: x = [N, O, F, G] where
N = Nanog, O = Oct4-Sox2, F = Fgf4, G = Gata6
Parameter L controls LIF concentration (external signal).
"""
function stem_cell_model(x; L=50.0, p=k)
N, O, F, G = x
# Production propensities (Eqns. 8–11)
a1 = p.k0 * O * (p.k1 + p.k2*N^2 + p.k0*O + p.k3*L) /
(1 + p.k0*O*(p.k2*N^2 + p.k0*O + p.k3*L + p.k4*F^2) + p.k5*O*G^2)
a2 = (p.k6 + p.k7*O) / (1 + p.k7*O + p.k8*G^2)
a3 = (p.k9 + p.k10*O) / (1 + p.k10*O)
a4 = (p.k11 + p.k12*G^2 + p.k14*O) / (1 + p.k12*G^2 + p.k13*N^2 + p.k14*O)
# Net rates: production − degradation (from stoichiometry, Eq. 16)
dN = a1 - p.kd * N
dO = a2 - p.kd * O
dF = a3 - p.kd * F
dG = a4 - p.kd * G
return [dN, dO, dF, dG]
end
# Create system with high LIF (favours pluripotency)
ds_high_LIF = DynamicalSystem(x -> stem_cell_model(x; L=150.0), 4)
# Create system with low LIF (favours differentiation)
ds_low_LIF = DynamicalSystem(x -> stem_cell_model(x; L=10.0), 4)
# Define bounds for the four-dimensional state space
# N ∈ [0, 100], O ∈ [0, 100], F ∈ [0, 100], G ∈ [0, 120]
bounds = ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0), (0.0, 120.0))
# Classify under high LIF conditions
println("=== High LIF (L=150) — Pluripotent conditions ===")
result_high = classify_system(ds_high_LIF, bounds; n_samples=200)
print_classification(result_high)
# Classify under low LIF conditions
println("\n=== Low LIF (L=10) — Differentiation conditions ===")
result_low = classify_system(ds_low_LIF, bounds; n_samples=200)
print_classification(result_low)
# Analyse fixed points (cell states)
println("\n=== Fixed Point Analysis ===")
for (i, fp) in enumerate(result_high.fixed_points)
N, O, F, G = fp.location
if N > 50 && G < 20
state = "Pluripotent (stem cell)"
elseif G > 50 && N < 20
state = "Differentiated"
else
state = "Transition state"
end
println("State $i: $state")
println(" Location: N=$(round(N, digits=1)), O=$(round(O, digits=1)), " *
"F=$(round(F, digits=1)), G=$(round(G, digits=1))")
println(" Type: $(fp.type)")
end
# Check for non-gradient (curl) dynamics
# The paper notes that curl dynamics are ubiquitous in gene regulatory networks
println("\n=== Curl Analysis ===")
test_point = [60.0, 50.0, 40.0, 20.0] # Near pluripotent state
curl = curl_magnitude(ds_high_LIF, test_point)
ratio = curl_to_gradient_ratio(ds_high_LIF, test_point)
println("Curl magnitude at test point: $(round(curl, digits=4))")
println("Curl/gradient ratio: $(round(ratio, digits=4))")
if ratio > 0.1
println("→ Significant non-gradient dynamics present")
println(" Forward and reverse differentiation paths will differ (see paper Fig 6)")
endExpected output:
The stem cell model exhibits:
- Multiple stable states: Pluripotent (high Nanog, low Gata6) and differentiated (low Nanog, high Gata6)
- Non-zero curl: The system is not a pure gradient system, meaning minimum action paths differ for differentiation vs reprogramming
- Transition state: An unstable fixed point between the two stable states
- LIF-dependent landscape: Changing LIF concentration reshapes the potential landscape
This connects to the paper's key insight: the presence of curl dynamics means that observing differentiation trajectories does not directly reveal reprogramming paths.
using FlowClass
# Simple bistable system (toggle switch)
function toggle_switch(x; a=1.0, n=2)
return [
a / (1 + x[2]^n) - x[1],
a / (1 + x[1]^n) - x[2]
]
end
ds = DynamicalSystem(toggle_switch, 2)
bounds = ((0.0, 2.0), (0.0, 2.0))
result = classify_system(ds, bounds)
# Examine fixed points
for fp in result.fixed_points
println("Fixed point at $(round.(fp.location, digits=3))")
println(" Type: $(fp.type)")
println(" Eigenvalues: $(round.(fp.eigenvalues, digits=3))")
if fp.type == SADDLE
println(" → This is a transition state between cell fates")
end
end
# Check landscape representation
can_rep, type, desc = has_landscape_representation(result)
println("\nLandscape: $desc")A gradient system satisfies dx/dt = −∇V(x) for some scalar potential V(x). Key properties:
- Jacobian symmetry: J = −H(V) where H is the Hessian, so J = Jᵀ
- Curl-free: ∇ × F = 0 (in 3D) or more generally, the Jacobian is symmetric
- No periodic orbits: Trajectories always descend the potential
- Path independence: Line integrals are path-independent
The condition ∂fᵢ/∂xⱼ = ∂fⱼ/∂xᵢ is both necessary and sufficient for the existence of a potential.
Gradient-like systems possess a global Lyapunov function but may have non-symmetric Jacobians away from fixed points. They share the key property that trajectories cannot form closed loops.
Morse-Smale systems allow hyperbolic periodic orbits (limit cycles) while maintaining structural stability. They require:
- Finitely many hyperbolic fixed points
- Finitely many hyperbolic periodic orbits
- Transverse intersection of stable/unstable manifolds
- No non-wandering points other than fixed points and periodic orbits
As discussed by Brackston et al. (2018), most biological systems exhibit non-gradient dynamics. The vector field can be decomposed as:
where U is the potential (related to the probability landscape) and F_U is the curl/flux component. The curl component:
- Is indicative of non-equilibrium dynamics
- Causes forward and reverse transition paths to differ
- Cannot be inferred from static snapshot data alone
- Arises naturally in gene regulatory networks due to asymmetric interactions
The classification hierarchy relates directly to interpretations of Waddington's epigenetic landscape:
| System Class | Landscape Interpretation |
|---|---|
| Gradient | True potential landscape; elevation = −log(probability) |
| Gradient-like | Quasi-potential exists; landscape approximation valid |
| Morse-Smale | Local potentials around attractors; limit cycles as valleys |
| General | Landscape metaphor breaks down; curl dynamics dominate |
- ForwardDiff.jl — Automatic differentiation for Jacobians
- NLsolve.jl — Nonlinear equation solving for fixed points
- OrdinaryDiffEq.jl — ODE integration for trajectories and manifolds
- LinearAlgebra — Standard library
Contributions are welcome! Please feel free to submit issues and pull requests.
- Brackston, R. D., Lakatos, E., & Stumpf, M. P. H. (2018). Transition state characteristics during cell differentiation. PLoS Computational Biology, 14(9), e1006405.
- Strogatz, S. H. (2015). Nonlinear Dynamics and Chaos. Westview Press.
- Palis, J., & de Melo, W. (1982). Geometric Theory of Dynamical Systems. Springer.
- Smale, S. (1967). Differentiable dynamical systems. Bulletin of the AMS, 73(6), 747-817.
- Wang, J. (2015). Landscape and flux theory of non-equilibrium dynamical systems with application to biology. Advances in Physics, 64(1), 1-137.
MIT License — see LICENSE for details.