diff --git a/Project.toml b/Project.toml index 46f57c63..4d19bc73 100644 --- a/Project.toml +++ b/Project.toml @@ -18,6 +18,7 @@ RecursiveArrayTools = "731186ca-8d62-57ce-b412-fbd966d074cd" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" +SimpleNonlinearSolve = "727e6d20-b764-4bd8-a329-72de5adea6c7" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" UnPack = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" diff --git a/src/JumpProcesses.jl b/src/JumpProcesses.jl index 8776cc6f..eda6a372 100644 --- a/src/JumpProcesses.jl +++ b/src/JumpProcesses.jl @@ -20,6 +20,8 @@ using Base.Threads: Threads, @threads using Base.FastMath: add_fast using Setfield: @set, @set! +using SimpleNonlinearSolve + # Import functions we extend from Base import Base: size, getindex, setindex!, length, similar, show, merge!, merge @@ -129,7 +131,7 @@ export SSAStepper # leaping: include("simple_regular_solve.jl") -export SimpleTauLeaping, EnsembleGPUKernel +export SimpleTauLeaping, SimpleImplicitTauLeaping, NewtonImplicitSolver, TrapezoidalImplicitSolver, EnsembleGPUKernel # spatial: include("spatial/spatial_massaction_jump.jl") diff --git a/src/simple_regular_solve.jl b/src/simple_regular_solve.jl index 7e3a34fd..9d24daee 100644 --- a/src/simple_regular_solve.jl +++ b/src/simple_regular_solve.jl @@ -1,5 +1,17 @@ struct SimpleTauLeaping <: DiffEqBase.DEAlgorithm end +# Define solver type hierarchy +abstract type AbstractImplicitSolver end +struct NewtonImplicitSolver <: AbstractImplicitSolver end +struct TrapezoidalImplicitSolver <: AbstractImplicitSolver end + +struct SimpleImplicitTauLeaping{T <: AbstractFloat} <: DiffEqBase.DEAlgorithm + epsilon::T # Error control parameter for tau selection + solver::AbstractImplicitSolver # Solver type: Newton or Trapezoidal +end + +SimpleImplicitTauLeaping(; epsilon=0.05, solver=NewtonImplicitSolver()) = SimpleImplicitTauLeaping(epsilon, solver) + function validate_pure_leaping_inputs(jump_prob::JumpProblem, alg) if !(jump_prob.aggregator isa PureLeaping) @warn "When using $alg, please pass PureLeaping() as the aggregator to the \ @@ -14,6 +26,19 @@ function validate_pure_leaping_inputs(jump_prob::JumpProblem, alg) jump_prob.regular_jump !== nothing end +function validate_pure_leaping_inputs(jump_prob::JumpProblem, alg::SimpleImplicitTauLeaping) + if !(jump_prob.aggregator isa PureLeaping) + @warn "When using $alg, please pass PureLeaping() as the aggregator to the \ + JumpProblem, i.e. call JumpProblem(::DiscreteProblem, PureLeaping(),...). \ + Passing $(jump_prob.aggregator) is deprecated and will be removed in the next breaking release." + end + isempty(jump_prob.jump_callback.continuous_callbacks) && + isempty(jump_prob.jump_callback.discrete_callbacks) && + isempty(jump_prob.constant_jumps) && + isempty(jump_prob.variable_jumps) && + jump_prob.massaction_jump !== nothing +end + function DiffEqBase.solve(jump_prob::JumpProblem, alg::SimpleTauLeaping; seed = nothing, dt = error("dt is required for SimpleTauLeaping.")) validate_pure_leaping_inputs(jump_prob, alg) || @@ -61,6 +86,236 @@ function DiffEqBase.solve(jump_prob::JumpProblem, alg::SimpleTauLeaping; interp = DiffEqBase.ConstantInterpolation(t, u)) end +function compute_hor(reactant_stoch, numjumps) + # Compute the highest order of reaction (HOR) for each reaction j, as per Cao et al. (2006), Section IV. + # HOR is the sum of stoichiometric coefficients of reactants in reaction j. + hor = zeros(Int, numjumps) + for j in 1:numjumps + order = sum(stoch for (spec_idx, stoch) in reactant_stoch[j]; init=0) + if order > 3 + error("Reaction $j has order $order, which is not supported (maximum order is 3).") + end + hor[j] = order + end + return hor +end + +function precompute_reaction_conditions(reactant_stoch, hor, numspecies, numjumps) + # Precompute reaction conditions for each species i, including: + # - max_hor: the highest order of reaction (HOR) where species i is a reactant. + # - max_stoich: the maximum stoichiometry (nu_ij) in reactions with max_hor. + # Used to optimize compute_gi, as per Cao et al. (2006), Section IV, equation (27). + max_hor = zeros(Int, numspecies) + max_stoich = zeros(Int, numspecies) + for j in 1:numjumps + for (spec_idx, stoch) in reactant_stoch[j] + if stoch > 0 # Species is a reactant + if hor[j] > max_hor[spec_idx] + max_hor[spec_idx] = hor[j] + max_stoich[spec_idx] = stoch + elseif hor[j] == max_hor[spec_idx] + max_stoich[spec_idx] = max(max_stoich[spec_idx], stoch) + end + end + end + end + return max_hor, max_stoich +end + +function compute_gi(u, max_hor, max_stoich, i, t) + # Compute g_i for species i to bound the relative change in propensity functions, + # as per Cao et al. (2006), Section IV, equation (27). + # g_i is determined by the highest order of reaction (HOR) and maximum stoichiometry (nu_ij) where species i is a reactant: + # - HOR = 1 (first-order, e.g., S_i -> products): g_i = 1 + # - HOR = 2 (second-order): + # - nu_ij = 1 (e.g., S_i + S_k -> products): g_i = 2 + # - nu_ij = 2 (e.g., 2S_i -> products): g_i = 2 + 1/(x_i - 1) + # - HOR = 3 (third-order): + # - nu_ij = 1 (e.g., S_i + S_k + S_m -> products): g_i = 3 + # - nu_ij = 2 (e.g., 2S_i + S_k -> products): g_i = (3/2) * (2 + 1/(x_i - 1)) + # - nu_ij = 3 (e.g., 3S_i -> products): g_i = 3 + 1/(x_i - 1) + 2/(x_i - 2) + # Uses precomputed max_hor and max_stoich to reduce work to O(num_species) per timestep. + if max_hor[i] == 0 # No reactions involve species i as a reactant + return 1.0 + elseif max_hor[i] == 1 + return 1.0 + elseif max_hor[i] == 2 + if max_stoich[i] == 1 + return 2.0 + elseif max_stoich[i] == 2 + return u[i] > 1 ? 2.0 + 1.0 / (u[i] - 1) : 2.0 # Fallback to 2.0 if x_i <= 1 + end + elseif max_hor[i] == 3 + if max_stoich[i] == 1 + return 3.0 + elseif max_stoich[i] == 2 + return u[i] > 1 ? 1.5 * (2.0 + 1.0 / (u[i] - 1)) : 3.0 # Fallback to 3.0 if x_i <= 1 + elseif max_stoich[i] == 3 + return u[i] > 2 ? 3.0 + 1.0 / (u[i] - 1) + 2.0 / (u[i] - 2) : 3.0 # Fallback to 3.0 if x_i <= 2 + end + end + return 1.0 # Default case +end + +function compute_tau(u, rate_cache, nu, hor, p, t, epsilon, rate, dtmin, max_hor, max_stoich, numjumps) + # Compute the tau-leaping step-size using equation (20) from Cao et al. (2006): + # tau = min_{i in I_rs} { max(epsilon * x_i / g_i, 1) / |mu_i(x)|, max(epsilon * x_i / g_i, 1)^2 / sigma_i^2(x) } + # where mu_i(x) and sigma_i^2(x) are defined in equations (9a) and (9b): + # mu_i(x) = sum_j nu_ij * a_j(x), sigma_i^2(x) = sum_j nu_ij^2 * a_j(x) + # I_rs is the set of reactant species (assumed to be all species here, as critical reactions are not specified). + rate(rate_cache, u, p, t) + if all(==(0.0), rate_cache) # Handle case where all rates are zero + return dtmin + end + tau = Inf + for i in 1:length(u) + mu = zero(eltype(u)) + sigma2 = zero(eltype(u)) + for j in 1:size(nu, 2) + mu += nu[i, j] * rate_cache[j] # Equation (9a) + sigma2 += nu[i, j]^2 * rate_cache[j] # Equation (9b) + end + gi = compute_gi(u, max_hor, max_stoich, i, t) + bound = max(epsilon * u[i] / gi, 1.0) # max(epsilon * x_i / g_i, 1) + mu_term = abs(mu) > 0 ? bound / abs(mu) : Inf # First term in equation (8) + sigma_term = sigma2 > 0 ? bound^2 / sigma2 : Inf # Second term in equation (8) + tau = min(tau, mu_term, sigma_term) # Equation (8) + end + return max(tau, dtmin) +end + +# Define residual for implicit equation +# Newton: u_new = u_current + sum_j nu_j * a_j(u_new) * tau (Cao et al., 2004) +# Trapezoidal: u_new = u_current + sum_j nu_j * (a_j(u_current) + a_j(u_new))/2 * tau +function implicit_equation!(resid, u_new, params) + u_current, rate_cache, nu, p, t, tau, rate, numjumps, solver = params + rate(rate_cache, u_new, p, t + tau) + resid .= u_new .- u_current + for j in 1:numjumps + for spec_idx in 1:size(nu, 1) + if isa(solver, NewtonImplicitSolver) + resid[spec_idx] -= nu[spec_idx, j] * rate_cache[j] * tau # Cao et al. (2004) + else # TrapezoidalImplicitSolver + rate_current = similar(rate_cache) + rate(rate_current, u_current, p, t) + resid[spec_idx] -= nu[spec_idx, j] * 0.5 * (rate_cache[j] + rate_current[j]) * tau + end + end + end + resid .= max.(resid, -u_new) # Ensure non-negative solution +end + +# Solve implicit equation using SimpleNonlinearSolve +function solve_implicit(u_current, rate_cache, nu, p, t, tau, rate, numjumps, solver) + u_new = convert(Vector{Float64}, u_current) + prob = NonlinearProblem(implicit_equation!, u_new, (u_current, rate_cache, nu, p, t, tau, rate, numjumps, solver)) + sol = solve(prob, SimpleNewtonRaphson(autodiff=AutoFiniteDiff()); abstol=1e-6, reltol=1e-6) + return sol.u, sol.retcode == ReturnCode.Success +end + +function DiffEqBase.solve(jump_prob::JumpProblem, alg::SimpleImplicitTauLeaping; + seed = nothing, + dtmin = 1e-10, + saveat = nothing) + validate_pure_leaping_inputs(jump_prob, alg) || + error("SimpleImplicitTauLeaping can only be used with PureLeaping JumpProblem with a MassActionJump.") + + @unpack prob, rng = jump_prob + (seed !== nothing) && seed!(rng, seed) + + maj = jump_prob.massaction_jump + numjumps = get_num_majumps(maj) + rj = jump_prob.regular_jump + # Extract rates + rate = rj !== nothing ? rj.rate : + (out, u, p, t) -> begin + for j in 1:numjumps + out[j] = evalrxrate(u, j, maj) + end + end + c = rj !== nothing ? rj.c : nothing + u0 = copy(prob.u0) + tspan = prob.tspan + p = prob.p + + u_current = copy(u0) + t_current = tspan[1] + usave = [copy(u0)] + tsave = [tspan[1]] + rate_cache = zeros(Float64, numjumps) + counts = zeros(Int64, numjumps) + du = similar(u0) + t_end = tspan[2] + epsilon = alg.epsilon + solver = alg.solver + + nu = zeros(Int64, length(u0), numjumps) + for j in 1:numjumps + for (spec_idx, stoch) in maj.net_stoch[j] + nu[spec_idx, j] = stoch + end + end + reactant_stoch = maj.reactant_stoch + hor = compute_hor(reactant_stoch, numjumps) + max_hor, max_stoich = precompute_reaction_conditions(reactant_stoch, hor, length(u0), numjumps) + + saveat_times = isnothing(saveat) ? Vector{Float64}() : + (saveat isa Number ? collect(range(tspan[1], tspan[2], step=saveat)) : collect(saveat)) + save_idx = 1 + + while t_current < t_end + rate(rate_cache, u_current, p, t_current) + tau = compute_tau(u_current, rate_cache, nu, hor, p, t_current, epsilon, rate, dtmin, max_hor, max_stoich, numjumps) + tau = min(tau, t_end - t_current) + if !isempty(saveat_times) && save_idx <= length(saveat_times) && t_current + tau > saveat_times[save_idx] + tau = saveat_times[save_idx] - t_current + end + + u_new_float, converged = solve_implicit(u_current, rate_cache, nu, p, t_current, tau, rate, numjumps, solver) + if !converged + tau /= 2 + continue + end + + rate(rate_cache, u_new_float, p, t_current + tau) + counts .= pois_rand.(rng, max.(rate_cache * tau, 0.0)) + du .= zero(eltype(u_current)) + for j in 1:numjumps + for (spec_idx, stoch) in maj.net_stoch[j] + du[spec_idx] += stoch * counts[j] + end + end + u_new = u_current + du + + if any(<(0), u_new) + # Halve tau to avoid negative populations, as per Cao et al. (2006), Section 3.3 + tau /= 2 + continue + end + # Ensure non-negativity, as per Cao et al. (2006), Section 3.3 + for i in eachindex(u_new) + u_new[i] = max(u_new[i], 0) + end + t_new = t_current + tau + + if isempty(saveat_times) || (save_idx <= length(saveat_times) && t_new >= saveat_times[save_idx]) + push!(usave, copy(u_new)) + push!(tsave, t_new) + if !isempty(saveat_times) && t_new >= saveat_times[save_idx] + save_idx += 1 + end + end + + u_current = u_new + t_current = t_new + end + + sol = DiffEqBase.build_solution(prob, alg, tsave, usave, + calculate_error=false, + interp=DiffEqBase.ConstantInterpolation(tsave, usave)) + return sol +end + struct EnsembleGPUKernel{Backend} <: SciMLBase.EnsembleAlgorithm backend::Backend cpu_offload::Float64 diff --git a/test/regular_jumps.jl b/test/regular_jumps.jl index 3ccc6740..152e4728 100644 --- a/test/regular_jumps.jl +++ b/test/regular_jumps.jl @@ -1,28 +1,142 @@ using JumpProcesses, DiffEqBase -using Test, LinearAlgebra +using Test, LinearAlgebra, Statistics using StableRNGs rng = StableRNG(12345) -function regular_rate(out, u, p, t) - out[1] = (0.1 / 1000.0) * u[1] * u[2] - out[2] = 0.01u[2] -end +Nsims = 1000 + +# SIR model with influx +@testset "SIR Model Correctness" begin + β = 0.1 / 1000.0 + ν = 0.01 + influx_rate = 1.0 + p = (β, ν, influx_rate) + + # ConstantRateJump formulation for SSAStepper + rate1(u, p, t) = p[1] * u[1] * u[2] # β*S*I (infection) + rate2(u, p, t) = p[2] * u[2] # ν*I (recovery) + rate3(u, p, t) = p[3] # influx_rate (S influx) + affect1!(integrator) = (integrator.u[1] -= 1; integrator.u[2] += 1; nothing) + affect2!(integrator) = (integrator.u[2] -= 1; integrator.u[3] += 1; nothing) + affect3!(integrator) = (integrator.u[1] += 1; nothing) + jumps = (ConstantRateJump(rate1, affect1!), ConstantRateJump(rate2, affect2!), ConstantRateJump(rate3, affect3!)) + + u0 = [999.0, 10.0, 0.0] # S, I, R + tspan = (0.0, 250.0) + prob_disc = DiscreteProblem(u0, tspan, p) + jump_prob = JumpProblem(prob_disc, Direct(), jumps...; rng=rng) + + # Solve with SSAStepper + sol_direct = solve(EnsembleProblem(jump_prob), SSAStepper(), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + + # RegularJump formulation for SimpleTauLeaping + regular_rate = (out, u, p, t) -> begin + out[1] = p[1] * u[1] * u[2] + out[2] = p[2] * u[2] + out[3] = p[3] + end + regular_c = (dc, u, p, t, counts, mark) -> begin + dc .= 0 + dc[1] = -counts[1] + counts[3] + dc[2] = counts[1] - counts[2] + dc[3] = counts[2] + end + rj = RegularJump(regular_rate, regular_c, 3) + jump_prob_tau = JumpProblem(prob_disc, PureLeaping(), rj; rng=rng) -const dc = zeros(3, 2) -dc[1, 1] = -1 -dc[2, 1] = 1 -dc[2, 2] = -1 -dc[3, 2] = 1 + # Solve with SimpleTauLeaping + sol_simple = solve(EnsembleProblem(jump_prob_tau), SimpleTauLeaping(), EnsembleSerial(); trajectories=Nsims, dt=0.1) -function regular_c(du, u, p, t, counts, mark) - mul!(du, dc, counts) + # MassActionJump formulation for SimpleImplicitTauLeaping + reactant_stoich = [[1=>1, 2=>1], [2=>1], Pair{Int,Int}[]] + net_stoich = [[1=>-1, 2=>1], [2=>-1, 3=>1], [1=>1]] + param_idxs = [1, 2, 3] + maj = MassActionJump(reactant_stoich, net_stoich; param_idxs=param_idxs) + jump_prob_maj = JumpProblem(prob_disc, PureLeaping(), maj; rng=rng) + + # Solve with SimpleImplicitTauLeaping + sol_implicit_newton = solve(EnsembleProblem(jump_prob_maj), SimpleImplicitTauLeaping(solver=NewtonImplicitSolver()), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + sol_implicit_trapezoidal = solve(EnsembleProblem(jump_prob_maj), SimpleImplicitTauLeaping(solver=TrapezoidalImplicitSolver()), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + + # Compute mean infected (I) trajectories + t_points = 0:1.0:250.0 + max_direct_I = maximum([mean(sol_direct[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_direct_I = maximum([mean(sol_simple[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_implicit_newton = maximum([mean(sol_implicit_newton[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_implicit_trapezoidal = maximum([mean(sol_implicit_trapezoidal[i](t)[2] for i in 1:Nsims) for t in t_points]) + + # Test mean infected trajectories + @test isapprox(max_direct_I, max_direct_I, rtol=0.05) + @test isapprox(max_direct_I, max_implicit_newton, rtol=0.05) + @test isapprox(max_direct_I, max_implicit_trapezoidal, rtol=0.05) end -rj = RegularJump(regular_rate, regular_c, 2) -jumps = JumpSet(rj) -prob = DiscreteProblem([999, 1, 0], (0.0, 250.0)) -jump_prob = JumpProblem(prob, PureLeaping(), rj; rng) -sol = solve(jump_prob, SimpleTauLeaping(); dt = 1.0) +# SEIR model with exposed compartment +@testset "SEIR Model Correctness" begin + β = 0.3 / 1000.0 + σ = 0.2 + ν = 0.01 + p = (β, σ, ν) + + # ConstantRateJump formulation for SSAStepper + rate1(u, p, t) = p[1] * u[1] * u[3] # β*S*I (infection) + rate2(u, p, t) = p[2] * u[2] # σ*E (progression) + rate3(u, p, t) = p[3] * u[3] # ν*I (recovery) + affect1!(integrator) = (integrator.u[1] -= 1; integrator.u[2] += 1; nothing) + affect2!(integrator) = (integrator.u[2] -= 1; integrator.u[3] += 1; nothing) + affect3!(integrator) = (integrator.u[3] -= 1; integrator.u[4] += 1; nothing) + jumps = (ConstantRateJump(rate1, affect1!), ConstantRateJump(rate2, affect2!), ConstantRateJump(rate3, affect3!)) + + u0 = [999.0, 0.0, 10.0, 0.0] # S, E, I, R + tspan = (0.0, 250.0) + prob_disc = DiscreteProblem(u0, tspan, p) + jump_prob = JumpProblem(prob_disc, Direct(), jumps...; rng=rng) + + # Solve with SSAStepper + sol_direct = solve(EnsembleProblem(jump_prob), SSAStepper(), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + + # RegularJump formulation for SimpleTauLeaping + regular_rate = (out, u, p, t) -> begin + out[1] = p[1] * u[1] * u[3] + out[2] = p[2] * u[2] + out[3] = p[3] * u[3] + end + regular_c = (dc, u, p, t, counts, mark) -> begin + dc .= 0.0 + dc[1] = -counts[1] + dc[2] = counts[1] - counts[2] + dc[3] = counts[2] - counts[3] + dc[4] = counts[3] + end + rj = RegularJump(regular_rate, regular_c, 3) + jump_prob_tau = JumpProblem(prob_disc, PureLeaping(), rj; rng=rng) + + # Solve with SimpleTauLeaping + sol_simple = solve(EnsembleProblem(jump_prob_tau), SimpleTauLeaping(), EnsembleSerial(); trajectories=Nsims, dt=0.1) + + # MassActionJump formulation for SimpleImplicitTauLeaping + reactant_stoich = [[1=>1, 3=>1], [2=>1], [3=>1]] + net_stoich = [[1=>-1, 2=>1], [2=>-1, 3=>1], [3=>-1, 4=>1]] + param_idxs = [1, 2, 3] + maj = MassActionJump(reactant_stoich, net_stoich; param_idxs=param_idxs) + jump_prob_maj = JumpProblem(prob_disc, PureLeaping(), maj; rng=rng) + + # Solve with SimpleImplicitTauLeaping + sol_implicit_newton = solve(EnsembleProblem(jump_prob_maj), SimpleImplicitTauLeaping(solver=NewtonImplicitSolver()), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + sol_implicit_trapezoidal = solve(EnsembleProblem(jump_prob_maj), SimpleImplicitTauLeaping(solver=TrapezoidalImplicitSolver()), EnsembleSerial(); trajectories=Nsims, saveat=1.0) + + # Compute mean infected (I) trajectories + t_points = 0:1.0:250.0 + max_direct_I = maximum([mean(sol_direct[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_direct_I = maximum([mean(sol_simple[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_implicit_newton = maximum([mean(sol_implicit_newton[i](t)[2] for i in 1:Nsims) for t in t_points]) + max_implicit_trapezoidal = maximum([mean(sol_implicit_trapezoidal[i](t)[2] for i in 1:Nsims) for t in t_points]) + + # Test mean infected trajectories + @test isapprox(max_direct_I, max_direct_I, rtol=0.05) + @test isapprox(max_direct_I, max_implicit_newton, rtol=0.05) + @test isapprox(max_direct_I, max_implicit_trapezoidal, rtol=0.05) +end # Test PureLeaping aggregator functionality @testset "PureLeaping Aggregator Tests" begin @@ -110,3 +224,27 @@ sol = solve(jump_prob, SimpleTauLeaping(); dt = 1.0) scaled_rates = [p[1], p[2]/2] @test jp_params.massaction_jump.scaled_rates == scaled_rates end + + +# Example system from Cao et al. (2007) +function define_stiff_system() + # Reactions: S1 -> S2, S2 -> S1, S2 -> S3 + # Rate constants + c = (1000.0, 1000.0, 1.0) + + # Define MassActionJump + # Reaction 1: S1 -> S2 + reactant_stoich1 = [Pair(1, 1)] # S1 consumed + net_stoich1 = [Pair(1, -1), Pair(2, 1)] # S1 -1, S2 +1 + # Reaction 2: S2 -> S1 + reactant_stoich2 = [Pair(2, 1)] # S2 consumed + net_stoich2 = [Pair(1, 1), Pair(2, -1)] # S1 +1, S2 -1 + # Reaction 3: S2 -> S3 + reactant_stoich3 = [Pair(2, 1)] # S2 consumed + net_stoich3 = [Pair(2, -1), Pair(3, 1)] # S2 -1, S3 +1 + + jumps = MassActionJump([c[1], c[2], c[3]], [reactant_stoich1, reactant_stoich2, reactant_stoich3], + [net_stoich1, net_stoich2, net_stoich3]) + + return jumps +end