diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index b53320ffbf..e3f54adf5b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -14,7 +14,7 @@ jobs: group: - Core version: - - '1.10.2' + - '1' steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v2 @@ -37,7 +37,7 @@ jobs: with: file: lcov.info token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false - uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index 7e9af3e1dd..a743dd3cc8 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -28,4 +28,4 @@ jobs: with: file: lcov.info token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false diff --git a/.github/workflows/FormatCheck.yml b/.github/workflows/FormatCheck.yml index 9cca2b9011..ce3eadc2b5 100644 --- a/.github/workflows/FormatCheck.yml +++ b/.github/workflows/FormatCheck.yml @@ -1,4 +1,4 @@ -name: format-check +name: "Format Check" on: push: @@ -9,34 +9,6 @@ on: pull_request: jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: [1] - julia-arch: [x86] - os: [ubuntu-latest] - steps: - - uses: julia-actions/setup-julia@latest - with: - version: ${{ matrix.julia-version }} - - - uses: actions/checkout@v4 - - name: Install JuliaFormatter and format - # This will use the latest version by default but you can set the version like so: - # - # julia -e 'using Pkg; Pkg.add(PackageSpec(name="JuliaFormatter", version="0.13.0"))' - run: | - julia -e 'using Pkg; Pkg.add(PackageSpec(name="JuliaFormatter", version="1.0.32"))' - julia -e 'using JuliaFormatter; format(".", verbose=true)' - - name: Format check - run: | - julia -e ' - out = Cmd(`git diff --name-only`) |> read |> String - if out == "" - exit(0) - else - @error "Some files have not been formatted !!!" - write(stdout, out) - exit(1) - end' + format-check: + name: "Format Check" + uses: "SciML/.github/.github/workflows/format-check.yml@v1" diff --git a/.github/workflows/Invalidations.yml b/.github/workflows/Invalidations.yml index 66c86a3627..0a6a27a88c 100644 --- a/.github/workflows/Invalidations.yml +++ b/.github/workflows/Invalidations.yml @@ -4,37 +4,10 @@ on: pull_request: concurrency: - # Skip intermediate builds: always. - # Cancel intermediate builds: always. group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - evaluate: - # Only run on PRs to the default branch. - # In the PR trigger above branches can be specified only explicitly whereas this check should work for master, main, or any other default branch - if: github.base_ref == github.event.repository.default_branch - runs-on: ubuntu-latest - steps: - - uses: julia-actions/setup-julia@v2 - with: - version: '1' - - uses: actions/checkout@v4 - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-invalidations@v1 - id: invs_pr - - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.repository.default_branch }} - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-invalidations@v1 - id: invs_default - - - name: Report invalidation counts - run: | - echo "Invalidations on default branch: ${{ steps.invs_default.outputs.total }} (${{ steps.invs_default.outputs.deps }} via deps)" >> $GITHUB_STEP_SUMMARY - echo "This branch: ${{ steps.invs_pr.outputs.total }} (${{ steps.invs_pr.outputs.deps }} via deps)" >> $GITHUB_STEP_SUMMARY - - name: Check if the PR does increase number of invalidations - if: steps.invs_pr.outputs.total > steps.invs_default.outputs.total - run: exit 1 + evaluate-invalidations: + name: "Evaluate Invalidations" + uses: "SciML/.github/.github/workflows/invalidations.yml@v1" diff --git a/HISTORY.md b/HISTORY.md index d2c610a262..ff92e9b5f5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,103 +3,190 @@ ## Catalyst unreleased (master branch) ## Catalyst 14.0 -- The `reactionparams`, `numreactionparams`, and `reactionparamsmap` functions have been removed. -- To be more consistent with ModelingToolkit's immutability requirement for systems, we have removed API functions that mutate `ReactionSystem`s such as `addparam!`, `addreaction!`, `addspecies`, `@add_reactions`, and `merge!`. Please use `ModelingToolkit.extend` and `ModelingToolkit.compose` to generate new merged and/or composed `ReactionSystem`s from multiple component systems. -- Added CatalystStructuralIdentifiabilityExtension, which permits StructuralIdentifiability.jl function to be applied directly to Catalyst systems. E.g. use -```julia -using Catalyst, StructuralIdentifiability -goodwind_oscillator = @reaction_network begin - (mmr(P,pₘ,1), dₘ), 0 <--> M - (pₑ*M,dₑ), 0 <--> E - (pₚ*E,dₚ), 0 <--> P -end -assess_identifiability(goodwind_oscillator; measured_quantities=[:M]) -``` -to assess (global) structural identifiability for all parameters and variables of the `goodwind_oscillator` model (under the presumption that we can measure `M` only). -- Automatically handles conservation laws for structural identifiability problems (eliminates these internally to speed up computations). -- Adds a tutorial to illustrate the use of the extension. -- Enable adding metadata to individual reactions, e.g: -```julia -rn = @reaction_network begin - @parameters η - k, 2X --> X2, [noise_scaling=η] -end -get_noise_scaling(rn) -``` -- `SDEProblem` no longer takes the `noise_scaling` argument (see above for new approach to handle noise scaling). -- Changed fields of internal `Reaction` structure. `ReactionSystems`s saved using `serialize` on previous Catalyst versions cannot be loaded using this (or later) versions. -- Simulation of spatial ODEs now supported. For full details, please see https://github.com/SciML/Catalyst.jl/pull/644 and upcoming documentation. Note that these methods are currently considered alpha, with the interface and approach changing even in non-breaking Catalyst releases. -- LatticeReactionSystem structure represents a spatial reaction network: + +#### Breaking changes +Catalyst v14 was prompted by the (breaking) release of ModelingToolkit v9, which +introduced several breaking changes to Catalyst. A summary of these (and how to +handle them) can be found +[here](https://docs.sciml.ai/Catalyst/stable/v14_migration_guide/). These are +briefly summarised in the following bullet points: +- `ReactionSystem`s must now be marked *complete* before they are exposed to + most forms of simulation and analysis. With the exception of `ReactionSystem`s + created through the `@reaction_network` macro, all `ReactionSystem`s are *not* + marked complete upon construction. The `complete` function can be used to mark + `ReactionSystem`s as complete. To construct a `ReactionSystem` that is not + marked complete via the DSL the new `@network_component` macro can be used. +- The `states` function has been replaced with `unknowns`. The `get_states` + function has been replaced with `get_unknowns`. +- Support for most units (with the exception of `s`, `m`, `kg`, `A`, `K`, `mol`, + and `cd`) has currently been dropped by ModelingToolkit, and hence they are + unavailable via Catalyst too. Its is expected that eventually support for + relevant chemical units such as molar will return to ModelingToolkit (and + should then immediately work in Catalyst too). +- Problem parameter values are now accessed through `prob.ps[p]` (rather than + `prob[p]`). +- ModelingToolkit currently does not support the safe application of the + `remake` function, or safe direct mutation, for problems for which + `remove_conserved = true` was used when updating the values of initial + conditions. Instead, the values of each conserved constant must be directly + specified. +- The `reactionparams`, `numreactionparams`, and `reactionparamsmap` functions + have been deprecated and removed. +- To be more consistent with ModelingToolkit's immutability requirement for + systems, we have removed API functions that mutate `ReactionSystem`s such as + `addparam!`, `addreaction!`, `addspecies`, `@add_reactions`, and `merge!`. + Please use `ModelingToolkit.extend` and `ModelingToolkit.compose` to generate + new merged and/or composed `ReactionSystem`s from multiple component systems. + +#### General changes +- The `default_t()` and `default_time_deriv()` functions are now the preferred + approaches for creating the default time independent variable and its + differential. i.e. + ```julia + # do + t = default_t() + @species A(t) + + # avoid + @variables t + @species A(t) +- It is now possible to add metadata to individual reactions, e.g. using: + ```julia + rn = @reaction_network begin + @parameters η + k, 2X --> X2, [description="Dimerisation"] + end + getdescription(rn) + ``` + a more detailed description can be found [here](https://docs.sciml.ai/Catalyst/dev/model_creation/dsl_advanced/#dsl_advanced_options_reaction_metadata). +- `SDEProblem` no longer takes the `noise_scaling` argument. Noise scaling is + now handled through the `noise_scaling` metadata (described in more detail + [here](https://docs.sciml.ai/Catalyst/stable/model_simulation/simulation_introduction/#simulation_intro_SDEs_noise_saling)) +- Fields of the internal `Reaction` structure have been changed. + `ReactionSystems`s saved using `serialize` on previous Catalyst versions + cannot be loaded using this (or later) versions. +- A new function, `save_reactionsystem`, which permits the writing of + `ReactionSystem` models to files, has been created. A thorough description of + this function can be found + [here](https://docs.sciml.ai/Catalyst/stable/model_creation/model_file_loading_and_export/#Saving-Catalyst-models-to,-and-loading-them-from,-Julia-files) +- Updated how compounds are created. E.g. use + ```julia + @variables t C(t) O(t) + @compound CO2 ~ C + 2O + ``` + to create a compound species `CO2` that consists of `C` and two `O`. +- Added documentation for chemistry-related functionality (compound creation and + reaction balancing). +- Added function `isautonomous` to check if a `ReactionSystem` is autonomous. +- Added function `steady_state_stability` to compute stability for steady + states. Example: ```julia + # Creates model. rn = @reaction_network begin (p,d), 0 <--> X end - tr = @transport_reaction D X - lattice = Graphs.grid([5, 5]) - lrs = LatticeReactionSystem(rn, [tr], lattice) -``` -- Here, if a `u0` or `p` vector is given with scalar values: + p = [:p => 1.0, :d => 0.5] + + # Finds (the trivial) steady state, and computes stability. + steady_state = [2.0] + steady_state_stability(steady_state, rn, p) + ``` + Here, `steady_state_stability` takes an optional keyword argument `tol = + 10*sqrt(eps())`, which is used to check that the real part of all eigenvalues + are at least `tol` away from zero. Eigenvalues within `tol` of zero indicate + that stability may not be reliably calculated. +- Added a DSL option, `@combinatoric_ratelaws`, which can be used to toggle + whether to use combinatorial rate laws within the DSL (this feature was + already supported for programmatic modelling). Example: + ```julia + # Creates model. + rn = @reaction_network begin + @combinatoric_ratelaws false + (kB,kD), 2X <--> X2 + end + ``` +- Added a DSL option, `@observables` for [creating + observables](https://docs.sciml.ai/Catalyst/stable/model_creation/dsl_advanced/#dsl_advanced_options_observables) + (this feature was already supported for programmatic modelling). +- Added DSL options `@continuous_events` and `@discrete_events` to add events to + a model as part of its creation (this feature was already supported for + programmatic modelling). Example: + ```julia + rn = @reaction_network begin + @continuous_events begin + [X ~ 1.0] => [X ~ X + 1.0] + end + d, X --> 0 + end + ``` +- Added DSL option `@equations` to add (algebraic or differential) equations to + a model as part of its creation (this feature was already supported for + programmatic modelling). Example: ```julia - u0 = [:X => 1.0] - p = [:p => 1.0, :d => 0.5, :D => 0.1] + rn = @reaction_network begin + @equations begin + D(V) ~ 1 - V + end + (p/V,d/V), 0 <--> X + end ``` - this value will be used across the entire system. If their values are instead vectors, different values are used across the spatial system. Here + couples the ODE $dV/dt = 1 - V$ to the reaction system. +- Coupled reaction networks and differential equation (or algebraic differential + equation) systems can now be converted to `SDESystem`s and `NonlinearSystem`s. + +#### Structural identifiability extension +- Added CatalystStructuralIdentifiabilityExtension, which permits + StructuralIdentifiability.jl to be applied directly to Catalyst systems. E.g. + use ```julia - X0 = zeros(25) - X0[1] = 1.0 - u0 = [:X => X0] + using Catalyst, StructuralIdentifiability + goodwind_oscillator = @reaction_network begin + (mmr(P,pₘ,1), dₘ), 0 <--> M + (pₑ*M,dₑ), 0 <--> E + (pₚ*E,dₚ), 0 <--> P + end + assess_identifiability(goodwind_oscillator; measured_quantities=[:M]) ``` - X's value will be `1.0` in the first vertex, but `0.0` in the remaining one (the system have 25 vertexes in total). SInce th parameters `p` and `d` are part of the non-spatial reaction network, their values are tied to vertexes. However, if the `D` parameter (which governs diffusion between vertexes) is given several values, these will instead correspond to the specific edges (and transportation along those edges.) + to assess (global) structural identifiability for all parameters and variables + of the `goodwind_oscillator` model (under the presumption that we can measure + `M` only). +- Automatically handles conservation laws for structural identifiability + problems (eliminates these internally to speed up computations). +- A more detailed of how this extension works can be found + [here](https://docs.sciml.ai/Catalyst/stable/inverse_problems/structural_identifiability/). -- Update how compounds are created. E.g. use -```julia -@variables t C(t) O(t) -@compound CO2 ~ C + 2O -``` -to create a compound species `CO2` that consists of `C` and 2 `O`. -- Added documentation for chemistry related functionality (compound creation and reaction balancing). -- Add a CatalystBifurcationKitExtension, permitting BifurcationKit's `BifurcationProblem`s to be created from Catalyst reaction networks. Example usage: -```julia -using Catalyst -wilhelm_2009_model = @reaction_network begin - k1, Y --> 2X - k2, 2X --> X + Y - k3, X + Y --> Y - k4, X --> 0 - k5, 0 --> X -end +#### Bifurcation analysis extension +- Add a CatalystBifurcationKitExtension, permitting BifurcationKit's + `BifurcationProblem`s to be created from Catalyst reaction networks. Example + usage: + ```julia + using Catalyst + wilhelm_2009_model = @reaction_network begin + k1, Y --> 2X + k2, 2X --> X + Y + k3, X + Y --> Y + k4, X --> 0 + k5, 0 --> X + end -using BifurcationKit -bif_par = :k1 -u_guess = [:X => 5.0, :Y => 2.0] -p_start = [:k1 => 4.0, :k2 => 1.0, :k3 => 1.0, :k4 => 1.5, :k5 => 1.25] -plot_var = :X -bprob = BifurcationProblem(wilhelm_2009_model, u_guess, p_start, bif_par; plot_var=plot_var) + using BifurcationKit + bif_par = :k1 + u_guess = [:X => 5.0, :Y => 2.0] + p_start = [:k1 => 4.0, :k2 => 1.0, :k3 => 1.0, :k4 => 1.5, :k5 => 1.25] + plot_var = :X + bprob = BifurcationProblem(wilhelm_2009_model, u_guess, p_start, bif_par; plot_var = plot_var) -p_span = (2.0, 20.0) -opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps=1000) + p_span = (2.0, 20.0) + opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps = 1000) -bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside=true) + bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) -using Plots -plot(bif_dia; xguide="k1", yguide="X") -``` -- Automatically handles elimination of conservation laws for computing bifurcation diagrams. + using Plots + plot(bif_dia; xguide = "k1", guide = "X") + ``` +- Automatically handles elimination of conservation laws for computing + bifurcation diagrams. - Updated Bifurcation documentation with respect to this new feature. -- Added function `isautonomous` to check if a `ReactionSystem` is autonomous. -- Added function `steady_state_stability` to compute stability for steady states. Example: -```julia -# Creates model. -rn = @reaction_network begin - (p,d), 0 <--> X -end -p = [:p => 1.0, :d => 0.5] - -# Finds (the trivial) steady state, and computes stability. -steady_state = [2.0] -steady_state_stability(steady_state, rn, p) -``` -Here, `steady_state_stability` take an optional argument `tol = 10*sqrt(eps())`, which is used to determine whether a eigenvalue real part is reliably less that 0. ## Catalyst 13.5 - Added a CatalystHomotopyContinuationExtension extension, which exports the `hc_steady_state` function if HomotopyContinuation is exported. `hc_steady_state` finds the steady states of a reaction system using the homotopy continuation method. This feature is only available for julia versions 1.9+. Example: @@ -658,7 +745,7 @@ hc_steady_states(wilhelm_2009_model, ps) field has been changed (only when created through the `@reaction_network` macro). Previously they were ordered according to the order with which they appeared in the macro. Now they are ordered according the to order with which - they appeard after the `end` part. E.g. in + they appeared after the `end` part. E.g. in ```julia rn = @reaction_network begin (p,d), 0 <--> X @@ -763,7 +850,7 @@ which gives ![rn_complexes](https://user-images.githubusercontent.com/9385167/130252763-4418ba5a-164f-47f7-b512-a768e4f73834.png) *2.* Support for units via ModelingToolkit and -[Uniftul.jl](https://github.com/PainterQubits/Unitful.jl) in directly constructed +[Unitful.jl](https://github.com/PainterQubits/Unitful.jl) in directly constructed `ReactionSystem`s: ```julia # ]add Unitful diff --git a/Project.toml b/Project.toml index 2b90ff7b2a..5d2088785a 100644 --- a/Project.toml +++ b/Project.toml @@ -1,8 +1,9 @@ name = "Catalyst" uuid = "479239e8-5488-4da2-87a7-35f2df7eef83" -version = "13.5.1" +version = "14.0.0" [deps] +Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" @@ -21,7 +22,6 @@ Requires = "ae029012-a4dd-5104-9daa-d747884805df" RuntimeGeneratedFunctions = "7e49a35a-f44a-4d26-94aa-eba1b4ca6b47" Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" -SymbolicUtils = "d1185830-fcd6-423d-90d6-eec64667417b" Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" @@ -37,9 +37,11 @@ CatalystStructuralIdentifiabilityExtension = "StructuralIdentifiability" [compat] BifurcationKit = "0.3" +Combinatorics = "1.0.2" DataStructures = "0.18" DiffEqBase = "6.83.0" DocStringExtensions = "0.8, 0.9" +DynamicPolynomials = "0.5" DynamicQuantities = "0.13.2" Graphs = "1.4" HomotopyContinuation = "2.9" @@ -47,17 +49,16 @@ JumpProcesses = "9.3.2" LaTeXStrings = "1.3.0" Latexify = "0.14, 0.15, 0.16" MacroTools = "0.5.5" -ModelingToolkit = "9.11.0" +ModelingToolkit = "9.16.0" Parameters = "0.12" Reexport = "0.2, 1.0" Requires = "1.0" RuntimeGeneratedFunctions = "0.5.12" Setfield = "1" -StructuralIdentifiability = "0.5.1" -SymbolicUtils = "1.0.3" -Symbolics = "5.27" +StructuralIdentifiability = "0.5.8" +Symbolics = "5.30.1" Unitful = "1.12.4" -julia = "1.9" +julia = "1.10" [extras] BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" @@ -66,6 +67,7 @@ DomainSets = "5b8099bc-c8ec-5219-889f-1d9e522a28bf" Graphviz_jll = "3c863552-8265-54e4-a6dc-903eb78fde85" HomotopyContinuation = "f213a82b-91d6-5c5d-acf7-10f1c761b327" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" @@ -74,6 +76,7 @@ SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" SciMLNLSolve = "e9a6253c-8580-4d32-9898-8661bb511710" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" +StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" SteadyStateDiffEq = "9672c7b4-1e72-59bd-8a11-6ac3964bc41f" StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" @@ -82,4 +85,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [targets] -test = ["BifurcationKit", "DiffEqCallbacks", "DomainSets", "Graphviz_jll", "HomotopyContinuation", "NonlinearSolve", "OrdinaryDiffEq", "Plots", "Random", "SafeTestsets", "SciMLBase", "SciMLNLSolve", "StableRNGs", "Statistics", "SteadyStateDiffEq", "StochasticDiffEq", "StructuralIdentifiability", "Test", "Unitful"] +test = ["BifurcationKit", "DiffEqCallbacks", "DomainSets", "Graphviz_jll", "HomotopyContinuation", "Logging", "NonlinearSolve", "OrdinaryDiffEq", "Plots", "Random", "SafeTestsets", "SciMLBase", "SciMLNLSolve", "StableRNGs", "StaticArrays", "Statistics", "SteadyStateDiffEq", "StochasticDiffEq", "StructuralIdentifiability", "Test", "Unitful"] diff --git a/README.md b/README.md index 0f8d03e474..761b2a26b3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Catalyst.jl -[![Join the chat at https://julialang.zulipchat.com #sciml-bridged](https://img.shields.io/static/v1?label=Zulip&message=chat&color=9558b2&labelColor=389826)](https://julialang.zulipchat.com/#narrow/stream/279055-sciml-bridged) -[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://docs.sciml.ai/Catalyst/stable/) -[![API Stable](https://img.shields.io/badge/API-stable-blue.svg)](https://docs.sciml.ai/Catalyst/stable/api/catalyst_api/) - +[![Latest Release (for users)](https://img.shields.io/badge/docs-latest_release_(for_users)-blue.svg)](https://docs.sciml.ai/Catalyst/stable/) +[![API Latest Release (for users)](https://img.shields.io/badge/API-latest_release_(for_users)-blue.svg)](https://docs.sciml.ai/Catalyst/stable/api/catalyst_api/) +[![Master (for developers)](https://img.shields.io/badge/docs-master_branch_(for_devs)-blue.svg)](https://docs.sciml.ai/Catalyst/dev/) +[![API Master (for developers](https://img.shields.io/badge/API-master_branch_(for_devs)-blue.svg)](https://docs.sciml.ai/Catalyst/dev/api/catalyst_api/) + [![Build Status](https://github.com/SciML/Catalyst.jl/workflows/CI/badge.svg)](https://github.com/SciML/Catalyst.jl/actions?query=workflow%3ACI) [![codecov.io](https://codecov.io/gh/SciML/Catalyst.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/SciML/Catalyst.jl) @@ -12,182 +12,197 @@ [![ColPrac: Contributor's Guide on Collaborative Practices for Community Packages](https://img.shields.io/badge/ColPrac-Contributor's%20Guide-blueviolet)](https://github.com/SciML/ColPrac) [![SciML Code Style](https://img.shields.io/static/v1?label=code%20style&message=SciML&color=9558b2&labelColor=389826)](https://github.com/SciML/SciMLStyle) +[![Citation](https://img.shields.io/badge/Publication-389826)](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530) -Catalyst.jl is a symbolic modeling package for analysis and high performance +Catalyst.jl is a symbolic modeling package for analysis and high-performance simulation of chemical reaction networks. Catalyst defines symbolic [`ReactionSystem`](https://docs.sciml.ai/Catalyst/stable/catalyst_functionality/programmatic_CRN_construction/)s, which can be created programmatically or easily -specified using Catalyst's domain specific language (DSL). Leveraging -[ModelingToolkit](https://github.com/SciML/ModelingToolkit.jl) and +specified using Catalyst's domain-specific language (DSL). Leveraging +[ModelingToolkit.jl](https://github.com/SciML/ModelingToolkit.jl) and [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl), Catalyst enables large-scale simulations through auto-vectorization and parallelism. Symbolic `ReactionSystem`s can be used to generate ModelingToolkit-based models, allowing the easy simulation and parameter estimation of mass action ODE models, Chemical Langevin SDE models, stochastic chemical kinetics jump process models, and more. -Generated models can be used with solvers throughout the broader -[SciML](https://sciml.ai) ecosystem, including higher level SciML packages (e.g. +Generated models can be used with solvers throughout the broader Julia and +[SciML](https://sciml.ai) ecosystems, including higher-level SciML packages (e.g. for sensitivity analysis, parameter estimation, machine learning applications, etc). ## Breaking changes and new features -**NOTE:** version 14 is a breaking release, prompted by the release of ModelingToolkit.jl version 9. This caused several breaking changes in how Catalyst models are represented and interfaced with. +**NOTE:** Version 14 is a breaking release, prompted by the release of ModelingToolkit.jl version 9. This caused several breaking changes in how Catalyst models are represented and interfaced with. -Breaking changes and new functionality are summarized in the -[HISTORY.md](HISTORY.md) file. +Breaking changes and new functionality are summarized in the [HISTORY.md](HISTORY.md) file. Furthermore, a migration guide on how to adapt your workflows to the new v14 update can be found [here](https://docs.sciml.ai/Catalyst/stable/v14_migration_guide/). ## Tutorials and documentation -The latest tutorials and information on using the package are available in the [stable +The latest tutorials and information on using Catalyst are available in the [stable documentation](https://docs.sciml.ai/Catalyst/stable/). The [in-development documentation](https://docs.sciml.ai/Catalyst/dev/) describes unreleased features in the current master branch. -Several Youtube video tutorials and overviews are also available, but note these use older -Catalyst versions with slightly different notation (for example, in building reaction networks): -- From JuliaCon 2023: A short 15 minute overview of Catalyst as of version 13 is -available in the talk [Catalyst.jl, Modeling Chemical Reaction Networks](https://www.youtube.com/watch?v=yreW94n98eM&ab_channel=TheJuliaProgrammingLanguage). -- From JuliaCon 2022: A three hour tutorial workshop overviewing how to use - Catalyst and its more advanced features as of version 12.1. [Workshop - video](https://youtu.be/tVfxT09AtWQ), [Workshop Pluto.jl - Notebooks](https://github.com/SciML/JuliaCon2022_Catalyst_Workshop). -- From SIAM CSE 2021: A short 15 minute overview of Catalyst as of version 6 is -available in the talk [Modeling Biochemical Systems with -Catalyst.jl](https://www.youtube.com/watch?v=5p1PJE5A5Jw). -- From JuliaCon 2018: A short 13 minute overview of Catalyst when it was known - as DiffEqBiological in older versions is available in the talk [Efficient - Modelling of Biochemical Reaction - Networks](https://www.youtube.com/watch?v=s1e72k5XD6s) - -Finally, an overview of the package and its features (as of version 13) can also be found in its corresponding research paper, [Catalyst: Fast and flexible modeling of reaction networks](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530). +An overview of the package, its features, and comparative benchmarking (as of version 13) can also +be found in its corresponding research paper, [Catalyst: Fast and flexible modeling of reaction networks](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530). ## Features -- A DSL provides a simple and readable format for manually specifying chemical - reactions. -- Catalyst `ReactionSystem`s provide a symbolic representation of reaction networks, - built on [ModelingToolkit.jl](https://docs.sciml.ai/ModelingToolkit/stable/) and - [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/). -- Non-integer (e.g. `Float64`) stoichiometric coefficients are supported for generating - ODE models, and symbolic expressions for stoichiometric coefficients are supported for - all system types. -- The [Catalyst.jl API](http://docs.sciml.ai/Catalyst/stable/api/catalyst_api) provides functionality for extending networks, - building networks programmatically, network analysis, and for composing multiple - networks together. -- `ReactionSystem`s generated by the DSL can be converted to a variety of - `ModelingToolkit.AbstractSystem`s, including symbolic ODE, SDE and jump process - representations. -- Coupled differential and algebraic constraint equations can be included in - Catalyst models, and are incorporated during conversion to ODEs or steady - state equations. -- Conservation laws can be detected and applied to reduce system sizes, and generate - non-singular Jacobians, during conversion to ODEs, SDEs, and steady state equations. -- By leveraging ModelingToolkit, users have a variety of options for generating - optimized system representations to use in solvers. These include construction - of dense or sparse Jacobians, multithreading or parallelization of generated - derivative functions, automatic classification of reactions into optimized - jump types for Gillespie type simulations, automatic construction of - dependency graphs for jump systems, and more. -- Generated systems can be solved using any - [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) - ODE/SDE/jump solver, and can be used within `EnsembleProblem`s for carrying - out parallelized parameter sweeps and statistical sampling. Plot recipes - are available for visualizing the solutions. -- [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl) symbolic - expressions and Julia `Expr`s can be obtained for all rate laws and functions - determining the deterministic and stochastic terms within resulting ODE, SDE - or jump models. -- [Latexify](https://korsbo.github.io/Latexify.jl/stable/) can be used to generate - LaTeX expressions corresponding to generated mathematical models or the - underlying set of reactions. -- [Graphviz](https://graphviz.org/) can be used to generate and visualize - reaction network graphs. (Reusing the Graphviz interface created in - [Catlab.jl](https://algebraicjulia.github.io/Catlab.jl/stable/).) - -## Packages supporting Catalyst -- Catalyst [`ReactionSystem`](@ref)s can be imported from SBML files via - [SBMLToolkit.jl](https://github.com/SciML/SBMLToolkit.jl), and from BioNetGen .net - files and various stoichiometric matrix network representations using - [ReactionNetworkImporters.jl](https://github.com/SciML/ReactionNetworkImporters.jl). -- [MomentClosure.jl](https://github.com/augustinas1/MomentClosure.jl) allows - generation of symbolic ModelingToolkit `ODESystem`s, representing moment - closure approximations to moments of the Chemical Master Equation, from - reaction networks defined in Catalyst. -- [FiniteStateProjection.jl](https://github.com/kaandocal/FiniteStateProjection.jl) - allows the construction and numerical solution of Chemical Master Equation - models from reaction networks defined in Catalyst. -- [DelaySSAToolkit.jl](https://github.com/palmtree2013/DelaySSAToolkit.jl) can - augment Catalyst reaction network models with delays, and can simulate the - resulting stochastic chemical kinetics with delays models. -- [BondGraphs.jl](https://github.com/jedforrest/BondGraphs.jl) a package for - constructing and analyzing bond graphs models, which can take Catalyst models as input. -- [PEtab.jl](https://github.com/sebapersson/PEtab.jl) a package that implements the PEtab format for fitting reaction network ODEs to data. Input can be provided either as SBML files or as Catalyst `ReactionSystem`s. - - -## Illustrative examples -#### Gillespie simulations of Michaelis-Menten enzyme kinetics +#### Features of Catalyst +- [The Catalyst DSL](https://docs.sciml.ai/Catalyst/stable/model_creation/dsl_basics/) provides a simple and readable format for manually specifying reaction network models using chemical reaction notation. +- Catalyst `ReactionSystem`s provides a symbolic representation of reaction networks, built on [ModelingToolkit.jl](https://docs.sciml.ai/ModelingToolkit/stable/) and [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/). +- The [Catalyst.jl API](http://docs.sciml.ai/Catalyst/stable/api/catalyst_api) provides functionality for building networks programmatically and for composing multiple networks together. +- Leveraging ModelingToolkit, generated models can be converted to symbolic reaction rate equation ODE models, symbolic Chemical Langevin Equation models, and symbolic stochastic chemical kinetics (jump process) models. These can be simulated using any [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) [ODE/SDE/jump solver](https://docs.sciml.ai/Catalyst/stable/model_simulation/simulation_introduction/), and can be used within `EnsembleProblem`s for carrying out [parallelized parameter sweeps and statistical sampling](https://docs.sciml.ai/Catalyst/stable/model_simulation/ensemble_simulations/). Plot recipes are available for [visualization of all solutions](https://docs.sciml.ai/Catalyst/stable/model_simulation/simulation_plotting/). +- Non-integer (e.g. `Float64`) stoichiometric coefficients [are supported](https://docs.sciml.ai/Catalyst/stable/model_creation/dsl_basics/#dsl_description_stoichiometries_decimal) for generating ODE models, and symbolic expressions for stoichiometric coefficients [are supported](https://docs.sciml.ai/Catalyst/stable/model_creation/parametric_stoichiometry/) for all system types. +- A [network analysis suite](https://docs.sciml.ai/Catalyst/stable/model_creation/network_analysis/) permits the computation of linkage classes, deficiencies, reversibility, and other network properties. +- [Conservation laws can be detected and utilized](https://docs.sciml.ai/Catalyst/stable/model_creation/network_analysis/#network_analysis_deficiency) to reduce system sizes, and to generate non-singular Jacobians (e.g. during conversion to ODEs, SDEs, and steady state equations). +- Catalyst reaction network models can be [coupled with differential and algebraic equations](https://docs.sciml.ai/Catalyst/stable/model_creation/constraint_equations/) (which are then incorporated during conversion to ODEs, SDEs, and steady state equations). +- Models can be [coupled with events](https://docs.sciml.ai/Catalyst/stable/model_creation/constraint_equations/#constraint_equations_events) that affect the system and its state during simulations. +- By leveraging ModelingToolkit, users have a variety of options for generating optimized system representations to use in solvers. These include construction of [dense or sparse Jacobians](https://docs.sciml.ai/Catalyst/stable/model_simulation/ode_simulation_performance/#ode_simulation_performance_sparse_jacobian), [multithreading or parallelization of generated derivative functions](https://docs.sciml.ai/Catalyst/stable/model_simulation/ode_simulation_performance/#ode_simulation_performance_parallelisation), [automatic classification of reactions into optimized jump types for Gillespie type simulations](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#jump_types), [automatic construction of dependency graphs for jump systems](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#Jump-Aggregators-Requiring-Dependency-Graphs), and more. +- [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl) symbolic expressions and Julia `Expr`s can be obtained for all rate laws and functions determining the deterministic and stochastic terms within resulting ODE, SDE, or jump models. +- [Steady states](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/homotopy_continuation/) (and their [stabilities](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/steady_state_stability_computation/)) can be computed for model ODE representations. + +#### Features of Catalyst composing with other packages +- [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) Can be used to numerically solver generated reaction rate equation ODE models. +- [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) can be used to numerically solve generated Chemical Langevin Equation SDE models. +- [JumpProcesses.jl](https://github.com/SciML/JumpProcesses.jl) can be used to numerically sample generated Stochastic Chemical Kinetics Jump Process models. +- Support for [parallelization of all simulations](https://docs.sciml.ai/Catalyst/stable/model_simulation/ode_simulation_performance/#ode_simulation_performance_parallelisation), including parallelization of [ODE simulations on GPUs](https://docs.sciml.ai/Catalyst/stable/model_simulation/ode_simulation_performance/#ode_simulation_performance_parallelisation_GPU) using [DiffEqGPU.jl](https://github.com/SciML/DiffEqGPU.jl). +- [Latexify](https://korsbo.github.io/Latexify.jl/stable/) can be used to [generate LaTeX expressions](https://docs.sciml.ai/Catalyst/stable/model_creation/model_visualisation/#visualisation_latex) corresponding to generated mathematical models or the underlying set of reactions. +- [Graphviz](https://graphviz.org/) can be used to generate and [visualize reaction network graphs](https://docs.sciml.ai/Catalyst/stable/model_creation/model_visualisation/#visualisation_graphs) (reusing the Graphviz interface created in [Catlab.jl](https://algebraicjulia.github.io/Catlab.jl/stable/)). +- Model steady states can be [computed through homotopy continuation](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/homotopy_continuation/) using [HomotopyContinuation.jl](https://github.com/JuliaHomotopyContinuation/HomotopyContinuation.jl) (which can find *all* steady states of systems with multiple ones), by [forward ODE simulations](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/nonlinear_solve/#steady_state_solving_simulation) using [SteadyStateDiffEq.jl](https://github.com/SciML/SteadyStateDiffEq.jl), or by [numerically solving steady-state nonlinear equations](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/nonlinear_solve/#steady_state_solving_nonlinear) using [NonlinearSolve.jl](https://github.com/SciML/NonlinearSolve.jl). +- [BifurcationKit.jl](https://github.com/bifurcationkit/BifurcationKit.jl) can be used to [compute bifurcation diagrams](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/bifurcation_diagrams/) of model steady states (including finding periodic orbits). +- [DynamicalSystems.jl](https://github.com/JuliaDynamics/DynamicalSystems.jl) can be used to compute model [basins of attraction](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/dynamical_systems/#dynamical_systems_basins_of_attraction), [Lyapunov spectrums](https://docs.sciml.ai/Catalyst/stable/steady_state_functionality/dynamical_systems/#dynamical_systems_lyapunov_exponents), and other dynamical system properties. +- [StructuralIdentifiability.jl](https://github.com/SciML/StructuralIdentifiability.jl) can be used to [perform structural identifiability analysis](https://docs.sciml.ai/Catalyst/stable/inverse_problems/structural_identifiability/). +- [Optimization.jl](https://github.com/SciML/Optimization.jl), [DiffEqParamEstim.jl](https://github.com/SciML/DiffEqParamEstim.jl), and [PEtab.jl](https://github.com/sebapersson/PEtab.jl) can all be used to [fit model parameters to data](https://sebapersson.github.io/PEtab.jl/stable/Define_in_julia/). +- [GlobalSensitivity.jl](https://github.com/SciML/GlobalSensitivity.jl) can be used to perform [global sensitivity analysis](https://docs.sciml.ai/Catalyst/stable/inverse_problems/global_sensitivity_analysis/) of model behaviors. +- [SciMLSensitivity.jl](https://github.com/SciML/SciMLSensitivity.jl) can be used to compute local sensitivities of functions containing forward model simulations. + +#### Features of packages built upon Catalyst +- Catalyst [`ReactionSystem`](@ref)s can be [imported from SBML files](https://docs.sciml.ai/Catalyst/stable/model_creation/model_file_loading_and_export/#Loading-SBML-files-using-SBMLImporter.jl-and-SBMLToolkit.jl) via [SBMLImporter.jl](https://github.com/SciML/SBMLImporter.jl) and [SBMLToolkit.jl](https://github.com/SciML/SBMLToolkit.jl), and [from BioNetGen .net files](https://docs.sciml.ai/Catalyst/stable/model_creation/model_file_loading_and_export/#file_loading_rni_net) and various stoichiometric matrix network representations using [ReactionNetworkImporters.jl](https://github.com/SciML/ReactionNetworkImporters.jl). +- [MomentClosure.jl](https://github.com/augustinas1/MomentClosure.jl) allows generation of symbolic ModelingToolkit `ODESystem`s that represent moment closure approximations to moments of the Chemical Master Equation, from reaction networks defined in Catalyst. +- [FiniteStateProjection.jl](https://github.com/kaandocal/FiniteStateProjection.jl) allows the construction and numerical solution of Chemical Master Equation models from reaction networks defined in Catalyst. +- [DelaySSAToolkit.jl](https://github.com/palmtree2013/DelaySSAToolkit.jl) can augment Catalyst reaction network models with delays, and can simulate the resulting stochastic chemical kinetics with delays models. +- [BondGraphs.jl](https://github.com/jedforrest/BondGraphs.jl), a package for constructing and analyzing bond graphs models, which can take Catalyst models as input. + + +## Illustrative example + +#### Deterministic ODE simulation of Michaelis-Menten enzyme kinetics +Here we show a simple example where a model is created using the Catalyst DSL, and then simulated as +an ordinary differential equation. ```julia -using Catalyst, Plots, JumpProcesses -rs = @reaction_network begin - c1, S + E --> SE - c2, SE --> S + E - c3, SE --> P + E +# Fetch required packages. +using Catalyst, OrdinaryDiffEq, Plots + +# Create model. +model = @reaction_network begin + kB, S + E --> SE + kD, SE --> S + E + kP, SE --> P + E end -p = (:c1 => 0.00166, :c2 => 0.0001, :c3 => 0.1) -tspan = (0., 100.) -u0 = [:S => 301, :E => 100, :SE => 0, :P => 0] - -# solve JumpProblem -dprob = DiscreteProblem(rs, u0, tspan, p) -jprob = JumpProblem(rs, dprob, Direct()) -jsol = solve(jprob, SSAStepper()) -plot(jsol; lw = 2, title = "Gillespie: Michaelis-Menten Enzyme Kinetics") -``` -![](https://user-images.githubusercontent.com/1814174/87864114-3bf9dd00-c932-11ea-83a0-58f38aee8bfb.png) +# Create an ODE that can be simulated. +u0 = [:S => 50.0, :E => 10.0, :SE => 0.0, :P => 0.0] +tspan = (0., 200.) +ps = [:kB => 0.01, :kD => 0.1, :kP => 0.1] +ode = ODEProblem(model, u0, tspan, ps) -#### Adaptive time stepping SDEs for a birth-death process +# Simulate ODE and plot results. +sol = solve(ode) +plot(sol; lw = 5) +``` +![ODE simulation](docs/src/assets/readme_ode_plot.svg) +#### Stochastic jump simulations +The same model can be used as input to other types of simulations. E.g. here we instead generate and simulate a stochastic chemical kinetics jump process model. ```julia -using Catalyst, Plots, StochasticDiffEq -rs = @reaction_network begin - c1, X --> 2X - c2, X --> 0 - c3, 0 --> X +# Create and simulate a jump process (here using Gillespie's direct algorithm). +# The initial conditions are now integers as we track exact populations for each species. +using JumpProcesses +u0_integers = [:S => 50, :E => 10, :SE => 0, :P => 0] +dprob = DiscreteProblem(model, u0_integers, tspan, ps) +jprob = JumpProblem(model, dprob, Direct()) +jump_sol = solve(jprob, SSAStepper()) +plot(jump_sol; lw = 2) +``` +![Jump simulation](docs/src/assets/readme_jump_plot.svg) + + +## More elaborate example +In the above example, we used basic Catalyst workflows to simulate a simple +model. Here we instead show how various Catalyst features can compose to create +a much more advanced model. Our model describes how the volume of a cell ($V$) +is affected by a growth factor ($G$). The growth factor only promotes growth +while in its phosphorylated form ($G^P$). The phosphorylation of $G$ ($G \to G^P$) +is promoted by sunlight (modeled as the cyclic sinusoid $k_a (\sin(t) + 1)$), +which phosphorylates the growth factor (producing $G^P$). When the cell reaches a +critical volume ($V_m$) it undergoes cell division. First, we declare our model: +```julia +using Catalyst +cell_model = @reaction_network begin + @parameters Vₘ g + @equations begin + D(V) ~ g*Gᴾ + end + @continuous_events begin + [V ~ Vₘ] => [V ~ V/2] + end + kₚ*(sin(t)+1)/V, G --> Gᴾ + kᵢ/V, Gᴾ --> G end -p = (:c1 => 1.0, :c2 => 2.0, :c3 => 50.) -tspan = (0.,10.) -u0 = [:X => 5.] -sprob = SDEProblem(rs, u0, tspan, p) -ssol = solve(sprob, LambaEM(), reltol=1e-3) -plot(ssol; lw = 2, title = "Adaptive SDE: Birth-Death Process") ``` +We now study the system as a Chemical Langevin Dynamics SDE model, which can be generated as follows +```julia +u0 = [:V => 25.0, :G => 50.0, :Gᴾ => 0.0] +tspan = (0.0, 20.0) +ps = [:Vₘ => 50.0, :g => 0.3, :kₚ => 100.0, :kᵢ => 60.0] +sprob = SDEProblem(cell_model, u0, tspan, ps) +``` +This problem encodes the following stochastic differential equation model: +```math +\begin{align*} +dG(t) &= - \left( \frac{k_p(\sin(t)+1)}{V(t)} G(t) + \frac{k_i}{V(t)} G^P(t) \right) dt - \sqrt{\frac{k_p (\sin(t)+1)}{V(t)} G(t)} \, dW_1(t) + \sqrt{\frac{k_i}{V(t)} G^P(t)} \, dW_2(t) \\ +dG^P(t) &= \left( \frac{k_p(\sin(t)+1)}{V(t)} G(t) - \frac{k_i}{V(t)} G^P(t) \right) dt + \sqrt{\frac{k_p (\sin(t)+1)}{V(t)} G(t)} \, dW_1(t) - \sqrt{\frac{k_i}{V(t)} G^P(t)} \, dW_2(t) \\ +dV(t) &= \left(g \, G^P(t)\right) dt +\end{align*} +``` +where the $dW_1(t)$ and $dW_2(t)$ terms represent independent Brownian Motions, encoding the noise added by the Chemical Langevin Equation. Finally, we can simulate and plot the results. +```julia +using StochasticDiffEq, Plots +sol = solve(sprob, EM(); dt = 0.05) +plot(sol; xguide = "Time (au)", lw = 2) +``` +![Elaborate SDE simulation](docs/src/assets/readme_elaborate_sde_plot.svg) -![](https://user-images.githubusercontent.com/1814174/87864113-3bf9dd00-c932-11ea-8275-f903eef90b91.png) - -## Getting help -Catalyst developers are active on the [Julia -Discourse](https://discourse.julialang.org/), the [Julia Slack](https://julialang.slack.com) channels \#sciml-bridged and \#sciml-sysbio, and the [Julia Zulip sciml-bridged channel](https://julialang.zulipchat.com/#narrow/stream/279055-sciml-bridged). -For bugs or feature requests [open an issue](https://github.com/SciML/Catalyst.jl/issues). +Some features we used here: +- The cell volume was [modeled as a differential equation, which was coupled to the reaction network model](https://docs.sciml.ai/Catalyst/stable/model_creation/constraint_equations/#constraint_equations_coupling_constraints). +- The cell divisions were created by [incorporating events into the model](https://docs.sciml.ai/Catalyst/stable/model_creation/constraint_equations/#constraint_equations_events). +- We designated a specific numeric [solver and corresponding solver options](https://docs.sciml.ai/Catalyst/stable/model_simulation/simulation_introduction/#simulation_intro_solver_options). +- The model simulation was [plotted using Plots.jl](https://docs.sciml.ai/Catalyst/stable/model_simulation/simulation_plotting/). +## Getting help or getting involved +Catalyst developers are active on the [Julia Discourse](https://discourse.julialang.org/) and +the [Julia Slack](https://julialang.slack.com) channels \#sciml-bridged and \#sciml-sysbio. +For bugs or feature requests, [open an issue](https://github.com/SciML/Catalyst.jl/issues). ## Supporting and citing Catalyst.jl -The software in this ecosystem was developed as part of academic research. If you would like to help support it, -please star the repository as such metrics may help us secure funding in the future. If you use Catalyst as part -of your research, teaching, or other activities, we would be grateful if you could cite our work: +The software in this ecosystem was developed as part of academic research. If you would like to help +support it, please star the repository as such metrics may help us secure funding in the future. If +you use Catalyst as part of your research, teaching, or other activities, we would be grateful if you +could cite our work: ``` @article{CatalystPLOSCompBio2023, - doi = {10.1371/journal.pcbi.1011530}, - author = {Loman, Torkel E. AND Ma, Yingbo AND Ilin, Vasily AND Gowda, Shashi AND Korsbo, Niklas AND Yewale, Nikhil AND Rackauckas, Chris AND Isaacson, Samuel A.}, - journal = {PLOS Computational Biology}, - publisher = {Public Library of Science}, - title = {Catalyst: Fast and flexible modeling of reaction networks}, - year = {2023}, - month = {10}, - volume = {19}, - url = {https://doi.org/10.1371/journal.pcbi.1011530}, - pages = {1-19}, - number = {10}, + doi = {10.1371/journal.pcbi.1011530}, + author = {Loman, Torkel E. AND Ma, Yingbo AND Ilin, Vasily AND Gowda, Shashi AND Korsbo, Niklas AND Yewale, Nikhil AND Rackauckas, Chris AND Isaacson, Samuel A.}, + journal = {PLOS Computational Biology}, + publisher = {Public Library of Science}, + title = {Catalyst: Fast and flexible modeling of reaction networks}, + year = {2023}, + month = {10}, + volume = {19}, + url = {https://doi.org/10.1371/journal.pcbi.1011530}, + pages = {1-19}, + number = {10}, } ``` diff --git a/docs/Project.toml b/docs/Project.toml index 28d446f257..0fe8c869b8 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -5,7 +5,6 @@ CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Catalyst = "479239e8-5488-4da2-87a7-35f2df7eef83" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DiffEqParamEstim = "1130ab10-4a5a-5621-a13d-e4788d82bd4c" -DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DynamicalSystems = "61744808-ddfa-5f27-97ff-6e42cc95d634" @@ -29,45 +28,45 @@ Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" QuasiMonteCarlo = "8a4e6c94-4038-4cdc-81c3-7e6ffdb2a71b" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" -Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" +StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" SteadyStateDiffEq = "9672c7b4-1e72-59bd-8a11-6ac3964bc41f" StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" StructuralIdentifiability = "220ca800-aa68-49bb-acd8-6037fa93a544" Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" [compat] -BenchmarkTools = "1" -BifurcationKit = "0.3" +BenchmarkTools = "1.5" +BifurcationKit = "0.3.4" CairoMakie = "0.12" -Catalyst = "13" -DataFrames = "1" -DiffEqParamEstim = "2.1" -DifferentialEquations = "7.7" +Catalyst = "14" +DataFrames = "1.6" +DiffEqParamEstim = "2.2" Distributions = "0.25" Documenter = "1.4.1" -DynamicalSystems = "3" -GlobalSensitivity = "2.4.0" -HomotopyContinuation = "2.6" +DynamicalSystems = "3.3" +GlobalSensitivity = "2.6" +HomotopyContinuation = "2.9" IncompleteLU = "0.2" -JumpProcesses = "9" -Latexify = "0.15, 0.16" -LinearSolve = "2" -ModelingToolkit = "9.5" -NonlinearSolve = "3.4.0" -Optim = "1" -Optimization = "3.19" -OptimizationBBO = "0.1.5, 0.2" -OptimizationNLopt = "0.1.8" -OptimizationOptimJL = "0.1.14" -OptimizationOptimisers = "0.1.1" -OrdinaryDiffEq = "6" -Plots = "1.36" -SciMLBase = "2.13" -SciMLSensitivity = "7.19" -Setfield = "1.1" -SpecialFunctions = "2.1" -SteadyStateDiffEq = "2.0.1" -StochasticDiffEq = "6" -StructuralIdentifiability = "0.5.1" -Symbolics = "5.14" +JumpProcesses = "9.11" +Latexify = "0.16" +LinearSolve = "2.30" +ModelingToolkit = "9.16.0" +NonlinearSolve = "3.12" +Optim = "1.9" +Optimization = "3.25" +OptimizationBBO = "0.3" +OptimizationNLopt = "0.2.1" +OptimizationOptimJL = "0.3.1" +OptimizationOptimisers = "0.2.1" +OrdinaryDiffEq = "6.80.1" +Plots = "1.40" +QuasiMonteCarlo = "0.3" +SciMLBase = "2.39" +SciMLSensitivity = "7.60" +SpecialFunctions = "2.4" +StaticArrays = "1.9" +SteadyStateDiffEq = "2.2" +StochasticDiffEq = "6.65" +StructuralIdentifiability = "0.5.8" +Symbolics = "5.30.1" diff --git a/docs/make.jl b/docs/make.jl index bd486f9711..0d354c641a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -31,17 +31,17 @@ include("pages.jl") # pages = pages) makedocs(sitename = "Catalyst.jl", - authors = "Samuel Isaacson", - format = Documenter.HTML(; analytics = "UA-90474609-3", - prettyurls = (get(ENV, "CI", nothing) == "true"), - assets = ["assets/favicon.ico"], - canonical = "https://docs.sciml.ai/Catalyst/stable/"), - modules = [Catalyst, ModelingToolkit], - doctest = false, - clean = true, - pages = pages, - pagesonly = true, - warnonly = true) + authors = "Samuel Isaacson", + format = Documenter.HTML(; analytics = "UA-90474609-3", + prettyurls = (get(ENV, "CI", nothing) == "true"), + assets = ["assets/favicon.ico"], + canonical = "https://docs.sciml.ai/Catalyst/stable/"), + modules = [Catalyst, ModelingToolkit], + doctest = false, + clean = true, + pages = pages, + pagesonly = false, + warnonly = [:missing_docs]) deploydocs(repo = "github.com/SciML/Catalyst.jl.git"; - push_preview = true) + push_preview = true) diff --git a/docs/old_files/advanced.md b/docs/old_files/advanced.md index 2888749744..80388d12bb 100644 --- a/docs/old_files/advanced.md +++ b/docs/old_files/advanced.md @@ -25,7 +25,7 @@ end ``` occurs at the rate ``d[X]/dt = -k[X]``, it is possible to ignore this by using any of the following non-filled arrows when declaring the reaction: `<=`, `⇐`, `⟽`, -`⇒`, `⟾`, `=>`, `⇔`, `⟺` (`<=>` currently not possible due to Julia langauge technical reasons). This means that the reaction +`⇒`, `⟾`, `=>`, `⇔`, `⟺` (`<=>` currently not possible due to Julia language technical reasons). This means that the reaction ```julia rn = @reaction_network begin diff --git a/docs/pages.jl b/docs/pages.jl index 6a0bebeaf4..5020417904 100644 --- a/docs/pages.jl +++ b/docs/pages.jl @@ -1,9 +1,8 @@ pages = Any[ - "Home" => "home.md", + "Home" => "index.md", "Introduction to Catalyst" => Any[ "introduction_to_catalyst/catalyst_for_new_julia_users.md", "introduction_to_catalyst/introduction_to_catalyst.md" - # Advanced introduction. ], "Model Creation and Properties" => Any[ "model_creation/dsl_basics.md", @@ -11,10 +10,9 @@ pages = Any[ "model_creation/programmatic_CRN_construction.md", "model_creation/compositional_modeling.md", "model_creation/constraint_equations.md", - # Events. - "model_creation/parametric_stoichiometry.md",# Distributed parameters, rates, and initial conditions. - # Loading and writing models to files. - # Model visualisation. + "model_creation/parametric_stoichiometry.md", + "model_creation/model_file_loading_and_export.md", + "model_creation/model_visualisation.md", "model_creation/reactionsystem_content_accessing.md", "model_creation/network_analysis.md", "model_creation/chemistry_related_functionality.md", @@ -27,47 +25,29 @@ pages = Any[ ], "Model simulation" => Any[ "model_simulation/simulation_introduction.md", - # Simulation introduction. "model_simulation/simulation_plotting.md", "model_simulation/simulation_structure_interfacing.md", "model_simulation/ensemble_simulations.md", - # Stochastic simulation statistical analysis. "model_simulation/ode_simulation_performance.md", - # ODE Performance considerations/advice. - # SDE Performance considerations/advice. - # Jump Performance considerations/advice. - # Finite state projection + "model_simulation/sde_simulation_performance.md" ], "Steady state analysis" => Any[ "steady_state_functionality/homotopy_continuation.md", "steady_state_functionality/nonlinear_solve.md", - "steady_state_functionality/steady_state_stability_computation.md", + "steady_state_functionality/steady_state_stability_computation.md", "steady_state_functionality/bifurcation_diagrams.md", "steady_state_functionality/dynamical_systems.md" ], "Inverse Problems" => Any[ - # Inverse problems introduction. "inverse_problems/optimization_ode_param_fitting.md", # "inverse_problems/petab_ode_param_fitting.md", - # ODE parameter fitting using Turing. - # SDE/Jump fitting. "inverse_problems/behaviour_optimisation.md", "inverse_problems/structural_identifiability.md", - # Practical identifiability. "inverse_problems/global_sensitivity_analysis.md", "Inverse problem examples" => Any[ "inverse_problems/examples/ode_fitting_oscillation.md" ] ], - "Spatial modelling" => Any[ - # Intro. - # Lattice ODEs. - # Lattice Jumps. - ], - # "Developer Documentation" => Any[ - # # Contributor's guide. - # # Repository structure. - # ], "FAQs" => "faqs.md", "API" => "api.md" -] \ No newline at end of file +] diff --git a/docs/src/api.md b/docs/src/api.md index 49b74adaa1..459dbd1c1b 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -1,4 +1,4 @@ -# Catalyst.jl API +# [Catalyst.jl API](@id api) ```@meta CurrentModule = Catalyst ``` @@ -35,7 +35,7 @@ corresponding chemical reaction ODE models, chemical Langevin equation SDE models, and stochastic chemical kinetics jump process models. ```@example ex1 -using Catalyst, DifferentialEquations, Plots +using Catalyst, OrdinaryDiffEq, StochasticDiffEq, JumpProcesses, Plots t = default_t() @parameters β γ @species S(t) I(t) R(t) @@ -43,6 +43,7 @@ t = default_t() rxs = [Reaction(β, [S,I], [I], [1,1], [2]) Reaction(γ, [I], [R])] @named rs = ReactionSystem(rxs, t) +rs = complete(rs) u₀map = [S => 999.0, I => 1.0, R => 0.0] parammap = [β => 1/10000, γ => 0.01] @@ -50,22 +51,25 @@ tspan = (0.0, 250.0) # solve as ODEs odesys = convert(ODESystem, rs) +odesys = complete(odesys) oprob = ODEProblem(odesys, u₀map, tspan, parammap) sol = solve(oprob, Tsit5()) p1 = plot(sol, title = "ODE") # solve as SDEs sdesys = convert(SDESystem, rs) +sdesys = complete(sdesys) sprob = SDEProblem(sdesys, u₀map, tspan, parammap) -sol = solve(sprob, EM(), dt=.01) +sol = solve(sprob, EM(), dt=.01, saveat = 2.0) p2 = plot(sol, title = "SDE") # solve as jump process jumpsys = convert(JumpSystem, rs) +jumpsys = complete(jumpsys) u₀map = [S => 999, I => 1, R => 0] dprob = DiscreteProblem(jumpsys, u₀map, tspan, parammap) -jprob = JumpProblem(jumpsys, dprob, Direct()) -sol = solve(jprob, SSAStepper()) +jprob = JumpProblem(jumpsys, dprob, Direct(); save_positions = (false,false)) +sol = solve(jprob, SSAStepper(), saveat = 2.0) p3 = plot(sol, title = "jump") plot(p1, p2, p3; layout = (3,1)) @@ -73,6 +77,7 @@ plot(p1, p2, p3; layout = (3,1)) ```@docs @reaction_network +@network_component make_empty_network @reaction Reaction @@ -123,7 +128,7 @@ can call: * `ModelingToolkit.unknowns(rn)` returns all species *and variables* across the system, *all sub-systems*, and all constraint systems. Species are ordered before non-species variables in `unknowns(rn)`, with the first `numspecies(rn)` - entires in `unknowns(rn)` being the same as `species(rn)`. + entries in `unknowns(rn)` being the same as `species(rn)`. * [`species(rn)`](@ref) is a vector collecting all the chemical species within the system and any sub-systems that are also `ReactionSystems`. * `ModelingToolkit.parameters(rn)` returns all parameters across the @@ -152,16 +157,13 @@ accessor functions. ```@docs species nonspecies -reactionsystemparams reactions nonreactions numspecies numparams numreactions -numreactionsystemparams speciesmap paramsmap -reactionsystemparamsmap isspecies isautonomous Catalyst.isconstant @@ -271,6 +273,7 @@ hillar ```@docs Base.convert ModelingToolkit.structural_simplify +set_default_noise_scaling ``` ## Chemistry-related functionalities diff --git a/docs/src/assets/Project.toml b/docs/src/assets/Project.toml deleted file mode 100644 index e7454091d2..0000000000 --- a/docs/src/assets/Project.toml +++ /dev/null @@ -1,57 +0,0 @@ -[deps] -BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" -Catalyst = "479239e8-5488-4da2-87a7-35f2df7eef83" -DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -DiffEqParamEstim = "1130ab10-4a5a-5621-a13d-e4788d82bd4c" -DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa" -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" -Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -HomotopyContinuation = "f213a82b-91d6-5c5d-acf7-10f1c761b327" -Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316" -Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" -ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" -NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" -Optim = "429524aa-4258-5aef-a3af-852621145aeb" -Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba" -OptimizationNLopt = "4e6fcdb7-1186-4e1f-a706-475e75c168bb" -OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e" -OptimizationOptimisers = "42dfb2eb-d2b4-4451-abcd-913932933ac1" -OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" -Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" -QuasiMonteCarlo = "8a4e6c94-4038-4cdc-81c3-7e6ffdb2a71b" -SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" -SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" -Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" -SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" -SteadyStateDiffEq = "9672c7b4-1e72-59bd-8a11-6ac3964bc41f" -StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" -StructuralIdentifiability = "220ca800-aa68-49bb-acd8-6037fa93a544" -Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" - -[compat] -BifurcationKit = "0.3" -Catalyst = "13" -DataFrames = "1" -DiffEqParamEstim = "2.1" -DifferentialEquations = "7.7" -Distributions = "0.25" -Documenter = "1.4.1" -HomotopyContinuation = "2.6" -Latexify = "0.15, 0.16" -ModelingToolkit = "9.5" -NonlinearSolve = "3.4.0" -Optim = "1" -Optimization = "3.19" -OptimizationNLopt = "0.1.8" -OptimizationOptimJL = "0.1.14" -OptimizationOptimisers = "0.1.1" -OrdinaryDiffEq = "6" -Plots = "1.36" -SciMLBase = "2.13" -SciMLSensitivity = "7.19" -Setfield = "1.1" -SpecialFunctions = "2.1" -SteadyStateDiffEq = "2.0.1" -StochasticDiffEq = "6" -StructuralIdentifiability = "0.5.1" -Symbolics = "5.14" diff --git a/docs/src/assets/brusselator_sim_SBMLImporter.svg b/docs/src/assets/brusselator_sim_SBMLImporter.svg new file mode 100644 index 0000000000..ea99a21681 --- /dev/null +++ b/docs/src/assets/brusselator_sim_SBMLImporter.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/model_files/brusselator.xml b/docs/src/assets/model_files/brusselator.xml new file mode 100644 index 0000000000..228706ff9b --- /dev/null +++ b/docs/src/assets/model_files/brusselator.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + 2024-01-09T21:46:45Z + + + + + + + + + 2024-01-09T21:46:45Z + + + 2024-01-09T21:46:45Z + + + + + + + + + + v + + v + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + compartment + k1 + + + X + 2 + + Y + + + + + + + + + + + + + + + + + + + compartment + B + X + + + + + + + + + + + + + compartment + k1 + X + + + + + + + + + + + + + + + + compartment + + Constant_flux__irreversible + A + + + + + + + + \ No newline at end of file diff --git a/docs/src/assets/model_files/repressilator.net b/docs/src/assets/model_files/repressilator.net new file mode 100644 index 0000000000..769d7a71f0 --- /dev/null +++ b/docs/src/assets/model_files/repressilator.net @@ -0,0 +1,93 @@ +# Created by BioNetGen 2.3.1 +begin parameters + 1 Na 6.022e23 # Constant + 2 V 1.4e-15 # Constant + 3 c0 1e9 # Constant + 4 c1 224 # Constant + 5 c2 9 # Constant + 6 c3 0.5 # Constant + 7 c4 5e-4 # Constant + 8 c5 0.167 # Constant + 9 c6 ln(2)/120 # Constant + 10 c7 ln(2)/600 # Constant + 11 tF 1e-4 # Constant + 12 rF 1000 # Constant + 13 pF 1000 # Constant + 14 _rateLaw1 (((c0/Na)/V)*tF)/pF # ConstantExpression + 15 _rateLaw2 c1*tF # ConstantExpression + 16 _rateLaw3 (((c0/Na)/V)*tF)/pF # ConstantExpression + 17 _rateLaw4 c2*tF # ConstantExpression + 18 _rateLaw5 c3*rF # ConstantExpression + 19 _rateLaw6 c4*rF # ConstantExpression + 20 _rateLaw7 (c5/rF)*pF # ConstantExpression + 21 _rateLaw8 (((c0/Na)/V)*tF)/pF # ConstantExpression + 22 _rateLaw9 c1*tF # ConstantExpression + 23 _rateLaw10 (((c0/Na)/V)*tF)/pF # ConstantExpression + 24 _rateLaw11 c2*tF # ConstantExpression + 25 _rateLaw12 c3*rF # ConstantExpression + 26 _rateLaw13 c4*rF # ConstantExpression + 27 _rateLaw14 (c5/rF)*pF # ConstantExpression + 28 _rateLaw15 (((c0/Na)/V)*tF)/pF # ConstantExpression + 29 _rateLaw16 c1*tF # ConstantExpression + 30 _rateLaw17 (((c0/Na)/V)*tF)/pF # ConstantExpression + 31 _rateLaw18 c2*tF # ConstantExpression + 32 _rateLaw19 c3*rF # ConstantExpression + 33 _rateLaw20 c4*rF # ConstantExpression + 34 _rateLaw21 (c5/rF)*pF # ConstantExpression +end parameters +begin species + 1 Null() 1 + 2 gTetR(lac!1,lac!2).pLacI(tet!1).pLacI(tet!2) 1 + 3 gCI(tet!1,tet!2).pTetR(cI!1).pTetR(cI!2) 1 + 4 gLacI(cI!1,cI!2).pCI(lac!1).pCI(lac!2) 1 + 5 mTetR() 3163 + 6 mCI() 6819 + 7 mLacI() 129 + 8 pTetR(cI) 183453 + 9 pCI(lac) 2006198 + 10 pLacI(tet) 165670 + 11 gTetR(lac!1,lac).pLacI(tet!1) 0 + 12 gCI(tet!1,tet).pTetR(cI!1) 0 + 13 gLacI(cI!1,cI).pCI(lac!1) 0 + 14 gTetR(lac,lac) 0 + 15 gCI(tet,tet) 0 + 16 gLacI(cI,cI) 0 +end species +begin reactions + 1 2 10,11 2*_rateLaw4 #_reverse__R2 + 2 2 2,5 _rateLaw6 #_R4 + 3 5 5,8 _rateLaw7 #_R5 + 4 1,5 1 c6 #_R6 + 5 1,8 1 c7 #_R7 + 6 3 8,12 2*_rateLaw11 #_reverse__R9 + 7 3 3,6 _rateLaw13 #_R11 + 8 6 6,9 _rateLaw14 #_R12 + 9 1,6 1 c6 #_R13 + 10 1,9 1 c7 #_R14 + 11 4 9,13 2*_rateLaw18 #_reverse__R16 + 12 4 4,7 _rateLaw20 #_R18 + 13 7 7,10 _rateLaw21 #_R19 + 14 1,7 1 c6 #_R20 + 15 1,10 1 c7 #_R21 + 16 11 10,14 _rateLaw2 #_reverse__R1 + 17 10,11 2 _rateLaw3 #_R2 + 18 11 5,11 _rateLaw6 #_R4 + 19 12 8,15 _rateLaw9 #_reverse__R8 + 20 8,12 3 _rateLaw10 #_R9 + 21 12 6,12 _rateLaw13 #_R11 + 22 13 9,16 _rateLaw16 #_reverse__R15 + 23 9,13 4 _rateLaw17 #_R16 + 24 13 7,13 _rateLaw20 #_R18 + 25 10,14 11 2*_rateLaw1 #_R1 + 26 14 5,14 _rateLaw5 #_R3 + 27 8,15 12 2*_rateLaw8 #_R8 + 28 15 6,15 _rateLaw12 #_R10 + 29 9,16 13 2*_rateLaw15 #_R15 + 30 16 7,16 _rateLaw19 #_R17 +end reactions +begin groups + 1 pTetR 8 + 2 pCI 9 + 3 pLacI 10 + 4 NULL 1 +end groups \ No newline at end of file diff --git a/docs/src/assets/network_graphs/brusselator_graph.png b/docs/src/assets/network_graphs/brusselator_graph.png new file mode 100644 index 0000000000..599d236158 Binary files /dev/null and b/docs/src/assets/network_graphs/brusselator_graph.png differ diff --git a/docs/src/assets/network_graphs/repressilator_complex_graph.png b/docs/src/assets/network_graphs/repressilator_complex_graph.png new file mode 100644 index 0000000000..a530265917 Binary files /dev/null and b/docs/src/assets/network_graphs/repressilator_complex_graph.png differ diff --git a/docs/src/assets/network_graphs/repressilator_graph.png b/docs/src/assets/network_graphs/repressilator_graph.png new file mode 100644 index 0000000000..e80b00ea72 Binary files /dev/null and b/docs/src/assets/network_graphs/repressilator_graph.png differ diff --git a/docs/src/assets/readme_elaborate_sde_plot.svg b/docs/src/assets/readme_elaborate_sde_plot.svg new file mode 100644 index 0000000000..503e76d2ee --- /dev/null +++ b/docs/src/assets/readme_elaborate_sde_plot.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/readme_jump_plot.svg b/docs/src/assets/readme_jump_plot.svg new file mode 100644 index 0000000000..5c45563c97 --- /dev/null +++ b/docs/src/assets/readme_jump_plot.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/readme_ode_plot.svg b/docs/src/assets/readme_ode_plot.svg new file mode 100644 index 0000000000..df9c2eb095 --- /dev/null +++ b/docs/src/assets/readme_ode_plot.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/repressilator_sim_ReactionNetworkImporters.svg b/docs/src/assets/repressilator_sim_ReactionNetworkImporters.svg new file mode 100644 index 0000000000..adb9b1262c --- /dev/null +++ b/docs/src/assets/repressilator_sim_ReactionNetworkImporters.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/faqs.md b/docs/src/faqs.md index dafeac35da..23c0dd7617 100644 --- a/docs/src/faqs.md +++ b/docs/src/faqs.md @@ -5,19 +5,17 @@ One can directly use symbolic variables to index into SciML solution objects. Moreover, observables can also be evaluated in this way. For example, consider the system ```@example faq1 -using Catalyst, DifferentialEquations, Plots +using Catalyst, OrdinaryDiffEq, Plots rn = @reaction_network ABtoC begin (k₊,k₋), A + B <--> C end - -# initial condition and parameter values -setdefaults!(rn, [:A => 1.0, :B => 2.0, :C => 0.0, :k₊ => 1.0, :k₋ => 1.0]) nothing # hide ``` Let's convert it to a system of ODEs, using the conservation laws of the system to eliminate two of the species: ```@example faq1 osys = convert(ODESystem, rn; remove_conserved = true) +osys = complete(osys) ``` Notice the resulting ODE system has just one ODE, while algebraic observables have been added for the two removed species (in terms of the conservation law @@ -28,7 +26,9 @@ observed(osys) Let's solve the system and see how to index the solution using our symbolic variables ```@example faq1 -oprob = ODEProblem(osys, [], (0.0, 10.0), []) +u0 = [osys.A => 1.0, osys.B => 2.0, osys.C => 0.0] +ps = [osys.k₊ => 1.0, osys.k₋ => 1.0] +oprob = ODEProblem(osys, u0, (0.0, 10.0), ps) sol = solve(oprob, Tsit5()) ``` Suppose we want to plot just species `C`, without having to know its integer @@ -44,8 +44,8 @@ sol[C] ``` To evaluate `C` at specific times and plot it we can just do ```@example faq1 -t = range(0.0, 10.0, length=101) -plot(t, sol(t, idxs = C), label = "C(t)", xlabel = "t") +t = range(0.0, 10.0, length = 101) +plot(sol(t, idxs = C), label = "C(t)", xlabel = "t") ``` If we want to get multiple variables we can just do ```@example faq1 @@ -59,13 +59,13 @@ plot(sol; idxs = [A, B]) ``` ## How to disable rescaling of reaction rates in rate laws? -As explained in the [Reaction rate laws used in simulations](@ref) section, for +As explained in the [Reaction rate laws used in simulations](@ref introduction_to_catalyst_ratelaws) section, for a reaction such as `k, 2X --> 0`, the generated rate law will rescale the rate constant, giving `k*X^2/2` instead of `k*X^2` for ODEs and `k*X*(X-1)/2` instead of `k*X*(X-1)` for jumps. This can be disabled when directly `convert`ing a [`ReactionSystem`](@ref). If `rn` is a generated [`ReactionSystem`](@ref), we can do -```julia +```@example faq1 osys = convert(ODESystem, rn; combinatoric_ratelaws=false) ``` Disabling these rescalings should work for all conversions of `ReactionSystem`s @@ -87,14 +87,16 @@ rx1 = Reaction(k,[B,C],[B,D], [2.5,1],[3.5, 2.5]) rx2 = Reaction(2*k, [B], [D], [1], [2.5]) rx3 = Reaction(2*k, [B], [D], [2.5], [2]) @named mixedsys = ReactionSystem([rx1, rx2, rx3], t, [A, B, C, D], [k, b]) +mixedsys = complete(mixedsys) osys = convert(ODESystem, mixedsys; combinatoric_ratelaws = false) +osys = complete(osys) ``` Note, when using `convert(ODESystem, mixedsys; combinatoric_ratelaws=false)` the `combinatoric_ratelaws=false` parameter must be passed. This is also true when calling `ODEProblem(mixedsys,...; combinatoric_ratelaws=false)`. As described above, this disables Catalyst's standard rescaling of reaction rates when generating reaction rate laws, see also the [Reaction rate laws used in -simulations](@ref) section. Leaving this keyword out for systems with floating +simulations](@ref introduction_to_catalyst_ratelaws) section. Leaving this keyword out for systems with floating point stoichiometry will give an error message. For a more extensive documentation of using non-integer stoichiometric @@ -103,7 +105,7 @@ parametric_stoichiometry) section. ## How to set default values for initial conditions and parameters? How to set defaults when using the `@reaction_network` macro is described in -more detail [here](@ref dsl_description_defaults). There are several ways to do +more detail [here](@ref dsl_advanced_options_default_vals). There are several ways to do this. Using the DSL, one can use the `@species` and `@parameters` options: ```@example faq3 using Catalyst @@ -127,6 +129,7 @@ t = default_t() rx1 = Reaction(β, [S, I], [I], [1,1], [2]) rx2 = Reaction(ν, [I], [R]) @named sir = ReactionSystem([rx1, rx2], t) +sir = complete(sir) oprob = ODEProblem(sir, [], (0.0, 250.0)) sol = solve(oprob, Tsit5()) plot(sol) @@ -162,7 +165,7 @@ Julia `Symbol`s corresponding to each variable/parameter to their values, or from ModelingToolkit symbolic variables/parameters to their values. Using `Symbol`s we have ```@example faq4 -using Catalyst, DifferentialEquations +using Catalyst, OrdinaryDiffEq rn = @reaction_network begin α, S + I --> 2I β, I --> R @@ -199,6 +202,7 @@ the second example, or one can use the `symmap_to_varmap` function to convert th `Symbol` mapping to a symbolic mapping. I.e. this works ```@example faq4 osys = convert(ODESystem, rn) +osys = complete(osys) # this works u0 = symmap_to_varmap(rn, [:S => 999.0, :I => 1.0, :R => 0.0]) @@ -221,6 +225,7 @@ rx1 = @reaction k, A --> 0 rx2 = @reaction $f, 0 --> A eq = f ~ (1 + sin(t)) @named rs = ReactionSystem([rx1, rx2, eq], t) +rs = complete(rs) osys = convert(ODESystem, rs) ``` In the final ODE model, `f` can be eliminated by using diff --git a/docs/src/home.md b/docs/src/home.md deleted file mode 100644 index 81053b8b2c..0000000000 --- a/docs/src/home.md +++ /dev/null @@ -1,177 +0,0 @@ -# Catalyst.jl for Reaction Network Modeling - -Catalyst.jl is a symbolic modeling package for analysis and high performance -simulation of chemical reaction networks. Catalyst defines symbolic -[`ReactionSystem`](@ref)s, which can be created programmatically or easily -specified using Catalyst's domain specific language (DSL). Leveraging -[ModelingToolkit.jl](https://docs.sciml.ai/ModelingToolkit/stable/) and -[Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/), Catalyst enables -large-scale simulations through auto-vectorization and parallelism. Symbolic -`ReactionSystem`s can be used to generate ModelingToolkit-based models, allowing -the easy simulation and parameter estimation of mass action ODE models, Chemical -Langevin SDE models, stochastic chemical kinetics jump process models, and more. -Generated models can be used with solvers throughout the broader -[SciML](https://sciml.ai) ecosystem, including higher level SciML packages (e.g. -for sensitivity analysis, parameter estimation, machine learning applications, -etc). - -## Features -- A DSL provides a simple and readable format for manually specifying chemical - reactions. -- Catalyst `ReactionSystem`s provide a symbolic representation of reaction networks, - built on [ModelingToolkit.jl](https://docs.sciml.ai/ModelingToolkit/stable/) and - [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/). -- Non-integer (e.g. `Float64`) stoichiometric coefficients are supported for generating - ODE models, and symbolic expressions for stoichiometric coefficients are supported for - all system types. -- The [Catalyst.jl API](@ref) provides functionality for extending networks, - building networks programmatically, network analysis, and for composing multiple - networks together. -- `ReactionSystem`s generated by the DSL can be converted to a variety of - `ModelingToolkit.AbstractSystem`s, including symbolic ODE, SDE and jump process - representations. -- Coupled differential and algebraic constraint equations can be included in - Catalyst models, and are incorporated during conversion to ODEs or steady - state equations. -- Conservation laws can be detected and applied to reduce system sizes, and - generate non-singular Jacobians, during conversion to ODEs, SDEs, and steady - state equations. -- By leveraging ModelingToolkit, users have a variety of options for generating - optimized system representations to use in solvers. These include construction - of dense or sparse Jacobians, multithreading or parallelization of generated - derivative functions, automatic classification of reactions into optimized - jump types for Gillespie type simulations, automatic construction of - dependency graphs for jump systems, and more. -- Generated systems can be solved using any - [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) - ODE/SDE/jump solver, and can be used within `EnsembleProblem`s for carrying - out parallelized parameter sweeps and statistical sampling. Plot recipes - are available for visualizing the solutions. -- [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl) symbolic - expressions and Julia `Expr`s can be obtained for all rate laws and functions - determining the deterministic and stochastic terms within resulting ODE, SDE - or jump models. -- [Latexify](https://korsbo.github.io/Latexify.jl/stable/) can be used to generate - LaTeX expressions corresponding to generated mathematical models or the - underlying set of reactions. -- [Graphviz](https://graphviz.org/) can be used to generate and visualize - reaction network graphs. (Reusing the Graphviz interface created in - [Catlab.jl](https://algebraicjulia.github.io/Catlab.jl/stable/).) - -## Packages Supporting Catalyst -- Catalyst [`ReactionSystem`](@ref)s can be imported from SBML files via - [SBMLToolkit.jl](https://docs.sciml.ai/SBMLToolkit/stable/), and from BioNetGen .net - files and various stoichiometric matrix network representations using - [ReactionNetworkImporters.jl](https://docs.sciml.ai/ReactionNetworkImporters/stable/). -- [MomentClosure.jl](https://augustinas1.github.io/MomentClosure.jl/dev) allows - generation of symbolic ModelingToolkit `ODESystem`s, representing moment - closure approximations to moments of the Chemical Master Equation, from - reaction networks defined in Catalyst. -- [FiniteStateProjection.jl](https://kaandocal.github.io/FiniteStateProjection.jl/dev/) - allows the construction and numerical solution of Chemical Master Equation - models from reaction networks defined in Catalyst. -- [DelaySSAToolkit.jl](https://palmtree2013.github.io/DelaySSAToolkit.jl/dev/) can - augment Catalyst reaction network models with delays, and can simulate the - resulting stochastic chemical kinetics with delays models. -- [BondGraphs.jl](https://github.com/jedforrest/BondGraphs.jl) a package for - constructing and analyzing bond graphs models, which can take Catalyst models as input. -- [PEtab.jl](https://github.com/sebapersson/PEtab.jl) a package that implements the PEtab format for fitting reaction network ODEs to data. Input can be provided either as SBML files or as Catalyst `ReactionSystem`s. - - -## Installation -Catalyst can be installed through the Julia package manager: - -```julia -using Pkg -Pkg.add("Catalyst") -``` - -To solve Catalyst models and visualize solutions, it is also recommended to -install DifferentialEquations.jl and Plots.jl -```julia -Pkg.add("DifferentialEquations") -Pkg.add("Plots") -``` - -## Illustrative Example -Here is a simple example of generating, visualizing and solving an SIR ODE -model. We first define the SIR reaction model using Catalyst -```@example ind1 -using Catalyst -rn = @reaction_network begin - α, S + I --> 2I - β, I --> R -end -``` -Assuming [Graphviz](https://graphviz.org/) and is installed and *command line -accessible*, the network can be visualized using the [`Graph`](@ref) command -```julia -Graph(rn) -``` -which in Jupyter notebooks will give the figure - -![SIR Network Graph](assets/SIR_rn.svg) - -To generate and solve a mass action ODE version of the model we use -```@example ind1 -using DifferentialEquations -p = [:α => .1/1000, :β => .01] -tspan = (0.0,250.0) -u0 = [:S => 999.0, :I => 1.0, :R => 0.0] -op = ODEProblem(rn, u0, tspan, p) -sol = solve(op, Tsit5()) # use Tsit5 ODE solver -``` -which we can plot as -```@example ind1 -using Plots -plot(sol, lw=2) -``` - -## Getting Help -Catalyst developers are active on the [Julia -Discourse](https://discourse.julialang.org/), and the [Julia -Slack's](https://julialang.slack.com) \#sciml-bridged and \#sciml-sysbio channels. -For bugs or feature requests [open an -issue](https://github.com/SciML/Catalyst.jl/issues). - -## [Supporting and Citing Catalyst.jl](@id catalyst_citation) -The software in this ecosystem was developed as part of academic research. If you would like to help support it, -please star the repository as such metrics may help us secure funding in the future. If you use Catalyst as part -of your research, teaching, or other activities, we would be grateful if you could cite our work: -``` -@article{CatalystPLOSCompBio2023, - doi = {10.1371/journal.pcbi.1011530}, - author = {Loman, Torkel E. AND Ma, Yingbo AND Ilin, Vasily AND Gowda, Shashi AND Korsbo, Niklas AND Yewale, Nikhil AND Rackauckas, Chris AND Isaacson, Samuel A.}, - journal = {PLOS Computational Biology}, - publisher = {Public Library of Science}, - title = {Catalyst: Fast and flexible modeling of reaction networks}, - year = {2023}, - month = {10}, - volume = {19}, - url = {https://doi.org/10.1371/journal.pcbi.1011530}, - pages = {1-19}, - number = {10}, -} -``` - -## Reproducibility -```@raw html -
The documentation of this SciML package was built using these direct dependencies, -``` -```@example -using Pkg # hide -Pkg.status() # hide -``` -```@raw html -
-``` -```@raw html -
and using this machine and Julia version. -``` -```@example -using InteractiveUtils # hide -versioninfo() # hide -``` -```@raw html -
-``` \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000000..7ba02ee07f --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,237 @@ +# [Catalyst.jl for Reaction Network Modeling](@id doc_index) + +Catalyst.jl is a symbolic modeling package for analysis and high-performance +simulation of chemical reaction networks. Catalyst defines symbolic +[`ReactionSystem`](@ref)s, which can be created programmatically or easily +specified using Catalyst's domain-specific language (DSL). Leveraging +[ModelingToolkit.jl](https://github.com/SciML/ModelingToolkit.jl) and +[Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl), Catalyst enables +large-scale simulations through auto-vectorization and parallelism. Symbolic +`ReactionSystem`s can be used to generate ModelingToolkit-based models, allowing +the easy simulation and parameter estimation of mass action ODE models, Chemical +Langevin SDE models, stochastic chemical kinetics jump process models, and more. +Generated models can be used with solvers throughout the broader Julia and +[SciML](https://sciml.ai) ecosystems, including higher-level SciML packages (e.g. +for sensitivity analysis, parameter estimation, machine learning applications, +etc). + +## [Features](@id doc_index_features) + +#### [Features of Catalyst](@id doc_index_features_catalyst) +- [The Catalyst DSL](@ref dsl_description) provides a simple and readable format for manually specifying reaction network models using chemical reaction notation. +- Catalyst `ReactionSystem`s provides a symbolic representation of reaction networks, built on [ModelingToolkit.jl](https://docs.sciml.ai/ModelingToolkit/stable/) and [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/). +- The [Catalyst.jl API](@ref api) provides functionality for building networks programmatically and for composing multiple networks together. +- Leveraging ModelingToolkit, generated models can be converted to symbolic reaction rate equation ODE models, symbolic Chemical Langevin Equation models, and symbolic stochastic chemical kinetics (jump process) models. These can be simulated using any [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) [ODE/SDE/jump solver](@ref simulation_intro), and can be used within `EnsembleProblem`s for carrying out [parallelized parameter sweeps and statistical sampling](@ref ensemble_simulations). Plot recipes are available for [visualization of all solutions](@ref simulation_plotting). +- Non-integer (e.g. `Float64`) stoichiometric coefficients [are supported](@ref dsl_description_stoichiometries_decimal) for generating ODE models, and symbolic expressions for stoichiometric coefficients [are supported](@ref parametric_stoichiometry) for all system types. +- A [network analysis suite](@ref network_analysis) permits the computation of linkage classes, deficiencies, reversibility, and other network properties. +- [Conservation laws can be detected and utilized](@ref network_analysis_deficiency) to reduce system sizes, and to generate non-singular Jacobians (e.g. during conversion to ODEs, SDEs, and steady state equations). +- Catalyst reaction network models can be [coupled with differential and algebraic equations](@ref constraint_equations_coupling_constraints) (which are then incorporated during conversion to ODEs, SDEs, and steady state equations). +- Models can be [coupled with events](@ref constraint_equations_events) that affect the system and its state during simulations. +- By leveraging ModelingToolkit, users have a variety of options for generating optimized system representations to use in solvers. These include construction of [dense or sparse Jacobians](@ref ode_simulation_performance_sparse_jacobian), [multithreading or parallelization of generated derivative functions](@ref ode_simulation_performance_parallelisation), [automatic classification of reactions into optimized jump types for Gillespie type simulations](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#jump_types), [automatic construction of dependency graphs for jump systems](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#Jump-Aggregators-Requiring-Dependency-Graphs), and more. +- [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl) symbolic expressions and Julia `Expr`s can be obtained for all rate laws and functions determining the deterministic and stochastic terms within resulting ODE, SDE, or jump models. +- [Steady states](@ref homotopy_continuation) (and their [stabilities](@ref steady_state_stability)) can be computed for model ODE representations. + +#### [Features of Catalyst composing with other packages](@id doc_index_features_composed) +- [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) Can be used to numerically solver generated reaction rate equation ODE models. +- [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) can be used to numerically solve generated Chemical Langevin Equation SDE models. +- [JumpProcesses.jl](https://github.com/SciML/JumpProcesses.jl) can be used to numerically sample generated Stochastic Chemical Kinetics Jump Process models. +- Support for [parallelization of all simulations](@ref ode_simulation_performance_parallelisation), including parallelization of [ODE simulations on GPUs](@ref ode_simulation_performance_parallelisation_GPU) using [DiffEqGPU.jl](https://github.com/SciML/DiffEqGPU.jl). +- [Latexify](https://korsbo.github.io/Latexify.jl/stable/) can be used to [generate LaTeX expressions](@ref visualisation_latex) corresponding to generated mathematical models or the underlying set of reactions. +- [Graphviz](https://graphviz.org/) can be used to generate and [visualize reaction network graphs](@ref visualisation_graphs) (reusing the Graphviz interface created in [Catlab.jl](https://algebraicjulia.github.io/Catlab.jl/stable/)). +- Model steady states can be [computed through homotopy continuation](@ref homotopy_continuation) using [HomotopyContinuation.jl](https://github.com/JuliaHomotopyContinuation/HomotopyContinuation.jl) (which can find *all* steady states of systems with multiple ones), by [forward ODE simulations](@ref steady_state_solving_simulation) using [SteadyStateDiffEq.jl)](https://github.com/SciML/SteadyStateDiffEq.jl), or by [numerically solving steady-state nonlinear equations](@ref steady_state_solving_nonlinear) using [NonlinearSolve.jl](https://github.com/SciML/NonlinearSolve.jl). +- [BifurcationKit.jl](https://github.com/bifurcationkit/BifurcationKit.jl) can be used to [compute bifurcation diagrams](@ref bifurcation_diagrams) of model steady states (including finding periodic orbits). +- [DynamicalSystems.jl](https://github.com/JuliaDynamics/DynamicalSystems.jl) can be used to compute model [basins of attraction](@ref dynamical_systems_basins_of_attraction), [Lyapunov spectrums](@ref dynamical_systems_lyapunov_exponents), and other dynamical system properties. +- [StructuralIdentifiability.jl](https://github.com/SciML/StructuralIdentifiability.jl) can be used to [perform structural identifiability analysis](@ref structural_identifiability). +- [Optimization.jl](https://github.com/SciML/Optimization.jl), [DiffEqParamEstim.jl](https://github.com/SciML/DiffEqParamEstim.jl), and [PEtab.jl](https://github.com/sebapersson/PEtab.jl) can all be used to [fit model parameters to data](https://sebapersson.github.io/PEtab.jl/stable/Define_in_julia/). +- [GlobalSensitivity.jl](https://github.com/SciML/GlobalSensitivity.jl) can be used to perform [global sensitivity analysis](@ref global_sensitivity_analysis) of model behaviors. +- [SciMLSensitivity.jl](https://github.com/SciML/SciMLSensitivity.jl) can be used to compute local sensitivities of functions containing forward model simulations. + +#### [Features of packages built upon Catalyst](@id doc_index_features_other_packages) +- Catalyst [`ReactionSystem`](@ref)s can be [imported from SBML files](@ref model_file_import_export_sbml) via [SBMLImporter.jl](https://github.com/SciML/SBMLImporter.jl) and [SBMLToolkit.jl](https://github.com/SciML/SBMLToolkit.jl), and [from BioNetGen .net files](@ref model_file_import_export_sbml_rni_net) and various stoichiometric matrix network representations using [ReactionNetworkImporters.jl](https://github.com/SciML/ReactionNetworkImporters.jl). +- [MomentClosure.jl](https://github.com/augustinas1/MomentClosure.jl) allows generation of symbolic ModelingToolkit `ODESystem`s that represent moment closure approximations to moments of the Chemical Master Equation, from reaction networks defined in Catalyst. +- [FiniteStateProjection.jl](https://github.com/kaandocal/FiniteStateProjection.jl) allows the construction and numerical solution of Chemical Master Equation models from reaction networks defined in Catalyst. +- [DelaySSAToolkit.jl](https://github.com/palmtree2013/DelaySSAToolkit.jl) can augment Catalyst reaction network models with delays, and can simulate the resulting stochastic chemical kinetics with delays models. +- [BondGraphs.jl](https://github.com/jedforrest/BondGraphs.jl), a package for constructing and analyzing bond graphs models, which can take Catalyst models as input. + +## [How to read this documentation](@id doc_index_documentation) +The Catalyst documentation is separated into sections describing Catalyst's various features. Where appropriate, some sections will also give advice on best practices for various modeling workflows, and provide links with further reading. Each section also contains a set of relevant example workflows. Finally, the [API](@ref api) section contains a list of all functions exported by Catalyst (as well as descriptions of them and their inputs and outputs). + +New users are recommended to start with either the [Introduction to Catalyst and Julia for New Julia users](@ref catalyst_for_new_julia_users) or [Introduction to Catalyst](@ref introduction_to_catalyst) sections (depending on whether they are familiar with Julia programming or not). This should be enough to carry out many basic Catalyst workflows. + +This documentation contains code which is dynamically run whenever it is built. If you copy the code and run it in your Julia environment it should work. The exact Julia environment that is used in this documentation can be found [here](@ref doc_index_reproducibility). + +For most code blocks in this documentation, the output of the last line of code is printed at the of the block, e.g. +```@example home_display +1 + 2 +``` +and +```@example home_display +using Catalyst # hide +@reaction_network begin + (p,d), 0 <--> X +end +``` +However, in some situations (e.g. when output is extensive, or irrelevant to what is currently being described) we have disabled this, e.g. like here: +```@example home_display +1 + 2 +nothing # hide +``` +and here: +```@example home_display +@reaction_network begin + (p,d), 0 <--> X +end +nothing # hide +``` + +## [Installation](@id doc_index_installation) +Catalyst is an officially registered Julia package, which can be installed through the Julia package manager: +```julia +using Pkg +Pkg.add("Catalyst") +``` + +Many Catalyst features require the installation of additional packages. E.g. for ODE-solving and simulation plotting +```julia +Pkg.add("OrdinaryDiffEq") +Pkg.add("Plots") +``` +is also needed. + +A more thorough guide for setting up Catalyst and installing Julia packages can be found [here](@ref catalyst_for_new_julia_users_packages). + +## [Illustrative example](@id doc_index_example) + +#### [Deterministic ODE simulation of Michaelis-Menten enzyme kinetics](@id doc_index_example_ode) +Here we show a simple example where a model is created using the Catalyst DSL, and then simulated as +an ordinary differential equation. + +```@example home_simple_example +# Fetch required packages. +using Catalyst, OrdinaryDiffEq, Plots + +# Create model. +model = @reaction_network begin + kB, S + E --> SE + kD, SE --> S + E + kP, SE --> P + E +end + +# Create an ODE that can be simulated. +u0 = [:S => 50.0, :E => 10.0, :SE => 0.0, :P => 0.0] +tspan = (0., 200.) +ps = [:kB => 0.01, :kD => 0.1, :kP => 0.1] +ode = ODEProblem(model, u0, tspan, ps) + +# Simulate ODE and plot results. +sol = solve(ode) +plot(sol; lw = 5) +``` + +#### [Stochastic jump simulations](@id doc_index_example_jump) +The same model can be used as input to other types of simulations. E.g. here we instead generate and simulate a stochastic chemical kinetics jump process model. +```@example home_simple_example +# Create and simulate a jump process (here using Gillespie's direct algorithm). +# The initial conditions are now integers as we track exact populations for each species. +using JumpProcesses +u0_integers = [:S => 50, :E => 10, :SE => 0, :P => 0] +dprob = DiscreteProblem(model, u0_integers, tspan, ps) +jprob = JumpProblem(model, dprob, Direct()) +jump_sol = solve(jprob, SSAStepper()) +jump_sol = solve(jprob, SSAStepper(); seed = 1234) # hide +plot(jump_sol; lw = 2) +``` + +## [More elaborate example](@id doc_index_elaborate_example) +In the above example, we used basic Catalyst workflows to simulate a simple +model. Here we instead show how various Catalyst features can compose to create +a much more advanced model. Our model describes how the volume of a cell ($V$) +is affected by a growth factor ($G$). The growth factor only promotes growth +while in its phosphorylated form ($G^P$). The phosphorylation of $G$ ($G \to G^P$) +is promoted by sunlight (modeled as the cyclic sinusoid $k_a (\sin(t) + 1)$), +which phosphorylates the growth factor (producing $G^P$). When the cell reaches a +critical volume ($V_m$) it undergoes cell division. First, we declare our model: +```@example home_elaborate_example +using Catalyst +cell_model = @reaction_network begin + @parameters Vₘ g + @equations begin + D(V) ~ g*Gᴾ + end + @continuous_events begin + [V ~ Vₘ] => [V ~ V/2] + end + kₚ*(sin(t)+1)/V, G --> Gᴾ + kᵢ/V, Gᴾ --> G +end +``` +We now study the system as a Chemical Langevin Dynamics SDE model, which can be generated as follows +```@example home_elaborate_example +u0 = [:V => 25.0, :G => 50.0, :Gᴾ => 0.0] +tspan = (0.0, 20.0) +ps = [:Vₘ => 50.0, :g => 0.3, :kₚ => 100.0, :kᵢ => 60.0] +sprob = SDEProblem(cell_model, u0, tspan, ps) +``` +This problem encodes the following stochastic differential equation model: +```math +\begin{align*} +dG(t) &= - \left( \frac{k_p(\sin(t)+1)}{V(t)} G(t) + \frac{k_i}{V(t)} G^P(t) \right) dt - \sqrt{\frac{k_p (\sin(t)+1)}{V(t)} G(t)} \, dW_1(t) + \sqrt{\frac{k_i}{V(t)} G^P(t)} \, dW_2(t) \\ +dG^P(t) &= \left( \frac{k_p(\sin(t)+1)}{V(t)} G(t) - \frac{k_i}{V(t)} G^P(t) \right) dt + \sqrt{\frac{k_p (\sin(t)+1)}{V(t)} G(t)} \, dW_1(t) - \sqrt{\frac{k_i}{V(t)} G^P(t)} \, dW_2(t) \\ +dV(t) &= \left(g \, G^P(t)\right) dt +\end{align*} +``` +where the $dW_1(t)$ and $dW_2(t)$ terms represent independent Brownian Motions, encoding the noise added by the Chemical Langevin Equation. Finally, we can simulate and plot the results. +```@example home_elaborate_example +using StochasticDiffEq, Plots +sol = solve(sprob, EM(); dt = 0.05) +sol = solve(sprob, EM(); dt = 0.05, seed = 1234) # hide +plot(sol; xguide = "Time (au)", lw = 2) +``` + +## [Getting Help](@id doc_index_help) +Catalyst developers are active on the [Julia Discourse](https://discourse.julialang.org/) and +the [Julia Slack](https://julialang.slack.com) channels \#sciml-bridged and \#sciml-sysbio. +For bugs or feature requests, [open an issue](https://github.com/SciML/Catalyst.jl/issues). + +## [Supporting and Citing Catalyst.jl](@id doc_index_citation) +The software in this ecosystem was developed as part of academic research. If you would like to help +support it, please star the repository as such metrics may help us secure funding in the future. If +you use Catalyst as part of your research, teaching, or other activities, we would be grateful if you +could cite our work: +``` +@article{CatalystPLOSCompBio2023, + doi = {10.1371/journal.pcbi.1011530}, + author = {Loman, Torkel E. AND Ma, Yingbo AND Ilin, Vasily AND Gowda, Shashi AND Korsbo, Niklas AND Yewale, Nikhil AND Rackauckas, Chris AND Isaacson, Samuel A.}, + journal = {PLOS Computational Biology}, + publisher = {Public Library of Science}, + title = {Catalyst: Fast and flexible modeling of reaction networks}, + year = {2023}, + month = {10}, + volume = {19}, + url = {https://doi.org/10.1371/journal.pcbi.1011530}, + pages = {1-19}, + number = {10}, +} +``` + +## [Reproducibility](@id doc_index_reproducibility) +```@raw html +
The documentation of this SciML package was built using these direct dependencies, +``` +```@example +using Pkg # hide +Pkg.status() # hide +``` +```@raw html +
+``` +```@raw html +
and using this machine and Julia version. +``` +```@example +using InteractiveUtils # hide +versioninfo() # hide +``` +```@raw html +
+``` \ No newline at end of file diff --git a/docs/src/introduction_to_catalyst/catalyst_for_new_julia_users.md b/docs/src/introduction_to_catalyst/catalyst_for_new_julia_users.md index 82c297b33d..4dc4c5d44b 100644 --- a/docs/src/introduction_to_catalyst/catalyst_for_new_julia_users.md +++ b/docs/src/introduction_to_catalyst/catalyst_for_new_julia_users.md @@ -1,5 +1,5 @@ # [Introduction to Catalyst and Julia for New Julia users](@id catalyst_for_new_julia_users) -The Catalyst tool for the modelling of chemical reaction networks is based in the Julia programming language. While experience in Julia programming is advantageous for using Catalyst, it is not necessary for accessing most of its basic features. This tutorial serves as an introduction to Catalyst for those unfamiliar with Julia, while also introducing some basic Julia concepts. Anyone who plans on using Catalyst extensively is recommended to familiarise oneself more thoroughly with the Julia programming language. A collection of resources for learning Julia can be found [here](https://julialang.org/learning/), and a full documentation is available [here](https://docs.julialang.org/en/v1/). A more practical (but also extensive) guide to Julia programming can be found [here](https://modernjuliaworkflows.github.io/writing/). +The Catalyst tool for the modelling of chemical reaction networks is based in the Julia programming language[^1][^2]. While experience in Julia programming is advantageous for using Catalyst, it is not necessary for accessing most of its basic features. This tutorial serves as an introduction to Catalyst for those unfamiliar with Julia, while also introducing some basic Julia concepts. Anyone who plans on using Catalyst extensively is recommended to familiarise oneself more thoroughly with the Julia programming language. A collection of resources for learning Julia can be found [here](https://julialang.org/learning/), and a full documentation is available [here](https://docs.julialang.org/en/v1/). A more practical (but also extensive) guide to Julia programming can be found [here](https://modernjuliaworkflows.github.io/writing/). Julia can be downloaded [here](https://julialang.org/downloads/). Generally, it is recommended to use the [*juliaup*](https://github.com/JuliaLang/juliaup) tool to install and update Julia. Furthermore, *Visual Studio Code* is a good IDE with [extensive Julia support](https://code.visualstudio.com/docs/languages/julia), and a good default choice. @@ -55,15 +55,15 @@ To import a Julia package into a session, you can use the `using PackageName` co using Pkg Pkg.add("Catalyst") ``` -Here, the Julia package manager package (`Pkg`) is by default installed on your computer when Julia is installed, and can be activated directly. Next, we also wish to install the `DifferentialEquations` and `Plots` packages (for numeric simulation of models, and plotting, respectively). +Here, the Julia package manager package (`Pkg`) is by default installed on your computer when Julia is installed, and can be activated directly. Next, we also wish to install the `OrdinaryDiffEq` and `Plots` packages (for numeric simulation of models, and plotting, respectively). ```julia -Pkg.add("DifferentialEquations") +Pkg.add("OrdinaryDiffEq") Pkg.add("Plots") ``` Once a package has been installed through the `Pkg.add` command, this command does not have to be repeated if we restart our Julia session. We can now import all three packages into our current session with: ```@example ex2 using Catalyst -using DifferentialEquations +using OrdinaryDiffEq using Plots ``` Here, if we restart Julia, these `using` commands *must be rerun*. @@ -77,11 +77,11 @@ Catalyst models are created through the `@reaction_network` *macro*. For more in The `@reaction_network` command is followed by the `begin` keyword, which is followed by one line for each *reaction* of the model. Each reaction consists of a *reaction rate*, followed by the reaction itself. The reaction contains a set of *substrates* and a set of *products* (what is consumed and produced by the reaction, respectively). These are separated by a `-->` arrow. Finally, the model ends with the `end` keyword. -Here, we create a simple *birth-death* model, where a single species ($X$) is created at rate $b$, and degraded at rate $d$. The model is stored in the variable `rn`. +Here, we create a simple [*birth-death* model](@ref basic_CRN_library_bd), where a single species ($X$) is created at rate $b$, and degraded at rate $d$. The model is stored in the variable `rn`. ```@example ex2 rn = @reaction_network begin - b, 0 --> X - d, X --> 0 + b, 0 --> X + d, X --> 0 end ``` For more information on how to use the Catalyst model creator (also known as *the Catalyst DSL*), please read [the corresponding documentation](https://docs.sciml.ai/Catalyst/stable/catalyst_functionality/dsl_description/). @@ -130,12 +130,16 @@ For more information about the numerical simulation package, please see the [Dif ## Additional modelling example To make this introduction more comprehensive, we here provide another example, using a more complicated model. Instead of simulating our model as concentrations evolve over time, we will now simulate the individual reaction events through the [Gillespie algorithm](https://en.wikipedia.org/wiki/Gillespie_algorithm) (a common approach for adding *noise* to models). -Remember (unless we have restarted Julia) we do not need to activate our packages (through the `using` command) again. +Remember (unless we have restarted Julia) we do not need to activate our packages (through the `using` command) again. However, we do need to install, and then import, the JumpProcesses package (just to perform Gillespie, and other jump, simulations) +```julia +Pkg.add("JumpProcesses") +using JumpProcesses +``` -This time, we will declare a so-called [SIR model for an infectious disease](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology#The_SIR_model). Note that even if this model does not describe a set of chemical reactions, it can be modelled using the same framework. The model consists of 3 species: -* $S$, the amount of *susceptible* individuals. -* $I$, the amount of *infected* individuals. -* $R$, the amount of *recovered* (or *removed*) individuals. +This time, we will declare a so-called [SIR model for an infectious disease](@ref basic_CRN_library_sir). Note that even if this model does not describe a set of chemical reactions, it can be modelled using the same framework. The model consists of 3 species: +* *S*, the amount of *susceptible* individuals. +* *I*, the amount of *infected* individuals. +* *R*, the amount of *recovered* (or *removed*) individuals. It also has 2 reaction events: * Infection, where a susceptible individual meets an infected individual and also becomes infected. @@ -148,8 +152,8 @@ Each reaction is also associated with a specific rate (corresponding to a parame We declare the model using the `@reaction_network` macro, and store it in the `sir_model` variable. ```@example ex2 sir_model = @reaction_network begin - b, S + I --> 2I - k, I --> R + b, S + I --> 2I + k, I --> R end ``` Note that the first reaction contains two different substrates (separated by a `+` sign). While there is only a single product (*I*), two copies of *I* are produced. The *2* in front of the product *I* denotes this. @@ -164,12 +168,14 @@ nothing # hide Previously we have bundled this information into an `ODEProblem` (denoting a deterministic *ordinary differential equation*). Now we wish to simulate our model as a jump process (where each reaction event corresponds to a single jump in the state of the system). We do this by first creating a `DiscreteProblem`, and then using this as an input to a `JumpProblem`. ```@example ex2 +using JumpProcesses # hide dprob = DiscreteProblem(sir_model, u0, tspan, params) jprob = JumpProblem(sir_model, dprob, Direct()) +nothing # hide ``` Again, the order in which the inputs are given to the `DiscreteProblem` and the `JumpProblem` is important. The last argument to the `JumpProblem` (`Direct()`) denotes which simulation method we wish to use. For now, we recommend that users simply use the `Direct()` option, and then consider alternative ones (see the [JumpProcesses.jl docs](https://docs.sciml.ai/JumpProcesses/stable/)) when they are more familiar with modelling in Catalyst and Julia. -Finally, we can simulate our model using the `solve` function, and plot the solution using the `plot` function. Here, the `solve` function also has a second argument (`SSAStepper()`). This is a time-stepping algorithm that calls the `Direct` solver to advance a simulation. Again, we recommend at this stage you simply use this option, and then explore exactly what this means at a later stage. +Finally, we can simulate our model using the `solve` function, and plot the solution using the `plot` function. For jump simulations, the `solve` function also requires a second argument (`SSAStepper()`). This is a time-stepping algorithm that calls the `Direct` solver to advance a simulation. Again, we recommend at this stage you simply use this option, and then explore exactly what this means at a later stage. ```@example ex2 sol = solve(jprob, SSAStepper()) sol = solve(jprob, SSAStepper(); seed=1234) # hide @@ -194,7 +200,7 @@ This will: 2. Switch your current Julia session to use the current folder's environment. !!! note - If you check any folder which has been designated as a Julia environment, it contains a Project.toml and a Manifest.toml file. These store all information regarding the corresponding environment. For non-advanced users, it is recommended to never touch these files directly (and instead do so using various functions from the Pkg package, the important ones which are described in the next two subsections). + If you check any folder which has been designated as a Julia environment, it contains a Project.toml and a Manifest.toml file. These store all information regarding the corresponding environment. For non-advanced users, it is recommended to never touch these files directly (and instead do so using various functions from the Pkg package, the important ones which are described in the next two subsections). ### [Installing and importing packages in Julia](@id catalyst_for_new_julia_users_packages_installing) Package installation and import have been described [previously](@ref catalyst_for_new_julia_users_packages_intro). However, for the sake of this extended tutorial, let us repeat the description by demonstrating how to install the [Latexify.jl](https://github.com/korsbo/Latexify.jl) package (which enables e.g. displaying Catalyst models in Latex format). First, we import the Julia Package manager ([Pkg](https://github.com/JuliaLang/Pkg.jl)) (which is required to install Julia packages): @@ -231,7 +237,7 @@ So, why is this required, and why cannot we simply import any package installed The reason why all this is important is that it is *highly recommended* to, for each project, define a separate environment. To these, only add the required packages. General-purpose environments with a large number of packages often, in the long term, produce package incompatibility issues. While these might not prevent you from installing all desired package, they often mean that you are unable to use the latest version of some packages. !!! note - A not-infrequent cause for reported errors with Catalyst (typically the inability to replicate code in tutorials) is package incompatibilities in large environments preventing the latest version of Catalyst from being installed. Hence, whenever an issue is encountered, it is useful to run `Pkg.status()` to check whenever the latest version of Catalyst is being used. + A not-infrequent cause for reported errors with Catalyst (typically the inability to replicate code in tutorials) is package incompatibilities in large environments preventing the latest version of Catalyst from being installed. Hence, whenever an issue is encountered, it is useful to run `Pkg.status()` to check whenever the latest version of Catalyst is being used. Some additional useful Pkg commands are: - `Pk.rm("PackageName")` removes a package from the current environment. @@ -239,7 +245,7 @@ Some additional useful Pkg commands are: - `Pkg.update()`: updates all packages. !!! note - A useful feature of Julia's environment system is that enables the exact definition of what packages and versions were used to execute a script. This supports e.g. reproducibility in academic research. Here, by providing the corresponding Project.toml and Manifest.toml files, you can enable someone to reproduce the exact program used to perform some set of analyses. + A useful feature of Julia's environment system is that enables the exact definition of what packages and versions were used to execute a script. This supports e.g. reproducibility in academic research. Here, by providing the corresponding Project.toml and Manifest.toml files, you can enable someone to reproduce the exact program used to perform some set of analyses. --- @@ -248,5 +254,5 @@ If you are a new Julia user who has used this tutorial, and there was something --- ## References -[^1]: [Jeff Bezanson, Alan Edelman, Stefan Karpinski, Viral B. Shah, *Julia: A Fresh Approach to Numerical Computing*, SIAM Review (2017).](https://epubs.siam.org/doi/abs/10.1137/141000671) -[^2]: [Torkel E. Loman, Yingbo Ma, Vasily Ilin, Shashi Gowda, Niklas Korsbo, Nikhil Yewale, Chris Rackauckas, Samuel A. Isaacson, *Catalyst: Fast and flexible modeling of reaction networks*, PLOS Computational Biology (2023).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530) \ No newline at end of file +[^1]: [Torkel E. Loman, Yingbo Ma, Vasily Ilin, Shashi Gowda, Niklas Korsbo, Nikhil Yewale, Chris Rackauckas, Samuel A. Isaacson, *Catalyst: Fast and flexible modeling of reaction networks*, PLOS Computational Biology (2023).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530) +[^2]: [Jeff Bezanson, Alan Edelman, Stefan Karpinski, Viral B. Shah, *Julia: A Fresh Approach to Numerical Computing*, SIAM Review (2017).](https://epubs.siam.org/doi/abs/10.1137/141000671) \ No newline at end of file diff --git a/docs/src/introduction_to_catalyst/introduction_to_catalyst.md b/docs/src/introduction_to_catalyst/introduction_to_catalyst.md index c0a36a5754..d46d5a9397 100644 --- a/docs/src/introduction_to_catalyst/introduction_to_catalyst.md +++ b/docs/src/introduction_to_catalyst/introduction_to_catalyst.md @@ -1,29 +1,42 @@ # [Introduction to Catalyst](@id introduction_to_catalyst) In this tutorial we provide an introduction to using Catalyst to specify chemical reaction networks, and then to solve ODE, jump, and SDE models -generated from them. At the end we show what mathematical rate laws and +generated from them [1]. At the end we show what mathematical rate laws and transition rate functions (i.e. intensities or propensities) are generated by Catalyst for ODE, SDE and jump process models. -Let's start by using the Catalyst [`@reaction_network`](@ref) macro -to specify a simple chemical reaction network: the well-known repressilator. - -We first import the basic packages we'll need: +We begin by installing Catalyst and any needed packages into a new environment. +This step can be skipped if you have already installed them in your current, +active environment: +```julia +using Pkg + +# name of the environment +Pkg.activate("catalyst_introduction") + +# packages we will use in this tutorial +Pkg.add("Catalyst") +Pkg.add("OrdinaryDiffEq") +Pkg.add("Plots") +Pkg.add("Latexify") +Pkg.add("JumpProcesses") +Pkg.add("StochasticDiffEq") +``` +We next load the basic packages we'll need for our first example: ```@example tut1 -# If not already installed, first hit "]" within a Julia REPL. Then type: -# add Catalyst DifferentialEquations Plots Latexify - -using Catalyst, DifferentialEquations, Plots, Latexify +using Catalyst, OrdinaryDiffEq, Plots, Latexify ``` -We now construct the reaction network. The basic types of arrows and predefined -rate laws one can use are discussed in detail within the tutorial, [The Reaction -DSL](@ref dsl_description). Here, we use a mix of first order, zero order, and repressive Hill -function rate laws. Note, $\varnothing$ corresponds to the empty state, and is -used for zeroth order production and first order degradation reactions: +Let's start by using the Catalyst [`@reaction_network`](@ref) macro to specify a +simple chemical reaction network: the well-known repressilator. We first construct +the reaction network. The basic types of arrows and predefined rate laws one can +use are discussed in detail within the tutorial, [The Reaction DSL](@ref +dsl_description). Here, we use a mix of first order, zero order, and repressive +Hill function rate laws. Note, $\varnothing$ corresponds to the empty state, and +is used for zeroth order production and first order degradation reactions: ```@example tut1 -repressilator = @reaction_network Repressilator begin +rn = @reaction_network Repressilator begin hillr(P₃,α,K,n), ∅ --> m₁ hillr(P₁,α,K,n), ∅ --> m₂ hillr(P₂,α,K,n), ∅ --> m₃ @@ -37,37 +50,37 @@ repressilator = @reaction_network Repressilator begin μ, P₂ --> ∅ μ, P₃ --> ∅ end -show(stdout, MIME"text/plain"(), repressilator) # hide +show(stdout, MIME"text/plain"(), rn) # hide ``` showing that we've created a new network model named `Repressilator` with the listed chemical species and unknowns. [`@reaction_network`](@ref) returns a -[`ReactionSystem`](@ref), which we saved in the `repressilator` variable. It can +[`ReactionSystem`](@ref), which we saved in the `rn` variable. It can be converted to a variety of other mathematical models represented as `ModelingToolkit.AbstractSystem`s, or analyzed in various ways using the -[Catalyst.jl API](@ref). For example, to see the chemical species, parameters, +[Catalyst.jl API](@ref api). For example, to see the chemical species, parameters, and reactions we can use ```@example tut1 -species(repressilator) +species(rn) ``` ```@example tut1 -parameters(repressilator) +parameters(rn) ``` and ```@example tut1 -reactions(repressilator) +reactions(rn) ``` We can also use Latexify to see the corresponding reactions in Latex, which shows what the `hillr` terms mathematically correspond to ```julia -latexify(repressilator) +latexify(rn) ``` ```@example tut1 -repressilator #hide +rn #hide ``` Assuming [Graphviz](https://graphviz.org/) is installed and command line accessible, within a Jupyter notebook we can also graph the reaction network by ```julia -g = Graph(repressilator) +g = Graph(rn) ``` giving @@ -96,8 +109,8 @@ Let's now use our `ReactionSystem` to generate and solve a corresponding mass action ODE model. We first convert the system to a `ModelingToolkit.ODESystem` by ```@example tut1 -repressilator = complete(repressilator) -odesys = convert(ODESystem, repressilator) +rn = complete(rn) +odesys = convert(ODESystem, rn) ``` (Here Latexify is used automatically to display `odesys` in Latex within Markdown documents or notebook environments like Pluto.jl.) @@ -117,12 +130,10 @@ nothing # hide Alternatively, we can use ModelingToolkit-based symbolic species variables to specify these mappings like ```@example tut1 -t = default_t() -@parameters α K n δ γ β μ -@species m₁(t) m₂(t) m₃(t) P₁(t) P₂(t) P₃(t) -psymmap = (α => .5, K => 40, n => 2, δ => log(2)/120, - γ => 5e-3, β => 20*log(2)/120, μ => log(2)/60) -u₀symmap = [m₁ => 0., m₂ => 0., m₃ => 0., P₁ => 20., P₂ => 0., P₃ => 0.] +psymmap = (rn.α => .5, rn.K => 40, rn.n => 2, rn.δ => log(2)/120, + rn.γ => 5e-3, rn.β => 20*log(2)/120, rn.μ => log(2)/60) +u₀symmap = [rn.m₁ => 0., rn.m₂ => 0., rn.m₃ => 0., rn.P₁ => 20., + rn.P₂ => 0., rn.P₃ => 0.] nothing # hide ``` Knowing these mappings we can set up the `ODEProblem` we want to solve: @@ -132,11 +143,11 @@ Knowing these mappings we can set up the `ODEProblem` we want to solve: tspan = (0., 10000.) # create the ODEProblem we want to solve -oprob = ODEProblem(repressilator, u₀map, tspan, pmap) +oprob = ODEProblem(rn, u₀map, tspan, pmap) nothing # hide ``` -By passing `repressilator` directly to the `ODEProblem`, Catalyst has to -(internally) call `convert(ODESystem, repressilator)` again to generate the +By passing `rn` directly to the `ODEProblem`, Catalyst has to +(internally) call `convert(ODESystem, rn)` again to generate the symbolic ODEs. We could instead pass `odesys` directly like ```@example tut1 odesys = complete(odesys) @@ -149,27 +160,26 @@ underlying problem. !!! note When passing `odesys` to `ODEProblem` we needed to use the symbolic variable-based parameter mappings, `u₀symmap` and `psymmap`, while when - directly passing `repressilator` we could use either those or the + directly passing `rn` we could use either those or the `Symbol`-based mappings, `u₀map` and `pmap`. `Symbol`-based mappings can - always be converted to `symbolic` mappings using [`symmap_to_varmap`](@ref), - see the [Basic Syntax](@ref basic_examples) section for more details. + always be converted to `symbolic` mappings using [`symmap_to_varmap`](@ref). !!! note - Above we have used `repressilator = complete(repressilator)` and `odesys = complete(odesys)` to mark these systems as *complete*, indicating to Catalyst and ModelingToolkit that these models are finalized. This must be done before any system is given as input to a `convert` call or some problem type. `ReactionSystem` models created through the @reaction_network` DSL (which is introduced elsewhere, and primarily used throughout these documentation) are always marked as complete when generated. Hence `complete` does not need to be called on them. Symbolically generated `ReactionSystem`s, `ReactionSystem`s generated via the `@network_component` macro, and any ModelingToolkit system generated by `convert` always needs to be manually marked as `complete` as we do for `odesys` above. An expanded description on *completeness* can be found [here](@ref completeness_note). + Above we have used `rn = complete(rn)` and `odesys = complete(odesys)` to mark these systems as *complete*, indicating to Catalyst and ModelingToolkit that these models are finalized. This must be done before any system is given as input to a `convert` call or some problem type. `ReactionSystem` models created through the `@reaction_network` DSL (which is introduced elsewhere, and primarily used throughout these documentation) are always marked as complete when generated. Hence `complete` does not need to be called on them. Symbolically generated `ReactionSystem`s, `ReactionSystem`s generated via the `@network_component` macro, and any ModelingToolkit system generated by `convert` always needs to be manually marked as `complete` as we do for `odesys` above. An expanded description on *completeness* can be found [here](@ref completeness_note). At this point we are all set to solve the ODEs. We can now use any ODE solver from within the -[DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/) +[OrdinaryDiffEq.jl](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/) package. We'll use the recommended default explicit solver, `Tsit5()`, and then plot the solutions: ```@example tut1 -sol = solve(oprob, Tsit5(), saveat=10.) +sol = solve(oprob, Tsit5(), saveat=10.0) plot(sol) ``` We see the well-known oscillatory behavior of the repressilator! For more on the -choices of ODE solvers, see the [DifferentialEquations.jl +choices of ODE solvers, see the [OrdinaryDiffEq.jl documentation](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/). --- @@ -182,18 +192,22 @@ Gillespie's `Direct` method, and then solve it to generate one realization of the jump process: ```@example tut1 +# imports the JumpProcesses packages +using JumpProcesses + # redefine the initial condition to be integer valued u₀map = [:m₁ => 0, :m₂ => 0, :m₃ => 0, :P₁ => 20, :P₂ => 0, :P₃ => 0] # next we create a discrete problem to encode that our species are integer-valued: -dprob = DiscreteProblem(repressilator, u₀map, tspan, pmap) +dprob = DiscreteProblem(rn, u₀map, tspan, pmap) # now, we create a JumpProblem, and specify Gillespie's Direct Method as the solver: -jprob = JumpProblem(repressilator, dprob, Direct(), save_positions=(false,false)) +jprob = JumpProblem(rn, dprob, Direct()) # now, let's solve and plot the jump process: -sol = solve(jprob, SSAStepper(), saveat=10.) +sol = solve(jprob, SSAStepper()) plot(sol) +plot(sol, density = 10000, fmt = :png) # hide ``` We see that oscillations remain, but become much noisier. Note, in constructing @@ -237,6 +251,9 @@ model by creating an `SDEProblem` and solving it similarly to what we did for OD above: ```@example tut1 +# imports the StochasticDiffEq package for SDE simulations +using StochasticDiffEq + # SDEProblem for CLE sprob = SDEProblem(bdp, u₀, tspan, p) @@ -285,7 +302,7 @@ For details on what information can be specified via the DSL see the [The Reaction DSL](@ref dsl_description) tutorial. --- -## Reaction rate laws used in simulations +## [Reaction rate laws used in simulations](@id introduction_to_catalyst_ratelaws) In generating mathematical models from a [`ReactionSystem`](@ref), reaction rates are treated as *microscopic* rates. That is, for a general mass action reaction of the form $n_1 S_1 + n_2 S_2 + \dots n_M S_M \to \dots$ with @@ -355,4 +372,4 @@ and the ODE model --- ## References -[^1]: [Torkel E. Loman, Yingbo Ma, Vasily Ilin, Shashi Gowda, Niklas Korsbo, Nikhil Yewale, Chris Rackauckas, Samuel A. Isaacson, *Catalyst: Fast and flexible modeling of reaction networks*, PLOS Computational Biology (2023).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530) \ No newline at end of file +1. [Torkel E. Loman, Yingbo Ma, Vasily Ilin, Shashi Gowda, Niklas Korsbo, Nikhil Yewale, Chris Rackauckas, Samuel A. Isaacson, *Catalyst: Fast and flexible modeling of reaction networks*, PLOS Computational Biology (2023).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011530) \ No newline at end of file diff --git a/docs/src/inverse_problems/behaviour_optimisation.md b/docs/src/inverse_problems/behaviour_optimisation.md index 41c1e9eecf..2d4c0cca4e 100644 --- a/docs/src/inverse_problems/behaviour_optimisation.md +++ b/docs/src/inverse_problems/behaviour_optimisation.md @@ -1,14 +1,14 @@ # [Optimization for non-data fitting purposes](@id behaviour_optimisation) -In previous tutorials we have described how to use [PEtab.jl](@ref petab_parameter_fitting) and [Optimization.jl](@ref optimization_parameter_fitting) for parameter fitting. This involves solving an optimisation problem (to find the parameter set yielding the best model-to-data fit). There are, however, other situations that require solving optimisation problems. Typically, these involve the creation of a custom cost function, which optimum can then be found using Optimization.jl. In this tutorial we will describe this process, demonstrating how parameter space can be searched to find values that achieve a desired system behaviour. A more throughout description on how to solve these problems is provided by [Optimization.jl's documentation](https://docs.sciml.ai/Optimization/stable/) and the literature[^1]. +In previous tutorials we have described how to use PEtab.jl and [Optimization.jl](@ref optimization_parameter_fitting) for parameter fitting. This involves solving an optimisation problem (to find the parameter set yielding the best model-to-data fit). There are, however, other situations that require solving optimisation problems. Typically, these involve the creation of a custom cost function, which optimum can then be found using Optimization.jl. In this tutorial we will describe this process, demonstrating how parameter space can be searched to find values that achieve a desired system behaviour. A more throughout description on how to solve these problems is provided by [Optimization.jl's documentation](https://docs.sciml.ai/Optimization/stable/) and the literature[^1]. -## Maximising the pulse amplitude of an incoherent feed forward loop. +## [Maximising the pulse amplitude of an incoherent feed forward loop](@id behaviour_optimisation_IFFL_example) Incoherent feedforward loops (network motifs where a single component both activates and deactivates a downstream component) are able to generate pulses in response to step inputs[^2]. In this tutorial we will consider such an incoherent feedforward loop, attempting to generate a system with as prominent a response pulse as possible. -Our model consists of 3 species: $X$ (the input node), $Y$ (an intermediary), and $Z$ (the output node). In it, $X$ activates the production of both $Y$ and $Z$, with $Y$ also deactivating $Z$. When $X$ is activated, there will be a brief time window where $Y$ is still inactive, and $Z$ is activated. However, as $Y$ becomes active, it will turn $Z$ off. This creates a pulse of $Z$ activity. To trigger the system, we create [an event](@ref ref), which increases the production rate of $X$ ($pX$) by a factor of $10$ at time $t = 10$. +Our model consists of 3 species: $X$ (the input node), $Y$ (an intermediary), and $Z$ (the output node). In it, $X$ activates the production of both $Y$ and $Z$, with $Y$ also deactivating $Z$. When $X$ is activated, there will be a brief time window where $Y$ is still inactive, and $Z$ is activated. However, as $Y$ becomes active, it will turn $Z$ off. This creates a pulse of $Z$ activity. To trigger the system, we create [an event](@ref constraint_equations_events), which increases the production rate of $X$ ($pX$) by a factor of $10$ at time $t = 10$. ```@example behaviour_optimization using Catalyst incoherent_feed_forward = @reaction_network begin - @discrete_event [10.0] ~ [p ~ 10*p] + @discrete_events [10.0] => [pX ~ 10*pX] pX, 0 --> X pY*X, 0 --> Y pZ*X/Y, 0 --> Z @@ -34,19 +34,20 @@ function pulse_amplitude(p, _) ps = Dict([:pX => p[1], :pY => p[2], :pZ => p[2]]) u0_new = [:X => ps[:pX], :Y => ps[:pX]*ps[:pY], :Z => ps[:pZ]/ps[:pY]^2] oprob_local = remake(oprob; u0= u0_new, p = ps) - sol = solve(oprob_local, verbose = false, maxiters = 10000) + sol = solve(oprob_local, Tsit5(); verbose = false, maxiters = 10000) SciMLBase.successful_retcode(sol) || return Inf return -(maximum(sol[:Z]) - sol[:Z][1]) end -nothing # here +nothing # hide ``` -This cost function takes two arguments (a parameter value `p`, and an additional one which we will ignore here but discuss later). It first calculates the new initial steady state concentration for the given parameter set. Next, it creates an updated `ODEProblem` using the steady state as initial conditions and the, to the cost function provided, input parameter set. While we could create a new `ODEProblem` within the cost function, cost functions are often called a large number of times during the optimisation process (making performance important). Here, using [`remake` on a previously created `ODEProblem`](@ref ref) is more performant than creating a new one. Just like [when using Optimization.jl to fit parameters to data](@ref optimization_parameter_fitting), we use the `verbose = false` option to prevent unnecessary simulation printouts, and a reduced `maxiters` value to reduce time spent simulating (for the model) unsuitable parameter sets. We also use `SciMLBase.successful_retcode(sol)` to check whether the simulation return code indicates a successful simulation (and if it did not, returns a large cost function value). Finally, Optimization.jl finds the function's *minimum value*, so to find the *maximum* relative pulse amplitude, we make our cost function return the negative pulse amplitude. +This cost function takes two arguments (a parameter value `p`, and an additional one which we will ignore here but discuss later). It first calculates the new initial steady state concentration for the given parameter set. Next, it creates an updated `ODEProblem` using the steady state as initial conditions and the, to the cost function provided, input parameter set. While we could create a new `ODEProblem` within the cost function, cost functions are often called a large number of times during the optimisation process (making performance important). Here, using [`remake` on a previously created `ODEProblem`](@ref simulation_structure_interfacing_problems_remake) is more performant than creating a new one. Just like [when using Optimization.jl to fit parameters to data](@ref optimization_parameter_fitting), we use the `verbose = false` option to prevent unnecessary simulation printouts, and a reduced `maxiters` value to reduce time spent simulating (for the model) unsuitable parameter sets. We also use `SciMLBase.successful_retcode(sol)` to check whether the simulation return code indicates a successful simulation (and if it did not, returns a large cost function value). Finally, Optimization.jl finds the function's *minimum value*, so to find the *maximum* relative pulse amplitude, we make our cost function return the negative pulse amplitude. Just like for [parameter fitting](@ref optimization_parameter_fitting), we create a `OptimizationProblem` using our cost function, and some initial guess of the parameter value. We also set upper and lower bounds for each parameter using the `lb` and `ub` optional arguments (in this case limiting each parameter's value to the interval $(0.1,10.0)$). ```@example behaviour_optimization using Optimization initial_guess = [1.0, 1.0, 1.0] opt_prob = OptimizationProblem(pulse_amplitude, initial_guess; lb = [1e-1, 1e-1, 1e-1], ub = [1e1, 1e1, 1e1]) +nothing # hide ``` !!! note As described in a [previous section on Optimization.jl](@ref optimization_parameter_fitting), `OptimizationProblem`s do not support setting parameter values using maps. We must instead set `initial_guess` values using a vector. Next, in the first line of our cost function, we reshape the parameter values to the common form used across Catalyst (e.g. `[:pX => p[1], :pY => p[2], :pZ => p[2]]`, however, here we use a dictionary to easier compute the steady state initial condition). We also note that the order used in this array corresponds to the order we give each parameter's bounds in `lb` and `ub`, and the order in which their values occur in the output solution. @@ -55,13 +56,14 @@ As [previously described](@ref optimization_parameter_fitting), Optimization.jl ```@example behaviour_optimization using OptimizationBBO opt_sol = solve(opt_prob, BBO_adaptive_de_rand_1_bin_radiuslimited()) +nothing # hide ``` Finally, we plot a simulation using the found parameter set (stored in `opt_sol.u`): ```@example behaviour_optimization ps_res = Dict([:pX => opt_sol.u[1], :pY => opt_sol.u[2], :pZ => opt_sol.u[2]]) u0_res = [:X => ps_res[:pX], :Y => ps_res[:pX]*ps_res[:pY], :Z => ps_res[:pZ]/ps_res[:pY]^2] oprob_res = remake(oprob; u0 = u0_res, p = ps_res) -sol_res = solve(oprob_res) +sol_res = solve(oprob_res, Tsit5()) plot(sol_res; idxs=:Z) ``` For this model, it turns out that $Z$'s maximum pulse amplitude is equal to twice its steady state concentration. Hence, the maximisation of its pulse amplitude is equivalent to maximising its steady state concentration. @@ -71,16 +73,17 @@ For this model, it turns out that $Z$'s maximum pulse amplitude is equal to twic There are several modifications to our problem where it would actually have parameters. E.g. our model might have had additional parameters (e.g. a degradation rate) which we would like to keep fixed throughout the optimisation process. If we then would like to run the optimisation process for several different values of these fixed parameters, we could have made them parameters to our `OptimizationProblem` (and their values provided as a third argument, after `initial_guess`). -## Utilising automatic differentiation +## [Utilising automatic differentiation](@id behaviour_optimisation_AD) Optimisation methods can be divided into differentiation-free and differentiation-based optimisation methods. E.g. consider finding the minimum of the function $f(x) = x^2$, given some initial guess of $x$. Here, we can simply compute the differential and descend along it until we find $x=0$ (admittedly, for this simple problem the minimum can be computed directly). This principle forms the basis of optimisation methods such as [gradient descent](https://en.wikipedia.org/wiki/Gradient_descent), which utilises information of a function's differential to minimise it. When attempting to find a global minimum, to avoid getting stuck in local minimums, these methods are often augmented by additional routines. While the differentiation of most algebraic functions is trivial, it turns out that even complicated functions (such as the one we used above) can be differentiated computationally through the use of [*automatic differentiation* (AD)](https://en.wikipedia.org/wiki/Automatic_differentiation). Through packages such as [ForwardDiff.jl](https://github.com/JuliaDiff/ForwardDiff.jl), [ReverseDiff.jl](https://github.com/JuliaDiff/ReverseDiff.jl), and [Zygote.jl](https://github.com/FluxML/Zygote.jl), Julia supports AD for most code. Specifically for code including simulation of differential equations, differentiation is supported by [SciMLSensitivity.jl](https://github.com/SciML/SciMLSensitivity.jl). Generally, AD can be used without specific knowledge from the user, however, it requires an additional step in the construction of our `OptimizationProblem`. Here, we create a [specialised `OptimizationFunction` from our cost function](https://docs.sciml.ai/Optimization/stable/API/optimization_function/#optfunction). To it, we will also provide our choice of AD method. There are [several alternatives](https://docs.sciml.ai/Optimization/stable/API/optimization_function/#Automatic-Differentiation-Construction-Choice-Recommendations), and in our case we will use `AutoForwardDiff()` (a good choice for small optimisation problems). We can then create a new `OptimizationProblem` using our updated cost function: ```@example behaviour_optimization opt_func = OptimizationFunction(pulse_amplitude, AutoForwardDiff()) opt_prob = OptimizationProblem(opt_func, initial_guess; lb = [1e-1, 1e-1, 1e-1], ub = [1e1, 1e1, 1e1]) +nothing # hide ``` Finally, we can find the optimum using some differentiation-based optimisation methods. Here we will use [Optim.jl](https://github.com/JuliaNLSolvers/Optim.jl)'s `BFGS` method: -```@example behaviour_optimization +```julia using OptimizationOptimJL opt_sol = solve(opt_prob, OptimizationOptimJL.BFGS()) ``` diff --git a/docs/src/inverse_problems/examples/ode_fitting_oscillation.md b/docs/src/inverse_problems/examples/ode_fitting_oscillation.md index 47f4623514..f5eaa4c65f 100644 --- a/docs/src/inverse_problems/examples/ode_fitting_oscillation.md +++ b/docs/src/inverse_problems/examples/ode_fitting_oscillation.md @@ -2,7 +2,7 @@ In this example we will use [Optimization.jl](https://github.com/SciML/Optimization.jl) to fit the parameters of an oscillatory system (the Brusselator) to data. Here, special consideration is taken to avoid reaching a local minimum. Instead of fitting the entire time series directly, we will start with fitting parameter values for the first period, and then use those as an initial guess for fitting the next (and then these to find the next one, and so on). Using this procedure is advantageous for oscillatory systems, and enables us to reach the global optimum. First, we fetch the required packages. -```@example pe1 +```@example pe_osc_example using Catalyst using OrdinaryDiffEq using Optimization @@ -11,7 +11,7 @@ using SciMLSensitivity # Required for `Optimization.AutoZygote()` automatic diff ``` Next, we declare our model, the Brusselator oscillator. -```@example pe1 +```@example pe_osc_example brusselator = @reaction_network begin A, ∅ --> X 1, 2X + Y --> 3X @@ -24,7 +24,7 @@ nothing # hide We simulate our model, and from the simulation generate sampled data points (to which we add noise). We will use this data to fit the parameters of our model. -```@example pe1 +```@example pe_osc_example u0 = [:X => 1.0, :Y => 1.0] tspan = (0.0, 30.0) @@ -37,7 +37,7 @@ nothing # hide ``` We can plot the real solution, as well as the noisy samples. -```@example pe1 +```@example pe_osc_example using Plots default(; lw = 3, framestyle = :box, size = (800, 400)) @@ -49,7 +49,7 @@ Next, we create a function to fit the parameters using the `ADAM` optimizer. For a given initial estimate of the parameter values, `pinit`, this function will fit parameter values, `p`, to our data samples. We use `tend` to indicate the time interval over which we fit the model. -```@example pe1 +```@example pe_osc_example function optimise_p(pinit, tend) function loss(p, _) newtimes = filter(<=(tend), sample_times) @@ -71,12 +71,12 @@ nothing # hide ``` Next, we will fit a parameter set to the data on the interval `(0, 10)`. -```@example pe1 +```@example pe_osc_example p_estimate = optimise_p([5.0, 5.0], 10.0) ``` We can compare this to the real solution, as well as the sample data -```@example pe1 +```@example pe_osc_example newprob = remake(prob; tspan = (0., 10.), p = p_estimate) sol_estimate = solve(newprob, Rosenbrock23()) plot(sol_real; color = [:blue :red], label = ["X real" "Y real"], linealpha = 0.2) @@ -88,7 +88,7 @@ plot!(sol_estimate; color = [:darkblue :darkred], linestyle = :dash, Next, we use this parameter estimate as the input to the next iteration of our fitting process, this time on the interval `(0, 20)`. -```@example pe1 +```@example pe_osc_example p_estimate = optimise_p(p_estimate, 20.) newprob = remake(prob; tspan = (0., 20.), p = p_estimate) sol_estimate = solve(newprob, Rosenbrock23()) @@ -101,20 +101,20 @@ plot!(sol_estimate; color = [:darkblue :darkred], linestyle = :dash, Finally, we use this estimate as the input to fit a parameter set on the full time interval of the sampled data. -```@example pe1 +```@example pe_osc_example p_estimate = optimise_p(p_estimate, 30.0) newprob = remake(prob; tspan = (0., 30.0), p = p_estimate) sol_estimate = solve(newprob, Rosenbrock23()) plot(sol_real; color = [:blue :red], label = ["X real" "Y real"], linealpha = 0.2) scatter!(sample_times, sample_vals'; color = [:blue :red], - label = ["Samples of X" "Samples of Y"], alpha = 0.4) + label = ["Samples of X" "Samples of Y"], alpha = 0.4) plot!(sol_estimate; color = [:darkblue :darkred], linestyle = :dash, label = ["X estimated" "Y estimated"], xlimit = tspan) ``` The final parameter estimate is then -```@example pe1 +```@example pe_osc_example p_estimate ``` which is close to the actual parameter set of `[1.0, 2.0]`. @@ -125,7 +125,7 @@ then extend the interval, is to avoid getting stuck in a local minimum. Here specifically, we chose our initial interval to be smaller than a full cycle of the oscillation. If we had chosen to fit a parameter set on the full interval immediately we would have obtained poor fit and an inaccurate estimate for the parameters. -```@example pe1 +```@example pe_osc_example p_estimate = optimise_p([5.0,5.0], 30.0) newprob = remake(prob; tspan = (0.0,30.0), p = p_estimate) diff --git a/docs/src/inverse_problems/global_sensitivity_analysis.md b/docs/src/inverse_problems/global_sensitivity_analysis.md index d611997a9f..e2a10759f9 100644 --- a/docs/src/inverse_problems/global_sensitivity_analysis.md +++ b/docs/src/inverse_problems/global_sensitivity_analysis.md @@ -1,6 +1,6 @@ # [Global Sensitivity Analysis](@id global_sensitivity_analysis) *Global sensitivity analysis* (GSA) is used to study the sensitivity of a function's outputs with respect to its input[^1]. Within the context of chemical reaction network modelling it is primarily used for two purposes: -- [When fitting a model's parameters to data](@ref petab_parameter_fitting), it can be applied to the cost function of the optimisation problem. Here, GSA helps determine which parameters do, and do not, affect the model's fit to the data. This can be used to identify parameters that are less relevant to the observed data. +- When fitting a model's parameters to data, it can be applied to the cost function of the optimisation problem. Here, GSA helps determine which parameters do, and do not, affect the model's fit to the data. This can be used to identify parameters that are less relevant to the observed data. - [When measuring some system behaviour or property](@ref behaviour_optimisation), it can help determine which parameters influence that property. E.g. for a model of a biofuel-producing circuit in a synthetic organism, GSA could determine which system parameters have the largest impact on the total rate of biofuel production. GSA can be carried out using the [GlobalSensitivity.jl](https://github.com/SciML/GlobalSensitivity.jl) package. This tutorial contains a brief introduction of how to use it for GSA on Catalyst models, with [GlobalSensitivity providing a more complete documentation](https://docs.sciml.ai/GlobalSensitivity/stable/). @@ -8,10 +8,10 @@ GSA can be carried out using the [GlobalSensitivity.jl](https://github.com/SciML ### [Global vs local sensitivity](@id global_sensitivity_analysis_global_vs_local_sensitivity) A related concept to global sensitivity is *local sensitivity*. This, rather than measuring a function's sensitivity (with regards to its inputs) across its entire (or large part of its) domain, measures it at a specific point. This is equivalent to computing the function's gradients at a specific point in phase space, which is an important routine for most gradient-based optimisation methods (typically carried out through [*automatic differentiation*](https://en.wikipedia.org/wiki/Automatic_differentiation)). For most Catalyst-related functionalities, local sensitivities are computed using the [SciMLSensitivity.jl](https://github.com/SciML/SciMLSensitivity.jl) package. While certain GSA methods can utilise local sensitivities, this is not necessarily the case. -While local sensitivities are primarily used as a subroutine of other methodologies (such as optimisation schemes), it also has direct uses. E.g., in the context of fitting parameters to data, local sensitivity analysis can be used to, at the parameter set of the optimal fit, [determine the cost function's sensitivity to the system parameters](@ref ref). +While local sensitivities are primarily used as a subroutine of other methodologies (such as optimisation schemes), it also has direct uses. E.g., in the context of fitting parameters to data, local sensitivity analysis can be used to, at the parameter set of the optimal fit, determine the cost function's sensitivity to the system parameters. ## [Basic example](@id global_sensitivity_analysis_basic_example) -We will consider a simple [SEIR model of an infectious disease](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology). This is an expansion of the classic [SIR model](@ref ref) with an additional *exposed* state, $E$, denoting individuals who are latently infected but currently unable to transmit their infection to others. +We will consider a simple [SEIR model of an infectious disease](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology). This is an expansion of the classic [SIR model](@ref basic_CRN_library_sir) with an additional *exposed* state, $E$, denoting individuals who are latently infected but currently unable to transmit their infection to others. ```@example gsa_1 using Catalyst seir_model = @reaction_network begin @@ -52,8 +52,8 @@ on the domain $10^β ∈ (-3.0,-1.0)$, $10^a ∈ (-2.0,0.0)$, $10^γ ∈ (-2.0,0 !!! note We should make a couple of notes about the example above: - - Here, we write our parameters on the forms $10^β$, $10^a$, and $10^γ$, which transforms them into log-space. As [previously described](@ref optimization_parameter_fitting_logarithmic_scale), this is advantageous in the context of inverse problems such as this one. - - For GSA, where a function is evaluated a large number of times, it is ideal to write it as performant as possible. Hence, we initially create a base `ODEProblem`, and then apply the [`remake`](@ref ref) function to it in each evaluation of `peak_cases` to generate a problem which is solved for that specific parameter set. + - Here, we write our parameters on the forms $10^β$, $10^a$, and $10^γ$, which transforms them into log-space. As [previously described](@ref optimization_parameter_fitting_log_scale), this is advantageous in the context of inverse problems such as this one. + - For GSA, where a function is evaluated a large number of times, it is ideal to write it as performant as possible. Hence, we initially create a base `ODEProblem`, and then apply the [`remake`](@ref simulation_structure_interfacing_problems_remake) function to it in each evaluation of `peak_cases` to generate a problem which is solved for that specific parameter set. - Again, as [previously described in other inverse problem tutorials](@ref optimization_parameter_fitting_basics), when exploring a function over large parameter spaces, we will likely simulate our model for unsuitable parameter sets. To reduce time spent on these, and to avoid excessive warning messages, we provide the `maxiters = 100000` and `verbose = false` arguments to `solve`. - As we have encountered in [a few other cases](@ref optimization_parameter_fitting_basics), the `gsa` function is not able to take parameter inputs of the map form usually used for Catalyst. Hence, as a first step in `peak_cases` we convert the parameter vector to this form. Next, we remember that the order of the parameters when we e.g. evaluate the GSA output, or set the parameter bounds, corresponds to the order used in `ps = [:β => p[1], :a => p[2], :γ => p[3]]`. @@ -142,7 +142,7 @@ Here, the function's sensitivity is evaluated with respect to each output indepe --- ## [Citations](@id global_sensitivity_analysis_citations) -If you use this functionality in your research, [in addition to Catalyst](@ref catalyst_citation), please cite the following paper to support the authors of the GlobalSensitivity.jl package: +If you use this functionality in your research, [in addition to Catalyst](@ref doc_index_citation), please cite the following paper to support the authors of the GlobalSensitivity.jl package: ``` @article{dixit2022globalsensitivity, title={GlobalSensitivity. jl: Performant and Parallel Global Sensitivity Analysis with Julia}, diff --git a/docs/src/inverse_problems/optimization_ode_param_fitting.md b/docs/src/inverse_problems/optimization_ode_param_fitting.md index c579a68aa7..5834b96823 100644 --- a/docs/src/inverse_problems/optimization_ode_param_fitting.md +++ b/docs/src/inverse_problems/optimization_ode_param_fitting.md @@ -1,11 +1,11 @@ -# [Parameter Fitting for ODEs using SciML/Optimization.jl and DiffEqParamEstim.jl](@id optimization_parameter_fitting) -Fitting parameters to data involves solving an optimisation problem (that is, finding the parameter set that optimally fits your model to your data, typically by minimising a cost function). The SciML ecosystem's primary package for solving optimisation problems is [Optimization.jl](https://github.com/SciML/Optimization.jl). It provides access to a variety of solvers via a single common interface by wrapping a large number of optimisation libraries that have been implemented in Julia. +# [Parameter Fitting for ODEs using Optimization.jl and DiffEqParamEstim.jl](@id optimization_parameter_fitting) +Fitting parameters to data involves solving an optimisation problem (that is, finding the parameter set that optimally fits your model to your data, typically by minimising a cost function)[^1]. The SciML ecosystem's primary package for solving optimisation problems is [Optimization.jl](https://github.com/SciML/Optimization.jl). It provides access to a variety of solvers via a single common interface by wrapping a large number of optimisation libraries that have been implemented in Julia. -This tutorial demonstrates both how to create parameter fitting cost functions using the [DiffEqParamEstim.jl](https://github.com/SciML/DiffEqParamEstim.jl) package, and how to use Optimization.jl to minimise these. Optimization.jl can also be used in other contexts, such as [finding parameter sets that maximise the magnitude of some system behaviour](@ref ref). More details on how to use these packages can be found in their [respective](https://docs.sciml.ai/Optimization/stable/) [documentations](https://docs.sciml.ai/DiffEqParamEstim/stable/). +This tutorial demonstrates both how to create parameter fitting cost functions using the [DiffEqParamEstim.jl](https://github.com/SciML/DiffEqParamEstim.jl) package, and how to use Optimization.jl to minimise these. Optimization.jl can also be used in other contexts, such as [finding parameter sets that maximise the magnitude of some system behaviour](@ref behaviour_optimisation). More details on how to use these packages can be found in their [respective](https://docs.sciml.ai/Optimization/stable/) [documentations](https://docs.sciml.ai/DiffEqParamEstim/stable/). ## [Basic example](@id optimization_parameter_fitting_basics) -Let us consider a [Michaelis-Menten enzyme kinetics model](@ref ref), where an enzyme ($E$) converts a substrate ($S$) into a product ($P$): +Let us consider a [Michaelis-Menten enzyme kinetics model](@ref basic_CRN_library_mm), where an enzyme ($E$) converts a substrate ($S$) into a product ($P$): ```@example diffeq_param_estim_1 using Catalyst rn = @reaction_network begin @@ -30,8 +30,8 @@ data_vals = (0.8 .+ 0.4*rand(10)) .* data_sol[:P][2:end] # Plots the true solutions and the (synthetic) data measurements. using Plots -plot(true_sol; idxs=:P, label="True solution", lw=8) -plot!(data_ts, data_vals; label="Measurements", seriestype=:scatter, ms=6, color=:blue) +plot(true_sol; idxs = :P, label = "True solution", lw = 8) +plot!(data_ts, data_vals; label = "Measurements", seriestype=:scatter, ms = 6, color = :blue) ``` Next, we will use DiffEqParamEstim to build a loss function to measure how well our model's solutions fit the data. @@ -40,12 +40,12 @@ using DiffEqParamEstim, Optimization ps_dummy = [:kB => 0.0, :kD => 0.0, :kP => 0.0] oprob = ODEProblem(rn, u0, (0.0, 10.0), ps_dummy) loss_function = build_loss_objective(oprob, Tsit5(), L2Loss(data_ts, data_vals), Optimization.AutoForwardDiff(); - maxiters = 10000, verbose = false, save_idxs = 4) + maxiters = 10000, verbose = false, save_idxs = 4) nothing # hide ``` To `build_loss_objective` we provide the following arguments: - `oprob`: The `ODEProblem` with which we simulate our model (using some dummy parameter values, since we do not know these). -- `Tsit5()`: The [numeric integrator](@ref ref) we wish to simulate our model with. +- `Tsit5()`: The [numeric solver](@ref simulation_intro_solver_options) we wish to simulate our model with. - `L2Loss(data_ts, data_vals)`: Defines the loss function. While [other alternatives](https://docs.sciml.ai/DiffEqParamEstim/stable/getting_started/#Alternative-Cost-Functions-for-Increased-Robustness) are available, `L2Loss` is the simplest one (measuring the sum of squared distances between model simulations and data measurements). Its first argument is the time points at which the data is collected, and the second is the data's values. - `Optimization.AutoForwardDiff()`: Our choice of [automatic differentiation](https://en.wikipedia.org/wiki/Automatic_differentiation) framework. @@ -63,27 +63,27 @@ nothing # hide !!! note `OptimizationProblem` cannot currently accept parameter values in the form of a map (e.g. `[:kB => 1.0, :kD => 1.0, :kP => 1.0]`). These must be provided as individual values (using the same order as the parameters occur in in the `parameters(rs)` vector). Similarly, `build_loss_objective`'s `save_idxs` uses the species' indexes, rather than the species directly. These inconsistencies should be remedied in future DiffEqParamEstim releases. -Finally, we can optimise `optprob` to find the parameter set that best fits our data. Optimization.jl only provides a few optimisation methods natively. However, for each supported optimisation package, it provides a corresponding wrapper-package to import that optimisation package for use with Optimization.jl. E.g., if we wish to use [Optim.jl](https://github.com/JuliaNLSolvers/Optim.jl)'s [Nelder-Mead](https://en.wikipedia.org/wiki/Nelder%E2%80%93Mead_method) method, we must install and import the OptimizationOptimJL package. A summary of all, by Optimization.jl supported, optimisation packages can be found [here](https://docs.sciml.ai/Optimization/stable/#Overview-of-the-Optimizers). Here, we import the Optim.jl package and uses it to minimise our cost function (thus finding a parameter set that fits the data): +Finally, we can optimise `optprob` to find the parameter set that best fits our data. Optimization.jl only provides a few optimisation methods natively. However, for each supported optimisation package, it provides a corresponding wrapper-package to import that optimisation package for use with Optimization.jl. E.g., if we wish to use [NLopt.jl](https://github.com/JuliaOpt/NLopt.jl)'s [Nelder-Mead](https://en.wikipedia.org/wiki/Nelder%E2%80%93Mead_method) method, we must install and import the OptimizationNLopt package. A summary of all, by Optimization.jl supported, optimisation packages can be found [here](https://docs.sciml.ai/Optimization/stable/#Overview-of-the-Optimizers). Here, we import the NLopt.jl package and uses it to minimise our cost function (thus finding a parameter set that fits the data): ```@example diffeq_param_estim_1 -using OptimizationOptimJL -optsol = solve(optprob, Optim.NelderMead()) +using OptimizationNLopt +optsol = solve(optprob, NLopt.LN_NELDERMEAD()) nothing # hide ``` We can now simulate our model for the corresponding parameter set, checking that it fits our data. ```@example diffeq_param_estim_1 -oprob_fitted = remake(oprob; p=optsol.u) +oprob_fitted = remake(oprob; p = optsol.u) fitted_sol = solve(oprob_fitted, Tsit5()) -plot!(fitted_sol; idxs=:P, label="Fitted solution", linestyle=:dash, lw=6, color=:lightblue) +plot!(fitted_sol; idxs = :P, label = "Fitted solution", linestyle = :dash, lw = 6, color = :lightblue) ``` !!! note Here, a good exercise is to check the resulting parameter set and note that, while it creates a good fit to the data, it does not actually correspond to the original parameter set. [Identifiability](@ref structural_identifiability) is a concept that studies how to deal with this problem. -Say that we instead would like to use the [Broyden–Fletcher–Goldfarb–Shannon](https://en.wikipedia.org/wiki/Broyden%E2%80%93Fletcher%E2%80%93Goldfarb%E2%80%93Shanno_algorithm) algorithm, as implemented by the [NLopt.jl](https://github.com/JuliaOpt/NLopt.jl) package. In this case we would run: +Say that we instead would like to use the [Broyden–Fletcher–Goldfarb–Shannon](https://en.wikipedia.org/wiki/Broyden%E2%80%93Fletcher%E2%80%93Goldfarb%E2%80%93Shanno_algorithm) algorithm, as implemented by the [Optim.jl](https://github.com/JuliaNLSolvers/Optim.jl) package. In this case we would run: ```@example diffeq_param_estim_1 -using OptimizationNLopt -sol = solve(optprob, NLopt.LD_LBFGS()) +using OptimizationOptimJL +sol = solve(optprob, Optim.LBFGS()) nothing # hide ``` @@ -129,13 +129,13 @@ If we from previous knowledge know that $kD = 0.1$, and only want to fit the val fixed_p_prob_generator(prob, p) = remake(prob; p = vcat(p[1], 0.1, p[2])) nothing # hide ``` -Here, it takes the `ODEProblem` (`prob`) we simulate, and the parameter set used, during the optimisation process (`p`), and creates a modified `ODEProblem` (by setting a customised parameter vector [using `remake`](@ref simulation_structure_interfacing_remake)). Now we create our modified loss function: +Here, it takes the `ODEProblem` (`prob`) we simulate, and the parameter set used, during the optimisation process (`p`), and creates a modified `ODEProblem` (by setting a customised parameter vector [using `remake`](@ref simulation_structure_interfacing_problems_remake)). Now we create our modified loss function: ```@example diffeq_param_estim_1 loss_function_fixed_kD = build_loss_objective(oprob, Tsit5(), L2Loss(data_ts, data_vals), Optimization.AutoForwardDiff(); prob_generator = fixed_p_prob_generator, maxiters=10000, verbose=false, save_idxs=4) nothing # hide ``` -We can create an `OptimizationProblem` from this one like previously, but keep in mind that it (and its output results) only contains two parameter values ($k$* and $kP$): +We can create an `OptimizationProblem` from this one like previously, but keep in mind that it (and its output results) only contains two parameter values ($k$ and $kP$): ```@example diffeq_param_estim_1 optprob_fixed_kD = OptimizationProblem(loss_function_fixed_kD, [1.0, 1.0]) optsol_fixed_kD = solve(optprob_fixed_kD, Optim.NelderMead()) @@ -160,7 +160,7 @@ nothing # hide corresponds to the same true parameter values as used previously (`[:kB => 1.0, :kD => 0.1, :kP => 0.5]`). ## [Parameter fitting to multiple experiments](@id optimization_parameter_fitting_multiple_experiments) -Say that we had measured our model for several different initial conditions, and would like to fit our model to all these measurements simultaneously. This can be done by first creating a [corresponding `EnsembleProblem`](@ref advanced_simulations_ensemble_problems). How to then create loss functions for these are described in more detail [here](https://docs.sciml.ai/DiffEqParamEstim/stable/tutorials/ensemble/). +Say that we had measured our model for several different initial conditions, and would like to fit our model to all these measurements simultaneously. This can be done by first creating a [corresponding `EnsembleProblem`](@ref ensemble_simulations). How to then create loss functions for these are described in more detail [here](https://docs.sciml.ai/DiffEqParamEstim/stable/tutorials/ensemble/). ## [Optimisation solver options](@id optimization_parameter_fitting_solver_options) Optimization.jl supports various [optimisation solver options](https://docs.sciml.ai/Optimization/stable/API/solve/) that can be supplied to the `solve` command. For example, to set a maximum number of seconds (after which the optimisation process is terminated), you can use the `maxtime` argument: @@ -170,7 +170,7 @@ nothing # hide ``` --- -## [Citation](@id structural_identifiability_citation) +## [Citation](@id optimization_parameter_fitting_citation) If you use this functionality in your research, please cite the following paper to support the authors of the Optimization.jl package: ``` @software{vaibhav_kumar_dixit_2023_7738525, diff --git a/docs/src/inverse_problems/structural_identifiability.md b/docs/src/inverse_problems/structural_identifiability.md index 95e5de615a..8b118b367c 100644 --- a/docs/src/inverse_problems/structural_identifiability.md +++ b/docs/src/inverse_problems/structural_identifiability.md @@ -31,27 +31,27 @@ Global identifiability can be assessed using the `assess_identifiability` functi - Unidentifiable. To it, we provide our `ReactionSystem` model and a list of quantities that we are able to measure. Here, we consider a Goodwind oscillator (a simple 3-component model, where the three species $M$, $E$, and $P$ are produced and degraded, which may exhibit oscillations)[^2]. Let us say that we are able to measure the concentration of $M$, we then designate this using the `measured_quantities` argument. We can now assess identifiability in the following way: -```example si1 +```@example si1 using Catalyst, Logging, StructuralIdentifiability -goodwind_oscillator = @reaction_network begin +gwo = @reaction_network begin (pₘ/(1+P), dₘ), 0 <--> M (pₑ*M,dₑ), 0 <--> E (pₚ*E,dₚ), 0 <--> P end -assess_identifiability(goodwind_oscillator; measured_quantities=[:M], loglevel=Logging.Error) +assess_identifiability(gwo; measured_quantities = [:M], loglevel = Logging.Error) ``` -From the output, we find that `E(t)`, `pₑ`, and `pₚ` (the trajectory of $E$, and the production rates of $E$ and $P$, respectively) are non-identifiable. Next, `dₑ` and `dₚ` (the degradation rates of $E$ and $P$, respectively) are locally identifiable. Finally, `P(t)`, `M(t)`, `pₘ`, and `dₘ` (the trajectories of `P` and `M`, and the production and degradation rate of `M`, respectively) are all globally identifiable. We note that we also imported the Logging.jl package, and provided the `loglevel=Logging.Error` input argument. StructuralIdentifiability functions generally provide a large number of output messages. Hence, we will use this argument (which requires the Logging package) throughout this tutorial to decrease the amount of printed text. +From the output, we find that `E(t)`, `pₑ`, and `pₚ` (the trajectory of $E$, and the production rates of $E$ and $P$, respectively) are non-identifiable. Next, `dₑ` and `dₚ` (the degradation rates of $E$ and $P$, respectively) are locally identifiable. Finally, `P(t)`, `M(t)`, `pₘ`, and `dₘ` (the trajectories of `P` and `M`, and the production and degradation rate of `M`, respectively) are all globally identifiable. We note that we also imported the Logging.jl package, and provided the `loglevel = Logging.Error` input argument. StructuralIdentifiability functions generally provide a large number of output messages. Hence, we will use this argument (which requires the Logging package) throughout this tutorial to decrease the amount of printed text. Next, we also assess identifiability in the case where we can measure all three species concentrations: -```example si1 -assess_identifiability(goodwind_oscillator; measured_quantities=[:M, :P, :E], loglevel=Logging.Error) +```@example si1 +assess_identifiability(gwo; measured_quantities = [:M, :P, :E], loglevel = Logging.Error) ``` in which case all species trajectories and parameters become identifiable. ### Indicating known parameters In the previous case we assumed that all parameters are unknown, however, this is not necessarily true. If there are parameters with known values, we can supply these using the `known_p` argument. Providing this additional information might also make other, previously unidentifiable, parameters identifiable. Let us consider the previous example, where we measure the concentration of $M$ only, but now assume we also know the production rate of $E$ ($pₑ$): -```example si1 -assess_identifiability(gwo; measured_quantities=[:M], known_p=[:pₑ], loglevel=Logging.Error) +```@example si1 +assess_identifiability(gwo; measured_quantities = [:M], known_p = [:pₑ], loglevel = Logging.Error) ``` Not only does this turn the previously non-identifiable `pₑ` (globally) identifiable (which is obvious, given that its value is now known), but this additional information improve identifiability for several other network components. @@ -59,47 +59,46 @@ To, in a similar manner, indicate that certain initial conditions are known is a ### Providing non-trivial measured quantities Sometimes, ones may not have measurements of species, but rather some combinations of species (or possibly parameters). To account for this, `measured_quantities` accepts any algebraic expression (and not just single species). To form such expressions, species and parameters have to first be `@unpack`'ed from the model. Say that we have a model where an enzyme ($E$) is converted between an active and inactive form, which in turns activates the production of a product, $P$: -```example si2 -using Catalyst, StructuralIdentifiability # hide -enzyme_activation = @reaction_network begin +```@example si1 +rs = @reaction_network begin (kA,kD), Eᵢ <--> Eₐ (Eₐ, d), 0 <-->P end ``` If we can measure the total amount of $E$ ($=Eᵢ+Eₐ$), as well as the amount of $P$, we can use the following to assess identifiability: -```example si2 -@unpack Eᵢ, Eₐ = enzyme_activation -assess_identifiability(enzyme_activation; measured_quantities=[Eᵢ+Eₐ, :P], loglevel=Logging.Error) +```@example si1 +@unpack Eᵢ, Eₐ = rs +assess_identifiability(rs; measured_quantities = [Eᵢ + Eₐ, :P], loglevel = Logging.Error) nothing # hide ``` ### Assessing identifiability for specified quantities only By default, StructuralIdentifiability assesses identifiability for all parameters and variables. It is, however, possible to designate precisely which quantities you want to check using the `funcs_to_check` option. This both includes selecting a smaller subset of parameters and variables to check, or defining customised expressions. Let us consider the Goodwind from previously, and say that we would like to check whether the production parameters ($pₘ$, $pₑ$, and $pₚ$) and the total amount of the three species ($P + M + E$) are identifiable quantities. Here, we would first unpack these (allowing us to form algebraic expressions) and then use the following code: -```example si1 -@unpack pₘ, pₑ, pₚ, M, E, P = goodwind_oscillator -assess_identifiability(goodwind_oscillator; measured_quantities=[:M], funcs_to_check=[pₘ, pₑ, pₚ, M + E + P], loglevel=Logging.Error) +```@example si1 +@unpack pₘ, pₑ, pₚ, M, E, P = gwo +assess_identifiability(gwo; measured_quantities = [:M], funcs_to_check = [pₘ, pₑ, pₚ, M + E + P], loglevel = Logging.Error) nothing # hide ``` ### Probability of correctness The identifiability methods used can, in theory, produce erroneous results. However, it is possible to adjust the lower bound for the probability of correctness using the argument `prob_threshold` (by default set to `0.99`, that is, at least a $99\%$ chance of correctness). We can e.g. increase the bound through: -```example si2 -assess_identifiability(goodwind_oscillator; measured_quantities=[:M], prob_threshold=0.999, loglevel=Logging.Error) +```@example si1 +assess_identifiability(gwo; measured_quantities=[:M], prob_threshold = 0.999, loglevel = Logging.Error) nothing # hide ``` giving a minimum bound of $99.9\%$ chance of correctness. In practise, the bounds used by StructuralIdentifiability are very conservative, which means that while the minimum guaranteed probability of correctness in the default case is $99\%$, in practise it is much higher. While increasing the value of `prob_threshold` increases the certainty of correctness, it will also increase the time required to assess identifiability. ## Local identifiability analysis Local identifiability can be assessed through the `assess_local_identifiability` function. While this is already determined by `assess_identifiability`, assessing local identifiability only has the advantage that it is easier to compute. Hence, there might be models where global identifiability analysis fails (or takes a prohibitively long time), where instead `assess_local_identifiability` can be used. This function takes the same inputs as `assess_identifiability` and returns, for each quantity, `true` if it is locally identifiable (or `false` if it is not). Here, for the Goodwind oscillator, we assesses it for local identifiability only: -```example si1 -assess_local_identifiability(goodwind_oscillator; measured_quantities=[:M], loglevel=Logging.Error) +```@example si1 +assess_local_identifiability(gwo; measured_quantities = [:M], loglevel = Logging.Error) ``` We note that the results are consistent with those produced by `assess_identifiability` (with globally or locally identifiable quantities here all being assessed as at least locally identifiable). ## Finding identifiable functions Finally, StructuralIdentifiability provides the `find_identifiable_functions` function. Rather than determining the identifiability of each parameter and unknown of the model, it finds a set of identifiable functions, such as any other identifiable expression of the model can be generated by these. Let us again consider the Goodwind oscillator, using the `find_identifiable_functions` function we find that identifiability can be reduced to five globally identifiable expressions: -```example si1 -find_identifiable_functions(goodwind_oscillator; measured_quantities=[:M], loglevel=Logging.Error) +```@example si1 +find_identifiable_functions(gwo; measured_quantities = [:M], loglevel = Logging.Error) ``` Again, these results are consistent with those produced by `assess_identifiability`. There, `pₑ` and `pₚ` where found to be globally identifiable. Here, they correspond directly to identifiable expressions. The remaining four parameters (`pₘ`, `dₘ`, `dₑ`, and `dₚ`) occur as part of more complicated composite expressions. @@ -107,34 +106,34 @@ Again, these results are consistent with those produced by `assess_identifiabili ## Creating StructuralIdentifiability compatible ODE models from Catalyst `ReactionSystem`s While the functionality described above covers the vast majority of analysis that user might want to perform, the StructuralIdentifiability package supports several additional features. While these does not have inherent Catalyst support, we do provide the `make_si_ode` function to simplify their use. Similar to the previous functions, it takes a `ReactionSystem`, lists of measured quantities, and known parameter values. The output is a [ODE of the standard form supported by StructuralIdentifiability](https://docs.sciml.ai/StructuralIdentifiability/stable/tutorials/creating_ode/#Defining-the-model-using-@ODEmodel-macro). It can be created using the following syntax: -```example si1 -si_ode = make_si_ode(goodwind_oscillator; measured_quantities=[:M]) +```@example si1 +si_ode = make_si_ode(gwo; measured_quantities = [:M]) nothing # hide ``` and then used as input to various StructuralIdentifiability functions. In the following example we use StructuralIdentifiability's `print_for_DAISY` function, printing the model as an expression that can be used by the [DAISY](https://daisy.dei.unipd.it/) software for identifiability analysis[^3]. -```example si1 +```@example si1 print_for_DAISY(si_ode) nothing # hide ``` ## Notes on systems with conservation laws Several reaction network models, such as -```example si2 +```@example si3 using Catalyst, Logging, StructuralIdentifiability # hide rs = @reaction_network begin - (k1,k2), X1 <--> X2 + (k1,k2), X1 <--> X2 end ``` contain conservation laws (in this case $Γ = X1 + X2$, where $Γ = X1(0) + X2(0)$ is a constant). Because the presence of such conservation laws makes structural identifiability analysis prohibitively computationally expensive (for all but the simplest of cases), these are automatically eliminated by Catalyst (removing one ODE from the resulting ODE system for each conservation law). For the `assess_identifiability` and `assess_local_identifiability` functions, this will be unnoticed by the user. However, for the `find_identifiable_functions` and `make_si_ode` functions, this may result in one, or several, parameters of the form `Γ[i]` (where `i` is an integer) appearing in the produced expressions. These correspond to the conservation law constants and can be found through -```example si2 +```@example si3 conservedequations(rs) ``` E.g. if you run: -```example si2 +```@example si3 find_identifiable_functions(rs; measured_quantities = [:X1, :X2]) ``` we see that `Γ[1]` (`= X1(0) + X2(0)`) is detected as an identifiable expression. If we want to disable this feature for any function, we can use the `remove_conserved = false` option: -```example si2 +```@example si3 find_identifiable_functions(rs; measured_quantities = [:X1, :X2], remove_conserved = false) ``` @@ -144,7 +143,7 @@ Structural identifiability cannot currently be applied to systems with parameter rn = @reaction_network begin (hill(X,v,K,n),d), 0 <--> X end -assess_identifiability(rn; measured_quantities=[:X]) +assess_identifiability(rn; measured_quantities = [:X]) ``` is currently not possible. Hopefully this will be a supported feature in the future. For now, such expressions will have to be rewritten to not include such exponents. For some cases, e.g. `10^k` this is trivial. However, it is also possible generally (but more involved and often includes introducing additional variables). diff --git a/docs/src/model_creation/chemistry_related_functionality.md b/docs/src/model_creation/chemistry_related_functionality.md index ff6dfd9373..f87402d91c 100644 --- a/docs/src/model_creation/chemistry_related_functionality.md +++ b/docs/src/model_creation/chemistry_related_functionality.md @@ -1,9 +1,10 @@ # [Chemistry-related functionality](@id chemistry_functionality) -While Catalyst has primarily been designed around the modelling of biological systems, reaction network models are also common across chemistry. This section describes two types of functionality, that while of general interest, should be especially useful in the modelling of chemical systems. +While Catalyst has primarily been designed around the modelling of biological systems, reaction network models are also common in chemistry. This section describes two types of functionality, that while of general interest, should be especially useful in the modelling of chemical systems. - The `@compound` option, which enables the user to designate that a specific species is composed of certain subspecies. - The `balance_reaction` function, which enables the user to balance a reaction so the same number of components occur on both sides. + ## Modelling with compound species ### Creating compound species programmatically @@ -11,13 +12,14 @@ We will first show how to create compound species through [programmatic model co ```@example chem1 using Catalyst t = default_t() -@species C(t) O(t) +@species C(t) O(t) +nothing # hide ``` Next, we create the `CO2` compound species: ```@example chem1 @compound CO2 ~ C + 2O ``` -Here, the compound is the first argument to the macro, followed by its component (with the left-hand and right-hand sides separated by a `~` sign). While non-compound species (such as `C` and `O`) have their independent variable (in this case `t`) designated, independent variables are generally not designated for compounds (these are instead directly inferred from their components). Components with non-unit stoichiometries have this value written before the component (generally, the rules for designating the components of a compound are identical to those of designating the substrates or products of a reaction). The created compound, `CO2`, is also a species, and can be used wherever e.g. `C` can be used: +Here, the compound is the first argument to the macro, followed by its component (with the left-hand and right-hand sides separated by a `~` sign). While non-compound species (such as `C` and `O`) have their independent variable (in this case `t`) designated, independent variables are generally not designated for compounds (these are instead directly inferred from their components). Components with non-unitary stoichiometries have this value written before the component (generally, the rules for designating the components of a compound are identical to those of designating the substrates or products of a reaction). The created compound, `CO2`, is also a species, and can be used wherever e.g. `C` can be used: ```@example chem1 isspecies(CO2) ``` @@ -32,7 +34,7 @@ Alternatively, we can retrieve the components and their stoichiometric coefficie ```@example chem1 component_coefficients(CO2) ``` -Finally, it is possible to check whether a species is a compound or not using the `iscompound` function: +Finally, it is possible to check whether a species is a compound using the `iscompound` function: ```@example chem1 iscompound(CO2) ``` @@ -68,7 +70,7 @@ end ``` When creating compound species using the DSL, it is important to note that *every component must be known to the system as a species, either by being declared using the `@species` or `@compound` options, or by appearing in a reaction*. E.g. the following is not valid ```julia -rn = @reaction_network begin`` +rn = @reaction_network begin @compounds begin C2O ~ C + 2O H2O ~ 2H + O @@ -77,25 +79,28 @@ rn = @reaction_network begin`` (k1,k2), H2O+ CO2 <--> H2CO3 end ``` -as the components `C`, `H`, and `O` are not declared as a species anywhere. Please also note that only `@compounds` can be used as an option in the DSL, not `@compound`. +as the components `C`, `H`, and `O` are not declared as species anywhere. Please also note that only `@compounds` can be used as an option in the DSL, not `@compound`. ### Designating metadata and default values for compounds Just like for normal species, it is possible to designate metadata and default values for compounds. Metadata is provided after the compound name, but separated from it by a `,`: ```@example chem1 @compound (CO2, [unit="mol"]) ~ C + 2O +nothing # hide ``` Default values are designated using `=`, and provided directly after the compound name.: ```@example chem1 @compound (CO2 = 2.0) ~ C + 2O +nothing # hide ``` If both default values and meta data are provided, the metadata is provided after the default value: ```@example chem1 @compound (CO2 = 2.0, [unit="mol"]) ~ C + 2O +nothing # hide ``` -In all of these cases, the side to the left of the `~` must be enclosed within `()`. +In all of these cases, the left-hand side must be enclosed within `()`. ### Compounds with multiple independent variables -While we generally do not need to specify independent variables for compound, if the components (together) have more than one independent variable, this have to be done: +While we generally do not need to specify independent variables for compound, if the components (together) have more than one independent variable, this *must be done*: ```@example chem1 t = default_t() @variables s @@ -113,7 +118,7 @@ Here, the reaction rate (`k`) is not involved in the reaction balancing. We use ```@example chem1 balance_reaction(rx) ``` -which correctly finds the (rather trivial) solution `C + 2O --> CO2`. Here we note that `balance_reaction` actually returns a vector. The reason is that the reaction balancing problem may have several solutions. Typically, there is only a single solution (in which case this is the vector's only element). No, or an infinite number of, solutions is also possible depending on the given reaction. +which correctly finds the (rather trivial) solution `C + 2O --> CO2`. Here we note that `balance_reaction` actually returns a vector. The reason is that, in some cases, the reaction balancing problem does not have a single obvious solution. Typically, a single solution is the obvious candidate (in which case this is the vector's only element). However, when this is not the case, the vector instead contain several reactions (from which a balanced reaction cab be generated). Let us consider a more elaborate example, the reaction between ammonia (NH₃) and oxygen (O₂) to form nitrogen monoxide (NO) and water (H₂O). Let us first create the components and the unbalanced reaction: ```@example chem2 diff --git a/docs/src/model_creation/compositional_modeling.md b/docs/src/model_creation/compositional_modeling.md index d5d547ac28..ca0807299c 100644 --- a/docs/src/model_creation/compositional_modeling.md +++ b/docs/src/model_creation/compositional_modeling.md @@ -18,16 +18,17 @@ end Alternatively one can just build the `ReactionSystem` via the symbolic interface. ```@example ex0 @parameters d -@variable t +t = default_t() @species X(t) rx = Reaction(d, [X], nothing) -@named degradation_component = ReactionSystem([rs], t) +@named degradation_component = ReactionSystem([rx], t) ``` We can test whether a system is complete using the `ModelingToolkit.iscomplete` function: ```@example ex0 ModelingToolkit.iscomplete(degradation_component) ``` -To mark a system as complete, after which is should be considered as representing a finalized model, use the `complete` function +To mark a system as complete, after which it should be considered as +representing a finalized model, use the `complete` function ```@example ex0 degradation_component_complete = complete(degradation_component) ModelingToolkit.iscomplete(degradation_component_complete) @@ -35,8 +36,9 @@ ModelingToolkit.iscomplete(degradation_component_complete) ## Compositional modeling tooling Catalyst supports two ModelingToolkit interfaces for composing multiple -[`ReactionSystem`](@ref)s together into a full model. The first mechanism for -extending a system is the `extend` command +[`ReactionSystem`](@ref)s together into a full model. The first mechanism allows +for extending an existing system by merging in a second system via the `extend` +command ```@example ex1 using Catalyst basern = @network_component rn1 begin @@ -139,7 +141,7 @@ nothing # hide Here we assume the user will pass in the repressor species as a ModelingToolkit variable, and specify a name for the network. We use Catalyst's interpolation ability to substitute the value of these variables into the DSL (see -[Interpolation of Julia Variables](@ref dsl_description_interpolation_of_variables)). To make the repressilator we now make +[Interpolation of Julia Variables](@ref dsl_advanced_options_symbolics_and_DSL_interpolation)). To make the repressilator we now make three genes, and then compose them together ```@example ex1 t = default_t() diff --git a/docs/src/model_creation/constraint_equations.md b/docs/src/model_creation/constraint_equations.md index 25ef0b8d7b..b8468dc62a 100644 --- a/docs/src/model_creation/constraint_equations.md +++ b/docs/src/model_creation/constraint_equations.md @@ -28,7 +28,7 @@ creating these two systems. Here, to create differentials with respect to time (for our differential equations), we must import the time differential operator from Catalyst. We do this through `D = default_time_deriv()`. Here, `D(V)` denotes the differential of the variable `V` with respect to time. ```@example ceq1 -using Catalyst, DifferentialEquations, Plots +using Catalyst, OrdinaryDiffEq, Plots t = default_t() D = default_time_deriv() @@ -52,12 +52,13 @@ end Notice, here we interpolated the variable `V` with `$V` to ensure we use the same symbolic unknown variable in the `rn` as we used in building `osys`. See the doc section on [interpolation of variables](@ref -dsl_description_interpolation_of_variables) for more information. +dsl_advanced_options_symbolics_and_DSL_interpolation) for more information. We can now merge the two systems into one complete `ReactionSystem` model using [`ModelingToolkit.extend`](@ref): ```@example ceq1 @named growing_cell = extend(osys, rn) +growing_cell = complete(growing_cell) ``` We see that the combined model now has both the reactions and ODEs as its @@ -72,7 +73,7 @@ plot(sol) As an alternative to the previous approach, we could have constructed our `ReactionSystem` all at once by directly using the symbolic interface: ```@example ceq2 -using Catalyst, DifferentialEquations, Plots +using Catalyst, OrdinaryDiffEq, Plots t = default_t() D = default_time_deriv() @@ -83,6 +84,7 @@ rx1 = @reaction $V, 0 --> P rx2 = @reaction 1.0, P --> 0 @named growing_cell = ReactionSystem([rx1, rx2, eq], t) setdefaults!(growing_cell, [:P => 0.0]) +growing_cell = complete(growing_cell) oprob = ODEProblem(growing_cell, [], (0.0, 1.0)) sol = solve(oprob, Tsit5()) @@ -100,12 +102,11 @@ the associated [ModelingToolkit tutorial](https://docs.sciml.ai/ModelingToolkit/stable/basics/Events/) for more details on the types of events that can be represented symbolically. A lower-level approach for creating events via the DifferentialEquations.jl -callback interface is illustrated in the [Advanced Simulation Options](@ref -advanced_simulations) tutorial. +callback interface is illustrated [here](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/) tutorial. Let's first create our equations and unknowns/species again ```@example ceq3 -using Catalyst, DifferentialEquations, Plots +using Catalyst, OrdinaryDiffEq, Plots t = default_t() D = default_time_deriv() @@ -124,6 +125,7 @@ continuous_events = [V ~ 2.0] => [V ~ V/2, P ~ P/2] We can now create and simulate our model ```@example ceq3 @named rs = ReactionSystem([rx1, rx2, eq], t; continuous_events) +rs = complete(rs) oprob = ODEProblem(rs, [], (0.0, 10.0)) sol = solve(oprob, Tsit5()) @@ -148,6 +150,7 @@ p = [k_on => 100.0, switch_time => 2.0, k_off => 10.0] Simulating our model, ```@example ceq3 @named osys = ReactionSystem(rxs, t, [A, B], [k_on, k_off, switch_time]; discrete_events) +osys = complete(osys) oprob = ODEProblem(osys, u0, tspan, p) sol = solve(oprob, Tsit5(); tstops = 2.0) diff --git a/docs/src/model_creation/dsl_advanced.md b/docs/src/model_creation/dsl_advanced.md index 8a1026bbae..4edd069ba0 100644 --- a/docs/src/model_creation/dsl_advanced.md +++ b/docs/src/model_creation/dsl_advanced.md @@ -1,7 +1,7 @@ # [The Catalyst DSL - Advanced Features and Options](@id dsl_advanced_options) Within the Catalyst DSL, each line can represent either *a reaction* or *an option*. The [previous DSL tutorial](@ref dsl_description) described how to create reactions. This one will focus on options. These are typically used to supply a model with additional information. Examples include the declaration of initial condition/parameter default values, or the creation of observables. -All option designations begin with a declaration starting with `@`, followed by its input. E.g. the `@observables` option allows for the generation of observables. Each option can only be used once within each use of `@reaction_network`. A full list of options can be found [here](@ref ref), with most (but not all) being described in more detail below. This tutorial will also describe some additional advanced DSL features that do not involve using an option. +All option designations begin with a declaration starting with `@`, followed by its input. E.g. the `@observables` option allows for the generation of observables. Each option can only be used once within each use of `@reaction_network`. This tutorial will also describe some additional advanced DSL features that do not involve using an option. As a first step, we import Catalyst (which is required to run the tutorial): ```@example dsl_advanced_explicit_definitions @@ -9,10 +9,10 @@ using Catalyst ``` ## [Explicit specification of network species and parameters](@id dsl_advanced_options_declaring_species_and_parameters) -[Previously](@ref ref), we mentioned that the DSL automatically determines which symbols correspond to species and which to parameters. This is done by designating everything that appears as either a substrate or a product as a species, and all remaining quantities as parameters (i.e. those only appearing within rates or [stoichiometric constants](@ref ref)). Sometimes, one might want to manually override this default behaviour for a given symbol. I.e. consider the following model, where the conversion of a protein `P` from its inactive form (`Pᵢ`) to its active form (`Pₐ`) is catalysed by an enzyme (`E`). Using the most natural description: +Previously, we mentioned that the DSL automatically determines which symbols correspond to species and which to parameters. This is done by designating everything that appears as either a substrate or a product as a species, and all remaining quantities as parameters (i.e. those only appearing within rates or [stoichiometric constants](@ref dsl_description_stoichiometries_parameters)). Sometimes, one might want to manually override this default behaviour for a given symbol. I.e. consider the following model, where the conversion of a protein `P` from its inactive form (`Pᵢ`) to its active form (`Pₐ`) is catalysed by an enzyme (`E`). Using the most natural description: ```@example dsl_advanced_explicit_definitions catalysis_sys = @reaction_network begin - k*E, Pᵢ --> Pₐ + k*E, Pᵢ --> Pₐ end ``` `E` (as well as `k`) will be considered a parameter, something we can confirm directly: @@ -22,19 +22,19 @@ parameters(catalysis_sys) If we want `E` to be considered a species, we can designate this using the `@species` option: ```@example dsl_advanced_explicit_definitions catalysis_sys = @reaction_network begin - @species E(t) - k*E, Pᵢ --> Pₐ + @species E(t) + k*E, Pᵢ --> Pₐ end parameters(catalysis_sys) ``` !!! note - When declaring species using the `@species` option, the species symbol must be followed by `(t)`. The reason is that species are time-dependent variables, and this time-dependency must be explicitly specified ([designation of non-`t` dependant species is also possible](@ref ref)). + When declaring species using the `@species` option, the species symbol must be followed by `(t)`. The reason is that species are time-dependent variables, and this time-dependency must be explicitly specified ([designation of non-`t` dependant species is also possible](@ref dsl_advanced_options_ivs)). Similarly, the `@parameters` option can be used to explicitly designate something as a parameter: ```@example dsl_advanced_explicit_definitions catalysis_sys = @reaction_network begin - @parameters k - k*E, Pᵢ --> Pₐ + @parameters k + k*E, Pᵢ --> Pₐ end ``` Here, while `k` is explicitly defined as a parameter, no information is provided about `E`. Hence, the default case will be used (setting `E` to a parameter). The `@species` and `@parameter` options can be used simultaneously (although a quantity cannot be declared *both* as a species and a parameter). They may be followed by a full list of all species/parameters, or just a subset. @@ -44,37 +44,37 @@ While designating something which would default to a parameter as a species is s Rather than listing all species/parameters on a single line after the options, a `begin ... end` block can be used (listing one species/parameter on each line). E.g. in the following example we use this notation to explicitly designate all species and parameters of the system: ```@example dsl_advanced_explicit_definitions catalysis_sys = @reaction_network begin - @species begin - E(t) - Pᵢ(t) - Pₐ(t) - end - @parameters begin - k - end - k*E, Pᵢ --> Pₐ + @species begin + E(t) + Pᵢ(t) + Pₐ(t) + end + @parameters begin + k + end + k*E, Pᵢ --> Pₐ end ``` A side-effect of using the `@species` and `@parameter` options is that they specify *the order in which the species and parameters are stored*. I.e. lets check the order of the parameters in the parameters in the following dimerisation model: ```@example dsl_advanced_explicit_definitions dimerisation = @reaction_network begin - (p,d), 0 <--> X - (kB,kD), 2X <--> X2 + (p,d), 0 <--> X + (kB,kD), 2X <--> X2 end parameters(dimerisation) ``` The default order is typically equal to the order with which the parameters (or species) are encountered in the DSL (this is, however, not guaranteed). If we specify the parameters using `@parameters`, the order used within the option is used instead: ```@example dsl_advanced_explicit_definitions dimerisation = @reaction_network begin - @parameters kB kD p d - (p,d), 0 <--> X - (kB,kD), 2X <--> X2 + @parameters kB kD p d + (p,d), 0 <--> X + (kB,kD), 2X <--> X2 end parameters(dimerisation) ``` !!! danger - Generally, Catalyst and the SciML ecosystem *do not* guarantee that parameter and species order are preserved throughout various operations on a model. Writing programs that depend on these orders is *strongly discouraged*. There are, however, some legacy packages which still depend on order (one example is provided [here](@ref ref)). In these situations, this might be useful. However, in these cases, it is recommended that the user is extra wary, and also checks the order manually. + Generally, Catalyst and the SciML ecosystem *do not* guarantee that parameter and species order are preserved throughout various operations on a model. Writing programs that depend on these orders is *strongly discouraged*. There are, however, some legacy packages which still depend on order (one example can be found [here](@ref optimization_parameter_fitting_basics)). In these situations, this might be useful. However, in these cases, it is recommended that the user is extra wary, and also checks the order manually. !!! note The syntax of the `@species` and `@parameters` options is identical to that used by the `@species` and `@parameters` macros [used in programmatic modelling in Catalyst](@ref programmatic_CRN_construction) (for e.g. designating metadata or initial conditions). Hence, if one has learnt how to specify species/parameters using either approach, that knowledge can be transferred to the other one. @@ -85,17 +85,17 @@ Generally, there are four main reasons for specifying species/parameters using t 3. To designate metadata for species/parameters (described [here](@ref dsl_advanced_options_species_and_parameters_metadata)). 4. To designate a species or parameters that do not occur in reactions, but are still part of the model (e.g a [parametric initial condition](@ref dsl_advanced_options_parametric_initial_conditions)) -!!!! warn - Catalyst's DSL automatically infer species and parameters from the input. However, it only does so for *quantities that appear in reactions*. Until now this has not been relevant. However, this tutorial will demonstrate cases where species/parameters that are not part of reactions are used. These *must* be designated using either the `@species` or `@parameters` options (or the `@variables` option, which is described [later](@ref ref)). +!!! warning + Catalyst's DSL automatically infer species and parameters from the input. However, it only does so for *quantities that appear in reactions*. Until now this has not been relevant. However, this tutorial will demonstrate cases where species/parameters that are not part of reactions are used. These *must* be designated using either the `@species` or `@parameters` options (or the `@variables` option, which is described [later](@ref constraint_equations)). ### [Setting default values for species and parameters](@id dsl_advanced_options_default_vals) When declaring species/parameters using the `@species` and `@parameters` options, one can also assign them default values (by appending them with `=` followed by the desired default value). E.g here we set `X`'s default initial condition value to $1.0$, and `p` and `d`'s default values to $1.0$ and $0.2$, respectively: ```@example dsl_advanced_defaults using Catalyst # hide rn = @reaction_network begin - @species X(t)=1.0 - @parameters p=1.0 d=0.1 - (p,d), 0 <--> X + @species X(t)=1.0 + @parameters p=1.0 d=0.1 + (p,d), 0 <--> X end ``` Next, if we simulate the model, we do not need to provide values for species or parameters that have default values. In this case all have default values, so both `u0` and `ps` can be empty vectors: @@ -120,25 +120,24 @@ It is also possible to declare a model with default values for only some initial ```@example dsl_advanced_defaults using Catalyst # hide rn = @reaction_network begin - @species X(t)=1.0 - (p,d), 0 <--> X + @species X(t)=1.0 + (p,d), 0 <--> X end tspan = (0.0, 10.0) -p = [:p => 1.0, :D => 0.2] +p = [:p => 1.0, :d => 0.2] oprob = ODEProblem(rn, u0, tspan, p) sol = solve(oprob) plot(sol) ``` -API for checking the default values of species and parameters can be found [here](@ref ref). ### [Setting parametric initial conditions](@id dsl_advanced_options_parametric_initial_conditions) In the previous section, we designated default values for initial conditions and parameters. However, the right-hand side of the designation accepts any valid expression (not only numeric values). While this can be used to set up some advanced default values, the most common use case is to designate a species's initial condition as a parameter. E.g. in the following example we represent the initial condition of `X` using the parameter `X₀`. ```@example dsl_advanced_defaults rn = @reaction_network begin - @species X(t)=X₀ - @parameters X₀ - (p,d), 0 <--> X + @species X(t)=X₀ + @parameters X₀ + (p,d), 0 <--> X end ``` Please note that as the parameter `X₀` does not occur as part of any reactions, Catalyst's DSL cannot infer whether it is a species or a parameter. This must hence be explicitly declared. We can now simulate our model while providing `X`'s value through the `X₀` parameter: @@ -166,53 +165,51 @@ Whenever a species/parameter is declared using the `@species`/`@parameters` opti ```@example dsl_advanced_metadata using Catalyst # hide two_state_system = @reaction_network begin - @species Xi(t) [description="The X's inactive form"] Xa(t) [description="The X's active form"] - @parameters kA [description="X's activation rate"] kD [description="X's deactivation rate"] - (ka,kD), Xi <--> Xa + @species Xᵢ(t) [description="X's inactive form"] Xₐ(t) [description=" X's active form"] + @parameters kA [description="Activation rate"] kD [description="Deactivation rate"] + (ka,kD), Xᵢ <--> Xₐ end ``` -A metadata can be given to only a subset of a system's species/parameters, and a quantity can be given several metadata entries. To give several metadata, separate each by a `,`. Here we only provide a description for `kA`, for which we also provide a [bounds metadata](@ref https://docs.sciml.ai/ModelingToolkit/dev/basics/Variable_metadata/#Bounds), +A metadata can be given to only a subset of a system's species/parameters, and a quantity can be given several metadata entries. To give several metadata, separate each by a `,`. Here we only provide a description for `kA`, for which we also provide a [`bounds` metadata](https://docs.sciml.ai/ModelingToolkit/dev/basics/Variable_metadata/#Bounds), ```@example dsl_advanced_metadata two_state_system = @reaction_network begin - @parameters kA [description="X's activation rate", bound=(0.01,10.0)] - (ka,kD), Xi <--> Xa + @parameters kA [description="Activation rate", bounds=(0.01,10.0)] + (ka,kD), Xᵢ <--> Xₐ end ``` It is possible to add both default values and metadata to a parameter/species. In this case, first provide the default value, and next the metadata. I.e. to in the above example set $kA$'s default value to $1.0$ we use ```@example dsl_advanced_metadata two_state_system = @reaction_network begin - @parameters kA=1.0 [description="X's activation rate", bound=(0.01,10.0)] - (ka,kD), Xi <--> Xa + @parameters kA=1.0 [description="Activation rate", bounds=(0.01,10.0)] + (ka,kD), Xᵢ <--> Xₐ end ``` When designating metadata for species/parameters in `begin ... end` blocks the syntax changes slightly. Here, a `,` must be inserted before the metadata (but after any potential default value). I.e. a version of the previous example can be written as ```@example dsl_advanced_metadata two_state_system = @reaction_network begin - @parameters begin - kA, [description="X's activation rate", bound=(0.01,10.0)] - kD = 1.0, [description="X's deactivation rate"] - end - (ka,kD), Xi <--> Xa + @parameters begin + kA, [description="Activation rate", bounds=(0.01,10.0)] + kD = 1.0, [description="Deactivation rate"] + end + (kA,kD), Xᵢ <--> Xₐ end ``` -Each metadata has its own getter functions. E.g. we can get the description of the parameter `kA` using `getdescription` (here we use [system indexing](@ref ref) to access the parameter): +Each metadata has its own getter functions. E.g. we can get the description of the parameter `kA` using `ModelingToolkit.getdescription`: ```@example dsl_advanced_metadata -getdescription(two_state_system.kA) +ModelingToolkit.getdescription(two_state_system.kA) ``` -It is not possible for the user to directly designate their own metadata. These have to first be added to Catalyst. Doing so is somewhat involved, and described in detail [here](@ref ref). A full list of metadata that can be used for species and/or parameters can be found [here](@ref ref). - ### [Designating constant-valued/fixed species parameters](@id dsl_advanced_options_constant_species) -Catalyst enables the designation of parameters as `constantspecies`. These parameters can be used as species in reactions, however, their values are not changed by the reaction and remain constant throughout the simulation (unless changed by e.g. the [occurrence of an event]@ref ref). Practically, this is done by setting the parameter's `isconstantspecies` metadata to `true`. Here, we create a simple reaction where the species `X` is converted to `Xᴾ` at rate `k`. By designating `X` as a constant species parameter, we ensure that its quantity is unchanged by the occurrence of the reaction. +Catalyst enables the designation of parameters as `constantspecies`. These parameters can be used as species in reactions, however, their values are not changed by the reaction and remain constant throughout the simulation (unless changed by e.g. the [occurrence of an event]@ref constraint_equations_events). Practically, this is done by setting the parameter's `isconstantspecies` metadata to `true`. Here, we create a simple reaction where the species `X` is converted to `Xᴾ` at rate `k`. By designating `X` as a constant species parameter, we ensure that its quantity is unchanged by the occurrence of the reaction. ```@example dsl_advanced_constant_species using Catalyst # hide rn = @reaction_network begin - @parameters X [isconstantspecies=true] - k, X --> Xᴾ + @parameters X [isconstantspecies=true] + k, X --> Xᴾ end ``` We can confirm that $Xᴾ$ is the only species of the system: @@ -222,14 +219,14 @@ species(rn) Here, the produced model is actually identical to if $X$ had simply been a parameter in the reaction's rate: ```@example dsl_advanced_constant_species rn = @reaction_network begin - k*X, 0 --> Xᴾ + k*X, 0 --> Xᴾ end ``` A common use-case for constant species is when modelling systems where some species are present in such surplus that their amounts the reactions' effect on it is negligible. A system which is commonly modelled this way is the [Brusselator](https://en.wikipedia.org/wiki/Brusselator). ### [Designating parameter types](@id dsl_advanced_options_parameter_types) -Sometimes it is desired to designate that a parameter should have a specific [type](@ref ref). When supplying this parameter's value to e.g. an `ODEProblem`, that parameter will then be restricted to that specific type. Designating a type is done by appending the parameter with `::` followed by its type. E.g. in the following example we specify that the parameter `n` (the number of `X` molecules in the `Xn` polymer) must be an integer (`Int64`) +Sometimes it is desired to designate that a parameter should have a specific [type](https://docs.julialang.org/en/v1/manual/types/). When supplying this parameter's value to e.g. an `ODEProblem`, that parameter will then be restricted to that specific type. Designating a type is done by appending the parameter with `::` followed by its type. E.g. in the following example we specify that the parameter `n` (the number of `X` molecules in the `Xn` polymer) must be an integer (`Int64`) ```@example dsl_advanced_parameter_types using Catalyst # hide polymerisation_network = @reaction_network begin @@ -238,7 +235,7 @@ polymerisation_network = @reaction_network begin end nothing # hide ``` -Generally, when simulating models with mixed parameter types, it is recommended to [declare parameter values as tuples, rather than vectors](@ref ref), e.g.: +Generally, when simulating models with mixed parameter types, it is recommended to [declare parameter values as tuples, rather than vectors](@ref simulation_intro_ODEs_input_forms), e.g.: ```@example dsl_advanced_parameter_types ps = (:kB => 0.2, :kD => 1.0, :n => 2) nothing # hide @@ -247,14 +244,14 @@ nothing # hide If a parameter has a type, metadata, and a default value, they are designated in the following order: ```@example dsl_advanced_parameter_types polymerisation_network = @reaction_network begin - @parameters n::Int64 = 2 [description="Parameter n, which is an integer and defaults to the value 2."] + @parameters n::Int64 = 2 [description="Parameter n, an integer with defaults value 2."] (kB,kD), n*X <--> Xn end nothing # hide ``` ### [Vector-valued species or parameters](@id dsl_advanced_options_vector_variables) -Sometimes, one wishes to declare a large number of similar parameters or species. This can be done by *creating them as vectors*. E.g. below we create a [two-state system](@ref ref). However, instead of declaring `X1` and `X2` (and `k1` and `k2`) as separate entities, we declare them as vectors: +Sometimes, one wishes to declare a large number of similar parameters or species. This can be done by *creating them as vectors*. E.g. below we create a [two-state system](@ref basic_CRN_library_two_states). However, instead of declaring `X1` and `X2` (and `k1` and `k2`) as separate entities, we declare them as vectors: ```@example dsl_advanced_vector_variables using Catalyst # hide two_state_model = @reaction_network begin @@ -279,14 +276,14 @@ Each reaction network model has a name. It can be accessed using the `nameof` fu ```@example dsl_advanced_names using Catalyst # hide rn = @reaction_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end nameof(rn) ``` A specific name can be given as an argument between the `@reaction_network` and the `begin`. E.g. to name a network `my_network` we can use: ```@example dsl_advanced_names rn = @reaction_network my_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end nameof(rn) ``` @@ -294,10 +291,10 @@ nameof(rn) A consequence of generic names being used by default is that networks, even if seemingly identical, by default are not. E.g. ```@example dsl_advanced_names rn1 = @reaction_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end rn2 = @reaction_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end rn1 == rn2 ``` @@ -308,15 +305,16 @@ nameof(rn1) == nameof(rn2) By designating the networks to have the same name, however, identity is achieved. ```@example dsl_advanced_names rn1 = @reaction_network my_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end rn2 = @reaction_network my_network begin - (p,d), 0 <--> X + (p,d), 0 <--> X end rn1 == rn2 ``` +If you wish to check for identity, and wish that models that have different names but are otherwise identical, should be considered equal, you can use the [`isequivalent`](@ref) function. -Setting model names is primarily useful for [hierarchical modelling](@ref ref), where network names are appended to the display names of subnetworks' species and parameters. +Setting model names is primarily useful for [hierarchical modelling](@ref compositional_modeling), where network names are appended to the display names of subnetworks' species and parameters. ## [Creating observables](@id dsl_advanced_options_observables) Sometimes one might want to use observable variables. These are variables with values that can be computed directly from a system's state (rather than having their values implicitly given by reactions or equations). Observables can be designated using the `@observables` option. Here, the `@observables` option is followed by a `begin ... end` block with one line for each observable. Each line first gives the observable, followed by a `~` (*not* a `=`!), followed by an expression describing how to compute it. @@ -325,11 +323,11 @@ Let us consider a model where two species (`X` and `Y`) can bind to form a compl ```@example dsl_advanced_observables using Catalyst # hide rn = @reaction_network begin - @observables begin - Xtot ~ X + XY - Ytot ~ Y + XY - end - (kB,kD), X + Y <--> XY + @observables begin + Xtot ~ X + XY + Ytot ~ Y + XY + end + (kB,kD), X + Y <--> XY end ``` We can now simulate our model using normal syntax (initial condition values for observables should not, and can not, be provided): @@ -346,40 +344,42 @@ nothing # hide Next, we can use [symbolic indexing](@ref simulation_structure_interfacing) of our solution object, but with the observable as input. E.g. we can use ```@example dsl_advanced_observables sol[:Xtot] +nothing # hide ``` to get a vector with `Xtot`'s value throughout the simulation. We can also use ```@example dsl_advanced_observables using Plots -plot(sol; idxs = [:Xtot, :Ytot]) +plot(sol; idxs = :Xtot) +plot!(ylimit = (minimum(sol[:Xtot])*0.95, maximum(sol[:Xtot])*1.05)) # hide ``` to plot the observables (rather than the species). -Observables can be defined using complicated expressions containing species, parameters, and [variables](@ref ref) (but not other observables). In the following example (which uses a [parametric stoichiometry](@ref ref)) `X` polymerises to form a complex `Xn` containing `n` copies of `X`. Here, we create an observable describing the total number of `X` molecules in the system: +Observables can be defined using complicated expressions containing species, parameters, and [variables](@ref constraint_equations) (but not other observables). In the following example (which uses a [parametric stoichiometry](@ref dsl_description_stoichiometries_parameters)) `X` polymerises to form a complex `Xn` containing `n` copies of `X`. Here, we create an observable describing the total number of `X` molecules in the system: ```@example dsl_advanced_observables rn = @reaction_network begin - @observables Xtot ~ X + n*Xn - (kB,kD), n*X <--> Xn + @observables Xtot ~ X + n*Xn + (kB,kD), n*X <--> Xn end nothing # hide ``` -!!! - If only a single observable is declared, the `begin .. end` block is not required and the observable can be declared directly after the `@observables` option. +!!! note + If only a single observable is declared, the `begin ... end` block is not required and the observable can be declared directly after the `@observables` option. [Metadata](@ref dsl_advanced_options_species_and_parameters_metadata) can be supplied to an observable directly after its declaration (but before its formula). If so, the metadata must be separated from the observable with a `,`, and the observable plus the metadata encapsulated by `()`. E.g. to add a [description metadata](@ref dsl_advanced_options_species_and_parameters_metadata) to our observable we can use ```@example dsl_advanced_observables rn = @reaction_network begin - @observables (Xtot, [description="The total amount of X in the system."]) ~ X + n*Xn - (kB,kD), n*X <--> Xn + @observables (Xtot, [description="The total amount of X in the system."]) ~ X + n*Xn + (kB,kD), n*X <--> Xn end nothing # hide ``` -Observables are by default considered [variables](@ref ref) (not species). To designate them as a species, they can be pre-declared using the `@species` option. I.e. Here `Xtot` becomes a species: +Observables are by default considered [variables](@ref constraint_equations) (not species). To designate them as a species, they can be pre-declared using the `@species` option. I.e. Here `Xtot` becomes a species: ```@example dsl_advanced_observables rn = @reaction_network begin - @species Xtot(t) - @observables Xtot ~ X + n*XnXY - (kB,kD), n*X <--> Xn + @species Xtot(t) + @observables Xtot ~ X + n*Xn + (kB,kD), n*X <--> Xn end nothing # hide ``` @@ -388,20 +388,20 @@ Some final notes regarding observables: - The left-hand side of the observable declaration must contain a single symbol only (with the exception of metadata, which can also be supplied). - All quantities appearing on the right-hand side must be declared elsewhere within the `@reaction_network` call (either by being part of a reaction, or through the `@species`, `@parameters`, or `@variables` options). - Observables may not depend on other observables. -- Observables have their [dependent variable(s)](@ref ref) automatically assigned as the union of the dependent variables of the species and variables on which it depends. +- Observables have their dependent variable(s) automatically assigned as the union of the dependent variables of the species and variables on which it depends. ## [Specifying non-time independent variables](@id dsl_advanced_options_ivs) -As [described elsewhere](@ref ref), Catalyst's `ReactionSystem` models depend on a *time independent variable*, and potentially one or more *spatial independent variables*. By default, the independent variable `t` is used. We can declare another independent variable (which is automatically used as the default one) using the `@ivs` option. E.g. to use `τ` instead of `t` we can use +Catalyst's `ReactionSystem` models depend on a *time independent variable*, and potentially one or more *spatial independent variables*. By default, the independent variable `t` is used. We can declare another independent variable (which is automatically used as the default one) using the `@ivs` option. E.g. to use `τ` instead of `t` we can use ```@example dsl_advanced_ivs using Catalyst # hide rn = @reaction_network begin - @ivs τ - (ka,kD), Xi <--> Xa + @ivs τ + (ka,kD), Xᵢ <--> Xₐ end nothing # hide ``` -We can confirm that `Xi` and `Xa` depend on `τ` (and not `t`): +We can confirm that `Xᵢ` and `Xₐ` depend on `τ` (and not `t`): ```@example dsl_advanced_ivs species(rn) ``` @@ -409,19 +409,19 @@ species(rn) It is possible to designate several independent variables using `@ivs`. If so, the first one is considered the default (time) independent variable, while the following one(s) are considered spatial independent variable(s). If we want some species to depend on a non-default independent variable, this has to be explicitly declared: ```@example dsl_advanced_ivs rn = @reaction_network begin - @ivs τ x - @species X(τ) Y(x) - (p1,d1), 0 <--> X - (p2,d2), 0 <--> Y + @ivs τ x + @species X(τ) Y(x) + (p1,d1), 0 <--> X + (p2,d2), 0 <--> Y end species(rn) ``` It is also possible to have species which depends on several independent variables: ```@example dsl_advanced_ivs rn = @reaction_network begin - @ivs t x - @species Xi(t,x) Xa(t,x) - (ka,kD), Xi <--> Xa + @ivs t x + @species Xᵢ(t,x) Xₐ(t,x) + (ka,kD), Xᵢ <--> Xₐ end species(rn) ``` @@ -435,8 +435,8 @@ It is possible to supply reactions with *metadata*, containing some additional i ```@example dsl_advanced_reaction_metadata using Catalyst # hide bd_model = @reaction_network begin - p, 0 --> X, [description="A production reaction"] - d, X --> 0, [description="A degradation reaction"] + p, 0 --> X, [description="Production reaction"] + d, X --> 0, [description="Degradation reaction"] end nothing # hide ``` @@ -444,7 +444,7 @@ nothing # hide When [bundling reactions](@ref dsl_description_reaction_bundling), reaction metadata can be bundled using the same rules as rates. Bellow we re-declare our birth-death process, but on a single line: ```@example dsl_advanced_reaction_metadata bd_model = @reaction_network begin - (p,d), 0 --> X, ([description="A production reaction"], [description="A degradation reaction"]) + (p,d), 0 <--> X, ([description="Production reaction"], [description="Degradation reaction"]) end nothing # hide ``` @@ -452,8 +452,8 @@ nothing # hide Here we declare a model where we also provide a `misc` metadata (which can hold any quantity we require) to our birth reaction: ```@example dsl_advanced_reaction_metadata bd_model = @reaction_network begin - p, 0 --> X, [description="A production reaction", misc=:value] - d, X --> 0, [description="A degradation reaction"] + p, 0 --> X, [description="Production reaction", misc=:value] + d, X --> 0, [description="Degradation reaction"] end nothing # hide ``` @@ -464,4 +464,76 @@ rx = @reaction p, 0 --> X, [description="A production reaction"] Catalyst.getdescription(rx) ``` -A list of all available reaction metadata can be found [here](@ref ref). \ No newline at end of file +## [Working with symbolic variables and the DSL](@id dsl_advanced_options_symbolics_and_DSL) +We have previously described how Catalyst represents its models symbolically (enabling e.g. symbolic differentiation of expressions stored in models). While Catalyst utilises this for many internal operation, these symbolic representations can also be accessed and harnessed by the user. Primarily, doing so is much easier during programmatic (as opposed to DSL-based) modelling. Indeed, the section on [programmatic modelling](@ref programmatic_CRN_construction) goes into more details about symbolic representation in models, and how these can be used. It is, however, also ways to utilise these methods during DSL-based modelling. Below we briefly describe two methods for doing so. + +### [Using `@unpack` to extract symbolic variables from `ReactionSystem`s](@id dsl_advanced_options_symbolics_and_DSL_unpack) +Let us consider a simple [birth-death process](@ref basic_CRN_library_bd) created using the DSL: +```@example dsl_advanced_programmatic_unpack +using Catalyst # hide +bd_model = @reaction_network begin + (p,d), 0 <--> X +end +nothing # hide +``` +Since we have not explicitly declared `p`, `d`, and `X` using `@parameters` and `@species`, we cannot represent these symbolically (only using `Symbol`s). If we wish to do so, however, we can fetch these into our current scope using the `@unpack` macro: +```@example dsl_advanced_programmatic_unpack +@unpack p, d, X = bd_model +nothing # hide +``` +This lists first the quantities we wish to fetch (does not need to be the model's full set of parameters and species), then `=`, followed by the model variable. `p`, `d` and `X` are now symbolic variables in the current scope, just as if they had been declared using `@parameters` or `@species`. We can confirm this: +```@example dsl_advanced_programmatic_unpack +X +``` +Next, we can now use these to e.g. designate initial conditions and parameter values for model simulations: +```@example dsl_advanced_programmatic_unpack +using OrdinaryDiffEq, Plots # hide +u0 = [X => 0.1] +tspan = (0.0, 10.0) +ps = [p => 1.0, d => 0.2] +oprob = ODEProblem(bd_model, u0, tspan, ps) +sol = solve(oprob) +plot(sol) +``` + +!!! warning + Just like when using `@parameters` and `@species`, `@unpack` will overwrite any variables in the current scope which share name with the imported quantities. + +### [Interpolating variables into the DSL](@id dsl_advanced_options_symbolics_and_DSL_interpolation) +Catalyst's DSL allows Julia variables to be interpolated for the network name, within rate constant expressions, or for species/stoichiometries within reactions. Using the lower-level symbolic interface we can then define symbolic variables and parameters outside of `@reaction_network`, which can then be used within expressions in the DSL. + +Interpolation is carried out by pre-appending the interpolating variable with a `$`. For example, here we declare the parameters and species of a birth-death model, and interpolate these into the model: +```@example dsl_advanced_programmatic_interpolation +using Catalyst # hide +t = default_t() +@species X(t) +@parameters p d +bd_model = @reaction_network begin + ($p, $d), 0 <--> $X +end +``` +Additional information (such as default values or metadata) supplied to `p`, `d`, and `X` is carried through to the DSL. However, interpolation for this purpose is of limited value, as such information [can be declared within the DSL](@ref dsl_advanced_options_declaring_species_and_parameters). However, it is possible to interpolate larger algebraic expressions into the DSL, e.g. here +```@example dsl_advanced_programmatic_interpolation +@species X1(t) X2(t) X3(t) E(t) +@parameters d +d_rate = d/(1 + E) +degradation_model = @reaction_network begin + $d_rate, X1 --> 0 + $d_rate, X2 --> 0 + $d_rate, X3 --> 0 +end +``` +we declare an expression `d_rate`, which then can be inserted into the DSL via interpolation. + +It is also possible to use interpolation in combination with the `@reaction` macro. E.g. the reactions of the above network can be declared individually using +```@example dsl_advanced_programmatic_interpolation +rxs = [ + @reaction $d_rate, $X1 --> 0 + @reaction $d_rate, $X2 --> 0 + @reaction $d_rate, $X3 --> 0 +] +nothing # hide +``` + +!!! note + When using interpolation, expressions like `2$spec` won't work; the multiplication symbol must be explicitly included like `2*$spec`. \ No newline at end of file diff --git a/docs/src/model_creation/dsl_basics.md b/docs/src/model_creation/dsl_basics.md index 19057d2d65..409e3b1f95 100644 --- a/docs/src/model_creation/dsl_basics.md +++ b/docs/src/model_creation/dsl_basics.md @@ -1,7 +1,7 @@ # [The Catalyst DSL - Introduction](@id dsl_description) In the [introduction to Catalyst](@ref introduction_to_catalyst) we described how the `@reaction_network` [macro](https://docs.julialang.org/en/v1/manual/metaprogramming/#man-macros) can be used to create chemical reaction network (CRN) models. This macro enables a so-called [domain-specific language](https://en.wikipedia.org/wiki/Domain-specific_language) (DSL) for creating CRN models. This tutorial will give a basic introduction on how to create Catalyst models using this macro (from now onwards called "*the Catalyst DSL*"). A [follow-up tutorial](@ref dsl_advanced_options) will describe some of the DSL's more advanced features. -The Catalyst DSL generates a [`ReactionSystem`](@ref) (the [julia structure](https://docs.julialang.org/en/v1/manual/types/#Composite-Types) Catalyst uses to represent CRN models). These can be created through alternative methods (e.g. [programmatically](@ref programmatic_CRN_construction) or [compositionally](@ref compositional_modeling)). A summary of the various ways to create `ReactionSystems`s can be found [here](@ref ref). [Previous](@ref ref) and [following](@ref ref) tutorials describe how to simulate models once they have been created using the DSL. This tutorial will solely focus on model creation. +The Catalyst DSL generates a [`ReactionSystem`](@ref) (the [julia structure](https://docs.julialang.org/en/v1/manual/types/#Composite-Types) Catalyst uses to represent CRN models). These can be created through alternative methods (e.g. [programmatically](@ref programmatic_CRN_construction) or [compositionally](@ref compositional_modeling)). [Previous](@ref introduction_to_catalyst) and [following](@ref simulation_intro) tutorials describe how to simulate models once they have been created using the DSL. This tutorial will solely focus on model creation. Before we begin, we will first load the Catalyst package (which is required to run the code). ```@example dsl_basics_intro @@ -9,21 +9,22 @@ using Catalyst ``` ### [Quick-start summary](@id dsl_description_quick_start) -The DSL is initiated through the `@reaction_network` macro, which is followed by one line for each reaction. Each reaction consists of a *rate*, followed lists first of the substrates and next of the products. E.g. a [Michaelis-Menten enzyme kinetics system](@ref ref) can be written as +The DSL is initiated through the `@reaction_network` macro, which is followed by one line for each reaction. Each reaction consists of a *rate*, followed lists first of the substrates and next of the products. E.g. a [Michaelis-Menten enzyme kinetics system](@ref basic_CRN_library_mm) can be written as ```@example dsl_basics_intro rn = @reaction_network begin - (kB,kD), S + E <--> SE - kP, SE --> P + E + (kB,kD), S + E <--> SE + kP, SE --> P + E end ``` -Here, `<-->` is used to create a bi-directional reaction (with forward rate `kP` and backward rate `kD`). Next, the model (stored in the variable `rn`) can be used as input to various types of [simulations](@ref ref). +Here, `<-->` is used to create a bi-directional reaction (with forward rate `kP` and backward rate `kD`). Next, the model (stored in the variable `rn`) can be used as input to various types of [simulations](@ref simulation_intro). ## [Basic syntax](@id dsl_description_basic_syntax) The basic syntax of the DSL is ```@example dsl_basics +using Catalyst # hide rn = @reaction_network begin - 2.0, X --> Y - 1.0, Y --> X + 2.0, X --> Y + 1.0, Y --> X end ``` Here, you start with `@reaction_network begin`, next list all of the model's reactions, and finish with `end`. Each reaction consists of @@ -36,23 +37,22 @@ Each reaction line declares, in order, the rate, the substrate(s), and the produ Finally, `rn = ` is used to store the model in the variable `rn` (a normal Julia variable, which does not need to be called `rn`). ## [Defining parameters and species in the DSL](@id dsl_description_parameters_basics) -Typically, the rates are not constants, but rather parameters (which values can be set e.g. at [the beginning of each simulation](@ref ref)). To set parametric rates, simply use whichever symbol you wish to represent your parameter with. E.g. to set the above rates to `a` and `b`, we use: +Typically, the rates are not constants, but rather parameters (which values can be set e.g. at [the beginning of each simulation](@ref simulation_intro_ODEs)). To set parametric rates, simply use whichever symbol you wish to represent your parameter with. E.g. to set the above rates to `a` and `b`, we use: ```@example dsl_basics rn1 = @reaction_network begin - a, X --> Y - b, Y --> X + a, X --> Y + b, Y --> X end ``` Here we have used single-character symbols to designate all species and parameters. Multi-character symbols, however, are also permitted. E.g. we could call the rates `kX` and `kY`: ```@example dsl_basics rn1 = @reaction_network begin - kX, X --> Y - kY, Y --> X + kX, X --> Y + kY, Y --> X end -nothing # hide ``` -Generally, anything that is a [permitted Julia variable name](@id https://docs.julialang.org/en/v1/manual/variables/#man-allowed-variable-names) can be used to designate a species or parameter in Catalyst. +Generally, anything that is a [permitted Julia variable name](https://docs.julialang.org/en/v1/manual/variables/#man-allowed-variable-names) can be used to designate a species or parameter in Catalyst. ## [Different types of reactions](@id dsl_description_reactions) @@ -60,14 +60,14 @@ Generally, anything that is a [permitted Julia variable name](@id https://docs.j Previously, our reactions have had a single substrate and a single product. However, reactions with multiple substrates and/or products are possible. Here, all the substrates (or products) are listed and separated by a `+`. E.g. to create a model where `X` and `Y` bind (at rate `kB`) to form `XY` (which then can dissociate, at rate `kD`, to form `XY`) we use: ```@example dsl_basics rn2 = @reaction_network begin - kB, X + Y --> XY - kD, XY --> X + Y + kB, X + Y --> XY + kD, XY --> X + Y end ``` Reactions can have any number of substrates and products, and their names do not need to have any relationship to each other, as demonstrated by the following mock model: ```@example dsl_basics rn3 = @reaction_network begin - k, X + Y + Z --> A + B + C + D + k, X + Y + Z --> A + B + C + D end ``` @@ -75,8 +75,8 @@ end Some reactions have no products, in which case the substrate(s) are degraded (i.e. removed from the system). To denote this, set the reaction's right-hand side to `0`. Similarly, some reactions have no substrates, in which case the product(s) are produced (i.e. added to the system). This is denoted by setting the left-hand side to `0`. E.g. to create a model where a single species `X` is both created (in the first reaction) and degraded (in a second reaction), we use: ```@example dsl_basics rn4 = @reaction_network begin - p, 0 --> X - d, X --> 0 + p, 0 --> X + d, X --> 0 end ``` @@ -84,19 +84,18 @@ end Reactions may include multiple copies of the same reactant (i.e. a substrate or a product). To specify this, the reactant is preceded by a number indicating its number of copies (also called the reactant's *stoichiometry*). E.g. to create a model where two copies of `X` dimerise to form `X2` (which then dissociate back to two `X` copies) we use: ```@example dsl_basics rn5 = @reaction_network begin - kB, 2X --> X2 - kD, X2 --> 2X + kB, 2X --> X2 + kD, X2 --> 2X end ``` -Reactants whose stoichiometries are not defined are assumed to have stoichiometry `1`. Any integer number can be used, furthermore, [decimal numbers and parameters can also be used as stoichiometries](@ref ref). A discussion of non-unitary (i.e. not equal to `1`) stoichiometries affecting the created model can be found [here](@ref ref). +Reactants whose stoichiometries are not defined are assumed to have stoichiometry `1`. Any integer number can be used, furthermore, [decimal numbers and parameters can also be used as stoichiometries](@ref dsl_description_stoichiometries). A discussion of non-unitary (i.e. not equal to `1`) stoichiometries affecting the created model can be found [here](@ref introduction_to_catalyst_ratelaws). Stoichiometries can be combined with `()` to define them for multiple reactants. Here, the following (mock) model declares the same reaction twice, both with and without this notation: ```@example dsl_basics rn6 = @reaction_network begin - k, 2X + 3(Y + 2Z) --> 5(V + W) - k, 2X + 3Y + 6Z --> 5V + 5W + k, 2X + 3(Y + 2Z) --> 5(V + W) + k, 2X + 3Y + 6Z --> 5V + 5W end -nothing # hide ``` ## [Bundling of similar reactions](@id dsl_description_reaction_bundling) @@ -105,14 +104,14 @@ nothing # hide As is the case for the following two-state model: ```@example dsl_basics rn7 = @reaction_network begin - k1, X1 --> X2 - k2, X2 --> X1 + k1, X1 --> X2 + k2, X2 --> X1 end ``` it is common that reactions occur in both directions (so-called *bi-directional* reactions). Here, it is possible to bundle the reactions into a single line by using the `<-->` arrow. When we do this, the rate term must include two separate rates (one for each direction, these are enclosed by a `()` and separated by a `,`). I.e. the two-state model can be declared using: ```@example dsl_basics rn7 = @reaction_network begin - (k1,k2), X1 <--> X2 + (k1,k2), X1 <--> X2 end ``` Here, the first rate (`k1`) denotes the *forward rate* and the second rate (`k2`) the *backwards rate*. @@ -120,13 +119,13 @@ Here, the first rate (`k1`) denotes the *forward rate* and the second rate (`k2` Catalyst also permits writing pure backwards reactions. These use identical syntax to forward reactions, but with the `<--` arrow: ```@example dsl_basics rn8 = @reaction_network begin - k, X <-- Y + k, X <-- Y end ``` Here, the substrate(s) are on the right-hand side and the product(s) are on the left-hand side. Hence, the above model can be written identically using: ```@example dsl_basics rn8 = @reaction_network begin - k, Y --> X + k, Y --> X end ``` Generally, using forward reactions is clearer than backwards ones, with the latter typically never being used. @@ -135,49 +134,49 @@ Generally, using forward reactions is clearer than backwards ones, with the latt There exist additional situations where models contain similar reactions (e.g. systems where all system components degrade at identical rates). Reactions which share either rates, substrates, or products can be bundled into a single line. Here, the parts which are different for the reactions are written using `(,)` (containing one separate expression for each reaction). E.g., let us consider the following model where species `X` and `Y` both degrade at the rate `d`: ```@example dsl_basics rn8 = @reaction_network begin - d, X --> 0 - d, Y --> 0 + d, X --> 0 + d, Y --> 0 end ``` These share both their rates (`d`) and products (`0`), however, the substrates are different (`X` and `Y`). Hence, the reactions can be bundled into a single line using the common rate and product expression while providing separate substrate expressions: ```@example dsl_basics rn8 = @reaction_network begin - d, (X,Y) --> 0 + d, (X,Y) --> 0 end ``` This declaration of the model is identical to the previous one. Reactions can share any subset of the rate, substrate, and product expression (the cases where they share all or none, however, do not make sense to use). I.e. if the two reactions also have different degradation rates: ```@example dsl_basics rn9 = @reaction_network begin - dX, X --> 0 - dY, Y --> 0 + dX, X --> 0 + dY, Y --> 0 end ``` This can be represented using: ```@example dsl_basics rn9 = @reaction_network begin - (dX,dY), (X,Y) --> 0 + (dX,dY), (X,Y) --> 0 end ``` It is possible to use bundling for any number of reactions. E.g. in the following model we bundle the conversion of a species $X$ between its various forms (where all reactions use the same rate $k$): ```@example dsl_basics rn10 = @reaction_network begin - k, (X0,X1,X2,X3) --> (X1,X2,X3,X4) + k, (X0,X1,X2,X3) --> (X1,X2,X3,X4) end ``` It is possible to combine bundling with bi-directional reactions. In this case, the rate is first split into the forward and backwards rates. These may then (or may not) indicate several rates. We exemplify this using the two following two (identical) networks, created with and without bundling. ```@example dsl_basics rn11 = @reaction_network begin - kf, S --> P1 - kf, S --> P2 - kb_1, P1 --> S - kb_2, P2 --> S + kf, S --> P1 + kf, S --> P2 + kb_1, P1 --> S + kb_2, P2 --> S end ``` ```@example dsl_basics rn11 = @reaction_network begin - (kf, (kb_1, kb_2)), S <--> (P1,P2) + (kf, (kb_1, kb_2)), S <--> (P1,P2) end ``` @@ -187,7 +186,7 @@ rn12 = @reaction_network begin ((pX, pY, pZ),d), (0, Y0, Z0) <--> (X, Y, Z1+Z2) end ``` -However, like for the above model, bundling reactions too zealously can reduce (rather than improve) a model's readability. +However, like for the above model, bundling reactions too zealously can reduce (rather than improve) a model's readability. ## [Non-constant reaction rates](@id dsl_description_nonconstant_rates) So far we have assumed that all reaction rates are constant (being either a number of a parameter). Non-constant rates that depend on one (or several) species are also possible. More generally, the rate can be any valid expression of parameters and species. @@ -199,10 +198,10 @@ rn_13 = @reaction_network begin kP*A, 0 --> P end ``` -Here, `P`'s production rate will be reduced as `A` decays. We can [print the ODE this model produces with `Latexify`](@ref ref): +Here, `P`'s production rate will be reduced as `A` decays. We can [print the ODE this model produces with `Latexify`](@ref visualisation_latex): ```@example dsl_basics using Latexify -latexify(rn_13; form=:ode) +latexify(rn_13; form = :ode) ``` In this case, we can generate an equivalent model by instead adding `A` as both a substrate and a product to `P`'s production reaction: @@ -214,26 +213,26 @@ end ``` We can confirm that this generates the same ODE: ```@example dsl_basics -latexify(rn_13_alt; form=:ode) +latexify(rn_13_alt; form = :ode) ``` Here, while these models will generate identical ODE, SDE, and jump simulations, the chemical reaction network models themselves are not equivalent. Generally, as pointed out in the two notes below, using the second form is preferable. -!!! warn - While `rn_13` and `rn_13_alt` will generate equivalent simulations, for jump simulations, the first model will have [reduced performance](@ref ref) (which generally are more performant when rates are constant). +!!! warning + While `rn_13` and `rn_13_alt` will generate equivalent simulations, for jump simulations, the first model will have reduced performance as it generates a less performant representation of the system in JumpProcesses. It is generally recommended to write pure mass action reactions such that there is just a single constant within the rate constant expression for optimal performance of jump process simulations. !!! danger - Catalyst automatically infers whether quantities appearing in the DSL are species or parameters (as described [here](@ref dsl_advanced_options_declaring_species_and_parameters)). Generally, anything that does not appear as a reactant is inferred to be a parameter. This means that if you want to model a reaction activated by a species (e.g. `kp*A, 0 --> P`), but that species does not occur as a reactant, it will be interpreted as a parameter. This can be handled by [manually declaring the system species](@ref dsl_advanced_options_declaring_species_and_parameters). A full example of how to do this for this example can be found [here](@ref ref). + Catalyst automatically infers whether quantities appearing in the DSL are species or parameters (as described [here](@ref dsl_advanced_options_declaring_species_and_parameters)). Generally, anything that does not appear as a reactant is inferred to be a parameter. This means that if you want to model a reaction activated by a species (e.g. `kp*A, 0 --> P`), but that species does not occur as a reactant, it will be interpreted as a parameter. This can be handled by [manually declaring the system species](@ref dsl_advanced_options_declaring_species_and_parameters). Above we used a simple example where the rate was the product of a species and a parameter. However, any valid Julia expression of parameters, species, and values can be used. E.g the following is a valid model: ```@example dsl_basics rn_14 = @reaction_network begin - 2.0 + X^2, 0 --> X + Y - k1 + k2^k3, X --> ∅ - pi * X/(sqrt(2) + Y), Y → ∅ + 2.0 + X^2, 0 --> X + Y + k1 + k2^k3, X --> ∅ + pi * X/(sqrt(2) + Y), Y → ∅ end ``` ### [Using functions in rates](@id dsl_description_nonconstant_rates_functions) -It is possible for the rate to contain Julia functions. These can either be functions from Julia's standard library: +It is possible for the rate to contain Julia functions. These can either be functions from Julia's standard library: ```@example dsl_basics rn_16 = @reaction_network begin d, A --> 0 @@ -266,7 +265,7 @@ Catalyst comes with the following predefined functions: - The activating/repressive Hill function: $hillar(X,Y,v,K,n) = v * (X^n)/(X^n + Y^n + K^n)$. ### [Time-dependant rates](@id dsl_description_nonconstant_rates_time) -Previously we have assumed that the rates are independent of the [time variable, $t$](@ref ref). However, time-dependent reactions are also possible. Here, simply use `t` to represent the time variable. E.g., to create a production/degradation model where the production rate decays as time progresses, we can use: +Previously we have assumed that the rates are independent of the time variable, $t$. However, time-dependent reactions are also possible. Here, simply use `t` to represent the time variable. E.g., to create a production/degradation model where the production rate decays as time progresses, we can use: ```@example dsl_basics rn_14 = @reaction_network begin kp/(1 + t), 0 --> P @@ -282,8 +281,12 @@ rn_15 = @reaction_network begin end ``` -!!! warn - Jump simulations cannot be performed for models with time-dependent rates without additional considerations, which are discussed [here](@ref ref). +!!! warning + Models with explicit time-dependent rates require additional steps to correctly + convert to stochastic chemical kinetics jump process representations. See + [here](https://github.com/SciML/Catalyst.jl/issues/636#issuecomment-1500311639) + for guidance on manually creating such representations. Enabling + Catalyst to handle this seamlessly is work in progress. ## [Non-standard stoichiometries](@id dsl_description_stoichiometries) @@ -295,7 +298,7 @@ rn_16 = @reaction_network begin d, X --> 0 end ``` -It is also possible to have non-integer stoichiometric coefficients for substrates. However, in this case the [`combinatoric_ratelaw = false`](@ref ref) option must be used. We note that non-integer stoichiometric coefficients do not make sense in most fields, however, this feature is available for use for models where it does make sense. +It is also possible to have non-integer stoichiometric coefficients for substrates. However, in this case the `combinatoric_ratelaw = false` option must be used. We note that non-integer stoichiometric coefficients do not make sense in most fields, however, this feature is available for use for models where it does make sense. ### [Parametric stoichiometries](@id dsl_description_stoichiometries_parameters) It is possible for stoichiometric coefficients to be parameters. E.g. here we create a generic polymerisation system where `n` copies of `X` bind to form `Xn`: @@ -319,8 +322,8 @@ Julia permits any Unicode characters to be used in variable names, thus Catalyst Previously, we described how `0` could be used to [create degradation or production reactions](@ref dsl_description_reactions_degradation_and_production). Catalyst permits the user to instead use the `∅` symbol. E.g. the production/degradation system can alternatively be written as: ```@example dsl_basics rn4 = @reaction_network begin - p, ∅ --> X - d, X --> ∅ + p, ∅ --> X + d, X --> ∅ end ``` @@ -328,13 +331,13 @@ end Catalyst uses `-->`, `<-->`, and `<--` to denote forward, bi-directional, and backwards reactions, respectively. Several unicode representations of these arrows are available. Here, - `>`, `→`, `↣`, `↦`, `⇾`, `⟶`, `⟼`, `⥟`, `⥟`, `⇀`, and `⇁` can be used to represent forward reactions. - `↔`, `⟷`, `⇄`, `⇆`, `⇌`, `⇋`, , and `⇔` can be used to represent bi-directional reactions. -- `<`, `←`, `↢`, `↤`, `⇽`, `⟵`, `⟻`, `⥚`, `⥞`, `↼`, , and `↽` can be used to represent backwards reactions. +- `<`, `←`, `↢`, `↤`, `⇽`, `⟵`, `⟻`, `⥚`, `⥞`, `↼`, , and `↽` can be used to represent backwards reactions. E.g. the production/degradation system can alternatively be written as: ```@example dsl_basics rn4 = @reaction_network begin - p, ∅ → X - d, X → ∅ + p, ∅ → X + d, X → ∅ end ``` @@ -348,18 +351,20 @@ A range of possible characters are available which can be incorporated into spec An example of how this can be used to create a neat-looking model can be found in [Schwall et al. (2021)](https://www.embopress.org/doi/full/10.15252/msb.20209832) where it was used to model a sigma factor V circuit in the bacteria *Bacillus subtilis*: ```@example dsl_basics σᵛ_model = @reaction_network begin - v₀ + hill(σᵛ,v,K,n), ∅ → σᵛ + A - kdeg, (σᵛ, A, Aσᵛ) → ∅ - (kB,kD), A + σᵛ ↔ Aσᵛ - L, Aσᵛ → σᵛ + v₀ + hill(σᵛ,v,K,n), ∅ → σᵛ + A + kdeg, (σᵛ, A, Aσᵛ) → ∅ + (kB,kD), A + σᵛ ↔ Aσᵛ + L, Aσᵛ → σᵛ end nothing # hide ``` This functionality can also be used to create less serious models: +```@example dsl_basics rn_13 = @reaction_network begin 🍦, 😢 --> 😃 end +``` It should be noted that the following symbols are *not permitted* to be used as species or parameter names: - `pi` and `π` (used in Julia to denote [`3.1415926535897...`](https://en.wikipedia.org/wiki/Pi)). @@ -368,5 +373,4 @@ It should be noted that the following symbols are *not permitted* to be used as - `∅` ([used for production/degradation reactions](@ref dsl_description_symbols_empty_set)). - `im` (used in Julia to represent [complex numbers](https://docs.julialang.org/en/v1/manual/complex-and-rational-numbers/#Complex-Numbers)). - `nothing` (used in Julia to denote [nothing](https://docs.julialang.org/en/v1/base/constants/#Core.nothing)). -- `Γ` (used by Catalyst to represent [conserved quantities](@ref ref)). - +- `Γ` (used by Catalyst to represent conserved quantities). diff --git a/docs/src/model_creation/examples/basic_CRN_library.md b/docs/src/model_creation/examples/basic_CRN_library.md index 4955aa522d..7279fa4f33 100644 --- a/docs/src/model_creation/examples/basic_CRN_library.md +++ b/docs/src/model_creation/examples/basic_CRN_library.md @@ -79,16 +79,15 @@ oplt = plot(osol; idxs = X₁ + X₂, title = "Reaction rate equation (ODE)") splt = plot(ssol; idxs = X₁ + X₂, title = "Chemical Langevin equation (SDE)") plot(oplt, splt; lw = 3, ylimit = (99,101), size = (800,450), layout = (2,1)) ``` -Catalyst has special methods for working with conserved quantities, which are described [here](@ref ref). ## [Michaelis-Menten enzyme kinetics](@id basic_CRN_library_mm) [Michaelis-Menten enzyme kinetics](https://en.wikipedia.org/wiki/Michaelis%E2%80%93Menten_kinetics) is a simple description of an enzyme ($E$) transforming a substrate ($S$) into a product ($P$). Under certain assumptions, it can be simplified to a single function (a Michaelis-Menten function) and used as a reaction rate. Here we instead present the full system model: ```@example crn_library_michaelis_menten using Catalyst mm_system = @reaction_network begin - kB, S + E --> SE - kD, SE --> S + E - kP, SE --> P + E + kB, S + E --> SE + kD, SE --> S + E + kP, SE --> P + E end ``` Next, we perform ODE, SDE, and jump simulations of the model: @@ -110,16 +109,19 @@ using JumpProcesses dprob = DiscreteProblem(mm_system, u0, tspan, ps) jprob = JumpProblem(mm_system, dprob, Direct()) jsol = solve(jprob, SSAStepper()) -jsol = solve(jprob, SSAStepper(); seed = 12) # hide +jsol = solve(jprob, SSAStepper(), seed = 12) # hide using Plots oplt = plot(osol; title = "Reaction rate equation (ODE)") splt = plot(ssol; title = "Chemical Langevin equation (SDE)") jplt = plot(jsol; title = "Stochastic chemical kinetics (Jump)") -plot(oplt, splt, jplt; lw = 2, size=(800,800), layout = (3,1)) +plot(oplt, splt, jplt; lw = 2, size=(800,800), layout = (3,1)) +oplt = plot(osol; title = "Reaction rate equation (ODE)", plotdensity = 1000, fmt = :png) # hide +splt = plot(ssol; title = "Chemical Langevin equation (SDE)", plotdensity = 1000, fmt = :png) # hide +jplt = plot(jsol; title = "Stochastic chemical kinetics (Jump)", plotdensity = 1000, fmt = :png) # hide +plot(oplt, splt, jplt; lw = 2, size=(800,800), layout = (3,1), plotdensity = 1000, fmt = :png) # hide plot!(bottom_margin = 3Plots.Measures.mm) # hide ``` -Note that, due to the large amounts of the species involved, teh stochastic trajectories are very similar to the deterministic one. ## [SIR infection model](@id basic_CRN_library_sir) The [SIR model](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology#The_SIR_model) is the simplest model of the spread of an infectious disease. While the real system is very different from the chemical and cellular processes typically modelled with CRNs, it (and several other epidemiological systems) can be modelled using the same CRN formalism. The SIR model consists of three species: susceptible ($S$), infected ($I$), and removed ($R$) individuals, and two reaction events: infection and recovery. @@ -146,19 +148,23 @@ Next, we perform 3 different Jump simulations. Note that for the stochastic mode ```@example crn_library_sir using JumpProcesses dprob = DiscreteProblem(sir_model, u0, tspan, ps) -jprob = JumpProblem(sir_model, dprob, Direct()) +jprob = JumpProblem(sir_model, dprob, Direct(); save_positions = (false, false)) -jsol1 = solve(jprob, SSAStepper()) -jsol2 = solve(jprob, SSAStepper()) -jsol3 = solve(jprob, SSAStepper()) -jsol1 = solve(jprob, SSAStepper(); seed=1) # hide -jsol2 = solve(jprob, SSAStepper(); seed=2) # hide -jsol3 = solve(jprob, SSAStepper(); seed=3) # hide +jsol1 = solve(jprob, SSAStepper(); saveat = 1.0) +jsol2 = solve(jprob, SSAStepper(); saveat = 1.0) +jsol3 = solve(jprob, SSAStepper(); saveat = 1.0) +jsol1 = solve(jprob, SSAStepper(); saveat = 1.0, seed = 1) # hide +jsol2 = solve(jprob, SSAStepper(); saveat = 1.0, seed = 2) # hide +jsol3 = solve(jprob, SSAStepper(); saveat = 1.0, seed = 3) # hide jplt1 = plot(jsol1; title = "Outbreak") jplt2 = plot(jsol2; title = "Outbreak") jplt3 = plot(jsol3; title = "No outbreak") plot(jplt1, jplt2, jplt3; lw = 3, size=(800,700), layout = (3,1)) +jplt1 = plot(jsol1; title = "Outbreak", plotdensity = 1000, fmt = :png) # hide +jplt2 = plot(jsol2; title = "Outbreak", plotdensity = 1000, fmt = :png) # hide +jplt3 = plot(jsol3; title = "No outbreak", plotdensity = 1000, fmt = :png) # hide +plot(jplt1, jplt2, jplt3; lw = 3, size=(800,700), layout = (3,1), plotdensity = 1000, fmt = :png) # hide ``` ## [Chemical cross-coupling](@id basic_CRN_library_cc) @@ -241,9 +247,9 @@ oprob = ODEProblem(sa_loop, u0, tspan, ps) osol = solve(oprob) dprob = DiscreteProblem(sa_loop, u0, tspan, ps) -jprob = JumpProblem(sa_loop, dprob, Direct()) -jsol = solve(jprob, SSAStepper()) -jsol = solve(jprob, SSAStepper(); seed = 12) # hide +jprob = JumpProblem(sa_loop, dprob, Direct(); save_positions = (false,false)) +jsol = solve(jprob, SSAStepper(); saveat = 10.0) +jsol = solve(jprob, SSAStepper(); saveat = 10.0, seed = 12) # hide plot(osol; lw = 3, label = "Reaction rate equation (ODE)") plot!(jsol; lw = 3, label = "Stochastic chemical kinetics (Jump)", yguide = "X", size = (800,350)) @@ -261,7 +267,7 @@ brusselator = @reaction_network begin 1, X --> ∅ end ``` -It is generally known to (for reaction rate equation-based ODE simulations) produce oscillations when $B > 1 + A^2$. However, this result is based on models generated when *combinatorial adjustment of rates is not performed*. Since Catalyst [automatically perform these adjustments](@ref ref), and one reaction contains a stoichiometric constant $>1$, the threshold will be different. Here, we trial two different values of $B$. In both cases, $B < 1 + A^2$, however, in the second case the system can generate oscillations. +It is generally known to (for reaction rate equation-based ODE simulations) produce oscillations when $B > 1 + A^2$. However, this result is based on models generated when *combinatorial adjustment of rates is not performed*. Since Catalyst [automatically perform these adjustments](@ref introduction_to_catalyst_ratelaws), and one reaction contains a stoichiometric constant $>1$, the threshold will be different. Here, we trial two different values of $B$. In both cases, $B < 1 + A^2$, however, in the second case the system can generate oscillations. ```@example crn_library_brusselator using OrdinaryDiffEq, Plots u0 = [:X => 1.0, :Y => 1.0] @@ -279,7 +285,7 @@ oplt2 = plot(osol2; title = "Oscillation") plot(oplt1, oplt2; lw = 3, size = (800,600), layout = (2,1)) ``` -## [The Repressilator](@id basic_CRN_library_) +## [The Repressilator](@id basic_CRN_library_repressilator) The Repressilator was introduced in [*Elowitz & Leibler (2000)*](https://www.nature.com/articles/35002125) as a simple system that can generate oscillations (most notably, they demonstrated this both in a model and in a synthetic in vivo implementation in *Escherichia col*). It consists of three genes, repressing each other in a cycle. Here, we will implement it using three species ($X$, $Y$, and $Z$) whose production rates are (repressing) [Hill functions](https://en.wikipedia.org/wiki/Hill_equation_(biochemistry)). ```@example crn_library_brusselator using Catalyst diff --git a/docs/src/model_creation/examples/hodgkin_huxley_equation.md b/docs/src/model_creation/examples/hodgkin_huxley_equation.md index ab1f072b6b..a2091035b1 100644 --- a/docs/src/model_creation/examples/hodgkin_huxley_equation.md +++ b/docs/src/model_creation/examples/hodgkin_huxley_equation.md @@ -13,7 +13,7 @@ cells such as neurons and muscle cells. We begin by importing some necessary packages. ```@example hh1 using ModelingToolkit, Catalyst, NonlinearSolve -using DifferentialEquations, Symbolics +using OrdinaryDiffEq, Symbolics using Plots t = default_t() D = default_time_deriv() @@ -77,6 +77,7 @@ I = I₀ * sin(2*pi*t/30)^2 # get the gating variables to use in the equation for dV/dt @unpack m,n,h = hhrn +Dₜ = default_time_deriv() eqs = [Dₜ(V) ~ -1/C * (ḡK*n^4*(V-EK) + ḡNa*m^3*h*(V-ENa) + ḡL*(V-EL)) + I/C] @named voltageode = ODESystem(eqs, t) nothing # hide @@ -88,6 +89,7 @@ Finally, we add this ODE into the reaction model as ```@example hh1 @named hhmodel = extend(voltageode, hhrn) +hhmodel = complete(hhmodel) nothing # hide ``` diff --git a/docs/src/model_creation/examples/programmatic_generative_linear_pathway.md b/docs/src/model_creation/examples/programmatic_generative_linear_pathway.md index ddd7993ed0..e6a12b876e 100644 --- a/docs/src/model_creation/examples/programmatic_generative_linear_pathway.md +++ b/docs/src/model_creation/examples/programmatic_generative_linear_pathway.md @@ -1,8 +1,8 @@ # [Programmatic, generative, modelling of a linear pathway](@id programmatic_generative_linear_pathway) -This example will show how to use programmatic, generative, modelling to model a system implicitly. I.e. rather than listing all system reactions explicitly, the reactions are implicitly generated from a simple set of rules. This example is specifically designed to show how [programmatic modelling](@ref ref) enables *generative workflows* (demonstrating one of its advantages as compared to [DSL-based modelling](@ref ref)). In our example, we will model linear pathways, so we will first introduce these. Next, we will model them first using the DSL, and then using a generative programmatic workflow. +This example will show how to use programmatic, generative, modelling to model a system implicitly. I.e. rather than listing all system reactions explicitly, the reactions are implicitly generated from a simple set of rules. This example is specifically designed to show how [programmatic modelling](@ref programmatic_CRN_construction) enables *generative workflows* (demonstrating one of its advantages as compared to [DSL-based modelling](@ref dsl_description)). In our example, we will model linear pathways, so we will first introduce these. Next, we will model them first using the DSL, and then using a generative programmatic workflow. ## [Linear pathways](@id programmatic_generative_linear_pathway_intro) -Linear pathways consists of a series of species ($X_0$, $X_1$, $X_2$, ..., $X_n$) where each activates the subsequent one. These are often modelled through the following reaction system: +Linear pathways consists of a series of species ($X_0$, $X_1$, $X_2$, ..., $X_n$) where each activates the subsequent one[^1]. These are often modelled through the following reaction system: ```math X_{i-1}/\tau,\hspace{0.33cm} ∅ \to X_{i}\\ 1/\tau,\hspace{0.33cm} X_{i} \to ∅ @@ -21,7 +21,7 @@ for some kernel $g(\tau)$. Here, a common kernel is a [gamma distribution](https ```math g(\tau; \alpha, \beta) = \frac{\beta^{\alpha}\tau^{\alpha-1}}{\Gamma(\alpha)}e^{-\beta\tau} ``` -When this is converted to an ODE, this generates an integro-differential equation. These (as well as the simpler delay differential equations) can be difficult to solve and analyse (especially when SDE or jump simulations are desired). Here, *the linear chain trick* can be used to instead model the delay as a linear pathway of the form described above[^1]. A result by Fargue shows that this is equivalent to a gamma-distributed delay, where $\alpha$ is equivalent to $n$ (the number of species in our linear pathway) and $\beta$ to %\tau$ (the delay length term)[^2]. While modelling time delays using the linear chain trick introduces additional system species, it is often advantageous as it enables simulations using standard ODE, SDE, and Jump methods. +When this is converted to an ODE, this generates an integro-differential equation. These (as well as the simpler delay differential equations) can be difficult to solve and analyse (especially when SDE or jump simulations are desired). Here, *the linear chain trick* can be used to instead model the delay as a linear pathway of the form described above[^2]. A result by Fargue shows that this is equivalent to a gamma-distributed delay, where $\alpha$ is equivalent to $n$ (the number of species in our linear pathway) and $\beta$ to %\tau$ (the delay length term)[^3]. While modelling time delays using the linear chain trick introduces additional system species, it is often advantageous as it enables simulations using standard ODE, SDE, and Jump methods. ## [Modelling linear pathways using the DSL](@id programmatic_generative_linear_pathway_dsl) It is known that two linear pathways have similar delays if the following equality holds: @@ -75,11 +75,13 @@ plot!(sol_n10; idxs = :X10, label = "n = 10") ``` ## [Modelling linear pathways using programmatic, generative, modelling](@id programmatic_generative_linear_pathway_generative) -Above, we investigated the impact of linear pathways' lengths on their behaviours. Since the models were implemented using the DSL, we had to implement a new model for each pathway (in each case writing out all reactions). Here, we will instead show how [programmatic modelling](@ref ref) can be used to generate pathways of arbitrary lengths. +Above, we investigated the impact of linear pathways' lengths on their behaviours. Since the models were implemented using the DSL, we had to implement a new model for each pathway (in each case writing out all reactions). Here, we will instead show how [programmatic modelling](@ref programmatic_CRN_construction) can be used to generate pathways of arbitrary lengths. -First, we create a function, `generate_lp`, which creates a linear pathway model of length `n`. It utilises [*vector variables*](@ref ref) to create an arbitrary number of species, and also creates an [observable](@ref ref) for the final species of the chain. +First, we create a function, `generate_lp`, which creates a linear pathway model of length `n`. It utilises *vector variables* to create an arbitrary number of species, and also creates an observable for the final species of the chain. ```@example programmatic_generative_linear_pathway_generative using Catalyst # hide +t = default_t() +@parameters τ function generate_lp(n) # Creates a vector `X` with n+1 species. @species X(t)[1:n+1] @@ -115,6 +117,7 @@ nothing # hide ``` We can now simulate linear pathways of arbitrary lengths using a simple syntax. We use this to recreate our previous result from the DSL: ```@example programmatic_generative_linear_pathway_generative +using OrdinaryDiffEq, Plots # hide sol_n3 = solve(generate_oprob(3)) sol_n10 = solve(generate_oprob(10)) plot(sol_n3; idxs = :Xend, label = "n = 3") @@ -129,6 +132,6 @@ plot!(sol_n20; idxs = :Xend, label = "n = 20") --- ## References -[^1]: [J. Metz, O. Diekmann *The Abstract Foundations of Linear Chain Trickery* (1991).](https://ir.cwi.nl/pub/1559/1559D.pdf) -[^2]: D. Fargue *Reductibilite des systemes hereditaires a des systemes dynamiques (regis par des equations differentielles aux derivees partielles)*, Comptes rendus de l'Académie des Sciences (1973). -[^3]: [N. Korsbo, H. Jönsson *It’s about time: Analysing simplifying assumptions for modelling multi-step pathways in systems biology*, PLoS Computational Biology (2020).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1007982) \ No newline at end of file +[^1]: [N. Korsbo, H. Jönsson *It’s about time: Analysing simplifying assumptions for modelling multi-step pathways in systems biology*, PLoS Computational Biology (2020).](https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1007982) +[^2]: [J. Metz, O. Diekmann *The Abstract Foundations of Linear Chain Trickery* (1991).](https://ir.cwi.nl/pub/1559/1559D.pdf) +[^3]: D. Fargue *Reductibilite des systemes hereditaires a des systemes dynamiques (regis par des equations differentielles aux derivees partielles)*, Comptes rendus de l'Académie des Sciences (1973). \ No newline at end of file diff --git a/docs/src/model_creation/examples/smoluchowski_coagulation_equation.md b/docs/src/model_creation/examples/smoluchowski_coagulation_equation.md index 8e9dd34ecb..d8865651b4 100644 --- a/docs/src/model_creation/examples/smoluchowski_coagulation_equation.md +++ b/docs/src/model_creation/examples/smoluchowski_coagulation_equation.md @@ -4,72 +4,89 @@ This tutorial shows how to programmatically construct a [`ReactionSystem`](@ref) The Smoluchowski coagulation equation describes a system of reactions in which monomers may collide to form dimers, monomers and dimers may collide to form trimers, and so on. This models a variety of chemical/physical processes, including polymerization and flocculation. We begin by importing some necessary packages. -```julia +```@example smcoag1 using ModelingToolkit, Catalyst, LinearAlgebra -using DiffEqBase, JumpProcesses +using JumpProcesses using Plots, SpecialFunctions ``` Suppose the maximum cluster size is `N`. We assume an initial concentration of monomers, `Nₒ`, and let `uₒ` denote the initial number of monomers in the system. We have `nr` total reactions, and label by `V` the bulk volume of the system (which plays an important role in the calculation of rate laws since we have bimolecular reactions). Our basic parameters are then -```julia -## Parameter -N = 10 # maximum cluster size -Vₒ = (4π/3)*(10e-06*100)^3 # volume of a monomers in cm³ -Nₒ = 1e-06/Vₒ # initial conc. = (No. of init. monomers) / bulk volume -uₒ = 10000 # No. of monomers initially -V = uₒ/Nₒ # Bulk volume of system in cm³ - -integ(x) = Int(floor(x)) -n = integ(N/2) -nr = N%2 == 0 ? (n*(n + 1) - n) : (n*(n + 1)) # No. of forward reactions +```@example smcoag1 +# maximum cluster size +N = 10 + +# volume of a monomers in cm³ +Vₒ = (4π / 3) * (10e-06 * 100)^3 + +# initial conc. = (No. of init. monomers) / bulk volume +Nₒ = 1e-06 / Vₒ + +# No. of monomers initially +uₒ = 10000 + +# Bulk volume of system in cm³ +V = uₒ / Nₒ +n = floor(Int, N / 2) + +# No. of forward reactions +nr = ((N % 2) == 0) ? (n*(n + 1) - n) : (n*(n + 1)) +nothing #hide ``` The [Smoluchowski coagulation equation](https://en.wikipedia.org/wiki/Smoluchowski_coagulation_equation) Wikipedia page illustrates the set of possible reactions that can occur. We can easily enumerate the `pair`s of multimer reactants that can combine when allowing a maximal cluster size of `N` monomers. We initialize the volumes of the reactant multimers as `volᵢ` and `volⱼ` -```julia +```@example smcoag1 # possible pairs of reactant multimers pair = [] for i = 2:N - push!(pair, [1:integ(i/2) i .- (1:integ(i/2))]) + halfi = floor(Int, i/2) + push!(pair, [(1:halfi) (i .- (1:halfi))]) end pair = vcat(pair...) -vᵢ = @view pair[:,1] # Reactant 1 indices -vⱼ = @view pair[:,2] # Reactant 2 indices -volᵢ = Vₒ*vᵢ # cm⁻³ -volⱼ = Vₒ*vⱼ # cm⁻³ +vᵢ = @view pair[:, 1] # Reactant 1 indices +vⱼ = @view pair[:, 2] # Reactant 2 indices +volᵢ = Vₒ * vᵢ # cm⁻³ +volⱼ = Vₒ * vⱼ # cm⁻³ sum_vᵢvⱼ = @. vᵢ + vⱼ # Product index +nothing #hide ``` We next specify the rates (i.e. kernel) at which reactants collide to form products. For simplicity, we allow a user-selected additive kernel or constant kernel. The constants(`B` and `C`) are adopted from Scott's paper [2](https://journals.ametsoc.org/view/journals/atsc/25/1/1520-0469_1968_025_0054_asocdc_2_0_co_2.xml) -```julia +```@example smcoag1 # set i to 1 for additive kernel, 2 for constant i = 1 -if i==1 - B = 1.53e03 # s⁻¹ - kv = @. B*(volᵢ + volⱼ)/V # dividing by volume as its a bi-molecular reaction chain +if i == 1 + B = 1.53e03 # s⁻¹ + + # dividing by volume as it is a bimolecular reaction chain + kv = @. B * (volᵢ + volⱼ) / V elseif i==2 - C = 1.84e-04 # cm³ s⁻¹ - kv = fill(C/V, nr) + C = 1.84e-04 # cm³ s⁻¹ + kv = fill(C / V, nr) end +nothing #hide ``` -We'll store the reaction rates in `pars` as `Pair`s, and set the initial condition that only monomers are present at ``t=0`` in `u₀map`. -```julia -# unknown variables are X, pars stores rate parameters for each rx +We'll set the parameters and the initial condition that only monomers are present at ``t=0`` in `u₀map`. +```@example smcoag1 +# k is a vector of the parameters, with values given by the vector kv +@parameters k[1:nr] = kv + +# create the vector of species X_1,...,X_N t = default_t() -@species k[1:nr] (X(t))[1:N] -pars = Pair.(collect(k), kv) +@species (X(t))[1:N] # time-span if i == 1 - tspan = (0. ,2000.) + tspan = (0.0, 2000.0) elseif i == 2 - tspan = (0. ,350.) + tspan = (0.0, 350.0) end # initial condition of monomers u₀ = zeros(Int64, N) u₀[1] = uₒ -u₀map = Pair.(collect(X), u₀) # map variable to its initial value +u₀map = Pair.(collect(X), u₀) # map species to its initial value +nothing #hide ``` Here we generate the reactions programmatically. We systematically create Catalyst `Reaction`s for each possible reaction shown in the figure on [Wikipedia](https://en.wikipedia.org/wiki/Smoluchowski_coagulation_equation). When `vᵢ[n] == vⱼ[n]`, we set the stoichiometric coefficient of the reactant multimer to two. -```julia +```@example smcoag1 # vector to store the Reactions in rx = [] for n = 1:nr @@ -82,19 +99,21 @@ for n = 1:nr end end @named rs = ReactionSystem(rx, t, collect(X), collect(k)) +rs = complete(rs) ``` We now convert the [`ReactionSystem`](@ref) into a `ModelingToolkit.JumpSystem`, and solve it using Gillespie's direct method. For details on other possible solvers (SSAs), see the [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/types/jump_types/) documentation -```julia +```@example smcoag1 # solving the system -jumpsys = convert(JumpSystem, rs) -dprob = DiscreteProblem(jumpsys, u₀map, tspan, pars) -jprob = JumpProblem(jumpsys, dprob, Direct(), save_positions=(false,false)) -jsol = solve(jprob, SSAStepper(), saveat = tspan[2]/30) +jumpsys = complete(convert(JumpSystem, rs)) +dprob = DiscreteProblem(rs, u₀map, tspan) +jprob = JumpProblem(jumpsys, dprob, Direct(), save_positions = (false, false)) +jsol = solve(jprob, SSAStepper(), saveat = tspan[2] / 30) +nothing #hide ``` Lets check the results for the first three polymers/cluster sizes. We compare to the analytical solution for this system: -```julia +```@example smcoag1 # Results for first three polymers...i.e. monomers, dimers and trimers -v_res = [1;2;3] +v_res = [1; 2; 3] # comparison with analytical solution # we only plot the stochastic solution at a small number of points @@ -114,23 +133,20 @@ elseif i == 2 end # plotting normalised concentration vs analytical solution -default(lw=2, xlabel="Time (sec)") -scatter(ϕ, jsol(t)[1,:]/uₒ, label="X1 (monomers)", markercolor=:blue) +default(lw = 2, xlabel = "Time (sec)") +scatter(ϕ, jsol(t)[1,:] / uₒ, label = "X1 (monomers)", markercolor = :blue) plot!(ϕ, sol[1,:]/Nₒ, line = (:dot,4,:blue), label="Analytical sol--X1") -scatter!(ϕ, jsol(t)[2,:]/uₒ, label="X2 (dimers)", markercolor=:orange) -plot!(ϕ, sol[2,:]/Nₒ, line = (:dot,4,:orange), label="Analytical sol--X2") +scatter!(ϕ, jsol(t)[2,:] / uₒ, label = "X2 (dimers)", markercolor = :orange) +plot!(ϕ, sol[2,:] / Nₒ, line = (:dot, 4, :orange), label = "Analytical sol--X2") -scatter!(ϕ, jsol(t)[3,:]/uₒ, label="X3 (trimers)", markercolor=:purple) -plot!(ϕ, sol[3,:]/Nₒ, line = (:dot,4,:purple), label="Analytical sol--X3", +scatter!(ϕ, jsol(t)[3,:] / uₒ, label = "X3 (trimers)", markercolor = :purple) +plot!(ϕ, sol[3,:] / Nₒ, line = (:dot, 4, :purple), label = "Analytical sol--X3", ylabel = "Normalized Concentration") ``` -For the **additive kernel** we find - -![additive_kernel](../../assets/additive_kernel.svg) --- ## References -[^1]: [https://en.wikipedia.org/wiki/Smoluchowski\_coagulation\_equation](https://en.wikipedia.org/wiki/Smoluchowski_coagulation_equation) -[^2]: Scott, W. T. (1968). Analytic Studies of Cloud Droplet Coalescence I, Journal of Atmospheric Sciences, 25(1), 54-65. Retrieved Feb 18, 2021, from https://journals.ametsoc.org/view/journals/atsc/25/1/1520-0469\_1968\_025\_0054\_asocdc\_2\_0\_co\_2.xml -[^3]: Ian J. Laurenzi, John D. Bartels, Scott L. Diamond, A General Algorithm for Exact Simulation of Multicomponent Aggregation Processes, Journal of Computational Physics, Volume 177, Issue 2, 2002, Pages 418-449, ISSN 0021-9991, https://doi.org/10.1006/jcph.2002.7017. +1. [https://en.wikipedia.org/wiki/Smoluchowski\_coagulation\_equation](https://en.wikipedia.org/wiki/Smoluchowski_coagulation_equation) +2. Scott, W. T. (1968). Analytic Studies of Cloud Droplet Coalescence I, Journal of Atmospheric Sciences, 25(1), 54-65. Retrieved Feb 18, 2021, from https://journals.ametsoc.org/view/journals/atsc/25/1/1520-0469\_1968\_025\_0054\_asocdc\_2\_0\_co\_2.xml +3. Ian J. Laurenzi, John D. Bartels, Scott L. Diamond, A General Algorithm for Exact Simulation of Multicomponent Aggregation Processes, Journal of Computational Physics, Volume 177, Issue 2, 2002, Pages 418-449, ISSN 0021-9991, https://doi.org/10.1006/jcph.2002.7017. diff --git a/docs/src/model_creation/model_file_loading_and_export.md b/docs/src/model_creation/model_file_loading_and_export.md new file mode 100644 index 0000000000..873fbb13ff --- /dev/null +++ b/docs/src/model_creation/model_file_loading_and_export.md @@ -0,0 +1,168 @@ +# [Loading Chemical Reaction Network Models from Files](@id model_file_import_export) +Catalyst stores chemical reaction network (CRN) models in `ReactionSystem` structures. This tutorial describes how to load such `ReactionSystem`s from, and save them to, files. This can be used to save models between Julia sessions, or transfer them from one session to another. Furthermore, to facilitate the computation modelling of CRNs, several standardised file formats have been created to represent CRN models (e.g. [SBML](https://sbml.org/)). This enables CRN models to be shared between different software and programming languages. While Catalyst itself does not have the functionality for loading such files, we will here (briefly) introduce a few packages that can load different file types to Catalyst `ReactionSystem`s. + +## [Saving Catalyst models to, and loading them from, Julia files](@id model_file_import_export_crn_serialization) +Catalyst provides a `save_reactionsystem` function, enabling the user to save a `ReactionSystem` to a file. Here we demonstrate this by first creating a [simple cross-coupling model](@ref basic_CRN_library_cc): +```@example file_handling_1 +using Catalyst +cc_system = @reaction_network begin + k₁, S₁ + C --> S₁C + k₂, S₁C + S₂ --> CP + k₃, CP --> C + P +end +``` +and next saving it to a file +```@example file_handling_1 +save_reactionsystem("cross_coupling.jl", cc_system) +``` +Here, `save_reactionsystem`'s first argument is the path to the file where we wish to save it. The second argument is the `ReactionSystem` we wish to save. To load the file, we use Julia's `include` function: +```@example file_handling_1 +cc_loaded = include("cross_coupling.jl") +rm("cross_coupling.jl") # hide +cc_loaded # hide +``` + +!!! note + The destination file can be in a folder. E.g. `save_reactionsystem("my\_folder/reaction_network.jl", rn)` saves the model to the file "reaction\_network.jl" in the folder "my_folder". + +Here, `include` is used to execute the Julia code from any file. This means that `save_reactionsystem` actually saves the model as executable code which re-generates the exact model which was saved (this is the reason why we use the ".jl" extension for the saved file). Indeed, we can confirm this if we check what is printed in the file: +``` +let + +# Independent variable: +@variables t + +# Parameters: +ps = @parameters kB kD kP + +# Species: +sps = @species S(t) E(t) SE(t) P(t) + +# Reactions: +rxs = [ + Reaction(kB, [S, E], [SE], [1, 1], [1]), + Reaction(kD, [SE], [S, E], [1], [1, 1]), + Reaction(kP, [SE], [P, E], [1], [1, 1]) +] + +# Declares ReactionSystem model: +rs = ReactionSystem(rxs, t, sps, ps; name = Symbol("##ReactionSystem#12592")) +complete(rs) + +end +``` +!!! note + The code that `save_reactionsystem` prints uses [programmatic modelling](@ref programmatic_CRN_construction) to generate the written model. + +In addition to transferring models between Julia sessions, the `save_reactionsystem` function can also be used or print a model to a text file where you can easily inspect its components. + +## [Loading and Saving arbitrary Julia variables using Serialization.jl](@id model_file_import_export_julia_serialisation) +Julia provides a general and lightweight interface for loading and saving Julia structures to and from files that it can be good to be aware of. It is called [Serialization.jl](https://docs.julialang.org/en/v1/stdlib/Serialization/) and provides two functions, `serialize` and `deserialize`. The first allows us to write a Julia structure to a file. E.g. if we wish to save a parameter set associated with our model, we can use +```@example file_handling_2 +using Serialization +ps = [:k₁ => 1.0, :k₂ => 0.1, :k₃ => 2.0] +serialize("saved_parameters.jls", ps) +``` +Here, we use the extension ".jls" (standing for **J**u**L**ia **S**erialization), however, any extension code can be used. To load a structure, we can then use +```@example file_handling_2 +loaded_sol = deserialize("saved_parameters.jls") +rm("saved_parameters.jls") # hide +loaded_sol # hide +``` + +## [Loading .net files using ReactionNetworkImporters.jl](@id model_file_import_export_sbml_rni_net) +A general-purpose format for storing CRN models is so-called .net files. These can be generated by e.g. [BioNetGen](https://bionetgen.org/). The [ReactionNetworkImporters.jl](https://github.com/SciML/ReactionNetworkImporters.jl) package enables the loading of such files to Catalyst `ReactionSystem`. Here we load a [Repressilator](@ref basic_CRN_library_repressilator) model stored in the "repressilator.net" file: +```julia +using ReactionNetworkImporters +prn = loadrxnetwork(BNGNetwork(), "repressilator.net") +``` +Here, .net files not only contain information regarding the reaction network itself, but also the numeric values (initial conditions and parameter values) required for simulating it. Hence, `loadrxnetwork` generates a `ParsedReactionNetwork` structure, containing all this information. You can access the model as `prn.rn`, the initial conditions as `prn.u0`, and the parameter values as `prn.p`. Furthermore, these initial conditions and parameter values are also made [*default* values](@ref dsl_advanced_options_default_vals) of the model. + +A parsed reaction network's content can then be provided to various problem types for simulation. E.g. here we perform an ODE simulation of our repressilator model: +```julia +using Catalyst, OrdinaryDiffEq, Plots +tspan = (0.0, 10000.0) +oprob = ODEProblem(prn.rn, Float64[], tspan, Float64[]) +sol = solve(oprob) +plot(sol; idxs = [:mTetR, :mLacI, :mCI]) +``` +![Repressilator Simulation](../assets/repressilator_sim_ReactionNetworkImporters.svg) + +Note that, as all initial conditions and parameters have default values, we can provide empty vectors for these into our `ODEProblem`. + + +!!! note + It should be noted that .net files support a wide range of potential model features, not all of which are currently supported by ReactionNetworkImporters. Hence, there might be some .net files which `loadrxnetwork` will not be able to load. + +A more detailed description of ReactionNetworkImporter's features can be found in its [documentation](https://docs.sciml.ai/ReactionNetworkImporters/stable/). + +## [Loading SBML files using SBMLImporter.jl and SBMLToolkit.jl](@id model_file_import_export_sbml) +The Systems Biology Markup Language (SBML) is the most widespread format for representing CRN models. Currently, there exist two different Julia packages, [SBMLImporter.jl](https://github.com/sebapersson/SBMLImporter.jl) and [SBMLToolkit.jl](https://github.com/SciML/SBMLToolkit.jl), that are able to load SBML files to Catalyst `ReactionSystem` structures. SBML is able to represent a *very* wide range of model features, with both packages supporting most features. However, there exist SBML files (typically containing obscure model features such as events with time delays) that currently cannot be loaded into Catalyst models. + +SBMLImporter's `load_SBML` function can be used to load SBML files. Here, we load a [Brusselator](@ref basic_CRN_library_brusselator) model stored in the "brusselator.xml" file: +```julia +using SBMLImporter +prn, cbs = load_SBML("brusselator.xml", massaction = true) +``` +Here, while [ReactionNetworkImporters generates a `ParsedReactionSystem` only](@ref model_file_import_export_sbml_rni_net), SBMLImporter generates a `ParsedReactionSystem` (here stored in `prn`) and a [so-called `CallbackSet`](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/#CallbackSet) (here stored in `cbs`). While `prn` can be used to create various problems, when we simulate them, we must also supply `cbs`. E.g. to simulate our brusselator we use: +```julia +using Catalyst, OrdinaryDiffEq, Plots +tspan = (0.0, 50.0) +oprob = ODEProblem(prn.rn, prn.u0, tspan, prn.p) +sol = solve(oprob; callback = cbs) +plot(sol) +``` +![Brusselator Simulation](../assets/brusselator_sim_SBMLImporter.svg) + +Note that, while ReactionNetworkImporters adds initial condition and species values as default to the imported model, SBMLImporter does not do this. These must hence be provided to the `ODEProblem` directly. + +A more detailed description of SBMLImporter's features can be found in its [documentation](https://sebapersson.github.io/SBMLImporter.jl/stable/). + +!!! note + The `massaction = true` option informs the importer that the target model follows mass-action principles. When given, this enables SBMLImporter to make appropriate modifications to the model (which are important for e.g. jump simulation performance). + +### [SBMLImporter and SBMLToolkit](@id model_file_import_export_package_alts) +Above, we described how to use SBMLImporter to import SBML files. Alternatively, SBMLToolkit can be used instead. It has a slightly different syntax, which is described in its [documentation](https://github.com/SciML/SBMLToolkit.jl), and does not support as wide a range of SBML features as SBMLImporter. A short comparison of the two packages can be found [here](https://github.com/sebapersson/SBMLImporter.jl?tab=readme-ov-file#differences-compared-to-sbmltoolkit). Generally, while they both perform well, we note that for *jump simulations* SBMLImporter is preferable (its way for internally representing reaction event enables more performant jump simulations). + +## [Loading models from matrix representation using ReactionNetworkImporters.jl](@id model_file_import_export_matrix_representations) +While CRN models can be represented through various file formats, they can also be represented in various matrix forms. E.g. a CRN with $m$ species and $n$ reactions (and with constant rates) can be represented with either +- An $mxn$ substrate matrix (with each species's substrate stoichiometry in each reaction) and an $nxm$ product matrix (with each species's product stoichiometry in each reaction). + +Or +- An $mxn$ complex stoichiometric matrix (...) and a $2mxn$ incidence matrix (...). + +The advantage of these forms is that they offer a compact and very general way to represent a large class of CRNs. ReactionNetworkImporters have the functionality for converting matrices of these forms directly into Catalyst `ReactionSystem` models. Instructions on how to do this are available in [ReactionNetworkImporter's documentation](https://docs.sciml.ai/ReactionNetworkImporters/stable/#Loading-a-matrix-representation). + + +--- +## [Citations](@id petab_citations) +If you use any of this functionality in your research, [in addition to Catalyst](@ref doc_index_citation), please cite the paper(s) corresponding to whichever package(s) you used: +``` +@software{2022ReactionNetworkImporters, + author = {Isaacson, Samuel}, + title = {{ReactionNetworkImporters.jl}}, + howpublished = {\url{https://github.com/SciML/ReactionNetworkImporters.jl}}, + year = {2022} +} +``` +``` +@software{2024SBMLImporter, + author = {Persson, Sebastian}, + title = {{SBMLImporter.jl}}, + howpublished = {\url{https://github.com/sebapersson/SBMLImporter.jl}}, + year = {2024} +} +``` +``` +@article{LangJainRackauckas+2024, + url = {https://doi.org/10.1515/jib-2024-0003}, + title = {SBMLToolkit.jl: a Julia package for importing SBML into the SciML ecosystem}, + title = {}, + author = {Paul F. Lang and Anand Jain and Christopher Rackauckas}, + pages = {20240003}, + journal = {Journal of Integrative Bioinformatics}, + doi = {doi:10.1515/jib-2024-0003}, + year = {2024}, + lastchecked = {2024-06-02} +} +``` \ No newline at end of file diff --git a/docs/src/model_creation/model_visualisation.md b/docs/src/model_creation/model_visualisation.md index 463c96ce5c..96768ecae0 100644 --- a/docs/src/model_creation/model_visualisation.md +++ b/docs/src/model_creation/model_visualisation.md @@ -4,7 +4,7 @@ Catalyst-created `ReactionSystem` models can be visualised either as LaTeX code ## [Displaying models using LaTeX](@id visualisation_latex) Once a model has been created, the [Latexify.jl](https://github.com/korsbo/Latexify.jl) package can be used to generate LaTeX code of the model. This can either be used for easy model inspection (e.g. to check which equations are being simulated), or to generate code which can be directly pasted into a LaTeX document. -Let us consider a simple [Brusselator model](@ref ref): +Let us consider a simple [Brusselator model](@ref basic_CRN_library_brusselator): ```@example visualisation_latex using Catalyst brusselator = @reaction_network begin @@ -18,13 +18,14 @@ To display its reaction (using LaTeX formatting) we run `latexify` with our mode ```@example visualisation_latex using Latexify latexify(brusselator) +brusselator # hide ``` Here, we note that the output of `latexify(brusselator)` is identical to how a model is displayed by default. Indeed, the reason is that Catalyst internally uses Latexify's `latexify` function to display its models. It is also possible to display the ODE equations a model would generate by adding the `form = :ode` argument: ```@example visualisation_latex latexify(brusselator; form = :ode) ``` !!! note - Internally, `latexify(brusselator; form = :ode)` calls `latexify(convert(ODESystem, brusselator))`. Hence, if you have already [generated the `ODESystem` corresponding to your model](@ref ref), it can be used directly as input to `latexify`. + Internally, `latexify(brusselator; form = :ode)` calls `latexify(convert(ODESystem, brusselator))`. Hence, if you have already generated the `ODESystem` corresponding to your model, it can be used directly as input to `latexify`. !!! note It should be possible to also generate SDEs through the `form = :sde` input. This feature is, however, currently broken. @@ -32,10 +33,10 @@ latexify(brusselator; form = :ode) If you wish to copy the output to your [clipboard](https://en.wikipedia.org/wiki/Clipboard_(computing)) (e.g. so that you can paste it into a LaTeX document), run `copy_to_clipboard(true)` before you run `latexify`. A more throughout description of Latexify's features can be found in [its documentation](https://korsbo.github.io/Latexify.jl/stable/). !!! note - For a model to be nicely displayed you have to use an IDE that actually supports this (such as a [notebook](https://jupyter.org/)). Other environments (such as [the Julia REPL]([@ref ref](https://docs.julialang.org/en/v1/stdlib/REPL/))) will simply return the full LaTeX code which would generate the desired expression. + For a model to be nicely displayed you have to use an IDE that actually supports this (such as a [notebook](https://jupyter.org/)). Other environments (such as [the Julia REPL](https://docs.julialang.org/en/v1/stdlib/REPL/)) will simply return the full LaTeX code which would generate the desired expression. ## [Displaying model networks](@id visualisation_graphs) -A network graph showing a Catalyst model's species and reactions can be displayed using the `Graph` function. This first requires [Graphviz](https://graphviz.org/) to be installed and command line accessible. Here, we first declare a [Brusselator model](@ref ref) and then displays its network topology: +A network graph showing a Catalyst model's species and reactions can be displayed using the `Graph` function. This first requires [Graphviz](https://graphviz.org/) to be installed and command line accessible. Here, we first declare a [Brusselator model](@ref basic_CRN_library_brusselator) and then displays its network topology: ```@example visualisation_graphs using Catalyst brusselator = @reaction_network begin @@ -45,8 +46,11 @@ brusselator = @reaction_network begin 1, X --> ∅ end Graph(brusselator) +nothing # hide ``` -The network graph represents species as blue nodes and reactions as orange dots. Black arrows from species to reactions indicate substrates, and are labelled with their respective stoichiometries. Similarly, black arrows from reactions to species indicate products (also labelled with their respective stoichiometries). If there are any reactions where a species affect the rate, but does not participate as a reactant, this is displayed with a dashed red arrow. This can be seen in the following [repressilator model](@ref ref): +!["Brusselator Graph"](../assets/network_graphs/brusselator_graph.png) + +The network graph represents species as blue nodes and reactions as orange dots. Black arrows from species to reactions indicate substrates, and are labelled with their respective stoichiometries. Similarly, black arrows from reactions to species indicate products (also labelled with their respective stoichiometries). If there are any reactions where a species affect the rate, but does not participate as a reactant, this is displayed with a dashed red arrow. This can be seen in the following [Repressilator model](@ref basic_CRN_library_repressilator): ```@example visualisation_graphs repressilator = @reaction_network begin hillr(Z,v,K,n), ∅ --> X @@ -55,17 +59,20 @@ repressilator = @reaction_network begin d, (X, Y, Z) --> ∅ end Graph(repressilator) +nothing # hide ``` +!["Repressilator Graph"](../assets/network_graphs/repressilator_graph.png) A generated graph can be saved using the `savegraph` function: -```@example visualisation_graphs +```julia repressilator_graph = Graph(repressilator) savegraph(repressilator_graph, "repressilator_graph.png") -rm("repressilator_graph.png") # hide ``` -Finally, a [network's reaction complexes](@ref ref) (and the reactions in between these) can be displayed using the `complexgraph(brusselator)` function: +Finally, a [network's reaction complexes](@ref network_analysis_reaction_complexes) (and the reactions in between these) can be displayed using the `complexgraph(brusselator)` function: ```@example visualisation_graphs complexgraph(brusselator) +nothing # hide ``` -Here, reaction complexes are displayed as blue nodes, and reactions in between these as black arrows. \ No newline at end of file +!["Repressilator Complex Graph"](../assets/network_graphs/repressilator_complex_graph.png) +Here, reaction complexes are displayed as blue nodes, and reactions in between these as black arrows. diff --git a/docs/src/model_creation/network_analysis.md b/docs/src/model_creation/network_analysis.md index 0f212e2447..f1cf33efc9 100644 --- a/docs/src/model_creation/network_analysis.md +++ b/docs/src/model_creation/network_analysis.md @@ -101,7 +101,7 @@ Let's check these are equal symbolically isequal(odes, odes2) ``` -## Reaction complex representation +## [Reaction complex representation](@id network_analysis_reaction_complexes) We now introduce a further decomposition of the RRE ODEs, which has been used to facilitate analysis of a variety of reaction network properties. Consider a simple reaction system like @@ -364,7 +364,7 @@ complexgraph(rn) It is evident from the preceding graph that the network is not reversible. However, it satisfies a weaker property in that there is a path from each reaction complex back to itself within its associated subgraph. This is known as -*weak reversiblity*. One can test a network for weak reversibility by using +*weak reversibility*. One can test a network for weak reversibility by using the [`isweaklyreversible`](@ref) function: ```@example s1 # need subnetworks from the reaction network first diff --git a/docs/src/model_creation/parametric_stoichiometry.md b/docs/src/model_creation/parametric_stoichiometry.md index 434002f72c..a219abfce5 100644 --- a/docs/src/model_creation/parametric_stoichiometry.md +++ b/docs/src/model_creation/parametric_stoichiometry.md @@ -7,14 +7,20 @@ use symbolic stoichiometries, and discuss several caveats to be aware of. Let's first consider a simple reversible reaction where the number of reactants is a parameter, and the number of products is the product of two parameters. ```@example s1 -using Catalyst, Latexify, DifferentialEquations, ModelingToolkit, Plots +using Catalyst, Latexify, OrdinaryDiffEq, ModelingToolkit, Plots revsys = @reaction_network revsys begin + @parameters m::Int64 n::Int64 k₊, m*A --> (m*n)*B k₋, B --> A end reactions(revsys) ``` -Note, as always the `@reaction_network` macro defaults to setting all symbols +Notice, as described in the [Reaction rate laws used in simulations](@ref introduction_to_catalyst_ratelaws) +section, the default rate laws involve factorials in the stoichiometric +coefficients. For this reason we explicitly specify `m` and `n` as integers (as +otherwise ModelingToolkit will implicitly assume they are floating point). + +As always the `@reaction_network` macro defaults to setting all symbols neither used as a reaction substrate nor a product to be parameters. Hence, in this example we have two species (`A` and `B`) and four parameters (`k₊`, `k₋`, `m`, and `n`). In addition, the stoichiometry is applied to the rightmost symbol @@ -36,21 +42,21 @@ We could have equivalently specified our systems directly via the Catalyst API. For example, for `revsys` we would could use ```@example s1 t = default_t() -@parameters k₊, k₋, m, n +@parameters k₊ k₋ m::Int n::Int @species A(t), B(t) rxs = [Reaction(k₊, [A], [B], [m], [m*n]), Reaction(k₋, [B], [A])] revsys2 = ReactionSystem(rxs,t; name=:revsys) revsys2 == revsys ``` -which can be simplified using the `@reaction` macro to +or ```@example s1 -rxs2 = [(@reaction k₊, m*A --> (m*n)*B), +rxs2 = [(@reaction k₊, $m*A --> ($m*$n)*B), (@reaction k₋, B --> A)] revsys3 = ReactionSystem(rxs2,t; name=:revsys) revsys3 == revsys ``` -Note, the `@reaction` macro again assumes all symbols are parameters except the +Here we interpolate in the pre-declared `m` and `n` symbolic variables using `$m` and `$n` to ensure the parameter is known to be integer-valued. The `@reaction` macro again assumes all symbols are parameters except the substrates or reactants (i.e. `A` and `B`). For example, in `@reaction k, F*A + 2(H*G+B) --> D`, the substrates are `(A,G,B)` with stoichiometries `(F,2*H,2)`. @@ -58,43 +64,45 @@ stoichiometries `(F,2*H,2)`. Let's now convert `revsys` to ODEs and look at the resulting equations: ```@example s1 osys = convert(ODESystem, revsys) +osys = complete(osys) equations(osys) show(stdout, MIME"text/plain"(), equations(osys)) # hide ``` -Notice, as described in the [Reaction rate laws used in simulations](@ref) -section, the default rate laws involve factorials in the stoichiometric -coefficients. For this reason we must specify `m` and `n` as integers, and hence -*use a tuple for the parameter mapping* +Specifying the parameter and initial condition values, ```@example s1 -p = (k₊ => 1.0, k₋ => 1.0, m => 2, n => 2) -u₀ = [A => 1.0, B => 1.0] +p = (revsys.k₊ => 1.0, revsys.k₋ => 1.0, revsys.m => 2, revsys.n => 2) +u₀ = [revsys.A => 1.0, revsys.B => 1.0] oprob = ODEProblem(osys, u₀, (0.0, 1.0), p) nothing # hide ``` -We can now solve and plot the system -```@julia +we can now solve and plot the system +```@example s1 sol = solve(oprob, Tsit5()) plot(sol) ``` -*If we had used a vector to store parameters, `m` and `n` would be converted to -floating point giving an error when solving the system.* **Note, currently a [bug](https://github.com/SciML/ModelingToolkit.jl/issues/2296) in ModelingToolkit has broken this example by converting to floating point when using tuple parameters, see the alternative approach below for a workaround.** An alternative approach to avoid the issues of using mixed floating point and integer variables is to disable the rescaling of rate laws as described in -[Reaction rate laws used in simulations](@ref) section. This requires passing -the `combinatoric_ratelaws=false` keyword to `convert` or to `ODEProblem` (if -directly building the problem from a `ReactionSystem` instead of first -converting to an `ODESystem`). For the previous example this gives the following -(different) system of ODEs +[Reaction rate laws used in simulations](@ref introduction_to_catalyst_ratelaws) +section. This requires passing the `combinatoric_ratelaws=false` keyword to +`convert` or to `ODEProblem` (if directly building the problem from a +`ReactionSystem` instead of first converting to an `ODESystem`). For the +previous example this gives the following (different) system of ODEs where we +now let `m` and `n` be floating point valued parameters (the default): ```@example s1 +revsys = @reaction_network revsys begin + k₊, m*A --> (m*n)*B + k₋, B --> A +end osys = convert(ODESystem, revsys; combinatoric_ratelaws = false) +osys = complete(osys) equations(osys) show(stdout, MIME"text/plain"(), equations(osys)) # hide ``` Since we no longer have factorial functions appearing, our example will now run -even with floating point values for `m` and `n`: +with `m` and `n` treated as floating point parameters: ```@example s1 -p = (k₊ => 1.0, k₋ => 1.0, m => 2.0, n => 2.0) +p = (revsys.k₊ => 1.0, revsys.k₋ => 1.0, revsys.m => 2.0, revsys.n => 2.0) oprob = ODEProblem(osys, u₀, (0.0, 1.0), p) sol = solve(oprob, Tsit5()) plot(sol) @@ -139,7 +147,9 @@ The parameter `b` does not need to be explicitly declared in the We next convert our network to a jump process representation ```@example s1 +using JumpProcesses jsys = convert(JumpSystem, burstyrn; combinatoric_ratelaws = false) +jsys = complete(jsys) equations(jsys) show(stdout, MIME"text/plain"(), equations(jsys)) # hide ``` diff --git a/docs/src/model_creation/programmatic_CRN_construction.md b/docs/src/model_creation/programmatic_CRN_construction.md index a8e7f15d86..d5d12911c8 100644 --- a/docs/src/model_creation/programmatic_CRN_construction.md +++ b/docs/src/model_creation/programmatic_CRN_construction.md @@ -61,7 +61,7 @@ system to be the same as the name of the variable storing the system. Alternatively, one can use the `name = :repressilator` keyword argument to the `ReactionSystem` constructor. -!!! warn +!!! warning All `ReactionSystem`s created via the symbolic interface (i.e. by calling `ReactionSystem` with some input, rather than using `@reaction_network`) are not marked as complete. To simulate them, they must first be marked as *complete*, indicating to Catalyst and ModelingToolkit that they represent finalized models. This can be done using the `complete` function, i.e. by calling `repressilator = complete(repressilator)`. An expanded description on *completeness* can be found [here](@ref completeness_note). We can check that this is the same model as the one we defined via the DSL as @@ -180,7 +180,7 @@ This ensured they were properly treated as species and not parameters. See the ## Basic querying of `ReactionSystems` -The [Catalyst.jl API](@ref) provides a large variety of functionality for +The [Catalyst.jl API](@ref api) provides a large variety of functionality for querying properties of a reaction network. Here we go over a few of the most useful basic functions. Given the `repressillator` defined above we have that ```@example ex @@ -247,5 +247,5 @@ rx1.prodstoich rx1.netstoich ``` -See the [Catalyst.jl API](@ref) for much more detail on the various querying and +See the [Catalyst.jl API](@ref api) for much more detail on the various querying and analysis functions provided by Catalyst. diff --git a/docs/src/model_simulation/ensemble_simulations.md b/docs/src/model_simulation/ensemble_simulations.md index 47689b7050..79a8dce1ec 100644 --- a/docs/src/model_simulation/ensemble_simulations.md +++ b/docs/src/model_simulation/ensemble_simulations.md @@ -3,10 +3,10 @@ In many contexts, a single model is re-simulated under similar conditions. Examp - Performing Monte Carlo simulations of a stochastic model to gain insight in its behaviour. - Scanning a model's behaviour for different parameter values and/or initial conditions. -While this can be handled using `for` loops, it is typically better to first create an `EnsembleProblem`, and then perform an ensemble simulation. Advantages include a more concise interface and the option for [automatic simulation parallelisation](@ref ref). Here we provide a short tutorial on how to perform parallel ensemble simulations, with a more extensive documentation being available [here](@ref https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/). +While this can be handled using `for` loops, it is typically better to first create an `EnsembleProblem`, and then perform an ensemble simulation. Advantages include a more concise interface and the option for [automatic simulation parallelisation](@ref ode_simulation_performance_parallelisation). Here we provide a short tutorial on how to perform parallel ensemble simulations, with a more extensive documentation being available [here](https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/). ## [Monte Carlo simulations using unmodified conditions](@id ensemble_simulations_monte_carlo) -We will first consider Monte Carlo simulations where the simulation conditions are identical in-between simulations. First, we declare a [simple self-activation loop](@ref ref) model +We will first consider Monte Carlo simulations where the simulation conditions are identical in-between simulations. First, we declare a [simple self-activation loop](@ref basic_CRN_library_self_activation) model ```@example ensemble using Catalyst sa_model = @reaction_network begin @@ -16,23 +16,28 @@ end u0 = [:X => 10.0] tspan = (0.0, 1000.0) ps = [:v0 => 0.1, :v => 2.5, :K => 40.0, :n => 4.0, :deg => 0.01] +nothing # hide ``` We wish to simulate it as an SDE. Rather than performing a single simulation, however, we want to perform multiple ones. Here, we first create a normal `SDEProblem`, and use it as the single input to a `EnsembleProblem` (`EnsembleProblem` are created similarly for ODE and jump simulations, but the `ODEProblem` or `JumpProblem` is used instead). ```@example ensemble +using StochasticDiffEq sprob = SDEProblem(sa_model, u0, tspan, ps) eprob = EnsembleProblem(sprob) nothing # hide ``` -Next, the `EnsembleProblem` can be used as input to the `solve` command. Here, we use exactly the same inputs that we use for single simulations, however, we add a `trajectories` argument to denote how many simulations we wish to carry out. Here we perform 100 simulations: +Next, the `EnsembleProblem` can be used as input to the `solve` command. Here, we use exactly the same inputs that we use for single simulations, however, we add a `trajectories` argument to denote how many simulations we wish to carry out. Here we perform 10 simulations: ```@example ensemble -sols = solve(eprob, STrapezoid(); trajectories = 100) +sols = solve(eprob, STrapezoid(); trajectories = 10) nothing # hide ``` Finally, we can use our ensemble simulation solution as input to `plot` (just like normal simulations): ```@example ensemble -plot(sols; la = 0.5) +using Plots +plot(sols) ``` -Here, each simulation is displayed as an individual trajectory. We also use the [`la` plotting option](@ref ref) to reduce the transparency of each individual line, improving the plot visual. +Here, each simulation is displayed as an individual trajectory. +!!! note + While not used here, the [`la` plotting option](@ref simulation_plotting_options) (which modifies line transparency) can help improve the plot visual when a large number of (overlapping) lines are plotted. Various convenience functions are available for analysing and plotting ensemble simulations (a full list can be found [here]). Here, we use these to first create an `EnsembleSummary` (retrieving each simulation's value at time points `0.0, 1.0, 2.0, ... 1000.0`). Next, we use this as an input to the `plot` command, which automatically plots the mean $X$ activity across the ensemble, while also displaying the 5% and 95% quantiles as the shaded area: ```@example ensemble @@ -45,7 +50,8 @@ Previously, we assumed that each simulation used the same initial conditions and Here, we first create an `ODEProblem` of our previous self-activation loop: ```@example ensemble -oprob = ODEProblem(sa_model, u0, tspan, p) +using OrdinaryDiffEq +oprob = ODEProblem(sa_model, u0, tspan, ps) nothing # hide ``` Next, we wish to simulate the model for a range of initial conditions of $X$`. To do this we create a problem function, which takes the following arguments: @@ -53,7 +59,7 @@ Next, we wish to simulate the model for a range of initial conditions of $X$`. T - `i`: The number of this specific Monte Carlo iteration in the interval `1:trajectories`. - `repeat`: The iteration of the repeat of the simulation. Typically `1`, but potentially higher if [the simulation re-running option](https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/#Building-a-Problem) is used. -Here we will use the following problem function (utilising [remake](@ref ref)), which will provide a uniform range of initial concentrations of $X$: +Here we will use the following problem function (utilising [remake](@ref simulation_structure_interfacing_problems_remake)), which will provide a uniform range of initial concentrations of $X$: ```@example ensemble function prob_func(prob, i, repeat) remake(prob; u0 = [:X => i * 5.0]) diff --git a/docs/src/model_simulation/ode_simulation_performance.md b/docs/src/model_simulation/ode_simulation_performance.md index 78c0dfe8a6..40e699657b 100644 --- a/docs/src/model_simulation/ode_simulation_performance.md +++ b/docs/src/model_simulation/ode_simulation_performance.md @@ -1,6 +1,6 @@ # [Advice for performant ODE simulations](@id ode_simulation_performance) -We have previously described how to perform ODE simulations of *chemical reaction network* (CRN) models. These simulations are typically fast and require little additional consideration. However, when a model is simulated many times (e.g. as a part of solving an [inverse problem](@ref ref)), or is very large, simulation run -times may become noticeable. Here we will give some advice on how to improve performance for these cases. +We have previously described how to perform ODE simulations of *chemical reaction network* (CRN) models. These simulations are typically fast and require little additional consideration. However, when a model is simulated many times (e.g. as a part of solving an inverse problem), or is very large, simulation run +times may become noticeable. Here we will give some advice on how to improve performance for these cases [^1]. Generally, there are few good ways to, before a simulation, determine the best options. Hence, while we below provide several options, if you face an application for which reducing run time is critical (e.g. if you need to simulate the same ODE many times), it might be required to manually trial these various options to see which yields the best performance ([BenchmarkTools.jl's](https://github.com/JuliaCI/BenchmarkTools.jl) `@btime` macro is useful for this purpose). It should be noted that the default options typically perform well, and it is primarily for large models where investigating alternative options is worthwhile. All ODE simulations of Catalyst models are performed using the OrdinaryDiffEq.jl package, [which documentation](https://docs.sciml.ai/DiffEqDocs/stable/) provides additional advice on performance. @@ -8,12 +8,12 @@ Generally, this short checklist provides a quick guide for dealing with ODE perf 1. If performance is not critical, use [the default solver choice](@ref ode_simulation_performance_solvers) and do not worry further about the issue. 2. If improved performance would be useful, read about solver selection (both in [this tutorial](@ref ode_simulation_performance_solvers) and [OrdinaryDiffEq's documentation](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/)) and then try a few different solvers to find one with good performance. 3. If you have a large ODE (approximately 100 variables or more), try the [various options for efficient Jacobian computation](@ref ode_simulation_performance_jacobian) (noting that some are non-trivial to use, and should only be investigated if truly required). -4. If you plan to simulate your ODE many times, try [parallelise it on CPUs or GPUs](@ref investigating) (with preference for the former, which is easier to use). +4. If you plan to simulate your ODE many times, try [parallelise it on CPUs or GPUs](@ref ode_simulation_performance_parallelisation) (with preference for the former, which is easier to use). ## [Regarding stiff and non-stiff problems and solvers](@id ode_simulation_performance_stiffness) Generally, ODE problems can be categorised into [*stiff ODEs* and *non-stiff ODEs*](https://en.wikipedia.org/wiki/Stiff_equation). This categorisation is important due to stiff ODEs requiring specialised solvers. A common cause of failure to simulate an ODE is the use of a non-stiff solver for a stiff problem. There is no exact way to determine whether a given ODE is stiff or not, however, systems with several different time scales (e.g. a CRN with both slow and fast reactions) typically generate stiff ODEs. -Here we simulate the (stiff) [Brusselator](@ref ref) model using the `Tsit5` solver (which is designed for non-stiff ODEs): +Here we simulate the (stiff) [Brusselator](@ref basic_CRN_library_brusselator) model using the `Tsit5` solver (which is designed for non-stiff ODEs): ```@example ode_simulation_performance_1 using Catalyst, OrdinaryDiffEq, Plots @@ -26,13 +26,15 @@ end u0 = [:X => 1.0, :Y => 0.0] tspan = (0.0, 20.0) -ps = [:A => 10.0, :B => 40.0] +ps = [:A => 400.0, :B => 2000.0] oprob = ODEProblem(brusselator, u0, tspan, ps) sol1 = solve(oprob, Tsit5()) plot(sol1) -``` -We note that we get a warning, indicating that an instability was detected (the typical indication of a non-stiff solver being used for a stiff ODE). Furthermore, the resulting plot ends at $t ≈ 10$, meaning that the simulation was not completed (as the simulation's endpoint is $t = 20$). Indeed, we can confirm this by checking the *return code* of the solution object: +plot(sol1, plotdensity = 1000, fmt = :png) # hide +``` + +We get a warning, indicating that the simulation was terminated. Furthermore, the resulting plot ends at $t ≈ 12$, meaning that the simulation was not completed (as the simulation's endpoint is $t = 20$). Indeed, we can confirm this by checking the *return code* of the solution object: ```@example ode_simulation_performance_1 sol1.retcode ``` @@ -52,7 +54,7 @@ Finally, we should note that stiffness is not tied to the model equations only. ## [ODE solver selection](@id ode_simulation_performance_solvers) -OrdinaryDiffEq implements an unusually large number of ODE solvers, with the performance of the simulation heavily depending on which one is chosen. These are provided as the second argument to the `solve` command, e.g. here we use the `Tsit5` solver to simulate a simple [birth-death process](@ref ref): +OrdinaryDiffEq implements an unusually large number of ODE solvers, with the performance of the simulation heavily depending on which one is chosen. These are provided as the second argument to the `solve` command, e.g. here we use the `Tsit5` solver to simulate a simple [birth-death process](@ref basic_CRN_library_bd): ```@example ode_simulation_performance_2 using Catalyst, OrdinaryDiffEq @@ -75,18 +77,18 @@ nothing # hide While the default choice is typically enough for most single simulations, if performance is important, it can be worthwhile exploring the available solvers to find one that is especially suited for the given problem. A complete list of possible ODE solvers, with advice on optimal selection, can be found [here](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/). This section will give some general advice. The most important part of solver selection is to select one appropriate for [the problem's stiffness](@ref ode_simulation_performance_stiffness). Generally, the `Tsit5` solver is good for non-stiff problems, and `Rodas5P` for stiff problems. For large stiff problems (with many species), `FBDF` can be a good choice. We can illustrate the impact of these choices by simulating our birth-death process using the `Tsit5`, `Vern7` (an explicit solver yielding [low error in the solution](@ref ode_simulation_performance_error)), `Rodas5P`, and `FBDF` solvers (benchmarking their respective performance using [BenchmarkTools.jl](https://github.com/JuliaCI/BenchmarkTools.jl)): -```@example ode_simulation_performance_2 +```julia using BenchmarkTools @btime solve(oprob, Tsit5()) @btime solve(oprob, Vern7()) @btime solve(oprob, Rodas5P()) @btime solve(oprob, FBDF()) ``` -Here, we note that the fastest solver is several times faster than the slowest one (`FBDF`, which is a poor choice for this ODE), +If you perform the above benchmarks on your machine, and check the results, you will note that the fastest solver is several times faster than the slowest one (`FBDF`, which is a poor choice for this ODE). ### [Simulation error, tolerance, and solver selection](@id ode_simulation_performance_error) Numerical ODE simulations [approximate an ODEs' continuous solutions as discrete vectors](https://en.wikipedia.org/wiki/Discrete_time_and_continuous_time). This introduces errors in the computed solution. The magnitude of these errors can be controlled by setting solver *tolerances*. By reducing the tolerance, solution errors will be reduced, however, this will also increase simulation run times. The (absolute and relative) tolerance of a solver can be tuned through the `abstol` and `reltol` arguments. Here we see how run time increases with larger tolerances: -```@example ode_simulation_performance_2 +```julia @btime solve(oprob, Tsit5(); abstol=1e-6, reltol=1e-6) @btime solve(oprob, Tsit5(); abstol=1e-12, reltol=1e-12) ``` @@ -123,7 +125,7 @@ nothing # hide ### [Using a sparse Jacobian](@id ode_simulation_performance_sparse_jacobian) For a system with $n$ variables, the Jacobian will be an $n\times n$ matrix. This means that, as $n$ becomes large, the Jacobian can become *very* large, potentially causing a significant strain on memory. In these cases, most Jacobian entries are typically $0$. This means that a [*sparse*](https://en.wikipedia.org/wiki/Sparse_matrix) Jacobian (rather than a *dense* one, which is the default) can be advantageous. To designate sparse Jacobian usage, simply provide the `sparse = true` option when constructing an `ODEProblem`: ```@example ode_simulation_performance_3 -oprob = ODEProblem(brusselator, u0, tspan, p; sparse = true) +oprob = ODEProblem(brusselator, u0, tspan, ps; sparse = true) nothing # hide ``` @@ -143,7 +145,7 @@ nothing # hide ``` Since these methods do not depend on a Jacobian, certain Jacobian options (such as [computing it symbolically](@ref ode_simulation_performance_symbolic_jacobian)) are irrelevant to them. -### [Designating preconditioners for Jacobian-free linear solvers](@ref ode_simulation_performance_preconditioners) +### [Designating preconditioners for Jacobian-free linear solvers](@id ode_simulation_performance_preconditioners) When an implicit method solves a linear equation through an (iterative) matrix-free Newton-Krylov method, the rate of convergence depends on the numerical properties of the matrix defining the linear system. To speed up convergence, a [*preconditioner*](https://en.wikipedia.org/wiki/Preconditioner) can be applied to both sides of the linear equation, attempting to create an equation that converges faster. Preconditioners are only relevant when using matrix-free Newton-Krylov methods. In practice, preconditioners are implemented as functions with a specific set of arguments. How to implement these is non-trivial, and we recommend reading OrdinaryDiffEq's documentation pages [here](https://docs.sciml.ai/DiffEqDocs/stable/features/linear_nonlinear/#Preconditioners:-precs-Specification) and [here](https://docs.sciml.ai/DiffEqDocs/stable/tutorials/advanced_ode_example/#Adding-a-Preconditioner). In this example, we will define an [Incomplete LU](https://en.wikipedia.org/wiki/Incomplete_LU_factorization) preconditioner (which requires the [IncompleteLU.jl](https://github.com/haampie/IncompleteLU.jl) package): @@ -172,10 +174,10 @@ Generally, the use of preconditioners is only recommended for advanced users who ## [Parallelisation on CPUs and GPUs](@id ode_simulation_performance_parallelisation) Whenever an ODE is simulated a large number of times (e.g. when investigating its behaviour for different parameter values), the best way to improve performance is to [parallelise the simulation over multiple processing units](https://en.wikipedia.org/wiki/Parallel_computing). Indeed, an advantage of the Julia programming language is that it was designed after the advent of parallel computing, making it well-suited for this task. Roughly, parallelisation can be divided into parallelisation on [CPUs](https://en.wikipedia.org/wiki/Central_processing_unit) and on [GPUs](https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units). CPU parallelisation is most straightforward, while GPU parallelisation requires specialised ODE solvers (which Catalyst have access to). -Both CPU and GPU parallelisation require first building an `EnsembleProblem` (which defines the simulations you wish to perform) and then supplying this with the correct parallelisation options. These have [previously been introduced in Catalyst's documentation](@ref ref) (but in the context of convenient bundling of similar simulations, rather than to improve performance), with a more throughout description being found in [OrdinaryDiffEq's documentation](https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/#ensemble). Finally, a general documentation of parallel computing in Julia is available [here](https://docs.julialang.org/en/v1/manual/parallel-computing/). +Both CPU and GPU parallelisation require first building an `EnsembleProblem` (which defines the simulations you wish to perform) and then supplying this with the correct parallelisation options. `EnsembleProblem`s have [previously been introduced in Catalyst's documentation](@ref ensemble_simulations) (but in the context of convenient bundling of similar simulations, rather than to improve performance), with a more throughout description being found in [OrdinaryDiffEq's documentation](https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/#ensemble). Finally, a general documentation of parallel computing in Julia is available [here](https://docs.julialang.org/en/v1/manual/parallel-computing/). ### [CPU parallelisation](@id ode_simulation_performance_parallelisation_CPU) -For this example (and the one for GPUs), we will consider a modified [Michaelis-Menten enzyme kinetics model](@ref ref), which describes an enzyme ($E$) that converts a substrate ($S$) to a product ($P$): +For this example (and the one for GPUs), we will consider a modified [Michaelis-Menten enzyme kinetics model](@ref basic_CRN_library_mm), which describes an enzyme ($E$) that converts a substrate ($S$) to a product ($P$): ```@example ode_simulation_performance_4 using Catalyst mm_model = @reaction_network begin @@ -186,12 +188,12 @@ mm_model = @reaction_network begin end ``` The model can be simulated, showing how $P$ is produced from $S$: -```@example ode_simulation_performance_3 +```@example ode_simulation_performance_4 using OrdinaryDiffEq, Plots u0 = [:S => 1.0, :E => 1.0, :SE => 0.0, :P => 0.0] tspan = (0.0, 50.0) -p = [:kB => 1.0, :kD => 0.1, :kP => 0.5, :d => 0.1] -oprob = ODEProblem(mm_model, u0, tspan, p) +ps = [:kB => 1.0, :kD => 0.1, :kP => 0.5, :d => 0.1] +oprob = ODEProblem(mm_model, u0, tspan, ps) sol = solve(oprob, Tsit5()) plot(sol) ``` @@ -208,7 +210,7 @@ Here, `prob_func` takes 3 arguments: and output the `ODEProblem` simulated in the i'th simulation. -Let us assume that we wish to simulate our model 100 times, for $kP = 0.01, 0.02, ..., 0.99, 1.0$. We define our `prob_func` using [`remake`](@ref ref): +Let us assume that we wish to simulate our model 100 times, for $kP = 0.01, 0.02, ..., 0.99, 1.0$. We define our `prob_func` using [`remake`](@ref simulation_structure_interfacing_problems_remake): ```@example ode_simulation_performance_4 function prob_func(prob, i, repeat) return remake(prob; p = [:kP => 0.01*i]) @@ -232,22 +234,21 @@ plot(esol.u[47]) To extract the amount of $P$ produced in each simulation, and plot this against the corresponding $kP$ value, we can use: ```@example ode_simulation_performance_4 plot(0.01:0.01:1.0, map(sol -> sol[:P][end], esol.u), xguide = "kP", yguide = "P produced", label="") +plot!(left_margin = 3Plots.Measures.mm) # hide ``` Above, we have simply used `EnsembleProblem` as a convenient interface to run a large number of similar simulations. However, these problems have the advantage that they allow the passing of an *ensemble algorithm* to the `solve` command, which describes a strategy for parallelising the simulations. By default, `EnsembleThreads` is used. This parallelises the simulations using [multithreading](https://en.wikipedia.org/wiki/Multithreading_(computer_architecture)) (parallelisation within a single process), which is typically advantageous for small problems on shared memory devices. An alternative is `EnsembleDistributed` which instead parallelises the simulations using [multiprocessing](https://en.wikipedia.org/wiki/Multiprocessing) (parallelisation across multiple processes). To do this, we simply supply this additional solver to the solve command: -```@example ode_simulation_performance_4 -esol = solve(eprob, Tsit5(), EnsembleDistributed(); trajectories=100) -nothing # hide +```julia +esol = solve(eprob, Tsit5(), EnsembleDistributed(); trajectories = 100) ``` To utilise multiple processes, you must first give Julia access to these. You can check how many processes are available using the `nprocs` (which requires the [Distributed.jl](https://github.com/JuliaLang/Distributed.jl) package): -```@example ode_simulation_performance_4 +```julia using Distributed nprocs() ``` Next, more processes can be added using `addprocs`. E.g. here we add an additional 4 processes: -```@example ode_simulation_performance_4 +```julia addprocs(4) -nothing # hide ``` Powerful personal computers and HPC clusters typically have a large number of available additional processes that can be added to improve performance. @@ -268,7 +269,7 @@ Furthermore (while not required) to receive good performance, we should also mak - We should designate all our vectors (i.e. initial conditions and parameter values) as [static vectors](https://github.com/JuliaArrays/StaticArrays.jl). We will assume that we are using the CUDA GPU hardware, so we will first load the [CUDA.jl](https://github.com/JuliaGPU/CUDA.jl) backend package, as well as DiffEqGPU: -```@example ode_simulation_performance_5 +```julia using CUDA, DiffEqGPU ``` Which backend package you should use depends on your available hardware, with the alternatives being listed [here](https://docs.sciml.ai/DiffEqGPU/stable/manual/backends/). @@ -283,11 +284,12 @@ mm_model = @reaction_network begin kP, SE --> P + E d, S --> ∅ end +@unpack S, E, SE, P, kB, kD, kP, d = mm_model using OrdinaryDiffEq, Plots -u0 = @SVector [:S => 1.0f0, :E => 1.0f0, :SE => 0.0f0, :P => 0.0f0] +u0 = @SVector [S => 1.0f0, E => 1.0f0, SE => 0.0f0, P => 0.0f0] tspan = (0.0f0, 50.0f0) -p = @SVector [:kB => 1.0f0, :kD => 0.1f0, :kP => 0.5f0, :d => 0.1f0] +p = @SVector [kB => 1.0f0, kD => 0.1f0, kP => 0.5f0, d => 0.1f0] oprob = ODEProblem(mm_model, u0, tspan, p) nothing # hide ``` @@ -300,16 +302,17 @@ eprob = EnsembleProblem(oprob; prob_func = prob_func) nothing # hide ``` Here have we increased the number of simulations to 10,000, since this is a more appropriate number for GPU parallelisation (as compared to the 100 simulations we performed in our CPU example). +!!! note + Currently, declaration of static vectors requires symbolic, rather than symbol, form for species and parameters. Hence, we here first `@unpack` these before constructing `u0` and `ps` using `@SVector`. We can now simulate our model using a GPU-based ensemble algorithm. Currently, two such algorithms are available, `EnsembleGPUArray` and `EnsembleGPUKernel`. Their differences are that - Only `EnsembleGPUKernel` requires arrays to be static arrays (although it is still advantageous for `EnsembleGPUArray`). - While `EnsembleGPUArray` can use standard ODE solvers, `EnsembleGPUKernel` requires specialised versions (such as `GPUTsit5`). A list of available such solvers can be found [here](https://docs.sciml.ai/DiffEqGPU/dev/manual/ensemblegpukernel/#specialsolvers). Generally, it is recommended to use `EnsembleGPUArray` for large models (that have at least $100$ variables), and `EnsembleGPUKernel` for smaller ones. Here we simulate our model using both approaches (noting that `EnsembleGPUKernel` requires `GPUTsit5`): -```@example ode_simulation_performance_5 +```julia esol1 = solve(eprob, Tsit5(), EnsembleGPUArray(CUDA.CUDABackend()); trajectories = 10000) esol2 = solve(eprob, GPUTsit5(), EnsembleGPUKernel(CUDA.CUDABackend()); trajectories = 10000) -nothing # hide ``` Note that we have to provide the `CUDA.CUDABackend()` argument to our ensemble algorithms (to designate our GPU backend, in this case, CUDA). diff --git a/docs/src/model_simulation/sde_simulation_performance.md b/docs/src/model_simulation/sde_simulation_performance.md new file mode 100644 index 0000000000..d32861ae69 --- /dev/null +++ b/docs/src/model_simulation/sde_simulation_performance.md @@ -0,0 +1,58 @@ +# [Advice for performant SDE simulations](@id sde_simulation_performance) +While there exist relatively straightforward approaches to manage performance for [ODE](@ref ode_simulation_performance) and jump simulations, this is generally not the case for SDE simulations. Below, we briefly describe some options. However, as one starts to investigate these, one quickly reaches what is (or could be) active areas of research. + +## [SDE solver selection](@id sde_simulation_performance_solvers) +We have previously described how [ODE solver selection](@ref ode_simulation_performance_solvers) can impact simulation performance. Again, it can be worthwhile to investigate solver selection's impact on performance for SDE simulations. Throughout this documentation, we generally use the `STrapezoid` solver as the default choice. However, if the `DifferentialEquations` package is loaded +```julia +using DifferentialEquations +``` +automatic SDE solver selection enabled (just like is the case for ODEs by default). Generally, the automatic SDE solver choice enabled by `DifferentialEquations` is better than just using `STrapezoid`. Next, if performance is critical, it can be worthwhile to check the [list of available SDE solvers](https://docs.sciml.ai/DiffEqDocs/stable/solvers/sde_solve/) to find one with advantageous performance for a given problem. When doing so, it is important to pick a solver compatible with *non-diagonal noise* and with [*Ito problems*](https://en.wikipedia.org/wiki/It%C3%B4_calculus). + +## [Options for Jacobian computation](@id sde_simulation_performance_jacobian) +In the section on ODE simulation performance, we describe various [options for computing the system Jacobian](@ref ode_simulation_performance_jacobian), and how these could be used to improve performance for [implicit solvers](@ref ode_simulation_performance_stiffness). These can be used in tandem with implicit SDE solvers (such as `STrapezoid`). However, due to additional considerations during SDE simulations, it is much less certain whether these will actually have any impact on performance. So while these options might be worth reading about and trialling, there is no guarantee that they will be beneficial. + +## [Parallelisation on CPUs and GPUs](@id sde_simulation_performance_parallelisation) +We have previously described how simulation parallelisation can be used to [improve performance when multiple ODE simulations are carried out](@ref ode_simulation_performance_parallelisation). The same approaches can be used for SDE simulations. Indeed, it is often more relevant for SDEs, as these are often re-simulated using identical simulation conditions (to investigate their typical behaviour across many samples). CPU parallelisation of SDE simulations uses the [same approach as ODEs](@ref ode_simulation_performance_parallelisation_CPU). GPU parallelisation requires some additional considerations, which are described below. + +### [GPU parallelisation of SDE simulations](@id sde_simulation_performance_parallelisation_GPU) +GPU parallelisation of SDE simulations uses a similar approach as that for [ODE simulations](@ref ode_simulation_performance_parallelisation_GPU). The main differences are that SDE parallelisation requires a GPU SDE solver (like `GPUEM`) and fixed time stepping. + +We will assume that we are using the CUDA GPU hardware, so we will first load the [CUDA.jl](https://github.com/JuliaGPU/CUDA.jl) backend package, as well as DiffEqGPU: +```julia +using CUDA, DiffEqGPU +``` +Which backend package you should use depends on your available hardware, with the alternatives being listed [here](https://docs.sciml.ai/DiffEqGPU/stable/manual/backends/). + +Next, we create the `SDEProblem` which we wish to simulate. Like for ODEs, we ensure that all vectors are [static vectors](https://github.com/JuliaArrays/StaticArrays.jl) and that all values are `Float32`s. Here we prepare the parallel simulations of a simple [birth-death process](@ref basic_CRN_library_bd). +```@example sde_simulation_performance_gpu +using Catalyst, StochasticDiffEq, StaticArrays +bd_model = @reaction_network begin + (p,d), 0 <--> X +end +@unpack X, p, d = bd_model + +u0 = @SVector [X => 20.0f0] +tspan = (0.0f0, 10.0f0) +ps = @SVector [p => 10.0f0, d => 1.0f0] +sprob = SDEProblem(bd_model, u0, tspan, ps) +nothing # hide +``` +The `SDEProblem` is then used to [create an `EnsembleProblem`](@ref ensemble_simulations_monte_carlo). +```@example sde_simulation_performance_gpu +eprob = EnsembleProblem(sprob) +nothing # hide +``` +Finally, we can solve our `EnsembleProblem` while: +- Using a valid GPU SDE solver (either [`GPUEM`](https://docs.sciml.ai/DiffEqGPU/stable/manual/ensemblegpukernel/#DiffEqGPU.GPUEM) or [`GPUSIEA`](https://docs.sciml.ai/DiffEqGPU/stable/manual/ensemblegpukernel/#DiffEqGPU.GPUSIEA)). +- Designating the GPU ensemble method, `EnsembleGPUKernel` (with the correct GPU backend as input). +- Designating the number of trajectories we wish to simulate. +- Designating a fixed time step size. + +```julia +esol = solve(eprob, GPUEM(), EnsembleGPUKernel(CUDA.CUDABackend()); trajectories = 10000, dt = 0.01) +``` + +Above we parallelise GPU simulations with identical initial conditions and parameter values. However, [varying these](@ref ensemble_simulations_varying_conditions) is also possible. + +### [Multilevel Monte Carlo](@id sde_simulation_performance_parallelisation_mlmc) +An approach for speeding up parallel stochastic simulations is so-called [*multilevel Monte Carlo approaches*](https://en.wikipedia.org/wiki/Multilevel_Monte_Carlo_method) (MLMC). These are used when a stochastic process is simulated repeatedly using identical simulation conditions. Here, instead of performing all simulations using identical [tolerance](@ref ode_simulation_performance_error), the ensemble is simulated using a range of tolerances (primarily lower ones, which yields faster simulations). Currently, [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) do not have a native implementation for performing MLMC simulations (this will hopefully be added in the future). However, if high performance of parallel SDE simulations is required, these approaches may be worth investigating. \ No newline at end of file diff --git a/docs/src/model_simulation/simulation_introduction.md b/docs/src/model_simulation/simulation_introduction.md index 113b0fa7b4..f2aafd8ec7 100644 --- a/docs/src/model_simulation/simulation_introduction.md +++ b/docs/src/model_simulation/simulation_introduction.md @@ -1,7 +1,7 @@ # [Model Simulation Introduction](@id simulation_intro) Catalyst's core functionality is the creation of *chemical reaction network* (CRN) models that can be simulated using ODE, SDE, and jump simulations. How such simulations are carried out has already been described in [Catalyst's introduction](@ref introduction_to_catalyst). This page provides a deeper introduction, giving some additional background and introducing various simulation-related options. -Here we will focus on the basics, with other sections of the simulation documentation describing various specialised features, or giving advice on performance. Anyone who plans on using Catalyst's simulation functionality extensively is recommended to also read the documentation on [solution plotting](@ref ref), and on how to [interact with simulation problems, integrators, and solutions](@ref ref). Anyone with an application for which performance is critical should consider reading the corresponding page on performance advice for [ODEs](@ref ref), [SDEs](@ref ref), or [jump simulations](@ref ref). +Here we will focus on the basics, with other sections of the simulation documentation describing various specialised features, or giving advice on performance. Anyone who plans on using Catalyst's simulation functionality extensively is recommended to also read the documentation on [solution plotting](@ref simulation_plotting), and on how to [interact with simulation problems, integrators, and solutions](@ref simulation_structure_interfacing). Anyone with an application for which performance is critical should consider reading the corresponding page on performance advice for [ODEs](@ref ode_simulation_performance) or [SDEs](@ref sde_simulation_performance). ### [Background to CRN simulations](@id simulation_intro_theory) This section provides some brief theory on CRN simulations. For details on how to carry out these simulations in actual code, please skip to the following sections. @@ -40,9 +40,9 @@ These three different approaches are summed up in the following table: Example simulation methods - [Euler](https://en.wikipedia.org/wiki/Euler_method#:~:text=The%20Euler%20method%20is%20a,proportional%20to%20the%20step%20size.), [Runge-Kutta](https://en.wikipedia.org/wiki/Runge%E2%80%93Kutta_methods) - [Euler-Maruyama](https://en.wikipedia.org/wiki/Euler%E2%80%93Maruyama_method), [Milstein](https://en.wikipedia.org/wiki/Milstein_method) - [Gillespie](https://en.wikipedia.org/wiki/Gillespie_algorithm), [Sorting direct](https://pubmed.ncbi.nlm.nih.gov/16321569/) + Euler, Runge-Kutta + Euler-Maruyama, Milstein + Gillespie, Sorting direct Species units @@ -70,22 +70,22 @@ These three different approaches are summed up in the following table: Simulation package - [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) - [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) - [JumpProcesses.jl](https://github.com/SciML/JumpProcesses.jl) + OrdinaryDiffEq.jl + StochasticDiffEq.jl + JumpProcesses.jl ``` ## [Performing (ODE) simulations](@id simulation_intro_ODEs) -The following section gives a (more throughout than [previous]) introduction of how to simulate Catalyst models. This is exemplified using ODE simulations (some ODE-specific options will also be discussed). Later on, we will describe options specific to [SDE](@ref ref) and [jump](@ref ref) simulations. All ODE simulations are performed using the [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) package, which full documentation can be found [here](https://docs.sciml.ai/OrdinaryDiffEq/stable/). A dedicated section giving advice on how to optimise ODE simulation performance can be found [here](@ref ref) +The following section gives a (more throughout than [previous]) introduction of how to simulate Catalyst models. This is exemplified using ODE simulations (some ODE-specific options will also be discussed). Later on, we will describe things specific to [SDE](@ref simulation_intro_SDEs) and [jump](@ref simulation_intro_jumps) simulations. All ODE simulations are performed using the [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) package, which full documentation can be found [here](https://docs.sciml.ai/OrdinaryDiffEq/stable/). A dedicated section giving advice on how to optimise ODE simulation performance can be found [here](@ref ode_simulation_performance) -To perform any simulation, we must first define our model, as well as the simulation's initial conditions, time span, and parameter values. Here we will use a simple [two-state model](@ref ref): +To perform any simulation, we must first define our model, as well as the simulation's initial conditions, time span, and parameter values. Here we will use a simple [two-state model](@ref basic_CRN_library_two_states): ```@example simulation_intro_ode using Catalyst two_state_model = @reaction_network begin - (k1,k2), X1 <--> X2 + (k1,k2), X1 <--> X2 end u0 = [:X1 => 100.0, :X2 => 200.0] tspan = (0.0, 5.0) @@ -108,12 +108,11 @@ Finally, the result can be plotted using the [Plots.jl](https://github.com/Julia using Plots plot(sol) ``` -More information on how to interact with solution structures is provided [here](@ref simulation_structure_interfacing) and on how to plot them [here](@ref ref). +More information on how to interact with solution structures is provided [here](@ref simulation_structure_interfacing) and on how to plot them [here](@ref simulation_plotting). Some additional considerations: - If a model without parameters has been declared, only the first three arguments must be provided to `ODEProblem`. - While the first value of `tspan` will almost always be `0.0`, other starting times (both negative and positive) are possible. -- A discussion of various ways to represent species and parameters when designating their values in the `u0` and `ps` vectors can be found [here](@ref ref). ### [Designating solvers and solver options](@id simulation_intro_solver_options) @@ -122,11 +121,11 @@ While good defaults are generally selected, OrdinaryDiffEq enables the user to c sol = solve(oprob, Rodas5P()) nothing # hide ``` -A full list of available solvers is provided [here](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/), and a discussion on optimal solver choices [here](@ref ref). +A full list of available solvers is provided [here](https://docs.sciml.ai/DiffEqDocs/stable/solvers/ode_solve/), and a discussion on optimal solver choices [here](@ref ode_simulation_performance_solvers). -Additional options can be provided as keyword arguments. E.g. the `adaptive` arguments determine whether adaptive time-stepping is used (for algorithms that permit this). This defaults to `true`, but can be disabled using +Additional options can be provided as keyword arguments. E.g. the `maxiters` arguments determines the maximum number of simulation time steps (before the simulation is terminated). This defaults to `1e5`, but can be modified through: ```@example simulation_intro_ode -sol = solve(oprob; adaptive = false) +sol = solve(oprob; maxiters = 1e4) nothing # hide ``` @@ -160,7 +159,7 @@ nothing # hide The forms used for `u0` and `ps` does not need to be the same (but can e.g. be a vector and a tuple). !!! note - It [is possible](@ref ref) to designate specific types for parameters. When this is done, the tuple form for providing parameter values should be preferred. + It is possible to [designate specific types for parameters](@ref dsl_advanced_options_parameter_types). When this is done, the tuple form for providing parameter values should be preferred. Throughout Catalyst's documentation, we typically provide the time span as a tuple. However, if the first time point is `0.0` (which is typically the case), this can be omitted. Here, we supply only the simulation endpoint to our `ODEProblem`: ```@example simulation_intro_ode @@ -170,13 +169,13 @@ nothing # hide ``` ## [Performing SDE simulations](@id simulation_intro_SDEs) -Catalyst uses the [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) package to perform SDE simulations. This section provides a brief introduction, with [StochasticDiffEq's documentation](https://docs.sciml.ai/StochasticDiffEq/stable/) providing a more extensive description. A dedicated section giving advice on how to optimise SDE simulation performance can be found [here](@ref ref). By default, Catalyst generates SDEs from CRN models using the chemical Langevin equation. +Catalyst uses the [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) package to perform SDE simulations. This section provides a brief introduction, with [StochasticDiffEq's documentation](https://docs.sciml.ai/StochasticDiffEq/stable/) providing a more extensive description. By default, Catalyst generates SDEs from CRN models using the chemical Langevin equation. A dedicated section giving advice on how to optimise SDE simulation performance can be found [here](@ref sde_simulation_performance). SDE simulations are performed in a similar manner to ODE simulations. The only exception is that an `SDEProblem` is created (rather than an `ODEProblem`). Furthermore, the [StochasticDiffEq.jl](https://github.com/SciML/StochasticDiffEq.jl) package (rather than the OrdinaryDiffEq package) is required for performing simulations. Here we simulate the two-state model for the same parameter set as previously used: ```@example simulation_intro_sde -using Catalyst, StochasticDiffEq +using Catalyst, StochasticDiffEq, Plots two_state_model = @reaction_network begin - (k1,k2), X1 <--> X2 + (k1,k2), X1 <--> X2 end u0 = [:X1 => 100.0, :X2 => 200.0] tspan = (0.0, 1.0) @@ -193,36 +192,55 @@ we can see that while this simulation (unlike the ODE ones) exhibits some fluctu Unlike for ODE and jump simulations, there are no good heuristics for automatically selecting suitable SDE solvers. Hence, for SDE simulations a solver must be provided. `STrapezoid` will work for a large number of cases. When this is not the case, however, please check the list of [available SDE solvers](https://docs.sciml.ai/DiffEqDocs/stable/solvers/sde_solve/) for a suitable alternative (making sure to select one compatible with non-diagonal noise and the [Ito interpretation]https://en.wikipedia.org/wiki/It%C3%B4_calculus). ### [Common SDE simulation pitfalls](@id simulation_intro_SDEs_pitfalls) -Next, let us reduce species amounts (using [`remake`](@ref ref)), thereby also increasing the relative amount of noise, we encounter a problem when the model is simulated: +Next, let us reduce species amounts (using [`remake`](@ref simulation_structure_interfacing_problems_remake)), thereby also increasing the relative amount of noise, we encounter a problem when the model is simulated: ```@example simulation_intro_sde sprob = remake(sprob; u0 = [:X1 => 0.33, :X2 => 0.66]) sol = solve(sprob, STrapezoid()) sol = solve(sprob, STrapezoid(); seed = 1234567) # hide +nothing # hide +``` +Here, we receive a warning that the simulation was terminated. next, if we plot the solution: +```@example simulation_intro_sde plot(sol) ``` -Here, we receive a warning that the simulation was aborted. In the plot, we also see that it is incomplete. In this case we also note that species concentrations are very low (and sometimes, due to the relatively high amount of noise, even negative). This, combined with the early termination, suggests that we are simulating our model for too low species concentration for the assumptions of the CLE to hold. Instead, [jump simulations](@ref simulation_intro_jumps) should be used. +we note that the simulation didn't reach the designated final time point ($t = 1.0$). In this case we also note that species concentrations are very low (and sometimes, due to the relatively high amount of noise, even negative). This, combined with the early termination, suggests that we are simulating our model for too low species concentration for the assumptions of the CLE to hold. Instead, [jump simulations](@ref simulation_intro_jumps) should be used. Next, let us consider a simulation for another parameter set: ```@example simulation_intro_sde sprob = remake(sprob; u0 = [:X1 => 100.0, :X2 => 200.0], p = [:k1 => 200.0, :k2 => 500.0]) sol = solve(sprob, STrapezoid()) +nothing # hide +``` +```@example simulation_intro_sde sol = solve(sprob, STrapezoid(); seed = 12345) # hide plot(sol) ``` -Again, the simulation is aborted. This time, however, species concentrations are relatively large, so the CLE might still hold. What has happened this time is that the accuracy of the simulations has not reached its desired threshold. This can be deal with [by reducing simulation tolerances](@ref ref): +Again, the simulation is aborted. This time, however, species concentrations are relatively large, so the CLE might still hold. What has happened this time is that the accuracy of the simulations has not reached its desired threshold. This can be deal with [by reducing simulation tolerances](@ref ode_simulation_performance_error): ```@example simulation_intro_sde sol = solve(sprob, STrapezoid(), abstol = 1e-1, reltol = 1e-1) sol = solve(sprob, STrapezoid(); seed = 12345, abstol = 1e-1, reltol = 1e-1) # hide plot(sol) ``` +### [SDE simulations with fixed time stepping](@id simulation_intro_SDEs_fixed_dt) +StochasticDiffEq implements SDE solvers with adaptive time stepping. However, when using a non-adaptive solver (or using the `adaptive = false` argument to turn adaptive time stepping off for an adaptive solver) a fixed time step `dt` must be designated. Here we simulate the same `SDEProblem` which we struggled with previously, but using the non-adaptive [`EM`](https://en.wikipedia.org/wiki/Euler%E2%80%93Maruyama_method) solver and a fixed `dt`: +```@example simulation_intro_sde +sol = solve(sprob, EM(); dt = 0.001) +sol = solve(sprob, EM(); dt = 0.001, seed = 1234567) # hide +plot(sol) +``` +We note that this approach also enables us to successfully simulate the SDE we previously struggled with. + +Generally, using a smaller fixed `dt` provides a more exact simulation, but also increases simulation runtime. + ### [Scaling the noise in the chemical Langevin equation](@id simulation_intro_SDEs_noise_saling) When using the CLE to generate SDEs from a CRN, it can sometimes be desirable to scale the magnitude of the noise. This can be done by introducing a *noise scaling term*, with each noise term generated by the CLE being multiplied with this term. A noise scaling term can be set using the `@default_noise_scaling` option: ```@example simulation_intro_sde two_state_model = @reaction_network begin - @default_noise_scaling 0.1 - (k1,k2), X1 <--> X2 + @default_noise_scaling 0.1 + (k1,k2), X1 <--> X2 end +nothing # hide ``` Here, we set the noise scaling term to `0.1`, reducing the noise with a factor $10$ (noise scaling terms $>1.0$ increase the noise, while terms $<1.0$ reduce the noise). If we re-simulate the model using the low-concentration settings used previously, we see that the noise has been reduced (in fact by so much that the model can now be simulated without issues): ```@example simulation_intro_sde @@ -238,10 +256,11 @@ plot(sol) The `@default_noise_scaling` option can take any expression. This can be used to e.g. designate a *noise scaling parameter*: ```@example simulation_intro_sde two_state_model = @reaction_network begin - @parameters η - @default_noise_scaling η - (k1,k2), X1 <--> X2 + @parameters η + @default_noise_scaling η + (k1,k2), X1 <--> X2 end +nothing # hide ``` Now we can tune the noise through $η$'s value. E.g. here we remove the noise entirely by setting $η = 0.0$ (thereby recreating an ODE simulation's behaviour): ```@example simulation_intro_sde @@ -255,29 +274,29 @@ plot(sol) ``` !!! note - Above, Catalyst is unable to infer that $η$ is a parameter from the `@default_noise_scaling η` option only. Hence, `@parameters η` is used to explicitly declare $η$ to be a parameter (as discussed in more detail [here](@ref ref)). + Above, Catalyst is unable to infer that $η$ is a parameter from the `@default_noise_scaling η` option only. Hence, `@parameters η` is used to explicitly declare $η$ to be a parameter (as discussed in more detail [here](@ref dsl_advanced_options_declaring_species_and_parameters)). -It is possible to designate specific noise scaling terms for individual reactions through the `noise_scaling` [reaction metadata](@ref ref). Here, CLE noise terms associated with a specific reaction are multiplied by that reaction's noise scaling term. Here we use this to turn off the noise in the $X1 \to X2$ reaction: +It is possible to designate specific noise scaling terms for individual reactions through the `noise_scaling` [reaction metadata](@ref dsl_advanced_options_reaction_metadata). Here, CLE noise terms associated with a specific reaction are multiplied by that reaction's noise scaling term. Here we use this to turn off the noise in the $X1 \to X2$ reaction: ```@example simulation_intro_sde two_state_model = @reaction_network begin - k1, X1 <--> X2, [noise_scaling = 0.0] - k2, X2 --> X1 + k1, X1 --> X2, [noise_scaling = 0.0] + k2, X2 --> X1 end nothing # hide ``` If the `@default_noise_scaling` option is used, that term is only applied to reactions *without* `noise_scaling` metadata. -While the `@default_noise_scaling` option is unavailable for [programmatically created models](@ref ref), the [`remake_reactionsystem`](@ref) function can be used to achieve a similar effect. +While the `@default_noise_scaling` option is unavailable for [programmatically created models](@ref programmatic_CRN_construction), the `set_default_noise_scaling` function can be used to achieve a similar effect. ## [Performing jump simulations using stochastic chemical kinetics](@id simulation_intro_jumps) -Catalyst uses the [JumpProcesses.jl](https://github.com/SciML/JumpProcesses.jl) package to perform jump simulations. This section provides a brief introduction, with [JumpProcesses's documentation](https://docs.sciml.ai/JumpProcesses/stable/) providing a more extensive description. A dedicated section giving advice on how to optimise jump simulation performance can be found [here](@ref ref). +Catalyst uses the [JumpProcesses.jl](https://github.com/SciML/JumpProcesses.jl) package to perform jump simulations. This section provides a brief introduction, with [JumpProcesses's documentation](https://docs.sciml.ai/JumpProcesses/stable/) providing a more extensive description. Jump simulations are performed using so-called `JumpProblem`s. Unlike ODEs and SDEs (for which the corresponding problem types can be created directly), jump simulations require first creating an intermediary `DiscreteProblem`. In this example, we first declare our two-state model and its initial conditions, time span, and parameter values. ```@example simulation_intro_jumps -using Catalyst +using Catalyst, JumpProcesses, Plots two_state_model = @reaction_network begin - (k1,k2), X1 <--> X2 + (k1,k2), X1 <--> X2 end u0 = [:X1 => 5, :X2 => 10] tspan = (0.0, 5.0) @@ -292,37 +311,33 @@ Next, we bundle these into a `DiscreteProblem` (similarly to how `ODEProblem`s a dprob = DiscreteProblem(two_state_model, u0, tspan, ps) nothing # hide ``` -This is then used as input to a `JumpProblem`. The `JumpProblem` also requires the CRN model as input. +This is then used as input to a `JumpProblem`. The `JumpProblem` also requires the CRN model and [an aggregator](@ref simulation_intro_jumps_solver_designation) as input. ```@example simulation_intro_jumps jprob = JumpProblem(two_state_model, dprob, Direct()) nothing # hide ``` The `JumpProblem` can now be simulated using `solve` (just like any other problem type). ```@example simulation_intro_jumps -using JumpProcesses sol = solve(jprob, SSAStepper()) nothing # hide ``` If we plot the solution we can see how the system's state does not change continuously, but instead in discrete jumps (due to the occurrence of the individual reactions of the system). ```@example simulation_intro_jumps +using Plots plot(sol) ``` ### [Designating aggregators and simulation methods for jump simulations](@id simulation_intro_jumps_solver_designation) Jump simulations (just like ODEs and SDEs) are performed using solver methods. Unlike ODEs and SDEs, jump simulations are carried out by two different types of methods acting in tandem. First, an *aggregator* method is used to (after each reaction) determine the time to, and type of, the next reaction. Next, a simulation method is used to actually carry out the simulation. -Several different aggregators are available (a full list is provided [here](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#Jump-Aggregators-for-Exact-Simulation)). To designate a specific one, provide it as the third argument to the `JumpProblem`. E.g. to designate that Gillespie's direct method (`Direct`) should be used, use: +Several different aggregators are available (a full list is provided [here](https://docs.sciml.ai/JumpProcesses/stable/jump_types/#Jump-Aggregators-for-Exact-Simulation)). To designate a specific one, provide it as the third argument to the `JumpProblem`. E.g. to designate that the sorting direct method (`SortingDirect`) should be used, use: ```@example simulation_intro_jumps -jprob = JumpProblem(brusselator, dprob, Direct()) +jprob = JumpProblem(two_state_model, dprob, SortingDirect()) nothing # hide ``` -Especially for large systems, the choice of aggregator is relevant to simulation performance. A guide for aggregator selection is provided [here](@ref ref). +Especially for large systems, the choice of aggregator is relevant to simulation performance. -Next, a simulation method can be provided (like for ODEs and SDEs) as the second argument to `solve`. Primarily two alternatives are available, `SSAStepper` and `FunctionMap` (other alternatives are only relevant when jump simulations are combined with ODEs/SDEs, which is described in more detail in JumpProcesses's documentation). Generally, `FunctionMap` is only used when a [continuous callback](@ref ref) is used (and `SSAStepper` otherwise). E.g. we can designate that the `FunctionMap` method should be used through: -```@example simulation_intro_jumps -sol = solve(jprob, FunctionMap()) -nothing # hide -``` +Next, a simulation method can be provided (like for ODEs and SDEs) as the second argument to `solve`. Currently, the only relevant solver is `SSAStepper()` (which is the one used throughout Catalyst's documentation). Other choices are primarily relevant to combined ODE/SDE + jump simulations, or inexact simulations. These situations are described in more detail [here](https://docs.sciml.ai/JumpProcesses/stable/jump_solve/). ### [Jump simulations where some rate depends on time](@id simulation_intro_jumps_variableratejumps) For some models, the rate of some reactions depend on time. E.g. consider the following [circadian model](https://en.wikipedia.org/wiki/Circadian_rhythm), where the production rate of some protein ($P$) depends on a sinusoid function: @@ -332,4 +347,54 @@ circadian_model = @reaction_network begin d, P --> 0 end ``` -This type of model will generate so called [*variable rate jumps*](@ref ref). Simulation of such model is non-trivial (and Catalyst currently lacks a good interface for this). A detailed description of how to carry out jump simulations for models with time-dependant rates can be found [here](https://docs.sciml.ai/JumpProcesses/stable/tutorials/simple_poisson_process/#VariableRateJumps-for-processes-that-are-not-constant-between-jumps). \ No newline at end of file +This type of model will generate so called *variable rate jumps*. Simulation of such model is non-trivial (and Catalyst currently lacks a good interface for this). A detailed description of how to carry out jump simulations for models with time-dependant rates can be found [here](https://docs.sciml.ai/JumpProcesses/stable/tutorials/simple_poisson_process/#VariableRateJumps-for-processes-that-are-not-constant-between-jumps). + + +--- +## [Citation](@id simulation_intro_citation) +When you simulate Catalyst models in your research, please cite the corresponding paper(s) to support the simulation package authors. For ODE simulations: +``` +@article{DifferentialEquations.jl-2017, + author = {Rackauckas, Christopher and Nie, Qing}, + doi = {10.5334/jors.151}, + journal = {The Journal of Open Research Software}, + keywords = {Applied Mathematics}, + note = {Exported from https://app.dimensions.ai on 2019/05/05}, + number = {1}, + pages = {}, + title = {DifferentialEquations.jl – A Performant and Feature-Rich Ecosystem for Solving Differential Equations in Julia}, + url = {https://app.dimensions.ai/details/publication/pub.1085583166 and http://openresearchsoftware.metajnl.com/articles/10.5334/jors.151/galley/245/download/}, + volume = {5}, + year = {2017} +} +``` +For SDE simulations: +``` +@article{rackauckas2017adaptive, + title={Adaptive methods for stochastic differential equations via natural embeddings and rejection sampling with memory}, + author={Rackauckas, Christopher and Nie, Qing}, + journal={Discrete and continuous dynamical systems. Series B}, + volume={22}, + number={7}, + pages={2731}, + year={2017}, + publisher={NIH Public Access} +} +``` +For jump simulations: +``` +@misc{2022JumpProcesses, + author = {Isaacson, S. A. and Ilin, V. and Rackauckas, C. V.}, + title = {{JumpProcesses.jl}}, + howpublished = {\url{https://github.com/SciML/JumpProcesses.jl/}}, + year = {2022} +} +@misc{zagatti_extending_2023, + title = {Extending {JumpProcess}.jl for fast point process simulation with time-varying intensities}, + url = {http://arxiv.org/abs/2306.06992}, + doi = {10.48550/arXiv.2306.06992}, + publisher = {arXiv}, + author = {Zagatti, Guilherme Augusto and Isaacson, Samuel A. and Rackauckas, Christopher and Ilin, Vasily and Ng, See-Kiong and Bressan, Stéphane}, + year = {2023}, +} +``` \ No newline at end of file diff --git a/docs/src/model_simulation/simulation_plotting.md b/docs/src/model_simulation/simulation_plotting.md index 25cbec54ee..f8c40d8684 100644 --- a/docs/src/model_simulation/simulation_plotting.md +++ b/docs/src/model_simulation/simulation_plotting.md @@ -1,11 +1,11 @@ -# [Simulation_plotting](@id simulation_plotting) +# [Simulation plotting](@id simulation_plotting) Catalyst uses the [Plots.jl](https://github.com/JuliaPlots/Plots.jl) package for performing all plots. This section provides a brief summary of some useful plotting options, while [Plots.jl's documentation](https://docs.juliaplots.org/stable/) provides a more throughout description of how to tune your plots. !!! note [Makie.jl](https://github.com/MakieOrg/Makie.jl) is a popular alternative to the Plots.jl package. While it is not used within Catalyst's documentation, it is worth considering (especially for users interested in interactivity, or increased control over their plots). ## [Common plotting options](@id simulation_plotting_options) -Let us consider the oscillating [Brusselator](@ref ref) model. We have previously shown how model simulation solutions can be plotted using the `plot` function. Here we plot an ODE simulation from the [Brusselator](@ref ref) model: +Let us consider the oscillating [Brusselator](@ref basic_CRN_library_brusselator) model. We have previously shown how model simulation solutions can be plotted using the `plot` function. Here we plot an ODE simulation from the Brusselator: ```@example simulation_plotting using Catalyst, OrdinaryDiffEq, Plots @@ -53,12 +53,12 @@ A useful option unique to Catalyst (and other DifferentialEquations.jl-based) pl ```@example simulation_plotting plot(sol; idxs = [:X]) ``` -can be used to plot `X` only. When only a single argument is given, the vector form is unnecessary (e.g. `idxs = :X` could have been used instead). If [symbolic species representation is used](@ref ref), this can be used to designate any algebraic expression(s) that should be plotted. E.g. here we plot the total concentration of $X + Y$ throughout the simulation: +can be used to plot `X` only. When only a single argument is given, the vector form is unnecessary (e.g. `idxs = :X` could have been used instead). If symbolic species representation is used, this can be used to designate any algebraic expression(s) that should be plotted. E.g. here we plot the total concentration of $X + Y$ throughout the simulation: ```@example simulation_plotting plot(sol; idxs = brusselator.X + brusselator.Y) ``` -## [Multi-plot plots](@id simulation_plotting_options) +## [Multi-plot plots](@id simulation_plotting_options_subplots) It is possible to save plots in variables. These can then be used as input to the `plot` command. Here, the plot command can be used to create plots containing multiple plots (by providing multiple inputs). E.g. here we plot the concentration of `X` and `Y` in separate subplots: ```@example simulation_plotting plt_X = plot(sol; idxs = [:X]) @@ -71,7 +71,7 @@ When working with subplots, the [`layout`](https://docs.juliaplots.org/latest/la plot(plt_X, plt_Y; layout = (2,1), size = (700,500)) ``` -## [Saving plots](@id simulation_plotting_options) +## [Saving plots](@id simulation_plotting_options_saving) Once a plot has been saved to a variable, the `savefig` function can be used to save it to a file. Here we save our Brusselator plot simulation (the first argument) to a file called "saved_plot.png" (the second argument): ```@example simulation_plotting plt = plot(sol) @@ -80,7 +80,7 @@ rm("saved_plot.png") # hide ``` The plot file type is automatically determined from the extension (if none is given, a .png file is created). -## [Phase-space plots](@id simulation_plotting_options) +## [Phase-space plots](@id simulation_plotting_options_phasespace) By default, simulations are plotted as species concentrations over time. However, [phase space](https://en.wikipedia.org/wiki/Phase_space#:~:text=In%20dynamical%20systems%20theory%20and,point%20in%20the%20phase%20space.) plots are also possible. This is done by designating the axis arguments using the `idxs` option, but providing them as a tuple. E.g. here we plot our simulation in $X-Y$ space: ```@example simulation_plotting plot(sol; idxs = (:X, :Y)) diff --git a/docs/src/model_simulation/simulation_structure_interfacing.md b/docs/src/model_simulation/simulation_structure_interfacing.md index a993841262..1c37df3672 100644 --- a/docs/src/model_simulation/simulation_structure_interfacing.md +++ b/docs/src/model_simulation/simulation_structure_interfacing.md @@ -1,11 +1,11 @@ # [Interfacing problems, integrators, and solutions](@id simulation_structure_interfacing) When simulating a model, one begins with creating a [problem](https://docs.sciml.ai/DiffEqDocs/stable/basics/problem/). Next, a simulation is performed on the problem, during which the simulation's state is recorded through an [integrator](https://docs.sciml.ai/DiffEqDocs/stable/basics/integrator/). Finally, the simulation output is returned as a [solution](https://docs.sciml.ai/DiffEqDocs/stable/basics/solution/). This tutorial describes how to access (or modify) the state (or parameter) values of problem, integrator, and solution structures. -Generally, when we have a structure `simulation_struct` and want to interface with the unknown (or parameter) `x`, we use `simulation_struct[:x]` to access the value, and `simulation_struct[:x] = 5.0` to set it to a new value. For situations where a value is accessed (or changed) a large number of times, it can *improve performance* to first create a [specialised getter/setter function](@ref simulation_structure_interfacing_functions). +Generally, when we have a structure `simulation_struct` and want to interface with the unknown (or parameter) `x`, we use `simulation_struct[:x]` to access the value. For situations where a value is accessed (or changed) a large number of times, it can *improve performance* to first create a [specialised getter/setter function](@ref simulation_structure_interfacing_functions). ## [Interfacing problem objects](@id simulation_structure_interfacing_problems) -We begin by demonstrating how we can interface with problem objects. First, we create an `ODEProblem` representation of a [chemical cross-coupling model](@ref ref) (where a catalyst, $C$, couples two substrates, $S₁$ and $S₂$, to form a product, $P$). +We begin by demonstrating how we can interface with problem objects. First, we create an `ODEProblem` representation of a [chemical cross-coupling model](@ref basic_CRN_library_cc) (where a catalyst, $C$, couples two substrates, $S₁$ and $S₂$, to form a product, $P$). ```@example structure_indexing using Catalyst cc_system = @reaction_network begin @@ -21,7 +21,7 @@ oprob = ODEProblem(cc_system, u0, tspan, ps) nothing # hide ``` -We can find a specie's (or [variable's](@ref ref)) initial condition value by simply indexing with the species of interest as input. Here we check the initial condition value of $C$: +We can find a species's (or [variable's](@ref constraint_equations)) initial condition value by simply indexing with the species of interest as input. Here we check the initial condition value of $C$: ```@example structure_indexing oprob[:C] ``` @@ -35,26 +35,6 @@ To retrieve several species initial condition (or parameter) values, simply give oprob[[:S₁, :S₂]] ``` -We can change a species's initial condition value using a similar notation. Here we increase the initial concentration of $C$ (and also confirm that the new value is stored in an updated `oprob`): -```@example structure_indexing -oprob[:C] = 0.1 -oprob[:C] -``` -Again, parameter values can be changed using a similar notation, however, again requiring `oprob.ps` notation: -```@example structure_indexing -oprob.ps[:k₁] = 10.0 -oprob.ps[:k₁] -``` -Finally, vectors can be used to update multiple quantities simultaneously -```@example structure_indexing -oprob[[:S₁, :S₂]] = [0.5, 0.3] -oprob[[:S₁, :S₂]] -``` -Generally, when updating problems, it is often better to use the [`remake` function](@ref simulation_structure_interfacing_problems_remake) (especially when several values are updated). - -!!! warn - Indexing *should not* be used not modify `JumpProblem`s. Here, [remake](@ref ref) should be used exclusively. - A problem's time span can be accessed through the `tspan` field: ```@example structure_indexing oprob.tspan @@ -64,8 +44,8 @@ oprob.tspan Here we have used an `ODEProblem`to demonstrate all interfacing functionality. However, identical workflows work for the other problem types. ### [Remaking problems using the `remake` function](@id simulation_structure_interfacing_problems_remake) -The `remake` function offers an (to indexing) alternative approach for updating problems. Unlike indexing, `remake` creates a new problem (rather than updating the old one). Furthermore, it permits the updating of several values simultaneously. The `remake` function takes the following inputs: -- The problem that is remakes. +To modify a problem, the `remake` function should be used. It takes an already created problem, and returns a new, updated, one (the input problem is unchanged). The `remake` function takes the following inputs: +- The problem that it remakes. - (optionally) `u0`: A vector with initial conditions that should be updated. The vector takes the same form as normal initial condition vectors, but does not need to be complete (in which case only a subset of the initial conditions are updated). - (optionally) `tspan`: An updated time span (using the same format as time spans normally are given in). - (optionally) `p`: A vector with parameters that should be updated. The vector takes the same form as normal parameter vectors, but does not need to be complete (in which case only a subset of the parameters are updated). @@ -74,22 +54,28 @@ Here we modify our problem to increase the initial condition concentrations of t ```@example structure_indexing using OrdinaryDiffEq oprob_new = remake(oprob; u0 = [:S₁ => 5.0, :S₂ => 2.5]) -oprob_new == oprob +oprob_new != oprob ``` -Here, we instead use `remake` to simultaneously update a all three fields: +Here, we instead use `remake` to simultaneously update all three fields: ```@example structure_indexing oprob_new_2 = remake(oprob; u0 = [:C => 0.2], tspan = (0.0, 20.0), p = [:k₁ => 2.0, :k₂ => 2.0]) nothing # hide ``` +Typically, when using `remake` to update a problem, the common workflow is to overwrite the old one with the output. E.g. to set the value of `k₁` to `5.0` in `oprob`, you would do: +```@example structure_indexing +oprob = remake(oprob; p = [:k₁ => 5.0]) +nothing # hide +``` + ## [Interfacing integrator objects](@id simulation_structure_interfacing_integrators) -During a simulation, the solution is stored in an integrator object. Here, we will describe how to interface with these. The almost exclusive circumstance when integrator-interfacing is relevant is when simulation events are [implemented through callbacks](@ref ref). However, to demonstrate integrator indexing in this tutorial, we will create one through the `init` function (while circumstances where one might [want to use `init` function exist](https://docs.sciml.ai/DiffEqDocs/stable/basics/integrator/#Initialization-and-Stepping), since integrators are automatically created during simulations, these are rare). +During a simulation, the solution is stored in an integrator object. Here, we will describe how to interface with these. The almost exclusive circumstance when integrator-interfacing is relevant is when simulation events are implemented through [callbacks](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/). However, to demonstrate integrator indexing in this tutorial, we will create one through the `init` function (while circumstances where one might [want to use `init` function exist](https://docs.sciml.ai/DiffEqDocs/stable/basics/integrator/#Initialization-and-Stepping), since integrators are automatically created during simulations, these are rare). ```@example structure_indexing integrator = init(oprob) nothing # hide ``` -We can interface with our integrator using an identical syntax as [was used for problems](@ref simulation_structure_interfacing_problems) (with the exception that `remake` is not available). Here we update, and then check the values of, first the species $C$ and then the parameter $k₁$: +We can interface with our integrator using an identical syntax as [was used for problems](@ref simulation_structure_interfacing_problems). The primary exception is that there is no `remake` function for integrators. Instead, we can update species and parameter values using normal indexing. Here we update, and then check the values of, first the species $C$ and then the parameter $k₁$: ```@example structure_indexing integrator[:C] = 0.0 integrator[:C] @@ -101,7 +87,7 @@ integrator.ps[:k₂] ``` Note that here, species-interfacing yields (or changes) a simulation's current value for a species, not its initial condition. -If you are interfacing with jump simulation integrators, please read [this, highly relevant, section](@ref ref). +If you are interfacing with jump simulation integrators, you must always call `reset_aggregated_jumps!(integrator)` afterwards. ## [Interfacing solution objects](@id simulation_structure_interfacing_solutions) @@ -162,9 +148,9 @@ get_S(oprob) ``` ## [Interfacing using symbolic representations](@id simulation_structure_interfacing_symbolic_representation) -As [previously described](@ref ref), when e.g. [programmatic modelling is used](@ref ref), species and parameters can be represented as *symbolic variables*. These can be used to index a problem, just like symbol-based representations can. Here we create a simple [two-state model](@ref ref) programmatically, and use its symbolic variables to check, and update, an initial condition: +When e.g. [programmatic modelling is used](@ref programmatic_CRN_construction), species and parameters can be represented as *symbolic variables*. These can be used to index a problem, just like symbol-based representations can. Here we create a simple [two-state model](@ref basic_CRN_library_two_states) programmatically, and use its symbolic variables to check, and update, an initial condition: ```@example structure_indexing_symbolic_variables -using Catalyst +using Catalyst, OrdinaryDiffEq t = default_t() @species X1(t) X2(t) @parameters k1 k2 @@ -180,12 +166,12 @@ tspan = (0.0, 1.0) ps = [k1 => 1.0, k2 => 2.0] oprob = ODEProblem(two_state_model, u0, tspan, ps) -oprob[X1] = 5.0 +oprob = remake(oprob; u0 = [X1 => 5.0]) oprob[X1] ``` Symbolic variables can be used to access or update species or parameters for all the cases when `Symbol`s can (including when using `remake` or e.g. `getu`). -An advantage when quantities are represented as symbolic variables is that [symbolic expressions](@ref ref) can be formed and used to index a structure. E.g. here we check the combined initial concentration of $X$ ($X1 + X2$) in our two-state problem: +An advantage when quantities are represented as symbolic variables is that symbolic expressions can be formed and used to index a structure. E.g. here we check the combined initial concentration of $X$ ($X1 + X2$) in our two-state problem: ```@example structure_indexing_symbolic_variables oprob[X1 + X2] ``` @@ -194,8 +180,7 @@ Just like symbolic variables can be used to directly interface with a structure, ```@example structure_indexing_symbolic_variables oprob[two_state_model.X1 + two_state_model.X2] ``` -This can be used to form symbolic expressions using model quantities when a model has been created using the DSL (as an alternative to [@unpack] -(@ref ref)). Alternatively, [creating an observable](@ref ref), and then interface using its `Symbol` representation, is also possible. +This can be used to form symbolic expressions using model quantities when a model has been created using the DSL (as an alternative to @unpack). Alternatively, [creating an observable](@ref dsl_advanced_options_observables), and then interface using its `Symbol` representation, is also possible. -!!! warn - With interfacing with a simulating structure using symbolic variables stored in a `ReactionSystem` model, ensure that the [model is complete](@ref ref). \ No newline at end of file +!!! warning + When accessing a simulation structure using symbolic variables from a `ReactionSystem` model, such as `rn.A` for `rn` a `ReactionSystem` and `A` a species within it, ensure that the model is complete. diff --git a/docs/src/steady_state_functionality/bifurcation_diagrams.md b/docs/src/steady_state_functionality/bifurcation_diagrams.md index d869141e03..37ea73dfa2 100644 --- a/docs/src/steady_state_functionality/bifurcation_diagrams.md +++ b/docs/src/steady_state_functionality/bifurcation_diagrams.md @@ -37,21 +37,21 @@ nothing # hide BifurcationKit computes bifurcation diagrams using the `bifurcationdiagram` function. From an initial point in the diagram, it tracks the solution (using a continuation algorithm) until the entire diagram is computed (BifurcationKit's continuation can be used for other purposes, however, this tutorial focuses on bifurcation diagram computation). The continuation settings are provided in a `ContinuationPar` structure. In this example, we will only specify three settings, `p_min` and `p_max` (which sets the minimum and maximum values over which the bifurcation parameter is varied) and `max_steps` (the maximum number of continuation steps to take as the bifurcation diagram is tracked). We wish to compute a bifurcation diagram over the interval $(2.0,20.0)$, and will use the following settings: ```@example ex1 p_span = (2.0, 20.0) -opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps=1000) +opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps = 1000) nothing # hide ``` Finally, we compute our bifurcation diagram using: ```@example ex1 -bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside=true) +bif_dia = bifurcationdiagram(bprob, PALC(), 2, opts_br; bothside = true) nothing # hide ``` -Where `PALC()` designates that we wish to use the pseudo arclength continuation method to track our solution. The third argument (`2`) designates the maximum number of recursions when branches of branches are computed (branches appear as continuation encounters certain bifurcation points). For diagrams with highly branched structures (rare for many common small chemical reaction networks) this input is important. Finally, `bothside=true` designates that we wish to perform continuation on both sides of the initial point (which is typically the case). +Where `PALC()` designates that we wish to use the pseudo arclength continuation method to track our solution. The third argument (`2`) designates the maximum number of recursions when branches of branches are computed (branches appear as continuation encounters certain bifurcation points). For diagrams with highly branched structures (rare for many common small chemical reaction networks) this input is important. Finally, `bothside = true` designates that we wish to perform continuation on both sides of the initial point (which is typically the case). We can plot our bifurcation diagram using the Plots.jl package: ```@example ex1 using Plots -plot(bif_dia; xguide="k1", yguide="X") +plot(bif_dia; xguide = "k1", yguide = "X") ``` Here, the steady state concentration of $X$ is shown as a function of $k1$'s value. Stable steady states are shown with thick lines, unstable ones with thin lines. The two [fold bifurcation points](https://en.wikipedia.org/wiki/Saddle-node_bifurcation) are marked with "bp". @@ -69,7 +69,7 @@ opt_newton = NewtonPar(tol = 1e-9, max_iterations = 1000) opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], dsmin = 0.001, dsmax = 0.01, max_steps = 1000, newton_options = opt_newton) -bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside=true) +bif_dia = bifurcationdiagram(bprob, PALC(), 2, opts_br; bothside = true) nothing # hide ``` (however, in this case these additional settings have no significant effect on the result) @@ -79,8 +79,8 @@ Let's consider the previous case, but instead compute the bifurcation diagram ov ```@example ex1 p_span = (2.0, 15.0) opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps = 1000) -bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside=true) -plot(bif_dia; xguide="k1", yguide="X") +bif_dia = bifurcationdiagram(bprob, PALC(), 2, opts_br; bothside = true) +plot(bif_dia; xguide = "k1", yguide = "X") ``` Here, in the bistable region, we only see a single branch. The reason is that the continuation algorithm starts at our initial guess (here made at $k1 = 4.0$ for $(X,Y) = (5.0,2.0)$) and tracks the diagram from there. However, with the upper bound set at $k1=15.0$ the bifurcation diagram has a disjoint branch structure, preventing the full diagram from being computed by continuation alone. In this case it could be solved by increasing the bound from $k1=15.0$, however, this is not possible in all cases. In these cases, *deflation* can be used. This is described in the [BifurcationKit documentation](https://bifurcationkit.github.io/BifurcationKitDocs.jl/dev/tutorials/tutorials2/#Snaking-computed-with-deflation). @@ -99,12 +99,12 @@ end u_guess = [:K => 1.0, :X => 1.0, :Xp => 1.0] p_start = [:p => 1.0, :d => 0.5, :kP => 2.0, :kD => 5.0] u0 = [:X => 1.0, :Xp => 0.0] -bprob = BifurcationProblem(kinase_model, u_guess, p_start, :d; plot_var=:Xp, u0=u0) +bprob = BifurcationProblem(kinase_model, u_guess, p_start, :d; plot_var = :Xp, u0) p_span = (0.1, 10.0) opts_br = ContinuationPar(p_min = p_span[1], p_max = p_span[2], max_steps = 1000) -bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside=true) -plot(bif_dia; xguide="d", yguide="Xp") +bif_dia = bifurcationdiagram(bprob, PALC(), 2, opts_br; bothside = true) +plot(bif_dia; xguide = "d", yguide = "Xp") ``` This bifurcation diagram does not contain any interesting features (such as bifurcation points), and only shows how the steady state concentration of $Xp$ is reduced as $d$ increases. diff --git a/docs/src/steady_state_functionality/dynamical_systems.md b/docs/src/steady_state_functionality/dynamical_systems.md index fcd56129e0..ab48f99bac 100644 --- a/docs/src/steady_state_functionality/dynamical_systems.md +++ b/docs/src/steady_state_functionality/dynamical_systems.md @@ -1,10 +1,10 @@ # [Analysing model steady state properties with DynamicalSystems.jl](@id dynamical_systems) -The [DynamicalSystems.jl package](https://github.com/JuliaDynamics/DynamicalSystems.jl) implements a wide range of methods for analysing dynamical systems. This includes both continuous-time systems (i.e. ODEs) and discrete-times ones (difference equations, however, these are not relevant to chemical reaction network modelling). Here we give two examples of how DynamicalSystems.jl can be used, with the package's [documentation describing many more features](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/dynamicalsystems/dev/tutorial/). Finally, it should also be noted that DynamicalSystems.jl contain several tools for [analysing data measured from dynamical systems](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/dynamicalsystems/dev/contents/#Exported-submodules). +The [DynamicalSystems.jl package](https://github.com/JuliaDynamics/DynamicalSystems.jl) implements a wide range of methods for analysing dynamical systems[^1][^2]. This includes both continuous-time systems (i.e. ODEs) and discrete-times ones (difference equations, however, these are not relevant to chemical reaction network modelling). Here we give two examples of how DynamicalSystems.jl can be used, with the package's [documentation describing many more features](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/dynamicalsystems/dev/tutorial/). Finally, it should also be noted that DynamicalSystems.jl contain several tools for [analysing data measured from dynamical systems](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/dynamicalsystems/dev/contents/#Exported-submodules). ## [Finding basins of attraction](@id dynamical_systems_basins_of_attraction) Given enough time, an ODE will eventually reach a so-called [*attractor*](https://en.wikipedia.org/wiki/Attractor). For chemical reaction networks (CRNs), this will typically be either a *steady state* or a *limit cycle*. Since ODEs are deterministic, which attractor a simulation will reach is uniquely determined by the initial condition (assuming parameter values are fixed). Conversely, each attractor is associated with a set of initial conditions such that model simulations originating in these will tend to that attractor. These sets are called *basins of attraction*. Here, phase space (the space of all possible states of the system) can be divided into a number of basins of attraction equal to the number of attractors. -DynamicalSystems.jl provides a simple interface for finding an ODE's basins of attraction across any given subspace of phase space. In this example we will use the bistable [Wilhelm model](https://bmcsystbiol.biomedcentral.com/articles/10.1186/1752-0509-3-90) (which steady states we have previous [computed using homotopy continuation](@ref homotopy_continuation_basic_example)). As a first step, we create an `ODEProblem` corresponding to the model which basins of attraction we wish to compute. For this application, `u0` and `tspan` is unused, and their values are of little importance (the only exception is than `tspan`, for implementation reason, must provide a not too small interval, we recommend minimum `(0.0, 1.0)`). +DynamicalSystems.jl provides a simple interface for finding an ODE's basins of attraction across any given subspace of phase space. In this example we will use the bistable [Wilhelm model](https://bmcsystbiol.biomedcentral.com/articles/10.1186/1752-0509-3-90) (which steady states we have previous [computed using homotopy continuation](@ref homotopy_continuation)). As a first step, we create an `ODEProblem` corresponding to the model which basins of attraction we wish to compute. For this application, `u0` and `tspan` is unused, and their values are of little importance (the only exception is than `tspan`, for implementation reason, must provide a not too small interval, we recommend minimum `(0.0, 1.0)`). ```@example dynamical_systems_basins using Catalyst wilhelm_model = @reaction_network begin @@ -54,7 +54,7 @@ More information on how to compute basins of attractions for ODEs using Dynamica While Lyapunov exponents can be used for other purposes, they are primarily used to characterise [*chaotic behaviours*](https://en.wikipedia.org/wiki/Chaos_theory) (where small changes in initial conditions has large effect on the resulting trajectories). Generally, an ODE exhibit chaotic behaviour if its attractor(s) have *at least one* positive Lyapunov exponent. Practically, Lyapunov exponents can be computed using DynamicalSystems.jl's `lyapunovspectrum` function. Here we will use it to investigate two models, one which exhibits chaos and one which do not. -First, let us consider the [Willamowski–Rössler model](@ref ref), which is known to exhibit chaotic behaviour. +First, let us consider the [Willamowski–Rössler model](@ref basic_CRN_library_wr), which is known to exhibit chaotic behaviour. ```@example dynamical_systems_lyapunov using Catalyst wr_model = @reaction_network begin @@ -89,7 +89,7 @@ Here, the `autodiff = false` argument is required when Lyapunov spectrums are co ```@example dynamical_systems_lyapunov lyapunovspectrum(ds, 100) ``` -Here, the largest exponent is positive, suggesting that the model is chaotic (or, more accurately, it has at least one chaotic attractor, to which is approached from the initial condition $(1.5,1.5,1.5)). +Here, the largest exponent is positive, suggesting that the model is chaotic (or, more accurately, it has at least one chaotic attractor, to which is approached from the initial condition $(1.5,1.5,1.5)$). Next, we consider the [Brusselator] model. First we simulate the model for two similar initial conditions, confirming that they converge to the same limit cycle: ```@example dynamical_systems_lyapunov @@ -124,7 +124,7 @@ More details on how to compute Lyapunov exponents using DynamicalSystems.jl can --- ## [Citations](@id dynamical_systems_citations) -If you use this functionality in your research, [in addition to Catalyst](@ref catalyst_citation), please cite the following paper to support the author of the DynamicalSystems.jl package: +If you use this functionality in your research, [in addition to Catalyst](@ref doc_index_citation), please cite the following paper to support the author of the DynamicalSystems.jl package: ``` @article{DynamicalSystems.jl-2018, doi = {10.21105/joss.00598}, @@ -144,11 +144,11 @@ If you use this functionality in your research, [in addition to Catalyst](@ref c --- ## Learning more -If you want to learn more about analysing dynamical systems, including chaotic behaviour, you can have a look at the textbook [Nonlinear Dynamics](https://link.springer.com/book/10.1007/978-3-030-91032-7). It utilizes DynamicalSystems.jl and provides a concise, hands-on approach to learning nonlinear dynamics and analysing dynamical systems [^1]. +If you want to learn more about analysing dynamical systems, including chaotic behaviour, see the textbook [Nonlinear Dynamics](https://link.springer.com/book/10.1007/978-3-030-91032-7). It utilizes DynamicalSystems.jl and provides a concise, hands-on approach to learning nonlinear dynamics and analysing dynamical systems [^3]. --- ## References -[^1]: [G. Datseris, U. Parlitz, *Nonlinear dynamics: A concise introduction interlaced with code*, Springer (2022).](https://link.springer.com/book/10.1007/978-3-030-91032-7) -[^2]: [S. H. Strogatz, *Nonlinear Dynamics and Chaos*, Westview Press (1994).](http://users.uoa.gr/~pjioannou/nonlin/Strogatz,%20S.%20H.%20-%20Nonlinear%20Dynamics%20And%20Chaos.pdf) -[^3]: [A. M. Lyapunov, *The general problem of the stability of motion*, International Journal of Control (1992).](https://www.tandfonline.com/doi/abs/10.1080/00207179208934253) \ No newline at end of file +[^1]: [S. H. Strogatz, *Nonlinear Dynamics and Chaos*, Westview Press (1994).](http://users.uoa.gr/~pjioannou/nonlin/Strogatz,%20S.%20H.%20-%20Nonlinear%20Dynamics%20And%20Chaos.pdf) +[^2]: [A. M. Lyapunov, *The general problem of the stability of motion*, International Journal of Control (1992).](https://www.tandfonline.com/doi/abs/10.1080/00207179208934253) +[^3]: [G. Datseris, U. Parlitz, *Nonlinear dynamics: A concise introduction interlaced with code*, Springer (2022).](https://link.springer.com/book/10.1007/978-3-030-91032-7) \ No newline at end of file diff --git a/docs/src/steady_state_functionality/homotopy_continuation.md b/docs/src/steady_state_functionality/homotopy_continuation.md index 19a15d39e6..f6a5e340c9 100644 --- a/docs/src/steady_state_functionality/homotopy_continuation.md +++ b/docs/src/steady_state_functionality/homotopy_continuation.md @@ -6,13 +6,13 @@ method guaranteed to find all steady states for a system that has multiple ones. However, many chemical reaction networks generate polynomial systems (for example those which are purely mass action or have only have [Hill functions](https://en.wikipedia.org/wiki/Hill_equation_(biochemistry)) with integer Hill exponents). The roots of these can reliably be found through a -*homotopy continuation* algorithm [^1]. This is implemented in Julia through the -[HomotopyContinuation.jl](https://www.juliahomotopycontinuation.org/) package. +*homotopy continuation* algorithm[^1][^2]. This is implemented in Julia through the +[HomotopyContinuation.jl](https://www.juliahomotopycontinuation.org/) package[^3]. Catalyst contains a special homotopy continuation extension that is loaded whenever HomotopyContinuation.jl is. This exports a single function, `hc_steady_states`, that can be used to find the steady states of any `ReactionSystem` structure. -For this tutorial, we will use the [Wilhelm model](@ref ref) (which +For this tutorial, we will use the [Wilhelm model](@ref basic_CRN_library_wilhelm) (which demonstrates bistability in a small chemical reaction network). We declare the model and the parameter set for which we want to find the steady states: ```@example hc_basics @@ -47,7 +47,7 @@ two_state_model = @reaction_network begin (k1,k2), X1 <--> X2 end ``` -Catalyst allows the conservation laws of such systems to be computed [using the `conservationlaws` function](@ref ref). By using these to reduce the dimensionality of the system, as well as specifying the initial amount of each species, homotopy continuation can again be used to find steady states. Here we do this by designating such an initial condition (which is used to compute the system's conserved quantities, in this case $X1 + X2$): +Catalyst allows the conservation laws of such systems to be computed using the `conservationlaws` function. By using these to reduce the dimensionality of the system, as well as specifying the initial amount of each species, homotopy continuation can again be used to find steady states. Here we do this by designating such an initial condition (which is used to compute the system's conserved quantities, in this case $X1 + X2$): ```@example hc_claws import HomotopyContinuation # hide ps = [:k1 => 2.0, :k2 => 1.0] @@ -79,5 +79,5 @@ If you use this functionality in your research, please cite the following paper --- ## References [^1]: [Andrew J Sommese, Charles W Wampler *The Numerical Solution of Systems of Polynomials Arising in Engineering and Science*, World Scientific (2005).](https://www.worldscientific.com/worldscibooks/10.1142/5763#t=aboutBook) -[^2]: [Paul Breiding, Sascha Timme, *HomotopyContinuation.jl: A Package for Homotopy Continuation in Julia*, International Congress on Mathematical Software (2018).](https://link.springer.com/chapter/10.1007/978-3-319-96418-8_54) -[^43]: [Daniel J. Bates, Paul Breiding, Tianran Chen, Jonathan D. Hauenstein, Anton Leykin, Frank Sottile, *Numerical Nonlinear Algebra*, arXiv (2023).](https://arxiv.org/abs/2302.08585) \ No newline at end of file +[^2]: [Daniel J. Bates, Paul Breiding, Tianran Chen, Jonathan D. Hauenstein, Anton Leykin, Frank Sottile, *Numerical Nonlinear Algebra*, arXiv (2023).](https://arxiv.org/abs/2302.08585) +[^3]: [Paul Breiding, Sascha Timme, *HomotopyContinuation.jl: A Package for Homotopy Continuation in Julia*, International Congress on Mathematical Software (2018).](https://link.springer.com/chapter/10.1007/978-3-319-96418-8_54) \ No newline at end of file diff --git a/docs/src/steady_state_functionality/nonlinear_solve.md b/docs/src/steady_state_functionality/nonlinear_solve.md index 601fd51c93..d5e438d89c 100644 --- a/docs/src/steady_state_functionality/nonlinear_solve.md +++ b/docs/src/steady_state_functionality/nonlinear_solve.md @@ -1,16 +1,16 @@ # [Finding Steady States using NonlinearSolve.jl and SteadyStateDiffEq.jl](@id steady_state_solving) -Catalyst `ReactionSystem` models can be converted to ODEs (through [the reaction rate equation](@ref ref)). We have previously described how these ODEs' steady states can be found through [homotopy continuation](@ref homotopy_continuation). Generally, homotopy continuation (due to its ability to find *all* of a system's steady states) is the preferred approach. However, Catalyst supports two additional approaches for finding steady states: -- Through solving the nonlinear system produced by setting all ODE differentials to 0. +Catalyst `ReactionSystem` models can be converted to ODEs (through [the reaction rate equation](@ref introduction_to_catalyst_ratelaws)). We have previously described how these ODEs' steady states can be found through [homotopy continuation](@ref homotopy_continuation). Generally, homotopy continuation (due to its ability to find *all* of a system's steady states) is the preferred approach. However, Catalyst supports two additional approaches for finding steady states: +- Through solving the nonlinear system produced by setting all ODE differentials to 0[^1]. - Through forward ODE simulation from an initial condition until a steady state has been reached. While these approaches only find a single steady state, they offer two advantages as compared to homotopy continuation: - They are typically much faster. - They can find steady states for models that do not produce multivariate, rational, polynomial systems (which is a requirement for homotopy continuation to work). Examples include models with non-integer hill coefficients. -In practice, model steady states are found through [nonlinear system solving](@ref steady_state_solving_nonlinear) by creating a `NonlinearProblem`, and through [forward ODE simulation](@ref ref) by creating a `SteadyStateProblem`. These are then solved through solvers implemented in the [NonlinearSolve.jl](https://github.com/SciML/NonlinearSolve.jl), package (with the latter approach also requiring the [SteadyStateDiffEq.jl](https://github.com/SciML/SteadyStateDiffEq.jl) package). This tutorial describes how to find steady states through these two approaches. More extensive descriptions of available solvers and options can be found in [NonlinearSolve's documentation](https://docs.sciml.ai/NonlinearSolve/stable/). +In practice, model steady states are found through [nonlinear system solving](@ref steady_state_solving_nonlinear) by creating a `NonlinearProblem`, and through forward ODE simulation by creating a `SteadyStateProblem`. These are then solved through solvers implemented in the [NonlinearSolve.jl](https://github.com/SciML/NonlinearSolve.jl), package (with the latter approach also requiring the [SteadyStateDiffEq.jl](https://github.com/SciML/SteadyStateDiffEq.jl) package). This tutorial describes how to find steady states through these two approaches. More extensive descriptions of available solvers and options can be found in [NonlinearSolve's documentation](https://docs.sciml.ai/NonlinearSolve/stable/). -## [Steady state finding through nonlinear solving](@ref steady_state_solving_nonlinear) +## [Steady state finding through nonlinear solving](@id steady_state_solving_nonlinear) Let us consider a simple dimerisation system, where a protein ($P$) can exist in a monomer and a dimer form. The protein is produced at a constant rate from its mRNA, which is also produced at a constant rate. ```@example steady_state_solving_nonlinear using Catalyst @@ -43,6 +43,7 @@ To solve this problem, we must first designate our parameter values, and also ma p = [:pₘ => 0.5, :pₚ => 2.0, :k₁ => 5.0, :k₂ => 1.0, :d => 1.0] u_guess = [:mRNA => 1.0, :P => 1.0, :P₂ => 1.0] nlprob = NonlinearProblem(dimer_production, u_guess, p) +nothing # hide ``` Finally, we can solve it using the `solve` command, returning the steady state solution: ```@example steady_state_solving_nonlinear @@ -57,14 +58,14 @@ sol ≈ sol_ntr ``` ### [Systems with conservation laws](@id steady_state_solving_nonlinear_conservation_laws) -As described in the section on homotopy continuation, when finding the steady states of systems with conservation laws, [additional considerations have to be taken](homotopy_continuation_conservation_laws). E.g. consider the following [two-state system](@ref ref): +As described in the section on homotopy continuation, when finding the steady states of systems with conservation laws, [additional considerations have to be taken](@ref homotopy_continuation_conservation_laws). E.g. consider the following [two-state system](@ref basic_CRN_library_two_states): ```@example steady_state_solving_claws using Catalyst, NonlinearSolve # hide two_state_model = @reaction_network begin (k1,k2), X1 <--> X2 end ``` -It has an infinite number of steady states. To make steady state finding possible, information of the system's conserved quantities (here $C = X1 + X2$) must be provided. Since these can be computed from system initial conditions (`u0`, i.e. those provided when performing ODE simulations), designating an `u0` is often the best way. There are two ways to do this. First, one can perform [forward ODE simulation-based steady state finding](@ref steady_state_solving_simulation), using the initial condition as the initial `u` guess. Alternatively, any conserved quantities can be eliminated when the `NonlinearProblem` is created. This feature is supported by Catalyst's [conservation law finding and elimination feature](@ref ref). +It has an infinite number of steady states. To make steady state finding possible, information of the system's conserved quantities (here $C = X1 + X2$) must be provided. Since these can be computed from system initial conditions (`u0`, i.e. those provided when performing ODE simulations), designating an `u0` is often the best way. There are two ways to do this. First, one can perform [forward ODE simulation-based steady state finding](@ref steady_state_solving_simulation), using the initial condition as the initial `u` guess. Alternatively, any conserved quantities can be eliminated when the `NonlinearProblem` is created. This feature is supported by Catalyst's conservation law finding and elimination feature. To eliminate conservation laws we simply provide the `remove_conserved = true` argument to `NonlinearProblem`: ```@example steady_state_solving_claws @@ -85,6 +86,7 @@ sol[[:X1, :X2]] ## [Finding steady states through ODE simulations](@id steady_state_solving_simulation) The `NonlinearProblem`s generated by Catalyst corresponds to ODEs. A common method of solving these is to simulate the ODE from an initial condition until a steady state is reached. Here we do so for the dimerisation system considered in the previous section. First, we declare our model, initial condition, and parameter values. ```@example steady_state_solving_simulation +using Catalyst # hide dimer_production = @reaction_network begin pₘ, 0 --> mRNA pₚ, mRNA --> mRNA + P @@ -98,21 +100,18 @@ nothing # hide Next, we provide these as an input to a `SteadyStateProblem` ```@example steady_state_solving_simulation ssprob = SteadyStateProblem(dimer_production, u0, p) +nothing # hide ``` -Finally, we can find the steady states using the `solver` command (which requires loading the SteadyStateDiffEq package). -```@example steady_state_solving_simulation -using SteadyStateDiffEq -solve(ssprob) -``` -Note that, unlike for nonlinear system solving, `u0` is not just an initial guess of the solution, but the initial conditions from which the steady state simulation is carried out. This means that, for a system with multiple steady states, we can determine the steady states associated with specific initial conditions (which is not possible when the nonlinear solving approach is used). This also permits us to easily [handle the presence of conservation laws](@ref steady_state_solving_nonlinear_conservation_laws). The forward ODE simulation approach (unlike homotopy continuation and nonlinear solving) cannot find unstable steady states. +Finally, we can find the steady states using the `solver` command. Since `SteadyStateProblem`s are solved through forward ODE simulation, we must load the [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) package, and [select an ODE solver](@ref simulation_intro_solver_options). Any available ODE solver can be used, however, it has to be encapsulated by the `DynamicSS()` function. E.g. here we designate the `Rodas5P` solver: -The forward ODE solving approach uses the ODE solvers implemented by the [OrdinaryDiffEq.jl](@ref ref) package. If this package is loaded, it is possible to designate a specific solver to use. Any available ODE solver can be used, however, it has to be encapsulated by the `DynamicSS()` function. E.g. here we designate the `Rodas5P` solver: +(which requires loading the SteadyStateDiffEq package). ```@example steady_state_solving_simulation -using OrdinaryDiffEqDiffEq +using SteadyStateDiffEq, OrdinaryDiffEq solve(ssprob, DynamicSS(Rodas5P())) -nothing # hide ``` -Generally, `SteadyStateProblem`s can be solved using the [same options that are available for ODE simulations](@ref ref). E.g. here we designate a specific `dt` step size: +Note that, unlike for nonlinear system solving, `u0` is not just an initial guess of the solution, but the initial conditions from which the steady state simulation is carried out. This means that, for a system with multiple steady states, we can determine the steady states associated with specific initial conditions (which is not possible when the nonlinear solving approach is used). This also permits us to easily [handle the presence of conservation laws](@ref steady_state_solving_nonlinear_conservation_laws). The forward ODE simulation approach (unlike homotopy continuation and nonlinear solving) cannot find unstable steady states. + +Generally, `SteadyStateProblem`s can be solved using the [same options that are available for ODE simulations](@ref simulation_intro_solver_options). E.g. here we designate a specific `dt` step size: ```@example steady_state_solving_simulation solve(ssprob, DynamicSS(Rodas5P()); dt = 0.01) nothing # hide @@ -134,7 +133,7 @@ However, especially when the forward ODE simulation approach is used, it is reco --- ## [Citations](@id nonlinear_solve_citation) -If you use this functionality in your research, [in addition to Catalyst](@ref catalyst_citation), please cite the following paper to support the authors of the NonlinearSolve.jl package: +If you use this functionality in your research, [in addition to Catalyst](@ref doc_index_citation), please cite the following paper to support the authors of the NonlinearSolve.jl package: ``` @article{pal2024nonlinearsolve, title={NonlinearSolve. jl: High-Performance and Robust Solvers for Systems of Nonlinear Equations in Julia}, @@ -144,6 +143,7 @@ If you use this functionality in your research, [in addition to Catalyst](@ref c } ``` + --- ## References -[^1]: [J. Nocedal, S. J. Wright, *Numerical Optimization*, Springer (2006).](https://www.math.uci.edu/~qnie/Publications/NumericalOptimization.pdf) +[^1]: [J. Nocedal, S. J. Wright, *Numerical Optimization*, Springer (2006).](https://www.math.uci.edu/~qnie/Publications/NumericalOptimization.pdf) \ No newline at end of file diff --git a/docs/src/steady_state_functionality/steady_state_stability_computation.md b/docs/src/steady_state_functionality/steady_state_stability_computation.md index 0cc60ebf94..c51b55cd2a 100644 --- a/docs/src/steady_state_functionality/steady_state_stability_computation.md +++ b/docs/src/steady_state_functionality/steady_state_stability_computation.md @@ -1,10 +1,10 @@ -# Steady state stability computation -After system steady states have been found using [HomotopyContinuation.jl](@ref homotopy_continuation), [NonlinearSolve.jl](@ref nonlinear_solve), or other means, their stability can be computed using Catalyst's `steady_state_stability` function. Systems with conservation laws will automatically have these removed, permitting stability computation on systems with singular Jacobian. +# [Steady state stability computation](@id steady_state_stability) +After system steady states have been found using [HomotopyContinuation.jl](@ref homotopy_continuation), [NonlinearSolve.jl](@ref steady_state_solving), or other means, their stability can be computed using Catalyst's `steady_state_stability` function. Systems with conservation laws will automatically have these removed, permitting stability computation on systems with singular Jacobian. -!!! warn +!!! warning Catalyst currently computes steady state stabilities using the naive approach of checking whether a system's largest eigenvalue real part is negative. While more advanced stability computation methods exist (and would be a welcome addition to Catalyst), there is no direct plans to implement these. Furthermore, Catalyst uses a tolerance `tol = 10*sqrt(eps())` to determine whether a computed eigenvalue is far away enough from 0 to be reliably used. This threshold can be changed through the `tol` keyword argument. -## Basic examples +## [Basic examples](@id steady_state_stability_basics) Let us consider the following basic example: ```@example stability_1 using Catalyst @@ -19,7 +19,7 @@ steady_state = [:X => 4.0] steady_state_stability(steady_state, rn, ps) ``` -Next, let us consider the following [self-activation loop](@ref ref): +Next, let us consider the following [self-activation loop](@ref basic_CRN_library_self_activation): ```@example stability_1 sa_loop = @reaction_network begin (hill(X,v,K,n),d), 0 <--> X @@ -42,7 +42,7 @@ Finally, as described above, Catalyst uses an optional argument, `tol`, to deter nothing# hide ``` -## Pre-computing the Jacobian to increase performance when computing stability for many steady states +## [Pre-computing the Jacobian to increase performance when computing stability for many steady states](@id steady_state_stability_jacobian) Catalyst uses the system Jacobian to compute steady state stability, and the Jacobian is computed once for each call to `steady_state_stability`. If you repeatedly compute stability for steady states of the same system, pre-computing the Jacobian and supplying it to the `steady_state_stability` function can improve performance. In this example we use the self-activation loop from previously, pre-computes its Jacobian, and uses it to multiple `steady_state_stability` calls: @@ -51,13 +51,13 @@ ss_jac = steady_state_jac(sa_loop) ps_1 = [:v => 2.0, :K => 0.5, :n => 3, :d => 1.0] steady_states_1 = hc_steady_states(sa_loop, ps) -stability_1 = [steady_state_stability(state, sa_loop, ps_1; ss_jac = ss_jac) for state in steady_states_1] +stabs_1 = [steady_state_stability(st, sa_loop, ps_1; ss_jac) for st in steady_states_1] ps_2 = [:v => 4.0, :K => 1.5, :n => 2, :d => 1.0] steady_states_2 = hc_steady_states(sa_loop, ps) -stability_2 = [steady_state_stability(state, sa_loop, ps_2; ss_jac = ss_jac) for state in steady_states_2] +stabs_2 = [steady_state_stability(st, sa_loop, ps_2; ss_jac) for st in steady_states_2] nothing # hide ``` -!!! warn +!!! warning For systems with [conservation laws](@ref homotopy_continuation_conservation_laws), `steady_state_jac` must be supplied a `u0` vector (indicating species concentrations for conservation law computation). This is required to eliminate the conserved quantities, preventing a singular Jacobian. These are supplied using the `u0` optional argument. \ No newline at end of file diff --git a/docs/src/v14_migration_guide.md b/docs/src/v14_migration_guide.md new file mode 100644 index 0000000000..ed5074839b --- /dev/null +++ b/docs/src/v14_migration_guide.md @@ -0,0 +1,214 @@ +# Version 14 Migration Guide + +Catalyst is built on the [ModelingToolkit.jl](https://github.com/SciML/ModelingToolkit.jl) modelling language. A recent update of ModelingToolkit from version 8 to version 9 has required a corresponding update to Catalyst (from version 13 to 14). This update has introduced a couple of breaking changes, all of which will be detailed below. + +!!! note + Catalyst version 14 also introduces several new features. These will not be discussed here, however, they are described in Catalyst's [history file](https://github.com/SciML/Catalyst.jl/blob/master/HISTORY.md). + +## System completeness +In ModelingToolkit v9 (and thus also Catalyst v14) all systems (e.g. `ReactionSystem`s and `ODESystem`s) are either *complete* or *incomplete*. Complete and incomplete systems differ in that +- Only complete systems can be used as inputs to simulations or most tools for model analysis. +- Only incomplete systems can be [composed with other systems to form hierarchical models](@ref compositional_modeling). + +A model's completeness depends on how it was created: +- Models created programmatically (using the `ReactionSystem` constructor) are *not marked as complete* by default. +- Models created using the `@reaction_network` DSL are *automatically marked as complete*. +- To *use the DSL to create models that are not marked as complete*, use the `@network_component` macro (which in all other aspects is identical to `@reaction_network`). +- Models generated through the `compose` and `extend` functions are *not marked as complete*. + +Furthermore, any systems generated through e.g. `convert(ODESystem, rs)` are *not marked as complete*. + +Complete models can be generated from incomplete models through the `complete` function. Here is a workflow where we take completeness into account in the simulation of a simple birth-death process. +```@example v14_migration_1 +using Catalyst +t = default_t() +@species X(t) +@parameters p d +rxs = [ + Reaction(p, [], [X]), + Reaction(d, [X], []) +] +@named rs = ReactionSystem(rxs, t) +``` +Here we have created a model that is not marked as complete. If our model is ready (i.e. we do not wish to compose it with additional models) we mark it as complete: +```@example v14_migration_1 +rs = complete(rs) +``` +Here, `complete` does not change the input model, but simply creates a new model that is tagged as complete. We hence overwrite our model variable (`rs`) with `complete`'s output. We can confirm that our model is complete using the `Catalyst.iscomplete` function: +```@example v14_migration_1 +Catalyst.iscomplete(rs) +``` +We can now go on and use our model for e.g. simulations: +```@example v14_migration_1 +using OrdinaryDiffEq, Plots +u0 = [X => 0.1] +tspan = (0.0, 10.0) +ps = [p => 1.0, d => 0.2] +oprob = ODEProblem(rs, u0, tspan, ps) +sol = solve(oprob) +plot(sol) +``` + +If we wish to first manually convert our `ReactionSystem` to an `ODESystem`, the generated `ODESystem` will *not* be marked as complete +```@example v14_migration_1 +osys = convert(ODESystem, rs) +Catalyst.iscomplete(osys) +``` +(note that `rs` must be complete before it can be converted to an `ODESystem` or any other system type) + +If we now wish to create an `ODEProblem` from our `ODESystem`, we must first mark it as complete (using similar syntax as for our `ReactionSystem`): +```@example v14_migration_1 +osys = complete(osys) +oprob = ODEProblem(osys, u0, tspan, ps) +sol = solve(oprob) +plot(sol) +``` + +Note, if we had instead used the [`@reaction_network`](@ref) DSL macro to build +our model, i.e. +```@example v14_migration_1 +rs2 = @reaction_network rs begin + p, ∅ --> X + d, X --> ∅ +end +``` +then the model is automatically marked as complete +```@example v14_migration_1 +Catalyst.iscomplete(rs2) +``` +In contrast, if we used the [`@network_component`](@ref) DSL macro to build our +model it is not marked as complete, and is equivalent to our original definition of `rs` +```@example v14_migration_1 +rs3 = @network_component rs begin + p, ∅ --> X + d, X --> ∅ +end +Catalyst.iscomplete(rs3) +``` + +## Unknowns instead of states +Previously, "states" was used as a term for system variables (both species and non-species variables). MTKv9 has switched to using the term "unknowns" instead. This means that there have been a number of changes to function names (e.g. `states` => `unknowns` and `get_states` => `get_unknowns`). + +E.g. here we declare a `ReactionSystem` model containing both species and non-species unknowns: +```@example v14_migration_2 +using Catalyst +t = default_t() +D = default_time_deriv() +@species X(t) +@variables V(t) +@parameters p d Vmax + +eqs = [ + Reaction(p, [], [X]), + Reaction(d, [X], []), + D(V) ~ Vmax - V*X*d/p +] +@named rs = ReactionSystem(eqs, t) +``` +We can now use `unknowns` to retrieve all unknowns +```@example v14_migration_2 +unknowns(rs) +``` +Meanwhile, `species` and `nonspecies` (like previously) returns all species or non-species unknowns, respectively: +```@example v14_migration_2 +species(rs) +``` +```@example v14_migration_2 +nonspecies(rs) +``` + +## Lost support for most units +As part of its v9 update, ModelingToolkit changed how units were handled. This includes using the package [DynamicQuantities.jl](https://github.com/SymbolicML/DynamicQuantities.jl) to manage units (instead of [Unitful.jl](https://github.com/PainterQubits/Unitful.jl), like previously). + +While this should lead to long-term improvements, unfortunately, as part of the process support for most units was removed. Currently, only the main SI units are supported (`s`, `m`, `kg`, `A`, `K`, `mol`, and `cd`). Composite units (e.g. `N = kg/(m^2)`) are no longer supported. Furthermore, prefix units (e.g. `mm = m/1000`) are not supported either. This means that most units relevant to Catalyst (such as `µM`) cannot be used directly. While composite units can still be written out in full and used (e.g. `kg/(m^2)`) this is hardly user-friendly. + +The maintainers of ModelingToolkit have been notified of this issue. We are unsure when this will be fixed, however, we do not think it will be a permanent change. + +## Removed support for system-mutating functions +According to the ModelingToolkit system API, systems should not be mutable. In accordance with this, the following functions have been deprecated and removed: `addparam!`, `addreaction!`, `addspecies!`, `@add_reactions`, and `merge!`. Please use `ModelingToolkit.extend` and `ModelingToolkit.compose` to generate new merged and/or composed `ReactionSystems` from multiple component systems. + +It is still possible to add default values to a created `ReactionSystem`, i.e. the `setdefaults!` function is still supported. + +## New interface for creating time variable (`t`) and its differential (`D`) +Previously, the time-independent variable (typically called `t`) was declared using +```@example v14_migration_3 +using Catalyst +@variables t +nothing # hide +``` +MTKv9 has introduced a standard global time variable, and as such a new, preferred, interface has been developed: +```@example v14_migration_3 +t = default_t() +nothing # hide +``` + +Similarly, the time differential (primarily relevant when creating combined reaction-ODE models) used to be declared through +```@example v14_migration_3 +D = Differential(t) +nothing # hide +``` +where the preferred method is now +```@example v14_migration_3 +D = default_time_deriv() +nothing # hide +``` + +!!! note + If you look at ModelingToolkit documentation, these defaults are instead retrieved using `using ModelingToolkit: t_nounits as t, D_nounits as D`. This will also work, however, in Catalyst we have opted to instead use the functions `default_t()` and `default_time_deriv()` as our main approach. + +## New interface for accessing problem/integrator/solution parameter (and species) values +Previously, it was possible to directly index problems to query them for their parameter values. e.g. +```@example v14_migration_4 +using Catalyst +rn = @reaction_network begin + (p,d), 0 <--> X +end +u0 = [:X => 1.0] +ps = [:p => 1.0, :d => 0.2] +oprob = ODEProblem(rn, u0, (0.0, 1.0), ps) +nothing # hide +``` +```julia +oprob[:p] +``` +This is *no longer supported*. When you wish to query a problem (or integrator or solution) for a parameter value (or to update a parameter value), you must append `.ps` to the problem variable name: +```@example v14_migration_4 +oprob.ps[:p] +``` + +Furthermore, a few new functions (`getp`, `getu`, `setp`, `setu`) have been introduced from [SymbolicIndexingInterface](https://github.com/SciML/SymbolicIndexingInterface.jl) to support efficient and systematic querying and/or updating of symbolic unknown/parameter values. Using these can *significantly* improve performance when querying or updating a value multiple times, for example within a callback. These are described in more detail [here](@ref simulation_structure_interfacing_functions). + +For more details on how to query various structures for parameter and species values, please read [this documentation page](@ref simulation_structure_interfacing). + +## Other changes + +#### Modification of problems with conservation laws broken +While it is possible to update e.g. `ODEProblem`s using the [`remake`](@ref simulation_structure_interfacing_problems_remake) function, this is currently not possible if the `remove_conserved = true` option was used. E.g. while +```@example v14_migration_5 +using Catalyst, OrdinaryDiffEq +rn = @reaction_network begin + (k1,k2), X1 <--> X2 +end +u0 = [:X1 => 1.0, :X2 => 2.0] +ps = [:k1 => 0.5, :k2 => 3.0] +oprob = ODEProblem(rn, u0, (0.0, 10.0), ps; remove_conserved = true) +solve(oprob) +# hide +``` +is perfectly fine, attempting to then modify any initial conditions or the value of the conservation constant in `oprob` will likely silently fail: +```@example v14_migration_5 +oprob_remade = remake(oprob; u0 = [:X1 => 5.0]) # NEVER do this. +solve(oprob_remade) +# hide +``` +This might generate a silent error, where the remade problem is different from the intended one (the value of the conserved constant will not be updated correctly). + +This bug was likely present on earlier versions as well, but was only recently discovered. While we hope it will be fixed soon, the issue is in ModelingToolkit, and will not be fixed until its maintainers find the time to do so. + +#### Depending on parameter order is even more dangerous than before +In early versions of Catalyst, parameters and species were provided as vectors (e.g. `[1.0, 2.0]`) rather than maps (e.g. `[p => 1.0, d => 2.0]`). While we previously *strongly* recommended users to use the map form (or they might produce unintended results), the vector form was still supported (technically). Due to recent internal ModelingToolkit updates, the purely numeric form is no longer supported and should never be used -- it will potentially lead to incorrect values for parameters and/or initial conditions. Note that if `rn` is a complete `ReactionSystem` you can now specify such mappings via `[rn.p => 1.0, rn.d => 2.0]`. + +*Users should never use vector-forms to represent parameter and species values* + +#### Additional deprecated functions +The `reactionparams`, `numreactionparams`, and `reactionparamsmap` functions have been deprecated. \ No newline at end of file diff --git a/docs/old_files/petab_ode_param_fitting.md b/docs/unpublished/petab_ode_param_fitting.md similarity index 99% rename from docs/old_files/petab_ode_param_fitting.md rename to docs/unpublished/petab_ode_param_fitting.md index 9e503d7411..64b61df5bf 100644 --- a/docs/old_files/petab_ode_param_fitting.md +++ b/docs/unpublished/petab_ode_param_fitting.md @@ -523,7 +523,7 @@ There exist several types of plots for both types of calibration results. More d --- ## [Citations](@id petab_citations) -If you use this functionality in your research, [in addition to Catalyst](@ref catalyst_citation), please cite the following papers to support the authors of the PEtab.jl package (currently there is no article associated with this package) and the PEtab standard: +If you use this functionality in your research, [in addition to Catalyst](@ref doc_index_citation), please cite the following papers to support the authors of the PEtab.jl package (currently there is no article associated with this package) and the PEtab standard: ``` @misc{2023Petabljl, author = {Ognissanti, Damiano AND Arutjunjan, Rafael AND Persson, Sebastian AND Hasselgren, Viktor}, diff --git a/ext/CatalystBifurcationKitExtension.jl b/ext/CatalystBifurcationKitExtension.jl index 636d151244..734d6810d8 100644 --- a/ext/CatalystBifurcationKitExtension.jl +++ b/ext/CatalystBifurcationKitExtension.jl @@ -7,4 +7,4 @@ import BifurcationKit as BK # Creates and exports hc_steady_states function. include("CatalystBifurcationKitExtension/bifurcation_kit_extension.jl") -end \ No newline at end of file +end diff --git a/ext/CatalystBifurcationKitExtension/bifurcation_kit_extension.jl b/ext/CatalystBifurcationKitExtension/bifurcation_kit_extension.jl index ef62ee3bc1..6aeafa9bc2 100644 --- a/ext/CatalystBifurcationKitExtension/bifurcation_kit_extension.jl +++ b/ext/CatalystBifurcationKitExtension/bifurcation_kit_extension.jl @@ -2,23 +2,31 @@ # Creates a BifurcationProblem, using a ReactionSystem as an input. function BK.BifurcationProblem(rs::ReactionSystem, u0_bif, ps, bif_par, args...; - plot_var=nothing, record_from_solution=BK.record_sol_default, jac=true, u0=[], kwargs...) - if !isautonomous(rs) - error("Attempting to create a `BifurcationProblem` for a non-autonomous system (e.g. where some rate depend on $(rs.iv)). This is not possible.") + plot_var = nothing, record_from_solution = BK.record_sol_default, jac = true, u0 = [], kwargs...) + if !isautonomous(rs) + error("Attempting to create a `BifurcationProblem` for a non-autonomous system (e.g. where some rate depend on $(get_iv(rs))). This is not possible.") end # Converts symbols to symbolics. (bif_par isa Symbol) && (bif_par = ModelingToolkit.get_var_to_name(rs)[bif_par]) (plot_var isa Symbol) && (plot_var = ModelingToolkit.get_var_to_name(rs)[plot_var]) - ((u0_bif isa Vector{<:Pair{Symbol,<:Any}}) || (u0_bif isa Dict{Symbol, <:Any})) && (u0_bif = symmap_to_varmap(rs, u0_bif)) - ((ps isa Vector{<:Pair{Symbol,<:Any}}) || (ps isa Dict{Symbol, <:Any})) && (ps = symmap_to_varmap(rs, ps)) - ((u0 isa Vector{<:Pair{Symbol,<:Any}}) || (u0 isa Dict{Symbol, <:Any})) && (u0 = symmap_to_varmap(rs, u0)) + if (u0_bif isa Vector{<:Pair{Symbol, <:Any}}) || (u0_bif isa Dict{Symbol, <:Any}) + u0_bif = symmap_to_varmap(rs, u0_bif) + end + if (ps isa Vector{<:Pair{Symbol, <:Any}}) || (ps isa Dict{Symbol, <:Any}) + ps = symmap_to_varmap(rs, ps) + end + if (u0 isa Vector{<:Pair{Symbol, <:Any}}) || (u0 isa Dict{Symbol, <:Any}) + u0 = symmap_to_varmap(rs, u0) + end # Creates NonlinearSystem. Catalyst.conservationlaw_errorcheck(rs, vcat(ps, u0)) - nsys = complete(convert(NonlinearSystem, rs; remove_conserved=true, defaults=Dict(u0))) + nsys = convert(NonlinearSystem, rs; defaults = Dict(u0), + remove_conserved = true, remove_conserved_warn = false) + nsys = complete(nsys) # Makes BifurcationProblem (this call goes through the ModelingToolkit-based BifurcationKit extension). - return BK.BifurcationProblem(nsys, u0_bif, ps, bif_par, args...; plot_var=plot_var, - record_from_solution=record_from_solution, jac=jac, kwargs...) -end \ No newline at end of file + return BK.BifurcationProblem(nsys, u0_bif, ps, bif_par, args...; plot_var, + record_from_solution, jac, kwargs...) +end diff --git a/ext/CatalystHomotopyContinuationExtension.jl b/ext/CatalystHomotopyContinuationExtension.jl index 6e7fdac50f..a4785992fe 100644 --- a/ext/CatalystHomotopyContinuationExtension.jl +++ b/ext/CatalystHomotopyContinuationExtension.jl @@ -6,9 +6,10 @@ import DynamicPolynomials import ModelingToolkit as MT import HomotopyContinuation as HC import Setfield: @set -import Symbolics: unwrap, wrap, Rewriters, symtype, issym, istree +import Symbolics: unwrap, wrap, Rewriters, symtype, issym +using Symbolics: iscall # Creates and exports hc_steady_states function. include("CatalystHomotopyContinuationExtension/homotopy_continuation_extension.jl") -end \ No newline at end of file +end diff --git a/ext/CatalystHomotopyContinuationExtension/homotopy_continuation_extension.jl b/ext/CatalystHomotopyContinuationExtension/homotopy_continuation_extension.jl index 1dcc64d9ba..916a124423 100644 --- a/ext/CatalystHomotopyContinuationExtension/homotopy_continuation_extension.jl +++ b/ext/CatalystHomotopyContinuationExtension/homotopy_continuation_extension.jl @@ -35,9 +35,10 @@ Notes: - Homotopy-based steady state finding only works when all rates are rational polynomials (e.g. constant, linear, mm, or hill functions). ``` """ -function Catalyst.hc_steady_states(rs::ReactionSystem, ps; filter_negative=true, neg_thres=-1e-20, u0=[], kwargs...) - if !isautonomous(rs) - error("Attempting to compute steady state for a non-autonomous system (e.g. where some rate depend on $(rs.iv)). This is not possible.") +function Catalyst.hc_steady_states(rs::ReactionSystem, ps; filter_negative = true, + neg_thres = -1e-20, u0 = [], kwargs...) + if !isautonomous(rs) + error("Attempting to compute steady state for a non-autonomous system (e.g. where some rate depend on $(get_iv(rs))). This is not possible.") end ss_poly = steady_state_polynomial(rs, ps, u0) sols = HC.real_solutions(HC.solve(ss_poly; kwargs...)) @@ -48,11 +49,13 @@ end # For a given reaction system, parameter values, and initial conditions, find the polynomial that HC solves to find steady states. function steady_state_polynomial(rs::ReactionSystem, ps, u0) rs = Catalyst.expand_registered_functions(rs) - ns = complete(convert(NonlinearSystem, rs; remove_conserved = true)) - pre_varmap = [symmap_to_varmap(rs,u0)..., symmap_to_varmap(rs,ps)...] + ns = complete(convert(NonlinearSystem, rs; + remove_conserved = true, remove_conserved_warn = false)) + pre_varmap = [symmap_to_varmap(rs, u0)..., symmap_to_varmap(rs, ps)...] Catalyst.conservationlaw_errorcheck(rs, pre_varmap) - p_vals = ModelingToolkit.varmap_to_vars(pre_varmap, parameters(ns); defaults = ModelingToolkit.defaults(ns)) - p_dict = Dict(parameters(ns) .=> p_vals) + p_vals = ModelingToolkit.varmap_to_vars(pre_varmap, parameters(ns); + defaults = ModelingToolkit.defaults(ns)) + p_dict = Dict(parameters(ns) .=> p_vals) eqs_pars_funcs = vcat(equations(ns), conservedequations(rs)) eqs = map(eq -> substitute(eq.rhs - eq.lhs, p_dict), eqs_pars_funcs) eqs_intexp = make_int_exps.(eqs) @@ -61,12 +64,14 @@ function steady_state_polynomial(rs::ReactionSystem, ps, u0) end # Parses and expression and return a version where any exponents that are Float64 (but an int, like 2.0) are turned into Int64s. -make_int_exps(expr) = wrap(Rewriters.Postwalk(Rewriters.PassThrough(___make_int_exps))(unwrap(expr))).val +function make_int_exps(expr) + wrap(Rewriters.Postwalk(Rewriters.PassThrough(___make_int_exps))(unwrap(expr))).val +end function ___make_int_exps(expr) - !istree(expr) && return expr - if (operation(expr) == ^) + !iscall(expr) && return expr + if (operation(expr) == ^) if isinteger(arguments(expr)[2]) - return arguments(expr)[1] ^ Int64(arguments(expr)[2]) + return arguments(expr)[1]^Int64(arguments(expr)[2]) else error("An non integer ($(arguments(expr)[2])) was found as a variable exponent. Non-integer exponents are not supported for homotopy continuation based steady state finding.") end @@ -76,7 +81,7 @@ end # If the input is a fraction, removes the denominator. function remove_denominators(expr) s_expr = simplify_fractions(expr) - !istree(expr) && return expr + !iscall(expr) && return expr if operation(s_expr) == / return remove_denominators(arguments(s_expr)[1]) end @@ -95,7 +100,7 @@ function reorder_sols!(sols, ss_poly, rs::ReactionSystem) end # Filters away solutions with negative species concentrations (and for neg_thres < val < 0.0, sets val=0.0). -function filter_negative_f(sols; neg_thres=-1e-20) +function filter_negative_f(sols; neg_thres = -1e-20) for sol in sols, idx in 1:length(sol) (neg_thres < sol[idx] < 0) && (sol[idx] = 0) end @@ -104,9 +109,13 @@ end # Sometimes (when polynomials are created from coupled CRN/DAEs), the steady state polynomial have the wrong type. # This converts it to the correct type, which homotopy continuation can handle. -const WRONG_POLY_TYPE = Vector{DynamicPolynomials.Polynomial{DynamicPolynomials.Commutative{DynamicPolynomials.CreationOrder}, DynamicPolynomials.Graded{DynamicPolynomials.LexOrder}}} -const CORRECT_POLY_TYPE = Vector{DynamicPolynomials.Polynomial{DynamicPolynomials.Commutative{DynamicPolynomials.CreationOrder}, DynamicPolynomials.Graded{DynamicPolynomials.LexOrder}, Float64}} +const WRONG_POLY_TYPE = Vector{DynamicPolynomials.Polynomial{ + DynamicPolynomials.Commutative{DynamicPolynomials.CreationOrder}, + DynamicPolynomials.Graded{DynamicPolynomials.LexOrder}}} +const CORRECT_POLY_TYPE = Vector{DynamicPolynomials.Polynomial{ + DynamicPolynomials.Commutative{DynamicPolynomials.CreationOrder}, + DynamicPolynomials.Graded{DynamicPolynomials.LexOrder}, Float64}} function poly_type_convert(ss_poly) (typeof(ss_poly) == WRONG_POLY_TYPE) && return convert(CORRECT_POLY_TYPE, ss_poly) return ss_poly -end \ No newline at end of file +end diff --git a/ext/CatalystStructuralIdentifiabilityExtension.jl b/ext/CatalystStructuralIdentifiabilityExtension.jl index 026fbe6122..ed80952bd1 100644 --- a/ext/CatalystStructuralIdentifiabilityExtension.jl +++ b/ext/CatalystStructuralIdentifiabilityExtension.jl @@ -2,9 +2,10 @@ module CatalystStructuralIdentifiabilityExtension # Fetch packages. using Catalyst +import DataStructures.OrderedDict import StructuralIdentifiability as SI # Creates and exports hc_steady_states function. include("CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl") -end \ No newline at end of file +end diff --git a/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl b/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl index 9ae15dc55e..29269b91eb 100644 --- a/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl +++ b/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl @@ -26,12 +26,13 @@ Notes: - This function is part of the StructuralIdentifiability.jl extension. StructuralIdentifiability.jl must be imported to access it. - `measured_quantities` and `known_p` input may also be symbolic (e.g. measured_quantities = [rs.X]) """ -function Catalyst.make_si_ode(rs::ReactionSystem; measured_quantities = [], known_p = [], - ignore_no_measured_warn = false, remove_conserved = true) +function Catalyst.make_si_ode(rs::ReactionSystem; measured_quantities = [], known_p = [], + ignore_no_measured_warn = false, remove_conserved = true) # Creates a MTK ODESystem, and a list of measured quantities (there are equations). # Gives these to SI to create an SI ode model of its preferred form. osys, conseqs, _ = make_osys(rs; remove_conserved) - measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) + measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, + conseqs; ignore_no_measured_warn) return SI.mtk_to_si(osys, measured_quantities)[1] end @@ -62,16 +63,18 @@ Notes: - This function is part of the StructuralIdentifiability.jl extension. StructuralIdentifiability.jl must be imported to access it. - `measured_quantities` and `known_p` input may also be symbolic (e.g. measured_quantities = [rs.X]) """ -function SI.assess_local_identifiability(rs::ReactionSystem, args...; measured_quantities = [], - known_p = [], funcs_to_check = Vector(), remove_conserved = true, - ignore_no_measured_warn=false, kwargs...) +function SI.assess_local_identifiability(rs::ReactionSystem, args...; + measured_quantities = [], known_p = [], funcs_to_check = Vector(), + remove_conserved = true, ignore_no_measured_warn = false, kwargs...) # Creates a ODESystem, list of measured quantities, and functions to check, of SI's preferred form. osys, conseqs, vars = make_osys(rs; remove_conserved) - measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) + measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, + conseqs; ignore_no_measured_warn) funcs_to_check = make_ftc(funcs_to_check, conseqs, vars) # Computes identifiability and converts it to a easy to read form. - out = SI.assess_local_identifiability(osys, args...; measured_quantities, funcs_to_check, kwargs...) + out = SI.assess_local_identifiability(osys, args...; measured_quantities, + funcs_to_check, kwargs...) return make_output(out, funcs_to_check, reverse.(conseqs)) end @@ -100,16 +103,18 @@ Notes: - This function is part of the StructuralIdentifiability.jl extension. StructuralIdentifiability.jl must be imported to access it. - `measured_quantities` and `known_p` input may also be symbolic (e.g. measured_quantities = [rs.X]) """ -function SI.assess_identifiability(rs::ReactionSystem, args...; measured_quantities = [], known_p = [], - funcs_to_check = Vector(), remove_conserved = true, - ignore_no_measured_warn=false, kwargs...) +function SI.assess_identifiability(rs::ReactionSystem, args...; + measured_quantities = [], known_p = [], funcs_to_check = Vector(), + remove_conserved = true, ignore_no_measured_warn = false, kwargs...) # Creates a ODESystem, list of measured quantities, and functions to check, of SI's preferred form. osys, conseqs, vars = make_osys(rs; remove_conserved) - measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) + measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, + conseqs; ignore_no_measured_warn) funcs_to_check = make_ftc(funcs_to_check, conseqs, vars) # Computes identifiability and converts it to a easy to read form. - out = SI.assess_identifiability(osys, args...; measured_quantities, funcs_to_check, kwargs...) + out = SI.assess_identifiability(osys, args...; measured_quantities, + funcs_to_check, kwargs...) return make_output(out, funcs_to_check, reverse.(conseqs)) end @@ -138,12 +143,13 @@ Notes: - This function is part of the StructuralIdentifiability.jl extension. StructuralIdentifiability.jl must be imported to access it. - `measured_quantities` and `known_p` input may also be symbolic (e.g. measured_quantities = [rs.X]) """ -function SI.find_identifiable_functions(rs::ReactionSystem, args...; measured_quantities = [], - known_p = [], remove_conserved = true, ignore_no_measured_warn=false, - kwargs...) +function SI.find_identifiable_functions(rs::ReactionSystem, args...; + measured_quantities = [], known_p = [], remove_conserved = true, + ignore_no_measured_warn = false, kwargs...) # Creates a ODESystem, and list of measured quantities, of SI's preferred form. osys, conseqs = make_osys(rs; remove_conserved) - measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) + measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, + conseqs; ignore_no_measured_warn) # Computes identifiable functions and converts it to a easy to read form. out = SI.find_identifiable_functions(osys, args...; measured_quantities, kwargs...) @@ -154,12 +160,14 @@ end # From a reaction system, creates the corresponding MTK-style ODESystem for SI application # Also compute the, later needed, conservation law equations and list of system symbols (unknowns and parameters). -function make_osys(rs::ReactionSystem; remove_conserved=true) +function make_osys(rs::ReactionSystem; remove_conserved = true) # Creates the ODESystem corresponding to the ReactionSystem (expanding functions and flattening it). # Creates a list of the systems all symbols (unknowns and parameters). - ModelingToolkit.iscomplete(rs) || error("Identifiability should only be computed for complete systems. A ReactionSystem can be marked as complete using the `complete` function.") + if !ModelingToolkit.iscomplete(rs) + error("Identifiability should only be computed for complete systems. A ReactionSystem can be marked as complete using the `complete` function.") + end rs = complete(Catalyst.expand_registered_functions(flatten(rs))) - osys = complete(convert(ODESystem, rs; remove_conserved)) + osys = complete(convert(ODESystem, rs; remove_conserved, remove_conserved_warn = false)) vars = [unknowns(rs); parameters(rs)] # Computes equations for system conservation laws. @@ -177,11 +185,12 @@ end # Creates a list of measured quantities of a form that SI can read. # Each measured quantity must have a form like: # `obs_var ~ X` # (Here, `obs_var` is a variable, and X is whatever we can measure). -function make_measured_quantities(rs::ReactionSystem, measured_quantities::Vector{T}, known_p::Vector{S}, - conseqs; ignore_no_measured_warn=false) where {T,S} +function make_measured_quantities( + rs::ReactionSystem, measured_quantities::Vector{T}, known_p::Vector{S}, + conseqs; ignore_no_measured_warn = false) where {T, S} # Warning if the user didn't give any measured quantities. - if ignore_no_measured_warn || isempty(measured_quantities) - @warn "No measured quantity provided to the `measured_quantities` argument, any further identifiability analysis will likely fail. You can disable this warning by setting `ignore_no_measured_warn=true`." + if !ignore_no_measured_warn && isempty(measured_quantities) + @warn "No measured quantity provided to the `measured_quantities` argument, any further identifiability analysis will likely fail. You can disable this warning by setting `ignore_no_measured_warn = true`." end # Appends the known parameters to the measured_quantities vector. Converts any Symbols to symbolics. @@ -192,7 +201,8 @@ function make_measured_quantities(rs::ReactionSystem, measured_quantities::Vecto # Creates one internal observation variable for each measured quantity (`___internal_observables`). # Creates a vector of equations, setting each measured quantity equal to one observation variable. @variables t (___internal_observables(Catalyst.get_iv(rs)))[1:length(mqs)] - return Equation[(q isa Equation) ? q : (___internal_observables[i] ~ q) for (i,q) in enumerate(mqs)] + return Equation[(q isa Equation) ? q : (___internal_observables[i] ~ q) + for (i, q) in enumerate(mqs)] end # Creates the functions that we wish to check for identifiability. @@ -210,10 +220,10 @@ end # Sorts the output according to their input order (defaults to the `[unknowns; parameters]` order). function make_output(out, funcs_to_check, conseqs) funcs_to_check = vector_subs(funcs_to_check, conseqs) - out = Dict(zip(vector_subs(keys(out), conseqs), values(out))) - sortdict = Dict(ftc => i for (i,ftc) in enumerate(funcs_to_check)) - return sort(out; by = x -> sortdict[x]) + out = OrderedDict(zip(vector_subs(keys(out), conseqs), values(out))) + sortdict = Dict(ftc => i for (i, ftc) in enumerate(funcs_to_check)) + return sort!(out; by = x -> sortdict[x]) end # For a vector of expressions and a conservation law, substitutes the law into every equation. -vector_subs(eqs, subs) = [substitute(eq, subs) for eq in eqs] \ No newline at end of file +vector_subs(eqs, subs) = [substitute(eq, subs) for eq in eqs] diff --git a/src/Catalyst.jl b/src/Catalyst.jl index d80c115119..584ca07db8 100644 --- a/src/Catalyst.jl +++ b/src/Catalyst.jl @@ -6,16 +6,16 @@ module Catalyst using DocStringExtensions using SparseArrays, DiffEqBase, Reexport, Setfield using LaTeXStrings, Latexify, Requires -using JumpProcesses: JumpProcesses, - JumpProblem, MassActionJump, ConstantRateJump, - VariableRateJump +using LinearAlgebra, Combinatorics +using JumpProcesses: JumpProcesses, JumpProblem, + MassActionJump, ConstantRateJump, VariableRateJump, + SpatialMassActionJump, CartesianGrid, CartesianGridRej # ModelingToolkit imports and convenience functions we use using ModelingToolkit const MT = ModelingToolkit using DynamicQuantities#, Unitful # Having Unitful here as well currently gives an error. - @reexport using ModelingToolkit using Symbolics using LinearAlgebra @@ -23,8 +23,8 @@ using RuntimeGeneratedFunctions RuntimeGeneratedFunctions.init(@__MODULE__) import Symbolics: BasicSymbolic -import SymbolicUtils -using ModelingToolkit: Symbolic, value, istree, get_unknowns, get_ps, get_iv, get_systems, +using Symbolics: iscall +using ModelingToolkit: Symbolic, value, get_unknowns, get_ps, get_iv, get_systems, get_eqs, get_defaults, toparam, get_var_to_name, get_observed, getvar @@ -44,6 +44,7 @@ import Graphs: DiGraph, SimpleGraph, SimpleDiGraph, vertices, edges, add_vertice import DataStructures: OrderedDict, OrderedSet import Parameters: @with_kw_noshow import Symbolics: occursin, wrap +import Symbolics.RewriteHelpers: hasnode, replacenode # globals for the modulate function default_time_deriv() @@ -80,7 +81,7 @@ const CONSERVED_CONSTANT_SYMBOL = :Γ # Declares symbols which may neither be used as parameters nor unknowns. const forbidden_symbols_skip = Set([:ℯ, :pi, :π, :t, :∅]) const forbidden_symbols_error = union(Set([:im, :nothing, CONSERVED_CONSTANT_SYMBOL]), - forbidden_symbols_skip) + forbidden_symbols_skip) const forbidden_variables_error = let fvars = copy(forbidden_symbols_error) delete!(fvars, :t) @@ -93,7 +94,6 @@ end include("reaction.jl") export isspecies export Reaction -export get_noise_scaling, has_noise_scaling # The `ReactionSystem` structure and its functions. include("reactionsystem.jl") @@ -127,7 +127,8 @@ export @reaction_network, @network_component, @reaction, @species include("network_analysis.jl") export reactioncomplexmap, reactioncomplexes, incidencemat export complexstoichmat -export complexoutgoingmat, incidencematgraph, linkageclasses, deficiency, subnetworks +export complexoutgoingmat, incidencematgraph, linkageclasses, stronglinkageclasses, + terminallinkageclasses, deficiency, subnetworks export linkagedeficiencies, isreversible, isweaklyreversible export conservationlaws, conservedquantities, conservedequations, conservationlaw_constants @@ -166,20 +167,35 @@ export make_si_ode ### Spatial Reaction Networks ### -# spatial reactions +# Spatial reactions. include("spatial_reaction_systems/spatial_reactions.jl") export TransportReaction, TransportReactions, @transport_reaction export isedgeparameter -# lattice reaction systems +# Lattice reaction systems. include("spatial_reaction_systems/lattice_reaction_systems.jl") export LatticeReactionSystem export spatial_species, vertex_parameters, edge_parameters +export CartesianGrid, CartesianGridReJ # (Implemented in JumpProcesses) +export has_cartesian_lattice, has_masked_lattice, has_grid_lattice, has_graph_lattice, + grid_dims, grid_size +export make_edge_p_values, make_directed_edge_values +include("spatial_reaction_systems/lattice_solution_interfacing.jl") +export get_lrs_vals + +# Specific spatial problem types. +include("spatial_reaction_systems/spatial_ODE_systems.jl") +export rebuild_lat_internals! +include("spatial_reaction_systems/lattice_jump_systems.jl") -# variosu utility functions +# General spatial modelling utility functions. include("spatial_reaction_systems/utility.jl") -# spatial lattice ode systems. -include("spatial_reaction_systems/spatial_ODE_systems.jl") +### ReactionSystem Serialisation ### +# Has to be at the end (because it uses records of all metadata declared by Catalyst). +include("reactionsystem_serialisation/serialisation_support.jl") +include("reactionsystem_serialisation/serialise_fields.jl") +include("reactionsystem_serialisation/serialise_reactionsystem.jl") +export save_reactionsystem end # module diff --git a/src/chemistry_functionality.jl b/src/chemistry_functionality.jl index 941038046b..ff3b663fe6 100644 --- a/src/chemistry_functionality.jl +++ b/src/chemistry_functionality.jl @@ -49,7 +49,6 @@ function component_coefficients(s) return [c => co for (c, co) in zip(components(s), coefficients(s))] end - ### Create @compound Macro(s) ### """ @@ -80,12 +79,14 @@ const COMPOUND_CREATION_ERROR_DEPENDENT_VAR_REQUIRED = "When the components (col function make_compound(expr) # Error checks. (expr isa Expr) || error(COMPOUND_CREATION_ERROR_BASE) - ((expr.head == :call) && (expr.args[1] == :~) && (length(expr.args) == 3)) || error(COMPOUND_CREATION_ERROR_BAD_SEPARATOR) + ((expr.head == :call) && (expr.args[1] == :~) && (length(expr.args) == 3)) || + error(COMPOUND_CREATION_ERROR_BAD_SEPARATOR) # Loops through all components, add the component and the coefficients to the corresponding vectors # Cannot extract directly using e.g. "getfield.(composition, :reactant)" because then # we get something like :([:C, :O]), rather than :([C, O]). - composition = Catalyst.recursive_find_reactants!(expr.args[3], 1, Vector{ReactantStruct}(undef, 0)) + composition = Catalyst.recursive_find_reactants!(expr.args[3], 1, + Vector{ReactantStruct}(undef, 0)) components = :([]) # Becomes something like :([C, O]). coefficients = :([]) # Becomes something like :([1, 2]). for comp in composition @@ -101,11 +102,13 @@ function make_compound(expr) species_name, ivs, _, _ = find_varinfo_in_declaration(expr.args[2]) # If no ivs were given, inserts `(..)` (e.g. turning `CO` to `CO(..)`). - isempty(ivs) && (species_expr = insert_independent_variable(species_expr, :(..))) + isempty(ivs) && (species_expr = insert_independent_variable(species_expr, :(..))) # Expression which when evaluated gives a vector with all the ivs of the components. - ivs_get_expr = :(unique(reduce(vcat,[arguments(ModelingToolkit.unwrap(comp)) for comp in $components]))) - + ivs_get_expr = :(unique(reduce( + vcat, [arguments(ModelingToolkit.unwrap(comp)) + for comp in $components]))) + # Creates the found expressions that will create the compound species. # The `Expr(:escape, :(...))` is required so that the expressions are evaluated in # the scope the users use the macro in (to e.g. detect already exiting species). @@ -117,13 +120,24 @@ function make_compound(expr) # `CO2 = ModelingToolkit.setmetadata(CO2, Catalyst.CompoundSpecies, true)` # `CO2 = ModelingToolkit.setmetadata(CO2, Catalyst.CompoundSpecies, [C, O])` # `CO2 = ModelingToolkit.setmetadata(CO2, Catalyst.CompoundSpecies, [1, 2])` - species_declaration_expr = Expr(:escape, :(@species $species_expr)) - multiple_ivs_error_check_expr = Expr(:escape, :($(isempty(ivs)) && (length($ivs_get_expr) > 1) && error($COMPOUND_CREATION_ERROR_DEPENDENT_VAR_REQUIRED))) - iv_designation_expr = Expr(:escape, :($(isempty(ivs)) && ($species_name = $(species_name)($(ivs_get_expr)...)))) - iv_check_expr = Expr(:escape, :(issetequal(arguments(ModelingToolkit.unwrap($species_name)), $ivs_get_expr) || error("The independent variable(S) provided to the compound ($(arguments(ModelingToolkit.unwrap($species_name)))), and those of its components ($($ivs_get_expr)))), are not identical."))) - compound_designation_expr = Expr(:escape, :($species_name = ModelingToolkit.setmetadata($species_name, Catalyst.CompoundSpecies, true))) - components_designation_expr = Expr(:escape, :($species_name = ModelingToolkit.setmetadata($species_name, Catalyst.CompoundComponents, $components))) - coefficients_designation_expr = Expr(:escape, :($species_name = ModelingToolkit.setmetadata($species_name, Catalyst.CompoundCoefficients, $coefficients))) + species_declaration_expr = Expr(:escape, :(@species $species_expr)) + multiple_ivs_error_check_expr = Expr(:escape, + :($(isempty(ivs)) && (length($ivs_get_expr) > 1) && + error($COMPOUND_CREATION_ERROR_DEPENDENT_VAR_REQUIRED))) + iv_designation_expr = Expr(:escape, + :($(isempty(ivs)) && ($species_name = $(species_name)($(ivs_get_expr)...)))) + iv_check_expr = Expr(:escape, + :(issetequal(arguments(ModelingToolkit.unwrap($species_name)), $ivs_get_expr) || + error("The independent variable(S) provided to the compound ($(arguments(ModelingToolkit.unwrap($species_name)))), and those of its components ($($ivs_get_expr)))), are not identical."))) + compound_designation_expr = Expr(:escape, + :($species_name = ModelingToolkit.setmetadata( + $species_name, Catalyst.CompoundSpecies, true))) + components_designation_expr = Expr(:escape, + :($species_name = ModelingToolkit.setmetadata( + $species_name, Catalyst.CompoundComponents, $components))) + coefficients_designation_expr = Expr(:escape, + :($species_name = ModelingToolkit.setmetadata( + $species_name, Catalyst.CompoundCoefficients, $coefficients))) # Returns the rephrased expression. return quote @@ -168,7 +182,7 @@ function make_compounds(expr) # For each compound in `expr`, creates the set of 7 compound creation lines (using `make_compound`). # Next, loops through all 7*[Number of compounds] lines and add them to compound_declarations. - compound_calls = [Catalyst.make_compound(line) for line in expr.args] + compound_calls = [Catalyst.make_compound(line) for line in expr.args] for compound_call in compound_calls, line in MacroTools.striplines(compound_call).args push!(compound_declarations.args, line) end @@ -183,13 +197,13 @@ function make_compounds(expr) push!(compound_declarations.args, :($(Expr(:escape, :($(compound_syms)))))) # The output needs to be converted to Vector{Num} (from Vector{SymbolicUtils.BasicSymbolic{Real}}) to be consistent with e.g. @variables. - compound_declarations.args[end] = :([ModelingToolkit.wrap(cmp) for cmp in $(compound_declarations.args[end])]) + compound_declarations.args[end] = :([ModelingToolkit.wrap(cmp) + for cmp in $(compound_declarations.args[end])]) # Returns output that. return compound_declarations end - ### Reaction Balancing Functionality ### """ @@ -245,21 +259,22 @@ function balance_reaction(reaction::Reaction) balancedrxs = Vector{Reaction}(undef, length(stoichiometries)) # Iterate over each stoichiometry vector and create a reaction - for (i,stoich) in enumerate(stoichiometries) + for (i, stoich) in enumerate(stoichiometries) # Divide the stoichiometry vector into substrate and product stoichiometries. substoich = stoich[1:length(reaction.substrates)] prodstoich = stoich[(length(reaction.substrates) + 1):end] # Create a new reaction with the balanced stoichiometries - balancedrx = Reaction(reaction.rate, reaction.substrates, - reaction.products, substoich, prodstoich) + balancedrx = Reaction(reaction.rate, reaction.substrates, reaction.products, + substoich, prodstoich) # Add the reaction to the vector of all reactions balancedrxs[i] = balancedrx end isempty(balancedrxs) && (@warn "Unable to balance reaction.") - (length(balancedrxs) > 1) && (@warn "The space of possible balanced versions of the reaction ($reaction) is greater than one-dimension. This prevents the selection of a single appropriate balanced reaction. Instead, a basis for balanced reactions is returned. Note that we do not check if they preserve the set of substrates and products from the original reaction.") + (length(balancedrxs) > 1) && + (@warn "The space of possible balanced versions of the reaction ($reaction) is greater than one-dimension. This prevents the selection of a single appropriate balanced reaction. Instead, a basis for balanced reactions is returned. Note that we do not check if they preserve the set of substrates and products from the original reaction.") return balancedrxs end @@ -321,7 +336,7 @@ function create_matrix(reaction::Catalyst.Reaction) coeffs = [1] end - for (atom,coeff) in zip(atoms, coeffs) + for (atom, coeff) in zip(atoms, coeffs) # Extract atom and coefficient from the pair i = findfirst(x -> isequal(x, atom), unique_atoms) if i === nothing @@ -387,4 +402,4 @@ function get_balanced_reaction(rx::Reaction) return only(brxs) end # For non-`Reaction` equations, returns the original equation. -get_balanced_reaction(eq::Equation) = eq \ No newline at end of file +get_balanced_reaction(eq::Equation) = eq diff --git a/src/dsl.jl b/src/dsl.jl index cbc72cdc61..6148eb4814 100644 --- a/src/dsl.jl +++ b/src/dsl.jl @@ -64,16 +64,14 @@ Example systems: const empty_set = Set{Symbol}([:∅]) const fwd_arrows = Set{Symbol}([:>, :(=>), :→, :↣, :↦, :⇾, :⟶, :⟼, :⥟, :⥟, :⇀, :⇁, :⇒, :⟾]) const bwd_arrows = Set{Symbol}([:<, :(<=), :←, :↢, :↤, :⇽, :⟵, :⟻, :⥚, :⥞, :↼, :↽, :⇐, :⟽, - Symbol("<--")]) + Symbol("<--")]) const double_arrows = Set{Symbol}([:↔, :⟷, :⇄, :⇆, :⇌, :⇋, :⇔, :⟺, Symbol("<-->")]) const pure_rate_arrows = Set{Symbol}([:(=>), :(<=), :⇐, :⟽, :⇒, :⟾, :⇔, :⟺]) - # Declares the keys used for various options. const option_keys = (:species, :parameters, :variables, :ivs, :compounds, :observables, - :default_noise_scaling, :differentials, :equations, - :continuous_events, :discrete_events, :combinatoric_ratelaws) - + :default_noise_scaling, :differentials, :equations, + :continuous_events, :discrete_events, :combinatoric_ratelaws) ### `@species` Macro ### @@ -88,9 +86,8 @@ macro species(ex...) idx = length(vars.args) resize!(vars.args, idx + length(lastarg.args) + 1) for sym in lastarg.args - vars.args[idx] = :($sym = ModelingToolkit.wrap(setmetadata(ModelingToolkit.value($sym), - Catalyst.VariableSpecies, - true))) + vars.args[idx] = :($sym = ModelingToolkit.wrap(setmetadata( + ModelingToolkit.value($sym), Catalyst.VariableSpecies, true))) idx += 1 end @@ -108,7 +105,6 @@ macro species(ex...) esc(vars) end - ### `@reaction_network` and `@network_component` Macros ### """ @@ -144,12 +140,14 @@ emptyrn = @reaction_network ReactionSystems generated through `@reaction_network` are complete. """ macro reaction_network(name::Symbol, ex::Expr) - :(complete($(make_reaction_system(MacroTools.striplines(ex); name = :($(QuoteNode(name))))))) + :(complete($(make_reaction_system( + MacroTools.striplines(ex); name = :($(QuoteNode(name))))))) end # Allows @reaction_network $name begin ... to interpolate variables storing a name. macro reaction_network(name::Expr, ex::Expr) - :(complete($(make_reaction_system(MacroTools.striplines(ex); name = :($(esc(name.args[1]))))))) + :(complete($(make_reaction_system( + MacroTools.striplines(ex); name = :($(esc(name.args[1]))))))) end macro reaction_network(ex::Expr) @@ -161,14 +159,14 @@ macro reaction_network(ex::Expr) else # empty but has interpolated name: @reaction_network $name networkname = :($(esc(ex.args[1]))) return Expr(:block, :(@parameters t), - :(complete(ReactionSystem(Reaction[], t, [], []; name = $networkname)))) + :(complete(ReactionSystem(Reaction[], t, [], []; name = $networkname)))) end end # Returns a empty network (with, or without, a declared name). macro reaction_network(name::Symbol = gensym(:ReactionSystem)) return Expr(:block, :(@parameters t), - :(complete(ReactionSystem(Reaction[], t, [], []; name = $(QuoteNode(name)))))) + :(complete(ReactionSystem(Reaction[], t, [], []; name = $(QuoteNode(name)))))) end # Ideally, it would have been possible to combine the @reaction_network and @network_component macros. @@ -177,7 +175,8 @@ end """ @network_component -As @reaction_network, but the output system is not complete. +Equivalent to `@reaction_network` except the generated `ReactionSystem` is not marked as +complete. """ macro network_component(name::Symbol, ex::Expr) make_reaction_system(MacroTools.striplines(ex); name = :($(QuoteNode(name)))) @@ -197,17 +196,16 @@ macro network_component(ex::Expr) else # empty but has interpolated name: @network_component $name networkname = :($(esc(ex.args[1]))) return Expr(:block, :(@parameters t), - :(ReactionSystem(Reaction[], t, [], []; name = $networkname))) + :(ReactionSystem(Reaction[], t, [], []; name = $networkname))) end end # Returns a empty network (with, or without, a declared name). macro network_component(name::Symbol = gensym(:ReactionSystem)) return Expr(:block, :(@parameters t), - :(ReactionSystem(Reaction[], t, [], []; name = $(QuoteNode(name))))) + :(ReactionSystem(Reaction[], t, [], []; name = $(QuoteNode(name))))) end - ### Internal DSL Structures ### # Structure containing information about one reactant in one reaction. @@ -224,7 +222,7 @@ struct ReactionStruct metadata::Expr function ReactionStruct(sub_line::ExprValues, prod_line::ExprValues, rate::ExprValues, - metadata_line::ExprValues) + metadata_line::ExprValues) sub = recursive_find_reactants!(sub_line, 1, Vector{ReactantStruct}(undef, 0)) prod = recursive_find_reactants!(prod_line, 1, Vector{ReactantStruct}(undef, 0)) metadata = extract_metadata(metadata_line) @@ -235,21 +233,21 @@ end # Recursive function that loops through the reaction line and finds the reactants and their # stoichiometry. Recursion makes it able to handle weird cases like 2(X+Y+3(Z+XY)). function recursive_find_reactants!(ex::ExprValues, mult::ExprValues, - reactants::Vector{ReactantStruct}) + reactants::Vector{ReactantStruct}) if typeof(ex) != Expr || (ex.head == :escape) || (ex.head == :ref) (ex == 0 || in(ex, empty_set)) && (return reactants) if any(ex == reactant.reactant for reactant in reactants) idx = findall(x -> x == ex, getfield.(reactants, :reactant))[1] reactants[idx] = ReactantStruct(ex, - processmult(+, mult, - reactants[idx].stoichiometry)) + processmult(+, mult, + reactants[idx].stoichiometry)) else push!(reactants, ReactantStruct(ex, mult)) end elseif ex.args[1] == :* if length(ex.args) == 3 recursive_find_reactants!(ex.args[3], processmult(*, mult, ex.args[2]), - reactants) + reactants) else newmult = processmult(*, mult, Expr(:call, ex.args[1:(end - 1)]...)) recursive_find_reactants!(ex.args[end], newmult, reactants) @@ -272,18 +270,19 @@ function processmult(op, mult, stoich) end end -# Finds the metadata from a metadata expresion (`[key=val, ...]`) and returns as a Vector{Pair{Symbol,ExprValues}}. +# Finds the metadata from a metadata expression (`[key=val, ...]`) and returns as a Vector{Pair{Symbol,ExprValues}}. function extract_metadata(metadata_line::Expr) metadata = :([]) for arg in metadata_line.args - (arg.head != :(=)) && error("Malformatted metadata line: $metadata_line. Each entry in the vector should contain a `=`.") - (arg.args[1] isa Symbol) || error("Malformatted metadata entry: $arg. Entries left-hand-side should be a single symbol.") + (arg.head != :(=)) && + error("Malformatted metadata line: $metadata_line. Each entry in the vector should contain a `=`.") + (arg.args[1] isa Symbol) || + error("Malformatted metadata entry: $arg. Entries left-hand-side should be a single symbol.") push!(metadata.args, :($(QuoteNode(arg.args[1])) => $(arg.args[2]))) end return metadata end - ### DSL Internal Master Function ### # Function for creating a ReactionSystem structure (used by the @reaction_network macro). @@ -298,10 +297,10 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem))) # Get macro options. if length(unique(arg.args[1] for arg in option_lines)) < length(option_lines) - error("Some options where given multiple times.") + error("Some options where given multiple times.") end options = Dict(map(arg -> Symbol(String(arg.args[1])[2:end]) => arg, - option_lines)) + option_lines)) # Reads options. default_reaction_metadata = :([]) @@ -317,7 +316,8 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem))) variables_declared = extract_syms(options, :variables) # Reads more options. - vars_extracted, add_default_diff, equations = read_equations_options(options, variables_declared) + vars_extracted, add_default_diff, equations = read_equations_options( + options, variables_declared) variables = vcat(variables_declared, vars_extracted) # handle independent variables @@ -340,17 +340,19 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem))) end # Reads more options. - observed_vars, observed_eqs, obs_syms = read_observed_options(options, [species_declared; variables], all_ivs) + observed_vars, observed_eqs, obs_syms = read_observed_options( + options, [species_declared; variables], all_ivs) declared_syms = Set(Iterators.flatten((parameters_declared, species_declared, - variables))) + variables))) species_extracted, parameters_extracted = extract_species_and_parameters!(reactions, - declared_syms) + declared_syms) species = vcat(species_declared, species_extracted) parameters = vcat(parameters_declared, parameters_extracted) # Create differential expression. - diffexpr = create_differential_expr(options, add_default_diff, [species; parameters; variables], tiv) + diffexpr = create_differential_expr( + options, add_default_diff, [species; parameters; variables], tiv) # Checks for input errors. (sum(length.([reaction_lines, option_lines])) != length(ex.args)) && @@ -398,7 +400,6 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem))) end end - ### DSL Reaction Reading Functions ### # Generates a vector containing a number of reaction structures, each containing the information about one reaction. @@ -412,12 +413,16 @@ function get_reactions(exprs::Vector{Expr}, reactions = Vector{ReactionStruct}(u if typeof(rate) != Expr || rate.head != :tuple error("Error: Must provide a tuple of reaction rates when declaring a bi-directional reaction.") end - push_reactions!(reactions, reaction.args[2], reaction.args[3], rate.args[1], metadata.args[1], arrow) - push_reactions!(reactions, reaction.args[3], reaction.args[2], rate.args[2], metadata.args[2], arrow) + push_reactions!(reactions, reaction.args[2], reaction.args[3], + rate.args[1], metadata.args[1], arrow) + push_reactions!(reactions, reaction.args[3], reaction.args[2], + rate.args[2], metadata.args[2], arrow) elseif in(arrow, fwd_arrows) - push_reactions!(reactions, reaction.args[2], reaction.args[3], rate, metadata, arrow) + push_reactions!(reactions, reaction.args[2], reaction.args[3], + rate, metadata, arrow) elseif in(arrow, bwd_arrows) - push_reactions!(reactions, reaction.args[3], reaction.args[2], rate, metadata, arrow) + push_reactions!(reactions, reaction.args[3], reaction.args[2], + rate, metadata, arrow) else throw("Malformed reaction, invalid arrow type used in: $(MacroTools.striplines(line))") end @@ -431,7 +436,9 @@ function read_reaction_line(line::Expr) # Special routine required for the`-->` case, which creates different expression from all other cases. rate = line.args[1] reaction = line.args[2] - (reaction.head == :-->) && (reaction = Expr(:call, :→, reaction.args[1], reaction.args[2])) + if reaction.head == :--> + reaction = Expr(:call, :→, reaction.args[1], reaction.args[2]) + end arrow = reaction.args[1] # Handles metadata. If not provided, empty metadata is created. @@ -448,8 +455,8 @@ end # Takes a reaction line and creates reaction(s) from it and pushes those to the reaction array. # Used to create multiple reactions from, for instance, `k, (X,Y) --> 0`. -function push_reactions!(reactions::Vector{ReactionStruct}, sub_line::ExprValues, prod_line::ExprValues, - rate::ExprValues, metadata::ExprValues, arrow::Symbol) +function push_reactions!(reactions::Vector{ReactionStruct}, sub_line::ExprValues, + prod_line::ExprValues, rate::ExprValues, metadata::ExprValues, arrow::Symbol) # The rates, substrates, products, and metadata may be in a tupple form (e.g. `k, (X,Y) --> 0`). # This finds the length of these tuples (or 1 if not in tuple forms). Errors if lengs inconsistent. lengs = (tup_leng(sub_line), tup_leng(prod_line), tup_leng(rate), tup_leng(metadata)) @@ -465,17 +472,17 @@ function push_reactions!(reactions::Vector{ReactionStruct}, sub_line::ExprValues push!(metadata_i.args, :(only_use_rate = $(in(arrow, pure_rate_arrows)))) end - # Checks that metadata fields are unqiue. + # Checks that metadata fields are unique. if !allunique(arg.args[1] for arg in metadata_i.args) error("Some reaction metadata fields where repeated: $(metadata_entries)") end - push!(reactions, ReactionStruct(get_tup_arg(sub_line, i), get_tup_arg(prod_line, i), - get_tup_arg(rate, i), metadata_i)) + push!(reactions, + ReactionStruct(get_tup_arg(sub_line, i), + get_tup_arg(prod_line, i), get_tup_arg(rate, i), metadata_i)) end end - ### DSL Species and Parameters Extraction ### # When the user have used the @species (or @parameters) options, extract species (or @@ -529,12 +536,11 @@ function add_syms_from_expr!(push_symbols::AbstractSet, rateex::ExprValues, excl nothing end - ### DSL Output Expression Builders ### # Given the species that were extracted from the reactions, and the options dictionary, creates the @species ... expression for the macro output. function get_sexpr(species_extracted, options, key = :species; - iv_symbols = (DEFAULT_IV_SYM,)) + iv_symbols = (DEFAULT_IV_SYM,)) if haskey(options, key) sexprs = options[key] elseif isempty(species_extracted) @@ -543,7 +549,7 @@ function get_sexpr(species_extracted, options, key = :species; sexprs = Expr(:macrocall, Symbol("@", key), LineNumberNode(0)) end foreach(s -> (s isa Symbol) && push!(sexprs.args, Expr(:call, s, iv_symbols...)), - species_extracted) + species_extracted) sexprs end @@ -562,8 +568,8 @@ function get_rxexprs(rxstruct) prod_init = isempty(rxstruct.products) ? nothing : :([]) prod_stoich_init = deepcopy(prod_init) reaction_func = :(Reaction($(recursive_expand_functions!(rxstruct.rate)), $subs_init, - $prod_init, $subs_stoich_init, $prod_stoich_init, - metadata = $(rxstruct.metadata),)) + $prod_init, $subs_stoich_init, $prod_stoich_init, + metadata = $(rxstruct.metadata))) for sub in rxstruct.substrates push!(reaction_func.args[3].args, sub.reactant) push!(reaction_func.args[5].args, sub.stoichiometry) @@ -591,27 +597,28 @@ function scalarize_macro(nonempty, ex, name) ex, namesym end - ### DSL Option Handling ### # Checks if the `@default_noise_scaling` option is used. If so, read its input and adds it as a # default metadata value to the `default_reaction_metadata` vector. function check_default_noise_scaling!(default_reaction_metadata, options) if haskey(options, :default_noise_scaling) - if (length(options[:default_noise_scaling].args) != 3) # Becasue of how expressions are, 3 is used. + if (length(options[:default_noise_scaling].args) != 3) # Because of how expressions are, 3 is used. error("@default_noise_scaling should only have a single input, this appears not to be the case: \"$(options[:default_noise_scaling])\"") end - push!(default_reaction_metadata.args, :(:noise_scaling => $(options[:default_noise_scaling].args[3]))) + push!(default_reaction_metadata.args, + :(:noise_scaling => $(options[:default_noise_scaling].args[3]))) end end # When compound species are declared using the "@compound begin ... end" option, get a list of the compound species, and also the expression that crates them. function read_compound_options(opts) - # If the compound option is used retrive a list of compound species (need to be added to the reaction system's species), and the option that creates them (used to declare them as compounds at the end). + # If the compound option is used retrieve a list of compound species (need to be added to the reaction system's species), and the option that creates them (used to declare them as compounds at the end). if haskey(opts, :compounds) compound_expr = opts[:compounds] # Find compound species names, and append the independent variable. - compound_species = [find_varinfo_in_declaration(arg.args[2])[1] for arg in compound_expr.args[3].args] + compound_species = [find_varinfo_in_declaration(arg.args[2])[1] + for arg in compound_expr.args[3].args] else # If option is not used, return empty vectors and expressions. compound_expr = :() compound_species = Union{Symbol, Expr}[] @@ -619,26 +626,31 @@ function read_compound_options(opts) return compound_expr, compound_species end -# Read the events (continious or discrete) provided as options to the DSL. Returns an expression which evalutes to these. +# Read the events (continuous or discrete) provided as options to the DSL. Returns an expression which evaluates to these. function read_events_option(options, event_type::Symbol) # Prepares the events, if required to, converts them to block form. - (event_type in [:continuous_events, :discrete_events]) || error("Trying to read an unsupported event type.") - events_input = haskey(options, event_type) ? options[event_type].args[3] : MacroTools.striplines(:(begin end)) + if event_type ∉ [:continuous_events, :discrete_events] + error("Trying to read an unsupported event type.") + end + events_input = haskey(options, event_type) ? options[event_type].args[3] : + MacroTools.striplines(:(begin end)) events_input = option_block_form(events_input) - # Goes throgh the events, checks for errors, and adds them to the output vector. + # Goes through the events, checks for errors, and adds them to the output vector. events_expr = :([]) for arg in events_input.args # Formatting error checks. # NOTE: Maybe we should move these deeper into the system (rather than the DSL), throwing errors more generally? - if (arg isa Expr) && (arg.head != :call) || (arg.args[1] != :(=>)) || length(arg.args) != 3 + if (arg isa Expr) && (arg.head != :call) || (arg.args[1] != :(=>)) || + (length(arg.args) != 3) error("Events should be on form `condition => affect`, separated by a `=>`. This appears not to be the case for: $(arg).") end - if (arg isa Expr) && (arg.args[2] isa Expr) && (arg.args[2].head != :vect) && (event_type == :continuous_events) - error("The condition part of continious events (the left-hand side) must be a vector. This is not the case for: $(arg).") + if (arg isa Expr) && (arg.args[2] isa Expr) && (arg.args[2].head != :vect) && + (event_type == :continuous_events) + error("The condition part of continuous events (the left-hand side) must be a vector. This is not the case for: $(arg).") end if (arg isa Expr) && (arg.args[3] isa Expr) && (arg.args[3].head != :vect) - error("The affect part of all events (the righ-hand side) must be a vector. This is not the case for: $(arg).") + error("The affect part of all events (the righ-hand side) must be a vector. This is not the case for: $(arg).") end # Adds the correctly formatted event to the event creation expression. @@ -650,15 +662,16 @@ end # Reads the variables options. Outputs: # `vars_extracted`: A vector with extracted variables (lhs in pure differential equations only). -# `dtexpr`: If a differentialequation is defined, the default derrivative (D ~ Differential(t)) must be defined. +# `dtexpr`: If a differential equation is defined, the default derivative (D ~ Differential(t)) must be defined. # `equations`: a vector with the equations provided. function read_equations_options(options, variables_declared) - # Prepares the equations. First, extracts equations from provided option (converting to block form if requried). + # Prepares the equations. First, extracts equations from provided option (converting to block form if required). # Next, uses MTK's `parse_equations!` function to split input into a vector with the equations. eqs_input = haskey(options, :equations) ? options[:equations].args[3] : :(begin end) eqs_input = option_block_form(eqs_input) equations = Expr[] - ModelingToolkit.parse_equations!(Expr(:block), equations, Dict{Symbol, Any}(), eqs_input) + ModelingToolkit.parse_equations!(Expr(:block), equations, + Dict{Symbol, Any}(), eqs_input) # Loops through all equations, checks for lhs of the form `D(X) ~ ...`. # When this is the case, the variable X and differential D are extracted (for automatic declaration). @@ -667,7 +680,7 @@ function read_equations_options(options, variables_declared) add_default_diff = false for eq in equations if (eq.head != :call) || (eq.args[1] != :~) - error("Malformed equation: \"$eq\". Equation's left hand and right hand sides should be separated by a \"~\".") + error("Malformed equation: \"$eq\". Equation's left hand and right hand sides should be separated by a \"~\".") end # Checks if the equation have the format D(X) ~ ... (where X is a symbol). This means that the @@ -675,7 +688,8 @@ function read_equations_options(options, variables_declared) # we make a note that a differential D = Differential(iv) should be made as well. lhs = eq.args[2] # if lhs: is an expression. Is a function call. The function's name is D. Calls a single symbol. - if (lhs isa Expr) && (lhs.head == :call) && (lhs.args[1] == :D) && (lhs.args[2] isa Symbol) + if (lhs isa Expr) && (lhs.head == :call) && (lhs.args[1] == :D) && + (lhs.args[2] isa Symbol) diff_var = lhs.args[2] if in(diff_var, forbidden_symbols_error) error("A forbidden symbol ($(diff_var)) was used as an variable in this differential equation: $eq") @@ -694,18 +708,23 @@ function create_differential_expr(options, add_default_diff, used_syms, tiv) # Creates the differential expression. # If differentials was provided as options, this is used as the initial expression. # If the default differential (D(...)) was used in equations, this is added to the expression. - diffexpr = (haskey(options, :differentials) ? options[:differentials].args[3] : MacroTools.striplines(:(begin end))) + diffexpr = (haskey(options, :differentials) ? options[:differentials].args[3] : + MacroTools.striplines(:(begin end))) diffexpr = option_block_form(diffexpr) # Goes through all differentials, checking that they are correctly formatted and their symbol is not used elsewhere. for dexpr in diffexpr.args - (dexpr.head != :(=)) && error("Differential declaration must have form like D = Differential(t), instead \"$(dexpr)\" was given.") - (dexpr.args[1] isa Symbol) || error("Differential left-hand side must be a single symbol, instead \"$(dexpr.args[1])\" was given.") - in(dexpr.args[1], used_syms) && error("Differential name ($(dexpr.args[1])) is also a species, variable, or parameter. This is ambigious and not allowed.") - in(dexpr.args[1], forbidden_symbols_error) && error("A forbidden symbol ($(dexpr.args[1])) was used as a differential name.") + (dexpr.head != :(=)) && + error("Differential declaration must have form like D = Differential(t), instead \"$(dexpr)\" was given.") + (dexpr.args[1] isa Symbol) || + error("Differential left-hand side must be a single symbol, instead \"$(dexpr.args[1])\" was given.") + in(dexpr.args[1], used_syms) && + error("Differential name ($(dexpr.args[1])) is also a species, variable, or parameter. This is ambiguous and not allowed.") + in(dexpr.args[1], forbidden_symbols_error) && + error("A forbidden symbol ($(dexpr.args[1])) was used as a differential name.") end - # If the default differential D has been used, but not pre-declared using the @differenitals + # If the default differential D has been used, but not pre-declared using the @differentials # options, add this declaration to the list of declared differentials. if add_default_diff && !any(diff_dec.args[1] == :D for diff_dec in diffexpr.args) push!(diffexpr.args, :(D = Differential($(tiv)))) @@ -714,11 +733,11 @@ function create_differential_expr(options, add_default_diff, used_syms, tiv) return diffexpr end -# Reads the observables options. Outputs an expression ofr creating the obervable variables, and a vector of observable equations. +# Reads the observables options. Outputs an expression ofr creating the observable variables, and a vector of observable equations. function read_observed_options(options, species_n_vars_declared, ivs_sorted) if haskey(options, :observables) # Gets list of observable equations and prepares variable declaration expression. - # (`options[:observables]` inlucdes `@observables`, `.args[3]` removes this part) + # (`options[:observables]` includes `@observables`, `.args[3]` removes this part) observed_eqs = make_observed_eqs(options[:observables].args[3]) observed_vars = Expr(:block, :(@variables)) obs_syms = :([]) @@ -726,16 +745,20 @@ function read_observed_options(options, species_n_vars_declared, ivs_sorted) for (idx, obs_eq) in enumerate(observed_eqs.args) # Extract the observable, checks errors, and continues the loop if the observable has been declared. obs_name, ivs, defaults, metadata = find_varinfo_in_declaration(obs_eq.args[2]) - isempty(ivs) || error("An observable ($obs_name) was given independent variable(s). These should not be given, as they are inferred automatically.") - isnothing(defaults) || error("An observable ($obs_name) was given a default value. This is forbidden.") - in(obs_name, forbidden_symbols_error) && error("A forbidden symbol ($(obs_eq.args[2])) was used as an observable name.") + isempty(ivs) || + error("An observable ($obs_name) was given independent variable(s). These should not be given, as they are inferred automatically.") + isnothing(defaults) || + error("An observable ($obs_name) was given a default value. This is forbidden.") + (obs_name in forbidden_symbols_error) && + error("A forbidden symbol ($(obs_eq.args[2])) was used as an observable name.") # Error checks. if (obs_name in species_n_vars_declared) && is_escaped_expr(obs_eq.args[2]) - error("An interpoalted observable have been used, which has also been explicitly delcared within the system using eitehr @species or @variables. This is not permited.") + error("An interpolated observable have been used, which has also been explicitly declared within the system using either @species or @variables. This is not permitted.") end - if ((obs_name in species_n_vars_declared) || is_escaped_expr(obs_eq.args[2])) && !isnothing(metadata) - error("Metadata was provided to observable $obs_name in the `@observables` macro. However, the obervable was also declared separately (using either @species or @variables). When this is done, metadata should instead be provided within the original @species or @variable declaration.") + if ((obs_name in species_n_vars_declared) || is_escaped_expr(obs_eq.args[2])) && + !isnothing(metadata) + error("Metadata was provided to observable $obs_name in the `@observables` macro. However, the observable was also declared separately (using either @species or @variables). When this is done, metadata should instead be provided within the original @species or @variable declaration.") end # This bits adds the observables to the @variables vector which is given as output. @@ -744,16 +767,21 @@ function read_observed_options(options, species_n_vars_declared, ivs_sorted) if !((obs_name in species_n_vars_declared) || is_escaped_expr(obs_eq.args[2])) # Appends (..) to the observable (which is later replaced with the extracted ivs). # Adds the observable to the first line of the output expression (starting with `@variables`). - obs_expr = insert_independent_variable(obs_eq.args[2], :(..)) - push!(observed_vars.args[1].args, obs_expr) + obs_expr = insert_independent_variable(obs_eq.args[2], :(..)) + push!(observed_vars.args[1].args, obs_expr) # Adds a line to the `observed_vars` expression, setting the ivs for this observable. # Cannot extract directly using e.g. "getfield.(dependants_structs, :reactant)" because # then we get something like :([:X1, :X2]), rather than :([X1, X2]). - dep_var_expr = :(filter(!MT.isparameter, Symbolics.get_variables($(obs_eq.args[3])))) - ivs_get_expr = :(unique(reduce(vcat,[arguments(MT.unwrap(dep)) for dep in $dep_var_expr]))) - ivs_get_expr_sorted = :(sort($(ivs_get_expr); by = iv -> findfirst(MT.getname(iv) == ivs for ivs in $ivs_sorted))) - push!(observed_vars.args, :($obs_name = $(obs_name)($(ivs_get_expr_sorted)...))) + dep_var_expr = :(filter(!MT.isparameter, + Symbolics.get_variables($(obs_eq.args[3])))) + ivs_get_expr = :(unique(reduce( + vcat, [arguments(MT.unwrap(dep)) + for dep in $dep_var_expr]))) + ivs_get_expr_sorted = :(sort($(ivs_get_expr); + by = iv -> findfirst(MT.getname(iv) == ivs for ivs in $ivs_sorted))) + push!(observed_vars.args, + :($obs_name = $(obs_name)($(ivs_get_expr_sorted)...))) end # In case metadata was given, this must be cleared from `observed_eqs`. @@ -762,7 +790,7 @@ function read_observed_options(options, species_n_vars_declared, ivs_sorted) # Adds the observable to the list of observable names. # This is required for filtering away so these are not added to the ReactionSystem's species list. - # Again, avoid this check if we have interpoalted the variable. + # Again, avoid this check if we have interpolated the variable. is_escaped_expr(obs_eq.args[2]) || push!(obs_syms.args, obs_name) end @@ -778,7 +806,7 @@ function read_observed_options(options, species_n_vars_declared, ivs_sorted) end # From the input to the @observables options, creates a vector containing one equation for each observable. -# Checks separate cases for "@obervables O ~ ..." and "@obervables begin ... end". Other cases errors. +# Checks separate cases for "@observables O ~ ..." and "@observables begin ... end". Other cases errors. function make_observed_eqs(observables_expr) if observables_expr.head == :call return :([$(observables_expr)]) @@ -793,7 +821,6 @@ function make_observed_eqs(observables_expr) end end - ### `@reaction` Macro & its Internals ### @doc raw""" @@ -870,14 +897,13 @@ function get_reaction(line) return reaction[1] end - ### Generic Expression Manipulation ### # Recursively traverses an expression and replaces special function call like "hill(...)" with the actual corresponding expression. function recursive_expand_functions!(expr::ExprValues) (typeof(expr) != Expr) && (return expr) foreach(i -> expr.args[i] = recursive_expand_functions!(expr.args[i]), - 1:length(expr.args)) + 1:length(expr.args)) if expr.head == :call !isdefined(Catalyst, expr.args[1]) && (expr.args[1] = esc(expr.args[1])) end @@ -895,4 +921,4 @@ end function get_tup_arg(ex::ExprValues, i::Int) (tup_leng(ex) == 1) && (return ex) return ex.args[i] -end \ No newline at end of file +end diff --git a/src/expression_utils.jl b/src/expression_utils.jl index f0d039759f..c1faa8ca8c 100644 --- a/src/expression_utils.jl +++ b/src/expression_utils.jl @@ -19,7 +19,6 @@ function is_escaped_expr(expr) return (expr isa Expr) && (expr.head == :escape) && (length(expr.args) == 1) end - ### Parameters/Species/Variables Symbols Correctness Checking ### # Throws an error when a forbidden symbol is used. @@ -27,8 +26,8 @@ function forbidden_symbol_check(v) !isempty(intersect(forbidden_symbols_error, v)) && error("The following symbol(s) are used as species or parameters: " * ((map(s -> "'" * string(s) * "', ", - intersect(forbidden_symbols_error, v))...)) * - "this is not permited.") + intersect(forbidden_symbols_error, v))...)) * + "this is not permitted.") nothing end @@ -37,8 +36,8 @@ function forbidden_variable_check(v) !isempty(intersect(forbidden_variables_error, v)) && error("The following symbol(s) are used as variables: " * ((map(s -> "'" * string(s) * "', ", - intersect(forbidden_variables_error, v))...)) * - "this is not permited.") + intersect(forbidden_variables_error, v))...)) * + "this is not permitted.") end function unique_symbol_check(syms) @@ -47,7 +46,6 @@ function unique_symbol_check(syms) nothing end - ### Catalyst-specific Expressions Manipulation ### # Some options takes input on form that is either `@option ...` or `@option begin ... end`. @@ -75,25 +73,31 @@ function find_varinfo_in_declaration(expr) is_escaped_expr(expr) && (return find_varinfo_in_declaration(expr.args[1])) # Case: X - (expr isa Symbol) && (return expr, [], nothing, nothing) + (expr isa Symbol) && (return expr, [], nothing, nothing) # Case: X(t) - (expr.head == :call) && (return expr.args[1], expr.args[2:end], nothing, nothing) + (expr.head == :call) && (return expr.args[1], expr.args[2:end], nothing, nothing) if expr.head == :(=) # Case: X = 1.0 - (expr.args[1] isa Symbol) && (return expr.args[1], [], expr.args[2], nothing) + (expr.args[1] isa Symbol) && (return expr.args[1], [], expr.args[2], nothing) # Case: X(t) = 1.0 - (expr.args[1].head == :call) && (return expr.args[1].args[1], expr.args[1].args[2:end], expr.args[2].args[1], nothing) + (expr.args[1].head == :call) && + (return expr.args[1].args[1], expr.args[1].args[2:end], expr.args[2].args[1], + nothing) end if expr.head == :tuple # Case: X, [metadata=true] - (expr.args[1] isa Symbol) && (return expr.args[1], [], nothing, expr.args[2]) + (expr.args[1] isa Symbol) && (return expr.args[1], [], nothing, expr.args[2]) # Case: X(t), [metadata=true] - (expr.args[1].head == :call) && (return expr.args[1].args[1], expr.args[1].args[2:end], nothing, expr.args[2]) - if (expr.args[1].head == :(=)) + (expr.args[1].head == :call) && + (return expr.args[1].args[1], expr.args[1].args[2:end], nothing, expr.args[2]) + if expr.args[1].head == :(=) # Case: X = 1.0, [metadata=true] - (expr.args[1].args[1] isa Symbol) && (return expr.args[1].args[1], [], expr.args[1].args[2], expr.args[2]) + (expr.args[1].args[1] isa Symbol) && + (return expr.args[1].args[1], [], expr.args[1].args[2], expr.args[2]) # Case: X(t) = 1.0, [metadata=true] - (expr.args[1].args[1].head == :call) && (return expr.args[1].args[1].args[1], expr.args[1].args[1].args[2:end], expr.args[1].args[2].args[1], expr.args[2]) + (expr.args[1].args[1].head == :call) && + (return expr.args[1].args[1].args[1], expr.args[1].args[1].args[2:end], + expr.args[1].args[2].args[1], expr.args[2]) end end error("Unable to detect the variable declared in expression: $expr.") @@ -119,11 +123,11 @@ function insert_independent_variable(expr_in, iv_expr) expr = deepcopy(expr_in) # Loops through possible cases. - if expr.head == :(=) + if expr.head == :(=) # Case: :(X = 1.0) expr.args[1] = Expr(:call, expr.args[1], iv_expr) elseif expr.head == :tuple - if expr.args[1] isa Symbol + if expr.args[1] isa Symbol # Case: :(X, [metadata=true]) expr.args[1] = Expr(:call, expr.args[1], iv_expr) elseif (expr.args[1].head == :(=)) && (expr.args[1].args[1] isa Symbol) diff --git a/src/graphs.jl b/src/graphs.jl index 456e9ea728..2f1fb490c4 100644 --- a/src/graphs.jl +++ b/src/graphs.jl @@ -34,7 +34,6 @@ References: - DOT language guide: http://www.graphviz.org/pdf/dotguide.pdf """ - ### AST ### abstract type Expression end @@ -123,7 +122,6 @@ Edge(path::Vector{String}, attrs::AbstractDict) = Edge(map(NodeID, path), attrs) Edge(path::Vector{String}; attrs...) = Edge(map(NodeID, path), attrs) Edge(path::Vararg{String}; attrs...) = Edge(map(NodeID, collect(path)), attrs) - ### Bindings ### """ Run a Graphviz program. @@ -137,7 +135,7 @@ For bindings to the Graphviz C API, see the the package GraphViz.jl is unmaintained. """ function run_graphviz(io::IO, graph::Graph; prog::Union{String, Nothing} = nothing, - format::String = "json0") + format::String = "json0") if isnothing(prog) prog = graph.prog end @@ -240,7 +238,7 @@ function pprint(io::IO, edge::Edge, n::Int; directed::Bool = false) end function pprint_attrs(io::IO, attrs::Attributes, n::Int = 0; - pre::String = "", post::String = "") + pre::String = "", post::String = "") if !isempty(attrs) indent(io, n) print(io, pre) @@ -319,7 +317,6 @@ function modifystrcomp(strcomp::Vector{String}) strcomp = "<" .* strcomp .* ">" end - ### Public-facing API ### """ @@ -366,7 +363,7 @@ function complexgraph(rn::ReactionSystem; complexdata = reactioncomplexes(rn)) append!(stmts2, compnodes) append!(stmts2, collect(Iterators.flatten(edges))) g = Digraph("G", stmts2; graph_attrs = graph_attrs, node_attrs = node_attrs, - edge_attrs = edge_attrs) + edge_attrs = edge_attrs) return g end @@ -392,15 +389,15 @@ function Graph(rn::ReactionSystem) rxs = reactions(rn) specs = species(rn) statenodes = [Node(string(getname(s)), - Attributes(:shape => "circle", :color => "#6C9AC3")) for s in specs] + Attributes(:shape => "circle", :color => "#6C9AC3")) for s in specs] transnodes = [Node(string("rx_$i"), - Attributes(:shape => "point", :color => "#E28F41", :width => ".1")) + Attributes(:shape => "point", :color => "#E28F41", :width => ".1")) for (i, r) in enumerate(rxs)] stmts = vcat(statenodes, transnodes) edges = map(enumerate(rxs)) do (i, r) vcat(edgify(zip(r.substrates, r.substoich), i, false), - edgify(zip(r.products, r.prodstoich), i, true)) + edgify(zip(r.products, r.prodstoich), i, true)) end es = edgifyrates(rxs, specs) (!isempty(es)) && push!(edges, es) @@ -409,7 +406,7 @@ function Graph(rn::ReactionSystem) append!(stmts2, stmts) append!(stmts2, collect(Iterators.flatten(edges))) g = Digraph("G", stmts2; graph_attrs = graph_attrs, node_attrs = node_attrs, - edge_attrs = edge_attrs) + edge_attrs = edge_attrs) return g end diff --git a/src/latexify_recipes.jl b/src/latexify_recipes.jl index a7d5cf1e7c..1104a55328 100644 --- a/src/latexify_recipes.jl +++ b/src/latexify_recipes.jl @@ -11,7 +11,7 @@ const LATEX_DEFS = CatalystLatexParams() ### Latexify Receipt ### -@latexrecipe function f(rs::ReactionSystem; form = :reactions, expand_functions=true) +@latexrecipe function f(rs::ReactionSystem; form = :reactions, expand_functions = true) expand_functions && (rs = expand_registered_functions(rs)) if form == :reactions # Returns chemical reaction network code. cdot --> false @@ -37,9 +37,9 @@ function Latexify.infer_output(env, rs::ReactionSystem, args...) end function chemical_arrows(rn::ReactionSystem; expand = true, - double_linebreak = LATEX_DEFS.double_linebreak, - starred = LATEX_DEFS.starred, mathrm = true, - mathjax = LATEX_DEFS.mathjax, kwargs...) + double_linebreak = LATEX_DEFS.double_linebreak, + starred = LATEX_DEFS.starred, mathrm = true, + mathjax = LATEX_DEFS.mathjax, kwargs...) any_nonrx_subsys(rn) && (@warn "Latexify currently ignores non-ReactionSystem subsystems. Please call `flatsys = flatten(sys)` to obtain a flattened version of your system before trying to Latexify it.") @@ -81,7 +81,7 @@ function chemical_arrows(rn::ReactionSystem; expand = true, ### Generate formatted string of substrates substrates = [make_stoich_str(substrate[1], substrate[2], subber; mathrm, - kwargs...) + kwargs...) for substrate in zip(r.substrates, r.substoich)] isempty(substrates) && (substrates = ["\\varnothing"]) @@ -106,7 +106,7 @@ function chemical_arrows(rn::ReactionSystem; expand = true, ### Generate formatted string of products products = [make_stoich_str(product[1], product[2], subber; mathrm = true, - kwargs...) + kwargs...) for product in zip(r.products, r.prodstoich)] isempty(products) && (products = ["\\varnothing"]) str *= join(products, " + ") @@ -134,7 +134,6 @@ function chemical_arrows(rn::ReactionSystem; expand = true, return latexstr end - ### Utility ### function any_nonrx_subsys(rn::MT.AbstractSystem) @@ -194,7 +193,7 @@ function make_stoich_str(spec, stoich, subber; mathrm = true, kwargs...) if isequal(stoich, one(stoich)) prestr * latexraw(subber(spec); kwargs...) * poststr else - if (stoich isa Symbolic) && istree(stoich) + if (stoich isa Symbolic) && iscall(stoich) LaTeXString("(") * latexraw(subber(stoich); kwargs...) * LaTeXString(")") * @@ -204,4 +203,4 @@ function make_stoich_str(spec, stoich, subber; mathrm = true, kwargs...) prestr * latexraw(subber(spec); kwargs...) * poststr end end -end \ No newline at end of file +end diff --git a/src/network_analysis.jl b/src/network_analysis.jl index cc33f9b63e..1b0a18c1bf 100644 --- a/src/network_analysis.jl +++ b/src/network_analysis.jl @@ -106,7 +106,7 @@ function reactioncomplexes(rn::ReactionSystem; sparse = false) end function reactioncomplexes(::Type{SparseMatrixCSC{Int, Int}}, rn::ReactionSystem, - complextorxsmap) + complextorxsmap) complexes = collect(keys(complextorxsmap)) Is = Int[] Js = Int[] @@ -260,25 +260,19 @@ end Construct a directed simple graph where nodes correspond to reaction complexes and directed edges to reactions converting between two complexes. -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -complexes,incidencemat = reactioncomplexes(sir) incidencematgraph(sir) ``` """ function incidencematgraph(rn::ReactionSystem) nps = get_networkproperties(rn) if Graphs.nv(nps.incidencegraph) == 0 - isempty(nps.incidencemat) && - error("Please call reactioncomplexes(rn) first to construct the incidence matrix.") + isempty(nps.incidencemat) && reactioncomplexes(rn) nps.incidencegraph = incidencematgraph(nps.incidencemat) end nps.incidencegraph @@ -320,7 +314,6 @@ function incidencematgraph(incidencemat::SparseMatrixCSC{Int, Int}) return graph end - ### Linkage, Deficiency, Reversibility ### """ @@ -330,17 +323,12 @@ Given the incidence graph of a reaction network, return a vector of the connected components of the graph (i.e. sub-groups of reaction complexes that are connected in the incidence graph). -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -complexes,incidencemat = reactioncomplexes(sir) linkageclasses(sir) ``` gives @@ -360,6 +348,56 @@ end linkageclasses(incidencegraph) = Graphs.connected_components(incidencegraph) +""" + stronglinkageclasses(rn::ReactionSystem) + + Return the strongly connected components of a reaction network's incidence graph (i.e. sub-groups of reaction complexes such that every complex is reachable from every other one in the sub-group). +""" + +function stronglinkageclasses(rn::ReactionSystem) + nps = get_networkproperties(rn) + if isempty(nps.stronglinkageclasses) + nps.stronglinkageclasses = stronglinkageclasses(incidencematgraph(rn)) + end + nps.stronglinkageclasses +end + +stronglinkageclasses(incidencegraph) = Graphs.strongly_connected_components(incidencegraph) + +""" + terminallinkageclasses(rn::ReactionSystem) + + Return the terminal strongly connected components of a reaction network's incidence graph (i.e. sub-groups of reaction complexes that are 1) strongly connected and 2) every outgoing reaction from a complex in the component produces a complex also in the component). +""" + +function terminallinkageclasses(rn::ReactionSystem) + nps = get_networkproperties(rn) + if isempty(nps.terminallinkageclasses) + slcs = stronglinkageclasses(rn) + tslcs = filter(lc -> isterminal(lc, rn), slcs) + nps.terminallinkageclasses = tslcs + end + nps.terminallinkageclasses +end + +# Helper function for terminallinkageclasses. Given a linkage class and a reaction network, say whether the linkage class is terminal, +# i.e. all outgoing reactions from complexes in the linkage class produce a complex also in the linkage class +function isterminal(lc::Vector, rn::ReactionSystem) + imat = incidencemat(rn) + + for r in 1:size(imat, 2) + # Find the index of the reactant complex for a given reaction + s = findfirst(==(-1), @view imat[:, r]) + + # If the reactant complex is in the linkage class, check whether the product complex is also in the linkage class. If any of them are not, return false. + if s in Set(lc) + p = findfirst(==(1), @view imat[:, r]) + p in Set(lc) ? continue : return false + end + end + true +end + @doc raw""" deficiency(rn::ReactionSystem) @@ -372,17 +410,12 @@ Here the deficiency, ``\delta``, of a network with ``n`` reaction complexes, \delta = n - \ell - s ``` -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -rcs,incidencemat = reactioncomplexes(sir) δ = deficiency(sir) ``` """ @@ -399,7 +432,7 @@ end # Used in the subsequent function. function subnetworkmapping(linkageclass, allrxs, complextorxsmap, p) rxinds = sort!(collect(Set(rxidx for rcidx in linkageclass - for rxidx in complextorxsmap[rcidx]))) + for rxidx in complextorxsmap[rcidx]))) rxs = allrxs[rxinds] specset = Set(s for rx in rxs for s in rx.substrates if !isconstant(s)) for rx in rxs @@ -420,17 +453,12 @@ end Find subnetworks corresponding to each linkage class of the reaction network. -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -complexes,incidencemat = reactioncomplexes(sir) subnetworks(sir) ``` """ @@ -447,7 +475,7 @@ function subnetworks(rs::ReactionSystem) reacs, specs, newps = subnetworkmapping(lcs[i], rxs, complextorxsmap, p) newname = Symbol(nameof(rs), "_", i) push!(subnetworks, - ReactionSystem(reacs, t, specs, newps; name = newname, spatial_ivs)) + ReactionSystem(reacs, t, specs, newps; name = newname, spatial_ivs)) end subnetworks end @@ -457,17 +485,12 @@ end Calculates the deficiency of each sub-reaction network within `network`. -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -rcs,incidencemat = reactioncomplexes(sir) linkage_deficiencies = linkagedeficiencies(sir) ``` """ @@ -488,17 +511,12 @@ end Given a reaction network, returns if the network is reversible or not. -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -rcs,incidencemat = reactioncomplexes(sir) isreversible(sir) ``` """ @@ -512,34 +530,30 @@ end Determine if the reaction network with the given subnetworks is weakly reversible or not. -Notes: -- Requires the `incidencemat` to already be cached in `rn` by a previous call to - `reactioncomplexes`. - For example, ```julia sir = @reaction_network SIR begin β, S + I --> 2I ν, I --> R end -rcs,incidencemat = reactioncomplexes(sir) subnets = subnetworks(rn) isweaklyreversible(rn, subnets) ``` """ function isweaklyreversible(rn::ReactionSystem, subnets) - im = get_networkproperties(rn).incidencemat - isempty(im) && - error("Error, please call reactioncomplexes(rn::ReactionSystem) to ensure the incidence matrix has been cached.") - sparseig = issparse(im) + nps = get_networkproperties(rn) + isempty(nps.incidencemat) && reactioncomplexes(rn) + sparseig = issparse(nps.incidencemat) + for subnet in subnets - nps = get_networkproperties(subnet) - isempty(nps.incidencemat) && reactioncomplexes(subnet; sparse = sparseig) + subnps = get_networkproperties(subnet) + isempty(subnps.incidencemat) && reactioncomplexes(subnet; sparse = sparseig) end + + # A network is weakly reversible if all of its subnetworks are strongly connected all(Graphs.is_strongly_connected ∘ incidencematgraph, subnets) end - ### Conservation Laws ### # Implements the `conserved` parameter metadata. @@ -653,7 +667,7 @@ function cache_conservationlaw_eqs!(rn::ReactionSystem, N::AbstractMatrix, col_o depidxs = col_order[(r + 1):end] depspecs = sts[depidxs] constants = MT.unwrap.(MT.scalarize(only( - @parameters $(CONSERVED_CONSTANT_SYMBOL)[1:nullity] [conserved=true]))) + @parameters $(CONSERVED_CONSTANT_SYMBOL)[1:nullity] [conserved = true]))) conservedeqs = Equation[] constantdefs = Equation[] @@ -711,10 +725,180 @@ conservedquantities(state, cons_laws) = cons_laws * state # If u0s are not given while conservation laws are present, throws an error. # Used in HomotopyContinuation and BifurcationKit extensions. # Currently only checks if any u0s are given -# (not whether these are enough for computing conserved quantitites, this will yield a less informative error). +# (not whether these are enough for computing conserved quantities, this will yield a less informative error). function conservationlaw_errorcheck(rs, pre_varmap) vars_with_vals = Set(p[1] for p in pre_varmap) any(s -> s in vars_with_vals, species(rs)) && return isempty(conservedequations(Catalyst.flatten(rs))) || error("The system has conservation laws but initial conditions were not provided for some species.") end + +""" + iscomplexbalanced(rs::ReactionSystem, parametermap) + +Constructively compute whether a network will have complex-balanced equilibrium +solutions, following the method in van der Schaft et al., [2015](https://link.springer.com/article/10.1007/s10910-015-0498-2#Sec3). Accepts a dictionary, vector, or tuple of variable-to-value mappings, e.g. [k1 => 1.0, k2 => 2.0,...]. +""" + +function iscomplexbalanced(rs::ReactionSystem, parametermap::Dict) + if length(parametermap) != numparams(rs) + error("Incorrect number of parameters specified.") + end + + pmap = symmap_to_varmap(rs, parametermap) + pmap = Dict(ModelingToolkit.value(k) => v for (k, v) in pmap) + + sm = speciesmap(rs) + cm = reactioncomplexmap(rs) + complexes, D = reactioncomplexes(rs) + rxns = reactions(rs) + nc = length(complexes) + nr = numreactions(rs) + nm = numspecies(rs) + + if !all(r -> ismassaction(r, rs), rxns) + error("The supplied ReactionSystem has reactions that are not ismassaction. Testing for being complex balanced is currently only supported for pure mass action networks.") + end + + rates = [substitute(rate, pmap) for rate in reactionrates(rs)] + + # Construct kinetic matrix, K + K = zeros(nr, nc) + for c in 1:nc + complex = complexes[c] + for (r, dir) in cm[complex] + rxn = rxns[r] + if dir == -1 + K[r, c] = rates[r] + end + end + end + + L = -D * K + S = netstoichmat(rs) + + # Compute ρ using the matrix-tree theorem + g = incidencematgraph(rs) + R = ratematrix(rs, rates) + ρ = matrixtree(g, R) + + # Determine if 1) ρ is positive and 2) D^T Ln ρ lies in the image of S^T + if all(>(0), ρ) + img = D' * log.(ρ) + if rank(S') == rank(hcat(S', img)) + return true + else + return false + end + else + return false + end +end + +function iscomplexbalanced(rs::ReactionSystem, parametermap::Vector{Pair{Symbol, Float64}}) + pdict = Dict(parametermap) + iscomplexbalanced(rs, pdict) +end + +function iscomplexbalanced(rs::ReactionSystem, parametermap::Tuple{Pair{Symbol, Float64}}) + pdict = Dict(parametermap) + iscomplexbalanced(rs, pdict) +end + +function iscomplexbalanced(rs::ReactionSystem, parametermap) + error("Parameter map must be a dictionary, tuple, or vector of symbol/value pairs.") +end + +""" + ratematrix(rs::ReactionSystem, parametermap) + + Given a reaction system with n complexes, outputs an n-by-n matrix where R_{ij} is the rate constant of the reaction between complex i and complex j. Accepts a dictionary, vector, or tuple of variable-to-value mappings, e.g. [k1 => 1.0, k2 => 2.0,...]. +""" + +function ratematrix(rs::ReactionSystem, rates::Vector{Float64}) + complexes, D = reactioncomplexes(rs) + n = length(complexes) + rxns = reactions(rs) + ratematrix = zeros(n, n) + + for r in 1:length(rxns) + rxn = rxns[r] + s = findfirst(==(-1), @view D[:, r]) + p = findfirst(==(1), @view D[:, r]) + ratematrix[s, p] = rates[r] + end + ratematrix +end + +function ratematrix(rs::ReactionSystem, parametermap::Dict) + if length(parametermap) != numparams(rs) + error("Incorrect number of parameters specified.") + end + + pmap = symmap_to_varmap(rs, parametermap) + pmap = Dict(ModelingToolkit.value(k) => v for (k, v) in pmap) + + rates = [substitute(rate, pmap) for rate in reactionrates(rs)] + ratematrix(rs, rates) +end + +function ratematrix(rs::ReactionSystem, parametermap::Vector{Pair{Symbol, Float64}}) + pdict = Dict(parametermap) + ratematrix(rs, pdict) +end + +function ratematrix(rs::ReactionSystem, parametermap::Tuple{Pair{Symbol, Float64}}) + pdict = Dict(parametermap) + ratematrix(rs, pdict) +end + +function ratematrix(rs::ReactionSystem, parametermap) + error("Parameter map must be a dictionary, tuple, or vector of symbol/value pairs.") +end + +### BELOW: Helper functions for iscomplexbalanced + +function matrixtree(g::SimpleDiGraph, distmx::Matrix) + n = nv(g) + if size(distmx) != (n, n) + error("Size of distance matrix is incorrect") + end + + π = zeros(n) + + if !Graphs.is_connected(g) + ccs = Graphs.connected_components(g) + for cc in ccs + sg, vmap = Graphs.induced_subgraph(g, cc) + distmx_s = distmx[cc, cc] + π_j = matrixtree(sg, distmx_s) + π[cc] = π_j + end + return π + end + + # generate all spanning trees + ug = SimpleGraph(SimpleDiGraph(g)) + trees = collect(Combinatorics.combinations(collect(edges(ug)), n - 1)) + trees = SimpleGraph.(trees) + trees = filter!(t -> isempty(Graphs.cycle_basis(t)), trees) + + # constructed rooted trees for every vertex, compute sum + for v in 1:n + rootedTrees = [reverse(Graphs.bfs_tree(t, v, dir = :in)) for t in trees] + π[v] = sum([treeweight(t, g, distmx) for t in rootedTrees]) + end + + # sum the contributions + return π +end + +function treeweight(t::SimpleDiGraph, g::SimpleDiGraph, distmx::Matrix) + prod = 1 + for e in edges(t) + s = Graphs.src(e) + t = Graphs.dst(e) + prod *= distmx[s, t] + end + prod +end diff --git a/src/reaction.jl b/src/reaction.jl index 95eb7c0e71..165bbeff37 100644 --- a/src/reaction.jl +++ b/src/reaction.jl @@ -62,7 +62,6 @@ Test if a species is valid as a reactant (i.e. a species variable or a constant """ isvalidreactant(s) = MT.isparameter(s) ? isconstant(s) : (isspecies(s) && !isconstant(s)) - ### Reaction Constructor Functions ### # Checks if a metadata input has an entry :only_use_rate => true @@ -161,8 +160,14 @@ end # Five-argument constructor accepting rate, substrates, and products, and their stoichiometries. function Reaction(rate, subs, prods, substoich, prodstoich; - netstoich = nothing, metadata = Pair{Symbol, Any}[], - only_use_rate = metadata_only_use_rate_check(metadata), kwargs...) + netstoich = nothing, metadata = Pair{Symbol, Any}[], + only_use_rate = metadata_only_use_rate_check(metadata), kwargs...) + # Handles empty/nothing vectors. + isnothing(subs) || isempty(subs) && (subs = nothing) + isnothing(prods) || isempty(prods) && (prods = nothing) + isnothing(prodstoich) || isempty(prodstoich) && (prodstoich = nothing) + isnothing(substoich) || isempty(substoich) && (substoich = nothing) + (isnothing(prods) && isnothing(subs)) && throw(ArgumentError("A reaction requires a non-nothing substrate or product vector.")) (isnothing(prodstoich) && isnothing(substoich)) && @@ -222,7 +227,7 @@ function Reaction(rate, subs, prods, substoich, prodstoich; end # Deletes potential `:only_use_rate => ` entries from the metadata. - if any(:only_use_rate == entry[1] for entry in metadata) + if any(:only_use_rate == entry[1] for entry in metadata) deleteat!(metadata, findfirst(:only_use_rate == entry[1] for entry in metadata)) end @@ -255,7 +260,7 @@ function print_rxside(io::IO, specs, stoich) spec : MT.operation(spec) if isequal(stoich[i], one(stoich[i])) print(io, prspec) - elseif istree(stoich[i]) + elseif iscall(stoich[i]) print(io, "(", stoich[i], ")*", prspec) else print(io, stoich[i], "*", prspec) @@ -311,7 +316,6 @@ function hash(rx::Reaction, h::UInt) Base.hash(rx.only_use_rate, h) end - ### ModelingToolkit Function Dispatches ### # Used by ModelingToolkit.namespace_equation. @@ -336,7 +340,8 @@ function MT.namespace_equation(rx::Reaction, name; kw...) ns = similar(rx.netstoich) map!(n -> f(n[1]) => f(n[2]), ns, rx.netstoich) end - Reaction(rate, subs, prods, substoich, prodstoich, netstoich, rx.only_use_rate, rx.metadata) + Reaction(rate, subs, prods, substoich, prodstoich, netstoich, + rx.only_use_rate, rx.metadata) end # Overwrites equation-type functions to give the correct input for `Reaction`s. @@ -368,17 +373,14 @@ encountered in: - Among potential noise scaling metadata. """ function ModelingToolkit.get_variables!(set, rx::Reaction) - if isdefined(Main, :Infiltrator) - Main.infiltrate(@__MODULE__, Base.@locals, @__FILE__, @__LINE__) - end get_variables!(set, rx.rate) foreach(sub -> push!(set, sub), rx.substrates) foreach(prod -> push!(set, prod), rx.products) for stoichs in (rx.substoich, rx.prodstoich), stoich in stoichs (stoich isa BasicSymbolic) && get_variables!(set, stoich) end - if has_noise_scaling(rx) - get_variables!(set, get_noise_scaling(rx)) + if hasnoisescaling(rx) + get_variables!(set, getnoisescaling(rx)) end return (set isa AbstractVector) ? unique!(set) : set end @@ -410,7 +412,6 @@ function MT.modified_unknowns!(munknowns, rx::Reaction, sts::AbstractVector) munknowns end - ### `Reaction`-specific Functions ### """ @@ -438,17 +439,16 @@ function isbcbalanced(rx::Reaction) true end - ### Reaction Metadata Implementation ### -# These are currently considered internal, but can be used by public accessor functions like get_noise_scaling. +# These are currently considered internal, but can be used by public accessor functions like getnoisescaling. """ getmetadata_dict(reaction::Reaction) -Retrives the `ImmutableDict` containing all of the metadata associated with a specific reaction. +Retrieves the `ImmutableDict` containing all of the metadata associated with a specific reaction. Arguments: -- `reaction`: The reaction for which we wish to retrive all metadata. +- `reaction`: The reaction for which we wish to retrieve all metadata. Example: ```julia @@ -482,11 +482,11 @@ end """ getmetadata(reaction::Reaction, md_key::Symbol) -Retrives a certain metadata value from a `Reaction`. If the metadata does not exists, throws an error. +Retrieves a certain metadata value from a `Reaction`. If the metadata does not exist, throws an error. Arguments: -- `reaction`: The reaction for which we wish to retrive a specific metadata value. -- `md_key`: The metadata for which we wish to retrive. +- `reaction`: The reaction for which we wish to retrieve a specific metadata value. +- `md_key`: The metadata for which we wish to retrieve. Example: ```julia @@ -495,19 +495,19 @@ getmetadata(reaction, :description) ``` """ function getmetadata(reaction::Reaction, md_key::Symbol) - if !hasmetadata(reaction, md_key) + if !hasmetadata(reaction, md_key) error("The reaction does not have a metadata field $md_key. It does have the following metadata fields: $(keys(getmetadata_dict(reaction))).") end metadata = getmetadata_dict(reaction) - return metadata[findfirst(isequal(md_key, entry[1]) for entry in getmetadata_dict(reaction))][2] + return metadata[findfirst(isequal(md_key, entry[1]) + for entry in getmetadata_dict(reaction))][2] end - ### Implemented Reaction Metadata ### # Noise scaling. """ -has_noise_scaling(reaction::Reaction) +hasnoisescaling(reaction::Reaction) Returns `true` if the input reaction has the `noise_scaing` metadata field assigned, else `false`. @@ -517,15 +517,15 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [noise_scaling=0.0] -has_noise_scaling(reaction) +hasnoisescaling(reaction) ``` """ -function has_noise_scaling(reaction::Reaction) +function hasnoisescaling(reaction::Reaction) return hasmetadata(reaction, :noise_scaling) end """ -get_noise_scaling(reaction::Reaction) +getnoisescaling(reaction::Reaction) Returns `noise_scaing` metadata field for the input reaction. @@ -535,11 +535,11 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [noise_scaling=0.0] -get_noise_scaling(reaction) +getnoisescaling(reaction) ``` """ -function get_noise_scaling(reaction::Reaction) - if has_noise_scaling(reaction) +function getnoisescaling(reaction::Reaction) + if hasnoisescaling(reaction) return getmetadata(reaction, :noise_scaling) else error("Attempts to access noise_scaling metadata field for a reaction which does not have a value assigned for this metadata.") @@ -548,7 +548,7 @@ end # Description. """ -has_description(reaction::Reaction) +hasdescription(reaction::Reaction) Returns `true` if the input reaction has the `description` metadata field assigned, else `false`. @@ -558,15 +558,15 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [description="A reaction"] -has_description(reaction) +hasdescription(reaction) ``` """ -function has_description(reaction::Reaction) +function hasdescription(reaction::Reaction) return hasmetadata(reaction, :description) end """ -get_description(reaction::Reaction) +getdescription(reaction::Reaction) Returns `description` metadata field for the input reaction. @@ -576,11 +576,11 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [description="A reaction"] -get_description(reaction) +getdescription(reaction) ``` """ -function get_description(reaction::Reaction) - if has_description(reaction) +function getdescription(reaction::Reaction) + if hasdescription(reaction) return getmetadata(reaction, :description) else error("Attempts to access `description` metadata field for a reaction which does not have a value assigned for this metadata.") @@ -589,7 +589,7 @@ end # Misc. """ -has_misc(reaction::Reaction) +hasmisc(reaction::Reaction) Returns `true` if the input reaction has the `misc` metadata field assigned, else `false`. @@ -599,15 +599,15 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [misc="A reaction"] -misc(reaction) +hasmisc(reaction) ``` """ -function has_misc(reaction::Reaction) +function hasmisc(reaction::Reaction) return hasmetadata(reaction, :misc) end """ -get_misc(reaction::Reaction) +getmisc(reaction::Reaction) Returns `misc` metadata field for the input reaction. @@ -617,26 +617,25 @@ Arguments: Example: ```julia reaction = @reaction k, 0 --> X, [misc="A reaction"] -get_misc(reaction) +getmisc(reaction) ``` Notes: - The `misc` field can contain any valid Julia structure. This mean that Catalyst cannot check it -for symbolci variables that are added here. This means that symbolic variables (e.g. parameters of +for symbolic variables that are added here. This means that symbolic variables (e.g. parameters of species) that are stored here are not accessible to Catalyst. This can cause troubles when e.g. creating a `ReactionSystem` programmatically (in which case any symbolic variables stored in the `misc` metadata field should also be explicitly provided to the `ReactionSystem` constructor). """ -function get_misc(reaction::Reaction) - if has_misc(reaction) +function getmisc(reaction::Reaction) + if hasmisc(reaction) return getmetadata(reaction, :misc) else error("Attempts to access `misc` metadata field for a reaction which does not have a value assigned for this metadata.") end end - ### Units Handling ### """ @@ -670,8 +669,8 @@ function validate(rx::Reaction; info::String = "") if (subunits !== nothing) && (produnits !== nothing) && (subunits != produnits) validated = false @warn(string("in ", rx, - " the substrate units are not consistent with the product units.")) + " the substrate units are not consistent with the product units.")) end validated -end \ No newline at end of file +end diff --git a/src/reactionsystem.jl b/src/reactionsystem.jl index 5c04b2e642..7bd09f555b 100644 --- a/src/reactionsystem.jl +++ b/src/reactionsystem.jl @@ -28,14 +28,14 @@ struct ReactionComplex{V <: Integer} <: AbstractVector{ReactionComplexElement{V} speciesstoichs::Vector{V} function ReactionComplex{V}(speciesids::Vector{Int}, - speciesstoichs::Vector{V}) where {V <: Integer} + speciesstoichs::Vector{V}) where {V <: Integer} new{V}(speciesids, speciesstoichs) end end # Special constructor. function ReactionComplex(speciesids::Vector{Int}, - speciesstoichs::Vector{V}) where {V <: Integer} + speciesstoichs::Vector{V}) where {V <: Integer} (length(speciesids) == length(speciesstoichs)) || error("Creating a complex with different number of species ids and associated stoichiometries.") ReactionComplex{V}(speciesids, speciesstoichs) @@ -59,10 +59,9 @@ function Base.getindex(rc::ReactionComplex, i...) end function Base.setindex!(rc::ReactionComplex, t::ReactionComplexElement, i...) - (setindex!(rc.speciesids, t.speciesid, i...); - setindex!(rc.speciesstoichs, - t.speciesstoich, i...); - rc) + setindex!(rc.speciesids, t.speciesid, i...) + setindex!(rc.speciesstoichs, t.speciesstoich, i...) + rc end function Base.isless(a::ReactionComplexElement, b::ReactionComplexElement) @@ -71,7 +70,6 @@ end Base.Sort.defalg(::ReactionComplex) = Base.DEFAULT_UNSTABLE - ### NetworkProperties Structure ### #! format: off @@ -80,6 +78,7 @@ Base.@kwdef mutable struct NetworkProperties{I <: Integer, V <: BasicSymbolic{Re isempty::Bool = true netstoichmat::Union{Matrix{Int}, SparseMatrixCSC{Int, Int}} = Matrix{Int}(undef, 0, 0) conservationmat::Matrix{I} = Matrix{I}(undef, 0, 0) + cyclemat::Matrix{I} = Matrix{I}(undef, 0, 0) col_order::Vector{Int} = Int[] rank::Int = 0 nullity::Int = 0 @@ -95,6 +94,8 @@ Base.@kwdef mutable struct NetworkProperties{I <: Integer, V <: BasicSymbolic{Re complexoutgoingmat::Union{Matrix{Int}, SparseMatrixCSC{Int, Int}} = Matrix{Int}(undef, 0, 0) incidencegraph::Graphs.SimpleDiGraph{Int} = Graphs.DiGraph() linkageclasses::Vector{Vector{Int}} = Vector{Vector{Int}}(undef, 0) + stronglinkageclasses::Vector{Vector{Int}} = Vector{Vector{Int}}(undef, 0) + terminallinkageclasses::Vector{Vector{Int}} = Vector{Vector{Int}}(undef, 0) deficiency::Int = 0 end #! format: on @@ -118,6 +119,7 @@ function reset!(nps::NetworkProperties{I, V}) where {I, V} nps.isempty && return nps.netstoichmat = Matrix{Int}(undef, 0, 0) nps.conservationmat = Matrix{I}(undef, 0, 0) + nps.cyclemat = Matrix{Int}(undef, 0, 0) empty!(nps.col_order) nps.rank = 0 nps.nullity = 0 @@ -133,6 +135,8 @@ function reset!(nps::NetworkProperties{I, V}) where {I, V} nps.complexoutgoingmat = Matrix{Int}(undef, 0, 0) nps.incidencegraph = Graphs.DiGraph() empty!(nps.linkageclasses) + empty!(nps.stronglinkageclasses) + empty!(nps.terminallinkageclasses) nps.deficiency = 0 # this needs to be last due to setproperty! setting it to false @@ -140,7 +144,6 @@ function reset!(nps::NetworkProperties{I, V}) where {I, V} nothing end - ### ReactionSystem Constructor Functions ### # Used to sort the reaction/equation vector as reactions first, equations second. @@ -198,7 +201,7 @@ end function find_event_vars!(ps, us, events::Vector, ivs, vars) foreach(event -> find_event_vars!(ps, us, event, ivs, vars), events) end -# For a single event, adds quantitites from its condition and affect expression(s) to `ps` and `us`. +# For a single event, adds quantities from its condition and affect expression(s) to `ps` and `us`. # Applies `findvars!` to the event's condition (`event[1])` and affec (`event[2]`). function find_event_vars!(ps, us, event, ivs, vars) findvars!(ps, us, event[1], ivs, vars) @@ -207,6 +210,22 @@ end ### ReactionSystem Structure ### +""" +WARNING!!! + +The following variable is used to check that code that should be updated when the `ReactionSystem` +fields are updated has in fact been updated. Do not just blindly update this without first checking +all such code and updating it appropriately (e.g. serialization). Please use a search for +`reactionsystem_fields` throughout the package to ensure all places which should be updated, are updated. +""" +# Constant storing all reaction system fields (in order). Used to check whether the `ReactionSystem` +# structure have been updated (in the `reactionsystem_uptodate_check` function). +const reactionsystem_fields = ( + :eqs, :rxs, :iv, :sivs, :unknowns, :species, :ps, :var_to_name, + :observed, :name, :systems, :defaults, :connection_type, + :networkproperties, :combinatoric_ratelaws, :continuous_events, + :discrete_events, :metadata, :complete) + """ $(TYPEDEF) @@ -304,12 +323,13 @@ struct ReactionSystem{V <: NetworkProperties} <: # inner constructor is considered private and may change between non-breaking releases. function ReactionSystem(eqs, rxs, iv, sivs, unknowns, spcs, ps, var_to_name, observed, - name, systems, defaults, connection_type, nps, cls, cevs, devs, - metadata = nothing, complete = false; checks::Bool = true) - + name, systems, defaults, connection_type, nps, cls, cevs, devs, + metadata = nothing, complete = false; checks::Bool = true) + # Checks that all parameters have the appropriate Symbolics type. for p in ps - (p isa Symbolics.BasicSymbolic) || error("Parameter $p is not a `BasicSymbolic`. This is required.") + (p isa Symbolics.BasicSymbolic) || + error("Parameter $p is not a `BasicSymbolic`. This is required.") end # unit checks are for ODEs and Reactions only currently @@ -330,9 +350,10 @@ struct ReactionSystem{V <: NetworkProperties} <: end end - rs = new{typeof(nps)}(eqs, rxs, iv, sivs, unknowns, spcs, ps, var_to_name, observed, - name, systems, defaults, connection_type, nps, cls, cevs, - devs, metadata, complete) + rs = new{typeof(nps)}( + eqs, rxs, iv, sivs, unknowns, spcs, ps, var_to_name, observed, + name, systems, defaults, connection_type, nps, cls, cevs, + devs, metadata, complete) checks && validate(rs) rs end @@ -341,31 +362,34 @@ end # Four-argument constructor. Permits additional inputs as optional arguments. # Calls the full constructor. function ReactionSystem(eqs, iv, unknowns, ps; - observed = Equation[], - systems = [], - name = nothing, - default_u0 = Dict(), - default_p = Dict(), - defaults = _merge(Dict(default_u0), Dict(default_p)), - connection_type = nothing, - checks = true, - networkproperties = nothing, - combinatoric_ratelaws = true, - balanced_bc_check = true, - spatial_ivs = nothing, - continuous_events = nothing, - discrete_events = nothing, - metadata = nothing) - + observed = Equation[], + systems = [], + name = nothing, + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + connection_type = nothing, + checks = true, + networkproperties = nothing, + combinatoric_ratelaws = true, + balanced_bc_check = true, + spatial_ivs = nothing, + continuous_events = nothing, + discrete_events = nothing, + metadata = nothing) + # Error checks name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) sysnames = nameof.(systems) - (length(unique(sysnames)) == length(sysnames)) || throw(ArgumentError("System names must be unique.")) + (length(unique(sysnames)) == length(sysnames)) || + throw(ArgumentError("System names must be unique.")) # Handle defaults values provided via optional arguments. if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :ReactionSystem, force = true) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :ReactionSystem, force = true) end defaults = MT.todict(defaults) defaults = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(defaults)) @@ -378,7 +402,7 @@ function ReactionSystem(eqs, iv, unknowns, ps; else value.(MT.scalarize(spatial_ivs)) end - unknowns′ = sort!(value.(MT.scalarize(unknowns)), by = !isspecies) + unknowns′ = sort!(value.(MT.scalarize(unknowns)), by = !isspecies) spcs = filter(isspecies, unknowns′) ps′ = value.(MT.scalarize(ps)) @@ -388,7 +412,7 @@ function ReactionSystem(eqs, iv, unknowns, ps; error("Catalyst reserves the symbols $forbidden_symbols_error for internal use. Please do not use these symbols as parameters or unknowns/species.") end - # Handles reactions and equations. Sorts so that reactions are before equaions in the equations vector. + # Handles reactions and equations. Sorts so that reactions are before equations in the equations vector. eqs′ = CatalystEqType[eq for eq in eqs] sort!(eqs′; by = eqsortby) rxs = Reaction[rx for rx in eqs if rx isa Reaction] @@ -425,13 +449,14 @@ function ReactionSystem(eqs, iv, unknowns, ps; networkproperties end - # Creates the continious and discrete callbacks. + # Creates the continuous and discrete callbacks. ccallbacks = MT.SymbolicContinuousCallbacks(continuous_events) dcallbacks = MT.SymbolicDiscreteCallbacks(discrete_events) - ReactionSystem(eqs′, rxs, iv′, sivs′, unknowns′, spcs, ps′, var_to_name, observed, name, - systems, defaults, connection_type, nps, combinatoric_ratelaws, - ccallbacks, dcallbacks, metadata; checks = checks) + ReactionSystem( + eqs′, rxs, iv′, sivs′, unknowns′, spcs, ps′, var_to_name, observed, name, + systems, defaults, connection_type, nps, combinatoric_ratelaws, + ccallbacks, dcallbacks, metadata; checks = checks) end # Two-argument constructor (reactions/equations and time variable). @@ -445,21 +470,22 @@ function ReactionSystem(iv; kwargs...) ReactionSystem(Reaction[], iv, [], []; kwargs...) end -# Called internally (whether DSL-based or programmtic model creation is used). +# Called internally (whether DSL-based or programmatic model creation is used). # Creates a sorted reactions + equations vector, also ensuring reaction is first in this vector. # Extracts potential species, variables, and parameters from the input (if not provided as part of # the model creation) and creates the corresponding vectors. # While species are ordered before variables in the unknowns vector, this ordering is not imposed here, # but carried out at a later stage. -function make_ReactionSystem_internal(rxs_and_eqs::Vector, iv, us_in, ps_in; spatial_ivs = nothing, - continuous_events = [], discrete_events = [], observed = [], kwargs...) +function make_ReactionSystem_internal(rxs_and_eqs::Vector, iv, us_in, ps_in; + spatial_ivs = nothing, continuous_events = [], discrete_events = [], + observed = [], kwargs...) - # Filters away any potential obervables from `states` and `spcs`. + # Filters away any potential observables from `states` and `spcs`. obs_vars = [obs_eq.lhs for obs_eq in observed] us_in = filter(u -> !any(isequal(u, obs_var) for obs_var in obs_vars), us_in) - + # Creates a combined iv vector (iv and sivs). This is used later in the function (so that - # independent variables can be exluded when encountered quantities are added to `us` and `ps`). + # independent variables can be excluded when encountered quantities are added to `us` and `ps`). t = value(iv) ivs = Set([t]) if (spatial_ivs !== nothing) @@ -481,23 +507,23 @@ function make_ReactionSystem_internal(rxs_and_eqs::Vector, iv, us_in, ps_in; spa # Loops through all reactions, adding encountered quantities to the unknown and parameter vectors. # Starts by looping through substrates + products only (so these are added to the vector first). - # Next, the otehr components of reactions (e.g. rates and stoichiometries) are added. + # Next, the other components of reactions (e.g. rates and stoichiometries) are added. for rx in rxs for reactants in (rx.substrates, rx.products), spec in reactants MT.isparameter(spec) ? push!(ps, spec) : push!(us, spec) end end for rx in rxs - # Adds all quantitites encountered in the reaction's rate. + # Adds all quantities encountered in the reaction's rate. findvars!(ps, us, rx.rate, ivs, vars) - # Extracts all quantitites encountered within stoichiometries. + # Extracts all quantities encountered within stoichiometries. for stoichiometry in (rx.substoich, rx.prodstoich), sym in stoichiometry (sym isa Symbolic) && findvars!(ps, us, sym, ivs, vars) end # Extract all quantities encountered in relevant `Reaction` metadata. - has_noise_scaling(rx) && findvars!(ps, us, get_noise_scaling(rx), ivs, vars) + hasnoisescaling(rx) && findvars!(ps, us, getnoisescaling(rx), ivs, vars) end # Extracts any species, variables, and parameters that occur in (non-reaction) equations. @@ -509,21 +535,21 @@ function make_ReactionSystem_internal(rxs_and_eqs::Vector, iv, us_in, ps_in; spa union!(ps, parameters(osys)) else fulleqs = rxs - end + end - # Loops through all events, adding encountered quantities to the unknwon and parameter vectors. - find_event_vars!(ps, us, continuous_events, ivs, vars) - find_event_vars!(ps, us, discrete_events, ivs, vars) + # Loops through all events, adding encountered quantities to the unknown and parameter vectors. + find_event_vars!(ps, us, continuous_events, ivs, vars) + find_event_vars!(ps, us, discrete_events, ivs, vars) # Converts the found unknowns and parameters to vectors. usv = collect(us) psv = collect(ps) # Passes the processed input into the next `ReactionSystem` call. - ReactionSystem(fulleqs, t, usv, psv; spatial_ivs, continuous_events, discrete_events, observed, kwargs...) + ReactionSystem(fulleqs, t, usv, psv; spatial_ivs, continuous_events, + discrete_events, observed, kwargs...) end - ### Base Function Dispatches ### """ @@ -574,7 +600,6 @@ function isequivalent(rn1::ReactionSystem, rn2::ReactionSystem; ignorenames = tr true end - ### Basic `ReactionSystem`-specific Accessors ### """ @@ -614,7 +639,7 @@ get_networkproperties(sys::ReactionSystem) = getfield(sys, :networkproperties) Returns true if the default for the system is to rescale ratelaws, see https://docs.sciml.ai/Catalyst/stable/introduction_to_catalyst/introduction_to_catalyst/#Reaction-rate-laws-used-in-simulations -for details. Can be overriden via passing `combinatoric_ratelaws` to `convert` or the +for details. Can be overridden via passing `combinatoric_ratelaws` to `convert` or the `*Problem` functions. """ get_combinatoric_ratelaws(sys::ReactionSystem) = getfield(sys, :combinatoric_ratelaws) @@ -623,7 +648,7 @@ get_combinatoric_ratelaws(sys::ReactionSystem) = getfield(sys, :combinatoric_rat combinatoric_ratelaws(sys::ReactionSystem) Returns the effective (default) `combinatoric_ratelaw` value for a compositional system, -calculated by taking the logical or of each component `ReactionSystem`. Can be overriden +calculated by taking the logical or of each component `ReactionSystem`. Can be overridden during calls to `convert` of problem constructors. """ function combinatoric_ratelaws(sys::ReactionSystem) @@ -769,7 +794,6 @@ function reactions(network) [rxs; reduce(vcat, namespace_reactions.(systems); init = Reaction[])] end - """ numreactions(network) @@ -787,7 +811,7 @@ end """ nonreactions(network) -Return the non-reaction equations within the network (i.e. algebraic and differnetial equations). +Return the non-reaction equations within the network (i.e. algebraic and differential equations). Notes: - Allocates a new array to store the non-species variables. @@ -813,10 +837,9 @@ Returns whether `rn` has any spatial independent variables (i.e. is a spatial ne """ isspatial(rn::ReactionSystem) = !isempty(get_sivs(rn)) - ### ModelingToolkit Function Dispatches ### -# Retrives events. +# Retrieves events. MT.get_continuous_events(sys::ReactionSystem) = getfield(sys, :continuous_events) # `MT.get_discrete_events(sys::ReactionSystem) = getfield(sys, :get_discrete_events)` should be added here. @@ -828,7 +851,7 @@ function MT.equations(sys::ReactionSystem) if !isempty(systems) eqs = CatalystEqType[eqs; reduce(vcat, MT.namespace_equations.(systems, (ivs,)); - init = Any[])] + init = Any[])] return sort!(eqs; by = eqsortby) end return eqs @@ -845,7 +868,6 @@ function MT.unknowns(sys::ReactionSystem) return sts end - ### Network Matrix Representations ### """ @@ -860,7 +882,7 @@ Note: the associated rate law. """ function substoichmat(::Type{SparseMatrixCSC{T, Int}}, - rn::ReactionSystem) where {T <: Number} + rn::ReactionSystem) where {T <: Number} Is = Int[] Js = Int[] Vs = T[] @@ -910,7 +932,7 @@ Note: the associated rate law. """ function prodstoichmat(::Type{SparseMatrixCSC{T, Int}}, - rn::ReactionSystem) where {T <: Number} + rn::ReactionSystem) where {T <: Number} Is = Int[] Js = Int[] Vs = T[] @@ -966,7 +988,7 @@ Notes: the associated rate law. As such they do not contribute to the net stoichiometry matrix. """ function netstoichmat(::Type{SparseMatrixCSC{T, Int}}, - rn::ReactionSystem) where {T <: Number} + rn::ReactionSystem) where {T <: Number} Is = Int[] Js = Int[] Vs = Vector{T}() @@ -1025,6 +1047,16 @@ end ### General `ReactionSystem`-specific Functions ### +# Checks if the `ReactionSystem` structure have been updated without also updating the +# `reactionsystem_fields` constant. If this is the case, returns `false`. This is used in +# certain functionalities which would break if the `ReactionSystem` structure is updated without +# also updating these functionalities. +function reactionsystem_uptodate_check() + if fieldnames(ReactionSystem) != reactionsystem_fields + @warn "The `ReactionSystem` structure have been modified without this being taken into account in the functionality you are attempting to use. Please report this at https://github.com/SciML/Catalyst.jl/issues. Proceed with caution, as there might be errors in whichever functionality you are attempting to use." + end +end + # used in the `__unpacksys` function. function __unpacksys(rn) ex = :(begin end) @@ -1191,7 +1223,6 @@ function isautonomous(rs::ReactionSystem) return true end - ### `ReactionSystem` Remaking ### """ @@ -1205,19 +1236,21 @@ default reaction metadata is currently the only supported feature. Arguments: - `rs::ReactionSystem`: The `ReactionSystem` which you wish to remake. - `default_reaction_metadata::Vector{Pair{Symbol, T}}`: A vector with default `Reaction` metadata values. - Each metadata in each `Reaction` of the updated `ReactionSystem` will have the value desiganted in + Each metadata in each `Reaction` of the updated `ReactionSystem` will have the value designated in `default_reaction_metadata` (however, `Reaction`s that already have that metadata designated will not have their value updated). """ -function remake_ReactionSystem_internal(rs::ReactionSystem; default_reaction_metadata = []) - rs = set_default_metadata(rs; default_reaction_metadata) +function remake_ReactionSystem_internal(rs::ReactionSystem; default_reaction_metadata = []) + rs = set_default_metadata(rs; default_reaction_metadata) return rs end # For a `ReactionSystem`, updates all `Reaction`'s default metadata. -function set_default_metadata(rs::ReactionSystem; default_reaction_metadata = []) +function set_default_metadata(rs::ReactionSystem; default_reaction_metadata = []) # Updates reaction metadata for for reactions in this specific system. - eqtransform(eq) = eq isa Reaction ? set_default_metadata(eq, default_reaction_metadata) : eq + function eqtransform(eq) + eq isa Reaction ? set_default_metadata(eq, default_reaction_metadata) : eq + end updated_equations = map(eqtransform, get_eqs(rs)) @set! rs.eqs = updated_equations @set! rs.rxs = Reaction[rx for rx in updated_equations if rx isa Reaction] @@ -1227,17 +1260,18 @@ function set_default_metadata(rs::ReactionSystem; default_reaction_metadata = [ drm_dict = Dict(default_reaction_metadata) if haskey(drm_dict, :noise_scaling) # Finds parameters, species, and variables in the noise scaling term. - ns_expr = drm_dict[:noise_scaling] + ns_expr = drm_dict[:noise_scaling] ns_syms = [Symbolics.unwrap(sym) for sym in get_variables(ns_expr)] ns_ps = Iterators.filter(ModelingToolkit.isparameter, ns_syms) ns_sps = Iterators.filter(Catalyst.isspecies, ns_syms) - ns_vs = Iterators.filter(sym -> !Catalyst.isspecies(sym) && - !ModelingToolkit.isparameter(sym), ns_syms) + ns_vs = Iterators.filter( + sym -> !Catalyst.isspecies(sym) && + !ModelingToolkit.isparameter(sym), ns_syms) # Adds parameters, species, and variables to the `ReactionSystem`. @set! rs.ps = union(get_ps(rs), ns_ps) sps_new = union(get_species(rs), ns_sps) @set! rs.species = sps_new - vs_old = @view get_unknowns(rs)[length(get_species(rs))+1 : end] + vs_old = @view get_unknowns(rs)[(length(get_species(rs)) + 1):end] @set! rs.unknowns = union(sps_new, vs_old, ns_vs) end @@ -1254,7 +1288,8 @@ end # For a `Reaction`, adds missing default metadata values. Equations are passed back unmodified. function set_default_metadata(rx::Reaction, default_metadata) - missing_metadata = filter(md -> !in(md[1], entry[1] for entry in rx.metadata), default_metadata) + missing_metadata = filter( + md -> !in(md[1], entry[1] for entry in rx.metadata), default_metadata) updated_metadata = vcat(rx.metadata, missing_metadata) updated_metadata = convert(Vector{Pair{Symbol, Any}}, updated_metadata) return @set rx.metadata = updated_metadata @@ -1273,10 +1308,10 @@ Arguments: - `noise_scaling`: The updated noise scaling terms """ function set_default_noise_scaling(rs::ReactionSystem, noise_scaling) - return remake_ReactionSystem_internal(rs, default_reaction_metadata = [:noise_scaling => noise_scaling]) + return remake_ReactionSystem_internal( + rs, default_reaction_metadata = [:noise_scaling => noise_scaling]) end - ### ReactionSystem Composing & Hierarchical Modelling ### """ @@ -1330,15 +1365,15 @@ function MT.flatten(rs::ReactionSystem; name = nameof(rs)) error("flattening is currently only supported for subsystems mixing ReactionSystems, NonlinearSystems and ODESystems.") ReactionSystem(equations(rs), get_iv(rs), unknowns(rs), parameters(rs); - observed = MT.observed(rs), - name, - defaults = MT.defaults(rs), - checks = false, - combinatoric_ratelaws = combinatoric_ratelaws(rs), - balanced_bc_check = false, - spatial_ivs = get_sivs(rs), - continuous_events = MT.continuous_events(rs), - discrete_events = MT.discrete_events(rs)) + observed = MT.observed(rs), + name, + defaults = MT.defaults(rs), + checks = false, + combinatoric_ratelaws = combinatoric_ratelaws(rs), + balanced_bc_check = false, + spatial_ivs = get_sivs(rs), + continuous_events = MT.continuous_events(rs), + discrete_events = MT.discrete_events(rs)) end """ @@ -1353,7 +1388,7 @@ Notes: - By default, the new `ReactionSystem` will have the same name as `sys`. """ function ModelingToolkit.extend(sys::MT.AbstractSystem, rs::ReactionSystem; - name::Symbol = nameof(sys)) + name::Symbol = nameof(sys)) any(T -> sys isa T, (ReactionSystem, ODESystem, NonlinearSystem)) || error("ReactionSystems can only be extended with ReactionSystems, ODESystems and NonlinearSystems currently. Received a $(typeof(sys)) system.") @@ -1386,16 +1421,16 @@ function ModelingToolkit.extend(sys::MT.AbstractSystem, rs::ReactionSystem; end ReactionSystem(eqs, t, sts, ps; - observed = obs, - systems = syss, - name, - defaults = defs, - checks = false, - combinatoric_ratelaws, - balanced_bc_check = false, - spatial_ivs = sivs, - continuous_events, - discrete_events) + observed = obs, + systems = syss, + name, + defaults = defs, + checks = false, + combinatoric_ratelaws, + balanced_bc_check = false, + spatial_ivs = sivs, + continuous_events, + discrete_events) end ### Units Handling ### @@ -1422,7 +1457,7 @@ function validate(rs::ReactionSystem, info::String = "") if get_unit(spec) != specunits validated = false @warn(string("Species are expected to have units of ", specunits, - " however, species ", spec, " has units ", get_unit(spec), ".")) + " however, species ", spec, " has units ", get_unit(spec), ".")) end end timeunits = get_unit(get_iv(rs)) @@ -1443,17 +1478,19 @@ function validate(rs::ReactionSystem, info::String = "") # Needs additional checks because for cases: (1.0^n) and (1.0^n1)*(1.0^n2). # These are not considered (be default) considered equal to `1.0` for unitless reactions. isequal(rxunits, rateunits) && continue - if istree(rxunits) + if iscall(rxunits) unitless_exp(rxunits) && continue - (operation(rxunits) == *) && all(unitless_exp(arg) for arg in arguments(rxunits)) && continue + (operation(rxunits) == *) && + all(unitless_exp(arg) for arg in arguments(rxunits)) && continue end validated = false - @warn(string("Reaction rate laws are expected to have units of ", rateunits, " however, ", - rx, " has units of ", rxunits, ".")) + @warn(string( + "Reaction rate laws are expected to have units of ", rateunits, " however, ", + rx, " has units of ", rxunits, ".")) end validated end # Checks if a unit consist of exponents with base 1 (and is this unitless). -unitless_exp(u) = istree(u) && (operation(u) == ^) && (arguments(u)[1] == 1) \ No newline at end of file +unitless_exp(u) = iscall(u) && (operation(u) == ^) && (arguments(u)[1] == 1) diff --git a/src/reactionsystem_conversions.jl b/src/reactionsystem_conversions.jl index 38a634ff77..c46854454f 100644 --- a/src/reactionsystem_conversions.jl +++ b/src/reactionsystem_conversions.jl @@ -90,7 +90,7 @@ function assemble_oderhs(rs, ispcs; combinatoric_ratelaws = true, remove_conserv end function assemble_drift(rs, ispcs; combinatoric_ratelaws = true, as_odes = true, - include_zero_odes = true, remove_conserved = false) + include_zero_odes = true, remove_conserved = false) rhsvec = assemble_oderhs(rs, ispcs; combinatoric_ratelaws, remove_conserved) if as_odes D = Differential(get_iv(rs)) @@ -104,12 +104,12 @@ end # this doesn't work with constraint equations currently function assemble_diffusion(rs, sts, ispcs; combinatoric_ratelaws = true, - remove_conserved = false) + remove_conserved = false) # as BC species should ultimately get an equation, we include them in the noise matrix num_bcsts = count(isbc, get_unknowns(rs)) # we make a matrix sized by the number of reactions - eqs = Matrix{Any}(undef, length(sts) + num_bcsts, length(get_rxs(rs))) + eqs = Matrix{Num}(undef, length(sts) + num_bcsts, length(get_rxs(rs))) eqs .= 0 species_to_idx = Dict((x => i for (i, x) in enumerate(ispcs))) nps = get_networkproperties(rs) @@ -121,7 +121,7 @@ function assemble_diffusion(rs, sts, ispcs; combinatoric_ratelaws = true, for (j, rx) in enumerate(get_rxs(rs)) rlsqrt = sqrt(abs(oderatelaw(rx; combinatoric_ratelaw = combinatoric_ratelaws))) - has_noise_scaling(rx) && (rlsqrt *= get_noise_scaling(rx)) + hasnoisescaling(rx) && (rlsqrt *= getnoisescaling(rx)) remove_conserved && (rlsqrt = substitute(rlsqrt, depspec_submap)) for (spec, stoich) in rx.netstoich @@ -227,8 +227,8 @@ Notes: coefficients. """ function ismassaction(rx, rs; rxvars = get_variables(rx.rate), - haveivdep::Union{Nothing, Bool} = nothing, - unknownset = Set(get_unknowns(rs)), ivset = nothing) + haveivdep::Union{Nothing, Bool} = nothing, + unknownset = Set(get_unknowns(rs)), ivset = nothing) # we define non-integer (i.e. float or symbolic) stoich to be non-mass action ((eltype(rx.substoich) <: Integer) && (eltype(rx.prodstoich) <: Integer)) || @@ -289,7 +289,7 @@ end error("$rx has no net stoichiometry change once accounting for constant and boundary condition species. This is not supported.") MassActionJump(Num(rate), reactant_stoch, net_stoch, scale_rates = false, - useiszero = false) + useiszero = false) end # recursively visit each neighbor's rooted tree and mark everything in it as vrj @@ -358,8 +358,7 @@ function assemble_jumps(rs; combinatoric_ratelaws = true) (rx.rate isa Symbolic) && get_variables!(rxvars, rx.rate) isvrj = isvrjvec[i] - if (!isvrj) && ismassaction(rx, rs; rxvars = rxvars, haveivdep = false, - unknownset = unknownset) + if (!isvrj) && ismassaction(rx, rs; rxvars, haveivdep = false, unknownset) push!(meqs, makemajump(rx; combinatoric_ratelaw = combinatoric_ratelaws)) else rl = jumpratelaw(rx; combinatoric_ratelaw = combinatoric_ratelaws) @@ -378,7 +377,6 @@ function assemble_jumps(rs; combinatoric_ratelaws = true) vcat(meqs, ceqs, veqs) end - ### Equation Coupling ### # merge constraint components with the ReactionSystem components @@ -429,30 +427,44 @@ end function error_if_constraints(::Type{T}, sys::ReactionSystem) where {T <: MT.AbstractSystem} any(eq -> eq isa Equation, get_eqs(sys)) && error("Can not convert to a system of type ", T, - " when there are constraint equations.") + " when there are constraint equations.") nothing end - ### Utility ### -# Throws an error when attempting to convert a spatial system to an unssuported type. +# Throws an error when attempting to convert a spatial system to an unsupported type. function spatial_convert_err(rs::ReactionSystem, systype) isspatial(rs) && error("Conversion to $systype is not supported for spatial networks.") end # Finds and differentials in an expression, and sets these to 0. function remove_diffs(expr) - if Symbolics._occursin(Symbolics.is_derivative, expr) - return Symbolics.replace(expr, diff_2_zero) + if hasnode(Symbolics.is_derivative, expr) + return replacenode(expr, diff_2_zero) else return expr end end -diff_2_zero(expr) = (Symbolics.is_derivative(expr) ? 0.0 : expr) +diff_2_zero(expr) = (Symbolics.is_derivative(expr) ? 0 : expr) COMPLETENESS_ERROR = "A ReactionSystem must be complete before it can be converted to other system types. A ReactionSystem can be marked as complete using the `complete` function." +# Used to, when required, display a warning about conservation law removal and remake. +function check_cons_warning(remove_conserved, remove_conserved_warn) + (remove_conserved && remove_conserved_warn) || return + @warn "You are creating a system or problem while eliminating conserved quantities. Please note, + due to limitations / design choices in ModelingToolkit if you use the created system to + create a problem (e.g. an `ODEProblem`), or are directly creating a problem, you *should not* + modify that problem's initial conditions for species (e.g. using `remake`). Changing initial + conditions must be done by creating a new Problem from your reaction system or the + ModelingToolkit system you converted it into with the new initial condition map. + Modification of parameter values is still possible, *except* for the modification of any + conservation law constants ($CONSERVED_CONSTANT_SYMBOL), which is not possible. You might + get this warning when creating a problem directly. + + You can remove this warning by setting `remove_conserved_warn = false`." +end ### System Conversions ### @@ -471,29 +483,35 @@ Keyword args and default values: - `remove_conserved=false`, if set to `true` will calculate conservation laws of the underlying set of reactions (ignoring constraint equations), and then apply them to reduce the number of equations. +- `remove_conserved_warn = true`: If `true`, if also `remove_conserved = true`, there will be + a warning regarding limitations of modifying problems generated from the created system. """ function Base.convert(::Type{<:ODESystem}, rs::ReactionSystem; name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - include_zero_odes = true, remove_conserved = false, checks = false, - default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), - kwargs...) + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + include_zero_odes = true, remove_conserved = false, remove_conserved_warn = true, + checks = false, default_u0 = Dict(), default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + kwargs...) + # Error checks. iscomplete(rs) || error(COMPLETENESS_ERROR) spatial_convert_err(rs::ReactionSystem, ODESystem) + check_cons_warning(remove_conserved, remove_conserved_warn) + fullrs = Catalyst.flatten(rs) remove_conserved && conservationlaws(fullrs) ists, ispcs = get_indep_sts(fullrs, remove_conserved) eqs = assemble_drift(fullrs, ispcs; combinatoric_ratelaws, remove_conserved, - include_zero_odes) + include_zero_odes) eqs, us, ps, obs, defs = addconstraints!(eqs, fullrs, ists, ispcs; remove_conserved) ODESystem(eqs, get_iv(fullrs), us, ps; - observed = obs, - name, - defaults = _merge(defaults,defs), - checks, - continuous_events = MT.get_continuous_events(fullrs), - discrete_events = MT.get_discrete_events(fullrs), - kwargs...) + observed = obs, + name, + defaults = _merge(defaults, defs), + checks, + continuous_events = MT.get_continuous_events(fullrs), + discrete_events = MT.get_discrete_events(fullrs), + kwargs...) end """ @@ -512,17 +530,21 @@ Keyword args and default values: - `remove_conserved=false`, if set to `true` will calculate conservation laws of the underlying set of reactions (ignoring constraint equations), and then apply them to reduce the number of equations. +- `remove_conserved_warn = true`: If `true`, if also `remove_conserved = true`, there will be + a warning regarding limitations of modifying problems generated from the created system. """ function Base.convert(::Type{<:NonlinearSystem}, rs::ReactionSystem; name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - include_zero_odes = true, remove_conserved = false, checks = false, - default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), - all_differentials_permitted = false, kwargs...) + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + include_zero_odes = true, remove_conserved = false, checks = false, + remove_conserved_warn = true, default_u0 = Dict(), default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + all_differentials_permitted = false, kwargs...) # Error checks. iscomplete(rs) || error(COMPLETENESS_ERROR) spatial_convert_err(rs::ReactionSystem, NonlinearSystem) - if !isautonomous(rs) - error("Attempting to convert a non-autonomous `ReactionSystem` (e.g. where some rate depend on $(rs.iv)) to a `NonlinearSystem`. This is not possible. if you are intending to compute system steady states, consider creating and solving a `SteadyStateProblem.") + check_cons_warning(remove_conserved, remove_conserved_warn) + if !isautonomous(rs) + error("Attempting to convert a non-autonomous `ReactionSystem` (e.g. where some rate depend on $(get_iv(rs))) to a `NonlinearSystem`. This is not possible. if you are intending to compute system steady states, consider creating and solving a `SteadyStateProblem.") end # Generates system equations. @@ -530,7 +552,7 @@ function Base.convert(::Type{<:NonlinearSystem}, rs::ReactionSystem; name = name remove_conserved && conservationlaws(fullrs) ists, ispcs = get_indep_sts(fullrs, remove_conserved) eqs = assemble_drift(fullrs, ispcs; combinatoric_ratelaws, remove_conserved, - as_odes = false, include_zero_odes) + as_odes = false, include_zero_odes) eqs, us, ps, obs, defs = addconstraints!(eqs, fullrs, ists, ispcs; remove_conserved) # Throws a warning if there are differential equations in non-standard format. @@ -538,18 +560,17 @@ function Base.convert(::Type{<:NonlinearSystem}, rs::ReactionSystem; name = name all_differentials_permitted || nonlinear_convert_differentials_check(rs) eqs = [remove_diffs(eq.lhs) ~ remove_diffs(eq.rhs) for eq in eqs] - NonlinearSystem(eqs, us, ps; - name, - observed = obs, - defaults = _merge(defaults,defs), - checks, - kwargs...) + name, + observed = obs, + defaults = _merge(defaults, defs), + checks, + kwargs...) end # Ideally, when `ReactionSystem`s are converted to `NonlinearSystem`s, any coupled ODEs should be # on the form D(X) ~ ..., where lhs is the time derivative w.r.t. a single variable, and the rhs -# does not contain any differentials. If this is not teh case, we throw a warning to let the user +# does not contain any differentials. If this is not the case, we throw a warning to let the user # know that they should be careful. function nonlinear_convert_differentials_check(rs::ReactionSystem) for eq in filter(is_diff_equation, equations(rs)) @@ -557,20 +578,20 @@ function nonlinear_convert_differentials_check(rs::ReactionSystem) # If there is a differential on the right hand side. # If the lhs is not on the form D(...). # If the lhs upper level function is not a differential w.r.t. time. - # If the contenct of the differential is not a variable (and nothing more). + # If the content of the differential is not a variable (and nothing more). # If either of this is a case, throws the warning. - if Symbolics._occursin(Symbolics.is_derivative, eq.rhs) || - !Symbolics.istree(eq.lhs) || - !isequal(Symbolics.operation(eq.lhs), Differential(get_iv(rs))) || - (length(arguments(eq.lhs)) != 1) || - !any(isequal(arguments(eq.lhs)[1]), nonspecies(rs)) + if hasnode(Symbolics.is_derivative, eq.rhs) || + !Symbolics.is_derivative(eq.lhs) || + !isequal(Symbolics.operation(eq.lhs), Differential(get_iv(rs))) || + (length(arguments(eq.lhs)) != 1) || + !any(isequal(arguments(eq.lhs)[1]), nonspecies(rs)) error("You are attempting to convert a `ReactionSystem` coupled with differential equations to a `NonlinearSystem`. However, some of these differentials are not of the form `D(x) ~ ...` where: (1) The left-hand side is a differential of a single variable with respect to the time independent variable, and (2) The right-hand side does not contain any differentials. This is generally not permitted. - - If you still would like to perform this conversions, please use the `all_differentials_permitted = true` option. In this case, all differential will be set to `0`. - However, it is recommended to proceed with caution to ensure that the produced nonlinear equation makes sense for you intended application." + + If you still would like to perform this conversion, please use the `all_differentials_permitted = true` option. In this case, all differentials will be set to `0`. + However, it is recommended to proceed with caution to ensure that the produced nonlinear equation makes sense for your intended application." ) end end @@ -592,24 +613,28 @@ Notes: - `remove_conserved=false`, if set to `true` will calculate conservation laws of the underlying set of reactions (ignoring constraint equations), and then apply them to reduce the number of equations. -- Does not currently support `ReactionSystem`s that include coupled algebraic or - differential equations. +- `remove_conserved_warn = true`: If `true`, if also `remove_conserved = true`, there will be + a warning regarding limitations of modifying problems generated from the created system. """ function Base.convert(::Type{<:SDESystem}, rs::ReactionSystem; - name = nameof(rs), combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - include_zero_odes = true, checks = false, remove_conserved = false, - default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), - kwargs...) + name = nameof(rs), combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + include_zero_odes = true, checks = false, remove_conserved = false, + remove_conserved_warn = true, default_u0 = Dict(), default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + kwargs...) + # Error checks. iscomplete(rs) || error(COMPLETENESS_ERROR) spatial_convert_err(rs::ReactionSystem, SDESystem) + check_cons_warning(remove_conserved, remove_conserved_warn) flatrs = Catalyst.flatten(rs) remove_conserved && conservationlaws(flatrs) ists, ispcs = get_indep_sts(flatrs, remove_conserved) eqs = assemble_drift(flatrs, ispcs; combinatoric_ratelaws, include_zero_odes, - remove_conserved) - noiseeqs = assemble_diffusion(flatrs, ists, ispcs; combinatoric_ratelaws, remove_conserved) + remove_conserved) + noiseeqs = assemble_diffusion(flatrs, ists, ispcs; + combinatoric_ratelaws, remove_conserved) eqs, us, ps, obs, defs = addconstraints!(eqs, flatrs, ists, ispcs; remove_conserved) if any(isbc, get_unknowns(flatrs)) @@ -617,13 +642,13 @@ function Base.convert(::Type{<:SDESystem}, rs::ReactionSystem; end SDESystem(eqs, noiseeqs, get_iv(flatrs), us, ps; - observed = obs, - name, - defaults = defs, - checks, - continuous_events = MT.get_continuous_events(flatrs), - discrete_events = MT.get_discrete_events(flatrs), - kwargs...) + observed = obs, + name, + defaults = defs, + checks, + continuous_events = MT.get_continuous_events(flatrs), + discrete_events = MT.get_discrete_events(flatrs), + kwargs...) end """ @@ -645,15 +670,15 @@ Notes: `ModelingToolkit.JumpSystems`. """ function Base.convert(::Type{<:JumpSystem}, rs::ReactionSystem; name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - remove_conserved = nothing, checks = false, - default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), - kwargs...) + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + remove_conserved = nothing, checks = false, + default_u0 = Dict(), default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + kwargs...) iscomplete(rs) || error(COMPLETENESS_ERROR) spatial_convert_err(rs::ReactionSystem, JumpSystem) - (remove_conserved !== nothing) && - error("Catalyst does not support removing conserved species when converting to JumpSystems.") + throw(ArgumentError("Catalyst does not support removing conserved species when converting to JumpSystems.")) flatrs = Catalyst.flatten(rs) error_if_constraints(JumpSystem, flatrs) @@ -669,88 +694,87 @@ function Base.convert(::Type{<:JumpSystem}, rs::ReactionSystem; name = nameof(rs ps = get_ps(flatrs) JumpSystem(eqs, get_iv(flatrs), sts, ps; - observed = MT.observed(flatrs), - name, - defaults = _merge(defaults,MT.defaults(flatrs)), - checks, - discrete_events = MT.discrete_events(flatrs), - kwargs...) + observed = MT.observed(flatrs), + name, + defaults = _merge(defaults, MT.defaults(flatrs)), + checks, + discrete_events = MT.discrete_events(flatrs), + kwargs...) end ### Problems ### # ODEProblem from AbstractReactionNetwork function DiffEqBase.ODEProblem(rs::ReactionSystem, u0, tspan, - p = DiffEqBase.NullParameters(), args...; - check_length = false, name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - include_zero_odes = true, remove_conserved = false, - checks = false, structural_simplify = false, kwargs...) + p = DiffEqBase.NullParameters(), args...; + check_length = false, name = nameof(rs), + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + include_zero_odes = true, remove_conserved = false, remove_conserved_warn = true, + checks = false, structural_simplify = false, kwargs...) u0map = symmap_to_varmap(rs, u0) pmap = symmap_to_varmap(rs, p) osys = convert(ODESystem, rs; name, combinatoric_ratelaws, include_zero_odes, checks, - remove_conserved) + remove_conserved, remove_conserved_warn) # Handles potential differential algebraic equations (which requires `structural_simplify`). - if structural_simplify + if structural_simplify (osys = MT.structural_simplify(osys)) elseif has_alg_equations(rs) error("The input ReactionSystem has algebraic equations. This requires setting `structural_simplify=true` within `ODEProblem` call.") else osys = complete(osys) end - + return ODEProblem(osys, u0map, tspan, pmap, args...; check_length, kwargs...) end # NonlinearProblem from AbstractReactionNetwork function DiffEqBase.NonlinearProblem(rs::ReactionSystem, u0, - p = DiffEqBase.NullParameters(), args...; - name = nameof(rs), include_zero_odes = true, - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - remove_conserved = false, checks = false, - check_length = false, all_differentials_permitted = false, kwargs...) + p = DiffEqBase.NullParameters(), args...; + name = nameof(rs), include_zero_odes = true, + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + remove_conserved = false, remove_conserved_warn = true, checks = false, + check_length = false, all_differentials_permitted = false, kwargs...) u0map = symmap_to_varmap(rs, u0) pmap = symmap_to_varmap(rs, p) nlsys = convert(NonlinearSystem, rs; name, combinatoric_ratelaws, include_zero_odes, - checks, all_differentials_permitted, remove_conserved) + checks, all_differentials_permitted, remove_conserved, remove_conserved_warn) nlsys = complete(nlsys) - return NonlinearProblem(nlsys, u0map, pmap, args...; check_length, - kwargs...) + return NonlinearProblem(nlsys, u0map, pmap, args...; check_length, + kwargs...) end # SDEProblem from AbstractReactionNetwork function DiffEqBase.SDEProblem(rs::ReactionSystem, u0, tspan, - p = DiffEqBase.NullParameters(), args...; - name = nameof(rs), combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - include_zero_odes = true, checks = false, check_length = false, - remove_conserved = false, structural_simplify = false, kwargs...) - + p = DiffEqBase.NullParameters(), args...; + name = nameof(rs), combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + include_zero_odes = true, checks = false, check_length = false, remove_conserved = false, + remove_conserved_warn = true, structural_simplify = false, kwargs...) u0map = symmap_to_varmap(rs, u0) pmap = symmap_to_varmap(rs, p) sde_sys = convert(SDESystem, rs; name, combinatoric_ratelaws, - include_zero_odes, checks, remove_conserved) + include_zero_odes, checks, remove_conserved, remove_conserved_warn) # Handles potential differential algebraic equations (which requires `structural_simplify`). - if structural_simplify + if structural_simplify (sde_sys = MT.structural_simplify(sde_sys)) elseif has_alg_equations(rs) error("The input ReactionSystem has algebraic equations. This requires setting `structural_simplify=true` within `ODEProblem` call.") else sde_sys = complete(sde_sys) end - + p_matrix = zeros(length(get_unknowns(sde_sys)), numreactions(rs)) return SDEProblem(sde_sys, u0map, tspan, pmap, args...; check_length, - noise_rate_prototype = p_matrix, kwargs...) + noise_rate_prototype = p_matrix, kwargs...) end # DiscreteProblem from AbstractReactionNetwork function DiffEqBase.DiscreteProblem(rs::ReactionSystem, u0, tspan::Tuple, - p = DiffEqBase.NullParameters(), args...; - name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - checks = false, kwargs...) + p = DiffEqBase.NullParameters(), args...; + name = nameof(rs), + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + checks = false, kwargs...) u0map = symmap_to_varmap(rs, u0) pmap = symmap_to_varmap(rs, p) jsys = convert(JumpSystem, rs; name, combinatoric_ratelaws, checks) @@ -760,9 +784,9 @@ end # JumpProblem from AbstractReactionNetwork function JumpProcesses.JumpProblem(rs::ReactionSystem, prob, aggregator, args...; - name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - checks = false, kwargs...) + name = nameof(rs), + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + checks = false, kwargs...) jsys = convert(JumpSystem, rs; name, combinatoric_ratelaws, checks) jsys = complete(jsys) return JumpProblem(jsys, prob, aggregator, args...; kwargs...) @@ -770,18 +794,18 @@ end # SteadyStateProblem from AbstractReactionNetwork function DiffEqBase.SteadyStateProblem(rs::ReactionSystem, u0, - p = DiffEqBase.NullParameters(), args...; - check_length = false, name = nameof(rs), - combinatoric_ratelaws = get_combinatoric_ratelaws(rs), - remove_conserved = false, include_zero_odes = true, - checks = false, structural_simplify = false, kwargs...) + p = DiffEqBase.NullParameters(), args...; + check_length = false, name = nameof(rs), + combinatoric_ratelaws = get_combinatoric_ratelaws(rs), + remove_conserved = false, remove_conserved_warn = true, include_zero_odes = true, + checks = false, structural_simplify = false, kwargs...) u0map = symmap_to_varmap(rs, u0) pmap = symmap_to_varmap(rs, p) osys = convert(ODESystem, rs; name, combinatoric_ratelaws, include_zero_odes, checks, - remove_conserved) + remove_conserved, remove_conserved_warn) # Handles potential differential algebraic equations (which requires `structural_simplify`). - if structural_simplify + if structural_simplify (osys = MT.structural_simplify(osys)) elseif has_alg_equations(rs) error("The input ReactionSystem has algebraic equations. This requires setting `structural_simplify=true` within `ODEProblem` call.") @@ -792,7 +816,6 @@ function DiffEqBase.SteadyStateProblem(rs::ReactionSystem, u0, return SteadyStateProblem(osys, u0map, pmap, args...; check_length, kwargs...) end - ### Symbolic Variable/Symbol Conversions ### # convert symbol of the form :sys.a.b.c to a symbolic a.b.c @@ -800,7 +823,7 @@ function _symbol_to_var(sys, sym) if hasproperty(sys, sym) var = getproperty(sys, sym, namespace = false) else - strs = split(String(sym), "₊") # need to check if this should be split of not!!! + strs = split(String(sym), ModelingToolkit.NAMESPACE_SEPARATOR) # need to check if this should be split of not!!! if length(strs) > 1 var = getproperty(sys, Symbol(strs[1]), namespace = false) for str in view(strs, 2:length(strs)) @@ -877,7 +900,6 @@ end symmap_to_varmap(sys, symmap) = symmap #error("symmap_to_varmap requires a Dict, AbstractArray or Tuple to map Symbols to values.") - ### Other Conversion-related Functions ### # the following function is adapted from SymbolicUtils.jl v.19 @@ -895,7 +917,7 @@ function to_multivariate_poly(polyeqs::AbstractVector{Symbolics.BasicSymbolic{Re pvar2sym, sym2term = SymbolicUtils.get_pvar2sym(), SymbolicUtils.get_sym2term() ps = map(polyeqs) do x - if istree(x) && operation(x) == (/) + if iscall(x) && operation(x) == (/) error("We should not be able to get here, please contact the package authors.") else PolyForm(x, pvar2sym, sym2term).p @@ -903,4 +925,4 @@ function to_multivariate_poly(polyeqs::AbstractVector{Symbolics.BasicSymbolic{Re end ps -end \ No newline at end of file +end diff --git a/src/reactionsystem_serialisation/serialisation_support.jl b/src/reactionsystem_serialisation/serialisation_support.jl new file mode 100644 index 0000000000..fd552074e8 --- /dev/null +++ b/src/reactionsystem_serialisation/serialisation_support.jl @@ -0,0 +1,296 @@ +## String Handling ### + +# Appends stuff to a string. +# E.g `@string_append! str_base str1 str2` becomes `str_base = str_base * str1 * str2`. +macro string_append!(string, inputs...) + rhs = :($string * $(inputs[1])) + for input in inputs[2:end] + push!(rhs.args, input) + end + return esc(:($string = $rhs)) +end + +# Prepends stuff to a string. Can only take 1 or 2 inputs. +# E.g `@string_prepend! str1 str_base` becomes `str_base = str1 * str_base`. +macro string_prepend!(input, string) + rhs = :($input * $string) + return esc(:($string = $rhs)) +end +macro string_prepend!(input1, input2, string) + rhs = :($input1 * $input2 * $string) + return esc(:($string = $rhs)) +end + +# Gets the character at a specific index. +get_char(str, idx) = collect(str)[idx] +get_char_end(str, offset) = collect(str)[end + offset] +# Gets a substring (which is robust to unicode characters like η). +get_substring(str, idx1, idx2) = String(collect(str)[idx1:idx2]) +get_substring_end(str, idx1, offset) = String(collect(str)[idx1:(end + offset)]) + +### Field Serialisation Support Functions ### + +# Function which handles the addition of a single component to the file string. +function push_field(file_text::String, rn::ReactionSystem, + annotate::Bool, top_level::Bool, comp_funcs::Tuple) + has_component, get_comp_string, get_comp_annotation = comp_funcs + has_component(rn) || (return (file_text, false)) + + # Prepares the text creating the field. For non-top level systems, add `local `. Observables + # must be handled differently (as the declaration is not at the beginning of the code for these). + # The independent variables is not declared as a variable, and also should not have a `1ocal `. + write_string = get_comp_string(rn) + if !(top_level || comp_funcs == IV_FS) + if comp_funcs == OBSERVED_FS + write_string = replace(write_string, "\nobserved = [" => "\nlocal observed = [") + else + @string_prepend! "local " write_string + end + end + @string_prepend! "\n" write_string + + # Adds (potential) annotation. Returns the expanded file text, and a Bool that this field was added. + annotate && (@string_prepend! "\n\n# " get_comp_annotation(rn) write_string) + return (file_text * write_string, true) +end + +# Generic function for creating an string for a unsupported argument. +function get_unsupported_comp_string(component::String) + @warn "Writing ReactionSystem models with $(component) is currently not supported. This field is not written to the file." + return "" +end + +# Generic function for creating the annotation string for an unsupported argument. +function get_unsupported_comp_annotation(component::String) + return "$(component): (OBS: Currently not supported, and hence empty)" +end + +### String Conversion ### + +# Converts a numeric expression (e.g. p*X + 2Y) to a string (e.g. "p*X + 2Y"). Also ensures that for +# any variables (e.g. X(t)) the call part is stripped, and only variable name (e.g. X) is written. +function expression_2_string(expr; + strip_call_dict = make_strip_call_dict(Symbolics.get_variables(expr))) + strip_called_expr = substitute(expr, strip_call_dict) + return repr(strip_called_expr) +end + +# Converts a vector of symbolics (e.g. the species or parameter vectors) to a string vector. Strips +# any calls (e.g. X(t) becomes X). E.g. a species vector [X, Y, Z] is converted to "[X, Y, Z]". +function syms_2_strings(syms) + strip_called_syms = [strip_call(Symbolics.unwrap(sym)) for sym in syms] + return get_substring_end("$(convert(Vector{Any}, strip_called_syms))", 4, 0) +end + +# Converts a vector of symbolic variables (e.g. the species or parameter vectors) to a string +# corresponding to the code required to declare them (potential @parameters or @species commands +# must still be added). The `multiline_format` option formats it with a `begin ... end` block +# and declarations on separate lines. +function syms_2_declaration_string(syms; multiline_format = false) + decs_string = (multiline_format ? " begin" : "") + for sym in syms + delimiter = (multiline_format ? "\n\t" : " ") + @string_append! decs_string delimiter sym_2_declaration_string(sym; + multiline_format) + end + multiline_format && (@string_append! decs_string "\nend") + return decs_string +end + +# Converts a symbolic (e.g. a species or parameter) to a string corresponding to how it would be declared +# in code. Takes default values and metadata into account. Example output "p=2.0 [bounds=(0.0, 1.0)]". +# The `multiline_format` option formats the string as if it is part of a `begin .. end` block. +function sym_2_declaration_string(sym; multiline_format = false) + # Creates the basic symbol. The `"$(sym)"` ensures that we get e.g. "X(t)" and not "X". + dec_string = "$(sym)" + + # If the symbol has a non-default type, appends the declaration of this. + # Assumes that the type is on the form `BasicSymbolic{X}`. Contain error checks + # to ensure that this is the case. + if !(sym isa BasicSymbolic{Real}) + sym_type = String(Symbol(typeof(Symbolics.unwrap(sym)))) + if (get_substring(sym_type, 1, 28) != "SymbolicUtils.BasicSymbolic{") || + (get_char_end(sym_type, 0) != '}') + error("Encountered symbolic of unexpected type: $sym_type.") + end + @string_append! dec_string "::" get_substring_end(sym_type, 29, -1) + end + + # If there is a default value, adds this to the declaration. + if ModelingToolkit.hasdefault(sym) + def_val = x_2_string(ModelingToolkit.getdefault(sym)) + separator = (multiline_format ? " = " : "=") + @string_append! dec_string separator "$(def_val)" + end + + # Adds any metadata to the declaration. + metadata_to_declare = get_metadata_to_declare(sym) + if !isempty(metadata_to_declare) + metadata_string = (multiline_format ? ", [" : " [") + for metadata in metadata_to_declare + @string_append! metadata_string metadata_2_string(sym, metadata) ", " + end + @string_append! dec_string get_substring_end(metadata_string, 1, -2) "]" + end + + # Returns the declaration entry for the symbol. + return dec_string +end + +# Converts a generic value to a String. Handles each type of value separately. Unsupported values might +# not necessarily generate valid code, and hence throw errors. Primarily used to write default values +# and metadata values (which hopefully almost exclusively) have simple, supported, types. Ideally, +# more supported types can be added here. +x_2_string(x::Num) = expression_2_string(x) +x_2_string(x::BasicSymbolic{<:Real}) = expression_2_string(x) +x_2_string(x::Bool) = string(x) +x_2_string(x::String) = "\"$x\"" +x_2_string(x::Char) = "\'$x\'" +x_2_string(x::Symbol) = ":$x" +x_2_string(x::Number) = string(x) +x_2_string(x::Pair) = "$(x_2_string(x[1])) => $(x_2_string(x[2]))" +x_2_string(x::Nothing) = "nothing" +function x_2_string(x::Vector) + output = "[" + for val in x + @string_append! output x_2_string(val) ", " + end + return get_substring_end(output, 1, -2) * "]" +end +function x_2_string(x::Tuple) + output = "(" + for val in x + @string_append! output x_2_string(val) ", " + end + return get_substring_end(output, 1, -2) * ")" +end +function x_2_string(x::Dict) + output = "Dict([" + for key in keys(x) + @string_append! output x_2_string(key) " => " x_2_string(x[key]) ", " + end + return get_substring_end(output, 1, -2) * "])" +end +function x_2_string(x::Union{Matrix, Symbolics.Arr{Any, 2}}) + output = "[" + for j in 1:size(x)[1] + for i in 1:size(x)[2] + @string_append! output x_2_string(x[j, i]) " " + end + output = get_substring_end(output, 1, -1) * "; " + end + return get_substring_end(output, 1, -2) * "]" +end + +function x_2_string(x) + error("Tried to write an unsupported value ($(x)) of an unsupported type ($(typeof(x))) to a string.") +end + +### Symbolics Metadata Handling ### + +# For a Symbolic, retrieve all metadata that needs to be added to its declaration. Certain metadata +# (such as default values and whether a variable is a species or not) are skipped (these are stored +# in the `SKIPPED_METADATA` constant). +# Because it is impossible to retrieve the keyword used to declare individual metadata from the +# metadata entry, these must be stored manually (in `RECOGNISED_METADATA`). If one of these are +# encountered, a warning is thrown and it is skipped (we could also throw an error). I have asked +# Shashi, and he claims there is not alternative (general) solution. +function get_metadata_to_declare(sym) + metadata_keys = collect(keys(sym.metadata)) + metadata_keys = filter(mdk -> !(mdk in SKIPPED_METADATA), metadata_keys) + if any(!(mdk in keys(RECOGNISED_METADATA)) for mdk in metadata_keys) + @warn "The following unrecognised metadata entries: $(setdiff(metadata_keys, keys(RECOGNISED_METADATA))) are not recognised for species/variable/parameter $sym. If you raise an issue at https://github.com/SciML/Catalyst.jl, we can add support for this metadata type." + metadata_keys = filter(mdk -> in(mdk, keys(RECOGNISED_METADATA)), metadata_keys) + end + return metadata_keys +end + +# Converts a given metadata into the string used to declare it. +function metadata_2_string(sym, metadata) + return RECOGNISED_METADATA[metadata] * " = " * x_2_string(sym.metadata[metadata]) +end + +# List of all recognised metadata (we should add as many as possible), and th keyword used to declare +# them in code. +const RECOGNISED_METADATA = Dict([Catalyst.ParameterConstantSpecies => "isconstantspecies" + Catalyst.VariableBCSpecies => "isbcspecies" + Catalyst.VariableSpecies => "isspecies" + Catalyst.EdgeParameter => "edgeparameter" + Catalyst.CompoundSpecies => "iscompound" + Catalyst.CompoundComponents => "components" + Catalyst.CompoundCoefficients => "coefficients" + ModelingToolkit.VariableDescription => "description" + ModelingToolkit.VariableBounds => "bounds" + ModelingToolkit.VariableUnit => "unit" + ModelingToolkit.VariableConnectType => "connect" + ModelingToolkit.VariableNoiseType => "noise" + ModelingToolkit.VariableInput => "input" + ModelingToolkit.VariableOutput => "output" + ModelingToolkit.VariableIrreducible => "irreducible" + ModelingToolkit.VariableStatePriority => "state_priority" + ModelingToolkit.VariableMisc => "misc" + ModelingToolkit.TimeDomain => "timedomain"]) + +# List of metadata that does not need to be explicitly declared to be added (or which is handled separately). +const SKIPPED_METADATA = [ModelingToolkit.MTKVariableTypeCtx, Symbolics.VariableSource, + Symbolics.VariableDefaultValue, Catalyst.VariableSpecies] + +### Generic Expression Handling ### + +# Potentially strips the call for a symbolics. E.g. X(t) becomes X (but p remains p). This is used +# when variables are written to files, as in code they are used without the call part. +function strip_call(sym) + return iscall(sym) ? Sym{Real}(Symbolics.getname(sym)) : sym +end + +# For an vector of symbolics, creates a dictionary taking each symbolics to each call-stripped form. +function make_strip_call_dict(syms) + return Dict([sym => strip_call(Symbolics.unwrap(sym)) for sym in syms]) +end + +# If the input is a `ReactionSystem`, extracts the unknowns (i.e. syms depending on another variable). +function make_strip_call_dict(rn::ReactionSystem) + return make_strip_call_dict(get_unknowns(rn)) +end + +### Handle Parameters/Species/Variables Declaration Dependencies ### + +# Gets a vector with the symbolics a symbolic depends on (currently only considers defaults). +function get_dep_syms(sym) + ModelingToolkit.hasdefault(sym) || return [] + return Symbolics.get_variables(ModelingToolkit.getdefault(sym)) +end + +# Checks if a symbolic depends on a symbolics in a vector being declared. +# Because Symbolics has to utilise `isequal`, the `isdisjoint` function cannot be used. +function depends_on(sym, syms) + dep_syms = get_dep_syms(sym) + for s1 in dep_syms + for s2 in syms + isequal(s1, s2) && return true + end + end + return false +end + +# For a set of remaining parameters/species/variables (remaining_syms), return this split into +# two sets: +# One with those that do not depend on any sym in `all_remaining_syms`. +# One with those that do depend on at least one sym in `all_remaining_syms`. +# The first set is returned. Next `remaining_syms` is updated to be the second set. +function dependency_split!(remaining_syms, all_remaining_syms) + writable_syms = filter(sym -> !depends_on(sym, all_remaining_syms), remaining_syms) + filter!(sym -> depends_on(sym, all_remaining_syms), remaining_syms) + return writable_syms +end + +### Other Functions ### + +# Checks if a symbolic's declaration is "complicated". The declaration is considered complicated +# if it has metadata, default value, or type designation that must be declared. +function complicated_declaration(sym) + isempty(get_metadata_to_declare(sym)) || (return true) + ModelingToolkit.hasdefault(sym) && (return true) + (sym isa BasicSymbolic{Real}) || (return true) + return false +end diff --git a/src/reactionsystem_serialisation/serialise_fields.jl b/src/reactionsystem_serialisation/serialise_fields.jl new file mode 100644 index 0000000000..e4e64aff28 --- /dev/null +++ b/src/reactionsystem_serialisation/serialise_fields.jl @@ -0,0 +1,567 @@ +### Handles Independent Variables ### + +# Checks if the reaction system has any independent variable. True for all valid reaction systems. +function seri_has_iv(rn::ReactionSystem) + return true +end + +# Extract a string which declares the system's independent variable. +function get_iv_string(rn::ReactionSystem) + iv_dec = MT.get_iv(rn) + return "@variables $(iv_dec)" +end + +# Creates an annotation for the system's independent variable. +function get_iv_annotation(rn::ReactionSystem) + return "Independent variable:" +end + +# Combines the 3 independent variable-related functions in a constant tuple. +IV_FS = (seri_has_iv, get_iv_string, get_iv_annotation) + +### Handles Spatial Independent Variables ### + +# Checks if the reaction system has any spatial independent variables. +function seri_has_sivs(rn::ReactionSystem) + return !isempty(get_sivs(rn)) +end + +# Extract a string which declares the system's spatial independent variables. +function get_sivs_string(rn::ReactionSystem) + return "spatial_ivs = @variables$(syms_2_declaration_string(get_sivs(rn)))" +end + +# Creates an annotation for the system's spatial independent variables. +function get_sivs_annotation(rn::ReactionSystem) + return "Spatial independent variables:" +end + +# Combines the 3 independent variables-related functions in a constant tuple. +SIVS_FS = (seri_has_sivs, get_sivs_string, get_sivs_annotation) + +### Handles Species, Variables, and Parameters ### + +# Function which handles the addition of species, variable, and parameter declarations to the file +# text. These must be handled as a unity in case there are default value dependencies between these. +function handle_us_n_ps(file_text::String, rn::ReactionSystem, annotate::Bool, + top_level::Bool) + # Fetches the system's parameters, species, and variables. Computes the `has_` `Bool`s. + ps_all = get_ps(rn) + sps_all = get_species(rn) + vars_all = filter(!isspecies, get_unknowns(rn)) + has_ps = seri_has_parameters(rn) + has_sps = seri_has_species(rn) + has_vars = seri_has_variables(rn) + + # Checks which sets have dependencies which require managing. + p_deps = any(depends_on(p, [ps_all; sps_all; vars_all]) for p in ps_all) + sp_deps = any(depends_on(sp, [sps_all; vars_all]) for sp in sps_all) + var_deps = any(depends_on(var, vars_all) for var in vars_all) + + # Makes the initial declaration. + us_n_ps_string = "" + if !p_deps && has_ps + annotate && (@string_append! us_n_ps_string "\n\n# " get_parameters_annotation(rn)) + @string_append! us_n_ps_string "\nps = " get_parameters_string(ps_all) + end + if !sp_deps && has_sps + annotate && (@string_append! us_n_ps_string "\n\n# " get_species_annotation(rn)) + @string_append! us_n_ps_string "\nsps = " get_species_string(sps_all) + end + if !var_deps && has_vars + annotate && (@string_append! us_n_ps_string "\n\n# " get_variables_annotation(rn)) + @string_append! us_n_ps_string "\nvars = " get_variables_string(vars_all) + end + + # If any set have dependencies, handle these. + # There are cases where the dependent syms come after their dependencies in the vector + # (e.g. corresponding to `@parameters p1 p2=p1`) + # which would not require this special treatment. However, this is currently not considered. + # Considering it would make the written code prettier, but would also require additional + # work in these functions to handle these cases (can be sorted out in the future). + if p_deps || sp_deps || var_deps + # Builds an annotation mentioning specially handled stuff. + if annotate + @string_append! us_n_ps_string "\n\n# Some " + p_deps && (@string_append! us_n_ps_string "parameters, ") + sp_deps && (@string_append! us_n_ps_string "species, ") + var_deps && (@string_append! us_n_ps_string "variables, ") + us_n_ps_string = get_substring_end(us_n_ps_string, 1, -2) + @string_append! us_n_ps_string " depends on the declaration of other parameters, species, and/or variables.\n# These are specially handled here.\n" + end + + # Pre-declares the sets with written/remaining parameters/species/variables. + # Whenever all/none are written depends on whether there were any initial dependencies. + # `deepcopy` is required as these get mutated by `dependency_split!`. + remaining_ps = (p_deps ? deepcopy(ps_all) : []) + remaining_sps = (sp_deps ? deepcopy(sps_all) : []) + remaining_vars = (var_deps ? deepcopy(vars_all) : []) + + # Iteratively loops through all parameters, species, and/or variables. In each iteration, + # adds the declaration of those that can still be declared. + while !(isempty(remaining_ps) && isempty(remaining_sps) && isempty(remaining_vars)) + # Checks which parameters/species/variables can be written. The `dependency_split` + # function updates the `remaining_` input. + writable_ps = dependency_split!(remaining_ps, + [remaining_ps; remaining_sps; remaining_vars]) + writable_sps = dependency_split!(remaining_sps, + [remaining_ps; remaining_sps; remaining_vars]) + writable_vars = dependency_split!(remaining_vars, + [remaining_ps; remaining_sps; remaining_vars]) + + # Writes those that can be written. + isempty(writable_ps) || + @string_append! us_n_ps_string get_parameters_string(writable_ps) "\n" + isempty(writable_sps) || + @string_append! us_n_ps_string get_species_string(writable_sps) "\n" + isempty(writable_vars) || + @string_append! us_n_ps_string get_variables_string(writable_vars) "\n" + end + + # For parameters, species, and/or variables with dependencies, create final vectors. + p_deps && (@string_append! us_n_ps_string "ps = " syms_2_strings(ps_all) "\n") + sp_deps && (@string_append! us_n_ps_string "sps = " syms_2_strings(sps_all) "\n") + var_deps && (@string_append! us_n_ps_string "vars = " syms_2_strings(vars_all) "\n") + us_n_ps_string = get_substring_end(us_n_ps_string, 1, -1) + end + + # If this is not a top-level system, `local ` must be added to all declarations. + if !top_level + us_n_ps_string = replace(us_n_ps_string, "\nps = " => "\nlocal ps = ") + us_n_ps_string = replace(us_n_ps_string, "\nsps = " => "\nlocal sps = ") + us_n_ps_string = replace(us_n_ps_string, "\nvars = " => "\nlocal vars = ") + end + + # Merges the file text with `us_n_ps_string` and returns the final outputs. + return file_text * us_n_ps_string, has_ps, has_sps, has_vars +end + +### Handles Parameters ### +# Unlike most other fields, these are not called via `push_field`, but rather via `handle_us_n_ps`. +# Hence they work slightly differently. + +# Checks if the reaction system has any parameters. +function seri_has_parameters(rn::ReactionSystem) + return !isempty(get_ps(rn)) +end + +# Extract a string which declares the system's parameters. Uses multiline declaration (a +# `begin ... end` block) if more than 3 parameters have a "complicated" declaration (if they +# have metadata, default value, or type designation). +function get_parameters_string(ps) + multiline_format = count(complicated_declaration(p) for p in ps) > 3 + return "@parameters$(syms_2_declaration_string(ps; multiline_format))" +end + +# Creates an annotation for the system's parameters. +function get_parameters_annotation(rn::ReactionSystem) + return "Parameters:" +end + +### Handles Species ### +# Unlike most other fields, these are not called via `push_field`, but rather via `handle_us_n_ps`. +# Hence they work slightly differently. + +# Checks if the reaction system has any species. +function seri_has_species(rn::ReactionSystem) + return !isempty(get_species(rn)) +end + +# Extract a string which declares the system's species. Uses multiline declaration (a +# `begin ... end` block) if more than 3 species have a "complicated" declaration (if they +# have metadata, default value, or type designation). +function get_species_string(sps) + multiline_format = count(complicated_declaration(sp) for sp in sps) > 3 + return "@species$(syms_2_declaration_string(sps; multiline_format))" +end + +# Creates an annotation for the system's species. +function get_species_annotation(rn::ReactionSystem) + return "Species:" +end + +### Handles Variables ### +# Unlike most other fields, these are not called via `push_field`, but rather via `handle_us_n_ps`. +# Hence they work slightly differently. + +# Checks if the reaction system has any variables. +function seri_has_variables(rn::ReactionSystem) + return length(get_unknowns(rn)) > length(get_species(rn)) +end + +# Extract a string which declares the system's variables. Uses multiline declaration (a +# `begin ... end` block) if more than 3 variables have a "complicated" declaration (if they +# have metadata, default value, or type designation). +function get_variables_string(vars) + multiline_format = count(complicated_declaration(var) for var in vars) > 3 + return "@variables$(syms_2_declaration_string(vars; multiline_format))" +end + +# Creates an annotation for the system's . +function get_variables_annotation(rn::ReactionSystem) + return "Variables:" +end + +# Combines the 3 variables-related functions in a constant tuple. +VARIABLES_FS = (seri_has_variables, get_variables_string, get_variables_annotation) + +### Handles Reactions ### + +# Checks if the reaction system has any reactions. +function seri_has_reactions(rn::ReactionSystem) + return length(reactions(rn)) != 0 +end + +# Extract a string which declares the system's reactions. +function get_reactions_string(rn::ReactionSystem) + # Creates a dictionary for converting symbolics to their call-stripped form (e.g. X(t) to X). + strip_call_dict = make_strip_call_dict(rn) + + # Handles the case with one reaction separately. Only effect is nicer formatting. + if length(get_rxs(rn)) == 1 + return "rxs = [$(reaction_string(get_rxs(rn)[1], strip_call_dict))]" + end + + # Creates the string corresponding to the code which generates the system's reactions. + rxs_string = "rxs = [" + for rx in get_rxs(rn) + @string_append! rxs_string "\n\t"*reaction_string(rx, strip_call_dict) "," + end + + # Updates the string (including removing the last `,`) and returns it. + return get_substring_end(rxs_string, 1, -1) * "\n]" +end + +# Creates a string that corresponds to the declaration of a single `Reaction`. +function reaction_string(rx::Reaction, strip_call_dict) + # Prepares the `Reaction` declaration components. + rate = expression_2_string(rx.rate; strip_call_dict) + substrates = isempty(rx.substrates) ? "nothing" : x_2_string(rx.substrates) + products = isempty(rx.products) ? "nothing" : x_2_string(rx.products) + substoich = isempty(rx.substoich) ? "nothing" : x_2_string(rx.substoich) + prodstoich = isempty(rx.prodstoich) ? "nothing" : x_2_string(rx.prodstoich) + + # Creates the full expression, including adding kwargs (`only_use_rate` and `metadata`). + rx_string = "Reaction($rate, $(substrates), $(products), $(substoich), $(prodstoich)" + if rx.only_use_rate + @string_append! rx_string "; only_use_rate = true" + isempty(getmetadata_dict(rx)) || (rx_string = rx_string * ", ") + end + if !isempty(getmetadata_dict(rx)) + rx.only_use_rate || (@string_append! rx_string "; ") + @string_append! rx_string "metadata = [" + for entry in getmetadata_dict(rx) + metadata_entry = "$(x_2_string(entry)), " + @string_append! rx_string metadata_entry + end + rx_string = get_substring_end(rx_string, 1, -2) * "]" + end + + # Returns the Reaction string. + return rx_string * ")" +end + +# Creates an annotation for the system's reactions. +function get_reactions_annotation(rn::ReactionSystem) + return "Reactions:" +end + +# Combines the 3 reaction-related functions in a constant tuple. +REACTIONS_FS = (seri_has_reactions, get_reactions_string, get_reactions_annotation) + +### Handles Equations ### + +# Checks if the reaction system has any equations. +function seri_has_equations(rn::ReactionSystem) + return length(get_eqs(rn)) > length(get_rxs(rn)) +end + +# Extract a string which declares the system's equations. +function get_equations_string(rn::ReactionSystem) + # Creates a dictionary for converting symbolics to their call-stripped form (e.g. X(t) to X). + strip_call_dict = make_strip_call_dict(rn) + + # Handles the case with one equation separately. Only effect is nicer formatting. + if length(get_eqs(rn)) - length(get_rxs(rn)) == 1 + return "eqs = [$(expression_2_string(get_eqs(rn)[end]; strip_call_dict))]" + end + + # Creates the string corresponding to the code which generates the system's reactions. + eqs_string = "eqs = [" + for eq in get_eqs(rn)[(length(get_rxs(rn)) + 1):end] + @string_append! eqs_string "\n\t" expression_2_string(eq; strip_call_dict) "," + end + + # Updates the string (including removing the last `,`) and returns it. + return get_substring_end(eqs_string, 1, -1) * "\n]" +end + +# Creates an annotation for the system's equations. +function get_equations_annotation(rn::ReactionSystem) + return "Equations:" +end + +# Combines the 3 equations-related functions in a constant tuple. +EQUATIONS_FS = (seri_has_equations, get_equations_string, get_equations_annotation) + +### Handles Observables ### + +# Checks if the reaction system has any observables. +function seri_has_observed(rn::ReactionSystem) + return !isempty(get_observed(rn)) +end + +# Extract a string which declares the system's observables. +function get_observed_string(rn::ReactionSystem) + # Finds the observable species and variables. + observed_unknowns = [obs_eq.lhs for obs_eq in MT.get_observed(rn)] + observed_species = filter(isspecies, observed_unknowns) + observed_variables = filter(!isspecies, observed_unknowns) + + # Creates a dictionary for converting symbolics to their call-stripped form (e.g. X(t) to X). + strip_call_dict = make_strip_call_dict([get_unknowns(rn); observed_unknowns]) + + # Initialises the observables string with declaring the observable species/variables. + observed_string = "" + if !isempty(observed_species) + @string_append! observed_string "@species$(syms_2_declaration_string(observed_species))\n" + end + if !isempty(observed_variables) + @string_append! observed_string "@variables$(syms_2_declaration_string(observed_variables))\n" + end + + # Handles the case with one observable separately. Only effect is nicer formatting. + if length(MT.get_observed(rn)) == 1 + @string_append! observed_string "observed = [$(expression_2_string(MT.get_observed(rn)[1]; strip_call_dict))]" + return observed_string + end + + # Appends with the code which will generate observables equations. + @string_append! observed_string "observed = [" + for obs in MT.get_observed(rn) + @string_append! observed_string "\n\t" expression_2_string(obs; strip_call_dict) "," + end + + # Updates the string (including removing the last `,`) and returns it. + return observed_string[1:(end - 1)] * "\n]" +end + +# Creates an annotation for the system's observables. +function get_observed_annotation(rn::ReactionSystem) + return "Observables:" +end + +# Combines the 3 -related functions in a constant tuple. +OBSERVED_FS = (seri_has_observed, get_observed_string, get_observed_annotation) + +### Handles Observables ### + +# Checks if the reaction system has any defaults. +function seri_has_defaults(rn::ReactionSystem) + return !isempty(get_defaults(rn)) +end + +# Extract a string which declares the system's defaults. +function get_defaults_string(rn::ReactionSystem) + defaults_string = "defaults = " * x_2_string(get_defaults(rn)) + return defaults_string +end + +# Creates an annotation for the system's defaults. +function get_defaults_annotation(rn::ReactionSystem) + return "Defaults:" +end + +# Combines the 3 defaults-related functions in a constant tuple. +DEFAULTS_FS = (seri_has_defaults, get_defaults_string, get_defaults_annotation) + +### Handles Continuous Events ### + +# Checks if the reaction system have has continuous events. +function seri_has_continuous_events(rn::ReactionSystem) + return !isempty(MT.get_continuous_events(rn)) +end + +# Extract a string which declares the system's continuous events. +function get_continuous_events_string(rn::ReactionSystem) + # Creates a dictionary for converting symbolics to their call-stripped form (e.g. X(t) to X). + strip_call_dict = make_strip_call_dict(rn) + + # Handles the case with one event separately. Only effect is nicer formatting. + if length(MT.get_continuous_events(rn)) == 1 + return "continuous_events = [$(continuous_event_string(MT.get_continuous_events(rn)[1], strip_call_dict))]" + end + + # Creates the string corresponding to the code which generates the system's reactions. + continuous_events_string = "continuous_events = [" + for continuous_event in MT.get_continuous_events(rn) + @string_append! continuous_events_string "\n\t" continuous_event_string( + continuous_event, strip_call_dict) "," + end + + # Updates the string (including removing the last `,`) and returns it. + return get_substring_end(continuous_events_string, 1, -1) * "\n]" +end + +# Creates a string that corresponds to the declaration of a single continuous event. +function continuous_event_string(continuous_event, strip_call_dict) + # Creates the string corresponding to the equations (i.e. conditions). + eqs_string = "[" + for eq in continuous_event.eqs + @string_append! eqs_string expression_2_string(eq; strip_call_dict) ", " + end + eqs_string = get_substring_end(eqs_string, 1, -2) * "]" + + # Creates the string corresponding to the affects. + # Continuous events' `affect` field should probably be called `affects`. Likely the `s` was + # dropped by mistake in MTK. + affects_string = "[" + for affect in continuous_event.affect + @string_append! affects_string expression_2_string(affect; strip_call_dict) ", " + end + affects_string = get_substring_end(affects_string, 1, -2) * "]" + + return eqs_string * " => " * affects_string +end + +# Creates an annotation for the system's continuous events. +function get_continuous_events_annotation(rn::ReactionSystem) + return "Continuous events:" +end + +# Combines the 3 -related functions in a constant tuple. +CONTINUOUS_EVENTS_FS = (seri_has_continuous_events, get_continuous_events_string, + get_continuous_events_annotation) + +### Handles Discrete Events ### + +# Checks if the reaction system has any discrete events. +function seri_has_discrete_events(rn::ReactionSystem) + return !isempty(MT.get_discrete_events(rn)) +end + +# Extract a string which declares the system's discrete events. +function get_discrete_events_string(rn::ReactionSystem) + # Creates a dictionary for converting symbolics to their call-stripped form (e.g. X(t) to X). + strip_call_dict = make_strip_call_dict(rn) + + # Handles the case with one event separately. Only effect is nicer formatting. + if length(MT.get_discrete_events(rn)) == 1 + return "discrete_events = [$(discrete_event_string(MT.get_discrete_events(rn)[1], strip_call_dict))]" + end + + # Creates the string corresponding to the code which generates the system's reactions. + discrete_events_string = "discrete_events = [" + for discrete_event in MT.get_discrete_events(rn) + @string_append! discrete_events_string "\n\t" discrete_event_string( + discrete_event, strip_call_dict) "," + end + + # Updates the string (including removing the last `,`) and returns it. + return get_substring_end(discrete_events_string, 1, -1) * "\n]" +end + +# Creates a string that corresponds to the declaration of a single discrete event. +function discrete_event_string(discrete_event, strip_call_dict) + # Creates the string corresponding to the conditions. The special check is if the condition is + # an expression like `X > 5.0`. Here, "(...)" is added for purely aesthetic reasons. + condition_string = x_2_string(discrete_event.condition) + if discrete_event.condition isa BasicSymbolic + @string_prepend! "(" condition_string + @string_append! condition_string ")" + end + + # Creates the string corresponding to the affects. + affects_string = "[" + for affect in discrete_event.affects + @string_append! affects_string expression_2_string(affect; strip_call_dict) ", " + end + affects_string = get_substring_end(affects_string, 1, -2) * "]" + + return condition_string * " => " * affects_string +end + +# Creates an annotation for the system's discrete events. +function get_discrete_events_annotation(rn::ReactionSystem) + return "Discrete events:" +end + +# Combines the 3 -related functions in a constant tuple. +DISCRETE_EVENTS_FS = (seri_has_discrete_events, get_discrete_events_string, + get_discrete_events_annotation) + +### Handles Systems ### + +# Specific `push_field` function, which is used for the system field (where the annotation option +# must be passed to the `get_component_string` function). Since non-ReactionSystem systems cannot be +# written to file, this function throws an error if any such systems are encountered. +function push_systems_field(file_text::String, rn::ReactionSystem, annotate::Bool, + top_level::Bool) + # Checks whether there are any subsystems, and if these are ReactionSystems. + seri_has_systems(rn) || (return (file_text, false)) + if any(!(system isa ReactionSystem) for system in MT.get_systems(rn)) + error("Tries to write a ReactionSystem to file which have non-ReactionSystem subs-systems. This is currently not possible.") + end + + # Adds the system declaration string to the file string. + write_string = "\n" + top_level || (@string_append! write_string "local ") + @string_append! write_string get_systems_string(rn, annotate) + annotate && (@string_prepend! "\n\n# " get_systems_annotation(rn) write_string) + return (file_text * write_string, true) +end + +# Checks if the reaction system has any systems. +function seri_has_systems(rn::ReactionSystem) + return !isempty(MT.get_systems(rn)) +end + +# Extract a string which declares the system's systems. +function get_systems_string(rn::ReactionSystem, annotate::Bool) + # Initiates the `systems` string. It is pre-declared vector, into which the systems are added. + systems_string = "systems = Vector(undef, $(length(MT.get_systems(rn))))" + + # Loops through all systems, adding their declaration to the system string. + for (idx, system) in enumerate(MT.get_systems(rn)) + if annotate + @string_append! systems_string "\n\n# Declares subsystem: $(getname(system))" + end + + # Manipulates the subsystem declaration to make it nicer. + subsystem_string = get_full_system_string(system, annotate, false) + subsystem_string = replace(subsystem_string, "\n" => "\n\t") + subsystem_string = "let\n" * get_substring_end(subsystem_string, 7, -6) * "end" + @string_append! systems_string "\nsystems[$idx] = " subsystem_string + end + + return systems_string +end + +# Creates an annotation for the system's systems. +function get_systems_annotation(rn::ReactionSystem) + return "Subystems:" +end + +# Combines the 3 systems-related functions in a constant tuple. +SYSTEMS_FS = (seri_has_systems, get_systems_string, get_systems_annotation) + +### Handles Connection Types ### + +# Checks if the reaction system has any connection types. +function seri_has_connection_type(rn::ReactionSystem) + return false +end + +# Extract a string which declares the system's connection types. +function get_connection_type_string(rn::ReactionSystem) + get_unsupported_comp_string("connection types") +end + +# Creates an annotation for the system's connection types. +function get_connection_type_annotation(rn::ReactionSystem) + get_unsupported_comp_annotation("Connection types:") +end + +# Combines the 3 connection types-related functions in a constant tuple. +CONNECTION_TYPE_FS = ( + seri_has_connection_type, get_connection_type_string, get_connection_type_annotation) diff --git a/src/reactionsystem_serialisation/serialise_reactionsystem.jl b/src/reactionsystem_serialisation/serialise_reactionsystem.jl new file mode 100644 index 0000000000..01a8a2243c --- /dev/null +++ b/src/reactionsystem_serialisation/serialise_reactionsystem.jl @@ -0,0 +1,177 @@ +""" + save_reactionsystem(filename::String, rn::ReactionSystem; annotate = true, safety_check = true) + +Save a `ReactionSystem` model to a file. The `ReactionSystem` is saved as runnable Julia code. This +can both be used to save a `ReactionSystem` model, but also to write it to a file for easy inspection. + +Arguments: +- `filename`: The name of the file to which the `ReactionSystem` is saved. +- `rn`: The `ReactionSystem` which should be saved to a file. +- `annotate = true`: Whether annotation should be added to the file. +- `safety_check = true`: After serialisation, Catalyst will automatically load the serialised + `ReactionSystem` and check that it is equal to `rn`. If it is not, an error will be thrown. For + models without the `connection_type` field, this should not happen. If performance is required + (i.e. when saving a large number of models), this can be disabled by setting `safety_check = false`. + +Example: +```julia +rn = @reaction_network begin + (p,d), 0 <--> X +end +save_reactionsystem("rn.jls", rn) +``` +The model can now be loaded using +```julia +rn = include("rn.jls") +``` + +Notes: +- `ReactionSystem`s with the `connection_type` field has this ignored (saving of this field has not + been implemented yet). +- `ReactionSystem`s with non-`ReactionSystem` sub-systems (e.g. `ODESystem`s) cannot be saved. +- Reaction systems with components that have units cannot currently be saved. +- The `ReactionSystem` is saved using *programmatic* (not DSL) format for model creation. +""" +function save_reactionsystem(filename::String, rn::ReactionSystem; + annotate = true, safety_check = true) + # Error and warning checks. + reactionsystem_uptodate_check() + if !isempty(get_networkproperties(rn)) + @warn "The serialised network has cached network properties (e.g. computed conservation laws). This will not be saved as part of the network, and must be recomputed when it is loaded." + end + + # Write model to file and performs a safety check. + open(filename, "w") do file + write(file, get_full_system_string(rn, annotate, true)) + end + if safety_check + if !isequal(rn, include(joinpath(pwd(), filename))) + rm(filename) + error("The serialised `ReactionSystem` is not equal to the original one. Please make a report (including the full system) at https://github.com/SciML/Catalyst.jl/issues. To disable this behaviour, please pass the `safety_check = false` argument to `save_reactionsystem` (warning, this will permit the serialisation of an erroneous system).") + end + end + return nothing +end + +# Gets the full string which corresponds to the declaration of a system. Might be called recursively +# for systems with subsystems. +function get_full_system_string(rn::ReactionSystem, annotate::Bool, top_level::Bool) + # Initiates the file string. + file_text = "" + + # Goes through each type of system component, potentially adding it to the string. + # Species, variables, and parameters must be handled differently in case there are default values + # dependencies between them. + # Systems use custom `push_field` function as these require the annotation `Bool`to be passed + # to the function that creates the next sub-system declarations. + file_text, _ = push_field(file_text, rn, annotate, top_level, IV_FS) + file_text, has_sivs = push_field(file_text, rn, annotate, top_level, SIVS_FS) + file_text, has_parameters, has_species, has_variables = handle_us_n_ps( + file_text, rn, annotate, top_level) + file_text, has_reactions = push_field(file_text, rn, annotate, top_level, REACTIONS_FS) + file_text, has_equations = push_field(file_text, rn, annotate, top_level, EQUATIONS_FS) + file_text, has_observed = push_field(file_text, rn, annotate, top_level, OBSERVED_FS) + file_text, has_defaults = push_field(file_text, rn, annotate, top_level, DEFAULTS_FS) + file_text, has_continuous_events = push_field(file_text, rn, annotate, + top_level, CONTINUOUS_EVENTS_FS) + file_text, has_discrete_events = push_field(file_text, rn, annotate, + top_level, DISCRETE_EVENTS_FS) + file_text, has_systems = push_systems_field(file_text, rn, annotate, top_level) + file_text, has_connection_type = push_field(file_text, rn, annotate, + top_level, CONNECTION_TYPE_FS) + + # Finalise the system. Creates the final `ReactionSystem` call. + # Enclose everything in a `let ... end` block. + rs_creation_code = make_reaction_system_call( + rn, annotate, top_level, has_sivs, has_species, + has_variables, has_parameters, has_reactions, + has_equations, has_observed, has_defaults, has_continuous_events, + has_discrete_events, has_systems, has_connection_type) + annotate || (@string_prepend! "\n" file_text) + @string_prepend! "let" file_text + @string_append! file_text "\n\n" rs_creation_code "\n\nend" + + return file_text +end + +# Creates a ReactionSystem call for creating the model. Adds all the correct inputs to it. The input +# `has_` `Bool`s described which inputs are used. If the model is `complete`, this is handled here. +function make_reaction_system_call(rs::ReactionSystem, annotate, top_level, has_sivs, + has_species, has_variables, has_parameters, has_reactions, has_equations, + has_observed, has_defaults, has_continuous_events, has_discrete_events, has_systems, + has_connection_type) + + # Gets the independent variable input. + iv = x_2_string(get_iv(rs)) + + # Gets the equations (reactions + equations) input. + if has_reactions && has_equations + eqs = "[rxs; eqs]" + elseif has_reactions + eqs = "rxs" + elseif has_equations + eqs = "eqs" + else + eqs = "[]" + end + + # Gets the unknowns (species + variables) input. + if has_species && has_variables + unknowns = "[sps; vars]" + elseif has_species + unknowns = "sps" + elseif has_variables + unknowns = "vars" + else + unknowns = "[]" + end + + # Gets the parameters input. + if has_parameters + ps = "ps" + else + ps = "[]" + end + + # Initiates the ReactionSystem call with the mandatory inputs. + reaction_system_string = "ReactionSystem($eqs, $iv, $unknowns, $ps" + + # Appends the reaction system name. Also initiates the optional argument part of the call. + if Base.isidentifier(Catalyst.getname(rs)) + rs_name = ":$(Catalyst.getname(rs))" + else + rs_name = "Symbol(\"$(Catalyst.getname(rs))\")" + end + @string_append! reaction_system_string "; name = $(rs_name)" + + # Goes through various fields that might exists, and if so, adds them to the string. + has_sivs && (@string_append! reaction_system_string ", spatial_ivs") + has_observed && (@string_append! reaction_system_string ", observed") + has_defaults && (@string_append! reaction_system_string ", defaults") + has_continuous_events && (@string_append! reaction_system_string ", continuous_events") + has_discrete_events && (@string_append! reaction_system_string ", discrete_events") + has_systems && (@string_append! reaction_system_string ", systems") + has_connection_type && (@string_append! reaction_system_string ", connection_type") + + # Potentially appends a combinatoric_ratelaws statement. + if !Symbolics.unwrap(combinatoric_ratelaws(rs)) + @string_append! reaction_system_string ", combinatoric_ratelaws = false" + end + + # Potentially appends `ReactionSystem` metadata value(s). Weird composite types are not supported. + if !isnothing(MT.get_metadata(rs)) + @string_append! reaction_system_string ", metadata = $(x_2_string(MT.get_metadata(rs)))" + end + + # Finalises the call. Appends potential annotation. If the system is complete, add a call for this. + @string_append! reaction_system_string ")" + if ModelingToolkit.iscomplete(rs) + @string_prepend! "rs = " reaction_system_string + top_level || (@string_prepend! "local " reaction_system_string) + @string_append! reaction_system_string "\ncomplete(rs)" + end + if annotate + @string_prepend! "# Declares ReactionSystem model:\n" reaction_system_string + end + return reaction_system_string +end diff --git a/src/registered_functions.jl b/src/registered_functions.jl index 70f06ac080..2c6bbebad4 100644 --- a/src/registered_functions.jl +++ b/src/registered_functions.jl @@ -109,45 +109,94 @@ function Symbolics.derivative(::typeof(hillar), args::NTuple{5, Any}, ::Val{5}) (args[1]^args[5] + args[2]^args[5] + args[4]^args[5])^2 end +# Tuple storing all registered function (for use in various functionalities). +const registered_funcs = (mm, mmr, hill, hillr, hillar) ### Custom CRN FUnction-related Functions ### """ -expand_registered_functions(expr) +expand_registered_functions(in) -Takes an expression, and expands registered function expressions. E.g. `mm(X,v,K)` is replaced with v*X/(X+K). Currently supported functions: `mm`, `mmr`, `hill`, `hillr`, and `hill`. +Takes an expression, and expands registered function expressions. E.g. `mm(X,v,K)` is replaced +with v*X/(X+K). Currently supported functions: `mm`, `mmr`, `hill`, `hillr`, and `hill`. Can +be applied to a reaction system, a reaction, an equation, or a symbolic expression. The input +is not modified, while an output with any functions expanded is returned. If applied to a +reaction system model, any cached network properties are reset. """ function expand_registered_functions(expr) - istree(expr) || return expr + if hasnode(is_catalyst_function, expr) + expr = replacenode(expr, expand_catalyst_function) + end + return expr +end + +# Checks whether an expression corresponds to a catalyst function call (e.g. `mm(X,v,K)`). +function is_catalyst_function(expr) + iscall(expr) || (return false) + return operation(expr) in registered_funcs +end + +# If the input expression corresponds to a catalyst function call (e.g. `mm(X,v,K)`), returns +# it in its expanded form. If not, returns the input expression. +function expand_catalyst_function(expr) + is_catalyst_function(expr) || (return expr) args = arguments(expr) if operation(expr) == Catalyst.mm - return args[2]*args[1]/(args[1] + args[3]) + return args[2] * args[1] / (args[1] + args[3]) elseif operation(expr) == Catalyst.mmr - return args[2]*args[3]/(args[1] + args[3]) + return args[2] * args[3] / (args[1] + args[3]) elseif operation(expr) == Catalyst.hill - return args[2]*(args[1]^args[4])/((args[1])^args[4] + (args[3])^args[4]) + return args[2] * (args[1]^args[4]) / ((args[1])^args[4] + (args[3])^args[4]) elseif operation(expr) == Catalyst.hillr - return args[2]*(args[3]^args[4])/((args[1])^args[4] + (args[3])^args[4]) + return args[2] * (args[3]^args[4]) / ((args[1])^args[4] + (args[3])^args[4]) elseif operation(expr) == Catalyst.hillar - return args[3]*(args[1]^args[5])/((args[1])^args[5] + (args[2])^args[5] + (args[4])^args[5]) - end - for i = 1:length(args) - args[i] = expand_registered_functions(args[i]) + return args[3] * (args[1]^args[5]) / + ((args[1])^args[5] + (args[2])^args[5] + (args[4])^args[5]) end - return expr end + # If applied to a Reaction, return a reaction with its rate modified. function expand_registered_functions(rx::Reaction) - Reaction(expand_registered_functions(rx.rate), rx.substrates, rx.products, rx.substoich, - rx.prodstoich, rx.netstoich, rx.only_use_rate, rx.metadata) + Reaction(expand_registered_functions(rx.rate), rx.substrates, rx.products, + rx.substoich, rx.prodstoich, rx.netstoich, rx.only_use_rate, rx.metadata) end -# If applied to a Equation, returns it with it applied to lhs and rhs + +# If applied to a Equation, returns it with it applied to lhs and rhs. function expand_registered_functions(eq::Equation) return expand_registered_functions(eq.lhs) ~ expand_registered_functions(eq.rhs) end + +# If applied to a continuous event, returns it applied to eqs and affect. +function expand_registered_functions(ce::ModelingToolkit.SymbolicContinuousCallback) + eqs = expand_registered_functions(ce.eqs) + affect = expand_registered_functions(ce.affect) + return ModelingToolkit.SymbolicContinuousCallback(eqs, affect) +end + +# If applied to a discrete event, returns it applied to condition and affects. +function expand_registered_functions(de::ModelingToolkit.SymbolicDiscreteCallback) + condition = expand_registered_functions(de.condition) + affects = expand_registered_functions(de.affects) + return ModelingToolkit.SymbolicDiscreteCallback(condition, affects) +end + +# If applied to a vector, applies it to every element in the vector. +function expand_registered_functions(vec::Vector) + return [Catalyst.expand_registered_functions(element) for element in vec] +end + # If applied to a ReactionSystem, applied function to all Reactions and other Equations, and return updated system. +# Currently, `ModelingToolkit.has_X_events` returns `true` even if event vector is empty (hence +# this function cannot be used). function expand_registered_functions(rs::ReactionSystem) - @set! rs.eqs = [Catalyst.expand_registered_functions(eq) for eq in get_eqs(rs)] - @set! rs.rxs = [Catalyst.expand_registered_functions(rx) for rx in get_rxs(rs)] + @set! rs.eqs = Catalyst.expand_registered_functions(get_eqs(rs)) + @set! rs.rxs = Catalyst.expand_registered_functions(get_rxs(rs)) + if !isempty(ModelingToolkit.get_continuous_events(rs)) + @set! rs.continuous_events = Catalyst.expand_registered_functions(ModelingToolkit.get_continuous_events(rs)) + end + if !isempty(ModelingToolkit.get_discrete_events(rs)) + @set! rs.discrete_events = Catalyst.expand_registered_functions(ModelingToolkit.get_discrete_events(rs)) + end + reset_networkproperties!(rs) return rs end diff --git a/src/spatial_reaction_systems/lattice_jump_systems.jl b/src/spatial_reaction_systems/lattice_jump_systems.jl new file mode 100644 index 0000000000..ebbe367cf1 --- /dev/null +++ b/src/spatial_reaction_systems/lattice_jump_systems.jl @@ -0,0 +1,154 @@ +### JumpProblem ### + +# Builds a spatial DiscreteProblem from a Lattice Reaction System. +function DiffEqBase.DiscreteProblem(lrs::LatticeReactionSystem, u0_in, tspan, + p_in = DiffEqBase.NullParameters(), args...; kwargs...) + if !is_transport_system(lrs) + error("Currently lattice Jump simulations only supported when all spatial reactions are transport reactions.") + end + + # Converts potential symmaps to varmaps. + u0_in = symmap_to_varmap(lrs, u0_in) + p_in = symmap_to_varmap(lrs, p_in) + + # Converts u0 and p to their internal forms. + # u0 is simply a vector with all the species' initial condition values across all vertices. + # u0 is [spec 1 at vert 1, spec 2 at vert 1, ..., spec 1 at vert 2, ...]. + u0 = lattice_process_u0(u0_in, species(lrs), lrs) + # vert_ps and `edge_ps` are vector maps, taking each parameter's Symbolics representation to its value(s). + # vert_ps values are vectors. Here, index (i) is a parameter's value in vertex i. + # edge_ps values are sparse matrices. Here, index (i,j) is a parameter's value in the edge from vertex i to vertex j. + # Uniform vertex/edge parameters store only a single value (a length 1 vector, or size 1x1 sparse matrix). + vert_ps, edge_ps = lattice_process_p(p_in, vertex_parameters(lrs), + edge_parameters(lrs), lrs) + + # Returns a DiscreteProblem (which basically just stores the processed input). + return DiscreteProblem(u0, tspan, [vert_ps; edge_ps], args...; kwargs...) +end + +# Builds a spatial JumpProblem from a DiscreteProblem containing a `LatticeReactionSystem`. +function JumpProcesses.JumpProblem(lrs::LatticeReactionSystem, dprob, aggregator, args...; + combinatoric_ratelaws = get_combinatoric_ratelaws(reactionsystem(lrs)), + name = nameof(reactionsystem(lrs)), kwargs...) + # Error checks. + if !isnothing(dprob.f.sys) + throw(ArgumentError("Unexpected `DiscreteProblem` passed into `JumpProblem`. Was a `LatticeReactionSystem` used as input to the initial `DiscreteProblem`?")) + end + + # Computes hopping constants and mass action jumps (requires some internal juggling). + # Currently, the resulting JumpProblem does not depend on parameters (no way to incorporate these). + # Hence the parameters of this one do not actually matter. If at some point JumpProcess can + # handle parameters this can be updated and improved. + # The non-spatial DiscreteProblem have a u0 matrix with entries for all combinations of species and vertexes. + hopping_constants = make_hopping_constants(dprob, lrs) + sma_jumps = make_spatial_majumps(dprob, lrs) + non_spat_dprob = DiscreteProblem(reshape(dprob.u0, num_species(lrs), num_verts(lrs)), + dprob.tspan, first.(dprob.p[1])) + + # Creates and returns a spatial JumpProblem (masked lattices are not supported by these). + spatial_system = has_masked_lattice(lrs) ? get_lattice_graph(lrs) : lattice(lrs) + return JumpProblem(non_spat_dprob, aggregator, sma_jumps; + hopping_constants, spatial_system, name, kwargs...) +end + +# Creates the hopping constants from a discrete problem and a lattice reaction system. +function make_hopping_constants(dprob::DiscreteProblem, lrs::LatticeReactionSystem) + # Creates the all_diff_rates vector, containing for each species, its transport rate across all edges. + # If the transport rate is uniform for one species, the vector has a single element, else one for each edge. + spatial_rates_dict = Dict(compute_all_transport_rates(Dict(dprob.p), lrs)) + all_diff_rates = [haskey(spatial_rates_dict, s) ? spatial_rates_dict[s] : [0.0] + for s in species(lrs)] + + # Creates an array (of the same size as the hopping constant array) containing all edges. + # First the array is a NxM matrix (number of species x number of vertices). Each element is a + # vector containing all edges leading out from that vertex (sorted by destination index). + edge_array = [Pair{Int64, Int64}[] for _1 in 1:num_species(lrs), _2 in 1:num_verts(lrs)] + for e in edge_iterator(lrs), s_idx in 1:num_species(lrs) + push!(edge_array[s_idx, e[1]], e) + end + foreach(e_vec -> sort!(e_vec; by = e -> e[2]), edge_array) + + # Creates the hopping constants array. It has the same shape as the edge array, but each + # element is that species transportation rate along that edge + hopping_constants = [[Catalyst.get_edge_value(all_diff_rates[s_idx], e) + for e in edge_array[s_idx, src_idx]] + for s_idx in 1:num_species(lrs), src_idx in 1:num_verts(lrs)] + return hopping_constants +end + +# Creates a SpatialMassActionJump struct from a (spatial) DiscreteProblem and a LatticeReactionSystem. +# Could implement a version which, if all reactions' rates are uniform, returns a MassActionJump. +# Not sure if there is any form of performance improvement from that though. Likely not the case. +function make_spatial_majumps(dprob, lrs::LatticeReactionSystem) + # Creates a vector, storing which reactions have spatial components. + is_spatials = [has_spatial_vertex_component(rx.rate, dprob.p) + for rx in reactions(reactionsystem(lrs))] + + # Creates templates for the rates (uniform and spatial) and the stoichiometries. + # We cannot fetch reactant_stoich and net_stoich from a (non-spatial) MassActionJump. + # The reason is that we need to re-order the reactions so that uniform appears first, and spatial next. + num_rxs = length(reactions(reactionsystem(lrs))) + u_rates = Vector{Float64}(undef, num_rxs - count(is_spatials)) + s_rates = Matrix{Float64}(undef, count(is_spatials), num_verts(lrs)) + reactant_stoich = Vector{Vector{Pair{Int64, Int64}}}(undef, num_rxs) + net_stoich = Vector{Vector{Pair{Int64, Int64}}}(undef, num_rxs) + + # Loops through reactions with non-spatial rates, computes their rates and stoichiometries. + cur_rx = 1 + for (is_spat, rx) in zip(is_spatials, reactions(reactionsystem(lrs))) + is_spat && continue + u_rates[cur_rx] = compute_vertex_value(rx.rate, lrs; ps = dprob.p)[1] + substoich_map = Pair.(rx.substrates, rx.substoich) + reactant_stoich[cur_rx] = int_map(substoich_map, reactionsystem(lrs)) + net_stoich[cur_rx] = int_map(rx.netstoich, reactionsystem(lrs)) + cur_rx += 1 + end + # Loops through reactions with spatial rates, computes their rates and stoichiometries. + for (is_spat, rx) in zip(is_spatials, reactions(reactionsystem(lrs))) + is_spat || continue + s_rates[cur_rx - length(u_rates), :] .= compute_vertex_value(rx.rate, lrs; + ps = dprob.p) + substoich_map = Pair.(rx.substrates, rx.substoich) + reactant_stoich[cur_rx] = int_map(substoich_map, reactionsystem(lrs)) + net_stoich[cur_rx] = int_map(rx.netstoich, reactionsystem(lrs)) + cur_rx += 1 + end + # SpatialMassActionJump expects empty rate containers to be nothing. + isempty(u_rates) && (u_rates = nothing) + (count(is_spatials) == 0) && (s_rates = nothing) + + return SpatialMassActionJump(u_rates, s_rates, reactant_stoich, net_stoich, nothing) +end + +### Extra ### + +# Temporary. Awaiting implementation in SII, or proper implementation within Catalyst (with +# more general functionality). +function int_map(map_in, sys) + return [ModelingToolkit.variable_index(sys, pair[1]) => pair[2] for pair in map_in] +end + +# Currently unused. If we want to create certain types of MassActionJumps (instead of SpatialMassActionJumps) we can take this one back. +# Creates the (non-spatial) mass action jumps from a (non-spatial) DiscreteProblem (and its Reaction System of origin). +# function make_majumps(non_spat_dprob, rs::ReactionSystem) +# # Computes various required inputs for assembling the mass action jumps. +# js = convert(JumpSystem, rs) +# statetoid = Dict(ModelingToolkit.value(state) => i for (i, state) in enumerate(unknowns(rs))) +# eqs = equations(js) +# invttype = non_spat_dprob.tspan[1] === nothing ? Float64 : typeof(1 / non_spat_dprob.tspan[2]) +# +# # Assembles the non-spatial mass action jumps. +# p = (non_spat_dprob.p isa DiffEqBase.NullParameters || non_spat_dprob.p === nothing) ? Num[] : non_spat_dprob.p +# majpmapper = ModelingToolkit.JumpSysMajParamMapper(js, p; jseqs = eqs, rateconsttype = invttype) +# return ModelingToolkit.assemble_maj(eqs.x[1], statetoid, majpmapper) +# end + +### Problem & Integrator Rebuilding ### + +# Currently not implemented. +function rebuild_lat_internals!(dprob::DiscreteProblem) + error("Modification and/or rebuilding of `DiscreteProblem`s is currently not supported. Please create a new problem instead.") +end +function rebuild_lat_internals!(jprob::JumpProblem) + error("Modification and/or rebuilding of `JumpProblem`s is currently not supported. Please create a new problem instead.") +end diff --git a/src/spatial_reaction_systems/lattice_reaction_systems.jl b/src/spatial_reaction_systems/lattice_reaction_systems.jl index a153a0b2a6..7f9e2923cf 100644 --- a/src/spatial_reaction_systems/lattice_reaction_systems.jl +++ b/src/spatial_reaction_systems/lattice_reaction_systems.jl @@ -1,91 +1,527 @@ +### New Type Unions ### + +# Cartesian and masked grids share several traits, hence we declare a common (union) type for them. +const GridLattice{N, T} = Union{Array{Bool, N}, CartesianGridRej{N, T}} + ### Lattice Reaction Network Structure ### -# Describes a spatial reaction network over a graph. -# Adding the "<: MT.AbstractTimeDependentSystem" part messes up show, disabling me from creating LRSs. -struct LatticeReactionSystem{S,T} # <: MT.AbstractTimeDependentSystem + +# Describes a spatial reaction network over a lattice. +struct LatticeReactionSystem{Q, R, S, T} <: MT.AbstractTimeDependentSystem # Input values. - """The reaction system within each compartment.""" - rs::ReactionSystem{S} - """The spatial reactions defined between individual nodes.""" - spatial_reactions::Vector{T} - """The graph on which the lattice is defined.""" - lattice::SimpleDiGraph{Int64} + """The (non-spatial) reaction system within each vertex.""" + reactionsystem::ReactionSystem{Q} + """The spatial reactions defined between individual vertices.""" + spatial_reactions::Vector{R} + """The lattice on which the (discrete) spatial system is defined.""" + lattice::S # Derived values. - """The number of compartments.""" + """The number of vertices (compartments).""" num_verts::Int64 """The number of edges.""" num_edges::Int64 """The number of species.""" num_species::Int64 - """Whenever the initial input was a digraph.""" - init_digraph::Bool - """Species that may move spatially.""" - spat_species::Vector{BasicSymbolic{Real}} + + """List of species that may move spatially.""" + spatial_species::Vector{BasicSymbolic{Real}} """ All parameters related to the lattice reaction system - (both with spatial and non-spatial effects). + (both those whose values are tied to vertices and edges). """ - parameters::Vector{BasicSymbolic{Real}} + parameters::Vector{Any} """ - Parameters which values are tied to vertexes (adjacencies), - e.g. (possibly) have an unique value at each vertex of the system. + Parameters which values are tied to vertices, + e.g. that possibly could have unique values at each vertex of the system. """ - vertex_parameters::Vector{BasicSymbolic{Real}} + vertex_parameters::Vector{Any} """ - Parameters which values are tied to edges (adjacencies), - e.g. (possibly) have an unique value at each edge of the system. + Parameters whose values are tied to edges (adjacencies), + e.g. that possibly could have unique values at each edge of the system. """ - edge_parameters::Vector{BasicSymbolic{Real}} + edge_parameters::Vector{Any} + """ + An iterator over all the lattice's edges. Currently, the format is always a Vector{Pair{Int64,Int64}}. + However, in the future, different types could potentially be used for different types of lattice + (E.g. for a Cartesian grid, we do not technically need to enumerate each edge) + """ + edge_iterator::T - function LatticeReactionSystem(rs::ReactionSystem{S}, spatial_reactions::Vector{T}, - lattice::DiGraph; init_digraph = true) where {S, T} - # There probably some better way to ascertain that T has that type. Not sure how. - if !(T <: AbstractSpatialReaction) - error("The second argument must be a vector of AbstractSpatialReaction subtypes.") + function LatticeReactionSystem(rs::ReactionSystem{Q}, spatial_reactions::Vector{R}, + lattice::S, num_verts::Int64, num_edges::Int64, + edge_iterator::T) where {Q, R, S, T} + # Error checks. + if !(R <: AbstractSpatialReaction) + throw(ArgumentError("The second argument must be a vector of AbstractSpatialReaction subtypes.")) + end + if !iscomplete(rs) + throw(ArgumentError("A non-complete `ReactionSystem` was used as input, this is not permitted.")) + end + if !isempty(MT.get_systems(rs)) + throw(ArgumentError("A non-flattened (hierarchical) `ReactionSystem` was used as input. `LatticeReactionSystem`s can only be based on non-hierarchical `ReactionSystem`s.")) + end + if length(species(rs)) != length(unknowns(rs)) + throw(ArgumentError("The `ReactionSystem` used as input contain variable unknowns (in addition to species unknowns). This is not permitted (the input `ReactionSystem` must contain species unknowns only).")) + end + if length(reactions(rs)) != length(equations(rs)) + throw(ArgumentError("The `ReactionSystem` used as input contain equations (in addition to reactions). This is not permitted.")) + end + if !isempty(MT.continuous_events(rs)) || !isempty(MT.discrete_events(rs)) + throw(ArgumentError("The `ReactionSystem` used as input to `LatticeReactionSystem contain events. These will be ignored in any simulations based on the created `LatticeReactionSystem`.")) + end + if !isempty(observed(rs)) + @warn "The `ReactionSystem` used as input to `LatticeReactionSystem contain observables. It will not be possible to access these from the created `LatticeReactionSystem`." end + # Computes the species which are parts of spatial reactions. Also counts the total number of + # species types. if isempty(spatial_reactions) spat_species = Vector{BasicSymbolic{Real}}[] else - spat_species = unique(reduce(vcat, [spatial_species(sr) for sr in spatial_reactions])) + spat_species = unique(reduce(vcat, + [spatial_species(sr) for sr in spatial_reactions])) end num_species = length(unique([species(rs); spat_species])) + + # Computes the sets of vertex, edge, and all, parameters. rs_edge_parameters = filter(isedgeparameter, parameters(rs)) if isempty(spatial_reactions) srs_edge_parameters = Vector{BasicSymbolic{Real}}[] else - srs_edge_parameters = setdiff(reduce(vcat, [parameters(sr) for sr in spatial_reactions]), parameters(rs)) + srs_edge_parameters = setdiff( + reduce(vcat, [parameters(sr) for sr in spatial_reactions]), parameters(rs)) end edge_parameters = unique([rs_edge_parameters; srs_edge_parameters]) vertex_parameters = filter(!isedgeparameter, parameters(rs)) + # Ensures the parameter order begins similarly to in the non-spatial ReactionSystem. - ps = [parameters(rs); setdiff([edge_parameters; vertex_parameters], parameters(rs))] + ps = [parameters(rs); setdiff([edge_parameters; vertex_parameters], parameters(rs))] + + # Checks that all spatial reactions are valid for this reaction system. + foreach( + sr -> check_spatial_reaction_validity(rs, sr; edge_parameters = edge_parameters), + spatial_reactions) - foreach(sr -> check_spatial_reaction_validity(rs, sr; edge_parameters=edge_parameters), spatial_reactions) - return new{S,T}(rs, spatial_reactions, lattice, nv(lattice), ne(lattice), num_species, - init_digraph, spat_species, ps, vertex_parameters, edge_parameters) + return new{Q, R, S, T}( + rs, spatial_reactions, lattice, num_verts, num_edges, num_species, + spat_species, ps, vertex_parameters, edge_parameters, edge_iterator) end end -function LatticeReactionSystem(rs, srs, lat::SimpleGraph) - return LatticeReactionSystem(rs, srs, DiGraph(lat); init_digraph = false) + +# Creates a LatticeReactionSystem from a (directed) Graph lattice (graph grid). +function LatticeReactionSystem(rs, srs, lattice::DiGraph) + num_verts = nv(lattice) + num_edges = ne(lattice) + edge_iterator = [e.src => e.dst for e in edges(lattice)] + return LatticeReactionSystem(rs, srs, lattice, num_verts, num_edges, edge_iterator) +end +# Creates a LatticeReactionSystem from a (undirected) Graph lattice (graph grid). +function LatticeReactionSystem(rs, srs, lattice::SimpleGraph) + LatticeReactionSystem(rs, srs, DiGraph(lattice)) +end + +# Creates a LatticeReactionSystem from a CartesianGrid lattice (cartesian grid) or a Boolean Array +# lattice (masked grid). These two are quite similar, so much code can be reused in a single interface. +function LatticeReactionSystem(rs, srs, lattice::GridLattice{N, T}; + diagonal_connections = false) where {N, T} + # Error checks. + (N > 3) && error("Grids of higher dimension than 3 is currently not supported.") + + # Computes the number of vertices and edges. The two grid types (Cartesian and masked) each + # uses their own function for this. + num_verts = count_verts(lattice) + num_edges = count_edges(lattice; diagonal_connections) + + # Finds all the grid's edges. First computer `flat_to_grid_idx` which is a vector which takes + # each vertex's flat (scalar) index to its grid index (e.g. (3,5) for a 2d grid). Next compute + # `grid_to_flat_idx` which is an array (of the same size as the grid) that does the reverse conversion. + # Especially for masked grids these have non-trivial forms. + flat_to_grid_idx, grid_to_flat_idx = get_index_converters(lattice, num_verts) + # Simultaneously iterates through all vertices' flat and grid indices. Finds the (grid) indices + # of their neighbours (different approaches for the two grid types). Converts these to flat + # indices and adds the edges to `edge_iterator`. + cur_vert = 0 + g_size = grid_size(lattice) + edge_iterator = Vector{Pair{Int64, Int64}}(undef, num_edges) + for (flat_idx, grid_idx) in enumerate(flat_to_grid_idx) + for neighbour_grid_idx in get_neighbours(lattice, grid_idx, g_size; + diagonal_connections) + cur_vert += 1 + edge_iterator[cur_vert] = flat_idx => grid_to_flat_idx[neighbour_grid_idx...] + end + end + + return LatticeReactionSystem(rs, srs, lattice, num_verts, num_edges, edge_iterator) +end + +### LatticeReactionSystem Helper Functions ### +# Note, most of these are specifically for (Cartesian or masked) grids, we call them `grid`, not `lattice`. + +# Counts the number of vertices on a (Cartesian or masked) grid. +count_verts(grid::CartesianGridRej{N, T}) where {N, T} = prod(grid_size(grid)) +count_verts(grid::Array{Bool, N}) where {N} = count(grid) + +# Counts and edges on a Cartesian grid. The formula counts the number of internal, side, edge, and +# corner vertices (on the grid). `l,m,n = grid_dims(grid),1,1` ensures that "extra" dimensions get +# length 1. The formula holds even if one or more of l, m, and n are 1. +function count_edges(grid::CartesianGridRej{N, T}; + diagonal_connections = false) where {N, T} + l, m, n = grid_size(grid)..., 1, 1 + (ni, ns, ne, nc) = diagonal_connections ? (26, 17, 11, 7) : (6, 5, 4, 3) + num_edges = ni * (l - 2) * (m - 2) * (n - 2) + # Edges from internal vertices. + ns * (2(l - 2) * (m - 2) + 2(l - 2) * (n - 2) + 2(m - 2) * (n - 2)) + # Edges from side vertices. + ne * (4(l - 2) + 4(m - 2) + 4(n - 2)) + # Edges from edge vertices. + nc * 8 # Edges from corner vertices. + return num_edges +end + +# Counts and edges on a masked grid. Does so by looping through all the vertices of the grid, +# finding their neighbours, and updating the edge count accordingly. +function count_edges(grid::Array{Bool, N}; diagonal_connections = false) where {N} + g_size = grid_size(grid) + num_edges = 0 + for grid_idx in get_grid_indices(grid) + grid[grid_idx] || continue + num_edges += length(get_neighbours(grid, Tuple(grid_idx), g_size; + diagonal_connections)) + end + return num_edges +end + +# For a (1d, 2d, or 3d) (Cartesian or masked) grid, returns a vector and an array, permitting the +# conversion between a vertex's flat (scalar) and grid indices. E.g. for a 2d grid, if grid point (3,2) +# corresponds to the fifth vertex, then `flat_to_grid_idx[5] = (3,2)` and `grid_to_flat_idx[3,2] = 5`. +function get_index_converters(grid::GridLattice{N, T}, num_verts) where {N, T} + flat_to_grid_idx = Vector{typeof(grid_size(grid))}(undef, num_verts) + grid_to_flat_idx = Array{Int64}(undef, grid_size(grid)) + + # Loops through the flat and grid indices simultaneously, adding them to their respective converters. + cur_flat_idx = 0 + for grid_idx in get_grid_indices(grid) + # For a masked grid, grid points with `false` values are skipped. + (grid isa Array{Bool}) && (!grid[grid_idx]) && continue + + cur_flat_idx += 1 + flat_to_grid_idx[cur_flat_idx] = grid_idx + grid_to_flat_idx[grid_idx] = cur_flat_idx + end + return flat_to_grid_idx, grid_to_flat_idx +end + +# For a vertex's grid index, and a lattice, returns the grid indices of all its (valid) neighbours. +function get_neighbours(grid::GridLattice{N, T}, grid_idx, g_size; + diagonal_connections = false) where {N, T} + # Depending on the grid's dimension, find all potential neighbours. + if grid_dims(grid) == 1 + potential_neighbours = [grid_idx .+ (i) for i in -1:1] + elseif grid_dims(grid) == 2 + potential_neighbours = [grid_idx .+ (i, j) for i in -1:1 for j in -1:1] + else + potential_neighbours = [grid_idx .+ (i, j, k) for i in -1:1 for j in -1:1 + for k in -1:1] + end + + # Depending on whether diagonal connections are used or not, find valid neighbours. + if diagonal_connections + filter!(n_idx -> n_idx !== grid_idx, potential_neighbours) + else + filter!(n_idx -> count(n_idx .== grid_idx) == (length(g_size) - 1), + potential_neighbours) + end + + # Removes neighbours outside of the grid, and returns the full list. + return filter(n_idx -> is_valid_grid_point(grid, n_idx, g_size), potential_neighbours) +end + +# Checks if a grid index corresponds to a valid grid point. First, check that each dimension of the +# index is within the grid's bounds. Next, perform an extra check for the masked grid. +function is_valid_grid_point(grid::GridLattice{N, T}, grid_idx, g_size) where {N, T} + if !all(0 < g_idx <= dim_leng for (g_idx, dim_leng) in zip(grid_idx, g_size)) + return false + end + return (grid isa Array{Bool}) ? grid[grid_idx...] : true +end + +# Gets an iterator over a grid's grid indices. Separate function so we can handle the two grid types +# separately (i.e. not calling `CartesianIndices(ones(grid_size(grid)))` unnecessarily for masked grids). +function get_grid_indices(grid::CartesianGridRej{N, T}) where {N, T} + CartesianIndices(ones(grid_size(grid))) +end +get_grid_indices(grid::Array{Bool, N}) where {N} = CartesianIndices(grid) + +### LatticeReactionSystem-specific Getters ### + +# Basic getters (because `LatticeReactionSystem`s are `AbstractSystem`s), normal `lrs.field` does not +# work and these getters must be used throughout all code. +reactionsystem(lrs::LatticeReactionSystem) = getfield(lrs, :reactionsystem) +spatial_reactions(lrs::LatticeReactionSystem) = getfield(lrs, :spatial_reactions) +lattice(lrs::LatticeReactionSystem) = getfield(lrs, :lattice) +num_verts(lrs::LatticeReactionSystem) = getfield(lrs, :num_verts) +num_edges(lrs::LatticeReactionSystem) = getfield(lrs, :num_edges) +num_species(lrs::LatticeReactionSystem) = getfield(lrs, :num_species) +spatial_species(lrs::LatticeReactionSystem) = getfield(lrs, :spatial_species) +MT.parameters(lrs::LatticeReactionSystem) = getfield(lrs, :parameters) +vertex_parameters(lrs::LatticeReactionSystem) = getfield(lrs, :vertex_parameters) +edge_parameters(lrs::LatticeReactionSystem) = getfield(lrs, :edge_parameters) +edge_iterator(lrs::LatticeReactionSystem) = getfield(lrs, :edge_iterator) + +# Non-trivial getters. +""" + is_transport_system(lrs::LatticeReactionSystem) + +Returns `true` if all spatial reactions in `lrs` are `TransportReaction`s. +""" +function is_transport_system(lrs::LatticeReactionSystem) + return all(sr -> sr isa TransportReaction, spatial_reactions(lrs)) +end + +""" + has_cartesian_lattice(lrs::LatticeReactionSystem) + +Returns `true` if `lrs` was created using a cartesian grid lattice (e.g. created via `CartesianGrid(5,5)`). +Otherwise, returns `false`. +""" +has_cartesian_lattice(lrs::LatticeReactionSystem) = lattice(lrs) isa + CartesianGridRej{N, T} where {N, T} + +""" + has_masked_lattice(lrs::LatticeReactionSystem) + +Returns `true` if `lrs` was created using a masked grid lattice (e.g. created via `[true true; true false]`). +Otherwise, returns `false`. +""" +has_masked_lattice(lrs::LatticeReactionSystem) = lattice(lrs) isa Array{Bool, N} where {N} + +""" + has_grid_lattice(lrs::LatticeReactionSystem) + +Returns `true` if `lrs` was created using a cartesian or masked grid lattice. Otherwise, returns `false`. +""" +function has_grid_lattice(lrs::LatticeReactionSystem) + return has_cartesian_lattice(lrs) || has_masked_lattice(lrs) end -### Lattice ReactionSystem Getters ### +""" + has_graph_lattice(lrs::LatticeReactionSystem) + +Returns `true` if `lrs` was created using a graph grid lattice (e.g. created via `path_graph(5)`). +Otherwise, returns `false`. +""" +has_graph_lattice(lrs::LatticeReactionSystem) = lattice(lrs) isa SimpleDiGraph + +""" + grid_size(lrs::LatticeReactionSystem) + +Returns the size of `lrs`'s lattice (only if it is a cartesian or masked grid lattice). +E.g. for a lattice `CartesianGrid(4,6)`, `(4,6)` is returned. +""" +grid_size(lrs::LatticeReactionSystem) = grid_size(lattice(lrs)) +grid_size(lattice::CartesianGridRej{N, T}) where {N, T} = lattice.dims +grid_size(lattice::Array{Bool, N}) where {N} = size(lattice) +function grid_size(lattice::Graphs.AbstractGraph) + throw(ArgumentError("Grid size is only defined for LatticeReactionSystems with grid-based lattices (not graph-based).")) +end + +""" + grid_dims(lrs::LatticeReactionSystem) + +Returns the number of dimensions of `lrs`'s lattice (only if it is a cartesian or masked grid lattice). +The output is either `1`, `2`, or `3`. +""" +grid_dims(lrs::LatticeReactionSystem) = grid_dims(lattice(lrs)) +grid_dims(lattice::GridLattice{N, T}) where {N, T} = return N +function grid_dims(lattice::Graphs.AbstractGraph) + throw(ArgumentError("Grid dimensions is only defined for LatticeReactionSystems with grid-based lattices (not graph-based).")) +end + +""" + get_lattice_graph(lrs::LatticeReactionSystem) + +Returns lrs's lattice, but in as a graph. Currently does not work for Cartesian lattices. +""" +function get_lattice_graph(lrs::LatticeReactionSystem) + has_graph_lattice(lrs) && return lattice(lrs) + return Graphs.SimpleGraphFromIterator(Graphs.SimpleEdge(e[1], e[2]) + for e in edge_iterator(lrs)) +end + +### Catalyst-based Getters ### # Get all species. -species(lrs::LatticeReactionSystem) = unique([species(lrs.rs); lrs.spat_species]) -# Get all species that may be transported. -spatial_species(lrs::LatticeReactionSystem) = lrs.spat_species - -# Get all parameters. -ModelingToolkit.parameters(lrs::LatticeReactionSystem) = lrs.parameters -# Get all parameters which values are tied to vertexes (compartments). -vertex_parameters(lrs::LatticeReactionSystem) = lrs.vertex_parameters -# Get all parameters which values are tied to edges (adjacencies). -edge_parameters(lrs::LatticeReactionSystem) = lrs.edge_parameters - -# Gets the lrs name (same as rs name). -ModelingToolkit.nameof(lrs::LatticeReactionSystem) = nameof(lrs.rs) - -# Checks if a lattice reaction system is a pure (linear) transport reaction system. -is_transport_system(lrs::LatticeReactionSystem) = all(sr -> sr isa TransportReaction, lrs.spatial_reactions) +function species(lrs::LatticeReactionSystem) + unique([species(reactionsystem(lrs)); spatial_species(lrs)]) +end + +# Generic ones (simply forwards call to the non-spatial system). +reactions(lrs::LatticeReactionSystem) = reactions(reactionsystem(lrs)) + +### ModelingToolkit-based Getters ### + +# Generic ones (simply forwards call to the non-spatial system) +# The `parameters` MTK getter have a specialised accessor for LatticeReactionSystems. +MT.nameof(lrs::LatticeReactionSystem) = MT.nameof(reactionsystem(lrs)) +MT.get_iv(lrs::LatticeReactionSystem) = MT.get_iv(reactionsystem(lrs)) +MT.equations(lrs::LatticeReactionSystem) = MT.equations(reactionsystem(lrs)) +MT.unknowns(lrs::LatticeReactionSystem) = MT.unknowns(reactionsystem(lrs)) +MT.get_metadata(lrs::LatticeReactionSystem) = MT.get_metadata(reactionsystem(lrs)) + +# Lattice reaction systems should not be combined with compositional modelling. +# Maybe these should be allowed anyway? Still feel a bit weird +function MT.get_eqs(lrs::LatticeReactionSystem) + MT.get_eqs(reactionsystem(lrs)) +end +function MT.get_unknowns(lrs::LatticeReactionSystem) + MT.get_unknowns(reactionsystem(lrs)) +end +function MT.get_ps(lrs::LatticeReactionSystem) + MT.get_ps(reactionsystem(lrs)) +end + +# Technically should not be used, but has to be declared for the `show` function to work. +function MT.get_systems(lrs::LatticeReactionSystem) + return [] +end + +# Other non-relevant getters. +function MT.independent_variables(lrs::LatticeReactionSystem) + MT.independent_variables(reactionsystem(lrs)) +end + +### Edge Parameter Value Generators ### + +""" + make_edge_p_values(lrs::LatticeReactionSystem, make_edge_p_value::Function) + +Generates edge parameter values for a lattice reaction system. Only work for (Cartesian or masked) +grid lattices (without diagonal adjacencies). + +Input: +- `lrs`: The lattice reaction system for which values should be generated. + - `make_edge_p_value`: a function describing a rule for generating the edge parameter values. + +Output: + - `ep_vals`: A sparse matrix of size (num_verts,num_verts) (where num_verts is the number of + vertices in `lrs`). Here, `eps[i,j]` is filled only if there is an edge going from vertex i to + vertex j. The value of `eps[i,j]` is determined by `make_edge_p_value`. + +Here, `make_edge_p_value` should take two arguments, `src_vert` and `dst_vert`, which correspond to +the grid indices of an edge's source and destination vertices, respectively. It outputs a single value, +which is the value assigned to that edge. + +Example: + In the following example, we assign the value `0.1` to all edges, except for the one leading from + vertex (1,1) to vertex (1,2), to which we assign the value `1.0`. +```julia +using Catalyst +rn = @reaction_network begin + (p,d), 0 <--> X +end +tr = @transport_reaction D X +lattice = CartesianGrid((5,5)) +lrs = LatticeReactionSystem(rn, [tr], lattice) + +function make_edge_p_value(src_vert, dst_vert) + if src_vert == (1,1) && dst_vert == (1,2) + return 1.0 + else + return 0.1 + end +end + +D_vals = make_edge_p_values(lrs, make_edge_p_value) +``` +""" +function make_edge_p_values(lrs::LatticeReactionSystem, make_edge_p_value::Function) + if has_graph_lattice(lrs) + error("The `make_edge_p_values` function is only meant for lattices with (Cartesian or masked) grid structures. It cannot be applied to graph lattices.") + end + + # Makes the flat to index grid converts. Predeclared the edge parameter value sparse matrix. + flat_to_grid_idx = get_index_converters(lattice(lrs), num_verts(lrs))[1] + values = spzeros(num_verts(lrs), num_verts(lrs)) + + # Loops through all edges, and applies the value function to these. + for e in edge_iterator(lrs) + # This extra step is needed to ensure that `0` is stored if make_edge_p_value yields a 0. + # If not, then the sparse matrix simply becomes empty in that position. + values[e[1], e[2]] = eps() + + values[e[1], e[2]] = make_edge_p_value(flat_to_grid_idx[e[1]], + flat_to_grid_idx[e[2]]) + end + + return values +end + +""" + make_directed_edge_values(lrs::LatticeReactionSystem, x_vals::Tuple{T,T}, y_vals::Tuple{T,T} = (undef,undef), + z_vals::Tuple{T,T} = (undef,undef)) where {T} + +Generates edge parameter values for a lattice reaction system. Only work for (Cartesian or masked) +grid lattices (without diagonal adjacencies). Each dimension (x, and possibly y and z), and +direction has assigned its own constant edge parameter value. + +Input: + - `lrs`: The lattice reaction system for which values should be generated. + - `x_vals::Tuple{T,T}`: The values in the increasing (from a lower x index to a higher x index) + and decreasing (from a higher x index to a lower x index) direction along the x dimension. + - `y_vals::Tuple{T,T}`: The values in the increasing and decreasing direction along the y dimension. + Should only be used for 2 and 3-dimensional grids. + - `z_vals::Tuple{T,T}`: The values in the increasing and decreasing direction along the z dimension. + Should only be used for 3-dimensional grids. + +Output: + - `ep_vals`: A sparse matrix of size (num_verts,num_verts) (where num_verts is the number of + vertices in `lrs`). Here, `eps[i,j]` is filled only if there is an edge going from vertex i to + vertex j. The value of `eps[i,j]` is determined by the `x_vals`, `y_vals`, and `z_vals` Tuples, + and vertices i and j's relative position in the grid. + +It should be noted that two adjacent vertices will always be different in exactly a single dimension +(x, y, or z). The corresponding tuple determines which value is assigned. + +Example: + In the following example, we wish to have diffusion in the x dimension, but a constant flow from + low y values to high y values (so not transportation from high to low y). We achieve it in the + following manner: +```julia +using Catalyst +rn = @reaction_network begin + (p,d), 0 <--> X +end +tr = @transport_reaction D X +lattice = CartesianGrid((5,5)) +lrs = LatticeReactionSystem(rn, [tr], lattice) + +D_vals = make_directed_edge_values(lrs, (0.1, 0.1), (0.1, 0.0)) +``` +Here, since we have a 2d grid, we only provide the first two Tuples to `make_directed_edge_values`. +""" +function make_directed_edge_values(lrs::LatticeReactionSystem, x_vals::Tuple{T, T}, + y_vals::Union{Nothing, Tuple{T, T}} = nothing, + z_vals::Union{Nothing, Tuple{T, T}} = nothing) where {T} + # Error checks. + if has_graph_lattice(lrs) + error("The `make_directed_edge_values` function is only meant for lattices with (Cartesian or masked) grid structures. It cannot be applied to graph lattices.") + end + if count(!isnothing(flow) for flow in [x_vals, y_vals, z_vals]) != grid_dims(lrs) + error("You must provide flows in the same number of dimensions as your lattice has dimensions. The lattice have $(grid_dims(lrs)), and flows where provided for $(count(isnothing(flow) for flow in [x_vals, y_vals, z_vals])) dimensions.") + end + + # Declares a function that assigns the correct flow value for a given edge. + function directed_vals_func(src_vert, dst_vert) + if count(src_vert .== dst_vert) != (grid_dims(lrs) - 1) + error("The `make_directed_edge_values` function can only be applied to lattices with rigid (non-diagonal) grid structure. It is being evaluated for the edge from $(src_vert) to $(dst_vert), which does not seem directly adjacent on a grid.") + elseif src_vert[1] != dst_vert[1] + return (src_vert[1] < dst_vert[1]) ? x_vals[1] : x_vals[2] + elseif src_vert[2] != dst_vert[2] + return (src_vert[2] < dst_vert[2]) ? y_vals[1] : y_vals[2] + elseif src_vert[3] != dst_vert[3] + return (src_vert[3] < dst_vert[3]) ? z_vals[1] : z_vals[2] + else + error("Problem when evaluating adjacency type for the edge from $(src_vert) to $(dst_vert).") + end + end + + # Uses the make_edge_p_values function to compute the output. + return make_edge_p_values(lrs, directed_vals_func) +end diff --git a/src/spatial_reaction_systems/lattice_solution_interfacing.jl b/src/spatial_reaction_systems/lattice_solution_interfacing.jl new file mode 100644 index 0000000000..3a286a8a02 --- /dev/null +++ b/src/spatial_reaction_systems/lattice_solution_interfacing.jl @@ -0,0 +1,121 @@ +### Rudimentary Interfacing Function ### +# A single function, `get_lrs_vals`, which contains all interfacing functionality. However, +# long-term it should be replaced with a sleeker interface. Ideally as MTK-wide support for +# lattice problems and solutions is introduced. + +""" + get_lrs_vals(sol, sp, lrs::LatticeReactionSystem; t = nothing) + +A function for retrieving the solution of a `LatticeReactionSystem`-based simulation on various +desired forms. Generally, for `LatticeReactionSystem`s, the values in `sol` is ordered in a +way which is not directly interpretable by the user. Furthermore, the normal Catalyst interface +for solutions (e.g. `sol[:X]`) does not work for these solutions. Hence this function is used instead. + +The output is a vector, which in each position contains sp's value (either at a time step of time, +depending on the input `t`). Its shape depends on the lattice (using a similar form as heterogeneous +initial conditions). I.e. for a NxM cartesian grid, the values are NxM matrices. For a masked grid, +the values are sparse matrices. For a graph lattice, the values are vectors (where the value in +the n'th position corresponds to sp's value in the n'th vertex). + +Arguments: +- `sol`: The solution from which we wish to retrieve some values. +- `sp`: The species which values we wish to retrieve. Can be either a symbol (e.g. `:X`) or a symbolic +variable (e.g. `X`). +- `lrs`: The `LatticeReactionSystem` which was simulated to generate the solution. +- `t = nothing`: If `nothing`, we simply return the solution across all saved time steps. If `t` +instead is a vector (or range of values), returns the solutions interpolated at these time points. + +Notes: +- The `get_lrs_vals` is not optimised for performance. However, it should still be quite performant, +but there might be some limitations if called a very large number of times. +- Long-term it is likely that this function gets replaced with a sleeker interface. + +Example: +```julia +using Catalyst, OrdinaryDiffEq + +# Prepare `LatticeReactionSystem`s. +rs = @reaction_network begin + (k1,k2), X1 <--> X2 +end +tr = @transport_reaction D X1 +lrs = LatticeReactionSystem(rs, [tr], CartesianGrid((2,2))) + +# Create problems. +u0 = [:X1 => 1, :X2 => 2] +tspan = (0.0, 10.0) +ps = [:k1 => 1, :k2 => 2.0, :D => 0.1] + +oprob = ODEProblem(lrs1, u0, tspan, ps) +osol = solve(oprob1, Tsit5()) +get_lrs_vals(osol, :X1, lrs) # Returns the value of X1 at each time step. +get_lrs_vals(osol, :X1, lrs; t = 0.0:10.0) # Returns the value of X1 at times 0.0, 1.0, ..., 10.0 +``` +""" +function get_lrs_vals(sol, sp, lrs::LatticeReactionSystem; t = nothing) + # Figures out which species we wish to fetch information about. + (sp isa Symbol) && (sp = Catalyst._symbol_to_var(lrs, sp)) + sp_idx = findfirst(isequal(sp), species(lrs)) + sp_tot = length(species(lrs)) + + # Extracts the lattice and calls the next function. Masked grids (Array of Bools) are converted + # to sparse array using the same template size as we wish to shape the data to. + lattice = Catalyst.lattice(lrs) + if has_masked_lattice(lrs) + if grid_dims(lrs) == 3 + error("The `get_lrs_vals` function is not defined for systems based on 3d sparse arrays. Please raise an issue at the Catalyst GitHub site if this is something which would be useful to you.") + end + lattice = sparse(lattice) + end + get_lrs_vals(sol, lattice, t, sp_idx, sp_tot) +end + +# Function which handles the input in the case where `t` is `nothing` (i.e. return `sp`s value +# across all sample points). +function get_lrs_vals(sol, lattice, t::Nothing, sp_idx, sp_tot) + # ODE simulations contain, in each data point, all values in a single vector. Jump simulations + # instead in a matrix (NxM, where N is the number of species and M the number of vertices). We + # must consider each case separately. + if sol.prob isa ODEProblem + return [reshape_vals(vals[sp_idx:sp_tot:end], lattice) for vals in sol.u] + elseif sol.prob isa DiscreteProblem + return [reshape_vals(vals[sp_idx, :], lattice) for vals in sol.u] + else + error("Unknown type of solution provided to `get_lrs_vals`. Only ODE or Jump solutions are supported.") + end +end + +# Function which handles the input in the case where `t` is a range of values (i.e. return `sp`s +# value at all designated time points. +function get_lrs_vals( + sol, lattice, t::AbstractVector{T}, sp_idx, sp_tot) where {T <: Number} + if (minimum(t) < sol.t[1]) || (maximum(t) > sol.t[end]) + error("The range of the t values provided for sampling, ($(minimum(t)),$(maximum(t))) is not fully within the range of the simulation time span ($(sol.t[1]),$(sol.t[end])).") + end + + # ODE simulations contain, in each data point, all values in a single vector. Jump simulations + # instead in a matrix (NxM, where N is the number of species and M the number of vertices). We + # must consider each case separately. + if sol.prob isa ODEProblem + return [reshape_vals(sol(ti)[sp_idx:sp_tot:end], lattice) for ti in t] + elseif sol.prob isa DiscreteProblem + return [reshape_vals(sol(ti)[sp_idx, :], lattice) for ti in t] + else + error("Unknown type of solution provided to `get_lrs_vals`. Only ODE or Jump solutions are supported.") + end +end + +# Functions which in each sample point reshape the vector of values to the correct form (depending +# on the type of lattice used). +function reshape_vals(vals, lattice::CartesianGridRej{N, T}) where {N, T} + return reshape(vals, lattice.dims...) +end +function reshape_vals(vals, lattice::AbstractSparseArray{Bool, Int64, 1}) + return SparseVector(lattice.n, lattice.nzind, vals) +end +function reshape_vals(vals, lattice::AbstractSparseArray{Bool, Int64, 2}) + return SparseMatrixCSC(lattice.m, lattice.n, lattice.colptr, lattice.rowval, vals) +end +function reshape_vals(vals, lattice::DiGraph) + return vals +end diff --git a/src/spatial_reaction_systems/spatial_ODE_systems.jl b/src/spatial_reaction_systems/spatial_ODE_systems.jl index b967dd29fb..3dced1e573 100644 --- a/src/spatial_reaction_systems/spatial_ODE_systems.jl +++ b/src/spatial_reaction_systems/spatial_ODE_systems.jl @@ -1,304 +1,436 @@ -### Spatial ODE Functor Structures ### +### Spatial ODE Functor Structure ### -# Functor with information for the forcing function of a spatial ODE with spatial movement on a lattice. -struct LatticeTransportODEf{Q,R,S,T} - """The ODEFunction of the (non-spatial) reaction system which generated this function.""" - ofunc::Q - """The number of vertices.""" +# Functor with information about a spatial Lattice Reaction ODEs forcing and Jacobian functions. +# Also used as ODE Function input to corresponding `ODEProblem`. +struct LatticeTransportODEFunction{P, Q, R, S, T} + """ + The ODEFunction of the (non-spatial) ReactionSystem that generated this + LatticeTransportODEFunction instance. + """ + ofunc::P + """The lattice's number of vertices.""" num_verts::Int64 - """The number of species.""" + """The system's number of species.""" num_species::Int64 - """The values of the parameters which values are tied to vertexes.""" - vert_ps::Vector{Vector{R}} """ - Temporary vector. For parameters which values are identical across the lattice, - at some point these have to be converted of a length num_verts vector. - To avoid re-allocation they are written to this vector. + Stores an index for each heterogeneous vertex parameter (i.e. vertex parameter which value is + not identical across the lattice). Each index corresponds to its position in the full parameter + vector (`parameters(lrs)`). """ - work_vert_ps::Vector{R} + heterogeneous_vert_p_idxs::Vector{Int64} """ - For each parameter in vert_ps, its value is a vector with length either num_verts or 1. - To know whenever a parameter's value need expanding to the work_vert_ps array, its length needs checking. - This check is done once, and the value stored to this array. - This field (specifically) is an enumerate over that array. + The MTKParameters structure which corresponds to the non-spatial `ReactionSystem`. During + simulations, as we loop through each vertex, this is updated to correspond to the vertex + parameters of that specific vertex. """ - v_ps_idx_types::Vector{Bool} + mtk_ps::Q """ - A vector of pairs, with a value for each species with transportation. - The first value is the species index (in the species(::ReactionSystem) vector), - and the second is a vector with its transport rate values. - If the transport rate is uniform (across all edges), that value is the only value in the vector. - Else, there is one value for each edge in the lattice. + Stores a SymbolicIndexingInterface `setp` function for each heterogeneous vertex parameter (i.e. + vertex parameter whose value is not identical across the lattice). The `setp` function at index + i of `p_setters` corresponds to the parameter in index i of `heterogeneous_vert_p_idxs`. """ - transport_rates::Vector{Pair{Int64, Vector{R}}} + p_setters::R """ - A matrix, NxM, where N is the number of species with transportation and M the number of vertexes. - Each value is the total rate at which that species leaves that vertex - (e.g. for a species with constant diffusion rate D, in a vertex with n neighbours, this value is n*D). + A vector that stores, for each species with transportation, its transportation rate(s). + Each entry is a pair from (the index of) the transported species (in the `species(lrs)` vector) + to its transportation rate (each species only has a single transportation rate, the sum of all + its transportation reactions' rates). If the transportation rate is uniform across all edges, + stores a single value (in a size (1,1) sparse matrix). Otherwise, stores these in a sparse + matrix where value (i,j) is the species transportation rate from vertex i to vertex j. """ - leaving_rates::Matrix{R} - """An (enumerate'ed) iterator over all the edges of the lattice.""" - edges::S + transport_rates::Vector{Pair{Int64, SparseMatrixCSC{S, Int64}}} """ - The edge parameters used to create the spatial ODEProblem. Currently unused, - but will be needed to support changing these (e.g. due to events). - Contain one vector for each edge parameter (length one if uniform, else one value for each edge). + For each transport rate in transport_rates, its value is a (sparse) matrix with a size of either + (num_verts,num_verts) or (1,1). In the second case, the transportation rate is uniform across + all edges. To avoid having to check which case holds for each transportation rate, we store the + corresponding case in this value. `true` means that a species has a uniform transportation rate. """ - edge_ps::Vector{Vector{T}} - - function LatticeTransportODEf(ofunc::Q, vert_ps::Vector{Vector{R}}, transport_rates::Vector{Pair{Int64, Vector{R}}}, - edge_ps::Vector{Vector{T}}, lrs::LatticeReactionSystem) where {Q,R,T} - leaving_rates = zeros(length(transport_rates), lrs.num_verts) - for (s_idx, trpair) in enumerate(transport_rates) - rates = last(trpair) - for (e_idx, e) in enumerate(edges(lrs.lattice)) - # Updates the exit rate for species s_idx from vertex e.src - leaving_rates[s_idx, e.src] += get_component_value(rates, e_idx) - end - end - work_vert_ps = zeros(lrs.num_verts) - # 1 if ps are constant across the graph, 0 else. - v_ps_idx_types = map(vp -> length(vp) == 1, vert_ps) - eds = edges(lrs.lattice) - new{Q,R,typeof(eds),T}(ofunc, lrs.num_verts, lrs.num_species, vert_ps, work_vert_ps, - v_ps_idx_types, transport_rates, leaving_rates, eds, edge_ps) - end -end - -# Functor with information for the Jacobian function of a spatial ODE with spatial movement on a lattice. -struct LatticeTransportODEjac{Q,R,S,T} - """The ODEFunction of the (non-spatial) reaction system which generated this function.""" - ofunc::Q - """The number of vertices.""" - num_verts::Int64 - """The number of species.""" - num_species::Int64 - """The values of the parameters which values are tied to vertexes.""" - vert_ps::Vector{Vector{R}} + t_rate_idx_types::Vector{Bool} """ - Temporary vector. For parameters which values are identical across the lattice, - at some point these have to be converted of a length(num_verts) vector. - To avoid re-allocation they are written to this vector. + A matrix, NxM, where N is the number of species with transportation and M is the number of + vertices. Each value is the total rate at which that species leaves that vertex (e.g. for a + species with constant diffusion rate D, in a vertex with n neighbours, this value is n*D). """ - work_vert_ps::Vector{R} + leaving_rates::Matrix{S} + """An iterator over all the edges of the lattice.""" + edge_iterator::Vector{Pair{Int64, Int64}} """ - For each parameter in vert_ps, it either have length num_verts or 1. - To know whenever a parameter's value need expanding to the work_vert_ps array, - its length needs checking. This check is done once, and the value stored to this array. - This field (specifically) is an enumerate over that array. + The transport rates. This is a dense or sparse matrix (depending on what type of Jacobian is + used). """ - v_ps_idx_types::Vector{Bool} - """Whether the Jacobian is sparse or not.""" + jac_transport::T + """ Whether sparse jacobian representation is used. """ sparse::Bool - """The transport rates. Can be a dense matrix (for non-sparse) or as the "nzval" field if sparse.""" - jac_transport::S - """ - The edge parameters used to create the spatial ODEProblem. Currently unused, - but will be needed to support changing these (e.g. due to events). - Contain one vector for each edge parameter (length one if uniform, else one value for each edge). - """ - edge_ps::Vector{Vector{T}} - - function LatticeTransportODEjac(ofunc::R, vert_ps::Vector{Vector{S}}, lrs::LatticeReactionSystem, - jac_transport::Union{Nothing, SparseMatrixCSC{Float64, Int64}}, - edge_ps::Vector{Vector{T}}, sparse::Bool) where {R,S,T} - work_vert_ps = zeros(lrs.num_verts) - v_ps_idx_types = map(vp -> length(vp) == 1, vert_ps) - new{R,S,typeof(jac_transport),T}(ofunc, lrs.num_verts, lrs.num_species, vert_ps, - work_vert_ps, v_ps_idx_types, sparse, jac_transport, edge_ps) + """Remove when we add this as problem metadata""" + lrs::LatticeReactionSystem + + function LatticeTransportODEFunction(ofunc::P, ps::Vector{<:Pair}, + lrs::LatticeReactionSystem, sparse::Bool, + jac_transport::Union{Nothing, Matrix{S}, SparseMatrixCSC{S, Int64}}, + transport_rates::Vector{Pair{Int64, SparseMatrixCSC{S, Int64}}}) where {P, S} + # Computes `LatticeTransportODEFunction` functor fields. + heterogeneous_vert_p_idxs = make_heterogeneous_vert_p_idxs(ps, lrs) + mtk_ps, p_setters = make_mtk_ps_structs(ps, lrs, heterogeneous_vert_p_idxs) + t_rate_idx_types, leaving_rates = make_t_types_and_leaving_rates(transport_rates, + lrs) + + # Creates and returns the `LatticeTransportODEFunction` functor. + new{P, typeof(mtk_ps), typeof(p_setters), S, typeof(jac_transport)}(ofunc, + num_verts(lrs), num_species(lrs), heterogeneous_vert_p_idxs, mtk_ps, p_setters, + transport_rates, t_rate_idx_types, leaving_rates, Catalyst.edge_iterator(lrs), + jac_transport, sparse, lrs) + end +end + +# `LatticeTransportODEFunction` helper functions (re-used by rebuild function later on). + +# Creates a vector with the heterogeneous vertex parameters' indexes in the full parameter vector. +function make_heterogeneous_vert_p_idxs(ps, lrs) + p_dict = Dict(ps) + return findall((p_dict[p] isa Vector) && (length(p_dict[p]) > 1) + for p in parameters(lrs)) +end + +# Creates the MTKParameters structure and `p_setters` vector (which are used to manage +# the vertex parameter values during the simulations). +function make_mtk_ps_structs(ps, lrs, heterogeneous_vert_p_idxs) + p_dict = Dict(ps) + nonspatial_osys = complete(convert(ODESystem, reactionsystem(lrs))) + p_init = [p => p_dict[p][1] for p in parameters(nonspatial_osys)] + mtk_ps = MT.MTKParameters(nonspatial_osys, p_init) + p_setters = [MT.setp(nonspatial_osys, p) + for p in parameters(lrs)[heterogeneous_vert_p_idxs]] + return mtk_ps, p_setters +end + +# Computes the transport rate type vector and leaving rate matrix. +function make_t_types_and_leaving_rates(transport_rates, lrs) + t_rate_idx_types = [size(tr[2]) == (1, 1) for tr in transport_rates] + leaving_rates = zeros(length(transport_rates), num_verts(lrs)) + for (s_idx, tr_pair) in enumerate(transport_rates) + for e in Catalyst.edge_iterator(lrs) + # Updates the exit rate for species s_idx from vertex e.src. + leaving_rates[s_idx, e[1]] += get_transport_rate(tr_pair[2], e, + t_rate_idx_types[s_idx]) + end end + return t_rate_idx_types, leaving_rates +end + +### Spatial ODE Functor Functions ### + +# Defines the functor's effect when applied as a forcing function. +function (lt_ofun::LatticeTransportODEFunction)(du::AbstractVector, u, p, t) + # Updates for non-spatial reactions. + for vert_i in 1:(lt_ofun.num_verts) + # Gets the indices of all the species at vertex i. + idxs = get_indexes(vert_i, lt_ofun.num_species) + + # Updates the functors vertex parameter tracker (`mtk_ps`) to contain the vertex parameter + # values for vertex vert_i. Then evaluates the reaction contributions to du at vert_i. + update_mtk_ps!(lt_ofun, p, vert_i) + lt_ofun.ofunc((@view du[idxs]), (@view u[idxs]), lt_ofun.mtk_ps, t) + end + + # s_idx is the species index among transport species, s is the index among all species. + # rates are the species' transport rates. + for (s_idx, (s, rates)) in enumerate(lt_ofun.transport_rates) + # Rate for leaving source vertex vert_i. + for vert_i in 1:(lt_ofun.num_verts) + idx_src = get_index(vert_i, s, lt_ofun.num_species) + du[idx_src] -= lt_ofun.leaving_rates[s_idx, vert_i] * u[idx_src] + end + # Add rates for entering a destination vertex via an incoming edge. + for e in lt_ofun.edge_iterator + idx_src = get_index(e[1], s, lt_ofun.num_species) + idx_dst = get_index(e[2], s, lt_ofun.num_species) + du[idx_dst] += get_transport_rate(rates, e, lt_ofun.t_rate_idx_types[s_idx]) * + u[idx_src] + end + end +end + +# Defines the functor's effect when applied as a Jacobian. +function (lt_ofun::LatticeTransportODEFunction)(J::AbstractMatrix, u, p, t) + # Resets the Jacobian J's values. + J .= 0.0 + + # Update the Jacobian from non-spatial reaction terms. + for vert_i in 1:(lt_ofun.num_verts) + # Gets the indices of all the species at vertex i. + idxs = get_indexes(vert_i, lt_ofun.num_species) + + # Updates the functors vertex parameter tracker (`mtk_ps`) to contain the vertex parameter + # values for vertex vert_i. Then evaluates the reaction contributions to J at vert_i. + update_mtk_ps!(lt_ofun, p, vert_i) + lt_ofun.ofunc.jac((@view J[idxs, idxs]), (@view u[idxs]), lt_ofun.mtk_ps, t) + end + + # Updates for the spatial reactions (adds the Jacobian values from the transportation reactions). + J .+= lt_ofun.jac_transport end ### ODEProblem ### # Creates an ODEProblem from a LatticeReactionSystem. function DiffEqBase.ODEProblem(lrs::LatticeReactionSystem, u0_in, tspan, - p_in = DiffEqBase.NullParameters(), args...; - jac = false, sparse = false, - name = nameof(lrs), include_zero_odes = true, - combinatoric_ratelaws = get_combinatoric_ratelaws(lrs.rs), - remove_conserved = false, checks = false, kwargs...) + p_in = DiffEqBase.NullParameters(), args...; + jac = false, sparse = false, + name = nameof(lrs), include_zero_odes = true, + combinatoric_ratelaws = get_combinatoric_ratelaws(reactionsystem(lrs)), + remove_conserved = false, checks = false, kwargs...) if !is_transport_system(lrs) error("Currently lattice ODE simulations are only supported when all spatial reactions are TransportReactions.") end - - # Converts potential symmaps to varmaps - # Vertex and edge parameters may be given in a tuple, or in a common vector, making parameter case complicated. + + # Converts potential symmaps to varmaps. u0_in = symmap_to_varmap(lrs, u0_in) - p_in = (p_in isa Tuple{<:Any,<:Any}) ? - (symmap_to_varmap(lrs, p_in[1]),symmap_to_varmap(lrs, p_in[2])) : - symmap_to_varmap(lrs, p_in) + p_in = symmap_to_varmap(lrs, p_in) # Converts u0 and p to their internal forms. + # u0 is simply a vector with all the species' initial condition values across all vertices. # u0 is [spec 1 at vert 1, spec 2 at vert 1, ..., spec 1 at vert 2, ...]. - u0 = lattice_process_u0(u0_in, species(lrs), lrs.num_verts) - # Both vert_ps and edge_ps becomes vectors of vectors. Each have 1 element for each parameter. - # These elements are length 1 vectors (if the parameter is uniform), - # or length num_verts/nE, with unique values for each vertex/edge (for vert_ps/edge_ps, respectively). - vert_ps, edge_ps = lattice_process_p(p_in, vertex_parameters(lrs), edge_parameters(lrs), lrs) - - # Creates ODEProblem. - ofun = build_odefunction(lrs, vert_ps, edge_ps, jac, sparse, name, include_zero_odes, - combinatoric_ratelaws, remove_conserved, checks) - return ODEProblem(ofun, u0, tspan, vert_ps, args...; kwargs...) + u0 = lattice_process_u0(u0_in, species(lrs), lrs) + # vert_ps and `edge_ps` are vector maps, taking each parameter's Symbolics representation to its value(s). + # vert_ps values are vectors. Here, index (i) is a parameter's value in vertex i. + # edge_ps values are sparse matrices. Here, index (i,j) is a parameter's value in the edge from vertex i to vertex j. + # Uniform vertex/edge parameters store only a single value (a length 1 vector, or size 1x1 sparse matrix). + # In the `ODEProblem` vert_ps and edge_ps are merged (but for building the ODEFunction, they are separate). + vert_ps, edge_ps = lattice_process_p(p_in, vertex_parameters(lrs), + edge_parameters(lrs), lrs) + + # Creates the ODEFunction. + ofun = build_odefunction(lrs, vert_ps, edge_ps, jac, sparse, name, include_zero_odes, + combinatoric_ratelaws, remove_conserved, checks) + + # Combines `vert_ps` and `edge_ps` to a single vector with values only (not a map). Creates ODEProblem. + pval_dict = Dict([vert_ps; edge_ps]) + ps = [pval_dict[p] for p in parameters(lrs)] + return ODEProblem(ofun, u0, tspan, ps, args...; kwargs...) end # Builds an ODEFunction for a spatial ODEProblem. -function build_odefunction(lrs::LatticeReactionSystem, vert_ps::Vector{Vector{T}}, - edge_ps::Vector{Vector{T}}, jac::Bool, sparse::Bool, - name, include_zero_odes, combinatoric_ratelaws, remove_conserved, checks) where {T} - if remove_conserved - error("Removal of conserved quantities is currently not supported for `LatticeReactionSystem`s") +function build_odefunction(lrs::LatticeReactionSystem, vert_ps::Vector{Pair{R, Vector{T}}}, + edge_ps::Vector{Pair{S, SparseMatrixCSC{T, Int64}}}, + jac::Bool, sparse::Bool, name, include_zero_odes, combinatoric_ratelaws, + remove_conserved, checks) where {R, S, T} + # Error check. + if remove_conserved + throw(ArgumentError("Removal of conserved quantities is currently not supported for `LatticeReactionSystem`s")) end - # Creates a map, taking (the index in species(lrs) each species (with transportation) - # to its transportation rate (uniform or one value for each edge). - transport_rates = make_sidxs_to_transrate_map(vert_ps, edge_ps, lrs) - - # Prepares the Jacobian and forcing functions (depending on jacobian and sparsity selection). - osys = complete(convert(ODESystem, lrs.rs; name, combinatoric_ratelaws, include_zero_odes, checks)) - if jac - # `build_jac_prototype` currently assumes a sparse (non-spatial) Jacobian. Hence compute this. - # `LatticeTransportODEjac` currently assumes a dense (non-spatial) Jacobian. Hence compute this. - # Long term we could write separate version of these functions for generic input. - ofunc_dense = ODEFunction(osys; jac = true, sparse = false) - ofunc_sparse = ODEFunction(osys; jac = true, sparse = true) - jac_vals = build_jac_prototype(ofunc_sparse.jac_prototype, transport_rates, lrs; set_nonzero = true) - if sparse - f = LatticeTransportODEf(ofunc_sparse, vert_ps, transport_rates, edge_ps, lrs) - jac_vals = build_jac_prototype(ofunc_sparse.jac_prototype, transport_rates, lrs; set_nonzero = true) - J = LatticeTransportODEjac(ofunc_dense, vert_ps, lrs, jac_vals, edge_ps, true) - jac_prototype = jac_vals - else - f = LatticeTransportODEf(ofunc_dense, vert_ps, transport_rates, edge_ps, lrs) - J = LatticeTransportODEjac(ofunc_dense, vert_ps, lrs, jac_vals, edge_ps, false) - jac_prototype = nothing - end + # Prepares the inputs to the `LatticeTransportODEFunction` functor. + osys = complete(convert(ODESystem, reactionsystem(lrs); + name, combinatoric_ratelaws, include_zero_odes, checks)) + ofunc_dense = ODEFunction(osys; jac = true, sparse = false) + ofunc_sparse = ODEFunction(osys; jac = true, sparse = true) + transport_rates = make_sidxs_to_transrate_map(vert_ps, edge_ps, lrs) + + # Depending on Jacobian and sparsity options, compute the Jacobian transport matrix and prototype. + if !sparse && !jac + jac_transport = nothing + jac_prototype = nothing else - if sparse - ofunc_sparse = ODEFunction(osys; jac = false, sparse = true) - f = LatticeTransportODEf(ofunc_sparse, vert_ps, transport_rates, edge_ps, lrs) - jac_prototype = build_jac_prototype(ofunc_sparse.jac_prototype, transport_rates, lrs; set_nonzero = false) - else - ofunc_dense = ODEFunction(osys; jac = false, sparse = false) - f = LatticeTransportODEf(ofunc_dense, vert_ps, transport_rates, edge_ps, lrs) - jac_prototype = nothing - end - J = nothing + jac_sparse = build_jac_prototype(ofunc_sparse.jac_prototype, transport_rates, lrs; + set_nonzero = jac) + jac_dense = Matrix(jac_sparse) + jac_transport = (jac ? (sparse ? jac_sparse : jac_dense) : nothing) + jac_prototype = (sparse ? jac_sparse : nothing) end - return ODEFunction(f; jac = J, jac_prototype = jac_prototype) + # Creates the `LatticeTransportODEFunction` functor (if `jac`, sets it as the Jacobian as well). + f = LatticeTransportODEFunction(ofunc_dense, [vert_ps; edge_ps], lrs, sparse, + jac_transport, transport_rates) + J = (jac ? f : nothing) + + # Extracts the `Symbol` form for parameters (but not species). Creates and returns the `ODEFunction`. + paramsyms = [MT.getname(p) for p in parameters(lrs)] + sys = SciMLBase.SymbolCache([], paramsyms, []) + return ODEFunction(f; jac = J, jac_prototype, sys) end -# Builds a jacobian prototype. If requested, populate it with the Jacobian's (constant) values as well. -function build_jac_prototype(ns_jac_prototype::SparseMatrixCSC{Float64, Int64}, trans_rates, - lrs::LatticeReactionSystem; set_nonzero = false) - # Finds the indexes of the transport species, and the species with transport only (and no non-spatial dynamics). - trans_species = first.(trans_rates) - trans_only_species = filter(s_idx -> !Base.isstored(ns_jac_prototype, s_idx, s_idx), trans_species) +# Builds a Jacobian prototype. +# If requested, populate it with the constant values of the Jacobian's transportation part. +function build_jac_prototype(ns_jac_prototype::SparseMatrixCSC{Float64, Int64}, + transport_rates::Vector{Pair{Int64, SparseMatrixCSC{T, Int64}}}, + lrs::LatticeReactionSystem; set_nonzero = false) where {T} + # Finds the indices of both the transport species, + # and the species with transport only (that is, with no non-spatial dynamics but with spatial dynamics). + trans_species = [tr[1] for tr in transport_rates] + trans_only_species = filter(s_idx -> !Base.isstored(ns_jac_prototype, s_idx, s_idx), + trans_species) - # Finds the indexes of all terms in the non-spatial jacobian. + # Finds the indices of all terms in the non-spatial jacobian. ns_jac_prototype_idxs = findnz(ns_jac_prototype) ns_i_idxs = ns_jac_prototype_idxs[1] ns_j_idxs = ns_jac_prototype_idxs[2] - # Prepares vectors to store i and j indexes of Jacobian entries. + # Prepares vectors to store i and j indices of Jacobian entries. idx = 1 - num_entries = lrs.num_verts * length(ns_i_idxs) + - lrs.num_edges * (length(trans_only_species) + length(trans_species)) + num_entries = num_verts(lrs) * length(ns_i_idxs) + + num_edges(lrs) * (length(trans_only_species) + length(trans_species)) i_idxs = Vector{Int}(undef, num_entries) j_idxs = Vector{Int}(undef, num_entries) - # Indexes of elements due to non-spatial dynamics. - for vert in 1:lrs.num_verts + # Indices of elements caused by non-spatial dynamics. + for vert in 1:num_verts(lrs) for n in 1:length(ns_i_idxs) - i_idxs[idx] = get_index(vert, ns_i_idxs[n], lrs.num_species) - j_idxs[idx] = get_index(vert, ns_j_idxs[n], lrs.num_species) + i_idxs[idx] = get_index(vert, ns_i_idxs[n], num_species(lrs)) + j_idxs[idx] = get_index(vert, ns_j_idxs[n], num_species(lrs)) idx += 1 end end - # Indexes of elements due to spatial dynamics. - for e in edges(lrs.lattice) - # Indexes due to terms for a species leaves its current vertex (but does not have + # Indices of elements caused by spatial dynamics. + for e in edge_iterator(lrs) + # Indexes due to terms for a species leaving its source vertex (but does not have # non-spatial dynamics). If the non-spatial Jacobian is fully dense, these would already # be accounted for. for s_idx in trans_only_species - i_idxs[idx] = get_index(e.src, s_idx, lrs.num_species) + i_idxs[idx] = get_index(e[1], s_idx, num_species(lrs)) j_idxs[idx] = i_idxs[idx] idx += 1 end - # Indexes due to terms for species arriving into a new vertex. + # Indexes due to terms for species arriving into a destination vertex. for s_idx in trans_species - i_idxs[idx] = get_index(e.src, s_idx, lrs.num_species) - j_idxs[idx] = get_index(e.dst, s_idx, lrs.num_species) + i_idxs[idx] = get_index(e[1], s_idx, num_species(lrs)) + j_idxs[idx] = get_index(e[2], s_idx, num_species(lrs)) idx += 1 end end - # Create sparse jacobian prototype with 0-valued entries. - jac_prototype = sparse(i_idxs, j_idxs, zeros(num_entries)) - - # Set element values. - if set_nonzero - for (s, rates) in trans_rates, (e_idx, e) in enumerate(edges(lrs.lattice)) - idx_src = get_index(e.src, s, lrs.num_species) - idx_dst = get_index(e.dst, s, lrs.num_species) - val = get_component_value(rates, e_idx) - - # Term due to species leaving source vertex. - jac_prototype[idx_src, idx_src] -= val - - # Term due to species arriving to destination vertex. - jac_prototype[idx_src, idx_dst] += val - end - end + # Create a sparse Jacobian prototype with 0-valued entries. If requested, + # updates values with non-zero entries. + jac_prototype = sparse(i_idxs, j_idxs, zeros(T, num_entries)) + set_nonzero && set_jac_transport_values!(jac_prototype, transport_rates, lrs) return jac_prototype end -# Defines the forcing functor's effect on the (spatial) ODE system. -function (f_func::LatticeTransportODEf)(du, u, p, t) - # Updates for non-spatial reactions. - for vert_i in 1:(f_func.num_verts) - # gets the indices of species at vertex i - idxs = get_indexes(vert_i, f_func.num_species) - - # vector of vertex ps at vert_i - vert_i_ps = view_vert_ps_vector!(f_func.work_vert_ps, p, vert_i, enumerate(f_func.v_ps_idx_types)) - - # evaluate reaction contributions to du at vert_i - f_func.ofunc((@view du[idxs]), (@view u[idxs]), vert_i_ps, t) - end +# For a Jacobian prototype with zero-valued entries. Set entry values according to a set of +# transport reaction values. +function set_jac_transport_values!(jac_prototype, transport_rates, lrs) + for (s, rates) in transport_rates, e in edge_iterator(lrs) + idx_src = get_index(e[1], s, num_species(lrs)) + idx_dst = get_index(e[2], s, num_species(lrs)) + val = get_transport_rate(rates, e, size(rates) == (1, 1)) - # s_idx is species index among transport species, s is index among all species - # rates are the species' transport rates - for (s_idx, (s, rates)) in enumerate(f_func.transport_rates) - # Rate for leaving vert_i - for vert_i in 1:(f_func.num_verts) - idx = get_index(vert_i, s, f_func.num_species) - du[idx] -= f_func.leaving_rates[s_idx, vert_i] * u[idx] - end - # Add rates for entering a given vertex via an incoming edge - for (e_idx, e) in enumerate(f_func.edges) - idx_dst = get_index(e.dst, s, f_func.num_species) - idx_src = get_index(e.src, s, f_func.num_species) - du[idx_dst] += get_component_value(rates, e_idx) * u[idx_src] - end + # Term due to species leaving source vertex. + jac_prototype[idx_src, idx_src] -= val + + # Term due to species arriving to destination vertex. + jac_prototype[idx_src, idx_dst] += val end end -# Defines the jacobian functor's effect on the (spatial) ODE system. -function (jac_func::LatticeTransportODEjac)(J, u, p, t) - J .= 0.0 +### Functor Updating Functionality ### + +""" + rebuild_lat_internals!(sciml_struct) + +Rebuilds the internal functions for simulating a LatticeReactionSystem. Wenever a problem or +integrator has had its parameter values updated, this function should be called for the update to +be taken into account. For ODE simulations, `rebuild_lat_internals!` needs only to be called when +- An edge parameter has been updated. +- When a parameter with spatially homogeneous values has been given spatially heterogeneous values +(or vice versa). + +Arguments: +- `sciml_struct`: The problem (e.g. an `ODEProblem`) or an integrator which we wish to rebuild. + +Notes: +- Currently does not work for `DiscreteProblem`s, `JumpProblem`s, or their integrators. +- The function is not built with performance in mind, so avoid calling it multiple times in +performance-critical applications. + +Example: +```julia +# Creates an initial `ODEProblem` +rs = @reaction_network begin + (k1,k2), X1 <--> X2 +end +tr = @transport_reaction D X1 +grid = CartesianGrid((2,2)) +lrs = LatticeReactionSystem(rs, [tr], grid) - # Update the Jacobian from reaction terms - for vert_i in 1:(jac_func.num_verts) - idxs = get_indexes(vert_i, jac_func.num_species) - vert_ps = view_vert_ps_vector!(jac_func.work_vert_ps, p, vert_i, enumerate(jac_func.v_ps_idx_types)) - jac_func.ofunc.jac((@view J[idxs, idxs]), (@view u[idxs]), vert_ps, t) +u0 = [:X1 => 2, :X2 => [5 6; 7 8]] +tspan = (0.0, 10.0) +ps = [:k1 => 1.5, :k2 => [1.0 1.5; 2.0 3.5], :D => 0.1] + +oprob = ODEProblem(lrs, u0, tspan, ps) + +# Updates parameter values. +oprob.ps[:ks] = [2.0 2.5; 3.0 4.5] +oprob.ps[:D] = 0.05 + +# Rebuilds `ODEProblem` to make changes have an effect. +rebuild_lat_internals!(oprob) +``` +""" +function rebuild_lat_internals!(oprob::ODEProblem) + rebuild_lat_internals!(oprob.f.f, oprob.p, oprob.f.f.lrs) +end + +# Function for rebuilding a `LatticeReactionSystem` integrator after it has been updated. +# We could specify `integrator`'s type, but that required adding OrdinaryDiffEq as a direct +# dependency of Catalyst. +function rebuild_lat_internals!(integrator) + rebuild_lat_internals!(integrator.f.f, integrator.p, integrator.f.f.lrs) +end + +# Function which rebuilds a `LatticeTransportODEFunction` functor for a new parameter set. +function rebuild_lat_internals!(lt_ofun::LatticeTransportODEFunction, ps_new, + lrs::LatticeReactionSystem) + # Computes Jacobian properties. + jac = !isnothing(lt_ofun.jac_transport) + sparse = lt_ofun.sparse + + # Recreates the new parameters on the requisite form. + ps_new = [(length(p) == 1) ? p[1] : p for p in deepcopy(ps_new)] + ps_new = [p => p_val for (p, p_val) in zip(parameters(lrs), deepcopy(ps_new))] + vert_ps, edge_ps = lattice_process_p(ps_new, vertex_parameters(lrs), + edge_parameters(lrs), lrs) + ps_new = [vert_ps; edge_ps] + + # Creates the new transport rates and transport Jacobian part. + transport_rates = make_sidxs_to_transrate_map(vert_ps, edge_ps, lrs) + if !isnothing(lt_ofun.jac_transport) + lt_ofun.jac_transport .= 0.0 + set_jac_transport_values!(lt_ofun.jac_transport, transport_rates, lrs) end - # Updates for the spatial reactions (adds the Jacobian values from the diffusion reactions). - J .+= jac_func.jac_transport -end \ No newline at end of file + # Computes new field values. + heterogeneous_vert_p_idxs = make_heterogeneous_vert_p_idxs(ps_new, lrs) + mtk_ps, p_setters = make_mtk_ps_structs(ps_new, lrs, heterogeneous_vert_p_idxs) + t_rate_idx_types, leaving_rates = make_t_types_and_leaving_rates(transport_rates, lrs) + + # Updates functor fields. + replace_vec!(lt_ofun.heterogeneous_vert_p_idxs, heterogeneous_vert_p_idxs) + replace_vec!(lt_ofun.p_setters, p_setters) + replace_vec!(lt_ofun.transport_rates, transport_rates) + replace_vec!(lt_ofun.t_rate_idx_types, t_rate_idx_types) + lt_ofun.leaving_rates .= leaving_rates + + # Updating the `MTKParameters` structure is a bit more complicated. + p_dict = Dict(ps_new) + osys = complete(convert(ODESystem, reactionsystem(lrs))) + for p in parameters(osys) + MT.setp(osys, p)(lt_ofun.mtk_ps, (p_dict[p] isa Number) ? p_dict[p] : p_dict[p][1]) + end + + return nothing +end + +# Specialised function which replaced one vector in another in a mutating way. +# Required to update the vectors in the `LatticeTransportODEFunction` functor. +function replace_vec!(vec1, vec2) + l1 = length(vec1) + l2 = length(vec2) + + # Updates the fields, then deletes superfluous fields, or additional ones. + for (i, v) in enumerate(vec2[1:min(l1, l2)]) + vec1[i] = v + end + foreach(idx -> deleteat!(vec1, idx), l1:-1:(l2 + 1)) + foreach(val -> push!(vec1, val), vec2[(l1 + 1):l2]) +end diff --git a/src/spatial_reaction_systems/spatial_reactions.jl b/src/spatial_reaction_systems/spatial_reactions.jl index 581bd7f735..204d94992a 100644 --- a/src/spatial_reaction_systems/spatial_reactions.jl +++ b/src/spatial_reaction_systems/spatial_reactions.jl @@ -3,7 +3,7 @@ # Abstract spatial reaction structures. abstract type AbstractSpatialReaction end -### EdgeParameter Metadata ### +### Edge Parameter Metadata ### # Implements the edgeparameter metadata field. struct EdgeParameter end @@ -22,15 +22,15 @@ end # A transport reaction. These are simple to handle, and should cover most types of spatial reactions. # Only permit constant rates (possibly consisting of several parameters). struct TransportReaction <: AbstractSpatialReaction - """The rate function (excluding mass action terms). Currently only constants supported""" + """The rate function (excluding mass action terms). Currently, only constants supported""" rate::Any """The species that is subject to diffusion.""" species::BasicSymbolic{Real} # Creates a diffusion reaction. function TransportReaction(rate, species) - if any(!ModelingToolkit.isparameter(var) for var in ModelingToolkit.get_variables(rate)) - error("TransportReaction rate contains variables: $(filter(var -> !ModelingToolkit.isparameter(var), ModelingToolkit.get_variables(rate))). The rate must consist of parameters only.") + if any(!MT.isparameter(var) for var in MT.get_variables(rate)) + error("TransportReaction rate contains variables: $(filter(var -> !MT.isparameter(var), MT.get_variables(rate))). The rate must consist of parameters only.") end new(rate, species.val) end @@ -40,7 +40,7 @@ function TransportReactions(transport_reactions) [TransportReaction(tr[1], tr[2]) for tr in transport_reactions] end -# Macro for creating a transport reaction. +# Macro for creating a TransportReactions. macro transport_reaction(rateex::ExprValues, species::ExprValues) make_transport_reaction(MacroTools.striplines(rateex), species) end @@ -62,6 +62,11 @@ function make_transport_reaction(rateex, species) iv = :(@variables $(DEFAULT_IV_SYM)) trxexpr = :(TransportReaction($rateex, $species)) + # Appends `edgeparameter` metadata to all declared parameters. + for idx in 4:2:(2 + 2 * length(parameters)) + insert!(pexprs.args, idx, :([edgeparameter = true])) + end + quote $pexprs $iv @@ -70,39 +75,43 @@ function make_transport_reaction(rateex, species) end end -# Gets the parameters in a transport reaction. +# Gets the parameters in a TransportReactions. ModelingToolkit.parameters(tr::TransportReaction) = Symbolics.get_variables(tr.rate) -# Gets the species in a transport reaction. +# Gets the species in a TransportReactions. spatial_species(tr::TransportReaction) = [tr.species] -# Checks that a transport reaction is valid for a given reaction system. -function check_spatial_reaction_validity(rs::ReactionSystem, tr::TransportReaction; edge_parameters=[]) +# Checks that a TransportReactions is valid for a given reaction system. +function check_spatial_reaction_validity(rs::ReactionSystem, tr::TransportReaction; + edge_parameters = []) # Checks that the species exist in the reaction system. # (ODE simulation code becomes difficult if this is not required, - # as non-spatial jacobian and f function generated from rs is of wrong size). - if !any(isequal(tr.species), species(rs)) + # as non-spatial jacobian and f function generated from rs are of the wrong size). + if !any(isequal(tr.species), species(rs)) error("Currently, species used in TransportReactions must have previously been declared within the non-spatial ReactionSystem. This is not the case for $(tr.species).") end # Checks that the rate does not depend on species. rate_vars = ModelingToolkit.getname.(Symbolics.get_variables(tr.rate)) - if !isempty(intersect(ModelingToolkit.getname.(species(rs)), rate_vars)) + if !isempty(intersect(ModelingToolkit.getname.(species(rs)), rate_vars)) error("The following species were used in rates of a transport reactions: $(setdiff(ModelingToolkit.getname.(species(rs)), rate_vars)).") end # Checks that the species does not exist in the system with different metadata. - if any(isequal(tr.species, s) && !isequivalent(tr.species, s) for s in species(rs)) - error("A transport reaction used a species, $(tr.species), with metadata not matching its lattice reaction system. Please fetch this species from the reaction system and used in transport reaction creation.") + if any(isequal(tr.species, s) && !isequivalent(tr.species, s) for s in species(rs)) + error("A transport reaction used a species, $(tr.species), with metadata not matching its lattice reaction system. Please fetch this species from the reaction system and use it during transport reaction creation.") end - if any(isequal(rs_p, tr_p) && !isequivalent(rs_p, tr_p) - for rs_p in parameters(rs), tr_p in Symbolics.get_variables(tr.rate)) - error("A transport reaction used a parameter with metadata not matching its lattice reaction system. Please fetch this parameter from the reaction system and used in transport reaction creation.") + # No `for` loop, just weird formatting by the formatter. + if any(isequal(rs_p, tr_p) && !isequivalent(rs_p, tr_p) + for rs_p in parameters(rs), tr_p in Symbolics.get_variables(tr.rate)) + error("A transport reaction used a parameter with metadata not matching its lattice reaction system. Please fetch this parameter from the reaction system and use it during transport reaction creation.") end - # Checks that no edge parameter occur among rates of non-spatial reactions. - if any(!isempty(intersect(Symbolics.get_variables(r.rate), edge_parameters)) for r in reactions(rs)) - error("Edge paramter(s) were found as a rate of a non-spatial reaction.") + # Checks that no edge parameter occurs among rates of non-spatial reactions. + # No `for` loop, just weird formatting by the formatter. + if any(!isempty(intersect(Symbolics.get_variables(r.rate), edge_parameters)) + for r in reactions(rs)) + error("Edge parameter(s) were found as a rate of a non-spatial reaction.") end end @@ -111,9 +120,13 @@ end const ep_metadata = Catalyst.EdgeParameter => true function isequivalent(sym1, sym2) isequal(sym1, sym2) || (return false) - any((md1 != ep_metadata) && !(md1 in sym2.metadata) for md1 in sym1.metadata) && (return false) - any((md2 != ep_metadata) && !(md2 in sym1.metadata) for md2 in sym2.metadata) && (return false) - (typeof(sym1) != typeof(sym2)) && (return false) + if any((md1 != ep_metadata) && !(md1 in sym2.metadata) for md1 in sym1.metadata) + return false + elseif any((md2 != ep_metadata) && !(md2 in sym1.metadata) for md2 in sym2.metadata) + return false + elseif typeof(sym1) != typeof(sym2) + return false + end return true end @@ -136,7 +149,8 @@ function hash(tr::TransportReaction, h::UInt) end ### Utility ### -# Loops through a rate and extract all parameters. + +# Loops through a rate and extracts all parameters. function find_parameters_in_rate!(parameters, rateex::ExprValues) if rateex isa Symbol if rateex in [:t, :∅, :im, :nothing, CONSERVED_CONSTANT_SYMBOL] @@ -145,10 +159,10 @@ function find_parameters_in_rate!(parameters, rateex::ExprValues) push!(parameters, rateex) end elseif rateex isa Expr - # Note, this (correctly) skips $(...) expressions + # Note, this (correctly) skips $(...) expressions. for i in 2:length(rateex.args) find_parameters_in_rate!(parameters, rateex.args[i]) end end - nothing -end \ No newline at end of file + return nothing +end diff --git a/src/spatial_reaction_systems/utility.jl b/src/spatial_reaction_systems/utility.jl index 03271562e8..e00f753d8c 100644 --- a/src/spatial_reaction_systems/utility.jl +++ b/src/spatial_reaction_systems/utility.jl @@ -3,295 +3,312 @@ # Defines _symbol_to_var, but where the system is a LRS. Required to make symmapt_to_varmap to work. function _symbol_to_var(lrs::LatticeReactionSystem, sym) # Checks if sym is a parameter. - p_idx = findfirst(sym==p_sym for p_sym in ModelingToolkit.getname.(parameters(lrs))) + p_idx = findfirst(sym == p_sym for p_sym in ModelingToolkit.getname.(parameters(lrs))) isnothing(p_idx) || return parameters(lrs)[p_idx] # Checks if sym is a species. - s_idx = findfirst(sym==s_sym for s_sym in ModelingToolkit.getname.(species(lrs))) + s_idx = findfirst(sym == s_sym for s_sym in ModelingToolkit.getname.(species(lrs))) isnothing(s_idx) || return species(lrs)[s_idx] error("Could not find property parameter/species $sym in lattice reaction system.") end -# From u0 input, extracts their values and store them in the internal format. -# Internal format: a vector on the form [spec 1 at vert 1, spec 2 at vert 1, ..., spec 1 at vert 2, ...]). -function lattice_process_u0(u0_in, u0_syms, num_verts) - # u0 values can be given in various forms. This converts it to a Vector{Vector{}} form. - # Top-level vector: Contains one vector for each species. - # Second-level vector: contain one value if species uniform across lattice, else one value for each vertex). - u0 = lattice_process_input(u0_in, u0_syms, num_verts) +# From u0 input, extract their values and store them in the internal format. +# Internal format: a vector on the form [spec 1 at vert 1, spec 2 at vert 1, ..., spec 1 at vert 2, ...]). +function lattice_process_u0(u0_in, u0_syms::Vector, lrs::LatticeReactionSystem) + # u0 values can be given in various forms. This converts it to a Vector{Pair{Symbolics,...}} form. + # Top-level vector: Maps each species to its value(s). + u0 = lattice_process_input(u0_in, u0_syms) - # Perform various error checks on the (by the user provided) initial conditions. - check_vector_lengths(u0, length(u0_syms), num_verts) + # Species' initial condition values can be given in different forms (also depending on the lattice). + # This converts each species's values to a Vector. In it, for species with uniform initial conditions, + # it holds that value only. For spatially heterogeneous initial conditions, + # the vector has the same length as the number of vertices (storing one value for each). + u0 = vertex_value_map(u0, lrs) - # Converts the Vector{Vector{}} format to a single Vector (with one values for each species and vertex). - expand_component_values(u0, num_verts) + # Converts the initial condition to a single Vector (with one value for each species and vertex). + return expand_component_values([entry[2] for entry in u0], num_verts(lrs)) end -# From p input, splits it into diffusion parameters and compartment parameters. +# From a parameter input, split it into vertex parameters and edge parameters. # Store these in the desired internal format. -function lattice_process_p(p_in, p_vertex_syms, p_edge_syms, lrs::LatticeReactionSystem) - # If the user provided parameters as a single map (mixing vertex and edge parameters): - # Split into two separate vectors. - vert_ps_in, edge_ps_in = split_parameters(p_in, p_vertex_syms, p_edge_syms) +function lattice_process_p(ps_in, ps_vertex_syms::Vector, + ps_edge_syms::Vector, lrs::LatticeReactionSystem) + # p values can be given in various forms. This converts it to a Vector{Pair{Symbolics,...}} form. + # Top-level vector: Maps each parameter to its value(s). + # Second-level: Contains either a vector (vertex parameters) or a sparse matrix (edge parameters). + # For uniform parameters these have size 1/(1,1). Else, they have size num_verts/(num_verts,num_verts). + ps = lattice_process_input(ps_in, [ps_vertex_syms; ps_edge_syms]) - # Parameter values can be given in various forms. This converts it to the Vector{Vector{}} form. - vert_ps = lattice_process_input(vert_ps_in, p_vertex_syms, lrs.num_verts) - - # Parameter values can be given in various forms. This converts it to the Vector{Vector{}} form. - edge_ps = lattice_process_input(edge_ps_in, p_edge_syms, lrs.num_edges) - - # If the lattice defined as (N edge) undirected graph, and we provides N/2 values for some edge parameter: - # Presume they want to expand that parameters value so it has the same value in both directions. - lrs.init_digraph || duplicate_trans_params!(edge_ps, lrs) - - # Perform various error checks on the (by the user provided) vertex and edge parameters. - check_vector_lengths(vert_ps, length(p_vertex_syms), lrs.num_verts) - check_vector_lengths(edge_ps, length(p_edge_syms), lrs.num_edges) + # Split the parameter vector into one for vertex parameters and one for edge parameters. + # Next, convert their values to the correct form (vectors for vert_ps and sparse matrices for edge_ps). + vert_ps, edge_ps = split_parameters(ps, ps_vertex_syms, ps_edge_syms) + vert_ps = vertex_value_map(vert_ps, lrs) + edge_ps = edge_value_map(edge_ps, lrs) return vert_ps, edge_ps end -# Splits parameters into those for the vertexes and those for the edges. +# The input (parameters or initial conditions) may either be a dictionary (symbolics to value(s).) +# or a map (in vector or tuple form) from symbolics to value(s). This converts the input to a +# (Vector) map from symbolics to value(s), where the entries have the same order as `syms`. +function lattice_process_input(input::Dict{<:Any, T}, syms::Vector) where {T} + # Error checks + if !isempty(setdiff(keys(input), syms)) + throw(ArgumentError("You have provided values for the following unrecognised parameters/initial conditions: $(setdiff(keys(input), syms)).")) + end + if !isempty(setdiff(syms, keys(input))) + throw(ArgumentError("You have not provided values for the following parameters/initial conditions: $(setdiff(syms, keys(input))).")) + end -# If they are already split, return that. -split_parameters(ps::Tuple{<:Any, <:Any}, args...) = ps -# Providing parameters to a spatial reaction system as a single vector of values (e.g. [1.0, 4.0, 0.1]) is not allowed. -# Either use tuple (e.g. ([1.0, 4.0], [0.1])) or map format (e.g. [A => 1.0, B => 4.0, D => 0.1]). -function split_parameters(ps::Vector{<:Number}, args...) - error("When providing parameters for a spatial system as a single vector, the paired form (e.g :D =>1.0) must be used.") + return [sym => input[sym] for sym in syms] end -# Splitting is only done for Vectors of Pairs (where the first value is a Symbols, and the second a value). -function split_parameters(ps::Vector{<: Pair}, p_vertex_syms::Vector, p_edge_syms::Vector) - vert_ps_in = [p for p in ps if any(isequal(p[1]), p_vertex_syms)] - edge_ps_in = [p for p in ps if any(isequal(p[1]), p_edge_syms)] - - # Error check, in case some input parameters where neither recognised as vertex or edge parameters. - if (sum(length.([vert_ps_in, edge_ps_in])) != length(ps)) - error("These input parameters are not recognised: $(setdiff(first.(ps), vcat(first.([vert_ps_in, edge_ps_in]))))") +function lattice_process_input(input, syms::Vector) + if ((input isa Vector) || (input isa Tuple)) && all(entry isa Pair for entry in input) + return lattice_process_input(Dict(input), syms) end - - return vert_ps_in, edge_ps_in + throw(ArgumentError("Input parameters/initial conditions have the wrong format ($(typeof(input))). These should either be a Dictionary, or a Tuple or a Vector (where each entry is a Pair taking a parameter/species to its value).")) end -# Input may have the following forms (after potential Symbol maps to Symbolic maps conversions): - # - A vector of values, where the i'th value corresponds to the value of the i'th - # initial condition value (for u0_in), vertex parameter value (for vert_ps_in), or edge parameter value (for edge_ps_in). - # - A vector of vectors of values. The same as previously, - # but here the species/parameter can have different values across the spatial structure. - # - A map of Symbols to values. These can either be a single value (if uniform across the spatial structure) - # or a vector (with different values for each vertex/edge). - # These can be mixed (e.g. [X => 1.0, Y => [1.0, 2.0, 3.0, 4.0]] is allowed). - # - A matrix. E.g. for initial conditions you can have a num_species * num_vertex matrix, - # indicating the value of each species at each vertex. - -# The lattice_process_input function takes input initial conditions/vertex parameters/edge parameters -# of whichever form the user have used, and converts them to the Vector{Vector{}} form used internally. -# E.g. for parameters the top-level vector contain one vector for each parameter (same order as in parameters(::ReactionSystem)). -# If a parameter is uniformly-values across the spatial structure, its vector has a single value. -# Else, it has a number of values corresponding to the number of vertexes/edges (for edge/vertex parameters). -# Initial conditions works similarly. - -# If the input is given in a map form, the vector needs sorting and the first value removed. -# The creates a Vector{Vector{Value}} or Vector{value} form, which is then again sent to lattice_process_input for reprocessing. -function lattice_process_input(input::Vector{<:Pair}, syms::Vector{BasicSymbolic{Real}}, args...) - if !isempty(setdiff(first.(input), syms)) - error("Some input symbols are not recognised: $(setdiff(first.(input), syms)).") - end - sorted_input = sort(input; by = p -> findfirst(isequal(p[1]), syms)) - return lattice_process_input(last.(sorted_input), syms, args...) -end -# If the input is a matrix: Processes the input and gives it in a form where it is a vector of vectors -# (some of which may have a single value). Sends it back to lattice_process_input for reprocessing. -function lattice_process_input(input::Matrix{<:Number}, args...) - lattice_process_input([vec(input[i, :]) for i in 1:size(input, 1)], args...) +# Splits parameters into vertex and edge parameters. +function split_parameters(ps, p_vertex_syms::Vector, p_edge_syms::Vector) + vert_ps = [p for p in ps if any(isequal(p[1]), p_vertex_syms)] + edge_ps = [p for p in ps if any(isequal(p[1]), p_edge_syms)] + return vert_ps, edge_ps end -# Possibly we want to support this type of input at some point. -function lattice_process_input(input::Array{<:Number, 3}, args...) - error("3 dimensional array parameter input currently not supported.") + +# Converts the values for the initial conditions/vertex parameters to the correct form: +# A map vector from symbolics to vectors of either length 1 (for uniform values) or num_verts. +function vertex_value_map(values, lrs::LatticeReactionSystem) + isempty(values) && (return Pair{BasicSymbolic{Real}, Vector{Float64}}[]) + return [entry[1] => vertex_value_form(entry[2], lrs, entry[1]) for entry in values] end -# If the input is a Vector containing both vectors and single values, converts it to the Vector{<:Vector} form. -# Technically this last lattice_process_input is probably not needed. -function lattice_process_input(input::Vector{<:Any}, args...) - isempty(input) ? Vector{Vector{Float64}}() : - lattice_process_input([(val isa Vector{<:Number}) ? val : [val] for val in input], - args...) + +# Converts the values for an individual species/vertex parameter to its correct vector form. +function vertex_value_form(values, lrs::LatticeReactionSystem, sym::BasicSymbolic) + # If the value is a scalar (i.e. uniform across the lattice), return it in vector form. + (values isa AbstractArray) || (return [values]) + + # If the value is a vector (something all three lattice types accept). + if values isa Vector + # For the case where we have a 1d (Cartesian or masked) grid, and the vector's values + # correspond to individual grid points. + if has_grid_lattice(lrs) && (size(values) == grid_size(lrs)) + return vertex_value_form(values, num_verts(lrs), lattice(lrs), sym) + end + + # For the case where the i'th value of the vector corresponds to the value in the i'th vertex. + # This is the only (non-uniform) case possible for graph grids. + if (length(values) != num_verts(lrs)) + throw(ArgumentError("You have provided ($(length(values))) values for $sym. This is not equal to the number of vertices ($(num_verts(lrs))).")) + end + return values + end + + # (2d and 3d) Cartesian and masked grids can take non-vector, non-scalar, values input. + return vertex_value_form(values, num_verts(lrs), lattice(lrs), sym) end -# If the input is of the correct form already, return it. -lattice_process_input(input::Vector{<:Vector}, syms::Vector{BasicSymbolic{Real}}, n::Int64) = input - -# Checks that a value vector have the right length, as well as that of all its sub vectors. -# Error check if e.g. the user does not provide values for all species/parameters, -# or for one: provides a vector of values, but that has the wrong length -# (e.g providing 7 values for one species, but there are 8 vertexes). -function check_vector_lengths(input::Vector{<:Vector}, n_syms, n_locations) - if (length(input)!=n_syms) - error("Missing values for some initial conditions/parameters. Expected $n_syms values, got $(length(input)).") + +# Converts values to the correct vector form for a Cartesian grid lattice. +function vertex_value_form(values::AbstractArray, num_verts::Int64, + lattice::CartesianGridRej{N, T}, sym::BasicSymbolic) where {N, T} + if size(values) != lattice.dims + throw(ArgumentError("The values for $sym did not have the same format as the lattice. Expected a $(lattice.dims) array, got one of size $(size(values))")) end - if !isempty(setdiff(unique(length.(input)), [1, n_locations])) - error("Some inputs where given values of inappropriate length.") + if (length(values) != num_verts) + throw(ArgumentError("You have provided ($(length(values))) values for $sym. This is not equal to the number of vertices ($(num_verts)).")) end + return [values[flat_idx] for flat_idx in 1:num_verts] end -# For transport parameters, if the lattice was given as an undirected graph of size n: -# this is converted to a directed graph of size 2n. -# If transport parameters are given with n values, we want to use the same value for both directions. -# Since the order of edges in the new graph is non-trivial, this function -# distributes the n input values to a 2n length vector, putting the correct value in each position. -function duplicate_trans_params!(edge_ps::Vector{Vector{Float64}}, lrs::LatticeReactionSystem) - cum_adjacency_counts = [0;cumsum(length.(lrs.lattice.fadjlist[1:end-1]))] - for idx in 1:length(edge_ps) - # If the edge parameter already values for each directed edge, we can continue. - (2length(edge_ps[idx]) == lrs.num_edges) || continue # - - # This entire thing depends on the fact that, in the edges(lattice) iterator, the edges are sorted by: - # (1) Their source node - # (2) Their destination node. - - # A vector where we will put the edge parameters new values. - # Has the correct length (the number of directed edges in the lattice). - new_vals = Vector{Float64}(undef, lrs.num_edges) - # As we loop through the edges of the di-graph, this keeps track of each edge's index in the original graph. - original_edge_count = 0 - for edge in edges(lrs.lattice) # For each edge. - # The digraph conversion only adds edges so that src > dst. - (edge.src < edge.dst) ? (original_edge_count += 1) : continue - # For original edge i -> j, finds the index of i -> j in DiGraph. - idx_fwd = cum_adjacency_counts[edge.src] + findfirst(isequal(edge.dst),lrs.lattice.fadjlist[edge.src]) - # For original edge i -> j, finds the index of j -> i in DiGraph. - idx_bwd = cum_adjacency_counts[edge.dst] + findfirst(isequal(edge.src),lrs.lattice.fadjlist[edge.dst]) - new_vals[idx_fwd] = edge_ps[idx][original_edge_count] - new_vals[idx_bwd] = edge_ps[idx][original_edge_count] - end - # Replaces the edge parameters values with the updated value vector. - edge_ps[idx] = new_vals +# Converts values to the correct vector form for a masked grid lattice. +function vertex_value_form(values::AbstractArray, num_verts::Int64, + lattice::Array{Bool, T}, sym::BasicSymbolic) where {T} + if size(values) != size(lattice) + throw(ArgumentError("The values for $sym did not have the same format as the lattice. Expected a $(size(lattice)) array, got one of size $(size(values))")) end + + # Pre-declares a vector with the values in each vertex (return_values). + # Loops through the lattice and the values, adding these to the return_values. + return_values = Vector{typeof(values[1])}(undef, num_verts) + cur_idx = 0 + for (idx, val) in enumerate(values) + lattice[idx] || continue + return_values[cur_idx += 1] = val + end + + # Checks that the correct number of values was provided, and returns the values. + if (length(return_values) != num_verts) + throw(ArgumentError("You have provided ($(length(return_values))) values for $sym. This is not equal to the number of vertices ($(num_verts)).")) + end + return return_values +end + +# Converts the values for the edge parameters to the correct form: +# A map vector from symbolics to sparse matrices of size either (1,1) or (num_verts,num_verts). +function edge_value_map(values, lrs::LatticeReactionSystem) + isempty(values) && (return Pair{BasicSymbolic{Real}, SparseMatrixCSC{Float64, Int64}}[]) + return [entry[1] => edge_value_form(entry[2], lrs, entry[1]) for entry in values] end -# For a set of input values on the given forms, and their symbolics, convert into a dictionary. -vals_to_dict(syms::Vector, vals::Vector{<:Vector}) = Dict(zip(syms, vals)) -# Produces a dictionary with all parameter values. -function param_dict(vert_ps, edge_ps, lrs) - merge(vals_to_dict(vertex_parameters(lrs), vert_ps), - vals_to_dict(edge_parameters(lrs), edge_ps)) +# Converts the values for an individual edge parameter to its correct sparse matrix form. +function edge_value_form(values, lrs::LatticeReactionSystem, sym) + # If the value is a scalar (i.e. uniform across the lattice), return it in sparse matrix form. + (values isa SparseMatrixCSC) || (return sparse([1], [1], [values])) + + # Error checks. + if nnz(values) != num_edges(lrs) + throw(ArgumentError("You have provided ($(nnz(values))) values for $sym. This is not equal to the number of edges ($(num_edges(lrs))).")) + end + if !all(Base.isstored(values, e[1], e[2]) for e in edge_iterator(lrs)) + throw(ArgumentError("Values was not provided for some edges for edge parameter $sym.")) + end + + # Unlike initial conditions/vertex parameters, (unless uniform) edge parameters' values are + # always provided in the same (sparse matrix) form. + return values end -# Computes the transport rates and stores them in a desired format -# (a Dictionary from species index to rates across all edges). -function compute_all_transport_rates(vert_ps::Vector{Vector{Float64}}, edge_ps::Vector{Vector{Float64}}, lrs::LatticeReactionSystem) - # Creates a dict, allowing us to access the values of wll parameters. - p_val_dict = param_dict(vert_ps, edge_ps, lrs) +# Creates a map, taking each species (with transportation) to its transportation rate. +# The species is represented by its index (in species(lrs). +# If the rate is uniform across all edges, the transportation rate will be a size (1,1) sparse matrix. +# Else, the rate will be a size (num_verts,num_verts) sparse matrix. +function make_sidxs_to_transrate_map(vert_ps::Vector{Pair{R, Vector{T}}}, + edge_ps::Vector{Pair{S, SparseMatrixCSC{T, Int64}}}, + lrs::LatticeReactionSystem) where {R, S, T} + # Creates a dictionary with each parameter's value(s). + p_val_dict = Dict(vcat(vert_ps, edge_ps)) + # First, compute a map from species in their symbolics form to their values. + # Next, convert to map from species index to values. + transport_rates_speciesmap = compute_all_transport_rates(p_val_dict, lrs) + return Pair{Int64, SparseMatrixCSC{T, Int64}}[ + speciesmap(reactionsystem(lrs))[spat_rates[1]] => spat_rates[2] + for spat_rates in transport_rates_speciesmap + ] +end + +# Computes the transport rates for all species with transportation rates. Output is a map +# taking each species' symbolics form to its transportation rates across all edges. +function compute_all_transport_rates(p_val_dict, lrs::LatticeReactionSystem) # For all species with transportation, compute their transportation rate (across all edges). # This is a vector, pairing each species to these rates. - unsorted_rates = [s => compute_transport_rates(get_transport_rate_law(s, lrs), p_val_dict, lrs.num_edges) - for s in spatial_species(lrs)] - - # Sorts all the species => rate pairs according to their species index in species(::ReactionSystem). - return sort(unsorted_rates; by=rate -> findfirst(isequal(rate[1]), species(lrs))) -end -# For a species, retrieves the symbolic expression for its transportation rate -# (likely only a single parameter, such as `D`, but could be e.g. L*D, where L and D are parameters). -# We could allows several transportation reactions for one species and simply sum them though, easy change. -function get_transport_rate_law(s::BasicSymbolic{Real}, lrs::LatticeReactionSystem) - rates = filter(sr -> isequal(s, sr.species), lrs.spatial_reactions) - (length(rates) > 1) && error("Species $s have more than one diffusion reaction.") - return rates[1].rate + unsorted_rates = [s => compute_transport_rates(s, p_val_dict, lrs) + for s in spatial_species(lrs)] + + # Sorts all the species => rate pairs according to their species index in species(lrs). + return sort(unsorted_rates; by = rate -> findfirst(isequal(rate[1]), species(lrs))) end -# For the numeric expression describing the rate of transport (likely only a single parameter, e.g. `D`), -# and the values of all our parameters, computes the transport rate(s). -# If all parameters the rate depend on are uniform all edges, this becomes a length 1 vector. -# Else a vector with each value corresponding to the rate at one specific edge. -function compute_transport_rates(rate_law::Num, - p_val_dict::Dict{SymbolicUtils.BasicSymbolic{Real}, Vector{Float64}}, num_edges::Int64) - # Finds parameters involved in rate and create a function evaluating the rate law. + +# For the expression describing the rate of transport (likely only a single parameter, e.g. `D`), +# and the values of all our parameters, compute the transport rate(s). +# If all parameters that the rate depends on are uniform across all edges, this becomes a length-1 vector. +# Else it becomes a vector where each value corresponds to the rate at one specific edge. +function compute_transport_rates(s::BasicSymbolic, p_val_dict, lrs::LatticeReactionSystem) + # Find parameters involved in the rate and create a function evaluating the rate law. + rate_law = get_transport_rate_law(s, lrs) relevant_ps = Symbolics.get_variables(rate_law) - rate_law_func = drop_expr(@RuntimeGeneratedFunction(build_function(rate_law, relevant_ps...))) + rate_law_func = drop_expr(@RuntimeGeneratedFunction(build_function( + rate_law, relevant_ps...))) - # If all these parameters are spatially uniform. `rates` becomes a vector with 1 value. - if all(length(p_val_dict[P]) == 1 for P in relevant_ps) - return [rate_law_func([p_val_dict[p][1] for p in relevant_ps]...)] - # If at least on parameter the rate depends on have a value varying across all edges, - # we have to compute one rate value for each edge. + # If all these parameters are spatially uniform, the rates become a size (1,1) sparse matrix. + # Else, the rates become a size (num_verts,num_verts) sparse matrix. + if all(size(p_val_dict[p]) == (1, 1) for p in relevant_ps) + relevant_p_vals = [get_edge_value(p_val_dict[p], 1 => 1) for p in relevant_ps] + return sparse([1], [1], rate_law_func(relevant_p_vals...)) else - return [rate_law_func([get_component_value(p_val_dict[p], idxE) for p in relevant_ps]...) - for idxE in 1:num_edges] + transport_rates = spzeros(num_verts(lrs), num_verts(lrs)) + for e in edge_iterator(lrs) + relevant_p_vals = [get_edge_value(p_val_dict[p], e) for p in relevant_ps] + transport_rates[e...] = rate_law_func(relevant_p_vals...)[1] + end + return transport_rates end end -# Creates a map, taking each species (with transportation) to its transportation rate. -# The species is represented by its index (in species(lrs). -# If the rate is uniform across all edges, the vector will be length 1 (with this value), -# else there will be a separate value for each edge. -# Pair{Int64, Vector{T}}[] is required in case vector is empty (otherwise it becomes Any[], causing type error later). -function make_sidxs_to_transrate_map(vert_ps::Vector{Vector{Float64}}, edge_ps::Vector{Vector{T}}, - lrs::LatticeReactionSystem) where T - transport_rates_speciesmap = compute_all_transport_rates(vert_ps, edge_ps, lrs) - return Pair{Int64, Vector{T}}[ - speciesmap(lrs.rs)[spat_rates[1]] => spat_rates[2] for spat_rates in transport_rates_speciesmap - ] +# For a species, retrieve the symbolic expression for its transportation rate +# (likely only a single parameter, such as `D`, but could be e.g. L*D, where L and D are parameters). +# If there are several transportation reactions for the species, their sum is used. +function get_transport_rate_law(s::BasicSymbolic, lrs::LatticeReactionSystem) + rates = filter(sr -> isequal(s, sr.species), spatial_reactions(lrs)) + return sum(getfield.(rates, :rate)) end ### Accessing Unknown & Parameter Array Values ### -# Gets the index in the u array of species s in vertex vert (when their are num_species species). +# Converts a vector of vectors to a single, long, vector. +# These are used when the initial condition is converted to a single vector (from vector of vector form). +function expand_component_values(values::Vector{Vector{T}}, num_verts::Int64) where {T} + vcat([get_vertex_value.(values, vert) for vert in 1:num_verts]...) +end + +# Gets the index in the u array of species s in vertex vert (when there are num_species species). get_index(vert::Int64, s::Int64, num_species::Int64) = (vert - 1) * num_species + s -# Gets the indexes in the u array of all species in vertex vert (when their are num_species species). -get_indexes(vert::Int64, num_species::Int64) = ((vert - 1) * num_species + 1):(vert * num_species) - -# For vectors of length 1 or n, we want to get value idx (or the one value, if length is 1). -# This function gets that. Here: -# - values is the vector with the values of the component across all locations -# (where the internal vectors may or may not be of size 1). -# - component_idx is the initial condition species/vertex parameter/edge parameters's index. -# This is predominantly used for parameters, for initial conditions, -# it is only used once (at initialisation) to re-process the input vector. -# - location_idx is the index of the vertex or edge for which we wish to access a initial condition or parameter values. -# The first two function takes the full value vector, and call the function of at the components specific index. -function get_component_value(values::Vector{<:Vector}, component_idx::Int64, - location_idx::Int64) - get_component_value(values[component_idx], location_idx) +# Gets the indices in the u array of all species in vertex vert (when there are num_species species). +function get_indexes(vert::Int64, num_species::Int64) + return ((vert - 1) * num_species + 1):(vert * num_species) end -# Sometimes we have pre-computed, for each component, whether it's vector is length 1 or not. -# This is stored in location_types. -function get_component_value(values::Vector{<:Vector}, component_idx::Int64, - location_idx::Int64, location_types::Vector{Bool}) - get_component_value(values[component_idx], location_idx, location_types[component_idx]) + +# Returns the value of a parameter in an edge. For vertex parameters, use their values in the source. +function get_edge_value(values::Vector{T}, edge::Pair{Int64, Int64}) where {T} + return (length(values) == 1) ? values[1] : values[edge[1]] end -# For a components value (which is a vector of either length 1 or some other length), retrieves its value. -function get_component_value(values::Vector{<:Number}, location_idx::Int64) - get_component_value(values, location_idx, length(values) == 1) +function get_edge_value(values::SparseMatrixCSC{T, Int64}, + edge::Pair{Int64, Int64}) where {T} + return (size(values) == (1, 1)) ? values[1, 1] : values[edge[1], edge[2]] end -# Again, the location type (length of the value vector) may be pre-computed. -function get_component_value(values::Vector{<:Number}, location_idx::Int64, - location_type::Bool) - location_type ? values[1] : values[location_idx] + +# Returns the value of an initial condition of vertex parameter in a vertex. +function get_vertex_value(values::Vector{T}, vert_idx::Int64) where {T} + return (length(values) == 1) ? values[1] : values[vert_idx] end -# Converts a vector of vectors to a long vector. -# These are used when the initial condition is converted to a single vector (from vector of vector form). -function expand_component_values(values::Vector{<:Vector}, n) - vcat([get_component_value.(values, comp) for comp in 1:n]...) +# Finds the transport rate of a parameter along a specific edge. +function get_transport_rate(transport_rate::SparseMatrixCSC{T, Int64}, + edge::Pair{Int64, Int64}, t_rate_idx_types::Bool) where {T} + return t_rate_idx_types ? transport_rate[1, 1] : transport_rate[edge[1], edge[2]] end -function expand_component_values(values::Vector{<:Vector}, n, location_types::Vector{Bool}) - vcat([get_component_value.(values, comp, location_types) for comp in 1:n]...) + +# For a `LatticeTransportODEFunction`, update its stored parameters (in `mtk_ps`) so that they +# the heterogeneous parameters' values correspond to the values in the specified vertex. +function update_mtk_ps!(lt_ofun::LatticeTransportODEFunction, all_ps::Vector{T}, + vert::Int64) where {T} + for (setp, idx) in zip(lt_ofun.p_setters, lt_ofun.heterogeneous_vert_p_idxs) + setp(lt_ofun.mtk_ps, all_ps[idx][vert]) + end end -# Creates a view of the vert_ps vector at a given location. -# Provides a work vector to which the converted vector is written. -function view_vert_ps_vector!(work_vert_ps, vert_ps, comp, enumerated_vert_ps_idx_types) - # Loops through all parameters. - for (idx,loc_type) in enumerated_vert_ps_idx_types - # If the parameter is uniform across the spatial structure, it will have a length-1 value vector - # (which value we write to the work vector). - # Else, we extract it value at the specific location. - work_vert_ps[idx] = (loc_type ? vert_ps[idx][1] : vert_ps[idx][comp]) +# For an expression, compute its values using the provided state and parameter vectors. +# The expression is assumed to be valid in vertexes (and can have vertex parameter and state components). +# If at least one component is non-uniform, output is a vector of length equal to the number of vertexes. +# If all components are uniform, the output is a length one vector. +function compute_vertex_value(exp, lrs::LatticeReactionSystem; u = [], ps = []) + # Finds the symbols in the expression. Checks that all correspond to unknowns or vertex parameters. + relevant_syms = Symbolics.get_variables(exp) + if any(any(isequal(sym) in edge_parameters(lrs)) for sym in relevant_syms) + throw(ArgumentError("An edge parameter was encountered in expressions: $exp. Here, only vertex-based components are expected.")) + end + + # Creates a Function that computes the expression value for a parameter set. + exp_func = drop_expr(@RuntimeGeneratedFunction(build_function(exp, relevant_syms...))) + + # Creates a dictionary with the value(s) for all edge parameters. + value_dict = Dict(vcat(u, ps)) + + # If all values are uniform, compute value once. Else, do it at all edges. + if all(length(value_dict[sym]) == 1 for sym in relevant_syms) + return [exp_func([value_dict[sym][1] for sym in relevant_syms]...)] end - return work_vert_ps + return [exp_func([get_vertex_value(value_dict[sym], vert_idx) for sym in relevant_syms]...) + for vert_idx in 1:num_verts(lrs)] end -# Expands a u0/p information stored in Vector{Vector{}} for to Matrix form -# (currently only used in Spatial Jump systems). -function matrix_expand_component_values(values::Vector{<:Vector}, n) - reshape(expand_component_values(values, n), length(values), n) +### System Property Checks ### + +# For a Symbolic expression, and a parameter set, check if any relevant parameters have a +# spatial component. Filters out any parameters that are edge parameters. +function has_spatial_vertex_component(exp, ps) + relevant_syms = Symbolics.get_variables(exp) + value_dict = Dict(filter(p -> p[2] isa Vector, ps)) + return any(length(value_dict[sym]) > 1 for sym in relevant_syms) end diff --git a/src/steady_state_stability.jl b/src/steady_state_stability.jl index f8512116e4..de9a229ef8 100644 --- a/src/steady_state_stability.jl +++ b/src/steady_state_stability.jl @@ -44,11 +44,11 @@ these. Furthermore, Catalyst uses a tolerance `tol = 10*sqrt(eps())` to determin computed eigenvalue is far away enough from 0 to be reliably used. This selected threshold can be changed through the `tol` argument. ``` """ -function steady_state_stability(u::Vector, rs::ReactionSystem, ps; tol = 10*sqrt(eps(ss_val_type(u))), - ss_jac = steady_state_jac(rs; u0 = u)) +function steady_state_stability(u::Vector, rs::ReactionSystem, ps; + tol = 10 * sqrt(eps(ss_val_type(u))), ss_jac = steady_state_jac(rs; u0 = u)) # Warning checks. - if !isautonomous(rs) - error("Attempting to compute stability for a non-autonomous system (e.g. where some rate depend on $(rs.iv)). This is not possible.") + if !isautonomous(rs) + error("Attempting to compute stability for a non-autonomous system (e.g. where some rate depend on $(get_iv(rs))). This is not possible.") end # If `u` is a vector of values, we convert it to a map. Also, if there are conservation laws, @@ -62,7 +62,7 @@ function steady_state_stability(u::Vector, rs::ReactionSystem, ps; tol = 10*sqrt J = zeros(length(u), length(u)) ss_jac = remake(ss_jac; u0 = u, p = ps) ss_jac.f.jac(J, ss_jac.u0, ss_jac.p, Inf) - + # Computes stability (by checking that the real part of all eigenvalues is negative). max_eig = maximum(real(ev) for ev in eigvals(J)) if abs(max_eig) < tol @@ -74,8 +74,8 @@ end # Used to determine the type of the steady states values, which is then used to set the tolerance's # type. ss_val_type(u::Vector{T}) where {T} = T -ss_val_type(u::Vector{Pair{S,T}}) where {S,T} = T -ss_val_type(u::Dict{S,T}) where {S,T} = T +ss_val_type(u::Vector{Pair{S, T}}) where {S, T} = T +ss_val_type(u::Dict{S, T}) where {S, T} = T """ steady_state_jac(rs::ReactionSystem; u0 = []) @@ -107,8 +107,8 @@ Notes: such a way that it can be used by the `steady_state_stability` function. ``` """ -function steady_state_jac(rs::ReactionSystem; u0 = [sp => 0.0 for sp in unknowns(rs)], - combinatoric_ratelaws = get_combinatoric_ratelaws(rs)) +function steady_state_jac(rs::ReactionSystem; u0 = [sp => 0.0 for sp in unknowns(rs)], + combinatoric_ratelaws = get_combinatoric_ratelaws(rs)) # If u0 is a vector of values, must be converted to something MTK understands. # Converts u0 to values MTK understands, and checks that potential conservation laws are accounted for. @@ -117,15 +117,15 @@ function steady_state_jac(rs::ReactionSystem; u0 = [sp => 0.0 for sp in unknowns # Creates an `ODEProblem` with a Jacobian. Dummy values for `u0` and `ps` must be provided. ps = [p => 0.0 for p in parameters(rs)] - return ODEProblem(rs, u0, 0, ps; jac = true, remove_conserved = true, - combinatoric_ratelaws = combinatoric_ratelaws) + return ODEProblem(rs, u0, 0, ps; jac = true, combinatoric_ratelaws, + remove_conserved = true, remove_conserved_warn = false) end # Converts a `u` vector from a vector of values to a map. function steady_state_u_conversion(u, rs::ReactionSystem) if (u isa Vector{<:Number}) if length(u) == length(unknowns(rs)) - u = [sp => v for (sp,v) in zip(unknowns(rs), u)] + u = [sp => v for (sp, v) in zip(unknowns(rs), u)] else error("You are trying to generate a stability Jacobian, providing u0 to compute conservation laws. Your provided u0 vector has length < the number of system states. If you provide a u0 vector, these have to be identical.") end diff --git a/test/dsl/dsl_basic_model_construction.jl b/test/dsl/dsl_basic_model_construction.jl index 72dd01f1ab..b79c229c6e 100644 --- a/test/dsl/dsl_basic_model_construction.jl +++ b/test/dsl/dsl_basic_model_construction.jl @@ -2,8 +2,9 @@ # Fetch packages. using DiffEqBase, Catalyst, Random, Test -using ModelingToolkit: operation, istree, get_unknowns, get_ps, get_eqs, get_systems, +using ModelingToolkit: operation, get_unknowns, get_ps, get_eqs, get_systems, get_iv, nameof +using Symbolics: iscall # Sets stable rng number. using StableRNGs @@ -22,7 +23,7 @@ function unpacksys(sys) get_eqs(sys), get_iv(sys), get_unknowns(sys), get_ps(sys), nameof(sys), get_systems(sys) end -opname(x) = istree(x) ? nameof(operation(x)) : nameof(x) +opname(x) = iscall(x) ? nameof(operation(x)) : nameof(x) alleq(xs, ys) = all(isequal(x, y) for (x, y) in zip(xs, ys)) # Gets all the reactants in a set of equations. @@ -71,7 +72,7 @@ let Set([:p, :k1, :k2, :k3, :k4, :k5, :k6, :d]) basic_test(reaction_networks_hill[1], 4, [:X1, :X2], [:v1, :v2, :K1, :K2, :n1, :n2, :d1, :d2]) - basic_test(reaction_networks_constraint[1], 6, [:X1, :X2, :X3], + basic_test(reaction_networks_conserved[1], 6, [:X1, :X2, :X3], [:k1, :k2, :k3, :k4, :k5, :k6]) basic_test(reaction_networks_real[1], 4, [:X, :Y], [:A, :B]) basic_test(reaction_networks_weird[1], 2, [:X], [:p, :d]) @@ -281,7 +282,7 @@ let Reaction(p + k5 * X2 * X3, nothing, [X5], nothing, [1]), Reaction(d, [X5], nothing, [1], nothing)] @named rs_2 = ReactionSystem(rxs_2, t, [X1, X2, X3, X4, X5], [k1, k2, k3, k4, p, k5, d]) - push!(identical_networks_4, reaction_networks_constraint[3] => rs_2) + push!(identical_networks_4, reaction_networks_conserved[3] => rs_2) rxs_3 = [Reaction(k1, [X1], [X2], [1], [1]), Reaction(0, [X2], [X3], [1], [1]), @@ -321,14 +322,14 @@ let for factor in [1e-2, 1e-1, 1e0, 1e1, 1e2, 1e3] τ = rand(rng) - u = rnd_u0(reaction_networks_constraint[1], rng; factor) + u = rnd_u0(reaction_networks_conserved[1], rng; factor) p_2 = rnd_ps(time_network, rng; factor) - p_1 = [p_2; reaction_networks_constraint[1].k1 => τ; - reaction_networks_constraint[1].k4 => τ; reaction_networks_constraint[1].k5 => τ] + p_1 = [p_2; reaction_networks_conserved[1].k1 => τ; + reaction_networks_conserved[1].k4 => τ; reaction_networks_conserved[1].k5 => τ] - @test f_eval(reaction_networks_constraint[1], u, p_1, τ) ≈ f_eval(time_network, u, p_2, τ) - @test jac_eval(reaction_networks_constraint[1], u, p_1, τ) ≈ jac_eval(time_network, u, p_2, τ) - @test g_eval(reaction_networks_constraint[1], u, p_1, τ) ≈ g_eval(time_network, u, p_2, τ) + @test f_eval(reaction_networks_conserved[1], u, p_1, τ) ≈ f_eval(time_network, u, p_2, τ) + @test jac_eval(reaction_networks_conserved[1], u, p_1, τ) ≈ jac_eval(time_network, u, p_2, τ) + @test g_eval(reaction_networks_conserved[1], u, p_1, τ) ≈ g_eval(time_network, u, p_2, τ) end end @@ -437,3 +438,5 @@ let @test_throws LoadError @eval @reaction k, 0 --> im @test_throws LoadError @eval @reaction k, 0 --> nothing end + + diff --git a/test/dsl/dsl_options.jl b/test/dsl/dsl_options.jl index b6c8ada2e3..819a887427 100644 --- a/test/dsl/dsl_options.jl +++ b/test/dsl/dsl_options.jl @@ -488,12 +488,12 @@ let @test sol[:Y][end] ≈ 3.0 # Tests that observables can be used for plot indexing. - @test_broken false # plot(sol; idxs=X).series_list[1].plotattributes[:y][end] ≈ 10.0 + plot(sol; idxs=X).series_list[1].plotattributes[:y][end] ≈ 10.0 @test plot(sol; idxs=rn.X).series_list[1].plotattributes[:y][end] ≈ 10.0 @test plot(sol; idxs=:X).series_list[1].plotattributes[:y][end] ≈ 10.0 @test plot(sol; idxs=[X, Y]).series_list[2].plotattributes[:y][end] ≈ 3.0 @test plot(sol; idxs=[rn.X, rn.Y]).series_list[2].plotattributes[:y][end] ≈ 3.0 - @test_broken false # plot(sol; idxs=[:X, :Y]).series_list[2].plotattributes[:y][end] ≈ 3.0 + @test plot(sol; idxs=[:X, :Y]).series_list[2].plotattributes[:y][end] ≈ 3.0 # (https://github.com/SciML/ModelingToolkit.jl/issues/2778) end # Compares programmatic and DSL system with observables. @@ -574,8 +574,8 @@ let end end V,W = getfield.(observed(rn), :lhs) - @test isequal(arguments(ModelingToolkit.unwrap(V)), Any[rn.iv, rn.sivs[1], rn.sivs[2]]) - @test isequal(arguments(ModelingToolkit.unwrap(W)), Any[rn.iv, rn.sivs[2]]) + @test isequal(arguments(ModelingToolkit.unwrap(V)), Any[Catalyst.get_iv(rn), Catalyst.get_sivs(rn)[1], Catalyst.get_sivs(rn)[2]]) + @test isequal(arguments(ModelingToolkit.unwrap(W)), Any[Catalyst.get_iv(rn), Catalyst.get_sivs(rn)[2]]) end # Checks that metadata is written properly. @@ -584,7 +584,7 @@ let @observables (X, [description="my_description"]) ~ X1 + X2 k, 0 --> X1 + X2 end - @test getdescription(observed(rn)[1].lhs) == "my_description" + @test ModelingToolkit.getdescription(observed(rn)[1].lhs) == "my_description" end # Declares observables implicitly/explicitly. @@ -629,7 +629,7 @@ let (k1, k2), X1 <--> X2 end @test isequal(observed(rn1)[1].lhs, X) - @test getdescription(rn1.X) == "An observable" + @test ModelingToolkit.getdescription(rn1.X) == "An observable" @test isspecies(rn1.X) @test length(unknowns(rn1)) == 2 @@ -950,4 +950,4 @@ let rl = oderatelaw(reactions(rn3)[1]; combinatoric_ratelaw) @unpack k1, A = rn3 @test isequal(rl, k1*A^2) -end \ No newline at end of file +end diff --git a/test/extensions/homotopy_continuation.jl b/test/extensions/homotopy_continuation.jl index 7a260e3b59..3a59f0f3a8 100644 --- a/test/extensions/homotopy_continuation.jl +++ b/test/extensions/homotopy_continuation.jl @@ -25,17 +25,18 @@ let u0 = [:X1 => 2.0, :X2 => 2.0, :X3 => 2.0, :X2_2X3 => 2.0] # Computes the single steady state, checks that when given to the ODE rhs, all are evaluated to 0. - hc_ss = hc_steady_states(rs, ps; u0=u0, show_progress=false) + hc_ss = hc_steady_states(rs, ps; u0 = u0, show_progress = false, seed = 0x000004d1) hc_ss = Pair.(unknowns(rs), hc_ss[1]) - @test maximum(abs.(f_eval(rs, hc_ss, ps, 0.0))) ≈ 0.0 atol=1e-12 + @test maximum(abs.(f_eval(rs, hc_ss, ps, 0.0))) ≈ 0.0 atol = 1e-12 # Checks that not giving a `u0` argument yields an error for systems with conservation laws. - @test_throws Exception hc_steady_states(rs, ps; show_progress=false) + @test_throws Exception hc_steady_states(rs, ps; show_progress = false) end # Tests for network with multiple steady state. # Tests for Symbol parameter input. -# Tests that passing kwargs to HC.solve does not error. +# Tests that passing kwargs to HC.solve does not error and have an effect (i.e. modifying the seed +# slightly modified the output in some way). let wilhelm_2009_model = @reaction_network begin k1, Y --> 2X @@ -43,13 +44,13 @@ let k3, X + Y --> Y k4, X --> 0 end - ps = [:k3 => 1.0, :k2 => 2.0, :k4 => 1.5, :k1=>8.0] + ps = [:k3 => 1.0, :k2 => 2.0, :k4 => 1.5, :k1 => 8.0] - hc_ss_1 = hc_steady_states(wilhelm_2009_model, ps; seed=0x000004d1, show_progress=false) - @test sort(hc_ss_1, by=sol->sol[1]) ≈ [[0.0, 0.0], [0.5, 2.0], [4.5, 6.0]] + hc_ss_1 = hc_steady_states(wilhelm_2009_model, ps; seed = 0x000004d1, show_progress = false) + @test sort(hc_ss_1, by = sol->sol[1]) ≈ [[0.0, 0.0], [0.5, 2.0], [4.5, 6.0]] - hc_ss_2 = hc_steady_states(wilhelm_2009_model, ps; seed=0x000004d2, show_progress=false) - hc_ss_3 = hc_steady_states(wilhelm_2009_model, ps; seed=0x000004d2, show_progress=false) + hc_ss_2 = hc_steady_states(wilhelm_2009_model, ps; seed = 0x000004d2, show_progress = false) + hc_ss_3 = hc_steady_states(wilhelm_2009_model, ps; seed = 0x000004d2, show_progress = false) @test hc_ss_1 != hc_ss_2 @test hc_ss_2 == hc_ss_3 end @@ -69,7 +70,7 @@ let ps = (:kY1 => 1.0, :kY2 => 3, :kZ1 => 1.0, :kZ2 => 4.0) u0_1 = (:Y1 => 1.0, :Y2 => 3, :Z1 => 10, :Z2 =>40.0) - ss_1 = sort(hc_steady_states(rs_1, ps; u0=u0_1, show_progress=false), by=sol->sol[1]) + ss_1 = sort(hc_steady_states(rs_1, ps; u0 = u0_1, show_progress = false, seed = 0x000004d1), by = sol->sol[1]) @test ss_1 ≈ [[0.2, 0.1, 3.0, 1.0, 40.0, 10.0]] rs_2 = @reaction_network begin @@ -81,7 +82,7 @@ let end u0_2 = [:B2 => 1.0, :B1 => 3.0, :A2 => 10.0, :A1 =>40.0] - ss_2 = sort(hc_steady_states(rs_2, ps; u0=u0_2, show_progress=false), by=sol->sol[1]) + ss_2 = sort(hc_steady_states(rs_2, ps; u0 = u0_2, show_progress = false, seed = 0x000004d1), by = sol->sol[1]) @test ss_1 ≈ ss_2 end @@ -96,14 +97,15 @@ let d, X --> 0 end ps = [:v => 5.0, :K => 2.5, :n => 3, :d => 1.0] - sss = hc_steady_states(rs, ps; filter_negative=false, show_progress=false) + sss = hc_steady_states(rs, ps; filter_negative = false, show_progress = false, seed = 0x000004d1) @test length(sss) == 4 for ss in sss - @test f_eval(rs,sss[1], last.(ps), 0.0)[1] ≈ 0.0 atol=1e-12 + @test f_eval(rs,sss[1], last.(ps), 0.0)[1] ≈ 0.0 atol = 1e-12 end - @test_throws Exception hc_steady_states(rs, [:v => 5.0, :K => 2.5, :n => 2.7, :d => 1.0]; show_progress=false) + ps = [:v => 5.0, :K => 2.5, :n => 2.7, :d => 1.0] + @test_throws Exception hc_steady_states(rs, ps; show_progress = false, seed = 0x000004d1) end @@ -124,7 +126,7 @@ let # Checks that homotopy continuation correctly find the system's single steady state. ps = [:p => 2.0, :d => 1.0, :k => 5.0] - hc_ss = hc_steady_states(rs, ps) + hc_ss = hc_steady_states(rs, ps; show_progress = false, seed = 0x000004d1) @test hc_ss ≈ [[2.0, 0.2, 10.0]] end @@ -137,7 +139,7 @@ let p_start = [:p => 1.0, :d => 0.2] # Computes bifurcation diagram. - @test_throws Exception hc_steady_states(incomplete_network, p_start) + @test_throws Exception hc_steady_states(incomplete_network, p_start; show_progress = false, seed = 0x000004d1) end # Tests that non-autonomous system throws an error @@ -146,5 +148,5 @@ let (k,t), 0 <--> X end ps = [:k => 1.0] - @test_throws Exception hc_steady_states(rs, ps) + @test_throws Exception hc_steady_states(rs, ps; show_progress = false, seed = 0x000004d1) end \ No newline at end of file diff --git a/test/extensions/structural_identifiability.jl b/test/extensions/structural_identifiability.jl index 8ef35c293b..c4becbc5e4 100644 --- a/test/extensions/structural_identifiability.jl +++ b/test/extensions/structural_identifiability.jl @@ -1,7 +1,10 @@ ### Prepares Tests ### # Fetch packages. -using Catalyst, StructuralIdentifiability, Test +using Catalyst, Logging, StructuralIdentifiability, Test + +# Sets the `loglevel`. +loglevel = Logging.Error # Helper function for checking that results are correct identifiability calls from different packages. # Converts the output dicts from StructuralIdentifiability functions from "weird symbol => stuff" to "symbol => stuff" (the output have some strange meta data which prevents equality checks, this enables this). @@ -28,15 +31,15 @@ let (pₑ*M,dₑ), 0 <--> E (pₚ*E,dₚ), 0 <--> P end - gi_1 = assess_identifiability(goodwind_oscillator_catalyst; measured_quantities=[:M]) - li_1 = assess_local_identifiability(goodwind_oscillator_catalyst; measured_quantities=[:M]) - ifs_1 = find_identifiable_functions(goodwind_oscillator_catalyst; measured_quantities=[:M]) + gi_1 = assess_identifiability(goodwind_oscillator_catalyst; measured_quantities = [:M], loglevel) + li_1 = assess_local_identifiability(goodwind_oscillator_catalyst; measured_quantities = [:M], loglevel) + ifs_1 = find_identifiable_functions(goodwind_oscillator_catalyst; measured_quantities = [:M], loglevel) # Identifiability analysis for Catalyst converted to StructuralIdentifiability.jl model. - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M]) - gi_2 = assess_identifiability(si_catalyst_ode) - li_2 = assess_local_identifiability(si_catalyst_ode) - ifs_2 = find_identifiable_functions(si_catalyst_ode) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M]) + gi_2 = assess_identifiability(si_catalyst_ode; loglevel) + li_2 = assess_local_identifiability(si_catalyst_ode; loglevel) + ifs_2 = find_identifiable_functions(si_catalyst_ode; loglevel) # Identifiability analysis for StructuralIdentifiability.jl model (declare this overwrites e.g. X2 variable etc.). goodwind_oscillator_si = @ODEmodel( @@ -45,9 +48,9 @@ let P'(t) = -dₚ*P(t) + pₚ*E(t), y1(t) = M(t) ) - gi_3 = assess_identifiability(goodwind_oscillator_si) - li_3 = assess_local_identifiability(goodwind_oscillator_si) - ifs_3 = find_identifiable_functions(goodwind_oscillator_si) + gi_3 = assess_identifiability(goodwind_oscillator_si; loglevel) + li_3 = assess_local_identifiability(goodwind_oscillator_si; loglevel) + ifs_3 = find_identifiable_functions(goodwind_oscillator_si; loglevel) # Check outputs. @test sym_dict(gi_1) == sym_dict(gi_2) == sym_dict(gi_3) @@ -74,15 +77,15 @@ let d, X4 --> 0 end @unpack X2, X3 = rs_catalyst - gi_1 = assess_identifiability(rs_catalyst; measured_quantities=[X2, X3], known_p=[:k2f]) - li_1 = assess_local_identifiability(rs_catalyst; measured_quantities=[X2, X3], known_p=[:k2f]) - ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities=[X2, X3], known_p=[:k2f]) + gi_1 = assess_identifiability(rs_catalyst; measured_quantities = [X2, X3], known_p = [:k2f], loglevel) + li_1 = assess_local_identifiability(rs_catalyst; measured_quantities = [X2, X3], known_p = [:k2f], loglevel) + ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities = [X2, X3], known_p = [:k2f], loglevel) # Identifiability analysis for Catalyst converted to StructuralIdentifiability.jl model. - rs_ode = make_si_ode(rs_catalyst; measured_quantities=[X2, X3], known_p=[:k2f]) - gi_2 = assess_identifiability(rs_ode) - li_2 = assess_local_identifiability(rs_ode) - ifs_2 = find_identifiable_functions(rs_ode) + rs_ode = make_si_ode(rs_catalyst; measured_quantities = [X2, X3], known_p = [:k2f]) + gi_2 = assess_identifiability(rs_ode; loglevel) + li_2 = assess_local_identifiability(rs_ode; loglevel) + ifs_2 = find_identifiable_functions(rs_ode; loglevel) # Identifiability analysis for StructuralIdentifiability.jl model (declare this overwrites e.g. X2 variable etc.). rs_si = @ODEmodel( @@ -94,9 +97,9 @@ let y2(t) = X3, y3(t) = k2f ) - gi_3 = assess_identifiability(rs_si) - li_3 = assess_local_identifiability(rs_si) - ifs_3 = find_identifiable_functions(rs_si) + gi_3 = assess_identifiability(rs_si; loglevel) + li_3 = assess_local_identifiability(rs_si; loglevel) + ifs_3 = find_identifiable_functions(rs_si; loglevel) # Check outputs. @test sym_dict(gi_1) == sym_dict(gi_2) == sym_dict(gi_3) @@ -126,15 +129,15 @@ let (kA*X3, kD), Yi <--> Ya end @unpack X1, X2, X3, X4, k1, k2, Yi, Ya, k1, kD = rs_catalyst - gi_1 = assess_identifiability(rs_catalyst; measured_quantities=[X1 + Yi, Ya], known_p=[k1, kD]) - li_1 = assess_local_identifiability(rs_catalyst; measured_quantities=[X1 + Yi, Ya], known_p=[k1, kD]) - ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities=[X1 + Yi, Ya], known_p=[k1, kD]) + gi_1 = assess_identifiability(rs_catalyst; measured_quantities = [X1 + Yi, Ya], known_p = [k1, kD], loglevel) + li_1 = assess_local_identifiability(rs_catalyst; measured_quantities = [X1 + Yi, Ya], known_p = [k1, kD], loglevel) + ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities = [X1 + Yi, Ya], known_p = [k1, kD], loglevel) # Identifiability analysis for Catalyst converted to StructuralIdentifiability.jl model. rs_ode = make_si_ode(rs_catalyst; measured_quantities=[X1 + Yi, Ya], known_p=[k1, kD], remove_conserved=false) - gi_2 = assess_identifiability(rs_ode) - li_2 = assess_local_identifiability(rs_ode) - ifs_2 = find_identifiable_functions(rs_ode) + gi_2 = assess_identifiability(rs_ode; loglevel) + li_2 = assess_local_identifiability(rs_ode; loglevel) + ifs_2 = find_identifiable_functions(rs_ode; loglevel) # Identifiability analysis for StructuralIdentifiability.jl model (declare this overwrites e.g. X2 variable etc.). rs_si = @ODEmodel( @@ -150,9 +153,9 @@ let y3(t) = k1, y4(t) = kD ) - gi_3 = assess_identifiability(rs_si) - li_3 = assess_local_identifiability(rs_si) - ifs_3 = find_identifiable_functions(rs_si) + gi_3 = assess_identifiability(rs_si; loglevel) + li_3 = assess_local_identifiability(rs_si; loglevel) + ifs_3 = find_identifiable_functions(rs_si; loglevel) # Check outputs. @test sym_dict(gi_1) == sym_dict(gi_2) == sym_dict(gi_3) @@ -174,31 +177,31 @@ let (pₚ*E,dₚ), 0 <--> P end @unpack M, E, P, pₑ, pₚ, pₘ = goodwind_oscillator_catalyst - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; known_p=[:pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M], known_p=[:pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M, :E], known_p=[:pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M], known_p=[:pₑ, :pₚ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[:M, :E], known_p=[:pₑ, :pₚ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; known_p=[pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M], known_p=[pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M, E], known_p=[pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M], known_p=[pₑ, pₚ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M, E], known_p=[pₑ, pₚ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M + pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[M + E, pₑ*M], known_p=[:pₑ]) - si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities=[pₑ, pₚ], known_p=[pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; known_p = [:pₑ], ignore_no_measured_warn = true) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M], known_p = [:pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M, :E], known_p = [:pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M], known_p = [:pₑ, :pₚ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [:M, :E], known_p = [:pₑ, :pₚ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; known_p = [pₑ], ignore_no_measured_warn = true) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M], known_p = [pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M, E], known_p = [pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M], known_p = [pₑ, pₚ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M, E], known_p = [pₑ, pₚ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M + pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [M + E, pₑ*M], known_p = [:pₑ]) + si_catalyst_ode = make_si_ode(goodwind_oscillator_catalyst; measured_quantities = [pₑ, pₚ], known_p = [pₑ]) # Tests using model.component style (have to make system complete first). gw_osc_complt = complete(goodwind_oscillator_catalyst) - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M]) isa ODE - @test make_si_ode(gw_osc_complt; known_p=[gw_osc_complt.pₑ]) isa ODE - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M], known_p=[gw_osc_complt.pₑ]) isa ODE - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M, gw_osc_complt.E], known_p=[gw_osc_complt.pₑ]) isa ODE - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M], known_p=[gw_osc_complt.pₑ, gw_osc_complt.pₚ]) isa ODE - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M], known_p = [:pₚ]) isa ODE - @test make_si_ode(gw_osc_complt; measured_quantities=[gw_osc_complt.M*gw_osc_complt.E]) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M]) isa ODE + @test make_si_ode(gw_osc_complt; known_p = [gw_osc_complt.pₑ], ignore_no_measured_warn = true) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M], known_p = [gw_osc_complt.pₑ]) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M, gw_osc_complt.E], known_p = [gw_osc_complt.pₑ]) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M], known_p = [gw_osc_complt.pₑ, gw_osc_complt.pₚ]) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M], known_p = [:pₚ]) isa ODE + @test make_si_ode(gw_osc_complt; measured_quantities = [gw_osc_complt.M*gw_osc_complt.E]) isa ODE end # Tests for hierarchical model with conservation laws at both top and internal levels. @@ -213,15 +216,15 @@ let @named rs_catalyst = compose(rs1, [rs2]) rs_catalyst = complete(rs_catalyst) @unpack X1, X2, k1, k2 = rs1 - gi_1 = assess_identifiability(rs_catalyst; measured_quantities=[X1, X2, rs2.X3], known_p=[k1]) - li_1 = assess_local_identifiability(rs_catalyst; measured_quantities=[X1, X2, rs2.X3], known_p=[k1]) - ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities=[X1, X2, rs2.X3], known_p=[k1]) + gi_1 = assess_identifiability(rs_catalyst; measured_quantities = [X1, X2, rs2.X3], known_p = [k1], loglevel) + li_1 = assess_local_identifiability(rs_catalyst; measured_quantities = [X1, X2, rs2.X3], known_p = [k1], loglevel) + ifs_1 = find_identifiable_functions(rs_catalyst; measured_quantities = [X1, X2, rs2.X3], known_p = [k1], loglevel) # Identifiability analysis for Catalyst converted to StructuralIdentifiability.jl model. - rs_ode = make_si_ode(rs_catalyst; measured_quantities=[X1, X2, rs2.X3], known_p=[k1]) - gi_2 = assess_identifiability(rs_ode) - li_2 = assess_local_identifiability(rs_ode) - ifs_2 = find_identifiable_functions(rs_ode) + rs_ode = make_si_ode(rs_catalyst; measured_quantities = [X1, X2, rs2.X3], known_p = [k1]) + gi_2 = assess_identifiability(rs_ode; loglevel) + li_2 = assess_local_identifiability(rs_ode; loglevel) + ifs_2 = find_identifiable_functions(rs_ode; loglevel) # Identifiability analysis for StructuralIdentifiability.jl model (declare this overwrites e.g. X2 variable etc.). rs_si = @ODEmodel( @@ -234,9 +237,9 @@ let y3(t) = rs2₊X3, y4(t) = k1 ) - gi_3 = assess_identifiability(rs_si) - li_3 = assess_local_identifiability(rs_si) - ifs_3 = find_identifiable_functions(rs_si) + gi_3 = assess_identifiability(rs_si; loglevel) + li_3 = assess_local_identifiability(rs_si; loglevel) + ifs_3 = find_identifiable_functions(rs_si; loglevel) # Check outputs. @test sym_dict(gi_1) == sym_dict(gi_3) @@ -261,14 +264,14 @@ let k1, x1 --> x2 end # Measure the source - id_report = assess_identifiability(rs, measured_quantities = [:x1]) + id_report = assess_identifiability(rs; measured_quantities = [:x1], loglevel) @test sym_dict(id_report) == Dict( :x1 => :globally, :x2 => :nonidentifiable, :k1 => :globally ) # Measure the target instead - id_report = assess_identifiability(rs, measured_quantities = [:x2]) + id_report = assess_identifiability(rs; measured_quantities = [:x2], loglevel) @test sym_dict(id_report) == Dict( :x1 => :globally, :x2 => :globally, @@ -285,7 +288,7 @@ let b, A0 --> 2A2 c, A0 --> A1 + A2 end - id_report = assess_identifiability(rs, measured_quantities = [:A0, :A1, :A2]) + id_report = assess_identifiability(rs; measured_quantities = [:A0, :A1, :A2], loglevel) @test sym_dict(id_report) == Dict( :A0 => :globally, :A1 => :globally, @@ -300,19 +303,20 @@ let 1, x1 --> x2 1, x2 --> x3 end - id_report = assess_identifiability(rs, measured_quantities = [:x3]) + id_report = assess_identifiability(rs; measured_quantities = [:x3], loglevel) @test sym_dict(id_report) == Dict( :x1 => :globally, :x2 => :globally, :x3 => :globally, ) - @test length(find_identifiable_functions(rs, measured_quantities = [:x3])) == 1 + @test length(find_identifiable_functions(rs; measured_quantities = [:x3], loglevel)) == 1 end ### Other Tests ### # Checks that identifiability can be assessed for coupled CRN/DAE systems. +# `remove_conserved = false` is used to remove info print statement from log. let rs = @reaction_network begin @parameters k c1 c2 @@ -326,9 +330,10 @@ let @unpack p, d, k, c1, c2 = rs # Tests identifiability assessment when all unknowns are measured. - gi_1 = assess_identifiability(rs; measured_quantities=[:X, :V, :C]) - li_1 = assess_local_identifiability(rs; measured_quantities=[:X, :V, :C]) - ifs_1 = find_identifiable_functions(rs; measured_quantities=[:X, :V, :C]) + remove_conserved = false + gi_1 = assess_identifiability(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) + li_1 = assess_local_identifiability(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) + ifs_1 = find_identifiable_functions(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) @test sym_dict(gi_1) == Dict([:X => :globally, :C => :globally, :V => :globally, :k => :globally, :c1 => :nonidentifiable, :c2 => :nonidentifiable, :p => :globally, :d => :globally]) @test sym_dict(li_1) == Dict([:X => 1, :C => 1, :V => 1, :k => 1, :c1 => 0, :c2 => 0, :p => 1, :d => 1]) @@ -336,9 +341,9 @@ let # Tests identifiability assessment when only variables are measured. # Checks that a parameter in an equation can be set as known. - gi_2 = assess_identifiability(rs; measured_quantities=[:V, :C], known_p = [:c1]) - li_2 = assess_local_identifiability(rs; measured_quantities=[:V, :C], known_p = [:c1]) - ifs_2 = find_identifiable_functions(rs; measured_quantities=[:V, :C], known_p = [:c1]) + gi_2 = assess_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) + li_2 = assess_local_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) + ifs_2 = find_identifiable_functions(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) @test sym_dict(gi_2) == Dict([:X => :nonidentifiable, :C => :globally, :V => :globally, :k => :nonidentifiable, :c1 => :globally, :c2 => :nonidentifiable, :p => :nonidentifiable, :d => :globally]) @test sym_dict(li_2) == Dict([:X => 0, :C => 1, :V => 1, :k => 0, :c1 => 1, :c2 => 0, :p => 0, :d => 1]) @@ -354,7 +359,7 @@ let measured_quantities = [:X] # Computes bifurcation diagram. - @test_throws Exception assess_identifiability(incomplete_network; measured_quantities) - @test_throws Exception assess_local_identifiability(incomplete_network; measured_quantities) - @test_throws Exception find_identifiable_functions(incomplete_network; measured_quantities) + @test_throws Exception assess_identifiability(incomplete_network; measured_quantities, loglevel) + @test_throws Exception assess_local_identifiability(incomplete_network; measured_quantities, loglevel) + @test_throws Exception find_identifiable_functions(incomplete_network; measured_quantities, loglevel) end \ No newline at end of file diff --git a/test/miscellaneous_tests/api.jl b/test/miscellaneous_tests/api.jl index 2a54807930..9f2cddbb20 100644 --- a/test/miscellaneous_tests/api.jl +++ b/test/miscellaneous_tests/api.jl @@ -314,7 +314,7 @@ end # Test defaults. # Uses mutating stuff (`setdefaults!`) and order dependent input (`species(rn) .=> u0`). -# If you want to test this here @Sam I can write a new one that simualtes using defaults. +# If you want to test this here @Sam I can write a new one that simulates using defaults. # If so, tell me if you have anything specific you want to check though, or I will just implement # it as I would. let diff --git a/test/miscellaneous_tests/reactionsystem_serialisation.jl b/test/miscellaneous_tests/reactionsystem_serialisation.jl new file mode 100644 index 0000000000..79e06c4ab1 --- /dev/null +++ b/test/miscellaneous_tests/reactionsystem_serialisation.jl @@ -0,0 +1,528 @@ +### Prepare Tests ### + +# Fetch packages. +using Catalyst, Test +using Catalyst: get_rxs +using ModelingToolkit: getdefault, getdescription, get_metadata +using Symbolics: getmetadata + +# Creates missing getters for MTK metadata (can be removed once added to MTK). +getmisc(x) = getmetadata(Symbolics.unwrap(x), ModelingToolkit.VariableMisc, nothing) +getinput(x) = getmetadata(Symbolics.unwrap(x), ModelingToolkit.VariableInput, nothing) + +# Sets the default `t` and `D` to use. +t = default_t() +D = default_time_deriv() + + +### Basic Test ### + +# Checks for a simple reaction network (containing variables, equations, and observables). +# Checks that declaration via DSL works. +# Checks annotated and non-annotated files against manually written ones. +let + # Creates and serialises the model. + rn = @reaction_network rn begin + @observables X2 ~ 2X + @equations D(V) ~ 1 - V + d, X --> 0 + end + save_reactionsystem("test_serialisation_annotated.jl", rn; safety_check = false) + save_reactionsystem("test_serialisation.jl", rn; annotate = false, safety_check = false) + + # Checks equivalence. + file_string_annotated = read("test_serialisation_annotated.jl", String) + file_string = read("test_serialisation.jl", String) + file_string_annotated_real = """let + + # Independent variable: + @variables t + + # Parameters: + ps = @parameters d + + # Species: + sps = @species X(t) + + # Variables: + vars = @variables V(t) + + # Reactions: + rxs = [Reaction(d, [X], nothing, [1], nothing)] + + # Equations: + eqs = [Differential(t)(V) ~ 1 - V] + + # Observables: + @variables X2(t) + observed = [X2 ~ 2X] + + # Declares ReactionSystem model: + rs = ReactionSystem([rxs; eqs], t, [sps; vars], ps; name = :rn, observed) + complete(rs) + + end""" + file_string_real = """let + + @variables t + ps = @parameters d + sps = @species X(t) + vars = @variables V(t) + rxs = [Reaction(d, [X], nothing, [1], nothing)] + eqs = [Differential(t)(V) ~ 1 - V] + @variables X2(t) + observed = [X2 ~ 2X] + + rs = ReactionSystem([rxs; eqs], t, [sps; vars], ps; name = :rn, observed) + complete(rs) + + end""" + @test file_string_annotated == file_string_annotated_real + @test file_string == file_string_real + + # Deletes the files. + rm("test_serialisation_annotated.jl") + rm("test_serialisation.jl") +end + +# Tests for hierarchical system created programmatically. +# Checks that the species, variables, and parameters have their non-default types, default values, +# and metadata recorded correctly (these are not considered for system equality is tested). +# Checks that various types (processed by the `x_2_string` function) are serialised properly. +# Checks that `ReactionSystem` and `Reaction` metadata fields are recorded properly. +let + # Prepares various stuff to add as metadata. + bool_md = false + int_md = 3 + float_md = 1.2 + rat_md = 4//5 + sym_md = :sym + c_md = 'c' + str_md = "A string" + nothing_md = nothing + @parameters s r + symb_md = s + expr_md = 2s + r^3 + pair_md = rat_md => symb_md + tup_md = (float_md, str_md, expr_md) + vec_md = [float_md, sym_md, tup_md] + dict_md = Dict([c_md => str_md, symb_md => vec_md]) + mat_md = [rat_md sym_md; symb_md tup_md] + + # Creates parameters, variables, and species (with various metadata and default values). + @parameters begin + a, [input=bool_md] + b, [misc=int_md] + c = float_md, [misc=rat_md] + d1, [misc=c_md] + d2, [description=str_md] + e1, [misc=nothing_md] + e2, [misc=symb_md] + end + @variables begin + A(t) = float_md + B(t), [misc=expr_md] + C(t), [misc=pair_md] + D1(t), [misc=tup_md] + D2(t), [misc=vec_md] + E1(t), [misc=dict_md] + E2(t), [misc=mat_md] + end + @species begin + X(t), [input=bool_md] + Y(t), [misc=int_md] + Z(t), [misc=float_md] + V1(t), [description=str_md] + V2(t), [misc=dict_md] + W1(t), [misc=mat_md] + W2(t) = float_md + end + + # Creates the hierarchical model. Adds metadata to both reactions and the systems. + # First reaction has `+ s + r` as that is easier than manually listing all symbolics. + # (These needs to be part of the system somehow, as they are only added through the `misc` metadata) + rxs1 = [ + Reaction(a + A + s + r, [X], [], metadata = [:misc => bool_md]) + Reaction(b + B, [Y], [], metadata = [:misc => int_md]) + Reaction(c + C, [Z], [], metadata = [:misc => sym_md]) + Reaction(d1 + D1, [V1], [], metadata = [:misc => str_md]) + Reaction(e1 + E1, [W1], [], metadata = [:misc => nothing_md]) + ] + rxs2 = [ + Reaction(a + A, [X], [], metadata = [:misc => expr_md]) + Reaction(b + B, [Y], [], metadata = [:misc => tup_md]) + Reaction(c + C, [Z], [], metadata = [:misc => vec_md]) + Reaction(d2 + D2, [V2], [], metadata = [:misc => dict_md]) + Reaction(e2 + E2, [W2], [], metadata = [:misc => mat_md]) + ] + @named rs2 = ReactionSystem(rxs2, t; metadata = dict_md) + @named rs1 = ReactionSystem(rxs1, t; systems = [rs2], metadata = mat_md) + rs = complete(rs1) + + # Loads the model and checks that it is correct. Removes the saved file + save_reactionsystem("serialised_rs.jl", rs; safety_check = false) + rs_loaded = include("../serialised_rs.jl") + @test rs == rs_loaded + rm("serialised_rs.jl") + + # Checks that parameters/species/variables metadata fields are correct. + @test isequal(getinput(rs_loaded.a), bool_md) + @test isequal(getmisc(rs_loaded.b), int_md) + @test isequal(getdefault(rs_loaded.c), float_md) + @test isequal(getmisc(rs_loaded.c), rat_md) + @test isequal(getmisc(rs_loaded.d1), c_md) + @test isequal(getdescription(rs_loaded.rs2.d2), str_md) + @test isequal(getmisc(rs_loaded.e1), nothing_md) + @test isequal(getmisc(rs_loaded.rs2.e2), symb_md) + + @test isequal(getdefault(rs.A), float_md) + @test isequal(getmisc(rs_loaded.B), expr_md) + @test isequal(getmisc(rs_loaded.C), pair_md) + @test isequal(getmisc(rs_loaded.D1), tup_md) + @test isequal(getmisc(rs_loaded.rs2.D2), vec_md) + @test isequal(getmisc(rs_loaded.E1), dict_md) + @test isequal(getmisc(rs_loaded.rs2.E2), mat_md) + + @test isequal(getinput(rs_loaded.X), bool_md) + @test isequal(getmisc(rs_loaded.Y), int_md) + @test isequal(getmisc(rs_loaded.Z), float_md) + @test isequal(getdescription(rs_loaded.V1), str_md) + @test isequal(getmisc(rs_loaded.rs2.V2), dict_md) + @test isequal(getmisc(rs_loaded.W1), mat_md) + @test isequal(getdefault(rs_loaded.rs2.W2), float_md) + + # Checks that `Reaction` metadata fields are correct. + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded)[1]), bool_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded)[2]), int_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded)[3]), sym_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded)[4]), str_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded)[5]), nothing_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded.rs2)[1]), expr_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded.rs2)[2]), tup_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded.rs2)[3]), vec_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded.rs2)[4]), dict_md) + @test isequal(Catalyst.getmisc(get_rxs(rs_loaded.rs2)[5]), mat_md) + + # Checks that `ReactionSystem` metadata fields are correct. + @test isequal(get_metadata(rs_loaded), mat_md) + @test isequal(get_metadata(rs_loaded.rs2), dict_md) +end + +# Checks systems where parameters/species/variables have complicated interdependency are correctly +# serialised. +# Checks for system with non-default independent variable. +let + # Prepares parameters/variables/species with complicated dependencies. + @variables τ + @parameters begin + b = 3.0 + c + f + end + @variables begin + A(τ) = c + B(τ) = c + A + f + C(τ) = 2.0 + D(τ) = C + G(τ) + end + @species begin + Y(τ) = f + Z(τ) + U(τ) = G + Z + V(τ) + end + @parameters begin + a = G + D + e = U + end + @variables begin + E(τ) = G + Z + F(τ) = f + G + end + @species begin + X(τ) = f + a + end + @parameters d = X + @species W(τ) = d + e + + # Creates model and checks it against serialised version. + @named rs = ReactionSystem([], τ, [X, Y, Z, U, V, W, A, B, C, D, E, F, G], [a, b, c, d, e, f]) + save_reactionsystem("serialised_rs.jl", rs; safety_check = false) + @test rs == include("../serialised_rs.jl") + rm("serialised_rs.jl") +end + + +# Tests for multi-layered hierarchical system. Tests with spatial independent variables, +# variables, (differential and algebraic) equations, observables (continuous and discrete) events, +# and with various species/variables/parameter/reaction/system metadata. +# Tests for complete and incomplete system. +let + # Prepares spatial independent variables (technically not used and only supplied to systems). + sivs = @variables x y z [description="A spatial independent variable."] + + # Prepares parameters, species, and variables. + @parameters p d k1_1 k2_1 k1_2 k2_2 k1_3 k2_3 k1_4 k2_4 a b_1 b_2 b_3 b_4 η + @parameters begin + t_1 = 2.0 + t_2::Float64 + t_3, [description="A parameter."] + t_4::Float32 = p, [description="A parameter."] + end + @species X(t) X2_1(t) X2_2(t) X2_3(t) X2_4(t)=p [description="A species."] + @variables A(t)=p [description="A variable."] B_1(t) B_2(t) B_3(t) B_4(t) + + # Prepares all equations. + eqs_1 = [ + Reaction(p, [], [X]; metadata = [:description => "A reaction"]), + Reaction(d, [X], []; metadata = [:noise_scaling => η]), + Reaction(k1_1, [X], [X2_1], [2], [1]), + Reaction(k2_1, [X2_1], [X], [1], [2]), + D(A) ~ a - A, + A + 2B_1^3 ~ b_1 * X + ] + eqs_2 = [ + Reaction(p, [], [X]; metadata = [:description => "A reaction"]), + Reaction(d, [X], []; metadata = [:noise_scaling => η]), + Reaction(k1_2, [X], [X2_2], [2], [1]), + Reaction(k2_2, [X2_2], [X], [1], [2]), + D(A) ~ a - A, + A + 2B_2^3 ~ b_2 * X + ] + eqs_3 = [ + Reaction(p, [], [X]; metadata = [:description => "A reaction"]), + Reaction(d, [X], []; metadata = [:noise_scaling => η]), + Reaction(k1_3, [X], [X2_3], [2], [1]), + Reaction(k2_3, [X2_3], [X], [1], [2]), + D(A) ~ a - A, + A + 2B_3^3 ~ b_3 * X + ] + eqs_4 = [ + Reaction(p, [], [X]; metadata = [:description => "A reaction"]), + Reaction(d, [X], []; metadata = [:noise_scaling => η]), + Reaction(k1_4, [X], [X2_4], [2], [1]), + Reaction(k2_4, [X2_4], [X], [1], [2]), + D(A) ~ a - A, + A + 2B_4^3 ~ b_4 * X + ] + + # Prepares all events. + continuous_events_1 = [(A ~ t_1) => [A ~ A + 2.0, X ~ X/2]] + continuous_events_2 = [(A ~ t_2) => [A ~ A + 2.0, X ~ X/2]] + continuous_events_3 = [(A ~ t_3) => [A ~ A + 2.0, X ~ X/2]] + continuous_events_4 = [(A ~ t_4) => [A ~ A + 2.0, X ~ X/2]] + discrete_events_1 = [ + 10.0 => [X2_1 ~ X2_1 + 1.0] + [5.0, 10.0] => [b_1 ~ 2 * b_1] + (X > 5.0) => [X2_1 ~ X2_1 + 1.0, X ~ X - 1] + ] + discrete_events_2 = [ + 10.0 => [X2_2 ~ X2_2 + 1.0] + [5.0, 10.0] => [b_2 ~ 2 * b_2] + (X > 5.0) => [X2_2 ~ X2_2 + 1.0, X ~ X - 1] + ] + discrete_events_3 = [ + 10.0 => [X2_3 ~ X2_3 + 1.0] + [5.0, 10.0] => [b_3 ~ 2 * b_3] + (X > 5.0) => [X2_3 ~ X2_3 + 1.0, X ~ X - 1] + ] + discrete_events_4 = [ + 10.0 => [X2_4 ~ X2_4 + 1.0] + [5.0, 10.0] => [b_4 ~ 2 * b_4] + (X > 5.0) => [X2_4 ~ X2_4 + 1.0, X ~ X - 1] + ] + + # Creates the systems. + @named rs_4 = ReactionSystem(eqs_4, t; continuous_events = continuous_events_4, + discrete_events = discrete_events_4, spatial_ivs = sivs, + metadata = "System 4", systems = []) + @named rs_2 = ReactionSystem(eqs_2, t; continuous_events = continuous_events_2, + discrete_events = discrete_events_2, spatial_ivs = sivs, + metadata = "System 2", systems = []) + @named rs_3 = ReactionSystem(eqs_3, t; continuous_events = continuous_events_3, + discrete_events = discrete_events_3, spatial_ivs = sivs, + metadata = "System 3", systems = [rs_4]) + @named rs_1 = ReactionSystem(eqs_1, t; continuous_events = continuous_events_1, + discrete_events = discrete_events_1, spatial_ivs = sivs, + metadata = "System 1", systems = [rs_2, rs_3]) + rs = complete(rs_1) + + # Checks that the correct system is saved (both complete and incomplete ones). + save_reactionsystem("serialised_rs_incomplete.jl", rs_1; safety_check = false) + @test isequal(rs_1, include("../serialised_rs_incomplete.jl")) + save_reactionsystem("serialised_rs_complete.jl", rs; safety_check = false) + @test isequal(rs, include("../serialised_rs_complete.jl")) + rm("serialised_rs_incomplete.jl") + rm("serialised_rs_complete.jl") +end + +# Tests for (slightly more) complicate system created via the DSL. +# Tests for cases where the number of input is untested (i.e. multiple observables and continuous +# events, but single equations and discrete events). +# Tests with and without `safety_check`. +let + # Declares the model. + rs = @reaction_network begin + @equations D(V) ~ 1 - V + @continuous_events begin + [X ~ 5.0] => [X ~ X + 1.0] + [X ~ 20.0] => [X ~ X - 1.0] + end + @discrete_events 5.0 => [d ~ d/2] + d, X --> 0 + end + + # Checks that serialisation works. + save_reactionsystem("serialised_rs_1.jl", rs) + save_reactionsystem("serialised_rs_2.jl", rs; safety_check = false) + isequal(rs, include("../serialised_rs_1.jl")) + isequal(rs, include("../serialised_rs_2.jl")) + rm("serialised_rs_1.jl") + rm("serialised_rs_2.jl") +end + +# Tests for system where species depends on multiple independent variables. +# Tests for system where variables depends on multiple independent variables. +let + rs = @reaction_network begin + @ivs t x y z + @parameters p + @species X(t,x,y) Y(t,x,y) XY(t,x,y) Z(t,x,y) + @variables V(t,x,z) + (kB,kD), X + Y <--> XY + end + save_reactionsystem("serialised_rs.jl", rs) + @test ModelingToolkit.isequal(rs, include("../serialised_rs.jl")) + rm("serialised_rs.jl") +end + + +### Other Tests ### + +# Checks that systems with cached network properties yields a warning. +# Checks that default values as saved properly (even if they have different types). +let + # Prepares model inputs. + @species X1(t) X2(t) + @parameters k1 k2::Int64 + rxs = [ + Reaction(k1, [X1], [X2]), + Reaction(k2, [X2], [X1]) + ] + defaults = Dict((X1 => 1.0, k2 => 2)) + + # Creates model and computes conservation laws. + @named rs = ReactionSystem(rxs, t; defaults) + conservationlaws(rs) + + # Serialises model and then loads and checks it. + @test_logs (:warn, ) match_mode=:any save_reactionsystem("serialised_rs.jl", rs) + rs_loaded = include("../serialised_rs.jl") + @test rs == rs_loaded + @test ModelingToolkit.get_defaults(rs) == ModelingToolkit.get_defaults(rs_loaded) + rm("serialised_rs.jl") +end + +# Tests that an error is generated when non-`ReactionSystem` subs-systems are used. +let + @variables V(t) + @species X(t) + @parameters p d V_max + + rxs = [ + Reaction(p, [], [X]), + Reaction(d, [X], []) + ] + eq = D(V) ~ V_max - V + + @named osys = ODESystem([eq], t) + @named rs = ReactionSystem(rxs, t; systems = [osys]) + @test_throws Exception save_reactionsystem("failed_serialisation.jl", rs) + rm("failed_serialisation.jl") +end + +# Checks that completeness is recorded correctly. +# Checks without turning off the `safety_check` option. +let + # Checks for complete system. + rs_complete = @reaction_network begin + (p,d), 0 <--> X + end + save_reactionsystem("serialised_rs_complete.jl", rs_complete) + rs_complete_loaded = include("../serialised_rs_complete.jl") + @test ModelingToolkit.iscomplete(rs_complete_loaded) + rm("serialised_rs_complete.jl") + + # Checks for non-complete system. + rs_incomplete = @network_component begin + (p,d), 0 <--> X + end + save_reactionsystem("serialised_rs_incomplete.jl", rs_incomplete) + rs_incomplete_loaded = include("../serialised_rs_incomplete.jl") + @test !ModelingToolkit.iscomplete(rs_incomplete_loaded) + rm("serialised_rs_incomplete.jl") +end + +# Tests network without species, reactions, and parameters. +let + # Creates model. + rs = @reaction_network begin + @equations D(V) ~ -V + end + + # Checks its serialisation. + save_reactionsystem("test_serialisation.jl", rs; safety_check = false) + isequal(rs, include("../test_serialisation.jl")) + rm("test_serialisation.jl") +end + +# Tests various corner cases (multiple observables, species observables, non-default combinatoric +# rate law, and rate law disabling) +let + # Creates model. + rs = @reaction_network begin + @combinatoric_ratelaws false + @species Xcount(t) + @observables begin + Xtot ~ X + 2X2 + Xcount ~ X + X2 + end + p, 0 --> X + d*X2, X => 0 + (k1,k2), 2X <--> X2 + end + + # Checks its serialisation. + save_reactionsystem("test_serialisation.jl", rs; safety_check = false) + isequal(rs, include("../test_serialisation.jl")) + rm("test_serialisation.jl") +end + +# Tests saving of empty network. +let + rs = @reaction_network + save_reactionsystem("test_serialisation.jl", rs; safety_check = false) + isequal(rs, include("../test_serialisation.jl")) + rm("test_serialisation.jl") +end + +# Test that serialisation of unknown type (here a function) yields an error. +let + rs = @reaction_network begin + d, X --> 0, [misc = x -> 2x] + end + @test_throws Exception save_reactionsystem("test_serialisation.jl", rs) +end + +# Test connection field. +# Not really used for `ReactionSystem`s right now, so tests the direct function and its warning. +let + rs = @reaction_network begin + d, X --> 0 + end + @test (@test_logs (:warn, ) match_mode=:any Catalyst.get_connection_type_string(rs)) == "" + @test Catalyst.get_connection_type_annotation(rs) == "Connection types:: (OBS: Currently not supported, and hence empty)" +end + + diff --git a/test/miscellaneous_tests/stability_computation.jl b/test/miscellaneous_tests/stability_computation.jl index 36f6125f02..542fcd4e51 100644 --- a/test/miscellaneous_tests/stability_computation.jl +++ b/test/miscellaneous_tests/stability_computation.jl @@ -29,7 +29,7 @@ let :d => 0.5 + rand(rng)) # Computes stability using various jacobian options. - sss = hc_steady_states(rn, ps) + sss = hc_steady_states(rn, ps; show_progress = false) stabs_1 = [steady_state_stability(ss, rn, ps) for ss in sss] stabs_2 = [steady_state_stability(ss, rn, ps; ss_jac = ss_jac) for ss in sss] @@ -71,7 +71,7 @@ let ps_3 = [rn.k1 => 8.0, rn.k2 => 2.0, rn.k3 => 1.0, rn.k4 => 1.5, rn.kD1 => 0.5, rn.kD2 => 4.0] # Computes stability using various input forms, and checks that the output is correct. - sss = hc_steady_states(rn, ps_1; u0 = u0_1) + sss = hc_steady_states(rn, ps_1; u0 = u0_1, show_progress = false) for u0 in [u0_1, u0_2, u0_3, u0_4], ps in [ps_1, ps_2, ps_3] stab_1 = [steady_state_stability(ss, rn, ps) for ss in sss] ss_jac = steady_state_jac(rn; u0 = u0) diff --git a/test/network_analysis/conservation_laws.jl b/test/network_analysis/conservation_laws.jl index d7fa4dc9a4..e962252ae0 100644 --- a/test/network_analysis/conservation_laws.jl +++ b/test/network_analysis/conservation_laws.jl @@ -1,11 +1,19 @@ ### Prepares Tests ### # Fetch packages. -using Catalyst, LinearAlgebra, NonlinearSolve, OrdinaryDiffEq +using Catalyst, JumpProcesses, LinearAlgebra, NonlinearSolve, OrdinaryDiffEq, SteadyStateDiffEq, StochasticDiffEq, Test + +# Sets stable rng number. +using StableRNGs +rng = StableRNG(123456) +seed = rand(rng, 1:100) # Fetch test networks. include("../test_networks.jl") +# Except where we test the warnings, we do not want to print this warning. +remove_conserved_warn = false + ### Basic Tests ### # Tests basic functionality on system with known conservation laws. @@ -42,17 +50,18 @@ end # Tests conservation law computation on large number of networks where we know which have conservation laws. let + # networks for which we know there is no conservation laws. Cs_standard = map(conservationlaws, reaction_networks_standard) - @test all(size(C, 1) == 0 for C in Cs_standard) - Cs_hill = map(conservationlaws, reaction_networks_hill) + @test all(size(C, 1) == 0 for C in Cs_standard) @test all(size(C, 1) == 0 for C in Cs_hill) + # Networks for which there are known conservation laws (stored in `reaction_network_conslaws`). function consequiv(A, B) rank([A; B]) == rank(A) == rank(B) end - Cs_constraint = map(conservationlaws, reaction_networks_constraint) - @test all(consequiv.(Matrix{Int}.(Cs_constraint), reaction_network_constraints)) + Cs_constraint = map(conservationlaws, reaction_networks_conserved) + @test all(consequiv.(Matrix{Int}.(Cs_constraint), reaction_network_conslaws)) end # Tests additional conservation law-related functions. @@ -74,8 +83,25 @@ let @test count(isequal.(conserved_quantity, Num(0))) == 2 end +# Tests that `conservationlaws`'s caches something. +let + # Creates network with/without cached conservation laws. + rn = @reaction_network rn begin + (k1,k2), X1 <--> X2 + end + rn_cached = deepcopy(rn) + conservationlaws(rn_cached) + + # Checks that equality is correct (currently equality does not consider network property caching). + @test rn_cached == rn + @test Catalyst.get_networkproperties(rn_cached) != Catalyst.get_networkproperties(rn) +end + +### Simulation & Solving Tests ### + # Test conservation law elimination. let + # Declares the model rn = @reaction_network begin (k1, k2), A + B <--> C (m1, m2), D <--> E @@ -83,47 +109,52 @@ let b23, F2 --> F3 b31, F3 --> F1 end - osys = complete(convert(ODESystem, rn; remove_conserved = true)) - @unpack A, B, C, D, E, F1, F2, F3, k1, k2, m1, m2, b12, b23, b31 = osys + @unpack A, B, C, D, E, F1, F2, F3, k1, k2, m1, m2, b12, b23, b31 = rn + sps = species(rn) u0 = [A => 10.0, B => 10.0, C => 0.0, D => 10.0, E => 0.0, F1 => 8.0, F2 => 0.0, F3 => 0.0] p = [k1 => 1.0, k2 => 0.1, m1 => 1.0, m2 => 2.0, b12 => 1.0, b23 => 2.0, b31 => 0.1] tspan = (0.0, 20.0) - oprob = ODEProblem(osys, u0, tspan, p) - sol = solve(oprob, Tsit5(); abstol = 1e-10, reltol = 1e-10) + + # Simulates model using ODEs and checks that simulations are identical. + osys = complete(convert(ODESystem, rn; remove_conserved = true, remove_conserved_warn)) + oprob1 = ODEProblem(osys, u0, tspan, p) oprob2 = ODEProblem(rn, u0, tspan, p) - sol2 = solve(oprob2, Tsit5(); abstol = 1e-10, reltol = 1e-10) - oprob3 = ODEProblem(rn, u0, tspan, p; remove_conserved = true) - sol3 = solve(oprob3, Tsit5(); abstol = 1e-10, reltol = 1e-10) - - tv = range(tspan[1], tspan[2], length = 101) - for s in species(rn) - @test isapprox(sol(tv, idxs = s), sol2(tv, idxs = s)) - @test isapprox(sol2(tv, idxs = s), sol2(tv, idxs = s)) - end + oprob3 = ODEProblem(rn, u0, tspan, p; remove_conserved = true, remove_conserved_warn) + osol1 = solve(oprob1, Tsit5(); abstol = 1e-8, reltol = 1e-8, saveat= 0.2) + osol2 = solve(oprob2, Tsit5(); abstol = 1e-8, reltol = 1e-8, saveat= 0.2) + osol3 = solve(oprob3, Tsit5(); abstol = 1e-8, reltol = 1e-8, saveat= 0.2) + @test osol1[sps] ≈ osol2[sps] ≈ osol3[sps] - nsys = complete(convert(NonlinearSystem, rn; remove_conserved = true)) - nprob = NonlinearProblem{true}(nsys, u0, p) - nsol = solve(nprob, NewtonRaphson(); abstol = 1e-10) - nprob2 = ODEProblem(rn, u0, (0.0, 100.0 * tspan[2]), p) - nsol2 = solve(nprob2, Tsit5(); abstol = 1e-10, reltol = 1e-10) - nprob3 = NonlinearProblem(rn, u0, p; remove_conserved = true) - nsol3 = solve(nprob3, NewtonRaphson(); abstol = 1e-10) - for s in species(rn) - @test isapprox(nsol[s], nsol2(tspan[2], idxs = s)) - @test isapprox(nsol2(tspan[2], idxs = s), nsol3[s]) - end + # Checks that steady states found using nonlinear solving and steady state simulations are identical. + nsys = complete(convert(NonlinearSystem, rn; remove_conserved = true, remove_conserved_warn)) + nprob1 = NonlinearProblem{true}(nsys, u0, p) + nprob2 = NonlinearProblem(rn, u0, p) + nprob3 = NonlinearProblem(rn, u0, p; remove_conserved = true, remove_conserved_warn) + ssprob1 = SteadyStateProblem{true}(osys, u0, p) + ssprob2 = SteadyStateProblem(rn, u0, p) + ssprob3 = SteadyStateProblem(rn, u0, p; remove_conserved = true, remove_conserved_warn) + nsol1 = solve(nprob1, NewtonRaphson(); abstol = 1e-8) + # Nonlinear problems cannot find steady states properly without removing conserved species. + nsol3 = solve(nprob3, NewtonRaphson(); abstol = 1e-8) + sssol1 = solve(ssprob1, DynamicSS(Tsit5()); abstol = 1e-8, reltol = 1e-8) + sssol2 = solve(ssprob2, DynamicSS(Tsit5()); abstol = 1e-8, reltol = 1e-8) + sssol3 = solve(ssprob3, DynamicSS(Tsit5()); abstol = 1e-8, reltol = 1e-8) + @test nsol1[sps] ≈ nsol3[sps] ≈ sssol1[sps] ≈ sssol2[sps] ≈ sssol3[sps] - u0 = [A => 100.0, B => 20.0, C => 5.0, D => 10.0, E => 3.0, F1 => 8.0, F2 => 2.0, + # Creates SDEProblems using various approaches. + u0_sde = [A => 100.0, B => 20.0, C => 5.0, D => 10.0, E => 3.0, F1 => 8.0, F2 => 2.0, F3 => 20.0] - ssys = complete(convert(SDESystem, rn; remove_conserved = true)) - sprob = SDEProblem(ssys, u0, tspan, p) - sprob2 = SDEProblem(rn, u0, tspan, p) - sprob3 = SDEProblem(rn, u0, tspan, p; remove_conserved = true) - ists = ModelingToolkit.get_unknowns(ssys) - sts = ModelingToolkit.get_unknowns(rn) - istsidxs = findall(in(ists), sts) - u1 = copy(sprob.u0) + ssys = complete(convert(SDESystem, rn; remove_conserved = true, remove_conserved_warn)) + sprob1 = SDEProblem(ssys, u0_sde, tspan, p) + sprob2 = SDEProblem(rn, u0_sde, tspan, p) + sprob3 = SDEProblem(rn, u0_sde, tspan, p; remove_conserved = true, remove_conserved_warn) + + # Checks that the SDEs f and g function evaluates to the same thing. + ind_us = ModelingToolkit.get_unknowns(ssys) + us = ModelingToolkit.get_unknowns(rn) + ind_uidxs = findall(in(ind_us), us) + u1 = copy(sprob1.u0) u2 = sprob2.u0 u3 = copy(sprob3.u0) du1 = similar(u1) @@ -132,19 +163,131 @@ let g1 = zeros(length(u1), numreactions(rn)) g2 = zeros(length(u2), numreactions(rn)) g3 = zeros(length(u3), numreactions(rn)) - sprob.f(du1, u1, sprob.p, 1.0) - sprob2.f(du2, u2, sprob2.p, 1.0) - sprob3.f(du3, u3, sprob3.p, 1.0) - @test isapprox(du1, du2[istsidxs]) - @test isapprox(du2[istsidxs], du3) - sprob.g(g1, u1, sprob.p, 1.0) - sprob2.g(g2, u2, sprob2.p, 1.0) - sprob3.g(g3, u3, sprob3.p, 1.0) - @test isapprox(g1, g2[istsidxs, :]) - @test isapprox(g2[istsidxs, :], g3) + sprob1.f(du1, sprob1.u0, sprob1.p, 1.0) + sprob2.f(du2, sprob2.u0, sprob2.p, 1.0) + sprob3.f(du3, sprob3.u0, sprob3.p, 1.0) + @test du1 ≈ du2[ind_uidxs] ≈ du3 + sprob1.g(g1, sprob1.u0, sprob1.p, 1.0) + sprob2.g(g2, sprob2.u0, sprob2.p, 1.0) + sprob3.g(g3, sprob3.u0, sprob3.p, 1.0) + @test g1 ≈ g2[ind_uidxs, :] ≈ g3 +end + +# Tests simulations for various input types (using X, rn.X, and :X forms). +# Tests that conservation laws can be generated for system with non-default parameter types. +let + # Prepares the model. + rn = @reaction_network rn begin + @parameters kB::Int64 + (kB,kD), X + Y <--> XY + end + sps = species(rn) + @unpack kB, kD, X, Y, XY = rn + + # Creates `ODEProblem`s using three types of inputs. Checks that solutions are identical. + u0_1 = [X => 2.0, Y => 3.0, XY => 4.0] + u0_2 = [rn.X => 2.0, rn.Y => 3.0, rn.XY => 4.0] + u0_3 = [:X => 2.0, :Y => 3.0, :XY => 4.0] + ps = (kB => 2, kD => 1.5) + oprob1 = ODEProblem(rn, u0_1, 10.0, ps; remove_conserved = true, remove_conserved_warn) + oprob2 = ODEProblem(rn, u0_2, 10.0, ps; remove_conserved = true, remove_conserved_warn) + oprob3 = ODEProblem(rn, u0_3, 10.0, ps; remove_conserved = true, remove_conserved_warn) + @test solve(oprob1)[sps] ≈ solve(oprob2)[sps] ≈ solve(oprob3)[sps] +end + +# Tests conservation laws in SDE simulation. +let + # Creates `SDEProblem`s. + rn = @reaction_network begin + (k1,k2), X1 <--> X2 + end + u0 = Dict([:X1 => 100.0, :X2 => 120.0]) + ps = [:k1 => 0.2, :k2 => 0.15] + sprob = SDEProblem(rn, u0, 10.0, ps; remove_conserved = true, remove_conserved_warn) + + # Checks that conservation laws hold in all simulations. + sol = solve(sprob, ImplicitEM(); seed) + @test sol[:X1] + sol[:X2] ≈ sol[rn.X1 + rn.X2] ≈ fill(u0[:X1] + u0[:X2], length(sol.t)) +end + +# Checks that the conservation law parameter's value can be changed in simulations. +let + # Prepares `ODEProblem`s. + rn = @reaction_network begin + (k1,k2), X1 <--> X2 + end + osys = complete(convert(ODESystem, rn; remove_conserved = true, remove_conserved_warn)) + u0 = [osys.X1 => 1.0, osys.X2 => 1.0] + ps_1 = [osys.k1 => 2.0, osys.k2 => 3.0] + ps_2 = [osys.k1 => 2.0, osys.k2 => 3.0, osys.Γ[1] => 4.0] + oprob1 = ODEProblem(osys, u0, 10.0, ps_1) + oprob2 = ODEProblem(osys, u0, 10.0, ps_2) + + # Checks that the solutions generates the correct conserved quantities. + sol1 = solve(oprob1; saveat = 1.0) + sol2 = solve(oprob2; saveat = 1.0) + @test all(sol1[osys.X1 + osys.X2] .== 2.0) + @test all(sol2[osys.X1 + osys.X2] .== 4.0) end -### ConservedParameter Metadata Tests ### +# Tests system problem updating when conservation laws are eliminated. +# Checks that the correct values are used after the conservation law species are updated. +# Here is an issue related to the broken tests: https://github.com/SciML/Catalyst.jl/issues/952 +let + # Create model and fetch the conservation parameter (Γ). + t = default_t() + @parameters k1 k2 + @species X1(t) X2(t) + rxs = [ + Reaction(k1, [X1], [X2]), + Reaction(k2, [X2], [X1]) + ] + @named rs = ReactionSystem(rxs, t) + osys = convert(ODESystem, complete(rs); remove_conserved = true, remove_conserved_warn = false) + osys = complete(osys) + @unpack Γ = osys + + # Creates an `ODEProblem`. + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + oprob = ODEProblem(osys, u0, (0.0, 1.0), ps) + + # Check `ODEProblem` content. + @test oprob[X1] == 1.0 + @test oprob[X2] == 2.0 + @test oprob.ps[k1] == 0.1 + @test oprob.ps[k2] == 0.2 + @test oprob.ps[Γ[1]] == 3.0 + + # Currently, any kind of updating of species or the conservation parameter(s) is not possible. + + # Update problem parameters using `remake`. + oprob_new = remake(oprob; p = [k1 => 0.3, k2 => 0.4]) + @test oprob_new.ps[k1] == 0.3 + @test oprob_new.ps[k2] == 0.4 + integrator = init(oprob_new, Tsit5()) + @test integrator.ps[k1] == 0.3 + @test integrator.ps[k2] == 0.4 + + # Update problem parameters using direct indexing. + oprob.ps[k1] = 0.5 + oprob.ps[k2] = 0.6 + @test oprob.ps[k1] == 0.5 + @test oprob.ps[k2] == 0.6 + integrator = init(oprob, Tsit5()) + @test integrator.ps[k1] == 0.5 + @test integrator.ps[k2] == 0.6 +end + +### Other Tests ### + +# Checks that `JumpSystem`s with conservation laws cannot be generated. +let + rn = @reaction_network begin + (k1,k2), X1 <--> X2 + end + @test_throws ArgumentError convert(JumpSystem, rn; remove_conserved = true, remove_conserved_warn) +end # Checks that `conserved` metadata is added correctly to parameters. # Checks that the `isconserved` getter function works correctly. @@ -154,7 +297,7 @@ let (k1,k2), X1 <--> X2 (k1,k2), Y1 <--> Y2 end - osys = convert(ODESystem, rs; remove_conserved = true) + osys = convert(ODESystem, rs; remove_conserved = true, remove_conserved_warn) # Checks that the correct parameters have the `conserved` metadata. @test Catalyst.isconserved(osys.Γ[1]) @@ -162,3 +305,59 @@ let @test !Catalyst.isconserved(osys.k1) @test !Catalyst.isconserved(osys.k2) end + +# Checks that conservation law elimination warnings are generated in the correct cases. +let + # Prepare model. + rn = @reaction_network begin + (k1,k2), X1 <--> X2 + end + u0 = [:X1 => 1.0, :X2 => 2.0] + tspan = (0.0, 1.0) + ps = [:k1 => 3.0, :k2 => 4.0] + + # Check warnings in system conversion. + for XSystem in [ODESystem, SDESystem, NonlinearSystem] + @test_nowarn convert(XSystem, rn) + @test_logs (:warn, r"You are creating a system or problem while eliminating conserved quantities. Please *") convert(XSystem, rn; remove_conserved = true) + @test_nowarn convert(XSystem, rn; remove_conserved_warn = false) + @test_nowarn convert(XSystem, rn; remove_conserved = true, remove_conserved_warn = false) + end + + # Checks during problem creation (separate depending on whether they have a time span or not). + for XProblem in [ODEProblem, SDEProblem] + @test_nowarn XProblem(rn, u0, tspan, ps) + @test_logs (:warn, r"You are creating a system or problem while eliminating conserved quantities. Please *") XProblem(rn, u0, tspan, ps; remove_conserved = true) + @test_nowarn XProblem(rn, u0, tspan, ps; remove_conserved_warn = false) + @test_nowarn XProblem(rn, u0, tspan, ps; remove_conserved = true, remove_conserved_warn = false) + end + for XProblem in [NonlinearProblem, SteadyStateProblem] + @test_nowarn XProblem(rn, u0, ps) + @test_logs (:warn, r"You are creating a system or problem while eliminating conserved quantities. Please *") XProblem(rn, u0, ps; remove_conserved = true) + @test_nowarn XProblem(rn, u0, ps; remove_conserved_warn = false) + @test_nowarn XProblem(rn, u0, ps; remove_conserved = true, remove_conserved_warn = false) + end +end + +# Conservation law simulations for vectorised species. +let + # Prepares the model. + t = default_t() + @species X(t)[1:2] + @parameters k[1:2] + rxs = [ + Reaction(k[1], [X[1]], [X[2]]), + Reaction(k[2], [X[2]], [X[1]]) + ] + @named rs = ReactionSystem(rxs, t) + rs = complete(rs) + + # Checks that simulation reaches known equilibrium. + @test_broken false # Currently broken on MTK . + # u0 = [:X => [3.0, 9.0]] + # ps = [:k => [1.0, 2.0]] + # oprob = ODEProblem(rs, u0, (0.0, 1000.0), ps; remove_conserved = true) + # sol = solve(oprob, Vern7()) + # @test sol[X[1]][end] ≈ 8.0 + # @test sol[X[2]][end] ≈ 4.0 +end diff --git a/test/network_analysis/network_properties.jl b/test/network_analysis/network_properties.jl index b5d488642a..811d6418cc 100644 --- a/test/network_analysis/network_properties.jl +++ b/test/network_analysis/network_properties.jl @@ -1,7 +1,9 @@ ### Prepares Tests ### # Fetch packages. -using Catalyst, LinearAlgebra, Test +using Catalyst, LinearAlgebra, Test, StableRNGs + +rng = StableRNG(514) ### Basic Tests ### @@ -40,6 +42,10 @@ let @test isweaklyreversible(MAPK, subnetworks(MAPK)) == false cls = conservationlaws(MAPK) @test Catalyst.get_networkproperties(MAPK).rank == 15 + + k = rand(rng, numparams(MAPK)) + rates = Dict(zip(parameters(MAPK), k)) + @test Catalyst.iscomplexbalanced(MAPK, rates) == false # i=0; # for lcs in linkageclasses(MAPK) # i=i+1 @@ -77,6 +83,10 @@ let @test isweaklyreversible(rn2, subnetworks(rn2)) == false cls = conservationlaws(rn2) @test Catalyst.get_networkproperties(rn2).rank == 6 + + k = rand(rng, numparams(rn2)) + rates = Dict(zip(parameters(rn2), k)) + @test Catalyst.iscomplexbalanced(rn2, rates) == false # i=0; # for lcs in linkageclasses(rn2) # i=i+1 @@ -117,6 +127,10 @@ let @test isweaklyreversible(rn3, subnetworks(rn3)) == false cls = conservationlaws(rn3) @test Catalyst.get_networkproperties(rn3).rank == 10 + + k = rand(rng, numparams(rn3)) + rates = Dict(zip(parameters(rn3), k)) + @test Catalyst.iscomplexbalanced(rn3, rates) == false # i=0; # for lcs in linkageclasses(rn3) # i=i+1 @@ -132,6 +146,18 @@ let # end end +let + rn4 = @reaction_network begin + (k1, k2), C1 <--> C2 + (k3, k4), C2 <--> C3 + (k5, k6), C3 <--> C1 + end + + k = rand(rng, numparams(rn4)) + rates = Dict(zip(parameters(rn4), k)) + @test Catalyst.iscomplexbalanced(rn4, rates) == true +end + ### Tests Reversibility ### # Test function. @@ -154,7 +180,12 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false end + let rn = @reaction_network begin (k2, k1), A1 <--> A2 + A3 @@ -167,6 +198,10 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false end let rn = @reaction_network begin @@ -176,6 +211,9 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false end let rn = @reaction_network begin @@ -186,17 +224,25 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false end let rn = @reaction_network begin (k2, k1), A <--> 2B - (k4, k3), A + C --> D + (k4, k3), A + C <--> D k5, D --> B + E k6, B + E --> A + C end rev = false weak_rev = true testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == true end let rn = @reaction_network begin @@ -206,6 +252,10 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false end let rn = @reaction_network begin @@ -215,12 +265,20 @@ let rev = true weak_rev = true testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == true end let rn = @reaction_network begin (k2, k1), A + B <--> 2A end rev = true weak_rev = true testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == true end let rn = @reaction_network begin @@ -232,6 +290,10 @@ let rev = false weak_rev = true testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == true end let rn = @reaction_network begin @@ -243,4 +305,107 @@ let rev = false weak_rev = false testreversibility(rn, reactioncomplexes(rn)[2], rev, weak_rev) -end \ No newline at end of file + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == false +end + +let + rn = @reaction_network begin + k1, 3A + 2B --> 3C + k2, B + 4D --> 2E + k3, 2E --> 3C + (k4, k5), B + 4D <--> 3A + 2B + k6, F --> B + 4D + k7, 3C --> F + end + + k = rand(rng, numparams(rn)) + rates = Dict(zip(parameters(rn), k)) + @test Catalyst.iscomplexbalanced(rn, rates) == true +end + +### STRONG LINKAGE CLASS TESTS + + +# a) Checks that strong/terminal linkage classes are correctly found. Should identify the (A, B+C) linkage class as non-terminal, since B + C produces D +let + rn = @reaction_network begin + (k1, k2), A <--> B + C + k3, B + C --> D + k4, D --> E + (k5, k6), E <--> 2F + k7, 2F --> D + (k8, k9), D + E <--> G + end + + rcs, D = reactioncomplexes(rn) + slcs = stronglinkageclasses(rn) + tslcs = terminallinkageclasses(rn) + @test length(slcs) == 3 + @test length(tslcs) == 2 + @test issubset([[1,2], [3,4,5], [6,7]], slcs) + @test issubset([[3,4,5], [6,7]], tslcs) +end + +# b) Makes the D + E --> G reaction irreversible. Thus, (D+E) becomes a non-terminal linkage class. Checks whether correctly identifies both (A, B+C) and (D+E) as non-terminal +let + rn = @reaction_network begin + (k1, k2), A <--> B + C + k3, B + C --> D + k4, D --> E + (k5, k6), E <--> 2F + k7, 2F --> D + (k8, k9), D + E --> G + end + + rcs, D = reactioncomplexes(rn) + slcs = stronglinkageclasses(rn) + tslcs = terminallinkageclasses(rn) + @test length(slcs) == 4 + @test length(tslcs) == 2 + @test issubset([[1,2], [3,4,5], [6], [7]], slcs) + @test issubset([[3,4,5], [7]], tslcs) +end + +# From a), makes the B + C <--> D reaction reversible. Thus, the non-terminal (A, B+C) linkage class gets absorbed into the terminal (A, B+C, D, E, 2F) linkage class, and the terminal linkage classes and strong linkage classes coincide. +let + rn = @reaction_network begin + (k1, k2), A <--> B + C + (k3, k4), B + C <--> D + k5, D --> E + (k6, k7), E <--> 2F + k8, 2F --> D + (k9, k10), D + E <--> G + end + + rcs, D = reactioncomplexes(rn) + slcs = stronglinkageclasses(rn) + tslcs = terminallinkageclasses(rn) + @test length(slcs) == 2 + @test length(tslcs) == 2 + @test issubset([[1,2,3,4,5], [6,7]], slcs) + @test issubset([[1,2,3,4,5], [6,7]], tslcs) +end + +# Simple test for strong and terminal linkage classes +let + rn = @reaction_network begin + (k1, k2), A <--> 2B + k3, A --> C + D + (k4, k5), C + D <--> E + k6, 2B --> F + (k7, k8), F <--> 2G + (k9, k10), 2G <--> H + k11, H --> F + end + + rcs, D = reactioncomplexes(rn) + slcs = stronglinkageclasses(rn) + tslcs = terminallinkageclasses(rn) + @test length(slcs) == 3 + @test length(tslcs) == 2 + @test issubset([[1,2], [3,4], [5,6,7]], slcs) + @test issubset([[3,4], [5,6,7]], tslcs) +end diff --git a/test/performance_benchmarks/lattice_reaction_systems_ODE_performance.jl b/test/performance_benchmarks/lattice_reaction_systems_ODE_performance.jl index c3c801c603..804dc4bc05 100644 --- a/test/performance_benchmarks/lattice_reaction_systems_ODE_performance.jl +++ b/test/performance_benchmarks/lattice_reaction_systems_ODE_performance.jl @@ -19,11 +19,11 @@ include("../spatial_test_networks.jl") # Small grid, small, non-stiff, system. let - lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, small_2d_grid) - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs.lattice), :R => 0.0] + lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, small_2d_graph_grid) + u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs), :R => 0.0] pV = SIR_p pE = [:dS => 0.01, :dI => 0.01, :dR => 0.01] - oprob = ODEProblem(lrs, u0, (0.0, 500.0), (pV, pE); jac = false) + oprob = ODEProblem(lrs, u0, (0.0, 500.0), [pV; pE]; jac = false) @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) runtime_target = 0.00027 @@ -35,10 +35,10 @@ end # Large grid, small, non-stiff, system. let lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, large_2d_grid) - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs.lattice), :R => 0.0] + u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs), :R => 0.0] pV = SIR_p pE = [:dS => 0.01, :dI => 0.01, :dR => 0.01] - oprob = ODEProblem(lrs, u0, (0.0, 500.0), (pV, pE); jac = false) + oprob = ODEProblem(lrs, u0, (0.0, 500.0), [pV; pE]; jac = false) @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) runtime_target = 0.12 @@ -49,11 +49,11 @@ end # Small grid, small, stiff, system. let - lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_grid) - u0 = [:X => rand_v_vals(lrs.lattice, 10), :Y => rand_v_vals(lrs.lattice, 10)] + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_graph_grid) + u0 = [:X => rand_v_vals(lrs, 10), :Y => rand_v_vals(lrs, 10)] pV = brusselator_p pE = [:dX => 0.2] - oprob = ODEProblem(lrs, u0, (0.0, 100.0), (pV, pE)) + oprob = ODEProblem(lrs, u0, (0.0, 100.0), [pV; pE]) @test SciMLBase.successful_retcode(solve(oprob, CVODE_BDF(linear_solver=:GMRES))) runtime_target = 0.013 @@ -65,10 +65,10 @@ end # Large grid, small, stiff, system. let lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, large_2d_grid) - u0 = [:X => rand_v_vals(lrs.lattice, 10), :Y => rand_v_vals(lrs.lattice, 10)] + u0 = [:X => rand_v_vals(lrs, 10), :Y => rand_v_vals(lrs, 10)] pV = brusselator_p pE = [:dX => 0.2] - oprob = ODEProblem(lrs, u0, (0.0, 100.0), (pV, pE)) + oprob = ODEProblem(lrs, u0, (0.0, 100.0), [pV; pE]) @test SciMLBase.successful_retcode(solve(oprob, CVODE_BDF(linear_solver=:GMRES))) runtime_target = 11. @@ -80,12 +80,12 @@ end # Small grid, mid-sized, non-stiff, system. let lrs = LatticeReactionSystem(CuH_Amination_system, CuH_Amination_srs_2, - small_2d_grid) + small_2d_graph_grid) u0 = [ - :CuoAc => 0.005 .+ rand_v_vals(lrs.lattice, 0.005), - :Ligand => 0.005 .+ rand_v_vals(lrs.lattice, 0.005), + :CuoAc => 0.005 .+ rand_v_vals(lrs, 0.005), + :Ligand => 0.005 .+ rand_v_vals(lrs, 0.005), :CuoAcLigand => 0.0, - :Silane => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), + :Silane => 0.5 .+ rand_v_vals(lrs, 0.5), :CuHLigand => 0.0, :SilaneOAc => 0.0, :Styrene => 0.16, @@ -99,7 +99,7 @@ let ] pV = CuH_Amination_p pE = [:D1 => 0.1, :D2 => 0.1, :D3 => 0.1, :D4 => 0.1, :D5 => 0.1] - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE); jac = false) + oprob = ODEProblem(lrs, u0, (0.0, 10.0), [pV; pE]; jac = false) @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) runtime_target = 0.0012 @@ -113,10 +113,10 @@ let lrs = LatticeReactionSystem(CuH_Amination_system, CuH_Amination_srs_2, large_2d_grid) u0 = [ - :CuoAc => 0.005 .+ rand_v_vals(lrs.lattice, 0.005), - :Ligand => 0.005 .+ rand_v_vals(lrs.lattice, 0.005), + :CuoAc => 0.005 .+ rand_v_vals(lrs, 0.005), + :Ligand => 0.005 .+ rand_v_vals(lrs, 0.005), :CuoAcLigand => 0.0, - :Silane => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), + :Silane => 0.5 .+ rand_v_vals(lrs, 0.5), :CuHLigand => 0.0, :SilaneOAc => 0.0, :Styrene => 0.16, @@ -130,7 +130,7 @@ let ] pV = CuH_Amination_p pE = [:D1 => 0.1, :D2 => 0.1, :D3 => 0.1, :D4 => 0.1, :D5 => 0.1] - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE); jac = false) + oprob = ODEProblem(lrs, u0, (0.0, 10.0), [pV; pE]; jac = false) @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) runtime_target = 0.56 @@ -141,22 +141,22 @@ end # Small grid, mid-sized, stiff, system. let - lrs = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, small_2d_grid) + lrs = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, small_2d_graph_grid) u0 = [ - :w => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2 => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2v => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :v => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2v2 => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :vP => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :σB => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2σB => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), + :w => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2 => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2v => 0.5 .+ rand_v_vals(lrs, 0.5), + :v => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2v2 => 0.5 .+ rand_v_vals(lrs, 0.5), + :vP => 0.5 .+ rand_v_vals(lrs, 0.5), + :σB => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2σB => 0.5 .+ rand_v_vals(lrs, 0.5), :vPp => 0.0, :phos => 0.4, ] pV = sigmaB_p pE = [:DσB => 0.1, :Dw => 0.1, :Dv => 0.1] - oprob = ODEProblem(lrs, u0, (0.0, 50.0), (pV, pE)) + oprob = ODEProblem(lrs, u0, (0.0, 50.0), [pV; pE]) @test SciMLBase.successful_retcode(solve(oprob, CVODE_BDF(linear_solver=:GMRES))) runtime_target = 0.61 @@ -169,20 +169,20 @@ end let lrs = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, large_2d_grid) u0 = [ - :w => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2 => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2v => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :v => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2v2 => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :vP => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :σB => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :w2σB => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), + :w => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2 => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2v => 0.5 .+ rand_v_vals(lrs, 0.5), + :v => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2v2 => 0.5 .+ rand_v_vals(lrs, 0.5), + :vP => 0.5 .+ rand_v_vals(lrs, 0.5), + :σB => 0.5 .+ rand_v_vals(lrs, 0.5), + :w2σB => 0.5 .+ rand_v_vals(lrs, 0.5), :vPp => 0.0, :phos => 0.4, ] pV = sigmaB_p pE = [:DσB => 0.1, :Dw => 0.1, :Dv => 0.1] - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE)) # Time reduced from 50.0 (which casues Julai to crash). + oprob = ODEProblem(lrs, u0, (0.0, 10.0), [pV; pE]) # Time reduced from 50.0 (which casues Julai to crash). @test SciMLBase.successful_retcode(solve(oprob, CVODE_BDF(linear_solver=:GMRES))) runtime_target = 59. diff --git a/test/reactionsystem_core/coupled_equation_crn_systems.jl b/test/reactionsystem_core/coupled_equation_crn_systems.jl index 33db205dd9..0db49c8964 100644 --- a/test/reactionsystem_core/coupled_equation_crn_systems.jl +++ b/test/reactionsystem_core/coupled_equation_crn_systems.jl @@ -47,7 +47,7 @@ let # Checks that the correct steady state is found through ODEProblem. oprob = ODEProblem(coupled_rs, u0, tspan, ps) osol = solve(oprob, Vern7(); abstol = 1e-8, reltol = 1e-8) - @test osol[end] ≈ [2.0, 1.0] + @test osol.u[end] ≈ [2.0, 1.0] # Checks that the correct steady state is found through NonlinearProblem. nlprob = NonlinearProblem(coupled_rs, u0, ps) @@ -116,7 +116,7 @@ let for coupled_rs in [coupled_rs_prog, coupled_rs_extended, coupled_rs_dsl] oprob = ODEProblem(coupled_rs, u0, tspan, ps) osol = solve(oprob, Vern7(); abstol = 1e-8, reltol = 1e-8) - osol[end] ≈ [10.0, 10.0, 11.0, 11.0] + osol.u[end] ≈ [10.0, 10.0, 11.0, 11.0] end end @@ -160,7 +160,7 @@ let # Checks that the correct steady state is found through ODEProblem. oprob = ODEProblem(coupled_rs, u0, tspan, ps; structural_simplify = true) osol = solve(oprob, Rosenbrock23(); abstol = 1e-8, reltol = 1e-8) - @test osol[end] ≈ [2.0, 3.0] + @test osol.u[end] ≈ [2.0, 3.0] # Checks that the correct steady state is found through NonlinearProblem. nlprob = NonlinearProblem(coupled_rs, u0, ps) @@ -207,7 +207,7 @@ let osol = solve(oprob, Rosenbrock23(); abstol = 1e-8, reltol = 1e-8) sssol = solve(ssprob, DynamicSS(Rosenbrock23()); abstol = 1e-8, reltol = 1e-8) nlsol = solve(nlprob; abstol = 1e-8, reltol = 1e-8) - @test osol[end] ≈ sssol ≈ nlsol + @test osol.u[end] ≈ sssol ≈ nlsol end @@ -293,7 +293,7 @@ end # Checks for both differential and algebraic equations. # Checks for problems, integrators, and solutions yielded by coupled systems. # Checks that metadata, types, and default values are carried through correctly. -@test_broken let # SDEs are currently broken with structural simplify. +@test_broken let # SDEs are currently broken with structural simplify (https://github.com/SciML/ModelingToolkit.jl/issues/2614). # Creates the model @parameters a1 [description="Parameter a1"] a2::Rational{Int64} a3=0.3 a4::Rational{Int64}=4//10 [description="Parameter a4"] @parameters b1 [description="Parameter b1"] b2::Int64 b3 = 3 b4::Int64=4 [description="Parameter b4"] @@ -557,15 +557,12 @@ let @test osol[B][end] ≈ 1.0 # Checks that SteadyState simulation of the system achieves the correct steady state. - # Currently broken due to MTK. - @test_broken begin - ssprob = SteadyStateProblem(coupled_rs, u0, ps; structural_simplify = true) - sssol = solve(oprob, DynamicSS(Vern7()); abstol = 1e-8, reltol = 1e-8) - @test osol[X][end] ≈ 2.0 - @test osol[A][end] ≈ 0.0 atol = 1e-8 - @test osol[D(A)][end] ≈ 0.0 atol = 1e-8 - @test osol[B][end] ≈ 1.0 - end + ssprob = SteadyStateProblem(coupled_rs, u0, ps; structural_simplify = true) + sssol = solve(ssprob, DynamicSS(Vern7()); abstol = 1e-8, reltol = 1e-8) + @test sssol[X][end] ≈ 2.0 + @test sssol[A][end] ≈ 0.0 atol = 1e-8 + @test sssol[D(A)][end] ≈ 0.0 atol = 1e-8 + @test sssol[B][end] ≈ 1.0 # Checks that the steady state can be found by solving a nonlinear problem. # Here `B => 0.1` has to be provided as well (and it shouldn't for the 2nd order ODE), hence the diff --git a/test/reactionsystem_core/custom_crn_functions.jl b/test/reactionsystem_core/custom_crn_functions.jl index 5d1070d65a..c4f115d7c3 100644 --- a/test/reactionsystem_core/custom_crn_functions.jl +++ b/test/reactionsystem_core/custom_crn_functions.jl @@ -2,6 +2,7 @@ # Fetch packages. using Catalyst, Test +using ModelingToolkit: get_continuous_events, get_discrete_events using Symbolics: derivative # Sets stable rng number. @@ -154,4 +155,63 @@ let @test isequal(Catalyst.expand_registered_functions(eq3), 0 ~ V * (X^N) / (X^N + K^N)) @test isequal(Catalyst.expand_registered_functions(eq4), 0 ~ V * (K^N) / (X^N + K^N)) @test isequal(Catalyst.expand_registered_functions(eq5), 0 ~ V * (X^N) / (X^N + Y^N + K^N)) -end \ No newline at end of file +end + +# Ensures that original system is not modified. +let + # Create model with a registered function. + @species X(t) + @variables V(t) + @parameters v K + eqs = [ + Reaction(mm(X,v,K), [], [X]), + mm(V,v,K) ~ V + 1 + ] + @named rs = ReactionSystem(eqs, t) + + # Check that `expand_registered_functions` does not mutate original model. + rs_expanded_funcs = Catalyst.expand_registered_functions(rs) + @test isequal(only(Catalyst.get_rxs(rs)).rate, Catalyst.mm(X,v,K)) + @test isequal(only(Catalyst.get_rxs(rs_expanded_funcs)).rate, v*X/(X + K)) + @test isequal(last(Catalyst.get_eqs(rs)).lhs, Catalyst.mm(V,v,K)) + @test isequal(last(Catalyst.get_eqs(rs_expanded_funcs)).lhs, v*V/(V + K)) +end + +# Tests on model with events. +let + # Creates a model, saves it, and creates an expanded version. + rs = @reaction_network begin + @continuous_events begin + [mm(X,v,K) ~ 1.0] => [X ~ X] + end + @discrete_events begin + [1.0] => [X ~ mmr(X,v,K) + Y*(v + K)] + 1.0 => [X ~ X] + (hill(X,v,K,n) > 1000.0) => [X ~ hillr(X,v,K,n) + 2] + end + v0 + hillar(X,Y,v,K,n), X --> Y + end + rs_saved = deepcopy(rs) + rs_expanded = Catalyst.expand_registered_functions(rs) + + # Checks that the original model is unchanged (equality currently does not consider events). + @test rs == rs_saved + @test get_continuous_events(rs) == get_continuous_events(rs_saved) + @test get_discrete_events(rs) == get_discrete_events(rs_saved) + + # Checks that the new system is expanded. + @unpack v0, X, Y, v, K, n = rs + continuous_events = [ + [v*X/(X + K) ~ 1.0] => [X ~ X] + ] + discrete_events = [ + [1.0] => [X ~ v*K/(X + K) + Y*(v + K)] + 1.0 => [X ~ X] + (v * (X^n) / (X^n + K^n) > 1000.0) => [X ~ v * (K^n) / (X^n + K^n) + 2] + ] + continuous_events = ModelingToolkit.SymbolicContinuousCallback.(continuous_events) + discrete_events = ModelingToolkit.SymbolicDiscreteCallback.(discrete_events) + @test isequal(only(Catalyst.get_rxs(rs_expanded)).rate, v0 + v * (X^n) / (X^n + Y^n + K^n)) + @test isequal(get_continuous_events(rs_expanded), continuous_events) + @test isequal(get_discrete_events(rs_expanded), discrete_events) +end diff --git a/test/reactionsystem_core/events.jl b/test/reactionsystem_core/events.jl index ab15dd68ae..e6de318837 100644 --- a/test/reactionsystem_core/events.jl +++ b/test/reactionsystem_core/events.jl @@ -97,9 +97,9 @@ let @test Symbolics.unwrap(rs_ce_de.α) isa Symbolics.BasicSymbolic{Int64} @test Symbolics.unwrap(rs_de.α) isa Symbolics.BasicSymbolic{Int64} @test Symbolics.unwrap(rs_ce_de.α) isa Symbolics.BasicSymbolic{Int64} - @test getdescription(rs_ce_de.A) == "A species" - @test getdescription(rs_de.A) == "A species" - @test getdescription(rs_ce_de.A) == "A species" + @test ModelingToolkit.getdescription(rs_ce_de.A) == "A species" + @test ModelingToolkit.getdescription(rs_de.A) == "A species" + @test ModelingToolkit.getdescription(rs_ce_de.A) == "A species" # Tests that species/variables/parameters can be accessed correctly one a MTK problem have been created. u0 = [X => 1] @@ -158,7 +158,7 @@ let ] # Declares various misformatted events . - # Relevant MTK issue regarding misformatted events not throwing an early error https://github.com/SciML/ModelingToolkit.jl/issues/2612. + @test_broken false # Some misformatted tests should throw error at this stage, but does not (https://github.com/SciML/ModelingToolkit.jl/issues/2612). continuous_events_bad = [ X ~ 1.0 => [X ~ 0.5], # Scalar condition. [X ~ 1.0] => X ~ 0.5, # Scalar affect. @@ -362,9 +362,9 @@ let sol = solve(jprob, SSAStepper(); seed) # Checks that all `e` parameters have been updated properly. - # Note that periodic discrete events are currently broken for jump processes. + # Note that periodic discrete events are currently broken for jump processes (and unlikely to be fixed soon due to periodic callbacks using the internals of ODE integrator and Datastructures heap implementations). @test sol.ps[:e1] == 1 - @test_broken sol.ps[:e2] == 1 + @test_broken sol.ps[:e2] == 1 # (https://github.com/SciML/JumpProcesses.jl/issues/417) @test sol.ps[:e3] == 1 end @@ -424,21 +424,22 @@ let osol_events = solve(oprob_events, Tsit5()) @test osol == osol_events - # Checks for SDE simulations. + # Checks for SDE simulations (note, non-seed dependant test should be created instead). sprob = SDEProblem(rn, u0, tspan, ps) sprob_events = SDEProblem(rn_events, u0, tspan, ps) ssol = solve(sprob, ImplicitEM(); seed, callback) ssol_events = solve(sprob_events, ImplicitEM(); seed) @test ssol == ssol_events - # Checks for Jump simulations. + # Checks for Jump simulations. (note, non-seed dependant test should be created instead) + # Note that periodic discrete events are currently broken for jump processes (and unlikely to be fixed soon due to have events are implemented). callback = CallbackSet(cb_disc_1, cb_disc_2, cb_disc_3) dprob = DiscreteProblem(rn, u0, tspan, ps) dprob_events = DiscreteProblem(rn_dics_events, u0, tspan, ps) jprob = JumpProblem(rn, dprob, Direct(); rng) jprob_events = JumpProblem(rn_dics_events, dprob_events, Direct(); rng) sol = solve(jprob, SSAStepper(); seed, callback) - @test_broken let # Broken due to. Even if fixed, seeding might not work due to events. + @test_broken let # (https://github.com/SciML/JumpProcesses.jl/issues/417) sol_events = solve(jprob_events, SSAStepper(); seed) @test sol == sol_events end diff --git a/test/reactionsystem_core/parameter_type_designation.jl b/test/reactionsystem_core/parameter_type_designation.jl index d6f7a7d13d..d3098d9cb0 100644 --- a/test/reactionsystem_core/parameter_type_designation.jl +++ b/test/reactionsystem_core/parameter_type_designation.jl @@ -2,7 +2,7 @@ # Fetch packages. using Catalyst, JumpProcesses, NonlinearSolve, OrdinaryDiffEq, StochasticDiffEq, Test -using Symbolics: unwrap +using Symbolics: BasicSymbolic, unwrap # Sets stable rng number. using StableRNGs @@ -47,16 +47,16 @@ end # Tests that parameters stored in the system have the correct type. let - @test Symbolics.unwrap(rs.p1) isa SymbolicUtils.BasicSymbolic{Real} - @test Symbolics.unwrap(rs.d1) isa SymbolicUtils.BasicSymbolic{Real} - @test Symbolics.unwrap(rs.p2) isa SymbolicUtils.BasicSymbolic{Float64} - @test Symbolics.unwrap(rs.d2) isa SymbolicUtils.BasicSymbolic{Float64} - @test Symbolics.unwrap(rs.p3) isa SymbolicUtils.BasicSymbolic{Int64} - @test Symbolics.unwrap(rs.d3) isa SymbolicUtils.BasicSymbolic{Int64} - @test Symbolics.unwrap(rs.p4) isa SymbolicUtils.BasicSymbolic{Float32} - @test Symbolics.unwrap(rs.d4) isa SymbolicUtils.BasicSymbolic{Rational{Int64}} - @test Symbolics.unwrap(rs.p5) isa SymbolicUtils.BasicSymbolic{Rational{Int64}} - @test Symbolics.unwrap(rs.d5) isa SymbolicUtils.BasicSymbolic{Float32} + @test Symbolics.unwrap(rs.p1) isa BasicSymbolic{Real} + @test Symbolics.unwrap(rs.d1) isa BasicSymbolic{Real} + @test Symbolics.unwrap(rs.p2) isa BasicSymbolic{Float64} + @test Symbolics.unwrap(rs.d2) isa BasicSymbolic{Float64} + @test Symbolics.unwrap(rs.p3) isa BasicSymbolic{Int64} + @test Symbolics.unwrap(rs.d3) isa BasicSymbolic{Int64} + @test Symbolics.unwrap(rs.p4) isa BasicSymbolic{Float32} + @test Symbolics.unwrap(rs.d4) isa BasicSymbolic{Rational{Int64}} + @test Symbolics.unwrap(rs.p5) isa BasicSymbolic{Rational{Int64}} + @test Symbolics.unwrap(rs.d5) isa BasicSymbolic{Float32} end # Tests that simulations with differentially typed variables yields correct results. @@ -64,7 +64,7 @@ let for p in p_alts oprob = ODEProblem(rs, u0, (0.0, 1000.0), p; abstol = 1e-10, reltol = 1e-10) sol = solve(oprob, Tsit5()) - @test all(sol[end] .≈ 1.0) + @test all(sol.u[end] .≈ 1.0) end end @@ -88,7 +88,7 @@ let nsol = solve(nprob, NewtonRaphson()) # Checks all stored parameters. - for mtk_struct in [oprob, sprob, dprob, jprob, nprob, oinit, sinit, jinit, osol, ssol, jsol, nsol] + for mtk_struct in [oprob, sprob, dprob, jprob, nprob, oinit, sinit, jinit, ninit, osol, ssol, jsol, nsol] # Checks that all parameters have the correct type. @test unwrap(mtk_struct.ps[p1]) isa Float64 @test unwrap(mtk_struct.ps[d1]) isa Float64 @@ -114,8 +114,8 @@ let @test unwrap(mtk_struct.ps[d5]) == Float32(1.5) end - # Checks all stored variables. - for mtk_struct in [oprob, sprob, dprob, jprob, nprob, oinit, sinit, jinit] + # Checks all stored variables (these should always be `Float64`). + for mtk_struct in [oprob, sprob, dprob, jprob, nprob, oinit, sinit, jinit, ninit] # Checks that all variables have the correct type. @test unwrap(mtk_struct[X1]) isa Float64 @test unwrap(mtk_struct[X2]) isa Float64 @@ -130,10 +130,4 @@ let @test unwrap(mtk_struct[X4]) == 0.4 @test unwrap(mtk_struct[X5]) == 0.5 end - - # This test started working now, probably due to a MTK fix. Need to look at where to put it - # back into the test properly though. - @test_broken false - # Indexing currently broken for NonlinearSystem integrators (MTK intend to support this though). - @test unwrap(ninit.ps[p1]) isa Float64 end \ No newline at end of file diff --git a/test/reactionsystem_core/reaction.jl b/test/reactionsystem_core/reaction.jl index eff2ab20b3..e70544d0fb 100644 --- a/test/reactionsystem_core/reaction.jl +++ b/test/reactionsystem_core/reaction.jl @@ -8,6 +8,89 @@ using ModelingToolkit: value, get_variables! # Sets the default `t` to use. t = default_t() +### Reaction Constructor Tests ### + +# Checks that `Reaction`s can be successfully created using various complicated inputs. +# Checks that the `Reaction`s have the correct type, and the correct net stoichiometries are generated. +let + # Declare symbolic variables. + @parameters k n1 n2::Int32 x [isconstantspecies=true] + @species X(t) Y(t) Z(t) + @variables A(t) + + # Tries for different types of rates (should not matter). + for rate in (k, k*A, 2, 3.0, 4//3) + # Creates `Reaction`s. + rx1 = Reaction(rate, [X], []) + rx2 = Reaction(rate, [x], [Y], [1.5], [1]) + rx3 = Reaction(rate, [x, X], [], [n1 + n2, n2], []) + rx4 = Reaction(rate, [X, Y], [X, Y, Z], [2//3, 3], [1//3, 1, 2]) + rx5 = Reaction(rate, [X, Y], [X, Y, Z], [2, 3], [1, n1, n2]) + rx6 = Reaction(rate, [X], [x], [n1], [1]) + + # Check `Reaction` types. + @test rx1 isa Reaction{Any,Int64} + @test rx2 isa Reaction{Any,Float64} + @test rx3 isa Reaction{Any,Any} + @test rx4 isa Reaction{Any,Rational{Int64}} + @test rx5 isa Reaction{Any,Any} + @test rx6 isa Reaction{Any,Any} + + # Check `Reaction` net stoichiometries. + issetequal(rx1.netstoich, [X => -1]) + issetequal(rx2.netstoich, [x => -1.5, Y => 1.0]) + issetequal(rx3.netstoich, [x => -n1 - n2, X => -n2]) + issetequal(rx4.netstoich, [X => -1//3, Y => -2//1, Z => 2//1]) + issetequal(rx5.netstoich, [X => -1, Y => n1 - 3, Z => n2]) + issetequal(rx6.netstoich, [X => -n1, x => 1]) + end +end + +# Tests that various `Reaction` constructors gives identical inputs. +let + # Declare symbolic variables. + @parameters k n1 n2::Int32 + @species X(t) Y(t) Z(t) + @variables A(t) + + # Tests that the three-argument constructor generates correct result. + @test Reaction(k*A, [X], [Y, Z]) == Reaction(k*A, [X], [Y, Z], [1], [1, 1]) + + # Tests that `[]` and `nothing` can be used interchangeably. + @test Reaction(k*A, [X, Z], nothing) == Reaction(k*A, [X, Z], []) + @test Reaction(k*A, nothing, [Y, Z]) == Reaction(k*A, [], [Y, Z]) + @test Reaction(k*A, [X, Z], nothing, [n1 + n2, 2], nothing) == Reaction(k*A, [X, Z], [], [n1 + n2, 2], []) + @test Reaction(k*A, nothing, [Y, Z], nothing, [n1 + n2, 2]) == Reaction(k*A, [], [Y, Z], [], [n1 + n2, 2]) +end + +# Tests that various incorrect inputs yields errors. +let + # Declare symbolic variables. + @parameters k n1 n2::Int32 + @species X(t) Y(t) Z(t) + @variables A(t) + + # Neither substrates nor products. + @test_throws ArgumentError Reaction(k*A, [], []) + + # Substrate vector not of equal length to substrate stoichiometry vector. + @test_throws ArgumentError Reaction(k*A, [X, X, Z], [], [1, 2], []) + + # Product vector not of equal length to product stoichiometry vector. + @test_throws ArgumentError Reaction(k*A, [], [X, X, Z], [], [1, 2]) + + # Repeated substrates. + @test_throws ArgumentError Reaction(k*A, [X, X, Z], []) + + # Repeated products. + @test_throws ArgumentError Reaction(k*A, [], [Y, Z, Z]) + + # Non-valid reactants (parameter or variable). + @test_throws ArgumentError Reaction(k*A, [], [A]) + @test_throws ArgumentError Reaction(k*A, [], [k]) +end + + ### Test Basic Accessors ### # Tests the `get_variables` function. @@ -42,7 +125,6 @@ end # Tests basic accessor functions. # Tests that repeated metadata entries are not permitted. let - @variables t @parameters k @species X(t) X2(t) @@ -60,7 +142,6 @@ end # Tests accessors for system without metadata. let - @variables t @parameters k @species X(t) X2(t) @@ -77,7 +158,6 @@ end # Tests basic accessor functions. # Tests various metadata types. let - @variables t @parameters k @species X(t) X2(t) @@ -109,17 +189,16 @@ end # Tests the noise scaling metadata. let - @variables t @parameters k η @species X(t) X2(t) r1 = Reaction(k, [X], [X2], [2], [1]) r2 = Reaction(k, [X], [X2], [2], [1]; metadata=[:noise_scaling => η]) - @test !Catalyst.has_noise_scaling(r1) - @test Catalyst.has_noise_scaling(r2) - @test_throws Exception Catalyst.get_noise_scaling(r1) - @test isequal(Catalyst.get_noise_scaling(r2), η) + @test !Catalyst.hasnoisescaling(r1) + @test Catalyst.hasnoisescaling(r2) + @test_throws Exception Catalyst.getnoisescaling(r1) + @test isequal(Catalyst.getnoisescaling(r2), η) end # Tests the description metadata. @@ -131,10 +210,10 @@ let r1 = Reaction(k, [X], [X2], [2], [1]) r2 = Reaction(k, [X], [X2], [2], [1]; metadata=[:description => "A reaction"]) - @test !Catalyst.has_description(r1) - @test Catalyst.has_description(r2) - @test_throws Exception Catalyst.get_description(r1) - @test isequal(Catalyst.get_description(r2), "A reaction") + @test !Catalyst.hasdescription(r1) + @test Catalyst.hasdescription(r2) + @test_throws Exception Catalyst.getdescription(r1) + @test isequal(Catalyst.getdescription(r2), "A reaction") end # Tests the misc metadata. @@ -146,8 +225,8 @@ let r1 = Reaction(k, [X], [X2], [2], [1]) r2 = Reaction(k, [X], [X2], [2], [1]; metadata=[:misc => ('M', :M)]) - @test !Catalyst.has_misc(r1) - @test Catalyst.has_misc(r2) - @test_throws Exception Catalyst.get_misc(r1) - @test isequal(Catalyst.get_misc(r2), ('M', :M)) + @test !Catalyst.hasmisc(r1) + @test Catalyst.hasmisc(r2) + @test_throws Exception Catalyst.getmisc(r1) + @test isequal(Catalyst.getmisc(r2), ('M', :M)) end \ No newline at end of file diff --git a/test/reactionsystem_core/reactionsystem.jl b/test/reactionsystem_core/reactionsystem.jl index 7e854efcc7..538ce0f174 100644 --- a/test/reactionsystem_core/reactionsystem.jl +++ b/test/reactionsystem_core/reactionsystem.jl @@ -159,7 +159,6 @@ end # Test with JumpSystem. let - p = rand(rng, length(k)) @species A(t) B(t) C(t) D(t) E(t) F(t) rxs = [Reaction(k[1], nothing, [A]), # 0 -> A Reaction(k[2], [B], nothing), # B -> 0 @@ -193,27 +192,29 @@ let @test all(map(i -> typeof(equations(js)[i]) <: JumpProcesses.ConstantRateJump, cidxs)) @test all(map(i -> typeof(equations(js)[i]) <: JumpProcesses.VariableRateJump, vidxs)) - pars = rand(rng, length(k)) + p = rand(rng, length(k)) + pmap = parameters(js) .=> p u0 = rand(rng, 2:10, 6) + u0map = unknowns(js) .=> u0 ttt = rand(rng) jumps = Vector{Union{ConstantRateJump, MassActionJump, VariableRateJump}}(undef, length(rxs)) - jumps[1] = MassActionJump(pars[1], Vector{Pair{Int, Int}}(), [1 => 1]) - jumps[2] = MassActionJump(pars[2], [2 => 1], [2 => -1]) - jumps[3] = MassActionJump(pars[3], [1 => 1], [1 => -1, 3 => 1]) - jumps[4] = MassActionJump(pars[4], [3 => 1], [1 => 1, 2 => 1, 3 => -1]) - jumps[5] = MassActionJump(pars[5], [3 => 1], [1 => 2, 3 => -1]) - jumps[6] = MassActionJump(pars[6], [1 => 1, 2 => 1], [1 => -1, 2 => -1, 3 => 1]) - jumps[7] = MassActionJump(pars[7], [2 => 2], [1 => 1, 2 => -2]) - jumps[8] = MassActionJump(pars[8], [1 => 1, 2 => 1], [2 => -1, 3 => 1]) - jumps[9] = MassActionJump(pars[9], [1 => 1, 2 => 1], [1 => -1, 2 => -1, 3 => 1, 4 => 1]) - jumps[10] = MassActionJump(pars[10], [1 => 2], [1 => -2, 3 => 1, 4 => 1]) - jumps[11] = MassActionJump(pars[11], [1 => 2], [1 => -1, 2 => 1]) - jumps[12] = MassActionJump(pars[12], [1 => 1, 2 => 3, 3 => 4], + jumps[1] = MassActionJump(p[1], Vector{Pair{Int, Int}}(), [1 => 1]) + jumps[2] = MassActionJump(p[2], [2 => 1], [2 => -1]) + jumps[3] = MassActionJump(p[3], [1 => 1], [1 => -1, 3 => 1]) + jumps[4] = MassActionJump(p[4], [3 => 1], [1 => 1, 2 => 1, 3 => -1]) + jumps[5] = MassActionJump(p[5], [3 => 1], [1 => 2, 3 => -1]) + jumps[6] = MassActionJump(p[6], [1 => 1, 2 => 1], [1 => -1, 2 => -1, 3 => 1]) + jumps[7] = MassActionJump(p[7], [2 => 2], [1 => 1, 2 => -2]) + jumps[8] = MassActionJump(p[8], [1 => 1, 2 => 1], [2 => -1, 3 => 1]) + jumps[9] = MassActionJump(p[9], [1 => 1, 2 => 1], [1 => -1, 2 => -1, 3 => 1, 4 => 1]) + jumps[10] = MassActionJump(p[10], [1 => 2], [1 => -2, 3 => 1, 4 => 1]) + jumps[11] = MassActionJump(p[11], [1 => 2], [1 => -1, 2 => 1]) + jumps[12] = MassActionJump(p[12], [1 => 1, 2 => 3, 3 => 4], [1 => -1, 2 => -3, 3 => -2, 4 => 3]) - jumps[13] = MassActionJump(pars[13], [1 => 3, 2 => 1], [1 => -3, 2 => -1]) - jumps[14] = MassActionJump(pars[14], Vector{Pair{Int, Int}}(), [1 => 2]) + jumps[13] = MassActionJump(p[13], [1 => 3, 2 => 1], [1 => -3, 2 => -1]) + jumps[14] = MassActionJump(p[14], Vector{Pair{Int, Int}}(), [1 => 2]) jumps[15] = ConstantRateJump((u, p, t) -> p[15] * u[1] / (2 + u[1]), integrator -> (integrator.u[1] -= 1)) @@ -230,35 +231,101 @@ let integrator -> (integrator.u[4] -= 2; integrator.u[5] -= 1; integrator.u[6] += 2)) unknownoid = Dict(unknown => i for (i, unknown) in enumerate(unknowns(js))) - jspmapper = ModelingToolkit.JumpSysMajParamMapper(js, pars) + dprob = DiscreteProblem(js, u0map, (0.0, 10.0), pmap) + mtkpars = dprob.p + jspmapper = ModelingToolkit.JumpSysMajParamMapper(js, mtkpars) symmaj = ModelingToolkit.assemble_maj(equations(js).x[1], unknownoid, jspmapper) - maj = MassActionJump(symmaj.param_mapper(pars), symmaj.reactant_stoch, symmaj.net_stoch, + maj = MassActionJump(symmaj.param_mapper(mtkpars), symmaj.reactant_stoch, symmaj.net_stoch, symmaj.param_mapper, scale_rates = false) for i in midxs - @test_broken abs(jumps[i].scaled_rates - maj.scaled_rates[i]) < 100 * eps() + @test abs(jumps[i].scaled_rates - maj.scaled_rates[i]) < 100 * eps() @test jumps[i].reactant_stoch == maj.reactant_stoch[i] @test jumps[i].net_stoch == maj.net_stoch[i] end for i in cidxs crj = ModelingToolkit.assemble_crj(js, equations(js)[i], unknownoid) - @test_broken isapprox(crj.rate(u0, p, ttt), jumps[i].rate(u0, p, ttt)) - fake_integrator1 = (u = zeros(6), p = p, t = 0.0) - fake_integrator2 = deepcopy(fake_integrator1) + @test isapprox(crj.rate(u0, mtkpars, ttt), jumps[i].rate(u0, p, ttt)) + fake_integrator1 = (u = zeros(6), p = mtkpars, t = 0.0) + fake_integrator2 = (u = zeros(6), p, t = 0.0) crj.affect!(fake_integrator1) jumps[i].affect!(fake_integrator2) - @test fake_integrator1 == fake_integrator2 + @test fake_integrator1.u == fake_integrator2.u end for i in vidxs crj = ModelingToolkit.assemble_vrj(js, equations(js)[i], unknownoid) - @test_broken isapprox(crj.rate(u0, p, ttt), jumps[i].rate(u0, p, ttt)) - fake_integrator1 = (u = zeros(6), p = p, t = 0.0) - fake_integrator2 = deepcopy(fake_integrator1) + @test isapprox(crj.rate(u0, mtkpars, ttt), jumps[i].rate(u0, p, ttt)) + fake_integrator1 = (u = zeros(6), p = mtkpars, t = 0.0) + fake_integrator2 = (u = zeros(6), p, t = 0.0) crj.affect!(fake_integrator1) jumps[i].affect!(fake_integrator2) - @test fake_integrator1 == fake_integrator2 + @test fake_integrator1.u == fake_integrator2.u end end +### Nich Model Declarations ### + +# Checks model with vector species and parameters. +# Checks that it works for programmatic/dsl-based modelling. +# Checks that all forms of model input (parameter/initial condition and vector/non-vector) are +# handled properly. +let + # Declares programmatic model. + @parameters p[1:2] k d1 d2 + @species (X(t))[1:2] Y1(t) Y2(t) + rxs = [ + Reaction(p[1], [], [X[1]]), + Reaction(p[2], [], [X[2]]), + Reaction(k, [X[1]], [Y1]), + Reaction(k, [X[2]], [Y2]), + Reaction(d1, [Y1], []), + Reaction(d2, [Y2], []), + ] + rs_prog = complete(ReactionSystem(rxs, t; name = :rs)) + + # Declares DSL-based model. + rs_dsl = @reaction_network rs begin + @parameters p[1:2] k d1 d2 + @species (X(t))[1:2] Y1(t) Y2(t) + (p[1],p[2]), 0 --> (X[1],X[2]) + k, (X[1],X[2]) --> (Y1,Y2) + (d1,d2), (Y1,Y2) --> 0 + end + + # Checks equivalence. + rs_dsl == rs_prog + + # Creates all possible initial conditions and parameter values. + u0_alts = [ + [X => [2.0, 5.0], Y1 => 0.2, Y2 => 0.5], + [X[1] => 2.0, X[2] => 5.0, Y1 => 0.2, Y2 => 0.5], + [rs_dsl.X => [2.0, 5.0], rs_dsl.Y1 => 0.2, rs_dsl.Y2 => 0.5], + [rs_dsl.X[1] => 2.0, X[2] => 5.0, rs_dsl.Y1 => 0.2, rs_dsl.Y2 => 0.5], + [:X => [2.0, 5.0], :Y1 => 0.2, :Y2 => 0.5] + ] + ps_alts = [ + [p => [1.0, 10.0], d1 => 5.0, d2 => 4.0, k => 2.0], + [p[1] => 1.0, p[2] => 10.0, d1 => 5.0, d2 => 4.0, k => 2.0], + [rs_dsl.p => [1.0, 10.0], rs_dsl.d1 => 5.0, rs_dsl.d2 => 4.0, rs_dsl.k => 2.0], + [rs_dsl.p[1] => 1.0, p[2] => 10.0, rs_dsl.d1 => 5.0, rs_dsl.d2 => 4.0, rs_dsl.k => 2.0], + [:p => [1.0, 10.0], :d1 => 5.0, :d2 => 4.0, :k => 2.0] + ] + + # Loops through all inputs and check that the correct steady state is reached + # Target steady state: (X1, X2, Y1, Y2) = (p1/k, p2/k, p1/d1, p2/d2). + # Technically only one model needs to be check. However, "equivalent" models in MTK can still + # have slight differences, so checking for both here to be certain. + for rs in [rs_prog, rs_dsl] + oprob = ODEProblem(rs, u0_alts[1], (0.0, 10000.), ps_alts[1]) + @test_broken false # Cannot currently `remake` this problem/ + # for rs in [rs_prog, rs_dsl], u0 in u0_alts, p in ps_alts + # oprob_remade = remake(oprob; u0, p) + # sol = solve(oprob_remade, Vern7(); abstol = 1e-8, reltol = 1e-8) + # @test sol[end] ≈ [0.5, 5.0, 0.2, 2.5] + # end + end +end + +### Other Tests ### ### Test Show ### @@ -356,7 +423,7 @@ let oprob1 = ODEProblem(osys, u0map, tspan, pmap) sts = [B, D, E, C] syms = [:B, :D, :E, :C] - ofun = ODEFunction(f!; syms) + ofun = ODEFunction(f!; sys = ModelingToolkit.SymbolCache(syms)) oprob2 = ODEProblem(ofun, u0, tspan, p) saveat = tspan[2] / 50 abstol = 1e-10 @@ -439,7 +506,7 @@ let umean += sol(10.0, idxs = [B1, B2, B3, C]) end umean /= Nsims - @test isapprox(umean[1], umean[2]; rtol = 1e-2) + @test isapprox(umean[1], umean[2]; rtol = 1e-2) @test isapprox(umean[1], umean[3]; rtol = 1e-2) @test umean[4] == 10 end @@ -740,4 +807,51 @@ let @test nameof(ModelingToolkit.get_iv(empty_network)) == :t @test length(ModelingToolkit.get_unknowns(empty_network)) == 0 @test length(ModelingToolkit.get_ps(empty_network)) == 0 -end \ No newline at end of file +end + +# Checks that the `reactionsystem_uptodate` function work. If it does not, the ReactionSystem +# structure's fields have been updated, without updating the `reactionsystem_fields` constant. If so, +# there are several places in the code where the `reactionsystem_uptodate` function is called, here +# the code might need adaptation to take the updated reaction system into account. +let + @test_nowarn Catalyst.reactionsystem_uptodate_check() +end + +# Test that functions using the incidence matrix properly cache it +let + rn = @reaction_network begin + k1, A --> B + k2, B --> C + k3, C --> A + end + + nps = Catalyst.get_networkproperties(rn) + @test isempty(nps.incidencemat) == true + + img = incidencematgraph(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + lcs = linkageclasses(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + sns = subnetworks(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + δ = deficiency(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + δ_l = linkagedeficiencies(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + rev = isreversible(rn) + @test size(nps.incidencemat) == (3,3) + + Catalyst.reset!(nps) + weakrev = isweaklyreversible(rn, sns) + @test size(nps.incidencemat) == (3,3) +end diff --git a/test/reactionsystem_core/symbolic_stoichiometry.jl b/test/reactionsystem_core/symbolic_stoichiometry.jl index 797a78f239..c2ec784919 100644 --- a/test/reactionsystem_core/symbolic_stoichiometry.jl +++ b/test/reactionsystem_core/symbolic_stoichiometry.jl @@ -2,7 +2,7 @@ # Fetch packages. using Catalyst, JumpProcesses, OrdinaryDiffEq, StochasticDiffEq, Statistics, Test -using Symbolics: unwrap +using Symbolics: BasicSymbolic, unwrap # Sets stable rng number. using StableRNGs @@ -46,8 +46,8 @@ let @test rs1 == rs2 == rs3 @test issetequal(unknowns(rs1), [X, Y]) @test issetequal(parameters(rs1), [p, k, d, n1, n2, n3]) - @test unwrap(d) isa SymbolicUtils.BasicSymbolic{Float64} - @test unwrap(n1) isa SymbolicUtils.BasicSymbolic{Int64} + @test unwrap(d) isa BasicSymbolic{Float64} + @test unwrap(n1) isa BasicSymbolic{Int64} end # Declares a network, parameter values, and initial conditions, to be used for the next couple of tests. diff --git a/test/runtests.jl b/test/runtests.jl index 8f255921d7..4197706e0d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -10,71 +10,68 @@ using SafeTestsets, Test ### Run Tests ### @time begin - #if GROUP == "All" || GROUP == "ModelCreation" - # Tests the `ReactionSystem` structure and its properties. - @time @safetestset "Reaction Structure" begin include("reactionsystem_core/reaction.jl") end - @time @safetestset "ReactionSystem Structure" begin include("reactionsystem_core/reactionsystem.jl") end - @time @safetestset "Higher Order Reactions" begin include("reactionsystem_core/higher_order_reactions.jl") end - @time @safetestset "Symbolic Stoichiometry" begin include("reactionsystem_core/symbolic_stoichiometry.jl") end - @time @safetestset "Parameter Type Designation" begin include("reactionsystem_core/parameter_type_designation.jl") end - @time @safetestset "Custom CRN Functions" begin include("reactionsystem_core/custom_crn_functions.jl") end - # @time @safetestset "Coupled CRN/Equation Systems" begin include("reactionsystem_core/coupled_equation_crn_systems.jl") end - @time @safetestset "Events" begin include("reactionsystem_core/events.jl") end - - # Tests model creation via the @reaction_network DSL. - @time @safetestset "DSL Basic Model Construction" begin include("dsl/dsl_basic_model_construction.jl") end - @time @safetestset "DSL Advanced Model Construction" begin include("dsl/dsl_advanced_model_construction.jl") end - @time @safetestset "DSL Options" begin include("dsl/dsl_options.jl") end - - # Tests compositional and hierarchical modelling. - @time @safetestset "ReactionSystem Components Based Creation" begin include("compositional_modelling/component_based_model_creation.jl") end - #end - - #if GROUP == "All" || GROUP == "Miscellaneous-NetworkAnalysis" - # Tests various miscellaneous features. - @time @safetestset "API" begin include("miscellaneous_tests/api.jl") end - @time @safetestset "Units" begin include("miscellaneous_tests/units.jl") end - @time @safetestset "Steady State Stability Computations" begin include("miscellaneous_tests/stability_computation.jl") end - @time @safetestset "Compound Species" begin include("miscellaneous_tests/compound_macro.jl") end - @time @safetestset "Reaction Balancing" begin include("miscellaneous_tests/reaction_balancing.jl") end - - # Tests reaction network analysis features. - @time @safetestset "Conservation Laws" begin include("network_analysis/conservation_laws.jl") end - @time @safetestset "Network Properties" begin include("network_analysis/network_properties.jl") end - #end - - #if GROUP == "All" || GROUP == "Simulation" - # Tests ODE, SDE, jump simulations, nonlinear solving, and steady state simulations. - @time @safetestset "ODE System Simulations" begin include("simulation_and_solving/simulate_ODEs.jl") end - @time @safetestset "Automatic Jacobian Construction" begin include("simulation_and_solving/jacobian_construction.jl") end - @time @safetestset "SDE System Simulations" begin include("simulation_and_solving/simulate_SDEs.jl") end - @time @safetestset "Jump System Simulations" begin include("simulation_and_solving/simulate_jumps.jl") end - @time @safetestset "Nonlinear and SteadyState System Solving" begin include("simulation_and_solving/solve_nonlinear.jl") end - - # Tests upstream SciML and DiffEq stuff. - @time @safetestset "MTK Structure Indexing" begin include("upstream/mtk_structure_indexing.jl") end - @time @safetestset "MTK Problem Inputs" begin include("upstream/mtk_problem_inputs.jl") end - #end - - #if GROUP == "All" || GROUP == "Spatial" - # Tests spatial modelling and simulations. - @time @safetestset "PDE Systems Simulations" begin include("spatial_modelling/simulate_PDEs.jl") end - @time @safetestset "Lattice Reaction Systems" begin include("spatial_modelling/lattice_reaction_systems.jl") end - @time @safetestset "ODE Lattice Systems Simulations" begin include("spatial_modelling/lattice_reaction_systems_ODEs.jl") end - #end - - #if GROUP == "All" || GROUP == "Visualisation-Extensions" - # Tests network visualisation. - @time @safetestset "Latexify" begin include("visualisation/latexify.jl") end - # Disable on Macs as can't install GraphViz via jll - if !Sys.isapple() - @time @safetestset "Graphs Visualisations" begin include("visualisation/graphs.jl") end - end - - # Tests extensions. - # @time @safetestset "BifurcationKit Extension" begin include("extensions/bifurcation_kit.jl") end - # @time @safetestset "HomotopyContinuation Extension" begin include("extensions/homotopy_continuation.jl") end - # @time @safetestset "Structural Identifiability Extension" begin include("extensions/structural_identifiability.jl") end - #end + # Tests the `ReactionSystem` structure and its properties. + @time @safetestset "Reaction Structure" begin include("reactionsystem_core/reaction.jl") end + @time @safetestset "ReactionSystem Structure" begin include("reactionsystem_core/reactionsystem.jl") end + @time @safetestset "Higher Order Reactions" begin include("reactionsystem_core/higher_order_reactions.jl") end + @time @safetestset "Symbolic Stoichiometry" begin include("reactionsystem_core/symbolic_stoichiometry.jl") end + @time @safetestset "Parameter Type Designation" begin include("reactionsystem_core/parameter_type_designation.jl") end + @time @safetestset "Custom CRN Functions" begin include("reactionsystem_core/custom_crn_functions.jl") end + @time @safetestset "Coupled CRN/Equation Systems" begin include("reactionsystem_core/coupled_equation_crn_systems.jl") end + @time @safetestset "Events" begin include("reactionsystem_core/events.jl") end + + # Tests model creation via the @reaction_network DSL. + @time @safetestset "DSL Basic Model Construction" begin include("dsl/dsl_basic_model_construction.jl") end + @time @safetestset "DSL Advanced Model Construction" begin include("dsl/dsl_advanced_model_construction.jl") end + @time @safetestset "DSL Options" begin include("dsl/dsl_options.jl") end + + # Tests compositional and hierarchical modelling. + @time @safetestset "ReactionSystem Components Based Creation" begin include("compositional_modelling/component_based_model_creation.jl") end + + # Tests various miscellaneous features. + @time @safetestset "API" begin include("miscellaneous_tests/api.jl") end + @time @safetestset "Units" begin include("miscellaneous_tests/units.jl") end + @time @safetestset "Compound Species" begin include("miscellaneous_tests/compound_macro.jl") end + @time @safetestset "Reaction Balancing" begin include("miscellaneous_tests/reaction_balancing.jl") end + @time @safetestset "ReactionSystem Serialisation" begin include("miscellaneous_tests/reactionsystem_serialisation.jl") end + + # Tests reaction network analysis features. + @time @safetestset "Conservation Laws" begin include("network_analysis/conservation_laws.jl") end + @time @safetestset "Network Properties" begin include("network_analysis/network_properties.jl") end + + # Tests ODE, SDE, jump simulations, nonlinear solving, and steady state simulations. + @time @safetestset "ODE System Simulations" begin include("simulation_and_solving/simulate_ODEs.jl") end + @time @safetestset "Automatic Jacobian Construction" begin include("simulation_and_solving/jacobian_construction.jl") end + @time @safetestset "SDE System Simulations" begin include("simulation_and_solving/simulate_SDEs.jl") end + @time @safetestset "Jump System Simulations" begin include("simulation_and_solving/simulate_jumps.jl") end + @time @safetestset "Nonlinear and SteadyState System Solving" begin include("simulation_and_solving/solve_nonlinear.jl") end + + # Tests upstream SciML and DiffEq stuff. + @time @safetestset "MTK Structure Indexing" begin include("upstream/mtk_structure_indexing.jl") end + @time @safetestset "MTK Problem Inputs" begin include("upstream/mtk_problem_inputs.jl") end + + # Tests network visualisation. + @time @safetestset "Latexify" begin include("visualisation/latexify.jl") end + # Disable on Macs as can't install GraphViz via jll + if !Sys.isapple() + @time @safetestset "Graphs Visualisations" begin include("visualisation/graphs.jl") end + end + + # Tests extensions. + @time @safetestset "BifurcationKit Extension" begin include("extensions/bifurcation_kit.jl") end + @time @safetestset "HomotopyContinuation Extension" begin include("extensions/homotopy_continuation.jl") end + @time @safetestset "Structural Identifiability Extension" begin include("extensions/structural_identifiability.jl") end + + # Tests stability computation (uses HomotopyContinuation extension). + @time @safetestset "Steady State Stability Computations" begin include("miscellaneous_tests/stability_computation.jl") end + + # Tests spatial modelling and simulations. + @time @safetestset "PDE Systems Simulations" begin include("spatial_modelling/simulate_PDEs.jl") end + @time @safetestset "Spatial Reactions" begin include("spatial_modelling/spatial_reactions.jl") end + @time @safetestset "Lattice Reaction Systems" begin include("spatial_modelling/lattice_reaction_systems.jl") end + @time @safetestset "Spatial Lattice Variants" begin include("spatial_modelling/lattice_reaction_systems_lattice_types.jl") end + @time @safetestset "ODE Lattice Systems Simulations" begin include("spatial_modelling/lattice_reaction_systems_ODEs.jl") end + @time @safetestset "Jump Lattice Systems Simulations" begin include("spatial_modelling/lattice_reaction_systems_jumps.jl") end + @time @safetestset "Jump Solution Interfacing" begin include("spatial_modelling/lattice_solution_interfacing.jl") end end # @time diff --git a/test/simulation_and_solving/simulate_ODEs.jl b/test/simulation_and_solving/simulate_ODEs.jl index b706b187e3..ecb2a468ce 100644 --- a/test/simulation_and_solving/simulate_ODEs.jl +++ b/test/simulation_and_solving/simulate_ODEs.jl @@ -104,7 +104,7 @@ let du[2] = -k3 * X2 + k4 * X3 + k1 * X1 - k2 * X2 du[3] = -k5 * X3 + k6 * X1 + k3 * X2 - k4 * X3 end - push!(catalyst_networks, reaction_networks_constraint[1]) + push!(catalyst_networks, reaction_networks_conserved[1]) push!(manual_networks, real_functions_4) push!(u0_syms, [:X1, :X2, :X3]) push!(ps_syms, [:k1, :k2, :k3, :k4, :k5, :k6]) diff --git a/test/simulation_and_solving/simulate_SDEs.jl b/test/simulation_and_solving/simulate_SDEs.jl index 5684db4132..485cad85f8 100644 --- a/test/simulation_and_solving/simulate_SDEs.jl +++ b/test/simulation_and_solving/simulate_SDEs.jl @@ -2,6 +2,7 @@ # Fetch packages. using Catalyst, Statistics, StochasticDiffEq, Test +using Catalyst: getnoisescaling # Sets stable rng number. using StableRNGs @@ -106,7 +107,7 @@ let du[7, 5] = sqrt(k5 * X5 * X6) du[7, 6] = -sqrt(k6 * X7) end - push!(catalyst_networks, reaction_networks_constraint[9]) + push!(catalyst_networks, reaction_networks_conserved[9]) push!(manual_networks, (f = real_f_3, g = real_g_3, nrp = zeros(7, 6))) push!(u0_syms, [:X1, :X2, :X3, :X4, :X5, :X6, :X7]) push!(ps_syms, [:k1, :k2, :k3, :k4, :k5, :k6]) @@ -223,13 +224,14 @@ let @species X1(t) X2(t) p_syms = @parameters $(η_stored) k1 k2 - r1 = Reaction(k1,[X1],[X2],[1],[1]; metadata = [:noise_scaling => η_stored]) - r2 = Reaction(k2,[X2],[X1],[1],[1]; metadata = [:noise_scaling => η_stored]) + r1 = Reaction(k1, [X1], [X2], [1], [1]; metadata = [:noise_scaling => p_syms[1]]) + r2 = Reaction(k2, [X2], [X1], [1], [1]; metadata = [:noise_scaling => p_syms[1]]) @named noise_scaling_network = ReactionSystem([r1, r2], t, [X1, X2], [k1, k2, p_syms[1]]) + noise_scaling_network = complete(noise_scaling_network) u0 = [:X1 => 1100.0, :X2 => 3900.0] p = [:k1 => 2.0, :k2 => 0.5, :η => 0.0] - @test_broken SDEProblem(noise_scaling_network, u0, (0.0, 1000.0), p).ps[:η] == 0.0 # Broken due to SII/MTK stuff. + @test SDEProblem(noise_scaling_network, u0, (0.0, 1000.0), p).ps[:η] == 0.0 end # Complicated test with many combinations of options. @@ -243,8 +245,8 @@ let u0 = [:X1 => 500.0, :X2 => 500.0] p = [:p => 20.0, :d => 0.1, :η1 => 0.0, :η3 => 0.0, :η4 => 0.0, :k1 => 2.0, :k2 => 2.0, :par1 => 1000.0, :par2 => 1000.0] - @test getdescription(parameters(noise_scaling_network)[2]) == "Parameter par1" - @test getdescription(parameters(noise_scaling_network)[5]) == "Parameter η2" + @test ModelingToolkit.getdescription(parameters(noise_scaling_network)[2]) == "Parameter par1" + @test ModelingToolkit.getdescription(parameters(noise_scaling_network)[5]) == "Parameter η2" sprob = SDEProblem(noise_scaling_network, u0, (0.0, 1000.0), p) @test sprob.ps[:η1] == sprob.ps[:η2] == sprob.ps[:η3] == sprob.ps[:η4] == 0.0 @@ -325,8 +327,8 @@ let @test issetequal([X, H], species(rs)) @test issetequal([X, H, h], unknowns(rs)) @test issetequal([p, d, η], parameters(rs)) - @test isequal(get_noise_scaling(reactions(rs)[1]), η*H + 1) - @test isequal(get_noise_scaling(reactions(rs)[2]), h) + @test isequal(getnoisescaling(reactions(rs)[1]), η*H + 1) + @test isequal(getnoisescaling(reactions(rs)[2]), h) end # Tests the `remake_noise_scaling` function. @@ -369,9 +371,9 @@ let # Checks that systems have the correct noise scaling terms. rn = set_default_noise_scaling(rn, 0.5) - rn1_noise_scaling = [get_noise_scaling(rx) for rx in Catalyst.get_rxs(rn)] - rn2_noise_scaling = [get_noise_scaling(rx) for rx in Catalyst.get_rxs(Catalyst.get_systems(rn)[1])] - rn_noise_scaling = [get_noise_scaling(rx) for rx in reactions(rn)] + rn1_noise_scaling = [getnoisescaling(rx) for rx in Catalyst.get_rxs(rn)] + rn2_noise_scaling = [getnoisescaling(rx) for rx in Catalyst.get_rxs(Catalyst.get_systems(rn)[1])] + rn_noise_scaling = [getnoisescaling(rx) for rx in reactions(rn)] @test issetequal(rn1_noise_scaling, [2.0, 0.5]) @test issetequal(rn2_noise_scaling, [5.0, 0.5]) @test issetequal(rn_noise_scaling, [2.0, 0.5, 5.0, 0.5]) diff --git a/test/simulation_and_solving/simulate_jumps.jl b/test/simulation_and_solving/simulate_jumps.jl index 9c8c02db8a..370b51036e 100644 --- a/test/simulation_and_solving/simulate_jumps.jl +++ b/test/simulation_and_solving/simulate_jumps.jl @@ -117,7 +117,7 @@ let jump_3_5 = ConstantRateJump(rate_3_5, affect_3_5!) jump_3_6 = ConstantRateJump(rate_3_6, affect_3_6!) jumps_3 = (jump_3_1, jump_3_2, jump_3_3, jump_3_4, jump_3_5, jump_3_6) - push!(catalyst_networks, reaction_networks_constraint[5]) + push!(catalyst_networks, reaction_networks_conserved[5]) push!(manual_networks, jumps_3) push!(u0_syms, [:X1, :X2, :X3, :X4]) push!(ps_syms, [:k1, :k2, :k3, :k4, :k5, :k6]) diff --git a/test/simulation_and_solving/solve_nonlinear.jl b/test/simulation_and_solving/solve_nonlinear.jl index 1dcb8cd5c1..0315a65ac5 100644 --- a/test/simulation_and_solving/solve_nonlinear.jl +++ b/test/simulation_and_solving/solve_nonlinear.jl @@ -90,7 +90,7 @@ let # Creates NonlinearProblem. u0 = [steady_state_network_3.X => rand(), steady_state_network_3.Y => rand() + 1.0, steady_state_network_3.Y2 => rand() + 3.0, steady_state_network_3.XY2 => 0.0] p = [:p => rand()+1.0, :d => 0.5, :k1 => 1.0, :k2 => 2.0, :k3 => 3.0, :k4 => 4.0] - nl_prob_1 = NonlinearProblem(steady_state_network_3, u0, p; remove_conserved = true) + nl_prob_1 = NonlinearProblem(steady_state_network_3, u0, p; remove_conserved = true, remove_conserved_warn = false) nl_prob_2 = NonlinearProblem(steady_state_network_3, u0, p) # Solves it using standard algorithm and simulation based algorithm. diff --git a/test/spatial_modelling/lattice_reaction_systems.jl b/test/spatial_modelling/lattice_reaction_systems.jl index a1fce40846..f36a0a5d4c 100644 --- a/test/spatial_modelling/lattice_reaction_systems.jl +++ b/test/spatial_modelling/lattice_reaction_systems.jl @@ -1,12 +1,13 @@ ### Preparations ### # Fetch packages. -using Catalyst, Graphs, Test -using Symbolics: unwrap -t = default_t() +using Catalyst, Graphs, OrdinaryDiffEq, Test -# Pre declares a grid. -grid = Graphs.grid([2, 2]) +# Fetch test networks. +include("../spatial_test_networks.jl") + +# Pre-declares a set of grids. +grids = [very_small_2d_cartesian_grid, very_small_2d_masked_grid, very_small_2d_graph_grid] ### Tests LatticeReactionSystem Getters Correctness ### @@ -16,16 +17,18 @@ let rs = @reaction_network begin (p, 1), 0 <--> X end - tr = @transport_reaction d X - lrs = LatticeReactionSystem(rs, [tr], grid) - - @unpack X, p = rs - d = edge_parameters(lrs)[1] - @test issetequal(species(lrs), [X]) - @test issetequal(spatial_species(lrs), [X]) - @test issetequal(parameters(lrs), [p, d]) - @test issetequal(vertex_parameters(lrs), [p]) - @test issetequal(edge_parameters(lrs), [d]) + tr = @transport_reaction d X + for grid in grids + lrs = LatticeReactionSystem(rs, [tr], grid) + + @unpack X, p = rs + d = edge_parameters(lrs)[1] + @test issetequal(species(lrs), [X]) + @test issetequal(spatial_species(lrs), [X]) + @test issetequal(parameters(lrs), [p, d]) + @test issetequal(vertex_parameters(lrs), [p]) + @test issetequal(edge_parameters(lrs), [d]) + end end # Test case 2. @@ -48,14 +51,16 @@ let end tr_1 = @transport_reaction dX X tr_2 = @transport_reaction dY Y - lrs = LatticeReactionSystem(rs, [tr_1, tr_2], grid) - - @unpack X, Y, pX, pY, dX, dY = rs - @test issetequal(species(lrs), [X, Y]) - @test issetequal(spatial_species(lrs), [X, Y]) - @test issetequal(parameters(lrs), [pX, pY, dX, dY]) - @test issetequal(vertex_parameters(lrs), [pX, pY, dY]) - @test issetequal(edge_parameters(lrs), [dX]) + for grid in grids + lrs = LatticeReactionSystem(rs, [tr_1, tr_2], grid) + + @unpack X, Y, pX, pY, dX, dY = rs + @test issetequal(species(lrs), [X, Y]) + @test issetequal(spatial_species(lrs), [X, Y]) + @test issetequal(parameters(lrs), [pX, pY, dX, dY]) + @test issetequal(vertex_parameters(lrs), [pX, pY, dY]) + @test issetequal(edge_parameters(lrs), [dX]) + end end # Test case 4. @@ -66,14 +71,16 @@ let (pY, 1), 0 <--> Y end tr_1 = @transport_reaction dX X - lrs = LatticeReactionSystem(rs, [tr_1], grid) - - @unpack dX, p, X, Y, pX, pY = rs - @test issetequal(species(lrs), [X, Y]) - @test issetequal(spatial_species(lrs), [X]) - @test issetequal(parameters(lrs), [dX, p, pX, pY]) - @test issetequal(vertex_parameters(lrs), [dX, p, pX, pY]) - @test issetequal(edge_parameters(lrs), []) + for grid in grids + lrs = LatticeReactionSystem(rs, [tr_1], grid) + + @unpack dX, p, X, Y, pX, pY = rs + @test issetequal(species(lrs), [X, Y]) + @test issetequal(spatial_species(lrs), [X]) + @test issetequal(parameters(lrs), [dX, p, pX, pY]) + @test issetequal(vertex_parameters(lrs), [dX, p, pX, pY]) + @test issetequal(edge_parameters(lrs), []) + end end # Test case 5. @@ -88,21 +95,24 @@ let end @unpack dX, X, V = rs @parameters dV dW + @variables t @species W(t) tr_1 = TransportReaction(dX, X) tr_2 = @transport_reaction dY Y tr_3 = @transport_reaction dZ Z tr_4 = TransportReaction(dV, V) - tr_5 = TransportReaction(dW, W) - lrs = LatticeReactionSystem(rs, [tr_1, tr_2, tr_3, tr_4, tr_5], grid) - - @unpack pX, pY, pZ, pV, dX, dY, X, Y, Z, V = rs - dZ, dV, dW = edge_parameters(lrs)[2:end] - @test issetequal(species(lrs), [W, X, Y, Z, V]) - @test issetequal(spatial_species(lrs), [X, Y, Z, V, W]) - @test issetequal(parameters(lrs), [pX, pY, dX, dY, pZ, pV, dZ, dV, dW]) - @test issetequal(vertex_parameters(lrs), [pX, pY, dY, pZ, pV]) - @test issetequal(edge_parameters(lrs), [dX, dZ, dV, dW]) + tr_5 = TransportReaction(dW, W) + for grid in grids + lrs = LatticeReactionSystem(rs, [tr_1, tr_2, tr_3, tr_4, tr_5], grid) + + @unpack pX, pY, pZ, pV, dX, dY, X, Y, Z, V = rs + dZ, dV, dW = edge_parameters(lrs)[2:end] + @test issetequal(species(lrs), [W, X, Y, Z, V]) + @test issetequal(spatial_species(lrs), [X, Y, Z, V, W]) + @test issetequal(parameters(lrs), [pX, pY, dX, dY, pZ, pV, dZ, dV, dW]) + @test issetequal(vertex_parameters(lrs), [pX, pY, dY, pZ, pV]) + @test issetequal(edge_parameters(lrs), [dX, dZ, dV, dW]) + end end # Test case 6. @@ -111,131 +121,55 @@ let (p, 1), 0 <--> X end tr = @transport_reaction d X - lrs = LatticeReactionSystem(rs, [tr], grid) - - @test nameof(lrs) == :customname -end - -### Tests Spatial Reactions Getters Correctness ### - -# Test case 1. -let - tr_1 = @transport_reaction dX X - tr_2 = @transport_reaction dY1*dY2 Y - - # @test ModelingToolkit.getname.(species(tr_1)) == ModelingToolkit.getname.(spatial_species(tr_1)) == [:X] # species(::TransportReaction) currently not supported. - # @test ModelingToolkit.getname.(species(tr_2)) == ModelingToolkit.getname.(spatial_species(tr_2)) == [:Y] - @test ModelingToolkit.getname.(spatial_species(tr_1)) == [:X] - @test ModelingToolkit.getname.(spatial_species(tr_2)) == [:Y] - @test ModelingToolkit.getname.(parameters(tr_1)) == [:dX] - @test ModelingToolkit.getname.(parameters(tr_2)) == [:dY1, :dY2] - - # @test issetequal(species(tr_1), [tr_1.species]) - # @test issetequal(species(tr_2), [tr_2.species]) - @test issetequal(spatial_species(tr_1), [tr_1.species]) - @test issetequal(spatial_species(tr_2), [tr_2.species]) -end + for grid in grids + lrs = LatticeReactionSystem(rs, [tr], grid) -# Test case 2. -let - rs = @reaction_network begin - @species X(t) Y(t) - @parameters dX dY1 dY2 - end - @unpack X, Y, dX, dY1, dY2 = rs - tr_1 = TransportReaction(dX, X) - tr_2 = TransportReaction(dY1*dY2, Y) - # @test isequal(species(tr_1), [X]) - # @test isequal(species(tr_1), [X]) - @test issetequal(spatial_species(tr_2), [Y]) - @test issetequal(spatial_species(tr_2), [Y]) - @test issetequal(parameters(tr_1), [dX]) - @test issetequal(parameters(tr_2), [dY1, dY2]) -end - -### Tests Spatial Reactions Generation ### - -# Tests TransportReaction with non-trivial rate. -let - rs = @reaction_network begin - @parameters dV dE [edgeparameter=true] - (p,1), 0 <--> X - end - @unpack dV, dE, X = rs - - tr = TransportReaction(dV*dE, X) - @test isequal(tr.rate, dV*dE) -end - -# Tests transport_reactions function for creating TransportReactions. -let - rs = @reaction_network begin - @parameters d - (p,1), 0 <--> X + @test nameof(lrs) == :customname end - @unpack d, X = rs - trs = TransportReactions([(d, X), (d, X)]) - @test isequal(trs[1], trs[2]) end -# Test reactions with constants in rate. -let - @species X(t) Y(t) - - tr_1 = TransportReaction(1.5, X) - tr_1_macro = @transport_reaction 1.5 X - @test isequal(tr_1.rate, tr_1_macro.rate) - @test isequal(tr_1.species, tr_1_macro.species) - - tr_2 = TransportReaction(π, Y) - tr_2_macro = @transport_reaction π Y - @test isequal(tr_2.rate, tr_2_macro.rate) - @test isequal(tr_2.species, tr_2_macro.species) -end - -### Test Interpolation ### - -# Does not currently work. The 3 tr_macro_ lines generate errors. -# Test case 1. +# Tests using various more obscure types of getters. let - rs = @reaction_network begin - @species X(t) Y(t) Z(t) - @parameters dX dY1 dY2 dZ - end - @unpack X, Y, Z, dX, dY1, dY2, dZ = rs - rate1 = dX - rate2 = dY1*dY2 - species3 = Z - tr_1 = TransportReaction(dX, X) - tr_2 = TransportReaction(dY1*dY2, Y) - tr_3 = TransportReaction(dZ, Z) - tr_macro_1 = @transport_reaction $dX X - tr_macro_2 = @transport_reaction $(rate2) Y - # tr_macro_3 = @transport_reaction dZ $species3 # Currently does not work, something with meta programming. - - @test isequal(tr_1, tr_macro_1) - @test isequal(tr_2, tr_macro_2) # Unsure why these fails, since for components equality hold: `isequal(tr_1.species, tr_macro_1.species)` and `isequal(tr_1.rate, tr_macro_1.rate)` are both true. - # @test isequal(tr_3, tr_macro_3) + # Create LatticeReactionsSystems. + t = default_t() + @parameters p d kB kD + @species X(t) X2(t) + rxs = [ + Reaction(p, [], [X]) + Reaction(d, [X], []) + Reaction(kB, [X], [X2], [2], [1]) + Reaction(kD, [X2], [X], [1], [2]) + ] + @named rs = ReactionSystem(rxs, t; metadata = "Metadata string") + rs = complete(rs) + tr = @transport_reaction D X2 + lrs = LatticeReactionSystem(rs, [tr], small_2d_cartesian_grid) + + # Generic ones (simply forwards call to the non-spatial system). + @test isequal(reactions(lrs), rxs) + @test isequal(nameof(lrs), :rs) + @test isequal(ModelingToolkit.get_iv(lrs), t) + @test isequal(equations(lrs), rxs) + @test isequal(unknowns(lrs), [X, X2]) + @test isequal(ModelingToolkit.get_metadata(lrs), "Metadata string") + @test isequal(ModelingToolkit.get_eqs(lrs), rxs) + @test isequal(ModelingToolkit.get_unknowns(lrs), [X, X2]) + @test isequal(ModelingToolkit.get_ps(lrs), [p, d, kB, kD]) + @test isequal(ModelingToolkit.get_systems(lrs), []) + @test isequal(independent_variables(lrs), [t]) end ### Tests Error generation ### -# Test creation of TransportReaction with non-parameters in rate. -# Tests that it works even when rate is highly nested. -let - @species X(t) Y(t) - @parameters D1 D2 D3 - @test_throws ErrorException TransportReaction(D1 + D2*(D3 + Y), X) - @test_throws ErrorException TransportReaction(Y, X) -end - # Network where diffusion species is not declared in non-spatial network. let rs = @reaction_network begin (p, d), 0 <--> X end tr = @transport_reaction D Y - @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + for grid in grids + @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + end end # Network where the rate depend on a species @@ -245,7 +179,9 @@ let (p, d), 0 <--> X end tr = @transport_reaction D*Y X - @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + for grid in grids + @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + end end # Network with edge parameter in non-spatial reaction rate. @@ -255,7 +191,9 @@ let (p, d), 0 <--> X end tr = @transport_reaction D X - @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + for grid in grids + @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + end end # Network where metadata has been added in rs (which is not seen in transport reaction). @@ -265,103 +203,193 @@ let (p, d), 0 <--> X end tr = @transport_reaction D X - @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + for grid in grids + @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) - rs = @reaction_network begin - @parameters D [description="Parameter with added metadata"] - (p, d), 0 <--> X + rs = @reaction_network begin + @parameters D [description="Parameter with added metadata"] + (p, d), 0 <--> X + end + tr = @transport_reaction D X + @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + end +end + +# Tests various networks with non-permitted content. + let + tr = @transport_reaction D X + + # Variable unknowns. + rs1 = @reaction_network begin + @variables V(t) + (p,d), 0 <--> X + end + @test_throws ArgumentError LatticeReactionSystem(rs1, [tr], short_path) + + # Non-reaction equations. + rs2 = @reaction_network begin + @equations D(V) ~ X - V + (p,d), 0 <--> X end + @test_throws ArgumentError LatticeReactionSystem(rs2, [tr], short_path) + + # Events. + rs3 = @reaction_network begin + @discrete_events [1.0] => [p ~ p + 1] + (p,d), 0 <--> X + end + @test_throws ArgumentError LatticeReactionSystem(rs3, [tr], short_path) + + # Observables (only generates a warning). + rs4 = @reaction_network begin + @observables X2 ~ 2X + (p,d), 0 <--> X + end + @test_logs (:warn, r"The `ReactionSystem` used as input to `LatticeReactionSystem contain observables. It *") match_mode=:any LatticeReactionSystem(rs4, [tr], short_path) +end + +# Tests for hierarchical input system. +let + t = default_t() + @parameters d D + @species X(t) + rxs = [Reaction(d, [X], [])] + @named rs1 = ReactionSystem(rxs, t) + @named rs2 = ReactionSystem(rxs, t; systems = [rs1]) + rs2 = complete(rs2) + @test_throws ArgumentError LatticeReactionSystem(rs2, [TransportReaction(D, X)], CartesianGrid((2,2))) +end + +# Tests for non-complete input `ReactionSystem`. +let tr = @transport_reaction D X - @test_throws ErrorException LatticeReactionSystem(rs, [tr], grid) + rs = @network_component begin + (p,d), 0 <--> X + end + @test_throws ArgumentError LatticeReactionSystem(rs, [tr], CartesianGrid((2,2))) end +### Tests Grid Vertex and Edge Number Computation ### -### Test Designation of Parameter Types ### -# Currently not supported. Won't be until the LatticeReactionSystem internal update is merged. +# Tests that the correct numbers are computed for num_edges. +let + # Function counting the values in an iterator by stepping through it. + function iterator_count(iterator) + count = 0 + foreach(e -> count+=1, iterator) + return count + end -# Checks that parameter types designated in the non-spatial `ReactionSystem` is handled correctly. -@test_broken let - # Declares LatticeReactionSystem with designated parameter types. - rs = @reaction_network begin - @parameters begin - k1 - l1 - k2::Float64 = 2.0 - l2::Float64 - k3::Int64 = 2, [description="A parameter"] - l3::Int64 - k4::Float32, [description="Another parameter"] - l4::Float32 - k5::Rational{Int64} - l5::Rational{Int64} - D1::Float32 - D2, [edgeparameter=true] - D3::Rational{Int64}, [edgeparameter=true] - end - (k1,l1), X1 <--> Y1 - (k2,l2), X2 <--> Y2 - (k3,l3), X3 <--> Y3 - (k4,l4), X4 <--> Y4 - (k5,l5), X5 <--> Y5 - end - tr1 = @transport_reaction $(rs.D1) X1 - tr2 = @transport_reaction $(rs.D2) X2 - tr3 = @transport_reaction $(rs.D3) X3 - lrs = LatticeReactionSystem(rs, [tr1, tr2, tr3], grid) - - # Loops through all parameters, ensuring that they have the correct type - p_types = Dict([ModelingToolkit.nameof(p) => typeof(unwrap(p)) for p in parameters(lrs)]) - @test p_types[:k1] == SymbolicUtils.BasicSymbolic{Real} - @test p_types[:l1] == SymbolicUtils.BasicSymbolic{Real} - @test p_types[:k2] == SymbolicUtils.BasicSymbolic{Float64} - @test p_types[:l2] == SymbolicUtils.BasicSymbolic{Float64} - @test p_types[:k3] == SymbolicUtils.BasicSymbolic{Int64} - @test p_types[:l3] == SymbolicUtils.BasicSymbolic{Int64} - @test p_types[:k4] == SymbolicUtils.BasicSymbolic{Float32} - @test p_types[:l4] == SymbolicUtils.BasicSymbolic{Float32} - @test p_types[:k5] == SymbolicUtils.BasicSymbolic{Rational{Int64}} - @test p_types[:l5] == SymbolicUtils.BasicSymbolic{Rational{Int64}} - @test p_types[:D1] == SymbolicUtils.BasicSymbolic{Float32} - @test p_types[:D2] == SymbolicUtils.BasicSymbolic{Real} - @test p_types[:D3] == SymbolicUtils.BasicSymbolic{Rational{Int64}} + # Cartesian and masked grid (test diagonal edges as well). + for lattice in [small_1d_cartesian_grid, small_2d_cartesian_grid, small_3d_cartesian_grid, + random_1d_masked_grid, random_2d_masked_grid, random_3d_masked_grid] + lrs1 = LatticeReactionSystem(SIR_system, SIR_srs_1, lattice) + lrs2 = LatticeReactionSystem(SIR_system, SIR_srs_1, lattice; diagonal_connections=true) + @test num_edges(lrs1) == iterator_count(edge_iterator(lrs1)) + @test num_edges(lrs2) == iterator_count(edge_iterator(lrs2)) + end + + # Graph grids (cannot test diagonal connections). + for lattice in [small_2d_graph_grid, small_3d_graph_grid, undirected_cycle, small_directed_cycle, unconnected_graph] + lrs1 = LatticeReactionSystem(SIR_system, SIR_srs_1, lattice) + @test num_edges(lrs1) == iterator_count(edge_iterator(lrs1)) + end end -# Checks that programmatically declared parameters (with types) can be used in `TransportReaction`s. -# Checks that LatticeReactionSystem with non-default parameter types can be simulated. -@test_broken let - rs = @reaction_network begin - @parameters p::Float32 +### Tests Edge Value Computation Helper Functions ### + +# Checks that we compute the correct values across various types of grids. +let + # Prepares the model and the function that determines the edge values. + rn = @reaction_network begin (p,d), 0 <--> X end - @parameters D::Rational{Int64} - tr = TransportReaction(D, rs.X) - lrs = LatticeReactionSystem(rs, [tr], grid) + tr = @transport_reaction D X + function make_edge_p_value(src_vert, dst_vert) + return prod(src_vert) + prod(dst_vert) + end + + # Loops through a variety of grids, checks that `make_edge_p_values` yields the correct values. + for grid in [small_1d_cartesian_grid, small_2d_cartesian_grid, small_3d_cartesian_grid, + small_1d_masked_grid, small_2d_masked_grid, small_3d_masked_grid, + random_1d_masked_grid, random_2d_masked_grid, random_3d_masked_grid] + lrs = LatticeReactionSystem(rn, [tr], grid) + flat_to_grid_idx = Catalyst.get_index_converters(lattice(lrs), num_verts(lrs))[1] + edge_values = make_edge_p_values(lrs, make_edge_p_value) - p_types = Dict([ModelingToolkit.nameof(p) => typeof(unwrap(p)) for p in parameters(lrs)]) - @test p_types[:p] == SymbolicUtils.BasicSymbolic{Float32} - @test p_types[:d] == SymbolicUtils.BasicSymbolic{Real} - @test p_types[:D] == SymbolicUtils.BasicSymbolic{Rational{Int64}} - - u0 = [:X => [0.25, 0.5, 2.0, 4.0]] - ps = [rs.p => 2.0, rs.d => 1.0, D => 1//2] - - # Currently broken. This requires some non-trivial reworking of internals. - # However, spatial internals have already been reworked (and greatly improved) in an unmerged PR. - # This will be sorted out once that has finished. - @test_broken false - # oprob = ODEProblem(lrs, u0, (0.0, 10.0), ps) - # sol = solve(oprob, Tsit5()) - # @test sol[end] == [1.0, 1.0, 1.0, 1.0] + for e in edge_iterator(lrs) + @test edge_values[e[1], e[2]] == make_edge_p_value(flat_to_grid_idx[e[1]], flat_to_grid_idx[e[2]]) + end + end +end + +# Checks that all species end up in the correct place in a pure flow system (checking various dimensions). +let + # Prepares a system with a single species which is transported only. + rn = @reaction_network begin + @species X(t) + end + n = 5 + tr = @transport_reaction D X + tspan = (0.0, 1000.0) + u0 = [:X => 1.0] + + # Checks the 1d case. + lrs = LatticeReactionSystem(rn, [tr], CartesianGrid(n)) + ps = [:D => make_directed_edge_values(lrs, (10.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test isapprox(solve(oprob, Tsit5()).u[end][5], n, rtol=1e-6) + + # Checks the 2d case (both with 1d and 2d flow). + lrs = LatticeReactionSystem(rn, [tr], CartesianGrid((n,n))) + + ps = [:D => make_directed_edge_values(lrs, (1.0, 0.0), (0.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test all(isapprox.(solve(oprob, Tsit5()).u[end][5:5:25], n, rtol=1e-6)) + + ps = [:D => make_directed_edge_values(lrs, (1.0, 0.0), (1.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test isapprox(solve(oprob, Tsit5()).u[end][25], n^2, rtol=1e-6) + + # Checks the 3d case (both with 1d and 2d flow). + lrs = LatticeReactionSystem(rn, [tr], CartesianGrid((n,n,n))) + + ps = [:D => make_directed_edge_values(lrs, (1.0, 0.0), (0.0, 0.0), (0.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test all(isapprox.(solve(oprob, Tsit5()).u[end][5:5:125], n, rtol=1e-6)) + + ps = [:D => make_directed_edge_values(lrs, (1.0, 0.0), (1.0, 0.0), (0.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test all(isapprox.(solve(oprob, Tsit5()).u[end][25:25:125], n^2, rtol=1e-6)) + + ps = [:D => make_directed_edge_values(lrs, (1.0, 0.0), (1.0, 0.0), (1.0, 0.0))] + oprob = ODEProblem(lrs, u0, tspan, ps) + @test isapprox(solve(oprob, Tsit5()).u[end][125], n^3, rtol=1e-6) end -# Tests that LatticeReactionSystem cannot be generated where transport reactions depend on parameters -# that have a type designated in the non-spatial `ReactionSystem`. -@test_broken false -# let -# rs = @reaction_network begin -# @parameters D::Int64 -# (p,d), 0 <--> X -# end -# tr = @transport_reaction D X -# @test_throws Exception LatticeReactionSystem(rs, tr, grid) -# end \ No newline at end of file +# Checks that erroneous input yields errors. +let + rn = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + tspan = (0.0, 10000.0) + make_edge_p_value(src_vert, dst_vert) = rand() + + # Graph grids. + lrs = LatticeReactionSystem(rn, [tr], path_graph(5)) + @test_throws Exception make_edge_p_values(lrs, make_edge_p_value,) + @test_throws Exception make_directed_edge_values(lrs, (1.0, 0.0)) + + # Wrong dimensions to `make_directed_edge_values`. + lrs_1d = LatticeReactionSystem(rn, [tr], CartesianGrid(5)) + lrs_2d = LatticeReactionSystem(rn, [tr], fill(true,5,5)) + lrs_3d = LatticeReactionSystem(rn, [tr], CartesianGrid((5,5,5))) + + @test_throws Exception make_directed_edge_values(lrs_1d, (1.0, 0.0), (1.0, 0.0)) + @test_throws Exception make_directed_edge_values(lrs_1d, (1.0, 0.0), (1.0, 0.0), (1.0, 0.0)) + @test_throws Exception make_directed_edge_values(lrs_2d, (1.0, 0.0)) + @test_throws Exception make_directed_edge_values(lrs_2d, (1.0, 0.0), (1.0, 0.0), (1.0, 0.0)) + @test_throws Exception make_directed_edge_values(lrs_3d, (1.0, 0.0)) + @test_throws Exception make_directed_edge_values(lrs_3d, (1.0, 0.0), (1.0, 0.0)) +end \ No newline at end of file diff --git a/test/spatial_modelling/lattice_reaction_systems_ODEs.jl b/test/spatial_modelling/lattice_reaction_systems_ODEs.jl index cdefc6ebc9..26da7ba3fc 100644 --- a/test/spatial_modelling/lattice_reaction_systems_ODEs.jl +++ b/test/spatial_modelling/lattice_reaction_systems_ODEs.jl @@ -14,78 +14,34 @@ rng = StableRNG(12345) # Sets defaults t = default_t() -### Tests Simulations Don't Error ### -for grid in [small_2d_grid, short_path, small_directed_cycle] - # Non-stiff case - for srs in [Vector{TransportReaction}(), SIR_srs_1, SIR_srs_2] - lrs = LatticeReactionSystem(SIR_system, srs, grid) - u0_1 = [:S => 999.0, :I => 1.0, :R => 0.0] - u0_2 = [:S => 500.0 .+ 500.0 * rand_v_vals(lrs.lattice), :I => 1.0, :R => 0.0] - u0_3 = [ - :S => 950.0, - :I => 50 * rand_v_vals(lrs.lattice), - :R => 50 * rand_v_vals(lrs.lattice), - ] - u0_4 = [ - :S => 500.0 .+ 500.0 * rand_v_vals(lrs.lattice), - :I => 50 * rand_v_vals(lrs.lattice), - :R => 50 * rand_v_vals(lrs.lattice), - ] - u0_5 = make_u0_matrix(u0_3, vertices(lrs.lattice), - map(s -> Symbol(s.f), species(lrs.rs))) - for u0 in [u0_1, u0_2, u0_3, u0_4, u0_5] - p1 = [:α => 0.1 / 1000, :β => 0.01] - p2 = [:α => 0.1 / 1000, :β => 0.02 * rand_v_vals(lrs.lattice)] - p3 = [ - :α => 0.1 / 2000 * rand_v_vals(lrs.lattice), - :β => 0.02 * rand_v_vals(lrs.lattice), - ] - p4 = make_u0_matrix(p1, vertices(lrs.lattice), Symbol.(parameters(lrs.rs))) - for pV in [p1, p2, p3, p4] - pE_1 = map(sp -> sp => 0.01, spatial_param_syms(lrs)) - pE_2 = map(sp -> sp => 0.01, spatial_param_syms(lrs)) - pE_3 = map(sp -> sp => rand_e_vals(lrs.lattice, 0.01), - spatial_param_syms(lrs)) - pE_4 = make_u0_matrix(pE_3, edges(lrs.lattice), spatial_param_syms(lrs)) - for pE in [pE_1, pE_2, pE_3, pE_4] - oprob = ODEProblem(lrs, u0, (0.0, 500.0), (pV, pE)) - @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) - - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE); jac = false) - @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) - end - end - end - end - - # Stiff case - for srs in [Vector{TransportReaction}(), brusselator_srs_1, brusselator_srs_2] - lrs = LatticeReactionSystem(brusselator_system, srs, grid) - u0_1 = [:X => 1.0, :Y => 20.0] - u0_2 = [:X => rand_v_vals(lrs.lattice, 10.0), :Y => 2.0] - u0_3 = [:X => rand_v_vals(lrs.lattice, 20), :Y => rand_v_vals(lrs.lattice, 10)] - u0_4 = make_u0_matrix(u0_3, vertices(lrs.lattice), - map(s -> Symbol(s.f), species(lrs.rs))) - for u0 in [u0_1, u0_2, u0_3, u0_4] - p1 = [:A => 1.0, :B => 4.0] - p2 = [:A => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), :B => 4.0] - p3 = [ - :A => 0.5 .+ rand_v_vals(lrs.lattice, 0.5), - :B => 4.0 .+ rand_v_vals(lrs.lattice, 1.0), +### Tests Simulations Do Not Error ### +let + for grid in [small_1d_cartesian_grid, small_1d_masked_grid, small_1d_graph_grid] + for srs in [Vector{TransportReaction}(), SIR_srs_1, SIR_srs_2] + lrs = LatticeReactionSystem(SIR_system, srs, grid) + u0_1 = [:S => 999.0, :I => 1.0, :R => 0.0] + u0_2 = [:S => 500.0 .+ 500.0 * rand_v_vals(lrs), :I => 1.0, :R => 0.0] + u0_3 = [ + :S => 500.0 .+ 500.0 * rand_v_vals(lrs), + :I => 50 * rand_v_vals(lrs), + :R => 50 * rand_v_vals(lrs), ] - p4 = make_u0_matrix(p2, vertices(lrs.lattice), Symbol.(parameters(lrs.rs))) - for pV in [p1, p2, p3, p4] - pE_1 = map(sp -> sp => 0.2, spatial_param_syms(lrs)) - pE_2 = map(sp -> sp => rand(rng), spatial_param_syms(lrs)) - pE_3 = map(sp -> sp => rand_e_vals(lrs.lattice, 0.2), - spatial_param_syms(lrs)) - pE_4 = make_u0_matrix(pE_3, edges(lrs.lattice), spatial_param_syms(lrs)) - for pE in [pE_1, pE_2, pE_3, pE_4] - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE)) - @test SciMLBase.successful_retcode(solve(oprob, QNDF())) - - oprob = ODEProblem(lrs, u0, (0.0, 10.0), (pV, pE); sparse = false) - @test SciMLBase.successful_retcode(solve(oprob, QNDF())) + for u0 in [u0_1, u0_2, u0_3] + pV_1 = [:α => 0.1 / 1000, :β => 0.01] + pV_2 = [:α => 0.1 / 1000, :β => 0.02 * rand_v_vals(lrs)] + pV_3 = [ + :α => 0.1 / 2000 * rand_v_vals(lrs), + :β => 0.02 * rand_v_vals(lrs), + ] + for pV in [pV_1, pV_2, pV_3] + pE_1 = map(sp -> sp => 0.01, spatial_param_syms(lrs)) + pE_2 = map(sp -> sp => 0.01, spatial_param_syms(lrs)) + pE_3 = map(sp -> sp => rand_e_vals(lrs, 0.01), spatial_param_syms(lrs)) + for pE in [pE_1, pE_2, pE_3] + isempty(spatial_param_syms(lrs)) && (pE = Vector{Pair{Symbol, Float64}}()) + oprob = ODEProblem(lrs, u0, (0.0, 500.0), [pV; pE]; jac = false, sparse = false) + @test SciMLBase.successful_retcode(solve(oprob, Tsit5())) + end end end end @@ -94,24 +50,23 @@ end ### Tests Simulation Correctness ### -# Checks that non-spatial brusselator simulation is identical to all on an unconnected lattice. +# Tests with non-Float64 parameter values. let - lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, unconnected_graph) - u0 = [:X => 2.0 + 2.0 * rand(rng), :Y => 10.0 + 10.0 * rand(rng)] - pV = brusselator_p - pE = [:dX => 0.2] - oprob_nonspatial = ODEProblem(brusselator_system, u0, (0.0, 100.0), pV) - oprob_spatial = ODEProblem(lrs, u0, (0.0, 100.0), (pV, pE)) - sol_nonspatial = solve(oprob_nonspatial, QNDF(); abstol = 1e-12, reltol = 1e-12) - sol_spatial = solve(oprob_spatial, QNDF(); abstol = 1e-12, reltol = 1e-12) - - for i in 1:nv(unconnected_graph) - @test all(isapprox.(sol_nonspatial.u[end], - sol_spatial.u[end][((i - 1) * 2 + 1):((i - 1) * 2 + 2)])) + lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, very_small_2d_cartesian_grid) + u0 = [:S => 990.0, :I => rand_v_vals(lrs), :R => 0.0] + ps_1 = [:α => 0.1, :β => 0.01, :dS => 0.01, :dI => 0.01, :dR => 0.01] + ps_2 = [:α => 1//10, :β => 1//100, :dS => 1//100, :dI => 1//100, :dR => 1//100] + ps_3 = [:α => 1//10, :β => 0.01, :dS => 0.01, :dI => 1//100, :dR => 0.01] + sol_base = solve(ODEProblem(lrs, u0, (0.0, 100.0), ps_1), Rosenbrock23(); saveat = 0.1) + for ps in [ps_1, ps_2, ps_3] + for jac in [true, false], sparse in [true, false] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps; jac, sparse) + @test sol_base ≈ solve(oprob, Rosenbrock23(); saveat = 0.1) + end end end -# Compares Jacobian and forcing functions of spatial system to analytically computed on. +# Compares Jacobian and forcing functions of spatial system to analytically computed ones. let # Creates LatticeReactionNetwork ODEProblem. rs = @reaction_network begin @@ -124,9 +79,11 @@ let lattice = path_graph(3) lrs = LatticeReactionSystem(rs, [tr], lattice); - D_vals = [0.2, 0.2, 0.3, 0.3] + D_vals = spzeros(3,3) + D_vals[1,2] = 0.2; D_vals[2,1] = 0.2; + D_vals[2,3] = 0.3; D_vals[3,2] = 0.3; u0 = [:X => [1.0, 2.0, 3.0], :Y => 1.0] - ps = [:pX => [2.0, 2.5, 3.0], :pY => 0.5, :d => 0.1, :D => D_vals] + ps = [:pX => [2.0, 2.5, 3.0], :d => 0.1, :pY => 0.5, :D => D_vals] oprob = ODEProblem(lrs, u0, (0.0, 0.0), ps; jac=true, sparse=true) # Creates manual f and jac functions. @@ -136,7 +93,8 @@ let pX1, pX2, pX3 = pX pY, = pY d, = d - D1, D2, D3, D4 = D_vals + D1 = D_vals[1,2]; D2 = D_vals[2,1]; + D3 = D_vals[2,3]; D4 = D_vals[3,2]; du[1] = pX1 - d*X1 - D1*X1 + D2*X2 du[2] = pY*X1 - d*Y1 du[3] = pX2 - d*X2 + D1*X1 - (D2+D3)*X2 + D4*X3 @@ -150,7 +108,8 @@ let pX1, pX2, pX3 = pX pY, = pY d, = d - D1, D2, D3, D4 = D_vals + D1 = D_vals[1,2]; D2 = D_vals[2,1]; + D3 = D_vals[2,3]; D4 = D_vals[3,2]; J .= 0.0 @@ -177,7 +136,7 @@ let # Sets test input values. u = rand(rng, 6) - p = [rand(rng, 3), rand(rng, 1), rand(rng, 1)] + p = [rand(rng, 3), ps[2][2], ps[3][2]] # Tests forcing function. du1 = fill(0.0, 6) @@ -198,9 +157,9 @@ end let lrs = LatticeReactionSystem(binding_system, binding_srs, undirected_cycle) u0 = [ - :X => 1.0 .+ rand_v_vals(lrs.lattice), - :Y => 2.0 * rand_v_vals(lrs.lattice), - :XY => 0.5, + :X => 1.0 .+ rand_v_vals(lrs), + :Y => 2.0 * rand_v_vals(lrs), + :XY => 0.5 ] oprob = ODEProblem(lrs, u0, (0.0, 1000.0), binding_p; tstops = 0.1:0.1:1000.0) ss = solve(oprob, Tsit5()).u[end] @@ -212,61 +171,236 @@ end # Checks that various combinations of jac and sparse gives the same result. let - lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_grid) - u0 = [:X => rand_v_vals(lrs.lattice, 10), :Y => rand_v_vals(lrs.lattice, 10)] + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_graph_grid) + u0 = [:X => rand_v_vals(lrs, 10), :Y => rand_v_vals(lrs, 10)] pV = brusselator_p pE = [:dX => 0.2] - oprob = ODEProblem(lrs, u0, (0.0, 50.0), (pV, pE); jac = false, sparse = false) - oprob_sparse = ODEProblem(lrs, u0, (0.0, 50.0), (pV, pE); jac = false, sparse = true) - oprob_jac = ODEProblem(lrs, u0, (0.0, 50.0), (pV, pE); jac = true, sparse = false) - oprob_sparse_jac = ODEProblem(lrs, u0, (0.0, 50.0), (pV, pE); jac = true, sparse = true) + oprob = ODEProblem(lrs, u0, (0.0, 5.0), [pV; pE]; jac = false, sparse = false) + oprob_sparse = ODEProblem(lrs, u0, (0.0, 5.0), [pV; pE]; jac = false, sparse = true) + oprob_jac = ODEProblem(lrs, u0, (0.0, 5.0), [pV; pE]; jac = true, sparse = false) + oprob_sparse_jac = ODEProblem(lrs, u0, (0.0, 5.0), [pV; pE]; jac = true, sparse = true) ss = solve(oprob, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end] - @test all(isapprox.(ss, - solve(oprob_sparse, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; - rtol = 0.0001)) - @test all(isapprox.(ss, - solve(oprob_jac, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; - rtol = 0.0001)) - @test all(isapprox.(ss, - solve(oprob_sparse_jac, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; - rtol = 0.0001)) + @test all(isapprox.(ss, solve(oprob_sparse, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; rtol = 0.0001)) + @test all(isapprox.(ss, solve(oprob_jac, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; rtol = 0.0001)) + @test all(isapprox.(ss, solve(oprob_sparse_jac, Rosenbrock23(); abstol = 1e-10, reltol = 1e-10).u[end]; rtol = 0.0001)) end -# Checks that, when non directed graphs are provided, the parameters are re-ordered correctly. +# Compares Catalyst-generated to hand-written one for the Brusselator for a line of cells. +let + function spatial_brusselator_f(du, u, p, t) + # Non-spatial + for i in 1:2:(length(u) - 1) + du[i] = p[1] + 0.5 * (u[i]^2) * u[i + 1] - u[i] - p[2] * u[i] + du[i + 1] = p[2] * u[i] - 0.5 * (u[i]^2) * u[i + 1] + end + + # Spatial + du[1] += p[3] * (u[3] - u[1]) + du[end - 1] += p[3] * (u[end - 3] - u[end - 1]) + for i in 3:2:(length(u) - 3) + du[i] += p[3] * (u[i - 2] + u[i + 2] - 2u[i]) + end + end + function spatial_brusselator_jac(J, u, p, t) + J .= 0 + # Non-spatial + for i in 1:2:(length(u) - 1) + J[i, i] = u[i] * u[i + 1] - 1 - p[2] + J[i, i + 1] = 0.5 * (u[i]^2) + J[i + 1, i] = p[2] - u[i] * u[i + 1] + J[i + 1, i + 1] = -0.5 * (u[i]^2) + end + + # Spatial + J[1, 1] -= p[3] + J[1, 3] += p[3] + J[end - 1, end - 1] -= p[3] + J[end - 1, end - 3] += p[3] + for i in 3:2:(length(u) - 3) + J[i, i] -= 2 * p[3] + J[i, i - 2] += p[3] + J[i, i + 2] += p[3] + end + end + function spatial_brusselator_jac_sparse(J, u, p, t) + # Spatial + J.nzval .= 0.0 + J.nzval[7:6:(end - 9)] .= -2p[3] + J.nzval[1] = -p[3] + J.nzval[end - 3] = -p[3] + J.nzval[3:3:(end - 4)] .= p[3] + + # Non-spatial + for i in 1:1:Int64(lenth(u) / 2 - 1) + j = 6(i - 1) + 1 + J.nzval[j] = u[i] * u[i + 1] - 1 - p[2] + J.nzval[j + 1] = 0.5 * (u[i]^2) + J.nzval[j + 3] = p[2] - u[i] * u[i + 1] + J.nzval[j + 4] = -0.5 * (u[i]^2) + end + J.nzval[end - 3] = u[end - 1] * u[end] - 1 - p[end - 1] + J.nzval[end - 2] = 0.5 * (u[end - 1]^2) + J.nzval[end - 1] = p[2] - u[end - 1] * u[end] + J.nzval[end] = -0.5 * (u[end - 1]^2) + end + function make_jac_prototype(u0) + jac_prototype_pre = zeros(length(u0), length(u0)) + for i in 1:2:(length(u0) - 1) + jac_prototype_pre[i, i] = 1 + jac_prototype_pre[i + 1, i] = 1 + jac_prototype_pre[i, i + 1] = 1 + jac_prototype_pre[i + 1, i + 1] = 1 + end + for i in 3:2:(length(u0) - 1) + jac_prototype_pre[i - 2, i] = 1 + jac_prototype_pre[i, i - 2] = 1 + end + return sparse(jac_prototype_pre) + end + + num_verts = 100 + u0 = 2 * rand(rng, 2*num_verts) + p = [1.0, 4.0, 0.1] + tspan = (0.0, 100.0) + + ofun_hw_dense = ODEFunction(spatial_brusselator_f; jac = spatial_brusselator_jac) + ofun_hw_sparse = ODEFunction(spatial_brusselator_f; jac = spatial_brusselator_jac, + jac_prototype = make_jac_prototype(u0)) + + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, path_graph(num_verts)) + u0_map = [:X => u0[1:2:(end - 1)], :Y => u0[2:2:end]] + ps_map = [:A => p[1], :B => p[2], :dX => p[3]] + oprob_aut_dense = ODEProblem(lrs, u0_map, tspan, ps_map; jac = true, sparse = false) + oprob_aut_sparse = ODEProblem(lrs, u0_map, tspan, ps_map; jac = true, sparse = true) + ofun_aut_dense = oprob_aut_dense.f + ofun_aut_sparse = oprob_aut_sparse.f + + du_hw_dense = deepcopy(u0) + du_hw_sparse = deepcopy(u0) + du_aut_dense = deepcopy(u0) + du_aut_sparse = deepcopy(u0) + + ofun_hw_dense(du_hw_dense, u0, p, 0.0) + ofun_hw_sparse(du_hw_sparse, u0, p, 0.0) + ofun_aut_dense(du_aut_dense, u0, oprob_aut_dense.p, 0.0) + ofun_aut_sparse(du_aut_sparse, u0, oprob_aut_dense.p, 0.0) + + @test isapprox(du_hw_dense, du_aut_dense) + @test isapprox(du_hw_sparse, du_aut_sparse) + + J_hw_dense = deepcopy(zeros(length(u0), length(u0))) + J_hw_sparse = deepcopy(make_jac_prototype(u0)) + J_aut_dense = deepcopy(zeros(length(u0), length(u0))) + J_aut_sparse = deepcopy(make_jac_prototype(u0)) + + ofun_hw_dense.jac(J_hw_dense, u0, p, 0.0) + ofun_hw_sparse.jac(J_hw_sparse, u0, p, 0.0) + ofun_aut_dense.jac(J_aut_dense, u0, oprob_aut_dense.p, 0.0) + ofun_aut_sparse.jac(J_aut_sparse, u0, oprob_aut_dense.p, 0.0) + + @test isapprox(J_hw_dense, J_aut_dense) + @test isapprox(J_hw_sparse, J_aut_sparse) +end + + +### Test Grid Types ### + +# Tests that identical lattices (using different types of lattices) give identical results. let - # Create the same lattice (one as digraph, one not). Algorithm depends on Graphs.jl reordering edges, hence the jumbled order. - lattice_1 = SimpleGraph(5) - lattice_2 = SimpleDiGraph(5) - - add_edge!(lattice_1, 5, 2) - add_edge!(lattice_1, 1, 4) - add_edge!(lattice_1, 1, 3) - add_edge!(lattice_1, 4, 3) - add_edge!(lattice_1, 4, 5) - - add_edge!(lattice_2, 4, 1) - add_edge!(lattice_2, 3, 4) - add_edge!(lattice_2, 5, 4) - add_edge!(lattice_2, 5, 2) - add_edge!(lattice_2, 4, 3) - add_edge!(lattice_2, 4, 5) - add_edge!(lattice_2, 3, 1) - add_edge!(lattice_2, 2, 5) - add_edge!(lattice_2, 1, 4) - add_edge!(lattice_2, 1, 3) - - lrs_1 = LatticeReactionSystem(SIR_system, SIR_srs_2, lattice_1) - lrs_2 = LatticeReactionSystem(SIR_system, SIR_srs_2, lattice_2) - - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_1.lattice), :R => 0.0] - pV = [:α => 0.1 / 1000, :β => 0.01] + # Declares the diffusion parameters. + sigmaB_p_spat = [:DσB => 0.05, :Dw => 0.04, :Dv => 0.03] + + # 1d lattices. + lrs1_cartesian = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_1d_cartesian_grid) + lrs1_masked = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_1d_masked_grid) + lrs1_graph = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_1d_graph_grid) + + oprob1_cartesian = ODEProblem(lrs1_cartesian, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob1_masked = ODEProblem(lrs1_masked, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob1_graph = ODEProblem(lrs1_graph, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + @test solve(oprob1_cartesian, QNDF()) ≈ solve(oprob1_masked, QNDF()) ≈ solve(oprob1_graph, QNDF()) + + # 2d lattices. + lrs2_cartesian = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_2d_cartesian_grid) + lrs2_masked = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_2d_masked_grid) + lrs2_graph = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_2d_graph_grid) + + oprob2_cartesian = ODEProblem(lrs2_cartesian, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob2_masked = ODEProblem(lrs2_masked, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob2_graph = ODEProblem(lrs2_graph, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + @test solve(oprob2_cartesian, QNDF()) ≈ solve(oprob2_masked, QNDF()) ≈ solve(oprob2_graph, QNDF()) + + # 3d lattices. + lrs3_cartesian = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_3d_cartesian_grid) + lrs3_masked = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_3d_masked_grid) + lrs3_graph = LatticeReactionSystem(sigmaB_system, sigmaB_srs_2, very_small_3d_graph_grid) + + oprob3_cartesian = ODEProblem(lrs3_cartesian, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob3_masked = ODEProblem(lrs3_masked, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + oprob3_graph = ODEProblem(lrs3_graph, sigmaB_u0, (0.0,1.0), [sigmaB_p; sigmaB_p_spat]) + @test solve(oprob3_cartesian, QNDF()) ≈ solve(oprob3_masked, QNDF()) ≈ solve(oprob3_graph, QNDF()) +end - pE_1 = [:dS => [1.3, 1.4, 2.5, 3.4, 4.5], :dI => 0.01, :dR => 0.02] - pE_2 = [:dS => [1.3, 1.4, 2.5, 1.3, 3.4, 1.4, 3.4, 4.5, 2.5, 4.5], :dI => 0.01, :dR => 0.02] - ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), (pV, pE_1)), Tsit5()).u[end] - ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), (pV, pE_2)), Tsit5()).u[end] - @test all(isapprox.(ss_1, ss_2)) +# Tests that input parameter and u0 values can be given using different types of input for 2d lattices. +# Tries both for cartesian and masked (where all vertices are `true`). +# Tries for Vector, Tuple, and Dictionary inputs. +let + for lattice in [CartesianGrid((4,3)), fill(true, 4, 3)] + lrs = LatticeReactionSystem(SIR_system, SIR_srs_1, lattice) + + # Initial condition values. + S_vals_vec = [100., 100., 200., 300., 200., 100., 200., 300., 300., 100., 200., 300.] + S_vals_mat = [100. 200. 300.; 100. 100. 100.; 200. 200. 200.; 300. 300. 300.] + SIR_u0_vec = [:S => S_vals_vec, :I => 1.0, :R => 0.0] + SIR_u0_mat = [:S => S_vals_mat, :I => 1.0, :R => 0.0] + + # Parameter values. + β_vals_vec = [0.01, 0.01, 0.02, 0.03, 0.02, 0.01, 0.02, 0.03, 0.03, 0.01, 0.02, 0.03] + β_vals_mat = [0.01 0.02 0.03; 0.01 0.01 0.01; 0.02 0.02 0.02; 0.03 0.03 0.03] + SIR_p_vec = [:α => 0.1 / 1000, :β => β_vals_vec, :dS => 0.01] + SIR_p_mat = [:α => 0.1 / 1000, :β => β_vals_mat, :dS => 0.01] + + oprob = ODEProblem(lrs, SIR_u0_vec, (0.0, 10.0), SIR_p_vec) + sol_base = solve(oprob, Tsit5()) + for u0_base in [SIR_u0_vec, SIR_u0_mat], ps_base in [SIR_p_vec, SIR_p_mat] + for u0 in [u0_base, Tuple(u0_base), Dict(u0_base)], ps in [ps_base, Tuple(ps_base), Dict(ps_base)] + sol = solve(ODEProblem(lrs, u0, (0.0, 10.0), ps), Tsit5()) + @test sol == sol_base + end + end + end +end + +# Tests that input parameter and u0 values can be given using different types of input for 2d masked grid. +# Tries when several of the mask values are `false`. +let + lattice = [true true false; true false false; true true true; false true true] + lrs = LatticeReactionSystem(SIR_system, SIR_srs_1, lattice) + + # Initial condition values. 999 is used for empty points. + S_vals_vec = [100.0, 100.0, 200.0, 200.0, 200.0, 300.0, 200.0, 300.0] + S_vals_mat = [100.0 200.0 999.0; 100.0 999.0 999.0; 200.0 200.0 200.0; 999.0 300.0 300.0] + S_vals_sparse_mat = sparse(S_vals_mat .* lattice) + SIR_u0_vec = [:S => S_vals_vec, :I => 1.0, :R => 0.0] + SIR_u0_mat = [:S => S_vals_mat, :I => 1.0, :R => 0.0] + SIR_u0_sparse_mat = [:S => S_vals_sparse_mat, :I => 1.0, :R => 0.0] + + # Parameter values. 9.99 is used for empty points. + β_vals_vec = [0.01, 0.01, 0.02, 0.02, 0.02, 0.03, 0.02, 0.03] + β_vals_mat = [0.01 0.02 9.99; 0.01 9.99 9.99; 0.02 0.02 0.02; 9.99 0.03 0.03] + β_vals_sparse_mat = sparse(β_vals_mat .* lattice) + SIR_p_vec = [:α => 0.1 / 1000, :β => β_vals_vec, :dS => 0.01] + SIR_p_mat = [:α => 0.1 / 1000, :β => β_vals_mat, :dS => 0.01] + SIR_p_sparse_mat = [:α => 0.1 / 1000, :β => β_vals_sparse_mat, :dS => 0.01] + + oprob = ODEProblem(lrs, SIR_u0_vec, (0.0, 10.0), SIR_p_vec) + sol = solve(oprob, Tsit5()) + for u0 in [SIR_u0_vec, SIR_u0_mat, SIR_u0_sparse_mat] + for p in [SIR_p_vec, SIR_p_mat, SIR_p_sparse_mat] + @test sol == solve(ODEProblem(lrs, u0, (0.0, 10.0), p), Tsit5()) + end + end end ### Test Transport Reaction Types ### @@ -280,13 +414,13 @@ let tr_macros_1 = @transport_reaction dS S tr_macros_2 = @transport_reaction dI I - lrs_1 = LatticeReactionSystem(SIR_system, [tr_1, tr_2], small_2d_grid) - lrs_2 = LatticeReactionSystem(SIR_system, [tr_macros_1, tr_macros_2], small_2d_grid) - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_1.lattice), :R => 0.0] + lrs_1 = LatticeReactionSystem(SIR_system, [tr_1, tr_2], small_2d_graph_grid) + lrs_2 = LatticeReactionSystem(SIR_system, [tr_macros_1, tr_macros_2], small_2d_graph_grid) + u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_1), :R => 0.0] pV = [:α => 0.1 / 1000, :β => 0.01] pE = [:dS => 0.01, :dI => 0.01] - ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), (pV, pE)), Tsit5()).u[end] - ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), (pV, pE)), Tsit5()).u[end] + ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), [pV; pE]), Tsit5()).u[end] + ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), [pV; pE]), Tsit5()).u[end] @test all(isapprox.(ss_1, ss_2)) end @@ -296,17 +430,17 @@ let SIR_tr_I_alt = @transport_reaction dI1*dI2 I SIR_tr_R_alt = @transport_reaction log(dR1)+dR2 R SIR_srs_2_alt = [SIR_tr_S_alt, SIR_tr_I_alt, SIR_tr_R_alt] - lrs_1 = LatticeReactionSystem(SIR_system, SIR_srs_2, small_2d_grid) - lrs_2 = LatticeReactionSystem(SIR_system, SIR_srs_2_alt, small_2d_grid) - - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_1.lattice), :R => 0.0] + lrs_1 = LatticeReactionSystem(SIR_system, SIR_srs_2, small_2d_graph_grid) + lrs_2 = LatticeReactionSystem(SIR_system, SIR_srs_2_alt, small_2d_graph_grid) + + u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_1), :R => 0.0] pV = [:α => 0.1 / 1000, :β => 0.01] pE_1 = [:dS => 0.01, :dI => 0.01, :dR => 0.01] - pE_2 = [:dS1 => 0.005, :dS1 => 0.005, :dI1 => 2, :dI2 => 0.005, :dR1 => 1.010050167084168, :dR2 => 1.0755285551056204e-16] - - ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), (pV, pE_1)), Tsit5()).u[end] - ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), (pV, pE_2)), Tsit5()).u[end] - @test all(isapprox.(ss_1, ss_2)) + pE_2 = [:dS1 => 0.003, :dS2 => 0.007, :dI1 => 2, :dI2 => 0.005, :dR1 => 1.010050167084168, :dR2 => 1.0755285551056204e-16] + + ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), [pV; pE_1]), Tsit5()).u[end] + ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), [pV; pE_2]), Tsit5()).u[end] + @test ss_1 == ss_2 end # Tries various ways of creating TransportReactions. @@ -335,7 +469,7 @@ let tr_alt_1_6 = @transport_reaction dCu_ELigand Cu_ELigand tr_alt_1_7 = @transport_reaction dNewspecies2 Newspecies2 CuH_Amination_srs_alt_1 = [tr_alt_1_1, tr_alt_1_2, tr_alt_1_3, tr_alt_1_4, tr_alt_1_5, tr_alt_1_6, tr_alt_1_7] - lrs_1 = LatticeReactionSystem(CuH_Amination_system_alt_1, CuH_Amination_srs_alt_1, small_2d_grid) + lrs_1 = LatticeReactionSystem(CuH_Amination_system_alt_1, CuH_Amination_srs_alt_1, small_2d_graph_grid) CuH_Amination_system_alt_2 = @reaction_network begin @species Newspecies1(t) Newspecies2(t) @@ -361,53 +495,146 @@ let tr_alt_2_6 = TransportReaction(dCu_ELigand, Cu_ELigand) tr_alt_2_7 = TransportReaction(dNewspecies2, Newspecies2) CuH_Amination_srs_alt_2 = [tr_alt_2_1, tr_alt_2_2, tr_alt_2_3, tr_alt_2_4, tr_alt_2_5, tr_alt_2_6, tr_alt_2_7] - lrs_2 = LatticeReactionSystem(CuH_Amination_system_alt_2, CuH_Amination_srs_alt_2, small_2d_grid) + lrs_2 = LatticeReactionSystem(CuH_Amination_system_alt_2, CuH_Amination_srs_alt_2, small_2d_graph_grid) u0 = [CuH_Amination_u0; :Newspecies1 => 0.1; :Newspecies2 => 0.1] pV = [CuH_Amination_p; :dLigand => 0.01; :dSilane => 0.01; :dCu_ELigand => 0.009; :dStyrene => -10000.0] pE = [:dAmine_E => 0.011, :dNewspecies1 => 0.013, :dDecomposition => 0.015, :dNewspecies2 => 0.016, :dCuoAc => -10000.0] - ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), (pV, pE)), Tsit5()).u[end] - ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), (pV, pE)), Tsit5()).u[end] + ss_1 = solve(ODEProblem(lrs_1, u0, (0.0, 500.0), [pV; pE]), Tsit5()).u[end] + ss_2 = solve(ODEProblem(lrs_2, u0, (0.0, 500.0), [pV; pE]), Tsit5()).u[end] @test all(isequal.(ss_1, ss_2)) end +### ODEProblem & Integrator Interfacing ### + +# Checks that basic interfacing with ODEProblem parameters (getting and setting) works. +let + # Creates an initial `ODEProblem`. + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_1d_cartesian_grid) + u0 = [:X => 1.0, :Y => 2.0] + ps = [:A => 1.0, :B => [1.0, 2.0, 3.0, 4.0, 5.0], :dX => 0.1] + oprob = ODEProblem(lrs, u0, (0.0, 10.0), ps) + + # Checks that retrieved parameters are correct. + @test oprob.ps[:A] == [1.0] + @test oprob.ps[:B] == [1.0, 2.0, 3.0, 4.0, 5.0] + @test oprob.ps[:dX] == sparse([1], [1], [0.1]) + + # Updates content. + oprob.ps[:A] = [10.0, 20.0, 30.0, 40.0, 50.0] + oprob.ps[:B] = [10.0] + oprob.ps[:dX] = [0.01] + + # Checks that content is correct. + @test oprob.ps[:A] == [10.0, 20.0, 30.0, 40.0, 50.0] + @test oprob.ps[:B] == [10.0] + @test oprob.ps[:dX] == [0.01] +end + +# Checks that the `rebuild_lat_internals!` function is correctly applied to an ODEProblem. +let + # Creates a Brusselator `LatticeReactionSystem`. + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_2, very_small_2d_cartesian_grid) + + # Checks for all combinations of Jacobian and sparsity. + for jac in [false, true], sparse in [false, true] + # Creates an initial ODEProblem. + u0 = [:X => 1.0, :Y => [1.0 2.0; 3.0 4.0]] + dY_vals = spzeros(4,4) + dY_vals[1,2] = 0.1; dY_vals[2,1] = 0.1; + dY_vals[1,3] = 0.2; dY_vals[3,1] = 0.2; + dY_vals[2,4] = 0.3; dY_vals[4,2] = 0.3; + dY_vals[3,4] = 0.4; dY_vals[4,3] = 0.4; + ps = [:A => 1.0, :B => [4.0 5.0; 6.0 7.0], :dX => 0.1, :dY => dY_vals] + oprob_1 = ODEProblem(lrs, u0, (0.0, 10.0), ps; jac, sparse) + + # Creates an alternative version of the ODEProblem. + dX_vals = spzeros(4,4) + dX_vals[1,2] = 0.01; dX_vals[2,1] = 0.01; + dX_vals[1,3] = 0.02; dX_vals[3,1] = 0.02; + dX_vals[2,4] = 0.03; dX_vals[4,2] = 0.03; + dX_vals[3,4] = 0.04; dX_vals[4,3] = 0.04; + ps = [:A => [1.1 1.2; 1.3 1.4], :B => 5.0, :dX => dX_vals, :dY => 0.01] + oprob_2 = ODEProblem(lrs, u0, (0.0, 10.0), ps; jac, sparse) + + # Modifies the initial ODEProblem to be identical to the new one. + oprob_1.ps[:A] = [1.1 1.2; 1.3 1.4] + oprob_1.ps[:B] = [5.0] + oprob_1.ps[:dX] = dX_vals + oprob_1.ps[:dY] = [0.01] + rebuild_lat_internals!(oprob_1) + + # Checks that simulations of the two `ODEProblem`s are identical. + @test solve(oprob_1, Rodas5P()) ≈ solve(oprob_2, Rodas5P()) + end +end + +# Checks that the `rebuild_lat_internals!` function is correctly applied to an integrator. +# Does through by applying it within a callback, and compare to simulations without callback. +# To keep test faster, only check for `jac = sparse = true` only. +let + # Prepares problem inputs. + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_2, very_small_2d_cartesian_grid) + u0 = [:X => 1.0, :Y => [1.0 2.0; 3.0 4.0]] + A1 = 1.0 + B1 = [4.0 5.0; 6.0 7.0] + A2 = [1.1 1.2; 1.3 1.4] + B2 = 5.0 + dY_vals = spzeros(4,4) + dY_vals[1,2] = 0.1; dY_vals[2,1] = 0.1; + dY_vals[1,3] = 0.2; dY_vals[3,1] = 0.2; + dY_vals[2,4] = 0.3; dY_vals[4,2] = 0.3; + dY_vals[3,4] = 0.4; dY_vals[4,3] = 0.4; + dX_vals = spzeros(4,4) + dX_vals[1,2] = 0.01; dX_vals[2,1] = 0.01; + dX_vals[1,3] = 0.02; dX_vals[3,1] = 0.02; + dX_vals[2,4] = 0.03; dX_vals[4,2] = 0.03; + dX_vals[3,4] = 0.04; dX_vals[4,3] = 0.04; + dX1 = 0.1 + dY1 = dY_vals + dX2 = dX_vals + dY2 = 0.01 + ps_1 = [:A => A1, :B => B1, :dX => dX1, :dY => dY1] + ps_2 = [:A => A2, :B => B2, :dX => dX2, :dY => dY2] + + # Creates simulation through two different separate simulations. + oprob_1_1 = ODEProblem(lrs, u0, (0.0, 5.0), ps_1; jac = true, sparse = true) + sol_1_1 = solve(oprob_1_1, Rosenbrock23(); saveat = 1.0, abstol = 1e-8, reltol = 1e-8) + u0_1_2 = [:X => sol_1_1.u[end][1:2:end], :Y => sol_1_1.u[end][2:2:end]] + oprob_1_2 = ODEProblem(lrs, u0_1_2, (0.0, 5.0), ps_2; jac = true, sparse = true) + sol_1_2 = solve(oprob_1_2, Rosenbrock23(); saveat = 1.0, abstol = 1e-8, reltol = 1e-8) + + # Creates simulation through a single simulation with a callback + oprob_2 = ODEProblem(lrs, u0, (0.0, 10.0), ps_1; jac = true, sparse = true) + condition(u, t, integrator) = (t == 5.0) + function affect!(integrator) + integrator.ps[:A] = A2 + integrator.ps[:B] = [B2] + integrator.ps[:dX] = dX2 + integrator.ps[:dY] = [dY2] + rebuild_lat_internals!(integrator) + end + callback = DiscreteCallback(condition, affect!) + sol_2 = solve(oprob_2, Rosenbrock23(); saveat = 1.0, tstops = [5.0], callback, abstol = 1e-8, reltol = 1e-8) + + # Check that trajectories are equivalent. + @test [sol_1_1.u; sol_1_2.u] ≈ sol_2.u +end + ### Tests Special Cases ### -# Create network with various combinations of graph/di-graph and parameters. +# Create networks using either graphs or di-graphs. let lrs_digraph = LatticeReactionSystem(SIR_system, SIR_srs_2, complete_digraph(3)) lrs_graph = LatticeReactionSystem(SIR_system, SIR_srs_2, complete_graph(3)) - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_digraph.lattice), :R => 0.0] + u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs_digraph), :R => 0.0] pV = SIR_p - pE_digraph_1 = [:dS => [0.10, 0.12, 0.10, 0.14, 0.12, 0.14], :dI => 0.01, :dR => 0.01] - pE_digraph_2 = [[0.10, 0.12, 0.10, 0.14, 0.12, 0.14], 0.01, 0.01] - pE_digraph_3 = [0.10 0.12 0.10 0.14 0.12 0.14; 0.01 0.01 0.01 0.01 0.01 0.01; 0.01 0.01 0.01 0.01 0.01 0.01] - pE_graph_1 = [:dS => [0.10, 0.12, 0.14], :dI => 0.01, :dR => 0.01] - pE_graph_2 = [[0.10, 0.12, 0.14], 0.01, 0.01] - pE_graph_3 = [0.10 0.12 0.14; 0.01 0.01 0.01; 0.01 0.01 0.01] - oprob_digraph_1 = ODEProblem(lrs_digraph, u0, (0.0, 500.0), (pV, pE_digraph_1)) - oprob_digraph_2 = ODEProblem(lrs_digraph, u0, (0.0, 500.0), (pV, pE_digraph_2)) - oprob_digraph_3 = ODEProblem(lrs_digraph, u0, (0.0, 500.0), (pV, pE_digraph_3)) - oprob_graph_11 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_digraph_1)) - oprob_graph_12 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_graph_1)) - oprob_graph_21 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_digraph_2)) - oprob_graph_22 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_graph_2)) - oprob_graph_31 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_digraph_3)) - oprob_graph_32 = ODEProblem(lrs_graph, u0, (0.0, 500.0), (pV, pE_graph_3)) - sim_end_digraph_1 = solve(oprob_digraph_1, Tsit5()).u[end] - sim_end_digraph_2 = solve(oprob_digraph_2, Tsit5()).u[end] - sim_end_digraph_3 = solve(oprob_digraph_3, Tsit5()).u[end] - sim_end_graph_11 = solve(oprob_graph_11, Tsit5()).u[end] - sim_end_graph_12 = solve(oprob_graph_12, Tsit5()).u[end] - sim_end_graph_21 = solve(oprob_graph_21, Tsit5()).u[end] - sim_end_graph_22 = solve(oprob_graph_22, Tsit5()).u[end] - sim_end_graph_31 = solve(oprob_graph_31, Tsit5()).u[end] - sim_end_graph_32 = solve(oprob_graph_32, Tsit5()).u[end] - - @test all(sim_end_digraph_1 .== sim_end_digraph_2 .== sim_end_digraph_3 .== - sim_end_graph_11 .== sim_end_graph_12 .== sim_end_graph_21 .== - sim_end_graph_22 .== sim_end_graph_31 .== sim_end_graph_32) + pE = [:dS => 0.10, :dI => 0.01, :dR => 0.01] + oprob_digraph = ODEProblem(lrs_digraph, u0, (0.0, 500.0), [pV; pE]) + oprob_graph = ODEProblem(lrs_graph, u0, (0.0, 500.0), [pV; pE]) + + @test solve(oprob_digraph, Tsit5()) == solve(oprob_graph, Tsit5()) end # Creates networks where some species or parameters have no effect on the system. @@ -424,194 +651,131 @@ let TransportReaction(dZ, Z), TransportReaction(dV, V), ] - lrs_alt = LatticeReactionSystem(binding_system_alt, binding_srs_alt, small_2d_grid) + lrs_alt = LatticeReactionSystem(binding_system_alt, binding_srs_alt, small_2d_graph_grid) u0_alt = [ :X => 1.0, - :Y => 2.0 * rand_v_vals(lrs_alt.lattice), + :Y => 2.0 * rand_v_vals(lrs_alt), :XY => 0.5, - :Z => 2.0 * rand_v_vals(lrs_alt.lattice), + :Z => 2.0 * rand_v_vals(lrs_alt), :V => 0.5, :W => 1.0, ] p_alt = [ :k1 => 2.0, - :k2 => 0.1 .+ rand_v_vals(lrs_alt.lattice), - :dX => 1.0 .+ rand_e_vals(lrs_alt.lattice), + :k2 => 0.1 .+ rand_v_vals(lrs_alt), + :dX => rand_e_vals(lrs_alt), :dXY => 3.0, - :dZ => rand_e_vals(lrs_alt.lattice), + :dZ => rand_e_vals(lrs_alt), :dV => 0.2, :p1 => 1.0, - :p2 => rand_v_vals(lrs_alt.lattice), + :p2 => rand_v_vals(lrs_alt), ] oprob_alt = ODEProblem(lrs_alt, u0_alt, (0.0, 10.0), p_alt) - ss_alt = solve(oprob_alt, Tsit5()).u[end] - + ss_alt = solve(oprob_alt, Tsit5(); abstol=1e-9, reltol=1e-9).u[end] + binding_srs_main = [TransportReaction(dX, X), TransportReaction(dXY, XY)] - lrs = LatticeReactionSystem(binding_system, binding_srs_main, small_2d_grid) + lrs = LatticeReactionSystem(binding_system, binding_srs_main, small_2d_graph_grid) u0 = u0_alt[1:3] p = p_alt[1:4] oprob = ODEProblem(lrs, u0, (0.0, 10.0), p) - ss = solve(oprob, Tsit5()).u[end] - + ss = solve(oprob, Tsit5(); abstol=1e-9, reltol=1e-9).u[end] + + i = 3 + ss_alt[((i - 1) * 6 + 1):((i - 1) * 6 + 3)] ≈ ss[((i - 1) * 3 + 1):((i - 1) * 3 + 3)] + for i in 1:25 - @test isapprox(ss_alt[((i - 1) * 6 + 1):((i - 1) * 6 + 3)], - ss[((i - 1) * 3 + 1):((i - 1) * 3 + 3)]) < 1e-3 + @test ss_alt[((i - 1) * 6 + 1):((i - 1) * 6 + 3)] ≈ ss[((i - 1) * 3 + 1):((i - 1) * 3 + 3)] end end -# Provides initial conditions and parameters in various different ways. +# Tests with non-Float64 parameter values. +# Tests for all Jacobian/sparsity combinations. +# Tests for parameters with/without uniform values. let - lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, very_small_2d_grid) - u0_1 = [:S => 990.0, :I => [1.0, 3.0, 2.0, 5.0], :R => 0.0] - u0_2 = [990.0, [1.0, 3.0, 2.0, 5.0], 0.0] - u0_3 = [990.0 990.0 990.0 990.0; 1.0 3.0 2.0 5.0; 0.0 0.0 0.0 0.0] - pV_1 = [:α => 0.1 / 1000, :β => [0.01, 0.02, 0.01, 0.03]] - pV_2 = [0.1 / 1000, [0.01, 0.02, 0.01, 0.03]] - pV_3 = [0.1/1000 0.1/1000 0.1/1000 0.1/1000; 0.01 0.02 0.01 0.03] - pE_1 = [:dS => [0.01, 0.02, 0.03, 0.04], :dI => 0.01, :dR => 0.01] - pE_2 = [[0.01, 0.02, 0.03, 0.04], :0.01, 0.01] - pE_3 = [0.01 0.02 0.03 0.04; 0.01 0.01 0.01 0.01; 0.01 0.01 0.01 0.01] - - p1 = [ - :α => 0.1 / 1000, - :β => [0.01, 0.02, 0.01, 0.03], - :dS => [0.01, 0.02, 0.03, 0.04], - :dI => 0.01, - :dR => 0.01, - ] - ss_1_1 = solve(ODEProblem(lrs, u0_1, (0.0, 1.0), p1), Tsit5()).u[end] - for u0 in [u0_1, u0_2, u0_3], pV in [pV_1, pV_2, pV_3], pE in [pE_1, pE_2, pE_3] - ss = solve(ODEProblem(lrs, u0, (0.0, 1.0), (pV, pE)), Tsit5()).u[end] - @test all(isequal.(ss, ss_1_1)) + lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, very_small_2d_cartesian_grid) + u0 = [:S => 990.0, :I => rand_v_vals(lrs), :R => 0.0] + ps_1 = [:α => 0.1, :β => 0.01, :dS => 0.01, :dI => 0.01, :dR => 0.01] + ps_2 = [:α => 1//10, :β => 1//100, :dS => 1//100, :dI => 1//100, :dR => 1//100] + ps_3 = [:α => 1//10, :β => 0.01, :dS => 0.01, :dI => 1//100, :dR => 0.01] + sol_base = solve(ODEProblem(lrs, u0, (0.0, 100.0), ps_1), Rosenbrock23(); saveat = 0.1) + for ps in [ps_1, ps_2, ps_3] + for jac in [true, false], sparse in [true, false] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps; jac, sparse) + @test sol_base ≈ solve(oprob, Rosenbrock23(); saveat = 0.1) + end end end -# Confirms parameters can be provided in [pV; pE] and (pV, pE) form. -let - lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, small_2d_grid) - u0 = [:S => 990.0, :I => 20.0 * rand_v_vals(lrs.lattice), :R => 0.0] - p1 = ([:α => 0.1 / 1000, :β => 0.01], [:dS => 0.01, :dI => 0.01, :dR => 0.01]) - p2 = [:α => 0.1 / 1000, :β => 0.01, :dS => 0.01, :dI => 0.01, :dR => 0.01] - oprob1 = ODEProblem(lrs, u0, (0.0, 500.0), p1; jac = false) - oprob2 = ODEProblem(lrs, u0, (0.0, 500.0), p2; jac = false) - - @test all(isapprox.(solve(oprob1, Tsit5()).u[end], solve(oprob2, Tsit5()).u[end])) -end - -### Compare to Hand-written Functions ### - -# Compares the brusselator for a line of cells. +# Tests various types of numbers for initial conditions/parameters (e.g. Real numbers, Float32, etc.). let - function spatial_brusselator_f(du, u, p, t) - # Non-spatial - for i in 1:2:(length(u) - 1) - du[i] = p[1] + 0.5 * (u[i]^2) * u[i + 1] - u[i] - p[2] * u[i] - du[i + 1] = p[2] * u[i] - 0.5 * (u[i]^2) * u[i + 1] - end - - # Spatial - du[1] += p[3] * (u[3] - u[1]) - du[end - 1] += p[3] * (u[end - 3] - u[end - 1]) - for i in 3:2:(length(u) - 3) - du[i] += p[3] * (u[i - 2] + u[i + 2] - 2u[i]) - end - end - function spatial_brusselator_jac(J, u, p, t) - J .= 0 - # Non-spatial - for i in 1:2:(length(u) - 1) - J[i, i] = u[i] * u[i + 1] - 1 - p[2] - J[i, i + 1] = 0.5 * (u[i]^2) - J[i + 1, i] = p[2] - u[i] * u[i + 1] - J[i + 1, i + 1] = -0.5 * (u[i]^2) - end - - # Spatial - J[1, 1] -= p[3] - J[1, 3] += p[3] - J[end - 1, end - 1] -= p[3] - J[end - 1, end - 3] += p[3] - for i in 3:2:(length(u) - 3) - J[i, i] -= 2 * p[3] - J[i, i - 2] += p[3] - J[i, i + 2] += p[3] - end - end - function spatial_brusselator_jac_sparse(J, u, p, t) - # Spatial - J.nzval .= 0.0 - J.nzval[7:6:(end - 9)] .= -2p[3] - J.nzval[1] = -p[3] - J.nzval[end - 3] = -p[3] - J.nzval[3:3:(end - 4)] .= p[3] - - # Non-spatial - for i in 1:1:Int64(lenth(u) / 2 - 1) - j = 6(i - 1) + 1 - J.nzval[j] = u[i] * u[i + 1] - 1 - p[2] - J.nzval[j + 1] = 0.5 * (u[i]^2) - J.nzval[j + 3] = p[2] - u[i] * u[i + 1] - J.nzval[j + 4] = -0.5 * (u[i]^2) + # Declare u0 versions. + u0_Int64 = [:X => 2, :Y => [1, 1, 1, 2]] + u0_Float64 = [:X => 2.0, :Y => [1.0, 1.0, 1.0, 2.0]] + u0_Int32 = [:X => Int32(2), :Y => Int32.([1, 1, 1, 2])] + u0_Any = Pair{Symbol,Any}[:X => 2.0, :Y => [1.0, 1.0, 1.0, 2.0]] + u0s = (u0_Int64, u0_Float64, u0_Int32, u0_Any) + + # Declare parameter versions. + dY_vals = spzeros(4,4) + dY_vals[1,2] = 1; dY_vals[2,1] = 1; + dY_vals[1,3] = 1; dY_vals[3,1] = 1; + dY_vals[2,4] = 1; dY_vals[4,2] = 1; + dY_vals[3,4] = 2; dY_vals[4,3] = 2; + p_Int64 = (:A => [1, 1, 1, 2], :B => 4, :dX => 1, :dY => Int64.(dY_vals)) + p_Float64 = (:A => [1.0, 1.0, 1.0, 2.0], :B => 4.0, :dX => 1.0, :dY => Float64.(dY_vals)) + p_Int32 = (:A => Int32.([1, 1, 1, 2]), :B => Int32(4), :dX => Int32(1), :dY => Int32.(dY_vals)) + p_Any = Pair{Symbol,Any}[:A => [1.0, 1.0, 1.0, 2.0], :B => 4.0, :dX => 1.0, :dY => dY_vals] + ps = (p_Int64, p_Float64, p_Int32, p_Any) + + # Creates a base solution to compare all solution to. + lrs_base = LatticeReactionSystem(brusselator_system, brusselator_srs_2, very_small_2d_graph_grid) + oprob_base = ODEProblem(lrs_base, u0s[1], (0.0, 1.0), ps[1]) + sol_base = solve(oprob_base, QNDF(); saveat = 0.01) + + # Checks all combinations of input types. + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_2, very_small_2d_cartesian_grid) + for u0_base in u0s, p_base in ps + for u0 in [u0_base, Tuple(u0_base), Dict(u0_base)], p in [p_base, Dict(p_base)] + oprob = ODEProblem(lrs, u0, (0.0, 1.0), p; sparse = true, jac = true) + sol = solve(oprob, QNDF(); saveat = 0.01) + @test sol.u ≈ sol_base.u atol = 1e-6 rtol = 1e-6 end - J.nzval[end - 3] = u[end - 1] * u[end] - 1 - p[end - 1] - J.nzval[end - 2] = 0.5 * (u[end - 1]^2) - J.nzval[end - 1] = p[2] - u[end - 1] * u[end] - J.nzval[end] = -0.5 * (u[end - 1]^2) end - function make_jac_prototype(u0) - jac_prototype_pre = zeros(length(u0), length(u0)) - for i in 1:2:(length(u0) - 1) - jac_prototype_pre[i, i] = 1 - jac_prototype_pre[i + 1, i] = 1 - jac_prototype_pre[i, i + 1] = 1 - jac_prototype_pre[i + 1, i + 1] = 1 - end - for i in 3:2:(length(u0) - 1) - jac_prototype_pre[i - 2, i] = 1 - jac_prototype_pre[i, i - 2] = 1 - end - return sparse(jac_prototype_pre) - end - - u0 = 2 * rand(rng, 10000) - p = [1.0, 4.0, 0.1] - tspan = (0.0, 100.0) - - ofun_hw_dense = ODEFunction(spatial_brusselator_f; jac = spatial_brusselator_jac) - ofun_hw_sparse = ODEFunction(spatial_brusselator_f; jac = spatial_brusselator_jac, - jac_prototype = make_jac_prototype(u0)) - - lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, - path_graph(Int64(length(u0) / 2))) - u0V = [:X => u0[1:2:(end - 1)], :Y => u0[2:2:end]] - pV = [:A => p[1], :B => p[2]] - pE = [:dX => p[3]] - ofun_aut_dense = ODEProblem(lrs, u0V, tspan, (pV, pE); jac = true, sparse = false).f - ofun_aut_sparse = ODEProblem(lrs, u0V, tspan, (pV, pE); jac = true, sparse = true).f - - du_hw_dense = deepcopy(u0) - du_hw_sparse = deepcopy(u0) - du_aut_dense = deepcopy(u0) - du_aut_sparse = deepcopy(u0) - - ofun_hw_dense(du_hw_dense, u0, p, 0.0) - ofun_hw_sparse(du_hw_sparse, u0, p, 0.0) - ofun_aut_dense(du_aut_dense, u0, p, 0.0) - ofun_aut_sparse(du_aut_sparse, u0, p, 0.0) - - @test isapprox(du_hw_dense, du_aut_dense) - @test isapprox(du_hw_sparse, du_aut_sparse) +end - J_hw_dense = deepcopy(zeros(length(u0), length(u0))) - J_hw_sparse = deepcopy(make_jac_prototype(u0)) - J_aut_dense = deepcopy(zeros(length(u0), length(u0))) - J_aut_sparse = deepcopy(make_jac_prototype(u0)) - ofun_hw_dense.jac(J_hw_dense, u0, p, 0.0) - ofun_hw_sparse.jac(J_hw_sparse, u0, p, 0.0) - ofun_aut_dense.jac(J_aut_dense, u0, p, 0.0) - ofun_aut_sparse.jac(J_aut_sparse, u0, p, 0.0) +### Error Tests ### - @test isapprox(J_hw_dense, J_aut_dense) - @test isapprox(J_hw_sparse, J_aut_sparse) +# Checks that attempting to remove conserved quantities yields an error. +let + lrs = LatticeReactionSystem(binding_system, binding_srs, very_small_2d_masked_grid) + @test_throws ArgumentError ODEProblem(lrs, binding_u0, (0.0, 10.0), binding_p; remove_conserved = true) end + +# Checks that various erroneous inputs to `ODEProblem` yields errors. +let + # Create `LatticeReactionSystem`. + @parameters d1 d2 D [edgeparameter=true] + @species X1(t) X2(t) + rxs = [Reaction(d1, [X1], [])] + @named rs = ReactionSystem(rxs, t) + rs = complete(rs) + lrs = LatticeReactionSystem(rs, [TransportReaction(D, X1)], CartesianGrid((4,))) + + # Attempts to create `ODEProblem` using various faulty inputs. + u0 = [X1 => 1.0] + tspan = (0.0, 1.0) + ps = [d1 => 1.0, D => 0.1] + @test_throws ArgumentError ODEProblem(lrs, [1.0], tspan, ps) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [1.0, 0.1]) + @test_throws ArgumentError ODEProblem(lrs, [X1 => 1.0, X2 => 2.0], tspan, ps) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [d1 => 1.0, d2 => 0.2, D => 0.1]) + @test_throws ArgumentError ODEProblem(lrs, [X1 => [1.0, 2.0, 3.0]], tspan, ps) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [d1 => [1.0, 2.0, 3.0], D => 0.1]) + @test_throws ArgumentError ODEProblem(lrs, [X1 => [1.0 2.0; 3.0 4.0]], tspan, ps) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [d1 => [1.0 2.0; 3.0 4.0], D => 0.1]) + bad_D_vals_1 = sparse([0.0 1.0 0.0 1.0; 1.0 0.0 1.0 0.0; 0.0 1.0 0.0 1.0; 1.0 0.0 1.0 0.0]) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [d1 => 1.0, D => bad_D_vals_1]) + bad_D_vals_2 = sparse([0.0 0.0 0.0 1.0; 1.0 0.0 1.0 0.0; 0.0 1.0 0.0 1.0; 1.0 0.0 0.0 0.0]) + @test_throws ArgumentError ODEProblem(lrs, u0, tspan, [d1 => 1.0, D => bad_D_vals_2]) +end \ No newline at end of file diff --git a/test/spatial_modelling/lattice_reaction_systems_jumps.jl b/test/spatial_modelling/lattice_reaction_systems_jumps.jl new file mode 100644 index 0000000000..576e543f40 --- /dev/null +++ b/test/spatial_modelling/lattice_reaction_systems_jumps.jl @@ -0,0 +1,235 @@ +### Preparations ### + +# Fetch packages. +using JumpProcesses, Statistics, SparseArrays, Test + +# Fetch test networks. +include("../spatial_test_networks.jl") + + +### General Tests ### + +# Tests that there are no errors during runs for a variety of input forms. +let + for grid in [small_2d_graph_grid, small_2d_cartesian_grid, small_2d_masked_grid] + for srs in [Vector{TransportReaction}(), SIR_srs_1, SIR_srs_2] + lrs = LatticeReactionSystem(SIR_system, srs, grid) + u0_1 = [:S => 999, :I => 1, :R => 0] + u0_2 = [:S => round.(Int64, 500 .+ 500 * rand_v_vals(lrs)), :I => 1, :R => 0] + u0_3 = [ + :S => round.(Int64, 500 .+ 500 * rand_v_vals(lrs)), + :I => round.(Int64, 50 * rand_v_vals(lrs)), + :R => round.(Int64, 50 * rand_v_vals(lrs)), + ] + for u0 in [u0_1, u0_2, u0_3] + pV_1 = [:α => 0.1 / 1000, :β => 0.01] + pV_2 = [:α => 0.1 / 1000, :β => 0.02 * rand_v_vals(lrs)] + pV_3 = [ + :α => 0.1 / 2000 * rand_v_vals(lrs), + :β => 0.02 * rand_v_vals(lrs), + ] + for pV in [pV_1, pV_2, pV_3] + pE_1 = [sp => 0.01 for sp in spatial_param_syms(lrs)] + pE_2 = [sp => rand_e_vals(lrs)/50.0 for sp in spatial_param_syms(lrs)] + for pE in [pE_1, pE_2] + isempty(spatial_param_syms(lrs)) && (pE = Vector{Pair{Symbol, Float64}}()) + dprob = DiscreteProblem(lrs, u0, (0.0, 1.0), [pV; pE]) + jprob = JumpProblem(lrs, dprob, NSM()) + @test SciMLBase.successful_retcode(solve(jprob, SSAStepper())) + end + end + end + end + end +end + + +### Input Handling Tests ### + +# Tests that the correct hopping rates and initial conditions are generated. +# In this base case, hopping rates should be on the form D_{s,i,j}. +let + # Prepares the system. + grid = small_2d_graph_grid + lrs = LatticeReactionSystem(SIR_system, SIR_srs_2, grid) + + # Prepares various u0 input types. + u0_1 = [:I => 2.0, :S => 1.0, :R => 3.0] + u0_2 = [:I => fill(2., nv(grid)), :S => 1.0, :R => 3.0] + + # Prepare various (compartment) parameter input types. + pV_1 = [:β => 0.2, :α => 0.1] + pV_2 = [:β => fill(0.2, nv(grid)), :α => 1.0] + + # Prepare various (diffusion) parameter input types. + pE_1 = [:dI => 0.02, :dS => 0.01, :dR => 0.03] + dS_vals = spzeros(num_verts(lrs), num_verts(lrs)) + foreach(e -> (dS_vals[e[1], e[2]] = 0.01), edge_iterator(lrs)) + pE_2 = [:dI => 0.02, :dS => dS_vals, :dR => 0.03] + + # Checks hopping rates and u0 are correct. + true_u0 = [fill(1.0, 1, 25); fill(2.0, 1, 25); fill(3.0, 1, 25)] + true_hopping_rates = cumsum.([fill(dval, length(v)) for dval in [0.01,0.02,0.03], v in grid.fadjlist]) + true_maj_scaled_rates = [0.1, 0.2] + true_maj_reactant_stoch = [[1 => 1, 2 => 1], [2 => 1]] + true_maj_net_stoch = [[1 => -1, 2 => 1], [2 => -1, 3 => 1]] + for u0 in [u0_1, u0_2] + for pV in [pV_1, pV_2], pE in [pE_1, pE_2] + dprob = DiscreteProblem(lrs, u0, (0.0, 100.0), [pE; pV]) + jprob = JumpProblem(lrs, dprob, NSM()) + @test jprob.prob.u0 == true_u0 + @test jprob.discrete_jump_aggregation.hop_rates.hop_const_cumulative_sums == true_hopping_rates + @test jprob.massaction_jump.reactant_stoch == true_maj_reactant_stoch + @test all(issetequal(ns1, ns2) for (ns1, ns2) in zip(jprob.massaction_jump.net_stoch, true_maj_net_stoch)) + end + end +end + + +### SpatialMassActionJump Testing ### + +# Checks that the correct structures are produced. +let + # Network for reference: + # A, ∅ → X + # 1, 2X + Y → 3X + # B, X → Y + # 1, X → ∅ + # srs = [@transport_reaction dX X] + # Create LatticeReactionSystem + lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_3d_graph_grid) + + # Create JumpProblem + u0 = [:X => 1, :Y => rand(1:10, num_verts(lrs))] + tspan = (0.0, 100.0) + ps = [:A => 1.0, :B => 5.0 .+ rand_v_vals(lrs), :dX => rand_e_vals(lrs)] + dprob = DiscreteProblem(lrs, u0, tspan, ps) + jprob = JumpProblem(lrs, dprob, NSM()) + + # Checks internal structures. + jprob.massaction_jump.uniform_rates == [1.0, 0.5 ,10.] # 0.5 is due to combinatoric /2! in (2X + Y). + jprob.massaction_jump.spatial_rates[1,:] == ps[2][2] + # Test when new SII functions are ready, or we implement them in Catalyst. + # @test isequal(to_int(getfield.(reactions(reactionsystem(lrs)), :netstoich)), jprob.massaction_jump.net_stoch) + # @test isequal(to_int(Pair.(getfield.(reactions(reactionsystem(lrs)), :substrates),getfield.(reactions(reactionsystem(lrs)), :substoich))), jprob.massaction_jump.net_stoch) + + # Checks that problems can be simulated. + @test SciMLBase.successful_retcode(solve(jprob, SSAStepper())) +end + +# Checks that heterogeneous vertex parameters work. Checks that birth-death system with different +# birth rates produce different means. +let + # Create model. + birth_death_network = @reaction_network begin + (p,d), 0 <--> X + end + srs = [(@transport_reaction D X)] + lrs = LatticeReactionSystem(birth_death_network, srs, very_small_2d_graph_grid) + + # Create JumpProblem. + u0 = [:X => 1] + tspan = (0.0, 100.0) + ps = [:p => [0.1, 1.0, 10.0, 100.0], :d => 1.0, :D => 0.0] + dprob = DiscreteProblem(lrs, u0, tspan, ps) + jprob = JumpProblem(lrs, dprob, NSM()) + + # Simulate model (a few repeats to ensure things don't succeed by change for uniform rates). + # Check that higher p gives higher mean. + for i = 1:5 + sol = solve(jprob, SSAStepper(); saveat = 1.) + @test mean(getindex.(sol.u, 1)) < mean(getindex.(sol.u, 2)) < mean(getindex.(sol.u, 3)) < mean(getindex.(sol.u, 4)) + end +end + + +### Tests taken from JumpProcesses ### + +# ABC Model Test +let + # Preparations (stuff used in JumpProcesses examples ported over here, could be written directly into code). + Nsims = 100 + reltol = 0.05 + non_spatial_mean = [65.7395, 65.7395, 434.2605] # Mean of 10,000 simulations. + dim = 1 + linear_size = 5 + num_nodes = linear_size^dim + dims = Tuple(repeat([linear_size], dim)) + domain_size = 1.0 # μ-meter. + mesh_size = domain_size / linear_size + rates = [0.1 / mesh_size, 1.0] + diffusivity = 1.0 + num_species = 3 + + # Make model. + rn = @reaction_network begin + (kB,kD), A + B <--> C + end + tr_1 = @transport_reaction D A + tr_2 = @transport_reaction D B + tr_3 = @transport_reaction D C + lattice = Graphs.grid(dims) + lrs = LatticeReactionSystem(rn, [tr_1, tr_2, tr_3], lattice) + + # Set simulation parameters and create problems. + u0 = [:A => [0,0,500,0,0], :B => [0,0,500,0,0], :C => 0] + tspan = (0.0, 10.0) + pV = [:kB => rates[1], :kD => rates[2]] + pE = [:D => diffusivity] + dprob = DiscreteProblem(lrs, u0, tspan, [pV; pE]) + # NRM could be added, but doesn't work. Might need Cartesian grid. + jump_problems = [JumpProblem(lrs, dprob, alg(); save_positions = (false, false)) for alg in [NSM, DirectCRDirect]] + + # Run tests. + function get_mean_end_state(jump_prob, Nsims) + end_state = zeros(size(jump_prob.prob.u0)) + for i in 1:Nsims + sol = solve(jump_prob, SSAStepper()) + end_state .+= sol.u[end] + end + end_state / Nsims + end + for jprob in jump_problems + solution = solve(jprob, SSAStepper()) + mean_end_state = get_mean_end_state(jprob, Nsims) + mean_end_state = reshape(mean_end_state, num_species, num_nodes) + diff = sum(mean_end_state, dims = 2) - non_spatial_mean + for (i, d) in enumerate(diff) + @test abs(d) < reltol * non_spatial_mean[i] + end + end +end + + +### JumpProblem & Integrator Interfacing ### + +# Currently not supported, check that corresponding functions yield errors. +let + # Prepare `LatticeReactionSystem`. + rs = @reaction_network begin + (k1,k2), X1 <--> X2 + end + tr = @transport_reaction D X1 + grid = CartesianGrid((2,2)) + lrs = LatticeReactionSystem(rs, [tr], grid) + + # Create problems. + u0 = [:X1 => 2, :X2 => [5 6; 7 8]] + tspan = (0.0, 10.0) + ps = [:k1 => 1.5, :k2 => [1.0 1.5; 2.0 3.5], :D => 0.1] + dprob = DiscreteProblem(lrs, u0, tspan, ps) + jprob = JumpProblem(lrs, dprob, NSM()) + + # Checks that rebuilding errors. + @test_throws Exception rebuild_lat_internals!(dprob) + @test_throws Exception rebuild_lat_internals!(jprob) +end + +### Other Tests ### + +# Checks that providing a non-spatial `DiscreteProblem` to a `JumpProblem` gives an error. +let + lrs = LatticeReactionSystem(binding_system, binding_srs, very_small_2d_masked_grid) + dprob = DiscreteProblem(binding_system, binding_u0, (0.0, 10.0), binding_p[1:2]) + @test_throws ArgumentError JumpProblem(lrs, dprob, NSM()) +end \ No newline at end of file diff --git a/test/spatial_modelling/lattice_reaction_systems_lattice_types.jl b/test/spatial_modelling/lattice_reaction_systems_lattice_types.jl new file mode 100644 index 0000000000..dbdc233b89 --- /dev/null +++ b/test/spatial_modelling/lattice_reaction_systems_lattice_types.jl @@ -0,0 +1,260 @@ +### Preparations ### + +# Fetch packages. +using Catalyst, Graphs, OrdinaryDiffEq, Test + +# Fetch test networks. +include("../spatial_test_networks.jl") + + +### Run Tests ### + +# Test errors when attempting to create networks with dimensions > 3. +let + @test_throws Exception LatticeReactionSystem(brusselator_system, brusselator_srs_1, CartesianGrid((5, 5, 5, 5))) + @test_throws Exception LatticeReactionSystem(brusselator_system, brusselator_srs_1, fill(true, 5, 5, 5, 5)) +end + +# Checks that getter functions give the correct output. +let + # Create LatticeReactionsSystems. + cartesian_1d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_1d_cartesian_grid) + cartesian_2d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_cartesian_grid) + cartesian_3d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_3d_cartesian_grid) + masked_1d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_1d_masked_grid) + masked_2d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_2d_masked_grid) + masked_3d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, small_3d_masked_grid) + graph_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, medium_2d_graph_grid) + + # Test lattice type getters. + @test has_cartesian_lattice(cartesian_2d_lrs) + @test !has_cartesian_lattice(masked_2d_lrs) + @test !has_cartesian_lattice(graph_lrs) + + @test !has_masked_lattice(cartesian_2d_lrs) + @test has_masked_lattice(masked_2d_lrs) + @test !has_masked_lattice(graph_lrs) + + @test has_grid_lattice(cartesian_2d_lrs) + @test has_grid_lattice(masked_2d_lrs) + @test !has_grid_lattice(graph_lrs) + + @test !has_graph_lattice(cartesian_2d_lrs) + @test !has_graph_lattice(masked_2d_lrs) + @test has_graph_lattice(graph_lrs) + + # Checks grid dimensions. + @test grid_dims(cartesian_1d_lrs) == 1 + @test grid_dims(cartesian_2d_lrs) == 2 + @test grid_dims(cartesian_3d_lrs) == 3 + @test grid_dims(masked_1d_lrs) == 1 + @test grid_dims(masked_2d_lrs) == 2 + @test grid_dims(masked_3d_lrs) == 3 + @test_throws ArgumentError grid_dims(graph_lrs) + + # Checks grid sizes. + @test grid_size(cartesian_1d_lrs) == (5,) + @test grid_size(cartesian_2d_lrs) == (5,5) + @test grid_size(cartesian_3d_lrs) == (5,5,5) + @test grid_size(masked_1d_lrs) == (5,) + @test grid_size(masked_2d_lrs) == (5,5) + @test grid_size(masked_3d_lrs) == (5,5,5) + @test_throws ArgumentError grid_size(graph_lrs) +end + +# Checks grid dimensions for 2d and 3d grids where some dimension is equal to 1. +let + # Creates LatticeReactionSystems + cartesian_2d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, CartesianGrid((1,5))) + cartesian_3d_lrs_1 = LatticeReactionSystem(brusselator_system, brusselator_srs_1, CartesianGrid((1,5,5))) + cartesian_3d_lrs_2 = LatticeReactionSystem(brusselator_system, brusselator_srs_1, CartesianGrid((1,1,5))) + masked_2d_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, fill(true, 1, 5)) + masked_3d_lrs_1 = LatticeReactionSystem(brusselator_system, brusselator_srs_1, fill(true, 1, 5,5)) + masked_3d_lrs_2 = LatticeReactionSystem(brusselator_system, brusselator_srs_1, fill(true, 1, 1,5)) + + # Check grid dimensions. + @test grid_dims(cartesian_2d_lrs) == 2 + @test grid_dims(cartesian_3d_lrs_1) == 3 + @test grid_dims(cartesian_3d_lrs_2) == 3 + @test grid_dims(masked_2d_lrs) == 2 + @test grid_dims(masked_3d_lrs_1) == 3 + @test grid_dims(masked_3d_lrs_2) == 3 +end + +# Checks that some grids, created using different approaches, generates the same spatial structures. +# Checks that some grids, created using different approaches, generates the same simulation output. +let + # Create LatticeReactionsSystems. + cartesian_grid = CartesianGrid((5, 5)) + masked_grid = fill(true, 5, 5) + graph_grid = Graphs.grid([5, 5]) + + cartesian_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, cartesian_grid) + masked_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, masked_grid) + graph_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, graph_grid) + + # Check internal structures. + @test reactionsystem(cartesian_lrs) == reactionsystem(masked_lrs) == reactionsystem(graph_lrs) + @test spatial_reactions(cartesian_lrs) == spatial_reactions(masked_lrs) == spatial_reactions(graph_lrs) + @test num_verts(cartesian_lrs) == num_verts(masked_lrs) == num_verts(graph_lrs) + @test num_edges(cartesian_lrs) == num_edges(masked_lrs) == num_edges(graph_lrs) + @test num_species(cartesian_lrs) == num_species(masked_lrs) == num_species(graph_lrs) + @test isequal(spatial_species(cartesian_lrs), spatial_species(masked_lrs)) + @test isequal(spatial_species(masked_lrs), spatial_species(graph_lrs)) + @test isequal(parameters(cartesian_lrs), parameters(masked_lrs)) + @test isequal(parameters(masked_lrs), parameters(graph_lrs)) + @test isequal(vertex_parameters(cartesian_lrs), vertex_parameters(masked_lrs)) + @test isequal(edge_parameters(masked_lrs), edge_parameters(graph_lrs)) + @test issetequal(edge_iterator(cartesian_lrs), edge_iterator(masked_lrs)) + @test issetequal(edge_iterator(masked_lrs), edge_iterator(graph_lrs)) + + # Checks that simulations yields the same output. + X_vals = rand(num_verts(cartesian_lrs)) + u0_cartesian = [:X => reshape(X_vals, 5, 5), :Y => 2.0] + u0_masked = [:X => reshape(X_vals, 5, 5), :Y => 2.0] + u0_graph = [:X => X_vals, :Y => 2.0] + B_vals = rand(num_verts(cartesian_lrs)) + pV_cartesian = [:A => 0.5 .+ reshape(B_vals, 5, 5), :B => 4.0] + pV_masked = [:A => 0.5 .+ reshape(B_vals, 5, 5), :B => 4.0] + pV_graph = [:A => 0.5 .+ B_vals, :B => 4.0] + pE = [:dX => 0.2] + + cartesian_oprob = ODEProblem(cartesian_lrs, u0_cartesian, (0.0, 100.0), [pV_cartesian; pE]) + masked_oprob = ODEProblem(masked_lrs, u0_masked, (0.0, 100.0), [pV_masked; pE]) + graph_oprob = ODEProblem(graph_lrs, u0_graph, (0.0, 100.0), [pV_graph; pE]) + + cartesian_sol = solve(cartesian_oprob, QNDF(); saveat=0.1) + masked_sol = solve(masked_oprob, QNDF(); saveat=0.1) + graph_sol = solve(graph_oprob, QNDF(); saveat=0.1) + + @test cartesian_sol.u == masked_sol.u == graph_sol.u +end + +# Checks that a regular grid with absent vertices generate the same output as corresponding graph. +let + # Create LatticeReactionsSystems. + masked_grid = [true true true; true false true; true true true] + graph_grid = SimpleGraph(8) + add_edge!(graph_grid, 1, 2); add_edge!(graph_grid, 2, 1); + add_edge!(graph_grid, 2, 3); add_edge!(graph_grid, 3, 2); + add_edge!(graph_grid, 3, 5); add_edge!(graph_grid, 5, 3); + add_edge!(graph_grid, 5, 8); add_edge!(graph_grid, 8, 5); + add_edge!(graph_grid, 8, 7); add_edge!(graph_grid, 7, 8); + add_edge!(graph_grid, 7, 6); add_edge!(graph_grid, 6, 7); + add_edge!(graph_grid, 6, 4); add_edge!(graph_grid, 4, 6); + add_edge!(graph_grid, 4, 1); add_edge!(graph_grid, 1, 4); + + masked_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, masked_grid) + graph_lrs = LatticeReactionSystem(brusselator_system, brusselator_srs_1, graph_grid) + + # Check internal structures. + @test num_verts(masked_lrs) == num_verts(graph_lrs) + @test num_edges(masked_lrs) == num_edges(graph_lrs) + @test issetequal(edge_iterator(masked_lrs), edge_iterator(graph_lrs)) + + # Checks that simulations yields the same output. + u0_masked_grid = [:X => [1. 4. 6.; 2. 0. 7.; 3. 5. 8.], :Y => 2.0] + u0_graph_grid = [:X => [1., 2., 3., 4., 5., 6., 7., 8.], :Y => 2.0] + pV_masked_grid = [:A => 0.5 .+ [1. 4. 6.; 2. 0. 7.; 3. 5. 8.], :B => 4.0] + pV_graph_grid = [:A => 0.5 .+ [1., 2., 3., 4., 5., 6., 7., 8.], :B => 4.0] + pE = [:dX => 0.2] + + base_oprob = ODEProblem(masked_lrs, u0_masked_grid, (0.0, 100.0), [pV_masked_grid; pE]) + base_osol = solve(base_oprob, QNDF(); saveat=0.1, abstol=1e-9, reltol=1e-9) + + for jac in [false, true], sparse in [false, true] + masked_oprob = ODEProblem(masked_lrs, u0_masked_grid, (0.0, 100.0), [pV_masked_grid; pE]; jac, sparse) + graph_oprob = ODEProblem(graph_lrs, u0_graph_grid, (0.0, 100.0), [pV_graph_grid; pE]; jac, sparse) + masked_sol = solve(masked_oprob, QNDF(); saveat=0.1, abstol=1e-9, reltol=1e-9) + graph_sol = solve(graph_oprob, QNDF(); saveat=0.1, abstol=1e-9, reltol=1e-9) + @test base_osol ≈ masked_sol ≈ graph_sol + end +end + +# For a system which is a single ine of vertices: (O-O-O-O-X-O-O-O), ensures that different simulations +# approach yield the same result. Checks for both masked and Cartesian grid. For both, simulates where +# initial conditions/vertex parameters are either a vector of the same length as the number of vertices (7), +# Or as the grid. Here, we try grid sizes (n), (1,n), and (1,n,1) (so the same grid, but in 1d, 2d, and 3d). +# For the Cartesian grid, we cannot represent the gap, so we make simulations both for length-4 and +# length-3 grids. +let + # Declares the initial condition/parameter values. + S_vals = [500.0, 600.0, 700.0, 800.0, 0.0, 900.0, 1000.0, 1100.0] + I_val = 1.0 + R_val = 1.0 + α_vals = [0.1, 0.11, 0.12, 0.13, 0.0, 0.14, 0.15, 0.16] + β_val = 0.01 + dS_val = 0.05 + SIR_p = [:α => 0.1 / 1000, :β => 0.01] + + # Declares the grids (1d, 2d, and 3d). For each dimension, there are a 2 Cartesian grids (length 4 and 3). + cart_grid_1d_1 = CartesianGrid(4) + cart_grid_1d_2 = CartesianGrid(3) + cart_grid_2d_1 = CartesianGrid((4,1)) + cart_grid_2d_2 = CartesianGrid((3,1)) + cart_grid_3d_1 = CartesianGrid((1,4,1)) + cart_grid_3d_2 = CartesianGrid((1,3,1)) + + masked_grid_1d = [true, true, true, true, false, true, true, true] + masked_grid_2d = reshape(masked_grid_1d,8,1) + masked_grid_3d = reshape(masked_grid_1d,1,8,1) + + # Creaets a base solution to which we will compare all simulations. + lrs_base = LatticeReactionSystem(SIR_system, SIR_srs_1, masked_grid_1d) + oprob_base = ODEProblem(lrs_base, [:S => S_vals, :I => I_val, :R => R_val], (0.0, 100.0), [:α => α_vals, :β => β_val, :dS => dS_val]) + sol_base = solve(oprob_base, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + + # Checks simulations for the masked grid (covering all 7 vertices, with a gap in the middle). + for grid in [masked_grid_1d, masked_grid_2d, masked_grid_3d] + # Checks where the values are vectors of length equal to the number of vertices. + lrs = LatticeReactionSystem(SIR_system, SIR_srs_1, grid) + u0 = [:S => [S_vals[1:4]; S_vals[6:8]], :I => I_val, :R => R_val] + ps = [:α => [α_vals[1:4]; α_vals[6:8]], :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test sol ≈ sol_base + + # Checks where the values are arrays of size equal to the grid. + u0 = [:S => reshape(S_vals, grid_size(lrs)), :I => I_val, :R => R_val] + ps = [:α => reshape(α_vals, grid_size(lrs)), :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test sol ≈ sol_base + end + + # Checks simulations for the first Cartesian grids (covering vertices 1 to 4). + for grid in [cart_grid_1d_1, cart_grid_2d_1, cart_grid_3d_1] + # Checks where the values are vectors of length equal to the number of vertices. + lrs = LatticeReactionSystem(SIR_system, SIR_srs_1, grid) + u0 = [:S => S_vals[1:4], :I => I_val, :R => R_val] + ps = [:α => α_vals[1:4], :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test hcat(sol.u...) ≈ sol_base[1:12,:] + + # Checks where the values are arrays of size equal to the grid. + u0 = [:S => reshape(S_vals[1:4], grid_size(lrs)), :I => I_val, :R => R_val] + ps = [:α => reshape(α_vals[1:4], grid_size(lrs)), :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test hcat(sol.u...) ≈ sol_base[1:12,:] + end + + # Checks simulations for the second Cartesian grids (covering vertices 6 to 8). + for grid in [cart_grid_1d_2, cart_grid_2d_2, cart_grid_3d_2] + # Checks where the values are vectors of length equal to the number of vertices. + lrs = LatticeReactionSystem(SIR_system, SIR_srs_1, grid) + u0 = [:S => S_vals[6:8], :I => I_val, :R => R_val] + ps = [:α => α_vals[6:8], :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test hcat(sol.u...) ≈ sol_base[13:end,:] + + # Checks where the values are arrays of size equal to the grid. + u0 = [:S => reshape(S_vals[6:8], grid_size(lrs)), :I => I_val, :R => R_val] + ps = [:α => reshape(α_vals[6:8], grid_size(lrs)), :β => β_val, :dS => dS_val] + oprob = ODEProblem(lrs, u0, (0.0, 100.0), ps) + sol = solve(oprob, Tsit5(); saveat = 1.0, abstol = 1e-9, reltol = 1e-9) + @test hcat(sol.u...) ≈ sol_base[13:end,:] + end +end \ No newline at end of file diff --git a/test/spatial_modelling/lattice_solution_interfacing.jl b/test/spatial_modelling/lattice_solution_interfacing.jl new file mode 100644 index 0000000000..238700a51f --- /dev/null +++ b/test/spatial_modelling/lattice_solution_interfacing.jl @@ -0,0 +1,196 @@ +### Preparations ### + +# Fetch packages. +using Catalyst, Graphs, JumpProcesses, OrdinaryDiffEq, SparseArrays, Test + +### `get_lrs_vals` Tests ### + +# Basic test. For simulations without change in system, check that the solution corresponds to known +# initial condition throughout the solution. +# Checks using both `t` sampling` and normal time step sampling. +# Checks for both ODE and jump simulations. +# Checks for all lattice types. +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (k1,k2), X1 <--> X2 + end + tr = @transport_reaction D X1 + lrs1 = LatticeReactionSystem(rs, [tr], CartesianGrid((2,))) + lrs2 = LatticeReactionSystem(rs, [tr], CartesianGrid((2,3))) + lrs3 = LatticeReactionSystem(rs, [tr], CartesianGrid((2,3,2))) + lrs4 = LatticeReactionSystem(rs, [tr], [true, true, false, true]) + lrs5 = LatticeReactionSystem(rs, [tr], [true false; true true]) + lrs6 = LatticeReactionSystem(rs, [tr], cycle_graph(4)) + + # Create problem inputs. + u0_1 = Dict([:X1 => 0, :X2 => [1, 2]]) + u0_2 = Dict([:X1 => 0, :X2 => [1 2 3; 4 5 6]]) + u0_3 = Dict([:X1 => 0, :X2 => fill(1, 2, 3, 2)]) + u0_4 = Dict([:X1 => 0, :X2 => sparse([1, 2, 0, 3])]) + u0_5 = Dict([:X1 => 0, :X2 => sparse([1 0; 2 3])]) + u0_6 = Dict([:X1 => 0, :X2 => [1, 2, 3, 4]]) + tspan = (0.0, 1.0) + ps = [:k1 => 0.0, :k2 => 0.0, :D => 0.0] + + # Loops through all lattice cases and check that they are correct. + for (u0,lrs) in zip([u0_1, u0_2, u0_3, u0_4, u0_5, u0_6], [lrs1, lrs2, lrs3, lrs4, lrs5, lrs6]) + # Simulates ODE version and checks `get_lrs_vals` on its solution. + oprob = ODEProblem(lrs, u0, tspan, ps) + osol = solve(oprob, Tsit5(), saveat = 0.5) + @test get_lrs_vals(osol, :X1, lrs) == get_lrs_vals(osol, :X1, lrs; t = 0.0:0.5:1.0) + @test all(all(val == Float64(u0[:X1]) for val in vals) for vals in get_lrs_vals(osol, :X1, lrs)) + @test get_lrs_vals(osol, :X2, lrs) == get_lrs_vals(osol, :X2, lrs; t = 0.0:0.5:1.0) == fill(u0[:X2], 3) + + # Simulates jump version and checks `get_lrs_vals` on its solution. + dprob = DiscreteProblem(lrs, u0, tspan, ps) + jprob = JumpProblem(lrs, dprob, NSM()) + jsol = solve(jprob, SSAStepper(), saveat = 0.5) + @test get_lrs_vals(jsol, :X1, lrs) == get_lrs_vals(jsol, :X1, lrs; t = 0.0:0.5:1.0) + @test all(all(val == Float64(u0[:X1]) for val in vals) for vals in get_lrs_vals(jsol, :X1, lrs)) + @test get_lrs_vals(jsol, :X2, lrs) == get_lrs_vals(jsol, :X2, lrs; t = 0.0:0.5:1.0) == fill(u0[:X2], 3) + end +end + +# Checks on simulations where the system changes in time. +# Checks that a solution has correct initial condition and end point (steady state). +# Checks that solution is monotonously increasing/decreasing (it should be for this problem). +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], CartesianGrid((2,))) + + # Prepares a corresponding ODEProblem. + u0 = [:X => [1.0, 3.0]] + tspan = (0.0, 50.0) + ps = [:p => 2.0, :d => 1.0, :D => 0.01] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Simulates the ODE. Checks that the start/end points are correct. + # Check that the first vertex is monotonously increasing in values, and that the second one is + # monotonously decreasing. The non evenly spaced `saveat` is so that non-monotonicity is + # not produced due to numeric errors. + saveat = [0.0, 1.0, 5.0, 10.0, 50.0] + sol = solve(oprob, Vern7(); abstol = 1e-8, reltol = 1e-8) + vals = get_lrs_vals(sol, :X, lrs) + @test vals[1] == [1.0, 3.0] + @test vals[end] ≈ [2.0, 2.0] + for i = 1:(length(saveat) - 1) + @test vals[i][1] < vals[i + 1][1] + @test vals[i][2] > vals[i + 1][2] + end +end + +# Checks interpolation when sampling at time point. Check that values at `t` is in between the +# sample points. Does so by checking that in simulation which is monotonously decreasing/increasing. +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], CartesianGrid((2,))) + + # Solved a corresponding ODEProblem. + u0 = [:X => [1.0, 3.0]] + tspan = (0.0, 1.0) + ps = [:p => 2.0, :d => 1.0, :D => 0.0] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Solves and check the interpolation of t. + sol = solve(oprob, Tsit5(); saveat = 1.0) + t5_vals = get_lrs_vals(sol, :X, lrs; t = [0.5])[1] + @test sol.u[1][1] < t5_vals[1] < sol.u[2][1] + @test sol.u[1][2] > t5_vals[2] > sol.u[2][2] +end + +### Error Tests ### + +# Checks that attempting to sample `t` outside tspan range yields an error. +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], CartesianGrid((2,))) + + # Solved a corresponding ODEProblem. + u0 = [:X => 1.0] + tspan = (1.0, 2.0) + ps = [:p => 2.0, :d => 1.0, :D => 1.0] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Solves and check the interpolation of t. + sol = solve(oprob, Tsit5(); saveat = 1.0) + @test_throws Exception get_lrs_vals(sol, :X, lrs; t = [0.0]) + @test_throws Exception get_lrs_vals(sol, :X, lrs; t = [3.0]) +end + +# Checks that attempting to sample `t` outside tspan range yields an error. +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], CartesianGrid((2,))) + + # Solved a corresponding ODEProblem. + u0 = [:X => 1.0] + tspan = (1.0, 2.0) + ps = [:p => 2.0, :d => 1.0, :D => 1.0] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Solves and check the interpolation of t. + sol = solve(oprob, Tsit5(); saveat = 1.0) + @test_throws Exception get_lrs_vals(sol, :X, lrs; t = [0.0]) + @test_throws Exception get_lrs_vals(sol, :X, lrs; t = [3.0]) +end + +# Checks that applying `get_lrs_vals` to a 3d masked lattice yields an error. +let + # Prepare `LatticeReactionSystem`s. + rs = @reaction_network begin + (p,d), 0 <--> X + end + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], rand([false, true], 2, 3, 4)) + + # Solved a corresponding ODEProblem. + u0 = [:X => 1.0] + tspan = (1.0, 2.0) + ps = [:p => 2.0, :d => 1.0, :D => 1.0] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Solves and check the interpolation of t. + sol = solve(oprob, Tsit5(); saveat = 1.0) + @test_throws Exception get_lrs_vals(sol, :X, lrs) +end + +### Other Tests ### + +# Checks that `get_lrs_vals` works for all types of symbols. +let + t = default_t() + @species X(t) + @parameters d + @named rs = ReactionSystem([Reaction(d, [X], [])], t) + rs = complete(rs) + tr = @transport_reaction D X + lrs = LatticeReactionSystem(rs, [tr], CartesianGrid(2,)) + + # Solved a corresponding ODEProblem. + u0 = [:X => 1.0] + tspan = (0.0, 1.0) + ps = [:d => 1.0, :D => 0.1] + oprob = ODEProblem(lrs, u0, tspan, ps) + + # Solves and check the interpolation of t. + sol = solve(oprob, Tsit5(); saveat = 1.0) + @test get_lrs_vals(sol, X, lrs) == get_lrs_vals(sol, rs.X, lrs) == get_lrs_vals(sol, :X, lrs) + @test get_lrs_vals(sol, X, lrs; t = 0.0:0.5:1.0) == get_lrs_vals(sol, rs.X, lrs; t = 0.0:0.5:1.0) == get_lrs_vals(sol, :X, lrs; t = 0.0:0.5:1.0) +end \ No newline at end of file diff --git a/test/spatial_modelling/spatial_reactions.jl b/test/spatial_modelling/spatial_reactions.jl new file mode 100644 index 0000000000..e764c619a3 --- /dev/null +++ b/test/spatial_modelling/spatial_reactions.jl @@ -0,0 +1,130 @@ +### Preparations ### + +# Fetch packages. +using Catalyst, Test + + +### TransportReaction Creation Tests ### + +# Tests TransportReaction with non-trivial rate. +let + rs = @reaction_network begin + @parameters dV dE [edgeparameter=true] + (p,1), 0 <--> X + end + @unpack dV, dE, X = rs + + tr = TransportReaction(dV*dE, X) + @test isequal(tr.rate, dV*dE) +end + +# Tests transport_reactions function for creating TransportReactions. +let + rs = @reaction_network begin + @parameters d + (p,1), 0 <--> X + end + @unpack d, X = rs + trs = TransportReactions([(d, X), (d, X)]) + @test isequal(trs[1], trs[2]) +end + +# Test reactions with constants in rate. +let + @variables t + @species X(t) Y(t) + + tr_1 = TransportReaction(1.5, X) + tr_1_macro = @transport_reaction 1.5 X + @test isequal(tr_1.rate, tr_1_macro.rate) + @test isequal(tr_1.species, tr_1_macro.species) + + tr_2 = TransportReaction(π, Y) + tr_2_macro = @transport_reaction π Y + @test isequal(tr_2.rate, tr_2_macro.rate) + @test isequal(tr_2.species, tr_2_macro.species) +end + +### Spatial Reactions Getters Correctness ### + +# Test case 1. +let + tr_1 = @transport_reaction dX X + tr_2 = @transport_reaction dY1*dY2 Y + + # @test ModelingToolkit.getname.(species(tr_1)) == ModelingToolkit.getname.(spatial_species(tr_1)) == [:X] # species(::TransportReaction) currently not supported. + # @test ModelingToolkit.getname.(species(tr_2)) == ModelingToolkit.getname.(spatial_species(tr_2)) == [:Y] + @test ModelingToolkit.getname.(spatial_species(tr_1)) == [:X] + @test ModelingToolkit.getname.(spatial_species(tr_2)) == [:Y] + @test ModelingToolkit.getname.(parameters(tr_1)) == [:dX] + @test ModelingToolkit.getname.(parameters(tr_2)) == [:dY1, :dY2] + + # @test issetequal(species(tr_1), [tr_1.species]) + # @test issetequal(species(tr_2), [tr_2.species]) + @test issetequal(spatial_species(tr_1), [tr_1.species]) + @test issetequal(spatial_species(tr_2), [tr_2.species]) +end + +# Test case 2. +let + rs = @reaction_network begin + @species X(t) Y(t) + @parameters dX dY1 dY2 + end + @unpack X, Y, dX, dY1, dY2 = rs + tr_1 = TransportReaction(dX, X) + tr_2 = TransportReaction(dY1*dY2, Y) + # @test isequal(species(tr_1), [X]) + # @test isequal(species(tr_1), [X]) + @test issetequal(spatial_species(tr_2), [Y]) + @test issetequal(spatial_species(tr_2), [Y]) + @test issetequal(parameters(tr_1), [dX]) + @test issetequal(parameters(tr_2), [dY1, dY2]) +end + +### Error Tests ### + +# Tests that creation of TransportReaction with non-parameters in rate yield errors. +# Tests that errors are throw even when the rate is highly nested. +let + @variables t + @species X(t) Y(t) + @parameters D1 D2 D3 + @test_throws ErrorException TransportReaction(D1 + D2*(D3 + Y), X) + @test_throws ErrorException TransportReaction(Y, X) +end + +### Other Tests ### + +# Test Interpolation +# Does not currently work. The 3 tr_macro_ lines generate errors. +let + rs = @reaction_network begin + @species X(t) Y(t) Z(t) + @parameters dX dY1 dY2 dZ + end + @unpack X, Y, Z, dX, dY1, dY2, dZ = rs + rate1 = dX + rate2 = dY1*dY2 + species3 = Z + tr_1 = TransportReaction(dX, X) + tr_2 = TransportReaction(dY1*dY2, Y) + tr_3 = TransportReaction(dZ, Z) + tr_macro_1 = @transport_reaction $dX X + tr_macro_2 = @transport_reaction $(rate2) Y + @test_broken false + # tr_macro_3 = @transport_reaction dZ $species3 # Currently does not work, something with meta programming. + + @test isequal(tr_1, tr_macro_1) + @test isequal(tr_2, tr_macro_2) + # @test isequal(tr_3, tr_macro_3) +end + +# Checks that the `hash` functions work for `TransportReaction`s. +let + tr1 = @transport_reaction D1 X + tr2 = @transport_reaction D1 X + tr3 = @transport_reaction D2 X + hash(tr1, 0x0000000000000001) == hash(tr2, 0x0000000000000001) + hash(tr2, 0x0000000000000001) != hash(tr3, 0x0000000000000001) +end \ No newline at end of file diff --git a/test/spatial_test_networks.jl b/test/spatial_test_networks.jl index f2f9472e87..e9322290c1 100644 --- a/test/spatial_test_networks.jl +++ b/test/spatial_test_networks.jl @@ -1,5 +1,7 @@ ### Fetch packages ### using Catalyst, Graphs +using Catalyst: reactionsystem, spatial_reactions, lattice, num_verts, num_edges, num_species, + spatial_species, vertex_parameters, edge_parameters, edge_iterator # Sets rnd number. using StableRNGs @@ -8,14 +10,34 @@ rng = StableRNG(12345) ### Helper Functions ### # Generates randomised initial condition or parameter values. -rand_v_vals(grid) = rand(rng, nv(grid)) rand_v_vals(grid, x::Number) = rand_v_vals(grid) * x -rand_e_vals(grid) = rand(rng, ne(grid)) +rand_v_vals(lrs::LatticeReactionSystem) = rand_v_vals(lattice(lrs)) +function rand_v_vals(grid::DiGraph) + return rand(rng, nv(grid)) +end +function rand_v_vals(grid::Catalyst.CartesianGridRej{N,T}) where {N,T} + return rand(rng, grid.dims) +end +function rand_v_vals(grid::Array{Bool, N}) where {N} + return rand(rng, size(grid)) +end + rand_e_vals(grid, x::Number) = rand_e_vals(grid) * x -function make_u0_matrix(value_map, vals, symbols) - (length(symbols) == 0) && (return zeros(0, length(vals))) - d = Dict(value_map) - return [(d[s] isa Vector) ? d[s][v] : d[s] for s in symbols, v in 1:length(vals)] +function rand_e_vals(lrs::LatticeReactionSystem) + e_vals = spzeros(num_verts(lrs), num_verts(lrs)) + for e in edge_iterator(lrs) + e_vals[e[1], e[2]] = rand(rng) + end + return e_vals +end + +# Generates edge values, where each edge have the same value. +function uniform_e_vals(lrs::LatticeReactionSystem, val) + e_vals = spzeros(num_verts(lrs), num_verts(lrs)) + for e in edge_iterator(lrs) + e_vals[e[1], e[2]] = val + end + return e_vals end # Gets a symbol list of spatial parameters. @@ -168,26 +190,63 @@ sigmaB_srs_2 = [sigmaB_tr_σB, sigmaB_tr_w, sigmaB_tr_v] ### Declares Lattices ### -# Grids. -very_small_2d_grid = Graphs.grid([2, 2]) -small_2d_grid = Graphs.grid([5, 5]) -medium_2d_grid = Graphs.grid([20, 20]) -large_2d_grid = Graphs.grid([100, 100]) +# Cartesian grids. +very_small_1d_cartesian_grid = CartesianGrid(2) +very_small_2d_cartesian_grid = CartesianGrid((2,2)) +very_small_3d_cartesian_grid = CartesianGrid((2,2,2)) + +small_1d_cartesian_grid = CartesianGrid(5) +small_2d_cartesian_grid = CartesianGrid((5,5)) +small_3d_cartesian_grid = CartesianGrid((5,5,5)) + +large_1d_cartesian_grid = CartesianGrid(100) +large_2d_cartesian_grid = CartesianGrid((100,100)) +large_3d_cartesian_grid = CartesianGrid((100,100,100)) + +# Masked grids. +very_small_1d_masked_grid = fill(true, 2) +very_small_2d_masked_grid = fill(true, 2, 2) +very_small_3d_masked_grid = fill(true, 2, 2, 2) + +small_1d_masked_grid = fill(true, 5) +small_2d_masked_grid = fill(true, 5, 5) +small_3d_masked_grid = fill(true, 5, 5, 5) + +large_1d_masked_grid = fill(true, 5) +large_2d_masked_grid = fill(true, 5, 5) +large_3d_masked_grid = fill(true, 5, 5, 5) + +random_1d_masked_grid = rand(rng, [true, true, true, false], 10) +random_2d_masked_grid = rand(rng, [true, true, true, false], 10, 10) +random_3d_masked_grid = rand(rng, [true, true, true, false], 10, 10, 10) + +# Graph - grids. +very_small_1d_graph_grid = Graphs.grid([2]) +very_small_2d_graph_grid = Graphs.grid([2, 2]) +very_small_3d_graph_grid = Graphs.grid([2, 2, 2]) + +small_1d_graph_grid = path_graph(5) +small_2d_graph_grid = Graphs.grid([5,5]) +small_3d_graph_grid = Graphs.grid([5,5,5]) + +medium_1d_graph_grid = path_graph(20) +medium_2d_graph_grid = Graphs.grid([20,20]) +medium_3d_graph_grid = Graphs.grid([20,20,20]) -small_3d_grid = Graphs.grid([5, 5, 5]) -medium_3d_grid = Graphs.grid([20, 20, 20]) -large_3d_grid = Graphs.grid([100, 100, 100]) +large_1d_graph_grid = path_graph(100) +large_2d_graph_grid = Graphs.grid([100,100]) +large_3d_graph_grid = Graphs.grid([100,100,100]) -# Paths. +# Graph - paths. short_path = path_graph(100) long_path = path_graph(1000) -# Unconnected graphs. +# Graph - unconnected graphs. unconnected_graph = SimpleGraph(10) -# Undirected cycle. +# Graph - undirected cycle. undirected_cycle = cycle_graph(49) -# Directed cycle. +# Graph - directed cycle. small_directed_cycle = cycle_graph(100) large_directed_cycle = cycle_graph(1000) \ No newline at end of file diff --git a/test/test_networks.jl b/test/test_networks.jl index 45b2ea5e59..d38c56daec 100644 --- a/test/test_networks.jl +++ b/test/test_networks.jl @@ -3,8 +3,8 @@ # Declares the vectors which contains the various test sets. reaction_networks_standard = Vector{ReactionSystem}(undef, 10) reaction_networks_hill = Vector{ReactionSystem}(undef, 10) -reaction_networks_constraint = Vector{ReactionSystem}(undef, 10) -reaction_network_constraints = Vector{Matrix{Int}}(undef, 10) +reaction_networks_conserved = Vector{ReactionSystem}(undef, 10) +reaction_network_conslaws = Vector{Matrix{Int}}(undef, 10) reaction_networks_real = Vector{ReactionSystem}(undef, 4) reaction_networks_weird = Vector{ReactionSystem}(undef, 10) @@ -160,69 +160,69 @@ end ### Reaction networks were some linear combination concentrations remain fixed (steady state values depends on initial conditions). ### -reaction_networks_constraint[1] = @reaction_network rnc1 begin +reaction_networks_conserved[1] = @reaction_network rnc1 begin (k1, k2), X1 ↔ X2 (k3, k4), X2 ↔ X3 (k5, k6), X3 ↔ X1 end -reaction_network_constraints[1] = [1 1 1] +reaction_network_conslaws[1] = [1 1 1] -reaction_networks_constraint[2] = @reaction_network rnc2 begin +reaction_networks_conserved[2] = @reaction_network rnc2 begin (k1, k2), X1 ↔ 2X1 (k3, k4), X1 + X2 ↔ X3 (k5, k6), X3 ↔ X2 end -reaction_network_constraints[2] = [0 1 1] +reaction_network_conslaws[2] = [0 1 1] -reaction_networks_constraint[3] = @reaction_network rnc3 begin +reaction_networks_conserved[3] = @reaction_network rnc3 begin (k1, k2 * X5), X1 ↔ X2 (k3 * X5, k4), X3 ↔ X4 (p + k5 * X2 * X3, d), ∅ ↔ X5 end -reaction_network_constraints[3] = [0 0 1 1 0; 1 1 0 0 0] +reaction_network_conslaws[3] = [0 0 1 1 0; 1 1 0 0 0] -reaction_networks_constraint[4] = @reaction_network rnc4 begin +reaction_networks_conserved[4] = @reaction_network rnc4 begin (k1, k2), X1 + X2 ↔ X3 (mm(X3, v, K), d), ∅ ↔ X4 end -reaction_network_constraints[4] = [0 1 1 0; -1 1 0 0] +reaction_network_conslaws[4] = [0 1 1 0; -1 1 0 0] -reaction_networks_constraint[5] = @reaction_network rnc5 begin +reaction_networks_conserved[5] = @reaction_network rnc5 begin (k1, k2), X1 ↔ 2X2 (k3, k4), 2X2 ↔ 3X3 (k5, k6), 3X3 ↔ 4X4 end -reaction_network_constraints[5] = [12 6 4 3] +reaction_network_conslaws[5] = [12 6 4 3] -reaction_networks_constraint[6] = @reaction_network rnc6 begin +reaction_networks_conserved[6] = @reaction_network rnc6 begin mmr(X1, v1, K1), X1 → X2 mmr(X2, v2, K2), X2 → X3 mmr(X3, v3, K3), X3 → X1 end -reaction_network_constraints[6] = [1 1 1] +reaction_network_conslaws[6] = [1 1 1] -reaction_networks_constraint[7] = @reaction_network rnc7 begin +reaction_networks_conserved[7] = @reaction_network rnc7 begin (k1, k2), X1 + X2 ↔ X3 (mm(X3, v, K), d), ∅ ↔ X2 (k3, k4), X2 ↔ X4 end -reaction_network_constraints[7] = [1 0 1 0] +reaction_network_conslaws[7] = [1 0 1 0] -reaction_networks_constraint[8] = @reaction_network rnc8 begin +reaction_networks_conserved[8] = @reaction_network rnc8 begin (k1, k2), X1 + X2 ↔ X3 (mm(X3, v1, K1), mm(X4, v2, K2)), X3 ↔ X4 end -reaction_network_constraints[8] = [-1 1 0 0; 0 1 1 1] +reaction_network_conslaws[8] = [-1 1 0 0; 0 1 1 1] -reaction_networks_constraint[9] = @reaction_network rnc9 begin +reaction_networks_conserved[9] = @reaction_network rnc9 begin (k1, k2), X1 + X2 ↔ X3 (k3, k4), X3 + X4 ↔ X5 (k5, k6), X5 + X6 ↔ X7 end -reaction_network_constraints[9] = [1 0 1 0 1 0 1; -1 1 0 0 0 0 0; 0 0 0 1 1 0 1; +reaction_network_conslaws[9] = [1 0 1 0 1 0 1; -1 1 0 0 0 0 0; 0 0 0 1 1 0 1; 0 0 0 0 0 1 1] -reaction_networks_constraint[10] = @reaction_network rnc10 begin +reaction_networks_conserved[10] = @reaction_network rnc10 begin kDeg, (w, w2, w2v, v, w2v2, vP, σB, w2σB) ⟶ ∅ kDeg, vPp ⟶ phos (kBw, kDw), 2w ⟷ w2 @@ -238,7 +238,7 @@ reaction_networks_constraint[10] = @reaction_network rnc10 begin λW * v0 * ((1 + F * σB) / (K + σB)), ∅ ⟶ w λV * v0 * ((1 + F * σB) / (K + σB)), ∅ ⟶ v end; -reaction_network_constraints[10] = [0 0 0 0 0 0 0 0 1 1] +reaction_network_conslaws[10] = [0 0 0 0 0 0 0 0 1 1] ### Reaction networks that are actual models that have been used ### @@ -347,9 +347,9 @@ reaction_networks_weird[10] = @reaction_network rnw10 begin d, 5X1 → 4X1 end -### Gathers all netowkrs in a simgle array ### +### Gathers all networks in a single array ### reaction_networks_all = [reaction_networks_standard..., reaction_networks_hill..., - reaction_networks_constraint..., + reaction_networks_conserved..., reaction_networks_real..., reaction_networks_weird...] diff --git a/test/upstream/mtk_problem_inputs.jl b/test/upstream/mtk_problem_inputs.jl index c1f86ca900..ffe2037519 100644 --- a/test/upstream/mtk_problem_inputs.jl +++ b/test/upstream/mtk_problem_inputs.jl @@ -3,7 +3,8 @@ ### Prepares Tests ### # Fetch packages -using Catalyst, JumpProcesses, NonlinearSolve, OrdinaryDiffEq, SteadyStateDiffEq, StochasticDiffEq, Test +using Catalyst, JumpProcesses, NonlinearSolve, OrdinaryDiffEq, StaticArrays, SteadyStateDiffEq, + StochasticDiffEq, Test # Sets rnd number. using StableRNGs @@ -33,6 +34,14 @@ begin [X => 4, Y => 5, Z => 10], [model.X => 4, model.Y => 5, model.Z => 10], [:X => 4, :Y => 5, :Z => 10], + # Static vectors not providing default values. + SA[X => 4, Y => 5], + SA[model.X => 4, model.Y => 5], + SA[:X => 4, :Y => 5], + # Static vectors providing default values. + SA[X => 4, Y => 5, Z => 10], + SA[model.X => 4, model.Y => 5, model.Z => 10], + SA[:X => 4, :Y => 5, :Z => 10], # Dicts not providing default values. Dict([X => 4, Y => 5]), Dict([model.X => 4, model.Y => 5]), @@ -60,6 +69,14 @@ begin [kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10], [model.kp => 1.0, model.kd => 0.1, model.k1 => 0.25, model.k2 => 0.5, model.Z0 => 10], [:kp => 1.0, :kd => 0.1, :k1 => 0.25, :k2 => 0.5, :Z0 => 10], + # Static vectors not providing default values. + SA[kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10], + SA[model.kp => 1.0, model.kd => 0.1, model.k1 => 0.25, model.Z0 => 10], + SA[:kp => 1.0, :kd => 0.1, :k1 => 0.25, :Z0 => 10], + # Static vectors providing default values. + SA[kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10], + SA[model.kp => 1.0, model.kd => 0.1, model.k1 => 0.25, model.k2 => 0.5, model.Z0 => 10], + SA[:kp => 1.0, :kd => 0.1, :k1 => 0.25, :k2 => 0.5, :Z0 => 10], # Dicts not providing default values. Dict([kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10]), Dict([model.kp => 1.0, model.kd => 0.1, model.k1 => 0.25, model.Z0 => 10]), @@ -158,6 +175,202 @@ let end end +### Vector Species/Parameters Tests ### + +begin + # Declares the model (with vector species/parameters, with/without default values, and observables). + t = default_t() + @species X(t)[1:2] Y(t)[1:2] = [10.0, 20.0] XY(t)[1:2] + @parameters p[1:2] d[1:2] = [0.2, 0.5] + rxs = [ + Reaction(p[1], [], [X[1]]), + Reaction(p[2], [], [X[2]]), + Reaction(d[1], [X[1]], []), + Reaction(d[2], [X[2]], []), + Reaction(p[1], [], [Y[1]]), + Reaction(p[2], [], [Y[2]]), + Reaction(d[1], [Y[1]], []), + Reaction(d[2], [Y[2]], []) + ] + observed = [XY[1] ~ X[1] + Y[1], XY[2] ~ X[2] + Y[2]] + @named model_vec = ReactionSystem(rxs, t; observed) + model_vec = complete(model_vec) + + # Declares various u0 versions (scalarised and vector forms). + u0_alts_vec = [ + # Vectors not providing default values. + [X => [1.0, 2.0]], + [X[1] => 1.0, X[2] => 2.0], + [model_vec.X => [1.0, 2.0]], + [model_vec.X[1] => 1.0, model_vec.X[2] => 2.0], + [:X => [1.0, 2.0]], + # Vectors providing default values. + [X => [1.0, 2.0], Y => [10.0, 20.0]], + [X[1] => 1.0, X[2] => 2.0, Y[1] => 10.0, Y[2] => 20.0], + [model_vec.X => [1.0, 2.0], model_vec.Y => [10.0, 20.0]], + [model_vec.X[1] => 1.0, model_vec.X[2] => 2.0, model_vec.Y[1] => 10.0, model_vec.Y[2] => 20.0], + [:X => [1.0, 2.0], :Y => [10.0, 20.0]], + # Static vectors not providing default values. + SA[X => [1.0, 2.0]], + SA[X[1] => 1.0, X[2] => 2.0], + SA[model_vec.X => [1.0, 2.0]], + SA[model_vec.X[1] => 1.0, model_vec.X[2] => 2.0], + SA[:X => [1.0, 2.0]], + # Static vectors providing default values. + SA[X => [1.0, 2.0], Y => [10.0, 20.0]], + SA[X[1] => 1.0, X[2] => 2.0, Y[1] => 10.0, Y[2] => 20.0], + SA[model_vec.X => [1.0, 2.0], model_vec.Y => [10.0, 20.0]], + SA[model_vec.X[1] => 1.0, model_vec.X[2] => 2.0, model_vec.Y[1] => 10.0, model_vec.Y[2] => 20.0], + SA[:X => [1.0, 2.0], :Y => [10.0, 20.0]], + # Dicts not providing default values. + Dict([X => [1.0, 2.0]]), + Dict([X[1] => 1.0, X[2] => 2.0]), + Dict([model_vec.X => [1.0, 2.0]]), + Dict([model_vec.X[1] => 1.0, model_vec.X[2] => 2.0]), + Dict([:X => [1.0, 2.0]]), + # Dicts providing default values. + Dict([X => [1.0, 2.0], Y => [10.0, 20.0]]), + Dict([X[1] => 1.0, X[2] => 2.0, Y[1] => 10.0, Y[2] => 20.0]), + Dict([model_vec.X => [1.0, 2.0], model_vec.Y => [10.0, 20.0]]), + Dict([model_vec.X[1] => 1.0, model_vec.X[2] => 2.0, model_vec.Y[1] => 10.0, model_vec.Y[2] => 20.0]), + Dict([:X => [1.0, 2.0], :Y => [10.0, 20.0]]), + # Tuples not providing default values. + (X => [1.0, 2.0]), + (X[1] => 1.0, X[2] => 2.0), + (model_vec.X => [1.0, 2.0]), + (model_vec.X[1] => 1.0, model_vec.X[2] => 2.0), + (:X => [1.0, 2.0]), + # Tuples providing default values. + (X => [1.0, 2.0], Y => [10.0, 20.0]), + (X[1] => 1.0, X[2] => 2.0, Y[1] => 10.0, Y[2] => 20.0), + (model_vec.X => [1.0, 2.0], model_vec.Y => [10.0, 20.0]), + (model_vec.X[1] => 1.0, model_vec.X[2] => 2.0, model_vec.Y[1] => 10.0, model_vec.Y[2] => 20.0), + (:X => [1.0, 2.0], :Y => [10.0, 20.0]), + ] + + # Declares various ps versions (vector forms only). + p_alts_vec = [ + # Vectors not providing default values. + [p => [1.0, 2.0]], + [model_vec.p => [1.0, 2.0]], + [:p => [1.0, 2.0]], + # Vectors providing default values. + [p => [4.0, 5.0], d => [0.2, 0.5]], + [model_vec.p => [4.0, 5.0], model_vec.d => [0.2, 0.5]], + [:p => [4.0, 5.0], :d => [0.2, 0.5]], + # Static vectors not providing default values. + SA[p => [1.0, 2.0]], + SA[model_vec.p => [1.0, 2.0]], + SA[:p => [1.0, 2.0]], + # Static vectors providing default values. + SA[p => [4.0, 5.0], d => [0.2, 0.5]], + SA[model_vec.p => [4.0, 5.0], model_vec.d => [0.2, 0.5]], + SA[:p => [4.0, 5.0], :d => [0.2, 0.5]], + # Dicts not providing default values. + Dict([p => [1.0, 2.0]]), + Dict([model_vec.p => [1.0, 2.0]]), + Dict([:p => [1.0, 2.0]]), + # Dicts providing default values. + Dict([p => [4.0, 5.0], d => [0.2, 0.5]]), + Dict([model_vec.p => [4.0, 5.0], model_vec.d => [0.2, 0.5]]), + Dict([:p => [4.0, 5.0], :d => [0.2, 0.5]]), + # Tuples not providing default values. + (p => [1.0, 2.0]), + (model_vec.p => [1.0, 2.0]), + (:p => [1.0, 2.0]), + # Tuples providing default values. + (p => [4.0, 5.0], d => [0.2, 0.5]), + (model_vec.p => [4.0, 5.0], model_vec.d => [0.2, 0.5]), + (:p => [4.0, 5.0], :d => [0.2, 0.5]), + ] + + # Declares a timespan. + tspan = (0.0, 10.0) +end + +# Perform ODE simulations (singular and ensemble). +let + # Creates normal and ensemble problems. + base_oprob = ODEProblem(model_vec, u0_alts_vec[1], tspan, p_alts_vec[1]) + base_sol = solve(base_oprob, Tsit5(); saveat = 1.0) + base_eprob = EnsembleProblem(base_oprob) + base_esol = solve(base_eprob, Tsit5(); trajectories = 2, saveat = 1.0) + + # Simulates problems for all input types, checking that identical solutions are found. + @test_broken false # Cannot remake problem (https://github.com/SciML/ModelingToolkit.jl/issues/2804). + # for u0 in u0_alts_vec, p in p_alts_vec + # oprob = remake(base_oprob; u0, p) + # @test base_sol == solve(oprob, Tsit5(); saveat = 1.0) + # eprob = remake(base_eprob; u0, p) + # @test base_esol == solve(eprob, Tsit5(); trajectories = 2, saveat = 1.0) + # end +end + +# Perform SDE simulations (singular and ensemble). +let + # Creates normal and ensemble problems. + base_sprob = SDEProblem(model_vec, u0_alts_vec[1], tspan, p_alts_vec[1]) + base_sol = solve(base_sprob, ImplicitEM(); seed, saveat = 1.0) + base_eprob = EnsembleProblem(base_sprob) + base_esol = solve(base_eprob, ImplicitEM(); seed, trajectories = 2, saveat = 1.0) + + # Simulates problems for all input types, checking that identical solutions are found. + @test_broken false # Cannot remake problem (https://github.com/SciML/ModelingToolkit.jl/issues/2804). + # for u0 in u0_alts_vec, p in p_alts_vec + # sprob = remake(base_sprob; u0, p) + # @test base_sol == solve(sprob, ImplicitEM(); seed, saveat = 1.0) + # eprob = remake(base_eprob; u0, p) + # @test base_esol == solve(eprob, ImplicitEM(); seed, trajectories = 2, saveat = 1.0) + # end +end + +# Perform jump simulations (singular and ensemble). +let + # Creates normal and ensemble problems. + base_dprob = DiscreteProblem(model_vec, u0_alts_vec[1], tspan, p_alts_vec[1]) + base_jprob = JumpProblem(model_vec, base_dprob, Direct(); rng) + base_sol = solve(base_jprob, SSAStepper(); seed, saveat = 1.0) + base_eprob = EnsembleProblem(base_jprob) + base_esol = solve(base_eprob, SSAStepper(); seed, trajectories = 2, saveat = 1.0) + + # Simulates problems for all input types, checking that identical solutions are found. + @test_broken false # Cannot remake problem (https://github.com/SciML/ModelingToolkit.jl/issues/2804). + # for u0 in u0_alts_vec, p in p_alts_vec + # jprob = remake(base_jprob; u0, p) + # @test base_sol == solve(base_jprob, SSAStepper(); seed, saveat = 1.0) + # eprob = remake(base_eprob; u0, p) + # @test base_esol == solve(eprob, SSAStepper(); seed, trajectories = 2, saveat = 1.0) + # end +end + +# Solves a nonlinear problem (EnsembleProblems are not possible for these). +let + base_nlprob = NonlinearProblem(model_vec, u0_alts_vec[1], p_alts_vec[1]) + base_sol = solve(base_nlprob, NewtonRaphson()) + @test_broken false # Cannot remake problem (https://github.com/SciML/ModelingToolkit.jl/issues/2804). + # for u0 in u0_alts_vec, p in p_alts_vec + # nlprob = remake(base_nlprob; u0, p) + # @test base_sol == solve(nlprob, NewtonRaphson()) + # end +end + +# Perform steady state simulations (singular and ensemble). +let + # Creates normal and ensemble problems. + base_ssprob = SteadyStateProblem(model_vec, u0_alts_vec[1], p_alts_vec[1]) + base_sol = solve(base_ssprob, DynamicSS(Tsit5())) + base_eprob = EnsembleProblem(base_ssprob) + base_esol = solve(base_eprob, DynamicSS(Tsit5()); trajectories = 2) + + # Simulates problems for all input types, checking that identical solutions are found. + @test_broken false # Cannot remake problem (https://github.com/SciML/ModelingToolkit.jl/issues/2804). + # for u0 in u0_alts_vec, p in p_alts_vec + # ssprob = remake(base_ssprob; u0, p) + # @test base_sol == solve(ssprob, DynamicSS(Tsit5())) + # eprob = remake(base_eprob; u0, p) + # @test base_esol == solve(eprob, DynamicSS(Tsit5()); trajectories = 2) + # end +end ### Checks Errors On Faulty Inputs ### @@ -183,6 +396,9 @@ let [X1 => 1], [rn.X1 => 1], [:X1 => 1], + SA[X1 => 1], + SA[rn.X1 => 1], + SA[:X1 => 1], Dict([X1 => 1]), Dict([rn.X1 => 1]), Dict([:X1 => 1]), @@ -192,6 +408,8 @@ let # Contain an additional value. [X1 => 1, X2 => 2, X3 => 3], [:X1 => 1, :X2 => 2, :X3 => 3], + SA[X1 => 1, X2 => 2, X3 => 3], + SA[:X1 => 1, :X2 => 2, :X3 => 3], Dict([X1 => 1, X2 => 2, X3 => 3]), Dict([:X1 => 1, :X2 => 2, :X3 => 3]), (X1 => 1, X2 => 2, X3 => 3), @@ -202,6 +420,9 @@ let [k1 => 1.0], [rn.k1 => 1.0], [:k1 => 1.0], + SA[k1 => 1.0], + SA[rn.k1 => 1.0], + SA[:k1 => 1.0], Dict([k1 => 1.0]), Dict([rn.k1 => 1.0]), Dict([:k1 => 1.0]), @@ -211,6 +432,8 @@ let # Contain an additional value. [k1 => 1.0, k2 => 2.0, k3 => 3.0], [:k1 => 1.0, :k2 => 2.0, :k3 => 3.0], + SA[k1 => 1.0, k2 => 2.0, k3 => 3.0], + SA[:k1 => 1.0, :k2 => 2.0, :k3 => 3.0], Dict([k1 => 1.0, k2 => 2.0, k3 => 3.0]), Dict([:k1 => 1.0, :k2 => 2.0, :k3 => 3.0]), (k1 => 1.0, k2 => 2.0, k3 => 3.0), @@ -218,10 +441,10 @@ let ] # Loops through all potential parameter sets, checking their inputs yield errors. - for ps in [ps_valid; ps_invalid], u0 in [u0_valid; u0s_invalid] + for ps in [[ps_valid]; ps_invalid], u0 in [[u0_valid]; u0s_invalid] # Handles problems with/without tspan separately. Special check ensuring that valid inputs passes. for XProblem in [ODEProblem, SDEProblem, DiscreteProblem] - if (ps == ps_valid) && (u0 == u0_valid) + if isequal(ps, ps_valid) && isequal(u0, u0_valid) XProblem(rn, u0, (0.0, 1.0), ps); @test true; else # Several of these cases do not throw errors (https://github.com/SciML/ModelingToolkit.jl/issues/2624). @@ -231,7 +454,7 @@ let end end for XProblem in [NonlinearProblem, SteadyStateProblem] - if (ps == ps_valid) && (u0 == u0_valid) + if isequal(ps, ps_valid) && isequal(u0, u0_valid) XProblem(rn, u0, ps); @test true; else @test_broken false @@ -240,4 +463,4 @@ let end end end -end \ No newline at end of file +end diff --git a/test/upstream/mtk_structure_indexing.jl b/test/upstream/mtk_structure_indexing.jl index 78b70eb279..f8768b8ed0 100644 --- a/test/upstream/mtk_structure_indexing.jl +++ b/test/upstream/mtk_structure_indexing.jl @@ -37,20 +37,20 @@ begin problems = [oprob, sprob, dprob, jprob, nprob, ssprob] # Creates an `EnsembleProblem` for each problem. - eoprob = EnsembleProblem(oprob) - esprob = EnsembleProblem(sprob) - edprob = EnsembleProblem(dprob) - ejprob = EnsembleProblem(jprob) - enprob = EnsembleProblem(nprob) - essprob = EnsembleProblem(ssprob) + eoprob = EnsembleProblem(deepcopy(oprob)) + esprob = EnsembleProblem(deepcopy(sprob)) + edprob = EnsembleProblem(deepcopy(dprob)) + ejprob = EnsembleProblem(deepcopy(jprob)) + enprob = EnsembleProblem(deepcopy(nprob)) + essprob = EnsembleProblem(deepcopy(ssprob)) eproblems = [eoprob, esprob, edprob, ejprob, enprob, essprob] # Creates integrators. - oint = init(oprob, Tsit5(); save_everystep=false) - sint = init(sprob, ImplicitEM(); save_everystep=false) + oint = init(oprob, Tsit5(); save_everystep = false) + sint = init(sprob, ImplicitEM(); save_everystep = false) jint = init(jprob, SSAStepper()) - nint = init(nprob, NewtonRaphson(); save_everystep=false) - @test_broken ssint = init(ssprob, DynamicSS(Tsit5()); save_everystep=false) # https://github.com/SciML/SciMLBase.jl/issues/660 + nint = init(nprob, NewtonRaphson(); save_everystep = false) + @test_broken ssint = init(ssprob, DynamicSS(Tsit5()); save_everystep = false) # https://github.com/SciML/SciMLBase.jl/issues/660 integrators = [oint, sint, jint, nint] # Creates solutions. @@ -64,14 +64,13 @@ end # Tests problem indexing and updating. let - @test_broken false # A few cases fails for SteadyStateProblem: https://github.com/SciML/SciMLBase.jl/issues/660 - @test_broken false # Most cases broken for Ensemble problems: https://github.com/SciML/SciMLBase.jl/issues/661 - for prob in deepcopy(problems[1:end-1]) + @test_broken false # A few cases fails for JumpProblem: https://github.com/SciML/ModelingToolkit.jl/issues/2838 + for prob in deepcopy([oprob, sprob, dprob, nprob, ssprob, eoprob, esprob, edprob, enprob, essprob]) # Get u values (including observables). @test prob[X] == prob[model.X] == prob[:X] == 4 @test prob[XY] == prob[model.XY] == prob[:XY] == 9 @test prob[[XY,Y]] == prob[[model.XY,model.Y]] == prob[[:XY,:Y]] == [9, 5] - @test_broken prob[(XY,Y)] == prob[(model.XY,model.Y)] == prob[(:XY,:Y)] == (9, 5) + @test prob[(XY,Y)] == prob[(model.XY,model.Y)] == prob[(:XY,:Y)] == (9, 5) @test getu(prob, X)(prob) == getu(prob, model.X)(prob) == getu(prob, :X)(prob) == 4 @test getu(prob, XY)(prob) == getu(prob, model.XY)(prob) == getu(prob, :XY)(prob) == 9 @test getu(prob, [XY,Y])(prob) == getu(prob, [model.XY,model.Y])(prob) == getu(prob, [:XY,:Y])(prob) == [9, 5] @@ -117,8 +116,8 @@ end # Test remake function. let - @test_broken false # Currently cannot be run for Ensemble problems: https://github.com/SciML/SciMLBase.jl/issues/661 (as indexing cannot be used to check values). - for prob in deepcopy(problems) + @test_broken false # Cannot check result for JumpProblem: https://github.com/SciML/ModelingToolkit.jl/issues/2838 + for prob in deepcopy([oprob, sprob, dprob, nprob, ssprob, eoprob, esprob, edprob, enprob, essprob]) # Remake for all u0s. rp = remake(prob; u0 = [X => 1, Y => 2]) @test rp[[X, Y]] == [1, 2] @@ -155,10 +154,8 @@ end # Test integrator indexing. let - @test_broken false # NOTE: Multiple problems for `nint` (https://github.com/SciML/SciMLBase.jl/issues/662). - @test_broken false # NOTE: Multiple problems for `jint` (https://github.com/SciML/SciMLBase.jl/issues/654). @test_broken false # NOTE: Cannot even create a `ssint` (https://github.com/SciML/SciMLBase.jl/issues/660). - for int in deepcopy([oint, sint]) + for int in deepcopy([oint, sint, jint, nint]) # Get u values. @test int[X] == int[model.X] == int[:X] == 4 @test int[XY] == int[model.XY] == int[:XY] == 9 @@ -208,6 +205,7 @@ let end # Test solve's save_idxs argument. +# Currently, `save_idxs` is broken with symbolic stuff (https://github.com/SciML/ModelingToolkit.jl/issues/1761). let for (prob, solver) in zip(deepcopy([oprob, sprob, jprob]), [Tsit5(), ImplicitEM(), SSAStepper()]) # Save single variable @@ -241,10 +239,10 @@ let @test getu(sol, (XY,Y))(sol)[1] == getu(sol, (model.XY,model.Y))(sol)[1] == getu(sol, (:XY,:Y))(sol)[1] == (9, 5) # Get u values via idxs and functional call. - @test osol(0.0; idxs=X) == osol(0.0; idxs=X) == osol(0.0; idxs=X) == 4 - @test osol(0.0; idxs=XY) == osol(0.0; idxs=XY) == osol(0.0; idxs=XY) == 9 - @test_broken osol(0.0; idxs=[model.Y,model.XY]) == osol(0.0; idxs=[model.Y,model.XY]) == osol(0.0; idxs=[model.XY,model.X]) == [9, 5] - @test_broken osol(0.0; idxs=(:Y,:XY)) == osol(0.0; idxs=(:Y,:XY)) == osol(0.0; idxs=(:XY,:Y)) == (9, 5) + @test sol(0.0; idxs=X) == sol(0.0; idxs=model.X) == sol(0.0; idxs=:X) == 4 + @test sol(0.0; idxs=XY) == sol(0.0; idxs=model.XY) == sol(0.0; idxs=:XY) == 9 + @test sol(0.0; idxs = [XY,Y]) == sol(0.0; idxs = [model.XY,model.Y]) == sol(0.0; idxs = [:XY,:Y]) == [9, 5] + @test_broken sol(0.0; idxs = (XY,Y)) == sol(0.0; idxs = (model.XY,model.Y)) == sol(0.0; idxs = (:XY,:Y)) == (9, 5) # https://github.com/SciML/SciMLBase.jl/issues/711 # Get p values. @test sol.ps[kp] == sol.ps[model.kp] == sol.ps[:kp] == 1.0 @@ -262,11 +260,11 @@ let @test sol[X] == sol[model.X] == sol[:X] @test sol[XY] == sol[model.XY][1] == sol[:XY] @test sol[[XY,Y]] == sol[[model.XY,model.Y]] == sol[[:XY,:Y]] - @test_broken sol[(XY,Y)] == sol[(model.XY,model.Y)] == sol[(:XY,:Y)] + @test sol[(XY,Y)] == sol[(model.XY,model.Y)] == sol[(:XY,:Y)] @test getu(sol, X)(sol) == getu(sol, model.X)(sol)[1] == getu(sol, :X)(sol) @test getu(sol, XY)(sol) == getu(sol, model.XY)(sol)[1] == getu(sol, :XY)(sol) @test getu(sol, [XY,Y])(sol) == getu(sol, [model.XY,model.Y])(sol) == getu(sol, [:XY,:Y])(sol) - @test_broken getu(sol, (XY,Y))(sol) == getu(sol, (model.XY,model.Y))(sol) == getu(sol, (:XY,:Y))(sol)[1] + @test_broken getu(sol, (XY,Y))(sol) == getu(sol, (model.XY,model.Y))(sol) == getu(sol, (:XY,:Y))(sol)[1] # https://github.com/SciML/SciMLBase.jl/issues/710 # Get p values. @test sol.ps[kp] == sol.ps[model.kp] == sol.ps[:kp] @@ -281,8 +279,7 @@ end # Tests plotting. let - @test_broken false # Currently broken for `ssol` (https://github.com/SciML/SciMLBase.jl/issues/580) - for sol in deepcopy([osol, jsol]) + for sol in deepcopy([osol, jsol, ssol]) # Single variable. @test length(plot(sol; idxs = X).series_list) == 1 @test length(plot(sol; idxs = XY).series_list) == 1 @@ -386,4 +383,4 @@ let @test jint.cb.condition.ma_jumps.scaled_rates[1] == 16.0 reset_aggregated_jumps!(jint) @test jint.cb.condition.ma_jumps.scaled_rates[1] == 6.0 -end \ No newline at end of file +end diff --git a/test/visualisation/latexify.jl b/test/visualisation/latexify.jl index e5f162e176..f83890a9dd 100644 --- a/test/visualisation/latexify.jl +++ b/test/visualisation/latexify.jl @@ -29,6 +29,8 @@ include("../test_networks.jl") ### Just be sure to remove all such macros before you commit a change since it ### will cause issues with Travis. +# Generally, for all latexify tests, the lines after `@test latexify(rn) == replace(` must +# start without any tabs, hence the somewhat weird formatting. ### Basic Tests ### @@ -48,68 +50,68 @@ let (d1,d2,d3,d4,d5,d6), (X1,X2,X3,X4,X5,X6) ⟶ ∅ end - # Latexify.@generate_test latexify(rn) - @test_broken latexify(rn; expand_functions = false) == replace( - raw"\begin{align*} - \varnothing &\xrightarrow{\frac{X4^{n1} v1^{2} K1^{n1}}{\left( K1^{n1} + X4^{n1} \right) \left( K1^{n1} + X2^{n1} \right)}} \mathrm{X1} \\ - \varnothing &\xrightarrow{\mathrm{hill}\left( X5, v2, K2, n2 \right)} \mathrm{X2} \\ - \varnothing &\xrightarrow{\mathrm{hill}\left( X3, v3, K3, n3 \right)} \mathrm{X3} \\ - \varnothing &\xrightarrow{\mathrm{hillr}\left( X1, v4, K4, n4 \right)} \mathrm{X4} \\ - \varnothing &\xrightarrow{\mathrm{hill}\left( X2, v5, K5, n5 \right)} \mathrm{X5} \\ - \varnothing &\xrightarrow{\mathrm{hillar}\left( X1, X6, v6, K6, n6 \right)} \mathrm{X6} \\ - \mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ - \mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ - 3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ - \mathrm{X1} &\xrightarrow{d1} \varnothing \\ - \mathrm{X2} &\xrightarrow{d2} \varnothing \\ - \mathrm{X3} &\xrightarrow{d3} \varnothing \\ - \mathrm{X4} &\xrightarrow{d4} \varnothing \\ - \mathrm{X5} &\xrightarrow{d5} \varnothing \\ - \mathrm{X6} &\xrightarrow{d6} \varnothing - \end{align*} - ", "\r\n"=>"\n") - - #Latexify.@generate_test latexify(rn; expand_functions=false) - @test_broken latexify(rn; expand_functions = false) == replace( - raw"\begin{align*} - \varnothing &\xrightarrow{\frac{X4^{n1} v1^{2} K1^{n1}}{\left( K1^{n1} + X4^{n1} \right) \left( K1^{n1} + X2^{n1} \right)}} \mathrm{X1} \\ - \varnothing &\xrightarrow{\mathrm{mm}\left( X5, v2, K2 \right)} \mathrm{X2} \\ - \varnothing &\xrightarrow{\mathrm{mmr}\left( X3, v3, K3 \right)} \mathrm{X3} \\ - \varnothing &\xrightarrow{\mathrm{hillr}\left( X1, v4, K4, n4 \right)} \mathrm{X4} \\ - \varnothing &\xrightarrow{\mathrm{hill}\left( X2, v5, K5, n5 \right)} \mathrm{X5} \\ - \varnothing &\xrightarrow{\mathrm{hillar}\left( X1, X6, v6, K6, n6 \right)} \mathrm{X6} \\ - \mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ - \mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ - 3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ - \mathrm{X1} &\xrightarrow{d1} \varnothing \\ - \mathrm{X2} &\xrightarrow{d2} \varnothing \\ - \mathrm{X3} &\xrightarrow{d3} \varnothing \\ - \mathrm{X4} &\xrightarrow{d4} \varnothing \\ - \mathrm{X5} &\xrightarrow{d5} \varnothing \\ - \mathrm{X6} &\xrightarrow{d6} \varnothing - \end{align*} - ", "\r\n"=>"\n") - - # Latexify.@generate_test latexify(rn, mathjax=false) - @test_broken latexify(rn, mathjax = false) == replace( - raw"\begin{align*} - \varnothing &\xrightarrow{\frac{X4^{n1} v1^{2} K1^{n1}}{\left( K1^{n1} + X4^{n1} \right) \left( K1^{n1} + X2^{n1} \right)}} \mathrm{X1} \\ - \varnothing &\xrightarrow{\frac{X5 v2}{K2 + X5}} \mathrm{X2} \\ - \varnothing &\xrightarrow{\frac{K3 v3}{K3 + X3}} \mathrm{X3} \\ - \varnothing &\xrightarrow{\frac{v4 K4^{n4}}{K4^{n4} + X1^{n4}}} \mathrm{X4} \\ - \varnothing &\xrightarrow{\frac{v5 X2^{n5}}{X2^{n5} + K5^{n5}}} \mathrm{X5} \\ - \varnothing &\xrightarrow{\frac{v6 X1^{n6}}{X6^{n6} + K6^{n6} + X1^{n6}}} \mathrm{X6} \\ - \mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ - \mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ - 3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ - \mathrm{X1} &\xrightarrow{d1} \varnothing \\ - \mathrm{X2} &\xrightarrow{d2} \varnothing \\ - \mathrm{X3} &\xrightarrow{d3} \varnothing \\ - \mathrm{X4} &\xrightarrow{d4} \varnothing \\ - \mathrm{X5} &\xrightarrow{d5} \varnothing \\ - \mathrm{X6} &\xrightarrow{d6} \varnothing - \end{align*} - ", "\r\n"=>"\n") + # Latexify.@generate_test latexify(rn; expand_functions = false) + @test latexify(rn; expand_functions = false) == replace( +raw"\begin{align*} +\varnothing &\xrightarrow{\mathrm{hillr}\left( X2, v1, K1, n1 \right) \mathrm{hill}\left( X4, v1, K1, n1 \right)} \mathrm{X1} \\ +\varnothing &\xrightarrow{\mathrm{hill}\left( X5, v2, K2, n2 \right)} \mathrm{X2} \\ +\varnothing &\xrightarrow{\mathrm{hill}\left( X3, v3, K3, n3 \right)} \mathrm{X3} \\ +\varnothing &\xrightarrow{\mathrm{hillr}\left( X1, v4, K4, n4 \right)} \mathrm{X4} \\ +\varnothing &\xrightarrow{\mathrm{hill}\left( X2, v5, K5, n5 \right)} \mathrm{X5} \\ +\varnothing &\xrightarrow{\mathrm{hillar}\left( X1, X6, v6, K6, n6 \right)} \mathrm{X6} \\ +\mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ +\mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ +3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ +\mathrm{X1} &\xrightarrow{d1} \varnothing \\ +\mathrm{X2} &\xrightarrow{d2} \varnothing \\ +\mathrm{X3} &\xrightarrow{d3} \varnothing \\ +\mathrm{X4} &\xrightarrow{d4} \varnothing \\ +\mathrm{X5} &\xrightarrow{d5} \varnothing \\ +\mathrm{X6} &\xrightarrow{d6} \varnothing + \end{align*} +", "\r\n"=>"\n") + + # Latexify.@generate_test latexify(rn; expand_functions = true) + @test latexify(rn; expand_functions = true) == replace( +raw"\begin{align*} +\varnothing &\xrightarrow{\frac{X4^{n1} v1^{2} K1^{n1}}{\left( K1^{n1} + X4^{n1} \right) \left( K1^{n1} + X2^{n1} \right)}} \mathrm{X1} \\ +\varnothing &\xrightarrow{\frac{v2 X5^{n2}}{X5^{n2} + K2^{n2}}} \mathrm{X2} \\ +\varnothing &\xrightarrow{\frac{v3 X3^{n3}}{X3^{n3} + K3^{n3}}} \mathrm{X3} \\ +\varnothing &\xrightarrow{\frac{v4 K4^{n4}}{K4^{n4} + X1^{n4}}} \mathrm{X4} \\ +\varnothing &\xrightarrow{\frac{v5 X2^{n5}}{X2^{n5} + K5^{n5}}} \mathrm{X5} \\ +\varnothing &\xrightarrow{\frac{v6 X1^{n6}}{X6^{n6} + K6^{n6} + X1^{n6}}} \mathrm{X6} \\ +\mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ +\mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ +3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ +\mathrm{X1} &\xrightarrow{d1} \varnothing \\ +\mathrm{X2} &\xrightarrow{d2} \varnothing \\ +\mathrm{X3} &\xrightarrow{d3} \varnothing \\ +\mathrm{X4} &\xrightarrow{d4} \varnothing \\ +\mathrm{X5} &\xrightarrow{d5} \varnothing \\ +\mathrm{X6} &\xrightarrow{d6} \varnothing + \end{align*} +", "\r\n"=>"\n") + + # Latexify.@generate_test latexify(rn, mathjax = false) + @test latexify(rn, mathjax = false) == replace( +raw"\begin{align*} +\varnothing &\xrightarrow{\frac{X4^{n1} v1^{2} K1^{n1}}{\left( K1^{n1} + X4^{n1} \right) \left( K1^{n1} + X2^{n1} \right)}} \mathrm{X1} \\ +\varnothing &\xrightarrow{\frac{v2 X5^{n2}}{X5^{n2} + K2^{n2}}} \mathrm{X2} \\ +\varnothing &\xrightarrow{\frac{v3 X3^{n3}}{X3^{n3} + K3^{n3}}} \mathrm{X3} \\ +\varnothing &\xrightarrow{\frac{v4 K4^{n4}}{K4^{n4} + X1^{n4}}} \mathrm{X4} \\ +\varnothing &\xrightarrow{\frac{v5 X2^{n5}}{X2^{n5} + K5^{n5}}} \mathrm{X5} \\ +\varnothing &\xrightarrow{\frac{v6 X1^{n6}}{X6^{n6} + K6^{n6} + X1^{n6}}} \mathrm{X6} \\ +\mathrm{X2} &\xrightleftharpoons[k2]{k1} \mathrm{X1} + 2 \mathrm{X4} \\ +\mathrm{X4} &\xrightleftharpoons[k4]{k3} \mathrm{X3} \\ +3 \mathrm{X5} + \mathrm{X1} &\xrightleftharpoons[k6]{k5} \mathrm{X2} \\ +\mathrm{X1} &\xrightarrow{d1} \varnothing \\ +\mathrm{X2} &\xrightarrow{d2} \varnothing \\ +\mathrm{X3} &\xrightarrow{d3} \varnothing \\ +\mathrm{X4} &\xrightarrow{d4} \varnothing \\ +\mathrm{X5} &\xrightarrow{d5} \varnothing \\ +\mathrm{X6} &\xrightarrow{d6} \varnothing + \end{align*} +", "\r\n"=>"\n") end # Tests basic functions on simple network (2). @@ -121,22 +123,22 @@ let end # Latexify.@generate_test latexify(rn) - @test_broken latexify(rn) == replace( - raw"\begin{align*} - \varnothing &\xrightleftharpoons[d_{a}]{\frac{p_{a} B^{n}}{k^{n} + B^{n}}} \mathrm{A} \\ - \varnothing &\xrightleftharpoons[d_{b}]{p_{b}} \mathrm{B} \\ - 3 \mathrm{B} &\xrightleftharpoons[r_{b}]{r_{a}} \mathrm{A} - \end{align*} - ", "\r\n"=>"\n") - - # Latexify.@generate_test latexify(rn, mathjax=false) - @test_broken latexify(rn, mathjax = false) == replace( - raw"\begin{align*} - \varnothing &\xrightleftharpoons[d_{a}]{\frac{p_{a} B^{n}}{k^{n} + B^{n}}} \mathrm{A} \\ - \varnothing &\xrightleftharpoons[d_{b}]{p_{b}} \mathrm{B} \\ - 3 \mathrm{B} &\xrightleftharpoons[r_{b}]{r_{a}} \mathrm{A} - \end{align*} - ", "\r\n"=>"\n") + @test latexify(rn) == replace( +raw"\begin{align*} +\varnothing &\xrightleftharpoons[d_{a}]{\frac{p_{a} B^{n}}{k^{n} + B^{n}}} \mathrm{A} \\ +\varnothing &\xrightleftharpoons[d_{b}]{p_{b}} \mathrm{B} \\ +3 \mathrm{B} &\xrightleftharpoons[r_{b}]{r_{a}} \mathrm{A} + \end{align*} +", "\r\n"=>"\n") + + # Latexify.@generate_test latexify(rn, mathjax = false) + @test latexify(rn, mathjax = false) == replace( +raw"\begin{align*} +\varnothing &\xrightleftharpoons[d_{a}]{\frac{p_{a} B^{n}}{k^{n} + B^{n}}} \mathrm{A} \\ +\varnothing &\xrightleftharpoons[d_{b}]{p_{b}} \mathrm{B} \\ +3 \mathrm{B} &\xrightleftharpoons[r_{b}]{r_{a}} \mathrm{A} + \end{align*} +", "\r\n"=>"\n") end # Tests for system with parametric stoichiometry. @@ -144,12 +146,26 @@ let rn = @reaction_network begin p, 0 --> (m + n)*X end - - @test_broken latexify(rn) == replace( - raw"\begin{align*} - \varnothing &\xrightarrow{p} (m + n)\mathrm{X} - \end{align*} - ", "\r\n"=>"\n") + + # Latexify.@generate_test latexify(rn) + @test latexify(rn) == replace( +raw"\begin{align*} +\varnothing &\xrightarrow{p} (m + n)\mathrm{X} + \end{align*} +", "\r\n"=>"\n") +end + +# Checks for systems with vector species/parameters. +# Technically tests would work, however, the display is non-ideal (https://github.com/SciML/Catalyst.jl/issues/932, https://github.com/JuliaSymbolics/Symbolics.jl/issues/1167). +let + rn = @reaction_network begin + @parameters k[1:2] x[1:2] [isconstantspecies=true] + @species (X(t))[1:2] (K(t))[1:2] + (k[1]*K[1],k[2]*K[2]), X[1] + x[1] <--> X[2] + x[2] + end + + # Latexify.@generate_test latexify(rn) + @test_broken false end ### Tests the `form` Option ### @@ -170,21 +186,15 @@ let end # Latexify.@generate_test latexify(rn; form=:ode) - @test_broken latexify(rn; form = :ode) == replace( - raw"$\begin{align} - \frac{\mathrm{d} X\left( t \right)}{\mathrm{d}t} =& p - \left( X\left( t \right) \right)^{2} kB - d X\left( t \right) + 2 kD \mathrm{X2}\left( t \right) \\ - \frac{\mathrm{d} \mathrm{X2}\left( t \right)}{\mathrm{d}t} =& \frac{1}{2} \left( X\left( t \right) \right)^{2} kB - kD \mathrm{X2}\left( t \right) - \end{align} - $", "\r\n"=>"\n") - - # Currently latexify doesn't handle SDE systems properly, and they look identical to ode systems. - # The "==" shoudl be a "!=", but due to latexify tests not working, for the broken test to work, I changed it. - @test_broken latexify(rn; form=:sde) == replace( - raw"$\begin{align} - \frac{\mathrm{d} X\left( t \right)}{\mathrm{d}t} =& p - \left( X\left( t \right) \right)^{2} kB - d X\left( t \right) + 2 kD \mathrm{X2}\left( t \right) \\ - \frac{\mathrm{d} \mathrm{X2}\left( t \right)}{\mathrm{d}t} =& \frac{1}{2} \left( X\left( t \right) \right)^{2} kB - kD \mathrm{X2}\left( t \right) - \end{align} - $", "\r\n"=>"\n") + @test latexify(rn; form = :ode) == replace( +raw"$\begin{align} +\frac{\mathrm{d} X\left( t \right)}{\mathrm{d}t} =& p - d X\left( t \right) + 2 kD \mathrm{X2}\left( t \right) - \left( X\left( t \right) \right)^{2} kB \\ +\frac{\mathrm{d} \mathrm{X2}\left( t \right)}{\mathrm{d}t} =& - kD \mathrm{X2}\left( t \right) + \frac{1}{2} \left( X\left( t \right) \right)^{2} kB +\end{align} +$", "\r\n"=>"\n") + + # Currently latexify doesn't handle SDE systems properly, and they look identical to ode systems (https://github.com/SciML/ModelingToolkit.jl/issues/2782). + @test_broken false # Tests that erroneous form gives error. @test_throws ErrorException latexify(rn; form=:xxx) @@ -229,14 +239,15 @@ let end # Latexify.@generate_test latexify(rn) - @test_broken latexify(rn) == replace( - raw"\begin{align*} - \varnothing &\xrightarrow{p} (m + n)\mathrm{X} - \end{align*} - ", "\r\n"=>"\n") + @test latexify(rn) == replace( +raw"\begin{align*} +\mathrm{Y} &\xrightarrow{Y k} \varnothing + \end{align*} +", "\r\n"=>"\n") end # Checks when combined with equations (nonlinear system). +# Technically tests would work, however, the display is non-ideal (https://github.com/SciML/Catalyst.jl/issues/927). let t = default_t() base_network = @reaction_network begin @@ -247,18 +258,8 @@ let extended = extend(decaying_rate, base_network) # Latexify.@generate_test latexify(extended) - @test_broken latexify(extended) == replace( - raw"\begin{align*} - \mathrm{X} &\xrightarrow{k r} \varnothing - 0 &= -1 - x\left( t \right) - \end{align*} - ", "\r\n"=>"\n") + @test_broken false # Latexify.@generate_test latexify(extended, mathjax=false) - @test_broken latexify(extended, mathjax = false) == replace( - raw"\begin{align*} - \mathrm{X} &\xrightarrow{k r} \varnothing - 0 &= -1 - x\left( t \right) - \end{align*} - ", "\r\n"=>"\n") + @test_broken false end