diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml new file mode 100644 index 0000000000..9c79359112 --- /dev/null +++ b/.JuliaFormatter.toml @@ -0,0 +1,2 @@ +style = "sciml" +format_markdown = true \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ec3b005a0e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + ignore: + - dependency-name: "crate-ci/typos" + update-types: ["version-update:semver-patch", "version-update:semver-minor"] diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 81c8c3fc1e..a4be307e55 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -13,4 +13,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COMPATHELPER_PRIV: ${{ secrets.COMPATHELPER_PRIV }} - run: julia -e 'using CompatHelper; CompatHelper.main()' + run: julia -e 'using CompatHelper; CompatHelper.main(;subdirs=["", "docs"])' diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index a1acdbb073..14bea532b3 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -7,18 +7,32 @@ on: tags: '*' pull_request: +concurrency: + # Skip intermediate builds: always, but for the master branch. + # Cancel intermediate builds: always, but for the master branch. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' && github.refs != 'refs/tags/*'}} + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@latest with: - version: '1' + version: 'lts' + - run: sudo apt-get update && sudo apt-get install -y xorg-dev mesa-utils xvfb libgl1 freeglut3-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev - name: Install dependencies - run: julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' + run: DISPLAY=:0 xvfb-run -s '-screen 0 1024x768x24' julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For authentication with GitHub Actions token DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} # For authentication with SSH deploy key - run: julia --project=docs/ docs/make.jl + JULIA_DEBUG: "Documenter" + run: DISPLAY=:0 xvfb-run -s '-screen 0 1024x768x24' julia --project=docs/ --code-coverage=user docs/make.jl + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v5 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true diff --git a/.github/workflows/Downstream.yml b/.github/workflows/Downstream.yml index 4dc8ee926d..1d1e4ce34e 100644 --- a/.github/workflows/Downstream.yml +++ b/.github/workflows/Downstream.yml @@ -4,10 +4,18 @@ on: branches: [master] tags: [v*] pull_request: + paths-ignore: + - 'docs/**' + +concurrency: + # Skip intermediate builds: always, but for the master branch and tags. + # Cancel intermediate builds: always, but for the master branch and tags. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' && github.refs != 'refs/tags/*' }} jobs: test: - name: ${{ matrix.package.repo }}/${{ matrix.package.group }} + name: ${{ matrix.package.repo }}/${{ matrix.package.group }}/${{ matrix.julia-version }} runs-on: ${{ matrix.os }} env: GROUP: ${{ matrix.package.group }} @@ -17,20 +25,28 @@ jobs: julia-version: [1] os: [ubuntu-latest] package: + - {user: SciML, repo: SciMLBase.jl, group: Downstream} - {user: SciML, repo: Catalyst.jl, group: All} - - {user: SciML, repo: CellMLToolkit.jl, group: All} + - {user: SciML, repo: CellMLToolkit.jl, group: Core} + - {user: SciML, repo: SBMLToolkit.jl, group: All} - {user: SciML, repo: NeuralPDE.jl, group: NNPDE} - - {user: SciML, repo: DataDrivenDiffEq.jl, group: Standard} - + - {user: SciML, repo: DataDrivenDiffEq.jl, group: Downstream} + - {user: SciML, repo: StructuralIdentifiability.jl, group: Core} + - {user: SciML, repo: ModelingToolkitStandardLibrary.jl, group: Core} + - {user: SciML, repo: ModelOrderReduction.jl, group: All} + - {user: SciML, repo: MethodOfLines.jl, group: Interface} + - {user: SciML, repo: MethodOfLines.jl, group: 2D_Diffusion} + - {user: SciML, repo: MethodOfLines.jl, group: DAE} + - {user: SciML, repo: ModelingToolkitNeuralNets.jl, group: All} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v1 with: version: ${{ matrix.julia-version }} arch: x64 - uses: julia-actions/julia-buildpkg@latest - name: Clone Downstream - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: ${{ matrix.package.user }}/${{ matrix.package.repo }} path: downstream @@ -42,7 +58,7 @@ jobs: # force it to use this PR's version of the package Pkg.develop(PackageSpec(path=".")) # resolver may fail with main deps Pkg.update() - Pkg.test() # resolver may fail with test time deps + Pkg.test(coverage=true) # resolver may fail with test time deps catch err err isa Pkg.Resolve.ResolverError || rethrow() # If we can't resolve that means this is incompatible by SemVer and this is fine @@ -51,3 +67,9 @@ jobs: @info "Not compatible with this release. No problem." exception=err exit(0) # Exit immediately, as a success end + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v5 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/FormatCheck.yml b/.github/workflows/FormatCheck.yml new file mode 100644 index 0000000000..6185015c44 --- /dev/null +++ b/.github/workflows/FormatCheck.yml @@ -0,0 +1,13 @@ +name: "Format Check" + +on: + push: + branches: + - 'master' + tags: '*' + pull_request: + +jobs: + format-check: + name: "Format Check" + uses: "SciML/.github/.github/workflows/format-suggestions-on-pr.yml@v1" diff --git a/.github/workflows/SpellCheck.yml b/.github/workflows/SpellCheck.yml new file mode 100644 index 0000000000..9246edd2af --- /dev/null +++ b/.github/workflows/SpellCheck.yml @@ -0,0 +1,13 @@ +name: Spell Check + +on: [pull_request] + +jobs: + typos-check: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v4 + - name: Check spelling + uses: crate-ci/typos@v1.18.0 \ No newline at end of file diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml new file mode 100644 index 0000000000..52c5482970 --- /dev/null +++ b/.github/workflows/Tests.yml @@ -0,0 +1,46 @@ +name: "Tests" + +on: + pull_request: + branches: + - master + - 'release-' + paths-ignore: + - 'docs/**' + push: + branches: + - master + paths-ignore: + - 'docs/**' + +concurrency: + # Skip intermediate builds: always, but for the master branch. + # Cancel intermediate builds: always, but for the master branch. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +jobs: + tests: + name: "Tests" + strategy: + fail-fast: false + matrix: + version: + - "1" + - "lts" + - "pre" + group: + - InterfaceI + - InterfaceII + - Initialization + - SymbolicIndexingInterface + - Extended + - Extensions + - Downstream + - RegressionI + - FMI + uses: "SciML/.github/.github/workflows/tests.yml@v1" + with: + julia-version: "${{ matrix.version }}" + group: "${{ matrix.group }}" + secrets: "inherit" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 264d9e9da3..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: CI -on: - pull_request: - branches: - - master - push: - branches: - - master -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: julia-actions/setup-julia@v1 - with: - version: 1 - - uses: actions/cache@v1 - env: - cache-name: cache-artifacts - with: - path: ~/.julia/artifacts - key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} - restore-keys: | - ${{ runner.os }}-test-${{ env.cache-name }}- - ${{ runner.os }}-test- - ${{ runner.os }}- - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-runtest@v1 - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 - with: - file: lcov.info - - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: ./lcov.info diff --git a/.gitignore b/.gitignore index 3401a5a4b3..6d305ad348 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ Manifest.toml .vscode .vscode/* +docs/src/assets/Project.toml +docs/src/assets/Manifest.toml diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000000..a933260fb5 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,9 @@ +[default.extend-words] +nin = "nin" +nd = "nd" +Strat = "Strat" +eles = "eles" +ser = "ser" +isconnection = "isconnection" +Ue = "Ue" +Derivate = "Derivate" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..9e2640e751 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ + - This repository follows the [SciMLStyle](https://github.com/SciML/SciMLStyle) and the SciML [ColPrac](https://github.com/SciML/ColPrac). + - Please run `using JuliaFormatter, ModelingToolkit; format(joinpath(dirname(pathof(ModelingToolkit)), ".."))` before committing. + - Add tests for any new features. diff --git a/LICENSE.md b/LICENSE.md index 3a7add8ed1..947cd9843e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,43 +1,37 @@ The ModelingToolkit.jl package is licensed under the MIT "Expat" License: -> Copyright (c) 2018-20: Christopher Rackauckas, Julia Computing. -> -> +> Copyright (c) 2018-22: Yingbo Ma, Christopher Rackauckas, Julia Computing, and +> contributors +> > Permission is hereby granted, free of charge, to any person obtaining a copy -> +> > of this software and associated documentation files (the "Software"), to deal -> +> > in the Software without restriction, including without limitation the rights -> +> > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> +> > copies of the Software, and to permit persons to whom the Software is -> +> > furnished to do so, subject to the following conditions: -> -> -> +> > The above copyright notice and this permission notice shall be included in all -> +> > copies or substantial portions of the Software. -> -> -> +> > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> +> > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> +> > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> +> > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> +> > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> +> > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -> +> > SOFTWARE. -> -> The code in `src/structural_transformation/bipartite_tearing/modia_tearing.jl`, which is from the [Modia.jl](https://github.com/ModiaSim/Modia.jl) project, is diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000000..038b1d79f6 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,60 @@ +# ModelingToolkit v9 Release Notes + +### Upgrade guide + + - The function `states` is renamed to `unknowns`. In a similar vein: + + + `unknown_states` is now `solved_unknowns`. + + `get_states` is `get_unknowns`. + + `get_unknown_states` is now `get_solved_unknowns`. + + - The default backend for using units in models is now `DynamicQuantities.jl` instead of + `Unitful.jl`. + - ModelingToolkit.jl now exports common definitions of `t` (time independent variable) + and `D` (the first derivative with respect to `t`). Any models made using ModelingToolkit.jl + should leverage these common definitions. There are three variants: + + + `t` and `D` use DynamicQuantities.jl units. This is the default for standard library + components. + + `t_unitful` and `D_unitful` use Unitful.jl units. + + `t_nounits` and `D_nounits` are unitless. + - `ODAEProblem` is deprecated in favor of `ODEProblem`. + - Specifying the independent variable for an `ODESystem` is now mandatory. The `ODESystem(eqs)` + constructor is removed. Use `ODESystem(eqs,t)` instead. + - Systems must be marked as `complete` before creating `*Function`/`*FunctionExpr`/`*Problem`/ + `*ProblemExpr`. Typically this involved using `@mtkbuild` to create the system or calling + `structural_simplify` on an existing system. + - All systems will perform parameter splitting by default. Problems created using ModelingToolkit.jl + systems will have a custom struct instead of a `Vector` of parameters. The internals of this + type are undocumented and subject to change without notice or a breaking release. Parameter values + can be queried, updated or manipulated using SciMLStructures.jl or SymbolicIndexingInterface.jl. + This also requires that the symbolic type of a parameter match its assigned value. For example, + `@parameters p` will always use a `Float64` value for `p`. To use `Int` instead, use + `@parameters p::Int`. Array-valued parameters must be array symbolics; `@parameters p = [1.0, 2.0]` + is now invalid and must be changed to `@parameters p[1:2] = [1.0, 2.0]`. The index of a parameter + in the system is also not guaranteed to be an `Int`, and will instead be a custom undocumented type. + Parameters that have a default value depending on other parameters are now treated as dependent + parameters. Their value cannot be modified directly. Whenever a parameter value is changed, dependent + parameter values are recalculated. For example, if `@parameters p1 p2 = 3p1` then `p2` can not be + modified directly. If `p1` is changed, then `p2` will be updated accordingly. To restore the old behavior: + + + Pass the `split = false` keyword to `structural_simplify`. E.g. `ss = structural_simplify(sys; split = false)`. + + Pass `split = false` to `@mtkbuild`. E.g. `@mtkbuild sys = ODESystem(...) split = false`. + - Discrete-time system using `Difference` are unsupported. Instead, use the new `Clock`-based syntax. + - Automatic scalarization has been removed, meaning that vector variables need to be treated with proper vector + equations. For example, `[p[1] => 1.0, p[2] => 2.0]` is no longer allowed in default equations, use + `[p => [1.0, 2.0]]` instead. Also, array equations like for `@variables u[1:2]` have `D(u) ~ A*u` as an + array equation. If the scalarized version is desired, use `scalarize(u)`. + - Parameter dependencies are now supported. They can be specified using the syntax + `(single_parameter => expression_involving_other_parameters)` and a `Vector` of these can be passed to + the `parameter_dependencies` keyword argument of `ODESystem`, `SDESystem` and `JumpSystem`. The dependent + parameters are updated whenever other parameters are modified, e.g. in callbacks. + - Support for `IfElse.jl` has been dropped. `Base.ifelse` can be used instead. + - DAE initialization and the solving for consistent initial conditions has been changed to use a customized + initialization solve. This change adds `guess` semantics which are clearly delinated from the behavior of + the defaults, where `default` (and `u0`) is designed to be always satisfied and error if unsatisfiable, + while `guess` is an initial guess to the initializer. In previous iterations, initialization with the + default (`BrownBasicInit`) would treat the initial condition to the algebraic variables as a `guess`, + and with `ShampineCollocationInit` would treat all initial conditions as a `guess`. To return to the + previous behavior, use the keyword argument `initializealg` in the solve, i.e. + `solve(prob;initializealg = BrownBasicInit())`. diff --git a/Project.toml b/Project.toml index 2980f88686..30580d961b 100644 --- a/Project.toml +++ b/Project.toml @@ -1,90 +1,197 @@ name = "ModelingToolkit" uuid = "961ee093-0014-501f-94e3-6117800e7a78" -authors = ["Chris Rackauckas "] -version = "5.16.0" +authors = ["Yingbo Ma ", "Chris Rackauckas and contributors"] +version = "9.73.0" [deps] +ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b" AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" ArrayInterface = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" +BlockArrays = "8e7c35d0-a365-5155-bbbb-fb81a777f24e" +Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" +CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" +Compat = "34da2185-b29b-5c13-b0c7-acf172513d20" ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" -DiffEqJump = "c894b116-72e5-5b58-be3c-e6d8d4ac2b12" +DiffEqCallbacks = "459566f4-90b8-5000-8ac3-15dfb0a30def" +DiffEqNoiseProcess = "77a26b50-5914-5dd7-bc55-306e6241c503" DiffRules = "b552c78f-8df3-52c6-915a-8e097449b14b" +DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" -IfElse = "615f187c-cbe4-4ef1-ba3b-2fcf58d6d173" +DomainSets = "5b8099bc-c8ec-5219-889f-1d9e522a28bf" +DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" +EnumX = "4e289a0a-7415-4d19-859d-a7e5c4648b56" +ExprTools = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +FindFirstFunctions = "64ca27bc-2ba2-4a57-88aa-44e436879224" +ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +FunctionWrappers = "069b7b12-0de2-55c6-9aab-29f3d0a68a2e" +FunctionWrappersWrappers = "77dc65aa-8811-40c2-897b-53d922fa7daf" +Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899" -LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" +JumpProcesses = "ccbc3e58-028d-4f4c-8cd5-9ae44345cda5" Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316" Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" -LightGraphs = "093fc24a-ae57-5d10-9952-331d41423f4d" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" +MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078" +Moshi = "2e0e35c7-a2e4-4343-998d-7ef72827ed2d" NaNMath = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" +OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" +OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" RecursiveArrayTools = "731186ca-8d62-57ce-b412-fbd966d074cd" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" -Requires = "ae029012-a4dd-5104-9daa-d747884805df" RuntimeGeneratedFunctions = "7e49a35a-f44a-4d26-94aa-eba1b4ca6b47" -SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" +SCCNonlinearSolve = "9dfe8606-65a1-4bb3-9748-cb89d1561431" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" +SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" +SimpleNonlinearSolve = "727e6d20-b764-4bd8-a329-72de5adea6c7" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" SymbolicUtils = "d1185830-fcd6-423d-90d6-eec64667417b" Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" +URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UnPack = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" +[weakdeps] +BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" +ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +DeepDiffs = "ab62b9b5-e342-54a8-a765-a90f495de1a6" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" + +[extensions] +MTKBifurcationKitExt = "BifurcationKit" +MTKChainRulesCoreExt = "ChainRulesCore" +MTKDeepDiffsExt = "DeepDiffs" +MTKFMIExt = "FMI" +MTKInfiniteOptExt = "InfiniteOpt" +MTKLabelledArraysExt = "LabelledArrays" + [compat] -AbstractTrees = "0.3" -ArrayInterface = "2.8, 3.0" +ADTypes = "1.14.0" +AbstractTrees = "0.3, 0.4" +ArrayInterface = "6, 7" +BifurcationKit = "0.4" +BlockArrays = "1.1" +BoundaryValueDiffEqAscher = "1.1.0" +BoundaryValueDiffEqMIRK = "1.4.0" +ChainRulesCore = "1" +Combinatorics = "1" +CommonSolve = "0.2.4" +Compat = "3.42, 4" ConstructionBase = "1" +DataInterpolations = "6.4" DataStructures = "0.17, 0.18" -DiffEqBase = "6.54.0" -DiffEqJump = "6.7.5" +DeepDiffs = "1" +DelayDiffEq = "5.50" +DiffEqBase = "6.165.1" +DiffEqCallbacks = "2.16, 3, 4" +DiffEqNoiseProcess = "5" DiffRules = "0.1, 1.0" +DifferentiationInterface = "0.6.47" +Distributed = "1" Distributions = "0.23, 0.24, 0.25" -DocStringExtensions = "0.7, 0.8" -IfElse = "0.1" -JuliaFormatter = "0.13" +DocStringExtensions = "0.7, 0.8, 0.9" +DomainSets = "0.6, 0.7" +DynamicQuantities = "^0.11.2, 0.12, 0.13, 1" +EnumX = "1.0.4" +ExprTools = "0.1.10" +FMI = "0.14" +FindFirstFunctions = "1" +ForwardDiff = "0.10.3" +FunctionWrappers = "1.1" +FunctionWrappersWrappers = "0.1" +Graphs = "1.5.2" +InfiniteOpt = "0.5" +InteractiveUtils = "1" +JuliaFormatter = "1.0.47" +JumpProcesses = "9.13.1" LabelledArrays = "1.3" -Latexify = "0.11, 0.12, 0.13, 0.14, 0.15" -LightGraphs = "1.3" -MacroTools = "0.5" -NaNMath = "0.3" -NonlinearSolve = "0.3.8" -RecursiveArrayTools = "2.3" +Latexify = "0.11, 0.12, 0.13, 0.14, 0.15, 0.16" +Libdl = "1" +LinearAlgebra = "1" +Logging = "1" +MLStyle = "0.4.17" +ModelingToolkitStandardLibrary = "2.19" +Moshi = "0.3" +NaNMath = "0.3, 1" +NonlinearSolve = "4.3" +OffsetArrays = "1" +OrderedCollections = "1" +OrdinaryDiffEq = "6.82.0" +OrdinaryDiffEqCore = "1.15.0" +OrdinaryDiffEqDefault = "1.2" +OrdinaryDiffEqNonlinearSolve = "1.5.0" +PrecompileTools = "1" +REPL = "1" +RecursiveArrayTools = "3.26" Reexport = "0.2, 1" -Requires = "1.0" -RuntimeGeneratedFunctions = "0.4.3, 0.5" -SafeTestsets = "0.0.1" -SciMLBase = "1.3" -Setfield = "0.7" -SpecialFunctions = "0.7, 0.8, 0.9, 0.10, 1.0" +RuntimeGeneratedFunctions = "0.5.9" +SCCNonlinearSolve = "1.0.0" +SciMLBase = "2.75" +SciMLStructures = "1.7" +Serialization = "1" +Setfield = "0.7, 0.8, 1" +SimpleNonlinearSolve = "0.1.0, 1, 2" +SparseArrays = "1" +SpecialFunctions = "0.7, 0.8, 0.9, 0.10, 1.0, 2" StaticArrays = "0.10, 0.11, 0.12, 1.0" -SymbolicUtils = "0.11.0" -Symbolics = "0.1.21" +StochasticDelayDiffEq = "1.8.1" +StochasticDiffEq = "6.72.1" +SymbolicIndexingInterface = "0.3.39" +SymbolicUtils = "3.25.1" +Symbolics = "6.37" +URIs = "1" UnPack = "0.1, 1.0" Unitful = "1.1" -julia = "1.2" +julia = "1.9" [extras] +AmplNLWriter = "7c4d4715-977e-5154-bfe0-e096adeac482" BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +BoundaryValueDiffEqAscher = "7227322d-7511-4e07-9247-ad6ff830280e" +BoundaryValueDiffEqMIRK = "1a22d4ce-7765-49ea-b6f2-13c8438986a6" +ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" +DeepDiffs = "ab62b9b5-e342-54a8-a765-a90f495de1a6" +DelayDiffEq = "bcd4f6db-9728-5f36-b5f7-82caef46ccdb" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" -GalacticOptim = "a75be94c-b780-496d-a8a9-0878b188d577" +Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" +Ipopt_jll = "9cc047cb-c261-5740-88fc-0cf96f7bdcc7" +JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" -Optim = "429524aa-4258-5aef-a3af-852621145aeb" +Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba" +OptimizationMOI = "fd9f6733-72f4-499f-8506-86b2bdd0dea1" +OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" +OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8" +OrdinaryDiffEqDefault = "50262376-6c5a-4cf5-baba-aaf4f84d72d7" +OrdinaryDiffEqNonlinearSolve = "127b3ac7-2247-4354-8eb6-78cf4e7c58e8" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +ReferenceTests = "324d217c-45ce-50fc-942e-d289b448e8cf" +SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" +StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" SteadyStateDiffEq = "9672c7b4-1e72-59bd-8a11-6ac3964bc41f" +StochasticDelayDiffEq = "29a0d76e-afc8-11e9-03a4-eda52ae4b960" StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" Sundials = "c3572dad-4567-51f8-b174-8c6c989267f4" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["BenchmarkTools", "ForwardDiff", "GalacticOptim", "NonlinearSolve", "OrdinaryDiffEq", "Optim", "Random", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials"] +test = ["AmplNLWriter", "BenchmarkTools", "BoundaryValueDiffEqMIRK", "BoundaryValueDiffEqAscher", "ControlSystemsBase", "DataInterpolations", "DelayDiffEq", "NonlinearSolve", "ForwardDiff", "Ipopt", "Ipopt_jll", "ModelingToolkitStandardLibrary", "Optimization", "OptimizationOptimJL", "OptimizationMOI", "OrdinaryDiffEq", "OrdinaryDiffEqCore", "OrdinaryDiffEqDefault", "REPL", "Random", "ReferenceTests", "SafeTestsets", "StableRNGs", "Statistics", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials", "StochasticDelayDiffEq", "Pkg", "JET", "OrdinaryDiffEqNonlinearSolve", "Logging"] diff --git a/README.md b/README.md index 77d864e503..12570c35a3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # ModelingToolkit.jl -[![Github Action CI](https://github.com/SciML/ModelingToolkit.jl/workflows/CI/badge.svg)](https://github.com/SciML/ModelingToolkit.jl/actions) +[![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) +[![Global Docs](https://img.shields.io/badge/docs-SciML-blue.svg)](https://docs.sciml.ai/ModelingToolkit/stable/) + +[![codecov](https://codecov.io/gh/SciML/ModelingToolkit.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/SciML/ModelingToolkit.jl) [![Coverage Status](https://coveralls.io/repos/github/SciML/ModelingToolkit.jl/badge.svg?branch=master)](https://coveralls.io/github/SciML/ModelingToolkit.jl?branch=master) -[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](http://mtk.sciml.ai/stable/) -[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](http://mtk.sciml.ai/dev/) -[![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) +[![Build Status](https://github.com/SciML/ModelingToolkit.jl/workflows/CI/badge.svg)](https://github.com/SciML/ModelingToolkit.jl/actions?query=workflow%3ACI) + +[![ColPrac: Contributor's Guide on Collaborative Practices for Community Packages](https://img.shields.io/badge/ColPrac-Contributor%27s%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) ModelingToolkit.jl is a modeling framework for high-performance symbolic-numeric computation in scientific computing and scientific machine learning. @@ -16,10 +20,15 @@ computations. Automatic transformations, such as index reduction, can be applied to the model to make it easier for numerical solvers to handle. For information on using the package, -[see the stable documentation](https://mtk.sciml.ai/stable/). Use the -[in-development documentation](https://mtk.sciml.ai/dev/) for the version of +[see the stable documentation](https://docs.sciml.ai/ModelingToolkit/stable/). Use the +[in-development documentation](https://docs.sciml.ai/ModelingToolkit/dev/) for the version of the documentation which contains the unreleased features. +## Standard Library + +For a standard library of ModelingToolkit components and blocks, check out the +[ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/) + ## High-Level Examples First, let's define a second order riff on the Lorenz equations, symbolically @@ -27,32 +36,32 @@ lower it to a first order system, symbolically generate the Jacobian function for the numerical integrator, and solve it. ```julia -using ModelingToolkit, OrdinaryDiffEq +using OrdinaryDiffEqDefault, ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters t σ ρ β +@parameters σ ρ β @variables x(t) y(t) z(t) -D = Differential(t) -eqs = [D(D(x)) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] +eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] -sys = ODESystem(eqs) -sys = ode_order_lowering(sys) +@mtkbuild sys = ODESystem(eqs, t) u0 = [D(x) => 2.0, - x => 1.0, - y => 0.0, - z => 0.0] - -p = [σ => 28.0, - ρ => 10.0, - β => 8/3] - -tspan = (0.0,100.0) -prob = ODEProblem(sys,u0,tspan,p,jac=true) -sol = solve(prob,Tsit5()) -using Plots; plot(sol,vars=(x,y)) + x => 1.0, + y => 0.0, + z => 0.0] + +p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +tspan = (0.0, 100.0) +prob = ODEProblem(sys, u0, tspan, p, jac = true) +sol = solve(prob) +using Plots +plot(sol, idxs = (x, y)) ``` ![Lorenz2](https://user-images.githubusercontent.com/1814174/79118645-744eb580-7d5c-11ea-9c37-13c4efd585ca.png) @@ -64,51 +73,54 @@ interacting Lorenz equations and simulate the resulting Differential-Algebraic Equation (DAE): ```julia -using ModelingToolkit, OrdinaryDiffEq +using DifferentialEquations, ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters t σ ρ β +@parameters σ ρ β @variables x(t) y(t) z(t) -D = Differential(t) -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] -lorenz1 = ODESystem(eqs,name=:lorenz1) -lorenz2 = ODESystem(eqs,name=:lorenz2) +@named lorenz1 = ODESystem(eqs, t) +@named lorenz2 = ODESystem(eqs, t) -@variables a +@variables a(t) @parameters γ -connections = [0 ~ lorenz1.x + lorenz2.y + a*γ] -connected = ODESystem(connections,t,[a],[γ],systems=[lorenz1,lorenz2]) +connections = [0 ~ lorenz1.x + lorenz2.y + a * γ] +@mtkbuild connected = ODESystem(connections, t, systems = [lorenz1, lorenz2]) u0 = [lorenz1.x => 1.0, - lorenz1.y => 0.0, - lorenz1.z => 0.0, - lorenz2.x => 0.0, - lorenz2.y => 1.0, - lorenz2.z => 0.0, - a => 2.0] - -p = [lorenz1.σ => 10.0, - lorenz1.ρ => 28.0, - lorenz1.β => 8/3, - lorenz2.σ => 10.0, - lorenz2.ρ => 28.0, - lorenz2.β => 8/3, - γ => 2.0] - -tspan = (0.0,100.0) -prob = ODEProblem(connected,u0,tspan,p) -sol = solve(prob,Rodas4()) - -using Plots; plot(sol,vars=(a,lorenz1.x,lorenz2.z)) + lorenz1.y => 0.0, + lorenz1.z => 0.0, + lorenz2.x => 0.0, + lorenz2.y => 1.0, + lorenz2.z => 0.0, + a => 2.0] + +p = [lorenz1.σ => 10.0, + lorenz1.ρ => 28.0, + lorenz1.β => 8 / 3, + lorenz2.σ => 10.0, + lorenz2.ρ => 28.0, + lorenz2.β => 8 / 3, + γ => 2.0] + +tspan = (0.0, 100.0) +prob = ODEProblem(connected, u0, tspan, p) +sol = solve(prob) + +using Plots +plot(sol, idxs = (a, lorenz1.x, lorenz2.z)) ``` -![](https://user-images.githubusercontent.com/1814174/110242538-87461780-7f24-11eb-983c-4b2c93cfc909.png) +![](https://user-images.githubusercontent.com/17304743/187790221-528046c3-dbdb-4853-b977-799596c147f3.png) # Citation + If you use ModelingToolkit.jl in your research, please cite [this paper](https://arxiv.org/abs/2103.05244): + ``` @misc{ma2021modelingtoolkit, title={ModelingToolkit: A Composable Graph Transformation System For Equation-Based Modeling}, diff --git a/docs/Project.toml b/docs/Project.toml index 1948117ebd..ca13773492 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +1,58 @@ [deps] -ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +Attractors = "f3fd9213-ca85-4dba-9dfd-7fc91308fec7" +BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e" +ControlSystemsMTK = "687d7614-c7e5-45fc-bfc3-9ee385575c88" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +FMIZoo = "724179cf-c260-40a9-bd27-cccc6fe2f195" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" +NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" +Optim = "429524aa-4258-5aef-a3af-852621145aeb" +Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba" +OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e" +OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" +PreallocationTools = "d236fae5-4411-538c-8e31-a6e3d9e00b46" +SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" +Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" +StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" +SymbolicUtils = "d1185830-fcd6-423d-90d6-eec64667417b" +Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" +Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [compat] -Documenter = "0.24.2" +Attractors = "1.24" +BenchmarkTools = "1.3" +BifurcationKit = "0.4" +CairoMakie = "0.13" +DataInterpolations = "6.5, 8" +Distributions = "0.25" +Documenter = "1" +DynamicQuantities = "^0.11.2, 0.12, 1" +FMI = "0.14" +FMIZoo = "1" +ModelingToolkit = "8.33, 9" +ModelingToolkitStandardLibrary = "2.19" +NonlinearSolve = "3, 4" +Optim = "1.7" +Optimization = "3.9, 4" +OptimizationOptimJL = "0.1, 0.4" +OrdinaryDiffEq = "6.31" +Plots = "1.36" +PreallocationTools = "0.4" +SciMLStructures = "1.1" +Setfield = "1" +StochasticDiffEq = "6" +SymbolicIndexingInterface = "0.3.1" +SymbolicUtils = "3" +Symbolics = "6" +Unitful = "1.12" diff --git a/docs/make.jl b/docs/make.jl index 739d842d42..36a27bf598 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,54 +1,42 @@ using Documenter, ModelingToolkit +using ModelingToolkit: SciMLBase -makedocs( - sitename="ModelingToolkit.jl", - authors="Chris Rackauckas", - modules=[ModelingToolkit], - clean=true,doctest=false, - format = Documenter.HTML(#analytics = "UA-90474609-3", - assets = ["assets/favicon.ico"], - canonical="https://mtk.sciml.ai/stable/"), - pages=[ - "Home" => "index.md", - "Symbolic Modeling Tutorials" => Any[ - "tutorials/ode_modeling.md", - "tutorials/acausal_components.md", - "tutorials/higher_order.md", - "tutorials/tearing_parallelism.md", - "tutorials/nonlinear.md", - "tutorials/optimization.md", - "tutorials/stochastic_diffeq.md", - "tutorials/nonlinear_optimal_control.md" - ], - "ModelingToolkitize Tutorials" => Any[ - "mtkitize_tutorials/modelingtoolkitize.md", - "mtkitize_tutorials/modelingtoolkitize_index_reduction.md", - #"mtkitize_tutorials/sparse_jacobians", - ], - "Basics" => Any[ - "basics/AbstractSystem.md", - "basics/ContextualVariables.md", - "basics/Composition.md", - "basics/Validation.md", - "basics/DependencyGraphs.md", - "basics/FAQ.md" - ], - "System Types" => Any[ - "systems/ODESystem.md", - "systems/SDESystem.md", - "systems/JumpSystem.md", - "systems/NonlinearSystem.md", - "systems/OptimizationSystem.md", - "systems/ControlSystem.md", - "systems/ReactionSystem.md", - "systems/PDESystem.md", - ], - "comparison.md", - "internals.md", - ] -) +# Make sure that plots don't throw a bunch of warnings / errors! +ENV["GKSwstype"] = "100" +using Plots -deploydocs( - repo = "github.com/SciML/ModelingToolkit.jl.git"; - push_preview = true -) +cp("./docs/Manifest.toml", "./docs/src/assets/Manifest.toml", force = true) +cp("./docs/Project.toml", "./docs/src/assets/Project.toml", force = true) + +include("pages.jl") + +mathengine = MathJax3(Dict(:loader => Dict("load" => ["[tex]/require", "[tex]/mathtools"]), + :tex => Dict("inlineMath" => [["\$", "\$"], ["\\(", "\\)"]], + "packages" => [ + "base", + "ams", + "autoload", + "mathtools", + "require" + ]))) + +makedocs(sitename = "ModelingToolkit.jl", + authors = "Chris Rackauckas", + modules = [ModelingToolkit], + clean = true, doctest = false, linkcheck = true, + warnonly = [:docs_block, :missing_docs, :cross_references], + linkcheck_ignore = [ + "https://epubs.siam.org/doi/10.1137/0903023", + # this link tends to fail linkcheck stochastically and often takes much longer to succeed + # even in the browser it takes ages + "http://www.scholarpedia.org/article/Differential-algebraic_equations" + ], + format = Documenter.HTML(; + assets = ["assets/favicon.ico"], + mathengine, + canonical = "https://docs.sciml.ai/ModelingToolkit/stable/", + prettyurls = (get(ENV, "CI", nothing) == "true")), + pages = pages) + +deploydocs(repo = "github.com/SciML/ModelingToolkit.jl.git"; + push_preview = true) diff --git a/docs/pages.jl b/docs/pages.jl new file mode 100644 index 0000000000..0177947ed2 --- /dev/null +++ b/docs/pages.jl @@ -0,0 +1,53 @@ +pages = [ + "Home" => "index.md", + "tutorials/ode_modeling.md", + "Tutorials" => Any["tutorials/acausal_components.md", + "tutorials/nonlinear.md", + "tutorials/initialization.md", + "tutorials/optimization.md", + "tutorials/modelingtoolkitize.md", + "tutorials/programmatically_generating.md", + "tutorials/stochastic_diffeq.md", + "tutorials/discrete_system.md", + "tutorials/parameter_identifiability.md", + "tutorials/change_independent_variable.md", + "tutorials/bifurcation_diagram_computation.md", + "tutorials/attractors.md", + "tutorials/SampledData.md", + "tutorials/domain_connections.md", + "tutorials/callable_params.md", + "tutorials/linear_analysis.md", + "tutorials/disturbance_modeling.md", + "tutorials/fmi.md"], + "Examples" => Any[ + "Basic Examples" => Any["examples/higher_order.md", + "examples/spring_mass.md", + "examples/modelingtoolkitize_index_reduction.md", + "examples/remake.md"], + "Advanced Examples" => Any["examples/tearing_parallelism.md", + "examples/sparse_jacobians.md", + "examples/perturbation.md"]], + "Basics" => Any["basics/AbstractSystem.md", + "basics/ContextualVariables.md", + "basics/Variable_metadata.md", + "basics/Composition.md", + "basics/Events.md", + "basics/Linearization.md", + "basics/InputOutput.md", + "basics/MTKLanguage.md", + "basics/Validation.md", + "basics/Debugging.md", + "basics/DependencyGraphs.md", + "basics/Precompilation.md", + "basics/FAQ.md"], + "System Types" => Any["systems/ODESystem.md", + "systems/SDESystem.md", + "systems/JumpSystem.md", + "systems/NonlinearSystem.md", + "systems/OptimizationSystem.md", + "systems/PDESystem.md", + "systems/DiscreteSystem.md", + "systems/ImplicitDiscreteSystem.md"], + "comparison.md", + "internals.md" +] diff --git a/docs/src/basics/AbstractSystem.md b/docs/src/basics/AbstractSystem.md index 8214a6ddcc..d1707f822f 100644 --- a/docs/src/basics/AbstractSystem.md +++ b/docs/src/basics/AbstractSystem.md @@ -4,63 +4,77 @@ The `AbstractSystem` interface is the core of the system level of ModelingToolkit.jl. It establishes a common set of functionality that is used between systems -from ODEs and chemical reactions, allowing users to have a common framework for +representing ODEs, PDEs, SDEs and more, allowing users to have a common framework for model manipulation and compilation. +### Subtypes + +There are three immediate subtypes of `AbstractSystem`, classified by how many independent variables each type has: + + - `AbstractTimeIndependentSystem`: has no independent variable (e.g.: `NonlinearSystem`) + - `AbstractTimeDependentSystem`: has a single independent variable (e.g.: `ODESystem`) + - `AbstractMultivariateSystem`: may have multiple independent variables (e.g.: `PDESystem`) + ## Constructors and Naming The `AbstractSystem` interface has a consistent method for constructing systems. -Generally it follows the order of: +Generally, it follows the order of: -1. Equations -2. Independent Variables -3. Dependent Variables (or States) -4. Parameters + 1. Equations + 2. Independent Variables + 3. Dependent Variables (or Unknowns) + 4. Parameters All other pieces are handled via keyword arguments. `AbstractSystem`s share the same keyword arguments, which are: -- `system`: This is used for specifying subsystems for hierarchical modeling with - reusable components. For more information, see the [components page](@ref components) -- Defaults: Keyword arguments like `defaults` are used for specifying default - values which are used. If a value is not given at the `SciMLProblem` construction - time, its numerical value will be the default. + - `system`: This is used for specifying subsystems for hierarchical modeling with + reusable components. For more information, see the [components page](@ref components). + - Defaults: Keyword arguments like `defaults` are used for specifying default + values which are used. If a value is not given at the `SciMLProblem` construction + time, its numerical value will be the default. ## Composition and Accessor Functions Each `AbstractSystem` has lists of variables in context, such as distinguishing -parameters vs states. In addition, an `AbstractSystem` also can hold other -`AbstractSystem` types. Direct accessing of the values, such as `sys.states`, -gives the immediate list, while the accessor functions `states(sys)` gives the +parameters vs unknowns. In addition, an `AbstractSystem` can also hold other +`AbstractSystem` types. Direct accessing of the values, such as `sys.unknowns`, +gives the immediate list, while the accessor functions `unknowns(sys)` gives the total set, which includes that of all systems held inside. The values which are common to all `AbstractSystem`s are: -- `equations(sys)`: All equations that define the system and its subsystems. -- `states(sys)`: All the states in the system and its subsystems. -- `parameters(sys)`: All parameters of the system and its subsystems. -- `nameof(sys)`: The name of the current-level system. -- `get_eqs(sys)`: Equations that define the current-level system. -- `get_states(sys)`: States that are in the current-level system. -- `get_ps(sys)`: Parameters that are in the current-level system. -- `get_systems(sys)`: Subsystems of the current-level system. + - `equations(sys)`: All equations that define the system and its subsystems. + - `unknowns(sys)`: All the unknowns in the system and its subsystems. + - `parameters(sys)`: All parameters of the system and its subsystems. + - `nameof(sys)`: The name of the current-level system. + - `get_eqs(sys)`: Equations that define the current-level system. + - `get_unknowns(sys)`: Unknowns that are in the current-level system. + - `get_ps(sys)`: Parameters that are in the current-level system. + - `get_systems(sys)`: Subsystems of the current-level system. Optionally, a system could have: -- `observed(sys)`: All observed equations of the system and its subsystems. -- `get_observed(sys)`: Observed equations of the current-level system. -- `get_defaults(sys)`: A `Dict` that maps variables into their default values. -- `independent_variable(sys)`: The independent variable of a system. -- `get_noiseeqs(sys)`: Noise equations of the current-level system. + - `observed(sys)`: All observed equations of the system and its subsystems. + - `independent_variables(sys)`: The independent variables of a system. + - `defaults(sys)`: A `Dict` that maps variables/parameters into their default values for the system and its subsystems. + - `get_observed(sys)`: Observed equations of the current-level system. + - `get_continuous_events(sys)`: `SymbolicContinuousCallback`s of the current-level system. + - `get_defaults(sys)`: A `Dict` that maps variables into their default values + for the current-level system. + - `get_noiseeqs(sys)`: Noise equations of the current-level system. + - `get_description(sys)`: A string that describes what a system represents. + - `get_metadata(sys)`: Any metadata about the system or its origin to be used by downstream packages. -Note that there's `get_iv(sys)`, but it is not advised to use, since it errors -when the system has no field `iv`. `independent_variable(sys)` returns `nothing` -for `NonlinearSystem`s. +Note that if you know a system is an `AbstractTimeDependentSystem` you could use `get_iv` to get the +unique independent variable directly, rather than using `independent_variables(sys)[1]`, which is clunky and may cause problems if `sys` is an `AbstractMultivariateSystem` because there may be more than one independent variable. `AbstractTimeIndependentSystem`s do not have a method `get_iv`, and `independent_variables(sys)` will return a size-zero result for such. For an `AbstractMultivariateSystem`, `get_ivs` is equivalent. + +For the `parameters`, `unknowns`, `continuous_events`, and `discrete_events` accessors there are corresponding `parameters_toplevel`, `unknowns_toplevel`, `continuous_events_toplevel`, and `discrete_events_toplevel` accessors which work similarly, but ignore the content of subsystems. Furthermore, a `equations_toplevel` version of `equations` exists as well, however, it can only be applied to non-complete systems. A system could also have caches: -- `get_jac(sys)`: The Jacobian of a system. -- `get_tgrad(sys)`: The gradient with respect to time of a system. + - `get_jac(sys)`: The Jacobian of a system. + - `get_tgrad(sys)`: The gradient with respect to time of a system. ## Transformations @@ -110,7 +124,7 @@ patterns via an abstract interpretation without requiring differentiation. At the end, the system types have `DEProblem` constructors, like `ODEProblem`, which allow for directly generating the problem types required for numerical -methods. The first argument is always the `AbstractSystem`, and the proceeding +methods. The first argument is always the `AbstractSystem`, and the next arguments match the argument order of their original constructors. Whenever an array would normally be provided, such as `u0` the initial condition of an `ODEProblem`, it is instead replaced with a variable map, i.e., an array of @@ -118,7 +132,7 @@ pairs `var=>value`, which allows the user to designate the values without having to know the order that ModelingToolkit is internally using. For the value maps, the parameters are allowed to be functions of each other, -and value maps of states can be functions of the parameters, i.e. you can do: +and value maps of unknowns can be functions of the parameters, i.e. you can do: ``` u0 = [ @@ -134,3 +148,15 @@ The `AbstractSystem` types allow for specifying default values, for example into the value maps, where for any repeats the value maps override the default. In addition, defaults of a higher level in the system override the defaults of a lower level in the system. + +## Namespacing + +By default, unsimplified systems will namespace variables accessed via `getproperty`. +Systems created via `@mtkbuild`, or ones passed through `structural_simplify` or +`complete` will not perform this namespacing. However, all of these processes modify +the system in a variety of ways. To toggle namespacing without transforming any other +property of the system, use `toggle_namespacing`. + +```@docs +toggle_namespacing +``` diff --git a/docs/src/basics/Composition.md b/docs/src/basics/Composition.md index 0cdec84a03..2e5d4be831 100644 --- a/docs/src/basics/Composition.md +++ b/docs/src/basics/Composition.md @@ -12,30 +12,27 @@ an optional forcing function, and allowing the user to specify the forcing later. Here, the library author defines a component named `decay`. The user then builds two `decay` components and connects them, saying the forcing term of `decay1` is a constant while the forcing term -of `decay2` is the value of the state variable `x`. +of `decay2` is the value of the unknown variable `x`. -```julia +```@example composition using ModelingToolkit - -function decay(;name) - @parameters t a - @variables x(t) f(t) - D = Differential(t) - ODESystem([ - D(x) ~ -a*x + f - ]; - name=name) +using ModelingToolkit: t_nounits as t, D_nounits as D + +function decay(; name) + @parameters a + @variables x(t) f(t) + ODESystem([ + D(x) ~ -a * x + f + ], t; + name = name) end @named decay1 = decay() @named decay2 = decay() -@parameters t -D = Differential(t) -@named connected = ODESystem([ - decay2.f ~ decay1.x - D(decay1.f) ~ 0 - ], t, systems=[decay1, decay2]) +connected = compose( + ODESystem([decay2.f ~ decay1.x + D(decay1.f) ~ 0], t; name = :connected), decay1, decay2) equations(connected) @@ -48,27 +45,18 @@ equations(connected) simplified_sys = structural_simplify(connected) equations(simplified_sys) - -#3-element Vector{Equation}: -# Differential(t)(decay1₊f(t)) ~ 0 -# Differential(t)(decay1₊x(t)) ~ decay1₊f(t) - (decay1₊a*(decay1₊x(t))) -# Differential(t)(decay2₊x(t)) ~ decay1₊x(t) - (decay2₊a*(decay2₊x(t))) ``` Now we can solve the system: -```julia -x0 = [ - decay1.x => 1.0 - decay1.f => 0.0 - decay2.x => 1.0 -] -p = [ - decay1.a => 0.1 - decay2.a => 0.2 -] - -using DifferentialEquations +```@example composition +x0 = [decay1.x => 1.0 + decay1.f => 0.0 + decay2.x => 1.0] +p = [decay1.a => 0.1 + decay2.a => 0.2] + +using OrdinaryDiffEq prob = ODEProblem(simplified_sys, x0, (0.0, 100.0), p) sol = solve(prob, Tsit5()) sol[decay2.f] @@ -81,18 +69,18 @@ subsystems. A model is the composition of itself and its subsystems. For example, if we have: ```julia -@named sys = ODESystem(eqs,indepvar,states,ps,system=[subsys]) +@named sys = compose(ODESystem(eqs, indepvar, unknowns, ps), subsys) ``` the `equations` of `sys` is the concatenation of `get_eqs(sys)` and -`equations(subsys)`, the states are the concatenation of their states, +`equations(subsys)`, the unknowns are the concatenation of their unknowns, etc. When the `ODEProblem` or `ODEFunction` is generated from this system, it will build and compile the functions associated with this composition. The new equations within the higher level system can access the variables in the lower level system by namespacing via the `nameof(subsys)`. For -example, let's say there is a variable `x` in `states` and a variable +example, let's say there is a variable `x` in `unknowns` and a variable `x` in `subsys`. We can declare that these two variables are the same by specifying their equality: `x ~ subsys.x` in the `eqs` for `sys`. This algebraic relationship can then be simplified by transformations @@ -106,15 +94,13 @@ this is done, the initial conditions and parameters must be specified in their namespaced form. For example: ```julia -u0 = [ - x => 2.0 - subsys.x => 2.0 -] +u0 = [x => 2.0 + subsys.x => 2.0] ``` Note that any default values within the given subcomponent will be used if no override is provided at construction time. If any values for -initial conditions or parameters are unspecified an error will be thrown. +initial conditions or parameters are unspecified, an error will be thrown. When the model is numerically solved, the solution can be accessed via its symbolic values. For example, if `sol` is the `ODESolution`, one @@ -129,7 +115,7 @@ will be lazily reconstructed on demand. In some scenarios, it could be useful for model parameters to be expressed in terms of other parameters, or shared between common subsystems. -To fascilitate this, ModelingToolkit supports sybmolic expressions +To facilitate this, ModelingToolkit supports symbolic expressions in default values, and scoped variables. With symbolic parameters, it is possible to set the default value of a parameter or initial condition to an expression of other variables. @@ -137,26 +123,54 @@ With symbolic parameters, it is possible to set the default value of a parameter ```julia # ... sys = ODESystem( - # ... - # directly in the defauls argument - defaults=Pair{Num, Any}[ - x => u, +# ... +# directly in the defaults argument + defaults = Pair{Num, Any}[x => u, y => σ, - z => u-0.1, -]) + z => u - 0.1]) # by assigning to the parameter -sys.y = u*1.1 +sys.y = u * 1.1 ``` -In a hierarchical system, variables of the subsystem get namespaced by the name of the system they are in. This prevents naming clashes, but also enforces that every state and parameter is local to the subsystem it is used in. In some cases it might be desirable to have variables and parameters that are shared between subsystems, or even global. This can be accomplished as follows. +In a hierarchical system, variables of the subsystem get namespaced by the name of the system they are in. This prevents naming clashes, but also enforces that every unknown and parameter is local to the subsystem it is used in. In some cases it might be desirable to have variables and parameters that are shared between subsystems, or even global. This can be accomplished as follows. ```julia -@variables a b c d +@parameters a b c d e f # a is a local variable b = ParentScope(b) # b is a variable that belongs to one level up in the hierarchy c = ParentScope(ParentScope(c)) # ParentScope can be nested -d = GlobalScope(d) # global variables will never be namespaced +d = DelayParentScope(d) # skips one level before applying ParentScope +e = DelayParentScope(e, 2) # second argument allows skipping N levels +f = GlobalScope(f) + +p = [a, b, c, d, e, f] + +level0 = ODESystem(Equation[], t, [], p; name = :level0) +level1 = ODESystem(Equation[], t, [], []; name = :level1) ∘ level0 +parameters(level1) +#level0₊a +#b +#c +#level0₊d +#level0₊e +#f +level2 = ODESystem(Equation[], t, [], []; name = :level2) ∘ level1 +parameters(level2) +#level1₊level0₊a +#level1₊b +#c +#level0₊d +#level1₊level0₊e +#f +level3 = ODESystem(Equation[], t, [], []; name = :level3) ∘ level2 +parameters(level3) +#level2₊level1₊level0₊a +#level2₊level1₊b +#level2₊c +#level2₊level0₊d +#level1₊level0₊e +#f ``` ## Structural Simplify @@ -167,104 +181,99 @@ before numerically solving. The `structural_simplify` function removes these trivial equality relationships and trivial singularity equations, i.e. equations which result in `0~0` expressions, in over-specified systems. -## Inheritance and Combine (TODO) +## Inheritance and Combine -Model inheritance can be done in two ways: explicitly or implicitly. -The explicit way is to shadow variables with equality expressions. -For example, let's assume we have three separate systems which we -want to compose to a single one. This is how one could explicitly -forward all states and parameters to the higher level system: +Model inheritance can be done in two ways: implicitly or explicitly. First, one +can use the `extend` function to extend a base model with another set of +equations, unknowns, and parameters. An example can be found in the +[acausal components tutorial](@ref acausal). -```julia +The explicit way is to shadow variables with equality expressions. For example, +let's assume we have three separate systems which we want to compose to a single +one. This is how one could explicitly forward all unknowns and parameters to the +higher level system: + +```@example compose using ModelingToolkit, OrdinaryDiffEq, Plots +using ModelingToolkit: t_nounits as t, D_nounits as D ## Library code - -@parameters t -D = Differential(t) - @variables S(t), I(t), R(t) N = S + I + R -@parameters β,γ - -@named seqn = ODESystem([D(S) ~ -β*S*I/N]) -@named ieqn = ODESystem([D(I) ~ β*S*I/N-γ*I]) -@named reqn = ODESystem([D(R) ~ γ*I]) - -@named sir = ODESystem([ - S ~ ieqn.S, - I ~ seqn.I, - R ~ ieqn.R, - ieqn.S ~ seqn.S, - seqn.I ~ ieqn.I, - seqn.R ~ reqn.R, - ieqn.R ~ reqn.R, - reqn.I ~ ieqn.I], t, [S,I,R], [β,γ], - systems=[seqn,ieqn,reqn], - default_p = [ - seqn.β => β - ieqn.β => β - ieqn.γ => γ - reqn.γ => γ - ]) +@parameters β, γ + +@named seqn = ODESystem([D(S) ~ -β * S * I / N], t) +@named ieqn = ODESystem([D(I) ~ β * S * I / N - γ * I], t) +@named reqn = ODESystem([D(R) ~ γ * I], t) + +sir = compose( + ODESystem( + [ + S ~ ieqn.S, + I ~ seqn.I, + R ~ ieqn.R, + ieqn.S ~ seqn.S, + seqn.I ~ ieqn.I, + seqn.R ~ reqn.R, + ieqn.R ~ reqn.R, + reqn.I ~ ieqn.I], + t, + [S, I, R], + [β, γ], + defaults = [seqn.β => β + ieqn.β => β + ieqn.γ => γ + reqn.γ => γ], name = :sir), + seqn, + ieqn, + reqn) ``` -Note that the states are forwarded by an equality relationship, while +Note that the unknowns are forwarded by an equality relationship, while the parameters are forwarded through a relationship in their default values. The user of this model can then solve this model simply by specifying the values at the highest level: -```julia +```@example compose sireqn_simple = structural_simplify(sir) equations(sireqn_simple) +``` -# 3-element Vector{Equation}: -#Differential(t)(seqn₊S(t)) ~ -seqn₊β*ieqn₊I(t)*seqn₊S(t)*(((ieqn₊I(t)) + (reqn₊R(t)) + (seqn₊S(t)))^-1) -#Differential(t)(ieqn₊I(t)) ~ ieqn₊β*ieqn₊I(t)*seqn₊S(t)*(((ieqn₊I(t)) + (reqn₊R(t)) + (seqn₊S(t)))^-1) - (ieqn₊γ*(ieqn₊I(t))) -#Differential(t)(reqn₊R(t)) ~ reqn₊γ*ieqn₊I(t) - +```@example compose ## User Code u0 = [seqn.S => 990.0, - ieqn.I => 10.0, - reqn.R => 0.0] + ieqn.I => 10.0, + reqn.R => 0.0] -p = [ - β => 0.5 - γ => 0.25 -] +p = [β => 0.5 + γ => 0.25] -tspan = (0.0,40.0) -prob = ODEProblem(sireqn_simple,u0,tspan,p,jac=true) -sol = solve(prob,Tsit5()) +tspan = (0.0, 40.0) +prob = ODEProblem(sireqn_simple, u0, tspan, p, jac = true) +sol = solve(prob, Tsit5()) sol[reqn.R] ``` -However, one can similarly simplify this process of inheritance by -using `combine` which concatenates all of the vectors within the -systems. For example, we could equivalently have done: - -```julia -@named sir = combine([seqn,ieqn,reqn]) -``` - ## Tearing Problem Construction -Some system types, specifically `ODESystem` and `NonlinearSystem`, can be further +Some system types (specifically `NonlinearSystem`) can be further reduced if `structural_simplify` has already been applied to them. This is done -by using the alternative problem constructors, `ODAEProblem` and `BlockNonlinearProblem` -respectively. In these cases, the constructor uses the knowledge of the +by using the alternative problem constructors (`BlockNonlinearProblem`). +In these cases, the constructor uses the knowledge of the strongly connected components calculated during the process of simplification as the basis for building pre-simplified nonlinear systems in the implicit solving. In summary: these problems are structurally modified, but could be more efficient and more stable. -## Automatic Model Promotion (TODO) +## Components with discontinuous dynamics -In many cases one might want to compose models of different types. For example, -one may want to include a `NonlinearSystem` as a set of algebraic equations -within an `ODESystem`, or one may want to use an `ODESystem` as a subsystem of -an `SDESystem`. In these cases, the compostion works automatically by promoting -the model via `promote_system`. System promotions exist in the cases where a -mathematically-trivial definition of the promotion exists. +When modeling, e.g., impacts, saturations or Coulomb friction, the dynamic +equations are discontinuous in either the unknown or one of its derivatives. This +causes the solver to take very small steps around the discontinuity, and +sometimes leads to early stopping due to `dt <= dt_min`. The correct way to +handle such dynamics is to tell the solver about the discontinuity by a +root-finding equation, which can be modeling using [`ODESystem`](@ref)'s event +support. Please see the tutorial on [Callbacks and Events](@ref events) for +details and examples. diff --git a/docs/src/basics/ContextualVariables.md b/docs/src/basics/ContextualVariables.md index 24982f3be8..448ade884f 100644 --- a/docs/src/basics/ContextualVariables.md +++ b/docs/src/basics/ContextualVariables.md @@ -4,40 +4,82 @@ ModelingToolkit.jl has a system of contextual variable types which allows for helping the system transformation machinery do complex manipulations and automatic detection. The standard variable definition in ModelingToolkit.jl is the `@variable` which is defined by -[Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl). For example: +[Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/). For example: ```julia @variables x y(x) ``` -This is used for the "normal" variable of a given system, like the states of a -differential equation or objective function. All of the macros below support +This is used for the “normal” variable of a given system, like the unknowns of a +differential equation or objective function. All the macros below support the same syntax as `@variables`. ## Parameters All modeling projects have some form of parameters. `@parameters` marks a variable as being the parameter of some system, which allows automatic detection algorithms -to ignore such variables when attempting to find the states of a system. +to ignore such variables when attempting to find the unknowns of a system. -## Flow Variables (TODO) +## [Constants](@id constants) -In many engineering systems some variables act like "flows" while others do not. +Constants, defined by e.g. `@constants myconst1` are like parameters that: + + - always have a default value, which must be assigned when the constants are + declared + - do not show up in the list of parameters of a system. + +The intended use-cases for constants are: + + - representing literals (e.g., π) symbolically, which results in cleaner + Latexification of equations (avoids turning `d ~ 2π*r` into `d = 6.283185307179586 r`) + - allowing auto-generated unit conversion factors to live outside the list of + parameters + - representing fundamental constants (e.g., speed of light `c`) that should never + be adjusted inadvertently. + +## Wildcard Variable Arguments + +```julia +@variables u(..) +``` + +It is possible to define a dependent variable which is an open function as above, +for which its arguments must be specified each time it is used. This is useful with +PDEs for example, where one may need to use `u(t, x)` in the equations, but will +need to be able to write `u(t, 0.0)` to define a boundary condition at `x = 0`. + +## Variable metadata + +In many engineering systems, some variables act like “flows” while others do not. For example, in circuit models you have current which flows, and the related voltage which does not. Or in thermal models you have heat flows. In these cases, the `connect` statement enforces conservation of flow between all of the connected components. -For example, the following specifies that `x` is a 2x2 matrix of flow variables: +For example, the following specifies that `x` is a 2x2 matrix of flow variables +with the unit m^3/s: ```julia -@variables x[1:2,1:2] type=flow +@variables x[1:2, 1:2] [connect = Flow; unit = u"m^3/s"] ``` -## Stream Variables +ModelingToolkit defines `connect`, `unit`, `noise`, and `description` keys for +the metadata. One can get and set metadata by + +```julia +julia> @variables x [unit = u"m^3/s"]; -TODO +julia> hasmetadata(x, VariableUnit) +true -## Brownian Variables +julia> ModelingToolkit.get_unit(x) +m³ s⁻¹ + +julia> x = setmetadata(x, VariableUnit, u"m/s") +x + +julia> ModelingToolkit.get_unit(x) +m s⁻¹ +``` -TODO +See [Symbolic Metadata](@ref symbolic_metadata) for more details on variable metadata. diff --git a/docs/src/basics/Debugging.md b/docs/src/basics/Debugging.md new file mode 100644 index 0000000000..6e2d471461 --- /dev/null +++ b/docs/src/basics/Debugging.md @@ -0,0 +1,73 @@ +# Debugging + +Every (mortal) modeler writes models that contain mistakes or are susceptible to numerical errors in their hunt for the perfect model. +Debugging such errors is part of the modeling process, and ModelingToolkit includes some functionality that helps with this. + +For example, consider an ODE model with "dangerous" functions (here `√`): + +```@example debug +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables u1(t) u2(t) u3(t) +eqs = [D(u1) ~ -√(u1), D(u2) ~ -√(u2), D(u3) ~ -√(u3)] +defaults = [u1 => 1.0, u2 => 2.0, u3 => 3.0] +@named sys = ODESystem(eqs, t; defaults) +sys = structural_simplify(sys) +``` + +This problem causes the ODE solver to crash: + +```@repl debug +prob = ODEProblem(sys, [], (0.0, 10.0), []); +sol = solve(prob, Tsit5()); +``` + +This suggests *that* something went wrong, but not exactly *what* went wrong and *where* it did. +In such situations, the `debug_system` function is helpful: + +```@repl debug +dsys = debug_system(sys; functions = [sqrt]); +dprob = ODEProblem(dsys, [], (0.0, 10.0), []); +dsol = solve(dprob, Tsit5()); +``` + +Now we see that it crashed because `u1` decreased so much that it became negative and outside the domain of the `√` function. +We could have figured that out ourselves, but it is not always so obvious for more complex models. + +Suppose we also want to validate that `u1 + u2 >= 2.0`. We can do this via the assertions functionality. + +```@example debug +@mtkbuild sys = ODESystem(eqs, t; defaults, assertions = [(u1 + u2 >= 2.0) => "Oh no!"]) +``` + +The assertions must be an iterable of pairs, where the first element is the symbolic condition and +the second is a message to be logged when the condition fails. All assertions are added to the +generated code and will cause the solver to reject steps that fail the assertions. For systems such +as the above where the assertion is guaranteed to eventually fail, the solver will likely exit +with a `dtmin` failure.. + +```@example debug +prob = ODEProblem(sys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +``` + +We can use `debug_system` to log the failing assertions in each call to the RHS function. + +```@repl debug +dsys = debug_system(sys; functions = []); +dprob = ODEProblem(dsys, [], (0.0, 10.0)); +dsol = solve(dprob, Tsit5()); +``` + +Note the logs containing the failed assertion and corresponding message. To temporarily disable +logging in a system returned from `debug_system`, use `ModelingToolkit.ASSERTION_LOG_VARIABLE`. + +```@repl debug +dprob[ModelingToolkit.ASSERTION_LOG_VARIABLE] = false; +solve(dprob, Tsit5()); +``` + +```@docs +debug_system +``` diff --git a/docs/src/basics/DependencyGraphs.md b/docs/src/basics/DependencyGraphs.md index 67de5ea8f1..73fbbca9be 100644 --- a/docs/src/basics/DependencyGraphs.md +++ b/docs/src/basics/DependencyGraphs.md @@ -22,3 +22,9 @@ asdigraph eqeq_dependencies varvar_dependencies ``` + +# Miscellaneous + +```@docs +map_variables_to_equations +``` diff --git a/docs/src/basics/Events.md b/docs/src/basics/Events.md new file mode 100644 index 0000000000..23e1e6d7d1 --- /dev/null +++ b/docs/src/basics/Events.md @@ -0,0 +1,595 @@ +# [Event Handling and Callback Functions](@id events) + +ModelingToolkit provides several ways to represent system events, which enable +system state or parameters to be changed when certain conditions are satisfied, +or can be used to detect discontinuities. These events are ultimately converted +into DifferentialEquations.jl [`ContinuousCallback`s or +`DiscreteCallback`s](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/), +or into more specialized callback types from the +[DiffEqCallbacks.jl](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_library/) +library. + +[`ODESystem`](@ref)s and [`SDESystem`](@ref)s accept keyword arguments +`continuous_events` and `discrete_events` to symbolically encode continuous or +discrete callbacks. [`JumpSystem`](@ref)s currently support only +`discrete_events`. Continuous events are applied when a given condition becomes +zero, with root finding used to determine the time at which a zero crossing +occurred. Discrete events are applied when a condition tested after each +timestep evaluates to true. See the [DifferentialEquations +docs](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/) +for more detail. + +Events involve both a *condition* function (for the zero crossing or truth +test), and an *affect* function (for determining how to update the system when +the event occurs). These can both be specified symbolically, but a more [general +functional affect](@ref func_affects) representation is also allowed, as described +below. + +## Continuous Events + +The basic purely symbolic continuous event interface to encode *one* continuous +event is + +```julia +AbstractSystem(eqs, ...; continuous_events::Vector{Equation}) +AbstractSystem(eqs, ...; continuous_events::Pair{Vector{Equation}, Vector{Equation}}) +``` + +In the former, equations that evaluate to 0 will represent conditions that should +be detected by the integrator, for example to force stepping to times of +discontinuities. The latter allow modeling of events that have an effect on the +state, where the first entry in the `Pair` is a vector of equations describing +event conditions, and the second vector of equations describes the effect on the +state. Each affect equation must be of the form + +```julia +single_unknown_variable ~ expression_involving_any_variables_or_parameters +``` + +or + +```julia +single_parameter ~ expression_involving_any_variables_or_parameters +``` + +In this basic interface, multiple variables can be changed in one event, or +multiple parameters, but not a mix of parameters and variables. The latter can +be handled via more [general functional affects](@ref func_affects). + +Finally, multiple events can be encoded via a `Vector{Pair{Vector{Equation}, Vector{Equation}}}`. + +### Example: Friction + +The system below illustrates how continuous events can be used to model Coulomb +friction + +```@example events +using ModelingToolkit, OrdinaryDiffEq, Plots +using ModelingToolkit: t_nounits as t, D_nounits as D + +function UnitMassWithFriction(k; name) + @variables x(t)=0 v(t)=0 + eqs = [D(x) ~ v + D(v) ~ sin(t) - k * sign(v)] + ODESystem(eqs, t; continuous_events = [v ~ 0], name) # when v = 0 there is a discontinuity +end +@mtkbuild m = UnitMassWithFriction(0.7) +prob = ODEProblem(m, Pair[], (0, 10pi)) +sol = solve(prob, Tsit5()) +plot(sol) +``` + +### Example: Bouncing ball + +In the documentation for +[DifferentialEquations](https://docs.sciml.ai/DiffEqDocs/stable/features/callback_functions/#Example-1:-Bouncing-Ball), +we have an example where a bouncing ball is simulated using callbacks which have +an `affect!` on the state. We can model the same system using ModelingToolkit +like this + +```@example events +@variables x(t)=1 v(t)=0 + +root_eqs = [x ~ 0] # the event happens at the ground x(t) = 0 +affect = [v ~ -v] # the effect is that the velocity changes sign + +@mtkbuild ball = ODESystem([D(x) ~ v + D(v) ~ -9.8], t; continuous_events = root_eqs => affect) # equation => affect + +tspan = (0.0, 5.0) +prob = ODEProblem(ball, Pair[], tspan) +sol = solve(prob, Tsit5()) +@assert 0 <= minimum(sol[x]) <= 1e-10 # the ball never went through the floor but got very close +plot(sol) +``` + +### Test bouncing ball in 2D with walls + +Multiple events? No problem! This example models a bouncing ball in 2D that is enclosed by two walls at $y = \pm 1.5$. + +```@example events +@variables x(t)=1 y(t)=0 vx(t)=0 vy(t)=2 + +continuous_events = [[x ~ 0] => [vx ~ -vx] + [y ~ -1.5, y ~ 1.5] => [vy ~ -vy]] + +@mtkbuild ball = ODESystem( + [ + D(x) ~ vx, + D(y) ~ vy, + D(vx) ~ -9.8 - 0.1vx, # gravity + some small air resistance + D(vy) ~ -0.1vy + ], t; continuous_events) + +tspan = (0.0, 10.0) +prob = ODEProblem(ball, Pair[], tspan) + +sol = solve(prob, Tsit5()) +@assert 0 <= minimum(sol[x]) <= 1e-10 # the ball never went through the floor but got very close +@assert minimum(sol[y]) > -1.5 # check wall conditions +@assert maximum(sol[y]) < 1.5 # check wall conditions + +tv = sort([LinRange(0, 10, 200); sol.t]) +plot(sol(tv)[y], sol(tv)[x], line_z = tv) +vline!([-1.5, 1.5], l = (:black, 5), primary = false) +hline!([0], l = (:black, 5), primary = false) +``` + +### [Generalized functional affect support](@id func_affects) + +In some instances, a more flexible response to events is needed, which cannot be +encapsulated by symbolic equations. For example, a component may implement +complex behavior that is inconvenient or impossible to represent symbolically. +ModelingToolkit therefore supports regular Julia functions as affects: instead +of one or more equations, an affect is defined as a `tuple`: + +```julia +[x ~ 0] => (affect!, [v, x], [p, q], [discretes...], ctx) +``` + +where, `affect!` is a Julia function with the signature: `affect!(integ, u, p, ctx)`; `[u,v]` and `[p,q]` are the symbolic unknowns (variables) and parameters +that are accessed by `affect!`, respectively; `discretes` are the parameters modified by `affect!`, if any; +and `ctx` is any context that is passed to `affect!` as the `ctx` argument. + +`affect!` receives a [DifferentialEquations.jl +integrator](https://docs.sciml.ai/DiffEqDocs/stable/basics/integrator/) +as its first argument, which can then be used to access unknowns and parameters +that are provided in the `u` and `p` arguments (implemented as `NamedTuple`s). +The integrator can also be manipulated more generally to control solution +behavior, see the [integrator +interface](https://docs.sciml.ai/DiffEqDocs/stable/basics/integrator/) +documentation. In affect functions, we have that + +```julia +function affect!(integ, u, p, ctx) + # integ.t is the current time + # integ.u[u.v] is the value of the unknown `v` above + # integ.ps[p.q] is the value of the parameter `q` above +end +``` + +When accessing variables of a sub-system, it can be useful to rename them +(alternatively, an affect function may be reused in different contexts): + +```julia +[x ~ 0] => (affect!, [resistor₊v => :v, x], [p, q => :p2], [], ctx) +``` + +Here, the symbolic variable `resistor₊v` is passed as `v` while the symbolic +parameter `q` has been renamed `p2`. + +As an example, here is the bouncing ball example from above using the functional +affect interface: + +```@example events +sts = @variables x(t), v(t) +par = @parameters g = 9.8 +bb_eqs = [D(x) ~ v + D(v) ~ -g] + +function bb_affect!(integ, u, p, ctx) + integ.u[u.v] = -integ.u[u.v] +end + +reflect = [x ~ 0] => (bb_affect!, [v], [], [], nothing) + +@mtkbuild bb_sys = ODESystem(bb_eqs, t, sts, par, + continuous_events = reflect) + +u0 = [v => 0.0, x => 1.0] + +bb_prob = ODEProblem(bb_sys, u0, (0, 5.0)) +bb_sol = solve(bb_prob, Tsit5()) + +plot(bb_sol) +``` + +## Discrete events support + +In addition to continuous events, discrete events are also supported. The +general interface to represent a collection of discrete events is + +```julia +AbstractSystem(eqs, ...; discrete_events = [condition1 => affect1, condition2 => affect2]) +``` + +where conditions are symbolic expressions that should evaluate to `true` when an +individual affect should be executed. Here `affect1` and `affect2` are each +either a vector of one or more symbolic equations, or a functional affect, just +as for continuous events. As before, for any *one* event the symbolic affect +equations can either all change unknowns (i.e. variables) or all change +parameters, but one cannot currently mix unknown variable and parameter changes within one +individual event. + +### Example: Injecting cells into a population + +Suppose we have a population of `N(t)` cells that can grow and die, and at time +`t1` we want to inject `M` more cells into the population. We can model this by + +```@example events +@parameters M tinject α +@variables N(t) +Dₜ = Differential(t) +eqs = [Dₜ(N) ~ α - N] + +# at time tinject we inject M cells +injection = (t == tinject) => [N ~ N + M] + +u0 = [N => 0.0] +tspan = (0.0, 20.0) +p = [α => 100.0, tinject => 10.0, M => 50] +@mtkbuild osys = ODESystem(eqs, t, [N], [α, M, tinject]; discrete_events = injection) +oprob = ODEProblem(osys, u0, tspan, p) +sol = solve(oprob, Tsit5(); tstops = 10.0) +plot(sol) +``` + +Notice, with generic discrete events that we want to occur at one or more fixed +times, we need to also set the `tstops` keyword argument to `solve` to ensure +the integrator stops at that time. In the next section, we show how one can +avoid this by using a preset-time callback. + +Note that more general logical expressions can be built, for example, suppose we +want the event to occur at that time only if the solution is smaller than 50% of +its steady-state value (which is 100). We can encode this by modifying the event +to + +```@example events +injection = ((t == tinject) & (N < 50)) => [N ~ N + M] + +@mtkbuild osys = ODESystem(eqs, t, [N], [M, tinject, α]; discrete_events = injection) +oprob = ODEProblem(osys, u0, tspan, p) +sol = solve(oprob, Tsit5(); tstops = 10.0) +plot(sol) +``` + +Since the solution is *not* smaller than half its steady-state value at the +event time, the event condition now returns false. Here we used logical and, +`&`, instead of the short-circuiting logical and, `&&`, as currently the latter +cannot be used within symbolic expressions. + +Let's now also add a drug at time `tkill` that turns off production of new +cells, modeled by setting `α = 0.0` + +```@example events +@parameters tkill + +# we reset the first event to just occur at tinject +injection = (t == tinject) => [N ~ N + M] + +# at time tkill we turn off production of cells +killing = (t == tkill) => [α ~ 0.0] + +tspan = (0.0, 30.0) +p = [α => 100.0, tinject => 10.0, M => 50, tkill => 20.0] +@mtkbuild osys = ODESystem(eqs, t, [N], [α, M, tinject, tkill]; + discrete_events = [injection, killing]) +oprob = ODEProblem(osys, u0, tspan, p) +sol = solve(oprob, Tsit5(); tstops = [10.0, 20.0]) +plot(sol) +``` + +### Periodic and preset-time events + +Two important subclasses of discrete events are periodic and preset-time +events. + +A preset-time event is triggered at specific set times, which can be +passed in a vector like + +```julia +discrete_events = [[1.0, 4.0] => [v ~ -v]] +``` + +This will change the sign of `v` *only* at `t = 1.0` and `t = 4.0`. + +As such, our last example with treatment and killing could instead be modeled by + +```@example events +injection = [10.0] => [N ~ N + M] +killing = [20.0] => [α ~ 0.0] + +p = [α => 100.0, M => 50] +@mtkbuild osys = ODESystem(eqs, t, [N], [α, M]; + discrete_events = [injection, killing]) +oprob = ODEProblem(osys, u0, tspan, p) +sol = solve(oprob, Tsit5()) +plot(sol) +``` + +Notice, one advantage of using a preset-time event is that one does not need to +also specify `tstops` in the call to solve. + +A periodic event is triggered at fixed intervals (e.g. every Δt seconds). To +specify a periodic interval, pass the interval as the condition for the event. +For example, + +```julia +discrete_events = [1.0 => [v ~ -v]] +``` + +will change the sign of `v` at `t = 1.0`, `2.0`, ... + +Finally, we note that to specify an event at precisely one time, say 2.0 below, +one must still use a vector + +```julia +discrete_events = [[2.0] => [v ~ -v]] +``` + +## Saving discrete values + +Time-dependent parameters which are updated in callbacks are termed as discrete variables. +ModelingToolkit enables automatically saving the timeseries of these discrete variables, +and indexing the solution object to obtain the saved timeseries. Consider the following +example: + +```@example events +@variables x(t) +@parameters c(t) + +@mtkbuild sys = ODESystem( + D(x) ~ c * cos(x), t, [x], [c]; discrete_events = [1.0 => [c ~ c + 1]]) + +prob = ODEProblem(sys, [x => 0.0], (0.0, 2pi), [c => 1.0]) +sol = solve(prob, Tsit5()) +sol[c] +``` + +The solution object can also be interpolated with the discrete variables + +```@example events +sol([1.0, 2.0], idxs = [c, c * cos(x)]) +``` + +Note that only time-dependent parameters will be saved. If we repeat the above example with +this change: + +```@example events +@variables x(t) +@parameters c + +@mtkbuild sys = ODESystem( + D(x) ~ c * cos(x), t, [x], [c]; discrete_events = [1.0 => [c ~ c + 1]]) + +prob = ODEProblem(sys, [x => 0.0], (0.0, 2pi), [c => 1.0]) +sol = solve(prob, Tsit5()) +sol.ps[c] # sol[c] will error, since `c` is not a timeseries value +``` + +It can be seen that the timeseries for `c` is not saved. + +## [(Experimental) Imperative affects](@id imp_affects) + +The `ImperativeAffect` can be used as an alternative to the aforementioned functional affect form. Note +that `ImperativeAffect` is still experimental; to emphasize this, we do not export it and it should be +included as `ModelingToolkit.ImperativeAffect`. `ImperativeAffect` aims to simplify the manipulation of +system state. + +We will use two examples to describe `ImperativeAffect`: a simple heater and a quadrature encoder. +These examples will also demonstrate advanced usage of `ModelingToolkit.SymbolicContinuousCallback`, +the low-level interface of the tuple form converts into that allows control over the SciMLBase-level +event that is generated for a continuous event. + +### [Heater](@id heater_events) + +Bang-bang control of a heater connected to a leaky plant requires hysteresis in order to prevent rapid control oscillation. + +```@example events +@variables temp(t) +params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on(t)::Bool=false +eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage +] +``` + +Our plant is simple. We have a heater that's turned on and off by the time-indexed parameter `furnace_on` +which adds `furnace_power` forcing to the system when enabled. We then leak heat proportional to `leakage` +as a function of the square of the current temperature. + +We need a controller with hysteresis to control the plant. We wish the furnace to turn on when the temperature +is below `furnace_on_threshold` and off when above `furnace_off_threshold`, while maintaining its current state +in between. To do this, we create two continuous callbacks: + +```@example events +using Setfield +furnace_disable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) +furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = true + end) +``` + +We're using the explicit form of `SymbolicContinuousCallback` here, though +so far we aren't using anything that's not possible with the implicit interface. +You can also write + +```julia +[temp ~ furnace_off_threshold] => ModelingToolkit.ImperativeAffect(modified = (; + furnace_on)) do x, o, i, c + @set! x.furnace_on = false +end +``` + +and it would work the same. + +The `ImperativeAffect` is the larger change in this example. `ImperativeAffect` has the constructor signature + +```julia +ImperativeAffect(f::Function; modified::NamedTuple, observed::NamedTuple, ctx) +``` + +that accepts the function to call, a named tuple of both the names of and symbolic values representing +values in the system to be modified, a named tuple of the values that are merely observed (that is, used from +the system but not modified), and a context that's passed to the affect function. + +In our example, each event merely changes whether the furnace is on or off. Accordingly, we pass a `modified` tuple +`(; furnace_on)` (creating a `NamedTuple` equivalent to `(furnace_on = furnace_on)`). `ImperativeAffect` will then +evaluate this before calling our function to fill out all of the numerical values, then apply them back to the system +once our affect function returns. Furthermore, it will check that it is possible to do this assignment. + +The function given to `ImperativeAffect` needs to have the signature: + +```julia +f(modified::NamedTuple, observed::NamedTuple, ctx, integrator)::NamedTuple +``` + +The function `f` will be called with `observed` and `modified` `NamedTuple`s that are derived from their respective `NamedTuple` definitions. +In our example, if `furnace_on` is `false`, then the value of the `x` that's passed in as `modified` will be `(furnace_on = false)`. +The modified values should be passed out in the same format: to set `furnace_on` to `true` we need to return a tuple `(furnace_on = true)`. +The examples does this with Setfield, recreating the result tuple before returning it; the returned tuple may optionally be missing values as +well, in which case those values will not be written back to the problem. + +Accordingly, we can now interpret the `ImperativeAffect` definitions to mean that when `temp = furnace_off_threshold` we +will write `furnace_on = false` back to the system, and when `temp = furnace_on_threshold` we will write `furnace_on = true` back +to the system. + +```@example events +@named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_disable, furnace_enable]) +ss = structural_simplify(sys) +prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +plot(sol) +hline!([sol.ps[furnace_off_threshold], sol.ps[furnace_on_threshold]], + l = (:black, 1), primary = false) +``` + +Here we see exactly the desired hysteresis. The heater starts on until the temperature hits +`furnace_off_threshold`. The temperature then bleeds away until `furnace_on_threshold` at which +point the furnace turns on again until `furnace_off_threshold` and so on and so forth. The controller +is effectively regulating the temperature of the plant. + +### [Quadrature Encoder](@id quadrature) + +For a more complex application we'll look at modeling a quadrature encoder attached to a shaft spinning at a constant speed. +Traditionally, a quadrature encoder is built out of a code wheel that interrupts the sensors at constant intervals and two sensors slightly out of phase with one another. +A state machine can take the pattern of pulses produced by the two sensors and determine the number of steps that the shaft has spun. The state machine takes the new value +from each sensor and the old values and decodes them into the direction that the wheel has spun in this step. + +```@example events +@variables theta(t) omega(t) +params = @parameters qA=0 qB=0 hA=0 hB=0 cnt::Int=0 +eqs = [D(theta) ~ omega + omega ~ 1.0] +``` + +Our continuous-time system is extremely simple. We have two unknown variables `theta` for the angle of the shaft +and `omega` for the rate at which it's spinning. We then have parameters for the state machine `qA, qB, hA, hB` +(corresponding to the current quadrature of the A/B sensors and the historical ones) and a step count `cnt`. + +We'll then implement the decoder as a simple Julia function. + +```@example events +function decoder(oldA, oldB, newA, newB) + state = (oldA, oldB, newA, newB) + if state == (0, 0, 1, 0) || state == (1, 0, 1, 1) || state == (1, 1, 0, 1) || + state == (0, 1, 0, 0) + return 1 + elseif state == (0, 0, 0, 1) || state == (0, 1, 1, 1) || state == (1, 1, 1, 0) || + state == (1, 0, 0, 0) + return -1 + elseif state == (0, 0, 0, 0) || state == (0, 1, 0, 1) || state == (1, 0, 1, 0) || + state == (1, 1, 1, 1) + return 0 + else + return 0 # err is interpreted as no movement + end +end +``` + +Based on the current and old state, this function will return 1 if the wheel spun in the positive direction, +-1 if in the negative, and 0 otherwise. + +The encoder state advances when the occlusion begins or ends. We model the +code wheel as simply detecting when `cos(100*theta)` is 0; if we're at a positive +edge of the 0 crossing, then we interpret that as occlusion (so the discrete `qA` goes to 1). Otherwise, if `cos` is +going negative, we interpret that as lack of occlusion (so the discrete goes to 0). The decoder function is +then invoked to update the count with this new information. + +We can implement this in one of two ways: using edge sign detection or right root finding. For exposition, we +will implement each sensor differently. + +For sensor A, we're using the edge detection method. By providing a different affect to `SymbolicContinuousCallback`'s +`affect_neg` argument, we can specify different behaviour for the negative crossing vs. the positive crossing of the root. +In our encoder, we interpret this as occlusion or nonocclusion of the sensor, update the internal state, and tick the decoder. + +```@example events +qAevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta) ~ 0], + ModelingToolkit.ImperativeAffect((; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 1 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 0 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end) +``` + +The other way we can implement a sensor is by changing the root find. +Normally, we use left root finding; the affect will be invoked instantaneously _before_ +the root is crossed. This makes it trickier to figure out what the new state is. +Instead, we can use right root finding: + +```@example events +qBevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta - π / 2) ~ 0], + ModelingToolkit.ImperativeAffect((; qB, hA, hB, cnt), (; qA, theta)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = clamp(sign(cos(100 * o.theta - π / 2)), 0.0, 1.0) + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end; rootfind = SciMLBase.RightRootFind) +``` + +Here, sensor B is located `π / 2` behind sensor A in angular space, so we're adjusting our +trigger function accordingly. We here ask for right root finding on the callback, so we know +that the value of said function will have the "new" sign rather than the old one. Thus, we can +determine the new state of the sensor from the sign of the indicator function evaluated at the +affect activation point, with -1 mapped to 0. + +We can now simulate the encoder. + +```@example events +@named sys = ODESystem( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) +ss = structural_simplify(sys) +prob = ODEProblem(ss, [theta => 0.0], (0.0, pi)) +sol = solve(prob, Tsit5(); dtmax = 0.01) +sol.ps[cnt] +``` + +`cos(100*theta)` will have 200 crossings in the half rotation we've gone through, so the encoder would notionally count 200 steps. +Our encoder counts 198 steps (it loses one step to initialization and one step due to the final state falling squarely on an edge). diff --git a/docs/src/basics/FAQ.md b/docs/src/basics/FAQ.md index 9eb1c70ea8..10671299c6 100644 --- a/docs/src/basics/FAQ.md +++ b/docs/src/basics/FAQ.md @@ -1,23 +1,315 @@ # Frequently Asked Questions +## Why are my parameters some obscure object? + +In ModelingToolkit.jl version 9, the parameter vector was replaced with a custom +`MTKParameters` object, whose internals are intentionally undocumented and subject +to change without a breaking release. This enables us to efficiently store and generate +code for parameters of multiple types. To obtain parameter values use +[SymbolicIndexingInterface.jl](https://github.com/SciML/SymbolicIndexingInterface.jl/) or +[SciMLStructures.jl](https://github.com/SciML/SciMLStructures.jl/). For example: + +```julia +prob.ps[lorenz.β] # obtains the value of parameter `β`. Note the `.ps` instead of `.p` +getβ = getp(prob, lorenz.β) # returns a function that can fetch the value of `β` +getβ(sol) # can be used on any object that is based off of the same system +getβ(prob) +``` + +Indexes into the `MTKParameters` object take the form of `ParameterIndex` objects, which +are similarly undocumented. Following is the list of behaviors that should be relied on for +`MTKParameters`: + + - It implements the SciMLStructures interface. + - It can be queried for parameters using functions returned from + `SymbolicIndexingInterface.getp`. + - `getindex(::MTKParameters, ::ParameterIndex)` can be used to obtain the value of a + parameter with the given index. + - `setindex!(::MTKParameters, value, ::ParameterIndex)` can be used to set the value of a + parameter with the given index. + - `parameter_values(sys, sym)` will return a `ParameterIndex` object if `sys` has been + `complete`d (through `structural_simplify`, `complete` or `@mtkbuild`). + - `copy(::MTKParameters)` is defined and duplicates the parameter object, including the + memory used by the underlying buffers. + +Any other behavior of `MTKParameters` (other `getindex`/`setindex!` methods, etc.) is an +undocumented internal and should not be relied upon. + +## How do I use non-numeric/array-valued parameters? + +In ModelingToolkit.jl version 9, parameters are required to have a `symtype` matching +the type of their values. For example, this will error during problem construction: + +```julia +@parameters p = [1, 2, 3] +``` + +Since by default parameters have a `symtype` of `Real` (which is interpreted as `Float64`) +but the default value given to it is a `Vector{Int}`. For array-valued parameters, use the +following syntax: + +```julia +@parameters p[1:n, 1:m]::T # `T` is the `eltype` of the parameter array +@parameters p::T # `T` is the type of the array +``` + +The former approach is preferred, since the size of the array is known. If the array is not +a `Base.Array` or the size is not known during model construction, the second syntax is +required. + +The same principle applies to any parameter type that is not `Float64`. + +```julia +@parameters p1::Int # integer-valued +@parameters p2::Bool # boolean-valued +@parameters p3::MyCustomStructType # non-numeric +@parameters p4::ComponentArray{...} # non-standard array +``` + ## Getting the index for a symbol -Since **ordering of symbols is not guaranteed after symbolic transformations**, -one should normally refer to values by their name. For example, `sol[lorenz.x]` -from the solution. But what if you need to get the index? The following helper -function will do the trick: +Ordering of symbols is not guaranteed after symbolic transformations, and parameters +are now stored in a custom `MTKParameters` object instead of a vector. Thus, values +should be referred to by their name. For example `sol[lorenz.x]`. To obtain the index, +use the following functions from +[SymbolicIndexingInterface.jl](https://github.com/SciML/SymbolicIndexingInterface.jl/): ```julia -indexof(sym,syms) = findfirst(isequal(sym),syms) -indexof(σ,parameters(sys)) +variable_index(sys, sym) +parameter_index(sys, sym) +``` + +Note that while the variable index will be an integer, the parameter index is a struct of +type `ParameterIndex` whose internals should not be relied upon. + +## Can I index with strings? + +Strings are not considered symbolic variables, and thus cannot directly be used for symbolic +indexing. However, ModelingToolkit does provide a method to parse the string representation of +a variable, given the system in which that variable exists. + +```@docs +ModelingToolkit.parse_variable ``` ## Transforming value maps to arrays ModelingToolkit.jl allows (and recommends) input maps like `[x => 2.0, y => 3.0]` because symbol ordering is not guaranteed. However, what if you want to get the -lowered array? You can use the internal function `varmap_to_vars`. For example: +lowered array? You can use the internal function `varmap_to_vars` for unknowns. +and the `MTKParameters` constructor for parameters. For example: ```julia -pnew = varmap_to_vars([β=>3.0, c=>10.0, γ=>2.0],parameters(sys)) +unew = varmap_to_vars([x => 1.0, y => 2.0, z => 3.0], unknowns(sys)) +pnew = ModelingToolkit.MTKParameters(sys, [β => 3.0, c => 10.0, γ => 2.0], unew) +``` + +## How do I handle `if` statements in my symbolic forms? + +For statements that are in the `if then else` form, use `Base.ifelse` from the +to represent the code in a functional form. For handling direct `if` statements, +you can use equivalent boolean mathematical expressions. For example, `if x > 0 ...` +can be implemented as just `(x > 0) * `, where if `x <= 0` then the boolean will +evaluate to `0` and thus the term will be excluded from the model. + +## ERROR: TypeError: non-boolean (Num) used in boolean context? + +If you see the error: + +``` +ERROR: TypeError: non-boolean (Num) used in boolean context +``` + +then it's likely you are trying to trace through a function which cannot be +directly represented in Julia symbols. The techniques to handle this problem, +such as `@register_symbolic`, are described in detail +[in the Symbolics.jl documentation](https://symbolics.juliasymbolics.org/dev/manual/faq/#Transforming-my-function-to-a-symbolic-equation-has-failed.-What-do-I-do?-1). + +## Using ModelingToolkit with Optimization / Automatic Differentiation + +If you are using ModelingToolkit inside a loss function and are having issues with +mixing MTK with automatic differentiation, getting performance, etc… don't! Instead, use +MTK outside the loss function to generate the code, and then use the generated code +inside the loss function. + +For example, let's say you were building ODEProblems in the loss function like: + +```julia +function loss(p) + prob = ODEProblem(sys, [], [p1 => p[1], p2 => p[2]]) + sol = solve(prob, Tsit5()) + sum(abs2, sol) +end +``` + +Since `ODEProblem` on a MTK `sys` will have to generate code, this will be slower than +caching the generated code, and will require automatic differentiation to go through the +code generation process itself. All of this is unnecessary. Instead, generate the problem +once outside the loss function, and update the parameter values inside the loss function: + +```julia +prob = ODEProblem(sys, [], [p1 => p[1], p2 => p[2]]) +function loss(p) + # update parameters + sol = solve(prob, Tsit5()) + sum(abs2, sol) +end +``` + +If a subset of the parameters are optimized, `setp` from SymbolicIndexingInterface.jl +should be used to generate an efficient function for setting parameter values. For example: + +```julia +using SymbolicIndexingInterface + +prob = ODEProblem(sys, [], [p1 => p[1], p2 => p[2]]) +setter! = setp(sys, [p1, p2]) +function loss(p) + setter!(prob, p) + sol = solve(prob, Tsit5()) + sum(abs2, sol) +end +``` + +[SciMLStructures.jl](https://github.com/SciML/SciMLStructures.jl/) can be leveraged to +obtain all the parameters for optimization using the `Tunable` portion. By default, all +numeric or numeric array parameters are marked as tunable, unless explicitly marked as +`tunable = false` in the variable metadata. + +```julia +using SciMLStructures: replace!, Tunable + +prob = ODEProblem(sys, [], [p1 => p[1], p2 => p[2]]) +function loss(p) + replace!(Tunable(), prob.p, p) + sol = solve(prob, Tsit5()) + sum(abs2, sol) +end + +p, replace, alias = SciMLStructures.canonicalize(Tunable(), prob.p) +# p is an `AbstractVector` which can be optimized +# if `alias == true`, then `p` aliases the memory used by `prob.p`, so +# changes to the array will be reflected in parameter values +``` + +# ERROR: ArgumentError: SymbolicUtils.BasicSymbolic{Real}[xˍt(t)] are missing from the variable map. + +This error can come up after running `structural_simplify` on a system that generates dummy derivatives (i.e. variables with `ˍt`). For example, here even though all the variables are defined with initial values, the `ODEProblem` generation will throw an error that defaults are missing from the variable map. + +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +sts = @variables x1(t)=0.0 x2(t)=0.0 x3(t)=0.0 x4(t)=0.0 +eqs = [x1 + x2 + 1 ~ 0 + x1 + x2 + x3 + 2 ~ 0 + x1 + D(x3) + x4 + 3 ~ 0 + 2 * D(D(x1)) + D(D(x2)) + D(D(x3)) + D(x4) + 4 ~ 0] +@named sys = ODESystem(eqs, t) +sys = structural_simplify(sys) +prob = ODEProblem(sys, [], (0, 1)) +``` + +We can solve this problem by using the `missing_variable_defaults()` function + +```julia +prob = ODEProblem(sys, ModelingToolkit.missing_variable_defaults(sys), (0, 1)) +``` + +This function provides 0 for the default values, which is a safe assumption for dummy derivatives of most models. However, the 2nd argument allows for a different default value or values to be used if needed. + +``` +julia> ModelingToolkit.missing_variable_defaults(sys, [1,2,3]) +3-element Vector{Pair}: + x1ˍt(t) => 1 + x2ˍtt(t) => 2 + x3ˍtt(t) => 3 +``` + +## Change the unknown variable vector type + +Use the `u0_constructor` keyword argument to map an array to the desired +container type. For example: + +```julia +using ModelingToolkit, StaticArrays +using ModelingToolkit: t_nounits as t, D_nounits as D + +sts = @variables x1(t) = 0.0 +eqs = [D(x1) ~ 1.1 * x1] +@mtkbuild sys = ODESystem(eqs, t) +prob = ODEProblem{false}(sys, [], (0, 1); u0_constructor = x -> SVector(x...)) +``` + +## Using a custom independent variable + +When possible, we recommend `using ModelingToolkit: t_nounits as t, D_nounits as D` as the independent variable and its derivative. +However, if you want to use your own, you can do so: + +```julia +using ModelingToolkit + +@independent_variables x +D = Differential(x) +@variables y(x) +@named sys = ODESystem([D(y) ~ x], x) +``` + +## Ordering of tunable parameters + +Tunable parameters are floating point parameters, not used in callbacks and not marked with `tunable = false` in their metadata. These are expected to be used with AD +and optimization libraries. As such, they are stored together in one `Vector{T}`. To obtain the ordering of tunable parameters in this buffer, use: + +```@docs +tunable_parameters +``` + +If you have an array in which a particular dimension is in the order of tunable parameters (e.g. the jacobian with respect to tunables) then that dimension of the +array can be reordered into the required permutation using the symbolic variables: + +```@docs +reorder_dimension_by_tunables! +reorder_dimension_by_tunables +``` + +For example: + +```@example reorder +using ModelingToolkit + +@parameters p q[1:3] r[1:2, 1:2] + +@named sys = ODESystem(Equation[], ModelingToolkit.t_nounits, [], [p, q, r]) +sys = complete(sys) +``` + +The canonicalized tunables portion of `MTKParameters` will be in the order of tunables: + +```@example reorder +using SciMLStructures: canonicalize, Tunable + +ps = MTKParameters(sys, [p => 1.0, q => [2.0, 3.0, 4.0], r => [5.0 6.0; 7.0 8.0]]) +arr = canonicalize(Tunable(), ps)[1] +``` + +We can reorder this to contain the value for `p`, then all values for `q`, then for `r` using: + +```@example reorder +reorder_dimension_by_tunables(sys, arr, [p, q, r]) +``` + +This also works with interleaved subarrays of symbolics: + +```@example reorder +reorder_dimension_by_tunables(sys, arr, [q[1], r[1, :], q[2], r[2, :], q[3], p]) +``` + +And arbitrary dimensions of higher dimensional arrays: + +```@example reorder +highdimarr = stack([i * arr for i in 1:5]; dims = 1) +``` + +```@example reorder +reorder_dimension_by_tunables(sys, highdimarr, [q[1:2], r[1, :], q[3], r[2, :], p]; dim = 2) ``` diff --git a/docs/src/basics/InputOutput.md b/docs/src/basics/InputOutput.md new file mode 100644 index 0000000000..4dc5a3d50f --- /dev/null +++ b/docs/src/basics/InputOutput.md @@ -0,0 +1,99 @@ +# [Input output](@id inputoutput) + +An input-output system is a system on the form + +```math +\begin{aligned} +M \dot x &= f(x, u, p, t) \\ +y &= g(x, u, p, t) +\end{aligned} +``` + +where ``x`` is the state, ``u`` is the input and ``y`` is an output (in some contexts called an _observed variables_ in MTK). + +While many uses of ModelingToolkit for simulation do not require the user to think about inputs and outputs (IO), there are certain situations in which handling IO explicitly may be important, such as + + - Linearization + - Control design + - System identification + - FMU export + - Real-time simulation with external data inputs + - Custom interfacing with other simulation tools + +This documentation page lists utilities that are useful for working with inputs and outputs in ModelingToolkit. + +## Generating a dynamics function with inputs, ``f`` + +ModelingToolkit can generate the dynamics of a system, the function ``M\dot x = f(x, u, p, t)`` above, such that the user can pass not only the state ``x`` and parameters ``p`` but also an external input ``u``. To this end, the function [`ModelingToolkit.generate_control_function`](@ref) exists. + +This function takes a vector of variables that are to be considered inputs, i.e., part of the vector ``u``. Alongside returning the function ``f``, [`ModelingToolkit.generate_control_function`](@ref) also returns the chosen state realization of the system after simplification. This vector specifies the order of the state variables ``x``, while the user-specified vector `u` specifies the order of the input variables ``u``. + +!!! note "Un-simplified system" + + This function expects `sys` to be un-simplified, i.e., `structural_simplify` or `@mtkbuild` should not be called on the system before passing it into this function. `generate_control_function` calls a special version of `structural_simplify` internally. + +### Example: + +The following example implements a simple first-order system with an input `u` and state `x`. The function `f` is generated using `generate_control_function`, and the function `f` is then tested with random input and state values. + +```@example inputoutput +using ModelingToolkit +import ModelingToolkit: t_nounits as t, D_nounits as D +@variables x(t)=0 u(t)=0 y(t) +@parameters k = 1 +eqs = [D(x) ~ -k * (x + u) + y ~ x] + +@named sys = ODESystem(eqs, t) +f, x_sym, ps = ModelingToolkit.generate_control_function(sys, [u], simplify = true); +nothing # hide +``` + +We can inspect the state realization chosen by MTK + +```@example inputoutput +x_sym +``` + +as expected, `x` is chosen as the state variable. + +```@example inputoutput +using Test # hide +@test isequal(x_sym[], x) # hide +@test isequal(ps, [k]) # hide +nothing # hide +``` + +Now we can test the generated function `f` with random input and state values + +```@example inputoutput +p = [1] +x = [rand()] +u = [rand()] +@test f[1](x, u, p, 1) ≈ -p[] * (x + u) # Test that the function computes what we expect D(x) = -k*(x + u) +``` + +## Generating an output function, ``g`` + +ModelingToolkit can also generate a function that computes a specified output of a system, the function ``y = g(x, u, p, t)`` above. This is done using the function [`build_explicit_observed_function`](@ref). When generating an output function, the user must specify the output variable(s) of interest, as well as any inputs if inputs are relevant to compute the output. + +The order of the user-specified output variables determines the order of the output vector ``y``. + +## Input-output variable metadata + +See [Symbolic Metadata](@ref symbolic_metadata). Metadata specified when creating variables is not directly used by any of the functions above, but the user can use the accessor functions `ModelingToolkit.inputs(sys)` and `ModelingToolkit.outputs(sys)` to obtain all variables with such metadata for passing to the functions above. The presence of this metadata is not required for any IO functionality and may be omitted. + +## Linearization + +See [Linearization](@ref linearization). + +## Docstrings + +```@index +Pages = ["InputOutput.md"] +``` + +```@docs +ModelingToolkit.generate_control_function +ModelingToolkit.build_explicit_observed_function +``` diff --git a/docs/src/basics/Linearization.md b/docs/src/basics/Linearization.md new file mode 100644 index 0000000000..1c06ce72d4 --- /dev/null +++ b/docs/src/basics/Linearization.md @@ -0,0 +1,161 @@ +# [Linearization](@id linearization) + +A nonlinear dynamical system with state (differential and algebraic) ``x`` and input signals ``u`` + +```math +M \dot x = f(x, u) +``` + +can be linearized using the function [`linearize`](@ref) to produce a linear statespace system on the form + +```math +\begin{aligned} +\dot x &= Ax + Bu\\ +y &= Cx + Du +\end{aligned} +``` + +The `linearize` function expects the user to specify the inputs ``u`` and the outputs ``y`` using the syntax shown in the example below. The system model is *not* supposed to be simplified before calling `linearize`: + +## Example + +```@example LINEARIZE +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables x(t)=0 y(t) u(t) r(t)=0 +@parameters kp = 1 + +eqs = [u ~ kp * (r - y) # P controller + D(x) ~ -x + u # First-order plant + y ~ x] # Output equation + +@named sys = ODESystem(eqs, t) # Do not call @mtkbuild when linearizing +matrices, simplified_sys = linearize(sys, [r], [y]) # Linearize from r to y +matrices +``` + +The named tuple `matrices` contains the matrices of the linear statespace representation, while `simplified_sys` is an `ODESystem` that, among other things, indicates the unknown variable order in the linear system through + +```@example LINEARIZE +using ModelingToolkit: inputs, outputs +[unknowns(simplified_sys); inputs(simplified_sys); outputs(simplified_sys)] +``` + +!!! note "Inputs must be unconnected" + + The model above has 4 variables but only three equations, there is no equation specifying the value of `r` since `r` is an input. This means that only unbalanced models can be linearized, or in other words, models that are balanced and can be simulated _cannot_ be linearized. To learn more about this, see [How to linearize a ModelingToolkit model (YouTube)](https://www.youtube.com/watch?v=-XOux-2XDGI&t=395s). Also see [ModelingToolkitStandardLibrary: Linear analysis](https://docs.sciml.ai/ModelingToolkit/stable/tutorials/linear_analysis/) for utilities that make linearization of completed models easier. + +!!! note "Un-simplified system" + + Linearization expects `sys` to be un-simplified, i.e., `structural_simplify` or `@mtkbuild` should not be called on the system before linearizing. + +## Operating point + +The operating point to linearize around can be specified with the keyword argument `op` like this: `op = Dict(x => 1, r => 2)`. The operating point may include specification of unknown variables, input variables and parameters. For variables that are not specified in `op`, the default value specified in the model will be used if available, if no value is specified, an error is thrown. + +## Batch linearization and algebraic variables + +If linearization is to be performed around multiple operating points, the simplification of the system has to be carried out a single time only. To facilitate this, the lower-level function [`ModelingToolkit.linearization_function`](@ref) is available. This function further allows you to obtain separate Jacobians for the differential and algebraic parts of the model. For ODE models without algebraic equations, the statespace representation above is available from the output of `linearization_function` as `A, B, C, D = f_x, f_u, h_x, h_u`. + +All variables that will be fixed by an operating point _must_ be provided in the operating point to `linearization_function`. For example, if the operating points fix the value of +`x`, `y` and `z` then an operating point with constant values for these variables (e.g. `Dict(x => 1.0, y => 1.0, z => 1.0)`) must be provided. The constant values themselves +do not matter and can be changed by subsequent operating points. + +One approach to batch linearization would be to call `linearize` in a loop, providing a different operating point each time. For example: + +```@example LINEARIZE +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Blocks + +@parameters k=10 k3=2 c=1 +@variables x(t)=0 [bounds = (-0.5, 1.5)] +@variables v(t) = 0 + +@named y = Blocks.RealOutput() +@named u = Blocks.RealInput() + +eqs = [D(x) ~ v + D(v) ~ -k * x - k3 * x^3 - c * v + 10u.u + y.u ~ x] + +@named duffing = ODESystem(eqs, t, systems = [y, u], defaults = [u.u => 0]) + +# pass a constant value for `x`, since it is the variable we will change in operating points +linfun, simplified_sys = linearization_function(duffing, [u.u], [y.u]; op = Dict(x => NaN)); + +println(linearize(simplified_sys, linfun; op = Dict(x => 1.0))) +println(linearize(simplified_sys, linfun; op = Dict(x => 0.0))) + +@time linearize(simplified_sys, linfun; op = Dict(x => 0.0)) + +nothing # hide +``` + +However, this route is still expensive since it has to repeatedly process the symbolic map provided to `op`. `linearize` is simply a wrapper for creating and solving a +[`ModelingToolkit.LinearizationProblem`](@ref). This object is symbolically indexable, and can thus integrate with SymbolicIndexingInterface.jl for fast updates. + +```@example LINEARIZE +using SymbolicIndexingInterface + +# The second argument is the value of the independent variable `t`. +linprob = LinearizationProblem(linfun, 1.0) +# It can be mutated +linprob.t = 0.0 +# create a setter function to update `x` efficiently +setter! = setu(linprob, x) + +function fast_linearize!(problem, setter!, value) + setter!(problem, value) + solve(problem) +end + +println(fast_linearize!(linprob, setter!, 1.0)) +println(fast_linearize!(linprob, setter!, 0.0)) + +@time fast_linearize!(linprob, setter!, 1.0) + +nothing # hide +``` + +Note that `linprob` above can be interacted with similar to a normal `ODEProblem`. + +```@repl LINEARIZE +prob[x] +prob[x] = 1.5 +prob[x] +``` + +## Symbolic linearization + +The function [`ModelingToolkit.linearize_symbolic`](@ref) works similar to [`ModelingToolkit.linearize`](@ref) but returns symbolic rather than numeric Jacobians. Symbolic linearization have several limitations and no all systems that can be linearized numerically can be linearized symbolically. + +## Input derivatives + +Physical systems are always *proper*, i.e., they do not differentiate causal inputs. However, ModelingToolkit allows you to model non-proper systems, such as inverse models, and may sometimes fail to find a realization of a proper system on proper form. In these situations, `linearize` may throw an error mentioning + +``` +Input derivatives appeared in expressions (-g_z\g_u != 0) +``` + +This means that to simulate this system, some order of derivatives of the input is required. To allow `linearize` to proceed in this situation, one may pass the keyword argument `allow_input_derivatives = true`, in which case the resulting model will have twice as many inputs, ``2n_u``, where the last ``n_u`` inputs correspond to ``\dot u``. + +If the modeled system is actually proper (but MTK failed to find a proper realization), further numerical simplification can be applied to the resulting statespace system to obtain a proper form. Such simplification is currently available in the package [ControlSystemsMTK](https://juliacontrol.github.io/ControlSystemsMTK.jl/dev/#Internals:-Transformation-of-non-proper-models-to-proper-statespace-form). + +## Tools for linear analysis + +ModelingToolkit contains a set of [tools for more advanced linear analysis](https://docs.sciml.ai/ModelingToolkit/stable/tutorials/linear_analysis/). These can be used to make it easier to work with and analyze causal models, such as control and signal-processing systems. + +Also see [ControlSystemsMTK.jl](https://juliacontrol.github.io/ControlSystemsMTK.jl/dev/) for an interface to [ControlSystems.jl](https://github.com/JuliaControl/ControlSystems.jl) that contains tools for linear analysis and frequency-domain analysis. + +## Docstrings + +```@index +Pages = ["Linearization.md"] +``` + +```@docs +linearize +ModelingToolkit.linearize_symbolic +ModelingToolkit.linearization_function +ModelingToolkit.LinearizationProblem +``` diff --git a/docs/src/basics/MTKLanguage.md b/docs/src/basics/MTKLanguage.md new file mode 100644 index 0000000000..e91f2bcb67 --- /dev/null +++ b/docs/src/basics/MTKLanguage.md @@ -0,0 +1,552 @@ +# [ModelingToolkit Language: Modeling with `@mtkmodel`, `@connectors` and `@mtkbuild`](@id mtk_language) + +## MTK Model + +MTK represents components and connectors with `Model`. + +```@docs +ModelingToolkit.Model +``` + +## Components + +Components are models from various domains. These models contain unknowns and their +equations. + +### [Defining components with `@mtkmodel`](@id mtkmodel) + +`@mtkmodel` is a convenience macro to define components. It returns +`ModelingToolkit.Model`, which includes a system constructor (`ODESystem` by +default), a `structure` dictionary with metadata, and flag `isconnector` which is +set to `false`. + +### What can an MTK-Model definition have? + +`@mtkmodel` definition contains begin blocks of + + - `@description`: for describing the whole system with a human-readable string + - `@components`: for listing sub-components of the system + - `@constants`: for declaring constants + - `@defaults`: for passing `defaults` to the system + - `@equations`: for the list of equations + - `@extend`: for extending a base system and unpacking its unknowns + - `@icon` : for embedding the model icon + - `@parameters`: for specifying the symbolic parameters + - `@structural_parameters`: for specifying non-symbolic parameters + - `@variables`: for specifying the unknowns + - `@continuous_events`: for specifying a list of continuous events + - `@discrete_events`: for specifying a list of discrete events + +Let's explore these in more detail with the following example: + +```@example mtkmodel-example +using ModelingToolkit +using ModelingToolkit: t + +@mtkmodel ModelA begin + @description "A component with parameters `k` and `k_array`." + @parameters begin + k + k_array[1:2] + end +end + +@mtkmodel ModelB begin + @description "A component with parameters `p1` and `p2`." + @parameters begin + p1 = 1.0, [description = "Parameter of ModelB"] + p2 = 1.0, [description = "Parameter of ModelB"] + end +end + +@mtkmodel ModelC begin + @description "A bigger system that contains many more things." + @icon "https://github.com/SciML/SciMLDocs/blob/main/docs/src/assets/logo.png" + @constants begin + c::Int = 1, [description = "Example constant."] + end + @structural_parameters begin + f = sin + N = 2 + M = 3 + end + begin + v_var = 1.0 + end + @variables begin + v(t) = v_var + v_array(t)[1:N, 1:M] + v_for_defaults(t) + end + @extend ModelB(p1 = 1) + @components begin + model_a = ModelA(; k_array) + model_array_a = [ModelA(; k = i) for i in 1:N] + model_array_b = for i in 1:N + k = i^2 + ModelA(; k) + end + end + @equations begin + model_a.k ~ f(v) + end + @defaults begin + v_for_defaults => 2.0 + end +end +``` + +#### `@description` + +A documenting `String` that summarizes and explains what the model is. + +#### `@icon` + +An icon can be embedded in 3 ways: + + - URI + - Path to a valid image-file.
+ It can be an absolute path. Or, a path relative to an icon directory; which is + `DEPOT_PATH[1]/mtk_icons` by default and can be changed by setting + `ENV["MTK_ICONS_DIR"]`.
+ Internally, it is saved in the _File URI_ scheme. + +```julia +@mtkmodel WithPathtoIcon begin + @icon "/home/user/.julia/dev/mtk_icons/icon.png" + # Rest of the model definition +end +``` + + - Inlined SVG. + +```julia +@mtkmodel WithInlinedSVGIcon begin + @icon """ + + + """ + # Rest of the model definition +end +``` + +#### `@structural_parameters` begin block + + - This block is for non symbolic input arguments. These are for inputs that usually are not meant to be part of components; but influence how they are defined. One can list inputs like boolean flags, functions etc... here. + - Whenever default values are specified, unlike parameters/variables, they are reflected in the keyword argument list. + +#### `@constants` begin block + + - Declare constants in the model definition. + - The values of these can't be changed by the user. + - This works similar to symbolic constants described [here](@ref constants) + +#### `@parameters` and `@variables` begin block + + - Parameters and variables are declared with respective begin blocks. + - Variables must be functions of an independent variable. + - Optionally, initial guess and metadata can be specified for these parameters and variables. See `ModelB` in the above example. + - Along with creating parameters and variables, keyword arguments of same name with default value `nothing` are created. + - Whenever a parameter or variable has initial value, for example `v(t) = 0.0`, a symbolic variable named `v` with initial value 0.0 and a keyword argument `v`, with default value `nothing` are created.
This way, users can optionally pass new value of `v` while creating a component. + +```julia +julia> @mtkbuild model_c1 = ModelC(; v = 2.0); + +julia> ModelingToolkit.getdefault(model_c1.v) +2.0 +``` + +#### `@extend` statement + +One or more partial systems can be extended in a higher system with `@extend` statements. This can be done in two ways: + + - `@extend PartialSystem(var1 = value1)` + + + This is the recommended way of extending a base system. + + The default values for the arguments of the base system can be declared in the `@extend` statement. + + Note that all keyword arguments of the base system are added as the keyword arguments of the main system. + + - `@extend var_to_unpack1, var_to_unpack2 = partial_sys = PartialSystem(var1 = value1)` + + + In this method: explicitly list the variables that should be unpacked, provide a name for the partial system and declare the base system. + + Note that only the arguments listed out in the declaration of the base system (here: `var1`) are added as the keyword arguments of the higher system. + +#### `@components` begin block + + - Declare the subcomponents within `@components` begin block. + - Array of components can be declared with a for loop or a list comprehension. + - The arguments in these subcomponents are promoted as keyword arguments as `subcomponent_name__argname` with `nothing` as default value. + - Whenever components are created with `@named` macro, these can be accessed with `.` operator as `subcomponent_name.argname` + - In the above example, as `k` of `model_a` isn't listed while defining the sub-component in `ModelC`, its default value can't be modified by users. While `k_array` can be set as: + +```@example mtkmodel-example +using ModelingToolkit: getdefault + +@mtkbuild model_c3 = ModelC(; model_a.k_array = [1.0, 2.0]) + +getdefault(model_c3.model_a.k_array[1]) +# 1.0 +getdefault(model_c3.model_a.k_array[2]) +# 2.0 +``` + +#### `@equations` begin block + + - List all the equations here + +#### `@defaults` begin block + + - Default values can be passed as pairs. + - This is equivalent to passing `defaults` argument to the system. + +#### `@continuous_events` begin block + + - Defining continuous events as described [here](https://docs.sciml.ai/ModelingToolkit/stable/basics/Events/#Continuous-Events). + - If this block is not defined in the model, no continuous events will be added. + +```@example mtkmodel-example +using ModelingToolkit +using ModelingToolkit: t + +@mtkmodel M begin + @parameters begin + k + end + @variables begin + x(t) + y(t) + end + @equations begin + x ~ k * D(x) + D(y) ~ -k + end + @continuous_events begin + [x ~ 1.5] => [x ~ 5, y ~ 5] + [t ~ 4] => [x ~ 10] + end +end +``` + +#### `@discrete_events` begin block + + - Defining discrete events as described [here](https://docs.sciml.ai/ModelingToolkit/stable/basics/Events/#Discrete-events-support). + - If this block is not defined in the model, no discrete events will be added. + +```@example mtkmodel-example +using ModelingToolkit + +@mtkmodel M begin + @parameters begin + k + end + @variables begin + x(t) + y(t) + end + @equations begin + x ~ k * D(x) + D(y) ~ -k + end + @discrete_events begin + (t == 1.5) => [x ~ x + 5, y ~ 5] + end +end +``` + +#### A begin block + + - Any other Julia operations can be included with dedicated begin blocks. + +### Setting the type of system: + +By default `@mtkmodel` returns an ODESystem. Different types of system can be +defined with the following syntax: + +``` +@mtkmodel ModelName::SystemType begin + ... +end + +``` + +Example: + +```@example mtkmodel-example +@mtkmodel Float2Bool::DiscreteSystem begin + @variables begin + u(t)::Float64 + y(t)::Bool + end + @equations begin + y ~ u != 0 + end +end +``` + +## Connectors + +Connectors are special models that can be used to connect different components together. +MTK provides 3 distinct connectors: + + - `DomainConnector`: A connector which has only one unknown which is of `Flow` type, + specified by `[connect = Flow]`. + - `StreamConnector`: A connector which has atleast one stream variable, specified by + `[connect = Stream]`. A `StreamConnector` must have exactly one flow variable. + - `RegularConnector`: Connectors that don't fall under above categories. + +### [Defining connectors with `@connector`](@id connector) + +`@connector` returns `ModelingToolkit.Model`. It includes a constructor that returns +a connector system (`ODESystem` by default), a `structure` dictionary with metadata, and flag `isconnector` +which is set to `true`. + +A simple connector can be defined with syntax similar to following example: + +```@example connector +using ModelingToolkit +using ModelingToolkit: t + +@connector Pin begin + v(t) = 0.0, [description = "Voltage"] + i(t), [connect = Flow] +end +``` + +Variables (as functions of independent variable) are listed out in the definition. These variables can optionally have initial values and metadata like `description`, `connect` and so on. For more details on setting metadata, check out [Symbolic Metadata](@ref symbolic_metadata). + +Similar to `@mtkmodel`, `@connector` accepts begin blocks of `@components`, `@equations`, `@extend`, `@parameters`, `@structural_parameters`, `@variables`. These keywords mean the same as described above for `@mtkmodel`. +For example, the following `HydraulicFluid` connector is defined with parameters, variables and equations. + +```@example connector +@connector HydraulicFluid begin + @parameters begin + ρ = 997 + β = 2.09e9 + μ = 0.0010016 + n = 1 + let_gas = 1 + ρ_gas = 0.0073955 + p_gas = -1000 + end + @variables begin + dm(t) = 0.0, [connect = Flow] + end + @equations begin + dm ~ 0 + end +end +``` + +!!! note + + For more examples of usage, checkout [ModelingToolkitStandardLibrary.jl](https://github.com/SciML/ModelingToolkitStandardLibrary.jl/) + +## [More on `Model.structure`](@id model_structure) + +`structure` stores metadata that describes composition of a model. It includes: + + - `:components`: The list of sub-components in the form of [[name, sub_component_name],...]. + - `:constants`: Dictionary of constants mapped to its metadata. + - `:defaults`: Dictionary of variables and default values specified in the `@defaults`. + - `:extend`: The list of extended unknowns, parameters and components, name given to the base system, and name of the base system. + When multiple extend statements are present, latter two are returned as lists. + - `:structural_parameters`: Dictionary of structural parameters mapped to their metadata. + - `:parameters`: Dictionary of symbolic parameters mapped to their metadata. For + parameter arrays, length is added to the metadata as `:size`. + - `:variables`: Dictionary of symbolic variables mapped to their metadata. For + variable arrays, length is added to the metadata as `:size`. + - `:kwargs`: Dictionary of keyword arguments mapped to their metadata. + - `:independent_variable`: Independent variable, which is added while generating the Model. + - `:equations`: List of equations (represented as strings). + +For example, the structure of `ModelC` is: + +```julia +julia> ModelC.structure +Dict{Symbol, Any} with 10 entries: + :components => Any[Union{Expr, Symbol}[:model_a, :ModelA], Union{Expr, Symbol}[:model_array_a, :ModelA, :(1:N)], Union{Expr, Symbol}[:model_array_b, :ModelA, :(1:N)]] + :variables => Dict{Symbol, Dict{Symbol, Any}}(:v=>Dict(:default=>:v_var, :type=>Real), :v_array=>Dict(:value=>nothing, :type=>Real, :size=>(:N, :M)), :v_for_defaults=>Dict(:type=>Real)) + :icon => URI("https://github.com/SciML/SciMLDocs/blob/main/docs/src/assets/logo.png") + :kwargs => Dict{Symbol, Dict}(:f=>Dict(:value=>:sin), :p2=>Dict(:value=>NoValue()), :N=>Dict(:value=>2), :M=>Dict(:value=>3), :v=>Dict{Symbol, Any}(:value=>:v_var, :type=>Real), :v_array=>Dict{Symbol, Any}(:value=>nothing, :type=>Real, :size=>(:N, :M)), :v_for_defaults=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real), :p1=>Dict(:value=>1)) + :structural_parameters => Dict{Symbol, Dict}(:f=>Dict(:value=>:sin), :N=>Dict(:value=>2), :M=>Dict(:value=>3)) + :independent_variable => :t + :constants => Dict{Symbol, Dict}(:c=>Dict{Symbol, Any}(:value=>1, :type=>Int64, :description=>"Example constant.")) + :extend => Any[[:p2, :p1], Symbol("#mtkmodel__anonymous__ModelB"), :ModelB] + :defaults => Dict{Symbol, Any}(:v_for_defaults=>2.0) + :equations => Any["model_a.k ~ f(v)"] +``` + +### Different ways to define symbolics arrays: + +`@mtkmodel` supports symbolics arrays in both `@parameters` and `@variables`. +Using a structural parameters, symbolic arrays of arbitrary lengths can be defined. +Refer the following example for different ways to define symbolic arrays. + +```@example mtkmodel-example +@mtkmodel ModelWithArrays begin + @structural_parameters begin + N = 2 + M = 3 + end + @parameters begin + p1[1:4] + p2[1:N] + p3[1:N, 1:M] = 10, + [description = "A multi-dimensional array of arbitrary length with description"] + (p4[1:N, 1:M] = 10), + [description = "An alternate syntax for p3 to match the syntax of vanilla parameters macro"] + end + @variables begin + v1(t)[1:2] = 10, [description = "An array of variable `v1`"] + v2(t)[1:3] = [1, 2, 3] + end +end +``` + +The size of symbolic array can be accessed via `:size` key, along with other metadata (refer [More on `Model.structure`](@ref model_structure)) +of the symbolic variable. + +```julia +julia> ModelWithArrays.structure +Dict{Symbol, Any} with 5 entries: + :variables => Dict{Symbol, Dict{Symbol, Any}}(:v2 => Dict(:value => :([1, 2, 3]), :type => Real, :size => (3,)), :v1 => Dict(:value => :v1, :type => Real, :description => "An array of variable `v1`", :size => (2,))) + :kwargs => Dict{Symbol, Dict}(:p2 => Dict{Symbol, Any}(:value => nothing, :type => Real, :size => (:N,)), :v1 => Dict{Symbol, Any}(:value => :v1, :type => Real, :description => "An array of variable `v1`", :size => (2,)), :N => Dict(:value => 2), :M => Dict(:value => 3), :p4 => Dict{Symbol, Any}(:value => 10, :type => Real, :description => "An alternate syntax for p3 to match the syntax of vanilla parameters macro", :size => (:N, :M)), :v2 => Dict{Symbol, Any}(:value => :([1, 2, 3]), :type => Real, :size => (3,)), :p1 => Dict{Symbol, Any}(:value => nothing, :type => Real, :size => (4,)), :p3 => Dict{Symbol, Any}(:value => :p3, :type => Real, :description => "A multi-dimensional array of arbitrary length with description", :size => (:N, :M))) + :structural_parameters => Dict{Symbol, Dict}(:N => Dict(:value => 2), :M => Dict(:value => 3)) + :independent_variable => :t + :parameters => Dict{Symbol, Dict{Symbol, Any}}(:p2 => Dict(:value => nothing, :type => Real, :size => (:N,)), :p4 => Dict(:value => 10, :type => Real, :description => "An alternate syntax for p3 to match the syntax of vanilla parameters macro", :size => (:N, :M)), :p1 => Dict(:value => nothing, :type => Real, :size => (4,)), :p3 => Dict(:value => :p3, :type => Real, :description => "A multi-dimensional array of arbitrary length with description", :size => (:N, :M)))), false) +``` + +### Using conditional statements + +#### Conditional elements of the system + +Both `@mtkmodel` and `@connector` support conditionally defining parameters, +variables, equations, and components. + +The if-elseif-else statements can be used inside `@equations`, `@parameters`, +`@variables`, `@components`. + +```@example branches-in-components +using ModelingToolkit +using ModelingToolkit: t + +@mtkmodel C begin end + +@mtkmodel BranchInsideTheBlock begin + @structural_parameters begin + flag = true + end + @parameters begin + if flag + a1 + else + a2 + end + end + @components begin + if flag + sys1 = C() + else + sys2 = C() + end + end +end +``` + +Alternatively, the `@equations`, `@parameters`, `@variables`, `@components` can be +used inside the if-elseif-else statements. + +```@example branches-in-components +@mtkmodel BranchOutsideTheBlock begin + @structural_parameters begin + flag = true + end + if flag + @parameters begin + a1 + end + @components begin + sys1 = C() + end + @equations begin + a1 ~ 0 + end + else + @parameters begin + a2 + end + @equations begin + a2 ~ 0 + end + end + @defaults begin + a1 => 10 + end +end +``` + +The conditional parts are reflected in the `structure`. For `BranchOutsideTheBlock`, the metadata is: + +```julia +julia> BranchOutsideTheBlock.structure +Dict{Symbol, Any} with 7 entries: + :components => Any[(:if, :flag, Vector{Union{Expr, Symbol}}[[:sys1, :C]], Any[])] + :kwargs => Dict{Symbol, Dict}(:flag=>Dict{Symbol, Bool}(:value=>1)) + :structural_parameters => Dict{Symbol, Dict}(:flag=>Dict{Symbol, Bool}(:value=>1)) + :independent_variable => t + :parameters => Dict{Symbol, Dict{Symbol, Any}}(:a2 => Dict(:type=>AbstractArray{Real}, :condition=>(:if, :flag, Dict{Symbol, Any}(:kwargs=>Dict{Any, Any}(:a1=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real)), :parameters=>Any[Dict{Symbol, Dict{Symbol, Any}}(:a1=>Dict(:type=>AbstractArray{Real}))]), Dict{Symbol, Any}(:variables=>Any[Dict{Symbol, Dict{Symbol, Any}}()], :kwargs=>Dict{Any, Any}(:a2=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real)), :parameters=>Any[Dict{Symbol, Dict{Symbol, Any}}(:a2=>Dict(:type=>AbstractArray{Real}))]))), :a1 => Dict(:type=>AbstractArray{Real}, :condition=>(:if, :flag, Dict{Symbol, Any}(:kwargs=>Dict{Any, Any}(:a1=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real)), :parameters=>Any[Dict{Symbol, Dict{Symbol, Any}}(:a1=>Dict(:type=>AbstractArray{Real}))]), Dict{Symbol, Any}(:variables=>Any[Dict{Symbol, Dict{Symbol, Any}}()], :kwargs=>Dict{Any, Any}(:a2=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real)), :parameters=>Any[Dict{Symbol, Dict{Symbol, Any}}(:a2=>Dict(:type=>AbstractArray{Real}))])))) + :defaults => Dict{Symbol, Any}(:a1=>10) + :equations => Any[(:if, :flag, ["a1 ~ 0"], ["a2 ~ 0"])] +``` + +Conditional entries are entered in the format of `(branch, condition, [case when it is true], [case when it is false])`; +where `branch` is either `:if` or `:elseif`.
+The `[case when it is false]` is either an empty vector or `nothing` when only if branch is +present; it is a vector or dictionary whenever else branch is present; it is a conditional tuple +whenever elseif branches are present. + +For the conditional components and equations these condition tuples are added +directly, while for parameters and variables these are added as `:condition` metadata. + +#### Conditional initial guess of symbolic variables + +Using ternary operator or if-elseif-else statement, conditional initial guesses can be assigned to parameters and variables. + +```@example branches-in-components +@mtkmodel DefaultValues begin + @structural_parameters begin + flag = true + end + @parameters begin + p = flag ? 1 : 2 + end +end +``` + +## Build structurally simplified models: + +`@mtkbuild` builds an instance of a component and returns a structurally simplied system. + +```julia +@mtkbuild sys = CustomModel() +``` + +This is equivalent to: + +```julia +@named model = CustomModel() +sys = structural_simplify(model) +``` + +Pass keyword arguments to `structural_simplify` using the following syntax: + +```julia +@mtkbuild sys=CustomModel() fully_determined=false +``` + +This is equivalent to: + +```julia +@named model = CustomModel() +sys = structural_simplify(model; fully_determined = false) +``` diff --git a/docs/src/basics/Precompilation.md b/docs/src/basics/Precompilation.md new file mode 100644 index 0000000000..97111f0d6b --- /dev/null +++ b/docs/src/basics/Precompilation.md @@ -0,0 +1,117 @@ +# Working with Precompilation and Binary Building + +## tl;dr, I just want precompilation to work + +The tl;dr is, if you want to make precompilation work then instead of + +```julia +ODEProblem(sys, u0, tspan, p) +``` + +use: + +```julia +ODEProblem(sys, u0, tspan, p, eval_module = @__MODULE__, eval_expression = true) +``` + +As a full example, here's an example of a module that would precompile effectively: + +```julia +module PrecompilationMWE +using ModelingToolkit + +@variables x(ModelingToolkit.t_nounits) +@named sys = ODESystem([ModelingToolkit.D_nounits(x) ~ -x + 1], ModelingToolkit.t_nounits) +prob = ODEProblem(structural_simplify(sys), [x => 30.0], (0, 100), [], + eval_expression = true, eval_module = @__MODULE__) + +end +``` + +If you use that in your package's code then 99% of the time that's the right answer to get +precompilation working. + +## I'm doing something fancier and need a bit more of an explanation + +Oh you dapper soul, time for the bigger explanation. Julia's `eval` function evaluates a +function into a module at a specified world-age. If you evaluate a function within a function +and try to call it from within that same function, you will hit a world-age error. This looks like: + +```julia +function worldageerror() + f = eval(:((x) -> 2x)) + f(2) +end +``` + +``` +julia> worldageerror() +ERROR: MethodError: no method matching (::var"#5#6")(::Int64) + +Closest candidates are: + (::var"#5#6")(::Any) (method too new to be called from this world context.) + @ Main REPL[12]:2 +``` + +This is done for many reasons, in particular if the code that is called within a function could change +at any time, then Julia functions could not ever properly optimize because the meaning of any function +or dispatch could always change and you would lose performance by guarding against that. For a full +discussion of world-age, see [this paper](https://arxiv.org/abs/2010.07516). + +However, this would be greatly inhibiting to standard ModelingToolkit usage because then something as +simple as building an ODEProblem in a function and then using it would get a world age error: + +```julia +function wouldworldage() + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob) +end +``` + +The reason is because `prob.f` would be constructed via `eval`, and thus `prob.f` could not be called +in the function, which means that no solve could ever work in the same function that generated the +problem. That does mean that: + +```julia +function wouldworldage() + prob = ODEProblem(sys, [], (0.0, 1.0)) +end +sol = solve(prob) +``` + +is fine, or putting + +```julia +prob = ODEProblem(sys, [], (0.0, 1.0)) +sol = solve(prob) +``` + +at the top level of a module is perfectly fine too. They just cannot happen in the same function. + +This would be a major limitation to ModelingToolkit, and thus we developed +[RuntimeGeneratedFunctions](https://github.com/SciML/RuntimeGeneratedFunctions.jl) to get around +this limitation. It will not be described beyond that, it is dark art and should not be investigated. +But it does the job. But that does mean that it plays... oddly with Julia's compilation. + +There are ways to force RuntimeGeneratedFunctions to perform their evaluation and caching within +a given module, but that is not recommended because it does not play nicely with Julia v1.9's +introduction of package images for binary caching. + +Thus when trying to make things work with precompilation, we recommend using `eval`. This is +done by simply adding `eval_expression=true` to the problem constructor. However, this is not +a silver bullet because the moment you start using eval, all potential world-age restrictions +apply, and thus it is recommended this is simply used for evaluating at the top level of modules +for the purpose of precompilation and ensuring binaries of your MTK functions are built correctly. + +However, there is one caveat that `eval` in Julia works depending on the module that it is given. +If you have `MyPackage` that you are precompiling into, or say you are using `juliac` or PackageCompiler +or some other static ahead-of-time (AOT) Julia compiler, then you don't want to accidentally `eval` +that function to live in ModelingToolkit and instead want to make sure it is `eval`'d to live in `MyPackage` +(since otherwise it will not cache into the binary). ModelingToolkit cannot know that in advance, and thus +you have to pass in the module you wish for the functions to "live" in. This is done via the `eval_module` +argument. + +Hence `ODEProblem(sys, u0, tspan, p, eval_module=@__MODULE__, eval_expression=true)` will work if you +are running this expression in the scope of the module you wish to be precompiling. However, if you are +attempting to AOT compile a different module, this means that `eval_module` needs to be appropriately +chosen. And, because `eval_expression=true`, all caveats of world-age apply. diff --git a/docs/src/basics/Validation.md b/docs/src/basics/Validation.md index 85f7ea3750..79c5d0d214 100644 --- a/docs/src/basics/Validation.md +++ b/docs/src/basics/Validation.md @@ -1,18 +1,183 @@ -# Model Validation and Units - -ModelingToolkit.jl provides extensive functionality for model validation -and unit checking. This is done by providing metadata to the variable -types and then running the validation functions which identify malformed -systems and non-physical equations. - -## Consistency Checking - -```@docs -check_consistency -``` - -## Unit and Type Validation - -```@docs -ModelingToolkit.validate -``` +# [Model Validation and Units](@id units) + +ModelingToolkit.jl provides extensive functionality for model validation and unit checking. This is done by providing metadata to the variable types and then running the validation functions which identify malformed systems and non-physical equations. This approach provides high performance and compatibility with numerical solvers. + +## Assigning Units + +Units may be assigned with the following syntax. + +```@example validation +using ModelingToolkit, DynamicQuantities +@independent_variables t [unit = u"s"] +@variables x(t) [unit = u"m"] g(t) w(t) [unit = u"Hz"] + +@parameters(t, [unit = u"s"]) +@variables(x(t), [unit = u"m"], g(t), w(t), [unit = u"Hz"]) + +@parameters begin + t, [unit = u"s"] +end +@variables(begin + x(t), [unit = u"m"], + g(t), + w(t), [unit = u"Hz"] +end) + +# Simultaneously set default value (use plain numbers, not quantities) +@variables x=10 [unit = u"m"] + +# Symbolic array: unit applies to all elements +@variables x[1:3] [unit = u"m"] +``` + +Do not use `quantities` such as `1u"s"`, `1/u"s"` or `u"1/s"` as these will result in errors; instead use `u"s"`, `u"s^-1"`, or `u"s"^-1`. + +## Unit Validation & Inspection + +Unit validation of equations happens automatically when creating a system. However, for debugging purposes, one may wish to validate the equations directly using `validate`. + +```@docs +ModelingToolkit.validate +``` + +Inside, `validate` uses `get_unit`, which may be directly applied to any term. Note that `validate` will not throw an error in the event of incompatible units, but `get_unit` will. If you would rather receive a warning instead of an error, use `safe_get_unit` which will yield `nothing` in the event of an error. Unit agreement is tested with `ModelingToolkit.equivalent(u1,u2)`. + +```@docs +ModelingToolkit.get_unit +``` + +Example usage below. Note that `ModelingToolkit` does not force unit conversions to preferred units in the event of nonstandard combinations -- it merely checks that the equations are consistent. + +```@example validation +using ModelingToolkit, DynamicQuantities +@independent_variables t [unit = u"ms"] +@parameters τ [unit = u"ms"] +@variables E(t) [unit = u"kJ"] P(t) [unit = u"MW"] +D = Differential(t) +eqs = [D(E) ~ P - E / τ, + 0 ~ P] +ModelingToolkit.validate(eqs) +``` + +```@example validation +ModelingToolkit.validate(eqs[1]) +``` + +```@example validation +try + ModelingToolkit.get_unit(eqs[1].rhs) +catch e + showerror(stdout, e) +end +``` + +An example of an inconsistent system: at present, `ModelingToolkit` requires that the units of all terms in an equation or sum to be equal-valued (`ModelingToolkit.equivalent(u1,u2)`), rather than simply dimensionally consistent. In the future, the validation stage may be upgraded to support the insertion of conversion factors into the equations. + +```@example validation +using ModelingToolkit, DynamicQuantities +@independent_variables t [unit = u"ms"] +@parameters τ [unit = u"ms"] +@variables E(t) [unit = u"J"] P(t) [unit = u"MW"] +D = Differential(t) +eqs = [D(E) ~ P - E / τ, + 0 ~ P] +ModelingToolkit.validate(eqs) #Returns false while displaying a warning message +``` + +## User-Defined Registered Functions and Types + +In order to validate user-defined types and `register`ed functions, specialize `get_unit`. Single-parameter calls to `get_unit` +expect an object type, while two-parameter calls expect a function type as the first argument, and a vector of arguments as the +second argument. + +```@example validation2 +using ModelingToolkit, DynamicQuantities +using ModelingToolkit: t_nounits as t, D_nounits as D +# Composite type parameter in registered function +struct NewType + f::Any +end +@register_symbolic dummycomplex(complex::Num, scalar) +dummycomplex(complex, scalar) = complex.f - scalar + +c = NewType(1) +ModelingToolkit.get_unit(x::NewType) = ModelingToolkit.get_unit(x.f) +function ModelingToolkit.get_unit(op::typeof(dummycomplex), args) + argunits = ModelingToolkit.get_unit.(args) + ModelingToolkit.get_unit(-, args) +end + +sts = @variables a(t)=0 [unit = u"cm"] +ps = @parameters s=-1 [unit = u"cm"] c=c [unit = u"cm"] +eqs = [D(a) ~ dummycomplex(c, s);] +sys = ODESystem( + eqs, t, [sts...;], [ps...;], name = :sys, checks = ~ModelingToolkit.CheckUnits) +sys_simple = structural_simplify(sys) +``` + +## `DynamicQuantities` Literals + +In order for a function to work correctly during both validation & execution, the function must be unit-agnostic. That is, no unitful literals may be used. Any unitful quantity must either be a `parameter` or `variable`. For example, these equations will not validate successfully. + +```julia +using ModelingToolkit, DynamicQuantities +@independent_variables t [unit = u"ms"] +@variables E(t) [unit = u"J"] P(t) [unit = u"MW"] +D = Differential(t) +eqs = [D(E) ~ P - E / 1u"ms"] +ModelingToolkit.validate(eqs) #Returns false while displaying a warning message + +myfunc(E) = E / 1u"ms" +eqs = [D(E) ~ P - myfunc(E)] +ModelingToolkit.validate(eqs) #Returns false while displaying a warning message +``` + +Instead, they should be parameterized: + +```@example validation3 +using ModelingToolkit, DynamicQuantities +@independent_variables t [unit = u"ms"] +@parameters τ [unit = u"ms"] +@variables E(t) [unit = u"kJ"] P(t) [unit = u"MW"] +D = Differential(t) +eqs = [D(E) ~ P - E / τ] +ModelingToolkit.validate(eqs) #Returns true +``` + +```@example validation3 +myfunc(E, τ) = E / τ +eqs = [D(E) ~ P - myfunc(E, τ)] +ModelingToolkit.validate(eqs) #Returns true +``` + +It is recommended *not* to circumvent unit validation by specializing user-defined functions on `DynamicQuantities` arguments vs. `Numbers`. This both fails to take advantage of `validate` for ensuring correctness, and may cause in errors in the +future when `ModelingToolkit` is extended to support eliminating `DynamicQuantities` literals from functions. + +## Other Restrictions + +`Unitful` provides non-scalar units such as `dBm`, `°C`, etc. At this time, `ModelingToolkit` only supports scalar quantities. Additionally, angular degrees (`°`) are not supported because trigonometric functions will treat plain numerical values as radians, which would lead systems validated using degrees to behave erroneously when being solved. + +## Troubleshooting & Gotchas + +If a system fails to validate due to unit issues, at least one warning message will appear, including a line number as well as the unit types and expressions that were in conflict. Some system constructors re-order equations before the unit checking can be done, in which case the equation numbers may be inaccurate. The printed expression that the problem resides in is always correctly shown. + +Symbolic exponents for unitful variables *are* supported (ex: `P^γ` in thermodynamics). However, this means that `ModelingToolkit` cannot reduce such expressions to `DynamicQuantities.Quantity` subtypes at validation time because the exponent value is not available. In this case `ModelingToolkit.get_unit` is type-unstable, yielding a symbolic result, which can still be checked for symbolic equality with `ModelingToolkit.equivalent`. + +## Parameter & Initial Condition Values + +Parameter and initial condition values are supplied to problem constructors as plain numbers, with the understanding that they have been converted to the appropriate units. This is done for simplicity of interfacing with optimization solvers. Some helper function for dealing with value maps: + +```julia +function remove_units(p::Dict) + Dict(k => Unitful.ustrip(ModelingToolkit.get_unit(k), v) for (k, v) in p) +end +add_units(p::Dict) = Dict(k => v * ModelingToolkit.get_unit(k) for (k, v) in p) +``` + +Recommended usage: + +```julia +pars = @parameters τ [unit = u"ms"] +p = Dict(τ => 1u"ms") +ODEProblem(sys, remove_units(u0), tspan, remove_units(p)) +``` diff --git a/docs/src/basics/Variable_metadata.md b/docs/src/basics/Variable_metadata.md new file mode 100644 index 0000000000..43d0a7f2ea --- /dev/null +++ b/docs/src/basics/Variable_metadata.md @@ -0,0 +1,274 @@ +# [Symbolic Metadata](@id symbolic_metadata) + +It is possible to add metadata to symbolic variables, the metadata will be displayed when calling help on a variable. + +The following information can be added (note, it's possible to extend this to user-defined metadata as well) + +## Variable descriptions + +Descriptive strings can be attached to variables using the `[description = "descriptive string"]` syntax: + +```@example metadata +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables u [description = "This is my input"] +getdescription(u) +``` + +When variables with descriptions are present in systems, they will be printed when the system is shown in the terminal: + +```@example metadata +@variables u(t) [description = "A short description of u"] +@parameters p [description = "A description of p"] +@named sys = ODESystem([u ~ p], t) +show(stdout, "text/plain", sys) # hide +``` + +Calling help on the variable `u` displays the description, alongside other metadata: + +``` +help?> u + + A variable of type Symbolics.Num (Num wraps anything in a type that is a subtype of Real) + + Metadata + ≡≡≡≡≡≡≡≡≡≡ + + ModelingToolkit.VariableDescription: This is my input + + Symbolics.VariableSource: (:variables, :u) +``` + +## Connect + +Variables in connectors can have `connect` metadata which describes the type of connections. + +`Flow` is used for variables that represent physical quantities that "flow" ex: +current in a resistor. These variables sum up to zero in connections. + +`Stream` can be specified for variables that flow bi-directionally. + +```@example connect +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables i(t) [connect = Flow] +@variables k(t) [connect = Stream] +hasconnect(i) +``` + +```@example connect +getconnect(k) +``` + +## Input or output + +Designate a variable as either an input or an output using the following + +```@example metadata +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables u [input = true] +isinput(u) +``` + +```@example metadata +@variables y [output = true] +isoutput(y) +``` + +## Bounds + +Bounds are useful when parameters are to be optimized, or to express intervals of uncertainty. + +```@example metadata +@variables u [bounds = (-1, 1)] +hasbounds(u) +``` + +```@example metadata +getbounds(u) +``` + +Bounds can also be specified for array variables. A scalar array bound is applied to each +element of the array. A bound may also be specified as an array, in which case the size of +the array must match the size of the symbolic variable. + +```@example metadata +@variables x[1:2, 1:2] [bounds = (-1, 1)] +hasbounds(x) +``` + +```@example metadata +getbounds(x) +``` + +```@example metadata +getbounds(x[1, 1]) +``` + +```@example metadata +getbounds(x[1:2, 1]) +``` + +```@example metadata +@variables x[1:2] [bounds = (-Inf, [1.0, Inf])] +hasbounds(x) +``` + +```@example metadata +getbounds(x) +``` + +```@example metadata +getbounds(x[2]) +``` + +```@example metadata +hasbounds(x[2]) +``` + +## Guess + +Specify an initial guess for custom initial conditions of an `ODESystem`. + +```@example metadata +@variables u [guess = 1] +hasguess(u) +``` + +```@example metadata +getguess(u) +``` + +## Mark input as a disturbance + +Indicate that an input is not available for control, i.e., it's a disturbance input. + +```@example metadata +@variables u [input = true, disturbance = true] +isdisturbance(u) +``` + +## Mark parameter as tunable + +Indicate that a parameter can be automatically tuned by parameter optimization or automatic control tuning apps. + +```@example metadata +@parameters Kp [tunable = true] +istunable(Kp) +``` + +## Probability distributions + +A probability distribution may be associated with a parameter to indicate either +uncertainty about its value, or as a prior distribution for Bayesian optimization. + +```julia +using Distributions +d = Normal(10, 1) +@parameters m [dist = d] +hasdist(m) +``` + +```julia +getdist(m) +``` + +## Irreducible + +A variable can be marked `irreducible` to prevent it from being moved to an +`observed` state. This forces the variable to be computed during solving so that +it can be accessed in [callbacks](@ref events) + +```@example metadata +@variables important_value [irreducible = true] +isirreducible(important_value) +``` + +## State Priority + +When a model is structurally simplified, the algorithm will try to ensure that the variables with higher state priority become states of the system. A variable's state priority is a number set using the `state_priority` metadata. + +```@example metadata +@variables important_dof [state_priority = 10] unimportant_dof [state_priority = -2] +state_priority(important_dof) +``` + +## Units + +Units for variables can be designated using symbolic metadata. For more information, please see the [model validation and units](@ref units) section of the docs. Note that `getunit` is not equivalent to `get_unit` - the former is a metadata getter for individual variables (and is provided so the same interface function for `unit` exists like other metadata), while the latter is used to handle more general symbolic expressions. + +```@example metadata +using DynamicQuantities +@variables speed [unit = u"m/s"] +hasunit(speed) +``` + +```@example metadata +getunit(speed) +``` + +## Miscellaneous metadata + +User-defined metadata can be added using the `misc` metadata. This can be queried +using the `hasmisc` and `getmisc` functions. + +```@example metadata +@variables u [misc = :conserved_parameter] y [misc = [2, 4, 6]] +hasmisc(u) +``` + +```@example metadata +getmisc(y) +``` + +## Additional functions + +For systems that contain parameters with metadata like described above, have some additional functions defined for convenience. +In the example below, we define a system with tunable parameters and extract bounds vectors + +```@example metadata +@variables x(t)=0 u(t)=0 [input = true] y(t)=0 [output = true] +@parameters T [tunable = true, bounds = (0, Inf)] +@parameters k [tunable = true, bounds = (0, Inf)] +eqs = [D(x) ~ (-x + k * u) / T # A first-order system with time constant T and gain k + y ~ x] +sys = ODESystem(eqs, t, name = :tunable_first_order) +``` + +```@example metadata +p = tunable_parameters(sys) # extract all parameters marked as tunable +``` + +```@example metadata +lb, ub = getbounds(p) # operating on a vector, we get lower and upper bound vectors +``` + +```@example metadata +b = getbounds(sys) # Operating on the system, we get a dict +``` + +See also: [`ModelingToolkit.dump_variable_metadata`](@ref), [`ModelingToolkit.dump_parameters`](@ref), +[`ModelingToolkit.dump_unknowns`](@ref). + +## Index + +```@index +Pages = ["Variable_metadata.md"] +``` + +## Docstrings + +```@autodocs +Modules = [ModelingToolkit] +Pages = ["variables.jl"] +Private = false +``` + +```@docs +ModelingToolkit.dump_variable_metadata +ModelingToolkit.dump_parameters +ModelingToolkit.dump_unknowns +``` diff --git a/docs/src/comparison.md b/docs/src/comparison.md index 446f0afebc..52d5ab2f70 100644 --- a/docs/src/comparison.md +++ b/docs/src/comparison.md @@ -1,123 +1,119 @@ -# Comparison of ModelingToolkit vs Equation-Based Modeling Languages +# Comparison of ModelingToolkit vs Equation-Based and Block Modeling Languages ## Comparison Against Modelica -- Both Modelica and ModelingToolkit.jl are acausal modeling languages. -- Modelica is a language with many different implementations, such as - [Dymola](https://www.3ds.com/products-services/catia/products/dymola/) and - [OpenModelica](https://openmodelica.org/), which have differing levels of - performance and can give different results on the same model. Many of the - commonly used Modelica compilers are not open source. ModelingToolkit.jl - is a language with a single canonical open source implementation. -- All current Modelica compiler implementations are fixed and not extendable - by the users from the Modelica language itself. For example, the Dymola - compiler [shares its symbolic processing pipeline](https://www.claytex.com/tech-blog/model-translation-and-symbolic-manipulation/) - which is roughly equivalent to the `dae_index_lowering` and `structural_simplify` - of ModelingToolkit.jl. ModelingToolkit.jl is an open and hackable transformation - system which allows users to add new non-standard transformations and control - the order of application. -- Modelica is a declarative programming language. ModelingToolkit.jl is a - declarative symbolic modeling language used from within the Julia programming - language. Its programming language semantics, such as loop constructs and - conditionals, can be used to more easily generate models. -- Modelica is an object-oriented single dispatch language. ModelingToolkit.jl, - built on Julia, uses multiple dispatch extensively to simplify code. -- Many Modelica compilers supply a GUI. ModelingToolkit.jl does not. -- Modelica can be used to simulate ODE and DAE systems. ModelingToolkit.jl - has a much more expansive set of system types, including nonlinear systems, - SDEs, PDEs, and more. + - Both Modelica and ModelingToolkit.jl are acausal modeling languages. + - Modelica is a language with many different implementations, such as + [Dymola](https://www.3ds.com/products/catia/dymola/) and + [OpenModelica](https://openmodelica.org/), which have differing levels of + performance and can give different results on the same model. Many of the + commonly used Modelica compilers are not open-source. ModelingToolkit.jl + is a language with a single canonical open-source implementation. + - All current Modelica compiler implementations are fixed and not extendable + by the users from the Modelica language itself. For example, the Dymola + compiler [shares its symbolic processing pipeline](https://www.claytex.com/tech-blog/model-translation-and-symbolic-manipulation/), + which is roughly equivalent to the `dae_index_lowering` and `structural_simplify` + of ModelingToolkit.jl. ModelingToolkit.jl is an open and hackable transformation + system which allows users to add new non-standard transformations and control + the order of application. + - Modelica is a declarative programming language. ModelingToolkit.jl is a + declarative symbolic modeling language used from within the Julia programming + language. Its programming language semantics, such as loop constructs and + conditionals, can be used to more easily generate models. + - Modelica is an object-oriented single dispatch language. ModelingToolkit.jl, + built on Julia, uses multiple dispatch extensively to simplify code. + - Many Modelica compilers supply a GUI. ModelingToolkit.jl does not. + - Modelica is designed for simulating ODE and DAE systems (which can include nonlinear dynamics). + In contrast, ModelingToolkit.jl supports a much broader range of system types, including SDEs, + PDEs, time-independent nonlinear systems (e.g. various forms of optimization problems) and more. ## Comparison Against Simulink -- Simulink is a causal modeling environment, whereas ModelingToolkit.jl is an - acausal modeling environment. For an overview of the differences, consult - academic reviews such as [this one](https://arxiv.org/abs/1909.00484). In this - sense, ModelingToolkit.jl is more similar to the Simscape sub-environment. -- Simulink is used from MATLAB while ModelingToolkit.jl is used from Julia. - Thus any user defined functions have the performance of their host language. - For information on the performance differences between Julia and MATLAB, - consult [open benchmarks](https://julialang.org/benchmarks/) which demonstrate - Julia as an order of magnitude or more faster in many cases due to its JIT - compilation. -- Simulink uses the MATLAB differential equation solvers while ModelingToolkit.jl - uses [DifferentialEquations.jl](https://diffeq.sciml.ai/dev/). For a systematic - comparison between the solvers, consult - [open benchmarks](https://benchmarks.sciml.ai/html/MultiLanguage/wrapper_packages.html) - which demonstrate two orders of magnitude performance advantage for the native - Julia solvers across many benchmark problems. -- Simulink comes with a Graphical User Interface (GUI), ModelingToolkit.jl - does not. -- Simulink is a proprietary software, meaning users cannot actively modify or - extend the software. ModelingToolkit.jl is built in Julia and used in Julia, - where users can actively extend and modify the software interactively in the - REPL and contribute to its open source repositories. -- Simulink covers ODE and DAE systems. ModelingToolkit.jl has a much more - expansive set of system types, including SDEs, PDEs, optimization problems, - and more. + - Simulink is a causal modeling environment, whereas ModelingToolkit.jl is an + acausal modeling environment. For an overview of the differences, consult + academic reviews such as [this one](https://arxiv.org/abs/1909.00484). In this + sense, ModelingToolkit.jl is more similar to the Simscape sub-environment. + - Simulink is used from MATLAB while ModelingToolkit.jl is used from Julia. + Thus any user-defined functions have the performance of their host language. + For information on the performance differences between Julia and MATLAB, + consult [open benchmarks](https://julialang.org/benchmarks/), which demonstrate + Julia as an order of magnitude or more faster in many cases due to its JIT + compilation. + - Simulink uses the MATLAB differential equation solvers, while ModelingToolkit.jl + uses [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/). For a systematic + comparison between the solvers, consult + [open benchmarks](https://docs.sciml.ai/SciMLBenchmarksOutput/stable/), + which demonstrate two orders of magnitude performance advantage for the native + Julia solvers across many benchmark problems. + - Simulink comes with a Graphical User Interface (GUI), ModelingToolkit.jl + does not. + - Simulink is a proprietary software, meaning users cannot actively modify or + extend the software. ModelingToolkit.jl is built in Julia and used in Julia, + where users can actively extend and modify the software interactively in the + REPL and contribute to its open-source repositories. + - Simulink covers ODE and DAE systems. ModelingToolkit.jl has a much more + expansive set of system types, including SDEs, PDEs, optimization problems, + and more. ## Comparison Against CASADI -- CASADI is written in C++ but used from Python/MATLAB, meaning that it cannot be - directly extended by users unless they are using the C++ interface and run a - local build of CASADI. ModelingToolkit.jl is both written and used from - Julia, meaning that users can easily extend the library on the fly, even - interactively in the REPL. -- CASADI includes limited support for Computer Algebra System (CAS) functionality, - while ModelingToolkit.jl is built on the full - [Symbolics.jl](https://github.com/JuliaSymbolics/Symbolics.jl) CAS. -- CASADI supports DAE and ODE problems via SUNDIALS IDAS and CVODES. ModelingToolkit.jl - supports DAE and ODE problems via [DifferentialEquations.jl](https://diffeq.sciml.ai/dev/), - of which Sundials.jl is <1% of the total available solvers and is outperformed - by the native Julia solvers on the vast majority of the benchmark equations. - In addition, the DifferentialEquations.jl interface is confederated, meaning - that any user can dynamically extend the system to add new solvers to the - interface by defining new dispatches of solve. -- CASADI's DAEBuilder does not implement efficiency transformations like tearing - which are standard in the ModelingToolkit.jl transformation pipeline. -- CASADI supports special functionality for quadratic programming problems while - ModelingToolkit only provides nonlinear programming via `OptimizationSystem`. -- ModelingToolkit.jl integrates with its host language Julia, so Julia code - can be automatically converted into ModelingToolkit expressions. Users of - CASADI must explicitly create CASADI expressions. + - CASADI is written in C++ but used from Python/MATLAB, meaning that it cannot be + directly extended by users unless they are using the C++ interface and run a + local build of CASADI. ModelingToolkit.jl is both written and used from + Julia, meaning that users can easily extend the library on the fly, even + interactively in the REPL. + - CASADI includes limited support for Computer Algebra System (CAS) functionality, + while ModelingToolkit.jl is built on the full + [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/) CAS. + - CASADI supports DAE and ODE problems via SUNDIALS IDAS and CVODES. ModelingToolkit.jl + supports DAE and ODE problems via [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/), + of which Sundials.jl is <1% of the total available solvers and is outperformed + by the native Julia solvers on the vast majority of the benchmark equations. + In addition, the DifferentialEquations.jl interface is confederated, meaning + that any user can dynamically extend the system to add new solvers to the + interface by defining new dispatches of solve. + - CASADI's DAEBuilder does not implement efficiency transformations like tearing, + which are standard in the ModelingToolkit.jl transformation pipeline. + - CASADI supports special functionality for quadratic programming problems, while + ModelingToolkit only provides nonlinear programming via `OptimizationSystem`. + - ModelingToolkit.jl integrates with its host language Julia, so Julia code + can be automatically converted into ModelingToolkit expressions. Users of + CASADI must explicitly create CASADI expressions. ## Comparison Against Modia.jl -- Modia.jl is a Modelica-like system built in pure Julia. As such, its syntax - is a domain-specific language (DSL) specified by macros to mirror the Modelica - syntax. -- Modia's compilation pipeline is similar to the - [Dymola symbolic processing pipeline](https://www.claytex.com/tech-blog/model-translation-and-symbolic-manipulation/) - with some improvements. ModelingToolkit.jl has an open transformation pipeline - that allows for users to extend and reorder transformation passes, where - `structural_simplify` is an adaptation of the Modia.jl-improved alias elimination - and tearing algorithms. -- Modia supports DAE problems via SUNDIALS IDAS. ModelingToolkit.jl - supports DAE and ODE problems via [DifferentialEquations.jl](https://diffeq.sciml.ai/dev/), - of which Sundials.jl is <1% of the total available solvers and is outperformed - by the native Julia solvers on the vast majority of the benchmark equations. - In addition, the DifferentialEquations.jl interface is confederated, meaning - that any user can dynamically extend the system to add new solvers to the - interface by defining new dispatches of solve. -- ModelingToolkit.jl integrates with its host language Julia, so Julia code - can be automatically converted into ModelingToolkit expressions. Users of - Modia must explicitly create Modia expressions within its macro. -- Modia covers DAE systems. ModelingToolkit.jl has a much more - expansive set of system types, including SDEs, PDEs, optimization problems, - and more. + - Modia.jl uses Julia's expression objects for representing its equations. + ModelingToolkit.jl uses [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/), + and thus the Julia expressions follow Julia semantics and can be manipulated + using a computer algebra system (CAS). + - Modia's compilation pipeline is similar to the + [Dymola symbolic processing pipeline](https://www.claytex.com/tech-blog/model-translation-and-symbolic-manipulation/) + with some improvements. ModelingToolkit.jl has an open transformation pipeline + that allows for users to extend and reorder transformation passes, where + `structural_simplify` is an adaptation of the Modia.jl-improved alias elimination + and tearing algorithms. + - Both Modia and ModelingToolkit generate `DAEProblem` and `ODEProblem` forms for + solving with [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/). + - ModelingToolkit.jl integrates with its host language Julia, so Julia code + can be automatically converted into ModelingToolkit expressions. Users of + Modia must explicitly create Modia expressions. + - Modia covers DAE systems. ModelingToolkit.jl has a much more + expansive set of system types, including SDEs, PDEs, optimization problems, + and more. ## Comparison Against Causal.jl -- Causal.jl is a causal modeling environment, whereas ModelingToolkit.jl is an - acausal modeling environment. For an overview of the differences, consult - academic reviews such as [this one](https://arxiv.org/abs/1909.00484). -- Both ModelingToolkit.jl and Causal.jl use [DifferentialEquations.jl](https://diffeq.sciml.ai/stable/) - as the backend solver library. -- Causal.jl lets one add arbitrary equation systems to a given node, and allow - the output to effect the next node. This means an SDE may drive an ODE. These - two portions are solved with different solver methods in tandem. In - ModelingToolkit.jl, such connections promote the whole system to an SDE. This - results in better accuracy and stability, though in some cases it can be - less performant. -- Causal.jl, similar to Simulink, breaks algebraic loops via inexact heuristics. - ModelingToolkit.jl treats algebraic loops exactly through algebraic equations - in the generated model. + - Causal.jl is a causal modeling environment, whereas ModelingToolkit.jl is an + acausal modeling environment. For an overview of the differences, consult + academic reviews such as [this one](https://arxiv.org/abs/1909.00484). + - Both ModelingToolkit.jl and Causal.jl use [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) + as the backend solver library. + - Causal.jl lets one add arbitrary equation systems to a given node, and allow + the output to effect the next node. This means an SDE may drive an ODE. These + two portions are solved with different solver methods in tandem. In + ModelingToolkit.jl, such connections promote the whole system to an SDE. This + results in better accuracy and stability, though in some cases it can be + less performant. + - Causal.jl, similar to Simulink, breaks algebraic loops via inexact heuristics. + ModelingToolkit.jl treats algebraic loops exactly through algebraic equations + in the generated model. diff --git a/docs/src/examples/higher_order.md b/docs/src/examples/higher_order.md new file mode 100644 index 0000000000..fac707525f --- /dev/null +++ b/docs/src/examples/higher_order.md @@ -0,0 +1,61 @@ +# Automatic Transformation of Nth Order ODEs to 1st Order ODEs + +ModelingToolkit has a system for transformations of mathematical +systems. These transformations allow for symbolically changing +the representation of the model to problems that are easier to +numerically solve. One simple to demonstrate transformation, is +`structural_simplify`, which does a lot of tricks, one being the +transformation that turns an Nth order ODE into N +coupled 1st order ODEs. + +To see this, let's define a second order riff on the Lorenz equations. +We utilize the derivative operator twice here to define the second order: + +```@example orderlowering +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel SECOND_ORDER begin + @parameters begin + σ = 28.0 + ρ = 10.0 + β = 8 / 3 + end + @variables begin + x(t) = 1.0 + y(t) = 0.0 + z(t) = 0.0 + end + @equations begin + D(D(x)) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + D(z) ~ x * y - β * z + end +end +@mtkbuild sys = SECOND_ORDER() +``` + +The second order ODE has been automatically transformed to two first order ODEs. + +Note that we could've used an alternative syntax for 2nd order, i.e. +`D = Differential(t)^2` and then `D(x)` would be the second derivative, +and this syntax extends to `N`-th order. Also, we can use `*` or `∘` to compose +`Differential`s, like `Differential(t) * Differential(x)`. + +Now let's transform this into the `ODESystem` of first order components. +We do this by calling `structural_simplify`: + +Now we can directly numerically solve the lowered system. Note that, +following the original problem, the solution requires knowing the +initial condition for both `x` and `D(x)`. +The former already got assigned a default value in the `@mtkmodel`, +but we still have to provide a value for the latter. + +```@example orderlowering +u0 = [D(sys.x) => 2.0] +tspan = (0.0, 100.0) +prob = ODEProblem(sys, u0, tspan, [], jac = true) +sol = solve(prob, Tsit5()) +using Plots +plot(sol, idxs = (sys.x, sys.y)) +``` diff --git a/docs/src/mtkitize_tutorials/modelingtoolkitize_index_reduction.md b/docs/src/examples/modelingtoolkitize_index_reduction.md similarity index 65% rename from docs/src/mtkitize_tutorials/modelingtoolkitize_index_reduction.md rename to docs/src/examples/modelingtoolkitize_index_reduction.md index f5bc98169a..b19ea46701 100644 --- a/docs/src/mtkitize_tutorials/modelingtoolkitize_index_reduction.md +++ b/docs/src/examples/modelingtoolkitize_index_reduction.md @@ -1,13 +1,13 @@ # Automated Index Reduction of DAEs In many cases one may accidentally write down a DAE that is not easily solvable -by numerical methods. In this tutorial we will walk through an example of a +by numerical methods. In this tutorial, we will walk through an example of a pendulum which accidentally generates an index-3 DAE, and show how to use the `modelingtoolkitize` to correct the model definition before solving. ## Copy-Pastable Example -```julia +```@example indexred using ModelingToolkit using LinearAlgebra using OrdinaryDiffEq @@ -17,29 +17,29 @@ function pendulum!(du, u, p, t) x, dx, y, dy, T = u g, L = p du[1] = dx - du[2] = T*x + du[2] = T * x du[3] = dy - du[4] = T*y - g + du[4] = T * y - g du[5] = x^2 + y^2 - L^2 return nothing end -pendulum_fun! = ODEFunction(pendulum!, mass_matrix=Diagonal([1,1,1,1,0])) +pendulum_fun! = ODEFunction(pendulum!, mass_matrix = Diagonal([1, 1, 1, 1, 0])) u0 = [1.0, 0, 0, 0, 0] p = [9.8, 1] tspan = (0, 10.0) pendulum_prob = ODEProblem(pendulum_fun!, u0, tspan, p) traced_sys = modelingtoolkitize(pendulum_prob) pendulum_sys = structural_simplify(dae_index_lowering(traced_sys)) -prob = ODAEProblem(pendulum_sys, Pair[], tspan) -sol = solve(prob, Tsit5(),abstol=1e-8,reltol=1e-8) -plot(sol, vars=states(traced_sys)) +prob = ODEProblem(pendulum_sys, [], tspan) +sol = solve(prob, Rodas5P(), abstol = 1e-8, reltol = 1e-8) +plot(sol, idxs = unknowns(traced_sys)) ``` ## Explanation ### Attempting to Solve the Equation -In this tutorial we will look at the pendulum system: +In this tutorial, we will look at the pendulum system: ```math \begin{aligned} @@ -51,33 +51,45 @@ In this tutorial we will look at the pendulum system: \end{aligned} ``` +These equations can be derived using the [Lagrangian equation of the first kind.](https://en.wikipedia.org/wiki/Lagrangian_mechanics#Lagrangian) +Specifically, for a pendulum with unit mass and length $L$, which thus has +kinetic energy $\frac{1}{2}(v_x^2 + v_y^2)$, +potential energy $gy$, +and holonomic constraint $x^2 + y^2 - L^2 = 0$. +The Lagrange multiplier related to this constraint is equal to half of $T$, +and represents the tension in the rope of the pendulum. + As a good DifferentialEquations.jl user, one would follow -[the mass matrix DAE tutorial](https://diffeq.sciml.ai/stable/tutorials/advanced_ode_example/#Handling-Mass-Matrices) +[the mass matrix DAE tutorial](https://docs.sciml.ai/DiffEqDocs/stable/tutorials/dae_example/#Mass-Matrix-Differential-Algebraic-Equations-(DAEs)) to arrive at code for simulating the model: -```julia +```@example indexred using OrdinaryDiffEq, LinearAlgebra function pendulum!(du, u, p, t) x, dx, y, dy, T = u g, L = p - du[1] = dx; du[2] = T*x - du[3] = dy; du[4] = T*y - g + du[1] = dx + du[2] = T * x + du[3] = dy + du[4] = T * y - g du[5] = x^2 + y^2 - L^2 end -pendulum_fun! = ODEFunction(pendulum!, mass_matrix=Diagonal([1,1,1,1,0])) -u0 = [1.0, 0, 0, 0, 0]; p = [9.8, 1]; tspan = (0, 10.0) +pendulum_fun! = ODEFunction(pendulum!, mass_matrix = Diagonal([1, 1, 1, 1, 0])) +u0 = [1.0, 0, 0, 0, 0]; +p = [9.8, 1]; +tspan = (0, 10.0); pendulum_prob = ODEProblem(pendulum_fun!, u0, tspan, p) -solve(pendulum_prob,Rodas4()) +solve(pendulum_prob, Rodas5P()) ``` However, one will quickly be greeted with the unfortunate message: -```julia +``` ┌ Warning: First function call produced NaNs. Exiting. └ @ OrdinaryDiffEq C:\Users\accou\.julia\packages\OrdinaryDiffEq\yCczp\src\initdt.jl:76 ┌ Warning: Automatic dt set the starting dt as NaN, causing instability. └ @ OrdinaryDiffEq C:\Users\accou\.julia\packages\OrdinaryDiffEq\yCczp\src\solve.jl:485 -┌ Warning: NaN dt detected. Likely a NaN value in the state, parameters, or derivative value caused this outcome. +┌ Warning: NaN dt detected. Likely a NaN value in the unknowns, parameters, or derivative value caused this outcome. └ @ SciMLBase C:\Users\accou\.julia\packages\SciMLBase\DrPil\src\integrator_interface.jl:325 ``` @@ -88,21 +100,21 @@ Did you implement the DAE incorrectly? No. Is the solver broken? No. It turns out that this is a property of the DAE that we are attempting to solve. This kind of DAE is known as an index-3 DAE. For a complete discussion of DAE index, see [this article](http://www.scholarpedia.org/article/Differential-algebraic_equations). -Essentially the issue here is that we have 4 differential variables (``x``, ``v_x``, ``y``, ``v_y``) +Essentially, the issue here is that we have 4 differential variables (``x``, ``v_x``, ``y``, ``v_y``) and one algebraic variable ``T`` (which we can know because there is no `D(T)` term in the equations). An index-1 DAE always satisfies that the Jacobian of the algebraic equations is non-singular. Here, the first 4 equations are differential equations, with the last term the algebraic relationship. However, the partial derivative of `x^2 + y^2 - L^2` w.r.t. `T` is zero, and thus the -Jacobian of the algebraic equations is the zero matrix and thus it's singular. -This is a very quick way to see whether the DAE is index 1! +Jacobian of the algebraic equations is the zero matrix, and thus it's singular. +This is a rapid way to see whether the DAE is index 1! The problem with higher order DAEs is that the matrices used in Newton solves are singular or close to singular when applied to such problems. Because of this fact, the nonlinear solvers (or Rosenbrock methods) break down, making them difficult to solve. The classic paper [DAEs are not ODEs](https://epubs.siam.org/doi/10.1137/0903023) goes into detail on this and shows that many methods are no longer convergent -when index is higher than one. So it's not necessarily the fault of the solver +when index is higher than one. So, it's not necessarily the fault of the solver or the implementation: this is known. But that's not a satisfying answer, so what do you do about it? @@ -111,21 +123,21 @@ But that's not a satisfying answer, so what do you do about it? It turns out that higher order DAEs can be transformed into lower order DAEs. [If you differentiate the last equation two times and perform a substitution, -you can arrive at the following set of equations](https://courses.seas.harvard.edu/courses/am205/g_act/dae_notes.pdf): +you can arrive at the following set of equations](https://people.math.wisc.edu/%7Echr/am205/g_act/DAE_slides.pdf): ```math \begin{aligned} -x^\prime =& v_x \\ -v_x^\prime =& x T \\ -y^\prime =& v_y \\ -v_y^\prime =& y T - g \\ -0 =& 2 \left(v_x^{2} + v_y^{2} + y ( y T - g ) + T x^2 \right) +x^\prime &= v_x \\ +v_x^\prime &= x T \\ +y^\prime &= v_y \\ +v_y^\prime &= y T - g \\ +0 &= 2 \left(v_x^{2} + v_y^{2} + y ( y T - g ) + T x^2 \right) \end{aligned} ``` Note that this is mathematically-equivalent to the equation that we had before, but the Jacobian w.r.t. `T` of the algebraic equation is no longer zero because -of the substitution. This means that if you wrote down this version of the model +of the substitution. This means that if you wrote down this version of the model, it will be index-1 and solve correctly! In fact, this is how DAE index is commonly defined: the number of differentiations it takes to transform the DAE into an ODE, where an ODE is an index-0 DAE by substituting out all of the @@ -143,39 +155,18 @@ the numerical code into symbolic code, run `dae_index_lowering` lowering, then transform back to numerical code with `ODEProblem`, and solve with a numerical solver. Let's try that out: -```julia +```@example indexred traced_sys = modelingtoolkitize(pendulum_prob) pendulum_sys = structural_simplify(dae_index_lowering(traced_sys)) prob = ODEProblem(pendulum_sys, Pair[], tspan) -sol = solve(prob, Rodas4()) +sol = solve(prob, Rodas5P()) using Plots -plot(sol, vars=states(traced_sys)) +plot(sol, idxs = unknowns(traced_sys)) ``` -![](https://user-images.githubusercontent.com/1814174/110587364-9524b400-8141-11eb-91c7-4e56ce4fa20b.png) - -Note that plotting using `states(traced_sys)` is done so that any -variables which are symbolically eliminated, or any variable reorderings +Note that plotting using `unknowns(traced_sys)` is done so that any +variables which are symbolically eliminated, or any variable reordering done for enhanced parallelism/performance, still show up in the resulting plot and the plot is shown in the same order as the original numerical code. - -Note that we can even go a little bit further. If we use the `ODAEProblem` -constructor, we can remove the algebraic equations from the states of the -system and fully transform the index-3 DAE into an index-0 ODE which can -be solved via an explicit Runge-Kutta method: - -```julia -traced_sys = modelingtoolkitize(pendulum_prob) -pendulum_sys = structural_simplify(dae_index_lowering(traced_sys)) -prob = ODAEProblem(pendulum_sys, Pair[], tspan) -sol = solve(prob, Tsit5(),abstol=1e-8,reltol=1e-8) -plot(sol, vars=states(traced_sys)) -``` - -![](https://user-images.githubusercontent.com/1814174/110587362-9524b400-8141-11eb-8b77-d940f108ae72.png) - -And there you go: this has transformed the model from being too hard to -solve with implicit DAE solvers, to something that is easily solved with -explicit Runge-Kutta methods for non-stiff equations. diff --git a/docs/src/examples/perturbation.md b/docs/src/examples/perturbation.md new file mode 100644 index 0000000000..0d1a493cb4 --- /dev/null +++ b/docs/src/examples/perturbation.md @@ -0,0 +1,105 @@ +# [Symbolic-Numeric Perturbation Theory for ODEs](@id perturb_diff) + +In the [Mixed Symbolic-Numeric Perturbation Theory tutorial](https://symbolics.juliasymbolics.org/stable/tutorials/perturbation/), we discussed how to solve algebraic equations using **Symbolics.jl**. Here we extend the method to differential equations. The procedure is similar, but the Taylor series coefficients now become functions of an independent variable (usually time). + +## Free fall in a varying gravitational field + +Our first ODE example is a well-known physics problem: what is the altitude $x(t)$ of an object (say, a ball or a rocket) thrown vertically with initial velocity $ẋ(0)$ from the surface of a planet with mass $M$ and radius $R$? According to Newton's second law and law of gravity, it is the solution of the ODE + +```math +ẍ = -\frac{GM}{(R+x)^2} = -\frac{GM}{R^2} \frac{1}{\left(1+ϵ\frac{x}{R}\right)^2}. +``` + +In the last equality, we introduced a perturbative expansion parameter $ϵ$. When $ϵ=1$, we recover the original problem. When $ϵ=0$, the problem reduces to the trivial problem $ẍ = -g$ with constant gravitational acceleration $g = GM/R^2$ and solution $x(t) = x(0) + ẋ(0) t - \frac{1}{2} g t^2$. This is a good setup for perturbation theory. + +To make the problem dimensionless, we redefine $x \leftarrow x / R$ and $t \leftarrow t / \sqrt{R^3/GM}$. Then the ODE becomes + +```@example perturbation +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables ϵ x(t) +eq = D(D(x)) ~ -(1 + ϵ * x)^(-2) +``` + +Next, expand $x(t)$ in a series up to second order in $ϵ$: + +```@example perturbation +using Symbolics +@variables y(t)[0:2] # coefficients +x_series = series(y, ϵ) +``` + +Insert this into the equation and collect perturbed equations to each order: + +```@example perturbation +eq_pert = substitute(eq, x => x_series) +eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2) +``` + +!!! note + + The 0-th order equation can be solved analytically, but ModelingToolkit does currently not feature automatic analytical solution of ODEs, so we proceed with solving it numerically. + +These are the ODEs we want to solve. Now construct an `ODESystem`, which automatically inserts dummy derivatives for the velocities: + +```@example perturbation +@mtkbuild sys = ODESystem(eqs_pert, t) +``` + +To solve the `ODESystem`, we generate an `ODEProblem` with initial conditions $x(0) = 0$, and $ẋ(0) = 1$, and solve it: + +```@example perturbation +using OrdinaryDiffEq +u0 = Dict([unknowns(sys) .=> 0.0; D(y[0]) => 1.0]) # nonzero initial velocity +prob = ODEProblem(sys, u0, (0.0, 3.0)) +sol = solve(prob) +``` + +This is the solution for the coefficients in the series for $x(t)$ and their derivatives. Finally, we calculate the solution to the original problem by summing the series for different $ϵ$: + +```@example perturbation +using Plots +p = plot() +for ϵᵢ in 0.0:0.1:1.0 + plot!(p, sol, idxs = substitute(x_series, ϵ => ϵᵢ), label = "ϵ = $ϵᵢ") +end +p +``` + +This makes sense: for larger $ϵ$, gravity weakens with altitude, and the trajectory goes higher for a fixed initial velocity. + +An advantage of the perturbative method is that we run the ODE solver only once and calculate trajectories for several $ϵ$ for free. Had we solved the full unperturbed ODE directly, we would need to do repeat it for every $ϵ$. + +## Weakly nonlinear oscillator + +Our second example applies perturbation theory to nonlinear oscillators -- a very important class of problems. As we will see, perturbation theory has difficulty providing a good solution to this problem, but the process is nevertheless instructive. This example closely follows chapter 7.6 of *Nonlinear Dynamics and Chaos* by Steven Strogatz. + +The goal is to solve the ODE + +```@example perturbation +eq = D(D(x)) + 2 * ϵ * D(x) + x ~ 0 +``` + +with initial conditions $x(0) = 0$ and $ẋ(0) = 1$. With $ϵ = 0$, the problem reduces to the simple linear harmonic oscillator with the exact solution $x(t) = \sin(t)$. + +We follow the same steps as in the previous example to construct the `ODESystem`: + +```@example perturbation +eq_pert = substitute(eq, x => x_series) +eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2) +@mtkbuild sys = ODESystem(eqs_pert, t) +``` + +We solve and plot it as in the previous example, and compare the solution with $ϵ=0.1$ to the exact solution $x(t, ϵ) = e^{-ϵ t} \sin(\sqrt{(1-ϵ^2)}\,t) / \sqrt{1-ϵ^2}$ of the unperturbed equation: + +```@example perturbation +u0 = Dict([unknowns(sys) .=> 0.0; D(y[0]) => 1.0]) # nonzero initial velocity +prob = ODEProblem(sys, u0, (0.0, 50.0)) +sol = solve(prob) +plot(sol, idxs = substitute(x_series, ϵ => 0.1); label = "Perturbative (ϵ=0.1)") + +x_exact(t, ϵ) = exp(-ϵ * t) * sin(√(1 - ϵ^2) * t) / √(1 - ϵ^2) +plot!(sol.t, x_exact.(sol.t, 0.1); label = "Exact (ϵ=0.1)") +``` + +This is similar to Figure 7.6.2 in *Nonlinear Dynamics and Chaos*. The two curves fit well for the first couple of cycles, but then the perturbative solution diverges from the exact solution. The main reason is that the problem has two or more time-scales that introduce secular terms in the solution. One solution is to explicitly account for the two time scales and use an analytic method called *two-timing*, but this is outside the scope of this example. diff --git a/docs/src/examples/remake.md b/docs/src/examples/remake.md new file mode 100644 index 0000000000..91dba4d7ae --- /dev/null +++ b/docs/src/examples/remake.md @@ -0,0 +1,143 @@ +# Optimizing through an ODE solve and re-creating MTK Problems + +Solving an ODE as part of an `OptimizationProblem`'s loss function is a common scenario. +In this example, we will go through an efficient way to model such scenarios using +ModelingToolkit.jl. + +First, we build the ODE to be solved. For this example, we will use a Lotka-Volterra model: + +```@example Remake +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters α β γ δ +@variables x(t) y(t) +eqs = [D(x) ~ (α - β * y) * x + D(y) ~ (δ * x - γ) * y] +@mtkbuild odesys = ODESystem(eqs, t) +``` + +To create the "data" for optimization, we will solve the system with a known set of +parameters. + +```@example Remake +using OrdinaryDiffEq + +odeprob = ODEProblem( + odesys, [x => 1.0, y => 1.0], (0.0, 10.0), [α => 1.5, β => 1.0, γ => 3.0, δ => 1.0]) +timesteps = 0.0:0.1:10.0 +sol = solve(odeprob, Tsit5(); saveat = timesteps) +data = Array(sol) +# add some random noise +data = data + 0.01 * randn(size(data)) +``` + +Now we will create the loss function for the Optimization solve. This will require creating +an `ODEProblem` with the parameter values passed to the loss function. Creating a new +`ODEProblem` is expensive and requires differentiating through the code generation process. +This can be bug-prone and is unnecessary. Instead, we will leverage the `remake` function. +This allows creating a copy of an existing problem with updating state/parameter values. It +should be noted that the types of the values passed to the loss function may not agree with +the types stored in the existing `ODEProblem`. Thus, we cannot use `setp` to modify the +problem in-place. Here, we will use the `replace` function from SciMLStructures.jl since +it allows updating the entire `Tunable` portion of the parameter object which contains the +parameters to optimize. + +```@example Remake +using SymbolicIndexingInterface: parameter_values, state_values +using SciMLStructures: Tunable, canonicalize, replace, replace! +using PreallocationTools + +function loss(x, p) + odeprob = p[1] # ODEProblem stored as parameters to avoid using global variables + ps = parameter_values(odeprob) # obtain the parameter object from the problem + diffcache = p[5] + # get an appropriately typed preallocated buffer to store the `x` values in + buffer = get_tmp(diffcache, x) + # copy the current values to this buffer + copyto!(buffer, canonicalize(Tunable(), ps)[1]) + # create a copy of the parameter object with the buffer + ps = replace(Tunable(), ps, buffer) + # set the updated values in the parameter object + setter = p[4] + setter(ps, x) + # remake the problem, passing in our new parameter object + newprob = remake(odeprob; p = ps) + timesteps = p[2] + sol = solve(newprob, AutoTsit5(Rosenbrock23()); saveat = timesteps) + truth = p[3] + data = Array(sol) + return sum((truth .- data) .^ 2) / length(truth) +end +``` + +Note how the problem, timesteps and true data are stored as model parameters. This helps +avoid referencing global variables in the function, which would slow it down significantly. + +We could have done the same thing by passing `remake` a map of parameter values. For example, +let us enforce that the order of ODE parameters in `x` is `[α β γ δ]`. Then, we could have +done: + +```julia +remake(odeprob; p = [α => x[1], β => x[2], γ => x[3], δ => x[4]]) +``` + +However, passing a symbolic map to `remake` is significantly slower than passing it a +parameter object directly. Thus, we use `replace` to speed up the process. In general, +`remake` is the most flexible method, but the flexibility comes at a cost of performance. + +We can perform the optimization as below: + +```@example Remake +using Optimization +using OptimizationOptimJL +using SymbolicIndexingInterface + +# manually create an OptimizationFunction to ensure usage of `ForwardDiff`, which will +# require changing the types of parameters from `Float64` to `ForwardDiff.Dual` +optfn = OptimizationFunction(loss, Optimization.AutoForwardDiff()) +# function to set the parameters we are optimizing +setter = setp(odeprob, [α, β, γ, δ]) +# `DiffCache` to avoid allocations. +# `copy` prevents the buffer stored by `DiffCache` from aliasing the one in +# `parameter_values(odeprob)`. +diffcache = DiffCache(copy(canonicalize(Tunable(), parameter_values(odeprob))[1])) +# parameter object is a tuple, to store differently typed objects together +optprob = OptimizationProblem( + optfn, rand(4), (odeprob, timesteps, data, setter, diffcache), + lb = 0.1zeros(4), ub = 3ones(4)) +sol = solve(optprob, BFGS()) +``` + +# Re-creating the problem + +There are multiple ways to re-create a problem with new state/parameter values. We will go +over the various methods, listing their use cases. + +## Pure `remake` + +This method is the most generic. It can handle symbolic maps, initializations of +parameters/states dependent on each other and partial updates. However, this comes at the +cost of performance. `remake` is also not always inferable. + +## `remake` and `setp`/`setu` + +Calling `remake(prob)` creates a copy of the existing problem. This new problem has the +exact same types as the original one, and the `remake` call is fully inferred. +State/parameter values can be modified after the copy by using `setp` and/or `setu`. This +is most appropriate when the types of state/parameter values does not need to be changed, +only their values. + +## `replace` and `remake` + +`replace` returns a copy of a parameter object, with the appropriate portion replaced by new +values. This is useful for changing the type of an entire portion, such as during the +optimization process described above. `remake` is used in this case to create a copy of the +problem with updated state/unknown values. + +## `remake` and `replace!` + +`replace!` is similar to `replace`, except that it operates in-place. This means that the +parameter values must be of the same types. This is useful for cases where bulk parameter +replacement is required without needing to change types. For example, optimization methods +where the gradient is not computed using dual numbers (as demonstrated above). diff --git a/docs/src/examples/sparse_jacobians.md b/docs/src/examples/sparse_jacobians.md new file mode 100644 index 0000000000..03bd80d432 --- /dev/null +++ b/docs/src/examples/sparse_jacobians.md @@ -0,0 +1,89 @@ +# Automated Sparse Analytical Jacobians + +In many cases where you have large stiff differential equations, getting a +sparse Jacobian can be essential for performance. In this tutorial, we will show +how to use `modelingtoolkitize` to regenerate an `ODEProblem` code with +the analytical solution to the sparse Jacobian, along with the sparsity +pattern required by DifferentialEquations.jl's solvers to specialize the solving +process. + +First, let's start out with an implementation of the 2-dimensional Brusselator +partial differential equation discretized using finite differences: + +```@example sparsejac +using OrdinaryDiffEq, ModelingToolkit + +const N = 32 +const xyd_brusselator = range(0, stop = 1, length = N) +brusselator_f(x, y, t) = (((x - 0.3)^2 + (y - 0.6)^2) <= 0.1^2) * (t >= 1.1) * 5.0 +limit(a, N) = a == N + 1 ? 1 : a == 0 ? N : a +function brusselator_2d_loop(du, u, p, t) + A, B, alpha, dx = p + alpha = alpha / dx^2 + @inbounds for I in CartesianIndices((N, N)) + i, j = Tuple(I) + x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] + ip1, im1, jp1, jm1 = limit(i + 1, N), limit(i - 1, N), limit(j + 1, N), + limit(j - 1, N) + du[i, j, 1] = alpha * (u[im1, j, 1] + u[ip1, j, 1] + u[i, jp1, 1] + u[i, jm1, 1] - + 4u[i, j, 1]) + + B + u[i, j, 1]^2 * u[i, j, 2] - (A + 1) * u[i, j, 1] + + brusselator_f(x, y, t) + du[i, j, 2] = alpha * (u[im1, j, 2] + u[ip1, j, 2] + u[i, jp1, 2] + u[i, jm1, 2] - + 4u[i, j, 2]) + + A * u[i, j, 1] - u[i, j, 1]^2 * u[i, j, 2] + end +end +p = (3.4, 1.0, 10.0, step(xyd_brusselator)) + +function init_brusselator_2d(xyd) + N = length(xyd) + u = zeros(N, N, 2) + for I in CartesianIndices((N, N)) + x = xyd[I[1]] + y = xyd[I[2]] + u[I, 1] = 22 * (y * (1 - y))^(3 / 2) + u[I, 2] = 27 * (x * (1 - x))^(3 / 2) + end + u +end +u0 = init_brusselator_2d(xyd_brusselator) +prob = ODEProblem(brusselator_2d_loop, u0, (0.0, 11.5), p) +``` + +Now let's use `modelingtoolkitize` to generate the symbolic version: + +```@example sparsejac +@mtkbuild sys = modelingtoolkitize(prob); +nothing # hide +``` + +Now we regenerate the problem using `jac=true` for the analytical Jacobian +and `sparse=true` to make it sparse: + +```@example sparsejac +sparseprob = ODEProblem(sys, Pair[], (0.0, 11.5), jac = true, sparse = true) +``` + +Hard? No! How much did that help? + +```@example sparsejac +using BenchmarkTools +@btime solve(prob, save_everystep = false); +return nothing # hide +``` + +```@example sparsejac +@btime solve(sparseprob, save_everystep = false); +return nothing # hide +``` + +Notice though that the analytical solution to the Jacobian can be quite expensive. +Thus in some cases we may only want to get the sparsity pattern. In this case, +we can simply do: + +```@example sparsejac +sparsepatternprob = ODEProblem(sys, Pair[], (0.0, 11.5), sparse = true) +@btime solve(sparsepatternprob, save_everystep = false); +return nothing # hide +``` diff --git a/docs/src/examples/spring_mass.md b/docs/src/examples/spring_mass.md new file mode 100644 index 0000000000..355e5c20b2 --- /dev/null +++ b/docs/src/examples/spring_mass.md @@ -0,0 +1,198 @@ +# Component-Based Modeling of a Spring-Mass System + +In this tutorial, we will build a simple component-based model of a spring-mass system. A spring-mass system consists of one or more masses connected by springs. [Hooke's law](https://en.wikipedia.org/wiki/Hooke%27s_law) gives the force exerted by a spring when it is extended or compressed by a given distance. This specifies a differential-equation system where the acceleration of the masses is specified using the forces acting on them. + +## Copy-Paste Example + +```@example component +using ModelingToolkit, Plots, OrdinaryDiffEq, LinearAlgebra +using ModelingToolkit: t_nounits as t, D_nounits as D +using Symbolics: scalarize + +function Mass(; name, m = 1.0, xy = [0.0, 0.0], u = [0.0, 0.0]) + ps = @parameters m = m + sts = @variables pos(t)[1:2]=xy v(t)[1:2]=u + eqs = scalarize(D.(pos) .~ v) + ODESystem(eqs, t, [pos..., v...], ps; name) +end + +function Spring(; name, k = 1e4, l = 1.0) + ps = @parameters k=k l=l + @variables x(t), dir(t)[1:2] + ODESystem(Equation[], t, [x, dir...], ps; name) +end + +function connect_spring(spring, a, b) + [spring.x ~ norm(scalarize(a .- b)) + scalarize(spring.dir .~ scalarize(a .- b))] +end + +function spring_force(spring) + -spring.k .* scalarize(spring.dir) .* (spring.x - spring.l) ./ spring.x +end + +m = 1.0 +xy = [1.0, -1.0] +k = 1e4 +l = 1.0 +center = [0.0, 0.0] +g = [0.0, -9.81] +@named mass = Mass(m = m, xy = xy) +@named spring = Spring(k = k, l = l) + +eqs = [connect_spring(spring, mass.pos, center) + scalarize(D.(mass.v) .~ spring_force(spring) / mass.m .+ g)] + +@named _model = ODESystem(eqs, t, [spring.x; spring.dir; mass.pos], []) +@named model = compose(_model, mass, spring) +sys = structural_simplify(model) + +prob = ODEProblem(sys, [], (0.0, 3.0)) +sol = solve(prob, Rosenbrock23()) +plot(sol) +``` + +## Explanation + +### Building the components + +For each component, we use a Julia function that returns an `ODESystem`. At the top, we define the fundamental properties of a `Mass`: it has a mass `m`, a position `pos` and a velocity `v`. We also define that the velocity is the rate of change of position with respect to time. + +```@example component +function Mass(; name, m = 1.0, xy = [0.0, 0.0], u = [0.0, 0.0]) + ps = @parameters m = m + sts = @variables pos(t)[1:2]=xy v(t)[1:2]=u + eqs = scalarize(D.(pos) .~ v) + ODESystem(eqs, t, [pos..., v...], ps; name) +end +``` + +Note that this is an incompletely specified `ODESystem`. It cannot be simulated on its own, since the equations for the velocity `v[1:2](t)` are unknown. Notice the addition of a `name` keyword. This allows us to generate different masses with different names. A `Mass` can now be constructed as: + +```@example component +Mass(name = :mass1) +``` + +Or using the `@named` helper macro + +```@example component +@named mass1 = Mass() +``` + +Next, we build the spring component. It is characterized by the spring constant `k` and the length `l` of the spring when no force is applied to it. The state of a spring is defined by its current length and direction. + +```@example component +function Spring(; name, k = 1e4, l = 1.0) + ps = @parameters k=k l=l + @variables x(t), dir(t)[1:2] + ODESystem(Equation[], t, [x, dir...], ps; name) +end +``` + +We now define functions that help construct the equations for a mass-spring system. First, the `connect_spring` function connects a `spring` between two positions `a` and `b`. Note that `a` and `b` can be the `pos` of a `Mass`, or just a fixed position such as `[0., 0.]`. In that sense, the length of the spring `x` is given by the length of the vector `dir` joining `a` and `b`. + +```@example component +function connect_spring(spring, a, b) + [spring.x ~ norm(scalarize(a .- b)) + scalarize(spring.dir .~ scalarize(a .- b))] +end +``` + +Lastly, we define the `spring_force` function that takes a `spring` and returns the force exerted by this spring. + +```@example component +function spring_force(spring) + -spring.k .* scalarize(spring.dir) .* (spring.x - spring.l) ./ spring.x +end +``` + +To create our system, we will first create the components: a mass and a spring. This is done as follows: + +```@example component +m = 1.0 +xy = [1.0, -1.0] +k = 1e4 +l = 1.0 +center = [0.0, 0.0] +g = [0.0, -9.81] +@named mass = Mass(m = m, xy = xy) +@named spring = Spring(k = k, l = l) +``` + +We can now create the equations describing this system, by connecting `spring` to `mass` and a fixed point. + +```@example component +eqs = [connect_spring(spring, mass.pos, center) + scalarize(D.(mass.v) .~ spring_force(spring) / mass.m .+ g)] +``` + +Finally, we can build the model using these equations and components. + +```@example component +@named _model = ODESystem(eqs, t, [spring.x; spring.dir; mass.pos], []) +@named model = compose(_model, mass, spring) +``` + +We can take a look at the equations in the model using the `equations` function. + +```@example component +equations(model) +``` + +The unknowns of this model are: + +```@example component +unknowns(model) +``` + +And the parameters of this model are: + +```@example component +parameters(model) +``` + +### Simplifying and solving this system + +This system can be solved directly as a DAE using [one of the DAE solvers from DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/solvers/dae_solve/). However, we can symbolically simplify the system first beforehand. Running `structural_simplify` eliminates unnecessary variables from the model to give the leanest numerical representation of the system. + +```@example component +sys = structural_simplify(model) +equations(sys) +``` + +We are left with only 4 equations involving 4 unknown variables (`mass.pos[1]`, +`mass.pos[2]`, `mass.v[1]`, `mass.v[2]`). We can solve the system by converting +it to an `ODEProblem`. Some observed variables are not expanded by default. To +view the complete equations, one can do + +```@example component +full_equations(sys) +``` + +This is done as follows: + +```@example component +prob = ODEProblem(sys, [], (0.0, 3.0)) +sol = solve(prob, Rosenbrock23()) +plot(sol) +``` + +What if we want the timeseries of a different variable? That information is not lost! Instead, `structural_simplify` simply changes unknown variables into `observed` variables. + +```@example component +observed(sys) +``` + +These are explicit algebraic equations which can be used to reconstruct the required variables on the fly. This leads to dramatic computational savings since implicitly solving an ODE scales as O(n^3), so fewer unknowns are significantly better! + +We can access these variables using the solution object. For example, let's retrieve the x-position of the mass over time: + +```@example component +sol[mass.pos[1]] +``` + +We can also plot the path of the mass: + +```@example component +plot(sol, idxs = (mass.pos[1], mass.pos[2])) +``` diff --git a/docs/src/examples/tearing_parallelism.md b/docs/src/examples/tearing_parallelism.md new file mode 100644 index 0000000000..9540e610bd --- /dev/null +++ b/docs/src/examples/tearing_parallelism.md @@ -0,0 +1,179 @@ +# Exposing More Parallelism By Tearing Algebraic Equations in ODESystems + +Sometimes it can be very non-trivial to parallelize a system. In this tutorial, +we will demonstrate how to make use of `structural_simplify` to expose more +parallelism in the solution process and parallelize the resulting simulation. + +## The Component Library + +The following tutorial will use the following set of components describing +electrical circuits: + +```@example tearing +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +# Basic electric components +@connector function Pin(; name) + @variables v(t)=1.0 i(t)=1.0 [connect = Flow] + ODESystem(Equation[], t, [v, i], [], name = name) +end + +function Ground(; name) + @named g = Pin() + eqs = [g.v ~ 0] + compose(ODESystem(eqs, t, [], [], name = name), g) +end + +function ConstantVoltage(; name, V = 1.0) + val = V + @named p = Pin() + @named n = Pin() + @parameters V = V + eqs = [V ~ p.v - n.v + 0 ~ p.i + n.i] + compose(ODESystem(eqs, t, [], [V], name = name), p, n) +end + +@connector function HeatPort(; name) + @variables T(t)=293.15 Q_flow(t)=0.0 [connect = Flow] + ODESystem(Equation[], t, [T, Q_flow], [], name = name) +end + +function HeatingResistor(; name, R = 1.0, TAmbient = 293.15, alpha = 1.0) + @named p = Pin() + @named n = Pin() + @named h = HeatPort() + @variables v(t) RTherm(t) + @parameters R=R TAmbient=TAmbient alpha=alpha + eqs = [RTherm ~ R * (1 + alpha * (h.T - TAmbient)) + v ~ p.i * RTherm + h.Q_flow ~ -v * p.i # -LossPower + v ~ p.v - n.v + 0 ~ p.i + n.i] + compose(ODESystem(eqs, t, [v, RTherm], [R, TAmbient, alpha], + name = name), p, n, h) +end + +function HeatCapacitor(; name, rho = 8050, V = 1, cp = 460, TAmbient = 293.15) + @parameters rho=rho V=V cp=cp + C = rho * V * cp + @named h = HeatPort() + eqs = [ + D(h.T) ~ h.Q_flow / C + ] + compose(ODESystem(eqs, t, [], [rho, V, cp], + name = name), h) +end + +function Capacitor(; name, C = 1.0) + @named p = Pin() + @named n = Pin() + @variables v(t) = 0.0 + @parameters C = C + eqs = [v ~ p.v - n.v + 0 ~ p.i + n.i + D(v) ~ p.i / C] + compose(ODESystem(eqs, t, [v], [C], + name = name), p, n) +end + +function parallel_rc_model(i; name, source, ground, R, C) + resistor = HeatingResistor(name = Symbol(:resistor, i), R = R) + capacitor = Capacitor(name = Symbol(:capacitor, i), C = C) + heat_capacitor = HeatCapacitor(name = Symbol(:heat_capacitor, i)) + + rc_eqs = [connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + connect(resistor.h, heat_capacitor.h)] + + compose(ODESystem(rc_eqs, t, name = Symbol(name, i)), + [resistor, capacitor, source, ground, heat_capacitor]) +end +``` + +## The Model + +Assuming that the components are defined, our model is 50 resistors and +capacitors connected in parallel. Thus following the [acausal components tutorial](@ref acausal), +we can connect a bunch of RC components as follows: + +```@example tearing +V = 2.0 +@named source = ConstantVoltage(V = V) +@named ground = Ground() +N = 50 +Rs = 10 .^ range(0, stop = -4, length = N) +Cs = 10 .^ range(-3, stop = 0, length = N) +rc_systems = map(1:N) do i + parallel_rc_model(i; name = :rc, source = source, ground = ground, R = Rs[i], C = Cs[i]) +end; +@variables E(t) = 0.0 +eqs = [ + D(E) ~ sum(((i, sys),) -> getproperty(sys, Symbol(:resistor, i)).h.Q_flow, + enumerate(rc_systems)) +] +@named _big_rc = ODESystem(eqs, t, [E], []) +@named big_rc = compose(_big_rc, rc_systems) +``` + +Now let's say we want to expose a bit more parallelism via running tearing. +How do we do that? + +```@example tearing +sys = structural_simplify(big_rc) +``` + +Done, that's it. There's no more to it. + +## What Happened? + +Yes, that's a good question! Let's investigate a little bit more what had happened. +If you look at the system we defined: + +```@example tearing +length(equations(big_rc)) +``` + +You see, it started as a massive 1051 set of equations. However, after eliminating +redundancies, we arrive at 151 equations: + +```@example tearing +equations(sys) +``` + +That's not all though. In addition, the tearing process has turned the sets of +nonlinear equations into separate blocks and constructed a DAG for the dependencies +between the blocks. We can use the bipartite graph functionality to dig in and +investigate what this means: + +```@example tearing +using ModelingToolkit.BipartiteGraphs +ts = TearingState(expand_connections(big_rc)) +inc_org = BipartiteGraphs.incidence_matrix(ts.structure.graph) +blt_org = StructuralTransformations.sorted_incidence_matrix(ts, only_algeqs = true, + only_algvars = true) +blt_reduced = StructuralTransformations.sorted_incidence_matrix( + ModelingToolkit.get_tearing_state(sys), + only_algeqs = true, + only_algvars = true) +``` + +![](https://user-images.githubusercontent.com/1814174/110589027-d4ec9b00-8143-11eb-8880-651da986504d.PNG) + +The figure on the left is the original incidence matrix of the algebraic equations. +Notice that the original formulation of the model has dependencies between different +equations, and so the full set of equations must be solved together. That exposes +no parallelism. However, the Block Lower Triangular (BLT) transformation exposes +independent blocks. This is then further improved by the tearing process, which +removes 90% of the equations and transforms the nonlinear equations into 50 +independent blocks, *which can now all be solved in parallel*. The conclusion +is that, your attempts to parallelize are neigh: performing parallelism after +structural simplification greatly improves the problem that can be parallelized, +so this is better than trying to do it by hand. + +After performing this, you can construct the `ODEProblem` and set +`parallel_form` to use the exposed parallelism in multithreaded function +constructions, but this showcases why `structural_simplify` is so important +to that process. diff --git a/docs/src/index.md b/docs/src/index.md index df01d1476b..3f079fa1d8 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -6,9 +6,9 @@ It then mixes ideas from symbolic computational algebra systems with causal and acausal equation-based modeling frameworks to give an extendable and parallel modeling system. It allows for users to give a high-level description of a model for symbolic preprocessing to analyze and enhance the model. Automatic -transformations, such as index reduction, can be applied to the model before -solving in order to make it easily handle equations would could not be solved -when modeled without symbolic intervention. +symbolic transformations, such as index reduction of differential-algebraic equations, +make it possible to solve equations that are impossible to solve +with a purely numeric-based technique. ## Installation @@ -46,106 +46,199 @@ before generating code. ### Feature List -- Causal and acausal modeling (Simulink/Modelica) -- Automated model transformation, simplification, and composition -- Automatic conversion of numerical models into symbolic models -- Composition of models through the components, a lazy connection system, and - tools for expanding/flattening -- Pervasive parallelism in symbolic computations and generated functions -- Transformations like alias elimination and tearing of nonlinear systems for - efficiently numerically handling large-scale systems of equations -- The ability to use the entire Symbolics.jl Computer Algebra System (CAS) as - part of the modeling process. -- Import models from common formats like SBML, CellML, BioNetGen, and more. -- Extendability: the whole system is written in pure Julia, so adding new - functions, simplification rules, and model transformations has no barrier. + - Causal and acausal modeling (Simulink/Modelica) + - Automated model transformation, simplification, and composition + - Automatic conversion of numerical models into symbolic models + - Composition of models through the components, a lazy connection system, and + tools for expanding/flattening + - Pervasive parallelism in symbolic computations and generated functions + - Transformations like alias elimination and tearing of nonlinear systems for + efficiently numerically handling large-scale systems of equations + - The ability to use the entire Symbolics.jl Computer Algebra System (CAS) as + part of the modeling process. + - Import models from common formats like SBML, CellML, BioNetGen, and more. + - Extensibility: the whole system is written in pure Julia, so adding new + functions, simplification rules, and model transformations has no barrier. For information on how to use the Symbolics.jl CAS system that ModelingToolkit.jl is built on, consult the -[Symbolics.jl documentation](https://github.com/JuliaSymbolics/Symbolics.jl) +[Symbolics.jl documentation](https://docs.sciml.ai/Symbolics/stable/) ### Equation Types -- Ordinary differential equations -- Stochastic differential equations -- Partial differential equations -- Nonlinear systems -- Optimization problems -- Continuous-Time Markov Chains -- Chemical Reactions -- Nonlinear Optimal Control + - Ordinary differential equations + - Stochastic differential equations + - Partial differential equations + - Nonlinear systems + - Optimization problems + - Continuous-Time Markov Chains + - Chemical Reactions (via [Catalyst.jl](https://docs.sciml.ai/Catalyst/stable/)) + - Nonlinear Optimal Control + +## Standard Library + +For quick development, ModelingToolkit.jl includes +[ModelingToolkitStandardLibrary.jl](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/), +a standard library of prebuilt components for the ModelingToolkit ecosystem. ## Model Import Formats -- [CellMLToolkit.jl](https://github.com/SciML/CellMLToolkit.jl): Import [CellML](https://www.cellml.org/) models into ModelingToolkit - - Repository of more than a thousand pre-made models - - Focus on biomedical models in areas such as: Calcium Dynamics, - Cardiovascular Circulation, Cell Cycle, Cell Migration, Circadian Rhythms, - Electrophysiology, Endocrine, Excitation-Contraction Coupling, Gene Regulation, - Hepatology, Immunology, Ion Transport, Mechanical Constitutive Laws, - Metabolism, Myofilament Mechanics, Neurobiology, pH Regulation, PKPD, - Protein Modules, Signal Transduction, and Synthetic Biology. -- [SbmlInterface.jl](https://github.com/paulflang/SbmlInterface.jl): Import [SBML](http://sbml.org/Main_Page) models into ModelingToolkit - - Uses the robust libsbml library for parsing and transforming the SBML -- [ReactionNetworkImporters.jl](https://github.com/isaacsas/ReactionNetworkImporters.jl): Import various models into ModelingToolkit - - Supports the BioNetGen `.net` file - - Supports importing networks specified by stoichiometric matrices + - [CellMLToolkit.jl](https://docs.sciml.ai/CellMLToolkit/stable/): Import [CellML](https://www.cellml.org/) models into ModelingToolkit + + + Repository of more than a thousand pre-made models + + Focus on biomedical models in areas such as: Calcium Dynamics, + Cardiovascular Circulation, Cell Cycle, Cell Migration, Circadian Rhythms, + Electrophysiology, Endocrine, Excitation-Contraction Coupling, Gene Regulation, + Hepatology, Immunology, Ion Transport, Mechanical Constitutive Laws, + Metabolism, Myofilament Mechanics, Neurobiology, pH Regulation, PKPD, + Protein Modules, Signal Transduction, and Synthetic Biology. + + - [SBMLToolkit.jl](https://docs.sciml.ai/SBMLToolkit/stable/): Import [SBML](http://sbml.org/) models into ModelingToolkit + + + Uses the robust libsbml library for parsing and transforming the SBML + - [ReactionNetworkImporters.jl](https://docs.sciml.ai/ReactionNetworkImporters/stable/): Import various models into ModelingToolkit + + + Supports the BioNetGen `.net` file + + Supports importing networks specified by stoichiometric matrices ## Extension Libraries -Because ModelingToolkit.jl is the core foundation of a equation-based modeling +Because ModelingToolkit.jl is the core foundation of an equation-based modeling ecosystem, there is a large set of libraries adding features to this system. Below is an incomplete list of extension libraries one may want to be aware of: -- [Catalyst.jl](https://github.com/SciML/Catalyst.jl): Symbolic representations - of chemical reactions - - Symbolically build and represent large systems of chemical reactions - - Generate code for ODEs, SDEs, continuous-time Markov Chains, and more - - Simulate the models using the SciML ecosystem with O(1) Gillespie methods -- [DataDrivenDiffEq.jl](https://github.com/SciML/DataDrivenDiffEq.jl): Automatic - identification of equations from data - - Automated construction of ODEs and DAEs from data - - Representations of Koopman operators and Dynamic Mode Decomposition (DMD) -- [MomentClosure.jl](https://github.com/augustinas1/MomentClosure.jl): Automatic - transformation of ReactionSystems into deterministic systems - - Generates ODESystems for the moment closures - - Allows for geometrically-distributed random reaction rates -- [ReactionMechanismSimulator.jl](https://github.com/ReactionMechanismGenerator/ReactionMechanismSimulator.jl): - simulating and analyzing large chemical reaction mechanisms - - Ideal gas and dilute liquid phases. - - Constant T and P and constant V adiabatic ideal gas reactors. - - Constant T and V dilute liquid reactors. - - Diffusion limited rates. Sensitivity analysis for all reactors. - - Flux diagrams with molecular images (if molecular information is provided). + - [Catalyst.jl](https://docs.sciml.ai/Catalyst/stable/): Symbolic representations + of chemical reactions + + + Symbolically build and represent large systems of chemical reactions + + Generate code for ODEs, SDEs, continuous-time Markov Chains, and more + + Simulate the models using the SciML ecosystem with O(1) Gillespie methods + + - [DataDrivenDiffEq.jl](https://docs.sciml.ai/DataDrivenDiffEq/stable/): Automatic + identification of equations from data + + + Automated construction of ODEs and DAEs from data + + Representations of Koopman operators and Dynamic Mode Decomposition (DMD) + - [MomentClosure.jl](https://augustinas1.github.io/MomentClosure.jl/dev/): Automatic + transformation of ReactionSystems into deterministic systems + + + Generates ODESystems for the moment closures + + Allows for geometrically-distributed random reaction rates + - [ReactionMechanismSimulator.jl](https://github.com/ReactionMechanismGenerator/ReactionMechanismSimulator.jl): + Simulating and analyzing large chemical reaction mechanisms + + + Ideal gas and dilute liquid phases. + + Constant T and P and constant V adiabatic ideal gas reactors. + + Constant T and V dilute liquid reactors. + + Diffusion limited rates. Sensitivity analysis for all reactors. + + Flux diagrams with molecular images (if molecular information is provided). + - [NumCME.jl](https://github.com/voduchuy/NumCME.jl): High-performance simulation of chemical master equations (CME) + + + Transient solution of the CME + + Dynamic state spaces + + Accepts reaction systems defined using Catalyst.jl DSL. + - [FiniteStateProjection.jl](https://github.com/SciML/FiniteStateProjection.jl): High-performance simulation of + chemical master equations (CME) via finite state projections + + + Accepts reaction systems defined using Catalyst.jl DSL. ## Compatible Numerical Solvers -All of the symbolic systems have a direct conversion to a numerical system which +All of the symbolic systems have a direct conversion to a numerical system, which can then be handled through the SciML interfaces. For example, after building a model and performing symbolic manipulations, an `ODESystem` can be converted into an `ODEProblem` to then be solved by a numerical ODE solver. Below is a list of the solver libraries which are the numerical targets of the ModelingToolkit system: -- [DifferentialEquations.jl](https://diffeq.sciml.ai/stable/) - - Multi-package interface of high performance numerical solvers for `ODESystem`, - `SDESystem`, and `JumpSystem` -- [NonlinearSolve.jl](https://github.com/JuliaComputing/NonlinearSolve.jl) - - High performance numerical solving of `NonlinearSystem` -- [GalacticOptim.jl](https://github.com/SciML/GalacticOptim.jl) - - Multi-package interface for numerical solving `OptimizationSystem` -- [NeuralPDE.jl](https://github.com/SciML/NeuralPDE.jl) - - Physics-Informed Neural Network (PINN) training on `PDESystem` -- [DiffEqOperators.jl](https://github.com/SciML/DiffEqOperators.jl) - - Automated finite difference method (FDM) discretization of `PDESystem` + - [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/) + + + Multi-package interface of high performance numerical solvers for `ODESystem`, + `SDESystem`, and `JumpSystem` + + - [NonlinearSolve.jl](https://docs.sciml.ai/NonlinearSolve/stable/) + + + High performance numerical solving of `NonlinearSystem` + - [Optimization.jl](https://docs.sciml.ai/Optimization/stable/) + + + Multi-package interface for numerical solving `OptimizationSystem` + - [NeuralPDE.jl](https://docs.sciml.ai/NeuralPDE/stable/) + + + Physics-Informed Neural Network (PINN) training on `PDESystem` + - [MethodOfLines.jl](https://docs.sciml.ai/MethodOfLines/stable/) + + + Automated finite difference method (FDM) discretization of `PDESystem` ## Contributing -- Please refer to the - [SciML ColPrac: Contributor's Guide on Collaborative Practices for Community Packages](https://github.com/SciML/ColPrac/blob/master/README.md) - for guidance on PRs, issues, and other matters relating to contributing to ModelingToolkit. -- There are a few community forums: - - The #diffeq-bridged channel in the [Julia Slack](https://julialang.org/slack/) - - [JuliaDiffEq](https://gitter.im/JuliaDiffEq/Lobby) on Gitter - - On the Julia Discourse forums (look for the [modelingtoolkit tag](https://discourse.julialang.org/tag/modelingtoolkit) - - See also [SciML Community page](https://sciml.ai/community/) + - Please refer to the + [SciML ColPrac: Contributor's Guide on Collaborative Practices for Community Packages](https://github.com/SciML/ColPrac/blob/master/README.md) + for guidance on PRs, issues, and other matters relating to contributing to SciML. + + - See the [SciML Style Guide](https://github.com/SciML/SciMLStyle) for common coding practices and other style decisions. + - There are a few community forums: + + + The #diffeq-bridged and #sciml-bridged channels in the + [Julia Slack](https://julialang.org/slack/) + + The #diffeq-bridged and #sciml-bridged channels in the + [Julia Zulip](https://julialang.zulipchat.com/#narrow/stream/279055-sciml-bridged) + + On the [Julia Discourse forums](https://discourse.julialang.org) + + See also [SciML Community page](https://sciml.ai/community/) + +## 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 +
+``` + +```@raw html +
A more complete overview of all dependencies and their versions is also provided. +``` + +```@example +using Pkg # hide +Pkg.status(; mode = PKGMODE_MANIFEST) # hide +``` + +```@raw html +
+``` + +```@eval +using TOML +using Markdown +version = TOML.parse(read("../../Project.toml", String))["version"] +name = TOML.parse(read("../../Project.toml", String))["name"] +link_manifest = "https://github.com/SciML/" * name * ".jl/tree/gh-pages/v" * version * + "/assets/Manifest.toml" +link_project = "https://github.com/SciML/" * name * ".jl/tree/gh-pages/v" * version * + "/assets/Project.toml" +Markdown.parse("""You can also download the +[manifest]($link_manifest) +file and the +[project]($link_project) +file. +""") +``` diff --git a/docs/src/internals.md b/docs/src/internals.md index 07e0721e25..00b29f1a64 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -5,14 +5,42 @@ contributors to the library. ## Observables and Variable Elimination -In the variable "elimination" algorithms, what is actually done is that variables -are removed from being states and equations are moved into the `observed` category +In the variable “elimination” algorithms, what is actually done is that variables +are removed from being unknowns and equations are moved into the `observed` category of the system. The `observed` equations are explicit algebraic equations which are then substituted out to completely eliminate these variables from the other equations, allowing the system to act as though these variables no longer exist. -However, as a user may have wanted to interact with such variables, for example, -plotting their output, these relationships are stored and are then used to -generate the `observed` equation found in the `SciMLFunction` interface, so that -`sol[x]` lazily reconstructs the observed variable when necessary. In this sense, -there is an equivalence between observables and the variable elimination system. +However, a user may want to interact with such variables, for example, +plotting their output. For this reason, these relationships are stored, +and are then used to generate the `observed` equation found in the +`SciMLFunction` interface, so that `sol[x]` lazily reconstructs the observed +variable when necessary. In this sense, there is an equivalence between +observables and the variable elimination system. + +The procedure for variable elimination inside [`structural_simplify`](@ref) is + + 1. [`ModelingToolkit.initialize_system_structure`](@ref). + 2. [`ModelingToolkit.alias_elimination`](@ref). This step moves equations into `observed(sys)`. + 3. [`ModelingToolkit.dae_index_lowering`](@ref) by means of [`pantelides!`](@ref) (if the system is an [`ODESystem`](@ref)). + 4. [`ModelingToolkit.tearing`](@ref). + +## Preparing a system for simulation + +Before a simulation or optimization can be performed, the symbolic equations stored in an [`AbstractSystem`](@ref) must be converted into executable code. This step typically occurs after the simplification explained above, and is performed when an instance of a [`SciMLBase.AbstractSciMLProblem`](@ref), such as a [`ODEProblem`](@ref), is constructed. +The call chain typically looks like this, with the function names in the case of an `ODESystem` indicated in parentheses + + 1. Problem constructor ([`ODEProblem`](@ref)) + 2. Build an `DEFunction` ([`process_DEProblem`](@ref) -> [`ODEFunction`](@ref) + 3. Write actual executable code ([`generate_function`](@ref) or [`generate_custom_function`](@ref)) + +Apart from [`generate_function`](@ref), which generates the dynamics function, `ODEFunction` also builds functions for observed equations (`build_explicit_observed_function`) and Jacobians (`generate_jacobian`) etc. These are all stored in the `ODEFunction`. + +## Creating an `MTKParameters` object + +It may be useful to create a parameter object without creating the problem. For this +purpose, the `MTKParameters` constructor is exposed as public API. + +```@docs +MTKParameters +``` diff --git a/docs/src/mtkitize_tutorials/modelingtoolkitize.md b/docs/src/mtkitize_tutorials/modelingtoolkitize.md deleted file mode 100644 index cc43090d06..0000000000 --- a/docs/src/mtkitize_tutorials/modelingtoolkitize.md +++ /dev/null @@ -1,33 +0,0 @@ -# Automatically Accelerating ODEProblem Code - -For some `DEProblem` types, automatic tracing functionality is already included -via the `modelingtoolkitize` function. Take, for example, the Robertson ODE -defined as an `ODEProblem` for DifferentialEquations.jl: - -```julia -using DifferentialEquations -function rober(du,u,p,t) - y₁,y₂,y₃ = u - k₁,k₂,k₃ = p - du[1] = -k₁*y₁+k₃*y₂*y₃ - du[2] = k₁*y₁-k₂*y₂^2-k₃*y₂*y₃ - du[3] = k₂*y₂^2 - nothing -end -prob = ODEProblem(rober,[1.0,0.0,0.0],(0.0,1e5),(0.04,3e7,1e4)) -``` - -If we want to get a symbolic representation, we can simply call `modelingtoolkitize` -on the `prob`, which will return an `ODESystem`: - -```julia -sys = modelingtoolkitize(prob) -``` - -Using this, we can symbolically build the Jacobian and then rebuild the ODEProblem: - -```julia -jac = eval(ModelingToolkit.generate_jacobian(sys)[2]) -f = ODEFunction(rober, jac=jac) -prob_jac = ODEProblem(f,[1.0,0.0,0.0],(0.0,1e5),(0.04,3e7,1e4)) -``` diff --git a/docs/src/mtkitize_tutorials/sparse_jacobians.md b/docs/src/mtkitize_tutorials/sparse_jacobians.md deleted file mode 100644 index 20b2086026..0000000000 --- a/docs/src/mtkitize_tutorials/sparse_jacobians.md +++ /dev/null @@ -1,78 +0,0 @@ -# Automated Sparse Analytical Jacobians - -In many cases where you have large stiff differential equations, getting a -sparse Jacobian can be essential for performance. In this tutorial we will show -how to use `modelingtoolkitize` to regenerate an `ODEProblem` code with -the analytical solution to the sparse Jacobian, along with the sparsity -pattern required by DifferentialEquations.jl's solvers to specialize the solving -process. - -First let's start out with an implementation of the 2-dimensional Brusselator -partial differential equation discretized using finite differences: - -```julia -using DifferentialEquations, ModelingToolkit - -const N = 32 -const xyd_brusselator = range(0,stop=1,length=N) -brusselator_f(x, y, t) = (((x-0.3)^2 + (y-0.6)^2) <= 0.1^2) * (t >= 1.1) * 5. -limit(a, N) = a == N+1 ? 1 : a == 0 ? N : a -function brusselator_2d_loop(du, u, p, t) - A, B, alpha, dx = p - alpha = alpha/dx^2 - @inbounds for I in CartesianIndices((N, N)) - i, j = Tuple(I) - x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] - ip1, im1, jp1, jm1 = limit(i+1, N), limit(i-1, N), limit(j+1, N), limit(j-1, N) - du[i,j,1] = alpha*(u[im1,j,1] + u[ip1,j,1] + u[i,jp1,1] + u[i,jm1,1] - 4u[i,j,1]) + - B + u[i,j,1]^2*u[i,j,2] - (A + 1)*u[i,j,1] + brusselator_f(x, y, t) - du[i,j,2] = alpha*(u[im1,j,2] + u[ip1,j,2] + u[i,jp1,2] + u[i,jm1,2] - 4u[i,j,2]) + - A*u[i,j,1] - u[i,j,1]^2*u[i,j,2] - end -end -p = (3.4, 1., 10., step(xyd_brusselator)) - -function init_brusselator_2d(xyd) - N = length(xyd) - u = zeros(N, N, 2) - for I in CartesianIndices((N, N)) - x = xyd[I[1]] - y = xyd[I[2]] - u[I,1] = 22*(y*(1-y))^(3/2) - u[I,2] = 27*(x*(1-x))^(3/2) - end - u -end -u0 = init_brusselator_2d(xyd_brusselator) -prob = ODEProblem(brusselator_2d_loop,u0,(0.,11.5),p) -``` - -Now let's use `modelingtoolkitize` to generate the symbolic version: - -```julia -sys = modelingtoolkitize(prob_ode_brusselator_2d) -``` - -Now we regenerate the problem using `jac=true` for the analytical Jacobian -and `sparse=true` to make it sparse: - -```julia -sparseprob = ODEProblem(sys,Pair[],(0.,11.5),jac=true,sparse=true) -``` - -Hard? No! How much did that help? - -```julia -using BenchmarkTools -@btime solve(prob,save_everystep=false) # 51.714 s (7317 allocations: 70.12 MiB) -@btime solve(sparseprob,save_everystep=false) # 2.880 s (55533 allocations: 885.09 MiB) -``` - -Notice though that the analytical solution to the Jacobian can be quite expensive. -Thus in some cases we may only want to get the sparsity pattern. In this case, -we can simply do: - -```julia -sparsepatternprob = ODEProblem(sys,Pair[],(0.,11.5),sparse=true) -@btime solve(sparsepatternprob,save_everystep=false) # 2.880 s (55533 allocations: 885.09 MiB) -``` diff --git a/docs/src/systems/ControlSystem.md b/docs/src/systems/ControlSystem.md deleted file mode 100644 index 940d872712..0000000000 --- a/docs/src/systems/ControlSystem.md +++ /dev/null @@ -1,23 +0,0 @@ -# ControlSystem - -## System Constructors - -```@docs -ControlSystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The equations that define the system. -- `get_states(sys)` or `states(sys)`: The set of states in the system. -- `get_ps(sys)` or `parameters(sys)`: The parameters of the system. -- `get_controls(sys)` or `controls(sys)`: The control variables of the system - -## Transformations - -```@docs -ModelingToolkit.runge_kutta_discretize -structural_simplify -``` - -## Analyses diff --git a/docs/src/systems/DiscreteSystem.md b/docs/src/systems/DiscreteSystem.md new file mode 100644 index 0000000000..f8a71043ab --- /dev/null +++ b/docs/src/systems/DiscreteSystem.md @@ -0,0 +1,34 @@ +# DiscreteSystem + +## System Constructors + +```@docs +DiscreteSystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the discrete system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the discrete system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the discrete system. + - `get_iv(sys)`: The independent variable of the discrete system + - `discrete_events(sys)`: The set of discrete events in the discrete system. + +## Transformations + +```@docs; canonical=false +structural_simplify +``` + +## Problem Constructors + +```@docs; canonical=false +DiscreteProblem(sys::DiscreteSystem, u0map, tspan) +DiscreteFunction(sys::DiscreteSystem, args...) +``` + +## Discrete Domain + +```@docs; canonical=false +Shift +``` diff --git a/docs/src/systems/ImplicitDiscreteSystem.md b/docs/src/systems/ImplicitDiscreteSystem.md new file mode 100644 index 0000000000..d69f88f106 --- /dev/null +++ b/docs/src/systems/ImplicitDiscreteSystem.md @@ -0,0 +1,34 @@ +# ImplicitDiscreteSystem + +## System Constructors + +```@docs +ImplicitDiscreteSystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the implicit discrete system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the implicit discrete system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the implicit discrete system. + - `get_iv(sys)`: The independent variable of the implicit discrete system + - `discrete_events(sys)`: The set of discrete events in the implicit discrete system. + +## Transformations + +```@docs; canonical=false +structural_simplify +``` + +## Problem Constructors + +```@docs; canonical=false +ImplicitDiscreteProblem(sys::ImplicitDiscreteSystem, u0map, tspan) +ImplicitDiscreteFunction(sys::ImplicitDiscreteSystem, args...) +``` + +## Discrete Domain + +```@docs; canonical=false +Shift +``` diff --git a/docs/src/systems/JumpSystem.md b/docs/src/systems/JumpSystem.md index 980ec24db4..5bd0d50602 100644 --- a/docs/src/systems/JumpSystem.md +++ b/docs/src/systems/JumpSystem.md @@ -8,14 +8,15 @@ JumpSystem ## Composition and Accessor Functions -- `get_eqs(sys)` or `equations(sys)`: The equations that define the jump system. -- `get_states(sys)` or `states(sys)`: The set of states in the jump system. -- `get_ps(sys)` or `parameters(sys)`: The parameters of the jump system. -- `independent_variable(sys)`: The independent variable of the jump system. + - `get_eqs(sys)` or `equations(sys)`: The equations that define the jump system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the jump system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the jump system. + - `get_iv(sys)`: The independent variable of the jump system. + - `discrete_events(sys)`: The set of discrete events in the jump system. ## Transformations -```@docs +```@docs; canonical=false structural_simplify ``` @@ -23,7 +24,10 @@ structural_simplify ## Problem Constructors +```@docs; canonical=false +DiscreteProblem(sys::JumpSystem, u0map, tspan) +``` + ```@docs -DiscreteProblem -JumpProblem +JumpProblem(sys::JumpSystem, prob, aggregator) ``` diff --git a/docs/src/systems/NonlinearSystem.md b/docs/src/systems/NonlinearSystem.md index 3147ecb0ee..06d587b1b9 100644 --- a/docs/src/systems/NonlinearSystem.md +++ b/docs/src/systems/NonlinearSystem.md @@ -1,47 +1,57 @@ -# NonlinearSystem - -## System Constructors - -```@docs -NonlinearSystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The equations that define the nonlinear system. -- `get_states(sys)` or `states(sys)`: The set of states in the nonlinear system. -- `get_ps(sys)` or `parameters(sys)`: The parameters of the nonlinear system. - -## Transformations - -```@docs -structural_simplify -alias_elimination -tearing -``` - -## Analyses - -```@docs -ModelingToolkit.islinear -``` - -## Applicable Calculation and Generation Functions - -```julia -calculate_jacobian -generate_jacobian -jacobian_sparsity -``` - -## Problem Constructors - -```@docs -NonlinearProblem -``` - -## Torn Problem Constructors - -```@docs -BlockNonlinearProblem -``` +# NonlinearSystem + +## System Constructors + +```@docs +NonlinearSystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the nonlinear system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the nonlinear system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the nonlinear system. + - `get_u0_p(sys, u0map, parammap)` Numeric arrays for the initial condition and parameters given `var => value` maps. + +## Transformations + +```@docs; canonical=false +structural_simplify +alias_elimination +tearing +``` + +## Analyses + +```@docs; canonical=false +ModelingToolkit.isaffine +ModelingToolkit.islinear +``` + +## Applicable Calculation and Generation Functions + +```@docs; canonical=false +calculate_jacobian +generate_jacobian +jacobian_sparsity +``` + +## Problem Constructors + +```@docs +NonlinearFunction(sys::ModelingToolkit.NonlinearSystem, args...) +NonlinearProblem(sys::ModelingToolkit.NonlinearSystem, args...) +``` + +## Torn Problem Constructors + +```@docs +BlockNonlinearProblem +``` + +## Expression Constructors + +```@docs +NonlinearFunctionExpr +NonlinearProblemExpr +``` diff --git a/docs/src/systems/ODESystem.md b/docs/src/systems/ODESystem.md index 9adf55c5b2..24e2952fc5 100644 --- a/docs/src/systems/ODESystem.md +++ b/docs/src/systems/ODESystem.md @@ -1,59 +1,74 @@ -# ODESystem - -## System Constructors - -```@docs -ODESystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The equations that define the ODE. -- `get_states(sys)` or `states(sys)`: The set of states in the ODE. -- `get_ps(sys)` or `parameters(sys)`: The parameters of the ODE. -- `independent_variable(sys)`: The independent variable of the ODE. - -## Transformations - -```@docs -structural_simplify -ode_order_lowering -dae_index_lowering -liouville_transform -alias_elimination -tearing -``` - -## Analyses - -```@docs -ModelingToolkit.islinear -ModelingToolkit.isautonomous -``` - -## Applicable Calculation and Generation Functions - -```julia -calculate_jacobian -calculate_tgrad -calculate_factorized_W -generate_jacobian -generate_tgrad -generate_factorized_W -jacobian_sparsity -``` - -## Standard Problem Constructors - -```@docs -ODEFunction -ODEProblem -SteadyStateFunction -SteadyStateProblem -``` - -## Torn Problem Constructors - -```@docs -ODAEProblem -``` +# ODESystem + +## System Constructors + +```@docs +ODESystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the ODE. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the ODE. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the ODE. + - `get_iv(sys)`: The independent variable of the ODE. + - `get_u0_p(sys, u0map, parammap)` Numeric arrays for the initial condition and parameters given `var => value` maps. + - `continuous_events(sys)`: The set of continuous events in the ODE. + - `discrete_events(sys)`: The set of discrete events in the ODE. + - `alg_equations(sys)`: The algebraic equations (i.e. that does not contain a differential) that defines the ODE. + - `get_alg_eqs(sys)`: The algebraic equations (i.e. that does not contain a differential) that defines the ODE. Only returns equations of the current-level system. + - `diff_equations(sys)`: The differential equations (i.e. that contain a differential) that defines the ODE. + - `get_diff_eqs(sys)`: The differential equations (i.e. that contain a differential) that defines the ODE. Only returns equations of the current-level system. + - `has_alg_equations(sys)`: Returns `true` if the ODE contains any algebraic equations (i.e. that does not contain a differential). + - `has_alg_eqs(sys)`: Returns `true` if the ODE contains any algebraic equations (i.e. that does not contain a differential). Only considers the current-level system. + - `has_diff_equations(sys)`: Returns `true` if the ODE contains any differential equations (i.e. that does contain a differential). + - `has_diff_eqs(sys)`: Returns `true` if the ODE contains any differential equations (i.e. that does contain a differential). Only considers the current-level system. + +## Transformations + +```@docs +structural_simplify +ode_order_lowering +dae_index_lowering +change_independent_variable +liouville_transform +alias_elimination +tearing +``` + +## Analyses + +```@docs +ModelingToolkit.islinear +ModelingToolkit.isautonomous +ModelingToolkit.isaffine +``` + +## Applicable Calculation and Generation Functions + +```@docs; canonical=false +calculate_jacobian +calculate_tgrad +calculate_factorized_W +generate_jacobian +generate_tgrad +generate_factorized_W +jacobian_sparsity +``` + +## Standard Problem Constructors + +```@docs +ODEFunction(sys::ModelingToolkit.AbstractODESystem, args...) +ODEProblem(sys::ModelingToolkit.AbstractODESystem, args...) +SteadyStateProblem(sys::ModelingToolkit.AbstractODESystem, args...) +DAEProblem(sys::ModelingToolkit.AbstractODESystem, args...) +``` + +## Expression Constructors + +```@docs +ODEFunctionExpr +DAEFunctionExpr +SteadyStateProblemExpr +``` diff --git a/docs/src/systems/OptimizationSystem.md b/docs/src/systems/OptimizationSystem.md index c1823b19df..bcc8b21de7 100644 --- a/docs/src/systems/OptimizationSystem.md +++ b/docs/src/systems/OptimizationSystem.md @@ -1,33 +1,40 @@ -# OptimizationSystem - -## System Constructors - -```@docs -OptimizationSystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The equation to be minimized. -- `get_states(sys)` or `states(sys)`: The set of states for the optimization. -- `get_ps(sys)` or `parameters(sys)`: The parameters for the optimization. - -## Transformations - -## Analyses - -## Applicable Calculation and Generation Functions - -```julia -calculate_gradient -calculate_hessian -generate_gradient -generate_hessian -hessian_sparsity -``` - -## Problem Constructors - -```@docs -OptimizationProblem -``` +# OptimizationSystem + +## System Constructors + +```@docs +OptimizationSystem +``` + +## Composition and Accessor Functions + + - `get_op(sys)`: The objective to be minimized. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns for the optimization. + - `get_ps(sys)` or `parameters(sys)`: The parameters for the optimization. + - `get_constraints(sys)` or `constraints(sys)`: The constraints for the optimization. + +## Transformations + +## Analyses + +## Applicable Calculation and Generation Functions + +```julia +calculate_gradient +calculate_hessian +generate_gradient +generate_hessian +hessian_sparsity +``` + +## Problem Constructors + +```@docs +OptimizationProblem(sys::ModelingToolkit.OptimizationSystem, args...) +``` + +## Expression Constructors + +```@docs +OptimizationProblemExpr +``` diff --git a/docs/src/systems/PDESystem.md b/docs/src/systems/PDESystem.md index e0790f46b8..fa78229cd3 100644 --- a/docs/src/systems/PDESystem.md +++ b/docs/src/systems/PDESystem.md @@ -1,82 +1,83 @@ -# PDESystem - -`PDESystem` is the common symbolic PDE specification for the SciML ecosystem. -It is currently being built as a component of the ModelingToolkit ecosystem, - -## Vision - -The vision for the common PDE interface is that a user should only have to specify -their PDE once, mathematically, and have instant access to everything as simple -as a finite difference method with constant grid spacing, to something as complex -as a distributed multi-GPU discrete Galerkin method. - -The key to the common PDE interface is a separation of the symbolic handling from -the numerical world. All of the discretizers should not "solve" the PDE, but -instead be a conversion of the mathematical specification to a numerical problem. -Preferably, the transformation should be to another ModelingToolkit.jl `AbstractSystem`, -but in some cases this cannot be done or will not be performant, so a `SciMLProblem` is -the other choice. - -These elementary problems, such as solving linear systems `Ax=b`, solving nonlinear -systems `f(x)=0`, ODEs, etc. are all defined by SciMLBase.jl, which then numerical -solvers can all target these common forms. Thus someone who works on linear solvers -doesn't necessarily need to be working on a Discontinuous Galerkin or finite element -library, but instead "linear solvers that are good for matrices A with -properties ..." which are then accessible by every other discretization method -in the common PDE interface. - -Similar to the rest of the `AbstractSystem` types, transformation and analyses -functions will allow for simplifying the PDE before solving it, and constructing -block symbolic functions like Jacobians. - -## Constructors - -```@docs -PDESystem -``` - -### Domains (WIP) - -Domains are specifying by saying `indepvar in domain`, where `indepvar` is a -single or a collection of independent variables, and `domain` is the chosen -domain type. Thus forms for the `indepvar` can be like: - -```julia -t ∈ IntervalDomain(0.0,1.0) -(t,x) ∈ UnitDisk() -[v,w,x,y,z] ∈ VectorUnitBall(5) -``` - -#### Domain Types (WIP) - -- `IntervalDomain(a,b)`: Defines the domain of an interval from `a` to `b` - -## `discretize` and `symbolic_discretize` - -The only functions which act on a PDESystem are the following: - -- `discretize(sys,discretizer)`: produces the outputted `AbstractSystem` or - `SciMLProblem`. -- `symbolic_discretize(sys,discretizer)`: produces a debugging symbolic description - of the discretized problem. - -## Boundary Conditions (WIP) - -## Transformations - -## Analyses - -## Discretizer Ecosystem - -### NeuralPDE.jl: PhysicsInformedNN - -[NeuralPDE.jl](https://github.com/SciML/NeuralPDE.jl) defines the `PhysicsInformedNN` -discretizer which uses a [DiffEqFlux.jl](https://github.com/SciML/DiffEqFlux.jl) -neural network to solve the differential equation. - -### DiffEqOperators.jl: MOLFiniteDifference (WIP) - -[DiffEqOperators.jl](https://github.com/SciML/DiffEqOperators.jl) defines the -`MOLFiniteDifference` discretizer which performs a finite difference discretization -using the DiffEqOperators.jl stencils. These stencils make use of NNLib.jl for -fast operations on semi-linear domains. +# PDESystem + +`PDESystem` is the common symbolic PDE specification for the SciML ecosystem. +It is currently being built as a component of the ModelingToolkit ecosystem, + +## Vision + +The vision for the common PDE interface is that a user should only have to specify +their PDE once, mathematically, and have instant access to everything as simple +as a finite difference method with constant grid spacing, to something as complex +as a distributed multi-GPU discontinuous Galerkin method. + +The key to the common PDE interface is a separation of the symbolic handling from +the numerical world. All the discretizers should not “solve” the PDE, but +instead be a conversion of the mathematical specification to a numerical problem. +Preferably, the transformation should be to another ModelingToolkit.jl `AbstractSystem`, +but in some cases this cannot be done or will not be performant, so a `SciMLProblem` is +the other choice. + +These elementary problems, such as solving linear systems `Ax=b`, solving nonlinear +systems `f(x)=0`, ODEs, etc. are all defined by SciMLBase.jl, which then numerical +solvers can all target these common forms. Thus, someone who works on linear solvers +doesn't necessarily need to be working on a discontinuous Galerkin or finite element +library, but instead "linear solvers that are good for matrices A with +properties ..." which are then accessible by every other discretization method +in the common PDE interface. + +Similar to the rest of the `AbstractSystem` types, transformation, and analysis +functions will allow for simplifying the PDE before solving it, and constructing +block symbolic functions like Jacobians. + +## Constructors + +```@docs +PDESystem +``` + +### Domains (WIP) + +Domains are specifying by saying `indepvar in domain`, where `indepvar` is a +single or a collection of independent variables, and `domain` is the chosen +domain type. A 2-tuple can be used to indicate an `Interval`. +Thus forms for the `indepvar` can be like: + +```julia +t ∈ (0.0, 1.0) +(t, x) ∈ UnitDisk() +[v, w, x, y, z] ∈ VectorUnitBall(5) +``` + +#### Domain Types (WIP) + + - `Interval(a,b)`: Defines the domain of an interval from `a` to `b` (requires explicit + import from `DomainSets.jl`, but a 2-tuple can be used instead) + +## `discretize` and `symbolic_discretize` + +The only functions which act on a PDESystem are the following: + + - `discretize(sys,discretizer)`: produces the outputted `AbstractSystem` or + `SciMLProblem`. + - `symbolic_discretize(sys,discretizer)`: produces a debugging symbolic description + of the discretized problem. + +## Boundary Conditions (WIP) + +## Transformations + +## Analyses + +## Discretizer Ecosystem + +### NeuralPDE.jl: PhysicsInformedNN + +[NeuralPDE.jl](https://docs.sciml.ai/NeuralPDE/stable/) defines the `PhysicsInformedNN` +discretizer which uses a [DiffEqFlux.jl](https://docs.sciml.ai/DiffEqFlux/stable/) +neural network to solve the differential equation. + +### MethodOfLines.jl: MOLFiniteDifference + +[MethodOfLines.jl](https://docs.sciml.ai/MethodOfLines/stable/) defines the +`MOLFiniteDifference` discretizer which performs a finite difference discretization. +Includes support for higher approximation order stencils and nonuniform grids. diff --git a/docs/src/systems/ReactionSystem.md b/docs/src/systems/ReactionSystem.md deleted file mode 100644 index 8bb43d2791..0000000000 --- a/docs/src/systems/ReactionSystem.md +++ /dev/null @@ -1,69 +0,0 @@ -# ReactionSystem - -A `ReactionSystem` represents a system of chemical reactions. Conversions are -provided to generate corresponding chemical reaction ODE models, chemical -Langevin equation SDE models, and stochastic chemical kinetics jump process -models. As a simple example, the code below creates a SIR model, and solves -the corresponding ODE, SDE, and jump process models. - -```julia -using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, DiffEqJump -@parameters β γ t -@variables S(t) I(t) R(t) - -rxs = [Reaction(β, [S,I], [I], [1,1], [2]) - Reaction(γ, [I], [R])] -rs = ReactionSystem(rxs, t, [S,I,R], [β,γ]) - -u₀map = [S => 999.0, I => 1.0, R => 0.0] -parammap = [β => 1/10000, γ => 0.01] -tspan = (0.0, 250.0) - -# solve as ODEs -odesys = convert(ODESystem, rs) -oprob = ODEProblem(odesys, u₀map, tspan, parammap) -sol = solve(oprob, Tsit5()) - -# solve as SDEs -sdesys = convert(SDESystem, rs) -sprob = SDEProblem(sdesys, u₀map, tspan, parammap) -sol = solve(sprob, EM(), dt=.01) - -# solve as jump process -jumpsys = convert(JumpSystem, rs) -u₀map = [S => 999, I => 1, R => 0] -dprob = DiscreteProblem(jumpsys, u₀map, tspan, parammap) -jprob = JumpProblem(jumpsys, dprob, Direct()) -sol = solve(jprob, SSAStepper()) -``` - -## System Constructors - -```@docs -Reaction -ReactionSystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The reactions that define the system. -- `get_states(sys)` or `states(sys)`: The set of chemical species in the system. -- `get_ps(sys)` or `parameters(sys)`: The parameters of the system. -- `independent_variable(sys)`: The independent variable of the - reaction system, usually time. - -## Query Functions -```@docs -oderatelaw -jumpratelaw -ismassaction -``` - -## Transformations - -```@docs -Base.convert -structural_simplify -``` - -## Analyses diff --git a/docs/src/systems/SDESystem.md b/docs/src/systems/SDESystem.md index 7026de7f3f..5789d2d9cb 100644 --- a/docs/src/systems/SDESystem.md +++ b/docs/src/systems/SDESystem.md @@ -1,42 +1,70 @@ -# SDESystem - -## System Constructors - -```@docs -SDESystem -``` - -## Composition and Accessor Functions - -- `get_eqs(sys)` or `equations(sys)`: The equations that define the SDE. -- `get_states(sys)` or `states(sys)`: The set of states in the SDE. -- `get_ps(sys)s` or `parameters(sys)`: The parameters of the SDE. -- `independent_variable(sys)`: The independent variable of the SDE. - -## Transformations - -```@docs -structural_simplify -alias_elimination -``` - -## Analyses - -## Applicable Calculation and Generation Functions - -```julia -calculate_jacobian -calculate_tgrad -calculate_factorized_W -generate_jacobian -generate_tgrad -generate_factorized_W -jacobian_sparsity -``` - -## Problem Constructors - -```@docs -SDEFunction -SDEProblem -``` +# SDESystem + +## System Constructors + +```@docs +SDESystem +``` + +To convert an `ODESystem` to an `SDESystem` directly: + +``` +ode = ODESystem(eqs,t,[x,y,z],[σ,ρ,β]) +sde = SDESystem(ode, noiseeqs) +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the SDE. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the SDE. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the SDE. + - `get_iv(sys)`: The independent variable of the SDE. + - `continuous_events(sys)`: The set of continuous events in the SDE. + - `discrete_events(sys)`: The set of discrete events in the SDE. + - `alg_equations(sys)`: The algebraic equations (i.e. that does not contain a differential) that defines the ODE. + - `get_alg_eqs(sys)`: The algebraic equations (i.e. that does not contain a differential) that defines the ODE. Only returns equations of the current-level system. + - `diff_equations(sys)`: The differential equations (i.e. that contain a differential) that defines the ODE. + - `get_diff_eqs(sys)`: The differential equations (i.e. that contain a differential) that defines the ODE. Only returns equations of the current-level system. + - `has_alg_equations(sys)`: Returns `true` if the ODE contains any algebraic equations (i.e. that does not contain a differential). + - `has_alg_eqs(sys)`: Returns `true` if the ODE contains any algebraic equations (i.e. that does not contain a differential). Only considers the current-level system. + - `has_diff_equations(sys)`: Returns `true` if the ODE contains any differential equations (i.e. that does contain a differential). + - `has_diff_eqs(sys)`: Returns `true` if the ODE contains any differential equations (i.e. that does contain a differential). Only considers the current-level system. + +## Transformations + +```@docs; canonical=false +structural_simplify +alias_elimination +``` + +```@docs +ModelingToolkit.Girsanov_transform +``` + +## Analyses + +## Applicable Calculation and Generation Functions + +```@docs; canonical=false +calculate_jacobian +calculate_tgrad +calculate_factorized_W +generate_jacobian +generate_tgrad +generate_factorized_W +jacobian_sparsity +``` + +## Problem Constructors + +```@docs +SDEFunction(sys::ModelingToolkit.SDESystem, args...) +SDEProblem(sys::ModelingToolkit.SDESystem, args...) +``` + +## Expression Constructors + +```@docs +SDEFunctionExpr +SDEProblemExpr +``` diff --git a/docs/src/tutorials/SampledData.md b/docs/src/tutorials/SampledData.md new file mode 100644 index 0000000000..c700bae5c2 --- /dev/null +++ b/docs/src/tutorials/SampledData.md @@ -0,0 +1,198 @@ +# Clocks and Sampled-Data Systems + +A sampled-data system contains both continuous-time and discrete-time components, such as a continuous-time plant model and a discrete-time control system. ModelingToolkit supports the modeling and simulation of sampled-data systems by means of *clocks*. + +!!! danger "Experimental" + + The sampled-data interface is currently experimental and at any time subject to breaking changes **not** respecting semantic versioning. + +!!! note "Negative shifts" + + The initial release of the sampled-data interface **only supports negative shifts**. + +A clock can be seen as an *event source*, i.e., when the clock ticks, an event is generated. In response to the event the discrete-time logic is executed, for example, a control signal is computed. For basic modeling of sampled-data systems, the user does not have to interact with clocks explicitly, instead, the modeling is performed using the operators + + - [`Sample`](@ref) + - [`Hold`](@ref) + - [`ShiftIndex`](@ref) + +When a continuous-time variable `x` is sampled using `xd = Sample(dt)(x)`, the result is a discrete-time variable `xd` that is defined and updated whenever the clock ticks. `xd` is *only defined when the clock ticks*, which it does with an interval of `dt`. If `dt` is unspecified, the tick rate of the clock associated with `xd` is inferred from the context in which `xd` appears. Any variable taking part in the same equation as `xd` is inferred to belong to the same *discrete partition* as `xd`, i.e., belonging to the same clock. A system may contain multiple different discrete-time partitions, each with a unique clock. This allows for modeling of multi-rate systems and discrete-time processes located on different computers etc. + +To make a discrete-time variable available to the continuous partition, the [`Hold`](@ref) operator is used. `xc = Hold(xd)` creates a continuous-time variable `xc` that is updated whenever the clock associated with `xd` ticks, and holds its value constant between ticks. + +The operators [`Sample`](@ref) and [`Hold`](@ref) are thus providing the interface between continuous and discrete partitions. + +The [`ShiftIndex`](@ref) operator is used to refer to past and future values of discrete-time variables. The example below illustrates its use, implementing the discrete-time system + +```math +\begin{align} + x(k+1) &= 0.5x(k) + u(k) \\ + y(k) &= x(k) +\end{align} +``` + +```@example clocks +using ModelingToolkit +using ModelingToolkit: t_nounits as t +@variables x(t) y(t) u(t) +dt = 0.1 # Sample interval +clock = Clock(dt) # A periodic clock with tick rate dt +k = ShiftIndex(clock) + +eqs = [ + x(k) ~ 0.5x(k - 1) + u(k - 1), + y ~ x +] +``` + +A few things to note in this basic example: + + - The equation `x(k+1) = 0.5x(k) + u(k)` has been rewritten in terms of negative shifts since positive shifts are not yet supported. + - `x` and `u` are automatically inferred to be discrete-time variables, since they appear in an equation with a discrete-time [`ShiftIndex`](@ref) `k`. + - `y` is also automatically inferred to be a discrete-time-time variable, since it appears in an equation with another discrete-time variable `x`. `x,u,y` all belong to the same discrete-time partition, i.e., they are all updated at the same *instantaneous point in time* at which the clock ticks. + - The equation `y ~ x` does not use any shift index, this is equivalent to `y(k) ~ x(k)`, i.e., discrete-time variables without shift index are assumed to refer to the variable at the current time step. + - The equation `x(k) ~ 0.5x(k-1) + u(k-1)` indicates how `x` is updated, i.e., what the value of `x` will be at the *current* time step in terms of the *past* value. The output `y`, is given by the value of `x` at the *current* time step, i.e., `y(k) ~ x(k)`. If this logic was implemented in an imperative programming style, the logic would thus be + +```julia +function discrete_step(x, u) + x = 0.5x + u # x is updated to a new value, i.e., x(k) is computed + y = x # y is assigned the current value of x, y(k) = x(k) + return x, y # The state x now refers to x at the current time step, x(k), and y equals x, y(k) = x(k) +end +``` + +An alternative and *equivalent* way of writing the same system is + +```@example clocks +eqs = [ + x(k + 1) ~ 0.5x(k) + u(k), + y(k) ~ x(k) +] +``` + +but the use of positive time shifts is not yet supported. Instead, we *shifted all indices* by `-1` above, resulting in exactly the same difference equations. However, the next system is *not equivalent* to the previous one: + +```@example clocks +eqs = [ + x(k) ~ 0.5x(k - 1) + u(k), + y ~ x +] +``` + +In this last example, `u(k)` refers to the input at the new time point `k`., this system is equivalent to + +``` +eqs = [ + x(k+1) ~ 0.5x(k) + u(k+1), + y(k) ~ x(k) +] +``` + +## Higher-order shifts + +The expression `x(k-1)` refers to the value of `x` at the *previous* clock tick. Similarly, `x(k-2)` refers to the value of `x` at the clock tick before that. In general, `x(k-n)` refers to the value of `x` at the `n`th clock tick before the current one. As an example, the Z-domain transfer function + +```math +H(z) = \dfrac{b_2 z^2 + b_1 z + b_0}{a_2 z^2 + a_1 z + a_0} +``` + +may thus be modeled as + +```julia +t = ModelingToolkit.t_nounits +@variables y(t) [description = "Output"] u(t) [description = "Input"] +k = ShiftIndex(Clock(dt)) +eqs = [ + a2 * y(k) + a1 * y(k - 1) + a0 * y(k - 2) ~ b2 * u(k) + b1 * u(k - 1) + b0 * u(k - 2) +] +``` + +(see also [ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/) for a discrete-time transfer-function component.) + +## Initial conditions + +The initial condition of discrete-time variables is defined using the [`ShiftIndex`](@ref) operator, for example + +```julia +ODEProblem(model, [x(k) => 1.0], (0.0, 10.0)) +``` + +If higher-order shifts are present, the corresponding initial conditions must be specified, e.g., the presence of the equation + +```julia +x(k) = x(k - 1) + x(k - 2) +``` + +requires specification of the initial condition for both `x(k-1)` and `x(k-2)`. + +## Multiple clocks + +Multi-rate systems are easy to model using multiple different clocks. The following set of equations is valid, and defines *two different discrete-time partitions*, each with its own clock: + +```julia +yd1 ~ Sample(dt1)(y) +ud1 ~ kp * (Sample(dt1)(r) - yd1) +yd2 ~ Sample(dt2)(y) +ud2 ~ kp * (Sample(dt2)(r) - yd2) +``` + +`yd1` and `ud1` belong to the same clock which ticks with an interval of `dt1`, while `yd2` and `ud2` belong to a different clock which ticks with an interval of `dt2`. The two clocks are *not synchronized*, i.e., they are not *guaranteed* to tick at the same point in time, even if one tick interval is a rational multiple of the other. Mechanisms for synchronization of clocks are not yet implemented. + +## Accessing discrete-time variables in the solution + +## A complete example + +Below, we model a simple continuous first-order system called `plant` that is controlled using a discrete-time controller `controller`. The reference signal is filtered using a discrete-time filter `filt` before being fed to the controller. + +```@example clocks +using ModelingToolkit, Plots, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t +using ModelingToolkit: D_nounits as D +dt = 0.5 # Sample interval +@variables r(t) +clock = Clock(dt) +k = ShiftIndex(clock) + +function plant(; name) + @variables x(t)=1 u(t)=0 y(t)=0 + D = Differential(t) + eqs = [D(x) ~ -x + u + y ~ x] + ODESystem(eqs, t; name = name) +end + +function filt(; name) # Reference filter + @variables x(t)=0 u(t)=0 y(t)=0 + a = 1 / exp(dt) + eqs = [x(k) ~ a * x(k - 1) + (1 - a) * u(k) + y ~ x] + ODESystem(eqs, t, name = name) +end + +function controller(kp; name) + @variables y(t)=0 r(t)=0 ud(t)=0 yd(t)=0 + @parameters kp = kp + eqs = [yd ~ Sample(y) + ud ~ kp * (r - yd)] + ODESystem(eqs, t; name = name) +end + +@named f = filt() +@named c = controller(1) +@named p = plant() + +connections = [r ~ sin(t) # reference signal + f.u ~ r # reference to filter input + f.y ~ c.r # filtered reference to controller reference + Hold(c.ud) ~ p.u # controller output to plant input (zero-order-hold) + p.y ~ c.y] # plant output to controller feedback + +@named cl = ODESystem(connections, t, systems = [f, c, p]) +``` + +```@docs +Sample +Hold +ShiftIndex +Clock +``` diff --git a/docs/src/tutorials/acausal_components.md b/docs/src/tutorials/acausal_components.md index e0fe79c714..b97500a3e9 100644 --- a/docs/src/tutorials/acausal_components.md +++ b/docs/src/tutorials/acausal_components.md @@ -1,437 +1,348 @@ -# Acausal Component-Based Modeling the RC Circuit +# [Acausal Component-Based Modeling](@id acausal) -In this tutorial we will build a hierarchical acausal component-based model of +In this tutorial, we will build a hierarchical acausal component-based model of the RC circuit. The RC circuit is a simple example where we connect a resistor -and a capacitor. [Kirchoff's laws](https://en.wikipedia.org/wiki/Kirchhoff%27s_circuit_laws) +and a capacitor. [Kirchhoff's laws](https://en.wikipedia.org/wiki/Kirchhoff%27s_circuit_laws) are then applied to state equalities between currents and voltages. This specifies a differential-algebraic equation (DAE) system, where the algebraic equations are given by the constraints and equalities between different component variables. We then simplify this to an ODE by eliminating the equalities before solving. Let's see this in action. -## Copy-Paste Example +!!! note + + This tutorial teaches how to build the entire RC circuit from scratch. + However, to simulate electric components with more ease, check out the + [ModelingToolkitStandardLibrary.jl](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/) + which includes a + [tutorial for simulating RC circuits with pre-built components](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/tutorials/rc_circuit/) -```julia -using ModelingToolkit, Plots, DifferentialEquations +## Copy-Paste Example -@parameters t +```@example acausal +using ModelingToolkit, Plots, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D -# Basic electric components -function Pin(;name) - @variables v(t) i(t) - ODESystem(Equation[], t, [v, i], [], name=name, defaults=[v=>1.0, i=>1.0]) +@connector Pin begin + v(t) + i(t), [connect = Flow] end -function Ground(;name) - @named g = Pin() - eqs = [g.v ~ 0] - ODESystem(eqs, t, [], [], systems=[g], name=name) +@mtkmodel Ground begin + @components begin + g = Pin() + end + @equations begin + g.v ~ 0 + end end -function Resistor(;name, R = 1.0) - val = R - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters R - eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - v ~ p.i * R - ] - ODESystem(eqs, t, [v], [R], systems=[p, n], defaults=Dict(R => val), name=name) +@mtkmodel OnePort begin + @components begin + p = Pin() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + v ~ p.v - n.v + 0 ~ p.i + n.i + i ~ p.i + end end -function Capacitor(; name, C = 1.0) - val = C - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters C - D = Differential(t) - eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - D(v) ~ p.i / C - ] - ODESystem(eqs, t, [v], [C], systems=[p, n], defaults=Dict(C => val), name=name) +@mtkmodel Resistor begin + @extend OnePort() + @parameters begin + R = 1.0 # Sets the default resistance + end + @equations begin + v ~ i * R + end end -function ConstantVoltage(;name, V = 1.0) - val = V - @named p = Pin() - @named n = Pin() - @parameters V - eqs = [ - V ~ p.v - n.v - 0 ~ p.i + n.i - ] - ODESystem(eqs, t, [], [V], systems=[p, n], defaults=Dict(V => val), name=name) +@mtkmodel Capacitor begin + @extend OnePort() + @parameters begin + C = 1.0 + end + @equations begin + D(v) ~ i / C + end end -R = 1.0 -C = 1.0 -V = 1.0 -@named resistor = Resistor(R=R) -@named capacitor = Capacitor(C=C) -@named source = ConstantVoltage(V=V) -@named ground = Ground() - -function connect_pins(ps...) - eqs = [ - 0 ~ sum(p->p.i, ps) # KCL - ] - # KVL - for i in 1:length(ps)-1 - push!(eqs, ps[i].v ~ ps[i+1].v) +@mtkmodel ConstantVoltage begin + @extend OnePort() + @parameters begin + V = 1.0 + end + @equations begin + V ~ v end - - return eqs end -rc_eqs = [ - connect_pins(source.p, resistor.p) - connect_pins(resistor.n, capacitor.p) - connect_pins(capacitor.n, source.n, ground.g) - ] +@mtkmodel RCModel begin + @description "A circuit with a constant voltage source, resistor and capacitor connected in series." + @components begin + resistor = Resistor(R = 1.0) + capacitor = Capacitor(C = 1.0) + source = ConstantVoltage(V = 1.0) + ground = Ground() + end + @equations begin + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n) + connect(capacitor.n, ground.g) + end +end -@named rc_model = ODESystem(rc_eqs, t, - systems=[resistor, capacitor, source, ground]) -sys = structural_simplify(rc_model) +@mtkbuild rc_model = RCModel(resistor.R = 2.0) u0 = [ - capacitor.v => 0.0 - capacitor.p.i => 0.0 - ] -prob = ODAEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Tsit5()) + rc_model.capacitor.v => 0.0 +] +prob = ODEProblem(rc_model, u0, (0, 10.0)) +sol = solve(prob) plot(sol) ``` -![](https://user-images.githubusercontent.com/1814174/109416294-55184100-798b-11eb-9f05-766a793f0ba2.png) - ## Explanation +We wish to build the following RC circuit by building individual components and connecting the pins: + +![](https://user-images.githubusercontent.com/1814174/172466302-907d39f3-6d2c-4d16-84a8-6de32bca757e.png) + ### Building the Component Library -For each of our components we use a Julia function which emits an `ODESystem`. -At the top we start with defining the fundamental qualities of an electrical -circuit component. At every input and output pin a circuit component has +For each of our components, we use ModelingToolkit `Model` that emits an `ODESystem`. +At the top, we start with defining the fundamental qualities of an electric +circuit component. At every input and output pin, a circuit component has two values: the current at the pin and the voltage. Thus we define the `Pin` -component to simply be the values there: - -```julia -function Pin(;name) - @variables v(t) i(t) - ODESystem(Equation[], t, [v, i], [], name=name, defaults=[v=>1.0, i=>1.0]) +component (connector) to simply be the values there. Whenever two `Pin`s in a +circuit are connected together, the system satisfies [Kirchhoff's laws](https://en.wikipedia.org/wiki/Kirchhoff%27s_circuit_laws), +i.e. that currents sum to zero and voltages across the pins are equal. +`[connect = Flow]` informs MTK that currents ought to sum to zero, and by +default, variables are equal in a connection. + +```@example acausal +@connector Pin begin + v(t) + i(t), [connect = Flow] end ``` Note that this is an incompletely specified ODESystem: it cannot be simulated -on its own because the equations for `v(t)` and `i(t)` are unknown. Instead +on its own because the equations for `v(t)` and `i(t)` are unknown. Instead, this just gives a common syntax for receiving this pair with some default -values. Notice that in a component we define the `name` as a keyword argument: -this is because later we will generate different `Pin` objects with different -names to correspond to duplicates of this topology with unique variables. -One can then construct a `Pin` like: - -```julia -Pin(name=:mypin1) -``` - -or equivalently using the `@named` helper macro: +values. +One can then construct a `Pin` using the `@named` helper macro: -```julia +```@example acausal @named mypin1 = Pin() ``` -Next we build our ground node. A ground node is just a pin that is connected -to a constant voltage reservoir, typically taken to be `V=0`. Thus to define +Next, we build our ground node. A ground node is just a pin that is connected +to a constant voltage reservoir, typically taken to be `V = 0`. Thus to define this component, we generate an `ODESystem` with a `Pin` subcomponent and specify that the voltage in such a `Pin` is equal to zero. This gives: -```julia -function Ground(;name) - @named g = Pin() - eqs = [g.v ~ 0] - ODESystem(eqs, t, [], [], systems=[g], name=name) +```@example acausal +@mtkmodel Ground begin + @components begin + g = Pin() + end + @equations begin + g.v ~ 0 + end +end +``` + +Next we build a `OnePort`: an abstraction for all simple electric component +with two pins. The voltage difference between the positive pin and the negative +pin is the voltage of the component, the current between two pins must sum to +zero, and the current of the component equals to the current of the positive +pin. + +```@example acausal +@mtkmodel OnePort begin + @components begin + p = Pin() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + v ~ p.v - n.v + 0 ~ p.i + n.i + i ~ p.i + end end ``` Next we build a resistor. A resistor is an object that has two `Pin`s, the positive and the negative pins, and follows Ohm's law: `v = i*r`. The voltage of the -resistor is given as the voltage difference across the two pins while by conservation +resistor is given as the voltage difference across the two pins, while by conservation of charge we know that the current in must equal the current out, which means (no matter the direction of the current flow) the sum of the currents must be zero. This leads to our resistor equations: -```julia -function Resistor(;name, R = 1.0) - val = R - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters R - eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - v ~ p.i * R - ] - ODESystem(eqs, t, [v], [R], systems=[p, n], defaults=Dict(R => val), name=name) +```@example acausal +@mtkmodel Resistor begin + @extend OnePort() + @parameters begin + R = 1.0 + end + @equations begin + v ~ i * R + end end ``` -Notice that we have created this system with a `defaults` for the resistor's -resistance. By doing so, if the resistance of this resistor is not overridden -by a higher level default or overridden at `ODEProblem` construction time, this -will be the value of the resistance. - -Using our knowledge of circuits we similarly construct the Capacitor: - -```julia -function Capacitor(; name, C = 1.0) - val = C - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters C - D = Differential(t) - eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - D(v) ~ p.i / C - ] - ODESystem(eqs, t, [v], [C], systems=[p, n], defaults=Dict(C => val), name=name) +Notice that we have created this system with a default parameter `R` for the +resistor's resistance. By doing so, if the resistance of this resistor is not +overridden by a higher level default or overridden at `ODEProblem` construction +time, this will be the value of the resistance. Also, note the use of `@extend`. +For the `Resistor`, we want to simply inherit `OnePort`'s +equations and unknowns and extend them with a new equation. Note that `v`, `i` are not namespaced as `oneport.v` or `oneport.i`. + +Using our knowledge of circuits, we similarly construct the `Capacitor`: + +```@example acausal +@mtkmodel Capacitor begin + @extend OnePort() + @parameters begin + C = 1.0 + end + @equations begin + D(v) ~ i / C + end end ``` -Now we want to build a constant voltage electrical source term. We can think of +Now we want to build a constant voltage electric source term. We can think of this as similarly being a two pin object, where the object itself is kept at a -constant voltage, essentially generating the electrical current. We would then +constant voltage, essentially generating the electric current. We would then model this as: -```julia -function ConstantVoltage(;name, V = 1.0) - val = V - @named p = Pin() - @named n = Pin() - @parameters V - eqs = [ - V ~ p.v - n.v - 0 ~ p.i + n.i - ] - ODESystem(eqs, t, [], [V], systems=[p, n], defaults=Dict(V => val), name=name) +```@example acausal +@mtkmodel ConstantVoltage begin + @extend OnePort() + @parameters begin + V = 1.0 + end + @equations begin + V ~ v + end end ``` -### Connecting and Simulating Our Electrical Circuit +Note that as we are extending only `v` from `OnePort`, it is explicitly specified as a tuple. -Now we are ready to simulate our circuit. Let's build our four components: -a `resistor`, `capacitor`, `source`, and `ground` term. For simplicity we will -make all of our parameter values 1. This is done by: - -```julia -R = 1.0 -C = 1.0 -V = 1.0 -@named resistor = Resistor(R=R) -@named capacitor = Capacitor(C=C) -@named source = ConstantVoltage(V=V) -@named ground = Ground() -``` +### Connecting and Simulating Our Electric Circuit -Next we have to define how we connect the circuit. Whenever two `Pin`s in a -circuit are connected together, the system satisfies -[Kirchoff's laws](https://en.wikipedia.org/wiki/Kirchhoff%27s_circuit_laws), -i.e. that currents sum to zero and voltages across the pins are equal. Thus -we will build a helper function `connect_pins` which implements these rules: - -```julia -function connect_pins(ps...) - eqs = [ - 0 ~ sum(p->p.i, ps) # KCL - ] - # KVL - for i in 1:length(ps)-1 - push!(eqs, ps[i].v ~ ps[i+1].v) +Now we are ready to simulate our circuit. Let's build our four components: +a `resistor`, `capacitor`, `source`, and `ground` term. For simplicity, we will +make all of our parameter values 1.0. As `resistor`, `capacitor`, `source` lists +`R`, `C`, `V` in their argument list, they are promoted as arguments of RCModel as +`resistor.R`, `capacitor.C`, `source.V` + +```@example acausal +@mtkmodel RCModel begin + @description "A circuit with a constant voltage source, resistor and capacitor connected in series." + @components begin + resistor = Resistor(R = 1.0) + capacitor = Capacitor(C = 1.0) + source = ConstantVoltage(V = 1.0) + ground = Ground() + end + @equations begin + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n) + connect(capacitor.n, ground.g) end - - return eqs end ``` -Finally we will connect the pieces of our circuit together. Let's connect the -positive pin of the resistor to the source, the negative pin of the resistor -to the capacitor, and the negative pin of the capacitor to a junction between -the source and the ground. This would mean our connection equations are: - -```julia -rc_eqs = [ - connect_pins(source.p, resistor.p) - connect_pins(resistor.n, capacitor.p) - connect_pins(capacitor.n, source.n, ground.g) - ] +We can create a RCModel component with `@named`. And using `subcomponent_name.parameter` we can set +the parameters or defaults values of variables of subcomponents. + +```@example acausal +@mtkbuild rc_model = RCModel(resistor.R = 2.0) ``` -Finally we build our four component model with these connection rules: +This model is acausal because we have not specified anything about the causality of the model. We have +simply specified what is true about each of the variables. This forms a system +of differential-algebraic equations (DAEs) which define the evolution of each +unknown of the system. The equations are: -```julia -@named rc_model = ODESystem(rc_eqs, t, - systems=[resistor, capacitor, source, ground]) +```@example acausal +equations(expand_connections(rc_model)) ``` -Notice that this model is acasual because we have not specified anything about -the causality of the model. We have simply specified what is true about each -of the variables. This forms a system of differential-algebraic equations -(DAEs) which define the evolution of each state of the system. The -equations are: - -```julia -equations(rc_model) - -16-element Vector{Equation}: - 0 ~ resistor₊p₊i(t) + source₊p₊i(t) - source₊p₊v(t) ~ resistor₊p₊v(t) - 0 ~ capacitor₊p₊i(t) + resistor₊n₊i(t) - resistor₊n₊v(t) ~ capacitor₊p₊v(t) - ⋮ - Differential(t)(capacitor₊v(t)) ~ capacitor₊p₊i(t)*(capacitor₊C^-1) - source₊V ~ source₊p₊v(t) - (source₊n₊v(t)) - 0 ~ source₊n₊i(t) + source₊p₊i(t) - ground₊g₊v(t) ~ 0 -``` +the unknowns are: -the states are: - -```julia -states(rc_model) - -16-element Vector{Term{Real}}: - resistor₊p₊i(t) - source₊p₊i(t) - source₊p₊v(t) - resistor₊p₊v(t) - ⋮ - source₊n₊v(t) - ground₊g₊v(t) - resistor₊v(t) - capacitor₊v(t) +```@example acausal +unknowns(rc_model) ``` and the parameters are: -```julia +```@example acausal parameters(rc_model) - -3-element Vector{Any}: - resistor₊R - capacitor₊C - source₊V ``` -## Simplifying and Solving this System - -This system could be solved directly as a DAE using [one of the DAE solvers -from DifferentialEquations.jl](https://diffeq.sciml.ai/stable/solvers/dae_solve/). -However, let's take a second to symbolically simplify the system before doing the -solve. The function `structural_simplify` looks for all of the equalities and -eliminates unnecessary variables to build the leanest numerical representation -of the system. Let's see what it does here: - -```julia -sys = structural_simplify(rc_model) -equations(sys) +The observed equations are: -2-element Vector{Equation}: - 0 ~ capacitor₊v(t) + resistor₊R*capacitor₊p₊i(t) - source₊V - Differential(t)(capacitor₊v(t)) ~ capacitor₊p₊i(t)*(capacitor₊C^-1) +```@example acausal +observed(rc_model) ``` -```julia -states(sys) +## Solving this System -2-element Vector{Any}: - capacitor₊v(t) - capacitor₊p₊i(t) -``` - -After structural simplification we are left with a system of only two equations -with two state variables. One of the equations is a differential equation +We are left with a system of only two equations +with two unknown variables. One of the equations is a differential equation, while the other is an algebraic equation. We can then give the values for the -initial conditions of our states and solve the system by converting it to +initial conditions of our unknowns, and solve the system by converting it to an ODEProblem in mass matrix form and solving it with an [ODEProblem mass matrix -DAE solver](https://diffeq.sciml.ai/stable/solvers/dae_solve/#OrdinaryDiffEq.jl-(Mass-Matrix)). +DAE solver](https://docs.sciml.ai/DiffEqDocs/stable/solvers/dae_solve/#OrdinaryDiffEq.jl-(Mass-Matrix)). This is done as follows: -```julia -u0 = [ - capacitor.v => 0.0 - capacitor.p.i => 0.0 - ] -prob = ODEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Rodas4()) -plot(sol) -``` - -![](https://user-images.githubusercontent.com/1814174/109416295-55184100-798b-11eb-96d1-5bb7e40135ba.png) +```@example acausal +u0 = [rc_model.capacitor.v => 0.0] -However, we can also choose to use the "torn nonlinear system" to remove all -of the algebraic variables from the solution of the system. Note that this -requires having done `structural_simplify`. This is done by using `ODAEProblem` -like: - -```julia -u0 = [ - capacitor.v => 0.0 - capacitor.p.i => 0.0 - ] -prob = ODAEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Rodas4()) +prob = ODEProblem(rc_model, u0, (0, 10.0)) +sol = solve(prob) plot(sol) ``` -![](https://user-images.githubusercontent.com/1814174/109416294-55184100-798b-11eb-9f05-766a793f0ba2.png) - -Notice that this solves the whole system by only solving for one variable! - However, what if we wanted to plot the timeseries of a different variable? Do not worry, that information was not thrown away! Instead, transformations -like `structural_simplify` simply change state variables into `observed` -variables. Let's see what our observed variables are: - -```julia -observed(sys) - -14-element Vector{Equation}: - resistor₊p₊i(t) ~ capacitor₊p₊i(t) - capacitor₊n₊v(t) ~ 0.0 - source₊n₊v(t) ~ 0.0 - ground₊g₊i(t) ~ 0.0 - source₊n₊i(t) ~ capacitor₊p₊i(t) - source₊p₊i(t) ~ -capacitor₊p₊i(t) - capacitor₊n₊i(t) ~ -capacitor₊p₊i(t) - resistor₊n₊i(t) ~ -capacitor₊p₊i(t) - ground₊g₊v(t) ~ 0.0 - source₊p₊v(t) ~ source₊V - capacitor₊p₊v(t) ~ capacitor₊v(t) - resistor₊p₊v(t) ~ source₊p₊v(t) - resistor₊n₊v(t) ~ capacitor₊p₊v(t) - resistor₊v(t) ~ -((capacitor₊p₊v(t)) - (source₊p₊v(t))) +like `structural_simplify` simply change unknown variables into observables which are +defined by `observed` equations. + +```@example acausal +observed(rc_model) ``` These are explicit algebraic equations which can then be used to reconstruct the required variables on the fly. This leads to dramatic computational savings because implicitly solving an ODE scales like O(n^3), so making there be as -few states as possible is good! +few unknowns as possible is good! The solution object can be accessed via its symbols. For example, let's retrieve the voltage of the resistor over time: -```julia -sol[resistor.v] +```@example acausal +sol[rc_model.resistor.v] ``` or we can plot the timeseries of the resistor's voltage: -```julia -plot(sol, vars=[resistor.v]) +```@example acausal +plot(sol, idxs = [rc_model.resistor.v]) ``` diff --git a/docs/src/tutorials/attractors.md b/docs/src/tutorials/attractors.md new file mode 100644 index 0000000000..317384b01a --- /dev/null +++ b/docs/src/tutorials/attractors.md @@ -0,0 +1,224 @@ +# [Multi- and Nonlocal- Continuation](@id attractors) + +In the tutorial on [Bifurcation Diagrams](@ref bifurcation_diagrams) we saw how one can create them by integrating ModelingToolkit.jl with BifurcationKit.jl. +This approach is also often called _continuation_ in the broader literature, +because in essence we are "continuing" the location of individual un/stable fixed points or limit cycles in a dynamical system across a parameter axis. + +Recently, an alternative continuation framework was proposed that takes a fundamentally different approach to continuation that is particularly suitable for complex systems. This framework is implemented in [Attractors.jl](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/attractors/stable/) as part of the DynamicalSystems.jl software library. +This new continuation is called _global_ continuation, while the one of BifurcationKit.jl is called _local_ continuation. + +Instead of continuing an individual fixed point or limit cycle, the global continuation finds all attractors of the dynamical system and continues all of them, in parallel, in a single continuation. It distinguishes and labels automatically the different attractors. +Hence "multi-" for multiple attractors. +Another key difference is that instead of estimating the local (or linear, or Jacobian) stability of the attractors, it estimates various measures of _nonlocal_ stability (e.g, related with the size of the basins of attraction, or the size of a perturbation that would make the dynamical system state converge to an alternative attractor). +Hence the "nonlocal-" component. +More differences and pros & cons are discussed in the documentation of Attractors.jl. + +!!! note "Attractors and basins" + + This tutorial assumes that you have some familiarity with dynamical systems, + specifically what are attractors and basins of attraction. If you don't have + this yet, we recommend Chapter 1 of the textbook + [Nonlinear Dynamics](https://link.springer.com/book/10.1007/978-3-030-91032-7). + +## Creating the `DynamicalSystem` via MTK + +Let's showcase this framework by modelling a chaotic bistable dynamical system that we define via ModelingToolkit.jl, which will the be casted into a `DynamicalSystem` type for the DynamicalSystems.jl library. The equations of our system are + +```@example Attractors +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables x(t)=-4.0 y(t)=5.0 z(t)=0.0 +@parameters a=5.0 b=0.1 + +eqs = [ + D(x) ~ y - x, + D(y) ~ -x * z + b * abs(z), + D(z) ~ x * y - a +] +``` + +Because our dynamical system is super simple, we will directly make an `ODESystem` and cast it in an `ODEProblem` as in the [`ODESystems` tutorial](@ref programmatically). Since all state variables and parameters have a default value we can immediately write + +```@example Attractors +@named modlorenz = ODESystem(eqs, t) +ssys = structural_simplify(modlorenz) +# The timespan given to the problem is irrelevant for DynamicalSystems.jl +prob = ODEProblem(ssys, [], (0.0, 1.0), []) +``` + +This `prob` can be turned to a dynamical system as simply as + +```@example Attractors +using Attractors # or `DynamicalSystems` +ds = CoupledODEs(prob) +``` + +DynamicalSystems.jl integrates fully with ModelingToolkit.jl out of the box and understands whether a given problem has been created via ModelingToolkit.jl. For example you can use the symbolic variables, or their `Symbol` representations, to access a system state or parameter + +```@example Attractors +observe_state(ds, x) +``` + +```@example Attractors +current_parameter(ds, :a) # or `a` directly +``` + +## Finding all attractors in the state space + +Attractors.jl provides an extensive interface for finding all (within a state space region and numerical accuracy) attractors of a dynamical system. +This interface is structured around the type `AttractorMapper` and is discussed in the Attractors.jl documentation in detail. Here we will briefly mention one of the possible approaches, the recurrences-based algorithm. It finds attractors by finding locations in the state space where the trajectory returns again and again. + +To use this technique, we first need to create a tessellation of the state space + +```@example Attractors +grid = ( + range(-15.0, 15.0; length = 150), # x + range(-20.0, 20.0; length = 150), # y + range(-20.0, 20.0; length = 150) # z +) +``` + +which we then give as input to the `AttractorsViaRecurrences` mapper along with the dynamical system + +```@example Attractors +mapper = AttractorsViaRecurrences(ds, grid; + consecutive_recurrences = 1000, + consecutive_lost_steps = 100 +) +``` + +to learn about the metaparameters of the algorithm visit the documentation of Attractors.jl. + +This `mapper` object is incredibly powerful! It can be used to map initial conditions to attractor they converge to, while ensuring that initial conditions that converge to the same attractor are given the same label. +For example, if we use the `mapper` as a function and give it an initial condition we get + +```@example Attractors +mapper([-4.0, 5, 0]) +``` + +```@example Attractors +mapper([4.0, 2, 0]) +``` + +```@example Attractors +mapper([1.0, 3, 2]) +``` + +The numbers returned are simply the unique identifiers of the attractors the initial conditions converged to. + +DynamicalSystems.jl library is the only dynamical systems software (in any language) that provides such an infrastructure for mapping initial conditions of any arbitrary dynamical system to its unique attractors. And this is only the tip of this iceberg! The rest of the functionality of Attractors.jl is all full of brand new cutting edge progress in dynamical systems research. + +The found attractors are stored in the mapper internally, to obtain them we +use the function + +```@example Attractors +attractors = extract_attractors(mapper) +``` + +This is a dictionary that maps attractor IDs to the attractor sets themselves. +`StateSpaceSet` is a wrapper of a vector of points and behaves exactly like a vector of points. We can plot them easily like + +```@example Attractors +using CairoMakie +fig = Figure() +ax = Axis(fig[1, 1]) +colors = ["#7143E0", "#191E44"] +for (id, A) in attractors + scatter!(ax, A[:, [1, 3]]; color = colors[id]) +end +fig +``` + +## Basins of attraction + +Estimating the basins of attraction of these attractors is a matter of a couple lines of code. +First we define the state space are to estimate the basins for. +Here we can re-use the `grid` we defined above. Then we only have to call + +```julia +basins = basins_of_attraction(mapper, grid) +``` + +We won't run this in this tutorial because it is a length computation (150×150×150). +We will however estimate a slice of the 3D basins of attraction. +DynamicalSystems.jl allows for a rather straightforward setting of initial conditions: + +```@example Attractors +ics = [Dict(:x => x, :y => 0, :z => z) for x in grid[1] for z in grid[3]] +``` + +now we can estimate the basins of attraction on a slice on the x-z grid + +```@example Attractors +fs, labels = basins_fractions(mapper, ics) +labels = reshape(labels, (length(grid[1]), length(grid[3]))) +``` + +and visualize them + +```@example Attractors +heatmap(grid[1], grid[3], labels; colormap = colors) +``` + +## Global continuation + +We've already outlined the principles of the global continuation, so let's just do it here! +We first have to define a global continuation algorithm, which for this tutorial, +it is just a wrapper of the existing `mapper` + +```@example Attractors +ascm = AttractorSeedContinueMatch(mapper); +``` + +we need two more ingredients to perform the global continuation. +One is a sampler of initial conditions in the state space. +Here we'll uniformly sample initial conditions within this grid we have already defined + +```@example Attractors +sampler, = statespace_sampler(grid); +``` + +the last ingredient is what parameter(s) to perform the continuation over. +In contrast to local continuation, where we can only specify a parameter range, in global continuation one can specify an exact parameter curve to continue over. +This curve can span any-dimensional parameter space, in contrast to the 1D or 2D parameter spaces supported in local continuation. +Here we will use the curve + +```@example Attractors +params(θ) = [:a => 5 + 0.5cos(θ), :b => 0.1 + 0.01sin(θ)] +θs = range(0, 2π; length = 101) +pcurve = params.(θs) +``` + +which makes an ellipsis over the parameter space. + +We put these three ingredients together to call the global continuation + +```@example Attractors +fractions_cont, attractors_cont = global_continuation(ascm, pcurve, sampler); +``` + +The output of the continuation is how the attractors and their basins fractions change over this parameter curve. We can visualize this directly using a convenience function + +```@example Attractors +fig = plot_basins_attractors_curves( + fractions_cont, attractors_cont, A -> minimum(A[:, 1]), θs +) +``` + +The top panel shows the relative basins of attractions of the attractors and the bottom panel shows their minimum x-position. The colors correspond to unique attractors. Perhaps making a video is easier to understand: + +```@example Attractors +animate_attractors_continuation( + ds, attractors_cont, fractions_cont, pcurve; + savename = "curvecont.mp4" +); +``` + +```@raw html + +``` + +To learn more about this global continuation and its various options, and more details about how it compares with local continuation, visit the documentation of [Attractors.jl](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/attractors/stable/). diff --git a/docs/src/tutorials/bifurcation_diagram_computation.md b/docs/src/tutorials/bifurcation_diagram_computation.md new file mode 100644 index 0000000000..f15d46e1e4 --- /dev/null +++ b/docs/src/tutorials/bifurcation_diagram_computation.md @@ -0,0 +1,147 @@ +# [Bifurcation Diagrams](@id bifurcation_diagrams) + +Bifurcation diagrams describes how, for a dynamic system, the quantity and quality of its steady states changes with a parameter's value. These can be computed through the [BifurcationKit.jl](https://github.com/bifurcationkit/BifurcationKit.jl) package. ModelingToolkit provides a simple interface for creating BifurcationKit compatible `BifurcationProblem`s from `NonlinearSystem`s and `ODESystem`s. All the features provided by BifurcationKit can then be applied to these systems. This tutorial provides a brief introduction for these features, with BifurcationKit.jl providing [a more extensive documentation](https://bifurcationkit.github.io/BifurcationKitDocs.jl/stable/). + +### Creating a `BifurcationProblem` + +Let us first consider a simple `NonlinearSystem`: + +```@example Bif1 +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables x(t) y(t) +@parameters μ α +eqs = [0 ~ μ * x - x^3 + α * y, + 0 ~ -y] +@mtkbuild nsys = NonlinearSystem(eqs, [x, y], [μ, α]) +``` + +we wish to compute a bifurcation diagram for this system as we vary the parameter `μ`. For this, we need to provide the following information: + + 1. The system for which we wish to compute the bifurcation diagram (`nsys`). + 2. The parameter which we wish to vary (`μ`). + 3. The parameter set for which we want to compute the bifurcation diagram. + 4. An initial guess of the state of the system for which there is a steady state at our provided parameter value. + 5. The variable which value we wish to plot in the bifurcation diagram (this argument is optional, if not provided, BifurcationKit default plot functions are used). + +We declare this additional information: + +```@example Bif1 +bif_par = μ +p_start = [μ => -1.0, α => 1.0] +u0_guess = [x => 1.0, y => 1.0] +plot_var = x; +``` + +For the initial state guess (`u0_guess`), typically any value can be provided, however, read BifurcatioKit's documentation for more details. + +We can now create our `BifurcationProblem`, which can be provided as input to BifurcationKit's various functions. + +```@example Bif1 +using BifurcationKit +bprob = BifurcationProblem(nsys, + u0_guess, + p_start, + bif_par; + plot_var = plot_var, + jac = false) +``` + +Here, the `jac` argument (by default set to `true`) sets whenever to provide BifurcationKit with a Jacobian or not. + +### Computing a bifurcation diagram + +Let us consider the `BifurcationProblem` from the last section. If we wish to compute the corresponding bifurcation diagram we must first declare various settings used by BifurcationKit to compute the diagram. These are stored in a `ContinuationPar` structure (which also contain a `NewtonPar` structure). + +```@example Bif1 +p_span = (-4.0, 6.0) +opts_br = ContinuationPar(nev = 2, + p_min = p_span[1], + p_max = p_span[2]) +``` + +Here, `p_span` sets the interval over which we wish to compute the diagram. + +Next, we can use this as input to our bifurcation diagram, and then plot it. + +```@example Bif1 +bf = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) +``` + +Here, the value `2` sets how sub-branches of the diagram that BifurcationKit should compute. Generally, for bifurcation diagrams, it is recommended to use the `bothside=true` argument. + +```@example Bif1 +using Plots +plot(bf; + putspecialptlegend = false, + markersize = 2, + plotfold = false, + xguide = "μ", + yguide = "x") +``` + +Here, the system exhibits a pitchfork bifurcation at *μ=0.0*. + +### Using `ODESystem` inputs + +It is also possible to use `ODESystem`s (rather than `NonlinearSystem`s) as input to `BifurcationProblem`. Here follows a brief such example. + +```@example Bif2 +using BifurcationKit, ModelingToolkit, Plots +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables x(t) y(t) +@parameters μ +eqs = [D(x) ~ μ * x - y - x * (x^2 + y^2), + D(y) ~ x + μ * y - y * (x^2 + y^2)] +@mtkbuild osys = ODESystem(eqs, t) + +bif_par = μ +plot_var = x +p_start = [μ => 1.0] +u0_guess = [x => 0.0, y => 0.0] + +bprob = BifurcationProblem(osys, + u0_guess, + p_start, + bif_par; + plot_var = plot_var, + jac = false) + +p_span = (-3.0, 3.0) +opts_br = ContinuationPar(nev = 2, + p_max = p_span[2], p_min = p_span[1]) + +bf = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) +using Plots +plot(bf; + putspecialptlegend = false, + markersize = 2, + plotfold = false, + xguide = "μ", + yguide = "x") +``` + +Here, the value of `x` in the steady state does not change, however, at `μ=0` a Hopf bifurcation occur and the steady state turn unstable. + +We compute the branch of periodic orbits which is nearby the Hopf Bifurcation. We thus provide the branch `bf.γ`, the index of the Hopf point we want to branch from: 2 in this case and a method `PeriodicOrbitOCollProblem(20, 5)` to compute periodic orbits. + +```@example Bif2 +br_po = continuation(bf.γ, 2, opts_br, + PeriodicOrbitOCollProblem(20, 5);) + +plot(bf; putspecialptlegend = false, + markersize = 2, + plotfold = false, + xguide = "μ", + yguide = "x") +plot!(br_po, xguide = "μ", yguide = "x", label = "Maximum of periodic orbit") +``` + +Let's see how to plot the periodic solution we just computed: + +```@example Bif2 +sol = get_periodic_orbit(br_po, 10) +plot(sol.t, sol[1, :], yguide = "x", xguide = "time", label = "") +``` diff --git a/docs/src/tutorials/callable_params.md b/docs/src/tutorials/callable_params.md new file mode 100644 index 0000000000..74e7ea87fa --- /dev/null +++ b/docs/src/tutorials/callable_params.md @@ -0,0 +1,91 @@ +# Callable parameters and interpolating data + +ModelingToolkit.jl allows creating parameters that represent functions to be called. This +is especially useful for including interpolants and/or lookup tables inside ODEs. In this +tutorial we will create an `ODESystem` which employs callable parameters to interpolate data +inside an ODE and go over the various syntax options and their implications. + +## Callable parameter syntax + +The syntax for callable parameters declared via `@parameters` must be one of the following + + 1. `(fn::FType)(..)` + 2. `fn(::argType1, ::argType2, ...)` + +In the first case, the parameter is callable with any number/combination of arguments, and +has a type of `FType` (the callable must be a subtype of `FType`). In the second case, +the parameter is callable with as many arguments as declared, and all must match the +declared types. + +By default, the return type of the callable symbolic is inferred to be `Real`. To change +this, a `::retType` annotation can be added at the end. + +To declare a function that returns an array of values, the same array syntax can be used +as for normal variables: + +```julia +@parameters (foo::FType)(..)[1:3]::retType +@parameters foo(::argType1, ::argType2)[1:3]::retType +``` + +`retType` here is the `eltype` of the returned array. + +## Storage of callable parameters + +Callable parameters declared with the `::FType` syntax will be stored in a `Vector{FType}`. +Thus, if `FType` is non-concrete, the buffer will also be non-concrete. This is sometimes +necessary to allow the value of the callable to be switched out for a different type without +rebuilding the model. Typically this syntax is preferable when `FType` is concrete or +a small union. + +Callable parameters declared with the `::argType1, ...` syntax will be stored in a +`Vector{FunctionWrappers.FunctionWrapper{retType, Tuple{argType1, ...}}}`. This suffers +the small overhead of a `FunctionWrapper` and restricts the signature of the callable, +symbolic, but allows storing the parameter in a type-stable manner and swapping it out. +This is preferable when the values that the callable can take do not share a common +subtype. For example, when a callable can represent the activation of a neural network +and can be `tanh`, `sigmoid`, etc. which have a common ancestor of `Function`. + +If both `::FType` and `::argType`s are specified, `::FType` takes priority. For example, + +```julia +@parameters (p::LinearInterpolation)(::Real) +``` + +`p` will be stored in a `Vector{LinearInterpolation}`. If `::LinearInterpolation` was not +specified, it would be stored in a `Vector{FunctionWrapper{Real, Tuple{Real}}}`. + +## Example using interpolations + +```@example callable +using ModelingToolkit +using OrdinaryDiffEq +using DataInterpolations +using ModelingToolkit: t_nounits as t, D_nounits as D + +ts = collect(0.0:0.1:10.0) +spline = LinearInterpolation(ts .^ 2, ts) +Tspline = typeof(spline) +@variables x(t) +@parameters (interp::Tspline)(..) + +@mtkbuild sys = ODESystem(D(x) ~ interp(t), t) +``` + +The derivative of `x` is obtained via an interpolation from DataInterpolations.jl. Note +the parameter syntax. The `(..)` marks the parameter as callable. `(interp::Tspline)` +indicates that the parameter is of type `Tspline`. + +```@example callable +prob = ODEProblem(sys, [x => 0.0], (0.0, 1.0), [interp => spline]) +solve(prob) +``` + +Note that the the following will not work: + +```julia +ODEProblem( + sys; [x => 0.0], (0.0, 1.0), [interp => LinearInterpolation(0.0:0.1:1.0, 0.0:0.1:1.0)]) +``` + +Since the type of the spline doesn't match. diff --git a/docs/src/tutorials/change_independent_variable.md b/docs/src/tutorials/change_independent_variable.md new file mode 100644 index 0000000000..d55639a669 --- /dev/null +++ b/docs/src/tutorials/change_independent_variable.md @@ -0,0 +1,147 @@ +# Changing the independent variable of ODEs + +Ordinary differential equations describe the rate of change of some dependent variables with respect to one independent variable. +For the modeler it is often most natural to write down the equations with a particular independent variable, say time $t$. +However, in many cases there are good reasons for changing the independent variable: + + 1. One may want $y(x)$ as a function of $x$ instead of $(x(t), y(t))$ as a function of $t$ + + 2. Some differential equations vary more nicely (e.g. less stiff) with respect to one independent variable than another. + 3. It can reduce the number of equations that must be solved (e.g. $y(x)$ is one equation, while $(x(t), y(t))$ are two). + +To manually change the independent variable of an ODE, one must rewrite all equations in terms of a new variable and transform differentials with the chain rule. +This is mechanical and error-prone. +ModelingToolkit provides the utility function [`change_independent_variable`](@ref) that automates this process. + +## 1. Get one dependent variable as a function of another + +Consider a projectile shot with some initial velocity in a vertical gravitational field with constant horizontal velocity. + +```@example changeivar +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables x(t) y(t) +@parameters g=9.81 v # gravitational acceleration and horizontal velocity +eqs = [D(D(y)) ~ -g, D(x) ~ v] +initialization_eqs = [D(x) ~ D(y)] # 45° initial angle +M1 = ODESystem(eqs, t; initialization_eqs, name = :M) +M1s = structural_simplify(M1) +@assert length(equations(M1s)) == 3 # hide +M1s # hide +``` + +This is the standard parametrization that arises naturally from kinematics and Newton's laws. +It expresses the position $(x(t), y(t))$ as a function of time $t$. +But suppose we want to determine whether the projectile hits a target 10 meters away. +There are at least three ways of answering this: + + - Solve the ODE for $(x(t), y(t))$ and use a callback to terminate when $x$ reaches 10 meters, and evaluate $y$ at the final time. + - Solve the ODE for $(x(t), y(t))$ and use root finding to find the time when $x$ reaches 10 meters, and evaluate $y$ at that time. + - Solve the ODE for $y(x)$ and evaluate it at 10 meters. + +We will demonstrate the last method by changing the independent variable from $t$ to $x$. +This transformation is well-defined for any non-zero horizontal velocity $v$, so $x$ and $t$ are one-to-one. + +```@example changeivar +M2 = change_independent_variable(M1, x) +M2s = structural_simplify(M2; allow_symbolic = true) +# a sanity test on the 10 x/y variables that are accessible to the user # hide +@assert allequal([x, M1s.x]) # hide +@assert allequal([M2.x, M2s.x]) # hide +@assert allequal([y, M1s.y]) # hide +@assert allunique([M1.x, M1.y, M2.y, M2s.y]) # hide +@assert length(equations(M2s)) == 2 # hide +M2s # display this # hide +``` + +The derivatives are now with respect to the new independent variable $x$, which can be accessed with `M2.x`. + +!!! info + + At this point `x`, `M1.x`, `M1s.x`, `M2.x`, `M2s.x` are *three* different variables. + Meanwhile `y`, `M1.y`, `M1s.y`, `M2.y` and `M2s.y` are *four* different variables. + It can be instructive to inspect these yourself to see their subtle differences. + +Notice how the number of equations has also decreased from three to two, as $\mathrm{d}x/\mathrm{d}t$ has been turned into an observed equation. +It is straightforward to evolve the ODE for 10 meters and plot the resulting trajectory $y(x)$: + +```@example changeivar +using OrdinaryDiffEq, Plots +prob = ODEProblem(M2s, [M2s.y => 0.0], [0.0, 10.0], [v => 8.0]) # throw 10 meters +sol = solve(prob, Tsit5()) +plot(sol; idxs = M2.y) # must index by M2.y = y(x); not M1.y = y(t)! +``` + +!!! tip "Usage tips" + + Look up the documentation of [`change_independent_variable`](@ref) for tips on how to use it. + + For example, if you also need $t(x)$, you can tell it to add a differential equation for the old independent variable in terms of the new one using the [inverse function rule](https://en.wikipedia.org/wiki/Inverse_function_rule) (here $\mathrm{d}t/\mathrm{d}x = 1 / (\mathrm{d}x/\mathrm{d}t)$). If you know an analytical expression between the independent variables (here $t = x/v$), you can also pass it directly to the function to avoid the extra differential equation. + +## 2. Alleviating stiffness by changing to logarithmic time + +In cosmology, the [Friedmann equations](https://en.wikipedia.org/wiki/Friedmann_equations) describe the expansion of the universe. +In terms of conformal time $t$, they can be written + +```@example changeivar +@variables a(t) Ω(t) +a = GlobalScope(a) # global var needed by all species +function species(w; kw...) + eqs = [D(Ω) ~ -3(1 + w) * D(a) / a * Ω] + return ODESystem(eqs, t, [Ω], []; kw...) +end +@named r = species(1 // 3) # radiation +@named m = species(0) # matter +@named Λ = species(-1) # dark energy / cosmological constant +eqs = [Ω ~ r.Ω + m.Ω + Λ.Ω, D(a) ~ √(Ω) * a^2] +initialization_eqs = [Λ.Ω + r.Ω + m.Ω ~ 1] +M1 = ODESystem(eqs, t, [Ω, a], []; initialization_eqs, name = :M) +M1 = compose(M1, r, m, Λ) +M1s = structural_simplify(M1) +``` + +Of course, we can solve this ODE as it is: + +```@example changeivar +prob = ODEProblem(M1s, [M1s.a => 1.0, M1s.r.Ω => 5e-5, M1s.m.Ω => 0.3], (0.0, -3.3), []) +sol = solve(prob, Tsit5(); reltol = 1e-5) +@assert Symbol(sol.retcode) == :Unstable # surrounding text assumes this was unstable # hide +plot(sol, idxs = [M1.a, M1.r.Ω / M1.Ω, M1.m.Ω / M1.Ω, M1.Λ.Ω / M1.Ω]) +``` + +But the solver becomes unstable due to stiffness. +Also notice the interesting dynamics taking place towards the end of the integration (in the early universe), which gets compressed into a very small time interval. +These ODEs would benefit from being defined with respect to a logarithmic "time" that better captures the evolution of the universe through *orders of magnitude* of time, rather than linear time. + +It is therefore common to write these ODEs in terms of $b = \ln a$. +To do this, we will change the independent variable in two stages; first from $t$ to $a$, and then from $a$ to $b$. +Notice that $\mathrm{d}a/\mathrm{d}t > 0$ provided that $\Omega > 0$, and $\mathrm{d}b/\mathrm{d}a > 0$, so the transformation is well-defined since $t \leftrightarrow a \leftrightarrow b$ are one-to-one. +First, we transform from $t$ to $a$: + +```@example changeivar +M2 = change_independent_variable(M1, M1.a) +@assert !ModelingToolkit.isautonomous(M2) # hide +M2 # hide +``` + +Unlike the original, notice that this system is *non-autonomous* because the independent variable $a$ appears explicitly in the equations! +This means that to change the independent variable from $a$ to $b$, we must provide not only the rate of change relation $db(a)/da = \exp(-b)$, but *also* the equation $a(b) = \exp(b)$ so $a$ can be eliminated in favor of $b$: + +```@example changeivar +a = M2.a # get independent variable of M2 +Da = Differential(a) +@variables b(a) +M3 = change_independent_variable(M2, b, [Da(b) ~ exp(-b), a ~ exp(b)]) +``` + +We can now solve and plot the ODE in terms of $b$: + +```@example changeivar +M3s = structural_simplify(M3; allow_symbolic = true) +prob = ODEProblem(M3s, [M3s.r.Ω => 5e-5, M3s.m.Ω => 0.3], (0, -15), []) +sol = solve(prob, Tsit5()) +@assert Symbol(sol.retcode) == :Success # surrounding text assumes the solution was successful # hide +plot(sol, idxs = [M3.r.Ω / M3.Ω, M3.m.Ω / M3.Ω, M3.Λ.Ω / M3.Ω]) +``` + +Notice that the variables vary "more nicely" with respect to $b$ than $t$, making the solver happier and avoiding numerical problems. diff --git a/docs/src/tutorials/discrete_system.md b/docs/src/tutorials/discrete_system.md new file mode 100644 index 0000000000..8f6828fde7 --- /dev/null +++ b/docs/src/tutorials/discrete_system.md @@ -0,0 +1,51 @@ +# (Experimental) Modeling Discrete Systems + +In this example, we will use the new [`DiscreteSystem`](@ref) API +to create an SIR model. + +```@example discrete +using ModelingToolkit +using ModelingToolkit: t_nounits as t +using OrdinaryDiffEq: solve, FunctionMap + +@inline function rate_to_proportion(r, t) + 1 - exp(-r * t) +end +@parameters c δt β γ +@constants h = 1 +@variables S(t) I(t) R(t) +k = ShiftIndex(t) +infection = rate_to_proportion( + β * c * I(k - 1) / (S(k - 1) * h + I(k - 1) + R(k - 1)), δt * h) * S(k - 1) +recovery = rate_to_proportion(γ * h, δt) * I(k - 1) + +# Equations +eqs = [S(k) ~ S(k - 1) - infection * h, + I(k) ~ I(k - 1) + infection - recovery, + R(k) ~ R(k - 1) + recovery] +@mtkbuild sys = DiscreteSystem(eqs, t) + +u0 = [S(k - 1) => 990.0, I(k - 1) => 10.0, R(k - 1) => 0.0] +p = [β => 0.05, c => 10.0, γ => 0.25, δt => 0.1] +tspan = (0.0, 100.0) +prob = DiscreteProblem(sys, u0, tspan, p) +sol = solve(prob, FunctionMap()) +``` + +All shifts must be non-positive, i.e., discrete-time variables may only be indexed at index +`k, k-1, k-2, ...`. If default values are provided, they are treated as the value of the +variable at the previous timestep. For example, consider the following system to generate +the Fibonacci series: + +```@example discrete +@variables x(t) = 1.0 +@mtkbuild sys = DiscreteSystem([x ~ x(k - 1) + x(k - 2)], t) +``` + +The "default value" here should be interpreted as the value of `x` at all past timesteps. +For example, here `x(k-1)` and `x(k-2)` will be `1.0`, and the initial value of `x(k)` will +thus be `2.0`. During problem construction, the _past_ value of a variable should be +provided. For example, providing `[x => 1.0]` while constructing this problem will error. +Provide `[x(k-1) => 1.0]` instead. Note that values provided during problem construction +_do not_ apply to the entire history. Hence, if `[x(k-1) => 2.0]` is provided, the value of +`x(k-2)` will still be `1.0`. diff --git a/docs/src/tutorials/disturbance_modeling.md b/docs/src/tutorials/disturbance_modeling.md new file mode 100644 index 0000000000..b77a73b0c1 --- /dev/null +++ b/docs/src/tutorials/disturbance_modeling.md @@ -0,0 +1,232 @@ +# Disturbance and input modeling modeling + +Disturbances are often seen as external factors that influence a system. Modeling and simulation of such external influences is common in order to ensure that the plant and or control system can adequately handle or suppress these disturbances. Disturbance modeling is also integral to the problem of state estimation, indeed, modeling how disturbances affect the evolution of the state of the system is crucial in order to accurately estimate this state. + +This tutorial will show how to model disturbances in ModelingToolkit as _disturbance inputs_. This involves demonstrating best practices that make it easy to use a single model to handle both disturbed and undisturbed systems, and making use of the model for both simulation and state estimation. + +## A flexible component-based workflow + +We will consider a simple system consisting of two inertias connected through a flexible shaft, such as a simple transmission system in a car. We start by modeling the plant _without any input signals_: + +```@example DISTURBANCE_MODELING +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra, Test +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +t = ModelingToolkit.t_nounits +D = ModelingToolkit.D_nounits + +@mtkmodel SystemModel begin + @parameters begin + m1 = 1 + m2 = 1 + k = 10 # Spring stiffness + c = 3 # Damping coefficient + end + @components begin + inertia1 = Inertia(; J = m1, phi = 0, w = 0) + inertia2 = Inertia(; J = m2, phi = 0, w = 0) + spring = Spring(; c = k) + damper = Damper(; d = c) + torque = Torque(use_support = false) + end + @equations begin + connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b) + end +end +``` + +Here, we have added a `torque` component that allows us to add a torque input to drive the system, but we have not connected any signal to it yet. We have not yet made any attempts at modeling disturbances, and this is deliberate, we will handle this later in order to make the plant model as generically useful as possible. + +In order to simulate this system in the presence of disturbances, we must 1. Reason about how disturbances may affect the system, and 2. attach _disturbance inputs_ and _disturbance signals_ to the model. We distinguish between an _input_ and a _signal_ here, where we by _input_ mean an attachment point (connector) to which we may connect a _signal_, i.e., a time-varying function. + +We create a new model that includes disturbance inputs and signals, and attach those to the already defined plant model. We assume that each of the two inertias can be affected by a disturbance torque, such as due to friction or an unknown load on the output inertia. + +```@example DISTURBANCE_MODELING +@mtkmodel ModelWithInputs begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Step(height = -1, start_time = 2) # We add an input signal that equals zero by default so that it has no effect during normal simulation + disturbance_signal2 = Blocks.Step(height = 2, start_time = 4) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_torque1.tau) # When we connect the input _signals_, we do so through an analysis point. This allows us to easily disconnect the input signals in situations when we do not need them. + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + end +end +``` + +This outer model, `ModelWithInputs`, contains two disturbance inputs, both of type `Torque`. It also contains three signal specifications, one for the control input and two for the corresponding disturbance inputs. Note how we added the disturbance torque inputs at this level of the model, but the control input was added inside the system model. This is a design choice that is up to the modeler, here, we consider the driving torque to be a fundamental part of the model that is always required to make use of it, while the disturbance inputs may be of interest only in certain situations, and we thus add them when needed. Since we have added not only input connectors, but also connected input signals to them, this model is complete and ready for simulation, i.e., there are no _unbound inputs_. + +```@example DISTURBANCE_MODELING +@named model = ModelWithInputs() # Model with load disturbance +ssys = structural_simplify(model) +prob = ODEProblem(ssys, [], (0.0, 6.0)) +sol = solve(prob, Tsit5()) +using Plots +plot(sol) +``` + +A thing to note in the specification of `ModelWithInputs` is the presence of three [analysis points](https://docs.sciml.ai/ModelingToolkit/dev/tutorials/linear_analysis/#ModelingToolkit.AnalysisPoint), `:u`, `:d1`, and `:d2`. When signals are connected through an analysis point, we may at any time linearize the model as if the signals were not connected, i.e., as if the corresponding inputs were unbound. We may also use this to generate a julia function for the dynamics on the form ``f(x,u,p,t,w)`` where the input ``u`` and disturbance ``w`` may be provided as separate function arguments, as if the corresponding input signals were not present in the model. More details regarding this will be presented further below, here, we just demonstrate how we could linearize this system model from the inputs to the angular velocity of the inertias + +```@example DISTURBANCE_MODELING +using ControlSystemsBase, ControlSystemsMTK # ControlSystemsMTK provides the high-level function named_ss and ControlSystemsBase provides the bodeplot function +P = named_ss(model, [ssys.u, ssys.d1, ssys.d2], + [ssys.system_model.inertia1.w, ssys.system_model.inertia2.w]) +bodeplot(P, plotphase = false) +``` + +It's worth noting at this point that the fact that we could connect disturbance outputs from outside of the plant-model definition was enabled by the fact that we used a component-based workflow, where the plant model had the appropriate connectors available. If the plant model had modeled the system using direct equations without connectors, this would not have been possible and the model would thus be significantly less flexible. + +We summarize the findings so far as a number of best practices: + +!!! tip "Best practices" + + - Use a component-based workflow to model the plant + - If possible, model the plant without explicit disturbance inputs to make it as generic as possible + - When disturbance inputs are needed, create a new model that includes the plant model and the disturbance inputs + - Only add input _signals_ at the top level of the model, this applies to both control inputs and disturbance inputs. + - Use analysis points to connect signals to inputs, this allows for easy disconnection of signals when needed, e.g., for linearization or function generation. + +## Modeling for state estimation + +In the example above, we constructed a model for _simulation_ of a disturbance affecting the system. When simulating, we connect an input signal of specified shape that simulates the disturbance, above, we used `Blocks.Step` as input signals. On the other hand, when performing state estimation, the exact shape of the disturbance is typically not known, we might only have some diffuse knowledge of the disturbance characteristics such as "varies smoothly", "makes sudden step changes" or "is approximately periodic with 24hr period". The encoding of such knowledge is commonly reasoned about in the frequency domain, where we specify a disturbance model as a dynamical system with a frequency response similar to the approximate spectrum of the disturbance. [For more details around this, see the in-depth tutorial notebook "How to tune a Kalman filter"](https://juliahub.com/pluto/editor.html?id=ad9ecbf9-bf83-45e7-bbe8-d2e5194f2240). Most algorithms for state estimation, such as a Kalman-filter like estimators, assume that disturbances are independent and identically distributed (i.i.d.). While seemingly restrictive at first glance, when combined with an appropriate disturbance models encoded as dynamical systems, this assumption still allows for a wide range of non i.i.d. disturbances to be modeled. + +When modeling a system in MTK, we essentially (without considering algebraic equations for simplicity in exposition) construct a model of a dynamical system + +```math +\begin{aligned} +\dot x &= f(x, p, t) \\ +y &= g(x, p, t) +\end{aligned} +``` + +where ``x`` is the state, ``y`` are observed variables, ``p`` are parameters, and ``t`` is time. When using MTK, which variables constitute ``x`` and which are considered part of the output, ``y``, is up to the tool rather than the user, this choice is made by MTK during the call to `@mtkbuild` or the lower-level function `structural_simplify`. + +If we further consider external inputs to the system, such as controlled input signals ``u`` and disturbance inputs ``w``, we can write the system as + +```math +\begin{aligned} +\dot x &= f(x, u, p, t, w) \\ +y &= g(x, u, p, t) +\end{aligned} +``` + +To make use of the model defined above for state estimation, we may want to generate a Julia function for the dynamics ``f`` and the output equations ``g`` that we can plug into, e.g., a nonlinear version of a Kalman filter or a particle filter, etc. MTK contains utilities to do this, namely, [`generate_control_function`](@ref) and [`build_explicit_observed_function`](@ref) (described in more details in ["Input output"](@ref inputoutput)). These functions take keyword arguments `disturbance_inputs` and `disturbance_argument`, that indicate which variables in the model are considered part of ``w``, and whether or not these variables are to be added as function arguments to ``f``, i.e., whether we have ``f(x, u, p, t)`` or ``f(x, u, p, t, w)``. If we do not include the disturbance inputs as function arguments, MTK will assume that the ``w`` variables are all zero, but any dynamics associated with these variables, such as disturbance models, will be included in the generated function. This allows a state estimator to estimate the state of the disturbance model, provided that this state is [observable](https://en.wikipedia.org/wiki/Observability) from the measured outputs of the system. + +Below, we demonstrate + + 1. How to add an integrating disturbance model + 2. how to generate the functions ``f`` and ``g`` for a typical nonlinear state estimator with explicit disturbance inputs + +```@example DISTURBANCE_MODELING +@mtkmodel IntegratingDisturbance begin + @variables begin + x(t) = 0.0 + w(t) = 0.0, [disturbance = true, input = true] + end + @components begin + input = RealInput() + output = RealOutput() + end + @equations begin + D(x) ~ w + w ~ input.u + output.u ~ x + end +end + +@mtkmodel SystemModelWithDisturbanceModel begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + disturbance_model = Blocks.Integrator() + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_model.input) + connect(disturbance_model.output, disturbance_torque1.tau) + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + end +end + +@named model_with_disturbance = SystemModelWithDisturbanceModel() +``` + +We demonstrate that this model is complete and can be simulated: + +```@example DISTURBANCE_MODELING +ssys = structural_simplify(model_with_disturbance) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +using Test +@test SciMLBase.successful_retcode(sol) +``` + +but we may also generate the functions ``f`` and ``g`` for state estimation: + +```@example DISTURBANCE_MODELING +inputs = [ssys.u] +disturbance_inputs = [ssys.d1, ssys.d2] +P = ssys.system_model +outputs = [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w] + +(f_oop, f_ip), x_sym, p_sym, io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, inputs, disturbance_inputs; disturbance_argument = true) + +g = ModelingToolkit.build_explicit_observed_function( + io_sys, outputs; inputs) + +op = ModelingToolkit.inputs(io_sys) .=> 0 +x0, _ = ModelingToolkit.get_u0_p(io_sys, op, op) +p = MTKParameters(io_sys, op) +u = zeros(1) # Control input +w = zeros(length(disturbance_inputs)) # Disturbance input +@test f_oop(x0, u, p, t, w) == zeros(5) +@test g(x0, u, p, 0.0) == [0, 0, 0, 0] + +# Non-zero disturbance inputs should result in non-zero state derivatives. We call `sort` since we do not generally know the order of the state variables +w = [1.0, 2.0] +@test sort(f_oop(x0, u, p, t, w)) == [0, 0, 0, 1, 2] +``` + +## Input signal library + +The [`Blocks` module in ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/API/blocks/) contains several predefined input signals, such as `Sine, Step, Ramp, Constant` etc., a few of which were used in the examples above. If you have an input signal represented as a sequence of samples, you may use an [`Interpolation` block](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/tutorials/input_component/), e.g., as `src = Interpolation(ConstantInterpolation, data, timepoints)`, see the docstring for a complete example. + +## Disturbance-model library + +There is no library explicitly constructed for disturbance modeling. Standard blocks from the [`Blocks` module in ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/API/blocks/), such as `Integrator, TransferFunction, StateSpace`, can model any disturbance with rational spectrum. Examples of this includes disturbance models such as constants, piecewise constant, periodic, highpass, lowpass, and bandpass. For help with filter design, see [ControlSystems.jl: Filter-design](https://juliacontrol.github.io/ControlSystems.jl/stable/man/creating_systems/#Filter-design) and the interface package [ControlSystemsMTK.jl](https://juliacontrol.github.io/ControlSystemsMTK.jl/dev/). In the example above, we made use of `Blocks.Integrator`, which is a disturbance model suitable for disturbances dominated by low-frequency components, such as piecewise constant signals or slowly drifting signals. + +## Further reading + +To see full examples that perform state estimation with ModelingToolkit models, see the following resources: + + - [C codegen considered unnecessary: go directly to binary, do not pass C. Compilation of Julia code for deployment in model-based engineering](https://arxiv.org/abs/2502.01128) + - [LowLevelParticleFiltersMTK.jl](https://github.com/baggepinnen/LowLevelParticleFiltersMTK.jl) + +## Index + +```@index +Pages = ["disturbance_modeling.md"] +``` + +```@autodocs +Modules = [ModelingToolkit] +Pages = ["systems/analysis_points.jl"] +Order = [:function, :type] +Private = false +``` diff --git a/docs/src/tutorials/domain_connections.md b/docs/src/tutorials/domain_connections.md new file mode 100644 index 0000000000..d6dc2d8781 --- /dev/null +++ b/docs/src/tutorials/domain_connections.md @@ -0,0 +1,294 @@ +# [Domains](@id domains) + +## Basics + +A domain in ModelingToolkit.jl is a network of connected components that share properties of the medium in the network. For example, a collection of hydraulic components connected together will have a fluid medium. Using the domain feature, one only needs to define and set the fluid medium properties once, in one component, rather than at each component. The way this works in ModelingToolkit.jl is by defining a connector (with Through/Flow and Across variables) with parameters defining the medium of the domain. Then a second connector is defined, with the same parameters, and the same Through/Flow variable, which acts as the setter. For example, a hydraulic domain may have a hydraulic connector, `HydraulicPort`, that defines a fluid medium with density (`ρ`), viscosity (`μ`), and a bulk modulus (`β`), a through/flow variable mass flow (`dm`) and an across variable pressure (`p`). + +```@example domain +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@connector function HydraulicPort(; p_int, name) + pars = @parameters begin + ρ + β + μ + end + + vars = @variables begin + p(t) = p_int + dm(t), [connect = Flow] + end + + ODESystem(Equation[], t, vars, pars; name, defaults = [dm => 0]) +end +nothing #hide +``` + +The fluid medium setter for `HydralicPort` may be defined as `HydraulicFluid` with the same parameters and through/flow variable. But now, the parameters can be set through the function keywords. + +```@example domain +@connector function HydraulicFluid(; + density = 997, + bulk_modulus = 2.09e9, + viscosity = 0.0010016, + name) + pars = @parameters begin + ρ = density + β = bulk_modulus + μ = viscosity + end + + vars = @variables begin + dm(t), [connect = Flow] + end + + eqs = [ + dm ~ 0 + ] + + ODESystem(eqs, t, vars, pars; name, defaults = [dm => 0]) +end +nothing #hide +``` + +Now, we can connect a `HydraulicFluid` component to any `HydraulicPort` connector, and the parameters of all `HydraulicPort`'s in the network will be automatically set. Let's consider a simple example, connecting a pressure source component to a volume component. Note that we don't need to define density for the volume component, it's supplied by the `HydraulicPort` (`port.ρ`). + +```@example domain +@component function FixedPressure(; p, name) + pars = @parameters p = p + systems = @named begin + port = HydraulicPort(; p_int = p) + end + + eqs = [port.p ~ p] + + ODESystem(eqs, t, [], pars; name, systems) +end + +@component function FixedVolume(; vol, p_int, name) + pars = @parameters begin + p_int = p_int + vol = vol + end + + systems = @named begin + port = HydraulicPort(; p_int) + end + + vars = @variables begin + rho(t) = port.ρ + drho(t) = 0 + end + + # let + dm = port.dm + p = port.p + + eqs = [D(rho) ~ drho + rho ~ port.ρ * (1 + p / port.β) + dm ~ drho * vol] + + ODESystem(eqs, t, vars, pars; name, systems) +end +nothing #hide +``` + +When the system is defined we can generate a fluid component and connect it to the system. Here `fluid` is connected to the `src.port`, but it could also be connected to `vol.port`, any connection in the network is fine. + +```@example domain +@component function System(; name) + systems = @named begin + src = FixedPressure(; p = 200e5) + vol = FixedVolume(; vol = 0.1, p_int = 200e5) + + fluid = HydraulicFluid(; density = 876) + end + + eqs = [connect(fluid, src.port) + connect(src.port, vol.port)] + + ODESystem(eqs, t, [], []; systems, name) +end + +@named odesys = System() +nothing #hide +``` + +To see how the domain works, we can examine the set parameter values for each of the ports `src.port` and `vol.port`. First we assemble the system using `structural_simplify()` and then check the default value of `vol.port.ρ`, whichs points to the setter value `fluid₊ρ`. Likewise, `src.port.ρ`, will also point to the setter value `fluid₊ρ`. Therefore, there is now only 1 defined density value `fluid₊ρ` which sets the density for the connected network. + +```@repl domain +sys = structural_simplify(odesys) +ModelingToolkit.defaults(sys)[odesys.vol.port.ρ] +``` + +## Multiple Domain Networks + +If we have a more complicated system, for example a hydraulic actuator, with a separated fluid on both sides of the piston, it's possible we might have 2 separate domain networks. In this case we can connect 2 separate fluids, or the same fluid, to both networks. First a simple actuator is defined with 2 ports. + +```@example domain +@component function Actuator(; p_int, mass, area, name) + pars = @parameters begin + p_int = p_int + mass = mass + area = area + end + + systems = @named begin + port_a = HydraulicPort(; p_int) + port_b = HydraulicPort(; p_int) + end + + vars = @variables begin + x(t) = 0 + dx(t) = 0 + ddx(t) = 0 + end + + eqs = [D(x) ~ dx + D(dx) ~ ddx + mass * ddx ~ (port_a.p - port_b.p) * area + port_a.dm ~ +(port_a.ρ) * dx * area + port_b.dm ~ -(port_b.ρ) * dx * area] + + ODESystem(eqs, t, vars, pars; name, systems) +end +nothing #hide +``` + +A system with 2 different fluids is defined and connected to each separate domain network. + +```@example domain +@component function ActuatorSystem2(; name) + systems = @named begin + src_a = FixedPressure(; p = 200e5) + src_b = FixedPressure(; p = 200e5) + act = Actuator(; p_int = 200e5, mass = 1000, area = 0.1) + + fluid_a = HydraulicFluid(; density = 876) + fluid_b = HydraulicFluid(; density = 999) + end + + eqs = [connect(fluid_a, src_a.port) + connect(fluid_b, src_b.port) + connect(src_a.port, act.port_a) + connect(src_b.port, act.port_b)] + + ODESystem(eqs, t, [], []; systems, name) +end + +@named actsys2 = ActuatorSystem2() +nothing #hide +``` + +After running `structural_simplify()` on `actsys2`, the defaults will show that `act.port_a.ρ` points to `fluid_a₊ρ` and `act.port_b.ρ` points to `fluid_b₊ρ`. This is a special case, in most cases a hydraulic system will have only 1 fluid, however this simple system has 2 separate domain networks. Therefore, we can connect a single fluid to both networks. This does not interfere with the mathematical equations of the system, since no unknown variables are connected. + +```@example domain +@component function ActuatorSystem1(; name) + systems = @named begin + src_a = FixedPressure(; p = 200e5) + src_b = FixedPressure(; p = 200e5) + act = Actuator(; p_int = 200e5, mass = 1000, area = 0.1) + + fluid = HydraulicFluid(; density = 876) + end + + eqs = [connect(fluid, src_a.port) + connect(fluid, src_b.port) + connect(src_a.port, act.port_a) + connect(src_b.port, act.port_b)] + + ODESystem(eqs, t, [], []; systems, name) +end + +@named actsys1 = ActuatorSystem1() +nothing #hide +``` + +## Special Connection Cases (`domain_connect()`) + +In some cases a component will be defined with 2 connectors of the same domain, but they are not connected. For example the `Restrictor` defined here gives equations to define the behavior of how the 2 connectors `port_a` and `port_b` are physically connected. + +```@example domain +@component function Restrictor(; name, p_int) + pars = @parameters begin + K = 0.1 + p_int = p_int + end + + systems = @named begin + port_a = HydraulicPort(; p_int) + port_b = HydraulicPort(; p_int) + end + + eqs = [port_a.dm ~ (port_a.p - port_b.p) * K + 0 ~ port_a.dm + port_b.dm] + + ODESystem(eqs, t, [], pars; systems, name) +end +nothing #hide +``` + +Adding the `Restrictor` to the original system example will cause a break in the domain network, since a `connect(port_a, port_b)` is not defined. + +```@example domain +@component function RestrictorSystem(; name) + systems = @named begin + src = FixedPressure(; p = 200e5) + res = Restrictor(; p_int = 200e5) + vol = FixedVolume(; vol = 0.1, p_int = 200e5) + + fluid = HydraulicFluid(; density = 876) + end + + eqs = [connect(fluid, src.port) + connect(src.port, res.port_a) + connect(res.port_b, vol.port)] + + ODESystem(eqs, t, [], []; systems, name) +end + +@mtkbuild ressys = RestrictorSystem() +nothing #hide +``` + +When `structural_simplify()` is applied to this system it can be seen that the defaults are missing for `res.port_b` and `vol.port`. + +```@repl domain +ModelingToolkit.defaults(ressys)[ressys.res.port_a.ρ] +ModelingToolkit.defaults(ressys)[ressys.res.port_b.ρ] +ModelingToolkit.defaults(ressys)[ressys.vol.port.ρ] +``` + +To ensure that the `Restrictor` component does not disrupt the domain network, the [`domain_connect()`](@ref) function can be used, which explicitly only connects the domain network and not the unknown variables. + +```@example domain +@component function Restrictor(; name, p_int) + pars = @parameters begin + K = 0.1 + p_int = p_int + end + + systems = @named begin + port_a = HydraulicPort(; p_int) + port_b = HydraulicPort(; p_int) + end + + eqs = [domain_connect(port_a, port_b) # <-- connect the domain network + port_a.dm ~ (port_a.p - port_b.p) * K + 0 ~ port_a.dm + port_b.dm] + + ODESystem(eqs, t, [], pars; systems, name) +end + +@mtkbuild ressys = RestrictorSystem() +nothing #hide +``` + +Now that the `Restrictor` component is properly defined using `domain_connect()`, the defaults for `res.port_b` and `vol.port` are properly defined. + +```@repl domain +ModelingToolkit.defaults(ressys)[ressys.res.port_a.ρ] +ModelingToolkit.defaults(ressys)[ressys.res.port_b.ρ] +ModelingToolkit.defaults(ressys)[ressys.vol.port.ρ] +``` diff --git a/docs/src/tutorials/fmi.md b/docs/src/tutorials/fmi.md new file mode 100644 index 0000000000..ef00477c78 --- /dev/null +++ b/docs/src/tutorials/fmi.md @@ -0,0 +1,226 @@ +# Importing FMUs + +ModelingToolkit is able to import FMUs following the [FMI Standard](https://fmi-standard.org/) versions 2 and 3. +This integration is done through [FMI.jl](https://github.com/ThummeTo/FMI.jl) and requires importing it to +enable the relevant functionality. Currently Model Exchange (ME) and CoSimulation (CS) FMUs are supported. +Events, non-floating-point variables and array variables are not supported. Additionally, calculating the +time derivatives of FMU states/outputs is not supported. + +!!! danger "Experimental" + + This functionality is currently experimental and subject to change without a breaking release of + ModelingToolkit.jl. + +## FMUs of full models + +Here, we will demonstrate the usage of an FMU of an entire model (as opposed to a single component). +First, the required libraries must be imported and the FMU loaded using FMI.jl. + +```@example fmi +using ModelingToolkit, FMI, FMIZoo, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +# This is a spring-pendulum FMU from FMIZoo.jl. It is a v2 FMU +# and we are importing it in ModelExchange format. +fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) +``` + +Following are the variables in the FMU (both states and parameters): + +```@example fmi +fmu.modelDescription.modelVariables +``` + +Next, [`FMIComponent`](@ref) is used to import the FMU as an MTK component. We provide the FMI +major version as a `Val` to the constructor, along with the loaded FMU and the type as keyword +arguments. + +```@example fmi +@named model = ModelingToolkit.FMIComponent(Val(2); fmu, type = :ME) +``` + +Note how hierarchical names in the FMU (e.g. `mass.m` or `spring.f`) are turned into flattened +names, with `__` being the namespace separator (`mass__m` and `spring__f`). + +!!! note + + Eventually we plan to reconstruct a hierarchical system structure mirroring the one indicated + by the variables in the FMU. This would allow accessing the above mentioned variables as + `model.mass.m` and `model.spring.f` instead of `model.mass__m` and `model.spring__f` respectively. + +Derivative variables such as `der(mass.v)` use the dummy derivative notation, and are hence transformed +into a form similar to `mass__vˍt`. However, they can still be referred to as `D(model.mass__v)`. + +```@example fmi +equations(model) +``` + +Since the FMI spec allows multiple names to alias the same quantity, ModelingToolkit.jl creates +equations to alias them. For example, it can be seen above that `der(mass.v)` and `mass.a` have the +same reference, and hence refer to the same quantity. Correspondingly, there is an equation +`mass__vˍt(t) ~ mass__a(t)` in the system. + +!!! note + + Any variables and/or parameters that are not part of the FMU should be ignored, as ModelingToolkit + creates them to manage the FMU. Unexpected usage of these variables/parameters can lead to errors. + +```@example fmi +defaults(model) +``` + +All parameters in the FMU are given a default equal to their start value, if present. Unknowns are not +assigned defaults even if a start value is present, as this would conflict with ModelingToolkit's own +initialization semantics. + +We can simulate this model like any other ModelingToolkit system. + +```@repl fmi +sys = structural_simplify(model) +prob = ODEProblem(sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 5.0)) +sol = solve(prob, Tsit5()) +``` + +We can interpolate the solution object to obtain values at arbitrary time points in the solved interval, +just like a normal solution. + +```@repl fmi +sol(0.0:0.1:1.0; idxs = sys.mass_a) +``` + +FMUs following version 3 of the specification can be simulated with almost the same process. This time, +we will create a model from a CoSimulation FMU. + +```@example fmi +fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :CS) +@named inner = ModelingToolkit.FMIComponent( + Val(3); fmu, communication_step_size = 0.001, type = :CS) +``` + +This FMU has fewer equations, partly due to missing aliasing variables and partly due to being a CS FMU. +CoSimulation FMUs are bundled with an integrator. As such, they do not function like ME FMUs. Instead, +a callback steps the FMU at periodic intervals in time and obtains the updated state. This state is held +constant until the next time the callback triggers. The periodic interval must be specified through the +`communication_step_size` keyword argument. A smaller step size typically leads to less error but is +more computationally expensive. + +This model alone does not have any differential variables, and calling `structural_simplify` will lead +to an `ODESystem` with no unknowns. + +```@example fmi +structural_simplify(inner) +``` + +Simulating this model will cause the OrdinaryDiffEq integrator to immediately finish, and will not +trigger the callback. Thus, we wrap this system in a trivial system with a differential variable. + +```@example fmi +@variables x(t) = 1.0 +@mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) +``` + +We can now simulate `sys`. + +```@example fmi +prob = ODEProblem(sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 5.0)) +sol = solve(prob, Tsit5()) +``` + +The variables of the FMU are discrete, and their timeseries can be obtained at intervals of +`communication_step_size`. + +```@example fmi +sol[sys.inner.mass__s] +``` + +## FMUs of components + +FMUs can also be imported as individual components. For this example, we will use custom FMUs used +in the test suite of ModelingToolkit.jl. + +```@example fmi +fmu = loadFMU( + joinpath(@__DIR__, "..", "..", "..", "test", "fmi", "fmus", "SimpleAdder.fmu"); + type = :ME) +fmu.modelDescription.modelVariables +``` + +This FMU is equivalent to the following model: + +```julia +@mtkmodel SimpleAdder begin + @variables begin + a(t) + b(t) + c(t) + out(t) + out2(t) + end + @parameters begin + value = 1.0 + end + @equations begin + out ~ a + b + value + D(c) ~ out + out2 ~ 2c + end +end +``` + +`a` and `b` are inputs, `c` is a state, and `out` and `out2` are outputs of the component. + +```@repl fmi +@named adder = ModelingToolkit.FMIComponent(Val(2); fmu, type = :ME); +isinput(adder.a) +isinput(adder.b) +isoutput(adder.out) +isoutput(adder.out2) +``` + +ModelingToolkit recognizes input and output variables of the component, and attaches the appropriate +metadata. We can now use this component as a subcomponent of a larger system. + +```@repl fmi +@variables a(t) b(t) c(t) [guess = 1.0]; +@mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) +equations(sys) +``` + +Note how the output `adder.out` is used in an algebraic equation of the system. We have also given +`sys.c` a guess, expecting it to be solved for by initialization. ModelingToolkit is able to use +FMUs in initialization to solve for initial states. As mentioned earlier, we cannot differentiate +through an FMU. Thus, automatic differentiation has to be disabled for the solver. + +```@example fmi +prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) +solve(prob, Rodas5P(autodiff = false)) +``` + +CoSimulation FMUs follow a nearly identical process. Since CoSimulation FMUs operate using callbacks, +after triggering the callbacks and altering the discrete state the algebraic equations may no longer +be satisfied. To resolve for the values of algebraic variables, we use the `reinitializealg` keyword +of `FMIComponent`. This is a DAE initialization algorithm to use at the end of every callback. Since +CoSimulation FMUs are not directly involved in the RHS of the system - instead operating through +callbacks - we can use a solver with automatic differentiation. + +```@example fmi +fmu = loadFMU( + joinpath(@__DIR__, "..", "..", "..", "test", "fmi", "fmus", "SimpleAdder.fmu"); + type = :CS) +@named adder = ModelingToolkit.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-3, + reinitializealg = BrownFullBasicInit()) +@mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) +prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) +solve(prob, Rodas5P()) +``` diff --git a/docs/src/tutorials/higher_order.md b/docs/src/tutorials/higher_order.md deleted file mode 100644 index 018236672e..0000000000 --- a/docs/src/tutorials/higher_order.md +++ /dev/null @@ -1,60 +0,0 @@ -# Automatic Transformation of Nth Order ODEs to 1st Order ODEs - -ModelingToolkit has a system for transformations of mathematical -systems. These transformations allow for symbolically changing -the representation of the model to problems that are easier to -numerically solve. One simple to demonstrate transformation is the -`ode_order_lowering` transformation that sends an Nth order ODE -to a 1st order ODE. - -To see this, let's define a second order riff on the Lorenz equations. -We utilize the derivative operator twice here to define the second order: - -```julia -using ModelingToolkit, OrdinaryDiffEq - -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) - -eqs = [D(D(x)) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -sys = ODESystem(eqs) -``` - -Note that we could've used an alternative syntax for 2nd order, i.e. -`D = Differential(t)^2` and then `E(x)` would be the second derivative, -and this syntax extends to `N`-th order. Also, we can use `*` or `∘` to compose -`Differential`s, like `Differential(t) * Differential(x)`. - -Now let's transform this into the `ODESystem` of first order components. -We do this by simply calling `ode_order_lowering`: - -```julia -sys = ode_order_lowering(sys) -``` - -Now we can directly numerically solve the lowered system. Note that, -following the original problem, the solution requires knowing the -initial condition for `x'`, and thus we include that in our input -specification: - -```julia -u0 = [D(x) => 2.0, - x => 1.0, - y => 0.0, - z => 0.0] - -p = [σ => 28.0, - ρ => 10.0, - β => 8/3] - -tspan = (0.0,100.0) -prob = ODEProblem(sys,u0,tspan,p,jac=true) -sol = solve(prob,Tsit5()) -using Plots; plot(sol,vars=(x,y)) -``` - -![Lorenz2](https://user-images.githubusercontent.com/1814174/79118645-744eb580-7d5c-11ea-9c37-13c4efd585ca.png) diff --git a/docs/src/tutorials/initialization.md b/docs/src/tutorials/initialization.md new file mode 100644 index 0000000000..ba733e0bfb --- /dev/null +++ b/docs/src/tutorials/initialization.md @@ -0,0 +1,549 @@ +# Initialization of ODESystems + +While for simple numerical ODEs choosing an initial condition can be an easy +affair, with ModelingToolkit's more general differential-algebraic equation +(DAE) system there is more care needed due to the flexibility of the solver +state. In this tutorial we will walk through the functionality involved in +initialization of ODESystem and the diagnostics to better understand and +debug the initialization problem. + +## Primer on Initialization of Differential-Algebraic Equations + +Before getting started, let's do a brief walkthrough of the mathematical +principles of initialization of DAE systems. Take a DAE written in semi-explicit +form: + +```math +\begin{aligned} + x^\prime &= f(x,y,t) \\ + 0 &= g(x,y,t) +\end{aligned} +``` + +where ``x`` are the differential variables and ``y`` are the algebraic variables. +An initial condition ``u0 = [x(t_0) y(t_0)]`` is said to be consistent if +``g(x(t_0),y(t_0),t_0) = 0``. + +For ODEs, this is trivially satisfied. However, for more complicated systems it may +not be easy to know how to choose the variables such that all of the conditions +are satisfied. This is even more complicated when taking into account ModelingToolkit's +simplification engine, given that variables can be eliminated and equations can be +changed. If this happens, how do you know how to initialize the system? + +## Initialization By Example: The Cartesian Pendulum + +To illustrate how to perform the initialization, let's take a look at the Cartesian +pendulum: + +```@example init +using ModelingToolkit, OrdinaryDiffEq, Plots +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters g +@variables x(t) y(t) [state_priority = 10] λ(t) +eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] +@mtkbuild pend = ODESystem(eqs, t) +``` + +While we defined the system using second derivatives and a length constraint, +the structural simplification system improved the numerics of the system to +be solvable using the dummy derivative technique, which results in 3 algebraic +equations and 2 differential equations. In this case, the differential equations +with respect to `y` and `D(y)`, though it could have just as easily have been +`x` and `D(x)`. How do you initialize such a system if you don't know in advance +what variables may defined the equation's state? + +To see how the system works, let's start the pendulum in the far right position, +i.e. `x(0) = 1` and `y(0) = 0`. We can do this by: + +```@example init +prob = ODEProblem(pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], guesses = [λ => 1]) +``` + +This solves via: + +```@example init +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +and we can check it satisfies our conditions via: + +```@example init +conditions = getfield.(equations(pend)[3:end], :rhs) +``` + +```@example init +[sol[conditions][1]; sol[x][1] - 1; sol[y][1]] +``` + +Notice that we set `[x => 1, y => 0]` as our initial conditions and `[λ => 1]` as our guess. +The difference is that the initial conditions are **required to be satisfied**, while the +guesses are simply a guess for what the initial value might be. Every variable must have +either an initial condition or a guess, and thus since we did not know what `λ` would be +we set it to 1 and let the initialization scheme find the correct value for λ. Indeed, +the value for `λ` at the initial time is not 1: + +```@example init +sol[λ][1] +``` + +We can similarly choose `λ = 0` and solve for `y` to start the system: + +```@example init +prob = ODEProblem(pend, [x => 1, λ => 0], (0.0, 1.5), [g => 1], guesses = [y => 1]) +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +or choose to satisfy derivative conditions: + +```@example init +prob = ODEProblem( + pend, [x => 1, D(y) => 0], (0.0, 1.5), [g => 1], guesses = [λ => 0, y => 1]) +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +Notice that since a derivative condition is given, we are required to give a +guess for `y`. + +We can also directly give equations to be satisfied at the initial point by using +the `initialization_eqs` keyword argument, for example: + +```@example init +prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], guesses = [λ => 0, y => 1], + initialization_eqs = [y ~ 0]) +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +Additionally, note that the initial conditions are allowed to be functions of other +variables and parameters: + +```@example init +prob = ODEProblem( + pend, [x => 1, D(y) => g], (0.0, 3.0), [g => 1], guesses = [λ => 0, y => 1]) +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +## Determinability: Underdetermined and Overdetermined Systems + +For this system we have 3 conditions to satisfy: + +```@example init +conditions = getfield.(equations(pend)[3:end], :rhs) +``` + +when we initialize with + +```@example init +prob = ODEProblem(pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], guesses = [y => 0, λ => 1]) +``` + +we have two extra conditions to satisfy, `x ~ 1` and `y ~ 0` at the initial point. That gives +5 equations for 5 variables and thus the system is well-formed. What happens if that's not the +case? + +```@example init +prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], guesses = [y => 0, λ => 1]) +``` + +Here we have 4 equations for 5 unknowns (note: the warning is post-simplification of the +nonlinear system, which solves the trivial `x ~ 1` equation analytical and thus says +3 equations for 4 unknowns). This warning thus lets you know the system is underdetermined +and thus the solution is not necessarily unique. It can still be solved: + +```@example init +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +and the found initial condition satisfies all constraints which were given. In the opposite +direction, we may have an overdetermined system: + +```@example init +prob = ODEProblem( + pend, [x => 1, y => 0.0, D(y) => 0], (0.0, 1.5), [g => 1], guesses = [λ => 1]) +``` + +Can that be solved? + +```@example init +sol = solve(prob, Rodas5P()) +plot(sol, idxs = (x, y)) +``` + +Indeed since we saw `D(y) = 0` at the initial point above, it turns out that this solution +is solvable with the chosen initial conditions. However, for overdetermined systems we often +aren't that lucky. If the set of initial conditions cannot be satisfied, then you will get +a `SciMLBase.ReturnCode.InitialFailure`: + +```@example init +prob = ODEProblem( + pend, [x => 1, y => 0.0, D(y) => 2.0, λ => 1], (0.0, 1.5), [g => 1], guesses = [λ => 1]) +sol = solve(prob, Rodas5P()) +``` + +What this means is that the initial condition finder failed to find an initial condition. +While this can be sometimes due to numerical error (which is then helped by picking guesses closer +to the correct value), most circumstances of this come from ill-formed models. Especially +**if your system is overdetermined and you receive an InitialFailure, the initial conditions +may not be analytically satisfiable!**. In our case here, if you sit down with a pen and paper +long enough you will see that `λ = 0` is required for this equation, but since we chose +`λ = 1` we end up with a set of equations that are impossible to satisfy. + +!!! note + + If you would prefer to have an error instead of a warning in the context of non-fully + determined systems, pass the keyword argument `fully_determined = true` into the + problem constructor. Additionally, any warning about not being fully determined can + be suppressed via passing `warn_initialize_determined = false`. + +## Constant constraints in initialization + +Consider the pendulum system again: + +```@repl init +equations(pend) +observed(pend) +``` + +Suppose we want to solve the same system with multiple different initial +y-velocities from a given position. + +```@example init +prob = ODEProblem( + pend, [x => 1, D(y) => 0], (0.0, 1.5), [g => 1], guesses = [λ => 0, y => 1, x => 1]) +sol1 = solve(prob, Rodas5P()) +``` + +```@example init +sol1[D(y), 1] +``` + +Repeatedly re-creating the `ODEProblem` with different values of `D(y)` and `x` or +repeatedly calling `remake` is slow. Instead, for any `variable => constant` constraint +in the `ODEProblem` initialization (whether provided to the `ODEProblem` constructor or +a default value) we can update the `constant` value. ModelingToolkit refers to these +values using the `Initial` operator. For example: + +```@example init +prob.ps[[Initial(x), Initial(D(y))]] +``` + +To solve with a different starting y-velocity, we can simply do + +```@example init +prob.ps[Initial(D(y))] = -0.1 +sol2 = solve(prob, Rodas5P()) +``` + +```@example init +sol2[D(y), 1] +``` + +Note that this _only_ applies for constant constraints for the current ODEProblem. +For example, `D(x)` does not have a constant constraint - it is solved for by +initialization. Thus, mutating `Initial(D(x))` does not have any effect: + +```@repl init +sol2[D(x), 1] +prob.ps[Initial(D(x))] = 1.0 +sol3 = solve(prob, Rodas5P()) +sol3[D(x), 1] +``` + +To enforce this constraint, we would have to `remake` the problem (or construct a new one). + +```@repl init +prob2 = remake(prob; u0 = [y => 0.0, D(x) => 0.0, x => nothing, D(y) => nothing]); +sol4 = solve(prob2, Rodas5P()) +sol4[D(x), 1] +``` + +Note the need to provide `x => nothing, D(y) => nothing` to override the previously +provided initial conditions. Since `remake` is a partial update, the constraints provided +to it are merged with the ones already present in the problem. Existing constraints can be +removed by providing a value of `nothing`. + +## Initialization of parameters + +Parameters may also be treated as unknowns in the initialization system. Doing so works +almost identically to the standard case. For a parameter to be an initialization unknown +(henceforth referred to as "solved parameter") it must represent a floating point number +(have a `symtype` of `Real` or `<:AbstractFloat`) or an array of such numbers. Additionally, +it must have a guess and one of the following conditions must be satisfied: + + 1. The value of the parameter as passed to `ODEProblem` is an expression involving other + variables/parameters. For example, if `[p => 2q + x]` is passed to `ODEProblem`. In + this case, `p ~ 2q + x` is used as an equation during initialization. + 2. The parameter has a default (and no value for it is given to `ODEProblem`, since + that is condition 1). The default will be used as an equation during initialization. + 3. The parameter has a default of `missing`. If `ODEProblem` is given a value for this + parameter, it is used as an equation during initialization (whether the value is an + expression or not). + 4. `ODEProblem` is given a value of `missing` for the parameter. If the parameter has a + default, it will be used as an equation during initialization. + +All parameter dependencies (where the dependent parameter is a floating point number or +array thereof) also become equations during initialization, and the dependent parameters +become unknowns. + +`remake` will reconstruct the initialization system and problem, given the new +constraints provided to it. The new values will be combined with the original +variable-value mapping provided to `ODEProblem` and used to construct the initialization +problem. + +The variable on the left hand side of all parameter dependencies also has an `Initial` +variant, which is used if a constant constraint is provided for the variable. + +### Parameter initialization by example + +Consider the following system, where the sum of two unknowns is a constant parameter +`total`. + +```@example paraminit +using ModelingToolkit, OrdinaryDiffEq # hidden +using ModelingToolkit: t_nounits as t, D_nounits as D # hidden + +@variables x(t) y(t) +@parameters total +@mtkbuild sys = ODESystem([D(x) ~ -x, total ~ x + y], t; + defaults = [total => missing], guesses = [total => 1.0]) +``` + +Given any two of `x`, `y` and `total` we can determine the remaining variable. + +```@example paraminit +prob = ODEProblem(sys, [x => 1.0, y => 2.0], (0.0, 1.0)) +integ = init(prob, Tsit5()) +@assert integ.ps[total] ≈ 3.0 # hide +integ.ps[total] +``` + +Suppose we want to re-create this problem, but now solve for `x` given `total` and `y`: + +```@example paraminit +prob2 = remake(prob; u0 = [y => 1.0], p = [total => 4.0]) +initsys = prob2.f.initializeprob.f.sys +``` + +The system is now overdetermined. In fact: + +```@example paraminit +[equations(initsys); observed(initsys)] +``` + +The system can never be satisfied and will always lead to an `InitialFailure`. This is +due to the aforementioned behavior of retaining the original variable-value mapping +provided to `ODEProblem`. To fix this, we pass `x => nothing` to `remake` to remove its +retained value. + +```@example paraminit +prob2 = remake(prob; u0 = [y => 1.0, x => nothing], p = [total => 4.0]) +initsys = prob2.f.initializeprob.f.sys +``` + +The system is fully determined, and the equations are solvable. + +```@example paraminit +[equations(initsys); observed(initsys)] +``` + +## Diving Deeper: Constructing the Initialization System + +To get a better sense of the initialization system and to help debug it, you can construct +the initialization system directly. The initialization system is a NonlinearSystem +which requires the system-level information and the additional nonlinear equations being +tagged to the system. + +```@example init +isys = generate_initializesystem(pend, u0map = [x => 1.0, y => 0.0], guesses = [λ => 1]) +``` + +We can inspect what its equations and unknown values are: + +```@example init +equations(isys) +``` + +```@example init +unknowns(isys) +``` + +Notice that all initial conditions are treated as initial equations. Additionally, for systems +with observables, those observables are too treated as initial equations. We can see the +resulting simplified system via the command: + +```@example init +isys = structural_simplify(isys; fully_determined = false) +``` + +Note `fully_determined=false` allows for the simplification to occur when the number of equations +does not match the number of unknowns, which we can use to investigate our overdetermined system: + +```@example init +isys = ModelingToolkit.generate_initializesystem( + pend, u0map = [x => 1, y => 0.0, D(y) => 2.0, λ => 1], guesses = [λ => 1]) +``` + +```@example init +isys = structural_simplify(isys; fully_determined = false) +``` + +```@example init +equations(isys) +``` + +```@example init +unknowns(isys) +``` + +```@example init +observed(isys) +``` + +After simplification we see that we have 5 equatinos to solve with 3 variables, and the +system that is given is not solvable. + +## Numerical Isolation: InitializationProblem + +To inspect the numerics of the initialization problem, we can use the `InitializationProblem` +constructor which acts just like an `ODEProblem` or `NonlinearProblem` constructor, but +creates the special initialization system for a given `sys`. This is done as follows: + +```@example init +iprob = ModelingToolkit.InitializationProblem(pend, 0.0, + [x => 1, y => 0.0, D(y) => 2.0, λ => 1], [g => 1], guesses = [λ => 1]) +``` + +We can see that because the system is overdetermined we receive a NonlinearLeastSquaresProblem, +solvable by [NonlinearSolve.jl](https://docs.sciml.ai/NonlinearSolve/stable/). Using NonlinearSolve +we can recreate the initialization solve directly: + +```@example init +using NonlinearSolve +sol = solve(iprob) +``` + +!!! note + + For more information on solving NonlinearProblems and NonlinearLeastSquaresProblems, + check out the [NonlinearSolve.jl tutorials!](https://docs.sciml.ai/NonlinearSolve/stable/tutorials/getting_started/). + +We can see that the default solver stalls + +```@example init +sol.stats +``` + +after doing many iterations, showing that it tried to compute but could not find a valid solution. +Trying other solvers: + +```@example init +sol = solve(iprob, GaussNewton()) +``` + +gives the same issue, indicating that the chosen initialization system is unsatisfiable. We can +check the residuals: + +```@example init +sol.resid +``` + +to see the problem is not equation 2 but other equations in the system. Meanwhile, changing +some of the conditions: + +```@example init +iprob = ModelingToolkit.InitializationProblem(pend, 0.0, + [x => 1, y => 0.0, D(y) => 0.0, λ => 0], [g => 1], guesses = [λ => 1]) +``` + +gives a NonlinearLeastSquaresProblem which can be solved: + +```@example init +sol = solve(iprob) +``` + +```@example init +sol.resid +``` + +In comparison, if we have a well-conditioned system: + +```@example init +iprob = ModelingToolkit.InitializationProblem(pend, 0.0, + [x => 1, y => 0.0], [g => 1], guesses = [λ => 1]) +``` + +notice that we instead obtained a NonlinearSystem. In this case we have to use +different solvers which can take advantage of the fact that the Jacobian is square. + +```@example init +sol = solve(iprob) +``` + +```@example init +sol = solve(iprob, TrustRegion()) +``` + +## More Features of the Initialization System: Steady-State and Observable Initialization + +Let's take a Lotka-Volterra system: + +```@example init +@variables x(t) y(t) z(t) +@parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + +eqs = [D(x) ~ α * x - β * x * y + D(y) ~ -γ * y + δ * x * y + z ~ x + y] + +@named sys = ODESystem(eqs, t) +simpsys = structural_simplify(sys) +tspan = (0.0, 10.0) +``` + +Using the derivative initializations, we can set the ODE to start at the steady state +by initializing the derivatives to zero: + +```@example init +prob = ODEProblem(simpsys, [D(x) => 0.0, D(y) => 0.0], tspan, guesses = [x => 1, y => 1]) +sol = solve(prob, Tsit5(), abstol = 1e-16) +``` + +Notice that this is a "numerical zero", not an exact zero, and thus the solution will leave the +steady state in this instance because it's an unstable steady state. + +Additionally, notice that in this setup we have an observable `z ~ x + y`. If we instead know the +initial condition for the observable we can use that directly: + +```@example init +prob = ODEProblem(simpsys, [D(x) => 0.0, z => 2.0], tspan, guesses = [x => 1, y => 1]) +sol = solve(prob, Tsit5()) +``` + +We can check that indeed the solution does satisfy that D(x) = 0 at the start: + +```@example init +sol[α * x - β * x * y] +``` + +```@example init +plot(sol) +``` + +## Summary of Initialization API + +```@docs; canonical=false +Initial +isinitial +generate_initializesystem +initialization_equations +guesses +defaults +``` diff --git a/docs/src/tutorials/linear_analysis.md b/docs/src/tutorials/linear_analysis.md new file mode 100644 index 0000000000..7fcb67f9fb --- /dev/null +++ b/docs/src/tutorials/linear_analysis.md @@ -0,0 +1,152 @@ +# Linear Analysis + +Linear analysis refers to the process of linearizing a nonlinear model and analysing the resulting linear dynamical system. To facilitate linear analysis, ModelingToolkit provides the concept of an [`AnalysisPoint`](@ref), which can be inserted in-between two causal blocks (such as those from `ModelingToolkitStandardLibrary.Blocks` sub module). Once a model containing analysis points is built, several operations are available: + + - [`get_sensitivity`](@ref) get the [sensitivity function (wiki)](https://en.wikipedia.org/wiki/Sensitivity_(control_systems)), $S(s)$, as defined in the field of control theory. + - [`get_comp_sensitivity`](@ref) get the complementary sensitivity function $T(s) : S(s)+T(s)=1$. + - [`get_looptransfer`](@ref) get the (open) loop-transfer function where the loop starts and ends in the analysis point. For a typical simple feedback connection with a plant $P(s)$ and a controller $C(s)$, the loop-transfer function at the plant output is $P(s)C(s)$. + - [`linearize`](@ref) can be called with two analysis points denoting the input and output of the linearized system. + - [`open_loop`](@ref) return a new (nonlinear) system where the loop has been broken in the analysis point, i.e., the connection the analysis point usually implies has been removed. + +An analysis point can be created explicitly using the constructor [`AnalysisPoint`](@ref), or automatically when connecting two causal components using `connect`: + +```julia +connect(comp1.output, :analysis_point_name, comp2.input) +``` + +A single output can also be connected to multiple inputs: + +```julia +connect(comp1.output, :analysis_point_name, comp2.input, comp3.input, comp4.input) +``` + +!!! warning "Causality" + + Analysis points are *causal*, i.e., they imply a directionality for the flow of information. The order of the connections in the connect statement is thus important, i.e., `connect(out, :name, in)` is different from `connect(in, :name, out)`. + +The directionality of an analysis point can be thought of as an arrow in a block diagram, where the name of the analysis point applies to the arrow itself. + +``` +┌─────┐ ┌─────┐ +│ │ name │ │ +│ out├────────►│in │ +│ │ │ │ +└─────┘ └─────┘ +``` + +This is signified by the name being the middle argument to `connect`. + +Of the above mentioned functions, all except for [`open_loop`](@ref) return the output of [`ModelingToolkit.linearize`](@ref), which is + +```julia +matrices, simplified_sys = linearize(...) +# matrices = (; A, B, C, D) +``` + +i.e., `matrices` is a named tuple containing the matrices of a linear state-space system on the form + +```math +\begin{aligned} +\dot x &= Ax + Bu\\ +y &= Cx + Du +\end{aligned} +``` + +## Example + +The following example builds a simple closed-loop system with a plant $P$ and a controller $C$. Two analysis points are inserted, one before and one after $P$. We then derive a number of sensitivity functions and show the corresponding code using the package ControlSystemBase.jl + +```@example LINEAR_ANALYSIS +using ModelingToolkitStandardLibrary.Blocks, ModelingToolkit +@named P = FirstOrder(k = 1, T = 1) # A first-order system with pole in -1 +@named C = Gain(-1) # A P controller +t = ModelingToolkit.get_iv(P) +eqs = [connect(P.output, :plant_output, C.input) # Connect with an automatically created analysis point called :plant_output + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :feedback_system) + +matrices_S = get_sensitivity(sys, :plant_input)[1] # Compute the matrices of a state-space representation of the (input)sensitivity function. +matrices_T = get_comp_sensitivity(sys, :plant_input)[1] +``` + +Continued linear analysis and design can be performed using ControlSystemsBase.jl. +We create `ControlSystemsBase.StateSpace` objects using + +```@example LINEAR_ANALYSIS +using ControlSystemsBase, Plots +S = ss(matrices_S...) +T = ss(matrices_T...) +bodeplot([S, T], lab = ["S" "" "T" ""], plot_title = "Bode plot of sensitivity functions", + margin = 5Plots.mm) +``` + +The sensitivity functions obtained this way should be equivalent to the ones obtained with the code below + +```@example LINEAR_ANALYSIS_CS +using ControlSystemsBase +P = tf(1.0, [1, 1]) |> ss +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +``` + +We may also derive the loop-transfer function $L(s) = P(s)C(s)$ using + +```@example LINEAR_ANALYSIS +matrices_L = get_looptransfer(sys, :plant_output)[1] +L = ss(matrices_L...) +``` + +which is equivalent to the following with ControlSystems + +```@example LINEAR_ANALYSIS_CS +L = P * (-C) # Add the minus sign to build the negative feedback into the controller +``` + +To obtain the transfer function between two analysis points, we call `linearize` + +```@example LINEAR_ANALYSIS +using ModelingToolkit # hide +matrices_PS = linearize(sys, :plant_input, :plant_output)[1] +``` + +this particular transfer function should be equivalent to the linear system `P(s)S(s)`, i.e., equivalent to + +```@example LINEAR_ANALYSIS_CS +feedback(P, C) +``` + +### Obtaining transfer functions + +A statespace system from [ControlSystemsBase](https://juliacontrol.github.io/ControlSystems.jl/stable/man/creating_systems/) can be converted to a transfer function using the function `tf`: + +```@example LINEAR_ANALYSIS_CS +tf(S) +``` + +## Gain and phase margins + +Further linear analysis can be performed using the [analysis methods from ControlSystemsBase](https://juliacontrol.github.io/ControlSystems.jl/stable/lib/analysis/). For example, calculating the gain and phase margins of a system can be done using + +```@example LINEAR_ANALYSIS_CS +margin(P) +``` + +(they are infinite for this system). A Nyquist plot can be produced using + +```@example LINEAR_ANALYSIS_CS +nyquistplot(P) +``` + +## Index + +```@index +Pages = ["linear_analysis.md"] +``` + +```@autodocs +Modules = [ModelingToolkit] +Pages = ["systems/analysis_points.jl"] +Order = [:function, :type] +Private = false +``` diff --git a/docs/src/tutorials/modelingtoolkitize.md b/docs/src/tutorials/modelingtoolkitize.md new file mode 100644 index 0000000000..545879f842 --- /dev/null +++ b/docs/src/tutorials/modelingtoolkitize.md @@ -0,0 +1,62 @@ +# Modelingtoolkitize: Automatically Translating Numerical to Symbolic Code + +## What is `modelingtoolkitize`? + +From the other tutorials you will have learned that ModelingToolkit is a symbolic library +with all kinds of goodies, such as the ability to derive analytical expressions for things +like Jacobians, determine the sparsity of a set of equations, perform index reduction, +tearing, and other transformations to improve both stability and performance. All of these +are good things, but all of these require that one has defined the problem symbolically. + +**But what happens if one wants to use ModelingToolkit functionality on code that is already +written for DifferentialEquations.jl, NonlinearSolve.jl, Optimization.jl, or beyond?** + +`modelingtoolktize` is a function in ModelingToolkit which takes a numerically-defined +`SciMLProblem` and transforms it into its symbolic ModelingToolkit equivalent. By doing +so, ModelingToolkit analysis passes and transformations can be run as intermediate steps +to improve a simulation code before it's passed to the solver. + +!!! note + + `modelingtoolkitize` does have some limitations, i.e. not all codes that work with the + numerical solvers will work with `modelingtoolkitize`. Namely, it requires the ability + to trace the equations with Symbolics.jl `Num` types. Generally, a code which is + compatible with forward-mode automatic differentiation is compatible with + `modelingtoolkitize`. + +!!! warn + + `modelingtoolkitize` expressions cannot keep control flow structures (loops), and thus + equations with long loops will be translated into large expressions, which can increase + the compile time of the equations and reduce the SIMD vectorization achieved by LLVM. + +## Example Usage: Generating an Analytical Jacobian Expression for an ODE Code + +Take, for example, the Robertson ODE +defined as an `ODEProblem` for OrdinaryDiffEq.jl: + +```@example mtkize +using OrdinaryDiffEq, ModelingToolkit +function rober(du, u, p, t) + y₁, y₂, y₃ = u + k₁, k₂, k₃ = p + du[1] = -k₁ * y₁ + k₃ * y₂ * y₃ + du[2] = k₁ * y₁ - k₂ * y₂^2 - k₃ * y₂ * y₃ + du[3] = k₂ * y₂^2 + nothing +end +prob = ODEProblem(rober, [1.0, 0.0, 0.0], (0.0, 1e5), (0.04, 3e7, 1e4)) +``` + +If we want to get a symbolic representation, we can simply call `modelingtoolkitize` +on the `prob`, which will return an `ODESystem`: + +```@example mtkize +@mtkbuild sys = modelingtoolkitize(prob) +``` + +Using this, we can symbolically build the Jacobian and then rebuild the ODEProblem: + +```@example mtkize +prob_jac = ODEProblem(sys, [], (0.0, 1e5), jac = true) +``` diff --git a/docs/src/tutorials/nonlinear.md b/docs/src/tutorials/nonlinear.md index 1fbdd2540d..057e856229 100644 --- a/docs/src/tutorials/nonlinear.md +++ b/docs/src/tutorials/nonlinear.md @@ -1,41 +1,43 @@ -# Modeling Nonlinear Systems - -In this example we will go one step deeper and showcase the direct function -generation capabilities in ModelingToolkit.jl to build nonlinear systems. -Let's say we wanted to solve for the steady state of the previous ODE. This is -the nonlinear system defined by where the derivatives are zero. We use (unknown) -variables for our nonlinear system. - -```julia -using ModelingToolkit, NonlinearSolve - -@variables x y z -@parameters σ ρ β - -# Define a nonlinear system -eqs = [0 ~ σ*(y-x), - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z], [σ,ρ,β]) - -guess = [x => 1.0, - y => 0.0, - z => 0.0] - -ps = [ - σ => 10.0 - ρ => 26.0 - β => 8/3 - ] - -prob = NonlinearProblem(ns,guess,ps) -sol = solve(prob,NewtonRaphson()) -``` - -We can similarly ask to generate the `NonlinearProblem` with the analytical -Jacobian function: - -```julia -prob = NonlinearProblem(ns,guess,ps,jac=true) -sol = solve(prob,NewtonRaphson()) -``` +# Modeling Nonlinear Systems + +ModelingToolkit.jl is not only useful for generating initial value problems (`ODEProblem`). +The package can also build nonlinear systems. +This is, for example, useful for finding the steady state of an ODE. +This steady state is reached when the nonlinear system of differential equations equals zero. + +!!! note + + The high level `@mtkmodel` macro used in the + [getting started tutorial](@ref getting_started) + is not yet compatible with `NonlinearSystem`. + We thus have to use a lower level interface to define nonlinear systems. + For an introduction to this interface, read the + [programmatically generating ODESystems tutorial](@ref programmatically). + +```@example nonlinear +using ModelingToolkit, NonlinearSolve + +# Define a nonlinear system +@variables x y z +@parameters σ ρ β +eqs = [0 ~ σ * (y - x) + 0 ~ x * (ρ - z) - y + 0 ~ x * y - β * z] +@mtkbuild ns = NonlinearSystem(eqs) + +guesses = [x => 1.0, y => 0.0, z => 0.0] +ps = [σ => 10.0, ρ => 26.0, β => 8 / 3] + +prob = NonlinearProblem(ns, guesses, ps) +sol = solve(prob, NewtonRaphson()) +``` + +We found the `x`, `y` and `z` for which the right hand sides of `eqs` are all equal to zero. + +Just like with `ODEProblem`s we can generate the `NonlinearProblem` with its analytical +Jacobian function: + +```@example nonlinear +prob = NonlinearProblem(ns, guesses, ps, jac = true) +sol = solve(prob, NewtonRaphson()) +``` diff --git a/docs/src/tutorials/nonlinear_optimal_control.md b/docs/src/tutorials/nonlinear_optimal_control.md deleted file mode 100644 index c4a5bed7df..0000000000 --- a/docs/src/tutorials/nonlinear_optimal_control.md +++ /dev/null @@ -1,95 +0,0 @@ -# Nonlinear Optimal Control - -#### Note: this is still a work in progress! - -The `ControlSystem` type is an interesting system because, unlike other -system types, it cannot be numerically solved on its own. Instead, it must be -transformed into another system before solving. Standard methods such as the -"direct method", "multiple shooting", or "discretize-then-optimize" can all be -phrased as symbolic transformations to a `ControlSystem`: this is the strategy -of this methodology. - -## Defining a Nonlinear Optimal Control Problem - -Here we will start by defining a classic optimal control problem. Let: - -```math -x^{′′} = u^3(t) -``` - -where we want to optimize our controller `u(t)` such that the following is -minimized: - -```math -L(\theta) = \sum_i \Vert 4 - x(t_i) \Vert + 2 \Vert x^\prime(t_i) \Vert + \Vert u(t_i) \Vert -``` - -where ``i`` is measured on (0,8) at 0.01 intervals. To do this, we rewrite the -ODE in first order form: - -```math -\begin{aligned} -x^\prime &= v \\ -v^′ &= u^3(t) \\ -\end{aligned} -``` - -and thus - -```math -L(\theta) = \sum_i \Vert 4 - x(t_i) \Vert + 2 \Vert v(t_i) \Vert + \Vert u(t_i) \Vert -``` - -is our loss function on the first order system. - -Defining such a control system is similar to an `ODESystem`, except we must also -specify a control variable `u(t)` and a loss function. Together, this problem -looks as follows: - -```julia -using ModelingToolkit - -@variables t x(t) v(t) u(t) -@parameters p[1:2] -D = Differential(t) - -loss = (4-x)^2 + 2v^2 + u^2 -eqs = [ - D(x) ~ v - p[2]*x - D(v) ~ p[1]*u^3 + v -] - -sys = ControlSystem(loss,eqs,t,[x,v],[u],p) -``` - -## Solving a Control Problem via Discretize-Then-Optimize - -One common way to solve nonlinear optimal control problems is by transforming -them into an optimization problem by performing a Runge-Kutta discretization -of the differential equation system and imposing equalities between variables -in the same steps. This can be done via the `runge_kutta_discretize` transformation -on the `ControlSystem`. While a tableau `tab` can be specified, it defaults to -a 5th order RadauIIA collocation, which is a common method in the field. To -perform this discretization, we simply need to give a `dt` and a timespan on which -to discretize: - -```julia -dt = 0.1 -tspan = (0.0,1.0) -sys = runge_kutta_discretize(sys,dt,tspan) -``` - -Now `sys` is an `OptimizationSystem` which, when solved, gives the values of -`x(t)`, `v(t)`, and `u(t)`. Thus we solve the `OptimizationSystem` using -GalacticOptim.jl: - -```julia -u0 = rand(length(states(sys))) # guess for the state values -prob = OptimizationProblem(sys,u0,[0.1,0.1],grad=true) - -using GalacticOptim, Optim -sol = solve(prob,BFGS()) -``` - -And this is missing some nice interfaces and ignores the equality constraints -right now so the tutorial is not complete. diff --git a/docs/src/tutorials/ode_modeling.md b/docs/src/tutorials/ode_modeling.md index 4bdbe15920..0a4cd80803 100644 --- a/docs/src/tutorials/ode_modeling.md +++ b/docs/src/tutorials/ode_modeling.md @@ -1,360 +1,389 @@ -# Composing Ordinary Differential Equations - -This is an introductory example for the usage of ModelingToolkit (MTK). -It illustrates the basic user-facing functionality by means of some -examples of Ordinary Differential Equations (ODE). Some references to -more specific documentation are given at appropriate places. - -## Copy-Pastable Simplified Example - -A much deeper tutorial with forcing functions and sparse Jacobians is all below. -But if you want to just see some code and run, here's an example: - -```julia -using ModelingToolkit - -@variables t x(t) RHS(t) # independent and dependent variables -@parameters τ # parameters -D = Differential(t) # define an operator for the differentiation w.r.t. time - -# your first ODE, consisting of a single equation, indicated by ~ -@named fol_separate = ODESystem([ RHS ~ (1 - x)/τ, - D(x) ~ RHS ]) - -using DifferentialEquations: solve -using Plots: plot - -prob = ODEProblem(structural_simplify(fol_separate), [x => 0.0], (0.0,10.0), [τ => 3.0]) -sol = solve(prob) -plot(sol, vars=[x,RHS]) -``` - -![Simulation result of first-order lag element, with right-hand side](https://user-images.githubusercontent.com/13935112/111958403-7e8d3e00-8aed-11eb-9d18-08b5180a59f9.png) - -Now let's start digging into MTK! - -## Your very first ODE - -Let us start with a minimal example. The system to be modelled is a - -first-order lag element: - -```math -\dot{x} = \frac{f(t) - x(t)}{\tau} -``` - -Here, ``t`` is the independent variable (time), ``x(t)`` is the (scalar) state -variable, ``f(t)`` is an external forcing function, and ``\tau`` is a constant -parameter. In MTK, this system can be modelled as follows. For simplicity, we -first set the forcing function to a constant value. - -```julia -using ModelingToolkit - -@variables t x(t) # independent and dependent variables -@parameters τ # parameters -D = Differential(t) # define an operator for the differentiation w.r.t. time - -# your first ODE, consisting of a single equation, indicated by ~ -@named fol_model = ODESystem(D(x) ~ (1 - x)/τ) - # Model fol_model with 1 equations - # States (1): - # x(t) - # Parameters (1): - # τ -``` - -Note that equations in MTK use the tilde character (`~`) as equality sign. -Also note that the `@named` macro simply ensures that the symbolic name -matches the name in the REPL. If omitted, you can directly set the `name` keyword. - -After construction of the ODE, you can solve it using [DifferentialEquations.jl](https://diffeq.sciml.ai/): - -```julia -using DifferentialEquations: solve -using Plots: plot - -prob = ODEProblem(fol_model, [x => 0.0], (0.0,10.0), [τ => 3.0]) -solve(prob) |> plot -``` - -![Simulation result of first-order lag element](https://user-images.githubusercontent.com/13935112/111958369-703f2200-8aed-11eb-8bb4-0abe9652e850.png) - -The initial state and the parameter values are specified using a mapping -from the actual symbolic elements to their values, represented as an array -of `Pair`s, which are constructed using the `=>` operator. - -## Algebraic relations and structural simplification - -You could separate the calculation of the right-hand side, by introducing an -intermediate variable `RHS`: - -```julia -@variables RHS(t) -@named fol_separate = ODESystem([ RHS ~ (1 - x)/τ, - D(x) ~ RHS ]) - # Model fol_separate with 2 equations - # States (2): - # x(t) - # RHS(t) - # Parameters (1): - # τ -``` - -To directly solve this system, you would have to create a Differential-Algebraic -Equation (DAE) problem, since besides the differential equation, there is an -additional algebraic equation now. However, this DAE system can obviously be -transformed into the single ODE we used in the first example above. MTK achieves -this by means of structural simplification: - -```julia -fol_simplified = structural_simplify(fol_separate) - -equations(fol_simplified) - # 1-element Array{Equation,1}: - # Differential(t)(x(t)) ~ (τ^-1)*(1 - x(t)) - -equations(fol_simplified) == equations(fol_model) - # true -``` - -You can extract the equations from a system using `equations` (and, in the same -way, `variables` and `parameters`). The simplified equation is exactly the same -as the original one, so the simulation performence will also be the same. -However, there is one difference. MTK does keep track of the eliminated -algebraic variables as "observables" (see -[Observables and Variable Elimination](@ref)). -That means, MTK still knows how to calculate them out of the information available -in a simulation result. The intermediate variable `RHS` therefore can be plotted -along with the state variable. Note that this has to be requested explicitly, -though: - -```julia -prob = ODEProblem(fol_simplified, [x => 0.0], (0.0,10.0), [τ => 3.0]) -sol = solve(prob) -plot(sol, vars=[x, RHS]) -``` - -![Simulation result of first-order lag element, with right-hand side](https://user-images.githubusercontent.com/13935112/111958403-7e8d3e00-8aed-11eb-9d18-08b5180a59f9.png) - -Note that similarly the indexing of the solution works via the names, and so -`sol[x]` gives the timeseries for `x`, `sol[x,2:10]` gives the 2nd through 10th -values of `x` matching `sol.t`, etc. Note that this works even for variables -which have been eliminated, and thus `sol[RHS]` retrieves the values of `RHS`. - -## Specifying a time-variable forcing function - -What if the forcing function (the "external input") ``f(t)`` is not constant? -Obviously, one could use an explicit, symbolic function of time: - -```julia -@variables f(t) -@named fol_variable_f = ODESystem([f ~ sin(t), D(x) ~ (f - x)/τ]) -``` - -But often there is time-series data, such as measurement data from an experiment, -we want to embed as data in the simulation of a PDE, or as a forcing function on -the right-hand side of an ODE -- is it is the case here. For this, MTK allows to -"register" arbitrary Julia functions, which are excluded from symbolic -transformations but are just used as-is. So, you could, for example, interpolate -a given time series using -[DataInterpolations.jl](https://github.com/PumasAI/DataInterpolations.jl). Here, -we illustrate this option by a simple lookup ("zero-order hold") of a vector -of random values: - -```julia -value_vector = randn(10) -f_fun(t) = t >= 10 ? value_vector[end] : value_vector[Int(floor(t))+1] -@register f_fun(t) - -@named fol_external_f = ODESystem([f ~ f_fun(t), D(x) ~ (f - x)/τ]) -prob = ODEProblem(structural_simplify(fol_external_f), [x => 0.0], (0.0,10.0), [τ => 0.75]) - -sol = solve(prob) -plot(sol, vars=[x,f]) -``` - -![Simulation result of first-order lag element, step-wise forcing function](https://user-images.githubusercontent.com/13935112/111958424-83ea8880-8aed-11eb-8f42-489f4b44c3bc.png) - -## Building component-based, hierarchical models - -Working with simple one-equation systems is already fun, but composing more -complex systems from simple ones is even more fun. Best practice for such a -"modeling framework" could be to use factory functions for model components: - -```julia -function fol_factory(separate=false;name) - @parameters τ - @variables t x(t) f(t) RHS(t) - - eqs = separate ? [RHS ~ (f - x)/τ, - D(x) ~ RHS] : - D(x) ~(f - x)/τ - - ODESystem(eqs;name) -end -``` - -Such a factory can then used to instantiate the same component multiple times, -but allows for customization: - -```julia -@named fol_1 = fol_factory() -@named fol_2 = fol_factory(true) # has observable RHS -``` - -Now, these two components can be used as subsystems of a parent system, i.e. -one level higher in the model hierarchy. The connections between the components -again are just algebraic relations: - -```julia -connections = [ fol_1.f ~ 1.5, - fol_2.f ~ fol_1.x ] - -@named connected = ODESystem(connections; systems=[fol_1,fol_2]) - # Model connected with 5 equations - # States (5): - # fol_1₊f(t) - # fol_2₊f(t) - # fol_1₊x(t) - # fol_2₊x(t) - # ⋮ - # Parameters (2): - # fol_1₊τ - # fol_2₊τ -``` - -All equations, variables and parameters are collected, but the structure of the -hierarchical model is still preserved. That is, you can still get information about -`fol_1` by addressing it by `connected.fol_1`, or its parameter by -`connected.fol_1.τ`. Before simulation, we again eliminate the algebraic -variables and connection equations from the system using structural -simplification: - -```julia -connected_simp = structural_simplify(connected) - # Model connected with 2 equations - # States (2): - # fol_1₊x(t) - # fol_2₊x(t) - # Parameters (2): - # fol_1₊τ - # fol_2₊τ - # Incidence matrix: - # [1, 1] = × - # [2, 1] = × - # [2, 2] = × - # [1, 3] = × - # [2, 4] = × - -equations(connected_simp) - # 2-element Array{Equation,1}: - # Differential(t)(fol_1₊x(t)) ~ (fol_1₊τ^-1)*(1.5 - fol_1₊x(t)) - # Differential(t)(fol_2₊x(t)) ~ (fol_2₊τ^-1)*(fol_1₊x(t) - fol_2₊x(t)) -``` - -As expected, only the two state-derivative equations remain, -as if you had manually eliminated as many variables as possible from the equations. -As mentioned above, the hierarchical structure is preserved though. So the -initial state and the parameter values can be specified accordingly when -building the `ODEProblem`: - -```julia -u0 = [ fol_1.x => -0.5, - fol_2.x => 1.0 ] - -p = [ fol_1.τ => 2.0, - fol_2.τ => 4.0 ] - -prob = ODEProblem(connected_simp, u0, (0.0,10.0), p) -solve(prob) |> plot -``` - -![Simulation of connected system (two first-order lag elements in series)](https://user-images.githubusercontent.com/13935112/111958439-877e0f80-8aed-11eb-9074-9d35458459a4.png) - -More on this topic may be found in [Composing Models and Building Reusable Components](@ref). - -## Defaults - -Often it is a good idea to specify reasonable values for the initial state and the -parameters of a model component. Then, these do not have to be explicitly specified when constructing the `ODEProblem`. - -```julia -function unitstep_fol_factory(;name) - @parameters τ - @variables t x(t) - ODESystem(D(x) ~ (1 - x)/τ; name, defaults=Dict(x=>0.0, τ=>1.0)) -end - -ODEProblem(unitstep_fol_factory(name=:fol),[],(0.0,5.0),[]) |> solve -``` - -Note that the defaults can be functions of the other variables, which is then -resolved at the time of the problem construction. Of course, the factory -function could accept additional arguments to optionally specify the initial -state or parameter values, etc. - -## Symbolic and sparse derivatives - -One advantage of a symbolic toolkit is that derivatives can be calculated -explicitly, and that the incidence matrix of partial derivatives (the -"sparsity pattern") can also be explicitly derived. These two facts lead to a -substantial speedup of all model calculations, e.g. when simulating a model -over time using an ODE solver. - -By default, analytical derivatives and sparse matrices, e.g. for the Jacobian, the -matrix of first partial derivatives, are not used. Let's benchmark this (`prob` -still is the problem using the `connected_simp` system above): - -```julia -using BenchmarkTools -using DifferentialEquations: Rodas4 - -@btime solve(prob, Rodas4()); - # 251.300 μs (873 allocations: 31.18 KiB) -``` - -Now have MTK provide sparse, analytical derivatives to the solver. This has to -be specified during the construction of the `ODEProblem`: - -```julia -prob_an = ODEProblem(connected_simp, u0, (0.0,10.0), p; jac=true, sparse=true) - -@btime solve(prob_an, Rodas4()); - # 142.899 μs (1297 allocations: 83.96 KiB) -``` - -The speedup is significant. For this small dense model (3 of 4 entries are -populated), using sparse matrices is counterproductive in terms of required -memory allocations. For large, hierarchically built models, which tend to be -sparse, speedup and the reduction of memory allocation can be expected to be -substantial. In addition, these problem builders allow for automatic parallelism -using the structural information. For more information, see the -[ODESystem](@ref ODESystem) page. - -## Notes and pointers how to go on - -Here are some notes that may be helpful during your initial steps with MTK: - -* Sometimes, the symbolic engine within MTK is not able to correctly identify the - independent variable (e.g. time) out of all variables. In such a case, you - usually get an error that some variable(s) is "missing from variable map". In - most cases, it is then sufficient to specify the independent variable as second - argument to `ODESystem`, e.g. `ODESystem(eqs, t)`. -* A completely macro-free usage of MTK is possible and is discussed in a - separate tutorial. This is for package developers, since the macros are only - essential for automatic symbolic naming for modelers. -* Vector-valued parameters and variables are possible. A cleaner, more - consistent treatment of these is work in progress, though. Once finished, - this introductory tutorial will also cover this feature. - -Where to go next? - -* Not sure how MTK relates to similar tools and packages? Read - [Comparison of ModelingToolkit vs Equation-Based Modeling Languages](@ref). -* Depending on what you want to do with MTK, have a look at some of the other - **Symbolic Modeling Tutorials**. -* If you want to automatically convert an existing function to a symbolic - representation, you might go through the **ModelingToolkitize Tutorials**. -* To learn more about the inner workings of MTK, consider the sections under - **Basics** and **System Types**. +# [Getting Started with ModelingToolkit.jl](@id getting_started) + +This is an introductory tutorial for ModelingToolkit (MTK). We will demonstrate +the basics of the package by demonstrating how to define and simulate simple +Ordinary Differential Equation (ODE) systems. + +## Installing ModelingToolkit + +To install ModelingToolkit, use the Julia package manager. This can be done as follows: + +```julia +using Pkg +Pkg.add("ModelingToolkit") +``` + +## Copy-Pastable Simplified Example + +A much deeper tutorial with forcing functions and sparse Jacobians is below. +But if you want to just see some code and run it, here's an example: + +```@example first-mtkmodel +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel FOL begin + @parameters begin + τ = 3.0 # parameters + end + @variables begin + x(t) = 0.0 # dependent variables + end + @equations begin + D(x) ~ (1 - x) / τ + end +end + +using OrdinaryDiffEq +@mtkbuild fol = FOL() +prob = ODEProblem(fol, [], (0.0, 10.0), []) +sol = solve(prob) + +using Plots +plot(sol) +``` + +Now let's start digging into MTK! + +## Your very first ODE + +Let us start with a minimal example. The system to be modelled is a +first-order lag element: + +```math +\dot{x} = \frac{f(t) - x(t)}{\tau} +``` + +Here, ``t`` is the independent variable (time), ``x(t)`` is the (scalar) unknown +variable, ``f(t)`` is an external forcing function, and ``\tau`` is a +parameter. +In MTK, this system can be modelled as follows. For simplicity, we +first set the forcing function to a time-independent value ``1``. And the +independent variable ``t`` is automatically added by `@mtkmodel`. + +```@example ode2 +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel FOL begin + @parameters begin + τ = 3.0 # parameters and their values + end + @variables begin + x(t) = 0.0 # dependent variables and their initial conditions + end + @equations begin + D(x) ~ (1 - x) / τ + end +end + +@mtkbuild fol = FOL() +``` + +Note that equations in MTK use the tilde character (`~`) as equality sign. + +`@mtkbuild` creates an instance of `FOL` named as `fol`. + +After construction of the ODE, you can solve it using [OrdinaryDiffEq.jl](https://docs.sciml.ai/DiffEqDocs/stable/): + +```@example ode2 +using OrdinaryDiffEq +using Plots + +prob = ODEProblem(fol, [], (0.0, 10.0), []) +plot(solve(prob)) +``` + +The parameter values are determined using the right hand side of the expressions in the `@parameters` block, +and similarly initial conditions are determined using the right hand side of the expressions in the `@variables` block. + +## Using different values for parameters and initial conditions + +If you want to simulate the same model, +but with different values for the parameters and initial conditions than the default values, +you likely do not want to write an entirely new `@mtkmodel`. +ModelingToolkit supports overwriting the default values: + +```@example ode2 +@mtkbuild fol_different_values = FOL(; τ = 1 / 3, x = 0.5) +prob = ODEProblem(fol_different_values, [], (0.0, 10.0), []) +plot(solve(prob)) +``` + +Alternatively, this overwriting could also have occurred at the `ODEProblem` level. + +```@example ode2 +prob = ODEProblem(fol, [fol.τ => 1 / 3], (0.0, 10.0), [fol.x => 0.5]) +plot(solve(prob)) +``` + +Here, the second argument of `ODEProblem` is an array of `Pairs`. +The left hand side of each Pair is the parameter you want to overwrite, +and the right hand side is the value to overwrite it with. +Similarly, the initial conditions are overwritten in the fourth argument. +One important difference with the previous method is +that the parameter has to be referred to as `fol.τ` instead of just `τ`. + +## Algebraic relations and structural simplification + +You could separate the calculation of the right-hand side, by introducing an +intermediate variable `RHS`: + +```@example ode2 +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel FOL begin + @parameters begin + τ = 3.0 # parameters and their values + end + @variables begin + x(t) = 0.0 # dependent variables and their initial conditions + RHS(t) + end + @equations begin + RHS ~ (1 - x) / τ + D(x) ~ RHS + end +end + +@mtkbuild fol = FOL() +``` + +If you copy this block of code to your REPL, you will not see the above LaTeX equations. +Instead, you can look at the equations by using the `equations` function: + +```@example ode2 +equations(fol) +``` + +Notice that there is only one equation in this system, `Differential(t)(x(t)) ~ RHS(t)`. +The other equation was removed from the system and was transformed into an `observed` +variable. Observed equations are variables that can be computed on-demand but are not +necessary for the solution of the system, and thus MTK tracks them separately. +For this reason, we also did not need to specify an initial condition for `RHS`. +You can check the observed equations via the `observed` function: + +```@example ode2 +observed(fol) +``` + +For more information on this process, see [Observables and Variable Elimination](@ref). + +MTK still knows how to calculate them out of the information available +in a simulation result. The intermediate variable `RHS` therefore can be plotted +along with the unknown variable. Note that this has to be requested explicitly: + +```@example ode2 +prob = ODEProblem(fol, [], (0.0, 10.0), []) +sol = solve(prob) +plot(sol, idxs = [fol.x, fol.RHS]) +``` + +## Named Indexing of Solutions + +Note that the indexing of the solution also works via the symbol, and so to get +the time series for `x`, you would do: + +```@example ode2 +sol[fol.x] +``` + +or to get the second value in the time series for `x`: + +```@example ode2 +sol[fol.x, 2] +``` + +Similarly, the time series for `RHS` can be retrieved using the same symbolic indexing: + +```@example ode2 +sol[fol.RHS] +``` + +## Specifying a time-variable forcing function + +What if the forcing function (the “external input”) ``f(t)`` is not constant? +Obviously, one could use an explicit, symbolic function of time: + +```@example ode2 +@mtkmodel FOL begin + @parameters begin + τ = 3.0 # parameters and their values + end + @variables begin + x(t) = 0.0 # dependent variables and their initial conditions + f(t) + end + @equations begin + f ~ sin(t) + D(x) ~ (f - x) / τ + end +end + +@mtkbuild fol_variable_f = FOL() +``` + +However, this function might not be available in an explicit form. +Instead, the function might be provided as time-series data. +MTK handles this situation by allowing us to “register” arbitrary Julia functions, +which are excluded from symbolic transformations and thus used as-is. +For example, you could interpolate given the time-series using +[DataInterpolations.jl](https://github.com/SciML/DataInterpolations.jl). Here, +we illustrate this option with a simple lookup ("zero-order hold") of a vector +of random values: + +```@example ode2 +value_vector = randn(10) +f_fun(t) = t >= 10 ? value_vector[end] : value_vector[Int(floor(t)) + 1] +@register_symbolic f_fun(t) + +@mtkmodel FOLExternalFunction begin + @parameters begin + τ = 0.75 # parameters and their values + end + @variables begin + x(t) = 0.0 # dependent variables and their initial conditions + f(t) + end + @equations begin + f ~ f_fun(t) + D(x) ~ (f - x) / τ + end +end + +@mtkbuild fol_external_f = FOLExternalFunction() +``` + +```@example ode2 +prob = ODEProblem(fol_external_f, [], (0.0, 10.0), []) +sol = solve(prob) +plot(sol, idxs = [fol_external_f.x, fol_external_f.f]) +``` + +## Building component-based, hierarchical models + +Working with simple one-equation systems is already fun, but composing more +complex systems from simple ones is even more fun. The best practice for such a +“modeling framework” is to use the `@components` block in the `@mtkmodel` macro: + +```@example ode2 +@mtkmodel FOLUnconnectedFunction begin + @parameters begin + τ # parameters + end + @variables begin + x(t) # dependent variables + f(t) + RHS(t) + end + @equations begin + RHS ~ f + D(x) ~ (RHS - x) / τ + end +end +@mtkmodel FOLConnected begin + @components begin + fol_1 = FOLUnconnectedFunction(; τ = 2.0, x = -0.5) + fol_2 = FOLUnconnectedFunction(; τ = 4.0, x = 1.0) + end + @equations begin + fol_1.f ~ 1.5 + fol_2.f ~ fol_1.x + end +end +@mtkbuild connected = FOLConnected() +``` + +Here the total model consists of two of the same submodels (components), +but with a different input function, parameter values and initial conditions. +The first model has a constant input, and the second model uses the state `x` of the first system as an input. +To avoid having to type the same differential equation multiple times, +we define the submodel in a separate `@mtkmodel`. +We then reuse this submodel twice in the total model `@components` block. +The inputs of two submodels then still have to be specified in the `@equations` block. + +All equations, variables, and parameters are collected, but the structure of the +hierarchical model is still preserved. This means you can still get information about +`fol_1` by addressing it by `connected.fol_1`, or its parameter by +`connected.fol_1.τ`. + +As expected, only the two equations with the derivatives of unknowns remain, +as if you had manually eliminated as many variables as possible from the equations. +Some observed variables are not expanded unless `full_equations` is used. +As mentioned above, the hierarchical structure is preserved. So, the +initial unknown and the parameter values can be specified accordingly when +building the `ODEProblem`: + +```@example ode2 +prob = ODEProblem(connected, [], (0.0, 10.0), []) +plot(solve(prob)) +``` + +More on this topic may be found in [Composing Models and Building Reusable Components](@ref acausal). + +## Symbolic and sparse derivatives + +One advantage of a symbolic toolkit is that derivatives can be calculated +explicitly, and that the incidence matrix of partial derivatives (the +“sparsity pattern”) can also be explicitly derived. These two facts lead to a +substantial speedup of all model calculations, e.g. when simulating a model +over time using an ODE solver. + +By default, analytical derivatives and sparse matrices, e.g. for the Jacobian, the +matrix of first partial derivatives, are not used. Let's benchmark this (`prob` +still is the problem using the `connected` system above): + +```@example ode2 +using BenchmarkTools +@btime solve(prob, Rodas4()); +nothing # hide +``` + +Now have MTK provide sparse, analytical derivatives to the solver. This has to +be specified during the construction of the `ODEProblem`: + +```@example ode2 +prob_an = ODEProblem(connected, [], (0.0, 10.0), []; jac = true) +@btime solve(prob_an, Rodas4()); +nothing # hide +``` + +```@example ode2 +prob_sparse = ODEProblem(connected, [], (0.0, 10.0), []; jac = true, sparse = true) +@btime solve(prob_sparse, Rodas4()); +nothing # hide +``` + +The speedup using the analytical Jacobian is significant. +For this small dense model (3 of 4 entries populated), +using sparse matrices is counterproductive in terms of required +memory allocations. For large, hierarchically built models, which tend to be +sparse, speedup and the reduction of memory allocation can also be expected to be +substantial. In addition, these problem builders allow for automatic parallelism by +exploiting the structural information. For more information, see the +[ODESystem](@ref ODESystem) page. + +## Notes and pointers how to go on + +Here are some notes that may be helpful during your initial steps with MTK: + + - The `@mtkmodel` macro is for high-level usage of MTK. However, in many cases you + may need to programmatically generate `ODESystem`s. If that's the case, check out + the [Programmatically Generating and Scripting ODESystems Tutorial](@ref programmatically). + - Vector-valued parameters and variables are possible. A cleaner, more + consistent treatment of these is still a work in progress, however. Once finished, + this introductory tutorial will also cover this feature. + +Where to go next? + + - Not sure how MTK relates to similar tools and packages? Read + [Comparison of ModelingToolkit vs Equation-Based and Block Modeling Languages](@ref). + - For a more detailed explanation of `@mtkmodel` checkout + [Defining components with `@mtkmodel` and connectors with `@connectors`](@ref mtk_language) + - Depending on what you want to do with MTK, have a look at some of the other + **Symbolic Modeling Tutorials**. + - If you want to automatically convert an existing function to a symbolic + representation, you might go through the **ModelingToolkitize Tutorials**. + - To learn more about the inner workings of MTK, consider the sections under + **Basics** and **System Types**. diff --git a/docs/src/tutorials/optimization.md b/docs/src/tutorials/optimization.md index 96f2895644..20f1079dcd 100644 --- a/docs/src/tutorials/optimization.md +++ b/docs/src/tutorials/optimization.md @@ -1,26 +1,116 @@ # Modeling Optimization Problems -```julia -using ModelingToolkit, GalacticOptim +ModelingToolkit.jl is not only useful for generating initial value problems (`ODEProblem`). +The package can also build optimization systems. -@variables x y -@parameters a b -loss = (a - x)^2 + b * (y - x^2)^2 -sys = OptimizationSystem(loss,[x,y],[a,b]) +!!! note + + The high level `@mtkmodel` macro used in the + [getting started tutorial](@ref getting_started) + is not yet compatible with `OptimizationSystem`. + We thus have to use a lower level interface to define optimization systems. + For an introduction to this interface, read the + [programmatically generating ODESystems tutorial](@ref programmatically). -u0 = [ - x=>1.0 - y=>2.0 -] -p = [ - a => 6.0 - b => 7.0 +## Unconstrained Rosenbrock Function + +Let's optimize the classical _Rosenbrock function_ in two dimensions. + +```@example optimization +using ModelingToolkit, Optimization, OptimizationOptimJL +@variables begin + x = 1.0, [bounds = (-2.0, 2.0)] + y = 3.0, [bounds = (-1.0, 3.0)] +end +@parameters a=1.0 b=1.0 +rosenbrock = (a - x)^2 + b * (y - x^2)^2 +@mtkbuild sys = OptimizationSystem(rosenbrock, [x, y], [a, b]) +``` + +Every optimization problem consists of a set of optimization variables. +In this case, we create two variables: `x` and `y`, +with initial guesses `1` and `3` for their optimal values. +Additionally, we assign box constraints for each of them, using `bounds`, +Bounds is an example of symbolic metadata. +Fore more information, take a look at the symbolic metadata +[documentation page](@ref symbolic_metadata). + +We also create two parameters with `@parameters`. +Parameters are useful if you want to solve the same optimization problem multiple times, +with different values for these parameters. +Default values for these parameters can also be assigned, here `1` is used for both `a` and `b`. +These optimization values and parameters are used in an objective function, here the Rosenbrock function. + +Next, the actual `OptimizationProblem` can be created. +The initial guesses for the optimization variables can be overwritten, via an array of `Pairs`, +in the second argument of `OptimizationProblem`. +Values for the parameters of the system can also be overwritten from their default values, +in the third argument of `OptimizationProblem`. +ModelingToolkit is also capable of constructing analytical gradients and Hessians of the objective function. + +```@example optimization +u0 = [y => 2.0] +p = [b => 100.0] + +prob = OptimizationProblem(sys, u0, p, grad = true, hess = true) +u_opt = solve(prob, GradientDescent()) +``` + +A visualization of the Rosenbrock function is depicted below. + +```@example optimization +using Plots +x_plot = -2:0.01:2 +y_plot = -1:0.01:3 +contour( + x_plot, y_plot, (x, y) -> (1 - x)^2 + 100 * (y - x^2)^2, fill = true, color = :viridis, + ratio = :equal, xlims = (-2, 2)) +scatter!([u_opt[1]], [u_opt[2]], ms = 10, label = "minimum") +``` + +## Rosenbrock Function with Constraints + +ModelingToolkit is also capable of handing more complicated constraints than box constraints. +Non-linear equality and inequality constraints can be added to the `OptimizationSystem`. +Let's add an inequality constraint to the previous example: + +```@example optimization_constrained +using ModelingToolkit, Optimization, OptimizationOptimJL + +@variables begin + x = 0.14, [bounds = (-2.0, 2.0)] + y = 0.14, [bounds = (-1.0, 3.0)] +end +@parameters a=1.0 b=100.0 +rosenbrock = (a - x)^2 + b * (y - x^2)^2 +cons = [ + x^2 + y^2 ≲ 1 ] +@mtkbuild sys = OptimizationSystem(rosenbrock, [x, y], [a, b], constraints = cons) +prob = OptimizationProblem(sys, [], grad = true, hess = true, cons_j = true, cons_h = true) +u_opt = solve(prob, IPNewton()) +``` + +Inequality constraints are constructed via a `≲` (or `≳`). +[(To write these symbols in your own code write `\lesssim` or `\gtrsim` and then press tab.)] +(https://docs.julialang.org/en/v1/manual/unicode-input/) +An equality constraint can be specified via a `~`, e.g., `x^2 + y^2 ~ 1`. -prob = OptimizationProblem(sys,u0,p,grad=true,hess=true) -solve(prob,Newton()) +A visualization of the Rosenbrock function and the inequality constraint is depicted below. + +```@example optimization_constrained +using Plots +x_plot = -2:0.01:2 +y_plot = -1:0.01:3 +contour( + x_plot, y_plot, (x, y) -> (1 - x)^2 + 100 * (y - x^2)^2, fill = true, color = :viridis, + ratio = :equal, xlims = (-2, 2)) +contour!(x_plot, y_plot, (x, y) -> x^2 + y^2, levels = [1], color = :lightblue, line = 4) +scatter!([u_opt[1]], [u_opt[2]], ms = 10, label = "minimum") ``` -Needs more text but it's super cool and auto-parallelizes and sparsifies too. -Plus you can hierarchically nest systems to have it generate huge +## Nested Systems + +Needs more text, but it's super cool and auto-parallelizes and sparsifies too. +Plus, you can hierarchically nest systems to have it generate huge optimization problems. diff --git a/docs/src/tutorials/parameter_identifiability.md b/docs/src/tutorials/parameter_identifiability.md new file mode 100644 index 0000000000..0875a7698c --- /dev/null +++ b/docs/src/tutorials/parameter_identifiability.md @@ -0,0 +1,195 @@ +# Parameter Identifiability in ODE Models + +Ordinary differential equations are commonly used for modeling real-world processes. The problem of parameter identifiability is one of the key design challenges for mathematical models. A parameter is said to be _identifiable_ if one can recover its value from experimental data. _Structural_ identifiability is a theoretical property of a model that answers this question. In this tutorial, we will show how to use `StructuralIdentifiability.jl` with `ModelingToolkit.jl` to assess identifiability of parameters in ODE models. The theory behind `StructuralIdentifiability.jl` is presented in paper [^4]. + +We will start by illustrating **local identifiability** in which a parameter is known up to _finitely many values_, and then proceed to determining **global identifiability**, that is, which parameters can be identified _uniquely_. + +The package has a standalone data structure for ordinary differential equations, but is also compatible with `ODESystem` type from `ModelingToolkit.jl`. + +## Local Identifiability + +### Input System + +We will consider the following model: + +$$\begin{cases} +\frac{d\,x_4}{d\,t} = - \frac{k_5 x_4}{k_6 + x_4},\\ +\frac{d\,x_5}{d\,t} = \frac{k_5 x_4}{k_6 + x_4} - \frac{k_7 x_5}{(k_8 + x_5 + x_6)},\\ +\frac{d\,x_6}{d\,t} = \frac{k_7 x_5}{(k_8 + x_5 + x_6)} - \frac{k_9 x_6 (k_{10} - x_6) }{k_{10}},\\ +\frac{d\,x_7}{d\,t} = \frac{k_9 x_6 (k_{10} - x_6)}{ k_{10}},\\ +y_1 = x_4,\\ +y_2 = x_5\end{cases}$$ + +This model describes the biohydrogenation[^1] process[^2] with unknown initial conditions. + +### Using the `ODESystem` object + +To define the ode system in Julia, we use `ModelingToolkit.jl`. + +We first define the parameters, variables, differential equations and the output equations. + +```julia +using StructuralIdentifiability, ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel Biohydrogenation begin + @variables begin + x4(t) + x5(t) + x6(t) + x7(t) + y1(t), [output = true] + y2(t), [output = true] + end + @parameters begin + k5 + k6 + k7 + k8 + k9 + k10 + end + # define equations + @equations begin + D(x4) ~ -k5 * x4 / (k6 + x4) + D(x5) ~ k5 * x4 / (k6 + x4) - k7 * x5 / (k8 + x5 + x6) + D(x6) ~ k7 * x5 / (k8 + x5 + x6) - k9 * x6 * (k10 - x6) / k10 + D(x7) ~ k9 * x6 * (k10 - x6) / k10 + y1 ~ x4 + y2 ~ x5 + end +end + +# define the system +@mtkbuild de = Biohydrogenation() +``` + +After that, we are ready to check the system for local identifiability: + +```julia +# query local identifiability +# we pass the ode-system +local_id_all = assess_local_identifiability(de, prob_threshold = 0.99) +``` + +We can see that all unknowns (except $x_7$) and all parameters are locally identifiable with probability 0.99. + +Let's try to check specific parameters and their combinations + +```julia +to_check = [de.k5, de.k7, de.k10 / de.k9, de.k5 + de.k6] +local_id_some = assess_local_identifiability( + de, funcs_to_check = to_check, prob_threshold = 0.99) +``` + +Notice that in this case, everything (except the unknown variable $x_7$) is locally identifiable, including combinations such as $k_{10}/k_9, k_5+k_6$ + +## Global Identifiability + +In this part tutorial, let us cover an example problem of querying the ODE for globally identifiable parameters. + +### Input System + +Let us consider the following four-dimensional model with two outputs: + +$$\begin{cases} +x_1'(t) = -b x_1(t) + \frac{1 }{ c + x_4(t)},\\ +x_2'(t) = \alpha x_1(t) - \beta x_2(t),\\ +x_3'(t) = \gamma x_2(t) - \delta x_3(t),\\ +x_4'(t) = \sigma x_4(t) \frac{(\gamma x_2(t) - \delta x_3(t))}{ x_3(t)},\\ +y(t) = x_1(t) +\end{cases}$$ + +We will run a global identifiability check on this enzyme dynamics[^3] model. We will use the default settings: the probability of correctness will be `p=0.99` and we are interested in identifiability of all possible parameters. + +Global identifiability needs information about local identifiability first, but the function we chose here will take care of that extra step for us. + +```julia +using StructuralIdentifiability, ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel GoodwinOsc begin + @parameters begin + b + c + α + β + γ + δ + σ + end + @variables begin + x1(t) + x2(t) + x3(t) + x4(t) + y(t), [output = true] + y2(t), [output = true] + end + @equations begin + D(x1) ~ -b * x1 + 1 / (c + x4) + D(x2) ~ α * x1 - β * x2 + D(x3) ~ γ * x2 - δ * x3 + D(x4) ~ σ * x4 * (γ * x2 - δ * x3) / x3 + y ~ x1 + x2 + y2 ~ x2 + end +end + +@named ode = GoodwinOsc() + +global_id = assess_identifiability(ode) +``` + +We can see that only parameters `a, g` are unidentifiable, and everything else can be uniquely recovered. + +Let us consider the same system but with two inputs, and we will find out identifiability with probability `0.9` for parameters `c` and `b`: + +```julia +using StructuralIdentifiability, ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@mtkmodel GoodwinOscillator begin + @parameters begin + b + c + α + β + γ + δ + σ + end + @variables begin + x1(t) + x2(t) + x3(t) + x4(t) + y(t), [output = true] + y2(t), [output = true] + u1(t), [input = true] + u2(t), [input = true] + end + @equations begin + D(x1) ~ -b * x1 + 1 / (c + x4) + D(x2) ~ α * x1 - β * x2 - u1 + D(x3) ~ γ * x2 - δ * x3 + u2 + D(x4) ~ σ * x4 * (γ * x2 - δ * x3) / x3 + y ~ x1 + x2 + y2 ~ x2 + end +end + +@mtkbuild ode = GoodwinOscillator() + +# check only 2 parameters +to_check = [ode.b, ode.c] + +global_id = assess_identifiability(ode, funcs_to_check = to_check, prob_threshold = 0.9) +``` + +Both parameters `b, c` are globally identifiable with probability `0.9` in this case. + +[^1]: > R. Munoz-Tamayo, L. Puillet, J.B. Daniel, D. Sauvant, O. Martin, M. Taghipoor, P. Blavy [*Review: To be or not to be an identifiable model. Is this a relevant question in animal science modelling?*](https://doi.org/10.1017/S1751731117002774), Animal, Vol 12 (4), 701-712, 2018. The model is the ODE system (3) in Supplementary Material 2, initial conditions are assumed to be unknown. +[^2]: > Moate P.J., Boston R.C., Jenkins T.C. and Lean I.J., [*Kinetics of Ruminal Lipolysis of Triacylglycerol and Biohydrogenationof Long-Chain Fatty Acids: New Insights from Old Data*](https://doi.org/10.3168/jds.2007-0398), Journal of Dairy Science 91, 731–742, 2008 +[^3]: > Goodwin, B.C. [*Oscillatory behavior in enzymatic control processes*](https://doi.org/10.1016/0065-2571(65)90067-1), Advances in Enzyme Regulation, Vol 3 (C), 425-437, 1965 +[^4]: > Dong, R., Goodbrake, C., Harrington, H. A., & Pogudin, G. [*Computing input-output projections of dynamical models with applications to structural identifiability*](https://arxiv.org/pdf/2111.00991). arXiv preprint arXiv:2111.00991. diff --git a/docs/src/tutorials/programmatically_generating.md b/docs/src/tutorials/programmatically_generating.md new file mode 100644 index 0000000000..9fc1db1834 --- /dev/null +++ b/docs/src/tutorials/programmatically_generating.md @@ -0,0 +1,77 @@ +# [Programmatically Generating and Scripting ODESystems](@id programmatically) + +In the following tutorial, we will discuss how to programmatically generate `ODESystem`s. +This is useful for functions that generate `ODESystem`s, for example +when you implement a reader that parses some file format, such as SBML, to generate an `ODESystem`. +It is also useful for functions that transform an `ODESystem`, for example +when you write a function that log-transforms a variable in an `ODESystem`. + +## The Representation of a ModelingToolkit System + +ModelingToolkit is built on [Symbolics.jl](https://symbolics.juliasymbolics.org/dev/), +a symbolic Computer Algebra System (CAS) developed in Julia. As such, all CAS functionality +is also available to be used on ModelingToolkit systems, such as symbolic differentiation, Groebner basis +calculations, and whatever else you can think of. Under the hood, all ModelingToolkit +variables and expressions are Symbolics.jl variables and expressions. Thus when scripting +a ModelingToolkit system, one simply needs to generate Symbolics.jl variables and equations +as demonstrated in the Symbolics.jl documentation. This looks like: + +```@example scripting +using ModelingToolkit # reexports Symbolics +@variables t x(t) y(t) # Define variables +D = Differential(t) +eqs = [D(x) ~ y + D(y) ~ x] # Define an array of equations +``` + +However, ModelingToolkit has many higher-level features which will make scripting ModelingToolkit systems more convenient. +For example, as shown in the next section, defining your own independent variables and differentials is rarely needed. + +## The Non-DSL (non-`@mtkmodel`) Way of Defining an ODESystem + +Using `@mtkmodel`, like in the [getting started tutorial](@ref getting_started), +is the preferred way of defining ODEs with MTK. +However generating the contents of a `@mtkmodel` programmatically can be tedious. +Let us look at how we can define the same system without `@mtkmodel`. + +```@example scripting +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables x(t) = 0.0 # independent and dependent variables +@parameters τ = 3.0 # parameters +@constants h = 1 # constants +eqs = [D(x) ~ (h - x) / τ] # create an array of equations + +# your first ODE, consisting of a single equation, indicated by ~ +@named model = ODESystem(eqs, t) + +# Perform the standard transformations and mark the model complete +# Note: Complete models cannot be subsystems of other models! +fol = structural_simplify(model) +prob = ODEProblem(fol, [], (0.0, 10.0), []) +using OrdinaryDiffEq +sol = solve(prob) + +using Plots +plot(sol) +``` + +As you can see, generating an ODESystem is as simple as creating an array of equations +and passing it to the `ODESystem` constructor. + +`@named` automatically gives a name to the `ODESystem`, and is shorthand for + +```@example scripting +fol_model = ODESystem(eqs, t; name = :fol_model) # @named fol_model = ODESystem(eqs, t) +``` + +Thus, if we had read a name from a file and wish to populate an `ODESystem` with said name, we could do: + +```@example scripting +namesym = :name_from_file +fol_model = ODESystem(eqs, t; name = namesym) +``` + +## Warning About Mutation + +Be advsied that it's never a good idea to mutate an `ODESystem`, or any `AbstractSystem`. diff --git a/docs/src/tutorials/stochastic_diffeq.md b/docs/src/tutorials/stochastic_diffeq.md index 07cf9d88e3..79c71a2e8d 100644 --- a/docs/src/tutorials/stochastic_diffeq.md +++ b/docs/src/tutorials/stochastic_diffeq.md @@ -1,42 +1,94 @@ # Modeling with Stochasticity -All models with `ODESystem` are deterministic. `SDESystem` adds another element -to the model: randomness. This is a +All previous differential equations tutorials deal with deterministic `ODESystem`s. +In this tutorial, we add randomness. +In particular, we show how to represent a [stochastic differential equation](https://en.wikipedia.org/wiki/Stochastic_differential_equation) -which has a deterministic (drift) component and a stochastic (diffusion) -component. Let's take the Lorenz equation from the first tutorial and extend -it to have multiplicative noise. +as a `SDESystem`. -```julia +!!! note + + The high level `@mtkmodel` macro used in the + [getting started tutorial](@ref getting_started) + is not yet compatible with `SDESystem`. + We thus have to use a lower level interface to define stochastic differential equations. + For an introduction to this interface, read the + [programmatically generating ODESystems tutorial](@ref programmatically). + +Let's take the Lorenz equation and add noise to each of the states. +To show the flexibility of ModelingToolkit, +we do not use homogeneous noise, with constant variance, +but instead use heterogeneous noise, +where the magnitude of the noise scales with (0.3 times) the magnitude of each of the states: + +```math +\begin{aligned} +\frac{dx}{dt} &= (\sigma (y-x)) &+ 0.3x\frac{dB}{dt} \\ +\frac{dy}{dt} &= (x(\rho-z) - y) &+ 0.3y\frac{dB}{dt} \\ +\frac{dz}{dt} &= (xy - \beta z) &+ 0.3z\frac{dB}{dt} \\ +\end{aligned} +``` + +Where $B$, is standard Brownian motion, also called the +[Wiener process](https://en.wikipedia.org/wiki/Wiener_process). +We use notation similar to the +[Langevin equation](https://en.wikipedia.org/wiki/Stochastic_differential_equation#Use_in_physics), +often used in physics. +By "multiplying" the equations by $dt$, the notation used in +[probability theory](https://en.wikipedia.org/wiki/Stochastic_differential_equation#Use_in_probability_and_mathematical_finance) +can be recovered. + +We use this Langevin-like notation because it allows us to extend MTK modeling capacity from ODEs to SDEs, +using only a single new concept, `@brownian` variables, which represent $\frac{dB}{dt}$ in the above equation. + +```@example SDE using ModelingToolkit, StochasticDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D +using Plots -# Define some variables -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) +@parameters σ=10.0 ρ=2.33 β=26.0 +@variables x(t)=5.0 y(t)=5.0 z(t)=1.0 +@brownian B +eqs = [D(x) ~ σ * (y - x) + 0.3x * B, + D(y) ~ x * (ρ - z) - y + 0.3y * B, + D(z) ~ x * y - β * z + 0.3z * B] -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] +@mtkbuild de = System(eqs, t) +``` -noiseeqs = [0.1*x, - 0.1*y, - 0.1*z] +Even though we did not explicitly use `SDESystem`, ModelingToolkit can still infer this from the equations. -de = SDESystem(eqs,noiseeqs,t,[x,y,z],[σ,ρ,β]) +```@example SDE +typeof(de) +``` + +We continue by solving and plotting the SDE. + +```@example SDE +prob = SDEProblem(de, [], (0.0, 100.0), []) +sol = solve(prob, SRIW1()) +plot(sol, idxs = [(1, 2, 3)]) +``` + +The noise present in all 3 equations is correlated, as can be seen on the below figure. +The figure also shows the multiplicative nature of the noise. +Because states `x` and `y` generally take on larger values, +the noise also takes on a more pronounced effect on these states compared to the state `z`. -u0map = [ - x => 1.0, - y => 0.0, - z => 0.0 -] +```@example SDE +plot(sol) +``` -parammap = [ - σ => 10.0, - β => 26.0, - ρ => 2.33 -] +If you want uncorrelated noise for each equation, +multiple `@brownian` variables have to be declared. -prob = SDEProblem(de,u0map,(0.0,100.0),parammap) -sol = solve(prob,SOSRI()) +```@example SDE +@brownian Bx By Bz +eqs = [D(x) ~ σ * (y - x) + 0.3x * Bx, + D(y) ~ x * (ρ - z) - y + 0.3y * By, + D(z) ~ x * y - β * z + 0.3z * Bz] +@mtkbuild de = System(eqs, t) +prob = SDEProblem(de, [], (0.0, 100.0), []) +sol = solve(prob, SRIW1()) +plot(sol) ``` diff --git a/docs/src/tutorials/tearing_parallelism.md b/docs/src/tutorials/tearing_parallelism.md deleted file mode 100644 index 775bb1ecb8..0000000000 --- a/docs/src/tutorials/tearing_parallelism.md +++ /dev/null @@ -1,242 +0,0 @@ -# Exposing More Parallelism By Tearing Algebraic Equations in ODESystems - -Sometimes it can be very non-trivial to parallelize a system. In this tutorial -we will demonstrate how to make use of `structural_simplify` to expose more -parallelism in the solution process and parallelize the resulting simulation. - -## The Component Library - -The following tutorial will use the following set of components describing -electrical circuits: - -```julia -using ModelingToolkit, OrdinaryDiffEq - -function connect_pin(ps...) - eqs = [ - 0 ~ sum(p->p.i, ps) # KCL - ] - # KVL - for i in 1:length(ps)-1 - push!(eqs, ps[i].v ~ ps[i+1].v) - end - - return eqs -end - -function connect_heat(ps...) - eqs = [ - 0 ~ sum(p->p.Q_flow, ps) # KCL - ] - # KVL - for i in 1:length(ps)-1 - push!(eqs, ps[i].T ~ ps[i+1].Q_flow) - end - - return eqs -end - -# Basic electric components -@parameters t -const D = Differential(t) -function Pin(;name) - @variables v(t) i(t) - ODESystem(Equation[], t, [v, i], [], name=name, defaults=Dict([v=>1.0, i=>1.0])) -end - -function Ground(;name) - @named g = Pin() - eqs = [g.v ~ 0] - ODESystem(eqs, t, [], [], systems=[g], name=name) -end - -function ConstantVoltage(;name, V = 1.0) - val = V - @named p = Pin() - @named n = Pin() - @parameters V - eqs = [ - V ~ p.v - n.v - 0 ~ p.i + n.i - ] - ODESystem(eqs, t, [], [V], systems=[p, n], defaults=Dict(V => val), name=name) -end - -function HeatPort(;name) - @variables T(t) Q_flow(t) - return ODESystem(Equation[], t, [T, Q_flow], [], defaults=Dict(T=>293.15, Q_flow=>0.0), name=name) -end - -function HeatingResistor(;name, R=1.0, TAmbient=293.15, alpha=1.0) - R_val, TAmbient_val, alpha_val = R, TAmbient, alpha - @named p = Pin() - @named n = Pin() - @named h = HeatPort() - @variables v(t) RTherm(t) - @parameters R TAmbient alpha - eqs = [ - RTherm ~ R*(1 + alpha*(h.T - TAmbient)) - v ~ p.i * RTherm - h.Q_flow ~ -v * p.i # -LossPower - v ~ p.v - n.v - 0 ~ p.i + n.i - ] - ODESystem( - eqs, t, [v, RTherm], [R, TAmbient, alpha], systems=[p, n, h], - defaults=Dict( - R=>R_val, TAmbient=>TAmbient_val, alpha=>alpha_val, - v=>0.0, RTherm=>R_val - ), - name=name, - ) -end - -function HeatCapacitor(;name, rho=8050, V=1, cp=460, TAmbient=293.15) - rho_val, V_val, cp_val = rho, V, cp - @parameters rho V cp - C = rho*V*cp - @named h = HeatPort() - eqs = [ - D(h.T) ~ h.Q_flow / C - ] - ODESystem( - eqs, t, [], [rho, V, cp], systems=[h], - defaults=Dict(rho=>rho_val, V=>V_val, cp=>cp_val), - name=name, - ) -end - -function Capacitor(;name, C = 1.0) - val = C - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters C - eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - D(v) ~ p.i / C - ] - ODESystem( - eqs, t, [v], [C], systems=[p, n], - defaults=Dict(v => 0.0, C => val), - name=name - ) -end - -function rc_model(i; name, source, ground, R, C) - resistor = HeatingResistor(name=Symbol(:resistor, i), R=R) - capacitor = Capacitor(name=Symbol(:capacitor, i), C=C) - heat_capacitor = HeatCapacitor(name=Symbol(:heat_capacitor, i)) - - rc_eqs = [ - connect_pin(source.p, resistor.p) - connect_pin(resistor.n, capacitor.p) - connect_pin(capacitor.n, source.n, ground.g) - connect_heat(resistor.h, heat_capacitor.h) - ] - - rc_model = ODESystem(rc_eqs, t, systems=[resistor, capacitor, source, ground, heat_capacitor], name=Symbol(name, i)) -end -``` - -## The Model - -Assuming that the components are defined, our model is 50 resistors and -capacitors connected in parallel. Thus following the [acausal components tutorial](@ref acausal), -we can connect a bunch of RC components as follows: - -```julia -V = 2.0 -source = ConstantVoltage(name=:source, V=V) -ground = Ground(name=:ground) -N = 50 -Rs = 10 .^range(0, stop=-4, length=N) -Cs = 10 .^range(-3, stop=0, length=N) -rc_systems = map(1:N) do i - rc_model(i; name=:rc, source=source, ground=ground, R=Rs[i], C=Cs[i]) -end -@variables E(t) -eqs = [ - D(E) ~ sum(((i, sys),)->getproperty(sys, Symbol(:resistor, i)).h.Q_flow, enumerate(rc_systems)) - ] -big_rc = ODESystem(eqs, t, [], [], systems=rc_systems, defaults=Dict(E=>0.0)) -``` - -Now let's say we want to expose a bit more parallelism via running tearing. -How do we do that? - -```julia -sys = structural_simplify(big_rc) -``` - -Done, that's it. There's no more to it. - -## What Happened? - -Yes, that's a good question! Let's investigate a little bit more what had happened. -If you look at the system we defined: - -```julia -equations(big_rc) - -1051-element Vector{Equation}: - Differential(t)(E(t)) ~ rc10₊resistor10₊h₊Q_flow(t) + rc11₊resistor11₊h₊Q_flow(t) + rc12₊resistor12₊h₊Q_flow(t) + rc13₊resistor13₊h₊Q_flow(t) + rc14₊resistor14₊h₊Q_flow(t) + rc15₊resistor15₊h₊Q_flow(t) + rc16₊resistor16₊h₊Q_flow(t) + rc17₊resistor17₊h₊Q_flow(t) + rc18₊resistor18₊h₊Q_flow(t) + rc19₊resistor19₊h₊Q_flow(t) + rc1₊resistor1₊h₊Q_flow(t) + rc20₊resistor20₊h₊Q_flow(t) + rc21₊resistor21₊h₊Q_flow(t) + rc22₊resistor22₊h₊Q_flow(t) + rc23₊resistor23₊h₊Q_flow(t) + rc24₊resistor24₊h₊Q_flow(t) + rc25₊resistor25₊h₊Q_flow(t) + rc26₊resistor26₊h₊Q_flow(t) + rc27₊resistor27₊h₊Q_flow(t) + rc28₊resistor28₊h₊Q_flow(t) + rc29₊resistor29₊h₊Q_flow(t) + rc2₊resistor2₊h₊Q_flow(t) + rc30₊resistor30₊h₊Q_flow(t) + rc31₊resistor31₊h₊Q_flow(t) + rc32₊resistor32₊h₊Q_flow(t) + rc33₊resistor33₊h₊Q_flow(t) + rc34₊resistor34₊h₊Q_flow(t) + rc35₊resistor35₊h₊Q_flow(t) + rc36₊resistor36₊h₊Q_flow(t) + rc37₊resistor37₊h₊Q_flow(t) + rc38₊resistor38₊h₊Q_flow(t) + rc39₊resistor39₊h₊Q_flow(t) + rc3₊resistor3₊h₊Q_flow(t) + rc40₊resistor40₊h₊Q_flow(t) + rc41₊resistor41₊h₊Q_flow(t) + rc42₊resistor42₊h₊Q_flow(t) + rc43₊resistor43₊h₊Q_flow(t) + rc44₊resistor44₊h₊Q_flow(t) + rc45₊resistor45₊h₊Q_flow(t) + rc46₊resistor46₊h₊Q_flow(t) + rc47₊resistor47₊h₊Q_flow(t) + rc48₊resistor48₊h₊Q_flow(t) + rc49₊resistor49₊h₊Q_flow(t) + rc4₊resistor4₊h₊Q_flow(t) + rc50₊resistor50₊h₊Q_flow(t) + rc5₊resistor5₊h₊Q_flow(t) + rc6₊resistor6₊h₊Q_flow(t) + rc7₊resistor7₊h₊Q_flow(t) + rc8₊resistor8₊h₊Q_flow(t) + rc9₊resistor9₊h₊Q_flow(t) - 0 ~ rc1₊resistor1₊p₊i(t) + rc1₊source₊p₊i(t) - rc1₊source₊p₊v(t) ~ rc1₊resistor1₊p₊v(t) - 0 ~ rc1₊capacitor1₊p₊i(t) + rc1₊resistor1₊n₊i(t) - ⋮ - rc50₊source₊V ~ rc50₊source₊p₊v(t) - (rc50₊source₊n₊v(t)) - 0 ~ rc50₊source₊n₊i(t) + rc50₊source₊p₊i(t) - rc50₊ground₊g₊v(t) ~ 0 - Differential(t)(rc50₊heat_capacitor50₊h₊T(t)) ~ rc50₊heat_capacitor50₊h₊Q_flow(t)*(rc50₊heat_capacitor50₊V^-1)*(rc50₊heat_capacitor50₊cp^-1)*(rc50₊heat_capacitor50₊rho^-1) -``` - -You see it started as a massive 1051 set of equations. However, after eliminating -redundancies we arrive at 151 equations: - -```julia -equations(sys) - -151-element Vector{Equation}: - Differential(t)(E(t)) ~ rc1₊resistor1₊p₊i(t)*((rc1₊capacitor1₊v(t)) - rc1₊source₊V) + rc4₊resistor4₊p₊i(t)*((rc4₊capacitor4₊v(t)) - rc4₊source₊V) - ((rc10₊capacitor10₊p₊i(t))*(rc10₊source₊V - (rc10₊capacitor10₊v(t)))) - ((rc11₊capacitor11₊p₊i(t))*(rc11₊source₊V - (rc11₊capacitor11₊v(t)))) - ((rc12₊capacitor12₊p₊i(t))*(rc12₊source₊V - (rc12₊capacitor12₊v(t)))) - ((rc13₊capacitor13₊p₊i(t))*(rc13₊source₊V - (rc13₊capacitor13₊v(t)))) - ((rc14₊capacitor14₊p₊i(t))*(rc14₊source₊V - (rc14₊capacitor14₊v(t)))) - ((rc15₊capacitor15₊p₊i(t))*(rc15₊source₊V - (rc15₊capacitor15₊v(t)))) - ((rc16₊capacitor16₊p₊i(t))*(rc16₊source₊V - (rc16₊capacitor16₊v(t)))) - ((rc17₊capacitor17₊p₊i(t))*(rc17₊source₊V - (rc17₊capacitor17₊v(t)))) - ((rc18₊capacitor18₊p₊i(t))*(rc18₊source₊V - (rc18₊capacitor18₊v(t)))) - ((rc19₊capacitor19₊p₊i(t))*(rc19₊source₊V - (rc19₊capacitor19₊v(t)))) - ((rc20₊capacitor20₊p₊i(t))*(rc20₊source₊V - (rc20₊capacitor20₊v(t)))) - ((rc21₊capacitor21₊p₊i(t))*(rc21₊source₊V - (rc21₊capacitor21₊v(t)))) - ((rc22₊capacitor22₊p₊i(t))*(rc22₊source₊V - (rc22₊capacitor22₊v(t)))) - ((rc23₊capacitor23₊p₊i(t))*(rc23₊source₊V - (rc23₊capacitor23₊v(t)))) - ((rc24₊capacitor24₊p₊i(t))*(rc24₊source₊V - (rc24₊capacitor24₊v(t)))) - ((rc25₊capacitor25₊p₊i(t))*(rc25₊source₊V - (rc25₊capacitor25₊v(t)))) - ((rc26₊capacitor26₊p₊i(t))*(rc26₊source₊V - (rc26₊capacitor26₊v(t)))) - ((rc27₊capacitor27₊p₊i(t))*(rc27₊source₊V - (rc27₊capacitor27₊v(t)))) - ((rc28₊capacitor28₊p₊i(t))*(rc28₊source₊V - (rc28₊capacitor28₊v(t)))) - ((rc29₊capacitor29₊p₊i(t))*(rc29₊source₊V - (rc29₊capacitor29₊v(t)))) - ((rc2₊capacitor2₊p₊i(t))*(rc2₊source₊V - (rc2₊capacitor2₊v(t)))) - ((rc30₊capacitor30₊p₊i(t))*(rc30₊source₊V - (rc30₊capacitor30₊v(t)))) - ((rc31₊capacitor31₊p₊i(t))*(rc31₊source₊V - (rc31₊capacitor31₊v(t)))) - ((rc32₊capacitor32₊p₊i(t))*(rc32₊source₊V - (rc32₊capacitor32₊v(t)))) - ((rc33₊capacitor33₊p₊i(t))*(rc33₊source₊V - (rc33₊capacitor33₊v(t)))) - ((rc34₊capacitor34₊p₊i(t))*(rc34₊source₊V - (rc34₊capacitor34₊v(t)))) - ((rc35₊capacitor35₊p₊i(t))*(rc35₊source₊V - (rc35₊capacitor35₊v(t)))) - ((rc36₊capacitor36₊p₊i(t))*(rc36₊source₊V - (rc36₊capacitor36₊v(t)))) - ((rc37₊capacitor37₊p₊i(t))*(rc37₊source₊V - (rc37₊capacitor37₊v(t)))) - ((rc38₊capacitor38₊p₊i(t))*(rc38₊source₊V - (rc38₊capacitor38₊v(t)))) - ((rc39₊capacitor39₊p₊i(t))*(rc39₊source₊V - (rc39₊capacitor39₊v(t)))) - ((rc3₊capacitor3₊p₊i(t))*(rc3₊source₊V - (rc3₊capacitor3₊v(t)))) - ((rc40₊capacitor40₊p₊i(t))*(rc40₊source₊V - (rc40₊capacitor40₊v(t)))) - ((rc41₊capacitor41₊p₊i(t))*(rc41₊source₊V - (rc41₊capacitor41₊v(t)))) - ((rc42₊capacitor42₊p₊i(t))*(rc42₊source₊V - (rc42₊capacitor42₊v(t)))) - ((rc43₊capacitor43₊p₊i(t))*(rc43₊source₊V - (rc43₊capacitor43₊v(t)))) - ((rc44₊capacitor44₊p₊i(t))*(rc44₊source₊V - (rc44₊capacitor44₊v(t)))) - ((rc45₊capacitor45₊p₊i(t))*(rc45₊source₊V - (rc45₊capacitor45₊v(t)))) - ((rc46₊capacitor46₊p₊i(t))*(rc46₊source₊V - (rc46₊capacitor46₊v(t)))) - ((rc47₊capacitor47₊p₊i(t))*(rc47₊source₊V - (rc47₊capacitor47₊v(t)))) - ((rc48₊capacitor48₊p₊i(t))*(rc48₊source₊V - (rc48₊capacitor48₊v(t)))) - ((rc49₊capacitor49₊p₊i(t))*(rc49₊source₊V - (rc49₊capacitor49₊v(t)))) - ((rc50₊capacitor50₊p₊i(t))*(rc50₊source₊V - (rc50₊capacitor50₊v(t)))) - ((rc5₊capacitor5₊p₊i(t))*(rc5₊source₊V - (rc5₊capacitor5₊v(t)))) - ((rc6₊capacitor6₊p₊i(t))*(rc6₊source₊V - (rc6₊capacitor6₊v(t)))) - ((rc7₊capacitor7₊p₊i(t))*(rc7₊source₊V - (rc7₊capacitor7₊v(t)))) - ((rc8₊capacitor8₊p₊i(t))*(rc8₊source₊V - (rc8₊capacitor8₊v(t)))) - ((rc9₊capacitor9₊p₊i(t))*(rc9₊source₊V - (rc9₊capacitor9₊v(t)))) - 0 ~ rc1₊resistor1₊R*rc1₊resistor1₊p₊i(t)*(1 + (rc1₊resistor1₊alpha*((-rc1₊resistor1₊TAmbient) - ((rc1₊resistor1₊p₊i(t))*((rc1₊capacitor1₊v(t)) - rc1₊source₊V))))) + rc1₊capacitor1₊v(t) - rc1₊source₊V - Differential(t)(rc1₊capacitor1₊v(t)) ~ rc1₊resistor1₊p₊i(t)*(rc1₊capacitor1₊C^-1) - Differential(t)(rc1₊heat_capacitor1₊h₊T(t)) ~ -rc1₊resistor1₊p₊i(t)*(rc1₊heat_capacitor1₊V^-1)*(rc1₊heat_capacitor1₊cp^-1)*(rc1₊heat_capacitor1₊rho^-1)*((rc1₊capacitor1₊v(t)) - rc1₊source₊V) - ⋮ - Differential(t)(rc49₊heat_capacitor49₊h₊T(t)) ~ rc49₊capacitor49₊p₊i(t)*(rc49₊heat_capacitor49₊V^-1)*(rc49₊heat_capacitor49₊cp^-1)*(rc49₊heat_capacitor49₊rho^-1)*(rc49₊source₊V - (rc49₊capacitor49₊v(t))) - 0 ~ rc50₊resistor50₊R*rc50₊capacitor50₊p₊i(t)*(1 + (rc50₊resistor50₊alpha*(((rc50₊capacitor50₊p₊i(t))*(rc50₊source₊V - (rc50₊capacitor50₊v(t)))) - rc50₊resistor50₊TAmbient))) - (rc50₊source₊V - (rc50₊capacitor50₊v(t))) - Differential(t)(rc50₊capacitor50₊v(t)) ~ rc50₊capacitor50₊p₊i(t)*(rc50₊capacitor50₊C^-1) - Differential(t)(rc50₊heat_capacitor50₊h₊T(t)) ~ rc50₊capacitor50₊p₊i(t)*(rc50₊heat_capacitor50₊V^-1)*(rc50₊heat_capacitor50₊cp^-1)*(rc50₊heat_capacitor50₊rho^-1)*(rc50₊source₊V - (rc50₊capacitor50₊v(t))) -``` - -That's not all though. In addition, the tearing process has turned the sets of -nonlinear equations into separate blocks and constructed a DAG for the dependencies -between the blocks. We can use the bipartite graph functionality to dig in and -investigate what this means: - -```julia -using ModelingToolkit.BipartiteGraphs -big_rc = initialize_system_structure(big_rc) -inc_org = BipartiteGraphs.incidence_matrix(structure(big_rc).graph) -blt_org = StructuralTransformations.sorted_incidence_matrix(big_rc, only_algeqs=true, only_algvars=true) -blt_reduced = StructuralTransformations.sorted_incidence_matrix(sys, only_algeqs=true, only_algvars=true) -``` - -![](https://user-images.githubusercontent.com/1814174/110589027-d4ec9b00-8143-11eb-8880-651da986504d.PNG) - -The figure on the left is the original incidence matrix of the algebraic equations. -Notice that the original formulation of the model has dependencies between different -equations, and so the full set of equations must be solved together. That exposes -no parallelism. However, the Block Lower Triangular (BLT) transformation exposes -independent blocks. This is then further impoved by the tearing process, which -removes 90% of the equations and transforms the nonlinear equations into 50 -independent blocks *which can now all be solved in parallel*. The conclusion -is that, your attempts to parallelize are neigh: performing parallelism after -structural simplification greatly improves the problem that can be parallelized, -so this is better than trying to do it by hand. - -After performing this, you can construct the `ODEProblem`/`ODAEProblem` and set -`parallel_form` to use the exposed parallelism in multithreaded function -constructions, but this showcases why `structural_simplify` is so important -to that process. diff --git a/examples/electrical_components.jl b/examples/electrical_components.jl index 9d9f15abf0..1f4f151c21 100644 --- a/examples/electrical_components.jl +++ b/examples/electrical_components.jl @@ -1,84 +1,95 @@ using Test using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D -# Basic electric components -@parameters t -@connector function Pin(;name) - @variables v(t) i(t) - ODESystem(Equation[], t, [v, i], [], name=name, defaults=[v=>1.0, i=>1.0]) +@connector function Pin(; name) + sts = @variables v(t) [guess = 1.0] i(t) [guess = 1.0, connect = Flow] + ODESystem(Equation[], t, sts, []; name = name) end -function ModelingToolkit.connect(::Type{Pin}, ps...) - eqs = [ - 0 ~ sum(p->p.i, ps) # KCL - ] - # KVL - for i in 1:length(ps)-1 - push!(eqs, ps[i].v ~ ps[i+1].v) - end - - return eqs -end - -function Ground(;name) +@component function Ground(; name) @named g = Pin() eqs = [g.v ~ 0] - ODESystem(eqs, t, [], [], systems=[g], name=name) + compose(ODESystem(eqs, t, [], []; name = name), g) end -function ConstantVoltage(;name, V = 1.0) - val = V +@component function OnePort(; name) @named p = Pin() @named n = Pin() - @parameters V - eqs = [ - V ~ p.v - n.v + sts = @variables v(t) [guess = 1.0] i(t) [guess = 1.0] + eqs = [v ~ p.v - n.v 0 ~ p.i + n.i - ] - ODESystem(eqs, t, [], [V], systems=[p, n], defaults=Dict(V => val), name=name) + i ~ p.i] + compose(ODESystem(eqs, t, sts, []; name = name), p, n) end -function Resistor(;name, R = 1.0) - val = R - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters R +@component function Resistor(; name, R = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + ps = @parameters R = R eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - v ~ p.i * R - ] - ODESystem(eqs, t, [v], [R], systems=[p, n], defaults=Dict(R => val), name=name) + v ~ i * R + ] + extend(ODESystem(eqs, t, [], ps; name = name), oneport) end -function Capacitor(;name, C = 1.0) - val = C - @named p = Pin() - @named n = Pin() - @variables v(t) - @parameters C - D = Differential(t) +@component function Capacitor(; name, C = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + ps = @parameters C = C eqs = [ - v ~ p.v - n.v - 0 ~ p.i + n.i - D(v) ~ p.i / C - ] - ODESystem(eqs, t, [v], [C], systems=[p, n], defaults=Dict(C => val), name=name) + D(v) ~ i / C + ] + extend(ODESystem(eqs, t, [], ps; name = name), oneport) end -function Inductor(; name, L = 1.0) - val = L +@component function ConstantVoltage(; name, V = 1.0) + @named oneport = OnePort() + @unpack v = oneport + ps = @parameters V = V + eqs = [ + V ~ v + ] + extend(ODESystem(eqs, t, [], ps; name = name), oneport) +end + +@component function Inductor(; name, L = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + ps = @parameters L = L + eqs = [ + D(i) ~ v / L + ] + extend(ODESystem(eqs, t, [], ps; name = name), oneport) +end + +@connector function HeatPort(; name) + @variables T(t) [guess = 293.15] Q_flow(t) [guess = 0.0, connect = Flow] + ODESystem(Equation[], t, [T, Q_flow], [], name = name) +end + +@component function HeatingResistor(; name, R = 1.0, TAmbient = 293.15, alpha = 1.0) @named p = Pin() @named n = Pin() - @variables v(t) i(t) - @parameters L - D = Differential(t) - eqs = [ + @named h = HeatPort() + @variables v(t) RTherm(t) + @parameters R=R TAmbient=TAmbient alpha=alpha + eqs = [RTherm ~ R * (1 + alpha * (h.T - TAmbient)) + v ~ p.i * RTherm + h.Q_flow ~ -v * p.i # -LossPower v ~ p.v - n.v - 0 ~ p.i + n.i - i ~ p.i - D(i) ~ v / L - ] - ODESystem(eqs, t, [v, i], [L], systems=[p, n], defaults=Dict(L => val), name=name) + 0 ~ p.i + n.i] + compose(ODESystem(eqs, t, [v, RTherm], [R, TAmbient, alpha], + name = name), p, n, h) +end + +@component function HeatCapacitor(; name, rho = 8050, V = 1, cp = 460, TAmbient = 293.15) + @parameters rho=rho V=V cp=cp + C = rho * V * cp + @named h = HeatPort() + eqs = [ + D(h.T) ~ h.Q_flow / C + ] + compose(ODESystem(eqs, t, [], [rho, V, cp], + name = name), h) end diff --git a/examples/rc_model.jl b/examples/rc_model.jl index 094197f714..158436d980 100644 --- a/examples/rc_model.jl +++ b/examples/rc_model.jl @@ -3,15 +3,15 @@ include("electrical_components.jl") R = 1.0 C = 1.0 V = 1.0 -@named resistor = Resistor(R=R) -@named capacitor = Capacitor(C=C) -@named source = ConstantVoltage(V=V) +@named resistor = Resistor(R = R) +@named capacitor = Capacitor(C = C) +@named source = ConstantVoltage(V = V) @named ground = Ground() -rc_eqs = [ - connect(source.p, resistor.p) +rc_eqs = [connect(source.p, resistor.p) connect(resistor.n, capacitor.p) - connect(capacitor.n, source.n, ground.g) - ] + connect(capacitor.n, source.n) + connect(capacitor.n, ground.g)] -@named rc_model = ODESystem(rc_eqs, t, systems=[resistor, capacitor, source, ground]) +@named rc_model = ODESystem(rc_eqs, t) +rc_model = compose(rc_model, [resistor, capacitor, source, ground]) diff --git a/examples/serial_inductor.jl b/examples/serial_inductor.jl index 4f49cf48b7..302df32c17 100644 --- a/examples/serial_inductor.jl +++ b/examples/serial_inductor.jl @@ -1,16 +1,33 @@ include("electrical_components.jl") -@named source = ConstantVoltage(V=10.0) -@named resistor = Resistor(R=1.0) -@named inductor1 = Inductor(L=1.0e-2) -@named inductor2 = Inductor(L=2.0e-2) +@named source = ConstantVoltage(V = 10.0) +@named resistor = Resistor(R = 1.0) +@named inductor1 = Inductor(L = 1.0e-2) +@named inductor2 = Inductor(L = 2.0e-2) @named ground = Ground() -eqs = [ - connect(source.p, resistor.p) +eqs = [connect(source.p, resistor.p) connect(resistor.n, inductor1.p) connect(inductor1.n, inductor2.p) - connect(source.n, inductor2.n, ground.g) - ] + connect(source.n, inductor2.n) + connect(inductor2.n, ground.g)] -@named ll_model = ODESystem(eqs, t, systems=[source, resistor, inductor1, inductor2, ground]) +@named ll_model = ODESystem(eqs, t) +ll_model = compose(ll_model, [source, resistor, inductor1, inductor2, ground]) + +@named source = ConstantVoltage(V = 10.0) +@named resistor1 = Resistor(R = 1.0) +@named resistor2 = Resistor(R = 1.0) +@named inductor1 = Inductor(L = 1.0e-2) +@named inductor2 = Inductor(L = 2.0e-2) +@named ground = Ground() + +eqs = [connect(source.p, inductor1.p) + connect(inductor1.n, resistor1.p) + connect(inductor1.n, resistor2.p) + connect(resistor1.n, resistor2.n) + connect(resistor2.n, inductor2.p) + connect(source.n, inductor2.n) + connect(inductor2.n, ground.g)] +@named ll2_model = ODESystem(eqs, t) +ll2_model = compose(ll2_model, [source, resistor1, resistor2, inductor1, inductor2, ground]) diff --git a/ext/MTKBifurcationKitExt.jl b/ext/MTKBifurcationKitExt.jl new file mode 100644 index 0000000000..0b9f104d9b --- /dev/null +++ b/ext/MTKBifurcationKitExt.jl @@ -0,0 +1,159 @@ +module MTKBifurcationKitExt + +### Preparations ### + +# Imports +using ModelingToolkit, Setfield +import BifurcationKit + +### Observable Plotting Handling ### + +# Functor used when the plotting variable is an observable. Keeps track of the required information for computing the observable's value at each point of the bifurcation diagram. +struct ObservableRecordFromSolution{S, T} + # The equations determining the observables values. + obs_eqs::S + # The index of the observable that we wish to plot. + target_obs_idx::Int64 + # The final index in subs_vals that contains a state. + state_end_idxs::Int64 + # The final index in subs_vals that contains a param. + param_end_idxs::Int64 + # The index (in subs_vals) that contain the bifurcation parameter. + bif_par_idx::Int64 + # A Vector of pairs (Symbolic => value) with the default values of all system variables and parameters. + subs_vals::T + + function ObservableRecordFromSolution(nsys::NonlinearSystem, + plot_var, + bif_idx, + u0_vals, + p_vals) + obs_eqs = observed(nsys) + target_obs_idx = findfirst(isequal(plot_var, eq.lhs) for eq in observed(nsys)) + state_end_idxs = length(unknowns(nsys)) + param_end_idxs = state_end_idxs + length(parameters(nsys)) + + bif_par_idx = state_end_idxs + bif_idx + # Gets the (base) substitution values for states. + subs_vals_states = Pair.(unknowns(nsys), u0_vals) + # Gets the (base) substitution values for parameters. + subs_vals_params = Pair.(parameters(nsys), p_vals) + # Gets the (base) substitution values for observables. + subs_vals_obs = [obs.lhs => substitute(obs.rhs, + [subs_vals_states; subs_vals_params]) + for obs in observed(nsys)] + # Sometimes observables depend on other observables, hence we make a second update to this vector. + subs_vals_obs = [obs.lhs => substitute(obs.rhs, + [subs_vals_states; subs_vals_params; subs_vals_obs]) + for obs in observed(nsys)] + # During the bifurcation process, the value of some states, parameters, and observables may vary (and are calculated in each step). Those that are not are stored in this vector + subs_vals = [subs_vals_states; subs_vals_params; subs_vals_obs] + + param_end_idxs = state_end_idxs + length(parameters(nsys)) + new{typeof(obs_eqs), typeof(subs_vals)}(obs_eqs, + target_obs_idx, + state_end_idxs, + param_end_idxs, + bif_par_idx, + subs_vals) + end +end +# Functor function that computes the value. +function (orfs::ObservableRecordFromSolution)(x, p; k...) + # Updates the state values (in subs_vals). + for state_idx in 1:(orfs.state_end_idxs) + orfs.subs_vals[state_idx] = orfs.subs_vals[state_idx][1] => x[state_idx] + end + + # Updates the bifurcation parameters value (in subs_vals). + orfs.subs_vals[orfs.bif_par_idx] = orfs.subs_vals[orfs.bif_par_idx][1] => p + + # Updates the observable values (in subs_vals). + for (obs_idx, obs_eq) in enumerate(orfs.obs_eqs) + orfs.subs_vals[orfs.param_end_idxs + obs_idx] = orfs.subs_vals[orfs.param_end_idxs + obs_idx][1] => substitute( + obs_eq.rhs, + orfs.subs_vals) + end + + # Substitutes in the value for all states, parameters, and observables into the equation for the designated observable. + return substitute(orfs.obs_eqs[orfs.target_obs_idx].rhs, orfs.subs_vals) +end + +### Creates BifurcationProblem Overloads ### + +# When input is a NonlinearSystem. +function BifurcationKit.BifurcationProblem(nsys::NonlinearSystem, + u0_bif, + ps, + bif_par, + args...; + plot_var = nothing, + record_from_solution = BifurcationKit.record_sol_default, + jac = true, + kwargs...) + if !ModelingToolkit.iscomplete(nsys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `BifurcationProblem`") + end + @set! nsys.index_cache = nothing # force usage of a parameter vector instead of `MTKParameters` + # Creates F and J functions. + ofun = NonlinearFunction(nsys; jac = jac) + F = let f = ofun.f + _f(resid, u, p) = (f(resid, u, p); resid) + _f(u, p) = f(u, p) + end + J = jac ? ofun.jac : nothing + + # Converts the input state guess. + u0_bif_vals = ModelingToolkit.varmap_to_vars(u0_bif, + unknowns(nsys); + defaults = ModelingToolkit.get_defaults(nsys)) + p_vals = ModelingToolkit.varmap_to_vars( + ps, parameters(nsys); defaults = ModelingToolkit.get_defaults(nsys)) + + # Computes bifurcation parameter and the plotting function. + bif_idx = findfirst(isequal(bif_par), parameters(nsys)) + if !isnothing(plot_var) + # If the plot var is a normal state. + if any(isequal(plot_var, var) for var in unknowns(nsys)) + plot_idx = findfirst(isequal(plot_var), unknowns(nsys)) + record_from_solution = (x, p; k...) -> x[plot_idx] + + # If the plot var is an observed state. + elseif any(isequal(plot_var, eq.lhs) for eq in observed(nsys)) + record_from_solution = ObservableRecordFromSolution(nsys, + plot_var, + bif_idx, + u0_bif_vals, + p_vals) + + # If neither an variable nor observable, throw an error. + else + error("The plot variable ($plot_var) was neither recognised as a system state nor observable.") + end + end + + return BifurcationKit.BifurcationProblem(F, + u0_bif_vals, + p_vals, + (BifurcationKit.@optic _[bif_idx]), + args...; + record_from_solution = record_from_solution, + J = J, + inplace = true, + kwargs...) +end + +# When input is a ODESystem. +function BifurcationKit.BifurcationProblem(osys::ODESystem, args...; kwargs...) + if !ModelingToolkit.iscomplete(osys) + error("A completed `ODESystem` is required. Call `complete` or `structural_simplify` on the system before creating a `BifurcationProblem`") + end + nsys = NonlinearSystem([0 ~ eq.rhs for eq in full_equations(osys)], + unknowns(osys), + parameters(osys); + observed = observed(osys), + name = nameof(osys)) + return BifurcationKit.BifurcationProblem(complete(nsys), args...; kwargs...) +end + +end # module diff --git a/ext/MTKChainRulesCoreExt.jl b/ext/MTKChainRulesCoreExt.jl new file mode 100644 index 0000000000..a2974ea2dd --- /dev/null +++ b/ext/MTKChainRulesCoreExt.jl @@ -0,0 +1,114 @@ +module MTKChainRulesCoreExt + +import ModelingToolkit as MTK +import ChainRulesCore +import ChainRulesCore: Tangent, ZeroTangent, NoTangent, zero_tangent, unthunk + +function ChainRulesCore.rrule(::Type{MTK.MTKParameters}, tunables, args...) + function mtp_pullback(dt) + dt = unthunk(dt) + dtunables = dt isa AbstractArray ? dt : dt.tunable + (NoTangent(), dtunables[1:length(tunables)], + ntuple(_ -> NoTangent(), length(args))...) + end + MTK.MTKParameters(tunables, args...), mtp_pullback +end + +function subset_idxs(idxs, portion, template) + ntuple(Val(length(template))) do subi + result = [Base.tail(idx.idx) + for idx in idxs if idx.portion == portion && idx.idx[1] == subi] + if isempty(result) + result = [] + end + result + end +end + +selected_tangents(::NoTangent, _) = () +selected_tangents(::ZeroTangent, _) = ZeroTangent() +function selected_tangents( + tangents::AbstractArray{T}, idxs::Vector{Tuple{Int}}) where {T <: Number} + selected_tangents(tangents, map(only, idxs)) +end +function selected_tangents(tangents::AbstractArray{T}, idxs...) where {T <: Number} + newtangents = copy(tangents) + view(newtangents, idxs...) .= zero(T) + newtangents +end +function selected_tangents( + tangents::AbstractVector{T}, idxs) where {S <: Number, T <: AbstractArray{S}} + newtangents = copy(tangents) + for i in idxs + j, k... = i + if k == () + newtangents[j] = zero(newtangents[j]) + else + newtangents[j] = selected_tangents(newtangents[j], k...) + end + end + newtangents +end +function selected_tangents(tangents::AbstractVector{T}, idxs) where {T <: AbstractArray} + newtangents = similar(tangents, Union{T, NoTangent}) + copyto!(newtangents, tangents) + for i in idxs + j, k... = i + if k == () + newtangents[j] = NoTangent() + else + newtangents[j] = selected_tangents(newtangents[j], k...) + end + end + newtangents +end +function selected_tangents( + tangents::Union{Tangent{<:Tuple}, Tangent{T, <:Tuple}}, idxs) where {T} + ntuple(Val(length(tangents))) do i + selected_tangents(tangents[i], idxs[i]) + end +end + +function ChainRulesCore.rrule( + ::typeof(MTK.remake_buffer), indp, oldbuf::MTK.MTKParameters, idxs, vals) + if idxs isa AbstractSet + idxs = collect(idxs) + end + idxs = map(idxs) do i + i isa MTK.ParameterIndex ? i : MTK.parameter_index(indp, i) + end + newbuf = MTK.remake_buffer(indp, oldbuf, idxs, vals) + tunable_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa MTK.SciMLStructures.Tunable); + init = Union{Int, AbstractVector{Int}}[]) + initials_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa MTK.SciMLStructures.Initials); + init = Union{Int, AbstractVector{Int}}[]) + disc_idxs = subset_idxs(idxs, MTK.SciMLStructures.Discrete(), oldbuf.discrete) + const_idxs = subset_idxs(idxs, MTK.SciMLStructures.Constants(), oldbuf.constant) + nn_idxs = subset_idxs(idxs, MTK.NONNUMERIC_PORTION, oldbuf.nonnumeric) + + pullback = let idxs = idxs + function remake_buffer_pullback(buf′) + buf′ = unthunk(buf′) + f′ = NoTangent() + indp′ = NoTangent() + + tunable = selected_tangents(buf′.tunable, tunable_idxs) + initials = selected_tangents(buf′.initials, initials_idxs) + discrete = selected_tangents(buf′.discrete, disc_idxs) + constant = selected_tangents(buf′.constant, const_idxs) + nonnumeric = selected_tangents(buf′.nonnumeric, nn_idxs) + oldbuf′ = Tangent{typeof(oldbuf)}(; + tunable, initials, discrete, constant, nonnumeric) + idxs′ = NoTangent() + vals′ = map(i -> MTK._ducktyped_parameter_values(buf′, i), idxs) + return f′, indp′, oldbuf′, idxs′, vals′ + end + end + newbuf, pullback +end + +ChainRulesCore.@non_differentiable Base.getproperty(sys::MTK.AbstractSystem, x::Symbol) + +end diff --git a/ext/MTKDeepDiffsExt.jl b/ext/MTKDeepDiffsExt.jl new file mode 100644 index 0000000000..a24a638d32 --- /dev/null +++ b/ext/MTKDeepDiffsExt.jl @@ -0,0 +1,190 @@ +module MTKDeepDiffsExt + +using DeepDiffs, ModelingToolkit +using ModelingToolkit.BipartiteGraphs: Label, + BipartiteAdjacencyList, unassigned, + HighlightInt +using ModelingToolkit: SystemStructure, + MatchedSystemStructure, + SystemStructurePrintMatrix + +""" +A utility struct for displaying the difference between two HighlightInts. + +# Example +```julia +using ModelingToolkit, DeepDiffs + +old_i = HighlightInt(1, :default, true) +new_i = HighlightInt(2, :default, false) +diff = HighlightIntDiff(new_i, old_i) + +show(diff) +``` +""" +struct HighlightIntDiff + new::HighlightInt + old::HighlightInt +end + +function Base.show(io::IO, d::HighlightIntDiff) + p_color = d.new.highlight + (d.new.match && !d.old.match) && (p_color = :light_green) + (!d.new.match && d.old.match) && (p_color = :light_red) + + (d.new.match || d.old.match) && printstyled(io, "(", color = p_color) + if d.new.i != d.old.i + Base.show(io, HighlightInt(d.old.i, :light_red, d.old.match)) + print(io, " ") + Base.show(io, HighlightInt(d.new.i, :light_green, d.new.match)) + else + Base.show(io, HighlightInt(d.new.i, d.new.highlight, false)) + end + (d.new.match || d.old.match) && printstyled(io, ")", color = p_color) +end + +""" +A utility struct for displaying the difference between two +BipartiteAdjacencyList's. + +# Example +```julia +using ModelingToolkit, DeepDiffs + +old = BipartiteAdjacencyList(...) +new = BipartiteAdjacencyList(...) +diff = BipartiteAdjacencyListDiff(new, old) + +show(diff) +``` +""" +struct BipartiteAdjacencyListDiff + new::BipartiteAdjacencyList + old::BipartiteAdjacencyList +end + +function Base.show(io::IO, l::BipartiteAdjacencyListDiff) + print(io, + LabelDiff(Label(l.new.match === true ? "∫ " : ""), + Label(l.old.match === true ? "∫ " : ""))) + (l.new.match !== true && l.old.match !== true) && print(io, " ") + + new_nonempty = isnothing(l.new.u) ? nothing : !isempty(l.new.u) + old_nonempty = isnothing(l.old.u) ? nothing : !isempty(l.old.u) + if new_nonempty === true && old_nonempty === true + if (!isempty(setdiff(l.new.highlight_u, l.new.u)) || + !isempty(setdiff(l.old.highlight_u, l.old.u))) + throw(ArgumentError("The provided `highlight_u` must be a sub-graph of `u`.")) + end + + new_items = Dict(i => HighlightInt(i, :nothing, i === l.new.match) for i in l.new.u) + old_items = Dict(i => HighlightInt(i, :nothing, i === l.old.match) for i in l.old.u) + + highlighted = union(map(intersect(l.new.u, l.old.u)) do i + HighlightIntDiff(new_items[i], old_items[i]) + end, + map(setdiff(l.new.u, l.old.u)) do i + HighlightInt(new_items[i].i, :light_green, + new_items[i].match) + end, + map(setdiff(l.old.u, l.new.u)) do i + HighlightInt(old_items[i].i, :light_red, + old_items[i].match) + end) + print(IOContext(io, :typeinfo => typeof(highlighted)), highlighted) + elseif new_nonempty === true + printstyled( + io, map(l.new.u) do i + HighlightInt(i, :nothing, i === l.new.match) + end, color = :light_green) + elseif old_nonempty === true + printstyled( + io, map(l.old.u) do i + HighlightInt(i, :nothing, i === l.old.match) + end, color = :light_red) + elseif old_nonempty !== nothing || new_nonempty !== nothing + print(io, + LabelDiff(Label(new_nonempty === false ? "∅" : "", :light_black), + Label(old_nonempty === false ? "∅" : "", :light_black))) + else + printstyled(io, '⋅', color = :light_black) + end +end + +""" +A utility struct for displaying the difference between two Labels +in git-style red/green highlighting. + +# Example +```julia +using ModelingToolkit, DeepDiffs + +old = Label("before") +new = Label("after") +diff = LabelDiff(new, old) + +show(diff) +``` +""" +struct LabelDiff + new::Label + old::Label +end +function Base.show(io::IO, l::LabelDiff) + if l.new != l.old + printstyled(io, l.old.s, color = :light_red) + length(l.new.s) != 0 && length(l.old.s) != 0 && print(io, " ") + printstyled(io, l.new.s, color = :light_green) + else + print(io, l.new) + end +end + +""" +A utility struct for displaying the difference between two +(Matched)SystemStructure's in git-style red/green highlighting. + +# Example +```julia +using ModelingToolkit, DeepDiffs + +old = SystemStructurePrintMatrix(...) +new = SystemStructurePrintMatrix(...) +diff = SystemStructureDiffPrintMatrix(new, old) + +show(diff) +``` +""" +struct SystemStructureDiffPrintMatrix <: + AbstractMatrix{Union{LabelDiff, BipartiteAdjacencyListDiff}} + new::SystemStructurePrintMatrix + old::SystemStructurePrintMatrix +end + +function Base.size(ssdpm::SystemStructureDiffPrintMatrix) + max.(Base.size(ssdpm.new), Base.size(ssdpm.old)) +end + +function Base.getindex(ssdpm::SystemStructureDiffPrintMatrix, i::Integer, j::Integer) + checkbounds(ssdpm, i, j) + if i > 1 && (j == 4 || j == 9) + old = new = BipartiteAdjacencyList(nothing, nothing, unassigned) + (i <= size(ssdpm.new, 1)) && (new = ssdpm.new[i, j]) + (i <= size(ssdpm.old, 1)) && (old = ssdpm.old[i, j]) + BipartiteAdjacencyListDiff(new, old) + else + old = new = Label("") + (i <= size(ssdpm.new, 1)) && (new = ssdpm.new[i, j]) + (i <= size(ssdpm.old, 1)) && (old = ssdpm.old[i, j]) + LabelDiff(new, old) + end +end + +function DeepDiffs.deepdiff(old::Union{MatchedSystemStructure, SystemStructure}, + new::Union{MatchedSystemStructure, SystemStructure}) + new_sspm = SystemStructurePrintMatrix(new) + old_sspm = SystemStructurePrintMatrix(old) + Base.print_matrix(stdout, SystemStructureDiffPrintMatrix(new_sspm, old_sspm)) +end + +end # module diff --git a/ext/MTKFMIExt.jl b/ext/MTKFMIExt.jl new file mode 100644 index 0000000000..5cfe9a82ef --- /dev/null +++ b/ext/MTKFMIExt.jl @@ -0,0 +1,933 @@ +module MTKFMIExt + +using ModelingToolkit +using SymbolicIndexingInterface +using ModelingToolkit: t_nounits as t, D_nounits as D +using DocStringExtensions +import ModelingToolkit as MTK +import SciMLBase +import FMI + +""" + $(TYPEDSIGNATURES) + +A utility macro for FMI.jl functions that return a status. Will terminate on +fatal statuses. Must be used as `@statuscheck FMI.fmiXFunction(...)` where +`X` should be `2` or `3`. Has an edge case for handling tuples for +`FMI.fmi2CompletedIntegratorStep`. +""" +macro statuscheck(expr) + @assert Meta.isexpr(expr, :call) + fn = expr.args[1] + @assert Meta.isexpr(fn, :.) + @assert fn.args[1] == :FMI + fnname = fn.args[2] + + instance = expr.args[2] + is_v2 = startswith("fmi2", string(fnname)) + + fmiTrue = is_v2 ? FMI.fmi2True : FMI.fmi3True + fmiStatusOK = is_v2 ? FMI.fmi2StatusOK : FMI.fmi3StatusOK + fmiStatusWarning = is_v2 ? FMI.fmi2StatusWarning : FMI.fmi3StatusWarning + fmiStatusFatal = is_v2 ? FMI.fmi2StatusFatal : FMI.fmi3StatusFatal + fmiTerminate = is_v2 ? FMI.fmi2Terminate : FMI.fmi3Terminate + fmiFreeInstance! = is_v2 ? FMI.fmi2FreeInstance! : FMI.fmi3FreeInstance! + return quote + status = $expr + fnname = $fnname + if status !== nothing && ((status isa Tuple && status[1] == $fmiTrue) || + (!(status isa Tuple) && status != $fmiStatusOK && + status != $fmiStatusWarning)) + if status != $fmiStatusFatal + $fmiTerminate(wrapper.instance) + end + $fmiFreeInstance!(wrapper.instance) + wrapper.instance = nothing + error("FMU Error in $fnname: status $status") + end + end |> esc +end + +@static if !hasmethod(FMI.getValueReferencesAndNames, Tuple{FMI.fmi3ModelDescription}) + """ + $(TYPEDSIGNATURES) + + This is type piracy, but FMI.jl is missing this implementation. It allows + `FMI.getStateValueReferencesAndNames` to work. + """ + function FMI.getValueReferencesAndNames( + md::FMI.fmi3ModelDescription; vrs = md.valueReferences) + dict = Dict{FMI.fmi3ValueReference, Array{String}}() + for vr in vrs + dict[vr] = FMI.valueReferenceToString(md, vr) + end + return dict + end +end + +""" + $(TYPEDSIGNATURES) + +A component that wraps an FMU loaded via FMI.jl. The FMI version (2 or 3) should be +provided as a `Val` to the function. Supports Model Exchange and CoSimulation FMUs. +All inputs, continuous variables and outputs must be `FMI.fmi2Real` or `FMI.fmi3Float64`. +Does not support events or discrete variables in the FMU. Does not support automatic +differentiation. Parameters of the FMU will have defaults corresponding to their initial +values in the FMU specification. All other variables will not have a default. Hierarchical +names in the FMU of the form `namespace.variable` are transformed into symbolic variables +with the name `namespace__variable`. + +# Keyword Arguments + +- `fmu`: The FMU loaded via `FMI.loadFMU`. +- `tolerance`: The tolerance to provide to the FMU. Not used for v3 FMUs since it is not + supported by FMI.jl. +- `communication_step_size`: The periodic interval at which communication with CoSimulation + FMUs will occur. Must be provided for CoSimulation FMU components. +- `reinitializealg`: The DAE initialization algorithm to use for the callback managing the + FMU. For CoSimulation FMUs whose states/outputs are used in algebraic equations of the + system, this needs to be an algorithm that will solve for the new algebraic variables. + For example, `OrdinaryDiffEqCore.BrownFullBasicInit()`. +- `type`: Either `:ME` or `:CS` depending on whether `fmu` is a Model Exchange or + CoSimulation FMU respectively. +- `name`: The name of the system. +""" +function MTK.FMIComponent(::Val{Ver}; fmu = nothing, tolerance = 1e-6, + communication_step_size = nothing, reinitializealg = SciMLBase.NoInit(), type, name) where {Ver} + if Ver != 2 && Ver != 3 + throw(ArgumentError("FMI Version must be `2` or `3`")) + end + if type == :CS && communication_step_size === nothing + throw(ArgumentError("`communication_step_size` must be specified for Co-Simulation FMUs.")) + end + # mapping from MTK variable to value reference + value_references = Dict() + # defaults + defs = Dict() + # unknowns of the system + states = [] + # differential variables of the system + # this is a subset of `states` in the case where the FMU has multiple names for + # the same value reference. + diffvars = [] + # variables that are derivatives of diffvars + dervars = [] + # observed equations + observed = Equation[] + # need to separate observed equations for duplicate derivative variables + # since they aren't included in CS FMUs + der_observed = Equation[] + + # parse states + fmi_variables_to_mtk_variables!(fmu, FMI.getStateValueReferencesAndNames(fmu), + value_references, diffvars, states, observed) + # create a symbolic variable __mtk_internal_u to pass to the relevant registered + # functions as the state vector + if isempty(diffvars) + # no differential variables + __mtk_internal_u = Float64[] + elseif type == :ME + # to avoid running into `structural_simplify` warnings about array variables + # and some unfortunate circular dependency issues, ME FMUs use an array of + # symbolics instead. This is also not worse off in performance + # because the former approach would allocate anyway. + # TODO: Can we avoid an allocation here using static arrays? + __mtk_internal_u = copy(diffvars) + elseif type == :CS + # CS FMUs do their own independent integration in a periodic callback, so their + # unknowns are discrete variables in the `ODESystem`. A default of `missing` allows + # them to be solved for during initialization. + @parameters __mtk_internal_u(t)[1:length(diffvars)]=missing [guess = diffvars] + push!(observed, __mtk_internal_u ~ copy(diffvars)) + end + + # parse derivatives of states + # the variables passed to `postprocess_variable` haven't been differentiated yet, so they + # should match one variable in states. That's the one this is the derivative of, and we + # keep track of this ordering + derivative_order = [] + function derivative_order_postprocess(var) + idx = findfirst(isequal(var), states) + idx === nothing || push!(derivative_order, states[idx]) + return var + end + fmi_variables_to_mtk_variables!( + fmu, FMI.getDerivateValueReferencesAndNames(fmu), value_references, dervars, + states, der_observed; postprocess_variable = derivative_order_postprocess) + @assert length(derivative_order) == length(dervars) + + # parse the inputs to the FMU + inputs = [] + fmi_variables_to_mtk_variables!(fmu, FMI.getInputValueReferencesAndNames(fmu), + value_references, inputs, states, observed; postprocess_variable = v -> MTK.setinput( + v, true)) + # create a symbolic variable for the input buffer + __mtk_internal_x = copy(inputs) + if isempty(__mtk_internal_x) + __mtk_internal_x = Float64[] + end + + # parse the outputs of the FMU + outputs = [] + fmi_variables_to_mtk_variables!(fmu, FMI.getOutputValueReferencesAndNames(fmu), + value_references, outputs, states, observed; postprocess_variable = v -> MTK.setoutput( + v, true)) + # create the output buffer. This is only required for CoSimulation to pass it to + # the callback affect + if type == :CS + if isempty(outputs) + __mtk_internal_o = Float64[] + else + @parameters __mtk_internal_o(t)[1:length(outputs)]=missing [guess = zeros(length(outputs))] + push!(observed, __mtk_internal_o ~ outputs) + end + end + + # parse the parameters + params = [] + # multiple names for the same parameter are treated as parameter dependencies. + parameter_dependencies = Equation[] + fmi_variables_to_mtk_variables!( + fmu, FMI.getParameterValueReferencesAndNames(fmu), value_references, + params, [], parameter_dependencies, defs; parameters = true) + # create a symbolic variable for the parameter buffer + __mtk_internal_p = copy(params) + if isempty(__mtk_internal_p) + __mtk_internal_p = Float64[] + end + + derivative_value_references = UInt32[value_references[var] for var in dervars] + state_value_references = UInt32[value_references[var] for var in diffvars] + output_value_references = UInt32[value_references[var] for var in outputs] + input_value_references = UInt32[value_references[var] for var in inputs] + param_value_references = UInt32[value_references[var] for var in params] + + # create a parameter for the instance wrapper + # this manages the creation and deallocation of FMU instances + buffer_length = length(diffvars) + length(outputs) + if Ver == 2 + @parameters (wrapper::FMI2InstanceWrapper)(..)[1:buffer_length] = FMI2InstanceWrapper( + fmu, derivative_value_references, state_value_references, output_value_references, + param_value_references, input_value_references, tolerance) + else + @parameters (wrapper::FMI3InstanceWrapper)(..)[1:buffer_length] = FMI3InstanceWrapper( + fmu, derivative_value_references, state_value_references, + output_value_references, param_value_references, input_value_references) + end + + # any additional initialization equations for the system + initialization_eqs = Equation[] + + if type == :ME + # the wrapper is a callable struct which returns the state derivative and + # output values + # symbolic expression for calling the wrapper + call_expr = wrapper(__mtk_internal_u, __mtk_internal_x, __mtk_internal_p, t) + + # differential and observed equations + diffeqs = Equation[] + for (i, var) in enumerate([dervars; outputs]) + push!(diffeqs, var ~ call_expr[i]) + end + for (var, dervar) in zip(derivative_order, dervars) + push!(diffeqs, D(var) ~ dervar) + end + + # instance management callback which deallocates the instance when + # necessary and notifies the FMU of completed integrator steps + finalize_affect = MTK.FunctionalAffect(fmiFinalize!, [], [wrapper], []) + step_affect = MTK.FunctionalAffect(Returns(nothing), [], [], []) + instance_management_callback = MTK.SymbolicDiscreteCallback( + (t != t - 1), step_affect; finalize = finalize_affect, reinitializealg = reinitializealg) + + push!(params, wrapper) + append!(observed, der_observed) + elseif type == :CS + _functor = if Ver == 2 + FMI2CSFunctor(state_value_references, output_value_references) + else + FMI3CSFunctor(state_value_references, output_value_references) + end + @parameters (functor::(typeof(_functor)))(..)[1:(length(__mtk_internal_u) + length(__mtk_internal_o))] = _functor + # for co-simulation, we need to ensure the output buffer is solved for + # during initialization + for (i, x) in enumerate(collect(__mtk_internal_o)) + push!(initialization_eqs, + x ~ functor( + wrapper, __mtk_internal_u, __mtk_internal_x, __mtk_internal_p, t)[i]) + end + + diffeqs = Equation[] + + # use `ImperativeAffect` for instance management here + cb_observed = (; inputs = __mtk_internal_x, params = copy(params), + t, wrapper, dt = communication_step_size) + cb_modified = (;) + # modify the outputs if present + if symbolic_type(__mtk_internal_o) != NotSymbolic() + cb_modified = (cb_modified..., outputs = __mtk_internal_o) + end + # modify the continuous state if present + if symbolic_type(__mtk_internal_u) != NotSymbolic() + cb_modified = (cb_modified..., states = __mtk_internal_u) + end + initialize_affect = MTK.ImperativeAffect(fmiCSInitialize!; observed = cb_observed, + modified = cb_modified, ctx = _functor) + finalize_affect = MTK.FunctionalAffect(fmiFinalize!, [], [wrapper], []) + # the callback affect performs the stepping + step_affect = MTK.ImperativeAffect( + fmiCSStep!; observed = cb_observed, modified = cb_modified, ctx = _functor) + instance_management_callback = MTK.SymbolicDiscreteCallback( + communication_step_size, step_affect; initialize = initialize_affect, + finalize = finalize_affect, reinitializealg = reinitializealg + ) + + # guarded in case there are no outputs/states and the variable is `[]`. + symbolic_type(__mtk_internal_o) == NotSymbolic() || push!(params, __mtk_internal_o) + symbolic_type(__mtk_internal_u) == NotSymbolic() || push!(params, __mtk_internal_u) + + push!(params, wrapper, functor) + end + + eqs = [observed; diffeqs] + return ODESystem(eqs, t, states, params; parameter_dependencies, defaults = defs, + discrete_events = [instance_management_callback], name, initialization_eqs) +end + +""" + $(TYPEDSIGNATURES) + +A utility function which accepts an FMU `fmu` and a mapping from value reference to a +list of associated names `varmap`. A symbolic variable is created for each name. The +associated value reference is kept track of in `value_references`. In case there are +multiple names for a value reference, the symbolic variable for the first name is pushed +to `truevars`. All of the created symbolic variables are pushed to `allvars`. Observed +equations equating identical variables are pushed to `obseqs`. `defs` is a dictionary of +defaults. + +# Keyword Arguments +- `parameters`: A boolean indicating whether to use `@parameters` for the symbolic + variables instead of `@variables`. +- `postprocess_variable`: A function applied to each created variable that should + return the updated variable. This is useful to add metadata to variables. +""" +function fmi_variables_to_mtk_variables!( + fmu::Union{FMI.FMU2, FMI.FMU3}, varmap::AbstractDict, + value_references::AbstractDict, truevars, allvars, + obseqs, defs = Dict(); parameters = false, postprocess_variable = identity) + for (valRef, varnames) in varmap + stateT = FMI.dataTypeForValueReference(fmu, valRef) + snames = Symbol[] + ders = Int[] + for name in varnames + sname, der = parseFMIVariableName(name) + push!(snames, sname) + push!(ders, der) + end + if parameters + vars = [postprocess_variable(MTK.unwrap(only(@parameters $sname::stateT))) + for sname in snames] + else + vars = [postprocess_variable(MTK.unwrap(only(@variables $sname(t)::stateT))) + for sname in snames] + end + for i in eachindex(vars) + der = ders[i] + vars[i] = MTK.unwrap(vars[i]) + for j in 1:der + vars[i] = D(vars[i]) + end + vars[i] = MTK.default_toterm(vars[i]) + end + for i in eachindex(vars) + if i == 1 + push!(truevars, vars[i]) + else + push!(obseqs, vars[i] ~ vars[1]) + end + value_references[vars[i]] = valRef + end + append!(allvars, vars) + defval = FMI.getStartValue(fmu, valRef) + defs[vars[1]] = defval + end +end + +""" + $(TYPEDSIGNATURES) + +Parse the string name of an FMI variable into a `Symbol` name for the corresponding +MTK variable. Return the `Symbol` name and the number of times it is differentiated. +""" +function parseFMIVariableName(name::AbstractString) + name = replace(name, "." => "__") + der = 0 + if startswith(name, "der(") + idx = findfirst(',', name) + if idx === nothing + name = @view name[5:(end - 1)] + der = 1 + else + der = parse(Int, @view name[(idx + 1):(end - 1)]) + name = @view name[5:(idx - 1)] + end + end + return Symbol(name), der +end + +""" + $(TYPEDEF) + +A struct which manages instance creation and deallocation for v2 FMUs. + +# Fields + +$(TYPEDFIELDS) +""" +mutable struct FMI2InstanceWrapper + """ + The FMU from `FMI.loadFMU`. + """ + const fmu::FMI.FMU2 + """ + The value references for derivatives of states of the FMU, in the order that the + caller expects them to be returned when calling this struct. + """ + const derivative_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references for the states of the FMU. + """ + const state_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references for outputs of the FMU, in the order that the caller expects + them to be returned when calling this struct. + """ + const output_value_references::Vector{FMI.fmi2ValueReference} + """ + The parameter value references. These should be in the same order as the parameter + vector passed to functions involving this wrapper. + """ + const param_value_references::Vector{FMI.fmi2ValueReference} + """ + The input value references. These should be in the same order as the inputs passed + to functions involving this wrapper. + """ + const input_value_references::Vector{FMI.fmi2ValueReference} + """ + The tolerance with which to setup the FMU instance. + """ + const tolerance::FMI.fmi2Real + """ + The FMU instance, if present, and `nothing` otherwise. + """ + instance::Union{FMI.FMU2Component{FMI.FMU2}, Nothing} +end + +""" + $(TYPEDSIGNATURES) + +Create an `FMI2InstanceWrapper` with no instance. +""" +function FMI2InstanceWrapper(fmu, ders, states, outputs, params, inputs, tolerance) + FMI2InstanceWrapper(fmu, ders, states, outputs, params, inputs, tolerance, nothing) +end + +Base.nameof(::FMI2InstanceWrapper) = :FMI2InstanceWrapper + +""" + $(TYPEDSIGNATURES) + +Common functionality for creating an instance of a v2 FMU. Does not check if +`wrapper.instance` is already present, and overwrites the existing value with +a new instance. `inputs` should be in the order of `wrapper.input_value_references`. +`params` should be in the order of `wrapper.param_value_references`. `t` is the current +time. Returns the created instance, which is also stored in `wrapper.instance`. +""" +function get_instance_common!(wrapper::FMI2InstanceWrapper, inputs, params, t) + wrapper.instance = FMI.fmi2Instantiate!(wrapper.fmu)::FMI.FMU2Component + if !isempty(inputs) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.input_value_references, + Csize_t(length(wrapper.param_value_references)), inputs) + end + if !isempty(params) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.param_value_references, + Csize_t(length(wrapper.param_value_references)), params) + end + @statuscheck FMI.fmi2SetupExperiment( + wrapper.instance, FMI.fmi2True, wrapper.tolerance, t, FMI.fmi2False, t) + @statuscheck FMI.fmi2EnterInitializationMode(wrapper.instance) + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a Model Exchange FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_ME!(wrapper::FMI2InstanceWrapper, inputs, params, t) + if wrapper.instance === nothing + get_instance_common!(wrapper, inputs, params, t) + @statuscheck FMI.fmi2ExitInitializationMode(wrapper.instance) + eventInfo = FMI.fmi2NewDiscreteStates(wrapper.instance) + @assert eventInfo.newDiscreteStatesNeeded == FMI.fmi2False + # TODO: Support FMU events + @statuscheck FMI.fmi2EnterContinuousTimeMode(wrapper.instance) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a CoSimulation FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_CS!(wrapper::FMI2InstanceWrapper, states, inputs, params, t) + if wrapper.instance === nothing + get_instance_common!(wrapper, inputs, params, t) + if !isempty(states) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.state_value_references, + Csize_t(length(wrapper.state_value_references)), states) + end + @statuscheck FMI.fmi2ExitInitializationMode(wrapper.instance) + end + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Call `fmiXCompletedIntegratorStep` with `noSetFMUStatePriorToCurrentPoint` as false. +""" +function partiallyCompleteIntegratorStep(wrapper::FMI2InstanceWrapper) + @statuscheck FMI.fmi2CompletedIntegratorStep(wrapper.instance, FMI.fmi2False) +end + +""" + $(TYPEDSIGNATURES) + +If `wrapper.instance !== nothing`, terminate and free the instance. Also set +`wrapper.instance` to `nothing`. +""" +function reset_instance!(wrapper::FMI2InstanceWrapper) + wrapper.instance === nothing && return + FMI.fmi2Terminate(wrapper.instance) + FMI.fmi2FreeInstance!(wrapper.instance) + wrapper.instance = nothing +end + +""" + $(TYPEDEF) + +A struct which manages instance creation and deallocation for v3 FMUs. + +# Fields + +$(TYPEDFIELDS) +""" +mutable struct FMI3InstanceWrapper + """ + The FMU from `FMI.loadFMU`. + """ + const fmu::FMI.FMU3 + """ + The value references for derivatives of states of the FMU, in the order that the + caller expects them to be returned when calling this struct. + """ + const derivative_value_references::Vector{FMI.fmi3ValueReference} + const state_value_references::Vector{FMI.fmi3ValueReference} + """ + The value references for outputs of the FMU, in the order that the caller expects + them to be returned when calling this struct. + """ + const output_value_references::Vector{FMI.fmi3ValueReference} + """ + The parameter value references. These should be in the same order as the parameter + vector passed to functions involving this wrapper. + """ + const param_value_references::Vector{FMI.fmi3ValueReference} + """ + The input value references. These should be in the same order as the inputs passed + to functions involving this wrapper. + """ + const input_value_references::Vector{FMI.fmi3ValueReference} + """ + The FMU instance, if present, and `nothing` otherwise. + """ + instance::Union{FMI.FMU3Instance{FMI.FMU3}, Nothing} +end + +""" + $(TYPEDSIGNATURES) + +Create an `FMI3InstanceWrapper` with no instance. +""" +function FMI3InstanceWrapper(fmu, ders, states, outputs, params, inputs) + FMI3InstanceWrapper(fmu, ders, states, outputs, params, inputs, nothing) +end + +Base.nameof(::FMI3InstanceWrapper) = :FMI3InstanceWrapper + +""" + $(TYPEDSIGNATURES) + +Common functionality for creating an instance of a v3 FMU. Since v3 FMUs need to be +instantiated differently depending on the type, this assumes `wrapper.instance` is a +freshly instantiated FMU which needs to be initialized. `inputs` should be in the order +of `wrapper.input_value_references`. `params` should be in the order of +`wrapper.param_value_references`. `t` is the current time. Returns `wrapper.instance`. +""" +function get_instance_common!(wrapper::FMI3InstanceWrapper, inputs, params, t) + if !isempty(params) + @statuscheck FMI.fmi3SetFloat64(wrapper.instance, wrapper.param_value_references, + params) + end + @statuscheck FMI.fmi3EnterInitializationMode( + wrapper.instance, FMI.fmi3False, zero(FMI.fmi3Float64), t, FMI.fmi3False, t) + if !isempty(inputs) + @statuscheck FMI.fmi3SetFloat64( + wrapper.instance, wrapper.input_value_references, inputs) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a Model Exchange FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_ME!(wrapper::FMI3InstanceWrapper, inputs, params, t) + if wrapper.instance === nothing + wrapper.instance = FMI.fmi3InstantiateModelExchange!(wrapper.fmu)::FMI.FMU3Instance + get_instance_common!(wrapper, inputs, params, t) + @statuscheck FMI.fmi3ExitInitializationMode(wrapper.instance) + eventInfo = FMI.fmi3UpdateDiscreteStates(wrapper.instance) + @assert eventInfo[1] == FMI.fmi2False + # TODO: Support FMU events + @statuscheck FMI.fmi3EnterContinuousTimeMode(wrapper.instance) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a CoSimulation FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_CS!(wrapper::FMI3InstanceWrapper, states, inputs, params, t) + if wrapper.instance === nothing + wrapper.instance = FMI.fmi3InstantiateCoSimulation!( + wrapper.fmu; eventModeUsed = false)::FMI.FMU3Instance + get_instance_common!(wrapper, inputs, params, t) + if !isempty(states) + @statuscheck FMI.fmi3SetFloat64( + wrapper.instance, wrapper.state_value_references, states) + end + @statuscheck FMI.fmi3ExitInitializationMode(wrapper.instance) + end + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) +""" +function partiallyCompleteIntegratorStep(wrapper::FMI3InstanceWrapper) + enterEventMode = Ref(FMI.fmi3False) + terminateSimulation = Ref(FMI.fmi3False) + @statuscheck FMI.fmi3CompletedIntegratorStep!( + wrapper.instance, FMI.fmi3False, enterEventMode, terminateSimulation) + @assert enterEventMode[] == FMI.fmi3False + @assert terminateSimulation[] == FMI.fmi3False +end + +""" + $(TYPEDSIGNATURES) +""" +function reset_instance!(wrapper::FMI3InstanceWrapper) + wrapper.instance === nothing && return + FMI.fmi3Terminate(wrapper.instance) + FMI.fmi3FreeInstance!(wrapper.instance) + wrapper.instance = nothing +end + +@register_array_symbolic (fn::FMI2InstanceWrapper)( + states::Vector{<:Real}, inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +@register_array_symbolic (fn::FMI3InstanceWrapper)( + wrapper::FMI3InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) + +Update the internal state of the ME FMU and return a vector of updated values +for continuous state derivatives and output variables respectively. Needs to be a +callable struct to enable symbolic registration with an inferred return size. +""" +function (wrapper::Union{FMI2InstanceWrapper, FMI3InstanceWrapper})( + states, inputs, params, t) + instance = get_instance_ME!(wrapper, inputs, params, t) + + # TODO: Find a way to do this without allocating. We can't pass a view to these + # functions. + states_buffer = zeros(length(states)) + outputs_buffer = zeros(length(wrapper.output_value_references)) + # Defined in FMIBase.jl/src/eval.jl + # Doesn't seem to be documented, but somehow this is the only way to + # propagate inputs to the FMU consistently. I have no idea why. + instance(; x = states, u = inputs, u_refs = wrapper.input_value_references, + p = params, p_refs = wrapper.param_value_references, t = t) + # the spec requires completing the step before getting updated derivative/output values + partiallyCompleteIntegratorStep(wrapper) + instance(; dx = states_buffer, dx_refs = wrapper.derivative_value_references, + y = outputs_buffer, y_refs = wrapper.output_value_references) + return [states_buffer; outputs_buffer] +end + +""" + $(TYPEDSIGNATURES) + +An affect function for use inside a `FunctionalAffect`. This should be triggered at the +end of the solve, regardless of whether it succeeded or failed. Expects `p` to be a +1-length array containing the index of the instance wrapper (`FMI2InstanceWrapper` or +`FMI3InstanceWrapper`) in the parameter object. +""" +function fmiFinalize!(integrator, u, p, ctx) + wrapper_idx = p[1] + wrapper = integrator.ps[wrapper_idx] + reset_instance!(wrapper) +end + +""" + $(TYPEDEF) + +A callable struct useful for initializing v2 CoSimulation FMUs. When called, updates the +internal state of the FMU and gets updated values for output variables. + +# Fields + +$(TYPEDFIELDS) +""" +struct FMI2CSFunctor + """ + The value references of state variables in the FMU. + """ + state_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references of output variables in the FMU. + """ + output_value_references::Vector{FMI.fmi2ValueReference} +end + +function (fn::FMI2CSFunctor)(wrapper::FMI2InstanceWrapper, states, inputs, params, t) + states = states isa SubArray ? copy(states) : states + inputs = inputs isa SubArray ? copy(inputs) : inputs + params = params isa SubArray ? copy(params) : params + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isempty(fn.output_value_references) + return eltype(states)[] + else + return FMI.fmi2GetReal(instance, fn.output_value_references) + end +end + +@register_array_symbolic (fn::FMI2CSFunctor)( + wrapper::FMI2InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) + +An affect function designed for use with `ImperativeAffect`. Should be triggered during +callback initialization. `m` should contain the key `:states` with the value being the +state vector if the FMU has continuous states. `m` should contain the key `:outputs` with +the value being the output vector if the FMU has output variables. `o` should contain the +`:inputs`, `:params`, `:t` and `:wrapper` where the latter contains the `FMI2InstanceWrapper`. + +Initializes the FMU. Only for use with CoSimulation FMUs. +""" +function fmiCSInitialize!(m, o, ctx::FMI2CSFunctor, integrator) + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + wrapper = o.wrapper + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isdefined(m, :states) + @statuscheck FMI.fmi2GetReal!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi2GetReal!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDSIGNATURES) + +An affect function designed for use with `ImperativeAffect`. Should be triggered +periodically to communicte with the CoSimulation FMU. Has the same requirements as +`fmiCSInitialize!` for `m` and `o`, with the addition that `o` should have a key +`:dt` with the value being the communication step size. +""" +function fmiCSStep!(m, o, ctx::FMI2CSFunctor, integrator) + wrapper = o.wrapper + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + dt = o.dt + + instance = get_instance_CS!(wrapper, states, inputs, params, integrator.t) + if !isempty(inputs) + FMI.fmi2SetReal( + instance, wrapper.input_value_references, Csize_t(length(inputs)), inputs) + end + @statuscheck FMI.fmi2DoStep(instance, integrator.t - dt, dt, FMI.fmi2True) + + if isdefined(m, :states) + @statuscheck FMI.fmi2GetReal!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi2GetReal!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDEF) + +A callable struct useful for initializing v3 CoSimulation FMUs. When called, updates the +internal state of the FMU and gets updated values for output variables. + +# Fields + +$(TYPEDFIELDS) +""" +struct FMI3CSFunctor + """ + The value references of state variables in the FMU. + """ + state_value_references::Vector{FMI.fmi3ValueReference} + """ + The value references of output variables in the FMU. + """ + output_value_references::Vector{FMI.fmi3ValueReference} +end + +function (fn::FMI3CSFunctor)(wrapper::FMI3InstanceWrapper, states, inputs, params, t) + states = states isa SubArray ? copy(states) : states + inputs = inputs isa SubArray ? copy(inputs) : inputs + params = params isa SubArray ? copy(params) : params + instance = get_instance_CS!(wrapper, states, inputs, params, t) + + if isempty(fn.output_value_references) + return eltype(states)[] + else + return FMI.fmi3GetFloat64(instance, fn.output_value_references) + end +end + +@register_array_symbolic (fn::FMI3CSFunctor)( + wrapper::FMI3InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) +""" +function fmiCSInitialize!(m, o, ctx::FMI3CSFunctor, integrator) + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + wrapper = o.wrapper + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isdefined(m, :states) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDSIGNATURES) +""" +function fmiCSStep!(m, o, ctx::FMI3CSFunctor, integrator) + wrapper = o.wrapper + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + dt = o.dt + + instance = get_instance_CS!(wrapper, states, inputs, params, integrator.t) + if !isempty(inputs) + FMI.fmi3SetFloat64(instance, wrapper.input_value_references, inputs) + end + eventEncountered = Ref(FMI.fmi3False) + terminateSimulation = Ref(FMI.fmi3False) + earlyReturn = Ref(FMI.fmi3False) + lastSuccessfulTime = Ref(zero(FMI.fmi3Float64)) + @statuscheck FMI.fmi3DoStep!( + instance, integrator.t - dt, dt, FMI.fmi3True, eventEncountered, + terminateSimulation, earlyReturn, lastSuccessfulTime) + @assert eventEncountered[] == FMI.fmi3False + @assert terminateSimulation[] == FMI.fmi3False + @assert earlyReturn[] == FMI.fmi3False + + if isdefined(m, :states) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +end # module diff --git a/ext/MTKInfiniteOptExt.jl b/ext/MTKInfiniteOptExt.jl new file mode 100644 index 0000000000..3b482a1f03 --- /dev/null +++ b/ext/MTKInfiniteOptExt.jl @@ -0,0 +1,26 @@ +module MTKInfiniteOptExt +import ModelingToolkit +import SymbolicUtils +import NaNMath +import InfiniteOpt +import InfiniteOpt: JuMP, GeneralVariableRef + +# This file contains method definitions to make it possible to trace through functions generated by MTK using JuMP variables + +for ff in [acos, log1p, acosh, log2, asin, tan, atanh, cos, log, sin, log10, sqrt] + f = nameof(ff) + # These need to be defined so that JuMP can trace through functions built by Symbolics + @eval NaNMath.$f(x::GeneralVariableRef) = Base.$f(x) +end + +# JuMP variables and Symbolics variables never compare equal. When tracing through dynamics, a function argument can be either a JuMP variable or A Symbolics variable, it can never be both. +function Base.isequal(::SymbolicUtils.Symbolic, + ::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr}) + false +end +function Base.isequal( + ::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr}, + ::SymbolicUtils.Symbolic) + false +end +end diff --git a/ext/MTKLabelledArraysExt.jl b/ext/MTKLabelledArraysExt.jl new file mode 100644 index 0000000000..dda04d07da --- /dev/null +++ b/ext/MTKLabelledArraysExt.jl @@ -0,0 +1,18 @@ +module MTKLabelledArraysExt + +using ModelingToolkit, LabelledArrays +using ModelingToolkit: _defvar, toparam, variable, varnames_length_check +function ModelingToolkit.define_vars(u::Union{SLArray, LArray}, t) + [ModelingToolkit._defvar(x)(t) for x in LabelledArrays.symnames(typeof(u))] +end + +function ModelingToolkit.define_params(p::Union{SLArray, LArray}, names = nothing) + if names === nothing + [toparam(variable(x)) for x in LabelledArrays.symnames(typeof(p))] + else + varnames_length_check(p, names) + [toparam(variable(names[i])) for i in eachindex(p)] + end +end + +end diff --git a/src/ModelingToolkit.jl b/src/ModelingToolkit.jl index 00cbb38cac..9f69458528 100644 --- a/src/ModelingToolkit.jl +++ b/src/ModelingToolkit.jl @@ -2,68 +2,118 @@ $(DocStringExtensions.README) """ module ModelingToolkit +using PrecompileTools, Reexport +@recompile_invalidations begin + using StaticArrays + using Symbolics +end + +import SymbolicUtils +import SymbolicUtils: iscall, arguments, operation, maketerm, promote_symtype, + Symbolic, isadd, ismul, ispow, issym, FnType, + @rule, Rewriters, substitute, metadata, BasicSymbolic, + Sym, Term +using SymbolicUtils.Code +import SymbolicUtils.Code: toexpr +import SymbolicUtils.Rewriters: Chain, Postwalk, Prewalk, Fixpoint using DocStringExtensions -using AbstractTrees -using DiffEqBase, SciMLBase, Reexport -using Distributed -using StaticArrays, LinearAlgebra, SparseArrays, LabelledArrays -using Latexify, Unitful, ArrayInterface -using MacroTools -using UnPack: @unpack -using Setfield, ConstructionBase -using DiffEqJump -using DataStructures using SpecialFunctions, NaNMath -using RuntimeGeneratedFunctions +using DiffEqCallbacks +using Graphs +import ExprTools: splitdef, combinedef +import OrderedCollections +using DiffEqNoiseProcess: DiffEqNoiseProcess, WienerProcess + +using SymbolicIndexingInterface +using LinearAlgebra, SparseArrays +using InteractiveUtils +using JumpProcesses +using DataStructures using Base.Threads -import MacroTools: splitdef, combinedef, postwalk, striplines +using Latexify, Unitful, ArrayInterface +using Setfield, ConstructionBase import Libdl using DocStringExtensions using Base: RefValue -import IfElse - +using Combinatorics import Distributions - -RuntimeGeneratedFunctions.init(@__MODULE__) - -using RecursiveArrayTools - -import SymbolicUtils -import SymbolicUtils: istree, arguments, operation, similarterm, promote_symtype, - Symbolic, Term, Add, Mul, Pow, Sym, FnType, - @rule, Rewriters, substitute -using SymbolicUtils.Code -import SymbolicUtils.Code: toexpr -import SymbolicUtils.Rewriters: Chain, Postwalk, Prewalk, Fixpoint +import FunctionWrappersWrappers +import FunctionWrappers: FunctionWrapper +using URIs: URI +using SciMLStructures +using Compat +using AbstractTrees +using DiffEqBase, SciMLBase, ForwardDiff +using SciMLBase: StandardODEProblem, StandardNonlinearProblem, handle_varmap, TimeDomain, + PeriodicClock, Clock, SolverStepClock, ContinuousClock, OverrideInit, + NoInit +using Distributed import JuliaFormatter - +using MLStyle +import Moshi +using Moshi.Data: @data +using NonlinearSolve +import SCCNonlinearSolve using Reexport -@reexport using Symbolics -export @derivatives -using Symbolics: _parse_vars, value, makesym, @derivatives, get_variables, - exprs_occur_in, solve_for, build_expr +using RecursiveArrayTools +import Graphs: SimpleDiGraph, add_edge!, incidence_matrix +import BlockArrays: BlockArray, BlockedArray, Block, blocksize, blocksizes, blockpush!, + undef_blocks, blocks +using OffsetArrays: Origin +import CommonSolve +import EnumX + +using RuntimeGeneratedFunctions +using RuntimeGeneratedFunctions: drop_expr + +using Symbolics: degree +using Symbolics: _parse_vars, value, @derivatives, get_variables, + exprs_occur_in, symbolic_linear_solve, build_expr, unwrap, wrap, + VariableSource, getname, variable, Connection, connect, + NAMESPACE_SEPARATOR, set_scalar_metadata, setdefaultval, + initial_state, transition, activeState, entry, hasnode, + ticksInState, timeInState, fixpoint_sub, fast_substitute, + CallWithMetadata, CallWithParent +const NAMESPACE_SEPARATOR_SYMBOL = Symbol(NAMESPACE_SEPARATOR) import Symbolics: rename, get_variables!, _solve, hessian_sparsity, - jacobian_sparsity, islinear, _iszero, _isone, + jacobian_sparsity, isaffine, islinear, _iszero, _isone, tosymbol, lower_varname, diff2term, var_from_nested_derivative, BuildTargets, JuliaTarget, StanTarget, CTarget, MATLABTarget, ParallelForm, SerialForm, MultithreadedForm, build_function, - unflatten_long_ops, rhss, lhss, prettify_expr, gradient, + rhss, lhss, prettify_expr, gradient, jacobian, hessian, derivative, sparsejacobian, sparsehessian, - substituter + substituter, scalarize, getparent, hasderiv, hasdiff import DiffEqBase: @add_kwonly +export independent_variables, unknowns, parameters, full_parameters, continuous_events, + discrete_events +@reexport using Symbolics +@reexport using UnPack +RuntimeGeneratedFunctions.init(@__MODULE__) + +import DynamicQuantities, Unitful +const DQ = DynamicQuantities -import LightGraphs: SimpleDiGraph, add_edge! +import DifferentiationInterface as DI +using ADTypes: AutoForwardDiff -using Requires +export @derivatives for fun in [:toexpr] @eval begin function $fun(eq::Equation; kw...) - Expr(:(=), $fun(eq.lhs; kw...), $fun(eq.rhs; kw...)) + Expr(:call, :(==), $fun(eq.lhs; kw...), $fun(eq.rhs; kw...)) + end + + function $fun(ineq::Inequality; kw...) + if ineq.relational_op == Symbolics.leq + Expr(:call, :(<=), $fun(ineq.lhs; kw...), $fun(ineq.rhs; kw...)) + else + Expr(:call, :(>=), $fun(ineq.lhs; kw...), $fun(ineq.rhs; kw...)) + end end - $fun(eqs::AbstractArray; kw...) = map(eq->$fun(eq; kw...), eqs) + $fun(eqs::AbstractArray; kw...) = map(eq -> $fun(eq; kw...), eqs) $fun(x::Integer; kw...) = x $fun(x::AbstractFloat; kw...) = x end @@ -75,115 +125,226 @@ $(TYPEDEF) TODO """ abstract type AbstractSystem end -abstract type AbstractODESystem <: AbstractSystem end - -""" -$(TYPEDSIGNATURES) - -Get the set of independent variables for the given system. -""" -function independent_variables end - -""" -$(TYPEDSIGNATURES) - -Get the set of states for the given system. -""" -function states end - -""" -$(TYPEDSIGNATURES) - -Get the set of parameters variables for the given system. -""" -function parameters end - +abstract type AbstractTimeDependentSystem <: AbstractSystem end +abstract type AbstractTimeIndependentSystem <: AbstractSystem end +abstract type AbstractODESystem <: AbstractTimeDependentSystem end +abstract type AbstractMultivariateSystem <: AbstractSystem end +abstract type AbstractOptimizationSystem <: AbstractTimeIndependentSystem end +abstract type AbstractDiscreteSystem <: AbstractTimeDependentSystem end + +function independent_variable end + +# this has to be included early to deal with dependency issues +include("structural_transformation/bareiss.jl") +function complete end +function var_derivative! end +function var_derivative_graph! end include("bipartite_graph.jl") using .BipartiteGraphs include("variables.jl") include("parameters.jl") +include("independent_variables.jl") +include("constants.jl") include("utils.jl") include("domains.jl") +include("systems/index_cache.jl") +include("systems/parameter_buffer.jl") include("systems/abstractsystem.jl") +include("systems/model_parsing.jl") +include("systems/connectors.jl") +include("systems/analysis_points.jl") +include("systems/imperative_affect.jl") +include("systems/callbacks.jl") +include("systems/codegen_utils.jl") +include("systems/problem_utils.jl") +include("linearization.jl") + +include("systems/optimization/constraints_system.jl") +include("systems/optimization/optimizationsystem.jl") +include("systems/optimization/modelingtoolkitize.jl") +include("systems/nonlinear/nonlinearsystem.jl") +include("systems/nonlinear/homotopy_continuation.jl") include("systems/diffeqs/odesystem.jl") include("systems/diffeqs/sdesystem.jl") include("systems/diffeqs/abstractodesystem.jl") +include("systems/nonlinear/modelingtoolkitize.jl") +include("systems/nonlinear/initializesystem.jl") include("systems/diffeqs/first_order_transform.jl") include("systems/diffeqs/modelingtoolkitize.jl") -include("systems/diffeqs/validation.jl") include("systems/diffeqs/basic_transformations.jl") -include("systems/jumps/jumpsystem.jl") - -include("systems/nonlinear/nonlinearsystem.jl") - -include("systems/optimization/optimizationsystem.jl") +include("systems/discrete_system/discrete_system.jl") +include("systems/discrete_system/implicit_discrete_system.jl") -include("systems/control/controlsystem.jl") +include("systems/jumps/jumpsystem.jl") include("systems/pde/pdesystem.jl") -include("systems/reaction/reactionsystem.jl") -include("systems/dependency_graphs.jl") - -include("systems/discrete_system/discrete_system.jl") +include("systems/sparsematrixclil.jl") +include("systems/unit_check.jl") +include("systems/validation.jl") +include("systems/dependency_graphs.jl") +include("clock.jl") +include("discretedomain.jl") include("systems/systemstructure.jl") -using .SystemStructures +include("systems/clock_inference.jl") +include("systems/systems.jl") +include("systems/if_lifting.jl") +include("debugging.jl") include("systems/alias_elimination.jl") include("structural_transformation/StructuralTransformations.jl") + @reexport using .StructuralTransformations +include("inputoutput.jl") -export ODESystem, ODEFunction, ODEFunctionExpr, ODEProblemExpr, convert_system +for S in subtypes(ModelingToolkit.AbstractSystem) + S = nameof(S) + @eval convert_system(::Type{<:$S}, sys::$S) = sys +end + +const t_nounits = let + only(@independent_variables t) +end +const t_unitful = let + only(@independent_variables t [unit = Unitful.u"s"]) +end +const t = let + only(@independent_variables t [unit = DQ.u"s"]) +end + +const D_nounits = Differential(t_nounits) +const D_unitful = Differential(t_unitful) +const D = Differential(t) + +PrecompileTools.@compile_workload begin + using ModelingToolkit + @variables x(ModelingToolkit.t_nounits) + @named sys = ODESystem([ModelingToolkit.D_nounits(x) ~ -x], ModelingToolkit.t_nounits) + prob = ODEProblem(structural_simplify(sys), [x => 30.0], (0, 100), [], jac = true) + @mtkmodel __testmod__ begin + @constants begin + c = 1.0 + end + @structural_parameters begin + structp = false + end + if structp + @variables begin + x(t) = 0.0, [description = "foo", guess = 1.0] + end + else + @variables begin + x(t) = 0.0, [description = "foo w/o structp", guess = 1.0] + end + end + @parameters begin + a = 1.0, [description = "bar"] + if structp + b = 2 * a, [description = "if"] + else + c + end + end + @equations begin + x ~ a + b + end + end +end + +export AbstractTimeDependentSystem, + AbstractTimeIndependentSystem, + AbstractMultivariateSystem + +export ODESystem, + ODEFunction, ODEFunctionExpr, ODEProblemExpr, convert_system, + add_accumulations, System export DAEFunctionExpr, DAEProblemExpr -export SDESystem, SDEFunction, SDEFunctionExpr, SDESystemExpr +export SDESystem, SDEFunction, SDEFunctionExpr, SDEProblemExpr export SystemStructure +export DiscreteSystem, DiscreteProblem, DiscreteFunction, DiscreteFunctionExpr +export ImplicitDiscreteSystem, ImplicitDiscreteProblem, ImplicitDiscreteFunction, + ImplicitDiscreteFunctionExpr export JumpSystem export ODEProblem, SDEProblem +export NonlinearFunction, NonlinearFunctionExpr export NonlinearProblem, NonlinearProblemExpr -export OptimizationProblem, OptimizationProblemExpr -export AutoModelingToolkit +export IntervalNonlinearFunction, IntervalNonlinearFunctionExpr +export IntervalNonlinearProblem, IntervalNonlinearProblemExpr +export OptimizationProblem, OptimizationProblemExpr, constraints export SteadyStateProblem, SteadyStateProblemExpr -export JumpProblem, DiscreteProblem -export NonlinearSystem, OptimizationSystem -export ControlSystem -export alias_elimination, flatten, connect, @connector -export ode_order_lowering, liouville_transform -export runge_kutta_discretize +export JumpProblem +export NonlinearSystem, OptimizationSystem, ConstraintsSystem +export alias_elimination, flatten +export connect, domain_connect, @connector, Connection, AnalysisPoint, Flow, Stream, + instream +export initial_state, transition, activeState, entry, ticksInState, timeInState +export @component, @mtkmodel, @mtkbuild +export isinput, isoutput, getbounds, hasbounds, getguess, hasguess, isdisturbance, + istunable, getdist, hasdist, + tunable_parameters, isirreducible, getdescription, hasdescription, + hasunit, getunit, hasconnect, getconnect, + hasmisc, getmisc, state_priority +export ode_order_lowering, dae_order_lowering, liouville_transform, + change_independent_variable, substitute_component export PDESystem -export Reaction, ReactionSystem, ismassaction, oderatelaw, jumpratelaw export Differential, expand_derivatives, @derivatives -export IntervalDomain, ProductDomain, ⊗, CircleDomain export Equation, ConstrainedEquation export Term, Sym -export SymScope, LocalScope, ParentScope, GlobalScope -export independent_variable, states, parameters, equations, controls, observed, structure -export structural_simplify -export DiscreteSystem, DiscreteProblem - -export calculate_jacobian, generate_jacobian, generate_function +export SymScope, LocalScope, ParentScope, DelayParentScope, GlobalScope +export independent_variable, equations, controls, observed, full_equations +export initialization_equations, guesses, defaults, parameter_dependencies, hierarchy +export structural_simplify, expand_connections, linearize, linearization_function, + LinearizationProblem +export solve + +export calculate_jacobian, generate_jacobian, generate_function, generate_custom_function, + generate_W +export calculate_control_jacobian, generate_control_jacobian export calculate_tgrad, generate_tgrad export calculate_gradient, generate_gradient export calculate_factorized_W, generate_factorized_W export calculate_hessian, generate_hessian export calculate_massmatrix, generate_diffusion_function export stochastic_integral_transform -export initialize_system_structure +export TearingState export BipartiteGraph, equation_dependencies, variable_dependencies export eqeq_dependencies, varvar_dependencies export asgraph, asdigraph +export map_variables_to_equations export toexpr, get_variables export simplify, substitute export build_function export modelingtoolkitize -export @variables, @parameters -export @named, @nonamespace +export generate_initializesystem, Initial, isinitial + +export alg_equations, diff_equations, has_alg_equations, has_diff_equations +export get_alg_eqs, get_diff_eqs, has_alg_eqs, has_diff_eqs + +export @variables, @parameters, @independent_variables, @constants, @brownian +export @named, @nonamespace, @namespace, extend, compose, complete, toggle_namespacing +export debug_system + +#export ContinuousClock, Discrete, sampletime, input_timedomain, output_timedomain +#export has_discrete_domain, has_continuous_domain +#export is_discrete_domain, is_continuous_domain, is_hybrid_domain +export Sample, Hold, Shift, ShiftIndex, sampletime, SampleTime +export Clock, SolverStepClock, TimeDomain + +export MTKParameters, reorder_dimension_by_tunables!, reorder_dimension_by_tunables + +export HomotopyContinuationProblem + +export AnalysisPoint, get_sensitivity_function, get_comp_sensitivity_function, + get_looptransfer_function, get_sensitivity, get_comp_sensitivity, get_looptransfer, + open_loop +function FMIComponent end end # module diff --git a/src/bipartite_graph.jl b/src/bipartite_graph.jl index 8e7a63ba48..b6665646c9 100644 --- a/src/bipartite_graph.jl +++ b/src/bipartite_graph.jl @@ -1,33 +1,138 @@ module BipartiteGraphs -export BipartiteEdge, BipartiteGraph +import ModelingToolkit: complete + +export BipartiteEdge, BipartiteGraph, DiCMOBiGraph, Unassigned, unassigned, + Matching, InducedCondensationGraph, maximal_matching, + construct_augmenting_path!, MatchedCondensationGraph export 𝑠vertices, 𝑑vertices, has_𝑠vertex, has_𝑑vertex, 𝑠neighbors, 𝑑neighbors, - 𝑠edges, 𝑑edges, nsrcs, ndsts, SRC, DST + 𝑠edges, 𝑑edges, nsrcs, ndsts, SRC, DST, set_neighbors!, invview, + delete_srcs!, delete_dsts! using DocStringExtensions -using Reexport using UnPack using SparseArrays -@reexport using LightGraphs +using Graphs using Setfield +### Matching +struct Unassigned + global unassigned + const unassigned = Unassigned.instance +end +# Behaves as a scalar +Base.length(u::Unassigned) = 1 +Base.size(u::Unassigned) = () +Base.iterate(u::Unassigned) = (unassigned, nothing) +Base.iterate(u::Unassigned, state) = nothing + +Base.show(io::IO, ::Unassigned) = printstyled(io, "u"; color = :light_black) + +struct Matching{U, V <: AbstractVector} <: AbstractVector{Union{U, Int}} #=> :Unassigned =# + match::V + inv_match::Union{Nothing, V} +end +# These constructors work around https://github.com/JuliaLang/julia/issues/41948 +function Matching{V}(m::Matching) where {V} + eltype(m) === Union{V, Int} && return M + VUT = typeof(similar(m.match, Union{V, Int})) + Matching{V}(convert(VUT, m.match), + m.inv_match === nothing ? nothing : convert(VUT, m.inv_match)) +end +Matching(m::Matching) = m +Matching{U}(v::V) where {U, V <: AbstractVector} = Matching{U, V}(v, nothing) +function Matching{U}(v::V, iv::Union{V, Nothing}) where {U, V <: AbstractVector} + Matching{U, V}(v, iv) +end +function Matching(v::V) where {U, V <: AbstractVector{Union{U, Int}}} + Matching{@isdefined(U) ? U : Unassigned, V}(v, nothing) +end +function Matching(m::Int) + Matching{Unassigned}(Union{Int, Unassigned}[unassigned for _ in 1:m], nothing) +end +function Matching{U}(m::Int) where {U} + Matching{Union{Unassigned, U}}(Union{Int, Unassigned, U}[unassigned for _ in 1:m], + nothing) +end + +Base.size(m::Matching) = Base.size(m.match) +Base.getindex(m::Matching, i::Integer) = m.match[i] +Base.iterate(m::Matching, state...) = iterate(m.match, state...) +function Base.copy(m::Matching{U}) where {U} + Matching{U}(copy(m.match), m.inv_match === nothing ? nothing : copy(m.inv_match)) +end +function Base.setindex!(m::Matching{U}, v::Union{Integer, U}, i::Integer) where {U} + if m.inv_match !== nothing + oldv = m.match[i] + # TODO: maybe default Matching to always have an `inv_match`? + + # To maintain the invariant that `m.inv_match[m.match[i]] == i`, we need + # to unassign the matching at `m.inv_match[v]` if it exists. + if v isa Int && 1 <= v <= length(m.inv_match) && (iv = m.inv_match[v]) isa Int + m.match[iv] = unassigned + end + if isa(oldv, Int) + @assert m.inv_match[oldv] == i + m.inv_match[oldv] = unassigned + end + if isa(v, Int) + for vv in (length(m.inv_match) + 1):v + push!(m.inv_match, unassigned) + end + m.inv_match[v] = i + end + end + return m.match[i] = v +end + +function Base.push!(m::Matching, v) + push!(m.match, v) + if v isa Integer && m.inv_match !== nothing + for vv in (length(m.inv_match) + 1):v + push!(m.inv_match, unassigned) + end + m.inv_match[v] = length(m.match) + end +end + +function complete(m::Matching{U}, + N = maximum((x for x in m.match if isa(x, Int)); init = 0)) where {U} + m.inv_match !== nothing && return m + inv_match = Union{U, Int}[unassigned for _ in 1:N] + for (i, eq) in enumerate(m.match) + isa(eq, Int) || continue + inv_match[eq] = i + end + return Matching{U}(collect(m.match), inv_match) +end + +@noinline function require_complete(m::Matching) + m.inv_match === nothing && + throw(ArgumentError("Backwards matching not defined. `complete` the matching first.")) +end + +function invview(m::Matching{U, V}) where {U, V} + require_complete(m) + return Matching{U, V}(m.inv_match, m.match) +end + ### ### Edges & Vertex ### -@enum VertType SRC DST ALL +@enum VertType SRC DST -struct BipartiteEdge{I<:Integer} <: LightGraphs.AbstractEdge{I} +struct BipartiteEdge{I <: Integer} <: Graphs.AbstractEdge{I} src::I dst::I - function BipartiteEdge(src::I, dst::V) where {I,V} + function BipartiteEdge(src::I, dst::V) where {I, V} T = promote_type(I, V) new{T}(T(src), T(dst)) end end -LightGraphs.src(edge::BipartiteEdge) = edge.src -LightGraphs.dst(edge::BipartiteEdge) = edge.dst +Graphs.src(edge::BipartiteEdge) = edge.src +Graphs.dst(edge::BipartiteEdge) = edge.dst function Base.show(io::IO, edge::BipartiteEdge) @unpack src, dst = edge @@ -66,22 +171,157 @@ badjlist = [[1,2,5,6],[3,4,6]] bg = BipartiteGraph(7, fadjlist, badjlist) ``` """ -mutable struct BipartiteGraph{I<:Integer,F<:Vector{Vector{I}},B<:Union{Vector{Vector{I}},I},M} <: LightGraphs.AbstractGraph{I} +mutable struct BipartiteGraph{I <: Integer, M} <: Graphs.AbstractGraph{I} ne::Int - fadjlist::F # `fadjlist[src] => dsts` - badjlist::B # `badjlist[dst] => srcs` or `ndsts` + fadjlist::Vector{Vector{I}} # `fadjlist[src] => dsts` + badjlist::Union{Vector{Vector{I}}, I} # `badjlist[dst] => srcs` or `ndsts` metadata::M end -BipartiteGraph(ne::Integer, fadj::AbstractVector, badj::Union{AbstractVector,Integer}=maximum(maximum, fadj); metadata=nothing) = BipartiteGraph(ne, fadj, badj, metadata) +function BipartiteGraph(ne::Integer, fadj::AbstractVector, + badj::Union{AbstractVector, Integer} = maximum(maximum, fadj); + metadata = nothing) + BipartiteGraph(ne, fadj, badj, metadata) +end +function BipartiteGraph(fadj::AbstractVector, + badj::Union{AbstractVector, Integer} = maximum(maximum, fadj); + metadata = nothing) + BipartiteGraph(mapreduce(length, +, fadj; init = 0), fadj, badj, metadata) +end + +@noinline function require_complete(g::BipartiteGraph) + g.badjlist isa AbstractVector || + throw(ArgumentError("The graph has no back edges. Use `complete`.")) +end + +function invview(g::BipartiteGraph) + require_complete(g) + BipartiteGraph(g.ne, g.badjlist, g.fadjlist) +end + +function complete(g::BipartiteGraph{I}) where {I} + isa(g.badjlist, AbstractVector) && return g + badjlist = Vector{I}[Vector{I}() for _ in 1:(g.badjlist)] + for (s, l) in enumerate(g.fadjlist) + for d in l + push!(badjlist[d], s) + end + end + BipartiteGraph(g.ne, g.fadjlist, badjlist) +end + +# Matrix whose only purpose is to pretty-print the bipartite graph +struct BipartiteAdjacencyList + u::Union{Vector{Int}, Nothing} + highlight_u::Union{Set{Int}, Nothing} + match::Union{Int, Bool, Unassigned} +end +function BipartiteAdjacencyList(u::Union{Vector{Int}, Nothing}) + BipartiteAdjacencyList(u, nothing, unassigned) +end + +struct HighlightInt + i::Int + highlight::Symbol + match::Bool +end +Base.typeinfo_implicit(::Type{HighlightInt}) = true +function Base.show(io::IO, hi::HighlightInt) + if hi.match + printstyled(io, "(", color = hi.highlight) + printstyled(io, hi.i, color = hi.highlight) + printstyled(io, ")", color = hi.highlight) + else + printstyled(io, hi.i, color = hi.highlight) + end +end + +function Base.show(io::IO, l::BipartiteAdjacencyList) + if l.match === true + printstyled(io, "∫ ", color = :cyan) + else + printstyled(io, " ") + end + if l.u === nothing + printstyled(io, '⋅', color = :light_black) + elseif isempty(l.u) + printstyled(io, '∅', color = :light_black) + elseif l.highlight_u === nothing + print(io, l.u) + else + match = l.match + isa(match, Bool) && (match = unassigned) + function choose_color(i) + solvable = i in l.highlight_u + matched = i == match + if !matched && solvable + :default + elseif !matched && !solvable + :light_black + elseif matched && solvable + :light_yellow + elseif matched && !solvable + :magenta + end + end + if !isempty(setdiff(l.highlight_u, l.u)) + # Only for debugging, shouldn't happen in practice + print(io, + map(union(l.u, l.highlight_u)) do i + HighlightInt(i, !(i in l.u) ? :light_red : choose_color(i), + i == match) + end) + else + print(io, map(l.u) do i + HighlightInt(i, choose_color(i), i == match) + end) + end + end +end + +struct Label + s::String + c::Symbol +end +Label(s::AbstractString) = Label(s, :nothing) +Label(x::Integer) = Label(string(x)) +Base.show(io::IO, l::Label) = printstyled(io, l.s, color = l.c) + +struct BipartiteGraphPrintMatrix <: + AbstractMatrix{Union{Label, Int, BipartiteAdjacencyList}} + bpg::BipartiteGraph +end +Base.size(bgpm::BipartiteGraphPrintMatrix) = (max(nsrcs(bgpm.bpg), ndsts(bgpm.bpg)) + 1, 3) +function Base.getindex(bgpm::BipartiteGraphPrintMatrix, i::Integer, j::Integer) + checkbounds(bgpm, i, j) + if i == 1 + return (Label.(("#", "src", "dst")))[j] + elseif j == 1 + return i - 1 + elseif j == 2 + return BipartiteAdjacencyList(i - 1 <= nsrcs(bgpm.bpg) ? + 𝑠neighbors(bgpm.bpg, i - 1) : nothing) + elseif j == 3 + return BipartiteAdjacencyList(i - 1 <= ndsts(bgpm.bpg) ? + 𝑑neighbors(bgpm.bpg, i - 1) : nothing) + else + @assert false + end +end + +function Base.show(io::IO, b::BipartiteGraph) + print(io, "BipartiteGraph with (", length(b.fadjlist), ", ", + isa(b.badjlist, Int) ? b.badjlist : length(b.badjlist), ") (𝑠,𝑑)-vertices\n") + Base.print_matrix(io, BipartiteGraphPrintMatrix(b)) +end """ ```julia -Base.isequal(bg1::BipartiteGraph{T}, bg2::BipartiteGraph{T}) where {T<:Integer} +Base.isequal(bg1::BipartiteGraph{T}, bg2::BipartiteGraph{T}) where {T <: Integer} ``` Test whether two [`BipartiteGraph`](@ref)s are equal. """ -function Base.isequal(bg1::BipartiteGraph{T}, bg2::BipartiteGraph{T}) where {T<:Integer} +function Base.isequal(bg1::BipartiteGraph{T}, bg2::BipartiteGraph{T}) where {T <: Integer} iseq = (bg1.ne == bg2.ne) iseq &= (bg1.fadjlist == bg2.fadjlist) iseq &= (bg1.badjlist == bg2.badjlist) @@ -93,13 +333,18 @@ $(SIGNATURES) Build an empty `BipartiteGraph` with `nsrcs` sources and `ndsts` destinations. """ -function BipartiteGraph(nsrcs::T, ndsts::T, backedge::Val{B}=Val(true); metadata=nothing) where {T,B} - fadjlist = map(_->T[], 1:nsrcs) - badjlist = B ? map(_->T[], 1:ndsts) : ndsts +function BipartiteGraph(nsrcs::T, ndsts::T, backedge::Val{B} = Val(true); + metadata = nothing) where {T, B} + fadjlist = map(_ -> T[], 1:nsrcs) + badjlist = B ? map(_ -> T[], 1:ndsts) : ndsts BipartiteGraph(0, fadjlist, badjlist, metadata) end -Base.eltype(::Type{<:BipartiteGraph{I}}) where I = I +function Base.copy(bg::BipartiteGraph) + BipartiteGraph(bg.ne, map(copy, bg.fadjlist), map(copy, bg.badjlist), + deepcopy(bg.metadata)) +end +Base.eltype(::Type{<:BipartiteGraph{I}}) where {I} = I function Base.empty!(g::BipartiteGraph) foreach(empty!, g.fadjlist) g.badjlist isa AbstractVector && foreach(empty!, g.badjlist) @@ -111,44 +356,98 @@ function Base.empty!(g::BipartiteGraph) end Base.length(::BipartiteGraph) = error("length is not well defined! Use `ne` or `nv`.") -@noinline throw_no_back_edges() = throw(ArgumentError("The graph has no back edges.")) - -if isdefined(LightGraphs, :has_contiguous_vertices) - LightGraphs.has_contiguous_vertices(::Type{<:BipartiteGraph}) = false +if isdefined(Graphs, :has_contiguous_vertices) + Graphs.has_contiguous_vertices(::Type{<:BipartiteGraph}) = false end -LightGraphs.is_directed(::Type{<:BipartiteGraph}) = false -LightGraphs.vertices(g::BipartiteGraph) = (𝑠vertices(g), 𝑑vertices(g)) +Graphs.is_directed(::Type{<:BipartiteGraph}) = false +Graphs.vertices(g::BipartiteGraph) = (𝑠vertices(g), 𝑑vertices(g)) 𝑠vertices(g::BipartiteGraph) = axes(g.fadjlist, 1) -𝑑vertices(g::BipartiteGraph) = g.badjlist isa AbstractVector ? axes(g.badjlist, 1) : Base.OneTo(g.badjlist) +function 𝑑vertices(g::BipartiteGraph) + g.badjlist isa AbstractVector ? axes(g.badjlist, 1) : Base.OneTo(g.badjlist) +end has_𝑠vertex(g::BipartiteGraph, v::Integer) = v in 𝑠vertices(g) has_𝑑vertex(g::BipartiteGraph, v::Integer) = v in 𝑑vertices(g) -𝑠neighbors(g::BipartiteGraph, i::Integer, with_metadata::Val{M}=Val(false)) where M = M ? zip(g.fadjlist[i], g.metadata[i]) : g.fadjlist[i] -function 𝑑neighbors(g::BipartiteGraph, j::Integer, with_metadata::Val{M}=Val(false)) where M - g.badjlist isa AbstractVector || throw_no_back_edges() +function 𝑠neighbors(g::BipartiteGraph, i::Integer, + with_metadata::Val{M} = Val(false)) where {M} + M ? zip(g.fadjlist[i], g.metadata[i]) : g.fadjlist[i] +end +function 𝑑neighbors(g::BipartiteGraph, j::Integer, + with_metadata::Val{M} = Val(false)) where {M} + require_complete(g) M ? zip(g.badjlist[j], (g.metadata[i][j] for i in g.badjlist[j])) : g.badjlist[j] end -LightGraphs.ne(g::BipartiteGraph) = g.ne -LightGraphs.nv(g::BipartiteGraph) = sum(length, vertices(g)) -LightGraphs.edgetype(g::BipartiteGraph{I}) where I = BipartiteEdge{I} +Graphs.ne(g::BipartiteGraph) = g.ne +Graphs.nv(g::BipartiteGraph) = sum(length, vertices(g)) +Graphs.edgetype(g::BipartiteGraph{I}) where {I} = BipartiteEdge{I} nsrcs(g::BipartiteGraph) = length(𝑠vertices(g)) ndsts(g::BipartiteGraph) = length(𝑑vertices(g)) -function LightGraphs.has_edge(g::BipartiteGraph, edge::BipartiteEdge) +function Graphs.has_edge(g::BipartiteGraph, edge::BipartiteEdge) @unpack src, dst = edge (src in 𝑠vertices(g) && dst in 𝑑vertices(g)) || return false # edge out of bounds - insorted(𝑠neighbors(src), dst) + insorted(dst, 𝑠neighbors(g, src)) +end +Base.in(edge::BipartiteEdge, g::BipartiteGraph) = Graphs.has_edge(g, edge) + +### Maximal matching +""" + construct_augmenting_path!(m::Matching, g::BipartiteGraph, vsrc, dstfilter, vcolor=falses(ndsts(g)), ecolor=nothing) -> path_found::Bool + +Try to construct an augmenting path in matching and if such a path is found, +update the matching accordingly. +""" +function construct_augmenting_path!(matching::Matching, g::BipartiteGraph, vsrc, dstfilter, + dcolor = falses(ndsts(g)), scolor = nothing) + scolor === nothing || (scolor[vsrc] = true) + + # if a `vdst` is unassigned and the edge `vsrc <=> vdst` exists + for vdst in 𝑠neighbors(g, vsrc) + if dstfilter(vdst) && matching[vdst] === unassigned + matching[vdst] = vsrc + return true + end + end + + # for every `vsrc` such that edge `vsrc <=> vdst` exists and `vdst` is uncolored + for vdst in 𝑠neighbors(g, vsrc) + (dstfilter(vdst) && !dcolor[vdst]) || continue + dcolor[vdst] = true + if construct_augmenting_path!(matching, g, matching[vdst], dstfilter, dcolor, + scolor) + matching[vdst] = vsrc + return true + end + end + return false +end + +""" + maximal_matching(g::BipartiteGraph, [srcfilter], [dstfilter]) + +For a bipartite graph `g`, construct a maximal matching of destination to source +vertices, subject to the constraint that vertices for which `srcfilter` or `dstfilter`, +return `false` may not be matched. +""" +function maximal_matching(g::BipartiteGraph, srcfilter = vsrc -> true, + dstfilter = vdst -> true, ::Type{U} = Unassigned) where {U} + matching = Matching{U}(max(nsrcs(g), ndsts(g))) + foreach(Iterators.filter(srcfilter, 𝑠vertices(g))) do vsrc + construct_augmenting_path!(matching, g, vsrc, dstfilter) + end + return matching end ### ### Populate ### -struct NoMetadata -end +struct NoMetadata end const NO_METADATA = NoMetadata() -LightGraphs.add_edge!(g::BipartiteGraph, i::Integer, j::Integer, md=NO_METADATA) = add_edge!(g, BipartiteEdge(i, j), md) -function LightGraphs.add_edge!(g::BipartiteGraph, edge::BipartiteEdge, md=NO_METADATA) +function Graphs.add_edge!(g::BipartiteGraph, i::Integer, j::Integer, md = NO_METADATA) + add_edge!(g, BipartiteEdge(i, j), md) +end +function Graphs.add_edge!(g::BipartiteGraph, edge::BipartiteEdge, md = NO_METADATA) @unpack fadjlist, badjlist = g s, d = src(edge), dst(edge) (has_𝑠vertex(g, s) && has_𝑑vertex(g, d)) || error("edge ($edge) out of range.") @@ -169,39 +468,98 @@ function LightGraphs.add_edge!(g::BipartiteGraph, edge::BipartiteEdge, md=NO_MET return true # edge successfully added end -function LightGraphs.add_vertex!(g::BipartiteGraph{T}, type::VertType) where T +function Graphs.rem_edge!(g::BipartiteGraph, i::Integer, j::Integer) + Graphs.rem_edge!(g, BipartiteEdge(i, j)) +end +function Graphs.rem_edge!(g::BipartiteGraph, edge::BipartiteEdge) + @unpack fadjlist, badjlist = g + s, d = src(edge), dst(edge) + (has_𝑠vertex(g, s) && has_𝑑vertex(g, d)) || error("edge ($edge) out of range.") + @inbounds list = fadjlist[s] + index = searchsortedfirst(list, d) + @inbounds (index <= length(list) && list[index] == d) || + error("graph does not have edge $edge") + deleteat!(list, index) + g.ne -= 1 + if badjlist isa AbstractVector + @inbounds list = badjlist[d] + index = searchsortedfirst(list, s) + deleteat!(list, index) + end + return true # edge successfully deleted +end + +function Graphs.add_vertex!(g::BipartiteGraph{T}, type::VertType) where {T} if type === DST if g.badjlist isa AbstractVector push!(g.badjlist, T[]) + return length(g.badjlist) else g.badjlist += 1 + return g.badjlist end elseif type === SRC push!(g.fadjlist, T[]) + return length(g.fadjlist) else error("type ($type) must be either `DST` or `SRC`") end - return true # vertex successfully added end +function set_neighbors!(g::BipartiteGraph, i::Integer, new_neighbors) + old_neighbors = g.fadjlist[i] + old_nneighbors = length(old_neighbors) + new_nneighbors = length(new_neighbors) + g.ne += new_nneighbors - old_nneighbors + if isa(g.badjlist, AbstractVector) + for n in old_neighbors + @inbounds list = g.badjlist[n] + index = searchsortedfirst(list, i) + if 1 <= index <= length(list) && list[index] == i + deleteat!(list, index) + end + end + for n in new_neighbors + @inbounds list = g.badjlist[n] + index = searchsortedfirst(list, i) + if !(1 <= index <= length(list) && list[index] == i) + insert!(list, index, i) + end + end + end + if iszero(new_nneighbors) # this handles Tuple as well + # Warning: Aliases old_neighbors + empty!(g.fadjlist[i]) + else + g.fadjlist[i] = unique!(sort(new_neighbors)) + end +end + +function delete_srcs!(g::BipartiteGraph, srcs) + for s in srcs + set_neighbors!(g, s, ()) + end + g +end +delete_dsts!(g::BipartiteGraph, srcs) = delete_srcs!(invview(g), srcs) + ### ### Edges iteration ### -LightGraphs.edges(g::BipartiteGraph) = BipartiteEdgeIter(g, Val(ALL)) +Graphs.edges(g::BipartiteGraph) = BipartiteEdgeIter(g, Val(SRC)) 𝑠edges(g::BipartiteGraph) = BipartiteEdgeIter(g, Val(SRC)) 𝑑edges(g::BipartiteGraph) = BipartiteEdgeIter(g, Val(DST)) -struct BipartiteEdgeIter{T,G} <: LightGraphs.AbstractEdgeIter +struct BipartiteEdgeIter{T, G} <: Graphs.AbstractEdgeIter g::G type::Val{T} end Base.length(it::BipartiteEdgeIter) = ne(it.g) -Base.length(it::BipartiteEdgeIter{ALL}) = 2ne(it.g) - Base.eltype(it::BipartiteEdgeIter) = edgetype(it.g) -function Base.iterate(it::BipartiteEdgeIter{SRC,<:BipartiteGraph{T}}, state=(1, 1, SRC)) where T +function Base.iterate(it::BipartiteEdgeIter{SRC, <:BipartiteGraph{T}}, + state = (1, 1, SRC)) where {T} @unpack g = it neqs = nsrcs(g) neqs == 0 && return nothing @@ -222,7 +580,8 @@ function Base.iterate(it::BipartiteEdgeIter{SRC,<:BipartiteGraph{T}}, state=(1, return nothing end -function Base.iterate(it::BipartiteEdgeIter{DST,<:BipartiteGraph{T}}, state=(1, 1, DST)) where T +function Base.iterate(it::BipartiteEdgeIter{DST, <:BipartiteGraph{T}}, + state = (1, 1, DST)) where {T} @unpack g = it nvars = ndsts(g) nvars == 0 && return nothing @@ -242,25 +601,10 @@ function Base.iterate(it::BipartiteEdgeIter{DST,<:BipartiteGraph{T}}, state=(1, return nothing end -function Base.iterate(it::BipartiteEdgeIter{ALL,<:BipartiteGraph}, state=nothing) - if state === nothing - ss = iterate((@set it.type = Val(SRC))) - elseif state[3] === SRC - ss = iterate((@set it.type = Val(SRC)), state) - elseif state[3] == DST - ss = iterate((@set it.type = Val(DST)), state) - end - if ss === nothing && state[3] == SRC - return iterate((@set it.type = Val(DST))) - else - return ss - end -end - ### ### Utils ### -function LightGraphs.incidence_matrix(g::BipartiteGraph, val=true) +function Graphs.incidence_matrix(g::BipartiteGraph, val = true) I = Int[] J = Int[] for i in 𝑠vertices(g), n in 𝑠neighbors(g, i) @@ -270,4 +614,222 @@ function LightGraphs.incidence_matrix(g::BipartiteGraph, val=true) S = sparse(I, J, val, nsrcs(g), ndsts(g)) end +""" + struct DiCMOBiGraph + +This data structure implements a "directed, contracted, matching-oriented" view of an +original (undirected) bipartite graph. It has two modes, depending on the `Transposed` +flag, which switches the direction of the induced matching. + +Essentially the graph adapter performs two largely orthogonal functions +[`Transposed == true` differences are indicated in square brackets]: + + 1. It pairs an undirected bipartite graph with a matching of the destination vertex. + + This matching is used to induce an orientation on the otherwise undirected graph: + Matched edges pass from destination to source [source to destination], all other edges + pass in the opposite direction. + + 2. It exposes the graph view obtained by contracting the destination [source] vertices + along the matched edges. + +The result of this operation is an induced, directed graph on the source [destination] vertices. +The resulting graph has a few desirable properties. In particular, this graph +is acyclic if and only if the induced directed graph on the original bipartite +graph is acyclic. + +# Hypergraph interpretation + +Consider the bipartite graph `B` as the incidence graph of some hypergraph `H`. +Note that a matching `M` on `B` in the above sense is equivalent to determining +an (1,n)-orientation on the hypergraph (i.e. each directed hyperedge has exactly +one head, but any arbitrary number of tails). In this setting, this is simply +the graph formed by expanding each directed hyperedge into `n` ordinary edges +between the same vertices. +""" +mutable struct DiCMOBiGraph{Transposed, I, G <: BipartiteGraph{I}, M <: Matching} <: + Graphs.AbstractGraph{I} + graph::G + ne::Union{Missing, Int} + matching::M + function DiCMOBiGraph{Transposed}(g::G, ne::Union{Missing, Int}, + m::M) where {Transposed, I, G <: BipartiteGraph{I}, M} + new{Transposed, I, G, M}(g, ne, m) + end +end +function DiCMOBiGraph{Transposed}(g::BipartiteGraph) where {Transposed} + DiCMOBiGraph{Transposed}(g, 0, Matching(ndsts(g))) +end +function DiCMOBiGraph{Transposed}(g::BipartiteGraph, m::M) where {Transposed, M} + DiCMOBiGraph{Transposed}(g, missing, m) +end + +function invview(g::DiCMOBiGraph{Transposed}) where {Transposed} + DiCMOBiGraph{!Transposed}(invview(g.graph), g.ne, invview(g.matching)) +end + +Graphs.is_directed(::Type{<:DiCMOBiGraph}) = true +function Graphs.nv(g::DiCMOBiGraph{Transposed}) where {Transposed} + Transposed ? ndsts(g.graph) : nsrcs(g.graph) +end +function Graphs.vertices(g::DiCMOBiGraph{Transposed}) where {Transposed} + Transposed ? 𝑑vertices(g.graph) : 𝑠vertices(g.graph) +end + +struct CMONeighbors{Transposed, V} + g::DiCMOBiGraph{Transposed} + v::V + function CMONeighbors{Transposed}(g::DiCMOBiGraph{Transposed}, + v::V) where {Transposed, V} + new{Transposed, V}(g, v) + end +end + +Graphs.outneighbors(g::DiCMOBiGraph{false}, v) = CMONeighbors{false}(g, v) +Graphs.inneighbors(g::DiCMOBiGraph{false}, v) = inneighbors(invview(g), v) +Base.iterate(c::CMONeighbors{false}) = iterate(c, (c.g.graph.fadjlist[c.v],)) +function Base.iterate(c::CMONeighbors{false}, (l, state...)) + while true + r = iterate(l, state...) + r === nothing && return nothing + # If this is a matched edge, skip it, it's reversed in the induced + # directed graph. Otherwise, if there is no matching for this destination + # edge, also skip it, since it got deleted in the contraction. + vsrc = c.g.matching[r[1]] + if vsrc === c.v || !isa(vsrc, Int) + state = (r[2],) + continue + end + return vsrc, (l, r[2]) + end +end +Base.length(c::CMONeighbors{false}) = count(_ -> true, c) + +liftint(f, x) = (!isa(x, Int)) ? nothing : f(x) +liftnothing(f, x) = x === nothing ? nothing : f(x) + +_vsrc(c::CMONeighbors{true}) = c.g.matching[c.v] +_neighbors(c::CMONeighbors{true}) = liftint(vsrc -> c.g.graph.fadjlist[vsrc], _vsrc(c)) +Base.length(c::CMONeighbors{true}) = something(liftnothing(length, _neighbors(c)), 1) - 1 +Graphs.inneighbors(g::DiCMOBiGraph{true}, v) = CMONeighbors{true}(g, v) +Graphs.outneighbors(g::DiCMOBiGraph{true}, v) = outneighbors(invview(g), v) +Base.iterate(c::CMONeighbors{true}) = liftnothing(ns -> iterate(c, (ns,)), _neighbors(c)) +function Base.iterate(c::CMONeighbors{true}, (l, state...)) + while true + r = iterate(l, state...) + r === nothing && return nothing + if r[1] === c.v + state = (r[2],) + continue + end + return r[1], (l, r[2]) + end +end + +function _edges(g::DiCMOBiGraph{Transposed}) where {Transposed} + Transposed ? + ((w => v for w in inneighbors(g, v)) for v in vertices(g)) : + ((v => w for w in outneighbors(g, v)) for v in vertices(g)) +end + +Graphs.edges(g::DiCMOBiGraph) = (Graphs.SimpleEdge(p) for p in Iterators.flatten(_edges(g))) +function Graphs.ne(g::DiCMOBiGraph) + if g.ne === missing + g.ne = mapreduce(x -> length(x.iter), +, _edges(g)) + end + return g.ne +end + +Graphs.has_edge(g::DiCMOBiGraph{true}, a, b) = a in inneighbors(g, b) +Graphs.has_edge(g::DiCMOBiGraph{false}, a, b) = b in outneighbors(g, a) +# This definition is required for `induced_subgraph` to work +(::Type{<:DiCMOBiGraph})(n::Integer) = SimpleDiGraph(n) + +# Condensation Graphs +abstract type AbstractCondensationGraph <: AbstractGraph{Int} end +function (T::Type{<:AbstractCondensationGraph})(g, sccs::Vector{Union{Int, Vector{Int}}}) + scc_assignment = Vector{Int}(undef, isa(g, BipartiteGraph) ? ndsts(g) : nv(g)) + for (i, c) in enumerate(sccs) + for v in c + scc_assignment[v] = i + end + end + T(g, sccs, scc_assignment) +end +function (T::Type{<:AbstractCondensationGraph})(g, sccs::Vector{Vector{Int}}) + T(g, Vector{Union{Int, Vector{Int}}}(sccs)) +end + +Graphs.is_directed(::Type{<:AbstractCondensationGraph}) = true +Graphs.nv(icg::AbstractCondensationGraph) = length(icg.sccs) +Graphs.vertices(icg::AbstractCondensationGraph) = Base.OneTo(nv(icg)) + +""" + struct MatchedCondensationGraph + +For some bipartite-graph and an orientation induced on its destination contraction, +records the condensation DAG of the digraph formed by the orientation. I.e. this +is a DAG of connected components formed by the destination vertices of some +underlying bipartite graph. +N.B.: This graph does not store explicit neighbor relations of the sccs. +Therefor, the edge multiplicity is derived from the underlying bipartite graph, +i.e. this graph is not strict. +""" +struct MatchedCondensationGraph{G <: DiCMOBiGraph} <: AbstractCondensationGraph + graph::G + # Records the members of a strongly connected component. For efficiency, + # trivial sccs (with one vertex member) are stored inline. Note: the sccs + # here need not be stored in topological order. + sccs::Vector{Union{Int, Vector{Int}}} + # Maps the vertices back to the scc of which they are a part + scc_assignment::Vector{Int} +end + +function Graphs.outneighbors(mcg::MatchedCondensationGraph, cc::Integer) + Iterators.flatten((mcg.scc_assignment[v′] + for v′ in outneighbors(mcg.graph, v) if mcg.scc_assignment[v′] != cc) + for v in mcg.sccs[cc]) +end + +function Graphs.inneighbors(mcg::MatchedCondensationGraph, cc::Integer) + Iterators.flatten((mcg.scc_assignment[v′] + for v′ in inneighbors(mcg.graph, v) if mcg.scc_assignment[v′] != cc) + for v in mcg.sccs[cc]) +end + +""" + struct InducedCondensationGraph + +For some bipartite-graph and a topologicall sorted list of connected components, +represents the condensation DAG of the digraph formed by the orientation. I.e. this +is a DAG of connected components formed by the destination vertices of some +underlying bipartite graph. +N.B.: This graph does not store explicit neighbor relations of the sccs. +Therefor, the edge multiplicity is derived from the underlying bipartite graph, +i.e. this graph is not strict. +""" +struct InducedCondensationGraph{G <: BipartiteGraph} <: AbstractCondensationGraph + graph::G + # Records the members of a strongly connected component. For efficiency, + # trivial sccs (with one vertex member) are stored inline. Note: the sccs + # here are stored in topological order. + sccs::Vector{Union{Int, Vector{Int}}} + # Maps the vertices back to the scc of which they are a part + scc_assignment::Vector{Int} +end + +function _neighbors(icg::InducedCondensationGraph, cc::Integer) + Iterators.flatten(Iterators.flatten(icg.graph.fadjlist[vsrc] + for vsrc in icg.graph.badjlist[v]) + for v in icg.sccs[cc]) +end + +function Graphs.outneighbors(icg::InducedCondensationGraph, v::Integer) + (icg.scc_assignment[n] for n in _neighbors(icg, v) if icg.scc_assignment[n] > v) +end + +function Graphs.inneighbors(icg::InducedCondensationGraph, v::Integer) + (icg.scc_assignment[n] for n in _neighbors(icg, v) if icg.scc_assignment[n] < v) +end + end # module diff --git a/src/clock.jl b/src/clock.jl new file mode 100644 index 0000000000..1c9ed89128 --- /dev/null +++ b/src/clock.jl @@ -0,0 +1,112 @@ +@data InferredClock begin + Inferred + InferredDiscrete +end + +const InferredTimeDomain = InferredClock.Type +using .InferredClock: Inferred, InferredDiscrete + +Base.Broadcast.broadcastable(x::InferredTimeDomain) = Ref(x) + +struct VariableTimeDomain end +Symbolics.option_to_metadata_type(::Val{:timedomain}) = VariableTimeDomain + +is_concrete_time_domain(::TimeDomain) = true +is_concrete_time_domain(_) = false + +""" + is_continuous_domain(x) + +true if `x` contains only continuous-domain signals. +See also [`has_continuous_domain`](@ref) +""" +function is_continuous_domain(x) + issym(x) && return getmetadata(x, VariableTimeDomain, false) == ContinuousClock() + !has_discrete_domain(x) && has_continuous_domain(x) +end + +get_time_domain(_, x) = get_time_domain(x) +function get_time_domain(x) + if iscall(x) && operation(x) isa Operator + output_timedomain(x) + else + getmetadata(x, VariableTimeDomain, nothing) + end +end +get_time_domain(x::Num) = get_time_domain(value(x)) + +has_time_domain(_, x) = has_time_domain(x) +""" + has_time_domain(x) + +Determine if variable `x` has a time-domain attributed to it. +""" +function has_time_domain(x::Symbolic) + # getmetadata(x, ContinuousClock, nothing) !== nothing || + # getmetadata(x, Discrete, nothing) !== nothing + getmetadata(x, VariableTimeDomain, nothing) !== nothing +end +has_time_domain(x::Num) = has_time_domain(value(x)) +has_time_domain(x) = false + +for op in [Differential] + @eval input_timedomain(::$op, arg = nothing) = ContinuousClock() + @eval output_timedomain(::$op, arg = nothing) = ContinuousClock() +end + +""" + has_discrete_domain(x) + +true if `x` contains discrete signals (`x` may or may not contain continuous-domain signals). `x` may be an expression or equation. +See also [`is_discrete_domain`](@ref) +""" +function has_discrete_domain(x) + issym(x) && return is_discrete_domain(x) + hasshift(x) || hassample(x) || hashold(x) +end + +""" + has_continuous_domain(x) + +true if `x` contains continuous signals (`x` may or may not contain discrete-domain signals). `x` may be an expression or equation. +See also [`is_continuous_domain`](@ref) +""" +function has_continuous_domain(x) + issym(x) && return is_continuous_domain(x) + hasderiv(x) || hasdiff(x) || hassample(x) || hashold(x) +end + +""" + is_hybrid_domain(x) + +true if `x` contains both discrete and continuous-domain signals. `x` may be an expression or equation. +""" +is_hybrid_domain(x) = has_discrete_domain(x) && has_continuous_domain(x) + +""" + is_discrete_domain(x) + +true if `x` contains only discrete-domain signals. +See also [`has_discrete_domain`](@ref) +""" +function is_discrete_domain(x) + if hasmetadata(x, VariableTimeDomain) || issym(x) + return is_discrete_time_domain(getmetadata(x, VariableTimeDomain, false)) + end + !has_discrete_domain(x) && has_continuous_domain(x) +end + +sampletime(c) = Moshi.Match.@match c begin + PeriodicClock(dt) => dt + _ => nothing +end + +struct ClockInferenceException <: Exception + msg::Any +end + +function Base.showerror(io::IO, cie::ClockInferenceException) + print(io, "ClockInferenceException: ", cie.msg) +end + +struct IntegerSequence end diff --git a/src/constants.jl b/src/constants.jl new file mode 100644 index 0000000000..a0a38fd057 --- /dev/null +++ b/src/constants.jl @@ -0,0 +1,50 @@ +import SymbolicUtils: symtype, term, hasmetadata, issym +struct MTKConstantCtx end + +isconstant(x::Num) = isconstant(unwrap(x)) +""" +Test whether `x` is a constant-type Sym. +""" +function isconstant(x) + x = unwrap(x) + x isa Symbolic && getmetadata(x, MTKConstantCtx, false) +end + +""" + toconstant(s) + +Maps the parameter to a constant. The parameter must have a default. +""" +function toconstant(s) + hasmetadata(s, Symbolics.VariableDefaultValue) || + throw(ArgumentError("Constant `$(s)` must be assigned a default value.")) + setmetadata(s, MTKConstantCtx, true) +end + +toconstant(s::Num) = wrap(toconstant(value(s))) + +""" +$(SIGNATURES) + +Define one or more constants. + +See also [`@independent_variables`](@ref), [`@parameters`](@ref) and [`@variables`](@ref). +""" +macro constants(xs...) + Symbolics._parse_vars(:constants, + Real, + xs, + toconstant) |> esc +end + +""" +Substitute all `@constants` in the given expression +""" +function subs_constants(eqs) + consts = collect_constants(eqs) + if !isempty(consts) + csubs = Dict(c => getdefault(c) for c in consts) + eqs = substitute(eqs, csubs) + end + return eqs +end diff --git a/src/debugging.jl b/src/debugging.jl new file mode 100644 index 0000000000..c16b47c2e3 --- /dev/null +++ b/src/debugging.jl @@ -0,0 +1,100 @@ +struct LoggedFunctionException <: Exception + msg::String +end +struct LoggedFun{F} + f::F + args::Any + error_nonfinite::Bool +end +function LoggedFunctionException(lf::LoggedFun, args, msg) + LoggedFunctionException( + "Function $(lf.f)($(join(lf.args, ", "))) " * msg * " with input" * + join("\n " .* string.(lf.args .=> args)) # one line for each "var => val" for readability + ) +end +Base.showerror(io::IO, err::LoggedFunctionException) = print(io, err.msg) +Base.nameof(lf::LoggedFun) = nameof(lf.f) +SymbolicUtils.promote_symtype(::LoggedFun, Ts...) = Real +function (lf::LoggedFun)(args...) + val = try + lf.f(args...) # try to call with numerical input, as usual + catch err + throw(LoggedFunctionException(lf, args, "errors")) # Julia automatically attaches original error message + end + if lf.error_nonfinite && !isfinite(val) + throw(LoggedFunctionException(lf, args, "output non-finite value $val")) + end + return val +end + +function logged_fun(f, args...; error_nonfinite = true) # remember to update error_nonfinite in debug_system() docstring + # Currently we don't really support complex numbers + term(LoggedFun(f, args, error_nonfinite), args..., type = Real) +end + +function debug_sub(eq::Equation, funcs; kw...) + debug_sub(eq.lhs, funcs; kw...) ~ debug_sub(eq.rhs, funcs; kw...) +end +function debug_sub(ex, funcs; kw...) + iscall(ex) || return ex + f = operation(ex) + args = map(ex -> debug_sub(ex, funcs; kw...), arguments(ex)) + f in funcs ? logged_fun(f, args...; kw...) : + maketerm(typeof(ex), f, args, metadata(ex)) +end + +""" + $(TYPEDSIGNATURES) + +A function which returns `NaN` if `condition` fails, and `0.0` otherwise. +""" +function _nan_condition(condition::Bool) + condition ? 0.0 : NaN +end + +@register_symbolic _nan_condition(condition::Bool) + +""" + $(TYPEDSIGNATURES) + +A function which takes a condition `expr` and returns `NaN` if it is false, +and zero if it is true. In case the condition is false and `log == true`, +`message` will be logged as an `@error`. +""" +function _debug_assertion(expr::Bool, message::String, log::Bool) + value = _nan_condition(expr) + isnan(value) || return value + log && @error message + return value +end + +@register_symbolic _debug_assertion(expr::Bool, message::String, log::Bool) + +""" +Boolean parameter added to models returned from `debug_system` to control logging of +assertions. +""" +const ASSERTION_LOG_VARIABLE = only(@parameters __log_assertions_ₘₜₖ::Bool = false) + +""" + $(TYPEDSIGNATURES) + +Get a symbolic expression for all the assertions in `sys`. The expression returns `NaN` +if any of the assertions fail, and `0.0` otherwise. If `ASSERTION_LOG_VARIABLE` is a +parameter in the system, it will control whether the message associated with each +assertion is logged when it fails. +""" +function get_assertions_expr(sys::AbstractSystem) + asserts = assertions(sys) + term = 0 + if is_parameter(sys, ASSERTION_LOG_VARIABLE) + for (k, v) in asserts + term += _debug_assertion(k, "Assertion $k failed:\n$v", ASSERTION_LOG_VARIABLE) + end + else + for (k, v) in asserts + term += _nan_condition(k) + end + end + return term +end diff --git a/src/discretedomain.jl b/src/discretedomain.jl new file mode 100644 index 0000000000..7260237053 --- /dev/null +++ b/src/discretedomain.jl @@ -0,0 +1,280 @@ +using Symbolics: Operator, Num, Term, value, recursive_hasoperator + +struct SampleTime <: Operator + SampleTime() = SymbolicUtils.term(SampleTime, type = Real) +end +SymbolicUtils.promote_symtype(::Type{<:SampleTime}, t...) = Real +Base.nameof(::SampleTime) = :SampleTime +SymbolicUtils.isbinop(::SampleTime) = false + +# Shift + +""" +$(TYPEDEF) + +Represents a shift operator. + +# Fields +$(FIELDS) + +# Examples + +```jldoctest +julia> using Symbolics + +julia> Δ = Shift(t) +(::Shift) (generic function with 2 methods) +``` +""" +struct Shift <: Operator + """Fixed Shift""" + t::Union{Nothing, Symbolic} + steps::Int + Shift(t, steps = 1) = new(value(t), steps) +end +Shift(steps::Int) = new(nothing, steps) +normalize_to_differential(s::Shift) = Differential(s.t)^s.steps +Base.nameof(::Shift) = :Shift +SymbolicUtils.isbinop(::Shift) = false + +function (D::Shift)(x, allow_zero = false) + !allow_zero && D.steps == 0 && return x + Term{symtype(x)}(D, Any[x]) +end +function (D::Shift)(x::Num, allow_zero = false) + !allow_zero && D.steps == 0 && return x + vt = value(x) + if iscall(vt) + op = operation(vt) + if op isa Sample + error("Cannot shift a `Sample`. Create a variable to represent the sampled value and shift that instead") + elseif op isa Shift + if D.t === nothing || isequal(D.t, op.t) + arg = arguments(vt)[1] + newsteps = D.steps + op.steps + return Num(newsteps == 0 ? arg : Shift(D.t, newsteps)(arg)) + end + end + end + Num(D(vt, allow_zero)) +end +SymbolicUtils.promote_symtype(::Shift, t) = t + +Base.show(io::IO, D::Shift) = print(io, "Shift(", D.t, ", ", D.steps, ")") + +Base.:(==)(D1::Shift, D2::Shift) = isequal(D1.t, D2.t) && isequal(D1.steps, D2.steps) +Base.hash(D::Shift, u::UInt) = hash(D.steps, hash(D.t, xor(u, 0x055640d6d952f101))) + +Base.:^(D::Shift, n::Integer) = Shift(D.t, D.steps * n) +Base.literal_pow(f::typeof(^), D::Shift, ::Val{n}) where {n} = Shift(D.t, D.steps * n) + +hasshift(eq::Equation) = hasshift(eq.lhs) || hasshift(eq.rhs) + +""" + hasshift(O) + +Returns true if the expression or equation `O` contains [`Shift`](@ref) terms. +""" +hasshift(O) = recursive_hasoperator(Shift, O) + +# Sample + +""" +$(TYPEDEF) + +Represents a sample operator. A discrete-time signal is created by sampling a continuous-time signal. + +# Constructors +`Sample(clock::Union{TimeDomain, InferredTimeDomain} = InferredDiscrete())` +`Sample(dt::Real)` + +`Sample(x::Num)`, with a single argument, is shorthand for `Sample()(x)`. + +# Fields +$(FIELDS) + +# Examples + +```jldoctest +julia> using Symbolics + +julia> t = ModelingToolkit.t_nounits + +julia> Δ = Sample(0.01) +(::Sample) (generic function with 2 methods) +``` +""" +struct Sample <: Operator + clock::Any + Sample(clock::Union{TimeDomain, InferredTimeDomain} = InferredDiscrete()) = new(clock) +end + +function Sample(arg::Real) + arg = unwrap(arg) + if symbolic_type(arg) == NotSymbolic() + Sample(Clock(arg)) + else + Sample()(arg) + end +end +(D::Sample)(x) = Term{symtype(x)}(D, Any[x]) +(D::Sample)(x::Num) = Num(D(value(x))) +SymbolicUtils.promote_symtype(::Sample, x) = x +Base.nameof(::Sample) = :Sample +SymbolicUtils.isbinop(::Sample) = false + +Base.show(io::IO, D::Sample) = print(io, "Sample(", D.clock, ")") + +Base.:(==)(D1::Sample, D2::Sample) = isequal(D1.clock, D2.clock) +Base.hash(D::Sample, u::UInt) = hash(D.clock, xor(u, 0x055640d6d952f101)) + +""" + hassample(O) + +Returns true if the expression or equation `O` contains [`Sample`](@ref) terms. +""" +hassample(O) = recursive_hasoperator(Sample, unwrap(O)) + +# Hold + +""" +$(TYPEDEF) + +Represents a hold operator. A continuous-time signal is produced by holding a discrete-time signal `x` with zero-order hold. + +``` +cont_x = Hold()(disc_x) +``` +""" +struct Hold <: Operator +end +(D::Hold)(x) = Term{symtype(x)}(D, Any[x]) +(D::Hold)(x::Num) = Num(D(value(x))) +SymbolicUtils.promote_symtype(::Hold, x) = x +Base.nameof(::Hold) = :Hold +SymbolicUtils.isbinop(::Hold) = false + +Hold(x) = Hold()(x) + +""" + hashold(O) + +Returns true if the expression or equation `O` contains [`Hold`](@ref) terms. +""" +hashold(O) = recursive_hasoperator(Hold, unwrap(O)) + +# ShiftIndex + +""" + ShiftIndex + +The `ShiftIndex` operator allows you to index a signal and obtain a shifted discrete-time signal. If the signal is continuous-time, the signal is sampled before shifting. + +# Examples + +``` +julia> t = ModelingToolkit.t_nounits; + +julia> @variables x(t); + +julia> k = ShiftIndex(t, 0.1); + +julia> x(k) # no shift +x(t) + +julia> x(k+1) # shift +Shift(1)(x(t)) +``` +""" +struct ShiftIndex + clock::Union{InferredTimeDomain, TimeDomain, IntegerSequence} + steps::Int + function ShiftIndex( + clock::Union{TimeDomain, InferredTimeDomain, IntegerSequence} = Inferred(), steps::Int = 0) + new(clock, steps) + end + ShiftIndex(dt::Real, steps::Int = 0) = new(Clock(dt), steps) + ShiftIndex(::Num, steps::Int) = new(IntegerSequence(), steps) +end + +function (xn::Num)(k::ShiftIndex) + @unpack clock, steps = k + x = value(xn) + # Verify that the independent variables of k and x match and that the expression doesn't have multiple variables + vars = Symbolics.get_variables(x) + length(vars) == 1 || + error("Cannot shift a multivariate expression $x. Either create a new unknown and shift this, or shift the individual variables in the expression.") + args = Symbolics.arguments(vars[]) # args should be one element vector with the t in x(t) + length(args) == 1 || + error("Cannot shift an expression with multiple independent variables $x.") + + # d, _ = propagate_time_domain(xn) + # if d != clock # this is only required if the variable has another clock + # xn = Sample(t, clock)(xn) + # end + # QUESTION: should we return a variable with time domain set to k.clock? + xn = setmetadata(xn, VariableTimeDomain, k.clock) + if steps == 0 + return xn # x(k) needs no shift operator if the step of k is 0 + end + Shift(t, steps)(xn) # a shift of k steps +end + +Base.:+(k::ShiftIndex, i::Int) = ShiftIndex(k.clock, k.steps + i) +Base.:-(k::ShiftIndex, i::Int) = k + (-i) + +""" + input_timedomain(op::Operator) + +Return the time-domain type (`ContinuousClock()` or `InferredDiscrete()`) that `op` operates on. +""" +function input_timedomain(s::Shift, arg = nothing) + if has_time_domain(arg) + return get_time_domain(arg) + end + InferredDiscrete() +end + +""" + output_timedomain(op::Operator) + +Return the time-domain type (`ContinuousClock()` or `InferredDiscrete()`) that `op` results in. +""" +function output_timedomain(s::Shift, arg = nothing) + if has_time_domain(t, arg) + return get_time_domain(t, arg) + end + InferredDiscrete() +end + +input_timedomain(::Sample, _ = nothing) = ContinuousClock() +output_timedomain(s::Sample, _ = nothing) = s.clock + +function input_timedomain(h::Hold, arg = nothing) + if has_time_domain(arg) + return get_time_domain(arg) + end + InferredDiscrete() # the Hold accepts any discrete +end +output_timedomain(::Hold, _ = nothing) = ContinuousClock() + +sampletime(op::Sample, _ = nothing) = sampletime(op.clock) +sampletime(op::ShiftIndex, _ = nothing) = sampletime(op.clock) + +changes_domain(op) = isoperator(op, Union{Sample, Hold}) + +function output_timedomain(x) + if isoperator(x, Operator) + return output_timedomain(operation(x), arguments(x)[]) + else + throw(ArgumentError("$x of type $(typeof(x)) is not an operator expression")) + end +end + +function input_timedomain(x) + if isoperator(x, Operator) + return input_timedomain(operation(x), arguments(x)[]) + else + throw(ArgumentError("$x of type $(typeof(x)) is not an operator expression")) + end +end diff --git a/src/domains.jl b/src/domains.jl index e2b0317b72..4972e44006 100644 --- a/src/domains.jl +++ b/src/domains.jl @@ -1,26 +1,17 @@ -abstract type AbstractDomain{T,N} end - -struct VarDomainPairing - variables - domain::AbstractDomain -end -Base.:∈(variable::ModelingToolkit.Num,domain::AbstractDomain) = VarDomainPairing(value(variable),domain) -Base.:∈(variables::NTuple{N,ModelingToolkit.Num},domain::AbstractDomain) where N = VarDomainPairing(value.(variables),domain) - -## Specific Domains - -struct IntervalDomain{T} <: AbstractDomain{T,1} - lower::T - upper::T -end - - -struct ProductDomain{D,T,N} <: AbstractDomain{T,N} - domains::D -end -⊗(args::AbstractDomain{T}...) where T = ProductDomain{typeof(args),T,length(args)}(args) - -struct CircleDomain <: AbstractDomain{Float64,2} - polar::Bool - CircleDomain(polar=false) = new(polar) -end +import DomainSets: Interval, Ball, infimum, supremum + +@deprecate IntervalDomain(a, b) Interval(a, b) +@deprecate CircleDomain() Ball() + +# type piracy on Interval for downstream compatibility to be reverted once upgrade is complete +function Base.getproperty(domain::Interval, sym::Symbol) + if sym === :lower + @warn "domain.lower is deprecated, use infimum(domain) instead" + return infimum(domain) + elseif sym === :upper + @warn "domain.upper is deprecated, use supremum(domain) instead" + return supremum(domain) + else + return getfield(domain, sym) + end +end diff --git a/src/independent_variables.jl b/src/independent_variables.jl new file mode 100644 index 0000000000..94d792a11e --- /dev/null +++ b/src/independent_variables.jl @@ -0,0 +1,14 @@ +""" + @independent_variables t₁ t₂ ... + +Define one or more independent variables. For example: + + @independent_variables t + @variables x(t) +""" +macro independent_variables(ts...) + :(@parameters $(ts...)) |> esc # TODO: treat independent variables separately from variables and parameters +end + +toiv(s::Symbolic) = setmetadata(s, MTKVariableTypeCtx, PARAMETER) +toiv(s::Num) = Num(toiv(value(s))) diff --git a/src/inputoutput.jl b/src/inputoutput.jl new file mode 100644 index 0000000000..5f9420ff3a --- /dev/null +++ b/src/inputoutput.jl @@ -0,0 +1,436 @@ +using Symbolics: get_variables +""" + inputs(sys) + +Return all variables that mare marked as inputs. See also [`unbound_inputs`](@ref) +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref) +""" +inputs(sys) = [filter(isinput, unknowns(sys)); filter(isinput, parameters(sys))] + +""" + outputs(sys) + +Return all variables that mare marked as outputs. See also [`unbound_outputs`](@ref) +See also [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +function outputs(sys) + o = observed(sys) + rhss = [eq.rhs for eq in o] + lhss = [eq.lhs for eq in o] + unique([filter(isoutput, unknowns(sys)) + filter(isoutput, parameters(sys)) + filter(x -> iscall(x) && isoutput(x), rhss) # observed can return equations with complicated expressions, we are only looking for single Terms + filter(x -> iscall(x) && isoutput(x), lhss)]) +end + +""" + bound_inputs(sys) + +Return inputs that are bound within the system, i.e., internal inputs +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref), [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +bound_inputs(sys) = filter(x -> is_bound(sys, x), inputs(sys)) + +""" + unbound_inputs(sys) + +Return inputs that are not bound within the system, i.e., external inputs +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref), [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +unbound_inputs(sys) = filter(x -> !is_bound(sys, x), inputs(sys)) + +""" + bound_outputs(sys) + +Return outputs that are bound within the system, i.e., internal outputs +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref), [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +bound_outputs(sys) = filter(x -> is_bound(sys, x), outputs(sys)) + +""" + unbound_outputs(sys) + +Return outputs that are not bound within the system, i.e., external outputs +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref), [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +unbound_outputs(sys) = filter(x -> !is_bound(sys, x), outputs(sys)) + +""" + is_bound(sys, u) + +Determine whether input/output variable `u` is "bound" within the system, i.e., if it's to be considered internal to `sys`. +A variable/signal is considered bound if it appears in an equation together with variables from other subsystems. +The typical usecase for this function is to determine whether the input to an IO component is connected to another component, +or if it remains an external input that the user has to supply before simulating the system. + +See also [`bound_inputs`](@ref), [`unbound_inputs`](@ref), [`bound_outputs`](@ref), [`unbound_outputs`](@ref) +""" +function is_bound(sys, u, stack = []) + #= + For observed quantities, we check if a variable is connected to something that is bound to something further out. + In the following scenario + julia> observed(syss) + 2-element Vector{Equation}: + sys₊y(tv) ~ sys₊x(tv) + y(tv) ~ sys₊x(tv) + sys₊y(t) is bound to the outer y(t) through the variable sys₊x(t) and should thus return is_bound(sys₊y(t)) = true. + When asking is_bound(sys₊y(t)), we know that we are looking through observed equations and can thus ask + if var is bound, if it is, then sys₊y(t) is also bound. This can lead to an infinite recursion, so we maintain a stack of variables we have previously asked about to be able to break cycles + =# + u ∈ Set(stack) && return false # Cycle detected + eqs = equations(sys) + eqs = filter(eq -> has_var(eq, u), eqs) # Only look at equations that contain u + # isout = isoutput(u) + for eq in eqs + vars = [get_variables(eq.rhs); get_variables(eq.lhs)] + for var in vars + var === u && continue + if !same_or_inner_namespace(u, var) + return true + end + end + end + # Look through observed equations as well + oeqs = observed(sys) + oeqs = filter(eq -> has_var(eq, u), oeqs) # Only look at equations that contain u + for eq in oeqs + vars = [get_variables(eq.rhs); get_variables(eq.lhs)] + for var in vars + var === u && continue + if !same_or_inner_namespace(u, var) + return true + end + if is_bound(sys, var, [stack; u]) && !inner_namespace(u, var) # The variable we are comparing to can not come from an inner namespace, binding only counts outwards + return true + end + end + end + false +end + +""" + same_or_inner_namespace(u, var) + +Determine whether `var` is in the same namespace as `u`, or a namespace internal to the namespace of `u`. +Example: `sys.u ~ sys.inner.u` will bind `sys.inner.u`, but `sys.u` remains an unbound, external signal. The namespaced signal `sys.inner.u` lives in a namespace internal to `sys`. +""" +function same_or_inner_namespace(u, var) + nu = get_namespace(u) + nv = get_namespace(var) + nu == nv || # namespaces are the same + startswith(nv, nu) || # or nv starts with nu, i.e., nv is an inner namespace to nu + occursin(NAMESPACE_SEPARATOR, string(getname(var))) && + !occursin(NAMESPACE_SEPARATOR, string(getname(u))) # or u is top level but var is internal +end + +function inner_namespace(u, var) + nu = get_namespace(u) + nv = get_namespace(var) + nu == nv && return false + startswith(nv, nu) || # or nv starts with nu, i.e., nv is an inner namespace to nu + occursin(NAMESPACE_SEPARATOR, string(getname(var))) && + !occursin(NAMESPACE_SEPARATOR, string(getname(u))) # or u is top level but var is internal +end + +""" + get_namespace(x) + +Return the namespace of a variable as a string. If the variable is not namespaced, the string is empty. +""" +function get_namespace(x) + sname = string(getname(x)) + parts = split(sname, NAMESPACE_SEPARATOR) + if length(parts) == 1 + return "" + end + join(parts[1:(end - 1)], NAMESPACE_SEPARATOR) +end + +""" + has_var(eq, x) + +Determine whether an equation or expression contains variable `x`. +""" +function has_var(eq::Equation, x) + has_var(eq.rhs, x) || has_var(eq.lhs, x) +end + +has_var(ex, x) = x ∈ Set(get_variables(ex)) + +# Build control function + +""" + (f_oop, f_ip), x_sym, p_sym, io_sys = generate_control_function( + sys::AbstractODESystem, + inputs = unbound_inputs(sys), + disturbance_inputs = nothing; + implicit_dae = false, + simplify = false, + ) + +For a system `sys` with inputs (as determined by [`unbound_inputs`](@ref) or user specified), generate a function with additional input argument `in` + +``` +f_oop : (x,u,p,t) -> rhs +f_ip : (xout,x,u,p,t) -> nothing +``` + +The return values also include the chosen state-realization (the remaining unknowns) `x_sym` and parameters, in the order they appear as arguments to `f`. + +If `disturbance_inputs` is an array of variables, the generated dynamics function will preserve any state and dynamics associated with disturbance inputs, but the disturbance inputs themselves will (by default) not be included as inputs to the generated function. The use case for this is to generate dynamics for state observers that estimate the influence of unmeasured disturbances, and thus require unknown variables for the disturbance model, but without disturbance inputs since the disturbances are not available for measurement. To add an input argument corresponding to the disturbance inputs, either include the disturbance inputs among the control inputs, or set `disturbance_argument=true`, in which case an additional input argument `w` is added to the generated function `(x,u,p,t,w)->rhs`. + +!!! note "Un-simplified system" + This function expects `sys` to be un-simplified, i.e., `structural_simplify` or `@mtkbuild` should not be called on the system before passing it into this function. `generate_control_function` calls a special version of `structural_simplify` internally. + +# Example + +``` +using ModelingToolkit: generate_control_function, varmap_to_vars, defaults +f, x_sym, ps = generate_control_function(sys, expression=Val{false}, simplify=false) +p = varmap_to_vars(defaults(sys), ps) +x = varmap_to_vars(defaults(sys), x_sym) +t = 0 +f[1](x, inputs, p, t) +``` +""" +function generate_control_function(sys::AbstractODESystem, inputs = unbound_inputs(sys), + disturbance_inputs = disturbances(sys); + disturbance_argument = false, + implicit_dae = false, + simplify = false, + eval_expression = false, + eval_module = @__MODULE__, + kwargs...) + isempty(inputs) && @warn("No unbound inputs were found in system.") + + if disturbance_inputs !== nothing + # add to inputs for the purposes of io processing + inputs = [inputs; disturbance_inputs] + end + + sys, _ = io_preprocessing(sys, inputs, []; simplify, kwargs...) + + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + ps = setdiff(ps, inputs) + if disturbance_inputs !== nothing + # remove from inputs since we do not want them as actual inputs to the dynamics + inputs = setdiff(inputs, disturbance_inputs) + # ps = [ps; disturbance_inputs] + end + inputs = map(x -> time_varying_as_func(value(x), sys), inputs) + disturbance_inputs = unwrap.(disturbance_inputs) + + eqs = [eq for eq in full_equations(sys)] + eqs = map(subs_constants, eqs) + if disturbance_inputs !== nothing && !disturbance_argument + # Set all disturbance *inputs* to zero (we just want to keep the disturbance state) + subs = Dict(disturbance_inputs .=> 0) + eqs = [eq.lhs ~ substitute(eq.rhs, subs) for eq in eqs] + end + check_operator_variables(eqs, Differential) + # substitute x(t) by just x + rhss = implicit_dae ? [_iszero(eq.lhs) ? eq.rhs : eq.rhs - eq.lhs for eq in eqs] : + [eq.rhs for eq in eqs] + + # TODO: add an optional check on the ordering of observed equations + p = reorder_parameters(sys, ps) + t = get_iv(sys) + + # pre = has_difference ? (ex -> ex) : get_postprocess_fbody(sys) + if disturbance_argument + args = (dvs, inputs, p..., t, disturbance_inputs) + else + args = (dvs, inputs, p..., t) + end + if implicit_dae + ddvs = map(Differential(get_iv(sys)), dvs) + args = (ddvs, args...) + end + f = build_function_wrapper(sys, rhss, args...; p_start = 3 + implicit_dae, + p_end = length(p) + 2 + implicit_dae) + f = eval_or_rgf.(f; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + 3 + implicit_dae, length(args) - length(p) + 1, is_split(sys))}(f...) + f = f, f + ps = setdiff(parameters(sys), inputs, disturbance_inputs) + (; f, dvs, ps, io_sys = sys) +end + +function inputs_to_parameters!(state::TransformationState, io) + check_bound = io === nothing + @unpack structure, fullvars, sys = state + @unpack var_to_diff, graph, solvable_graph = structure + @assert solvable_graph === nothing + + inputs = BitSet() + var_reidx = zeros(Int, length(fullvars)) + ninputs = 0 + nvar = 0 + new_parameters = [] + input_to_parameters = Dict() + new_fullvars = [] + for (i, v) in enumerate(fullvars) + if isinput(v) && !(check_bound && is_bound(sys, v)) + if var_to_diff[i] !== nothing + error("Input $(fullvars[i]) is differentiated!") + end + push!(inputs, i) + ninputs += 1 + var_reidx[i] = -1 + p = toparam(v) + push!(new_parameters, p) + input_to_parameters[v] = p + else + nvar += 1 + var_reidx[i] = nvar + push!(new_fullvars, v) + end + end + ninputs == 0 && return (state, 1:0) + + nvars = ndsts(graph) - ninputs + new_graph = BipartiteGraph(nsrcs(graph), nvars, Val(false)) + + for ie in 1:nsrcs(graph) + for iv in 𝑠neighbors(graph, ie) + iv = var_reidx[iv] + iv > 0 || continue + add_edge!(new_graph, ie, iv) + end + end + + new_var_to_diff = DiffGraph(nvars, true) + for (i, v) in enumerate(var_to_diff) + new_i = var_reidx[i] + (new_i < 1 || v === nothing) && continue + new_v = var_reidx[v] + @assert new_v > 0 + new_var_to_diff[new_i] = new_v + end + @set! structure.var_to_diff = complete(new_var_to_diff) + @set! structure.graph = complete(new_graph) + + @set! sys.eqs = isempty(input_to_parameters) ? equations(sys) : + fast_substitute(equations(sys), input_to_parameters) + @set! sys.unknowns = setdiff(unknowns(sys), keys(input_to_parameters)) + ps = parameters(sys) + + if io !== nothing + inputs, = io + # Change order of new parameters to correspond to user-provided order in argument `inputs` + d = Dict{Any, Int}() + for (i, inp) in enumerate(new_parameters) + d[inp] = i + end + permutation = [d[i] for i in inputs] + new_parameters = new_parameters[permutation] + end + + @set! sys.ps = [ps; new_parameters] + + @set! state.sys = sys + @set! state.fullvars = new_fullvars + @set! state.structure = structure + base_params = length(ps) + return state, (base_params + 1):(base_params + length(new_parameters)) # (1:length(new_parameters)) .+ base_params +end + +""" + DisturbanceModel{M} + +The structure represents a model of a disturbance, along with the input variable that is affected by the disturbance. See [`add_input_disturbance`](@ref) for additional details and an example. + +# Fields: + + - `input`: The variable affected by the disturbance. + - `model::M`: A model of the disturbance. This is typically an `ODESystem`, but type that implements [`ModelingToolkit.get_disturbance_system`](@ref)`(dist::DisturbanceModel) -> ::ODESystem` is supported. +""" +struct DisturbanceModel{M} + input::Any + model::M + name::Symbol +end +DisturbanceModel(input, model; name) = DisturbanceModel(input, model, name) + +# Point of overloading for libraries, e.g., to be able to support disturbance models from ControlSystemsBase +function get_disturbance_system(dist::DisturbanceModel{<:ODESystem}) + dist.model +end + +""" + (f_oop, f_ip), augmented_sys, dvs, p = add_input_disturbance(sys, dist::DisturbanceModel, inputs = nothing) + +Add a model of an unmeasured disturbance to `sys`. The disturbance model is an instance of [`DisturbanceModel`](@ref). + +The generated dynamics functions `(f_oop, f_ip)` will preserve any state and dynamics associated with disturbance inputs, but the disturbance inputs themselves will not be included as inputs to the generated function. The use case for this is to generate dynamics for state observers that estimate the influence of unmeasured disturbances, and thus require state variables for the disturbance model, but without disturbance inputs since the disturbances are not available for measurement. + +`dvs` will be the states of the simplified augmented system, consisting of the states of `sys` as well as the states of the disturbance model. + +For MIMO systems, all inputs to the system has to be specified in the argument `inputs` + +# Example + +The example below builds a double-mass model and adds an integrating disturbance to the input + +```julia +using ModelingToolkit +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +t = ModelingToolkitStandardLibrary.Blocks.t + +# Parameters +m1 = 1 +m2 = 1 +k = 1000 # Spring stiffness +c = 10 # Damping coefficient + +@named inertia1 = Inertia(; J = m1) +@named inertia2 = Inertia(; J = m2) +@named spring = Spring(; c = k) +@named damper = Damper(; d = c) +@named torque = Torque(; use_support = false) + +eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] +model = ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name = :model) +model = complete(model) +model_outputs = [model.inertia1.w, model.inertia2.w, model.inertia1.phi, model.inertia2.phi] + +# Disturbance model +@named dmodel = Blocks.StateSpace([0.0], [1.0], [1.0], [0.0]) # An integrating disturbance +@named dist = ModelingToolkit.DisturbanceModel(model.torque.tau.u, dmodel) +(f_oop, f_ip), augmented_sys, dvs, p = ModelingToolkit.add_input_disturbance(model, dist) +``` + +`f_oop` will have an extra state corresponding to the integrator in the disturbance model. This state will not be affected by any input, but will affect the dynamics from where it enters, in this case it will affect additively from `model.torque.tau.u`. +""" +function add_input_disturbance(sys, dist::DisturbanceModel, inputs = nothing; kwargs...) + t = get_iv(sys) + @variables d(t)=0 [disturbance = true] + @variables u(t)=0 [input = true] # New system input + dsys = get_disturbance_system(dist) + + if inputs === nothing + all_inputs = [u] + else + i = findfirst(isequal(dist.input), inputs) + if i === nothing + throw(ArgumentError("Input $(dist.input) indicated in the disturbance model was not found among inputs specified to add_input_disturbance")) + end + all_inputs = convert(Vector{Any}, copy(inputs)) + all_inputs[i] = u # The input where the disturbance acts is no longer an input, the new input is u + end + + eqs = [dsys.input.u[1] ~ d + dist.input ~ u + dsys.output.u[1]] + augmented_sys = ODESystem(eqs, t, systems = [dsys], name = gensym(:outer)) + augmented_sys = extend(augmented_sys, sys) + + (f_oop, f_ip), dvs, p, io_sys = generate_control_function(augmented_sys, all_inputs, + [d]; kwargs...) + (f_oop, f_ip), augmented_sys, dvs, p, io_sys +end diff --git a/src/linearization.jl b/src/linearization.jl new file mode 100644 index 0000000000..77f4422b63 --- /dev/null +++ b/src/linearization.jl @@ -0,0 +1,777 @@ +""" + lin_fun, simplified_sys = linearization_function(sys::AbstractSystem, inputs, outputs; simplify = false, initialize = true, initialization_solver_alg = TrustRegion(), kwargs...) + +Return a function that linearizes the system `sys`. The function [`linearize`](@ref) provides a higher-level and easier to use interface. + +`lin_fun` is a function `(variables, p, t) -> (; f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u)`, i.e., it returns a NamedTuple with the Jacobians of `f,g,h` for the nonlinear `sys` (technically for `simplified_sys`) on the form + +```math +\\begin{aligned} +ẋ &= f(x, z, u) \\\\ +0 &= g(x, z, u) \\\\ +y &= h(x, z, u) +\\end{aligned} +``` + +where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. To obtain a linear statespace representation, see [`linearize`](@ref). The input argument `variables` is a vector defining the operating point, corresponding to `unknowns(simplified_sys)` and `p` is a vector corresponding to the parameters of `simplified_sys`. Note: all variables in `inputs` have been converted to parameters in `simplified_sys`. + +The `simplified_sys` has undergone [`structural_simplify`](@ref) and had any occurring input or output variables replaced with the variables provided in arguments `inputs` and `outputs`. The unknowns of this system also indicate the order of the unknowns that holds for the linearized matrices. + +# Arguments: + + - `sys`: An [`ODESystem`](@ref). This function will automatically apply simplification passes on `sys` and return the resulting `simplified_sys`. + - `inputs`: A vector of variables that indicate the inputs of the linearized input-output model. + - `outputs`: A vector of variables that indicate the outputs of the linearized input-output model. + - `simplify`: Apply simplification in tearing. + - `initialize`: If true, a check is performed to ensure that the operating point is consistent (satisfies algebraic equations). If the op is not consistent, initialization is performed. + - `initialization_solver_alg`: A NonlinearSolve algorithm to use for solving for a feasible set of state and algebraic variables that satisfies the specified operating point. + - `autodiff`: An `ADType` supported by DifferentiationInterface.jl to use for calculating the necessary jacobians. Defaults to using `AutoForwardDiff()` + - `kwargs`: Are passed on to `find_solvables!` + +See also [`linearize`](@ref) which provides a higher-level interface. +""" +function linearization_function(sys::AbstractSystem, inputs, + outputs; simplify = false, + initialize = true, + initializealg = nothing, + initialization_abstol = 1e-5, + initialization_reltol = 1e-3, + op = Dict(), + p = DiffEqBase.NullParameters(), + zero_dummy_der = false, + initialization_solver_alg = TrustRegion(), + autodiff = AutoForwardDiff(), + eval_expression = false, eval_module = @__MODULE__, + warn_initialize_determined = true, + guesses = Dict(), + warn_empty_op = true, + kwargs...) + op = Dict(op) + if isempty(op) && warn_empty_op + @warn "An empty operating point was passed to `linearization_function`. An operating point containing the variables that will be changed in `linearize` should be provided. Disable this warning by passing `warn_empty_op = false`." + end + inputs isa AbstractVector || (inputs = [inputs]) + outputs isa AbstractVector || (outputs = [outputs]) + inputs = mapreduce(vcat, inputs; init = []) do var + symbolic_type(var) == ArraySymbolic() ? collect(var) : [var] + end + outputs = mapreduce(vcat, outputs; init = []) do var + symbolic_type(var) == ArraySymbolic() ? collect(var) : [var] + end + ssys, diff_idxs, alge_idxs, input_idxs = io_preprocessing(sys, inputs, outputs; + simplify, + kwargs...) + if zero_dummy_der + dummyder = setdiff(unknowns(ssys), unknowns(sys)) + defs = Dict(x => 0.0 for x in dummyder) + @set! ssys.defaults = merge(defs, defaults(ssys)) + op = merge(defs, op) + end + sys = ssys + + if initializealg === nothing + initializealg = initialize ? OverrideInit() : NoInit() + end + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, op, (nothing, nothing), p; allow_incomplete = true, + algebraic_only = true, guesses) + u0 = state_values(prob) + + ps = parameters(sys) + h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) + + initialization_kwargs = (; + abstol = initialization_abstol, reltol = initialization_reltol, + nlsolve_alg = initialization_solver_alg) + + p = parameter_values(prob) + t0 = current_time(prob) + inputvals = [p[idx] for idx in input_idxs] + + hp_fun = let fun = h, setter = setp_oop(sys, input_idxs) + function hpf(du, input, u, p, t) + p = setter(p, input) + fun(du, u, p, t) + return du + end + end + if u0 === nothing + uf_jac = h_jac = pf_jac = nothing + T = p isa MTKParameters ? eltype(p.tunable) : eltype(p) + hp_jac = PreparedJacobian{true}( + hp_fun, zeros(T, size(outputs)), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + else + uf_fun = let fun = prob.f + function uff(du, u, p, t) + SciMLBase.UJacobianWrapper(fun, t, p)(du, u) + end + end + uf_jac = PreparedJacobian{true}( + uf_fun, similar(prob.u0), autodiff, prob.u0, DI.Constant(p), DI.Constant(t0)) + # observed function is a `GeneratedFunctionWrapper` with iip component + h_jac = PreparedJacobian{true}(h, similar(prob.u0, size(outputs)), autodiff, + prob.u0, DI.Constant(p), DI.Constant(t0)) + pf_fun = let fun = prob.f, setter = setp_oop(sys, input_idxs) + function pff(du, input, u, p, t) + p = setter(p, input) + SciMLBase.ParamJacobianWrapper(fun, t, u)(du, p) + end + end + pf_jac = PreparedJacobian{true}(pf_fun, similar(prob.u0), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + hp_jac = PreparedJacobian{true}( + hp_fun, similar(prob.u0, size(outputs)), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + end + + lin_fun = LinearizationFunction( + diff_idxs, alge_idxs, input_idxs, length(unknowns(sys)), + prob, h, u0 === nothing ? nothing : similar(u0), uf_jac, h_jac, pf_jac, + hp_jac, initializealg, initialization_kwargs) + return lin_fun, sys +end + +""" + $(TYPEDEF) + +Callable struct which stores a function and its prepared `DI.jacobian`. Calling with the +appropriate arguments for DI returns the jacobian. + +# Fields + +$(TYPEDFIELDS) +""" +struct PreparedJacobian{iip, P, F, B, A} + """ + The preparation object. + """ + prep::P + """ + The function whose jacobian is calculated. + """ + f::F + """ + Buffer for in-place functions. + """ + buf::B + """ + ADType to use for differentiation. + """ + autodiff::A +end + +function PreparedJacobian{true}(f, buf, autodiff, args...) + prep = DI.prepare_jacobian(f, buf, autodiff, args...) + return PreparedJacobian{true, typeof(prep), typeof(f), typeof(buf), typeof(autodiff)}( + prep, f, buf, autodiff) +end + +function PreparedJacobian{false}(f, autodiff, args...) + prep = DI.prepare_jacobian(f, autodiff, args...) + return PreparedJacobian{true, typeof(prep), typeof(f), Nothing, typeof(autodiff)}( + prep, f, nothing) +end + +function (pj::PreparedJacobian{true})(args...) + DI.jacobian(pj.f, pj.buf, pj.prep, pj.autodiff, args...) +end + +function (pj::PreparedJacobian{false})(args...) + DI.jacobian(pj.f, pj.prep, pj.autodiff, args...) +end + +""" + $(TYPEDEF) + +A callable struct which linearizes a system. + +# Fields + +$(TYPEDFIELDS) +""" +struct LinearizationFunction{ + DI <: AbstractVector{Int}, AI <: AbstractVector{Int}, II, P <: ODEProblem, + H, C, J1, J2, J3, J4, IA <: SciMLBase.DAEInitializationAlgorithm, IK} + """ + The indexes of differential equations in the linearized system. + """ + diff_idxs::DI + """ + The indexes of algebraic equations in the linearized system. + """ + alge_idxs::AI + """ + The indexes of parameters in the linearized system which represent + input variables. + """ + input_idxs::II + """ + The number of unknowns in the linearized system. + """ + num_states::Int + """ + The `ODEProblem` of the linearized system. + """ + prob::P + """ + A function which takes `(u, p, t)` and returns the outputs of the linearized system. + """ + h::H + """ + Any required cache buffers. + """ + caches::C + """ + `PreparedJacobian` for calculating jacobian of `prob.f` w.r.t. `u` + """ + uf_jac::J1 + """ + `PreparedJacobian` for calculating jacobian of `h` w.r.t. `u` + """ + h_jac::J2 + """ + `PreparedJacobian` for calculating jacobian of `prob.f` w.r.t. `p` + """ + pf_jac::J3 + """ + `PreparedJacobian` for calculating jacobian of `h` w.r.t. `p` + """ + hp_jac::J4 + """ + The initialization algorithm to use. + """ + initializealg::IA + """ + Keyword arguments to be passed to `SciMLBase.get_initial_values`. + """ + initialize_kwargs::IK +end + +SymbolicIndexingInterface.symbolic_container(f::LinearizationFunction) = f.prob +SymbolicIndexingInterface.state_values(f::LinearizationFunction) = state_values(f.prob) +function SymbolicIndexingInterface.parameter_values(f::LinearizationFunction) + parameter_values(f.prob) +end +SymbolicIndexingInterface.current_time(f::LinearizationFunction) = current_time(f.prob) + +function Base.show(io::IO, mime::MIME"text/plain", lf::LinearizationFunction) + printstyled(io, "LinearizationFunction"; bold = true, color = :blue) + println(io, " which wraps:") + show(io, mime, lf.prob) +end + +""" + $(TYPEDSIGNATURES) + +Linearize the wrapped system at the point given by `(u, p, t)`. +""" +function (linfun::LinearizationFunction)(u, p, t) + if eltype(p) <: Pair + p = todict(p) + newps = copy(parameter_values(linfun.prob)) + for (k, v) in p + if is_parameter(linfun, k) + v = fixpoint_sub(v, p) + setp(linfun, k)(newps, v) + end + end + p = newps + end + + fun = linfun.prob.f + if u !== nothing # Handle systems without unknowns + linfun.num_states == length(u) || + error("Number of unknown variables ($(linfun.num_states)) does not match the number of input unknowns ($(length(u)))") + integ_cache = (linfun.caches,) + integ = MockIntegrator{true}(u, p, t, integ_cache, nothing) + u, p, success = SciMLBase.get_initial_values( + linfun.prob, integ, fun, linfun.initializealg, Val(true); + linfun.initialize_kwargs...) + if !success + error("Initialization algorithm $(linfun.initializealg) failed with `u = $u` and `p = $p`.") + end + fg_xz = linfun.uf_jac(u, DI.Constant(p), DI.Constant(t)) + h_xz = linfun.h_jac(u, DI.Constant(p), DI.Constant(t)) + fg_u = linfun.pf_jac([p[idx] for idx in linfun.input_idxs], + DI.Constant(u), DI.Constant(p), DI.Constant(t)) + else + linfun.num_states == 0 || + error("Number of unknown variables (0) does not match the number of input unknowns ($(length(u)))") + fg_xz = zeros(0, 0) + h_xz = fg_u = zeros(0, length(linfun.input_idxs)) + end + h_u = linfun.hp_jac([p[idx] for idx in linfun.input_idxs], + DI.Constant(u), DI.Constant(p), DI.Constant(t)) + (f_x = fg_xz[linfun.diff_idxs, linfun.diff_idxs], + f_z = fg_xz[linfun.diff_idxs, linfun.alge_idxs], + g_x = fg_xz[linfun.alge_idxs, linfun.diff_idxs], + g_z = fg_xz[linfun.alge_idxs, linfun.alge_idxs], + f_u = fg_u[linfun.diff_idxs, :], + g_u = fg_u[linfun.alge_idxs, :], + h_x = h_xz[:, linfun.diff_idxs], + h_z = h_xz[:, linfun.alge_idxs], + h_u = h_u) +end + +""" + $(TYPEDEF) + +Mock `DEIntegrator` to allow using `CheckInit` without having to create a new integrator +(and consequently depend on `OrdinaryDiffEq`). + +# Fields + +$(TYPEDFIELDS) +""" +struct MockIntegrator{iip, U, P, T, C, O} <: SciMLBase.DEIntegrator{Nothing, iip, U, T} + """ + The state vector. + """ + u::U + """ + The parameter object. + """ + p::P + """ + The current time. + """ + t::T + """ + The integrator cache. + """ + cache::C + """ + Integrator "options" for `CheckInit`. + """ + opts::O +end + +function MockIntegrator{iip}(u::U, p::P, t::T, cache::C, opts::O) where {iip, U, P, T, C, O} + return MockIntegrator{iip, U, P, T, C, O}(u, p, t, cache, opts) +end + +SymbolicIndexingInterface.state_values(integ::MockIntegrator) = integ.u +SymbolicIndexingInterface.parameter_values(integ::MockIntegrator) = integ.p +SymbolicIndexingInterface.current_time(integ::MockIntegrator) = integ.t +SciMLBase.get_tmp_cache(integ::MockIntegrator) = integ.cache + +""" + $(TYPEDEF) + +A struct representing a linearization operation to be performed. Can be symbolically +indexed to efficiently update the operating point for multiple linearizations in a loop. +The value of the independent variable can be set by mutating the `.t` field of this struct. +""" +mutable struct LinearizationProblem{F <: LinearizationFunction, T} + """ + The wrapped `LinearizationFunction` + """ + const f::F + t::T +end + +function Base.show(io::IO, mime::MIME"text/plain", prob::LinearizationProblem) + printstyled(io, "LinearizationProblem"; bold = true, color = :blue) + println(io, " at time ", prob.t, " which wraps:") + show(io, mime, prob.f.prob) +end + +""" + $(TYPEDSIGNATURES) + +Construct a `LinearizationProblem` for linearizing the system `sys` with the given +`inputs` and `outputs`. + +# Keyword arguments + +- `t`: The value of the independent variable + +All other keyword arguments are forwarded to `linearization_function`. +""" +function LinearizationProblem(sys::AbstractSystem, inputs, outputs; t = 0.0, kwargs...) + linfun, _ = linearization_function(sys, inputs, outputs; kwargs...) + return LinearizationProblem(linfun, t) +end + +SymbolicIndexingInterface.symbolic_container(p::LinearizationProblem) = p.f +SymbolicIndexingInterface.state_values(p::LinearizationProblem) = state_values(p.f) +SymbolicIndexingInterface.parameter_values(p::LinearizationProblem) = parameter_values(p.f) +SymbolicIndexingInterface.current_time(p::LinearizationProblem) = p.t + +function Base.getindex(prob::LinearizationProblem, idx) + getu(prob, idx)(prob) +end + +function Base.setindex!(prob::LinearizationProblem, val, idx) + setu(prob, idx)(prob, val) +end + +function Base.getproperty(prob::LinearizationProblem, x::Symbol) + if x == :ps + return ParameterIndexingProxy(prob) + end + return getfield(prob, x) +end + +function CommonSolve.solve(prob::LinearizationProblem; allow_input_derivatives = false) + u0 = state_values(prob) + p = parameter_values(prob) + t = current_time(prob) + linres = prob.f(u0, p, t) + f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u = linres + + nx, nu = size(f_u) + nz = size(f_z, 2) + ny = size(h_x, 1) + + D = h_u + + if isempty(g_z) + A = f_x + B = f_u + C = h_x + @assert iszero(g_x) + @assert iszero(g_z) + @assert iszero(g_u) + else + gz = lu(g_z; check = false) + issuccess(gz) || + error("g_z not invertible, this indicates that the DAE is of index > 1.") + gzgx = -(gz \ g_x) + A = [f_x f_z + gzgx*f_x gzgx*f_z] + B = [f_u + gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula + + C = [h_x h_z] + Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. + if !iszero(Bs) + if !allow_input_derivatives + der_inds = findall(vec(any(!=(0), Bs, dims = 1))) + error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(inputs(prob.f.prob.f.sys)[der_inds]). Call `linearize` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") + end + B = [B [zeros(nx, nu); Bs]] + D = [D zeros(ny, nu)] + end + end + + (; A, B, C, D) +end + +""" + (; A, B, C, D), simplified_sys = linearize_symbolic(sys::AbstractSystem, inputs, outputs; simplify = false, allow_input_derivatives = false, kwargs...) + +Similar to [`linearize`](@ref), but returns symbolic matrices `A,B,C,D` rather than numeric. While `linearize` uses ForwardDiff to perform the linearization, this function uses `Symbolics.jacobian`. + +See [`linearize`](@ref) for a description of the arguments. + +# Extended help +The named tuple returned as the first argument additionally contains the jacobians `f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u` of +```math +\\begin{aligned} +ẋ &= f(x, z, u) \\\\ +0 &= g(x, z, u) \\\\ +y &= h(x, z, u) +\\end{aligned} +``` +where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. +""" +function linearize_symbolic(sys::AbstractSystem, inputs, + outputs; simplify = false, allow_input_derivatives = false, + eval_expression = false, eval_module = @__MODULE__, + kwargs...) + sys, diff_idxs, alge_idxs, input_idxs = io_preprocessing( + sys, inputs, outputs; simplify, + kwargs...) + sts = unknowns(sys) + t = get_iv(sys) + ps = parameters(sys; initial_parameters = true) + p = reorder_parameters(sys, ps) + + fun_expr = generate_function(sys, sts, ps; expression = Val{true})[1] + fun = eval_or_rgf(fun_expr; eval_expression, eval_module) + dx = fun(sts, p, t) + + h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) + y = h(sts, p, t) + + fg_xz = Symbolics.jacobian(dx, sts) + fg_u = Symbolics.jacobian(dx, inputs) + h_xz = Symbolics.jacobian(y, sts) + h_u = Symbolics.jacobian(y, inputs) + f_x = fg_xz[diff_idxs, diff_idxs] + f_z = fg_xz[diff_idxs, alge_idxs] + g_x = fg_xz[alge_idxs, diff_idxs] + g_z = fg_xz[alge_idxs, alge_idxs] + f_u = fg_u[diff_idxs, :] + g_u = fg_u[alge_idxs, :] + h_x = h_xz[:, diff_idxs] + h_z = h_xz[:, alge_idxs] + + nx, nu = size(f_u) + nz = size(f_z, 2) + ny = size(h_x, 1) + + D = h_u + + if isempty(g_z) # ODE + A = f_x + B = f_u + C = h_x + else + gz = lu(g_z; check = false) + issuccess(gz) || + error("g_z not invertible, this indicates that the DAE is of index > 1.") + gzgx = -(gz \ g_x) + A = [f_x f_z + gzgx*f_x gzgx*f_z] + B = [f_u + gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula + + C = [h_x h_z] + Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. + if !iszero(Bs) + if !allow_input_derivatives + der_inds = findall(vec(any(!iszero, Bs, dims = 1))) + error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(ModelingToolkit.inputs(sys)[der_inds]). Call `linearize_symbolic` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") + end + B = [B [zeros(nx, nu); Bs]] + D = [D zeros(ny, nu)] + end + end + + (; A, B, C, D, f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u), sys +end + +function markio!(state, orig_inputs, inputs, outputs; check = true) + fullvars = get_fullvars(state) + inputset = Dict{Any, Bool}(i => false for i in inputs) + outputset = Dict{Any, Bool}(o => false for o in outputs) + for (i, v) in enumerate(fullvars) + if v in keys(inputset) + if v in keys(outputset) + v = setio(v, true, true) + outputset[v] = true + else + v = setio(v, true, false) + end + inputset[v] = true + fullvars[i] = v + elseif v in keys(outputset) + v = setio(v, false, true) + outputset[v] = true + fullvars[i] = v + else + if isinput(v) + push!(orig_inputs, v) + end + v = setio(v, false, false) + fullvars[i] = v + end + end + if check + ikeys = keys(filter(!last, inputset)) + if !isempty(ikeys) + error( + "Some specified inputs were not found in system. The following variables were not found ", + ikeys) + end + end + check && (all(values(outputset)) || + error( + "Some specified outputs were not found in system. The following Dict indicates the found variables ", + outputset)) + state, orig_inputs +end + +""" + (; A, B, C, D), simplified_sys = linearize(sys, inputs, outputs; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false, kwargs...) + (; A, B, C, D) = linearize(simplified_sys, lin_fun; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false) + +Linearize `sys` between `inputs` and `outputs`, both vectors of variables. Return a NamedTuple with the matrices of a linear statespace representation +on the form + +```math +\\begin{aligned} +ẋ &= Ax + Bu\\\\ +y &= Cx + Du +\\end{aligned} +``` + +The first signature automatically calls [`linearization_function`](@ref) internally, +while the second signature expects the outputs of [`linearization_function`](@ref) as input. + +`op` denotes the operating point around which to linearize. If none is provided, +the default values of `sys` are used. + +If `allow_input_derivatives = false`, an error will be thrown if input derivatives (``u̇``) appear as inputs in the linearized equations. If input derivatives are allowed, the returned `B` matrix will be of double width, corresponding to the input `[u; u̇]`. + +`zero_dummy_der` can be set to automatically set the operating point to zero for all dummy derivatives. + +See also [`linearization_function`](@ref) which provides a lower-level interface, [`linearize_symbolic`](@ref) and [`ModelingToolkit.reorder_unknowns`](@ref). + +See extended help for an example. + +The implementation and notation follows that of +["Linear Analysis Approach for Modelica Models", Allain et al. 2009](https://ep.liu.se/ecp/043/075/ecp09430097.pdf) + +# Extended help + +This example builds the following feedback interconnection and linearizes it from the input of `F` to the output of `P`. + +``` + + r ┌─────┐ ┌─────┐ ┌─────┐ +───►│ ├──────►│ │ u │ │ + │ F │ │ C ├────►│ P │ y + └─────┘ ┌►│ │ │ ├─┬─► + │ └─────┘ └─────┘ │ + │ │ + └─────────────────────┘ +``` + +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +function plant(; name) + @variables x(t) = 1 + @variables u(t)=0 y(t)=0 + eqs = [D(x) ~ -x + u + y ~ x] + ODESystem(eqs, t; name = name) +end + +function ref_filt(; name) + @variables x(t)=0 y(t)=0 + @variables u(t)=0 [input = true] + eqs = [D(x) ~ -2 * x + u + y ~ x] + ODESystem(eqs, t, name = name) +end + +function controller(kp; name) + @variables y(t)=0 r(t)=0 u(t)=0 + @parameters kp = kp + eqs = [ + u ~ kp * (r - y), + ] + ODESystem(eqs, t; name = name) +end + +@named f = ref_filt() +@named c = controller(1) +@named p = plant() + +connections = [f.y ~ c.r # filtered reference to controller reference + c.u ~ p.u # controller output to plant input + p.y ~ c.y] + +@named cl = ODESystem(connections, t, systems = [f, c, p]) + +lsys0, ssys = linearize(cl, [f.u], [p.x]) +desired_order = [f.x, p.x] +lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) + +@assert lsys.A == [-2 0; 1 -2] +@assert lsys.B == [1; 0;;] +@assert lsys.C == [0 1] +@assert lsys.D[] == 0 + +## Symbolic linearization +lsys_sym, _ = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) + +@assert substitute(lsys_sym.A, ModelingToolkit.defaults(cl)) == lsys.A +``` +""" +function linearize(sys, lin_fun::LinearizationFunction; t = 0.0, + op = Dict(), allow_input_derivatives = false, + p = DiffEqBase.NullParameters()) + prob = LinearizationProblem(lin_fun, t) + op = anydict(op) + evaluate_varmap!(op, keys(op)) + for (k, v) in op + v === nothing && continue + if symbolic_type(v) != NotSymbolic() || is_array_of_symbolics(v) + v = getu(prob, v)(prob) + end + if is_parameter(prob, Initial(k)) + setu(prob, Initial(k))(prob, v) + else + setu(prob, k)(prob, v) + end + end + p = anydict(p) + for (k, v) in p + setu(prob, k)(prob, v) + end + return solve(prob; allow_input_derivatives) +end + +function linearize(sys, inputs, outputs; op = Dict(), t = 0.0, + allow_input_derivatives = false, + zero_dummy_der = false, + kwargs...) + lin_fun, ssys = linearization_function(sys, + inputs, + outputs; + zero_dummy_der, + op, + kwargs...) + linearize(ssys, lin_fun; op, t, allow_input_derivatives), ssys +end + +""" + (; Ã, B̃, C̃, D̃) = similarity_transform(sys, T; unitary=false) + +Perform a similarity transform `T : Tx̃ = x` on linear system represented by matrices in NamedTuple `sys` such that + +``` +Ã = T⁻¹AT +B̃ = T⁻¹ B +C̃ = CT +D̃ = D +``` + +If `unitary=true`, `T` is assumed unitary and the matrix adjoint is used instead of the inverse. +""" +function similarity_transform(sys::NamedTuple, T; unitary = false) + if unitary + A = T'sys.A * T + B = T'sys.B + else + Tf = lu(T) + A = Tf \ sys.A * T + B = Tf \ sys.B + end + C = sys.C * T + D = sys.D + (; A, B, C, D) +end + +""" + reorder_unknowns(sys::NamedTuple, old, new) + +Permute the state representation of `sys` obtained from [`linearize`](@ref) so that the state unknown is changed from `old` to `new` +Example: + +``` +lsys, ssys = linearize(pid, [reference.u, measurement.u], [ctr_output.u]) +desired_order = [int.x, der.x] # Unknowns that are present in unknowns(ssys) +lsys = ModelingToolkit.reorder_unknowns(lsys, unknowns(ssys), desired_order) +``` + +See also [`ModelingToolkit.similarity_transform`](@ref) +""" +function reorder_unknowns(sys::NamedTuple, old, new) + nx = length(old) + length(new) == nx || error("old and new must have the same length") + perm = [findfirst(isequal(n), old) for n in new] + issorted(perm) && return sys # shortcut return, no reordering + P = zeros(Int, nx, nx) + for i in 1:nx # Build permutation matrix + P[i, perm[i]] = 1 + end + similarity_transform(sys, P; unitary = true) +end diff --git a/src/parameters.jl b/src/parameters.jl index f8ebfe4dd3..91121b7cbb 100644 --- a/src/parameters.jl +++ b/src/parameters.jl @@ -1,35 +1,131 @@ -import SymbolicUtils: symtype, term, hasmetadata -struct MTKParameterCtx end - -isparameter(x::Num) = isparameter(value(x)) -isparameter(x::Symbolic) = getmetadata(x, MTKParameterCtx, false) -isparameter(x) = false - -""" - toparam(s::Sym) - -Maps the variable to a paramter. -""" -toparam(s::Symbolic) = setmetadata(s, MTKParameterCtx, true) -toparam(s::Num) = Num(toparam(value(s))) - -""" - tovar(s::Sym) - -Maps the variable to a state. -""" -tovar(s::Symbolic) = setmetadata(s, MTKParameterCtx, false) -tovar(s::Num) = Num(tovar(value(s))) - -""" -$(SIGNATURES) - -Define one or more known variables. -""" -macro parameters(xs...) - Symbolics._parse_vars(:parameters, - Real, - xs, - x -> x isa Array ? toparam.(x) : toparam(x) - ) |> esc -end +import SymbolicUtils: symtype, term, hasmetadata, issym +@enum VariableType VARIABLE PARAMETER BROWNIAN +struct MTKVariableTypeCtx end + +getvariabletype(x, def = VARIABLE) = getmetadata(unwrap(x), MTKVariableTypeCtx, def) + +function isparameter(x) + x = unwrap(x) + + if x isa Symbolic && (varT = getvariabletype(x, nothing)) !== nothing + return varT === PARAMETER + #TODO: Delete this branch + elseif x isa Symbolic && Symbolics.getparent(x, false) !== false + p = Symbolics.getparent(x) + isparameter(p) || + (hasmetadata(p, Symbolics.VariableSource) && + getmetadata(p, Symbolics.VariableSource)[1] == :parameters) + elseif iscall(x) && operation(x) isa Symbolic + varT === PARAMETER || isparameter(operation(x)) + elseif iscall(x) && operation(x) == (getindex) + isparameter(arguments(x)[1]) + elseif x isa Symbolic + varT === PARAMETER + else + false + end +end + +function iscalledparameter(x) + x = unwrap(x) + return isparameter(getmetadata(x, CallWithParent, nothing)) +end + +function getcalledparameter(x) + x = unwrap(x) + # `parent` is a `CallWithMetadata` with the correct metadata, + # but no namespacing. `operation(x)` has the correct namespacing, + # but is not a `CallWithMetadata` and doesn't have any metadata. + # This approach combines both. + parent = getmetadata(x, CallWithParent) + return CallWithMetadata(operation(x), metadata(parent)) +end + +""" + toparam(s) + +Maps the variable to a parameter. +""" +function toparam(s) + if s isa Symbolics.Arr + Symbolics.wrap(toparam(Symbolics.unwrap(s))) + elseif s isa AbstractArray + map(toparam, s) + else + setmetadata(s, MTKVariableTypeCtx, PARAMETER) + end +end +toparam(s::Num) = wrap(toparam(value(s))) + +""" + tovar(s) + +Maps the variable to an unknown. +""" +tovar(s::Symbolic) = setmetadata(s, MTKVariableTypeCtx, VARIABLE) +tovar(s::Num) = Num(tovar(value(s))) + +""" +$(SIGNATURES) + +Define one or more known parameters. + +See also [`@independent_variables`](@ref), [`@variables`](@ref) and [`@constants`](@ref). +""" +macro parameters(xs...) + Symbolics._parse_vars(:parameters, + Real, + xs, + toparam) |> esc +end + +function find_types(array) + by = let set = Dict{Any, Int}(), counter = Ref(0) + x -> begin + # t = typeof(x) + + get!(set, typeof(x)) do + # if t == Float64 + # 1 + # else + counter[] += 1 + # end + end + end + end + return by.(array) +end + +function split_parameters_by_type(ps) + if ps === SciMLBase.NullParameters() + return Float64[], [] #use Float64 to avoid Any type warning + else + by = let set = Dict{Any, Int}(), counter = Ref(0) + x -> begin + get!(set, typeof(x)) do + counter[] += 1 + end + end + end + idxs = by.(ps) + split_idxs = [Int[]] + for (i, idx) in enumerate(idxs) + if idx > length(split_idxs) + push!(split_idxs, Int[]) + end + push!(split_idxs[idx], i) + end + tighten_types = x -> identity.(x) + split_ps = tighten_types.(Base.Fix1(getindex, ps).(split_idxs)) + + if ps isa StaticArray + parrs = map(x -> SArray{Tuple{size(x)...}}(x), split_ps) + split_ps = SArray{Tuple{size(parrs)...}}(parrs) + end + if length(split_ps) == 1 #Tuple not needed, only 1 type + return split_ps[1], split_idxs + else + return (split_ps...,), split_idxs + end + end +end diff --git a/src/structural_transformation/StructuralTransformations.jl b/src/structural_transformation/StructuralTransformations.jl index 38c0961446..4adc817ef8 100644 --- a/src/structural_transformation/StructuralTransformations.jl +++ b/src/structural_transformation/StructuralTransformations.jl @@ -1,44 +1,79 @@ module StructuralTransformations -const UNVISITED = typemin(Int) -const UNASSIGNED = typemin(Int) - using Setfield: @set!, @set using UnPack: @unpack +using Symbolics: unwrap, linear_expansion, fast_substitute +import Symbolics using SymbolicUtils using SymbolicUtils.Code using SymbolicUtils.Rewriters -using SymbolicUtils: similarterm, istree +using SymbolicUtils: maketerm, iscall using ModelingToolkit -using ModelingToolkit: ODESystem, var_from_nested_derivative, Differential, - states, equations, vars, Symbolic, diff2term, value, - operation, arguments, Sym, Term, simplify, solve_for, - isdiffeq, isdifferential, - get_structure, defaults, InvalidSystemException +using ModelingToolkit: ODESystem, AbstractSystem, var_from_nested_derivative, Differential, + unknowns, equations, vars, Symbolic, diff2term_with_unit, + shift2term_with_unit, value, + operation, arguments, Sym, Term, simplify, symbolic_linear_solve, + isdiffeq, isdifferential, isirreducible, + empty_substitutions, get_substitutions, + get_tearing_state, get_iv, independent_variables, + has_tearing_state, defaults, InvalidSystemException, + ExtraEquationsSystemException, + ExtraVariablesSystemException, + get_postprocess_fbody, vars!, + IncrementalCycleTracker, add_edge_checked!, topological_sort, + invalidate_cache!, Substitutions, get_or_construct_tearing_state, + filter_kwargs, lower_varname_with_unit, + lower_shift_varname_with_unit, setio, SparseMatrixCLIL, + get_fullvars, has_equations, observed, + Schedule, schedule using ModelingToolkit.BipartiteGraphs -using ModelingToolkit.SystemStructures +import .BipartiteGraphs: invview, complete +import ModelingToolkit: var_derivative!, var_derivative_graph! +using Graphs +using ModelingToolkit: algeqs, EquationsView, + SystemStructure, TransformationState, TearingState, + structural_simplify!, + isdiffvar, isdervar, isalgvar, isdiffeq, algeqs, is_only_discrete, + dervars_range, diffvars_range, algvars_range, + DiffGraph, complete!, + get_fullvars, system_subset using ModelingToolkit.DiffEqBase using ModelingToolkit.StaticArrays -using ModelingToolkit: @RuntimeGeneratedFunction, RuntimeGeneratedFunctions +using RuntimeGeneratedFunctions: @RuntimeGeneratedFunction, + RuntimeGeneratedFunctions, + drop_expr RuntimeGeneratedFunctions.init(@__MODULE__) using SparseArrays -using NonlinearSolve +using SimpleNonlinearSolve + +using DocStringExtensions -export tearing, dae_index_lowering, check_consistency +export tearing, partial_state_selection, dae_index_lowering, check_consistency +export dummy_derivative export build_torn_function, build_observed_function, ODAEProblem -export sorted_incidence_matrix +export sorted_incidence_matrix, + pantelides!, pantelides_reassemble, tearing_reassemble, find_solvables!, + linear_subsys_adjmat! +export tearing_assignments, tearing_substitution +export torn_system_jacobian_sparsity +export full_equations +export but_ordered_incidence, lowest_order_variable_mask, highest_order_variable_mask +export computed_highest_diff_variables +export shift2term, lower_shift_varname, simplify_shifts, distribute_shift include("utils.jl") include("pantelides.jl") include("bipartite_tearing/modia_tearing.jl") include("tearing.jl") +include("symbolics_tearing.jl") +include("partial_state_selection.jl") include("codegen.jl") end # module diff --git a/src/structural_transformation/bareiss.jl b/src/structural_transformation/bareiss.jl new file mode 100644 index 0000000000..7957427e5d --- /dev/null +++ b/src/structural_transformation/bareiss.jl @@ -0,0 +1,356 @@ +# Keeps compatibility with bariess code moved to Base/stdlib on older releases + +using LinearAlgebra +using SparseArrays +using SparseArrays: AbstractSparseMatrixCSC, getcolptr + +macro swap(a, b) + esc(:(($a, $b) = ($b, $a))) +end + +# https://github.com/JuliaLang/julia/pull/42678 +@static if VERSION > v"1.8.0-DEV.762" + import Base: swaprows! +else + function swaprows!(a::AbstractMatrix, i, j) + i == j && return + rows = axes(a, 1) + @boundscheck i in rows || throw(BoundsError(a, (:, i))) + @boundscheck j in rows || throw(BoundsError(a, (:, j))) + for k in axes(a, 2) + @inbounds a[i, k], a[j, k] = a[j, k], a[i, k] + end + end + function Base.circshift!(a::AbstractVector, shift::Integer) + n = length(a) + n == 0 && return + shift = mod(shift, n) + shift == 0 && return + reverse!(a, 1, shift) + reverse!(a, shift + 1, length(a)) + reverse!(a) + return a + end + function Base.swapcols!(A::AbstractSparseMatrixCSC, i, j) + i == j && return + + # For simplicity, let i denote the smaller of the two columns + j < i && @swap(i, j) + + colptr = getcolptr(A) + irow = colptr[i]:(colptr[i + 1] - 1) + jrow = colptr[j]:(colptr[j + 1] - 1) + + function rangeexchange!(arr, irow, jrow) + if length(irow) == length(jrow) + for (a, b) in zip(irow, jrow) + @inbounds @swap(arr[i], arr[j]) + end + return + end + # This is similar to the triple-reverse tricks for + # circshift!, except that we have three ranges here, + # so it ends up being 4 reverse calls (but still + # 2 overall reversals for the memory range). Like + # circshift!, there's also a cycle chasing algorithm + # with optimal memory complexity, but the performance + # tradeoffs against this implementation are non-trivial, + # so let's just do this simple thing for now. + # See https://github.com/JuliaLang/julia/pull/42676 for + # discussion of circshift!-like algorithms. + reverse!(@view arr[irow]) + reverse!(@view arr[jrow]) + reverse!(@view arr[(last(irow) + 1):(first(jrow) - 1)]) + reverse!(@view arr[first(irow):last(jrow)]) + end + rangeexchange!(rowvals(A), irow, jrow) + rangeexchange!(nonzeros(A), irow, jrow) + + if length(irow) != length(jrow) + @inbounds colptr[(i + 1):j] .+= length(jrow) - length(irow) + end + return nothing + end + function swaprows!(A::AbstractSparseMatrixCSC, i, j) + # For simplicity, let i denote the smaller of the two rows + j < i && @swap(i, j) + + rows = rowvals(A) + vals = nonzeros(A) + for col in 1:size(A, 2) + rr = nzrange(A, col) + iidx = searchsortedfirst(@view(rows[rr]), i) + has_i = iidx <= length(rr) && rows[rr[iidx]] == i + + jrange = has_i ? (iidx:last(rr)) : rr + jidx = searchsortedlast(@view(rows[jrange]), j) + has_j = jidx != 0 && rows[jrange[jidx]] == j + + if !has_j && !has_i + # Has neither row - nothing to do + continue + elseif has_i && has_j + # This column had both i and j rows - swap them + @swap(vals[rr[iidx]], vals[jrange[jidx]]) + elseif has_i + # Update the rowval and then rotate both nonzeros + # and the remaining rowvals into the correct place + rows[rr[iidx]] = j + jidx == 0 && continue + rotate_range = rr[iidx]:jrange[jidx] + circshift!(@view(vals[rotate_range]), -1) + circshift!(@view(rows[rotate_range]), -1) + else + # Same as i, but in the opposite direction + @assert has_j + rows[jrange[jidx]] = i + iidx > length(rr) && continue + rotate_range = rr[iidx]:jrange[jidx] + circshift!(@view(vals[rotate_range]), 1) + circshift!(@view(rows[rotate_range]), 1) + end + end + return nothing + end +end + +function bareiss_update!(zero!, M::StridedMatrix, k, swapto, pivot, + prev_pivot::Base.BitInteger) + flag = zero(prev_pivot) + prev_pivot = Base.MultiplicativeInverses.SignedMultiplicativeInverse(prev_pivot) + @inbounds for i in (k + 1):size(M, 2) + Mki = M[k, i] + @simd ivdep for j in (k + 1):size(M, 1) + M[j, i], r = divrem(M[j, i] * pivot - M[j, k] * Mki, prev_pivot) + flag = flag | r + end + end + iszero(flag) || error("Overflow occurred") + zero!(M, (k + 1):size(M, 1), k) +end + +function bareiss_update!(zero!, M::StridedMatrix, k, swapto, pivot, prev_pivot) + @inbounds for i in (k + 1):size(M, 2), j in (k + 1):size(M, 1) + M[j, i] = exactdiv(M[j, i] * pivot - M[j, k] * M[k, i], prev_pivot) + end + zero!(M, (k + 1):size(M, 1), k) +end + +@views function bareiss_update!(zero!, M::AbstractMatrix, k, swapto, pivot, prev_pivot) + if prev_pivot isa Base.BitInteger + prev_pivot = Base.MultiplicativeInverses.SignedMultiplicativeInverse(prev_pivot) + end + V = M[(k + 1):end, (k + 1):end] + V .= exactdiv.(V .* pivot .- M[(k + 1):end, k] * M[k, (k + 1):end]', prev_pivot) + zero!(M, (k + 1):size(M, 1), k) + if M isa AbstractSparseMatrixCSC + dropzeros!(M) + end +end + +function bareiss_update_virtual_colswap!(zero!, M::AbstractMatrix, k, swapto, pivot, + prev_pivot) + if prev_pivot isa Base.BitInteger + prev_pivot = Base.MultiplicativeInverses.SignedMultiplicativeInverse(prev_pivot) + end + V = @view M[(k + 1):end, :] + V .= @views exactdiv.(V .* pivot .- M[(k + 1):end, swapto[2]] * M[k, :]', prev_pivot) + zero!(M, (k + 1):size(M, 1), swapto[2]) +end + +bareiss_zero!(M, i, j) = M[i, j] .= zero(eltype(M)) + +function find_pivot_col(M, i) + p = findfirst(!iszero, @view M[i, i:end]) + p === nothing && return nothing + idx = CartesianIndex(i, p + i - 1) + (idx, M[idx]) +end + +function find_pivot_any(M, i) + p = findfirst(!iszero, @view M[i:end, i:end]) + p === nothing && return nothing + idx = p + CartesianIndex(i - 1, i - 1) + (idx, M[idx]) +end + +const bareiss_colswap = (Base.swapcols!, swaprows!, bareiss_update!, bareiss_zero!) +const bareiss_virtcolswap = ((M, i, j) -> nothing, swaprows!, + bareiss_update_virtual_colswap!, bareiss_zero!) + +""" + bareiss!(M, [swap_strategy]) + +Perform Bareiss's fraction-free row-reduction algorithm on the matrix `M`. +Optionally, a specific pivoting method may be specified. + +swap_strategy is an optional argument that determines how the swapping of rows and columns is performed. +bareiss_colswap (the default) swaps the columns and rows normally. +bareiss_virtcolswap pretends to swap the columns which can be faster for sparse matrices. +""" +function bareiss!(M::AbstractMatrix{T}, swap_strategy = bareiss_colswap; + find_pivot = find_pivot_any, column_pivots = nothing) where {T} + swapcols!, swaprows!, update!, zero! = swap_strategy + prev = one(eltype(M)) + n = size(M, 1) + pivot = one(T) + column_permuted = false + for k in 1:n + r = find_pivot(M, k) + r === nothing && return (k - 1, pivot, column_permuted) + (swapto, pivot) = r + if column_pivots !== nothing && k != swapto[2] + column_pivots[k] = swapto[2] + column_permuted |= true + end + if CartesianIndex(k, k) != swapto + swapcols!(M, k, swapto[2]) + swaprows!(M, k, swapto[1]) + end + update!(zero!, M, k, swapto, pivot, prev) + prev = pivot + end + return (n, pivot, column_permuted) +end + +function nullspace(A; col_order = nothing) + n = size(A, 2) + workspace = zeros(Int, 2 * n) + column_pivots = @view workspace[1:n] + pivots_cache = @view workspace[(n + 1):(2n)] + @inbounds for i in 1:n + column_pivots[i] = i + end + B = copy(A) + (rank, d, column_permuted) = bareiss!(B; column_pivots) + reduce_echelon!(B, rank, d, pivots_cache) + + # The first rank entries in col_order are columns that give a basis + # for the column space. The remainder give the free variables. + if col_order !== nothing + resize!(col_order, size(A, 2)) + col_order .= 1:size(A, 2) + for (i, cp) in enumerate(column_pivots) + @swap(col_order[i], col_order[cp]) + end + end + + fill!(pivots_cache, 0) + N = ModelingToolkit.reduced_echelon_nullspace(rank, B, pivots_cache) + apply_inv_pivot_rows!(N, column_pivots) +end + +function apply_inv_pivot_rows!(M, ipiv) + for i in size(M, 1):-1:1 + swaprows!(M, i, ipiv[i]) + end + M +end + +### +### Modified from AbstractAlgebra.jl +### +### https://github.com/Nemocas/AbstractAlgebra.jl/blob/4803548c7a945f3f7bd8c63f8bb7c79fac92b11a/LICENSE.md +function reduce_echelon!(A::AbstractMatrix{T}, rank, d, + pivots_cache = zeros(Int, size(A, 2))) where {T} + m, n = size(A) + isreduced = true + @inbounds for i in 1:rank + for j in 1:(i - 1) + if A[j, i] != zero(T) + isreduced = false + @goto out + end + end + if A[i, i] != one(T) + isreduced = false + @goto out + end + end + @label out + @inbounds for i in (rank + 1):m, j in 1:n + A[i, j] = zero(T) + end + isreduced && return A + + @inbounds if rank > 1 + t = zero(T) + q = zero(T) + d = -d + pivots = pivots_cache + np = rank + j = k = 1 + for i in 1:rank + while iszero(A[i, j]) + pivots[np + k] = j + j += 1 + k += 1 + end + pivots[i] = j + j += 1 + end + while k <= n - rank + pivots[np + k] = j + j += 1 + k += 1 + end + for k in 1:(n - rank) + for i in (rank - 1):-1:1 + t = A[i, pivots[np + k]] * d + for j in (i + 1):rank + t += A[i, pivots[j]] * A[j, pivots[np + k]] + q + end + A[i, pivots[np + k]] = exactdiv(-t, A[i, pivots[i]]) + end + end + d = -d + for i in 1:rank + for j in 1:rank + if i == j + A[j, pivots[i]] = d + else + A[j, pivots[i]] = zero(T) + end + end + end + end + return A +end + +function reduced_echelon_nullspace(rank, A::AbstractMatrix{T}, + pivots_cache = zeros(Int, size(A, 2))) where {T} + n = size(A, 2) + nullity = n - rank + U = zeros(T, n, nullity) + @inbounds if rank == 0 + for i in 1:nullity + U[i, i] = one(T) + end + elseif nullity != 0 + pivots = @view pivots_cache[1:rank] + nonpivots = @view pivots_cache[(rank + 1):n] + j = k = 1 + for i in 1:rank + while iszero(A[i, j]) + nonpivots[k] = j + j += 1 + k += 1 + end + pivots[i] = j + j += 1 + end + while k <= nullity + nonpivots[k] = j + j += 1 + k += 1 + end + d = -A[1, pivots[1]] + for i in 1:nullity + for j in 1:rank + U[pivots[j], i] = A[j, nonpivots[i]] + end + U[nonpivots[i], i] = d + end + end + return U +end diff --git a/src/structural_transformation/bipartite_tearing/modia_tearing.jl b/src/structural_transformation/bipartite_tearing/modia_tearing.jl index 35b555fdb5..5da873afdf 100644 --- a/src/structural_transformation/bipartite_tearing/modia_tearing.jl +++ b/src/structural_transformation/bipartite_tearing/modia_tearing.jl @@ -1,380 +1,130 @@ -# This code is from the Modia project and is licensed as follows: +# This code is derived from the Modia project and is licensed as follows: # https://github.com/ModiaSim/Modia.jl/blob/b61daad643ef7edd0c1ccce6bf462c6acfb4ad1a/LICENSE -################################################ -# -# Functions to tear systems of equations -# -# Author: Martin Otter, DLR-SR (first version: Jan. 14, 2017) -# -# Details are described in the paper: -# Otter, Elmqvist (2017): Transformation of Differential Algebraic Array Equations to -# Index One Form. Modelica'2017 Conference. -# -# The following utility algorithm is used below to incrementally added edges to a -# DAG (Directed Acyclic Graph). This algorithm leads to an O(n*m) worst time complexity of the -# tearing (instead of O(m*m)) where n is the number of equations and m is the number of -# variable incidences. Note, the traversals of the DAG are not performed with recursive function -# calls but with while loops and an explicit stack, in order to avoid function stack overflow -# for large algebraic loops. -# -# Bender, Fineman, Gilbert, Tarjan: -# A New Approach to Incremental Cycle Detection and Related Problems. -# ACM Transactions on Algorithms, Volume 12, Issue 2, Feb. 2016 -# http://dl.acm.org/citation.cfm?id=2756553 -# -# Text excerpt from this paper (the advantage of Algorithm N is that it -# is simple to implement and needs no sophisticated data structures) -# -# 3. A ONE-WAY-SEARCH ALGORITHM FOR DENSE GRAPHS -# [..] -# To develop the algorithm, we begin with a simple algorithm and then modify it to -# improve its running time. We call the simple algorithm Algorithm N (for “naive”). The -# algorithm initializes k(v) = 1 for each vertex v. The initial vertex levels are a weak -# topological numbering since the initial graph contains no arcs. To add an arc (v,w), -# if k(v) < k(w), merely add the arc. If, on the other hand, k(v) ≥ k(w), add the arc and -# then do a selective forward search that begins by traversing (v,w) and continues until -# the search traverses an arc into v (there is a cycle) or there are no more candidate -# arcs to traverse. To traverse an arc (x, y), if y = v, stop (there is a cycle); otherwise, if -# k(x) ≥ k(y), increase k(y) to k(x)+1 and add all arcs (y, z) to the set of candidate arcs to -# traverse. -# It is easy to show that (1) after each arc addition that does not form a cycle -# the vertex levels are a weak topological numbering, (2) Algorithm N is correct, and -# (3) 1 ≤ k(v) ≤ size(v) ≤ n for all v. Since an arc (x, y) notEqual (v,w) is only traversed as a -# result of k(x) increasing, each arc is traversed at most n times, resulting in a total time -# bound of O(nm) for all arc additions. -# -################################################ - -const Undefined = typemin(Int) - - -""" - td = TraverseDAG(G,nv) - -Generate an object td to traverse a set of equations that are -represented as a DAG (Directed Acyclic Graph). -G is the bipartite graph of all relevant equations -and nv is the largest variable number used in G (or larger). -""" -mutable struct TraverseDAG - minlevel::Int - curlevel::Int - level::Vector{Int} - lastlevel::Vector{Int} - levelStack::Vector{Int} - visitedStack::Vector{Int} - vActive::Vector{Bool} - visited::Vector{Bool} - check::Vector{Bool} - stack::Vector{Int} - eSolved::Vector{Int} - vSolved::Vector{Int} - G # Vector{ Vector{Int} } - assign::Vector{Int} - es::Vector{Int} - vs::Vector{Int} - - function TraverseDAG(G, nv::Int) - visited = fill(false, length(G)) - check = fill(false, length(G)) - vActive = fill(false, nv) - level = fill(Undefined, nv) - lastlevel = fill(Undefined, nv) - levelStack = fill(0, 0) - stack = fill(0, 0) - visitedStack = fill(0, 0) - eSolved = fill(0, 0) - vSolved = fill(0, 0) - assign = fill(0, nv) - - new(0, Undefined, level, lastlevel, levelStack, visitedStack, vActive, - visited, check, stack, eSolved, vSolved, G, assign) +function try_assign_eq!(ict::IncrementalCycleTracker, vj::Integer, eq::Integer) + G = ict.graph + add_edge_checked!(ict, Iterators.filter(!=(vj), 𝑠neighbors(G.graph, eq)), vj) do G + G.matching[vj] = eq + G.ne += length(𝑠neighbors(G.graph, eq)) - 1 end end - -""" - initAlgebraicSystem(td::TraverseDAG,es,vs) - -Define the set of equations and the set variables for which the equations shall be solved for -(equations es shall be solved for variables vs) and re-initialize td. - -eSolvedFixed/vSolvedFixed must be a DAG starting at eSolvedFixed/SolvedFixed[1] -""" -function initAlgebraicSystem(td::TraverseDAG, es::Vector{Int}, vs::Vector{Int}, - eSolvedFixed::Vector{Int}, vSolvedFixed::Vector{Int}, vTearFixed::Vector{Int}) - # check arguments - for i in eachindex(es) - if es[i] <= 0 - error("\n\n... Internal error in Tearing.jl: es[", i, "] = ", es[i], ".\n") - end - end - - for i in eachindex(vs) - if vs[i] <= 0 - error("\n\n... Internal error in Tearing.jl: vs[", i, "] = ", vs[i], ".\n") - end - end - - # check that all elements of eSolvedFixed are in es, vSolvedFixed in vs, - # vTearFixed in vs and that vSolvedFixed and vTearFixed have no variables in common - @assert( length(eSolvedFixed) == length(vSolvedFixed) ) - ediff = setdiff(eSolvedFixed, es) - @assert(length(ediff) == 0) - vdiff = setdiff(vSolvedFixed, vs) - @assert(length(vdiff) == 0) - vdiff2 = setdiff(vTearFixed, vs) - @assert(length(vdiff2) == 0) - vdiff3 = intersect(vSolvedFixed, vTearFixed) - @assert(length(vdiff3) == 0) - - # Re-initialize td - td.minlevel = 0 - td.curlevel = Undefined - for i in eachindex(td.visited) - td.visited[i] = false - td.check[i] = false +function try_assign_eq!(ict::IncrementalCycleTracker, vars, v_active, eq::Integer, + condition::F = _ -> true) where {F} + G = ict.graph + for vj in vars + (vj in v_active && G.matching[vj] === unassigned && condition(vj)) || continue + try_assign_eq!(ict, vj, eq) && return true end - - for i in eachindex(td.vActive) - td.vActive[i] = false - td.assign[i] = 0 - td.level[i] = Undefined - td.lastlevel[i] = Undefined - end - - for i in eachindex(vs) - td.vActive[ vs[i] ] = true - end - - empty!(td.levelStack) - empty!(td.stack) - empty!(td.visitedStack) - empty!(td.eSolved) - empty!(td.vSolved) - - # Define initial DAG - vs2 = Int[] - for i in eachindex(vSolvedFixed) - vFixed = vSolvedFixed[i] - td.assign[vFixed] = eSolvedFixed[i] - td.level[ vFixed] = i - push!(vs2, vFixed) - end - - for i in eachindex(vTearFixed) - td.vActive[ vTearFixed[i] ] = false # vTearFixed shall not be assigned - end - - # Store es, vs in td - td.es = es - td.vs = vs - - return vs2 -end - - -in_vs(td, v) = td.vActive[v] - -function setlevel(td::TraverseDAG, v::Int, parentLevel::Int) - td.lastlevel[v] = td.level[v] - td.level[v] = parentLevel + 1 - push!(td.visitedStack, v) + return false end - -""" - success = visit!(td::TraverseDAG, v) - -Traverse potential DAG starting from new variable node v. -If no cycle is detected return true, otherwise return false. -""" -function visit!(td::TraverseDAG, vcheck::Int) - empty!(td.stack) - empty!(td.levelStack) - empty!(td.visitedStack) - td.curlevel = td.level[vcheck] - push!(td.levelStack, td.curlevel) - push!(td.stack, vcheck) - first = true - - while length(td.stack) > 0 - parentLevel = pop!(td.levelStack) - veq = pop!(td.stack) - eq = td.assign[veq] - if first - first = false - else - if td.level[veq] == td.curlevel - # cycle detected - return false - elseif td.level[veq] == Undefined || td.level[veq] <= parentLevel - setlevel(td, veq, parentLevel) +function tearEquations!(ict::IncrementalCycleTracker, Gsolvable, es::Vector{Int}, + v_active::BitSet, isder′::F) where {F} + check_der = isder′ !== nothing + if check_der + has_der = Ref(false) + isder = let has_der = has_der, isder′ = isder′ + v -> begin + r = isder′(v) + has_der[] |= r + r end end - - if eq > 0 - # Push all child nodes on stack - parentLevel = td.level[veq] - for v in td.G[eq] - if in_vs(td, v) && v != veq # v is an element of td.vs and is not the variable to solve for - eq2 = td.assign[v] - if eq2 == 0 || td.level[v] <= parentLevel - push!(td.levelStack, parentLevel) - push!(td.stack, v) - end + end + # Heuristic: As a first pass, try to assign any equations that only have one + # solvable variable. + for only_single_solvable in (true, false) + for eq in es # iterate only over equations that are not in eSolvedFixed + vs = Gsolvable[eq] + ((length(vs) == 1) ⊻ only_single_solvable) && continue + if check_der + # if there're differentiated variables, then only consider them + try_assign_eq!(ict, vs, v_active, eq, isder) + if has_der[] + has_der[] = false + continue end end + try_assign_eq!(ict, vs, v_active, eq) end end - return true + return ict end - -""" - visit2!(td::TraverseDAG,v) - -Traverse DAG starting from variable v and store visited equations and variables in stacks -eSolved, vSolved. If a cycle is deteced, raise an error (signals a programming error). -""" -function visit2!(td::TraverseDAG, vVisit::Int) - push!(td.stack, vVisit) - while length(td.stack) > 0 - veq = td.stack[end] - eq = td.assign[veq] - if !td.visited[eq] - td.visited[eq] = true - td.check[eq] = true - for v in td.G[eq] - if in_vs(td, v) && v != veq # v is an element of td.vs and is not the variable to solve for - eq2 = td.assign[v] - if eq2 != 0 - if !td.visited[eq2] # visit eq2 if not yet visited - push!(td.stack, v) - elseif td.check[eq2] # cycle detected - error("... error in Tearing.jl code: \n", - " cycle detected (should not occur): eq = ", eq, ", veq = ", veq, ", eq2 = ", eq2, ", v = ", v) - end - end - end - end - else - td.check[eq] = false - push!(td.eSolved, eq) - push!(td.vSolved, veq) - pop!(td.stack) - end +function tear_graph_block_modia!(var_eq_matching, ict, solvable_graph, eqs, vars, + isder::F) where {F} + tearEquations!(ict, solvable_graph.fadjlist, eqs, vars, isder) + for var in vars + var_eq_matching[var] = ict.graph.matching[var] end - nothing + return nothing end - -""" - (eSolved, vSolved) = sortDAG!(td::TraverseDAG, vs) - -Sort the equations that are assigned by variables vs using object td of type TraverseDAG -and return the sorted equations eSolved and assigned variables vSolved. -""" -function sortDAG!(td::TraverseDAG, vs::Vector{Int}) - # initialize data structure - empty!(td.stack) - empty!(td.eSolved) - empty!(td.vSolved) - - for i in eachindex(td.visited) - td.visited[i] = false - td.check[i] = false - end - - # visit all assigned variables and equations - for veq in vs - if !td.visited[ td.assign[veq] ] - visit2!(td, veq) - end - end - - return (td.eSolved, td.vSolved) +function build_var_eq_matching(structure::SystemStructure, ::Type{U} = Unassigned; + varfilter::F2 = v -> true, eqfilter::F3 = eq -> true) where {U, F2, F3} + @unpack graph, solvable_graph = structure + var_eq_matching = maximal_matching(graph, eqfilter, varfilter, U) + matching_len = max(length(var_eq_matching), + maximum(x -> x isa Int ? x : 0, var_eq_matching, init = 0)) + return complete(var_eq_matching, matching_len), matching_len end - -""" - (eSolved, vSolved, eResidue, vTear) = tearEquations!(td, Gsolvable, es, vs; eSolvedFixed=Int[], vSolvedFixed=Int[], vTearFixed=Int[]) - -Equations es shall be solved with respect to variables vs. The function returns -the teared equation so that if vTear is given, vSolved can be computed from eSolved -in a forward sequence (so solving eSolved[1] for vSolved[1], eSolved[2] for vSolved[2], -and so on). vTear must be selected, so that the equations eResidues are fulfilled. -Equations es are the union of eSolved and eResidue. -Variables vs are the union of vSolved and vTear. - -Input argument td is an object of type TraverseDAG. Gsolvable defines the variables -that can be explicitly solved in every equation without influencing the solution space -(= rank preserving operation). - -eSolvedFixed/vSolvedFixed must be a DAG starting at eSolvedFixed/SolvedFixed[1] -""" -function tearEquations!(td::TraverseDAG, Gsolvable, es::Vector{Int}, vs::Vector{Int}; - eSolvedFixed::Vector{Int}=Int[], vSolvedFixed::Vector{Int}=Int[], vTearFixed::Vector{Int}=Int[]) - vs2 = initAlgebraicSystem(td, es, vs, eSolvedFixed, vSolvedFixed, vTearFixed) - # eResidue = fill(0,0) - residue = true - esReduced = setdiff(es, eSolvedFixed) - # println(" es = ", es, ", eSolvedFixed = ", eSolvedFixed, ", esReduced = ", esReduced) - # println(" vs = ", vs, ", vSolvedFixed = ", vSolvedFixed) - for eq in esReduced # iterate only over equations that are not in eSolvedFixed - residue = true - for vj in Gsolvable[eq] - if td.assign[vj] == 0 && in_vs(td, vj) - # vj is an element of vs that is not yet assigned - # Add equation to graph - td.assign[vj] = eq - - # Check for cycles - if td.level[vj] == Undefined - # (eq,vj) cannot introduce a cycle - # Introduce a new level (the smallest level that exists yet) - td.minlevel += -1 - td.level[vj] = td.minlevel - - # Inspect all childs and use level+1, if child has no level yet - for v in td.G[eq] - if in_vs(td, v) && v != vj && - (td.level[v] == Undefined || td.level[v] <= td.level[vj]) # v is an element of td.vs and is not the variable to solve for and no level yet defined - setlevel(td, v, td.level[vj]) - end - end - - push!(vs2, vj) - residue = false - break # continue with next equation - - else # Traverse DAG starting from eq - if visit!(td, vj) - # accept vj - push!(vs2, vj) - residue = false - break # continue with next equation - else - # cycle; remove vj from DAG and undo its changes - for vv in td.visitedStack - td.level[vv] = td.lastlevel[vv] - end - td.assign[vj] = 0 - # continue with next variable in equation eq - end +function tear_graph_modia(structure::SystemStructure, isder::F = nothing, + ::Type{U} = Unassigned; + varfilter::F2 = v -> true, + eqfilter::F3 = eq -> true) where {F, U, F2, F3} + # It would be possible here to simply iterate over all variables and attempt to + # use tearEquations! to produce a matching that greedily selects the minimal + # number of torn variables. However, we can do this process faster if we first + # compute the strongly connected components. In the absence of cycles and + # non-solvability, a maximal matching on the original graph will give us an + # optimal assignment. However, even with cycles, we can use the maximal matching + # to give us a good starting point for a good matching and then proceed to + # reverse edges in each scc to improve the solution. Note that it is possible + # to have optimal solutions that cannot be found by this process. We will not + # find them here [TODO: It would be good to have an explicit example of this.] + + @unpack graph, solvable_graph = structure + var_eq_matching, matching_len = build_var_eq_matching(structure, U; varfilter, eqfilter) + full_var_eq_matching = copy(var_eq_matching) + var_sccs = find_var_sccs(graph, var_eq_matching) + vargraph = DiCMOBiGraph{true}(graph, 0, Matching(matching_len)) + ict = IncrementalCycleTracker(vargraph; dir = :in) + + ieqs = Int[] + filtered_vars = BitSet() + free_eqs = free_equations(graph, var_sccs, var_eq_matching, varfilter) + is_overdetemined = !isempty(free_eqs) + for vars in var_sccs + for var in vars + if varfilter(var) + push!(filtered_vars, var) + if var_eq_matching[var] !== unassigned + ieq = var_eq_matching[var] + push!(ieqs, ieq) end end + var_eq_matching[var] = unassigned end - #if residue - # push!(eResidue, eq) - #end + tear_graph_block_modia!(var_eq_matching, ict, solvable_graph, ieqs, + filtered_vars, isder) + # If the systems is overdetemined, we cannot assume the free equations + # will not form algebraic loops with equations in the sccs. + if !is_overdetemined + vargraph.ne = 0 + for var in vars + vargraph.matching[var] = unassigned + end + end + empty!(ieqs) + empty!(filtered_vars) end - - # Determine solved equations and variables - (eSolved, vSolved) = sortDAG!(td, vs2) - vTear = setdiff(vs, vSolved) - eResidue = setdiff(es, eSolved) - return (eSolved, vSolved, eResidue, vTear) + if is_overdetemined + free_vars = findall(x -> !(x isa Int), var_eq_matching) + tear_graph_block_modia!(var_eq_matching, ict, solvable_graph, free_eqs, + BitSet(free_vars), isder) + end + return var_eq_matching, full_var_eq_matching, var_sccs end diff --git a/src/structural_transformation/codegen.jl b/src/structural_transformation/codegen.jl index 94cdfdb090..d2ca5b8748 100644 --- a/src/structural_transformation/codegen.jl +++ b/src/structural_transformation/codegen.jl @@ -1,6 +1,12 @@ -function torn_system_jacobian_sparsity(sys) - s = structure(sys) - @unpack fullvars, graph, partitions = s +using LinearAlgebra + +using ModelingToolkit: process_events, get_preprocess_constants + +const MAX_INLINE_NLSOLVE_SIZE = 8 + +function torn_system_with_nlsolve_jacobian_sparsity(state, var_eq_matching, var_sccs, + nlsolve_scc_idxs, eqs_idxs, states_idxs) + graph = state.structure.graph # The sparsity pattern of `nlsolve(f, u, p)` w.r.t `p` is difficult to # determine in general. Consider the "simplest" case, a linear system. We @@ -21,7 +27,7 @@ function torn_system_jacobian_sparsity(sys) # 0 # ``` # - # Let 𝑇 be the set of tearing variables and 𝑉 be the set of all *states* in + # Let 𝑇 be the set of tearing variables and 𝑉 be the set of all *unknowns* in # the residual equations. In the following code, we are going to assume the # connection between 𝑇 (the `u` in from above) and 𝑉 ∖ 𝑇 (the `p` in from # above) has full incidence. @@ -31,321 +37,480 @@ function torn_system_jacobian_sparsity(sys) # from other partitions. # # We know that partitions are BLT ordered. Hence, the tearing variables in - # each partition is unique, and all states in a partition must be + # each partition is unique, and all unknowns in a partition must be # either differential variables or algebraic tearing variables that are # from previous partitions. Hence, we can build the dependency chain as we # traverse the partitions. - # `avars2dvars` maps a algebraic variable to its differential variable - # dependencies. - avars2dvars = Dict{Int,Set{Int}}() - c = 0 - for partition in partitions - @unpack e_residual, v_residual = partition - # initialization - for tvar in v_residual - avars2dvars[tvar] = Set{Int}() + var_rename = ones(Int64, ndsts(graph)) + nlsolve_vars = Int[] + for i in nlsolve_scc_idxs, c in var_sccs[i] + append!(nlsolve_vars, c) + for v in c + var_rename[v] = 0 end - for teq in e_residual - c += 1 - for var in 𝑠neighbors(graph, teq) - # Skip the tearing variables in the current partition, because - # we are computing them from all the other states. - LightGraphs.insorted(var, v_residual) && continue - deps = get(avars2dvars, var, nothing) - if deps === nothing # differential variable - @assert !isalgvar(s, var) - for tvar in v_residual - push!(avars2dvars[tvar], var) - end - else # tearing variable from previous partitions - @assert isalgvar(s, var) - for tvar in v_residual - union!(avars2dvars[tvar], avars2dvars[var]) - end - end + end + masked_cumsum!(var_rename) + + dig = DiCMOBiGraph{true}(graph, var_eq_matching) + + fused_var_deps = map(1:ndsts(graph)) do v + BitSet(v′ for v′ in neighborhood(dig, v, Inf; dir = :in) if var_rename[v′] != 0) + end + + for scc in var_sccs[nlsolve_scc_idxs] + if length(scc) >= 2 + deps = fused_var_deps[scc[1]] + for c in 2:length(scc) + union!(deps, fused_var_deps[c]) + fused_var_deps[c] = deps end end end - dvrange = diffvars_range(s) - dvar2idx = Dict(v=>i for (i, v) in enumerate(dvrange)) - I = Int[]; J = Int[] - eqidx = 0 + var2idx = Dict{Int, Int}(v => i for (i, v) in enumerate(states_idxs)) + eqs2idx = Dict{Int, Int}(v => i for (i, v) in enumerate(eqs_idxs)) + + I = Int[] + J = Int[] + s = state.structure for ieq in 𝑠vertices(graph) - isalgeq(s, ieq) && continue - eqidx += 1 + nieq = get(eqs2idx, ieq, 0) + nieq == 0 && continue for ivar in 𝑠neighbors(graph, ieq) - if isdiffvar(s, ivar) - push!(I, eqidx) - push!(J, dvar2idx[ivar]) - elseif isalgvar(s, ivar) - for dvar in avars2dvars[ivar] - push!(I, eqidx) - push!(J, dvar2idx[dvar]) + isdervar(s, ivar) && continue + if var_rename[ivar] != 0 + push!(I, nieq) + push!(J, var2idx[ivar]) + else + for dvar in fused_var_deps[ivar] + isdervar(s, dvar) && continue + niv = get(var2idx, dvar, 0) + niv == 0 && continue + push!(I, nieq) + push!(J, niv) end end end end - sparse(I, J, true) + sparse(I, J, true, length(eqs_idxs), length(states_idxs)) end -""" - partitions_dag(s::SystemStructure) +function gen_nlsolve!(is_not_prepended_assignment, eqs, vars, u0map::AbstractDict, + assignments, (deps, invdeps), var2assignment; checkbounds = true) + isempty(vars) && throw(ArgumentError("vars may not be empty")) + length(eqs) == length(vars) || + throw(ArgumentError("vars must be of the same length as the number of equations to find the roots of")) + rhss = map(x -> x.rhs, eqs) + # We use `vars` instead of `graph` to capture parameters, too. + paramset = ModelingToolkit.vars(r for r in rhss) -Return a DAG (sparse matrix) of partitions to use for parallelism. -""" -function partitions_dag(s::SystemStructure) - @unpack partitions, graph = s - - # `partvars[i]` contains all the states that appear in `partitions[i]` - partvars = map(partitions) do partition - ipartvars = Set{Int}() - for req in partition.e_residual - union!(ipartvars, 𝑠neighbors(graph, req)) + # Compute necessary assignments for the nlsolve expr + init_assignments = [var2assignment[p] for p in paramset if haskey(var2assignment, p)] + if isempty(init_assignments) + needed_assignments_idxs = Int[] + needed_assignments = similar(assignments, 0) + else + tmp = [init_assignments] + # `deps[init_assignments]` gives the dependency of `init_assignments` + while true + next_assignments = unique(reduce(vcat, deps[init_assignments])) + isempty(next_assignments) && break + init_assignments = next_assignments + push!(tmp, init_assignments) end - ipartvars + needed_assignments_idxs = unique(reduce(vcat, reverse(tmp))) + needed_assignments = assignments[needed_assignments_idxs] end - I, J = Int[], Int[] - n = length(partitions) - for (i, partition) in enumerate(partitions) - for j in i+1:n - # The only way for a later partition `j` to depend on an earlier - # partition `i` is when `partvars[j]` contains one of tearing - # variables of partition `i`. - if !isdisjoint(partvars[j], partition.v_residual) - # j depends on i - push!(I, i) - push!(J, j) - end + # Compute `params`. They are like enclosed variables + rhsvars = [ModelingToolkit.vars(r.rhs) for r in needed_assignments] + vars_set = Set(vars) + outer_set = BitSet() + inner_set = BitSet() + for (i, vs) in enumerate(rhsvars) + j = needed_assignments_idxs[i] + if isdisjoint(vars_set, vs) + push!(outer_set, j) + else + push!(inner_set, j) end end + init_refine = BitSet() + for i in inner_set + union!(init_refine, invdeps[i]) + end + intersect!(init_refine, outer_set) + setdiff!(outer_set, init_refine) + union!(inner_set, init_refine) + + next_refine = BitSet() + while true + for i in init_refine + id = invdeps[i] + isempty(id) && break + union!(next_refine, id) + end + intersect!(next_refine, outer_set) + isempty(next_refine) && break + setdiff!(outer_set, next_refine) + union!(inner_set, next_refine) - sparse(I, J, true, n, n) -end - -function gen_nlsolve(sys, eqs, vars; checkbounds=true) - @assert !isempty(vars) - @assert length(eqs) == length(vars) - rhss = map(x->x.rhs, eqs) - # We use `vars` instead of `graph` to capture parameters, too. - allvars = unique(collect(Iterators.flatten(map(ModelingToolkit.vars, rhss)))) - params = setdiff(allvars, vars) + init_refine, next_refine = next_refine, init_refine + empty!(next_refine) + end + global2local = Dict(j => i for (i, j) in enumerate(needed_assignments_idxs)) + inner_idxs = [global2local[i] for i in collect(inner_set)] + outer_idxs = [global2local[i] for i in collect(outer_set)] + extravars = reduce(union!, rhsvars[inner_idxs], init = Set()) + union!(paramset, extravars) + setdiff!(paramset, vars) + setdiff!(paramset, [needed_assignments[i].lhs for i in inner_idxs]) + union!(paramset, [needed_assignments[i].lhs for i in outer_idxs]) + params = collect(paramset) - u0map = defaults(sys) # splatting to tighten the type - u0 = [map(var->get(u0map, var, 1e-3), vars)...] + u0 = [] + for v in vars + v in keys(u0map) || (push!(u0, 1e-3); continue) + u = substitute(v, u0map) + for i in 1:length(u0map) + u = substitute(u, u0map) + u isa Number && (push!(u0, u); break) + end + u isa Number || error("$v doesn't have a default.") + end + u0 = [u0...] # specialize on the scalar case isscalar = length(u0) == 1 u0 = isscalar ? u0[1] : SVector(u0...) fname = gensym("fun") + # f is the function to find roots on + if isscalar + funex = rhss[1] + pre = get_preprocess_constants(funex) + else + funex = MakeArray(rhss, SVector) + pre = get_preprocess_constants(rhss) + end f = Func( - [ - DestructuredArgs(vars, inbounds=!checkbounds) - DestructuredArgs(params, inbounds=!checkbounds) - ], + [DestructuredArgs(vars, inbounds = !checkbounds) + DestructuredArgs(params, inbounds = !checkbounds)], [], - isscalar ? rhss[1] : MakeArray(rhss, SVector) - ) |> SymbolicUtils.Code.toexpr + pre(Let(needed_assignments[inner_idxs], + funex, + false))) |> SymbolicUtils.Code.toexpr + # solver call contains code to call the root-finding solver on the function f solver_call = LiteralExpr(quote - $numerical_nlsolve( - $fname, - # initial guess - $u0, - # "captured variables" - ($(params...),) - ) - end) - - [ - fname ← @RuntimeGeneratedFunction(f) - DestructuredArgs(vars, inbounds=!checkbounds) ← solver_call - ] + $numerical_nlsolve($fname, + # initial guess + $u0, + # "captured variables" + ($(params...),)) + end) + + preassignments = [] + for i in outer_idxs + ii = needed_assignments_idxs[i] + is_not_prepended_assignment[ii] || continue + is_not_prepended_assignment[ii] = false + push!(preassignments, assignments[ii]) + end + + nlsolve_expr = Assignment[preassignments + fname ← drop_expr(@RuntimeGeneratedFunction(f)) + DestructuredArgs(vars, inbounds = !checkbounds) ← solver_call] + + nlsolve_expr end -function get_torn_eqs_vars(sys) - s = structure(sys) - partitions = s.partitions - vars = s.fullvars +function build_torn_function(sys; + expression = false, + jacobian_sparsity = true, + checkbounds = false, + max_inlining_size = nothing, + kw...) + max_inlining_size = something(max_inlining_size, MAX_INLINE_NLSOLVE_SIZE) + rhss = [] eqs = equations(sys) + eqs_idxs = Int[] + for (i, eq) in enumerate(eqs) + isdiffeq(eq) || continue + push!(eqs_idxs, i) + push!(rhss, eq.rhs) + end - torn_eqs = map(idxs-> eqs[idxs], map(x->x.e_residual, partitions)) - torn_vars = map(idxs->vars[idxs], map(x->x.v_residual, partitions)) - - gen_nlsolve.((sys,), torn_eqs, torn_vars) -end + state = get_or_construct_tearing_state(sys) + fullvars = state.fullvars + var_eq_matching, var_sccs = algebraic_variables_scc(state) + condensed_graph = MatchedCondensationGraph( + DiCMOBiGraph{true}(complete(state.structure.graph), + complete(var_eq_matching)), + var_sccs) + toporder = topological_sort_by_dfs(condensed_graph) + var_sccs = var_sccs[toporder] + + unknowns_idxs = collect(diffvars_range(state.structure)) + mass_matrix_diag = ones(length(unknowns_idxs)) + + assignments, deps, sol_states = tearing_assignments(sys) + invdeps = map(_ -> BitSet(), deps) + for (i, d) in enumerate(deps) + for a in d + push!(invdeps[a], i) + end + end + var2assignment = Dict{Any, Int}(eq.lhs => i for (i, eq) in enumerate(assignments)) + is_not_prepended_assignment = trues(length(assignments)) -function build_torn_function( - sys; - expression=false, - jacobian_sparsity=true, - checkbounds=false, - kw... - ) + torn_expr = Assignment[] - rhss = [] - for eq in equations(sys) - isdiffeq(eq) && push!(rhss, eq.rhs) + defs = defaults(sys) + nlsolve_scc_idxs = Int[] + + needs_extending = false + @views for (i, scc) in enumerate(var_sccs) + torn_vars_idxs = Int[var for var in scc if var_eq_matching[var] !== unassigned] + torn_eqs_idxs = [var_eq_matching[var] for var in torn_vars_idxs] + isempty(torn_eqs_idxs) && continue + if length(torn_eqs_idxs) <= max_inlining_size + nlsolve_expr = gen_nlsolve!(is_not_prepended_assignment, eqs[torn_eqs_idxs], + fullvars[torn_vars_idxs], defs, assignments, + (deps, invdeps), var2assignment, + checkbounds = checkbounds) + append!(torn_expr, nlsolve_expr) + push!(nlsolve_scc_idxs, i) + else + needs_extending = true + append!(eqs_idxs, torn_eqs_idxs) + append!(rhss, map(x -> x.rhs, eqs[torn_eqs_idxs])) + append!(unknowns_idxs, torn_vars_idxs) + append!(mass_matrix_diag, zeros(length(torn_eqs_idxs))) + end end + sort!(unknowns_idxs) + + mass_matrix = needs_extending ? Diagonal(mass_matrix_diag) : I out = Sym{Any}(gensym("out")) - odefunbody = SetArray( - checkbounds, + funbody = SetArray(!checkbounds, out, - rhss - ) + rhss) - s = structure(sys) - states = map(i->s.fullvars[i], diffvars_range(s)) - syms = map(Symbol, states) + unknown_vars = Any[fullvars[i] for i in unknowns_idxs] + @set! sys.solved_unknowns = unknown_vars + + pre = get_postprocess_fbody(sys) + cpre = get_preprocess_constants(rhss) + pre2 = x -> pre(cpre(x)) expr = SymbolicUtils.Code.toexpr( Func( - [ - out - DestructuredArgs(states, inbounds=!checkbounds) - DestructuredArgs(parameters(sys), inbounds=!checkbounds) - independent_variable(sys) - ], - [], - Let( - collect(Iterators.flatten(get_torn_eqs_vars(sys))), - odefunbody - ) - ) - ) + [out + DestructuredArgs(unknown_vars, + inbounds = !checkbounds) + DestructuredArgs(parameters(sys), + inbounds = !checkbounds) + independent_variables(sys)], + [], + pre2(Let([torn_expr; + assignments[is_not_prepended_assignment]], + funbody, + false))), + sol_states) if expression - expr + expr, unknown_vars else - observedfun = let sys = sys, dict = Dict() - function generated_observed(obsvar, u, p, t) + observedfun = let state = state, + dict = Dict(), + is_solver_unknown_idxs = insorted.(1:length(fullvars), (unknowns_idxs,)), + assignments = assignments, + deps = (deps, invdeps), + sol_states = sol_states, + var2assignment = var2assignment + + function generated_observed(obsvar, args...) obs = get!(dict, value(obsvar)) do - build_observed_function(sys, obsvar, checkbounds=checkbounds) + build_observed_function(state, obsvar, var_eq_matching, var_sccs, + is_solver_unknown_idxs, assignments, deps, + sol_states, var2assignment, + checkbounds = checkbounds) + end + if args === () + let obs = obs + (u, p, t) -> obs(u, p, t) + end + else + obs(args...) end - obs(u, p, t) end end - ODEFunction{true}( - @RuntimeGeneratedFunction(expr), - sparsity = torn_system_jacobian_sparsity(sys), - syms = syms, - observed = observedfun, - ) + ODEFunction{true, SciMLBase.AutoSpecialize}( + drop_expr(@RuntimeGeneratedFunction(expr)), + sparsity = jacobian_sparsity ? + torn_system_with_nlsolve_jacobian_sparsity(state, + var_eq_matching, + var_sccs, + nlsolve_scc_idxs, + eqs_idxs, + unknowns_idxs) : + nothing, + observed = observedfun, + mass_matrix = mass_matrix, + sys = sys), + unknown_vars end end """ - find_solve_sequence(partitions, vars) + find_solve_sequence(sccs, vars) given a set of `vars`, find the groups of equations we need to solve for to obtain the solution to `vars` """ -function find_solve_sequence(partitions, vars) - subset = filter(x -> !isdisjoint(x.v_residual, vars), partitions) +function find_solve_sequence(sccs, vars) + subset = filter(i -> !isdisjoint(sccs[i], vars), 1:length(sccs)) isempty(subset) && return [] - vars′ = mapreduce(x->x.v_residual, union, subset) + vars′ = mapreduce(i -> sccs[i], union, subset) if vars′ == vars return subset else - return find_solve_sequence(partitions, vars′) + return find_solve_sequence(sccs, vars′) end end -function build_observed_function( - sys, syms; - expression=false, - output_type=Array, - checkbounds=true - ) - - if (isscalar = !(syms isa Vector)) - syms = [syms] +function build_observed_function(state, ts, var_eq_matching, var_sccs, + is_solver_unknown_idxs, + assignments, + deps, + sol_states, + var2assignment; + expression = false, + output_type = Array, + checkbounds = true) + is_not_prepended_assignment = trues(length(assignments)) + if (isscalar = !(ts isa AbstractVector)) + ts = [ts] end - syms = value.(syms) - syms_set = Set(syms) - s = structure(sys) - @unpack partitions, fullvars, graph = s - diffvars = map(i->fullvars[i], diffvars_range(s)) - algvars = map(i->fullvars[i], algvars_range(s)) - - required_algvars = Set(intersect(algvars, syms_set)) + ts = unwrap.(Symbolics.scalarize(ts)) + + vars = Set() + sys = state.sys + foreach(Base.Fix1(vars!, vars), ts) + ivs = independent_variables(sys) + dep_vars = collect(setdiff(vars, ivs)) + + fullvars = state.fullvars + s = state.structure + unknown_vars = fullvars[is_solver_unknown_idxs] + algvars = fullvars[.!is_solver_unknown_idxs] + + required_algvars = Set(intersect(algvars, vars)) obs = observed(sys) - observed_idx = Dict(map(x->x.lhs, obs) .=> 1:length(obs)) - # FIXME: this is a rather rough estimate of dependencies. + observed_idx = Dict(x.lhs => i for (i, x) in enumerate(obs)) + namespaced_to_obs = Dict(unknowns(sys, x.lhs) => x.lhs for x in obs) + namespaced_to_sts = Dict(unknowns(sys, x) => x for x in unknowns(sys)) + sts = Set(unknowns(sys)) + + # FIXME: This is a rather rough estimate of dependencies. We assume + # the expression depends on everything before the `maxidx`. + subs = Dict() maxidx = 0 - for (i, s) in enumerate(syms) + for (i, s) in enumerate(dep_vars) idx = get(observed_idx, s, nothing) - idx === nothing && continue - idx > maxidx && (maxidx = idx) + if idx !== nothing + idx > maxidx && (maxidx = idx) + else + s′ = get(namespaced_to_obs, s, nothing) + if s′ !== nothing + subs[s] = s′ + s = s′ + idx = get(observed_idx, s, nothing) + end + if idx !== nothing + idx > maxidx && (maxidx = idx) + elseif !(s in sts) + s′ = get(namespaced_to_sts, s, nothing) + if s′ !== nothing + subs[s] = s′ + continue + end + throw(ArgumentError("$s is either an observed nor an unknown variable.")) + end + continue + end end + ts = map(t -> substitute(t, subs), ts) + vs = Set() for idx in 1:maxidx - vs = vars(obs[idx].rhs) + vars!(vs, obs[idx].rhs) union!(required_algvars, intersect(algvars, vs)) + empty!(vs) + end + for eq in assignments + vars!(vs, eq.rhs) + union!(required_algvars, intersect(algvars, vs)) + empty!(vs) end - varidxs = findall(x->x in required_algvars, fullvars) - subset = find_solve_sequence(partitions, varidxs) + varidxs = findall(x -> x in required_algvars, fullvars) + subset = find_solve_sequence(var_sccs, varidxs) if !isempty(subset) eqs = equations(sys) - torn_eqs = map(idxs-> eqs[idxs.e_residual], subset) - torn_vars = map(idxs->fullvars[idxs.v_residual], subset) - - solves = gen_nlsolve.((sys,), torn_eqs, torn_vars; checkbounds=checkbounds) + nested_torn_vars_idxs = [] + for iscc in subset + torn_vars_idxs = Int[var + for var in var_sccs[iscc] + if var_eq_matching[var] !== unassigned] + isempty(torn_vars_idxs) || push!(nested_torn_vars_idxs, torn_vars_idxs) + end + torn_eqs = [[eqs[var_eq_matching[i]] for i in idxs] + for idxs in nested_torn_vars_idxs] + torn_vars = [fullvars[idxs] for idxs in nested_torn_vars_idxs] + u0map = defaults(sys) + assignments = copy(assignments) + solves = map(zip(torn_eqs, torn_vars)) do (eqs, vars) + gen_nlsolve!(is_not_prepended_assignment, eqs, vars, + u0map, assignments, deps, var2assignment; + checkbounds = checkbounds) + end else solves = [] end - output = map(syms) do sym - if sym in required_algvars - sym - else - obs[observed_idx[sym]].rhs - end + subs = [] + for sym in vars + eqidx = get(observed_idx, sym, nothing) + eqidx === nothing && continue + push!(subs, sym ← obs[eqidx].rhs) end - - ex = Func( - [ - DestructuredArgs(diffvars, inbounds=!checkbounds) - DestructuredArgs(parameters(sys), inbounds=!checkbounds) - independent_variable(sys) - ], - [], - Let( - [ - collect(Iterators.flatten(solves)) - map(eq -> eq.lhs←eq.rhs, obs[1:maxidx]) - ], - isscalar ? output[1] : MakeArray(output, output_type) - ) - ) |> Code.toexpr - - expression ? ex : @RuntimeGeneratedFunction(ex) -end - -struct ODAEProblem{iip} + pre = get_postprocess_fbody(sys) + cpre = get_preprocess_constants([obs[1:maxidx]; + isscalar ? ts[1] : MakeArray(ts, output_type)]) + pre2 = x -> pre(cpre(x)) + ex = Code.toexpr( + Func( + [DestructuredArgs(unknown_vars, inbounds = !checkbounds) + DestructuredArgs(parameters(sys), inbounds = !checkbounds) + independent_variables(sys)], + [], + pre2(Let( + [collect(Iterators.flatten(solves)) + assignments[is_not_prepended_assignment] + map(eq -> eq.lhs ← eq.rhs, obs[1:maxidx]) + subs], + isscalar ? ts[1] : MakeArray(ts, output_type), + false))), + sol_states) + + expression ? ex : drop_expr(@RuntimeGeneratedFunction(ex)) end -ODAEProblem(args...; kw...) = ODAEProblem{true}(args...; kw...) -function ODAEProblem{iip}( - sys, - u0map, - tspan, - parammap=DiffEqBase.NullParameters(); - kw... - ) where {iip} - s = structure(sys) - @unpack fullvars = s - dvs = map(i->fullvars[i], diffvars_range(s)) - ps = parameters(sys) - defs = defaults(sys) - - u0 = ModelingToolkit.varmap_to_vars(u0map, dvs; defaults=defs) - p = ModelingToolkit.varmap_to_vars(parammap, ps; defaults=defs) +struct ODAEProblem{iip} end - ODEProblem{iip}(build_torn_function(sys; kw...), u0, tspan, p; kw...) -end +@deprecate ODAEProblem(args...; kw...) ODEProblem(args...; kw...) +@deprecate ODAEProblem{iip}(args...; kw...) where {iip} ODEProblem{iip}(args...; kw...) diff --git a/src/structural_transformation/pantelides.jl b/src/structural_transformation/pantelides.jl index 9684805caf..b6877d65f8 100644 --- a/src/structural_transformation/pantelides.jl +++ b/src/structural_transformation/pantelides.jl @@ -2,43 +2,41 @@ ### Reassemble: structural information -> system ### -function pantelides_reassemble(sys, eqassoc, assign) - s = structure(sys) - @unpack fullvars, varassoc = s +function pantelides_reassemble(state::TearingState, var_eq_matching) + fullvars = state.fullvars + @unpack var_to_diff, eq_to_diff = state.structure + sys = state.sys # Step 1: write derivative equations in_eqs = equations(sys) - out_eqs = Vector{Any}(undef, length(eqassoc)) + out_eqs = Vector{Any}(undef, nv(eq_to_diff)) fill!(out_eqs, nothing) out_eqs[1:length(in_eqs)] .= in_eqs - out_vars = Vector{Any}(undef, length(varassoc)) + out_vars = Vector{Any}(undef, nv(var_to_diff)) fill!(out_vars, nothing) out_vars[1:length(fullvars)] .= fullvars - D = Differential(independent_variable(sys)) + iv = get_iv(sys) + D = Differential(iv) - for (i, v) in enumerate(varassoc) - # fullvars[v] = D(fullvars[i]) - v == 0 && continue - vi = out_vars[i] - @assert vi !== nothing "Something went wrong on reconstructing states from variable association list" + for (varidx, diff) in edges(var_to_diff) + # fullvars[diff] = D(fullvars[var]) + vi = out_vars[varidx] + @assert vi!==nothing "Something went wrong on reconstructing unknowns from variable association list" # `fullvars[i]` needs to be not a `D(...)`, because we want the DAE to be # first-order. if isdifferential(vi) - vi = out_vars[i] = diff2term(vi) + vi = out_vars[varidx] = diff2term_with_unit(vi, iv) end - out_vars[v] = D(vi) + out_vars[diff] = D(vi) end d_dict = Dict(zip(fullvars, 1:length(fullvars))) lhss = Set{Any}([x.lhs for x in in_eqs if isdiffeq(x)]) - for (i, e) in enumerate(eqassoc) - if e === 0 - continue - end - # LHS variable is looked up from varassoc - # the varassoc[i]-th variable is the differentiated version of var at i - eq = out_eqs[i] + for (eqidx, diff) in edges(eq_to_diff) + # LHS variable is looked up from var_to_diff + # the var_to_diff[i]-th variable is the differentiated version of var at i + eq = out_eqs[eqidx] lhs = if !(eq.lhs isa Symbolic) 0 elseif isdiffeq(eq) @@ -56,40 +54,99 @@ function pantelides_reassemble(sys, eqassoc, assign) D(eq.lhs) end rhs = ModelingToolkit.expand_derivatives(D(eq.rhs)) - substitution_dict = Dict(x.lhs => x.rhs for x in out_eqs if x !== nothing && x.lhs isa Symbolic) + substitution_dict = Dict(x.lhs => x.rhs + for x in out_eqs if x !== nothing && x.lhs isa Symbolic) sub_rhs = substitute(rhs, substitution_dict) - out_eqs[e] = lhs ~ sub_rhs + out_eqs[diff] = lhs ~ sub_rhs end - final_vars = unique(filter(x->!(operation(x) isa Differential), fullvars)) - final_eqs = map(identity, filter(x->value(x.lhs) !== nothing, out_eqs[sort(filter(x->x != UNASSIGNED, assign))])) + final_vars = unique(filter(x -> !(operation(x) isa Differential), fullvars)) + final_eqs = map(identity, + filter(x -> value(x.lhs) !== nothing, + out_eqs[sort(filter(x -> x !== unassigned, var_eq_matching))])) @set! sys.eqs = final_eqs - @set! sys.states = final_vars - @set! sys.structure = nothing + @set! sys.unknowns = final_vars return sys end """ - pantelides!(sys::ODESystem; kwargs...) + computed_highest_diff_variables(structure) + +Computes which variables are the "highest-differentiated" for purposes of +pantelides. Ordinarily this is relatively straightforward. However, in our +case, there is one complicating condition: + + We allow variables in the structure graph that don't appear in the + system at all. What we are interested in is the highest-differentiated + variable that actually appears in the system. + +This function takes care of these complications are returns a boolean array +for every variable, indicating whether it is considered "highest-differentiated". +""" +function computed_highest_diff_variables(structure) + @unpack graph, var_to_diff = structure + + nvars = length(var_to_diff) + varwhitelist = falses(nvars) + for var in 1:nvars + if var_to_diff[var] === nothing && !varwhitelist[var] + # This variable is structurally highest-differentiated, but may not actually appear in the + # system (complication 1 above). Ascend the differentiation graph to find the highest + # differentiated variable that does appear in the system or the alias graph). + while isempty(𝑑neighbors(graph, var)) + var′ = invview(var_to_diff)[var] + var′ === nothing && break + var = var′ + end + varwhitelist[var] = true + end + end + + # Remove any variables from the varwhitelist for whom a higher-differentiated + # var is already on the whitelist. + for var in 1:nvars + varwhitelist[var] || continue + var′ = var + while (var′ = var_to_diff[var′]) !== nothing + if varwhitelist[var′] + varwhitelist[var] = false + break + end + end + end + + return varwhitelist +end + +""" + pantelides!(state::TransformationState; kwargs...) Perform Pantelides algorithm. """ -function pantelides!(sys; maxiters = 8000) - s = structure(sys) - # D(j) = assoc[j] - @unpack graph, fullvars, varassoc = s - iv = independent_variable(sys) +function pantelides!( + state::TransformationState; finalize = true, maxiters = 8000, kwargs...) + @unpack graph, solvable_graph, var_to_diff, eq_to_diff = state.structure neqs = nsrcs(graph) - nvars = length(varassoc) + nvars = nv(var_to_diff) vcolor = falses(nvars) ecolor = falses(neqs) - assign = fill(UNASSIGNED, nvars) - eqassoc = fill(0, neqs) + var_eq_matching = Matching(nvars) neqs′ = neqs - D = Differential(iv) + nnonemptyeqs = count( + eq -> !isempty(𝑠neighbors(graph, eq)) && eq_to_diff[eq] === nothing, + 1:neqs′) + + varwhitelist = computed_highest_diff_variables(state.structure) + + if nnonemptyeqs > count(varwhitelist) + throw(InvalidSystemException("System is structurally singular")) + end + for k in 1:neqs′ eq′ = k + eq_to_diff[eq′] === nothing || continue + isempty(𝑠neighbors(graph, eq′)) && continue pathfound = false # In practice, `maxiters=8000` should never be reached, otherwise, the # index would be on the order of thousands. @@ -98,57 +155,65 @@ function pantelides!(sys; maxiters = 8000) # # the derivatives and algebraic variables are zeros in the variable # association list - varwhitelist = varassoc .== 0 resize!(vcolor, nvars) fill!(vcolor, false) resize!(ecolor, neqs) fill!(ecolor, false) - pathfound = find_augmenting_path(graph, eq′, assign, varwhitelist, vcolor, ecolor) + pathfound = construct_augmenting_path!(var_eq_matching, graph, eq′, + v -> varwhitelist[v], vcolor, ecolor) pathfound && break # terminating condition - for var in eachindex(vcolor); vcolor[var] || continue - # introduce a new variable - nvars += 1 - add_vertex!(graph, DST) - # the new variable is the derivative of `var` - varassoc[var] = nvars - push!(varassoc, 0) - push!(assign, UNASSIGNED) + if is_only_discrete(state.structure) + error("The discrete system has high structural index. This is not supported.") + end + for var in eachindex(vcolor) + vcolor[var] || continue + if var_to_diff[var] === nothing + # introduce a new variable + nvars += 1 + var_diff = var_derivative!(state, var) + push!(var_eq_matching, unassigned) + push!(varwhitelist, false) + @assert length(var_eq_matching) == var_diff + end + varwhitelist[var] = false + varwhitelist[var_to_diff[var]] = true end - for eq in eachindex(ecolor); ecolor[eq] || continue + for eq in eachindex(ecolor) + ecolor[eq] || continue # introduce a new equation neqs += 1 - add_vertex!(graph, SRC) - # the new equation is created by differentiating `eq` - eqassoc[eq] = neqs - for var in 𝑠neighbors(graph, eq) - add_edge!(graph, neqs, var) - add_edge!(graph, neqs, varassoc[var]) - end - push!(eqassoc, 0) + eq_derivative!(state, eq; kwargs...) end - for var in eachindex(vcolor); vcolor[var] || continue + for var in eachindex(vcolor) + vcolor[var] || continue # the newly introduced `var`s and `eq`s have the inherits # assignment - assign[varassoc[var]] = eqassoc[assign[var]] + var_eq_matching[var_to_diff[var]] = eq_to_diff[var_eq_matching[var]] end - eq′ = eqassoc[eq′] + eq′ = eq_to_diff[eq′] end # for _ in 1:maxiters - pathfound || error("maxiters=$maxiters reached! File a bug report if your system has a reasonable index (<100), and you are using the default `maxiters`. Try to increase the maxiters by `pantelides(sys::ODESystem; maxiters=1_000_000)` if your system has an incredibly high index and it is truly extremely large.") + pathfound || + error("maxiters=$maxiters reached! File a bug report if your system has a reasonable index (<100), and you are using the default `maxiters`. Try to increase the maxiters by `pantelides(sys::ODESystem; maxiters=1_000_000)` if your system has an incredibly high index and it is truly extremely large.") end # for k in 1:neqs′ - return sys, assign, eqassoc + + finalize && for var in 1:ndsts(graph) + varwhitelist[var] && continue + var_eq_matching[var] = unassigned + end + return var_eq_matching end """ - dae_index_lowering(sys::ODESystem) -> ODESystem + dae_index_lowering(sys::ODESystem; kwargs...) -> ODESystem Perform the Pantelides algorithm to transform a higher index DAE to an index 1 -DAE. +DAE. `kwargs` are forwarded to [`pantelides!`](@ref). End users are encouraged to call [`structural_simplify`](@ref) +instead, which calls this function internally. """ function dae_index_lowering(sys::ODESystem; kwargs...) - s = get_structure(sys) - (s isa SystemStructure) || (sys = initialize_system_structure(sys)) - sys, assign, eqassoc = pantelides!(sys; kwargs...) - return pantelides_reassemble(sys, eqassoc, assign) + state = TearingState(sys) + var_eq_matching = pantelides!(state; finalize = false, kwargs...) + return invalidate_cache!(pantelides_reassemble(state, var_eq_matching)) end diff --git a/src/structural_transformation/partial_state_selection.jl b/src/structural_transformation/partial_state_selection.jl new file mode 100644 index 0000000000..8a0ae5276e --- /dev/null +++ b/src/structural_transformation/partial_state_selection.jl @@ -0,0 +1,402 @@ +function partial_state_selection_graph!(state::TransformationState) + find_solvables!(state; allow_symbolic = true) + var_eq_matching = complete(pantelides!(state)) + complete!(state.structure) + partial_state_selection_graph!(state.structure, var_eq_matching) +end + +function ascend_dg(xs, dg, level) + while level > 0 + xs = Int[dg[x] for x in xs] + level -= 1 + end + return xs +end + +function ascend_dg_all(xs, dg, level, maxlevel) + r = Int[] + while true + if level <= 0 + append!(r, xs) + end + maxlevel <= 0 && break + xs = Int[dg[x] for x in xs if dg[x] !== nothing] + level -= 1 + maxlevel -= 1 + end + return r +end + +function pss_graph_modia!(structure::SystemStructure, maximal_top_matching, varlevel, + inv_varlevel, inv_eqlevel) + @unpack eq_to_diff, var_to_diff, graph, solvable_graph = structure + + # var_eq_matching is a maximal matching on the top-differentiated variables. + # Find Strongly connected components. Note that after pantelides, we expect + # a balanced system, so a maximal matching should be possible. + var_sccs::Vector{Union{Vector{Int}, Int}} = find_var_sccs(graph, maximal_top_matching) + var_eq_matching = Matching{Union{Unassigned, SelectedState}}(ndsts(graph)) + for vars in var_sccs + # TODO: We should have a way to not have the scc code look at unassigned vars. + if length(vars) == 1 && maximal_top_matching[vars[1]] === unassigned + continue + end + + # Now proceed level by level from lowest to highest and tear the graph. + eqs = [maximal_top_matching[var] + for var in vars if maximal_top_matching[var] !== unassigned] + isempty(eqs) && continue + maxeqlevel = maximum(map(x -> inv_eqlevel[x], eqs)) + maxvarlevel = level = maximum(map(x -> inv_varlevel[x], vars)) + old_level_vars = () + ict = IncrementalCycleTracker( + DiCMOBiGraph{true}(graph, + complete(Matching(ndsts(graph)), nsrcs(graph))), + dir = :in) + + while level >= 0 + to_tear_eqs_toplevel = filter(eq -> inv_eqlevel[eq] >= level, eqs) + to_tear_eqs = ascend_dg(to_tear_eqs_toplevel, invview(eq_to_diff), level) + + to_tear_vars_toplevel = filter(var -> inv_varlevel[var] >= level, vars) + to_tear_vars = ascend_dg(to_tear_vars_toplevel, invview(var_to_diff), level) + + assigned_eqs = Int[] + + if old_level_vars !== () + # Inherit constraints from previous level. + # TODO: Is this actually a good idea or do we want full freedom + # to tear differently on each level? Does it make a difference + # whether we're using heuristic or optimal tearing? + removed_eqs = Int[] + removed_vars = Int[] + for var in old_level_vars + old_assign = var_eq_matching[var] + if isa(old_assign, SelectedState) + push!(removed_vars, var) + continue + elseif !isa(old_assign, Int) || + ict.graph.matching[var_to_diff[var]] !== unassigned + continue + end + # Make sure the ict knows about this edge, so it doesn't accidentally introduce + # a cycle. + assgned_eq = eq_to_diff[old_assign] + ok = try_assign_eq!(ict, var_to_diff[var], assgned_eq) + @assert ok + var_eq_matching[var_to_diff[var]] = assgned_eq + push!(removed_eqs, eq_to_diff[ict.graph.matching[var]]) + push!(removed_vars, var_to_diff[var]) + push!(removed_vars, var) + end + to_tear_eqs = setdiff(to_tear_eqs, removed_eqs) + to_tear_vars = setdiff(to_tear_vars, removed_vars) + end + tearEquations!(ict, solvable_graph.fadjlist, to_tear_eqs, BitSet(to_tear_vars), + nothing) + + for var in to_tear_vars + @assert var_eq_matching[var] === unassigned + assgned_eq = ict.graph.matching[var] + var_eq_matching[var] = assgned_eq + isa(assgned_eq, Int) && push!(assigned_eqs, assgned_eq) + end + + if level != 0 + remaining_vars = collect(v + for v in to_tear_vars + if var_eq_matching[v] === unassigned) + if !isempty(remaining_vars) + remaining_eqs = setdiff(to_tear_eqs, assigned_eqs) + nlsolve_matching = maximal_matching(graph, + Base.Fix2(in, remaining_eqs), + Base.Fix2(in, remaining_vars)) + for var in remaining_vars + if nlsolve_matching[var] === unassigned && + var_eq_matching[var] === unassigned + var_eq_matching[var] = SelectedState() + end + end + end + end + + old_level_vars = to_tear_vars + level -= 1 + end + end + return complete(var_eq_matching, nsrcs(graph)) +end + +struct SelectedState end +function partial_state_selection_graph!(structure::SystemStructure, var_eq_matching) + @unpack eq_to_diff, var_to_diff, graph, solvable_graph = structure + eq_to_diff = complete(eq_to_diff) + + inv_eqlevel = map(1:nsrcs(graph)) do eq + level = 0 + while invview(eq_to_diff)[eq] !== nothing + eq = invview(eq_to_diff)[eq] + level += 1 + end + level + end + + varlevel = map(1:ndsts(graph)) do var + graph_level = level = 0 + while var_to_diff[var] !== nothing + var = var_to_diff[var] + level += 1 + if !isempty(𝑑neighbors(graph, var)) + graph_level = level + end + end + graph_level + end + + inv_varlevel = map(1:ndsts(graph)) do var + level = 0 + while invview(var_to_diff)[var] !== nothing + var = invview(var_to_diff)[var] + level += 1 + end + level + end + + var_eq_matching = pss_graph_modia!(structure, + complete(var_eq_matching), varlevel, inv_varlevel, + inv_eqlevel) + + var_eq_matching +end + +function dummy_derivative_graph!(state::TransformationState, jac = nothing; + state_priority = nothing, log = Val(false), kwargs...) + state.structure.solvable_graph === nothing && find_solvables!(state; kwargs...) + complete!(state.structure) + var_eq_matching = complete(pantelides!(state; kwargs...)) + dummy_derivative_graph!(state.structure, var_eq_matching, jac, state_priority, log) +end + +struct DummyDerivativeSummary + var_sccs::Vector{Vector{Int}} + state_priority::Vector{Vector{Float64}} +end + +function dummy_derivative_graph!( + structure::SystemStructure, var_eq_matching, jac = nothing, + state_priority = nothing, ::Val{log} = Val(false)) where {log} + @unpack eq_to_diff, var_to_diff, graph = structure + diff_to_eq = invview(eq_to_diff) + diff_to_var = invview(var_to_diff) + invgraph = invview(graph) + extended_sp = let state_priority = state_priority, var_to_diff = var_to_diff, + diff_to_var = diff_to_var + + var -> begin + min_p = max_p = 0.0 + while var_to_diff[var] !== nothing + var = var_to_diff[var] + end + while true + p = state_priority(var) + max_p = max(max_p, p) + min_p = min(min_p, p) + (var = diff_to_var[var]) === nothing && break + end + min_p < 0 ? min_p : max_p + end + end + + var_sccs = find_var_sccs(graph, var_eq_matching) + var_perm = Int[] + var_dummy_scc = Vector{Int}[] + var_state_priority = Vector{Float64}[] + eqcolor = falses(nsrcs(graph)) + dummy_derivatives = Int[] + col_order = Int[] + neqs = nsrcs(graph) + nvars = ndsts(graph) + eqs = Int[] + vars = Int[] + next_eq_idxs = Int[] + next_var_idxs = Int[] + new_eqs = Int[] + new_vars = Int[] + eqs_set = BitSet() + for vars′ in var_sccs + empty!(eqs) + empty!(vars) + for var in vars′ + eq = var_eq_matching[var] + eq isa Int || continue + diff_to_eq[eq] === nothing || push!(eqs, eq) + if var_to_diff[var] !== nothing + error("Invalid SCC") + end + (diff_to_var[var] !== nothing && is_present(structure, var)) && push!(vars, var) + end + isempty(eqs) && continue + + rank_matching = Matching(max(nvars, neqs)) + isfirst = true + if jac === nothing + J = nothing + else + _J = jac(eqs, vars) + # only accept small integers to avoid overflow + is_all_small_int = all(_J) do x′ + x = unwrap(x′) + x isa Number || return false + isinteger(x) && typemin(Int8) <= x <= typemax(Int8) + end + J = is_all_small_int ? Int.(unwrap.(_J)) : nothing + end + while true + nrows = length(eqs) + iszero(nrows) && break + + if state_priority !== nothing && isfirst + sp = extended_sp.(vars) + resize!(var_perm, length(sp)) + sortperm!(var_perm, sp) + permute!(vars, var_perm) + permute!(sp, var_perm) + push!(var_dummy_scc, copy(vars)) + push!(var_state_priority, sp) + end + # TODO: making the algorithm more robust + # 1. If the Jacobian is a integer matrix, use Bareiss to check + # linear independence. (done) + # + # 2. If the Jacobian is a single row, generate pivots. (Dynamic + # state selection.) + # + # 3. If the Jacobian is a polynomial matrix, use Gröbner basis (?) + if J !== nothing + if !isfirst + J = J[next_eq_idxs, next_var_idxs] + end + N = ModelingToolkit.nullspace(J; col_order) # modifies col_order + rank = length(col_order) - size(N, 2) + for i in 1:rank + push!(dummy_derivatives, vars[col_order[i]]) + end + else + empty!(eqs_set) + union!(eqs_set, eqs) + rank = 0 + for var in vars + eqcolor .= false + # We need `invgraph` here because we are matching from + # variables to equations. + pathfound = construct_augmenting_path!(rank_matching, invgraph, var, + Base.Fix2(in, eqs_set), eqcolor) + pathfound || continue + push!(dummy_derivatives, var) + rank += 1 + rank == nrows && break + end + fill!(rank_matching, unassigned) + end + if rank != nrows + @warn "The DAE system is singular!" + end + + # prepare the next iteration + if J !== nothing + empty!(next_eq_idxs) + empty!(next_var_idxs) + end + empty!(new_eqs) + empty!(new_vars) + for (i, eq) in enumerate(eqs) + ∫eq = diff_to_eq[eq] + # descend by one diff level, but the next iteration of equations + # must still be differentiated + ∫eq === nothing && continue + ∫∫eq = diff_to_eq[∫eq] + ∫∫eq === nothing && continue + if J !== nothing + push!(next_eq_idxs, i) + end + push!(new_eqs, ∫eq) + end + for (i, var) in enumerate(vars) + ∫var = diff_to_var[var] + ∫var === nothing && continue + ∫∫var = diff_to_var[∫var] + ∫∫var === nothing && continue + if J !== nothing + push!(next_var_idxs, i) + end + push!(new_vars, ∫var) + end + eqs, new_eqs = new_eqs, eqs + vars, new_vars = new_vars, vars + isfirst = false + end + end + + if (n_diff_eqs = count(!isnothing, diff_to_eq)) != + (n_dummys = length(dummy_derivatives)) + @warn "The number of dummy derivatives ($n_dummys) does not match the number of differentiated equations ($n_diff_eqs)." + end + + ret = tearing_with_dummy_derivatives(structure, BitSet(dummy_derivatives)) + if log + (ret..., DummyDerivativeSummary(var_dummy_scc, var_state_priority)) + else + ret[1] + end +end + +function is_present(structure, v)::Bool + @unpack var_to_diff, graph = structure + while true + # if a higher derivative is present, then it's present + isempty(𝑑neighbors(graph, v)) || return true + v = var_to_diff[v] + v === nothing && return false + end +end + +# Derivatives that are either in the dummy derivatives set or ended up not +# participating in the system at all are not considered differential +function is_some_diff(structure, dummy_derivatives, v)::Bool + !(v in dummy_derivatives) && is_present(structure, v) +end + +# We don't want tearing to give us `y_t ~ D(y)`, so we skip equations with +# actually differentiated variables. +function isdiffed((structure, dummy_derivatives), v)::Bool + @unpack var_to_diff, graph = structure + diff_to_var = invview(var_to_diff) + diff_to_var[v] !== nothing && is_some_diff(structure, dummy_derivatives, v) +end + +function tearing_with_dummy_derivatives(structure, dummy_derivatives) + @unpack var_to_diff = structure + # We can eliminate variables that are not selected (differential + # variables). Selected unknowns are differentiated variables that are not + # dummy derivatives. + can_eliminate = falses(length(var_to_diff)) + for (v, dv) in enumerate(var_to_diff) + dv = var_to_diff[v] + if dv === nothing || !is_some_diff(structure, dummy_derivatives, dv) + can_eliminate[v] = true + end + end + var_eq_matching, full_var_eq_matching, var_sccs = tear_graph_modia(structure, + Base.Fix1(isdiffed, (structure, dummy_derivatives)), + Union{Unassigned, SelectedState}; + varfilter = Base.Fix1(getindex, can_eliminate)) + + for v in 𝑑vertices(structure.graph) + is_present(structure, v) || continue + dv = var_to_diff[v] + (dv === nothing || !is_some_diff(structure, dummy_derivatives, dv)) && continue + var_eq_matching[v] = SelectedState() + end + + return var_eq_matching, full_var_eq_matching, var_sccs, can_eliminate +end diff --git a/src/structural_transformation/symbolics_tearing.jl b/src/structural_transformation/symbolics_tearing.jl new file mode 100644 index 0000000000..552c6d13c3 --- /dev/null +++ b/src/structural_transformation/symbolics_tearing.jl @@ -0,0 +1,1059 @@ +using OffsetArrays: Origin + +# N.B. assumes `slist` and `dlist` are unique +function substitution_graph(graph, slist, dlist, var_eq_matching) + ns = length(slist) + nd = length(dlist) + ns == nd || error("internal error") + newgraph = BipartiteGraph(ns, nd) + erename = uneven_invmap(nsrcs(graph), slist) + vrename = uneven_invmap(ndsts(graph), dlist) + for e in 𝑠vertices(graph) + ie = erename[e] + ie == 0 && continue + for v in 𝑠neighbors(graph, e) + iv = vrename[v] + iv == 0 && continue + add_edge!(newgraph, ie, iv) + end + end + + newmatching = Matching(ns) + for (v, e) in enumerate(var_eq_matching) + (e === unassigned || e === SelectedState()) && continue + iv = vrename[v] + ie = erename[e] + iv == 0 && continue + ie == 0 && error("internal error") + newmatching[iv] = ie + end + + return DiCMOBiGraph{true}(newgraph, complete(newmatching)) +end + +function var_derivative_graph!(s::SystemStructure, v::Int) + sg = g = add_vertex!(s.graph, DST) + var_diff = add_vertex!(s.var_to_diff) + add_edge!(s.var_to_diff, v, var_diff) + s.solvable_graph === nothing || (sg = add_vertex!(s.solvable_graph, DST)) + @assert sg == g == var_diff + return var_diff +end + +function var_derivative!(ts::TearingState{ODESystem}, v::Int) + s = ts.structure + var_diff = var_derivative_graph!(s, v) + sys = ts.sys + D = Differential(get_iv(sys)) + push!(ts.fullvars, D(ts.fullvars[v])) + return var_diff +end + +function eq_derivative_graph!(s::SystemStructure, eq::Int) + add_vertex!(s.graph, SRC) + s.solvable_graph === nothing || add_vertex!(s.solvable_graph, SRC) + # the new equation is created by differentiating `eq` + eq_diff = add_vertex!(s.eq_to_diff) + add_edge!(s.eq_to_diff, eq, eq_diff) + return eq_diff +end + +function eq_derivative!(ts::TearingState{ODESystem}, ieq::Int; kwargs...) + s = ts.structure + + eq_diff = eq_derivative_graph!(s, ieq) + + sys = ts.sys + eq = equations(ts)[ieq] + eq = 0 ~ Symbolics.derivative(eq.rhs - eq.lhs, get_iv(sys); throw_no_derivative = true) + push!(equations(ts), eq) + # Analyze the new equation and update the graph/solvable_graph + # First, copy the previous incidence and add the derivative terms. + # That's a superset of all possible occurrences. find_solvables! will + # remove those that doesn't actually occur. + eq_diff = length(equations(ts)) + for var in 𝑠neighbors(s.graph, ieq) + add_edge!(s.graph, eq_diff, var) + add_edge!(s.graph, eq_diff, s.var_to_diff[var]) + end + s.solvable_graph === nothing || + find_eq_solvables!( + ts, eq_diff; may_be_zero = true, allow_symbolic = false, kwargs...) + + return eq_diff +end + +function tearing_sub(expr, dict, s) + expr = Symbolics.fixpoint_sub(expr, dict; operator = ModelingToolkit.Initial) + s ? simplify(expr) : expr +end + +function tearing_substitute_expr(sys::AbstractSystem, expr; simplify = false) + empty_substitutions(sys) && return expr + substitutions = get_substitutions(sys) + @unpack subs = substitutions + solved = Dict(eq.lhs => eq.rhs for eq in subs) + return tearing_sub(expr, solved, simplify) +end + +""" +$(TYPEDSIGNATURES) + +Like `equations(sys)`, but includes substitutions done by the tearing process. +These equations matches generated numerical code. + +See also [`equations`](@ref) and [`ModelingToolkit.get_eqs`](@ref). +""" +function full_equations(sys::AbstractSystem; simplify = false) + empty_substitutions(sys) && return equations(sys) + substitutions = get_substitutions(sys) + substitutions.subed_eqs === nothing || return substitutions.subed_eqs + @unpack subs = substitutions + solved = Dict(eq.lhs => eq.rhs for eq in subs) + neweqs = map(equations(sys)) do eq + if iscall(eq.lhs) && operation(eq.lhs) isa Union{Shift, Differential} + return tearing_sub(eq.lhs, solved, simplify) ~ tearing_sub(eq.rhs, solved, + simplify) + else + if !(eq.lhs isa Number && eq.lhs == 0) + eq = 0 ~ eq.rhs - eq.lhs + end + rhs = tearing_sub(eq.rhs, solved, simplify) + if rhs isa Symbolic + return 0 ~ rhs + else # a number + error("tearing failed because the system is singular") + end + end + eq + end + substitutions.subed_eqs = neweqs + return neweqs +end + +function tearing_substitution(sys::AbstractSystem; kwargs...) + neweqs = full_equations(sys::AbstractSystem; kwargs...) + @set! sys.eqs = neweqs + @set! sys.substitutions = nothing + @set! sys.schedule = nothing +end + +function tearing_assignments(sys::AbstractSystem) + if empty_substitutions(sys) + assignments = [] + deps = Int[] + sol_states = Code.LazyState() + else + @unpack subs, deps = get_substitutions(sys) + assignments = [Assignment(eq.lhs, eq.rhs) for eq in subs] + sol_states = Code.NameState(Dict(eq.lhs => Symbol(eq.lhs) for eq in subs)) + end + return assignments, deps, sol_states +end + +function solve_equation(eq, var, simplify) + rhs = value(symbolic_linear_solve(eq, var; simplify = simplify, check = false)) + occursin(var, rhs) && throw(EquationSolveErrors(eq, var, rhs)) + var ~ rhs +end + +function substitute_vars!(structure, subs, cache = Int[], callback! = nothing; + exclude = ()) + @unpack graph, solvable_graph = structure + for su in subs + su === nothing && continue + v, v′ = su + eqs = 𝑑neighbors(graph, v) + # Note that the iterator is not robust under deletion and + # insertion. Hence, we have a copy here. + resize!(cache, length(eqs)) + for eq in copyto!(cache, eqs) + eq in exclude && continue + rem_edge!(graph, eq, v) + add_edge!(graph, eq, v′) + + if BipartiteEdge(eq, v) in solvable_graph + rem_edge!(solvable_graph, eq, v) + add_edge!(solvable_graph, eq, v′) + end + callback! !== nothing && callback!(eq, su) + end + end + return structure +end + +function to_mass_matrix_form(neweqs, ieq, graph, fullvars, isdervar::F, + var_to_diff) where {F} + eq = neweqs[ieq] + if !(eq.lhs isa Number && eq.lhs == 0) + eq = 0 ~ eq.rhs - eq.lhs + end + rhs = eq.rhs + if rhs isa Symbolic + # Check if the RHS is solvable in all unknown variable derivatives and if those + # the linear terms for them are all zero. If so, move them to the + # LHS. + dervar::Union{Nothing, Int} = nothing + for var in 𝑠neighbors(graph, ieq) + if isdervar(var) + if dervar !== nothing + error("$eq has more than one differentiated variable!") + end + dervar = var + end + end + dervar === nothing && return (0 ~ rhs), dervar + new_lhs = var = fullvars[dervar] + # 0 ~ a * D(x) + b + # D(x) ~ -b/a + a, b, islinear = linear_expansion(rhs, var) + if !islinear + return (0 ~ rhs), nothing + end + new_rhs = -b / a + return (new_lhs ~ new_rhs), invview(var_to_diff)[dervar] + else # a number + if abs(rhs) > 100eps(float(rhs)) + @warn "The equation $eq is not consistent. It simplified to 0 == $rhs." + end + return nothing + end +end + +#= +function check_diff_graph(var_to_diff, fullvars) + diff_to_var = invview(var_to_diff) + for (iv, v) in enumerate(fullvars) + ov, order = var_from_nested_derivative(v) + graph_order = 0 + vv = iv + while true + vv = diff_to_var[vv] + vv === nothing && break + graph_order += 1 + end + @assert graph_order==order "graph_order: $graph_order, order: $order for variable $v" + end +end +=# + +""" +Replace derivatives of non-selected unknown variables by dummy derivatives. + +State selection may determine that some differential variables are +algebraic variables in disguise. The derivative of such variables are +called dummy derivatives. + +`SelectedState` information is no longer needed after this function is called. +State selection is done. All non-differentiated variables are algebraic +variables, and all variables that appear differentiated are differential variables. +""" +function substitute_derivatives_algevars!( + ts::TearingState, neweqs, var_eq_matching, dummy_sub; iv = nothing, D = nothing) + @unpack fullvars, sys, structure = ts + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + diff_to_var = invview(var_to_diff) + + for var in 1:length(fullvars) + dv = var_to_diff[var] + dv === nothing && continue + if var_eq_matching[var] !== SelectedState() + dd = fullvars[dv] + v_t = setio(diff2term_with_unit(unwrap(dd), unwrap(iv)), false, false) + for eq in 𝑑neighbors(graph, dv) + dummy_sub[dd] = v_t + neweqs[eq] = fast_substitute(neweqs[eq], dd => v_t) + end + fullvars[dv] = v_t + # If we have: + # x -> D(x) -> D(D(x)) + # We need to to transform it to: + # x x_t -> D(x_t) + # update the structural information + dx = dv + x_t = v_t + while (ddx = var_to_diff[dx]) !== nothing + dx_t = D(x_t) + for eq in 𝑑neighbors(graph, ddx) + neweqs[eq] = fast_substitute(neweqs[eq], fullvars[ddx] => dx_t) + end + fullvars[ddx] = dx_t + dx = ddx + x_t = dx_t + end + diff_to_var[dv] = nothing + end + end +end + +#= +There are three cases where we want to generate new variables to convert +the system into first order (semi-implicit) ODEs. + +1. To first order: +Whenever higher order differentiated variable like `D(D(D(x)))` appears, +we introduce new variables `x_t`, `x_tt`, and `x_ttt` and new equations +``` +D(x_tt) = x_ttt +D(x_t) = x_tt +D(x) = x_t +``` +and replace `D(x)` to `x_t`, `D(D(x))` to `x_tt`, and `D(D(D(x)))` to +`x_ttt`. + +2. To implicit to semi-implicit ODEs: +2.1: Unsolvable derivative: +If one derivative variable `D(x)` is unsolvable in all the equations it +appears in, then we introduce a new variable `x_t`, a new equation +``` +D(x) ~ x_t +``` +and replace all other `D(x)` to `x_t`. + +2.2: Solvable derivative: +If one derivative variable `D(x)` is solvable in at least one of the +equations it appears in, then we introduce a new variable `x_t`. One of +the solvable equations must be in the form of `0 ~ L(D(x), u...)` and +there exists a function `l` such that `D(x) ~ l(u...)`. We should replace +it to +``` +0 ~ x_t - l(u...) +D(x) ~ x_t +``` +and replace all other `D(x)` to `x_t`. + +Observe that we don't need to actually introduce a new variable `x_t`, as +the above equations can be lowered to +``` +x_t := l(u...) +D(x) ~ x_t +``` +where `:=` denotes assignment. + +As a final note, in all the above cases where we need to introduce new +variables and equations, don't add them when they already exist. + +###### DISCRETE SYSTEMS ####### + +Documenting the differences to structural simplification for discrete systems: + +In discrete systems everything gets shifted forward a timestep by `shift_discrete_system` +in order to properly generate the difference equations. + +In the system x(k) ~ x(k-1) + x(k-2), becomes Shift(t, 1)(x(t)) ~ x(t) + Shift(t, -1)(x(t)) + +The lowest-order term is Shift(t, k)(x(t)), instead of x(t). As such we actually want +dummy variables for the k-1 lowest order terms instead of the k-1 highest order terms. + +Shift(t, -1)(x(t)) -> x\_{t-1}(t) + +Since Shift(t, -1)(x) is not a derivative, it is directly substituted in `fullvars`. +No equation or variable is added for it. + +For ODESystems D(D(D(x))) in equations is recursively substituted as D(x) ~ x_t, D(x_t) ~ x_tt, etc. +The analogue for discrete systems, Shift(t, 1)(Shift(t,1)(Shift(t,1)(Shift(t, -3)(x(t))))) +does not actually appear. So `total_sub` in generate_system_equations` is directly +initialized with all of the lowered variables `Shift(t, -3)(x) -> x_t-3(t)`, etc. +=# +""" +Generate new derivative variables for the system. + +Effects on the system structure: +- fullvars: add the new derivative variables x_t +- neweqs: add the identity equations for the new variables, D(x) ~ x_t +- graph: update graph with the new equations and variables, and their connections +- solvable_graph: +- var_eq_matching: match D(x) to the added identity equation D(x) ~ x_t +""" +function generate_derivative_variables!( + ts::TearingState, neweqs, var_eq_matching; mm = nothing, iv = nothing, D = nothing) + @unpack fullvars, sys, structure = ts + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + eq_var_matching = invview(var_eq_matching) + diff_to_var = invview(var_to_diff) + is_discrete = is_only_discrete(structure) + linear_eqs = mm === nothing ? Dict{Int, Int}() : + Dict(reverse(en) for en in enumerate(mm.nzrows)) + + # For variable x, make dummy derivative x_t if the + # derivative is in the system + for v in 1:length(var_to_diff) + dv = var_to_diff[v] + dv isa Int || continue + solved = var_eq_matching[dv] isa Int + solved && continue + + # If there's `D(x) = x_t` already, update mappings and continue without + # adding new equations/variables + dd = find_duplicate_dd(dv, solvable_graph, diff_to_var, linear_eqs, mm) + if !isnothing(dd) + dummy_eq, v_t = dd + var_to_diff[v_t] = var_to_diff[dv] + var_eq_matching[dv] = unassigned + eq_var_matching[dummy_eq] = dv + continue + end + + dx = fullvars[dv] + order, lv = var_order(dv, diff_to_var) + x_t = is_discrete ? lower_shift_varname_with_unit(fullvars[dv], iv) : + lower_varname_with_unit(fullvars[lv], iv, order) + + # Add `x_t` to the graph + v_t = add_dd_variable!(structure, fullvars, x_t, dv) + # Add `D(x) - x_t ~ 0` to the graph + dummy_eq = add_dd_equation!(structure, neweqs, 0 ~ dx - x_t, dv, v_t) + # Update graph to say, all the equations featuring D(x) also feature x_t + for e in 𝑑neighbors(graph, dv) + add_edge!(graph, e, v_t) + end + + # Update matching + push!(var_eq_matching, unassigned) + var_eq_matching[dv] = unassigned + eq_var_matching[dummy_eq] = dv + end +end + +""" +Check if there's `D(x) ~ x_t` already. +""" +function find_duplicate_dd(dv, solvable_graph, diff_to_var, linear_eqs, mm) + for eq in 𝑑neighbors(solvable_graph, dv) + mi = get(linear_eqs, eq, 0) + iszero(mi) && continue + row = @view mm[mi, :] + nzs = nonzeros(row) + rvs = SparseArrays.nonzeroinds(row) + # note that `v_t` must not be differentiated + if length(nzs) == 2 && + (abs(nzs[1]) == 1 && nzs[1] == -nzs[2]) && + (v_t = rvs[1] == dv ? rvs[2] : rvs[1]; + diff_to_var[v_t] === nothing) + @assert dv in rvs + return eq, v_t + end + end + return nothing +end + +""" +Add a dummy derivative variable x_t corresponding to symbolic variable D(x) +which has index dv in `fullvars`. Return the new index of x_t. +""" +function add_dd_variable!(s::SystemStructure, fullvars, x_t, dv) + push!(fullvars, simplify_shifts(x_t)) + v_t = length(fullvars) + v_t_idx = add_vertex!(s.var_to_diff) + add_vertex!(s.graph, DST) + # TODO: do we care about solvable_graph? We don't use them after + # `dummy_derivative_graph`. + add_vertex!(s.solvable_graph, DST) + s.var_to_diff[v_t] = s.var_to_diff[dv] + v_t +end + +""" +Add the equation D(x) - x_t ~ 0 to `neweqs`. `dv` and `v_t` are the indices +of the higher-order derivative variable and the newly-introduced dummy +derivative variable. Return the index of the new equation in `neweqs`. +""" +function add_dd_equation!(s::SystemStructure, neweqs, eq, dv, v_t) + push!(neweqs, eq) + add_vertex!(s.graph, SRC) + dummy_eq = length(neweqs) + add_edge!(s.graph, dummy_eq, dv) + add_edge!(s.graph, dummy_eq, v_t) + add_vertex!(s.solvable_graph, SRC) + add_edge!(s.solvable_graph, dummy_eq, dv) + dummy_eq +end + +""" +Solve the equations in `neweqs` to obtain the final equations of the +system. + +For each equation of `neweqs`, do one of the following: + 1. If the equation is solvable for a differentiated variable D(x), + then solve for D(x), and add D(x) ~ sol as a differential equation + of the system. + 2. If the equation is solvable for an un-differentiated variable x, + solve for x and then add x ~ sol as a solved equation. These will + become observables. + 3. If the equation is not solvable, add it as an algebraic equation. + +Solved equations are added to `total_sub`. Occurrences of differential +or solved variables on the RHS of the final equations will get substituted. +The topological sort of the equations ensures that variables are solved for +before they appear in equations. + +Reorder the equations and unknowns to be: + [diffeqs; ...] + [diffvars; ...] +such that the mass matrix is: + [I 0 + 0 0]. + +Order the new equations and variables such that the differential equations +and variables come first. Return the new equations, the solved equations, +the new orderings, and the number of solved variables and equations. +""" +function generate_system_equations!(state::TearingState, neweqs, var_eq_matching; + simplify = false, iv = nothing, D = nothing) + @unpack fullvars, sys, structure = state + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + eq_var_matching = invview(var_eq_matching) + diff_to_var = invview(var_to_diff) + + total_sub = Dict() + if is_only_discrete(structure) + for (i, v) in enumerate(fullvars) + op = operation(v) + op isa Shift && (op.steps < 0) && + begin + lowered = lower_shift_varname_with_unit(v, iv) + total_sub[v] = lowered + fullvars[i] = lowered + end + end + end + + # if var is like D(x) or Shift(t, 1)(x) + isdervar = let diff_to_var = diff_to_var + var -> diff_to_var[var] !== nothing + end + + # Extract partition information + is_solvable = let solvable_graph = solvable_graph + (eq, iv) -> eq isa Int && iv isa Int && BipartiteEdge(eq, iv) in solvable_graph + end + + diff_eqs = Equation[] + diffeq_idxs = Int[] + diff_vars = Int[] + alge_eqs = Equation[] + algeeq_idxs = Int[] + solved_eqs = Equation[] + solvedeq_idxs = Int[] + solved_vars = Int[] + + toporder = topological_sort(DiCMOBiGraph{false}(graph, var_eq_matching)) + eqs = Iterators.reverse(toporder) + idep = iv + + # Generate equations. + # Solvable equations of differential variables D(x) become differential equations + # Solvable equations of non-differential variables become observable equations + # Non-solvable equations become algebraic equations. + for ieq in eqs + iv = eq_var_matching[ieq] + eq = neweqs[ieq] + + if is_solvable(ieq, iv) && isdervar(iv) + var = fullvars[iv] + isnothing(D) && throw(UnexpectedDifferentialError(equations(sys)[ieq])) + order, lv = var_order(iv, diff_to_var) + dx = D(simplify_shifts(fullvars[lv])) + + neweq = make_differential_equation(var, dx, eq, total_sub) + for e in 𝑑neighbors(graph, iv) + e == ieq && continue + rem_edge!(graph, e, iv) + end + + total_sub[simplify_shifts(neweq.lhs)] = neweq.rhs + # Substitute unshifted variables x(k), y(k) on RHS of implicit equations + if is_only_discrete(structure) + var_to_diff[iv] === nothing && (total_sub[var] = neweq.rhs) + end + push!(diff_eqs, neweq) + push!(diffeq_idxs, ieq) + push!(diff_vars, diff_to_var[iv]) + elseif is_solvable(ieq, iv) + var = fullvars[iv] + neweq = make_solved_equation(var, eq, total_sub; simplify) + !isnothing(neweq) && begin + push!(solved_eqs, neweq) + push!(solvedeq_idxs, ieq) + push!(solved_vars, iv) + end + else + neweq = make_algebraic_equation(eq, total_sub) + push!(alge_eqs, neweq) + push!(algeeq_idxs, ieq) + end + end + + # Generate new equations and orderings + neweqs = [diff_eqs; alge_eqs] + eq_ordering = [diffeq_idxs; algeeq_idxs] + diff_vars_set = BitSet(diff_vars) + if length(diff_vars_set) != length(diff_vars) + error("Tearing internal error: lowering DAE into semi-implicit ODE failed!") + end + solved_vars_set = BitSet(solved_vars) + var_ordering = [diff_vars; + setdiff!(setdiff(1:ndsts(graph), diff_vars_set), + solved_vars_set)] + + return neweqs, solved_eqs, eq_ordering, var_ordering, length(solved_vars), + length(solved_vars_set) +end + +""" +Occurs when a variable D(x) occurs in a non-differential system. +""" +struct UnexpectedDifferentialError + eq::Equation +end + +function Base.showerror(io::IO, err::UnexpectedDifferentialError) + error("Differential found in a non-differential system. Likely this is a bug in the construction of an initialization system. Please report this issue with a reproducible example. Offending equation: $(err.eq)") +end + +""" +Generate a first-order differential equation whose LHS is `dx`. + +`var` and `dx` represent the same variable, but `var` may be a higher-order differential and `dx` is always first-order. For example, if `var` is D(D(x)), then `dx` would be `D(x_t)`. Solve `eq` for `var`, substitute previously solved variables, and return the differential equation. +""" +function make_differential_equation(var, dx, eq, total_sub) + dx ~ simplify_shifts(Symbolics.fixpoint_sub( + Symbolics.symbolic_linear_solve(eq, var), + total_sub; operator = ModelingToolkit.Shift)) +end + +""" +Generate an algebraic equation. Substitute solved variables into `eq` and return the equation. +""" +function make_algebraic_equation(eq, total_sub) + rhs = eq.rhs + if !(eq.lhs isa Number && eq.lhs == 0) + rhs = eq.rhs - eq.lhs + end + 0 ~ simplify_shifts(Symbolics.fixpoint_sub(rhs, total_sub)) +end + +""" +Solve equation `eq` for `var`, substitute previously solved variables, and return the solved equation. +""" +function make_solved_equation(var, eq, total_sub; simplify = false) + residual = eq.lhs - eq.rhs + a, b, islinear = linear_expansion(residual, var) + @assert islinear + # 0 ~ a * var + b + # var ~ -b/a + if ModelingToolkit._iszero(a) + @warn "Tearing: solving $eq for $var is singular!" + return nothing + else + rhs = -b / a + return var ~ simplify_shifts(Symbolics.fixpoint_sub( + simplify ? + Symbolics.simplify(rhs) : rhs, + total_sub; operator = ModelingToolkit.Shift)) + end +end + +""" +Given the ordering returned by `generate_system_equations!`, update the +tearing state to account for the new order. Permute the variables and equations. +Eliminate the solved variables and equations from the graph and permute the +graph's vertices to account for the new variable/equation ordering. +""" +# TODO: BLT sorting +function reorder_vars!(state::TearingState, var_eq_matching, eq_ordering, + var_ordering, nsolved_eq, nsolved_var) + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = state.structure + + eqsperm = zeros(Int, nsrcs(graph)) + for (i, v) in enumerate(eq_ordering) + eqsperm[v] = i + end + varsperm = zeros(Int, ndsts(graph)) + for (i, v) in enumerate(var_ordering) + varsperm[v] = i + end + + # Contract the vertices in the structure graph to make the structure match + # the new reality of the system we've just created. + new_graph = contract_variables(graph, var_eq_matching, varsperm, eqsperm, + nsolved_eq, nsolved_var) + + new_var_to_diff = complete(DiffGraph(length(var_ordering))) + for (v, d) in enumerate(var_to_diff) + v′ = varsperm[v] + (v′ > 0 && d !== nothing) || continue + d′ = varsperm[d] + new_var_to_diff[v′] = d′ > 0 ? d′ : nothing + end + new_eq_to_diff = complete(DiffGraph(length(eq_ordering))) + for (v, d) in enumerate(eq_to_diff) + v′ = eqsperm[v] + (v′ > 0 && d !== nothing) || continue + d′ = eqsperm[d] + new_eq_to_diff[v′] = d′ > 0 ? d′ : nothing + end + new_fullvars = state.fullvars[var_ordering] + + # Update system structure + @set! state.structure.graph = complete(new_graph) + @set! state.structure.var_to_diff = new_var_to_diff + @set! state.structure.eq_to_diff = new_eq_to_diff + @set! state.fullvars = new_fullvars + state +end + +""" +Update the system equations, unknowns, and observables after simplification. +""" +function update_simplified_system!( + state::TearingState, neweqs, solved_eqs, dummy_sub, var_eq_matching, extra_unknowns; + cse_hack = true, array_hack = true) + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = state.structure + diff_to_var = invview(var_to_diff) + + ispresent = let var_to_diff = var_to_diff, graph = graph + i -> (!isempty(𝑑neighbors(graph, i)) || + (var_to_diff[i] !== nothing && !isempty(𝑑neighbors(graph, var_to_diff[i])))) + end + + sys = state.sys + obs_sub = dummy_sub + for eq in neweqs + isdiffeq(eq) || continue + obs_sub[eq.lhs] = eq.rhs + end + # TODO: compute the dependency correctly so that we don't have to do this + obs = [fast_substitute(observed(sys), obs_sub); solved_eqs] + + unknowns = Any[v + for (i, v) in enumerate(state.fullvars) + if diff_to_var[i] === nothing && ispresent(i)] + unknowns = [unknowns; extra_unknowns] + @set! sys.unknowns = unknowns + + obs, subeqs, deps = cse_and_array_hacks( + sys, obs, solved_eqs, unknowns, neweqs; cse = cse_hack, array = array_hack) + + @set! sys.eqs = neweqs + @set! sys.observed = obs + @set! sys.substitutions = Substitutions(subeqs, deps) + + # Only makes sense for time-dependent + # TODO: generalize to SDE + if sys isa ODESystem + @set! sys.schedule = Schedule(var_eq_matching, dummy_sub) + end + sys = schedule(sys) +end + +""" +Give the order of the variable indexed by dv. +""" +function var_order(dv, diff_to_var) + order = 0 + while (dv′ = diff_to_var[dv]) !== nothing + order += 1 + dv = dv′ + end + order, dv +end + +""" +Main internal function for structural simplification for DAE systems and discrete systems. +Generate dummy derivative variables, new equations in terms of variables, return updated +system and tearing state. + +Terminology and Definition: + +A general DAE is in the form of `F(u'(t), u(t), p, t) == 0`. We can +characterize variables in `u(t)` into two classes: differential variables +(denoted `v(t)`) and algebraic variables (denoted `z(t)`). Differential +variables are marked as `SelectedState` and they are differentiated in the +DAE system, i.e. `v'(t)` are all the variables in `u'(t)` that actually +appear in the system. Algebraic variables are variables that are not +differential variables. +""" +function tearing_reassemble(state::TearingState, var_eq_matching, + full_var_eq_matching = nothing; simplify = false, mm = nothing, cse_hack = true, array_hack = true) + extra_vars = Int[] + if full_var_eq_matching !== nothing + for v in 𝑑vertices(state.structure.graph) + eq = full_var_eq_matching[v] + eq isa Int && continue + push!(extra_vars, v) + end + end + extra_unknowns = state.fullvars[extra_vars] + neweqs = collect(equations(state)) + dummy_sub = Dict() + + if ModelingToolkit.has_iv(state.sys) + iv = get_iv(state.sys) + if !is_only_discrete(state.structure) + D = Differential(iv) + else + D = Shift(iv, 1) + end + else + iv = D = nothing + end + + # Structural simplification + substitute_derivatives_algevars!(state, neweqs, var_eq_matching, dummy_sub; iv, D) + + generate_derivative_variables!(state, neweqs, var_eq_matching; mm, iv, D) + + neweqs, solved_eqs, eq_ordering, var_ordering, nelim_eq, nelim_var = generate_system_equations!( + state, neweqs, var_eq_matching; simplify, iv, D) + + state = reorder_vars!( + state, var_eq_matching, eq_ordering, var_ordering, nelim_eq, nelim_var) + + sys = update_simplified_system!(state, neweqs, solved_eqs, dummy_sub, var_eq_matching, + extra_unknowns; cse_hack, array_hack) + + @set! state.sys = sys + @set! sys.tearing_state = state + return invalidate_cache!(sys) +end + +""" +# HACK 1 + +Since we don't support array equations, any equation of the sort `x[1:n] ~ f(...)[1:n]` +gets turned into `x[1] ~ f(...)[1], x[2] ~ f(...)[2]`. Repeatedly calling `f` gets +_very_ expensive. this hack performs a limited form of CSE specifically for this case to +avoid the unnecessary cost. This and the below hack are implemented simultaneously + +# HACK 2 + +Add equations for array observed variables. If `p[i] ~ (...)` are equations, add an +equation `p ~ [p[1], p[2], ...]` allow topsort to reorder them only add the new equation +if all `p[i]` are present and the unscalarized form is used in any equation (observed or +not) we first count the number of times the scalarized form of each observed variable +occurs in observed equations (and unknowns if it's split). +""" +function cse_and_array_hacks(sys, obs, subeqs, unknowns, neweqs; cse = true, array = true) + # HACK 1 + # mapping of rhs to temporary CSE variable + # `f(...) => tmpvar` in above example + rhs_to_tempvar = Dict() + + # HACK 2 + # map of array observed variable (unscalarized) to number of its + # scalarized terms that appear in observed equations + arr_obs_occurrences = Dict() + # to check if array variables occur in unscalarized form anywhere + all_vars = Set() + for (i, eq) in enumerate(obs) + lhs = eq.lhs + rhs = eq.rhs + vars!(all_vars, rhs) + + # HACK 1 + if cse && is_getindexed_array(rhs) + rhs_arr = arguments(rhs)[1] + iscall(rhs_arr) && operation(rhs_arr) isa Symbolics.Operator && continue + if !haskey(rhs_to_tempvar, rhs_arr) + tempvar = gensym(Symbol(lhs)) + N = length(rhs_arr) + tempvar = unwrap(Symbolics.variable( + tempvar; T = Symbolics.symtype(rhs_arr))) + tempvar = setmetadata( + tempvar, Symbolics.ArrayShapeCtx, Symbolics.shape(rhs_arr)) + tempeq = tempvar ~ rhs_arr + rhs_to_tempvar[rhs_arr] = tempvar + push!(obs, tempeq) + push!(subeqs, tempeq) + end + + # getindex_wrapper is used because `observed2graph` treats `x` and `x[i]` as different, + # so it doesn't find a dependency between this equation and `tempvar ~ rhs_arr` + # which fails the topological sort + neweq = lhs ~ getindex_wrapper( + rhs_to_tempvar[rhs_arr], Tuple(arguments(rhs)[2:end])) + obs[i] = neweq + subeqi = findfirst(isequal(eq), subeqs) + if subeqi !== nothing + subeqs[subeqi] = neweq + end + end + # end HACK 1 + + array || continue + iscall(lhs) || continue + operation(lhs) === getindex || continue + Symbolics.shape(lhs) != Symbolics.Unknown() || continue + arg1 = arguments(lhs)[1] + cnt = get(arr_obs_occurrences, arg1, 0) + arr_obs_occurrences[arg1] = cnt + 1 + continue + end + + # Also do CSE for `equations(sys)` + if cse + for (i, eq) in enumerate(neweqs) + (; lhs, rhs) = eq + is_getindexed_array(rhs) || continue + rhs_arr = arguments(rhs)[1] + if !haskey(rhs_to_tempvar, rhs_arr) + tempvar = gensym(Symbol(lhs)) + N = length(rhs_arr) + tempvar = unwrap(Symbolics.variable( + tempvar; T = Symbolics.symtype(rhs_arr))) + tempvar = setmetadata( + tempvar, Symbolics.ArrayShapeCtx, Symbolics.shape(rhs_arr)) + vars!(all_vars, rhs_arr) + tempeq = tempvar ~ rhs_arr + rhs_to_tempvar[rhs_arr] = tempvar + push!(obs, tempeq) + push!(subeqs, tempeq) + end + # don't need getindex_wrapper, but do it anyway to know that this + # hack took place + neweq = lhs ~ getindex_wrapper( + rhs_to_tempvar[rhs_arr], Tuple(arguments(rhs)[2:end])) + neweqs[i] = neweq + end + end + + # count variables in unknowns if they are scalarized forms of variables + # also present as observed. e.g. if `x[1]` is an unknown and `x[2] ~ (..)` + # is an observed equation. + for sym in unknowns + iscall(sym) || continue + operation(sym) === getindex || continue + Symbolics.shape(sym) != Symbolics.Unknown() || continue + arg1 = arguments(sym)[1] + cnt = get(arr_obs_occurrences, arg1, 0) + cnt == 0 && continue + arr_obs_occurrences[arg1] = cnt + 1 + end + for eq in neweqs + vars!(all_vars, eq.rhs) + end + + # also count unscalarized variables used in callbacks + for ev in Iterators.flatten((continuous_events(sys), discrete_events(sys))) + vars!(all_vars, ev) + end + obs_arr_eqs = Equation[] + for (arrvar, cnt) in arr_obs_occurrences + cnt == length(arrvar) || continue + arrvar in all_vars || continue + # firstindex returns 1 for multidimensional array symbolics + firstind = first(eachindex(arrvar)) + scal = [arrvar[i] for i in eachindex(arrvar)] + # respect non-1-indexed arrays + # TODO: get rid of this hack together with the above hack, then remove OffsetArrays dependency + # `change_origin` is required because `Origin(firstind)(scal)` makes codegen + # try to `create_array(OffsetArray{...}, ...)` which errors. + # `term(Origin(firstind), scal)` doesn't retain the `symtype` and `size` + # of `scal`. + rhs = scal + rhs = change_origin(firstind, rhs) + push!(obs_arr_eqs, arrvar ~ rhs) + end + append!(obs, obs_arr_eqs) + append!(subeqs, obs_arr_eqs) + + # need to re-sort subeqs + subeqs = ModelingToolkit.topsort_equations(subeqs, [eq.lhs for eq in subeqs]) + + deps = Vector{Int}[i == 1 ? Int[] : collect(1:(i - 1)) + for i in 1:length(subeqs)] + + return obs, subeqs, deps +end + +function is_getindexed_array(rhs) + (!ModelingToolkit.isvariable(rhs) || ModelingToolkit.iscalledparameter(rhs)) && + iscall(rhs) && operation(rhs) === getindex && + Symbolics.shape(rhs) != Symbolics.Unknown() +end + +# PART OF HACK 1 +getindex_wrapper(x, i) = x[i...] + +@register_symbolic getindex_wrapper(x::AbstractArray, i::Tuple{Vararg{Int}}) + +# PART OF HACK 2 +function change_origin(origin, arr) + if all(isone, Tuple(origin)) + return arr + end + return Origin(origin)(arr) +end + +@register_array_symbolic change_origin(origin::Any, arr::AbstractArray) begin + size = size(arr) + eltype = eltype(arr) + ndims = ndims(arr) +end + +function tearing(state::TearingState; kwargs...) + state.structure.solvable_graph === nothing && find_solvables!(state; kwargs...) + complete!(state.structure) + tearing_with_dummy_derivatives(state.structure, ()) +end + +""" + tearing(sys; simplify=false) + +Tear the nonlinear equations in system. When `simplify=true`, we simplify the +new residual equations after tearing. End users are encouraged to call [`structural_simplify`](@ref) +instead, which calls this function internally. +""" +function tearing(sys::AbstractSystem, state = TearingState(sys); mm = nothing, + simplify = false, cse_hack = true, array_hack = true, kwargs...) + var_eq_matching, full_var_eq_matching = tearing(state) + invalidate_cache!(tearing_reassemble( + state, var_eq_matching, full_var_eq_matching; mm, simplify, cse_hack, array_hack)) +end + +""" + partial_state_selection(sys; simplify=false) + +Perform partial state selection and tearing. +""" +function partial_state_selection(sys; simplify = false) + state = TearingState(sys) + var_eq_matching = partial_state_selection_graph!(state) + + tearing_reassemble(state, var_eq_matching; simplify = simplify) +end + +""" + dummy_derivative(sys) + +Perform index reduction and use the dummy derivative technique to ensure that +the system is balanced. +""" +function dummy_derivative(sys, state = TearingState(sys); simplify = false, + mm = nothing, cse_hack = true, array_hack = true, kwargs...) + jac = let state = state + (eqs, vars) -> begin + symeqs = EquationsView(state)[eqs] + Symbolics.jacobian((x -> x.rhs).(symeqs), state.fullvars[vars]) + end + end + state_priority = let state = state + var -> begin + p = 0.0 + var_to_diff = state.structure.var_to_diff + diff_to_var = invview(var_to_diff) + while var_to_diff[var] !== nothing + var = var_to_diff[var] + end + while true + p = max(p, ModelingToolkit.state_priority(state.fullvars[var])) + (var = diff_to_var[var]) === nothing && break + end + p + end + end + var_eq_matching = dummy_derivative_graph!(state, jac; state_priority, + kwargs...) + tearing_reassemble(state, var_eq_matching; simplify, mm, cse_hack, array_hack) +end diff --git a/src/structural_transformation/tearing.jl b/src/structural_transformation/tearing.jl index cb209fa85f..d37eedc853 100644 --- a/src/structural_transformation/tearing.jl +++ b/src/structural_transformation/tearing.jl @@ -1,225 +1,83 @@ -""" - tear_graph(sys) -> sys - -Tear the bipartite graph in a system. -""" -function tear_graph(sys) - find_solvables!(sys) - s = structure(sys) - @unpack graph, solvable_graph, assign, inv_assign, scc = s - - @set! sys.structure.partitions = map(scc) do c - ieqs = filter(eq->isalgeq(s, eq), c) - vars = inv_assign[ieqs] - - td = TraverseDAG(graph.fadjlist, length(assign)) - SystemPartition(tearEquations!(td, solvable_graph.fadjlist, ieqs, vars)...) - end - return sys +struct EquationSolveError + eq::Any + var::Any + rhs::Any end -function tearing_sub(expr, dict, s) - expr = ModelingToolkit.fixpoint_sub(expr, dict) - s ? simplify(expr) : expr +function Base.showerror(io::IO, ese::EquationSolveError) + print(io, "EquationSolveError: While solving\n\n\t") + print(io, ese.eq) + print(io, "\nfor ") + printstyled(io, var, bold = true) + print(io, ", obtained RHS\n\n\tt") + println(io, rhs) end -function tearing_reassemble(sys; simplify=false) - s = structure(sys) - @unpack fullvars, partitions, assign, inv_assign, graph, scc = s - eqs = equations(sys) - - ### extract partition information - rhss = [] - solvars = [] - ns, nd = nsrcs(graph), ndsts(graph) - active_eqs = trues(ns) - active_vars = trues(nd) - rvar2req = Vector{Int}(undef, nd) - for (ith_scc, partition) in enumerate(partitions) - @unpack e_solved, v_solved, e_residual, v_residual = partition - for ii in eachindex(e_solved) - ieq = e_solved[ii]; ns -= 1 - iv = v_solved[ii]; nd -= 1 - rvar2req[iv] = ieq - - active_eqs[ieq] = false - active_vars[iv] = false - - eq = eqs[ieq] - var = fullvars[iv] - rhs = value(solve_for(eq, var; simplify=simplify, check=false)) - # if we don't simplify the rhs and the `eq` is not solved properly - (!simplify && occursin(rhs, var)) && (rhs = SymbolicUtils.polynormalize(rhs)) - # Since we know `eq` is linear wrt `var`, so the round off must be a - # linear term. We can correct the round off error by a linear - # correction. - rhs -= expand_derivatives(Differential(var)(rhs))*var - @assert !(var in vars(rhs)) """ - When solving - $eq - $var remainded in - $rhs. - """ - push!(rhss, rhs) - push!(solvars, var) - end - # DEBUG: - #@show ith_scc solvars .~ rhss - #Main._nlsys[] = eqs[e_solved], fullvars[v_solved] - #ModelingToolkit.topsort_equations(solvars .~ rhss, fullvars) - #empty!(solvars); empty!(rhss) - end - - ### update SCC - eq_reidx = Vector{Int}(undef, nsrcs(graph)) - idx = 0 - for (i, active) in enumerate(active_eqs) - eq_reidx[i] = active ? (idx += 1) : -1 +function masked_cumsum!(A::Vector) + acc = zero(eltype(A)) + for i in eachindex(A) + iszero(A[i]) && continue + A[i] = (acc += A[i]) end +end - rmidxs = Int[] - newscc = Vector{Int}[]; sizehint!(newscc, length(scc)) - for component′ in newscc - component = copy(component′) - for (idx, eq) in enumerate(component) - if active_eqs[eq] - component[idx] = eq_reidx[eq] - else - push!(rmidxs, idx) - end - end - push!(newscc, component) - deleteat!(component, rmidxs) - empty!(rmidxs) - end +function contract_variables(graph::BipartiteGraph, var_eq_matching::Matching, + var_rename, eq_rename, nelim_eq, nelim_var) + dig = DiCMOBiGraph{true}(graph, var_eq_matching) - ### update graph - var_reidx = Vector{Int}(undef, ndsts(graph)) - idx = 0 - for (i, active) in enumerate(active_vars) - var_reidx[i] = active ? (idx += 1) : -1 + # Update bipartite graph + var_deps = map(1:ndsts(graph)) do v + [var_rename[v′] + for v′ in neighborhood(dig, v, Inf; dir = :in) if var_rename[v′] != 0] end - newgraph = BipartiteGraph(ns, nd, Val(false)) - - function visit!(ii, gidx, basecase=true) - ieq = basecase ? ii : rvar2req[ii] - for ivar in 𝑠neighbors(graph, ieq) - # Note that we need to check `ii` against the rhs states to make - # sure we don't run in circles. - (!basecase && ivar === ii) && continue - if active_vars[ivar] - add_edge!(newgraph, gidx, var_reidx[ivar]) + newgraph = BipartiteGraph(nsrcs(graph) - nelim_eq, ndsts(graph) - nelim_var) + for e in 𝑠vertices(graph) + ne = eq_rename[e] + ne == 0 && continue + for v in 𝑠neighbors(graph, e) + newvar = var_rename[v] + if newvar != 0 + add_edge!(newgraph, ne, newvar) else - # If a state is reduced, then we go to the rhs and collect - # its states. - visit!(ivar, gidx, false) - end - end - return nothing - end - - ### update equations - odestats = [] - for idx in eachindex(fullvars); isdervar(s, idx) && continue - push!(odestats, fullvars[idx]) - end - newstates = setdiff(odestats, solvars) - varidxmap = Dict(newstates .=> 1:length(newstates)) - neweqs = Vector{Equation}(undef, ns) - newalgeqs = falses(ns) - - dict = Dict(value.(solvars) .=> value.(rhss)) - - for ieq in Iterators.flatten(scc); active_eqs[ieq] || continue - eq = eqs[ieq] - ridx = eq_reidx[ieq] - - visit!(ieq, ridx) - - if isdiffeq(eq) - neweqs[ridx] = eq.lhs ~ tearing_sub(eq.rhs, dict, simplify) - else - newalgeqs[ridx] = true - if !(eq.lhs isa Number && eq.lhs != 0) - eq = 0 ~ eq.rhs - eq.lhs - end - rhs = tearing_sub(eq.rhs, dict, simplify) - if rhs isa Symbolic - neweqs[ridx] = 0 ~ rhs - else # a number - if abs(rhs) > 100eps(float(rhs)) - @warn "The equation $eq is not consistent. It simplifed to 0 == $rhs." + for nv in var_deps[v] + add_edge!(newgraph, ne, nv) end - neweqs[ridx] = 0 ~ fullvars[inv_assign[ieq]] end end end - ### update partitions - newpartitions = similar(partitions, 0) - emptyintvec = Int[] - for (ii, partition) in enumerate(partitions) - @unpack e_residual, v_residual = partition - isempty(v_residual) && continue - new_e_residual = similar(e_residual) - new_v_residual = similar(v_residual) - for ii in eachindex(e_residual) - new_e_residual[ii] = eq_reidx[ e_residual[ii]] - new_v_residual[ii] = var_reidx[v_residual[ii]] - end - # `emptyintvec` is aliased to save memory - # We need them for type stability - newpart = SystemPartition(emptyintvec, emptyintvec, new_e_residual, new_v_residual) - push!(newpartitions, newpart) - end - - obseqs = solvars .~ rhss - - @set! s.graph = newgraph - @set! s.scc = newscc - @set! s.fullvars = fullvars[active_vars] - @set! s.vartype = s.vartype[active_vars] - @set! s.partitions = newpartitions - @set! s.algeqs = newalgeqs - - @set! sys.structure = s - @set! sys.eqs = neweqs - @set! sys.states = newstates - @set! sys.observed = [observed(sys); obseqs] - return sys + return newgraph end """ - algebraic_equations_scc(sys) + algebraic_variables_scc(sys) -Find strongly connected components of algebraic equations in a system. +Find strongly connected components of algebraic variables in a system. """ -function algebraic_equations_scc(sys) - s = get_structure(sys) - if !(s isa SystemStructure) - sys = initialize_system_structure(sys) - s = structure(sys) - end - +function algebraic_variables_scc(state::TearingState) + graph = state.structure.graph # skip over differential equations - algvars = isalgvar.(Ref(s), 1:ndsts(s.graph)) - eqs = equations(sys) - assign = matching(s, algvars, s.algeqs) - - components = find_scc(s.graph, assign) - inv_assign = inverse_mapping(assign) - - @set! sys.structure.assign = assign - @set! sys.structure.inv_assign = inv_assign - @set! sys.structure.scc = components - return sys + algvars = BitSet(findall(v -> isalgvar(state.structure, v), 1:ndsts(graph))) + algeqs = BitSet(findall(map(1:nsrcs(graph)) do eq + all(v -> !isdervar(state.structure, v), + 𝑠neighbors(graph, eq)) + end)) + var_eq_matching = complete(maximal_matching(graph, e -> e in algeqs, v -> v in algvars)) + var_sccs = find_var_sccs(complete(graph), var_eq_matching) + + return var_eq_matching, var_sccs end -""" - tearing(sys; simplify=false) - -Tear the nonlinear equations in system. When `simplify=true`, we simplify the -new residual residual equations after tearing. -""" -tearing(sys; simplify=false) = tearing_reassemble(tear_graph(algebraic_equations_scc(sys)); simplify=simplify) +function free_equations(graph, vars_scc, var_eq_matching, varfilter::F) where {F} + ne = nsrcs(graph) + seen_eqs = falses(ne) + for vars in vars_scc, var in vars + varfilter(var) || continue + ieq = var_eq_matching[var] + if ieq isa Int + seen_eqs[ieq] = true + end + end + findall(!, seen_eqs) +end diff --git a/src/structural_transformation/utils.jl b/src/structural_transformation/utils.jl index b85f671198..14628f2958 100644 --- a/src/structural_transformation/utils.jl +++ b/src/structural_transformation/utils.jl @@ -3,78 +3,129 @@ ### """ - find_augmenting_path(g::BipartiteGraph, eq, assign, varwhitelist, vcolor=falses(ndsts(g)), ecolor=falses(nsrcs(g))) -> path_found::Bool + maximal_matching(s::SystemStructure, eqfilter=eq->true, varfilter=v->true) -> Matching -Try to find augmenting paths. +Find equation-variable maximal bipartite matching. `s.graph` is a bipartite graph. """ -function find_augmenting_path(g, eq, assign, varwhitelist, vcolor=falses(ndsts(g)), ecolor=falses(nsrcs(g))) - ecolor[eq] = true - - # if a `var` is unassigned and the edge `eq <=> var` exists - for var in 𝑠neighbors(g, eq) - if (varwhitelist === nothing || varwhitelist[var]) && assign[var] == UNASSIGNED - assign[var] = eq - return true - end - end - - # for every `var` such that edge `eq <=> var` exists and `var` is uncolored - for var in 𝑠neighbors(g, eq) - ((varwhitelist === nothing || varwhitelist[var]) && !vcolor[var]) || continue - vcolor[var] = true - if find_augmenting_path(g, assign[var], assign, varwhitelist, vcolor, ecolor) - assign[var] = eq - return true - end - end - return false +function BipartiteGraphs.maximal_matching(s::SystemStructure, eqfilter = eq -> true, + varfilter = v -> true) + maximal_matching(s.graph, eqfilter, varfilter) end -""" - matching(s::Union{SystemStructure,BipartiteGraph}, varwhitelist=nothing, eqwhitelist=nothing) -> assign +n_concrete_eqs(state::TransformationState) = n_concrete_eqs(state.structure) +n_concrete_eqs(structure::SystemStructure) = n_concrete_eqs(structure.graph) +function n_concrete_eqs(graph::BipartiteGraph) + neqs = count(e -> !isempty(𝑠neighbors(graph, e)), 𝑠vertices(graph)) +end -Find equation-variable bipartite matching. `s.graph` is a bipartite graph. -""" -matching(s::SystemStructure, varwhitelist=nothing, eqwhitelist=nothing) = matching(s.graph, varwhitelist, eqwhitelist) -function matching(g::BipartiteGraph, varwhitelist=nothing, eqwhitelist=nothing) - assign = fill(UNASSIGNED, ndsts(g)) - for eq in 𝑠vertices(g) - if eqwhitelist !== nothing - eqwhitelist[eq] || continue +function error_reporting(state, bad_idxs, n_highest_vars, iseqs, orig_inputs) + io = IOBuffer() + neqs = n_concrete_eqs(state) + if iseqs + error_title = "More equations than variables, here are the potential extra equation(s):\n" + out_arr = has_equations(state) ? equations(state)[bad_idxs] : bad_idxs + else + error_title = "More variables than equations, here are the potential extra variable(s):\n" + out_arr = get_fullvars(state)[bad_idxs] + unset_inputs = intersect(out_arr, orig_inputs) + n_missing_eqs = n_highest_vars - neqs + n_unset_inputs = length(unset_inputs) + if n_unset_inputs > 0 + println(io, "In particular, the unset input(s) are:") + Base.print_array(io, unset_inputs) + println(io) + println(io, "The rest of potentially unset variable(s) are:") end - find_augmenting_path(g, eq, assign, varwhitelist) end - return assign + + Base.print_array(io, out_arr) + msg = String(take!(io)) + if iseqs + throw(ExtraEquationsSystemException("The system is unbalanced. There are " * + "$n_highest_vars highest order derivative variables " + * "and $neqs equations.\n" + * error_title + * msg)) + else + throw(ExtraVariablesSystemException("The system is unbalanced. There are " * + "$n_highest_vars highest order derivative variables " + * "and $neqs equations.\n" + * error_title + * msg)) + end end ### ### Structural check ### -function check_consistency(s::SystemStructure) - @unpack varmask, graph, varassoc, fullvars = s - n_highest_vars = count(varmask) - neqs = nsrcs(graph) - is_balanced = n_highest_vars == neqs - (neqs > 0 && !is_balanced) && throw(InvalidSystemException( - "The system is unbalanced. " - * "There are $n_highest_vars highest order derivative variables " - * "and $neqs equations." - )) +""" + $(TYPEDSIGNATURES) +Check if the `state` represents a singular system, and return the unmatched variables. +""" +function singular_check(state::TransformationState) + @unpack graph, var_to_diff = state.structure + fullvars = get_fullvars(state) # This is defined to check if Pantelides algorithm terminates. For more # details, check the equation (15) of the original paper. - extended_graph = (@set graph.fadjlist = [graph.fadjlist; pantelides_extended_graph(varassoc)]) - extended_assign = matching(extended_graph) + extended_graph = (@set graph.fadjlist = Vector{Int}[graph.fadjlist; + map(collect, edges(var_to_diff))]) + extended_var_eq_matching = maximal_matching(extended_graph) + nvars = ndsts(graph) unassigned_var = [] - for (vj, eq) in enumerate(extended_assign) - if eq === UNASSIGNED + for (vj, eq) in enumerate(extended_var_eq_matching) + vj > nvars && break + if eq === unassigned && !isempty(𝑑neighbors(graph, vj)) push!(unassigned_var, fullvars[vj]) end end + return unassigned_var +end + +""" + $(TYPEDSIGNATURES) + +Check the consistency of `state`, given the inputs `orig_inputs`. If `nothrow == false`, +throws an error if the system is under-/over-determined or singular. In this case, if the +function returns it will return `true`. If `nothrow == true`, it will return `false` +instead of throwing an error. The singular case will print a warning. +""" +function check_consistency(state::TransformationState, orig_inputs; nothrow = false) + fullvars = get_fullvars(state) + neqs = n_concrete_eqs(state) + @unpack graph, var_to_diff = state.structure + highest_vars = computed_highest_diff_variables(complete!(state.structure)) + n_highest_vars = 0 + for (v, h) in enumerate(highest_vars) + h || continue + isempty(𝑑neighbors(graph, v)) && continue + n_highest_vars += 1 + end + is_balanced = n_highest_vars == neqs + + if neqs > 0 && !is_balanced + nothrow && return false + varwhitelist = var_to_diff .== nothing + var_eq_matching = maximal_matching(graph, eq -> true, v -> varwhitelist[v]) # not assigned + # Just use `error_reporting` to do conditional + iseqs = n_highest_vars < neqs + if iseqs + eq_var_matching = invview(complete(var_eq_matching, nsrcs(graph))) # extra equations + bad_idxs = findall(isequal(unassigned), @view eq_var_matching[1:nsrcs(graph)]) + else + bad_idxs = findall(isequal(unassigned), var_eq_matching) + end + error_reporting(state, bad_idxs, n_highest_vars, iseqs, orig_inputs) + end + + unassigned_var = singular_check(state) if !isempty(unassigned_var) || !is_balanced + if nothrow + return false + end io = IOBuffer() Base.print_array(io, unassigned_var) unassigned_var_str = String(take!(io)) @@ -84,16 +135,7 @@ function check_consistency(s::SystemStructure) throw(InvalidSystemException(errmsg)) end - return nothing -end - -function pantelides_extended_graph(varassoc) - adj = Vector{Int}[] - for (j, v) in enumerate(varassoc) - dj = varassoc[j] - dj > 0 && push!(adj, [j, dj]) - end - return adj + return true end ### @@ -101,95 +143,34 @@ end ### """ - find_scc(g::BipartiteGraph, assign=nothing) + find_var_sccs(g::BipartiteGraph, assign=nothing) -Find strongly connected components of the equations defined by `g`. `assign` +Find strongly connected components of the variables defined by `g`. `assign` gives the undirected bipartite graph a direction. When `assign === nothing`, we assume that the ``i``-th variable is assigned to the ``i``-th equation. """ -function find_scc(g::BipartiteGraph, assign=nothing) - id = 0 - stack = Int[] - components = Vector{Int}[] - n = nsrcs(g) - onstack = falses(n) - lowlink = zeros(Int, n) - ids = fill(UNVISITED, n) - - for eq in 𝑠vertices(g) - if ids[eq] == UNVISITED - id = strongly_connected!(stack, onstack, components, lowlink, ids, g, assign, eq, id) - end - end - return components +function find_var_sccs(g::BipartiteGraph, assign = nothing) + cmog = DiCMOBiGraph{true}(g, + Matching(assign === nothing ? Base.OneTo(nsrcs(g)) : assign)) + sccs = Graphs.strongly_connected_components(cmog) + foreach(sort!, sccs) + return sccs end -""" - strongly_connected!(stack, onstack, components, lowlink, ids, g, assign, eq, id) - -Use Tarjan's algorithm to find strongly connected components. -""" -function strongly_connected!(stack, onstack, components, lowlink, ids, g, assign, eq, id) - id += 1 - lowlink[eq] = ids[eq] = id - - # add `eq` to the stack - push!(stack, eq) - onstack[eq] = true - - # for `adjeq` in the adjacency list of `eq` - for var in 𝑠neighbors(g, eq) - if assign === nothing - adjeq = var - else - # assign[var] => the equation that's assigned to var - adjeq = assign[var] - # skip equations that are not assigned - adjeq == UNASSIGNED && continue - end - - # if `adjeq` is not yet idsed - if ids[adjeq] == UNVISITED # visit unvisited nodes - id = strongly_connected!(stack, onstack, components, lowlink, ids, g, assign, adjeq, id) - end - # at the callback of the DFS - if onstack[adjeq] - lowlink[eq] = min(lowlink[eq], lowlink[adjeq]) - end - end - - # if we are at a start of a strongly connected component - if lowlink[eq] == ids[eq] - component = Int[] - repeat = true - # pop until we are at the start of the strongly connected component - while repeat - w = pop!(stack) - onstack[w] = false - lowlink[w] = ids[eq] - # put `w` in current component - push!(component, w) - repeat = w != eq - end - push!(components, sort!(component)) - end - return id -end - -function sorted_incidence_matrix(sys, val=true; only_algeqs=false, only_algvars=false) - sys = algebraic_equations_scc(sys) - s = structure(sys) - @unpack assign, inv_assign, fullvars, scc, graph = s - g = graph +function sorted_incidence_matrix(ts::TransformationState, val = true; only_algeqs = false, + only_algvars = false) + var_eq_matching, var_scc = algebraic_variables_scc(ts) + s = ts.structure + graph = ts.structure.graph varsmap = zeros(Int, ndsts(graph)) eqsmap = zeros(Int, nsrcs(graph)) varidx = 0 eqidx = 0 - for c in scc, eq in c - var = inv_assign[eq] - if var != 0 + for vs in var_scc, v in vs + eq = var_eq_matching[v] + if eq !== unassigned eqsmap[eq] = (eqidx += 1) - varsmap[var] = (varidx += 1) + varsmap[v] = (varidx += 1) end end for i in diffvars_range(s) @@ -206,9 +187,10 @@ function sorted_incidence_matrix(sys, val=true; only_algeqs=false, only_algvars= I = Int[] J = Int[] - for eq in 𝑠vertices(g) - only_algeqs && (isalgeq(s, eq) || continue) - for var in 𝑠neighbors(g, eq) + algeqs_set = algeqs(s) + for eq in 𝑠vertices(graph) + only_algeqs && (eq in algeqs_set || continue) + for var in 𝑠neighbors(graph, eq) only_algvars && (isalgvar(s, var) || continue) i = eqsmap[eq] j = varsmap[var] @@ -217,74 +199,191 @@ function sorted_incidence_matrix(sys, val=true; only_algeqs=false, only_algvars= push!(J, j) end end - #sparse(I, J, val, nsrcs(g), ndsts(g)) - sparse(I, J, val) + sparse(I, J, val, nsrcs(graph), ndsts(graph)) end ### ### Structural and symbolic utilities ### -function find_solvables!(sys) - s = structure(sys) - @unpack fullvars, graph, solvable_graph = s - eqs = equations(sys) - empty!(solvable_graph) - for (i, eq) in enumerate(eqs) - isdiffeq(eq) && continue - term = value(eq.rhs - eq.lhs) - for j in 𝑠neighbors(graph, i) - isalgvar(s, j) || continue - D = Differential(fullvars[j]) - c = expand_derivatives(D(term), false) - if !(c isa Symbolic) && c isa Number && c != 0 - add_edge!(solvable_graph, i, j) +function find_eq_solvables!(state::TearingState, ieq, to_rm = Int[], coeffs = nothing; + may_be_zero = false, + allow_symbolic = false, allow_parameter = true, + conservative = false, + kwargs...) + fullvars = state.fullvars + @unpack graph, solvable_graph = state.structure + eq = equations(state)[ieq] + term = value(eq.rhs - eq.lhs) + all_int_vars = true + coeffs === nothing || empty!(coeffs) + empty!(to_rm) + for j in 𝑠neighbors(graph, ieq) + var = fullvars[j] + isirreducible(var) && (all_int_vars = false; continue) + a, b, islinear = linear_expansion(term, var) + a, b = unwrap(a), unwrap(b) + islinear || (all_int_vars = false; continue) + a = ModelingToolkit.fold_constants(a) + b = ModelingToolkit.fold_constants(b) + if a isa Symbolic + all_int_vars = false + if !allow_symbolic + if allow_parameter + all( + x -> ModelingToolkit.isparameter(x) || ModelingToolkit.isconstant(x), + vars(a)) || continue + else + continue + end + end + add_edge!(solvable_graph, ieq, j) + continue + end + if !(a isa Number) + all_int_vars = false + continue + end + # When the expression is linear with numeric `a`, then we can safely + # only consider `b` for the following iterations. + term = b + if isone(abs(a)) + coeffs === nothing || push!(coeffs, convert(Int, a)) + else + all_int_vars = false + conservative && continue + end + if a != 0 + add_edge!(solvable_graph, ieq, j) + else + if may_be_zero + push!(to_rm, j) + else + @warn "Internal error: Variable $var was marked as being in $eq, but was actually zero" end end end - s + for j in to_rm + rem_edge!(graph, ieq, j) + end + all_int_vars, term end -### -### Miscellaneous -### +function find_solvables!(state::TearingState; kwargs...) + @assert state.structure.solvable_graph === nothing + eqs = equations(state) + graph = state.structure.graph + state.structure.solvable_graph = BipartiteGraph(nsrcs(graph), ndsts(graph)) + to_rm = Int[] + for ieq in 1:length(eqs) + find_eq_solvables!(state, ieq, to_rm; kwargs...) + end + return nothing +end -function inverse_mapping(assign) - invassign = zeros(Int, length(assign)) - for (i, eq) in enumerate(assign) - eq <= 0 && continue - invassign[eq] = i +function linear_subsys_adjmat!(state::TransformationState; kwargs...) + graph = state.structure.graph + if state.structure.solvable_graph === nothing + state.structure.solvable_graph = BipartiteGraph(nsrcs(graph), ndsts(graph)) + end + linear_equations = Int[] + eqs = equations(state.sys) + eadj = Vector{Int}[] + cadj = Vector{Int}[] + coeffs = Int[] + to_rm = Int[] + for i in eachindex(eqs) + all_int_vars, rhs = find_eq_solvables!(state, i, to_rm, coeffs; kwargs...) + + # Check if all unknowns in the equation is both linear and homogeneous, + # i.e. it is in the form of + # + # ``∑ c_i * v_i = 0``, + # + # where ``c_i`` ∈ ℤ and ``v_i`` denotes unknowns. + if all_int_vars && Symbolics._iszero(rhs) + push!(linear_equations, i) + push!(eadj, copy(𝑠neighbors(graph, i))) + push!(cadj, copy(coeffs)) + end end - return invassign + + mm = SparseMatrixCLIL(nsrcs(graph), + ndsts(graph), + linear_equations, eadj, cadj) + return mm +end + +highest_order_variable_mask(ts) = + let v2d = ts.structure.var_to_diff + v -> isempty(outneighbors(v2d, v)) + end + +lowest_order_variable_mask(ts) = + let v2d = ts.structure.var_to_diff + v -> isempty(inneighbors(v2d, v)) + end + +function but_ordered_incidence(ts::TearingState, varmask = highest_order_variable_mask(ts)) + graph = complete(ts.structure.graph) + var_eq_matching = complete(maximal_matching(graph, _ -> true, varmask)) + scc = find_var_sccs(graph, var_eq_matching) + vordering = Vector{Int}(undef, 0) + bb = Int[1] + sizehint!(vordering, ndsts(graph)) + sizehint!(bb, ndsts(graph)) + l = 1 + for c in scc + isemptyc = true + for v in c + if varmask(v) + push!(vordering, v) + l += 1 + isemptyc = false + end + end + isemptyc || push!(bb, l) + end + mm = incidence_matrix(graph) + mm[[var_eq_matching[v] for v in vordering if var_eq_matching[v] isa Int], vordering], bb end # debugging use -function reordered_matrix(sys, partitions=structure(sys).partitions) - s = structure(sys) - @unpack graph = s +function reordered_matrix(sys, torn_matching) + s = TearingState(sys) + complete!(s.structure) + @unpack graph = s.structure eqs = equations(sys) nvars = ndsts(graph) + max_matching = complete(maximal_matching(graph)) + torn_matching = complete(torn_matching) + sccs = find_var_sccs(graph, max_matching) I, J = Int[], Int[] ii = 0 M = Int[] - for partition in partitions - append!(M, partition.v_solved) - append!(M, partition.v_residual) + solved = BitSet(findall(torn_matching .!== unassigned)) + for vars in sccs + append!(M, filter(in(solved), vars)) + append!(M, filter(!in(solved), vars)) end - M = inverse_mapping(vcat(M, setdiff(1:nvars, M))) - for partition in partitions - for es in partition.e_solved + M = invperm(vcat(M, setdiff(1:nvars, M))) + for vars in sccs + e_solved = [torn_matching[v] for v in vars if torn_matching[v] !== unassigned] + for es in e_solved isdiffeq(eqs[es]) && continue ii += 1 - js = [M[x] for x in 𝑠neighbors(graph, es) if isalgvar(s, x)] + js = [M[x] for x in 𝑠neighbors(graph, es) if isalgvar(s.structure, x)] append!(I, fill(ii, length(js))) append!(J, js) end - for er in partition.e_residual + e_residual = setdiff( + [max_matching[v] + for v in vars if max_matching[v] !== unassigned], e_solved) + for er in e_residual isdiffeq(eqs[er]) && continue ii += 1 - js = [M[x] for x in 𝑠neighbors(graph, er) if isalgvar(s, x)] + js = [M[x] for x in 𝑠neighbors(graph, er) if isalgvar(s.structure, x)] append!(I, fill(ii, length(js))) append!(J, js) end @@ -293,17 +392,177 @@ function reordered_matrix(sys, partitions=structure(sys).partitions) sparse(I, J, true) end +""" + uneven_invmap(n::Int, list) + +returns an uneven inv map with length `n`. +""" +function uneven_invmap(n::Int, list) + rename = zeros(Int, n) + for (i, v) in enumerate(list) + rename[v] = i + end + return rename +end + +function torn_system_jacobian_sparsity(sys) + state = get_tearing_state(sys) + state isa TearingState || return nothing + @unpack structure = state + @unpack graph, var_to_diff = structure + + neqs = nsrcs(graph) + nsts = ndsts(graph) + states_idxs = findall(!Base.Fix1(isdervar, structure), 1:nsts) + var2idx = uneven_invmap(nsts, states_idxs) + I = Int[] + J = Int[] + for ieq in 1:neqs + for ivar in 𝑠neighbors(graph, ieq) + nivar = get(var2idx, ivar, 0) + nivar == 0 && continue + push!(I, ieq) + push!(J, nivar) + end + end + return sparse(I, J, true, neqs, neqs) +end + ### ### Nonlinear equation(s) solving ### -@noinline nlsolve_failure(rc) = error("The nonlinear solver failed with the return code $rc.") +@noinline function nlsolve_failure(rc) + error("The nonlinear solver failed with the return code $rc.") +end function numerical_nlsolve(f, u0, p) prob = NonlinearProblem{false}(f, u0, p) - sol = solve(prob, NewtonRaphson()) + sol = solve(prob, SimpleNewtonRaphson()) rc = sol.retcode - rc === :DEFAULT || nlsolve_failure(rc) + (rc == ReturnCode.Success) || nlsolve_failure(rc) # TODO: robust initial guess, better debugging info, and residual check sol.u end + +### +### Misc +### + +""" +Handle renaming variable names for discrete structural simplification. Three cases: +- positive shift: do nothing +- zero shift: x(t) => Shift(t, 0)(x(t)) +- negative shift: rename the variable +""" +function lower_shift_varname(var, iv) + op = operation(var) + if op isa Shift + return shift2term(var) + else + return Shift(iv, 0)(var, true) + end +end + +""" +Rename a Shift variable with negative shift, Shift(t, k)(x(t)) to xₜ₋ₖ(t). +""" +function shift2term(var) + op = operation(var) + iv = op.t + arg = only(arguments(var)) + is_lowered = !isnothing(ModelingToolkit.getunshifted(arg)) + + backshift = is_lowered ? op.steps + ModelingToolkit.getshift(arg) : op.steps + + # Char(0x208b) = ₋ (subscripted minus) + # Char(0x208a) = ₊ (subscripted plus) + pm = backshift > 0 ? Char(0x208a) : Char(0x208b) + # subscripted number, e.g. ₁ + num = join(Char(0x2080 + d) for d in reverse!(digits(abs(backshift)))) + # Char(0x209c) = ₜ + # ds = ₜ₋₁ + ds = join([Char(0x209c), pm, num]) + + O = is_lowered ? ModelingToolkit.getunshifted(arg) : arg + oldop = operation(O) + newname = backshift != 0 ? Symbol(string(nameof(oldop)), ds) : + Symbol(string(nameof(oldop))) + + newvar = maketerm(typeof(O), Symbolics.rename(oldop, newname), + Symbolics.children(O), Symbolics.metadata(O)) + newvar = setmetadata(newvar, Symbolics.VariableSource, (:variables, newname)) + newvar = setmetadata(newvar, ModelingToolkit.VariableUnshifted, O) + newvar = setmetadata(newvar, ModelingToolkit.VariableShift, backshift) + return newvar +end + +function isdoubleshift(var) + return ModelingToolkit.isoperator(var, ModelingToolkit.Shift) && + ModelingToolkit.isoperator(arguments(var)[1], ModelingToolkit.Shift) +end + +""" +Simplify multiple shifts: Shift(t, k1)(Shift(t, k2)(x)) becomes Shift(t, k1+k2)(x). +""" +function simplify_shifts(var) + ModelingToolkit.hasshift(var) || return var + var isa Equation && return simplify_shifts(var.lhs) ~ simplify_shifts(var.rhs) + (op = operation(var)) isa Shift && op.steps == 0 && return first(arguments(var)) + if isdoubleshift(var) + op1 = operation(var) + vv1 = arguments(var)[1] + op2 = operation(vv1) + vv2 = arguments(vv1)[1] + s1 = op1.steps + s2 = op2.steps + t1 = op1.t + t2 = op2.t + return simplify_shifts(ModelingToolkit.Shift(t1 === nothing ? t2 : t1, s1 + s2)(vv2)) + else + return maketerm(typeof(var), operation(var), simplify_shifts.(arguments(var)), + unwrap(var).metadata) + end +end + +""" +Distribute a shift applied to a whole expression or equation. +Shift(t, 1)(x + y) will become Shift(t, 1)(x) + Shift(t, 1)(y). +Only shifts variables whose independent variable is the same t that appears in the Shift (i.e. constants, time-independent parameters, etc. do not get shifted). +""" +function distribute_shift(var) + var = unwrap(var) + var isa Equation && return distribute_shift(var.lhs) ~ distribute_shift(var.rhs) + + ModelingToolkit.hasshift(var) || return var + shift = operation(var) + shift isa Shift || return var + + shift = operation(var) + expr = only(arguments(var)) + if expr isa Equation + return distribute_shift(shift(expr.lhs)) ~ distribute_shift(shift(expr.rhs)) + end + shiftexpr = _distribute_shift(expr, shift) + return simplify_shifts(shiftexpr) +end + +function _distribute_shift(expr, shift) + if iscall(expr) + op = operation(expr) + args = arguments(expr) + + if ModelingToolkit.isvariable(expr) + (length(args) == 1 && isequal(shift.t, only(args))) ? (return shift(expr)) : + (return expr) + elseif op isa Shift + return shift(expr) + else + return maketerm( + typeof(expr), operation(expr), Base.Fix2(_distribute_shift, shift).(args), + unwrap(expr).metadata) + end + else + return expr + end +end diff --git a/src/systems/abstractsystem.jl b/src/systems/abstractsystem.jl index 886c7bae41..29338d0722 100644 --- a/src/systems/abstractsystem.jl +++ b/src/systems/abstractsystem.jl @@ -1,744 +1,3461 @@ -""" -```julia -calculate_tgrad(sys::AbstractSystem) -``` - -Calculate the time gradient of a system. - -Returns a vector of [`Num`](@ref) instances. The result from the first -call will be cached in the system object. -""" -function calculate_tgrad end - -""" -```julia -calculate_gradient(sys::AbstractSystem) -``` - -Calculate the gradient of a scalar system. - -Returns a vector of [`Num`](@ref) instances. The result from the first -call will be cached in the system object. -""" -function calculate_gradient end - -""" -```julia -calculate_jacobian(sys::AbstractSystem) -``` - -Calculate the jacobian matrix of a system. - -Returns a matrix of [`Num`](@ref) instances. The result from the first -call will be cached in the system object. -""" -function calculate_jacobian end - -""" -```julia -calculate_factorized_W(sys::AbstractSystem) -``` - -Calculate the factorized W-matrix of a system. - -Returns a matrix of [`Num`](@ref) instances. The result from the first -call will be cached in the system object. -""" -function calculate_factorized_W end - -""" -```julia -calculate_hessian(sys::AbstractSystem) -``` - -Calculate the hessian matrix of a scalar system. - -Returns a matrix of [`Num`](@ref) instances. The result from the first -call will be cached in the system object. -""" -function calculate_hessian end - -""" -```julia -generate_tgrad(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; kwargs...) -``` - -Generates a function for the time gradient of a system. Extra arguments control -the arguments to the internal [`build_function`](@ref) call. -""" -function generate_tgrad end - -""" -```julia -generate_gradient(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; kwargs...) -``` - -Generates a function for the gradient of a system. Extra arguments control -the arguments to the internal [`build_function`](@ref) call. -""" -function generate_gradient end - -""" -```julia -generate_jacobian(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; sparse = false, kwargs...) -``` - -Generates a function for the jacobian matrix matrix of a system. Extra arguments control -the arguments to the internal [`build_function`](@ref) call. -""" -function generate_jacobian end - -""" -```julia -generate_factorized_W(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; sparse = false, kwargs...) -``` - -Generates a function for the factorized W-matrix matrix of a system. Extra arguments control -the arguments to the internal [`build_function`](@ref) call. -""" -function generate_factorized_W end - -""" -```julia -generate_hessian(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; sparse = false, kwargs...) -``` - -Generates a function for the hessian matrix matrix of a system. Extra arguments control -the arguments to the internal [`build_function`](@ref) call. -""" -function generate_hessian end - -""" -```julia -generate_function(sys::AbstractSystem, dvs = states(sys), ps = parameters(sys), expression = Val{true}; kwargs...) -``` - -Generate a function to evaluate the system's equations. -""" -function generate_function end - -Base.nameof(sys::AbstractSystem) = getfield(sys, :name) - -function getname(t) - if istree(t) - operation(t) isa Sym ? getname(operation(t)) : error("Cannot get name of $t") - else - nameof(t) - end -end - -independent_variable(sys::AbstractSystem) = isdefined(sys, :iv) ? getfield(sys, :iv) : nothing - -function structure(sys::AbstractSystem) - s = get_structure(sys) - s isa SystemStructure || throw(ArgumentError("SystemStructure is not yet initialized, please run `sys = initialize_system_structure(sys)` or `sys = alias_elimination(sys)`.")) - return s -end -for prop in [ - :eqs - :noiseeqs - :iv - :states - :ps - :defaults - :observed - :tgrad - :jac - :Wfact - :Wfact_t - :systems - :structure - :op - :equality_constraints - :inequality_constraints - :controls - :loss - :bcs - :domain - :depvars - :indvars - :connection_type - ] - fname1 = Symbol(:get_, prop) - fname2 = Symbol(:has_, prop) - @eval begin - $fname1(sys::AbstractSystem) = getfield(sys, $(QuoteNode(prop))) - $fname2(sys::AbstractSystem) = isdefined(sys, $(QuoteNode(prop))) - end -end - -Setfield.get(obj::AbstractSystem, l::Setfield.PropertyLens{field}) where {field} = getfield(obj, field) -@generated function ConstructionBase.setproperties(obj::AbstractSystem, patch::NamedTuple) - if issubset(fieldnames(patch), fieldnames(obj)) - args = map(fieldnames(obj)) do fn - if fn in fieldnames(patch) - :(patch.$fn) - else - :(getfield(obj, $(Meta.quot(fn)))) - end - end - return Expr(:block, - Expr(:meta, :inline), - Expr(:call,:(constructorof($obj)), args...) - ) - else - error("This should never happen. Trying to set $(typeof(obj)) with $patch.") - end -end - -rename(x::AbstractSystem, name) = @set x.name = name - -function Base.propertynames(sys::AbstractSystem; private=false) - if private - return fieldnames(typeof(sys)) - else - names = Symbol[] - for s in get_systems(sys) - push!(names, getname(s)) - end - has_states(sys) && for s in get_states(sys) - push!(names, getname(s)) - end - has_ps(sys) && for s in get_ps(sys) - push!(names, getname(s)) - end - has_observed(sys) && for s in get_observed(sys) - push!(names, getname(s.lhs)) - end - return names - end -end - -function Base.getproperty(sys::AbstractSystem, name::Symbol; namespace=true) - sysname = nameof(sys) - systems = get_systems(sys) - if isdefined(sys, name) - Base.depwarn("`sys.name` like `sys.$name` is deprecated. Use getters like `get_$name` instead.", "sys.$name") - return getfield(sys, name) - elseif !isempty(systems) - i = findfirst(x->nameof(x)==name,systems) - if i !== nothing - return namespace ? rename(systems[i],renamespace(sysname,name)) : systems[i] - end - end - - sts = get_states(sys) - i = findfirst(x->getname(x) == name, sts) - - if i !== nothing - return namespace ? renamespace(sysname,sts[i]) : sts[i] - end - - if has_ps(sys) - ps = get_ps(sys) - i = findfirst(x->getname(x) == name,ps) - if i !== nothing - return namespace ? renamespace(sysname,ps[i]) : ps[i] - end - end - - if has_observed(sys) - obs = get_observed(sys) - i = findfirst(x->getname(x.lhs)==name,obs) - if i !== nothing - return namespace ? renamespace(sysname,obs[i]) : obs[i] - end - end - - throw(ArgumentError("Variable $name does not exist")) -end - -function Base.setproperty!(sys::AbstractSystem, prop::Symbol, val) - # We use this weird syntax because `parameters` and `states` calls are - # potentially expensive. - if ( - params = parameters(sys); - idx = findfirst(s->getname(s) == prop, params); - idx !== nothing; - ) - get_defaults(sys)[params[idx]] = value(val) - elseif ( - sts = states(sys); - idx = findfirst(s->getname(s) == prop, sts); - idx !== nothing; - ) - get_defaults(sys)[sts[idx]] = value(val) - else - setfield!(sys, prop, val) - end -end - -abstract type SymScope end - -struct LocalScope <: SymScope end -LocalScope(sym::Union{Num, Sym}) = setmetadata(sym, SymScope, LocalScope()) - -struct ParentScope <: SymScope - parent::SymScope -end -ParentScope(sym::Union{Num, Sym}) = setmetadata(sym, SymScope, ParentScope(getmetadata(value(sym), SymScope, LocalScope()))) - -struct GlobalScope <: SymScope end -GlobalScope(sym::Union{Num, Sym}) = setmetadata(sym, SymScope, GlobalScope()) - -function renamespace(namespace, x) - if x isa Num - renamespace(namespace, value(x)) - elseif x isa Symbolic - let scope = getmetadata(x, SymScope, LocalScope()) - if scope isa LocalScope - rename(x, renamespace(namespace, getname(x))) - elseif scope isa ParentScope - setmetadata(x, SymScope, scope.parent) - else # GlobalScope - x - end - end - else - Symbol(namespace,:₊,x) - end -end - -namespace_variables(sys::AbstractSystem) = states(sys, states(sys)) -namespace_parameters(sys::AbstractSystem) = parameters(sys, parameters(sys)) - -function namespace_defaults(sys) - defs = defaults(sys) - Dict((isparameter(k) ? parameters(sys, k) : states(sys, k)) => namespace_expr(defs[k], nameof(sys), independent_variable(sys)) for k in keys(defs)) -end - -function namespace_equations(sys::AbstractSystem) - eqs = equations(sys) - isempty(eqs) && return Equation[] - iv = independent_variable(sys) - map(eq->namespace_equation(eq,nameof(sys),iv), eqs) -end - -function namespace_equation(eq::Equation,name,iv) - _lhs = namespace_expr(eq.lhs,name,iv) - _rhs = namespace_expr(eq.rhs,name,iv) - _lhs ~ _rhs -end - -function namespace_expr(O::Sym,name,iv) - isequal(O, iv) ? O : renamespace(name,O) -end - -_symparam(s::Symbolic{T}) where {T} = T -function namespace_expr(O,name,iv) where {T} - O = value(O) - if istree(O) - renamed = map(a->namespace_expr(a,name,iv), arguments(O)) - if operation(O) isa Sym - rename(O,getname(renamespace(name, O))) - else - similarterm(O,operation(O),renamed) - end - else - O - end -end - -function states(sys::AbstractSystem) - sts = get_states(sys) - systems = get_systems(sys) - unique(isempty(systems) ? - sts : - [sts;reduce(vcat,namespace_variables.(systems))]) -end -function parameters(sys::AbstractSystem) - ps = get_ps(sys) - systems = get_systems(sys) - isempty(systems) ? ps : [ps;reduce(vcat,namespace_parameters.(systems))] -end -function observed(sys::AbstractSystem) - iv = independent_variable(sys) - obs = get_observed(sys) - systems = get_systems(sys) - [obs; - reduce(vcat, - (map(o->namespace_equation(o, nameof(s), iv), observed(s)) for s in systems), - init=Equation[])] -end - -Base.@deprecate default_u0(x) defaults(x) false -Base.@deprecate default_p(x) defaults(x) false -function defaults(sys::AbstractSystem) - systems = get_systems(sys) - defs = get_defaults(sys) - isempty(systems) ? defs : mapreduce(namespace_defaults, merge, systems; init=defs) -end - -states(sys::AbstractSystem, v) = renamespace(nameof(sys), v) -parameters(sys::AbstractSystem, v) = toparam(states(sys, v)) -for f in [:states, :parameters] - @eval $f(sys::AbstractSystem, vs::AbstractArray) = map(v->$f(sys, v), vs) -end - -flatten(sys::AbstractSystem) = sys - -function equations(sys::ModelingToolkit.AbstractSystem) - eqs = get_eqs(sys) - systems = get_systems(sys) - if isempty(systems) - return eqs - else - eqs = Equation[eqs; - reduce(vcat, - namespace_equations.(get_systems(sys)); - init=Equation[])] - return eqs - end -end - -function islinear(sys::AbstractSystem) - rhs = [eq.rhs for eq ∈ equations(sys)] - - all(islinear(r, states(sys)) for r in rhs) -end - -struct AbstractSysToExpr - sys::AbstractSystem - states::Vector -end -AbstractSysToExpr(sys) = AbstractSysToExpr(sys,states(sys)) -function (f::AbstractSysToExpr)(O) - !istree(O) && return toexpr(O) - any(isequal(O), f.states) && return nameof(operation(O)) # variables - if isa(operation(O), Sym) - return build_expr(:call, Any[nameof(operation(O)); f.(arguments(O))]) - end - return build_expr(:call, Any[operation(O); f.(arguments(O))]) -end - -### -### System utils -### -function push_vars!(stmt, name, typ, vars) - isempty(vars) && return - vars_expr = Expr(:macrocall, typ, nothing) - for s in vars - if istree(s) - f = nameof(operation(s)) - args = arguments(s) - ex = :($f($(args...))) - else - ex = nameof(s) - end - push!(vars_expr.args, ex) - end - push!(stmt, :($name = $collect($vars_expr))) - return -end - -function round_trip_expr(t, var2name) - name = get(var2name, t, nothing) - name !== nothing && return name - t isa Sym && return nameof(t) - istree(t) || return t - f = round_trip_expr(operation(t), var2name) - args = map(Base.Fix2(round_trip_expr, var2name), arguments(t)) - return :($f($(args...))) -end -round_trip_eq(eq, var2name) = Expr(:call, :~, round_trip_expr(eq.lhs, var2name), round_trip_expr(eq.rhs, var2name)) - -function push_eqs!(stmt, eqs, var2name) - eqs_name = gensym(:eqs) - eqs_expr = Expr(:vcat) - eqs_blk = Expr(:(=), eqs_name, eqs_expr) - for eq in eqs - push!(eqs_expr.args, round_trip_eq(eq, var2name)) - end - - push!(stmt, eqs_blk) - return eqs_name -end - -function push_defaults!(stmt, defs, var2name) - defs_name = gensym(:defs) - defs_expr = Expr(:call, Dict) - defs_blk = Expr(:(=), defs_name, defs_expr) - for d in defs - n = round_trip_expr(d.first, var2name) - v = round_trip_expr(d.second, var2name) - push!(defs_expr.args, :($(=>)($n, $v))) - end - - push!(stmt, defs_blk) - return defs_name -end - -### -### System I/O -### -function toexpr(sys::AbstractSystem) - sys = flatten(sys) - expr = Expr(:block) - stmt = expr.args - - iv = independent_variable(sys) - ivname = gensym(:iv) - if iv !== nothing - push!(stmt, :($ivname = (@variables $(getname(iv)))[1])) - end - - stsname = gensym(:sts) - sts = states(sys) - push_vars!(stmt, stsname, Symbol("@variables"), sts) - psname = gensym(:ps) - ps = parameters(sys) - push_vars!(stmt, psname, Symbol("@parameters"), ps) - - var2name = Dict{Any,Symbol}() - for v in Iterators.flatten((sts, ps)) - var2name[v] = getname(v) - end - - eqs_name = push_eqs!(stmt, equations(sys), var2name) - defs_name = push_defaults!(stmt, defaults(sys), var2name) - - if sys isa ODESystem - push!(stmt, :($ODESystem($eqs_name, $ivname, $stsname, $psname; defaults=$defs_name))) - elseif sys isa NonlinearSystem - push!(stmt, :($NonlinearSystem($eqs_name, $stsname, $psname; defaults=$defs_name))) - end - - striplines(expr) # keeping the line numbers is never helpful -end - -Base.write(io::IO, sys::AbstractSystem) = write(io, readable_code(toexpr(sys))) - -function Base.show(io::IO, ::MIME"text/plain", sys::AbstractSystem) - eqs = equations(sys) - if eqs isa AbstractArray - Base.printstyled(io, "Model $(nameof(sys)) with $(length(eqs)) equations\n"; bold=true) - else - Base.printstyled(io, "Model $(nameof(sys))\n"; bold=true) - end - # The reduced equations are usually very long. It's not that useful to print - # them. - #Base.print_matrix(io, eqs) - #println(io) - - rows = first(displaysize(io)) ÷ 5 - limit = get(io, :limit, false) - - vars = states(sys); nvars = length(vars) - Base.printstyled(io, "States ($nvars):"; bold=true) - nrows = min(nvars, limit ? rows : nvars) - limited = nrows < length(vars) - defs = has_defaults(sys) ? defaults(sys) : nothing - for i in 1:nrows - s = vars[i] - print(io, "\n ", s) - - if defs !== nothing - val = get(defs, s, nothing) - if val !== nothing - print(io, " [defaults to $val]") - end - end - end - limited && print(io, "\n⋮") - println(io) - - vars = parameters(sys); nvars = length(vars) - Base.printstyled(io, "Parameters ($nvars):"; bold=true) - nrows = min(nvars, limit ? rows : nvars) - limited = nrows < length(vars) - for i in 1:nrows - s = vars[i] - print(io, "\n ", s) - - if defs !== nothing - val = get(defs, s, nothing) - if val !== nothing - print(io, " [defaults to $val]") - end - end - end - limited && print(io, "\n⋮") - - if has_structure(sys) - s = get_structure(sys) - if s !== nothing - Base.printstyled(io, "\nIncidence matrix:"; color=:magenta) - show(io, incidence_matrix(s.graph, Num(Sym{Real}(:×)))) - end - end - return nothing -end - -function _named(expr) - if !(expr isa Expr && expr.head === :(=) && expr.args[2].head === :call) - throw(ArgumentError("expression should be of the form `sys = foo(a, b)`")) - end - name, call = expr.args - - has_kw = false - if length(call.args) >= 2 && call.args[2] isa Expr - # canonicalize to use `:parameters` - if call.args[2].head === :kw - call.args[2] = Expr(:parameters, Expr(:kw, call.args[2].args...)) - has_kw = true - elseif call.args[2].head === :parameters - has_kw = true - end - end - - if !has_kw - param = Expr(:parameters) - if length(call.args) == 1 - push!(call.args, param) - else - insert!(call.args, 2, param) - end - end - - kws = call.args[2].args - - if !any(kw->(kw isa Symbol ? kw : kw.args[1]) == :name, kws) # don't overwrite `name` kwarg - pushfirst!(kws, Expr(:kw, :name, Meta.quot(name))) - end - :($name = $call) -end - -""" -$(SIGNATURES) - -Rewrite `@named y = foo(x)` to `y = foo(x; name=:y)`. -""" -macro named(expr) - esc(_named(expr)) -end - -function _nonamespace(expr) - if Meta.isexpr(expr, :.) - return :($getproperty($(map(_nonamespace, expr.args)...); namespace=false)) - elseif expr isa Expr && !isempty(expr.args) - return Expr(expr.head, map(_nonamespace, expr.args)...) - else - expr - end -end - -""" -$(SIGNATURES) - -Rewrite `@nonamespace a.b.c` to -`getproperty(getproperty(a, :b; namespace = false), :c; namespace = false)`. -""" -macro nonamespace(expr) - esc(_nonamespace(expr)) -end - -""" -$(SIGNATURES) - -Structurally simplify algebraic equations in a system and compute the -topological sort of the observed equations. -""" -function structural_simplify(sys::AbstractSystem) - sys = initialize_system_structure(alias_elimination(sys)) - check_consistency(structure(sys)) - if sys isa ODESystem - sys = dae_index_lowering(sys) - end - sys = tearing(sys) - fullstates = [map(eq->eq.lhs, observed(sys)); states(sys)] - @set! sys.observed = topsort_equations(observed(sys), fullstates) - return sys -end - -@latexrecipe function f(sys::AbstractSystem) - return latexify(equations(sys)) -end - -Base.show(io::IO, ::MIME"text/latex", x::AbstractSystem) = print(io, latexify(x)) - -struct InvalidSystemException <: Exception - msg::String -end -Base.showerror(io::IO, e::InvalidSystemException) = print(io, "InvalidSystemException: ", e.msg) - -AbstractTrees.children(sys::ModelingToolkit.AbstractSystem) = ModelingToolkit.get_systems(sys) -AbstractTrees.printnode(io::IO, sys::ModelingToolkit.AbstractSystem) = print(io, nameof(sys)) -AbstractTrees.nodetype(::ModelingToolkit.AbstractSystem) = ModelingToolkit.AbstractSystem - -function check_eqs_u0(eqs, dvs, u0) - if u0 !== nothing - if !(length(eqs) == length(dvs) == length(u0)) - throw(ArgumentError("Equations ($(length(eqs))), states ($(length(dvs))), and initial conditions ($(length(u0))) are of different lengths.")) - end - else - if !(length(eqs) == length(dvs)) - throw(ArgumentError("Equations ($(length(eqs))), states ($(length(dvs))) are of different lengths.")) - end - end - return nothing -end - -### -### Connectors -### - -function with_connection_type(expr) - @assert expr isa Expr && (expr.head == :function || (expr.head == :(=) && - expr.args[1] isa Expr && - expr.args[1].head == :call)) - - sig = expr.args[1] - body = expr.args[2] - - fname = sig.args[1] - args = sig.args[2:end] - - quote - struct $fname - $(gensym()) -> 1 # this removes the default constructor - end - function $fname($(args...)) - function f() - $body - end - res = f() - $isdefined(res, :connection_type) ? $Setfield.@set!(res.connection_type = $fname) : res - end - end -end - -macro connector(expr) - esc(with_connection_type(expr)) -end - -promote_connect_rule(::Type{T}, ::Type{S}) where {T, S} = Union{} -promote_connect_rule(::Type{T}, ::Type{T}) where {T} = T -promote_connect_type(t1::Type, t2::Type, ts::Type...) = promote_connect_rule(promote_connect_rule(t1, t2), ts...) -@inline function promote_connect_type(::Type{T}, ::Type{S}) where {T,S} - promote_connect_result( - T, - S, - promote_connect_rule(T,S), - promote_connect_rule(S,T) - ) -end - -promote_connect_result(::Type, ::Type, ::Type{T}, ::Type{Union{}}) where {T} = T -promote_connect_result(::Type, ::Type, ::Type{Union{}}, ::Type{S}) where {S} = S -promote_connect_result(::Type, ::Type, ::Type{T}, ::Type{T}) where {T} = T -function promote_connect_result(::Type{T}, ::Type{S}, ::Type{P1}, ::Type{P2}) where {T,S,P1,P2} - throw(ArgumentError("connection promotion for $T and $S resulted in $P1 and $P2. " * - "Define promotion only in one direction.")) -end - -throw_connector_promotion(T, S) = throw(ArgumentError("Don't know how to connect systems of type $S and $T")) -promote_connect_result(::Type{T},::Type{S},::Type{Union{}},::Type{Union{}}) where {T,S} = throw_connector_promotion(T,S) - -promote_connect_type(::Type{T}, ::Type{T}) where {T} = T -function promote_connect_type(T, S) - error("Don't know how to connect systems of type $S and $T") -end - -function connect(syss...) - connect(promote_connect_type(map(get_connection_type, syss)...), syss...) -end +const SYSTEM_COUNT = Threads.Atomic{UInt}(0) + +get_component_type(x::AbstractSystem) = get_gui_metadata(x).type +struct GUIMetadata + type::GlobalRef + layout::Any +end + +GUIMetadata(type) = GUIMetadata(type, nothing) + +""" +```julia +calculate_tgrad(sys::AbstractTimeDependentSystem) +``` + +Calculate the time gradient of a system. + +Returns a vector of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_tgrad end + +""" +```julia +calculate_gradient(sys::AbstractSystem) +``` + +Calculate the gradient of a scalar system. + +Returns a vector of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_gradient end + +""" +```julia +calculate_jacobian(sys::AbstractSystem) +``` + +Calculate the Jacobian matrix of a system. + +Returns a matrix of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_jacobian end + +""" +```julia +calculate_control_jacobian(sys::AbstractSystem) +``` + +Calculate the Jacobian matrix of a system with respect to the system's controls. + +Returns a matrix of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_control_jacobian end + +""" +```julia +calculate_factorized_W(sys::AbstractSystem) +``` + +Calculate the factorized W-matrix of a system. + +Returns a matrix of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_factorized_W end + +""" +```julia +calculate_hessian(sys::AbstractSystem) +``` + +Calculate the hessian matrix of a scalar system. + +Returns a matrix of [`Num`](@ref) instances. The result from the first +call will be cached in the system object. +""" +function calculate_hessian end + +""" +```julia +generate_tgrad(sys::AbstractTimeDependentSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; kwargs...) +``` + +Generates a function for the time gradient of a system. Extra arguments control +the arguments to the internal [`build_function`](@ref) call. +""" +function generate_tgrad end + +""" +```julia +generate_gradient(sys::AbstractSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; kwargs...) +``` + +Generates a function for the gradient of a system. Extra arguments control +the arguments to the internal [`build_function`](@ref) call. +""" +function generate_gradient end + +""" +```julia +generate_jacobian(sys::AbstractSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; sparse = false, kwargs...) +``` + +Generates a function for the Jacobian matrix of a system. Extra arguments control +the arguments to the internal [`build_function`](@ref) call. +""" +function generate_jacobian end + +""" +```julia +generate_factorized_W(sys::AbstractSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; sparse = false, kwargs...) +``` + +Generates a function for the factorized W matrix of a system. Extra arguments control +the arguments to the internal [`build_function`](@ref) call. +""" +function generate_factorized_W end + +""" +```julia +generate_hessian(sys::AbstractSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; sparse = false, kwargs...) +``` + +Generates a function for the hessian matrix of a system. Extra arguments control +the arguments to the internal [`build_function`](@ref) call. +""" +function generate_hessian end + +""" +```julia +generate_function(sys::AbstractSystem, dvs = unknowns(sys), ps = parameters(sys), + expression = Val{true}; kwargs...) +``` + +Generate a function to evaluate the system's equations. +""" +function generate_function end + +""" +```julia +generate_custom_function(sys::AbstractSystem, exprs, dvs = unknowns(sys), + ps = parameters(sys); kwargs...) +``` + +Generate a function to evaluate `exprs`. `exprs` is a symbolic expression or +array of symbolic expression involving symbolic variables in `sys`. The symbolic variables +may be subsetted using `dvs` and `ps`. All `kwargs` are passed to the internal +[`build_function`](@ref) call. The returned function can be called as `f(u, p, t)` or +`f(du, u, p, t)` for time-dependent systems and `f(u, p)` or `f(du, u, p)` for +time-independent systems. If `split=true` (the default) was passed to [`complete`](@ref), +[`structural_simplify`](@ref) or [`@mtkbuild`](@ref), `p` is expected to be an `MTKParameters` +object. +""" +function generate_custom_function(sys::AbstractSystem, exprs, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + expression = Val{true}, eval_expression = false, eval_module = @__MODULE__, + cachesyms::Tuple = (), kwargs...) + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system.") + end + p = (reorder_parameters(sys, unwrap.(ps))..., cachesyms...) + isscalar = !(exprs isa AbstractArray) + fnexpr = if is_time_dependent(sys) + build_function_wrapper(sys, exprs, + dvs, + p..., + get_iv(sys); + kwargs..., + expression = Val{true} + ) + else + build_function_wrapper(sys, exprs, + dvs, + p...; + kwargs..., + expression = Val{true} + ) + end + if expression == Val{true} + return fnexpr + end + if fnexpr isa Tuple + return eval_or_rgf.(fnexpr; eval_expression, eval_module) + else + return eval_or_rgf(fnexpr; eval_expression, eval_module) + end +end + +function wrap_assignments(isscalar, assignments; let_block = false) + function wrapper(expr) + Func(expr.args, [], Let(assignments, expr.body, let_block)) + end + if isscalar + wrapper + else + wrapper, wrapper + end +end + +const MTKPARAMETERS_ARG = Sym{Vector{Vector}}(:___mtkparameters___) + +mutable struct Substitutions + subs::Vector{Equation} + deps::Vector{Vector{Int}} + subed_eqs::Union{Nothing, Vector{Equation}} +end +Substitutions(subs, deps) = Substitutions(subs, deps, nothing) + +Base.nameof(sys::AbstractSystem) = getfield(sys, :name) +description(sys::AbstractSystem) = has_description(sys) ? get_description(sys) : "" + +#Deprecated +function independent_variable(sys::AbstractSystem) + Base.depwarn( + "`independent_variable` is deprecated. Use `get_iv` or `independent_variables` instead.", + :independent_variable) + isdefined(sys, :iv) ? getfield(sys, :iv) : nothing +end + +function independent_variables(sys::AbstractTimeDependentSystem) + return [getfield(sys, :iv)] +end + +independent_variables(::AbstractTimeIndependentSystem) = [] + +function independent_variables(sys::AbstractMultivariateSystem) + return getfield(sys, :ivs) +end + +""" +$(TYPEDSIGNATURES) + +Get the independent variable(s) of the system `sys`. + +See also [`@independent_variables`](@ref) and [`ModelingToolkit.get_iv`](@ref). +""" +function independent_variables(sys::AbstractSystem) + @warn "Please declare ($(typeof(sys))) as a subtype of `AbstractTimeDependentSystem`, `AbstractTimeIndependentSystem` or `AbstractMultivariateSystem`." + if isdefined(sys, :iv) + return [getfield(sys, :iv)] + elseif isdefined(sys, :ivs) + return getfield(sys, :ivs) + else + return [] + end +end + +#Treat the result as a vector of symbols always +function SymbolicIndexingInterface.is_variable(sys::AbstractSystem, sym) + sym = unwrap(sym) + if sym isa Int # [x, 1] coerces 1 to a Num + return sym in 1:length(variable_symbols(sys)) + end + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return is_variable(ic, sym) || + iscall(sym) && operation(sym) === getindex && + is_variable(ic, first(arguments(sym))) + end + return any(isequal(sym), variable_symbols(sys)) || + hasname(sym) && is_variable(sys, getname(sym)) +end + +function SymbolicIndexingInterface.is_variable(sys::AbstractSystem, sym::Symbol) + sym = unwrap(sym) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return is_variable(ic, sym) + end + return any(isequal(sym), getname.(variable_symbols(sys))) || + count(NAMESPACE_SEPARATOR, string(sym)) == 1 && + count(isequal(sym), + Symbol.(nameof(sys), NAMESPACE_SEPARATOR_SYMBOL, getname.(variable_symbols(sys)))) == + 1 +end + +function SymbolicIndexingInterface.variable_index(sys::AbstractSystem, sym) + sym = unwrap(sym) + if sym isa Int + return sym + end + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return if (idx = variable_index(ic, sym)) !== nothing + idx + elseif iscall(sym) && operation(sym) === getindex && + (idx = variable_index(ic, first(arguments(sym)))) !== nothing + idx[arguments(sym)[(begin + 1):end]...] + else + nothing + end + end + idx = findfirst(isequal(sym), variable_symbols(sys)) + if idx === nothing && hasname(sym) + idx = variable_index(sys, getname(sym)) + end + return idx +end + +function SymbolicIndexingInterface.variable_index(sys::AbstractSystem, sym::Symbol) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return variable_index(ic, sym) + end + idx = findfirst(isequal(sym), getname.(variable_symbols(sys))) + if idx !== nothing + return idx + elseif count(NAMESPACE_SEPARATOR, string(sym)) == 1 + return findfirst(isequal(sym), + Symbol.( + nameof(sys), NAMESPACE_SEPARATOR_SYMBOL, getname.(variable_symbols(sys)))) + end + return nothing +end + +SymbolicIndexingInterface.variable_symbols(sys::AbstractMultivariateSystem) = sys.dvs + +function SymbolicIndexingInterface.variable_symbols(sys::AbstractSystem) + return solved_unknowns(sys) +end + +function SymbolicIndexingInterface.is_parameter(sys::AbstractSystem, sym) + sym = unwrap(sym) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return sym isa ParameterIndex || is_parameter(ic, sym) || + iscall(sym) && + operation(sym) === getindex && + is_parameter(ic, first(arguments(sym))) + end + if unwrap(sym) isa Int + return unwrap(sym) in 1:length(parameter_symbols(sys)) + end + return any(isequal(sym), parameter_symbols(sys)) || + hasname(sym) && !(iscall(sym) && operation(sym) == getindex) && + is_parameter(sys, getname(sym)) +end + +function SymbolicIndexingInterface.is_parameter(sys::AbstractSystem, sym::Symbol) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return is_parameter(ic, sym) + end + + named_parameters = [getname(x) + for x in parameter_symbols(sys) + if hasname(x) && !(iscall(x) && operation(x) == getindex)] + return any(isequal(sym), named_parameters) || + count(NAMESPACE_SEPARATOR, string(sym)) == 1 && + count(isequal(sym), + Symbol.(nameof(sys), NAMESPACE_SEPARATOR_SYMBOL, named_parameters)) == 1 +end + +function SymbolicIndexingInterface.parameter_index(sys::AbstractSystem, sym) + sym = unwrap(sym) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return if sym isa ParameterIndex + sym + elseif (idx = parameter_index(ic, sym)) !== nothing + idx + elseif iscall(sym) && operation(sym) === getindex && + (idx = parameter_index(ic, first(arguments(sym)))) !== nothing + if idx.portion isa SciMLStructures.Tunable + return ParameterIndex( + idx.portion, idx.idx[arguments(sym)[(begin + 1):end]...]) + else + return ParameterIndex( + idx.portion, (idx.idx..., arguments(sym)[(begin + 1):end]...)) + end + else + nothing + end + end + + if sym isa Int + return sym + end + idx = findfirst(isequal(sym), parameter_symbols(sys)) + if idx === nothing && hasname(sym) && !(iscall(sym) && operation(sym) == getindex) + idx = parameter_index(sys, getname(sym)) + end + return idx +end + +function SymbolicIndexingInterface.parameter_index(sys::AbstractSystem, sym::Symbol) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + idx = parameter_index(ic, sym) + if idx === nothing || + idx.portion isa SciMLStructures.Discrete && idx.idx[2] == idx.idx[3] == 0 + return nothing + else + return idx + end + end + pnames = [getname(x) + for x in parameter_symbols(sys) + if hasname(x) && !(iscall(x) && operation(x) == getindex)] + idx = findfirst(isequal(sym), pnames) + if idx !== nothing + return idx + elseif count(NAMESPACE_SEPARATOR, string(sym)) == 1 + return findfirst(isequal(sym), + Symbol.( + nameof(sys), NAMESPACE_SEPARATOR_SYMBOL, pnames)) + end + return nothing +end + +function SymbolicIndexingInterface.is_timeseries_parameter(sys::AbstractSystem, sym) + is_time_dependent(sys) || return false + has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing || return false + is_timeseries_parameter(ic, sym) +end + +function SymbolicIndexingInterface.timeseries_parameter_index(sys::AbstractSystem, sym) + is_time_dependent(sys) || return nothing + has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing || return nothing + timeseries_parameter_index(ic, sym) +end + +function SymbolicIndexingInterface.parameter_observed(sys::AbstractSystem, sym) + return build_explicit_observed_function(sys, sym; param_only = true) +end + +function has_observed_with_lhs(sys, sym) + has_observed(sys) || return false + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return haskey(ic.observed_syms_to_timeseries, sym) + else + return any(isequal(sym), [eq.lhs for eq in observed(sys)]) + end +end + +function has_parameter_dependency_with_lhs(sys, sym) + has_parameter_dependencies(sys) || return false + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return haskey(ic.dependent_pars_to_timeseries, unwrap(sym)) + else + return any(isequal(sym), [eq.lhs for eq in parameter_dependencies(sys)]) + end +end + +function _all_ts_idxs!(ts_idxs, ::NotSymbolic, sys, sym) + if is_variable(sys, sym) || is_independent_variable(sys, sym) + push!(ts_idxs, ContinuousTimeseries()) + elseif is_timeseries_parameter(sys, sym) + push!(ts_idxs, timeseries_parameter_index(sys, sym).timeseries_idx) + end +end +# Need this to avoid ambiguity with the array case +for traitT in [ + ScalarSymbolic, + ArraySymbolic +] + @eval function _all_ts_idxs!(ts_idxs, ::$traitT, sys, sym) + allsyms = vars(sym; op = Symbolics.Operator) + for s in allsyms + s = unwrap(s) + if is_variable(sys, s) || is_independent_variable(sys, s) + push!(ts_idxs, ContinuousTimeseries()) + elseif is_timeseries_parameter(sys, s) + push!(ts_idxs, timeseries_parameter_index(sys, s).timeseries_idx) + elseif is_time_dependent(sys) && iscall(s) && issym(operation(s)) && + is_variable(sys, operation(s)(get_iv(sys))) + # DDEs case, to detect x(t - k) + push!(ts_idxs, ContinuousTimeseries()) + else + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + if (ts = get(ic.observed_syms_to_timeseries, s, nothing)) !== nothing + union!(ts_idxs, ts) + elseif (ts = get(ic.dependent_pars_to_timeseries, s, nothing)) !== + nothing + union!(ts_idxs, ts) + end + else + # for split=false systems + if has_observed_with_lhs(sys, sym) + push!(ts_idxs, ContinuousTimeseries()) + end + end + end + end + end +end +function _all_ts_idxs!(ts_idxs, ::ScalarSymbolic, sys, sym::Symbol) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return _all_ts_idxs!(ts_idxs, sys, ic.symbol_to_variable[sym]) + elseif is_variable(sys, sym) || is_independent_variable(sys, sym) || + any(isequal(sym), [getname(eq.lhs) for eq in observed(sys)]) + push!(ts_idxs, ContinuousTimeseries()) + elseif is_timeseries_parameter(sys, sym) + push!(ts_idxs, timeseries_parameter_index(sys, s).timeseries_idx) + end +end +function _all_ts_idxs!(ts_idxs, ::NotSymbolic, sys, sym::AbstractArray) + for s in sym + _all_ts_idxs!(ts_idxs, sys, s) + end +end +_all_ts_idxs!(ts_idxs, sys, sym) = _all_ts_idxs!(ts_idxs, symbolic_type(sym), sys, sym) + +function SymbolicIndexingInterface.get_all_timeseries_indexes(sys::AbstractSystem, sym) + if !is_time_dependent(sys) + return Set() + end + ts_idxs = Set() + _all_ts_idxs!(ts_idxs, sys, sym) + return ts_idxs +end + +function SymbolicIndexingInterface.parameter_symbols(sys::AbstractSystem) + return parameters(sys; initial_parameters = true) +end + +function SymbolicIndexingInterface.is_independent_variable(sys::AbstractSystem, sym) + return any(isequal(sym), independent_variable_symbols(sys)) +end + +function SymbolicIndexingInterface.is_independent_variable(sys::AbstractSystem, sym::Symbol) + return any(isequal(sym), getname.(independent_variables(sys))) +end + +function SymbolicIndexingInterface.independent_variable_symbols(sys::AbstractSystem) + return independent_variables(sys) +end + +function SymbolicIndexingInterface.is_observed(sys::AbstractSystem, sym) + return !is_variable(sys, sym) && parameter_index(sys, sym) === nothing && + !is_independent_variable(sys, sym) && symbolic_type(sym) != NotSymbolic() +end + +SymbolicIndexingInterface.supports_tuple_observed(::AbstractSystem) = true + +function SymbolicIndexingInterface.observed( + sys::AbstractSystem, sym; eval_expression = false, eval_module = @__MODULE__, + checkbounds = true, cse = true) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + if sym isa Symbol + _sym = get(ic.symbol_to_variable, sym, nothing) + if _sym === nothing + throw(ArgumentError("Symbol $sym does not exist in the system")) + end + sym = _sym + elseif (sym isa Tuple || + (sym isa AbstractArray && symbolic_type(sym) isa NotSymbolic)) && + any(x -> x isa Symbol, sym) + sym = map(sym) do s + if s isa Symbol + _s = get(ic.symbol_to_variable, s, nothing) + if _s === nothing + throw(ArgumentError("Symbol $s does not exist in the system")) + end + return _s + end + return unwrap(s) + end + end + end + return build_explicit_observed_function( + sys, sym; eval_expression, eval_module, checkbounds, cse) +end + +function SymbolicIndexingInterface.default_values(sys::AbstractSystem) + return merge( + Dict(eq.lhs => eq.rhs for eq in observed(sys)), + defaults(sys) + ) +end + +SymbolicIndexingInterface.is_time_dependent(::AbstractTimeDependentSystem) = true +SymbolicIndexingInterface.is_time_dependent(::AbstractTimeIndependentSystem) = false + +SymbolicIndexingInterface.is_markovian(sys::AbstractSystem) = !is_dde(sys) + +SymbolicIndexingInterface.constant_structure(::AbstractSystem) = true + +function SymbolicIndexingInterface.all_variable_symbols(sys::AbstractSystem) + syms = variable_symbols(sys) + obs = getproperty.(observed(sys), :lhs) + return isempty(obs) ? syms : vcat(syms, obs) +end + +function SymbolicIndexingInterface.all_symbols(sys::AbstractSystem) + syms = all_variable_symbols(sys) + for other in (full_parameters(sys), independent_variable_symbols(sys)) + isempty(other) || (syms = vcat(syms, other)) + end + return syms +end + +""" + $(TYPEDSIGNATURES) + +Check whether a system is marked as `complete`. +""" +iscomplete(sys::AbstractSystem) = isdefined(sys, :complete) && getfield(sys, :complete) +""" + $(TYPEDSIGNATURES) + +Check whether a system performs namespacing. +""" +function does_namespacing(sys::AbstractSystem) + if isdefined(sys, :namespacing) + getfield(sys, :namespacing) + else + !iscomplete(sys) + end +end + +""" +$(TYPEDSIGNATURES) + +Mark a system as scheduled. It is only intended in compiler internals. A system +is scheduled after tearing based simplifications where equations are converted +into assignments. +""" +function schedule(sys::AbstractSystem) + has_schedule(sys) ? sys : (@set! sys.isscheduled = true) +end + +""" +$(TYPEDSIGNATURES) + +If a system is scheduled, then changing its equations, variables, and +parameters is no longer legal. +""" +function isscheduled(sys::AbstractSystem) + if has_schedule(sys) + get_schedule(sys) !== nothing + elseif has_isscheduled(sys) + get_isscheduled(sys) + else + false + end +end + +""" + Initial(x) + +The `Initial` operator. Used by initialization to store constant constraints on variables +of a system. See the documentation section on initialization for more information. +""" +struct Initial <: Symbolics.Operator end +Initial(x) = Initial()(x) +SymbolicUtils.promote_symtype(::Type{Initial}, T) = T +SymbolicUtils.isbinop(::Initial) = false +Base.nameof(::Initial) = :Initial +Base.show(io::IO, x::Initial) = print(io, "Initial") +input_timedomain(::Initial, _ = nothing) = ContinuousClock() +output_timedomain(::Initial, _ = nothing) = ContinuousClock() + +function (f::Initial)(x) + # wrap output if wrapped input + iw = Symbolics.iswrapped(x) + x = unwrap(x) + # non-symbolic values don't change + if symbolic_type(x) == NotSymbolic() + return x + end + # differential variables are default-toterm-ed + if iscall(x) && operation(x) isa Differential + x = default_toterm(x) + end + # don't double wrap + iscall(x) && operation(x) isa Initial && return x + result = if symbolic_type(x) == ArraySymbolic() + # create an array for `Initial(array)` + Symbolics.array_term(f, toparam(x)) + elseif iscall(x) && operation(x) == getindex + # instead of `Initial(x[1])` create `Initial(x)[1]` + # which allows parameter indexing to handle this case automatically. + arr = arguments(x)[1] + term(getindex, f(toparam(arr)), arguments(x)[2:end]...) + else + term(f, toparam(x)) + end + # the result should be a parameter + result = toparam(result) + if iw + result = wrap(result) + end + return result +end + +# This is required so `fast_substitute` works +function SymbolicUtils.maketerm(::Type{<:BasicSymbolic}, ::Initial, args, meta) + val = Initial()(args...) + if symbolic_type(val) == NotSymbolic() + return val + end + return metadata(val, meta) +end + +supports_initialization(sys::AbstractSystem) = true + +function add_initialization_parameters(sys::AbstractSystem; split = true) + @assert !has_systems(sys) || isempty(get_systems(sys)) + supports_initialization(sys) || return sys + is_initializesystem(sys) && return sys + + all_initialvars = Set{BasicSymbolic}() + # time-independent systems don't initialize unknowns + # but may initialize parameters using guesses for unknowns + eqs = equations(sys) + if !(eqs isa Vector{Equation}) + eqs = Equation[x for x in eqs if x isa Equation] + end + obs, eqs = unhack_observed(observed(sys), eqs) + for x in Iterators.flatten((unknowns(sys), Iterators.map(eq -> eq.lhs, obs))) + x = unwrap(x) + if iscall(x) && operation(x) == getindex && split + push!(all_initialvars, arguments(x)[1]) + else + push!(all_initialvars, x) + end + end + + # add derivatives of all variables for steady-state initial conditions + if is_time_dependent(sys) && !(sys isa AbstractDiscreteSystem) + D = Differential(get_iv(sys)) + union!(all_initialvars, [D(v) for v in all_initialvars if iscall(v)]) + end + for eq in parameter_dependencies(sys) + is_variable_floatingpoint(eq.lhs) || continue + push!(all_initialvars, eq.lhs) + end + all_initialvars = collect(all_initialvars) + initials = map(Initial(), all_initialvars) + @set! sys.ps = unique!([get_ps(sys); initials]) + defs = copy(get_defaults(sys)) + for ivar in initials + if symbolic_type(ivar) == ScalarSymbolic() + defs[ivar] = false + else + defs[ivar] = collect(ivar) + for scal_ivar in defs[ivar] + defs[scal_ivar] = false + end + end + end + @set! sys.defaults = defs + return sys +end + +""" +Returns true if the parameter `p` is of the form `Initial(x)`. +""" +function isinitial(p) + p = unwrap(p) + return iscall(p) && (operation(p) isa Initial || + operation(p) === getindex && isinitial(arguments(p)[1])) +end + +""" +$(TYPEDSIGNATURES) + +Mark a system as completed. A completed system is a system which is done being +defined/modified and is ready for structural analysis or other transformations. +This allows for analyses and optimizations to be performed which require knowing +the global structure of the system. + +One property to note is that if a system is complete, the system will no longer +namespace its subsystems or variables, i.e. `isequal(complete(sys).v.i, v.i)`. +""" +function complete( + sys::AbstractSystem; split = true, flatten = true, add_initial_parameters = true) + newunknowns = OrderedSet() + newparams = OrderedSet() + iv = has_iv(sys) ? get_iv(sys) : nothing + collect_scoped_vars!(newunknowns, newparams, sys, iv; depth = -1) + # don't update unknowns to not disturb `structural_simplify` order + # `GlobalScope`d unknowns will be picked up and added there + @set! sys.ps = unique!(vcat(get_ps(sys), collect(newparams))) + + if flatten + eqs = equations(sys) + if eqs isa AbstractArray && eltype(eqs) <: Equation + newsys = expand_connections(sys) + else + newsys = sys + end + newsys = ModelingToolkit.flatten(newsys) + if has_parent(newsys) && get_parent(sys) === nothing + @set! newsys.parent = complete(sys; split = false, flatten = false) + end + sys = newsys + if add_initial_parameters + sys = add_initialization_parameters(sys; split) + end + end + if split && has_index_cache(sys) + @set! sys.index_cache = IndexCache(sys) + # Ideally we'd do `get_ps` but if `flatten = false` + # we don't get all of them. So we call `parameters`. + all_ps = parameters(sys; initial_parameters = true) + if !isempty(all_ps) + # reorder parameters by portions + ps_split = reorder_parameters(sys, all_ps) + # if there are tunables, they will all be in `ps_split[1]` + # and the arrays will have been scalarized + ordered_ps = eltype(all_ps)[] + # if there are no tunables, vcat them + if !isempty(get_index_cache(sys).tunable_idx) + unflatten_parameters!(ordered_ps, ps_split[1], all_ps) + ps_split = Base.tail(ps_split) + end + # unflatten initial parameters + if !isempty(get_index_cache(sys).initials_idx) + unflatten_parameters!(ordered_ps, ps_split[1], all_ps) + ps_split = Base.tail(ps_split) + end + ordered_ps = vcat( + ordered_ps, reduce(vcat, ps_split; init = eltype(ordered_ps)[])) + @set! sys.ps = ordered_ps + end + elseif has_index_cache(sys) + @set! sys.index_cache = nothing + end + if isdefined(sys, :initializesystem) && get_initializesystem(sys) !== nothing + @set! sys.initializesystem = complete(get_initializesystem(sys); split) + end + sys = toggle_namespacing(sys, false; safe = true) + isdefined(sys, :complete) ? (@set! sys.complete = true) : sys +end + +""" + $(TYPEDSIGNATURES) + +Return a new `sys` with namespacing enabled or disabled, depending on `value`. The +keyword argument `safe` denotes whether systems that do not support such a toggle +should error or be ignored. +""" +function toggle_namespacing(sys::AbstractSystem, value::Bool; safe = false) + if !isdefined(sys, :namespacing) + safe && return sys + throw(ArgumentError("The system must define the `namespacing` flag to toggle namespacing")) + end + @set sys.namespacing = value +end + +""" + $(TYPEDSIGNATURES) + +Given a flattened array of parameters `params` and a collection of all (unscalarized) +parameters in the system `all_ps`, unscalarize the elements in `params` and append +to `buffer` in the same order as they are present in `params`. Effectively, if +`params = [p[1], p[2], p[3], q]` then this is equivalent to `push!(buffer, p, q)`. +""" +function unflatten_parameters!(buffer, params, all_ps) + i = 1 + # go through all the tunables + while i <= length(params) + sym = params[i] + # if the sym is not a scalarized array symbolic OR it was already scalarized, + # just push it as-is + if !iscall(sym) || operation(sym) != getindex || + any(isequal(sym), all_ps) + push!(buffer, sym) + i += 1 + continue + end + # the next `length(sym)` symbols should be scalarized versions of the same + # array symbolic + if !allequal(first(arguments(x)) + for x in view(params, i:(i + length(sym) - 1))) + error("This should not be possible. Please open an issue in ModelingToolkit.jl with an MWE and stacktrace.") + end + arrsym = first(arguments(sym)) + push!(buffer, arrsym) + i += length(arrsym) + end +end + +for prop in [:eqs + :tag + :noiseeqs + :iv + :unknowns + :ps + :tspan + :name + :description + :var_to_name + :ctrls + :defaults + :guesses + :observed + :tgrad + :jac + :ctrl_jac + :Wfact + :Wfact_t + :systems + :structure + :op + :constraints + :constraintsystem + :controls + :loss + :bcs + :domain + :ivs + :dvs + :connector_type + :connections + :preface + :torn_matching + :initializesystem + :initialization_eqs + :schedule + :tearing_state + :substitutions + :metadata + :gui_metadata + :discrete_subsystems + :parameter_dependencies + :assertions + :solved_unknowns + :split_idxs + :ignored_connections + :parent + :is_dde + :tstops + :index_cache + :is_scalar_noise + :isscheduled + :costs + :consolidate] + fname_get = Symbol(:get_, prop) + fname_has = Symbol(:has_, prop) + @eval begin + """ + $(TYPEDSIGNATURES) + + Get the internal field `$($(QuoteNode(prop)))` of a system `sys`. + It only includes `$($(QuoteNode(prop)))` local to `sys`; not those of its subsystems, + like `unknowns(sys)`, `parameters(sys)` and `equations(sys)` does. + This is equivalent to, but preferred over `sys.$($(QuoteNode(prop)))`. + + See also [`has_$($(QuoteNode(prop)))`](@ref). + """ + $fname_get(sys::AbstractSystem) = getfield(sys, $(QuoteNode(prop))) + + """ + $(TYPEDSIGNATURES) + + Returns whether the system `sys` has the internal field `$($(QuoteNode(prop)))`. + + See also [`get_$($(QuoteNode(prop)))`](@ref). + """ + $fname_has(sys::AbstractSystem) = isdefined(sys, $(QuoteNode(prop))) + end +end + +has_equations(::AbstractSystem) = true + +const EMPTY_TGRAD = Vector{Num}(undef, 0) +const EMPTY_JAC = Matrix{Num}(undef, 0, 0) +function invalidate_cache!(sys::AbstractSystem) + if has_tgrad(sys) + get_tgrad(sys)[] = EMPTY_TGRAD + end + if has_jac(sys) + get_jac(sys)[] = EMPTY_JAC + end + if has_ctrl_jac(sys) + get_ctrl_jac(sys)[] = EMPTY_JAC + end + if has_Wfact(sys) + get_Wfact(sys)[] = EMPTY_JAC + end + if has_Wfact_t(sys) + get_Wfact_t(sys)[] = EMPTY_JAC + end + return sys +end + +function Setfield.get(obj::AbstractSystem, ::Setfield.PropertyLens{field}) where {field} + getfield(obj, field) +end +@generated function ConstructionBase.setproperties(obj::AbstractSystem, patch::NamedTuple) + if issubset(fieldnames(patch), fieldnames(obj)) + args = map(fieldnames(obj)) do fn + if fn in fieldnames(patch) + :(patch.$fn) + else + :(getfield(obj, $(Meta.quot(fn)))) + end + end + kwarg = :($(Expr(:kw, :checks, false))) # Inputs should already be checked + return Expr(:block, + Expr(:meta, :inline), + Expr(:call, :(constructorof($obj)), args..., kwarg)) + else + error("This should never happen. Trying to set $(typeof(obj)) with $patch.") + end +end + +rename(x, name) = @set x.name = name + +function Base.propertynames(sys::AbstractSystem; private = false) + if private + return fieldnames(typeof(sys)) + else + if has_parent(sys) && (parent = get_parent(sys); parent !== nothing) + return propertynames(parent; private) + end + names = Symbol[] + for s in get_systems(sys) + push!(names, getname(s)) + end + has_unknowns(sys) && for s in get_unknowns(sys) + push!(names, getname(s)) + end + has_ps(sys) && for s in get_ps(sys) + hasname(s) || continue + push!(names, getname(s)) + end + has_observed(sys) && for s in get_observed(sys) + push!(names, getname(s.lhs)) + end + has_iv(sys) && push!(names, getname(get_iv(sys))) + return names + end +end + +function Base.getproperty( + sys::AbstractSystem, name::Symbol; namespace = does_namespacing(sys)) + if has_parent(sys) && (parent = get_parent(sys); parent !== nothing) + return getproperty(parent, name; namespace) + end + wrap(getvar(sys, name; namespace = namespace)) +end +function getvar(sys::AbstractSystem, name::Symbol; namespace = does_namespacing(sys)) + systems = get_systems(sys) + if isdefined(sys, name) + Base.depwarn( + "`sys.name` like `sys.$name` is deprecated. Use getters like `get_$name` instead.", + "sys.$name") + return getfield(sys, name) + elseif !isempty(systems) + i = findfirst(x -> nameof(x) == name, systems) + if i !== nothing + return namespace ? renamespace(sys, systems[i]) : systems[i] + end + end + + if has_var_to_name(sys) + avs = get_var_to_name(sys) + v = get(avs, name, nothing) + v === nothing || return namespace ? renamespace(sys, v) : v + end + + sts = get_unknowns(sys) + i = findfirst(x -> getname(x) == name, sts) + if i !== nothing + return namespace ? renamespace(sys, sts[i]) : sts[i] + end + + if has_ps(sys) + ps = get_ps(sys) + i = findfirst(x -> getname(x) == name, ps) + if i !== nothing + return namespace ? renamespace(sys, ps[i]) : ps[i] + end + end + + if has_observed(sys) + obs = get_observed(sys) + i = findfirst(x -> getname(x.lhs) == name, obs) + if i !== nothing + return namespace ? renamespace(sys, obs[i].lhs) : obs[i].lhs + end + end + + if has_iv(sys) + iv = get_iv(sys) + if getname(iv) == name + return iv + end + end + + if has_eqs(sys) + for eq in get_eqs(sys) + eq isa Equation || continue + if eq.lhs isa AnalysisPoint && nameof(eq.rhs) == name + return namespace ? renamespace(sys, eq.rhs) : eq.rhs + end + end + end + + throw(ArgumentError("System $(nameof(sys)): variable $name does not exist")) +end + +function Base.setproperty!(sys::AbstractSystem, prop::Symbol, val) + # We use this weird syntax because `parameters` and `unknowns` calls are + # potentially expensive. + if (params = parameters(sys); + idx = findfirst(s -> getname(s) == prop, params); + idx !== nothing) + get_defaults(sys)[params[idx]] = value(val) + elseif (sts = unknowns(sys); + idx = findfirst(s -> getname(s) == prop, sts); + idx !== nothing) + get_defaults(sys)[sts[idx]] = value(val) + else + setfield!(sys, prop, val) + end +end + +apply_to_variables(f::F, ex) where {F} = _apply_to_variables(f, ex) +apply_to_variables(f::F, ex::Num) where {F} = wrap(_apply_to_variables(f, unwrap(ex))) +function _apply_to_variables(f::F, ex) where {F} + if isvariable(ex) + return f(ex) + end + iscall(ex) || return ex + maketerm(typeof(ex), _apply_to_variables(f, operation(ex)), + map(Base.Fix1(_apply_to_variables, f), arguments(ex)), + metadata(ex)) +end + +""" +Variable metadata key which contains information about scoping/namespacing of the +variable in a hierarchical system. +""" +abstract type SymScope end + +""" + $(TYPEDEF) + +The default scope of a variable. It belongs to the system whose equations it is involved +in and is namespaced by every level of the hierarchy. +""" +struct LocalScope <: SymScope end + +""" + $(TYPEDSIGNATURES) + +Apply `LocalScope` to `sym`. +""" +function LocalScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) + apply_to_variables(sym) do sym + if iscall(sym) && operation(sym) === getindex + args = arguments(sym) + a1 = setmetadata(args[1], SymScope, LocalScope()) + maketerm(typeof(sym), operation(sym), [a1, args[2:end]...], + metadata(sym)) + else + setmetadata(sym, SymScope, LocalScope()) + end + end +end + +""" + $(TYPEDEF) + +Denotes that the variable does not belong to the system whose equations it is involved +in. It is not namespaced by this system. In the immediate parent of this system, the +scope of this variable is given by `parent`. + +# Fields + +$(TYPEDFIELDS) +""" +struct ParentScope <: SymScope + parent::SymScope +end +""" + $(TYPEDSIGNATURES) + +Apply `ParentScope` to `sym`, with `parent` being `LocalScope`. +""" +function ParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) + apply_to_variables(sym) do sym + if iscall(sym) && operation(sym) === getindex + args = arguments(sym) + a1 = setmetadata(args[1], SymScope, + ParentScope(getmetadata(value(args[1]), SymScope, LocalScope()))) + maketerm(typeof(sym), operation(sym), [a1, args[2:end]...], + metadata(sym)) + else + setmetadata(sym, SymScope, + ParentScope(getmetadata(value(sym), SymScope, LocalScope()))) + end + end +end + +""" + $(TYPEDEF) + +Denotes that a variable belongs to a system that is at least `N + 1` levels up in the +hierarchy from the system whose equations it is involved in. It is namespaced by the +first `N` parents and not namespaced by the `N+1`th parent in the hierarchy. The scope +of the variable after this point is given by `parent`. + +In other words, this scope delays applying `ParentScope` by `N` levels, and applies +`LocalScope` in the meantime. + +# Fields + +$(TYPEDFIELDS) +""" +struct DelayParentScope <: SymScope + parent::SymScope + N::Int +end + +""" + $(TYPEDSIGNATURES) + +Apply `DelayParentScope` to `sym`, with a delay of `N` and `parent` being `LocalScope`. +""" +function DelayParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}, N) + Base.depwarn( + "`DelayParentScope` is deprecated and will be removed soon", :DelayParentScope) + apply_to_variables(sym) do sym + if iscall(sym) && operation(sym) == getindex + args = arguments(sym) + a1 = setmetadata(args[1], SymScope, + DelayParentScope(getmetadata(value(args[1]), SymScope, LocalScope()), N)) + maketerm(typeof(sym), operation(sym), [a1, args[2:end]...], + metadata(sym)) + else + setmetadata(sym, SymScope, + DelayParentScope(getmetadata(value(sym), SymScope, LocalScope()), N)) + end + end +end + +""" + $(TYPEDSIGNATURES) + +Apply `DelayParentScope` to `sym`, with a delay of `1` and `parent` being `LocalScope`. +""" +DelayParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) = DelayParentScope(sym, 1) + +""" + $(TYPEDEF) + +Denotes that a variable belongs to the root system in the hierarchy, regardless of which +equations of subsystems in the hierarchy it is involved in. Variables with this scope +are never namespaced and only added to the unknowns/parameters of a system when calling +`complete` or `structural_simplify`. +""" +struct GlobalScope <: SymScope end + +""" + $(TYPEDSIGNATURES) + +Apply `GlobalScope` to `sym`. +""" +function GlobalScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) + apply_to_variables(sym) do sym + if iscall(sym) && operation(sym) == getindex + args = arguments(sym) + a1 = setmetadata(args[1], SymScope, GlobalScope()) + maketerm(typeof(sym), operation(sym), [a1, args[2:end]...], + metadata(sym)) + else + setmetadata(sym, SymScope, GlobalScope()) + end + end +end + +renamespace(sys, eq::Equation) = namespace_equation(eq, sys) + +renamespace(names::AbstractVector, x) = foldr(renamespace, names, init = x) +function renamespace(sys, x) + sys === nothing && return x + x = unwrap(x) + if x isa Symbolic + T = typeof(x) + if iscall(x) && operation(x) isa Operator + return maketerm(typeof(x), operation(x), + Any[renamespace(sys, only(arguments(x)))], + metadata(x))::T + end + if iscall(x) && operation(x) === getindex + args = arguments(x) + return maketerm( + typeof(x), operation(x), vcat(renamespace(sys, args[1]), args[2:end]), + metadata(x))::T + end + let scope = getmetadata(x, SymScope, LocalScope()) + if scope isa LocalScope + rename(x, renamespace(getname(sys), getname(x)))::T + elseif scope isa ParentScope + setmetadata(x, SymScope, scope.parent)::T + elseif scope isa DelayParentScope + if scope.N > 0 + x = setmetadata(x, SymScope, + DelayParentScope(scope.parent, scope.N - 1)) + rename(x, renamespace(getname(sys), getname(x)))::T + else + #rename(x, renamespace(getname(sys), getname(x))) + setmetadata(x, SymScope, scope.parent)::T + end + else # GlobalScope + x::T + end + end + elseif x isa AbstractSystem + rename(x, renamespace(sys, nameof(x))) + else + Symbol(getname(sys), NAMESPACE_SEPARATOR_SYMBOL, x) + end +end + +namespace_variables(sys::AbstractSystem) = unknowns(sys, unknowns(sys)) +namespace_parameters(sys::AbstractSystem) = parameters(sys, parameters(sys)) +namespace_controls(sys::AbstractSystem) = controls(sys, controls(sys)) + +function namespace_defaults(sys) + defs = defaults(sys) + Dict((isparameter(k) ? parameters(sys, k) : unknowns(sys, k)) => namespace_expr(v, sys) + for (k, v) in pairs(defs)) +end + +function namespace_guesses(sys) + guess = guesses(sys) + Dict(unknowns(sys, k) => namespace_expr(v, sys) for (k, v) in guess) +end + +function namespace_equations(sys::AbstractSystem, ivs = independent_variables(sys)) + eqs = equations(sys) + isempty(eqs) && return Equation[] + map(eq -> namespace_equation(eq, sys; ivs), eqs) +end + +function namespace_initialization_equations( + sys::AbstractSystem, ivs = independent_variables(sys)) + eqs = initialization_equations(sys) + isempty(eqs) && return Equation[] + map(eq -> namespace_equation(eq, sys; ivs), eqs) +end + +function namespace_tstops(sys::AbstractSystem) + tstops = symbolic_tstops(sys) + isempty(tstops) && return tstops + map(tstops) do val + namespace_expr(val, sys) + end +end + +function namespace_equation(eq::Equation, + sys, + n = nameof(sys); + ivs = independent_variables(sys)) + _lhs = namespace_expr(eq.lhs, sys, n; ivs) + _rhs = namespace_expr(eq.rhs, sys, n; ivs) + (_lhs ~ _rhs)::Equation +end + +function namespace_assignment(eq::Assignment, sys) + _lhs = namespace_expr(eq.lhs, sys) + _rhs = namespace_expr(eq.rhs, sys) + Assignment(_lhs, _rhs) +end + +function is_array_of_symbolics(x) + symbolic_type(x) == ArraySymbolic() && return true + symbolic_type(x) == ScalarSymbolic() && return false + x isa AbstractArray && + any(y -> symbolic_type(y) != NotSymbolic() || is_array_of_symbolics(y), x) +end + +function namespace_expr( + O, sys, n = nameof(sys); ivs = independent_variables(sys)) + O = unwrap(O) + # Exceptions for arrays of symbolic and Ref of a symbolic, the latter + # of which shows up in broadcasts + if symbolic_type(O) == NotSymbolic() && !(O isa AbstractArray) && !(O isa Ref) + return O + end + if any(isequal(O), ivs) + return O + elseif iscall(O) + T = typeof(O) + renamed = let sys = sys, n = n, T = T + map(a -> namespace_expr(a, sys, n; ivs)::Any, arguments(O)) + end + if isvariable(O) + # Use renamespace so the scope is correct, and make sure to use the + # metadata from the rescoped variable + rescoped = renamespace(n, O) + maketerm(typeof(rescoped), operation(rescoped), renamed, + metadata(rescoped)) + elseif Symbolics.isarraysymbolic(O) + # promote_symtype doesn't work for array symbolics + maketerm(typeof(O), operation(O), renamed, metadata(O)) + else + maketerm(typeof(O), operation(O), renamed, metadata(O)) + end + elseif isvariable(O) + renamespace(n, O) + elseif O isa AbstractArray && is_array_of_symbolics(O) + let sys = sys, n = n + map(o -> namespace_expr(o, sys, n; ivs), O) + end + else + O + end +end +_nonum(@nospecialize x) = x isa Num ? x.val : x + +""" +$(TYPEDSIGNATURES) + +Get the unknown variables of the system `sys` and its subsystems. + +See also [`ModelingToolkit.get_unknowns`](@ref). +""" +function unknowns(sys::AbstractSystem) + sts = get_unknowns(sys) + systems = get_systems(sys) + nonunique_unknowns = if isempty(systems) + sts + else + system_unknowns = reduce(vcat, namespace_variables.(systems)) + isempty(sts) ? system_unknowns : [sts; system_unknowns] + end + isempty(nonunique_unknowns) && return nonunique_unknowns + # `Vector{Any}` is incompatible with the `SymbolicIndexingInterface`, which uses + # `elsymtype = symbolic_type(eltype(_arg))` + # which inappropriately returns `NotSymbolic()` + if nonunique_unknowns isa Vector{Any} + nonunique_unknowns = _nonum.(nonunique_unknowns) + end + @assert typeof(nonunique_unknowns) !== Vector{Any} + unique(nonunique_unknowns) +end + +""" + unknowns_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `unknowns`, but ignores unknowns of subsystems. +""" +function unknowns_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return unknowns_toplevel(parent) + end + return get_unknowns(sys) +end + +""" +$(TYPEDSIGNATURES) + +Get the parameters of the system `sys` and its subsystems. + +See also [`@parameters`](@ref) and [`ModelingToolkit.get_ps`](@ref). +""" +function parameters(sys::AbstractSystem; initial_parameters = false) + ps = get_ps(sys) + if ps == SciMLBase.NullParameters() + return [] + end + if eltype(ps) <: Pair + ps = first.(ps) + end + systems = get_systems(sys) + result = unique(isempty(systems) ? ps : + [ps; reduce(vcat, namespace_parameters.(systems))]) + if !initial_parameters && !is_initializesystem(sys) + filter!(result) do sym + return !(isoperator(sym, Initial) || + iscall(sym) && operation(sym) == getindex && + isoperator(arguments(sym)[1], Initial)) + end + end + return result +end + +function dependent_parameters(sys::AbstractSystem) + return map(eq -> eq.lhs, parameter_dependencies(sys)) +end + +""" + parameters_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `parameters`, but ignores parameters of subsystems. +""" +function parameters_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return parameters_toplevel(parent) + end + return get_ps(sys) +end + +""" +$(TYPEDSIGNATURES) +Get the parameter dependencies of the system `sys` and its subsystems. + +See also [`defaults`](@ref) and [`ModelingToolkit.get_parameter_dependencies`](@ref). +""" +function parameter_dependencies(sys::AbstractSystem) + if !has_parameter_dependencies(sys) + return Equation[] + end + pdeps = get_parameter_dependencies(sys) + systems = get_systems(sys) + # put pdeps after those of subsystems to maintain topological sorted order + namespaced_deps = mapreduce( + s -> map(eq -> namespace_equation(eq, s), parameter_dependencies(s)), vcat, + systems; init = Equation[]) + + return vcat(namespaced_deps, pdeps) +end + +function full_parameters(sys::AbstractSystem) + vcat(parameters(sys; initial_parameters = true), dependent_parameters(sys)) +end + +""" +$(TYPEDSIGNATURES) + +Get the assertions for a system `sys` and its subsystems. +""" +function assertions(sys::AbstractSystem) + has_assertions(sys) || return Dict{BasicSymbolic, String}() + + asserts = get_assertions(sys) + systems = get_systems(sys) + namespaced_asserts = mapreduce( + merge!, systems; init = Dict{BasicSymbolic, String}()) do subsys + Dict{BasicSymbolic, String}(namespace_expr(k, subsys) => v + for (k, v) in assertions(subsys)) + end + return merge(asserts, namespaced_asserts) +end + +""" + $(TYPEDEF) + +Information about an `AnalysisPoint` for which the corresponding connection must be +ignored during `expand_connections`, since the analysis point has been transformed. + +# Fields + +$(TYPEDFIELDS) +""" +struct IgnoredAnalysisPoint + """ + The input variable/connector. + """ + input::Union{BasicSymbolic, AbstractSystem} + """ + The output variables/connectors. + """ + outputs::Vector{Union{BasicSymbolic, AbstractSystem}} +end + +const HierarchyVariableT = Vector{Union{BasicSymbolic, Symbol}} +const HierarchySystemT = Vector{Union{AbstractSystem, Symbol}} +""" +The type returned from `analysis_point_common_hierarchy`. +""" +const HierarchyAnalysisPointT = Vector{Union{IgnoredAnalysisPoint, Symbol}} +""" +The type returned from `as_hierarchy`. +""" +const HierarchyT = Union{HierarchyVariableT, HierarchySystemT} + +""" + $(TYPEDSIGNATURES) + +The inverse operation of `as_hierarchy`. +""" +function from_hierarchy(hierarchy::HierarchyT) + namefn = hierarchy[1] isa AbstractSystem ? nameof : getname + foldl(@view hierarchy[2:end]; init = hierarchy[1]) do sys, name + rename(sys, Symbol(name, NAMESPACE_SEPARATOR, namefn(sys))) + end +end + +""" + $(TYPEDSIGNATURES) + +Represent an ignored analysis point as a namespaced hierarchy. The hierarchy is built +using the common hierarchy of all involved systems/variables. +""" +function analysis_point_common_hierarchy(ap::IgnoredAnalysisPoint)::HierarchyAnalysisPointT + isys = as_hierarchy(ap.input) + osyss = as_hierarchy.(ap.outputs) + suffix = Symbol[] + while isys[end] == osyss[1][end] && allequal(last.(osyss)) + push!(suffix, isys[end]) + pop!(isys) + pop!.(osyss) + end + isys = from_hierarchy(isys) + osyss = from_hierarchy.(osyss) + newap = IgnoredAnalysisPoint(isys, osyss) + hierarchy = HierarchyAnalysisPointT([suffix; newap]) + reverse!(hierarchy) + return hierarchy +end + +""" + $(TYPEDSIGNATURES) + +Represent a namespaced system (or variable) `sys` as a hierarchy. Return a vector, where +the first element is the unnamespaced system (variable) and subsequent elements are +`Symbol`s representing the parents of the unnamespaced system (variable) in order from +inner to outer. +""" +function as_hierarchy(sys::Union{AbstractSystem, BasicSymbolic})::HierarchyT + namefn = sys isa AbstractSystem ? nameof : getname + # get the hierarchy + hierarchy = namespace_hierarchy(namefn(sys)) + # rename the system with unnamespaced name + newsys = rename(sys, hierarchy[end]) + # and remove it from the list + pop!(hierarchy) + # reverse it to go from inner to outer + reverse!(hierarchy) + # concatenate + T = sys isa AbstractSystem ? AbstractSystem : BasicSymbolic + return Union{Symbol, T}[newsys; hierarchy] +end + +""" + $(TYPEDSIGNATURES) + +Get the analysis points to ignore for `sys` and its subsystems. The returned value is a +`Tuple` similar in structure to the `ignored_connections` field. +""" +function ignored_connections(sys::AbstractSystem) + has_ignored_connections(sys) || + return (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[]) + + ics = get_ignored_connections(sys) + if ics === nothing + ics = (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[]) + end + # turn into hierarchies + ics = (map(analysis_point_common_hierarchy, ics[1]), + map(analysis_point_common_hierarchy, ics[2])) + systems = get_systems(sys) + # for each subsystem, get its ignored connections, add the name of the subsystem + # to the hierarchy and concatenate corresponding buffers of the result + result = mapreduce(Broadcast.BroadcastFunction(vcat), systems; init = ics) do subsys + sub_ics = ignored_connections(subsys) + (map(Base.Fix2(push!, nameof(subsys)), sub_ics[1]), + map(Base.Fix2(push!, nameof(subsys)), sub_ics[2])) + end + return (Vector{HierarchyAnalysisPointT}(result[1]), + Vector{HierarchyAnalysisPointT}(result[2])) +end + +""" +$(TYPEDSIGNATURES) + +Get the guesses for variables in the initialization system of the system `sys` and its subsystems. + +See also [`initialization_equations`](@ref) and [`ModelingToolkit.get_guesses`](@ref). +""" +function guesses(sys::AbstractSystem) + guess = get_guesses(sys) + systems = get_systems(sys) + isempty(systems) && return guess + for subsys in systems + guess = merge(guess, namespace_guesses(subsys)) + end + return guess +end + +# required in `src/connectors.jl:437` +parameters(_) = [] + +function controls(sys::AbstractSystem) + ctrls = get_ctrls(sys) + systems = get_systems(sys) + isempty(systems) ? ctrls : [ctrls; reduce(vcat, namespace_controls.(systems))] +end + +function observed(sys::AbstractSystem) + obs = get_observed(sys) + systems = get_systems(sys) + [obs; + reduce(vcat, + (map(o -> namespace_equation(o, s), observed(s)) for s in systems), + init = Equation[])] +end + +Base.@deprecate default_u0(x) defaults(x) false +Base.@deprecate default_p(x) defaults(x) false + +""" +$(TYPEDSIGNATURES) + +Get the default values of the system sys and its subsystems. +If they are not explicitly provided, variables and parameters are initialized to these values. + +See also [`initialization_equations`](@ref), [`parameter_dependencies`](@ref) and [`ModelingToolkit.get_defaults`](@ref). +""" +function defaults(sys::AbstractSystem) + systems = get_systems(sys) + defs = get_defaults(sys) + # `mapfoldr` is really important!!! We should prefer the base model for + # defaults, because people write: + # + # `compose(ODESystem(...; defaults=defs), ...)` + # + # Thus, right associativity is required and crucial for correctness. + isempty(systems) ? defs : mapfoldr(namespace_defaults, merge, systems; init = defs) +end + +function defaults_and_guesses(sys::AbstractSystem) + merge(guesses(sys), defaults(sys)) +end + +unknowns(sys::Union{AbstractSystem, Nothing}, v) = renamespace(sys, v) +for vType in [Symbolics.Arr, Symbolics.Symbolic{<:AbstractArray}] + @eval unknowns(sys::AbstractSystem, v::$vType) = renamespace(sys, v) + @eval parameters(sys::AbstractSystem, v::$vType) = toparam(unknowns(sys, v)) +end +parameters(sys::Union{AbstractSystem, Nothing}, v) = toparam(unknowns(sys, v)) +for f in [:unknowns, :parameters] + @eval function $f(sys::AbstractSystem, vs::AbstractArray) + map(v -> $f(sys, v), vs) + end +end + +flatten(sys::AbstractSystem, args...) = sys + +""" +$(TYPEDSIGNATURES) + +Get the flattened equations of the system `sys` and its subsystems. +It may include some abbreviations and aliases of observables. +It is often the most useful way to inspect the equations of a system. + +See also [`full_equations`](@ref) and [`ModelingToolkit.get_eqs`](@ref). +""" +function equations(sys::AbstractSystem) + eqs = get_eqs(sys) + systems = get_systems(sys) + if isempty(systems) + return eqs + else + eqs = Equation[eqs; + reduce(vcat, + namespace_equations.(get_systems(sys)); + init = Equation[])] + return eqs + end +end + +""" + equations_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `equations`, but ignores equations of subsystems. + +Notes: +- Cannot be applied to non-complete systems. +""" +function equations_toplevel(sys::AbstractSystem) + iscomplete(sys) && error("Cannot apply `equations_toplevel` to complete systems.") + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return equations_toplevel(parent) + end + return get_eqs(sys) +end + +""" +$(TYPEDSIGNATURES) + +Get the initialization equations of the system `sys` and its subsystems. + +See also [`guesses`](@ref), [`defaults`](@ref), [`parameter_dependencies`](@ref) and [`ModelingToolkit.get_initialization_eqs`](@ref). +""" +function initialization_equations(sys::AbstractSystem) + eqs = get_initialization_eqs(sys) + systems = get_systems(sys) + if isempty(systems) + return eqs + else + eqs = Equation[eqs; + reduce(vcat, + namespace_initialization_equations.(get_systems(sys)); + init = Equation[])] + return eqs + end +end + +function symbolic_tstops(sys::AbstractSystem) + tstops = get_tstops(sys) + systems = get_systems(sys) + isempty(systems) && return tstops + tstops = [tstops; reduce(vcat, namespace_tstops.(get_systems(sys)); init = [])] + return tstops +end + +function preface(sys::AbstractSystem) + has_preface(sys) || return nothing + pre = get_preface(sys) + systems = get_systems(sys) + if isempty(systems) + return pre + else + pres = pre === nothing ? [] : pre + for sys in systems + pre = get_preface(sys) + pre === nothing && continue + for eq in pre + push!(pres, namespace_assignment(eq, sys)) + end + end + return isempty(pres) ? nothing : pres + end +end + +function islinear(sys::AbstractSystem) + rhs = [eq.rhs for eq in equations(sys)] + + all(islinear(r, unknowns(sys)) for r in rhs) +end + +function isaffine(sys::AbstractSystem) + rhs = [eq.rhs for eq in equations(sys)] + + all(isaffine(r, unknowns(sys)) for r in rhs) +end + +function time_varying_as_func(x, sys::AbstractTimeDependentSystem) + # if something is not x(t) (the current unknown) + # but is `x(t-1)` or something like that, pass in `x` as a callable function rather + # than pass in a value in place of x(t). + # + # This is done by just making `x` the argument of the function. + if iscall(x) && + issym(operation(x)) && + !(length(arguments(x)) == 1 && isequal(arguments(x)[1], get_iv(sys))) + return operation(x) + end + return x +end + +""" +$(SIGNATURES) + +Return a list of actual unknowns needed to be solved by solvers. +""" +function solved_unknowns(sys::AbstractSystem) + sts = unknowns(sys) + if has_solved_unknowns(sys) + sts = something(get_solved_unknowns(sys), sts) + end + return sts +end + +### +### System utils +### +struct ObservedFunctionCache{S} + sys::S + dict::Dict{Any, Any} + steady_state::Bool + eval_expression::Bool + eval_module::Module + checkbounds::Bool + cse::Bool +end + +function ObservedFunctionCache( + sys; steady_state = false, eval_expression = false, + eval_module = @__MODULE__, checkbounds = true, cse = true) + return ObservedFunctionCache( + sys, Dict(), steady_state, eval_expression, eval_module, checkbounds, cse) +end + +# This is hit because ensemble problems do a deepcopy +function Base.deepcopy_internal(ofc::ObservedFunctionCache, stackdict::IdDict) + sys = deepcopy(ofc.sys) + dict = deepcopy(ofc.dict) + steady_state = ofc.steady_state + eval_expression = ofc.eval_expression + eval_module = ofc.eval_module + checkbounds = ofc.checkbounds + cse = ofc.cse + newofc = ObservedFunctionCache( + sys, dict, steady_state, eval_expression, eval_module, checkbounds, cse) + stackdict[ofc] = newofc + return newofc +end + +function (ofc::ObservedFunctionCache)(obsvar, args...) + obs = get!(ofc.dict, value(obsvar)) do + SymbolicIndexingInterface.observed( + ofc.sys, obsvar; eval_expression = ofc.eval_expression, + eval_module = ofc.eval_module, checkbounds = ofc.checkbounds, cse = ofc.cse) + end + if ofc.steady_state + obs = let fn = obs + fn1(u, p, t = Inf) = fn(u, p, t) + fn1 + end + end + if args === () + return obs + else + return obs(args...) + end +end + +function push_vars!(stmt, name, typ, vars) + isempty(vars) && return + vars_expr = Expr(:macrocall, typ, nothing) + for s in vars + if iscall(s) + f = nameof(operation(s)) + args = arguments(s) + ex = :($f($(args...))) + else + ex = nameof(s) + end + push!(vars_expr.args, ex) + end + push!(stmt, :($name = $collect($vars_expr))) + return +end + +function round_trip_expr(t, var2name) + name = get(var2name, t, nothing) + name !== nothing && return name + issym(t) && return nameof(t) + iscall(t) || return t + f = round_trip_expr(operation(t), var2name) + args = map(Base.Fix2(round_trip_expr, var2name), arguments(t)) + return :($f($(args...))) +end + +function round_trip_eq(eq::Equation, var2name) + if eq.lhs isa Connection + syss = get_systems(eq.rhs) + call = Expr(:call, connect) + for sys in syss + strs = split(string(nameof(sys)), NAMESPACE_SEPARATOR) + s = Symbol(strs[1]) + for st in strs[2:end] + s = Expr(:., s, Meta.quot(Symbol(st))) + end + push!(call.args, s) + end + call + else + Expr(:call, (~), round_trip_expr(eq.lhs, var2name), + round_trip_expr(eq.rhs, var2name)) + end +end + +function push_eqs!(stmt, eqs, var2name) + eqs_name = gensym(:eqs) + eqs_expr = Expr(:vcat) + eqs_blk = Expr(:(=), eqs_name, eqs_expr) + for eq in eqs + push!(eqs_expr.args, round_trip_eq(eq, var2name)) + end + + push!(stmt, eqs_blk) + return eqs_name +end + +function push_defaults!(stmt, defs, var2name) + defs_name = gensym(:defs) + defs_expr = Expr(:call, Dict) + defs_blk = Expr(:(=), defs_name, defs_expr) + for d in defs + n = round_trip_expr(d.first, var2name) + v = round_trip_expr(d.second, var2name) + push!(defs_expr.args, :($(=>)($n, $v))) + end + + push!(stmt, defs_blk) + return defs_name +end + +### +### System I/O +### +function toexpr(sys::AbstractSystem) + sys = flatten(sys) + expr = Expr(:block) + stmt = expr.args + + name = Meta.quot(nameof(sys)) + ivs = independent_variables(sys) + ivname = gensym(:iv) + for iv in ivs + ivname = gensym(:iv) + push!(stmt, :($ivname = (@variables $(getname(iv)))[1])) + end + + stsname = gensym(:sts) + sts = unknowns(sys) + push_vars!(stmt, stsname, Symbol("@variables"), sts) + psname = gensym(:ps) + ps = parameters(sys) + push_vars!(stmt, psname, Symbol("@parameters"), ps) + obs = observed(sys) + obsvars = [o.lhs for o in obs] + obsvarsname = gensym(:obs) + push_vars!(stmt, obsvarsname, Symbol("@variables"), obsvars) + + var2name = Dict{Any, Symbol}() + for v in Iterators.flatten((sts, ps, obsvars)) + var2name[v] = getname(v) + end + + eqs_name = push_eqs!(stmt, full_equations(sys), var2name) + filtered_defs = filter( + kvp -> !(iscall(kvp[1]) && operation(kvp[1]) isa Initial), defaults(sys)) + defs_name = push_defaults!(stmt, filtered_defs, var2name) + obs_name = push_eqs!(stmt, obs, var2name) + + if sys isa ODESystem + iv = get_iv(sys) + ivname = gensym(:iv) + push!(stmt, :($ivname = (@variables $(getname(iv)))[1])) + push!(stmt, + :($ODESystem($eqs_name, $ivname, $stsname, $psname; defaults = $defs_name, + observed = $obs_name, + name = $name, checks = false))) + elseif sys isa NonlinearSystem + push!(stmt, + :($NonlinearSystem($eqs_name, $stsname, $psname; defaults = $defs_name, + observed = $obs_name, + name = $name, checks = false))) + end + + expr = :(let + $expr + end) + Base.remove_linenums!(expr) # keeping the line numbers is never helpful +end + +Base.write(io::IO, sys::AbstractSystem) = write(io, readable_code(toexpr(sys))) + +function get_or_construct_tearing_state(sys) + if has_tearing_state(sys) + state = get_tearing_state(sys) + if state === nothing + state = TearingState(sys) + end + else + state = nothing + end + state +end + +""" + n_expanded_connection_equations(sys::AbstractSystem) + +Returns the number of equations that the connections in `sys` expands to. +Equivalent to `length(equations(expand_connections(sys))) - length(filter(eq -> !(eq.lhs isa Connection), equations(sys)))`. +""" +function n_expanded_connection_equations(sys::AbstractSystem) + # TODO: what about inputs? + isconnector(sys) && return length(get_unknowns(sys)) + sys = remove_analysis_points(sys) + n_variable_connect_eqs = 0 + for eq in equations(sys) + is_causal_variable_connection(eq.rhs) || continue + n_variable_connect_eqs += length(get_systems(eq.rhs)) - 1 + end + + sys, (csets, _) = generate_connection_set(sys) + ceqs, instream_csets = generate_connection_equations_and_stream_connections(csets) + n_outer_stream_variables = 0 + for cset in instream_csets + n_outer_stream_variables += count(x -> x.isouter, cset.set) + end + + #n_toplevel_unused_flows = 0 + #toplevel_flows = Set() + #for cset in csets + # e1 = first(cset.set) + # e1.sys.namespace === nothing || continue + # for e in cset.set + # get_connection_type(e.v) === Flow || continue + # push!(toplevel_flows, e.v) + # end + #end + #for m in get_systems(sys) + # isconnector(m) || continue + # n_toplevel_unused_flows += count(x->get_connection_type(x) === Flow && !(x in toplevel_flows), get_unknowns(m)) + #end + + nextras = n_outer_stream_variables + length(ceqs) + n_variable_connect_eqs +end + +function Base.show( + io::IO, mime::MIME"text/plain", sys::AbstractSystem; hint = true, bold = true) + limit = get(io, :limit, false) # if output should be limited, + rows = first(displaysize(io)) ÷ 5 # then allocate ≈1/5 of display height to each list + + # Print name and description + desc = description(sys) + name = nameof(sys) + printstyled(io, "Model ", name, ":"; bold) + !isempty(desc) && print(io, " ", desc) + + # Print subsystems + subs = get_systems(sys) + nsubs = length(subs) + nrows = min(nsubs, limit ? rows : nsubs) + nrows > 0 && printstyled(io, "\nSubsystems ($(nsubs)):"; bold) + nrows > 0 && hint && print(io, " see hierarchy($name)") + for i in 1:nrows + sub = subs[i] + local name = String(nameof(sub)) + print(io, "\n ", name) + desc = description(sub) + if !isempty(desc) + maxlen = displaysize(io)[2] - length(name) - 6 # remaining length of line + if limit && length(desc) > maxlen + desc = chop(desc, tail = length(desc) - maxlen) * "…" # too long + end + print(io, ": ", desc) + end + end + limited = nrows < nsubs + limited && print(io, "\n ⋮") # too many to print + + # Print equations + eqs = equations(sys) + if eqs isa AbstractArray && eltype(eqs) <: Equation + neqs = count(eq -> !(eq.lhs isa Connection), eqs) + next = n_expanded_connection_equations(sys) + ntot = neqs + next + ntot > 0 && printstyled(io, "\nEquations ($ntot):"; bold) + neqs > 0 && print(io, "\n $neqs standard", hint ? ": see equations($name)" : "") + next > 0 && print(io, "\n $next connecting", + hint ? ": see equations(expand_connections($name))" : "") + #Base.print_matrix(io, eqs) # usually too long and not useful to print all equations + end + + # Print variables + for varfunc in [unknowns, parameters] + vars = varfunc(sys) + nvars = length(vars) + nvars == 0 && continue # skip + header = titlecase(String(nameof(varfunc))) # e.g. "Unknowns" + printstyled(io, "\n$header ($nvars):"; bold) + hint && print(io, " see $(nameof(varfunc))($name)") + nrows = min(nvars, limit ? rows : nvars) + defs = has_defaults(sys) ? defaults(sys) : nothing + for i in 1:nrows + s = vars[i] + print(io, "\n ", s) + if !isnothing(defs) + val = get(defs, s, nothing) + if !isnothing(val) + print(io, " [defaults to ") + show( + IOContext(io, :compact => true, :limit => true, + :displaysize => (1, displaysize(io)[2])), + val) + print(io, "]") + end + desc = getdescription(s) + end + if !isnothing(desc) && desc != "" + print(io, ": ", desc) + end + end + limited = nrows < nvars + limited && printstyled(io, "\n ⋮") # too many variables to print + end + + # Print parameter dependencies + npdeps = has_parameter_dependencies(sys) ? length(parameter_dependencies(sys)) : 0 + npdeps > 0 && printstyled(io, "\nParameter dependencies ($npdeps):"; bold) + npdeps > 0 && hint && print(io, " see parameter_dependencies($name)") + + # Print observed + nobs = has_observed(sys) ? length(observed(sys)) : 0 + nobs > 0 && printstyled(io, "\nObserved ($nobs):"; bold) + nobs > 0 && hint && print(io, " see observed($name)") + + return nothing +end + +function Graphs.incidence_matrix(sys) + if has_torn_matching(sys) && has_tearing_state(sys) + state = get_tearing_state(sys) + incidence_matrix(state.structure.graph, Num(Sym{Real}(:×))) + else + return nothing + end +end + +function split_assign(expr) + if !(expr isa Expr && expr.head === :(=) && expr.args[2].head === :call) + throw(ArgumentError("expression should be of the form `sys = foo(a, b)`")) + end + name, call = expr.args +end + +varname_fix!(s) = return + +function varname_fix!(expr::Expr) + for arg in expr.args + MLStyle.@match arg begin + ::Symbol => continue + Expr(:kw, a...) || Expr(:kw, a) => varname_sanitization!(arg) + Expr(:parameters, a...) => begin + for _arg in arg.args + varname_sanitization!(_arg) + end + end + _ => @debug "skipping variable sanitization of $arg" + end + end +end + +varname_sanitization!(a) = return + +function varname_sanitization!(expr::Expr) + var_splits = split(string(expr.args[1]), ".") + if length(var_splits) > 1 + expr.args[1] = Symbol(join(var_splits, "__")) + end +end + +function _named(name, call, runtime = false) + has_kw = false + call isa Expr || throw(Meta.ParseError("The rhs must be an Expr. Got $call.")) + if length(call.args) >= 2 && call.args[2] isa Expr + # canonicalize to use `:parameters` + if call.args[2].head === :kw + call = Expr(call.head, call.args[1], Expr(:parameters, call.args[2:end]...)) + has_kw = true + elseif call.args[2].head === :parameters + has_kw = true + end + end + + varname_fix!(call) + + if !has_kw + param = Expr(:parameters) + if length(call.args) == 1 + push!(call.args, param) + else + insert!(call.args, 2, param) + end + end + + is_sys_construction = gensym("###__is_system_construction###") + kws = call.args[2].args + for (i, kw) in enumerate(kws) + if Meta.isexpr(kw, (:(=), :kw)) + kw.args[2] = :($is_sys_construction ? $(kw.args[2]) : + $default_to_parentscope($(kw.args[2]))) + elseif kw isa Symbol + rhs = :($is_sys_construction ? $(kw) : $default_to_parentscope($(kw))) + kws[i] = Expr(:kw, kw, rhs) + end + end + + if !any(kw -> (kw isa Symbol ? kw : kw.args[1]) == :name, kws) # don't overwrite `name` kwarg + pushfirst!(kws, Expr(:kw, :name, runtime ? name : Meta.quot(name))) + end + op = call.args[1] + quote + $is_sys_construction = ($op isa $DataType) && ($op <: $AbstractSystem) + $call + end +end + +function _named_idxs(name::Symbol, idxs, call; extra_args = "") + if call.head !== :-> + throw(ArgumentError("Not an anonymous function")) + end + if !isa(call.args[1], Symbol) + throw(ArgumentError("not a single-argument anonymous function")) + end + sym, ex = call.args + ex = Base.Cartesian.poplinenum(ex) + ex = _named(:(Symbol($(Meta.quot(name)), :_, $sym)), ex, true) + ex = Base.Cartesian.poplinenum(ex) + :($name = map($sym -> begin + $extra_args + $ex + end, $idxs)) +end + +function single_named_expr(expr) + name, call = split_assign(expr) + if Meta.isexpr(name, :ref) + name, idxs = name.args + check_name(name) + var = gensym(name) + ex = quote + $var = $(_named(name, call)) + $name = map(i -> $rename($var, Symbol($(Meta.quot(name)), :_, i)), $idxs) + end + ex + else + check_name(name) + :($name = $(_named(name, call))) + end +end + +function named_expr(expr) + if Meta.isexpr(expr, :block) + newexpr = Expr(:block) + names = Expr(:vcat) + for ex in expr.args + ex isa LineNumberNode && continue + push!(newexpr.args, single_named_expr(ex)) + push!(names.args, ex.args[1]) + end + push!(newexpr.args, names) + newexpr + else + single_named_expr(expr) + end +end + +function check_name(name) + name isa Symbol || + throw(Meta.ParseError("The lhs must be a symbol (a) or a ref (a[1:10]). Got $name.")) +end + +""" + @named y = foo(x) + @named y[1:10] = foo(x) + @named begin + y[1:10] = foo(x) + z = foo(x) + end # returns `[y; z]` + @named y 1:10 i -> foo(x*i) # This is not recommended + +Pass the LHS name to the model. When it's calling anything that's not an +AbstractSystem, it wraps all keyword arguments in `default_to_parentscope` so +that namespacing works intuitively when passing a symbolic default into a +component. + +Examples: + +```julia-repl +julia> using ModelingToolkit + +julia> foo(i; name) = (; i, name) +foo (generic function with 1 method) + +julia> x = 41 +41 + +julia> @named y = foo(x) +(i = 41, name = :y) + +julia> @named y[1:3] = foo(x) +3-element Vector{NamedTuple{(:i, :name), Tuple{Int64, Symbol}}}: + (i = 41, name = :y_1) + (i = 41, name = :y_2) + (i = 41, name = :y_3) +``` +""" +macro named(expr) + esc(named_expr(expr)) +end + +macro named(name::Symbol, idxs, call) + esc(_named_idxs(name, idxs, call)) +end + +function default_to_parentscope(v) + uv = unwrap(v) + uv isa Symbolic || return v + apply_to_variables(v) do sym + if !hasmetadata(uv, SymScope) + ParentScope(sym) + else + sym + end + end +end + +function _config(expr, namespace) + cn = Base.Fix2(_config, namespace) + if Meta.isexpr(expr, :.) + return :($getproperty($(map(cn, expr.args)...); namespace = $namespace)) + elseif Meta.isexpr(expr, :function) + def = splitdef(expr) + def[:args] = map(cn, def[:args]) + def[:body] = cn(def[:body]) + combinedef(def) + elseif expr isa Expr && !isempty(expr.args) + return Expr(expr.head, map(cn, expr.args)...) + elseif Meta.isexpr(expr, :(=)) + return Expr(:(=), map(cn, expr.args)...) + else + expr + end +end + +""" +$(SIGNATURES) + +Rewrite `@nonamespace a.b.c` to +`getvar(getvar(a, :b; namespace = false), :c; namespace = false)`. + +This is the default behavior of `getvar`. This should be used when inheriting unknowns from a model. +""" +macro nonamespace(expr) + esc(_config(expr, false)) +end + +""" +$(SIGNATURES) + +Rewrite `@namespace a.b.c` to +`getvar(getvar(a, :b; namespace = true), :c; namespace = true)`. +""" +macro namespace(expr) + esc(_config(expr, true)) +end + +function component_post_processing(expr, isconnector) + @assert expr isa Expr && (expr.head == :function || (expr.head == :(=) && + expr.args[1] isa Expr && + expr.args[1].head == :call)) + + sig = expr.args[1] + body = expr.args[2] + + fname = sig.args[1] + args = sig.args[2:end] + + quote + $Base.@__doc__ function $fname($(args...)) + # we need to create a closure to escape explicit return in `body`. + res = (() -> $body)() + if $isdefined(res, :gui_metadata) && $getfield(res, :gui_metadata) === nothing + name = $(Meta.quot(fname)) + if $isconnector + $Setfield.@set!(res.connector_type=$connector_type(res)) + end + $Setfield.@set!(res.gui_metadata=$GUIMetadata($GlobalRef(@__MODULE__, name))) + else + res + end + end + end +end + +macro component(expr) + esc(component_post_processing(expr, false)) +end + +macro mtkbuild(exprs...) + expr = exprs[1] + named_expr = ModelingToolkit.named_expr(expr) + name = named_expr.args[1] + kwargs = Base.tail(exprs) + kwargs = map(kwargs) do ex + @assert ex.head == :(=) + Expr(:kw, ex.args[1], ex.args[2]) + end + if isempty(kwargs) + kwargs = () + else + kwargs = (Expr(:parameters, kwargs...),) + end + call_expr = Expr(:call, structural_simplify, kwargs..., name) + esc(quote + $named_expr + $name = $call_expr + end) +end + +""" + debug_system(sys::AbstractSystem; functions = [log, sqrt, (^), /, inv, asin, acos], error_nonfinite = true) + +Wrap `functions` in `sys` so any error thrown in them shows helpful symbolic-numeric +information about its input. If `error_nonfinite`, functions that output nonfinite +values (like `Inf` or `NaN`) also display errors, even though the raw function itself +does not throw an exception (like `1/0`). For example: + +```julia-repl +julia> sys = debug_system(complete(sys)) + +julia> prob = ODEProblem(sys, [0.0, 2.0], (0.0, 1.0)) + +julia> prob.f(prob.u0, prob.p, 0.0) +ERROR: Function /(1, sin(P(t))) output non-finite value Inf with input + 1 => 1 + sin(P(t)) => 0.0 +``` + +Additionally, all assertions in the system are optionally logged when they fail. +A new parameter is also added to the system which controls whether the message associated +with each assertion will be logged when the assertion fails. This parameter defaults to +`true` and can be toggled by symbolic indexing with +`ModelingToolkit.ASSERTION_LOG_VARIABLE`. For example, +`prob.ps[ModelingToolkit.ASSERTION_LOG_VARIABLE] = false` will disable logging. +""" +function debug_system( + sys::AbstractSystem; functions = [log, sqrt, (^), /, inv, asin, acos], kw...) + if !(functions isa Set) + functions = Set(functions) # more efficient "in" lookup + end + if has_systems(sys) && !isempty(get_systems(sys)) + error("debug_system(sys) only works on systems with no sub-systems! Consider flattening it with flatten(sys) or structural_simplify(sys) first.") + end + if has_eqs(sys) + eqs = debug_sub.(equations(sys), Ref(functions); kw...) + @set! sys.eqs = eqs + @set! sys.ps = unique!([get_ps(sys); ASSERTION_LOG_VARIABLE]) + @set! sys.defaults = merge(get_defaults(sys), Dict(ASSERTION_LOG_VARIABLE => true)) + end + if has_observed(sys) + @set! sys.observed = debug_sub.(observed(sys), Ref(functions); kw...) + end + if iscomplete(sys) + sys = complete(sys; split = is_split(sys)) + end + return sys +end + +function eliminate_constants(sys::AbstractSystem) + if has_eqs(sys) + eqs = get_eqs(sys) + eq_cs = collect_constants(eqs) + if !isempty(eq_cs) + new_eqs = eliminate_constants(eqs, eq_cs) + @set! sys.eqs = new_eqs + end + end + return sys +end + +function io_preprocessing(sys::AbstractSystem, inputs, + outputs; simplify = false, kwargs...) + sys, input_idxs = structural_simplify(sys, (inputs, outputs); simplify, kwargs...) + + eqs = equations(sys) + alg_start_idx = findfirst(!isdiffeq, eqs) + if alg_start_idx === nothing + alg_start_idx = length(eqs) + 1 + end + diff_idxs = 1:(alg_start_idx - 1) + alge_idxs = alg_start_idx:length(eqs) + + sys, diff_idxs, alge_idxs, input_idxs +end + +@latexrecipe function f(sys::AbstractSystem) + return latexify(equations(sys)) +end + +function Base.show(io::IO, ::MIME"text/latex", x::AbstractSystem) + print(io, "\$\$ " * latexify(x) * " \$\$") +end + +struct InvalidSystemException <: Exception + msg::String +end +function Base.showerror(io::IO, e::InvalidSystemException) + print(io, "InvalidSystemException: ", e.msg) +end + +struct ExtraVariablesSystemException <: Exception + msg::String +end +function Base.showerror(io::IO, e::ExtraVariablesSystemException) + println(io, "ExtraVariablesSystemException: ", e.msg) + print(io, + "Note that the process of determining extra variables is a best-effort heuristic. " * + "The true extra variables are dependent on the model and may not be in this list.") +end + +struct ExtraEquationsSystemException <: Exception + msg::String +end +function Base.showerror(io::IO, e::ExtraEquationsSystemException) + println(io, "ExtraEquationsSystemException: ", e.msg) + print(io, + "Note that the process of determining extra equations is a best-effort heuristic. " * + "The true extra equations are dependent on the model and may not be in this list.") +end + +struct HybridSystemNotSupportedException <: Exception + msg::String +end +function Base.showerror(io::IO, e::HybridSystemNotSupportedException) + print(io, "HybridSystemNotSupportedException: ", e.msg) +end + +function AbstractTrees.children(sys::AbstractSystem) + ModelingToolkit.get_systems(sys) +end +function AbstractTrees.printnode( + io::IO, sys::AbstractSystem; describe = false, bold = false) + printstyled(io, nameof(sys); bold) + describe && !isempty(description(sys)) && print(io, ": ", description(sys)) +end +""" + hierarchy(sys::AbstractSystem; describe = false, bold = describe, kwargs...) + +Print a tree of a system's hierarchy of subsystems. +""" +function hierarchy(sys::AbstractSystem; describe = false, bold = describe, kwargs...) + print_tree(sys; printnode_kw = (describe = describe, bold = bold), kwargs...) +end + +function Base.IteratorEltype(::Type{<:TreeIterator{ModelingToolkit.AbstractSystem}}) + Base.HasEltype() +end +function Base.eltype(::Type{<:TreeIterator{ModelingToolkit.AbstractSystem}}) + ModelingToolkit.AbstractSystem +end + +function check_array_equations_unknowns(eqs, dvs) + if any(eq -> eq isa Equation && Symbolics.isarraysymbolic(eq.lhs), eqs) + throw(ArgumentError("The system has array equations. Call `structural_simplify` to handle such equations or scalarize them manually.")) + end + if any(x -> Symbolics.isarraysymbolic(x), dvs) + throw(ArgumentError("The system has array unknowns. Call `structural_simplify` to handle this or scalarize them manually.")) + end +end + +function check_eqs_u0(eqs, dvs, u0; check_length = true, kwargs...) + if u0 !== nothing + if check_length + if !(length(eqs) == length(dvs) == length(u0)) + throw(ArgumentError("Equations ($(length(eqs))), unknowns ($(length(dvs))), and initial conditions ($(length(u0))) are of different lengths.")) + end + elseif length(dvs) != length(u0) + throw(ArgumentError("Unknowns ($(length(dvs))) and initial conditions ($(length(u0))) are of different lengths.")) + end + elseif check_length && (length(eqs) != length(dvs)) + throw(ArgumentError("Equations ($(length(eqs))) and Unknowns ($(length(dvs))) are of different lengths.")) + end + return nothing +end + +### +### Inheritance & composition +### +function Base.hash(sys::AbstractSystem, s::UInt) + s = hash(nameof(sys), s) + s = foldr(hash, get_systems(sys), init = s) + s = foldr(hash, get_unknowns(sys), init = s) + s = foldr(hash, get_ps(sys), init = s) + if sys isa OptimizationSystem + s = hash(get_op(sys), s) + else + s = foldr(hash, get_eqs(sys), init = s) + end + s = foldr(hash, get_observed(sys), init = s) + s = foldr(hash, get_continuous_events(sys), init = s) + s = foldr(hash, get_discrete_events(sys), init = s) + s = hash(independent_variables(sys), s) + return s +end + +""" +$(TYPEDSIGNATURES) + +Extend `basesys` with `sys`. +By default, the resulting system inherits `sys`'s name and description. + +See also [`compose`](@ref). +""" +function extend(sys::AbstractSystem, basesys::AbstractSystem; + name::Symbol = nameof(sys), description = description(sys), + gui_metadata = get_gui_metadata(sys)) + T = SciMLBase.parameterless_type(basesys) + ivs = independent_variables(basesys) + if !(sys isa T) + if length(ivs) == 0 + sys = convert_system(T, sys) + elseif length(ivs) == 1 + sys = convert_system(T, sys, ivs[1]) + else + throw("Extending multivariate systems is not supported") + end + end + + # collect fields common to all system types + eqs = union(get_eqs(basesys), get_eqs(sys)) + sts = union(get_unknowns(basesys), get_unknowns(sys)) + ps = union(get_ps(basesys), get_ps(sys)) + dep_ps = union(parameter_dependencies(basesys), parameter_dependencies(sys)) + obs = union(get_observed(basesys), get_observed(sys)) + cevs = union(get_continuous_events(basesys), get_continuous_events(sys)) + devs = union(get_discrete_events(basesys), get_discrete_events(sys)) + defs = merge(get_defaults(basesys), get_defaults(sys)) # prefer `sys` + meta = union_nothing(get_metadata(basesys), get_metadata(sys)) + syss = union(get_systems(basesys), get_systems(sys)) + args = length(ivs) == 0 ? (eqs, sts, ps) : (eqs, ivs[1], sts, ps) + kwargs = (parameter_dependencies = dep_ps, observed = obs, continuous_events = cevs, + discrete_events = devs, defaults = defs, systems = syss, metadata = meta, + name = name, description = description, gui_metadata = gui_metadata) + + # collect fields specific to some system types + if basesys isa ODESystem + ieqs = union(get_initialization_eqs(basesys), get_initialization_eqs(sys)) + guesses = merge(get_guesses(basesys), get_guesses(sys)) # prefer `sys` + kwargs = merge(kwargs, (initialization_eqs = ieqs, guesses = guesses)) + end + + if has_assertions(basesys) + kwargs = merge( + kwargs, (; assertions = merge(get_assertions(basesys), get_assertions(sys)))) + end + + return T(args...; kwargs...) +end + +function extend(sys, basesys::Vector{T}) where {T <: AbstractSystem} + foldl(extend, basesys, init = sys) +end + +function Base.:(&)(sys::AbstractSystem, basesys::AbstractSystem; kwargs...) + extend(sys, basesys; kwargs...) +end + +function Base.:(&)( + sys::AbstractSystem, basesys::Vector{T}; kwargs...) where {T <: AbstractSystem} + extend(sys, basesys; kwargs...) +end + +""" +$(SIGNATURES) + +Compose multiple systems together. The resulting system would inherit the first +system's name. + +See also [`extend`](@ref). +""" +function compose(sys::AbstractSystem, systems::AbstractArray; name = nameof(sys)) + nsys = length(systems) + nsys == 0 && return sys + @set! sys.name = name + @set! sys.systems = [get_systems(sys); systems] + if has_is_dde(sys) + @set! sys.is_dde = _check_if_dde(equations(sys), get_iv(sys), get_systems(sys)) + end + newunknowns = OrderedSet() + newparams = OrderedSet() + iv = has_iv(sys) ? get_iv(sys) : nothing + for ssys in systems + collect_scoped_vars!(newunknowns, newparams, ssys, iv) + end + @set! sys.unknowns = unique!(vcat(get_unknowns(sys), collect(newunknowns))) + @set! sys.ps = unique!(vcat(get_ps(sys), collect(newparams))) + return sys +end +function compose(syss...; name = nameof(first(syss))) + compose(first(syss), collect(syss[2:end]); name = name) +end +Base.:(∘)(sys1::AbstractSystem, sys2::AbstractSystem) = compose(sys1, sys2) + +function UnPack.unpack(sys::ModelingToolkit.AbstractSystem, ::Val{p}) where {p} + getproperty(sys, p; namespace = false) +end + +""" + missing_variable_defaults(sys::AbstractSystem, default = 0.0; subset = unknowns(sys)) + +Returns a `Vector{Pair}` of variables set to `default` which are missing from `get_defaults(sys)`. The `default` argument can be a single value or vector to set the missing defaults respectively. +""" +function missing_variable_defaults( + sys::AbstractSystem, default = 0.0; subset = unknowns(sys)) + varmap = get_defaults(sys) + varmap = Dict(Symbolics.diff2term(value(k)) => value(varmap[k]) for k in keys(varmap)) + missingvars = setdiff(subset, keys(varmap)) + ds = Pair[] + + n = length(missingvars) + + if default isa Vector + @assert length(default)==n "`default` size ($(length(default))) should match the number of missing variables: $n" + end + + for (i, missingvar) in enumerate(missingvars) + if default isa Vector + push!(ds, missingvar => default[i]) + else + push!(ds, missingvar => default) + end + end + + return ds +end + +keytype(::Type{<:Pair{T, V}}) where {T, V} = T +function Symbolics.substitute(sys::AbstractSystem, rules::Union{Vector{<:Pair}, Dict}) + if has_continuous_domain(sys) && get_continuous_events(sys) !== nothing && + !isempty(get_continuous_events(sys)) || + has_discrete_events(sys) && get_discrete_events(sys) !== nothing && + !isempty(get_discrete_events(sys)) + @warn "`substitute` only supports performing substitutions in equations. This system has events, which will not be updated." + end + if keytype(eltype(rules)) <: Symbol + dict = todict(rules) + systems = get_systems(sys) + # post-walk to avoid infinite recursion + @set! sys.systems = map(Base.Fix2(substitute, dict), systems) + something(get(rules, nameof(sys), nothing), sys) + elseif sys isa ODESystem + rules = todict(map(r -> Symbolics.unwrap(r[1]) => Symbolics.unwrap(r[2]), + collect(rules))) + eqs = fast_substitute(get_eqs(sys), rules) + pdeps = fast_substitute(get_parameter_dependencies(sys), rules) + defs = Dict(fast_substitute(k, rules) => fast_substitute(v, rules) + for (k, v) in get_defaults(sys)) + guess = Dict(fast_substitute(k, rules) => fast_substitute(v, rules) + for (k, v) in get_guesses(sys)) + subsys = map(s -> substitute(s, rules), get_systems(sys)) + ODESystem(eqs, get_iv(sys); name = nameof(sys), defaults = defs, + guesses = guess, parameter_dependencies = pdeps, systems = subsys) + else + error("substituting symbols is not supported for $(typeof(sys))") + end +end + +struct InvalidParameterDependenciesType + got::Any +end + +function Base.showerror(io::IO, err::InvalidParameterDependenciesType) + print( + io, "Parameter dependencies must be a `Dict`, or an array of `Pair` or `Equation`.") + if err.got !== nothing + print(io, " Got ", err.got) + end +end + +function process_parameter_dependencies(pdeps, ps) + if pdeps === nothing || isempty(pdeps) + return Equation[], ps + end + if pdeps isa Dict + pdeps = [k ~ v for (k, v) in pdeps] + else + pdeps isa AbstractArray || throw(InvalidParameterDependenciesType(pdeps)) + pdeps = [if p isa Pair + p[1] ~ p[2] + elseif p isa Equation + p + else + error("Parameter dependencies must be a `Dict`, `Vector{Pair}` or `Vector{Equation}`") + end + for p in pdeps] + end + lhss = [] + for p in pdeps + if !isparameter(p.lhs) + error("LHS of parameter dependency must be a single parameter. Found $(p.lhs).") + end + syms = vars(p.rhs) + if !all(isparameter, syms) + error("RHS of parameter dependency must only include parameters. Found $(p.rhs)") + end + push!(lhss, p.lhs) + end + lhss = map(identity, lhss) + pdeps = topsort_equations(pdeps, union(ps, lhss)) + ps = filter(ps) do p + !any(isequal(p), lhss) + end + return pdeps, ps +end + +""" + dump_parameters(sys::AbstractSystem) + +Return an array of `NamedTuple`s containing the metadata associated with each parameter in +`sys`. Also includes the default value of the parameter, if provided. + +```@example +using ModelingToolkit +using DynamicQuantities +using ModelingToolkit: t, D + +@parameters p = 1.0, [description = "My parameter", tunable = false] q = 2.0, [description = "Other parameter"] +@variables x(t) = 3.0 [unit = u"m"] +@named sys = ODESystem(Equation[], t, [x], [p, q]) +ModelingToolkit.dump_parameters(sys) +``` + +See also: [`ModelingToolkit.dump_variable_metadata`](@ref), [`ModelingToolkit.dump_unknowns`](@ref) +""" +function dump_parameters(sys::AbstractSystem) + defs = defaults(sys) + pdeps = parameter_dependencies(sys) + metas = map(dump_variable_metadata.(parameters(sys))) do meta + if haskey(defs, meta.var) + meta = merge(meta, (; default = defs[meta.var])) + end + meta + end + pdep_metas = map(pdeps) do eq + sym = eq.lhs + val = eq.rhs + meta = dump_variable_metadata(sym) + defs[eq.lhs] = eq.rhs + meta = merge(meta, + (; dependency = val, + default = symbolic_evaluate(val, defs))) + return meta + end + return vcat(metas, pdep_metas) +end + +""" + dump_unknowns(sys::AbstractSystem) + +Return an array of `NamedTuple`s containing the metadata associated with each unknown in +`sys`. Also includes the default value of the unknown, if provided. + +```@example +using ModelingToolkit +using DynamicQuantities +using ModelingToolkit: t, D + +@parameters p = 1.0, [description = "My parameter", tunable = false] q = 2.0, [description = "Other parameter"] +@variables x(t) = 3.0 [unit = u"m"] +@named sys = ODESystem(Equation[], t, [x], [p, q]) +ModelingToolkit.dump_unknowns(sys) +``` + +See also: [`ModelingToolkit.dump_variable_metadata`](@ref), [`ModelingToolkit.dump_parameters`](@ref) +""" +function dump_unknowns(sys::AbstractSystem) + defs = varmap_with_toterm(defaults(sys)) + gs = varmap_with_toterm(guesses(sys)) + map(dump_variable_metadata.(unknowns(sys))) do meta + if haskey(defs, meta.var) + meta = merge(meta, (; default = defs[meta.var])) + end + if haskey(gs, meta.var) + meta = merge(meta, (; guess = gs[meta.var])) + end + meta + end +end + +""" + $(TYPEDSIGNATURES) + +Return the variable in `sys` referred to by its string representation `str`. +Roughly supports the following CFG: + +``` +varname = "D(" varname ")" | "Differential(" iv ")(" varname ")" | arrvar | maybe_dummy_var +arrvar = maybe_dummy_var "[idxs...]" +idxs = int | int "," idxs +maybe_dummy_var = namespacedvar | namespacedvar "(" iv ")" | + namespacedvar "(" iv ")" "ˍ" ts | namespacedvar "ˍ" ts | + namespacedvar "ˍ" ts "(" iv ")" +ts = iv | iv ts +namespacedvar = ident "₊" namespacedvar | ident "." namespacedvar | ident +``` + +Where `iv` is the independent variable, `int` is an integer and `ident` is an identifier. +""" +function parse_variable(sys::AbstractSystem, str::AbstractString) + iv = has_iv(sys) ? string(getname(get_iv(sys))) : nothing + + # I'd write a regex to validate `str`, but https://xkcd.com/1171/ + str = strip(str) + derivative_level = 0 + while ((cond1 = startswith(str, "D(")) || startswith(str, "Differential(")) && + endswith(str, ")") + if cond1 + derivative_level += 1 + str = _string_view_inner(str, 2, 1) + continue + end + _tmpstr = _string_view_inner(str, 13, 1) + if !startswith(_tmpstr, "$iv)(") + throw(ArgumentError("Expected differential with respect to independent variable $iv in $str")) + end + derivative_level += 1 + str = _string_view_inner(_tmpstr, length(iv) + 2, 0) + end + + arr_idxs = nothing + if endswith(str, ']') + open_idx = only(findfirst('[', str)) + idxs_range = nextind(str, open_idx):prevind(str, lastindex(str)) + idxs_str = view(str, idxs_range) + str = view(str, firstindex(str):prevind(str, open_idx)) + arr_idxs = map(Base.Fix1(parse, Int), eachsplit(idxs_str, ",")) + end + + if iv !== nothing && endswith(str, "($iv)") + str = _string_view_inner(str, 0, 2 + length(iv)) + end + + dummyderivative_level = 0 + if iv !== nothing && (dd_idx = findfirst('ˍ', str)) !== nothing + t_idx = findnext(iv, str, dd_idx) + while t_idx !== nothing + dummyderivative_level += 1 + t_idx = findnext(iv, str, nextind(str, last(t_idx))) + end + str = view(str, firstindex(str):prevind(str, dd_idx)) + end + + if iv !== nothing && endswith(str, "($iv)") + str = _string_view_inner(str, 0, 2 + length(iv)) + end + + cur = sys + for ident in eachsplit(str, ('.', NAMESPACE_SEPARATOR)) + ident = Symbol(ident) + hasproperty(cur, ident) || + throw(ArgumentError("System $(nameof(cur)) does not have a subsystem/variable named $(ident)")) + cur = getproperty(cur, ident) + end + + if arr_idxs !== nothing + cur = cur[arr_idxs...] + end + + for i in 1:(derivative_level + dummyderivative_level) + cur = Differential(get_iv(sys))(cur) + end + + return cur +end + +function _string_view_inner(str, startoffset, endoffset) + view(str, + nextind(str, firstindex(str), startoffset):prevind(str, lastindex(str), endoffset)) +end + +### Functions for accessing algebraic/differential equations in systems ### + +""" + is_diff_equation(eq) + +Return `true` if the input is a differential equation, i.e. an equation that contains a +differential term. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X + +is_diff_equation(eq1) # true +is_diff_equation(eq2) # false +``` +""" +function is_diff_equation(eq) + (eq isa Equation) || (return false) + isdefined(eq, :lhs) && hasnode(is_derivative, wrap(eq.lhs)) && + (return true) + isdefined(eq, :rhs) && hasnode(is_derivative, wrap(eq.rhs)) && + (return true) + return false +end + +""" + is_alg_equation(eq) + +Return `true` if the input is an algebraic equation, i.e. an equation that does not contain +any differentials. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X + +is_alg_equation(eq1) # false +is_alg_equation(eq2) # true +``` +""" +function is_alg_equation(eq) + return (eq isa Equation) && !is_diff_equation(eq) +end + +""" + alg_equations(sys::AbstractSystem) + +For a system, returns a vector of all its algebraic equations (i.e. that does not contain any +differentials). + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys = ODESystem([eq1, eq2], t) + +alg_equations(osys) # returns `[0 ~ p - d*X(t)]`. +""" +alg_equations(sys::AbstractSystem) = filter(is_alg_equation, equations(sys)) + +""" + diff_equations(sys::AbstractSystem) + +For a system, returns a vector of all its differential equations (i.e. that does contain a differential). + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys = ODESystem([eq1, eq2], t) + +diff_equations(osys) # returns `[Differential(t)(X(t)) ~ p - d*X(t)]`. +""" +diff_equations(sys::AbstractSystem) = filter(is_diff_equation, equations(sys)) + +""" + has_alg_equations(sys::AbstractSystem) + +For a system, returns true if it contain at least one algebraic equation (i.e. that does not contain any +differentials). + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) + +has_alg_equations(osys1) # returns `false`. +has_alg_equations(osys2) # returns `true`. +``` +""" +has_alg_equations(sys::AbstractSystem) = any(is_alg_equation, equations(sys)) + +""" + has_diff_equations(sys::AbstractSystem) + +For a system, returns true if it contain at least one differential equation (i.e. that contain a differential). + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) + +has_diff_equations(osys1) # returns `true`. +has_diff_equations(osys2) # returns `false`. +``` +""" +has_diff_equations(sys::AbstractSystem) = any(is_diff_equation, equations(sys)) + +""" + get_alg_eqs(sys::AbstractSystem) + +For a system, returns a vector of all algebraic equations (i.e. that does not contain any +differentials) in its *top-level system*. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) +osys12 = compose(osys1, [osys2]) +osys21 = compose(osys2, [osys1]) + +get_alg_eqs(osys12) # returns `Equation[]`. +get_alg_eqs(osys21) # returns `[0 ~ p - d*X(t)]`. +``` +""" +get_alg_eqs(sys::AbstractSystem) = filter(is_alg_equation, get_eqs(sys)) + +""" + get_diff_eqs(sys::AbstractSystem) + +For a system, returns a vector of all differential equations (i.e. that does contain a differential) +in its *top-level system*. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) +osys12 = compose(osys1, [osys2]) +osys21 = compose(osys2, [osys1]) + +get_diff_eqs(osys12) # returns `[Differential(t)(X(t)) ~ p - d*X(t)]`. +get_diff_eqs(osys21) # returns `Equation[]``. +``` +""" +get_diff_eqs(sys::AbstractSystem) = filter(is_diff_equation, get_eqs(sys)) + +""" + has_alg_eqs(sys::AbstractSystem) + +For a system, returns true if it contain at least one algebraic equation (i.e. that does not contain any +differentials) in its *top-level system*. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) +osys12 = compose(osys1, [osys2]) +osys21 = compose(osys2, [osys1]) + +has_alg_eqs(osys12) # returns `false`. +has_alg_eqs(osys21) # returns `true`. +``` +""" +has_alg_eqs(sys::AbstractSystem) = any(is_alg_equation, get_eqs(sys)) + +""" + has_diff_eqs(sys::AbstractSystem) + +Return `true` if a system contains at least one differential equation (i.e. an equation with a +differential term). Note that this does not consider subsystems, and only takes into account +equations in the top-level system. + +Example: +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@parameters p d +@variables X(t) +eq1 = D(X) ~ p - d*X +eq2 = 0 ~ p - d*X +@named osys1 = ODESystem([eq1], t) +@named osys2 = ODESystem([eq2], t) +osys12 = compose(osys1, [osys2]) +osys21 = compose(osys2, [osys1]) + +has_diff_eqs(osys12) # returns `true`. +has_diff_eqs(osys21) # returns `false`. +``` +""" +has_diff_eqs(sys::AbstractSystem) = any(is_diff_equation, get_eqs(sys)) + +""" + $(TYPEDSIGNATURES) + +Validate the rules for replacement of subcomponents as defined in `substitute_component`. +""" +function validate_replacement_rule( + rule::Pair{T, T}; namespace = []) where {T <: AbstractSystem} + lhs, rhs = rule + + iscomplete(lhs) && throw(ArgumentError("LHS of replacement rule cannot be completed.")) + iscomplete(rhs) && throw(ArgumentError("RHS of replacement rule cannot be completed.")) + + rhs_h = namespace_hierarchy(nameof(rhs)) + if length(rhs_h) != 1 + throw(ArgumentError("RHS of replacement rule must not be namespaced.")) + end + rhs_h[1] == namespace_hierarchy(nameof(lhs))[end] || + throw(ArgumentError("LHS and RHS must have the same name.")) + + if !isequal(get_iv(lhs), get_iv(rhs)) + throw(ArgumentError("LHS and RHS of replacement rule must have the same independent variable.")) + end + + lhs_u = get_unknowns(lhs) + rhs_u = Dict(get_unknowns(rhs) .=> nothing) + for u in lhs_u + if !haskey(rhs_u, u) + if isempty(namespace) + throw(ArgumentError("RHS of replacement rule does not contain unknown $u.")) + else + throw(ArgumentError("Subsystem $(join([namespace; nameof(lhs)], NAMESPACE_SEPARATOR)) of RHS does not contain unknown $u.")) + end + end + ru = getkey(rhs_u, u, nothing) + name = join([namespace; nameof(lhs); (hasname(u) ? getname(u) : Symbol(u))], + NAMESPACE_SEPARATOR) + l_connect = something(getconnect(u), Equality) + r_connect = something(getconnect(ru), Equality) + if l_connect != r_connect + throw(ArgumentError("Variable $(name) should have connection metadata $(l_connect),")) + end + + l_input = isinput(u) + r_input = isinput(ru) + if l_input != r_input + throw(ArgumentError("Variable $name has differing causality. Marked as `input = $l_input` in LHS and `input = $r_input` in RHS.")) + end + l_output = isoutput(u) + r_output = isoutput(ru) + if l_output != r_output + throw(ArgumentError("Variable $name has differing causality. Marked as `output = $l_output` in LHS and `output = $r_output` in RHS.")) + end + end + + lhs_p = get_ps(lhs) + rhs_p = Set(get_ps(rhs)) + for p in lhs_p + if !(p in rhs_p) + if isempty(namespace) + throw(ArgumentError("RHS of replacement rule does not contain parameter $p")) + else + throw(ArgumentError("Subsystem $(join([namespace; nameof(lhs)], NAMESPACE_SEPARATOR)) of RHS does not contain parameter $p.")) + end + end + end + + lhs_s = get_systems(lhs) + rhs_s = Dict(nameof(s) => s for s in get_systems(rhs)) + + for s in lhs_s + if haskey(rhs_s, nameof(s)) + rs = rhs_s[nameof(s)] + if isconnector(s) + name = join([namespace; nameof(lhs); nameof(s)], NAMESPACE_SEPARATOR) + if !isconnector(rs) + throw(ArgumentError("Subsystem $name of RHS is not a connector.")) + end + if (lct = get_connector_type(s)) !== (rct = get_connector_type(rs)) + throw(ArgumentError("Subsystem $name of RHS has connection type $rct but LHS has $lct.")) + end + end + validate_replacement_rule(s => rs; namespace = [namespace; nameof(rhs)]) + continue + end + name1 = join([namespace; nameof(lhs)], NAMESPACE_SEPARATOR) + throw(ArgumentError("$name1 of replacement rule does not contain subsystem $(nameof(s)).")) + end +end + +""" + $(TYPEDSIGNATURES) + +Chain `getproperty` calls on `root` in the order given in `hierarchy`. + +# Keyword Arguments + +- `skip_namespace_first`: Whether to avoid namespacing in the first `getproperty` call. +""" +function recursive_getproperty( + root::AbstractSystem, hierarchy::Vector{Symbol}; skip_namespace_first = true) + cur = root + for (i, name) in enumerate(hierarchy) + cur = getproperty(cur, name; namespace = i > 1 || !skip_namespace_first) + end + return unwrap(cur) +end + +""" + $(TYPEDSIGNATURES) + +Recursively descend through `sys`, finding all connection equations and re-creating them +using the names of the involved variables/systems and finding the required variables/ +systems in the hierarchy. +""" +function recreate_connections(sys::AbstractSystem) + eqs = map(get_eqs(sys)) do eq + eq.lhs isa Union{Connection, AnalysisPoint} || return eq + if eq.lhs isa Connection + oldargs = get_systems(eq.rhs) + else + ap::AnalysisPoint = eq.rhs + oldargs = [ap.input; ap.outputs] + end + newargs = map(get_systems(eq.rhs)) do arg + rewrap_nameof = arg isa SymbolicWithNameof + if rewrap_nameof + arg = arg.var + end + name = arg isa AbstractSystem ? nameof(arg) : getname(arg) + hierarchy = namespace_hierarchy(name) + newarg = recursive_getproperty(sys, hierarchy) + if rewrap_nameof + newarg = SymbolicWithNameof(newarg) + end + return newarg + end + if eq.lhs isa Connection + return eq.lhs ~ Connection(newargs) + else + return eq.lhs ~ AnalysisPoint(newargs[1], eq.rhs.name, newargs[2:end]) + end + end + @set! sys.eqs = eqs + @set! sys.systems = map(recreate_connections, get_systems(sys)) + return sys +end + +""" + $(TYPEDSIGNATURES) + +Given a hierarchical system `sys` and a rule `lhs => rhs`, replace the subsystem `lhs` in +`sys` by `rhs`. The `lhs` must be the namespaced version of a subsystem of `sys` (e.g. +obtained via `sys.inner.component`). The `rhs` must be valid as per the following +conditions: + +1. `rhs` must not be namespaced. +2. The name of `rhs` must be the same as the unnamespaced name of `lhs`. +3. Neither one of `lhs` or `rhs` can be marked as complete. +4. Both `lhs` and `rhs` must share the same independent variable. +5. `rhs` must contain at least all of the unknowns and parameters present in + `lhs`. +6. Corresponding unknowns in `rhs` must share the same connection and causality + (input/output) metadata as their counterparts in `lhs`. +7. For each subsystem of `lhs`, there must be an identically named subsystem of `rhs`. + These two corresponding subsystems must satisfy conditions 3, 4, 5, 6, 7. If the + subsystem of `lhs` is a connector, the corresponding subsystem of `rhs` must also + be a connector of the same type. + +`sys` also cannot be marked as complete. +""" +function substitute_component(sys::T, rule::Pair{T, T}) where {T <: AbstractSystem} + iscomplete(sys) && + throw(ArgumentError("Cannot replace subsystems of completed systems")) + + validate_replacement_rule(rule) + + lhs, rhs = rule + hierarchy = namespace_hierarchy(nameof(lhs)) + + newsys, _ = modify_nested_subsystem(sys, hierarchy) do inner + return rhs, () + end + return recreate_connections(newsys) +end diff --git a/src/systems/alias_elimination.jl b/src/systems/alias_elimination.jl index 38e87612b1..fb4fedc920 100644 --- a/src/systems/alias_elimination.jl +++ b/src/systems/alias_elimination.jl @@ -1,363 +1,386 @@ using SymbolicUtils: Rewriters +using Graphs.Experimental.Traversals -const KEEP = typemin(Int) +function alias_eliminate_graph!(state::TransformationState; kwargs...) + mm = linear_subsys_adjmat!(state; kwargs...) + if size(mm, 1) == 0 + return mm # No linear subsystems + end -function alias_elimination(sys) - sys = initialize_system_structure(sys) - s = structure(sys) - is_linear_equations, eadj, cadj = find_linear_equations(sys) + @unpack graph, var_to_diff, solvable_graph = state.structure + mm = alias_eliminate_graph!(state, mm) + s = state.structure + for g in (s.graph, s.solvable_graph) + g === nothing && continue + for (ei, e) in enumerate(mm.nzrows) + set_neighbors!(g, e, mm.row_cols[ei]) + end + end - v_eliminated, v_types, n_null_vars, degenerate_equations, linear_equations = alias_eliminate_graph( - s, is_linear_equations, eadj, cadj - ) + return mm +end - s = structure(sys) - @unpack fullvars, graph = s +# For debug purposes +function aag_bareiss(sys::AbstractSystem) + state = TearingState(sys) + complete!(state.structure) + mm = linear_subsys_adjmat!(state) + return aag_bareiss!(state.structure.graph, state.structure.var_to_diff, mm) +end - n_reduced_states = length(v_eliminated) - n_null_vars - subs = OrderedDict() - if n_reduced_states > 0 - for (i, v) in enumerate(@view v_eliminated[n_null_vars+1:end]) - subs[fullvars[v]] = iszeroterm(v_types, v) ? 0.0 : - isalias(v_types, v) ? fullvars[alias(v_types, v)] : - -fullvars[negalias(v_types, v)] +function extreme_var(var_to_diff, v, level = nothing, ::Val{descend} = Val(true); + callback = _ -> nothing) where {descend} + g = descend ? invview(var_to_diff) : var_to_diff + callback(v) + while (v′ = g[v]) !== nothing + v::Int = v′ + callback(v) + if level !== nothing + descend ? (level -= 1) : (level += 1) end end + level === nothing ? v : (v => level) +end - dels = Set{Int}() - eqs = copy(equations(sys)) - for (ei, e) in enumerate(linear_equations) +alias_elimination(sys) = alias_elimination!(TearingState(sys))[1] +function alias_elimination!(state::TearingState; kwargs...) + sys = state.sys + complete!(state.structure) + graph_orig = copy(state.structure.graph) + mm = alias_eliminate_graph!(state; kwargs...) + + fullvars = state.fullvars + @unpack var_to_diff, graph, solvable_graph = state.structure + + subs = Dict() + obs = Equation[] + # If we encounter y = -D(x), then we need to expand the derivative when + # D(y) appears in the equation, so that D(-D(x)) becomes -D(D(x)). + to_expand = Int[] + diff_to_var = invview(var_to_diff) + + dels = Int[] + eqs = collect(equations(state)) + resize!(eqs, nsrcs(graph)) + for (ei, e) in enumerate(mm.nzrows) vs = 𝑠neighbors(graph, e) if isempty(vs) + # remove empty equations push!(dels, e) else - rhs = 0 - for vj in eachindex(vs) - var = fullvars[vs[vj]] - rhs += cadj[ei][vj] * var + rhs = mapfoldl(+, pairs(nonzerosmap(@view mm[ei, :]))) do (var, coeff) + iszero(coeff) && return 0 + return coeff * fullvars[var] end eqs[e] = 0 ~ rhs end end - dels = sort(collect(dels)) - deleteat!(eqs, dels) - - dict = Dict(subs) - for (ieq, eq) in enumerate(eqs) - eqs[ieq] = eq.lhs ~ fixpoint_sub(eq.rhs, dict) - end - - newstates = [] - sts = states(sys) - for j in eachindex(fullvars) - if isirreducible(v_types, j) - isdervar(s, j) || push!(newstates, fullvars[j]) + deleteat!(eqs, sort!(dels)) + old_to_new_eq = Vector{Int}(undef, nsrcs(graph)) + idx = 0 + cursor = 1 + ndels = length(dels) + for i in eachindex(old_to_new_eq) + if cursor <= ndels && i == dels[cursor] + cursor += 1 + old_to_new_eq[i] = -1 + continue end + idx += 1 + old_to_new_eq[i] = idx end - @set! sys.eqs = eqs - @set! sys.states = newstates - @set! sys.observed = [observed(sys); [lhs ~ rhs for (lhs, rhs) in pairs(subs)]] - @set! sys.structure = nothing - return sys -end - -function alias_eliminate_graph(s::SystemStructure, is_linear_equations, eadj, cadj) - @unpack graph, varassoc = s - invvarassoc = inverse_mapping(varassoc) - - old_cadj = map(copy, cadj) + n_new_eqs = idx - is_not_potential_state = iszero.(varassoc) - is_linear_variables = copy(is_not_potential_state) - for i in 𝑠vertices(graph); is_linear_equations[i] && continue - for j in 𝑠neighbors(graph, i) - is_linear_variables[j] = false - end + lineqs = BitSet(mm.nzrows) + eqs_to_update = BitSet() + nvs_orig = ndsts(graph_orig) + for ieq in eqs_to_update + eq = eqs[ieq] + eqs[ieq] = fast_substitute(eq, subs) end - solvable_variables = findall(is_linear_variables) - - linear_equations = findall(is_linear_equations) - - rank1 = bareiss!( - (eadj, cadj), - old_cadj, linear_equations, is_linear_variables, 1 - ) - - v_solved = [eadj[i][1] for i in 1:rank1] - v_eliminated = setdiff(solvable_variables, v_solved) - n_null_vars = length(v_eliminated) - - v_types = fill(KEEP, ndsts(graph)) - for v in v_eliminated - v_types[v] = 0 + @set! mm.nparentrows = nsrcs(graph) + @set! mm.row_cols = eltype(mm.row_cols)[mm.row_cols[i] + for (i, eq) in enumerate(mm.nzrows) + if old_to_new_eq[eq] > 0] + @set! mm.row_vals = eltype(mm.row_vals)[mm.row_vals[i] + for (i, eq) in enumerate(mm.nzrows) + if old_to_new_eq[eq] > 0] + @set! mm.nzrows = Int[old_to_new_eq[eq] for eq in mm.nzrows if old_to_new_eq[eq] > 0] + + for old_ieq in to_expand + ieq = old_to_new_eq[old_ieq] + eqs[ieq] = expand_derivatives(eqs[ieq]) end - rank2 = bareiss!( - (eadj, cadj), - old_cadj, linear_equations, is_not_potential_state, rank1+1 - ) - - rank3 = bareiss!( - (eadj, cadj), - old_cadj, linear_equations, nothing, rank2+1 - ) - - # kind of like the backward substitution - for ei in reverse(1:rank2) - locally_structure_simplify!( - (eadj[ei], old_cadj[ei]), - invvarassoc, v_eliminated, v_types - ) + diff_to_var = invview(var_to_diff) + new_graph = BipartiteGraph(n_new_eqs, ndsts(graph)) + new_solvable_graph = BipartiteGraph(n_new_eqs, ndsts(graph)) + new_eq_to_diff = DiffGraph(n_new_eqs) + eq_to_diff = state.structure.eq_to_diff + for (i, ieq) in enumerate(old_to_new_eq) + ieq > 0 || continue + set_neighbors!(new_graph, ieq, 𝑠neighbors(graph, i)) + set_neighbors!(new_solvable_graph, ieq, 𝑠neighbors(solvable_graph, i)) + new_eq_to_diff[ieq] = eq_to_diff[i] end - reduced = false - for ei in 1:rank2 - if length(cadj[ei]) >= length(old_cadj[ei]) - cadj[ei] = old_cadj[ei] - else - # MEMORY ALIAS of a vector - eadj[ei] = 𝑠neighbors(graph, linear_equations[ei]) - reduced |= locally_structure_simplify!( - (eadj[ei], cadj[ei]), - invvarassoc, v_eliminated, v_types - ) - end + # update DiffGraph + new_var_to_diff = DiffGraph(length(var_to_diff)) + for v in 1:length(var_to_diff) + new_var_to_diff[v] = var_to_diff[v] end + state.structure.graph = new_graph + state.structure.solvable_graph = new_solvable_graph + state.structure.eq_to_diff = new_eq_to_diff + state.structure.var_to_diff = new_var_to_diff - while reduced - for ei in 1:rank2 - if !isempty(eadj[ei]) - reduced |= locally_structure_simplify!( - (eadj[ei], cadj[ei]), - invvarassoc, v_eliminated, v_types - ) - reduced && break # go back to the begining of equations - end - end - end + sys = state.sys + @set! sys.eqs = eqs + state.sys = sys + return invalidate_cache!(sys), mm +end - for ei in rank2+1:length(linear_equations) - cadj[ei] = old_cadj[ei] - end +""" +$(SIGNATURES) - for (ei, e) in enumerate(linear_equations) - graph.fadjlist[e] = eadj[ei] +Find the first linear variable such that `𝑠neighbors(adj, i)[j]` is true given +the `constraint`. +""" +@inline function find_first_linear_variable(M::SparseMatrixCLIL, + range, + mask, + constraint) + eadj = M.row_cols + @inbounds for i in range + vertices = eadj[i] + if constraint(length(vertices)) + for (j, v) in enumerate(vertices) + if (mask === nothing || mask[v]) + return (CartesianIndex(i, v), M.row_vals[i][j]) + end + end + end end - - degenerate_equations = rank3 < length(linear_equations) ? linear_equations[rank3+1:end] : Int[] - return v_eliminated, v_types, n_null_vars, degenerate_equations, linear_equations + return nothing end -iszeroterm(v_types, v) = v_types[v] == 0 -isirreducible(v_types, v) = v_types[v] == KEEP -isalias(v_types, v) = v_types[v] > 0 && !isirreducible(v_types, v) -alias(v_types, v) = v_types[v] -negalias(v_types, v) = -v_types[v] - -function locally_structure_simplify!( - (vars, coeffs), - invvarassoc, v_eliminated, v_types - ) - while length(vars) > 1 && any(!isequal(KEEP), (v_types[v] for v in @view vars[2:end])) - for vj in 2:length(vars) - v = vars[vj] - if isirreducible(v_types, v) - continue - elseif iszeroterm(v_types, v) - deleteat!(vars, vj) - deleteat!(coeffs, vj) - break - else - coeff = coeffs[vj] - if isalias(v_types, v) - v = alias(v_types, v) - else - v = negalias(v_types, v) - coeff = -coeff +@inline function find_first_linear_variable(M::AbstractMatrix, + range, + mask, + constraint) + @inbounds for i in range + row = @view M[i, :] + if constraint(count(!iszero, row)) + for (v, val) in enumerate(row) + if mask === nothing || mask[v] + return CartesianIndex(i, v), val end - - has_v = false - for vi in 2:length(vars) - (vi !== vj && vars[vi] == v) || continue - has_v = true - c = (coeffs[vi] += coeff) - if c == 0 - if vi < vj - deleteat!(vars, [vi, vj]) - deleteat!(coeffs, [vi, vj]) - else - deleteat!(vars, [vj, vi]) - deleteat!(coeffs, [vj, vi]) - end - end - break - end # for vi - - if has_v - break - else - vars[vj] = v - coeffs[vj] = coeff - end # if - end # else - end # for - end # while - - v = first(vars) - if invvarassoc[v] == 0 - if length(vars) == 1 - push!(v_eliminated, v) - v_types[v] = 0 - empty!(vars); empty!(coeffs) - return true - elseif length(vars) == 2 && abs(coeffs[1]) == abs(coeffs[2]) - if (coeffs[1] > 0 && coeffs[2] < 0) || (coeffs[1] < 0 && coeffs[2] > 0) - # positive alias - push!(v_eliminated, v) - v_types[v] = vars[2] - else - # negative alias - push!(v_eliminated, v) - v_types[v] = -vars[2] end - empty!(vars); empty!(coeffs) - return true end end - return false + return nothing end -""" -$(SIGNATURES) - -Use Bareiss algorithm to compute the nullspace of an integer matrix exactly. -""" -function bareiss!( - (eadj, cadj), - old_cadj, linear_equations, is_linear_variables, offset - ) - m = length(eadj) - # v = eadj[ei][vj] - v = ei = vj = 0 - pivot = last_pivot = 1 - tmp_incidence = Int[] - tmp_coeffs = Int[] - - for k in offset:m - ### - ### Pivoting: - ### - ei, vj = find_first_linear_variable(eadj, k:m, is_linear_variables, isequal(1)) - if vj == 0 - ei, vj = find_first_linear_variable(eadj, k:m, is_linear_variables, isequal(2)) - end - if vj == 0 - ei, vj = find_first_linear_variable(eadj, k:m, is_linear_variables, _->true) - end +function find_masked_pivot(variables, M, k) + r = find_first_linear_variable(M, k:size(M, 1), variables, isequal(1)) + r !== nothing && return r + r = find_first_linear_variable(M, k:size(M, 1), variables, isequal(2)) + r !== nothing && return r + r = find_first_linear_variable(M, k:size(M, 1), variables, _ -> true) + return r +end - if vj > 0 # has a pivot - pivot = old_cadj[ei][vj] - deleteat!(old_cadj[ei] , vj) - v = eadj[ei][vj] - deleteat!(eadj[ei], vj) - if ei != k - swap!(cadj, ei, k) - swap!(old_cadj, ei, k) - swap!(eadj, ei, k) - swap!(linear_equations, ei, k) +count_nonzeros(a::AbstractArray) = count(!iszero, a) + +# N.B.: Ordinarily sparse vectors allow zero stored elements. +# Here we have a guarantee that they won't, so we can make this identification +count_nonzeros(a::CLILVector) = nnz(a) + +# Linear variables are highest order differentiated variables that only appear +# in linear equations with only linear variables. Also, if a variable's any +# derivatives is nonlinear, then all of them are not linear variables. +function find_linear_variables(graph, linear_equations, var_to_diff, irreducibles) + stack = Int[] + linear_variables = falses(length(var_to_diff)) + var_to_lineq = Dict{Int, BitSet}() + mark_not_linear! = let linear_variables = linear_variables, stack = stack, + var_to_lineq = var_to_lineq + + v -> begin + linear_variables[v] = false + push!(stack, v) + while !isempty(stack) + v = pop!(stack) + eqs = get(var_to_lineq, v, nothing) + eqs === nothing && continue + for eq in eqs, v′ in 𝑠neighbors(graph, eq) + if linear_variables[v′] + linear_variables[v′] = false + push!(stack, v′) + end + end end - else # rank deficient - return k-1 end + end + for eq in linear_equations, v in 𝑠neighbors(graph, eq) + linear_variables[v] = true + vlineqs = get!(() -> BitSet(), var_to_lineq, v) + push!(vlineqs, eq) + end + for v in irreducibles + lv = extreme_var(var_to_diff, v) + while true + mark_not_linear!(lv) + lv = var_to_diff[lv] + lv === nothing && break + end + end - for ei in k+1:m - # elimate `v` - coeff = 0 - ivars = eadj[ei] - vj = findfirst(isequal(v), ivars) - if vj === nothing # `v` is not in in `e` - continue - else # remove `v` - coeff = old_cadj[ei][vj] - deleteat!(old_cadj[ei], vj) - deleteat!(eadj[ei], vj) - end - - # the pivot row - kvars = eadj[k] - kcoeffs = old_cadj[k] - # the elimination target - ivars = eadj[ei] - icoeffs = old_cadj[ei] - - empty!(tmp_incidence) - empty!(tmp_coeffs) - vars = union(ivars, kvars) - - for v in vars - ck = getcoeff(kvars, kcoeffs, v) - ci = getcoeff(ivars, icoeffs, v) - ci = (pivot*ci - coeff*ck) ÷ last_pivot - if ci !== 0 - push!(tmp_incidence, v) - push!(tmp_coeffs, ci) + linear_equations_set = BitSet(linear_equations) + for (v, islinear) in enumerate(linear_variables) + islinear || continue + lv = extreme_var(var_to_diff, v) + oldlv = lv + remove = invview(var_to_diff)[v] !== nothing + while !remove + for eq in 𝑑neighbors(graph, lv) + if !(eq in linear_equations_set) + remove = true end end - - eadj[ei], tmp_incidence = tmp_incidence, eadj[ei] - old_cadj[ei], tmp_coeffs = tmp_coeffs, old_cadj[ei] + lv = var_to_diff[lv] + lv === nothing && break + end + lv = oldlv + remove && while true + mark_not_linear!(lv) + lv = var_to_diff[lv] + lv === nothing && break end - last_pivot = pivot - # add `v` in the front of the `k`-th equation - pushfirst!(eadj[k], v) - pushfirst!(old_cadj[k], pivot) end - return m # fully ranked + return linear_variables end -swap!(v, i, j) = v[i], v[j] = v[j], v[i] +function aag_bareiss!(structure, mm_orig::SparseMatrixCLIL{T, Ti}) where {T, Ti} + @unpack graph, var_to_diff = structure + mm = copy(mm_orig) + linear_equations_set = BitSet(mm_orig.nzrows) -function getcoeff(vars, coeffs, var) - for (vj, v) in enumerate(vars) - v == var && return coeffs[vj] + # All unassigned (not a pivot) algebraic variables that only appears in + # linear algebraic equations can be set to 0. + # + # For all the other variables, we can update the original system with + # Bareiss'ed coefficients as Gaussian elimination is nullspace preserving + # and we are only working on linear homogeneous subsystem. + + is_algebraic = let var_to_diff = var_to_diff + v -> var_to_diff[v] === nothing === invview(var_to_diff)[v] end - return 0 -end + is_linear_variables = is_algebraic.(1:length(var_to_diff)) + is_highest_diff = computed_highest_diff_variables(structure) + for i in 𝑠vertices(graph) + # only consider linear algebraic equations + (i in linear_equations_set && all(is_algebraic, 𝑠neighbors(graph, i))) && + continue + for j in 𝑠neighbors(graph, i) + is_linear_variables[j] = false + end + end + solvable_variables = findall(is_linear_variables) -""" -$(SIGNATURES) + local bar + try + bar = do_bareiss!(mm, mm_orig, is_linear_variables, is_highest_diff) + catch e + e isa OverflowError || rethrow(e) + mm = convert(SparseMatrixCLIL{BigInt, Ti}, mm_orig) + bar = do_bareiss!(mm, mm_orig, is_linear_variables, is_highest_diff) + end -Find the first linear variable such that `𝑠neighbors(adj, i)[j]` is true given -the `constraint`. -""" -@inline function find_first_linear_variable( - eadj, - range, - mask, - constraint, - ) - for i in range - vertices = eadj[i] - if constraint(length(vertices)) - for (j, v) in enumerate(vertices) - (mask === nothing || mask[v]) && return i, j + return mm, solvable_variables, bar +end + +function do_bareiss!(M, Mold, is_linear_variables, is_highest_diff) + rank1r = Ref{Union{Nothing, Int}}(nothing) + rank2r = Ref{Union{Nothing, Int}}(nothing) + find_pivot = let rank1r = rank1r + (M, k) -> begin + if rank1r[] === nothing + r = find_masked_pivot(is_linear_variables, M, k) + r !== nothing && return r + rank1r[] = k - 1 end + if rank2r[] === nothing + r = find_masked_pivot(is_highest_diff, M, k) + r !== nothing && return r + rank2r[] = k - 1 + end + # TODO: It would be better to sort the variables by + # derivative order here to enable more elimination + # opportunities. + return find_masked_pivot(nothing, M, k) + end + end + pivots = Int[] + find_and_record_pivot = let pivots = pivots + (M, k) -> begin + r = find_pivot(M, k) + r === nothing && return nothing + push!(pivots, r[1][2]) + return r end end - return 0, 0 + myswaprows! = let Mold = Mold + (M, i, j) -> begin + Mold !== nothing && swaprows!(Mold, i, j) + swaprows!(M, i, j) + end + end + bareiss_ops = ((M, i, j) -> nothing, myswaprows!, + bareiss_update_virtual_colswap_mtk!, bareiss_zero!) + + rank3, = bareiss!(M, bareiss_ops; find_pivot = find_and_record_pivot) + rank2 = something(rank2r[], rank3) + rank1 = something(rank1r[], rank2) + (rank1, rank2, rank3, pivots) end -function inverse_mapping(assoc) - invassoc = zeros(Int, length(assoc)) - for (i, v) in enumerate(assoc) - v <= 0 && continue - invassoc[v] = i +function alias_eliminate_graph!(state::TransformationState, ils::SparseMatrixCLIL) + @unpack structure = state + @unpack graph, solvable_graph, var_to_diff, eq_to_diff = state.structure + # Step 1: Perform Bareiss factorization on the adjacency matrix of the linear + # subsystem of the system we're interested in. + # + ils, solvable_variables, (rank1, rank2, rank3, pivots) = aag_bareiss!(structure, ils) + + ## Step 2: Simplify the system using the Bareiss factorization + rk1vars = BitSet(@view pivots[1:rank1]) + for v in solvable_variables + v in rk1vars && continue + @set! ils.nparentrows += 1 + push!(ils.nzrows, ils.nparentrows) + push!(ils.row_cols, [v]) + push!(ils.row_vals, [convert(eltype(ils), 1)]) + add_vertex!(graph, SRC) + add_vertex!(solvable_graph, SRC) + add_edge!(graph, ils.nparentrows, v) + add_edge!(solvable_graph, ils.nparentrows, v) + add_vertex!(eq_to_diff) end - return invassoc + + return ils +end + +function exactdiv(a::Integer, b) + d, r = divrem(a, b) + @assert r == 0 + return d end +swap!(v, i, j) = v[i], v[j] = v[j], v[i] + """ $(SIGNATURES) @@ -365,8 +388,10 @@ Use Kahn's algorithm to topologically sort observed equations. Example: ```julia -julia> @variables t x(t) y(t) z(t) k(t) -(t, x(t), y(t), z(t), k(t)) +julia> t = ModelingToolkit.t_nounits + +julia> @variables x(t) y(t) z(t) k(t) +(x(t), y(t), z(t), k(t)) julia> eqs = [ x ~ y + z @@ -381,12 +406,13 @@ julia> ModelingToolkit.topsort_equations(eqs, [x, y, z, k]) Equation(x(t), y(t) + z(t)) ``` """ -function topsort_equations(eqs, states; check=true) - graph, assigns = observed2graph(eqs, states) +function topsort_equations(eqs, unknowns; check = true) + graph, assigns = observed2graph(eqs, unknowns) neqs = length(eqs) degrees = zeros(Int, neqs) - for 𝑠eq in 1:length(eqs); var = assigns[𝑠eq] + for 𝑠eq in 1:length(eqs) + var = assigns[𝑠eq] for 𝑑eq in 𝑑neighbors(graph, var) # 𝑠eq => 𝑑eq degrees[𝑑eq] += 1 @@ -399,10 +425,11 @@ function topsort_equations(eqs, states; check=true) end idx = 0 - ordered_eqs = similar(eqs, 0); sizehint!(ordered_eqs, neqs) + ordered_eqs = similar(eqs, 0) + sizehint!(ordered_eqs, neqs) while !isempty(q) 𝑠eq = dequeue!(q) - idx+=1 + idx += 1 push!(ordered_eqs, eqs[𝑠eq]) var = assigns[𝑠eq] for 𝑑eq in 𝑑neighbors(graph, var) @@ -416,18 +443,19 @@ function topsort_equations(eqs, states; check=true) return ordered_eqs end -function observed2graph(eqs, states) - graph = BipartiteGraph(length(eqs), length(states)) - v2j = Dict(states .=> 1:length(states)) +function observed2graph(eqs, unknowns) + graph = BipartiteGraph(length(eqs), length(unknowns)) + v2j = Dict(unknowns .=> 1:length(unknowns)) # `assigns: eq -> var`, `eq` defines `var` assigns = similar(eqs, Int) for (i, eq) in enumerate(eqs) lhs_j = get(v2j, eq.lhs, nothing) - lhs_j === nothing && throw(ArgumentError("The lhs $(eq.lhs) of $eq, doesn't appear in states.")) + lhs_j === nothing && + throw(ArgumentError("The lhs $(eq.lhs) of $eq, doesn't appear in unknowns.")) assigns[i] = lhs_j - vs = vars(eq.rhs) + vs = vars(eq.rhs; op = Symbolics.Operator) for v in vs j = get(v2j, v, nothing) j !== nothing && add_edge!(graph, i, j) @@ -436,18 +464,3 @@ function observed2graph(eqs, states) return graph, assigns end - -function fixpoint_sub(x, dict) - y = substitute(x, dict) - while !isequal(x, y) - y = x - x = substitute(y, dict) - end - - return x -end - -function substitute_aliases(eqs, dict) - sub = Base.Fix2(fixpoint_sub, dict) - map(eq->eq.lhs ~ sub(eq.rhs), eqs) -end diff --git a/src/systems/analysis_points.jl b/src/systems/analysis_points.jl new file mode 100644 index 0000000000..0d1a2830cf --- /dev/null +++ b/src/systems/analysis_points.jl @@ -0,0 +1,1039 @@ +""" + $(TYPEDEF) + AnalysisPoint(input, name::Symbol, outputs::Vector) + +Create an AnalysisPoint for linear analysis. Analysis points can be created by calling + +``` +connect(out, :ap_name, in...) +``` + +Where `out` is the output being connected to the inputs `in...`. All involved +connectors (input and outputs) are required to either have an unknown named +`u` or a single unknown, all of which should have the same size. + +See also [`get_sensitivity`](@ref), [`get_comp_sensitivity`](@ref), [`get_looptransfer`](@ref), [`open_loop`](@ref) + +# Fields + +$(TYPEDFIELDS) + +# Example + +```julia +using ModelingToolkit +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: t_nounits as t + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) +t = ModelingToolkit.get_iv(P) + +eqs = [connect(P.output, C.input) + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :feedback_system) + +matrices_S, _ = get_sensitivity(sys, :plant_input) # Compute the matrices of a state-space representation of the (input) sensitivity function. +matrices_T, _ = get_comp_sensitivity(sys, :plant_input) +``` + +Continued linear analysis and design can be performed using ControlSystemsBase.jl. +Create `ControlSystemsBase.StateSpace` objects using + +```julia +using ControlSystemsBase, Plots +S = ss(matrices_S...) +T = ss(matrices_T...) +bodeplot([S, T], lab = ["S" "T"]) +``` + +The sensitivity functions obtained this way should be equivalent to the ones obtained with the code below + +```julia +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +``` +""" +struct AnalysisPoint + """ + The input to the connection. In the context of ModelingToolkitStandardLibrary.jl, + this is a `RealOutput` connector. + """ + input::Any + """ + The name of the analysis point. + """ + name::Symbol + """ + The outputs of the connection. In the context of ModelingToolkitStandardLibrary.jl, + these are all `RealInput` connectors. + """ + outputs::Union{Nothing, Vector{Any}} + + function AnalysisPoint(input, name::Symbol, outputs; verbose = true) + # input to analysis point should be an output variable + if verbose && input !== nothing + var = ap_var(input) + isoutput(var) || ap_warning(1, name, true) + end + # outputs of analysis points should be input variables + if verbose && outputs !== nothing + for (i, output) in enumerate(outputs) + var = ap_var(output) + isinput(var) || ap_warning(2 + i, name, false) + end + end + + return new(input, name, outputs) + end +end + +function ap_warning(arg::Int, name::Symbol, should_be_output) + causality = should_be_output ? "output" : "input" + @warn """ + The $(arg)-th argument to analysis point $(name) was not a $causality. This is supported in \ + order to handle inverse models, but may not be what you intended. + + If you are building a forward mode (causal), you may want to swap this argument with \ + one on the opposite side of the name of the analysis point provided to `connect`. \ + Learn more about the causality of analysis points in the docstring for `AnalysisPoint`. \ + Silence this message using `connect(out, :name, in...; warn = false)`. + """ +end + +AnalysisPoint() = AnalysisPoint(nothing, Symbol(), nothing) +""" + $(TYPEDSIGNATURES) + +Create an `AnalysisPoint` with the given name, with no input or outputs specified. +""" +AnalysisPoint(name::Symbol) = AnalysisPoint(nothing, name, nothing) + +Base.nameof(ap::AnalysisPoint) = ap.name + +Base.show(io::IO, ap::AnalysisPoint) = show(io, MIME"text/plain"(), ap) +function Base.show(io::IO, ::MIME"text/plain", ap::AnalysisPoint) + if ap.input === nothing + print(io, "0") + return + end + if get(io, :compact, false) + print(io, + "AnalysisPoint($(ap_var(ap.input)), $(ap_var.(ap.outputs)); name=$(ap.name))") + else + print(io, "AnalysisPoint(") + printstyled(io, ap.name, color = :cyan) + if ap.input !== nothing && ap.outputs !== nothing + print(io, " from ") + printstyled(io, ap_var(ap.input), color = :green) + print(io, " to ") + if length(ap.outputs) == 1 + printstyled(io, ap_var(ap.outputs[1]), color = :blue) + else + printstyled(io, "[", join(ap_var.(ap.outputs), ", "), "]", color = :blue) + end + end + print(io, ")") + end +end + +@latexrecipe function f(ap::AnalysisPoint) + index --> :subscript + snakecase --> true + ap.input === nothing && return 0 + outs = Expr(:vect) + append!(outs.args, ap_var.(ap.outputs)) + return Expr(:call, :AnalysisPoint, ap_var(ap.input), ap.name, outs) +end + +function Base.show(io::IO, ::MIME"text/latex", ap::AnalysisPoint) + print(io, latexify(ap)) +end + +""" + $(TYPEDSIGNATURES) + +Convert an `AnalysisPoint` to a standard connection. +""" +function to_connection(ap::AnalysisPoint) + return connect(ap.input, ap.outputs...) +end + +""" + $(TYPEDSIGNATURES) + +Namespace an `AnalysisPoint` by namespacing the involved systems and the name of the point. +""" +function renamespace(sys, ap::AnalysisPoint) + return AnalysisPoint( + ap.input === nothing ? nothing : renamespace(sys, ap.input), + renamespace(sys, ap.name), + ap.outputs === nothing ? nothing : map(Base.Fix1(renamespace, sys), ap.outputs) + ) +end + +# create analysis points via `connect` +function Symbolics.connect(in, ap::AnalysisPoint, outs...; verbose = true) + return AnalysisPoint() ~ AnalysisPoint(in, ap.name, collect(outs); verbose) +end + +""" + connect(output_connector, ap_name::Symbol, input_connector; verbose = true) + connect(output_connector, ap::AnalysisPoint, input_connector; verbose = true) + +Connect `output_connector` and `input_connector` with an [`AnalysisPoint`](@ref) inbetween. +The incoming connection `output_connector` is expected to be an output connector (for +example, `ModelingToolkitStandardLibrary.Blocks.RealOutput`), and vice versa. + +*PLEASE NOTE*: The connection is assumed to be *causal*, meaning that + +```julia +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) +connect(C.output, :plant_input, P.input) +``` + +is correct, whereas + +```julia +connect(P.input, :plant_input, C.output) +``` + +typically is not (unless the model is an inverse model). + +# Arguments + +- `output_connector`: An output connector +- `input_connector`: An input connector +- `ap`: An explicitly created [`AnalysisPoint`](@ref) +- `ap_name`: If a name is given, an [`AnalysisPoint`](@ref) with the given name will be + created automatically. + +# Keyword arguments + +- `verbose`: Warn if an input is connected to an output (reverse causality). Silence this + warning if you are analyzing an inverse model. +""" +function Symbolics.connect(in::AbstractSystem, name::Symbol, out, outs...; verbose = true) + return AnalysisPoint() ~ AnalysisPoint(in, name, [out; collect(outs)]; verbose) +end + +function Symbolics.connect( + in::ConnectableSymbolicT, name::Symbol, out::ConnectableSymbolicT, + outs::ConnectableSymbolicT...; verbose = true) + allvars = (in, out, outs...) + validate_causal_variables_connection(allvars) + return AnalysisPoint() ~ AnalysisPoint( + unwrap(in), name, unwrap.([out; collect(outs)]); verbose) +end + +""" + $(TYPEDSIGNATURES) + +Return all the namespaces in `name`. Namespaces should be separated by `.` or +`$NAMESPACE_SEPARATOR`. +""" +namespace_hierarchy(name::Symbol) = map( + Symbol, split(string(name), ('.', NAMESPACE_SEPARATOR))) + +""" + $(TYPEDSIGNATURES) + +Remove all `AnalysisPoint`s in `sys` and any of its subsystems, replacing them by equivalent connections. +""" +function remove_analysis_points(sys::AbstractSystem) + eqs = map(get_eqs(sys)) do eq + eq.lhs isa AnalysisPoint ? to_connection(eq.rhs) : eq + end + @set! sys.eqs = eqs + @set! sys.systems = map(remove_analysis_points, get_systems(sys)) + + return sys +end + +""" + $(TYPEDSIGNATURES) + +Given a system involved in an `AnalysisPoint`, get the variable to be used in the +connection. This is the variable named `u` if present, and otherwise the only +variable in the system. If the system does not have a variable named `u` and +contains multiple variables, throw an error. +""" +function ap_var(sys::AbstractSystem) + if hasproperty(sys, :u) + return sys.u + end + x = unknowns(sys) + length(x) == 1 && return renamespace(sys, x[1]) + error("Could not determine the analysis-point variable in system $(nameof(sys)). To use an analysis point, apply it to a connection between causal blocks which have a variable named `u` or a single unknown of the same size.") +end + +""" + $(TYPEDSIGNATURES) + +For an `AnalysisPoint` involving causal variables. Simply return the variable. +""" +function ap_var(var::ConnectableSymbolicT) + return var +end + +""" + $(TYPEDEF) + +The supertype of all transformations that can be applied to an `AnalysisPoint`. All +concrete subtypes must implement `apply_transformation`. +""" +abstract type AnalysisPointTransformation end + +""" + apply_transformation(tf::AnalysisPointTransformation, sys::AbstractSystem) + +Apply the given analysis point transformation `tf` to the system `sys`. Throw an error if +any analysis points referred to in `tf` are not present in `sys`. Return a tuple +containing the modified system as the first element, and a tuple of the additional +variables added by the transformation as the second element. +""" +function apply_transformation end + +""" + $(TYPEDSIGNATURES) + +Given a namespaced subsystem `target` of root system `root`, return a modified copy of +`root` with `target` modified according to `fn` alongside any extra variables added +by `fn`. + +`fn` is a function which takes the instance of `target` present in the hierarchy of +`root`, and returns a 2-tuple consisting of the modified version of `target` and a tuple +of the extra variables added. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::AbstractSystem) = modify_nested_subsystem( + fn, root, nameof(target)) +""" + $(TYPEDSIGNATURES) + +Apply the modification to the system containing the namespaced analysis point `target`. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::AnalysisPoint) = modify_nested_subsystem( + fn, root, @view namespace_hierarchy(nameof(target))[1:(end - 1)]) +""" + $(TYPEDSIGNATURES) + +Apply the modification to the nested subsystem of `root` whose namespaced name matches +the provided name `target`. The namespace separator in `target` should be `.` or +`$NAMESPACE_SEPARATOR`. The `target` may include `nameof(root)` as the first namespace. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::Symbol) = modify_nested_subsystem( + fn, root, namespace_hierarchy(target)) + +""" + $(TYPEDSIGNATURES) + +Apply the modification to the nested subsystem of `root` where the name of the subsystem at +each level in the hierarchy is given by elements of `hierarchy`. For example, if +`hierarchy = [:a, :b, :c]`, the system being searched for will be `root.a.b.c`. Note that +the hierarchy may include the name of the root system, in which the first element will be +ignored. For example, `hierarchy = [:root, :a, :b, :c]` also searches for `root.a.b.c`. +An empty `hierarchy` will apply the modification to `root`. +""" +function modify_nested_subsystem( + fn, root::AbstractSystem, hierarchy::AbstractVector{Symbol}) + # no hierarchy, so just apply to the root + if isempty(hierarchy) + return fn(root) + end + # ignore the name of the root + if nameof(root) != hierarchy[1] + error("The name of the root system $(nameof(root)) must be included in the name passed to `modify_nested_subsystem`") + end + hierarchy = @view hierarchy[2:end] + + # recursive helper function which does the searching and modification + function _helper(sys::AbstractSystem, i::Int) + if i > length(hierarchy) + # we reached past the end, so everything matched and + # `sys` is the system to modify. + sys, vars = fn(sys) + else + # find the subsystem with the given name and error otherwise + cur = hierarchy[i] + idx = findfirst(subsys -> nameof(subsys) == cur, get_systems(sys)) + idx === nothing && + error("System $(join([nameof(root); hierarchy[1:i-1]], '.')) does not have a subsystem named $cur.") + + # recurse into new subsystem + newsys, vars = _helper(get_systems(sys)[idx], i + 1) + # update this system with modified subsystem + @set! sys.systems[idx] = newsys + end + # only namespace variables from inner systems + if i != 1 + vars = ntuple(Val(length(vars))) do i + renamespace(sys, vars[i]) + end + end + return sys, vars + end + + return _helper(root, 1) +end + +""" + $(TYPEDSIGNATURES) + +Given a system `sys` and analysis point `ap`, return the index in `get_eqs(sys)` +containing an equation which has as it's RHS an analysis point with name `nameof(ap)`. +""" +analysis_point_index(sys::AbstractSystem, ap::AnalysisPoint) = analysis_point_index( + sys, nameof(ap)) +""" + $(TYPEDSIGNATURES) + +Search for the analysis point with the given `name` in `get_eqs(sys)`. +""" +function analysis_point_index(sys::AbstractSystem, name::Symbol) + name = namespace_hierarchy(name)[end] + findfirst(get_eqs(sys)) do eq + eq.lhs isa AnalysisPoint && nameof(eq.rhs) == name + end +end + +""" + $(TYPEDSIGNATURES) + +Create a new variable of the same `symtype` and size as `var`, using `name` as the base +name for the new variable. `iv` denotes the independent variable of the system. Prefix +`d_` to the name of the new variable if `perturb == true`. Return the new symbolic +variable and the appropriate zero value for it. +""" +function get_analysis_variable(var, name, iv; perturb = true) + var = unwrap(var) + if perturb + name = Symbol(:d_, name) + end + if symbolic_type(var) == ArraySymbolic() + T = Array{eltype(symtype(var)), ndims(var)} + pvar = unwrap(only(@variables $name(iv)::T)) + pvar = setmetadata(pvar, Symbolics.ArrayShapeCtx, Symbolics.shape(var)) + default = zeros(eltype(symtype(var)), size(var)) + else + T = symtype(var) + pvar = unwrap(only(@variables $name(iv)::T)) + default = zero(T) + end + return pvar, default +end + +function with_analysis_point_ignored(sys::AbstractSystem, ap::AnalysisPoint) + has_ignored_connections(sys) || return sys + ignored = get_ignored_connections(sys) + if ignored === nothing + ignored = (IgnoredAnalysisPoint[], IgnoredAnalysisPoint[]) + else + ignored = copy.(ignored) + end + if ap.outputs === nothing + error("Empty analysis point") + end + + if ap.input isa AbstractSystem && all(x -> x isa AbstractSystem, ap.outputs) + push!(ignored[1], IgnoredAnalysisPoint(ap.input, ap.outputs)) + else + push!(ignored[2], IgnoredAnalysisPoint(unwrap(ap.input), unwrap.(ap.outputs))) + end + + return @set sys.ignored_connections = ignored +end + +#### PRIMITIVE TRANSFORMATIONS + +const DOC_WILL_REMOVE_AP = """ + Note that this transformation will remove `ap`, causing any subsequent transformations \ + referring to it to fail.\ + """ + +const DOC_ADDED_VARIABLE = """ + The added variable(s) will have a default of zero, of the appropriate type and size.\ + """ + +""" + $(TYPEDEF) + +A transformation which breaks the connection referred to by `ap`. If `add_input == true`, +it will add a new input variable which connects to the outputs of the analysis point. +`apply_transformation` returns the new input variable (if added) as the auxiliary +information. The new input variable will have the name `Symbol(:d_, nameof(ap))`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +## Fields + +$(TYPEDFIELDS) +""" +struct Break <: AnalysisPointTransformation + """ + The analysis point to break. + """ + ap::AnalysisPoint + """ + Whether to add a new input variable connected to all the outputs of `ap`. + """ + add_input::Bool +end + +""" + $(TYPEDSIGNATURES) + +`Break` the given analysis point `ap` without adding an input. +""" +Break(ap::AnalysisPoint) = Break(ap, false) + +function apply_transformation(tf::Break, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do breaksys + # get analysis point + ap_idx = analysis_point_index(breaksys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + breaksys_eqs = copy(get_eqs(breaksys)) + @set! breaksys.eqs = breaksys_eqs + + ap = breaksys_eqs[ap_idx].rhs + deleteat!(breaksys_eqs, ap_idx) + + breaksys = with_analysis_point_ignored(breaksys, ap) + + tf.add_input || return breaksys, () + + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable(ap_ivar, nameof(ap), get_iv(sys)) + for outsys in ap.outputs + push!(breaksys_eqs, ap_var(outsys) ~ new_var) + end + defs = copy(get_defaults(breaksys)) + defs[new_var] = new_def + @set! breaksys.defaults = defs + unks = copy(get_unknowns(breaksys)) + push!(unks, new_var) + @set! breaksys.unknowns = unks + + return breaksys, (new_var,) + end +end + +""" + $(TYPEDEF) + +A transformation which returns the variable corresponding to the input of the analysis +point. Does not modify the system. + +## Fields + +$(TYPEDFIELDS) +""" +struct GetInput <: AnalysisPointTransformation + """ + The analysis point to get the input of. + """ + ap::AnalysisPoint +end + +function apply_transformation(tf::GetInput, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get the analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + # get the analysis point + ap_sys_eqs = get_eqs(ap_sys) + ap = ap_sys_eqs[ap_idx].rhs + + # input variable + ap_ivar = ap_var(ap.input) + return ap_sys, (ap_ivar,) + end +end + +""" + $(TYPEDEF) + +A transformation that creates a new input variable which is added to the input of +the analysis point before connecting to the outputs. The new variable will have the name +`Symbol(:d_, nameof(ap))`. + +If `with_output == true`, also creates an additional new variable which has the value +provided to the outputs after the above modification. This new variable has the same name +as the analysis point and will be the second variable in the tuple of new variables returned +from `apply_transformation`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +## Fields + +$(TYPEDFIELDS) +""" +struct PerturbOutput <: AnalysisPointTransformation + """ + The analysis point to modify + """ + ap::AnalysisPoint + """ + Whether to add an additional output variable. + """ + with_output::Bool +end + +""" + $(TYPEDSIGNATURES) + +Add an input without an additional output variable. +""" +PerturbOutput(ap::AnalysisPoint) = PerturbOutput(ap, false) + +function apply_transformation(tf::PerturbOutput, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + # modified equations + ap_sys_eqs = copy(get_eqs(ap_sys)) + @set! ap_sys.eqs = ap_sys_eqs + ap = ap_sys_eqs[ap_idx].rhs + # remove analysis point + deleteat!(ap_sys_eqs, ap_idx) + ap_sys = with_analysis_point_ignored(ap_sys, ap) + + # add equations involving new variable + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable(ap_ivar, nameof(ap), get_iv(sys)) + for outsys in ap.outputs + push!(ap_sys_eqs, ap_var(outsys) ~ ap_ivar + wrap(new_var)) + end + # add variable + unks = copy(get_unknowns(ap_sys)) + push!(unks, new_var) + @set! ap_sys.unknowns = unks + # add default + defs = copy(get_defaults(ap_sys)) + defs[new_var] = new_def + @set! ap_sys.defaults = defs + + tf.with_output || return ap_sys, (new_var,) + + # add output variable, equation, default + out_var, out_def = get_analysis_variable( + ap_ivar, nameof(ap), get_iv(sys); perturb = false) + push!(ap_sys_eqs, out_var ~ ap_ivar + wrap(new_var)) + push!(unks, out_var) + + return ap_sys, (new_var, out_var) + end +end + +""" + $(TYPEDEF) + +A transformation which adds a variable named `name` to the system containing the analysis +point `ap`. $DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct AddVariable <: AnalysisPointTransformation + """ + The analysis point in the system to modify, and whose input should be used as the + template for the new variable. + """ + ap::AnalysisPoint + """ + The name of the added variable. + """ + name::Symbol +end + +""" + $(TYPEDSIGNATURES) + +Add a new variable to the system containing analysis point `ap` with the same name as the +analysis point. +""" +AddVariable(ap::AnalysisPoint) = AddVariable(ap, nameof(ap)) + +function apply_transformation(tf::AddVariable, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + ap_sys_eqs = get_eqs(ap_sys) + ap = ap_sys_eqs[ap_idx].rhs + + # add equations involving new variable + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable( + ap_ivar, tf.name, get_iv(sys); perturb = false) + # add variable + unks = copy(get_unknowns(ap_sys)) + push!(unks, new_var) + @set! ap_sys.unknowns = unks + return ap_sys, (new_var,) + end +end + +#### DERIVED TRANSFORMATIONS + +""" + $(TYPEDSIGNATURES) + +A transformation enable calculating the sensitivity function about the analysis point `ap`. +The returned added variables are `(du, u)` where `du` is the perturbation added to the +input, and `u` is the output after perturbation. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE +""" +SensitivityTransform(ap::AnalysisPoint) = PerturbOutput(ap, true) + +""" + $(TYPEDEF) + +A transformation to enable calculating the complementary sensitivity function about the +analysis point `ap`. The returned added variables are `(du, u)` where `du` is the +perturbation added to the outputs and `u` is the input to the analysis point. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct ComplementarySensitivityTransform <: AnalysisPointTransformation + """ + The analysis point to modify. + """ + ap::AnalysisPoint +end + +function apply_transformation(cst::ComplementarySensitivityTransform, sys::AbstractSystem) + sys, (u,) = apply_transformation(GetInput(cst.ap), sys) + sys, (du,) = apply_transformation( + AddVariable( + cst.ap, Symbol(namespace_hierarchy(nameof(cst.ap))[end], :_comp_sens_du)), + sys) + sys, (_du,) = apply_transformation(PerturbOutput(cst.ap), sys) + + # `PerturbOutput` adds the equation `input + _du ~ output` + # but comp sensitivity wants `output + du ~ input`. Thus, `du ~ -_du`. + eqs = copy(get_eqs(sys)) + @set! sys.eqs = eqs + push!(eqs, du ~ -wrap(_du)) + + defs = copy(get_defaults(sys)) + @set! sys.defaults = defs + defs[du] = -wrap(_du) + return sys, (du, u) +end + +""" + $(TYPEDEF) + +A transformation to enable calculating the loop transfer function about the analysis point +`ap`. The returned added variables are `(du, u)` where `du` feeds into the outputs of `ap` +and `u` is the input of `ap`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct LoopTransferTransform <: AnalysisPointTransformation + """ + The analysis point to modify. + """ + ap::AnalysisPoint +end + +function apply_transformation(tf::LoopTransferTransform, sys::AbstractSystem) + sys, (u,) = apply_transformation(GetInput(tf.ap), sys) + sys, (du,) = apply_transformation(Break(tf.ap, true), sys) + return sys, (du, u) +end + +""" + $(TYPEDSIGNATURES) + +A utility function to get the "canonical" form of a list of analysis points. Always returns +a list of values. Any value that cannot be turned into an `AnalysisPoint` (i.e. isn't +already an `AnalysisPoint` or `Symbol`) is simply wrapped in an array. `Symbol` names of +`AnalysisPoint`s are namespaced with `sys`. +""" +canonicalize_ap(sys::AbstractSystem, ap::Symbol) = [AnalysisPoint(renamespace(sys, ap))] +function canonicalize_ap(sys::AbstractSystem, ap::AnalysisPoint) + if does_namespacing(sys) + return [ap] + else + return [renamespace(sys, ap)] + end +end +canonicalize_ap(sys::AbstractSystem, ap) = [ap] +function canonicalize_ap(sys::AbstractSystem, aps::Vector) + mapreduce(Base.Fix1(canonicalize_ap, sys), vcat, aps; init = []) +end + +""" + $(TYPEDSIGNATURES) + +Given a list of analysis points, break the connection for each and set the output to zero. +""" +function handle_loop_openings(sys::AbstractSystem, aps) + for ap in canonicalize_ap(sys, aps) + sys, (outvar,) = apply_transformation(Break(ap, true), sys) + if Symbolics.isarraysymbolic(outvar) + push!(get_eqs(sys), outvar ~ zeros(size(outvar))) + else + push!(get_eqs(sys), outvar ~ 0) + end + end + return sys +end + +const DOC_LOOP_OPENINGS = """ + - `loop_openings`: A list of analysis points whose connections should be removed and + the outputs set to zero as a part of the linear analysis. +""" + +const DOC_SYS_MODIFIER = """ + - `system_modifier`: A function taking the transformed system and applying any + additional transformations, returning the modified system. The modified system + is passed to `linearization_function`. +""" +""" + $(TYPEDSIGNATURES) + +Utility function for linear analyses that apply a transformation `transform`, which +returns the added variables `(du, u)`, to each of the analysis points in `aps` and then +calls `linearization_function` with all the `du`s as inputs and `u`s as outputs. Returns +the linearization function and modified, simplified system. + +# Keyword arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_linear_analysis_function( + sys::AbstractSystem, transform, aps; system_modifier = identity, loop_openings = [], kwargs...) + sys = handle_loop_openings(sys, loop_openings) + aps = canonicalize_ap(sys, aps) + dus = [] + us = [] + for ap in aps + sys, (du, u) = apply_transformation(transform(ap), sys) + push!(dus, du) + push!(us, u) + end + linearization_function(system_modifier(sys), dus, us; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the sensitivity function for the analysis point(s) `aps`, and the modified system +simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_sensitivity_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, SensitivityTransform, aps; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the complementary sensitivity function for the analysis point(s) `aps`, and the +modified system simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_comp_sensitivity_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, ComplementarySensitivityTransform, aps; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the loop-transfer function for the analysis point(s) `aps`, and the modified +system simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_looptransfer_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, LoopTransferTransform, aps; kwargs...) +end + +for f in [:get_sensitivity, :get_comp_sensitivity, :get_looptransfer] + utility_fun = Symbol(f, :_function) + @eval function $f( + sys, ap, args...; loop_openings = [], system_modifier = identity, kwargs...) + lin_fun, ssys = $(utility_fun)( + sys, ap, args...; loop_openings, system_modifier, kwargs...) + ModelingToolkit.linearize(ssys, lin_fun), ssys + end +end + +""" + $(TYPEDSIGNATURES) + +Apply `LoopTransferTransform` to the analysis point `ap` and return the +result of `apply_transformation`. + +# Keyword Arguments + +- `system_modifier`: a function which takes the modified system and returns a new system + with any required further modifications performed. +""" +function open_loop(sys, ap::Union{Symbol, AnalysisPoint}; system_modifier = identity) + ap = only(canonicalize_ap(sys, ap)) + tf = LoopTransferTransform(ap) + sys, vars = apply_transformation(tf, sys) + return system_modifier(sys), vars +end + +function linearization_function(sys::AbstractSystem, + inputs::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + outputs; loop_openings = [], system_modifier = identity, kwargs...) + loop_openings = Set(map(nameof, canonicalize_ap(sys, loop_openings))) + inputs = canonicalize_ap(sys, inputs) + outputs = canonicalize_ap(sys, outputs) + + input_vars = [] + for input in inputs + if nameof(input) in loop_openings + delete!(loop_openings, nameof(input)) + sys, (input_var,) = apply_transformation(Break(input, true), sys) + else + sys, (input_var,) = apply_transformation(PerturbOutput(input), sys) + end + push!(input_vars, input_var) + end + output_vars = [] + for output in outputs + if output isa AnalysisPoint + sys, (output_var,) = apply_transformation(AddVariable(output), sys) + sys, (input_var,) = apply_transformation(GetInput(output), sys) + push!(get_eqs(sys), output_var ~ input_var) + else + output_var = output + end + push!(output_vars, output_var) + end + + sys = handle_loop_openings(sys, map(AnalysisPoint, collect(loop_openings))) + + return linearization_function(system_modifier(sys), input_vars, output_vars; kwargs...) +end + +@doc """ + get_sensitivity(sys, ap::AnalysisPoint; kwargs) + get_sensitivity(sys, ap_name::Symbol; kwargs) + +Compute the sensitivity function in analysis point `ap`. The sensitivity function is obtained by introducing an infinitesimal perturbation `d` at the input of `ap`, linearizing the system and computing the transfer function between `d` and the output of `ap`. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_comp_sensitivity`](@ref), [`get_looptransfer`](@ref). +""" get_sensitivity + +@doc """ + get_comp_sensitivity(sys, ap::AnalysisPoint; kwargs) + get_comp_sensitivity(sys, ap_name::Symbol; kwargs) + +Compute the complementary sensitivity function in analysis point `ap`. The complementary sensitivity function is obtained by introducing an infinitesimal perturbation `d` at the output of `ap`, linearizing the system and computing the transfer function between `d` and the input of `ap`. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_sensitivity`](@ref), [`get_looptransfer`](@ref). +""" get_comp_sensitivity + +@doc """ + get_looptransfer(sys, ap::AnalysisPoint; kwargs) + get_looptransfer(sys, ap_name::Symbol; kwargs) + +Compute the (linearized) loop-transfer function in analysis point `ap`, from `ap.out` to `ap.in`. + +!!! info "Negative feedback" + + Feedback loops often use negative feedback, and the computed loop-transfer function will in this case have the negative feedback included. Standard analysis tools often assume a loop-transfer function without the negative gain built in, and the result of this function may thus need negation before use. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_sensitivity`](@ref), [`get_comp_sensitivity`](@ref), [`open_loop`](@ref). +""" get_looptransfer +# + +""" + generate_control_function(sys::ModelingToolkit.AbstractODESystem, input_ap_name::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, dist_ap_name::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}; system_modifier = identity, kwargs) + +When called with analysis points as input arguments, we assume that all analysis points corresponds to connections that should be opened (broken). The use case for this is to get rid of input signal blocks, such as `Step` or `Sine`, since these are useful for simulation but are not needed when using the plant model in a controller or state estimator. +""" +function generate_control_function( + sys::ModelingToolkit.AbstractODESystem, input_ap_name::Union{ + Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + dist_ap_name::Union{ + Nothing, Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}} = nothing; + system_modifier = identity, + kwargs...) + input_ap_name = canonicalize_ap(sys, input_ap_name) + u = [] + for input_ap in input_ap_name + sys, (du, _) = open_loop(sys, input_ap) + push!(u, du) + end + if dist_ap_name === nothing + return ModelingToolkit.generate_control_function(system_modifier(sys), u; kwargs...) + end + + dist_ap_name = canonicalize_ap(sys, dist_ap_name) + d = [] + for dist_ap in dist_ap_name + sys, (du, _) = open_loop(sys, dist_ap) + push!(d, du) + end + + ModelingToolkit.generate_control_function(system_modifier(sys), u, d; kwargs...) +end diff --git a/src/systems/callbacks.jl b/src/systems/callbacks.jl new file mode 100644 index 0000000000..07809bf611 --- /dev/null +++ b/src/systems/callbacks.jl @@ -0,0 +1,1141 @@ +#################################### system operations ##################################### +has_continuous_events(sys::AbstractSystem) = isdefined(sys, :continuous_events) +function get_continuous_events(sys::AbstractSystem) + has_continuous_events(sys) || return SymbolicContinuousCallback[] + getfield(sys, :continuous_events) +end + +has_discrete_events(sys::AbstractSystem) = isdefined(sys, :discrete_events) +function get_discrete_events(sys::AbstractSystem) + has_discrete_events(sys) || return SymbolicDiscreteCallback[] + getfield(sys, :discrete_events) +end + +struct FunctionalAffect + f::Any + sts::Vector + sts_syms::Vector{Symbol} + pars::Vector + pars_syms::Vector{Symbol} + discretes::Vector + ctx::Any +end + +function FunctionalAffect(f, sts, pars, discretes, ctx = nothing) + # sts & pars contain either pairs: resistor.R => R, or Syms: R + vs = [x isa Pair ? x.first : x for x in sts] + vs_syms = Symbol[x isa Pair ? Symbol(x.second) : getname(x) for x in sts] + length(vs_syms) == length(unique(vs_syms)) || error("Variables are not unique") + + ps = [x isa Pair ? x.first : x for x in pars] + ps_syms = Symbol[x isa Pair ? Symbol(x.second) : getname(x) for x in pars] + length(ps_syms) == length(unique(ps_syms)) || error("Parameters are not unique") + + FunctionalAffect(f, vs, vs_syms, ps, ps_syms, discretes, ctx) +end + +function FunctionalAffect(; f, sts, pars, discretes, ctx = nothing) + FunctionalAffect(f, sts, pars, discretes, ctx) +end + +func(f::FunctionalAffect) = f.f +context(a::FunctionalAffect) = a.ctx +parameters(a::FunctionalAffect) = a.pars +parameters_syms(a::FunctionalAffect) = a.pars_syms +unknowns(a::FunctionalAffect) = a.sts +unknowns_syms(a::FunctionalAffect) = a.sts_syms +discretes(a::FunctionalAffect) = a.discretes + +function Base.:(==)(a1::FunctionalAffect, a2::FunctionalAffect) + isequal(a1.f, a2.f) && isequal(a1.sts, a2.sts) && isequal(a1.pars, a2.pars) && + isequal(a1.sts_syms, a2.sts_syms) && isequal(a1.pars_syms, a2.pars_syms) && + isequal(a1.ctx, a2.ctx) +end + +function Base.hash(a::FunctionalAffect, s::UInt) + s = hash(a.f, s) + s = hash(a.sts, s) + s = hash(a.sts_syms, s) + s = hash(a.pars, s) + s = hash(a.pars_syms, s) + s = hash(a.discretes, s) + hash(a.ctx, s) +end + +namespace_affect(affect, s) = namespace_equation(affect, s) +function namespace_affect(affect::FunctionalAffect, s) + FunctionalAffect(func(affect), + renamespace.((s,), unknowns(affect)), + unknowns_syms(affect), + renamespace.((s,), parameters(affect)), + parameters_syms(affect), + renamespace.((s,), discretes(affect)), + context(affect)) +end + +function has_functional_affect(cb) + (affects(cb) isa FunctionalAffect || affects(cb) isa ImperativeAffect) +end + +function vars!(vars, aff::FunctionalAffect; op = Differential) + for var in Iterators.flatten((unknowns(aff), parameters(aff), discretes(aff))) + vars!(vars, var) + end + return vars +end + +#################################### continuous events ##################################### + +const NULL_AFFECT = Equation[] +""" + SymbolicContinuousCallback(eqs::Vector{Equation}, affect, affect_neg, rootfind) + +A [`ContinuousCallback`](@ref SciMLBase.ContinuousCallback) specified symbolically. Takes a vector of equations `eq` +as well as the positive-edge `affect` and negative-edge `affect_neg` that apply when *any* of `eq` are satisfied. +By default `affect_neg = affect`; to only get rising edges specify `affect_neg = nothing`. + +Assume without loss of generality that the equation is of the form `c(u,p,t) ~ 0`; we denote the integrator state as `i.u`. +For compactness, we define `prev_sign = sign(c(u[t-1], p[t-1], t-1))` and `cur_sign = sign(c(u[t], p[t], t))`. +A condition edge will be detected and the callback will be invoked iff `prev_sign * cur_sign <= 0`. +The positive edge `affect` will be triggered iff an edge is detected and if `prev_sign < 0`; similarly, `affect_neg` will be +triggered iff an edge is detected and `prev_sign > 0`. + +Inter-sample condition activation is not guaranteed; for example if we use the dirac delta function as `c` to insert a +sharp discontinuity between integrator steps (which in this example would not normally be identified by adaptivity) then the condition is not +guaranteed to be triggered. + +Once detected the integrator will "wind back" through a root-finding process to identify the point when the condition became active; the method used +is specified by `rootfind` from [`SciMLBase.RootfindOpt`](@ref). If we denote the time when the condition becomes active as `tc`, +the value in the integrator after windback will be: +* `u[tc-epsilon], p[tc-epsilon], tc` if `LeftRootFind` is used, +* `u[tc+epsilon], p[tc+epsilon], tc` if `RightRootFind` is used, +* or `u[t], p[t], t` if `NoRootFind` is used. +For example, if we want to detect when an unknown variable `x` satisfies `x > 0` using the condition `x ~ 0` on a positive edge (that is, `D(x) > 0`), +then left root finding will get us `x=-epsilon`, right root finding `x=epsilon` and no root finding will produce whatever the next step of the integrator was after +it passed through 0. + +Multiple callbacks in the same system with different `rootfind` operations will be grouped +by their `rootfind` value into separate VectorContinuousCallbacks in the enumeration order of `SciMLBase.RootfindOpt`. This may cause some callbacks to not fire if several become +active at the same instant. See the `SciMLBase` documentation for more information on the semantic rules. + +Affects (i.e. `affect` and `affect_neg`) can be specified as either: +* A list of equations that should be applied when the callback is triggered (e.g. `x ~ 3, y ~ 7`) which must be of the form `unknown ~ observed value` where each `unknown` appears only once. Equations will be applied in the order that they appear in the vector; parameters and state updates will become immediately visible to following equations. +* A tuple `(f!, unknowns, read_parameters, modified_parameters, ctx)`, where: + + `f!` is a function with signature `(integ, u, p, ctx)` that is called with the integrator, a state *index* vector `u` derived from `unknowns`, a parameter *index* vector `p` derived from `read_parameters`, and the `ctx` that was given at construction time. Note that `ctx` is aliased between instances. + + `unknowns` is a vector of symbolic unknown variables and optionally their aliases (e.g. if the model was defined with `@variables x(t)` then a valid value for `unknowns` would be `[x]`). A variable can be aliased with a pair `x => :y`. The indices of these `unknowns` will be passed to `f!` in `u` in a named tuple; in the earlier example, if we pass `[x]` as `unknowns` then `f!` can access `x` as `integ.u[u.x]`. If no alias is specified the name of the index will be the symbol version of the variable name. + + `read_parameters` is a vector of the parameters that are *used* by `f!`. Their indices are passed to `f` in `p` similarly to the indices of `unknowns` passed in `u`. + + `modified_parameters` is a vector of the parameters that are *modified* by `f!`. Note that a parameter will not appear in `p` if it only appears in `modified_parameters`; it must appear in both `parameters` and `modified_parameters` if it is used in the affect definition. + + `ctx` is a user-defined context object passed to `f!` when invoked. This value is aliased for each problem. +* A [`ImperativeAffect`](@ref); refer to its documentation for details. + +DAEs will be reinitialized using `reinitializealg` (which defaults to `SciMLBase.CheckInit`) after callbacks are applied. +This reinitialization algorithm ensures that the DAE is satisfied after the callback runs. The default value of `CheckInit` will simply validate +that the newly-assigned values indeed satisfy the algebraic system; see the documentation on DAE initialization for a more detailed discussion of +initialization. + +Initial and final affects can also be specified with SCC, which are specified identically to positive and negative edge affects. Initialization affects +will run as soon as the solver starts, while finalization affects will be executed after termination. +""" +struct SymbolicContinuousCallback + eqs::Vector{Equation} + initialize::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + finalize::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + affect::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + affect_neg::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect, Nothing} + rootfind::SciMLBase.RootfindOpt + reinitializealg::SciMLBase.DAEInitializationAlgorithm + function SymbolicContinuousCallback(; + eqs::Vector{Equation}, + affect = NULL_AFFECT, + affect_neg = affect, + initialize = NULL_AFFECT, + finalize = NULL_AFFECT, + rootfind = SciMLBase.LeftRootFind, + reinitializealg = SciMLBase.CheckInit()) + new(eqs, initialize, finalize, make_affect(affect), + make_affect(affect_neg), rootfind, reinitializealg) + end # Default affect to nothing +end +make_affect(affect) = affect +make_affect(affect::Tuple) = FunctionalAffect(affect...) +make_affect(affect::NamedTuple) = FunctionalAffect(; affect...) + +function Base.:(==)(e1::SymbolicContinuousCallback, e2::SymbolicContinuousCallback) + isequal(e1.eqs, e2.eqs) && isequal(e1.affect, e2.affect) && + isequal(e1.initialize, e2.initialize) && isequal(e1.finalize, e2.finalize) && + isequal(e1.affect_neg, e2.affect_neg) && isequal(e1.rootfind, e2.rootfind) +end +Base.isempty(cb::SymbolicContinuousCallback) = isempty(cb.eqs) +function Base.hash(cb::SymbolicContinuousCallback, s::UInt) + hash_affect(affect::AbstractVector, s) = foldr(hash, affect, init = s) + hash_affect(affect, s) = hash(affect, s) + s = foldr(hash, cb.eqs, init = s) + s = hash_affect(cb.affect, s) + s = hash_affect(cb.affect_neg, s) + s = hash_affect(cb.initialize, s) + s = hash_affect(cb.finalize, s) + s = hash(cb.reinitializealg, s) + hash(cb.rootfind, s) +end + +function Base.show(io::IO, cb::SymbolicContinuousCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + print(io, "SymbolicContinuousCallback(") + print(iio, "Equations:") + show(iio, equations(cb)) + print(iio, "; ") + if affects(cb) != NULL_AFFECT + print(iio, "Affect:") + show(iio, affects(cb)) + print(iio, ", ") + end + if affect_negs(cb) != NULL_AFFECT + print(iio, "Negative-edge affect:") + show(iio, affect_negs(cb)) + print(iio, ", ") + end + if initialize_affects(cb) != NULL_AFFECT + print(iio, "Initialization affect:") + show(iio, initialize_affects(cb)) + print(iio, ", ") + end + if finalize_affects(cb) != NULL_AFFECT + print(iio, "Finalization affect:") + show(iio, finalize_affects(cb)) + end + print(iio, ")") +end + +function Base.show(io::IO, mime::MIME"text/plain", cb::SymbolicContinuousCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + println(io, "SymbolicContinuousCallback:") + println(iio, "Equations:") + show(iio, mime, equations(cb)) + print(iio, "\n") + if affects(cb) != NULL_AFFECT + println(iio, "Affect:") + show(iio, mime, affects(cb)) + print(iio, "\n") + end + if affect_negs(cb) != NULL_AFFECT + println(iio, "Negative-edge affect:") + show(iio, mime, affect_negs(cb)) + print(iio, "\n") + end + if initialize_affects(cb) != NULL_AFFECT + println(iio, "Initialization affect:") + show(iio, mime, initialize_affects(cb)) + print(iio, "\n") + end + if finalize_affects(cb) != NULL_AFFECT + println(iio, "Finalization affect:") + show(iio, mime, finalize_affects(cb)) + print(iio, "\n") + end +end + +to_equation_vector(eq::Equation) = [eq] +to_equation_vector(eqs::Vector{Equation}) = eqs +function to_equation_vector(eqs::Vector{Any}) + isempty(eqs) || error("This should never happen") + Equation[] +end + +function SymbolicContinuousCallback(args...) + SymbolicContinuousCallback(to_equation_vector.(args)...) +end # wrap eq in vector +SymbolicContinuousCallback(p::Pair) = SymbolicContinuousCallback(p[1], p[2]) +SymbolicContinuousCallback(cb::SymbolicContinuousCallback) = cb # passthrough +function SymbolicContinuousCallback(eqs::Equation, affect = NULL_AFFECT; + initialize = NULL_AFFECT, finalize = NULL_AFFECT, + affect_neg = affect, rootfind = SciMLBase.LeftRootFind) + SymbolicContinuousCallback( + eqs = [eqs], affect = affect, affect_neg = affect_neg, + initialize = initialize, finalize = finalize, rootfind = rootfind) +end +function SymbolicContinuousCallback(eqs::Vector{Equation}, affect = NULL_AFFECT; + affect_neg = affect, initialize = NULL_AFFECT, finalize = NULL_AFFECT, + rootfind = SciMLBase.LeftRootFind) + SymbolicContinuousCallback( + eqs = eqs, affect = affect, affect_neg = affect_neg, + initialize = initialize, finalize = finalize, rootfind = rootfind) +end + +SymbolicContinuousCallbacks(cb::SymbolicContinuousCallback) = [cb] +SymbolicContinuousCallbacks(cbs::Vector{<:SymbolicContinuousCallback}) = cbs +SymbolicContinuousCallbacks(cbs::Vector) = SymbolicContinuousCallback.(cbs) +function SymbolicContinuousCallbacks(ve::Vector{Equation}) + SymbolicContinuousCallbacks(SymbolicContinuousCallback(ve)) +end +function SymbolicContinuousCallbacks(others) + SymbolicContinuousCallbacks(SymbolicContinuousCallback(others)) +end +SymbolicContinuousCallbacks(::Nothing) = SymbolicContinuousCallback[] + +equations(cb::SymbolicContinuousCallback) = cb.eqs +function equations(cbs::Vector{<:SymbolicContinuousCallback}) + mapreduce(equations, vcat, cbs, init = Equation[]) +end + +affects(cb::SymbolicContinuousCallback) = cb.affect +function affects(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(affects, vcat, cbs, init = Equation[]) +end + +affect_negs(cb::SymbolicContinuousCallback) = cb.affect_neg +function affect_negs(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(affect_negs, vcat, cbs, init = Equation[]) +end + +reinitialization_alg(cb::SymbolicContinuousCallback) = cb.reinitializealg +function reinitialization_algs(cbs::Vector{SymbolicContinuousCallback}) + mapreduce( + reinitialization_alg, vcat, cbs, init = SciMLBase.DAEInitializationAlgorithm[]) +end + +initialize_affects(cb::SymbolicContinuousCallback) = cb.initialize +function initialize_affects(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(initialize_affects, vcat, cbs, init = Equation[]) +end + +finalize_affects(cb::SymbolicContinuousCallback) = cb.finalize +function finalize_affects(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(finalize_affects, vcat, cbs, init = Equation[]) +end + +namespace_affects(af::Vector, s) = Equation[namespace_affect(a, s) for a in af] +namespace_affects(af::FunctionalAffect, s) = namespace_affect(af, s) +namespace_affects(::Nothing, s) = nothing + +function namespace_callback(cb::SymbolicContinuousCallback, s)::SymbolicContinuousCallback + SymbolicContinuousCallback(; + eqs = namespace_equation.(equations(cb), (s,)), + affect = namespace_affects(affects(cb), s), + affect_neg = namespace_affects(affect_negs(cb), s), + initialize = namespace_affects(initialize_affects(cb), s), + finalize = namespace_affects(finalize_affects(cb), s), + rootfind = cb.rootfind) +end + +""" + continuous_events(sys::AbstractSystem)::Vector{SymbolicContinuousCallback} + +Returns a vector of all the `continuous_events` in an abstract system and its component subsystems. +The `SymbolicContinuousCallback`s in the returned vector are structs with two fields: `eqs` and +`affect` which correspond to the first and second elements of a `Pair` used to define an event, i.e. +`eqs => affect`. +""" +function continuous_events(sys::AbstractSystem) + cbs = get_continuous_events(sys) + filter(!isempty, cbs) + + systems = get_systems(sys) + cbs = [cbs; + reduce(vcat, + (map(cb -> namespace_callback(cb, s), continuous_events(s)) + for s in systems), + init = SymbolicContinuousCallback[])] + filter(!isempty, cbs) +end + +function vars!(vars, cb::SymbolicContinuousCallback; op = Differential) + for eq in equations(cb) + vars!(vars, eq; op) + end + for aff in (affects(cb), affect_negs(cb), initialize_affects(cb), finalize_affects(cb)) + if aff isa Vector{Equation} + for eq in aff + vars!(vars, eq; op) + end + elseif aff !== nothing + vars!(vars, aff; op) + end + end + return vars +end + +""" + continuous_events_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `continuous_events`, but ignores events of subsystems. + +Notes: +- Cannot be applied to non-complete systems. +""" +function continuous_events_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return continuous_events_toplevel(parent) + end + return get_continuous_events(sys) +end + +#################################### discrete events ##################################### + +struct SymbolicDiscreteCallback + # condition can be one of: + # Δt::Real - Periodic with period Δt + # Δts::Vector{Real} - events trigger in this times (Preset) + # condition::Vector{Equation} - event triggered when condition is true + # TODO: Iterative + condition::Any + affects::Any + initialize::Any + finalize::Any + reinitializealg::SciMLBase.DAEInitializationAlgorithm + + function SymbolicDiscreteCallback( + condition, affects = NULL_AFFECT; reinitializealg = SciMLBase.CheckInit(), + initialize = NULL_AFFECT, finalize = NULL_AFFECT) + c = scalarize_condition(condition) + a = scalarize_affects(affects) + new(c, a, scalarize_affects(initialize), + scalarize_affects(finalize), reinitializealg) + end # Default affect to nothing +end + +is_timed_condition(cb) = false +is_timed_condition(::R) where {R <: Real} = true +is_timed_condition(::V) where {V <: AbstractVector} = eltype(V) <: Real +is_timed_condition(::Num) = false +is_timed_condition(cb::SymbolicDiscreteCallback) = is_timed_condition(condition(cb)) + +function scalarize_condition(condition) + is_timed_condition(condition) ? condition : value(scalarize(condition)) +end +function namespace_condition(condition, s) + is_timed_condition(condition) ? condition : namespace_expr(condition, s) +end + +scalarize_affects(affects) = scalarize(affects) +scalarize_affects(affects::Tuple) = FunctionalAffect(affects...) +scalarize_affects(affects::NamedTuple) = FunctionalAffect(; affects...) +scalarize_affects(affects::FunctionalAffect) = affects + +SymbolicDiscreteCallback(p::Pair) = SymbolicDiscreteCallback(p[1], p[2]) +SymbolicDiscreteCallback(cb::SymbolicDiscreteCallback) = cb # passthrough + +function Base.show(io::IO, db::SymbolicDiscreteCallback) + println(io, "condition: ", db.condition) + println(io, "affects:") + if db.affects isa FunctionalAffect || db.affects isa ImperativeAffect + # TODO + println(io, " ", db.affects) + else + for affect in db.affects + println(io, " ", affect) + end + end +end + +function Base.:(==)(e1::SymbolicDiscreteCallback, e2::SymbolicDiscreteCallback) + isequal(e1.condition, e2.condition) && isequal(e1.affects, e2.affects) && + isequal(e1.initialize, e2.initialize) && isequal(e1.finalize, e2.finalize) +end +function Base.hash(cb::SymbolicDiscreteCallback, s::UInt) + s = hash(cb.condition, s) + s = cb.affects isa AbstractVector ? foldr(hash, cb.affects, init = s) : + hash(cb.affects, s) + s = cb.initialize isa AbstractVector ? foldr(hash, cb.initialize, init = s) : + hash(cb.initialize, s) + s = cb.finalize isa AbstractVector ? foldr(hash, cb.finalize, init = s) : + hash(cb.finalize, s) + s = hash(cb.reinitializealg, s) + return s +end + +condition(cb::SymbolicDiscreteCallback) = cb.condition +function conditions(cbs::Vector{<:SymbolicDiscreteCallback}) + reduce(vcat, condition(cb) for cb in cbs) +end + +affects(cb::SymbolicDiscreteCallback) = cb.affects + +function affects(cbs::Vector{SymbolicDiscreteCallback}) + reduce(vcat, affects(cb) for cb in cbs; init = []) +end + +reinitialization_alg(cb::SymbolicDiscreteCallback) = cb.reinitializealg +function reinitialization_algs(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce( + reinitialization_alg, vcat, cbs, init = SciMLBase.DAEInitializationAlgorithm[]) +end + +initialize_affects(cb::SymbolicDiscreteCallback) = cb.initialize +function initialize_affects(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce(initialize_affects, vcat, cbs, init = Equation[]) +end + +finalize_affects(cb::SymbolicDiscreteCallback) = cb.finalize +function finalize_affects(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce(finalize_affects, vcat, cbs, init = Equation[]) +end + +function namespace_callback(cb::SymbolicDiscreteCallback, s)::SymbolicDiscreteCallback + function namespace_affects(af) + return af isa AbstractVector ? namespace_affect.(af, Ref(s)) : + namespace_affect(af, s) + end + SymbolicDiscreteCallback( + namespace_condition(condition(cb), s), namespace_affects(affects(cb)), + reinitializealg = cb.reinitializealg, initialize = namespace_affects(initialize_affects(cb)), + finalize = namespace_affects(finalize_affects(cb))) +end + +SymbolicDiscreteCallbacks(cb::Pair) = SymbolicDiscreteCallback[SymbolicDiscreteCallback(cb)] +SymbolicDiscreteCallbacks(cbs::Vector) = SymbolicDiscreteCallback.(cbs) +SymbolicDiscreteCallbacks(cb::SymbolicDiscreteCallback) = [cb] +SymbolicDiscreteCallbacks(cbs::Vector{<:SymbolicDiscreteCallback}) = cbs +SymbolicDiscreteCallbacks(::Nothing) = SymbolicDiscreteCallback[] + +""" + discrete_events(sys::AbstractSystem) :: Vector{SymbolicDiscreteCallback} + +Returns a vector of all the `discrete_events` in an abstract system and its component subsystems. +The `SymbolicDiscreteCallback`s in the returned vector are structs with two fields: `condition` and +`affect` which correspond to the first and second elements of a `Pair` used to define an event, i.e. +`condition => affect`. +""" +function discrete_events(sys::AbstractSystem) + cbs = get_discrete_events(sys) + systems = get_systems(sys) + cbs = [cbs; + reduce(vcat, + (map(cb -> namespace_callback(cb, s), discrete_events(s)) for s in systems), + init = SymbolicDiscreteCallback[])] + cbs +end + +function vars!(vars, cb::SymbolicDiscreteCallback; op = Differential) + if symbolic_type(cb.condition) == NotSymbolic + if cb.condition isa AbstractArray + for eq in cb.condition + vars!(vars, eq; op) + end + end + else + vars!(vars, cb.condition; op) + end + for aff in (cb.affects, cb.initialize, cb.finalize) + if aff isa Vector{Equation} + for eq in aff + vars!(vars, eq; op) + end + elseif aff !== nothing + vars!(vars, aff; op) + end + end + return vars +end + +""" + discrete_events_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `discrete_events`, but ignores events of subsystems. + +Notes: +- Cannot be applied to non-complete systems. +""" +function discrete_events_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return discrete_events_toplevel(parent) + end + return get_discrete_events(sys) +end + +################################# compilation functions #################################### + +# handles ensuring that affect! functions work with integrator arguments +function add_integrator_header( + sys::AbstractSystem, integrator = gensym(:MTKIntegrator), out = :u) + expr -> Func([DestructuredArgs(expr.args, integrator, inds = [:u, :p, :t])], [], + expr.body), + expr -> Func( + [DestructuredArgs(expr.args, integrator, inds = [out, :u, :p, :t])], [], + expr.body) +end + +function condition_header(sys::AbstractSystem, integrator = gensym(:MTKIntegrator)) + expr -> Func( + [expr.args[1], expr.args[2], + DestructuredArgs(expr.args[3:end], integrator, inds = [:p])], + [], + expr.body) +end + +function callback_save_header(sys::AbstractSystem, cb) + if !(has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing) + return (identity, identity) + end + save_idxs = get(ic.callback_to_clocks, cb, Int[]) + isempty(save_idxs) && return (identity, identity) + + wrapper = function (expr) + return Func(expr.args, [], + LiteralExpr(quote + $(expr.body) + save_idxs = $(save_idxs) + for idx in save_idxs + $(SciMLBase.save_discretes!)($(expr.args[1]), idx) + end + end)) + end + + return wrapper, wrapper +end + +""" + compile_condition(cb::SymbolicDiscreteCallback, sys, dvs, ps; expression, kwargs...) + +Returns a function `condition(u,t,integrator)` returning the `condition(cb)`. + +Notes + + - `expression = Val{true}`, causes the generated function to be returned as an expression. + If set to `Val{false}` a `RuntimeGeneratedFunction` will be returned. + - `kwargs` are passed through to `Symbolics.build_function`. +""" +function compile_condition(cb::SymbolicDiscreteCallback, sys, dvs, ps; + expression = Val{true}, eval_expression = false, eval_module = @__MODULE__, kwargs...) + u = map(x -> time_varying_as_func(value(x), sys), dvs) + p = map.(x -> time_varying_as_func(value(x), sys), reorder_parameters(sys, ps)) + t = get_iv(sys) + condit = condition(cb) + cs = collect_constants(condit) + if !isempty(cs) + cmap = map(x -> x => getdefault(x), cs) + condit = substitute(condit, cmap) + end + expr = build_function_wrapper(sys, + condit, u, t, p...; expression = Val{true}, + p_start = 3, p_end = length(p) + 2, + wrap_code = condition_header(sys), + kwargs...) + if expression == Val{true} + return expr + end + return eval_or_rgf(expr; eval_expression, eval_module) +end + +function compile_affect(cb::SymbolicContinuousCallback, args...; kwargs...) + compile_affect(affects(cb), cb, args...; kwargs...) +end + +""" + compile_affect(eqs::Vector{Equation}, sys, dvs, ps; expression, outputidxs, kwargs...) + compile_affect(cb::SymbolicContinuousCallback, args...; kwargs...) + +Returns a function that takes an integrator as argument and modifies the state with the +affect. The generated function has the signature `affect!(integrator)`. + +Notes + + - `expression = Val{true}`, causes the generated function to be returned as an expression. + If set to `Val{false}` a `RuntimeGeneratedFunction` will be returned. + - `outputidxs`, a vector of indices of the output variables which should correspond to + `unknowns(sys)`. If provided, checks that the LHS of affect equations are variables are + dropped, i.e. it is assumed these indices are correct and affect equations are + well-formed. + - `kwargs` are passed through to `Symbolics.build_function`. +""" +function compile_affect(eqs::Vector{Equation}, cb, sys, dvs, ps; outputidxs = nothing, + expression = Val{true}, checkvars = true, eval_expression = false, + eval_module = @__MODULE__, + postprocess_affect_expr! = nothing, kwargs...) + if isempty(eqs) + if expression == Val{true} + return :((args...) -> ()) + else + return (args...) -> () # We don't do anything in the callback, we're just after the event + end + else + eqs = flatten_equations(eqs) + rhss = map(x -> x.rhs, eqs) + outvar = :u + if outputidxs === nothing + lhss = map(x -> x.lhs, eqs) + all(isvariable, lhss) || + error("Non-variable symbolic expression found on the left hand side of an affect equation. Such equations must be of the form variable ~ symbolic expression for the new value of the variable.") + update_vars = collect(Iterators.flatten(map(ModelingToolkit.vars, lhss))) # these are the ones we're changing + length(update_vars) == length(unique(update_vars)) == length(eqs) || + error("affected variables not unique, each unknown can only be affected by one equation for a single `root_eqs => affects` pair.") + alleq = all(isequal(isparameter(first(update_vars))), + Iterators.map(isparameter, update_vars)) + if !isparameter(first(lhss)) && alleq + unknownind = Dict(reverse(en) for en in enumerate(dvs)) + update_inds = map(sym -> unknownind[sym], update_vars) + elseif isparameter(first(lhss)) && alleq + if has_index_cache(sys) && get_index_cache(sys) !== nothing + update_inds = map(update_vars) do sym + return parameter_index(sys, sym) + end + else + psind = Dict(reverse(en) for en in enumerate(ps)) + update_inds = map(sym -> psind[sym], update_vars) + end + outvar = :p + else + error("Error, building an affect function for a callback that wants to modify both parameters and unknowns. This is not currently allowed in one individual callback.") + end + else + update_inds = outputidxs + end + + _ps = ps + ps = reorder_parameters(sys, ps) + if checkvars + u = map(x -> time_varying_as_func(value(x), sys), dvs) + p = map.(x -> time_varying_as_func(value(x), sys), ps) + else + u = dvs + p = ps + end + t = get_iv(sys) + integ = gensym(:MTKIntegrator) + rf_oop, rf_ip = build_function_wrapper( + sys, rhss, u, p..., t; expression = Val{true}, + wrap_code = callback_save_header(sys, cb) .∘ + add_integrator_header(sys, integ, outvar), + outputidxs = update_inds, + create_bindings = false, + kwargs..., cse = false) + # applied user-provided function to the generated expression + if postprocess_affect_expr! !== nothing + postprocess_affect_expr!(rf_ip, integ) + end + if expression == Val{false} + return eval_or_rgf(rf_ip; eval_expression, eval_module) + end + return rf_ip + end +end + +function generate_rootfinding_callback(sys::AbstractTimeDependentSystem, + dvs = unknowns(sys), ps = parameters(sys; initial_parameters = true); kwargs...) + cbs = continuous_events(sys) + isempty(cbs) && return nothing + generate_rootfinding_callback(cbs, sys, dvs, ps; kwargs...) +end +""" +Generate a single rootfinding callback; this happens if there is only one equation in `cbs` passed to +generate_rootfinding_callback and thus we can produce a ContinuousCallback instead of a VectorContinuousCallback. +""" +function generate_single_rootfinding_callback( + eq, cb, sys::AbstractTimeDependentSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) + if !isequal(eq.lhs, 0) + eq = 0 ~ eq.lhs - eq.rhs + end + + rf_oop, rf_ip = generate_custom_function( + sys, [eq.rhs], dvs, ps; expression = Val{false}, kwargs..., cse = false) + affect_function = compile_affect_fn(cb, sys, dvs, ps, kwargs) + cond = function (u, t, integ) + if DiffEqBase.isinplace(integ.sol.prob) + tmp, = DiffEqBase.get_tmp_cache(integ) + rf_ip(tmp, u, parameter_values(integ), t) + tmp[1] + else + rf_oop(u, parameter_values(integ), t) + end + end + user_initfun = isnothing(affect_function.initialize) ? SciMLBase.INITIALIZE_DEFAULT : + (c, u, t, i) -> affect_function.initialize(i) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && + (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing + initfn = let save_idxs = save_idxs + function (cb, u, t, integrator) + user_initfun(cb, u, t, integrator) + for idx in save_idxs + SciMLBase.save_discretes!(integrator, idx) + end + end + end + else + initfn = user_initfun + end + + return ContinuousCallback( + cond, affect_function.affect, affect_function.affect_neg, rootfind = cb.rootfind, + initialize = initfn, + finalize = isnothing(affect_function.finalize) ? SciMLBase.FINALIZE_DEFAULT : + (c, u, t, i) -> affect_function.finalize(i), + initializealg = reinitialization_alg(cb)) +end + +function generate_vector_rootfinding_callback( + cbs, sys::AbstractTimeDependentSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); rootfind = SciMLBase.RightRootFind, + reinitialization = SciMLBase.CheckInit(), kwargs...) + eqs = map(cb -> flatten_equations(cb.eqs), cbs) + num_eqs = length.(eqs) + # fuse equations to create VectorContinuousCallback + eqs = reduce(vcat, eqs) + # rewrite all equations as 0 ~ interesting stuff + eqs = map(eqs) do eq + isequal(eq.lhs, 0) && return eq + 0 ~ eq.lhs - eq.rhs + end + + rhss = map(x -> x.rhs, eqs) + _, rf_ip = generate_custom_function( + sys, rhss, dvs, ps; expression = Val{false}, kwargs..., cse = false) + + affect_functions = @NamedTuple{ + affect::Function, + affect_neg::Union{Function, Nothing}, + initialize::Union{Function, Nothing}, + finalize::Union{Function, Nothing}}[ + compile_affect_fn(cb, sys, dvs, ps, kwargs) + for cb in cbs] + cond = function (out, u, t, integ) + rf_ip(out, u, parameter_values(integ), t) + end + + # since there may be different number of conditions and affects, + # we build a map that translates the condition eq. number to the affect number + eq_ind2affect = reduce(vcat, + [fill(i, num_eqs[i]) for i in eachindex(affect_functions)]) + @assert length(eq_ind2affect) == length(eqs) + @assert maximum(eq_ind2affect) == length(affect_functions) + + affect = let affect_functions = affect_functions, eq_ind2affect = eq_ind2affect + function (integ, eq_ind) # eq_ind refers to the equation index that triggered the event, each event has num_eqs[i] equations + affect_functions[eq_ind2affect[eq_ind]].affect(integ) + end + end + affect_neg = let affect_functions = affect_functions, eq_ind2affect = eq_ind2affect + function (integ, eq_ind) # eq_ind refers to the equation index that triggered the event, each event has num_eqs[i] equations + affect_neg = affect_functions[eq_ind2affect[eq_ind]].affect_neg + if isnothing(affect_neg) + return # skip if the neg function doesn't exist - don't want to split this into a separate VCC because that'd break ordering + end + affect_neg(integ) + end + end + function handle_optional_setup_fn(funs, default) + if all(isnothing, funs) + return default + else + return let funs = funs + function (cb, u, t, integ) + for func in funs + if isnothing(func) + continue + else + func(integ) + end + end + end + end + end + end + initialize = nothing + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + initialize = handle_optional_setup_fn( + map(cbs, affect_functions) do cb, fn + if (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing + let save_idxs = save_idxs + custom_init = fn.initialize + (i) -> begin + !isnothing(custom_init) && custom_init(i) + for idx in save_idxs + SciMLBase.save_discretes!(i, idx) + end + end + end + else + fn.initialize + end + end, + SciMLBase.INITIALIZE_DEFAULT) + + else + initialize = handle_optional_setup_fn( + map(fn -> fn.initialize, affect_functions), SciMLBase.INITIALIZE_DEFAULT) + end + + finalize = handle_optional_setup_fn( + map(fn -> fn.finalize, affect_functions), SciMLBase.FINALIZE_DEFAULT) + return VectorContinuousCallback( + cond, affect, affect_neg, length(eqs), rootfind = rootfind, + initialize = initialize, finalize = finalize, initializealg = reinitialization) +end + +""" +Compile a single continuous callback affect function(s). +""" +function compile_affect_fn(cb, sys::AbstractTimeDependentSystem, dvs, ps, kwargs) + eq_aff = affects(cb) + eq_neg_aff = affect_negs(cb) + affect = compile_affect(eq_aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + function compile_optional_affect(aff, default = nothing) + if isnothing(aff) || aff == default + return nothing + else + return compile_affect(aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + end + end + if eq_neg_aff === eq_aff + affect_neg = affect + else + affect_neg = _compile_optional_affect( + NULL_AFFECT, eq_neg_aff, cb, sys, dvs, ps; kwargs...) + end + initialize = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + finalize = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) + (affect = affect, affect_neg = affect_neg, initialize = initialize, finalize = finalize) +end + +function generate_rootfinding_callback(cbs, sys::AbstractTimeDependentSystem, + dvs = unknowns(sys), ps = parameters(sys; initial_parameters = true); kwargs...) + eqs = map(cb -> flatten_equations(cb.eqs), cbs) + num_eqs = length.(eqs) + total_eqs = sum(num_eqs) + (isempty(eqs) || total_eqs == 0) && return nothing + if total_eqs == 1 + # find the callback with only one eq + cb_ind = findfirst(>(0), num_eqs) + if isnothing(cb_ind) + error("Inconsistent state in affect compilation; one equation but no callback with equations?") + end + cb = cbs[cb_ind] + return generate_single_rootfinding_callback(cb.eqs[], cb, sys, dvs, ps; kwargs...) + end + + # group the cbs by what rootfind op they use + # groupby would be very useful here, but alas + cb_classes = Dict{ + @NamedTuple{ + rootfind::SciMLBase.RootfindOpt, + reinitialization::SciMLBase.DAEInitializationAlgorithm}, Vector{SymbolicContinuousCallback}}() + for cb in cbs + push!( + get!(() -> SymbolicContinuousCallback[], cb_classes, + ( + rootfind = cb.rootfind, + reinitialization = reinitialization_alg(cb))), + cb) + end + + # generate the callbacks out; we sort by the equivalence class to ensure a deterministic preference order + compiled_callbacks = map(collect(pairs(sort!( + OrderedDict(cb_classes); by = p -> p.rootfind)))) do (equiv_class, cbs_in_class) + return generate_vector_rootfinding_callback( + cbs_in_class, sys, dvs, ps; rootfind = equiv_class.rootfind, + reinitialization = equiv_class.reinitialization, kwargs...) + end + if length(compiled_callbacks) == 1 + return compiled_callbacks[] + else + return CallbackSet(compiled_callbacks...) + end +end + +function compile_user_affect(affect::FunctionalAffect, cb, sys, dvs, ps; kwargs...) + dvs_ind = Dict(reverse(en) for en in enumerate(dvs)) + v_inds = map(sym -> dvs_ind[sym], unknowns(affect)) + + if has_index_cache(sys) && get_index_cache(sys) !== nothing + p_inds = [if (pind = parameter_index(sys, sym)) === nothing + sym + else + pind + end + for sym in parameters(affect)] + else + ps_ind = Dict(reverse(en) for en in enumerate(ps)) + p_inds = map(sym -> get(ps_ind, sym, sym), parameters(affect)) + end + # HACK: filter out eliminated symbols. Not clear this is the right thing to do + # (MTK should keep these symbols) + u = filter(x -> !isnothing(x[2]), collect(zip(unknowns_syms(affect), v_inds))) |> + NamedTuple + p = filter(x -> !isnothing(x[2]), collect(zip(parameters_syms(affect), p_inds))) |> + NamedTuple + + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + save_idxs = get(ic.callback_to_clocks, cb, Int[]) + else + save_idxs = Int[] + end + let u = u, p = p, user_affect = func(affect), ctx = context(affect), + save_idxs = save_idxs + + function (integ) + user_affect(integ, u, p, ctx) + for idx in save_idxs + SciMLBase.save_discretes!(integ, idx) + end + end + end +end + +function invalid_variables(sys, expr) + filter(x -> !any(isequal(x), all_symbols(sys)), reduce(vcat, vars(expr); init = [])) +end +function unassignable_variables(sys, expr) + assignable_syms = reduce( + vcat, Symbolics.scalarize.(vcat( + unknowns(sys), parameters(sys; initial_parameters = true))); + init = []) + written = reduce(vcat, Symbolics.scalarize.(vars(expr)); init = []) + return filter( + x -> !any(isequal(x), assignable_syms), written) +end + +@generated function _generated_writeback(integ, setters::NamedTuple{NS1, <:Tuple}, + values::NamedTuple{NS2, <:Tuple}) where {NS1, NS2} + setter_exprs = [] + for name in NS2 + if !(name in NS1) + missing_name = "Tried to write back to $name from affect; only declared states ($NS1) may be written to." + error(missing_name) + end + push!(setter_exprs, :(setters.$name(integ, values.$name))) + end + return :(begin + $(setter_exprs...) + end) +end + +function check_assignable(sys, sym) + if symbolic_type(sym) == ScalarSymbolic() + is_variable(sys, sym) || is_parameter(sys, sym) + elseif symbolic_type(sym) == ArraySymbolic() + is_variable(sys, sym) || is_parameter(sys, sym) || + all(x -> check_assignable(sys, x), collect(sym)) + elseif sym isa Union{AbstractArray, Tuple} + all(x -> check_assignable(sys, x), sym) + else + false + end +end + +function compile_affect(affect::FunctionalAffect, cb, sys, dvs, ps; kwargs...) + compile_user_affect(affect, cb, sys, dvs, ps; kwargs...) +end +function _compile_optional_affect(default, aff, cb, sys, dvs, ps; kwargs...) + if isnothing(aff) || aff == default + return nothing + else + return compile_affect(aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + end +end +function generate_timed_callback(cb, sys, dvs, ps; postprocess_affect_expr! = nothing, + kwargs...) + cond = condition(cb) + as = compile_affect(affects(cb), cb, sys, dvs, ps; expression = Val{false}, + postprocess_affect_expr!, kwargs...) + + user_initfun = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + user_finfun = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && + (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing + initfn = let + save_idxs = save_idxs + initfun = user_initfun + function (cb, u, t, integrator) + if !isnothing(initfun) + initfun(integrator) + end + for idx in save_idxs + SciMLBase.save_discretes!(integrator, idx) + end + end + end + else + initfn = isnothing(user_initfun) ? SciMLBase.INITIALIZE_DEFAULT : + (_, _, _, i) -> user_initfun(i) + end + finfun = isnothing(user_finfun) ? SciMLBase.FINALIZE_DEFAULT : + (_, _, _, i) -> user_finfun(i) + if cond isa AbstractVector + # Preset Time + return PresetTimeCallback( + cond, as; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) + else + # Periodic + return PeriodicCallback( + as, cond; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) + end +end + +function generate_discrete_callback(cb, sys, dvs, ps; postprocess_affect_expr! = nothing, + kwargs...) + if is_timed_condition(cb) + return generate_timed_callback(cb, sys, dvs, ps; postprocess_affect_expr!, + kwargs...) + else + c = compile_condition(cb, sys, dvs, ps; expression = Val{false}, kwargs...) + as = compile_affect(affects(cb), cb, sys, dvs, ps; expression = Val{false}, + postprocess_affect_expr!, kwargs...) + + user_initfun = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + user_finfun = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && + (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing + initfn = let save_idxs = save_idxs, initfun = user_initfun + function (cb, u, t, integrator) + if !isnothing(initfun) + initfun(integrator) + end + for idx in save_idxs + SciMLBase.save_discretes!(integrator, idx) + end + end + end + else + initfn = isnothing(user_initfun) ? SciMLBase.INITIALIZE_DEFAULT : + (_, _, _, i) -> user_initfun(i) + end + finfun = isnothing(user_finfun) ? SciMLBase.FINALIZE_DEFAULT : + (_, _, _, i) -> user_finfun(i) + return DiscreteCallback( + c, as; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) + end +end + +function generate_discrete_callbacks(sys::AbstractSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) + has_discrete_events(sys) || return nothing + symcbs = discrete_events(sys) + isempty(symcbs) && return nothing + + dbs = map(symcbs) do cb + generate_discrete_callback(cb, sys, dvs, ps; kwargs...) + end + + dbs +end + +merge_cb(::Nothing, ::Nothing) = nothing +merge_cb(::Nothing, x) = merge_cb(x, nothing) +merge_cb(x, ::Nothing) = x +merge_cb(x, y) = CallbackSet(x, y) + +function process_events(sys; callback = nothing, kwargs...) + if has_continuous_events(sys) && !isempty(continuous_events(sys)) + contin_cb = generate_rootfinding_callback(sys; kwargs...) + else + contin_cb = nothing + end + if has_discrete_events(sys) && !isempty(discrete_events(sys)) + discrete_cb = generate_discrete_callbacks(sys; kwargs...) + else + discrete_cb = nothing + end + + cb = merge_cb(contin_cb, callback) + (discrete_cb === nothing) ? cb : CallbackSet(contin_cb, discrete_cb...) +end diff --git a/src/systems/clock_inference.jl b/src/systems/clock_inference.jl new file mode 100644 index 0000000000..b535773061 --- /dev/null +++ b/src/systems/clock_inference.jl @@ -0,0 +1,197 @@ +struct ClockInference{S} + ts::S + eq_domain::Vector{TimeDomain} + var_domain::Vector{TimeDomain} + inferred::BitSet +end + +function ClockInference(ts::TransformationState) + @unpack structure = ts + @unpack graph = structure + eq_domain = TimeDomain[ContinuousClock() for _ in 1:nsrcs(graph)] + var_domain = TimeDomain[ContinuousClock() for _ in 1:ndsts(graph)] + inferred = BitSet() + for (i, v) in enumerate(get_fullvars(ts)) + d = get_time_domain(ts, v) + if is_concrete_time_domain(d) + push!(inferred, i) + var_domain[i] = d + end + end + ClockInference(ts, eq_domain, var_domain, inferred) +end + +struct NotInferredTimeDomain end +function error_sample_time(eq) + error("$eq\ncontains `SampleTime` but it is not an Inferred discrete equation.") +end +function substitute_sample_time(ci::ClockInference, ts::TearingState) + @unpack eq_domain = ci + eqs = copy(equations(ts)) + @assert length(eqs) == length(eq_domain) + for i in eachindex(eqs) + eq = eqs[i] + domain = eq_domain[i] + dt = sampletime(domain) + neweq = substitute_sample_time(eq, dt) + if neweq isa NotInferredTimeDomain + error_sample_time(eq) + end + eqs[i] = neweq + end + @set! ts.sys.eqs = eqs + @set! ci.ts = ts +end + +function substitute_sample_time(eq::Equation, dt) + substitute_sample_time(eq.lhs, dt) ~ substitute_sample_time(eq.rhs, dt) +end + +function substitute_sample_time(ex, dt) + iscall(ex) || return ex + op = operation(ex) + args = arguments(ex) + if op == SampleTime + dt === nothing && return NotInferredTimeDomain() + return dt + else + new_args = similar(args) + for (i, arg) in enumerate(args) + ex_arg = substitute_sample_time(arg, dt) + if ex_arg isa NotInferredTimeDomain + return ex_arg + end + new_args[i] = ex_arg + end + maketerm(typeof(ex), op, new_args, metadata(ex)) + end +end + +function infer_clocks!(ci::ClockInference) + @unpack ts, eq_domain, var_domain, inferred = ci + @unpack var_to_diff, graph = ts.structure + fullvars = get_fullvars(ts) + isempty(inferred) && return ci + # TODO: add a graph type to do this lazily + var_graph = SimpleGraph(ndsts(graph)) + for eq in 𝑠vertices(graph) + vvs = 𝑠neighbors(graph, eq) + if !isempty(vvs) + fv, vs = Iterators.peel(vvs) + for v in vs + add_edge!(var_graph, fv, v) + end + end + end + for v in vertices(var_to_diff) + if (v′ = var_to_diff[v]) !== nothing + add_edge!(var_graph, v, v′) + end + end + cc = connected_components(var_graph) + for c′ in cc + c = BitSet(c′) + idxs = intersect(c, inferred) + isempty(idxs) && continue + if !allequal(var_domain[i] for i in idxs) + display(fullvars[c′]) + throw(ClockInferenceException("Clocks are not consistent in connected component $(fullvars[c′])")) + end + vd = var_domain[first(idxs)] + for v in c′ + var_domain[v] = vd + end + end + + for v in 𝑑vertices(graph) + vd = var_domain[v] + eqs = 𝑑neighbors(graph, v) + isempty(eqs) && continue + for eq in eqs + eq_domain[eq] = vd + end + end + + ci = substitute_sample_time(ci, ts) + return ci +end + +function resize_or_push!(v, val, idx) + n = length(v) + if idx > n + for _ in (n + 1):idx + push!(v, Int[]) + end + resize!(v, idx) + end + push!(v[idx], val) +end + +function is_time_domain_conversion(v) + iscall(v) && (o = operation(v)) isa Operator && + input_timedomain(o) != output_timedomain(o) +end + +function split_system(ci::ClockInference{S}) where {S} + @unpack ts, eq_domain, var_domain, inferred = ci + fullvars = get_fullvars(ts) + @unpack graph = ts.structure + continuous_id = Ref(0) + clock_to_id = Dict{TimeDomain, Int}() + id_to_clock = TimeDomain[] + eq_to_cid = Vector{Int}(undef, nsrcs(graph)) + cid_to_eq = Vector{Int}[] + var_to_cid = Vector{Int}(undef, ndsts(graph)) + cid_to_var = Vector{Int}[] + cid_counter = Ref(0) + for (i, d) in enumerate(eq_domain) + cid = let cid_counter = cid_counter, id_to_clock = id_to_clock, + continuous_id = continuous_id + + get!(clock_to_id, d) do + cid = (cid_counter[] += 1) + push!(id_to_clock, d) + if d == ContinuousClock() + continuous_id[] = cid + end + cid + end + end + eq_to_cid[i] = cid + resize_or_push!(cid_to_eq, i, cid) + end + continuous_id = continuous_id[] + input_idxs = map(_ -> Int[], 1:cid_counter[]) + inputs = map(_ -> Any[], 1:cid_counter[]) + nvv = length(var_domain) + for i in 1:nvv + d = var_domain[i] + cid = get(clock_to_id, d, 0) + @assert cid!==0 "Internal error! Variable $(fullvars[i]) doesn't have a inferred time domain." + var_to_cid[i] = cid + v = fullvars[i] + if is_time_domain_conversion(v) + push!(input_idxs[cid], i) + push!(inputs[cid], fullvars[i]) + end + resize_or_push!(cid_to_var, i, cid) + end + + tss = similar(cid_to_eq, S) + for (id, ieqs) in enumerate(cid_to_eq) + ts_i = system_subset(ts, ieqs) + if id != continuous_id + ts_i = shift_discrete_system(ts_i) + @set! ts_i.structure.only_discrete = true + end + tss[id] = ts_i + end + if continuous_id != 0 + tss[continuous_id], tss[end] = tss[end], tss[continuous_id] + inputs[continuous_id], inputs[end] = inputs[end], inputs[continuous_id] + id_to_clock[continuous_id], id_to_clock[end] = id_to_clock[end], + id_to_clock[continuous_id] + continuous_id = lastindex(tss) + end + return tss, inputs, continuous_id, id_to_clock +end diff --git a/src/systems/codegen_utils.jl b/src/systems/codegen_utils.jl new file mode 100644 index 0000000000..a3fe53b95d --- /dev/null +++ b/src/systems/codegen_utils.jl @@ -0,0 +1,294 @@ +""" + $(TYPEDSIGNATURES) + +Return the name for the `i`th argument in a function generated by `build_function_wrapper`. +""" +function generated_argument_name(i::Int) + return Symbol(:__mtk_arg_, i) +end + +""" + $(TYPEDSIGNATURES) + +Given the arguments to `build_function_wrapper`, return a list of assignments which +reconstruct array variables if they are present scalarized in `args`. + +# Keyword Arguments + +- `argument_name` a function of the form `(::Int) -> Symbol` which takes the index of + an argument to the generated function and returns the name of the argument in the + generated function. +""" +function array_variable_assignments(args...; argument_name = generated_argument_name) + # map array symbolic to an identically sized array where each element is (buffer_idx, idx_in_buffer) + var_to_arridxs = Dict{BasicSymbolic, Array{Tuple{Int, Int}}}() + for (i, arg) in enumerate(args) + # filter out non-arrays + # any element of args which is not an array is assumed to not contain a + # scalarized array symbolic. This works because the only non-array element + # is the independent variable + symbolic_type(arg) == NotSymbolic() || continue + arg isa AbstractArray || continue + + # go through symbolics + for (j, var) in enumerate(arg) + var = unwrap(var) + # filter out non-array-symbolics + iscall(var) || continue + operation(var) == getindex || continue + arrvar = arguments(var)[1] + # get and/or construct the buffer storing indexes + idxbuffer = get!( + () -> map(Returns((0, 0)), eachindex(arrvar)), var_to_arridxs, arrvar) + Origin(first.(axes(arrvar))...)(idxbuffer)[arguments(var)[2:end]...] = (i, j) + end + end + + assignments = Assignment[] + for (arrvar, idxs) in var_to_arridxs + # all elements of the array need to be present in `args` to form the + # reconstructing assignment + any(iszero ∘ first, idxs) && continue + + # if they are all in the same buffer, we can take a shortcut and `view` into it + if allequal(Iterators.map(first, idxs)) + buffer_idx = first(first(idxs)) + idxs = map(last, idxs) + # if all the elements are contiguous and ordered, turn the array of indexes into a range + # to help reduce allocations + if first(idxs) < last(idxs) && vec(idxs) == first(idxs):last(idxs) + idxs = first(idxs):last(idxs) + elseif vec(idxs) == last(idxs):-1:first(idxs) + idxs = last(idxs):-1:first(idxs) + else + # Otherwise, turn the indexes into an `SArray` so they're stack-allocated + idxs = SArray{Tuple{size(idxs)...}}(idxs) + end + # view and reshape + + expr = term(reshape, term(view, argument_name(buffer_idx), idxs), + size(arrvar)) + else + elems = map(idxs) do idx + i, j = idx + term(getindex, argument_name(i), j) + end + # use `MakeArray` syntax and generate a stack-allocated array + expr = term(SymbolicUtils.Code.create_array, SArray, nothing, + Val(ndims(arrvar)), Val(length(arrvar)), elems...) + end + if any(x -> !isone(first(x)), axes(arrvar)) + expr = term(Origin(first.(axes(arrvar))...), expr) + end + push!(assignments, arrvar ← expr) + end + + return assignments +end + +""" + $(TYPEDSIGNATURES) + +A wrapper around `build_function` which performs the necessary transformations for +code generation of all types of systems. `expr` is the expression returned from the +generated functions, and `args` are the arguments. + +# Keyword Arguments + +- `p_start`, `p_end`: Denotes the indexes in `args` where the buffers of the splatted + `MTKParameters` object are present. These are collapsed into a single argument and + destructured inside the function. `p_start` must also be provided for non-split systems + since it is used by `wrap_delays`. +- `wrap_delays`: Whether to transform delayed unknowns of `sys` present in `expr` into + calls to a history function. The history function is added to the list of arguments + right before parameters, at the index `p_start`. +- `wrap_code`: Forwarded to `build_function`. +- `add_observed`: Whether to add assignment statements for observed equations in the + generated code. +- `filter_observed`: A predicate function to filter out observed equations which should + not be added to the generated code. +- `create_bindings`: Whether to explicitly destructure arrays of symbolics present in + `args` in the generated code. If `false`, all usages of the individual symbolics will + instead call `getindex` on the relevant argument. This is useful if the generated + function writes to one of its arguments and expects subsequent code to use the new + values. Note that the collapsed `MTKParameters` argument will always be explicitly + destructured regardless of this keyword argument. +- `output_type`: The type of the output buffer. If `mkarray` (see below) is `nothing`, + this will be passed to the `similarto` argument of `build_function`. If `output_type` + is `Tuple`, `expr` will be wrapped in `SymbolicUtils.Code.MakeTuple` (regardless of + whether it is scalar or an array). +- `mkarray`: A function which accepts `expr` and `output_type` and returns a code + generation object similar to `MakeArray` or `MakeTuple` to be used to generate + code for `expr`. +- `wrap_mtkparameters`: Whether to collapse parameter buffers for a split system into a + argument. +- `extra_assignments`: Extra `Assignment` statements to prefix to `expr`, after all other + assignments. + +All other keyword arguments are forwarded to `build_function`. +""" +function build_function_wrapper(sys::AbstractSystem, expr, args...; p_start = 2, + p_end = is_time_dependent(sys) ? length(args) - 1 : length(args), + wrap_delays = is_dde(sys), wrap_code = identity, + add_observed = true, filter_observed = Returns(true), + create_bindings = false, output_type = nothing, mkarray = nothing, + wrap_mtkparameters = true, extra_assignments = Assignment[], cse = true, kwargs...) + isscalar = !(expr isa AbstractArray || symbolic_type(expr) == ArraySymbolic()) + # filter observed equations + obs = filter(filter_observed, observed(sys)) + # turn delayed unknowns into calls to the history function + if wrap_delays + history_arg = is_split(sys) ? MTKPARAMETERS_ARG : generated_argument_name(p_start) + obs = map(obs) do eq + delay_to_function(sys, eq; history_arg) + end + expr = delay_to_function(sys, expr; history_arg) + # add extra argument + args = (args[1:(p_start - 1)]..., DDE_HISTORY_FUN, args[p_start:end]...) + p_start += 1 + p_end += 1 + end + pdeps = parameter_dependencies(sys) + # get the constants to add to the code + cmap, _ = get_cmap(sys) + extra_constants = collect_constants(expr) + filter!(extra_constants) do c + !any(x -> isequal(c, x.lhs), cmap) + end + for c in extra_constants + push!(cmap, c ~ getdefault(c)) + end + # only get the necessary observed equations, avoiding extra computation + if add_observed && !isempty(obs) + obsidxs = observed_equations_used_by(sys, expr; obs) + else + obsidxs = Int[] + end + # similarly for parameter dependency equations + pdepidxs = observed_equations_used_by(sys, expr; obs = pdeps) + for i in obsidxs + union!(pdepidxs, observed_equations_used_by(sys, obs[i].rhs; obs = pdeps)) + end + # assignments for reconstructing scalarized array symbolics + assignments = array_variable_assignments(args...) + + for eq in Iterators.flatten((cmap, pdeps[pdepidxs], obs[obsidxs])) + push!(assignments, eq.lhs ← eq.rhs) + end + append!(assignments, extra_assignments) + + args = ntuple(Val(length(args))) do i + arg = args[i] + # for time-dependent systems, all arguments are passed through `time_varying_as_func` + # TODO: This is legacy behavior and a candidate for removal in v10 since we have callable + # parameters now. + if is_time_dependent(sys) + arg = if symbolic_type(arg) == NotSymbolic() + arg isa AbstractArray ? + map(x -> time_varying_as_func(unwrap(x), sys), arg) : arg + else + time_varying_as_func(unwrap(arg), sys) + end + end + # Make sure to use the proper names for arguments + if symbolic_type(arg) == NotSymbolic() && arg isa AbstractArray + DestructuredArgs(arg, generated_argument_name(i); create_bindings) + else + arg + end + end + + # wrap into a single MTKParameters argument + if is_split(sys) && wrap_mtkparameters + if p_start > p_end + # In case there are no parameter buffers, still insert an argument + args = (args[1:(p_start - 1)]..., MTKPARAMETERS_ARG, args[(p_end + 1):end]...) + else + # cannot apply `create_bindings` here since it doesn't nest + args = (args[1:(p_start - 1)]..., + DestructuredArgs(collect(args[p_start:p_end]), MTKPARAMETERS_ARG), + args[(p_end + 1):end]...) + end + end + + # add preface assignments + if has_preface(sys) && (pref = preface(sys)) !== nothing + append!(assignments, pref) + end + + wrap_code = wrap_code .∘ wrap_assignments(isscalar, assignments) + + # handling of `output_type` and `mkarray` + similarto = nothing + if output_type === Tuple + expr = MakeTuple(Tuple(expr)) + wrap_code = wrap_code[1] + elseif mkarray === nothing + similarto = output_type + else + expr = mkarray(expr, output_type) + wrap_code = wrap_code[2] + end + + # scalar `build_function` only accepts a single function for `wrap_code`. + if wrap_code isa Tuple && symbolic_type(expr) == ScalarSymbolic() + wrap_code = wrap_code[1] + end + return build_function(expr, args...; wrap_code, similarto, cse, kwargs...) +end + +""" + $(TYPEDEF) + +A wrapper around a generated in-place and out-of-place function. The type-parameter `P` +must be a 3-tuple where the first element is the index of the parameter object in the +arguments, the second is the expected number of arguments in the out-of-place variant +of the function, and the third is a boolean indicating whether the generated functions +are for a split system. For scalar functions, the inplace variant can be `nothing`. +""" +struct GeneratedFunctionWrapper{P, O, I} <: Function + f_oop::O + f_iip::I +end + +function GeneratedFunctionWrapper{P}(foop::O, fiip::I) where {P, O, I} + GeneratedFunctionWrapper{P, O, I}(foop, fiip) +end + +function (gfw::GeneratedFunctionWrapper)(args...) + _generated_call(gfw, args...) +end + +@generated function _generated_call(gfw::GeneratedFunctionWrapper{P}, args...) where {P} + paramidx, nargs, issplit = P + iip = false + # IIP case has one more argument + if length(args) == nargs + 1 + nargs += 1 + paramidx += 1 + iip = true + end + if length(args) != nargs + throw(ArgumentError("Expected $nargs arguments, got $(length(args)).")) + end + + # the function to use + f = iip ? :(gfw.f_iip) : :(gfw.f_oop) + # non-split systems just call it as-is + if !issplit + return :($f(args...)) + end + if args[paramidx] <: Union{Tuple, MTKParameters} && + !(args[paramidx] <: Tuple{Vararg{Number}}) + # for split systems, call it as-is if the parameter object is a tuple or MTKParameters + # but not if it is a tuple of numbers + return :($f(args...)) + else + # The user provided a single buffer/tuple for the parameter object, so wrap that + # one in a tuple + fargs = ntuple(Val(length(args))) do i + i == paramidx ? :((args[$i], nothing)) : :(args[$i]) + end + return :($f($(fargs...))) + end +end diff --git a/src/systems/connectors.jl b/src/systems/connectors.jl new file mode 100644 index 0000000000..6b0600fbb7 --- /dev/null +++ b/src/systems/connectors.jl @@ -0,0 +1,1060 @@ +using Symbolics: StateMachineOperator +isconnection(_) = false +isconnection(_::Connection) = true +""" + domain_connect(sys1, sys2, syss...) + +Adds a domain only connection equation, through and across state equations are not generated. +""" +function domain_connect(sys1, sys2, syss...) + syss = (sys1, sys2, syss...) + length(unique(nameof, syss)) == length(syss) || error("connect takes distinct systems!") + Equation(Connection(:domain), Connection(syss)) # the RHS are connected systems +end + +function get_connection_type(s) + s = unwrap(s) + if iscall(s) && operation(s) === getindex + s = arguments(s)[1] + end + getmetadata(s, VariableConnectType, Equality) +end + +macro connector(expr) + esc(component_post_processing(expr, true)) +end + +abstract type AbstractConnectorType end +struct StreamConnector <: AbstractConnectorType end +struct RegularConnector <: AbstractConnectorType end +struct DomainConnector <: AbstractConnectorType end + +function connector_type(sys::AbstractSystem) + unkvars = get_unknowns(sys) + n_stream = 0 + n_flow = 0 + n_regular = 0 # unknown that is not input, output, stream, or flow. + for s in unkvars + vtype = get_connection_type(s) + if vtype === Stream + isarray(s) && error("Array stream variables are not supported. Got $s.") + n_stream += 1 + elseif vtype === Flow + n_flow += 1 + elseif !(isinput(s) || isoutput(s)) + n_regular += 1 + end + end + (n_stream > 0 && n_flow > 1) && + error("There are multiple flow variables in the stream connector $(nameof(sys))!") + if n_flow == 1 && length(unkvars) == 1 + return DomainConnector() + end + if n_flow != n_regular && !isframe(sys) + @warn "$(nameof(sys)) contains $n_flow flow variables, yet $n_regular regular " * + "(non-flow, non-stream, non-input, non-output) variables. " * + "This could lead to imbalanced model that are difficult to debug. " * + "Consider marking some of the regular variables as input/output variables." + end + n_stream > 0 ? StreamConnector() : RegularConnector() +end + +is_domain_connector(s) = isconnector(s) && get_connector_type(s) === DomainConnector() + +get_systems(c::Connection) = c.systems + +instream(a) = term(instream, unwrap(a), type = symtype(a)) +SymbolicUtils.promote_symtype(::typeof(instream), _) = Real + +isconnector(s::AbstractSystem) = has_connector_type(s) && get_connector_type(s) !== nothing + +""" + $(TYPEDEF) + +Utility struct which wraps a symbolic variable used in a `Connection` to enable `Base.show` +to work. +""" +struct SymbolicWithNameof + var::Any +end + +function Base.nameof(x::SymbolicWithNameof) + return Symbol(x.var) +end + +is_causal_variable_connection(c) = false +function is_causal_variable_connection(c::Connection) + all(x -> x isa SymbolicWithNameof, get_systems(c)) +end + +const ConnectableSymbolicT = Union{BasicSymbolic, Num, Symbolics.Arr} + +const CAUSAL_CONNECTION_ERR = """ +Only causal variables can be used in a `connect` statement. The first argument must \ +be a single output variable and all subsequent variables must be input variables. +""" + +function VariableNotOutputError(var) + ArgumentError(""" + $CAUSAL_CONNECTION_ERR Expected $var to be marked as an output with `[output = true]` \ + in the variable metadata. + """) +end + +function VariableNotInputError(var) + ArgumentError(""" + $CAUSAL_CONNECTION_ERR Expected $var to be marked an input with `[input = true]` \ + in the variable metadata. + """) +end + +""" + $(TYPEDSIGNATURES) + +Perform validation for a connect statement involving causal variables. +""" +function validate_causal_variables_connection(allvars) + var1 = allvars[1] + var2 = allvars[2] + vars = Base.tail(Base.tail(allvars)) + for var in allvars + vtype = getvariabletype(var) + vtype === VARIABLE || + throw(ArgumentError("Expected $var to be of kind `$VARIABLE`. Got `$vtype`.")) + end + if length(unique(allvars)) !== length(allvars) + throw(ArgumentError("Expected all connection variables to be unique. Got variables $allvars which contains duplicate entries.")) + end + allsizes = map(size, allvars) + if !allequal(allsizes) + throw(ArgumentError("Expected all connection variables to have the same size. Got variables $allvars with sizes $allsizes respectively.")) + end + isoutput(var1) || throw(VariableNotOutputError(var1)) + isinput(var2) || throw(VariableNotInputError(var2)) + for var in vars + isinput(var) || throw(VariableNotInputError(var)) + end +end + +""" + $(TYPEDSIGNATURES) + +Connect multiple causal variables. The first variable must be an output, and all subsequent +variables must be inputs. The statement `connect(var1, var2, var3, ...)` expands to: + +```julia +var1 ~ var2 +var1 ~ var3 +# ... +``` +""" +function Symbolics.connect(var1::ConnectableSymbolicT, var2::ConnectableSymbolicT, + vars::ConnectableSymbolicT...) + allvars = (var1, var2, vars...) + validate_causal_variables_connection(allvars) + return Equation(Connection(), Connection(map(SymbolicWithNameof, unwrap.(allvars)))) +end + +function flowvar(sys::AbstractSystem) + sts = get_unknowns(sys) + for s in sts + vtype = get_connection_type(s) + vtype === Flow && return s + end + error("There in no flow variable in $(nameof(sys))") +end + +function collect_instream!(set, eq::Equation) + collect_instream!(set, eq.lhs) | collect_instream!(set, eq.rhs) +end + +function collect_instream!(set, expr, occurs = false) + iscall(expr) || return occurs + op = operation(expr) + op === instream && (push!(set, expr); occurs = true) + for a in SymbolicUtils.arguments(expr) + occurs |= collect_instream!(set, a, occurs) + end + return occurs +end + +#positivemax(m, ::Any; tol=nothing)= max(m, something(tol, 1e-8)) +#_positivemax(m, tol) = ifelse((-tol <= m) & (m <= tol), ((3 * tol - m) * (tol + m)^3)/(16 * tol^3) + tol, max(m, tol)) +function _positivemax(m, si) + T = typeof(m) + relativeTolerance = 1e-4 + nominal = one(T) + eps = relativeTolerance * nominal + alpha = if si > eps + one(T) + else + if si > 0 + (si / eps)^2 * (3 - 2 * si / eps) + else + zero(T) + end + end + alpha * max(m, 0) + (1 - alpha) * eps +end +@register_symbolic _positivemax(m, tol) +positivemax(m, ::Any; tol = nothing) = _positivemax(m, tol) +mydiv(num, den) = + if den == 0 + error() + else + num / den + end +@register_symbolic mydiv(n, d) + +function generate_isouter(sys::AbstractSystem) + outer_connectors = Symbol[] + for s in get_systems(sys) + n = nameof(s) + isconnector(s) && push!(outer_connectors, n) + end + let outer_connectors = outer_connectors + function isouter(sys)::Bool + s = string(nameof(sys)) + isconnector(sys) || error("$s is not a connector!") + idx = findfirst(isequal(NAMESPACE_SEPARATOR), s) + parent_name = Symbol(idx === nothing ? s : s[1:prevind(s, idx)]) + parent_name in outer_connectors + end + end +end + +struct LazyNamespace + namespace::Union{Nothing, AbstractSystem} + sys::Any +end + +_getname(::Nothing) = nothing +_getname(sys) = nameof(sys) +Base.copy(l::LazyNamespace) = renamespace(_getname(l.namespace), l.sys) +Base.nameof(l::LazyNamespace) = renamespace(_getname(l.namespace), nameof(l.sys)) + +struct ConnectionElement + sys::LazyNamespace + v::Any + isouter::Bool + h::UInt +end +function _hash_impl(sys, v, isouter) + hashcore = hash(nameof(sys)::Symbol) ⊻ hash(getname(v)::Symbol) + hashouter = isouter ? hash(true) : hash(false) + hashcore ⊻ hashouter +end +function ConnectionElement(sys::LazyNamespace, v, isouter::Bool) + ConnectionElement(sys, v, isouter, _hash_impl(sys, v, isouter)) +end +Base.nameof(l::ConnectionElement) = renamespace(nameof(l.sys), getname(l.v)) +Base.isequal(l1::ConnectionElement, l2::ConnectionElement) = l1 == l2 +function Base.:(==)(l1::ConnectionElement, l2::ConnectionElement) + l1.isouter == l2.isouter && nameof(l1.sys) == nameof(l2.sys) && isequal(l1.v, l2.v) +end + +const _debug_mode = Base.JLOptions().check_bounds == 1 + +function Base.show(io::IO, c::ConnectionElement) + @unpack sys, v, isouter = c + print(io, nameof(sys), ".", v, "::", isouter ? "outer" : "inner") +end + +function Base.hash(e::ConnectionElement, salt::UInt) + if _debug_mode + @assert e.h === _hash_impl(e.sys, e.v, e.isouter) + end + e.h ⊻ salt +end +namespaced_var(l::ConnectionElement) = unknowns(l, l.v) +unknowns(l::ConnectionElement, v) = unknowns(copy(l.sys), v) + +function withtrueouter(e::ConnectionElement) + e.isouter && return e + # we undo the xor + newhash = (e.h ⊻ hash(false)) ⊻ hash(true) + ConnectionElement(e.sys, e.v, true, newhash) +end + +struct ConnectionSet + set::Vector{ConnectionElement} # namespace.sys, var, isouter +end +ConnectionSet() = ConnectionSet(ConnectionElement[]) +Base.copy(c::ConnectionSet) = ConnectionSet(copy(c.set)) +Base.:(==)(a::ConnectionSet, b::ConnectionSet) = a.set == b.set +Base.sort(a::ConnectionSet) = ConnectionSet(sort(a.set, by = string)) + +function Base.show(io::IO, c::ConnectionSet) + print(io, "<") + for i in 1:(length(c.set) - 1) + @unpack sys, v, isouter = c.set[i] + print(io, nameof(sys), ".", v, "::", isouter ? "outer" : "inner", ", ") + end + @unpack sys, v, isouter = last(c.set) + print(io, nameof(sys), ".", v, "::", isouter ? "outer" : "inner", ">") +end + +@noinline function connection_error(ss) + error("Different types of connectors are in one connection statement: <$(map(nameof, ss))>") +end + +"Return true if the system is a 3D multibody frame, otherwise return false." +function isframe(sys) + (has_metadata(sys) && (md = get_metadata(sys)) isa Dict) || return false + get(md, :frame, false) +end + +"Return orientation object of a multibody frame." +function ori(sys) + @assert has_metadata(sys) + md = get_metadata(sys) + if md isa Dict && (O = get(md, :orientation, nothing)) !== nothing + return O + else + error("System $(sys.name) does not have an orientation object.") + end +end + +""" + $(TYPEDSIGNATURES) + +Populate `connectionsets` with connections between the connectors `ss`, all of which are +namespaced by `namespace`. + +# Keyword Arguments +- `ignored_connects`: A tuple of the systems and variables for which connections should be + ignored. Of the format returned from `as_hierarchy`. +- `namespaced_ignored_systems`: The `from_hierarchy` versions of entries in + `ignored_connects[1]`, purely to avoid unnecessary recomputation. +""" +function connection2set!(connectionsets, namespace, ss, isouter; + ignored_systems = HierarchySystemT[], ignored_variables = HierarchyVariableT[]) + ns_ignored_systems = from_hierarchy.(ignored_systems) + ns_ignored_variables = from_hierarchy.(ignored_variables) + # ignore specified systems + ss = filter(ss) do s + !any(x -> nameof(x) == nameof(s), ns_ignored_systems) + end + # `ignored_variables` for each `s` in `ss` + corresponding_ignored_variables = map( + Base.Fix2(ignored_systems_for_subsystem, ignored_variables), ss) + corresponding_namespaced_ignored_variables = map( + Broadcast.BroadcastFunction(from_hierarchy), corresponding_ignored_variables) + + regular_ss = [] + domain_ss = nothing + for s in ss + if is_domain_connector(s) + if domain_ss === nothing + domain_ss = s + else + names = join(map(string ∘ nameof, ss), ",") + error("connect($names) contains multiple source domain connectors. There can only be one!") + end + else + push!(regular_ss, s) + end + end + T = ConnectionElement + @assert !isempty(regular_ss) + ss = regular_ss + # domain connections don't generate any equations + if domain_ss !== nothing + cset = ConnectionElement[] + dv = only(unknowns(domain_ss)) + for (i, s) in enumerate(ss) + sts = unknowns(s) + io = isouter(s) + _ignored_variables = corresponding_ignored_variables[i] + _namespaced_ignored_variables = corresponding_namespaced_ignored_variables[i] + for v in sts + vtype = get_connection_type(v) + (vtype === Flow && isequal(v, dv)) || continue + any(isequal(v), _namespaced_ignored_variables) && continue + push!(cset, T(LazyNamespace(namespace, domain_ss), dv, false)) + push!(cset, T(LazyNamespace(namespace, s), v, io)) + end + end + @assert length(cset) > 0 + push!(connectionsets, ConnectionSet(cset)) + return connectionsets + end + s1 = first(ss) + sts1v = unknowns(s1) + if isframe(s1) # Multibody + O = ori(s1) + orientation_vars = Symbolics.unwrap.(collect(vec(O.R))) + sts1v = [sts1v; orientation_vars] + end + sts1 = Set(sts1v) + num_unknowns = length(sts1) + + # we don't filter here because `csets` should include the full set of unknowns. + # not all of `ss` will have the same (or any) variables filtered so the ones + # that aren't should still go in the right cset. Since `sts1` is only used for + # validating that all systems being connected are of the same type, it has + # unfiltered entries. + csets = [T[] for _ in 1:num_unknowns] # Add 9 orientation variables if connection is between multibody frames + for (i, s) in enumerate(ss) + unknown_vars = unknowns(s) + if isframe(s) # Multibody + O = ori(s) + orientation_vars = Symbolics.unwrap.(vec(O.R)) + unknown_vars = [unknown_vars; orientation_vars] + end + i != 1 && ((num_unknowns == length(unknown_vars) && + all(Base.Fix2(in, sts1), unknown_vars)) || + connection_error(ss)) + io = isouter(s) + # don't `filter!` here so that `j` points to the correct cset regardless of + # which variables are filtered. + for (j, v) in enumerate(unknown_vars) + any(isequal(v), corresponding_namespaced_ignored_variables[i]) && continue + push!(csets[j], T(LazyNamespace(namespace, s), v, io)) + end + end + for cset in csets + v = first(cset).v + vtype = get_connection_type(v) + if domain_ss !== nothing && vtype === Flow && + (dv = only(unknowns(domain_ss)); isequal(v, dv)) + push!(cset, T(LazyNamespace(namespace, domain_ss), dv, false)) + end + for k in 2:length(cset) + vtype === get_connection_type(cset[k].v) || connection_error(ss) + end + push!(connectionsets, ConnectionSet(cset)) + end +end + +function generate_connection_set( + sys::AbstractSystem, find = nothing, replace = nothing; scalarize = false) + connectionsets = ConnectionSet[] + domain_csets = ConnectionSet[] + sys = generate_connection_set!( + connectionsets, domain_csets, sys, find, replace, scalarize, nothing, ignored_connections(sys)) + csets = merge(connectionsets) + domain_csets = merge([csets; domain_csets], true) + + sys, (csets, domain_csets) +end + +""" + $(TYPEDSIGNATURES) + +For a list of `systems` in a connect equation, return the subset of it to ignore (as a +list of hierarchical systems) based on `ignored_system_aps`, the analysis points to be +ignored. All analysis points in `ignored_system_aps` must contain systems (connectors) +as their input/outputs. +""" +function systems_to_ignore(ignored_system_aps::Vector{HierarchyAnalysisPointT}, + systems::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: AbstractSystem} + to_ignore = HierarchySystemT[] + for ap in ignored_system_aps + # if `systems` contains the input of the AP, ignore any outputs of the AP present in it. + isys_hierarchy = HierarchySystemT([ap[1].input; @view ap[2:end]]) + isys = from_hierarchy(isys_hierarchy) + any(x -> nameof(x) == nameof(isys), systems) || continue + + for outsys in ap[1].outputs + osys_hierarchy = HierarchySystemT([outsys; @view ap[2:end]]) + osys = from_hierarchy(osys_hierarchy) + any(x -> nameof(x) == nameof(osys), systems) || continue + push!(to_ignore, HierarchySystemT(osys_hierarchy)) + end + end + + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +For a list of `systems` in a connect equation, return the subset of their variables to +ignore (as a list of hierarchical variables) based on `ignored_system_aps`, the analysis +points to be ignored. All analysis points in `ignored_system_aps` must contain variables +as their input/outputs. +""" +function variables_to_ignore(ignored_variable_aps::Vector{HierarchyAnalysisPointT}, + systems::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: AbstractSystem} + to_ignore = HierarchyVariableT[] + for ap in ignored_variable_aps + ivar_hierarchy = HierarchyVariableT([ap[1].input; @view ap[2:end]]) + ivar = from_hierarchy(ivar_hierarchy) + any(x -> any(isequal(ivar), renamespace.((x,), unknowns(x))), systems) || continue + + for outvar in ap[1].outputs + ovar_hierarchy = HierarchyVariableT([as_hierarchy(outvar); @view ap[2:end]]) + ovar = from_hierarchy(ovar_hierarchy) + any(x -> any(isequal(ovar), renamespace.((x,), unknowns(x))), systems) || + continue + push!(to_ignore, HierarchyVariableT(ovar_hierarchy)) + end + end + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +For a list of variables `vars` in a connect equation, return the subset of them ignore +(as a list of symbolic variables) based on `ignored_system_aps`, the analysis points to +be ignored. All analysis points in `ignored_system_aps` must contain variables as their +input/outputs. +""" +function variables_to_ignore(ignored_variable_aps::Vector{HierarchyAnalysisPointT}, + vars::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: BasicSymbolic} + to_ignore = eltype(vars)[] + for ap in ignored_variable_aps + ivar_hierarchy = HierarchyVariableT([ap[1].input; @view ap[2:end]]) + ivar = from_hierarchy(ivar_hierarchy) + any(isequal(ivar), vars) || continue + + for outvar in ap[1].outputs + ovar_hierarchy = HierarchyVariableT([outvar; @view ap[2:end]]) + ovar = from_hierarchy(ovar_hierarchy) + any(isequal(ovar), vars) || continue + push!(to_ignore, ovar) + end + end + + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +Generate connection sets from `connect` equations. + +# Arguments + +- `connectionsets` is the list of connection sets to be populated by recursively + descending `sys`. +- `domain_csets` is the list of connection sets for domain connections. +- `sys` is the system whose equations are to be searched. +- `namespace` is a system representing the namespace in which `sys` exists, or `nothing` + for no namespace (if `sys` is top-level). +""" +function generate_connection_set!(connectionsets, domain_csets, + sys::AbstractSystem, find, replace, scalarize, namespace = nothing, + ignored_connects = (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[])) + subsys = get_systems(sys) + ignored_system_aps, ignored_variable_aps = ignored_connects + + isouter = generate_isouter(sys) + eqs′ = get_eqs(sys) + eqs = Equation[] + + cts = [] # connections + domain_cts = [] # connections + extra_unknowns = [] + for eq in eqs′ + lhs = eq.lhs + rhs = eq.rhs + + # causal variable connections will be expanded before we get here, + # but this guard is useful for `n_expanded_connection_equations`. + is_causal_variable_connection(rhs) && continue + if find !== nothing && find(rhs, _getname(namespace)) + neweq, extra_unknown = replace(rhs, _getname(namespace)) + if extra_unknown isa AbstractArray + append!(extra_unknowns, unwrap.(extra_unknown)) + elseif extra_unknown !== nothing + push!(extra_unknowns, extra_unknown) + end + neweq isa AbstractArray ? append!(eqs, neweq) : push!(eqs, neweq) + else + if lhs isa Connection && get_systems(lhs) === :domain + connected_systems = get_systems(rhs) + connection2set!(domain_csets, namespace, connected_systems, isouter; + ignored_systems = systems_to_ignore( + ignored_system_aps, connected_systems), + ignored_variables = variables_to_ignore( + ignored_variable_aps, connected_systems)) + elseif isconnection(rhs) + push!(cts, get_systems(rhs)) + else + # split connections and equations + if eq.lhs isa AbstractArray || eq.rhs isa AbstractArray + append!(eqs, Symbolics.scalarize(eq)) + else + push!(eqs, eq) + end + end + end + end + + # all connectors are eventually inside connectors. + T = ConnectionElement + # only generate connection sets for systems that are not ignored + for s in subsys + isconnector(s) || continue + is_domain_connector(s) && continue + for v in unknowns(s) + Flow === get_connection_type(v) || continue + push!(connectionsets, ConnectionSet([T(LazyNamespace(namespace, s), v, false)])) + end + end + + for ct in cts + connection2set!(connectionsets, namespace, ct, isouter; + ignored_systems = systems_to_ignore(ignored_system_aps, ct), + ignored_variables = variables_to_ignore(ignored_variable_aps, ct)) + end + + # pre order traversal + if !isempty(extra_unknowns) + @set! sys.unknowns = [get_unknowns(sys); extra_unknowns] + end + @set! sys.systems = map( + s -> generate_connection_set!(connectionsets, domain_csets, s, + find, replace, scalarize, renamespace(namespace, s), + ignored_systems_for_subsystem.((s,), ignored_connects)), + subsys) + @set! sys.eqs = eqs +end + +""" + $(TYPEDSIGNATURES) + +Given a subsystem `subsys` of a parent system and a list of systems (variables) to be +ignored by `generate_connection_set!` (`expand_variable_connections`), filter +`ignored_systems` to only include those present in the subtree of `subsys` and update +their hierarchy to not include `subsys`. +""" +function ignored_systems_for_subsystem( + subsys::AbstractSystem, ignored_systems::Vector{<:Union{ + HierarchyT, HierarchyAnalysisPointT}}) + result = eltype(ignored_systems)[] + # in case `subsys` is namespaced, get its hierarchy and compare suffixes + # instead of the just the last element + suffix = reverse!(namespace_hierarchy(nameof(subsys))) + N = length(suffix) + for igsys in ignored_systems + if length(igsys) > N && igsys[(end - N + 1):end] == suffix + push!(result, copy(igsys)) + for i in 1:N + pop!(result[end]) + end + end + end + return result +end + +function Base.merge(csets::AbstractVector{<:ConnectionSet}, allouter = false) + ele2idx = Dict{ConnectionElement, Int}() + idx2ele = ConnectionElement[] + union_find = IntDisjointSets(0) + prev_id = Ref(-1) + for cset in csets, (j, s) in enumerate(cset.set) + v = allouter ? withtrueouter(s) : s + id = let ele2idx = ele2idx, idx2ele = idx2ele + get!(ele2idx, v) do + push!(idx2ele, v) + id = length(idx2ele) + id′ = push!(union_find) + @assert id == id′ + id + end + end + # isequal might not be equal? lol + if v.sys.namespace !== nothing + idx2ele[id] = v + end + if j > 1 + union!(union_find, prev_id[], id) + end + prev_id[] = id + end + id2set = Dict{Int, Int}() + merged_set = ConnectionSet[] + for (id, ele) in enumerate(idx2ele) + rid = find_root!(union_find, id) + set_idx = get!(id2set, rid) do + set = ConnectionSet() + push!(merged_set, set) + length(merged_set) + end + push!(merged_set[set_idx].set, ele) + end + merged_set +end + +function generate_connection_equations_and_stream_connections(csets::AbstractVector{ + <:ConnectionSet, +}) + eqs = Equation[] + stream_connections = ConnectionSet[] + + for cset in csets + v = cset.set[1].v + v = getparent(v, v) + vtype = get_connection_type(v) + if vtype === Stream + push!(stream_connections, cset) + elseif vtype === Flow + rhs = 0 + for ele in cset.set + v = namespaced_var(ele) + rhs += ele.isouter ? -v : v + end + push!(eqs, 0 ~ rhs) + else # Equality + base = namespaced_var(cset.set[1]) + for i in 2:length(cset.set) + v = namespaced_var(cset.set[i]) + push!(eqs, base ~ v) + end + end + end + eqs, stream_connections +end + +function domain_defaults(sys, domain_csets) + def = Dict() + for c in domain_csets + cset = c.set + idx = findfirst(s -> is_domain_connector(s.sys.sys), cset) + idx === nothing && continue + s = cset[idx] + root = s.sys + s_def = defaults(root.sys) + for (j, m) in enumerate(cset) + if j == idx + continue + elseif is_domain_connector(m.sys.sys) + error("Domain sources $(nameof(root)) and $(nameof(m)) are connected!") + else + ns_s_def = Dict(unknowns(m.sys.sys, n) => n for (n, v) in s_def) + for p in parameters(m.sys.namespace) + d_p = get(ns_s_def, p, nothing) + if d_p !== nothing + def[parameters(m.sys.namespace, p)] = parameters(s.sys.namespace, + parameters(s.sys.sys, + d_p)) + end + end + end + end + end + def +end + +""" + $(TYPEDSIGNATURES) + +Recursively descend through the hierarchy of `sys` and expand all connection equations +of causal variables. Return the modified system. +""" +function expand_variable_connections(sys::AbstractSystem; ignored_variables = nothing) + if ignored_variables === nothing + ignored_variables = ignored_connections(sys)[2] + end + eqs = copy(get_eqs(sys)) + valid_idxs = trues(length(eqs)) + additional_eqs = Equation[] + + for (i, eq) in enumerate(eqs) + eq.lhs isa Connection || continue + connection = eq.rhs + elements = get_systems(connection) + is_causal_variable_connection(connection) || continue + + valid_idxs[i] = false + elements = map(x -> x.var, elements) + to_ignore = variables_to_ignore(ignored_variables, elements) + elements = setdiff(elements, to_ignore) + outvar = first(elements) + for invar in Iterators.drop(elements, 1) + push!(additional_eqs, outvar ~ invar) + end + end + eqs = [eqs[valid_idxs]; additional_eqs] + subsystems = map(get_systems(sys)) do subsys + expand_variable_connections(subsys; + ignored_variables = ignored_systems_for_subsystem(subsys, ignored_variables)) + end + @set! sys.eqs = eqs + @set! sys.systems = subsystems + return sys +end + +function expand_connections(sys::AbstractSystem, find = nothing, replace = nothing; + debug = false, tol = 1e-10, scalarize = true) + sys = remove_analysis_points(sys) + sys = expand_variable_connections(sys) + sys, (csets, domain_csets) = generate_connection_set(sys, find, replace; scalarize) + ceqs, instream_csets = generate_connection_equations_and_stream_connections(csets) + _sys = expand_instream(instream_csets, sys; debug = debug, tol = tol) + sys = flatten(sys, true) + @set! sys.eqs = [equations(_sys); ceqs] + d_defs = domain_defaults(sys, domain_csets) + @set! sys.defaults = merge(get_defaults(sys), d_defs) +end + +function unnamespace(root, namespace) + root === nothing && return namespace + root = string(root) + namespace = string(namespace) + if length(namespace) > length(root) + @assert root == namespace[1:length(root)] + Symbol(namespace[nextind(namespace, length(root)):end]) + else + @assert root == namespace + nothing + end +end + +function expand_instream(csets::AbstractVector{<:ConnectionSet}, sys::AbstractSystem, + namespace = nothing, prevnamespace = nothing; debug = false, + tol = 1e-8) + subsys = get_systems(sys) + # post order traversal + @set! sys.systems = map( + s -> expand_instream(csets, s, + renamespace(namespace, nameof(s)), + namespace; debug, tol), + subsys) + subsys = get_systems(sys) + + if debug + @info "Expanding" namespace + end + + sub = Dict() + eqs = Equation[] + instream_eqs = Equation[] + instream_exprs = Set() + for s in subsys + for eq in get_eqs(s) + eq = namespace_equation(eq, s) + if collect_instream!(instream_exprs, eq) + push!(instream_eqs, eq) + else + push!(eqs, eq) + end + end + end + + if !isempty(instream_exprs) + # map from a namespaced stream variable to a ConnectionSet + expr_cset = Dict() + for cset in csets + crep = first(cset.set) + current = namespace == _getname(crep.sys.namespace) + for v in cset.set + if (current || !v.isouter) + expr_cset[namespaced_var(v)] = cset.set + end + end + end + end + + for ex in instream_exprs + ns_sv = only(arguments(ex)) + full_name_sv = renamespace(namespace, ns_sv) + cset = get(expr_cset, full_name_sv, nothing) + cset === nothing && error("$ns_sv is not a variable inside stream connectors") + idx_in_set, sv = get_cset_sv(full_name_sv, cset) + + n_inners = n_outers = 0 + for (i, e) in enumerate(cset) + if e.isouter + n_outers += 1 + else + n_inners += 1 + end + end + if debug + @info "Expanding at [$idx_in_set]" ex ConnectionSet(cset) n_inners n_outers + end + if n_inners == 1 && n_outers == 0 + sub[ex] = sv + elseif n_inners == 2 && n_outers == 0 + other = idx_in_set == 1 ? 2 : 1 + sub[ex] = get_current_var(namespace, cset[other], sv) + elseif n_inners == 1 && n_outers == 1 + if !cset[idx_in_set].isouter + other = idx_in_set == 1 ? 2 : 1 + outerstream = get_current_var(namespace, cset[other], sv) + sub[ex] = instream(outerstream) + end + else + if !cset[idx_in_set].isouter + fv = flowvar(first(cset).sys.sys) + # mj.c.m_flow + innerfvs = [get_current_var(namespace, s, fv) + for (j, s) in enumerate(cset) if j != idx_in_set && !s.isouter] + innersvs = [get_current_var(namespace, s, sv) + for (j, s) in enumerate(cset) if j != idx_in_set && !s.isouter] + # ck.m_flow + outerfvs = [get_current_var(namespace, s, fv) for s in cset if s.isouter] + outersvs = [get_current_var(namespace, s, sv) for s in cset if s.isouter] + + sub[ex] = term(instream_rt, Val(length(innerfvs)), Val(length(outerfvs)), + innerfvs..., innersvs..., outerfvs..., outersvs...) + end + end + end + + # additional equations + additional_eqs = Equation[] + csets = filter(cset -> any(e -> _getname(e.sys.namespace) === namespace, cset.set), + csets) + for cset′ in csets + cset = cset′.set + connectors = Vector{Any}(undef, length(cset)) + n_inners = n_outers = 0 + for (i, e) in enumerate(cset) + connectors[i] = e.sys.sys + if e.isouter + n_outers += 1 + else + n_inners += 1 + end + end + iszero(n_outers) && continue + connector_representative = first(cset).sys.sys + fv = flowvar(connector_representative) + sv = first(cset).v + vtype = get_connection_type(sv) + vtype === Stream || continue + if n_inners == 1 && n_outers == 1 + push!(additional_eqs, + unknowns(cset[1].sys.sys, sv) ~ unknowns(cset[2].sys.sys, sv)) + elseif n_inners == 0 && n_outers == 2 + # we don't expand `instream` in this case. + v1 = unknowns(cset[1].sys.sys, sv) + v2 = unknowns(cset[2].sys.sys, sv) + push!(additional_eqs, v1 ~ instream(v2)) + push!(additional_eqs, v2 ~ instream(v1)) + else + sq = 0 + s_inners = (s for s in cset if !s.isouter) + s_outers = (s for s in cset if s.isouter) + for (q, oscq) in enumerate(s_outers) + sq += sum(s -> max(-unknowns(s, fv), 0), s_inners, init = 0) + for (k, s) in enumerate(s_outers) + k == q && continue + f = unknowns(s.sys.sys, fv) + sq += max(f, 0) + end + + num = 0 + den = 0 + for s in s_inners + f = unknowns(s.sys.sys, fv) + tmp = positivemax(-f, sq; tol = tol) + den += tmp + num += tmp * unknowns(s.sys.sys, sv) + end + for (k, s) in enumerate(s_outers) + k == q && continue + f = unknowns(s.sys.sys, fv) + tmp = positivemax(f, sq; tol = tol) + den += tmp + num += tmp * instream(unknowns(s.sys.sys, sv)) + end + push!(additional_eqs, unknowns(oscq.sys.sys, sv) ~ num / den) + end + end + end + + subed_eqs = substitute(instream_eqs, sub) + if debug && !(isempty(csets) && isempty(additional_eqs) && isempty(instream_eqs)) + println("======================================") + @info "Additional equations" csets + display(additional_eqs) + println("======================================") + println("Substitutions") + display(sub) + println("======================================") + println("Substituted equations") + foreach(i -> println(instream_eqs[i] => subed_eqs[i]), eachindex(subed_eqs)) + println("======================================") + end + + @set! sys.systems = [] + @set! sys.eqs = [get_eqs(sys); eqs; subed_eqs; additional_eqs] + sys +end + +function get_current_var(namespace, cele, sv) + unknowns( + renamespace(unnamespace(namespace, _getname(cele.sys.namespace)), + cele.sys.sys), + sv) +end + +function get_cset_sv(full_name_sv, cset) + for (idx_in_set, v) in enumerate(cset) + if isequal(namespaced_var(v), full_name_sv) + return idx_in_set, v.v + end + end + error("$ns_sv is not a variable inside stream connectors") +end + +# instream runtime +@generated function _instream_split(::Val{inner_n}, ::Val{outer_n}, + vars::NTuple{N, Any}) where {inner_n, outer_n, N} + #instream_rt(innerfvs..., innersvs..., outerfvs..., outersvs...) + ret = Expr(:tuple) + # mj.c.m_flow + inner_f = :(Base.@ntuple $inner_n i->vars[i]) + offset = inner_n + inner_s = :(Base.@ntuple $inner_n i->vars[$offset + i]) + offset += inner_n + # ck.m_flow + outer_f = :(Base.@ntuple $outer_n i->vars[$offset + i]) + offset += outer_n + outer_s = :(Base.@ntuple $outer_n i->vars[$offset + i]) + Expr(:tuple, inner_f, inner_s, outer_f, outer_s) +end + +function instream_rt(ins::Val{inner_n}, outs::Val{outer_n}, + vars::Vararg{Any, N}) where {inner_n, outer_n, N} + @assert N == 2 * (inner_n + outer_n) + + # inner: mj.c.m_flow + # outer: ck.m_flow + inner_f, inner_s, outer_f, outer_s = _instream_split(ins, outs, vars) + + T = float(first(inner_f)) + si = zero(T) + num = den = zero(T) + for f in inner_f + si += max(-f, 0) + end + for f in outer_f + si += max(f, 0) + end + #for (f, s) in zip(inner_f, inner_s) + for j in 1:inner_n + @inbounds f = inner_f[j] + @inbounds s = inner_s[j] + num += _positivemax(-f, si) * s + den += _positivemax(-f, si) + end + #for (f, s) in zip(outer_f, outer_s) + for j in 1:outer_n + @inbounds f = outer_f[j] + @inbounds s = outer_s[j] + num += _positivemax(-f, si) * s + den += _positivemax(-f, si) + end + return num / den + #= + si = sum(max(-mj.c.m_flow,0) for j in cat(1,1:i-1, i+1:N)) + + sum(max(ck.m_flow ,0) for k in 1:M) + + inStream(mi.c.h_outflow) = + (sum(positiveMax(-mj.c.m_flow,si)*mj.c.h_outflow) + + sum(positiveMax(ck.m_flow,s_i)*inStream(ck.h_outflow)))/ + (sum(positiveMax(-mj.c.m_flow,s_i)) + + sum(positiveMax(ck.m_flow,s_i))) + for j in 1:N and i <> j and mj.c.m_flow.min < 0, + for k in 1:M and ck.m_flow.max > 0 + =# +end +SymbolicUtils.promote_symtype(::typeof(instream_rt), ::Vararg) = Real diff --git a/src/systems/control/controlsystem.jl b/src/systems/control/controlsystem.jl deleted file mode 100644 index 7d3766e67b..0000000000 --- a/src/systems/control/controlsystem.jl +++ /dev/null @@ -1,187 +0,0 @@ -abstract type AbstractControlSystem <: AbstractSystem end - -function namespace_controls(sys::AbstractSystem) - [rename(x,renamespace(nameof(sys),nameof(x))) for x in controls(sys)] -end - -function controls(sys::AbstractControlSystem,args...) - name = last(args) - extra_names = reduce(Symbol,[Symbol(:₊,nameof(x)) for x in args[1:end-1]]) - newname = renamespace(extra_names,name) - rename(x,renamespace(nameof(sys),newname))(get_iv(sys)) -end - -function controls(sys::AbstractControlSystem,name::Symbol) - x = get_controls(sys)[findfirst(x->nameof(x)==name,sys.ps)] - rename(x,renamespace(nameof(sys),nameof(x))) -end - -controls(sys::AbstractControlSystem) = isempty(get_systems(sys)) ? get_controls(sys) : [get_controls(sys);reduce(vcat,namespace_controls.(get_systems(sys)))] - -""" -$(TYPEDEF) - -A system describing an optimal control problem. This contains a loss function -and ordinary differential equations with control variables that describe the -dynamics. - -# Fields -$(FIELDS) - -# Example - -```julia -using ModelingToolkit - -@variables t x(t) v(t) u(t) -D = Differential(t) - -loss = (4-x)^2 + 2v^2 + u^2 -eqs = [ - D(x) ~ v - D(v) ~ u^3 -] - -sys = ControlSystem(loss,eqs,t,[x,v],[u],[]) -``` -""" -struct ControlSystem <: AbstractControlSystem - """The Loss function""" - loss::Any - """The ODEs defining the system.""" - eqs::Vector{Equation} - """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Control variables.""" - controls::Vector - """Parameter variables.""" - ps::Vector - observed::Vector{Equation} - """ - Name: the name of the system - """ - name::Symbol - """ - systems: The internal systems - """ - systems::Vector{ControlSystem} - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict -end - -function ControlSystem(loss, deqs::AbstractVector{<:Equation}, iv, dvs, controls, ps; - observed = [], - systems = ODESystem[], - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - name=gensym(:ControlSystem)) - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :ControlSystem, force=true) - end - iv′ = value(iv) - dvs′ = value.(dvs) - controls′ = value.(controls) - ps′ = value.(ps) - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - ControlSystem(value(loss), deqs, iv′, dvs′, controls′, - ps′, observed, name, systems, defaults) -end - -struct ControlToExpr - sys::AbstractControlSystem - states::Vector - controls::Vector -end -ControlToExpr(@nospecialize(sys)) = ControlToExpr(sys,states(sys),controls(sys)) -function (f::ControlToExpr)(O) - !istree(O) && return O - res = if isa(operation(O), Sym) - # normal variables and control variables - (any(isequal(O), f.states) || any(isequal(O), controls(f))) && return tosymbol(O) - build_expr(:call, Any[operation(O).name; f.(arguments(O))]) - else - build_expr(:call, Any[Symbol(operation(O)); f.(arguments(O))]) - end -end -(f::ControlToExpr)(x::Sym) = nameof(x) - -function constructRadauIIA5(T::Type = Float64) - sq6 = sqrt(convert(T, 6)) - A = [11//45-7sq6/360 37//225-169sq6/1800 -2//225+sq6/75 - 37//225+169sq6/1800 11//45+7sq6/360 -2//225-sq6/75 - 4//9-sq6/36 4//9+sq6/36 1//9] - c = [2//5-sq6/10;2/5+sq6/10;1] - α = [4//9-sq6/36;4//9+sq6/36;1//9] - A = map(T,A) - α = map(T,α) - c = map(T,c) - return DiffEqBase.ImplicitRKTableau(A,c,α,5) -end - - -""" -```julia -runge_kutta_discretize(sys::ControlSystem,dt,tspan; - tab = ModelingToolkit.constructRadauIIA5()) -``` - -Transforms a nonlinear optimal control problem into a constrained -`OptimizationProblem` according to a Runge-Kutta tableau that describes -a collocation method. Requires a fixed `dt` over a given `timespan`. -Defaults to using the 5th order RadauIIA tableau, and altnerative tableaus -can be specified using the SciML tableau style. -""" -function runge_kutta_discretize(sys::ControlSystem,dt,tspan; - tab = ModelingToolkit.constructRadauIIA5()) - n = length(tspan[1]:dt:tspan[2]) - 1 - m = length(tab.α) - - sts = states(sys) - ctr = controls(sys) - ps = parameters(sys) - lo = get_loss(sys) - iv = get_iv(sys) - f = @RuntimeGeneratedFunction(build_function([x.rhs for x in equations(sys)],sts,ctr,ps,iv,conv = ModelingToolkit.ControlToExpr(sys))[1]) - L = @RuntimeGeneratedFunction(build_function(lo,sts,ctr,ps,iv,conv = ModelingToolkit.ControlToExpr(sys))) - - var(n, i...) = var(nameof(n), i...) - var(n::Symbol, i...) = Sym{FnType{Tuple{symtype(iv)}, Real}}(nameof(Variable(n, i...))) - # Expand out all of the variables in time and by stages - timed_vars = [[var(operation(x),i)(iv) for i in 1:n+1] for x in states(sys)] - k_vars = [[var(Symbol(:ᵏ,nameof(operation(x))),i,j)(iv) for i in 1:m, j in 1:n] for x in states(sys)] - states_timeseries = [getindex.(timed_vars,j) for j in 1:n+1] - k_timeseries = [[Num.(getindex.(k_vars,i,j)) for i in 1:m] for j in 1:n] - control_timeseries = [[[var(operation(x),i,j)(iv) for x in sts] for i in 1:m] for j in 1:n] - ps = parameters(sys) - iv = iv - - # Calculate all of the update and stage equations - mult = [tab.A * k_timeseries[i] for i in 1:n] - tmps = [[states_timeseries[i] .+ mult[i][j] for j in 1:m] for i in 1:n] - - bs = [states_timeseries[i] .+ dt .* reduce(+, tab.α .* k_timeseries[i],dims=1)[1] for i in 1:n] - updates = reduce(vcat,[states_timeseries[i+1] .~ bs[i] for i in 1:n]) - - df = [[dt .* Base.invokelatest(f,tmps[j][i],control_timeseries[j][i],ps,iv) for i in 1:m] for j in 1:n] - stages = reduce(vcat,[k_timeseries[i][j] .~ df[i][j] for i in 1:n for j in 1:m]) - - # Enforce equalities in the controls - control_equality = reduce(vcat,[control_timeseries[i][end] .~ control_timeseries[i+1][1] for i in 1:n-1]) - - # Create the loss function - losses = [Base.invokelatest(L,states_timeseries[i],control_timeseries[i][1],ps,iv) for i in 1:n] - losses = vcat(losses,[Base.invokelatest(L,states_timeseries[n+1],control_timeseries[n][end],ps,iv)]) - - # Calculate final pieces - equalities = vcat(stages,updates,control_equality) - opt_states = vcat(reduce(vcat,reduce(vcat,states_timeseries)),reduce(vcat,reduce(vcat,k_timeseries)),reduce(vcat,reduce(vcat,control_timeseries))) - - OptimizationSystem(reduce(+,losses, init=0),opt_states,ps,equality_constraints = equalities) -end diff --git a/src/systems/dependency_graphs.jl b/src/systems/dependency_graphs.jl index 45c2d165f4..344526add0 100644 --- a/src/systems/dependency_graphs.jl +++ b/src/systems/dependency_graphs.jl @@ -1,52 +1,47 @@ """ ```julia -equation_dependencies(sys::AbstractSystem; variables=states(sys)) +equation_dependencies(sys::AbstractSystem; variables = unknowns(sys)) ``` Given an `AbstractSystem` calculate for each equation the variables it depends on. Notes: -- Variables that are not in `variables` are filtered out. -- `get_variables!` is used to determine the variables within a given equation. -- returns a `Vector{Vector{Variable}}()` mapping the index of an equation to the `variables` it depends on. + + - Variables that are not in `variables` are filtered out. + - `get_variables!` is used to determine the variables within a given equation. + - returns a `Vector{Vector{Variable}}()` mapping the index of an equation to the `variables` it depends on. Example: + ```julia using ModelingToolkit -@parameters β γ κ η t +using ModelingToolkit: t_nounits as t +@parameters β γ κ η @variables S(t) I(t) R(t) -# use a reaction system to easily generate ODE and jump systems -rxs = [Reaction(β, [S,I], [I], [1,1], [2]), - Reaction(γ, [I], [R]), - Reaction(κ+η, [R], [S])] -rs = ReactionSystem(rxs, t, [S,I,R], [β,γ,κ,η]) - -# ODEs: -odesys = convert(ODESystem, rs) - -# dependency of each ODE on state variables -equation_dependencies(odesys) - -# dependency of each ODE on parameters -equation_dependencies(odesys, variables=parameters(odesys)) +rate₁ = β * S * I +rate₂ = γ * I + t +affect₁ = [S ~ S - 1, I ~ I + 1] +affect₂ = [I ~ I - 1, R ~ R + 1] +j₁ = ModelingToolkit.ConstantRateJump(rate₁, affect₁) +j₂ = ModelingToolkit.VariableRateJump(rate₂, affect₂) -# Jumps -jumpsys = convert(JumpSystem, rs) +# create a JumpSystem using these jumps +@named jumpsys = JumpSystem([j₁, j₂], t, [S, I, R], [β, γ]) -# dependency of each jump rate function on state variables +# dependency of each jump rate function on unknown variables equation_dependencies(jumpsys) # dependency of each jump rate function on parameters -equation_dependencies(jumpsys, variables=parameters(jumpsys)) +equation_dependencies(jumpsys, variables = parameters(jumpsys)) ``` """ -function equation_dependencies(sys::AbstractSystem; variables=states(sys)) - eqs = equations(sys) +function equation_dependencies(sys::AbstractSystem; variables = unknowns(sys), + eqs = equations(sys)) deps = Set() - depeqs_to_vars = Vector{Vector}(undef,length(eqs)) + depeqs_to_vars = Vector{Vector}(undef, length(eqs)) - for (i,eq) in enumerate(eqs) + for (i, eq) in enumerate(eqs) get_variables!(deps, eq, variables) depeqs_to_vars[i] = [value(v) for v in deps] empty!(deps) @@ -64,24 +59,27 @@ Convert a collection of equation dependencies, for example as returned by `equation_dependencies`, to a [`BipartiteGraph`](@ref). Notes: -- `vtois` should provide a `Dict` like mapping from each `Variable` dependency in - `eqdeps` to the integer idx of the variable to use in the graph. + + - `vtois` should provide a `Dict` like mapping from each `Variable` dependency in + `eqdeps` to the integer idx of the variable to use in the graph. Example: Continuing the example started in [`equation_dependencies`](@ref) + ```julia -digr = asgraph(equation_dependencies(odesys), Dict(s => i for (i,s) in enumerate(states(odesys)))) +digr = asgraph(equation_dependencies(jumpsys), + Dict(s => i for (i, s) in enumerate(unknowns(jumpsys)))) ``` """ function asgraph(eqdeps, vtois) fadjlist = Vector{Vector{Int}}(undef, length(eqdeps)) - for (i,dep) in enumerate(eqdeps) + for (i, dep) in enumerate(eqdeps) fadjlist[i] = sort!([vtois[var] for var in dep]) end - badjlist = [Vector{Int}() for i = 1:length(vtois)] + badjlist = [Vector{Int}() for i in 1:length(vtois)] ne = 0 - for (eqidx,vidxs) in enumerate(fadjlist) + for (eqidx, vidxs) in enumerate(fadjlist) foreach(vidx -> push!(badjlist[vidx], eqidx), vidxs) ne += length(vidxs) end @@ -89,70 +87,76 @@ function asgraph(eqdeps, vtois) BipartiteGraph(ne, fadjlist, badjlist) end - # could be made to directly generate graph and save memory """ ```julia -asgraph(sys::AbstractSystem; variables=states(sys), - variablestoids=Dict(convert(Variable, v) => i for (i,v) in enumerate(variables))) +asgraph(sys::AbstractSystem; variables = unknowns(sys), + variablestoids = Dict(convert(Variable, v) => i for (i, v) in enumerate(variables))) ``` Convert an `AbstractSystem` to a [`BipartiteGraph`](@ref) mapping the index of equations to the indices of variables they depend on. Notes: -- Defaults for kwargs creating a mapping from `equations(sys)` to `states(sys)` - they depend on. -- `variables` should provide the list of variables to use for generating - the dependency graph. -- `variablestoids` should provide `Dict` like mapping from a `Variable` to its - `Int` index within `variables`. + + - Defaults for kwargs creating a mapping from `equations(sys)` to `unknowns(sys)` + they depend on. + - `variables` should provide the list of variables to use for generating + the dependency graph. + - `variablestoids` should provide `Dict` like mapping from a `Variable` to its + `Int` index within `variables`. Example: Continuing the example started in [`equation_dependencies`](@ref) + ```julia -digr = asgraph(odesys) +digr = asgraph(jumpsys) ``` """ -function asgraph(sys::AbstractSystem; variables=states(sys), - variablestoids=Dict(v => i for (i,v) in enumerate(variables))) - asgraph(equation_dependencies(sys, variables=variables), variablestoids) +function asgraph(sys::AbstractSystem; variables = unknowns(sys), + variablestoids = Dict(v => i for (i, v) in enumerate(variables)), + eqs = equations(sys)) + asgraph(equation_dependencies(sys; variables, eqs), variablestoids) end """ ```julia -variable_dependencies(sys::AbstractSystem; variables=states(sys), variablestoids=nothing) +variable_dependencies(sys::AbstractSystem; variables = unknowns(sys), + variablestoids = nothing) ``` -For each variable determine the equations that modify it and return as a [`BipartiteGraph`](@ref). +For each variable, determine the equations that modify it and return as a [`BipartiteGraph`](@ref). Notes: -- Dependencies are returned as a [`BipartiteGraph`](@ref) mapping variable - indices to the indices of equations that modify them. -- `variables` denotes the list of variables to determine dependencies for. -- `variablestoids` denotes a `Dict` mapping `Variable`s to their `Int` index in `variables`. + + - Dependencies are returned as a [`BipartiteGraph`](@ref) mapping variable + indices to the indices of equations that modify them. + - `variables` denotes the list of variables to determine dependencies for. + - `variablestoids` denotes a `Dict` mapping `Variable`s to their `Int` index in `variables`. Example: Continuing the example of [`equation_dependencies`](@ref) + ```julia -variable_dependencies(odesys) +variable_dependencies(jumpsys) ``` """ -function variable_dependencies(sys::AbstractSystem; variables=states(sys), variablestoids=nothing) - eqs = equations(sys) - vtois = isnothing(variablestoids) ? Dict(v => i for (i,v) in enumerate(variables)) : variablestoids +function variable_dependencies(sys::AbstractSystem; variables = unknowns(sys), + variablestoids = nothing, eqs = equations(sys)) + vtois = isnothing(variablestoids) ? Dict(v => i for (i, v) in enumerate(variables)) : + variablestoids deps = Set() badjlist = Vector{Vector{Int}}(undef, length(eqs)) - for (eidx,eq) in enumerate(eqs) - modified_states!(deps, eq, variables) + for (eidx, eq) in enumerate(eqs) + modified_unknowns!(deps, eq, variables) badjlist[eidx] = sort!([vtois[var] for var in deps]) empty!(deps) end - fadjlist = [Vector{Int}() for i = 1:length(variables)] + fadjlist = [Vector{Int}() for i in 1:length(variables)] ne = 0 - for (eqidx,vidxs) in enumerate(badjlist) + for (eqidx, vidxs) in enumerate(badjlist) foreach(vidx -> push!(fadjlist[vidx], eqidx), vidxs) ne += length(vidxs) end @@ -162,72 +166,80 @@ end """ ```julia -asdigraph(g::BipartiteGraph, sys::AbstractSystem; variables = states(sys), equationsfirst = true) +asdigraph(g::BipartiteGraph, sys::AbstractSystem; variables = unknowns(sys), + equationsfirst = true) ``` Convert a [`BipartiteGraph`](@ref) to a `LightGraph.SimpleDiGraph`. Notes: -- The resulting `SimpleDiGraph` unifies the two sets of vertices (equations - and then states in the case it comes from [`asgraph`](@ref)), producing one - ordered set of integer vertices (`SimpleDiGraph` does not support two distinct - collections of vertices so they must be merged). -- `variables` gives the variables that `g` is associated with (usually the - `states` of a system). -- `equationsfirst` (default is `true`) gives whether the [`BipartiteGraph`](@ref) - gives a mapping from equations to variables they depend on (`true`), as calculated - by [`asgraph`](@ref), or whether it gives a mapping from variables to the equations - that modify them, as calculated by [`variable_dependencies`](@ref). + + - The resulting `SimpleDiGraph` unifies the two sets of vertices (equations + and then unknowns in the case it comes from [`asgraph`](@ref)), producing one + ordered set of integer vertices (`SimpleDiGraph` does not support two distinct + collections of vertices, so they must be merged). + - `variables` gives the variables that `g` are associated with (usually the + `unknowns` of a system). + - `equationsfirst` (default is `true`) gives whether the [`BipartiteGraph`](@ref) + gives a mapping from equations to variables they depend on (`true`), as calculated + by [`asgraph`](@ref), or whether it gives a mapping from variables to the equations + that modify them, as calculated by [`variable_dependencies`](@ref). Example: Continuing the example in [`asgraph`](@ref) + ```julia -dg = asdigraph(digr) +dg = asdigraph(digr, jumpsys) ``` """ -function asdigraph(g::BipartiteGraph, sys::AbstractSystem; variables = states(sys), equationsfirst = true) - neqs = length(equations(sys)) - nvars = length(variables) +function asdigraph(g::BipartiteGraph, sys::AbstractSystem; variables = unknowns(sys), + equationsfirst = true, eqs = equations(sys)) + neqs = length(eqs) + nvars = length(variables) fadjlist = deepcopy(g.fadjlist) badjlist = deepcopy(g.badjlist) # offset is for determining indices for the second set of vertices offset = equationsfirst ? neqs : nvars - for i = 1:offset + for i in 1:offset fadjlist[i] .+= offset end # add empty rows for vertices without connections - append!(fadjlist, [Vector{Int}() for i=1:(equationsfirst ? nvars : neqs)]) - prepend!(badjlist, [Vector{Int}() for i=1:(equationsfirst ? neqs : nvars)]) + append!(fadjlist, [Vector{Int}() for i in 1:(equationsfirst ? nvars : neqs)]) + prepend!(badjlist, [Vector{Int}() for i in 1:(equationsfirst ? neqs : nvars)]) SimpleDiGraph(g.ne, fadjlist, badjlist) end """ ```julia -eqeq_dependencies(eqdeps::BipartiteGraph{T}, vardeps::BipartiteGraph{T}) where {T <: Integer} +eqeq_dependencies(eqdeps::BipartiteGraph{T}, + vardeps::BipartiteGraph{T}) where {T <: Integer} ``` Calculate a `LightGraph.SimpleDiGraph` that maps each equation to equations they depend on. Notes: -- The `fadjlist` of the `SimpleDiGraph` maps from an equation to the equations that - modify variables it depends on. -- The `badjlist` of the `SimpleDiGraph` maps from an equation to equations that - depend on variables it modifies. + + - The `fadjlist` of the `SimpleDiGraph` maps from an equation to the equations that + modify variables it depends on. + - The `badjlist` of the `SimpleDiGraph` maps from an equation to equations that + depend on variables it modifies. Example: Continuing the example of `equation_dependencies` + ```julia -eqeqdep = eqeq_dependencies(asgraph(odesys), variable_dependencies(odesys)) +eqeqdep = eqeq_dependencies(asgraph(jumpsys), variable_dependencies(jumpsys)) ``` """ -function eqeq_dependencies(eqdeps::BipartiteGraph{T}, vardeps::BipartiteGraph{T}) where {T <: Integer} +function eqeq_dependencies(eqdeps::BipartiteGraph{T}, + vardeps::BipartiteGraph{T}) where {T <: Integer} g = SimpleDiGraph{T}(length(eqdeps.fadjlist)) - for (eqidx,sidxs) in enumerate(vardeps.badjlist) - # states modified by eqidx + for (eqidx, sidxs) in enumerate(vardeps.badjlist) + # unknowns modified by eqidx for sidx in sidxs # equations depending on sidx foreach(v -> add_edge!(g, eqidx, v), eqdeps.badjlist[sidx]) @@ -239,21 +251,29 @@ end """ ```julia -varvar_dependencies(eqdeps::BipartiteGraph{T}, vardeps::BipartiteGraph{T}) where {T <: Integer} = eqeq_dependencies(vardeps, eqdeps) +function varvar_dependencies(eqdeps::BipartiteGraph{T}, + vardeps::BipartiteGraph{T}) where {T <: Integer} + eqeq_dependencies(vardeps, eqdeps) +end ``` Calculate a `LightGraph.SimpleDiGraph` that maps each variable to variables they depend on. Notes: -- The `fadjlist` of the `SimpleDiGraph` maps from a variable to the variables that - depend on it. -- The `badjlist` of the `SimpleDiGraph` maps from a variable to variables on which - it depends. + + - The `fadjlist` of the `SimpleDiGraph` maps from a variable to the variables that + depend on it. + - The `badjlist` of the `SimpleDiGraph` maps from a variable to variables on which + it depends. Example: Continuing the example of `equation_dependencies` + ```julia -varvardep = varvar_dependencies(asgraph(odesys), variable_dependencies(odesys)) +varvardep = varvar_dependencies(asgraph(jumpsys), variable_dependencies(jumpsys)) ``` """ -varvar_dependencies(eqdeps::BipartiteGraph{T}, vardeps::BipartiteGraph{T}) where {T <: Integer} = eqeq_dependencies(vardeps, eqdeps) +function varvar_dependencies(eqdeps::BipartiteGraph{T}, + vardeps::BipartiteGraph{T}) where {T <: Integer} + eqeq_dependencies(vardeps, eqdeps) +end diff --git a/src/systems/diffeqs/abstractodesystem.jl b/src/systems/diffeqs/abstractodesystem.jl index 08811da467..62ddd12a08 100644 --- a/src/systems/diffeqs/abstractodesystem.jl +++ b/src/systems/diffeqs/abstractodesystem.jl @@ -1,670 +1,1560 @@ -function calculate_tgrad(sys::AbstractODESystem; - simplify=false) - isempty(get_tgrad(sys)[]) || return get_tgrad(sys)[] # use cached tgrad, if possible - - # We need to remove explicit time dependence on the state because when we - # have `u(t) * t` we want to have the tgrad to be `u(t)` instead of `u'(t) * - # t + u(t)`. - rhs = [detime_dvs(eq.rhs) for eq ∈ equations(sys)] - iv = get_iv(sys) - xs = states(sys) - rule = Dict(map((x, xt) -> xt=>x, detime_dvs.(xs), xs)) - rhs = substitute.(rhs, Ref(rule)) - tgrad = [expand_derivatives(ModelingToolkit.Differential(iv)(r), simplify) for r in rhs] - reverse_rule = Dict(map((x, xt) -> x=>xt, detime_dvs.(xs), xs)) - tgrad = Num.(substitute.(tgrad, Ref(reverse_rule))) - get_tgrad(sys)[] = tgrad - return tgrad -end - -function calculate_jacobian(sys::AbstractODESystem; - sparse=false, simplify=false) - isempty(get_jac(sys)[]) || return get_jac(sys)[] # use cached Jacobian, if possible - rhs = [eq.rhs for eq ∈ equations(sys)] - - iv = get_iv(sys) - dvs = states(sys) - - if sparse - jac = sparsejacobian(rhs, dvs, simplify=simplify) - else - jac = jacobian(rhs, dvs, simplify=simplify) - end - - get_jac(sys)[] = jac # cache Jacobian - return jac -end - -function generate_tgrad(sys::AbstractODESystem, dvs = states(sys), ps = parameters(sys); - simplify=false, kwargs...) - tgrad = calculate_tgrad(sys,simplify=simplify) - return build_function(tgrad, dvs, ps, get_iv(sys); kwargs...) -end - -function generate_jacobian(sys::AbstractODESystem, dvs = states(sys), ps = parameters(sys); - simplify=false, sparse = false, kwargs...) - jac = calculate_jacobian(sys;simplify=simplify,sparse=sparse) - return build_function(jac, dvs, ps, get_iv(sys); kwargs...) -end - -@noinline function throw_invalid_derivative(dervar, eq) - msg = "The derivative variable must be isolated to the left-hand " * - "side of the equation like `$dervar ~ ...`.\n Got $eq." - throw(InvalidSystemException(msg)) -end - -function check_derivative_variables(eq, expr=eq.rhs) - istree(expr) || return nothing - if operation(expr) isa Differential - throw_invalid_derivative(expr, eq) - end - foreach(Base.Fix1(check_derivative_variables, eq), arguments(expr)) -end - -function generate_function( - sys::AbstractODESystem, dvs = states(sys), ps = parameters(sys); - implicit_dae=false, - ddvs=implicit_dae ? map(Differential(independent_variable(sys)), dvs) : nothing, - kwargs... - ) - # optimization - #obsvars = map(eq->eq.lhs, observed(sys)) - #fulldvs = [dvs; obsvars] - - eqs = equations(sys) - foreach(check_derivative_variables, eqs) - # substitute x(t) by just x - rhss = implicit_dae ? [_iszero(eq.lhs) ? eq.rhs : eq.rhs - eq.lhs for eq in eqs] : - [eq.rhs for eq in eqs] - #obss = [makesym(value(eq.lhs)) ~ substitute(eq.rhs, sub) for eq ∈ observed(sys)] - #rhss = Let(obss, rhss) - - # TODO: add an optional check on the ordering of observed equations - u = map(x->time_varying_as_func(value(x), sys), dvs) - p = map(x->time_varying_as_func(value(x), sys), ps) - t = get_iv(sys) - - if implicit_dae - build_function(rhss, ddvs, u, p, t; kwargs...) - else - build_function(rhss, u, p, t; kwargs...) - end -end - -function time_varying_as_func(x, sys) - # if something is not x(t) (the current state) - # but is `x(t-1)` or something like that, pass in `x` as a callable function rather - # than pass in a value in place of x(t). - # - # This is done by just making `x` the argument of the function. - if istree(x) && - operation(x) isa Sym && - !(length(arguments(x)) == 1 && isequal(arguments(x)[1], independent_variable(sys))) - return operation(x) - end - return x -end - -function calculate_massmatrix(sys::AbstractODESystem; simplify=false) - eqs = equations(sys) - dvs = states(sys) - M = zeros(length(eqs),length(eqs)) - for (i,eq) in enumerate(eqs) - if eq.lhs isa Term && operation(eq.lhs) isa Differential - j = findfirst(x->isequal(tosymbol(x),tosymbol(var_from_nested_derivative(eq.lhs)[1])),dvs) - M[i,j] = 1 - else - _iszero(eq.lhs) || error("Only semi-explicit constant mass matrices are currently supported. Faulty equation: $eq.") - end - end - M = simplify ? ModelingToolkit.simplify.(M) : M - # M should only contain concrete numbers - M == I ? I : M -end - -jacobian_sparsity(sys::AbstractODESystem) = - jacobian_sparsity([eq.rhs for eq ∈ equations(sys)], - [dv for dv in states(sys)]) - -function isautonomous(sys::AbstractODESystem) - tgrad = calculate_tgrad(sys;simplify=true) - all(iszero,tgrad) -end - -for F in [:ODEFunction, :DAEFunction] - @eval function DiffEqBase.$F(sys::AbstractODESystem, args...; kwargs...) - $F{true}(sys, args...; kwargs...) - end -end - -""" -```julia -function DiffEqBase.ODEFunction{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, tgrad=false, - jac = false, - sparse = false, - kwargs...) where {iip} -``` - -Create an `ODEFunction` from the [`ODESystem`](@ref). The arguments `dvs` and `ps` -are used to set the order of the dependent variable and parameter vectors, -respectively. -""" -function DiffEqBase.ODEFunction{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, tgrad=false, - jac = false, - eval_expression = true, - sparse = false, simplify=false, - eval_module = @__MODULE__, - steady_state = false, - checkbounds=false, - kwargs...) where {iip} - - f_gen = generate_function(sys, dvs, ps; expression=Val{eval_expression}, expression_module=eval_module, checkbounds=checkbounds, kwargs...) - f_oop,f_iip = eval_expression ? (@RuntimeGeneratedFunction(eval_module, ex) for ex in f_gen) : f_gen - f(u,p,t) = f_oop(u,p,t) - f(du,u,p,t) = f_iip(du,u,p,t) - - if tgrad - tgrad_gen = generate_tgrad(sys, dvs, ps; - simplify=simplify, - expression=Val{eval_expression}, expression_module=eval_module, - checkbounds=checkbounds, kwargs...) - tgrad_oop,tgrad_iip = eval_expression ? (@RuntimeGeneratedFunction(eval_module, ex) for ex in tgrad_gen) : tgrad_gen - _tgrad(u,p,t) = tgrad_oop(u,p,t) - _tgrad(J,u,p,t) = tgrad_iip(J,u,p,t) - else - _tgrad = nothing - end - - if jac - jac_gen = generate_jacobian(sys, dvs, ps; - simplify=simplify, sparse = sparse, - expression=Val{eval_expression}, expression_module=eval_module, - checkbounds=checkbounds, kwargs...) - jac_oop,jac_iip = eval_expression ? (@RuntimeGeneratedFunction(eval_module, ex) for ex in jac_gen) : jac_gen - _jac(u,p,t) = jac_oop(u,p,t) - _jac(J,u,p,t) = jac_iip(J,u,p,t) - else - _jac = nothing - end - - M = calculate_massmatrix(sys) - - _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0',M) - - observedfun = if steady_state - let sys = sys, dict = Dict() - function generated_observed(obsvar, u, p, t=Inf) - obs = get!(dict, value(obsvar)) do - build_explicit_observed_function(sys, obsvar) - end - obs(u, p, t) - end - end - else - let sys = sys, dict = Dict() - function generated_observed(obsvar, u, p, t) - obs = get!(dict, value(obsvar)) do - build_explicit_observed_function(sys, obsvar; checkbounds=checkbounds) - end - obs(u, p, t) - end - end - end - - uElType = eltype(u0) - ODEFunction{iip}( - f, - jac = _jac === nothing ? nothing : _jac, - tgrad = _tgrad === nothing ? nothing : _tgrad, - mass_matrix = _M, - jac_prototype = (!isnothing(u0) && sparse) ? (!jac ? similar(jacobian_sparsity(sys),uElType) : similar(get_jac(sys)[],uElType)) : nothing, - syms = Symbol.(states(sys)), - indepsym = Symbol(independent_variable(sys)), - observed = observedfun, - ) -end - -""" -```julia -function DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, tgrad=false, - jac = false, - sparse = false, - kwargs...) where {iip} -``` - -Create an `DAEFunction` from the [`ODESystem`](@ref). The arguments `dvs` and -`ps` are used to set the order of the dependent variable and parameter vectors, -respectively. -""" -function DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - ddvs=map(diff2term ∘ Differential(independent_variable(sys)), dvs), - version = nothing, - #= - tgrad=false, - jac = false, - sparse = false, - =# - simplify=false, - eval_expression = true, - eval_module = @__MODULE__, - kwargs...) where {iip} - - f_gen = generate_function(sys, dvs, ps; implicit_dae = true, expression=Val{eval_expression}, expression_module=eval_module, kwargs...) - f_oop,f_iip = eval_expression ? (@RuntimeGeneratedFunction(eval_module, ex) for ex in f_gen) : f_gen - f(du,u,p,t) = f_oop(du,u,p,t) - f(out,du,u,p,t) = f_iip(out,du,u,p,t) - - # TODO: Jacobian sparsity / sparse Jacobian / dense Jacobian - - #= - observedfun = let sys = sys, dict = Dict() - # TODO: We don't have enought information to reconstruct arbitrary state - # in general from `(u, p, t)`, e.g. `a ~ D(x)`. - function generated_observed(obsvar, u, p, t) - obs = get!(dict, value(obsvar)) do - build_explicit_observed_function(sys, obsvar) - end - obs(u, p, t) - end - end - =# - - DAEFunction{iip}( - f, - syms = Symbol.(dvs), - # missing fields in `DAEFunction` - #indepsym = Symbol(independent_variable(sys)), - #observed = observedfun, - ) -end - -""" -```julia -function ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, tgrad=false, - jac = false, - sparse = false, - kwargs...) where {iip} -``` - -Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). -The arguments `dvs` and `ps` are used to set the order of the dependent -variable and parameter vectors, respectively. -""" -struct ODEFunctionExpr{iip} end - -struct ODEFunctionClosure{O, I} <: Function - f_oop::O - f_iip::I -end -(f::ODEFunctionClosure)(u, p, t) = f.f_oop(u, p, t) -(f::ODEFunctionClosure)(du, u, p, t) = f.f_iip(du, u, p, t) - -function ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, tgrad=false, - jac = false, - linenumbers = false, - sparse = false, simplify=false, - steady_state = false, - kwargs...) where {iip} - - f_oop, f_iip = generate_function(sys, dvs, ps; expression=Val{true}, kwargs...) - - dict = Dict() - #= - observedfun = if steady_state - :(function generated_observed(obsvar, u, p, t=Inf) - obs = get!($dict, value(obsvar)) do - build_explicit_observed_function($sys, obsvar) - end - obs(u, p, t) - end) - else - :(function generated_observed(obsvar, u, p, t) - obs = get!($dict, value(obsvar)) do - build_explicit_observed_function($sys, obsvar) - end - obs(u, p, t) - end) - end - =# - - fsym = gensym(:f) - _f = :($fsym = ModelingToolkit.ODEFunctionClosure($f_oop, $f_iip)) - tgradsym = gensym(:tgrad) - if tgrad - tgrad_oop, tgrad_iip = generate_tgrad(sys, dvs, ps; - simplify=simplify, - expression=Val{true}, kwargs...) - _tgrad = :($tgradsym = ModelingToolkit.ODEFunctionClosure($tgrad_oop, $tgrad_iip)) - else - _tgrad = :($tgradsym = nothing) - end - - jacsym = gensym(:jac) - if jac - jac_oop,jac_iip = generate_jacobian(sys, dvs, ps; - sparse=sparse, simplify=simplify, - expression=Val{true}, kwargs...) - _jac = :($jacsym = ModelingToolkit.ODEFunctionClosure($jac_oop, $jac_iip)) - else - _jac = :($jacsym = nothing) - end - - M = calculate_massmatrix(sys) - - _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0',M) - - jp_expr = sparse ? :(similar($(get_jac(sys)[]),Float64)) : :nothing - ex = quote - $_f - $_tgrad - $_jac - M = $_M - ODEFunction{$iip}( - $fsym, - jac = $jacsym, - tgrad = $tgradsym, - mass_matrix = M, - jac_prototype = $jp_expr, - syms = $(Symbol.(states(sys))), - indepsym = $(QuoteNode(Symbol(independent_variable(sys)))), - ) - end - !linenumbers ? striplines(ex) : ex -end - -function process_DEProblem(constructor, sys::AbstractODESystem,u0map,parammap; - implicit_dae = false, du0map = nothing, - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - simplify=false, - linenumbers = true, parallel=SerialForm(), - eval_expression = true, - kwargs...) - eqs = equations(sys) - dvs = states(sys) - ps = parameters(sys) - defs = defaults(sys) - iv = independent_variable(sys) - - u0 = varmap_to_vars(u0map,dvs; defaults=defs) - if implicit_dae && du0map !== nothing - ddvs = map(Differential(iv), dvs) - du0 = varmap_to_vars(du0map, ddvs; defaults=defaults, toterm=identity) - else - du0 = nothing - ddvs = nothing - end - p = varmap_to_vars(parammap,ps; defaults=defs) - - check_eqs_u0(eqs, dvs, u0) - - f = constructor(sys,dvs,ps,u0;ddvs=ddvs,tgrad=tgrad,jac=jac,checkbounds=checkbounds, - linenumbers=linenumbers,parallel=parallel,simplify=simplify, - sparse=sparse,eval_expression=eval_expression,kwargs...) - implicit_dae ? (f, du0, u0, p) : (f, u0, p) -end - -function ODEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) - ODEFunctionExpr{true}(sys, args...; kwargs...) -end - -""" -```julia -function DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, tgrad=false, - jac = false, - sparse = false, - kwargs...) where {iip} -``` - -Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). -The arguments `dvs` and `ps` are used to set the order of the dependent -variable and parameter vectors, respectively. -""" -struct DAEFunctionExpr{iip} end - -struct DAEFunctionClosure{O, I} <: Function - f_oop::O - f_iip::I -end -(f::DAEFunctionClosure)(du, u, p, t) = f.f_oop(du, u, p, t) -(f::DAEFunctionClosure)(out, du, u, p, t) = f.f_iip(out, du, u, p, t) - -function DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, tgrad=false, - jac = false, - linenumbers = false, - sparse = false, simplify=false, - kwargs...) where {iip} - f_oop, f_iip = generate_function(sys, dvs, ps; expression=Val{true}, implicit_dae = true, kwargs...) - fsym = gensym(:f) - _f = :($fsym = $DAEFunctionClosure($f_oop, $f_iip)) - ex = quote - $_f - ODEFunction{$iip}($fsym,) - end - !linenumbers ? striplines(ex) : ex -end - -function DAEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) - DAEFunctionExpr{true}(sys, args...; kwargs...) -end - -for P in [:ODEProblem, :DAEProblem] - @eval function DiffEqBase.$P(sys::AbstractODESystem, args...; kwargs...) - $P{true}(sys, args...; kwargs...) - end -end - -""" -```julia -function DiffEqBase.ODEProblem{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - simplify=false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` - -Generates an ODEProblem from an ODESystem and allows for automatically -symbolically calculating numerical enhancements. -""" -function DiffEqBase.ODEProblem{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters();kwargs...) where iip - f, u0, p = process_DEProblem(ODEFunction{iip}, sys, u0map, parammap; kwargs...) - ODEProblem{iip}(f,u0,tspan,p;kwargs...) -end - -""" -```julia -function DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - simplify=false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` - -Generates an DAEProblem from an ODESystem and allows for automatically -symbolically calculating numerical enhancements. -""" -function DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem,du0map,u0map,tspan, - parammap=DiffEqBase.NullParameters();kwargs...) where iip - f, du0, u0, p = process_DEProblem( - DAEFunction{iip}, sys, u0map, parammap; - implicit_dae=true, du0map=du0map, kwargs... - ) - diffvars = collect_differential_variables(sys) - sts = states(sys) - differential_vars = map(Base.Fix2(in, diffvars), sts) - DAEProblem{iip}(f,du0,u0,tspan,p;differential_vars=differential_vars,kwargs...) -end - -""" -```julia -function ODEProblemExpr{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - linenumbers = true, parallel=SerialForm(), - skipzeros=true, fillzeros=true, - simplify=false, - kwargs...) where iip -``` - -Generates a Julia expression for constructing an ODEProblem from an -ODESystem and allows for automatically symbolically calculating -numerical enhancements. -""" -struct ODEProblemExpr{iip} end - -function ODEProblemExpr{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip - - f, u0, p = process_DEProblem(ODEFunctionExpr{iip}, sys, u0map, parammap; kwargs...) - linenumbers = get(kwargs, :linenumbers, true) - - ex = quote - f = $f - u0 = $u0 - tspan = $tspan - p = $p - ODEProblem(f,u0,tspan,p;$(kwargs...)) - end - !linenumbers ? striplines(ex) : ex -end - -function ODEProblemExpr(sys::AbstractODESystem, args...; kwargs...) - ODEProblemExpr{true}(sys, args...; kwargs...) -end - -""" -```julia -function DAEProblemExpr{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - linenumbers = true, parallel=SerialForm(), - skipzeros=true, fillzeros=true, - simplify=false, - kwargs...) where iip -``` - -Generates a Julia expression for constructing an ODEProblem from an -ODESystem and allows for automatically symbolically calculating -numerical enhancements. -""" -struct DAEProblemExpr{iip} end - -function DAEProblemExpr{iip}(sys::AbstractODESystem,du0map,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip - f, du0, u0, p = process_DEProblem( - DAEFunctionExpr{iip}, sys, u0map, parammap; - implicit_dae=true, du0map=du0map, kwargs... - ) - linenumbers = get(kwargs, :linenumbers, true) - diffvars = collect_differential_variables(sys) - sts = states(sys) - differential_vars = map(Base.Fix2(in, diffvars), sts) - - ex = quote - f = $f - u0 = $u0 - du0 = $du0 - tspan = $tspan - p = $p - differential_vars = $differential_vars - DAEProblem{$iip}(f,du0,u0,tspan,p;differential_vars=differential_vars,$(kwargs...)) - end - !linenumbers ? striplines(ex) : ex -end - -function DAEProblemExpr(sys::AbstractODESystem, args...; kwargs...) - DAEProblemExpr{true}(sys, args...; kwargs...) -end - - -### Enables Steady State Problems ### -function DiffEqBase.SteadyStateProblem(sys::AbstractODESystem, args...; kwargs...) - SteadyStateProblem{true}(sys, args...; kwargs...) -end - -""" -```julia -function DiffEqBase.SteadyStateProblem(sys::AbstractODESystem,u0map, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` -Generates an SteadyStateProblem from an ODESystem and allows for automatically -symbolically calculating numerical enhancements. -""" -function DiffEqBase.SteadyStateProblem{iip}(sys::AbstractODESystem,u0map, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip - f, u0, p = process_DEProblem(ODEFunction{iip}, sys, u0map, parammap; steady_state = true, kwargs...) - SteadyStateProblem{iip}(f,u0,p;kwargs...) -end - -""" -```julia -function DiffEqBase.SteadyStateProblemExpr(sys::AbstractODESystem,u0map, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, - checkbounds = false, sparse = false, - skipzeros=true, fillzeros=true, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` -Generates a Julia expression for building a SteadyStateProblem from -an ODESystem and allows for automatically symbolically calculating -numerical enhancements. -""" -struct SteadyStateProblemExpr{iip} end - -function SteadyStateProblemExpr{iip}(sys::AbstractODESystem,u0map, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip - f, u0, p = process_DEProblem(ODEFunctionExpr{iip}, sys, u0map, parammap;steady_state = true, kwargs...) - linenumbers = get(kwargs, :linenumbers, true) - ex = quote - f = $f - u0 = $u0 - p = $p - SteadyStateProblem(f,u0,p;$(kwargs...)) - end - !linenumbers ? striplines(ex) : ex -end - -function SteadyStateProblemExpr(sys::AbstractODESystem, args...; kwargs...) - SteadyStateProblemExpr{true}(sys, args...; kwargs...) -end - -isdifferential(expr) = istree(expr) && operation(expr) isa Differential -isdiffeq(eq) = isdifferential(eq.lhs) +struct Schedule + var_eq_matching::Any + dummy_sub::Any +end + +""" + is_dde(sys::AbstractSystem) + +Return a boolean indicating whether a system represents a set of delay +differential equations. +""" +is_dde(sys::AbstractSystem) = has_is_dde(sys) && get_is_dde(sys) + +function _check_if_dde(eqs, iv, subsystems) + is_dde = any(ModelingToolkit.is_dde, subsystems) + if !is_dde + vs = Set() + for eq in eqs + vars!(vs, eq) + is_dde = any(vs) do sym + isdelay(unwrap(sym), iv) + end + is_dde && break + end + end + return is_dde +end + +function filter_kwargs(kwargs) + kwargs = Dict(kwargs) + for key in keys(kwargs) + key in DiffEqBase.allowedkeywords || delete!(kwargs, key) + end + pairs(NamedTuple(kwargs)) +end +function gen_quoted_kwargs(kwargs) + kwargparam = Expr(:parameters) + for kw in kwargs + push!(kwargparam.args, Expr(:kw, kw[1], kw[2])) + end + kwargparam +end + +function calculate_tgrad(sys::AbstractODESystem; + simplify = false) + isempty(get_tgrad(sys)[]) || return get_tgrad(sys)[] # use cached tgrad, if possible + + # We need to remove explicit time dependence on the unknown because when we + # have `u(t) * t` we want to have the tgrad to be `u(t)` instead of `u'(t) * + # t + u(t)`. + rhs = [detime_dvs(eq.rhs) for eq in full_equations(sys)] + iv = get_iv(sys) + xs = unknowns(sys) + rule = Dict(map((x, xt) -> xt => x, detime_dvs.(xs), xs)) + rhs = substitute.(rhs, Ref(rule)) + tgrad = [expand_derivatives(Differential(iv)(r), simplify) for r in rhs] + reverse_rule = Dict(map((x, xt) -> x => xt, detime_dvs.(xs), xs)) + tgrad = Num.(substitute.(tgrad, Ref(reverse_rule))) + get_tgrad(sys)[] = tgrad + return tgrad +end + +function calculate_jacobian(sys::AbstractODESystem; + sparse = false, simplify = false, dvs = unknowns(sys)) + if isequal(dvs, unknowns(sys)) + cache = get_jac(sys)[] + if cache isa Tuple && cache[2] == (sparse, simplify) + return cache[1] + end + end + + rhs = [eq.rhs - eq.lhs for eq in full_equations(sys)] #need du terms on rhs for differentiating wrt du + + if sparse + jac = sparsejacobian(rhs, dvs, simplify = simplify) + W_s = W_sparsity(sys) + (Is, Js, Vs) = findnz(W_s) + # Add nonzeros of W as non-structural zeros of the Jacobian (to ensure equal results for oop and iip Jacobian.) + for (i, j) in zip(Is, Js) + iszero(jac[i, j]) && begin + jac[i, j] = 1 + jac[i, j] = 0 + end + end + else + jac = jacobian(rhs, dvs, simplify = simplify) + end + + if isequal(dvs, unknowns(sys)) + get_jac(sys)[] = jac, (sparse, simplify) # cache Jacobian + end + + return jac +end + +function calculate_control_jacobian(sys::AbstractODESystem; + sparse = false, simplify = false) + cache = get_ctrl_jac(sys)[] + if cache isa Tuple && cache[2] == (sparse, simplify) + return cache[1] + end + + rhs = [eq.rhs for eq in full_equations(sys)] + ctrls = controls(sys) + + if sparse + jac = sparsejacobian(rhs, ctrls, simplify = simplify) + else + jac = jacobian(rhs, ctrls, simplify = simplify) + end + + get_ctrl_jac(sys)[] = jac, (sparse, simplify) # cache Jacobian + return jac +end + +function generate_tgrad( + sys::AbstractODESystem, dvs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + simplify = false, kwargs...) + tgrad = calculate_tgrad(sys, simplify = simplify) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, tgrad, + dvs, + p..., + get_iv(sys); + kwargs...) +end + +function generate_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + simplify = false, sparse = false, kwargs...) + jac = calculate_jacobian(sys; simplify = simplify, sparse = sparse) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, + dvs, + p..., + get_iv(sys); + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity), + kwargs...) +end + +function assert_jac_length_header(sys) + W = W_sparsity(sys) + identity, + function add_header(expr) + Func(expr.args, [], expr.body, + [:(@assert $(SymbolicUtils.Code.toexpr(term(findnz, expr.args[1])))[1:2] == + $(findnz(W)[1:2]))]) + end +end + +function generate_W(sys::AbstractODESystem, γ = 1.0, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + simplify = false, sparse = false, kwargs...) + @variables ˍ₋gamma + M = calculate_massmatrix(sys; simplify) + sparse && (M = SparseArrays.sparse(M)) + J = calculate_jacobian(sys; simplify, sparse, dvs) + W = ˍ₋gamma * M + J + + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, W, + dvs, + p..., + ˍ₋gamma, + get_iv(sys); + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity), + p_end = 1 + length(p), + kwargs...) +end + +function generate_control_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + simplify = false, sparse = false, kwargs...) + jac = calculate_control_jacobian(sys; simplify = simplify, sparse = sparse) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, dvs, p..., get_iv(sys); kwargs...) +end + +function generate_dae_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); simplify = false, sparse = false, + kwargs...) + jac_u = calculate_jacobian(sys; simplify = simplify, sparse = sparse) + derivatives = Differential(get_iv(sys)).(unknowns(sys)) + jac_du = calculate_jacobian(sys; simplify = simplify, sparse = sparse, + dvs = derivatives) + dvs = unknowns(sys) + @variables ˍ₋gamma + jac = ˍ₋gamma * jac_du + jac_u + pre = get_preprocess_constants(jac) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, derivatives, dvs, p..., ˍ₋gamma, get_iv(sys); + p_start = 3, p_end = 2 + length(p), kwargs...) +end + +function generate_function(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + implicit_dae = false, + ddvs = implicit_dae ? map(Differential(get_iv(sys)), dvs) : + nothing, + isdde = false, + kwargs...) + eqs = [eq for eq in equations(sys)] + if !implicit_dae + check_operator_variables(eqs, Differential) + check_lhs(eqs, Differential, Set(dvs)) + end + + rhss = implicit_dae ? [_iszero(eq.lhs) ? eq.rhs : eq.rhs - eq.lhs for eq in eqs] : + [eq.rhs for eq in eqs] + + if !isempty(assertions(sys)) + rhss[end] += unwrap(get_assertions_expr(sys)) + end + + # TODO: add an optional check on the ordering of observed equations + u = dvs + p = reorder_parameters(sys, ps) + t = get_iv(sys) + + if implicit_dae + build_function_wrapper(sys, rhss, ddvs, u, p..., t; p_start = 3, kwargs...) + else + build_function_wrapper(sys, rhss, u, p..., t; kwargs...) + end +end + +function isdelay(var, iv) + iv === nothing && return false + isvariable(var) || return false + isparameter(var) && return false + if iscall(var) && !ModelingToolkit.isoperator(var, Symbolics.Operator) + args = arguments(var) + length(args) == 1 || return false + isequal(args[1], iv) || return true + end + return false +end +const DDE_HISTORY_FUN = Sym{Symbolics.FnType{Tuple{Any, <:Real}, Vector{Real}}}(:___history___) +const DEFAULT_PARAMS_ARG = Sym{Any}(:ˍ₋arg3) +function delay_to_function( + sys::AbstractODESystem, eqs = full_equations(sys); history_arg = DEFAULT_PARAMS_ARG) + delay_to_function(eqs, + get_iv(sys), + Dict{Any, Int}(operation(s) => i for (i, s) in enumerate(unknowns(sys))), + parameters(sys), + DDE_HISTORY_FUN; history_arg) +end +function delay_to_function(eqs::Vector, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) + delay_to_function.(eqs, (iv,), (sts,), (ps,), (h,); history_arg) +end +function delay_to_function(eq::Equation, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) + delay_to_function(eq.lhs, iv, sts, ps, h; history_arg) ~ delay_to_function( + eq.rhs, iv, sts, ps, h; history_arg) +end +function delay_to_function(expr, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) + if isdelay(expr, iv) + v = operation(expr) + time = arguments(expr)[1] + idx = sts[v] + return term(getindex, h(history_arg, time), idx, type = Real) # BIG BIG HACK + elseif iscall(expr) + return maketerm(typeof(expr), + operation(expr), + map(x -> delay_to_function(x, iv, sts, ps, h; history_arg), arguments(expr)), + metadata(expr)) + else + return expr + end +end + +function calculate_massmatrix(sys::AbstractODESystem; simplify = false) + eqs = [eq for eq in equations(sys)] + M = zeros(length(eqs), length(eqs)) + for (i, eq) in enumerate(eqs) + if iscall(eq.lhs) && operation(eq.lhs) isa Differential + st = var_from_nested_derivative(eq.lhs)[1] + j = variable_index(sys, st) + M[i, j] = 1 + else + _iszero(eq.lhs) || + error("Only semi-explicit constant mass matrices are currently supported. Faulty equation: $eq.") + end + end + M = simplify ? ModelingToolkit.simplify.(M) : M + # M should only contain concrete numbers + M == I ? I : M +end + +function jacobian_sparsity(sys::AbstractODESystem) + sparsity = torn_system_jacobian_sparsity(sys) + sparsity === nothing || return sparsity + + jacobian_sparsity([eq.rhs for eq in full_equations(sys)], + [dv for dv in unknowns(sys)]) +end + +function jacobian_dae_sparsity(sys::AbstractODESystem) + J1 = jacobian_sparsity([eq.rhs for eq in full_equations(sys)], + [dv for dv in unknowns(sys)]) + derivatives = Differential(get_iv(sys)).(unknowns(sys)) + J2 = jacobian_sparsity([eq.rhs for eq in full_equations(sys)], + [dv for dv in derivatives]) + J1 + J2 +end + +function W_sparsity(sys::AbstractODESystem) + jac_sparsity = jacobian_sparsity(sys) + (n, n) = size(jac_sparsity) + M = calculate_massmatrix(sys) + M_sparsity = M isa UniformScaling ? sparse(I(n)) : + SparseMatrixCSC{Bool, Int64}((!iszero).(M)) + jac_sparsity .| M_sparsity +end + +function isautonomous(sys::AbstractODESystem) + tgrad = calculate_tgrad(sys; simplify = true) + all(iszero, tgrad) +end + +""" +```julia +DiffEqBase.ODEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, tgrad = false, + jac = false, + sparse = false, + kwargs...) where {iip} +``` + +Create an `ODEFunction` from the [`ODESystem`](@ref). The arguments `dvs` and `ps` +are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function DiffEqBase.ODEFunction(sys::AbstractODESystem, args...; kwargs...) + ODEFunction{true}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEFunction{true}(sys::AbstractODESystem, args...; + kwargs...) + ODEFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEFunction{false}(sys::AbstractODESystem, args...; + kwargs...) + ODEFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, + dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, tgrad = false, + jac = false, p = nothing, + t = nothing, + eval_expression = false, + sparse = false, simplify = false, + eval_module = @__MODULE__, + steady_state = false, + checkbounds = false, + sparsity = false, + analytic = nothing, + split_idxs = nothing, + initialization_data = nothing, + cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEFunction`") + end + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, + expression_module = eval_module, checkbounds = checkbounds, cse, + kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) + + if specialize === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on ODEFunction.") + end + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + + if tgrad + tgrad_gen = generate_tgrad(sys, dvs, ps; + simplify = simplify, + expression = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + tgrad_oop, tgrad_iip = eval_or_rgf.(tgrad_gen; eval_expression, eval_module) + _tgrad = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(tgrad_oop, tgrad_iip) + else + _tgrad = nothing + end + + if jac + jac_gen = generate_jacobian(sys, dvs, ps; + simplify = simplify, sparse = sparse, + expression = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) + + _jac = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(jac_oop, jac_iip) + else + _jac = nothing + end + + M = calculate_massmatrix(sys) + + _M = if sparse && !(u0 === nothing || M === I) + SparseArrays.sparse(M) + elseif u0 === nothing || M === I + M + else + ArrayInterface.restructure(u0 .* u0', M) + end + + observedfun = ObservedFunctionCache( + sys; steady_state, eval_expression, eval_module, checkbounds, cse) + + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + W_prototype = similar(W_sparsity(sys), uElType) + else + W_prototype = nothing + end + + @set! sys.split_idxs = split_idxs + + ODEFunction{iip, specialize}(f; + sys = sys, + jac = _jac === nothing ? nothing : _jac, + tgrad = _tgrad === nothing ? nothing : _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + observed = observedfun, + sparsity = sparsity ? W_sparsity(sys) : nothing, + analytic = analytic, + initialization_data) +end + +""" +```julia +DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, tgrad = false, + jac = false, + sparse = false, + kwargs...) where {iip} +``` + +Create an `DAEFunction` from the [`ODESystem`](@ref). The arguments `dvs` and +`ps` are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function DiffEqBase.DAEFunction(sys::AbstractODESystem, args...; kwargs...) + DAEFunction{true}(sys, args...; kwargs...) +end + +function DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + ddvs = map(Base.Fix2(diff2term, get_iv(sys)) ∘ Differential(get_iv(sys)), dvs), + version = nothing, p = nothing, + jac = false, + eval_expression = false, + sparse = false, simplify = false, + eval_module = @__MODULE__, + checkbounds = false, + initialization_data = nothing, + cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEFunction`") + end + f_gen = generate_function(sys, dvs, ps; implicit_dae = true, + expression = Val{true}, cse, + expression_module = eval_module, checkbounds = checkbounds, + kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) + + if jac + jac_gen = generate_dae_jacobian(sys, dvs, ps; + simplify = simplify, sparse = sparse, + expression = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) + + _jac = GeneratedFunctionWrapper{(3, 5, is_split(sys))}(jac_oop, jac_iip) + else + _jac = nothing + end + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + jac_prototype = if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + if jac + J1 = calculate_jacobian(sys, sparse = sparse) + derivatives = Differential(get_iv(sys)).(unknowns(sys)) + J2 = calculate_jacobian(sys; sparse = sparse, dvs = derivatives) + similar(J1 + J2, uElType) + else + similar(jacobian_dae_sparsity(sys), uElType) + end + else + nothing + end + + DAEFunction{iip}(f; + sys = sys, + jac = _jac === nothing ? nothing : _jac, + jac_prototype = jac_prototype, + observed = observedfun, + initialization_data) +end + +function DiffEqBase.DDEFunction(sys::AbstractODESystem, args...; kwargs...) + DDEFunction{true}(sys, args...; kwargs...) +end + +function DiffEqBase.DDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + eval_expression = false, + eval_module = @__MODULE__, + checkbounds = false, + initialization_data = nothing, + cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `DDEFunction`") + end + f_gen = generate_function(sys, dvs, ps; isdde = true, + expression = Val{true}, + expression_module = eval_module, checkbounds = checkbounds, + cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) + + DDEFunction{iip}(f; sys = sys, initialization_data) +end + +function DiffEqBase.SDDEFunction(sys::AbstractODESystem, args...; kwargs...) + SDDEFunction{true}(sys, args...; kwargs...) +end + +function DiffEqBase.SDDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + eval_expression = false, + eval_module = @__MODULE__, + checkbounds = false, + initialization_data = nothing, + cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `SDDEFunction`") + end + f_gen = generate_function(sys, dvs, ps; isdde = true, + expression = Val{true}, + expression_module = eval_module, checkbounds = checkbounds, + cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) + + g_gen = generate_diffusion_function(sys, dvs, ps; expression = Val{true}, + isdde = true, cse, kwargs...) + g_oop, g_iip = eval_or_rgf.(g_gen; eval_expression, eval_module) + g = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(g_oop, g_iip) + + SDDEFunction{iip}(f, g; sys = sys, initialization_data) +end + +""" +```julia +ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, tgrad = false, + jac = false, + sparse = false, + kwargs...) where {iip} +``` + +Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct ODEFunctionExpr{iip, specialize} end + +function ODEFunctionExpr{iip, specialize}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, tgrad = false, + jac = false, p = nothing, + linenumbers = false, + sparse = false, simplify = false, + steady_state = false, + sparsity = false, + observedfun_exp = nothing, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEFunctionExpr`") + end + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + + fsym = gensym(:f) + _f = :($fsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})($f_oop, $f_iip)) + tgradsym = gensym(:tgrad) + if tgrad + tgrad_oop, tgrad_iip = generate_tgrad(sys, dvs, ps; + simplify = simplify, + expression = Val{true}, kwargs...) + _tgrad = :($tgradsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})( + $tgrad_oop, $tgrad_iip)) + else + _tgrad = :($tgradsym = nothing) + end + + jacsym = gensym(:jac) + if jac + jac_oop, jac_iip = generate_jacobian(sys, dvs, ps; + sparse = sparse, simplify = simplify, + expression = Val{true}, kwargs...) + _jac = :($jacsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})( + $jac_oop, $jac_iip)) + else + _jac = :($jacsym = nothing) + end + + Msym = gensym(:M) + M = calculate_massmatrix(sys) + if sparse && !(u0 === nothing || M === I) + _M = :($Msym = $(SparseArrays.sparse(M))) + elseif u0 === nothing || M === I + _M = :($Msym = $M) + else + _M = :($Msym = $(ArrayInterface.restructure(u0 .* u0', M))) + end + + jp_expr = sparse ? :($similar($(get_jac(sys)[]), Float64)) : :nothing + ex = quote + let $_f, $_tgrad, $_jac, $_M + ODEFunction{$iip, $specialize}($fsym, + sys = $sys, + jac = $jacsym, + tgrad = $tgradsym, + mass_matrix = $Msym, + jac_prototype = $jp_expr, + sparsity = $(sparsity ? jacobian_sparsity(sys) : nothing), + observed = $observedfun_exp) + end + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function ODEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) + ODEFunctionExpr{true}(sys, args...; kwargs...) +end + +function ODEFunctionExpr{true}(sys::AbstractODESystem, args...; kwargs...) + return ODEFunctionExpr{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function ODEFunctionExpr{false}(sys::AbstractODESystem, args...; kwargs...) + return ODEFunctionExpr{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +""" +```julia +DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, tgrad = false, + jac = false, + sparse = false, + kwargs...) where {iip} +``` + +Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct DAEFunctionExpr{iip} end + +function DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, tgrad = false, + jac = false, p = nothing, + linenumbers = false, + sparse = false, simplify = false, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `DAEFunctionExpr`") + end + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, + implicit_dae = true, kwargs...) + fsym = gensym(:f) + _f = :($fsym = $(GeneratedFunctionWrapper{(3, 4, is_split(sys))})($f_oop, $f_iip)) + ex = quote + $_f + ODEFunction{$iip}($fsym) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function DAEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) + DAEFunctionExpr{true}(sys, args...; kwargs...) +end + +struct SymbolicTstops{F} + fn::F +end + +function (st::SymbolicTstops)(p, tspan) + unique!(sort!(reduce(vcat, st.fn(p, tspan...)))) +end + +function SymbolicTstops( + sys::AbstractSystem; eval_expression = false, eval_module = @__MODULE__) + tstops = symbolic_tstops(sys) + isempty(tstops) && return nothing + t0 = gensym(:t0) + t1 = gensym(:t1) + tstops = map(tstops) do val + if is_array_of_symbolics(val) || val isa AbstractArray + collect(val) + else + term(:, t0, unwrap(val), t1; type = AbstractArray{Real}) + end + end + rps = reorder_parameters(sys) + tstops, _ = build_function_wrapper(sys, tstops, + rps..., + t0, + t1; + expression = Val{true}, + p_start = 1, p_end = length(rps), add_observed = false, force_SA = true) + tstops = eval_or_rgf(tstops; eval_expression, eval_module) + tstops = GeneratedFunctionWrapper{(1, 3, is_split(sys))}(tstops, nothing) + return SymbolicTstops(tstops) +end + +""" +```julia +DiffEqBase.ODEProblem{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + allow_cost = false, + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + simplify = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates an ODEProblem from an ODESystem and allows for automatically +symbolically calculating numerical enhancements. +""" +function DiffEqBase.ODEProblem(sys::AbstractODESystem, args...; kwargs...) + ODEProblem{true}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEProblem(sys::AbstractODESystem, + u0map::StaticArray, + args...; + kwargs...) + ODEProblem{false, SciMLBase.FullSpecialize}(sys, u0map, args...; kwargs...) +end + +function DiffEqBase.ODEProblem{true}(sys::AbstractODESystem, args...; kwargs...) + ODEProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEProblem{false}(sys::AbstractODESystem, args...; kwargs...) + ODEProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.ODEProblem{iip, specialize}(sys::AbstractODESystem, u0map = [], + tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + allow_cost = false, + callback = nothing, + check_length = true, + warn_initialize_determined = true, + eval_expression = false, + eval_module = @__MODULE__, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEProblem`") + end + + if !isnothing(get_constraintsystem(sys)) + error("An ODESystem with constraints cannot be used to construct a regular ODEProblem. + Consider a BVProblem instead.") + end + + if !isempty(get_costs(sys)) && !allow_cost + error("ODEProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") + end + + f, u0, p = process_SciMLProblem(ODEFunction{iip, specialize}, sys, u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, + check_length, warn_initialize_determined, eval_expression, eval_module, kwargs...) + cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) + + kwargs = filter_kwargs(kwargs) + pt = something(get_metadata(sys), StandardODEProblem()) + + kwargs1 = (;) + if cbs !== nothing + kwargs1 = merge(kwargs1, (callback = cbs,)) + end + + tstops = SymbolicTstops(sys; eval_expression, eval_module) + if tstops !== nothing + kwargs1 = merge(kwargs1, (; tstops)) + end + + # Call `remake` so it runs initialization if it is trivial + return remake(ODEProblem{iip}(f, u0, tspan, p, pt; kwargs1..., kwargs...)) +end +get_callback(prob::ODEProblem) = prob.kwargs[:callback] + +""" +```julia +SciMLBase.BVProblem{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + constraints = nothing, guesses = nothing, + version = nothing, tgrad = false, + jac = true, sparse = true, + simplify = false, + kwargs...) where {iip} +``` + +Create a boundary value problem from the [`ODESystem`](@ref). + +`u0map` is used to specify fixed initial values for the states. Every variable +must have either an initial guess supplied using `guesses` or a fixed initial +value specified using `u0map`. + +Boundary value conditions are supplied to ODESystems +in the form of a ConstraintsSystem. These equations +should specify values that state variables should +take at specific points, as in `x(0.5) ~ 1`). More general constraints that +should hold over the entire solution, such as `x(t)^2 + y(t)^2`, should be +specified as one of the equations used to build the `ODESystem`. + +If an ODESystem without `constraints` is specified, it will be treated as an initial value problem. + +```julia + @parameters g t_c = 0.5 + @variables x(..) y(t) λ(t) + eqs = [D(D(x(t))) ~ λ * x(t) + D(D(y)) ~ λ * y - g + x(t)^2 + y^2 ~ 1] + cstr = [x(0.5) ~ 1] + @mtkbuild pend = ODESystem(eqs, t; constraints = cstrs) + + tspan = (0.0, 1.5) + u0map = [x(t) => 0.6, y => 0.8] + parammap = [g => 1] + guesses = [λ => 1] + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +``` + +If the `ODESystem` has algebraic equations, like `x(t)^2 + y(t)^2`, the resulting +`BVProblem` must be solved using BVDAE solvers, such as Ascher. +""" +function SciMLBase.BVProblem(sys::AbstractODESystem, args...; kwargs...) + BVProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem(sys::AbstractODESystem, + u0map::StaticArray, + args...; + kwargs...) + BVProblem{false, SciMLBase.FullSpecialize}(sys, u0map, args...; kwargs...) +end + +function SciMLBase.BVProblem{true}(sys::AbstractODESystem, args...; kwargs...) + BVProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem{false}(sys::AbstractODESystem, args...; kwargs...) + BVProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem{iip, specialize}(sys::AbstractODESystem, u0map = [], + tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + guesses = Dict(), + allow_cost = false, + version = nothing, tgrad = false, + callback = nothing, + check_length = true, + warn_initialize_determined = true, + eval_expression = false, + eval_module = @__MODULE__, + cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `BVProblem`") + end + !isnothing(callback) && error("BVP solvers do not support callbacks.") + + if !isempty(get_costs(sys)) && !allow_cost + error("BVProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") + end + + has_alg_eqs(sys) && + error("The BVProblem constructor currently does not support ODESystems with algebraic equations.") # Remove this when the BVDAE solvers get updated, the codegen should work when it does. + + sts = unknowns(sys) + ps = parameters(sys) + constraintsys = get_constraintsystem(sys) + + if !isnothing(constraintsys) + (length(constraints(constraintsys)) + length(u0map) > length(sts)) && + @warn "The BVProblem is overdetermined. The total number of conditions (# constraints + # fixed initial values given by u0map) exceeds the total number of states. The BVP solvers will default to doing a nonlinear least-squares optimization." + end + + # ODESystems without algebraic equations should use both fixed values + guesses + # for initialization. + _u0map = has_alg_eqs(sys) ? u0map : merge(Dict(u0map), Dict(guesses)) + f, u0, p = process_SciMLProblem(ODEFunction{iip, specialize}, sys, _u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, guesses, + check_length, warn_initialize_determined, eval_expression, eval_module, cse, kwargs...) + + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + u0_idxs = has_alg_eqs(sys) ? collect(1:length(sts)) : [stidxmap[k] for (k, v) in u0map] + + fns = generate_function_bc(sys, u0, u0_idxs, tspan; cse) + bc_oop, bc_iip = eval_or_rgf.(fns; eval_expression, eval_module) + bc(sol, p, t) = bc_oop(sol, p, t) + bc(resid, u, p, t) = bc_iip(resid, u, p, t) + + return BVProblem{iip}(f, bc, u0, tspan, p; kwargs...) +end + +get_callback(prob::BVProblem) = error("BVP solvers do not support callbacks.") + +""" + generate_function_bc(sys::ODESystem, u0, u0_idxs, tspan) + + Given an ODESystem with constraints, generate the boundary condition function to pass to boundary value problem solvers. + Expression uses the constraints and the provided initial conditions. +""" +function generate_function_bc(sys::ODESystem, u0, u0_idxs, tspan; kwargs...) + iv = get_iv(sys) + sts = unknowns(sys) + ps = parameters(sys) + np = length(ps) + ns = length(sts) + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + pidxmap = Dict([v => i for (i, v) in enumerate(ps)]) + + @variables sol(..)[1:ns] + + conssys = get_constraintsystem(sys) + cons = Any[] + if !isnothing(conssys) + cons = [con.lhs - con.rhs for con in constraints(conssys)] + + for st in get_unknowns(conssys) + x = operation(st) + t = only(arguments(st)) + idx = stidxmap[x(iv)] + + cons = map(c -> Symbolics.substitute(c, Dict(x(t) => sol(t)[idx])), cons) + end + end + + init_conds = Any[] + for i in u0_idxs + expr = sol(tspan[1])[i] - u0[i] + push!(init_conds, expr) + end + + exprs = vcat(init_conds, cons) + _p = reorder_parameters(sys, ps) + + build_function_wrapper(sys, exprs, sol, _p..., iv; output_type = Array, kwargs...) +end + +""" +```julia +DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + simplify = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates a DAEProblem from an ODESystem and allows for automatically +symbolically calculating numerical enhancements. + +Note: Solvers for DAEProblems like DFBDF, DImplicitEuler, DABDF2 are +generally slower than the ones for ODEProblems. We recommend trying +ODEProblem and its solvers for your problem first. +""" +function DiffEqBase.DAEProblem(sys::AbstractODESystem, args...; kwargs...) + DAEProblem{true}(sys, args...; kwargs...) +end + +function DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + allow_cost = false, + warn_initialize_determined = true, + check_length = true, eval_expression = false, eval_module = @__MODULE__, kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEProblem`.") + end + + if !isempty(get_costs(sys)) && !allow_cost + error("DAEProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") + end + + f, du0, u0, p = process_SciMLProblem(DAEFunction{iip}, sys, u0map, parammap; + implicit_dae = true, du0map = du0map, check_length, + t = tspan !== nothing ? tspan[1] : tspan, + warn_initialize_determined, kwargs...) + diffvars = collect_differential_variables(sys) + sts = unknowns(sys) + differential_vars = map(Base.Fix2(in, diffvars), sts) + kwargs = filter_kwargs(kwargs) + + kwargs1 = (;) + + tstops = SymbolicTstops(sys; eval_expression, eval_module) + if tstops !== nothing + kwargs1 = merge(kwargs1, (; tstops)) + end + + # Call `remake` so it runs initialization if it is trivial + return remake(DAEProblem{iip}( + f, du0, u0, tspan, p; differential_vars = differential_vars, + kwargs..., kwargs1...)) +end + +function generate_history(sys::AbstractODESystem, u0; expression = Val{false}, kwargs...) + p = reorder_parameters(sys) + build_function_wrapper( + sys, u0, p..., get_iv(sys); expression, p_start = 1, p_end = length(p), + similarto = typeof(u0), wrap_delays = false, kwargs...) +end + +function DiffEqBase.DDEProblem(sys::AbstractODESystem, args...; kwargs...) + DDEProblem{true}(sys, args...; kwargs...) +end +function DiffEqBase.DDEProblem{iip}(sys::AbstractODESystem, u0map = [], + tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + callback = nothing, + check_length = true, + eval_expression = false, + eval_module = @__MODULE__, + u0_constructor = identity, + cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DDEProblem`") + end + f, u0, p = process_SciMLProblem(DDEFunction{iip}, sys, u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, + symbolic_u0 = true, u0_constructor, cse, + check_length, eval_expression, eval_module, kwargs...) + h_gen = generate_history(sys, u0; expression = Val{true}, cse) + h_oop, h_iip = eval_or_rgf.(h_gen; eval_expression, eval_module) + h = h_oop + u0 = float.(h(p, tspan[1])) + if u0 !== nothing + u0 = u0_constructor(u0) + end + + cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) + kwargs = filter_kwargs(kwargs) + + kwargs1 = (;) + if cbs !== nothing + kwargs1 = merge(kwargs1, (callback = cbs,)) + end + # Call `remake` so it runs initialization if it is trivial + return remake(DDEProblem{iip}(f, u0, h, tspan, p; kwargs1..., kwargs...)) +end + +function DiffEqBase.SDDEProblem(sys::AbstractODESystem, args...; kwargs...) + SDDEProblem{true}(sys, args...; kwargs...) +end +function DiffEqBase.SDDEProblem{iip}(sys::AbstractODESystem, u0map = [], + tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + callback = nothing, + check_length = true, + sparsenoise = nothing, + eval_expression = false, + eval_module = @__MODULE__, + u0_constructor = identity, + cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SDDEProblem`") + end + f, u0, p = process_SciMLProblem(SDDEFunction{iip}, sys, u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, + symbolic_u0 = true, eval_expression, eval_module, u0_constructor, + check_length, cse, kwargs...) + h_gen = generate_history(sys, u0; expression = Val{true}, cse) + h_oop, h_iip = eval_or_rgf.(h_gen; eval_expression, eval_module) + h = h_oop + u0 = h(p, tspan[1]) + if u0 !== nothing + u0 = u0_constructor(u0) + end + + cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) + kwargs = filter_kwargs(kwargs) + + kwargs1 = (;) + if cbs !== nothing + kwargs1 = merge(kwargs1, (callback = cbs,)) + end + + noiseeqs = get_noiseeqs(sys) + sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) + if noiseeqs isa AbstractVector + noise_rate_prototype = nothing + elseif sparsenoise + I, J, V = findnz(SparseArrays.sparse(noiseeqs)) + noise_rate_prototype = SparseArrays.sparse(I, J, zero(eltype(u0))) + else + noise_rate_prototype = zeros(eltype(u0), size(noiseeqs)) + end + # Call `remake` so it runs initialization if it is trivial + return remake(SDDEProblem{iip}(f, f.g, u0, h, tspan, p; + noise_rate_prototype = + noise_rate_prototype, kwargs1..., kwargs...)) +end + +""" +```julia +ODEProblemExpr{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + linenumbers = true, parallel = SerialForm(), + skipzeros = true, fillzeros = true, + simplify = false, + kwargs...) where {iip} +``` + +Generates a Julia expression for constructing an ODEProblem from an +ODESystem and allows for automatically symbolically calculating +numerical enhancements. +""" +struct ODEProblemExpr{iip} end + +function ODEProblemExpr{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); check_length = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `ODEProblemExpr`") + end + f, u0, p = process_SciMLProblem( + ODEFunctionExpr{iip}, sys, u0map, parammap; check_length, + t = tspan !== nothing ? tspan[1] : tspan, + kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + kwargs = filter_kwargs(kwargs) + kwarg_params = gen_quoted_kwargs(kwargs) + odep = Expr(:call, :ODEProblem, kwarg_params, :f, :u0, :tspan, :p) + ex = quote + f = $f + u0 = $u0 + tspan = $tspan + p = $p + $odep + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function ODEProblemExpr(sys::AbstractODESystem, args...; kwargs...) + ODEProblemExpr{true}(sys, args...; kwargs...) +end + +""" +```julia +DAEProblemExpr{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + linenumbers = true, parallel = SerialForm(), + skipzeros = true, fillzeros = true, + simplify = false, + kwargs...) where {iip} +``` + +Generates a Julia expression for constructing a DAEProblem from an +ODESystem and allows for automatically symbolically calculating +numerical enhancements. +""" +struct DAEProblemExpr{iip} end + +function DAEProblemExpr{iip}(sys::AbstractODESystem, du0map, u0map, tspan, + parammap = DiffEqBase.NullParameters(); check_length = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEProblemExpr`") + end + f, du0, u0, p = process_SciMLProblem(DAEFunctionExpr{iip}, sys, u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, + implicit_dae = true, du0map = du0map, check_length, + kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + diffvars = collect_differential_variables(sys) + sts = unknowns(sys) + differential_vars = map(Base.Fix2(in, diffvars), sts) + kwargs = filter_kwargs(kwargs) + kwarg_params = gen_quoted_kwargs(kwargs) + push!(kwarg_params.args, Expr(:kw, :differential_vars, :differential_vars)) + prob = Expr(:call, :(DAEProblem{$iip}), kwarg_params, :f, :du0, :u0, :tspan, :p) + ex = quote + f = $f + u0 = $u0 + du0 = $du0 + tspan = $tspan + p = $p + differential_vars = $differential_vars + $prob + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function DAEProblemExpr(sys::AbstractODESystem, args...; kwargs...) + DAEProblemExpr{true}(sys, args...; kwargs...) +end + +""" +```julia +SciMLBase.SteadyStateProblem(sys::AbstractODESystem, u0map, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates an SteadyStateProblem from an ODESystem and allows for automatically +symbolically calculating numerical enhancements. +""" +function SciMLBase.SteadyStateProblem(sys::AbstractODESystem, args...; kwargs...) + SteadyStateProblem{true}(sys, args...; kwargs...) +end + +function DiffEqBase.SteadyStateProblem{iip}(sys::AbstractODESystem, u0map, + parammap = SciMLBase.NullParameters(); + check_length = true, kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SteadyStateProblem`") + end + f, u0, p = process_SciMLProblem(ODEFunction{iip}, sys, u0map, parammap; + steady_state = true, + check_length, force_initialization_time_independent = true, kwargs...) + kwargs = filter_kwargs(kwargs) + SteadyStateProblem{iip}(f, u0, p; kwargs...) +end + +""" +```julia +SciMLBase.SteadyStateProblemExpr(sys::AbstractODESystem, u0map, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + skipzeros = true, fillzeros = true, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates a Julia expression for building a SteadyStateProblem from +an ODESystem and allows for automatically symbolically calculating +numerical enhancements. +""" +struct SteadyStateProblemExpr{iip} end + +function SteadyStateProblemExpr{iip}(sys::AbstractODESystem, u0map, + parammap = SciMLBase.NullParameters(); + check_length = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SteadyStateProblemExpr`") + end + f, u0, p = process_SciMLProblem(ODEFunctionExpr{iip}, sys, u0map, parammap; + steady_state = true, + check_length, kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + kwargs = filter_kwargs(kwargs) + kwarg_params = gen_quoted_kwargs(kwargs) + prob = Expr(:call, :SteadyStateProblem, kwarg_params, :f, :u0, :p) + ex = quote + f = $f + u0 = $u0 + p = $p + $prob + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function SteadyStateProblemExpr(sys::AbstractODESystem, args...; kwargs...) + SteadyStateProblemExpr{true}(sys, args...; kwargs...) +end + +function _match_eqs(eqs1, eqs2) + eqpairs = Pair[] + for (i, eq) in enumerate(eqs1) + for (j, eq2) in enumerate(eqs2) + if isequal(eq, eq2) + push!(eqpairs, i => j) + break + end + end + end + eqpairs +end + +function isisomorphic(sys1::AbstractODESystem, sys2::AbstractODESystem) + sys1 = flatten(sys1) + sys2 = flatten(sys2) + + iv2 = only(independent_variables(sys2)) + sys1 = convert_system(ODESystem, sys1, iv2) + s1, s2 = unknowns(sys1), unknowns(sys2) + p1, p2 = parameters(sys1), parameters(sys2) + + (length(s1) != length(s2)) || (length(p1) != length(p2)) && return false + + eqs1 = equations(sys1) + eqs2 = equations(sys2) + + pps = permutations(p2) + psts = permutations(s2) + orig = [p1; s1] + perms = ([x; y] for x in pps for y in psts) + + for perm in perms + rules = Dict(orig .=> perm) + neweqs1 = substitute(eqs1, rules) + eqpairs = _match_eqs(neweqs1, eqs2) + if length(eqpairs) == length(eqs1) + return true + end + end + return false +end + +function flatten_equations(eqs) + mapreduce(vcat, eqs; init = Equation[]) do eq + islhsarr = eq.lhs isa AbstractArray || Symbolics.isarraysymbolic(eq.lhs) + isrhsarr = eq.rhs isa AbstractArray || Symbolics.isarraysymbolic(eq.rhs) + if islhsarr || isrhsarr + islhsarr && isrhsarr || + error("LHS ($(eq.lhs)) and RHS ($(eq.rhs)) must either both be array expressions or both scalar") + size(eq.lhs) == size(eq.rhs) || + error("Size of LHS ($(eq.lhs)) and RHS ($(eq.rhs)) must match: got $(size(eq.lhs)) and $(size(eq.rhs))") + return vec(collect(eq.lhs) .~ collect(eq.rhs)) + else + eq + end + end +end + +struct InitializationProblem{iip, specialization} end + +""" +```julia +InitializationProblem{iip}(sys::AbstractODESystem, t, u0map, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, + checkbounds = false, sparse = false, + simplify = false, + linenumbers = true, parallel = SerialForm(), + initialization_eqs = [], + fully_determined = false, + kwargs...) where {iip} +``` + +Generates a NonlinearProblem or NonlinearLeastSquaresProblem from an ODESystem +which represents the initialization, i.e. the calculation of the consistent +initial conditions for the given DAE. +""" +function InitializationProblem(sys::AbstractSystem, args...; kwargs...) + InitializationProblem{true}(sys, args...; kwargs...) +end + +function InitializationProblem(sys::AbstractSystem, t, + u0map::StaticArray, + args...; + kwargs...) + InitializationProblem{false, SciMLBase.FullSpecialize}( + sys, t, u0map, args...; kwargs...) +end + +function InitializationProblem{true}(sys::AbstractSystem, args...; kwargs...) + InitializationProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function InitializationProblem{false}(sys::AbstractSystem, args...; kwargs...) + InitializationProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +const INCOMPLETE_INITIALIZATION_MESSAGE = """ + Initialization incomplete. Not all of the state variables of the + DAE system can be determined by the initialization. Missing + variables: + """ + +struct IncompleteInitializationError <: Exception + uninit::Any +end + +function Base.showerror(io::IO, e::IncompleteInitializationError) + println(io, INCOMPLETE_INITIALIZATION_MESSAGE) + println(io, e.uninit) +end + +function InitializationProblem{iip, specialize}(sys::AbstractSystem, + t, u0map = [], + parammap = DiffEqBase.NullParameters(); + guesses = [], + check_length = true, + warn_initialize_determined = true, + initialization_eqs = [], + fully_determined = nothing, + check_units = true, + use_scc = true, + allow_incomplete = false, + force_time_independent = false, + algebraic_only = false, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEProblem`") + end + if isempty(u0map) && get_initializesystem(sys) !== nothing + isys = get_initializesystem(sys; initialization_eqs, check_units) + simplify_system = false + elseif isempty(u0map) && get_initializesystem(sys) === nothing + isys = generate_initializesystem( + sys; initialization_eqs, check_units, pmap = parammap, + guesses, extra_metadata = (; use_scc), algebraic_only) + simplify_system = true + else + isys = generate_initializesystem( + sys; u0map, initialization_eqs, check_units, + pmap = parammap, guesses, extra_metadata = (; use_scc), algebraic_only) + simplify_system = true + end + + # useful for `SteadyStateProblem` since `f` has to be autonomous and the + # initialization should be too + if force_time_independent + idx = findfirst(isequal(get_iv(sys)), get_ps(isys)) + idx === nothing || deleteat!(get_ps(isys), idx) + end + + if simplify_system + isys = structural_simplify(isys; fully_determined) + end + + meta = get_metadata(isys) + if meta isa InitializationSystemMetadata + @set! isys.metadata.oop_reconstruct_u0_p = ReconstructInitializeprob( + sys, isys) + end + + ts = get_tearing_state(isys) + unassigned_vars = StructuralTransformations.singular_check(ts) + if warn_initialize_determined && !isempty(unassigned_vars) + errmsg = """ + The initialization system is structurally singular. Guess values may \ + significantly affect the initial values of the ODE. The problematic variables \ + are $unassigned_vars. + + Note that the identification of problematic variables is a best-effort heuristic. + """ + @warn errmsg + end + + uninit = setdiff(unknowns(sys), [unknowns(isys); getfield.(observed(isys), :lhs)]) + + # TODO: throw on uninitialized arrays + filter!(x -> !(x isa Symbolics.Arr), uninit) + if is_time_dependent(sys) && !isempty(uninit) + allow_incomplete || throw(IncompleteInitializationError(uninit)) + # for incomplete initialization, we will add the missing variables as parameters. + # they will be updated by `update_initializeprob!` and `initializeprobmap` will + # use them to construct the new `u0`. + newparams = map(toparam, uninit) + append!(get_ps(isys), newparams) + isys = complete(isys) + end + + neqs = length(equations(isys)) + nunknown = length(unknowns(isys)) + + if use_scc + scc_message = "`SCCNonlinearProblem` can only be used for initialization of fully determined systems and hence will not be used here. " + else + scc_message = "" + end + + if warn_initialize_determined && neqs > nunknown + @warn "Initialization system is overdetermined. $neqs equations for $nunknown unknowns. Initialization will default to using least squares. $(scc_message)To suppress this warning pass warn_initialize_determined = false. To make this warning into an error, pass fully_determined = true" + end + if warn_initialize_determined && neqs < nunknown + @warn "Initialization system is underdetermined. $neqs equations for $nunknown unknowns. Initialization will default to using least squares. $(scc_message)To suppress this warning pass warn_initialize_determined = false. To make this warning into an error, pass fully_determined = true" + end + + parammap = recursive_unwrap(anydict(parammap)) + if t !== nothing + parammap[get_iv(sys)] = t + end + filter!(kvp -> kvp[2] !== missing, parammap) + + u0map = to_varmap(u0map, unknowns(sys)) + if isempty(guesses) + guesses = Dict() + end + + filter_missing_values!(u0map) + filter_missing_values!(parammap) + u0map = merge(ModelingToolkit.guesses(sys), todict(guesses), u0map) + + TProb = if neqs == nunknown && isempty(unassigned_vars) + if use_scc && neqs > 0 + if is_split(isys) + SCCNonlinearProblem + else + @warn "`SCCNonlinearProblem` can only be used with `split = true` systems. Simplify your `ODESystem` with `split = true` or pass `use_scc = false` to disable this warning" + NonlinearProblem + end + else + NonlinearProblem + end + else + NonlinearLeastSquaresProblem + end + TProb(isys, u0map, parammap; kwargs..., + build_initializeprob = false, is_initializeprob = true) +end diff --git a/src/systems/diffeqs/basic_transformations.jl b/src/systems/diffeqs/basic_transformations.jl index 1478a968b8..a08c83ffb6 100644 --- a/src/systems/diffeqs/basic_transformations.jl +++ b/src/systems/diffeqs/basic_transformations.jl @@ -8,31 +8,26 @@ propagation from a given initial distribution density. For example, if ``u'=p*u`` and `p` follows a probability distribution ``f(p)``, then the probability density of a future value with a given -choice of ``p`` is computed by setting the inital `trJ = f(p)`, and +choice of ``p`` is computed by setting the initial `trJ = f(p)`, and the final value of `trJ` is the probability of ``u(t)``. Example: ```julia -using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit, OrdinaryDiffEq -@parameters t α β γ δ +@independent_variables t +@parameters α β γ δ @variables x(t) y(t) D = Differential(t) +eqs = [D(x) ~ α*x - β*x*y, D(y) ~ -δ*y + γ*x*y] +@named sys = ODESystem(eqs, t) -eqs = [D(x) ~ α*x - β*x*y, - D(y) ~ -δ*y + γ*x*y] - -sys = ODESystem(eqs) sys2 = liouville_transform(sys) -@variables trJ - -u0 = [x => 1.0, - y => 1.0, - trJ => 1.0] - -prob = ODEProblem(sys2,u0,tspan,p) -sol = solve(prob,Tsit5()) +sys2 = complete(sys2) +u0 = [x => 1.0, y => 1.0, sys2.trJ => 1.0] +prob = ODEProblem(sys2, u0, tspan, p) +sol = solve(prob, Tsit5()) ``` Where `sol[3,:]` is the evolution of `trJ` over time. @@ -45,12 +40,159 @@ Optimal Transport Approach Abhishek Halder, Kooktae Lee, and Raktim Bhattacharya https://abhishekhalder.bitbucket.io/F16ACC2013Final.pdf """ -function liouville_transform(sys) - t = independent_variable(sys) - @variables trJ - D = ModelingToolkit.Differential(t) - neweq = D(trJ) ~ trJ*-tr(calculate_jacobian(sys)) - neweqs = [equations(sys);neweq] - vars = [states(sys);trJ] - ODESystem(neweqs,t,vars,parameters(sys)) +function liouville_transform(sys::AbstractODESystem; kwargs...) + t = get_iv(sys) + @variables trJ + D = ModelingToolkit.Differential(t) + neweq = D(trJ) ~ trJ * -tr(calculate_jacobian(sys)) + neweqs = [equations(sys); neweq] + vars = [unknowns(sys); trJ] + ODESystem( + neweqs, t, vars, parameters(sys); + checks = false, name = nameof(sys), kwargs... + ) +end + +""" + change_independent_variable( + sys::AbstractODESystem, iv, eqs = []; + add_old_diff = false, simplify = true, fold = false + ) + +Transform the independent variable (e.g. ``t``) of the ODE system `sys` to a dependent variable `iv` (e.g. ``u(t)``). +The transformation is well-defined when the mapping between the new and old independent variables are one-to-one. +This is satisfied if one is a strictly increasing function of the other (e.g. ``du(t)/dt > 0`` or ``du(t)/dt < 0``). + +Any extra equations `eqs` involving the new and old independent variables will be taken into account in the transformation. + +# Keyword arguments + +- `add_old_diff`: Whether to add a differential equation for the old independent variable in terms of the new one using the inverse function rule ``dt/du = 1/(du/dt)``. +- `simplify`: Whether expanded derivative expressions are simplified. This can give a tidier transformation. +- `fold`: Whether internal substitutions will evaluate numerical expressions. + +# Usage before structural simplification + +The variable change must take place before structural simplification. +In following calls to `structural_simplify`, consider passing `allow_symbolic = true` to avoid undesired constraint equations between between dummy variables. + +# Usage with non-autonomous systems + +If `sys` is non-autonomous (i.e. ``t`` appears explicitly in its equations), consider passing an algebraic equation relating the new and old independent variables (e.g. ``t = f(u(t))``). +Otherwise the transformed system can be underdetermined. +If an algebraic relation is not known, consider using `add_old_diff` instead. + +# Usage with hierarchical systems + +It is recommended that `iv` is a non-namespaced variable in `sys`. +This means it can belong to the top-level system or be a variable in a subsystem declared with `GlobalScope`. + +# Example + +Consider a free fall with constant horizontal velocity. +Physics naturally describes position as a function of time. +By changing the independent variable, it can be reformulated for vertical position as a function of horizontal position: +```julia +julia> @variables x(t) y(t); + +julia> @named M = ODESystem([D(D(y)) ~ -9.81, D(D(x)) ~ 0.0], t); + +julia> M = change_independent_variable(M, x); + +julia> M = structural_simplify(M; allow_symbolic = true); + +julia> unknowns(M) +3-element Vector{SymbolicUtils.BasicSymbolic{Real}}: + xˍt(x) + y(x) + yˍx(x) +``` +""" +function change_independent_variable( + sys::AbstractODESystem, iv, eqs = []; + add_old_diff = false, simplify = true, fold = false +) + iv2_of_iv1 = unwrap(iv) # e.g. u(t) + iv1 = get_iv(sys) # e.g. t + + if is_dde(sys) + error("System $(nameof(sys)) contains delay differential equations (DDEs). This is currently not supported!") + elseif isscheduled(sys) + error("System $(nameof(sys)) is structurally simplified. Change independent variable before structural simplification!") + elseif !iscall(iv2_of_iv1) || !isequal(only(arguments(iv2_of_iv1)), iv1) + error("Variable $iv is not a function of the independent variable $iv1 of the system $(nameof(sys))!") + end + + # Set up intermediate and final variables for the transformation + iv1name = nameof(iv1) # e.g. :t + iv2name = nameof(operation(iv2_of_iv1)) # e.g. :u + iv2, = @independent_variables $iv2name # e.g. u + iv1_of_iv2, = GlobalScope.(@variables $iv1name(iv2)) # inverse, e.g. t(u), global because iv1 has no namespacing in sys + D1 = Differential(iv1) # e.g. d/d(t) + div2_of_iv1 = GlobalScope(default_toterm(D1(iv2_of_iv1))) # e.g. uˍt(t) + div2_of_iv2 = substitute(div2_of_iv1, iv1 => iv2) # e.g. uˍt(u) + div2_of_iv2_of_iv1 = substitute(div2_of_iv2, iv2 => iv2_of_iv1) # e.g. uˍt(u(t)) + + # If requested, add a differential equation for the old independent variable as a function of the old one + if add_old_diff + eqs = [eqs; Differential(iv2)(iv1_of_iv2) ~ 1 / div2_of_iv2] # e.g. dt(u)/du ~ 1 / uˍt(u) (https://en.wikipedia.org/wiki/Inverse_function_rule) + end + @set! sys.eqs = [get_eqs(sys); eqs] # add extra equations we derived + @set! sys.unknowns = [get_unknowns(sys); [iv1, div2_of_iv1]] # add new variables, will be transformed to e.g. t(u) and uˍt(u) + + # Create a utility that performs the chain rule on an expression, followed by insertion of the new independent variable: + # e.g. (d/dt)(f(t)) -> (d/dt)(f(u(t))) -> df(u(t))/du(t) * du(t)/dt -> df(u)/du * uˍt(u) + function transform(ex) + # 1) Replace the argument of every function; e.g. f(t) -> f(u(t)) + for var in vars(ex; op = Nothing) # loop over all variables in expression (op = Nothing prevents interpreting "D(f(t))" as one big variable) + is_function_of_iv1 = iscall(var) && isequal(only(arguments(var)), iv1) # of the form f(t)? + if is_function_of_iv1 && !isequal(var, iv2_of_iv1) # prevent e.g. u(t) -> u(u(t)) + var_of_iv1 = var # e.g. f(t) + var_of_iv2_of_iv1 = substitute(var_of_iv1, iv1 => iv2_of_iv1) # e.g. f(u(t)) + ex = substitute(ex, var_of_iv1 => var_of_iv2_of_iv1; fold) + end + end + # 2) Repeatedly expand chain rule until nothing changes anymore + orgex = nothing + while !isequal(ex, orgex) + orgex = ex # save original + ex = expand_derivatives(ex, simplify) # expand chain rule, e.g. (d/dt)(f(u(t)))) -> df(u(t))/du(t) * du(t)/dt + ex = substitute(ex, D1(iv2_of_iv1) => div2_of_iv2_of_iv1; fold) # e.g. du(t)/dt -> uˍt(u(t)) + end + # 3) Set new independent variable + ex = substitute(ex, iv2_of_iv1 => iv2; fold) # set e.g. u(t) -> u everywhere + ex = substitute(ex, iv1 => iv1_of_iv2; fold) # set e.g. t -> t(u) everywhere + return ex + end + + # Use the utility function to transform everything in the system! + function transform(sys::AbstractODESystem) + eqs = map(transform, get_eqs(sys)) + unknowns = map(transform, get_unknowns(sys)) + unknowns = filter(var -> !isequal(var, iv2), unknowns) # remove e.g. u + ps = map(transform, get_ps(sys)) + ps = filter(!isinitial, ps) # remove Initial(...) # TODO: shouldn't have to touch this + observed = map(transform, get_observed(sys)) + initialization_eqs = map(transform, get_initialization_eqs(sys)) + parameter_dependencies = map(transform, get_parameter_dependencies(sys)) + defaults = Dict(transform(var) => transform(val) + for (var, val) in get_defaults(sys)) + guesses = Dict(transform(var) => transform(val) for (var, val) in get_guesses(sys)) + assertions = Dict(transform(ass) => msg for (ass, msg) in get_assertions(sys)) + systems = get_systems(sys) # save before reconstructing system + wascomplete = iscomplete(sys) # save before reconstructing system + sys = typeof(sys)( # recreate system with transformed fields + eqs, iv2, unknowns, ps; observed, initialization_eqs, + parameter_dependencies, defaults, guesses, + assertions, name = nameof(sys), description = description(sys) + ) + systems = map(transform, systems) # recurse through subsystems + sys = compose(sys, systems) # rebuild hierarchical system + if wascomplete + wasflat = isempty(systems) + sys = complete(sys; flatten = wasflat) # complete output if input was complete + end + return sys + end + return transform(sys) end diff --git a/src/systems/diffeqs/first_order_transform.jl b/src/systems/diffeqs/first_order_transform.jl index 920a390d82..97fd6460d9 100644 --- a/src/systems/diffeqs/first_order_transform.jl +++ b/src/systems/diffeqs/first_order_transform.jl @@ -5,37 +5,44 @@ Takes a Nth order ODESystem and returns a new ODESystem written in first order form by defining new variables which represent the N-1 derivatives. """ function ode_order_lowering(sys::ODESystem) - iv = independent_variable(sys) - eqs_lowered, new_vars = ode_order_lowering(equations(sys), iv, states(sys)) + iv = get_iv(sys) + eqs_lowered, new_vars = ode_order_lowering(equations(sys), iv, unknowns(sys)) @set! sys.eqs = eqs_lowered - @set! sys.states = new_vars - @set! sys.structure = nothing + @set! sys.unknowns = new_vars return sys end -function ode_order_lowering(eqs, iv, states) - var_order = OrderedDict{Any,Int}() +function dae_order_lowering(sys::ODESystem) + iv = get_iv(sys) + eqs_lowered, new_vars = dae_order_lowering(equations(sys), iv, unknowns(sys)) + @set! sys.eqs = eqs_lowered + @set! sys.unknowns = new_vars + return sys +end + +function ode_order_lowering(eqs, iv, unknown_vars) + var_order = OrderedDict{Any, Int}() D = Differential(iv) diff_eqs = Equation[] diff_vars = [] alge_eqs = Equation[] - for (i, eq) ∈ enumerate(eqs) + for (i, eq) in enumerate(eqs) if !isdiffeq(eq) push!(alge_eqs, eq) else var, maxorder = var_from_nested_derivative(eq.lhs) maxorder > get(var_order, var, 1) && (var_order[var] = maxorder) var′ = lower_varname(var, iv, maxorder - 1) - rhs′ = diff2term(eq.rhs) + rhs′ = diff2term_with_unit(eq.rhs, iv) push!(diff_vars, var′) push!(diff_eqs, D(var′) ~ rhs′) end end - for (var, order) ∈ var_order - for o in (order-1):-1:1 - lvar = lower_varname(var, iv, o-1) + for (var, order) in var_order + for o in (order - 1):-1:1 + lvar = lower_varname(var, iv, o - 1) rvar = lower_varname(var, iv, o) push!(diff_vars, lvar) @@ -46,5 +53,54 @@ function ode_order_lowering(eqs, iv, states) end # we want to order the equations and variables to be `(diff, alge)` - return (vcat(diff_eqs, alge_eqs), vcat(diff_vars, setdiff(states, diff_vars))) + return (vcat(diff_eqs, alge_eqs), vcat(diff_vars, setdiff(unknown_vars, diff_vars))) +end + +function dae_order_lowering(eqs, iv, unknown_vars) + var_order = OrderedDict{Any, Int}() + D = Differential(iv) + diff_eqs = Equation[] + diff_vars = OrderedSet() + alge_eqs = Equation[] + vars = Set() + subs = Dict() + + for (i, eq) in enumerate(eqs) + vars!(vars, eq) + n_diffvars = 0 + for vv in vars + isdifferential(vv) || continue + var, maxorder = var_from_nested_derivative(vv) + isparameter(var) && continue + n_diffvars += 1 + order = get(var_order, var, nothing) + seen = order !== nothing + if !seen + order = 1 + end + maxorder > order && (var_order[var] = maxorder) + var′ = lower_varname(var, iv, maxorder - 1) + subs[vv] = D(var′) + if !seen + push!(diff_vars, var′) + end + end + n_diffvars == 0 && push!(alge_eqs, eq) + empty!(vars) + end + + for (var, order) in var_order + for o in (order - 1):-1:1 + lvar = lower_varname(var, iv, o - 1) + rvar = lower_varname(var, iv, o) + push!(diff_vars, lvar) + + rhs = rvar + eq = Differential(iv)(lvar) ~ rhs + push!(diff_eqs, eq) + end + end + + return ([diff_eqs; substitute.(eqs, (subs,))], + vcat(collect(diff_vars), setdiff(unknown_vars, diff_vars))) end diff --git a/src/systems/diffeqs/modelingtoolkitize.jl b/src/systems/diffeqs/modelingtoolkitize.jl index f9b99374e5..b2954f81e4 100644 --- a/src/systems/diffeqs/modelingtoolkitize.jl +++ b/src/systems/diffeqs/modelingtoolkitize.jl @@ -1,146 +1,303 @@ -""" -$(TYPEDSIGNATURES) - -Generate `ODESystem`, dependent variables, and parameters from an `ODEProblem`. -""" -function modelingtoolkitize(prob::DiffEqBase.ODEProblem) - prob.f isa DiffEqBase.AbstractParameterizedFunction && - return prob.f.sys - @parameters t - - if prob.p isa Tuple || prob.p isa NamedTuple - p = [x for x in prob.p] - else - p = prob.p - end - - has_p = !(p isa Union{DiffEqBase.NullParameters,Nothing}) - - var(x, i) = Num(Sym{FnType{Tuple{symtype(t)}, Real}}(nameof(Variable(x, i)))) - vars = ArrayInterface.restructure(prob.u0,[var(:x, i)(ModelingToolkit.value(t)) for i in eachindex(prob.u0)]) - params = has_p ? reshape([Num(toparam(Sym{Real}(nameof(Variable(:α, i))))) for i in eachindex(p)],size(p)) : [] - var_set = Set(vars) - - D = Differential(t) - mm = prob.f.mass_matrix - - if mm === I - lhs = map(v->D(v), vars) - else - lhs = map(mm * vars) do v - if iszero(v) - 0 - elseif v in var_set - D(v) - else - error("Non-permuation mass matrix is not supported.") - end - end - end - - if DiffEqBase.isinplace(prob) - rhs = similar(vars, Num) - prob.f(rhs, vars, params, t) - else - rhs = prob.f(vars, params, t) - end - - eqs = vcat([lhs[i] ~ rhs[i] for i in eachindex(prob.u0)]...) - - sts = vec(collect(vars)) - params = if ndims(params) == 0 - [params[1]] - else - vec(collect(params)) - end - default_u0 = Dict(sts .=> vec(collect(prob.u0))) - default_p = has_p ? Dict(params .=> vec(collect(prob.p))) : Dict() - - de = ODESystem( - eqs, t, sts, params, - defaults=merge(default_u0, default_p), - ) - - de -end - - - -""" -$(TYPEDSIGNATURES) - -Generate `SDESystem`, dependent variables, and parameters from an `SDEProblem`. -""" -function modelingtoolkitize(prob::DiffEqBase.SDEProblem) - prob.f isa DiffEqBase.AbstractParameterizedFunction && - return (prob.f.sys, prob.f.sys.states, prob.f.sys.ps) - @parameters t - if prob.p isa Tuple || prob.p isa NamedTuple - p = [x for x in prob.p] - else - p = prob.p - end - var(x, i) = Num(Sym{FnType{Tuple{symtype(t)}, Real}}(nameof(Variable(x, i)))) - vars = ArrayInterface.restructure(prob.u0,[var(:x, i)(ModelingToolkit.value(t)) for i in eachindex(prob.u0)]) - params = p isa DiffEqBase.NullParameters ? [] : - reshape([Num(Sym{Real}(nameof(Variable(:α, i)))) for i in eachindex(p)],size(p)) - - D = Differential(t) - - rhs = [D(var) for var in vars] - - if DiffEqBase.isinplace(prob) - lhs = similar(vars, Any) - - prob.f(lhs, vars, params, t) - - if DiffEqBase.is_diagonal_noise(prob) - neqs = similar(vars, Any) - prob.g(neqs, vars, params, t) - else - neqs = similar(vars, Any, size(prob.noise_rate_prototype)) - prob.g(neqs, vars, params, t) - end - else - lhs = prob.f(vars, params, t) - if DiffEqBase.is_diagonal_noise(prob) - neqs = prob.g(vars, params, t) - else - neqs = prob.g(vars, params, t) - end - end - deqs = vcat([rhs[i] ~ lhs[i] for i in eachindex(prob.u0)]...) - - params = if ndims(params) == 0 - [params[1]] - else - Vector(vec(params)) - end - - de = SDESystem(deqs,neqs,t,Vector(vec(vars)),params) - - de -end - - -""" -$(TYPEDSIGNATURES) - -Generate `OptimizationSystem`, dependent variables, and parameters from an `OptimizationProblem`. -""" -function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem) - - if prob.p isa Tuple || prob.p isa NamedTuple - p = [x for x in prob.p] - else - p = prob.p - end - - vars = reshape([Num(Sym{Real}(nameof(Variable(:x, i)))) for i in eachindex(prob.u0)],size(prob.u0)) - params = p isa DiffEqBase.NullParameters ? [] : - reshape([Num(Sym{Real}(nameof(Variable(:α, i)))) for i in eachindex(p)],size(Array(p))) - - eqs = prob.f(vars, params) - de = OptimizationSystem(eqs,vec(vars),vec(params)) - de -end +""" +$(TYPEDSIGNATURES) + +Generate `ODESystem`, dependent variables, and parameters from an `ODEProblem`. +""" +function modelingtoolkitize( + prob::DiffEqBase.ODEProblem; u_names = nothing, p_names = nothing, kwargs...) + prob.f isa DiffEqBase.AbstractParameterizedFunction && + return prob.f.sys + t = t_nounits + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + if u_names !== nothing + varnames_length_check(prob.u0, u_names; is_unknowns = true) + _vars = [_defvar(name)(t) for name in u_names] + elseif SciMLBase.has_sys(prob.f) + varnames = getname.(variable_symbols(prob.f.sys)) + varidxs = variable_index.((prob.f.sys,), varnames) + invpermute!(varnames, varidxs) + _vars = [_defvar(name)(t) for name in varnames] + else + _vars = define_vars(prob.u0, t) + end + + vars = prob.u0 isa Number ? _vars : ArrayInterface.restructure(prob.u0, _vars) + params = if has_p + if p_names === nothing && SciMLBase.has_sys(prob.f) + p_names = Dict(parameter_index(prob.f.sys, sym) => sym + for sym in parameter_symbols(prob.f.sys)) + end + _params = define_params(p, p_names) + p isa Number ? _params[1] : + (p isa Tuple || p isa NamedTuple || p isa AbstractDict || p isa MTKParameters ? + _params : + ArrayInterface.restructure(p, _params)) + else + [] + end + + var_set = Set(vars) + + D = D_nounits + mm = prob.f.mass_matrix + + if mm === I + lhs = map(v -> D(v), vars) + else + lhs = map(mm * vars) do v + if iszero(v) + 0 + elseif v in var_set + D(v) + else + error("Non-permutation mass matrix is not supported.") + end + end + end + + if DiffEqBase.isinplace(prob) + rhs = ArrayInterface.restructure(prob.u0, similar(vars, Num)) + fill!(rhs, 0) + if prob.f isa ODEFunction && + prob.f.f isa FunctionWrappersWrappers.FunctionWrappersWrapper + prob.f.f.fw[1].obj[](rhs, vars, params, t) + else + prob.f(rhs, vars, params, t) + end + else + rhs = prob.f(vars, params, t) + end + + eqs = vcat([lhs[i] ~ rhs[i] for i in eachindex(prob.u0)]...) + + sts = vec(collect(vars)) + + _params = params + params = values(params) + params = if params isa Number || (params isa Array && ndims(params) == 0) + [params[1]] + else + vec(collect(params)) + end + default_u0 = Dict(sts .=> vec(collect(prob.u0))) + default_p = if has_p + if prob.p isa AbstractDict + Dict(v => prob.p[k] for (k, v) in pairs(_params)) + elseif prob.p isa MTKParameters + Dict(params .=> reduce(vcat, prob.p)) + else + Dict(params .=> vec(collect(prob.p))) + end + else + Dict() + end + filter!(x -> !iscall(x) || !(operation(x) isa Initial), params) + filter!(x -> !iscall(x[1]) || !(operation(x[1]) isa Initial), default_p) + de = ODESystem(eqs, t, sts, params, + defaults = merge(default_u0, default_p); + name = gensym(:MTKizedODE), + tspan = prob.tspan, + kwargs...) + + de +end + +_defvaridx(x, i) = variable(x, i, T = SymbolicUtils.FnType{Tuple, Real}) +_defvar(x) = variable(x, T = SymbolicUtils.FnType{Tuple, Real}) + +function define_vars(u, t) + [_defvaridx(:x, i)(t) for i in eachindex(u)] +end + +function define_vars(u::NTuple{<:Number}, t) + tuple((_defvaridx(:x, i)(ModelingToolkit.value(t)) for i in eachindex(u))...) +end + +function define_vars(u::NamedTuple, t) + NamedTuple(x => _defvar(x)(ModelingToolkit.value(t)) for x in keys(u)) +end + +const PARAMETERS_NOT_SUPPORTED_MESSAGE = """ + The chosen parameter type is currently not supported by `modelingtoolkitize`. The + current supported types are: + + - AbstractArrays + - AbstractDicts + - LabelledArrays (SLArray, LArray) + - Flat tuples (tuples of numbers) + - Flat named tuples (namedtuples of numbers) + """ + +struct ModelingtoolkitizeParametersNotSupportedError <: Exception + type::Any +end + +function Base.showerror(io::IO, e::ModelingtoolkitizeParametersNotSupportedError) + println(io, PARAMETERS_NOT_SUPPORTED_MESSAGE) + print(io, "Parameter type: ") + println(io, e.type) +end + +function varnames_length_check(vars, names; is_unknowns = false) + if length(names) != length(vars) + throw(ArgumentError(""" + Number of $(is_unknowns ? "unknowns" : "parameters") ($(length(vars))) \ + does not match number of names ($(length(names))). + """)) + end +end + +function define_params(p, _ = nothing) + throw(ModelingtoolkitizeParametersNotSupportedError(typeof(p))) +end + +function define_params(p::AbstractArray, names = nothing) + if names === nothing + [toparam(variable(:α, i)) for i in eachindex(p)] + else + varnames_length_check(p, names) + [toparam(variable(names[i])) for i in eachindex(p)] + end +end + +function define_params(p::Number, names = nothing) + if names === nothing + [toparam(variable(:α))] + elseif names isa Union{AbstractArray, AbstractDict} + varnames_length_check(p, names) + [toparam(variable(names[i])) for i in eachindex(p)] + else + [toparam(variable(names))] + end +end + +function define_params(p::AbstractDict, names = nothing) + if names === nothing + OrderedDict(k => toparam(variable(:α, i)) for (i, k) in zip(1:length(p), keys(p))) + else + varnames_length_check(p, names) + OrderedDict(k => toparam(variable(names[k])) for k in keys(p)) + end +end + +function define_params(p::Tuple, names = nothing) + if names === nothing + tuple((toparam(variable(:α, i)) for i in eachindex(p))...) + else + varnames_length_check(p, names) + tuple((toparam(variable(names[i])) for i in eachindex(p))...) + end +end + +function define_params(p::NamedTuple, names = nothing) + if names === nothing + NamedTuple(x => toparam(variable(x)) for x in keys(p)) + else + varnames_length_check(p, names) + NamedTuple(x => toparam(variable(names[x])) for x in keys(p)) + end +end + +function define_params(p::MTKParameters, names = nothing) + if names === nothing + bufs = (p...,) + i = 1 + ps = [] + for buf in bufs + for _ in buf + push!( + ps, + toparam(variable(:α, i)) + ) + end + end + return identity.(ps) + else + new_p = as_any_buffer(p) + for (k, v) in names + new_p[k] = v + end + return reduce(vcat, new_p; init = []) + end +end + +""" +$(TYPEDSIGNATURES) + +Generate `SDESystem`, dependent variables, and parameters from an `SDEProblem`. +""" +function modelingtoolkitize(prob::DiffEqBase.SDEProblem; kwargs...) + prob.f isa DiffEqBase.AbstractParameterizedFunction && + return (prob.f.sys, prob.f.sys.unknowns, prob.f.sys.ps) + @independent_variables t + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + _vars = define_vars(prob.u0, t) + + vars = prob.u0 isa Number ? _vars : ArrayInterface.restructure(prob.u0, _vars) + params = if has_p + _params = define_params(p) + p isa MTKParameters ? _params : + p isa Number ? _params[1] : + (p isa Tuple || p isa NamedTuple ? _params : + ArrayInterface.restructure(p, _params)) + else + [] + end + + D = Differential(t) + + rhs = [D(var) for var in vars] + + if DiffEqBase.isinplace(prob) + lhs = similar(vars, Any) + + prob.f(lhs, vars, params, t) + + if DiffEqBase.is_diagonal_noise(prob) + neqs = similar(vars, Any) + prob.g(neqs, vars, params, t) + else + neqs = similar(vars, Any, size(prob.noise_rate_prototype)) + prob.g(neqs, vars, params, t) + end + else + lhs = prob.f(vars, params, t) + if DiffEqBase.is_diagonal_noise(prob) + neqs = prob.g(vars, params, t) + else + neqs = prob.g(vars, params, t) + end + end + deqs = vcat([rhs[i] ~ lhs[i] for i in eachindex(prob.u0)]...) + + params = if ndims(params) == 0 + [params[1]] + else + Vector(vec(params)) + end + sts = Vector(vec(vars)) + default_u0 = Dict(sts .=> vec(collect(prob.u0))) + default_p = if has_p + if prob.p isa AbstractDict + Dict(v => prob.p[k] for (k, v) in pairs(_params)) + elseif prob.p isa MTKParameters + Dict(params .=> reduce(vcat, prob.p)) + else + Dict(params .=> vec(collect(prob.p))) + end + else + Dict() + end + + de = SDESystem(deqs, neqs, t, sts, params; + name = gensym(:MTKizedSDE), + tspan = prob.tspan, + defaults = merge(default_u0, default_p), + kwargs...) + + de +end diff --git a/src/systems/diffeqs/odesystem.jl b/src/systems/diffeqs/odesystem.jl index 913041e41e..cbce569a9b 100644 --- a/src/systems/diffeqs/odesystem.jl +++ b/src/systems/diffeqs/odesystem.jl @@ -10,28 +10,51 @@ $(FIELDS) ```julia using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters t σ ρ β +@parameters σ ρ β @variables x(t) y(t) z(t) -D = Differential(t) eqs = [D(x) ~ σ*(y-x), D(y) ~ x*(ρ-z)-y, D(z) ~ x*y - β*z] -de = ODESystem(eqs,t,[x,y,z],[σ,ρ,β]) +@named de = ODESystem(eqs,t,[x,y,z],[σ,ρ,β],tspan=(0, 1000.0)) ``` """ struct ODESystem <: AbstractODESystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt """The ODEs defining the system.""" eqs::Vector{Equation} """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Parameter variables.""" + iv::BasicSymbolic{Real} + """ + Dependent (unknown) variables. Must not contain the independent variable. + + N.B.: If `torn_matching !== nothing`, this includes all variables. Actual + ODE unknowns are determined by the `SelectedState()` entries in `torn_matching`. + """ + unknowns::Vector + """Parameter variables. Must not contain the independent variable.""" ps::Vector + """Time span.""" + tspan::Union{NTuple{2, Any}, Nothing} + """Array variables.""" + var_to_name::Any + """Control parameters (some subset of `ps`).""" + ctrls::Vector + """Observed variables.""" observed::Vector{Equation} + """System of constraints that must be satisfied by the solution to the system.""" + constraintsystem::Union{Nothing, ConstraintsSystem} + """A set of expressions defining the costs of the system for optimal control.""" + costs::Vector + """Takes the cost vector and returns a scalar for optimization.""" + consolidate::Union{Nothing, Function} """ Time-derivative matrix. Note: this field will not be defined until [`calculate_tgrad`](@ref) is called on the system. @@ -43,264 +66,600 @@ struct ODESystem <: AbstractODESystem """ jac::RefValue{Any} """ - `Wfact` matrix. Note: this field will not be defined until + Control Jacobian matrix. Note: this field will not be defined until + [`calculate_control_jacobian`](@ref) is called on the system. + """ + ctrl_jac::RefValue{Any} + """ + Note: this field will not be defined until [`generate_factorized_W`](@ref) is called on the system. """ Wfact::RefValue{Matrix{Num}} """ - `Wfact_t` matrix. Note: this field will not be defined until + Note: this field will not be defined until [`generate_factorized_W`](@ref) is called on the system. """ Wfact_t::RefValue{Matrix{Num}} """ - Name: the name of the system + The name of the system. """ name::Symbol """ - systems: The internal systems. These are required to have unique names. + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. """ systems::Vector{ODESystem} """ - defaults: The default values to use when initial conditions and/or + The default values to use when initial conditions and/or parameters are not supplied in `ODEProblem`. """ defaults::Dict """ - structure: structural information of the system + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + Tearing result specifying how to solve the system. + """ + torn_matching::Union{Matching, Nothing} + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + The schedule for the code generation process. + """ + schedule::Any + """ + Type of the system. + """ + connector_type::Any + """ + Inject assignment statements before the evaluation of the RHS function. + """ + preface::Any + """ + A `Vector{SymbolicContinuousCallback}` that model events. + The integrator will use root finding to guarantee that it steps at each zero crossing. + """ + continuous_events::Vector{SymbolicContinuousCallback} + """ + A `Vector{SymbolicDiscreteCallback}` that models events. Symbolic + analog to `SciMLBase.DiscreteCallback` that executes an affect when a given condition is + true at the end of an integration step. + """ + discrete_events::Vector{SymbolicDiscreteCallback} + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Mapping of conditions which should be true throughout the solution process to corresponding error + messages. These will be added to the equations when calling `debug_system`. + """ + assertions::Dict{BasicSymbolic, String} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + A boolean indicating if the given `ODESystem` represents a system of DDEs. + """ + is_dde::Bool + """ + A list of points to provide to the solver as tstops. Uses the same syntax as discrete + events. + """ + tstops::Vector{Any} + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ - structure::Any + complete::Bool """ - type: type of the system + Cached data for fast symbolic indexing. """ - connection_type::Any + index_cache::Union{Nothing, IndexCache} + """ + A list of discrete subsystems. + """ + discrete_subsystems::Any + """ + A list of actual unknowns needed to be solved by solvers. + """ + solved_unknowns::Union{Nothing, Vector{Any}} + """ + A vector of vectors of indices for the split parameters. + """ + split_idxs::Union{Nothing, Vector{Vector{Int}}} + """ + The analysis points removed by transformations, representing connections to be + ignored. The first element of the tuple analysis points connecting systems and + the second are ones connecting variables (for the trivial form of `connect`). + """ + ignored_connections::Union{ + Nothing, Tuple{Vector{IgnoredAnalysisPoint}, Vector{IgnoredAnalysisPoint}}} + """ + The hierarchical parent system before simplification. + """ + parent::Any + + function ODESystem( + tag, deqs, iv, dvs, ps, tspan, var_to_name, ctrls, + observed, constraints, costs, consolidate, tgrad, + jac, ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, + torn_matching, initializesystem, initialization_eqs, schedule, + connector_type, preface, cevents, + devents, parameter_dependencies, assertions = Dict{BasicSymbolic, String}(), + metadata = nothing, gui_metadata = nothing, is_dde = false, + tstops = [], tearing_state = nothing, substitutions = nothing, + namespacing = true, complete = false, index_cache = nothing, + discrete_subsystems = nothing, solved_unknowns = nothing, + split_idxs = nothing, ignored_connections = nothing, parent = nothing; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(dvs, iv) + check_parameters(ps, iv) + check_equations(deqs, iv) + check_equations(equations(cevents), iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ps, iv) + check_units(u, deqs) + end + new(tag, deqs, iv, dvs, ps, tspan, var_to_name, + ctrls, observed, constraints, costs, consolidate, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, torn_matching, + initializesystem, initialization_eqs, schedule, connector_type, preface, + cevents, devents, parameter_dependencies, assertions, metadata, + gui_metadata, is_dde, tstops, tearing_state, substitutions, namespacing, + complete, index_cache, + discrete_subsystems, solved_unknowns, split_idxs, ignored_connections, parent) + end end -function ODESystem( - deqs::AbstractVector{<:Equation}, iv, dvs, ps; - observed = Num[], - systems = ODESystem[], - name=gensym(:ODESystem), - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - connection_type=nothing, - ) +function ODESystem(deqs::AbstractVector{<:Equation}, iv, dvs, ps; + controls = Num[], + observed = Equation[], + constraintsystem = nothing, + costs = Num[], + consolidate = nothing, + systems = ODESystem[], + tspan = nothing, + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + schedule = nothing, + connector_type = nothing, + preface = nothing, + continuous_events = nothing, + discrete_events = nothing, + parameter_dependencies = Equation[], + assertions = Dict(), + checks = true, + metadata = nothing, + gui_metadata = nothing, + is_dde = nothing, + tstops = [], + discover_from_metadata = true) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + @assert all(control -> any(isequal.(control, ps)), controls) "All controls must also be parameters." iv′ = value(iv) - dvs′ = value.(dvs) ps′ = value.(ps) - + ctrl′ = value.(controls) + dvs′ = value.(dvs) + dvs′ = filter(x -> !isdelay(x, iv), dvs′) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :ODESystem, force=true) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :ODESystem, force = true) end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - - tgrad = RefValue(Vector{Num}(undef, 0)) - jac = RefValue{Any}(Matrix{Num}(undef, 0, 0)) - Wfact = RefValue(Matrix{Num}(undef, 0, 0)) - Wfact_t = RefValue(Matrix{Num}(undef, 0, 0)) + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + let defaults = discover_from_metadata ? defaults : Dict(), + guesses = discover_from_metadata ? guesses : Dict() + + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + end + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + tgrad = RefValue(EMPTY_TGRAD) + jac = RefValue{Any}(EMPTY_JAC) + ctrl_jac = RefValue{Any}(EMPTY_JAC) + Wfact = RefValue(EMPTY_JAC) + Wfact_t = RefValue(EMPTY_JAC) sysnames = nameof.(systems) if length(unique(sysnames)) != length(sysnames) throw(ArgumentError("System names must be unique.")) end - ODESystem(deqs, iv′, dvs′, ps′, observed, tgrad, jac, Wfact, Wfact_t, name, systems, defaults, nothing, connection_type) -end - -iv_from_nested_derivative(x::Term) = operation(x) isa Differential ? iv_from_nested_derivative(arguments(x)[1]) : arguments(x)[1] -iv_from_nested_derivative(x::Sym) = x -iv_from_nested_derivative(x) = missing + cont_callbacks = SymbolicContinuousCallbacks(continuous_events) + disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) -vars(x::Sym) = Set([x]) -vars(exprs::Symbolic) = vars([exprs]) -vars(exprs) = foldl(vars!, exprs; init = Set()) -vars!(vars, eq::Equation) = (vars!(vars, eq.lhs); vars!(vars, eq.rhs); vars) -function vars!(vars, O) - isa(O, Sym) && return push!(vars, O) - !istree(O) && return vars + if is_dde === nothing + is_dde = _check_if_dde(deqs, iv′, systems) + end - operation(O) isa Differential && return push!(vars, O) + if !isempty(systems) && !isnothing(constraintsystem) + conssystems = ConstraintsSystem[] + for sys in systems + cons = get_constraintsystem(sys) + cons !== nothing && push!(conssystems, cons) + end + @set! constraintsystem.systems = conssystems + end + costs = wrap.(costs) - operation(O) isa Sym && push!(vars, O) - for arg in arguments(O) - vars!(vars, arg) + if length(costs) > 1 && isnothing(consolidate) + error("Must specify a consolidation function for the costs vector.") end - return vars + assertions = Dict{BasicSymbolic, Any}(unwrap(k) => v for (k, v) in assertions) + + ODESystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + deqs, iv′, dvs′, ps′, tspan, var_to_name, ctrl′, observed, + constraintsystem, costs, consolidate, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, + defaults, guesses, nothing, initializesystem, + initialization_eqs, schedule, connector_type, preface, cont_callbacks, + disc_callbacks, parameter_dependencies, assertions, + metadata, gui_metadata, is_dde, tstops, checks = checks) end -find_derivatives!(vars, expr::Equation, f=identity) = (find_derivatives!(vars, expr.lhs, f); find_derivatives!(vars, expr.rhs, f); vars) -function find_derivatives!(vars, expr, f) - !istree(O) && return vars - operation(O) isa Differential && push!(vars, f(O)) - for arg in arguments(O) - vars!(vars, arg) +function ODESystem(eqs, iv; constraints = Equation[], costs = Num[], kwargs...) + diffvars, allunknowns, ps, eqs = process_equations(eqs, iv) + + for eq in get(kwargs, :parameter_dependencies, Equation[]) + collect_vars!(allunknowns, ps, eq, iv) + end + + for ssys in get(kwargs, :systems, ODESystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, iv) end - return vars -end -function ODESystem(eqs, iv=nothing; kwargs...) - # NOTE: this assumes that the order of algebric equations doesn't matter - diffvars = OrderedSet() - allstates = OrderedSet() - ps = OrderedSet() - # reorder equations such that it is in the form of `diffeq, algeeq` - diffeq = Equation[] - algeeq = Equation[] - # initial loop for finding `iv` - if iv === nothing - for eq in eqs - if !(eq.lhs isa Number) # assume eq.lhs is either Differential or Number - iv = iv_from_nested_derivative(eq.lhs) - break + for v in allunknowns + isdelay(v, iv) || continue + collect_vars!(allunknowns, ps, arguments(v)[1], iv) + end + + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) end + else + push!(new_ps, p) end end - iv = value(iv) - iv === nothing && throw(ArgumentError("Please pass in independent variables.")) - for eq in eqs - collect_vars!(allstates, ps, eq.lhs, iv) - collect_vars!(allstates, ps, eq.rhs, iv) - if isdiffeq(eq) - diffvar, _ = var_from_nested_derivative(eq.lhs) - isequal(iv, iv_from_nested_derivative(eq.lhs)) || throw(ArgumentError("An ODESystem can only have one independent variable.")) - diffvar in diffvars && throw(ArgumentError("The differential variable $diffvar is not unique in the system of equations.")) - push!(diffvars, diffvar) - push!(diffeq, eq) - else - push!(algeeq, eq) + algevars = setdiff(allunknowns, diffvars) + + consvars = OrderedSet() + constraintsystem = nothing + if !isempty(constraints) + constraintsystem = process_constraint_system(constraints, allunknowns, new_ps, iv) + for st in get_unknowns(constraintsystem) + iscall(st) ? + !in(operation(st)(iv), allunknowns) && push!(consvars, st) : + !in(st, allunknowns) && push!(consvars, st) + end + for p in parameters(constraintsystem) + !in(p, new_ps) && push!(new_ps, p) end end - algevars = setdiff(allstates, diffvars) - # the orders here are very important! - return ODESystem(append!(diffeq, algeeq), iv, vcat(collect(diffvars), collect(algevars)), ps; kwargs...) -end -function collect_vars!(states, parameters, expr, iv) - if expr isa Sym - collect_var!(states, parameters, expr, iv) - else - for var in vars(expr) - if istree(var) && operation(var) isa Differential - var, _ = var_from_nested_derivative(var) - end - collect_var!(states, parameters, var, iv) + if !isempty(costs) + coststs, costps = process_costs(costs, allunknowns, new_ps, iv) + for p in costps + !in(p, new_ps) && push!(new_ps, p) end end - return nothing -end + costs = wrap.(costs) -function collect_var!(states, parameters, var, iv) - isequal(var, iv) && return nothing - if isparameter(var) || (istree(var) && isparameter(operation(var))) - push!(parameters, var) - else - push!(states, var) - end - return nothing + return ODESystem(eqs, iv, collect(Iterators.flatten((diffvars, algevars, consvars))), + collect(new_ps); constraintsystem, costs, kwargs...) end # NOTE: equality does not check cached Jacobian function Base.:(==)(sys1::ODESystem, sys2::ODESystem) - iv1 = independent_variable(sys1) - iv2 = independent_variable(sys2) + sys1 === sys2 && return true + iv1 = get_iv(sys1) + iv2 = get_iv(sys2) isequal(iv1, iv2) && - _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && - _eq_unordered(get_states(sys1), get_states(sys2)) && - _eq_unordered(get_ps(sys1), get_ps(sys2)) && - all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) + isequal(nameof(sys1), nameof(sys2)) && + _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && + _eq_unordered(get_unknowns(sys1), get_unknowns(sys2)) && + _eq_unordered(get_ps(sys1), get_ps(sys2)) && + _eq_unordered(continuous_events(sys1), continuous_events(sys2)) && + _eq_unordered(discrete_events(sys1), discrete_events(sys2)) && + all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) && + isequal(get_constraintsystem(sys1), get_constraintsystem(sys2)) && + _eq_unordered(get_costs(sys1), get_costs(sys2)) end -function flatten(sys::ODESystem) +function flatten(sys::ODESystem, noeqs = false) systems = get_systems(sys) if isempty(systems) return sys else - return ODESystem( - equations(sys), - independent_variable(sys), - states(sys), - parameters(sys), - observed=observed(sys), - defaults=defaults(sys), - name=nameof(sys), - ) + return ODESystem(noeqs ? Equation[] : equations(sys), + get_iv(sys), + unknowns(sys), + parameters(sys; initial_parameters = true), + parameter_dependencies = parameter_dependencies(sys), + guesses = guesses(sys), + observed = observed(sys), + continuous_events = continuous_events(sys), + discrete_events = discrete_events(sys), + defaults = defaults(sys), + name = nameof(sys), + description = description(sys), + initialization_eqs = initialization_equations(sys), + assertions = assertions(sys), + is_dde = is_dde(sys), + tstops = symbolic_tstops(sys), + metadata = get_metadata(sys), + checks = false, + # without this, any defaults/guesses obtained from metadata that were + # later removed by the user will be re-added. Right now, we just want to + # retain `defaults(sys)` as-is. + discover_from_metadata = false) end end ODESystem(eq::Equation, args...; kwargs...) = ODESystem([eq], args...; kwargs...) """ -$(SIGNATURES) - -Build the observed function assuming the observed equations are all explicit, -i.e. there are no cycles. + build_explicit_observed_function(sys, ts; kwargs...) -> Function(s) + +Generates a function that computes the observed value(s) `ts` in the system `sys`, while making the assumption that there are no cycles in the equations. + +## Arguments +- `sys`: The system for which to generate the function +- `ts`: The symbolic observed values whose value should be computed + +## Keywords +- `return_inplace = false`: If true and the observed value is a vector, then return both the in place and out of place methods. +- `expression = false`: Generates a Julia `Expr`` computing the observed value if `expression` is true +- `eval_expression = false`: If true and `expression = false`, evaluates the returned function in the module `eval_module` +- `output_type = Array` the type of the array generated by a out-of-place vector-valued function +- `param_only = false` if true, only allow the generated function to access system parameters +- `inputs = nothing` additinoal symbolic variables that should be provided to the generated function +- `checkbounds = true` checks bounds if true when destructuring parameters +- `op = Operator` sets the recursion terminator for the walk done by `vars` to identify the variables that appear in `ts`. See the documentation for `vars` for more detail. +- `throw = true` if true, throw an error when generating a function for `ts` that reference variables that do not exist. +- `mkarray`: only used if the output is an array (that is, `!isscalar(ts)` and `ts` is not a tuple, in which case the result will always be a tuple). Called as `mkarray(ts, output_type)` where `ts` are the expressions to put in the array and `output_type` is the argument of the same name passed to build_explicit_observed_function. +- `cse = true`: Whether to use Common Subexpression Elimination (CSE) to generate a more efficient function. + +## Returns + +The return value will be either: +* a single function `f_oop` if the input is a scalar or if the input is a Vector but `return_inplace` is false +* the out of place and in-place functions `(f_ip, f_oop)` if `return_inplace` is true and the input is a `Vector` + +The function(s) `f_oop` (and potentially `f_ip`) will be: +* `RuntimeGeneratedFunction`s by default, +* A Julia `Expr` if `expression` is true, +* A directly evaluated Julia function in the module `eval_module` if `eval_expression` is true and `expression` is false. + +The signatures will be of the form `g(...)` with arguments: + +- `output` for in-place functions +- `unknowns` if `param_only` is `false` +- `inputs` if `inputs` is an array of symbolic inputs that should be available in `ts` +- `p...` unconditionally; note that in the case of `MTKParameters` more than one parameters argument may be present, so it must be splatted +- `t` if the system is time-dependent; for example `NonlinearSystem` will not have `t` + +For example, a function `g(op, unknowns, p..., inputs, t)` will be the in-place function generated if `return_inplace` is true, `ts` is a vector, +an array of inputs `inputs` is given, and `param_only` is false for a time-dependent system. """ -function build_explicit_observed_function( - sys, syms; - expression=false, - output_type=Array, - checkbounds=true) - - if (isscalar = !(syms isa Vector)) - syms = [syms] - end - syms = value.(syms) - - obs = observed(sys) - observed_idx = Dict(map(x->x.lhs, obs) .=> 1:length(obs)) - output = similar(syms, Any) - # FIXME: this is a rather rough estimate of dependencies. - maxidx = 0 - for (i, s) in enumerate(syms) - idx = observed_idx[s] - idx > maxidx && (maxidx = idx) - output[i] = obs[idx].rhs - end - - dvs = DestructuredArgs(states(sys), inbounds=!checkbounds) - ps = DestructuredArgs(parameters(sys), inbounds=!checkbounds) - iv = independent_variable(sys) - args = iv === nothing ? [dvs, ps] : [dvs, ps, iv] - - ex = Func( - args, [], - Let( - map(eq -> eq.lhs←eq.rhs, obs[1:maxidx]), - isscalar ? output[1] : MakeArray(output, output_type) - ) - ) |> toexpr - - expression ? ex : @RuntimeGeneratedFunction(ex) +function build_explicit_observed_function(sys, ts; + inputs = nothing, + disturbance_inputs = nothing, + disturbance_argument = false, + expression = false, + eval_expression = false, + eval_module = @__MODULE__, + output_type = Array, + checkbounds = true, + ps = parameters(sys; initial_parameters = true), + return_inplace = false, + param_only = false, + op = Operator, + throw = true, + cse = true, + mkarray = nothing) + is_tuple = ts isa Tuple + if is_tuple + ts = collect(ts) + output_type = Tuple + end + + allsyms = all_symbols(sys) + if symbolic_type(ts) == NotSymbolic() && ts isa AbstractArray + ts = map(x -> symbol_to_symbolic(sys, x; allsyms), ts) + else + ts = symbol_to_symbolic(sys, ts; allsyms) + end + + vs = ModelingToolkit.vars(ts; op) + namespace_subs = Dict() + ns_map = Dict{Any, Any}(renamespace(sys, eq.lhs) => eq.lhs for eq in observed(sys)) + for sym in unknowns(sys) + ns_map[renamespace(sys, sym)] = sym + if iscall(sym) && operation(sym) === getindex + ns_map[renamespace(sys, arguments(sym)[1])] = arguments(sym)[1] + end + end + for sym in full_parameters(sys) + ns_map[renamespace(sys, sym)] = sym + if iscall(sym) && operation(sym) === getindex + ns_map[renamespace(sys, arguments(sym)[1])] = arguments(sym)[1] + end + end + allsyms = Set(all_symbols(sys)) + iv = has_iv(sys) ? get_iv(sys) : nothing + for var in vs + var = unwrap(var) + newvar = get(ns_map, var, nothing) + if newvar !== nothing + namespace_subs[var] = newvar + var = newvar + end + if throw && !var_in_varlist(var, allsyms, iv) + Base.throw(ArgumentError("Symbol $var is not present in the system.")) + end + end + ts = fast_substitute(ts, namespace_subs) + + obsfilter = if param_only + if is_split(sys) + let ic = get_index_cache(sys) + eq -> !(ContinuousTimeseries() in ic.observed_syms_to_timeseries[eq.lhs]) + end + else + Returns(false) + end + else + Returns(true) + end + dvs = if param_only + () + else + (unknowns(sys),) + end + if inputs === nothing + inputs = () + else + ps = setdiff(ps, inputs) # Inputs have been converted to parameters by io_preprocessing, remove those from the parameter list + inputs = (inputs,) + end + if disturbance_inputs !== nothing + # Disturbance inputs may or may not be included as inputs, depending on disturbance_argument + ps = setdiff(ps, disturbance_inputs) + end + if disturbance_argument + disturbance_inputs = (disturbance_inputs,) + else + disturbance_inputs = () + end + ps = reorder_parameters(sys, ps) + iv = if is_time_dependent(sys) + (get_iv(sys),) + else + () + end + args = (dvs..., inputs..., ps..., iv..., disturbance_inputs...) + p_start = length(dvs) + length(inputs) + 1 + p_end = length(dvs) + length(inputs) + length(ps) + fns = build_function_wrapper( + sys, ts, args...; p_start, p_end, filter_observed = obsfilter, + output_type, mkarray, try_namespaced = true, expression = Val{true}, cse) + if fns isa Tuple + if expression + return return_inplace ? fns : fns[1] + end + oop, iip = eval_or_rgf.(fns; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + p_start + is_dde(sys), length(args) - length(ps) + 1 + is_dde(sys), is_split(sys))}( + oop, iip) + return return_inplace ? (f, f) : f + else + if expression + return fns + end + f = eval_or_rgf(fns; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + p_start + is_dde(sys), length(args) - length(ps) + 1 + is_dde(sys), is_split(sys))}( + f, nothing) + return f + end +end + +function populate_delays(delays::Set, obsexprs, histfn, sys, sym) + _vars_util = vars(sym) + for v in _vars_util + v in delays && continue + iscall(v) && issym(operation(v)) && (args = arguments(v); length(args) == 1) && + iscall(only(args)) || continue + + idx = variable_index(sys, operation(v)(get_iv(sys))) + idx === nothing && error("Delay term $v is not an unknown in the system") + push!(delays, v) + push!(obsexprs, v ← histfn(only(args))[idx]) + end end function _eq_unordered(a, b) + # a and b may be multidimensional + # e.g. comparing noiseeqs of SDESystem + a = vec(a) + b = vec(b) length(a) === length(b) || return false n = length(a) idxs = Set(1:n) - for x ∈ a + for x in a idx = findfirst(isequal(x), b) + # loop since there might be multiple identical entries in a/b + # and while we might have already matched the first there could + # be a second that is equal to x + while idx !== nothing && !(idx in idxs) + idx = findnext(isequal(x), b, idx + 1) + end idx === nothing && return false - idx ∈ idxs || return false delete!(idxs, idx) end return true end -function collect_differential_variables(sys::ODESystem) - eqs = equations(sys) - vars = Set() - diffvars = Set() - for eq in eqs - vars!(vars, eq) - for v in vars - isdifferential(v) || continue - push!(diffvars, arguments(v)[1]) - end - empty!(vars) - end - return diffvars -end - # We have a stand-alone function to convert a `NonlinearSystem` or `ODESystem` # to an `ODESystem` to connect systems, and we later can reply on # `structural_simplify` to convert `ODESystem`s to `NonlinearSystem`s. @@ -310,32 +669,190 @@ $(TYPEDSIGNATURES) Convert a `NonlinearSystem` to an `ODESystem` or converts an `ODESystem` to a new `ODESystem` with a different independent variable. """ -function convert_system(::Type{<:ODESystem}, sys, t; name=nameof(sys)) - isempty(observed(sys)) || throw(ArgumentError("`convert_system` cannot handle reduced model (i.e. observed(sys) is non-empty).")) +function convert_system(::Type{<:ODESystem}, sys, t; name = nameof(sys)) + isempty(observed(sys)) || + throw(ArgumentError("`convert_system` cannot handle reduced model (i.e. observed(sys) is non-empty).")) t = value(t) varmap = Dict() - sts = states(sys) + sts = unknowns(sys) newsts = similar(sts, Any) for (i, s) in enumerate(sts) - if istree(s) + if iscall(s) args = arguments(s) - length(args) == 1 || throw(InvalidSystemException("Illegal state: $s. The state can have at most one argument like `x(t)`.")) + length(args) == 1 || + throw(InvalidSystemException("Illegal unknown: $s. The unknown can have at most one argument like `x(t)`.")) arg = args[1] if isequal(arg, t) newsts[i] = s continue end - ns = operation(s)(t) + ns = maketerm(typeof(s), operation(s), Any[t], + SymbolicUtils.metadata(s)) newsts[i] = ns varmap[s] = ns else - ns = indepvar2depvar(s, t) + ns = variable(getname(s); T = FnType)(t) newsts[i] = ns varmap[s] = ns end end sub = Base.Fix2(substitute, varmap) + if sys isa AbstractODESystem + iv = only(independent_variables(sys)) + sub.x[iv] = t # otherwise the Differentials aren't fixed + end neweqs = map(sub, equations(sys)) defs = Dict(sub(k) => sub(v) for (k, v) in defaults(sys)) - return ODESystem(neweqs, t, newsts, parameters(sys); defaults=defs, name=name) + return ODESystem(neweqs, t, newsts, parameters(sys); defaults = defs, name = name, + checks = false) +end + +""" +$(SIGNATURES) + +Add accumulation variables for `vars`. +""" +function add_accumulations(sys::ODESystem, vars = unknowns(sys)) + avars = [rename(v, Symbol(:accumulation_, getname(v))) for v in vars] + add_accumulations(sys, avars .=> vars) +end + +""" +$(SIGNATURES) + +Add accumulation variables for `vars`. `vars` is a vector of pairs in the form +of + +```julia +[cumulative_var1 => x + y, cumulative_var2 => x^2] +``` +Then, cumulative variables `cumulative_var1` and `cumulative_var2` that computes +the cumulative `x + y` and `x^2` would be added to `sys`. +""" +function add_accumulations(sys::ODESystem, vars::Vector{<:Pair}) + eqs = get_eqs(sys) + avars = map(first, vars) + if (ints = intersect(avars, unknowns(sys)); !isempty(ints)) + error("$ints already exist in the system!") + end + D = Differential(get_iv(sys)) + @set! sys.eqs = [eqs; Equation[D(a) ~ v[2] for (a, v) in zip(avars, vars)]] + @set! sys.unknowns = [get_unknowns(sys); avars] + @set! sys.defaults = merge(get_defaults(sys), Dict(a => 0.0 for a in avars)) +end + +function Base.show(io::IO, mime::MIME"text/plain", sys::ODESystem; hint = true, bold = true) + # Print general AbstractSystem information + invoke(Base.show, Tuple{typeof(io), typeof(mime), AbstractSystem}, + io, mime, sys; hint, bold) + + name = nameof(sys) + + # Print initialization equations (unique to ODESystems) + nini = length(initialization_equations(sys)) + nini > 0 && printstyled(io, "\nInitialization equations ($nini):"; bold) + nini > 0 && hint && print(io, " see initialization_equations($name)") + + return nothing +end + +""" +Build the constraint system for the ODESystem. +""" +function process_constraint_system( + constraints::Vector{Equation}, sts, ps, iv; consname = :cons) + isempty(constraints) && return nothing + + constraintsts = OrderedSet() + constraintps = OrderedSet() + for cons in constraints + collect_vars!(constraintsts, constraintps, cons, iv) + end + + # Validate the states. + validate_vars_and_find_ps!(constraintsts, constraintps, sts, iv) + + ConstraintsSystem( + constraints, collect(constraintsts), collect(constraintps); name = consname) +end + +""" +Process the costs for the constraint system. +""" +function process_costs(costs::Vector, sts, ps, iv) + coststs = OrderedSet() + costps = OrderedSet() + for cost in costs + collect_vars!(coststs, costps, cost, iv) + end + + validate_vars_and_find_ps!(coststs, costps, sts, iv) + coststs, costps +end + +""" +Validate that all the variables in an auxiliary system of the ODESystem (constraint or costs) are +well-formed states or parameters. + - Callable/delay variables (e.g. of the form x(0.6) should be unknowns of the system (and have one arg, etc.) + - Callable/delay parameters should be parameters of the system + +Return the set of additional parameters found in the system, e.g. in x(p) ~ 3 then p should be added as a +parameter of the system. +""" +function validate_vars_and_find_ps!(auxvars, auxps, sysvars, iv) + sts = sysvars + + for var in auxvars + if !iscall(var) + occursin(iv, var) && (var ∈ sts || + throw(ArgumentError("Time-dependent variable $var is not an unknown of the system."))) + elseif length(arguments(var)) > 1 + throw(ArgumentError("Too many arguments for variable $var.")) + elseif length(arguments(var)) == 1 + arg = only(arguments(var)) + operation(var)(iv) ∈ sts || + throw(ArgumentError("Variable $var is not a variable of the ODESystem. Called variables must be variables of the ODESystem.")) + + isequal(arg, iv) || isparameter(arg) || arg isa Integer || + arg isa AbstractFloat || + throw(ArgumentError("Invalid argument specified for variable $var. The argument of the variable should be either $iv, a parameter, or a value specifying the time that the constraint holds.")) + + isparameter(arg) && push!(auxps, arg) + else + var ∈ sts && + @warn "Variable $var has no argument. It will be interpreted as $var($iv), and the constraint will apply to the entire interval." + end + end +end + +""" +Generate a function that takes a solution object and computes the cost function obtained by coalescing the costs vector. +""" +function generate_cost_function(sys::ODESystem, kwargs...) + costs = get_costs(sys) + consolidate = get_consolidate(sys) + iv = get_iv(sys) + + ps = parameters(sys; initial_parameters = false) + sts = unknowns(sys) + np = length(ps) + ns = length(sts) + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + pidxmap = Dict([v => i for (i, v) in enumerate(ps)]) + + @variables sol(..)[1:ns] + for st in vars(costs) + x = operation(st) + t = only(arguments(st)) + idx = stidxmap[x(iv)] + + costs = map(c -> Symbolics.fast_substitute(c, Dict(x(t) => sol(t)[idx])), costs) + end + + _p = reorder_parameters(sys, ps) + fs = build_function_wrapper(sys, costs, sol, _p..., t; output_type = Array, kwargs...) + vc_oop, vc_iip = eval_or_rgf.(fs) + + cost(sol, p, t) = consolidate(vc_oop(sol, p, t)) + return cost end diff --git a/src/systems/diffeqs/sdesystem.jl b/src/systems/diffeqs/sdesystem.jl index d804bc2ca2..3fa1302630 100644 --- a/src/systems/diffeqs/sdesystem.jl +++ b/src/systems/diffeqs/sdesystem.jl @@ -1,391 +1,931 @@ -""" -$(TYPEDEF) - -A system of stochastic differential equations. - -# Fields -$(FIELDS) - -# Example - -```julia -using ModelingToolkit - -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) - -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -noiseeqs = [0.1*x, - 0.1*y, - 0.1*z] - -de = SDESystem(eqs,noiseeqs,t,[x,y,z],[σ,ρ,β]) -``` -""" -struct SDESystem <: AbstractODESystem - """The expressions defining the drift term.""" - eqs::Vector{Equation} - """The expressions defining the diffusion term.""" - noiseeqs::AbstractArray - """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Parameter variables.""" - ps::Vector - observed::Vector - """ - Time-derivative matrix. Note: this field will not be defined until - [`calculate_tgrad`](@ref) is called on the system. - """ - tgrad::RefValue - """ - Jacobian matrix. Note: this field will not be defined until - [`calculate_jacobian`](@ref) is called on the system. - """ - jac::RefValue - """ - `Wfact` matrix. Note: this field will not be defined until - [`generate_factorized_W`](@ref) is called on the system. - """ - Wfact::RefValue - """ - `Wfact_t` matrix. Note: this field will not be defined until - [`generate_factorized_W`](@ref) is called on the system. - """ - Wfact_t::RefValue - """ - Name: the name of the system - """ - name::Symbol - """ - Systems: the internal systems - """ - systems::Vector{SDESystem} - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict - """ - type: type of the system - """ - connection_type::Any -end - -function SDESystem(deqs::AbstractVector{<:Equation}, neqs, iv, dvs, ps; - observed = [], - systems = SDESystem[], - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - name = gensym(:SDESystem), - connection_type=nothing, - ) - iv′ = value(iv) - dvs′ = value.(dvs) - ps′ = value.(ps) - - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :SDESystem, force=true) - end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - - tgrad = RefValue(Vector{Num}(undef, 0)) - jac = RefValue{Any}(Matrix{Num}(undef, 0, 0)) - Wfact = RefValue(Matrix{Num}(undef, 0, 0)) - Wfact_t = RefValue(Matrix{Num}(undef, 0, 0)) - SDESystem(deqs, neqs, iv′, dvs′, ps′, observed, tgrad, jac, Wfact, Wfact_t, name, systems, defaults, connection_type) -end - -function generate_diffusion_function(sys::SDESystem, dvs = states(sys), ps = parameters(sys); kwargs...) - return build_function(get_noiseeqs(sys), - map(x->time_varying_as_func(value(x), sys), dvs), - map(x->time_varying_as_func(value(x), sys), ps), - independent_variable(sys); kwargs...) -end - -""" -$(TYPEDSIGNATURES) - -Choose correction_factor=-1//2 (1//2) to converte Ito -> Stratonovich (Stratonovich->Ito). -""" -function stochastic_integral_transform(sys::SDESystem, correction_factor) - # use the general interface - if typeof(get_noiseeqs(sys)) <: Vector - eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[i] for i in eachindex(states(sys))]...) - de = ODESystem(eqs,get_iv(sys),states(sys),parameters(sys)) - - jac = calculate_jacobian(de, sparse=false, simplify=false) - ∇σσ′ = simplify.(jac*get_noiseeqs(sys)) - - deqs = vcat([equations(sys)[i].lhs ~ equations(sys)[i].rhs+ correction_factor*∇σσ′[i] for i in eachindex(states(sys))]...) - else - dimstate, m = size(get_noiseeqs(sys)) - eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[i] for i in eachindex(states(sys))]...) - de = ODESystem(eqs,get_iv(sys),states(sys),parameters(sys)) - - jac = calculate_jacobian(de, sparse=false, simplify=false) - ∇σσ′ = simplify.(jac*get_noiseeqs(sys)[:,1]) - for k = 2:m - eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[Int(i+(k-1)*dimstate)] for i in eachindex(states(sys))]...) - de = ODESystem(eqs,get_iv(sys),states(sys),parameters(sys)) - - jac = calculate_jacobian(de, sparse=false, simplify=false) - ∇σσ′ = ∇σσ′ + simplify.(jac*get_noiseeqs(sys)[:,k]) - end - - deqs = vcat([equations(sys)[i].lhs ~ equations(sys)[i].rhs + correction_factor*∇σσ′[i] for i in eachindex(states(sys))]...) - end - - - SDESystem(deqs,get_noiseeqs(sys),get_iv(sys),states(sys),parameters(sys)) -end - - - - - -""" -```julia -function DiffEqBase.SDEFunction{iip}(sys::SDESystem, dvs = sys.states, ps = sys.ps; - version = nothing, tgrad=false, sparse = false, - jac = false, Wfact = false, kwargs...) where {iip} -``` - -Create an `SDEFunction` from the [`SDESystem`](@ref). The arguments `dvs` and `ps` -are used to set the order of the dependent variable and parameter vectors, -respectively. -""" -function DiffEqBase.SDEFunction{iip}(sys::SDESystem, dvs = states(sys), ps = parameters(sys), - u0 = nothing; - version = nothing, tgrad=false, sparse = false, - jac = false, Wfact = false, eval_expression = true, kwargs...) where {iip} - f_gen = generate_function(sys, dvs, ps; expression=Val{eval_expression}, kwargs...) - f_oop,f_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in f_gen) : f_gen - g_gen = generate_diffusion_function(sys, dvs, ps; expression=Val{eval_expression}, kwargs...) - g_oop,g_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in g_gen) : g_gen - - f(u,p,t) = f_oop(u,p,t) - f(du,u,p,t) = f_iip(du,u,p,t) - g(u,p,t) = g_oop(u,p,t) - g(du,u,p,t) = g_iip(du,u,p,t) - - if tgrad - tgrad_gen = generate_tgrad(sys, dvs, ps; expression=Val{eval_expression}, kwargs...) - tgrad_oop,tgrad_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in tgrad_gen) : tgrad_gen - _tgrad(u,p,t) = tgrad_oop(u,p,t) - _tgrad(J,u,p,t) = tgrad_iip(J,u,p,t) - else - _tgrad = nothing - end - - if jac - jac_gen = generate_jacobian(sys, dvs, ps; expression=Val{eval_expression}, sparse=sparse, kwargs...) - jac_oop,jac_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in jac_gen) : jac_gen - _jac(u,p,t) = jac_oop(u,p,t) - _jac(J,u,p,t) = jac_iip(J,u,p,t) - else - _jac = nothing - end - - if Wfact - tmp_Wfact,tmp_Wfact_t = generate_factorized_W(sys, dvs, ps, true; expression=Val{true}, kwargs...) - Wfact_oop, Wfact_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in tmp_Wfact) : tmp_Wfact - Wfact_oop_t, Wfact_iip_t = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in tmp_Wfact_t) : tmp_Wfact_t - _Wfact(u,p,dtgamma,t) = Wfact_oop(u,p,dtgamma,t) - _Wfact(W,u,p,dtgamma,t) = Wfact_iip(W,u,p,dtgamma,t) - _Wfact_t(u,p,dtgamma,t) = Wfact_oop_t(u,p,dtgamma,t) - _Wfact_t(W,u,p,dtgamma,t) = Wfact_iip_t(W,u,p,dtgamma,t) - else - _Wfact,_Wfact_t = nothing,nothing - end - - M = calculate_massmatrix(sys) - _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0',M) - - sts = states(sys) - SDEFunction{iip}(f,g, - jac = _jac === nothing ? nothing : _jac, - tgrad = _tgrad === nothing ? nothing : _tgrad, - Wfact = _Wfact === nothing ? nothing : _Wfact, - Wfact_t = _Wfact_t === nothing ? nothing : _Wfact_t, - mass_matrix = _M, - syms = Symbol.(states(sys))) -end - -function DiffEqBase.SDEFunction(sys::SDESystem, args...; kwargs...) - SDEFunction{true}(sys, args...; kwargs...) -end - -""" -```julia -function DiffEqBase.SDEFunctionExpr{iip}(sys::AbstractODESystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, tgrad=false, - jac = false, Wfact = false, - skipzeros = true, fillzeros = true, - sparse = false, - kwargs...) where {iip} -``` - -Create a Julia expression for an `SDEFunction` from the [`SDESystem`](@ref). -The arguments `dvs` and `ps` are used to set the order of the dependent -variable and parameter vectors, respectively. -""" -struct SDEFunctionExpr{iip} end - -function SDEFunctionExpr{iip}(sys::SDESystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, tgrad=false, - jac = false, Wfact = false, - sparse = false,linenumbers = false, - kwargs...) where {iip} - - idx = iip ? 2 : 1 - f = generate_function(sys, dvs, ps; expression=Val{true}, kwargs...)[idx] - g = generate_diffusion_function(sys, dvs, ps; expression=Val{true}, kwargs...)[idx] - if tgrad - _tgrad = generate_tgrad(sys, dvs, ps; expression=Val{true}, kwargs...)[idx] - else - _tgrad = :nothing - end - - if jac - _jac = generate_jacobian(sys, dvs, ps; sparse = sparse, expression=Val{true}, kwargs...)[idx] - else - _jac = :nothing - end - - if Wfact - tmp_Wfact,tmp_Wfact_t = generate_factorized_W(sys, dvs, ps; expression=Val{true}, kwargs...) - _Wfact = tmp_Wfact[idx] - _Wfact_t = tmp_Wfact_t[idx] - else - _Wfact,_Wfact_t = :nothing,:nothing - end - - M = calculate_massmatrix(sys) - - _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0',M) - - ex = quote - f = $f - g = $g - tgrad = $_tgrad - jac = $_jac - Wfact = $_Wfact - Wfact_t = $_Wfact_t - M = $_M - SDEFunction{$iip}(f,g, - jac = jac, - tgrad = tgrad, - Wfact = Wfact, - Wfact_t = Wfact_t, - mass_matrix = M, - syms = $(Symbol.(states(sys)))) - end - !linenumbers ? striplines(ex) : ex -end - - -function SDEFunctionExpr(sys::SDESystem, args...; kwargs...) - SDEFunctionExpr{true}(sys, args...; kwargs...) -end - -function rename(sys::SDESystem,name) - SDESystem(sys.eqs, sys.noiseeqs, sys.iv, sys.states, sys.ps, sys.tgrad, sys.jac, sys.Wfact, sys.Wfact_t, name, sys.systems) -end - -""" -```julia -function DiffEqBase.SDEProblem{iip}(sys::SDESystem,u0map,tspan,p=parammap; - version = nothing, tgrad=false, - jac = false, Wfact = false, - checkbounds = false, sparse = false, - sparsenoise = sparse, - skipzeros = true, fillzeros = true, - linenumbers = true, parallel=SerialForm(), - kwargs...) -``` - -Generates an SDEProblem from an SDESystem and allows for automatically -symbolically calculating numerical enhancements. -""" -function DiffEqBase.SDEProblem{iip}(sys::SDESystem,u0map,tspan,parammap=DiffEqBase.NullParameters(); - sparsenoise = nothing, - kwargs...) where iip - f, u0, p = process_DEProblem(SDEFunction{iip}, sys, u0map, parammap; kwargs...) - sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) - - noiseeqs = get_noiseeqs(sys) - if noiseeqs isa AbstractVector - noise_rate_prototype = nothing - elseif sparsenoise - I,J,V = findnz(SparseArrays.sparse(noiseeqs)) - noise_rate_prototype = SparseArrays.sparse(I,J,zero(eltype(u0))) - else - noise_rate_prototype = zeros(eltype(u0),size(noiseeqs)) - end - - SDEProblem{iip}(f,f.g,u0,tspan,p;noise_rate_prototype=noise_rate_prototype,kwargs...) -end - -function DiffEqBase.SDEProblem(sys::SDESystem, args...; kwargs...) - SDEProblem{true}(sys, args...; kwargs...) -end - -""" -```julia -function DiffEqBase.SDEProblemExpr{iip}(sys::AbstractODESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - version = nothing, tgrad=false, - jac = false, Wfact = false, - checkbounds = false, sparse = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` - -Generates a Julia expression for constructing an ODEProblem from an -ODESystem and allows for automatically symbolically calculating -numerical enhancements. -""" -struct SDEProblemExpr{iip} end - -function SDEProblemExpr{iip}(sys::SDESystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - sparsenoise = nothing, - kwargs...) where iip - f, u0, p = process_DEProblem(SDEFunctionExpr{iip}, sys, u0map, parammap; kwargs...) - linenumbers = get(kwargs, :linenumbers, true) - sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) - - noiseeqs = get_noiseeqs(sys) - if noiseeqs isa AbstractVector - noise_rate_prototype = nothing - elseif sparsenoise - I,J,V = findnz(SparseArrays.sparse(noiseeqs)) - noise_rate_prototype = SparseArrays.sparse(I,J,zero(eltype(u0))) - else - T = u0 === nothing ? Float64 : eltype(u0) - noise_rate_prototype = zeros(T,size(get_noiseeqs(sys))) - end - ex = quote - f = $f - u0 = $u0 - tspan = $tspan - p = $p - noise_rate_prototype = $noise_rate_prototype - SDEProblem(f,f.g,u0,tspan,p;noise_rate_prototype=noise_rate_prototype,$(kwargs...)) - end - !linenumbers ? striplines(ex) : ex -end - -function SDEProblemExpr(sys::SDESystem, args...; kwargs...) - SDEProblemExpr{true}(sys, args...; kwargs...) -end +""" +$(TYPEDEF) + +A system of stochastic differential equations. + +# Fields +$(FIELDS) + +# Example + +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters σ ρ β +@variables x(t) y(t) z(t) + +eqs = [D(x) ~ σ*(y-x), + D(y) ~ x*(ρ-z)-y, + D(z) ~ x*y - β*z] + +noiseeqs = [0.1*x, + 0.1*y, + 0.1*z] + +@named de = SDESystem(eqs,noiseeqs,t,[x,y,z],[σ,ρ,β]; tspan = (0, 1000.0)) +``` +""" +struct SDESystem <: AbstractODESystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt + """The expressions defining the drift term.""" + eqs::Vector{Equation} + """The expressions defining the diffusion term.""" + noiseeqs::AbstractArray + """Independent variable.""" + iv::BasicSymbolic{Real} + """Dependent variables. Must not contain the independent variable.""" + unknowns::Vector + """Parameter variables. Must not contain the independent variable.""" + ps::Vector + """Time span.""" + tspan::Union{NTuple{2, Any}, Nothing} + """Array variables.""" + var_to_name::Any + """Control parameters (some subset of `ps`).""" + ctrls::Vector + """Observed variables.""" + observed::Vector{Equation} + """ + Time-derivative matrix. Note: this field will not be defined until + [`calculate_tgrad`](@ref) is called on the system. + """ + tgrad::RefValue + """ + Jacobian matrix. Note: this field will not be defined until + [`calculate_jacobian`](@ref) is called on the system. + """ + jac::RefValue + """ + Control Jacobian matrix. Note: this field will not be defined until + [`calculate_control_jacobian`](@ref) is called on the system. + """ + ctrl_jac::RefValue{Any} + """ + Note: this field will not be defined until + [`generate_factorized_W`](@ref) is called on the system. + """ + Wfact::RefValue + """ + Note: this field will not be defined until + [`generate_factorized_W`](@ref) is called on the system. + """ + Wfact_t::RefValue + """ + The name of the system. + """ + name::Symbol + """ + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. + """ + systems::Vector{SDESystem} + """ + The default values to use when initial conditions and/or + parameters are not supplied in `ODEProblem`. + """ + defaults::Dict + """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Type of the system. + """ + connector_type::Any + """ + A `Vector{SymbolicContinuousCallback}` that model events. + The integrator will use root finding to guarantee that it steps at each zero crossing. + """ + continuous_events::Vector{SymbolicContinuousCallback} + """ + A `Vector{SymbolicDiscreteCallback}` that models events. Symbolic + analog to `SciMLBase.DiscreteCallback` that executes an affect when a given condition is + true at the end of an integration step. + """ + discrete_events::Vector{SymbolicDiscreteCallback} + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Mapping of conditions which should be true throughout the solution process to corresponding error + messages. These will be added to the equations when calling `debug_system`. + """ + assertions::Dict{BasicSymbolic, String} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + """ + The hierarchical parent system before simplification. + """ + parent::Any + """ + Signal for whether the noise equations should be treated as a scalar process. This should only + be `true` when `noiseeqs isa Vector`. + """ + is_scalar_noise::Bool + """ + A boolean indicating if the given `ODESystem` represents a system of DDEs. + """ + is_dde::Bool + isscheduled::Bool + tearing_state::Any + + function SDESystem(tag, deqs, neqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, + tgrad, jac, ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, + guesses, initializesystem, initialization_eqs, connector_type, + cevents, devents, parameter_dependencies, assertions = Dict{ + BasicSymbolic, Nothing}, + metadata = nothing, gui_metadata = nothing, namespacing = true, + complete = false, index_cache = nothing, parent = nothing, is_scalar_noise = false, + is_dde = false, + isscheduled = false, + tearing_state = nothing; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(dvs, iv) + check_parameters(ps, iv) + check_equations(deqs, iv) + check_equations(neqs, dvs) + if size(neqs, 1) != length(deqs) + throw(ArgumentError("Noise equations ill-formed. Number of rows must match number of drift equations. size(neqs,1) = $(size(neqs,1)) != length(deqs) = $(length(deqs))")) + end + check_equations(equations(cevents), iv) + if is_scalar_noise && neqs isa AbstractMatrix + throw(ArgumentError("Noise equations ill-formed. Received a matrix of noise equations of size $(size(neqs)), but `is_scalar_noise` was set to `true`. Scalar noise is only compatible with an `AbstractVector` of noise equations.")) + end + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ps, iv) + check_units(u, deqs, neqs) + end + new(tag, deqs, neqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, connector_type, cevents, + devents, parameter_dependencies, assertions, metadata, gui_metadata, namespacing, + complete, index_cache, parent, is_scalar_noise, is_dde, isscheduled, tearing_state) + end +end + +function SDESystem(deqs::AbstractVector{<:Equation}, neqs::AbstractArray, iv, dvs, ps; + controls = Num[], + observed = Num[], + systems = SDESystem[], + tspan = nothing, + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + name = nothing, + description = "", + connector_type = nothing, + checks = true, + continuous_events = nothing, + discrete_events = nothing, + parameter_dependencies = Equation[], + assertions = Dict{BasicSymbolic, String}(), + metadata = nothing, + gui_metadata = nothing, + index_cache = nothing, + parent = nothing, + is_scalar_noise = false, + is_dde = nothing) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + iv′ = value(iv) + dvs′ = value.(dvs) + ps′ = value.(ps) + ctrl′ = value.(controls) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) + + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :SDESystem, force = true) + end + + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + tgrad = RefValue(EMPTY_TGRAD) + jac = RefValue{Any}(EMPTY_JAC) + ctrl_jac = RefValue{Any}(EMPTY_JAC) + Wfact = RefValue(EMPTY_JAC) + Wfact_t = RefValue(EMPTY_JAC) + cont_callbacks = SymbolicContinuousCallbacks(continuous_events) + disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) + if is_dde === nothing + is_dde = _check_if_dde(deqs, iv′, systems) + end + assertions = Dict{BasicSymbolic, Any}(unwrap(k) => v for (k, v) in assertions) + SDESystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + deqs, neqs, iv′, dvs′, ps′, tspan, var_to_name, ctrl′, observed, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, + initializesystem, initialization_eqs, connector_type, + cont_callbacks, disc_callbacks, parameter_dependencies, assertions, metadata, gui_metadata, + true, false, index_cache, parent, is_scalar_noise, is_dde; checks = checks) +end + +function SDESystem(sys::ODESystem, neqs; kwargs...) + SDESystem(equations(sys), neqs, get_iv(sys), unknowns(sys), parameters(sys); kwargs...) +end + +function SDESystem(eqs::Vector{Equation}, noiseeqs::AbstractArray, iv; kwargs...) + diffvars, allunknowns, ps, eqs = process_equations(eqs, iv) + + for eq in get(kwargs, :parameter_dependencies, Equation[]) + collect_vars!(allunknowns, ps, eq, iv) + end + + for ssys in get(kwargs, :systems, ODESystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, iv) + end + + for v in allunknowns + isdelay(v, iv) || continue + collect_vars!(allunknowns, ps, arguments(v)[1], iv) + end + + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + + # validate noise equations + noisedvs = OrderedSet() + noiseps = OrderedSet() + collect_vars!(noisedvs, noiseps, noiseeqs, iv) + for dv in noisedvs + dv ∈ allunknowns || + throw(ArgumentError("Variable $dv in noise equations is not an unknown of the system.")) + end + algevars = setdiff(allunknowns, diffvars) + return SDESystem(eqs, noiseeqs, iv, Iterators.flatten((diffvars, algevars)), + [collect(ps); collect(noiseps)]; kwargs...) +end + +function SDESystem(eq::Equation, noiseeqs::AbstractArray, args...; kwargs...) + SDESystem([eq], noiseeqs, args...; kwargs...) +end +function SDESystem(eq::Equation, noiseeq, args...; kwargs...) + SDESystem([eq], [noiseeq], args...; kwargs...) +end + +function Base.:(==)(sys1::SDESystem, sys2::SDESystem) + sys1 === sys2 && return true + iv1 = get_iv(sys1) + iv2 = get_iv(sys2) + isequal(iv1, iv2) && + isequal(nameof(sys1), nameof(sys2)) && + _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && + _eq_unordered(get_noiseeqs(sys1), get_noiseeqs(sys2)) && + isequal(get_is_scalar_noise(sys1), get_is_scalar_noise(sys2)) && + _eq_unordered(get_unknowns(sys1), get_unknowns(sys2)) && + _eq_unordered(get_ps(sys1), get_ps(sys2)) && + _eq_unordered(continuous_events(sys1), continuous_events(sys2)) && + _eq_unordered(discrete_events(sys1), discrete_events(sys2)) && + all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) +end + +""" + function ODESystem(sys::SDESystem) + +Convert an `SDESystem` to the equivalent `ODESystem` using `@brownian` variables instead +of noise equations. The returned system will not be `iscomplete` and will not have an +index cache, regardless of `iscomplete(sys)`. +""" +function ODESystem(sys::SDESystem) + neqs = get_noiseeqs(sys) + eqs = equations(sys) + is_scalar_noise = get_is_scalar_noise(sys) + nbrownian = if is_scalar_noise + length(neqs) + else + size(neqs, 2) + end + brownvars = map(1:nbrownian) do i + name = gensym(Symbol(:brown_, i)) + only(@brownian $name) + end + if is_scalar_noise + brownterms = reduce(+, neqs .* brownvars; init = 0) + neweqs = map(eqs) do eq + eq.lhs ~ eq.rhs + brownterms + end + else + if neqs isa AbstractVector + neqs = reshape(neqs, (length(neqs), 1)) + end + brownterms = neqs * brownvars + neweqs = map(eqs, brownterms) do eq, brown + eq.lhs ~ eq.rhs + brown + end + end + newsys = ODESystem(neweqs, get_iv(sys), unknowns(sys), parameters(sys); + parameter_dependencies = parameter_dependencies(sys), defaults = defaults(sys), + continuous_events = continuous_events(sys), discrete_events = discrete_events(sys), + assertions = assertions(sys), + name = nameof(sys), description = description(sys), metadata = get_metadata(sys)) + @set newsys.parent = sys +end + +function __num_isdiag_noise(mat) + for i in axes(mat, 1) + nnz = 0 + for j in axes(mat, 2) + if !isequal(mat[i, j], 0) + nnz += 1 + end + end + if nnz > 1 + return (false) + end + end + true +end +function __get_num_diag_noise(mat) + map(axes(mat, 1)) do i + for j in axes(mat, 2) + mij = mat[i, j] + if !isequal(mij, 0) + return mij + end + end + 0 + end +end + +function generate_diffusion_function(sys::SDESystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); isdde = false, kwargs...) + eqs = get_noiseeqs(sys) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, eqs, dvs, p..., get_iv(sys); kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Choose correction_factor=-1//2 (1//2) to convert Ito -> Stratonovich (Stratonovich->Ito). +""" +function stochastic_integral_transform(sys::SDESystem, correction_factor) + name = nameof(sys) + # use the general interface + if typeof(get_noiseeqs(sys)) <: Vector + eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[i] + for i in eachindex(unknowns(sys))]...) + de = ODESystem(eqs, get_iv(sys), unknowns(sys), parameters(sys), name = name, + checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = simplify.(jac * get_noiseeqs(sys)) + + deqs = vcat([equations(sys)[i].lhs ~ equations(sys)[i].rhs + + correction_factor * ∇σσ′[i] + for i in eachindex(unknowns(sys))]...) + else + dimunknowns, m = size(get_noiseeqs(sys)) + eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[i] + for i in eachindex(unknowns(sys))]...) + de = ODESystem(eqs, get_iv(sys), unknowns(sys), parameters(sys), name = name, + checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = simplify.(jac * get_noiseeqs(sys)[:, 1]) + for k in 2:m + eqs = vcat([equations(sys)[i].lhs ~ get_noiseeqs(sys)[Int(i + + (k - 1) * + dimunknowns)] + for i in eachindex(unknowns(sys))]...) + de = ODESystem(eqs, get_iv(sys), unknowns(sys), parameters(sys), name = name, + checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = ∇σσ′ + simplify.(jac * get_noiseeqs(sys)[:, k]) + end + + deqs = vcat([equations(sys)[i].lhs ~ equations(sys)[i].rhs + + correction_factor * ∇σσ′[i] + for i in eachindex(unknowns(sys))]...) + end + + SDESystem(deqs, get_noiseeqs(sys), get_iv(sys), unknowns(sys), parameters(sys), + name = name, description = description(sys), + parameter_dependencies = parameter_dependencies(sys), checks = false) +end + +""" +$(TYPEDSIGNATURES) + +Measure transformation method that allows for a reduction in the variance of an estimator `Exp(g(X_t))`. +Input: Original SDE system and symbolic function `u(t,x)` with scalar output that + defines the adjustable parameters `d` in the Girsanov transformation. Optional: initial + condition for `θ0`. +Output: Modified SDESystem with additional component `θ_t` and initial value `θ0`, as well as + the weight `θ_t/θ0` as observed equation, such that the estimator `Exp(g(X_t)θ_t/θ0)` + has a smaller variance. + +Reference: +Kloeden, P. E., Platen, E., & Schurz, H. (2012). Numerical solution of SDE through computer +experiments. Springer Science & Business Media. + +# Example + +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters α β +@variables x(t) y(t) z(t) + +eqs = [D(x) ~ α*x] +noiseeqs = [β*x] + +@named de = SDESystem(eqs,noiseeqs,t,[x],[α,β]) + +# define u (user choice) +u = x +θ0 = 0.1 +g(x) = x[1]^2 +demod = ModelingToolkit.Girsanov_transform(de, u; θ0=0.1) + +u0modmap = [ + x => x0 +] + +parammap = [ + α => 1.5, + β => 1.0 +] + +probmod = SDEProblem(complete(demod),u0modmap,(0.0,1.0),parammap) +ensemble_probmod = EnsembleProblem(probmod; + output_func = (sol,i) -> (g(sol[x,end])*sol[demod.weight,end],false), + ) + +simmod = solve(ensemble_probmod,EM(),dt=dt,trajectories=numtraj) +``` + +""" +function Girsanov_transform(sys::SDESystem, u; θ0 = 1.0) + name = nameof(sys) + + # register new variable θ corresponding to 1D correction process θ(t) + t = get_iv(sys) + D = Differential(t) + @variables θ(t), weight(t) + + # determine the adjustable parameters `d` given `u` + # gradient of u with respect to unknowns + grad = Symbolics.gradient(u, unknowns(sys)) + + noiseeqs = get_noiseeqs(sys) + if noiseeqs isa Vector + d = simplify.(-(noiseeqs .* grad) / u) + drift_correction = noiseeqs .* d + else + d = simplify.(-noiseeqs * grad / u) + drift_correction = noiseeqs * d + end + + # transformation adds additional unknowns θ: newX = (X,θ) + # drift function for unknowns is modified + # θ has zero drift + deqs = vcat([equations(sys)[i].lhs ~ equations(sys)[i].rhs - drift_correction[i] + for i in eachindex(unknowns(sys))]...) + deqsθ = D(θ) ~ 0 + push!(deqs, deqsθ) + + # diffusion matrix is of size d x m (d unknowns, m noise), with diagonal noise represented as a d-dimensional vector + # for diagonal noise processes with m>1, the noise process will become non-diagonal; extra unknown component but no new noise process. + # new diffusion matrix is of size d+1 x M + # diffusion for state is unchanged + + noiseqsθ = θ * d + + if noiseeqs isa Vector + m = size(noiseeqs) + if m == 1 + push!(noiseeqs, noiseqsθ) + else + noiseeqs = [Array(Diagonal(noiseeqs)); noiseqsθ'] + end + else + noiseeqs = [Array(noiseeqs); noiseqsθ'] + end + + unknown_vars = [unknowns(sys); θ] + + # return modified SDE System + SDESystem(deqs, noiseeqs, get_iv(sys), unknown_vars, parameters(sys); + defaults = Dict(θ => θ0), observed = [weight ~ θ / θ0], + name = name, description = description(sys), + parameter_dependencies = parameter_dependencies(sys), + checks = false) +end + +function DiffEqBase.SDEFunction{iip, specialize}(sys::SDESystem, dvs = unknowns(sys), + ps = parameters(sys), + u0 = nothing; + version = nothing, tgrad = false, sparse = false, + jac = false, Wfact = false, eval_expression = false, + sparsity = false, analytic = nothing, + eval_module = @__MODULE__, + checkbounds = false, initialization_data = nothing, + cse = true, kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEFunction`") + end + dvs = scalarize.(dvs) + + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + g_gen = generate_diffusion_function(sys, dvs, ps; expression = Val{true}, + cse, kwargs...) + g_oop, g_iip = eval_or_rgf.(g_gen; eval_expression, eval_module) + + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) + g = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(g_oop, g_iip) + + if tgrad + tgrad_gen = generate_tgrad(sys, dvs, ps; expression = Val{true}, cse, + kwargs...) + tgrad_oop, tgrad_iip = eval_or_rgf.(tgrad_gen; eval_expression, eval_module) + _tgrad = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(tgrad_oop, tgrad_iip) + else + _tgrad = nothing + end + + if jac + jac_gen = generate_jacobian(sys, dvs, ps; expression = Val{true}, + sparse = sparse, cse, kwargs...) + jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) + + _jac = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(jac_oop, jac_iip) + else + _jac = nothing + end + + if Wfact + tmp_Wfact, tmp_Wfact_t = generate_factorized_W(sys, dvs, ps, true; + expression = Val{true}, cse, kwargs...) + Wfact_oop, Wfact_iip = eval_or_rgf.(tmp_Wfact; eval_expression, eval_module) + Wfact_oop_t, Wfact_iip_t = eval_or_rgf.(tmp_Wfact_t; eval_expression, eval_module) + + _Wfact = GeneratedFunctionWrapper{(2, 4, is_split(sys))}(Wfact_oop, Wfact_iip) + _Wfact_t = GeneratedFunctionWrapper{(2, 4, is_split(sys))}(Wfact_oop_t, Wfact_iip_t) + else + _Wfact, _Wfact_t = nothing, nothing + end + + M = calculate_massmatrix(sys) + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + W_prototype = similar(W_sparsity(sys), uElType) + else + W_prototype = nothing + end + + _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0', M) + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + SDEFunction{iip, specialize}(f, g; + sys = sys, + jac = _jac === nothing ? nothing : _jac, + tgrad = _tgrad === nothing ? nothing : _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + observed = observedfun, + sparsity = sparsity ? W_sparsity(sys) : nothing, + analytic = analytic, + Wfact = _Wfact === nothing ? nothing : _Wfact, + Wfact_t = _Wfact_t === nothing ? nothing : _Wfact_t, + initialization_data) +end + +""" +```julia +DiffEqBase.SDEFunction{iip}(sys::SDESystem, dvs = sys.unknowns, ps = sys.ps; + version = nothing, tgrad = false, sparse = false, + jac = false, Wfact = false, kwargs...) where {iip} +``` + +Create an `SDEFunction` from the [`SDESystem`](@ref). The arguments `dvs` and `ps` +are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function DiffEqBase.SDEFunction(sys::SDESystem, args...; kwargs...) + SDEFunction{true}(sys, args...; kwargs...) +end + +function DiffEqBase.SDEFunction{true}(sys::SDESystem, args...; + kwargs...) + SDEFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.SDEFunction{false}(sys::SDESystem, args...; + kwargs...) + SDEFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +""" +```julia +DiffEqBase.SDEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, tgrad = false, + jac = false, Wfact = false, + skipzeros = true, fillzeros = true, + sparse = false, + kwargs...) where {iip} +``` + +Create a Julia expression for an `SDEFunction` from the [`SDESystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct SDEFunctionExpr{iip} end + +function SDEFunctionExpr{iip}(sys::SDESystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, tgrad = false, + jac = false, Wfact = false, + sparse = false, linenumbers = false, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEFunctionExpr`") + end + idx = iip ? 2 : 1 + f = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...)[idx] + g = generate_diffusion_function(sys, dvs, ps; expression = Val{true}, kwargs...)[idx] + if tgrad + _tgrad = generate_tgrad(sys, dvs, ps; expression = Val{true}, kwargs...)[idx] + else + _tgrad = :nothing + end + + if jac + _jac = generate_jacobian(sys, dvs, ps; sparse = sparse, expression = Val{true}, + kwargs...)[idx] + else + _jac = :nothing + end + + M = calculate_massmatrix(sys) + _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0', M) + + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + W_prototype = similar(W_sparsity(sys), uElType) + else + W_prototype = nothing + end + + if Wfact + tmp_Wfact, tmp_Wfact_t = generate_factorized_W( + sys, dvs, ps; expression = Val{true}, + kwargs...) + _Wfact = tmp_Wfact[idx] + _Wfact_t = tmp_Wfact_t[idx] + else + _Wfact, _Wfact_t = :nothing, :nothing + end + + ex = quote + f = $f + g = $g + tgrad = $_tgrad + jac = $_jac + W_prototype = $W_prototype + Wfact = $_Wfact + Wfact_t = $_Wfact_t + M = $_M + SDEFunction{$iip}(f, g, + jac = jac, + jac_prototype = W_prototype, + tgrad = tgrad, + Wfact = Wfact, + Wfact_t = Wfact_t, + mass_matrix = M) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function SDEFunctionExpr(sys::SDESystem, args...; kwargs...) + SDEFunctionExpr{true}(sys, args...; kwargs...) +end + +function DiffEqBase.SDEProblem{iip, specialize}( + sys::SDESystem, u0map = [], tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + sparsenoise = nothing, check_length = true, + callback = nothing, kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEProblem`") + end + + f, u0, p = process_SciMLProblem( + SDEFunction{iip, specialize}, sys, u0map, parammap; check_length, + t = tspan === nothing ? nothing : tspan[1], kwargs...) + cbs = process_events(sys; callback, kwargs...) + sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) + + noiseeqs = get_noiseeqs(sys) + is_scalar_noise = get_is_scalar_noise(sys) + if noiseeqs isa AbstractVector + noise_rate_prototype = nothing + if is_scalar_noise + noise = WienerProcess(0.0, 0.0, 0.0) + else + noise = nothing + end + elseif sparsenoise + I, J, V = findnz(SparseArrays.sparse(noiseeqs)) + noise_rate_prototype = SparseArrays.sparse(I, J, zero(eltype(u0))) + noise = nothing + else + noise_rate_prototype = zeros(eltype(u0), size(noiseeqs)) + noise = nothing + end + + kwargs = filter_kwargs(kwargs) + + # Call `remake` so it runs initialization if it is trivial + return remake(SDEProblem{iip}(f, u0, tspan, p; callback = cbs, noise, + noise_rate_prototype = noise_rate_prototype, kwargs...)) +end + +function DiffEqBase.SDEProblem(sys::ODESystem, args...; kwargs...) + if any(ModelingToolkit.isbrownian, unknowns(sys)) + error("SDESystem constructed by defining Brownian variables with @brownian must be simplified by calling `structural_simplify` before a SDEProblem can be constructed.") + else + error("Cannot construct SDEProblem from a normal ODESystem.") + end +end + +""" +```julia +DiffEqBase.SDEProblem{iip}(sys::SDESystem, u0map, tspan, p = parammap; + version = nothing, tgrad = false, + jac = false, Wfact = false, + checkbounds = false, sparse = false, + sparsenoise = sparse, + skipzeros = true, fillzeros = true, + linenumbers = true, parallel = SerialForm(), + kwargs...) +``` + +Generates an SDEProblem from an SDESystem and allows for automatically +symbolically calculating numerical enhancements. +""" +function DiffEqBase.SDEProblem(sys::SDESystem, args...; kwargs...) + SDEProblem{true}(sys, args...; kwargs...) +end + +function DiffEqBase.SDEProblem(sys::SDESystem, + u0map::StaticArray, + args...; + kwargs...) + SDEProblem{false, SciMLBase.FullSpecialize}(sys, u0map, args...; kwargs...) +end + +function DiffEqBase.SDEProblem{true}(sys::SDESystem, args...; kwargs...) + SDEProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function DiffEqBase.SDEProblem{false}(sys::SDESystem, args...; kwargs...) + SDEProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +""" +```julia +DiffEqBase.SDEProblemExpr{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + version = nothing, tgrad = false, + jac = false, Wfact = false, + checkbounds = false, sparse = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates a Julia expression for constructing an ODEProblem from an +ODESystem and allows for automatically symbolically calculating +numerical enhancements. +""" +struct SDEProblemExpr{iip} end + +function SDEProblemExpr{iip}(sys::SDESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + sparsenoise = nothing, check_length = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEProblemExpr`") + end + f, u0, p = process_SciMLProblem( + SDEFunctionExpr{iip}, sys, u0map, parammap; check_length, + kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) + + noiseeqs = get_noiseeqs(sys) + is_scalar_noise = get_is_scalar_noise(sys) + if noiseeqs isa AbstractVector + noise_rate_prototype = nothing + if is_scalar_noise + noise = WienerProcess(0.0, 0.0, 0.0) + else + noise = nothing + end + elseif sparsenoise + I, J, V = findnz(SparseArrays.sparse(noiseeqs)) + noise_rate_prototype = SparseArrays.sparse(I, J, zero(eltype(u0))) + noise = nothing + else + T = u0 === nothing ? Float64 : eltype(u0) + noise_rate_prototype = zeros(T, size(get_noiseeqs(sys))) + noise = nothing + end + ex = quote + f = $f + u0 = $u0 + tspan = $tspan + p = $p + noise_rate_prototype = $noise_rate_prototype + noise = $noise + SDEProblem( + f, u0, tspan, p; noise_rate_prototype = noise_rate_prototype, noise = noise, + $(kwargs...)) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function SDEProblemExpr(sys::SDESystem, args...; kwargs...) + SDEProblemExpr{true}(sys, args...; kwargs...) +end diff --git a/src/systems/diffeqs/validation.jl b/src/systems/diffeqs/validation.jl deleted file mode 100644 index e550273f50..0000000000 --- a/src/systems/diffeqs/validation.jl +++ /dev/null @@ -1,26 +0,0 @@ -Base.:*(x::Union{Num,Symbolic},y::Unitful.AbstractQuantity) = x * y - -instantiate(x::Sym{Real}) = 1.0 -instantiate(x::Symbolic) = oneunit(1*ModelingToolkit.vartype(x)) -function instantiate(x::Num) - x = value(x) - if operation(x) isa Sym - return instantiate(operation(x)) - elseif operation(x) isa Differential - instantiate(arguments(x)[1])/instantiate(arguments(x)[1].args[1]) - else - operation(x)(instantiate.(arguments(x))...) - end -end - -function validate(eq::ModelingToolkit.Equation) - try - return typeof(instantiate(eq.lhs)) == typeof(instantiate(eq.rhs)) - catch - return false - end -end - -function validate(sys::AbstractODESystem) - all(validate.(equations(sys))) -end diff --git a/src/systems/discrete_system/discrete_system.jl b/src/systems/discrete_system/discrete_system.jl index c47ccb6889..5f7c986659 100644 --- a/src/systems/discrete_system/discrete_system.jl +++ b/src/systems/discrete_system/discrete_system.jl @@ -1,107 +1,431 @@ """ $(TYPEDEF) - A system of difference equations. - # Fields $(FIELDS) - # Example - ``` using ModelingToolkit - -@parameters t σ ρ β -@variables x(t) y(t) z(t) next_x(t) next_y(t) next_z(t) - -eqs = [next_x ~ σ*(y-x), - next_y ~ x*(ρ-z)-y, - next_z ~ x*y - β*z] - -de = DiscreteSystem(eqs,t,[x,y,z],[σ,ρ,β]) +using ModelingToolkit: t_nounits as t +@parameters σ=28.0 ρ=10.0 β=8/3 δt=0.1 +@variables x(t)=1.0 y(t)=0.0 z(t)=0.0 +k = ShiftIndex(t) +eqs = [x(k+1) ~ σ*(y-x), + y(k+1) ~ x*(ρ-z)-y, + z(k+1) ~ x*y - β*z] +@named de = DiscreteSystem(eqs,t,[x,y,z],[σ,ρ,β]; tspan = (0, 1000.0)) # or +@named de = DiscreteSystem(eqs) ``` """ -struct DiscreteSystem <: AbstractSystem +struct DiscreteSystem <: AbstractDiscreteSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt """The differential equations defining the discrete system.""" eqs::Vector{Equation} """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Parameter variables.""" + iv::BasicSymbolic{Real} + """Dependent (state) variables. Must not contain the independent variable.""" + unknowns::Vector + """Parameter variables. Must not contain the independent variable.""" ps::Vector + """Time span.""" + tspan::Union{NTuple{2, Any}, Nothing} + """Array variables.""" + var_to_name::Any + """Observed states.""" observed::Vector{Equation} """ - Name: the name of the system + The name of the system """ name::Symbol """ - systems: The internal systems. These are required to have unique names. + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. """ systems::Vector{DiscreteSystem} """ - default_u0: The default initial conditions to use when initial conditions - are not supplied in `DiscreteSystem`. + The default values to use when initial conditions and/or + parameters are not supplied in `DiscreteProblem`. + """ + defaults::Dict + """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Inject assignment statements before the evaluation of the RHS function. + """ + preface::Any + """ + Type of the system. + """ + connector_type::Any + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} """ - default_u0::Dict + Metadata for the system, to be used by downstream packages. """ - default_p: The default parameters to use when parameters are not supplied - in `DiscreteSystem`. + metadata::Any """ - default_p::Dict + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + """ + The hierarchical parent system before simplification. + """ + parent::Any + isscheduled::Bool + + function DiscreteSystem(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, preface, connector_type, parameter_dependencies = Equation[], + metadata = nothing, gui_metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, + complete = false, index_cache = nothing, parent = nothing, + isscheduled = false; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(dvs, iv) + check_parameters(ps, iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ps, iv) + check_units(u, discreteEqs) + end + new(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, + preface, connector_type, parameter_dependencies, metadata, gui_metadata, + tearing_state, substitutions, namespacing, complete, index_cache, parent, + isscheduled) + end end """ $(TYPEDSIGNATURES) - Constructs a DiscreteSystem. """ -function DiscreteSystem( - discreteEqs::AbstractVector{<:Equation}, iv, dvs, ps; - observed = Num[], - systems = DiscreteSystem[], - name=gensym(:DiscreteSystem), - default_u0=Dict(), - default_p=Dict(), - ) +function DiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; + observed = Num[], + systems = DiscreteSystem[], + tspan = nothing, + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + defaults = _merge(Dict(default_u0), Dict(default_p)), + preface = nothing, + connector_type = nothing, + parameter_dependencies = Equation[], + metadata = nothing, + gui_metadata = nothing, + kwargs...) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) iv′ = value(iv) dvs′ = value.(dvs) ps′ = value.(ps) + if any(hasderiv, eqs) || any(hashold, eqs) || any(hassample, eqs) || any(hasdiff, eqs) + error("Equations in a `DiscreteSystem` can only have `Shift` operators.") + end + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :DiscreteSystem, force = true) + end - default_u0 isa Dict || (default_u0 = Dict(default_u0)) - default_p isa Dict || (default_p = Dict(default_p)) - default_u0 = Dict(value(k) => value(default_u0[k]) for k in keys(default_u0)) - default_p = Dict(value(k) => value(default_p[k]) for k in keys(default_p)) + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) sysnames = nameof.(systems) if length(unique(sysnames)) != length(sysnames) throw(ArgumentError("System names must be unique.")) end - DiscreteSystem(discreteEqs, iv′, dvs′, ps′, observed, name, systems, default_u0, default_p) + DiscreteSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + eqs, iv′, dvs′, ps′, tspan, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, preface, connector_type, + parameter_dependencies, metadata, gui_metadata, kwargs...) +end + +function DiscreteSystem(eqs, iv; kwargs...) + eqs = collect(eqs) + diffvars = OrderedSet() + allunknowns = OrderedSet() + ps = OrderedSet() + iv = value(iv) + for eq in eqs + collect_vars!(allunknowns, ps, eq, iv; op = Shift) + if iscall(eq.lhs) && operation(eq.lhs) isa Shift + isequal(iv, operation(eq.lhs).t) || + throw(ArgumentError("A DiscreteSystem can only have one independent variable.")) + eq.lhs in diffvars && + throw(ArgumentError("The shift variable $(eq.lhs) is not unique in the system of equations.")) + push!(diffvars, eq.lhs) + end + end + for eq in get(kwargs, :parameter_dependencies, Equation[]) + if eq isa Pair + collect_vars!(allunknowns, ps, eq, iv) + else + collect_vars!(allunknowns, ps, eq, iv) + end + end + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + return DiscreteSystem(eqs, iv, + collect(allunknowns), collect(new_ps); kwargs...) +end + +DiscreteSystem(eq::Equation, args...; kwargs...) = DiscreteSystem([eq], args...; kwargs...) + +function flatten(sys::DiscreteSystem, noeqs = false) + systems = get_systems(sys) + if isempty(systems) + return sys + else + return DiscreteSystem(noeqs ? Equation[] : equations(sys), + get_iv(sys), + unknowns(sys), + parameters(sys), + observed = observed(sys), + defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), + name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), + checks = false) + end +end + +function generate_function( + sys::DiscreteSystem, dvs = unknowns(sys), ps = parameters(sys); wrap_code = identity, kwargs...) + exprs = [eq.rhs for eq in equations(sys)] + generate_custom_function(sys, exprs, dvs, ps; kwargs...) +end + +function shift_u0map_forward(sys::DiscreteSystem, u0map, defs) + iv = get_iv(sys) + updated = AnyDict() + for k in collect(keys(u0map)) + v = u0map[k] + if !((op = operation(k)) isa Shift) + isnothing(getunshifted(k)) && + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(k)).") + + updated[Shift(iv, 1)(k)] = v + elseif op.steps > 0 + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(only(arguments(k)))).") + else + updated[Shift(iv, op.steps + 1)(only(arguments(k)))] = v + end + end + for var in unknowns(sys) + op = operation(var) + root = getunshifted(var) + shift = getshift(var) + isnothing(root) && continue + (haskey(updated, Shift(iv, shift)(root)) || haskey(updated, var)) && continue + haskey(defs, root) || error("Initial condition for $var not provided.") + updated[var] = defs[root] + end + return updated end """ $(TYPEDSIGNATURES) - Generates an DiscreteProblem from an DiscreteSystem. """ -function DiffEqBase.DiscreteProblem(sys::DiscreteSystem,u0map,tspan, - parammap=DiffEqBase.NullParameters(); - eval_module = @__MODULE__, - eval_expression = true, - kwargs...) - dvs = states(sys) +function SciMLBase.DiscreteProblem( + sys::DiscreteSystem, u0map = [], tspan = get_tspan(sys), + parammap = SciMLBase.NullParameters(); + eval_module = @__MODULE__, + eval_expression = false, + kwargs... +) + if !iscomplete(sys) + error("A completed `DiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") + end + dvs = unknowns(sys) ps = parameters(sys) eqs = equations(sys) - # defs = defaults(sys) - t = get_iv(sys) - u0 = varmap_to_vars(u0map,dvs) - rhss = [eq.rhs for eq in eqs] - u = dvs - p = varmap_to_vars(parammap,ps) - - f_gen = build_function(rhss, dvs, ps, t; expression=Val{eval_expression}, expression_module=eval_module) - f_oop,f_iip = (@RuntimeGeneratedFunction(eval_module, ex) for ex in f_gen) - f(u,p,t) = f_oop(u,p,t) - DiscreteProblem(f,u0,tspan,p;kwargs...) + iv = get_iv(sys) + + u0map = to_varmap(u0map, dvs) + u0map = shift_u0map_forward(sys, u0map, defaults(sys)) + f, u0, p = process_SciMLProblem( + DiscreteFunction, sys, u0map, parammap; eval_expression, eval_module, build_initializeprob = false) + u0 = f(u0, p, tspan[1]) + DiscreteProblem(f, u0, tspan, p; kwargs...) +end + +function SciMLBase.DiscreteFunction(sys::DiscreteSystem, args...; kwargs...) + DiscreteFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.DiscreteFunction{true}(sys::DiscreteSystem, args...; kwargs...) + DiscreteFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.DiscreteFunction{false}(sys::DiscreteSystem, args...; kwargs...) + DiscreteFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +""" +```julia +SciMLBase.DiscreteFunction{iip}(sys::DiscreteSystem, + dvs = unknowns(sys), + ps = parameters(sys); + kwargs...) where {iip} +``` + +Create an `DiscreteFunction` from the [`DiscreteSystem`](@ref). The arguments `dvs` and `ps` +are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function SciMLBase.DiscreteFunction{iip, specialize}( + sys::DiscreteSystem, + dvs = unknowns(sys), + ps = parameters(sys), + u0 = nothing; + version = nothing, + p = nothing, + t = nothing, + eval_expression = false, + eval_module = @__MODULE__, + analytic = nothing, cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `DiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") + end + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, + expression_module = eval_module, cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) + + if specialize === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on DiscreteFunction.") + end + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + DiscreteFunction{iip, specialize}(f; + sys = sys, + observed = observedfun, + analytic = analytic) end + +""" +```julia +DiscreteFunctionExpr{iip}(sys::DiscreteSystem, dvs = states(sys), + ps = parameters(sys); + version = nothing, + kwargs...) where {iip} +``` + +Create a Julia expression for an `DiscreteFunction` from the [`DiscreteSystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct DiscreteFunctionExpr{iip} end +struct DiscreteFunctionClosure{O, I} <: Function + f_oop::O + f_iip::I +end +(f::DiscreteFunctionClosure)(u, p, t) = f.f_oop(u, p, t) +(f::DiscreteFunctionClosure)(du, u, p, t) = f.f_iip(du, u, p, t) + +function DiscreteFunctionExpr{iip}(sys::DiscreteSystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, p = nothing, + linenumbers = false, + simplify = false, + kwargs...) where {iip} + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + + fsym = gensym(:f) + _f = :($fsym = $DiscreteFunctionClosure($f_oop, $f_iip)) + + ex = quote + $_f + DiscreteFunction{$iip}($fsym) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function DiscreteFunctionExpr(sys::DiscreteSystem, args...; kwargs...) + DiscreteFunctionExpr{true}(sys, args...; kwargs...) +end + +supports_initialization(::DiscreteSystem) = false diff --git a/src/systems/discrete_system/implicit_discrete_system.jl b/src/systems/discrete_system/implicit_discrete_system.jl new file mode 100644 index 0000000000..3956c089d4 --- /dev/null +++ b/src/systems/discrete_system/implicit_discrete_system.jl @@ -0,0 +1,443 @@ +""" +$(TYPEDEF) +An implicit system of difference equations. +# Fields +$(FIELDS) +# Example +``` +using ModelingToolkit +using ModelingToolkit: t_nounits as t +@parameters σ=28.0 ρ=10.0 β=8/3 δt=0.1 +@variables x(t)=1.0 y(t)=0.0 z(t)=0.0 +k = ShiftIndex(t) +eqs = [x ~ σ*(y-x(k-1)), + y ~ x(k-1)*(ρ-z(k-1))-y, + z ~ x(k-1)*y(k-1) - β*z] +@named ide = ImplicitDiscreteSystem(eqs,t,[x,y,z],[σ,ρ,β]; tspan = (0, 1000.0)) +``` +""" +struct ImplicitDiscreteSystem <: AbstractDiscreteSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt + """The difference equations defining the discrete system.""" + eqs::Vector{Equation} + """Independent variable.""" + iv::BasicSymbolic{Real} + """Dependent (state) variables. Must not contain the independent variable.""" + unknowns::Vector + """Parameter variables. Must not contain the independent variable.""" + ps::Vector + """Time span.""" + tspan::Union{NTuple{2, Any}, Nothing} + """Array variables.""" + var_to_name::Any + """Observed states.""" + observed::Vector{Equation} + """ + The name of the system + """ + name::Symbol + """ + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. + """ + systems::Vector{ImplicitDiscreteSystem} + """ + The default values to use when initial conditions and/or + parameters are not supplied in `ImplicitDiscreteProblem`. + """ + defaults::Dict + """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Inject assignment statements before the evaluation of the RHS function. + """ + preface::Any + """ + Type of the system. + """ + connector_type::Any + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + """ + The hierarchical parent system before simplification. + """ + parent::Any + isscheduled::Bool + + function ImplicitDiscreteSystem(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, preface, connector_type, parameter_dependencies = Equation[], + metadata = nothing, gui_metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, + complete = false, index_cache = nothing, parent = nothing, + isscheduled = false; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(dvs, iv) + check_parameters(ps, iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ps, iv) + check_units(u, discreteEqs) + end + new(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, + preface, connector_type, parameter_dependencies, metadata, gui_metadata, + tearing_state, substitutions, namespacing, complete, index_cache, parent, + isscheduled) + end +end + +""" + $(TYPEDSIGNATURES) + +Constructs a ImplicitDiscreteSystem. +""" +function ImplicitDiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; + observed = Num[], + systems = ImplicitDiscreteSystem[], + tspan = nothing, + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + defaults = _merge(Dict(default_u0), Dict(default_p)), + preface = nothing, + connector_type = nothing, + parameter_dependencies = Equation[], + metadata = nothing, + gui_metadata = nothing, + kwargs...) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + iv′ = value(iv) + dvs′ = value.(dvs) + ps′ = value.(ps) + if any(hasderiv, eqs) || any(hashold, eqs) || any(hassample, eqs) || any(hasdiff, eqs) + error("Equations in a `ImplicitDiscreteSystem` can only have `Shift` operators.") + end + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :ImplicitDiscreteSystem, force = true) + end + + # Copy equations to canonical form, but do not touch array expressions + eqs = [wrap(eq.lhs) isa Symbolics.Arr ? eq : 0 ~ eq.rhs - eq.lhs for eq in eqs] + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + ImplicitDiscreteSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + eqs, iv′, dvs′, ps′, tspan, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, preface, connector_type, + parameter_dependencies, metadata, gui_metadata, kwargs...) +end + +function ImplicitDiscreteSystem(eqs, iv; kwargs...) + eqs = collect(eqs) + diffvars = OrderedSet() + allunknowns = OrderedSet() + ps = OrderedSet() + iv = value(iv) + for eq in eqs + collect_vars!(allunknowns, ps, eq, iv; op = Shift) + if iscall(eq.lhs) && operation(eq.lhs) isa Shift + isequal(iv, operation(eq.lhs).t) || + throw(ArgumentError("An ImplicitDiscreteSystem can only have one independent variable.")) + eq.lhs in diffvars && + throw(ArgumentError("The shift variable $(eq.lhs) is not unique in the system of equations.")) + push!(diffvars, eq.lhs) + end + end + for eq in get(kwargs, :parameter_dependencies, Equation[]) + if eq isa Pair + collect_vars!(allunknowns, ps, eq, iv) + else + collect_vars!(allunknowns, ps, eq, iv) + end + end + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + return ImplicitDiscreteSystem(eqs, iv, + collect(allunknowns), collect(new_ps); kwargs...) +end + +function ImplicitDiscreteSystem(eq::Equation, args...; kwargs...) + ImplicitDiscreteSystem([eq], args...; kwargs...) +end + +function flatten(sys::ImplicitDiscreteSystem, noeqs = false) + systems = get_systems(sys) + if isempty(systems) + return sys + else + return ImplicitDiscreteSystem(noeqs ? Equation[] : equations(sys), + get_iv(sys), + unknowns(sys), + parameters(sys), + observed = observed(sys), + defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), + name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), + checks = false) + end +end + +function generate_function( + sys::ImplicitDiscreteSystem, dvs = unknowns(sys), ps = parameters(sys); wrap_code = identity, kwargs...) + iv = get_iv(sys) + # Algebraic equations get shifted forward 1, to match with differential equations + exprs = map(equations(sys)) do eq + _iszero(eq.lhs) ? distribute_shift(Shift(iv, 1)(eq.rhs)) : (eq.rhs - eq.lhs) + end + + # Handle observables in algebraic equations, since they are shifted + obs = observed(sys) + shifted_obs = Symbolics.Equation[distribute_shift(Shift(iv, 1)(eq)) for eq in obs] + obsidxs = observed_equations_used_by(sys, exprs; obs = shifted_obs) + extra_assignments = [Assignment(shifted_obs[i].lhs, shifted_obs[i].rhs) + for i in obsidxs] + + u_next = map(Shift(iv, 1), dvs) + u = dvs + build_function_wrapper( + sys, exprs, u_next, u, ps..., iv; p_start = 3, extra_assignments, kwargs...) +end + +function shift_u0map_forward(sys::ImplicitDiscreteSystem, u0map, defs) + iv = get_iv(sys) + updated = AnyDict() + for k in collect(keys(u0map)) + v = u0map[k] + if !((op = operation(k)) isa Shift) + isnothing(getunshifted(k)) && + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(k)).") + + updated[k] = v + elseif op.steps > 0 + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(only(arguments(k)))).") + else + updated[k] = v + end + end + for var in unknowns(sys) + op = operation(var) + root = getunshifted(var) + shift = getshift(var) + isnothing(root) && continue + (haskey(updated, Shift(iv, shift)(root)) || haskey(updated, var)) && continue + haskey(defs, root) || error("Initial condition for $var not provided.") + updated[var] = defs[root] + end + return updated +end + +""" + $(TYPEDSIGNATURES) +Generates an ImplicitDiscreteProblem from an ImplicitDiscreteSystem. +""" +function SciMLBase.ImplicitDiscreteProblem( + sys::ImplicitDiscreteSystem, u0map = [], tspan = get_tspan(sys), + parammap = SciMLBase.NullParameters(); + eval_module = @__MODULE__, + eval_expression = false, + kwargs... +) + if !iscomplete(sys) + error("A completed `ImplicitDiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `ImplicitDiscreteProblem`.") + end + dvs = unknowns(sys) + ps = parameters(sys) + eqs = equations(sys) + iv = get_iv(sys) + + u0map = to_varmap(u0map, dvs) + u0map = shift_u0map_forward(sys, u0map, defaults(sys)) + f, u0, p = process_SciMLProblem( + ImplicitDiscreteFunction, sys, u0map, parammap; eval_expression, eval_module, kwargs...) + + kwargs = filter_kwargs(kwargs) + ImplicitDiscreteProblem(f, u0, tspan, p; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction(sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction{true}( + sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction{false}( + sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end +function SciMLBase.ImplicitDiscreteFunction{iip, specialize}( + sys::ImplicitDiscreteSystem, + dvs = unknowns(sys), + ps = parameters(sys), + u0 = nothing; + version = nothing, + p = nothing, + t = nothing, + eval_expression = false, + eval_module = @__MODULE__, + analytic = nothing, cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `ImplicitDiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `ImplicitDiscreteProblem`") + end + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, + expression_module = eval_module, cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f(u_next, u, p, t) = f_oop(u_next, u, p, t) + f(resid, u_next, u, p, t) = f_iip(resid, u_next, u, p, t) + + if specialize === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on ImplicitDiscreteFunction.") + end + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + ImplicitDiscreteFunction{iip, specialize}(f; + sys = sys, + observed = observedfun, + analytic = analytic, + kwargs...) +end + +""" +```julia +ImplicitDiscreteFunctionExpr{iip}(sys::ImplicitDiscreteSystem, dvs = states(sys), + ps = parameters(sys); + version = nothing, + kwargs...) where {iip} +``` + +Create a Julia expression for an `ImplicitDiscreteFunction` from the [`ImplicitDiscreteSystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct ImplicitDiscreteFunctionExpr{iip} end +struct ImplicitDiscreteFunctionClosure{O, I} <: Function + f_oop::O + f_iip::I +end +(f::ImplicitDiscreteFunctionClosure)(u_next, u, p, t) = f.f_oop(u_next, u, p, t) +function (f::ImplicitDiscreteFunctionClosure)(resid, u_next, u, p, t) + f.f_iip(resid, u_next, u, p, t) +end + +function ImplicitDiscreteFunctionExpr{iip}( + sys::ImplicitDiscreteSystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, p = nothing, + linenumbers = false, + simplify = false, + kwargs...) where {iip} + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + + fsym = gensym(:f) + _f = :($fsym = $ImplicitDiscreteFunctionClosure($f_oop, $f_iip)) + + ex = quote + $_f + ImplicitDiscreteFunction{$iip}($fsym) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function ImplicitDiscreteFunctionExpr(sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunctionExpr{true}(sys, args...; kwargs...) +end diff --git a/src/systems/if_lifting.jl b/src/systems/if_lifting.jl new file mode 100644 index 0000000000..da069cc76e --- /dev/null +++ b/src/systems/if_lifting.jl @@ -0,0 +1,511 @@ +""" + struct CondRewriter + +Callable struct used to transform symbolic conditions into conditions involving discrete +variables. +""" +struct CondRewriter + """ + The independent variable which the discrete variables depend on. + """ + iv::BasicSymbolic + """ + A mapping from a discrete variables to a `NamedTuple` containing the condition + determining whether the discrete variable needs to be evaluated and the symbolic + expression the discrete variable represents. The expression is used as a rootfinding + function, and zero-crossings trigger re-evaluation of the condition (if `dependency` + is `true`). `expression < 0` is evaluated on an up-crossing and `expression <= 0` is + evaluated on a down-crossing to get the updated value of the condition variable. + """ + conditions::Dict{Any, @NamedTuple{dependency, expression}} +end + +function CondRewriter(iv) + return CondRewriter(iv, Dict()) +end + +""" + $(TYPEDSIGNATURES) + +Given a symbolic condition `expr` and the condition `dep` it depends on, update the +mapping in `cw` and generate a new discrete variable if necessary. +""" +function new_cond_sym(cw::CondRewriter, expr, dep) + if !iscall(expr) || operation(expr) != Base.:(<) || !iszero(arguments(expr)[2]) + throw(ArgumentError("`expr` passed to `new_cond_sym` must be of the form `f(args...) < 0`. Got $expr.")) + end + # check if the same expression exists in the mapping + existing_var = findfirst(p -> isequal(p.expression, expr), cw.conditions) + if existing_var !== nothing + # cache hit + (existing_dep, _) = cw.conditions[existing_var] + # update the dependency condition + cw.conditions[existing_var] = (dependency = (dep | existing_dep), expression = expr) + return existing_var + end + # generate a new condition variable + cvar = gensym("cond") + st = symtype(expr) + iv = cw.iv + cv = unwrap(first(@parameters $(cvar)(iv)::st = true)) # TODO: real init + cw.conditions[cv] = (dependency = dep, expression = expr) + return cv +end + +""" +Utility function for boolean implication. +""" +implies(a, b) = !a & b + +""" + $(TYPEDSIGNATURES) + +Recursively rewrite conditions into discrete variables. `expr` is the condition to rewrite, +`dep` is a boolean expression/value which determines when the `expr` is to be evaluated. For +example, if `expr = expr1 | expr2` and `dep = dep1`, then `expr` should only be evaluated if +`dep1` evaluates to `true`. Recursively, `expr1` should only be evaluated if `dep1` is `true`, +and `expr2` should only be evaluated if `dep & !expr1`. + +Returns a 3-tuple of the substituted expression, a condition describing when `expr` evaluates +to `true`, and a condition describing when `expr` evaluates to `false`. + +This expects that all expressions with discontinuities or with discontinuous derivatives have +been rewritten into the form of `ifelse(rootfunc(args...) < 0, left(args...), right(args...))`. +The transformation is performed via `discontinuities_to_ifelse` using `Symbolics.rootfunction` +and family. +""" +function (cw::CondRewriter)(expr, dep) + # single variable, trivial case + if issym(expr) || iscall(expr) && issym(operation(expr)) + return (expr, expr, !expr) + # literal boolean or integer + elseif expr isa Bool + return (expr, expr, !expr) + elseif expr isa Int + return (expr, true, true) + # other singleton symbolic variables + elseif !iscall(expr) + @warn "Automatic conversion of if statements to events requires use of a limited conditional grammar; see the documentation. Skipping due to $expr" + return (expr, true, true) # error case => conservative assumption is that both true and false have to be evaluated + elseif operation(expr) == Base.:(|) # OR of two conditions + a, b = arguments(expr) + (rw_conda, truea, falsea) = cw(a, dep) + # only evaluate second if first is false + (rw_condb, trueb, falseb) = cw(b, dep & falsea) + return (rw_conda | rw_condb, truea | trueb, falsea & falseb) + + elseif operation(expr) == Base.:(&) # AND of two conditions + a, b = arguments(expr) + (rw_conda, truea, falsea) = cw(a, dep) + # only evaluate second if first is true + (rw_condb, trueb, falseb) = cw(b, dep & truea) + return (rw_conda & rw_condb, truea & trueb, falsea | falseb) + elseif operation(expr) == ifelse + c, a, b = arguments(expr) + (rw_cond, ctrue, cfalse) = cw(c, dep) + # only evaluate if condition is true + (rw_conda, truea, falsea) = cw(a, dep & ctrue) + # only evaluate if condition is false + (rw_condb, trueb, falseb) = cw(b, dep & cfalse) + # expression is true if condition is true and THEN branch is true, or condition is false + # and ELSE branch is true + # similarly for expression being false + return (ifelse(rw_cond, rw_conda, rw_condb), + implies(ctrue, truea) | implies(cfalse, trueb), + implies(ctrue, falsea) | implies(cfalse, falseb)) + elseif operation(expr) == Base.:(!) # NOT of expression + (a,) = arguments(expr) + (rw, ctrue, cfalse) = cw(a, dep) + return (!rw, cfalse, ctrue) + elseif operation(expr) == Base.:(<) + if !isequal(arguments(expr)[2], 0) + throw(ArgumentError("Expected comparison to be written as `f(args...) < 0`. Found $expr.")) + end + + # if the comparison does not include time-dependent variables, + # don't create a callback for it + + # Calling `expression_is_time_dependent` is `O(d)` where `d` is the depth of the + # expression tree. We only call this in this here to avoid turning this into + # an `O(d^2)` time complexity recursion, which would happen if it were called + # at the beginning of the function. Now, it only happens near the leaves of + # the recursive tree. + if !expression_is_time_dependent(expr, cw.iv) + return (expr, expr, !expr) + end + cv = new_cond_sym(cw, expr, dep) + return (cv, cv, !cv) + elseif operation(expr) == (==) + # we don't touch equality since it's a point discontinuity. It's basically always + # false for continuous variables. In case it's an equality between discrete + # quantities, we don't need to transform it. + return (expr, expr, !expr) + elseif !expression_is_time_dependent(expr, cw.iv) + return (expr, expr, !expr) + end + error(""" + Unsupported expression form in decision variable computation $expr. If the expression + involves a registered function, declare the discontinuity using + `Symbolics.@register_discontinuity`. If this is not meant to be transformed via + `IfLifting`, wrap the parent expression in `ModelingToolkit.no_if_lift`. + """) +end + +""" + $(TYPEDSIGNATURES) + +Acts as the identity function, and prevents transformation of conditional expressions inside it. Useful +if specific `ifelse` or other functions with discontinuous derivatives shouldn't be transformed into +callbacks. +""" +no_if_lift(s) = s +@register_symbolic no_if_lift(s) + +""" + $(TYPEDEF) + +A utility struct to search through an expression specifically for `ifelse` terms, and find +all variables used in the condition of such terms. The variables are stored in a field of +the struct. +""" +struct VarsUsedInCondition + """ + Stores variables used in conditions of `ifelse` statements in the expression. + """ + vars::Set{Any} +end + +VarsUsedInCondition() = VarsUsedInCondition(Set()) + +function (v::VarsUsedInCondition)(expr) + expr = Symbolics.unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + is_array_of_symbolics(expr) || return + foreach(v, expr) + return + end + iscall(expr) || return + op = operation(expr) + + # do not search inside no_if_lift to avoid discovering + # redundant variables + op == no_if_lift && return + + args = arguments(expr) + if op == ifelse + cond, branch_a, branch_b = arguments(expr) + vars!(v.vars, cond) + v(branch_a) + v(branch_b) + end + foreach(v, args) + return +end + +""" + $(TYPEDSIGNATURES) + +Check if `expr` depends on the independent variable `iv`. Return `true` if `iv` is present +in the expression, `Differential(iv)` is in the expression, or a dependent variable such +as `@variables x(iv)` is in the expression. +""" +function expression_is_time_dependent(expr, iv) + any(vars(expr)) do sym + sym = unwrap(sym) + isequal(sym, iv) && return true + iscall(sym) || return false + op = operation(sym) + args = arguments(sym) + op isa Differential && op == Differential(iv) || + issym(op) && length(args) == 1 && expression_is_time_dependent(args[1], iv) + end +end + +""" + $(TYPEDSIGNATURES) + +Given an expression `expr` which is to be evaluated if `dep` evaluates to `true`, transform +the conditions of all all `ifelse` statements in `expr` into functions of new discrete +variables. `cw` is used to store the information relevant to these newly introduced variables. +""" +function rewrite_ifs(cw::CondRewriter, expr, dep) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + # non-symbolic expression might still be an array of symbolic expressions + is_array_of_symbolics(expr) || return expr + return map(ex -> rewrite_ifs(cw, ex, dep), expr) + end + + iscall(expr) || return expr + op = operation(expr) + args = arguments(expr) + # do not search into `no_if_lift` + op == no_if_lift && return expr + + # transform `ifelse` + if op == ifelse + cond, iftrue, iffalse = args + + (rw_cond, deptrue, depfalse) = cw(cond, dep) + rw_iftrue = rewrite_ifs(cw, iftrue, deptrue) + rw_iffalse = rewrite_ifs(cw, iffalse, depfalse) + return maketerm( + typeof(expr), ifelse, [unwrap(rw_cond), rw_iftrue, rw_iffalse], metadata(expr)) + end + + # recurse into the rest of the cases + args = map(ex -> rewrite_ifs(cw, ex, dep), args) + return maketerm(typeof(expr), op, args, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Return a modified `expr` where functions with known discontinuities or discontinuous +derivatives are transformed into `ifelse` statements. Utilizes the discontinuity API +in Symbolics. See [`Symbolics.rootfunction`](@ref), +[`Symbolics.left_continuous_function`](@ref), [`Symbolics.right_continuous_function`](@ref). + +`iv` is the independent variable of the system. Only subexpressions of `expr` which +depend on `iv` are transformed. +""" +function discontinuities_to_ifelse(expr, iv) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + # non-symbolic expression might still be an array of symbolic expressions + is_array_of_symbolics(expr) || return expr + return map(ex -> discontinuities_to_ifelse(ex, iv), expr) + end + + iscall(expr) || return expr + op = operation(expr) + args = arguments(expr) + # do not search into `no_if_lift` + op == no_if_lift && return expr + + # Case I: the operation is symbolic. + # We don't actually care if this is a callable parameter or not. + # If it is, we want to search inside and perform if-lifting there. + # If it isn't, either it's `x(t)` in which case this recursion is + # effectively a no-op OR it's `x(f(t))` for DDEs and we want to + # perform if-lifting inside. + # + # Case II: the operation is not symbolic. + # We anyway want to recursively apply the transformation. + # + # Thus, we can do this here regardless of the subsequent checks + args = map(ex -> discontinuities_to_ifelse(ex, iv), args) + + # if the operation is a known discontinuity + if hasmethod(Symbolics.rootfunction, Tuple{typeof(op)}) + rootfn = Symbolics.rootfunction(op) + leftfn = Symbolics.left_continuous_function(op) + rightfn = Symbolics.right_continuous_function(op) + rootexpr = rootfn(args...) < 0 + leftexpr = leftfn(args...) + rightexpr = rightfn(args...) + return maketerm( + typeof(expr), ifelse, [rootexpr, leftexpr, rightexpr], metadata(expr)) + end + + return maketerm(typeof(expr), op, args, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Generate the symbolic condition for discrete variable `sym`, which represents the condition +of an `ifelse` statement created through [`IfLifting`](@ref). This condition is used to +trigger a callback which updates the value of the condition appropriately. +""" +function generate_condition(cw::CondRewriter, sym) + (dep, expr) = cw.conditions[sym] + + # expr is `f(args...) < 0`, `f(args...)` is the zero-crossing expression + zero_crossing = arguments(expr)[1] + + # if we're meant to evaluate the condition, evaluate it. Otherwise, return `NaN`. + # the solvers don't treat the transition from a number to NaN or back as a zero-crossing, + # so it can be used to effectively disable the affect when the condition is not meant to + # be evaluated. + return ifelse(dep, zero_crossing, NaN) ~ 0 +end + +""" + $(TYPEDSIGNATURES) + +Generate the upcrossing and downcrossing affect functions for discrete variable `sym` involved +in `ifelse` statements that are lifted to callbacks using [`IfLifting`](@ref). `syms` is a +condition variable introduced by `cw`, and is thus a key in `cw.conditions`. `new_cond_vars` +is the list of all such new condition variables, corresponding to the order of vertices in +`new_cond_vars_graph`. `new_cond_vars_graph` is a directed graph where edges denote the +condition variables involved in the dependency expression of the source vertex. +""" +function generate_affects(cw::CondRewriter, sym, new_cond_vars, new_cond_vars_graph) + sym_idx = findfirst(isequal(sym), new_cond_vars) + if sym_idx === nothing + throw(ArgumentError("Expected variable $sym to be a condition variable in $new_cond_vars.")) + end + # use reverse direction of edges because instead of finding the variables it depends + # on, we want the variables that depend on it + parents = bfs_parents(new_cond_vars_graph, sym_idx; dir = :in) + cond_vars_to_update = [new_cond_vars[i] + for i in eachindex(parents) if !iszero(parents[i])] + update_syms = Symbol.(cond_vars_to_update) + modified = NamedTuple{(update_syms...,)}(cond_vars_to_update) + + upcrossing_update_exprs = [arguments(last(cw.conditions[sym]))[1] < 0 + for sym in cond_vars_to_update] + upcrossing = ImperativeAffect( + modified, observed = NamedTuple{(update_syms...,)}(upcrossing_update_exprs), + skip_checks = true) do x, o, c, i + return o + end + downcrossing_update_exprs = [arguments(last(cw.conditions[sym]))[1] <= 0 + for sym in cond_vars_to_update] + downcrossing = ImperativeAffect( + modified, observed = NamedTuple{(update_syms...,)}(downcrossing_update_exprs), + skip_checks = true) do x, o, c, i + return o + end + + return upcrossing, downcrossing +end + +const CONDITION_SIMPLIFIER = Rewriters.Fixpoint(Rewriters.Postwalk(Rewriters.Chain([ + # simple boolean laws + (@rule (!!(~x)) => (~x)) + (@rule ((~x) & true) => (~x)) + (@rule ((~x) & false) => false) + (@rule ((~x) | true) => true) + (@rule ((~x) | false) => (~x)) + (@rule ((~x) & !(~x)) => false) + (@rule ((~x) | !(~x)) => true) + # reversed order of the above, because it matters and `@acrule` refuses to do its job + (@rule (true & (~x)) => (~x)) + (@rule (false & (~x)) => false) + (@rule (true | (~x)) => true) + (@rule (false | (~x)) => (~x)) + (@rule (!(~x) & (~x)) => false) + (@rule (!(~x) | (~x)) => true) + # idempotent + (@rule ((~x) & (~x)) => (~x)) + (@rule ((~x) | (~x)) => (~x)) + # ifelse with determined branches + (@rule ifelse((~x), true, false) => (~x)) + (@rule ifelse((~x), false, true) => !(~x)) + # ifelse with identical branches + (@rule ifelse((~x), (~y), (~y)) => (~y)) + (@rule ifelse((~x), (~y), !(~y)) => ((~x) & + (~y))) + (@rule ifelse((~x), !(~y), (~y)) => ((~x) & + !(~y))) + # ifelse with determined condition + (@rule ifelse(true, (~x), (~y)) => (~x)) + (@rule ifelse(false, (~x), (~y)) => (~y))]))) + +""" +If lifting converts (nested) if statements into a series of continuous events + a logically equivalent if statement + parameters. + +Lifting proceeds through the following process: +* rewrite comparisons to be of the form eqn [op] 0; subtract the RHS from the LHS +* replace comparisons with generated parameters; for each comparison eqn [op] 0, generate an event (dependent on op) that sets the parameter +""" +function IfLifting(sys::ODESystem) + cw = CondRewriter(get_iv(sys)) + + eqs = copy(equations(sys)) + obs = copy(observed(sys)) + + # get variables used by `eqs` + syms = vars(eqs) + # get observed equations used by `eqs` + obs_idxs = observed_equations_used_by(sys, eqs; involved_vars = syms) + # and the variables used in those equations + for i in obs_idxs + vars!(syms, obs[i]) + end + + # get all integral variables used in conditions + # this is used when performing the transformation on observed equations + # since they are transformed differently depending on whether they are + # discrete variables involved in a condition or not + condition_vars = Set() + # searcher struct + # we can use the same one since it avoids iterating over duplicates + vars_in_condition! = VarsUsedInCondition() + for i in eachindex(eqs) + eq = eqs[i] + vars_in_condition!(eq.rhs) + # also transform the equation + eqs[i] = eq.lhs ~ rewrite_ifs(cw, discontinuities_to_ifelse(eq.rhs, cw.iv), true) + end + # also search through relevant observed equations + for i in obs_idxs + vars_in_condition!(obs[i].rhs) + end + # add to `condition_vars` after filtering out differential, parameter, independent and + # non-integral variables + for v in vars_in_condition!.vars + v = unwrap(v) + stype = symtype(v) + if isdifferential(v) || isparameter(v) || isequal(v, get_iv(sys)) + continue + end + stype <: Union{Integer, AbstractArray{Integer}} && push!(condition_vars, v) + end + # transform observed equations + for i in obs_idxs + obs[i] = if obs[i].lhs in condition_vars + obs[i].lhs ~ first(cw(discontinuities_to_ifelse(obs[i].rhs, cw.iv), true)) + else + obs[i].lhs ~ rewrite_ifs(cw, discontinuities_to_ifelse(obs[i].rhs, cw.iv), true) + end + end + + # `rewrite_ifs` and calling `cw` generate a lot of redundant code, simplify it + eqs = map(eqs) do eq + eq.lhs ~ CONDITION_SIMPLIFIER(eq.rhs) + end + obs = map(obs) do eq + eq.lhs ~ CONDITION_SIMPLIFIER(eq.rhs) + end + # also simplify dependencies + for (k, v) in cw.conditions + cw.conditions[k] = map(CONDITION_SIMPLIFIER ∘ unwrap, v) + end + + # get directed graph where nodes are the new condition variables and edges from each + # node denote the condition variables used in it's dependency expression + + # so we have an ordering for the vertices + new_cond_vars = collect(keys(cw.conditions)) + # "observed" equations + new_cond_dep_eqs = [v ~ cw.conditions[v] for v in new_cond_vars] + # construct the graph as a `DiCMOBiGraph` + new_cond_vars_graph = observed_dependency_graph(new_cond_dep_eqs) + + new_callbacks = continuous_events(sys) + new_defaults = defaults(sys) + new_ps = Vector{SymbolicParam}(parameters(sys)) + + for var in new_cond_vars + condition = generate_condition(cw, var) + up_affect, down_affect = generate_affects( + cw, var, new_cond_vars, new_cond_vars_graph) + cb = SymbolicContinuousCallback([condition], up_affect; affect_neg = down_affect, + initialize = up_affect, rootfind = SciMLBase.RightRootFind) + + push!(new_callbacks, cb) + new_defaults[var] = getdefault(var) + push!(new_ps, var) + end + + @set! sys.defaults = new_defaults + @set! sys.eqs = eqs + # do not need to topsort because we didn't modify the order + @set! sys.observed = obs + @set! sys.continuous_events = new_callbacks + @set! sys.ps = new_ps + return sys +end diff --git a/src/systems/imperative_affect.jl b/src/systems/imperative_affect.jl new file mode 100644 index 0000000000..4c9ff3d248 --- /dev/null +++ b/src/systems/imperative_affect.jl @@ -0,0 +1,240 @@ + +""" + ImperativeAffect(f::Function; modified::NamedTuple, observed::NamedTuple, ctx) + +`ImperativeAffect` is a helper for writing affect functions that will compute observed values and +ensure that modified values are correctly written back into the system. The affect function `f` needs to have +the signature + +``` + f(modified::NamedTuple, observed::NamedTuple, ctx, integrator)::NamedTuple +``` + +The function `f` will be called with `observed` and `modified` `NamedTuple`s that are derived from their respective `NamedTuple` definitions. +Each declaration`NamedTuple` should map an expression to a symbol; for example if we pass `observed=(; x = a + b)` this will alias the result of executing `a+b` in the system as `x` +so the value of `a + b` will be accessible as `observed.x` in `f`. `modified` currently restricts symbolic expressions to only bare variables, so only tuples of the form +`(; x = y)` or `(; x)` (which aliases `x` as itself) are allowed. + +The argument NamedTuples (for instance `(;x=y)`) will be populated with the declared values on function entry; if we require `(;x=y)` in `observed` and `y=2`, for example, +then the NamedTuple `(;x=2)` will be passed as `observed` to the affect function `f`. + +The NamedTuple returned from `f` includes the values to be written back to the system after `f` returns. For example, if we want to update the value of `x` to be the result of `x + y` we could write + + ImperativeAffect(observed=(; x_plus_y = x + y), modified=(; x)) do m, o + @set! m.x = o.x_plus_y + end + +Where we use Setfield to copy the tuple `m` with a new value for `x`, then return the modified value of `m`. All values updated by the tuple must have names originally declared in +`modified`; a runtime error will be produced if a value is written that does not appear in `modified`. The user can dynamically decide not to write a value back by not including it +in the returned tuple, in which case the associated field will not be updated. +""" +@kwdef struct ImperativeAffect + f::Any + obs::Vector + obs_syms::Vector{Symbol} + modified::Vector + mod_syms::Vector{Symbol} + ctx::Any + skip_checks::Bool +end + +function ImperativeAffect(f; + observed::NamedTuple = NamedTuple{()}(()), + modified::NamedTuple = NamedTuple{()}(()), + ctx = nothing, + skip_checks = false) + ImperativeAffect(f, + collect(values(observed)), collect(keys(observed)), + collect(values(modified)), collect(keys(modified)), + ctx, skip_checks) +end +function ImperativeAffect(f, modified::NamedTuple; + observed::NamedTuple = NamedTuple{()}(()), ctx = nothing, skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end +function ImperativeAffect( + f, modified::NamedTuple, observed::NamedTuple; ctx = nothing, skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end +function ImperativeAffect( + f, modified::NamedTuple, observed::NamedTuple, ctx; skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end + +function Base.show(io::IO, mfa::ImperativeAffect) + obs_vals = join(map((ob, nm) -> "$ob => $nm", mfa.obs, mfa.obs_syms), ", ") + mod_vals = join(map((md, nm) -> "$md => $nm", mfa.modified, mfa.mod_syms), ", ") + affect = mfa.f + print(io, + "ImperativeAffect(observed: [$obs_vals], modified: [$mod_vals], affect:$affect)") +end +func(f::ImperativeAffect) = f.f +context(a::ImperativeAffect) = a.ctx +observed(a::ImperativeAffect) = a.obs +observed_syms(a::ImperativeAffect) = a.obs_syms +function discretes(a::ImperativeAffect) + Iterators.filter(ModelingToolkit.isparameter, + Iterators.flatten(Iterators.map( + x -> symbolic_type(x) == NotSymbolic() && x isa AbstractArray ? x : [x], + a.modified))) +end +modified(a::ImperativeAffect) = a.modified +modified_syms(a::ImperativeAffect) = a.mod_syms + +function Base.:(==)(a1::ImperativeAffect, a2::ImperativeAffect) + isequal(a1.f, a2.f) && isequal(a1.obs, a2.obs) && isequal(a1.modified, a2.modified) && + isequal(a1.obs_syms, a2.obs_syms) && isequal(a1.mod_syms, a2.mod_syms) && + isequal(a1.ctx, a2.ctx) +end + +function Base.hash(a::ImperativeAffect, s::UInt) + s = hash(a.f, s) + s = hash(a.obs, s) + s = hash(a.obs_syms, s) + s = hash(a.modified, s) + s = hash(a.mod_syms, s) + hash(a.ctx, s) +end + +namespace_affects(af::ImperativeAffect, s) = namespace_affect(af, s) +function namespace_affect(affect::ImperativeAffect, s) + ImperativeAffect(func(affect), + namespace_expr.(observed(affect), (s,)), + observed_syms(affect), + renamespace.((s,), modified(affect)), + modified_syms(affect), + context(affect), + affect.skip_checks) +end + +function compile_affect(affect::ImperativeAffect, cb, sys, dvs, ps; kwargs...) + compile_user_affect(affect, cb, sys, dvs, ps; kwargs...) +end + +function compile_user_affect(affect::ImperativeAffect, cb, sys, dvs, ps; kwargs...) + #= + Implementation sketch: + generate observed function (oop), should save to a component array under obs_syms + do the same stuff as the normal FA for pars_syms + call the affect method + unpack and apply the resulting values + =# + function check_dups(syms, exprs) # = (syms_dedup, exprs_dedup) + seen = Set{Symbol}() + syms_dedup = [] + exprs_dedup = [] + for (sym, exp) in Iterators.zip(syms, exprs) + if !in(sym, seen) + push!(syms_dedup, sym) + push!(exprs_dedup, exp) + push!(seen, sym) + elseif !affect.skip_checks + @warn "Expression $(expr) is aliased as $sym, which has already been used. The first definition will be used." + end + end + return (syms_dedup, exprs_dedup) + end + + obs_exprs = observed(affect) + if !affect.skip_checks + for oexpr in obs_exprs + invalid_vars = invalid_variables(sys, oexpr) + if length(invalid_vars) > 0 + error("Observed equation $(oexpr) in affect refers to missing variable(s) $(invalid_vars); the variables may not have been added (e.g. if a component is missing).") + end + end + end + obs_syms = observed_syms(affect) + obs_syms, obs_exprs = check_dups(obs_syms, obs_exprs) + + mod_exprs = modified(affect) + if !affect.skip_checks + for mexpr in mod_exprs + if !check_assignable(sys, mexpr) + @warn ("Expression $mexpr cannot be assigned to; currently only unknowns and parameters may be updated by an affect.") + end + invalid_vars = unassignable_variables(sys, mexpr) + if length(invalid_vars) > 0 + error("Modified equation $(mexpr) in affect refers to missing variable(s) $(invalid_vars); the variables may not have been added (e.g. if a component is missing) or they may have been reduced away.") + end + end + end + mod_syms = modified_syms(affect) + mod_syms, mod_exprs = check_dups(mod_syms, mod_exprs) + + overlapping_syms = intersect(mod_syms, obs_syms) + if length(overlapping_syms) > 0 && !affect.skip_checks + @warn "The symbols $overlapping_syms are declared as both observed and modified; this is a code smell because it becomes easy to confuse them and assign/not assign a value." + end + + # sanity checks done! now build the data and update function for observed values + mkzero(sz) = + if sz === () + 0.0 + else + zeros(sz) + end + obs_fun = build_explicit_observed_function( + sys, Symbolics.scalarize.(obs_exprs); + mkarray = (es, _) -> MakeTuple(es)) + obs_sym_tuple = (obs_syms...,) + + # okay so now to generate the stuff to assign it back into the system + mod_pairs = mod_exprs .=> mod_syms + mod_names = (mod_syms...,) + mod_og_val_fun = build_explicit_observed_function( + sys, Symbolics.scalarize.(first.(mod_pairs)); + mkarray = (es, _) -> MakeTuple(es)) + + upd_funs = NamedTuple{mod_names}((setu.((sys,), first.(mod_pairs))...,)) + + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + save_idxs = get(ic.callback_to_clocks, cb, Int[]) + else + save_idxs = Int[] + end + + let user_affect = func(affect), ctx = context(affect) + function (integ) + # update the to-be-mutated values; this ensures that if you do a no-op then nothing happens + modvals = mod_og_val_fun(integ.u, integ.p, integ.t) + upd_component_array = NamedTuple{mod_names}(modvals) + + # update the observed values + obs_component_array = NamedTuple{obs_sym_tuple}(obs_fun( + integ.u, integ.p, integ.t)) + + # let the user do their thing + upd_vals = user_affect(upd_component_array, obs_component_array, ctx, integ) + + # write the new values back to the integrator + _generated_writeback(integ, upd_funs, upd_vals) + + for idx in save_idxs + SciMLBase.save_discretes!(integ, idx) + end + end + end +end + +scalarize_affects(affects::ImperativeAffect) = affects + +function vars!(vars, aff::ImperativeAffect; op = Differential) + for var in Iterators.flatten((observed(aff), modified(aff))) + if symbolic_type(var) == NotSymbolic() + if var isa AbstractArray + for v in var + v = unwrap(v) + vars!(vars, v) + end + end + else + var = unwrap(var) + vars!(vars, var) + end + end + return vars +end diff --git a/src/systems/index_cache.jl b/src/systems/index_cache.jl new file mode 100644 index 0000000000..47a784c00b --- /dev/null +++ b/src/systems/index_cache.jl @@ -0,0 +1,701 @@ +struct BufferTemplate + type::Union{DataType, UnionAll} + length::Int +end + +function BufferTemplate(s::Type{<:Symbolics.Struct}, length::Int) + T = Symbolics.juliatype(s) + BufferTemplate(T, length) +end + +struct Nonnumeric <: SciMLStructures.AbstractPortion end +const NONNUMERIC_PORTION = Nonnumeric() + +struct ParameterIndex{P, I} + portion::P + idx::I + validate_size::Bool +end + +ParameterIndex(portion, idx) = ParameterIndex(portion, idx, false) +ParameterIndex(p::ParameterIndex) = ParameterIndex(p.portion, p.idx, false) + +struct DiscreteIndex + # of all buffers corresponding to types, which one + buffer_idx::Int + # Index in the above buffer + idx_in_buffer::Int + # Which clock (corresponds to Block of BlockedArray) + clock_idx::Int + # Which index in `buffer[Block(clockidx)]` + idx_in_clock::Int +end + +const ParamIndexMap = Dict{BasicSymbolic, Tuple{Int, Int}} +const NonnumericMap = Dict{ + Union{BasicSymbolic, Symbolics.CallWithMetadata}, Tuple{Int, Int}} +const UnknownIndexMap = Dict{ + BasicSymbolic, Union{Int, UnitRange{Int}, AbstractArray{Int}}} +const TunableIndexMap = Dict{BasicSymbolic, + Union{Int, UnitRange{Int}, Base.ReshapedArray{Int, N, UnitRange{Int}} where {N}}} +const TimeseriesSetType = Set{Union{ContinuousTimeseries, Int}} + +const SymbolicParam = Union{BasicSymbolic, CallWithMetadata} + +struct IndexCache + unknown_idx::UnknownIndexMap + # sym => (bufferidx, idx_in_buffer) + discrete_idx::Dict{SymbolicParam, DiscreteIndex} + # sym => (clockidx, idx_in_clockbuffer) + callback_to_clocks::Dict{Any, Vector{Int}} + tunable_idx::TunableIndexMap + initials_idx::TunableIndexMap + constant_idx::ParamIndexMap + nonnumeric_idx::NonnumericMap + observed_syms_to_timeseries::Dict{BasicSymbolic, TimeseriesSetType} + dependent_pars_to_timeseries::Dict{ + Union{BasicSymbolic, CallWithMetadata}, TimeseriesSetType} + discrete_buffer_sizes::Vector{Vector{BufferTemplate}} + tunable_buffer_size::BufferTemplate + initials_buffer_size::BufferTemplate + constant_buffer_sizes::Vector{BufferTemplate} + nonnumeric_buffer_sizes::Vector{BufferTemplate} + symbol_to_variable::Dict{Symbol, SymbolicParam} +end + +function IndexCache(sys::AbstractSystem) + unks = solved_unknowns(sys) + unk_idxs = UnknownIndexMap() + symbol_to_variable = Dict{Symbol, SymbolicParam}() + + let idx = 1 + for sym in unks + usym = unwrap(sym) + rsym = renamespace(sys, usym) + sym_idx = if Symbolics.isarraysymbolic(sym) + reshape(idx:(idx + length(sym) - 1), size(sym)) + else + idx + end + unk_idxs[usym] = sym_idx + unk_idxs[rsym] = sym_idx + idx += length(sym) + end + for sym in unks + usym = unwrap(sym) + iscall(sym) && operation(sym) === getindex || continue + arrsym = arguments(sym)[1] + all(haskey(unk_idxs, arrsym[i]) for i in eachindex(arrsym)) || continue + + idxs = [unk_idxs[arrsym[i]] for i in eachindex(arrsym)] + if idxs == idxs[begin]:idxs[end] + idxs = reshape(idxs[begin]:idxs[end], size(idxs)) + end + rsym = renamespace(sys, arrsym) + unk_idxs[arrsym] = idxs + unk_idxs[rsym] = idxs + end + end + + tunable_buffers = Dict{Any, Set{BasicSymbolic}}() + initial_param_buffers = Dict{Any, Set{BasicSymbolic}}() + constant_buffers = Dict{Any, Set{BasicSymbolic}}() + nonnumeric_buffers = Dict{Any, Set{SymbolicParam}}() + + function insert_by_type!(buffers::Dict{Any, S}, sym, ctype) where {S} + sym = unwrap(sym) + buf = get!(buffers, ctype, S()) + push!(buf, sym) + end + + disc_param_callbacks = Dict{SymbolicParam, Set{Int}}() + events = vcat(continuous_events(sys), discrete_events(sys)) + for (i, event) in enumerate(events) + discs = Set{SymbolicParam}() + affs = affects(event) + if !(affs isa AbstractArray) + affs = [affs] + end + for affect in affs + if affect isa Equation + is_parameter(sys, affect.lhs) && push!(discs, affect.lhs) + elseif affect isa FunctionalAffect || affect isa ImperativeAffect + union!(discs, unwrap.(discretes(affect))) + else + error("Unhandled affect type $(typeof(affect))") + end + end + + for sym in discs + is_parameter(sys, sym) || + error("Expected discrete variable $sym in callback to be a parameter") + + # Only `foo(t)`-esque parameters can be saved + if iscall(sym) && length(arguments(sym)) == 1 && + isequal(only(arguments(sym)), get_iv(sys)) + clocks = get!(() -> Set{Int}(), disc_param_callbacks, sym) + push!(clocks, i) + elseif is_variable_floatingpoint(sym) + insert_by_type!(constant_buffers, sym, symtype(sym)) + else + stype = symtype(sym) + if stype <: FnType + stype = fntype_to_function_type(stype) + end + insert_by_type!(nonnumeric_buffers, sym, stype) + end + end + end + clock_partitions = unique(collect(values(disc_param_callbacks))) + disc_symtypes = unique(symtype.(keys(disc_param_callbacks))) + disc_symtype_idx = Dict(disc_symtypes .=> eachindex(disc_symtypes)) + disc_syms_by_symtype = [SymbolicParam[] for _ in disc_symtypes] + for sym in keys(disc_param_callbacks) + push!(disc_syms_by_symtype[disc_symtype_idx[symtype(sym)]], sym) + end + disc_syms_by_symtype_by_partition = [Vector{SymbolicParam}[] for _ in disc_symtypes] + for (i, buffer) in enumerate(disc_syms_by_symtype) + for partition in clock_partitions + push!(disc_syms_by_symtype_by_partition[i], + [sym for sym in buffer if disc_param_callbacks[sym] == partition]) + end + end + disc_idxs = Dict{SymbolicParam, DiscreteIndex}() + callback_to_clocks = Dict{ + Union{SymbolicContinuousCallback, SymbolicDiscreteCallback}, Set{Int}}() + for (typei, disc_syms_by_partition) in enumerate(disc_syms_by_symtype_by_partition) + symi = 0 + for (parti, disc_syms) in enumerate(disc_syms_by_partition) + for clockidx in clock_partitions[parti] + buffer = get!(() -> Set{Int}(), callback_to_clocks, events[clockidx]) + push!(buffer, parti) + end + clocki = 0 + for sym in disc_syms + symi += 1 + clocki += 1 + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for cursym in (sym, ttsym, rsym, rttsym) + disc_idxs[cursym] = DiscreteIndex(typei, symi, parti, clocki) + end + end + end + end + callback_to_clocks = Dict{ + Union{SymbolicContinuousCallback, SymbolicDiscreteCallback}, Vector{Int}}(k => collect(v) + for (k, v) in callback_to_clocks) + + disc_buffer_templates = Vector{BufferTemplate}[] + for (symtype, disc_syms_by_partition) in zip( + disc_symtypes, disc_syms_by_symtype_by_partition) + push!(disc_buffer_templates, + [BufferTemplate(symtype, length(buf)) for buf in disc_syms_by_partition]) + end + + for p in parameters(sys; initial_parameters = true) + p = unwrap(p) + ctype = symtype(p) + if ctype <: FnType + ctype = fntype_to_function_type(ctype) + end + haskey(disc_idxs, p) && continue + haskey(constant_buffers, ctype) && p in constant_buffers[ctype] && continue + haskey(nonnumeric_buffers, ctype) && p in nonnumeric_buffers[ctype] && continue + insert_by_type!( + if ctype <: Real || ctype <: AbstractArray{<:Real} + if istunable(p, true) && Symbolics.shape(p) != Symbolics.Unknown() && + (ctype == Real || ctype <: AbstractFloat || + ctype <: AbstractArray{Real} || + ctype <: AbstractArray{<:AbstractFloat}) + if iscall(p) && operation(p) isa Initial + initial_param_buffers + else + tunable_buffers + end + else + constant_buffers + end + else + nonnumeric_buffers + end, + p, + ctype + ) + end + + function get_buffer_sizes_and_idxs(T, buffers::Dict) + idxs = T() + buffer_sizes = BufferTemplate[] + for (i, (T, buf)) in enumerate(buffers) + for (j, p) in enumerate(buf) + ttp = default_toterm(p) + rp = renamespace(sys, p) + rttp = renamespace(sys, ttp) + idxs[p] = (i, j) + idxs[ttp] = (i, j) + idxs[rp] = (i, j) + idxs[rttp] = (i, j) + end + if T <: Symbolics.FnType + T = Any + end + push!(buffer_sizes, BufferTemplate(T, length(buf))) + end + return idxs, buffer_sizes + end + + const_idxs, const_buffer_sizes = get_buffer_sizes_and_idxs( + ParamIndexMap, constant_buffers) + nonnumeric_idxs, nonnumeric_buffer_sizes = get_buffer_sizes_and_idxs( + NonnumericMap, nonnumeric_buffers) + + tunable_idxs = TunableIndexMap() + tunable_buffer_size = 0 + bufferlist = is_initializesystem(sys) ? (tunable_buffers, initial_param_buffers) : + (tunable_buffers,) + for buffers in bufferlist + for (i, (_, buf)) in enumerate(buffers) + for (j, p) in enumerate(buf) + idx = if size(p) == () + tunable_buffer_size + 1 + else + reshape( + (tunable_buffer_size + 1):(tunable_buffer_size + length(p)), size(p)) + end + tunable_buffer_size += length(p) + tunable_idxs[p] = idx + tunable_idxs[default_toterm(p)] = idx + if hasname(p) && (!iscall(p) || operation(p) !== getindex) + symbol_to_variable[getname(p)] = p + symbol_to_variable[getname(default_toterm(p))] = p + end + end + end + end + + initials_idxs = TunableIndexMap() + initials_buffer_size = 0 + if !is_initializesystem(sys) + for (i, (_, buf)) in enumerate(initial_param_buffers) + for (j, p) in enumerate(buf) + idx = if size(p) == () + initials_buffer_size + 1 + else + reshape( + (initials_buffer_size + 1):(initials_buffer_size + length(p)), size(p)) + end + initials_buffer_size += length(p) + initials_idxs[p] = idx + initials_idxs[default_toterm(p)] = idx + if hasname(p) && (!iscall(p) || operation(p) !== getindex) + symbol_to_variable[getname(p)] = p + symbol_to_variable[getname(default_toterm(p))] = p + end + end + end + end + + for k in collect(keys(tunable_idxs)) + v = tunable_idxs[k] + v isa AbstractArray || continue + for (kk, vv) in zip(collect(k), v) + tunable_idxs[kk] = vv + end + end + for k in collect(keys(initials_idxs)) + v = initials_idxs[k] + v isa AbstractArray || continue + for (kk, vv) in zip(collect(k), v) + initials_idxs[kk] = vv + end + end + + dependent_pars_to_timeseries = Dict{ + Union{BasicSymbolic, CallWithMetadata}, TimeseriesSetType}() + + for eq in parameter_dependencies(sys) + sym = eq.lhs + vs = vars(eq.rhs) + timeseries = TimeseriesSetType() + if is_time_dependent(sys) + for v in vs + if (idx = get(disc_idxs, v, nothing)) !== nothing + push!(timeseries, idx.clock_idx) + end + end + end + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + dependent_pars_to_timeseries[s] = timeseries + if hasname(s) && (!iscall(s) || operation(s) != getindex) + symbol_to_variable[getname(s)] = sym + end + end + end + + observed_syms_to_timeseries = Dict{BasicSymbolic, TimeseriesSetType}() + for eq in observed(sys) + if symbolic_type(eq.lhs) != NotSymbolic() + sym = eq.lhs + vs = vars(eq.rhs; op = Nothing) + timeseries = TimeseriesSetType() + if is_time_dependent(sys) + for v in vs + if (idx = get(disc_idxs, v, nothing)) !== nothing + push!(timeseries, idx.clock_idx) + elseif iscall(v) && operation(v) === getindex && + (idx = get(disc_idxs, arguments(v)[1], nothing)) !== nothing + push!(timeseries, idx.clock_idx) + elseif haskey(observed_syms_to_timeseries, v) + union!(timeseries, observed_syms_to_timeseries[v]) + elseif haskey(dependent_pars_to_timeseries, v) + union!(timeseries, dependent_pars_to_timeseries[v]) + end + end + if isempty(timeseries) + push!(timeseries, ContinuousTimeseries()) + end + end + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + observed_syms_to_timeseries[s] = timeseries + end + end + end + + for sym in Iterators.flatten((keys(unk_idxs), keys(disc_idxs), keys(tunable_idxs), + keys(const_idxs), keys(nonnumeric_idxs), + keys(observed_syms_to_timeseries), independent_variable_symbols(sys))) + if hasname(sym) && (!iscall(sym) || operation(sym) !== getindex) + symbol_to_variable[getname(sym)] = sym + end + end + + return IndexCache( + unk_idxs, + disc_idxs, + callback_to_clocks, + tunable_idxs, + initials_idxs, + const_idxs, + nonnumeric_idxs, + observed_syms_to_timeseries, + dependent_pars_to_timeseries, + disc_buffer_templates, + BufferTemplate(Real, tunable_buffer_size), + BufferTemplate(Real, initials_buffer_size), + const_buffer_sizes, + nonnumeric_buffer_sizes, + symbol_to_variable + ) +end + +function SymbolicIndexingInterface.is_variable(ic::IndexCache, sym) + variable_index(ic, sym) !== nothing +end + +function SymbolicIndexingInterface.variable_index(ic::IndexCache, sym) + if sym isa Symbol + sym = get(ic.symbol_to_variable, sym, nothing) + sym === nothing && return nothing + end + idx = check_index_map(ic.unknown_idx, sym) + idx === nothing || return idx + iscall(sym) && operation(sym) == getindex || return nothing + args = arguments(sym) + idx = variable_index(ic, args[1]) + idx === nothing && return nothing + return idx[args[2:end]...] +end + +function SymbolicIndexingInterface.is_parameter(ic::IndexCache, sym) + parameter_index(ic, sym) !== nothing +end + +function SymbolicIndexingInterface.parameter_index(ic::IndexCache, sym) + if sym isa Symbol + sym = get(ic.symbol_to_variable, sym, nothing) + sym === nothing && return nothing + end + sym = unwrap(sym) + validate_size = Symbolics.isarraysymbolic(sym) && symtype(sym) <: AbstractArray && + Symbolics.shape(sym) !== Symbolics.Unknown() + return if (idx = check_index_map(ic.tunable_idx, sym)) !== nothing + ParameterIndex(SciMLStructures.Tunable(), idx, validate_size) + elseif (idx = check_index_map(ic.initials_idx, sym)) !== nothing + ParameterIndex(SciMLStructures.Initials(), idx, validate_size) + elseif (idx = check_index_map(ic.discrete_idx, sym)) !== nothing + ParameterIndex( + SciMLStructures.Discrete(), (idx.buffer_idx, idx.idx_in_buffer), validate_size) + elseif (idx = check_index_map(ic.constant_idx, sym)) !== nothing + ParameterIndex(SciMLStructures.Constants(), idx, validate_size) + elseif (idx = check_index_map(ic.nonnumeric_idx, sym)) !== nothing + ParameterIndex(NONNUMERIC_PORTION, idx, validate_size) + elseif iscall(sym) && operation(sym) == getindex + args = arguments(sym) + pidx = parameter_index(ic, args[1]) + pidx === nothing && return nothing + if pidx.portion == SciMLStructures.Tunable() + ParameterIndex(pidx.portion, + Origin(first.(axes((args[1]))))(reshape(pidx.idx, size(args[1])))[args[2:end]...], + pidx.validate_size) + else + ParameterIndex(pidx.portion, (pidx.idx..., args[2:end]...), pidx.validate_size) + end + end +end + +function SymbolicIndexingInterface.is_timeseries_parameter(ic::IndexCache, sym) + timeseries_parameter_index(ic, sym) !== nothing +end + +function SymbolicIndexingInterface.timeseries_parameter_index(ic::IndexCache, sym) + if sym isa Symbol + sym = get(ic.symbol_to_variable, sym, nothing) + sym === nothing && return nothing + end + sym = unwrap(sym) + idx = check_index_map(ic.discrete_idx, sym) + idx === nothing || + return ParameterTimeseriesIndex(idx.clock_idx, (idx.buffer_idx, idx.idx_in_clock)) + iscall(sym) && operation(sym) == getindex || return nothing + args = arguments(sym) + idx = timeseries_parameter_index(ic, args[1]) + idx === nothing && return nothing + return ParameterTimeseriesIndex( + idx.timeseries_idx, (idx.parameter_idx..., args[2:end]...)) +end + +function check_index_map(idxmap, sym) + if (idx = get(idxmap, sym, nothing)) !== nothing + return idx + elseif !isa(sym, Symbol) && (!iscall(sym) || operation(sym) !== getindex) && + hasname(sym) && (idx = get(idxmap, getname(sym), nothing)) !== nothing + return idx + end + dsym = default_toterm(sym) + isequal(sym, dsym) && return nothing + if (idx = get(idxmap, dsym, nothing)) !== nothing + idx + elseif !isa(dsym, Symbol) && (!iscall(dsym) || operation(dsym) !== getindex) && + hasname(dsym) && (idx = get(idxmap, getname(dsym), nothing)) !== nothing + idx + else + nothing + end +end + +function reorder_parameters( + sys::AbstractSystem, ps = parameters(sys; initial_parameters = true); kwargs...) + if has_index_cache(sys) && get_index_cache(sys) !== nothing + reorder_parameters(get_index_cache(sys), ps; kwargs...) + elseif ps isa Tuple + ps + else + (ps,) + end +end + +function reorder_parameters(ic::IndexCache, ps; drop_missing = false) + isempty(ps) && return () + param_buf = if ic.tunable_buffer_size.length == 0 + () + else + (BasicSymbolic[unwrap(variable(:DEF)) + for _ in 1:(ic.tunable_buffer_size.length)],) + end + initials_buf = if ic.initials_buffer_size.length == 0 + () + else + (BasicSymbolic[unwrap(variable(:DEF)) + for _ in 1:(ic.initials_buffer_size.length)],) + end + + disc_buf = Tuple(BasicSymbolic[unwrap(variable(:DEF)) + for _ in 1:(sum(x -> x.length, temp))] + for temp in ic.discrete_buffer_sizes) + const_buf = Tuple(BasicSymbolic[unwrap(variable(:DEF)) for _ in 1:(temp.length)] + for temp in ic.constant_buffer_sizes) + nonnumeric_buf = Tuple(Union{BasicSymbolic, CallWithMetadata}[unwrap(variable(:DEF)) + for _ in 1:(temp.length)] + for temp in ic.nonnumeric_buffer_sizes) + for p in ps + p = unwrap(p) + if haskey(ic.discrete_idx, p) + idx = ic.discrete_idx[p] + disc_buf[idx.buffer_idx][idx.idx_in_buffer] = p + elseif haskey(ic.tunable_idx, p) + i = ic.tunable_idx[p] + if i isa Int + param_buf[1][i] = unwrap(p) + else + param_buf[1][i] = unwrap.(collect(p)) + end + elseif haskey(ic.initials_idx, p) + i = ic.initials_idx[p] + if i isa Int + initials_buf[1][i] = unwrap(p) + else + initials_buf[1][i] = unwrap.(collect(p)) + end + elseif haskey(ic.constant_idx, p) + i, j = ic.constant_idx[p] + const_buf[i][j] = p + elseif haskey(ic.nonnumeric_idx, p) + i, j = ic.nonnumeric_idx[p] + nonnumeric_buf[i][j] = p + else + error("Invalid parameter $p") + end + end + + result = broadcast.( + unwrap, ( + param_buf..., initials_buf..., disc_buf..., const_buf..., nonnumeric_buf...)) + if drop_missing + result = map(result) do buf + filter(buf) do sym + return !isequal(sym, unwrap(variable(:DEF))) + end + end + end + if all(isempty, result) + return () + end + return result +end + +# Given a parameter index, find the index of the buffer it is in when +# `MTKParameters` is iterated +function iterated_buffer_index(ic::IndexCache, ind::ParameterIndex) + idx = 0 + if ind.portion isa SciMLStructures.Tunable + return idx + 1 + elseif ic.tunable_buffer_size.length > 0 + idx += 1 + end + if ind.portion isa SciMLStructures.Initials + return idx + 1 + elseif ic.initials_buffer_size.length > 0 + idx += 1 + end + if ind.portion isa SciMLStructures.Discrete + return idx + ind.idx[1] + elseif !isempty(ic.discrete_buffer_sizes) + idx += length(ic.discrete_buffer_sizes) + end + if ind.portion isa SciMLStructures.Constants + return idx + ind.idx[1] + elseif !isempty(ic.constant_buffer_sizes) + idx += length(ic.constant_buffer_sizes) + end + if ind.portion == NONNUMERIC_PORTION + return idx + ind.idx[1] + end + error("Unhandled portion $(ind.portion)") +end + +function get_buffer_template(ic::IndexCache, pidx::ParameterIndex) + (; portion, idx) = pidx + + if portion isa SciMLStructures.Tunable + return ic.tunable_buffer_size + elseif portion isa SciMLStructures.Initials + return ic.initials_buffer_size + elseif portion isa SciMLStructures.Discrete + return ic.discrete_buffer_sizes[idx[1]][1] + elseif portion isa SciMLStructures.Constants + return ic.constant_buffer_sizes[idx[1]] + elseif portion isa Nonnumeric + return ic.nonnumeric_buffer_sizes[idx[1]] + else + error("Unhandled portion $portion") + end +end + +fntype_to_function_type(::Type{FnType{A, R, T}}) where {A, R, T} = T +fntype_to_function_type(::Type{FnType{A, R, Nothing}}) where {A, R} = FunctionWrapper{R, A} +fntype_to_function_type(::Type{FnType{A, R}}) where {A, R} = FunctionWrapper{R, A} + +""" + reorder_dimension_by_tunables!(dest::AbstractArray, sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + +Assuming the order of values in dimension `dim` of `arr` correspond to the order of tunable +parameters in the system, reorder them according to the order described in `syms`. `syms` must +be a permutation of `tunable_parameters(sys)`. The result is written to `dest`. The `size` of `dest` and +`arr` must be equal. Return `dest`. + +See also: [`MTKParameters`](@ref), [`tunable_parameters`](@ref), [`reorder_dimension_by_tunables`](@ref). +""" +function reorder_dimension_by_tunables!( + dest::AbstractArray, sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + if !iscomplete(sys) + throw(ArgumentError("A completed system is required. Call `complete` or `structural_simplify` on the system.")) + end + if !has_index_cache(sys) || (ic = get_index_cache(sys)) === nothing + throw(ArgumentError("The system does not have an index cache. Call `complete` or `structural_simplify` on the system with `split = true`.")) + end + if size(dest) != size(arr) + throw(ArgumentError("Source and destination arrays must have the same size. Got source array with size $(size(arr)) and destination with size $(size(dest)).")) + end + + dsti = 1 + for sym in syms + idx = parameter_index(ic, sym) + if !(idx.portion isa SciMLStructures.Tunable) + throw(ArgumentError("`syms` must be a permutation of `tunable_parameters(sys)`. Found $sym which is not a tunable parameter.")) + end + + dstidx = ntuple( + i -> i == dim ? (dsti:(dsti + length(sym) - 1)) : (:), Val(ndims(arr))) + destv = @view dest[dstidx...] + dsti += length(sym) + arridx = ntuple(i -> i == dim ? (idx.idx) : (:), Val(ndims(arr))) + srcv = @view arr[arridx...] + copyto!(destv, srcv) + end + return dest +end + +""" + reorder_dimension_by_tunables(sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + +Out-of-place version of [`reorder_dimension_by_tunables!`](@ref). +""" +function reorder_dimension_by_tunables( + sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + buffer = similar(arr) + reorder_dimension_by_tunables!(buffer, sys, arr, syms; dim) + return buffer +end + +function subset_unknowns_observed( + ic::IndexCache, sys::AbstractSystem, newunknowns, newobsvars) + unknown_idx = copy(ic.unknown_idx) + empty!(unknown_idx) + for (i, sym) in enumerate(newunknowns) + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + unknown_idx[sym] = unknown_idx[ttsym] = unknown_idx[rsym] = unknown_idx[rttsym] = i + end + observed_syms_to_timeseries = copy(ic.observed_syms_to_timeseries) + empty!(observed_syms_to_timeseries) + for sym in newobsvars + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + observed_syms_to_timeseries[s] = ic.observed_syms_to_timeseries[sym] + end + end + ic = @set ic.unknown_idx = unknown_idx + @set! ic.observed_syms_to_timeseries = observed_syms_to_timeseries + return ic +end diff --git a/src/systems/jumps/jumpsystem.jl b/src/systems/jumps/jumpsystem.jl index 460191f1bf..57a3aee7df 100644 --- a/src/systems/jumps/jumpsystem.jl +++ b/src/systems/jumps/jumpsystem.jl @@ -1,4 +1,18 @@ -JumpType = Union{VariableRateJump, ConstantRateJump, MassActionJump} +const JumpType = Union{VariableRateJump, ConstantRateJump, MassActionJump} + +# modifies the expression representing an affect function to +# call reset_aggregated_jumps!(integrator). +# assumes iip +function _reset_aggregator!(expr, integrator) + @assert Meta.isexpr(expr, :function) + body = expr.args[end] + body = quote + $body + $reset_aggregated_jumps!($integrator) + end + expr.args[end] = body + return nothing +end """ $(TYPEDEF) @@ -11,10 +25,11 @@ $(FIELDS) # Example ```julia -using ModelingToolkit +using ModelingToolkit, JumpProcesses +using ModelingToolkit: t_nounits as t -@parameters β γ t -@variables S I R +@parameters β γ +@variables S(t) I(t) R(t) rate₁ = β*S*I affect₁ = [S ~ S - 1, I ~ I + 1] rate₂ = γ*I @@ -22,10 +37,15 @@ affect₂ = [I ~ I - 1, R ~ R + 1] j₁ = ConstantRateJump(rate₁,affect₁) j₂ = ConstantRateJump(rate₂,affect₂) j₃ = MassActionJump(2*β+γ, [R => 1], [S => 1, R => -1]) -js = JumpSystem([j₁,j₂,j₃], t, [S,I,R], [β,γ]) +@named js = JumpSystem([j₁,j₂,j₃], t, [S,I,R], [β,γ]) ``` """ -struct JumpSystem{U <: ArrayPartition} <: AbstractSystem +struct JumpSystem{U <: ArrayPartition} <: AbstractTimeDependentSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt """ The jumps of the system. Allowable types are `ConstantRateJump`, `VariableRateJump`, `MassActionJump`. @@ -33,37 +53,169 @@ struct JumpSystem{U <: ArrayPartition} <: AbstractSystem eqs::U """The independent variable, usually time.""" iv::Any - """The dependent variables, representing the state of the system.""" - states::Vector - """The parameters of the system.""" + """The dependent variables, representing the state of the system. Must not contain the independent variable.""" + unknowns::Vector + """The parameters of the system. Must not contain the independent variable.""" ps::Vector + """Array variables.""" + var_to_name::Any + """Observed variables.""" observed::Vector{Equation} """The name of the system.""" name::Symbol - """The internal systems.""" + """A description of the system.""" + description::String + """The internal systems. These are required to have unique names.""" systems::Vector{JumpSystem} """ - defaults: The default values to use when initial conditions and/or + The default values to use when initial conditions and/or parameters are not supplied in `ODEProblem`. """ defaults::Dict """ - type: type of the system + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Type of the system. + """ + connector_type::Any + """ + A `Vector{SymbolicContinuousCallback}` that model events. + The integrator will use root finding to guarantee that it steps at each zero crossing. + """ + continuous_events::Vector{SymbolicContinuousCallback} + """ + A `Vector{SymbolicDiscreteCallback}` that models events. Symbolic + analog to `SciMLBase.DiscreteCallback` that executes an affect when a given condition is + true at the end of an integration step. Note, one must make sure to call + `reset_aggregated_jumps!(integrator)` if using a custom affect function that changes any + unknown value or parameter. + """ + discrete_events::Vector{SymbolicDiscreteCallback} + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. """ - connection_type::Any + index_cache::Union{Nothing, IndexCache} + isscheduled::Bool + + function JumpSystem{U}( + tag, ap::U, iv, unknowns, ps, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, connector_type, + cevents, devents, + parameter_dependencies, metadata = nothing, gui_metadata = nothing, + namespacing = true, complete = false, index_cache = nothing, isscheduled = false; + checks::Union{Bool, Int} = true) where {U <: ArrayPartition} + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(unknowns, iv) + check_parameters(ps, iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(unknowns, ps, iv) + check_units(u, ap, iv) + end + new{U}(tag, ap, iv, unknowns, ps, var_to_name, + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, + connector_type, cevents, devents, parameter_dependencies, metadata, + gui_metadata, namespacing, complete, index_cache, isscheduled) + end +end +function JumpSystem(tag, ap, iv, states, ps, var_to_name, args...; kwargs...) + JumpSystem{typeof(ap)}(tag, ap, iv, states, ps, var_to_name, args...; kwargs...) end -function JumpSystem(eqs, iv, states, ps; - observed = Equation[], - systems = JumpSystem[], - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - name = gensym(:JumpSystem), - connection_type=nothing, - ) - - ap = ArrayPartition(MassActionJump[], ConstantRateJump[], VariableRateJump[]) +function JumpSystem(eqs, iv, unknowns, ps; + observed = Equation[], + systems = JumpSystem[], + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + name = nothing, + description = "", + connector_type = nothing, + checks = true, + continuous_events = nothing, + discrete_events = nothing, + parameter_dependencies = Equation[], + metadata = nothing, + gui_metadata = nothing, + kwargs...) + + # variable processing, similar to ODESystem + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + iv′ = value(iv) + us′ = value.(unknowns) + ps′ = value.(ps) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :JumpSystem, force = true) + end + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, us′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + #! format: off + defaults = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(defaults) if value(v) !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(guesses) if v !== nothing) + #! format: on + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + + # equation processing + # this and the treatment of continuous events are the only part + # unique to JumpSystems + eqs = scalarize.(eqs) + ap = ArrayPartition( + MassActionJump[], ConstantRateJump[], VariableRateJump[], Equation[]) for eq in eqs if eq isa MassActionJump push!(ap.x[1], eq) @@ -71,125 +223,161 @@ function JumpSystem(eqs, iv, states, ps; push!(ap.x[2], eq) elseif eq isa VariableRateJump push!(ap.x[3], eq) + elseif eq isa Equation + push!(ap.x[4], eq) else - error("JumpSystem equations must contain MassActionJumps, ConstantRateJumps, or VariableRateJumps.") + error("JumpSystem equations must contain MassActionJumps, ConstantRateJumps, VariableRateJumps, or Equations.") end end - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :JumpSystem, force=true) - end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - JumpSystem{typeof(ap)}(ap, value(iv), value.(states), value.(ps), observed, name, systems, defaults, connection_type) + cont_callbacks = SymbolicContinuousCallbacks(continuous_events) + disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) + + JumpSystem{typeof(ap)}(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + ap, iv′, us′, ps′, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, connector_type, + cont_callbacks, disc_callbacks, + parameter_dependencies, metadata, gui_metadata, checks = checks) end -function generate_rate_function(js, rate) - rf = build_function(rate, states(js), parameters(js), - independent_variable(js), - conv = states_to_sym(states(js)), - expression=Val{true}) +##### MTK dispatches for JumpSystems ##### +eqtype_supports_collect_vars(j::MassActionJump) = true +function collect_vars!(unknowns, parameters, j::MassActionJump, iv; depth = 0, + op = Differential) + collect_vars!(unknowns, parameters, j.scaled_rates, iv; depth, op) + for field in (j.reactant_stoch, j.net_stoch) + for el in field + collect_vars!(unknowns, parameters, el, iv; depth, op) + end + end + return nothing end -function add_integrator_header() - integrator = gensym(:MTKIntegrator) - expr -> Func([DestructuredArgs(expr.args, integrator, inds=[:u, :p, :t])], [], expr.body), - expr -> Func([DestructuredArgs(expr.args, integrator, inds=[:u, :u, :p, :t])], [], expr.body) +eqtype_supports_collect_vars(j::Union{ConstantRateJump, VariableRateJump}) = true +function collect_vars!(unknowns, parameters, j::Union{ConstantRateJump, VariableRateJump}, + iv; depth = 0, op = Differential) + collect_vars!(unknowns, parameters, j.rate, iv; depth, op) + for eq in j.affect! + (eq isa Equation) && collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + return nothing +end + +########################################## + +has_massactionjumps(js::JumpSystem) = !isempty(equations(js).x[1]) +has_constantratejumps(js::JumpSystem) = !isempty(equations(js).x[2]) +has_variableratejumps(js::JumpSystem) = !isempty(equations(js).x[3]) +has_equations(js::JumpSystem) = !isempty(equations(js).x[4]) + +function generate_rate_function(js::JumpSystem, rate) + consts = collect_constants(rate) + if !isempty(consts) # The SymbolicUtils._build_function method of this case doesn't support postprocess_fbody + csubs = Dict(c => getdefault(c) for c in consts) + rate = substitute(rate, csubs) + end + p = reorder_parameters(js) + build_function_wrapper(js, rate, unknowns(js), p..., + get_iv(js), + expression = Val{true}) end -function generate_affect_function(js, affect, outputidxs) - bf = build_function(map(x->x isa Equation ? x.rhs : x , affect), states(js), - parameters(js), - independent_variable(js), - expression=Val{true}, - wrap_code=add_integrator_header(), - outputidxs=outputidxs)[2] +function generate_affect_function(js::JumpSystem, affect, outputidxs) + consts = collect_constants(affect) + if !isempty(consts) # The SymbolicUtils._build_function method of this case doesn't support postprocess_fbody + csubs = Dict(c => getdefault(c) for c in consts) + affect = substitute(affect, csubs) + end + compile_affect( + affect, nothing, js, unknowns(js), parameters(js); outputidxs = outputidxs, + expression = Val{true}, checkvars = false) end -function assemble_vrj(js, vrj, statetoid) - rate = @RuntimeGeneratedFunction(generate_rate_function(js, vrj.rate)) +function assemble_vrj( + js, vrj, unknowntoid; eval_expression = false, eval_module = @__MODULE__) + rate = eval_or_rgf(generate_rate_function(js, vrj.rate); eval_expression, eval_module) + rate = GeneratedFunctionWrapper{(2, 3, is_split(js))}(rate, nothing) outputvars = (value(affect.lhs) for affect in vrj.affect!) - outputidxs = [statetoid[var] for var in outputvars] - affect = @RuntimeGeneratedFunction(generate_affect_function(js, vrj.affect!, outputidxs)) - VariableRateJump(rate, affect) + outputidxs = [unknowntoid[var] for var in outputvars] + affect = eval_or_rgf(generate_affect_function(js, vrj.affect!, outputidxs); + eval_expression, eval_module) + VariableRateJump(rate, affect; save_positions = vrj.save_positions) end -function assemble_vrj_expr(js, vrj, statetoid) - rate = generate_rate_function(js, vrj.rate) +function assemble_vrj_expr(js, vrj, unknowntoid) + rate = generate_rate_function(js, vrj.rate) outputvars = (value(affect.lhs) for affect in vrj.affect!) - outputidxs = ((statetoid[var] for var in outputvars)...,) + outputidxs = ((unknowntoid[var] for var in outputvars)...,) affect = generate_affect_function(js, vrj.affect!, outputidxs) quote rate = $rate + affect = $affect VariableRateJump(rate, affect) end end -function assemble_crj(js, crj, statetoid) - rate = @RuntimeGeneratedFunction(generate_rate_function(js, crj.rate)) +function assemble_crj( + js, crj, unknowntoid; eval_expression = false, eval_module = @__MODULE__) + rate = eval_or_rgf(generate_rate_function(js, crj.rate); eval_expression, eval_module) + rate = GeneratedFunctionWrapper{(2, 3, is_split(js))}(rate, nothing) outputvars = (value(affect.lhs) for affect in crj.affect!) - outputidxs = [statetoid[var] for var in outputvars] - affect = @RuntimeGeneratedFunction(generate_affect_function(js, crj.affect!, outputidxs)) + outputidxs = [unknowntoid[var] for var in outputvars] + affect = eval_or_rgf(generate_affect_function(js, crj.affect!, outputidxs); + eval_expression, eval_module) ConstantRateJump(rate, affect) end -function assemble_crj_expr(js, crj, statetoid) - rate = generate_rate_function(js, crj.rate) +function assemble_crj_expr(js, crj, unknowntoid) + rate = generate_rate_function(js, crj.rate) outputvars = (value(affect.lhs) for affect in crj.affect!) - outputidxs = ((statetoid[var] for var in outputvars)...,) + outputidxs = ((unknowntoid[var] for var in outputvars)...,) affect = generate_affect_function(js, crj.affect!, outputidxs) quote rate = $rate + affect = $affect ConstantRateJump(rate, affect) end end -function numericrstoich(mtrs::Vector{Pair{V,W}}, statetoid) where {V,W} - rs = Vector{Pair{Int,W}}() - for (spec,stoich) in mtrs - if !istree(spec) && _iszero(spec) +function numericrstoich(mtrs::Vector{Pair{V, W}}, unknowntoid) where {V, W} + rs = Vector{Pair{Int, W}}() + for (wspec, stoich) in mtrs + spec = value(wspec) + if !iscall(spec) && _iszero(spec) push!(rs, 0 => stoich) else - push!(rs, statetoid[value(spec)] => stoich) + push!(rs, unknowntoid[spec] => stoich) end end sort!(rs) rs end -function numericnstoich(mtrs::Vector{Pair{V,W}}, statetoid) where {V,W} - ns = Vector{Pair{Int,W}}() - for (spec,stoich) in mtrs - !istree(spec) && _iszero(spec) && error("Net stoichiometry can not have a species labelled 0.") - push!(ns, statetoid[spec] => stoich) +function numericnstoich(mtrs::Vector{Pair{V, W}}, unknowntoid) where {V, W} + ns = Vector{Pair{Int, W}}() + for (wspec, stoich) in mtrs + spec = value(wspec) + !iscall(spec) && _iszero(spec) && + error("Net stoichiometry can not have a species labelled 0.") + push!(ns, unknowntoid[spec] => stoich) end sort!(ns) end -# assemble a numeric MassActionJump from a MT MassActionJump representing one rx. -function assemble_maj(maj::MassActionJump, statetoid, subber, invttype) - rval = subber(maj.scaled_rates) - rs = numericrstoich(maj.reactant_stoch, statetoid) - ns = numericnstoich(maj.net_stoch, statetoid) - maj = MassActionJump(convert(invttype, value(rval)), rs, ns, scale_rates = false) - maj +# assemble a numeric MassActionJump from a MT symbolics MassActionJumps +function assemble_maj(majv::Vector{U}, unknowntoid, pmapper) where {U <: MassActionJump} + rs = [numericrstoich(maj.reactant_stoch, unknowntoid) for maj in majv] + ns = [numericnstoich(maj.net_stoch, unknowntoid) for maj in majv] + MassActionJump(rs, ns; param_mapper = pmapper, nocopy = true) end -# For MassActionJumps that contain many reactions -# function assemble_maj(maj::MassActionJump{U,V,W}, statetoid, subber, -# invttype) where {U <: AbstractVector,V,W} -# rval = [convert(invttype,numericrate(sr, subber)) for sr in maj.scaled_rates] -# rs = [numericrstoich(rs, statetoid) for rs in maj.reactant_stoch] -# ns = [numericnstoich(ns, statetoid) for ns in maj.net_stoch] -# maj = MassActionJump(rval, rs, ns, scale_rates = false) -# maj -# end """ ```julia -function DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan, - parammap=DiffEqBase.NullParameters; kwargs...) +DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan, + parammap = DiffEqBase.NullParameters; + kwargs...) ``` Generates a blank DiscreteProblem for a pure jump JumpSystem to utilize as @@ -197,28 +385,45 @@ its `prob.prob`. This is used in the case where there are no ODEs and no SDEs associated with the system. Continuing the example from the [`JumpSystem`](@ref) definition: + ```julia -using DiffEqBase, DiffEqJump +using DiffEqBase, JumpProcesses u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] +parammap = [β => 0.1 / 1000, γ => 0.01] tspan = (0.0, 250.0) -dprob = DiscreteProblem(js, u₀map, tspan, parammap) +dprob = DiscreteProblem(complete(js), u₀map, tspan, parammap) ``` """ -function DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan::Union{Tuple,Nothing}, - parammap=DiffEqBase.NullParameters(); kwargs...) - defs = defaults(sys) - u0 = varmap_to_vars(u0map, states(sys); defaults=defs) - p = varmap_to_vars(parammap, parameters(sys); defaults=defs) - f = DiffEqBase.DISCRETE_INPLACE_DEFAULT - df = DiscreteFunction{true,true}(f, syms=Symbol.(states(sys))) +function DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, + parammap = DiffEqBase.NullParameters(); + eval_expression = false, + eval_module = @__MODULE__, + cse = true, + kwargs...) + if !iscomplete(sys) + error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") + end + + if has_equations(sys) || (!isempty(continuous_events(sys))) + error("The passed in JumpSystem contains `Equation`s or continuous events, please use a problem type that supports these features, such as ODEProblem.") + end + + _f, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, check_length = false, build_initializeprob = false, cse) + f = DiffEqBase.DISCRETE_INPLACE_DEFAULT + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + df = DiscreteFunction{true, true}(f; sys = sys, observed = observedfun, + initialization_data = get(_f.kwargs, :initialization_data, nothing)) DiscreteProblem(df, u0, tspan, p; kwargs...) end """ ```julia -function DiffEqBase.DiscreteProblemExpr(sys::JumpSystem, u0map, tspan, - parammap=DiffEqBase.NullParameters; kwargs...) +DiffEqBase.DiscreteProblemExpr(sys::JumpSystem, u0map, tspan, + parammap = DiffEqBase.NullParameters; kwargs...) ``` Generates a blank DiscreteProblem for a JumpSystem to utilize as its @@ -226,76 +431,161 @@ solving `prob.prob`. This is used in the case where there are no ODEs and no SDEs associated with the system. Continuing the example from the [`JumpSystem`](@ref) definition: + ```julia -using DiffEqBase, DiffEqJump +using DiffEqBase, JumpProcesses u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] +parammap = [β => 0.1 / 1000, γ => 0.01] tspan = (0.0, 250.0) -dprob = DiscreteProblem(js, u₀map, tspan, parammap) +dprob = DiscreteProblem(complete(js), u₀map, tspan, parammap) ``` """ -function DiscreteProblemExpr(sys::JumpSystem, u0map, tspan::Union{Tuple,Nothing}, - parammap=DiffEqBase.NullParameters(); kwargs...) - defs = defaults(sys) - u0 = varmap_to_vars(u0map, states(sys); defaults=defs) - p = varmap_to_vars(parammap, parameters(sys); defaults=defs) +struct DiscreteProblemExpr{iip} end + +function DiscreteProblemExpr{iip}(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, + parammap = DiffEqBase.NullParameters(); + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblemExpr`") + end + + _, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, check_length = false) # identity function to make syms works quote - f = DiffEqBase.DISCRETE_INPLACE_DEFAULT + f = DiffEqBase.DISCRETE_INPLACE_DEFAULT u0 = $u0 p = $p + sys = $sys tspan = $tspan - df = DiscreteFunction{true,true}(f, syms=$(Symbol.(states(sys)))) + df = DiscreteFunction{true, true}(f; sys = sys) DiscreteProblem(df, u0, tspan, p) end end """ ```julia -function DiffEqBase.JumpProblem(js::JumpSystem, prob, aggregator; kwargs...) +DiffEqBase.ODEProblem(sys::JumpSystem, u0map, tspan, + parammap = DiffEqBase.NullParameters; + kwargs...) +``` + +Generates a blank ODEProblem for a pure jump JumpSystem to utilize as its `prob.prob`. This +is used in the case where there are no ODEs and no SDEs associated with the system but there +are jumps with an explicit time dependency (i.e. `VariableRateJump`s). If no jumps have an +explicit time dependence, i.e. all are `ConstantRateJump`s or `MassActionJump`s then +`DiscreteProblem` should be preferred for performance reasons. + +Continuing the example from the [`JumpSystem`](@ref) definition: + +```julia +using DiffEqBase, JumpProcesses +u₀map = [S => 999, I => 1, R => 0] +parammap = [β => 0.1 / 1000, γ => 0.01] +tspan = (0.0, 250.0) +oprob = ODEProblem(complete(js), u₀map, tspan, parammap) +``` +""" +function DiffEqBase.ODEProblem(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, + parammap = DiffEqBase.NullParameters(); + eval_expression = false, + eval_module = @__MODULE__, cse = true, + kwargs...) + if !iscomplete(sys) + error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") + end + + # forward everything to be an ODESystem but the jumps and discrete events + if has_equations(sys) + osys = ODESystem(equations(sys).x[4], get_iv(sys), unknowns(sys), parameters(sys); + observed = observed(sys), name = nameof(sys), description = description(sys), + systems = get_systems(sys), defaults = defaults(sys), guesses = guesses(sys), + parameter_dependencies = parameter_dependencies(sys), + metadata = get_metadata(sys), gui_metadata = get_gui_metadata(sys)) + osys = complete(osys; add_initial_parameters = false) + return ODEProblem(osys, u0map, tspan, parammap; check_length = false, + build_initializeprob = false, kwargs...) + else + _, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, + check_length = false, build_initializeprob = false, cse) + f = (du, u, p, t) -> (du .= 0; nothing) + observedfun = ObservedFunctionCache(sys; eval_expression, eval_module, + checkbounds = get(kwargs, :checkbounds, false), cse) + df = ODEFunction(f; sys, observed = observedfun) + return ODEProblem(df, u0, tspan, p; kwargs...) + end +end + +""" +```julia +DiffEqBase.JumpProblem(js::JumpSystem, prob, aggregator; kwargs...) ``` Generates a JumpProblem from a JumpSystem. Continuing the example from the [`DiscreteProblem`](@ref) definition: + ```julia -jprob = JumpProblem(js, dprob, Direct()) +jprob = JumpProblem(complete(js), dprob, Direct()) sol = solve(jprob, SSAStepper()) ``` """ -function DiffEqJump.JumpProblem(js::JumpSystem, prob, aggregator; kwargs...) - statetoid = Dict(value(state) => i for (i,state) in enumerate(states(js))) - eqs = equations(js) - invttype = prob.tspan[1] === nothing ? Float64 : typeof(1 / prob.tspan[2]) +function JumpProcesses.JumpProblem(js::JumpSystem, prob, + aggregator = JumpProcesses.NullAggregator(); callback = nothing, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + if !iscomplete(js) + error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `JumpProblem`") + end + unknowntoid = Dict(value(unknown) => i for (i, unknown) in enumerate(unknowns(js))) + eqs = equations(js) + invttype = prob.tspan[1] === nothing ? Float64 : typeof(1 / prob.tspan[2]) - # handling parameter substition and empty param vecs + # handling parameter substitution and empty param vecs p = (prob.p isa DiffEqBase.NullParameters || prob.p === nothing) ? Num[] : prob.p - parammap = map((x,y)->Pair(x,y), parameters(js), p) - subber = substituter(parammap) - - majs = MassActionJump[assemble_maj(j, statetoid, subber, invttype) for j in eqs.x[1]] - crjs = ConstantRateJump[assemble_crj(js, j, statetoid) for j in eqs.x[2]] - vrjs = VariableRateJump[assemble_vrj(js, j, statetoid) for j in eqs.x[3]] - ((prob isa DiscreteProblem) && !isempty(vrjs)) && error("Use continuous problems such as an ODEProblem or a SDEProblem with VariableRateJumps") - jset = JumpSet(Tuple(vrjs), Tuple(crjs), nothing, isempty(majs) ? nothing : majs) - - if needs_vartojumps_map(aggregator) || needs_depgraph(aggregator) - jdeps = asgraph(js) - vdeps = variable_dependencies(js) + + majpmapper = JumpSysMajParamMapper(js, p; jseqs = eqs, rateconsttype = invttype) + majs = isempty(eqs.x[1]) ? nothing : assemble_maj(eqs.x[1], unknowntoid, majpmapper) + crjs = ConstantRateJump[assemble_crj(js, j, unknowntoid; eval_expression, eval_module) + for j in eqs.x[2]] + vrjs = VariableRateJump[assemble_vrj(js, j, unknowntoid; eval_expression, eval_module) + for j in eqs.x[3]] + if prob isa DiscreteProblem + if (!isempty(vrjs) || has_equations(js) || !isempty(continuous_events(js))) + error("Use continuous problems such as an ODEProblem or a SDEProblem with VariableRateJumps, coupled differential equations, or continuous events.") + end + end + jset = JumpSet(Tuple(vrjs), Tuple(crjs), nothing, majs) + + # dep graphs are only for constant rate jumps + nonvrjs = ArrayPartition(eqs.x[1], eqs.x[2]) + if needs_vartojumps_map(aggregator) || needs_depgraph(aggregator) || + (aggregator isa JumpProcesses.NullAggregator) + jdeps = asgraph(js; eqs = nonvrjs) + vdeps = variable_dependencies(js; eqs = nonvrjs) vtoj = jdeps.badjlist jtov = vdeps.badjlist - jtoj = needs_depgraph(aggregator) ? eqeq_dependencies(jdeps, vdeps).fadjlist : nothing + jtoj = needs_depgraph(aggregator) ? eqeq_dependencies(jdeps, vdeps).fadjlist : + nothing else - vtoj = nothing; jtov = nothing; jtoj = nothing + vtoj = nothing + jtov = nothing + jtoj = nothing end - JumpProblem(prob, aggregator, jset; dep_graph=jtoj, vartojumps_map=vtoj, jumptovars_map=jtov, kwargs...) -end + # handle events, making sure to reset aggregators in the generated affect functions + cbs = process_events(js; callback, eval_expression, eval_module, + postprocess_affect_expr! = _reset_aggregator!) + JumpProblem(prob, aggregator, jset; dep_graph = jtoj, vartojumps_map = vtoj, + jumptovars_map = jtov, scale_rates = false, nocopy = true, + callback = cbs, kwargs...) +end -### Functions to determine which states a jump depends on -function get_variables!(dep, jump::Union{ConstantRateJump,VariableRateJump}, variables) - (jump.rate isa Symbolic) && get_variables!(dep, jump.rate, variables) +### Functions to determine which unknowns a jump depends on +function get_variables!(dep, jump::Union{ConstantRateJump, VariableRateJump}, variables) + jr = value(jump.rate) + (jr isa Symbolic) && get_variables!(dep, jr, variables) dep end @@ -308,16 +598,87 @@ function get_variables!(dep, jump::MassActionJump, variables) dep end -### Functions to determine which states are modified by a given jump -function modified_states!(mstates, jump::Union{ConstantRateJump,VariableRateJump}, sts) +### Functions to determine which unknowns are modified by a given jump +function modified_unknowns!(munknowns, jump::Union{ConstantRateJump, VariableRateJump}, sts) for eq in jump.affect! st = eq.lhs - any(isequal(st), sts) && push!(mstates, st) + any(isequal(st), sts) && push!(munknowns, st) end + munknowns end -function modified_states!(mstates, jump::MassActionJump, sts) - for (state,stoich) in jump.net_stoch - any(isequal(state), sts) && push!(mstates, state) +function modified_unknowns!(munknowns, jump::MassActionJump, sts) + for (unknown, stoich) in jump.net_stoch + any(isequal(unknown), sts) && push!(munknowns, unknown) end + munknowns end + +###################### parameter mapper ########################### +struct JumpSysMajParamMapper{U, V, W} + paramexprs::U # the parameter expressions to use for each jump rate constant + sympars::V # parameters(sys) from the underlying JumpSystem + subdict::Any # mapping from an element of parameters(sys) to its current numerical value +end + +function JumpSysMajParamMapper(js::JumpSystem, p; jseqs = nothing, rateconsttype = Float64) + eqs = (jseqs === nothing) ? equations(js) : jseqs + paramexprs = [maj.scaled_rates for maj in eqs.x[1]] + psyms = reduce(vcat, reorder_parameters(js); init = []) + paramdict = Dict(value(k) => value(v) for (k, v) in zip(psyms, vcat(p...))) + JumpSysMajParamMapper{typeof(paramexprs), typeof(psyms), rateconsttype}(paramexprs, + psyms, + paramdict) +end + +function updateparams!(ratemap::JumpSysMajParamMapper{U, V, W}, + params) where {U <: AbstractArray, V <: AbstractArray, W} + for (i, p) in enumerate(params) + sympar = ratemap.sympars[i] + ratemap.subdict[sympar] = p + end + nothing +end + +function updateparams!(ratemap::JumpSysMajParamMapper{U, V, W}, + params::MTKParameters) where {U <: AbstractArray, V <: AbstractArray, W} + for (i, p) in enumerate(ArrayPartition(params...)) + sympar = ratemap.sympars[i] + ratemap.subdict[sympar] = p + end + nothing +end + +function updateparams!(::JumpSysMajParamMapper{U, V, W}, + params::Nothing) where {U <: AbstractArray, V <: AbstractArray, W} + nothing +end + +# create the initial parameter vector for use in a MassActionJump +function (ratemap::JumpSysMajParamMapper{ + U, + V, + W +})(params) where {U <: AbstractArray, + V <: AbstractArray, W} + updateparams!(ratemap, params) + [convert(W, value(substitute(paramexpr, ratemap.subdict))) + for paramexpr in ratemap.paramexprs] +end + +# update a maj with parameter vectors +function (ratemap::JumpSysMajParamMapper{U, V, W})(maj::MassActionJump, newparams; + scale_rates, + kwargs...) where {U <: AbstractArray, + V <: AbstractArray, W} + updateparams!(ratemap, newparams) + for i in 1:get_num_majumps(maj) + maj.scaled_rates[i] = convert(W, + value(substitute(ratemap.paramexprs[i], + ratemap.subdict))) + end + scale_rates && JumpProcesses.scalerates!(maj.scaled_rates, maj.reactant_stoch) + nothing +end + +supports_initialization(::JumpSystem) = false diff --git a/src/systems/model_parsing.jl b/src/systems/model_parsing.jl new file mode 100644 index 0000000000..195b02118e --- /dev/null +++ b/src/systems/model_parsing.jl @@ -0,0 +1,1459 @@ +""" +$(TYPEDEF) + +ModelingToolkit component or connector with metadata + +# Fields +$(FIELDS) +""" +struct Model{F, S} + """The constructor that returns System.""" + f::F + """ + The dictionary with metadata like keyword arguments (:kwargs), base + system this Model extends (:extend), sub-components of the Model (:components), + variables (:variables), parameters (:parameters), structural parameters + (:structural_parameters) and equations (:equations). + """ + structure::S + """ + This flag is `true` when the Model is a connector and is `false` when it is + a component + """ + isconnector::Bool +end +(m::Model)(args...; kw...) = m.f(args...; kw...) + +Base.parentmodule(m::Model) = parentmodule(m.f) + +for f in (:connector, :mtkmodel) + isconnector = f == :connector ? true : false + @eval begin + macro $f(fullname::Union{Expr, Symbol}, body) + esc($(:_model_macro)(__module__, fullname, body, $isconnector)) + end + end +end + +flatten_equations(eqs::Vector{Equation}, eq::Equation) = vcat(eqs, [eq]) +flatten_equations(eq::Vector{Equation}, eqs::Vector{Equation}) = vcat(eq, eqs) +function flatten_equations(eqs::Vector{Union{Equation, Vector{Equation}}}) + foldl(flatten_equations, eqs; init = Equation[]) +end + +function _model_macro(mod, fullname::Union{Expr, Symbol}, expr, isconnector) + if fullname isa Symbol + name, type = fullname, :System + else + if fullname.head == :(::) + name, type = fullname.args + else + error("`$fullname` is not a valid name.") + end + end + exprs = Expr(:block) + dict = Dict{Symbol, Any}( + :constants => Dict{Symbol, Dict}(), + :defaults => Dict{Symbol, Any}(), + :kwargs => Dict{Symbol, Dict}(), + :structural_parameters => Dict{Symbol, Dict}() + ) + comps = Union{Symbol, Expr}[] + ext = [] + eqs = Expr[] + icon = Ref{Union{String, URI}}() + ps, sps, vs, = [], [], [] + c_evts = [] + d_evts = [] + kwargs = OrderedCollections.OrderedSet() + where_types = Union{Symbol, Expr}[] + + push!(exprs.args, :(variables = [])) + push!(exprs.args, :(parameters = [])) + # We build `System` by default; vectors can't be created for `System` as it is + # a function. + push!(exprs.args, :(systems = ModelingToolkit.AbstractSystem[])) + push!(exprs.args, :(equations = Union{Equation, Vector{Equation}}[])) + push!(exprs.args, :(defaults = Dict{Num, Union{Number, Symbol, Function}}())) + + Base.remove_linenums!(expr) + for arg in expr.args + if arg.head == :macrocall + parse_model!(exprs.args, comps, ext, eqs, icon, vs, ps, + sps, c_evts, d_evts, dict, mod, arg, kwargs, where_types) + elseif arg.head == :block + push!(exprs.args, arg) + elseif arg.head == :if + MLStyle.@match arg begin + Expr(:if, condition, x) => begin + parse_conditional_model_statements(comps, dict, eqs, exprs, kwargs, + mod, ps, vs, where_types, + parse_top_level_branch(condition, x.args)...) + end + Expr(:if, condition, x, y) => begin + parse_conditional_model_statements(comps, dict, eqs, exprs, kwargs, + mod, ps, vs, where_types, + parse_top_level_branch(condition, x.args, y)...) + end + _ => error("Got an invalid argument: $arg") + end + elseif isconnector + # Connectors can have variables listed without `@variables` prefix or + # begin block. + parse_variable_arg!( + exprs.args, vs, dict, mod, arg, :variables, kwargs, where_types) + else + error("$arg is not valid syntax. Expected a macro call.") + end + end + + iv = get(dict, :independent_variable, nothing) + if iv === nothing + iv = dict[:independent_variable] = get_t(mod, :t) + end + + push!(exprs.args, :(push!(equations, $(eqs...)))) + push!(exprs.args, :(push!(parameters, $(ps...)))) + push!(exprs.args, :(push!(systems, $(comps...)))) + push!(exprs.args, :(push!(variables, $(vs...)))) + + gui_metadata = isassigned(icon) > 0 ? GUIMetadata(GlobalRef(mod, name), icon[]) : + GUIMetadata(GlobalRef(mod, name)) + + description = get(dict, :description, "") + + @inline pop_structure_dict!.( + Ref(dict), [:constants, :defaults, :kwargs, :structural_parameters]) + + sys = :($type($(flatten_equations)(equations), $iv, variables, parameters; + name, description = $description, systems, gui_metadata = $gui_metadata, defaults)) + + if length(ext) == 0 + push!(exprs.args, :(var"#___sys___" = $sys)) + else + push!(exprs.args, :(var"#___sys___" = $extend($sys, [$(ext...)]))) + end + + isconnector && push!(exprs.args, + :($Setfield.@set!(var"#___sys___".connector_type=$connector_type(var"#___sys___")))) + + !isempty(c_evts) && push!(exprs.args, + :($Setfield.@set!(var"#___sys___".continuous_events=$SymbolicContinuousCallback.([ + $(c_evts...) + ])))) + + !isempty(d_evts) && push!(exprs.args, + :($Setfield.@set!(var"#___sys___".discrete_events=$SymbolicDiscreteCallback.([ + $(d_evts...) + ])))) + + f = if length(where_types) == 0 + :($(Symbol(:__, name, :__))(; name, $(kwargs...)) = $exprs) + else + f_with_where = Expr(:where) + push!(f_with_where.args, + :($(Symbol(:__, name, :__))(; name, $(kwargs...))), where_types...) + :($f_with_where = $exprs) + end + + :($name = $Model($f, $dict, $isconnector)) +end + +pop_structure_dict!(dict, key) = length(dict[key]) == 0 && pop!(dict, key) + +struct NoValue end +const NO_VALUE = NoValue() + +function update_kwargs_and_metadata!(dict, kwargs, a, def, type, + varclass, where_types, meta) + if !isnothing(meta) && haskey(meta, VariableUnit) + uvar = gensym() + push!(where_types, uvar) + push!(kwargs, + Expr(:kw, :($a::Union{Nothing, Missing, $NoValue, $uvar}), NO_VALUE)) + else + push!(kwargs, + Expr(:kw, :($a::Union{Nothing, Missing, $NoValue, $type}), NO_VALUE)) + end + dict[:kwargs][a] = Dict(:value => def, :type => type) + if dict[varclass] isa Vector + dict[varclass][1][a][:type] = AbstractArray{type} + else + dict[varclass][a][:type] = type + end +end + +function update_readable_metadata!(varclass_dict, meta::Dict, varname) + metatypes = [(:connection_type, VariableConnectType), + (:description, VariableDescription), + (:unit, VariableUnit), + (:bounds, VariableBounds), + (:noise, VariableNoiseType), + (:input, VariableInput), + (:output, VariableOutput), + (:irreducible, VariableIrreducible), + (:state_priority, VariableStatePriority), + (:misc, VariableMisc), + (:disturbance, VariableDisturbance), + (:tunable, VariableTunable), + (:dist, VariableDistribution)] + + var_dict = get!(varclass_dict, varname) do + Dict{Symbol, Any}() + end + + for (type, key) in metatypes + if (mt = get(meta, key, nothing)) !== nothing + key == VariableConnectType && (mt = nameof(mt)) + var_dict[type] = mt + end + end +end + +function update_array_kwargs_and_metadata!( + dict, indices, kwargs, meta, type, varclass, varname, varval, where_types) + dict[varclass] = get!(dict, varclass) do + Dict{Symbol, Dict{Symbol, Any}}() + end + varclass_dict = dict[varclass] isa Vector ? Ref(dict[varclass][1]) : Ref(dict[varclass]) + + merge!(varclass_dict[], + Dict(varname => Dict( + :size => tuple([index_arg.args[end] for index_arg in indices]...), + :value => varval, + :type => type + ))) + + vartype = gensym(:T) + push!(kwargs, + Expr(:kw, + Expr(:(::), varname, + Expr(:curly, :Union, :Nothing, :Missing, NoValue, + Expr(:curly, :AbstractArray, vartype))), + NO_VALUE)) + if !isnothing(meta) && haskey(meta, VariableUnit) + push!(where_types, vartype) + else + push!(where_types, :($vartype <: $type)) + end + + # Useful keys for kwargs entry are: value, type and size. + dict[:kwargs][varname] = varclass_dict[][varname] + + meta !== nothing && update_readable_metadata!(varclass_dict[], meta, varname) +end + +function unit_handled_variable_value(meta, varname) + varval = if meta isa Nothing || get(meta, VariableUnit, nothing) isa Nothing + varname + else + :($convert_units($(meta[VariableUnit]), $varname)) + end + return varval +end + +# This function parses various variable/parameter definitions. +# +# The comments indicate the syntax matched by a block; either when parsed directly +# when it is called recursively for parsing a part of an expression. +# These variable definitions are part of test suite in `test/model_parsing.jl` +Base.@nospecializeinfer function parse_variable_def!( + dict, mod, arg, varclass, kwargs, where_types; + def = nothing, type::Type = Real, meta = Dict{DataType, Expr}()) + @nospecialize + arg isa LineNumberNode && return + MLStyle.@match arg begin + # Parses: `a` + # Recursively called by: `c(t) = cval + jval` + # Recursively called by: `d = 2` + # Recursively called by: `e, [description = "e"]` + # Recursively called by: `f = 3, [description = "f"]` + # Recursively called by: `k = kval, [description = "k"]` + # Recursively called by: `par0::Bool = true` + a::Symbol => begin + var = generate_var!(dict, a, varclass; type) + update_kwargs_and_metadata!(dict, kwargs, a, def, type, + varclass, where_types, meta) + return var, def, Dict() + end + # Parses: `par5[1:3]::BigFloat` + # Parses: `par6(t)[1:3]::BigFloat` + # Recursively called by: `par2(t)::Int` + # Recursively called by: `par3(t)::BigFloat = 1.0` + Expr(:(::), a, type) => begin + type = getfield(mod, type) + parse_variable_def!( + dict, mod, a, varclass, kwargs, where_types; def, type, meta) + end + # Recursively called by: `i(t) = 4, [description = "i(t)"]` + # Recursively called by: `h(t), [description = "h(t)"]` + # Recursively called by: `j(t) = jval, [description = "j(t)"]` + # Recursively called by: `par2(t)::Int` + # Recursively called by: `par3(t)::BigFloat = 1.0` + Expr(:call, a, b) => begin + var = generate_var!(dict, a, b, varclass, mod; type) + update_kwargs_and_metadata!(dict, kwargs, a, def, type, + varclass, where_types, meta) + return var, def, Dict() + end + # Condition 1 parses: + # `(l(t)[1:2, 1:3] = 1), [description = "l is more than 1D"]` + # `(l2(t)[1:N, 1:M] = 2), [description = "l is more than 1D, with arbitrary length"]` + # `(l3(t)[1:3] = 3), [description = "l2 is 1D"]` + # `(l4(t)[1:N] = 4), [description = "l2 is 1D, with arbitrary length"]` + # + # Condition 2 parses: + # `(l5(t)[1:3]::Int = 5), [description = "l3 is 1D and has a type"]` + # `(l6(t)[1:N]::Int = 6), [description = "l3 is 1D and has a type, with arbitrary length"]` + # + # Condition 3 parses: + # `e2[1:2]::Int, [description = "e2"]` + # `h2(t)[1:2]::Int, [description = "h2(t)"]` + # + # Condition 4 parses: + # `e2[1:2], [description = "e2"]` + # `h2(t)[1:2], [description = "h2(t)"]` + Expr(:tuple, Expr(:(=), Expr(:ref, a, indices...), default_val), meta_val) || + Expr(:tuple, Expr(:(=), Expr(:(::), Expr(:ref, a, indices...), type), default_val), meta_val) || + Expr(:tuple, Expr(:(::), Expr(:ref, a, indices...), type), meta_val) || + Expr(:tuple, Expr(:ref, a, indices...), meta_val) => begin + (@isdefined type) || (type = Real) + varname = Meta.isexpr(a, :call) ? a.args[1] : a + meta = parse_metadata(mod, meta_val) + varval = (@isdefined default_val) ? default_val : + unit_handled_variable_value(meta, varname) + if varclass == :parameters + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@parameters ($a[$(indices...)]::$type = $varval), + $meta_val)) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@variables ($a[$(indices)]::$type = $varval), + $meta_val)) + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, meta, type, varclass, varname, varval, where_types) + (:($varname...), var), nothing, Dict() + end + # Condition 1 parses: + # `par7(t)[1:3, 1:3]::BigFloat = 1.0, [description = "with description"]` + # + # Condition 2 parses: + # `d2[1:2] = 2` + # `l(t)[1:2, 1:3] = 2, [description = "l is more than 1D"]` + Expr(:(=), Expr(:(::), Expr(:ref, a, indices...), type), def_n_meta) || + Expr(:(=), Expr(:ref, a, indices...), def_n_meta) => begin + (@isdefined type) || (type = Real) + varname = Meta.isexpr(a, :call) ? a.args[1] : a + if Meta.isexpr(def_n_meta, :tuple) + meta = parse_metadata(mod, def_n_meta) + varval = unit_handled_variable_value(meta, varname) + val, def_n_meta = (def_n_meta.args[1], def_n_meta.args[2:end]) + if varclass == :parameters + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $val : $varname; + $varname = $first(@parameters ($a[$(indices...)]::$type = $varval), + $(def_n_meta...))) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $val : $varname; + $varname = $first(@variables $a[$(indices...)]::$type = ( + $varval), + $(def_n_meta...))) + end + else + if varclass == :parameters + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $def_n_meta : $varname; + $varname = $first(@parameters $a[$(indices...)]::$type = $varname)) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $def_n_meta : $varname; + $varname = $first(@variables $a[$(indices...)]::$type = $varname)) + end + varval, meta = def_n_meta, nothing + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, meta, type, varclass, varname, varval, where_types) + (:($varname...), var), nothing, Dict() + end + # Condition 1 is recursively called by: + # `par5[1:3]::BigFloat` + # `par6(t)[1:3]::BigFloat` + # + # Condition 2 parses: + # `b2(t)[1:2]` + # `a2[1:2]` + Expr(:(::), Expr(:ref, a, indices...), type) || + Expr(:ref, a, indices...) => begin + (@isdefined type) || (type = Real) + varname = a isa Expr && a.head == :call ? a.args[1] : a + if varclass == :parameters + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@parameters $a[$(indices...)]::$type = $varname)) + elseif varclass == :variables + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@variables $a[$(indices...)]::$type = $varname)) + else + throw("Symbolic array with arbitrary length is not handled for $varclass. + Please open an issue with an example.") + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, nothing, type, varclass, varname, nothing, where_types) + (:($varname...), var), nothing, Dict() + end + # Parses: `c(t) = cval + jval` + # Parses: `d = 2` + # Parses: `f = 3, [description = "f"]` + # Parses: `i(t) = 4, [description = "i(t)"]` + # Parses: `j(t) = jval, [description = "j(t)"]` + # Parses: `k = kval, [description = "k"]` + # Parses: `par0::Bool = true` + # Parses: `par3(t)::BigFloat = 1.0` + Expr(:(=), a, b) => begin + Base.remove_linenums!(b) + def, meta = parse_default(mod, b) + var, def, _ = parse_variable_def!( + dict, mod, a, varclass, kwargs, where_types; def, type, meta) + varclass_dict = dict[varclass] isa Vector ? Ref(dict[varclass][1]) : + Ref(dict[varclass]) + varclass_dict[][getname(var)][:default] = def + if meta !== nothing + update_readable_metadata!(varclass_dict[], meta, getname(var)) + var, metadata_with_exprs = set_var_metadata(var, meta) + return var, def, metadata_with_exprs + end + return var, def, Dict() + end + # Parses: `e, [description = "e"]` + # Parses: `h(t), [description = "h(t)"]` + # Parses: `par2(t)::Int` + Expr(:tuple, a, b) => begin + meta = parse_metadata(mod, b) + var, def, _ = parse_variable_def!( + dict, mod, a, varclass, kwargs, where_types; type, meta) + varclass_dict = dict[varclass] isa Vector ? Ref(dict[varclass][1]) : + Ref(dict[varclass]) + if meta !== nothing + update_readable_metadata!(varclass_dict[], meta, getname(var)) + var, metadata_with_exprs = set_var_metadata(var, meta) + return var, def, metadata_with_exprs + end + return var, def, Dict() + end + _ => error("$arg cannot be parsed") + end +end + +function generate_var(a, varclass; type = Real) + var = Symbolics.variable(a; T = type) + if varclass == :parameters + var = toparam(var) + elseif varclass == :independent_variables + var = toiv(var) + end + var +end + +singular(sym) = last(string(sym)) == 's' ? Symbol(string(sym)[1:(end - 1)]) : sym + +function check_name_uniqueness(dict, a, newvarclass) + for varclass in [:variables, :parameters, :structural_parameters, :constants] + dvarclass = get(dict, varclass, nothing) + if dvarclass !== nothing && a in keys(dvarclass) + error("Cannot create a $(singular(newvarclass)) `$(a)` because there is already a $(singular(varclass)) with that name") + end + end +end + +function generate_var!(dict, a, varclass; + indices::Union{Vector{UnitRange{Int}}, Nothing} = nothing, + type = Real) + check_name_uniqueness(dict, a, varclass) + vd = get!(dict, varclass) do + Dict{Symbol, Dict{Symbol, Any}}() + end + vd isa Vector && (vd = first(vd)) + vd[a] = Dict{Symbol, Any}() + indices !== nothing && (vd[a][:size] = Tuple(lastindex.(indices))) + generate_var(a, varclass; type) +end + +function assert_unique_independent_var(dict, iv::Num) + assert_unique_independent_var(dict, nameof(iv)) +end +function assert_unique_independent_var(dict, iv) + prev_iv = get!(dict, :independent_variable) do + iv + end + prev_iv isa Num && (prev_iv = nameof(prev_iv)) + @assert isequal(iv, prev_iv) "Multiple independent variables are used in the model $(typeof(iv)) $(typeof(prev_iv))" +end + +function generate_var!(dict, a, b, varclass, mod; + indices::Union{Vector{UnitRange{Int}}, Nothing} = nothing, + type = Real) + iv = b == :t ? get_t(mod, b) : generate_var(b, :independent_variables) + assert_unique_independent_var(dict, iv) + check_name_uniqueness(dict, a, varclass) + vd = get!(dict, varclass) do + Dict{Symbol, Dict{Symbol, Any}}() + end + vd isa Vector && (vd = first(vd)) + vd[a] = Dict{Symbol, Any}() + var = if indices === nothing + first(@variables $a(iv)::type) + else + vd[a][:size] = Tuple(lastindex.(indices)) + first(@variables $a(iv)[indices...]::type) + end + if varclass == :parameters + var = toparam(var) + end + var +end + +# Use the `t` defined in the `mod`. When it is unavailable, generate a new `t` with a warning. +function get_t(mod, t) + try + get_var(mod, t) + catch e + if e isa UndefVarError + @warn("Could not find a predefined `t` in `$mod`; generating a new one within this model.\nConsider defining it or importing `t` (or `t_nounits`, `t_unitful` as `t`) from ModelingToolkit.") + variable(:t) + else + throw(e) + end + end +end + +function parse_default(mod, a) + a = Base.remove_linenums!(deepcopy(a)) + MLStyle.@match a begin + Expr(:block, x) => parse_default(mod, x) + Expr(:tuple, x, y) => begin + def, _ = parse_default(mod, x) + meta = parse_metadata(mod, y) + (def, meta) + end + ::Symbol || ::Number => (a, nothing) + Expr(:call, a...) => begin + def = parse_default.(Ref(mod), a) + expr = Expr(:call) + for (d, _) in def + push!(expr.args, d) + end + (expr, nothing) + end + Expr(:if, condition, x, y) => (a, nothing) + Expr(:vect, x...) => begin + (a, nothing) + end + _ => error("Cannot parse default $a $(typeof(a))") + end +end + +function parse_metadata(mod, a::Expr) + MLStyle.@match a begin + Expr(:vect, b...) => Dict(parse_metadata(mod, m) for m in b) + Expr(:tuple, a, b...) => parse_metadata(mod, b) + Expr(:(=), a, b) => Symbolics.option_to_metadata_type(Val(a)) => get_var(mod, b) + _ => error("Cannot parse metadata $a") + end +end + +function parse_metadata(mod, metadata::AbstractArray) + ret = Dict() + for m in metadata + merge!(ret, parse_metadata(mod, m)) + end + ret +end + +function _set_var_metadata!(metadata_with_exprs, a, m, v::Expr) + push!(metadata_with_exprs, m => v) + a +end +function _set_var_metadata!(metadata_with_exprs, a, m, v) + wrap(set_scalar_metadata(unwrap(a), m, v)) +end + +function set_var_metadata(a, ms) + metadata_with_exprs = Dict{DataType, Expr}() + for (m, v) in ms + if m == VariableGuess && v isa Symbol + v = quote + $v + end + end + a = _set_var_metadata!(metadata_with_exprs, a, m, v) + end + a, metadata_with_exprs +end + +function get_var(mod::Module, b) + if b isa Symbol + isdefined(mod, b) && return getproperty(mod, b) + isdefined(@__MODULE__, b) && return getproperty(@__MODULE__, b) + end + b +end + +function parse_model!(exprs, comps, ext, eqs, icon, vs, ps, sps, c_evts, d_evts, + dict, mod, arg, kwargs, where_types) + mname = arg.args[1] + body = arg.args[end] + if mname == Symbol("@description") + parse_description!(body, dict) + elseif mname == Symbol("@components") + parse_components!(exprs, comps, dict, body, kwargs) + elseif mname == Symbol("@extend") + parse_extend!(exprs, ext, dict, mod, body, kwargs) + elseif mname == Symbol("@variables") + parse_variables!(exprs, vs, dict, mod, body, :variables, kwargs, where_types) + elseif mname == Symbol("@parameters") + parse_variables!(exprs, ps, dict, mod, body, :parameters, kwargs, where_types) + elseif mname == Symbol("@structural_parameters") + parse_structural_parameters!(exprs, sps, dict, mod, body, kwargs) + elseif mname == Symbol("@equations") + parse_equations!(exprs, eqs, dict, body) + elseif mname == Symbol("@constants") + parse_constants!(exprs, dict, body, mod) + elseif mname == Symbol("@continuous_events") + parse_continuous_events!(c_evts, dict, body) + elseif mname == Symbol("@discrete_events") + parse_discrete_events!(d_evts, dict, body) + elseif mname == Symbol("@icon") + isassigned(icon) && error("This model has more than one icon.") + parse_icon!(body, dict, icon, mod) + elseif mname == Symbol("@defaults") + parse_system_defaults!(exprs, arg, dict) + else + error("$mname is not handled.") + end +end + +function parse_constants!(exprs, dict, body, mod) + Base.remove_linenums!(body) + for arg in body.args + MLStyle.@match arg begin + Expr(:(=), Expr(:(::), a, type), Expr(:tuple, b, metadata)) || Expr(:(=), Expr(:(::), a, type), b) => begin + type = getfield(mod, type) + b = _type_check!(get_var(mod, b), a, type, :constants) + push!(exprs, + :($(Symbolics._parse_vars( + :constants, type, [:($a = $b), metadata], toconstant)))) + dict[:constants][a] = Dict(:value => b, :type => type) + if @isdefined metadata + for data in metadata.args + dict[:constants][a][data.args[1]] = data.args[2] + end + end + end + Expr(:(=), a, Expr(:tuple, b, metadata)) => begin + push!(exprs, + :($(Symbolics._parse_vars( + :constants, Real, [:($a = $b), metadata], toconstant)))) + dict[:constants][a] = Dict{Symbol, Any}(:value => get_var(mod, b)) + for data in metadata.args + dict[:constants][a][data.args[1]] = data.args[2] + end + end + Expr(:(=), a, b) => begin + push!(exprs, + :($(Symbolics._parse_vars( + :constants, Real, [:($a = $b)], toconstant)))) + dict[:constants][a] = Dict(:value => get_var(mod, b)) + end + _ => error("""Malformed constant definition `$arg`. Please use the following syntax: + ``` + @constants begin + var = value, [description = "This is an example constant."] + end + ``` + """) + end + end +end + +push_additional_defaults!(dict, a, b::Number) = dict[:defaults][a] = b +push_additional_defaults!(dict, a, b::QuoteNode) = dict[:defaults][a] = b.value +function push_additional_defaults!(dict, a, b::Expr) + dict[:defaults][a] = readable_code(b) +end + +function parse_system_defaults!(exprs, defaults_body, dict) + for default_arg in defaults_body.args[end].args + # for arg in default_arg.args + MLStyle.@match default_arg begin + # For cases like `p => 1` and `p => f()`. In both cases the definitions of + # `a`, here `p` and when `b` is a function, here `f` are available while + # defining the model + Expr(:call, :(=>), a, b) => begin + push!(exprs, :(defaults[$a] = $b)) + push_additional_defaults!(dict, a, b) + end + _ => error("Invalid `@defaults` entry: `$default_arg`") + end + end +end + +function parse_structural_parameters!(exprs, sps, dict, mod, body, kwargs) + Base.remove_linenums!(body) + for arg in body.args + MLStyle.@match arg begin + Expr(:(=), Expr(:(::), a, type), b) => begin + type = getfield(mod, type) + b = _type_check!(get_var(mod, b), a, type, :structural_parameters) + push!(sps, a) + push!(kwargs, Expr(:kw, Expr(:(::), a, type), b)) + dict[:structural_parameters][a] = dict[:kwargs][a] = Dict( + :value => b, :type => type) + end + Expr(:(=), a, b) => begin + push!(sps, a) + push!(kwargs, Expr(:kw, a, b)) + dict[:structural_parameters][a] = dict[:kwargs][a] = Dict(:value => b) + end + a => begin + push!(sps, a) + push!(kwargs, a) + dict[:structural_parameters][a] = dict[:kwargs][a] = Dict(:value => nothing) + end + end + end +end + +function extend_args!(a, b, dict, expr, kwargs, has_param = false) + # Whenever `b` is a function call, skip the first arg aka the function name. + # Whenever it is a kwargs list, include it. + start = b.head == :call ? 2 : 1 + for i in start:lastindex(b.args) + arg = b.args[i] + arg isa LineNumberNode && continue + MLStyle.@match arg begin + x::Symbol => begin + if b.head != :parameters + if has_param + popat!(b.args, i) + push!(b.args[2].args, x) + else + b.args[i] = Expr(:parameters, x) + end + end + push!(kwargs, Expr(:kw, x, nothing)) + dict[:kwargs][x] = Dict(:value => nothing) + end + Expr(:kw, x) => begin + b.args[i] = Expr(:kw, x, x) + push!(kwargs, Expr(:kw, x, nothing)) + dict[:kwargs][x] = Dict(:value => nothing) + end + Expr(:kw, x, y) => begin + b.args[i] = Expr(:kw, x, x) + push!(kwargs, Expr(:kw, x, y)) + dict[:kwargs][x] = Dict(:value => y) + end + Expr(:parameters, x...) => begin + has_param = true + extend_args!(a, arg, dict, expr, kwargs, has_param) + end + _ => error("Could not parse $arg of component $a") + end + end +end + +const EMPTY_DICT = Dict() +const EMPTY_VoVoSYMBOL = Vector{Symbol}[] +const EMPTY_VoVoVoSYMBOL = Vector{Symbol}[[]] + +function _arguments(model::Model) + vars = keys(get(model.structure, :variables, EMPTY_DICT)) + vars = union(vars, keys(get(model.structure, :parameters, EMPTY_DICT))) + vars = union(vars, first(get(model.structure, :extend, EMPTY_VoVoVoSYMBOL))) + collect(vars) +end + +function Base.names(model::Model) + collect(union(_arguments(model), + map(first, get(model.structure, :components, EMPTY_VoVoSYMBOL)))) +end + +function _parse_extend!(ext, a, b, dict, expr, kwargs, vars, implicit_arglist) + extend_args!(a, b, dict, expr, kwargs) + + # `implicit_arglist` doubles as a flag to check the mode of `@extend`. It is + # `nothing` for explicit destructuring. + # The following block modifies the arguments of both base and higher systems + # for the implicit extend statements. + if implicit_arglist !== nothing + b.args = [b.args[1]] + push!(b.args, Expr(:parameters)) + for var in implicit_arglist.args + push!(b.args[end].args, var) + if !haskey(dict[:kwargs], var) + push!(dict[:kwargs], var => Dict(:value => NO_VALUE)) + push!(kwargs, Expr(:kw, var, NO_VALUE)) + end + end + end + + push!(ext, a) + push!(b.args, Expr(:kw, :name, Meta.quot(a))) + push!(expr.args, :($a = $b)) + + if !haskey(dict, :extend) + dict[:extend] = [Symbol.(vars.args), a, b.args[1]] + else + push!(dict[:extend][1], Symbol.(vars.args)...) + dict[:extend][2] = vcat(dict[:extend][2], a) + dict[:extend][3] = vcat(dict[:extend][3], b.args[1]) + end + + push!(expr.args, :(@unpack $vars = $a)) +end + +function parse_extend!(exprs, ext, dict, mod, body, kwargs) + expr = Expr(:block) + push!(exprs, expr) + body = deepcopy(body) + MLStyle.@match body begin + Expr(:(=), a, b) => begin + if Meta.isexpr(b, :(=)) + vars = a + if !Meta.isexpr(vars, :tuple) + error("`@extend` destructuring only takes an tuple as LHS. Got $body") + end + a, b = b.args + # This doubles as a flag to identify the mode of `@extend` + implicit_arglist = nothing + _parse_extend!(ext, a, b, dict, expr, kwargs, vars, implicit_arglist) + else + error("When explicitly destructing in `@extend` please use the syntax: `@extend a, b = oneport = OnePort()`.") + end + end + Expr(:call, a′, _...) => begin + a = Symbol(Symbol("#mtkmodel"), :__anonymous__, a′) + b = body + if (model = getproperty(mod, b.args[1])) isa Model + vars = Expr(:tuple) + append!(vars.args, names(model)) + implicit_arglist = Expr(:tuple) + append!(implicit_arglist.args, _arguments(model)) + append!(implicit_arglist.args, + keys(get(model.structure, :structural_parameters, EMPTY_DICT))) + _parse_extend!(ext, a, b, dict, expr, kwargs, vars, implicit_arglist) + else + error("Cannot infer the exact `Model` that `@extend $(body)` refers." * + " Please specify the names that it brings into scope by:" * + " `@extend a, b = oneport = OnePort()`.") + end + end + _ => error("`@extend` only takes an assignment expression. Got $body") + end + return nothing +end + +function parse_variable_arg!(exprs, vs, dict, mod, arg, varclass, kwargs, where_types) + name, ex = parse_variable_arg(dict, mod, arg, varclass, kwargs, where_types) + push!(vs, name) + push!(exprs, ex) +end + +function convert_units(varunits::DynamicQuantities.Quantity, value) + DynamicQuantities.ustrip(DynamicQuantities.uconvert( + DynamicQuantities.SymbolicUnits.as_quantity(varunits), value)) +end + +convert_units(::DynamicQuantities.Quantity, value::NoValue) = NO_VALUE + +function convert_units( + varunits::DynamicQuantities.Quantity, value::AbstractArray{T}) where {T} + DynamicQuantities.ustrip.(DynamicQuantities.uconvert.( + DynamicQuantities.SymbolicUnits.as_quantity(varunits), value)) +end + +function convert_units(varunits::Unitful.FreeUnits, value) + Unitful.ustrip(varunits, value) +end + +convert_units(::Unitful.FreeUnits, value::NoValue) = NO_VALUE + +function convert_units(varunits::Unitful.FreeUnits, value::AbstractArray{T}) where {T} + Unitful.ustrip.(varunits, value) +end + +convert_units(::Unitful.FreeUnits, value::Num) = value + +convert_units(::DynamicQuantities.Quantity, value::Num) = value + +function parse_variable_arg(dict, mod, arg, varclass, kwargs, where_types) + vv, def, metadata_with_exprs = parse_variable_def!( + dict, mod, arg, varclass, kwargs, where_types) + if !(vv isa Tuple) + name = getname(vv) + varexpr = if haskey(metadata_with_exprs, VariableUnit) + unit = metadata_with_exprs[VariableUnit] + quote + $name = if $name === $NO_VALUE + $setdefault($vv, $def) + else + try + $setdefault($vv, $convert_units($unit, $name)) + catch e + if isa(e, $(DynamicQuantities.DimensionError)) || + isa(e, $(Unitful.DimensionError)) + error("Unable to convert units for \'" * string(:($$vv)) * "\'") + elseif isa(e, MethodError) + error("No or invalid units provided for \'" * string(:($$vv)) * + "\'") + else + rethrow(e) + end + end + end + end + else + quote + $name = if $name === $NO_VALUE + $setdefault($vv, $def) + else + $setdefault($vv, $name) + end + end + end + + metadata_expr = Expr(:block) + for (k, v) in metadata_with_exprs + push!(metadata_expr.args, + :($name = $wrap($set_scalar_metadata($unwrap($name), $k, $v)))) + end + + push!(varexpr.args, metadata_expr) + return symbolic_type(vv) == ScalarSymbolic() ? name : :($name...), varexpr + else + return vv + end +end + +function handle_conditional_vars!( + arg, conditional_branch, mod, varclass, kwargs, where_types) + conditional_dict = Dict(:kwargs => Dict(), + :parameters => Any[Dict{Symbol, Dict{Symbol, Any}}()], + :variables => Any[Dict{Symbol, Dict{Symbol, Any}}()]) + for _arg in arg.args + name, ex = parse_variable_arg( + conditional_dict, mod, _arg, varclass, kwargs, where_types) + push!(conditional_branch.args, ex) + push!(conditional_branch.args, :(push!($varclass, $name))) + end + conditional_dict +end + +function prune_conditional_dict!(conditional_tuple::Tuple) + prune_conditional_dict!.(collect(conditional_tuple)) +end +function prune_conditional_dict!(conditional_dict::Dict) + for k in [:parameters, :variables] + length(conditional_dict[k]) == 1 && isempty(first(conditional_dict[k])) && + delete!(conditional_dict, k) + end + isempty(conditional_dict[:kwargs]) && delete!(conditional_dict, :kwargs) +end +prune_conditional_dict!(_) = return nothing + +function get_conditional_dict!(conditional_dict, conditional_y_tuple::Tuple) + k = get_conditional_dict!.(Ref(conditional_dict), collect(conditional_y_tuple)) + push_something!(conditional_dict, + k...) + conditional_dict +end + +function get_conditional_dict!(conditional_dict::Dict, conditional_y_tuple::Dict) + merge!(conditional_dict[:kwargs], conditional_y_tuple[:kwargs]) + for key in [:parameters, :variables] + merge!(conditional_dict[key][1], conditional_y_tuple[key][1]) + end + conditional_dict +end + +get_conditional_dict!(a, b) = (return nothing) + +function push_conditional_dict!(dict, condition, conditional_dict, + conditional_y_tuple, varclass) + vd = get!(dict, varclass) do + Dict{Symbol, Dict{Symbol, Any}}() + end + for k in keys(conditional_dict[varclass][1]) + vd[k] = copy(conditional_dict[varclass][1][k]) + vd[k][:condition] = (:if, condition, conditional_dict, conditional_y_tuple) + end + conditional_y_dict = Dict(:kwargs => Dict(), + :parameters => Any[Dict{Symbol, Dict{Symbol, Any}}()], + :variables => Any[Dict{Symbol, Dict{Symbol, Any}}()]) + get_conditional_dict!(conditional_y_dict, conditional_y_tuple) + + prune_conditional_dict!(conditional_y_dict) + prune_conditional_dict!(conditional_dict) + !isempty(conditional_y_dict) && for k in keys(conditional_y_dict[varclass][1]) + vd[k] = copy(conditional_y_dict[varclass][1][k]) + vd[k][:condition] = (:if, condition, conditional_dict, conditional_y_tuple) + end +end + +function parse_variables!(exprs, vs, dict, mod, body, varclass, kwargs, where_types) + expr = Expr(:block) + push!(exprs, expr) + for arg in body.args + arg isa LineNumberNode && continue + MLStyle.@match arg begin + Expr(:if, condition, x) => begin + conditional_expr = Expr(:if, condition, Expr(:block)) + conditional_dict = handle_conditional_vars!(x, + conditional_expr.args[2], + mod, + varclass, + kwargs, + where_types) + push!(expr.args, conditional_expr) + push_conditional_dict!(dict, condition, conditional_dict, nothing, varclass) + end + Expr(:if, condition, x, y) => begin + conditional_expr = Expr(:if, condition, Expr(:block)) + conditional_dict = handle_conditional_vars!(x, + conditional_expr.args[2], + mod, + varclass, + kwargs, + where_types) + conditional_y_expr, conditional_y_tuple = handle_y_vars(y, + conditional_dict, + mod, + varclass, + kwargs, where_types) + push!(conditional_expr.args, conditional_y_expr) + push!(expr.args, conditional_expr) + push_conditional_dict!(dict, + condition, + conditional_dict, + conditional_y_tuple, + varclass) + end + _ => parse_variable_arg!( + exprs, vs, dict, mod, arg, varclass, kwargs, where_types) + end + end +end + +function handle_y_vars(y, dict, mod, varclass, kwargs, where_types) + conditional_dict = if Meta.isexpr(y, :elseif) + conditional_y_expr = Expr(:elseif, y.args[1], Expr(:block)) + conditional_dict = handle_conditional_vars!(y.args[2], + conditional_y_expr.args[2], + mod, + varclass, + kwargs, + where_types) + _y_expr, _conditional_dict = handle_y_vars( + y.args[end], dict, mod, varclass, kwargs, where_types) + push!(conditional_y_expr.args, _y_expr) + (:elseif, y.args[1], conditional_dict, _conditional_dict) + else + conditional_y_expr = Expr(:block) + handle_conditional_vars!(y, conditional_y_expr, mod, varclass, kwargs, where_types) + end + conditional_y_expr, conditional_dict +end + +function handle_if_x_equations!(condition, dict, ifexpr, x) + if Meta.isexpr(x, :block) + push!(ifexpr.args, condition, :(push!(equations, $(x.args...)))) + return readable_code.(x.args) + else + push!(ifexpr.args, condition, :(push!(equations, $x))) + return readable_code(x) + end + # push!(dict[:equations], [:if, readable_code(condition), readable_code.(x.args)]) +end + +function handle_if_y_equations!(ifexpr, y, dict) + if y.head == :elseif + elseifexpr = Expr(:elseif) + eq_entry = [:elseif, readable_code.(y.args[1].args)...] + push!(eq_entry, handle_if_x_equations!(y.args[1], dict, elseifexpr, y.args[2])) + get(y.args, 3, nothing) !== nothing && + push!(eq_entry, handle_if_y_equations!(elseifexpr, y.args[3], dict)) + push!(ifexpr.args, elseifexpr) + (eq_entry...,) + else + if Meta.isexpr(y, :block) + push!(ifexpr.args, :(push!(equations, $(y.args...)))) + else + push!(ifexpr.args, :(push!(equations, $(y)))) + end + readable_code.(y.args) + end +end + +function parse_equations!(exprs, eqs, dict, body) + dict[:equations] = [] + Base.remove_linenums!(body) + for arg in body.args + MLStyle.@match arg begin + Expr(:if, condition, x) => begin + ifexpr = Expr(:if) + eq_entry = handle_if_x_equations!(condition, dict, ifexpr, x) + push!(exprs, ifexpr) + push!(dict[:equations], (:if, condition, eq_entry)) + end + Expr(:if, condition, x, y) => begin + ifexpr = Expr(:if) + xeq_entry = handle_if_x_equations!(condition, dict, ifexpr, x) + yeq_entry = handle_if_y_equations!(ifexpr, y, dict) + push!(exprs, ifexpr) + push!(dict[:equations], (:if, condition, xeq_entry, yeq_entry)) + end + _ => begin + push!(eqs, arg) + push!(dict[:equations], readable_code.(eqs)...) + end + end + end +end + +function parse_continuous_events!(c_evts, dict, body) + dict[:continuous_events] = [] + Base.remove_linenums!(body) + for arg in body.args + push!(c_evts, arg) + push!(dict[:continuous_events], readable_code.(c_evts)...) + end +end + +function parse_discrete_events!(d_evts, dict, body) + dict[:discrete_events] = [] + Base.remove_linenums!(body) + for arg in body.args + push!(d_evts, arg) + push!(dict[:discrete_events], readable_code.(d_evts)...) + end +end + +function parse_icon!(body::String, dict, icon, mod) + icon_dir = get(ENV, "MTK_ICONS_DIR", joinpath(DEPOT_PATH[1], "mtk_icons")) + dict[:icon] = icon[] = if isfile(body) + URI("file:///" * abspath(body)) + elseif (iconpath = abspath(joinpath(icon_dir, body)); isfile(iconpath)) + URI("file:///" * abspath(iconpath)) + elseif try + Base.isvalid(URI(body)) + catch e + false + end + URI(body) + elseif (_body = lstrip(body); startswith(_body, r"<\?xml| begin + varname, _varname = _rename(a, x) + b.args[i] = Expr(:kw, x, _varname) + push!(varexpr.args, :((if $varname !== nothing + $_varname = $varname + elseif @isdefined $x + # Allow users to define a var in `structural_parameters` and set + # that as positional arg of subcomponents; it is useful for cases + # where it needs to be passed to multiple subcomponents. + $_varname = $x + end))) + push!(kwargs, Expr(:kw, varname, nothing)) + # dict[:kwargs][varname] = nothing + end + Expr(:parameters, x...) => begin + component_args!(a, arg, varexpr, kwargs) + end + Expr(:kw, x, y) => begin + varname, _varname = _rename(a, x) + b.args[i] = Expr(:kw, x, _varname) + if isnothing(index_name) + push!(varexpr.args, :($_varname = $varname === nothing ? $y : $varname)) + else + push!(varexpr.args, + :($_varname = $varname === nothing ? $y : $varname[$index_name])) + end + push!(kwargs, Expr(:kw, varname, nothing)) + # dict[:kwargs][varname] = nothing + end + _ => error("Could not parse $arg of component $a") + end + end +end + +model_name(name, range) = Symbol.(name, :_, collect(range)) + +function _parse_components!(body, kwargs) + local expr + varexpr = Expr(:block) + comps = Vector{Union{Union{Expr, Symbol}, Expr}}[] + comp_names = [] + + Base.remove_linenums!(body) + arg = body.args[end] + + MLStyle.@match arg begin + Expr(:(=), a, Expr(:comprehension, Expr(:generator, b, Expr(:(=), c, d)))) => begin + array_varexpr = Expr(:block) + + push!(comp_names, :($a...)) + push!(comps, [a, b.args[1], d]) + b = deepcopy(b) + + component_args!(a, b, array_varexpr, kwargs; index_name = c) + + expr = _named_idxs(a, d, :($c -> $b); extra_args = array_varexpr) + end + Expr(:(=), a, Expr(:comprehension, Expr(:generator, b, Expr(:filter, e, Expr(:(=), c, d))))) => begin + error("List comprehensions with conditional statements aren't supported.") + end + Expr(:(=), a, Expr(:comprehension, Expr(:generator, b, Expr(:(=), c, d), e...))) => begin + # Note that `e` is of the form `Tuple{Expr(:(=), c, d)}` + error("More than one index isn't supported while building component array") + end + Expr(:block) => begin + # TODO: Do we need this? + error("Multiple `@components` block detected within a single block") + end + Expr(:(=), a, Expr(:for, Expr(:(=), c, d), b)) => begin + Base.remove_linenums!(b) + array_varexpr = Expr(:block) + push!(array_varexpr.args, b.args[1:(end - 1)]...) + push!(comp_names, :($a...)) + push!(comps, [a, b.args[end].args[1], d]) + b = deepcopy(b) + + component_args!(a, b.args[end], array_varexpr, kwargs; index_name = c) + + expr = _named_idxs(a, d, :($c -> $(b.args[end])); extra_args = array_varexpr) + end + Expr(:(=), a, b) => begin + arg = deepcopy(arg) + b = deepcopy(arg.args[2]) + + component_args!(a, b, varexpr, kwargs) + + arg.args[2] = b + expr = :(@named $arg) + push!(comp_names, a) + if (isa(b.args[1], Symbol) || Meta.isexpr(b.args[1], :.)) + push!(comps, [a, b.args[1]]) + end + end + _ => error("Couldn't parse the component body: $arg") + end + + return comp_names, comps, expr, varexpr +end + +function push_conditional_component!(ifexpr, expr_vec, comp_names, varexpr) + blk = Expr(:block) + push!(blk.args, varexpr) + push!(blk.args, expr_vec) + push!(blk.args, :($push!(systems, $(comp_names...)))) + push!(ifexpr.args, blk) +end + +function handle_if_x!(mod, exprs, ifexpr, x, kwargs, condition = nothing) + push!(ifexpr.args, condition) + comp_names, comps, expr_vec, varexpr = _parse_components!(x, kwargs) + push_conditional_component!(ifexpr, expr_vec, comp_names, varexpr) + comps +end + +function handle_if_y!(exprs, ifexpr, y, kwargs) + Base.remove_linenums!(y) + if Meta.isexpr(y, :elseif) + comps = [:elseif, y.args[1]] + elseifexpr = Expr(:elseif) + push!(comps, handle_if_x!(mod, exprs, elseifexpr, y.args[2], kwargs, y.args[1])) + get(y.args, 3, nothing) !== nothing && + push!(comps, handle_if_y!(exprs, elseifexpr, y.args[3], kwargs)) + push!(ifexpr.args, elseifexpr) + (comps...,) + else + comp_names, comps, expr_vec, varexpr = _parse_components!(y, kwargs) + push_conditional_component!(ifexpr, expr_vec, comp_names, varexpr) + comps + end +end + +function handle_conditional_components(condition, dict, exprs, kwargs, x, y = nothing) + ifexpr = Expr(:if) + comps = handle_if_x!(mod, exprs, ifexpr, x, kwargs, condition) + ycomps = y === nothing ? [] : handle_if_y!(exprs, ifexpr, y, kwargs) + push!(exprs, ifexpr) + push!(dict[:components], (:if, condition, comps, ycomps)) +end + +function parse_components!(exprs, cs, dict, compbody, kwargs) + dict[:components] = [] + Base.remove_linenums!(compbody) + for arg in compbody.args + MLStyle.@match arg begin + Expr(:if, condition, x) => begin + handle_conditional_components(condition, dict, exprs, kwargs, x) + end + Expr(:if, condition, x, y) => begin + handle_conditional_components(condition, dict, exprs, kwargs, x, y) + end + # Either the arg is top level component declaration or an invalid cause - both are handled by `_parse_components` + _ => begin + comp_names, comps, expr_vec, varexpr = _parse_components!(:(begin + $arg + end), + kwargs) + push!(cs, comp_names...) + push!(dict[:components], comps...) + push!(exprs, varexpr, expr_vec) + end + end + end +end + +function _rename(compname, varname) + compname = Symbol(compname, :__, varname) + (compname, Symbol(:_, compname)) +end + +# Handle top level branching +push_something!(v, ::Nothing) = v +push_something!(v, x) = push!(v, x) +push_something!(v::Dict, x::Dict) = merge!(v, x) +push_something!(v, x...) = push_something!.(Ref(v), x) + +define_blocks(branch) = [Expr(branch), Expr(branch), Expr(branch), Expr(branch)] + +Base.@nospecializeinfer function parse_top_level_branch( + condition, x, y = nothing, branch::Symbol = :if) + @nospecialize + blocks::Vector{Union{Expr, Nothing}} = component_blk, equations_blk, parameter_blk, variable_blk = define_blocks(branch) + + for arg in x + if arg.args[1] == Symbol("@components") + push_something!(component_blk.args, condition, arg.args[end]) + elseif arg.args[1] == Symbol("@equations") + push_something!(equations_blk.args, condition, arg.args[end]) + elseif arg.args[1] == Symbol("@variables") + push_something!(variable_blk.args, condition, arg.args[end]) + elseif arg.args[1] == Symbol("@parameters") + push_something!(parameter_blk.args, condition, arg.args[end]) + else + error("$(arg.args[1]) isn't supported") + end + end + + if y !== nothing + yblocks = if y.head == :elseif + parse_top_level_branch(y.args[1], + y.args[2].args, + lastindex(y.args) == 3 ? y.args[3] : nothing, + :elseif) + else + yblocks = parse_top_level_branch(nothing, y.args, nothing, :block) + + for i in 1:lastindex(yblocks) + yblocks[i] !== nothing && (yblocks[i] = yblocks[i].args[end]) + end + yblocks + end + for i in 1:lastindex(yblocks) + if lastindex(blocks[i].args) == 1 + push_something!(blocks[i].args, Expr(:block), yblocks[i]) + elseif lastindex(blocks[i].args) == 0 + blocks[i] = yblocks[i] + else + push_something!(blocks[i].args, yblocks[i]) + end + end + end + + for i in 1:lastindex(blocks) + blocks[i] !== nothing && isempty(blocks[i].args) && (blocks[i] = nothing) + end + + return blocks +end + +function parse_conditional_model_statements(comps, dict, eqs, exprs, kwargs, mod, + ps, vs, where_types, component_blk, equations_blk, parameter_blk, variable_blk) + parameter_blk !== nothing && + parse_variables!( + exprs.args, ps, dict, mod, :(begin + $parameter_blk + end), :parameters, kwargs, where_types) + + variable_blk !== nothing && + parse_variables!( + exprs.args, vs, dict, mod, :(begin + $variable_blk + end), :variables, kwargs, where_types) + + component_blk !== nothing && + parse_components!(exprs.args, + comps, dict, :(begin + $component_blk + end), kwargs) + + equations_blk !== nothing && + parse_equations!(exprs.args, eqs, dict, :(begin + $equations_blk + end)) +end + +function _type_check!(val, a, type, class) + if val isa type + return val + else + try + return convert(type, val) + catch e + throw(TypeError(Symbol("`@mtkmodel`"), + "`$class`, while assigning to `$a`", type, typeof(val))) + end + end +end diff --git a/src/systems/nonlinear/homotopy_continuation.jl b/src/systems/nonlinear/homotopy_continuation.jl new file mode 100644 index 0000000000..9a77779103 --- /dev/null +++ b/src/systems/nonlinear/homotopy_continuation.jl @@ -0,0 +1,563 @@ +function contains_variable(x, wrt) + any(y -> occursin(y, x), wrt) +end + +""" +Possible reasons why a term is not polynomial +""" +EnumX.@enumx NonPolynomialReason begin + """ + Exponent of an expression involving unknowns is not an integer. + """ + NonIntegerExponent + """ + Exponent is an expression containing unknowns. + """ + ExponentContainsUnknowns + """ + The base of an exponent is not a polynomial in the unknowns. + """ + BaseNotPolynomial + """ + An expression involves a non-polynomial operation involving unknowns. + """ + UnrecognizedOperation +end + +function display_reason(reason::NonPolynomialReason.T, sym) + if reason == NonPolynomialReason.NonIntegerExponent + pow = arguments(sym)[2] + "In $sym: Exponent $pow is not an integer" + elseif reason == NonPolynomialReason.ExponentContainsUnknowns + pow = arguments(sym)[2] + "In $sym: Exponent $pow contains unknowns of the system" + elseif reason == NonPolynomialReason.BaseNotPolynomial + base = arguments(sym)[1] + "In $sym: Base $base is not a polynomial in the unknowns" + elseif reason == NonPolynomialReason.UnrecognizedOperation + op = operation(sym) + """ + In $sym: Operation $op is not recognized. Allowed polynomial operations are \ + `*, /, +, -, ^`. + """ + else + error("This should never happen. Please open an issue in ModelingToolkit.jl.") + end +end + +""" + $(TYPEDEF) + +Information about an expression about its polynomial nature. +""" +mutable struct PolynomialData + """ + A list of all non-polynomial terms in the expression. + """ + non_polynomial_terms::Vector{BasicSymbolic} + """ + Corresponding to `non_polynomial_terms`, a list of reasons why they are + not polynomial. + """ + reasons::Vector{NonPolynomialReason.T} + """ + Whether the polynomial contains parametric exponents of unknowns. + """ + has_parametric_exponent::Bool +end + +PolynomialData() = PolynomialData(BasicSymbolic[], NonPolynomialReason.T[], false) + +abstract type PolynomialTransformationError <: Exception end + +struct MultivarTerm <: PolynomialTransformationError + term::Any + vars::Any +end + +function Base.showerror(io::IO, err::MultivarTerm) + println(io, + "Cannot convert system to polynomial: Found term $(err.term) which is a function of multiple unknowns $(err.vars).") +end + +struct MultipleTermsOfSameVar <: PolynomialTransformationError + terms::Any + var::Any +end + +function Base.showerror(io::IO, err::MultipleTermsOfSameVar) + println(io, + "Cannot convert system to polynomial: Found multiple non-polynomial terms $(err.terms) involving the same unknown $(err.var).") +end + +struct SymbolicSolveFailure <: PolynomialTransformationError + term::Any + var::Any +end + +function Base.showerror(io::IO, err::SymbolicSolveFailure) + println(io, + "Cannot convert system to polynomial: Unable to symbolically solve $(err.term) for $(err.var).") +end + +struct NemoNotLoaded <: PolynomialTransformationError end + +function Base.showerror(io::IO, err::NemoNotLoaded) + println(io, + "ModelingToolkit may be able to solve this system as a polynomial system if `Nemo` is loaded. Run `import Nemo` and try again.") +end + +struct VariablesAsPolyAndNonPoly <: PolynomialTransformationError + vars::Any +end + +function Base.showerror(io::IO, err::VariablesAsPolyAndNonPoly) + println(io, + "Cannot convert convert system to polynomial: Variables $(err.vars) occur in both polynomial and non-polynomial terms in the system.") +end + +struct NotPolynomialError <: Exception + transformation_err::Union{PolynomialTransformationError, Nothing} + eq::Vector{Equation} + data::Vector{PolynomialData} +end + +function Base.showerror(io::IO, err::NotPolynomialError) + if err.transformation_err !== nothing + Base.showerror(io, err.transformation_err) + end + for (eq, data) in zip(err.eq, err.data) + if isempty(data.non_polynomial_terms) + continue + end + println(io, + "Equation $(eq) is not a polynomial in the unknowns for the following reasons:") + for (term, reason) in zip(data.non_polynomial_terms, data.reasons) + println(io, display_reason(reason, term)) + end + end +end + +function is_polynomial!(data, y, wrt) + process_polynomial!(data, y, wrt) + isempty(data.reasons) +end + +""" +$(TYPEDSIGNATURES) + +Return information about the polynmial `x` with respect to variables in `wrt`, +writing said information to `data`. +""" +function process_polynomial!(data::PolynomialData, x, wrt) + x = unwrap(x) + symbolic_type(x) == NotSymbolic() && return true + iscall(x) || return true + contains_variable(x, wrt) || return true + any(isequal(x), wrt) && return true + + if operation(x) in (*, +, -, /) + # `map` because `all` will early exit, but we want to search + # through everything to get all the non-polynomial terms + return all(map(y -> is_polynomial!(data, y, wrt), arguments(x))) + end + if operation(x) == (^) + b, p = arguments(x) + is_pow_integer = symtype(p) <: Integer + if !is_pow_integer + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.NonIntegerExponent) + end + if symbolic_type(p) != NotSymbolic() + data.has_parametric_exponent = true + end + + exponent_has_unknowns = contains_variable(p, wrt) + if exponent_has_unknowns + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.ExponentContainsUnknowns) + end + base_polynomial = is_polynomial!(data, b, wrt) + return base_polynomial && !exponent_has_unknowns && is_pow_integer + end + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.UnrecognizedOperation) + return false +end + +""" + $(TYPEDEF) + +Information about how an unknown in the system is substituted for a non-polynomial +expression to turn the system into a polynomial. Used in `PolynomialTransformation`. +""" +struct PolynomialTransformationData + """ + The new variable to use as an unknown of the transformed system. + """ + new_var::BasicSymbolic + """ + The non-polynomial expression being substituted. + """ + term::BasicSymbolic + """ + A vector of expressions corresponding to the solutions of + the non-polynomial expression `term` in terms of the new unknown `new_var`, + used to backsolve for the original unknown of the system. + """ + inv_term::Vector{BasicSymbolic} +end + +""" + $(TYPEDEF) + +Information representing how to transform a `NonlinearSystem` into a polynomial +system. +""" +struct PolynomialTransformation + """ + Substitutions mapping non-polynomial terms to temporary unknowns. The system + is a polynomial in the new unknowns. Currently, each non-polynomial term is a + function of a single unknown of the original system. + """ + substitution_rules::Dict{BasicSymbolic, BasicSymbolic} + """ + A vector of expressions involving unknowns of the transformed system, mapping + back to solutions of the original system. + """ + all_solutions::Vector{Vector{BasicSymbolic}} + """ + The new unknowns of the transformed system. + """ + new_dvs::Vector{BasicSymbolic} + """ + The polynomial data for each equation. + """ + polydata::Vector{PolynomialData} +end + +function PolynomialTransformation(sys::NonlinearSystem) + # we need to consider `full_equations` because observed also should be + # polynomials (if used in equations) and we don't know if observed is used + # in denominator. + # This is not the most efficient, and would be improved significantly with + # CSE/hashconsing. + eqs = full_equations(sys) + dvs = unknowns(sys) + + # Collect polynomial information about all equations + polydata = map(eqs) do eq + data = PolynomialData() + process_polynomial!(data, eq.lhs, dvs) + process_polynomial!(data, eq.rhs, dvs) + data + end + + # Get all unique non-polynomial terms + # NOTE: + # Is there a better way to check for uniqueness? `simplify` is relatively slow + # (maybe use the threaded version?) and `expand` can blow up expression size. + # Could metatheory help? + all_non_poly_terms = mapreduce( + d -> d.non_polynomial_terms, vcat, polydata; init = BasicSymbolic[]) + unique!(all_non_poly_terms) + + # each variable can only be replaced by one non-polynomial expression involving + # that variable. Keep track of this mapping. + var_to_nonpoly = Dict{BasicSymbolic, PolynomialTransformationData}() + + is_poly = true + transformation_err = nothing + for t in all_non_poly_terms + # if the term involves multiple unknowns, we can't invert it + dvs_in_term = map(x -> occursin(x, t), dvs) + if count(dvs_in_term) > 1 + transformation_err = MultivarTerm(t, dvs[dvs_in_term]) + is_poly = false + break + end + # we already have a substitution solving for `var` + var = dvs[findfirst(dvs_in_term)] + if haskey(var_to_nonpoly, var) && !isequal(var_to_nonpoly[var].term, t) + transformation_err = MultipleTermsOfSameVar([t, var_to_nonpoly[var].term], var) + is_poly = false + break + end + # we want to solve `term - new_var` for `var` + new_var = gensym(Symbol(var)) + new_var = unwrap(only(@variables $new_var)) + invterm = Symbolics.ia_solve( + t - new_var, var; complex_roots = false, periodic_roots = false, warns = false) + # if we can't invert it, quit + if invterm === nothing || isempty(invterm) + transformation_err = SymbolicSolveFailure(t, var) + is_poly = false + break + end + # `ia_solve` returns lazy terms i.e. `asin(1.0)` instead of `pi/2` + # this just evaluates the constant expressions + invterm = Symbolics.substitute.(invterm, (Dict(),)) + # RootsOf implies Symbolics couldn't solve the inner polynomial because + # `Nemo` wasn't loaded. + if any(x -> iscall(x) && operation(x) == Symbolics.RootsOf, invterm) + transformation_err = NemoNotLoaded() + is_poly = false + break + end + var_to_nonpoly[var] = PolynomialTransformationData(new_var, t, invterm) + end + + # return the error instead of throwing it, so the user can choose what to do + # without having to catch the exception + if !is_poly + return NotPolynomialError(transformation_err, eqs, polydata) + end + + subrules = Dict{BasicSymbolic, BasicSymbolic}() + # corresponding to each unknown in `dvs`, the list of its possible solutions + # in terms of the new unknown. + combinations = Vector{BasicSymbolic}[] + new_dvs = BasicSymbolic[] + for x in dvs + if haskey(var_to_nonpoly, x) + _data = var_to_nonpoly[x] + # map term to new unknown + subrules[_data.term] = _data.new_var + push!(combinations, _data.inv_term) + push!(new_dvs, _data.new_var) + else + push!(combinations, BasicSymbolic[x]) + push!(new_dvs, x) + end + end + all_solutions = vec(collect.(collect(Iterators.product(combinations...)))) + return PolynomialTransformation(subrules, all_solutions, new_dvs, polydata) +end + +""" + $(TYPEDEF) + +A struct containing the result of transforming a system into a polynomial system +using the appropriate `PolynomialTransformation`. Also contains the denominators +in the equations, to rule out invalid roots. +""" +struct PolynomialTransformationResult + sys::NonlinearSystem + denominators::Vector{BasicSymbolic} +end + +""" + $(TYPEDSIGNATURES) + +Transform the system `sys` with `transformation` and return a +`PolynomialTransformationResult`, or a `NotPolynomialError` if the system cannot +be transformed. +""" +function transform_system(sys::NonlinearSystem, transformation::PolynomialTransformation; + fraction_cancel_fn = simplify_fractions) + subrules = transformation.substitution_rules + dvs = unknowns(sys) + eqs = full_equations(sys) + polydata = transformation.polydata + new_dvs = transformation.new_dvs + all_solutions = transformation.all_solutions + + eqs2 = Equation[] + denoms = BasicSymbolic[] + for eq in eqs + t = eq.rhs - eq.lhs + t = Symbolics.fixpoint_sub(t, subrules; maxiters = length(dvs)) + # the substituted variable occurs outside the substituted term + poly_and_nonpoly = map(dvs) do x + all(!isequal(x), new_dvs) && occursin(x, t) + end + if any(poly_and_nonpoly) + return NotPolynomialError( + VariablesAsPolyAndNonPoly(dvs[poly_and_nonpoly]), eqs, polydata) + end + num, den = handle_rational_polynomials(t, new_dvs; fraction_cancel_fn) + # make factors different elements, otherwise the nonzero factors artificially + # inflate the error of the zero factor. + if iscall(den) && operation(den) == * + for arg in arguments(den) + # ignore constant factors + symbolic_type(arg) == NotSymbolic() && continue + push!(denoms, abs(arg)) + end + elseif symbolic_type(den) != NotSymbolic() + push!(denoms, abs(den)) + end + push!(eqs2, 0 ~ num) + end + + sys2 = @set sys.eqs = eqs2 + @set! sys2.unknowns = new_dvs + # remove observed equations to avoid adding them in codegen + @set! sys2.observed = Equation[] + @set! sys2.substitutions = nothing + return PolynomialTransformationResult(sys2, denoms) +end + +""" +$(TYPEDSIGNATURES) + +Given a `x`, a polynomial in variables in `wrt` which may contain rational functions, +express `x` as a single rational function with polynomial `num` and denominator `den`. +Return `(num, den)`. + +Keyword arguments: +- `fraction_cancel_fn`: A function which takes a fraction (`operation(expr) == /`) and returns + a simplified symbolic quantity with common factors in the numerator and denominator are + cancelled. Defaults to `SymbolicUtils.simplify_fractions`, but can be changed to + `nothing` to improve performance on large polynomials at the cost of avoiding non-trivial + cancellation. +""" +function handle_rational_polynomials(x, wrt; fraction_cancel_fn = simplify_fractions) + x = unwrap(x) + symbolic_type(x) == NotSymbolic() && return x, 1 + iscall(x) || return x, 1 + contains_variable(x, wrt) || return x, 1 + any(isequal(x), wrt) && return x, 1 + + op = operation(x) + args = arguments(x) + + if op == / + # numerator and denominator are trivial + num, den = args + n1, d1 = handle_rational_polynomials(num, wrt; fraction_cancel_fn) + n2, d2 = handle_rational_polynomials(den, wrt; fraction_cancel_fn) + num, den = n1 * d2, d1 * n2 + elseif (op == +) || (op == -) + num = 0 + den = 1 + if op == - + args[2] = -args[2] + end + for arg in args + n, d = handle_rational_polynomials(arg, wrt; fraction_cancel_fn) + num = num * d + n * den + den *= d + end + elseif op == ^ + base, pow = args + num, den = handle_rational_polynomials(base, wrt; fraction_cancel_fn) + num ^= pow + den ^= pow + elseif op == * + num = 1 + den = 1 + for arg in args + n, d = handle_rational_polynomials(arg, wrt; fraction_cancel_fn) + num *= n + den *= d + end + else + error("Unhandled operation in `handle_rational_polynomials`. This should never happen. Please open an issue in ModelingToolkit.jl with an MWE.") + end + + if fraction_cancel_fn !== nothing + expr = fraction_cancel_fn(num / den) + if iscall(expr) && operation(expr) == / + num, den = arguments(expr) + else + num, den = expr, 1 + end + end + + # if the denominator isn't a polynomial in `wrt`, better to not include it + # to reduce the size of the gcd polynomial + if !contains_variable(den, wrt) + return num / den, 1 + end + return num, den +end + +function SciMLBase.HomotopyNonlinearFunction(sys::NonlinearSystem, args...; kwargs...) + ODEFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{true}(sys::NonlinearSystem, args...; + kwargs...) + ODEFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{false}(sys::NonlinearSystem, args...; + kwargs...) + ODEFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{iip, specialize}( + sys::NonlinearSystem, args...; eval_expression = false, eval_module = @__MODULE__, + p = nothing, fraction_cancel_fn = SymbolicUtils.simplify_fractions, cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `HomotopyContinuationFunction`") + end + transformation = PolynomialTransformation(sys) + if transformation isa NotPolynomialError + throw(transformation) + end + result = transform_system(sys, transformation; fraction_cancel_fn) + if result isa NotPolynomialError + throw(result) + end + + sys2 = result.sys + denoms = result.denominators + polydata = transformation.polydata + new_dvs = transformation.new_dvs + all_solutions = transformation.all_solutions + + # we want to create f, jac etc. according to `sys2` since that will do the solving + # but the `sys` inside for symbolic indexing should be the non-polynomial system + fn = NonlinearFunction{iip}(sys2; eval_expression, eval_module, cse, kwargs...) + obsfn = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + fn = remake(fn; sys = sys, observed = obsfn) + + denominator = build_explicit_observed_function(sys2, denoms) + unpolynomialize = build_explicit_observed_function(sys2, all_solutions) + + inv_mapping = Dict(v => k for (k, v) in transformation.substitution_rules) + polynomialize_terms = [get(inv_mapping, var, var) for var in unknowns(sys2)] + polynomialize = build_explicit_observed_function(sys, polynomialize_terms) + + return HomotopyNonlinearFunction{iip, specialize}( + fn; polynomialize, unpolynomialize, denominator) +end + +struct HomotopyContinuationProblem{iip, specialization} end + +function HomotopyContinuationProblem(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{true}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem(sys::NonlinearSystem, t, + u0map::StaticArray, + args...; + kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}( + sys, t, u0map, args...; kwargs...) +end + +function HomotopyContinuationProblem{true}(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{false}(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{iip, spec}( + sys::NonlinearSystem, u0map, pmap = SciMLBase.NullParameters(); + kwargs...) where {iip, spec} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `HomotopyContinuationProblem`") + end + f, u0, p = process_SciMLProblem( + HomotopyNonlinearFunction{iip, spec}, sys, u0map, pmap; kwargs...) + + kwargs = filter_kwargs(kwargs) + return NonlinearProblem{iip}(f, u0, p; kwargs...) +end diff --git a/src/systems/nonlinear/initializesystem.jl b/src/systems/nonlinear/initializesystem.jl new file mode 100644 index 0000000000..ec25b9b660 --- /dev/null +++ b/src/systems/nonlinear/initializesystem.jl @@ -0,0 +1,773 @@ +""" +$(TYPEDSIGNATURES) + +Generate `NonlinearSystem` which initializes a problem from specified initial conditions of an `AbstractTimeDependentSystem`. +""" +function generate_initializesystem(sys::AbstractTimeDependentSystem; + u0map = Dict(), + pmap = Dict(), + initialization_eqs = [], + guesses = Dict(), + default_dd_guess = Bool(0), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), extra_metadata = (;), kwargs...) + eqs = equations(sys) + if !(eqs isa Vector{Equation}) + eqs = Equation[x for x in eqs if x isa Equation] + end + trueobs, eqs = unhack_observed(observed(sys), eqs) + # remove any observed equations that directly or indirectly contain + # delayed unknowns + isempty(trueobs) || filter_delay_equations_variables!(sys, trueobs) + vars = unique([unknowns(sys); getfield.(trueobs, :lhs)]) + vars_set = Set(vars) # for efficient in-lookup + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + additional_initialization_eqs = Vector{Equation}(initialization_eqs) + guesses = merge(get_guesses(sys), additional_guesses) + idxs_diff = isdiffeq.(eqs) + + # PREPROCESSING + u0map = copy(anydict(u0map)) + pmap = anydict(pmap) + initsys_preprocessing!(u0map, defs) + + # 1) Use algebraic equations of system as initialization constraints + idxs_alge = .!idxs_diff + append!(eqs_ics, eqs[idxs_alge]) # start equation list with algebraic equations + + eqs_diff = eqs[idxs_diff] + D = Differential(get_iv(sys)) + diffmap = merge( + Dict(eq.lhs => eq.rhs for eq in eqs_diff), + Dict(D(eq.lhs) => D(eq.rhs) for eq in trueobs) + ) + + if has_schedule(sys) && (schedule = get_schedule(sys); !isnothing(schedule)) + # 2) process dummy derivatives and u0map into initialization system + # prepare map for dummy derivative substitution + for x in filter(x -> !isnothing(x[1]), schedule.dummy_sub) + # set dummy derivatives to default_dd_guess unless specified + push!(defs, x[1] => get(guesses, x[1], default_dd_guess)) + end + function process_u0map_with_dummysubs(y, x) + y = get(schedule.dummy_sub, y, y) + y = fixpoint_sub(y, diffmap) + # If we have `D(x) ~ x` and provide [D(x) => x, x => 1.0] to `u0map`, then + # without this condition `defs` would get `x => x` instead of retaining + # `x => 1.0`. + isequal(y, x) && return + if y ∈ vars_set + # variables specified in u0 overrides defaults + push!(defs, y => x) + elseif y isa Symbolics.Arr + # TODO: don't scalarize arrays + merge!(defs, Dict(scalarize(y .=> x))) + elseif y isa Symbolics.BasicSymbolic + # y is a derivative expression expanded; add it to the initialization equations + push!(eqs_ics, y ~ x) + else + error("Initialization expression $y is currently not supported. If its a higher order derivative expression, then only the dummy derivative expressions are supported.") + end + end + for (y, x) in u0map + if Symbolics.isarraysymbolic(y) + process_u0map_with_dummysubs.(collect(y), collect(x)) + else + process_u0map_with_dummysubs(y, x) + end + end + else + # 2) System doesn't have a schedule, so dummy derivatives don't exist/aren't handled (SDESystem) + for (k, v) in u0map + defs[k] = v + end + end + + # 3) process other variables + for var in vars + if var ∈ keys(defs) + push!(eqs_ics, var ~ defs[var]) + elseif var ∈ keys(guesses) + push!(defs, var => guesses[var]) + elseif check_defguess + error("Invalid setup: variable $(var) has no default value or initial guess") + end + end + + # 4) process explicitly provided initialization equations + if !algebraic_only + initialization_eqs = [get_initialization_eqs(sys); initialization_eqs] + for eq in initialization_eqs + eq = fixpoint_sub(eq, diffmap) # expand dummy derivatives + push!(eqs_ics, eq) + end + end + + # 5) process parameters as initialization unknowns + paramsubs = setup_parameter_initialization!( + sys, pmap, defs, guesses, eqs_ics; check_defguess) + + # 6) parameter dependencies become equations, their LHS become unknowns + # non-numeric dependent parameters stay as parameter dependencies + new_parameter_deps = solve_parameter_dependencies!( + sys, paramsubs, eqs_ics, defs, guesses) + + # 7) handle values provided for dependent parameters similar to values for observed variables + handle_dependent_parameter_constraints!(sys, pmap, eqs_ics, paramsubs) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + p -> !haskey(paramsubs, p), parameters(sys; initial_parameters = true))) + push!(pars, get_iv(sys)) + + # 8) use observed equations for guesses of observed variables if not provided + for eq in trueobs + haskey(defs, eq.lhs) && continue + any(x -> isequal(default_toterm(x), eq.lhs), keys(defs)) && continue + + defs[eq.lhs] = eq.rhs + end + append!(eqs_ics, trueobs) + + vars = [vars; collect(values(paramsubs))] + + # even if `p => tovar(p)` is in `paramsubs`, `isparameter(p[1]) === true` after substitution + # so add scalarized versions as well + scalarize_varmap!(paramsubs) + + eqs_ics = Symbolics.substitute.(eqs_ics, (paramsubs,)) + for k in keys(defs) + defs[k] = substitute(defs[k], paramsubs) + end + meta = InitializationSystemMetadata( + anydict(u0map), anydict(pmap), additional_guesses, + additional_initialization_eqs, extra_metadata, nothing) + return NonlinearSystem(eqs_ics, + vars, + pars; + defaults = defs, + checks = check_units, + parameter_dependencies = new_parameter_deps, + name, + metadata = meta, + kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Generate `NonlinearSystem` which initializes a problem from specified initial conditions of an `AbstractTimeDependentSystem`. +""" +function generate_initializesystem(sys::AbstractTimeIndependentSystem; + u0map = Dict(), + pmap = Dict(), + initialization_eqs = [], + guesses = Dict(), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), extra_metadata = (;), kwargs...) + eqs = equations(sys) + trueobs, eqs = unhack_observed(observed(sys), eqs) + vars = unique([unknowns(sys); getfield.(trueobs, :lhs)]) + vars_set = Set(vars) # for efficient in-lookup + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + additional_initialization_eqs = Vector{Equation}(initialization_eqs) + guesses = merge(get_guesses(sys), additional_guesses) + + # PREPROCESSING + u0map = copy(anydict(u0map)) + pmap = anydict(pmap) + initsys_preprocessing!(u0map, defs) + + # Calculate valid `Initial` parameters. These are unknowns for + # which constant initial values were provided. By this point, + # they have been separated into `x => Initial(x)` in `u0map` + # and `Initial(x) => val` in `pmap`. + valid_initial_parameters = Set{BasicSymbolic}() + for (k, v) in u0map + isequal(Initial(k), v) || continue + push!(valid_initial_parameters, v) + end + + # get the initialization equations + if !algebraic_only + initialization_eqs = [get_initialization_eqs(sys); initialization_eqs] + end + + # only include initialization equations where all the involved `Initial` + # parameters are valid. + vs = Set() + initialization_eqs = filter(initialization_eqs) do eq + empty!(vs) + vars!(vs, eq; op = Initial) + non_params = filter(!isparameter, vs) + # error if non-parameters are present in the initialization equations + if !isempty(non_params) + throw(UnknownsInTimeIndependentInitializationError(eq, non_params)) + end + filter!(x -> iscall(x) && isinitial(x), vs) + invalid_initials = setdiff(vs, valid_initial_parameters) + return isempty(invalid_initials) + end + + append!(eqs_ics, initialization_eqs) + + # process parameters as initialization unknowns + paramsubs = setup_parameter_initialization!( + sys, pmap, defs, guesses, eqs_ics; check_defguess) + + # parameter dependencies become equations, their LHS become unknowns + # non-numeric dependent parameters stay as parameter dependencies + new_parameter_deps = solve_parameter_dependencies!( + sys, paramsubs, eqs_ics, defs, guesses) + + # handle values provided for dependent parameters similar to values for observed variables + handle_dependent_parameter_constraints!(sys, pmap, eqs_ics, paramsubs) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + p -> !haskey(paramsubs, p), parameters(sys; initial_parameters = true))) + vars = collect(values(paramsubs)) + + # even if `p => tovar(p)` is in `paramsubs`, `isparameter(p[1]) === true` after substitution + # so add scalarized versions as well + scalarize_varmap!(paramsubs) + + eqs_ics = Symbolics.substitute.(eqs_ics, (paramsubs,)) + for k in keys(defs) + defs[k] = substitute(defs[k], paramsubs) + end + meta = InitializationSystemMetadata( + anydict(u0map), anydict(pmap), additional_guesses, + additional_initialization_eqs, extra_metadata, nothing) + return NonlinearSystem(eqs_ics, + vars, + pars; + defaults = defs, + checks = check_units, + parameter_dependencies = new_parameter_deps, + name, + metadata = meta, + kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Preprocessing step for initialization. Currently removes key `k` from `defs` and `u0map` +if `k => nothing` is present in `u0map`. +""" +function initsys_preprocessing!(u0map::AbstractDict, defs::AbstractDict) + for (k, v) in u0map + v === nothing || continue + delete!(defs, k) + end + filter_missing_values!(u0map) +end + +""" + $(TYPEDSIGNATURES) + +Update `defs` and `eqs_ics` appropriately for parameter initialization. Return a dictionary +mapping solvable parameters to their `tovar` variants. +""" +function setup_parameter_initialization!( + sys::AbstractSystem, pmap::AbstractDict, defs::AbstractDict, + guesses::AbstractDict, eqs_ics::Vector{Equation}; check_defguess = false) + paramsubs = Dict() + for p in parameters(sys) + if is_parameter_solvable(p, pmap, defs, guesses) + # If either of them are `missing` the parameter is an unknown + # But if the parameter is passed a value, use that as an additional + # equation in the system + _val1 = get_possibly_array_fallback_singletons(pmap, p) + _val2 = get_possibly_array_fallback_singletons(defs, p) + _val3 = get_possibly_array_fallback_singletons(guesses, p) + varp = tovar(p) + paramsubs[p] = varp + # Has a default of `missing`, and (either an equation using the value passed to `ODEProblem` or a guess) + if _val2 === missing + if _val1 !== nothing && _val1 !== missing + push!(eqs_ics, varp ~ _val1) + push!(defs, varp => _val1) + elseif _val3 !== nothing + # assuming an equation exists (either via algebraic equations or initialization_eqs) + push!(defs, varp => _val3) + elseif check_defguess + error("Invalid setup: parameter $(p) has no default value, initial value, or guess") + end + # `missing` passed to `ODEProblem`, and (either an equation using default or a guess) + elseif _val1 === missing + if _val2 !== nothing && _val2 !== missing + push!(eqs_ics, varp ~ _val2) + push!(defs, varp => _val2) + elseif _val3 !== nothing + push!(defs, varp => _val3) + elseif check_defguess + error("Invalid setup: parameter $(p) has no default value, initial value, or guess") + end + # given a symbolic value to ODEProblem + elseif symbolic_type(_val1) != NotSymbolic() || is_array_of_symbolics(_val1) + push!(eqs_ics, varp ~ _val1) + push!(defs, varp => _val3) + # No value passed to `ODEProblem`, but a default and a guess are present + # _val2 !== missing is implied by it falling this far in the elseif chain + elseif _val1 === nothing && _val2 !== nothing + push!(eqs_ics, varp ~ _val2) + push!(defs, varp => _val3) + else + # _val1 !== missing and _val1 !== nothing, so a value was provided to ODEProblem + # This would mean `is_parameter_solvable` returned `false`, so we never end up + # here + error("This should never be reached") + end + end + end + + return paramsubs +end + +""" + $(TYPEDSIGNATURES) + +Add appropriate parameter dependencies as initialization equations. Return the new list of +parameter dependencies for the initialization system. +""" +function solve_parameter_dependencies!(sys::AbstractSystem, paramsubs::AbstractDict, + eqs_ics::Vector{Equation}, defs::AbstractDict, guesses::AbstractDict) + new_parameter_deps = Equation[] + for eq in parameter_dependencies(sys) + if !is_variable_floatingpoint(eq.lhs) + push!(new_parameter_deps, eq) + continue + end + varp = tovar(eq.lhs) + paramsubs[eq.lhs] = varp + push!(eqs_ics, eq) + guessval = get(guesses, eq.lhs, eq.rhs) + push!(defs, varp => guessval) + end + + return new_parameter_deps +end + +""" + $(TYPEDSIGNATURES) + +Turn values provided for parameter dependencies into initialization equations. +""" +function handle_dependent_parameter_constraints!(sys::AbstractSystem, pmap::AbstractDict, + eqs_ics::Vector{Equation}, paramsubs::AbstractDict) + for (k, v) in merge(defaults(sys), pmap) + if is_variable_floatingpoint(k) && has_parameter_dependency_with_lhs(sys, k) + push!(eqs_ics, paramsubs[k] ~ v) + end + end + + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Get a new symbolic variable of the same type and size as `sym`, which is a parameter. +""" +function get_initial_value_parameter(sym) + sym = default_toterm(unwrap(sym)) + name = hasname(sym) ? getname(sym) : Symbol(sym) + if iscall(sym) && operation(sym) === getindex + name = Symbol(name, :_, join(arguments(sym)[2:end], "_")) + end + name = Symbol(name, :ₘₜₖ_₀) + newvar = unwrap(similar_variable(sym, name; use_gensym = false)) + return toparam(newvar) +end + +""" + $(TYPEDSIGNATURES) + +Given `sys` and a list of observed equations `trueobs`, remove all the equations that +directly or indirectly contain a delayed unknown of `sys`. +""" +function filter_delay_equations_variables!(sys::AbstractSystem, trueobs::Vector{Equation}) + is_time_dependent(sys) || return trueobs + banned_vars = Set() + idxs_to_remove = Int[] + for (i, eq) in enumerate(trueobs) + _has_delays(sys, eq.rhs, banned_vars) || continue + push!(idxs_to_remove, i) + push!(banned_vars, eq.lhs) + end + return deleteat!(trueobs, idxs_to_remove) +end + +""" + $(TYPEDSIGNATURES) + +Check if the expression `ex` contains a delayed unknown of `sys` or a term in +`banned`. +""" +function _has_delays(sys::AbstractSystem, ex, banned) + ex = unwrap(ex) + ex in banned && return true + if symbolic_type(ex) == NotSymbolic() + if is_array_of_symbolics(ex) + return any(x -> _has_delays(sys, x, banned), ex) + end + return false + end + iscall(ex) || return false + op = operation(ex) + args = arguments(ex) + if iscalledparameter(ex) + return any(x -> _has_delays(sys, x, banned), args) + end + if issym(op) && length(args) == 1 && is_variable(sys, op(get_iv(sys))) && + iscall(args[1]) && get_iv(sys) in vars(args[1]) + return true + end + return any(x -> _has_delays(sys, x, banned), args) +end + +struct ReconstructInitializeprob + getter::Any + setter::Any +end + +function ReconstructInitializeprob( + srcsys::AbstractSystem, dstsys::AbstractSystem) + syms = reduce( + vcat, reorder_parameters(dstsys, parameters(dstsys)); + init = []) + getter = getu(srcsys, syms) + setter = setp_oop(dstsys, syms) + return ReconstructInitializeprob(getter, setter) +end + +function (rip::ReconstructInitializeprob)(srcvalp, dstvalp) + newp = rip.setter(dstvalp, rip.getter(srcvalp)) + if state_values(dstvalp) === nothing + return nothing, newp + end + srcu0 = state_values(srcvalp) + T = srcu0 === nothing || isempty(srcu0) ? Union{} : eltype(srcu0) + if parameter_values(dstvalp) isa MTKParameters + if !isempty(newp.tunable) + T = promote_type(eltype(newp.tunable), T) + end + elseif !isempty(newp) + T = promote_type(eltype(newp), T) + end + if T == eltype(state_values(dstvalp)) + u0 = state_values(dstvalp) + elseif T != Union{} + u0 = T.(state_values(dstvalp)) + end + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Tunable(), newp) + if eltype(buf) != T + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Initials(), newp) + if eltype(buf) != T + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + return u0, newp +end + +struct InitializationSystemMetadata + u0map::Dict{Any, Any} + pmap::Dict{Any, Any} + additional_guesses::Dict{Any, Any} + additional_initialization_eqs::Vector{Equation} + extra_metadata::NamedTuple + oop_reconstruct_u0_p::Union{Nothing, ReconstructInitializeprob} +end + +function get_possibly_array_fallback_singletons(varmap, p) + if haskey(varmap, p) + return varmap[p] + end + if symbolic_type(p) == ArraySymbolic() + is_sized_array_symbolic(p) || return nothing + scal = collect(p) + if all(x -> haskey(varmap, x), scal) + res = [varmap[x] for x in scal] + if any(x -> x === nothing, res) + return nothing + elseif any(x -> x === missing, res) + return missing + end + return res + end + elseif iscall(p) && operation(p) == getindex + arrp = arguments(p)[1] + val = get_possibly_array_fallback_singletons(varmap, arrp) + if val === nothing + return nothing + elseif val === missing + return missing + else + return val + end + end + return nothing +end + +function is_parameter_solvable(p, pmap, defs, guesses) + p = unwrap(p) + is_variable_floatingpoint(p) || return false + _val1 = pmap isa AbstractDict ? get_possibly_array_fallback_singletons(pmap, p) : + nothing + _val2 = get_possibly_array_fallback_singletons(defs, p) + _val3 = get_possibly_array_fallback_singletons(guesses, p) + # either (missing is a default or was passed to the ODEProblem) or (nothing was passed to + # the ODEProblem and it has a default and a guess) + return ((_val1 === missing || _val2 === missing) || + (symbolic_type(_val1) != NotSymbolic() || is_array_of_symbolics(_val1) || + _val1 === nothing && _val2 !== nothing)) && _val3 !== nothing +end + +function SciMLBase.remake_initialization_data( + sys::AbstractSystem, odefn, u0, t0, p, newu0, newp) + if u0 === missing && p === missing + return odefn.initialization_data + end + if !(eltype(u0) <: Pair) && !(eltype(p) <: Pair) + oldinitdata = odefn.initialization_data + oldinitdata === nothing && return nothing + + oldinitprob = oldinitdata.initializeprob + oldinitprob === nothing && return nothing + if !SciMLBase.has_sys(oldinitprob.f) || !(oldinitprob.f.sys isa NonlinearSystem) + return oldinitdata + end + oldinitsys = oldinitprob.f.sys + meta = get_metadata(oldinitsys) + if meta isa InitializationSystemMetadata && meta.oop_reconstruct_u0_p !== nothing + reconstruct_fn = meta.oop_reconstruct_u0_p + else + reconstruct_fn = ReconstructInitializeprob(sys, oldinitsys) + end + # the history function doesn't matter because `reconstruct_fn` is only going to + # update the values of parameters, which aren't time dependent. The reason it + # is called is because `Initial` parameters are calculated from the corresponding + # state values. + history_fn = is_time_dependent(sys) && !is_markovian(sys) ? Returns(newu0) : nothing + new_initu0, new_initp = reconstruct_fn( + ProblemState(; u = newu0, p = newp, t = t0, h = history_fn), oldinitprob) + if oldinitprob.f.resid_prototype === nothing + newf = oldinitprob.f + else + newf = NonlinearFunction{ + SciMLBase.isinplace(oldinitprob.f), SciMLBase.specialization(oldinitprob.f)}( + oldinitprob.f; + resid_prototype = calculate_resid_prototype( + length(oldinitprob.f.resid_prototype), new_initu0, new_initp)) + end + initprob = remake(oldinitprob; f = newf, u0 = new_initu0, p = new_initp) + return SciMLBase.OverrideInitData(initprob, oldinitdata.update_initializeprob!, + oldinitdata.initializeprobmap, oldinitdata.initializeprobpmap) + end + dvs = unknowns(sys) + ps = parameters(sys) + u0map = to_varmap(u0, dvs) + symbols_to_symbolics!(sys, u0map) + add_toterms!(u0map) + pmap = to_varmap(p, ps) + symbols_to_symbolics!(sys, pmap) + guesses = Dict() + defs = defaults(sys) + cmap, cs = get_cmap(sys) + use_scc = true + initialization_eqs = Equation[] + + if SciMLBase.has_initializeprob(odefn) + oldsys = odefn.initialization_data.initializeprob.f.sys + meta = get_metadata(oldsys) + if meta isa InitializationSystemMetadata + u0map = merge(meta.u0map, u0map) + pmap = merge(meta.pmap, pmap) + merge!(guesses, meta.additional_guesses) + use_scc = get(meta.extra_metadata, :use_scc, true) + initialization_eqs = meta.additional_initialization_eqs + end + else + # there is no initializeprob, so the original problem construction + # had no solvable parameters and had the differential variables + # specified in `u0map`. + if u0 === missing + # the user didn't pass `u0` to `remake`, so they want to retain + # existing values. Fill the differential variables in `u0map`, + # initialization will either be elided or solve for the algebraic + # variables + diff_idxs = isdiffeq.(equations(sys)) + for i in eachindex(dvs) + diff_idxs[i] || continue + u0map[dvs[i]] = newu0[i] + end + end + # ensure all unknowns have guesses in case they weren't given one + # and become solvable + for i in eachindex(dvs) + haskey(guesses, dvs[i]) && continue + guesses[dvs[i]] = newu0[i] + end + if p === missing + # the user didn't pass `p` to `remake`, so they want to retain + # existing values. Fill all parameters in `pmap` so that none of + # them are solvable. + for p in ps + pmap[p] = getp(sys, p)(newp) + end + end + # all non-solvable parameters need values regardless + for p in ps + haskey(pmap, p) && continue + is_parameter_solvable(p, pmap, defs, guesses) && continue + pmap[p] = getp(sys, p)(newp) + end + end + if t0 === nothing && is_time_dependent(sys) + t0 = 0.0 + end + filter_missing_values!(u0map) + filter_missing_values!(pmap) + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0map, pmap, defs, cmap, dvs, ps) + floatT = float_type_from_varmap(op) + kws = maybe_build_initialization_problem( + sys, op, u0map, pmap, t0, defs, guesses, missing_unknowns; + use_scc, initialization_eqs, floatT, allow_incomplete = true) + + return SciMLBase.remake_initialization_data(sys, kws, newu0, t0, newp, newu0, newp) +end + +function SciMLBase.late_binding_update_u0_p( + prob, sys::AbstractSystem, u0, p, t0, newu0, newp) + supports_initialization(sys) || return newu0, newp + u0 === missing && return newu0, (p === missing ? copy(newp) : newp) + # non-symbolic u0 updates initials... + if !(eltype(u0) <: Pair) + # if `p` is not provided or is symbolic + p === missing || eltype(p) <: Pair || return newu0, newp + newu0 === nothing && return newu0, newp + all(is_parameter(sys, Initial(x)) for x in unknowns(sys)) || return newu0, newp + newp = p === missing ? copy(newp) : newp + initials, repack, alias = SciMLStructures.canonicalize( + SciMLStructures.Initials(), newp) + if eltype(initials) != eltype(newu0) + initials = DiffEqBase.promote_u0(initials, newu0, t0) + newp = repack(initials) + end + if length(newu0) != length(unknowns(sys)) + throw(ArgumentError("Expected `newu0` to be of same length as unknowns ($(length(unknowns(sys)))). Got $(typeof(newu0)) of length $(length(newu0))")) + end + setp(sys, Initial.(unknowns(sys)))(newp, newu0) + return newu0, newp + end + + newp = p === missing ? copy(newp) : newp + newu0 = DiffEqBase.promote_u0(newu0, newp, t0) + tunables, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Tunable(), newp) + if eltype(tunables) != eltype(newu0) + tunables = DiffEqBase.promote_u0(tunables, newu0, t0) + newp = repack(tunables) + end + initials, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Initials(), newp) + if eltype(initials) != eltype(newu0) + initials = DiffEqBase.promote_u0(initials, newu0, t0) + newp = repack(initials) + end + + allsyms = all_symbols(sys) + for (k, v) in u0 + v === nothing && continue + (symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v)) || continue + if k isa Symbol + k2 = symbol_to_symbolic(sys, k; allsyms) + # if it is returned as-is, there is no match so skip it + k2 === k && continue + k = k2 + end + is_parameter(sys, Initial(k)) || continue + setp(sys, Initial(k))(newp, v) + end + + return newu0, newp +end + +""" + $(TYPEDSIGNATURES) + +Check if the given system is an initialization system. +""" +function is_initializesystem(sys::AbstractSystem) + sys isa NonlinearSystem && get_metadata(sys) isa InitializationSystemMetadata +end + +""" +Counteracts the CSE/array variable hacks in `symbolics_tearing.jl` so it works with +initialization. +""" +function unhack_observed(obseqs::Vector{Equation}, eqs::Vector{Equation}) + subs = Dict() + tempvars = Set() + rm_idxs = Int[] + for (i, eq) in enumerate(obseqs) + iscall(eq.rhs) || continue + if operation(eq.rhs) == StructuralTransformations.change_origin + push!(rm_idxs, i) + continue + end + if operation(eq.rhs) == StructuralTransformations.getindex_wrapper + var, idxs = arguments(eq.rhs) + subs[eq.rhs] = var[idxs...] + push!(tempvars, var) + end + end + + for (i, eq) in enumerate(eqs) + iscall(eq.rhs) || continue + if operation(eq.rhs) == StructuralTransformations.getindex_wrapper + var, idxs = arguments(eq.rhs) + subs[eq.rhs] = var[idxs...] + push!(tempvars, var) + end + end + + for (i, eq) in enumerate(obseqs) + if eq.lhs in tempvars + subs[eq.lhs] = eq.rhs + push!(rm_idxs, i) + end + end + + obseqs = obseqs[setdiff(eachindex(obseqs), rm_idxs)] + obseqs = map(obseqs) do eq + fixpoint_sub(eq.lhs, subs) ~ fixpoint_sub(eq.rhs, subs) + end + eqs = map(eqs) do eq + fixpoint_sub(eq.lhs, subs) ~ fixpoint_sub(eq.rhs, subs) + end + return obseqs, eqs +end + +function UnknownsInTimeIndependentInitializationError(eq, non_params) + ArgumentError(""" + Initialization equations for time-independent systems can only contain parameters. \ + Found $non_params in $eq. If the equations refer to the initial guess for unknowns, \ + use the `Initial` operator. + """) +end diff --git a/src/systems/nonlinear/modelingtoolkitize.jl b/src/systems/nonlinear/modelingtoolkitize.jl new file mode 100644 index 0000000000..2f12157884 --- /dev/null +++ b/src/systems/nonlinear/modelingtoolkitize.jl @@ -0,0 +1,85 @@ +""" +$(TYPEDSIGNATURES) + +Generate `NonlinearSystem`, dependent variables, and parameters from an `NonlinearProblem`. +""" +function modelingtoolkitize( + prob::Union{NonlinearProblem, NonlinearLeastSquaresProblem}; + u_names = nothing, p_names = nothing, kwargs...) + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + if u_names !== nothing + varnames_length_check(prob.u0, u_names; is_unknowns = true) + _vars = [variable(name) for name in u_names] + elseif SciMLBase.has_sys(prob.f) + varnames = getname.(variable_symbols(prob.f.sys)) + varidxs = variable_index.((prob.f.sys,), varnames) + invpermute!(varnames, varidxs) + _vars = [variable(name) for name in varnames] + else + _vars = [variable(:x, i) for i in eachindex(prob.u0)] + end + _vars = reshape(_vars, size(prob.u0)) + + vars = prob.u0 isa Number ? _vars : ArrayInterface.restructure(prob.u0, _vars) + params = if has_p + if p_names === nothing && SciMLBase.has_sys(prob.f) + p_names = Dict(parameter_index(prob.f.sys, sym) => sym + for sym in parameter_symbols(prob.f.sys)) + end + _params = define_params(p, p_names) + p isa Number ? _params[1] : + (p isa Tuple || p isa NamedTuple || p isa AbstractDict || p isa MTKParameters ? + _params : + ArrayInterface.restructure(p, _params)) + else + [] + end + + if DiffEqBase.isinplace(prob) + if prob isa NonlinearLeastSquaresProblem + rhs = ArrayInterface.restructure( + prob.f.resid_prototype, similar(prob.f.resid_prototype, Num)) + prob.f(rhs, vars, params) + eqs = vcat([0.0 ~ rhs[i] for i in 1:length(prob.f.resid_prototype)]...) + else + rhs = ArrayInterface.restructure(prob.u0, similar(vars, Num)) + prob.f(rhs, vars, params) + eqs = vcat([0.0 ~ rhs[i] for i in 1:length(rhs)]...) + end + + else + rhs = prob.f(vars, params) + out_def = prob.f(prob.u0, prob.p) + eqs = vcat([0.0 ~ rhs[i] for i in 1:length(out_def)]...) + end + + sts = vec(collect(vars)) + _params = params + params = values(params) + params = if params isa Number || (params isa Array && ndims(params) == 0) + [params[1]] + else + vec(collect(params)) + end + default_u0 = Dict(sts .=> vec(collect(prob.u0))) + default_p = if has_p + if prob.p isa AbstractDict + Dict(v => prob.p[k] for (k, v) in pairs(_params)) + elseif prob.p isa MTKParameters + Dict(params .=> reduce(vcat, prob.p)) + else + Dict(params .=> vec(collect(prob.p))) + end + else + Dict() + end + + de = NonlinearSystem(eqs, sts, params, + defaults = merge(default_u0, default_p); + name = gensym(:MTKizedNonlinProb), + kwargs...) + + de +end diff --git a/src/systems/nonlinear/nonlinearsystem.jl b/src/systems/nonlinear/nonlinearsystem.jl index 40f1230ccd..856822492b 100644 --- a/src/systems/nonlinear/nonlinearsystem.jl +++ b/src/systems/nonlinear/nonlinearsystem.jl @@ -15,157 +15,435 @@ $(FIELDS) eqs = [0 ~ σ*(y-x), 0 ~ x*(ρ-z)-y, 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z],[σ,ρ,β]) +@named ns = NonlinearSystem(eqs, [x,y,z],[σ,ρ,β]) ``` """ -struct NonlinearSystem <: AbstractSystem +struct NonlinearSystem <: AbstractTimeIndependentSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt """Vector of equations defining the system.""" eqs::Vector{Equation} """Unknown variables.""" - states::Vector + unknowns::Vector """Parameters.""" ps::Vector + """Array variables.""" + var_to_name::Any + """Observed variables.""" observed::Vector{Equation} """ - Name: the name of the system + Jacobian matrix. Note: this field will not be defined until + [`calculate_jacobian`](@ref) is called on the system. + """ + jac::RefValue{Any} + """ + The name of the system. """ name::Symbol """ - systems: The internal systems + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. """ systems::Vector{NonlinearSystem} """ - defaults: The default values to use when initial conditions and/or + The default values to use when initial conditions and/or parameters are not supplied in `ODEProblem`. """ defaults::Dict """ - structure: structural information of the system + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Type of the system. + """ + connector_type::Any + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. """ - structure::Any + index_cache::Union{Nothing, IndexCache} """ - type: type of the system + The hierarchical parent system before simplification. """ - connection_type::Any + parent::Any + isscheduled::Bool + + function NonlinearSystem( + tag, eqs, unknowns, ps, var_to_name, observed, jac, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, connector_type, + parameter_dependencies = Equation[], metadata = nothing, gui_metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, + complete = false, index_cache = nothing, parent = nothing, + isscheduled = false; checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(unknowns, ps) + check_units(u, eqs) + check_subsystems(systems) + end + new(tag, eqs, unknowns, ps, var_to_name, observed, jac, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, + connector_type, parameter_dependencies, metadata, gui_metadata, tearing_state, + substitutions, namespacing, complete, index_cache, parent, isscheduled) + end end -function NonlinearSystem(eqs, states, ps; - observed=[], - name=gensym(:NonlinearSystem), - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - systems=NonlinearSystem[], - connection_type=nothing, - ) - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :NonlinearSystem, force=true) +function NonlinearSystem(eqs, unknowns, ps; + observed = [], + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + systems = NonlinearSystem[], + connector_type = nothing, + continuous_events = nothing, # this argument is only required for ODESystems, but is added here for the constructor to accept it without error + discrete_events = nothing, # this argument is only required for ODESystems, but is added here for the constructor to accept it without error + checks = true, + parameter_dependencies = Equation[], + metadata = nothing, + gui_metadata = nothing) + continuous_events === nothing || isempty(continuous_events) || + throw(ArgumentError("NonlinearSystem does not accept `continuous_events`, you provided $continuous_events")) + discrete_events === nothing || isempty(discrete_events) || + throw(ArgumentError("NonlinearSystem does not accept `discrete_events`, you provided $discrete_events")) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + length(unique(nameof.(systems))) == length(systems) || + throw(ArgumentError("System names must be unique.")) + (isempty(default_u0) && isempty(default_p)) || + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :NonlinearSystem, force = true) + + # Accept a single (scalar/vector) equation, but make array for consistent internal handling + if !(eqs isa AbstractArray) + eqs = [eqs] + end + + # Copy equations to canonical form, but do not touch array expressions + eqs = [wrap(eq.lhs) isa Symbolics.Arr ? eq : 0 ~ eq.rhs - eq.lhs for eq in eqs] + + jac = RefValue{Any}(EMPTY_JAC) + + ps′ = value.(ps) + dvs′ = value.(unknowns) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) + + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + NonlinearSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + eqs, dvs′, ps′, var_to_name, observed, jac, name, description, systems, defaults, + guesses, initializesystem, initialization_eqs, connector_type, parameter_dependencies, + metadata, gui_metadata, checks = checks) +end + +function NonlinearSystem(eqs; kwargs...) + eqs = collect(eqs) + allunknowns = OrderedSet() + ps = OrderedSet() + for eq in eqs + collect_vars!(allunknowns, ps, eq, nothing) + end + for eq in get(kwargs, :parameter_dependencies, Equation[]) + if eq isa Pair + collect_vars!(allunknowns, ps, eq, nothing) + else + collect_vars!(allunknowns, ps, eq, nothing) + end end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - NonlinearSystem(eqs, value.(states), value.(ps), observed, name, systems, defaults, nothing, connection_type) + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + if symbolic_type(p) == ArraySymbolic() && + Symbolics.shape(unwrap(p)) != Symbolics.Unknown() + for i in eachindex(p) + delete!(new_ps, p[i]) + end + end + push!(new_ps, p) + end + end + + return NonlinearSystem(eqs, collect(allunknowns), collect(new_ps); kwargs...) end -function calculate_jacobian(sys::NonlinearSystem;sparse=false,simplify=false) - rhs = [eq.rhs for eq ∈ equations(sys)] - vals = [dv for dv in states(sys)] +""" + $(TYPEDSIGNATURES) + +Convert an `ODESystem` to a `NonlinearSystem` solving for its steady state (where derivatives are zero). +Any differential variable `D(x) ~ f(...)` will be turned into `0 ~ f(...)`. The returned system is not +simplified. If the input system is `complete`d, then so will the returned system. +""" +function NonlinearSystem(sys::AbstractODESystem) + eqs = equations(sys) + obs = observed(sys) + subrules = Dict(D(x) => 0.0 for x in unknowns(sys)) + eqs = map(eqs) do eq + fast_substitute(eq, subrules) + end + + nsys = NonlinearSystem(eqs, unknowns(sys), [parameters(sys); get_iv(sys)]; + parameter_dependencies = parameter_dependencies(sys), + defaults = merge(defaults(sys), Dict(get_iv(sys) => Inf)), guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), name = nameof(sys), + observed = obs) + if iscomplete(sys) + nsys = complete(nsys; split = is_split(sys)) + end + return nsys +end + +function calculate_jacobian(sys::NonlinearSystem; sparse = false, simplify = false) + cache = get_jac(sys)[] + if cache isa Tuple && cache[2] == (sparse, simplify) + return cache[1] + end + + # observed equations may depend on unknowns, so substitute them in first + # TODO: rather keep observed derivatives unexpanded, like "Differential(obs)(expr)"? + obs = Dict(eq.lhs => eq.rhs for eq in observed(sys)) + rhs = map(eq -> fixpoint_sub(eq.rhs, obs), equations(sys)) + vals = [dv for dv in unknowns(sys)] + if sparse - jac = sparsejacobian(rhs, vals, simplify=simplify) + jac = sparsejacobian(rhs, vals, simplify = simplify) else - jac = jacobian(rhs, vals, simplify=simplify) + jac = jacobian(rhs, vals, simplify = simplify) end + get_jac(sys)[] = jac, (sparse, simplify) return jac end -function generate_jacobian(sys::NonlinearSystem, vs = states(sys), ps = parameters(sys); - sparse = false, simplify=false, kwargs...) - jac = calculate_jacobian(sys,sparse=sparse, simplify=simplify) - return build_function(jac, vs, ps; - conv = AbstractSysToExpr(sys), kwargs...) +function generate_jacobian( + sys::NonlinearSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) + jac = calculate_jacobian(sys, sparse = sparse, simplify = simplify) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, vs, p...; kwargs...) +end + +function calculate_hessian(sys::NonlinearSystem; sparse = false, simplify = false) + obs = Dict(eq.lhs => eq.rhs for eq in observed(sys)) + rhs = map(eq -> fixpoint_sub(eq.rhs, obs), equations(sys)) + vals = [dv for dv in unknowns(sys)] + if sparse + hess = [sparsehessian(rhs[i], vals, simplify = simplify) for i in 1:length(rhs)] + else + hess = [hessian(rhs[i], vals, simplify = simplify) for i in 1:length(rhs)] + end + return hess end -function generate_function(sys::NonlinearSystem, dvs = states(sys), ps = parameters(sys); kwargs...) - #obsvars = map(eq->eq.lhs, observed(sys)) - #fulldvs = [dvs; obsvars] - fulldvs = dvs - fulldvs′ = makesym.(value.(fulldvs)) +function generate_hessian( + sys::NonlinearSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) + hess = calculate_hessian(sys, sparse = sparse, simplify = simplify) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) +end - sub = Dict(fulldvs .=> fulldvs′) - # substitute x(t) by just x - rhss = [substitute(deq.rhs, sub) for deq ∈ equations(sys)] - #obss = [makesym(value(eq.lhs)) ~ substitute(eq.rhs, sub) for eq ∈ observed(sys)] - #rhss = Let(obss, rhss) +function generate_function( + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + scalar = false, kwargs...) + rhss = [deq.rhs for deq in equations(sys)] + dvs′ = value.(dvs) + if scalar + rhss = only(rhss) + dvs′ = only(dvs) + end + p = reorder_parameters(sys, value.(ps)) + return build_function_wrapper(sys, rhss, dvs′, p...; kwargs...) +end - dvs′ = fulldvs′[1:length(dvs)] - ps′ = makesym.(value.(ps), states=()) - return build_function(rhss, dvs′, ps′; - conv = AbstractSysToExpr(sys), kwargs...) +function jacobian_sparsity(sys::NonlinearSystem) + jacobian_sparsity([eq.rhs for eq in equations(sys)], + unknowns(sys)) end -jacobian_sparsity(sys::NonlinearSystem) = - jacobian_sparsity([eq.rhs for eq ∈ equations(sys)], - states(sys)) +function hessian_sparsity(sys::NonlinearSystem) + [hessian_sparsity(eq.rhs, + unknowns(sys)) for eq in equations(sys)] +end -function DiffEqBase.NonlinearFunction(sys::NonlinearSystem, args...; kwargs...) - NonlinearFunction{true}(sys, args...; kwargs...) +function calculate_resid_prototype(N, u0, p) + u0ElType = u0 === nothing ? Float64 : eltype(u0) + if SciMLStructures.isscimlstructure(p) + u0ElType = promote_type( + eltype(SciMLStructures.canonicalize(SciMLStructures.Tunable(), p)[1]), + u0ElType) + end + return zeros(u0ElType, N) end """ ```julia -function DiffEqBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, - jac = false, - sparse = false, - kwargs...) where {iip} +SciMLBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = unknowns(sys), + ps = parameters(sys); + version = nothing, + jac = false, + sparse = false, + kwargs...) where {iip} ``` -Create an `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments +Create a `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, respectively. """ -function DiffEqBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, - jac = false, - eval_expression = true, - sparse = false, simplify=false, - kwargs...) where {iip} +function SciMLBase.NonlinearFunction(sys::NonlinearSystem, args...; kwargs...) + NonlinearFunction{true}(sys, args...; kwargs...) +end - f_gen = generate_function(sys, dvs, ps; expression=Val{eval_expression}, kwargs...) - f_oop,f_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in f_gen) : f_gen - f(u,p) = f_oop(u,p) - f(du,u,p) = f_iip(du,u,p) +function SciMLBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; p = nothing, + version = nothing, + jac = false, + eval_expression = false, + eval_module = @__MODULE__, + sparse = false, simplify = false, + initialization_data = nothing, cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearFunction`") + end + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f_oop, f_iip) if jac jac_gen = generate_jacobian(sys, dvs, ps; - simplify=simplify, sparse = sparse, - expression=Val{eval_expression}, kwargs...) - jac_oop,jac_iip = eval_expression ? (@RuntimeGeneratedFunction(ex) for ex in jac_gen) : jac_gen - _jac(u,p) = jac_oop(u,p) - _jac(J,u,p) = jac_iip(J,u,p) + simplify = simplify, sparse = sparse, + expression = Val{true}, cse, kwargs...) + jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) + _jac = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(jac_oop, jac_iip) else _jac = nothing end - observedfun = let sys = sys, dict = Dict() - function generated_observed(obsvar, u, p) - obs = get!(dict, value(obsvar)) do - build_explicit_observed_function(sys, obsvar) - end - obs(u, p) - end + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + if length(dvs) == length(equations(sys)) + resid_prototype = nothing + else + resid_prototype = calculate_resid_prototype(length(equations(sys)), u0, p) end - NonlinearFunction{iip}(f, - jac = _jac === nothing ? nothing : _jac, - jac_prototype = sparse ? similar(sys.jac[],Float64) : nothing, - syms = Symbol.(states(sys)), observed = observedfun) + NonlinearFunction{iip}(f; + sys = sys, + jac = _jac === nothing ? nothing : _jac, + resid_prototype = resid_prototype, + jac_prototype = sparse ? + similar(calculate_jacobian(sys, sparse = sparse), + Float64) : nothing, + observed = observedfun, initialization_data) +end + +""" +$(TYPEDSIGNATURES) + +Create an `IntervalNonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments +`dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function SciMLBase.IntervalNonlinearFunction( + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys), u0 = nothing; + p = nothing, eval_expression = false, eval_module = @__MODULE__, + initialization_data = nothing, kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearFunction`") + end + if !isone(length(dvs)) || !isone(length(equations(sys))) + error("`IntervalNonlinearFunction` only supports systems with a single equation and a single unknown.") + end + + f_gen = generate_function( + sys, dvs, ps; expression = Val{true}, scalar = true, kwargs...) + f = eval_or_rgf(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f, nothing) + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false)) + + IntervalNonlinearFunction{false}( + f; observed = observedfun, sys = sys, initialization_data) end """ ```julia -function DiffEqBase.NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = states(sys), +SciMLBase.NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys); version = nothing, jac = false, @@ -173,98 +451,424 @@ function DiffEqBase.NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = state kwargs...) where {iip} ``` -Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). +Create a Julia expression for a `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, respectively. """ struct NonlinearFunctionExpr{iip} end -function NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = states(sys), - ps = parameters(sys), u0 = nothing; - version = nothing, tgrad=false, - jac = false, - linenumbers = false, - sparse = false, simplify=false, - kwargs...) where {iip} - - idx = iip ? 2 : 1 - f = generate_function(sys, dvs, ps; expression=Val{true}, kwargs...)[idx] +function NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; p = nothing, + version = nothing, tgrad = false, + jac = false, + linenumbers = false, + sparse = false, simplify = false, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearFunctionExpr`") + end + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + f = :($(GeneratedFunctionWrapper{(2, 2, is_split(sys))})($f_oop, $f_iip)) if jac - _jac = generate_jacobian(sys, dvs, ps; - sparse=sparse, simplify=simplify, - expression=Val{true}, kwargs...)[idx] + jac_oop, jac_iip = generate_jacobian(sys, dvs, ps; + sparse = sparse, simplify = simplify, + expression = Val{true}, kwargs...) + _jac = :($(GeneratedFunctionWrapper{(2, 2, is_split(sys))})($jac_oop, $jac_iip)) else _jac = :nothing end - jp_expr = sparse ? :(similar($(sys.jac[]),Float64)) : :nothing + jp_expr = sparse ? :(similar($(get_jac(sys)[]), Float64)) : :nothing + if length(dvs) == length(equations(sys)) + resid_expr = :nothing + else + u0ElType = u0 === nothing ? Float64 : eltype(u0) + if SciMLStructures.isscimlstructure(p) + u0ElType = promote_type( + eltype(SciMLStructures.canonicalize(SciMLStructures.Tunable(), p)[1]), + u0ElType) + end + resid_expr = :(zeros($u0ElType, $(length(equations(sys))))) + end ex = quote f = $f jac = $_jac NonlinearFunction{$iip}(f, - jac = jac, - jac_prototype = $jp_expr, - syms = $(Symbol.(states(sys)))) + jac = jac, + resid_prototype = resid_expr, + jac_prototype = $jp_expr) end - !linenumbers ? striplines(ex) : ex + !linenumbers ? Base.remove_linenums!(ex) : ex end -function process_NonlinearProblem(constructor, sys::NonlinearSystem,u0map,parammap; - version = nothing, - jac = false, - checkbounds = false, sparse = false, - simplify=false, - linenumbers = true, parallel=SerialForm(), - eval_expression = true, - kwargs...) - eqs = equations(sys) - dvs = states(sys) - ps = parameters(sys) - defs = defaults(sys) - u0 = varmap_to_vars(u0map,dvs; defaults=defs) - p = varmap_to_vars(parammap,ps; defaults=defs) +""" +$(TYPEDSIGNATURES) - check_eqs_u0(eqs, dvs, u0) +Create a Julia expression for an `IntervalNonlinearFunction` from the +[`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the +dependent variable and parameter vectors, respectively. +""" +function IntervalNonlinearFunctionExpr( + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys), + u0 = nothing; p = nothing, linenumbers = false, kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearFunctionExpr`") + end + if !isone(length(dvs)) || !isone(length(equations(sys))) + error("`IntervalNonlinearFunctionExpr` only supports systems with a single equation and a single unknown.") + end + + f = generate_function(sys, dvs, ps; expression = Val{true}, scalar = true, kwargs...) + f = :($(GeneratedFunctionWrapper{2, 2, is_split(sys)})($f, nothing)) - f = constructor(sys,dvs,ps,u0;jac=jac,checkbounds=checkbounds, - linenumbers=linenumbers,parallel=parallel,simplify=simplify, - sparse=sparse,eval_expression=eval_expression,kwargs...) - return f, u0, p + ex = quote + f = $f + NonlinearFunction{false}(f) + end + !linenumbers ? Base.remove_linenums!(ex) : ex end +""" +```julia +DiffEqBase.NonlinearProblem{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + jac = false, sparse = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates an NonlinearProblem from a NonlinearSystem and allows for automatically +symbolically calculating numerical enhancements. +""" function DiffEqBase.NonlinearProblem(sys::NonlinearSystem, args...; kwargs...) NonlinearProblem{true}(sys, args...; kwargs...) end +function DiffEqBase.NonlinearProblem{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + check_length = true, kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblem`") + end + f, u0, p = process_SciMLProblem(NonlinearFunction{iip}, sys, u0map, parammap; + check_length, kwargs...) + pt = something(get_metadata(sys), StandardNonlinearProblem()) + # Call `remake` so it runs initialization if it is trivial + return remake(NonlinearProblem{iip}(f, u0, p, pt; filter_kwargs(kwargs)...)) +end + +function DiffEqBase.NonlinearProblem(sys::AbstractODESystem, args...; kwargs...) + NonlinearProblem(NonlinearSystem(sys), args...; kwargs...) +end + """ ```julia -function DiffEqBase.NonlinearProblem{iip}(sys::NonlinearSystem,u0map, - parammap=DiffEqBase.NullParameters(); - jac = false, sparse=false, - checkbounds = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip +DiffEqBase.NonlinearLeastSquaresProblem{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + jac = false, sparse = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} ``` Generates an NonlinearProblem from a NonlinearSystem and allows for automatically symbolically calculating numerical enhancements. """ -function DiffEqBase.NonlinearProblem{iip}(sys::NonlinearSystem,u0map, - parammap=DiffEqBase.NullParameters();kwargs...) where iip - f, u0, p = process_NonlinearProblem(NonlinearFunction{iip}, sys, u0map, parammap; kwargs...) - NonlinearProblem{iip}(f,u0,p;kwargs...) +function DiffEqBase.NonlinearLeastSquaresProblem(sys::NonlinearSystem, args...; kwargs...) + NonlinearLeastSquaresProblem{true}(sys, args...; kwargs...) +end + +function DiffEqBase.NonlinearLeastSquaresProblem{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + check_length = false, kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearLeastSquaresProblem`") + end + f, u0, p = process_SciMLProblem(NonlinearFunction{iip}, sys, u0map, parammap; + check_length, kwargs...) + pt = something(get_metadata(sys), StandardNonlinearProblem()) + # Call `remake` so it runs initialization if it is trivial + return remake(NonlinearLeastSquaresProblem{iip}(f, u0, p; filter_kwargs(kwargs)...)) +end + +const TypeT = Union{DataType, UnionAll} + +struct CacheWriter{F} + fn::F +end + +function (cw::CacheWriter)(p, sols) + cw.fn(p.caches, sols, p) +end + +function CacheWriter(sys::AbstractSystem, buffer_types::Vector{TypeT}, + exprs::Dict{TypeT, Vector{Any}}, solsyms, obseqs::Vector{Equation}; + eval_expression = false, eval_module = @__MODULE__, cse = true) + ps = parameters(sys; initial_parameters = true) + rps = reorder_parameters(sys, ps) + obs_assigns = [eq.lhs ← eq.rhs for eq in obseqs] + body = map(eachindex(buffer_types), buffer_types) do i, T + Symbol(:tmp, i) ← SetArray(true, :(out[$i]), get(exprs, T, [])) + end + + function argument_name(i::Int) + if i <= length(solsyms) + return :($(generated_argument_name(1))[$i]) + end + return generated_argument_name(i - length(solsyms)) + end + array_assignments = array_variable_assignments(solsyms...; argument_name) + fn = build_function_wrapper( + sys, nothing, :out, + DestructuredArgs(DestructuredArgs.(solsyms), generated_argument_name(1)), + rps...; p_start = 3, p_end = length(rps) + 2, + expression = Val{true}, add_observed = false, cse, + extra_assignments = [array_assignments; obs_assigns; body]) + fn = eval_or_rgf(fn; eval_expression, eval_module) + fn = GeneratedFunctionWrapper{(3, 3, is_split(sys))}(fn, nothing) + return CacheWriter(fn) +end + +struct SCCNonlinearFunction{iip} end + +function SCCNonlinearFunction{iip}( + sys::NonlinearSystem, _eqs, _dvs, _obs, cachesyms; eval_expression = false, + eval_module = @__MODULE__, cse = true, kwargs...) where {iip} + ps = parameters(sys; initial_parameters = true) + rps = reorder_parameters(sys, ps) + + obs_assignments = [eq.lhs ← eq.rhs for eq in _obs] + + rhss = [eq.rhs - eq.lhs for eq in _eqs] + f_gen = build_function_wrapper(sys, + rhss, _dvs, rps..., cachesyms...; p_start = 2, + p_end = length(rps) + length(cachesyms) + 1, add_observed = false, + extra_assignments = obs_assignments, expression = Val{true}, cse) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f_oop, f_iip) + + subsys = NonlinearSystem(_eqs, _dvs, ps; observed = _obs, + parameter_dependencies = parameter_dependencies(sys), name = nameof(sys)) + if get_index_cache(sys) !== nothing + @set! subsys.index_cache = subset_unknowns_observed( + get_index_cache(sys), sys, _dvs, getproperty.(_obs, (:lhs,))) + @set! subsys.complete = true + end + + return NonlinearFunction{iip}(f; sys = subsys) +end + +function SciMLBase.SCCNonlinearProblem(sys::NonlinearSystem, args...; kwargs...) + SCCNonlinearProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.SCCNonlinearProblem{iip}(sys::NonlinearSystem, u0map, + parammap = SciMLBase.NullParameters(); eval_expression = false, eval_module = @__MODULE__, + cse = true, kwargs...) where {iip} + if !iscomplete(sys) || get_tearing_state(sys) === nothing + error("A simplified `NonlinearSystem` is required. Call `structural_simplify` on the system before creating an `SCCNonlinearProblem`.") + end + + if !is_split(sys) + error("The system has been simplified with `split = false`. `SCCNonlinearProblem` is not compatible with this system. Pass `split = true` to `structural_simplify` to use `SCCNonlinearProblem`.") + end + + ts = get_tearing_state(sys) + var_eq_matching, var_sccs = StructuralTransformations.algebraic_variables_scc(ts) + + if length(var_sccs) == 1 + return NonlinearProblem{iip}( + sys, u0map, parammap; eval_expression, eval_module, kwargs...) + end + + condensed_graph = MatchedCondensationGraph( + DiCMOBiGraph{true}(complete(ts.structure.graph), + complete(var_eq_matching)), + var_sccs) + toporder = topological_sort_by_dfs(condensed_graph) + var_sccs = var_sccs[toporder] + eq_sccs = map(Base.Fix1(getindex, var_eq_matching), var_sccs) + + dvs = unknowns(sys) + ps = parameters(sys) + eqs = equations(sys) + obs = observed(sys) + + _, u0, p = process_SciMLProblem( + EmptySciMLFunction, sys, u0map, parammap; eval_expression, eval_module, kwargs...) + + explicitfuns = [] + nlfuns = [] + prevobsidxs = BlockArray(undef_blocks, Vector{Int}, Int[]) + # Cache buffer types and corresponding sizes. Stored as a pair of arrays instead of a + # dict to maintain a consistent order of buffers across SCCs + cachetypes = TypeT[] + cachesizes = Int[] + # explicitfun! related information for each SCC + # We need to compute buffer sizes before doing any codegen + scc_cachevars = Dict{TypeT, Vector{Any}}[] + scc_cacheexprs = Dict{TypeT, Vector{Any}}[] + scc_eqs = Vector{Equation}[] + scc_obs = Vector{Equation}[] + # variables solved in previous SCCs + available_vars = Set() + for (i, (escc, vscc)) in enumerate(zip(eq_sccs, var_sccs)) + # subset unknowns and equations + _dvs = dvs[vscc] + _eqs = eqs[escc] + # get observed equations required by this SCC + union!(available_vars, _dvs) + obsidxs = observed_equations_used_by(sys, _eqs; available_vars) + # the ones used by previous SCCs can be precomputed into the cache + setdiff!(obsidxs, prevobsidxs) + _obs = obs[obsidxs] + union!(available_vars, getproperty.(_obs, (:lhs,))) + + # get all subexpressions in the RHS which we can precompute in the cache + # precomputed subexpressions should not contain `banned_vars` + banned_vars = Set{Any}(vcat(_dvs, getproperty.(_obs, (:lhs,)))) + state = Dict() + for i in eachindex(_obs) + _obs[i] = _obs[i].lhs ~ subexpressions_not_involving_vars!( + _obs[i].rhs, banned_vars, state) + end + for i in eachindex(_eqs) + _eqs[i] = _eqs[i].lhs ~ subexpressions_not_involving_vars!( + _eqs[i].rhs, banned_vars, state) + end + + # map from symtype to cached variables and their expressions + cachevars = Dict{Union{DataType, UnionAll}, Vector{Any}}() + cacheexprs = Dict{Union{DataType, UnionAll}, Vector{Any}}() + # observed of previous SCCs are in the cache + # NOTE: When we get proper CSE, we can substitute these + # and then use `subexpressions_not_involving_vars!` + for i in prevobsidxs + T = symtype(obs[i].lhs) + buf = get!(() -> Any[], cachevars, T) + push!(buf, obs[i].lhs) + + buf = get!(() -> Any[], cacheexprs, T) + push!(buf, obs[i].lhs) + end + + for (k, v) in state + k = unwrap(k) + v = unwrap(v) + T = symtype(k) + buf = get!(() -> Any[], cachevars, T) + push!(buf, v) + buf = get!(() -> Any[], cacheexprs, T) + push!(buf, k) + end + + # update the sizes of cache buffers + for (T, buf) in cachevars + idx = findfirst(isequal(T), cachetypes) + if idx === nothing + push!(cachetypes, T) + push!(cachesizes, 0) + idx = lastindex(cachetypes) + end + cachesizes[idx] = max(cachesizes[idx], length(buf)) + end + + push!(scc_cachevars, cachevars) + push!(scc_cacheexprs, cacheexprs) + push!(scc_eqs, _eqs) + push!(scc_obs, _obs) + blockpush!(prevobsidxs, obsidxs) + end + + for (i, (escc, vscc)) in enumerate(zip(eq_sccs, var_sccs)) + _dvs = dvs[vscc] + _eqs = scc_eqs[i] + _prevobsidxs = reduce(vcat, blocks(prevobsidxs)[1:(i - 1)]; init = Int[]) + _obs = scc_obs[i] + cachevars = scc_cachevars[i] + cacheexprs = scc_cacheexprs[i] + available_vars = [dvs[reduce(vcat, var_sccs[1:(i - 1)]; init = Int[])]; + getproperty.( + reduce(vcat, scc_obs[1:(i - 1)]; init = []), (:lhs,))] + _prevobsidxs = vcat(_prevobsidxs, + observed_equations_used_by( + sys, reduce(vcat, values(cacheexprs); init = []); available_vars)) + if isempty(cachevars) + push!(explicitfuns, Returns(nothing)) + else + solsyms = getindex.((dvs,), view(var_sccs, 1:(i - 1))) + push!(explicitfuns, + CacheWriter(sys, cachetypes, cacheexprs, solsyms, obs[_prevobsidxs]; + eval_expression, eval_module, cse)) + end + + cachebufsyms = Tuple(map(cachetypes) do T + get(cachevars, T, []) + end) + f = SCCNonlinearFunction{iip}( + sys, _eqs, _dvs, _obs, cachebufsyms; eval_expression, eval_module, cse, kwargs...) + push!(nlfuns, f) + end + + if !isempty(cachetypes) + templates = map(cachetypes, cachesizes) do T, n + # Real refers to `eltype(u0)` + if T == Real + T = eltype(u0) + elseif T <: Array && eltype(T) == Real + T = Array{eltype(u0), ndims(T)} + end + BufferTemplate(T, n) + end + p = rebuild_with_caches(p, templates...) + end + + subprobs = [] + for (f, vscc) in zip(nlfuns, var_sccs) + prob = NonlinearProblem(f, u0[vscc], p) + push!(subprobs, prob) + end + + new_dvs = dvs[reduce(vcat, var_sccs)] + new_eqs = eqs[reduce(vcat, eq_sccs)] + @set! sys.unknowns = new_dvs + @set! sys.eqs = new_eqs + @set! sys.index_cache = subset_unknowns_observed( + get_index_cache(sys), sys, new_dvs, getproperty.(obs, (:lhs,))) + return SCCNonlinearProblem(subprobs, explicitfuns, p, true; sys) +end + +""" +$(TYPEDSIGNATURES) + +Generate an `IntervalNonlinearProblem` from a `NonlinearSystem` and allow for automatically +symbolically calculating numerical enhancements. +""" +function DiffEqBase.IntervalNonlinearProblem(sys::NonlinearSystem, uspan::NTuple{2}, + parammap = SciMLBase.NullParameters(); kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearProblem`") + end + if !isone(length(unknowns(sys))) || !isone(length(equations(sys))) + error("`IntervalNonlinearProblem` only supports with a single equation and a single unknown.") + end + f, u0, p = process_SciMLProblem( + IntervalNonlinearFunction, sys, unknowns(sys) .=> uspan[1], parammap; kwargs...) + + return IntervalNonlinearProblem(f, uspan, p; filter_kwargs(kwargs)...) end """ ```julia -function DiffEqBase.NonlinearProblemExpr{iip}(sys::NonlinearSystem,u0map, - parammap=DiffEqBase.NullParameters(); - jac = false, sparse=false, - checkbounds = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip +DiffEqBase.NonlinearProblemExpr{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + jac = false, sparse = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} ``` Generates a Julia expression for a NonlinearProblem from a @@ -277,41 +881,117 @@ function NonlinearProblemExpr(sys::NonlinearSystem, args...; kwargs...) NonlinearProblemExpr{true}(sys, args...; kwargs...) end -function NonlinearProblemExpr{iip}(sys::NonlinearSystem,u0map, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip +function NonlinearProblemExpr{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + check_length = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblemExpr`") + end + f, u0, p = process_SciMLProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; + check_length, kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + + ex = quote + f = $f + u0 = $u0 + p = $p + NonlinearProblem(f, u0, p; $(filter_kwargs(kwargs)...)) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +""" +```julia +DiffEqBase.NonlinearLeastSquaresProblemExpr{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + jac = false, sparse = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates a Julia expression for a NonlinearProblem from a +NonlinearSystem and allows for automatically symbolically calculating +numerical enhancements. +""" +struct NonlinearLeastSquaresProblemExpr{iip} end + +function NonlinearLeastSquaresProblemExpr(sys::NonlinearSystem, args...; kwargs...) + NonlinearLeastSquaresProblemExpr{true}(sys, args...; kwargs...) +end - f, u0, p = process_NonlinearProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; kwargs...) +function NonlinearLeastSquaresProblemExpr{iip}(sys::NonlinearSystem, u0map, + parammap = DiffEqBase.NullParameters(); + check_length = false, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblemExpr`") + end + f, u0, p = process_SciMLProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; + check_length, kwargs...) linenumbers = get(kwargs, :linenumbers, true) ex = quote f = $f u0 = $u0 p = $p - NonlinearProblem(f,u0,p;$(kwargs...)) + NonlinearLeastSquaresProblem(f, u0, p; $(filter_kwargs(kwargs)...)) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +""" +$(TYPEDSIGNATURES) + +Generates a Julia expression for an IntervalNonlinearProblem from a +NonlinearSystem and allows for automatically symbolically calculating +numerical enhancements. +""" +function IntervalNonlinearProblemExpr(sys::NonlinearSystem, uspan::NTuple{2}, + parammap = SciMLBase.NullParameters(); kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearProblemExpr`") + end + if !isone(length(unknowns(sys))) || !isone(length(equations(sys))) + error("`IntervalNonlinearProblemExpr` only supports with a single equation and a single unknown.") + end + f, u0, p = process_SciMLProblem( + IntervalNonlinearFunctionExpr, sys, unknowns(sys) .=> uspan[1], parammap; kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + + ex = quote + f = $f + uspan = $uspan + p = $p + IntervalNonlinearProblem(f, uspan, p; $(filter_kwargs(kwargs)...)) end - !linenumbers ? striplines(ex) : ex + !linenumbers ? Base.remove_linenums!(ex) : ex end -function flatten(sys::NonlinearSystem) +function flatten(sys::NonlinearSystem, noeqs = false) systems = get_systems(sys) if isempty(systems) return sys else - return NonlinearSystem( - equations(sys), - states(sys), - parameters(sys), - observed=observed(sys), - defaults=defaults(sys), - name=nameof(sys), - ) + return NonlinearSystem(noeqs ? Equation[] : equations(sys), + unknowns(sys), + parameters(sys), + observed = observed(sys), + defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), + name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), + checks = false) end end function Base.:(==)(sys1::NonlinearSystem, sys2::NonlinearSystem) - _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && - _eq_unordered(get_states(sys1), get_states(sys2)) && - _eq_unordered(get_ps(sys1), get_ps(sys2)) && - all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) + isequal(nameof(sys1), nameof(sys2)) && + _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && + _eq_unordered(get_unknowns(sys1), get_unknowns(sys2)) && + _eq_unordered(get_ps(sys1), get_ps(sys2)) && + all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) end diff --git a/src/systems/optimization/constraints_system.jl b/src/systems/optimization/constraints_system.jl new file mode 100644 index 0000000000..0f69e6d0b9 --- /dev/null +++ b/src/systems/optimization/constraints_system.jl @@ -0,0 +1,255 @@ +""" +$(TYPEDEF) + +A constraint system of equations. + +# Fields +$(FIELDS) + +# Examples + +```julia +@variables x y z +@parameters a b c + +cstr = [0 ~ a*(y-x), + 0 ~ x*(b-z)-y, + 0 ~ x*y - c*z + x^2 + y^2 ≲ 1] +@named ns = ConstraintsSystem(cstr, [x,y,z],[a,b,c]) +``` +""" +struct ConstraintsSystem <: AbstractTimeIndependentSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt + """Vector of equations defining the system.""" + constraints::Vector{Union{Equation, Inequality}} + """Unknown variables.""" + unknowns::Vector + """Parameters.""" + ps::Vector + """Array variables.""" + var_to_name::Any + """Observed variables.""" + observed::Vector{Equation} + """ + Jacobian matrix. Note: this field will not be defined until + [`calculate_jacobian`](@ref) is called on the system. + """ + jac::RefValue{Any} + """ + The name of the system. + """ + name::Symbol + """ + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. + """ + systems::Vector{ConstraintsSystem} + """ + The default values to use when initial conditions and/or + parameters are not supplied in `ODEProblem`. + """ + defaults::Dict + """ + Type of the system. + """ + connector_type::Any + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + + function ConstraintsSystem(tag, constraints, unknowns, ps, var_to_name, observed, jac, + name, description, + systems, + defaults, connector_type, metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, + complete = false, index_cache = nothing; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(unknowns, ps) + check_units(u, constraints) + check_subsystems(systems) + end + new(tag, constraints, unknowns, ps, var_to_name, + observed, jac, name, description, systems, + defaults, connector_type, metadata, tearing_state, substitutions, + namespacing, complete, index_cache) + end +end + +equations(sys::ConstraintsSystem) = constraints(sys) # needed for Base.show + +function ConstraintsSystem(constraints, unknowns, ps; + observed = [], + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + systems = ConstraintsSystem[], + connector_type = nothing, + continuous_events = nothing, # this argument is only required for ODESystems, but is added here for the constructor to accept it without error + discrete_events = nothing, # this argument is only required for ODESystems, but is added here for the constructor to accept it without error + checks = true, + metadata = nothing) + continuous_events === nothing || isempty(continuous_events) || + throw(ArgumentError("ConstraintsSystem does not accept `continuous_events`, you provided $continuous_events")) + discrete_events === nothing || isempty(discrete_events) || + throw(ArgumentError("ConstraintsSystem does not accept `discrete_events`, you provided $discrete_events")) + + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + + cstr = value.(Symbolics.canonical_form.(vcat(scalarize(constraints)...))) + unknowns′ = value.(scalarize(unknowns)) + ps′ = value.(ps) + + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :ConstraintsSystem, force = true) + end + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + + jac = RefValue{Any}(EMPTY_JAC) + defaults = todict(defaults) + defaults = Dict(value(k) => value(v) + for (k, v) in pairs(defaults) if value(v) !== nothing) + + var_to_name = Dict() + process_variables!(var_to_name, defaults, Dict(), unknowns′) + process_variables!(var_to_name, defaults, Dict(), ps′) + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + ConstraintsSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + cstr, unknowns, ps, var_to_name, observed, jac, name, description, systems, + defaults, + connector_type, metadata, checks = checks) +end + +function calculate_jacobian(sys::ConstraintsSystem; sparse = false, simplify = false) + cache = get_jac(sys)[] + if cache isa Tuple && cache[2] == (sparse, simplify) + return cache[1] + end + + lhss = generate_canonical_form_lhss(sys) + vals = [dv for dv in unknowns(sys)] + if sparse + jac = sparsejacobian(lhss, vals, simplify = simplify) + else + jac = jacobian(lhss, vals, simplify = simplify) + end + get_jac(sys)[] = jac, (sparse, simplify) + return jac +end + +function generate_jacobian( + sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) + jac = calculate_jacobian(sys, sparse = sparse, simplify = simplify) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, vs, p...; kwargs...) +end + +function calculate_hessian(sys::ConstraintsSystem; sparse = false, simplify = false) + lhss = generate_canonical_form_lhss(sys) + vals = [dv for dv in unknowns(sys)] + if sparse + hess = [sparsehessian(lhs, vals, simplify = simplify) for lhs in lhss] + else + hess = [hessian(lhs, vals, simplify = simplify) for lhs in lhss] + end + return hess +end + +function generate_hessian( + sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) + hess = calculate_hessian(sys, sparse = sparse, simplify = simplify) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) +end + +function generate_function(sys::ConstraintsSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + kwargs...) + lhss = generate_canonical_form_lhss(sys) + p = reorder_parameters(sys, value.(ps)) + func = build_function_wrapper(sys, lhss, value.(dvs), p...; kwargs...) + + cstr = constraints(sys) + lcons = fill(-Inf, length(cstr)) + ucons = zeros(length(cstr)) + lcons[findall(Base.Fix2(isa, Equation), cstr)] .= 0.0 + + return func, lcons, ucons +end + +function jacobian_sparsity(sys::ConstraintsSystem) + lhss = generate_canonical_form_lhss(sys) + jacobian_sparsity(lhss, unknowns(sys)) +end + +function hessian_sparsity(sys::ConstraintsSystem) + lhss = generate_canonical_form_lhss(sys) + [hessian_sparsity(eq, unknowns(sys)) for eq in lhss] +end + +""" +Convert the system of equalities and inequalities into a canonical form: +h(x) = 0 +g(x) <= 0 +""" +function generate_canonical_form_lhss(sys) + lhss = subs_constants([Symbolics.canonical_form(eq).lhs for eq in constraints(sys)]) +end + +function get_cmap(sys::ConstraintsSystem, exprs = nothing) + #Inject substitutions for constants => values + cs = collect_constants([get_constraints(sys); get_observed(sys)]) #ctrls? what else? + if !empty_substitutions(sys) + cs = [cs; collect_constants(get_substitutions(sys).subs)] + end + if exprs !== nothing + cs = [cs; collect_constants(exprs)] + end + # Swap constants for their values + cmap = map(x -> x ~ getdefault(x), cs) + return cmap, cs +end + +supports_initialization(::ConstraintsSystem) = false diff --git a/src/systems/optimization/modelingtoolkitize.jl b/src/systems/optimization/modelingtoolkitize.jl new file mode 100644 index 0000000000..27dccc251a --- /dev/null +++ b/src/systems/optimization/modelingtoolkitize.jl @@ -0,0 +1,148 @@ +""" +$(TYPEDSIGNATURES) + +Generate `OptimizationSystem`, dependent variables, and parameters from an `OptimizationProblem`. +""" +function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem; + u_names = nothing, p_names = nothing, kwargs...) + num_cons = isnothing(prob.lcons) ? 0 : length(prob.lcons) + if prob.p isa Tuple || prob.p isa NamedTuple + p = [x for x in prob.p] + else + p = prob.p + end + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + if u_names !== nothing + varnames_length_check(prob.u0, u_names; is_unknowns = true) + _vars = [variable(name) for name in u_names] + elseif SciMLBase.has_sys(prob.f) + varnames = getname.(variable_symbols(prob.f.sys)) + varidxs = variable_index.((prob.f.sys,), varnames) + invpermute!(varnames, varidxs) + _vars = [variable(name) for name in varnames] + if prob.f.sys isa OptimizationSystem + for (i, sym) in enumerate(variable_symbols(prob.f.sys)) + if hasbounds(sym) + _vars[i] = Symbolics.setmetadata( + _vars[i], VariableBounds, getbounds(sym)) + end + end + end + else + _vars = [variable(:x, i) for i in eachindex(prob.u0)] + end + _vars = reshape(_vars, size(prob.u0)) + vars = ArrayInterface.restructure(prob.u0, _vars) + if prob.ub !== nothing # lb is also !== nothing + vars = map(vars, prob.lb, prob.ub) do sym, lb, ub + if iszero(lb) && iszero(ub) || isinf(lb) && lb < 0 && isinf(ub) && ub > 0 + sym + else + Symbolics.setmetadata(sym, VariableBounds, (lb, ub)) + end + end + end + params = if has_p + if p_names === nothing && SciMLBase.has_sys(prob.f) + p_names = Dict(parameter_index(prob.f.sys, sym) => sym + for sym in parameter_symbols(prob.f.sys)) + end + if p isa MTKParameters + old_to_new = Dict() + for sym in parameter_symbols(prob) + idx = parameter_index(prob, sym) + old_to_new[unwrap(sym)] = unwrap(p_names[idx]) + end + order = reorder_parameters(prob.f.sys) + for arr in order + for i in eachindex(arr) + arr[i] = old_to_new[arr[i]] + end + end + _params = order + else + _params = define_params(p, p_names) + end + p isa Number ? _params[1] : + (p isa Tuple || p isa NamedTuple || p isa AbstractDict || p isa MTKParameters ? + _params : + ArrayInterface.restructure(p, _params)) + else + [] + end + + if p isa MTKParameters + eqs = prob.f(vars, params...) + else + eqs = prob.f(vars, params) + end + + if DiffEqBase.isinplace(prob) && !isnothing(prob.f.cons) + lhs = Array{Num}(undef, num_cons) + if p isa MTKParameters + prob.f.cons(lhs, vars, params...) + else + prob.f.cons(lhs, vars, params) + end + cons = Union{Equation, Inequality}[] + + if !isnothing(prob.lcons) + for i in 1:num_cons + if !isinf(prob.lcons[i]) + if prob.lcons[i] != prob.ucons[i] + push!(cons, prob.lcons[i] ≲ lhs[i]) + else + push!(cons, lhs[i] ~ prob.ucons[i]) + end + end + end + end + + if !isnothing(prob.ucons) + for i in 1:num_cons + if !isinf(prob.ucons[i]) && prob.lcons[i] != prob.ucons[i] + push!(cons, lhs[i] ≲ prob.ucons[i]) + end + end + end + if (isnothing(prob.lcons) || all(isinf, prob.lcons)) && + (isnothing(prob.ucons) || all(isinf, prob.ucons)) + throw(ArgumentError("Constraints passed have no proper bounds defined. + Ensure you pass equal bounds (the scalar that the constraint should evaluate to) for equality constraints + or pass the lower and upper bounds for inequality constraints.")) + end + elseif !isnothing(prob.f.cons) + cons = p isa MTKParameters ? prob.f.cons(vars, params...) : + prob.f.cons(vars, params) + else + cons = [] + end + params = values(params) + params = if params isa Number || (params isa Array && ndims(params) == 0) + [params[1]] + elseif p isa MTKParameters + reduce(vcat, params) + else + vec(collect(params)) + end + + sts = vec(collect(vars)) + default_u0 = Dict(sts .=> vec(collect(prob.u0))) + default_p = if has_p + if prob.p isa AbstractDict + Dict(v => prob.p[k] for (k, v) in pairs(_params)) + elseif prob.p isa MTKParameters + Dict(params .=> reduce(vcat, prob.p)) + else + Dict(params .=> vec(collect(prob.p))) + end + else + Dict() + end + de = OptimizationSystem(eqs, sts, params; + name = gensym(:MTKizedOpt), + constraints = cons, + defaults = merge(default_u0, default_p), + kwargs...) + de +end diff --git a/src/systems/optimization/optimizationsystem.jl b/src/systems/optimization/optimizationsystem.jl index 30380ca93c..be4567aee5 100644 --- a/src/systems/optimization/optimizationsystem.jl +++ b/src/systems/optimization/optimizationsystem.jl @@ -1,242 +1,760 @@ -""" -$(TYPEDEF) - -A scalar equation for optimization. - -# Fields -$(FIELDS) - -# Examples - -```julia -@variables x y z -@parameters σ ρ β - -op = σ*(y-x) + x*(ρ-z)-y + x*y - β*z -os = OptimizationSystem(eqs, [x,y,z],[σ,ρ,β]) -``` -""" -struct OptimizationSystem <: AbstractSystem - """Vector of equations defining the system.""" - op::Any - """Unknown variables.""" - states::Vector - """Parameters.""" - ps::Vector - observed::Vector{Equation} - equality_constraints::Vector{Equation} - inequality_constraints::Vector - """ - Name: the name of the system - """ - name::Symbol - """ - systems: The internal systems - """ - systems::Vector{OptimizationSystem} - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict -end - -function OptimizationSystem(op, states, ps; - observed = [], - equality_constraints = Equation[], - inequality_constraints = [], - default_u0=Dict(), - default_p=Dict(), - defaults=_merge(Dict(default_u0), Dict(default_p)), - name = gensym(:OptimizationSystem), - systems = OptimizationSystem[]) - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn("`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :OptimizationSystem, force=true) - end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) for (k, v) in pairs(defaults)) - - OptimizationSystem( - value(op), value.(states), value.(ps), - observed, - equality_constraints, inequality_constraints, - name, systems, defaults - ) -end - -function calculate_gradient(sys::OptimizationSystem) - expand_derivatives.(gradient(equations(sys), states(sys))) -end - -function generate_gradient(sys::OptimizationSystem, vs = states(sys), ps = parameters(sys); kwargs...) - grad = calculate_gradient(sys) - return build_function(grad, vs, ps; - conv = AbstractSysToExpr(sys),kwargs...) -end - -function calculate_hessian(sys::OptimizationSystem) - expand_derivatives.(hessian(equations(sys), states(sys))) -end - -function generate_hessian(sys::OptimizationSystem, vs = states(sys), ps = parameters(sys); - sparse = false, kwargs...) - if sparse - hess = sparsehessian(equations(sys),states(sys)) - else - hess = calculate_hessian(sys) - end - return build_function(hess, vs, ps; - conv = AbstractSysToExpr(sys),kwargs...) -end - -function generate_function(sys::OptimizationSystem, vs = states(sys), ps = parameters(sys); kwargs...) - return build_function(equations(sys), vs, ps; - conv = AbstractSysToExpr(sys),kwargs...) -end - -equations(sys::OptimizationSystem) = isempty(get_systems(sys)) ? get_op(sys) : get_op(sys) + reduce(+,namespace_expr.(get_systems(sys))) -namespace_expr(sys::OptimizationSystem) = namespace_expr(get_op(sys),nameof(sys),nothing) - -hessian_sparsity(sys::OptimizationSystem) = hessian_sparsity(get_op(sys), states(sys)) - -struct AutoModelingToolkit <: DiffEqBase.AbstractADType end - -DiffEqBase.OptimizationProblem(sys::OptimizationSystem,args...;kwargs...) = - DiffEqBase.OptimizationProblem{true}(sys::OptimizationSystem,args...;kwargs...) - -""" -```julia -function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, - parammap=DiffEqBase.NullParameters(); - u0=nothing, lb=nothing, ub=nothing, - grad = false, - hess = false, sparse = false, - checkbounds = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` - -Generates an OptimizationProblem from an OptimizationSystem and allows for automatically -symbolically calculating numerical enhancements. -""" -function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0, - parammap=DiffEqBase.NullParameters(); - lb=nothing, ub=nothing, - grad = false, - hess = false, sparse = false, - checkbounds = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip - dvs = states(sys) - ps = parameters(sys) - - f = generate_function(sys,checkbounds=checkbounds,linenumbers=linenumbers, - expression=Val{false}) - - if grad - grad_oop,grad_iip = generate_gradient(sys,checkbounds=checkbounds,linenumbers=linenumbers, - parallel=parallel,expression=Val{false}) - _grad(u,p) = grad_oop(u,p) - _grad(J,u,p) = (grad_iip(J,u,p); J) - else - _grad = nothing - end - - if hess - hess_oop,hess_iip = generate_hessian(sys,checkbounds=checkbounds,linenumbers=linenumbers, - sparse=sparse,parallel=parallel,expression=Val{false}) - _hess(u,p) = hess_oop(u,p) - _hess(J,u,p) = (hess_iip(J,u,p); J) - else - _hess = nothing - end - - _f = DiffEqBase.OptimizationFunction{iip,AutoModelingToolkit,typeof(f),typeof(_grad),typeof(_hess),Nothing,Nothing,Nothing,Nothing}(f,AutoModelingToolkit(),_grad,_hess,nothing,nothing,nothing,nothing) - - defs = defaults(sys) - u0 = varmap_to_vars(u0,dvs; defaults=defs) - p = varmap_to_vars(parammap,ps; defaults=defs) - lb = varmap_to_vars(lb,dvs; check=false) - ub = varmap_to_vars(ub,dvs; check=false) - OptimizationProblem{iip}(_f,u0,p;lb=lb,ub=ub,kwargs...) -end - -""" -```julia -function DiffEqBase.OptimizationProblemExpr{iip}(sys::OptimizationSystem, - parammap=DiffEqBase.NullParameters(); - u0=nothing, lb=nothing, ub=nothing, - grad = false, - hes = false, sparse = false, - checkbounds = false, - linenumbers = true, parallel=SerialForm(), - kwargs...) where iip -``` - -Generates a Julia expression for an OptimizationProblem from an -OptimizationSystem and allows for automatically symbolically -calculating numerical enhancements. -""" -struct OptimizationProblemExpr{iip} end - -OptimizationProblemExpr(sys::OptimizationSystem,args...;kwargs...) = - OptimizationProblemExpr{true}(sys::OptimizationSystem,args...;kwargs...) - -function OptimizationProblemExpr{iip}(sys::OptimizationSystem, u0, - parammap=DiffEqBase.NullParameters(); - lb=nothing, ub=nothing, - grad = false, - hess = false, sparse = false, - checkbounds = false, - linenumbers = false, parallel=SerialForm(), - kwargs...) where iip - dvs = states(sys) - ps = parameters(sys) - idx = iip ? 2 : 1 - f = generate_function(sys,checkbounds=checkbounds,linenumbers=linenumbers, - expression=Val{true}) - if grad - _grad = generate_gradient(sys,checkbounds=checkbounds,linenumbers=linenumbers, - parallel=parallel,expression=Val{false})[idx] - else - _grad = :nothing - end - - if hess - _hess = generate_hessian(sys,checkbounds=checkbounds,linenumbers=linenumbers, - sparse=sparse,parallel=parallel,expression=Val{false})[idx] - else - _hess = :nothing - end - - defs = defaults(sys) - u0 = varmap_to_vars(u0,dvs; defaults=defs) - p = varmap_to_vars(parammap,ps; defaults=defs) - lb = varmap_to_vars(lb,dvs) - ub = varmap_to_vars(ub,dvs) - quote - f = $f - p = $p - u0 = $u0 - grad = $_grad - hess = $_hess - lb = $lb - ub = $ub - _f = OptimizationFunction{$iip,typeof(f),typeof(grad),typeof(hess),Nothing,Nothing,Nothing,Nothing}(f,grad,hess,nothing,AutoModelingToolkit(),nothing,nothing,nothing,0) - OptimizationProblem{$iip}(_f,u0,p;lb=lb,ub=ub,kwargs...) - end -end - -function DiffEqBase.OptimizationFunction{iip}(f, ::AutoModelingToolkit, x, p = DiffEqBase.NullParameters(); - grad=false, hess=false, cons = nothing, cons_j = nothing, cons_h = nothing, - num_cons = 0, chunksize = 1, hv = nothing) where iip - - sys = modelingtoolkitize(OptimizationProblem(f,x,p)) - u0map = states(sys) .=> x - if p == DiffEqBase.NullParameters() - parammap = DiffEqBase.NullParameters() - else - parammap = parameters(sys) .=> p - end - OptimizationProblem(sys,u0map,parammap,grad=grad,hess=hess).f -end +""" +$(TYPEDEF) + +A scalar equation for optimization. + +# Fields +$(FIELDS) + +# Examples + +```julia +@variables x y z +@parameters a b c + +obj = a * (y - x) + x * (b - z) - y + x * y - c * z +cons = [x^2 + y^2 ≲ 1] +@named os = OptimizationSystem(obj, [x, y, z], [a, b, c]; constraints = cons) +``` +""" +struct OptimizationSystem <: AbstractOptimizationSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt + """Objective function of the system.""" + op::Any + """Unknown variables.""" + unknowns::Array + """Parameters.""" + ps::Vector + """Array variables.""" + var_to_name::Any + """Observed variables.""" + observed::Vector{Equation} + """List of constraint equations of the system.""" + constraints::Vector{Union{Equation, Inequality}} + """The name of the system.""" + name::Symbol + """A description of the system.""" + description::String + """The internal systems. These are required to have unique names.""" + systems::Vector{OptimizationSystem} + """ + The default values to use when initial guess and/or + parameters are not supplied in `OptimizationProblem`. + """ + defaults::Dict + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + """ + The hierarchical parent system before simplification. + """ + parent::Any + isscheduled::Bool + + function OptimizationSystem(tag, op, unknowns, ps, var_to_name, observed, + constraints, name, description, systems, defaults, metadata = nothing, + gui_metadata = nothing, namespacing = true, complete = false, + index_cache = nothing, parent = nothing, isscheduled = false; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(unknowns, ps) + unwrap(op) isa Symbolic && check_units(u, op) + check_units(u, observed) + check_units(u, constraints) + check_subsystems(systems) + end + new(tag, op, unknowns, ps, var_to_name, observed, + constraints, name, description, systems, defaults, metadata, gui_metadata, + namespacing, complete, index_cache, parent, isscheduled) + end +end + +equations(sys::AbstractOptimizationSystem) = objective(sys) # needed for Base.show + +function OptimizationSystem(op, unknowns, ps; + observed = [], + constraints = [], + default_u0 = Dict(), + default_p = Dict(), + defaults = _merge(Dict(default_u0), Dict(default_p)), + name = nothing, + description = "", + systems = OptimizationSystem[], + checks = true, + metadata = nothing, + gui_metadata = nothing) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + constraints = value.(reduce(vcat, scalarize(constraints); init = [])) + unknowns′ = value.(reduce(vcat, scalarize(unknowns); init = [])) + ps′ = value.(ps) + op′ = value(scalarize(op)) + + irreducible_subs = Dict() + for i in eachindex(unknowns′) + var = unknowns′[i] + if hasbounds(var) + irreducible_subs[var] = irrvar = setirreducible(var, true) + unknowns′[i] = irrvar + end + end + op′ = fast_substitute(op′, irreducible_subs) + constraints = fast_substitute.(constraints, (irreducible_subs,)) + + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :OptimizationSystem, force = true) + end + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + defaults = todict(defaults) + defaults = Dict(fast_substitute(value(k), irreducible_subs) => fast_substitute( + value(v), irreducible_subs) + for (k, v) in pairs(defaults) if value(v) !== nothing) + + var_to_name = Dict() + process_variables!(var_to_name, defaults, Dict(), unknowns′) + process_variables!(var_to_name, defaults, Dict(), ps′) + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + OptimizationSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + op′, unknowns′, ps′, var_to_name, + observed, + constraints, + name, description, systems, defaults, metadata, gui_metadata; + checks = checks) +end + +function OptimizationSystem(objective; constraints = [], kwargs...) + allunknowns = OrderedSet() + ps = OrderedSet() + collect_vars!(allunknowns, ps, objective, nothing) + for cons in constraints + collect_vars!(allunknowns, ps, cons, nothing) + end + for ssys in get(kwargs, :systems, OptimizationSystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, nothing) + end + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + return OptimizationSystem( + objective, collect(allunknowns), collect(new_ps); constraints, kwargs...) +end + +function flatten(sys::OptimizationSystem) + systems = get_systems(sys) + isempty(systems) && return sys + + return OptimizationSystem( + objective(sys), + unknowns(sys), + parameters(sys); + observed = observed(sys), + constraints = constraints(sys), + defaults = defaults(sys), + name = nameof(sys), + metadata = get_metadata(sys), + checks = false + ) +end + +function calculate_gradient(sys::OptimizationSystem) + expand_derivatives.(gradient(objective(sys), unknowns(sys))) +end + +function generate_gradient(sys::OptimizationSystem, vs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) + grad = calculate_gradient(sys) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, grad, vs, p...; kwargs...) +end + +function calculate_hessian(sys::OptimizationSystem) + expand_derivatives.(hessian(objective(sys), unknowns(sys))) +end + +function generate_hessian( + sys::OptimizationSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, kwargs...) + if sparse + hess = sparsehessian(objective(sys), unknowns(sys)) + else + hess = calculate_hessian(sys) + end + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) +end + +function generate_function(sys::OptimizationSystem, vs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + kwargs...) + eqs = objective(sys) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, eqs, vs, p...; kwargs...) +end + +function namespace_objective(sys::AbstractSystem) + op = objective(sys) + namespace_expr(op, sys) +end + +function objective(sys) + op = get_op(sys) + systems = get_systems(sys) + if isempty(systems) + op + else + op + reduce(+, map(sys_ -> namespace_objective(sys_), systems)) + end +end + +namespace_constraint(eq::Equation, sys) = namespace_equation(eq, sys) + +namespace_constraint(ineq::Inequality, sys) = namespace_inequality(ineq, sys) + +function namespace_inequality(ineq::Inequality, sys, n = nameof(sys)) + _lhs = namespace_expr(ineq.lhs, sys, n) + _rhs = namespace_expr(ineq.rhs, sys, n) + Inequality(_lhs, + _rhs, + ineq.relational_op) +end + +function namespace_constraints(sys) + cstrs = constraints(sys) + isempty(cstrs) && return Vector{Union{Equation, Inequality}}(undef, 0) + map(cstr -> namespace_constraint(cstr, sys), cstrs) +end + +function constraints(sys) + cs = get_constraints(sys) + systems = get_systems(sys) + isempty(systems) ? cs : [cs; reduce(vcat, namespace_constraints.(systems))] +end + +hessian_sparsity(sys::OptimizationSystem) = hessian_sparsity(get_op(sys), unknowns(sys)) + +""" +```julia +DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, + parammap = DiffEqBase.NullParameters(); + grad = false, + hess = false, sparse = false, + cons_j = false, cons_h = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates an OptimizationProblem from an OptimizationSystem and allows for automatically +symbolically calculating numerical enhancements. + +Certain solvers require setting `cons_j`, `cons_h` to `true` for constrained-optimization problems. +""" +function DiffEqBase.OptimizationProblem(sys::OptimizationSystem, args...; kwargs...) + DiffEqBase.OptimizationProblem{true}(sys::OptimizationSystem, args...; kwargs...) +end +function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, + parammap = DiffEqBase.NullParameters(); + lb = nothing, ub = nothing, + grad = false, + hess = false, sparse = false, + cons_j = false, cons_h = false, + cons_sparse = false, checkbounds = false, + linenumbers = true, parallel = SerialForm(), + eval_expression = false, eval_module = @__MODULE__, + checks = true, cse = true, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `OptimizationSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `OptimizationProblem`") + end + if haskey(kwargs, :lcons) || haskey(kwargs, :ucons) + Base.depwarn( + "`lcons` and `ucons` are deprecated. Specify constraints directly instead.", + :OptimizationProblem, force = true) + end + + dvs = unknowns(sys) + ps = parameters(sys) + cstr = constraints(sys) + + if isnothing(lb) && isnothing(ub) # use the symbolically specified bounds + lb = first.(getbounds.(dvs)) + ub = last.(getbounds.(dvs)) + isboolean = symtype.(unwrap.(dvs)) .<: Bool + lb[isboolean] .= 0 + ub[isboolean] .= 1 + else # use the user supplied variable bounds + xor(isnothing(lb), isnothing(ub)) && + throw(ArgumentError("Expected both `lb` and `ub` to be supplied")) + !isnothing(lb) && length(lb) != length(dvs) && + throw(ArgumentError("Expected both `lb` to be of the same length as the vector of optimization variables")) + !isnothing(ub) && length(ub) != length(dvs) && + throw(ArgumentError("Expected both `ub` to be of the same length as the vector of optimization variables")) + end + + int = symtype.(unwrap.(dvs)) .<: Integer + + defs = defaults(sys) + defs = mergedefaults(defs, parammap, ps) + defs = mergedefaults(defs, u0map, dvs) + + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false) + if parammap isa MTKParameters + p = parammap + elseif has_index_cache(sys) && get_index_cache(sys) !== nothing + p = MTKParameters(sys, parammap, u0map) + else + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false) + end + lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false) + ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false) + + if !isnothing(lb) && all(lb .== -Inf) && !isnothing(ub) && all(ub .== Inf) + lb = nothing + ub = nothing + end + + f = let _f = eval_or_rgf( + generate_function( + sys; checkbounds = checkbounds, linenumbers = linenumbers, + expression = Val{true}, wrap_mtkparameters = false, cse); + eval_expression, + eval_module) + __f(u, p) = _f(u, p) + __f(u, p::MTKParameters) = _f(u, p...) + __f + end + obj_expr = subs_constants(objective(sys)) + + if grad + _grad = let (grad_oop, grad_iip) = eval_or_rgf.( + generate_gradient( + sys; checkbounds = checkbounds, + linenumbers = linenumbers, + parallel = parallel, expression = Val{true}, + wrap_mtkparameters = false, cse); + eval_expression, + eval_module) + _grad(u, p) = grad_oop(u, p) + _grad(J, u, p) = (grad_iip(J, u, p); J) + _grad(u, p::MTKParameters) = grad_oop(u, p...) + _grad(J, u, p::MTKParameters) = (grad_iip(J, u, p...); J) + _grad + end + else + _grad = nothing + end + + if hess + _hess = let (hess_oop, hess_iip) = eval_or_rgf.( + generate_hessian( + sys; checkbounds = checkbounds, + linenumbers = linenumbers, + sparse = sparse, parallel = parallel, + expression = Val{true}, wrap_mtkparameters = false, cse); + eval_expression, + eval_module) + _hess(u, p) = hess_oop(u, p) + _hess(J, u, p) = (hess_iip(J, u, p); J) + _hess(u, p::MTKParameters) = hess_oop(u, p...) + _hess(J, u, p::MTKParameters) = (hess_iip(J, u, p...); J) + _hess + end + else + _hess = nothing + end + + if sparse + hess_prototype = hessian_sparsity(sys) + else + hess_prototype = nothing + end + + observedfun = ObservedFunctionCache(sys; eval_expression, eval_module, checkbounds, cse) + + if length(cstr) > 0 + @named cons_sys = ConstraintsSystem(cstr, dvs, ps; checks) + cons_sys = complete(cons_sys) + cons, lcons_, ucons_ = generate_function(cons_sys; checkbounds = checkbounds, + linenumbers = linenumbers, + expression = Val{true}, wrap_mtkparameters = false, cse) + cons = let (cons_oop, cons_iip) = eval_or_rgf.(cons; eval_expression, eval_module) + _cons(u, p) = cons_oop(u, p) + _cons(resid, u, p) = cons_iip(resid, u, p) + _cons(u, p::MTKParameters) = cons_oop(u, p...) + _cons(resid, u, p::MTKParameters) = cons_iip(resid, u, p...) + end + if cons_j + _cons_j = let (cons_jac_oop, cons_jac_iip) = eval_or_rgf.( + generate_jacobian(cons_sys; + checkbounds = checkbounds, + linenumbers = linenumbers, + parallel = parallel, expression = Val{true}, + sparse = cons_sparse, wrap_mtkparameters = false, cse); + eval_expression, + eval_module) + _cons_j(u, p) = cons_jac_oop(u, p) + _cons_j(J, u, p) = (cons_jac_iip(J, u, p); J) + _cons_j(u, p::MTKParameters) = cons_jac_oop(u, p...) + _cons_j(J, u, p::MTKParameters) = (cons_jac_iip(J, u, p...); J) + _cons_j + end + else + _cons_j = nothing + end + if cons_h + _cons_h = let (cons_hess_oop, cons_hess_iip) = eval_or_rgf.( + generate_hessian( + cons_sys; checkbounds = checkbounds, + linenumbers = linenumbers, + sparse = cons_sparse, parallel = parallel, + expression = Val{true}, wrap_mtkparameters = false, cse); + eval_expression, + eval_module) + _cons_h(u, p) = cons_hess_oop(u, p) + _cons_h(J, u, p) = (cons_hess_iip(J, u, p); J) + _cons_h(u, p::MTKParameters) = cons_hess_oop(u, p...) + _cons_h(J, u, p::MTKParameters) = (cons_hess_iip(J, u, p...); J) + _cons_h + end + else + _cons_h = nothing + end + cons_expr = subs_constants(constraints(cons_sys)) + + if !haskey(kwargs, :lcons) && !haskey(kwargs, :ucons) # use the symbolically specified bounds + lcons = lcons_ + ucons = ucons_ + else # use the user supplied constraints bounds + (haskey(kwargs, :lcons) ⊻ haskey(kwargs, :ucons)) && + throw(ArgumentError("Expected both `ucons` and `lcons` to be supplied")) + haskey(kwargs, :lcons) && length(kwargs[:lcons]) != length(cstr) && + throw(ArgumentError("Expected `lcons` to be of the same length as the vector of constraints")) + haskey(kwargs, :ucons) && length(kwargs[:ucons]) != length(cstr) && + throw(ArgumentError("Expected `ucons` to be of the same length as the vector of constraints")) + lcons = haskey(kwargs, :lcons) + ucons = haskey(kwargs, :ucons) + end + + if cons_sparse + cons_jac_prototype = jacobian_sparsity(cons_sys) + cons_hess_prototype = hessian_sparsity(cons_sys) + else + cons_jac_prototype = nothing + cons_hess_prototype = nothing + end + _f = DiffEqBase.OptimizationFunction{iip}(f, + sys = sys, + SciMLBase.NoAD(); + grad = _grad, + hess = _hess, + hess_prototype = hess_prototype, + cons = cons, + cons_j = _cons_j, + cons_h = _cons_h, + cons_jac_prototype = cons_jac_prototype, + cons_hess_prototype = cons_hess_prototype, + expr = obj_expr, + cons_expr = cons_expr, + observed = observedfun) + OptimizationProblem{iip}(_f, u0, p; lb = lb, ub = ub, int = int, + lcons = lcons, ucons = ucons, kwargs...) + else + _f = DiffEqBase.OptimizationFunction{iip}(f, + sys = sys, + SciMLBase.NoAD(); + grad = _grad, + hess = _hess, + hess_prototype = hess_prototype, + expr = obj_expr, + observed = observedfun) + OptimizationProblem{iip}(_f, u0, p; lb = lb, ub = ub, int = int, + kwargs...) + end +end + +""" +```julia +DiffEqBase.OptimizationProblemExpr{iip}(sys::OptimizationSystem, + parammap = DiffEqBase.NullParameters(); + u0 = nothing, + grad = false, + hes = false, sparse = false, + checkbounds = false, + linenumbers = true, parallel = SerialForm(), + kwargs...) where {iip} +``` + +Generates a Julia expression for an OptimizationProblem from an +OptimizationSystem and allows for automatically symbolically +calculating numerical enhancements. +""" +struct OptimizationProblemExpr{iip} end + +function OptimizationProblemExpr(sys::OptimizationSystem, args...; kwargs...) + OptimizationProblemExpr{true}(sys::OptimizationSystem, args...; kwargs...) +end + +function OptimizationProblemExpr{iip}(sys::OptimizationSystem, u0map, + parammap = DiffEqBase.NullParameters(); + lb = nothing, ub = nothing, + grad = false, + hess = false, sparse = false, + cons_j = false, cons_h = false, + checkbounds = false, + linenumbers = false, parallel = SerialForm(), + eval_expression = false, eval_module = @__MODULE__, + kwargs...) where {iip} + if !iscomplete(sys) + error("A completed `OptimizationSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `OptimizationProblemExpr`") + end + if haskey(kwargs, :lcons) || haskey(kwargs, :ucons) + Base.depwarn( + "`lcons` and `ucons` are deprecated. Specify constraints directly instead.", + :OptimizationProblem, force = true) + end + + dvs = unknowns(sys) + ps = parameters(sys) + cstr = constraints(sys) + + if isnothing(lb) && isnothing(ub) # use the symbolically specified bounds + lb = first.(getbounds.(dvs)) + ub = last.(getbounds.(dvs)) + isboolean = symtype.(unwrap.(dvs)) .<: Bool + lb[isboolean] .= 0 + ub[isboolean] .= 1 + else # use the user supplied variable bounds + xor(isnothing(lb), isnothing(ub)) && + throw(ArgumentError("Expected both `lb` and `ub` to be supplied")) + !isnothing(lb) && length(lb) != length(dvs) && + throw(ArgumentError("Expected `lb` to be of the same length as the vector of optimization variables")) + !isnothing(ub) && length(ub) != length(dvs) && + throw(ArgumentError("Expected `ub` to be of the same length as the vector of optimization variables")) + end + + int = symtype.(unwrap.(dvs)) .<: Integer + + defs = defaults(sys) + defs = mergedefaults(defs, parammap, ps) + defs = mergedefaults(defs, u0map, dvs) + + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false) + if has_index_cache(sys) && get_index_cache(sys) !== nothing + p = MTKParameters(sys, parammap, u0map) + else + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false) + end + lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false) + ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false) + + if !isnothing(lb) && all(lb .== -Inf) && !isnothing(ub) && all(ub .== Inf) + lb = nothing + ub = nothing + end + + idx = iip ? 2 : 1 + f = generate_function(sys, checkbounds = checkbounds, linenumbers = linenumbers, + expression = Val{true}) + if grad + _grad = eval_or_rgf( + generate_gradient( + sys, checkbounds = checkbounds, linenumbers = linenumbers, + parallel = parallel, expression = Val{true})[idx]; + eval_expression, + eval_module) + else + _grad = :nothing + end + + if hess + _hess = eval_or_rgf( + generate_hessian(sys, checkbounds = checkbounds, linenumbers = linenumbers, + sparse = sparse, parallel = parallel, + expression = Val{false})[idx]; + eval_expression, + eval_module) + else + _hess = :nothing + end + + if sparse + hess_prototype = hessian_sparsity(sys) + else + hess_prototype = nothing + end + + obj_expr = toexpr(subs_constants(objective(sys))) + pairs_arr = if p isa SciMLBase.NullParameters + [Symbol(_s) => Expr(:ref, :x, i) for (i, _s) in enumerate(dvs)] + else + vcat([Symbol(_s) => Expr(:ref, :x, i) for (i, _s) in enumerate(dvs)], + [Symbol(_p) => p[i] for (i, _p) in enumerate(ps)]) + end + rep_pars_vals!(obj_expr, pairs_arr) + + if length(cstr) > 0 + @named cons_sys = ConstraintsSystem(cstr, dvs, ps) + cons, lcons_, ucons_ = generate_function(cons_sys, checkbounds = checkbounds, + linenumbers = linenumbers, + expression = Val{true}) + cons = eval_or_rgf(cons; eval_expression, eval_module) + if cons_j + _cons_j = eval_or_rgf( + generate_jacobian(cons_sys; expression = Val{true}, sparse = sparse)[2]; + eval_expression, eval_module) + else + _cons_j = nothing + end + if cons_h + _cons_h = eval_or_rgf( + generate_hessian(cons_sys; expression = Val{true}, sparse = sparse)[2]; + eval_expression, eval_module) + else + _cons_h = nothing + end + + cons_expr = toexpr.(subs_constants(constraints(cons_sys))) + rep_pars_vals!.(cons_expr, Ref(pairs_arr)) + + if !haskey(kwargs, :lcons) && !haskey(kwargs, :ucons) # use the symbolically specified bounds + lcons = lcons_ + ucons = ucons_ + else # use the user supplied constraints bounds + (haskey(kwargs, :lcons) ⊻ haskey(kwargs, :ucons)) && + throw(ArgumentError("Expected both `ucons` and `lcons` to be supplied")) + haskey(kwargs, :lcons) && length(kwargs[:lcons]) != length(cstr) && + throw(ArgumentError("Expected `lcons` to be of the same length as the vector of constraints")) + haskey(kwargs, :ucons) && length(kwargs[:ucons]) != length(cstr) && + throw(ArgumentError("Expected `ucons` to be of the same length as the vector of constraints")) + lcons = haskey(kwargs, :lcons) + ucons = haskey(kwargs, :ucons) + end + + if sparse + cons_jac_prototype = jacobian_sparsity(cons_sys) + cons_hess_prototype = hessian_sparsity(cons_sys) + else + cons_jac_prototype = nothing + cons_hess_prototype = nothing + end + + quote + f = $f + p = $p + u0 = $u0 + grad = $_grad + hess = $_hess + lb = $lb + ub = $ub + int = $int + cons = $cons[1] + lcons = $lcons + ucons = $ucons + cons_j = $_cons_j + cons_h = $_cons_h + _f = OptimizationFunction{iip}(f, SciMLBase.NoAD(); + grad = grad, + hess = hess, + hess_prototype = hess_prototype, + cons = cons, + cons_j = cons_j, + cons_h = cons_h, + cons_jac_prototype = cons_jac_prototype, + cons_hess_prototype = cons_hess_prototype, + expr = obj_expr, + cons_expr = cons_expr) + OptimizationProblem{$iip}( + _f, u0, p; lb = lb, ub = ub, int = int, lcons = lcons, + ucons = ucons, kwargs...) + end + else + quote + f = $f + p = $p + u0 = $u0 + grad = $_grad + hess = $_hess + lb = $lb + ub = $ub + int = $int + _f = OptimizationFunction{iip}(f, SciMLBase.NoAD(); + grad = grad, + hess = hess, + hess_prototype = hess_prototype, + expr = obj_expr) + OptimizationProblem{$iip}(_f, u0, p; lb = lb, ub = ub, int = int, kwargs...) + end + end +end + +function structural_simplify(sys::OptimizationSystem; split = true, kwargs...) + sys = flatten(sys) + cons = constraints(sys) + econs = Equation[] + icons = similar(cons, 0) + for e in cons + if e isa Equation + push!(econs, e) + else + push!(icons, e) + end + end + nlsys = NonlinearSystem(econs, unknowns(sys), parameters(sys); name = :___tmp_nlsystem) + snlsys = structural_simplify(nlsys; fully_determined = false, kwargs...) + obs = observed(snlsys) + subs = Dict(eq.lhs => eq.rhs for eq in observed(snlsys)) + seqs = equations(snlsys) + cons_simplified = similar(cons, length(icons) + length(seqs)) + for (i, eq) in enumerate(Iterators.flatten((seqs, icons))) + cons_simplified[i] = fixpoint_sub(eq, subs) + end + newsts = setdiff(unknowns(sys), keys(subs)) + @set! sys.constraints = cons_simplified + @set! sys.observed = [observed(sys); obs] + neweqs = fixpoint_sub.(equations(sys), (subs,)) + @set! sys.op = length(neweqs) == 1 ? first(neweqs) : neweqs + @set! sys.unknowns = newsts + sys = complete(sys; split) + return sys +end + +supports_initialization(::OptimizationSystem) = false diff --git a/src/systems/parameter_buffer.jl b/src/systems/parameter_buffer.jl new file mode 100644 index 0000000000..4d33aa0a06 --- /dev/null +++ b/src/systems/parameter_buffer.jl @@ -0,0 +1,888 @@ +symconvert(::Type{Symbolics.Struct{T}}, x) where {T} = convert(T, x) +symconvert(::Type{T}, x::V) where {T, V} = convert(promote_type(T, V), x) +symconvert(::Type{Real}, x::Integer) = convert(Float16, x) +symconvert(::Type{V}, x) where {V <: AbstractArray} = convert(V, symconvert.(eltype(V), x)) + +struct MTKParameters{T, I, D, C, N, H} + tunable::T + initials::I + discrete::D + constant::C + nonnumeric::N + caches::H +end + +""" + function MTKParameters(sys::AbstractSystem, p, u0 = Dict(); t0 = nothing) + +Create an `MTKParameters` object for the system `sys`. `p` (`u0`) are symbolic maps from +parameters (unknowns) to their values. The values can also be symbolic expressions, which +are evaluated given the values of other parameters/unknowns. `u0` is only required if +the values of parameters depend on the unknowns. `t0` is the initial time, for time- +dependent systems. It is only required if the symbolic expressions also use the independent +variable of the system. + +This requires that `complete` has been called on the system (usually via +`structural_simplify` or `@mtkbuild`) and the keyword `split = true` was passed (which is +the default behavior). +""" +function MTKParameters( + sys::AbstractSystem, p, u0 = Dict(); tofloat = false, + t0 = nothing, substitution_limit = 1000, floatT = nothing) + ic = if has_index_cache(sys) && get_index_cache(sys) !== nothing + get_index_cache(sys) + else + error("Cannot create MTKParameters if system does not have index_cache") + end + all_ps = Set(unwrap.(parameters(sys; initial_parameters = true))) + union!(all_ps, default_toterm.(unwrap.(parameters(sys; initial_parameters = true)))) + + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + u0 = to_varmap(u0, dvs) + symbols_to_symbolics!(sys, u0) + p = to_varmap(p, ps) + symbols_to_symbolics!(sys, p) + defs = add_toterms(recursive_unwrap(defaults(sys))) + cmap, cs = get_cmap(sys) + + is_time_dependent(sys) && add_observed!(sys, u0) + add_parameter_dependencies!(sys, p) + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0, p, defs, cmap, dvs, ps) + + if t0 !== nothing + op[get_iv(sys)] = t0 + end + + if floatT === nothing + floatT = float(float_type_from_varmap(op)) + end + + isempty(missing_pars) || throw(MissingParametersError(collect(missing_pars))) + evaluate_varmap!(op, ps; limit = substitution_limit) + + p = op + filter!(p) do kvp + kvp[1] in all_ps + end + + tunable_buffer = Vector{ic.tunable_buffer_size.type}( + undef, ic.tunable_buffer_size.length) + initials_buffer = Vector{ic.initials_buffer_size.type}( + undef, ic.initials_buffer_size.length) + disc_buffer = Tuple(BlockedArray( + Vector{subbuffer_sizes[1].type}( + undef, sum(x -> x.length, subbuffer_sizes)), + map(x -> x.length, subbuffer_sizes)) + for subbuffer_sizes in ic.discrete_buffer_sizes) + const_buffer = Tuple(Vector{temp.type}(undef, temp.length) + for temp in ic.constant_buffer_sizes) + nonnumeric_buffer = Tuple(Vector{temp.type}(undef, temp.length) + for temp in ic.nonnumeric_buffer_sizes) + function set_value(sym, val) + done = true + if haskey(ic.tunable_idx, sym) + idx = ic.tunable_idx[sym] + tunable_buffer[idx] = val + elseif haskey(ic.initials_idx, sym) + idx = ic.initials_idx[sym] + initials_buffer[idx] = val + elseif haskey(ic.discrete_idx, sym) + idx = ic.discrete_idx[sym] + disc_buffer[idx.buffer_idx][idx.idx_in_buffer] = val + elseif haskey(ic.constant_idx, sym) + i, j = ic.constant_idx[sym] + const_buffer[i][j] = val + elseif haskey(ic.nonnumeric_idx, sym) + i, j = ic.nonnumeric_idx[sym] + nonnumeric_buffer[i][j] = val + elseif !isequal(default_toterm(sym), sym) + done = set_value(default_toterm(sym), val) + else + done = false + end + return done + end + for (sym, val) in p + sym = unwrap(sym) + val = unwrap(val) + ctype = symtype(sym) + if symbolic_type(val) !== NotSymbolic() + error("Could not evaluate value of parameter $sym. Missing values for variables in expression $val.") + end + if ctype <: FnType + ctype = fntype_to_function_type(ctype) + end + if ctype == Real && floatT !== nothing + ctype = floatT + end + val = symconvert(ctype, val) + done = set_value(sym, val) + if !done && Symbolics.isarraysymbolic(sym) + if Symbolics.shape(sym) === Symbolics.Unknown() + for i in eachindex(val) + set_value(sym[i], val[i]) + end + else + if size(sym) != size(val) + error("Got value of size $(size(val)) for parameter $sym of size $(size(sym))") + end + set_value.(collect(sym), val) + end + end + end + tunable_buffer = narrow_buffer_type(tunable_buffer) + if isempty(tunable_buffer) + tunable_buffer = SizedVector{0, Float64}() + end + initials_buffer = narrow_buffer_type(initials_buffer) + if isempty(initials_buffer) + initials_buffer = SizedVector{0, Float64}() + end + disc_buffer = narrow_buffer_type.(disc_buffer) + const_buffer = narrow_buffer_type.(const_buffer) + # Don't narrow nonnumeric types + nonnumeric_buffer = nonnumeric_buffer + + mtkps = MTKParameters{ + typeof(tunable_buffer), typeof(initials_buffer), typeof(disc_buffer), + typeof(const_buffer), typeof(nonnumeric_buffer), typeof(())}(tunable_buffer, + initials_buffer, disc_buffer, const_buffer, nonnumeric_buffer, ()) + return mtkps +end + +function rebuild_with_caches(p::MTKParameters, cache_templates::BufferTemplate...) + buffers = map(cache_templates) do template + Vector{template.type}(undef, template.length) + end + @set p.caches = buffers +end + +function narrow_buffer_type(buffer::AbstractArray) + type = Union{} + for x in buffer + type = promote_type(type, typeof(x)) + end + return convert.(type, buffer) +end + +function narrow_buffer_type(buffer::AbstractArray{<:AbstractArray}) + buffer = narrow_buffer_type.(buffer) + type = Union{} + for x in buffer + type = promote_type(type, eltype(x)) + end + return broadcast.(convert, type, buffer) +end + +function buffer_to_arraypartition(buf) + return ArrayPartition(ntuple(i -> _buffer_to_arrp_helper(buf[i]), Val(length(buf)))) +end + +_buffer_to_arrp_helper(v::T) where {T} = _buffer_to_arrp_helper(eltype(T), v) +_buffer_to_arrp_helper(::Type{<:AbstractArray}, v) = buffer_to_arraypartition(v) +_buffer_to_arrp_helper(::Any, v) = v + +function _split_helper(buf_v::T, recurse, raw, idx) where {T} + _split_helper(eltype(T), buf_v, recurse, raw, idx) +end + +function _split_helper(::Type{<:AbstractArray}, buf_v, ::Val{N}, raw, idx) where {N} + map(b -> _split_helper(eltype(b), b, Val(N - 1), raw, idx), buf_v) +end + +function _split_helper( + ::Type{<:AbstractArray}, buf_v::BlockedArray, ::Val{N}, raw, idx) where {N} + BlockedArray(map(b -> _split_helper(eltype(b), b, Val(N - 1), raw, idx), buf_v), + blocksizes(buf_v, 1)) +end + +function _split_helper(::Type{<:AbstractArray}, buf_v::Tuple, ::Val{N}, raw, idx) where {N} + ntuple(i -> _split_helper(eltype(buf_v[i]), buf_v[i], Val(N - 1), raw, idx), + Val(length(buf_v))) +end + +function _split_helper(::Type{<:AbstractArray}, buf_v, ::Val{0}, raw, idx) + _split_helper((), buf_v, (), raw, idx) +end + +function _split_helper(_, buf_v, _, raw, idx) + res = reshape(raw[idx[]:(idx[] + length(buf_v) - 1)], size(buf_v)) + idx[] += length(buf_v) + return res +end + +function _split_helper(_, buf_v::BlockedArray, _, raw, idx) + res = BlockedArray( + reshape(raw[idx[]:(idx[] + length(buf_v) - 1)], size(buf_v)), blocksizes(buf_v, 1)) + idx[] += length(buf_v) + return res +end + +function split_into_buffers(raw::AbstractArray, buf, recurse = Val(1)) + idx = Ref(1) + ntuple(i -> _split_helper(buf[i], recurse, raw, idx), Val(length(buf))) +end + +function _update_tuple_helper(buf_v::T, raw, idx) where {T} + _update_tuple_helper(eltype(T), buf_v, raw, idx) +end + +function _update_tuple_helper(::Type{<:AbstractArray}, buf_v, raw, idx) + ntuple(i -> _update_tuple_helper(buf_v[i], raw, idx), length(buf_v)) +end + +function _update_tuple_helper(::Any, buf_v, raw, idx) + copyto!(buf_v, view(raw, idx[]:(idx[] + length(buf_v) - 1))) + idx[] += length(buf_v) + return nothing +end + +function update_tuple_of_buffers(raw::AbstractArray, buf) + idx = Ref(1) + ntuple(i -> _update_tuple_helper(buf[i], raw, idx), Val(length(buf))) +end + +SciMLStructures.isscimlstructure(::MTKParameters) = true + +SciMLStructures.ismutablescimlstructure(::MTKParameters) = true + +function SciMLStructures.canonicalize(::SciMLStructures.Tunable, p::MTKParameters) + arr = p.tunable + repack = let p = p + function (new_val) + return SciMLStructures.replace(SciMLStructures.Tunable(), p, new_val) + end + end + return arr, repack, true +end + +function SciMLStructures.replace(::SciMLStructures.Tunable, p::MTKParameters, newvals) + @set! p.tunable = newvals + return p +end + +function SciMLStructures.replace!(::SciMLStructures.Tunable, p::MTKParameters, newvals) + copyto!(p.tunable, newvals) + return nothing +end + +function SciMLStructures.canonicalize(::SciMLStructures.Initials, p::MTKParameters) + arr = p.initials + repack = let p = p + function (new_val) + return SciMLStructures.replace(SciMLStructures.Initials(), p, new_val) + end + end + return arr, repack, true +end + +function SciMLStructures.replace(::SciMLStructures.Initials, p::MTKParameters, newvals) + @set! p.initials = newvals + return p +end + +function SciMLStructures.replace!(::SciMLStructures.Initials, p::MTKParameters, newvals) + copyto!(p.initials, newvals) + return nothing +end + +for (Portion, field, recurse) in [(SciMLStructures.Discrete, :discrete, 1) + (SciMLStructures.Constants, :constant, 1) + (Nonnumeric, :nonnumeric, 1) + (SciMLStructures.Caches, :caches, 1)] + @eval function SciMLStructures.canonicalize(::$Portion, p::MTKParameters) + as_vector = buffer_to_arraypartition(p.$field) + repack = let p = p + function (new_val) + return SciMLStructures.replace(($Portion)(), p, new_val) + end + end + return as_vector, repack, true + end + + @eval function SciMLStructures.replace(::$Portion, p::MTKParameters, newvals) + @set! p.$field = split_into_buffers(newvals, p.$field, Val($recurse)) + p + end + + @eval function SciMLStructures.replace!(::$Portion, p::MTKParameters, newvals) + update_tuple_of_buffers(newvals, p.$field) + nothing + end +end + +function Base.copy(p::MTKParameters) + tunable = copy(p.tunable) + initials = copy(p.initials) + discrete = Tuple(eltype(buf) <: Real ? copy(buf) : copy.(buf) for buf in p.discrete) + constant = Tuple(eltype(buf) <: Real ? copy(buf) : copy.(buf) for buf in p.constant) + nonnumeric = copy.(p.nonnumeric) + caches = copy.(p.caches) + return MTKParameters( + tunable, + initials, + discrete, + constant, + nonnumeric, + caches + ) +end + +function SymbolicIndexingInterface.parameter_values(p::MTKParameters, pind::ParameterIndex) + _ducktyped_parameter_values(p, pind) +end +function _ducktyped_parameter_values(p, pind::ParameterIndex) + @unpack portion, idx = pind + if portion isa SciMLStructures.Tunable + return idx isa Int ? p.tunable[idx] : view(p.tunable, idx) + end + if portion isa SciMLStructures.Initials + return idx isa Int ? p.initials[idx] : view(p.initials, idx) + end + i, j, k... = idx + if portion isa SciMLStructures.Discrete + return isempty(k) ? p.discrete[i][j] : p.discrete[i][j][k...] + elseif portion isa SciMLStructures.Constants + return isempty(k) ? p.constant[i][j] : p.constant[i][j][k...] + elseif portion === NONNUMERIC_PORTION + return isempty(k) ? p.nonnumeric[i][j] : p.nonnumeric[i][j][k...] + else + error("Unhandled portion $portion") + end +end + +function SymbolicIndexingInterface.set_parameter!( + p::MTKParameters, val, pidx::ParameterIndex) + @unpack portion, idx, validate_size = pidx + if portion isa SciMLStructures.Tunable + if validate_size && size(val) !== size(idx) + throw(InvalidParameterSizeException(size(idx), size(val))) + end + p.tunable[idx] = val + elseif portion isa SciMLStructures.Initials + if validate_size && size(val) !== size(idx) + throw(InvalidParameterSizeException(size(idx), size(val))) + end + p.initials[idx] = val + else + i, j, k... = idx + if portion isa SciMLStructures.Discrete + if isempty(k) + if validate_size && size(val) !== size(p.discrete[i][j]) + throw(InvalidParameterSizeException( + size(p.discrete[i][j]), size(val))) + end + p.discrete[i][j] = val + else + p.discrete[i][j][k...] = val + end + elseif portion isa SciMLStructures.Constants + if isempty(k) + if validate_size && size(val) !== size(p.constant[i][j]) + throw(InvalidParameterSizeException(size(p.constant[i][j]), size(val))) + end + p.constant[i][j] = val + else + p.constant[i][j][k...] = val + end + elseif portion === NONNUMERIC_PORTION + if isempty(k) + p.nonnumeric[i][j] = val + else + p.nonnumeric[i][j][k...] = val + end + else + error("Unhandled portion $portion") + end + end + return nothing +end + +function narrow_buffer_type_and_fallback_undefs( + oldbuf::AbstractVector, newbuf::AbstractVector) + type = Union{} + for i in eachindex(newbuf) + isassigned(newbuf, i) || continue + type = promote_type(type, typeof(newbuf[i])) + end + if type == Union{} + type = eltype(oldbuf) + end + newerbuf = similar(newbuf, type) + for i in eachindex(newbuf) + if isassigned(newbuf, i) + newerbuf[i] = newbuf[i] + else + newerbuf[i] = oldbuf[i] + end + end + return newerbuf +end + +function validate_parameter_type(ic::IndexCache, p, idx::ParameterIndex, val) + p = unwrap(p) + if p isa Symbol + p = get(ic.symbol_to_variable, p, nothing) + p === nothing && return validate_parameter_type(ic, idx, val) + end + stype = symtype(p) + sz = if stype <: AbstractArray + Symbolics.shape(p) == Symbolics.Unknown() ? Symbolics.Unknown() : size(p) + elseif stype <: Number + size(p) + else + Symbolics.Unknown() + end + validate_parameter_type(ic, stype, sz, p, idx, val) +end + +function validate_parameter_type(ic::IndexCache, idx::ParameterIndex, val) + stype = get_buffer_template(ic, idx).type + if (idx.portion == SciMLStructures.Tunable() || + idx.portion == SciMLStructures.Initials()) && !(idx.idx isa Int) + stype = AbstractArray{<:stype} + end + validate_parameter_type( + ic, stype, Symbolics.Unknown(), nothing, idx, val) +end + +function validate_parameter_type(ic::IndexCache, stype, sz, sym, index, val) + (; portion) = index + if stype <: FnType + stype = fntype_to_function_type(stype) + end + # Nonnumeric parameters have to match the type + if portion === NONNUMERIC_PORTION + val isa stype && return nothing + throw(ParameterTypeException( + :validate_parameter_type, sym === nothing ? index : sym, stype, val)) + end + # Array parameters need array values... + if stype <: AbstractArray && !isa(val, AbstractArray) + throw(ParameterTypeException( + :validate_parameter_type, sym === nothing ? index : sym, stype, val)) + end + # ... and must match sizes + if stype <: AbstractArray && sz != Symbolics.Unknown() && size(val) != sz + throw(InvalidParameterSizeException(sym, val)) + end + # Early exit + val isa stype && return nothing + if stype <: AbstractArray + # Arrays need handling when eltype is `Real` (accept any real array) + etype = eltype(stype) + if etype <: Real + etype = Real + end + # This is for duals and other complicated number types + etype = SciMLBase.parameterless_type(etype) + eltype(val) <: etype || throw(ParameterTypeException( + :validate_parameter_type, sym === nothing ? index : sym, AbstractArray{etype}, val)) + else + # Real check + if stype <: Real + stype = Real + end + stype = SciMLBase.parameterless_type(stype) + val isa stype || + throw(ParameterTypeException( + :validate_parameter_type, sym === nothing ? index : sym, stype, val)) + end +end + +function indp_to_system(indp) + while hasmethod(symbolic_container, Tuple{typeof(indp)}) + indp = symbolic_container(indp) + end + return indp +end + +function SymbolicIndexingInterface.remake_buffer(indp, oldbuf::MTKParameters, idxs, vals) + _remake_buffer(indp, oldbuf, idxs, vals) +end + +# For type-inference when using `SII.setp_oop` +@generated function _remake_buffer( + indp, oldbuf::MTKParameters{T, I, D, C, N, H}, + idxs::Union{Tuple{Vararg{ParameterIndex}}, AbstractArray{<:ParameterIndex{P}}}, + vals::Union{AbstractArray, Tuple}; validate = true) where {T, I, D, C, N, H, P} + + # fallback to non-generated method if values aren't type-stable + if vals <: AbstractArray && !isconcretetype(eltype(vals)) + return quote + $__remake_buffer(indp, oldbuf, collect(idxs), vals; validate) + end + end + + # given an index in idxs/vals and the current `eltype` of the buffer, + # return the promoted eltype of the buffer + function promote_valtype(i, valT) + # tuples have distinct types, arrays have a common eltype + valT′ = vals <: AbstractArray ? eltype(vals) : fieldtype(vals, i) + # if the buffer is a scalarized buffer but the variable is an array + # e.g. an array tunable, take the eltype + if valT′ <: AbstractArray && !(valT <: AbstractArray) + valT′ = eltype(valT′) + end + return promote_type(valT, valT′) + end + + # types of the idxs + idxtypes = if idxs <: AbstractArray + # if both are arrays, there is only one possible type to check + if vals <: AbstractArray + (eltype(idxs),) + else + # if `vals` is a tuple, we repeat `eltype(idxs)` to check against + # every possible type of the buffer + ntuple(Returns(eltype(idxs)), Val(fieldcount(vals))) + end + else + # `idxs` is a tuple, so we check against all buffers + fieldtypes(idxs) + end + # promote types + tunablesT = eltype(T) + for (i, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Tunable} || continue + tunablesT = promote_valtype(i, tunablesT) + end + initialsT = eltype(I) + for (i, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Initials} || continue + initialsT = promote_valtype(i, initialsT) + end + discretesT = ntuple(Val(fieldcount(D))) do i + bufT = eltype(fieldtype(D, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Discrete, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + constantsT = ntuple(Val(fieldcount(C))) do i + bufT = eltype(fieldtype(C, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Constants, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + nonnumericT = ntuple(Val(fieldcount(N))) do i + bufT = eltype(fieldtype(N, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{Nonnumeric, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + + expr = quote + tunables = $similar(oldbuf.tunable, $tunablesT) + copyto!(tunables, oldbuf.tunable) + initials = $similar(oldbuf.initials, $initialsT) + copyto!(initials, oldbuf.initials) + discretes = $(Expr(:tuple, + (:($similar(oldbuf.discrete[$i], $(discretesT[i]))) for i in 1:length(discretesT))...)) + $((:($copyto!(discretes[$i], oldbuf.discrete[$i])) for i in 1:length(discretesT))...) + constants = $(Expr(:tuple, + (:($similar(oldbuf.constant[$i], $(constantsT[i]))) for i in 1:length(constantsT))...)) + $((:($copyto!(constants[$i], oldbuf.constant[$i])) for i in 1:length(constantsT))...) + nonnumerics = $(Expr(:tuple, + (:($similar(oldbuf.nonnumeric[$i], $(nonnumericT[i]))) for i in 1:length(nonnumericT))...)) + $((:($copyto!(nonnumerics[$i], oldbuf.nonnumeric[$i])) for i in 1:length(nonnumericT))...) + newbuf = MTKParameters( + tunables, initials, discretes, constants, nonnumerics, copy.(oldbuf.caches)) + end + if idxs <: AbstractArray + push!(expr.args, :(for (idx, val) in zip(idxs, vals) + $setindex!(newbuf, val, idx) + end)) + else + for i in 1:fieldcount(idxs) + push!(expr.args, :($setindex!(newbuf, vals[$i], idxs[$i]))) + end + end + push!(expr.args, :(return newbuf)) + + return expr +end + +function _remake_buffer(indp, oldbuf::MTKParameters, idxs, vals; validate = true) + return __remake_buffer(indp, oldbuf, idxs, vals; validate) +end + +function __remake_buffer(indp, oldbuf::MTKParameters, idxs, vals; validate = true) + newbuf = @set oldbuf.tunable = similar(oldbuf.tunable, Any) + @set! newbuf.initials = similar(oldbuf.initials, Any) + @set! newbuf.discrete = Tuple(similar(buf, Any) for buf in newbuf.discrete) + @set! newbuf.constant = Tuple(similar(buf, Any) for buf in newbuf.constant) + @set! newbuf.nonnumeric = Tuple(similar(buf, Any) for buf in newbuf.nonnumeric) + + function handle_parameter(ic, sym, idx, val) + if validate + if sym === nothing + validate_parameter_type(ic, idx, val) + else + validate_parameter_type(ic, sym, idx, val) + end + end + # `ParameterIndex(idx)` turns off size validation since it relies on there + # being an existing value + set_parameter!(newbuf, val, ParameterIndex(idx)) + end + + handled_idxs = Set{ParameterIndex}() + # If the parameter buffer is an `MTKParameters` object, `indp` must eventually drill + # down to an `AbstractSystem` using `symbolic_container`. We leverage this to get + # the index cache. + ic = get_index_cache(indp_to_system(indp)) + for (idx, val) in zip(idxs, vals) + sym = nothing + if val === missing + val = get_temporary_value(idx) + end + if symbolic_type(idx) == ScalarSymbolic() + sym = idx + idx = parameter_index(ic, sym) + if idx === nothing + @warn "Symbolic variable $sym is not a (non-dependent) parameter in the system" + continue + end + idx in handled_idxs && continue + handle_parameter(ic, sym, idx, val) + push!(handled_idxs, idx) + elseif symbolic_type(idx) == ArraySymbolic() + sym = idx + idx = parameter_index(ic, sym) + if idx === nothing + Symbolics.shape(sym) == Symbolics.Unknown() && + throw(ParameterNotInSystem(sym)) + size(sym) == size(val) || throw(InvalidParameterSizeException(sym, val)) + + for (i, vali) in zip(eachindex(sym), eachindex(val)) + idx = parameter_index(ic, sym[i]) + if idx === nothing + @warn "Symbolic variable $sym is not a (non-dependent) parameter in the system" + continue + end + # Intentionally don't check handled_idxs here because array variables always take priority + # See Issue#2804 + handle_parameter(ic, sym[i], idx, val[vali]) + push!(handled_idxs, idx) + end + else + idx in handled_idxs && continue + handle_parameter(ic, sym, idx, val) + push!(handled_idxs, idx) + end + else # NotSymbolic + if !(idx isa ParameterIndex) + throw(ArgumentError("Expected index for parameter to be a symbolic variable or `ParameterIndex`, got $idx")) + end + handle_parameter(ic, nothing, idx, val) + end + end + + @set! newbuf.tunable = narrow_buffer_type_and_fallback_undefs( + oldbuf.tunable, newbuf.tunable) + if eltype(newbuf.tunable) <: Integer + T = promote_type(eltype(newbuf.tunable), Float64) + @set! newbuf.tunable = T.(newbuf.tunable) + end + @set! newbuf.initials = narrow_buffer_type_and_fallback_undefs( + oldbuf.initials, newbuf.initials) + if eltype(newbuf.initials) <: Integer + T = promote_type(eltype(newbuf.initials), Float64) + @set! newbuf.initials = T.(newbuf.initials) + end + @set! newbuf.discrete = narrow_buffer_type_and_fallback_undefs.( + oldbuf.discrete, newbuf.discrete) + @set! newbuf.constant = narrow_buffer_type_and_fallback_undefs.( + oldbuf.constant, newbuf.constant) + @set! newbuf.nonnumeric = narrow_buffer_type_and_fallback_undefs.( + oldbuf.nonnumeric, newbuf.nonnumeric) + return newbuf +end + +function as_any_buffer(p::MTKParameters) + @set! p.tunable = similar(p.tunable, Any) + @set! p.initials = similar(p.initials, Any) + @set! p.discrete = Tuple(similar(buf, Any) for buf in p.discrete) + @set! p.constant = Tuple(similar(buf, Any) for buf in p.constant) + @set! p.nonnumeric = Tuple(similar(buf, Any) for buf in p.nonnumeric) + @set! p.caches = Tuple(similar(buf, Any) for buf in p.caches) + return p +end + +struct NestedGetIndex{T} + x::T +end + +function Base.getindex(ngi::NestedGetIndex, idx::Tuple) + i, j, k... = idx + return ngi.x[i][j][k...] +end + +# Required for DiffEqArray constructor to work during interpolation +Base.size(::NestedGetIndex) = () + +function SymbolicIndexingInterface.with_updated_parameter_timeseries_values( + ::AbstractSystem, ps::MTKParameters, args::Pair{A, B}...) where { + A, B <: NestedGetIndex} + for (i, ngi) in args + for (j, val) in enumerate(ngi.x) + copyto!(view(ps.discrete[j], Block(i)), val) + end + end + return ps +end + +function SciMLBase.create_parameter_timeseries_collection( + sys::AbstractSystem, ps::MTKParameters, tspan) + ic = get_index_cache(sys) # this exists because the parameters are `MTKParameters` + isempty(ps.discrete) && return nothing + num_discretes = only(blocksize(ps.discrete[1])) + buffers = [] + partition_type = Tuple{(Vector{eltype(buf)} for buf in ps.discrete)...} + for i in 1:num_discretes + ts = eltype(tspan)[] + us = NestedGetIndex{partition_type}[] + push!(buffers, DiffEqArray(us, ts, (1, 1))) + end + + return ParameterTimeseriesCollection(Tuple(buffers), copy(ps)) +end + +function SciMLBase.get_saveable_values( + sys::AbstractSystem, ps::MTKParameters, timeseries_idx) + return NestedGetIndex(Tuple(buffer[Block(timeseries_idx)] for buffer in ps.discrete)) +end + +function save_callback_discretes!(integ::SciMLBase.DEIntegrator, callback) + ic = get_index_cache(indp_to_system(integ)) + ic === nothing && return + clockidxs = get(ic.callback_to_clocks, callback, nothing) + clockidxs === nothing && return + + for idx in clockidxs + SciMLBase.save_discretes!(integ, idx) + end +end + +function DiffEqBase.anyeltypedual( + p::MTKParameters, ::Type{Val{counter}} = Val{0}) where {counter} + DiffEqBase.anyeltypedual(p.tunable) +end +function DiffEqBase.anyeltypedual(p::Type{<:MTKParameters{T}}, + ::Type{Val{counter}} = Val{0}) where {counter} where {T} + DiffEqBase.__anyeltypedual(T) +end + +# for compiling callbacks +# getindex indexes the vectors, setindex! linearly indexes values +# it's inconsistent, but we need it to be this way +@generated function Base.getindex( + ps::MTKParameters{T, I, D, C, N, H}, idx::Int) where {T, I, D, C, N, H} + paths = [] + if !(T <: SizedVector{0, Float64}) + push!(paths, :(ps.tunable)) + end + if !(I <: SizedVector{0, Float64}) + push!(paths, :(ps.initials)) + end + for i in 1:fieldcount(D) + push!(paths, :(ps.discrete[$i])) + end + for i in 1:fieldcount(C) + push!(paths, :(ps.constant[$i])) + end + for i in 1:fieldcount(N) + push!(paths, :(ps.nonnumeric[$i])) + end + for i in 1:fieldcount(H) + push!(paths, :(ps.caches[$i])) + end + expr = Expr(:if, :(idx == 1), :(return $(paths[1]))) + curexpr = expr + for i in 2:length(paths) + push!(curexpr.args, Expr(:elseif, :(idx == $i), :(return $(paths[i])))) + curexpr = curexpr.args[end] + end + return Expr(:block, expr, :(throw(BoundsError(ps, idx)))) +end + +@generated function Base.length(ps::MTKParameters{ + T, I, D, C, N, H}) where {T, I, D, C, N, H} + len = 0 + if !(T <: SizedVector{0, Float64}) + len += 1 + end + if !(I <: SizedVector{0, Float64}) + len += 1 + end + len += fieldcount(D) + fieldcount(C) + fieldcount(N) + fieldcount(H) + return len +end + +Base.size(ps::MTKParameters) = (length(ps),) + +Base.IndexStyle(::Type{T}) where {T <: MTKParameters} = IndexLinear() + +Base.getindex(p::MTKParameters, pind::ParameterIndex) = parameter_values(p, pind) + +Base.setindex!(p::MTKParameters, val, pind::ParameterIndex) = set_parameter!(p, val, pind) + +function Base.iterate(buf::MTKParameters, state = 1) + total_len = length(buf) + if state <= total_len + return (buf[state], state + 1) + else + return nothing + end +end + +function Base.:(==)(a::MTKParameters, b::MTKParameters) + return a.tunable == b.tunable && a.initials == b.initials && a.discrete == b.discrete && + a.constant == b.constant && a.nonnumeric == b.nonnumeric && + all(Iterators.map(a.caches, b.caches) do acache, bcache + eltype(acache) == eltype(bcache) && length(acache) == length(bcache) + end) +end + +const MISSING_PARAMETERS_MESSAGE = """ + Some parameters are missing from the variable map. + Please provide a value or default for the following variables: + """ + +struct MissingParametersError <: Exception + vars::Any +end + +function Base.showerror(io::IO, e::MissingParametersError) + println(io, MISSING_PARAMETERS_MESSAGE) + println(io, join(e.vars, ", ")) +end + +function InvalidParameterSizeException(param, val) + DimensionMismatch("InvalidParameterSizeException: For parameter $(param) expected value of size $(size(param)). Received value $(val) of size $(size(val)).") +end + +function InvalidParameterSizeException(param::Tuple, val::Tuple) + DimensionMismatch("InvalidParameterSizeException: Expected value of size $(param). Received value of size $(val).") +end + +function ParameterTypeException(func, param, expected, val) + TypeError(func, "Parameter $param", expected, val) +end + +struct ParameterNotInSystem <: Exception + p::Any +end + +function Base.showerror(io::IO, e::ParameterNotInSystem) + println(io, "Symbolic variable $(e.p) is not a parameter in the system") +end diff --git a/src/systems/pde/pdesystem.jl b/src/systems/pde/pdesystem.jl index 0d3fc75177..96e6a6b276 100644 --- a/src/systems/pde/pdesystem.jl +++ b/src/systems/pde/pdesystem.jl @@ -1,81 +1,170 @@ -""" -$(TYPEDEF) - -A system of partial differential equations. - -# Fields -$(FIELDS) - -# Example - -```julia -using ModelingToolkit - -@parameters t x -@variables u(..) -Dxx = Differential(x)^2 -Dtt = Differential(t)^2 -Dt = Differential(t) - -#2D PDE -C=1 -eq = Dtt(u(t,x)) ~ C^2*Dxx(u(t,x)) - -# Initial and boundary conditions -bcs = [u(t,0) ~ 0.,# for all t > 0 - u(t,1) ~ 0.,# for all t > 0 - u(0,x) ~ x*(1. - x), #for all 0 < x < 1 - Dt(u(0,x)) ~ 0. ] #for all 0 < x < 1] - -# Space and time domains -domains = [t ∈ IntervalDomain(0.0,1.0), - x ∈ IntervalDomain(0.0,1.0)] - -pde_system = PDESystem(eq,bcs,domains,[t,x],[u]) -``` -""" -struct PDESystem <: ModelingToolkit.AbstractSystem - "The equations which define the PDE" - eqs - "The boundary conditions" - bcs - "The domain for the independent variables." - domain - "The independent variables" - indvars - "The dependent variables" - depvars - "The parameters" - ps - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict - """ - type: type of the system - """ - connection_type::Any - @add_kwonly function PDESystem(eqs, bcs, domain, indvars, depvars, - ps=SciMLBase.NullParameters(); - defaults=Dict(), - connection_type=nothing, - ) - new(eqs, bcs, domain, indvars, depvars, ps, defaults, connection_type) - end -end - -Base.getproperty(x::PDESystem, sym::Symbol) = getfield(x, sym) - -Base.summary(prob::PDESystem) = string(nameof(typeof(prob))) -function Base.show(io::IO, ::MIME"text/plain", sys::PDESystem) - println(io,summary(sys)) - println(io,"Equations: ", get_eqs(sys)) - println(io,"Boundary Conditions: ", get_bcs(sys)) - println(io,"Domain: ", get_domain(sys)) - println(io,"Dependent Variables: ", get_depvars(sys)) - println(io,"Independent Variables: ", get_indvars(sys)) - println(io,"Parameters: ", get_ps(sys)) - print(io,"Default Parameter Values", get_defaults(sys)) - return nothing -end +""" +$(TYPEDEF) + +A system of partial differential equations. + +# Fields +$(FIELDS) + +# Example + +```julia +using ModelingToolkit + +@parameters x t +@variables u(..) +Dxx = Differential(x)^2 +Dtt = Differential(t)^2 +Dt = Differential(t) + +#2D PDE +C=1 +eq = Dtt(u(t,x)) ~ C^2*Dxx(u(t,x)) + +# Initial and boundary conditions +bcs = [u(t,0) ~ 0.,# for all t > 0 + u(t,1) ~ 0.,# for all t > 0 + u(0,x) ~ x*(1. - x), #for all 0 < x < 1 + Dt(u(0,x)) ~ 0. ] #for all 0 < x < 1] + +# Space and time domains +domains = [t ∈ (0.0,1.0), + x ∈ (0.0,1.0)] + +@named pde_system = PDESystem(eq,bcs,domains,[t,x],[u]) +``` +""" +struct PDESystem <: ModelingToolkit.AbstractMultivariateSystem + "The equations which define the PDE." + eqs::Any + "The boundary conditions." + bcs::Any + "The domain for the independent variables." + domain::Any + "The independent variables." + ivs::Any + "The dependent variables." + dvs::Any + "The parameters." + ps::Any + """ + The default values to use when initial conditions and/or + parameters are not supplied in `ODEProblem`. + """ + defaults::Dict + """ + Type of the system. + """ + connector_type::Any + """ + The internal systems. These are required to have unique names. + """ + systems::Vector + """ + A vector of explicit symbolic expressions for the analytic solutions of each + dependent variable. e.g. `analytic = [u(t, x) ~ a*sin(c*t) * cos(k*x)]`. + """ + analytic::Any + """ + A vector of functions for the analytic solutions of each dependent + variable. Will be generated from `analytic` if not provided. Should have the same + argument signature as the variable, and a `ps` argument as the last argument, + which takes an indexable of parameter values in the order you specified them in `ps`. + e.g. `analytic_func = [u(t, x) => (ps, t, x) -> ps[1]*sin(ps[2]*t) * cos(ps[3]*x)]`. + """ + analytic_func::Any + """ + The name of the system. + """ + name::Symbol + """ + A description of the system. + """ + description::String + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + @add_kwonly function PDESystem(eqs, bcs, domain, ivs, dvs, + ps = SciMLBase.NullParameters(); + defaults = Dict(), + systems = [], + connector_type = nothing, + metadata = nothing, + analytic = nothing, + analytic_func = nothing, + gui_metadata = nothing, + eval_module = @__MODULE__, + checks::Union{Bool, Int} = true, + description = "", + name) + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ivs, ps) + check_units(u, eqs) + end + + eqs = eqs isa Vector ? eqs : [eqs] + + if !isnothing(analytic) + analytic = analytic isa Vector ? analytic : [analytic] + if length(analytic) != length(dvs) + throw(ArgumentError("The number of analytic solutions must match the number of dependent variables")) + end + + if isnothing(analytic_func) + analytic_func = map(analytic) do eq + args = arguments(eq.lhs) + p = ps isa SciMLBase.NullParameters ? [] : ps + args = vcat(DestructuredArgs(p), args) + ex = Func(args, [], eq.rhs) |> toexpr + eq.lhs => drop_expr(RuntimeGeneratedFunction( + eval_module, eval_module, ex)) + end + end + end + + if !isnothing(analytic_func) + analytic_func = analytic_func isa Dict ? analytic_func : analytic_func |> Dict + end + + new(eqs, bcs, domain, ivs, dvs, ps, defaults, connector_type, systems, analytic, + analytic_func, name, description, metadata, gui_metadata) + end +end + +function Base.getproperty(x::PDESystem, sym::Symbol) + if sym == :indvars + return getfield(x, :ivs) + Base.depwarn( + "`sys.indvars` is deprecated, please use `get_ivs(sys)`", :getproperty, + force = true) + + elseif sym == :depvars + return getfield(x, :dvs) + Base.depwarn( + "`sys.depvars` is deprecated, please use `get_dvs(sys)`", :getproperty, + force = true) + + else + return getfield(x, sym) + end +end + +Base.summary(prob::PDESystem) = string(nameof(typeof(prob))) +function Base.show(io::IO, ::MIME"text/plain", sys::PDESystem) + println(io, summary(sys)) + println(io, "Equations: ", get_eqs(sys)) + println(io, "Boundary Conditions: ", get_bcs(sys)) + println(io, "Domain: ", get_domain(sys)) + println(io, "Dependent Variables: ", get_dvs(sys)) + println(io, "Independent Variables: ", get_ivs(sys)) + println(io, "Parameters: ", get_ps(sys)) + print(io, "Default Parameter Values", get_defaults(sys)) + return nothing +end + +SymbolicIndexingInterface.is_time_dependent(::AbstractMultivariateSystem) = true diff --git a/src/systems/problem_utils.jl b/src/systems/problem_utils.jl new file mode 100644 index 0000000000..0750585905 --- /dev/null +++ b/src/systems/problem_utils.jl @@ -0,0 +1,1082 @@ +const AnyDict = Dict{Any, Any} + +""" + $(TYPEDSIGNATURES) + +If called without arguments, return `Dict{Any, Any}`. Otherwise, interpret the input +as a symbolic map and turn it into a `Dict{Any, Any}`. Handles `SciMLBase.NullParameters`, +`missing` and `nothing`. +""" +anydict() = AnyDict() +anydict(::SciMLBase.NullParameters) = AnyDict() +anydict(::Nothing) = AnyDict() +anydict(::Missing) = AnyDict() +anydict(x::AnyDict) = x +anydict(x) = AnyDict(x) + +""" + $(TYPEDSIGNATURES) + +Check if `x` is a symbolic with known size. Assumes `Symbolics.shape(unwrap(x))` +is a valid operation. +""" +is_sized_array_symbolic(x) = Symbolics.shape(unwrap(x)) != Symbolics.Unknown() + +""" + $(TYPEDSIGNATURES) + +Check if the system is in split form (has an `IndexCache`). +""" +is_split(sys::AbstractSystem) = has_index_cache(sys) && get_index_cache(sys) !== nothing + +""" + $(TYPEDSIGNATURES) + +Given a variable-value mapping, add mappings for the `toterm` of each of the keys. +""" +function add_toterms!(varmap::AbstractDict; toterm = default_toterm) + for k in collect(keys(varmap)) + varmap[toterm(k)] = varmap[k] + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Out-of-place version of [`add_toterms!`](@ref). +""" +function add_toterms(varmap::AbstractDict; toterm = default_toterm) + cp = copy(varmap) + add_toterms!(cp; toterm) + return cp +end + +""" + $(TYPEDSIGNATURES) + +Turn any `Symbol` keys in `varmap` to the appropriate symbolic variables in `sys`. Any +symbols that cannot be converted are ignored. +""" +function symbols_to_symbolics!(sys::AbstractSystem, varmap::AbstractDict) + if is_split(sys) + ic = get_index_cache(sys) + for k in collect(keys(varmap)) + k isa Symbol || continue + newk = get(ic.symbol_to_variable, k, nothing) + newk === nothing && continue + varmap[newk] = varmap[k] + delete!(varmap, k) + end + else + syms = all_symbols(sys) + for k in collect(keys(varmap)) + k isa Symbol || continue + idx = findfirst(syms) do sym + hasname(sym) || return false + name = getname(sym) + return name == k + end + idx === nothing && continue + newk = syms[idx] + if iscall(newk) && operation(newk) === getindex + newk = arguments(newk)[1] + end + varmap[newk] = varmap[k] + delete!(varmap, k) + end + end +end + +""" + $(TYPEDSIGNATURES) + +Utility function to get the value `val` corresponding to key `var` in `varmap`, and +return `getindex(val, idx)` if it exists or `nothing` otherwise. +""" +function get_and_getindex(varmap, var, idx) + val = get(varmap, var, nothing) + val === nothing && return nothing + return val[idx] +end + +""" + $(TYPEDSIGNATURES) + +Ensure `varmap` contains entries for all variables in `vars` by using values from +`fallbacks` if they don't already exist in `varmap`. Return the set of all variables in +`vars` not present in `varmap` or `fallbacks`. If an array variable in `vars` does not +exist in `varmap` or `fallbacks`, each of its scalarized elements will be searched for. +In case none of the scalarized elements exist, the array variable will be reported as +missing. In case some of the scalarized elements exist, the missing elements will be +reported as missing. If `fallbacks` contains both the scalarized and non-scalarized forms, +the latter will take priority. + +Variables as they are specified in `vars` will take priority over their `toterm` forms. +""" +function add_fallbacks!( + varmap::AnyDict, vars::Vector, fallbacks::Dict; toterm = default_toterm) + missingvars = Set() + arrvars = Set() + for var in vars + haskey(varmap, var) && continue + ttvar = toterm(var) + haskey(varmap, ttvar) && continue + + # array symbolics with a defined size may be present in the scalarized form + if Symbolics.isarraysymbolic(var) && is_sized_array_symbolic(var) + val = map(eachindex(var)) do idx + # @something is lazy and saves from writing a massive if-elseif-else + @something(get(varmap, var[idx], nothing), + get(varmap, ttvar[idx], nothing), get_and_getindex(fallbacks, var, idx), + get_and_getindex(fallbacks, ttvar, idx), get( + fallbacks, var[idx], nothing), + get(fallbacks, ttvar[idx], nothing), Some(nothing)) + end + # only push the missing entries + mask = map(x -> x === nothing, val) + if all(mask) + push!(missingvars, var) + elseif any(mask) + for i in eachindex(var) + if mask[i] + push!(missingvars, var) + else + varmap[var[i]] = val[i] + end + end + else + varmap[var] = val + end + else + if iscall(var) && operation(var) == getindex + args = arguments(var) + arrvar = args[1] + ttarrvar = toterm(arrvar) + idxs = args[2:end] + val = @something get(varmap, arrvar, nothing) get(varmap, ttarrvar, nothing) get( + fallbacks, arrvar, nothing) get(fallbacks, ttarrvar, nothing) Some(nothing) + if val !== nothing + val = val[idxs...] + is_sized_array_symbolic(arrvar) && push!(arrvars, arrvar) + end + else + val = nothing + end + val = @something val get(fallbacks, var, nothing) get(fallbacks, ttvar, nothing) Some(nothing) + if val === nothing + push!(missingvars, var) + else + varmap[var] = val + end + end + end + + for arrvar in arrvars + varmap[arrvar] = collect(arrvar) + end + + return missingvars +end + +""" + $(TYPEDSIGNATURES) + +Return the list of variables in `varlist` not present in `varmap`. Uses the same criteria +for missing array variables and `toterm` forms as [`add_fallbacks!`](@ref). +""" +function missingvars( + varmap::AbstractDict, varlist::Vector; toterm = default_toterm) + missingvars = Set() + for var in varlist + haskey(varmap, var) && continue + ttsym = toterm(var) + haskey(varmap, ttsym) && continue + + if Symbolics.isarraysymbolic(var) && is_sized_array_symbolic(var) + mask = map(eachindex(var)) do idx + !haskey(varmap, var[idx]) && !haskey(varmap, ttsym[idx]) + end + if all(mask) + push!(missingvars, var) + else + for i in eachindex(var) + mask[i] && push!(missingvars, var[i]) + end + end + else + push!(missingvars, var) + end + end + return missingvars +end + +""" + $(TYPEDSIGNATURES) + +Attempt to interpret `vals` as a symbolic map of variables in `varlist` to values. Return +the result as a `Dict{Any, Any}`. In case `vals` is already an iterable of pairs, convert +it to a `Dict{Any, Any}` and return. If `vals` is an array (whose `eltype` is not `Pair`) +with the same length as `varlist`, assume the `i`th element of `varlist` is mapped to the +`i`th element of `vals`. Automatically `unwrap`s all keys and values in the mapping. Also +handles `SciMLBase.NullParameters` and `nothing`, both of which are interpreted as empty +maps. +""" +function to_varmap(vals, varlist::Vector) + if vals isa AbstractArray && !(eltype(vals) <: Pair) && !isempty(vals) + check_eqs_u0(varlist, varlist, vals) + vals = vec(varlist) .=> vec(vals) + end + return recursive_unwrap(anydict(vals)) +end + +""" + $(TYPEDSIGNATURES) + +Recursively call `Symbolics.unwrap` on `x`. Useful when `x` is an array of (potentially) +symbolic values, all of which need to be unwrapped. Specializes when `x isa AbstractDict` +to unwrap keys and values, returning an `AnyDict`. +""" +function recursive_unwrap(x::AbstractArray) + symbolic_type(x) == ArraySymbolic() ? unwrap(x) : recursive_unwrap.(x) +end + +recursive_unwrap(x) = unwrap(x) + +function recursive_unwrap(x::AbstractDict) + return anydict(unwrap(k) => recursive_unwrap(v) for (k, v) in x) +end + +""" + $(TYPEDSIGNATURES) + +Add equations `eqs` to `varmap`. Assumes each element in `eqs` maps a single symbolic +variable to an expression representing its value. In case `varmap` already contains an +entry for `eq.lhs`, insert the reverse mapping if `eq.rhs` is not a number. +""" +function add_observed_equations!(varmap::AbstractDict, eqs) + for eq in eqs + if var_in_varlist(eq.lhs, keys(varmap), nothing) + eq.rhs isa Number && continue + var_in_varlist(eq.rhs, keys(varmap), nothing) && continue + !iscall(eq.rhs) || issym(operation(eq.rhs)) || continue + varmap[eq.rhs] = eq.lhs + else + varmap[eq.lhs] = eq.rhs + end + end +end + +""" + $(TYPEDSIGNATURES) + +Add all equations in `observed(sys)` to `varmap` using [`add_observed_equations!`](@ref). +""" +function add_observed!(sys::AbstractSystem, varmap::AbstractDict) + add_observed_equations!(varmap, observed(sys)) +end + +""" + $(TYPEDSIGNATURES) + +Add all equations in `parameter_dependencies(sys)` to `varmap` using +[`add_observed_equations!`](@ref). +""" +function add_parameter_dependencies!(sys::AbstractSystem, varmap::AbstractDict) + has_parameter_dependencies(sys) || return nothing + add_observed_equations!(varmap, parameter_dependencies(sys)) +end + +struct UnexpectedSymbolicValueInVarmap <: Exception + sym::Any + val::Any +end + +function Base.showerror(io::IO, err::UnexpectedSymbolicValueInVarmap) + println(io, + """ + Found symbolic value $(err.val) for variable $(err.sym). You may be missing an \ + initial condition or have cyclic initial conditions. If this is intended, pass \ + `symbolic_u0 = true`. In case the initial conditions are not cyclic but \ + require more substitutions to resolve, increase `substitution_limit`. To report \ + cycles in initial conditions of unknowns/parameters, pass \ + `warn_cyclic_dependency = true`. If the cycles are still not reported, you \ + may need to pass a larger value for `circular_dependency_max_cycle_length` \ + or `circular_dependency_max_cycles`. + """) +end + +struct MissingGuessError <: Exception + syms::Vector{Any} + vals::Vector{Any} +end + +function Base.showerror(io::IO, err::MissingGuessError) + println(io, + """ + Cyclic guesses detected in the system. Symbolic values were found for the following variables/parameters in the map: \ + """) + for (sym, val) in zip(err.syms, err.vals) + println(io, "$sym => $val") + end + println(io, + """ + In order to resolve this, please provide additional numeric guesses so that the chain can be resolved to assign numeric values to each variable. """) +end + +""" + $(TYPEDSIGNATURES) + +Return an array of values where the `i`th element corresponds to the value of `vars[i]` +in `varmap`. Does not perform symbolic substitution in the values of `varmap`. + +Keyword arguments: +- `tofloat`: Convert values to floating point numbers using `float`. +- `container_type`: The type of container to use for the values. +- `toterm`: The `toterm` method to use for converting symbolics. +- `promotetoconcrete`: whether the promote to a concrete buffer (respecting + `tofloat`). Defaults to `container_type <: AbstractArray`. +- `check`: Error if any variables in `vars` do not have a mapping in `varmap`. Uses + [`missingvars`](@ref) to perform the check. +- `allow_symbolic` allows the returned array to contain symbolic values. If this is `true`, + `promotetoconcrete` is set to `false`. +- `is_initializeprob, guesses`: Used to determine whether the system is missing guesses. +""" +function better_varmap_to_vars(varmap::AbstractDict, vars::Vector; + tofloat = true, container_type = Array, floatT = Nothing, + toterm = default_toterm, promotetoconcrete = nothing, check = true, + allow_symbolic = false, is_initializeprob = false) + isempty(vars) && return nothing + + if check + missing_vars = missingvars(varmap, vars; toterm) + isempty(missing_vars) || throw(MissingVariablesError(missing_vars)) + end + vals = map(x -> varmap[x], vars) + if !allow_symbolic + missingsyms = Any[] + missingvals = Any[] + for (sym, val) in zip(vars, vals) + symbolic_type(val) == NotSymbolic() && continue + push!(missingsyms, sym) + push!(missingvals, val) + end + + if !isempty(missingsyms) + is_initializeprob ? throw(MissingGuessError(missingsyms, missingvals)) : + throw(UnexpectedSymbolicValueInVarmap(missingsyms[1], missingvals[1])) + end + if tofloat && !(floatT == Nothing) + vals = floatT.(vals) + end + end + + if container_type <: Union{AbstractDict, Tuple, Nothing, SciMLBase.NullParameters} + container_type = Array + end + + promotetoconcrete === nothing && (promotetoconcrete = container_type <: AbstractArray) + if promotetoconcrete && !allow_symbolic + vals = promote_to_concrete(vals; tofloat = tofloat, use_union = false) + end + + if isempty(vals) + return nothing + elseif container_type <: Tuple + return (vals...,) + else + return SymbolicUtils.Code.create_array(container_type, eltype(vals), Val{1}(), + Val(length(vals)), vals...) + end +end + +""" + $(TYPEDSIGNATURES) + +Check if any of the substitution rules in `varmap` lead to cycles involving +variables in `vars`. Return a vector of vectors containing all the variables +in each cycle. + +Keyword arguments: +- `max_cycle_length`: The maximum length (number of variables) of detected cycles. +- `max_cycles`: The maximum number of cycles to report. +""" +function check_substitution_cycles( + varmap::AbstractDict, vars; max_cycle_length = length(varmap), max_cycles = 10) + # ordered set so that `vars` are the first `k` in the list + allvars = OrderedSet{Any}(vars) + union!(allvars, keys(varmap)) + allvars = collect(allvars) + var_to_idx = Dict(allvars .=> eachindex(allvars)) + graph = SimpleDiGraph(length(allvars)) + + buffer = Set() + for (k, v) in varmap + kidx = var_to_idx[k] + if symbolic_type(v) != NotSymbolic() + vars!(buffer, v) + for var in buffer + haskey(var_to_idx, var) || continue + add_edge!(graph, kidx, var_to_idx[var]) + end + elseif v isa AbstractArray + for val in v + vars!(buffer, val) + end + for var in buffer + haskey(var_to_idx, var) || continue + add_edge!(graph, kidx, var_to_idx[var]) + end + end + empty!(buffer) + end + + # detect at most 100 cycles involving at most `length(varmap)` vertices + cycles = Graphs.simplecycles_limited_length(graph, max_cycle_length, max_cycles) + # only count those which contain variables in `vars` + filter!(Base.Fix1(any, <=(length(vars))), cycles) + + map(cycles) do cycle + map(Base.Fix1(getindex, allvars), cycle) + end +end + +""" + $(TYPEDSIGNATURES) + +Performs symbolic substitution on the values in `varmap` for the keys in `vars`, using +`varmap` itself as the set of substitution rules. If an entry in `vars` is not a key +in `varmap`, it is ignored. +""" +function evaluate_varmap!(varmap::AbstractDict, vars; limit = 100) + for k in vars + haskey(varmap, k) || continue + varmap[k] = fixpoint_sub(varmap[k], varmap; maxiters = limit) + end +end + +""" + $(TYPEDSIGNATURES) + +Remove keys in `varmap` whose values are `nothing`. + +If `missing_values` is not `nothing`, it is assumed to be a collection and all removed +keys will be added to it. +""" +function filter_missing_values!(varmap::AbstractDict; missing_values = nothing) + filter!(varmap) do kvp + if kvp[2] !== nothing + return true + end + if missing_values !== nothing + push!(missing_values, kvp[1]) + end + return false + end +end + +""" + $(TYPEDSIGNATURES) + +For each `k => v` in `varmap` where `k` is an array (or array symbolic) add +`k[i] => v[i]` for all `i in eachindex(k)`. Return the modified `varmap`. +""" +function scalarize_varmap!(varmap::AbstractDict) + for k in collect(keys(varmap)) + symbolic_type(k) == ArraySymbolic() || continue + for i in eachindex(k) + varmap[k[i]] = varmap[k][i] + end + end + return varmap +end + +struct GetUpdatedMTKParameters{G, S} + # `getu` functor which gets parameters that are unknowns during initialization + getpunknowns::G + # `setu` functor which returns a modified MTKParameters using those parameters + setpunknowns::S +end + +function (f::GetUpdatedMTKParameters)(prob, initializesol) + p = parameter_values(prob) + p === nothing && return nothing + mtkp = copy(p) + f.setpunknowns(mtkp, f.getpunknowns(initializesol)) + mtkp +end + +struct UpdateInitializeprob{G, S} + # `getu` functor which gets all values from prob + getvals::G + # `setu` functor which updates initializeprob with values + setvals::S +end + +function (f::UpdateInitializeprob)(initializeprob, prob) + f.setvals(initializeprob, f.getvals(prob)) +end + +function get_temporary_value(p, floatT = Float64) + stype = symtype(unwrap(p)) + return if stype == Real + zero(floatT) + elseif stype <: AbstractArray{Real} + zeros(floatT, size(p)) + elseif stype <: Real + zero(stype) + elseif stype <: AbstractArray + zeros(eltype(stype), size(p)) + else + error("Nonnumeric parameter $p with symtype $stype cannot be solved for during initialization") + end +end + +""" + $(TYPEDEF) + +A simple utility meant to be used as the `constructor` passed to `process_SciMLProblem` in +case constructing a SciMLFunction is not required. The arguments passed to it are available +in the `args` field, and the keyword arguments in the `kwargs` field. +""" +struct EmptySciMLFunction{A, K} + args::A + kwargs::K +end + +function EmptySciMLFunction(args...; kwargs...) + return EmptySciMLFunction{typeof(args), typeof(kwargs)}(args, kwargs) +end + +""" + $(TYPEDSIGNATURES) + +Construct the operating point of the system from the user-provided `u0map` and `pmap`, system +defaults `defs`, constant equations `cmap` (from `get_cmap(sys)`), unknowns `dvs` and +parameters `ps`. Return the operating point as a dictionary, the list of unknowns for which +no values can be determined, and the list of parameters for which no values can be determined. + +Also updates `u0map` and `pmap` in-place to contain all the initial conditions in `op`, split +by unknowns and parameters respectively. +""" +function build_operating_point!(sys::AbstractSystem, + u0map::AbstractDict, pmap::AbstractDict, defs::AbstractDict, cmap, dvs, ps) + op = add_toterms(u0map) + missing_unknowns = add_fallbacks!(op, dvs, defs) + for (k, v) in defs + haskey(op, k) && continue + op[k] = v + end + filter_missing_values!(op; missing_values = missing_unknowns) + + merge!(op, pmap) + missing_pars = add_fallbacks!(op, ps, defs) + filter_missing_values!(op; missing_values = missing_pars) + for eq in cmap + op[eq.lhs] = eq.rhs + end + + filter!(kvp -> kvp[2] === nothing, u0map) + filter!(kvp -> kvp[2] === nothing, pmap) + neithermap = anydict() + + for (k, v) in op + k = unwrap(k) + if is_parameter(sys, k) + pmap[k] = v + elseif has_parameter_dependency_with_lhs(sys, k) && is_variable_floatingpoint(k) && + v !== nothing && !isequal(v, Initial(k)) + op[Initial(k)] = v + pmap[Initial(k)] = v + op[k] = Initial(k) + pmap[k] = Initial(k) + elseif is_variable(sys, k) || has_observed_with_lhs(sys, k) || + iscall(k) && + operation(k) isa Differential && is_variable(sys, arguments(k)[1]) + if symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v) && + v !== nothing + op[Initial(k)] = v + pmap[Initial(k)] = v + op[k] = Initial(k) + v = Initial(k) + end + u0map[k] = v + else + neithermap[k] = v + end + end + + for k in keys(u0map) + v = fixpoint_sub(u0map[k], neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + u0map[k] = v + end + for k in keys(pmap) + v = fixpoint_sub(pmap[k], neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + pmap[k] = v + end + + return op, missing_unknowns, missing_pars +end + +""" + $(TYPEDSIGNATURES) + +Build and return the initialization problem and associated data as a `NamedTuple` to be passed +to the `SciMLFunction` constructor. Requires the system `sys`, operating point `op`, +user-provided `u0map` and `pmap`, initial time `t`, system defaults `defs`, user-provided +`guesses`, and list of unknowns which don't have a value in `op`. The keyword `implicit_dae` +denotes whether the `SciMLProblem` being constructed is in implicit DAE form (`DAEProblem`). +All other keyword arguments are forwarded to `InitializationProblem`. +""" +function maybe_build_initialization_problem( + sys::AbstractSystem, op::AbstractDict, u0map, pmap, t, defs, + guesses, missing_unknowns; implicit_dae = false, + u0_constructor = identity, floatT = Float64, kwargs...) + guesses = merge(ModelingToolkit.guesses(sys), todict(guesses)) + + if t === nothing && is_time_dependent(sys) + t = zero(floatT) + end + + initializeprob = ModelingToolkit.InitializationProblem{true, SciMLBase.FullSpecialize}( + sys, t, u0map, pmap; guesses, kwargs...) + if state_values(initializeprob) !== nothing + initializeprob = remake(initializeprob; u0 = floatT.(state_values(initializeprob))) + end + initp = parameter_values(initializeprob) + if is_split(sys) + buffer, repack, _ = SciMLStructures.canonicalize(SciMLStructures.Tunable(), initp) + initp = repack(floatT.(buffer)) + buffer, repack, _ = SciMLStructures.canonicalize(SciMLStructures.Initials(), initp) + initp = repack(floatT.(buffer)) + elseif initp isa AbstractArray + initp′ = similar(initp, floatT) + copyto!(initp′, initp) + initp = initp′ + end + initializeprob = remake(initializeprob; p = initp) + + meta = get_metadata(initializeprob.f.sys) + + if is_time_dependent(sys) + all_init_syms = Set(all_symbols(initializeprob)) + solved_unknowns = filter(var -> var in all_init_syms, unknowns(sys)) + initializeprobmap = u0_constructor ∘ getu(initializeprob, solved_unknowns) + else + initializeprobmap = nothing + end + + punknowns = [p + for p in all_variable_symbols(initializeprob) + if is_parameter(sys, p)] + if isempty(punknowns) + initializeprobpmap = nothing + else + getpunknowns = getu(initializeprob, punknowns) + setpunknowns = setp(sys, punknowns) + initializeprobpmap = GetUpdatedMTKParameters(getpunknowns, setpunknowns) + end + + reqd_syms = parameter_symbols(initializeprob) + # we still want the `initialization_data` because it helps with `remake` + if initializeprobmap === nothing && initializeprobpmap === nothing + update_initializeprob! = nothing + else + update_initializeprob! = UpdateInitializeprob( + getu(sys, reqd_syms), setu(initializeprob, reqd_syms)) + end + + for p in punknowns + is_parameter_solvable(p, pmap, defs, guesses) || continue + get(op, p, missing) === missing || continue + p = unwrap(p) + stype = symtype(p) + op[p] = get_temporary_value(p, floatT) + if iscall(p) && operation(p) === getindex + arrp = arguments(p)[1] + op[arrp] = collect(arrp) + end + end + + if is_time_dependent(sys) + for v in missing_unknowns + op[v] = get_temporary_value(v, floatT) + end + empty!(missing_unknowns) + end + + return (; + initialization_data = SciMLBase.OverrideInitData( + initializeprob, update_initializeprob!, initializeprobmap, + initializeprobpmap)) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the floating point type to use from the given `varmap` by looking at variables +with a constant value. +""" +function float_type_from_varmap(varmap, floatT = Bool) + for (k, v) in varmap + symbolic_type(v) == NotSymbolic() || continue + is_array_of_symbolics(v) && continue + + if v isa AbstractArray + floatT = promote_type(floatT, eltype(v)) + elseif v isa Real + floatT = promote_type(floatT, typeof(v)) + end + end + return float(floatT) +end + +""" + $(TYPEDSIGNATURES) + +Return the SciMLFunction created via calling `constructor`, the initial conditions `u0` +and parameter object `p` given the system `sys`, and user-provided initial values `u0map` +and `pmap`. `u0map` and `pmap` are converted into variable maps via [`to_varmap`](@ref). + +The order of unknowns is determined by `unknowns(sys)`. If the system is split +[`is_split`](@ref) create an [`MTKParameters`](@ref) object. Otherwise, a parameter vector. +Initial values provided in terms of other variables will be symbolically evaluated using +[`evaluate_varmap!`](@ref). The type of `u0map` and `pmap` will be used to determine the +type of the containers (if parameters are not in an `MTKParameters` object). `Dict`s will be +turned into `Array`s. + +If `sys isa ODESystem`, this will also build the initialization problem and related objects +and pass them to the SciMLFunction as keyword arguments. + +Keyword arguments: +- `build_initializeprob`: If `false`, avoids building the initialization problem for an + `ODESystem`. +- `t`: The initial time of the `ODEProblem`. If this is not provided, the initialization + problem cannot be built. +- `implicit_dae`: Also build a mapping of derivatives of states to values for implicit DAEs, + using `du0map`. Changes the return value of this function to `(f, du0, u0, p)` instead of + `(f, u0, p)`. +- `guesses`: The guesses for variables in the system, used as initial values for the + initialization problem. +- `warn_initialize_determined`: Warn if the initialization system is under/over-determined. +- `initialization_eqs`: Extra equations to use in the initialization problem. +- `eval_expression`: Whether to compile any functions via `eval` or `RuntimeGeneratedFunctions`. +- `eval_module`: If `eval_expression == true`, the module to `eval` into. Otherwise, the module + in which to generate the `RuntimeGeneratedFunction`. +- `fully_determined`: Override whether the initialization system is fully determined. +- `check_initialization_units`: Enable or disable unit checks when constructing the + initialization problem. +- `tofloat`, `is_initializeprob`: Passed to [`better_varmap_to_vars`](@ref) for building `u0` (and possibly `p`). +- `u0_constructor`: A function to apply to the `u0` value returned from `better_varmap_to_vars` + to construct the final `u0` value. +- `du0map`: A map of derivatives to values. See `implicit_dae`. +- `check_length`: Whether to check the number of equations along with number of unknowns and + length of `u0` vector for consistency. If `false`, do not check with equations. This is + forwarded to `check_eqs_u0` +- `symbolic_u0` allows the returned `u0` to be an array of symbolics. +- `warn_cyclic_dependency`: Whether to emit a warning listing out cycles in initial + conditions provided for unknowns and parameters. +- `circular_dependency_max_cycle_length`: Maximum length of cycle to check for. + Only applicable if `warn_cyclic_dependency == true`. +- `circular_dependency_max_cycles`: Maximum number of cycles to check for. + Only applicable if `warn_cyclic_dependency == true`. +- `substitution_limit`: The number times to substitute initial conditions into each + other to attempt to arrive at a numeric value. +- `use_scc`: Whether to use `SCCNonlinearProblem` for initialization if the system is fully + determined. +- `force_initialization_time_independent`: Whether to force the initialization to not use + the independent variable of `sys`. +- `algebraic_only`: Whether to build the initialization problem using only algebraic equations. +- `allow_incomplete`: Whether to allow incomplete initialization problems. + +All other keyword arguments are passed as-is to `constructor`. +""" +function process_SciMLProblem( + constructor, sys::AbstractSystem, u0map, pmap; build_initializeprob = true, + implicit_dae = false, t = nothing, guesses = AnyDict(), + warn_initialize_determined = true, initialization_eqs = [], + eval_expression = false, eval_module = @__MODULE__, fully_determined = nothing, + check_initialization_units = false, tofloat = true, + u0_constructor = identity, du0map = nothing, check_length = true, + symbolic_u0 = false, warn_cyclic_dependency = false, + circular_dependency_max_cycle_length = length(all_symbols(sys)), + circular_dependency_max_cycles = 10, + substitution_limit = 100, use_scc = true, + force_initialization_time_independent = false, algebraic_only = false, + allow_incomplete = false, is_initializeprob = false, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + iv = has_iv(sys) ? get_iv(sys) : nothing + eqs = equations(sys) + + check_array_equations_unknowns(eqs, dvs) + + u0Type = typeof(u0map) + pType = typeof(pmap) + + u0map = to_varmap(u0map, dvs) + symbols_to_symbolics!(sys, u0map) + pmap = to_varmap(pmap, parameters(sys)) + symbols_to_symbolics!(sys, pmap) + + check_inputmap_keys(sys, u0map, pmap) + + defs = add_toterms(recursive_unwrap(defaults(sys))) + cmap, cs = get_cmap(sys) + kwargs = NamedTuple(kwargs) + + if eltype(eqs) <: Equation + obs, eqs = unhack_observed(observed(sys), eqs) + else + obs, _ = unhack_observed(observed(sys), Equation[x for x in eqs if x isa Equation]) + end + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0map, pmap, defs, cmap, dvs, ps) + + floatT = Bool + if u0Type <: AbstractArray && eltype(u0Type) <: Real + floatT = float(eltype(u0Type)) + else + floatT = float_type_from_varmap(op, floatT) + end + + if !is_time_dependent(sys) || is_initializesystem(sys) + add_observed_equations!(u0map, obs) + end + if u0_constructor === identity && u0Type <: StaticArray + u0_constructor = vals -> SymbolicUtils.Code.create_array( + u0Type, floatT, Val(1), Val(length(vals)), vals...) + end + if build_initializeprob + kws = maybe_build_initialization_problem( + sys, op, u0map, pmap, t, defs, guesses, missing_unknowns; + implicit_dae, warn_initialize_determined, initialization_eqs, + eval_expression, eval_module, fully_determined, + warn_cyclic_dependency, check_units = check_initialization_units, + circular_dependency_max_cycle_length, circular_dependency_max_cycles, use_scc, + force_time_independent = force_initialization_time_independent, algebraic_only, allow_incomplete, + u0_constructor, floatT) + + kwargs = merge(kwargs, kws) + end + + if t !== nothing && !(constructor <: Union{DDEFunction, SDDEFunction}) + op[iv] = t + end + + add_observed_equations!(op, obs) + add_parameter_dependencies!(sys, op) + + if warn_cyclic_dependency + cycles = check_substitution_cycles( + op, dvs; max_cycle_length = circular_dependency_max_cycle_length, + max_cycles = circular_dependency_max_cycles) + if !isempty(cycles) + buffer = IOBuffer() + for cycle in cycles + println(buffer, cycle) + end + msg = String(take!(buffer)) + @warn "Cycles in unknowns:\n$msg" + end + end + evaluate_varmap!(op, dvs; limit = substitution_limit) + + u0 = better_varmap_to_vars( + op, dvs; tofloat, floatT, + container_type = u0Type, allow_symbolic = symbolic_u0, is_initializeprob) + + if u0 !== nothing + u0 = u0_constructor(u0) + end + + check_eqs_u0(eqs, dvs, u0; check_length, kwargs...) + + if warn_cyclic_dependency + cycles = check_substitution_cycles( + op, ps; max_cycle_length = circular_dependency_max_cycle_length, + max_cycles = circular_dependency_max_cycles) + if !isempty(cycles) + buffer = IOBuffer() + for cycle in cycles + println(buffer, cycle) + end + msg = String(take!(buffer)) + @warn "Cycles in parameters:\n$msg" + end + end + evaluate_varmap!(op, ps; limit = substitution_limit) + if is_split(sys) + p = MTKParameters(sys, op; floatT = floatT) + else + p = better_varmap_to_vars(op, ps; tofloat, container_type = pType) + end + + if implicit_dae && du0map !== nothing + ddvs = map(Differential(iv), dvs) + du0map = to_varmap(du0map, ddvs) + merge!(op, du0map) + du0 = varmap_to_vars(op, ddvs; toterm = identity, + tofloat) + kwargs = merge(kwargs, (; ddvs)) + else + du0 = nothing + end + + if build_initializeprob + t0 = t + if is_time_dependent(sys) && t0 === nothing + t0 = zero(floatT) + end + initialization_data = SciMLBase.remake_initialization_data( + kwargs.initialization_data, kwargs, u0, t0, p, u0, p) + kwargs = merge(kwargs,) + end + + f = constructor(sys, dvs, ps, u0; p = p, + eval_expression = eval_expression, + eval_module = eval_module, + kwargs...) + implicit_dae ? (f, du0, u0, p) : (f, u0, p) +end + +# Check that the keys of a u0map or pmap are valid +# (i.e. are symbolic keys, and are defined for the system.) +function check_inputmap_keys(sys, u0map, pmap) + badvarkeys = Any[] + for k in keys(u0map) + if symbolic_type(k) === NotSymbolic() + push!(badvarkeys, k) + end + end + + badparamkeys = Any[] + for k in keys(pmap) + if symbolic_type(k) === NotSymbolic() + push!(badparamkeys, k) + end + end + (isempty(badvarkeys) && isempty(badparamkeys)) || + throw(InvalidKeyError(collect(badvarkeys), collect(badparamkeys))) +end + +const BAD_KEY_MESSAGE = """ + Undefined keys found in the parameter or initial condition maps. Check if symbolic variable names have been reassigned. + The following keys are invalid: + """ + +struct InvalidKeyError <: Exception + vars::Any + params::Any +end + +function Base.showerror(io::IO, e::InvalidKeyError) + println(io, BAD_KEY_MESSAGE) + println(io, "u0map: $(join(e.vars, ", "))") + println(io, "pmap: $(join(e.params, ", "))") +end + +function SciMLBase.detect_cycles(sys::AbstractSystem, varmap::Dict{Any, Any}, vars) + varmap = AnyDict(unwrap(k) => unwrap(v) for (k, v) in varmap) + vars = map(unwrap, vars) + cycles = check_substitution_cycles(varmap, vars) + return !isempty(cycles) +end + +############## +# Legacy functions for backward compatibility +############## + +""" + u0, p, defs = get_u0_p(sys, u0map, parammap; use_union=true, tofloat=true) + +Take dictionaries with initial conditions and parameters and convert them to numeric arrays `u0` and `p`. Also return the merged dictionary `defs` containing the entire operating point. +""" +function get_u0_p(sys, + u0map, + parammap = nothing; + t0 = nothing, + tofloat = true, + use_union = true, + symbolic_u0 = false) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + + defs = defaults(sys) + if t0 !== nothing + defs[get_iv(sys)] = t0 + end + if parammap !== nothing + defs = mergedefaults(defs, parammap, ps) + end + if u0map isa Vector && eltype(u0map) <: Pair + u0map = Dict(u0map) + end + if u0map isa Dict + allobs = Set(getproperty.(observed(sys), :lhs)) + if any(in(allobs), keys(u0map)) + u0s_in_obs = filter(in(allobs), keys(u0map)) + @warn "Observed variables cannot be assigned initial values. Initial values for $u0s_in_obs will be ignored." + end + end + obs = filter!(x -> !(x[1] isa Number), map(x -> x.rhs => x.lhs, observed(sys))) + observedmap = isempty(obs) ? Dict() : todict(obs) + defs = mergedefaults(defs, observedmap, u0map, dvs) + for (k, v) in defs + if Symbolics.isarraysymbolic(k) + ks = scalarize(k) + length(ks) == length(v) || error("$k has default value $v with unmatched size") + for (kk, vv) in zip(ks, v) + if !haskey(defs, kk) + defs[kk] = vv + end + end + end + end + + if symbolic_u0 + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false, use_union = false) + else + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat, use_union) + end + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat, use_union) + p = p === nothing ? SciMLBase.NullParameters() : p + t0 !== nothing && delete!(defs, get_iv(sys)) + u0, p, defs +end + +function get_u0( + sys, u0map, parammap = nothing; symbolic_u0 = false, + toterm = default_toterm, t0 = nothing, use_union = true) + dvs = unknowns(sys) + ps = parameters(sys) + defs = defaults(sys) + if t0 !== nothing + defs[get_iv(sys)] = t0 + end + if parammap !== nothing + defs = mergedefaults(defs, parammap, ps) + end + + # Convert observed equations "lhs ~ rhs" into defaults. + # Use the order "lhs => rhs" by default, but flip it to "rhs => lhs" + # if "lhs" is known by other means (parameter, another default, ...) + # TODO: Is there a better way to determine which equations to flip? + obs = map(x -> x.lhs => x.rhs, observed(sys)) + obs = map(x -> x[1] in keys(defs) ? reverse(x) : x, obs) + obs = filter!(x -> !(x[1] isa Number), obs) # exclude e.g. "0 => x^2 + y^2 - 25" + obsmap = isempty(obs) ? Dict() : todict(obs) + + defs = mergedefaults(defs, obsmap, u0map, dvs) + if symbolic_u0 + u0 = varmap_to_vars( + u0map, dvs; defaults = defs, tofloat = false, use_union = false, toterm) + else + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = true, use_union, toterm) + end + t0 !== nothing && delete!(defs, get_iv(sys)) + return u0, defs +end diff --git a/src/systems/reaction/reactionsystem.jl b/src/systems/reaction/reactionsystem.jl deleted file mode 100644 index 2df2217dad..0000000000 --- a/src/systems/reaction/reactionsystem.jl +++ /dev/null @@ -1,555 +0,0 @@ - -""" -$(TYPEDEF) - -One chemical reaction. - -# Fields -$(FIELDS) - -# Examples - -```julia -using ModelingToolkit -@parameters t k[1:20] -@variables A(t) B(t) C(t) D(t) -rxs = [Reaction(k[1], nothing, [A]), # 0 -> A - Reaction(k[2], [B], nothing), # B -> 0 - Reaction(k[3],[A],[C]), # A -> C - Reaction(k[4], [C], [A,B]), # C -> A + B - Reaction(k[5], [C], [A], [1], [2]), # C -> A + A - Reaction(k[6], [A,B], [C]), # A + B -> C - Reaction(k[7], [B], [A], [2], [1]), # 2B -> A - Reaction(k[8], [A,B], [A,C]), # A + B -> A + C - Reaction(k[9], [A,B], [C,D]), # A + B -> C + D - Reaction(k[10], [A], [C,D], [2], [1,1]), # 2A -> C + D - Reaction(k[11], [A], [A,B], [2], [1,1]), # 2A -> A + B - Reaction(k[12], [A,B,C], [C,D], [1,3,4], [2, 3]), # A+3B+4C -> 2C + 3D - Reaction(k[13], [A,B], nothing, [3,1], nothing), # 3A+B -> 0 - Reaction(k[14], nothing, [A], nothing, [2]), # 0 -> 2A - Reaction(k[15]*A/(2+A), [A], nothing; only_use_rate=true), # A -> 0 with custom rate - Reaction(k[16], [A], [B]; only_use_rate=true), # A -> B with custom rate. - Reaction(k[17]*A*exp(B), [C], [D], [2], [1]), # 2C -> D with non constant rate. - Reaction(k[18]*B, nothing, [B], nothing, [2]), # 0 -> 2B with non constant rate. - Reaction(k[19]*t, [A], [B]), # A -> B with non constant rate. - Reaction(k[20]*t*A, [B,C], [D],[2,1],[2]) # 2A +B -> 2C with non constant rate. - ] -``` - -Notes: -- `nothing` can be used to indicate a reaction that has no reactants or no products. - In this case the corresponding stoichiometry vector should also be set to `nothing`. -- The three-argument form assumes all reactant and product stoichiometric coefficients - are one. -""" -struct Reaction{S, T <: Number} - """The rate function (excluding mass action terms).""" - rate - """Reaction substrates.""" - substrates::Vector - """Reaction products.""" - products::Vector - """The stoichiometric coefficients of the reactants.""" - substoich::Vector{T} - """The stoichiometric coefficients of the products.""" - prodstoich::Vector{T} - """The net stoichiometric coefficients of all species changed by the reaction.""" - netstoich::Vector{Pair{S,T}} - """ - `false` (default) if `rate` should be multiplied by mass action terms to give the rate law. - `true` if `rate` represents the full reaction rate law. - """ - only_use_rate::Bool - """ - type: type of the system - """ - connection_type::Any -end - -function Reaction(rate, subs, prods, substoich, prodstoich; - netstoich=nothing, only_use_rate=false, - connection_type=nothing, - kwargs...) - - (isnothing(prods)&&isnothing(subs)) && error("A reaction requires a non-nothing substrate or product vector.") - (isnothing(prodstoich)&&isnothing(substoich)) && error("Both substrate and product stochiometry inputs cannot be nothing.") - if isnothing(subs) - subs = Vector{Term}() - !isnothing(substoich) && error("If substrates are nothing, substrate stiocihometries have to be so too.") - substoich = typeof(prodstoich)() - end - if isnothing(prods) - prods = Vector{Term}() - !isnothing(prodstoich) && error("If products are nothing, product stiocihometries have to be so too.") - prodstoich = typeof(substoich)() - end - subs = value.(subs) - prods = value.(prods) - ns = isnothing(netstoich) ? get_netstoich(subs, prods, substoich, prodstoich) : netstoich - Reaction(value(rate), subs, prods, substoich, prodstoich, ns, only_use_rate, connection_type) -end - - -# three argument constructor assumes stoichiometric coefs are one and integers -function Reaction(rate, subs, prods; kwargs...) - - sstoich = isnothing(subs) ? nothing : ones(Int,length(subs)) - pstoich = isnothing(prods) ? nothing : ones(Int,length(prods)) - Reaction(rate, subs, prods, sstoich, pstoich; kwargs...) -end - -function namespace_equation(rx::Reaction, name, iv) - Reaction(namespace_expr(rx.rate, name, iv), - namespace_expr(rx.substrates, name, iv), - namespace_expr(rx.products, name, iv), - rx.substoich, rx.prodstoich, - [namespace_expr(n[1],name,iv) => n[2] for n in rx.netstoich], rx.only_use_rate) -end - -# calculates the net stoichiometry of a reaction as a vector of pairs (sub,substoich) -function get_netstoich(subs, prods, sstoich, pstoich) - # stoichiometry as a Dictionary - nsdict = Dict{Any, eltype(sstoich)}(sub => -sstoich[i] for (i,sub) in enumerate(subs)) - for (i,p) in enumerate(prods) - coef = pstoich[i] - @inbounds nsdict[p] = haskey(nsdict, p) ? nsdict[p] + coef : coef - end - - # stoichiometry as a vector - ns = [el for el in nsdict if el[2] != zero(el[2])] - - ns -end - -""" -$(TYPEDEF) - -A system of chemical reactions. - -# Fields -$(FIELDS) - -# Example -Continuing from the example in the [`Reaction`](@ref) definition: -```julia -rs = ReactionSystem(rxs, t, [A,B,C,D], k) -``` -""" -struct ReactionSystem <: AbstractSystem - """The reactions defining the system.""" - eqs::Vector{Reaction} - """Independent variable (usually time).""" - iv::Any - """Dependent (state) variables representing amount of each species.""" - states::Vector - """Parameter variables.""" - ps::Vector - observed::Vector{Equation} - """The name of the system""" - name::Symbol - """systems: The internal systems""" - systems::Vector - - function ReactionSystem(eqs, iv, states, ps, observed, name, systems) - new(eqs, value(iv), value.(states), value.(ps), observed, name, systems) - end -end - -function ReactionSystem(eqs, iv, species, params; - observed = [], - systems = [], - name = gensym(:ReactionSystem)) - - #isempty(species) && error("ReactionSystems require at least one species.") - ReactionSystem(eqs, iv, species, params, observed, name, systems) -end - -function ReactionSystem(iv; kwargs...) - ReactionSystem(Reaction[], iv, [], []; kwargs...) -end - -function equations(sys::ModelingToolkit.ReactionSystem) - eqs = get_eqs(sys) - systems = get_systems(sys) - if isempty(systems) - return eqs - else - eqs = [eqs; - reduce(vcat, - namespace_equations.(get_systems(sys)); - init=[])] - return eqs - end -end - -""" - oderatelaw(rx; combinatoric_ratelaw=true) - -Given a [`Reaction`](@ref), return the reaction rate law [`Operation`](@ref) used in -generated ODEs for the reaction. Note, for a reaction defined by - -`k*X*Y, X+Z --> 2X + Y` - -the expression that is returned will be `k*X(t)^2*Y(t)*Z(t)`. For a reaction -of the form - -`k, 2X+3Y --> Z` - -the `Operation` that is returned will be `k * (X(t)^2/2) * (Y(t)^3/6)`. - -Notes: -- Allocates -- `combinatoric_ratelaw=true` uses factorial scaling factors in calculating the rate - law, i.e. for `2S -> 0` at rate `k` the ratelaw would be `k*S^2/2!`. If - `combinatoric_ratelaw=false` then the ratelaw is `k*S^2`, i.e. the scaling factor is - ignored. -""" -function oderatelaw(rx; combinatoric_ratelaw=true) - @unpack rate, substrates, substoich, only_use_rate = rx - rl = rate - if !only_use_rate - coef = one(eltype(substoich)) - for (i,stoich) in enumerate(substoich) - coef *= factorial(stoich) - rl *= isone(stoich) ? substrates[i] : substrates[i]^stoich - end - combinatoric_ratelaw && (!isone(coef)) && (rl /= coef) - end - rl -end - -function assemble_oderhs(rs; combinatoric_ratelaws=true) - sts = get_states(rs) - species_to_idx = Dict((x => i for (i,x) in enumerate(sts))) - rhsvec = Any[0 for i in eachindex(sts)] - - for rx in get_eqs(rs) - rl = oderatelaw(rx; combinatoric_ratelaw=combinatoric_ratelaws) - for (spec,stoich) in rx.netstoich - i = species_to_idx[spec] - if _iszero(rhsvec[i]) - signedrl = (stoich > zero(stoich)) ? rl : -rl - rhsvec[i] = isone(abs(stoich)) ? signedrl : stoich * rl - else - Δspec = isone(abs(stoich)) ? rl : abs(stoich) * rl - rhsvec[i] = (stoich > zero(stoich)) ? (rhsvec[i] + Δspec) : (rhsvec[i] - Δspec) - end - end - end - - rhsvec -end - -function assemble_drift(rs; combinatoric_ratelaws=true, as_odes=true) - rhsvec = assemble_oderhs(rs; combinatoric_ratelaws=combinatoric_ratelaws) - if as_odes - D = Differential(get_iv(rs)) - eqs = [Equation(D(x),rhs) for (x,rhs) in zip(get_states(rs),rhsvec) if (!_iszero(rhs))] - else - eqs = [Equation(0,rhs) for rhs in rhsvec if (!_iszero(rhs))] - end - eqs -end - -function assemble_diffusion(rs, noise_scaling; combinatoric_ratelaws=true) - sts = get_states(rs) - eqs = Matrix{Any}(undef, length(sts), length(get_eqs(rs))) - eqs .= 0 - species_to_idx = Dict((x => i for (i,x) in enumerate(sts))) - - for (j,rx) in enumerate(equations(rs)) - rlsqrt = sqrt(abs(oderatelaw(rx; combinatoric_ratelaw=combinatoric_ratelaws))) - (noise_scaling!==nothing) && (rlsqrt *= noise_scaling[j]) - for (spec,stoich) in rx.netstoich - i = species_to_idx[spec] - signedrlsqrt = (stoich > zero(stoich)) ? rlsqrt : -rlsqrt - eqs[i,j] = isone(abs(stoich)) ? signedrlsqrt : stoich * rlsqrt - end - end - eqs -end - -""" - jumpratelaw(rx; rxvars=get_variables(rx.rate), combinatoric_ratelaw=true) - -Given a [`Reaction`](@ref), return the reaction rate law [`Operation`](@ref) used in -generated stochastic chemical kinetics model SSAs for the reaction. Note, -for a reaction defined by - -`k*X*Y, X+Z --> 2X + Y` - -the expression that is returned will be `k*X^2*Y*Z`. For a reaction of -the form - -`k, 2X+3Y --> Z` - -the `Operation` that is returned will be `k * binomial(X,2) * -binomial(Y,3)`. - -Notes: -- `rxvars` should give the `Variable`s, i.e. species and parameters, the rate depends on. -- Allocates -- `combinatoric_ratelaw=true` uses binomials in calculating the rate law, i.e. for `2S -> - 0` at rate `k` the ratelaw would be `k*S*(S-1)/2`. If `combinatoric_ratelaw=false` then - the ratelaw is `k*S*(S-1)`, i.e. the rate law is not normalized by the scaling - factor. -""" -function jumpratelaw(rx; rxvars=get_variables(rx.rate), combinatoric_ratelaw=true) - @unpack rate, substrates, substoich, only_use_rate = rx - rl = rate - if !only_use_rate - coef = one(eltype(substoich)) - for (i,stoich) in enumerate(substoich) - s = substrates[i] - rl *= s - isone(stoich) && continue - for i in one(stoich):(stoich-one(stoich)) - rl *= (s - i) - end - combinatoric_ratelaw && (coef *= factorial(stoich)) - end - !isone(coef) && (rl /= coef) - end - rl -end - -# if haveivdep=false then time dependent rates will still be classified as mass action -""" -```julia -ismassaction(rx, rs; rxvars = get_variables(rx.rate), - haveivdep = any(var -> isequal(get_iv(rs),var), rxvars), - stateset = Set(states(rs))) -``` - -True if a given reaction is of mass action form, i.e. `rx.rate` does not depend -on any chemical species that correspond to states of the system, and does not depend -explicitly on the independent variable (usually time). - -# Arguments -- `rx`, the [`Reaction`](@ref). -- `rs`, a [`ReactionSystem`](@ref) containing the reaction. -- Optional: `rxvars`, `Variable`s which are not in `rxvars` are ignored as possible dependencies. -- Optional: `haveivdep`, `true` if the [`Reaction`](@ref) `rate` field explicitly depends on the independent variable. -- Optional: `stateset`, set of states which if the rxvars are within mean rx is non-mass action. -""" -function ismassaction(rx, rs; rxvars = get_variables(rx.rate), - haveivdep = any(var -> isequal(get_iv(rs),var), rxvars), - stateset = Set(get_states(rs))) - # if no dependencies must be zero order - (length(rxvars)==0) && return true - haveivdep && return false - rx.only_use_rate && return false - @inbounds for i = 1:length(rxvars) - (rxvars[i] in stateset) && return false - end - return true -end - -@inline function makemajump(rx; combinatoric_ratelaw=true) - @unpack rate, substrates, substoich, netstoich = rx - zeroorder = (length(substoich) == 0) - reactant_stoch = Vector{Pair{Any,eltype(substoich)}}(undef, length(substoich)) - @inbounds for i = 1:length(reactant_stoch) - reactant_stoch[i] = substrates[i] => substoich[i] - end - #push!(rstoich, reactant_stoch) - coef = (zeroorder || (!combinatoric_ratelaw)) ? one(eltype(substoich)) : prod(stoich -> factorial(stoich), substoich) - (!isone(coef)) && (rate /= coef) - #push!(rates, rate) - net_stoch = [Pair(p[1],p[2]) for p in netstoich] - #push!(nstoich, net_stoch) - MassActionJump(Num(rate), reactant_stoch, net_stoch, scale_rates=false, useiszero=false) -end - -function assemble_jumps(rs; combinatoric_ratelaws=true) - meqs = MassActionJump[]; ceqs = ConstantRateJump[]; veqs = VariableRateJump[] - stateset = Set(get_states(rs)) - #rates = []; rstoich = []; nstoich = [] - rxvars = [] - ivname = nameof(get_iv(rs)) - - isempty(equations(rs)) && error("Must give at least one reaction before constructing a JumpSystem.") - for rx in equations(rs) - empty!(rxvars) - (rx.rate isa Symbolic) && get_variables!(rxvars, rx.rate) - haveivdep = false - @inbounds for i = 1:length(rxvars) - if isequal(rxvars[i], get_iv(rs)) - haveivdep = true - break - end - end - if ismassaction(rx, rs; rxvars=rxvars, haveivdep=haveivdep, stateset=stateset) - push!(meqs, makemajump(rx, combinatoric_ratelaw=combinatoric_ratelaws)) - else - rl = jumpratelaw(rx, rxvars=rxvars, combinatoric_ratelaw=combinatoric_ratelaws) - affect = Vector{Equation}() - for (spec,stoich) in rx.netstoich - push!(affect, spec ~ spec + stoich) - end - if haveivdep - push!(veqs, VariableRateJump(rl,affect)) - else - push!(ceqs, ConstantRateJump(rl,affect)) - end - end - end - #eqs[1] = MassActionJump(rates, rstoich, nstoich, scale_rates=false, useiszero=false) - ArrayPartition(meqs,ceqs,veqs) -end - -""" -```julia -Base.convert(::Type{<:ODESystem},rs::ReactionSystem) -``` -Convert a [`ReactionSystem`](@ref) to an [`ODESystem`](@ref). - -Notes: -- `combinatoric_ratelaws=true` uses factorial scaling factors in calculating the rate -law, i.e. for `2S -> 0` at rate `k` the ratelaw would be `k*S^2/2!`. If -`combinatoric_ratelaws=false` then the ratelaw is `k*S^2`, i.e. the scaling factor is -ignored. -""" -function Base.convert(::Type{<:ODESystem}, rs::ReactionSystem; - name=nameof(rs), combinatoric_ratelaws=true, kwargs...) - eqs = assemble_drift(rs; combinatoric_ratelaws=combinatoric_ratelaws) - systems = map(sys -> (sys isa ODESystem) ? sys : convert(ODESystem, sys), get_systems(rs)) - ODESystem(eqs, get_iv(rs), get_states(rs), get_ps(rs); name=name, systems=systems, kwargs...) -end - -""" -```julia -Base.convert(::Type{<:NonlinearSystem},rs::ReactionSystem) -``` - -Convert a [`ReactionSystem`](@ref) to an [`NonlinearSystem`](@ref). - -Notes: -- `combinatoric_ratelaws=true` uses factorial scaling factors in calculating the rate -law, i.e. for `2S -> 0` at rate `k` the ratelaw would be `k*S^2/2!`. If -`combinatoric_ratelaws=false` then the ratelaw is `k*S^2`, i.e. the scaling factor is -ignored. -""" -function Base.convert(::Type{<:NonlinearSystem},rs::ReactionSystem; - name=nameof(rs), combinatoric_ratelaws=true, kwargs...) - eqs = assemble_drift(rs; combinatoric_ratelaws=combinatoric_ratelaws, as_odes=false) - systems = convert.(NonlinearSystem, get_systems(rs)) - NonlinearSystem(eqs, get_states(rs), get_ps(rs); name=name, systems=systems, kwargs...) -end - -""" -```julia -Base.convert(::Type{<:SDESystem},rs::ReactionSystem) -``` - -Convert a [`ReactionSystem`](@ref) to an [`SDESystem`](@ref). - -Notes: -- `combinatoric_ratelaws=true` uses factorial scaling factors in calculating the rate -law, i.e. for `2S -> 0` at rate `k` the ratelaw would be `k*S^2/2!`. If -`combinatoric_ratelaws=false` then the ratelaw is `k*S^2`, i.e. the scaling factor is -ignored. -- `noise_scaling=nothing::Union{Vector{Operation},Operation,Nothing}` allows for linear -scaling of the noise in the chemical Langevin equations. If `nothing` is given, the default -value as in Gillespie 2000 is used. Alternatively, an `Operation` can be given, this is -added as a parameter to the system (at the end of the parameter array). All noise terms -are linearly scaled with this value. The parameter may be one already declared in the `ReactionSystem`. -Finally, a `Vector{Operation}` can be provided (the length must be equal to the number of reactions). -Here the noise for each reaction is scaled by the corresponding parameter in the input vector. -This input may contain repeat parameters. -""" -function Base.convert(::Type{<:SDESystem}, rs::ReactionSystem; - noise_scaling=nothing, name=nameof(rs), combinatoric_ratelaws=true, kwargs...) - - if noise_scaling isa Vector - (length(noise_scaling)!=length(equations(rs))) && - error("The number of elements in 'noise_scaling' must be equal " * - "to the number of reactions in the reaction system.") - noise_scaling = value.(noise_scaling) - elseif !isnothing(noise_scaling) - noise_scaling = fill(value(noise_scaling),length(equations(rs))) - end - - eqs = assemble_drift(rs; combinatoric_ratelaws=combinatoric_ratelaws) - noiseeqs = assemble_diffusion(rs,noise_scaling; - combinatoric_ratelaws=combinatoric_ratelaws) - systems = convert.(SDESystem, get_systems(rs)) - SDESystem(eqs, noiseeqs, get_iv(rs), get_states(rs), - (noise_scaling===nothing) ? get_ps(rs) : union(get_ps(rs), toparam.(noise_scaling)); - name=name, - systems=systems, - kwargs...) -end - -""" -```julia -Base.convert(::Type{<:JumpSystem},rs::ReactionSystem; combinatoric_ratelaws=true) -``` - -Convert a [`ReactionSystem`](@ref) to an [`JumpSystem`](@ref). - -Notes: -- `combinatoric_ratelaws=true` uses binomials in calculating the rate law, i.e. for `2S -> - 0` at rate `k` the ratelaw would be `k*S*(S-1)/2`. If `combinatoric_ratelaws=false` then - the ratelaw is `k*S*(S-1)`, i.e. the rate law is not normalized by the scaling - factor. -""" -function Base.convert(::Type{<:JumpSystem},rs::ReactionSystem; - name=nameof(rs), combinatoric_ratelaws=true, kwargs...) - eqs = assemble_jumps(rs; combinatoric_ratelaws=combinatoric_ratelaws) - systems = convert.(JumpSystem, get_systems(rs)) - JumpSystem(eqs, get_iv(rs), get_states(rs), get_ps(rs); name=name, systems=systems, kwargs...) -end - - -### Converts a reaction system to ODE or SDE problems ### - - -# ODEProblem from AbstractReactionNetwork -function DiffEqBase.ODEProblem(rs::ReactionSystem, u0, tspan, p=DiffEqBase.NullParameters(), args...; kwargs...) - return ODEProblem(convert(ODESystem,rs; kwargs...),u0,tspan,p, args...; kwargs...) -end - -# NonlinearProblem from AbstractReactionNetwork -function DiffEqBase.NonlinearProblem(rs::ReactionSystem, u0, p=DiffEqBase.NullParameters(), args...; kwargs...) - return NonlinearProblem(convert(NonlinearSystem,rs; kwargs...), u0, p, args...; kwargs...) -end - - -# SDEProblem from AbstractReactionNetwork -function DiffEqBase.SDEProblem(rs::ReactionSystem, u0, tspan, p=DiffEqBase.NullParameters(), args...; noise_scaling=nothing, kwargs...) - sde_sys = convert(SDESystem,rs;noise_scaling=noise_scaling, kwargs...) - p_matrix = zeros(length(get_states(rs)), length(get_eqs(rs))) - return SDEProblem(sde_sys,u0,tspan,p,args...; noise_rate_prototype=p_matrix,kwargs...) -end - -# DiscreteProblem from AbstractReactionNetwork -function DiffEqBase.DiscreteProblem(rs::ReactionSystem, u0, tspan::Tuple, p=DiffEqBase.NullParameters(), args...; kwargs...) - return DiscreteProblem(convert(JumpSystem,rs; kwargs...), u0,tspan,p, args...; kwargs...) -end - -# JumpProblem from AbstractReactionNetwork -function DiffEqJump.JumpProblem(rs::ReactionSystem, prob, aggregator, args...; kwargs...) - return JumpProblem(convert(JumpSystem,rs; kwargs...), prob, aggregator, args...; kwargs...) -end - -# SteadyStateProblem from AbstractReactionNetwork -function DiffEqBase.SteadyStateProblem(rs::ReactionSystem, u0, p=DiffEqBase.NullParameters(), args...; kwargs...) - return SteadyStateProblem(ODEFunction(convert(ODESystem,rs; kwargs...)),u0,p, args...; kwargs...) -end - -# determine which species a reaction depends on -function get_variables!(deps::Set, rx::Reaction, variables) - (rx.rate isa Symbolic) && get_variables!(deps, rx.rate, variables) - for s in rx.substrates - push!(deps, s) - end - deps -end - -# determine which species a reaction modifies -function modified_states!(mstates, rx::Reaction, sts::Set) - for (species,stoich) in rx.netstoich - (species in sts) && push!(mstates, species) - end -end diff --git a/src/systems/sparsematrixclil.jl b/src/systems/sparsematrixclil.jl new file mode 100644 index 0000000000..cddf316084 --- /dev/null +++ b/src/systems/sparsematrixclil.jl @@ -0,0 +1,346 @@ +""" + SparseMatrixCLIL{T, Ti} + +The SparseMatrixCLIL represents a sparse matrix in two distinct ways: + + 1. As a sparse (in both row and column) n x m matrix + 2. As a row-dense, column-sparse k x m matrix + +The data structure keeps a permutation between the row order of the two representations. +Swapping the rows in one does not affect the other. + +On construction, the second representation is equivalent to the first with fully-sparse +rows removed, though this may cease being true as row permutations are being applied +to the matrix. + +The default structure of the `SparseMatrixCLIL` type is the second structure, while +the first is available via the thin `AsSubMatrix` wrapper. +""" +struct SparseMatrixCLIL{T, Ti <: Integer} <: AbstractSparseMatrix{T, Ti} + nparentrows::Int + ncols::Int + nzrows::Vector{Ti} + row_cols::Vector{Vector{Ti}} # issorted + row_vals::Vector{Vector{T}} +end +Base.size(S::SparseMatrixCLIL) = (length(S.nzrows), S.ncols) +function Base.copy(S::SparseMatrixCLIL{T, Ti}) where {T, Ti} + SparseMatrixCLIL(S.nparentrows, S.ncols, copy(S.nzrows), map(copy, S.row_cols), + map(copy, S.row_vals)) +end +function swaprows!(S::SparseMatrixCLIL, i, j) + i == j && return + swap!(S.nzrows, i, j) + swap!(S.row_cols, i, j) + swap!(S.row_vals, i, j) +end + +function Base.convert(::Type{SparseMatrixCLIL{T, Ti}}, S::SparseMatrixCLIL) where {T, Ti} + return SparseMatrixCLIL(S.nparentrows, + S.ncols, + copy.(S.nzrows), + copy.(S.row_cols), + [T.(row) for row in S.row_vals]) +end + +function SparseMatrixCLIL(mm::AbstractMatrix) + nrows, ncols = size(mm) + row_cols = [findall(!iszero, row) for row in eachrow(mm)] + row_vals = [row[cols] for (row, cols) in zip(eachrow(mm), row_cols)] + SparseMatrixCLIL(nrows, ncols, Int[1:length(row_cols);], row_cols, row_vals) +end + +struct CLILVector{T, Ti} <: AbstractSparseVector{T, Ti} + vec::SparseVector{T, Ti} +end +Base.hash(v::CLILVector, s::UInt) = hash(v.vec, s) ⊻ 0xc71be0e9ccb75fbd +Base.size(v::CLILVector) = Base.size(v.vec) +Base.getindex(v::CLILVector, idx::Integer...) = Base.getindex(v.vec, idx...) +Base.setindex!(vec::CLILVector, v, idx::Integer...) = Base.setindex!(vec.vec, v, idx...) +function Base.view(a::SparseMatrixCLIL, i::Integer, ::Colon) + CLILVector(SparseVector(a.ncols, a.row_cols[i], a.row_vals[i])) +end +SparseArrays.nonzeroinds(a::CLILVector) = SparseArrays.nonzeroinds(a.vec) +SparseArrays.nonzeros(a::CLILVector) = SparseArrays.nonzeros(a.vec) +SparseArrays.nnz(a::CLILVector) = nnz(a.vec) + +function Base.setindex!(S::SparseMatrixCLIL, v::CLILVector, i::Integer, c::Colon) + if v.vec.n != S.ncols + throw(BoundsError(v, 1:(S.ncols))) + end + any(iszero, v.vec.nzval) && error("setindex failed") + S.row_cols[i] = copy(v.vec.nzind) + S.row_vals[i] = copy(v.vec.nzval) + return v +end + +zero!(a::AbstractArray{T}) where {T} = a[:] .= zero(T) +zero!(a::SparseVector) = (empty!(a.nzind); empty!(a.nzval)) +zero!(a::CLILVector) = zero!(a.vec) +SparseArrays.dropzeros!(a::CLILVector) = SparseArrays.dropzeros!(a.vec) + +struct NonZeros{T <: AbstractArray} + v::T +end +Base.pairs(nz::NonZeros{<:CLILVector}) = NonZerosPairs(nz.v) + +struct NonZerosPairs{T <: AbstractArray} + v::T +end + +Base.IteratorSize(::Type{<:NonZerosPairs}) = Base.SizeUnknown() +# N.B.: Because of how we're using this, this must be robust to modification of +# the underlying vector. As such, we treat this as an iteration over indices +# that happens to short cut using the sparse structure and sortedness of the +# array. +function Base.iterate(nzp::NonZerosPairs{<:CLILVector}, (idx, col)) + v = nzp.v.vec + nzind = v.nzind + nzval = v.nzval + if idx > length(nzind) + idx = length(col) + end + oldcol = nzind[idx] + if col != oldcol + # The vector was changed since the last iteration. Find our + # place in the vector again. + tail = col > oldcol ? (@view nzind[(idx + 1):end]) : (@view nzind[1:idx]) + tail_i = searchsortedfirst(tail, col + 1) + # No remaining indices. + tail_i > length(tail) && return nothing + new_idx = col > oldcol ? idx + tail_i : tail_i + new_col = nzind[new_idx] + return (new_col => nzval[new_idx], (new_idx, new_col)) + end + idx == length(nzind) && return nothing + new_col = nzind[idx + 1] + return (new_col => nzval[idx + 1], (idx + 1, new_col)) +end + +function Base.iterate(nzp::NonZerosPairs{<:CLILVector}) + v = nzp.v.vec + nzind = v.nzind + nzval = v.nzval + isempty(nzind) && return nothing + return nzind[1] => nzval[1], (1, nzind[1]) +end + +# Arguably this is how nonzeros should behave in the first place, but let's +# build something that works for us here and worry about it later. +nonzerosmap(a::CLILVector) = NonZeros(a) + +using FindFirstFunctions: findfirstequal + +function bareiss_update_virtual_colswap_mtk!(zero!, M::SparseMatrixCLIL, k, swapto, pivot, + last_pivot; pivot_equal_optimization = true) + # for ei in nzrows(>= k) + eadj = M.row_cols + old_cadj = M.row_vals + vpivot = swapto[2] + + ## N.B.: Micro-optimization + # + # For rows that do not have an entry in the eliminated column, all this + # update does is multiply the row in question by `pivot/last_pivot` (the + # result of which is guaranteed to be integer by general properties of the + # bareiss algorithm, even if `pivot/last_pivot` is not). + # + # Thus, when `pivot == last pivot`, we can skip the update for any rows that + # do not have an entry in the eliminated column (because we'd simply be + # multiplying by 1). + # + # As an additional MTK-specific enhancement, we further allow the case + # when the absolute values are equal, i.e. effectively multiplying the row + # by `-1`. To ensure this is legal, we need to show two things. + # 1. The multiplication does not change the answer and + # 2. The multiplication does not affect the fraction-freeness of the Bareiss + # algorithm. + # + # For point 1, remember that we're working on a system of linear equations, + # so it is always legal for us to multiply any row by a scalar without changing + # the underlying system of equations. + # + # For point 2, note that the factorization we're now computing is the same + # as if we had multiplied the corresponding row (accounting for row swaps) + # in the original matrix by `last_pivot/pivot`, ensuring that the matrix + # itself has integral entries when `last_pivot/pivot` is integral (here we + # have -1, which counts). We could use the more general integrality + # condition, but that would in turn disturb the growth bounds on the + # factorization matrix entries that the bareiss algorithm guarantees. To be + # conservative, we leave it at this, as this captures the most important + # case for MTK (where most pivots are `1` or `-1`). + pivot_equal = pivot_equal_optimization && abs(pivot) == abs(last_pivot) + @inbounds for ei in (k + 1):size(M, 1) + # eliminate `v` + coeff = 0 + ivars = eadj[ei] + vj = findfirstequal(vpivot, ivars) + if vj !== nothing + coeff = old_cadj[ei][vj] + deleteat!(old_cadj[ei], vj) + deleteat!(eadj[ei], vj) + elseif pivot_equal + continue + end + + # the pivot row + kvars = eadj[k] + kcoeffs = old_cadj[k] + # the elimination target + ivars = eadj[ei] + icoeffs = old_cadj[ei] + + numkvars = length(kvars) + numivars = length(ivars) + tmp_incidence = similar(eadj[ei], numkvars + numivars) + tmp_coeffs = similar(old_cadj[ei], numkvars + numivars) + tmp_len = 0 + kvind = ivind = 0 + if _debug_mode + # in debug mode, we at least check to confirm we're iterating over + # `v`s in the correct order + vars = sort(union(ivars, kvars)) + vi = 0 + end + if numivars > 0 && numkvars > 0 + kvv = kvars[kvind += 1] + ivv = ivars[ivind += 1] + dobreak = false + while true + if kvv == ivv + v = kvv + ck = kcoeffs[kvind] + ci = icoeffs[ivind] + kvind += 1 + ivind += 1 + if kvind > numkvars + dobreak = true + else + kvv = kvars[kvind] + end + if ivind > numivars + dobreak = true + else + ivv = ivars[ivind] + end + p1 = Base.Checked.checked_mul(pivot, ci) + p2 = Base.Checked.checked_mul(coeff, ck) + ci = exactdiv(Base.Checked.checked_sub(p1, p2), last_pivot) + elseif kvv < ivv + v = kvv + ck = kcoeffs[kvind] + kvind += 1 + if kvind > numkvars + dobreak = true + else + kvv = kvars[kvind] + end + p2 = Base.Checked.checked_mul(coeff, ck) + ci = exactdiv(Base.Checked.checked_neg(p2), last_pivot) + else # kvv > ivv + v = ivv + ci = icoeffs[ivind] + ivind += 1 + if ivind > numivars + dobreak = true + else + ivv = ivars[ivind] + end + ci = exactdiv(Base.Checked.checked_mul(pivot, ci), last_pivot) + end + if _debug_mode + @assert v == vars[vi += 1] + end + if v != vpivot && !iszero(ci) + tmp_incidence[tmp_len += 1] = v + tmp_coeffs[tmp_len] = ci + end + dobreak && break + end + elseif numkvars > 0 + ivind = 1 + kvv = kvars[kvind += 1] + elseif numivars > 0 + kvind = 1 + ivv = ivars[ivind += 1] + end + if kvind <= numkvars + v = kvv + while true + if _debug_mode + @assert v == vars[vi += 1] + end + if v != vpivot + ck = kcoeffs[kvind] + p2 = Base.Checked.checked_mul(coeff, ck) + ci = exactdiv(Base.Checked.checked_neg(p2), last_pivot) + if !iszero(ci) + tmp_incidence[tmp_len += 1] = v + tmp_coeffs[tmp_len] = ci + end + end + (kvind == numkvars) && break + v = kvars[kvind += 1] + end + elseif ivind <= numivars + v = ivv + while true + if _debug_mode + @assert v == vars[vi += 1] + end + if v != vpivot + p1 = Base.Checked.checked_mul(pivot, icoeffs[ivind]) + ci = exactdiv(p1, last_pivot) + if !iszero(ci) + tmp_incidence[tmp_len += 1] = v + tmp_coeffs[tmp_len] = ci + end + end + (ivind == numivars) && break + v = ivars[ivind += 1] + end + end + resize!(tmp_incidence, tmp_len) + resize!(tmp_coeffs, tmp_len) + eadj[ei] = tmp_incidence + old_cadj[ei] = tmp_coeffs + end +end + +function bareiss_update_virtual_colswap_mtk!(zero!, M::AbstractMatrix, k, swapto, pivot, + last_pivot; pivot_equal_optimization = true) + if pivot_equal_optimization + error("MTK pivot micro-optimization not implemented for `$(typeof(M))`. + Turn off the optimization for debugging or use a different matrix type.") + end + bareiss_update_virtual_colswap!(zero!, M, k, swapto, pivot, last_pivot) +end + +struct AsSubMatrix{T, Ti <: Integer} <: AbstractSparseMatrix{T, Ti} + M::SparseMatrixCLIL{T, Ti} +end +Base.size(S::AsSubMatrix) = (S.M.nparentrows, S.M.ncols) + +function Base.getindex(S::SparseMatrixCLIL{T}, i1::Integer, i2::Integer) where {T} + checkbounds(S, i1, i2) + + col = S.row_cols[i1] + nncol = searchsortedfirst(col, i2) + (nncol > length(col) || col[nncol] != i2) && return zero(T) + + return S.row_vals[i1][nncol] +end + +function Base.getindex(S::AsSubMatrix{T}, i1::Integer, i2::Integer) where {T} + checkbounds(S, i1, i2) + S = S.M + + nnrow = findfirst(==(i1), S.nzrows) + isnothing(nnrow) && return zero(T) + + col = S.row_cols[nnrow] + nncol = searchsortedfirst(col, i2) + (nncol > length(col) || col[nncol] != i2) && return zero(T) + + return S.row_vals[nnrow][nncol] +end diff --git a/src/systems/systems.jl b/src/systems/systems.jl new file mode 100644 index 0000000000..0f8633f31f --- /dev/null +++ b/src/systems/systems.jl @@ -0,0 +1,216 @@ +function System(eqs::AbstractVector{<:Equation}, iv, args...; name = nothing, + kw...) + ODESystem(eqs, iv, args...; name, kw..., checks = false) +end + +const REPEATED_SIMPLIFICATION_MESSAGE = "Structural simplification cannot be applied to a completed system. Double simplification is not allowed." + +struct RepeatedStructuralSimplificationError <: Exception end + +function Base.showerror(io::IO, e::RepeatedStructuralSimplificationError) + print(io, REPEATED_SIMPLIFICATION_MESSAGE) +end + +""" +$(SIGNATURES) + +Structurally simplify algebraic equations in a system and compute the +topological sort of the observed equations in `sys`. + +### Optional Arguments: ++ optional argument `io` may take a tuple `(inputs, outputs)`. This will convert all `inputs` to parameters and allow them to be unconnected, i.e., simplification will allow models where `n_unknowns = n_equations - n_inputs`. + +### Optional Keyword Arguments: ++ When `simplify=true`, the `simplify` function will be applied during the tearing process. ++ `allow_symbolic=false`, `allow_parameter=true`, and `conservative=false` limit the coefficient types during tearing. In particular, `conservative=true` limits tearing to only solve for trivial linear systems where the coefficient has the absolute value of ``1``. ++ `fully_determined=true` controls whether or not an error will be thrown if the number of equations don't match the number of inputs, outputs, and equations. +""" +function structural_simplify( + sys::AbstractSystem, io = nothing; additional_passes = [], simplify = false, split = true, + allow_symbolic = false, allow_parameter = true, conservative = false, fully_determined = true, + kwargs...) + isscheduled(sys) && throw(RepeatedStructuralSimplificationError()) + newsys′ = __structural_simplify(sys, io; simplify, + allow_symbolic, allow_parameter, conservative, fully_determined, + kwargs...) + if newsys′ isa Tuple + @assert length(newsys′) == 2 + newsys = newsys′[1] + else + newsys = newsys′ + end + if newsys isa DiscreteSystem && + any(eq -> symbolic_type(eq.lhs) == NotSymbolic(), equations(newsys)) + error(""" + Encountered algebraic equations when simplifying discrete system. Please construct \ + an ImplicitDiscreteSystem instead. + """) + end + for pass in additional_passes + newsys = pass(newsys) + end + if newsys isa ODESystem || has_parent(newsys) + @set! newsys.parent = complete(sys; split = false, flatten = false) + end + newsys = complete(newsys; split) + if newsys′ isa Tuple + idxs = [parameter_index(newsys, i) for i in io[1]] + return newsys, idxs + else + return newsys + end +end + +function __structural_simplify(sys::JumpSystem, args...; kwargs...) + return sys +end + +function __structural_simplify(sys::SDESystem, args...; kwargs...) + return __structural_simplify(ODESystem(sys), args...; kwargs...) +end + +function __structural_simplify(sys::AbstractSystem, io = nothing; simplify = false, + kwargs...) + sys = expand_connections(sys) + state = TearingState(sys) + + @unpack structure, fullvars = state + @unpack graph, var_to_diff, var_types = structure + eqs = equations(state) + brown_vars = Int[] + new_idxs = zeros(Int, length(var_types)) + idx = 0 + for (i, vt) in enumerate(var_types) + if vt === BROWNIAN + push!(brown_vars, i) + else + new_idxs[i] = (idx += 1) + end + end + if isempty(brown_vars) + return structural_simplify!(state, io; simplify, kwargs...) + else + Is = Int[] + Js = Int[] + vals = Num[] + new_eqs = copy(eqs) + dvar2eq = Dict{Any, Int}() + for (v, dv) in enumerate(var_to_diff) + dv === nothing && continue + deqs = 𝑑neighbors(graph, dv) + if length(deqs) != 1 + error("$(eqs[deqs]) is not handled.") + end + dvar2eq[fullvars[dv]] = only(deqs) + end + for (j, bj) in enumerate(brown_vars), i in 𝑑neighbors(graph, bj) + push!(Is, i) + push!(Js, j) + eq = new_eqs[i] + brown = fullvars[bj] + (coeff, residual, islinear) = Symbolics.linear_expansion(eq, brown) + islinear || error("$brown isn't linear in $eq") + new_eqs[i] = 0 ~ residual + push!(vals, coeff) + end + g = Matrix(sparse(Is, Js, vals)) + sys = state.sys + @set! sys.eqs = new_eqs + @set! sys.unknowns = [v + for (i, v) in enumerate(fullvars) + if !iszero(new_idxs[i]) && + invview(var_to_diff)[i] === nothing] + # TODO: IO is not handled. + ode_sys = structural_simplify(sys, io; simplify, kwargs...) + eqs = equations(ode_sys) + sorted_g_rows = zeros(Num, length(eqs), size(g, 2)) + for (i, eq) in enumerate(eqs) + dvar = eq.lhs + # differential equations always precede algebraic equations + _iszero(dvar) && break + g_row = get(dvar2eq, dvar, 0) + iszero(g_row) && error("$dvar isn't handled.") + g_row > size(g, 1) && continue + @views copyto!(sorted_g_rows[i, :], g[g_row, :]) + end + # Fix for https://github.com/SciML/ModelingToolkit.jl/issues/2490 + if sorted_g_rows isa AbstractMatrix && size(sorted_g_rows, 2) == 1 + # If there's only one brownian variable referenced across all the equations, + # we get a Nx1 matrix of noise equations, which is a special case known as scalar noise + noise_eqs = sorted_g_rows[:, 1] + is_scalar_noise = true + elseif __num_isdiag_noise(sorted_g_rows) + # If each column of the noise matrix has either 0 or 1 non-zero entry, then this is "diagonal noise". + # In this case, the solver just takes a vector column of equations and it interprets that to + # mean that each noise process is independent + noise_eqs = __get_num_diag_noise(sorted_g_rows) + is_scalar_noise = false + else + noise_eqs = sorted_g_rows + is_scalar_noise = false + end + + noise_eqs = StructuralTransformations.tearing_substitute_expr(ode_sys, noise_eqs) + ssys = SDESystem(Vector{Equation}(full_equations(ode_sys)), noise_eqs, + get_iv(ode_sys), unknowns(ode_sys), parameters(ode_sys); + name = nameof(ode_sys), is_scalar_noise, observed = observed(ode_sys), defaults = defaults(sys), + parameter_dependencies = parameter_dependencies(sys), assertions = assertions(sys), + guesses = guesses(sys), initialization_eqs = initialization_equations(sys)) + @set! ssys.tearing_state = get_tearing_state(ode_sys) + return ssys + end +end + +""" + $(TYPEDSIGNATURES) + +Given a system that has been simplified via `structural_simplify`, return a `Dict` mapping +variables of the system to equations that are used to solve for them. This includes +observed variables. + +# Keyword Arguments + +- `rename_dummy_derivatives`: Whether to rename dummy derivative variable keys into their + `Differential` forms. For example, this would turn the key `yˍt(t)` into + `Differential(t)(y(t))`. +""" +function map_variables_to_equations(sys::AbstractSystem; rename_dummy_derivatives = true) + if !has_tearing_state(sys) + throw(ArgumentError("$(typeof(sys)) is not supported.")) + end + ts = get_tearing_state(sys) + if ts === nothing + throw(ArgumentError("`map_variables_to_equations` requires a simplified system. Call `structural_simplify` on the system before calling this function.")) + end + + dummy_sub = Dict() + if rename_dummy_derivatives && has_schedule(sys) && (sc = get_schedule(sys)) !== nothing + dummy_sub = Dict(v => k for (k, v) in sc.dummy_sub if isequal(default_toterm(k), v)) + end + + mapping = Dict{Union{Num, BasicSymbolic}, Equation}() + eqs = equations(sys) + for eq in eqs + isdifferential(eq.lhs) || continue + var = arguments(eq.lhs)[1] + var = get(dummy_sub, var, var) + mapping[var] = eq + end + + graph = ts.structure.graph + algvars = BitSet(findall( + Base.Fix1(StructuralTransformations.isalgvar, ts.structure), 1:ndsts(graph))) + algeqs = BitSet(findall(1:nsrcs(graph)) do eq + all(!Base.Fix1(isdervar, ts.structure), 𝑠neighbors(graph, eq)) + end) + alge_var_eq_matching = complete(maximal_matching(graph, in(algeqs), in(algvars))) + for (i, eq) in enumerate(alge_var_eq_matching) + eq isa Unassigned && continue + mapping[get(dummy_sub, ts.fullvars[i], ts.fullvars[i])] = eqs[eq] + end + for eq in observed(sys) + mapping[get(dummy_sub, eq.lhs, eq.lhs)] = eq + end + + return mapping +end diff --git a/src/systems/systemstructure.jl b/src/systems/systemstructure.jl index 2341501486..d27e5c93a1 100644 --- a/src/systems/systemstructure.jl +++ b/src/systems/systemstructure.jl @@ -1,126 +1,323 @@ -module SystemStructures - using DataStructures -using SymbolicUtils: istree, operation, arguments, Symbolic +using Symbolics: linear_expansion, unwrap, Connection +using SymbolicUtils: iscall, operation, arguments, Symbolic +using SymbolicUtils: quick_cancel, maketerm using ..ModelingToolkit import ..ModelingToolkit: isdiffeq, var_from_nested_derivative, vars!, flatten, - value, InvalidSystemException, isdifferential, _iszero, isparameter + value, InvalidSystemException, isdifferential, _iszero, + isparameter, isconstant, + independent_variables, SparseMatrixCLIL, AbstractSystem, + equations, isirreducible, input_timedomain, TimeDomain, + InferredTimeDomain, + VariableType, getvariabletype, has_equations, ODESystem using ..BipartiteGraphs +import ..BipartiteGraphs: invview, complete +using Graphs using UnPack using Setfield using SparseArrays -#= -When we don't do subsitution, variable information is split into two different -places, i.e. `states` and the right-hand-side of `observed`. +function quick_cancel_expr(expr) + Rewriters.Postwalk(quick_cancel, + similarterm = (x, f, args; kws...) -> maketerm(typeof(x), f, args, + SymbolicUtils.metadata(x), + kws...))(expr) +end -eqs = [0 ~ z + x; 0 ~ y + z^2] -states = [y, z] -observed = [x ~ sin(y) + z] -struct Reduced - var - expr - idxs +export SystemStructure, TransformationState, TearingState, structural_simplify! +export isdiffvar, isdervar, isalgvar, isdiffeq, algeqs, is_only_discrete +export dervars_range, diffvars_range, algvars_range +export DiffGraph, complete! +export get_fullvars, system_subset + +struct DiffGraph <: Graphs.AbstractGraph{Int} + primal_to_diff::Vector{Union{Int, Nothing}} + diff_to_primal::Union{Nothing, Vector{Union{Int, Nothing}}} end -fullvars = [Reduced(x, sin(y) + z, [2, 3]), y, z] -active_𝑣vertices = [false, true, true] - x y z -eq1: 1 1 -eq2: 1 1 - x y z -eq1: 1 1 -eq2: 1 1 +DiffGraph(primal_to_diff::Vector{Union{Int, Nothing}}) = DiffGraph(primal_to_diff, nothing) +function DiffGraph(n::Integer, with_badj::Bool = false) + DiffGraph(Union{Int, Nothing}[nothing for _ in 1:n], + with_badj ? Union{Int, Nothing}[nothing for _ in 1:n] : nothing) +end -for v in 𝑣vertices(graph); active_𝑣vertices[v] || continue +function Base.copy(dg::DiffGraph) + DiffGraph(copy(dg.primal_to_diff), + dg.diff_to_primal === nothing ? nothing : copy(dg.diff_to_primal)) +end +@noinline function require_complete(dg::DiffGraph) + dg.diff_to_primal === nothing && + error("Not complete. Run `complete` first.") end -=# -export SystemStructure, SystemPartition -export initialize_system_structure, find_linear_equations -export isdiffvar, isdervar, isalgvar, isdiffeq, isalgeq -export dervars_range, diffvars_range, algvars_range +Graphs.is_directed(dg::DiffGraph) = true +function Graphs.edges(dg::DiffGraph) + (i => v for (i, v) in enumerate(dg.primal_to_diff) if v !== nothing) +end +Graphs.nv(dg::DiffGraph) = length(dg.primal_to_diff) +Graphs.ne(dg::DiffGraph) = count(x -> x !== nothing, dg.primal_to_diff) +Graphs.vertices(dg::DiffGraph) = Base.OneTo(nv(dg)) +function Graphs.outneighbors(dg::DiffGraph, var::Integer) + diff = dg.primal_to_diff[var] + return diff === nothing ? () : (diff,) +end +function Graphs.inneighbors(dg::DiffGraph, var::Integer) + require_complete(dg) + diff = dg.diff_to_primal[var] + return diff === nothing ? () : (diff,) +end +function Graphs.add_vertex!(dg::DiffGraph) + push!(dg.primal_to_diff, nothing) + if dg.diff_to_primal !== nothing + push!(dg.diff_to_primal, nothing) + end + return length(dg.primal_to_diff) +end + +function Graphs.add_edge!(dg::DiffGraph, var::Integer, diff::Integer) + dg[var] = diff +end + +# Also pass through the array interface for ease of use +Base.:(==)(dg::DiffGraph, v::AbstractVector) = dg.primal_to_diff == v +Base.:(==)(dg::AbstractVector, v::DiffGraph) = v == dg.primal_to_diff +Base.eltype(::DiffGraph) = Union{Int, Nothing} +Base.size(dg::DiffGraph) = size(dg.primal_to_diff) +Base.length(dg::DiffGraph) = length(dg.primal_to_diff) +Base.getindex(dg::DiffGraph, var::Integer) = dg.primal_to_diff[var] +Base.getindex(dg::DiffGraph, a::AbstractArray) = [dg[x] for x in a] + +function Base.setindex!(dg::DiffGraph, val::Union{Integer, Nothing}, var::Integer) + if dg.diff_to_primal !== nothing + old_pd = dg.primal_to_diff[var] + if old_pd !== nothing + dg.diff_to_primal[old_pd] = nothing + end + if val !== nothing + #old_dp = dg.diff_to_primal[val] + #old_dp === nothing || error("Variable already assigned.") + dg.diff_to_primal[val] = var + end + end + return dg.primal_to_diff[var] = val +end +Base.iterate(dg::DiffGraph, state...) = iterate(dg.primal_to_diff, state...) -@enum VariableType::Int8 DIFFERENTIAL_VARIABLE ALGEBRAIC_VARIABLE DERIVATIVE_VARIABLE +function complete(dg::DiffGraph) + dg.diff_to_primal !== nothing && return dg + diff_to_primal = Union{Int, Nothing}[nothing for _ in 1:length(dg.primal_to_diff)] + for (var, diff) in edges(dg) + diff_to_primal[diff] = var + end + return DiffGraph(dg.primal_to_diff, diff_to_primal) +end -Base.@kwdef struct SystemPartition - e_solved::Vector{Int} - v_solved::Vector{Int} - e_residual::Vector{Int} - v_residual::Vector{Int} +function invview(dg::DiffGraph) + require_complete(dg) + return DiffGraph(dg.diff_to_primal, dg.primal_to_diff) end -function Base.:(==)(s1::SystemPartition, s2::SystemPartition) - tup1 = (s1.e_solved, s1.v_solved, s1.e_residual, s1.v_residual) - tup2 = (s2.e_solved, s2.v_solved, s2.e_residual, s2.v_residual) - tup1 == tup2 +struct DiffChainIterator{Descend} + var_to_diff::DiffGraph + v::Int +end + +function Base.iterate(di::DiffChainIterator{Descend}, v = nothing) where {Descend} + if v === nothing + vv = di.v + return (vv, vv) + end + g = Descend ? invview(di.var_to_diff) : di.var_to_diff + v′ = g[v] + v′ === nothing ? nothing : (v′, v′) +end + +abstract type TransformationState{T} end +abstract type AbstractTearingState{T} <: TransformationState{T} end + +get_fullvars(ts::TransformationState) = ts.fullvars +has_equations(::TransformationState) = true + +Base.@kwdef mutable struct SystemStructure + """Maps the index of variable x to the index of variable D(x).""" + var_to_diff::DiffGraph + """Maps the index of an algebraic equation to the index of the equation it is differentiated into.""" + eq_to_diff::DiffGraph + # Can be access as + # `graph` to automatically look at the bipartite graph + # or as `torn` to assert that tearing has run. + """Graph that connects equations to variables that appear in them.""" + graph::BipartiteGraph{Int, Nothing} + """Graph that connects equations to the variable they will be solved for during simplification.""" + solvable_graph::Union{BipartiteGraph{Int, Nothing}, Nothing} + """Variable types (brownian, variable, parameter) in the system.""" + var_types::Union{Vector{VariableType}, Nothing} + """Whether the system is discrete.""" + only_discrete::Bool +end + +function Base.copy(structure::SystemStructure) + var_types = structure.var_types === nothing ? nothing : copy(structure.var_types) + SystemStructure(copy(structure.var_to_diff), copy(structure.eq_to_diff), + copy(structure.graph), copy(structure.solvable_graph), + var_types, structure.only_discrete) +end + +is_only_discrete(s::SystemStructure) = s.only_discrete +isdervar(s::SystemStructure, i) = invview(s.var_to_diff)[i] !== nothing +function isalgvar(s::SystemStructure, i) + s.var_to_diff[i] === nothing && + invview(s.var_to_diff)[i] === nothing +end +function isdiffvar(s::SystemStructure, i) + s.var_to_diff[i] !== nothing && invview(s.var_to_diff)[i] === nothing end -Base.@kwdef struct SystemStructure +function dervars_range(s::SystemStructure) + Iterators.filter(Base.Fix1(isdervar, s), Base.OneTo(ndsts(s.graph))) +end +function diffvars_range(s::SystemStructure) + Iterators.filter(Base.Fix1(isdiffvar, s), Base.OneTo(ndsts(s.graph))) +end +function algvars_range(s::SystemStructure) + Iterators.filter(Base.Fix1(isalgvar, s), Base.OneTo(ndsts(s.graph))) +end + +function algeqs(s::SystemStructure) + BitSet(findall(map(1:nsrcs(s.graph)) do eq + all(v -> !isdervar(s, v), 𝑠neighbors(s.graph, eq)) + end)) +end + +function complete!(s::SystemStructure) + s.var_to_diff = complete(s.var_to_diff) + s.eq_to_diff = complete(s.eq_to_diff) + s.graph = complete(s.graph) + if s.solvable_graph !== nothing + s.solvable_graph = complete(s.solvable_graph) + end + s +end + +mutable struct TearingState{T <: AbstractSystem} <: AbstractTearingState{T} + """The system of equations.""" + sys::T + """The set of variables of the system.""" fullvars::Vector - vartype::Vector{VariableType} - varassoc::Vector{Int} - inv_varassoc::Vector{Int} - varmask::BitVector # `true` if the variable has the highest order derivative - algeqs::BitVector - graph::BipartiteGraph{Int,Vector{Vector{Int}},Int,Nothing} - solvable_graph::BipartiteGraph{Int,Vector{Vector{Int}},Int,Nothing} - assign::Vector{Int} - inv_assign::Vector{Int} - scc::Vector{Vector{Int}} - partitions::Vector{SystemPartition} -end - -isdervar(s::SystemStructure, var::Integer) = s.vartype[var] === DERIVATIVE_VARIABLE -isdiffvar(s::SystemStructure, var::Integer) = s.vartype[var] === DIFFERENTIAL_VARIABLE -isalgvar(s::SystemStructure, var::Integer) = s.vartype[var] === ALGEBRAIC_VARIABLE - -dervars_range(s::SystemStructure) = Iterators.filter(Base.Fix1(isdervar, s), eachindex(s.vartype)) -diffvars_range(s::SystemStructure) = Iterators.filter(Base.Fix1(isdiffvar, s), eachindex(s.vartype)) -algvars_range(s::SystemStructure) = Iterators.filter(Base.Fix1(isalgvar, s), eachindex(s.vartype)) - -isalgeq(s::SystemStructure, eq::Integer) = s.algeqs[eq] -isdiffeq(s::SystemStructure, eq::Integer) = !isalgeq(s, eq) - -function initialize_system_structure(sys) - sys = flatten(sys) + structure::SystemStructure + extra_eqs::Vector +end + +TransformationState(sys::AbstractSystem) = TearingState(sys) +function system_subset(ts::TearingState, ieqs::Vector{Int}) + eqs = equations(ts) + @set! ts.sys.eqs = eqs[ieqs] + @set! ts.structure = system_subset(ts.structure, ieqs) + ts +end - iv = independent_variable(sys) - eqs = copy(equations(sys)) +function system_subset(structure::SystemStructure, ieqs::Vector{Int}) + @unpack graph, eq_to_diff = structure + fadj = Vector{Int}[] + eq_to_diff = DiffGraph(length(ieqs)) + ne = 0 + for (j, eq_i) in enumerate(ieqs) + ivars = copy(graph.fadjlist[eq_i]) + ne += length(ivars) + push!(fadj, ivars) + eq_to_diff[j] = structure.eq_to_diff[eq_i] + end + @set! structure.graph = complete(BipartiteGraph(ne, fadj, ndsts(graph))) + @set! structure.eq_to_diff = eq_to_diff + structure +end + +function Base.show(io::IO, state::TearingState) + print(io, "TearingState of ", typeof(state.sys)) +end + +struct EquationsView{T} <: AbstractVector{Any} + ts::TearingState{T} +end +equations(ts::TearingState) = EquationsView(ts) +Base.size(ev::EquationsView) = (length(equations(ev.ts.sys)) + length(ev.ts.extra_eqs),) +function Base.getindex(ev::EquationsView, i::Integer) + eqs = equations(ev.ts.sys) + if i > length(eqs) + return ev.ts.extra_eqs[i - length(eqs)] + end + return eqs[i] +end +function Base.push!(ev::EquationsView, eq) + push!(ev.ts.extra_eqs, eq) +end + +function TearingState(sys; quick_cancel = false, check = true) + sys = flatten(sys) + ivs = independent_variables(sys) + iv = length(ivs) == 1 ? ivs[1] : nothing + # scalarize array equations, without scalarizing arguments to registered functions + eqs = flatten_equations(copy(equations(sys))) neqs = length(eqs) - algeqs = trues(neqs) dervaridxs = OrderedSet{Int}() - var2idx = Dict{Any,Int}() + var2idx = Dict{Any, Int}() symbolic_incidence = [] fullvars = [] var_counter = Ref(0) - addvar! = let fullvars=fullvars, var_counter=var_counter - var -> begin - get!(var2idx, var) do - push!(fullvars, var) - var_counter[] += 1 - end + var_types = VariableType[] + addvar! = let fullvars = fullvars, var_counter = var_counter, var_types = var_types + var -> get!(var2idx, var) do + push!(fullvars, var) + push!(var_types, getvariabletype(var)) + var_counter[] += 1 end end vars = OrderedSet() + varsvec = [] for (i, eq′) in enumerate(eqs) + if eq′.lhs isa Connection + check ? error("$(nameof(sys)) has unexpanded `connect` statements") : + return nothing + end if _iszero(eq′.lhs) + rhs = quick_cancel ? quick_cancel_expr(eq′.rhs) : eq′.rhs eq = eq′ else - eq = 0 ~ eq′.rhs - eq′.lhs + lhs = quick_cancel ? quick_cancel_expr(eq′.lhs) : eq′.lhs + rhs = quick_cancel ? quick_cancel_expr(eq′.rhs) : eq′.rhs + eq = 0 ~ rhs - lhs + end + vars!(vars, eq.rhs, op = Symbolics.Operator) + for v in vars + _var, _ = var_from_nested_derivative(v) + any(isequal(_var), ivs) && continue + if isparameter(_var) || + (iscall(_var) && isparameter(operation(_var)) || isconstant(_var)) + continue + end + v = scalarize(v) + if v isa AbstractArray + append!(varsvec, v) + else + push!(varsvec, v) + end end - vars!(vars, eq.rhs) isalgeq = true - statevars = [] - for var in vars - isequal(var, iv) && continue - if isparameter(var) || (istree(var) && isparameter(operation(var))) + unknownvars = [] + for var in varsvec + ModelingToolkit.isdelay(var, iv) && continue + set_incidence = true + @label ANOTHER_VAR + _var, _ = var_from_nested_derivative(var) + any(isequal(_var), ivs) && continue + if isparameter(_var) || + (iscall(_var) && isparameter(operation(_var)) || isconstant(_var)) continue end varidx = addvar!(var) - push!(statevars, var) + set_incidence && push!(unknownvars, var) dvar = var idx = varidx @@ -132,22 +329,75 @@ function initialize_system_structure(sys) dvar = arguments(dvar)[1] idx = addvar!(dvar) end + + dvar = var + idx = varidx + + if iscall(var) && operation(var) isa Symbolics.Operator && + !isdifferential(var) && (it = input_timedomain(var)) !== nothing + set_incidence = false + var = only(arguments(var)) + var = setmetadata(var, VariableTimeDomain, it) + @goto ANOTHER_VAR + end end - push!(symbolic_incidence, copy(statevars)) - empty!(statevars) + push!(symbolic_incidence, copy(unknownvars)) + empty!(unknownvars) empty!(vars) - algeqs[i] = isalgeq + empty!(varsvec) if isalgeq eqs[i] = eq + else + eqs[i] = eqs[i].lhs ~ rhs end end + ### Handle discrete variables + lowest_shift = Dict() + for var in fullvars + if ModelingToolkit.isoperator(var, ModelingToolkit.Shift) + steps = operation(var).steps + if steps > 0 + error("Only non-positive shifts allowed. Found $var with a shift of $steps") + end + v = arguments(var)[1] + lowest_shift[v] = min(get(lowest_shift, v, 0), steps) + end + end + for var in fullvars + if ModelingToolkit.isoperator(var, ModelingToolkit.Shift) + op = operation(var) + steps = op.steps + v = arguments(var)[1] + lshift = lowest_shift[v] + tt = op.t + elseif haskey(lowest_shift, var) + lshift = lowest_shift[var] + steps = 0 + tt = iv + v = var + else + continue + end + if lshift < steps + push!(dervaridxs, var2idx[var]) + end + for s in (steps - 1):-1:(lshift + 1) + sf = Shift(tt, s) + dvar = sf(v) + idx = addvar!(dvar) + if !(idx in dervaridxs) + push!(dervaridxs, idx) + end + end + end # sort `fullvars` such that the mass matrix is as diagonal as possible. dervaridxs = collect(dervaridxs) sorted_fullvars = OrderedSet(fullvars[dervaridxs]) + var_to_old_var = Dict(zip(fullvars, fullvars)) for dervaridx in dervaridxs dervar = fullvars[dervaridx] - diffvar = arguments(dervar)[1] + diffvar = var_to_old_var[lower_order_var(dervar, iv)] if !(diffvar in sorted_fullvars) push!(sorted_fullvars, diffvar) end @@ -157,31 +407,22 @@ function initialize_system_structure(sys) push!(sorted_fullvars, v) end end - fullvars = collect(sorted_fullvars) + new_fullvars = collect(sorted_fullvars) + sortperm = indexin(new_fullvars, fullvars) + fullvars = new_fullvars + var_types = var_types[sortperm] var2idx = Dict(fullvars .=> eachindex(fullvars)) dervaridxs = 1:length(dervaridxs) nvars = length(fullvars) diffvars = [] - vartype = fill(DIFFERENTIAL_VARIABLE, nvars) - varassoc = zeros(Int, nvars) - inv_varassoc = zeros(Int, nvars) + var_to_diff = DiffGraph(nvars, true) for dervaridx in dervaridxs - vartype[dervaridx] = DERIVATIVE_VARIABLE dervar = fullvars[dervaridx] - diffvar = arguments(dervar)[1] + diffvar = lower_order_var(dervar, iv) diffvaridx = var2idx[diffvar] push!(diffvars, diffvar) - varassoc[diffvaridx] = dervaridx - inv_varassoc[dervaridx] = diffvaridx - end - - algvars = setdiff(states(sys), diffvars) - for algvar in algvars - # it could be that a variable appeared in the states, but never appeared - # in the equations. - algvaridx = get(var2idx, algvar, 0) - vartype[algvaridx] = ALGEBRAIC_VARIABLE + var_to_diff[diffvaridx] = dervaridx end graph = BipartiteGraph(neqs, nvars, Val(false)) @@ -191,75 +432,297 @@ function initialize_system_structure(sys) end @set! sys.eqs = eqs - @set! sys.structure = SystemStructure( - fullvars = fullvars, - vartype = vartype, - varassoc = varassoc, - inv_varassoc = inv_varassoc, - varmask = iszero.(varassoc), - algeqs = algeqs, - graph = graph, - solvable_graph = BipartiteGraph(nsrcs(graph), ndsts(graph), Val(false)), - assign = Int[], - inv_assign = Int[], - scc = Vector{Int}[], - partitions = SystemPartition[], - ) - return sys -end - -function find_linear_equations(sys) - s = structure(sys) - @unpack fullvars, graph = s - is_linear_equations = falses(nsrcs(graph)) - eqs = equations(sys) - eadj = Vector{Int}[] - cadj = Vector{Int}[] - coeffs = Int[] - for (i, eq) in enumerate(eqs); isdiffeq(eq) && continue - empty!(coeffs) - linear_term = 0 - all_int_vars = true - - term = value(eq.rhs - eq.lhs) - for j in 𝑠neighbors(graph, i) - var = fullvars[j] - c = expand_derivatives(Differential(var)(term), false) - # test if `var` is linear in `eq`. - if !(c isa Symbolic) && c isa Number - if c == 1 || c == -1 - c = convert(Integer, c) - linear_term += c * var - push!(coeffs, c) - else - all_int_vars = false - end - end - end - # Check if all states in the equation is both linear and homogeneous, - # i.e. it is in the form of - # - # ``∑ c_i * v_i = 0``, - # - # where ``c_i`` ∈ ℤ and ``v_i`` denotes states. - if all_int_vars && isequal(linear_term, term) - is_linear_equations[i] = true - push!(eadj, copy(𝑠neighbors(graph, i))) - push!(cadj, copy(coeffs)) + eq_to_diff = DiffGraph(nsrcs(graph)) + + ts = TearingState(sys, fullvars, + SystemStructure(complete(var_to_diff), complete(eq_to_diff), + complete(graph), nothing, var_types, sys isa AbstractDiscreteSystem), + Any[]) + if sys isa DiscreteSystem + ts = shift_discrete_system(ts) + end + return ts +end + +function lower_order_var(dervar, t) + if isdifferential(dervar) + diffvar = arguments(dervar)[1] + elseif ModelingToolkit.isoperator(dervar, ModelingToolkit.Shift) + s = operation(dervar) + step = s.steps - 1 + vv = arguments(dervar)[1] + if step != 0 + diffvar = Shift(s.t, step)(vv) else - is_linear_equations[i] = false + diffvar = vv + end + else + return Shift(t, -1)(dervar) + end + diffvar +end + +function shift_discrete_system(ts::TearingState) + @unpack fullvars, sys = ts + discvars = OrderedSet() + eqs = equations(sys) + for eq in eqs + vars!(discvars, eq; op = Union{Sample, Hold}) + end + iv = get_iv(sys) + + discmap = Dict(k => StructuralTransformations.simplify_shifts(Shift(iv, 1)(k)) + for k in discvars + if any(isequal(k), fullvars) && !isa(operation(k), Union{Sample, Hold})) + + for i in eachindex(fullvars) + fullvars[i] = StructuralTransformations.simplify_shifts(fast_substitute( + fullvars[i], discmap; operator = Union{Sample, Hold})) + end + for i in eachindex(eqs) + eqs[i] = StructuralTransformations.simplify_shifts(fast_substitute( + eqs[i], discmap; operator = Union{Sample, Hold})) + end + @set! ts.sys.eqs = eqs + @set! ts.fullvars = fullvars + return ts +end + +using .BipartiteGraphs: Label, BipartiteAdjacencyList +struct SystemStructurePrintMatrix <: + AbstractMatrix{Union{Label, BipartiteAdjacencyList}} + bpg::BipartiteGraph + highlight_graph::Union{Nothing, BipartiteGraph} + var_to_diff::DiffGraph + eq_to_diff::DiffGraph + var_eq_matching::Union{Matching, Nothing} +end + +""" +Create a SystemStructurePrintMatrix to display the contents +of the provided SystemStructure. +""" +function SystemStructurePrintMatrix(s::SystemStructure) + return SystemStructurePrintMatrix(complete(s.graph), + s.solvable_graph === nothing ? nothing : + complete(s.solvable_graph), + complete(s.var_to_diff), + complete(s.eq_to_diff), + nothing) +end +Base.size(bgpm::SystemStructurePrintMatrix) = (max(nsrcs(bgpm.bpg), ndsts(bgpm.bpg)) + 1, 9) +function compute_diff_label(diff_graph, i, symbol) + di = i - 1 <= length(diff_graph) ? diff_graph[i - 1] : nothing + return di === nothing ? Label("") : Label(string(di, symbol)) +end +function Base.getindex(bgpm::SystemStructurePrintMatrix, i::Integer, j::Integer) + checkbounds(bgpm, i, j) + if i <= 1 + return (Label.(("#", "∂ₜ", " ", " eq", "", "#", "∂ₜ", " ", " v")))[j] + elseif j == 5 + colors = Base.text_colors + return Label("|", :light_black) + elseif j == 2 + return compute_diff_label(bgpm.eq_to_diff, i, '↑') + elseif j == 3 + return compute_diff_label(invview(bgpm.eq_to_diff), i, '↓') + elseif j == 7 + return compute_diff_label(bgpm.var_to_diff, i, '↑') + elseif j == 8 + return compute_diff_label(invview(bgpm.var_to_diff), i, '↓') + elseif j == 1 + return Label((i - 1 <= length(bgpm.eq_to_diff)) ? string(i - 1) : "") + elseif j == 6 + return Label((i - 1 <= length(bgpm.var_to_diff)) ? string(i - 1) : "") + elseif j == 4 + return BipartiteAdjacencyList( + i - 1 <= nsrcs(bgpm.bpg) ? + 𝑠neighbors(bgpm.bpg, i - 1) : nothing, + bgpm.highlight_graph !== nothing && + i - 1 <= nsrcs(bgpm.highlight_graph) ? + Set(𝑠neighbors(bgpm.highlight_graph, i - 1)) : + nothing, + bgpm.var_eq_matching !== nothing && + (i - 1 <= length(invview(bgpm.var_eq_matching))) ? + invview(bgpm.var_eq_matching)[i - 1] : unassigned) + elseif j == 9 + match = unassigned + if bgpm.var_eq_matching !== nothing && i - 1 <= length(bgpm.var_eq_matching) + match = bgpm.var_eq_matching[i - 1] + isa(match, Union{Int, Unassigned}) || (match = true) # Selected Unknown end + return BipartiteAdjacencyList( + i - 1 <= ndsts(bgpm.bpg) ? + 𝑑neighbors(bgpm.bpg, i - 1) : nothing, + bgpm.highlight_graph !== nothing && + i - 1 <= ndsts(bgpm.highlight_graph) ? + Set(𝑑neighbors(bgpm.highlight_graph, i - 1)) : + nothing, match) + else + @assert false end +end - return is_linear_equations, eadj, cadj +function Base.show(io::IO, mime::MIME"text/plain", s::SystemStructure) + @unpack graph, solvable_graph, var_to_diff, eq_to_diff = s + if !get(io, :limit, true) || !get(io, :mtk_limit, true) + print(io, "SystemStructure with ", length(s.graph.fadjlist), " equations and ", + isa(s.graph.badjlist, Int) ? s.graph.badjlist : length(s.graph.badjlist), + " variables\n") + Base.print_matrix(io, SystemStructurePrintMatrix(s)) + else + S = incidence_matrix(s.graph, Num(Sym{Real}(:×))) + print(io, "Incidence matrix:") + show(io, mime, S) + end +end + +struct MatchedSystemStructure + structure::SystemStructure + var_eq_matching::Matching +end + +""" +Create a SystemStructurePrintMatrix to display the contents +of the provided MatchedSystemStructure. +""" +function SystemStructurePrintMatrix(ms::MatchedSystemStructure) + return SystemStructurePrintMatrix(complete(ms.structure.graph), + complete(ms.structure.solvable_graph), + complete(ms.structure.var_to_diff), + complete(ms.structure.eq_to_diff), + complete(ms.var_eq_matching, + nsrcs(ms.structure.graph))) +end + +function Base.copy(ms::MatchedSystemStructure) + MatchedSystemStructure(Base.copy(ms.structure), Base.copy(ms.var_eq_matching)) end -function Base.show(io::IO, s::SystemStructure) - @unpack graph = s - S = incidence_matrix(graph, Num(Sym{Real}(:×))) - print(io, "Incidence matrix:") - show(io, S) +function Base.show(io::IO, mime::MIME"text/plain", ms::MatchedSystemStructure) + s = ms.structure + @unpack graph, solvable_graph, var_to_diff, eq_to_diff = s + print(io, "Matched SystemStructure with ", length(graph.fadjlist), " equations and ", + isa(graph.badjlist, Int) ? graph.badjlist : length(graph.badjlist), + " variables\n") + Base.print_matrix(io, SystemStructurePrintMatrix(ms)) + printstyled(io, "\n\nLegend: ") + printstyled(io, "Solvable") + print(io, " | ") + printstyled(io, "(Solvable + Matched)", color = :light_yellow) + print(io, " | ") + printstyled(io, "Unsolvable", color = :light_black) + print(io, " | ") + printstyled(io, "(Unsolvable + Matched)", color = :magenta) + print(io, " | ") + printstyled(io, " ∫", color = :cyan) + printstyled(io, " SelectedState") end -end # module +# TODO: clean up +function merge_io(io, inputs) + isempty(inputs) && return io + if io === nothing + io = (inputs, []) + else + io = ([inputs; io[1]], io[2]) + end + return io +end + +function structural_simplify!(state::TearingState, io = nothing; simplify = false, + check_consistency = true, fully_determined = true, warn_initialize_determined = true, + kwargs...) + if state.sys isa ODESystem + ci = ModelingToolkit.ClockInference(state) + ci = ModelingToolkit.infer_clocks!(ci) + time_domains = merge(Dict(state.fullvars .=> ci.var_domain), + Dict(default_toterm.(state.fullvars) .=> ci.var_domain)) + tss, inputs, continuous_id, id_to_clock = ModelingToolkit.split_system(ci) + cont_io = merge_io(io, inputs[continuous_id]) + sys, input_idxs = _structural_simplify!(tss[continuous_id], cont_io; simplify, + check_consistency, fully_determined, + kwargs...) + if length(tss) > 1 + if continuous_id > 0 + throw(HybridSystemNotSupportedException("Hybrid continuous-discrete systems are currently not supported with the standard MTK compiler. This system requires JuliaSimCompiler.jl, see https://help.juliahub.com/juliasimcompiler/stable/")) + end + # TODO: rename it to something else + discrete_subsystems = Vector{ODESystem}(undef, length(tss)) + # Note that the appended_parameters must agree with + # `generate_discrete_affect`! + appended_parameters = parameters(sys) + for (i, state) in enumerate(tss) + if i == continuous_id + discrete_subsystems[i] = sys + continue + end + dist_io = merge_io(io, inputs[i]) + ss, = _structural_simplify!(state, dist_io; simplify, check_consistency, + fully_determined, kwargs...) + append!(appended_parameters, inputs[i], unknowns(ss)) + discrete_subsystems[i] = ss + end + @set! sys.discrete_subsystems = discrete_subsystems, inputs, continuous_id, + id_to_clock + @set! sys.ps = appended_parameters + @set! sys.defaults = merge(ModelingToolkit.defaults(sys), + Dict(v => 0.0 for v in Iterators.flatten(inputs))) + end + ps = [sym isa CallWithMetadata ? sym : + setmetadata( + sym, VariableTimeDomain, get(time_domains, sym, ContinuousClock())) + for sym in get_ps(sys)] + @set! sys.ps = ps + else + sys, input_idxs = _structural_simplify!(state, io; simplify, check_consistency, + fully_determined, kwargs...) + end + has_io = io !== nothing + return has_io ? (sys, input_idxs) : sys +end + +function _structural_simplify!(state::TearingState, io; simplify = false, + check_consistency = true, fully_determined = true, warn_initialize_determined = false, + dummy_derivative = true, + kwargs...) + if fully_determined isa Bool + check_consistency &= fully_determined + else + check_consistency = true + end + has_io = io !== nothing + orig_inputs = Set() + if has_io + ModelingToolkit.markio!(state, orig_inputs, io...) + end + if io !== nothing + state, input_idxs = ModelingToolkit.inputs_to_parameters!(state, io) + else + input_idxs = 0:-1 # Empty range + end + sys, mm = ModelingToolkit.alias_elimination!(state; kwargs...) + if check_consistency + fully_determined = ModelingToolkit.check_consistency( + state, orig_inputs; nothrow = fully_determined === nothing) + end + if fully_determined && dummy_derivative + sys = ModelingToolkit.dummy_derivative( + sys, state; simplify, mm, check_consistency, kwargs...) + elseif fully_determined + var_eq_matching = pantelides!(state; finalize = false, kwargs...) + sys = pantelides_reassemble(state, var_eq_matching) + state = TearingState(sys) + sys, mm = ModelingToolkit.alias_elimination!(state; kwargs...) + sys = ModelingToolkit.dummy_derivative( + sys, state; simplify, mm, check_consistency, kwargs...) + else + sys = ModelingToolkit.tearing( + sys, state; simplify, mm, check_consistency, kwargs...) + end + fullunknowns = [map(eq -> eq.lhs, observed(sys)); unknowns(sys)] + @set! sys.observed = ModelingToolkit.topsort_equations(observed(sys), fullunknowns) + + ModelingToolkit.invalidate_cache!(sys), input_idxs +end diff --git a/src/systems/unit_check.jl b/src/systems/unit_check.jl new file mode 100644 index 0000000000..5035a22b5e --- /dev/null +++ b/src/systems/unit_check.jl @@ -0,0 +1,316 @@ +#For dispatching get_unit +const Conditional = Union{typeof(ifelse)} +const Comparison = Union{typeof.([==, !=, ≠, <, <=, ≤, >, >=, ≥])...} + +struct ValidationError <: Exception + message::String +end + +check_units(::Nothing, _...) = true + +function __get_literal_unit(x) + if x isa Pair + x = x[1] + end + if !(x isa Union{Num, Symbolic}) + return nothing + end + v = value(x) + u = getmetadata(v, VariableUnit, nothing) + u isa DQ.AbstractQuantity ? screen_unit(u) : u +end +function __get_scalar_unit_type(v) + u = __get_literal_unit(v) + if u isa DQ.AbstractQuantity + return Val(:DynamicQuantities) + elseif u isa Unitful.Unitlike + return Val(:Unitful) + end + return nothing +end +function __get_unit_type(vs′...) + for vs in vs′ + if vs isa AbstractVector + for v in vs + u = __get_scalar_unit_type(v) + u === nothing || return u + end + else + v = vs + u = __get_scalar_unit_type(v) + u === nothing || return u + end + end + return nothing +end + +function screen_unit(result) + if result isa DQ.AbstractQuantity + d = DQ.dimension(result) + if d isa DQ.Dimensions + return result + elseif d isa DQ.SymbolicDimensions + return DQ.uexpand(oneunit(result)) + else + throw(ValidationError("$result doesn't have a recognized unit")) + end + else + throw(ValidationError("$result doesn't have any unit.")) + end +end + +const unitless = DQ.Quantity(1.0) +get_literal_unit(x) = screen_unit(something(__get_literal_unit(x), unitless)) + +""" +Find the unit of a symbolic item. +""" +get_unit(x::Real) = unitless +get_unit(x::DQ.AbstractQuantity) = screen_unit(x) +get_unit(x::AbstractArray) = map(get_unit, x) +get_unit(x::Num) = get_unit(unwrap(x)) +get_unit(x::Symbolics.Arr) = get_unit(unwrap(x)) +get_unit(op::Differential, args) = get_unit(args[1]) / get_unit(op.x) +get_unit(op::Difference, args) = get_unit(args[1]) / get_unit(op.t) +get_unit(op::typeof(getindex), args) = get_unit(args[1]) +get_unit(x::SciMLBase.NullParameters) = unitless +get_unit(op::typeof(instream), args) = get_unit(args[1]) + +function get_unit(op, args) # Fallback + result = oneunit(op(get_unit.(args)...)) + try + get_unit(result) + catch + throw(ValidationError("Unable to get unit for operation $op with arguments $args.")) + end +end + +function get_unit(::Union{typeof(+), typeof(-)}, args) + u = get_unit(args[1]) + if all(i -> get_unit(args[i]) == u, 2:length(args)) + return u + end +end + +function get_unit(op::Integral, args) + unit = 1 + if op.domain.variables isa Vector + for u in op.domain.variables + unit *= get_unit(u) + end + else + unit *= get_unit(op.domain.variables) + end + return get_unit(args[1]) * unit +end + +equivalent(x, y) = isequal(x, y) +function get_unit(op::Conditional, args) + terms = get_unit.(args) + terms[1] == unitless || + throw(ValidationError(", in $op, [$(terms[1])] is not dimensionless.")) + equivalent(terms[2], terms[3]) || + throw(ValidationError(", in $op, units [$(terms[2])] and [$(terms[3])] do not match.")) + return terms[2] +end + +function get_unit(op::typeof(Symbolics._mapreduce), args) + if args[2] == + + get_unit(args[3]) + else + throw(ValidationError("Unsupported array operation $op")) + end +end + +function get_unit(op::Comparison, args) + terms = get_unit.(args) + equivalent(terms[1], terms[2]) || + throw(ValidationError(", in comparison $op, units [$(terms[1])] and [$(terms[2])] do not match.")) + return unitless +end + +function get_unit(x::Symbolic) + if (u = __get_literal_unit(x)) !== nothing + screen_unit(u) + elseif issym(x) + get_literal_unit(x) + elseif isadd(x) + terms = get_unit.(arguments(x)) + firstunit = terms[1] + for other in terms[2:end] + termlist = join(map(repr, terms), ", ") + equivalent(other, firstunit) || + throw(ValidationError(", in sum $x, units [$termlist] do not match.")) + end + return firstunit + elseif ispow(x) + pargs = arguments(x) + base, expon = get_unit.(pargs) + @assert oneunit(expon) == unitless + if base == unitless + unitless + else + pargs[2] isa Number ? base^pargs[2] : (1 * base)^pargs[2] + end + elseif iscall(x) + op = operation(x) + if issym(op) || (iscall(op) && iscall(operation(op))) # Dependent variables, not function calls + return screen_unit(getmetadata(x, VariableUnit, unitless)) # Like x(t) or x[i] + elseif iscall(op) && !iscall(operation(op)) + gp = getmetadata(x, Symbolics.GetindexParent, nothing) # Like x[1](t) + return screen_unit(getmetadata(gp, VariableUnit, unitless)) + end # Actual function calls: + args = arguments(x) + return get_unit(op, args) + else # This function should only be reached by Terms, for which `iscall` is true + throw(ArgumentError("Unsupported value $x.")) + end +end + +""" +Get unit of term, returning nothing & showing warning instead of throwing errors. +""" +function safe_get_unit(term, info) + side = nothing + try + side = get_unit(term) + catch err + if err isa DQ.DimensionError + @warn("$info: $(err.x) and $(err.y) are not dimensionally compatible.") + elseif err isa ValidationError + @warn(info*err.message) + elseif err isa MethodError + @warn("$info: no method matching $(err.f) for arguments $(typeof.(err.args)).") + else + rethrow() + end + end + side +end + +function _validate(terms::Vector, labels::Vector{String}; info::String = "") + valid = true + first_unit = nothing + first_label = nothing + for (term, label) in zip(terms, labels) + equnit = safe_get_unit(term, info * label) + if equnit === nothing + valid = false + elseif !isequal(term, 0) + if first_unit === nothing + first_unit = equnit + first_label = label + elseif !equivalent(first_unit, equnit) + valid = false + str = "$info: units [$(first_unit)] for $(first_label) and [$(equnit)] for $(label) do not match." + if oneunit(first_unit) == oneunit(equnit) + str *= " If there are non-SI units in the system, please use symbolic units like `us\"ms\"`" + end + @warn(str) + end + end + end + valid +end + +function _validate(conn::Connection; info::String = "") + valid = true + syss = get_systems(conn) + sys = first(syss) + st = unknowns(sys) + for i in 2:length(syss) + s = syss[i] + sst = unknowns(s) + if length(st) != length(sst) + valid = false + @warn("$info: connected systems $(nameof(sys)) and $(nameof(s)) have $(length(st)) and $(length(sst)) unknowns, cannot connect.") + continue + end + for (i, x) in enumerate(st) + j = findfirst(isequal(x), sst) + if j == nothing + valid = false + @warn("$info: connected systems $(nameof(sys)) and $(nameof(s)) do not have the same unknowns.") + else + aunit = safe_get_unit(x, info * string(nameof(sys)) * "#$i") + bunit = safe_get_unit(sst[j], info * string(nameof(s)) * "#$j") + if !equivalent(aunit, bunit) + valid = false + str = "$info: connected system unknowns $x ($aunit) and $(sst[j]) ($bunit) have mismatched units." + if oneunit(aunit) == oneunit(bunit) + str *= " If there are non-SI units in the system, please use symbolic units like `us\"ms\"`" + end + @warn(str) + end + end + end + end + valid +end + +function validate(jump::Union{VariableRateJump, + ConstantRateJump}, t::Symbolic; + info::String = "") + newinfo = replace(info, "eq." => "jump") + _validate([jump.rate, 1 / t], ["rate", "1/t"], info = newinfo) && # Assuming the rate is per time units + validate(jump.affect!, info = newinfo) +end + +function validate(jump::MassActionJump, t::Symbolic; info::String = "") + left_symbols = [x[1] for x in jump.reactant_stoch] #vector of pairs of symbol,int -> vector symbols + net_symbols = [x[1] for x in jump.net_stoch] + all_symbols = vcat(left_symbols, net_symbols) + allgood = _validate(all_symbols, string.(all_symbols); info) + n = sum(x -> x[2], jump.reactant_stoch, init = 0) + base_unitful = all_symbols[1] #all same, get first + allgood && _validate([jump.scaled_rates, 1 / (t * base_unitful^n)], + ["scaled_rates", "1/(t*reactants^$n))"]; info) +end + +function validate(jumps::ArrayPartition{<:Union{Any, Vector{<:JumpType}}}, t::Symbolic) + labels = ["in Mass Action Jumps,", "in Constant Rate Jumps,", "in Variable Rate Jumps,"] + all([validate(jumps.x[idx], t, info = labels[idx]) for idx in 1:3]) +end + +function validate(eq::Equation; info::String = "") + if typeof(eq.lhs) == Connection + _validate(eq.rhs; info) + else + _validate([eq.lhs, eq.rhs], ["left", "right"]; info) + end +end +function validate(eq::Equation, + term::Union{Symbolic, DQ.AbstractQuantity, Num}; info::String = "") + _validate([eq.lhs, eq.rhs, term], ["left", "right", "noise"]; info) +end +function validate(eq::Equation, terms::Vector; info::String = "") + _validate(vcat([eq.lhs, eq.rhs], terms), + vcat(["left", "right"], "noise #" .* string.(1:length(terms))); info) +end + +""" +Returns true iff units of equations are valid. +""" +function validate(eqs::Vector; info::String = "") + all([validate(eqs[idx], info = info * " in eq. #$idx") for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, noise::Vector; info::String = "") + all([validate(eqs[idx], noise[idx], info = info * " in eq. #$idx") + for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, noise::Matrix; info::String = "") + all([validate(eqs[idx], noise[idx, :], info = info * " in eq. #$idx") + for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, term::Symbolic; info::String = "") + all([validate(eqs[idx], term, info = info * " in eq. #$idx") for idx in 1:length(eqs)]) +end +validate(term::Symbolics.SymbolicUtils.Symbolic) = safe_get_unit(term, "") !== nothing + +""" +Throws error if units of equations are invalid. +""" +function check_units(::Val{:DynamicQuantities}, eqs...) + validate(eqs...) || + throw(ValidationError("Some equations had invalid units. See warnings for details.")) +end diff --git a/src/systems/validation.jl b/src/systems/validation.jl new file mode 100644 index 0000000000..84dd3b07e5 --- /dev/null +++ b/src/systems/validation.jl @@ -0,0 +1,282 @@ +module UnitfulUnitCheck + +using ..ModelingToolkit, Symbolics, SciMLBase, Unitful, RecursiveArrayTools +using ..ModelingToolkit: ValidationError, + ModelingToolkit, Connection, instream, JumpType, VariableUnit, + get_systems, + Conditional, Comparison +using Symbolics: Symbolic, value, issym, isadd, ismul, ispow +const MT = ModelingToolkit + +Base.:*(x::Union{Num, Symbolic}, y::Unitful.AbstractQuantity) = x * y +Base.:/(x::Union{Num, Symbolic}, y::Unitful.AbstractQuantity) = x / y + +""" +Throw exception on invalid unit types, otherwise return argument. +""" +function screen_unit(result) + result isa Unitful.Unitlike || + throw(ValidationError("Unit must be a subtype of Unitful.Unitlike, not $(typeof(result)).")) + result isa Unitful.ScalarUnits || + throw(ValidationError("Non-scalar units such as $result are not supported. Use a scalar unit instead.")) + result == u"°" && + throw(ValidationError("Degrees are not supported. Use radians instead.")) + result +end + +""" +Test unit equivalence. + +Example of implemented behavior: + +```julia +using ModelingToolkit, Unitful +MT = ModelingToolkit +@parameters γ P [unit = u"MW"] E [unit = u"kJ"] τ [unit = u"ms"] +@test MT.equivalent(u"MW", u"kJ/ms") # Understands prefixes +@test !MT.equivalent(u"m", u"cm") # Units must be same magnitude +@test MT.equivalent(MT.get_unit(P^γ), MT.get_unit((E / τ)^γ)) # Handles symbolic exponents +``` +""" +equivalent(x, y) = isequal(1 * x, 1 * y) +const unitless = Unitful.unit(1) + +""" +Find the unit of a symbolic item. +""" +get_unit(x::Real) = unitless +get_unit(x::Unitful.Quantity) = screen_unit(Unitful.unit(x)) +get_unit(x::AbstractArray) = map(get_unit, x) +get_unit(x::Num) = get_unit(value(x)) +function get_unit(x::Union{Symbolics.ArrayOp, Symbolics.Arr, Symbolics.CallWithMetadata}) + get_literal_unit(x) +end +get_unit(op::Differential, args) = get_unit(args[1]) / get_unit(op.x) +get_unit(op::typeof(getindex), args) = get_unit(args[1]) +get_unit(x::SciMLBase.NullParameters) = unitless +get_unit(op::typeof(instream), args) = get_unit(args[1]) + +get_literal_unit(x) = screen_unit(getmetadata(x, VariableUnit, unitless)) + +function get_unit(op, args) # Fallback + result = op(1 .* get_unit.(args)...) + try + unit(result) + catch + throw(ValidationError("Unable to get unit for operation $op with arguments $args.")) + end +end + +function get_unit(op::Integral, args) + unit = 1 + if op.domain.variables isa Vector + for u in op.domain.variables + unit *= get_unit(u) + end + else + unit *= get_unit(op.domain.variables) + end + return get_unit(args[1]) * unit +end + +function get_unit(op::Conditional, args) + terms = get_unit.(args) + terms[1] == unitless || + throw(ValidationError(", in $op, [$(terms[1])] is not dimensionless.")) + equivalent(terms[2], terms[3]) || + throw(ValidationError(", in $op, units [$(terms[2])] and [$(terms[3])] do not match.")) + return terms[2] +end + +function get_unit(op::typeof(Symbolics._mapreduce), args) + if args[2] == + + get_unit(args[3]) + else + throw(ValidationError("Unsupported array operation $op")) + end +end + +function get_unit(op::Comparison, args) + terms = get_unit.(args) + equivalent(terms[1], terms[2]) || + throw(ValidationError(", in comparison $op, units [$(terms[1])] and [$(terms[2])] do not match.")) + return unitless +end + +function get_unit(x::Symbolic) + if issym(x) + get_literal_unit(x) + elseif isadd(x) + terms = get_unit.(arguments(x)) + firstunit = terms[1] + for other in terms[2:end] + termlist = join(map(repr, terms), ", ") + equivalent(other, firstunit) || + throw(ValidationError(", in sum $x, units [$termlist] do not match.")) + end + return firstunit + elseif ispow(x) + pargs = arguments(x) + base, expon = get_unit.(pargs) + @assert expon isa Unitful.DimensionlessUnits + if base == unitless + unitless + else + pargs[2] isa Number ? base^pargs[2] : (1 * base)^pargs[2] + end + elseif iscall(x) + op = operation(x) + if issym(op) || (iscall(op) && iscall(operation(op))) # Dependent variables, not function calls + return screen_unit(getmetadata(x, VariableUnit, unitless)) # Like x(t) or x[i] + elseif iscall(op) && !iscall(operation(op)) + gp = getmetadata(x, Symbolics.GetindexParent, nothing) # Like x[1](t) + return screen_unit(getmetadata(gp, VariableUnit, unitless)) + end # Actual function calls: + args = arguments(x) + return get_unit(op, args) + else # This function should only be reached by Terms, for which `iscall` is true + throw(ArgumentError("Unsupported value $x.")) + end +end + +""" +Get unit of term, returning nothing & showing warning instead of throwing errors. +""" +function safe_get_unit(term, info) + side = nothing + try + side = get_unit(term) + catch err + if err isa Unitful.DimensionError + @warn("$info: $(err.x) and $(err.y) are not dimensionally compatible.") + elseif err isa ValidationError + @warn(info*err.message) + elseif err isa MethodError + @warn("$info: no method matching $(err.f) for arguments $(typeof.(err.args)).") + else + rethrow() + end + end + side +end + +function _validate(terms::Vector, labels::Vector{String}; info::String = "") + valid = true + first_unit = nothing + first_label = nothing + for (term, label) in zip(terms, labels) + equnit = safe_get_unit(term, info * label) + if equnit === nothing + valid = false + elseif !isequal(term, 0) + if first_unit === nothing + first_unit = equnit + first_label = label + elseif !equivalent(first_unit, equnit) + valid = false + @warn("$info: units [$(first_unit)] for $(first_label) and [$(equnit)] for $(label) do not match.") + end + end + end + valid +end + +function _validate(conn::Connection; info::String = "") + valid = true + syss = get_systems(conn) + sys = first(syss) + unks = unknowns(sys) + for i in 2:length(syss) + s = syss[i] + _unks = unknowns(s) + if length(unks) != length(_unks) + valid = false + @warn("$info: connected systems $(nameof(sys)) and $(nameof(s)) have $(length(unks)) and $(length(_unks)) unknowns, cannot connect.") + continue + end + for (i, x) in enumerate(unks) + j = findfirst(isequal(x), _unks) + if j == nothing + valid = false + @warn("$info: connected systems $(nameof(sys)) and $(nameof(s)) do not have the same unknowns.") + else + aunit = safe_get_unit(x, info * string(nameof(sys)) * "#$i") + bunit = safe_get_unit(_unks[j], info * string(nameof(s)) * "#$j") + if !equivalent(aunit, bunit) + valid = false + @warn("$info: connected system unknowns $x and $(_unks[j]) have mismatched units.") + end + end + end + end + valid +end + +function validate(jump::Union{MT.VariableRateJump, + MT.ConstantRateJump}, t::Symbolic; + info::String = "") + newinfo = replace(info, "eq." => "jump") + _validate([jump.rate, 1 / t], ["rate", "1/t"], info = newinfo) && # Assuming the rate is per time units + validate(jump.affect!, info = newinfo) +end + +function validate(jump::MT.MassActionJump, t::Symbolic; info::String = "") + left_symbols = [x[1] for x in jump.reactant_stoch] #vector of pairs of symbol,int -> vector symbols + net_symbols = [x[1] for x in jump.net_stoch] + all_symbols = vcat(left_symbols, net_symbols) + allgood = _validate(all_symbols, string.(all_symbols); info) + n = sum(x -> x[2], jump.reactant_stoch, init = 0) + base_unitful = all_symbols[1] #all same, get first + allgood && _validate([jump.scaled_rates, 1 / (t * base_unitful^n)], + ["scaled_rates", "1/(t*reactants^$n))"]; info) +end + +function validate(jumps::ArrayPartition{<:Union{Any, Vector{<:JumpType}}}, t::Symbolic) + labels = ["in Mass Action Jumps,", "in Constant Rate Jumps,", "in Variable Rate Jumps,"] + all([validate(jumps.x[idx], t, info = labels[idx]) for idx in 1:3]) +end + +function validate(eq::MT.Equation; info::String = "") + if typeof(eq.lhs) == Connection + _validate(eq.rhs; info) + else + _validate([eq.lhs, eq.rhs], ["left", "right"]; info) + end +end +function validate(eq::MT.Equation, + term::Union{Symbolic, Unitful.Quantity, Num}; info::String = "") + _validate([eq.lhs, eq.rhs, term], ["left", "right", "noise"]; info) +end +function validate(eq::MT.Equation, terms::Vector; info::String = "") + _validate(vcat([eq.lhs, eq.rhs], terms), + vcat(["left", "right"], "noise #" .* string.(1:length(terms))); info) +end + +""" +Returns true iff units of equations are valid. +""" +function validate(eqs::Vector; info::String = "") + all([validate(eqs[idx], info = info * " in eq. #$idx") for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, noise::Vector; info::String = "") + all([validate(eqs[idx], noise[idx], info = info * " in eq. #$idx") + for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, noise::Matrix; info::String = "") + all([validate(eqs[idx], noise[idx, :], info = info * " in eq. #$idx") + for idx in 1:length(eqs)]) +end +function validate(eqs::Vector, term::Symbolic; info::String = "") + all([validate(eqs[idx], term, info = info * " in eq. #$idx") for idx in 1:length(eqs)]) +end +validate(term::Symbolics.SymbolicUtils.Symbolic) = safe_get_unit(term, "") !== nothing + +""" +Throws error if units of equations are invalid. +""" +function MT.check_units(::Val{:Unitful}, eqs...) + validate(eqs...) || + throw(ValidationError("Some equations had invalid units. See warnings for details.")) +end + +end # module diff --git a/src/utils.jl b/src/utils.jl index 3d745b24f8..1884a91c19 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,3 +1,16 @@ +""" + union_nothing(x::Union{T1, Nothing}, y::Union{T2, Nothing}) where {T1, T2} + +Unite x and y gracefully when they could be nothing. If neither is nothing, x and y are united normally. If one is nothing, the other is returned unmodified. If both are nothing, nothing is returned. +""" +function union_nothing(x::Union{T1, Nothing}, y::Union{T2, Nothing}) where {T1, T2} + isnothing(x) && return y # y can be nothing or something + isnothing(y) && return x # x can be nothing or something + return union(x, y) # both x and y are something and can be united normally +end + +get_iv(D::Differential) = D.x + function make_operation(@nospecialize(op), args) if op === (*) args = filter(!_isone, args) @@ -14,26 +27,27 @@ function make_operation(@nospecialize(op), args) end function detime_dvs(op) - if !istree(op) + if !iscall(op) op - elseif operation(op) isa Sym + elseif issym(operation(op)) Sym{Real}(nameof(operation(op))) else - similarterm(op, operation(op),detime_dvs.(arguments(op))) + maketerm(typeof(op), operation(op), detime_dvs.(arguments(op)), + metadata(op)) end end -function retime_dvs(op::Sym,dvs,iv) - Sym{FnType{Tuple{symtype(iv)}, Real}}(nameof(op))(iv) -end - function retime_dvs(op, dvs, iv) - istree(op) ? - similarterm(op, operation(op), retime_dvs.(arguments(op),(dvs,),(iv,))) : - op + issym(op) && return Sym{FnType{Tuple{symtype(iv)}, Real}}(nameof(op))(iv) + iscall(op) ? + maketerm(typeof(op), operation(op), retime_dvs.(arguments(op), (dvs,), (iv,)), + metadata(op)) : + op end -modified_states!(mstates, e::Equation, statelist=nothing) = get_variables!(mstates, e.lhs, statelist) +function modified_unknowns!(munknowns, e::Equation, unknownlist = nothing) + get_variables!(munknowns, e.lhs, unknownlist) +end macro showarr(x) n = string(x) @@ -46,31 +60,7 @@ macro showarr(x) end end -@deprecate substitute_expr!(expr,s) substitute(expr,s) - -function states_to_sym(states::Set) - function _states_to_sym(O) - if O isa Equation - Expr(:(=), _states_to_sym(O.lhs), _states_to_sym(O.rhs)) - elseif istree(O) - op = operation(O) - args = arguments(O) - if op isa Sym - O in states && return tosymbol(O) - # dependent variables - return build_expr(:call, Any[nameof(op); _states_to_sym.(args)]) - else - canonical, O = canonicalexpr(O) - return canonical ? O : build_expr(:call, Any[op; _states_to_sym.(args)]) - end - elseif O isa Num - return _states_to_sym(value(O)) - else - return toexpr(O) - end - end -end -states_to_sym(states) = states_to_sym(Set(states)) +@deprecate substitute_expr!(expr, s) substitute(expr, s) function todict(d) eltype(d) <: Pair || throw(ArgumentError("The variable-value mapping must be a Dict.")) @@ -79,12 +69,6 @@ end _merge(d1, d2) = merge(todict(d1), todict(d2)) -function indepvar2depvar(s::Sym, args...) - T = FnType{NTuple{length(args)}, symtype(s)} - ns = Sym{T}(nameof(s))(args...) - @set! ns.metadata = s.metadata -end - function _readable_code(ex) ex isa Expr || return ex if ex.head === :call @@ -103,4 +87,1215 @@ function _readable_code(ex) end expr end -readable_code(expr) = JuliaFormatter.format_text(string(Base.remove_linenums!(_readable_code(expr)))) + +function rec_remove_macro_linenums!(expr) + if expr isa Expr + if expr.head === :macrocall + expr.args[2] = nothing + rec_remove_macro_linenums!(expr.args[3]) + else + for ex in expr.args + rec_remove_macro_linenums!(ex) + end + end + end + expr +end +function readable_code(expr) + expr = Base.remove_linenums!(_readable_code(expr)) + rec_remove_macro_linenums!(expr) + JuliaFormatter.format_text(string(expr), JuliaFormatter.SciMLStyle()) +end + +# System validation enums +const CheckNone = 0 +const CheckAll = 1 << 0 +const CheckComponents = 1 << 1 +const CheckUnits = 1 << 2 + +function check_independent_variables(ivs) + for iv in ivs + isparameter(iv) || + @warn "Independent variable $iv should be defined with @independent_variables $iv." + end +end + +function check_parameters(ps, iv) + for p in ps + isequal(iv, p) && + throw(ArgumentError("Independent variable $iv not allowed in parameters.")) + isparameter(p) || + throw(ArgumentError("$p is not a parameter.")) + end +end + +function is_delay_var(iv, var) + args = nothing + try + args = arguments(var) + catch + return false + end + length(args) > 1 && return false + isequal(first(args), iv) && return false + delay = iv - first(args) + delay isa Integer || + delay isa AbstractFloat || + (delay isa Num && isreal(value(delay))) +end + +function check_variables(dvs, iv) + for dv in dvs + isequal(iv, dv) && + throw(ArgumentError("Independent variable $iv not allowed in dependent variables.")) + (is_delay_var(iv, dv) || occursin(iv, dv)) || + throw(ArgumentError("Variable $dv is not a function of independent variable $iv.")) + isparameter(dv) && + throw(ArgumentError("$dv is not an unknown. It is a parameter.")) + end +end + +function check_lhs(eq::Equation, op, dvs::Set) + v = unwrap(eq.lhs) + _iszero(v) && return + (operation(v) isa op && only(arguments(v)) in dvs) && return + error("$v is not a valid LHS. Please run structural_simplify before simulation.") +end +check_lhs(eqs, op, dvs::Set) = + for eq in eqs + check_lhs(eq, op, dvs) + end + +""" + collect_ivs(eqs, op = Differential) + +Get all the independent variables with respect to which differentials (`op`) are taken. +""" +function collect_ivs(eqs, op = Differential) + vars = Set() + ivs = Set() + for eq in eqs + vars!(vars, eq; op = op) + for v in vars + if isoperator(v, op) + collect_ivs_from_nested_operator!(ivs, v, op) + end + end + empty!(vars) + end + return ivs +end + +""" + check_equations(eqs, iv) + +Assert that equations are well-formed when building ODE, i.e., only containing a single independent variable. +""" +function check_equations(eqs, iv) + ivs = collect_ivs(eqs) + display = collect(ivs) + length(ivs) <= 1 || + throw(ArgumentError("Differential w.r.t. multiple variables $display are not allowed.")) + if length(ivs) == 1 + single_iv = pop!(ivs) + isequal(single_iv, iv) || + throw(ArgumentError("Differential w.r.t. variable ($single_iv) other than the independent variable ($iv) are not allowed.")) + end +end + +""" + $(TYPEDSIGNATURES) + +Assert that the subsystems have the appropriate namespacing behavior. +""" +function check_subsystems(systems) + idxs = findall(!does_namespacing, systems) + if !isempty(idxs) + names = join(" " .* string.(nameof.(systems[idxs])), "\n") + throw(ArgumentError("All subsystems must have namespacing enabled. The following subsystems do not perform namespacing:\n$(names)")) + end +end + +""" +Get all the independent variables with respect to which differentials are taken. +""" +function collect_ivs_from_nested_operator!(ivs, x, target_op) + if !iscall(x) + return + end + op = operation(unwrap(x)) + if op isa target_op + push!(ivs, get_iv(op)) + x = if target_op <: Differential + op.x + else + error("Unknown target op type in collect_ivs $target_op. Pass Differential") + end + collect_ivs_from_nested_operator!(ivs, x, target_op) + end +end + +function iv_from_nested_derivative(x, op = Differential) + if iscall(x) && + (operation(x) == getindex || operation(x) == real || operation(x) == imag) + iv_from_nested_derivative(arguments(x)[1], op) + elseif iscall(x) + operation(x) isa op ? iv_from_nested_derivative(arguments(x)[1], op) : + arguments(x)[1] + elseif issym(x) + x + else + nothing + end +end + +hasdefault(v) = hasmetadata(v, Symbolics.VariableDefaultValue) +getdefault(v) = value(Symbolics.getdefaultval(v)) +function getdefaulttype(v) + def = value(getmetadata(unwrap(v), Symbolics.VariableDefaultValue, nothing)) + def === nothing ? Float64 : typeof(def) +end +function setdefault(v, val) + val === nothing ? v : wrap(setdefaultval(unwrap(v), value(val))) +end + +function process_variables!(var_to_name, defs, guesses, vars) + collect_defaults!(defs, vars) + collect_guesses!(guesses, vars) + collect_var_to_name!(var_to_name, vars) + return nothing +end + +function process_variables!(var_to_name, defs, vars) + collect_defaults!(defs, vars) + collect_var_to_name!(var_to_name, vars) + return nothing +end + +function collect_defaults!(defs, vars) + for v in vars + symbolic_type(v) == NotSymbolic() && continue + if haskey(defs, v) || !hasdefault(unwrap(v)) || (def = getdefault(v)) === nothing + continue + end + defs[v] = getdefault(v) + end + return defs +end + +function collect_guesses!(guesses, vars) + for v in vars + symbolic_type(v) == NotSymbolic() && continue + if haskey(guesses, v) || !hasguess(unwrap(v)) || (def = getguess(v)) === nothing + continue + end + guesses[v] = getguess(v) + end + return guesses +end + +function collect_var_to_name!(vars, xs) + for x in xs + symbolic_type(x) == NotSymbolic() && continue + x = unwrap(x) + if hasmetadata(x, Symbolics.GetindexParent) + xarr = getmetadata(x, Symbolics.GetindexParent) + hasname(xarr) || continue + vars[Symbolics.getname(xarr)] = xarr + else + if iscall(x) && operation(x) === getindex + x = arguments(x)[1] + end + x = unwrap(x) + hasname(x) || continue + vars[Symbolics.getname(unwrap(x))] = x + end + end +end + +""" +Throw error when difference/derivative operation occurs in the R.H.S. +""" +@noinline function throw_invalid_operator(opvar, eq, op::Type) + if op === Difference + error("The Difference operator is deprecated, use ShiftIndex instead") + elseif op === Differential + optext = "derivative" + end + msg = "The $optext variable must be isolated to the left-hand " * + "side of the equation like `$opvar ~ ...`. You may want to use `structural_simplify` or the DAE form.\nGot $eq." + throw(InvalidSystemException(msg)) +end + +""" +Check if difference/derivative operation occurs in the R.H.S. of an equation +""" +function _check_operator_variables(eq, op::T, expr = eq.rhs) where {T} + iscall(expr) || return nothing + if operation(expr) isa op + throw_invalid_operator(expr, eq, op) + end + foreach(expr -> _check_operator_variables(eq, op, expr), + SymbolicUtils.arguments(expr)) +end +""" +Check if all the LHS are unique +""" +function check_operator_variables(eqs, op::T) where {T} + ops = Set() + tmp = Set() + for eq in eqs + _check_operator_variables(eq, op) + vars!(tmp, eq.lhs) + if length(tmp) == 1 + x = only(tmp) + if op === Differential + is_tmp_fine = isdifferential(x) + else + is_tmp_fine = iscall(x) && !(operation(x) isa op) + end + else + nd = count(x -> iscall(x) && !(operation(x) isa op), tmp) + is_tmp_fine = iszero(nd) + end + is_tmp_fine || + error("The LHS cannot contain nondifferentiated variables. Please run `structural_simplify` or use the DAE form.\nGot $eq") + for v in tmp + v in ops && + error("The LHS operator must be unique. Please run `structural_simplify` or use the DAE form. $v appears in LHS more than once.") + push!(ops, v) + end + empty!(tmp) + end +end + +isoperator(expr, op) = iscall(expr) && operation(expr) isa op +isoperator(op) = expr -> isoperator(expr, op) + +isdifferential(expr) = isoperator(expr, Differential) +isdiffeq(eq) = isdifferential(eq.lhs) || isoperator(eq.lhs, Shift) + +isvariable(x::Num)::Bool = isvariable(value(x)) +function isvariable(x)::Bool + x isa Symbolic || return false + p = getparent(x, nothing) + p === nothing || (x = p) + hasmetadata(x, VariableSource) +end + +""" + vars(x; op=Differential) + +Return a `Set` containing all variables in `x` that appear in + + - differential equations if `op = Differential` + +Example: + +``` +t = ModelingToolkit.t_nounits +@variables u(t) y(t) +D = Differential(t) +v = ModelingToolkit.vars(D(y) ~ u) +v == Set([D(y), u]) +``` +""" +function vars(exprs::Symbolic; op = Differential) + iscall(exprs) ? vars([exprs]; op = op) : Set([exprs]) +end +vars(exprs::Num; op = Differential) = vars(unwrap(exprs); op) +vars(exprs::Symbolics.Arr; op = Differential) = vars(unwrap(exprs); op) +function vars(exprs; op = Differential) + if hasmethod(iterate, Tuple{typeof(exprs)}) + foldl((x, y) -> vars!(x, unwrap(y); op = op), exprs; init = Set()) + else + vars!(Set(), unwrap(exprs); op) + end +end +vars(eq::Equation; op = Differential) = vars!(Set(), eq; op = op) +function vars!(vars, eq::Equation; op = Differential) + (vars!(vars, eq.lhs; op = op); vars!(vars, eq.rhs; op = op); vars) +end +function vars!(vars, O; op = Differential) + if isvariable(O) + if iscall(O) && operation(O) === getindex && iscalledparameter(first(arguments(O))) + O = first(arguments(O)) + end + if iscalledparameter(O) + f = getcalledparameter(O) + push!(vars, f) + for arg in arguments(O) + if symbolic_type(arg) == NotSymbolic() && arg isa AbstractArray + for el in arg + vars!(vars, unwrap(el); op) + end + else + vars!(vars, arg; op) + end + end + return vars + end + return push!(vars, O) + end + if symbolic_type(O) == NotSymbolic() && O isa AbstractArray + for arg in O + vars!(vars, unwrap(arg); op) + end + return vars + end + !iscall(O) && return vars + + operation(O) isa op && return push!(vars, O) + + if operation(O) === (getindex) + arr = first(arguments(O)) + iscall(arr) && operation(arr) isa op && return push!(vars, O) + isvariable(arr) && return push!(vars, O) + end + + isvariable(operation(O)) && push!(vars, O) + for arg in arguments(O) + vars!(vars, arg; op = op) + end + + return vars +end + +function collect_operator_variables(sys::AbstractSystem, args...) + collect_operator_variables(equations(sys), args...) +end +function collect_operator_variables(eq::Equation, args...) + collect_operator_variables([eq], args...) +end + +""" + collect_operator_variables(eqs::AbstractVector{Equation}, op) + +Return a `Set` containing all variables that have Operator `op` applied to them. +See also [`collect_differential_variables`](@ref). +""" +function collect_operator_variables(eqs::AbstractVector{Equation}, op) + vars = Set() + diffvars = Set() + for eq in eqs + vars!(vars, eq; op = op) + for v in vars + isoperator(v, op) || continue + push!(diffvars, arguments(v)[1]) + end + empty!(vars) + end + return diffvars +end +collect_differential_variables(sys) = collect_operator_variables(sys, Differential) + +""" + collect_applied_operators(x, op) + +Return a `Set` with all applied operators in `x`, example: + +``` +@independent_variables t +@variables u(t) y(t) +D = Differential(t) +eq = D(y) ~ u +ModelingToolkit.collect_applied_operators(eq, Differential) == Set([D(y)]) +``` + +The difference compared to `collect_operator_variables` is that `collect_operator_variables` returns the variable without the operator applied. +""" +function collect_applied_operators(x, op) + v = vars(x, op = op) + filter(v) do x + issym(x) && return false + iscall(x) && return operation(x) isa op + false + end +end + +function find_derivatives!(vars, expr::Equation, f = identity) + (find_derivatives!(vars, expr.lhs, f); find_derivatives!(vars, expr.rhs, f); vars) +end +function find_derivatives!(vars, expr, f) + !iscall(O) && return vars + operation(O) isa Differential && push!(vars, f(O)) + for arg in arguments(O) + vars!(vars, arg) + end + return vars +end + +""" + $(TYPEDSIGNATURES) + +Search through equations and parameter dependencies of `sys`, where sys is at a depth of +`depth` from the root system, looking for variables scoped to the root system. Also +recursively searches through all subsystems of `sys`, increasing the depth if it is not +`-1`. A depth of `-1` indicates searching for variables with `GlobalScope`. +""" +function collect_scoped_vars!(unknowns, parameters, sys, iv; depth = 1, op = Differential) + if has_eqs(sys) + for eq in equations(sys) + eqtype_supports_collect_vars(eq) || continue + if eq isa Equation + eq.lhs isa Union{Symbolic, Number} || continue + end + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + if has_parameter_dependencies(sys) + for eq in parameter_dependencies(sys) + if eq isa Pair + collect_vars!(unknowns, parameters, eq, iv; depth, op) + else + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + end + if has_constraints(sys) + for eq in constraints(sys) + eqtype_supports_collect_vars(eq) || continue + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + if has_op(sys) + collect_vars!(unknowns, parameters, objective(sys), iv; depth, op) + end +end + +function collect_vars!(unknowns, parameters, expr, iv; depth = 0, op = Differential) + if issym(expr) + collect_var!(unknowns, parameters, expr, iv; depth) + else + for var in vars(expr; op) + if iscall(var) && operation(var) isa Differential + var, _ = var_from_nested_derivative(var) + end + collect_var!(unknowns, parameters, var, iv; depth) + end + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Indicate whether the given equation type (Equation, Pair, etc) supports `collect_vars!`. +Can be dispatched by higher-level libraries to indicate support. +""" +eqtype_supports_collect_vars(eq) = false +eqtype_supports_collect_vars(eq::Equation) = true +eqtype_supports_collect_vars(eq::Inequality) = true +eqtype_supports_collect_vars(eq::Pair) = true + +function collect_vars!(unknowns, parameters, eq::Union{Equation, Inequality}, iv; + depth = 0, op = Differential) + collect_vars!(unknowns, parameters, eq.lhs, iv; depth, op) + collect_vars!(unknowns, parameters, eq.rhs, iv; depth, op) + return nothing +end + +function collect_vars!(unknowns, parameters, p::Pair, iv; depth = 0, op = Differential) + collect_vars!(unknowns, parameters, p[1], iv; depth, op) + collect_vars!(unknowns, parameters, p[2], iv; depth, op) + return nothing +end + +function collect_var!(unknowns, parameters, var, iv; depth = 0) + isequal(var, iv) && return nothing + check_scope_depth(getmetadata(var, SymScope, LocalScope()), depth) || return nothing + var = setmetadata(var, SymScope, LocalScope()) + if iscalledparameter(var) + callable = getcalledparameter(var) + push!(parameters, callable) + collect_vars!(unknowns, parameters, arguments(var), iv) + elseif isparameter(var) || (iscall(var) && isparameter(operation(var))) + push!(parameters, var) + elseif !isconstant(var) + push!(unknowns, var) + end + # Add also any parameters that appear only as defaults in the var + if hasdefault(var) && (def = getdefault(var)) !== missing + collect_vars!(unknowns, parameters, def, iv) + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Check if the given `scope` is at a depth of `depth` from the root system. Only +returns `true` for `scope::GlobalScope` if `depth == -1`. +""" +function check_scope_depth(scope, depth) + if scope isa LocalScope + return depth == 0 + elseif scope isa ParentScope + return depth > 0 && check_scope_depth(scope.parent, depth - 1) + elseif scope isa DelayParentScope + return depth >= scope.N && check_scope_depth(scope.parent, depth - scope.N - 1) + elseif scope isa GlobalScope + return depth == -1 + end +end + +""" +Find all the symbolic constants of some equations or terms and return them as a vector. +""" +function collect_constants(x) + constants = BasicSymbolic[] + collect_constants!(constants, x) + return constants +end + +collect_constants!(::Any, ::Symbol) = nothing + +function collect_constants!(constants, arr::AbstractArray) + for el in arr + collect_constants!(constants, el) + end +end + +function collect_constants!(constants, eq::Equation) + collect_constants!(constants, eq.lhs) + collect_constants!(constants, eq.rhs) +end + +function collect_constants!(constants, eq::Inequality) + collect_constants!(constants, eq.lhs) + collect_constants!(constants, eq.rhs) +end + +collect_constants!(constants, x::Num) = collect_constants!(constants, unwrap(x)) +collect_constants!(constants, x::Real) = nothing +collect_constants(n::Nothing) = BasicSymbolic[] + +function collect_constants!(constants, expr::Symbolic) + if issym(expr) && isconstant(expr) + push!(constants, expr) + else + evars = vars(expr) + if length(evars) == 1 && isequal(only(evars), expr) + return nothing #avoid infinite recursion for vars(x(t)) == [x(t)] + else + for var in evars + collect_constants!(constants, var) + end + end + end +end + +function collect_constants!(constants, expr::Union{ConstantRateJump, VariableRateJump}) + collect_constants!(constants, expr.rate) + collect_constants!(constants, expr.affect!) +end + +function collect_constants!(constants, ::MassActionJump) + return constants +end + +""" +Replace symbolic constants with their literal values +""" +function eliminate_constants(eqs, cs) + cmap = Dict(x => getdefault(x) for x in cs) + return substitute(eqs, cmap) +end + +""" +Create a function preface containing assignments of default values to constants. +""" +function get_preprocess_constants(eqs) + cs = collect_constants(eqs) + pre = ex -> Let(Assignment[Assignment(x, getdefault(x)) for x in cs], + ex, false) + return pre +end + +function get_postprocess_fbody(sys) + if has_preface(sys) && (pre = preface(sys); pre !== nothing) + pre_ = let pre = pre + ex -> Let(pre, ex, false) + end + else + pre_ = ex -> ex + end + return pre_ +end + +""" +$(SIGNATURES) + +find duplicates in an iterable object. +""" +function find_duplicates(xs, ::Val{Ret} = Val(false)) where {Ret} + appeared = Set() + duplicates = Set() + for x in xs + if x in appeared + push!(duplicates, x) + else + push!(appeared, x) + end + end + return Ret ? (appeared, duplicates) : duplicates +end + +isarray(x) = x isa AbstractArray || x isa Symbolics.Arr + +function empty_substitutions(sys) + has_substitutions(sys) || return true + subs = get_substitutions(sys) + isnothing(subs) || isempty(subs.deps) +end + +function get_cmap(sys, exprs = nothing) + #Inject substitutions for constants => values + buffer = [] + has_eqs(sys) && append!(buffer, collect(get_eqs(sys))) + has_observed(sys) && append!(buffer, collect(get_observed(sys))) + has_op(sys) && push!(buffer, get_op(sys)) + has_constraints(sys) && append!(buffer, get_constraints(sys)) + cs = collect_constants(buffer) #ctrls? what else? + if !empty_substitutions(sys) + cs = [cs; collect_constants(get_substitutions(sys).subs)] + end + if exprs !== nothing + cs = [cs; collect_constants(exprs)] + end + # Swap constants for their values + cmap = map(x -> x ~ getdefault(x), cs) + return cmap, cs +end + +function get_substitutions_and_solved_unknowns(sys, exprs = nothing; no_postprocess = false) + cmap, cs = get_cmap(sys, exprs) + if empty_substitutions(sys) && isempty(cs) + sol_states = Code.LazyState() + pre = no_postprocess ? (ex -> ex) : get_postprocess_fbody(sys) + else # Have to do some work + if !empty_substitutions(sys) + @unpack subs = get_substitutions(sys) + else + subs = [] + end + subs = [cmap; subs] # The constants need to go first + sol_states = Code.NameState(Dict(eq.lhs => Symbol(eq.lhs) for eq in subs)) + if no_postprocess + pre = ex -> Let(Assignment[Assignment(eq.lhs, eq.rhs) for eq in subs], ex, + false) + else + process = get_postprocess_fbody(sys) + pre = ex -> Let(Assignment[Assignment(eq.lhs, eq.rhs) for eq in subs], + process(ex), false) + end + end + return pre, sol_states +end + +function mergedefaults(defaults, varmap, vars) + defs = if varmap isa Dict + merge(defaults, varmap) + elseif eltype(varmap) <: Pair + merge(defaults, Dict(varmap)) + elseif eltype(varmap) <: Number + merge(defaults, Dict(zip(vars, varmap))) + else + defaults + end +end + +function mergedefaults(defaults, observedmap, varmap, vars) + defs = if varmap isa Dict + merge(observedmap, defaults, varmap) + elseif eltype(varmap) <: Pair + merge(observedmap, defaults, Dict(varmap)) + elseif eltype(varmap) <: Number + merge(observedmap, defaults, Dict(zip(vars, varmap))) + else + merge(observedmap, defaults) + end +end + +@noinline function throw_missingvars_in_sys(vars) + throw(ArgumentError("$vars are either missing from the variable map or missing from the system's unknowns/parameters list.")) +end + +function promote_to_concrete(vs; tofloat = true, use_union = true) + if isempty(vs) + return vs + end + if vs isa Tuple #special rule, if vs is a Tuple, preserve types, container converted to Array + tofloat = false + use_union = true + vs = Any[vs...] + end + T = eltype(vs) + + # return early if there is nothing to do + #Base.isconcretetype(T) && (!tofloat || T === float(T)) && return vs # TODO: disabled float(T) to restore missing errors in https://github.com/SciML/ModelingToolkit.jl/issues/2873 + Base.isconcretetype(T) && !tofloat && return vs + + sym_vs = filter(x -> SymbolicUtils.issym(x) || SymbolicUtils.iscall(x), vs) + isempty(sym_vs) || throw_missingvars_in_sys(sym_vs) + + C = nothing + for v in vs + E = typeof(v) + if E <: Number + if tofloat + E = float(E) + end + end + if C === nothing + C = E + end + if use_union + C = Union{C, E} + else + C2 = promote_type(C, E) + @assert C2 == E||C2 == C "`promote_to_concrete` can't make type $E uniform with $C" + C = C2 + end + end + + y = similar(vs, C) + for i in eachindex(vs) + if (vs[i] isa Number) & tofloat + y[i] = float(vs[i]) #needed because copyto! can't convert Int to Float automatically + else + y[i] = vs[i] + end + end + + return y +end + +struct BitDict <: AbstractDict{Int, Int} + keys::Vector{Int} + values::Vector{Union{Nothing, Int}} +end +BitDict(n::Integer) = BitDict(Int[], Union{Nothing, Int}[nothing for _ in 1:n]) +struct BitDictKeySet <: AbstractSet{Int} + d::BitDict +end + +Base.keys(d::BitDict) = BitDictKeySet(d) +Base.in(v::Integer, s::BitDictKeySet) = s.d.values[v] !== nothing +Base.iterate(s::BitDictKeySet, state...) = iterate(s.d.keys, state...) +function Base.setindex!(d::BitDict, val::Integer, ind::Integer) + if 1 <= ind <= length(d.values) && d.values[ind] === nothing + push!(d.keys, ind) + end + d.values[ind] = val +end +function Base.getindex(d::BitDict, ind::Integer) + if 1 <= ind <= length(d.values) && d.values[ind] === nothing + return d.values[ind] + else + throw(KeyError(ind)) + end +end +function Base.iterate(d::BitDict, state...) + r = Base.iterate(d.keys, state...) + r === nothing && return nothing + k, state = r + (k => d.values[k]), state +end +function Base.empty!(d::BitDict) + for v in d.keys + d.values[v] = nothing + end + empty!(d.keys) + d +end + +abstract type AbstractSimpleTreeIter{T} end +Base.IteratorSize(::Type{<:AbstractSimpleTreeIter}) = Base.SizeUnknown() +Base.eltype(::Type{<:AbstractSimpleTreeIter{T}}) where {T} = childtype(T) +has_fast_reverse(::Type{<:AbstractSimpleTreeIter}) = true +has_fast_reverse(::T) where {T <: AbstractSimpleTreeIter} = has_fast_reverse(T) +reverse_buffer(it::AbstractSimpleTreeIter) = has_fast_reverse(it) ? nothing : eltype(it)[] +reverse_children!(::Nothing, cs) = Iterators.reverse(cs) +function reverse_children!(rev_buff, cs) + Iterators.reverse(cs) + empty!(rev_buff) + for c in cs + push!(rev_buff, c) + end + Iterators.reverse(rev_buff) +end + +struct StatefulPreOrderDFS{T} <: AbstractSimpleTreeIter{T} + t::T +end +function Base.iterate(it::StatefulPreOrderDFS, + state = (eltype(it)[it.t], reverse_buffer(it))) + stack, rev_buff = state + isempty(stack) && return nothing + t = pop!(stack) + for c in reverse_children!(rev_buff, children(t)) + push!(stack, c) + end + return t, state +end +struct StatefulPostOrderDFS{T} <: AbstractSimpleTreeIter{T} + t::T +end +function Base.iterate(it::StatefulPostOrderDFS, + state = (eltype(it)[it.t], falses(1), reverse_buffer(it))) + isempty(state[2]) && return nothing + vstack, sstack, rev_buff = state + while true + t = pop!(vstack) + isresume = pop!(sstack) + isresume && return t, state + push!(vstack, t) + push!(sstack, true) + for c in reverse_children!(rev_buff, children(t)) + push!(vstack, c) + push!(sstack, false) + end + end +end + +# Note that StatefulBFS also returns the depth. +struct StatefulBFS{T} <: AbstractSimpleTreeIter{T} + t::T +end +Base.eltype(::Type{<:StatefulBFS{T}}) where {T} = Tuple{Int, childtype(T)} +function Base.iterate(it::StatefulBFS, queue = (eltype(it)[(0, it.t)])) + isempty(queue) && return nothing + lv, t = popfirst!(queue) + nextlv = lv + 1 + for c in children(t) + push!(queue, (nextlv, c)) + end + return (lv, t), queue +end + +function fold_constants(ex) + if iscall(ex) + maketerm(typeof(ex), operation(ex), map(fold_constants, arguments(ex)), + metadata(ex)) + elseif issym(ex) && isconstant(ex) + if (unit = getmetadata(ex, VariableUnit, nothing); unit !== nothing) + ex # we cannot fold constant with units + else + getdefault(ex) + end + else + ex + end +end + +normalize_to_differential(s) = s + +function restrict_array_to_union(arr) + isempty(arr) && return arr + T = foldl(arr; init = Union{}) do prev, cur + Union{prev, typeof(cur)} + end + return Array{T, ndims(arr)}(arr) +end + +function eval_or_rgf(expr::Expr; eval_expression = false, eval_module = @__MODULE__) + if eval_expression + return eval_module.eval(expr) + else + return drop_expr(RuntimeGeneratedFunction(eval_module, eval_module, expr)) + end +end + +function _with_unit(f, x, t, args...) + x = f(x, args...) + if hasmetadata(x, VariableUnit) && (t isa Symbolic && hasmetadata(t, VariableUnit)) + xu = getmetadata(x, VariableUnit) + tu = getmetadata(t, VariableUnit) + x = setmetadata(x, VariableUnit, xu / tu) + end + return x +end + +diff2term_with_unit(x, t) = _with_unit(diff2term, x, t) +lower_varname_with_unit(var, iv, order) = _with_unit(lower_varname, var, iv, iv, order) +shift2term_with_unit(x, t) = _with_unit(shift2term, x, t) +lower_shift_varname_with_unit(var, iv) = _with_unit(lower_shift_varname, var, iv, iv) + +""" + $(TYPEDSIGNATURES) + +Check if `sym` represents a symbolic floating point number or array of such numbers. +""" +function is_variable_floatingpoint(sym) + sym = unwrap(sym) + T = symtype(sym) + return T == Real || T <: AbstractFloat || T <: AbstractArray{Real} || + T <: AbstractArray{<:AbstractFloat} +end + +""" + $(TYPEDSIGNATURES) + +Return the `DiCMOBiGraph` denoting the dependencies between observed equations `eqs`. +""" +function observed_dependency_graph(eqs::Vector{Equation}) + for eq in eqs + if symbolic_type(eq.lhs) == NotSymbolic() + error("All equations must be observed equations of the form `var ~ expr`. Got $eq") + end + end + graph, assigns = observed2graph(eqs, getproperty.(eqs, (:lhs,))) + matching = complete(Matching(Vector{Union{Unassigned, Int}}(assigns))) + return DiCMOBiGraph{false}(graph, matching) +end + +""" + $(TYPEDSIGNATURES) + +Return the indexes of observed equations of `sys` used by expression `exprs`. + +Keyword arguments: +- `involved_vars`: A collection of the variables involved in `exprs`. This is the set of + variables which will be explored to find dependencies on observed equations. Typically, + providing this keyword is not necessary and is only useful to avoid repeatedly calling + `vars(exprs)` +- `obs`: the list of observed equations. +- `available_vars`: If `exprs` involves a variable `x[1]`, this function will look for + observed equations whose LHS is `x[1]` OR `x`. Sometimes, the latter is not required + since `x[1]` might already be present elsewhere in the generated code (e.g. an argument + to the function) but other elements of `x` are part of the observed equations, thus + requiring them to be obtained from the equation for `x`. Any variable present in + `available_vars` will not be searched for in the observed equations. +""" +function observed_equations_used_by(sys::AbstractSystem, exprs; + involved_vars = vars(exprs; op = Union{Shift, Differential}), obs = observed(sys), available_vars = []) + obsvars = getproperty.(obs, :lhs) + graph = observed_dependency_graph(obs) + if !(available_vars isa Set) + available_vars = Set(available_vars) + end + + obsidxs = BitSet() + for sym in involved_vars + sym in available_vars && continue + arrsym = iscall(sym) && operation(sym) === getindex ? arguments(sym)[1] : nothing + idx = findfirst(v -> isequal(v, sym) || isequal(v, arrsym), obsvars) + idx === nothing && continue + idx in obsidxs && continue + parents = dfs_parents(graph, idx) + for i in eachindex(parents) + parents[i] == 0 && continue + push!(obsidxs, i) + end + end + + obsidxs = collect(obsidxs) + sort!(obsidxs) + return obsidxs +end + +""" + $(TYPEDSIGNATURES) + +Given an expression `expr`, return a dictionary mapping subexpressions of `expr` that do +not involve variables in `vars` to anonymous symbolic variables. Also return the modified +`expr` with the substitutions indicated by the dictionary. If `expr` is a function +of only `vars`, then all of the returned subexpressions can be precomputed. + +Note that this will only process subexpressions floating point value. Additionally, +array variables must be passed in both scalarized and non-scalarized forms in `vars`. +""" +function subexpressions_not_involving_vars(expr, vars) + expr = unwrap(expr) + vars = map(unwrap, vars) + state = Dict() + newexpr = subexpressions_not_involving_vars!(expr, vars, state) + return state, newexpr +end + +""" + $(TYPEDSIGNATURES) + +Mutating version of `subexpressions_not_involving_vars` which writes to `state`. Only +returns the modified `expr`. +""" +function subexpressions_not_involving_vars!(expr, vars, state::Dict{Any, Any}) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + if is_array_of_symbolics(expr) + return map(expr) do el + subexpressions_not_involving_vars!(el, vars, state) + end + end + return expr + end + any(isequal(expr), vars) && return expr + iscall(expr) || return expr + Symbolics.shape(expr) == Symbolics.Unknown() && return expr + haskey(state, expr) && return state[expr] + op = operation(expr) + args = arguments(expr) + # if this is a `getindex` and the getindex-ed value is a `Sym` + # or it is not a called parameter + # OR + # none of `vars` are involved in `expr` + if op === getindex && (issym(args[1]) || !iscalledparameter(args[1])) || + (vs = ModelingToolkit.vars(expr); intersect!(vs, vars); isempty(vs)) + sym = gensym(:subexpr) + stype = symtype(expr) + var = similar_variable(expr, sym) + state[expr] = var + return var + end + + if (op == (+) || op == (*)) && symbolic_type(expr) !== ArraySymbolic() + indep_args = [] + dep_args = [] + for arg in args + _vs = ModelingToolkit.vars(arg) + intersect!(_vs, vars) + if !isempty(_vs) + push!(dep_args, subexpressions_not_involving_vars!(arg, vars, state)) + else + push!(indep_args, arg) + end + end + indep_term = reduce(op, indep_args; init = Int(op == (*))) + indep_term = subexpressions_not_involving_vars!(indep_term, vars, state) + dep_term = reduce(op, dep_args; init = Int(op == (*))) + return op(indep_term, dep_term) + end + newargs = map(args) do arg + subexpressions_not_involving_vars!(arg, vars, state) + end + return maketerm(typeof(expr), op, newargs, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Create an anonymous symbolic variable of the same shape, size and symtype as `var`, with +name `gensym(name)`. Does not support unsized array symbolics. + +If `use_gensym == false`, will not `gensym` the name. +""" +function similar_variable(var::BasicSymbolic, name = :anon; use_gensym = true) + if use_gensym + name = gensym(name) + end + stype = symtype(var) + sym = Symbolics.variable(name; T = stype) + if size(var) !== () + sym = setmetadata(sym, Symbolics.ArrayShapeCtx, map(Base.OneTo, size(var))) + end + return sym +end + +function guesses_from_metadata!(guesses, vars) + varguesses = [getguess(v) for v in vars] + hasaguess = findall(!isnothing, varguesses) + for i in hasaguess + haskey(guesses, vars[i]) && continue + guesses[vars[i]] = varguesses[i] + end +end + +""" + $(TYPEDSIGNATURES) + +Find all the unknowns and parameters from the equations of a SDESystem or ODESystem. Return re-ordered equations, differential variables, all variables, and parameters. +""" +function process_equations(eqs, iv) + if eltype(eqs) <: AbstractVector + eqs = reduce(vcat, eqs) + end + eqs = collect(eqs) + + diffvars = OrderedSet() + allunknowns = OrderedSet() + ps = OrderedSet() + + # NOTE: this assumes that the order of algebraic equations doesn't matter + # reorder equations such that it is in the form of `diffeq, algeeq` + diffeq = Equation[] + algeeq = Equation[] + # initial loop for finding `iv` + if iv === nothing + for eq in eqs + if !(eq.lhs isa Number) # assume eq.lhs is either Differential or Number + iv = iv_from_nested_derivative(eq.lhs) + break + end + end + end + iv = value(iv) + iv === nothing && throw(ArgumentError("Please pass in independent variables.")) + + compressed_eqs = Equation[] # equations that need to be expanded later, like `connect(a, b)` + for eq in eqs + eq.lhs isa Union{Symbolic, Number} || (push!(compressed_eqs, eq); continue) + collect_vars!(allunknowns, ps, eq, iv) + if isdiffeq(eq) + diffvar, _ = var_from_nested_derivative(eq.lhs) + if check_scope_depth(getmetadata(diffvar, SymScope, LocalScope()), 0) + isequal(iv, iv_from_nested_derivative(eq.lhs)) || + throw(ArgumentError("An ODESystem can only have one independent variable.")) + diffvar in diffvars && + throw(ArgumentError("The differential variable $diffvar is not unique in the system of equations.")) + !has_diffvar_type(diffvar) && + throw(ArgumentError("Differential variable $diffvar has type $(symtype(diffvar)). Differential variables should be of a continuous, non-concrete number type: Real, Complex, AbstractFloat, or Number.")) + push!(diffvars, diffvar) + end + push!(diffeq, eq) + else + push!(algeeq, eq) + end + end + + diffvars, allunknowns, ps, Equation[diffeq; algeeq; compressed_eqs] +end + +function has_diffvar_type(diffvar) + st = symtype(diffvar) + st === Real || eltype(st) === Real || st === Complex || eltype(st) === Complex || + st === Number || eltype(st) === Number || st === AbstractFloat || + eltype(st) === AbstractFloat +end + +""" + $(TYPEDSIGNATURES) + +If `sym isa Symbol`, try and convert it to a symbolic by matching against symbolic +variables in `allsyms`. If `sym` is not a `Symbol` or no match was found, return +`sym` as-is. +""" +function symbol_to_symbolic(sys::AbstractSystem, sym; allsyms = all_symbols(sys)) + sym isa Symbol || return sym + idx = findfirst(x -> (hasname(x) ? getname(x) : Symbol(x)) == sym, allsyms) + idx === nothing && return sym + sym = allsyms[idx] + if iscall(sym) && operation(sym) == getindex + sym = arguments(sym)[1] + end + return sym +end + +""" + $(TYPEDSIGNATURES) + +Check if `var` is present in `varlist`. `iv` is the independent variable of the system, +and should be `nothing` if not applicable. +""" +function var_in_varlist(var, varlist::AbstractSet, iv) + var = unwrap(var) + # simple case + return var in varlist || + # indexed array symbolic, unscalarized array present + (iscall(var) && operation(var) === getindex && arguments(var)[1] in varlist) || + # unscalarized sized array symbolic, all scalarized elements present + (symbolic_type(var) == ArraySymbolic() && is_sized_array_symbolic(var) && + all(x -> x in varlist, collect(var))) || + # delayed variables + (isdelay(var, iv) && var_in_varlist(operation(var)(iv), varlist, iv)) +end diff --git a/src/variables.jl b/src/variables.jl index e09104bbef..f3dd16819d 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -1,7 +1,153 @@ struct VariableUnit end struct VariableConnectType end +struct VariableNoiseType end +struct VariableInput end +struct VariableOutput end +struct VariableIrreducible end +struct VariableStatePriority end +struct VariableMisc end +# Metadata for renamed shift variables xₜ₋₁ +struct VariableUnshifted end +struct VariableShift end Symbolics.option_to_metadata_type(::Val{:unit}) = VariableUnit Symbolics.option_to_metadata_type(::Val{:connect}) = VariableConnectType +Symbolics.option_to_metadata_type(::Val{:input}) = VariableInput +Symbolics.option_to_metadata_type(::Val{:output}) = VariableOutput +Symbolics.option_to_metadata_type(::Val{:irreducible}) = VariableIrreducible +Symbolics.option_to_metadata_type(::Val{:state_priority}) = VariableStatePriority +Symbolics.option_to_metadata_type(::Val{:misc}) = VariableMisc +Symbolics.option_to_metadata_type(::Val{:unshifted}) = VariableUnshifted +Symbolics.option_to_metadata_type(::Val{:shift}) = VariableShift + +""" + dump_variable_metadata(var) + +Return all the metadata associated with symbolic variable `var` as a `NamedTuple`. + +```@example +using ModelingToolkit + +@parameters p::Int [description = "My description", bounds = (0.5, 1.5)] +ModelingToolkit.dump_variable_metadata(p) +``` +""" +function dump_variable_metadata(var) + uvar = unwrap(var) + variable_source, name = Symbolics.getmetadata( + uvar, VariableSource, (:unknown, :unknown)) + type = symtype(uvar) + if type <: AbstractArray + shape = Symbolics.shape(var) + if shape == () + shape = nothing + end + else + shape = nothing + end + unit = getunit(uvar) + connect = getconnect(uvar) + input = isinput(uvar) || nothing + output = isoutput(uvar) || nothing + irreducible = isirreducible(var) + state_priority = Symbolics.getmetadata(uvar, VariableStatePriority, nothing) + misc = getmisc(uvar) + bounds = hasbounds(uvar) ? getbounds(uvar) : nothing + desc = getdescription(var) + if desc == "" + desc = nothing + end + default = hasdefault(uvar) ? getdefault(uvar) : nothing + guess = getguess(uvar) + disturbance = isdisturbance(uvar) || nothing + tunable = istunable(uvar, isparameter(uvar)) + dist = getdist(uvar) + variable_type = getvariabletype(uvar) + + meta = ( + var = var, + variable_source, + name, + variable_type, + shape, + unit, + connect, + input, + output, + irreducible, + state_priority, + misc, + bounds, + desc, + guess, + disturbance, + tunable, + dist, + type, + default + ) + + return NamedTuple(k => v for (k, v) in pairs(meta) if v !== nothing) +end + +### Connect +abstract type AbstractConnectType end +struct Equality <: AbstractConnectType end # Equality connection +struct Flow <: AbstractConnectType end # sum to 0 +struct Stream <: AbstractConnectType end # special stream connector + +""" + getconnect(x) + +Get the connect type of x. See also [`hasconnect`](@ref). +""" +getconnect(x::Num) = getconnect(unwrap(x)) +getconnect(x::Symbolic) = Symbolics.getmetadata(x, VariableConnectType, nothing) +""" + hasconnect(x) + +Determine whether variable `x` has a connect type. See also [`getconnect`](@ref). +""" +hasconnect(x) = getconnect(x) !== nothing +function setconnect(x, t::Type{T}) where {T <: AbstractConnectType} + setmetadata(x, VariableConnectType, t) +end + +### Input, Output, Irreducible +isvarkind(m, x::Union{Num, Symbolics.Arr}) = isvarkind(m, value(x)) +function isvarkind(m, x) + iskind = getmetadata(x, m, nothing) + iskind !== nothing && return iskind + x = getparent(x, x) + getmetadata(x, m, false) +end + +setinput(x, v::Bool) = setmetadata(x, VariableInput, v) +setoutput(x, v::Bool) = setmetadata(x, VariableOutput, v) +setio(x, i::Bool, o::Bool) = setoutput(setinput(x, i), o) + +isinput(x) = isvarkind(VariableInput, x) +isoutput(x) = isvarkind(VariableOutput, x) + +# Before the solvability check, we already have handled IO variables, so +# irreducibility is independent from IO. +isirreducible(x) = isvarkind(VariableIrreducible, x) +setirreducible(x, v::Bool) = setmetadata(x, VariableIrreducible, v) +state_priority(x::Union{Num, Symbolics.Arr}) = state_priority(unwrap(x)) +state_priority(x) = convert(Float64, getmetadata(x, VariableStatePriority, 0.0))::Float64 + +function default_toterm(x) + if iscall(x) && (op = operation(x)) isa Operator + if !(op isa Differential) + if op isa Shift && op.steps < 0 + return shift2term(x) + end + x = normalize_to_differential(op)(arguments(x)...) + end + Symbolics.diff2term(x) + else + x + end +end """ $(SIGNATURES) @@ -10,13 +156,18 @@ Takes a list of pairs of `variables=>values` and an ordered list of variables and creates the array of values in the correct order with default values when applicable. """ -function varmap_to_vars(varmap, varlist; defaults=Dict(), check=true, toterm=Symbolics.diff2term) +function varmap_to_vars(varmap, varlist; defaults = Dict(), check = true, + toterm = default_toterm, promotetoconcrete = nothing, + tofloat = true, use_union = true) + varlist = collect(map(unwrap, varlist)) + # Edge cases where one of the arguments is effectively empty. - is_incomplete_initialization = varmap isa DiffEqBase.NullParameters || varmap === nothing + is_incomplete_initialization = varmap isa DiffEqBase.NullParameters || + varmap === nothing if is_incomplete_initialization || isempty(varmap) if isempty(defaults) if !is_incomplete_initialization && check - isempty(varlist) || throw_missingvars(varlist) + isempty(varlist) || throw(MissingVariablesError(varlist)) end return nothing else @@ -24,16 +175,25 @@ function varmap_to_vars(varmap, varlist; defaults=Dict(), check=true, toterm=Sym end end - T = typeof(varmap) - # We respect the input type - container_type = T <: Dict ? Array : T + # We respect the input type if it's a static array + # otherwise canonicalize to a normal array + # container_type = T <: Union{Dict,Tuple} ? Array : T + if varmap isa StaticArray + container_type = typeof(varmap) + else + container_type = Array + end - if eltype(varmap) <: Pair # `varmap` is a dict or an array of pairs + vals = if eltype(varmap) <: Pair # `varmap` is a dict or an array of pairs varmap = todict(varmap) - rules = Dict(varmap) - vals = _varmap_to_vars(varmap, varlist; defaults=defaults, check=check, toterm=toterm) + _varmap_to_vars(varmap, varlist; defaults, check, toterm) else # plain array-like initialization - vals = varmap + varmap + end + + promotetoconcrete === nothing && (promotetoconcrete = container_type <: AbstractArray) + if promotetoconcrete + vals = promote_to_concrete(vals; tofloat, use_union) end if isempty(vals) @@ -41,27 +201,414 @@ function varmap_to_vars(varmap, varlist; defaults=Dict(), check=true, toterm=Sym elseif container_type <: Tuple (vals...,) else - SymbolicUtils.Code.create_array(container_type, eltype(vals), Val{1}(), Val(length(vals)), vals...) + SymbolicUtils.Code.create_array(container_type, eltype(vals), Val{1}(), + Val(length(vals)), vals...) end end -function _varmap_to_vars(varmap::Dict, varlist; defaults=Dict(), check=false, toterm=Symbolics.diff2term) - varmap = merge(defaults, varmap) # prefers the `varmap` - varmap = Dict(toterm(value(k))=>value(varmap[k]) for k in keys(varmap)) - # resolve symbolic parameter expressions - for (p, v) in pairs(varmap) - varmap[p] = fixpoint_sub(v, varmap) +const MISSING_VARIABLES_MESSAGE = """ + Initial condition underdefined. Some are missing from the variable map. + Please provide a default (`u0`), initialization equation, or guess + for the following variables: + """ + +struct MissingVariablesError <: Exception + vars::Any +end + +function Base.showerror(io::IO, e::MissingVariablesError) + println(io, MISSING_VARIABLES_MESSAGE) + println(io, join(e.vars, ", ")) +end + +function _varmap_to_vars(varmap::Dict, varlist; defaults = Dict(), check = false, + toterm = Symbolics.diff2term, initialization_phase = false) + varmap = canonicalize_varmap(varmap; toterm) + defaults = canonicalize_varmap(defaults; toterm) + varmap = merge(defaults, varmap) + values = Dict() + + T = Union{} + for var in varlist + var = unwrap(var) + val = unwrap(fixpoint_sub(var, varmap; operator = Symbolics.Operator)) + if !isequal(val, var) + values[var] = val + end end - T′ = eltype(values(varmap)) - T = Base.isconcretetype(T′) ? T′ : Base.promote_typeof(values(varmap)...) - out = Vector{T}(undef, length(varlist)) - missingvars = setdiff(varlist, keys(varmap)) - check && (isempty(missingvars) || throw_missingvars(missingvars)) - - for (i, var) in enumerate(varlist) - out[i] = varmap[var] + missingvars = setdiff(varlist, collect(keys(values))) + check && (isempty(missingvars) || throw(MissingVariablesError(missingvars))) + return [values[unwrap(var)] for var in varlist] +end + +function varmap_with_toterm(varmap; toterm = Symbolics.diff2term) + return merge(todict(varmap), Dict(toterm(unwrap(k)) => v for (k, v) in varmap)) +end + +function canonicalize_varmap(varmap; toterm = Symbolics.diff2term) + new_varmap = Dict() + for (k, v) in varmap + k = unwrap(k) + v = unwrap(v) + new_varmap[k] = v + new_varmap[toterm(k)] = v + if Symbolics.isarraysymbolic(k) && Symbolics.shape(k) !== Symbolics.Unknown() + for i in eachindex(k) + new_varmap[k[i]] = v[i] + new_varmap[toterm(k[i])] = v[i] + end + end end - out + return new_varmap +end + +@noinline function throw_missingvars(vars) + throw(ArgumentError("$vars are missing from the variable map.")) +end + +struct IsHistory end +ishistory(x::Num) = ishistory(unwrap(x)) +ishistory(x::Symbolic) = getmetadata(x, IsHistory, false) +hist(x, t) = wrap(hist(unwrap(x), t)) +function hist(x::Symbolic, t) + setmetadata( + toparam(maketerm(typeof(x), operation(x), [unwrap(t)], metadata(x))), + IsHistory, true) +end + +## Bounds ====================================================================== +struct VariableBounds end +Symbolics.option_to_metadata_type(::Val{:bounds}) = VariableBounds + +""" + getbounds(x) + +Get the bounds associated with symbolic variable `x`. +Create parameters with bounds like this + +``` +@parameters p [bounds=(-1, 1)] +``` +""" +function getbounds(x::Union{Num, Symbolics.Arr, SymbolicUtils.Symbolic}) + x = unwrap(x) + p = Symbolics.getparent(x, nothing) + if p === nothing + bounds = Symbolics.getmetadata(x, VariableBounds, (-Inf, Inf)) + if symbolic_type(x) == ArraySymbolic() && Symbolics.shape(x) != Symbolics.Unknown() + bounds = map(bounds) do b + b isa AbstractArray && return b + return fill(b, size(x)) + end + end + else + # if we reached here, `x` is the result of calling `getindex` + bounds = @something Symbolics.getmetadata(x, VariableBounds, nothing) getbounds(p) + idxs = arguments(x)[2:end] + bounds = map(bounds) do b + if b isa AbstractArray + if Symbolics.shape(p) != Symbolics.Unknown() && size(p) != size(b) + throw(DimensionMismatch("Expected array variable $p with shape $(size(p)) to have bounds of identical size. Found $bounds of size $(size(bounds)).")) + end + return b[idxs...] + elseif symbolic_type(x) == ArraySymbolic() + return fill(b, size(x)) + else + return b + end + end + end + return bounds +end + +""" + hasbounds(x) + +Determine whether symbolic variable `x` has bounds associated with it. +See also [`getbounds`](@ref). +""" +function hasbounds(x) + b = getbounds(x) + any(isfinite.(b[1]) .|| isfinite.(b[2])) +end + +## Disturbance ================================================================= +struct VariableDisturbance end +Symbolics.option_to_metadata_type(::Val{:disturbance}) = VariableDisturbance + +isdisturbance(x::Num) = isdisturbance(Symbolics.unwrap(x)) + +""" + isdisturbance(x) + +Determine whether symbolic variable `x` is marked as a disturbance input. +""" +function isdisturbance(x) + p = Symbolics.getparent(x, nothing) + p === nothing || (x = p) + Symbolics.getmetadata(x, VariableDisturbance, false) +end + +function disturbances(sys) + [filter(isdisturbance, unknowns(sys)); filter(isdisturbance, parameters(sys))] +end + +## Tunable ===================================================================== +struct VariableTunable end +Symbolics.option_to_metadata_type(::Val{:tunable}) = VariableTunable + +istunable(x::Num, args...) = istunable(Symbolics.unwrap(x), args...) + +""" + istunable(x, default = true) + +Determine whether symbolic variable `x` is marked as a tunable for an automatic tuning algorithm. + +`default` indicates whether variables without `tunable` metadata are to be considered tunable or not. + +Create a tunable parameter by + +``` +@parameters u [tunable=true] +``` + +See also [`tunable_parameters`](@ref), [`getbounds`](@ref) +""" +function istunable(x, default = true) + p = Symbolics.getparent(x, nothing) + p === nothing || (x = p) + Symbolics.getmetadata(x, VariableTunable, default) end -@noinline throw_missingvars(vars) = throw(ArgumentError("$vars are missing from the variable map.")) +## Dist ======================================================================== +struct VariableDistribution end +Symbolics.option_to_metadata_type(::Val{:dist}) = VariableDistribution +getdist(x::Num) = getdist(Symbolics.unwrap(x)) + +""" + getdist(x) + +Get the probability distribution associated with symbolic variable `x`. If no distribution +is associated with `x`, `nothing` is returned. +Create parameters with associated distributions like this + +```julia +using Distributions +d = Normal(0, 1) +@parameters u [dist = d] +hasdist(u) # true +getdist(u) # retrieve distribution +``` +""" +function getdist(x) + p = Symbolics.getparent(x, nothing) + p === nothing || (x = p) + Symbolics.getmetadata(x, VariableDistribution, nothing) +end + +""" + hasdist(x) + +Determine whether symbolic variable `x` has a probability distribution associated with it. +""" +function hasdist(x) + b = getdist(x) + b !== nothing +end + +## System interface + +""" + tunable_parameters(sys, p = parameters(sys; initial_parameters = true); default=true) + +Get all parameters of `sys` that are marked as `tunable`. + +Keyword argument `default` indicates whether variables without `tunable` metadata are to be considered tunable or not. + +Create a tunable parameter by + +``` +@parameters u [tunable=true] +``` + +For systems created with `split = true` (the default) and `default = true` passed to this function, the order +of parameters returned is the order in which they are stored in the tunables portion of `MTKParameters`. Note +that array variables will not be scalarized. To obtain the flattened representation of the tunables portion, +call `Symbolics.scalarize(tunable_parameters(sys))` and concatenate the resulting arrays. + +See also [`getbounds`](@ref), [`istunable`](@ref), [`MTKParameters`](@ref), [`complete`](@ref) +""" +function tunable_parameters( + sys, p = parameters(sys; initial_parameters = true); default = true) + filter(x -> istunable(x, default), p) +end + +""" + getbounds(sys::ModelingToolkit.AbstractSystem, p = parameters(sys)) + +Returns a dict with pairs `p => (lb, ub)` mapping parameters of `sys` to lower and upper bounds. +Create parameters with bounds like this + +``` +@parameters p [bounds=(-1, 1)] +``` + +To obtain unknown variable bounds, call `getbounds(sys, unknowns(sys))` +""" +function getbounds(sys::ModelingToolkit.AbstractSystem, p = parameters(sys)) + Dict(p .=> getbounds.(p)) +end + +""" + lb, ub = getbounds(p::AbstractVector) + +Return vectors of lower and upper bounds of parameter vector `p`. +Create parameters with bounds like this + +``` +@parameters p [bounds=(-1, 1)] +``` + +See also [`tunable_parameters`](@ref), [`hasbounds`](@ref) +""" +function getbounds(p::AbstractVector) + bounds = getbounds.(p) + lb = first.(bounds) + ub = last.(bounds) + (; lb, ub) +end + +## Description ================================================================= +struct VariableDescription end +Symbolics.option_to_metadata_type(::Val{:description}) = VariableDescription + +getdescription(x::Num) = getdescription(Symbolics.unwrap(x)) +getdescription(x::Symbolics.Arr) = getdescription(Symbolics.unwrap(x)) +""" + getdescription(x) + +Return any description attached to variables `x`. If no description is attached, an empty string is returned. +""" +function getdescription(x) + p = Symbolics.getparent(x, nothing) + p === nothing || (x = p) + Symbolics.getmetadata(x, VariableDescription, "") +end + +function hasdescription(x) + getdescription(x) != "" +end + +## Brownian +""" + tobrownian(s::Sym) + +Maps the brownianiable to an unknown. +""" +tobrownian(s::Symbolic) = setmetadata(s, MTKVariableTypeCtx, BROWNIAN) +tobrownian(s::Num) = Num(tobrownian(value(s))) +isbrownian(s) = getvariabletype(s) === BROWNIAN + +""" +$(SIGNATURES) + +Define one or more Brownian variables. +""" +macro brownian(xs...) + all( + x -> x isa Symbol || Meta.isexpr(x, :call) && x.args[1] == :$ || Meta.isexpr(x, :$), + xs) || + error("@brownian only takes scalar expressions!") + Symbolics._parse_vars(:brownian, + Real, + xs, + tobrownian) |> esc +end + +## Guess ====================================================================== +struct VariableGuess end +Symbolics.option_to_metadata_type(::Val{:guess}) = VariableGuess +getguess(x::Union{Num, Symbolics.Arr}) = getguess(Symbolics.unwrap(x)) + +""" + getguess(x) + +Get the guess for the initial value associated with symbolic variable `x`. +Create variables with a guess like this + +``` +@variables x [guess=1] +``` +""" +function getguess(x) + Symbolics.getmetadata(x, VariableGuess, nothing) +end + +""" + setguess(x, v) + +Set the guess for the initial value associated with symbolic variable `x` to `v`. +See also [`hasguess`](@ref). +""" +function setguess(x, v) + Symbolics.setmetadata(x, VariableGuess, v) +end + +""" + hasguess(x) + +Determine whether symbolic variable `x` has a guess associated with it. +See also [`getguess`](@ref). +""" +function hasguess(x) + getguess(x) !== nothing +end + +function get_default_or_guess(x) + if hasdefault(x) && !((def = getdefault(x)) isa Equation) + return def + else + return getguess(x) + end +end + +## Miscellaneous metadata ====================================================================== +""" + getmisc(x) + +Fetch any miscellaneous data associated with symbolic variable `x`. +See also [`hasmisc(x)`](@ref). +""" +getmisc(x::Num) = getmisc(unwrap(x)) +getmisc(x::Symbolic) = Symbolics.getmetadata(x, VariableMisc, nothing) +""" + hasmisc(x) + +Determine whether a symbolic variable `x` has misc +metadata associated with it. + +See also [`getmisc(x)`](@ref). +""" +hasmisc(x) = getmisc(x) !== nothing +setmisc(x, miscdata) = setmetadata(x, VariableMisc, miscdata) + +## Units ====================================================================== +""" + getunit(x) + +Fetch the unit associated with variable `x`. This function is a metadata getter for an individual variable, while `get_unit` is used for unit inference on more complicated sdymbolic expressions. +""" +getunit(x::Num) = getunit(unwrap(x)) +getunit(x::Symbolic) = Symbolics.getmetadata(x, VariableUnit, nothing) +""" + hasunit(x) + +Check if the variable `x` has a unit. +""" +hasunit(x) = getunit(x) !== nothing + +getunshifted(x::Num) = getunshifted(unwrap(x)) +getunshifted(x::Symbolic) = Symbolics.getmetadata(x, VariableUnshifted, nothing) + +getshift(x::Num) = getshift(unwrap(x)) +getshift(x::Symbolic) = Symbolics.getmetadata(x, VariableShift, 0) diff --git a/test/abstractsystem.jl b/test/abstractsystem.jl new file mode 100644 index 0000000000..bd5b6fe542 --- /dev/null +++ b/test/abstractsystem.jl @@ -0,0 +1,31 @@ +using ModelingToolkit +using Test +MT = ModelingToolkit + +@independent_variables t +@variables x +struct MyNLS <: MT.AbstractSystem + name::Any + systems::Any +end +@test_logs (:warn,) tmp=independent_variables(MyNLS("sys", [])) +tmp = independent_variables(MyNLS("sys", [])) +@test tmp == [] + +struct MyTDS <: MT.AbstractSystem + iv::Any + name::Any + systems::Any +end +@test_logs (:warn,) iv=independent_variables(MyTDS(t, "sys", [])) +iv = independent_variables(MyTDS(t, "sys", [])) +@test all(isequal.(iv, [t])) + +struct MyMVS <: MT.AbstractSystem + ivs::Any + name::Any + systems::Any +end +@test_logs (:warn,) ivs=independent_variables(MyMVS([t, x], "sys", [])) +ivs = independent_variables(MyMVS([t, x], "sys", [])) +@test all(isequal.(ivs, [t, x])) diff --git a/test/accessor_functions.jl b/test/accessor_functions.jl new file mode 100644 index 0000000000..7ce477155b --- /dev/null +++ b/test/accessor_functions.jl @@ -0,0 +1,171 @@ +### Preparations ### + +# Fetch packages. +using ModelingToolkit, Test +using ModelingToolkit: t_nounits as t, D_nounits as D +import ModelingToolkit: get_ps, get_unknowns, get_observed, get_eqs, get_continuous_events, + get_discrete_events, namespace_equations +import ModelingToolkit: parameters_toplevel, unknowns_toplevel, equations_toplevel, + continuous_events_toplevel, discrete_events_toplevel + +# Creates helper functions. +function all_sets_equal(args...) + for arg in args[2:end] + issetequal(args[1], arg) || return false + end + return true +end +function sym_issubset(set1, set2) + for sym1 in set1 + any(isequal(sym1, sym2) for sym2 in set2) || return false + end + return true +end + +### Basic Tests ### + +# Checks `toplevel = false` argument for various accessors (currently only for `ODESystem`s). +# Compares to `` version, and `get_` functions. +# Checks accessors for parameters, unknowns, equations, observables, and events. +# Some tests looks funny (caused by the formatter). +let + # Prepares model components. + @parameters p_top p_mid1 p_mid2 p_bot d + @variables X_top(t) X_mid1(t) X_mid2(t) X_bot(t) Y(t) O(t) + + # Creates the systems (individual and hierarchical). + eqs_top = [ + D(X_top) ~ p_top - d * X_top, + D(Y) ~ log(X_top) - Y^2 + 3.0, + O ~ (p_top + d) * X_top + Y + ] + eqs_mid1 = [ + D(X_mid1) ~ p_mid1 - d * X_mid1^2, + D(Y) ~ D(X_mid1) - Y^3, + O ~ (p_mid1 + d) * X_mid1 + Y + ] + eqs_mid2 = [ + D(X_mid2) ~ p_mid2 - d * X_mid2, + X_mid2^3 ~ log(X_mid2 + Y) - Y^2 + 3.0, + O ~ (p_mid2 + d) * X_mid2 + Y + ] + eqs_bot = [ + D(X_bot) ~ p_bot - d * X_bot, + D(Y) ~ -Y^3, + O ~ (p_bot + d) * X_bot + Y + ] + cevs = [[t ~ 1.0] => [Y ~ Y + 2.0]] + devs = [(t == 2.0) => [Y ~ Y + 2.0]] + @named sys_bot = ODESystem( + eqs_bot, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid2 = ODESystem( + eqs_mid2, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid1 = ODESystem( + eqs_mid1, t; systems = [sys_bot], continuous_events = cevs, discrete_events = devs) + @named sys_top = ODESystem(eqs_top, t; systems = [sys_mid1, sys_mid2], + continuous_events = cevs, discrete_events = devs) + sys_bot_comp = complete(sys_bot) + sys_mid2_comp = complete(sys_mid2) + sys_mid1_comp = complete(sys_mid1) + sys_top_comp = complete(sys_top) + sys_bot_ss = structural_simplify(sys_bot) + sys_mid2_ss = structural_simplify(sys_mid2) + sys_mid1_ss = structural_simplify(sys_mid1) + sys_top_ss = structural_simplify(sys_top) + + # Checks `parameters1. + @test all_sets_equal(parameters.([sys_bot, sys_bot_comp, sys_bot_ss])..., [d, p_bot]) + @test all_sets_equal(parameters.([sys_mid1, sys_mid1_comp, sys_mid1_ss])..., + [d, p_mid1, sys_bot.d, sys_bot.p_bot]) + @test all_sets_equal( + parameters.([sys_mid2, sys_mid2_comp, sys_mid2_ss])..., [d, p_mid2]) + @test all_sets_equal(parameters.([sys_top, sys_top_comp, sys_top_ss])..., + [d, p_top, sys_mid1.d, sys_mid1.p_mid1, sys_mid1.sys_bot.d, + sys_mid1.sys_bot.p_bot, sys_mid2.d, sys_mid2.p_mid2]) + + # Checks `parameters_toplevel`. Compares to known parameters and also checks that + # these are subset of what `get_ps` returns. + @test all_sets_equal( + parameters_toplevel.([sys_bot, sys_bot_comp, sys_bot_ss])..., [d, p_bot]) + @test all_sets_equal( + parameters_toplevel.([sys_mid1, sys_mid1_comp, sys_mid1_ss])..., + [d, p_mid1]) + @test all_sets_equal( + parameters_toplevel.([sys_mid2, sys_mid2_comp, sys_mid2_ss])..., + [d, p_mid2]) + @test all_sets_equal( + parameters_toplevel.([sys_top, sys_top_comp, sys_top_ss])..., [d, p_top]) + @test all(sym_issubset(parameters_toplevel(sys), get_ps(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + + # Checks `unknowns`. O(t) is eliminated by `structural_simplify` and + # must be considered separately. + @test all_sets_equal(unknowns.([sys_bot, sys_bot_comp])..., [O, Y, X_bot]) + @test all_sets_equal(unknowns.([sys_bot_ss])..., [Y, X_bot]) + @test all_sets_equal(unknowns.([sys_mid1, sys_mid1_comp])..., + [O, Y, X_mid1, sys_bot.Y, sys_bot.O, sys_bot.X_bot]) + @test all_sets_equal(unknowns.([sys_mid1_ss])..., [Y, X_mid1, sys_bot.Y, sys_bot.X_bot]) + @test all_sets_equal(unknowns.([sys_mid2, sys_mid2_comp])..., [O, Y, X_mid2]) + @test all_sets_equal(unknowns.([sys_mid2_ss])..., [Y, X_mid2]) + @test all_sets_equal(unknowns.([sys_top, sys_top_comp])..., + [O, Y, X_top, sys_mid1.O, sys_mid1.Y, sys_mid1.X_mid1, + sys_mid1.sys_bot.O, sys_mid1.sys_bot.Y, sys_mid1.sys_bot.X_bot, + sys_mid2.O, sys_mid2.Y, sys_mid2.X_mid2]) + @test all_sets_equal(unknowns.([sys_top_ss])..., + [Y, X_top, sys_mid1.Y, sys_mid1.X_mid1, sys_mid1.sys_bot.Y, + sys_mid1.sys_bot.X_bot, sys_mid2.Y, sys_mid2.X_mid2]) + + # Checks `unknowns_toplevel`. Note that O is not eliminated here (as we go back + # to original parent system). Also checks that outputs are subsets of what `get_unknowns` returns. + @test all_sets_equal( + unknowns_toplevel.([sys_bot, sys_bot_comp, sys_bot_ss])..., [O, Y, X_bot]) + @test all_sets_equal( + unknowns_toplevel.([sys_mid1, sys_mid1_comp])..., [O, Y, X_mid1]) + @test all_sets_equal( + unknowns_toplevel.([sys_mid2, sys_mid2_comp])..., [O, Y, X_mid2]) + @test all_sets_equal( + unknowns_toplevel.([sys_top, sys_top_comp])..., [O, Y, X_top]) + @test all(sym_issubset(unknowns_toplevel(sys), get_unknowns(sys)) + for sys in [sys_bot, sys_mid1, sys_mid2, sys_top]) + + # Checks `equations`. Do not check ss equations as these might potentially + # be structurally simplified to new equations. + @test all_sets_equal(equations.([sys_bot, sys_bot_comp])..., eqs_bot) + @test all_sets_equal( + equations.([sys_mid1, sys_mid1_comp])..., [eqs_mid1; namespace_equations(sys_bot)]) + @test all_sets_equal(equations.([sys_mid2, sys_mid2_comp])..., eqs_mid2) + @test all_sets_equal(equations.([sys_top, sys_top_comp])..., + [eqs_top; namespace_equations(sys_mid1); namespace_equations(sys_mid2)]) + + # Checks `equations_toplevel`. Do not check ss equations directly as these + # might potentially be structurally simplified to new equations. Do not check + @test all_sets_equal(equations_toplevel.([sys_bot])..., eqs_bot) + @test all_sets_equal( + equations_toplevel.([sys_mid1])..., eqs_mid1) + @test all_sets_equal( + equations_toplevel.([sys_mid2])..., eqs_mid2) + @test all_sets_equal(equations_toplevel.([sys_top])..., eqs_top) + @test all(sym_issubset(equations_toplevel(sys), get_eqs(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + + # Checks `continuous_events_toplevel` and `discrete_events_toplevel` (straightforward + # as I stored the same singe event in all systems). Don't check for non-toplevel cases as + # technically not needed for these tests and name spacing the events is a mess. + mtk_cev = ModelingToolkit.SymbolicContinuousCallback.(cevs)[1] + mtk_dev = ModelingToolkit.SymbolicDiscreteCallback.(devs)[1] + @test all_sets_equal( + continuous_events_toplevel.( + [sys_bot, sys_bot_comp, sys_bot_ss, sys_mid1, sys_mid1_comp, sys_mid1_ss, + sys_mid2, sys_mid2_comp, sys_mid2_ss, sys_top, sys_top_comp, sys_top_ss])..., + [mtk_cev]) + @test all_sets_equal( + discrete_events_toplevel.( + [sys_bot, sys_bot_comp, sys_bot_ss, sys_mid1, sys_mid1_comp, sys_mid1_ss, + sys_mid2, sys_mid2_comp, sys_mid2_ss, sys_top, sys_top_comp, sys_top_ss])..., + [mtk_dev]) + @test all(sym_issubset( + continuous_events_toplevel(sys), get_continuous_events(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + @test all(sym_issubset(discrete_events_toplevel(sys), get_discrete_events(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) +end diff --git a/test/analysis_points.jl b/test/analysis_points.jl new file mode 100644 index 0000000000..e36afc02f7 --- /dev/null +++ b/test/analysis_points.jl @@ -0,0 +1,186 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEq, LinearAlgebra +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D, AnalysisPoint, AbstractSystem +import ModelingToolkit as MTK +using Symbolics: NAMESPACE_SEPARATOR + +@testset "AnalysisPoint is lowered to `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + eqs = [connect(P.output, C.input) + connect(C.output, ap, P.input)] + sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys_ap2 = @test_nowarn expand_connections(sys_ap) + + @test all(eq -> !(eq.lhs isa AnalysisPoint), equations(sys_ap2)) + + eqs = [connect(P.output, C.input) + connect(C.output, P.input)] + sys_normal = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys_normal2 = @test_nowarn expand_connections(sys_normal) + + @test isequal(sys_ap2, sys_normal2) +end + +@testset "Inverse causality throws a warning" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + @test_warn ["1-th argument", "plant_input", "not a output"] connect( + P.input, ap, C.output) + @test_nowarn connect(P.input, ap, C.output; verbose = false) +end + +# also tests `connect(input, name::Symbol, outputs...)` syntax +@testset "AnalysisPoint is accessible via `getproperty`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + eqs = [connect(P.output, C.input), connect(C.output, :plant_input, P.input)] + sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) + ap2 = @test_nowarn sys_ap.plant_input + @test nameof(ap2) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) + @named sys = ODESystem(Equation[], t; systems = [sys_ap]) + ap3 = @test_nowarn sys.hej.plant_input + @test nameof(ap3) == Symbol(join(["sys", "hej", "plant_input"], NAMESPACE_SEPARATOR)) + csys = complete(sys) + ap4 = csys.hej.plant_input + @test nameof(ap4) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) + nsys = toggle_namespacing(sys, false) + ap5 = nsys.hej.plant_input + @test nameof(ap4) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) +end + +### Ported from MTKStdlib + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) + +ap = AnalysisPoint(:plant_input) +eqs = [connect(P.output, C.input), connect(C.output, ap, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = ODESystem(Equation[], t; systems = [sys]) +nonamespace_sys = toggle_namespacing(nested_sys, false) + +@testset "simplifies and solves" begin + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [P.x => 1], (0, 10)) + sol = solve(prob, Rodas5()) + @test norm(sol.u[1]) >= 1 + @test norm(sol.u[end]) < 1e-6 # This fails without the feedback through C +end + +test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("nonamespace", nonamespace_sys, nonamespace_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) +] + +@testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 +end + +@testset "get_comp_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_comp_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive or negative + @test matrices.D[] == 0 +end + +#= +# Equivalent code using ControlSystems. This can be used to verify the expected results tested for above. +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +=# + +@testset "get_looptransfer - $name" for (name, sys, ap) in test_cases + matrices, _ = get_looptransfer(sys, ap) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 +end + +#= +# Equivalent code using ControlSystems. This can be used to verify the expected results tested for above. +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = -1 +L = P*C +=# + +@testset "open_loop - $name" for (name, sys, ap) in test_cases + open_sys, (du, u) = open_loop(sys, ap) + matrices, _ = linearize(open_sys, [du], [u]) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 +end + +# Multiple analysis points + +eqs = [connect(P.output, :plant_output, C.input) + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = ODESystem(Equation[], t; systems = [sys]) + +test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) +] + +@testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 +end + +@testset "linearize - $name" for (name, sys, inputap, outputap) in [ + ("inner", sys, sys.plant_input, sys.plant_output), + ("nested", nested_sys, nested_sys.hej.plant_input, nested_sys.hej.plant_output) +] + inputname = Symbol(join( + MTK.namespace_hierarchy(nameof(inputap))[2:end], NAMESPACE_SEPARATOR)) + outputname = Symbol(join( + MTK.namespace_hierarchy(nameof(outputap))[2:end], NAMESPACE_SEPARATOR)) + @testset "input - $(typeof(input)), output - $(typeof(output))" for (input, output) in [ + (inputap, outputap), + (inputname, outputap), + (inputap, outputname), + (inputname, outputname), + (inputap, [outputap]), + (inputname, [outputap]), + (inputap, [outputname]), + (inputname, [outputname]) + ] + matrices, _ = linearize(sys, input, output) + # Result should be the same as feedpack(P, 1), i.e., the closed-loop transfer function from plant input to plant output + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive + @test matrices.D[] == 0 + end +end + +@testset "linearize - variable output - $name" for (name, sys, input, output) in [ + ("inner", sys, sys.plant_input, P.output.u), + ("nested", nested_sys, nested_sys.hej.plant_input, sys.P.output.u) +] + matrices, _ = linearize(sys, input, [output]) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive + @test matrices.D[] == 0 +end diff --git a/test/basic_transformations.jl b/test/basic_transformations.jl index 98e75b688d..544a89cd29 100644 --- a/test/basic_transformations.jl +++ b/test/basic_transformations.jl @@ -1,33 +1,217 @@ -using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit, OrdinaryDiffEq, DataInterpolations, Test -@parameters t α β γ δ -@variables x(t) y(t) +@independent_variables t D = Differential(t) -eqs = [D(x) ~ α*x - β*x*y, - D(y) ~ -δ*y + γ*x*y] +@testset "Liouville transform" begin + @parameters α β γ δ + @variables x(t) y(t) + eqs = [D(x) ~ α * x - β * x * y, D(y) ~ -δ * y + γ * x * y] + @named sys = ODESystem(eqs, t) + sys = complete(sys) -sys = ODESystem(eqs) + u0 = [x => 1.0, y => 1.0] + p = [α => 1.5, β => 1.0, δ => 3.0, γ => 1.0] + tspan = (0.0, 10.0) + prob = ODEProblem(sys, u0, tspan, p) + sol = solve(prob, Tsit5()) -u0 = [x => 1.0, - y => 1.0] + sys2 = liouville_transform(sys) + sys2 = complete(sys2) -p = [α => 1.5, - β => 1.0, - δ => 3.0, - γ => 1.0] + u0 = [x => 1.0, y => 1.0, sys2.trJ => 1.0] + prob = ODEProblem(sys2, u0, tspan, p, jac = true) + sol = solve(prob, Tsit5()) + @test sol[end, end] ≈ 1.0742818931017244 +end -tspan = (0.0,10.0) -prob = ODEProblem(sys,u0,tspan,p) -sol = solve(prob,Tsit5()) +@testset "Change independent variable (trivial)" begin + @variables x(t) y(t) + eqs1 = [D(D(x)) ~ D(x) + x, D(y) ~ 1] + M1 = ODESystem(eqs1, t; name = :M) + M2 = change_independent_variable(M1, y) + @variables y x(y) yˍt(y) + Dy = Differential(y) + @test Set(equations(M2)) == Set([ + yˍt^2 * (Dy^2)(x) + yˍt * Dy(yˍt) * Dy(x) ~ x + Dy(x) * yˍt, + yˍt ~ 1 + ]) +end -sys2 = liouville_transform(sys) -@variables trJ +@testset "Change independent variable" begin + @variables x(t) y(t) z(t) s(t) + eqs = [ + D(x) ~ y, + D(D(y)) ~ 2 * x * D(y), + z ~ x + D(y), + D(s) ~ 1 / (2 * s) + ] + initialization_eqs = [x ~ 1.0, y ~ 1.0, D(y) ~ 0.0] + M1 = ODESystem(eqs, t; initialization_eqs, name = :M) + M2 = change_independent_variable(M1, s) -u0 = [x => 1.0, - y => 1.0, - trJ => 1.0] + M1 = structural_simplify(M1; allow_symbolic = true) + M2 = structural_simplify(M2; allow_symbolic = true) + prob1 = ODEProblem(M1, [M1.s => 1.0], (1.0, 4.0), []) + prob2 = ODEProblem(M2, [], (1.0, 2.0), []) + sol1 = solve(prob1, Tsit5(); reltol = 1e-10, abstol = 1e-10) + sol2 = solve(prob2, Tsit5(); reltol = 1e-10, abstol = 1e-10) + ts = range(0.0, 1.0, length = 50) + ss = .√(ts) + @test all(isapprox.(sol1(ts, idxs = M1.x), sol2(ss, idxs = M2.x); atol = 1e-7)) && + all(isapprox.(sol1(ts, idxs = M1.y), sol2(ss, idxs = M2.y); atol = 1e-7)) +end -prob = ODEProblem(sys2,u0,tspan,p,jac=true) -sol = solve(prob,Tsit5()) -@test sol[end,end] ≈ 1.0742818931017244 +@testset "Change independent variable (Friedmann equation)" begin + @independent_variables t + D = Differential(t) + @variables a(t) ȧ(t) Ω(t) ϕ(t) + a, ȧ = GlobalScope.([a, ȧ]) + species(w; kw...) = ODESystem([D(Ω) ~ -3(1 + w) * D(a) / a * Ω], t, [Ω], []; kw...) + @named r = species(1 // 3) + @named m = species(0) + @named Λ = species(-1) + eqs = [ + Ω ~ r.Ω + m.Ω + Λ.Ω, + D(a) ~ ȧ, + ȧ ~ √(Ω) * a^2, + D(D(ϕ)) ~ -3 * D(a) / a * D(ϕ) + ] + M1 = ODESystem(eqs, t, [Ω, a, ȧ, ϕ], []; name = :M) + M1 = compose(M1, r, m, Λ) + + # Apply in two steps, where derivatives are defined at each step: first t -> a, then a -> b + M2 = change_independent_variable(M1, M1.a) + M2c = complete(M2) # just for the following equation comparison (without namespacing) + a, ȧ, Ω, ϕ, aˍt = M2c.a, M2c.ȧ, M2c.Ω, M2c.ϕ, M2c.aˍt + Ωr, Ωm, ΩΛ = M2c.r.Ω, M2c.m.Ω, M2c.Λ.Ω + Da = Differential(a) + @test Set(equations(M2)) == Set([ + aˍt ~ ȧ, # dummy equation + Ω ~ Ωr + Ωm + ΩΛ, + ȧ ~ √(Ω) * a^2, + Da(aˍt) * Da(ϕ) * aˍt + aˍt^2 * (Da^2)(ϕ) ~ -3 * aˍt^2 / a * Da(ϕ), + aˍt * Da(Ωr) ~ -4 * Ωr * aˍt / a, + aˍt * Da(Ωm) ~ -3 * Ωm * aˍt / a, + aˍt * Da(ΩΛ) ~ 0 + ]) + + @variables b(M2.a) + extraeqs = [Differential(M2.a)(b) ~ exp(-b), M2.a ~ exp(b)] + M3 = change_independent_variable(M2, b, extraeqs) + + M1 = structural_simplify(M1) + M2 = structural_simplify(M2; allow_symbolic = true) + M3 = structural_simplify(M3; allow_symbolic = true) + @test length(unknowns(M3)) == length(unknowns(M2)) == length(unknowns(M1)) - 1 +end + +@testset "Change independent variable (simple)" begin + @variables x(t) y1(t) # y(t)[1:1] # TODO: use array variables y(t)[1:2] when fixed: https://github.com/JuliaSymbolics/Symbolics.jl/issues/1383 + Mt = ODESystem([D(x) ~ 2 * x, D(y1) ~ y1], t; name = :M) + Mx = change_independent_variable(Mt, x) + @variables x xˍt(x) xˍtt(x) y1(x) # y(x)[1:1] # TODO: array variables + Dx = Differential(x) + @test Set(equations(Mx)) == Set([xˍt ~ 2 * x, xˍt * Dx(y1) ~ y1]) +end + +@testset "Change independent variable (free fall with 1st order horizontal equation)" begin + @variables x(t) y(t) + @parameters g=9.81 v # gravitational acceleration and constant horizontal velocity + Mt = ODESystem([D(D(y)) ~ -g, D(x) ~ v], t; name = :M) # gives (x, y) as function of t, ... + Mx = change_independent_variable(Mt, x; add_old_diff = true) # ... but we want y as a function of x + Mx = structural_simplify(Mx; allow_symbolic = true) + Dx = Differential(Mx.x) + u0 = [Mx.y => 0.0, Dx(Mx.y) => 1.0, Mx.t => 0.0] + p = [v => 10.0] + prob = ODEProblem(Mx, u0, (0.0, 20.0), p) # 1 = dy/dx = (dy/dt)/(dx/dt) means equal initial horizontal and vertical velocities + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test all(isapprox.(sol[Mx.y], sol[Mx.x - g * (Mx.t)^2 / 2]; atol = 1e-10)) # compare to analytical solution (x(t) = v*t, y(t) = v*t - g*t^2/2) +end + +@testset "Change independent variable (free fall with 2nd order horizontal equation)" begin + @variables x(t) y(t) + @parameters g = 9.81 # gravitational acceleration + Mt = ODESystem([D(D(y)) ~ -g, D(D(x)) ~ 0], t; name = :M) # gives (x, y) as function of t, ... + Mx = change_independent_variable(Mt, x; add_old_diff = true) # ... but we want y as a function of x + Mx = structural_simplify(Mx; allow_symbolic = true) + Dx = Differential(Mx.x) + u0 = [Mx.y => 0.0, Dx(Mx.y) => 1.0, Mx.t => 0.0, Mx.xˍt => 10.0] + prob = ODEProblem(Mx, u0, (0.0, 20.0), []) # 1 = dy/dx = (dy/dt)/(dx/dt) means equal initial horizontal and vertical velocities + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test all(isapprox.(sol[Mx.y], sol[Mx.x - g * (Mx.t)^2 / 2]; atol = 1e-10)) # compare to analytical solution (x(t) = v*t, y(t) = v*t - g*t^2/2) +end + +@testset "Change independent variable (crazy 3rd order nonlinear system)" begin + @independent_variables t + D = Differential(t) + @variables x(t) y(t) + eqs = [ + (D^3)(y) ~ D(x)^2 + (D^2)(y^2) |> expand_derivatives, + D(x)^2 + D(y)^2 ~ x^4 + y^5 + t^6 + ] + M1 = ODESystem(eqs, t; name = :M) + M2 = change_independent_variable(M1, x; add_old_diff = true) + @test_nowarn structural_simplify(M2) + + # Compare to pen-and-paper result + @variables x xˍt(x) xˍt(x) y(x) t(x) + Dx = Differential(x) + areequivalent(eq1, eq2) = isequal(expand(eq1.lhs - eq2.lhs), 0) && + isequal(expand(eq1.rhs - eq2.rhs), 0) + eq1lhs = xˍt^3 * (Dx^3)(y) + xˍt^2 * Dx(y) * (Dx^2)(xˍt) + + xˍt * Dx(y) * (Dx(xˍt))^2 + + 3 * xˍt^2 * (Dx^2)(y) * Dx(xˍt) + eq1rhs = xˍt^2 + 2 * xˍt^2 * Dx(y)^2 + + 2 * xˍt^2 * y * (Dx^2)(y) + + 2 * y * Dx(y) * Dx(xˍt) * xˍt + eq1 = eq1lhs ~ eq1rhs + eq2 = xˍt^2 + xˍt^2 * Dx(y)^2 ~ x^4 + y^5 + t^6 + eq3 = Dx(t) ~ 1 / xˍt + @test areequivalent(equations(M2)[1], eq1) + @test areequivalent(equations(M2)[2], eq2) + @test areequivalent(equations(M2)[3], eq3) +end + +@testset "Change independent variable (registered function / callable parameter)" begin + @independent_variables t + D = Differential(t) + @variables x(t) y(t) + @parameters f::LinearInterpolation (fc::LinearInterpolation)(..) # non-callable and callable + callme(interp::LinearInterpolation, input) = interp(input) + @register_symbolic callme(interp::LinearInterpolation, input) + eqs = [ + D(x) ~ 2t, + D(y) ~ 1fc(t) + 2fc(x) + 3fc(y) + 1callme(f, t) + 2callme(f, x) + 3callme(f, y) + ] + M1 = ODESystem(eqs, t; name = :M) + + # Ensure that interpolations are called with the same variables + M2 = change_independent_variable(M1, x, [t ~ √(x)]) + @variables x xˍt(x) y(x) t(x) + Dx = Differential(x) + @test Set(equations(M2)) == Set([ + t ~ √(x), + xˍt ~ 2t, + xˍt * Dx(y) ~ 1fc(t) + 2fc(x) + 3fc(y) + + 1callme(f, t) + 2callme(f, x) + 3callme(f, y) + ]) + + _f = LinearInterpolation([1.0, 1.0], [-100.0, +100.0]) # constant value 1 + M2s = structural_simplify(M2; allow_symbolic = true) + prob = ODEProblem(M2s, [M2s.y => 0.0], (1.0, 4.0), [fc => _f, f => _f]) + sol = solve(prob, Tsit5(); abstol = 1e-5) + @test isapprox(sol(4.0, idxs = M2.y), 12.0; atol = 1e-5) # Anal solution is D(y) ~ 12 => y(t) ~ 12*t + C => y(x) ~ 12*√(x) + C. With y(x=1)=0 => 12*(√(x)-1), so y(x=4) ~ 12 +end + +@testset "Change independent variable (errors)" begin + @variables x(t) y z(y) w(t) v(t) + M = ODESystem([D(x) ~ 1, v ~ x], t; name = :M) + Ms = structural_simplify(M) + @test_throws "structurally simplified" change_independent_variable(Ms, y) + @test_throws "not a function of" change_independent_variable(M, y) + @test_throws "not a function of" change_independent_variable(M, z) + @variables x(..) # require explicit argument + M = ODESystem([D(x(t)) ~ x(t - 1)], t; name = :M) + @test_throws "DDE" change_independent_variable(M, x(t)) +end diff --git a/test/bigsystem.jl b/test/bigsystem.jl index 9fb988da69..612e96c418 100644 --- a/test/bigsystem.jl +++ b/test/bigsystem.jl @@ -1,104 +1,111 @@ -using ModelingToolkit, LinearAlgebra, SparseArrays - -# Define the constants for the PDE -const α₂ = 1.0 -const α₃ = 1.0 -const β₁ = 1.0 -const β₂ = 1.0 -const β₃ = 1.0 -const r₁ = 1.0 -const r₂ = 1.0 -const _DD = 100.0 -const γ₁ = 0.1 -const γ₂ = 0.1 -const γ₃ = 0.1 -const N = 8 -const X = reshape([i for i in 1:N for j in 1:N],N,N) -const Y = reshape([j for i in 1:N for j in 1:N],N,N) -const α₁ = 1.0.*(X.>=4*N/5) - -const Mx = Tridiagonal([1.0 for i in 1:N-1],[-2.0 for i in 1:N],[1.0 for i in 1:N-1]) -const My = copy(Mx) -Mx[2,1] = 2.0 -Mx[end-1,end] = 2.0 -My[1,2] = 2.0 -My[end,end-1] = 2.0 - -# Define the initial condition as normal arrays -@variables du[1:N,1:N,1:3] u[1:N,1:N,1:3] MyA[1:N,1:N] AMx[1:N,1:N] DA[1:N,1:N] - -# Define the discretized PDE as an ODE function -function f(du,u,p,t) - A = @view u[:,:,1] - B = @view u[:,:,2] - C = @view u[:,:,3] - dA = @view du[:,:,1] - dB = @view du[:,:,2] - dC = @view du[:,:,3] - mul!(MyA,My,A) - mul!(AMx,A,Mx) - @. DA = _DD*(MyA + AMx) - @. dA = DA + α₁ - β₁*A - r₁*A*B + r₂*C - @. dB = α₂ - β₂*B - r₁*A*B + r₂*C - @. dC = α₃ - β₃*C + r₁*A*B - r₂*C -end - -f(du,u,nothing,0.0) - -multithreadedf = eval(ModelingToolkit.build_function(du,u,fillzeros=true, - parallel=ModelingToolkit.MultithreadedForm())[2]) - -MyA = zeros(N,N); -AMx = zeros(N,N); -DA = zeros(N,N); -# Loop to catch syncronization issues -for i in 1:100 - _du = rand(N,N,3) - _u = rand(N,N,3) - multithreadedf(_du,_u) - _du2 = copy(_du) - f(_du2,_u,nothing,0.0) - @test _du ≈ _du2 -end - -#= -jac = sparse(ModelingToolkit.jacobian(vec(du),vec(u))) -fjac = eval(ModelingToolkit.build_function(jac,u,parallel=ModelingToolkit.SerialForm())[2]) -multithreadedfjac = eval(ModelingToolkit.build_function(jac,u,parallel=ModelingToolkit.MultithreadedForm())[2]) - -u = rand(N,N,3) -J = similar(jac,Float64) -fjac(J,u) - -J2 = similar(jac,Float64) -multithreadedfjac(J2,u) -@test J ≈ J2 - -using FiniteDiff -J3 = Array(similar(jac,Float64)) -FiniteDiff.finite_difference_jacobian!(J2,(du,u)->f!(du,u,nothing,nothing),u) -maximum(J2 .- Array(J)) < 1e-5 -=# - -jac = ModelingToolkit.sparsejacobian(vec(du),vec(u)) -serialjac = eval(ModelingToolkit.build_function(vec(jac),u)[2]) -multithreadedjac = eval(ModelingToolkit.build_function(vec(jac),u,parallel=ModelingToolkit.MultithreadedForm())[2]) - -MyA = zeros(N,N) -AMx = zeros(N,N) -DA = zeros(N,N) -_du = rand(N,N,3) -_u = rand(N,N,3) - -f(_du,_u,nothing,0.0) -multithreadedf(_du,_u) - -#= -using BenchmarkTools -@btime f(_du,_u,nothing,0.0) -@btime multithreadedf(_du,_u) - -_jac = similar(jac,Float64) -@btime serialjac(_jac,_u) -@btime multithreadedjac(_jac,_u) -=# +using ModelingToolkit, LinearAlgebra, SparseArrays +using Symbolics +using Symbolics: scalarize + +# Define the constants for the PDE +const α₂ = 1.0 +const α₃ = 1.0 +const β₁ = 1.0 +const β₂ = 1.0 +const β₃ = 1.0 +const r₁ = 1.0 +const r₂ = 1.0 +const _DD = 100.0 +const γ₁ = 0.1 +const γ₂ = 0.1 +const γ₃ = 0.1 +const N = 8 +const X = reshape([i for i in 1:N for j in 1:N], N, N) +const Y = reshape([j for i in 1:N for j in 1:N], N, N) +const α₁ = 1.0 .* (X .>= 4 * N / 5) + +const Mx = Tridiagonal([1.0 for i in 1:(N - 1)], [-2.0 for i in 1:N], + [1.0 for i in 1:(N - 1)]) +const My = copy(Mx) +Mx[2, 1] = 2.0 +Mx[end - 1, end] = 2.0 +My[1, 2] = 2.0 +My[end, end - 1] = 2.0 + +# Define the initial condition as normal arrays +@variables du[1:N, 1:N, 1:3] u[1:N, 1:N, 1:3] MyA[1:N, 1:N] AMx[1:N, 1:N] DA[1:N, 1:N] + +du, u, MyA, AMx, DA = scalarize.((du, u, MyA, AMx, DA)) +@show typeof.((du, u, MyA, AMx, DA)) + +# Define the discretized PDE as an ODE function +function f(du, u, p, t) + A = @view u[:, :, 1] + B = @view u[:, :, 2] + C = @view u[:, :, 3] + dA = @view du[:, :, 1] + dB = @view du[:, :, 2] + dC = @view du[:, :, 3] + mul!(MyA, My, A) + mul!(AMx, A, Mx) + @. DA = _DD * (MyA + AMx) + @. dA = DA + α₁ - β₁ * A - r₁ * A * B + r₂ * C + @. dB = α₂ - β₂ * B - r₁ * A * B + r₂ * C + @. dC = α₃ - β₃ * C + r₁ * A * B - r₂ * C +end + +f(du, u, nothing, 0.0) + +multithreadedf = eval(ModelingToolkit.build_function(du, u, fillzeros = true, + parallel = ModelingToolkit.MultithreadedForm())[2]) + +MyA = zeros(N, N); +AMx = zeros(N, N); +DA = zeros(N, N); +# Loop to catch synchronization issues +for i in 1:100 + _du = rand(N, N, 3) + _u = rand(N, N, 3) + multithreadedf(_du, _u) + _du2 = copy(_du) + f(_du2, _u, nothing, 0.0) + @test _du ≈ _du2 +end + +#= +jac = sparse(ModelingToolkit.jacobian(vec(du),vec(u))) +fjac = eval(ModelingToolkit.build_function(jac,u,parallel=ModelingToolkit.SerialForm())[2]) +multithreadedfjac = eval(ModelingToolkit.build_function(jac,u,parallel=ModelingToolkit.MultithreadedForm())[2]) + +u = rand(N,N,3) +J = similar(jac,Float64) +fjac(J,u) + +J2 = similar(jac,Float64) +multithreadedfjac(J2,u) +@test J ≈ J2 + +using FiniteDiff +J3 = Array(similar(jac,Float64)) +FiniteDiff.finite_difference_jacobian!(J2,(du,u)->f!(du,u,nothing,nothing),u) +maximum(J2 .- Array(J)) < 1e-5 +=# + +jac = ModelingToolkit.sparsejacobian(vec(du), vec(u)) +serialjac = eval(ModelingToolkit.build_function(vec(jac), u)[2]) +#multithreadedjac = eval(ModelingToolkit.build_function(vec(jac), u, +# parallel = ModelingToolkit.MultithreadedForm())[2]) + +MyA = zeros(N, N) +AMx = zeros(N, N) +DA = zeros(N, N) +_du = rand(N, N, 3) +_u = rand(N, N, 3) + +f(_du, _u, nothing, 0.0) +multithreadedf(_du, _u) + +#= +using BenchmarkTools +@btime f(_du,_u,nothing,0.0) +@btime multithreadedf(_du,_u) + +_jac = similar(jac,Float64) +@btime serialjac(_jac,_u) +@btime multithreadedjac(_jac,_u) +=# diff --git a/test/causal_variables_connection.jl b/test/causal_variables_connection.jl new file mode 100644 index 0000000000..c22e8319d4 --- /dev/null +++ b/test/causal_variables_connection.jl @@ -0,0 +1,98 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Error checking" begin + @variables begin + x(t) + y(t), [input = true] + z(t), [output = true] + w(t) + v(t), [input = true] + u(t), [output = true] + xarr(t)[1:4], [output = true] + yarr(t)[1:2, 1:2], [input = true] + end + @parameters begin + p, [input = true] + q, [output = true] + end + + @test_throws ["p", "kind", "VARIABLE", "PARAMETER"] connect(z, p) + @test_throws ["q", "kind", "VARIABLE", "PARAMETER"] connect(q, y) + @test_throws ["p", "kind", "VARIABLE", "PARAMETER"] connect(z, y, p) + + @test_throws ["unique"] connect(z, y, y) + + @test_throws ["same size"] connect(xarr, yarr) + + @test_throws ["Expected", "x", "output = true", "metadata"] connect(x, y) + @test_throws ["Expected", "y", "output = true", "metadata"] connect(y, v) + + @test_throws ["Expected", "x", "input = true", "metadata"] connect(z, x) + @test_throws ["Expected", "x", "input = true", "metadata"] connect(z, y, x) + @test_throws ["Expected", "u", "input = true", "metadata"] connect(z, u) + @test_throws ["Expected", "u", "input = true", "metadata"] connect(z, y, u) +end + +@testset "Connection expansion" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + eqs = [connect(P.output.u, C.input.u) + connect(C.output.u, P.input.u)] + sys1 = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys = expand_connections(sys1) + @test any(isequal(P.output.u ~ C.input.u), equations(sys)) + @test any(isequal(C.output.u ~ P.input.u), equations(sys)) + + @named sysouter = ODESystem(Equation[], t; systems = [sys1]) + sys = expand_connections(sysouter) + @test any(isequal(sys1.P.output.u ~ sys1.C.input.u), equations(sys)) + @test any(isequal(sys1.C.output.u ~ sys1.P.input.u), equations(sys)) +end + +@testset "With Analysis Points" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + eqs = [connect(P.output, C.input), connect(C.output.u, ap, P.input.u)] + sys = ODESystem(eqs, t, systems = [P, C], name = :hej) + @named nested_sys = ODESystem(Equation[], t; systems = [sys]) + + test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) + ] + + @testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 + end + + @testset "get_comp_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_comp_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive or negative + @test matrices.D[] == 0 + end + + @testset "get_looptransfer - $name" for (name, sys, ap) in test_cases + matrices, _ = get_looptransfer(sys, ap) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 + end + + @testset "open_loop - $name" for (name, sys, ap) in test_cases + open_sys, (du, u) = open_loop(sys, ap) + matrices, _ = linearize(open_sys, [du], [u]) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 + end +end diff --git a/test/ccompile.jl b/test/ccompile.jl index 68471ac770..4572119ab8 100644 --- a/test/ccompile.jl +++ b/test/ccompile.jl @@ -1,15 +1,18 @@ using ModelingToolkit, Test -@parameters t a +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters a @variables x y -D = Differential(t) -eqs = [D(x) ~ a*x - x*y, - D(y) ~ -3y + x*y] -f = build_function(eqs,[x,y],[a],t,expression=Val{false},target=ModelingToolkit.CTarget()) -f2 = eval(build_function([x.rhs for x in eqs],[x,y],[a],t)[2]) -du = rand(2); du2 = rand(2) +eqs = [D(x) ~ a * x - x * y, + D(y) ~ -3y + x * y] +f = build_function([x.rhs for x in eqs], [x, y], [a], t, expression = Val{false}, + target = ModelingToolkit.CTarget()) +f2 = eval(build_function([x.rhs for x in eqs], [x, y], [a], t)[2]) +du = rand(2); +du2 = rand(2); u = rand(2) p = rand(1) _t = rand() -f(du,u,p,_t) -f2(du2,u,p,_t) -@test du == du2 +f(du, u, p, _t) +f2(du2, u, p, _t) +@test du ≈ du2 diff --git a/test/clock.jl b/test/clock.jl new file mode 100644 index 0000000000..961c7af181 --- /dev/null +++ b/test/clock.jl @@ -0,0 +1,533 @@ +using ModelingToolkit, Test, Setfield, OrdinaryDiffEq, DiffEqCallbacks +using ModelingToolkit: ContinuousClock +using ModelingToolkit: t_nounits as t, D_nounits as D + +function infer_clocks(sys) + ts = TearingState(sys) + ci = ModelingToolkit.ClockInference(ts) + ModelingToolkit.infer_clocks!(ci), Dict(ci.ts.fullvars .=> ci.var_domain) +end + +@info "Testing hybrid system" +dt = 0.1 +@variables x(t) y(t) u(t) yd(t) ud(t) r(t) +@parameters kp +# u(n + 1) := f(u(n)) + +eqs = [yd ~ Sample(dt)(y) + ud ~ kp * (r - yd) + r ~ 1.0 + + # plant (time continuous part) + u ~ Hold(ud) + D(x) ~ -x + u + y ~ x] +@named sys = ODESystem(eqs, t) +# compute equation and variables' time domains +#TODO: test linearize + +#= + Differential(t)(x(t)) ~ u(t) - x(t) + 0 ~ Sample(Clock(t, 0.1))(y(t)) - yd(t) + 0 ~ kp*(r(t) - yd(t)) - ud(t) + 0 ~ Hold()(ud(t)) - u(t) + 0 ~ x(t) - y(t) + +==== +By inference: + + Differential(t)(x(t)) ~ u(t) - x(t) + 0 ~ Hold()(ud(t)) - u(t) # Hold()(ud(t)) is constant except in an event + 0 ~ x(t) - y(t) + + 0 ~ Sample(Clock(t, 0.1))(y(t)) - yd(t) + 0 ~ kp*(r(t) - yd(t)) - ud(t) + +==== + + Differential(t)(x(t)) ~ u(t) - x(t) + 0 ~ Hold()(ud(t)) - u(t) + 0 ~ x(t) - y(t) + + yd(t) := Sample(Clock(t, 0.1))(y(t)) + ud(t) := kp*(r(t) - yd(t)) +=# + +#= + D(x) ~ Shift(x, 0, dt) + 1 # this should never meet with continuous variables +=> (Shift(x, 0, dt) - Shift(x, -1, dt))/dt ~ Shift(x, 0, dt) + 1 +=> Shift(x, 0, dt) - Shift(x, -1, dt) ~ Shift(x, 0, dt) * dt + dt +=> Shift(x, 0, dt) - Shift(x, 0, dt) * dt ~ Shift(x, -1, dt) + dt +=> (1 - dt) * Shift(x, 0, dt) ~ Shift(x, -1, dt) + dt +=> Shift(x, 0, dt) := (Shift(x, -1, dt) + dt) / (1 - dt) # Discrete system +=# + +ci, varmap = infer_clocks(sys) +eqmap = ci.eq_domain +tss, inputs, continuous_id = ModelingToolkit.split_system(deepcopy(ci)) +sss, = ModelingToolkit._structural_simplify!( + deepcopy(tss[continuous_id]), (inputs[continuous_id], ())) +@test equations(sss) == [D(x) ~ u - x] +sss, = ModelingToolkit._structural_simplify!(deepcopy(tss[1]), (inputs[1], ())) +@test isempty(equations(sss)) +d = Clock(dt) +k = ShiftIndex(d) +@test observed(sss) == [yd(k + 1) ~ Sample(dt)(y); r(k + 1) ~ 1.0; + ud(k + 1) ~ kp * (r(k + 1) - yd(k + 1))] + +d = Clock(dt) +# Note that TearingState reorders the equations +@test eqmap[1] == ContinuousClock() +@test eqmap[2] == d +@test eqmap[3] == d +@test eqmap[4] == d +@test eqmap[5] == ContinuousClock() +@test eqmap[6] == ContinuousClock() + +@test varmap[yd] == d +@test varmap[ud] == d +@test varmap[r] == d +@test varmap[x] == ContinuousClock() +@test varmap[y] == ContinuousClock() +@test varmap[u] == ContinuousClock() + +@info "Testing shift normalization" +dt = 0.1 +@variables x(t) y(t) u(t) yd(t) ud(t) +@parameters kp +d = Clock(dt) +k = ShiftIndex(d) + +eqs = [yd ~ Sample(dt)(y) + ud ~ kp * yd + ud(k - 2) + + # plant (time continuous part) + u ~ Hold(ud) + D(x) ~ -x + u + y ~ x] +@named sys = ODESystem(eqs, t) +@test_throws ModelingToolkit.HybridSystemNotSupportedException ss=structural_simplify(sys); + +@test_skip begin + Tf = 1.0 + prob = ODEProblem(ss, [x => 0.1], (0.0, Tf), + [kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0]) + # create integrator so callback is evaluated at t=0 and we can test correct param values + int = init(prob, Tsit5(); kwargshandle = KeywordArgSilent) + @test sort(vcat(int.p...)) == [0.1, 1.0, 2.1, 2.1, 2.1] # yd, kp, ud(k-1), ud, Hold(ud) + prob = ODEProblem(ss, [x => 0.1], (0.0, Tf), + [kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0]) # recreate problem to empty saved values + sol = solve(prob, Tsit5(), kwargshandle = KeywordArgSilent) + + ss_nosplit = structural_simplify(sys; split = false) + prob_nosplit = ODEProblem(ss_nosplit, [x => 0.1], (0.0, Tf), + [kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0]) + int = init(prob_nosplit, Tsit5(); kwargshandle = KeywordArgSilent) + @test sort(int.p) == [0.1, 1.0, 2.1, 2.1, 2.1] # yd, kp, ud(k-1), ud, Hold(ud) + prob_nosplit = ODEProblem(ss_nosplit, [x => 0.1], (0.0, Tf), + [kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0]) # recreate problem to empty saved values + sol_nosplit = solve(prob_nosplit, Tsit5(), kwargshandle = KeywordArgSilent) + # For all inputs in parameters, just initialize them to 0.0, and then set them + # in the callback. + + # kp is the only real parameter + function foo!(du, u, p, t) + x = u[1] + ud = p[2] + du[1] = -x + ud + end + function affect!(integrator, saved_values) + yd = integrator.u[1] + kp = integrator.p[1] + ud = integrator.p[2] + udd = integrator.p[3] + + integrator.p[2] = kp * yd + udd + integrator.p[3] = ud + + push!(saved_values.t, integrator.t) + push!(saved_values.saveval, [integrator.p[2], integrator.p[3]]) + + nothing + end + saved_values = SavedValues(Float64, Vector{Float64}) + cb = PeriodicCallback( + Base.Fix2(affect!, saved_values), 0.1; final_affect = true, initial_affect = true) + # kp ud + prob = ODEProblem(foo!, [0.1], (0.0, Tf), [1.0, 2.1, 2.0], callback = cb) + sol2 = solve(prob, Tsit5()) + @test sol.u == sol2.u + @test sol_nosplit.u == sol2.u + @test saved_values.t == sol.prob.kwargs[:disc_saved_values][1].t + @test saved_values.t == sol_nosplit.prob.kwargs[:disc_saved_values][1].t + @test saved_values.saveval == sol.prob.kwargs[:disc_saved_values][1].saveval + @test saved_values.saveval == sol_nosplit.prob.kwargs[:disc_saved_values][1].saveval + + @info "Testing multi-rate hybrid system" + dt = 0.1 + dt2 = 0.2 + @variables x(t) y(t) u(t) r(t) yd1(t) ud1(t) yd2(t) ud2(t) + @parameters kp + + eqs = [ + # controller (time discrete part `dt=0.1`) + yd1 ~ Sample(dt)(y) + ud1 ~ kp * (Sample(dt)(r) - yd1) + yd2 ~ Sample(dt2)(y) + ud2 ~ kp * (Sample(dt2)(r) - yd2) + + # plant (time continuous part) + u ~ Hold(ud1) + Hold(ud2) + D(x) ~ -x + u + y ~ x] + @named sys = ODESystem(eqs, t) + ci, varmap = infer_clocks(sys) + + d = Clock(dt) + d2 = Clock(dt2) + #@test get_eq_domain(eqs[1]) == d + #@test get_eq_domain(eqs[3]) == d2 + + @test varmap[yd1] == d + @test varmap[ud1] == d + @test varmap[yd2] == d2 + @test varmap[ud2] == d2 + @test varmap[r] == ContinuousClock() + @test varmap[x] == ContinuousClock() + @test varmap[y] == ContinuousClock() + @test varmap[u] == ContinuousClock() + + @info "test composed systems" + + dt = 0.5 + d = Clock(dt) + k = ShiftIndex(d) + timevec = 0:0.1:4 + + function plant(; name) + @variables x(t)=1 u(t)=0 y(t)=0 + eqs = [D(x) ~ -x + u + y ~ x] + ODESystem(eqs, t; name = name) + end + + function filt(; name) + @variables x(t)=0 u(t)=0 y(t)=0 + a = 1 / exp(dt) + eqs = [x ~ a * x(k - 1) + (1 - a) * u(k - 1) + y ~ x] + ODESystem(eqs, t, name = name) + end + + function controller(kp; name) + @variables y(t)=0 r(t)=0 ud(t)=0 yd(t)=0 + @parameters kp = kp + eqs = [yd ~ Sample(y) + ud ~ kp * (r - yd)] + ODESystem(eqs, t; name = name) + end + + @named f = filt() + @named c = controller(1) + @named p = plant() + + connections = [f.u ~ -1#(t >= 1) # step input + f.y ~ c.r # filtered reference to controller reference + Hold(c.ud) ~ p.u # controller output to plant input + p.y ~ c.y] + + @named cl = ODESystem(connections, t, systems = [f, c, p]) + + ci, varmap = infer_clocks(cl) + + @test varmap[f.x] == Clock(0.5) + @test varmap[p.x] == ContinuousClock() + @test varmap[p.y] == ContinuousClock() + @test varmap[c.ud] == Clock(0.5) + @test varmap[c.yd] == Clock(0.5) + @test varmap[c.y] == ContinuousClock() + @test varmap[f.y] == Clock(0.5) + @test varmap[f.u] == Clock(0.5) + @test varmap[p.u] == ContinuousClock() + @test varmap[c.r] == Clock(0.5) + + ## Multiple clock rates + @info "Testing multi-rate hybrid system" + dt = 0.1 + dt2 = 0.2 + @variables x(t)=0 y(t)=0 u(t)=0 yd1(t)=0 ud1(t)=0 yd2(t)=0 ud2(t)=0 + @parameters kp=1 r=1 + + eqs = [ + # controller (time discrete part `dt=0.1`) + yd1 ~ Sample(dt)(y) + ud1 ~ kp * (r - yd1) + # controller (time discrete part `dt=0.2`) + yd2 ~ Sample(dt2)(y) + ud2 ~ kp * (r - yd2) + + # plant (time continuous part) + u ~ Hold(ud1) + Hold(ud2) + D(x) ~ -x + u + y ~ x] + + @named cl = ODESystem(eqs, t) + + d = Clock(dt) + d2 = Clock(dt2) + + ci, varmap = infer_clocks(cl) + @test varmap[yd1] == d + @test varmap[ud1] == d + @test varmap[yd2] == d2 + @test varmap[ud2] == d2 + @test varmap[x] == ContinuousClock() + @test varmap[y] == ContinuousClock() + @test varmap[u] == ContinuousClock() + + ss = structural_simplify(cl) + ss_nosplit = structural_simplify(cl; split = false) + + if VERSION >= v"1.7" + prob = ODEProblem(ss, [x => 0.0], (0.0, 1.0), [kp => 1.0]) + prob_nosplit = ODEProblem(ss_nosplit, [x => 0.0], (0.0, 1.0), [kp => 1.0]) + sol = solve(prob, Tsit5(), kwargshandle = KeywordArgSilent) + sol_nosplit = solve(prob_nosplit, Tsit5(), kwargshandle = KeywordArgSilent) + + function foo!(dx, x, p, t) + kp, ud1, ud2 = p + dx[1] = -x[1] + ud1 + ud2 + end + + function affect1!(integrator) + kp = integrator.p[1] + y = integrator.u[1] + r = 1.0 + ud1 = kp * (r - y) + integrator.p[2] = ud1 + nothing + end + function affect2!(integrator) + kp = integrator.p[1] + y = integrator.u[1] + r = 1.0 + ud2 = kp * (r - y) + integrator.p[3] = ud2 + nothing + end + cb1 = PeriodicCallback(affect1!, dt; final_affect = true, initial_affect = true) + cb2 = PeriodicCallback(affect2!, dt2; final_affect = true, initial_affect = true) + cb = CallbackSet(cb1, cb2) + # kp ud1 ud2 + prob = ODEProblem(foo!, [0.0], (0.0, 1.0), [1.0, 1.0, 1.0], callback = cb) + sol2 = solve(prob, Tsit5()) + + @test sol.u≈sol2.u atol=1e-6 + @test sol_nosplit.u≈sol2.u atol=1e-6 + end + + ## + @info "Testing hybrid system with components" + using ModelingToolkitStandardLibrary.Blocks + + dt = 0.05 + d = Clock(dt) + k = ShiftIndex(d) + + @mtkmodel DiscretePI begin + @components begin + input = RealInput() + output = RealOutput() + end + @parameters begin + kp = 1, [description = "Proportional gain"] + ki = 1, [description = "Integral gain"] + end + @variables begin + x(t) = 0, [description = "Integral state"] + u(t) + y(t) + end + @equations begin + x(k) ~ x(k - 1) + ki * u(k) * SampleTime() / dt + output.u(k) ~ y(k) + input.u(k) ~ u(k) + y(k) ~ x(k - 1) + kp * u(k) + end + end + + @mtkmodel Sampler begin + @components begin + input = RealInput() + output = RealOutput() + end + @equations begin + output.u ~ Sample(dt)(input.u) + end + end + + @mtkmodel ZeroOrderHold begin + @extend u, y = siso = Blocks.SISO() + @equations begin + y ~ Hold(u) + end + end + + @mtkmodel ClosedLoop begin + @components begin + plant = FirstOrder(k = 0.3, T = 1) + sampler = Sampler() + holder = ZeroOrderHold() + controller = DiscretePI(kp = 2, ki = 2) + feedback = Feedback() + ref = Constant(k = 0.5) + end + @equations begin + connect(ref.output, feedback.input1) + connect(feedback.output, controller.input) + connect(controller.output, holder.input) + connect(holder.output, plant.input) + connect(plant.output, sampler.input) + connect(sampler.output, feedback.input2) + end + end + + ## + @named model = ClosedLoop() + _model = complete(model) + + ci, varmap = infer_clocks(expand_connections(_model)) + + @test varmap[_model.plant.input.u] == ContinuousClock() + @test varmap[_model.plant.u] == ContinuousClock() + @test varmap[_model.plant.x] == ContinuousClock() + @test varmap[_model.plant.y] == ContinuousClock() + @test varmap[_model.plant.output.u] == ContinuousClock() + @test varmap[_model.holder.output.u] == ContinuousClock() + @test varmap[_model.sampler.input.u] == ContinuousClock() + @test varmap[_model.controller.u] == d + @test varmap[_model.holder.input.u] == d + @test varmap[_model.controller.output.u] == d + @test varmap[_model.controller.y] == d + @test varmap[_model.feedback.input1.u] == d + @test varmap[_model.ref.output.u] == d + @test varmap[_model.controller.input.u] == d + @test varmap[_model.controller.x] == d + @test varmap[_model.sampler.output.u] == d + @test varmap[_model.feedback.output.u] == d + @test varmap[_model.feedback.input2.u] == d + + ssys = structural_simplify(model) + + Tf = 0.2 + timevec = 0:(d.dt):Tf + + import ControlSystemsBase as CS + import ControlSystemsBase: c2d, tf, feedback, lsim + # z = tf('z', d.dt) + # P = c2d(tf(0.3, [1, 1]), d.dt) + P = c2d(CS.ss([-1], [0.3], [1], 0), d.dt) + C = CS.ss([1], [2], [1], [2], d.dt) + + # Test the output of the continuous partition + G = feedback(P * C) + res = lsim(G, (x, t) -> [0.5], timevec) + y = res.y[:] + + # plant = FirstOrder(k = 0.3, T = 1) + # controller = DiscretePI(kp = 2, ki = 2) + # ref = Constant(k = 0.5) + + # ; model.controller.x(k-1) => 0.0 + prob = ODEProblem(ssys, + [model.plant.x => 0.0; model.controller.kp => 2.0; model.controller.ki => 2.0], + (0.0, Tf)) + int = init(prob, Tsit5(); kwargshandle = KeywordArgSilent) + @test_broken int.ps[Hold(ssys.holder.input.u)] == 2 # constant output * kp issue https://github.com/SciML/ModelingToolkit.jl/issues/2356 + @test int.ps[ssys.controller.x] == 1 # c2d + @test int.ps[Sample(d)(ssys.sampler.input.u)] == 0 # disc state + sol = solve(prob, + Tsit5(), + kwargshandle = KeywordArgSilent, + abstol = 1e-8, + reltol = 1e-8) + @test_skip begin + # plot([y sol(timevec, idxs = model.plant.output.u)], m = :o, lab = ["CS" "MTK"]) + + ## + + @test sol(timevec, idxs = model.plant.output.u)≈y rtol=1e-8 # The output of the continuous partition is delayed exactly one sample + + # Test the output of the discrete partition + G = feedback(C, P) + res = lsim(G, (x, t) -> [0.5], timevec) + y = res.y[:] + + @test_broken sol(timevec .+ 1e-10, idxs = model.controller.output.u)≈y rtol=1e-8 # Broken due to discrete observed + # plot([y sol(timevec .+ 1e-12, idxs=model.controller.output.u)], lab=["CS" "MTK"]) + + # TODO: test the same system, but with the PI controller implemented as + # x(k) ~ x(k-1) + ki * u + # y ~ x(k-1) + kp * u + # Instead. This should be equivalent to the above, but gve me an error when I tried + end + + ## Test continuous clock + + c = ModelingToolkit.SolverStepClock() + k = ShiftIndex(c) + + @mtkmodel CounterSys begin + @variables begin + count(t) = 0 + u(t) = 0 + ud(t) = 0 + end + @equations begin + ud ~ Sample(c)(u) + count ~ ud(k - 1) + end + end + + @mtkmodel FirstOrderSys begin + @variables begin + x(t) = 0 + end + @equations begin + D(x) ~ -x + sin(t) + end + end + + @mtkmodel FirstOrderWithStepCounter begin + @components begin + counter = CounterSys() + firstorder = FirstOrderSys() + end + @equations begin + counter.u ~ firstorder.x + end + end + + @mtkbuild model = FirstOrderWithStepCounter() + prob = ODEProblem(model, [], (0.0, 10.0)) + sol = solve(prob, Tsit5(), kwargshandle = KeywordArgSilent) + + @test sol.prob.kwargs[:disc_saved_values][1].t == sol.t[1:2:end] # Test that the discrete-time system executed at every step of the continuous solver. The solver saves each time step twice, one state value before discrete affect and one after. + @test_nowarn ModelingToolkit.build_explicit_observed_function( + model, model.counter.ud)(sol.u[1], prob.p, sol.t[1]) + + @variables x(t)=1.0 y(t)=1.0 + eqs = [D(y) ~ Hold(x) + x ~ x(k - 1) + x(k - 2)] + @mtkbuild sys = ODESystem(eqs, t) + prob = ODEProblem(sys, [], (0.0, 10.0)) + int = init(prob, Tsit5(); kwargshandle = KeywordArgSilent) + @test int.ps[x] == 2.0 + @test int.ps[x(k - 1)] == 1.0 + + @test_throws ErrorException ODEProblem(sys, [], (0.0, 10.0), [x => 2.0]) + prob = ODEProblem(sys, [], (0.0, 10.0), [x(k - 1) => 2.0]) + int = init(prob, Tsit5(); kwargshandle = KeywordArgSilent) + @test int.ps[x] == 3.0 + @test int.ps[x(k - 1)] == 2.0 +end diff --git a/test/code_generation.jl b/test/code_generation.jl new file mode 100644 index 0000000000..cf3d660b81 --- /dev/null +++ b/test/code_generation.jl @@ -0,0 +1,80 @@ +using ModelingToolkit, OrdinaryDiffEq, SymbolicIndexingInterface +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "`generate_custom_function`" begin + @variables x(t) y(t)[1:3] + @parameters p1=1.0 p2[1:3]=[1.0, 2.0, 3.0] p3::Int=1 p4::Bool=false + + sys = complete(ODESystem(Equation[], t, [x; y], [p1, p2, p3, p4]; name = :sys)) + u0 = [1.0, 2.0, 3.0, 4.0] + p = ModelingToolkit.MTKParameters(sys, []) + + fn1 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3 * t; expression = Val(false)) + @test fn1(u0, p, 0.0) == 5.0 + + fn2 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3 * t, [x], [p1, p2, p3]; expression = Val(false)) + @test fn1(u0, p, 0.0) == 5.0 + + fn3_oop, fn3_iip = generate_custom_function( + sys, [x + y[2], y[3] + p2[2], p1 + p3, 3t]; expression = Val(false)) + + buffer = zeros(4) + fn3_iip(buffer, u0, p, 1.0) + @test buffer == [4.0, 6.0, 2.0, 3.0] + @test fn3_oop(u0, p, 1.0) == [4.0, 6.0, 2.0, 3.0] + + fn4 = generate_custom_function(sys, ifelse(p4, p1, p2[2]); expression = Val(false)) + @test fn4(u0, p, 1.0) == 2.0 + fn5 = generate_custom_function(sys, ifelse(!p4, p1, p2[2]); expression = Val(false)) + @test fn5(u0, p, 1.0) == 1.0 + + @variables x y[1:3] + sys = complete(NonlinearSystem(Equation[], [x; y], [p1, p2, p3, p4]; name = :sys)) + p = MTKParameters(sys, []) + + fn1 = generate_custom_function(sys, x + y[1] + p1 + p2[1] + p3; expression = Val(false)) + @test fn1(u0, p) == 6.0 + + fn2 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3, [x], [p1, p2, p3]; expression = Val(false)) + @test fn1(u0, p) == 6.0 + + fn3_oop, fn3_iip = generate_custom_function( + sys, [x + y[2], y[3] + p2[2], p1 + p3]; expression = Val(false)) + + buffer = zeros(3) + fn3_iip(buffer, u0, p) + @test buffer == [4.0, 6.0, 2.0] + @test fn3_oop(u0, p, 1.0) == [4.0, 6.0, 2.0] + + fn4 = generate_custom_function(sys, ifelse(p4, p1, p2[2]); expression = Val(false)) + @test fn4(u0, p, 1.0) == 2.0 + fn5 = generate_custom_function(sys, ifelse(!p4, p1, p2[2]); expression = Val(false)) + @test fn5(u0, p, 1.0) == 1.0 +end + +@testset "Non-standard array variables" begin + @variables x(t) + @parameters p[0:2] (f::Function)(..) + @mtkbuild sys = ODESystem(D(x) ~ p[0] * x + p[1] * t + p[2] + f(p), t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => [1.0, 2.0, 3.0], f => sum]) + @test prob.ps[p] == [1.0, 2.0, 3.0] + @test prob.ps[p[0]] == 1.0 + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + + @testset "Array split across buffers" begin + @variables x(t)[0:2] + @parameters p[1:2] (f::Function)(..) + @named sys = ODESystem( + [D(x[0]) ~ p[1] * x[0] + x[2], D(x[1]) ~ p[2] * f(x) + x[2]], t) + sys, = structural_simplify(sys, ([x[2]], [])) + @test is_parameter(sys, x[2]) + prob = ODEProblem(sys, [x[0] => 1.0, x[1] => 1.0], (0.0, 1.0), + [p => ones(2), f => sum, x[2] => 2.0]) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + end +end diff --git a/test/complex.jl b/test/complex.jl new file mode 100644 index 0000000000..69cc22c985 --- /dev/null +++ b/test/complex.jl @@ -0,0 +1,16 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t +using Test + +@mtkmodel ComplexModel begin + @variables begin + x(t) + y(t) + z(t)::Complex + end + @equations begin + z ~ x + im * y + end +end +@named mixed = ComplexModel() +@test length(equations(mixed)) == 2 diff --git a/test/components.jl b/test/components.jl index 90df0028e3..8ac40f6fbb 100644 --- a/test/components.jl +++ b/test/components.jl @@ -1,46 +1,408 @@ -using Test -using ModelingToolkit, OrdinaryDiffEq - -include("../examples/rc_model.jl") - -sys = structural_simplify(rc_model) -@test !isempty(ModelingToolkit.defaults(sys)) -u0 = [ - capacitor.v => 0.0 - capacitor.p.i => 0.0 - resistor.v => 0.0 - ] -prob = ODEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Rodas4()) - -@test sol[resistor.p.i] == sol[capacitor.p.i] -@test sol[resistor.n.i] == -sol[capacitor.p.i] -@test sol[capacitor.n.i] == -sol[capacitor.p.i] -@test iszero(sol[ground.g.i]) -@test iszero(sol[ground.g.v]) -@test sol[resistor.v] == sol[source.p.v] - sol[capacitor.p.v] - -prob = ODAEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Tsit5()) - -@test sol[resistor.p.i] == sol[capacitor.p.i] -@test sol[resistor.n.i] == -sol[capacitor.p.i] -@test sol[capacitor.n.i] == -sol[capacitor.p.i] -@test iszero(sol[ground.g.i]) -@test iszero(sol[ground.g.v]) -@test sol[resistor.v] == sol[source.p.v] - sol[capacitor.p.v] -#using Plots -#plot(sol) - -include("../examples/serial_inductor.jl") -sys = structural_simplify(ll_model) -u0 = [ - inductor1.i => 0.0 - inductor2.i => 0.0 - inductor2.v => 0.0 - ] -prob = ODEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Rodas4()) - -prob = ODAEProblem(sys, u0, (0, 10.0)) -sol = solve(prob, Tsit5()) +using Test +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: get_component_type +using ModelingToolkit.BipartiteGraphs +using ModelingToolkit.StructuralTransformations +using ModelingToolkit: t_nounits as t, D_nounits as D +include("../examples/rc_model.jl") + +function check_contract(sys) + state = ModelingToolkit.get_tearing_state(sys) + graph = state.structure.graph + fullvars = state.fullvars + sys = tearing_substitution(sys) + + eqs = equations(sys) + for (i, eq) in enumerate(eqs) + actual = union(ModelingToolkit.vars(eq.lhs), ModelingToolkit.vars(eq.rhs)) + actual = filter(!ModelingToolkit.isparameter, collect(actual)) + current = Set(fullvars[𝑠neighbors(graph, i)]) + @test isempty(setdiff(actual, current)) + end +end + +function check_rc_sol(sol) + rpi = sol[rc_model.resistor.p.i] + rpifun = sol.prob.f.observed(rc_model.resistor.p.i) + @test rpifun.(sol.u, (sol.prob.p,), sol.t) == rpi + @test any(!isequal(rpi[1]), rpi) # test that we don't have a constant system + @test sol[rc_model.resistor.p.i] == sol[resistor.p.i] == sol[capacitor.p.i] + @test sol[rc_model.resistor.n.i] == sol[resistor.n.i] == -sol[capacitor.p.i] + @test sol[rc_model.capacitor.n.i] == sol[capacitor.n.i] == -sol[capacitor.p.i] + @test iszero(sol[rc_model.ground.g.i]) + @test iszero(sol[rc_model.ground.g.v]) + @test sol[rc_model.resistor.v] == sol[resistor.v] == + sol[source.p.v] - sol[capacitor.p.v] +end + +@named pin = Pin() +@test get_component_type(pin).name == :Pin +@test get_component_type(rc_model.resistor).name == :Resistor + +completed_rc_model = complete(rc_model) +@test isequal(completed_rc_model.resistor.n.i, resistor.n.i) +@test ModelingToolkit.n_expanded_connection_equations(capacitor) == 2 +@test length(equations(structural_simplify(rc_model, allow_parameter = false))) == 2 +sys = structural_simplify(rc_model) +@test_throws ModelingToolkit.RepeatedStructuralSimplificationError structural_simplify(sys) +@test length(equations(sys)) == 1 +check_contract(sys) +@test !isempty(ModelingToolkit.defaults(sys)) +u0 = [capacitor.v => 0.0] +prob = ODEProblem(sys, u0, (0, 10.0)) +sol = solve(prob, Rodas4()) +check_rc_sol(sol) + +# https://discourse.julialang.org/t/using-optimization-parameters-in-modelingtoolkit/82099 +let + @parameters param_r1 param_c1 + @named resistor = Resistor(R = param_r1) + @named capacitor = Capacitor(C = param_c1) + @named source = ConstantVoltage(V = 1.0) + @named ground = Ground() + + rc_eqs = [connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n) + connect(capacitor.n, ground.g)] + + @named _rc_model = ODESystem(rc_eqs, t) + @named rc_model = compose(_rc_model, + [resistor, capacitor, source, ground]) + sys = structural_simplify(rc_model) + u0 = [ + capacitor.v => 0.0 + ] + + params = [param_r1 => 1.0, param_c1 => 1.0] + tspan = (0.0, 10.0) + + prob = ODEProblem(sys, u0, tspan, params) + @test solve(prob, Tsit5()).retcode == ReturnCode.Success +end + +let + # 1478 + @named resistor2 = Resistor(R = R) + rc_eqs2 = [connect(source.p, resistor.p) + connect(resistor.n, resistor2.p) + connect(resistor2.n, capacitor.p) + connect(capacitor.n, source.n) + connect(capacitor.n, ground.g)] + + @named _rc_model2 = ODESystem(rc_eqs2, t) + @named rc_model2 = compose(_rc_model2, + [resistor, resistor2, capacitor, source, ground]) + sys2 = structural_simplify(rc_model2) + prob2 = ODEProblem(sys2, [source.p.i => 0.0], (0, 10.0), guesses = u0) + sol2 = solve(prob2, Rosenbrock23()) + @test sol2[source.p.i] ≈ sol2[rc_model2.source.p.i] ≈ -sol2[capacitor.i] + + prob3 = ODEProblem(sys2, [], (0, 10.0), guesses = u0) + sol3 = solve(prob2, Rosenbrock23()) + @test sol3[unknowns(rc_model2), end] ≈ sol2[unknowns(rc_model2), end] +end + +# Outer/inner connections +function rc_component(; name, R = 1, C = 1) + @parameters R=R C=C + @named p = Pin() + @named n = Pin() + @named resistor = Resistor(R = R) # test parent scope default of @named + @named capacitor = Capacitor(C = ParentScope(C)) + eqs = [connect(p, resistor.p); + connect(resistor.n, capacitor.p); + connect(capacitor.n, n)] + @named sys = ODESystem(eqs, t, [], [R, C]) + compose(sys, [p, n, resistor, capacitor]; name = name) +end + +@named ground = Ground() +@named source = ConstantVoltage(V = 1) +@named rc_comp = rc_component() +eqs = [connect(source.p, rc_comp.p) + connect(source.n, rc_comp.n) + connect(source.n, ground.g)] +@named sys′ = ODESystem(eqs, t) +@named sys_inner_outer = compose(sys′, [ground, source, rc_comp]) +@test_nowarn show(IOBuffer(), MIME"text/plain"(), sys_inner_outer) +expand_connections(sys_inner_outer, debug = true) +sys_inner_outer = structural_simplify(sys_inner_outer) +@test !isempty(ModelingToolkit.defaults(sys_inner_outer)) +u0 = [rc_comp.capacitor.v => 0.0] +prob = ODEProblem(sys_inner_outer, u0, (0, 10.0), sparse = true) +sol_inner_outer = solve(prob, Rodas4()) +@test sol[capacitor.v] ≈ sol_inner_outer[rc_comp.capacitor.v] + +u0 = [ + capacitor.v => 0.0 +] +prob = ODEProblem(sys, u0, (0, 10.0)) +sol = solve(prob, Tsit5()) + +@test sol[resistor.p.i] == sol[capacitor.p.i] +@test sol[resistor.n.i] == -sol[capacitor.p.i] +@test sol[capacitor.n.i] == -sol[capacitor.p.i] +@test iszero(sol[ground.g.i]) +@test iszero(sol[ground.g.v]) +@test sol[resistor.v] == sol[source.p.v] - sol[capacitor.p.v] +#using Plots +#plot(sol) + +include("../examples/serial_inductor.jl") +sys = structural_simplify(ll_model) +@test length(equations(sys)) == 2 +u0 = unknowns(sys) .=> 0 +@test_nowarn ODEProblem( + sys, [], (0, 10.0), guesses = u0, warn_initialize_determined = false) +prob = DAEProblem(sys, D.(unknowns(sys)) .=> 0, [], (0, 0.5), guesses = u0) +sol = solve(prob, DFBDF()) +@test sol.retcode == SciMLBase.ReturnCode.Success + +sys2 = structural_simplify(ll2_model) +@test length(equations(sys2)) == 3 +u0 = unknowns(sys) .=> 0 +prob = ODEProblem(sys, u0, (0, 10.0)) +@test_nowarn sol = solve(prob, FBDF()) + +@variables x1(t) x2(t) x3(t) x4(t) +@named sys1_inner = ODESystem([D(x1) ~ x1], t) +@named sys1_partial = compose(ODESystem([D(x2) ~ x2], t; name = :foo), sys1_inner) +@named sys1 = extend(ODESystem([D(x3) ~ x3], t; name = :foo), sys1_partial) +@named sys2 = compose(ODESystem([D(x4) ~ x4], t; name = :foo), sys1) +@test_nowarn sys2.sys1.sys1_inner.x1 # test the correct nesting + +# compose tests +function record_fun(; name) + pars = @parameters a=10 b=100 + ODESystem(Equation[], t, [], pars; name) +end + +function first_model(; name) + @named foo = record_fun() + + defs = Dict() + defs[foo.a] = 3 + defs[foo.b] = 300 + pars = @parameters x=2 y=20 + compose(ODESystem(Equation[], t, [], pars; name, defaults = defs), foo) +end +@named goo = first_model() +@unpack foo = goo +@test ModelingToolkit.defaults(goo)[foo.a] == 3 +@test ModelingToolkit.defaults(goo)[foo.b] == 300 + +#= +model Circuit + Ground ground; + Load load; + Resistor resistor; +equation + connect(load.p , ground.p); + connect(resistor.p, ground.p); +end Circuit; +model Load + extends TwoPin; + Resistor resistor; +equation + connect(p, resistor.p); + connect(resistor.n, n); +end Load; +=# + +function Load(; name) + R = 1 + @named p = Pin() + @named n = Pin() + @named resistor = Resistor(R = R) + eqs = [connect(p, resistor.p); + connect(resistor.n, n)] + @named sys = ODESystem(eqs, t) + compose(sys, [p, n, resistor]; name = name) +end + +function Circuit(; name) + R = 1 + @named ground = Ground() + @named load = Load() + @named resistor = Resistor(R = R) + eqs = [connect(load.p, ground.g); + connect(resistor.p, ground.g)] + @named sys = ODESystem(eqs, t) + compose(sys, [ground, resistor, load]; name = name) +end + +@named foo = Circuit() +@test structural_simplify(foo) isa ModelingToolkit.AbstractSystem + +# BLT tests +using LinearAlgebra +function parallel_rc_model(i; name, source, ground, R, C) + resistor = HeatingResistor(name = Symbol(:resistor, i), R = R) + capacitor = Capacitor(name = Symbol(:capacitor, i), C = C) + heat_capacitor = HeatCapacitor(name = Symbol(:heat_capacitor, i)) + + rc_eqs = [connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + connect(resistor.h, heat_capacitor.h)] + + compose(ODESystem(rc_eqs, t, name = Symbol(name, i)), + [resistor, capacitor, source, ground, heat_capacitor]) +end +V = 2.0 +@named source = ConstantVoltage(V = V) +@named ground = Ground() +N = 50 +Rs = 10 .^ range(0, stop = -4, length = N) +Cs = 10 .^ range(-3, stop = 0, length = N) +rc_systems = map(1:N) do i + parallel_rc_model(i; name = :rc, source = source, ground = ground, R = Rs[i], C = Cs[i]) +end; +@variables E(t) = 0.0 +eqs = [ + D(E) ~ sum(((i, sys),) -> getproperty(sys, Symbol(:resistor, i)).h.Q_flow, + enumerate(rc_systems)) +] +@named _big_rc = ODESystem(eqs, t, [E], []) +@named big_rc = compose(_big_rc, rc_systems) +ts = TearingState(expand_connections(big_rc)) +@test istriu(but_ordered_incidence(ts)[1]) + +# Test using constants inside subsystems +function FixedResistor(; name, R = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + @constants R = R + eqs = [ + v ~ i * R + ] + extend(ODESystem(eqs, t, [], []; name = name), oneport) +end +capacitor = Capacitor(; name = :c1) +resistor = FixedResistor(; name = :r1) +ground = Ground(; name = :ground) +rc_eqs = [connect(capacitor.n, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, ground.g)] + +@named _rc_model = ODESystem(rc_eqs, t) +@named rc_model = compose(_rc_model, + [resistor, capacitor, ground]) +sys = structural_simplify(rc_model) +prob = ODEProblem(sys, u0, (0, 10.0)) +sol = solve(prob, Tsit5()) + +@testset "docstrings (#1155)" begin + """ + Hey there, Pin1! + """ + @connector function Pin1(; name) + @independent_variables t + sts = @variables v(t)=1.0 i(t)=1.0 + ODESystem(Equation[], t, sts, []; name = name) + end + @test string(Base.doc(Pin1)) == "Hey there, Pin1!\n" + + """ + Hey there, Pin2! + """ + @component function Pin2(; name) + @independent_variables t + sts = @variables v(t)=1.0 i(t)=1.0 + ODESystem(Equation[], t, sts, []; name = name) + end + @test string(Base.doc(Pin2)) == "Hey there, Pin2!\n" +end + +@testset "Issue#3016 Hierarchical indexing" begin + @mtkmodel Inner begin + @parameters begin + p + end + end + @mtkmodel Outer begin + @components begin + inner = Inner() + end + @variables begin + x(t) + end + @equations begin + x ~ inner.p + end + end + + @named outer = Outer() + simp = structural_simplify(outer) + + @test sort(propertynames(outer)) == [:inner, :t, :x] + @test propertynames(simp) == propertynames(outer) + @test sort(propertynames(outer.inner)) == [:p, :t] + @test propertynames(simp.inner) == propertynames(outer.inner) + + for sym in (:t, :x) + @test_nowarn getproperty(simp, sym) + @test_nowarn getproperty(outer, sym) + end + @test_nowarn simp.inner.p + @test_nowarn outer.inner.p + @test_throws ArgumentError simp.inner₊p + @test_throws ArgumentError outer.inner₊p +end + +@testset "`getproperty` on `structural_simplify(complete(sys))`" begin + @mtkmodel Foo begin + @variables begin + x(t) + end + end + @mtkmodel Bar begin + @components begin + foo = Foo() + end + @equations begin + D(foo.x) ~ foo.x + end + end + @named bar = Bar() + cbar = complete(bar) + ss = structural_simplify(cbar) + @test isequal(cbar.foo.x, ss.foo.x) +end + +@testset "Issue#3275: Metadata retained on `complete`" begin + @variables x(t) y(t) + @testset "ODESystem" begin + @named inner = ODESystem(D(x) ~ x, t) + @named outer = ODESystem(D(y) ~ y, t; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + @testset "NonlinearSystem" begin + @named inner = NonlinearSystem([0 ~ x^2 + 4x + 4], [x], []) + @named outer = NonlinearSystem( + [0 ~ x^3 - y^3], [x, y], []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + k = ShiftIndex(t) + @testset "DiscreteSystem" begin + @named inner = DiscreteSystem([x(k) ~ x(k - 1) + x(k - 2)], t, [x], []) + @named outer = DiscreteSystem([y(k) ~ y(k - 1) + y(k - 2)], t, [x, y], + []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + @testset "OptimizationSystem" begin + @named inner = OptimizationSystem(x^2 + y^2 - 3, [x, y], []) + @named outer = OptimizationSystem( + x^3 - y, [x, y], []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end +end diff --git a/test/connectors.jl b/test/connectors.jl deleted file mode 100644 index b5b4357d0a..0000000000 --- a/test/connectors.jl +++ /dev/null @@ -1,31 +0,0 @@ -using Test, ModelingToolkit - -@parameters t - -@connector function Foo(;name) - @variables x(t) - ODESystem(Equation[], t, [x], [], defaults=Dict(x=>1.0)) -end - -@connector function Goo(;name) - @variables x(t) - @parameters p - ODESystem(Equation[], t, [x], [p], defaults=Dict(x=>1.0, p=>1.0)) -end - -ModelingToolkit.connect(::Type{<:Foo}, sys1, sys2) = [sys1.x ~ sys2.x] -@named f1 = Foo() -@named f2 = Foo() -@named g = Goo() - -@test isequal(connect(f1, f2), [f1.x ~ f2.x]) -@test_throws ArgumentError connect(f1, g) - -# Note that since there're overloadings, these tests are not re-runable. -ModelingToolkit.promote_connect_rule(::Type{<:Foo}, ::Type{<:Goo}) = Foo -@test isequal(connect(f1, g), [f1.x ~ g.x]) -ModelingToolkit.promote_connect_rule(::Type{<:Goo}, ::Type{<:Foo}) = Foo -@test isequal(connect(f1, g), [f1.x ~ g.x]) -# test conflict -ModelingToolkit.promote_connect_rule(::Type{<:Goo}, ::Type{<:Foo}) = Goo -@test_throws ArgumentError connect(f1, g) diff --git a/test/constants.jl b/test/constants.jl new file mode 100644 index 0000000000..f2c4fdaa86 --- /dev/null +++ b/test/constants.jl @@ -0,0 +1,52 @@ +using ModelingToolkit, OrdinaryDiffEq, Unitful +using Test +MT = ModelingToolkit +UMT = ModelingToolkit.UnitfulUnitCheck + +@constants a = 1 +@test_throws MT.ArgumentError @constants b + +@independent_variables t +@variables x(t) w(t) +D = Differential(t) +eqs = [D(x) ~ a] +@named sys = ODESystem(eqs, t) +prob = ODEProblem(complete(sys), [0], [0.0, 1.0], []) +sol = solve(prob, Tsit5()) + +newsys = MT.eliminate_constants(sys) +@test isequal(equations(newsys), [D(x) ~ 1]) + +# Test structural_simplify substitutions & observed values +eqs = [D(x) ~ 1, + w ~ a] +@named sys = ODESystem(eqs, t) +# Now eliminate the constants first +simp = structural_simplify(sys) +@test equations(simp) == [D(x) ~ 1.0] + +#Constant with units +@constants β=1 [unit = u"m/s"] +UMT.get_unit(β) +@test MT.isconstant(β) +@independent_variables t [unit = u"s"] +@variables x(t) [unit = u"m"] +D = Differential(t) +eqs = [D(x) ~ β] +@named sys = ODESystem(eqs, t) +simp = structural_simplify(sys) + +@test isempty(MT.collect_constants(nothing)) + +@testset "Issue#3044" begin + @constants h = 1 + @parameters τ = 0.5 * h + @variables x(MT.t_nounits) = h + eqs = [MT.D_nounits(x) ~ (h - x) / τ] + + @mtkbuild fol_model = ODESystem(eqs, MT.t_nounits) + + prob = ODEProblem(fol_model, [], (0.0, 10.0)) + @test prob[x] ≈ 1 + @test prob.ps[τ] ≈ 0.5 +end diff --git a/test/constraints.jl b/test/constraints.jl index ec20dbedd4..7a49659fa4 100644 --- a/test/constraints.jl +++ b/test/constraints.jl @@ -1,7 +1,7 @@ -using ModelingToolkit, DiffEqBase, LinearAlgebra - -# Define some variables -@parameters t x y -@variables u(..) - -ConstrainedEquation([x ~ 0,y < 1/2], u(t,x,y) ~ x + y^2) +using ModelingToolkit, DiffEqBase, LinearAlgebra + +# Define some variables +@parameters t x y +@variables u(..) + +ConstrainedEquation([x ~ 0, y < 1 / 2], u(t, x, y) ~ x + y^2) diff --git a/test/controlsystem.jl b/test/controlsystem.jl deleted file mode 100644 index ee4f6174db..0000000000 --- a/test/controlsystem.jl +++ /dev/null @@ -1,20 +0,0 @@ -using ModelingToolkit, GalacticOptim, Optim - -@variables t x(t) v(t) u(t) -@parameters p[1:2] -D = Differential(t) - -loss = (4-x)^2 + 2v^2 + u^2 -eqs = [ - D(x) ~ v - p[2]*x - D(v) ~ p[1]*u^3 + v -] - -sys = ControlSystem(loss,eqs,t,[x,v],[u],p) -dt = 0.1 -tspan = (0.0,1.0) -sys = runge_kutta_discretize(sys,dt,tspan) - -u0 = rand(length(states(sys))) # guess for the state values -prob = OptimizationProblem(sys,u0,[0.1,0.1],grad=true) -sol = solve(prob,BFGS()) diff --git a/test/dae_jacobian.jl b/test/dae_jacobian.jl new file mode 100644 index 0000000000..8c68df767a --- /dev/null +++ b/test/dae_jacobian.jl @@ -0,0 +1,54 @@ +using ModelingToolkit +using Sundials, Test, SparseArrays +using ModelingToolkit: t_nounits as t, D_nounits as D + +# Comparing solution obtained by defining explicit Jacobian function with solution obtained from +# symbolically generated Jacobian + +function testjac(res, du, u, p, t) #System of equations + res[1] = du[1] - 1.5 * u[1] + 1.0 * u[1] * u[2] + res[2] = du[2] + 3 * u[2] - u[1] * u[2] +end + +function testjac_jac(J, du, u, p, gamma, t) #Explicit Jacobian + J[1, 1] = gamma - 1.5 + 1.0 * u[2] + J[1, 2] = 1.0 * u[1] + J[2, 1] = -1 * u[2] + J[2, 2] = gamma + 3 - u[1] + nothing +end + +testjac_f = DAEFunction(testjac, jac = testjac_jac, + jac_prototype = sparse([1, 2, 1, 2], [1, 1, 2, 2], zeros(4))) + +prob1 = DAEProblem(testjac_f, + [0.5, -2.0], + ones(2), + (0.0, 10.0), + differential_vars = [true, true]) +sol1 = solve(prob1, IDA(linear_solver = :KLU)) + +# Now MTK style solution with generated Jacobian + +@variables u1(t) u2(t) +@parameters p1 p2 + +eqs = [D(u1) ~ p1 * u1 - u1 * u2, + D(u2) ~ u1 * u2 - p2 * u2] + +@named sys = ODESystem(eqs, t) + +u0 = [u1 => 1.0, + u2 => 1.0] + +tspan = (0.0, 10.0) + +du0 = [0.5, -2.0] + +p = [p1 => 1.5, + p2 => 3.0] + +prob = DAEProblem(complete(sys), du0, u0, tspan, p, jac = true, sparse = true) +sol = solve(prob, IDA(linear_solver = :KLU)) + +@test maximum(sol - sol1) < 1e-12 diff --git a/test/dde.jl b/test/dde.jl new file mode 100644 index 0000000000..c7561e6c24 --- /dev/null +++ b/test/dde.jl @@ -0,0 +1,201 @@ +using ModelingToolkit, DelayDiffEq, StaticArrays, Test +using SymbolicIndexingInterface: is_markovian +using ModelingToolkit: t_nounits as t, D_nounits as D + +p0 = 0.2; +q0 = 0.3; +v0 = 1; +d0 = 5; +p1 = 0.2; +q1 = 0.3; +v1 = 1; +d1 = 1; +d2 = 1; +beta0 = 1; +beta1 = 1; +tau = 1; +function bc_model(du, u, h, p, t) + du[1] = (v0 / (1 + beta0 * (h(p, t - tau)[3]^2))) * (p0 - q0) * u[1] - d0 * u[1] + du[2] = (v0 / (1 + beta0 * (h(p, t - tau)[3]^2))) * (1 - p0 + q0) * u[1] + + (v1 / (1 + beta1 * (h(p, t - tau)[3]^2))) * (p1 - q1) * u[2] - d1 * u[2] + du[3] = (v1 / (1 + beta1 * (h(p, t - tau)[3]^2))) * (1 - p1 + q1) * u[2] - d2 * u[3] +end +lags = [tau] +h(p, t) = ones(3) +h2(p, t) = ones(3) .- t * q1 * 10 +tspan = (0.0, 10.0) +u0 = [1.0, 1.0, 1.0] +prob = DDEProblem(bc_model, u0, h, tspan, constant_lags = lags) +alg = MethodOfSteps(Vern9()) +sol = solve(prob, alg, reltol = 1e-7, abstol = 1e-10) +prob2 = DDEProblem(bc_model, u0, h2, tspan, constant_lags = lags) +sol2 = solve(prob2, alg, reltol = 1e-7, abstol = 1e-10) + +@parameters p0=0.2 p1=0.2 q0=0.3 q1=0.3 v0=1 v1=1 d0=5 d1=1 d2=1 beta0=1 beta1=1 +@variables x₀(t) x₁(t) x₂(..) +tau = 1 +eqs = [D(x₀) ~ (v0 / (1 + beta0 * (x₂(t - tau)^2))) * (p0 - q0) * x₀ - d0 * x₀ + D(x₁) ~ (v0 / (1 + beta0 * (x₂(t - tau)^2))) * (1 - p0 + q0) * x₀ + + (v1 / (1 + beta1 * (x₂(t - tau)^2))) * (p1 - q1) * x₁ - d1 * x₁ + D(x₂(t)) ~ (v1 / (1 + beta1 * (x₂(t - tau)^2))) * (1 - p1 + q1) * x₁ - d2 * x₂(t)] +@mtkbuild sys = System(eqs, t) +@test ModelingToolkit.is_dde(sys) +@test !is_markovian(sys) +prob = DDEProblem(sys, + [x₀ => 1.0, x₁ => 1.0, x₂(t) => 1.0], + tspan, + constant_lags = [tau]) +sol_mtk = solve(prob, alg, reltol = 1e-7, abstol = 1e-10) +@test sol_mtk.u[end] ≈ sol.u[end] +prob2 = DDEProblem(sys, + [x₀ => 1.0 - t * q1 * 10, x₁ => 1.0 - t * q1 * 10, x₂(t) => 1.0 - t * q1 * 10], + tspan, + constant_lags = [tau]) +sol2_mtk = solve(prob2, alg, reltol = 1e-7, abstol = 1e-10) +@test sol2_mtk.u[end] ≈ sol2.u[end] +@test_nowarn sol2_mtk[[x₀, x₁, x₂(t)]] +@test_nowarn sol2_mtk[[x₀, x₁, x₂(t - 0.1)]] + +using StochasticDelayDiffEq +function hayes_modelf(du, u, h, p, t) + τ, a, b, c, α, β, γ = p + du .= a .* u .+ b .* h(p, t - τ) .+ c +end +function hayes_modelg(du, u, h, p, t) + τ, a, b, c, α, β, γ = p + du .= α .* u .+ γ +end +h(p, t) = (ones(1) .+ t); +tspan = (0.0, 10.0) + +pmul = [1.0, + -4.0, -2.0, 10.0, + -1.3, -1.2, 1.1] + +prob = SDDEProblem(hayes_modelf, hayes_modelg, [1.0], h, tspan, pmul; + constant_lags = (pmul[1],)); +sol = solve(prob, RKMil(), seed = 100) + +@variables x(..) delx(t) +@parameters a=-4.0 b=-2.0 c=10.0 α=-1.3 β=-1.2 γ=1.1 +@brownian η +τ = 1.0 +eqs = [D(x(t)) ~ a * x(t) + b * x(t - τ) + c + (α * x(t) + γ) * η, delx ~ x(t - τ)] +@mtkbuild sys = System(eqs, t) +@test ModelingToolkit.has_observed_with_lhs(sys, delx) +@test ModelingToolkit.is_dde(sys) +@test !is_markovian(sys) +@test equations(sys) == [D(x(t)) ~ a * x(t) + b * x(t - τ) + c] +@test isequal(ModelingToolkit.get_noiseeqs(sys), [α * x(t) + γ]) +prob_mtk = SDDEProblem(sys, [x(t) => 1.0 + t], tspan; constant_lags = (τ,)); +@test_nowarn sol_mtk = solve(prob_mtk, RKMil(), seed = 100) + +prob_sa = SDDEProblem( + sys, [x(t) => 1.0 + t], tspan; constant_lags = (τ,), u0_constructor = SVector{1}) +@test prob_sa.u0 isa SVector{1, Float64} + +@parameters x(..) a + +function oscillator(; name, k = 1.0, τ = 0.01) + @parameters k=k τ=τ + @variables x(..)=0.1 y(t)=0.1 jcn(t)=0.0 delx(t) + eqs = [D(x(t)) ~ y, + D(y) ~ -k * x(t - τ) + jcn, + delx ~ x(t - τ)] + return System(eqs, t; name = name) +end + +systems = @named begin + osc1 = oscillator(k = 1.0, τ = 0.01) + osc2 = oscillator(k = 2.0, τ = 0.04) +end +eqs = [osc1.jcn ~ osc2.delx, + osc2.jcn ~ osc1.delx] +@named coupledOsc = System(eqs, t) +@named coupledOsc = compose(coupledOsc, systems) +@test ModelingToolkit.is_dde(coupledOsc) +@test !is_markovian(coupledOsc) +@named coupledOsc2 = System(eqs, t; systems) +@test ModelingToolkit.is_dde(coupledOsc2) +@test !is_markovian(coupledOsc2) +for coupledOsc in [coupledOsc, coupledOsc2] + local sys = structural_simplify(coupledOsc) + @test length(equations(sys)) == 4 + @test length(unknowns(sys)) == 4 +end +sys = structural_simplify(coupledOsc) +prob = DDEProblem(sys, [], (0.0, 10.0); constant_lags = [sys.osc1.τ, sys.osc2.τ]) +sol = solve(prob, MethodOfSteps(Tsit5())) +obsfn = ModelingToolkit.build_explicit_observed_function( + sys, [sys.osc1.delx, sys.osc2.delx]) +@test_nowarn sol[[sys.osc1.delx, sys.osc2.delx]] +@test sol[sys.osc1.delx] ≈ sol(sol.t .- 0.01; idxs = sys.osc1.x).u + +prob_sa = DDEProblem(sys, [], (0.0, 10.0); constant_lags = [sys.osc1.τ, sys.osc2.τ], + u0_constructor = SVector{4}) +@test prob_sa.u0 isa SVector{4, Float64} + +@testset "DDE observed with array variables" begin + @component function valve(; name) + @parameters begin + open(t)::Bool = false + Kp = 2 + Ksnap = 1.1 + τ = 0.1 + end + @variables begin + opening(..) + lag_opening(t) + snap_opening(t) + end + eqs = [D(opening(t)) ~ Kp * (open - opening(t)) + lag_opening ~ opening(t - τ) + snap_opening ~ clamp(Ksnap * lag_opening - 1 / Ksnap, 0, 1)] + return System(eqs, t; name = name) + end + + @component function veccy(; name) + @parameters dx[1:3] = ones(3) + @variables begin + x(t)[1:3] = zeros(3) + end + return System([D(x) ~ dx], t; name = name) + end + + @mtkbuild ssys = System( + Equation[], t; systems = [valve(name = :valve), veccy(name = :vvecs)]) + prob = DDEProblem(ssys, [ssys.valve.opening => 1.0], (0.0, 1.0)) + sol = solve(prob, MethodOfSteps(Tsit5())) + obsval = @test_nowarn sol[ssys.valve.lag_opening + sum(ssys.vvecs.x)] + @test obsval ≈ + sol(sol.t .- prob.ps[ssys.valve.τ]; idxs = ssys.valve.opening).u .+ + sum.(sol[ssys.vvecs.x]) +end + +@testset "Issue#3165 DDEs with non-tunables" begin + @variables x(..) = 1.0 + @parameters w=1.0 [tunable = false] τ=0.5 + eqs = [D(x(t)) ~ -w * x(t - τ)] + + @named sys = System(eqs, t) + sys = structural_simplify(sys) + + prob = DDEProblem(sys, + [], + (0.0, 10.0), + constant_lags = [τ]) + + alg = MethodOfSteps(Vern7()) + @test_nowarn solve(prob, alg) + + @brownian r + eqs = [D(x(t)) ~ -w * x(t - τ) + r] + @named sys = System(eqs, t) + sys = structural_simplify(sys) + prob = SDDEProblem(sys, + [], + (0.0, 10.0), + constant_lags = [τ]) + + @test_nowarn solve(prob, RKMil()) +end diff --git a/test/debugging.jl b/test/debugging.jl new file mode 100644 index 0000000000..a55684737c --- /dev/null +++ b/test/debugging.jl @@ -0,0 +1,51 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, SymbolicIndexingInterface +import Logging +using ModelingToolkit: t_nounits as t, D_nounits as D, ASSERTION_LOG_VARIABLE + +@variables x(t) +@brownian a +@named inner_ode = ODESystem(D(x) ~ -sqrt(x), t; assertions = [(x > 0) => "ohno"]) +@named inner_sde = System([D(x) ~ -sqrt(x) + a], t; assertions = [(x > 0) => "ohno"]) +sys_ode = structural_simplify(inner_ode) +sys_sde = structural_simplify(inner_sde) + +@testset "assertions are present in generated `f`" begin + @testset "$(typeof(sys))" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + @test !is_parameter(sys, ASSERTION_LOG_VARIABLE) + prob = Problem(sys, [x => 0.1], (0.0, 5.0)) + sol = solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + @test isnan(prob.f.f([0.0], prob.p, sol.t[end])[1]) + end +end + +@testset "`debug_system` adds logging" begin + @testset "$(typeof(sys))" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + dsys = debug_system(sys; functions = []) + @test is_parameter(dsys, ASSERTION_LOG_VARIABLE) + prob = Problem(dsys, [x => 0.1], (0.0, 5.0)) + sol = @test_logs (:error, r"ohno") match_mode=:any solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + prob.ps[ASSERTION_LOG_VARIABLE] = false + sol = @test_logs min_level=Logging.Error solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + end +end + +@testset "Hierarchical system" begin + @testset "$(typeof(inner))" for (ctor, Problem, inner, alg) in [ + (ODESystem, ODEProblem, inner_ode, Tsit5()), + (System, SDEProblem, inner_sde, ImplicitEM())] + @mtkbuild outer = ctor(Equation[], t; systems = [inner]) + dsys = debug_system(outer; functions = []) + @test is_parameter(dsys, ASSERTION_LOG_VARIABLE) + prob = Problem(dsys, [inner.x => 0.1], (0.0, 5.0)) + sol = @test_logs (:error, r"ohno") match_mode=:any solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + prob.ps[ASSERTION_LOG_VARIABLE] = false + sol = @test_logs min_level=Logging.Error solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + end +end diff --git a/test/dep_graphs.jl b/test/dep_graphs.jl index 3f1c731538..3b02efb2d0 100644 --- a/test/dep_graphs.jl +++ b/test/dep_graphs.jl @@ -1,92 +1,178 @@ using Test -using ModelingToolkit, LightGraphs - +using ModelingToolkit, Graphs, JumpProcesses, RecursiveArrayTools +using ModelingToolkit: t_nounits as t, D_nounits as D import ModelingToolkit: value -# use a ReactionSystem to generate systems for testing -@parameters k1 k2 t -@variables S(t) I(t) R(t) - -rxs = [Reaction(k1, nothing, [S]), - Reaction(k1, [S], nothing), - Reaction(k2, [S,I], [I], [1,1], [2]), - Reaction(k2, [S,R], [S], [2,1], [2]), - Reaction(k1*I, nothing, [R]), - Reaction(k1*k2/(1+t), [S], [R])] -rs = ReactionSystem(rxs, t, [S,I,R], [k1,k2]) - - ################################# # testing for Jumps / all dgs ################################# -js = convert(JumpSystem, rs) -S = value(S); I = value(I); R = value(R) -k1 = value(k1); k2 = value(k2) +@parameters k1 k2 +@variables S(t) I(t) R(t) +j₁ = MassActionJump(k1, [0 => 1], [S => 1]) +j₂ = MassActionJump(k1, [S => 1], [S => -1]) +j₃ = MassActionJump(k2, [S => 1, I => 1], [S => -1, I => 1]) +j₄ = MassActionJump(k2, [S => 2, R => 1], [R => -1]) +j₅ = ConstantRateJump(k1 * I, [R ~ R + 1]) +j₆ = VariableRateJump(k1 * k2 / (1 + t) * S, [S ~ S - 1, R ~ R + 1]) +eqs = [j₁, j₂, j₃, j₄, j₅, j₆] +@named js = JumpSystem(eqs, t, [S, I, R], [k1, k2]) +S = value(S) +I = value(I) +R = value(R) +k1 = value(k1) +k2 = value(k2) # eq to vars they depend on -eq_sdeps = [Variable[], [S], [S,I], [S,R], [I], [S]] -eq_sidepsf = [Int[], [1], [1,2], [1,3], [2], [1]] -eq_sidepsb = [[2,3,4,6], [3,5],[4]] +eq_sdeps = [Variable[], [S], [S, I], [S, R], [I], [S]] +eq_sidepsf = [Int[], [1], [1, 2], [1, 3], [2], [1]] +eq_sidepsb = [[2, 3, 4, 6], [3, 5], [4]] deps = equation_dependencies(js) -@test all(i -> isequal(Set(eq_sdeps[i]),Set(deps[i])), 1:length(rxs)) +@test all(i -> isequal(Set(eq_sdeps[i]), Set(deps[i])), 1:length(eqs)) depsbg = asgraph(js) @test depsbg.fadjlist == eq_sidepsf @test depsbg.badjlist == eq_sidepsb # eq to params they depend on -eq_pdeps = [[k1],[k1],[k2],[k2],[k1],[k1,k2]] -eq_pidepsf = [[1],[1],[2],[2],[1],[1,2]] -eq_pidepsb = [[1,2,5,6],[3,4,6]] -deps = equation_dependencies(js, variables=parameters(js)) -@test all(i -> isequal(Set(eq_pdeps[i]),Set(deps[i])), 1:length(rxs)) -depsbg2 = asgraph(js, variables=parameters(js)) +eq_pdeps = [[k1], [k1], [k2], [k2], [k1], [k1, k2]] +eq_pidepsf = [[1], [1], [2], [2], [1], [1, 2]] +eq_pidepsb = [[1, 2, 5, 6], [3, 4, 6]] +deps = equation_dependencies(js, variables = parameters(js)) +@test all(i -> isequal(Set(eq_pdeps[i]), Set(deps[i])), 1:length(eqs)) +depsbg2 = asgraph(js, variables = parameters(js)) @test depsbg2.fadjlist == eq_pidepsf @test depsbg2.badjlist == eq_pidepsb # var to eqs that modify them -s_eqdepsf = [[1,2,3,6],[3],[4,5,6]] -s_eqdepsb = [[1],[1],[1,2],[3],[3],[1,3]] -ne = 8 -bg = BipartiteGraph(ne, s_eqdepsf, s_eqdepsb) -deps2 = variable_dependencies(js) -@test isequal(bg,deps2) +s_eqdepsf = [[1, 2, 3, 6], [3], [4, 5, 6]] +s_eqdepsb = [[1], [1], [1, 2], [3], [3], [1, 3]] +ne = 8 +bg = BipartiteGraph(ne, s_eqdepsf, s_eqdepsb) +deps2 = variable_dependencies(js) +@test isequal(bg, deps2) # eq to eqs that depend on them -eq_eqdeps = [[2,3,4,6],[2,3,4,6],[2,3,4,5,6],[4],[4],[2,3,4,6]] +eq_eqdeps = [[2, 3, 4, 6], [2, 3, 4, 6], [2, 3, 4, 5, 6], [4], [4], [2, 3, 4, 6]] dg = SimpleDiGraph(6) -for (eqidx,eqdeps) in enumerate(eq_eqdeps) +for (eqidx, eqdeps) in enumerate(eq_eqdeps) for eqdepidx in eqdeps - add_edge!(dg, eqidx, eqdepidx) + add_edge!(dg, eqidx, eqdepidx) end end -dg3 = eqeq_dependencies(depsbg,deps2) +dg3 = eqeq_dependencies(depsbg, deps2) @test dg == dg3 # var to vars that depend on them -var_vardeps = [[1,2,3],[1,2,3],[3]] +var_vardeps = [[1, 2, 3], [1, 2, 3], [3]] ne = 7 dg = SimpleDiGraph(3) -for (vidx,vdeps) in enumerate(var_vardeps) +for (vidx, vdeps) in enumerate(var_vardeps) for vdepidx in vdeps - add_edge!(dg, vidx, vdepidx) + add_edge!(dg, vidx, vdepidx) end end -dg4 = varvar_dependencies(depsbg,deps2) +dg4 = varvar_dependencies(depsbg, deps2) @test dg == dg4 +# testing when ignoring VariableRateJumps +let + @parameters k1 k2 + @variables S(t) I(t) R(t) + j₁ = MassActionJump(k1, [0 => 1], [S => 1]) + j₂ = MassActionJump(k1, [S => 1], [S => -1]) + j₃ = MassActionJump(k2, [S => 1, I => 1], [S => -1, I => 1]) + j₄ = MassActionJump(k2, [S => 2, R => 1], [R => -1]) + j₅ = ConstantRateJump(k1 * I, [R ~ R + 1]) + j₆ = VariableRateJump(k1 * k2 / (1 + t) * S, [S ~ S - 1, R ~ R + 1]) + eqs = [j₁, j₂, j₃, j₄, j₅, j₆] + @named js = JumpSystem(eqs, t, [S, I, R], [k1, k2]) + S = value(S) + I = value(I) + R = value(R) + k1 = value(k1) + k2 = value(k2) + # eq to vars they depend on + eq_sdeps = [Variable[], [S], [S, I], [S, R], [I]] + eq_sidepsf = [Int[], [1], [1, 2], [1, 3], [2]] + eq_sidepsb = [[2, 3, 4], [3, 5], [4]] + + # filter out vrjs in making graphs + eqs = ArrayPartition(equations(js).x[1], equations(js).x[2]) + deps = equation_dependencies(js; eqs) + @test length(deps) == length(eq_sdeps) + @test all(i -> isequal(Set(eq_sdeps[i]), Set(deps[i])), 1:length(eqs)) + depsbg = asgraph(js; eqs) + @test depsbg.fadjlist == eq_sidepsf + @test depsbg.badjlist == eq_sidepsb + + # eq to params they depend on + eq_pdeps = [[k1], [k1], [k2], [k2], [k1]] + eq_pidepsf = [[1], [1], [2], [2], [1]] + eq_pidepsb = [[1, 2, 5], [3, 4]] + deps = equation_dependencies(js; variables = parameters(js), eqs) + @test length(deps) == length(eq_pdeps) + @test all(i -> isequal(Set(eq_pdeps[i]), Set(deps[i])), 1:length(eqs)) + depsbg2 = asgraph(js; variables = parameters(js), eqs) + @test depsbg2.fadjlist == eq_pidepsf + @test depsbg2.badjlist == eq_pidepsb + + # var to eqs that modify them + s_eqdepsf = [[1, 2, 3], [3], [4, 5]] + s_eqdepsb = [[1], [1], [1, 2], [3], [3]] + ne = 6 + bg = BipartiteGraph(ne, s_eqdepsf, s_eqdepsb) + deps2 = variable_dependencies(js; eqs) + @test isequal(bg, deps2) + + # eq to eqs that depend on them + eq_eqdeps = [[2, 3, 4], [2, 3, 4], [2, 3, 4, 5], [4], [4], [2, 3, 4]] + dg = SimpleDiGraph(5) + for (eqidx, eqdeps) in enumerate(eq_eqdeps) + for eqdepidx in eqdeps + add_edge!(dg, eqidx, eqdepidx) + end + end + dg3 = eqeq_dependencies(depsbg, deps2) + @test dg == dg3 + + # var to vars that depend on them + var_vardeps = [[1, 2, 3], [1, 2, 3], [3]] + ne = 7 + dg = SimpleDiGraph(3) + for (vidx, vdeps) in enumerate(var_vardeps) + for vdepidx in vdeps + add_edge!(dg, vidx, vdepidx) + end + end + dg4 = varvar_dependencies(depsbg, deps2) + @test dg == dg4 +end + ##################################### # testing for ODE/SDEs ##################################### -os = convert(ODESystem, rs) +@parameters k1 k2 +@variables S(t) I(t) R(t) +eqs = [D(S) ~ k1 - k1 * S - k2 * S * I - k1 * k2 / (1 + t) * S, + D(I) ~ k2 * S * I, + D(R) ~ -k2 * S^2 * R / 2 + k1 * I + k1 * k2 * S / (1 + t)] +noiseeqs = [S, I, R] +@named os = ODESystem(eqs, t, [S, I, R], [k1, k2]) deps = equation_dependencies(os) -eq_sdeps = [[S,I], [S,I], [S,I,R]] -@test all(i -> isequal(Set(eq_sdeps[i]),Set(deps[i])), 1:length(deps)) +S = value(S); +I = value(I); +R = value(R); +k1 = value(k1); +k2 = value(k2); +eq_sdeps = [[S, I], [S, I], [S, I, R]] +@test all(i -> isequal(Set(eq_sdeps[i]), Set(deps[i])), 1:length(deps)) -sdes = convert(SDESystem, rs) +@parameters k1 k2 +@variables S(t) I(t) R(t) +@named sdes = SDESystem(eqs, noiseeqs, t, [S, I, R], [k1, k2]) deps = equation_dependencies(sdes) -@test all(i -> isequal(Set(eq_sdeps[i]),Set(deps[i])), 1:length(deps)) +@test all(i -> isequal(Set(eq_sdeps[i]), Set(deps[i])), 1:length(deps)) deps = variable_dependencies(os) -s_eqdeps = [[1],[2],[3]] +s_eqdeps = [[1], [2], [3]] @test deps.fadjlist == s_eqdeps ##################################### @@ -95,10 +181,10 @@ s_eqdeps = [[1],[2],[3]] @variables x y z @parameters σ ρ β -eqs = [0 ~ σ*(y-x), - 0 ~ ρ-y, - 0 ~ y - β*z] -ns = NonlinearSystem(eqs, [x,y,z],[σ,ρ,β]) +eqs = [0 ~ σ * (y - x), + 0 ~ ρ - y, + 0 ~ y - β * z] +@named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) deps = equation_dependencies(ns) -eq_sdeps = [[x,y],[y],[y,z]] -@test all(i -> isequal(Set(deps[i]),Set(value.(eq_sdeps[i]))), 1:length(deps)) +eq_sdeps = [[x, y], [y], [y, z]] +@test all(i -> isequal(Set(deps[i]), Set(value.(eq_sdeps[i]))), 1:length(deps)) diff --git a/test/direct.jl b/test/direct.jl index 77562946e4..70e1babe3f 100644 --- a/test/direct.jl +++ b/test/direct.jl @@ -1,253 +1,297 @@ -using ModelingToolkit, StaticArrays, LinearAlgebra, SparseArrays -using DiffEqBase -using Test - -canonequal(a, b) = isequal(simplify(a), simplify(b)) - -# Calculus -@parameters t σ ρ β -@variables x y z -@test isequal( - (Differential(z) * Differential(y) * Differential(x))(t), - Differential(z)(Differential(y)(Differential(x)(t))) -) - -@test canonequal( - ModelingToolkit.derivative(sin(cos(x)), x), - -sin(x) * cos(cos(x)) - ) - -@register no_der(x) -@test canonequal( - ModelingToolkit.derivative([sin(cos(x)), hypot(x, no_der(x))], x), - [ - -sin(x) * cos(cos(x)), - x/hypot(x, no_der(x)) + no_der(x)*Differential(x)(no_der(x))/hypot(x, no_der(x)) - ] - ) - -@register intfun(x)::Int -@test ModelingToolkit.symtype(intfun(x)) === Int - -eqs = [σ*(y-x), - x*(ρ-z)-y, - x*y - β*z] - - -simpexpr = [ - :($(*)(σ, $(+)(y, $(*)(-1, x)))) - :($(+)($(*)(x, $(+)(ρ, $(*)(-1, z))), $(*)(-1, y))) - :($(+)($(*)(x, y), $(*)(-1, z, β))) -] - -for i in 1:3 - @test ModelingToolkit.toexpr.(eqs)[i] == simpexpr[i] - @test ModelingToolkit.toexpr.(eqs)[i] == simpexpr[i] -end - -∂ = ModelingToolkit.jacobian(eqs,[x,y,z]) -for i in 1:3 - ∇ = ModelingToolkit.gradient(eqs[i],[x,y,z]) - @test canonequal(∂[i,:],∇) -end - -@test all(canonequal.(ModelingToolkit.gradient(eqs[1],[x,y,z]),[σ * -1,σ,0])) -@test all(canonequal.(ModelingToolkit.hessian(eqs[1],[x,y,z]),0)) - -du = [x^2, y^3, x^4, sin(y), x+y, x+z^2, z+x, x+y^2+sin(z)] -reference_jac = sparse(ModelingToolkit.jacobian(du, [x,y,z])) - -@test findnz(ModelingToolkit.jacobian_sparsity(du, [x,y,z]))[[1,2]] == findnz(reference_jac)[[1,2]] - -let - @variables t x(t) y(t) z(t) - @test ModelingToolkit.exprs_occur_in([x,y,z], x^2*y) == [true, true, false] -end - -@test isequal(ModelingToolkit.sparsejacobian(du, [x,y,z]), reference_jac) - -using ModelingToolkit - -rosenbrock(X) = sum(1:length(X)-1) do i - 100 * (X[i+1] - X[i]^2)^2 + (1 - X[i])^2 -end - -@variables a,b -X = [a,b] - -spoly(x) = simplify(x, polynorm=true) -rr = rosenbrock(X) - -reference_hes = ModelingToolkit.hessian(rr, X) -@test findnz(sparse(reference_hes))[1:2] == findnz(ModelingToolkit.hessian_sparsity(rr, X))[1:2] - -sp_hess = ModelingToolkit.sparsehessian(rr, X) -@test findnz(sparse(reference_hes))[1:2] == findnz(sp_hess)[1:2] -@test isequal(map(spoly, findnz(sparse(reference_hes))[3]), map(spoly, findnz(sp_hess)[3])) - -Joop, Jiip = eval.(ModelingToolkit.build_function(∂,[x,y,z],[σ,ρ,β],t)) -J = Joop([1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J isa Matrix -J2 = copy(J) -Jiip(J2,[1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J2 == J - -Joop,Jiip = eval.(ModelingToolkit.build_function(vcat(∂,∂),[x,y,z],[σ,ρ,β],t)) -J = Joop([1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J isa Matrix -J2 = copy(J) -Jiip(J2,[1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J2 == J - -Joop,Jiip = eval.(ModelingToolkit.build_function(hcat(∂,∂),[x,y,z],[σ,ρ,β],t)) -J = Joop([1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J isa Matrix -J2 = copy(J) -Jiip(J2,[1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J2 == J - -∂3 = cat(∂,∂,dims=3) -Joop,Jiip = eval.(ModelingToolkit.build_function(∂3,[x,y,z],[σ,ρ,β],t)) -J = Joop([1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test size(J) == (3,3,2) -J2 = copy(J) -Jiip(J2,[1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J2 == J - -s∂ = sparse(∂) -@test nnz(s∂) == 8 -Joop,Jiip = eval.(ModelingToolkit.build_function(s∂,[x,y,z],[σ,ρ,β],t,linenumbers=true)) -J = Joop([1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test length(nonzeros(s∂)) == 8 -J2 = copy(J) -Jiip(J2,[1.0,2.0,3.0],[1.0,2.0,3.0],1.0) -@test J2 == J - -# Function building - -@parameters σ ρ β -@variables x y z -eqs = [σ*(y-x), - x*(ρ-z)-y, - x*y - β*z] -f1,f2 = ModelingToolkit.build_function(eqs,[x,y,z],[σ,ρ,β]) -f = eval(f1) -out = [1.0,2,3] -o1 = f([1.0,2,3],[1.0,2,3]) -f = eval(f2) -f(out,[1.0,2,3],[1.0,2,3]) -@test all(o1 .== out) - -function test_worldage() - @parameters σ ρ β - @variables x y z - eqs = [σ*(y-x), - x*(ρ-z)-y, - x*y - β*z] - f, f_iip = ModelingToolkit.build_function(eqs,[x,y,z],[σ,ρ,β];expression=Val{false}) - out = [1.0,2,3] - o1 = f([1.0,2,3],[1.0,2,3]) - f_iip(out,[1.0,2,3],[1.0,2,3]) -end -test_worldage() - -## No parameters -@variables x y z -eqs = [(y-x)^2, - x*(x-z)-y, - x*y - y*z] -f1,f2 = ModelingToolkit.build_function(eqs,[x,y,z]) -f = eval(f1) -out = zeros(3) -o1 = f([1.0,2,3]) -f = eval(f2) -f(out,[1.0,2,3]) -@test all(out .== o1) - -# y ^ -1 test -g = let - f(x,y) = x/y - @variables x y - ex = expand_derivatives(Differential(x)(f(x, y))) - func_ex = build_function(ex, x, y) - eval(func_ex) -end - -@test g(42,4) == 1/4 - -function test_worldage() - @variables x y z - eqs = [(y-x)^2, - x*(x-z)-y, - x*y - y*z] - f, f_iip = ModelingToolkit.build_function(eqs,[x,y,z];expression=Val{false}) - out = zeros(3) - o1 = f([1.0,2,3]) - f_iip(out,[1.0,2,3]) -end -test_worldage() - -@test_nowarn muladd(x, y, 0) -@test promote(x, 0) == (x, identity(0)) -@test_nowarn [x, y, z]' - -let - @register foo(x) - @variables t - D = Differential(t) - - - @test isequal(expand_derivatives(D(foo(t))), D(foo(t))) - @test isequal(expand_derivatives(D(sin(t) * foo(t))), cos(t) * foo(t) + sin(t) * D(foo(t))) - -end - -foo(;kw...) = kw -foo(args... ;kw...) = args, kw -pp = :name => :cool_name - -@named cool_name = foo() -@test collect(cool_name) == [pp] - -@named cool_name = foo(42) -@test cool_name[1] == (42,) -@test collect(cool_name[2]) == [pp] - -@named cool_name = foo(42; a = 2) -@test cool_name[1] == (42,) -@test collect(cool_name[2]) == [pp; :a => 2] - -@named cool_name = foo(a = 2) -@test collect(cool_name) == [pp; :a => 2] - -@named cool_name = foo(;a = 2) -@test collect(cool_name) == [pp; :a => 2] - -@named cool_name = foo(name = 2) -@test collect(cool_name) == [:name => 2] - -@named cool_name = foo(42; name = 3) -@test cool_name[1] == (42,) -@test collect(cool_name[2]) == [:name => 3] - -kwargs = (;name = 3) -@named cool_name = foo(42; kwargs...) -@test cool_name[1] == (42,) -@test collect(cool_name[2]) == [:name => 3] - -if VERSION >= v"1.5" - name = 3 - @named cool_name = foo(42; name) - @test cool_name[1] == (42,) - @test collect(cool_name[2]) == [:name => name] - @named cool_name = foo(; name) - @test collect(cool_name) == [:name => name] - - ff = 3 - @named cool_name = foo(42; ff) - @test cool_name[1] == (42,) - @test collect(cool_name[2]) == [pp; :ff => ff] - - @named cool_name = foo(;ff) - @test collect(cool_name) == [pp; :ff => ff] -end +using ModelingToolkit, StaticArrays, LinearAlgebra, SparseArrays +using DiffEqBase +using Test + +using ModelingToolkit: getdefault, getmetadata, SymScope + +canonequal(a, b) = isequal(simplify(a), simplify(b)) + +# Calculus +@parameters t σ ρ β +@variables x y z +@test isequal((Differential(z) * Differential(y) * Differential(x))(t), + Differential(z)(Differential(y)(Differential(x)(t)))) + +@test canonequal(ModelingToolkit.derivative(sin(cos(x)), x), + -sin(x) * cos(cos(x))) + +@register_symbolic no_der(x) +@test canonequal(ModelingToolkit.derivative([sin(cos(x)), hypot(x, no_der(x))], x), + [ + -sin(x) * cos(cos(x)), + x / hypot(x, no_der(x)) + + no_der(x) * Differential(x)(no_der(x)) / hypot(x, no_der(x)) + ]) + +@register_symbolic intfun(x)::Int +@test ModelingToolkit.symtype(intfun(x)) === Int + +eqs = [σ * (y - x), + x * (ρ - z) - y, + x * y - β * z] + +simpexpr = [:($(*)(σ, $(+)(y, $(*)(-1, x)))) + :($(+)($(*)(x, $(+)(ρ, $(*)(-1, z))), $(*)(-1, y))) + :($(+)($(*)(x, y), $(*)(-1, z, β)))] + +σ, β, ρ = 2 // 3, 3 // 4, 4 // 5 +x, y, z = 6 // 7, 7 // 8, 8 // 9 +for i in 1:3 + @test eval(ModelingToolkit.toexpr.(eqs)[i]) == eval(simpexpr[i]) + @test eval(ModelingToolkit.toexpr.(eqs)[i]) == eval(simpexpr[i]) +end + +@parameters σ ρ β +@variables x y z +∂ = ModelingToolkit.jacobian(eqs, [x, y, z]) +for i in 1:3 + ∇ = ModelingToolkit.gradient(eqs[i], [x, y, z]) + @test canonequal(∂[i, :], ∇) +end + +@test all(canonequal.(ModelingToolkit.gradient(eqs[1], [x, y, z]), [σ * -1, σ, 0])) +@test all(canonequal.(ModelingToolkit.hessian(eqs[1], [x, y, z]), 0)) + +du = [x^2, y^3, x^4, sin(y), x + y, x + z^2, z + x, x + y^2 + sin(z)] +reference_jac = sparse(ModelingToolkit.jacobian(du, [x, y, z])) + +@test findnz(ModelingToolkit.jacobian_sparsity(du, [x, y, z]))[[1, 2]] == + findnz(reference_jac)[[1, 2]] + +let + @independent_variables t + @variables x(t) y(t) z(t) + @test ModelingToolkit.exprs_occur_in([x, y, z], x^2 * y) == [true, true, false] +end + +@test isequal(ModelingToolkit.sparsejacobian(du, [x, y, z]), reference_jac) + +using ModelingToolkit + +rosenbrock(X) = + sum(1:(length(X) - 1)) do i + 100 * (X[i + 1] - X[i]^2)^2 + (1 - X[i])^2 + end + +@variables a, b +X = [a, b] + +spoly(x) = simplify(x, expand = true) +rr = rosenbrock(X) + +reference_hes = ModelingToolkit.hessian(rr, X) +@test findnz(sparse(reference_hes))[1:2] == + findnz(ModelingToolkit.hessian_sparsity(rr, X))[1:2] + +sp_hess = ModelingToolkit.sparsehessian(rr, X) +@test findnz(sparse(reference_hes))[1:2] == findnz(sp_hess)[1:2] +@test isequal(map(spoly, findnz(sparse(reference_hes))[3]), map(spoly, findnz(sp_hess)[3])) + +Joop, Jiip = eval.(ModelingToolkit.build_function(∂, [x, y, z], [σ, ρ, β], t)) +J = Joop([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J isa Matrix +J2 = copy(J) +Jiip(J2, [1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J2 == J + +Joop, Jiip = eval.(ModelingToolkit.build_function(vcat(∂, ∂), [x, y, z], [σ, ρ, β], t)) +J = Joop([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J isa Matrix +J2 = copy(J) +Jiip(J2, [1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J2 == J + +Joop, Jiip = eval.(ModelingToolkit.build_function(hcat(∂, ∂), [x, y, z], [σ, ρ, β], t)) +J = Joop([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J isa Matrix +J2 = copy(J) +Jiip(J2, [1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J2 == J + +∂3 = cat(∂, ∂, dims = 3) +Joop, Jiip = eval.(ModelingToolkit.build_function(∂3, [x, y, z], [σ, ρ, β], t)) +J = Joop([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test size(J) == (3, 3, 2) +J2 = copy(J) +Jiip(J2, [1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J2 == J + +s∂ = sparse(∂) +@test nnz(s∂) == 8 +Joop, Jiip = eval.(ModelingToolkit.build_function(s∂, [x, y, z], [σ, ρ, β], t, + linenumbers = true)) +J = Joop([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test length(nonzeros(s∂)) == 8 +J2 = copy(J) +Jiip(J2, [1.0, 2.0, 3.0], [1.0, 2.0, 3.0], 1.0) +@test J2 == J + +# Function building + +@parameters σ ρ β +@variables x y z +eqs = [σ * (y - x), + x * (ρ - z) - y, + x * y - β * z] +f1, f2 = ModelingToolkit.build_function(eqs, [x, y, z], [σ, ρ, β]) +f = eval(f1) +out = [1.0, 2, 3] +o1 = f([1.0, 2, 3], [1.0, 2, 3]) +f = eval(f2) +f(out, [1.0, 2, 3], [1.0, 2, 3]) +@test all(o1 .== out) + +function test_worldage() + @parameters σ ρ β + @variables x y z + eqs = [σ * (y - x), + x * (ρ - z) - y, + x * y - β * z] + f, f_iip = ModelingToolkit.build_function(eqs, [x, y, z], [σ, ρ, β]; + expression = Val{false}) + out = [1.0, 2, 3] + o1 = f([1.0, 2, 3], [1.0, 2, 3]) + f_iip(out, [1.0, 2, 3], [1.0, 2, 3]) +end +test_worldage() + +## No parameters +@variables x y z +eqs = [(y - x)^2, + x * (x - z) - y, + x * y - y * z] +f1, f2 = ModelingToolkit.build_function(eqs, [x, y, z]) +f = eval(f1) +out = zeros(3) +o1 = f([1.0, 2, 3]) +f = eval(f2) +f(out, [1.0, 2, 3]) +@test all(out .== o1) + +# y ^ -1 test +g = let + f(x, y) = x / y + @variables x y + ex = expand_derivatives(Differential(x)(f(x, y))) + func_ex = build_function(ex, x, y) + eval(func_ex) +end + +@test g(42, 4) == 1 / 4 + +function test_worldage() + @variables x y z + eqs = [(y - x)^2, + x * (x - z) - y, + x * y - y * z] + f, f_iip = ModelingToolkit.build_function(eqs, [x, y, z]; expression = Val{false}) + out = zeros(3) + o1 = f([1.0, 2, 3]) + f_iip(out, [1.0, 2, 3]) +end +test_worldage() + +@test_nowarn muladd(x, y, 0) +@test promote(x, 0) == (x, identity(0)) +@test_nowarn [x, y, z]' + +let + @register_symbolic foo(x) + @independent_variables t + D = Differential(t) + + @test isequal(expand_derivatives(D(foo(t))), D(foo(t))) + @test isequal(expand_derivatives(D(sin(t) * foo(t))), + cos(t) * foo(t) + sin(t) * D(foo(t))) +end + +foo(; kw...) = kw +foo(args...; kw...) = args, kw +pp = :name => :cool_name + +@named cool_name = foo() +@test collect(cool_name) == [pp] + +@named cool_name = foo(42) +@test cool_name[1] == (42,) +@test collect(cool_name[2]) == [pp] + +@named cool_name = foo(42; a = 2) +@test cool_name[1] == (42,) +@test collect(cool_name[2]) == [pp; :a => 2] + +@named cool_name = foo(a = 2) +@test collect(cool_name) == [pp; :a => 2] + +@named cool_name = foo(; a = 2) +@test collect(cool_name) == [pp; :a => 2] + +@named cool_name = foo(name = 2) +@test collect(cool_name) == [:name => 2] + +@named cool_name = foo(42; name = 3) +@test cool_name[1] == (42,) +@test collect(cool_name[2]) == [:name => 3] + +kwargs = (; name = 3) +@named cool_name = foo(42; kwargs...) +@test cool_name[1] == (42,) +@test collect(cool_name[2]) == [:name => 3] + +if VERSION >= v"1.5" + name = 3 + @named cool_name = foo(42; name) + @test cool_name[1] == (42,) + @test collect(cool_name[2]) == [:name => name] + @named cool_name = foo(; name) + @test collect(cool_name) == [:name => name] + + ff = 3 + @named cool_name = foo(42; ff) + @test cool_name[1] == (42,) + @test collect(cool_name[2]) == [pp; :ff => ff] + + @named cool_name = foo(; ff) + @test collect(cool_name) == [pp; :ff => ff] +end + +foo(i; name) = (; i, name) +@named goo[1:3] = foo(10) +@test isequal(goo, [(i = 10, name = Symbol(:goo_, i)) for i in 1:3]) +@named koo 1:3 i->foo(10i) +@test isequal(koo, [(i = 10i, name = Symbol(:koo_, i)) for i in 1:3]) +xys = @named begin + x = foo(12) + y[1:3] = foo(13) +end +@test isequal(x, (i = 12, name = :x)) +@test isequal(y, [(i = 13, name = Symbol(:y_, i)) for i in 1:3]) +@test isequal(xys, [x; y]) + +@variables x [misc = "wow"] +@test SymbolicUtils.getmetadata(Symbolics.unwrap(x), ModelingToolkit.VariableMisc, + nothing) == "wow" +@parameters x [misc = "wow"] +@test SymbolicUtils.getmetadata(Symbolics.unwrap(x), ModelingToolkit.VariableMisc, + nothing) == "wow" + +# Scope of defaults in the systems generated by @named +@mtkmodel MoreThanOneArg begin + @variables begin + x(t) + y(t) + z(t) + end +end + +@parameters begin + l + m + n +end + +@named model = MoreThanOneArg(x = l, y = m, z = n) + +@test getmetadata(getdefault(model.x), SymScope) == ParentScope(LocalScope()) +@test getmetadata(getdefault(model.y), SymScope) == ParentScope(LocalScope()) +@test getmetadata(getdefault(model.z), SymScope) == ParentScope(LocalScope()) diff --git a/test/discrete_system.jl b/test/discrete_system.jl new file mode 100644 index 0000000000..0d215052d8 --- /dev/null +++ b/test/discrete_system.jl @@ -0,0 +1,325 @@ +# Example: Compartmental models in epidemiology +#= +- https://github.com/epirecipes/sir-julia/blob/master/markdown/function_map/function_map.md +- https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology#Deterministic_versus_stochastic_epidemic_models +=# +using ModelingToolkit, Test +using ModelingToolkit: t_nounits as t +using ModelingToolkit: get_metadata, MTKParameters + +# Make sure positive shifts error +@variables x(t) +k = ShiftIndex(t) +@test_throws ErrorException @mtkbuild sys = DiscreteSystem([x(k + 1) ~ x + x(k - 1)], t) + +@inline function rate_to_proportion(r, t) + 1 - exp(-r * t) +end; + +# Independent and dependent variables and parameters +@parameters c nsteps δt β γ +@constants h = 1 +@variables S(t) I(t) R(t) +infection = rate_to_proportion( + β * c * I(k - 1) / (S(k - 1) * h + I(k - 1) + R(k - 1)), δt * h) * S(k - 1) +recovery = rate_to_proportion(γ * h, δt) * I(k - 1) + +# Equations +eqs = [S ~ S(k - 1) - infection * h, + I ~ I(k - 1) + infection - recovery, + R ~ R(k - 1) + recovery] + +# System +@named sys = DiscreteSystem(eqs, t, [S, I, R], [c, nsteps, δt, β, γ]) +syss = structural_simplify(sys) +@test syss == syss + +df = DiscreteFunction(syss) +# iip +du = zeros(3) +u = collect(1:3) +p = MTKParameters(syss, [c, nsteps, δt, β, γ] .=> collect(1:5)) +df.f(du, u, p, 0) +@test du ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] + +# oop +@test df.f(u, p, 0) ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] + +# Problem +u0 = [S(k - 1) => 990.0, I(k - 1) => 10.0, R(k - 1) => 0.0] +p = [β => 0.05, c => 10.0, γ => 0.25, δt => 0.1, nsteps => 400] +tspan = (0.0, ModelingToolkit.value(substitute(nsteps, p))) # value function (from Symbolics) is used to convert a Num to Float64 +prob_map = DiscreteProblem(syss, u0, tspan, p) +@test prob_map.f.sys === syss + +# Solution +using OrdinaryDiffEq +sol_map = solve(prob_map, FunctionMap()); +@test sol_map[S] isa Vector + +# Using defaults constructor +@parameters c=10.0 nsteps=400 δt=0.1 β=0.05 γ=0.25 +@variables S(t)=990.0 I(t)=10.0 R(t)=0.0 R2(t) + +infection2 = rate_to_proportion(β * c * I(k - 1) / (S(k - 1) + I(k - 1) + R(k - 1)), δt) * + S(k - 1) +recovery2 = rate_to_proportion(γ, δt) * I(k - 1) + +eqs2 = [S ~ S(k - 1) - infection2, + I ~ I(k - 1) + infection2 - recovery2, + R ~ R(k - 1) + recovery2, + R2 ~ R] + +@mtkbuild sys = DiscreteSystem( + eqs2, t, [S, I, R, R2], [c, nsteps, δt, β, γ]; controls = [β, γ], tspan) +@test ModelingToolkit.defaults(sys) != Dict() + +prob_map2 = DiscreteProblem(sys) +sol_map2 = solve(prob_map2, FunctionMap()); + +@test sol_map.u ≈ sol_map2.u +for p in parameters(sys) + @test sol_map.prob.ps[p] ≈ sol_map2.prob.ps[p] +end +@test_throws Any sol_map2[R2] +@test sol_map2[R2(k + 1)][begin:(end - 1)] == sol_map2[R][(begin + 1):end] +# Direct Implementation + +function sir_map!(u_diff, u, p, t) + (S, I, R) = u + (β, c, γ, δt) = p + N = S + I + R + infection = rate_to_proportion(β * c * I / N, δt) * S + recovery = rate_to_proportion(γ, δt) * I + @inbounds begin + u_diff[1] = S - infection + u_diff[2] = I + infection - recovery + u_diff[3] = R + recovery + end + nothing +end; +u0 = prob_map2.u0; +p = [0.05, 10.0, 0.25, 0.1]; +prob_map = DiscreteProblem(sir_map!, u0, tspan, p); +sol_map2 = solve(prob_map, FunctionMap()); + +@test Array(sol_map) ≈ Array(sol_map2) + +# Delayed difference equation +# @variables x(..) y(..) z(t) +# D1 = Difference(t; dt = 1.5) +# D2 = Difference(t; dt = 2) + +# @test ModelingToolkit.is_delay_var(Symbolics.value(t), Symbolics.value(x(t - 2))) +# @test ModelingToolkit.is_delay_var(Symbolics.value(t), Symbolics.value(y(t - 1))) +# @test !ModelingToolkit.is_delay_var(Symbolics.value(t), Symbolics.value(z)) +# @test_throws ErrorException ModelingToolkit.get_delay_val(Symbolics.value(t), +# Symbolics.arguments(Symbolics.value(x(t + +# 2)))[1]) +# @test_throws ErrorException z(t) + +# # Equations +# eqs = [ +# D1(x(t)) ~ 0.4x(t) + 0.3x(t - 1.5) + 0.1x(t - 3), +# D2(y(t)) ~ 0.3y(t) + 0.7y(t - 2) + 0.1z * h, +# ] + +# # System +# @named sys = DiscreteSystem(eqs, t, [x(t), x(t - 1.5), x(t - 3), y(t), y(t - 2), z], []) + +# eqs2, max_delay = ModelingToolkit.linearize_eqs(sys; return_max_delay = true) + +# @test max_delay[Symbolics.operation(Symbolics.value(x(t)))] ≈ 3 +# @test max_delay[Symbolics.operation(Symbolics.value(y(t)))] ≈ 2 + +# linearized_eqs = [eqs +# x(t - 3.0) ~ x(t - 1.5) +# x(t - 1.5) ~ x(t) +# y(t - 2.0) ~ y(t)] +# @test all(eqs2 .== linearized_eqs) + +# observed variable handling +@variables x(t) RHS(t) +@parameters τ +@named fol = DiscreteSystem( + [x ~ (1 - x(k - 1)) / τ], t, [x, RHS], [τ]; observed = [RHS ~ (1 - x) / τ * h]) +@test isequal(RHS, @nonamespace fol.RHS) +RHS2 = RHS +@unpack RHS = fol +@test isequal(RHS, RHS2) + +# @testset "Preface tests" begin +# using OrdinaryDiffEq +# using Symbolics +# using DiffEqBase: isinplace +# using ModelingToolkit +# using SymbolicUtils.Code +# using SymbolicUtils: Sym + +# c = [0] +# f = function f(c, d::Vector{Float64}, u::Vector{Float64}, p, t::Float64, dt::Float64) +# c .= [c[1] + 1] +# d .= randn(length(u)) +# nothing +# end + +# dummy_identity(x, _) = x +# @register_symbolic dummy_identity(x, y) + +# u0 = ones(5) +# p0 = Float64[] +# syms = [Symbol(:a, i) for i in 1:5] +# syms_p = Symbol[] +# dt = 0.1 +# @assert isinplace(f, 6) +# wf = let c = c, buffer = similar(u0), u = similar(u0), p = similar(p0), dt = dt +# t -> (f(c, buffer, u, p, t, dt); buffer) +# end + +# num = hash(f) ⊻ length(u0) ⊻ length(p0) +# buffername = Symbol(:fmi_buffer_, num) + +# Δ = DiscreteUpdate(t; dt = dt) +# us = map(s -> (@variables $s(t))[1], syms) +# ps = map(s -> (@variables $s(t))[1], syms_p) +# buffer, = @variables $buffername[1:length(u0)] +# dummy_var = Sym{Any}(:_) # this is safe because _ cannot be a rvalue in Julia + +# ss = Iterators.flatten((us, ps)) +# vv = Iterators.flatten((u0, p0)) +# defs = Dict{Any, Any}(s => v for (s, v) in zip(ss, vv)) + +# preface = [Assignment(dummy_var, SetArray(true, term(getfield, wf, Meta.quot(:u)), us)) +# Assignment(dummy_var, SetArray(true, term(getfield, wf, Meta.quot(:p)), ps)) +# Assignment(buffer, term(wf, t))] +# eqs = map(1:length(us)) do i +# Δ(us[i]) ~ dummy_identity(buffer[i], us[i]) +# end + +# @mtkbuild sys = DiscreteSystem(eqs, t, us, ps; defaults = defs, preface = preface) +# prob = DiscreteProblem(sys, [], (0.0, 1.0)) +# sol = solve(prob, FunctionMap(); dt = dt) +# @test c[1] + 1 == length(sol) +# end + +@variables x(t) y(t) +testdict = Dict([:test => 1]) +@named sys = DiscreteSystem([x(k + 1) ~ 1.0], t, [x], []; metadata = testdict) +@test get_metadata(sys) == testdict + +@variables x(t) y(t) u(t) +eqs = [u ~ 1 + x ~ x(k - 1) + u + y ~ x + u] +@mtkbuild de = DiscreteSystem(eqs, t) +prob = DiscreteProblem(de, [x(k - 1) => 0.0], (0, 10)) +sol = solve(prob, FunctionMap()) + +@test reduce(vcat, sol.u) == 1:11 + +# Issue#2585 +getdata(buffer, t) = buffer[mod1(Int(t), length(buffer))] +@register_symbolic getdata(buffer::Vector, t) +k = ShiftIndex(t) +function SampledData(; name, buffer) + L = length(buffer) + pars = @parameters begin + buffer[1:L] = buffer + end + @variables output(t) time(t) + eqs = [time ~ time(k - 1) + 1 + output ~ getdata(buffer, time)] + return DiscreteSystem(eqs, t; name) +end +function System(; name, buffer) + @named y_sys = SampledData(; buffer = buffer) + pars = @parameters begin + α = 0.5, [description = "alpha"] + β = 0.5, [description = "beta"] + end + vars = @variables y(t)=0.0 y_shk(t)=0.0 + + eqs = [y_shk ~ y_sys.output + # y[t] = 0.5 * y[t - 1] + 0.5 * y[t + 1] + y_shk[t] + y(k - 1) ~ α * y(k - 2) + (β * y(k) + y_shk(k - 1))] + + DiscreteSystem(eqs, t, vars, pars; systems = [y_sys], name = name) +end + +@test_nowarn @mtkbuild sys = System(; buffer = ones(10)) + +# Ensure discrete systems with algebraic equations throw +@variables x(t) y(t) +k = ShiftIndex(t) +@named sys = DiscreteSystem([x ~ x^2 + y^2, y ~ x(k - 1) + y(k - 1)], t) +@test_throws ["algebraic equations", "ImplicitDiscreteSystem"] structural_simplify(sys) + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + k = ShiftIndex() + @mtkbuild sys = DiscreteSystem([x(k) ~ x(k - 1) + 1], t) + prob = @test_nowarn DiscreteProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob, FunctionMap()) +end + +@testset "Initialization" begin + # test that default values apply to the entire history + @variables x(t) = 1.0 + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2)], t) + prob = DiscreteProblem(de, [], (0, 10)) + @test prob[x] == 2.0 + @test prob[x(k - 1)] == 1.0 + + # must provide initial conditions for history + @test_throws ErrorException DiscreteProblem(de, [x => 2.0], (0, 10)) + @test_throws ErrorException DiscreteProblem(de, [x(k + 1) => 2.0], (0, 10)) + + # initial values only affect _that timestep_, not the entire history + prob = DiscreteProblem(de, [x(k - 1) => 2.0], (0, 10)) + @test prob[x] == 3.0 + @test prob[x(k - 1)] == 2.0 + @variables xₜ₋₁(t) + @test prob[xₜ₋₁] == 2.0 + + # Test initial assignment with lowered variable + prob = DiscreteProblem(de, [xₜ₋₁(k - 1) => 4.0], (0, 10)) + @test prob[x(k - 1)] == prob[xₜ₋₁] == 1.0 + @test prob[x] == 5.0 + + # Test missing initial throws error + @variables x(t) + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2) * x(k - 3)], t) + @test_throws ErrorException prob=DiscreteProblem(de, [x(k - 3) => 2.0], (0, 10)) + @test_throws ErrorException prob=DiscreteProblem( + de, [x(k - 3) => 2.0, x(k - 1) => 3.0], (0, 10)) + + # Test non-assigned initials are given default value + @variables x(t) = 2.0 + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2) * x(k - 3)], t) + prob = DiscreteProblem(de, [x(k - 3) => 12.0], (0, 10)) + @test prob[x] == 26.0 + @test prob[x(k - 1)] == 2.0 + @test prob[x(k - 2)] == 2.0 + + # Elaborate test + @variables xₜ₋₂(t) zₜ₋₁(t) z(t) + eqs = [x ~ x(k - 1) + z(k - 2), + z ~ x(k - 2) * x(k - 3) - z(k - 1)^2] + @mtkbuild de = DiscreteSystem(eqs, t) + u0 = [x(k - 1) => 3, + xₜ₋₂(k - 1) => 4, + x(k - 2) => 1, + z(k - 1) => 5, + zₜ₋₁(k - 1) => 12] + prob = DiscreteProblem(de, u0, (0, 10)) + @test prob[x] == 15 + @test prob[z] == -21 + + import ModelingToolkit: shift2term + # unknowns(de) = xₜ₋₁, x, zₜ₋₁, xₜ₋₂, z + vars = ModelingToolkit.value.(unknowns(de)) + @test isequal(shift2term(Shift(t, 1)(vars[1])), vars[2]) + @test isequal(shift2term(Shift(t, 1)(vars[4])), vars[1]) + @test isequal(shift2term(Shift(t, -1)(vars[5])), vars[3]) + @test isequal(shift2term(Shift(t, -2)(vars[2])), vars[4]) +end diff --git a/test/discretesystem.jl b/test/discretesystem.jl deleted file mode 100644 index 24f44dc092..0000000000 --- a/test/discretesystem.jl +++ /dev/null @@ -1,57 +0,0 @@ -# Example: Compartmental models in epidemiology -#= -- https://github.com/epirecipes/sir-julia/blob/master/markdown/function_map/function_map.md -- https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology#Deterministic_versus_stochastic_epidemic_models -=# -using ModelingToolkit - -@inline function rate_to_proportion(r,t) - 1-exp(-r*t) -end; - -# Independent and dependent variables and parameters -@parameters t c nsteps δt β γ -@variables S(t) I(t) R(t) next_S(t) next_I(t) next_R(t) - -infection = rate_to_proportion(β*c*I/(S+I+R),δt)*S -recovery = rate_to_proportion(γ,δt)*I - -# Equations -eqs = [next_S ~ S-infection, - next_I ~ I+infection-recovery, - next_R ~ R+recovery] - -# System -sys = DiscreteSystem(eqs,t,[S,I,R],[c,nsteps,δt,β,γ]) - -# Problem -u0 = [S => 990.0, I => 10.0, R => 0.0] -p = [β => 0.05, c => 10.0, γ => 0.25, δt => 0.1, nsteps => 400] -tspan = (0.0,ModelingToolkit.value(substitute(nsteps,p))) # value function (from Symbolics) is used to convert a Num to Float64 -prob_map = DiscreteProblem(sys,u0,tspan,p) - -# Solution -using OrdinaryDiffEq -sol_map = solve(prob_map,FunctionMap()); - -# Direct Implementation - -function sir_map!(du,u,p,t) - (S,I,R) = u - (β,c,γ,δt) = p - N = S+I+R - infection = rate_to_proportion(β*c*I/N,δt)*S - recovery = rate_to_proportion(γ,δt)*I - @inbounds begin - du[1] = S-infection - du[2] = I+infection-recovery - du[3] = R+recovery - end - nothing -end; -u0 = [990.0,10.0,0.0]; -p = [0.05,10.0,0.25,0.1]; -prob_map = DiscreteProblem(sir_map!,u0,tspan,p); -sol_map2 = solve(prob_map,FunctionMap()); - -@test Array(sol_map) ≈ Array(sol_map2) diff --git a/test/distributed.jl b/test/distributed.jl index 55ce97d72f..0b75b8eeb3 100644 --- a/test/distributed.jl +++ b/test/distributed.jl @@ -1,37 +1,36 @@ -using Distributed -# add processes to workspace -addprocs(2) - -@everywhere using ModelingToolkit, OrdinaryDiffEq - -# create the Lorenz system -@everywhere @parameters t σ ρ β -@everywhere @variables x(t) y(t) z(t) -@everywhere D = Differential(t) - -@everywhere eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -@everywhere de = ODESystem(eqs) -@everywhere ode_func = ODEFunction(de, [x,y,z], [σ, ρ, β]) - -@everywhere u0 = [19.,20.,50.] -@everywhere params = [16.,45.92,4] - -@everywhere ode_prob = ODEProblem(ode_func, u0, (0., 10.),params) - -@everywhere begin - - using OrdinaryDiffEq - using ModelingToolkit - - function solve_lorenz(ode_problem) - print(solve(ode_problem,Tsit5())) - end -end - -solve_lorenz(ode_prob) - -future = @spawn solve_lorenz(ode_prob) -@test_broken fetch(future) +using Distributed +# add processes to workspace +addprocs(2) + +@everywhere using ModelingToolkit, OrdinaryDiffEq +@everywhere using ModelingToolkit: t_nounits as t, D_nounits as D + +# create the Lorenz system +@everywhere @parameters σ ρ β +@everywhere @variables x(t) y(t) z(t) + +@everywhere eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@everywhere @named de = ODESystem(eqs, t) +@everywhere de = complete(de) + +@everywhere u0 = [19.0, 20.0, 50.0] +@everywhere params = [16.0, 45.92, 4] + +@everywhere ode_prob = ODEProblem(de, u0, (0.0, 10.0), params) + +@everywhere begin + using OrdinaryDiffEq + using ModelingToolkit + + function solve_lorenz(ode_problem) + print(solve(ode_problem, Tsit5())) + end +end + +solve_lorenz(ode_prob) + +future = @spawn solve_lorenz(ode_prob) +fetch(future) diff --git a/test/domain_connectors.jl b/test/domain_connectors.jl new file mode 100644 index 0000000000..9a43d2938f --- /dev/null +++ b/test/domain_connectors.jl @@ -0,0 +1,155 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +using Test + +@connector function HydraulicPort(; p_int, name) + pars = @parameters begin + ρ + β + μ + end + + vars = @variables begin + p(t) = p_int + dm(t), [connect = Flow] + end + + ODESystem(Equation[], t, vars, pars; name, defaults = [dm => 0]) +end + +@connector function HydraulicFluid(; + density = 997, + bulk_modulus = 2.09e9, + viscosity = 0.0010016, + name) + pars = @parameters begin + ρ = density + β = bulk_modulus + μ = viscosity + end + + vars = @variables begin + dm(t), [connect = Flow] + end + + eqs = [ + dm ~ 0 + ] + + ODESystem(eqs, t, vars, pars; name, defaults = [dm => 0]) +end + +function FixedPressure(; p, name) + pars = @parameters begin + p = p + end + + vars = [] + + systems = @named begin + port = HydraulicPort(; p_int = p) + end + + eqs = [ + port.p ~ p + ] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function FixedVolume(; vol, p_int, name) + pars = @parameters begin + p_int = p_int + vol = vol + end + + systems = @named begin + port = HydraulicPort(; p_int) + end + + vars = @variables begin + rho(t) = port.ρ + drho(t) = 0 + end + + # let + dm = port.dm + p = port.p + + eqs = [D(rho) ~ drho + rho ~ port.ρ * (1 + p / port.β) + dm ~ drho * vol] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function Valve2Port(; p_s_int, p_r_int, p_int, name) + pars = @parameters begin + p_s_int = p_s_int + p_r_int = p_r_int + p_int = p_int + x_int = 0 + scale = 1.0 + + k = 0.1 + end + + systems = @named begin + HS = HydraulicPort(; p_int = p_s_int) + HR = HydraulicPort(; p_int = p_r_int) + port = HydraulicPort(; p_int) + end + + vars = @variables begin + x(t) = x_int + end + + # let (flow) --------- + Δp_s = HS.p - port.p + Δp_r = port.p - HR.p + + x̃ = abs(x / scale) + Δp̃_s = abs(Δp_s) + Δp̃_r = abs(Δp_r) + + flow(Δp̃) = (k) * (Δp̃) * (x̃) + + # + eqs = [domain_connect(port, HS, HR) + port.dm ~ -ifelse(x >= 0, +flow(Δp̃_s), -flow(Δp̃_r)) + HS.dm ~ ifelse(x >= 0, port.dm, 0) + HR.dm ~ ifelse(x < 0, port.dm, 0)] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function System(; name) + vars = [] + pars = [] + systems = @named begin + fluid = HydraulicFluid(; density = 500, bulk_modulus = 1e9, viscosity = 0.01) + src = FixedPressure(; p = 200) + rtn = FixedPressure(; p = 0) + valve = Valve2Port(; p_s_int = 200, p_r_int = 0, p_int = 100) + vol = FixedVolume(; vol = 0.1, p_int = 100) + end + eqs = [domain_connect(fluid, src.port) + connect(src.port, valve.HS) + connect(rtn.port, valve.HR) + connect(vol.port, valve.port) + valve.x ~ sin(2π * t * 10)] + + return ODESystem(eqs, t, vars, pars; systems, name) +end + +@named odesys = System() +esys = ModelingToolkit.expand_connections(odesys) +@test length(equations(esys)) == length(unknowns(esys)) + +csys = complete(odesys) + +sys = structural_simplify(odesys) +@test length(equations(sys)) == length(unknowns(sys)) + +sys_defs = ModelingToolkit.defaults(sys) +@test Symbol(sys_defs[csys.vol.port.ρ]) == Symbol(csys.fluid.ρ) diff --git a/test/domains.jl b/test/domains.jl deleted file mode 100644 index e8760d3822..0000000000 --- a/test/domains.jl +++ /dev/null @@ -1,13 +0,0 @@ -using ModelingToolkit - -@parameters t x -domains = [t ∈ IntervalDomain(0.0,1.0), - x ∈ IntervalDomain(0.0,1.0)] - -@parameters z -z ∈ (IntervalDomain(0.0,1.0) ⊗ IntervalDomain(0.0,1.0)) - -@parameters y -(x,y) ∈ CircleDomain() -@parameters r θ -(r,θ) ∈ CircleDomain(true) diff --git a/test/downstream/Project.toml b/test/downstream/Project.toml new file mode 100644 index 0000000000..ade09e797b --- /dev/null +++ b/test/downstream/Project.toml @@ -0,0 +1,10 @@ +[deps] +ControlSystemsMTK = "687d7614-c7e5-45fc-bfc3-9ee385575c88" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" +OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" + +[compat] +ModelingToolkitStandardLibrary = "2.19" diff --git a/test/downstream/analysis_points.jl b/test/downstream/analysis_points.jl new file mode 100644 index 0000000000..29b9aad512 --- /dev/null +++ b/test/downstream/analysis_points.jl @@ -0,0 +1,496 @@ +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra, ControlSystemsBase +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: connect, t_nounits as t, D_nounits as D +import ControlSystemsBase as CS + +@testset "Complicated model" begin + # Parameters + m1 = 1 + m2 = 1 + k = 1000 # Spring stiffness + c = 10 # Damping coefficient + @named inertia1 = Inertia(; J = m1) + @named inertia2 = Inertia(; J = m2) + @named spring = Spring(; c = k) + @named damper = Damper(; d = c) + @named torque = Torque() + + function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return ODESystem(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ], + name) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + end + + @named r = Step(start_time = 0) + model = SystemModel() + @named pid = PID(k = 100, Ti = 0.5, Td = 1) + @named filt = SecondOrder(d = 0.9, w = 10) + @named sensor = AngleSensor() + @named er = Add(k2 = -1) + + connections = [connect(r.output, :r, filt.input) + connect(filt.output, er.input1) + connect(pid.ctr_output, :u, model.torque.tau) + connect(model.inertia2.flange_b, sensor.flange) + connect(sensor.phi, :y, er.input2) + connect(er.output, :e, pid.err_input)] + + closed_loop = ODESystem(connections, t, systems = [model, pid, filt, sensor, r, er], + name = :closed_loop, defaults = [ + model.inertia1.phi => 0.0, + model.inertia2.phi => 0.0, + model.inertia1.w => 0.0, + model.inertia2.w => 0.0, + filt.x => 0.0, + filt.xd => 0.0 + ]) + + sys = structural_simplify(closed_loop) + prob = ODEProblem(sys, unknowns(sys) .=> 0.0, (0.0, 4.0)) + sol = solve(prob, Rodas5P(), reltol = 1e-6, abstol = 1e-9) + + matrices, ssys = linearize(closed_loop, :r, :y) + lsys = ss(matrices...) |> sminreal + @test lsys.nx == 8 + + stepres = ControlSystemsBase.step(c2d(lsys, 0.001), 4) + @test Array(stepres.y[:])≈Array(sol(0:0.001:4, idxs = model.inertia2.phi)) rtol=1e-4 + + matrices, ssys = get_sensitivity(closed_loop, :y) + So = ss(matrices...) + + matrices, ssys = get_sensitivity(closed_loop, :u) + Si = ss(matrices...) + + @test tf(So) ≈ tf(Si) +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - standard `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output, :plant_output, add.input2) + connect(add.output, C.input) + connect(C.output, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output, sys_inner.add.input2) + connect(sys_inner.C.output, :plant_input, sys_inner.P.input) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - causal variable `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output.u, :plant_output, add.input2.u) + connect(add.output, C.input) + connect(C.output.u, P.input.u)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output.u, sys_inner.add.input2.u) + connect(sys_inner.C.output.u, :plant_input, sys_inner.P.input.u) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - mixed `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output.u, :plant_output, add.input2.u) + connect(add.output, C.input) + connect(C.output, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output, sys_inner.add.input2) + connect(sys_inner.C.output.u, :plant_input, sys_inner.P.input.u) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "multilevel system with loop openings" begin + @named P_inner = FirstOrder(k = 1, T = 1) + @named feedback = Feedback() + @named ref = Step() + @named sys_inner = ODESystem( + [connect(P_inner.output, :y, feedback.input2) + connect(feedback.output, :u, P_inner.input) + connect(ref.output, :r, feedback.input1)], + t, + systems = [P_inner, feedback, ref]) + + P_not_broken, _ = linearize(sys_inner, :u, :y) + @test P_not_broken.A[] == -2 + P_broken, _ = linearize(sys_inner, :u, :y, loop_openings = [:u]) + @test P_broken.A[] == -1 + P_broken, _ = linearize(sys_inner, :u, :y, loop_openings = [:y]) + @test P_broken.A[] == -1 + + Sinner = sminreal(ss(get_sensitivity(sys_inner, :u)[1]...)) + + @named sys_inner = ODESystem( + [connect(P_inner.output, :y, feedback.input2) + connect(feedback.output, :u, P_inner.input)], + t, + systems = [P_inner, feedback]) + + @named P_outer = FirstOrder(k = rand(), T = rand()) + + @named sys_outer = ODESystem( + [connect(sys_inner.P_inner.output, :y2, P_outer.input) + connect(P_outer.output, :u2, sys_inner.feedback.input1)], + t, + systems = [P_outer, sys_inner]) + + Souter = sminreal(ss(get_sensitivity(sys_outer, sys_outer.sys_inner.u)[1]...)) + + Sinner2 = sminreal(ss(get_sensitivity( + sys_outer, sys_outer.sys_inner.u, loop_openings = [:y2])[1]...)) + + @test Sinner.nx == 1 + @test Sinner == Sinner2 + @test Souter.nx == 2 +end + +@testset "sensitivities in multivariate signals" begin + A = [-0.994 -0.0794; -0.006242 -0.0134] + B = [-0.181 -0.389; 1.1 1.12] + C = [1.74 0.72; -0.33 0.33] + D = [0.0 0.0; 0.0 0.0] + @named P = Blocks.StateSpace(A, B, C, D) + Pss = CS.ss(A, B, C, D) + + A = [-0.097;;] + B = [-0.138 -1.02] + C = [-0.076; 0.09;;] + D = [0.0 0.0; 0.0 0.0] + @named K = Blocks.StateSpace(A, B, C, D) + Kss = CS.ss(A, B, C, D) + + eqs = [connect(P.output, :plant_output, K.input) + connect(K.output, :plant_input, P.input)] + sys = ODESystem(eqs, t, systems = [P, K], name = :hej) + + matrices, _ = get_sensitivity(sys, :plant_input) + S = CS.feedback(I(2), Kss * Pss, pos_feedback = true) + + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(S) + + matrices, _ = get_comp_sensitivity(sys, :plant_input) + T = -CS.feedback(Kss * Pss, I(2), pos_feedback = true) + + # bodeplot([ss(matrices...), T]) + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(T) + + matrices, _ = get_looptransfer( + sys, :plant_input) + L = Kss * Pss + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(L) + + matrices, _ = linearize(sys, AnalysisPoint(:plant_input), :plant_output) + G = CS.feedback(Pss, Kss, pos_feedback = true) + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(G) +end + +@testset "multiple analysis points" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output, :plant_output, add.input2) + connect(add.output, C.input) + connect(C.output, :plant_input, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + matrices, _ = get_sensitivity( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + + Ps = tf(1, [1, 1]) |> ss + Cs = tf(1) |> ss + + G = CS.ss(matrices...) |> sminreal + Si = CS.feedback(1, Cs * Ps) + @test tf(G[1, 1]) ≈ tf(Si) + + So = CS.feedback(1, Ps * Cs) + @test tf(G[2, 2]) ≈ tf(So) + @test tf(G[1, 2]) ≈ tf(-CS.feedback(Cs, Ps)) + @test tf(G[2, 1]) ≈ tf(CS.feedback(Ps, Cs)) + + matrices, _ = get_comp_sensitivity( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + + G = CS.ss(matrices...) |> sminreal + Ti = CS.feedback(Cs * Ps) + @test tf(G[1, 1]) ≈ tf(Ti) + + To = CS.feedback(Ps * Cs) + @test tf(G[2, 2]) ≈ tf(To) + @test tf(G[1, 2]) ≈ tf(CS.feedback(Cs, Ps)) # The negative sign appears in a confusing place due to negative feedback not happening through Ps + @test tf(G[2, 1]) ≈ tf(-CS.feedback(Ps, Cs)) + + # matrices, _ = get_looptransfer(sys_outer, [:inner_plant_input, :inner_plant_output]) + matrices, _ = get_looptransfer(sys_outer, sys_outer.inner.plant_input) + L = CS.ss(matrices...) |> sminreal + @test tf(L) ≈ -tf(Cs * Ps) + + matrices, _ = get_looptransfer(sys_outer, sys_outer.inner.plant_output) + L = CS.ss(matrices...) |> sminreal + @test tf(L[1, 1]) ≈ -tf(Ps * Cs) + + # Calling looptransfer like below is not the intended way, but we can work out what it should return if we did so it remains a valid test + matrices, _ = get_looptransfer( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + L = CS.ss(matrices...) |> sminreal + @test tf(L[1, 1]) ≈ tf(0) + @test tf(L[2, 2]) ≈ tf(0) + @test sminreal(L[1, 2]) ≈ ss(-1) + @test tf(L[2, 1]) ≈ tf(Ps) + + matrices, _ = linearize( + sys_outer, [sys_outer.inner.plant_input], [nameof(sys_inner.plant_output)]) + G = CS.ss(matrices...) |> sminreal + @test tf(G) ≈ tf(CS.feedback(Ps, Cs)) +end + +function normal_test_system() + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs_normal = [connect(back.output, :ap, F1.input) + connect(back.output, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named normal_inner = ODESystem(eqs_normal, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2_normal = [ + connect(step.output, normal_inner.back.input1) + ] + @named sys_normal = ODESystem(eqs2_normal, t; systems = [normal_inner, step]) +end + +sys_normal = normal_test_system() + +prob = ODEProblem(structural_simplify(sys_normal), [], (0.0, 10.0)) +@test SciMLBase.successful_retcode(solve(prob, Rodas5P())) +matrices_normal, _ = get_sensitivity(sys_normal, sys_normal.normal_inner.ap) + +@testset "Analysis point overriding part of connection - normal connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output, F1.input, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output, :ap, inner.F1.input)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Analysis point overriding part of connection - variable connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output.u, F1.input.u, F2.input.u) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output.u, :ap, inner.F1.input.u)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Analysis point overriding part of connection - mixed connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output, F1.input, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output.u, :ap, inner.F1.input.u)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Ignored analysis points only affect relevant connection sets" begin + m1 = 1 + m2 = 1 + k = 1000 # Spring stiffness + c = 10 # Damping coefficient + + @named inertia1 = Inertia(; J = m1, phi = 0, w = 0) + @named inertia2 = Inertia(; J = m2, phi = 0, w = 0) + + @named spring = Spring(; c = k) + @named damper = Damper(; d = c) + + @named torque = Torque(use_support = false) + + function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return @named model = ODESystem( + eqs, t; systems = [torque, inertia1, inertia2, spring, damper, u]) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + end + + @named r = Step(start_time = 1) + @named pid = LimPID(k = 400, Ti = 0.5, Td = 1, u_max = 350) + @named filt = SecondOrder(d = 0.9, w = 10, x = 0, xd = 0) + @named sensor = AngleSensor() + @named add = Add() # To add the feedback and feedforward control signals + model = SystemModel() + @named inverse_model = SystemModel() + @named inverse_sensor = AngleSensor() + connections = [connect(r.output, :r, filt.input) # Name connection r to form an analysis point + connect(inverse_model.inertia1.flange_b, inverse_sensor.flange) # Attach the inverse sensor to the inverse model + connect(filt.output, pid.reference, inverse_sensor.phi) # the filtered reference now goes to both the PID controller and the inverse model input + connect(inverse_model.torque.tau, add.input1) + connect(pid.ctr_output, add.input2) + connect(add.output, :u, model.torque.tau) # Name connection u to form an analysis point + connect(model.inertia1.flange_b, sensor.flange) + connect(sensor.phi, :y, pid.measurement)] + closed_loop = ODESystem(connections, t, + systems = [model, inverse_model, pid, filt, sensor, inverse_sensor, r, add], + name = :closed_loop) + # just ensure the system simplifies + mats, _ = get_sensitivity(closed_loop, :y) + S = CS.ss(mats...) + fr = CS.freqrespv(S, [0.01, 1, 100]) + # https://github.com/SciML/ModelingToolkit.jl/pull/3469 + reference_fr = ComplexF64[-1.2505330104772838e-11 - 2.500062613816021e-9im, + -0.0024688370221621625 - 0.002279011866413123im, + 1.8100018764334602 + 0.3623845793211718im] + @test isapprox(fr, reference_fr) +end diff --git a/test/downstream/inversemodel.jl b/test/downstream/inversemodel.jl new file mode 100644 index 0000000000..32d5ee87ec --- /dev/null +++ b/test/downstream/inversemodel.jl @@ -0,0 +1,190 @@ +using ModelingToolkit +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEq +using SymbolicIndexingInterface +using Test +using ControlSystemsMTK: tf, ss, get_named_sensitivity, get_named_comp_sensitivity +using ModelingToolkit: t_nounits as t, D_nounits as D +# ============================================================================== +## Mixing tank +# This tests a common workflow in control engineering, the use of an inverse-based +# feedforward model. Such a model differentiates "inputs", exercising the dummy-derivative functionality of ModelingToolkit. We also test linearization and computation of sensitivity functions +# for such models. +# ============================================================================== + +connect = ModelingToolkit.connect; +rc = 0.25 # Reference concentration + +@mtkmodel MixingTank begin + @parameters begin + c0 = 0.8, [description = "Nominal concentration"] + T0 = 308.5, [description = "Nominal temperature"] + a1 = 0.2674 + a21 = 1.815 + a22 = 0.4682 + b = 1.5476 + k0 = 1.05e14 + ϵ = 34.2894 + end + @variables begin + gamma(t), [description = "Reaction speed"] + xc(t) = c0, [description = "Concentration"] + xT(t) = T0, [description = "Temperature"] + xT_c(t), [description = "Cooling temperature"] + end + @components begin + T_c = RealInput() + c = RealOutput() + T = RealOutput() + end + begin + τ0 = 60 + wk0 = k0 / c0 + wϵ = ϵ * T0 + wa11 = a1 / τ0 + wa12 = c0 / τ0 + wa13 = c0 * a1 / τ0 + wa21 = a21 / τ0 + wa22 = a22 * T0 / τ0 + wa23 = T0 * (a21 - b) / τ0 + wb = b / τ0 + end + @equations begin + gamma ~ xc * wk0 * exp(-wϵ / xT) + D(xc) ~ -wa11 * xc - wa12 * gamma + wa13 + D(xT) ~ -wa21 * xT + wa22 * gamma + wa23 + wb * xT_c + xc ~ c.u + xT ~ T.u + xT_c ~ T_c.u + end +end +begin + Ftf = tf(1, [(100), 1])^2 + Fss = ss(Ftf) + # Create an MTK-compatible constructor + function RefFilter(; name) + sys = ODESystem(Fss; name) + "Compute initial state that yields y0 as output" + empty!(ModelingToolkit.get_defaults(sys)) + return sys + end +end +@mtkmodel InverseControlledTank begin + begin + c0 = 0.8 # "Nominal concentration + T0 = 308.5 # "Nominal temperature + x10 = 0.42 + x20 = 0.01 + u0 = -0.0224 + c_start = c0 * (1 - x10) # Initial concentration + T_start = T0 * (1 + x20) # Initial temperature + c_high_start = c0 * (1 - 0.72) # Reference concentration + T_c_start = T0 * (1 + u0) # Initial cooling temperature + end + @components begin + ref = Constant(k = 0.25) # Concentration reference + ff_gain = Gain(k = 1) # To allow turning ff off + controller = PI(gainPI.k = 10, T = 500) + tank = MixingTank(xc = c_start, xT = T_start, c0 = c0, T0 = T0) + inverse_tank = MixingTank(xc = nothing, xT = T_start, c0 = c0, T0 = T0) + feedback = Feedback() + add = Add() + filter = RefFilter() + noise_filter = FirstOrder(k = 1, T = 1, x = T_start) + # limiter = Gain(k=1) + limiter = Limiter(y_max = 370, y_min = 250) # Saturate the control input + end + @equations begin + connect(ref.output, :r, filter.input) + connect(filter.output, inverse_tank.c) + connect(inverse_tank.T_c, ff_gain.input) + connect(ff_gain.output, :uff, limiter.input) + connect(limiter.output, add.input1) + connect(controller.ctr_output, :u, add.input2) + connect(add.output, :u_tot, tank.T_c) + connect(inverse_tank.T, feedback.input1) + connect(tank.T, :y, noise_filter.input) + connect(noise_filter.output, feedback.input2) + connect(feedback.output, :e, controller.err_input) + end +end; +@named model = InverseControlledTank() +ssys = structural_simplify(model) +cm = complete(model) + +op = Dict( + cm.filter.y.u => 0.8 * (1 - 0.42), + D(cm.filter.y.u) => 0 +) +tspan = (0.0, 1000.0) +# https://github.com/SciML/ModelingToolkit.jl/issues/2786 +prob = ODEProblem(ssys, op, tspan) +sol = solve(prob, Rodas5P()) + +@test SciMLBase.successful_retcode(sol) + +# plot(sol, idxs=[model.tank.xc, model.tank.xT, model.controller.ctr_output.u], layout=3, sp=[1 2 3]) +# hline!([prob[cm.ref.k]], label="ref", sp=1) + +@test sol(tspan[2], idxs = cm.tank.xc)≈getp(prob, cm.ref.k)(prob) atol=1e-2 # Test that the inverse model led to the correct reference + +# we need to provide `op` so the initialization system knows what to hold constant +# the values don't matter +Sf, simplified_sys = Blocks.get_sensitivity_function(model, :y; op); # This should work without providing an operating opint containing a dummy derivative +x = state_values(Sf) +p = parameter_values(Sf) +# If this somehow passes, mention it on +# https://github.com/SciML/ModelingToolkit.jl/issues/2786 +matrices1 = Sf(x, p, 0) +matrices2, _ = Blocks.get_sensitivity(model, :y; op); # Test that we get the same result when calling the higher-level API +@test matrices1.f_x ≈ matrices2.A[1:6, 1:6] +nsys = get_named_sensitivity(model, :y; op) # Test that we get the same result when calling an even higher-level API +@test matrices2.A ≈ nsys.A + +# Test the same thing for comp sensitivities + +# This should work without providing an operating opint containing a dummy derivative +Sf, simplified_sys = Blocks.get_comp_sensitivity_function(model, :y; op); +x = state_values(Sf) +p = parameter_values(Sf) +# If this somehow passes, mention it on +# https://github.com/SciML/ModelingToolkit.jl/issues/2786 +matrices1 = Sf(x, p, 0) +# Test that we get the same result when calling the higher-level API +matrices2, _ = Blocks.get_comp_sensitivity(model, :y; op) +@test matrices1.f_x ≈ matrices2.A[1:6, 1:6] +# Test that we get the same result when calling an even higher-level API +nsys = get_named_comp_sensitivity(model, :y; op) +@test matrices2.A ≈ nsys.A + +@testset "Issue #3319" begin + op1 = Dict( + cm.filter.y.u => 0.8 * (1 - 0.42), + cm.tank.xc => 0.65 + ) + + op2 = Dict( + cm.filter.y.u => 0.8 * (1 - 0.42), + cm.tank.xc => 0.45 + ) + + output = :y + # we need to provide `op` so the initialization system knows which + # values to hold constant + lin_fun, ssys = Blocks.get_sensitivity_function(model, output; op = op1) + matrices1 = linearize(ssys, lin_fun, op = op1) + matrices2 = linearize(ssys, lin_fun, op = op2) + S1f = ss(matrices1...) + S2f = ss(matrices2...) + @test S1f != S2f + + matrices1, ssys = Blocks.get_sensitivity(model, output; op = op1) + matrices2, ssys = Blocks.get_sensitivity(model, output; op = op2) + S1 = ss(matrices1...) + S2 = ss(matrices2...) + @test S1 != S2 + + @test S1 == S1f + @test S2 == S2f +end diff --git a/test/downstream/linearization_dd.jl b/test/downstream/linearization_dd.jl new file mode 100644 index 0000000000..c4076b6aad --- /dev/null +++ b/test/downstream/linearization_dd.jl @@ -0,0 +1,62 @@ +## Test that dummy_derivatives can be set to zero +# The call to Link(; m = 0.2, l = 10, I = 1, g = -9.807) hangs forever on Julia v1.6 +using LinearAlgebra +using ModelingToolkit +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkitStandardLibrary.Mechanical.MultiBody2D +using ModelingToolkitStandardLibrary.Mechanical.TranslationalPosition +using Test + +using ControlSystemsMTK +using ControlSystemsMTK.ControlSystemsBase: sminreal, minreal, poles +connect = ModelingToolkit.connect + +@independent_variables t +D = Differential(t) + +@named link1 = Link(; m = 0.2, l = 10, I = 1, g = -9.807) +@named cart = TranslationalPosition.Mass(; m = 1, s = 0) +@named fixed = Fixed() +@named force = Force(use_support = false) + +eqs = [connect(link1.TX1, cart.flange) + connect(cart.flange, force.flange) + connect(link1.TY1, fixed.flange)] + +@named model = ODESystem(eqs, t, [], []; systems = [link1, cart, force, fixed]) +lin_outputs = [cart.s, cart.v, link1.A, link1.dA] +lin_inputs = [force.f.u] + +# => nothing to remove extra defaults +op = Dict(cart.s => 10, cart.v => 0, link1.A => -pi / 2, link1.dA => 0, force.f.u => 0, + link1.x1 => nothing, link1.y1 => nothing, link1.x2 => nothing, link1.x_cm => nothing) +@info "named_ss" +G = named_ss(model, lin_inputs, lin_outputs; allow_symbolic = true, op, + allow_input_derivatives = true, zero_dummy_der = true) +G = sminreal(G) +@info "minreal" +G = minreal(G) +@info "poles" +ps = poles(G) + +@test minimum(abs, ps) < 1e-6 +@test minimum(abs, complex(0, 1.3777260367206716) .- ps) < 1e-10 + +lsys, syss = linearize(model, lin_inputs, lin_outputs, allow_symbolic = true, op = op, + allow_input_derivatives = true, zero_dummy_der = true) +lsyss, sysss = ModelingToolkit.linearize_symbolic(model, lin_inputs, lin_outputs; + allow_input_derivatives = true) + +dummyder = setdiff(unknowns(sysss), unknowns(model)) +# op2 = merge(ModelingToolkit.guesses(model), op, Dict(x => 0.0 for x in dummyder)) +op2 = merge(ModelingToolkit.defaults(syss), op) +op2[link1.fy1] = -op2[link1.g] * op2[link1.m] +op2[cart.f] = 0 + +@test substitute(lsyss.A, op2) ≈ lsys.A +# We cannot pivot symbolically, so the part where a linear solve is required +# is not reliable. +@test substitute(lsyss.B, op2)[1:6, 1] ≈ lsys.B[1:6, 1] +@test substitute(lsyss.C, op2) ≈ lsys.C +@test substitute(lsyss.D, op2) ≈ lsys.D diff --git a/test/downstream/linearize.jl b/test/downstream/linearize.jl new file mode 100644 index 0000000000..9918f8145d --- /dev/null +++ b/test/downstream/linearize.jl @@ -0,0 +1,350 @@ +using ModelingToolkit, ADTypes, Test +using CommonSolve: solve + +# r is an input, and y is an output. +@independent_variables t +@variables x(t)=0 y(t)=0 u(t)=0 r(t)=0 +@variables x(t)=0 y(t)=0 u(t)=0 r(t)=0 [input = true] +@parameters kp = 1 +D = Differential(t) + +eqs = [u ~ kp * (r - y) + D(x) ~ -x + u + y ~ x] + +@named sys = ODESystem(eqs, t) + +lsys, ssys = linearize(sys, [r], [y]) +lprob = LinearizationProblem(sys, [r], [y]) +lsys2 = solve(lprob) +lsys3, _ = linearize(sys, [r], [y]; autodiff = AutoFiniteDiff()) + +@test lsys.A[] == lsys2.A[] == lsys3.A[] == -2 +@test lsys.B[] == lsys2.B[] == lsys3.B[] == 1 +@test lsys.C[] == lsys2.C[] == lsys3.C[] == 1 +@test lsys.D[] == lsys2.D[] == lsys3.D[] == 0 + +lsys, ssys = linearize(sys, [r], [r]) + +@test lsys.A[] == -2 +@test lsys.B[] == 1 +@test lsys.C[] == 0 +@test lsys.D[] == 1 + +lsys, ssys = linearize(sys, r, r) # Test allow scalars + +@test lsys.A[] == -2 +@test lsys.B[] == 1 +@test lsys.C[] == 0 +@test lsys.D[] == 1 + +## +``` + + r ┌─────┐ ┌─────┐ ┌─────┐ +───►│ ├──────►│ │ u │ │ + │ F │ │ C ├────►│ P │ y + └─────┘ ┌►│ │ │ ├─┬─► + │ └─────┘ └─────┘ │ + │ │ + └─────────────────────┘ +``` + +function plant(; name) + @variables x(t) + @variables u(t) y(t) + D = Differential(t) + eqs = [D(x) ~ -x + u + y ~ x] + ODESystem(eqs, t; name = name) +end + +function filt_(; name) + @variables x(t) y(t) + @variables u(t)=0 [input = true] + D = Differential(t) + eqs = [D(x) ~ -2 * x + u + y ~ x] + ODESystem(eqs, t, name = name) +end + +function controller(kp; name) + @variables y(t)=0 r(t)=0 u(t) + @parameters kp = kp + eqs = [ + u ~ kp * (r - y) + ] + ODESystem(eqs, t; name = name) +end + +@named f = filt_() +@named c = controller(1) +@named p = plant() + +connections = [f.y ~ c.r # filtered reference to controller reference + c.u ~ p.u # controller output to plant input + p.y ~ c.y] + +@named cl = ODESystem(connections, t, systems = [f, c, p]) + +lsys0, ssys = linearize(cl, [f.u], [p.x]) +desired_order = [f.x, p.x] +lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) +lsys1, ssys = linearize(cl, [f.u], [p.x]; autodiff = AutoFiniteDiff()) +lsys2 = ModelingToolkit.reorder_unknowns(lsys1, unknowns(ssys), desired_order) + +@test lsys.A == lsys2.A == [-2 0; 1 -2] +@test lsys.B == lsys2.B == reshape([1, 0], 2, 1) +@test lsys.C == lsys2.C == [0 1] +@test lsys.D[] == lsys2.D[] == 0 + +## Symbolic linearization +lsyss, _ = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) + +@test ModelingToolkit.fixpoint_sub(lsyss.A, ModelingToolkit.defaults(cl)) == lsys.A +@test ModelingToolkit.fixpoint_sub(lsyss.B, ModelingToolkit.defaults(cl)) == lsys.B +@test ModelingToolkit.fixpoint_sub(lsyss.C, ModelingToolkit.defaults(cl)) == lsys.C +@test ModelingToolkit.fixpoint_sub(lsyss.D, ModelingToolkit.defaults(cl)) == lsys.D +## +using ModelingToolkitStandardLibrary.Blocks: LimPID +k = 400 +Ti = 0.5 +Td = 1 +Nd = 10 +@named pid = LimPID(; k, Ti, Td, Nd) + +@unpack reference, measurement, ctr_output = pid +lsys0, ssys = linearize(pid, [reference.u, measurement.u], [ctr_output.u]; + op = Dict(reference.u => 0.0, measurement.u => 0.0)) +@unpack int, der = pid +desired_order = [int.x, der.x] +lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) + +@test lsys.A == [0 0; 0 -10] +@test lsys.B == [2 -2; 10 -10] +@test lsys.C == [400 -4000] +@test lsys.D == [4400 -4400] + +lsyss, _ = ModelingToolkit.linearize_symbolic(pid, [reference.u, measurement.u], + [ctr_output.u]) + +@test ModelingToolkit.fixpoint_sub( + lsyss.A, ModelingToolkit.defaults_and_guesses(pid)) == lsys.A +@test ModelingToolkit.fixpoint_sub( + lsyss.B, ModelingToolkit.defaults_and_guesses(pid)) == lsys.B +@test ModelingToolkit.fixpoint_sub( + lsyss.C, ModelingToolkit.defaults_and_guesses(pid)) == lsys.C +@test ModelingToolkit.fixpoint_sub( + lsyss.D, ModelingToolkit.defaults_and_guesses(pid)) == lsys.D + +# Test with the reverse desired unknown order as well to verify that similarity transform and reoreder_unknowns really works +lsys = ModelingToolkit.reorder_unknowns(lsys, unknowns(ssys), reverse(desired_order)) + +@test lsys.A == [-10 0; 0 0] +@test lsys.B == [10 -10; 2 -2] +@test lsys.C == [-4000 400] +@test lsys.D == [4400 -4400] + +## Test that there is a warning when input is misspecified +if VERSION >= v"1.8" + @test_throws "Some specified inputs were not found" linearize(pid, + [ + pid.reference.u, + pid.measurement.u + ], [ctr_output.u]) + @test_throws "Some specified outputs were not found" linearize(pid, + [ + reference.u, + measurement.u + ], + [pid.ctr_output.u]) +else # v1.6 does not have the feature to match error message + @test_throws ErrorException linearize(pid, + [ + pid.reference.u, + pid.measurement.u + ], [ctr_output.u]) + @test_throws ErrorException linearize(pid, + [reference.u, measurement.u], + [pid.ctr_output.u]) +end + +## Test operating points + +# The saturation has no dynamics +function saturation(; y_max, y_min = y_max > 0 ? -y_max : -Inf, name) + @variables u(t)=0 y(t)=0 + @parameters y_max=y_max y_min=y_min + ie = ifelse + eqs = [ + # The equation below is equivalent to y ~ clamp(u, y_min, y_max) + y ~ ie(u > y_max, y_max, ie((y_min < u) & (u < y_max), u, y_min)) + ] + ODESystem(eqs, t, name = name) +end +@named sat = saturation(; y_max = 1) +# inside the linear region, the function is identity +@unpack u, y = sat +lsys, ssys = linearize(sat, [u], [y]) +@test isempty(lsys.A) # there are no differential variables in this system +@test isempty(lsys.B) +@test isempty(lsys.C) +@test lsys.D[] == 1 + +@test_skip lsyss, _ = ModelingToolkit.linearize_symbolic(sat, [u], [y]) # Code gen replaces ifelse with if statements causing symbolic evaluation to fail +# @test substitute(lsyss.A, ModelingToolkit.defaults(sat)) == lsys.A +# @test substitute(lsyss.B, ModelingToolkit.defaults(sat)) == lsys.B +# @test substitute(lsyss.C, ModelingToolkit.defaults(sat)) == lsys.C +# @test substitute(lsyss.D, ModelingToolkit.defaults(sat)) == lsys.D + +# outside the linear region the derivative is 0 +lsys, ssys = linearize(sat, [u], [y]; op = Dict(u => 2)) +@test isempty(lsys.A) # there are no differential variables in this system +@test isempty(lsys.B) +@test isempty(lsys.C) +@test lsys.D[] == 0 + +# Test case when unknowns in system do not have equations in initialization system +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks: Add, Sine, PID, SecondOrder, Step, RealOutput +using ModelingToolkit: connect + +# Parameters +m1 = 1 +m2 = 1 +k = 1000 # Spring stiffness +c = 10 # Damping coefficient +@named inertia1 = Inertia(; J = m1) +@named inertia2 = Inertia(; J = m2) +@named spring = Spring(; c = k) +@named damper = Damper(; d = c) +@named torque = Torque() + +function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return ODESystem(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ], + name) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) +end + +@named r = Step(start_time = 0) +model = SystemModel() +@named pid = PID(k = 100, Ti = 0.5, Td = 1) +@named filt = SecondOrder(d = 0.9, w = 10) +@named sensor = AngleSensor() +@named er = Add(k2 = -1) + +connections = [connect(r.output, :r, filt.input) + connect(filt.output, er.input1) + connect(pid.ctr_output, :u, model.torque.tau) + connect(model.inertia2.flange_b, sensor.flange) + connect(sensor.phi, :y, er.input2) + connect(er.output, :e, pid.err_input)] + +closed_loop = ODESystem(connections, t, systems = [model, pid, filt, sensor, r, er], + name = :closed_loop, defaults = [ + model.inertia1.phi => 0.0, + model.inertia2.phi => 0.0, + model.inertia1.w => 0.0, + model.inertia2.w => 0.0, + filt.x => 0.0, + filt.xd => 0.0 + ]) + +@test_nowarn linearize(closed_loop, :r, :y; warn_empty_op = false) + +# https://discourse.julialang.org/t/mtk-change-in-linearize/115760/3 +@mtkmodel Tank_noi begin + # Model parameters + @parameters begin + ρ = 1, [description = "Liquid density"] + A = 5, [description = "Cross sectional tank area"] + K = 5, [description = "Effluent valve constant"] + h_ς = 3, [description = "Scaling level in valve model"] + end + # Model variables, with initial values needed + @variables begin + m(t) = 1.5 * ρ * A, [description = "Liquid mass"] + md_i(t), [description = "Influent mass flow rate"] + md_e(t), [description = "Effluent mass flow rate"] + V(t), [description = "Liquid volume"] + h(t), [description = "level"] + end + # Providing model equations + @equations begin + D(m) ~ md_i - md_e + m ~ ρ * V + V ~ A * h + md_e ~ K * sqrt(h / h_ς) + end +end + +@named tank_noi = Tank_noi() +@unpack md_i, h, m = tank_noi +m_ss = 2.4000000003229878 +@test_nowarn linearize(tank_noi, [md_i], [h]; op = Dict(m => m_ss, md_i => 2)) + +# Test initialization +@variables x(t) y(t) u(t)=1.0 +@parameters p = 1.0 +eqs = [D(x) ~ p * u, x ~ y] +@named sys = ODESystem(eqs, t) + +matrices1, _ = linearize(sys, [u], []; op = Dict(x => 2.0)) +matrices2, _ = linearize(sys, [u], []; op = Dict(y => 2.0)) +@test matrices1 == matrices2 + +# Ensure parameter values passed as `Dict` are respected +linfun, _ = linearization_function(sys, [u], []; op = Dict(x => 2.0)) +matrices = linfun([1.0], Dict(p => 3.0), 1.0) +# this would be 1 if the parameter value isn't respected +@test matrices.f_u[] == 3.0 + +@testset "linearization_function handles dependent values" begin + @parameters q + matrices = @test_nowarn linfun([1.0], Dict(p => 3q, q => 1.0), 1.0) + @test matrices.f_u[] == 3.0 +end + +@testset "Issue #2941 and #3400" begin + @variables x(t) y(t) + @parameters p + eqs = [0 ~ x * log(y) - p] + @named sys = ODESystem(eqs, t; defaults = [p => 1.0]) + sys = complete(sys) + @test_throws ModelingToolkit.MissingVariablesError linearize( + sys, [x], []; op = Dict(x => 1.0), allow_input_derivatives = true) + @test_nowarn linearize( + sys, [x], []; op = Dict(x => 1.0), guesses = Dict(y => 1.0), + allow_input_derivatives = true) +end + +@testset "Symbolic values for parameters in `linearize`" begin + @named tank_noi = Tank_noi() + @unpack md_i, h, m, ρ, A, K = tank_noi + m_ss = 2.4000000003229878 + @test_nowarn linearize( + tank_noi, [md_i], [h]; op = Dict(m => m_ss, md_i => 2, ρ => A / K, A => 5)) +end + +@testset "Warn on empty operating point" begin + @named tank_noi = Tank_noi() + @unpack md_i, h, m = tank_noi + m_ss = 2.4000000003229878 + @test_warn ["empty operating point", "warn_empty_op"] linearize( + tank_noi, [md_i], [h]; p = [md_i => 1.0]) +end diff --git a/test/downstream/test_disturbance_model.jl b/test/downstream/test_disturbance_model.jl new file mode 100644 index 0000000000..97276437e2 --- /dev/null +++ b/test/downstream/test_disturbance_model.jl @@ -0,0 +1,216 @@ +#= +This file implements and tests a typical workflow for state estimation with disturbance models +The primary subject of the tests is the analysis-point features and the +analysis-point specific method for `generate_control_function`. +=# +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra, Test +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: connect +# using Plots + +using ModelingToolkit: t_nounits as t, D_nounits as D + +indexof(sym, syms) = findfirst(isequal(sym), syms) + +## Build the system model ====================================================== +@mtkmodel SystemModel begin + @parameters begin + m1 = 1 + m2 = 1 + k = 10 # Spring stiffness + c = 3 # Damping coefficient + end + @components begin + inertia1 = Inertia(; J = m1, phi = 0, w = 0) + inertia2 = Inertia(; J = m2, phi = 0, w = 0) + spring = Spring(; c = k) + damper = Damper(; d = c) + torque = Torque(use_support = false) + end + @equations begin + connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b) + end +end + +# The addition of disturbance inputs relies on the fact that the plant model has been constructed using connectors, we use these to connect the disturbance inputs from outside the plant-model definition +@mtkmodel ModelWithInputs begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) # We add an input signal that equals zero by default so that it has no effect during normal simulation + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_torque1.tau) # When we connect the input _signals_, we do so through an analysis point. This allows us to easily disconnect the input signals in situations when we do not need them. + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + end +end + +@named model = ModelWithInputs() # Model with load disturbance +ssys = structural_simplify(model) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +# plot(sol) + +## +using ControlSystemsBase, ControlSystemsMTK +cmodel = complete(model) +P = cmodel.system_model +lsys = named_ss( + model, [:u, :d1], [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) + +## +# If we now want to add a disturbance model, we cannot do that since we have already connected a constant to the disturbance input, we thus create a new wrapper model with inputs + +s = tf("s") +dist(; name) = ODESystem(1 / s; name) + +@mtkmodel SystemModelWithDisturbanceModel begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + disturbance_model = dist() + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_model.input) + connect(disturbance_model.output, disturbance_torque1.tau) + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + end +end + +@named model_with_disturbance = SystemModelWithDisturbanceModel() +# ssys = structural_simplify(open_loop(model_with_disturbance, :d)) # Open loop worked, but it's a bit awkward that we have to use it here +# lsys2 = named_ss(model_with_disturbance, [:u, :d1], +# [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) +ssys = structural_simplify(model_with_disturbance) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +# plot(sol) + +## +# Now we only have an integrating disturbance affecting inertia1, what if we want both integrating and direct Gaussian? We'd need a "PI controller" disturbancemodel. If we add the disturbance model (s+1)/s we get the integrating and non-integrating noises being correlated which is fine, it reduces the dimensions of the sigma point by 1. + +dist3(; name) = ODESystem(ss(1 + 10 / s, balance = false); name) + +@mtkmodel SystemModelWithDisturbanceModel begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + disturbance_model = dist3() + system_model = SystemModel() + + y = Blocks.Add() + angle_sensor = AngleSensor() + output_disturbance = Blocks.Constant(k = 0) + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_model.input) + connect(disturbance_model.output, disturbance_torque1.tau) + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + + connect(system_model.inertia1.flange_b, angle_sensor.flange) + connect(angle_sensor.phi, y.input1) + connect(output_disturbance.output, :dy, y.input2) + end +end + +@named model_with_disturbance = SystemModelWithDisturbanceModel() +# ssys = structural_simplify(open_loop(model_with_disturbance, :d)) # Open loop worked, but it's a bit awkward that we have to use it here +# lsys3 = named_ss(model_with_disturbance, [:u, :d1], +# [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) +ssys = structural_simplify(model_with_disturbance) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +# plot(sol) + +## Generate function for an augmented Unscented Kalman Filter ===================== +# temp = open_loop(model_with_disturbance, :d) +outputs = [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w] +(f_oop1, f_ip), x_sym, p_sym, io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, [:u], [:d1, :d2, :dy], split = false) + +(f_oop2, f_ip2), x_sym, p_sym, io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, [:u], [:d1, :d2, :dy], + disturbance_argument = true, split = false) + +measurement = ModelingToolkit.build_explicit_observed_function( + io_sys, outputs, inputs = ModelingToolkit.inputs(io_sys)[1:1]) +measurement2 = ModelingToolkit.build_explicit_observed_function( + io_sys, [io_sys.y.output.u], inputs = ModelingToolkit.inputs(io_sys)[1:1], + disturbance_inputs = ModelingToolkit.inputs(io_sys)[2:end], + disturbance_argument = true) + +op = ModelingToolkit.inputs(io_sys) .=> 0 +x0, p = ModelingToolkit.get_u0_p(io_sys, op, op) +x = zeros(5) +u = zeros(1) +d = zeros(3) +@test f_oop2(x, u, p, t, d) == zeros(5) +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [0] + +# Add to the integrating disturbance input +d = [1, 0, 0] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 1, 1] # Affects disturbance state and one velocity +@test measurement2(x, u, p, 0.0, d) == [0] + +d = [0, 1, 0] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 0, 1] # Affects one velocity +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [0] + +d = [0, 0, 1] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 0, 0] # Affects nothing +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [1] # We have now disturbed the output + +## Further downstream tests that the functions generated above actually have the properties required to use for state estimation +# +# using LowLevelParticleFilters, SeeToDee +# Ts = 0.001 +# discrete_dynamics = SeeToDee.Rk4(f_oop2, Ts) +# nx = length(x_sym) +# nu = 1 +# nw = 2 +# ny = length(outputs) +# R1 = Diagonal([1e-5, 1e-5]) +# R2 = 0.1 * I(ny) +# op = ModelingToolkit.inputs(io_sys) .=> 0 +# x0, p = ModelingToolkit.get_u0_p(io_sys, op, op) +# d0 = LowLevelParticleFilters.SimpleMvNormal(x0, 10.0I(nx)) +# measurement_model = UKFMeasurementModel{Float64, false, false}(measurement, R2; nx, ny) +# kf = UnscentedKalmanFilter{false, false, true, false}( +# discrete_dynamics, measurement_model, R1, d0; nu, Ts, p) + +# tvec = 0:Ts:sol.t[end] +# u = vcat.(Array(sol(tvec, idxs = P.torque.tau.u))) +# y = collect.(eachcol(Array(sol(tvec, idxs = outputs)) .+ 1e-2 .* randn.())) + +# inds = 1:5805 +# res = forward_trajectory(kf, u, y) + +# plot(res, size = (1000, 1000)); +# plot!(sol, idxs = x_sym, sp = (1:nx)', l = :dash); diff --git a/test/dq_units.jl b/test/dq_units.jl new file mode 100644 index 0000000000..3c59c479c1 --- /dev/null +++ b/test/dq_units.jl @@ -0,0 +1,286 @@ +using ModelingToolkit, OrdinaryDiffEq, JumpProcesses, DynamicQuantities +using Symbolics +using Test +MT = ModelingToolkit +using ModelingToolkit: t, D +@parameters τ [unit = u"s"] γ +@variables E(t) [unit = u"J"] P(t) [unit = u"W"] + +# Basic access +@test MT.get_unit(t) == u"s" +@test MT.get_unit(E) == u"J" +@test MT.get_unit(τ) == u"s" +@test MT.get_unit(γ) == MT.unitless +@test MT.get_unit(0.5) == MT.unitless +@test MT.get_unit(MT.SciMLBase.NullParameters()) == MT.unitless + +eqs = [D(E) ~ P - E / τ + 0 ~ P] +@test MT.validate(eqs) +@named sys = ODESystem(eqs, t) + +@test !MT.validate(D(D(E)) ~ P) +@test !MT.validate(0 ~ P + E * τ) + +# Disabling unit validation/checks selectively +@test_throws MT.ArgumentError ODESystem(eqs, t, [E, P, t], [τ], name = :sys) +ODESystem(eqs, t, [E, P, t], [τ], name = :sys, checks = MT.CheckUnits) +eqs = [D(E) ~ P - E / τ + 0 ~ P + E * τ] +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, checks = MT.CheckAll) +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, checks = true) +ODESystem(eqs, t, name = :sys, checks = MT.CheckNone) +ODESystem(eqs, t, name = :sys, checks = false) +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, + checks = MT.CheckComponents | MT.CheckUnits) +@named sys = ODESystem(eqs, t, checks = MT.CheckComponents) +@test_throws MT.ValidationError ODESystem(eqs, t, [E, P, t], [τ], name = :sys, + checks = MT.CheckUnits) + +# connection validation +@connector function Pin(; name) + sts = @variables(v(t)=1.0, [unit = u"V"], + i(t)=1.0, [unit = u"A", connect = Flow]) + ODESystem(Equation[], t, sts, []; name = name) +end +@connector function OtherPin(; name) + sts = @variables(v(t)=1.0, [unit = u"mV"], + i(t)=1.0, [unit = u"mA", connect = Flow]) + ODESystem(Equation[], t, sts, []; name = name) +end +@connector function LongPin(; name) + sts = @variables(v(t)=1.0, [unit = u"V"], + i(t)=1.0, [unit = u"A", connect = Flow], + x(t)=1.0) + ODESystem(Equation[], t, sts, []; name = name) +end +@named p1 = Pin() +@named p2 = Pin() +@named lp = LongPin() +good_eqs = [connect(p1, p2)] +@test MT.validate(good_eqs) +@named sys = ODESystem(good_eqs, t, [], []) +@named op = OtherPin() +bad_eqs = [connect(p1, op)] +@test !MT.validate(bad_eqs) +@test_throws MT.ValidationError @named sys = ODESystem(bad_eqs, t, [], []) +@named op2 = OtherPin() +good_eqs = [connect(op, op2)] +@test MT.validate(good_eqs) +@named sys = ODESystem(good_eqs, t, [], []) + +# Array variables +@variables x(t)[1:3] [unit = u"m"] +@parameters v[1:3]=[1, 2, 3] [unit = u"m/s"] +eqs = D.(x) .~ v +ODESystem(eqs, t, name = :sys) + +# Nonlinear system +@parameters a [unit = u"kg"^-1] +@variables x [unit = u"kg"] +eqs = [ + 0 ~ a * x +] +@named nls = NonlinearSystem(eqs, [x], [a]) + +# SDE test w/ noise vector +@parameters τ [unit = u"s"] Q [unit = u"W"] +@variables E(t) [unit = u"J"] P(t) [unit = u"W"] +eqs = [D(E) ~ P - E / τ + P ~ Q] + +noiseeqs = [0.1us"W", + 0.1us"W"] +@named sys = SDESystem(eqs, noiseeqs, t, [P, E], [τ, Q]) + +noiseeqs = [0.1u"W", + 0.1u"W"] +@test_throws MT.ValidationError @named sys = SDESystem(eqs, noiseeqs, t, [P, E], [τ, Q]) + +# With noise matrix +noiseeqs = [0.1us"W" 0.1us"W" + 0.1us"W" 0.1us"W"] +@named sys = SDESystem(eqs, noiseeqs, t, [P, E], [τ, Q]) + +# Invalid noise matrix +noiseeqs = [0.1us"W" 0.1us"W" + 0.1us"W" 0.1us"s"] +@test !MT.validate(eqs, noiseeqs) + +# Non-trivial simplifications +@variables V(t) [unit = u"m"^3] L(t) [unit = u"m"] +@parameters v [unit = u"m/s"] r [unit = u"m"^3 / u"s"] +eqs = [D(L) ~ v, + V ~ L^3] +@named sys = ODESystem(eqs, t) +sys_simple = structural_simplify(sys) + +eqs = [D(V) ~ r, + V ~ L^3] +@named sys = ODESystem(eqs, t) +sys_simple = structural_simplify(sys) + +@variables V [unit = u"m"^3] L [unit = u"m"] +@parameters v [unit = u"m/s"] r [unit = u"m"^3 / u"s"] +eqs = [V ~ r * t, + V ~ L^3] +@named sys = NonlinearSystem(eqs, [V, L], [t, r]) +sys_simple = structural_simplify(sys) + +eqs = [L ~ v * t, + V ~ L^3] +@named sys = NonlinearSystem(eqs, [V, L], [t, r]) +sys_simple = structural_simplify(sys) + +#Jump System +@parameters β [unit = u"(mol^2*s)^-1"] γ [unit = u"(mol*s)^-1"] jumpmol [ + unit = u"mol" +] +@variables S(t) [unit = u"mol"] I(t) [unit = u"mol"] R(t) [unit = u"mol"] +rate₁ = β * S * I +affect₁ = [S ~ S - 1 * jumpmol, I ~ I + 1 * jumpmol] +rate₂ = γ * I +affect₂ = [I ~ I - 1 * jumpmol, R ~ R + 1 * jumpmol] +j₁ = ConstantRateJump(rate₁, affect₁) +j₂ = VariableRateJump(rate₂, affect₂) +js = JumpSystem([j₁, j₂], t, [S, I, R], [β, γ], name = :sys) + +affect_wrong = [S ~ S - jumpmol, I ~ I + 1] +j_wrong = ConstantRateJump(rate₁, affect_wrong) +@test_throws MT.ValidationError JumpSystem([j_wrong, j₂], t, [S, I, R], [β, γ], name = :sys) + +rate_wrong = γ^2 * I +j_wrong = ConstantRateJump(rate_wrong, affect₂) +@test_throws MT.ValidationError JumpSystem([j₁, j_wrong], t, [S, I, R], [β, γ], name = :sys) + +# mass action jump tests for SIR model +maj1 = MassActionJump(2 * β / 2, [S => 1, I => 1], [S => -1, I => 1]) +maj2 = MassActionJump(γ, [I => 1], [I => -1, R => 1]) +@named js3 = JumpSystem([maj1, maj2], t, [S, I, R], [β, γ]) + +#Test unusual jump system +@parameters β γ +@variables S(t) I(t) R(t) + +maj1 = MassActionJump(2.0, [0 => 1], [S => 1]) +maj2 = MassActionJump(γ, [S => 1], [S => -1]) +@named js4 = JumpSystem([maj1, maj2], ModelingToolkit.t_nounits, [S], [β, γ]) + +@mtkmodel ParamTest begin + @parameters begin + a, [unit = u"m"] + end + @variables begin + b(t), [unit = u"kg"] + end +end + +@named sys = ParamTest() + +@named sys = ParamTest(a = 3.0u"cm") +@test ModelingToolkit.getdefault(sys.a) ≈ 0.03 + +@test_throws ErrorException ParamTest(; name = :t, a = 1.0) +@test_throws ErrorException ParamTest(; name = :t, a = 1.0u"s") + +@mtkmodel ArrayParamTest begin + @parameters begin + a[1:2], [unit = u"m"] + end +end + +@named sys = ArrayParamTest() + +@named sys = ArrayParamTest(a = [1.0, 3.0]u"cm") +@test ModelingToolkit.getdefault(sys.a) ≈ [0.01, 0.03] + +@testset "Initialization checks" begin + @mtkmodel PendulumUnits begin + @parameters begin + g, [unit = u"m/s^2"] + L, [unit = u"m"] + end + @variables begin + x(t), [unit = u"m"] + y(t), [state_priority = 10, unit = u"m"] + λ(t), [unit = u"s^-2"] + end + @equations begin + D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ L^2 + end + end + @mtkbuild pend = PendulumUnits() + u0 = [pend.x => 1.0, pend.y => 0.0] + p = [pend.g => 1.0, pend.L => 1.0] + guess = [pend.λ => 0.0] + @test prob = ODEProblem( + pend, u0, (0.0, 1.0), p; guesses = guess, check_units = false) isa Any +end + +@parameters p [unit = u"L/s"] d [unit = u"s^(-1)"] +@parameters tt [unit = u"s"] +@variables X(tt) [unit = u"L"] +DD = Differential(tt) +eqs = [DD(X) ~ p - d * X + d * X] +@test ModelingToolkit.validate(eqs) + +@constants begin + to_m = 1, [unit = u"m"] +end +@variables begin + L(t), [unit = u"m"] + L_out(t), [unit = u"1"] +end +@test to_m in ModelingToolkit.vars(ModelingToolkit.fold_constants(Symbolics.unwrap(L_out * + -to_m))) + +# test units for registered functions +let + mm(X, v, K) = v * X / (X + K) + mm2(X, v, K) = v * X / (X + K) + Symbolics.@register_symbolic mm2(X, v, K) + @parameters t [unit = u"s"] K [unit = u"mol/m^3"] v [unit = u"(m^6)/(s*mol^2)"] + @variables X(t) [unit = u"mol/m^3"] + mmunits = MT.get_unit(mm(X, v, K)) + mm2units = MT.get_unit(mm2(X, v, K)) + @test mmunits == MT.oneunit(mmunits) + @test mm2units == MT.oneunit(mm2units) + @test mmunits == mm2units +end + +# test for array variable units https://github.com/SciML/ModelingToolkit.jl/issues/3009 +let + @variables x_vec(t)[1:3] [unit = u"1"] x_mat(t)[1:3, 1:3] [unit = u"1"] + @test MT.get_unit(x_vec) == u"1" + @test MT.get_unit(x_mat) == u"1" +end + +module UnitTD +using Test +using ModelingToolkit +using ModelingToolkit: t, D +using DynamicQuantities + +@mtkmodel UnitsExample begin + @parameters begin + g, [unit = u"m/s^2"] + L = 1.0, [unit = u"m"] + end + @variables begin + x(t), [unit = u"m"] + y(t), [state_priority = 10, unit = u"m"] + λ(t), [unit = u"s^-2"] + end + @equations begin + D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ L^2 + end +end + +@mtkbuild pend = UnitsExample() +@test ModelingToolkit.get_unit.(filter(x -> occursin("ˍt", string(x)), unknowns(pend))) == + [u"m/s", u"m/s"] +end diff --git a/test/equation_type_accessors.jl b/test/equation_type_accessors.jl new file mode 100644 index 0000000000..f118784f44 --- /dev/null +++ b/test/equation_type_accessors.jl @@ -0,0 +1,182 @@ +# Fetch packages. +using ModelingToolkit +import ModelingToolkit: get_systems, namespace_equations +import ModelingToolkit: is_alg_equation, is_diff_equation +import ModelingToolkit: t_nounits as t, D_nounits as D, wrap, get_eqs + +# Creates equations. +@variables X(t) Y(t) Z(t) +@parameters a b c d +eq1 = X^Z - Z^(X + 1) ~ log(X - a + b) * Y +eq2 = X ~ Y^(X + 1) +eq3 = a + b + c + d ~ X * (Y + d * (Y + Z)) +eq4 = X ~ sqrt(a + Z) + t +eq5 = D(D(X)) ~ a^(2Y) + 3Z * t - 6 +eq6 = X * (Z - Z * (b + X)) ~ c^(X + D(Y)) +eq7 = sqrt(X + c) ~ 2 * (Y + log(a + D(Z))) +eq8 = -0.1 ~ D(Z) + X + +@test is_alg_equation(eq1) +@test is_alg_equation(eq2) +@test is_alg_equation(eq3) +@test is_alg_equation(eq4) +@test !is_alg_equation(eq5) +@test !is_alg_equation(eq6) +@test !is_alg_equation(eq7) +@test !is_alg_equation(eq8) + +@test !is_diff_equation(eq1) +@test !is_diff_equation(eq2) +@test !is_diff_equation(eq3) +@test !is_diff_equation(eq4) +@test is_diff_equation(eq5) +@test is_diff_equation(eq6) +@test is_diff_equation(eq7) +@test is_diff_equation(eq8) + +# Creates systems. +eqs1 = [X * Y + a ~ Z^3 - X * log(b + Y) + X ~ Z * Y * X + a + b + c * sin(X) + sin(Y) ~ d * (a + X * (b + Y * (c + Z)))] +eqs2 = [X + Y + c ~ b * X^(X + Z + a) + D(X) ~ a * Y + b * X + c * Z + D(Z) + Z * Y ~ X - log(Z)] +eqs3 = [D(X) ~ sqrt(X + b) + sqrt(Z + c) + 2Z * (Z + Y) ~ D(Y) * log(a) + D(Z) + c * X ~ b / (X + Y^d) + D(Z)] +@named osys1 = ODESystem(eqs1, t) +@named osys2 = ODESystem(eqs2, t) +@named osys3 = ODESystem(eqs3, t) + +# Test `has...` for non-composed systems. +@test has_alg_equations(osys1) +@test has_alg_equations(osys2) +@test !has_alg_equations(osys3) +@test has_alg_eqs(osys1) +@test has_alg_eqs(osys2) +@test !has_alg_eqs(osys3) +@test !has_diff_equations(osys1) +@test has_diff_equations(osys2) +@test has_diff_equations(osys3) +@test !has_diff_eqs(osys1) +@test has_diff_eqs(osys2) +@test has_diff_eqs(osys3) + +# Test getters for non-composed systems. +isequal(alg_equations(osys1), eqs1) +isequal(alg_equations(osys2), eqs2[1:1]) +isequal(alg_equations(osys3), []) +isequal(get_alg_eqs(osys1), eqs1) +isequal(get_alg_eqs(osys2), eqs2[1:1]) +isequal(get_alg_eqs(osys3), []) +isequal(diff_equations(osys1), []) +isequal(diff_equations(osys2), eqs2[2:3]) +isequal(diff_equations(osys3), eqs3) +isequal(get_diff_eqs(osys1), []) +isequal(get_diff_eqs(osys2), eqs2[2:3]) +isequal(get_diff_eqs(osys3), eqs3) + +# Creates composed systems. +osys1_1 = compose(osys1, [osys1]) +osys1_12 = compose(osys1, [osys1, osys2]) +osys1_12_1 = compose(osys1, [osys1, compose(osys2, [osys1])]) +osys3_2 = compose(osys3, [osys2]) +osys3_33 = compose(osys3, [osys3, osys3]) + +# Test `has...` for composed systems. +@test has_alg_equations(osys1_1) +@test !has_diff_equations(osys1_1) +@test has_alg_eqs(osys1_1) +@test !has_diff_eqs(osys1_1) +@test has_alg_equations(get_systems(osys1_1)[1]) +@test !has_diff_equations(get_systems(osys1_1)[1]) +@test has_alg_eqs(get_systems(osys1_1)[1]) +@test !has_diff_eqs(get_systems(osys1_1)[1]) + +@test has_alg_equations(osys1_12) +@test has_diff_equations(osys1_12) +@test has_alg_eqs(osys1_12) +@test !has_diff_eqs(osys1_12) +@test has_alg_equations(get_systems(osys1_12)[1]) +@test !has_diff_equations(get_systems(osys1_12)[1]) +@test has_alg_eqs(get_systems(osys1_12)[1]) +@test !has_diff_eqs(get_systems(osys1_12)[1]) +@test has_alg_equations(get_systems(osys1_12)[2]) +@test has_diff_equations(get_systems(osys1_12)[2]) +@test has_alg_eqs(get_systems(osys1_12)[2]) +@test has_diff_eqs(get_systems(osys1_12)[2]) + +@test has_alg_equations(osys1_12_1) +@test has_diff_equations(osys1_12_1) +@test has_alg_eqs(osys1_12_1) +@test !has_diff_eqs(osys1_12_1) +@test has_alg_equations(get_systems(osys1_12_1)[1]) +@test !has_diff_equations(get_systems(osys1_12_1)[1]) +@test has_alg_eqs(get_systems(osys1_12_1)[1]) +@test !has_diff_eqs(get_systems(osys1_12_1)[1]) +@test has_alg_equations(get_systems(osys1_12_1)[2]) +@test has_diff_equations(get_systems(osys1_12_1)[2]) +@test has_alg_eqs(get_systems(osys1_12_1)[2]) +@test has_diff_eqs(get_systems(osys1_12_1)[2]) +@test has_alg_equations(get_systems(get_systems(osys1_12_1)[2])[1]) +@test !has_diff_equations(get_systems(get_systems(osys1_12_1)[2])[1]) +@test has_alg_eqs(get_systems(get_systems(osys1_12_1)[2])[1]) +@test !has_diff_eqs(get_systems(get_systems(osys1_12_1)[2])[1]) + +@test has_alg_equations(osys3_2) +@test has_diff_equations(osys3_2) +@test !has_alg_eqs(osys3_2) +@test has_diff_eqs(osys3_2) +@test has_alg_equations(get_systems(osys3_2)[1]) +@test has_diff_equations(get_systems(osys3_2)[1]) +@test has_alg_eqs(get_systems(osys3_2)[1]) +@test has_diff_eqs(get_systems(osys3_2)[1]) + +@test !has_alg_equations(osys3_33) +@test has_diff_equations(osys3_33) +@test !has_alg_eqs(osys3_33) +@test has_diff_eqs(osys3_33) +@test !has_alg_equations(get_systems(osys3_33)[1]) +@test has_diff_equations(get_systems(osys3_33)[1]) +@test !has_alg_eqs(get_systems(osys3_33)[1]) +@test has_diff_eqs(get_systems(osys3_33)[1]) +@test !has_alg_equations(get_systems(osys3_33)[2]) +@test has_diff_equations(get_systems(osys3_33)[2]) +@test !has_alg_eqs(get_systems(osys3_33)[2]) +@test has_diff_eqs(get_systems(osys3_33)[2]) + +# Test getters for composed systems. +ns_eqs1 = namespace_equations(osys1) +ns_eqs2 = namespace_equations(osys2) +ns_eqs3 = namespace_equations(osys3) + +isequal(alg_equations(osys1_1), vcat(eqs1, ns_eqs1)) +isequal(diff_equations(osys1_1), []) +isequal(get_alg_eqs(osys1_1), eqs1) +isequal(get_diff_eqs(osys1_1), []) +isequal(alg_equations(get_systems(osys1_1)[1]), eqs1) +isequal(diff_equations(get_systems(osys1_1)[1]), []) +isequal(get_alg_eqs(get_systems(osys1_1)[1]), eqs1) +isequal(get_diff_eqs(get_systems(osys1_1)[1]), []) + +isequal(alg_equations(osys1_12), vcat(eqs1, ns_eqs1, filter(is_alg_equation, ns_eqs2))) +isequal(diff_equations(osys1_12), filter(is_diff_equation, ns_eqs2)) +isequal(get_alg_eqs(osys1_12), eqs1) +isequal(get_diff_eqs(osys1_12), []) +isequal(alg_equations(get_systems(osys1_12)[1]), eqs1) +isequal(diff_equations(get_systems(osys1_12)[1]), []) +isequal(get_alg_eqs(get_systems(osys1_12)[1]), eqs1) +isequal(get_diff_eqs(get_systems(osys1_12)[1]), []) +isequal(alg_equations(get_systems(osys1_12)[2]), eqs2[1:1]) +isequal(diff_equations(get_systems(osys1_12)[2]), eqs2[2:3]) +isequal(get_alg_eqs(get_systems(osys1_12)[2]), eqs2[1:1]) +isequal(get_diff_eqs(get_systems(osys1_12)[2]), eqs2[2:3]) + +isequal(alg_equations(osys3_2), vcat(filter(is_alg_equation, ns_eqs2))) +isequal(diff_equations(osys3_2), vcat(eqs3, filter(is_diff_equation, ns_eqs2))) +isequal(get_alg_eqs(osys3_2), []) +isequal(get_diff_eqs(osys3_2), eqs3) +isequal(alg_equations(get_systems(osys3_2)[1]), eqs2[1:1]) +isequal(diff_equations(get_systems(osys3_2)[1]), eqs2[2:3]) +isequal(get_alg_eqs(get_systems(osys3_2)[1]), eqs2[1:1]) +isequal(get_diff_eqs(get_systems(osys3_2)[1]), eqs2[2:3]) diff --git a/test/error_handling.jl b/test/error_handling.jl new file mode 100644 index 0000000000..59aa6b79a1 --- /dev/null +++ b/test/error_handling.jl @@ -0,0 +1,53 @@ +using Test +using ModelingToolkit +import ModelingToolkit: ExtraVariablesSystemException, ExtraEquationsSystemException + +include("../examples/electrical_components.jl") + +function UnderdefinedConstantVoltage(; name, V = 1.0) + val = V + @named p = Pin() + @named n = Pin() + @parameters V + eqs = [ + V ~ p.v - n.v # Remove equation + # 0 ~ p.i + n.i + ] + ODESystem(eqs, t, [], [V], systems = [p, n], defaults = Dict(V => val), name = name) +end + +function OverdefinedConstantVoltage(; name, V = 1.0, I = 1.0) + val = V + val2 = I + @named p = Pin() + @named n = Pin() + @parameters V I + eqs = [V ~ p.v - n.v + # Overdefine p.i and n.i + n.i ~ I + p.i ~ I] + ODESystem(eqs, t, [], [V], systems = [p, n], defaults = Dict(V => val, I => val2), + name = name) +end + +R = 1.0 +C = 1.0 +V = 1.0 +@named resistor = Resistor(R = R) +@named capacitor = Capacitor(C = C) +@named source = UnderdefinedConstantVoltage(V = V) + +rc_eqs = [connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n)] + +@named rc_model = ODESystem(rc_eqs, t, systems = [resistor, capacitor, source]) +@test_throws ModelingToolkit.ExtraVariablesSystemException structural_simplify(rc_model) + +@named source2 = OverdefinedConstantVoltage(V = V, I = V / R) +rc_eqs2 = [connect(source2.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source2.n)] + +@named rc_model2 = ODESystem(rc_eqs2, t, systems = [resistor, capacitor, source2]) +@test_throws ModelingToolkit.ExtraEquationsSystemException structural_simplify(rc_model2) diff --git a/test/extensions/Project.toml b/test/extensions/Project.toml new file mode 100644 index 0000000000..5b0de73cdf --- /dev/null +++ b/test/extensions/Project.toml @@ -0,0 +1,19 @@ +[deps] +BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" +ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +ChainRulesTestUtils = "cdddcdb0-9152-4a09-a978-84456f9df70a" +ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +HomotopyContinuation = "f213a82b-91d6-5c5d-acf7-10f1c761b327" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" +LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +Nemo = "2edaba10-b0f1-5616-af89-8c11ac63239a" +NonlinearSolveHomotopyContinuation = "2ac3b008-d579-4536-8c91-a1a5998c2f8b" +OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" +SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" +SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" +Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" +Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" diff --git a/test/extensions/ad.jl b/test/extensions/ad.jl new file mode 100644 index 0000000000..adaf6117c6 --- /dev/null +++ b/test/extensions/ad.jl @@ -0,0 +1,136 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +using Zygote +using SymbolicIndexingInterface +using SciMLStructures +using OrdinaryDiffEq +using NonlinearSolve +using SciMLSensitivity +using ForwardDiff +using ChainRulesCore +using ChainRulesCore: NoTangent +using ChainRulesTestUtils: test_rrule, rand_tangent + +@variables x(t)[1:3] y(t) +@parameters p[1:3, 1:3] q +eqs = [D(x) ~ p * x + D(y) ~ sum(p) + q * y] +u0 = [x => zeros(3), + y => 1.0] +ps = [p => zeros(3, 3), + q => 1.0] +tspan = (0.0, 10.0) +@mtkbuild sys = ODESystem(eqs, t) +prob = ODEProblem(sys, u0, tspan, ps) +sol = solve(prob, Tsit5()) + +mtkparams = parameter_values(prob) +new_p = rand(14) +gs = gradient(new_p) do new_p + new_params = SciMLStructures.replace(SciMLStructures.Tunable(), mtkparams, new_p) + new_prob = remake(prob, p = new_params) + new_sol = solve(new_prob, Tsit5()) + sum(new_sol) +end + +@testset "Issue#2997" begin + pars = @parameters y0 mh Tγ0 Th0 h ργ0 + vars = @variables x(t) + @named sys = ODESystem([D(x) ~ y0], + t, + vars, + pars; + defaults = [ + y0 => mh * 3.1 / (2.3 * Th0), + mh => 123.4, + Th0 => (4 / 11)^(1 / 3) * Tγ0, + Tγ0 => (15 / π^2 * ργ0 * (2 * h)^2 / 7)^(1 / 4) / 5 + ]) + sys = structural_simplify(sys) + + function x_at_0(θ) + prob = ODEProblem(sys, [sys.x => 1.0], (0.0, 1.0), [sys.ργ0 => θ[1], sys.h => θ[2]]) + return prob.u0[1] + end + + @test ForwardDiff.gradient(x_at_0, [0.3, 0.7]) == zeros(2) +end + +@parameters a b[1:3] c(t) d::Integer e[1:3] f[1:3, 1:3]::Int g::Vector{AbstractFloat} h::String +@named sys = ODESystem( + Equation[], t, [], [a, b, c, d, e, f, g, h], + continuous_events = [[a ~ 0] => [c ~ 0]]) +sys = complete(sys) + +ivs = Dict(c => 3a, b => ones(3), a => 1.0, d => 4, e => [5.0, 6.0, 7.0], + f => ones(Int, 3, 3), g => [0.1, 0.2, 0.3], h => "foo") + +ps = MTKParameters(sys, ivs) + +varmap = Dict(a => 1.0f0, b => 3ones(Float32, 3), c => 2.0, + e => Float32[0.4, 0.5, 0.6], g => ones(Float32, 4)) +get_values = getp(sys, [a, b..., c, e...]) +get_g = getp(sys, g) +for (_idxs, vals) in [ + # all portions + (collect(keys(varmap)), collect(values(varmap))), + # non-arrays + (keys(varmap), values(varmap)), + # tunable only + ([a], [varmap[a]]), + ([a, b], (varmap[a], varmap[b])), + ([a, b[2]], (varmap[a], varmap[b][2])) +] + for idxs in [_idxs, map(i -> parameter_index(sys, i), collect(_idxs))] + loss = function (p) + newps = remake_buffer(sys, ps, idxs, p) + return sum(get_values(newps)) + sum(get_g(newps)) + end + + grad = Zygote.gradient(loss, vals)[1] + for (val, g) in zip(vals, grad) + @test eltype(val) == eltype(g) + if val isa Number + @test isone(g) + else + @test all(isone, g) + end + end + end +end + +idxs = (parameter_index(sys, a), parameter_index(sys, b)) +vals = (1.0f0, 3ones(Float32, 3)) +tangent = rand_tangent(ps) +fwd, back = ChainRulesCore.rrule(remake_buffer, sys, ps, idxs, vals) +@inferred back(tangent) + +@testset "Dual type promotion in remake with dummy derivatives" begin # https://github.com/SciML/ModelingToolkit.jl/issues/3336 + # Throw ball straight up into the air + @variables y(t) + eqs = [D(D(y)) ~ -9.81] + initialization_eqs = [y^2 ~ 0] # initialize y = 0 in a way that builds an initialization problem + @named sys = ODESystem(eqs, t; initialization_eqs) + sys = structural_simplify(sys) + + # Find initial throw velocity that reaches exactly 10 m after 1 s + dprob0 = ODEProblem(sys, [D(y) => NaN], (0.0, 1.0), []; guesses = [y => 0.0]) + function f(ics, _) + dprob = remake(dprob0, u0 = Dict(D(y) => ics[1])) + dsol = solve(dprob, Tsit5()) + return [dsol[y][end] - 10.0] + end + nprob = NonlinearProblem(f, [1.0]) + nsol = solve(nprob, NewtonRaphson()) + @test nsol[1] ≈ 10.0 / 1.0 + 9.81 * 1.0 / 2 # anal free fall solution is y = v0*t - g*t^2/2 -> v0 = y/t + g*t/2 +end + +@testset "`sys.var` is non-differentiable" begin + @variables x(t) + @mtkbuild sys = ODESystem(D(x) ~ x, t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + + grad = Zygote.gradient(prob) do prob + prob[sys.x] + end +end diff --git a/test/extensions/bifurcationkit.jl b/test/extensions/bifurcationkit.jl new file mode 100644 index 0000000000..629edf46a6 --- /dev/null +++ b/test/extensions/bifurcationkit.jl @@ -0,0 +1,173 @@ +using BifurcationKit, ModelingToolkit, Test +using ModelingToolkit: t_nounits as t, D_nounits as D +# Simple pitchfork diagram, compares solution to native BifurcationKit, checks they are identical. +# Checks using `jac=false` option. +let + # Creates model. + @variables x(t) y(t) + @parameters μ α + eqs = [0 ~ μ * x - x^3 + α * y, + 0 ~ -y] + @named nsys = NonlinearSystem(eqs, [x, y], [μ, α]) + nsys = complete(nsys) + # Creates BifurcationProblem + bif_par = μ + p_start = [μ => -1.0, α => 1.0] + u0_guess = [x => 1.0, y => 1.0] + plot_var = x + bprob = BifurcationProblem(nsys, + u0_guess, + p_start, + bif_par; + plot_var = plot_var, + jac = false) + + # Conputes bifurcation diagram. + p_span = (-4.0, 6.0) + opts_br = ContinuationPar(max_steps = 500, p_min = p_span[1], p_max = p_span[2]) + bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) + + # Computes bifurcation diagram using BifurcationKit directly (without going through MTK). + function f_BK(u, p) + x, y = u + μ, α = p + return [μ * x - x^3 + α * y, -y] + end + bprob_BK = BifurcationProblem(f_BK, + [1.0, 1.0], + [-1.0, 1.0], + (BifurcationKit.@optic _[1]); + record_from_solution = (x, p; k...) -> x[1]) + bif_dia_BK = bifurcationdiagram(bprob_BK, + PALC(), + 2, + (args...) -> opts_br; + bothside = true) + + # Compares results. + @test getfield.(bif_dia.γ.branch, :x) ≈ getfield.(bif_dia_BK.γ.branch, :x) + @test getfield.(bif_dia.γ.branch, :param) ≈ getfield.(bif_dia_BK.γ.branch, :param) + @test bif_dia.γ.specialpoint[1].x == bif_dia_BK.γ.specialpoint[1].x + @test bif_dia.γ.specialpoint[1].param == bif_dia_BK.γ.specialpoint[1].param + @test bif_dia.γ.specialpoint[1].type == bif_dia_BK.γ.specialpoint[1].type +end + +# Lotka–Volterra model, checks exact position of bifurcation variable and bifurcation points. +# Checks using ODESystem input. +let + # Creates a Lotka–Volterra model. + @parameters α a b + @variables x(t) y(t) z(t) + eqs = [D(x) ~ -x + a * y + x^2 * y, + D(y) ~ b - a * y - x^2 * y] + @named sys = ODESystem(eqs, t) + sys = complete(sys) + # Creates BifurcationProblem + bprob = BifurcationProblem(sys, + [x => 1.5, y => 1.0], + [a => 0.1, b => 0.5], + b; + plot_var = x) + + # Computes bifurcation diagram. + p_span = (0.0, 2.0) + opt_newton = NewtonPar(tol = 1e-9, max_iterations = 2000) + opts_br = ContinuationPar(dsmax = 0.05, + max_steps = 500, + newton_options = opt_newton, + p_min = p_span[1], + p_max = p_span[2], + n_inversion = 4) + bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) + + # Tests that the diagram has the correct values (x = b) + all([b.x ≈ b.param for b in bif_dia.γ.branch]) + + # Tests that we get two Hopf bifurcations at the correct positions. + hopf_points = sort( + getfield.(filter(sp -> sp.type == :hopf, bif_dia.γ.specialpoint), + :x); + by = x -> x[1]) + @test length(hopf_points) == 2 + @test hopf_points[1] ≈ [0.41998733080424205, 1.5195495712453098] + @test hopf_points[2] ≈ [0.7899715592573977, 1.0910379583813192] +end + +# Simple fold bifurcation model, checks exact position of bifurcation variable and bifurcation points. +# Checks that default parameter values are accounted for. +# Checks that observables (that depend on other observables, as in this case) are accounted for. +let + # Creates model, and uses `structural_simplify` to generate observables. + @parameters μ p=2 + @variables x(t) y(t) z(t) + eqs = [0 ~ μ - x^3 + 2x^2, + 0 ~ p * μ - y, + 0 ~ y - z] + @named nsys = NonlinearSystem(eqs, [x, y, z], [μ, p]) + nsys = structural_simplify(nsys) + + # Creates BifurcationProblem. + bif_par = μ + p_start = [μ => 1.0] + u0_guess = [x => 1.0, y => 0.1, z => 0.1] + plot_var = x + bprob = BifurcationProblem(nsys, u0_guess, p_start, bif_par; plot_var = plot_var) + + # Computes bifurcation diagram. + p_span = (-4.3, 12.0) + opt_newton = NewtonPar(tol = 1e-9, max_iterations = 20) + opts_br = ContinuationPar(dsmax = 0.05, + max_steps = 500, + newton_options = opt_newton, + p_min = p_span[1], + p_max = p_span[2], + n_inversion = 4) + bif_dia = bifurcationdiagram(bprob, PALC(), 2, (args...) -> opts_br; bothside = true) + + # Tests that the diagram has the correct values (x = b) + all([b.x ≈ 2 * b.param for b in bif_dia.γ.branch]) + + # Tests that we get two fold bifurcations at the correct positions. + fold_points = sort(getfield.(filter(sp -> sp.type == :bp, bif_dia.γ.specialpoint), + :param)) + @test length(fold_points) == 2 + @test fold_points ≈ [-1.1851851706940317, -5.6734983580551894e-6] # test that they occur at the correct parameter values). +end + +let + @mtkmodel FOL begin + @parameters begin + τ # parameters + end + @variables begin + x(t) # dependent variables + RHS(t) + end + @equations begin + RHS ~ τ + x^2 - 0.1 + D(x) ~ RHS + end + end + + @mtkbuild fol = FOL() + + par = [fol.τ => 0.0] + u0 = [fol.x => -1.0] + #prob = ODEProblem(fol, u0, (0.0, 1.), par) + + bif_par = fol.τ + bp = BifurcationProblem(fol, u0, par, bif_par) + opts_br = ContinuationPar(p_min = -1.0, + p_max = 1.0) + bf = bifurcationdiagram(bp, PALC(), 2, opts_br) + + @test bf.γ.specialpoint[1].param≈0.1 atol=1e-4 rtol=1e-4 + + # Test with plot variable as observable + pvar = ModelingToolkit.get_var_to_name(fol)[:RHS] + bp = BifurcationProblem(fol, u0, par, bif_par; plot_var = pvar) + opts_br = ContinuationPar(p_min = -1.0, + p_max = 1.0) + bf = bifurcationdiagram(bp, PALC(), 2, opts_br) + @test bf.γ.specialpoint[1].param≈0.1 atol=1e-4 rtol=1e-4 +end diff --git a/test/extensions/homotopy_continuation.jl b/test/extensions/homotopy_continuation.jl new file mode 100644 index 0000000000..65f7d765a4 --- /dev/null +++ b/test/extensions/homotopy_continuation.jl @@ -0,0 +1,252 @@ +using ModelingToolkit, NonlinearSolve, NonlinearSolveHomotopyContinuation, + SymbolicIndexingInterface +using SymbolicUtils +import ModelingToolkit as MTK +using LinearAlgebra +using Test + +allrootsalg = HomotopyContinuationJL{true}(; threading = false) +singlerootalg = HomotopyContinuationJL{false}(; threading = false) + +function test_single_root(sol; atol = 1e-10) + @test SciMLBase.successful_retcode(sol) + @test norm(sol.resid)≈0.0 atol=atol +end + +function test_all_roots(sol; atol = 1e-10) + @test sol.converged + for nlsol in sol.u + @test SciMLBase.successful_retcode(nlsol) + @test norm(nlsol.resid)≈0.0 atol=1e-10 + end +end + +function solve_allroots_closest(prob) + sol = solve(prob, allrootsalg) + return argmin(sol.u) do nlsol + return norm(nlsol.u - prob.u0) + end +end + +@testset "No parameters" begin + @variables x y z + eqs = [0 ~ x^2 + y^2 + 2x * y + 0 ~ x^2 + 4x + 4 + 0 ~ y * z + 4x^2] + @mtkbuild sys = NonlinearSystem(eqs) + u0 = [x => 1.0, y => 1.0, z => 1.0] + prob = HomotopyContinuationProblem(sys, u0) + @test prob isa NonlinearProblem + @test prob[x] == prob[y] == prob[z] == 1.0 + @test prob[x + y] == 2.0 + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +struct Wrapper + x::Matrix{Float64} +end + +@testset "Parameters" begin + wrapper(w::Wrapper) = det(w.x) + @register_symbolic wrapper(w::Wrapper) + + @variables x y z + @parameters p q::Int r::Wrapper + + eqs = [0 ~ x^2 + y^2 + p * x * y + 0 ~ x^2 + 4x + q + 0 ~ y * z + 4x^2 + wrapper(r)] + + @mtkbuild sys = NonlinearSystem(eqs) + prob = HomotopyContinuationProblem(sys, [x => 1.0, y => 1.0, z => 1.0], + [p => 2.0, q => 4, r => Wrapper([1.0 1.0; 0.0 0.0])]) + @test prob.ps[p] == 2.0 + @test prob.ps[q] == 4 + @test prob.ps[r].x == [1.0 1.0; 0.0 0.0] + @test prob.ps[p * q] == 8.0 + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +@testset "Array variables" begin + @variables x[1:3] + @parameters p[1:3] + _x = collect(x) + eqs = collect(0 .~ vec(sum(_x * _x'; dims = 2)) + collect(p)) + @mtkbuild sys = NonlinearSystem(eqs) + prob = HomotopyContinuationProblem(sys, [x => ones(3)], [p => 1:3]) + @test prob[x] == ones(3) + @test prob[p + x] == [2, 3, 4] + prob[x] = 2ones(3) + @test prob[x] == 2ones(3) + prob.ps[p] = [2, 3, 4] + @test prob.ps[p] == [2, 3, 4] + sol = @test_nowarn solve(prob, singlerootalg) + @test sol.retcode == ReturnCode.ConvergenceFailure +end + +@testset "Parametric exponents" begin + @variables x = 1.0 + @parameters n::Integer = 4 + @mtkbuild sys = NonlinearSystem([x^n + x^2 - 1 ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +@testset "Polynomial check and warnings" begin + @variables x = 1.0 + @mtkbuild sys = NonlinearSystem([x^1.5 + x^2 - 1 ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "not an integer", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([x^x - x ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "unknowns", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + @mtkbuild sys = NonlinearSystem([((x^2) / sin(x))^2 + x ~ 0]) + @test_throws ["Cannot convert", "both polynomial", "non-polynomial", + "recognized", "sin", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @variables y = 2.0 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 + 2 ~ 0, y ~ sin(x)]) + @test_throws ["Cannot convert", "recognized", "sin", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([x^2 + y^2 - 2 ~ 0, sin(x + y) ~ 0]) + @test_throws ["Cannot convert", "function of multiple unknowns"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([sin(x)^2 + 1 ~ 0, cos(y) - cos(x) - 1 ~ 0]) + @test_throws ["Cannot convert", "multiple non-polynomial terms", "same unknown"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([sin(x^2)^2 + sin(x^2) - 1 ~ 0]) + @test_throws ["import Nemo"] HomotopyContinuationProblem(sys, []) +end + +import Nemo + +@testset "With Nemo" begin + @variables x = 2.0 + @mtkbuild sys = NonlinearSystem([sin(x^2)^2 + sin(x^2) - 1 ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + @test prob[1] ≈ 2.0 + # singlerootalg doesn't converge + sol = solve(prob, allrootsalg).u[1] + _x = sol[1] + @test sin(_x^2)^2 + sin(_x^2) - 1≈0.0 atol=1e-12 +end + +@testset "Function of polynomial" begin + @variables x=0.25 y=0.125 + a = sin(x^2 - 4x + 1) + b = cos(3log(y) + 4) + @mtkbuild sys = NonlinearSystem([(a^2 - 5a * b + 6b^2) / (a - 0.25) ~ 0 + (a^2 - 0.75a + 0.125) ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + @test prob[x] ≈ 0.25 + @test prob[y] ≈ 0.125 + sol = solve(prob, allrootsalg).u[1] + @test SciMLBase.successful_retcode(sol) + @test sol[a]≈0.5 atol=1e-6 + @test isapprox(sol[b], 0.25; atol = 1e-6) || isapprox(sol[b], 0.5 / 3; atol = 1e-6) +end + +@testset "Rational functions" begin + @variables x=2.0 y=2.0 + @parameters n = 5 + @mtkbuild sys = NonlinearSystem([ + 0 ~ (x^2 - n * x + 6) * (x - 1) / (x - 2) / (x - 3) + ]) + prob = HomotopyContinuationProblem(sys, []) + sol = solve_allroots_closest(prob) + @test sol[x] ≈ 1.0 + p = parameter_values(prob) + for invalid in [2.0, 3.0] + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([invalid + err, 2.0], p)) + end + end + + @named sys = NonlinearSystem( + [ + 0 ~ (x - 2) / (x - 4) + ((x - 3) / (y - 7)) / ((x^2 - 4x + y) / (x - 2.5)), + 0 ~ ((y - 3) / (y - 4)) * (n / (y - 5)) + ((x - 1.5) / (x - 5.5))^2 + ], + [x, y], + [n]; defaults = [n => 4]) + sys = complete(sys) + prob = HomotopyContinuationProblem(sys, []) + sol = solve(prob, singlerootalg) + disallowed_x = [4, 5.5] + disallowed_y = [7, 5, 4] + @test all(!isapprox(sol[x]; atol = 1e-8), disallowed_x) + @test all(!isapprox(sol[y]; atol = 1e-8), disallowed_y) + @test abs(sol[x^2 - 4x + y]) >= 1e-8 + + p = parameter_values(prob) + for val in disallowed_x + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([val + err, 2.0], p)) + end + end + for val in disallowed_y + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([2.0, val + err], p)) + end + end + @test prob.f.denominator([2.0, 4.0], p)[1] <= 1e-8 + + @testset "Rational function in observed" begin + @variables x=1 y=1 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 - 2x - 2 ~ 0, y ~ (x - 1) / (x - 2)]) + prob = HomotopyContinuationProblem(sys, []) + @test any(prob.f.denominator([2.0], parameter_values(prob)) .≈ 0.0) + @test SciMLBase.successful_retcode(solve(prob, singlerootalg)) + end + + @testset "Rational function forced to common denominators" begin + @variables x = 1 + @mtkbuild sys = NonlinearSystem([0 ~ 1 / (1 + x) - x]) + prob = HomotopyContinuationProblem(sys, []) + @test any(prob.f.denominator([-1.0], parameter_values(prob)) .≈ 0.0) + sol = solve(prob, singlerootalg) + @test SciMLBase.successful_retcode(sol) + @test 1 / (1 + sol.u[1]) - sol.u[1]≈0.0 atol=1e-10 + end +end + +@testset "Non-polynomial observed not used in equations" begin + @variables x=1 y + @mtkbuild sys = NonlinearSystem([x^2 - 2 ~ 0, y ~ sin(x)]) + prob = HomotopyContinuationProblem(sys, []) + sol = @test_nowarn solve(prob, singlerootalg) + @test sol[x] ≈ √2.0 + @test sol[y] ≈ sin(√2.0) +end + +@testset "`fraction_cancel_fn`" begin + @variables x = 1 + @named sys = NonlinearSystem([0 ~ ((x^2 - 5x + 6) / (x - 2) - 1) * (x^2 - 7x + 12) / + (x - 4)^3]) + sys = complete(sys) + + @testset "`simplify_fractions`" begin + prob = HomotopyContinuationProblem(sys, []) + @test prob.f.denominator([0.0], parameter_values(prob)) ≈ [4.0] + end + @testset "`nothing`" begin + prob = HomotopyContinuationProblem(sys, []; fraction_cancel_fn = nothing) + @test sort(prob.f.denominator([0.0], parameter_values(prob))) ≈ [2.0, 4.0^3] + end +end diff --git a/test/extensions/test_infiniteopt.jl b/test/extensions/test_infiniteopt.jl new file mode 100644 index 0000000000..e45aa0f2fd --- /dev/null +++ b/test/extensions/test_infiniteopt.jl @@ -0,0 +1,102 @@ +using ModelingToolkit, InfiniteOpt, JuMP, Ipopt +using ModelingToolkit: D_nounits as D, t_nounits as t, varmap_to_vars + +@mtkmodel Pendulum begin + @parameters begin + g = 9.8 + L = 0.4 + K = 1.2 + m = 0.3 + end + @variables begin + θ(t) # state + ω(t) # state + τ(t) = 0 # input + y(t) # output + end + @equations begin + D(θ) ~ ω + D(ω) ~ -g / L * sin(θ) - K / m * ω + τ / m / L^2 + y ~ θ * 180 / π + end +end +@named model = Pendulum() +model = complete(model) + +inputs = [model.τ] +(f_oop, f_ip), dvs, psym, io_sys = ModelingToolkit.generate_control_function( + model, inputs, split = false) + +outputs = [model.y] +f_obs = ModelingToolkit.build_explicit_observed_function(io_sys, outputs; inputs = inputs) + +expected_state_order = [model.θ, model.ω] +permutation = [findfirst(isequal(x), expected_state_order) for x in dvs] # This maps our expected state order to the actual state order + +## + +ub = varmap_to_vars([model.θ => 2pi, model.ω => 10], dvs) +lb = varmap_to_vars([model.θ => -2pi, model.ω => -10], dvs) +xf = varmap_to_vars([model.θ => pi, model.ω => 0], dvs) +nx = length(dvs) +nu = length(inputs) +ny = length(outputs) + +## +m = InfiniteModel(optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "acceptable_tol" => 1e-3, "constr_viol_tol" => 1e-5, "max_iter" => 1000, + "tol" => 1e-5, "mu_strategy" => "monotone", "nlp_scaling_method" => "gradient-based", + "alpha_for_y" => "safer-min-dual-infeas", "bound_mult_init_method" => "mu-based", "print_user_options" => "yes")); + +@infinite_parameter(m, τ in [0, 1], num_supports=51, + derivative_method=OrthogonalCollocation(4)) # Time variable +guess_xs = [t -> pi, t -> 0.1][permutation] +guess_us = [t -> 0.1] +InfiniteOpt.@variables(m, + begin + # state variables + (lb[i] <= x[i = 1:nx] <= ub[i], Infinite(τ), start = guess_xs[i]) # state variables + -10 <= u[i = 1:nu] <= 10, Infinite(τ), (start = guess_us[i]) # control variables + 0 <= tf <= 10, (start = 5) # Final time + 0.2 <= L <= 0.6, (start = 0.4) # Length parameter + end) + +# Trace the dynamics +x0, p = ModelingToolkit.get_u0_p(io_sys, [model.θ => 0, model.ω => 0], [model.L => L]) + +xp = f_oop(x, u, p, τ) +cp = f_obs(x, u, p, τ) # Test that it's possible to trace through an observed function + +@objective(m, Min, tf) +@constraint(m, [i = 1:nx], x[i](0)==x0[i]) # Initial condition +@constraint(m, [i = 1:nx], x[i](1)==xf[i]) # Terminal state + +x_scale = varmap_to_vars([model.θ => 1 + model.ω => 1], dvs) + +# Add dynamics constraints +@constraint(m, [i = 1:nx], (∂(x[i], τ) - tf * xp[i]) / x_scale[i]==0) + +optimize!(m) + +# Extract the optimal solution +opt_tf = value(tf) +opt_time = opt_tf * value(τ) +opt_x = [value(x[i]) for i in permutation] +opt_u = [value(u[i]) for i in 1:nu] +opt_L = value(L) + +# Plot the results +# using Plots +# plot(opt_time, opt_x[1], label = "θ", xlabel = "Time [s]", layout=3) +# plot!(opt_time, opt_x[2], label = "ω", sp=2) +# plot!(opt_time, opt_u[1], label = "τ", sp=3) + +using Test +@test opt_x[1][end]≈pi atol=1e-3 +@test opt_x[2][end]≈0 atol=1e-3 + +@test opt_x[1][1]≈0 atol=1e-3 +@test opt_x[2][1]≈0 atol=1e-3 + +@test opt_L≈0.2 atol=1e-3 # Smallest permissible length is optimal diff --git a/test/fmi/Project.toml b/test/fmi/Project.toml new file mode 100644 index 0000000000..59bd3c90da --- /dev/null +++ b/test/fmi/Project.toml @@ -0,0 +1,8 @@ +[deps] +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +FMIZoo = "724179cf-c260-40a9-bd27-cccc6fe2f195" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" + +[compat] +FMI = "0.14" diff --git a/test/fmi/fmi.jl b/test/fmi/fmi.jl new file mode 100644 index 0000000000..98c93398ff --- /dev/null +++ b/test/fmi/fmi.jl @@ -0,0 +1,313 @@ +using ModelingToolkit, FMI, FMIZoo, OrdinaryDiffEq, NonlinearSolve, SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D +import ModelingToolkit as MTK + +const FMU_DIR = joinpath(@__DIR__, "fmus") + +@testset "Standalone pendulum model" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) + truesol = FMI.simulate( + fmu, (0.0, 8.0); saveat = 0.0:0.1:8.0, recordValues = ["mass.s", "mass.v"]) + + function test_no_inputs_outputs(sys) + for var in unknowns(sys) + @test !MTK.isinput(var) + @test !MTK.isoutput(var) + end + end + @testset "v2, ME" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) + @mtkbuild sys = MTK.FMIComponent(Val(2); fmu, type = :ME) + test_no_inputs_outputs(sys) + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.mass__s, sys.mass__v]).u≈collect.(truesol.values.saveval) atol=1e-4 + # repeated solve works + @test_nowarn solve(prob, Tsit5()) + end + @testset "v2, CS" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :CS) + @named inner = MTK.FMIComponent( + Val(2); fmu, communication_step_size = 1e-5, type = :CS) + @variables x(t) = 1.0 + @mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) + test_no_inputs_outputs(sys) + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.inner.mass__s, sys.inner.mass__v]).u≈collect.(truesol.values.saveval) rtol=1e-2 + end + + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :ME) + truesol = FMI.simulate( + fmu, (0.0, 8.0); saveat = 0.0:0.1:8.0, recordValues = ["mass.s", "mass.v"]) + @testset "v3, ME" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :ME) + @mtkbuild sys = MTK.FMIComponent(Val(3); fmu, type = :ME) + test_no_inputs_outputs(sys) + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.mass__s, sys.mass__v]).u≈collect.(truesol.values.saveval) atol=1e-4 + # repeated solve works + @test_nowarn solve(prob, Tsit5()) + end + @testset "v3, CS" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :CS) + @named inner = MTK.FMIComponent( + Val(3); fmu, communication_step_size = 1e-5, type = :CS) + @variables x(t) = 1.0 + @mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) + test_no_inputs_outputs(sys) + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.inner.mass__s, sys.inner.mass__v]).u≈collect.(truesol.values.saveval) rtol=1e-2 + end +end + +@mtkmodel SimpleAdder begin + @variables begin + a(t) + b(t) + c(t) + out(t) + out2(t) + end + @parameters begin + value = 1.0 + end + @equations begin + out ~ a + b + value + D(c) ~ out + out2 ~ 2c + end +end + +@mtkmodel StateSpace begin + @variables begin + x(t) + y(t) + u(t) + end + @parameters begin + A = 1.0 + B = 1.0 + C = 1.0 + _D = 1.0 + end + @equations begin + D(x) ~ A * x + B * u + y ~ C * x + _D * u + end +end + +@testset "IO Model" begin + function build_simple_adder(adder) + @variables a(t) b(t) c(t) [guess = 1.0] + @mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) + # c will be solved for by initialization + # this tests that initialization also works with FMUs + prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) + return sys, prob + end + + @named adder = SimpleAdder() + truesys, trueprob = build_simple_adder(adder) + truesol = solve(trueprob, abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v2, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :ME) + @named adder = MTK.FMIComponent(Val(2); fmu, type = :ME) + @test MTK.isinput(adder.a) + @test MTK.isinput(adder.b) + @test MTK.isoutput(adder.out) + @test MTK.isoutput(adder.out2) + @test !MTK.isinput(adder.c) && !MTK.isoutput(adder.c) + + sys, prob = build_simple_adder(adder) + sol = solve(prob, Rodas5P(autodiff = false), abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; + idxs = [truesys.a, truesys.b, truesys.c, truesys.adder.c]).u≈sol[[ + sys.a, sys.b, sys.c, sys.adder.c]] rtol=1e-7 + end + @testset "v2, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :CS) + @named adder = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-6, + reinitializealg = BrownFullBasicInit()) + @test MTK.isinput(adder.a) + @test MTK.isinput(adder.b) + @test MTK.isoutput(adder.out) + @test MTK.isoutput(adder.out2) + @test !MTK.isinput(adder.c) && !MTK.isoutput(adder.c) + + sys, prob = build_simple_adder(adder) + sol = solve(prob, Rodas5P(autodiff = false), abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; idxs = [truesys.a, truesys.b, truesys.c]).u≈sol.u rtol=4e-2 + # sys.adder.c is a discrete variable + @test truesol(sol.t; idxs = truesys.adder.c).u≈sol(sol.t; idxs = sys.adder.c).u rtol=4e-2 + end + + function build_sspace_model(sspace) + @variables u(t)=1.0 x(t)=1.0 y(t) [guess = 1.0] + @mtkbuild sys = ODESystem( + [sspace.u ~ u, D(u) ~ t, D(x) ~ sspace.x + sspace.y, y^2 ~ sspace.y + sspace.x], t; + systems = [sspace] + ) + + prob = ODEProblem( + sys, [sys.sspace.x => 1.0], (0.0, 1.0), [sys.sspace.A => 2.0]; use_scc = false) + return sys, prob + end + + @named sspace = StateSpace() + truesys, trueprob = build_sspace_model(sspace) + truesol = solve(trueprob, abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v3, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :ME) + @named sspace = MTK.FMIComponent(Val(3); fmu, type = :ME) + @test MTK.isinput(sspace.u) + @test MTK.isoutput(sspace.y) + @test !MTK.isinput(sspace.x) && !MTK.isoutput(sspace.x) + + sys, prob = build_sspace_model(sspace) + sol = solve(prob, Rodas5P(autodiff = false); abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; + idxs = [truesys.u, truesys.x, truesys.y, truesys.sspace.x]).u≈sol[[ + sys.u, sys.x, sys.y, sys.sspace.x]] rtol=1e-7 + end + + @testset "v3, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :CS) + @named sspace = MTK.FMIComponent( + Val(3); fmu, communication_step_size = 1e-6, type = :CS, + reinitializealg = BrownFullBasicInit()) + @test MTK.isinput(sspace.u) + @test MTK.isoutput(sspace.y) + @test !MTK.isinput(sspace.x) && !MTK.isoutput(sspace.x) + + sys, prob = build_sspace_model(sspace) + sol = solve(prob, Rodas5P(autodiff = false); abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol( + sol.t; idxs = [truesys.u, truesys.x, truesys.y]).u≈sol[[sys.u, sys.x, sys.y]] rtol=1e-2 + @test truesol(sol.t; idxs = truesys.sspace.x).u≈sol(sol.t; idxs = sys.sspace.x).u rtol=1e-2 + end +end + +@testset "FMUs in a loop" begin + function build_looped_adders(adder1, adder2) + @variables x(t) = 1 + @mtkbuild sys = ODESystem( + [D(x) ~ x, adder1.a ~ adder2.out2, + adder2.a ~ adder1.out2, adder1.b ~ 1.0, adder2.b ~ 2.0], + t; + systems = [adder1, adder2]) + prob = ODEProblem( + sys, [adder1.c => 1.0, adder2.c => 1.0, adder1.a => 2.0], + (0.0, 1.0); guesses = [adder2.a => 0.0]) + return sys, prob + end + @named adder1 = SimpleAdder() + @named adder2 = SimpleAdder() + truesys, trueprob = build_looped_adders(adder1, adder2) + truesol = solve(trueprob, Tsit5(), reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v2, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :ME) + @named adder1 = MTK.FMIComponent(Val(2); fmu, type = :ME) + @named adder2 = MTK.FMIComponent(Val(2); fmu, type = :ME) + sys, prob = build_looped_adders(adder1, adder2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.adder1.c, truesys.adder2.c]).u≈sol( + sol.t; idxs = [sys.adder1.c, sys.adder2.c]).u rtol=1e-7 + end + @testset "v2, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :CS) + @named adder1 = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-5) + @named adder2 = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-5) + sys, prob = build_looped_adders(adder1, adder2) + sol = solve(prob, + Tsit5(); + reltol = 1e-8, + initializealg = SciMLBase.OverrideInit(nlsolve = FastShortcutNLLSPolyalg(autodiff = AutoFiniteDiff()))) + @test truesol(sol.t; + idxs = [truesys.adder1.c, truesys.adder2.c]).u≈sol( + sol.t; idxs = [sys.adder1.c, sys.adder2.c]).u rtol=1e-3 + end + + function build_looped_sspace(sspace1, sspace2) + @variables x(t) = 1 + @mtkbuild sys = ODESystem([D(x) ~ x, sspace1.u ~ sspace2.x, sspace2.u ~ sspace1.y], + t; systems = [sspace1, sspace2]) + prob = ODEProblem(sys, [sspace1.x => 1.0, sspace2.x => 1.0], (0.0, 1.0)) + return sys, prob + end + @named sspace1 = StateSpace() + @named sspace2 = StateSpace() + truesys, trueprob = build_looped_sspace(sspace1, sspace2) + truesol = solve(trueprob, Rodas5P(), reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v3, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :ME) + @named sspace1 = MTK.FMIComponent(Val(3); fmu, type = :ME) + @named sspace2 = MTK.FMIComponent(Val(3); fmu, type = :ME) + sys, prob = build_looped_sspace(sspace1, sspace2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.sspace1.x, truesys.sspace2.x]).u≈sol( + sol.t; idxs = [sys.sspace1.x, sys.sspace2.x]).u rtol=1e-7 + end + + @testset "v3, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :CS) + @named sspace1 = MTK.FMIComponent( + Val(3); fmu, type = :CS, communication_step_size = 1e-5) + @named sspace2 = MTK.FMIComponent( + Val(3); fmu, type = :CS, communication_step_size = 1e-5) + sys, prob = build_looped_sspace(sspace1, sspace2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.sspace1.x, truesys.sspace2.x]).u≈sol( + sol.t; idxs = [sys.sspace1.x, sys.sspace2.x]).u rtol=1e-2 + end +end diff --git a/test/fmi/fmus/SimpleAdder.fmu b/test/fmi/fmus/SimpleAdder.fmu new file mode 100644 index 0000000000..ed12961bfb Binary files /dev/null and b/test/fmi/fmus/SimpleAdder.fmu differ diff --git a/test/fmi/fmus/SimpleAdder/README.md b/test/fmi/fmus/SimpleAdder/README.md new file mode 100644 index 0000000000..b26c247bce --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/README.md @@ -0,0 +1,3 @@ +SimpleAdder.fmu is a v2 FMU built using OpenModelica. The file `SimpleAdder.mo` contains the +modelica source code for the model. The file `buildFMU.mos` is the OpenModelica script used +to build the FMU. diff --git a/test/fmi/fmus/SimpleAdder/SimpleAdder.mo b/test/fmi/fmus/SimpleAdder/SimpleAdder.mo new file mode 100644 index 0000000000..ce97f09a86 --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/SimpleAdder.mo @@ -0,0 +1,12 @@ +block SimpleAdder + parameter Real value = 1.0; + input Real a; + input Real b; + Real c(start = 1.0, fixed = true); + output Real out; + output Real out2; +equation + der(c) = out; + out = a + b + value; + out2 = 2 * c; +end SimpleAdder; diff --git a/test/fmi/fmus/SimpleAdder/buildFmu.mos b/test/fmi/fmus/SimpleAdder/buildFmu.mos new file mode 100644 index 0000000000..df2c9bd58e --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/buildFmu.mos @@ -0,0 +1,12 @@ +OpenModelica.Scripting.loadFile("SimpleAdder.mo"); getErrorString(); + +installPackage(Modelica); + +setCommandLineOptions("-d=newInst"); getErrorString(); +setCommandLineOptions("-d=initialization"); getErrorString(); +setCommandLineOptions("-d=-disableDirectionalDerivatives"); getErrorString(); + +cd("output"); getErrorString(); +buildModelFMU(SimpleAdder, version = "2.0", fmuType = "me_cs"); getErrorString(); +system("unzip -l SimpleAdder.fmu | egrep -v 'sources|files' | tail -n+3 | grep -o '[A-Za-z._0-9/]*$' > BB.log") + diff --git a/test/fmi/fmus/StateSpace.fmu b/test/fmi/fmus/StateSpace.fmu new file mode 100644 index 0000000000..6138e1fdb2 Binary files /dev/null and b/test/fmi/fmus/StateSpace.fmu differ diff --git a/test/fmi/fmus/StateSpace/0001-tmp-commit.patch b/test/fmi/fmus/StateSpace/0001-tmp-commit.patch new file mode 100644 index 0000000000..951af2d4fd --- /dev/null +++ b/test/fmi/fmus/StateSpace/0001-tmp-commit.patch @@ -0,0 +1,425 @@ +From 10ac73996a7c09772ff31ccd6e030112d3bb5c43 Mon Sep 17 00:00:00 2001 +From: Aayush Sabharwal +Date: Wed, 8 Jan 2025 11:04:01 +0000 +Subject: tmp commit + +--- + StateSpace/FMI3.xml | 31 ++---- + StateSpace/config.h | 18 ++-- + StateSpace/model.c | 230 +++++++++++++------------------------------- + 3 files changed, 83 insertions(+), 196 deletions(-) + +diff --git a/StateSpace/FMI3.xml b/StateSpace/FMI3.xml +index 8d2e4d9..002fbab 100644 +--- a/StateSpace/FMI3.xml ++++ b/StateSpace/FMI3.xml +@@ -33,36 +33,23 @@ + + + +- +- +- ++ + +- +- +- ++ + +- +- +- ++ + +- +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + + + +diff --git a/StateSpace/config.h b/StateSpace/config.h +index 707e500..8b422e2 100644 +--- a/StateSpace/config.h ++++ b/StateSpace/config.h +@@ -44,15 +44,15 @@ typedef struct { + uint64_t m; + uint64_t n; + uint64_t r; +- double A[M_MAX][N_MAX]; +- double B[M_MAX][N_MAX]; +- double C[M_MAX][N_MAX]; +- double D[M_MAX][N_MAX]; +- double x0[N_MAX]; +- double u[N_MAX]; +- double y[N_MAX]; +- double x[N_MAX]; +- double der_x[N_MAX]; ++ double A; ++ double B; ++ double C; ++ double D; ++ double x0; ++ double u; ++ double y; ++ double x; ++ double der_x; + } ModelData; + + #endif /* config_h */ +diff --git a/StateSpace/model.c b/StateSpace/model.c +index 8a47e74..9c170d8 100644 +--- a/StateSpace/model.c ++++ b/StateSpace/model.c +@@ -3,77 +3,29 @@ + + + void setStartValues(ModelInstance *comp) { +- +- M(m) = 3; +- M(n) = 3; +- M(r) = 3; +- + // identity matrix +- for (int i = 0; i < M_MAX; i++) +- for (int j = 0; j < N_MAX; j++) { +- M(A)[i][j] = i == j ? 1 : 0; +- M(B)[i][j] = i == j ? 1 : 0; +- M(C)[i][j] = i == j ? 1 : 0; +- M(D)[i][j] = i == j ? 1 : 0; +- } +- +- for (int i = 0; i < M_MAX; i++) { +- M(u)[i] = i + 1; +- } +- +- for (int i = 0; i < N_MAX; i++) { +- M(y)[i] = 0; +- } +- +- for (int i = 0; i < N_MAX; i++) { +- M(x)[i] = M(x0)[i]; +- M(x)[i] = 0; +- } +- ++ M(m) = 1; ++ M(n) = 1; ++ M(r) = 1; ++ ++ M(A) = 1; ++ M(B) = 1; ++ M(C) = 1; ++ M(D) = 1; ++ ++ M(u) = 1; ++ M(y) = 0; ++ M(x) = 0; ++ M(x0) = 0; + } + + Status calculateValues(ModelInstance *comp) { +- +- // der(x) = Ax + Bu +- for (size_t i = 0; i < M(n); i++) { +- +- M(der_x)[i] = 0; +- +- for (size_t j = 0; j < M(n); j++) { +- M(der_x)[i] += M(A)[i][j] * M(x)[j]; +- } +- } +- +- for (size_t i = 0; i < M(n); i++) { +- +- for (size_t j = 0; j < M(r); j++) { +- M(der_x)[i] += M(B)[i][j] * M(u)[j]; +- } +- } +- +- +- // y = Cx + Du +- for (size_t i = 0; i < M(r); i++) { +- +- M(y)[i] = 0; +- +- for (size_t j = 0; j < M(n); j++) { +- M(y)[i] += M(C)[i][j] * M(x)[j]; +- } +- } +- +- for (size_t i = 0; i < M(r); i++) { +- +- for (size_t j = 0; j < M(m); j++) { +- M(y)[i] += M(D)[i][j] * M(u)[j]; +- } +- } +- ++ M(der_x) = M(A) * M(x) + M(B) * M(u); ++ M(y) = M(C) * M(x) + M(D) * M(u); + return OK; + } + + Status getFloat64(ModelInstance* comp, ValueReference vr, double values[], size_t nValues, size_t* index) { +- + calculateValues(comp); + + switch (vr) { +@@ -82,66 +34,40 @@ Status getFloat64(ModelInstance* comp, ValueReference vr, double values[], size_ + values[(*index)++] = comp->time; + return OK; + case vr_A: +- ASSERT_NVALUES((size_t)(M(n) * M(n))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(A)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(A); + return OK; + case vr_B: +- ASSERT_NVALUES((size_t)(M(m) * M(n))); +- for (size_t i = 0; i < M(m); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(B)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(B); + return OK; + case vr_C: +- ASSERT_NVALUES((size_t)(M(r) * M(n))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(C)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(C); + return OK; + case vr_D: +- ASSERT_NVALUES((size_t)(M(r) * M(m))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(m); j++) { +- values[(*index)++] = M(D)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(D); + return OK; + case vr_x0: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(x0)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(x0); + return OK; + case vr_u: +- ASSERT_NVALUES((size_t)M(m)); +- for (size_t i = 0; i < M(m); i++) { +- values[(*index)++] = M(u)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(u); + return OK; + case vr_y: +- ASSERT_NVALUES((size_t)M(r)); +- for (size_t i = 0; i < M(r); i++) { +- values[(*index)++] = M(y)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(y); + return OK; + case vr_x: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(x)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(x); + return OK; + case vr_der_x: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(der_x)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(der_x); + return OK; + default: + logError(comp, "Get Float64 is not allowed for value reference %u.", vr); +@@ -153,58 +79,40 @@ Status setFloat64(ModelInstance* comp, ValueReference vr, const double values[], + + switch (vr) { + case vr_A: +- ASSERT_NVALUES((size_t)(M(n) * M(n))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(n); j++) { +- M(A)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(A) = values[(*index)++]; + break; + case vr_B: +- ASSERT_NVALUES((size_t)(M(n) * M(m))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(m); j++) { +- M(B)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(B) = values[(*index)++]; + break; + case vr_C: +- ASSERT_NVALUES((size_t)(M(r) * M(n))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(n); j++) { +- M(C)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(C) = values[(*index)++]; + break; + case vr_D: +- ASSERT_NVALUES((size_t)(M(r) * M(m))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(m); j++) { +- M(D)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(D) = values[(*index)++]; + break; + case vr_x0: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- M(x0)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(x0) = values[(*index)++]; + break; + case vr_u: +- ASSERT_NVALUES((size_t)M(m)); +- for (size_t i = 0; i < M(m); i++) { +- M(u)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(u) = values[(*index)++]; ++ break; ++ case vr_y: ++ ASSERT_NVALUES(1); ++ M(y) = values[(*index)++]; + break; + case vr_x: +- if (comp->state != ContinuousTimeMode && comp->state != EventMode) { +- logError(comp, "Variable \"x\" can only be set in Continuous Time Mode and Event Mode."); +- return Error; +- } +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- M(x)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(x) = values[(*index)++]; ++ break; ++ case vr_der_x: ++ ASSERT_NVALUES(1); ++ M(der_x) = values[(*index)++]; + break; + default: + logError(comp, "Set Float64 is not allowed for value reference %u.", vr); +@@ -217,8 +125,6 @@ Status setFloat64(ModelInstance* comp, ValueReference vr, const double values[], + } + + Status getUInt64(ModelInstance* comp, ValueReference vr, uint64_t values[], size_t nValues, size_t* index) { +- +- + switch (vr) { + case vr_m: + ASSERT_NVALUES(1); +@@ -289,49 +195,43 @@ Status eventUpdate(ModelInstance *comp) { + } + + size_t getNumberOfContinuousStates(ModelInstance* comp) { +- return (size_t)M(n); ++ return M(n) == 1 ? M(n) : 1; + } + + Status getContinuousStates(ModelInstance* comp, double x[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + +- for (size_t i = 0; i < M(n); i++) { +- x[i] = M(x)[i]; +- } ++ x[0] = M(x); + + return OK; + } + + Status setContinuousStates(ModelInstance* comp, const double x[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + +- for (size_t i = 0; i < M(n); i++) { +- M(x)[i] = x[i]; +- } ++ M(x) = x[0]; + + return OK; + } + + Status getDerivatives(ModelInstance* comp, double dx[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + + calculateValues(comp); + +- for (size_t i = 0; i < M(n); i++) { +- dx[i] = M(der_x)[i]; +- } ++ dx[0] = M(der_x); + + return OK; + } +-- +2.34.1 + diff --git a/test/fmi/fmus/StateSpace/README.md b/test/fmi/fmus/StateSpace/README.md new file mode 100644 index 0000000000..108147443d --- /dev/null +++ b/test/fmi/fmus/StateSpace/README.md @@ -0,0 +1,7 @@ +StateSpace.fmu is a v3 FMU built using a modified version of the StateSpace FMU in Modelica's +Reference-FMUs. The link to the specific commit hash used is + +[https://github.com/modelica/Reference-FMUs/tree/0e1374cad0a7f0583a583ce189060ba7a67c27a7]() + +The git patch containing the changes is provided as `0001-tmp-commit.patch`. The FMU can be +built using the instructions provided in the repository's README. diff --git a/test/funcaffect.jl b/test/funcaffect.jl new file mode 100644 index 0000000000..3004044d61 --- /dev/null +++ b/test/funcaffect.jl @@ -0,0 +1,289 @@ +using ModelingToolkit, Test, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +@constants h=1 zr=0 +@variables u(t) + +eqs = [D(u) ~ -u] + +affect1!(integ, u, p, ctx) = integ.u[u.u] += 10 + +@named sys = ODESystem(eqs, t, [u], [], + discrete_events = [[4.0] => (affect1!, [u], [], [], nothing)]) +prob = ODEProblem(complete(sys), [u => 10.0], (0, 10.0)) +sol = solve(prob, Tsit5()) +i4 = findfirst(==(4.0), sol[:t]) +@test sol.u[i4 + 1][1] > 10.0 + +# callback +cb = ModelingToolkit.SymbolicDiscreteCallback(t == zr, + (f = affect1!, sts = [], pars = [], discretes = [], + ctx = [1])) +cb1 = ModelingToolkit.SymbolicDiscreteCallback(t == zr, (affect1!, [], [], [], [1])) +@test ModelingToolkit.affects(cb) isa ModelingToolkit.FunctionalAffect +@test cb == cb1 +@test ModelingToolkit.SymbolicDiscreteCallback(cb) === cb # passthrough +@test hash(cb) == hash(cb1) +ModelingToolkit.generate_discrete_callback(cb, sys, ModelingToolkit.get_variables(sys), + ModelingToolkit.get_ps(sys)); + +cb = ModelingToolkit.SymbolicContinuousCallback([t ~ zr], + (f = affect1!, sts = [], pars = [], discretes = [], + ctx = [1])) +cb1 = ModelingToolkit.SymbolicContinuousCallback([t ~ zr], (affect1!, [], [], [], [1])) +@test cb == cb1 +@test ModelingToolkit.SymbolicContinuousCallback(cb) === cb # passthrough +@test hash(cb) == hash(cb1) + +# named tuple +sys1 = ODESystem(eqs, t, [u], [], name = :sys, + discrete_events = [ + [4.0] => (f = affect1!, sts = [u], pars = [], discretes = [], ctx = nothing) + ]) +@test sys == sys1 + +# has_functional_affect +de = ModelingToolkit.get_discrete_events(sys1) +@test length(de) == 1 +de = de[1] +@test ModelingToolkit.condition(de) == [4.0] +@test ModelingToolkit.has_functional_affect(de) + +sys2 = ODESystem(eqs, t, [u], [], name = :sys, + discrete_events = [[4.0] => [u ~ -u * h]]) +@test !ModelingToolkit.has_functional_affect(ModelingToolkit.get_discrete_events(sys2)[1]) + +# context +function affect2!(integ, u, p, ctx) + integ.u[u.u] += ctx[1] + ctx[1] *= 2 +end +ctx1 = [10.0] +@named sys = ODESystem(eqs, t, [u], [], + discrete_events = [[4.0, 8.0] => (affect2!, [u], [], [], ctx1)]) +prob = ODEProblem(complete(sys), [u => 10.0], (0, 10.0)) +sol = solve(prob, Tsit5()) +i4 = findfirst(==(4.0), sol[:t]) +@test sol.u[i4 + 1][1] > 10.0 +i8 = findfirst(==(8.0), sol[:t]) +@test sol.u[i8 + 1][1] > 20.0 +@test ctx1[1] == 40.0 + +# parameter +function affect3!(integ, u, p, ctx) + integ.u[u.u] += integ.ps[p.a] + integ.ps[p.a] *= 2 +end + +@parameters a = 10.0 +@named sys = ODESystem(eqs, t, [u], [a], + discrete_events = [[4.0, 8.0] => (affect3!, [u], [a], [a], nothing)]) +prob = ODEProblem(complete(sys), [u => 10.0], (0, 10.0)) + +sol = solve(prob, Tsit5()) +i4 = findfirst(==(4.0), sol[:t]) +@test sol.u[i4 + 1][1] > 10.0 +i8 = findfirst(==(8.0), sol[:t]) +@test sol.u[i8 + 1][1] > 20.0 + +# rename parameter +function affect3!(integ, u, p, ctx) + integ.u[u.u] += integ.ps[p.b] + integ.ps[p.b] *= 2 +end + +@named sys = ODESystem(eqs, t, [u], [a], + discrete_events = [ + [4.0, 8.0] => (affect3!, [u], [a => :b], [a], nothing) + ]) +prob = ODEProblem(complete(sys), [u => 10.0], (0, 10.0)) + +sol = solve(prob, Tsit5()) +i4 = findfirst(==(4.0), sol[:t]) +@test sol.u[i4 + 1][1] > 10.0 +i8 = findfirst(==(8.0), sol[:t]) +@test sol.u[i8 + 1][1] > 20.0 + +# same name +@variables v(t) +@test_throws ErrorException ODESystem(eqs, t, [u], [a], + discrete_events = [ + [4.0, 8.0] => (affect3!, [u, v => :u], [a], [a], + nothing) + ]; name = :sys) + +@test_nowarn ODESystem(eqs, t, [u], [a], + discrete_events = [ + [4.0, 8.0] => (affect3!, [u], [a => :u], [a], nothing) + ]; name = :sys) + +@named resistor = ODESystem(D(v) ~ v, t, [v], []) + +# nested namespace +ctx = [0] +function affect4!(integ, u, p, ctx) + ctx[1] += 1 + @test u.resistor₊v == 1 +end +s1 = compose( + ODESystem(Equation[], t, [], [], name = :s1, + discrete_events = 1.0 => (affect4!, [resistor.v], [], [], ctx)), + resistor) +s2 = structural_simplify(s1) +prob = ODEProblem(s2, [resistor.v => 10.0], (0, 2.01)) +sol = solve(prob, Tsit5()) +@test ctx[1] == 2 + +include("../examples/rc_model.jl") + +function affect5!(integ, u, p, ctx) + @test integ.u[u.capacitor₊v] ≈ 0.3 + integ.ps[p.C] *= 200 +end + +@named rc_model = ODESystem(rc_eqs, t, + continuous_events = [ + [capacitor.v ~ 0.3] => (affect5!, [capacitor.v], + [capacitor.C => :C], [capacitor.C], nothing) + ]) +rc_model = compose(rc_model, [resistor, capacitor, source, ground]) + +sys = structural_simplify(rc_model) +u0 = [capacitor.v => 0.0 + capacitor.p.i => 0.0 + resistor.v => 0.0] + +prob = ODEProblem(sys, u0, (0, 10.0)) +sol = solve(prob, Rodas4()) +@test all(sol[rc_model.capacitor.v] .< 0.4) + +# hierarchical - result should be identical + +function affect6!(integ, u, p, ctx) + @test integ.u[u.v] ≈ 0.3 + integ.ps[p.C] *= 200 +end + +function Capacitor2(; name, C = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + ps = @parameters C = C + eqs = [ + D(v) ~ i / C + ] + extend( + ODESystem(eqs, t, [], ps; name = name, + continuous_events = [[v ~ 0.3] => (affect6!, [v], [C], [C], nothing)]), + oneport) +end + +@named capacitor2 = Capacitor2(C = C) + +rc_eqs2 = [connect(source.p, resistor.p) + connect(resistor.n, capacitor2.p) + connect(capacitor2.n, source.n) + connect(capacitor2.n, ground.g)] + +@named rc_model2 = ODESystem(rc_eqs2, t) +rc_model2 = compose(rc_model2, [resistor, capacitor2, source, ground]) + +sys2 = structural_simplify(rc_model2) +u0 = [capacitor2.v => 0.0 + capacitor2.p.i => 0.0 + resistor.v => 0.0] + +prob2 = ODEProblem(sys2, u0, (0, 10.0)) +sol2 = solve(prob2, Rodas4()) +@test all(sol2[rc_model2.capacitor2.v] .== sol[rc_model.capacitor.v]) + +# discrete events + +a7_count = 0 +function affect7!(integ, u, p, ctx) + integ.ps[p.g] = 0 + ctx[1] += 1 + @test ctx[1] <= 2 + @test (ctx[1] == 1 && integ.t == 1.0) || (ctx[1] == 2 && integ.t == 2.0) + global a7_count += 1 +end + +a7_ctx = [0] +function Ball(; name, g = 9.8, anti_gravity_time = 1.0) + pars = @parameters g = g + sts = @variables x(t), v(t) + eqs = [D(x) ~ v, D(v) ~ g] + ODESystem(eqs, t, sts, pars; name = name, + discrete_events = [[anti_gravity_time] => (affect7!, [], [g], [g], a7_ctx)]) +end + +@named ball1 = Ball(anti_gravity_time = 1.0) +@named ball2 = Ball(anti_gravity_time = 2.0) + +@named balls = ODESystem(Equation[], t) +balls = compose(balls, [ball1, ball2]) + +@test ModelingToolkit.has_discrete_events(balls) +@test length(ModelingToolkit.affects(ModelingToolkit.discrete_events(balls))) == 2 + +prob = ODEProblem(complete(balls), + [ball1.x => 10.0, ball1.v => 0, ball2.x => 10.0, ball2.v => 0], + (0, 3.0)) +sol = solve(prob, Tsit5()) + +@test a7_count == 2 +@test sol(0.99)[1] == sol(0.99)[3] +@test sol(1.01)[4] > sol(1.01)[2] +@test sol(1.99)[2] == sol(1.01)[2] +@test sol(1.99)[4] > sol(1.01)[4] +@test sol(2.5)[4] == sol(3.0)[4] + +# bouncing ball + +# DiffEq implementation +function f_(du, u, p, t) + du[1] = u[2] + du[2] = -p +end + +function condition_(u, t, integrator) # Event when event_f(u,t) == 0 + u[1] +end + +function affect_!(integrator) + integrator.u[2] = -integrator.u[2] +end + +cb_ = ContinuousCallback(condition_, affect_!) + +u0 = [50.0, 0.0] +tspan = (0.0, 15.0) +p = 9.8 +prob_ = ODEProblem(f_, u0, tspan, p) +sol_ = solve(prob_, Tsit5(), callback = cb_) + +# same - with MTK +sts = @variables y(t), v(t) +par = @parameters g = 9.8 +bb_eqs = [D(y) ~ v + D(v) ~ -g] + +function bb_affect!(integ, u, p, ctx) + integ.u[u.v] = -integ.u[u.v] +end + +@named bb_model = ODESystem(bb_eqs, t, sts, par, + continuous_events = [ + [y ~ zr] => (bb_affect!, [v], [], [], nothing) + ]) + +bb_sys = structural_simplify(bb_model) +@test only(ModelingToolkit.affects(ModelingToolkit.continuous_events(bb_sys))) isa + ModelingToolkit.FunctionalAffect + +u0 = [v => 0.0, y => 50.0] + +bb_prob = ODEProblem(bb_sys, u0, (0, 15.0)) +bb_sol = solve(bb_prob, Tsit5()) + +@test bb_sol[y] ≈ map(u -> u[1], sol_.u) +@test bb_sol[v] ≈ map(u -> u[2], sol_.u) diff --git a/test/function_registration.jl b/test/function_registration.jl index e70a94f08b..a1d9041127 100644 --- a/test/function_registration.jl +++ b/test/function_registration.jl @@ -3,78 +3,78 @@ # appropriately calls the registered functions, whether the call is # qualified (with a module name) or not. - # TEST: Function registration in a module. # ------------------------------------------------ module MyModule - using ModelingToolkit, DiffEqBase, LinearAlgebra, Test - @parameters t x - @variables u(t) - Dt = Differential(t) +using ModelingToolkit, DiffEqBase, LinearAlgebra, Test +using ModelingToolkit: t_nounits as t, D_nounits as Dt +@parameters x +@variables u(t) - function do_something(a) - a + 10 - end - @register do_something(a) +function do_something(a) + a + 10 +end +@register_symbolic do_something(a) - eq = Dt(u) ~ do_something(x) + MyModule.do_something(x) - sys = ODESystem([eq], t, [u], [x]) - fun = ODEFunction(sys) +eq = Dt(u) ~ do_something(x) + MyModule.do_something(x) +@named sys = ODESystem([eq], t, [u], [x]) +sys = complete(sys) +fun = ODEFunction(sys) - u0 = 5.0 - @test fun([0.5], [u0], 0.) == [do_something(u0) * 2] +u0 = 5.0 +@test fun([0.5], u0, 0.0) == [do_something(u0) * 2] end - # TEST: Function registration in a nested module. # ------------------------------------------------ module MyModule2 - module MyNestedModule - using ModelingToolkit, DiffEqBase, LinearAlgebra, Test - @parameters t x - @variables u(t) - Dt = Differential(t) - - function do_something_2(a) - a + 20 - end - @register do_something_2(a) - - eq = Dt(u) ~ do_something_2(x) + MyNestedModule.do_something_2(x) - sys = ODESystem([eq], t, [u], [x]) - fun = ODEFunction(sys) - - u0 = 3.0 - @test fun([0.5], [u0], 0.) == [do_something_2(u0) * 2] - end +module MyNestedModule +using ModelingToolkit, DiffEqBase, LinearAlgebra, Test +using ModelingToolkit: t_nounits as t, D_nounits as Dt +@parameters x +@variables u(t) + +function do_something_2(a) + a + 20 end +@register_symbolic do_something_2(a) + +eq = Dt(u) ~ do_something_2(x) + MyNestedModule.do_something_2(x) +@named sys = ODESystem([eq], t, [u], [x]) +sys = complete(sys) +fun = ODEFunction(sys) +u0 = 3.0 +@test fun([0.5], u0, 0.0) == [do_something_2(u0) * 2] +end +end # TEST: Function registration outside any modules. # ------------------------------------------------ using ModelingToolkit, DiffEqBase, LinearAlgebra, Test -@parameters t x +using ModelingToolkit: t_nounits as t, D_nounits as Dt +@parameters x @variables u(t) -Dt = Differential(t) function do_something_3(a) a + 30 end -@register do_something_3(a) +@register_symbolic do_something_3(a) -eq = Dt(u) ~ do_something_3(x) + (@__MODULE__).do_something_3(x) -sys = ODESystem([eq], t, [u], [x]) +eq = Dt(u) ~ do_something_3(x) + (@__MODULE__).do_something_3(x) +@named sys = ODESystem([eq], t, [u], [x]) +sys = complete(sys) fun = ODEFunction(sys) u0 = 7.0 -@test fun([0.5], [u0], 0.) == [do_something_3(u0) * 2] - +@test fun([0.5], u0, 0.0) == [do_something_3(u0) * 2] # TEST: Function registration works with derivatives. # --------------------------------------------------- foo(x, y) = sin(x) * cos(y) -@parameters t; @variables x(t) y(t) z(t); D = Differential(t) -@register foo(x, y) +@variables x(t) y(t) z(t); +D = Dt +@register_symbolic foo(x, y) using ModelingToolkit: value, arguments, operation expr = value(foo(x, y)) @@ -85,7 +85,6 @@ ModelingToolkit.derivative(::typeof(foo), (x, y), ::Val{1}) = cos(x) * cos(y) # ModelingToolkit.derivative(::typeof(foo), (x, y), ::Val{2}) = -sin(x) * sin(y) # derivative w.r.t. the second argument @test isequal(expand_derivatives(D(foo(x, y))), expand_derivatives(D(sin(x) * cos(y)))) - # TEST: Function registration run from inside a function. # ------------------------------------------------------- # This tests that we can get around the world age issue by falling back to @@ -95,24 +94,24 @@ ModelingToolkit.derivative(::typeof(foo), (x, y), ::Val{2}) = -sin(x) * sin(y) # function do_something_4(a) a + 30 end -@register do_something_4(a) +@register_symbolic do_something_4(a) function build_ode() - @parameters t x + @parameters x @variables u(t) - Dt = Differential(t) - eq = Dt(u) ~ do_something_4(x) + (@__MODULE__).do_something_4(x) - sys = ODESystem([eq], t, [u], [x]) - fun = ODEFunction(sys, eval_expression=false) + eq = Dt(u) ~ do_something_4(x) + (@__MODULE__).do_something_4(x) + @named sys = ODESystem([eq], t, [u], [x]) + sys = complete(sys) + fun = ODEFunction(sys, eval_expression = false) end function run_test() fun = build_ode() u0 = 10.0 - @test fun([0.5], [u0], 0.) == [do_something_4(u0) * 2] + @test fun([0.5], u0, 0.0) == [do_something_4(u0) * 2] end run_test() using ModelingToolkit: arguments @variables a -@register foo(x,y,z) -@test 1 * foo(a,a,a) * Num(1) isa Num -@test !any(x->x isa Num, arguments(value(1 * foo(a,a,a) * Num(1)))) +@register_symbolic foo(x, y, z) +@test 1 * foo(a, a, a) * Num(1) isa Num +@test !any(x -> x isa Num, arguments(value(1 * foo(a, a, a) * Num(1)))) diff --git a/test/guess_propagation.jl b/test/guess_propagation.jl new file mode 100644 index 0000000000..738e930adc --- /dev/null +++ b/test/guess_propagation.jl @@ -0,0 +1,110 @@ +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: D, t_nounits as t +using Test + +# Standard case + +@variables x(t) [guess = 2] +@variables y(t) +eqs = [D(x) ~ 1 + x ~ y] +initialization_eqs = [1 ~ exp(1 + x)] + +@named sys = ODESystem(eqs, t; initialization_eqs) +sys = complete(structural_simplify(sys)) +tspan = (0.0, 0.2) +prob = ODEProblem(sys, [], tspan, []) + +@test prob.f.initializeprob[y] == 2.0 +@test prob.f.initializeprob[x] == 2.0 +sol = solve(prob.f.initializeprob; show_trace = Val(true)) + +# Guess via observed + +@variables x(t) +@variables y(t) [guess = 2] +eqs = [D(x) ~ 1 + x ~ y] +initialization_eqs = [1 ~ exp(1 + x)] + +@named sys = ODESystem(eqs, t; initialization_eqs) +sys = complete(structural_simplify(sys)) +tspan = (0.0, 0.2) +prob = ODEProblem(sys, [], tspan, []) + +@test prob.f.initializeprob[x] == 2.0 +@test prob.f.initializeprob[y] == 2.0 +sol = solve(prob.f.initializeprob; show_trace = Val(true)) + +# Guess via parameter + +@parameters a = -1.0 +@variables x(t) [guess = a] + +eqs = [D(x) ~ a] + +initialization_eqs = [1 ~ exp(1 + x)] + +@named sys = ODESystem(eqs, t; initialization_eqs) +sys = complete(structural_simplify(sys)) + +tspan = (0.0, 0.2) +prob = ODEProblem(sys, [], tspan, []) + +@test prob.f.initializeprob[x] == -1.0 +sol = solve(prob.f.initializeprob; show_trace = Val(true)) + +# Guess via observed parameter + +@parameters a = -1.0 +@variables x(t) +@variables y(t) [guess = a] + +eqs = [D(x) ~ a, + y ~ x] + +initialization_eqs = [1 ~ exp(1 + x)] + +@named sys = ODESystem(eqs, t; initialization_eqs) +sys = complete(structural_simplify(sys)) + +tspan = (0.0, 0.2) +prob = ODEProblem(sys, [], tspan, []) + +@test prob.f.initializeprob[x] == -1.0 +sol = solve(prob.f.initializeprob; show_trace = Val(true)) + +# Test parameters + defaults +# https://github.com/SciML/ModelingToolkit.jl/issues/2774 + +@parameters x0 +@variables x(t) +@variables y(t) = x +@mtkbuild sys = ODESystem([x ~ x0, D(y) ~ x], t) +prob = ODEProblem(sys, [], (0.0, 1.0), [x0 => 1.0]) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) +@variables y(t) = x0 +@mtkbuild sys = ODESystem([x ~ x0, D(y) ~ x], t) +prob = ODEProblem(sys, [], (0.0, 1.0), [x0 => 1.0]) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) +@variables y(t) = x0 +@mtkbuild sys = ODESystem([x ~ y, D(y) ~ x], t) +prob = ODEProblem(sys, [], (0.0, 1.0), [x0 => 1.0]) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) = x0 +@variables y(t) = x +@mtkbuild sys = ODESystem([x ~ y, D(y) ~ x], t) +prob = ODEProblem(sys, [], (0.0, 1.0), [x0 => 1.0]) +@test prob[x] == 1.0 +@test prob[y] == 1.0 diff --git a/test/hierarchical_initialization_eqs.jl b/test/hierarchical_initialization_eqs.jl new file mode 100644 index 0000000000..1e3109a66e --- /dev/null +++ b/test/hierarchical_initialization_eqs.jl @@ -0,0 +1,147 @@ +using ModelingToolkit, OrdinaryDiffEq + +t = only(@parameters(t)) +D = Differential(t) +""" +A simple linear resistor model + +![Resistor](https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTpJkiEyqh-BRx27pvVH0GLZ4MP_D1oriBwJhnZdgIq7m17z9VKUWaW9MeNQAz1rTML2ho&usqp=CAU) +""" +@component function Resistor(; name, R = 1.0) + systems = @named begin + p = Pin() + n = Pin() + end + vars = @variables begin + v(t), [guess = 0.0] + i(t), [guess = 0.0] + end + params = @parameters begin + R = R, [description = "Resistance of this Resistor"] + end + eqs = [v ~ p.v - n.v + i ~ p.i + p.i + n.i ~ 0 + # Ohm's Law + v ~ i * R] + return ODESystem(eqs, t, vars, params; systems, name) +end +@connector Pin begin + v(t) + i(t), [connect = Flow] +end +@component function ConstantVoltage(; name, V = 1.0) + systems = @named begin + p = Pin() + n = Pin() + end + vars = @variables begin + v(t), [guess = 0.0] + i(t), [guess = 0.0] + end + params = @parameters begin + V = 10 + end + eqs = [v ~ p.v - n.v + i ~ p.i + p.i + n.i ~ 0 + v ~ V] + return ODESystem(eqs, t, vars, params; systems, name) +end + +@component function Capacitor(; name, C = 1.0) + systems = @named begin + p = Pin() + n = Pin() + end + vars = @variables begin + v(t), [guess = 0.0] + i(t), [guess = 0.0] + end + params = @parameters begin + C = C + end + initialization_eqs = [ + v ~ 0 + ] + eqs = [v ~ p.v - n.v + i ~ p.i + p.i + n.i ~ 0 + C * D(v) ~ i] + return ODESystem(eqs, t, vars, params; systems, name, initialization_eqs) +end + +@component function Ground(; name) + systems = @named begin + g = Pin() + end + eqs = [ + g.v ~ 0 + ] + return ODESystem(eqs, t, [], []; systems, name) +end + +@component function Inductor(; name, L = 1.0) + systems = @named begin + p = Pin() + n = Pin() + end + vars = @variables begin + v(t), [guess = 0.0] + i(t), [guess = 0.0] + end + params = @parameters begin + (L = L) + end + eqs = [v ~ p.v - n.v + i ~ p.i + p.i + n.i ~ 0 + L * D(i) ~ v] + return ODESystem(eqs, t, vars, params; systems, name) +end + +""" +This is an RLC model. This should support markdown. That includes +HTML as well. +""" +@component function RLCModel(; name) + systems = @named begin + resistor = Resistor(R = 100) + capacitor = Capacitor(C = 0.001) + inductor = Inductor(L = 1) + source = ConstantVoltage(V = 30) + ground = Ground() + end + initialization_eqs = [ + inductor.i ~ 0 + ] + eqs = [connect(source.p, inductor.n) + connect(inductor.p, resistor.p, capacitor.p) + connect(resistor.n, ground.g, capacitor.n, source.n)] + return ODESystem(eqs, t, [], []; systems, name, initialization_eqs) +end +"""Run model RLCModel from 0 to 10""" +function simple() + @mtkbuild model = RLCModel() + u0 = [] + prob = ODEProblem(model, u0, (0.0, 10.0)) + sol = solve(prob) +end +@test SciMLBase.successful_retcode(simple()) + +@named model = RLCModel() +@test length(ModelingToolkit.get_initialization_eqs(model)) == 1 +syslist = ModelingToolkit.get_systems(model) +@test length(ModelingToolkit.get_initialization_eqs(syslist[1])) == 0 +@test length(ModelingToolkit.get_initialization_eqs(syslist[2])) == 1 +@test length(ModelingToolkit.get_initialization_eqs(syslist[3])) == 0 +@test length(ModelingToolkit.get_initialization_eqs(syslist[4])) == 0 +@test length(ModelingToolkit.get_initialization_eqs(syslist[5])) == 0 +@test length(ModelingToolkit.initialization_equations(model)) == 2 + +u0 = [] +prob = ODEProblem(structural_simplify(model), u0, (0.0, 10.0)) +sol = solve(prob, Rodas5P()) +@test length(sol.u[end]) == 2 +@test length(equations(prob.f.initializeprob.f.sys)) == 0 +@test length(unknowns(prob.f.initializeprob.f.sys)) == 0 diff --git a/test/icons/ground.svg b/test/icons/ground.svg new file mode 100644 index 0000000000..81a35f5faf --- /dev/null +++ b/test/icons/ground.svg @@ -0,0 +1,2 @@ + + diff --git a/test/icons/oneport.png b/test/icons/oneport.png new file mode 100644 index 0000000000..f0d36e22ff Binary files /dev/null and b/test/icons/oneport.png differ diff --git a/test/icons/pin.png b/test/icons/pin.png new file mode 100644 index 0000000000..f0745d68a4 Binary files /dev/null and b/test/icons/pin.png differ diff --git a/test/icons/resistor.svg b/test/icons/resistor.svg new file mode 100644 index 0000000000..954dad52cc --- /dev/null +++ b/test/icons/resistor.svg @@ -0,0 +1,13 @@ + + + + diff --git a/test/if_lifting.jl b/test/if_lifting.jl new file mode 100644 index 0000000000..9c58e676d0 --- /dev/null +++ b/test/if_lifting.jl @@ -0,0 +1,126 @@ +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D, IfLifting, no_if_lift + +@testset "Simple `abs(x)`" begin + @mtkmodel SimpleAbs begin + @variables begin + x(t) + y(t) + end + @equations begin + D(x) ~ abs(y) + y ~ sin(t) + end + end + @named sys = SimpleAbs() + ss1 = structural_simplify(sys) + @test length(equations(ss1)) == 1 + ss2 = structural_simplify(sys, additional_passes = [IfLifting]) + @test length(equations(ss2)) == 1 + @test length(parameters(ss2)) == 1 + @test operation(only(equations(ss2)).rhs) === ifelse + + discvar = only(parameters(ss2)) + prob1 = ODEProblem(ss1, [ss1.x => 0.0], (0.0, 5.0)) + sol1 = solve(prob1, Tsit5()) + prob2 = ODEProblem(ss2, [ss2.x => 0.0], (0.0, 5.0)) + sol2 = solve(prob2, Tsit5()) + @test count(isapprox(pi), sol2.t) == 2 + @test any(isapprox(pi), sol2.discretes[1].t) + @test !sol2[discvar][1] + @test sol2[discvar][end] + + _t = pi + 1.0 + # x(t) = 1 - cos(t) in [0, pi) + # x(t) = 3 + cos(t) in [pi, 2pi) + _trueval = 3 + cos(_t) + @test !isapprox(sol1(_t)[1], _trueval; rtol = 1e-3) + @test isapprox(sol2(_t)[1], _trueval; rtol = 1e-3) +end + +@testset "Big test case" begin + @mtkmodel BigModel begin + @variables begin + x(t) + y(t) + z(t) + c(t)::Bool + w(t) + q(t) + r(t) + end + @parameters begin + p + end + @equations begin + # ifelse, max, min + D(x) ~ ifelse(c, max(x, y), min(x, y)) + # discrete observed + c ~ x <= y + # observed should also get if-lifting + y ~ abs(sin(t)) + # should be ignored + D(z) ~ no_if_lift(ifelse(x < y, x, y)) + # ignore time-independent ifelse + D(w) ~ ifelse(p < 3, 1.0, 2.0) + # all the boolean operators + D(q) ~ ifelse((x < 1) & ((y < 0.5) | ifelse(y > 0.8, c, !c)), 1.0, 2.0) + # don't touch time-independent condition, but modify time-dependent branches + D(r) ~ ifelse(p < 2, abs(x), max(y, 0.9)) + end + end + + @named sys = BigModel() + ss = structural_simplify(sys, additional_passes = [IfLifting]) + + ps = parameters(ss) + @test length(ps) == 9 + eqs = equations(ss) + obs = observed(ss) + + @testset "no_if_lift is untouched" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.z)), eqs) + eq = eqs[idx] + @test isequal(eq.rhs, no_if_lift(ifelse(ss.x < ss.y, ss.x, ss.y))) + end + @testset "time-independent ifelse is untouched" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.w)), eqs) + eq = eqs[idx] + @test operation(arguments(eq.rhs)[1]) === Base.:< + end + @testset "time-dependent branch of time-independent condition is modified" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.r)), eqs) + eq = eqs[idx] + @test operation(eq.rhs) === ifelse + args = arguments(eq.rhs) + @test operation(args[1]) == Base.:< + @test operation(args[2]) === ifelse + condvars = ModelingToolkit.vars(arguments(args[2])[1]) + @test length(condvars) == 1 && any(isequal(only(condvars)), ps) + @test operation(args[3]) === ifelse + condvars = ModelingToolkit.vars(arguments(args[3])[1]) + @test length(condvars) == 1 && any(isequal(only(condvars)), ps) + end + @testset "Observed variables are modified" begin + idx = findfirst(eq -> isequal(eq.lhs, ss.c), obs) + eq = obs[idx] + @test operation(eq.rhs) === Base.:! && any(isequal(only(arguments(eq.rhs))), ps) + idx = findfirst(eq -> isequal(eq.lhs, ss.y), obs) + eq = obs[idx] + @test operation(eq.rhs) === ifelse + end +end + +@testset "`@mtkbuild` macro accepts `additional_passes`" begin + @mtkmodel SimpleAbs begin + @variables begin + x(t) + y(t) + end + @equations begin + D(x) ~ abs(y) + y ~ sin(t) + end + end + @test_nowarn @mtkbuild sys=SimpleAbs() additional_passes=[IfLifting] +end diff --git a/test/implicit_discrete_system.jl b/test/implicit_discrete_system.jl new file mode 100644 index 0000000000..932b6c6981 --- /dev/null +++ b/test/implicit_discrete_system.jl @@ -0,0 +1,75 @@ +using ModelingToolkit, Test +using ModelingToolkit: t_nounits as t +using StableRNGs + +k = ShiftIndex(t) +rng = StableRNG(22525) + +@testset "Correct ImplicitDiscreteFunction" begin + @variables x(t) = 1 + @mtkbuild sys = ImplicitDiscreteSystem([x(k) ~ x(k) * x(k - 1) - 3], t) + tspan = (0, 10) + + # u[2] - u_next[1] + # -3 - u_next[2] + u_next[2]*u_next[1] + f = ImplicitDiscreteFunction(sys) + u_next = [3.0, 1.5] + @test f(u_next, [2.0, 3.0], [], t) ≈ [0.0, 0.0] + u_next = [0.0, 0.0] + @test f(u_next, [2.0, 3.0], [], t) ≈ [3.0, -3.0] + + resid = rand(2) + f(resid, u_next, [2.0, 3.0], [], t) + @test resid ≈ [3.0, -3.0] + + prob = ImplicitDiscreteProblem(sys, [x(k - 1) => 3.0], tspan) + @test prob.u0 == [3.0, 1.0] + prob = ImplicitDiscreteProblem(sys, [], tspan) + @test prob.u0 == [1.0, 1.0] + @variables x(t) + @mtkbuild sys = ImplicitDiscreteSystem([x(k) ~ x(k) * x(k - 1) - 3], t) + @test_throws ErrorException prob=ImplicitDiscreteProblem(sys, [], tspan) +end + +@testset "System with algebraic equations" begin + @variables x(t) y(t) + eqs = [x(k) ~ x(k - 1) + x(k - 2), + x^2 ~ 1 - y^2] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + f = ImplicitDiscreteFunction(sys) + + function correct_f(u_next, u, p, t) + [u[2] - u_next[1], + u[1] + u[2] - u_next[2], + 1 - (u_next[1] + u_next[2])^2 - u_next[3]^2] + end + + for _ in 1:10 + u_next = rand(rng, 3) + u = rand(rng, 3) + @test correct_f(u_next, u, [], 0.0) ≈ f(u_next, u, [], 0.0) + end + + # Initialization is satisfied. + prob = ImplicitDiscreteProblem( + sys, [x(k - 1) => 0.3, x(k - 2) => 0.4], (0, 10), guesses = [y => 1]) + @test length(equations(prob.f.initialization_data.initializeprob.f.sys)) == 1 +end + +@testset "Handle observables in function codegen" begin + # Observable appears in differential equation + @variables x(t) y(t) z(t) + eqs = [x(k) ~ x(k - 1) + x(k - 2), + y(k) ~ x(k) + x(k - 2) * z(k - 1), + x + y + z ~ 2] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + @test length(unknowns(sys)) == length(equations(sys)) == 3 + @test occursin("var\"y(t)\"", string(ImplicitDiscreteFunctionExpr(sys))) + + # Shifted observable that appears in algebraic equation is properly handled. + eqs = [z(k) ~ x(k) + sin(x(k)), + y(k) ~ x(k - 1) + x(k - 2), + z(k) * x(k) ~ 3] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + @test occursin("var\"Shift(t, 1)(z(t))\"", string(ImplicitDiscreteFunctionExpr(sys))) +end diff --git a/test/index_cache.jl b/test/index_cache.jl new file mode 100644 index 0000000000..455203d759 --- /dev/null +++ b/test/index_cache.jl @@ -0,0 +1,120 @@ +using ModelingToolkit, SymbolicIndexingInterface, SciMLStructures +using ModelingToolkit: t_nounits as t + +# Ensure indexes of array symbolics are cached appropriately +@variables x(t)[1:2] +@named sys = ODESystem(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = ODESystem(Equation[], t, [x...], []) +sys2 = complete(sys) +for sys in [sys1, sys2] + for (sym, idx) in [(x, 1:2), (x[1], 1), (x[2], 2)] + @test is_variable(sys, sym) + @test variable_index(sys, sym) == idx + end +end + +@variables x(t)[1:2, 1:2] +@named sys = ODESystem(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = ODESystem(Equation[], t, [x...], []) +sys2 = complete(sys) +for sys in [sys1, sys2] + @test is_variable(sys, x) + @test variable_index(sys, x) == [1 3; 2 4] + for i in eachindex(x) + @test is_variable(sys, x[i]) + @test variable_index(sys, x[i]) == variable_index(sys, x)[i] + end +end + +# Ensure Symbol to symbolic map is correct +@parameters p1 p2[1:2] p3::String +@variables x(t) y(t)[1:2] z(t) + +@named sys = ODESystem(Equation[], t, [x, y, z], [p1, p2, p3]) +sys = complete(sys) + +ic = ModelingToolkit.get_index_cache(sys) + +@test isequal(ic.symbol_to_variable[:p1], p1) +@test isequal(ic.symbol_to_variable[:p2], p2) +@test isequal(ic.symbol_to_variable[:p3], p3) +@test isequal(ic.symbol_to_variable[:x], x) +@test isequal(ic.symbol_to_variable[:y], y) +@test isequal(ic.symbol_to_variable[:z], z) + +@testset "tunable_parameters is ordered" begin + @parameters p q[1:3] r[1:2, 1:2] s [tunable = false] + @named sys = ODESystem(Equation[], t, [], [p, q, r, s]) + sys = complete(sys) + @test all(splat(isequal), zip(tunable_parameters(sys), parameters(sys)[1:3])) + + offset = 1 + for par in tunable_parameters(sys) + idx = parameter_index(sys, par) + @test idx.portion isa SciMLStructures.Tunable + if Symbolics.isarraysymbolic(par) + @test vec(idx.idx) == offset:(offset + length(par) - 1) + else + @test idx.idx == offset + end + offset += length(par) + end +end + +@testset "reorder_dimension_by_tunables" begin + @parameters p q[1:3] r[1:2, 1:2] s [tunable = false] + @named sys = ODESystem(Equation[], t, [], [p, q, r, s]) + src = ones(8) + dst = zeros(8) + # system must be complete... + @test_throws ArgumentError reorder_dimension_by_tunables!(dst, sys, src, [p, q, r]) + @test_throws ArgumentError reorder_dimension_by_tunables(sys, src, [p, q, r]) + sys = complete(sys; split = false) + # with split = true... + @test_throws ArgumentError reorder_dimension_by_tunables!(dst, sys, src, [p, q, r]) + @test_throws ArgumentError reorder_dimension_by_tunables(sys, src, [p, q, r]) + sys = complete(sys) + # and the arrays must have matching size + @test_throws ArgumentError reorder_dimension_by_tunables!( + zeros(2, 4), sys, src, [p, q, r]) + + ps = MTKParameters(sys, [p => 1.0, q => 3ones(3), r => 4ones(2, 2), s => 0.0]) + src = ps.tunable + reorder_dimension_by_tunables!(dst, sys, src, [q, r, p]) + @test dst ≈ vcat(3ones(3), 4ones(4), 1.0) + @test reorder_dimension_by_tunables(sys, src, [r, p, q]) ≈ vcat(4ones(4), 1.0, 3ones(3)) + reorder_dimension_by_tunables!(dst, sys, src, [q[1], r[:, 1], q[2], r[:, 2], q[3], p]) + @test dst ≈ vcat(3.0, 4ones(2), 3.0, 4ones(2), 3.0, 1.0) + src = stack([copy(ps.tunable) for i in 1:5]; dims = 1) + dst = zeros(size(src)) + reorder_dimension_by_tunables!(dst, sys, src, [r, q, p]; dim = 2) + @test dst ≈ stack([vcat(4ones(4), 3ones(3), 1.0) for i in 1:5]; dims = 1) +end + +mutable struct ParamTest + y::Any +end +(pt::ParamTest)(x) = pt.y - x +@testset "Issue#3215: Callable discrete parameter" begin + function update_affect!(integ, u, p, ctx) + integ.p[p.p_1].y = integ.t + end + + tp1 = typeof(ParamTest(1)) + @parameters (p_1::tp1)(..) = ParamTest(1) + @variables x(ModelingToolkit.t_nounits) = 0 + + event1 = [1.0, 2, 3] => (update_affect!, [], [p_1], [p_1], nothing) + + @named sys = ODESystem([ + ModelingToolkit.D_nounits(x) ~ p_1(x) + ], + ModelingToolkit.t_nounits; + discrete_events = [event1] + ) + ss = @test_nowarn complete(sys) + @test length(parameters(ss)) == 1 + @test !is_timeseries_parameter(ss, p_1) +end diff --git a/test/initial_values.jl b/test/initial_values.jl new file mode 100644 index 0000000000..79b6b8e067 --- /dev/null +++ b/test/initial_values.jl @@ -0,0 +1,283 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, get_u0 +using OrdinaryDiffEq +using DataInterpolations +using SymbolicIndexingInterface: getu + +@variables x(t)[1:3]=[1.0, 2.0, 3.0] y(t) z(t)[1:2] + +@mtkbuild sys=ODESystem([D(x) ~ t * x], t) simplify=false +@test get_u0(sys, [])[1] == [1.0, 2.0, 3.0] +@test get_u0(sys, [x => [2.0, 3.0, 4.0]])[1] == [2.0, 3.0, 4.0] +@test get_u0(sys, [x[1] => 2.0, x[2] => 3.0, x[3] => 4.0])[1] == [2.0, 3.0, 4.0] +@test get_u0(sys, [2.0, 3.0, 4.0])[1] == [2.0, 3.0, 4.0] + +@mtkbuild sys=ODESystem([ + D(x) ~ 3x, + D(y) ~ t, + D(z[1]) ~ z[2] + t, + D(z[2]) ~ y + z[1] + ], t) simplify=false + +@test_throws ModelingToolkit.MissingVariablesError get_u0(sys, []) +getter = getu(sys, [x..., y, z...]) +@test getter(get_u0(sys, [y => 4.0, z => [5.0, 6.0]])[1]) == collect(1.0:6.0) +@test getter(get_u0(sys, [y => 4.0, z => [3y, 4y]])[1]) == [1.0, 2.0, 3.0, 4.0, 12.0, 16.0] +@test getter(get_u0(sys, [y => 3.0, z[1] => 3y, z[2] => 2x[1]])[1]) == + [1.0, 2.0, 3.0, 3.0, 9.0, 2.0] + +@variables w(t) +@parameters p1 p2 + +@test getter(get_u0(sys, [y => 2p1, z => [3y, 2p2]], [p1 => 5.0, p2 => 6.0])[1]) == + [1.0, 2.0, 3.0, 10.0, 30.0, 12.0] +@test_throws Any getter(get_u0(sys, [y => 2w, w => 3.0, z[1] => 2p1, z[2] => 3p2])) +@test getter(get_u0( + sys, [y => 2w, w => 3.0, z[1] => 2p1, z[2] => 3p2], [p1 => 3.0, p2 => 4.0])[1]) == + [1.0, 2.0, 3.0, 6.0, 6.0, 12.0] + +# Issue#2566 +@variables X(t) +@parameters p1 p2 p3 + +p_vals = [p1 => 1.0, p2 => 2.0] +u_vals = [X => 3.0] + +var_vals = [p1 => 1.0, p2 => 2.0, X => 3.0] +desired_values = [p1, p2, p3] +defaults = Dict([p3 => X]) +vals = ModelingToolkit.varmap_to_vars(var_vals, desired_values; defaults = defaults) +@test vals == [1.0, 2.0, 3.0] + +# Issue#2565 +# Create ODESystem. +@variables X1(t) X2(t) +@parameters k1 k2 Γ[1:1]=X1 + X2 +eq = D(X1) ~ -k1 * X1 + k2 * (-X1 + Γ[1]) +obs = X2 ~ Γ[1] - X1 +@mtkbuild osys_m = ODESystem([eq], t, [X1], [k1, k2, Γ[1]]; observed = [X2 ~ Γ[1] - X1]) + +# Creates ODEProblem. +u0 = [X1 => 1.0, X2 => 2.0] +tspan = (0.0, 1.0) +ps = [k1 => 1.0, k2 => 5.0] +# Broken since we need both X1 and X2 to initialize Γ but this makes the initialization system +# overdetermined because parameter initialization isn't in yet +@test_warn "overdetermined" oprob=ODEProblem(osys_m, u0, tspan, ps) + +# Make sure it doesn't error on array variables with unspecified size +@parameters p::Vector{Real} q[1:3] +varmap = Dict(p => ones(3), q => 2ones(3)) +cvarmap = ModelingToolkit.canonicalize_varmap(varmap) +target_varmap = Dict(p => ones(3), q => 2ones(3), q[1] => 2.0, q[2] => 2.0, q[3] => 2.0) +@test cvarmap == target_varmap + +# Initialization of ODEProblem with dummy derivatives of multidimensional arrays +# Issue#1283 +@variables z(t)[1:2, 1:2] +eqs = [D(D(z)) ~ ones(2, 2)] +@mtkbuild sys = ODESystem(eqs, t) +@test_nowarn ODEProblem(sys, [z => zeros(2, 2), D(z) => ones(2, 2)], (0.0, 10.0)) + +# Initialization with defaults involving parameters that are not part of the system +# Issue#2817 +@parameters A1 A2 B1 B2 +@variables x1(t) x2(t) +@mtkbuild sys = ODESystem( + [ + x1 ~ B1, + x2 ~ B2 + ], t; defaults = [ + A2 => 1 - A1, + B1 => A1, + B2 => A2 + ]) +prob = ODEProblem(sys, [], (0.0, 1.0), [A1 => 0.3]) +@test prob.ps[B1] == 0.3 +@test prob.ps[B2] == 0.7 + +@testset "default=nothing is skipped" begin + @parameters p = nothing + @variables x(t)=nothing y(t) + for sys in [ + ODESystem(Equation[], t, [x, y], [p]; defaults = [y => nothing], name = :osys), + SDESystem(Equation[], [], t, [x, y], [p]; defaults = [y => nothing], name = :ssys), + JumpSystem(Equation[], t, [x, y], [p]; defaults = [y => nothing], name = :jsys), + NonlinearSystem(Equation[], [x, y], [p]; defaults = [y => nothing], name = :nsys), + OptimizationSystem( + Equation[], [x, y], [p]; defaults = [y => nothing], name = :optsys), + ConstraintsSystem( + Equation[], [x, y], [p]; defaults = [y => nothing], name = :conssys) + ] + @test isempty(ModelingToolkit.defaults(sys)) + end +end + +# Using indepvar in initialization +# Issue#2799 +@variables x(t) +@parameters p +@mtkbuild sys = ODESystem([D(x) ~ p], t; defaults = [x => t, p => 2t]) +prob = ODEProblem(sys, [], (1.0, 2.0), []) +@test prob[x] == 1.0 +@test prob.ps[p] == 2.0 + +@testset "Array of symbolics is unwrapped" begin + @variables x(t)[1:2] y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t], t; defaults = [x => [y, 3.0]]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test eltype(prob.u0) <: Float64 + prob = ODEProblem(sys, [x => [y, 4.0], y => 2.0], (0.0, 1.0)) + @test eltype(prob.u0) <: Float64 +end + +@testset "split=false systems with all parameter defaults" begin + @variables x(t) = 1.0 + @parameters p=1.0 q=2.0 r=3.0 + @mtkbuild sys=ODESystem(D(x) ~ p * x + q * t + r, t) split=false + prob = @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + @test prob.p isa Vector{Float64} +end + +@testset "Issue#3153" begin + @variables x(t) y(t) + @parameters c1 c2 c3 + eqs = [D(x) ~ y, + y ~ ifelse(t < c1, 0.0, (-c1 + t)^(c3))] + sps = [x, y] + ps = [c1, c2, c3] + @mtkbuild osys = ODESystem(eqs, t, sps, ps) + u0map = [x => 1.0] + pmap = [c1 => 5.0, c2 => 1.0, c3 => 1.2] + oprob = ODEProblem(osys, u0map, (0.0, 10.0), pmap) +end + +@testset "Cyclic dependency checking and substitution limits" begin + @variables x(t) y(t) + @mtkbuild sys = ODESystem( + [D(x) ~ x, D(y) ~ y], t; initialization_eqs = [x ~ 2y + 3, y ~ 2x], + guesses = [x => 2y, y => 2x]) + @test_warn ["Cycle", "unknowns", "x", "y"] try + ODEProblem(sys, [], (0.0, 1.0), warn_cyclic_dependency = true) + catch + end + @test_throws ModelingToolkit.UnexpectedSymbolicValueInVarmap ODEProblem( + sys, [x => 2y + 1, y => 2x], (0.0, 1.0); build_initializeprob = false) + + @parameters p q + @mtkbuild sys = ODESystem( + [D(x) ~ x * p, D(y) ~ y * q], t; guesses = [p => 1.0, q => 2.0]) + # "unknowns" because they are initialization unknowns + @test_warn ["Cycle", "unknowns", "p", "q"] try + ODEProblem(sys, [x => 1, y => 2], (0.0, 1.0), + [p => 2q, q => 3p]; warn_cyclic_dependency = true) + catch + end + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + sys, [x => 1, y => 2], (0.0, 1.0), [p => 2q, q => 3p]) +end + +@testset "`add_fallbacks!` checks scalarized array parameters correctly" begin + @variables x(t)[1:2] + @parameters p[1:2, 1:2] + @mtkbuild sys = ODESystem(D(x) ~ p * x, t) + # used to throw a `MethodError` complaining about `getindex(::Nothing, ::CartesianIndex{2})` + @test_throws ModelingToolkit.MissingParametersError ODEProblem( + sys, [x => ones(2)], (0.0, 1.0)) +end + +@testset "Unscalarized default for scalarized observed variable" begin + @parameters p[1:4] = rand(4) + @variables x(t)[1:4] y(t)[1:2] + eqs = [ + D(x) ~ x, + y[1] ~ x[3], + y[2] ~ x[4] + ] + @mtkbuild sys = ODESystem(eqs, t; defaults = [x => vcat(ones(2), y), y => x[1:2] ./ 2]) + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.u[1] ≈ [1.0, 1.0, 0.5, 0.5] +end + +@testset "Missing/cyclic guesses throws error" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + pend, [x => 1], (0, 1), [g => 1], guesses = [y => λ, λ => y + 1]) + ODEProblem(pend, [x => 1], (0, 1), [g => 1], guesses = [y => λ, λ => 0.5]) + + # Throw multiple if multiple are missing + @variables a(t) b(t) c(t) d(t) e(t) + eqs = [D(a) ~ b, D(b) ~ c, D(c) ~ d, D(d) ~ e, D(e) ~ 1] + @mtkbuild sys = ODESystem(eqs, t) + @test_throws ["a(t)", "c(t)"] ODEProblem( + sys, [e => 2, a => b, b => a + 1, c => d, d => c + 1], (0, 1)) +end + +@testset "Issue#3490: `remake` works with callable parameters" begin + ts = collect(0.0:0.1:10.0) + spline = LinearInterpolation(ts .^ 2, ts) + Tspline = typeof(spline) + @variables x(t) + @parameters (interp::Tspline)(..) + + @mtkbuild sys = ODESystem(D(x) ~ interp(t), t) + + prob = ODEProblem(sys, [x => 0.0], (0.0, 1.0), [interp => spline]) + spline2 = LinearInterpolation(ts .^ 2, ts .^ 2) + p_new = [interp => spline2] + prob2 = remake(prob; p = p_new) + @test prob2.ps[interp] == spline2 +end + +@testset "Issue#3523: don't substitute inside initial in `build_operating_point!`" begin + @variables (X(t))[1:2] + @parameters p[1:2] + eqs = [ + 0 ~ p[1] - X[1], + 0 ~ p[2] - X[2] + ] + @named nlsys = NonlinearSystem(eqs) + nlsys = complete(nlsys) + + # Creates the `NonlinearProblem`. + u0 = [X => [1.0, 2.0]] + ps = [p => [4.0, 5.0]] + @test_nowarn NonlinearProblem(nlsys, u0, ps) +end + +@testset "Issue#3553: Retain `Float32` initial values" begin + @parameters p d + @variables X(t) + eqs = [D(X) ~ p - d * X] + @mtkbuild osys = ODESystem(eqs, t) + u0 = [X => 1.0f0] + ps = [p => 1.0f0, d => 2.0f0] + oprob = ODEProblem(osys, u0, (0.0f0, 1.0f0), ps) + sol = solve(oprob) + @test eltype(oprob.u0) == Float32 + @test eltype(eltype(sol.u)) == Float32 +end + +@testset "Array initials and scalar parameters with `split = false`" begin + @variables x(t)[1:2] + @parameters p + @mtkbuild sys=ODESystem([D(x[1]) ~ x[1], D(x[2]) ~ x[2] + p], t) split=false + ps = Set(parameters(sys; initial_parameters = true)) + @test length(ps) == 5 + for i in 1:2 + @test Initial(x[i]) in ps + @test Initial(D(x[i])) in ps + end + @test p in ps + prob = ODEProblem(sys, [x => ones(2)], (0.0, 1.0), [p => 1.0]) + @test prob.p isa Vector{Float64} + @test length(prob.p) == 5 +end diff --git a/test/initializationsystem.jl b/test/initializationsystem.jl new file mode 100644 index 0000000000..4ade3481cb --- /dev/null +++ b/test/initializationsystem.jl @@ -0,0 +1,1498 @@ +using ModelingToolkit, OrdinaryDiffEq, NonlinearSolve, Test +using StochasticDiffEq, DelayDiffEq, StochasticDelayDiffEq, JumpProcesses +using ForwardDiff +using SymbolicIndexingInterface, SciMLStructures +using SciMLStructures: Tunable +using ModelingToolkit: t_nounits as t, D_nounits as D, observed +using DynamicQuantities + +@parameters g +@variables x(t) y(t) [state_priority = 10] λ(t) +eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] +@mtkbuild pend = ODESystem(eqs, t) + +initprob = ModelingToolkit.InitializationProblem(pend, 0.0, [], [g => 1]; + guesses = [ModelingToolkit.missing_variable_defaults(pend); x => 1; y => 0.2]) +conditions = getfield.(equations(initprob.f.sys), :rhs) + +@test initprob isa NonlinearLeastSquaresProblem +sol = solve(initprob) +@test SciMLBase.successful_retcode(sol) +@test maximum(abs.(sol[conditions])) < 1e-14 + +@test_throws ModelingToolkit.ExtraVariablesSystemException ModelingToolkit.InitializationProblem( + pend, 0.0, [], [g => 1]; + guesses = [ModelingToolkit.missing_variable_defaults(pend); x => 1; y => 0.2], + fully_determined = true) + +initprob = ModelingToolkit.InitializationProblem(pend, 0.0, [x => 1, y => 0], [g => 1]; + guesses = ModelingToolkit.missing_variable_defaults(pend)) +@test initprob isa NonlinearLeastSquaresProblem +sol = solve(initprob) +@test SciMLBase.successful_retcode(sol) +@test all(iszero, sol.u) +@test maximum(abs.(sol[conditions])) < 1e-14 + +initprob = ModelingToolkit.InitializationProblem( + pend, 0.0, [], [g => 1]; guesses = ModelingToolkit.missing_variable_defaults(pend)) +@test initprob isa NonlinearLeastSquaresProblem +sol = solve(initprob) +@test !SciMLBase.successful_retcode(sol) + +@test_throws ModelingToolkit.ExtraVariablesSystemException ModelingToolkit.InitializationProblem( + pend, 0.0, [], [g => 1]; guesses = ModelingToolkit.missing_variable_defaults(pend), + fully_determined = true) + +prob = ODEProblem(pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], + guesses = ModelingToolkit.missing_variable_defaults(pend)) +prob.f.initializeprob isa NonlinearProblem +sol = solve(prob.f.initializeprob) +@test maximum(abs.(sol[conditions])) < 1e-14 +sol = solve(prob, Rodas5P()) +@test maximum(abs.(sol[conditions][1])) < 1e-14 + +prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], + guesses = ModelingToolkit.missing_variable_defaults(pend)) +prob.f.initializeprob isa NonlinearLeastSquaresProblem +sol = solve(prob.f.initializeprob) +@test maximum(abs.(sol[conditions])) < 1e-14 +sol = solve(prob, Rodas5P()) +@test maximum(abs.(sol[conditions][1])) < 1e-14 + +@test_throws ModelingToolkit.ExtraVariablesSystemException ODEProblem( + pend, [x => 1], (0.0, 1.5), [g => 1], + guesses = ModelingToolkit.missing_variable_defaults(pend), + fully_determined = true) + +@connector Port begin + p(t) + dm(t) = 0, [connect = Flow] +end + +@connector Flange begin + dx(t) = 0 + f(t), [connect = Flow] +end + +# Components ---- +@mtkmodel Orifice begin + @parameters begin + Cₒ = 2.7 + Aₒ = 0.00094 + ρ₀ = 1000 + p′ = 0 + end + @variables begin + dm(t) = 0 + p₁(t) = p′ + p₂(t) = p′ + end + @components begin + port₁ = Port(p = p′) + port₂ = Port(p = p′) + end + begin + u = dm / (ρ₀ * Aₒ) + end + @equations begin + dm ~ +port₁.dm + dm ~ -port₂.dm + p₁ ~ port₁.p + p₂ ~ port₂.p + + p₁ - p₂ ~ (1 / 2) * ρ₀ * u^2 * Cₒ + end +end + +@mtkmodel Volume begin + @parameters begin + A = 0.1 + ρ₀ = 1000 + β = 2e9 + direction = +1 + p′ + x′ + end + @variables begin + p(t) + x(t) = x′ + dm(t) = 0 + f(t) = p′ * A + dx(t) = 0 + r(t), [guess = 1000] + dr(t), [guess = 1000] + end + @components begin + port = Port(p = p′) + flange = Flange(f = -p′ * A * direction) + end + @equations begin + D(x) ~ dx + D(r) ~ dr + + p ~ +port.p + dm ~ +port.dm # mass is entering + f ~ -flange.f * direction # force is leaving + dx ~ flange.dx * direction + + r ~ ρ₀ * (1 + p / β) + dm ~ (r * dx * A) + (dr * x * A) + f ~ p * A + end +end + +@mtkmodel Mass begin + @parameters begin + m = 100 + f′ + end + @variables begin + f(t) = f′ + x(t) = 0 + dx(t) = 0 + ẍ(t) = f′ / m + end + @components begin + flange = Flange(f = f′) + end + @equations begin + D(x) ~ dx + D(dx) ~ ẍ + + f ~ flange.f + dx ~ flange.dx + + m * ẍ ~ f + end +end + +@mtkmodel Actuator begin + @parameters begin + p₁′ + p₂′ + end + begin #constants + x′ = 0.5 + A = 0.1 + end + @components begin + port₁ = Port(p = p₁′) + port₂ = Port(p = p₂′) + vol₁ = Volume(p′ = p₁′, x′ = x′, direction = -1) + vol₂ = Volume(p′ = p₂′, x′ = x′, direction = +1) + mass = Mass(f′ = (p₂′ - p₁′) * A) + flange = Flange(f = 0) + end + @equations begin + connect(port₁, vol₁.port) + connect(port₂, vol₂.port) + connect(vol₁.flange, vol₂.flange, mass.flange, flange) + end +end + +@mtkmodel Source begin + @parameters begin + p′ + end + @components begin + port = Port(p = p′) + end + @equations begin + port.p ~ p′ + end +end + +@mtkmodel Damper begin + @parameters begin + c = 1000 + end + @components begin + flange = Flange(f = 0) + end + @equations begin + flange.f ~ c * flange.dx + end +end + +@mtkmodel HydraulicSystem begin + @components begin + res₁ = Orifice(p′ = 300e5) + res₂ = Orifice(p′ = 0) + act = Actuator(p₁′ = 300e5, p₂′ = 0) + src = Source(p′ = 300e5) + snk = Source(p′ = 0) + dmp = Damper() + end + @equations begin + connect(src.port, res₁.port₁) + connect(res₁.port₂, act.port₁) + connect(act.port₂, res₂.port₁) + connect(res₂.port₂, snk.port) + connect(dmp.flange, act.flange) + end +end + +@mtkbuild sys = HydraulicSystem() +initprob = ModelingToolkit.InitializationProblem(sys, 0.0) +conditions = getfield.(equations(initprob.f.sys), :rhs) + +@test initprob isa NonlinearLeastSquaresProblem +@test length(initprob.u0) == 2 +initsol = solve(initprob, reltol = 1e-12, abstol = 1e-12) +@test SciMLBase.successful_retcode(initsol) +@test maximum(abs.(initsol[conditions])) < 1e-14 + +@test_throws ModelingToolkit.ExtraEquationsSystemException ModelingToolkit.InitializationProblem( + sys, 0.0, fully_determined = true) + +allinit = unknowns(sys) .=> initsol[unknowns(sys)] +prob = ODEProblem(sys, allinit, (0, 0.1)) +sol = solve(prob, Rodas5P(), initializealg = BrownFullBasicInit()) +# If initialized incorrectly, then it would be InitialFailure +@test sol.retcode == SciMLBase.ReturnCode.Unstable +@test maximum(abs.(initsol[conditions][1])) < 1e-14 + +prob = ODEProblem(sys, allinit, (0, 0.1)) +prob = ODEProblem(sys, [], (0, 0.1), check = false) + +@test_throws ModelingToolkit.ExtraEquationsSystemException ODEProblem( + sys, [], (0, 0.1), fully_determined = true) + +sol = solve(prob, Rodas5P()) +# If initialized incorrectly, then it would be InitialFailure +@test sol.retcode == SciMLBase.ReturnCode.Unstable +@test maximum(abs.(initsol[conditions][1])) < 1e-14 + +@connector Flange begin + dx(t), [guess = 0] + f(t), [guess = 0, connect = Flow] +end + +@mtkmodel Mass begin + @parameters begin + m = 100 + end + @variables begin + dx(t) + f(t), [guess = 0] + end + @components begin + flange = Flange() + end + @equations begin + # connectors + flange.dx ~ dx + flange.f ~ -f + + # physics + f ~ m * D(dx) + end +end + +@mtkmodel Damper begin + @parameters begin + d = 1 + end + @variables begin + dx(t), [guess = 0] + f(t), [guess = 0] + end + @components begin + flange = Flange() + end + @equations begin + # connectors + flange.dx ~ dx + flange.f ~ -f + + # physics + f ~ d * dx + end +end + +@mtkmodel MassDamperSystem begin + @components begin + mass = Mass(; dx = 100, m = 10) + damper = Damper(; d = 1) + end + @equations begin + connect(mass.flange, damper.flange) + end +end + +@mtkbuild sys = MassDamperSystem() +initprob = ModelingToolkit.InitializationProblem(sys, 0.0) +@test initprob isa NonlinearProblem +initsol = solve(initprob, reltol = 1e-12, abstol = 1e-12) +@test SciMLBase.successful_retcode(initsol) + +allinit = unknowns(sys) .=> initsol[unknowns(sys)] +prob = ODEProblem(sys, allinit, (0, 0.1)) +sol = solve(prob, Rodas5P()) +# If initialized incorrectly, then it would be InitialFailure +@test sol.retcode == SciMLBase.ReturnCode.Success + +prob = ODEProblem(sys, [], (0, 0.1)) +sol = solve(prob, Rodas5P()) +@test sol.retcode == SciMLBase.ReturnCode.Success + +### Ensure that non-DAEs still throw for missing variables without the initialize system + +@parameters σ ρ β +@variables x(t) y(t) z(t) + +eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@mtkbuild sys = ODESystem(eqs, t) + +u0 = [D(x) => 2.0, + y => 0.0, + z => 0.0] + +p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +tspan = (0.0, 100.0) +@test_throws ModelingToolkit.IncompleteInitializationError prob=ODEProblem( + sys, u0, tspan, p, jac = true) + +# DAE Initialization on ODE with nonlinear system for initial conditions +# https://github.com/SciML/ModelingToolkit.jl/issues/2508 + +using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +function System2(; name) + vars = @variables begin + dx(t), [guess = 0] + ddx(t), [guess = 0] + end + eqs = [D(dx) ~ ddx + 0 ~ ddx + dx + 1] + return ODESystem(eqs, t, vars, []; name) +end + +@mtkbuild sys = System2() +prob = ODEProblem(sys, [sys.dx => 1], (0, 1)) # OK +prob = ODEProblem(sys, [sys.ddx => -2], (0, 1), guesses = [sys.dx => 1]) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +@test sol.u[1] == [1.0] + +## Late binding initialization_eqs + +function System3(; name) + vars = @variables begin + dx(t), [guess = 0] + ddx(t), [guess = 0] + end + eqs = [D(dx) ~ ddx + 0 ~ ddx + dx + 1] + initialization_eqs = [ + ddx ~ -2 + ] + return ODESystem(eqs, t, vars, []; name, initialization_eqs) +end + +@mtkbuild sys = System3() +prob = ODEProblem(sys, [], (0, 1), guesses = [sys.dx => 1]) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +@test sol.u[1] == [1.0] + +# Steady state initialization +@testset "Steady state initialization" begin + @parameters σ ρ β + @variables x(t) y(t) z(t) + + eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + @named sys = ODESystem(eqs, t) + sys = structural_simplify(sys) + + u0 = [D(x) => 2.0, + x => 1.0, + D(y) => 0.0, + z => 0.0] + + p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + + tspan = (0.0, 0.2) + prob_mtk = ODEProblem(sys, u0, tspan, p) + sol = solve(prob_mtk, Tsit5()) + @test sol[x * (ρ - z) - y][1] == 0.0 + + prob_mtk.ps[Initial(D(y))] = 1.0 + sol = solve(prob_mtk, Tsit5()) + @test sol[x * (ρ - z) - y][1] == 1.0 +end + +@variables x(t) y(t) z(t) +@parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + +eqs = [D(x) ~ α * x - β * x * y + D(y) ~ -γ * y + δ * x * y + z ~ x + y] + +@named sys = ODESystem(eqs, t) +simpsys = structural_simplify(sys) +tspan = (0.0, 10.0) + +prob = ODEProblem(simpsys, [D(x) => 0.0, y => 0.0], tspan, guesses = [x => 0.0]) +sol = solve(prob, Tsit5()) +@test sol.u[1] == [0.0, 0.0] + +# Initialize with an observed variable +prob = ODEProblem(simpsys, [z => 0.0], tspan, guesses = [x => 2.0, y => 4.0]) +sol = solve(prob, Tsit5()) +@test sol.u[1] == [0.0, 0.0] + +prob = ODEProblem(simpsys, [z => 1.0, y => 1.0], tspan, guesses = [x => 2.0]) +sol = solve(prob, Tsit5()) +@test sol.u[1] == [0.0, 1.0] + +# This should warn, but logging tests can't be marked as broken +@test_logs prob = ODEProblem(simpsys, [], tspan, guesses = [x => 2.0]) + +# Late Binding initialization_eqs +# https://github.com/SciML/ModelingToolkit.jl/issues/2787 + +@parameters g +@variables x(t) y(t) [state_priority = 10] λ(t) +eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] +@mtkbuild pend = ODESystem(eqs, t) + +prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], + guesses = [λ => 0, y => 1], initialization_eqs = [y ~ 1]) + +unsimp = generate_initializesystem(pend; u0map = [x => 1], initialization_eqs = [y ~ 1]) +sys = structural_simplify(unsimp; fully_determined = false) +@test length(equations(sys)) in (3, 4) # could be either depending on tearing + +# Extend two systems with initialization equations and guesses +# https://github.com/SciML/ModelingToolkit.jl/issues/2845 +@variables x(t) y(t) +@named sysx = ODESystem([D(x) ~ 0], t; initialization_eqs = [x ~ 1]) +@named sysy = ODESystem([D(y) ~ 0], t; initialization_eqs = [y^2 ~ 2], guesses = [y => 1]) +sys = extend(sysx, sysy) +@test length(equations(generate_initializesystem(sys))) == 2 +@test length(ModelingToolkit.guesses(sys)) == 1 + +# https://github.com/SciML/ModelingToolkit.jl/issues/2873 +@testset "Error on missing defaults" begin + @variables x(t) y(t) + @named sys = ODESystem([x^2 + y^2 ~ 25, D(x) ~ 1], t) + ssys = structural_simplify(sys) + @test_throws ModelingToolkit.MissingVariablesError ODEProblem( + ssys, [x => 3], (0, 1), []) # y should have a guess +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/3025 +@testset "Override defaults when setting initial conditions with unknowns(sys) or similar" begin + @variables x(t) y(t) + + # system 1 should solve to x = 1 + ics1 = [x => 1] + sys1 = ODESystem([D(x) ~ 0], t; defaults = ics1, name = :sys1) |> structural_simplify + prob1 = ODEProblem(sys1, [], (0.0, 1.0), []) + sol1 = solve(prob1, Tsit5()) + @test all(sol1[x] .== 1) + + # system 2 should solve to x = y = 2 + sys2 = extend( + sys1, + ODESystem([D(y) ~ 0], t; initialization_eqs = [y ~ 2], name = :sys2) + ) |> structural_simplify + ics2 = unknowns(sys1) .=> 2 # should be equivalent to "ics2 = [x => 2]" + prob2 = ODEProblem(sys2, ics2, (0.0, 1.0), []; fully_determined = true) + sol2 = solve(prob2, Tsit5()) + @test all(sol2[x] .== 2) && all(sol2[y] .== 2) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/3029 +@testset "Derivatives in initialization equations" begin + @variables x(t) + sys = ODESystem( + [D(D(x)) ~ 0], t; initialization_eqs = [x ~ 0, D(x) ~ 1], name = :sys) |> + structural_simplify + @test_nowarn ODEProblem(sys, [], (0.0, 1.0), []) + + sys = ODESystem( + [D(D(x)) ~ 0], t; initialization_eqs = [x ~ 0, D(D(x)) ~ 0], name = :sys) |> + structural_simplify + @test_nowarn ODEProblem(sys, [D(x) => 1.0], (0.0, 1.0), []) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/3049 +@testset "Derivatives in initialization guesses" begin + for sign in [-1.0, +1.0] + @variables x(t) + sys = ODESystem( + [D(D(x)) ~ 0], t; + initialization_eqs = [D(x)^2 ~ 1, x ~ 0], guesses = [D(x) => sign], name = :sys + ) |> structural_simplify + prob = ODEProblem(sys, [], (0.0, 1.0), []) + sol = solve(prob, Tsit5()) + @test sol(1.0, idxs = sys.x) ≈ sign # system with D(x(0)) = ±1 should solve to x(1) = ±1 + end +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2619 +@parameters k1 k2 ω +@variables X(t) Y(t) +eqs_1st_order = [D(Y) + Y - ω ~ 0, + X + k1 ~ Y + k2] +eqs_2nd_order = [D(D(Y)) + 2ω * D(Y) + (ω^2) * Y ~ 0, + X + k1 ~ Y + k2] +@mtkbuild sys_1st_order = ODESystem(eqs_1st_order, t) +@mtkbuild sys_2nd_order = ODESystem(eqs_2nd_order, t) + +u0_1st_order_1 = [X => 1.0, Y => 2.0] +u0_1st_order_2 = [Y => 2.0] +u0_2nd_order_1 = [X => 1.0, Y => 2.0, D(Y) => 0.5] +u0_2nd_order_2 = [Y => 2.0, D(Y) => 0.5] +tspan = (0.0, 10.0) +ps = [ω => 0.5, k1 => 2.0, k2 => 3.0] + +oprob_1st_order_1 = ODEProblem(sys_1st_order, u0_1st_order_1, tspan, ps) +oprob_1st_order_2 = ODEProblem(sys_1st_order, u0_1st_order_2, tspan, ps) +oprob_2nd_order_1 = ODEProblem(sys_2nd_order, u0_2nd_order_1, tspan, ps) # gives sys_2nd_order +oprob_2nd_order_2 = ODEProblem(sys_2nd_order, u0_2nd_order_2, tspan, ps) + +@test solve(oprob_1st_order_1, Rosenbrock23()).retcode == + SciMLBase.ReturnCode.InitialFailure +@test solve(oprob_1st_order_2, Rosenbrock23())[Y][1] == 2.0 +@test solve(oprob_2nd_order_1, Rosenbrock23()).retcode == + SciMLBase.ReturnCode.InitialFailure +sol = solve(oprob_2nd_order_2, Rosenbrock23()) # retcode: Success +@test sol[Y][1] == 2.0 +@test sol[D(Y)][1] == 0.5 + +@testset "Vector in initial conditions" begin + @variables x(t)[1:5] y(t)[1:5] + @named sys = ODESystem([D(x) ~ x, D(y) ~ y], t; initialization_eqs = [y ~ -x]) + sys = structural_simplify(sys) + prob = ODEProblem(sys, [sys.x => ones(5)], (0.0, 1.0), []) + sol = solve(prob, Tsit5(), reltol = 1e-4) + @test all(sol(1.0, idxs = sys.x) .≈ +exp(1)) && all(sol(1.0, idxs = sys.y) .≈ -exp(1)) +end + +@testset "Initialization of parameters" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + # `System` constructor creates appropriate type with mtkbuild + # `Problem` and `alg` create the problem to test and allow calling `init` with + # the correct solver. + # `rhss` allows adding terms to the end of equations (only 2 equations allowed) to influence + # the system type (brownian vars to turn it into an SDE). + @testset "$Problem with $(SciMLBase.parameterless_type(alg))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + function test_parameter(prob, sym, val) + if prob.u0 !== nothing + @test init(prob, alg).ps[sym] ≈ val + end + @test solve(prob, alg).ps[sym] ≈ val + end + function test_initializesystem(sys, u0map, pmap, p, equation) + isys = ModelingToolkit.generate_initializesystem( + sys; u0map, pmap, guesses = ModelingToolkit.guesses(sys)) + @test is_variable(isys, p) + @test equation in equations(isys) || (0 ~ -equation.rhs) in equations(isys) + end + + u0map = Dict(x => 1.0, y => 1.0) + pmap = Dict() + pmap[q] = 1.0 + # `missing` default, equation from Problem + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => missing], guesses = [p => 1.0]) + pmap[p] = 2q + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + # `missing` default, provided guess + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; defaults = [p => missing], guesses = [p => 0.0]) + prob = Problem(sys, u0map, (0.0, 1.0)) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ p - x - y) + prob2 = remake(prob; u0 = u0map) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # `missing` to Problem, equation from default + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + pmap[p] = missing + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ 2q - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + # `missing` to Problem, provided guess + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [p => 0.0]) + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ x + y - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # No `missing`, default and guess + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 0.0]) + delete!(pmap, p) + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ 2q - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # Default overridden by Problem, guess provided + @mtkbuild sys = System( + [D(x) ~ q * x + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + _pmap = merge(pmap, Dict(p => q)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + test_parameter(prob, p, _pmap[q]) + test_initializesystem(sys, u0map, _pmap, p, 0 ~ q - p) + # Problem dependent value with guess, no `missing` + @mtkbuild sys = System( + [D(x) ~ y * q + p + rhss[1], D(y) ~ x * p + q + rhss[2]], t; guesses = [p => 0.0]) + _pmap = merge(pmap, Dict(p => 3q)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + test_parameter(prob, p, 3pmap[q]) + + # Should not be solved for: + # Override dependent default with direct value + @mtkbuild sys = System( + [D(x) ~ q * x + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + _pmap = merge(pmap, Dict(p => 1.0)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + @test prob.ps[p] ≈ 1.0 + initsys = prob.f.initialization_data.initializeprob.f.sys + @test is_parameter(initsys, p) + + # Non-floating point + @parameters r::Int s::Int + @mtkbuild sys = System( + [D(x) ~ s * x + rhss[1], D(y) ~ y * r + rhss[2]], t; defaults = [s => 2r], guesses = [s => 1.0]) + prob = Problem(sys, u0map, (0.0, 1.0), [r => 1]) + @test prob.ps[r] == 1 + @test prob.ps[s] == 2 + initsys = prob.f.initialization_data.initializeprob.f.sys + @test is_parameter(initsys, r) + @test is_parameter(initsys, s) + + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [p => 0.0]) + @test_throws ModelingToolkit.MissingParametersError Problem( + sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + + # Unsatisfiable initialization + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0), + [p => 2.0]; initialization_eqs = [x^2 + y^2 ~ 3]) + @test prob.f.initialization_data !== nothing + @test solve(prob, alg).retcode == ReturnCode.InitialFailure + cache = init(prob, alg) + @test solve!(cache).retcode == ReturnCode.InitialFailure + end + + @testset "Null system" begin + @variables x(t) y(t) s(t) + @parameters x0 y0 + @mtkbuild sys = ODESystem([x ~ x0, y ~ y0, s ~ x + y], t; guesses = [y0 => 0.0]) + prob = ODEProblem(sys, [s => 1.0], (0.0, 1.0), [x0 => 0.3, y0 => missing]) + # trivial initialization run immediately + @test prob.ps[y0] ≈ 0.7 + @test init(prob, Tsit5()).ps[y0] ≈ 0.7 + @test solve(prob, Tsit5()).ps[y0] ≈ 0.7 + end + + using ModelingToolkitStandardLibrary.Mechanical.TranslationalModelica: Fixed, Mass, + Spring, Force, + Damper + using ModelingToolkitStandardLibrary.Mechanical: TranslationalModelica as TM + using ModelingToolkitStandardLibrary.Blocks: Constant + + @named mass = TM.Mass(; m = 1.0, s = 1.0, v = 0.0, a = 0.0) + @named fixed = Fixed(; s0 = 0.0) + @named spring = Spring(; c = 2.0, s_rel0 = nothing) + @named gravity = Force() + @named constant = Constant(; k = 9.81) + @named damper = TM.Damper(; d = 0.1) + @mtkbuild sys = ODESystem( + [connect(fixed.flange, spring.flange_a), connect(spring.flange_b, mass.flange_a), + connect(mass.flange_a, gravity.flange), connect(constant.output, gravity.f), + connect(fixed.flange, damper.flange_a), connect(damper.flange_b, mass.flange_a)], + t; + systems = [fixed, spring, mass, gravity, constant, damper], + guesses = [spring.s_rel0 => 1.0]) + prob = ODEProblem(sys, [], (0.0, 1.0), [spring.s_rel0 => missing]) + # trivial initialization run immediately + @test prob.ps[spring.s_rel0] ≈ -3.905 + @test init(prob, Tsit5()).ps[spring.s_rel0] ≈ -3.905 + @test solve(prob, Tsit5()).ps[spring.s_rel0] ≈ -3.905 +end + +@testset "NonlinearSystem initialization" begin + nl_algs = [FastShortcutNonlinearPolyalg(), NewtonRaphson(), + Klement(), SimpleNewtonRaphson(), DFSane()] + nlls_algs = [FastShortcutNLLSPolyalg(), LevenbergMarquardt(), SimpleGaussNewton()] + + @testset "No initialization for variables" begin + @variables x=1.0 y=0.0 z=0.0 + @parameters σ=10.0 ρ=26.0 β=8 / 3 + + eqs = [0 ~ σ * (y - x), + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] + @mtkbuild ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) + + prob = NonlinearProblem(ns, []) + @test prob.f.initialization_data.update_initializeprob! === nothing + @test prob.f.initialization_data.initializeprobmap === nothing + @test prob.f.initialization_data.initializeprobpmap === nothing + for alg in nl_algs + @test SciMLBase.successful_retcode(solve(prob, alg)) + end + + prob = NonlinearLeastSquaresProblem(ns, []) + @test prob.f.initialization_data.update_initializeprob! === nothing + @test prob.f.initialization_data.initializeprobmap === nothing + @test prob.f.initialization_data.initializeprobpmap === nothing + for alg in nlls_algs + @test SciMLBase.successful_retcode(solve(prob, alg)) + end + end + + prob_alg_combinations = zip( + [NonlinearProblem, NonlinearLeastSquaresProblem], [nl_algs, nlls_algs]) + @testset "Parameter initialization" begin + function test_parameter(prob, alg, param, val) + integ = init(prob, alg) + @test integ.ps[param]≈val rtol=1e-5 + # some algorithms are a little temperamental + sol = solve(prob, alg) + @test sol.ps[param]≈val rtol=1e-5 + @test SciMLBase.successful_retcode(sol) + end + + @parameters p=2.0 q=missing [guess = 1.0] c=1.0 + @variables x=1.0 y=2.0 z=3.0 + + eqs = [0 ~ p * (y - x), + 0 ~ x * (q - z) - y, + 0 ~ x * y - c * z] + @mtkbuild sys = NonlinearSystem(eqs; initialization_eqs = [p^2 + q^2 + 2p * q ~ 0]) + # @mtkbuild sys = NonlinearSystem( + # [p * x^2 + q * y^3 ~ 0, x - q ~ 0]; defaults = [q => missing], + # guesses = [q => 1.0], initialization_eqs = [p^2 + q^2 + 2p * q ~ 0]) + + for (probT, algs) in prob_alg_combinations + prob = probT(sys, []) + @test prob.f.initialization_data !== nothing + @test prob.f.initialization_data.initializeprobmap === nothing + for alg in algs + test_parameter(prob, alg, q, -2.0) + end + + # `update_initializeprob!` works + prob.ps[p] = -2.0 + for alg in algs + test_parameter(prob, alg, q, 2.0) + end + prob.ps[p] = 2.0 + + # `remake` works + prob2 = remake(prob; p = [p => -2.0]) + @test prob2.f.initialization_data !== nothing + @test prob2.f.initialization_data.initializeprobmap === nothing + for alg in algs + test_parameter(prob2, alg, q, 2.0) + end + + # changing types works + ps = parameter_values(prob) + newps = SciMLStructures.replace(Tunable(), ps, ForwardDiff.Dual.(ps.tunable)) + prob3 = remake(prob; p = newps) + @test prob3.f.initialization_data !== nothing + @test eltype(state_values(prob3.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob3.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + end + end +end + +@testset "Update initializeprob parameters" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [x => 0.0, p => 0.0]) + prob = Problem(sys, [y => 1.0], (0.0, 1.0), [p => 3.0]) + @test prob.f.initialization_data.initializeprob.ps[p] ≈ 3.0 + @test init(prob, alg)[x] ≈ 2.0 + prob.ps[p] = 2.0 + @test prob.f.initialization_data.initializeprob.ps[p] ≈ 3.0 + @test init(prob, alg)[x] ≈ 1.0 + ModelingToolkit.defaults(prob.f.sys)[p] = missing + prob2 = remake(prob; u0 = [y => 1.0], p = [p => 3x]) + @test !is_variable(prob2.f.initialization_data.initializeprob, p) && + !is_parameter(prob2.f.initialization_data.initializeprob, p) + @test init(prob2, alg)[x] ≈ 0.5 + @test_nowarn solve(prob2, alg) + end +end + +@testset "Equations for dependent parameters" begin + @variables _x(..) + @parameters p q=5 r + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + @mtkbuild sys = System( + [D(x) ~ 2x + r + rhss], t; parameter_dependencies = [r ~ p + 2q, q ~ p + 3], + guesses = [p => 1.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => missing]) + @test length(equations(ModelingToolkit.get_parent(prob.f.initialization_data.initializeprob.f.sys))) == + 4 + integ = init(prob, alg) + @test integ.ps[p] ≈ 2 + end +end + +@testset "Re-creating initialization problem on remake" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; defaults = [p => missing], guesses = [ + x => 0.0, p => 0.0]) + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test init(prob, alg).ps[p] ≈ 2.0 + # nonsensical value for y just to test that equations work + prob2 = remake(prob; u0 = [x => 1.0, y => 2x + exp(x)]) + @test init(prob2, alg).ps[p] ≈ 3 + exp(1) + # solve for `x` given `p` and `y` + prob3 = remake(prob; u0 = [x => nothing, y => 1.0], p = [p => 2x + exp(y)]) + @test init(prob3, alg)[x] ≈ 1 - exp(1) + @test_logs (:warn, r"overdetermined") remake( + prob; u0 = [x => 1.0, y => 2.0], p = [p => 4.0]) + prob4 = remake(prob; u0 = [x => 1.0, y => 2.0], p = [p => 4.0]) + @test solve(prob4, alg).retcode == ReturnCode.InitialFailure + prob5 = remake(prob) + @test init(prob, alg).ps[p] ≈ 2.0 + end +end + +@testset "`remake` changes initialization problem types" begin + @variables _x(..) y(t) z(t) + @parameters p q + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + alge_eqs = [y^2 * q + q^2 * x ~ 0, z * p - p^2 * x * z ~ 0] + + @mtkbuild sys = System( + [D(x) ~ x * p + y^2 * q + rhss; alge_eqs], + t; guesses = [x => 0.0, y => 0.0, z => 0.0, p => 0.0, q => 0.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0, q => missing]) + @test is_variable(prob.f.initialization_data.initializeprob, q) + ps = prob.p + newps = SciMLStructures.replace(Tunable(), ps, ForwardDiff.Dual.(ps.tunable)) + prob2 = remake(prob; p = newps) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = ForwardDiff.Dual.(prob.u0)) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = ForwardDiff.Dual.(prob.u0), p = newps) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = [x => ForwardDiff.Dual(1.0)], + p = [p => ForwardDiff.Dual(1.0), q => missing]) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + @test eltype(prob2.p.initials) <: ForwardDiff.Dual + end +end + +@testset "`remake` preserves old u0map and pmap" begin + @variables _x(..) y(t) + @parameters p + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + alge_eqs = [y^2 + 4y * p^2 ~ x^3] + @mtkbuild sys = System( + [D(x) ~ x + p * y^2 + rhss; alge_eqs], t; guesses = [ + y => 1.0, p => 1.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + @test is_variable(prob.f.initialization_data.initializeprob, y) + prob2 = @test_nowarn remake(prob; p = [p => 3.0]) # ensure no over/under-determined warning + @test is_variable(prob.f.initialization_data.initializeprob, y) + + prob = Problem(sys, [y => 1.0, x => 2.0], (0.0, 1.0), [p => missing]) + @test is_variable(prob.f.initialization_data.initializeprob, p) + prob2 = @test_nowarn remake(prob; u0 = [y => 0.5]) + @test is_variable(prob.f.initialization_data.initializeprob, p) + end +end + +struct Multiplier{T} + a::T + b::T +end + +function (m::Multiplier)(x, y) + m.a * x + m.b * y +end + +@register_symbolic Multiplier(x::Real, y::Real) + +@testset "Nonnumeric parameter dependencies are retained" begin + @variables x(t) y(t) + @parameters foo(::Real, ::Real) p + @mtkbuild sys = ODESystem([D(x) ~ t, 0 ~ foo(x, y)], t; + parameter_dependencies = [foo ~ Multiplier(p, 2p)], guesses = [y => -1.0]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + integ = init(prob, Rosenbrock23()) + @test integ[y] ≈ -0.5 +end + +@testset "Use observed equations for guesses of observed variables" begin + @variables x(t) y(t) [state_priority = 100] + @mtkbuild sys = ODESystem( + [D(x) ~ x + t, y ~ 2x + 1], t; initialization_eqs = [x^3 + y^3 ~ 1]) + isys = ModelingToolkit.generate_initializesystem(sys) + @test isequal(defaults(isys)[y], 2x + 1) +end + +@testset "Create initializeprob when unknown has dependent value" begin + @variables x(t) y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t * y], t; defaults = [x => 2y]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test prob.f.initializeprob !== nothing + integ = init(prob) + @test integ[x] ≈ 2.0 + + @variables x(t)[1:2] y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t], t; defaults = [x => [y, 3.0]]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test prob.f.initializeprob !== nothing + integ = init(prob) + @test integ[x] ≈ [1.0, 3.0] +end + +@testset "units" begin + t = ModelingToolkit.t + D = ModelingToolkit.D + @parameters g [unit = u"m/s^2"] L=1 [unit = u"m^2"] + @variables x(t) [unit = u"m"] y(t) [unit = u"m" state_priority = 10] λ(t) [unit = u"s^-2"] + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ L] + @mtkbuild pend = ODESystem(eqs, t) + + prob = ODEProblem(pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], + guesses = ModelingToolkit.missing_variable_defaults(pend)) + sol = solve(prob, Rodas5P()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Issue#3205" begin + using ModelingToolkitStandardLibrary.Electrical + import ModelingToolkitStandardLibrary.Mechanical.Rotational as MR + using ModelingToolkitStandardLibrary.Blocks + using SciMLBase + + function dc_motor(R1 = 0.5) + R = R1 # [Ohm] armature resistance + L = 4.5e-3 # [H] armature inductance + k = 0.5 # [N.m/A] motor constant + J = 0.02 # [kg.m²] inertia + f = 0.01 # [N.m.s/rad] friction factor + tau_L_step = -0.3 # [N.m] amplitude of the load torque step + + @named ground = Ground() + @named source = Voltage() + @named ref = Blocks.Step(height = 0.2, start_time = 0) + @named pi_controller = Blocks.LimPI(k = 1.1, T = 0.035, u_max = 10, Ta = 0.035) + @named feedback = Blocks.Feedback() + @named R1 = Resistor(R = R) + @named L1 = Inductor(L = L) + @named emf = EMF(k = k) + @named fixed = MR.Fixed() + @named load = MR.Torque() + @named load_step = Blocks.Step(height = tau_L_step, start_time = 3) + @named inertia = MR.Inertia(J = J) + @named friction = MR.Damper(d = f) + @named speed_sensor = MR.SpeedSensor() + + connections = [connect(fixed.flange, emf.support, friction.flange_b) + connect(emf.flange, friction.flange_a, inertia.flange_a) + connect(inertia.flange_b, load.flange) + connect(inertia.flange_b, speed_sensor.flange) + connect(load_step.output, load.tau) + connect(ref.output, feedback.input1) + connect(speed_sensor.w, :y, feedback.input2) + connect(feedback.output, pi_controller.err_input) + connect(pi_controller.ctr_output, :u, source.V) + connect(source.p, R1.p) + connect(R1.n, L1.p) + connect(L1.n, emf.p) + connect(emf.n, source.n, ground.g)] + + @named model = ODESystem(connections, t, + systems = [ + ground, + ref, + pi_controller, + feedback, + source, + R1, + L1, + emf, + fixed, + load, + load_step, + inertia, + friction, + speed_sensor + ]) + end + + model = dc_motor() + sys = structural_simplify(model) + + prob = ODEProblem(sys, [sys.L1.i => 0.0], (0, 6.0)) + + @test_nowarn remake(prob, p = prob.p) +end + +@testset "Singular initialization prints a warning" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + @test_warn ["structurally singular", "initialization", "Guess", "heuristic"] ODEProblem( + pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], guesses = [λ => 1]) +end + +@testset "DAEProblem initialization" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p=missing [guess = 1.0] q=missing [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * y + q * t, x^3 + y^3 ~ 5], t; initialization_eqs = [p^2 + q^3 ~ 3]) + + # FIXME: solve for du0 + prob = DAEProblem( + sys, [D(x) => cbrt(4), D(y) => -1 / cbrt(4)], [x => 1.0], (0.0, 1.0), [p => 1.0]) + + integ = init(prob, DImplicitEuler()) + @test integ[x] ≈ 1.0 + @test integ[y]≈cbrt(4) rtol=1e-6 + @test integ.ps[p] ≈ 1.0 + @test integ.ps[q]≈cbrt(2) rtol=1e-6 +end + +@testset "Guesses provided to `ODEProblem` are used in `remake`" begin + @variables x(t) y(t)=2x + @parameters p q=3x + @mtkbuild sys = ODESystem([D(x) ~ x * p + q, x^3 + y^3 ~ 3], t) + prob = ODEProblem( + sys, [], (0.0, 1.0), [p => 1.0]; guesses = [x => 1.0, y => 1.0, q => 1.0]) + @test prob[x] == 0.0 + @test prob[y] == 0.0 + @test prob.ps[p] == 1.0 + @test prob.ps[q] == 0.0 + integ = init(prob) + @test integ[x] ≈ 1 / cbrt(3) + @test integ[y] ≈ 2 / cbrt(3) + @test integ.ps[p] == 1.0 + @test integ.ps[q] ≈ 3 / cbrt(3) + prob2 = remake(prob; u0 = [y => 3x], p = [q => 2x]) + integ2 = init(prob2) + @test integ2[x] ≈ cbrt(3 / 28) + @test integ2[y] ≈ 3cbrt(3 / 28) + @test integ2.ps[p] == 1.0 + @test integ2.ps[q] ≈ 2cbrt(3 / 28) +end + +function test_dummy_initialization_equation(prob, var) + initsys = prob.f.initialization_data.initializeprob.f.sys + @test isempty(equations(initsys)) + idx = findfirst(eq -> isequal(var, eq.lhs), observed(initsys)) + @test idx !== nothing && is_parameter(initsys, observed(initsys)[idx].rhs) +end + +@testset "Remake problem with no initializeprob" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p [guess = 1.0] q [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * y, y ~ 2x], t; parameter_dependencies = [q ~ 2p]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + test_dummy_initialization_equation(prob, x) + prob2 = remake(prob; u0 = [x => 2.0]) + @test prob2[x] == 2.0 + test_dummy_initialization_equation(prob2, x) + # otherwise we have `x ~ 2, y ~ 2` which is unsatisfiable + prob3 = remake(prob; u0 = [x => nothing, y => 2.0]) + @test prob3.f.initialization_data !== nothing + @test init(prob3)[x] ≈ 1.0 + prob4 = remake(prob; p = [p => 1.0]) + test_dummy_initialization_equation(prob4, x) + prob5 = remake(prob; p = [p => missing, q => 2.0]) + @test prob5.f.initialization_data !== nothing + @test init(prob5).ps[p] ≈ 1.0 +end + +@testset "Variables provided as symbols" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p [guess = 1.0] q [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * y, y ~ 2x], t; parameter_dependencies = [q ~ 2p]) + prob = ODEProblem(sys, [:x => 1.0], (0.0, 1.0), [p => 1.0]) + test_dummy_initialization_equation(prob, x) + prob2 = remake(prob; u0 = [:x => 2.0]) + test_dummy_initialization_equation(prob2, x) + prob3 = remake(prob; u0 = [:y => 1.0, :x => nothing]) + @test init(prob3)[x] ≈ 0.5 + @test SciMLBase.successful_retcode(solve(prob3)) +end + +@testset "Issue#3246: type promotion with parameter dependent initialization_eqs" begin + @variables x(t)=1 y(t)=1 + @parameters a = 1 + @named sys = ODESystem([D(x) ~ 0, D(y) ~ x + a], t; initialization_eqs = [y ~ a]) + + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [], (0, 1), []) + + @test SciMLBase.successful_retcode(solve(prob)) + + seta = setsym_oop(prob, [a]) + (newu0, newp) = seta(prob, ForwardDiff.Dual{ForwardDiff.Tag{:tag, Float64}}.([1.0], 1)) + newprob = remake(prob, u0 = newu0, p = newp) + + @test SciMLBase.successful_retcode(solve(newprob)) +end + +@testset "Issue#3295: Incomplete initialization of pure-ODE systems" begin + @variables X(t) Y(t) + @parameters p d + eqs = [ + D(X) ~ p - d * X, + D(Y) ~ p - d * Y + ] + @mtkbuild osys = ODESystem(eqs, t) + + # Make problem. + u0_vals = [X => 4, Y => 5.0] + tspan = (0.0, 10.0) + p_vals = [p => 1.0, d => 0.1] + oprob = ODEProblem(osys, u0_vals, tspan, p_vals) + integ = init(oprob) + @test integ[X] ≈ 4.0 + @test integ[Y] ≈ 5.0 + # Attempt to `remake`. + rp = remake(oprob; u0 = [Y => 7]) + integ = init(rp) + @test integ[X] ≈ 4.0 + @test integ[Y] ≈ 7.0 +end + +@testset "Issue#3297: `generate_initializesystem(::JumpSystem)`" begin + @parameters β γ S0 + @variables S(t)=S0 I(t) R(t) + rate₁ = β * S * I + affect₁ = [S ~ S - 1, I ~ I + 1] + rate₂ = γ * I + affect₂ = [I ~ I - 1, R ~ R + 1] + j₁ = ConstantRateJump(rate₁, affect₁) + j₂ = ConstantRateJump(rate₂, affect₂) + j₃ = MassActionJump(2 * β + γ, [R => 1], [S => 1, R => -1]) + @mtkbuild js = JumpSystem([j₁, j₂, j₃], t, [S, I, R], [β, γ, S0]) + + u0s = [I => 1, R => 0] + ps = [S0 => 999, β => 0.01, γ => 0.001] + dprob = DiscreteProblem(js, u0s, (0.0, 10.0), ps) + @test_broken dprob.f.initialization_data !== nothing + sol = solve(dprob, FunctionMap()) + @test sol[S, 1] ≈ 999 + @test SciMLBase.successful_retcode(sol) + + jprob = JumpProblem(js, dprob) + sol = solve(jprob, SSAStepper()) + @test sol[S, 1] ≈ 999 + @test SciMLBase.successful_retcode(sol) +end + +@testset "Solvable array parameters with scalarized guesses" begin + @variables x(t) + @parameters p[1:2] q + @mtkbuild sys = ODESystem( + D(x) ~ p[1] + p[2] + q, t; defaults = [p[1] => q, p[2] => 2q], + guesses = [p[1] => q, p[2] => 2q]) + @test ModelingToolkit.is_parameter_solvable(p, Dict(), defaults(sys), guesses(sys)) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [q => 2.0]) + @test length(ModelingToolkit.observed(prob.f.initialization_data.initializeprob.f.sys)) == + 3 + sol = solve(prob, Tsit5()) + @test sol.ps[p] ≈ [2.0, 4.0] +end + +@testset "Issue#3318: Mutating `Initial` parameters works" begin + @variables x(t) y(t)[1:2] [guess = ones(2)] + @parameters p[1:2, 1:2] + @mtkbuild sys = ODESystem( + [D(x) ~ x, D(y) ~ p * y], t; initialization_eqs = [x^2 + y[1]^2 + y[2]^2 ~ 4]) + prob = ODEProblem(sys, [x => 1.0, y[1] => 1], (0.0, 1.0), [p => 2ones(2, 2)]) + integ = init(prob, Tsit5()) + @test integ[x] ≈ 1.0 + @test integ[y] ≈ [1.0, sqrt(2.0)] + prob.ps[Initial(x)] = 0.5 + integ = init(prob, Tsit5()) + @test integ[x] ≈ 0.5 + @test integ[y] ≈ [1.0, sqrt(2.75)] + prob.ps[Initial(y[1])] = 0.5 + integ = init(prob, Tsit5()) + @test integ[x] ≈ 0.5 + @test integ[y]≈[0.5, sqrt(3.5)] atol=1e-6 +end + +@testset "Issue#3342" begin + @variables x(t) y(t) + stop!(integrator, _, _, _) = terminate!(integrator) + @named sys = ODESystem([D(x) ~ 1.0 + D(y) ~ 1.0], t; initialization_eqs = [ + y ~ 0.0 + ], + continuous_events = [ + [y ~ 0.5] => (stop!, [y], [], [], nothing) + ]) + sys = structural_simplify(sys) + prob0 = ODEProblem(sys, [x => NaN], (0.0, 1.0), []) + + # final_x(x0) is equivalent to x0 + 0.5 + function final_x(x0) + prob = remake(prob0; u0 = [x => x0]) + sol = solve(prob) + return sol[x][end] + end + @test final_x(0.3) ≈ 0.8 # should be 0.8 + @test ForwardDiff.derivative(final_x, 0.3) ≈ 1.0 +end + +@testset "Issue#3330: Initialization for unsimplified systems" begin + @variables x(t) [guess = 1.0] + @mtkbuild sys = ODESystem(D(x) ~ x, t; initialization_eqs = [x^2 ~ 4]) + prob = ODEProblem(sys, [], (0.0, 1.0)) + @test prob.f.initialization_data !== nothing +end + +@testset "`ReconstructInitializeprob` with `nothing` state" begin + @parameters p + @variables x(t) + @mtkbuild sys = ODESystem(x ~ p * t, t) + prob = @test_nowarn ODEProblem(sys, [], (0.0, 1.0), [p => 1.0]) + @test_nowarn remake(prob, p = [p => 1.0]) + @test_nowarn remake(prob, p = [p => ForwardDiff.Dual(1.0)]) +end + +@testset "`late_binding_update_u0_p` copies `newp`" begin + @parameters k1 k2 + @variables X1(t) X2(t) + @parameters Γ[1:1]=missing [guess = [1.0]] + eqs = [ + D(X1) ~ k1 * (Γ[1] - X1) - k2 * X1 + ] + obs = [X2 ~ Γ[1] - X1] + @mtkbuild osys = ODESystem(eqs, t, [X1, X2], [k1, k2, Γ]; observed = obs) + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + + oprob1 = ODEProblem(osys, u0, 1.0, ps) + oprob2 = remake(oprob1, u0 = [X1 => 10.0]) + integ1 = init(oprob1) + @test integ1[X1] ≈ 1.0 +end + +@testset "Trivial initialization is run on problem construction" begin + @variables _x(..) y(t) + @brownian a + @parameters tot + x = _x(t) + @testset "$Problem" for (Problem, lhs, rhs) in [ + (ODEProblem, D, 0.0), + (SDEProblem, D, a), + (DDEProblem, D, _x(t - 0.1)), + (SDDEProblem, D, _x(t - 0.1) + a) + ] + @mtkbuild sys = ModelingToolkit.System([lhs(x) ~ x + rhs, x + y ~ tot], t; + guesses = [tot => 1.0], defaults = [tot => missing]) + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test prob.ps[tot] ≈ 2.0 + end + @testset "$Problem" for Problem in [NonlinearProblem, NonlinearLeastSquaresProblem] + @parameters p1 p2 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 ~ p1, (x - 1)^2 + (y - 1)^2 ~ p2]; + parameter_dependencies = [p2 ~ 2p1], + guesses = [p1 => 0.0], defaults = [p1 => missing]) + prob = Problem(sys, [x => 1.0, y => 1.0], [p2 => 6.0]) + @test prob.ps[p1] ≈ 3.0 + end +end + +@testset "`Initial(X)` in time-independent systems: $Problem" for Problem in [ + NonlinearProblem, NonlinearLeastSquaresProblem] + @parameters k1 k2 + @variables X1(t) X2(t) + @parameters Γ[1:1]=missing [guess = [1.0]] + eqs = [ + 0 ~ k1 * (Γ[1] - X1) - k2 * X1 + ] + initialization_eqs = [ + X2 ~ Γ[1] - X1 + ] + @mtkbuild nlsys = NonlinearSystem(eqs, [X1, X2], [k1, k2, Γ]; initialization_eqs) + + @testset "throws if initialization_eqs contain unknowns" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + @test_throws ArgumentError Problem(nlsys, u0, ps) + end + + eqs = [0 ~ k1 * (Γ[1] - X1) - k2 * X1 + X2 ~ Γ[1] - X1] + initialization_eqs = [ + Initial(X2) ~ Γ[1] - Initial(X1) + ] + @mtkbuild nlsys = NonlinearSystem(eqs, [X1, X2], [k1, k2, Γ]; initialization_eqs) + + @testset "solves initialization" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + prob = Problem(nlsys, u0, ps) + @test state_values(prob.f.initialization_data.initializeprob) === nothing + @test prob.ps[Γ[1]] ≈ 3.0 + end + + @testset "respects explicitly provided value" begin + u0 = [] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + @test prob.ps[Γ[1]] ≈ 5.0 + end + + @testset "fails initialization if inconsistent explicit value" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + sol = solve(prob) + @test sol.retcode == SciMLBase.ReturnCode.InitialFailure + end + + @testset "Ignores initial equation if given insufficient u0" begin + u0 = [X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.ps[Γ[1]] ≈ 5.0 + end +end + +@testset "Issue#3504: Update initials when `remake` called with non-symbolic `u0`" begin + @variables x(t) y(t) + @parameters c1 c2 + @mtkbuild sys = ODESystem([D(x) ~ -c1 * x + c2 * y, D(y) ~ c1 * x - c2 * y], t) + prob1 = ODEProblem(sys, [1.0, 2.0], (0.0, 1.0), [c1 => 1.0, c2 => 2.0]) + prob2 = remake(prob1, u0 = [2.0, 3.0]) + prob3 = remake(prob1, u0 = [2.0, 3.0], p = [c1 => 2.0]) + integ1 = init(prob1, Tsit5()) + integ2 = init(prob2, Tsit5()) + integ3 = init(prob3, Tsit5()) + @test integ2.u ≈ [2.0, 3.0] + @test integ3.u ≈ [2.0, 3.0] + @test integ3.ps[c1] ≈ 2.0 +end diff --git a/test/input_output_handling.jl b/test/input_output_handling.jl new file mode 100644 index 0000000000..7f4a3247ad --- /dev/null +++ b/test/input_output_handling.jl @@ -0,0 +1,461 @@ +using ModelingToolkit, Symbolics, Test +using ModelingToolkit: get_namespace, has_var, inputs, outputs, is_bound, bound_inputs, + unbound_inputs, bound_outputs, unbound_outputs, isinput, isoutput, + ExtraVariablesSystemException +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables xx(t) some_input(t) [input = true] +eqs = [D(xx) ~ some_input] +@named model = ODESystem(eqs, t) +@test_throws ExtraVariablesSystemException structural_simplify(model, ((), ())) +if VERSION >= v"1.8" + err = "In particular, the unset input(s) are:\n some_input(t)" + @test_throws err structural_simplify(model, ((), ())) +end + +# Test input handling +@variables x(t) u(t) [input = true] v(t)[1:2] [input = true] +@test isinput(u) + +@named sys = ODESystem([D(x) ~ -x + u], t) # both u and x are unbound +@named sys1 = ODESystem([D(x) ~ -x + v[1] + v[2]], t) # both v and x are unbound +@named sys2 = ODESystem([D(x) ~ -sys.x], t, systems = [sys]) # this binds sys.x in the context of sys2, sys2.x is still unbound +@named sys21 = ODESystem([D(x) ~ -sys1.x], t, systems = [sys1]) # this binds sys.x in the context of sys2, sys2.x is still unbound +@named sys3 = ODESystem([D(x) ~ -sys.x + sys.u], t, systems = [sys]) # This binds both sys.x and sys.u +@named sys31 = ODESystem([D(x) ~ -sys1.x + sys1.v[1]], t, systems = [sys1]) # This binds both sys.x and sys1.v[1] + +@named sys4 = ODESystem([D(x) ~ -sys.x, u ~ sys.u], t, systems = [sys]) # This binds both sys.x and sys3.u, this system is one layer deeper than the previous. u is directly forwarded to sys.u, and in this case sys.u is bound while u is not + +@test has_var(x ~ 1, x) +@test has_var(1 ~ x, x) +@test has_var(x + x, x) +@test !has_var(2 ~ 1, x) + +@test get_namespace(x) == "" +@test get_namespace(sys.x) == "sys" +@test get_namespace(sys2.x) == "sys2" +@test get_namespace(sys2.sys.x) == "sys2₊sys" +@test get_namespace(sys21.sys1.v) == "sys21₊sys1" + +@test !is_bound(sys, u) +@test !is_bound(sys, x) +@test !is_bound(sys, sys.u) +@test is_bound(sys2, sys.x) +@test !is_bound(sys2, sys.u) +@test !is_bound(sys2, sys2.sys.u) +@test is_bound(sys21, sys1.x) +@test !is_bound(sys21, sys1.v[1]) +@test !is_bound(sys21, sys1.v[2]) +@test is_bound(sys31, sys1.v[1]) +@test !is_bound(sys31, sys1.v[2]) + +# simplification turns input variables into parameters +ssys, _ = structural_simplify(sys, ([u], [])) +@test ModelingToolkit.isparameter(unbound_inputs(ssys)[]) +@test !is_bound(ssys, u) +@test u ∈ Set(unbound_inputs(ssys)) + +fsys2 = flatten(sys2) +@test is_bound(fsys2, sys.x) +@test !is_bound(fsys2, sys.u) +@test !is_bound(fsys2, sys2.sys.u) + +@test is_bound(sys3, sys.u) # I would like to write sys3.sys.u here but that's not how the variable is stored in the equations +@test is_bound(sys3, sys.x) + +@test is_bound(sys4, sys.u) +@test !is_bound(sys4, u) + +fsys4 = flatten(sys4) +@test is_bound(fsys4, sys.u) +@test !is_bound(fsys4, u) + +@test isequal(inputs(sys), [u]) +@test isequal(inputs(sys2), [sys.u]) + +@test isempty(bound_inputs(sys)) +@test isequal(unbound_inputs(sys), [u]) + +@test isempty(bound_inputs(sys2)) +@test isempty(bound_inputs(fsys2)) +@test isequal(unbound_inputs(sys2), [sys.u]) +@test isequal(unbound_inputs(fsys2), [sys.u]) + +@test isequal(bound_inputs(sys3), [sys.u]) +@test isempty(unbound_inputs(sys3)) + +# Test output handling +@variables x(t) y(t) [output = true] +@test isoutput(y) +@named sys = ODESystem([D(x) ~ -x, y ~ x], t) # both y and x are unbound +syss = structural_simplify(sys) # This makes y an observed variable + +@named sys2 = ODESystem([D(x) ~ -sys.x, y ~ sys.y], t, systems = [sys]) + +@test !is_bound(sys, y) +@test !is_bound(sys, x) +@test !is_bound(sys, sys.y) + +@test !is_bound(syss, y) +@test !is_bound(syss, x) +@test !is_bound(syss, sys.y) + +@test isequal(unbound_outputs(sys), [y]) +@test isequal(unbound_outputs(syss), [y]) + +@test isequal(unbound_outputs(sys2), [y]) +@test isequal(bound_outputs(sys2), [sys.y]) + +syss = structural_simplify(sys2) + +@test !is_bound(syss, y) +@test !is_bound(syss, x) +@test is_bound(syss, sys.y) + +#@test isequal(unbound_outputs(syss), [y]) +@test isequal(bound_outputs(syss), [sys.y]) + +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Mechanical.Rotational +@named inertia1 = Inertia(; J = 1) +@named inertia2 = Inertia(; J = 1) +@named spring = Rotational.Spring(; c = 10) +@named damper = Rotational.Damper(; d = 3) +@named torque = Torque(; use_support = false) +@variables y(t) = 0 +eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b) + y ~ inertia2.w + torque.tau.u] +model = ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name = :name, guesses = [spring.flange_a.phi => 0.0]) +model_outputs = [inertia1.w, inertia2.w, inertia1.phi, inertia2.phi] +model_inputs = [torque.tau.u] +op = Dict(torque.tau.u => 0.0) +matrices, ssys = linearize( + model, model_inputs, model_outputs; op); +@test length(ModelingToolkit.outputs(ssys)) == 4 + +if VERSION >= v"1.8" # :opaque_closure not supported before + let # Just to have a local scope for D + matrices, ssys = linearize(model, model_inputs, [y]; op) + A, B, C, D = matrices + obsf = ModelingToolkit.build_explicit_observed_function(ssys, + [y], + inputs = [torque.tau.u]) + x = randn(size(A, 1)) + u = randn(size(B, 2)) + p = getindex.( + Ref(ModelingToolkit.defaults_and_guesses(ssys)), + parameters(ssys)) + y1 = obsf(x, u, p, 0) + y2 = C * x + D * u + @test y1[] ≈ y2[] + end +end + +## Code generation with unbound inputs +@testset "generate_control_function with disturbance inputs" begin + for split in [true, false] + simplify = true + + @variables x(t)=0 u(t)=0 [input = true] + eqs = [ + D(x) ~ -x + u + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys; simplify, split) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + @test f[1](x, u, p, 1) ≈ -x + u + + # With disturbance inputs + @variables x(t)=0 u(t)=0 [input = true] d(t)=0 + eqs = [ + D(x) ~ -x + u + d^2 + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function( + sys, [u], [d]; simplify, split) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + @test f[1](x, u, p, 1) ≈ -x + u + + ## With added d argument + @variables x(t)=0 u(t)=0 [input = true] d(t)=0 + eqs = [ + D(x) ~ -x + u + d^2 + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function( + sys, [u], [d]; simplify, split, disturbance_argument = true) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + d = [rand()] + @test f[1](x, u, p, t, d) ≈ -x + u + [d[]^2] + end +end + +## more complicated system + +@variables u(t) [input = true] + +function Mass(; name, m = 1.0, p = 0, v = 0) + @variables y(t)=0 [output = true] + ps = @parameters m = m + sts = @variables pos(t)=p vel(t)=v + eqs = [D(pos) ~ vel + y ~ pos] + ODESystem(eqs, t, [pos, vel, y], ps; name) +end + +function MySpring(; name, k = 1e4) + ps = @parameters k = k + @variables x(t) = 0 # Spring deflection + ODESystem(Equation[], t, [x], ps; name) +end + +function MyDamper(; name, c = 10) + ps = @parameters c = c + @variables vel(t) = 0 + ODESystem(Equation[], t, [vel], ps; name) +end + +function SpringDamper(; name, k = false, c = false) + spring = MySpring(; name = :spring, k) + damper = MyDamper(; name = :damper, c) + compose(ODESystem(Equation[], t; name), + spring, damper) +end + +connect_sd(sd, m1, m2) = [sd.spring.x ~ m1.pos - m2.pos, sd.damper.vel ~ m1.vel - m2.vel] +sd_force(sd) = -sd.spring.k * sd.spring.x - sd.damper.c * sd.damper.vel + +# Parameters +m1 = 1 +m2 = 1 +k = 1000 +c = 10 + +@named mass1 = Mass(; m = m1) +@named mass2 = Mass(; m = m2) +@named sd = SpringDamper(; k, c) + +eqs = [connect_sd(sd, mass1, mass2) + D(mass1.vel) ~ (sd_force(sd) + u) / mass1.m + D(mass2.vel) ~ (-sd_force(sd)) / mass2.m] +@named _model = ODESystem(eqs, t) +@named model = compose(_model, mass1, mass2, sd); + +f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(model, simplify = true) +@test length(dvs) == 4 +@test length(ps) == length(parameters(model)) +p = MTKParameters(io_sys, [io_sys.u => NaN]) +x = ModelingToolkit.varmap_to_vars( + merge(ModelingToolkit.defaults(model), + Dict(D.(unknowns(model)) .=> 0.0)), dvs) +u = [rand()] +out = f[1](x, u, p, 1) +i = findfirst(isequal(u[1]), out) +@test i isa Int +@test iszero(out[[1:(i - 1); (i + 1):end]]) + +@variables x(t) u(t) [input = true] +eqs = [D(x) ~ u] +@named sys = ODESystem(eqs, t) +@test_nowarn structural_simplify(sys, ([u], [])) + +#= +## Disturbance input handling +We test that the generated disturbance dynamics is correct by calling the dynamics in two different points that differ in the disturbance state, and check that we get the same result as when we call the linearized dynamics in the same two points. The true system is linear so the linearized dynamics are exact. + +The test below builds a double-mass model and adds an integrating disturbance to the input +=# + +using ModelingToolkit +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks + +# Parameters +m1 = 1 +m2 = 1 +k = 1000 # Spring stiffness +c = 10 # Damping coefficient + +@named inertia1 = Rotational.Inertia(; J = m1) +@named inertia2 = Rotational.Inertia(; J = m2) +@named spring = Rotational.Spring(; c = k) +@named damper = Rotational.Damper(; d = c) +@named torque = Rotational.Torque(; use_support = false) + +function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return @named model = ODESystem(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ]) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name, guesses = [spring.flange_a.phi => 0.0]) +end + +model = SystemModel() # Model with load disturbance +model = complete(model) +model_outputs = [model.inertia1.w, model.inertia2.w, model.inertia1.phi, model.inertia2.phi] + +@named dmodel = Blocks.StateSpace([0.0], [1.0], [1.0], [0.0]) # An integrating disturbance + +@named dist = ModelingToolkit.DisturbanceModel(model.torque.tau.u, dmodel) +(f_oop, f_ip), outersys, dvs, p, io_sys = ModelingToolkit.add_input_disturbance(model, dist) + +@unpack u, d = outersys +matrices, ssys = linearize(outersys, [u, d], model_outputs) + +def = ModelingToolkit.defaults(outersys) + +# Create a perturbation in the disturbance state +dstate = setdiff(dvs, model_outputs)[] +x_add = ModelingToolkit.varmap_to_vars(merge(Dict(dvs .=> 0), Dict(dstate => 1)), dvs) + +x0 = randn(5) +x1 = copy(x0) + x_add # add disturbance state perturbation +u = randn(1) +pn = MTKParameters(io_sys, []) +xp0 = f_oop(x0, u, pn, 0) +xp1 = f_oop(x1, u, pn, 0) + +@test xp0 ≈ matrices.A * x0 + matrices.B * [u; 0] +@test xp1 ≈ matrices.A * x1 + matrices.B * [u; 0] + +@variables x(t)[1:3] = 0 +@variables u(t)[1:2] +y₁, y₂, y₃ = x +u1, u2 = u +k₁, k₂, k₃ = 1, 1, 1 +eqs = [D(y₁) ~ -k₁ * y₁ + k₃ * y₂ * y₃ + u1 + D(y₂) ~ k₁ * y₁ - k₃ * y₂ * y₃ - k₂ * y₂^2 + u2 + y₁ + y₂ + y₃ ~ 1] + +@named sys = ODESystem(eqs, t) +m_inputs = [u[1], u[2]] +m_outputs = [y₂] +sys_simp, input_idxs = structural_simplify(sys, (; inputs = m_inputs, outputs = m_outputs)) +@test isequal(unknowns(sys_simp), collect(x[1:2])) +@test length(input_idxs) == 2 + +# https://github.com/SciML/ModelingToolkit.jl/issues/1577 +@named c = Constant(; k = 2) +@named gain = Gain(1;) +@named int = Integrator(; k = 1) +@named fb = Feedback(;) +@named model = ODESystem( + [ + connect(c.output, fb.input1), + connect(fb.input2, int.output), + connect(fb.output, gain.input), + connect(gain.output, int.input) + ], + t, + systems = [int, gain, c, fb]) +sys = structural_simplify(model) +@test length(unknowns(sys)) == length(equations(sys)) == 1 + +## Disturbance models when plant has multiple inputs +using ModelingToolkit, LinearAlgebra +using ModelingToolkit: DisturbanceModel, io_preprocessing, get_iv, get_disturbance_system +using ModelingToolkitStandardLibrary.Blocks +A, C = [randn(2, 2) for i in 1:2] +B = [1.0 0; 0 1.0] +@named model = Blocks.StateSpace(A, B, C) +@named integrator = Blocks.StateSpace([-0.001;;], [1.0;;], [1.0;;], [0.0;;]) + +ins = collect(complete(model).input.u) +outs = collect(complete(model).output.u) + +disturbed_input = ins[1] +@named dist_integ = DisturbanceModel(disturbed_input, integrator) + +(f_oop, f_ip), augmented_sys, dvs, p = ModelingToolkit.add_input_disturbance(model, + dist_integ, + ins) + +augmented_sys = complete(augmented_sys) +matrices, ssys = linearize(augmented_sys, + [ + augmented_sys.u, + augmented_sys.input.u[2], + augmented_sys.d + ], outs; + op = [augmented_sys.u => 0.0, augmented_sys.input.u[2] => 0.0, augmented_sys.d => 0.0]) +@test matrices.A ≈ [A [1; 0]; zeros(1, 2) -0.001] +@test matrices.B == I +@test matrices.C == [C zeros(2)] +@test matrices.D == zeros(2, 3) + +# Verify using ControlSystemsBase +# P = ss(A,B,C,0) +# G = ss(matrices...) +# @test sminreal(G[1, 3]) ≈ sminreal(P[1,1])*dist + +@testset "Observed functions with inputs" begin + @variables x(t)=0 u(t)=0 [input = true] + eqs = [ + D(x) ~ -x + u + ] + + @named sys = ODESystem(eqs, t) + (; io_sys,) = ModelingToolkit.generate_control_function(sys, simplify = true) + obsfn = ModelingToolkit.build_explicit_observed_function( + io_sys, [x + u * t]; inputs = [u]) + @test obsfn([1.0], [2.0], MTKParameters(io_sys, []), 3.0) ≈ [7.0] +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2896 +@testset "Constants substitution" begin + @constants c = 2.0 + @variables x(t) + eqs = [D(x) ~ c * x] + @named sys = ODESystem(eqs, t, [x], []) + + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys, simplify = true) + @test f[1]([0.5], nothing, MTKParameters(io_sys, []), 0.0) ≈ [1.0] +end + +@testset "With callable symbolic" begin + @variables x(t)=0 u(t)=0 [input = true] + @parameters p(::Real) = (x -> 2x) + eqs = [D(x) ~ -x + p(u)] + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys, simplify = true) + p = MTKParameters(io_sys, []) + u = [1.0] + x = [1.0] + @test_nowarn f[1](x, u, p, 0.0) +end diff --git a/test/inputoutput.jl b/test/inputoutput.jl index 28ae3aabb9..2a81a0e315 100644 --- a/test/inputoutput.jl +++ b/test/inputoutput.jl @@ -1,39 +1,55 @@ -using ModelingToolkit, OrdinaryDiffEq, Test - -@parameters t σ ρ β -@variables x(t) y(t) z(t) F(t) u(t) -D = Differential(t) - -eqs = [D(x) ~ σ*(y-x) + F, - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -aliases = [u ~ x + y - z] -lorenz1 = ODESystem(eqs,pins=[F],observed=aliases,name=:lorenz1) -lorenz2 = ODESystem(eqs,pins=[F],observed=aliases,name=:lorenz2) - -connections = [lorenz1.F ~ lorenz2.u, - lorenz2.F ~ lorenz1.u] -connected = ODESystem(Equation[],t,[],[],observed=connections,systems=[lorenz1,lorenz2]) - -sys = connected - -@variables lorenz1₊F lorenz2₊F -@test pins(connected) == Variable[lorenz1₊F, lorenz2₊F] -@test isequal(observed(connected), - [connections..., - lorenz1.u ~ lorenz1.x + lorenz1.y - lorenz1.z, - lorenz2.u ~ lorenz2.x + lorenz2.y - lorenz2.z]) - -collapsed_eqs = [D(lorenz1.x) ~ (lorenz1.σ * (lorenz1.y - lorenz1.x) + - (lorenz2.x + lorenz2.y - lorenz2.z)), - D(lorenz1.y) ~ lorenz1.x * (lorenz1.ρ - lorenz1.z) - lorenz1.y, - D(lorenz1.z) ~ lorenz1.x * lorenz1.y - (lorenz1.β * lorenz1.z), - D(lorenz2.x) ~ (lorenz2.σ * (lorenz2.y - lorenz2.x) + - (lorenz1.x + lorenz1.y - lorenz1.z)), - D(lorenz2.y) ~ lorenz2.x * (lorenz2.ρ - lorenz2.z) - lorenz2.y, - D(lorenz2.z) ~ lorenz2.x * lorenz2.y - (lorenz2.β * lorenz2.z)] - -simplifyeqs(eqs) = Equation.((x->x.lhs).(eqs), simplify.((x->x.rhs).(eqs))) - -@test isequal(simplifyeqs(equations(connected)), simplifyeqs(collapsed_eqs)) +using ModelingToolkit, OrdinaryDiffEq, Symbolics, Test + +@independent_variables t +@parameters σ ρ β +@variables x(t) y(t) z(t) F(t) u(t) +D = Differential(t) + +eqs = [D(x) ~ σ * (y - x) + F, + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +aliases = [u ~ x + y - z] +lorenz1 = ODESystem(eqs, pins = [F], observed = aliases, name = :lorenz1) +lorenz2 = ODESystem(eqs, pins = [F], observed = aliases, name = :lorenz2) + +connections = [lorenz1.F ~ lorenz2.u, + lorenz2.F ~ lorenz1.u] +connected = ODESystem(Equation[], t, [], [], observed = connections, + systems = [lorenz1, lorenz2]) + +sys = connected + +@variables lorenz1₊F lorenz2₊F +@test pins(connected) == Variable[lorenz1₊F, lorenz2₊F] +@test isequal(observed(connected), + [connections..., + lorenz1.u ~ lorenz1.x + lorenz1.y - lorenz1.z, + lorenz2.u ~ lorenz2.x + lorenz2.y - lorenz2.z]) + +collapsed_eqs = [ + D(lorenz1.x) ~ (lorenz1.σ * (lorenz1.y - lorenz1.x) + + (lorenz2.x + lorenz2.y - lorenz2.z)), + D(lorenz1.y) ~ lorenz1.x * (lorenz1.ρ - lorenz1.z) - lorenz1.y, + D(lorenz1.z) ~ lorenz1.x * lorenz1.y - (lorenz1.β * lorenz1.z), + D(lorenz2.x) ~ (lorenz2.σ * (lorenz2.y - lorenz2.x) + + (lorenz1.x + lorenz1.y - lorenz1.z)), + D(lorenz2.y) ~ lorenz2.x * (lorenz2.ρ - lorenz2.z) - lorenz2.y, + D(lorenz2.z) ~ lorenz2.x * lorenz2.y - (lorenz2.β * lorenz2.z)] + +simplifyeqs(eqs) = Equation.((x -> x.lhs).(eqs), simplify.((x -> x.rhs).(eqs))) + +@test isequal(simplifyeqs(equations(connected)), simplifyeqs(collapsed_eqs)) + +# Variables indicated to be input/output +@variables x [input = true] +@test hasmetadata(x, Symbolics.option_to_metadata_type(Val(:input))) +@test getmetadata(x, Symbolics.option_to_metadata_type(Val(:input))) == true +@test !hasmetadata(x, Symbolics.option_to_metadata_type(Val(:output))) +@test_throws KeyError getmetadata(x, Symbolics.option_to_metadata_type(Val(:output))) + +@variables y [output = true] +@test hasmetadata(y, Symbolics.option_to_metadata_type(Val(:output))) +@test getmetadata(y, Symbolics.option_to_metadata_type(Val(:output))) == true +@test !hasmetadata(y, Symbolics.option_to_metadata_type(Val(:input))) +@test_throws KeyError getmetadata(y, Symbolics.option_to_metadata_type(Val(:input))) diff --git a/test/jacobiansparsity.jl b/test/jacobiansparsity.jl index 2100387f1f..1f936bc878 100644 --- a/test/jacobiansparsity.jl +++ b/test/jacobiansparsity.jl @@ -1,73 +1,137 @@ -using OrdinaryDiffEq, ModelingToolkit, Test, SparseArrays +using ModelingToolkit, SparseArrays, OrdinaryDiffEq -N = 32 -const xyd_brusselator = range(0,stop=1,length=N) -brusselator_f(x, y, t) = (((x-0.3)^2 + (y-0.6)^2) <= 0.1^2) * (t >= 1.1) * 5. -limit(a, N) = ModelingToolkit.ifelse(a == N+1, 1, ModelingToolkit.ifelse(a == 0, N, a)) +N = 3 +xyd_brusselator = range(0, stop = 1, length = N) +brusselator_f(x, y, t) = (((x - 0.3)^2 + (y - 0.6)^2) <= 0.1^2) * (t >= 1.1) * 5.0 +lim(a, N) = ModelingToolkit.ifelse(a == N + 1, 1, ModelingToolkit.ifelse(a == 0, N, a)) function brusselator_2d_loop(du, u, p, t) - A, B, alpha, dx = p - alpha = alpha/dx^2 - @inbounds for I in CartesianIndices((N, N)) - i, j = Tuple(I) - x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] - ip1, im1, jp1, jm1 = limit(i+1, N), limit(i-1, N), limit(j+1, N), limit(j-1, N) - du[i,j,1] = alpha*(u[im1,j,1] + u[ip1,j,1] + u[i,jp1,1] + u[i,jm1,1] - 4u[i,j,1]) + - B + u[i,j,1]^2*u[i,j,2] - (A + 1)*u[i,j,1] + brusselator_f(x, y, t) - du[i,j,2] = alpha*(u[im1,j,2] + u[ip1,j,2] + u[i,jp1,2] + u[i,jm1,2] - 4u[i,j,2]) + - A*u[i,j,1] - u[i,j,1]^2*u[i,j,2] + A, B, alpha, dx = p + alpha = alpha / dx^2 + @inbounds for I in CartesianIndices((N, N)) + i, j = Tuple(I) + x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] + ip1, im1, jp1, jm1 = lim(i + 1, N), lim(i - 1, N), lim(j + 1, N), + lim(j - 1, N) + du[i, j, 1] = alpha * (u[im1, j, 1] + u[ip1, j, 1] + u[i, jp1, 1] + u[i, jm1, 1] - + 4u[i, j, 1]) + + B + u[i, j, 1]^2 * u[i, j, 2] - (A + 1) * u[i, j, 1] + + brusselator_f(x, y, t) + du[i, j, 2] = alpha * (u[im1, j, 2] + u[ip1, j, 2] + u[i, jp1, 2] + u[i, jm1, 2] - + 4u[i, j, 2]) + + A * u[i, j, 1] - u[i, j, 1]^2 * u[i, j, 2] end end # Test with tuple parameters -p = (3.4, 1., 10., step(xyd_brusselator)) +p = (3.4, 1.0, 10.0, step(xyd_brusselator)) function init_brusselator_2d(xyd) - N = length(xyd) - u = zeros(N, N, 2) - for I in CartesianIndices((N, N)) - x = xyd[I[1]] - y = xyd[I[2]] - u[I,1] = 22*(y*(1-y))^(3/2) - u[I,2] = 27*(x*(1-x))^(3/2) - end - u + N = length(xyd) + u = zeros(N, N, 2) + for I in CartesianIndices((N, N)) + x = xyd[I[1]] + y = xyd[I[2]] + u[I, 1] = 22 * (y * (1 - y))^(3 / 2) + u[I, 2] = 27 * (x * (1 - x))^(3 / 2) + end + u end u0 = init_brusselator_2d(xyd_brusselator) prob_ode_brusselator_2d = ODEProblem(brusselator_2d_loop, - u0,(0.,11.5),p) -sys = modelingtoolkitize(prob_ode_brusselator_2d) + u0, (0.0, 11.5), p) +sys = complete(modelingtoolkitize(prob_ode_brusselator_2d)) # test sparse jacobian pattern only. -prob = ODEProblem(sys, u0, (0, 11.5), sparse=true, jac=false) -@test findnz(Symbolics.jacobian_sparsity(map(x->x.rhs, equations(sys)), states(sys)))[1:2] == findnz(prob.f.jac_prototype)[1:2] +prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = false) +JP = prob.f.jac_prototype +@test findnz(Symbolics.jacobian_sparsity(map(x -> x.rhs, equations(sys)), + unknowns(sys)))[1:2] == + findnz(JP)[1:2] # test sparse jacobian -prob = ODEProblem(sys, u0, (0, 11.5), sparse=true, jac=true) -@test findnz(calculate_jacobian(sys))[1:2] == findnz(prob.f.jac_prototype)[1:2] +prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = true) +#@test_nowarn solve(prob, Rosenbrock23()) +@test findnz(calculate_jacobian(sys, sparse = true))[1:2] == + findnz(prob.f.jac_prototype)[1:2] # test when not sparse -prob = ODEProblem(sys, u0, (0, 11.5), sparse=false, jac=true) +prob = ODEProblem(sys, u0, (0, 11.5), sparse = false, jac = true) @test prob.f.jac_prototype == nothing -prob = ODEProblem(sys, u0, (0, 11.5), sparse=false, jac=false) +prob = ODEProblem(sys, u0, (0, 11.5), sparse = false, jac = false) @test prob.f.jac_prototype == nothing # test when u0 is nothing -f = DiffEqBase.ODEFunction(sys, u0=nothing, sparse=true, jac=true) -@test f.jac_prototype == nothing +f = DiffEqBase.ODEFunction(sys, u0 = nothing, sparse = true, jac = true) +@test findnz(f.jac_prototype)[1:2] == findnz(JP)[1:2] +@test eltype(f.jac_prototype) == Float64 -f = DiffEqBase.ODEFunction(sys, u0=nothing, sparse=true, jac=false) -@test f.jac_prototype == nothing +f = DiffEqBase.ODEFunction(sys, u0 = nothing, sparse = true, jac = false) +@test findnz(f.jac_prototype)[1:2] == findnz(JP)[1:2] +@test eltype(f.jac_prototype) == Float64 # test when u0 is not Float64 u0 = similar(init_brusselator_2d(xyd_brusselator), Float32) prob_ode_brusselator_2d = ODEProblem(brusselator_2d_loop, - u0,(0.,11.5),p) -sys = modelingtoolkitize(prob_ode_brusselator_2d) + u0, (0.0, 11.5), p) +sys = complete(modelingtoolkitize(prob_ode_brusselator_2d)) -prob = ODEProblem(sys, u0, (0, 11.5), sparse=true, jac=false) +prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = false) @test eltype(prob.f.jac_prototype) == Float32 -prob = ODEProblem(sys, u0, (0, 11.5), sparse=true, jac=true) +prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = true) @test eltype(prob.f.jac_prototype) == Float32 + +@testset "W matrix sparsity" begin + t = ModelingToolkit.t_nounits + D = ModelingToolkit.D_nounits + @parameters g + @variables x(t) y(t) λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + + u0 = [x => 1, y => 0] + prob = ODEProblem( + pend, u0, (0, 11.5), [g => 1], guesses = [λ => 1], sparse = true, jac = true) + jac, jac! = generate_jacobian(pend; expression = Val{false}, sparse = true) + jac_prototype = ModelingToolkit.jacobian_sparsity(pend) + W_prototype = ModelingToolkit.W_sparsity(pend) + @test nnz(W_prototype) == nnz(jac_prototype) + 2 + + # jac_prototype should be the same as W_prototype + @test findnz(prob.f.jac_prototype)[1:2] == findnz(W_prototype)[1:2] + + u = zeros(5) + p = prob.p + t = 0.0 + @test_throws AssertionError jac!(similar(jac_prototype, Float64), u, p, t) + + W, W! = generate_W(pend; expression = Val{false}, sparse = true) + γ = 0.1 + M = sparse(calculate_massmatrix(pend)) + @test_throws AssertionError W!(similar(jac_prototype, Float64), u, p, γ, t) + @test W!(similar(W_prototype, Float64), u, p, γ, t) == + 0.1 * M + jac!(similar(W_prototype, Float64), u, p, t) +end + +@testset "Issue#3556: Numerical accuracy" begin + t = ModelingToolkit.t_nounits + D = ModelingToolkit.D_nounits + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + prob = ODEProblem(pend, [x => 0.0, D(x) => 1.0], (0.0, 1.0), [g => 1.0]; + guesses = [y => 1.0, λ => 1.0], jac = true, sparse = true) + J = deepcopy(prob.f.jac_prototype) + prob.f.jac(J, prob.u0, prob.p, 1.0) + # this currently works but may not continue to do so + # see https://github.com/SciML/ModelingToolkit.jl/pull/3556#issuecomment-2792664039 + @test J == prob.f.jac(prob.u0, prob.p, 1.0) + @test J ≈ prob.f.jac(prob.u0, prob.p, 1.0) +end diff --git a/test/jumpsystem.jl b/test/jumpsystem.jl index 94cf3cc518..9568990e73 100644 --- a/test/jumpsystem.jl +++ b/test/jumpsystem.jl @@ -1,155 +1,553 @@ -using ModelingToolkit, DiffEqBase, DiffEqJump, Test, LinearAlgebra +using ModelingToolkit, DiffEqBase, JumpProcesses, Test, LinearAlgebra +using Random, StableRNGs, NonlinearSolve +using OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D MT = ModelingToolkit +rng = StableRNG(12345) + # basic MT SIR model with tweaks -@parameters β γ t -@variables S I R -rate₁ = β*S*I -affect₁ = [S ~ S - 1, I ~ I + 1] -rate₂ = γ*I+t +@parameters β γ +@constants h = 1 +@variables S(t) I(t) R(t) +rate₁ = β * S * I * h +affect₁ = [S ~ S - 1 * h, I ~ I + 1] +rate₂ = γ * I + t affect₂ = [I ~ I - 1, R ~ R + 1] -j₁ = ConstantRateJump(rate₁,affect₁) -j₂ = VariableRateJump(rate₂,affect₂) -js = JumpSystem([j₁,j₂], t, [S,I,R], [β,γ]) -statetoid = Dict(MT.value(state) => i for (i,state) in enumerate(states(js))) -mtjump1 = MT.assemble_crj(js, j₁, statetoid) -mtjump2 = MT.assemble_vrj(js, j₂, statetoid) +j₁ = ConstantRateJump(rate₁, affect₁) +j₂ = VariableRateJump(rate₂, affect₂) +@named js = JumpSystem([j₁, j₂], t, [S, I, R], [β, γ]) +unknowntoid = Dict(MT.value(unknown) => i for (i, unknown) in enumerate(unknowns(js))) +mtjump1 = MT.assemble_crj(js, j₁, unknowntoid) +mtjump2 = MT.assemble_vrj(js, j₂, unknowntoid) # doc version -rate1(u,p,t) = (0.1/1000.0)*u[1]*u[2] +rate1(u, p, t) = (0.1 / 1000.0) * u[1] * u[2] function affect1!(integrator) - integrator.u[1] -= 1 - integrator.u[2] += 1 + integrator.u[1] -= 1 + integrator.u[2] += 1 end -jump1 = ConstantRateJump(rate1,affect1!) -rate2(u,p,t) = 0.01u[2]+t +jump1 = ConstantRateJump(rate1, affect1!) +rate2(u, p, t) = 0.01u[2] + t function affect2!(integrator) - integrator.u[2] -= 1 - integrator.u[3] += 1 + integrator.u[2] -= 1 + integrator.u[3] += 1 end -jump2 = VariableRateJump(rate2,affect2!) +jump2 = VariableRateJump(rate2, affect2!) # test crjs u = [100, 9, 5] -p = (0.1/1000,0.01) +p = (0.1 / 1000, 0.01) tf = 1.0 -mutable struct TestInt{U,V,T} +mutable struct TestInt{U, V, T} u::U p::V t::T end -mtintegrator = TestInt(u,p,tf) -integrator = TestInt(u,p,tf) -@test abs(mtjump1.rate(u,p,tf) - jump1.rate(u,p,tf)) < 10*eps() -@test abs(mtjump2.rate(u,p,tf) - jump2.rate(u,p,tf)) < 10*eps() +mtintegrator = TestInt(u, p, tf) +integrator = TestInt(u, p, tf) +@test abs(mtjump1.rate(u, p, tf) - jump1.rate(u, p, tf)) < 10 * eps() +@test abs(mtjump2.rate(u, p, tf) - jump2.rate(u, p, tf)) < 10 * eps() mtjump1.affect!(mtintegrator) jump1.affect!(integrator) @test all(integrator.u .== mtintegrator.u) -mtintegrator.u .= u; integrator.u .= u +mtintegrator.u .= u; +integrator.u .= u; mtjump2.affect!(mtintegrator) jump2.affect!(integrator) @test all(integrator.u .== mtintegrator.u) # test MT can make and solve a jump problem -rate₃ = γ*I -affect₃ = [I ~ I - 1, R ~ R + 1] -j₃ = ConstantRateJump(rate₃,affect₃) -js2 = JumpSystem([j₁,j₃], t, [S,I,R], [β,γ]) -u₀ = [999,1,0]; p = (0.1/1000,0.01); tspan = (0.,250.) +rate₃ = γ * I * h +affect₃ = [I ~ I * h - 1, R ~ R + 1] +j₃ = ConstantRateJump(rate₃, affect₃) +@named js2 = JumpSystem([j₁, j₃], t, [S, I, R], [β, γ]) +js2 = complete(js2) +u₀ = [999, 1, 0]; +p = (0.1 / 1000, 0.01); +tspan = (0.0, 250.0); u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] +parammap = [β => 0.1 / 1000, γ => 0.01] dprob = DiscreteProblem(js2, u₀map, tspan, parammap) -jprob = JumpProblem(js2, dprob, Direct(), save_positions=(false,false)) +jprob = JumpProblem(js2, dprob, Direct(); save_positions = (false, false), rng) Nsims = 30000 -function getmean(jprob,Nsims) - m = 0.0 - for i = 1:Nsims - sol = solve(jprob, SSAStepper()) - m += sol[end,end] - end - m/Nsims +function getmean(jprob, Nsims; use_stepper = true) + m = 0.0 + for i in 1:Nsims + sol = use_stepper ? solve(jprob, SSAStepper()) : solve(jprob) + m += sol[end, end] + end + m / Nsims end -m = getmean(jprob,Nsims) +m = getmean(jprob, Nsims) + +# test auto-alg selection works +jprobb = JumpProblem(js2, dprob; save_positions = (false, false), rng) +mb = getmean(jprobb, Nsims; use_stepper = false) +@test abs(m - mb) / m < 0.01 + +@variables S2(t) +obs = [S2 ~ 2 * S] +@named js2b = JumpSystem([j₁, j₃], t, [S, I, R], [β, γ], observed = obs) +js2b = complete(js2b) +dprob = DiscreteProblem(js2b, u₀map, tspan, parammap) +jprob = JumpProblem(js2b, dprob, Direct(); save_positions = (false, false), rng) +sol = solve(jprob, SSAStepper(); saveat = tspan[2] / 10) +@test all(2 .* sol[S] .== sol[S2]) # test save_positions is working -jprob = JumpProblem(js2, dprob, Direct(), save_positions=(false,false)) -sol = solve(jprob, SSAStepper(), saveat=1.0) -@test all((sol.t) .== collect(0.:tspan[2])) +jprob = JumpProblem(js2, dprob, Direct(); save_positions = (false, false), rng) +sol = solve(jprob, SSAStepper(); saveat = 1.0) +@test all((sol.t) .== collect(0.0:tspan[2])) #test the MT JumpProblem rates/affects are correct -rate2(u,p,t) = 0.01u[2] -jump2 = ConstantRateJump(rate2,affect2!) +rate2(u, p, t) = 0.01u[2] +jump2 = ConstantRateJump(rate2, affect2!) mtjumps = jprob.discrete_jump_aggregation -@test abs(mtjumps.rates[1](u,p,tf) - jump1.rate(u,p,tf)) < 10*eps() -@test abs(mtjumps.rates[2](u,p,tf) - jump2.rate(u,p,tf)) < 10*eps() +@test abs(mtjumps.rates[1](u, p, tf) - jump1.rate(u, p, tf)) < 10 * eps() +@test abs(mtjumps.rates[2](u, p, tf) - jump2.rate(u, p, tf)) < 10 * eps() mtjumps.affects![1](mtintegrator) jump1.affect!(integrator) @test all(integrator.u .== mtintegrator.u) -mtintegrator.u .= u; integrator.u .= u +mtintegrator.u .= u; +integrator.u .= u; mtjumps.affects![2](mtintegrator) jump2.affect!(integrator) @test all(integrator.u .== mtintegrator.u) # direct vers -p = (0.1/1000,0.01) -prob = DiscreteProblem([999,1,0],(0.0,250.0),p) -r1(u,p,t) = (0.1/1000.0)*u[1]*u[2] +p = (0.1 / 1000, 0.01) +prob = DiscreteProblem([999, 1, 0], (0.0, 250.0), p) +r1(u, p, t) = (0.1 / 1000.0) * u[1] * u[2] function a1!(integrator) - integrator.u[1] -= 1 - integrator.u[2] += 1 + integrator.u[1] -= 1 + integrator.u[2] += 1 end -j1 = ConstantRateJump(r1,a1!) -r2(u,p,t) = 0.01u[2] +j1 = ConstantRateJump(r1, a1!) +r2(u, p, t) = 0.01u[2] function a2!(integrator) - integrator.u[2] -= 1 - integrator.u[3] += 1 + integrator.u[2] -= 1 + integrator.u[3] += 1 end -j2 = ConstantRateJump(r2,a2!) -jset = JumpSet((),(j1,j2),nothing,nothing) -jprob = JumpProblem(prob,Direct(),jset, save_positions=(false,false)) -m2 = getmean(jprob,Nsims) +j2 = ConstantRateJump(r2, a2!) +jset = JumpSet((), (j1, j2), nothing, nothing) +jprob = JumpProblem(prob, Direct(), jset; save_positions = (false, false), rng) +m2 = getmean(jprob, Nsims) # test JumpSystem solution agrees with direct version -@test abs(m-m2)/m < .01 +@test abs(m - m2) / m < 0.01 # mass action jump tests for SIR model -maj1 = MassActionJump(2*β/2, [S => 1, I => 1], [S => -1, I => 1]) +maj1 = MassActionJump(2 * β / 2, [S => 1, I => 1], [S => -1, I => 1]) maj2 = MassActionJump(γ, [I => 1], [I => -1, R => 1]) -js3 = JumpSystem([maj1,maj2], t, [S,I,R], [β,γ]) -statetoid = Dict(MT.value(state) => i for (i,state) in enumerate(states(js))) -ptoid = Dict(MT.value(par) => i for (i,par) in enumerate(parameters(js))) +@named js3 = JumpSystem([maj1, maj2], t, [S, I, R], [β, γ]) +js3 = complete(js3) dprob = DiscreteProblem(js3, u₀map, tspan, parammap) -jprob = JumpProblem(js3, dprob, Direct()) -m3 = getmean(jprob,Nsims) -@test abs(m-m3)/m < .01 +jprob = JumpProblem(js3, dprob, Direct(); rng) +m3 = getmean(jprob, Nsims) +@test abs(m - m3) / m < 0.01 # maj jump test with various dep graphs -js3b = JumpSystem([maj1,maj2], t, [S,I,R], [β,γ]) -jprobb = JumpProblem(js3b, dprob, NRM()) -m4 = getmean(jprobb,Nsims) -@test abs(m-m4)/m < .01 -jprobc = JumpProblem(js3b, dprob, RSSA()) -m4 = getmean(jprobc,Nsims) -@test abs(m-m4)/m < .01 +@named js3b = JumpSystem([maj1, maj2], t, [S, I, R], [β, γ]) +js3b = complete(js3b) +jprobb = JumpProblem(js3b, dprob, NRM(); rng) +m4 = getmean(jprobb, Nsims) +@test abs(m - m4) / m < 0.01 +jprobc = JumpProblem(js3b, dprob, RSSA(); rng) +m4 = getmean(jprobc, Nsims) +@test abs(m - m4) / m < 0.01 # mass action jump tests for other reaction types (zero order, decay) maj1 = MassActionJump(2.0, [0 => 1], [S => 1]) maj2 = MassActionJump(γ, [S => 1], [S => -1]) -js4 = JumpSystem([maj1,maj2], t, [S], [β,γ]) -statetoid = Dict(MT.value(state) => i for (i,state) in enumerate(states(js))) -ptoid = Dict(MT.value(par) => i for (i,par) in enumerate(parameters(js))) -dprob = DiscreteProblem(js4, [S => 999], (0,1000.), [β => 100.,γ => .01]) -jprob = JumpProblem(js4, dprob, Direct()) -m4 = getmean(jprob,Nsims) -@test abs(m4 - 2.0/.01)*.01/2.0 < .01 +@named js4 = JumpSystem([maj1, maj2], t, [S], [β, γ]) +js4 = complete(js4) +dprob = DiscreteProblem(js4, [S => 999], (0, 1000.0), [β => 100.0, γ => 0.01]) +jprob = JumpProblem(js4, dprob, Direct(); rng) +m4 = getmean(jprob, Nsims) +@test abs(m4 - 2.0 / 0.01) * 0.01 / 2.0 < 0.01 # test second order rx runs maj1 = MassActionJump(2.0, [0 => 1], [S => 1]) maj2 = MassActionJump(γ, [S => 2], [S => -1]) -js4 = JumpSystem([maj1,maj2], t, [S], [β,γ]) -statetoid = Dict(MT.value(state) => i for (i,state) in enumerate(states(js))) -ptoid = Dict(MT.value(par) => i for (i,par) in enumerate(parameters(js))) -dprob = DiscreteProblem(js4, [S => 999], (0,1000.), [β => 100.,γ => .01]) -jprob = JumpProblem(js4, dprob, Direct()) +@named js4 = JumpSystem([maj1, maj2], t, [S], [β, γ]) +js4 = complete(js4) +dprob = DiscreteProblem(js4, [S => 999], (0, 1000.0), [β => 100.0, γ => 0.01]) +jprob = JumpProblem(js4, dprob, Direct(); rng) sol = solve(jprob, SSAStepper()); +# issue #819 +@testset "Combined system name collisions" begin + sys1 = JumpSystem([maj1, maj2], t, [S], [β, γ], name = :sys1) + sys2 = JumpSystem([maj1, maj2], t, [S], [β, γ], name = :sys1) + @test_throws ArgumentError JumpSystem([sys1.γ ~ sys2.γ], t, [], [], + systems = [sys1, sys2], name = :foo) +end + +# test if param mapper is setup correctly for callbacks +let + @parameters k1 k2 k3 + @variables A(t) B(t) + maj1 = MassActionJump(k1 * k3, [0 => 1], [A => -1, B => 1]) + maj2 = MassActionJump(k2, [B => 1], [A => 1, B => -1]) + @named js5 = JumpSystem([maj1, maj2], t, [A, B], [k1, k2, k3]) + js5 = complete(js5) + p = [k1 => 2.0, k2 => 0.0, k3 => 0.5] + u₀ = [A => 100, B => 0] + tspan = (0.0, 2000.0) + dprob = DiscreteProblem(js5, u₀, tspan, p) + jprob = JumpProblem(js5, dprob, Direct(); save_positions = (false, false), rng) + @test all(jprob.massaction_jump.scaled_rates .== [1.0, 0.0]) + + pcondit(u, t, integrator) = t == 1000.0 + function paffect!(integrator) + integrator.ps[k1] = 0.0 + integrator.ps[k2] = 1.0 + reset_aggregated_jumps!(integrator) + end + cb = DiscreteCallback(pcondit, paffect!) + sol = solve(jprob, SSAStepper(); tstops = [1000.0], callback = cb) + @test sol.u[end][1] == 100 +end + +# observed variable handling +@variables OBS(t) +@named js5 = JumpSystem([maj1, maj2], t, [S], [β, γ]; observed = [OBS ~ 2 * S * h]) +OBS2 = OBS +@test isequal(OBS2, @nonamespace js5.OBS) +@unpack OBS = js5 +@test isequal(OBS2, OBS) + +# test to make sure dep graphs are correct +let + # A + 2X --> 3X + # 3X --> A + 2X + # B --> X + # X --> B + @variables A(t) X(t) B(t) + jumps = [MassActionJump(1.0, [A => 1, X => 2], [A => -1, X => 1]), + MassActionJump(1.0, [X => 3], [A => 1, X => -1]), + MassActionJump(1.0, [B => 1], [B => -1, X => 1]), + MassActionJump(1.0, [X => 1], [B => 1, X => -1])] + @named js = JumpSystem(jumps, t, [A, X, B], []) + jdeps = asgraph(js) + vdeps = variable_dependencies(js) + vtoj = jdeps.badjlist + @test vtoj == [[1], [1, 2, 4], [3]] + jtov = vdeps.badjlist + @test jtov == [[1, 2], [1, 2], [2, 3], [2, 3]] + jtoj = eqeq_dependencies(jdeps, vdeps).fadjlist + @test jtoj == [[1, 2, 4], [1, 2, 4], [1, 2, 3, 4], [1, 2, 3, 4]] +end + +# Create JumpProblems for systems without parameters +# Issue#2559 +@parameters k +@variables X(t) +rate = k +affect = [X ~ X - 1] + +crj = ConstantRateJump(1.0, [X ~ X - 1]) +js1 = complete(JumpSystem([crj], t, [X], [k]; name = :js1)) +js2 = complete(JumpSystem([crj], t, [X], []; name = :js2)) + +maj = MassActionJump(1.0, [X => 1], [X => -1]) +js3 = complete(JumpSystem([maj], t, [X], [k]; name = :js2)) +js4 = complete(JumpSystem([maj], t, [X], []; name = :js3)) + +u0 = [X => 10] +tspan = (0.0, 1.0) +ps = [k => 1.0] + +dp1 = DiscreteProblem(js1, u0, tspan, ps) +dp2 = DiscreteProblem(js2, u0, tspan) +dp3 = DiscreteProblem(js3, u0, tspan, ps) +dp4 = DiscreteProblem(js4, u0, tspan) + +@test_nowarn jp1 = JumpProblem(js1, dp1, Direct()) +@test_nowarn jp2 = JumpProblem(js2, dp2, Direct()) +@test_nowarn jp3 = JumpProblem(js3, dp3, Direct()) +@test_nowarn jp4 = JumpProblem(js4, dp4, Direct()) + +# Ensure `structural_simplify` (and `@mtkbuild`) works on JumpSystem (by doing nothing) +# Issue#2558 +@parameters k +@variables X(t) +rate = k +affect = [X ~ X - 1] + +j1 = ConstantRateJump(k, [X ~ X - 1]) +@test_nowarn @mtkbuild js1 = JumpSystem([j1], t, [X], [k]) + +# test correct autosolver is selected, which implies appropriate dep graphs are available +let + @parameters k + @variables X(t) + rate = k + affect = [X ~ X - 1] + j1 = ConstantRateJump(k, [X ~ X - 1]) + + Nv = [1, JumpProcesses.USE_DIRECT_THRESHOLD + 1, JumpProcesses.USE_RSSA_THRESHOLD + 1] + algtypes = [Direct, RSSA, RSSACR] + for (N, algtype) in zip(Nv, algtypes) + @named jsys = JumpSystem([deepcopy(j1) for _ in 1:N], t, [X], [k]) + jsys = complete(jsys) + dprob = DiscreteProblem(jsys, [X => 10], (0.0, 10.0), [k => 1]) + jprob = JumpProblem(jsys, dprob) + @test jprob.aggregator isa algtype + end +end + +# basic VariableRateJump test +let + N = 1000 # number of simulations for testing solve accuracy + Random.seed!(rng, 1111) + @variables A(t) B(t) C(t) + @parameters k + vrj = VariableRateJump(k * (sin(t) + 1), [A ~ A + 1, C ~ C + 2]) + js = complete(JumpSystem([vrj], t, [A, C], [k]; name = :js, observed = [B ~ C * A])) + oprob = ODEProblem(js, [A => 0, C => 0], (0.0, 10.0), [k => 1.0]) + jprob = JumpProblem(js, oprob, Direct(); rng) + sol = solve(jprob, Tsit5()) + + # test observed and symbolic indexing work + @test all(sol[:A] .* sol[:C] .== sol[:B]) + + dt = 1.0 + tv = range(0.0, 10.0; step = 1.0) + cmean = zeros(11) + for n in 1:N + sol = solve(jprob, Tsit5(); save_everystep = false, saveat = dt) + cmean += Array(sol(tv; idxs = :C)) + end + cmean ./= N + + vrjrate(u, p, t) = p[1] * (sin(t) + 1) + function vrjaffect!(integ) + integ.u[1] += 1 + integ.u[2] += 2 + nothing + end + vrj2 = VariableRateJump(vrjrate, vrjaffect!) + oprob2 = ODEProblem((du, u, p, t) -> (du .= 0; nothing), [0, 0], (0.0, 10.0), (1.0,)) + jprob2 = JumpProblem(oprob2, Direct(), vrj2; rng) + cmean2 = zeros(11) + for n in 1:N + sol2 = solve(jprob2, Tsit5(); saveat = dt) + cmean2 += Array(sol2(tv; idxs = 2)) + end + cmean2 ./= N + + @test all(abs.(cmean .- cmean2) .<= 0.05 .* cmean) +end + +# collect_vars! tests for jumps +let + @variables x1(t) x2(t) x3(t) x4(t) x5(t) + @parameters p1 p2 p3 p4 p5 + j1 = ConstantRateJump(p1, [x1 ~ x1 + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ x3 + 1, x4 ~ x4 + 1]) + j4 = MassActionJump(p4 * p5, [x1 => 1, x5 => 1], [x1 => -1, x5 => -1, x2 => 1]) + us = Set() + ps = Set() + iv = t + + MT.collect_vars!(us, ps, j1, iv) + @test issetequal(us, [x1]) + @test issetequal(ps, [p1]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j2, iv) + @test issetequal(us, [x2, x3]) + @test issetequal(ps, [p2]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j3, iv) + @test issetequal(us, [x3, x4]) + @test issetequal(ps, [p3]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j4, iv) + @test issetequal(us, [x1, x5, x2]) + @test issetequal(ps, [p4, p5]) +end + +# scoping tests +let + @variables x1(t) x2(t) x3(t) x4(t) x5(t) + x2 = ParentScope(x2) + x3 = ParentScope(ParentScope(x3)) + x4 = DelayParentScope(x4) + x5 = GlobalScope(x5) + @parameters p1 p2 p3 p4 p5 + p2 = ParentScope(p2) + p3 = ParentScope(ParentScope(p3)) + p4 = DelayParentScope(p4) + p5 = GlobalScope(p5) + + j1 = ConstantRateJump(p1, [x1 ~ x1 + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ x3 + 1, x4 ~ x4 + 1]) + j4 = MassActionJump(p4 * p5, [x1 => 1, x5 => 1], [x1 => -1, x5 => -1, x2 => 1]) + @named js = JumpSystem([j1, j2, j3, j4], t, [x1, x2, x3, x4, x5], [p1, p2, p3, p4, p5]) + + us = Set() + ps = Set() + iv = t + MT.collect_scoped_vars!(us, ps, js, iv) + @test issetequal(us, [x2]) + @test issetequal(ps, [p2]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 0) + @test issetequal(us, [x1]) + @test issetequal(ps, [p1]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 1) + @test issetequal(us, [x2]) + @test issetequal(ps, [p2]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 2) + @test issetequal(us, [x3, x4]) + @test issetequal(ps, [p3, p4]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = -1) + @test issetequal(us, [x5]) + @test issetequal(ps, [p5]) +end + +# PDMP test +let + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters k1 k2 + vrj1 = VariableRateJump(k1 * X, [X ~ X - 1]; save_positions = (false, false)) + vrj2 = VariableRateJump(k1, [Y ~ Y + 1]; save_positions = (false, false)) + eqs = [D(X) ~ k2, D(Y) ~ -k2 / 10 * Y] + @named jsys = JumpSystem([vrj1, vrj2, eqs[1], eqs[2]], t, [X, Y], [k1, k2]) + jsys = complete(jsys) + X0 = 0.0 + Y0 = 3.0 + u0 = [X => X0, Y => Y0] + k1val = 1.0 + k2val = 20.0 + p = [k1 => k1val, k2 => k2val] + tspan = (0.0, 10.0) + oprob = ODEProblem(jsys, u0, tspan, p) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + + times = range(0.0, tspan[2], length = 100) + Nsims = 4000 + Xv = zeros(length(times)) + Yv = zeros(length(times)) + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = times, seed) + Xv .+= sol[1, :] + Yv .+= sol[2, :] + seed += 1 + end + Xv ./= Nsims + Yv ./= Nsims + + Xact(t) = X0 * exp(-k1val * t) + (k2val / k1val) * (1 - exp(-k1val * t)) + function Yact(t) + Y0 * exp(-k2val / 10 * t) + (k1val / (k2val / 10)) * (1 - exp(-k2val / 10 * t)) + end + @test all(abs.(Xv .- Xact.(times)) .<= 0.05 .* Xv) + @test all(abs.(Yv .- Yact.(times)) .<= 0.1 .* Yv) +end + +# that mixes ODEs and jump types, and then contin events +let + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters α β + vrj = VariableRateJump(β * X, [X ~ X - 1]; save_positions = (false, false)) + crj = ConstantRateJump(β * Y, [Y ~ Y - 1]) + maj = MassActionJump(α, [0 => 1], [Y => 1]) + eqs = [D(X) ~ α * (1 + Y)] + @named jsys = JumpSystem([maj, crj, vrj, eqs[1]], t, [X, Y], [α, β]) + jsys = complete(jsys) + p = (α = 6.0, β = 2.0, X₀ = 2.0, Y₀ = 1.0) + u0map = [X => p.X₀, Y => p.Y₀] + pmap = [α => p.α, β => p.β] + tspan = (0.0, 20.0) + oprob = ODEProblem(jsys, u0map, tspan, pmap) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + times = range(0.0, tspan[2], length = 100) + Nsims = 4000 + Xv = zeros(length(times)) + Yv = zeros(length(times)) + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = times, seed) + Xv .+= sol[1, :] + Yv .+= sol[2, :] + seed += 1 + end + Xv ./= Nsims + Yv ./= Nsims + + function Yf(t, p) + local α, β, X₀, Y₀ = p + return (α / β) + (Y₀ - α / β) * exp(-β * t) + end + function Xf(t, p) + local α, β, X₀, Y₀ = p + return (α / β) + (α^2 / β^2) + α * (Y₀ - α / β) * t * exp(-β * t) + + (X₀ - α / β - α^2 / β^2) * exp(-β * t) + end + Xact = [Xf(t, p) for t in times] + Yact = [Yf(t, p) for t in times] + @test all(abs.(Xv .- Xact) .<= 0.05 .* Xv) + @test all(abs.(Yv .- Yact) .<= 0.05 .* Yv) + + function affect!(integ, u, p, ctx) + savevalues!(integ, true) + terminate!(integ) + nothing + end + cevents = [t ~ 0.2] => (affect!, [], [], [], nothing) + @named jsys = JumpSystem([maj, crj, vrj, eqs[1]], t, [X, Y], [α, β]; + continuous_events = cevents) + jsys = complete(jsys) + tspan = (0.0, 200.0) + oprob = ODEProblem(jsys, u0map, tspan, pmap) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + Xsamp = 0.0 + Nsims = 4000 + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = tspan[2], seed) + @test sol.retcode == ReturnCode.Terminated + Xsamp += sol[1, end] + seed += 1 + end + Xsamp /= Nsims + @test abs(Xsamp - Xf(0.2, p) < 0.05 * Xf(0.2, p)) +end + +@testset "JumpProcess simulation should be Int64 valued (#3446)" begin + @parameters p d + @variables X(t) + rate1 = p + rate2 = X * d + affect1 = [X ~ X + 1] + affect2 = [X ~ X - 1] + j1 = ConstantRateJump(rate1, affect1) + j2 = ConstantRateJump(rate2, affect2) + + # Works. + @mtkbuild js = JumpSystem([j1, j2], t, [X], [p, d]) + dprob = DiscreteProblem(js, [X => 15], (0.0, 10.0), [p => 2.0, d => 0.5]) + jprob = JumpProblem(js, dprob, Direct()) + sol = solve(jprob, SSAStepper()) + @test eltype(sol[X]) === Int64 +end diff --git a/test/labelledarrays.jl b/test/labelledarrays.jl index 5122cb6183..c9ee7ee50b 100644 --- a/test/labelledarrays.jl +++ b/test/labelledarrays.jl @@ -1,42 +1,92 @@ -using ModelingToolkit, StaticArrays, LinearAlgebra, LabelledArrays -using DiffEqBase, ForwardDiff -using Test - -# Define some variables -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) - -# Define a differential equation -eqs = [D(x) ~ σ*(y-x), - D(y) ~ t*x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -de = ODESystem(eqs) -ff = ODEFunction(de, [x,y,z], [σ,ρ,β], jac=true) - -a = @SVector [1.0,2.0,3.0] -b = SLVector(x=1.0,y=2.0,z=3.0) -c = [1.0,2.0,3.0] -p = SLVector(σ=10.0,ρ=26.0,β=8/3) -@test ff(a,p,0.0) isa SVector -@test typeof(ff(b,p,0.0)) <: SLArray -@test ff(c,p,0.0) isa Vector -@test ff(a,p,0.0) == ff(b,p,0.0) -@test ff(a,p,0.0) == ff(c,p,0.0) - -@test ff.jac(a,p,0.0) isa SMatrix -@test typeof(ff.jac(b,p,0.0)) <: SMatrix -@test ff.jac(c,p,0.0) isa Matrix -@test ff.jac(a,p,0.0) == ff.jac(b,p,0.0) -@test ff.jac(a,p,0.0) == ff.jac(c,p,0.0) - -# Test similar_type -@test ff(b,p,ForwardDiff.Dual(0.0,1.0)) isa SLArray -d = LVector(x=1.0,y=2.0,z=3.0) -@test ff(d,p,ForwardDiff.Dual(0.0,1.0)) isa LArray -@test ff.jac(b,p,ForwardDiff.Dual(0.0,1.0)) isa SArray -@test eltype(ff.jac(b,p,ForwardDiff.Dual(0.0,1.0))) <: ForwardDiff.Dual -@test ff.jac(d,p,ForwardDiff.Dual(0.0,1.0)) isa Array -@inferred ff.jac(d,p,ForwardDiff.Dual(0.0,1.0)) -@test eltype(ff.jac(d,p,ForwardDiff.Dual(0.0,1.0))) <: ForwardDiff.Dual +using ModelingToolkit, StaticArrays, LinearAlgebra, LabelledArrays +using DiffEqBase, ForwardDiff +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +# Define some variables +@parameters σ ρ β +@variables x(t) y(t) z(t) + +# Define a differential equation +eqs = [D(x) ~ σ * (y - x), + D(y) ~ t * x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@named de = ODESystem(eqs, t) +de = complete(de) +ff = ODEFunction(de, [x, y, z], [σ, ρ, β], jac = true) + +a = @SVector [1.0, 2.0, 3.0] +b = SLVector(x = 1.0, y = 2.0, z = 3.0) +c = [1.0, 2.0, 3.0] +p = SLVector(σ = 10.0, ρ = 26.0, β = 8 / 3) +@test ff(a, p, 0.0) isa SVector +@test typeof(ff(b, p, 0.0)) <: SLArray +@test ff(c, p, 0.0) isa Vector +@test ff(a, p, 0.0) == ff(b, p, 0.0) +@test ff(a, p, 0.0) == ff(c, p, 0.0) + +@test ff.jac(a, p, 0.0) isa SMatrix +@test typeof(ff.jac(b, p, 0.0)) <: SMatrix +@test ff.jac(c, p, 0.0) isa Matrix +@test ff.jac(a, p, 0.0) == ff.jac(b, p, 0.0) +@test ff.jac(a, p, 0.0) == ff.jac(c, p, 0.0) + +# Test similar_type +@test ff(b, p, ForwardDiff.Dual(0.0, 1.0)) isa SLArray +d = LVector(x = 1.0, y = 2.0, z = 3.0) +@test ff(d, p, ForwardDiff.Dual(0.0, 1.0)) isa LArray +@test ff.jac(b, p, ForwardDiff.Dual(0.0, 1.0)) isa SArray +@test eltype(ff.jac(b, p, ForwardDiff.Dual(0.0, 1.0))) <: ForwardDiff.Dual +@test ff.jac(d, p, ForwardDiff.Dual(0.0, 1.0)) isa Array +@inferred ff.jac(d, p, ForwardDiff.Dual(0.0, 1.0)) +@test eltype(ff.jac(d, p, ForwardDiff.Dual(0.0, 1.0))) <: ForwardDiff.Dual + +## https://github.com/SciML/ModelingToolkit.jl/issues/1054 +using LabelledArrays +using ModelingToolkit + +# ODE model: simple SIR model with seasonally forced contact rate +function SIR!(du, u, p, t) + + # Unknowns + (S, I, R) = u[1:3] + N = S + I + R + + # params + β = p.β + η = p.η + φ = p.φ + ω = 1.0 / p.ω + μ = p.μ + σ = p.σ + + # FOI + βeff = β * (1.0 + η * cos(2.0 * π * (t - φ) / 365.0)) + λ = βeff * I / N + + # change in unknowns + du[1] = (μ * N - λ * S - μ * S + ω * R) + du[2] = (λ * S - σ * I - μ * I) + du[3] = (σ * I - μ * R - ω * R) + du[4] = (σ * I) # cumulative incidence +end + +# Solver settings +tmin = 0.0 +tmax = 10.0 * 365.0 +tspan = (tmin, tmax) + +# Initiate ODE problem +theta_fix = [1.0 / (80 * 365)] +theta_est = [0.28, 0.07, 1.0 / 365.0, 1.0, 1.0 / 5.0] +p = @LArray [theta_est; theta_fix] (:β, :η, :ω, :φ, :σ, :μ) +u0 = @LArray [9998.0, 1.0, 1.0, 1.0] (:S, :I, :R, :C) + +# Initiate ODE problem +problem = ODEProblem(SIR!, u0, tspan, p) +sys = complete(modelingtoolkitize(problem)) + +@test all(any(isequal(x), parameters(sys)) +for x in ModelingToolkit.unwrap.(@variables(β, η, ω, φ, σ, μ))) +@test all(isequal.(Symbol.(unknowns(sys)), Symbol.(@variables(S(t), I(t), R(t), C(t))))) diff --git a/test/latexify.jl b/test/latexify.jl index 28efafd129..105a17ca6e 100644 --- a/test/latexify.jl +++ b/test/latexify.jl @@ -1,6 +1,9 @@ using Test using Latexify using ModelingToolkit +using ReferenceTests +using ModelingToolkit: t_nounits as t, D_nounits as D +using ModelingToolkitStandardLibrary.Blocks ### Tips for generating latex tests: ### Latexify has an unexported macro: @@ -18,57 +21,40 @@ using ModelingToolkit ### Just be sure to remove all such macros before you commit a change since it ### will cause issues with Travis. -@parameters t σ ρ β +@parameters σ ρ β @variables x(t) y(t) z(t) -D = Differential(t) - -eqs = [D(x) ~ σ*(y-x)*D(x-y)/D(z), - 0 ~ σ*x*(ρ-z)/10-y, - D(z) ~ x*y^(2//3) - β*z] +eqs = [D(x) ~ σ * (y - x) * D(x - y) / D(z), + 0 ~ σ * x * (ρ - z) / 10 - y, + D(z) ~ x * y^(2 // 3) - β * z] # Latexify.@generate_test latexify(eqs) -@test latexify(eqs) == replace( -raw"\begin{align} -\frac{dx(t)}{dt} =& \frac{\sigma \mathrm{\frac{d}{d t}}\left( x\left( t \right) - y\left( t \right) \right) \left( y\left( t \right) - x\left( t \right) \right)}{\frac{dz(t)}{dt}} \\ -0 =& - y\left( t \right) + 0.1 \sigma x\left( t \right) \left( \rho - z\left( t \right) \right) \\ -\frac{dz(t)}{dt} =& \left( y\left( t \right) \right)^{\frac{2}{3}} x\left( t \right) - \beta z\left( t \right) -\end{align} -", "\r\n"=>"\n") +@test_reference "latexify/10.tex" latexify(eqs) -@variables u[1:3](t) +@variables u(t)[1:3] @parameters p[1:3] -eqs = [D(u[1]) ~ p[3]*(u[2]-u[1]), - 0 ~ p[2]*p[3]*u[1]*(p[1]-u[1])/10-u[2], - D(u[3]) ~ u[1]*u[2]^(2//3) - p[3]*u[3]] - -@test latexify(eqs) == replace( -raw"\begin{align} -\frac{du{_1}(t)}{dt} =& p{_3} \left( \mathrm{u{_2}}\left( t \right) - \mathrm{u{_1}}\left( t \right) \right) \\ -0 =& - \mathrm{u{_2}}\left( t \right) + 0.1 p{_2} p{_3} \mathrm{u{_1}}\left( t \right) \left( p{_1} - \mathrm{u{_1}}\left( t \right) \right) \\ -\frac{du{_3}(t)}{dt} =& \left( \mathrm{u{_2}}\left( t \right) \right)^{\frac{2}{3}} \mathrm{u{_1}}\left( t \right) - p{_3} \mathrm{u{_3}}\left( t \right) -\end{align} -", "\r\n"=>"\n") +eqs = [D(u[1]) ~ p[3] * (u[2] - u[1]), + 0 ~ p[2] * p[3] * u[1] * (p[1] - u[1]) / 10 - u[2], + D(u[3]) ~ u[1] * u[2]^(2 // 3) - p[3] * u[3]] -eqs = [D(u[1]) ~ p[3]*(u[2]-u[1]), - D(u[2]) ~ p[2]*p[3]*u[1]*(p[1]-u[1])/10-u[2], - D(u[3]) ~ u[1]*u[2]^(2//3) - p[3]*u[3]] +@test_reference "latexify/20.tex" latexify(eqs) -@test latexify(eqs) == replace( -raw"\begin{align} -\frac{du{_1}(t)}{dt} =& p{_3} \left( \mathrm{u{_2}}\left( t \right) - \mathrm{u{_1}}\left( t \right) \right) \\ -\frac{du{_2}(t)}{dt} =& - \mathrm{u{_2}}\left( t \right) + 0.1 p{_2} p{_3} \mathrm{u{_1}}\left( t \right) \left( p{_1} - \mathrm{u{_1}}\left( t \right) \right) \\ -\frac{du{_3}(t)}{dt} =& \left( \mathrm{u{_2}}\left( t \right) \right)^{\frac{2}{3}} \mathrm{u{_1}}\left( t \right) - p{_3} \mathrm{u{_3}}\left( t \right) -\end{align} -", "\r\n"=>"\n") +eqs = [D(u[1]) ~ p[3] * (u[2] - u[1]), + D(u[2]) ~ p[2] * p[3] * u[1] * (p[1] - u[1]) / 10 - u[2], + D(u[3]) ~ u[1] * u[2]^(2 // 3) - p[3] * u[3]] -@parameters t +@test_reference "latexify/30.tex" latexify(eqs) @variables x(t) -D = Differential(t) -eqs = [D(x) ~ (1+cos(t))/(1+2*x)] +eqs = [D(x) ~ (1 + cos(t)) / (1 + 2 * x)] + +@test_reference "latexify/40.tex" latexify(eqs) + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) + +ap = AnalysisPoint(:plant_input) +eqs = [connect(P.output, C.input) + connect(C.output, ap, P.input)] +sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) -@test latexify(eqs) == replace( -raw"\begin{align} -\frac{dx(t)}{dt} =& \frac{\left( 1 + \cos\left( t \right) \right)}{\left( 1 + 2 x\left( t \right) \right)} -\end{align} -", "\r\n"=>"\n") +@test_reference "latexify/50.tex" latexify(sys_ap) diff --git a/test/latexify/10.tex b/test/latexify/10.tex new file mode 100644 index 0000000000..d91a4295ae --- /dev/null +++ b/test/latexify/10.tex @@ -0,0 +1,5 @@ +\begin{align} +\frac{\mathrm{d} x\left( t \right)}{\mathrm{d}t} &= \frac{\left( - x\left( t \right) + y\left( t \right) \right) \frac{\mathrm{d}}{\mathrm{d}t} \left( x\left( t \right) - y\left( t \right) \right) \sigma}{\frac{\mathrm{d} z\left( t \right)}{\mathrm{d}t}} \\ +0 &= - y\left( t \right) + \frac{1}{10} x\left( t \right) \left( - z\left( t \right) + \rho \right) \sigma \\ +\frac{\mathrm{d} z\left( t \right)}{\mathrm{d}t} &= \left( y\left( t \right) \right)^{\frac{2}{3}} x\left( t \right) - z\left( t \right) \beta +\end{align} diff --git a/test/latexify/20.tex b/test/latexify/20.tex new file mode 100644 index 0000000000..012243e981 --- /dev/null +++ b/test/latexify/20.tex @@ -0,0 +1,5 @@ +\begin{align} +\frac{\mathrm{d} u\_{1}\left( t \right)}{\mathrm{d}t} &= p_{3} \left( - u\_{1}\left( t \right) + u\_{2}\left( t \right) \right) \\ +0 &= - u\_{2}\left( t \right) + \frac{1}{10} \left( p_{1} - u\_{1}\left( t \right) \right) p_{2} p_{3} u\_{1}\left( t \right) \\ +\frac{\mathrm{d} u\_{3}\left( t \right)}{\mathrm{d}t} &= u\_{2}\left( t \right)^{\frac{2}{3}} u\_{1}\left( t \right) - p_{3} u\_{3}\left( t \right) +\end{align} diff --git a/test/latexify/30.tex b/test/latexify/30.tex new file mode 100644 index 0000000000..b51b73c34b --- /dev/null +++ b/test/latexify/30.tex @@ -0,0 +1,5 @@ +\begin{align} +\frac{\mathrm{d} u\_{1}\left( t \right)}{\mathrm{d}t} &= p_{3} \left( - u\_{1}\left( t \right) + u\_{2}\left( t \right) \right) \\ +\frac{\mathrm{d} u\_{2}\left( t \right)}{\mathrm{d}t} &= - u\_{2}\left( t \right) + \frac{1}{10} \left( p_{1} - u\_{1}\left( t \right) \right) p_{2} p_{3} u\_{1}\left( t \right) \\ +\frac{\mathrm{d} u\_{3}\left( t \right)}{\mathrm{d}t} &= u\_{2}\left( t \right)^{\frac{2}{3}} u\_{1}\left( t \right) - p_{3} u\_{3}\left( t \right) +\end{align} diff --git a/test/latexify/40.tex b/test/latexify/40.tex new file mode 100644 index 0000000000..3807185ae2 --- /dev/null +++ b/test/latexify/40.tex @@ -0,0 +1,3 @@ +\begin{align} +\frac{\mathrm{d} x\left( t \right)}{\mathrm{d}t} &= \frac{1 + \cos\left( t \right)}{1 + 2 x\left( t \right)} +\end{align} diff --git a/test/latexify/50.tex b/test/latexify/50.tex new file mode 100644 index 0000000000..b1e6a1fda4 --- /dev/null +++ b/test/latexify/50.tex @@ -0,0 +1,19 @@ +\begin{equation} +\left[ +\begin{array}{c} +\mathrm{connect}\left( P_{+}output, C_{+}input \right) \\ +AnalysisPoint\left( \mathtt{C.output.u}\left( t \right), plant\_input, \left[ +\begin{array}{c} +\mathtt{P.input.u}\left( t \right) \\ +\end{array} +\right] \right) \\ +\mathtt{P.u}\left( t \right) = \mathtt{P.input.u}\left( t \right) \\ +\mathtt{P.y}\left( t \right) = \mathtt{P.output.u}\left( t \right) \\ +\mathtt{P.y}\left( t \right) = \mathtt{P.x}\left( t \right) \\ +\frac{\mathrm{d} \mathtt{P.x}\left( t \right)}{\mathrm{d}t} = \frac{ - \mathtt{P.x}\left( t \right) + \mathtt{P.k} \mathtt{P.u}\left( t \right)}{\mathtt{P.T}} \\ +\mathtt{C.u}\left( t \right) = \mathtt{C.input.u}\left( t \right) \\ +\mathtt{C.y}\left( t \right) = \mathtt{C.output.u}\left( t \right) \\ +\mathtt{C.y}\left( t \right) = \mathtt{C.k} \mathtt{C.u}\left( t \right) \\ +\end{array} +\right] +\end{equation} diff --git a/test/linalg.jl b/test/linalg.jl new file mode 100644 index 0000000000..be6fb39b1b --- /dev/null +++ b/test/linalg.jl @@ -0,0 +1,30 @@ +using ModelingToolkit +using LinearAlgebra +using Test + +A = [0 1 1 2 2 1 1 2 1 2 + 0 1 -1 -3 -2 2 1 -5 0 -5 + 0 1 2 2 1 1 2 1 1 2 + 0 1 1 1 2 1 1 2 2 1 + 0 2 1 2 2 2 2 1 1 1 + 0 1 1 1 2 2 1 1 2 1 + 0 2 1 2 2 1 2 1 1 2 + 0 1 7 17 14 2 1 19 4 23 + 0 1 -1 -3 -2 1 1 -4 0 -5 + 0 1 1 2 2 1 1 2 2 2] +N = ModelingToolkit.nullspace(A) +@test size(N, 2) == 3 +@test rank(N) == 3 +@test iszero(A * N) + +A = [0 1 2 0 1 0; + 0 0 0 0 0 1; + 0 0 0 0 0 1; + 1 0 1 2 0 1; + 0 0 0 2 1 0] +col_order = Int[] +N = ModelingToolkit.nullspace(A; col_order) +colspan = A[:, col_order[1:4]] # rank is 4 +@test iszero(ModelingToolkit.nullspace(colspan)) +@test !iszero(ModelingToolkit.nullspace(A[:, col_order[1:5]])) +@test !iszero(ModelingToolkit.nullspace(A[:, [col_order[1:4]..., col_order[6]]])) diff --git a/test/linearity.jl b/test/linearity.jl index e26a15c91d..aed9a256d2 100644 --- a/test/linearity.jl +++ b/test/linearity.jl @@ -3,18 +3,25 @@ using DiffEqBase using Test # Define some variables -@parameters t σ ρ β +@independent_variables t +@parameters σ ρ β @variables x(t) y(t) z(t) D = Differential(t) -eqs = [D(x) ~ σ*(y-x), - D(y) ~ -z-y, - D(z) ~ y - β*z] +eqs = [D(x) ~ σ * (y - x), + D(y) ~ -z - y, + D(z) ~ y - β * z] -@test ModelingToolkit.islinear(ODESystem(eqs)) +@test ModelingToolkit.islinear(@named sys = ODESystem(eqs, t)) -eqs2 = [D(x) ~ σ*(y-x), - D(y) ~ -z-1/y, - D(z) ~ y - β*z] +eqs2 = [D(x) ~ σ * (y - x), + D(y) ~ -z - 1 / y, + D(z) ~ y - β * z] -@test !ModelingToolkit.islinear(ODESystem(eqs2)) +@test !ModelingToolkit.islinear(@named sys = ODESystem(eqs2, t)) + +eqs3 = [D(x) ~ σ * (y - x), + D(y) ~ -z - y, + D(z) ~ y - β * z + 1] + +@test ModelingToolkit.isaffine(@named sys = ODESystem(eqs, t)) diff --git a/test/log.txt b/test/log.txt new file mode 100644 index 0000000000..1b40d3fa68 --- /dev/null +++ b/test/log.txt @@ -0,0 +1,2 @@ +integrator.t: 1.0 +integrator.u: [0.9999999999999998] diff --git a/test/lowering_solving.jl b/test/lowering_solving.jl index e690daa082..bfaeee60cf 100644 --- a/test/lowering_solving.jl +++ b/test/lowering_solving.jl @@ -1,76 +1,77 @@ -using ModelingToolkit, OrdinaryDiffEq, Test, LinearAlgebra - -@parameters t σ ρ β -@variables x(t) y(t) z(t) k(t) -D = Differential(t) - -eqs = [D(D(x)) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -sys′ = ODESystem(eqs) -sys = ode_order_lowering(sys′) - -eqs2 = [0 ~ x*y - k, - D(D(x)) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] -sys2 = ODESystem(eqs2, t, [x, y, z, k], parameters(sys′)) -sys2 = ode_order_lowering(sys2) -# test equation/varible ordering -ModelingToolkit.calculate_massmatrix(sys2) == Diagonal([1, 1, 1, 1, 0]) - -u0 = [D(x) => 2.0, - x => 1.0, - y => 0.0, - z => 0.0] - -p = [σ => 28.0, - ρ => 10.0, - β => 8/3] - -tspan = (0.0,100.0) -prob = ODEProblem(sys,u0,tspan,p,jac=true) -probexpr = ODEProblemExpr(sys,u0,tspan,p,jac=true) -sol = solve(prob,Tsit5()) -solexpr = solve(eval(prob),Tsit5()) -@test all(x->x==0,Array(sol - solexpr)) -#using Plots; plot(sol,vars=(:x,:y)) - -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) - -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -lorenz1 = ODESystem(eqs,name=:lorenz1) -lorenz2 = ODESystem(eqs,name=:lorenz2) - -@variables α -@parameters γ -connections = [0 ~ lorenz1.x + lorenz2.y + α*γ] -connected = ODESystem(connections,t,[α],[γ],systems=[lorenz1,lorenz2]) - -u0 = [lorenz1.x => 1.0, - lorenz1.y => 0.0, - lorenz1.z => 0.0, - lorenz2.x => 0.0, - lorenz2.y => 1.0, - lorenz2.z => 0.0, - α => 2.0] - -p = [lorenz1.σ => 10.0, - lorenz1.ρ => 28.0, - lorenz1.β => 8/3, - lorenz2.σ => 10.0, - lorenz2.ρ => 28.0, - lorenz2.β => 8/3, - γ => 2.0] - -tspan = (0.0,100.0) -prob = ODEProblem(connected,u0,tspan,p) -sol = solve(prob,Rodas5()) -@test maximum(sol[2,:] + sol[6,:] + 2sol[1,:]) < 1e-12 -#using Plots; plot(sol,vars=(:α,Symbol(lorenz1.x),Symbol(lorenz2.y))) +using ModelingToolkit, OrdinaryDiffEq, Test, LinearAlgebra +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters σ ρ β +@variables x(t) y(t) z(t) k(t) + +eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@named sys′ = ODESystem(eqs, t) +sys = ode_order_lowering(sys′) + +eqs2 = [0 ~ x * y - k, + D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] +@named sys2 = ODESystem(eqs2, t, [x, y, z, k], parameters(sys′)) +sys2 = ode_order_lowering(sys2) +# test equation/variable ordering +ModelingToolkit.calculate_massmatrix(sys2) == Diagonal([1, 1, 1, 1, 0]) + +u0 = [D(x) => 2.0, + x => 1.0, + y => 0.0, + z => 0.0] + +p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +tspan = (0.0, 100.0) + +sys = complete(sys) +prob = ODEProblem(sys, u0, tspan, p, jac = true) +probexpr = ODEProblemExpr(sys, u0, tspan, p, jac = true) +sol = solve(prob, Tsit5()) +solexpr = solve(eval(prob), Tsit5()) +@test all(x -> x == 0, Array(sol - solexpr)) +#using Plots; plot(sol,idxs=(:x,:y)) + +@parameters σ ρ β +@variables x(t) y(t) z(t) + +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +lorenz1 = ODESystem(eqs, t, name = :lorenz1) +lorenz2 = ODESystem(eqs, t, name = :lorenz2) + +@variables α(t) +@parameters γ +connections = [0 ~ lorenz1.x + lorenz2.y + α * γ] +@named connected = ODESystem(connections, t, [α], [γ], systems = [lorenz1, lorenz2]) +connected = complete(connected) +u0 = [lorenz1.x => 1.0, + lorenz1.y => 0.0, + lorenz1.z => 0.0, + lorenz2.x => 0.0, + lorenz2.y => 1.0, + lorenz2.z => 0.0, + α => 2.0] + +p = [lorenz1.σ => 10.0, + lorenz1.ρ => 28.0, + lorenz1.β => 8 / 3, + lorenz2.σ => 10.0, + lorenz2.ρ => 28.0, + lorenz2.β => 8 / 3, + γ => 2.0] + +tspan = (0.0, 100.0) +prob = ODEProblem(connected, u0, tspan, p) +sol = solve(prob, Rodas5()) +@test maximum(sol[2, :] + sol[6, :] + 2sol[1, :]) < 1e-12 +#using Plots; plot(sol,idxs=(:α,Symbol(lorenz1.x),Symbol(lorenz2.y))) diff --git a/test/mass_matrix.jl b/test/mass_matrix.jl index fbe9b831bb..5183b4ab3f 100644 --- a/test/mass_matrix.jl +++ b/test/mass_matrix.jl @@ -1,36 +1,45 @@ -using OrdinaryDiffEq, ModelingToolkit, Test -@parameters t -@variables y[1:3](t) -@parameters k[1:3] -D = Differential(t) - -eqs = [D(y[1]) ~ -k[1]*y[1] + k[3]*y[2]*y[3], - D(y[2]) ~ k[1]*y[1] - k[3]*y[2]*y[3] - k[2]*y[2]^2, - 0 ~ y[1] + y[2] + y[3] - 1] - -sys = ODESystem(eqs,t,y,k) -@test_throws ArgumentError ODESystem(eqs,y[1]) -M = calculate_massmatrix(sys) -@test M == [1 0 0 - 0 1 0 - 0 0 0] - -f = ODEFunction(sys) -prob_mm = ODEProblem(f,[1.0,0.0,0.0],(0.0,1e5),(0.04,3e7,1e4)) -sol = solve(prob_mm,Rodas5(),reltol=1e-8,abstol=1e-8) - -function rober(du,u,p,t) - y₁,y₂,y₃ = u - k₁,k₂,k₃ = p - du[1] = -k₁*y₁ + k₃*y₂*y₃ - du[2] = k₁*y₁ - k₃*y₂*y₃ - k₂*y₂^2 - du[3] = y₁ + y₂ + y₃ - 1 - nothing -end -f = ODEFunction(rober,mass_matrix=M) -prob_mm2 = ODEProblem(f,[1.0,0.0,0.0],(0.0,1e5),(0.04,3e7,1e4)) -sol2 = solve(prob_mm2,Rodas5(),reltol=1e-8,abstol=1e-8,tstops=sol.t,adaptive=false) - -# MTK expression are canonicalized, so the floating point numbers are slightly -# different -@test Array(sol) ≈ Array(sol2) +using OrdinaryDiffEq, ModelingToolkit, Test, LinearAlgebra +using ModelingToolkit: t_nounits as t, D_nounits as D, MTKParameters + +@variables y(t)[1:3] +@parameters k[1:3] + +eqs = [D(y[1]) ~ -k[1] * y[1] + k[3] * y[2] * y[3], + D(y[2]) ~ k[1] * y[1] - k[3] * y[2] * y[3] - k[2] * y[2]^2, + 0 ~ y[1] + y[2] + y[3] - 1] + +@named sys = ODESystem(eqs, t, collect(y), [k]) +sys = complete(sys) +@test_throws ArgumentError ODESystem(eqs, y[1]) +M = calculate_massmatrix(sys) +@test M == [1 0 0 + 0 1 0 + 0 0 0] + +prob_mm = ODEProblem(sys, [y => [1.0, 0.0, 0.0]], (0.0, 1e5), + [k => [0.04, 3e7, 1e4]]) +sol = solve(prob_mm, Rodas5(), reltol = 1e-8, abstol = 1e-8) + +function rober(du, u, p, t) + y₁, y₂, y₃ = u + k₁, k₂, k₃ = p + du[1] = -k₁ * y₁ + k₃ * y₂ * y₃ + du[2] = k₁ * y₁ - k₃ * y₂ * y₃ - k₂ * y₂^2 + du[3] = y₁ + y₂ + y₃ - 1 + nothing +end +f = ODEFunction(rober, mass_matrix = M) +prob_mm2 = ODEProblem(f, [1.0, 0.0, 0.0], (0.0, 1e5), (0.04, 3e7, 1e4)) +sol2 = solve(prob_mm2, Rodas5(), reltol = 1e-8, abstol = 1e-8, tstops = sol.t, + adaptive = false) + +# MTK expression are canonicalized, so the floating point numbers are slightly +# different +@test Array(sol) ≈ Array(sol2) + +# Test mass matrix in the identity case +eqs = [D(y[1]) ~ y[1], D(y[2]) ~ y[2], D(y[3]) ~ y[3]] + +@named sys = ODESystem(eqs, t, collect(y), [k]) + +@test calculate_massmatrix(sys) === I diff --git a/test/model_parsing.jl b/test/model_parsing.jl new file mode 100644 index 0000000000..e8464707de --- /dev/null +++ b/test/model_parsing.jl @@ -0,0 +1,1028 @@ +using ModelingToolkit, Symbolics, Test +using ModelingToolkit: get_connector_type, get_defaults, get_gui_metadata, + get_systems, get_ps, getdefault, getname, readable_code, + scalarize, symtype, VariableDescription, RegularConnector, + get_unit +using SymbolicIndexingInterface +using URIs: URI +using Distributions +using DynamicQuantities, OrdinaryDiffEq +using ModelingToolkit: t, D + +ENV["MTK_ICONS_DIR"] = "$(@__DIR__)/icons" + +# Mock module used to test if the `@mtkmodel` macro works with fully-qualified names as well. +module MyMockModule +using ModelingToolkit, DynamicQuantities +using ModelingToolkit: t, D + +export Pin +@connector Pin begin + v(t), [unit = u"V"] # Potential at the pin [V] + i(t), [connect = Flow, unit = u"A"] # Current flowing into the pin [A] + @icon "pin.png" +end + +ground_logo = read(abspath(ENV["MTK_ICONS_DIR"], "ground.svg"), String) +@mtkmodel Ground begin + @components begin + g = Pin() + end + @icon ground_logo + @equations begin + g.v ~ 0 + end +end +end + +using .MyMockModule + +@connector RealInput begin + u(t), [input = true, unit = u"V"] +end +@connector RealOutput begin + u(t), [output = true, unit = u"V"] +end +@mtkmodel Constant begin + @components begin + output = RealOutput() + end + @parameters begin + k, [description = "Constant output value of block", unit = u"V"] + end + @equations begin + output.u ~ k + end +end + +@named p = Pin(; v = π * u"V") + +@test getdefault(p.v) ≈ π +@test Pin.isconnector == true + +@mtkmodel OnePort begin + @components begin + p = Pin() + n = Pin() + end + @variables begin + v(t), [unit = u"V"] + i(t), [unit = u"A"] + end + @icon "oneport.png" + @equations begin + v ~ p.v - n.v + 0 ~ p.i + n.i + i ~ p.i + end +end + +@test OnePort.isconnector == false + +@mtkmodel Resistor begin + @extend v, i = oneport = OnePort() + @parameters begin + R, [unit = u"Ω"] + end + @icon """ + + + +""" + @equations begin + v ~ i * R + end +end + +@mtkmodel Capacitor begin + @parameters begin + C, [unit = u"F"] + end + @extend OnePort(; v = 0.0u"V") + @icon "https://upload.wikimedia.org/wikipedia/commons/7/78/Capacitor_symbol.svg" + @equations begin + D(v) ~ i / C + end +end + +@named capacitor = Capacitor(C = 10u"F", v = 10.0u"V") +@test getdefault(capacitor.v) == 10.0 + +@mtkmodel Voltage begin + @extend v, i = oneport = OnePort() + @components begin + V = RealInput() + end + @equations begin + v ~ V.u + end +end + +@mtkmodel RC begin + @description "An RC circuit." + @structural_parameters begin + R_val = 10u"Ω" + C_val = 10u"F" + k_val = 10u"V" + end + @components begin + resistor = Resistor(; R = R_val) + capacitor = Capacitor(; C = C_val) + source = Voltage() + constant = Constant(; k = k_val) + ground = MyMockModule.Ground() + end + @equations begin + connect(constant.output, source.V) + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + end +end + +C_val = 20u"F" +R_val = 20u"Ω" +res__R = 100u"Ω" +@mtkbuild rc = RC(; C_val, R_val, resistor.R = res__R) +prob = ODEProblem(rc, [], (0, 1e9)) +sol = solve(prob) +defs = ModelingToolkit.defaults(rc) +@test sol[rc.capacitor.v, end] ≈ defs[rc.constant.k] +resistor = getproperty(rc, :resistor; namespace = false) +@test ModelingToolkit.description(rc) == "An RC circuit." +@test getname(rc.resistor) === getname(resistor) +@test getname(rc.resistor.R) === getname(resistor.R) +@test getname(rc.resistor.v) === getname(resistor.v) +# Test that `resistor.R` overrides `R_val` in the argument. +@test getdefault(rc.resistor.R) * get_unit(rc.resistor.R) == res__R != R_val +# Test that `C_val` passed via argument is set as default of C. +@test getdefault(rc.capacitor.C) * get_unit(rc.capacitor.C) == C_val +# Test that `k`'s default value is unchanged. +@test getdefault(rc.constant.k) * get_unit(rc.constant.k) == + eval(RC.structure[:kwargs][:k_val][:value]) +@test getdefault(rc.capacitor.v) == 0.0 + +@test get_gui_metadata(rc.resistor).layout == Resistor.structure[:icon] == + read(joinpath(ENV["MTK_ICONS_DIR"], "resistor.svg"), String) +@test get_gui_metadata(rc.ground).layout == + read(abspath(ENV["MTK_ICONS_DIR"], "ground.svg"), String) +@test get_gui_metadata(rc.capacitor).layout == + URI("https://upload.wikimedia.org/wikipedia/commons/7/78/Capacitor_symbol.svg") +@test OnePort.structure[:icon] == + URI("file:///" * abspath(ENV["MTK_ICONS_DIR"], "oneport.png")) +@test ModelingToolkit.get_gui_metadata(rc.resistor.p).layout == Pin.structure[:icon] == + URI("file:///" * abspath(ENV["MTK_ICONS_DIR"], "pin.png")) + +@test length(equations(rc)) == 1 + +@testset "Constants" begin + @mtkmodel PiModel begin + @constants begin + _p::Irrational = π, [description = "Value of Pi.", unit = u"V"] + end + @parameters begin + p = _p, [description = "Assign constant `_p` value."] + e, [unit = u"V"] + end + @equations begin + # This validates units; indirectly verifies that metadata was correctly passed. + e ~ _p + end + end + + @named pi_model = PiModel() + + @test typeof(ModelingToolkit.getdefault(pi_model.p)) <: + SymbolicUtils.BasicSymbolic{Irrational} + @test getdefault(getdefault(pi_model.p)) == π +end + +@testset "Parameters and Structural parameters in various modes" begin + @mtkmodel MockModel begin + @parameters begin + a + a2[1:2] + b(t) + b2(t)[1:2] + cval + jval + kval + c(t) = cval + jval + d = 2 + d2[1:2] = 2 + e, [description = "e"] + e2[1:2], [description = "e2"] + f = 3, [description = "f"] + h(t), [description = "h(t)"] + h2(t)[1:2], [description = "h2(t)"] + i(t) = 4, [description = "i(t)"] + j(t) = jval, [description = "j(t)"] + k = kval, [description = "k"] + l(t)[1:2, 1:3] = 2, [description = "l is more than 1D"] + n # test defaults with Number input + n2 # test defaults with Function input + end + @structural_parameters begin + m = 1 + func + end + begin + g() = 5 + end + @defaults begin + n => 1.0 + n2 => g() + end + end + + kval = 5 + @named model = MockModel(; b2 = [1, 3], kval, cval = 1, func = identity) + + @test lastindex(parameters(model)) == 31 + + @test all(getdescription.([model.e2...]) .== "e2") + @test all(getdescription.([model.h2...]) .== "h2(t)") + + @test hasmetadata(model.e, VariableDescription) + @test hasmetadata(model.f, VariableDescription) + @test hasmetadata(model.h, VariableDescription) + @test hasmetadata(model.i, VariableDescription) + @test hasmetadata(model.j, VariableDescription) + @test hasmetadata(model.k, VariableDescription) + @test all(collect(hasmetadata.(model.l, ModelingToolkit.VariableDescription))) + + @test all(lastindex.([model.a2, model.b2, model.d2, model.e2, model.h2]) .== 2) + @test size(model.l) == (2, 3) + @test MockModel.structure[:parameters][:l][:size] == (2, 3) + + model = complete(model) + @test getdefault(model.cval) == 1 + @test isequal(getdefault(model.c), model.cval + model.jval) + @test getdefault(model.d) == 2 + @test_throws ErrorException getdefault(model.e) + @test getdefault(model.f) == 3 + @test getdefault(model.i) == 4 + @test all(getdefault.(scalarize(model.b2)) .== [1, 3]) + @test all(getdefault.(scalarize(model.l)) .== 2) + @test isequal(getdefault(model.j), model.jval) + @test isequal(getdefault(model.k), model.kval) + @test get_defaults(model)[model.n] == 1.0 + @test get_defaults(model)[model.n2] == 5 + + @test MockModel.structure[:defaults] == Dict(:n => 1.0, :n2 => "g()") +end + +@testset "Arrays using vanilla-@variable syntax" begin + @mtkmodel TupleInArrayDef begin + @structural_parameters begin + N + M + end + @parameters begin + (l(t)[1:2, 1:3] = 1), [description = "l is more than 1D"] + (l2(t)[1:N, 1:M] = 2), + [description = "l is more than 1D, with arbitrary length"] + (l3(t)[1:3] = 3), [description = "l2 is 1D"] + (l4(t)[1:N] = 4), [description = "l2 is 1D, with arbitrary length"] + (l5(t)[1:3]::Int = 5), [description = "l3 is 1D and has a type"] + (l6(t)[1:N]::Int = 6), + [description = "l3 is 1D and has a type, with arbitrary length"] + end + end + + N, M = 4, 5 + @named arr = TupleInArrayDef(; N, M) + @test getdefault(arr.l) == 1 + @test getdefault(arr.l2) == 2 + @test getdefault(arr.l3) == 3 + @test getdefault(arr.l4) == 4 + @test getdefault(arr.l5) == 5 + @test getdefault(arr.l6) == 6 + + @test size(arr.l2) == (N, M) + @test size(arr.l4) == (N,) + @test size(arr.l6) == (N,) +end + +@testset "Type annotation" begin + @mtkmodel TypeModel begin + @structural_parameters begin + flag::Bool = true + end + @parameters begin + par0::Bool = true + par1::Int = 1 + par2(t)::Int, + [description = "Enforced `par4` to be an Int by setting the type to the keyword-arg."] + par3(t)::BigFloat = 1.0 + par4(t)::Float64 = 1 # converts 1 to 1.0 of Float64 type + par5[1:3]::BigFloat + par6(t)[1:3]::BigFloat + par7(t)[1:3, 1:3]::BigFloat = 1.0, [description = "with description"] + end + end + + @named type_model = TypeModel() + + @test symtype(type_model.par1) == Int + @test symtype(type_model.par2) == Int + @test symtype(type_model.par3) == BigFloat + @test symtype(type_model.par4) == Float64 + @test symtype(type_model.par5[1]) == BigFloat + @test symtype(type_model.par6[1]) == BigFloat + @test symtype(type_model.par7[1, 1]) == BigFloat + + @test_throws TypeError TypeModel(; name = :throws, flag = 1) + @test_throws TypeError TypeModel(; name = :throws, par0 = 1) + @test_throws TypeError TypeModel(; name = :throws, par1 = 1.5) + @test_throws TypeError TypeModel(; name = :throws, par2 = 1.5) + @test_throws TypeError TypeModel(; name = :throws, par3 = true) + @test_throws TypeError TypeModel(; name = :throws, par4 = true) + # par7 should be an AbstractArray of BigFloat. + @test_throws MethodError TypeModel(; name = :throws, par7 = rand(Int, 3, 3)) + + # Test that array types are correctly added. + @named type_model2 = TypeModel(; par5 = rand(BigFloat, 3)) + @test symtype(type_model2.par5[1]) == BigFloat + + @named type_model3 = TypeModel(; par7 = rand(BigFloat, 3, 3)) + @test symtype(type_model3.par7[1, 1]) == BigFloat + + # Ensure that instances of models with conditional arrays with types can be created. + @mtkmodel TypeCondition begin + @structural_parameters begin + flag + end + if flag + @parameters begin + k_if(t)[1:3, 1:3]::Float64, [description = "when true"] + end + else + @parameters begin + k_else[1:3]::Float64, [description = "when false"] + end + end + end + + @named type_condition1 = TypeCondition(; flag = true, k_if = rand(Float64, 3, 3)) + @test symtype(type_condition1.k_if[1, 2]) == Float64 + + @named type_condition2 = TypeCondition(; flag = false, k_else = rand(Float64, 3)) + @test symtype(type_condition2.k_else[1]) == Float64 +end + +@testset "Defaults of subcomponents MTKModel" begin + @mtkmodel A begin + @parameters begin + p + end + @components begin + b = B(i = p, j = 1 / p, k = 1) + end + end + + @mtkmodel B begin + @parameters begin + i + j + k + end + end + + @named a = A(p = 10) + params = get_ps(a) + @test isequal(getdefault(a.b.i), params[1]) + @test isequal(getdefault(a.b.j), 1 / params[1]) + @test getdefault(a.b.k) == 1 + + @named a = A(p = 10, b.i = 20, b.j = 30, b.k = 40) + @test getdefault(a.b.i) == 20 + @test getdefault(a.b.j) == 30 + @test getdefault(a.b.k) == 40 +end + +@testset "Metadata in variables" begin + metadata = Dict(:description => "Variable to test metadata in the Model.structure", + :input => true, :bounds => :((-1, 1)), :connection_type => :Flow, + :tunable => false, :disturbance => true, :dist => :(Normal(1, 1))) + + @connector MockMeta begin + m(t), + [description = "Variable to test metadata in the Model.structure", + input = true, bounds = (-1, 1), connect = Flow, + tunable = false, disturbance = true, dist = Normal(1, 1)] + end + + for (k, v) in metadata + @test MockMeta.structure[:variables][:m][k] == v + end +end + +@testset "Connector with parameters, equations..." begin + @connector A begin + @extend (e,) = extended_e = E() + @icon "pin.png" + @parameters begin + p + end + @variables begin + v(t) + end + @components begin + cc = C() + end + @equations begin + e ~ 0 + end + end + + @connector C begin + c(t) + end + + @connector E begin + e(t) + end + + @named aa = A() + @test get_connector_type(aa) == RegularConnector() + + @test A.isconnector == true + + @test A.structure[:parameters] == Dict(:p => Dict(:type => Real)) + @test A.structure[:extend] == [[:e], :extended_e, :E] + @test A.structure[:equations] == ["e ~ 0"] + @test A.structure[:kwargs] == Dict{Symbol, Dict}( + :p => Dict{Symbol, Union{Nothing, DataType}}(:value => nothing, :type => Real), + :v => Dict{Symbol, Union{Nothing, DataType}}(:value => nothing, :type => Real)) + @test A.structure[:components] == [[:cc, :C]] +end + +using ModelingToolkit: D_nounits +@testset "Event handling in MTKModel" begin + @mtkmodel M begin + @variables begin + x(t) + y(t) + z(t) + end + @equations begin + x ~ -D_nounits(x) + D_nounits(y) ~ 0 + D_nounits(z) ~ 0 + end + @continuous_events begin + [x ~ 1.5] => [x ~ 5, y ~ 1] + end + @discrete_events begin + (t == 1.5) => [x ~ x + 5, z ~ 2] + end + end + + @mtkbuild model = M() + u0 = [model.x => 10, model.y => 0, model.z => 0] + + prob = ODEProblem(model, u0, (0, 5.0)) + sol = solve(prob, Tsit5(), tstops = [1.5]) + + @test isequal(sol[model.y][end], 1.0) + @test isequal(sol[model.z][end], 2.0) +end + +# Ensure that modules consisting MTKModels with component arrays and icons of +# `Expr` type and `unit` metadata can be precompiled. +module PrecompilationTest +push!(LOAD_PATH, joinpath(@__DIR__, "precompile_test")) +using Unitful, Test, ModelParsingPrecompile, ModelingToolkit +using ModelingToolkit: getdefault, scalarize +@testset "Precompile packages with MTKModels" begin + using ModelParsingPrecompile: ModelWithComponentArray + + @named model_with_component_array = ModelWithComponentArray() + + @test eval(ModelWithComponentArray.structure[:parameters][:r][:unit]) == + eval(u"Ω") + @test lastindex(parameters(model_with_component_array)) == 3 + + # Test the constant `k`. Manually k's value should be kept in sync here + # and the ModelParsingPrecompile. + @test all(getdefault.(getdefault.(scalarize(model_with_component_array.r))) .== 1) + + pop!(LOAD_PATH) +end +end + +@testset "Conditional statements inside the blocks" begin + @mtkmodel C begin end + + # Conditional statements inside @components, @equations + # Conditional default value of parameters and variables + @mtkmodel InsideTheBlock begin + @structural_parameters begin + flag = 1 + end + @parameters begin + eq = flag == 1 ? 1 : 0 + if flag == 1 + if_parameter = 100 + elseif flag == 2 + elseif_parameter = 101 + else + else_parameter = 102 + end + end + @components begin + default_sys = C() + if flag == 1 + if_sys = C() + elseif flag == 2 + elseif_sys = C() + else + else_sys = C() + end + end + @equations begin + eq ~ 0 + if flag == 1 + eq ~ 1 + elseif flag == 2 + eq ~ 2 + else + eq ~ 3 + end + flag == 1 ? eq ~ 4 : eq ~ 5 + end + end + + @named if_in_sys = InsideTheBlock() + if_in_sys = complete(if_in_sys; flatten = false) + @named elseif_in_sys = InsideTheBlock(flag = 2) + elseif_in_sys = complete(elseif_in_sys; flatten = false) + @named else_in_sys = InsideTheBlock(flag = 3) + else_in_sys = complete(else_in_sys; flatten = false) + + @test sort(getname.(parameters(if_in_sys))) == [:eq, :if_parameter] + @test sort(getname.(parameters(elseif_in_sys))) == [:elseif_parameter, :eq] + @test sort(getname.(parameters(else_in_sys))) == [:else_parameter, :eq] + + @test getdefault(if_in_sys.if_parameter) == 100 + @test getdefault(elseif_in_sys.elseif_parameter) == 101 + @test getdefault(else_in_sys.else_parameter) == 102 + + @test nameof.(get_systems(if_in_sys)) == [:if_sys, :default_sys] + @test nameof.(get_systems(elseif_in_sys)) == [:elseif_sys, :default_sys] + @test nameof.(get_systems(else_in_sys)) == [:else_sys, :default_sys] + + @test all([ + if_in_sys.eq ~ 0, + if_in_sys.eq ~ 1, + if_in_sys.eq ~ 4 + ] .∈ [equations(if_in_sys)]) + @test all([ + elseif_in_sys.eq ~ 0, + elseif_in_sys.eq ~ 2, + elseif_in_sys.eq ~ 5 + ] .∈ [equations(elseif_in_sys)]) + @test all([ + else_in_sys.eq ~ 0, + else_in_sys.eq ~ 3, + else_in_sys.eq ~ 5 + ] .∈ [equations(else_in_sys)]) + + @test getdefault(if_in_sys.eq) == 1 + @test getdefault(elseif_in_sys.eq) == 0 +end + +@testset "Conditional statements outside the blocks" begin + @mtkmodel C begin end + + # Branching statement outside the begin blocks + @mtkmodel OutsideTheBlock begin + @structural_parameters begin + condition = 0 + end + + @parameters begin + default_parameter + end + @components begin + default_sys = C() + end + @equations begin + default_parameter ~ 0 + end + + if condition == 1 + @parameters begin + if_parameter = 100 + end + @equations begin + if_parameter ~ 0 + end + @components begin + if_sys = C() + end + elseif condition == 2 + @parameters begin + elseif_parameter = 101 + end + @equations begin + elseif_parameter ~ 0 + end + @components begin + elseif_sys = C() + end + else + @parameters begin + else_parameter = 102 + end + @equations begin + else_parameter ~ 0 + end + @components begin + else_sys = C() + end + end + end + + @named if_out_sys = OutsideTheBlock(condition = 1) + if_out_sys = complete(if_out_sys; flatten = false) + @named elseif_out_sys = OutsideTheBlock(condition = 2) + elseif_out_sys = complete(elseif_out_sys; flatten = false) + @named else_out_sys = OutsideTheBlock(condition = 10) + else_out_sys = complete(else_out_sys; flatten = false) + @named ternary_out_sys = OutsideTheBlock(condition = 4) + else_out_sys = complete(else_out_sys; flatten = false) + + @test getname.(parameters(if_out_sys)) == [:if_parameter, :default_parameter] + @test getname.(parameters(elseif_out_sys)) == [:elseif_parameter, :default_parameter] + @test getname.(parameters(else_out_sys)) == [:else_parameter, :default_parameter] + + @test getdefault(if_out_sys.if_parameter) == 100 + @test getdefault(elseif_out_sys.elseif_parameter) == 101 + @test getdefault(else_out_sys.else_parameter) == 102 + + @test nameof.(get_systems(if_out_sys)) == [:if_sys, :default_sys] + @test nameof.(get_systems(elseif_out_sys)) == [:elseif_sys, :default_sys] + @test nameof.(get_systems(else_out_sys)) == [:else_sys, :default_sys] + + @test Equation[if_out_sys.if_parameter ~ 0 + if_out_sys.default_parameter ~ 0] == equations(if_out_sys) + @test Equation[elseif_out_sys.elseif_parameter ~ 0 + elseif_out_sys.default_parameter ~ 0] == equations(elseif_out_sys) + @test Equation[else_out_sys.else_parameter ~ 0 + else_out_sys.default_parameter ~ 0] == equations(else_out_sys) + + @mtkmodel TernaryBranchingOutsideTheBlock begin + @structural_parameters begin + condition = true + end + condition ? begin + @parameters begin + ternary_parameter_true + end + @equations begin + ternary_parameter_true ~ 0 + end + @components begin + ternary_sys_true = C() + end + end : begin + @parameters begin + ternary_parameter_false + end + @equations begin + ternary_parameter_false ~ 0 + end + @components begin + ternary_sys_false = C() + end + end + end + + @named ternary_true = TernaryBranchingOutsideTheBlock() + ternary_true = complete(ternary_true; flatten = false) + + @named ternary_false = TernaryBranchingOutsideTheBlock(condition = false) + ternary_false = complete(ternary_false; flatten = false) + + @test getname.(parameters(ternary_true)) == [:ternary_parameter_true] + @test getname.(parameters(ternary_false)) == [:ternary_parameter_false] + + @test nameof.(get_systems(ternary_true)) == [:ternary_sys_true] + @test nameof.(get_systems(ternary_false)) == [:ternary_sys_false] + + @test Equation[ternary_true.ternary_parameter_true ~ 0] == equations(ternary_true) + @test Equation[ternary_false.ternary_parameter_false ~ 0] == equations(ternary_false) +end + +_b = Ref{Any}() +@mtkmodel MyModel begin + @variables begin + x___(t) = 0 + end + begin + _b[] = x___ + end +end +@named m = MyModel() +@variables x___(t) +@test isequal(x___, _b[]) + +@testset "Component array" begin + @mtkmodel SubComponent begin + @parameters begin + sc + end + end + + @mtkmodel Component begin + @structural_parameters begin + N = 2 + end + @components begin + comprehension = [SubComponent(sc = i) for i in 1:N] + written_out_for = for i in 1:N + sc = i + 1 + SubComponent(; sc) + end + single_sub_component = SubComponent() + end + end + + @named component = Component() + component = complete(component; flatten = false) + + @test nameof.(ModelingToolkit.get_systems(component)) == [ + :comprehension_1, + :comprehension_2, + :written_out_for_1, + :written_out_for_2, + :single_sub_component + ] + + @test getdefault(component.comprehension_1.sc) == 1 + @test getdefault(component.comprehension_2.sc) == 2 + @test getdefault(component.written_out_for_1.sc) == 2 + @test getdefault(component.written_out_for_2.sc) == 3 + + @mtkmodel ConditionalComponent begin + @structural_parameters begin + N = 2 + end + @components begin + if N == 2 + if_comprehension = [SubComponent(sc = i) for i in 1:N] + elseif N == 3 + elseif_comprehension = [SubComponent(sc = i) for i in 1:N] + else + else_comprehension = [SubComponent(sc = i) for i in 1:N] + end + end + end + + @named if_component = ConditionalComponent() + @test nameof.(get_systems(if_component)) == [:if_comprehension_1, :if_comprehension_2] + + @named elseif_component = ConditionalComponent(; N = 3) + @test nameof.(get_systems(elseif_component)) == + [:elseif_comprehension_1, :elseif_comprehension_2, :elseif_comprehension_3] + + @named else_component = ConditionalComponent(; N = 4) + @test nameof.(get_systems(else_component)) == + [:else_comprehension_1, :else_comprehension_2, + :else_comprehension_3, :else_comprehension_4] +end + +@testset "Parent module of Models" begin + @test parentmodule(MyMockModule.Ground) == MyMockModule +end + +@testset "Guesses with expression" begin + @mtkmodel GuessModel begin + @variables begin + k(t) + l(t) = 10, [guess = k, unit = u"A"] + i(t), [guess = k, unit = u"A"] + j(t), [guess = k + l / i] + end + end + + @named guess_model = GuessModel() + + j_guess = getguess(guess_model.j) + @test symbolic_type(j_guess) == ScalarSymbolic() + @test readable_code(j_guess) == "l(t) / i(t) + k(t)" + + i_guess = getguess(guess_model.i) + @test symbolic_type(i_guess) == ScalarSymbolic() + @test readable_code(i_guess) == "k(t)" + + l_guess = getguess(guess_model.l) + @test symbolic_type(l_guess) == ScalarSymbolic() + @test readable_code(l_guess) == "k(t)" +end + +@testset "Argument order" begin + @mtkmodel OrderModel begin + @structural_parameters begin + b = 1 # reverse alphabetical order to test that the order is preserved + a = b + end + @parameters begin + c = a + d = b + end + end + @named ordermodel = OrderModel() + ordermodel = complete(ordermodel) + defs = ModelingToolkit.defaults(ordermodel) + @test defs[ordermodel.c] == 1 + @test defs[ordermodel.d] == 1 + + @test_nowarn @named ordermodel = OrderModel(a = 2) + ordermodel = complete(ordermodel) + defs = ModelingToolkit.defaults(ordermodel) + @test defs[ordermodel.c] == 2 + @test defs[ordermodel.d] == 1 +end + +@testset "Vector defaults" begin + @mtkmodel VectorDefaultWithMetadata begin + @parameters begin + n[1:3] = [1, 2, 3], [description = "Vector defaults"] + end + end + + @named vec = VectorDefaultWithMetadata() + for i in 1:3 + @test getdefault(vec.n[i]) == i + end + + @mtkmodel VectorConditionalDefault begin + @structural_parameters begin + flag = true + end + @parameters begin + n[1:3] = if flag + [2, 2, 2] + else + [1, 1, 1] + end + end + end + + @named vec_true = VectorConditionalDefault() + for i in 1:3 + @test getdefault(vec_true.n[i]) == 2 + end + @named vec_false = VectorConditionalDefault(flag = false) + for i in 1:3 + @test getdefault(vec_false.n[i]) == 1 + end +end + +@testset "Duplicate names" begin + mod = @__MODULE__ + @test_throws ErrorException ModelingToolkit._model_macro(mod, :ATest, + :(begin + @variables begin + a(t) + a(t) + end + end), + false) + @test_throws ErrorException ModelingToolkit._model_macro(mod, :ATest, + :(begin + @variables begin + a(t) + end + @parameters begin + a + end + end), + false) +end + +@mtkmodel BaseSys begin + @parameters begin + p1 + p2 + end + @variables begin + v1(t) + end +end + +@testset "Arguments of base system" begin + @mtkmodel MainSys begin + @extend BaseSys(p1 = 1) + end + + @test names(MainSys) == [:p2, :p1, :v1] + @named main_sys = MainSys(p1 = 11, p2 = 12, v1 = 13) + @test getdefault(main_sys.p1) == 11 + @test getdefault(main_sys.p2) == 12 + @test getdefault(main_sys.v1) == 13 +end + +@mtkmodel InnerModel begin + @parameters begin + p + end +end + +@mtkmodel MidModel begin + @components begin + inmodel = InnerModel() + end +end + +@mtkmodel MidModelB begin + @parameters begin + b + end + @components begin + inmodel_b = InnerModel() + end +end + +@mtkmodel OuterModel begin + @extend MidModel() + @equations begin + inmodel.p ~ 0 + end +end + +# The base system is fetched from the module while extending implicitly. This +# way of defining fails when defined inside the `@testset`. So, it is moved out. +@testset "Test unpacking of components in implicit extend" begin + @named out = OuterModel() + @test OuterModel.structure[:extend][1] == [:inmodel] +end + +@mtkmodel MultipleExtend begin + @extend MidModel() + @extend MidModelB() +end + +@testset "Multiple extend statements" begin + @named multiple_extend = MultipleExtend() + @test collect(nameof.(multiple_extend.systems)) == [:inmodel_b, :inmodel] + @test MultipleExtend.structure[:extend][1] == [:inmodel, :b, :inmodel_b] + @test tosymbol.(parameters(multiple_extend)) == [:b, :inmodel_b₊p, :inmodel₊p] +end + +struct CustomStruct end +@testset "Nonnumeric parameters" begin + @mtkmodel MyModel begin + @parameters begin + p::CustomStruct + end + end + @named sys = MyModel(p = CustomStruct()) + @test ModelingToolkit.defaults(sys)[@nonamespace sys.p] == CustomStruct() +end + +@testset "Variables are not callable symbolics" begin + @mtkmodel Example begin + @variables begin + x(t) + y(t) + end + @equations begin + x ~ y + end + end + @named ex = Example() + vars = Symbolics.get_variables(only(equations(ex))) + @test length(vars) == 2 + for u in Symbolics.unwrap.(unknowns(ex)) + @test !Symbolics.hasmetadata(u, Symbolics.CallWithParent) + @test any(isequal(u), vars) + end +end + +@testset "Specify the type of system" begin + @mtkmodel Float2Bool::DiscreteSystem begin + @variables begin + u(t)::Float64 + y(t)::Bool + end + @equations begin + y ~ u != 0 + end + end + + @named sys = Float2Bool() + @test typeof(sys) == DiscreteSystem +end diff --git a/test/modelingtoolkitize.jl b/test/modelingtoolkitize.jl index d943e355df..db99cc91a4 100644 --- a/test/modelingtoolkitize.jl +++ b/test/modelingtoolkitize.jl @@ -1,64 +1,81 @@ -using OrdinaryDiffEq, ModelingToolkit, Test -using GalacticOptim, Optim, RecursiveArrayTools +using OrdinaryDiffEq, ModelingToolkit, DataStructures, Test +using Optimization, RecursiveArrayTools, OptimizationOptimJL +using SymbolicIndexingInterface +using ModelingToolkit: t_nounits as t, D_nounits as D +using SciMLBase: parameterless_type N = 32 -const xyd_brusselator = range(0,stop=1,length=N) -brusselator_f(x, y, t) = (((x-0.3)^2 + (y-0.6)^2) <= 0.1^2) * (t >= 1.1) * 5. -limit(a, N) = ModelingToolkit.ifelse(a == N+1, 1, ModelingToolkit.ifelse(a == 0, N, a)) +const xyd_brusselator = range(0, stop = 1, length = N) +brusselator_f(x, y, t) = (((x - 0.3)^2 + (y - 0.6)^2) <= 0.1^2) * (t >= 1.1) * 5.0 +limit(a, N) = ModelingToolkit.ifelse(a == N + 1, 1, ModelingToolkit.ifelse(a == 0, N, a)) function brusselator_2d_loop(du, u, p, t) - A, B, alpha, dx = p - alpha = alpha/dx^2 - @inbounds for I in CartesianIndices((N, N)) - i, j = Tuple(I) - x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] - ip1, im1, jp1, jm1 = limit(i+1, N), limit(i-1, N), limit(j+1, N), limit(j-1, N) - du[i,j,1] = alpha*(u[im1,j,1] + u[ip1,j,1] + u[i,jp1,1] + u[i,jm1,1] - 4u[i,j,1]) + - B + u[i,j,1]^2*u[i,j,2] - (A + 1)*u[i,j,1] + brusselator_f(x, y, t) - du[i,j,2] = alpha*(u[im1,j,2] + u[ip1,j,2] + u[i,jp1,2] + u[i,jm1,2] - 4u[i,j,2]) + - A*u[i,j,1] - u[i,j,1]^2*u[i,j,2] + A, B, alpha, dx = p + alpha = alpha / dx^2 + @inbounds for I in CartesianIndices((N, N)) + i, j = Tuple(I) + x, y = xyd_brusselator[I[1]], xyd_brusselator[I[2]] + ip1, im1, jp1, jm1 = limit(i + 1, N), limit(i - 1, N), limit(j + 1, N), + limit(j - 1, N) + du[i, j, 1] = alpha * (u[im1, j, 1] + u[ip1, j, 1] + u[i, jp1, 1] + u[i, jm1, 1] - + 4u[i, j, 1]) + + B + u[i, j, 1]^2 * u[i, j, 2] - (A + 1) * u[i, j, 1] + + brusselator_f(x, y, t) + du[i, j, 2] = alpha * (u[im1, j, 2] + u[ip1, j, 2] + u[i, jp1, 2] + u[i, jm1, 2] - + 4u[i, j, 2]) + + A * u[i, j, 1] - u[i, j, 1]^2 * u[i, j, 2] end end # Test with tuple parameters -p = (3.4, 1., 10., step(xyd_brusselator)) +p = (3.4, 1.0, 10.0, step(xyd_brusselator)) function init_brusselator_2d(xyd) - N = length(xyd) - u = zeros(N, N, 2) - for I in CartesianIndices((N, N)) - x = xyd[I[1]] - y = xyd[I[2]] - u[I,1] = 22*(y*(1-y))^(3/2) - u[I,2] = 27*(x*(1-x))^(3/2) - end - u + N = length(xyd) + u = zeros(N, N, 2) + for I in CartesianIndices((N, N)) + x = xyd[I[1]] + y = xyd[I[2]] + u[I, 1] = 22 * (y * (1 - y))^(3 / 2) + u[I, 2] = 27 * (x * (1 - x))^(3 / 2) + end + u end u0 = init_brusselator_2d(xyd_brusselator) # Test with 3-tensor inputs prob_ode_brusselator_2d = ODEProblem(brusselator_2d_loop, - u0,(0.,11.5),p) + u0, (0.0, 11.5), p) modelingtoolkitize(prob_ode_brusselator_2d) ## Optimization -rosenbrock(x,p) = (p[1] - x[1])^2 + p[2] * (x[2] - x[1]^2)^2 +rosenbrock(x, p) = (p[1] - x[1])^2 + p[2] * (x[2] - x[1]^2)^2 x0 = zeros(2) -p = [1.0,100.0] +p = [1.0, 100.0] + +prob = OptimizationProblem(rosenbrock, x0, p) +sys = complete(modelingtoolkitize(prob)) # symbolicitize me captain! + +prob = OptimizationProblem(sys, x0, p, grad = true, hess = true) +sol = solve(prob, NelderMead()) +@test sol.objective < 1e-8 -prob = OptimizationProblem(rosenbrock,x0,p) -sys = modelingtoolkitize(prob) # symbolicitize me captain! +sol = solve(prob, BFGS()) +@test sol.objective < 1e-8 -prob = OptimizationProblem(sys,x0,p,grad=true,hess=true) -sol = solve(prob,NelderMead()) -@test sol.minimum < 1e-8 +sol = solve(prob, Newton()) +@test sol.objective < 1e-8 -sol = solve(prob,BFGS()) -@test sol.minimum < 1e-8 +prob = OptimizationProblem(ones(3); lb = [-Inf, 0.0, 1.0], ub = [Inf, 0.0, 2.0]) do u, p + sum(abs2, u) +end -sol = solve(prob,Newton()) -@test sol.minimum < 1e-8 +sys = complete(modelingtoolkitize(prob)) +@test !ModelingToolkit.hasbounds(unknowns(sys)[1]) +@test !ModelingToolkit.hasbounds(unknowns(sys)[2]) +@test ModelingToolkit.hasbounds(unknowns(sys)[3]) +@test ModelingToolkit.getbounds(unknowns(sys)[3]) == (1.0, 2.0) ## SIR System Regression Test @@ -71,15 +88,17 @@ i₀ = 0.075 # fraction of initial infected people in every age class # regional contact matrix and regional population ## regional contact matrix -regional_all_contact_matrix = [3.45536 0.485314 0.506389 0.123002 ; 0.597721 2.11738 0.911374 0.323385 ; 0.906231 1.35041 1.60756 0.67411 ; 0.237902 0.432631 0.726488 0.979258] # 4x4 contact matrix +regional_all_contact_matrix = [3.45536 0.485314 0.506389 0.123002; + 0.597721 2.11738 0.911374 0.323385; + 0.906231 1.35041 1.60756 0.67411; + 0.237902 0.432631 0.726488 0.979258] # 4x4 contact matrix ## regional population stratified by age -N = [723208 , 874150, 1330993, 1411928] # array of 4 elements, each of which representing the absolute amount of population in the corresponding age class. - +N = [723208, 874150, 1330993, 1411928] # array of 4 elements, each of which representing the absolute amount of population in the corresponding age class. # Initial conditions -I₀ = repeat([i₀],4) -S₀ = N.-I₀ +I₀ = repeat([i₀], 4) +S₀ = N .- I₀ R₀ = [0.0 for n in 1:length(N)] D₀ = [0.0 for n in 1:length(N)] D_tot₀ = [0.0 for n in 1:length(N)] @@ -87,78 +106,73 @@ D_tot₀ = [0.0 for n in 1:length(N)] # Time final_time = 20 -𝒯 = (1.0,final_time); - +𝒯 = (1.0, final_time); - - -function SIRD_ac!(du,u,p,t) +function SIRD_ac!(du, u, p, t) # Parameters to be calibrated β, λ_R, λ_D = p # initialize this parameter (death probability stratified by age, taken from literature) - δ₁, δ₂, δ₃, δ₄ = [0.003/100, 0.004/100, (0.015+0.030+0.064+0.213+0.718)/(5*100), (2.384+8.466+12.497+1.117)/(4*100)] - δ = vcat(repeat([δ₁],1),repeat([δ₂],1),repeat([δ₃],1),repeat([δ₄],4-1-1-1)) - + δ₁, δ₂, δ₃, δ₄ = [ + 0.003 / 100, + 0.004 / 100, + (0.015 + 0.030 + 0.064 + 0.213 + 0.718) / (5 * 100), + (2.384 + 8.466 + 12.497 + 1.117) / (4 * 100) + ] + δ = vcat(repeat([δ₁], 1), repeat([δ₂], 1), repeat([δ₃], 1), repeat([δ₄], 4 - 1 - 1 - 1)) C = regional_all_contact_matrix - - # State variables - S = @view u[4*0+1:4*1] - I = @view u[4*1+1:4*2] - R = @view u[4*2+1:4*3] - D = @view u[4*3+1:4*4] - D_tot = @view u[4*4+1:4*5] + # Unknown variables + S = @view u[(4 * 0 + 1):(4 * 1)] + I = @view u[(4 * 1 + 1):(4 * 2)] + R = @view u[(4 * 2 + 1):(4 * 3)] + D = @view u[(4 * 3 + 1):(4 * 4)] + D_tot = @view u[(4 * 4 + 1):(4 * 5)] # Differentials - dS = @view du[4*0+1:4*1] - dI = @view du[4*1+1:4*2] - dR = @view du[4*2+1:4*3] - dD = @view du[4*3+1:4*4] - dD_tot = @view du[4*4+1:4*5] + dS = @view du[(4 * 0 + 1):(4 * 1)] + dI = @view du[(4 * 1 + 1):(4 * 2)] + dR = @view du[(4 * 2 + 1):(4 * 3)] + dD = @view du[(4 * 3 + 1):(4 * 4)] + dD_tot = @view du[(4 * 4 + 1):(4 * 5)] # Force of infection - Λ = β*[sum([C[i,j]*I[j]/N[j] for j in 1:size(C)[1]]) for i in 1:size(C)[2]] + Λ = β * [sum([C[i, j] * I[j] / N[j] for j in 1:size(C)[1]]) for i in 1:size(C)[2]] # System of equations - @. dS = -Λ*S - @. dI = Λ*S - ((1-δ)*λ_R + δ*λ_D)*I - @. dR = λ_R*(1-δ)*I - @. dD = λ_D*δ*I - @. dD_tot = dD[1]+dD[2]+dD[3]+dD[4] - - + @. dS = -Λ * S + @. dI = Λ * S - ((1 - δ) * λ_R + δ * λ_D) * I + @. dR = λ_R * (1 - δ) * I + @. dD = λ_D * δ * I + @. dD_tot = dD[1] + dD[2] + dD[3] + dD[4] end; - # create problem and check it works problem = ODEProblem(SIRD_ac!, ℬ, 𝒯, 𝒫) @time solution = solve(problem, Tsit5(), saveat = 1:final_time); problem = ODEProblem(SIRD_ac!, ℬ, 𝒯, 𝒫) -sys = modelingtoolkitize(problem) -fast_problem = ODEProblem(sys,ℬ, 𝒯, 𝒫 ) +sys = complete(modelingtoolkitize(problem)) +fast_problem = ODEProblem(sys, ℬ, 𝒯, parameters(sys) .=> 𝒫) @time solution = solve(fast_problem, Tsit5(), saveat = 1:final_time) ## Issue #778 r0 = [1131.340, -2282.343, 6672.423] v0 = [-5.64305, 4.30333, 2.42879] -Δt = 86400.0*365 +Δt = 86400.0 * 365 μ = 398600.4418 -rv0 = ArrayPartition(r0,v0) +rv0 = ArrayPartition(r0, v0) -function f(dy, y, μ, t) - r = sqrt(sum(y[1,:].^2)) - dy[1,:] = y[2,:] - dy[2,:] = -μ .* y[1,:] / r^3 +f = function (dy, y, μ, t) + r = sqrt(sum(y[1, :] .^ 2)) + dy[1, :] = y[2, :] + dy[2, :] = -μ .* y[1, :] / r^3 end prob = ODEProblem(f, rv0, (0.0, Δt), μ) -sol = solve(prob, Vern8()) - modelingtoolkitize(prob) # Index reduction and mass matrix handling @@ -167,28 +181,291 @@ function pendulum!(du, u, p, t) x, dx, y, dy, T = u g, L = p du[1] = dx - du[2] = T*x + du[2] = T * x du[3] = dy - du[4] = T*y - g + du[4] = T * y - g du[5] = x^2 + y^2 - L^2 return nothing end -pendulum_fun! = ODEFunction(pendulum!, mass_matrix=Diagonal([1,1,1,1,0])) +pendulum_fun! = ODEFunction(pendulum!, mass_matrix = Diagonal([1, 1, 1, 1, 0])) u0 = [1.0, 0, 0, 0, 0] p = [9.8, 1] tspan = (0, 10.0) pendulum_prob = ODEProblem(pendulum_fun!, u0, tspan, p) -pendulum_sys_org = modelingtoolkitize(pendulum_prob) -sts = states(pendulum_sys_org) +pendulum_sys_org = complete(modelingtoolkitize(pendulum_prob)) +sts = unknowns(pendulum_sys_org) pendulum_sys = dae_index_lowering(pendulum_sys_org) prob = ODEProblem(pendulum_sys, Pair[], tspan) sol = solve(prob, Rodas4()) -l2 = sol[sts[1]].^2 + sol[sts[3]].^2 -@test all(l->abs(sqrt(l) - 1) < 0.05, l2) +l2 = sol[sts[1]] .^ 2 + sol[sts[3]] .^ 2 +@test all(l -> abs(sqrt(l) - 1) < 0.05, l2) -ff911 = (du,u,p,t) -> begin +ff911 = (du, u, p, t) -> begin du[1] = u[2] + 1.0 du[2] = u[1] - 1.0 end prob = ODEProblem(ff911, zeros(2), (0, 1.0)) @test_nowarn modelingtoolkitize(prob) + +k(x, p, t) = p * x +x0 = 1.0 +p = 0.98 +tspan = (0.0, 1.0) +prob = ODEProblem(k, x0, tspan, p) +sys = modelingtoolkitize(prob) + +k(x, p, t) = 0.98 * x +x0 = 1.0 +tspan = (0.0, 1.0) +prob = ODEProblem(k, x0, tspan) +sys = modelingtoolkitize(prob) + +# https://github.com/SciML/ModelingToolkit.jl/issues/1158 + +function ode_prob(du, u, p::NamedTuple, t) + du[1] = u[1] + p.α * u[2] + du[2] = u[2] + p.β * u[1] +end +params = (α = 1, β = 1) +prob = ODEProblem(ode_prob, [1 1], (0, 1), params) +sys = modelingtoolkitize(prob) +@test nameof.(parameters(sys)) == [:α, :β] + +function ode_prob(du, u, p::Tuple, t) + α, β = p + du[1] = u[1] + α * u[2] + du[2] = u[2] + β * u[1] +end + +params = (1, 1) +prob = ODEProblem(ode_prob, [1 1], (0, 1), params) +sys = modelingtoolkitize(prob) +@test nameof.(parameters(sys)) == [:α₁, :α₂] + +function ode_prob_dict(du, u, p, t) + du[1] = u[1] + p[:a] + du[2] = u[2] + p[:b] + nothing +end +params = OrderedDict(:a => 10, :b => 20) +u0 = [1, 2.0] +prob = ODEProblem(ode_prob_dict, u0, (0.0, 1.0), params) +sys = modelingtoolkitize(prob) +@test [ModelingToolkit.defaults(sys)[s] for s in unknowns(sys)] == u0 +@test [ModelingToolkit.defaults(sys)[s] for s in parameters(sys)] == [10, 20] +@test ModelingToolkit.has_tspan(sys) + +@parameters sig=10 rho=28.0 beta=8 / 3 +@variables x(t)=100 y(t)=1.0 z(t)=1 + +eqs = [D(x) ~ sig * (y - x), + D(y) ~ x * (rho - z) - y, + D(z) ~ x * y - beta * z] + +noiseeqs = [0.1 * x, + 0.1 * y, + 0.1 * z] + +@named sys = SDESystem(eqs, noiseeqs, t, [x, y, z], [sig, rho, beta]; tspan = (0, 1000.0)) +prob = SDEProblem(complete(sys)) +sys = modelingtoolkitize(prob) +@test ModelingToolkit.has_tspan(sys) + +@testset "Explicit variable names" begin + function fn(du, u, p::NamedTuple, t) + du[1] = u[1] + p.a * u[2] + du[2] = u[2] + p.b * u[1] + end + function fn(du, u, p::AbstractDict, t) + du[1] = u[1] + p[:a] * u[2] + du[2] = u[2] + p[:b] * u[1] + end + function fn(du, u, p, t) + du[1] = u[1] + p[1] * u[2] + du[2] = u[2] + p[2] * u[1] + end + function fn(du, u, p::Real, t) + du[1] = u[1] + p * u[2] + du[2] = u[2] + p * u[1] + end + function nl_fn(u, p::NamedTuple) + [u[1] + p.a * u[2], u[2] + p.b * u[1]] + end + function nl_fn(u, p::AbstractDict) + [u[1] + p[:a] * u[2], u[2] + p[:b] * u[1]] + end + function nl_fn(u, p) + [u[1] + p[1] * u[2], u[2] + p[2] * u[1]] + end + function nl_fn(u, p::Real) + [u[1] + p * u[2], u[2] + p * u[1]] + end + params = (a = 1, b = 1) + odeprob = ODEProblem(fn, [1 1], (0, 1), params) + nlprob = NonlinearProblem(nl_fn, [1, 1], params) + optprob = OptimizationProblem(nl_fn, [1, 1], params) + + @testset "$(parameterless_type(prob))" for prob in [optprob] + sys = modelingtoolkitize(prob, u_names = [:a, :b]) + @test is_variable(sys, sys.a) + @test is_variable(sys, sys.b) + @test is_variable(sys, :a) + @test is_variable(sys, :b) + + @test_throws ["unknowns", "2", "does not match", "names", "3"] modelingtoolkitize( + prob, u_names = [:a, :b, :c]) + for (pvals, pnames) in [ + ([1, 2], [:p, :q]), + ((1, 2), [:p, :q]), + ([1, 2], Dict(1 => :p, 2 => :q)), + ((1, 2), Dict(1 => :p, 2 => :q)), + (1.0, :p), + (1.0, [:p]), + (1.0, Dict(1 => :p)), + (Dict(:a => 2, :b => 4), Dict(:a => :p, :b => :q)), + ((a = 1, b = 2), (a = :p, b = :q)), + ((a = 1, b = 2), Dict(:a => :p, :b => :q)) + ] + if pvals isa NamedTuple && prob isa OptimizationProblem + continue + end + sys = modelingtoolkitize( + remake(prob, p = pvals, interpret_symbolicmap = false), p_names = pnames) + if pnames isa Symbol + @test is_parameter(sys, pnames) + continue + end + for p in values(pnames) + @test is_parameter(sys, p) + end + end + + for (pvals, pnames) in [ + ([1, 2], [:p, :q, :r]), + ((1, 2), [:p, :q, :r]), + ([1, 2], Dict(1 => :p, 2 => :q, 3 => :r)), + ((1, 2), Dict(1 => :p, 2 => :q, 3 => :r)), + (1.0, [:p, :q]), + (1.0, Dict(1 => :p, 2 => :q)), + (Dict(:a => 2, :b => 4), Dict(:a => :p, :b => :q, :c => :r)), + ((a = 1, b = 2), (a = :p, b = :q, c = :r)), + ((a = 1, b = 2), Dict(:a => :p, :b => :q, :c => :r)) + ] + newprob = remake(prob, p = pvals, interpret_symbolicmap = false) + @test_throws [ + "parameters", "$(length(pvals))", "does not match", "$(length(pnames))"] modelingtoolkitize( + newprob, p_names = pnames) + end + + sc = SymbolCache([:a, :b], [:p, :q]) + sci_f = parameterless_type(prob.f)(prob.f.f, sys = sc) + newprob = remake(prob, f = sci_f, p = [1, 2]) + sys = modelingtoolkitize(newprob) + @test is_variable(sys, sys.a) + @test is_variable(sys, sys.b) + @test is_variable(sys, :a) + @test is_variable(sys, :b) + @test is_parameter(sys, sys.p) + @test is_parameter(sys, sys.q) + @test is_parameter(sys, :p) + @test is_parameter(sys, :q) + end + + @testset "From MTK model" begin + @testset "ODE" begin + @variables x(t)=1.0 y(t)=2.0 + @parameters p=3.0 q=4.0 + @mtkbuild sys = ODESystem([D(x) ~ p * y, D(y) ~ q * x], t) + prob1 = ODEProblem(sys, [], (0.0, 5.0)) + newsys = complete(modelingtoolkitize(prob1)) + @test is_variable(newsys, newsys.x) + @test is_variable(newsys, newsys.y) + @test is_parameter(newsys, newsys.p) + @test is_parameter(newsys, newsys.q) + prob2 = ODEProblem(newsys, [], (0.0, 5.0)) + + sol1 = solve(prob1, Tsit5()) + sol2 = solve(prob2, Tsit5()) + @test sol1 ≈ sol2 + end + @testset "Nonlinear" begin + @variables x=1.0 y=2.0 + @parameters p=3.0 q=4.0 + @mtkbuild nlsys = NonlinearSystem([0 ~ p * y^2 + x, 0 ~ x + exp(x) * q]) + prob1 = NonlinearProblem(nlsys, []) + newsys = complete(modelingtoolkitize(prob1)) + @test is_variable(newsys, newsys.x) + @test is_variable(newsys, newsys.y) + @test is_parameter(newsys, newsys.p) + @test is_parameter(newsys, newsys.q) + prob2 = NonlinearProblem(newsys, []) + + sol1 = solve(prob1) + sol2 = solve(prob2) + @test sol1 ≈ sol2 + end + @testset "Optimization" begin + @variables begin + x = 1.0, [bounds = (-2.0, 10.0)] + y = 2.0, [bounds = (-1.0, 10.0)] + end + @parameters p=3.0 q=4.0 + loss = (p - x)^2 + q * (y - x^2)^2 + @mtkbuild optsys = OptimizationSystem(loss, [x, y], [p, q]) + prob1 = OptimizationProblem(optsys, [], grad = true, hess = true) + newsys = complete(modelingtoolkitize(prob1)) + @test is_variable(newsys, newsys.x) + @test is_variable(newsys, newsys.y) + @test is_parameter(newsys, newsys.p) + @test is_parameter(newsys, newsys.q) + prob2 = OptimizationProblem(newsys, [], grad = true, hess = true) + + sol1 = solve(prob1, GradientDescent()) + sol2 = solve(prob2, GradientDescent()) + + @test sol1 ≈ sol2 + end + end +end + +## NonlinearLeastSquaresProblem + +function nlls!(du, u, p) + du[1] = 2u[1] - 2 + du[2] = u[1] - 4u[2] + du[3] = 0 +end +u0 = [0.0, 0.0] +prob = NonlinearLeastSquaresProblem( + NonlinearFunction(nlls!, resid_prototype = zeros(3)), u0) +sys = modelingtoolkitize(prob) +@test length(equations(sys)) == 3 +@test length(equations(structural_simplify(sys; fully_determined = false))) == 0 + +@testset "`modelingtoolkitize(::SDEProblem)` sets defaults" begin + function sdeg!(du, u, p, t) + du[1] = 0.3 * u[1] + du[2] = 0.3 * u[2] + du[3] = 0.3 * u[3] + end + function sdef!(du, u, p, t) + x, y, z = u + sigma, rho, beta = p + du[1] = sigma * (y - x) + du[2] = x * (rho - z) - y + du[3] = x * y - beta * z + end + u0 = [1.0, 0.0, 0.0] + tspan = (0.0, 100.0) + p = [10.0, 28.0, 2.66] + sprob = SDEProblem(sdef!, sdeg!, u0, tspan, p) + sys = complete(modelingtoolkitize(sprob)) + @test length(ModelingToolkit.defaults(sys)) == 3length(u0) + length(p) + sprob2 = SDEProblem(sys, [], tspan) + + truevals = similar(u0) + sprob.f(truevals, u0, p, tspan[1]) + mtkvals = similar(u0) + sprob2.f(mtkvals, sprob2.u0, sprob2.p, tspan[1]) + @test mtkvals ≈ truevals +end diff --git a/test/mtkparameters.jl b/test/mtkparameters.jl new file mode 100644 index 0000000000..22201b1988 --- /dev/null +++ b/test/mtkparameters.jl @@ -0,0 +1,352 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, MTKParameters +using SymbolicIndexingInterface +using SciMLStructures: SciMLStructures, canonicalize, Tunable, Discrete, Constants +using BlockArrays: BlockedArray, Block +using OrdinaryDiffEq +using ForwardDiff +using JET + +@parameters a b c(t) d::Integer e[1:3] f[1:3, 1:3]::Int g::Vector{AbstractFloat} h::String +@named sys = ODESystem( + Equation[], t, [], [a, c, d, e, f, g, h], parameter_dependencies = [b ~ 2a], + continuous_events = [[a ~ 0] => [c ~ 0]], defaults = Dict(a => 0.0)) +sys = complete(sys) + +ivs = Dict(c => 3a, d => 4, e => [5.0, 6.0, 7.0], + f => ones(Int, 3, 3), g => [0.1, 0.2, 0.3], h => "foo") + +ps = MTKParameters(sys, ivs) +@test_nowarn copy(ps) +# dependent initialization, also using defaults +@test getp(sys, a)(ps) == getp(sys, b)(ps) == getp(sys, c)(ps) == 0.0 +@test getp(sys, d)(ps) isa Int + +ivs[a] = 1.0 +ps = MTKParameters(sys, ivs) +for (p, val) in ivs + if isequal(p, c) + val = 3ivs[a] + end + idx = parameter_index(sys, p) + # ensure getindex with `ParameterIndex` works + @test ps[idx] == getp(sys, p)(ps) == val +end + +# ensure setindex! with `ParameterIndex` works +ps[parameter_index(sys, a)] = 3.0 +@test getp(sys, a)(ps) == 3.0 +setp(sys, a)(ps, 1.0) + +@test getp(sys, a)(ps) == getp(sys, b)(ps) / 2 == getp(sys, c)(ps) / 3 == 1.0 + +for (portion, values) in [(Tunable(), [1.0, 5.0, 6.0, 7.0]) + (Discrete(), [3.0]) + (Constants(), vcat([0.1, 0.2, 0.3], ones(9), [4.0]))] + buffer, repack, alias = canonicalize(portion, ps) + @test alias + @test sort(collect(buffer)) == values + @test all(isone, + canonicalize(portion, SciMLStructures.replace(portion, ps, ones(length(buffer))))[1]) + # make sure it is out-of-place + @test sort(collect(buffer)) == values + SciMLStructures.replace!(portion, ps, ones(length(buffer))) + # make sure it is in-place + @test all(isone, canonicalize(portion, ps)[1]) + global ps = repack(zeros(length(buffer))) + @test all(iszero, canonicalize(portion, ps)[1]) +end + +setp(sys, a)(ps, 2.0) # test set_parameter! +@test getp(sys, a)(ps) == 2.0 + +setp(sys, e)(ps, 5ones(3)) # with an array +@test getp(sys, e)(ps) == 5ones(3) + +setp(sys, f[2, 2])(ps, 42) # with a sub-index +@test getp(sys, f[2, 2])(ps) == 42 + +setp(sys, g)(ps, ones(100)) # with non-fixed-length array +@test getp(sys, g)(ps) == ones(100) + +setp(sys, h)(ps, "bar") # with a non-numeric +@test getp(sys, h)(ps) == "bar" + +varmap = Dict(a => 1.0f0, b => 5.0f0, c => 2.0, d => 0x5, e => Float32[0.4, 0.5, 0.6], + f => 3ones(UInt, 3, 3), g => ones(Float32, 4), h => "bar") +@test_deprecated remake_buffer(sys, ps, varmap) +@test_warn ["Symbolic variable b", "non-dependent", "parameter"] remake_buffer( + sys, ps, keys(varmap), values(varmap)) +newps = remake_buffer(sys, ps, keys(varmap), values(varmap)) + +for fname in (:tunable, :discrete, :constant) + # ensure same number of sub-buffers + @test length(getfield(ps, fname)) == length(getfield(newps, fname)) +end + +@test getp(sys, a)(newps) isa Float32 +@test getp(sys, b)(newps) == 2.0f0 # ensure dependent update still happened, despite explicit value +@test getp(sys, c)(newps) isa Float64 +@test getp(sys, d)(newps) isa UInt8 +@test getp(sys, f)(newps) isa Matrix{UInt} +@test getp(sys, g)(newps) isa Vector{Float32} + +@testset "Type-stability of `remake_buffer`" begin + prob = ODEProblem(sys, [], (0.0, 1.0), ivs) + + idxs = (a, c, d, e, f, g, h) + vals = (1.0, 2.0, 3, ones(3), ones(Int, 3, 3), ones(2), "a") + + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) + @test_throws ErrorException @inferred setter(prob, collect(vals)) + + idxs = (a, c, e...) + vals = Float16[1.0, 2.0, 3.0, 4.0, 5.0] + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) + + idxs = [a, e] + vals = (Float16(1.0), ForwardDiff.Dual{Nothing, Float16, 0}[1.0, 2.0, 3.0]) + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) +end + +ps = MTKParameters(sys, ivs) +function loss(value, sys, ps) + @test value isa ForwardDiff.Dual + ps = remake_buffer(sys, ps, (a,), (value,)) + getp(sys, a)(ps) + getp(sys, b)(ps) +end + +@test ForwardDiff.derivative(x -> loss(x, sys, ps), 1.5) == 3.0 + +# Issue#2615 +@parameters p::Vector{Float64} +@variables X(t) +eq = D(X) ~ p[1] - p[2] * X +@mtkbuild osys = ODESystem([eq], t) + +u0 = [X => 1.0] +ps = [p => [2.0, 0.1]] +p = MTKParameters(osys, ps, u0) +@test p.tunable == [2.0, 0.1] + +# Ensure partial update promotes the buffer +@parameters p q r +@named sys = ODESystem(Equation[], t, [], [p, q, r]) +sys = complete(sys) +ps = MTKParameters(sys, [p => 1.0, q => 2.0, r => 3.0]) +newps = remake_buffer(sys, ps, (p,), (1.0f0,)) +@test newps.tunable isa Vector{Float32} +@test newps.tunable == [1.0f0, 2.0f0, 3.0f0] + +# Issue#2624 +@parameters p d +@variables X(t) +eqs = [D(X) ~ p - d * X] +@mtkbuild sys = ODESystem(eqs, t) + +u0 = [X => 1.0] +tspan = (0.0, 100.0) +ps = [p => 1.0] # Value for `d` is missing + +@test_throws ModelingToolkit.MissingParametersError ODEProblem(sys, u0, tspan, ps) +@test_nowarn ODEProblem(sys, u0, tspan, [ps..., d => 1.0]) + +# JET tests + +# scalar parameters only +function level1() + @parameters p1=0.5 [tunable = true] p2=1 [tunable = true] p3=3 [tunable = false] p4=3 [tunable = true] y0=1 + @variables x(t)=2 y(t)=y0 + D = Differential(t) + + eqs = [D(x) ~ p1 * x - p2 * x * y + D(y) ~ -p3 * y + p4 * x * y] + + sys = structural_simplify(complete(ODESystem( + eqs, t, tspan = (0, 3.0), name = :sys, parameter_dependencies = [y0 => 2p4]))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys) +end + +# scalar and vector parameters +function level2() + @parameters p1=0.5 [tunable = true] (p23[1:2]=[1, 3.0]) [tunable = true] p4=3 [tunable = false] y0=1 + @variables x(t)=2 y(t)=y0 + D = Differential(t) + + eqs = [D(x) ~ p1 * x - p23[1] * x * y + D(y) ~ -p23[2] * y + p4 * x * y] + + sys = structural_simplify(complete(ODESystem( + eqs, t, tspan = (0, 3.0), name = :sys, parameter_dependencies = [y0 => 2p4]))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys) +end + +# scalar and vector parameters with different scalar types +function level3() + @parameters p1=0.5 [tunable = true] (p23[1:2]=[1, 3.0]) [tunable = true] p4::Int=3 [tunable = true] y0::Int=1 + @variables x(t)=2 y(t)=y0 + D = Differential(t) + + eqs = [D(x) ~ p1 * x - p23[1] * x * y + D(y) ~ -p23[2] * y + p4 * x * y] + + sys = structural_simplify(complete(ODESystem( + eqs, t, tspan = (0, 3.0), name = :sys, parameter_dependencies = [y0 => 2p4]))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys) +end + +@testset "level$i" for (i, prob) in enumerate([level1(), level2(), level3()]) + ps = prob.p + @testset "Type stability of $portion" for portion in [ + Tunable(), Discrete(), Constants()] + @test_call canonicalize(portion, ps) + @inferred canonicalize(portion, ps) + + # broken because the size of a vector of vectors can't be determined at compile time + @test_opt target_modules=(ModelingToolkit,) canonicalize( + portion, ps) + + buffer, repack, alias = canonicalize(portion, ps) + + # broken because dependent update functions break inference + @test_call target_modules=(ModelingToolkit,) SciMLStructures.replace( + portion, ps, ones(length(buffer))) + @inferred SciMLStructures.replace( + portion, ps, ones(length(buffer))) + @inferred MTKParameters SciMLStructures.replace(portion, ps, ones(length(buffer))) + @test_opt target_modules=(ModelingToolkit,) SciMLStructures.replace( + portion, ps, ones(length(buffer))) + + @test_call target_modules=(ModelingToolkit,) SciMLStructures.replace!( + portion, ps, ones(length(buffer))) + @inferred SciMLStructures.replace!(portion, ps, ones(length(buffer))) + @test_opt target_modules=(ModelingToolkit,) SciMLStructures.replace!( + portion, ps, ones(length(buffer))) + end +end + +# Issue#2642 +@parameters α β γ δ +@variables x(t) y(t) +eqs = [D(x) ~ (α - β * y) * x + D(y) ~ (δ * x - γ) * y] +@mtkbuild odesys = ODESystem(eqs, t) +odeprob = ODEProblem( + odesys, [x => 1.0, y => 1.0], (0.0, 10.0), [α => 1.5, β => 1.0, γ => 3.0, δ => 1.0]) +tunables, _... = canonicalize(Tunable(), odeprob.p) +@test tunables isa AbstractVector{Float64} + +function loss(x) + ps = odeprob.p + newps = SciMLStructures.replace(Tunable(), ps, x) + newprob = remake(odeprob, p = newps) + sol = solve(newprob, Tsit5()) + return sum(sol) +end + +@test_nowarn ForwardDiff.gradient(loss, collect(tunables)) + +VDual = Vector{<:ForwardDiff.Dual} +VVDual = Vector{<:Vector{<:ForwardDiff.Dual}} + +@testset "Parameter type validation" begin + struct Foo{T} + x::T + end + + @parameters a b::Int c::Vector{Float64} d[1:2, 1:2]::Int e::Foo{Int} f::Foo + @named sys = ODESystem(Equation[], t, [], [a, b, c, d, e, f]) + sys = complete(sys) + ps = MTKParameters(sys, + Dict(a => 1.0, b => 2, c => 3ones(2), + d => 3ones(Int, 2, 2), e => Foo(1), f => Foo("a"))) + @test_nowarn setp(sys, c)(ps, ones(4)) # so this is fixed when SII is fixed + @test_throws DimensionMismatch set_parameter!( + ps, 4ones(Int, 3, 2), parameter_index(sys, d)) + @test_throws DimensionMismatch set_parameter!( + ps, 4ones(Int, 4), parameter_index(sys, d)) # size has to match, not just length + @test_nowarn setp(sys, f)(ps, Foo(:a)) # can change non-concrete type + + # Same flexibility is afforded to `b::Int` to allow for ForwardDiff + for sym in [a, b] + @test_nowarn remake_buffer(sys, ps, (sym,), (1,)) + newps = @test_nowarn remake_buffer(sys, ps, (sym,), (1.0f0,)) # Can change type if it's numeric + @test getp(sys, sym)(newps) isa Float32 + newps = @test_nowarn remake_buffer(sys, ps, sym, ForwardDiff.Dual(1.0)) + @test getp(sys, sym)(newps) isa ForwardDiff.Dual + @test_throws TypeError remake_buffer(sys, ps, (sym,), (:a,)) # still has to be numeric + end + + newps = @test_nowarn remake_buffer(sys, ps, (c,), (view(1.0:4.0, 2:4),)) # can change type of array + @test getp(sys, c)(newps) == 2.0:4.0 + @test parameter_values(newps, parameter_index(sys, c)) ≈ [2.0, 3.0, 4.0] + @test_throws TypeError remake_buffer(sys, ps, (c,), ([:a, :b, :c],)) # can't arbitrarily change eltype + @test_throws TypeError remake_buffer(sys, ps, (c,), (:a,)) # can't arbitrarily change type + + newps = @test_nowarn remake_buffer(sys, ps, (d,), (ForwardDiff.Dual.(ones(2, 2)),)) # can change eltype + @test_throws TypeError remake_buffer(sys, ps, (d,), ([:a :b; :c :d],)) # eltype still has to be numeric + @test getp(sys, d)(newps) isa Matrix{<:ForwardDiff.Dual} + + @test_throws TypeError remake_buffer(sys, ps, (e,), (Foo(2.0),)) # need exact same type for nonnumeric + @test_nowarn remake_buffer(sys, ps, (f,), (Foo(:a),)) +end + +@testset "Error on missing parameter defaults" begin + @parameters a b c + @named sys = ODESystem(Equation[], t, [], [a, b]; defaults = Dict(b => 2c)) + sys = complete(sys) + @test_throws ["Could not evaluate", "b", "Missing", "2c"] MTKParameters(sys, [a => 1.0]) +end + +@testset "Issue#2804" begin + @parameters k[1:4] + @variables (V(t))[1:2] + eqs = [ + D(V[1]) ~ k[1] - k[2] * V[1], + D(V[2]) ~ k[3] - k[4] * V[2] + ] + @mtkbuild osys_scal = ODESystem(eqs, t, [V[1], V[2]], [k[1], k[2], k[3], k[4]]) + + u0 = [V => [10.0, 20.0]] + ps_vec = [k => [2.0, 3.0, 4.0, 5.0]] + ps_scal = [k[1] => 1.0, k[2] => 2.0, k[3] => 3.0, k[4] => 4.0] + oprob_scal_scal = ODEProblem(osys_scal, u0, 1.0, ps_scal) + newoprob = remake(oprob_scal_scal; p = ps_vec, build_initializeprob = false) + @test newoprob.ps[k] == [2.0, 3.0, 4.0, 5.0] +end + +# Parameter timeseries +ps = MTKParameters([1.0, 1.0], (), (BlockedArray(zeros(4), [2, 2]),), + (), (), ()) +ps2 = SciMLStructures.replace(Discrete(), ps, ones(4)) +@test typeof(ps2.discrete) == typeof(ps.discrete) +with_updated_parameter_timeseries_values( + sys, ps, 1 => ModelingToolkit.NestedGetIndex(([5.0, 10.0],))) +@test ps.discrete[1][Block(1)] == [5.0, 10.0] +with_updated_parameter_timeseries_values( + sys, ps, 1 => ModelingToolkit.NestedGetIndex(([3.0, 30.0],)), + 2 => ModelingToolkit.NestedGetIndex(([4.0, 40.0],))) +@test ps.discrete[1][Block(1)] == [3.0, 30.0] +@test ps.discrete[1][Block(2)] == [4.0, 40.0] +@test SciMLBase.get_saveable_values(sys, ps, 1).x == (ps.discrete[1][Block(1)],) + +# With multiple types and clocks +ps = MTKParameters( + (), (), + (BlockedArray([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [3, 3]), + BlockedArray(falses(1), [1, 0])), + (), (), ()) +@test SciMLBase.get_saveable_values(sys, ps, 1).x isa Tuple{Vector{Float64}, Vector{Bool}} +tsidx1 = 1 +tsidx2 = 2 +@test length(ps.discrete[1][Block(tsidx1)]) == 3 +@test length(ps.discrete[2][Block(tsidx1)]) == 1 +@test length(ps.discrete[1][Block(tsidx2)]) == 3 +@test length(ps.discrete[2][Block(tsidx2)]) == 0 +with_updated_parameter_timeseries_values( + sys, ps, tsidx1 => ModelingToolkit.NestedGetIndex(([10.0, 11.0, 12.0], [false]))) +@test ps.discrete[1][Block(tsidx1)] == [10.0, 11.0, 12.0] +@test ps.discrete[2][Block(tsidx1)][] == false diff --git a/test/namespacing.jl b/test/namespacing.jl new file mode 100644 index 0000000000..b6305a1776 --- /dev/null +++ b/test/namespacing.jl @@ -0,0 +1,186 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, iscomplete, does_namespacing + +@testset "ODESystem" begin + @variables x(t) + @parameters p + sys = ODESystem(D(x) ~ p * x, t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ODESystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "SDESystem" begin + @variables x(t) + @parameters p + sys = SDESystem([D(x) ~ p * x], [x], t, [x], [p]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] SDESystem( + Equation[], [], t; systems = [nsys], name = :a) +end + +@testset "DiscreteSystem" begin + @variables x(t) + @parameters p + k = ShiftIndex(t) + sys = DiscreteSystem([x(k) ~ p * x(k - 1)], t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] DiscreteSystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "ImplicitDiscreteSystem" begin + @variables x(t) + @parameters p + k = ShiftIndex(t) + sys = ImplicitDiscreteSystem([x(k) ~ p + x(k - 1) * x(k)], t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ImplicitDiscreteSystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "NonlinearSystem" begin + @variables x + @parameters p + sys = NonlinearSystem([x ~ p * x^2 + 1]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] NonlinearSystem( + Equation[]; systems = [nsys], name = :a) +end + +@testset "OptimizationSystem" begin + @variables x + @parameters p + sys = OptimizationSystem(p * x^2 + 1; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] OptimizationSystem( + []; systems = [nsys], name = :a) +end + +@testset "ConstraintsSystem" begin + @variables x + @parameters p + sys = ConstraintsSystem([x^2 + p ~ 0], [x], [p]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ConstraintsSystem( + [], [], []; systems = [nsys], name = :a) +end diff --git a/test/nonlinearsystem.jl b/test/nonlinearsystem.jl index 98923e804a..a315371141 100644 --- a/test/nonlinearsystem.jl +++ b/test/nonlinearsystem.jl @@ -1,109 +1,444 @@ -using ModelingToolkit, StaticArrays, LinearAlgebra -using DiffEqBase, SparseArrays -using Test -using NonlinearSolve -using ModelingToolkit: value - -canonequal(a, b) = isequal(simplify(a), simplify(b)) - -# Define some variables -@parameters t σ ρ β -@variables x y z - -function test_nlsys_inference(name, sys, vs, ps) - @testset "NonlinearSystem construction: $name" begin - @test Set(states(sys)) == Set(value.(vs)) - @test Set(parameters(sys)) == Set(value.(ps)) - end -end - -# Define a nonlinear system -eqs = [0 ~ σ*(y-x), - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z], [σ,ρ,β], defaults = Dict(x => 2)) -@test eval(toexpr(ns)) == ns -test_nlsys_inference("standard", ns, (x, y, z), (σ, ρ, β)) -@test begin - f = eval(generate_function(ns, [x,y,z], [σ,ρ,β])[2]) - du = [0.0, 0.0, 0.0] - f(du, [1,2,3], [1,2,3]) - du ≈ [1, -3, -7] -end - -# Now nonlinear system with only variables -@variables x y z -@parameters σ ρ β - -# Define a nonlinear system -eqs = [0 ~ σ*(y-x), - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z], [σ,ρ,β]) -jac = calculate_jacobian(ns) -@testset "nlsys jacobian" begin - @test canonequal(jac[1,1], σ * -1) - @test canonequal(jac[1,2], σ) - @test canonequal(jac[1,3], 0) - @test canonequal(jac[2,1], ρ - z) - @test canonequal(jac[2,2], -1) - @test canonequal(jac[2,3], x * -1) - @test canonequal(jac[3,1], y) - @test canonequal(jac[3,2], x) - @test canonequal(jac[3,3], -1 * β) -end -nlsys_func = generate_function(ns, [x,y,z], [σ,ρ,β]) -jac_func = generate_jacobian(ns) -f = @eval eval(nlsys_func) - -# Intermediate calculations -a = y - x -# Define a nonlinear system -eqs = [0 ~ σ*a, - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z], [σ,ρ,β]) -nlsys_func = generate_function(ns, [x,y,z], [σ,ρ,β]) -nf = NonlinearFunction(ns) -jac = calculate_jacobian(ns) - -@test ModelingToolkit.jacobian_sparsity(ns).colptr == sparse(jac).colptr -@test ModelingToolkit.jacobian_sparsity(ns).rowval == sparse(jac).rowval - -jac = generate_jacobian(ns) - -prob = NonlinearProblem(ns,ones(3),ones(3)) -sol = solve(prob,NewtonRaphson()) -@test sol.u[1] ≈ sol.u[2] - -@test_throws ArgumentError NonlinearProblem(ns,ones(4),ones(3)) - -@variables u F s a -eqs1 = [ - 0 ~ σ*(y-x) + F, - 0 ~ x*(ρ-z)-u, - 0 ~ x*y - β*z, - 0 ~ x + y - z - u, - ] - -lorenz = name -> NonlinearSystem(eqs1, [x,y,z,u,F], [σ,ρ,β], name=name) -lorenz1 = lorenz(:lorenz1) -@test_throws ArgumentError NonlinearProblem(lorenz1, zeros(5)) -lorenz2 = lorenz(:lorenz2) -connected = NonlinearSystem([s ~ a + lorenz1.x - lorenz2.y ~ s - lorenz1.F ~ lorenz2.u - lorenz2.F ~ lorenz1.u], [s, a], [], systems=[lorenz1,lorenz2]) -@test_nowarn alias_elimination(connected) - -# system promotion -using OrdinaryDiffEq -@variables t -D = Differential(t) -@named subsys = convert_system(ODESystem, lorenz1, t) -@named sys = ODESystem([D(subsys.x) ~ subsys.x + subsys.x], t, systems=[subsys]) -sys = structural_simplify(sys) -prob = ODEProblem(sys, [subsys.x => 1, subsys.z => 2.0], (0, 1.0), [subsys.σ=>1,subsys.ρ=>2,subsys.β=>3]) -sol = solve(prob, Rodas5()) -@test sol[subsys.x] + sol[subsys.y] - sol[subsys.z] ≈ sol[subsys.u] -@test_throws ArgumentError convert_system(ODESystem, sys, t) +using ModelingToolkit, StaticArrays, LinearAlgebra +using ModelingToolkit: get_metadata +using DiffEqBase, SparseArrays +using Test +using NonlinearSolve +using ForwardDiff +using SymbolicIndexingInterface +using ModelingToolkit: value +using ModelingToolkit: get_default_or_guess, MTKParameters + +canonequal(a, b) = isequal(simplify(a), simplify(b)) + +# Define some variables +@parameters σ ρ β +@constants h = 1 +@variables x y z + +function test_nlsys_inference(name, sys, vs, ps) + @testset "NonlinearSystem construction: $name" begin + @test Set(unknowns(sys)) == Set(value.(vs)) + @test Set(parameters(sys)) == Set(value.(ps)) + end +end + +# Define a nonlinear system +eqs = [0 ~ σ * (y - x) * h, + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] +@named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β], defaults = Dict(x => 2)) +@test eval(toexpr(ns)) == ns +test_nlsys_inference("standard", ns, (x, y, z), (σ, ρ, β)) +@test begin + f = generate_function(ns, [x, y, z], [σ, ρ, β], expression = Val{false})[2] + du = [0.0, 0.0, 0.0] + f(du, [1, 2, 3], [1, 2, 3]) + du ≈ [1, -3, -7] +end + +# Now nonlinear system with only variables +@variables x y z +@parameters σ ρ β + +# Define a nonlinear system +eqs = [0 ~ σ * (y - x), + y ~ x * (ρ - z), + β * z ~ x * y] +@named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) +jac = calculate_jacobian(ns) +@testset "nlsys jacobian" begin + @test canonequal(jac[1, 1], σ * -1) + @test canonequal(jac[1, 2], σ) + @test canonequal(jac[1, 3], 0) + @test canonequal(jac[2, 1], ρ - z) + @test canonequal(jac[2, 2], -1) + @test canonequal(jac[2, 3], x * -1) + @test canonequal(jac[3, 1], y) + @test canonequal(jac[3, 2], x) + @test canonequal(jac[3, 3], -1 * β) +end +nlsys_func = generate_function(ns, [x, y, z], [σ, ρ, β]) +jac_func = generate_jacobian(ns) +f = @eval eval(nlsys_func) + +# Intermediate calculations +a = y - x +# Define a nonlinear system +eqs = [0 ~ σ * a * h, + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] +@named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) +ns = complete(ns) +nlsys_func = generate_function(ns, [x, y, z], [σ, ρ, β]) +nf = NonlinearFunction(ns) +jac = calculate_jacobian(ns) + +@test ModelingToolkit.jacobian_sparsity(ns).colptr == sparse(jac).colptr +@test ModelingToolkit.jacobian_sparsity(ns).rowval == sparse(jac).rowval + +jac = generate_jacobian(ns) + +sH = calculate_hessian(ns) +@test getfield.(ModelingToolkit.hessian_sparsity(ns), :colptr) == + getfield.(sparse.(sH), :colptr) +@test getfield.(ModelingToolkit.hessian_sparsity(ns), :rowval) == + getfield.(sparse.(sH), :rowval) + +prob = NonlinearProblem(ns, ones(3), [σ => 1.0, ρ => 1.0, β => 1.0]) +@test prob.f.sys === ns +sol = solve(prob, NewtonRaphson()) +@test sol.u[1] ≈ sol.u[2] + +prob = NonlinearProblem(ns, ones(3), [σ => 1.0, ρ => 1.0, β => 1.0], jac = true) +@test_nowarn solve(prob, NewtonRaphson()) + +@test_throws ArgumentError NonlinearProblem(ns, ones(4), [σ => 1.0, ρ => 1.0, β => 1.0]) + +@variables u F s a +eqs1 = [ + 0 ~ σ * (y - x) * h + F, + 0 ~ x * (ρ - z) - u, + 0 ~ x * y - β * z, + 0 ~ x + y - z - u +] + +lorenz = name -> NonlinearSystem(eqs1, [x, y, z, u, F], [σ, ρ, β], name = name) +lorenz1 = lorenz(:lorenz1) +@test_throws ArgumentError NonlinearProblem(complete(lorenz1), zeros(5), zeros(3)) +lorenz2 = lorenz(:lorenz2) +@named connected = NonlinearSystem( + [s ~ a + lorenz1.x + lorenz2.y ~ s * h + lorenz1.F ~ lorenz2.u + lorenz2.F ~ lorenz1.u], + [s, a], [], + systems = [lorenz1, lorenz2]) +@test_nowarn alias_elimination(connected) + +# system promotion +using OrdinaryDiffEq +@independent_variables t +D = Differential(t) +@named subsys = convert_system(ODESystem, lorenz1, t) +@named sys = ODESystem([D(subsys.x) ~ subsys.x + subsys.x], t, systems = [subsys]) +sys = structural_simplify(sys) +u0 = [subsys.x => 1, subsys.z => 2.0, subsys.y => 1.0] +prob = ODEProblem(sys, u0, (0, 1.0), [subsys.σ => 1, subsys.ρ => 2, subsys.β => 3]) +sol = solve(prob, FBDF(), reltol = 1e-7, abstol = 1e-7) +@test sol[subsys.x] + sol[subsys.y] - sol[subsys.z]≈sol[subsys.u] atol=1e-7 +@test_throws ArgumentError convert_system(ODESystem, sys, t) + +@parameters σ ρ β +@variables x y z + +# Define a nonlinear system +eqs = [0 ~ σ * (y - x), + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z * h] +@named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) +np = NonlinearProblem( + complete(ns), [0, 0, 0], [σ => 1, ρ => 2, β => 3], jac = true, sparse = true) +@test calculate_jacobian(ns, sparse = true) isa SparseMatrixCSC + +# issue #819 +@testset "Combined system name collisions" begin + function makesys(name) + @parameters a + @variables x f + + NonlinearSystem([0 ~ -a * x + f], [x, f], [a]; name) + end + + function issue819() + sys1 = makesys(:sys1) + sys2 = makesys(:sys1) + @test_throws ArgumentError NonlinearSystem([sys2.f ~ sys1.x, sys1.f ~ 0], [], [], + systems = [sys1, sys2], name = :foo) + end + issue819() +end + +# issue #1115 +@testset "Extending a NonlinearSystem with no iv" begin + @parameters a b + @variables x y + eqs1 = [ + 0 ~ a * x + ] + eqs2 = [ + 0 ~ b * y + ] + + @named sys1 = NonlinearSystem(eqs1, [x], [a]) + @named sys2 = NonlinearSystem(eqs2, [y], [b]) + @named sys3 = extend(sys1, sys2) + + @test isequal(union(Set(parameters(sys1)), Set(parameters(sys2))), + Set(parameters(sys3))) + @test isequal(union(Set(unknowns(sys1)), Set(unknowns(sys2))), Set(unknowns(sys3))) + @test isequal(union(Set(equations(sys1)), Set(equations(sys2))), Set(equations(sys3))) +end + +# observed variable handling +@independent_variables t +@parameters τ +@variables x(t) RHS(t) +@named fol = NonlinearSystem([0 ~ (1 - x * h) / τ], [x], [τ]; + observed = [RHS ~ (1 - x) / τ]) +@test isequal(RHS, @nonamespace fol.RHS) +RHS2 = RHS +@unpack RHS = fol +@test isequal(RHS, RHS2) + +# issue #1358 +@independent_variables t +@variables v1(t) v2(t) i1(t) i2(t) +eq = [v1 ~ sin(2pi * t * h) + v1 - v2 ~ i1 + v2 ~ i2 + i1 ~ i2] +@named sys = ODESystem(eq, t) +@test length(equations(structural_simplify(sys))) == 0 + +@testset "Issue: 1504" begin + @variables u[1:4] + + eqs = [u[1] ~ 1, + u[2] ~ 1, + u[3] ~ 1, + u[4] ~ h] + + sys = NonlinearSystem(eqs, collect(u[1:4]), Num[], defaults = Dict([]), name = :test) + sys = complete(sys) + prob = NonlinearProblem(sys, ones(length(unknowns(sys)))) + + sol = NonlinearSolve.solve(prob, NewtonRaphson()) + + @test sol[u] ≈ ones(4) +end + +@variables x(t) +@parameters a +eqs = [0 ~ a * x] + +testdict = Dict([:test => 1]) +@named sys = NonlinearSystem(eqs, [x], [a], metadata = testdict) +@test get_metadata(sys) == testdict + +@testset "Remake" begin + @parameters a=1.0 b=1.0 c=1.0 + @constants h = 1 + @variables x y z + + eqs = [0 ~ a * (y - x) * h, + 0 ~ x * (b - z) - y, + 0 ~ x * y - c * z] + @named sys = NonlinearSystem(eqs, [x, y, z], [a, b, c], defaults = Dict(x => 2.0)) + sys = complete(sys) + prob = NonlinearProblem(sys, ones(length(unknowns(sys)))) + + prob_ = remake(prob, u0 = [1.0, 2.0, 3.0], p = [a => 1.1, b => 1.2, c => 1.3]) + @test prob_.u0 == [1.0, 2.0, 3.0] + initials = unknowns(sys) .=> [1.0, 2.0, 3.0] + @test prob_.p == MTKParameters(sys, [a => 1.1, b => 1.2, c => 1.3, initials...]) + + prob_ = remake(prob, u0 = Dict(y => 2.0), p = Dict(a => 2.0)) + @test prob_.u0 == [1.0, 2.0, 1.0] + initials = [x => 1.0, y => 2.0, z => 1.0] + @test prob_.p == MTKParameters(sys, [a => 2.0, b => 1.0, c => 1.0, initials...]) +end + +@testset "Observed function generation without parameters" begin + @variables x y z + + eqs = [0 ~ x + sin(y), + 0 ~ z - cos(x), + 0 ~ x * y] + @named ns = NonlinearSystem(eqs, [x, y, z], []) + ns = complete(ns) + vs = [unknowns(ns); parameters(ns)] + ss_mtk = structural_simplify(ns) + prob = NonlinearProblem(ss_mtk, vs .=> 1.0) + sol = solve(prob) + @test_nowarn sol[unknowns(ns)] +end + +# Issue#2625 +@parameters p d +@variables X(t) +alg_eqs = [0 ~ p - d * X] + +sys = @test_nowarn NonlinearSystem(alg_eqs; name = :name) +@test isequal(only(unknowns(sys)), X) +@test all(isequal.(parameters(sys), [p, d])) + +# Over-determined sys +@variables u1 u2 +@parameters u3 u4 +eqs = [u3 ~ u1 + u2, u4 ~ 2 * (u1 + u2), u3 + u4 ~ 3 * (u1 + u2)] +@named ns = NonlinearSystem(eqs, [u1, u2], [u3, u4]) +sys = structural_simplify(ns; fully_determined = false) +@test length(unknowns(sys)) == 1 + +# Conservative +@variables X(t) +alg_eqs = [1 ~ 2X] +@named ns = NonlinearSystem(alg_eqs) +sys = structural_simplify(ns) +@test length(equations(sys)) == 0 +sys = structural_simplify(ns; conservative = true) +@test length(equations(sys)) == 1 + +# https://github.com/SciML/ModelingToolkit.jl/issues/2858 +@testset "Jacobian/Hessian with observed equations that depend on unknowns" begin + @variables x y z + @parameters σ ρ β + eqs = [0 ~ σ * (y - x) + 0 ~ x * (ρ - z) - y + 0 ~ x * y - β * z] + guesses = [x => 1.0, z => 0.0] + ps = [σ => 10.0, ρ => 26.0, β => 8 / 3] + @mtkbuild ns = NonlinearSystem(eqs) + + @test isequal(calculate_jacobian(ns), [(-1 - z + ρ)*σ -x*σ + 2x*(-z + ρ) -β-(x^2)]) + # solve without analytical jacobian + prob = NonlinearProblem(ns, guesses, ps) + sol = solve(prob, NewtonRaphson()) + @test sol.retcode == ReturnCode.Success + + # solve with analytical jacobian + prob = NonlinearProblem(ns, guesses, ps, jac = true) + sol = solve(prob, NewtonRaphson()) + @test sol.retcode == ReturnCode.Success + + # system that contains a chain of observed variables when simplified + @variables x y z + eqs = [0 ~ x^2 + 2z + y, z ~ y, y ~ x] # analytical solution x = y = z = 0 or -3 + @mtkbuild ns = NonlinearSystem(eqs) # solve for y with observed chain z -> x -> y + @test isequal(expand.(calculate_jacobian(ns)), [3 // 2 + y;;]) + @test isequal(calculate_hessian(ns), [[1;;]]) + prob = NonlinearProblem(ns, unknowns(ns) .=> -4.0) # give guess < -3 to reach -3 + sol = solve(prob, NewtonRaphson()) + @test sol[x] ≈ sol[y] ≈ sol[z] ≈ -3 +end + +@testset "Passing `nothing` to `u0`" begin + @variables x = 1 + @mtkbuild sys = NonlinearSystem([0 ~ x^2 - x^3 + 3]) + prob = @test_nowarn NonlinearProblem(sys, nothing) + @test_nowarn solve(prob) +end + +@testset "System of linear equations with vector variable" begin + # 1st example in https://en.wikipedia.org/w/index.php?title=System_of_linear_equations&oldid=1247697953 + @variables x[1:3] + A = [3 2 -1 + 2 -2 4 + -1 1/2 -1] + b = [1, -2, 0] + @named sys = NonlinearSystem(A * x ~ b, [x], []) + sys = structural_simplify(sys) + prob = NonlinearProblem(sys, unknowns(sys) .=> 0.0) + sol = solve(prob) + @test all(sol[x] .≈ A \ b) +end + +@testset "resid_prototype when system has no unknowns and an equation" begin + @variables x + @parameters p + @named sys = NonlinearSystem([x ~ 1, x^2 - p ~ 0]) + for sys in [ + structural_simplify(sys, fully_determined = false), + structural_simplify(sys, fully_determined = false, split = false) + ] + @test length(equations(sys)) == 1 + @test length(unknowns(sys)) == 0 + T = typeof(ForwardDiff.Dual(1.0)) + prob = NonlinearProblem(sys, [], [p => ForwardDiff.Dual(1.0)]; check_length = false) + @test prob.f(Float64[], prob.p) isa Vector{T} + @test prob.f.resid_prototype isa Vector{T} + @test_nowarn solve(prob) + end +end + +@testset "IntervalNonlinearProblem" begin + @variables x + @parameters p + @named nlsys = NonlinearSystem([0 ~ x * x - p]) + + for sys in [complete(nlsys), complete(nlsys; split = false)] + prob = IntervalNonlinearProblem(sys, (0.0, 2.0), [p => 1.0]) + sol = @test_nowarn solve(prob, ITP()) + @test SciMLBase.successful_retcode(sol) + @test_nowarn IntervalNonlinearProblemExpr(sys, (0.0, 2.0), [p => 1.0]) + end + + @variables y + @mtkbuild sys = NonlinearSystem([0 ~ x * x - p * x + p, 0 ~ x * y + p]) + @test_throws ["single equation", "unknown"] IntervalNonlinearProblem(sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearFunction(sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearProblemExpr( + sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearFunctionExpr( + sys, (0.0, 1.0)) +end + +@testset "Vector parameter used unscalarized and partially scalarized" begin + @variables x y + @parameters p[1:2] (f::Function)(..) + + @mtkbuild sys = NonlinearSystem([x^2 - p[1]^2 ~ 0, y^2 ~ f(p)]) + @test !any(isequal(p[1]), parameters(sys)) + @test is_parameter(sys, p) +end + +@testset "Can convert from `ODESystem`" begin + @variables x(t) y(t) + @parameters p q r + @named sys = ODESystem([D(x) ~ p * x^3 + q, 0 ~ -y + q * x - r], t; + defaults = [x => 1.0, p => missing], guesses = [p => 1.0], + initialization_eqs = [p^3 + q^3 ~ 4r], parameter_dependencies = [r ~ 3p]) + nlsys = NonlinearSystem(sys) + defs = defaults(nlsys) + @test length(defs) == 3 + @test defs[x] == 1.0 + @test defs[p] === missing + @test isinf(defs[t]) + @test length(guesses(nlsys)) == 1 + @test guesses(nlsys)[p] == 1.0 + @test length(initialization_equations(nlsys)) == 1 + @test length(parameter_dependencies(nlsys)) == 1 + @test length(equations(nlsys)) == 2 + @test all(iszero, [eq.lhs for eq in equations(nlsys)]) + @test nameof(nlsys) == nameof(sys) + @test !ModelingToolkit.iscomplete(nlsys) + + sys1 = complete(sys; split = false) + nlsys = NonlinearSystem(sys1) + @test ModelingToolkit.iscomplete(nlsys) + @test !ModelingToolkit.is_split(nlsys) + + sys2 = complete(sys) + nlsys = NonlinearSystem(sys2) + @test ModelingToolkit.iscomplete(nlsys) + @test ModelingToolkit.is_split(nlsys) + + sys3 = structural_simplify(sys) + nlsys = NonlinearSystem(sys3) + @test length(equations(nlsys)) == length(ModelingToolkit.observed(nlsys)) == 1 + + prob = NonlinearProblem(sys3, [q => 2.0]) + @test prob.f.initialization_data.initializeprobmap === nothing + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.ps[p^3 + q^3]≈sol.ps[4r] atol=1e-10 + + @testset "Differential inside expression also substituted" begin + @named sys = ODESystem([0 ~ y * D(x) + x^2 - p, 0 ~ x * D(y) + y * p], t) + nlsys = NonlinearSystem(sys) + vs = ModelingToolkit.vars(equations(nlsys)) + @test !in(D(x), vs) + @test !in(D(y), vs) + end +end diff --git a/test/odesystem.jl b/test/odesystem.jl index 2ce0868b28..4b76da6e9d 100644 --- a/test/odesystem.jl +++ b/test/odesystem.jl @@ -1,331 +1,1734 @@ using ModelingToolkit, StaticArrays, LinearAlgebra +using ModelingToolkit: get_metadata, MTKParameters +using SymbolicIndexingInterface using OrdinaryDiffEq, Sundials using DiffEqBase, SparseArrays using StaticArrays using Test - +using SymbolicUtils: issym +using ForwardDiff using ModelingToolkit: value +using ModelingToolkit: t_nounits as t, D_nounits as D # Define some variables -@parameters t σ ρ β +@parameters σ ρ β +@constants κ = 1 @variables x(t) y(t) z(t) -D = Differential(t) +@parameters k # Define a differential equation -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z * κ] ModelingToolkit.toexpr.(eqs)[1] -de = ODESystem(eqs; defaults=Dict(x => 1)) +@named de = ODESystem(eqs, t; defaults = Dict(x => 1)) +subed = substitute(de, [σ => k]) +ssort(eqs) = sort(eqs, by = string) +@test isequal(ssort(parameters(subed)), [k, β, ρ]) +@test isequal(equations(subed), + [D(x) ~ k * (y - x) + D(y) ~ (ρ - z) * x - y + D(z) ~ x * y - β * κ * z]) +@named des[1:3] = ODESystem(eqs, t) +@test length(unique(x -> ModelingToolkit.get_tag(x), des)) == 1 + @test eval(toexpr(de)) == de +@test hash(deepcopy(de)) == hash(de) generate_function(de) function test_diffeq_inference(name, sys, iv, dvs, ps) @testset "ODESystem construction: $name" begin - @test isequal(independent_variable(sys), value(iv)) - @test isempty(setdiff(Set(states(sys)), Set(value.(dvs)))) + @test isequal(independent_variables(sys)[1], value(iv)) + @test length(independent_variables(sys)) == 1 + @test isempty(setdiff(Set(unknowns(sys)), Set(value.(dvs)))) @test isempty(setdiff(Set(parameters(sys)), Set(value.(ps)))) end end test_diffeq_inference("standard", de, t, [x, y, z], [ρ, σ, β]) -generate_function(de, [x,y,z], [σ,ρ,β]) +generate_function(de, [x, y, z], [σ, ρ, β]) jac_expr = generate_jacobian(de) jac = calculate_jacobian(de) jacfun = eval(jac_expr[2]) -for f in [ - ODEFunction(de, [x,y,z], [σ,ρ,β], tgrad = true, jac = true), - eval(ODEFunctionExpr(de, [x,y,z], [σ,ρ,β], tgrad = true, jac = true)), -] - # iip - du = zeros(3) - u = collect(1:3) - p = collect(4:6) - f.f(du, u, p, 0.1) - @test du == [4, 0, -16] - - # oop - du = @SArray zeros(3) - u = SVector(1:3...) - p = SVector(4:6...) - @test f.f(u, p, 0.1) === @SArray [4, 0, -16] - - # iip vs oop - du = zeros(3) - g = similar(du) - J = zeros(3, 3) - u = collect(1:3) - p = collect(4:6) - f.f(du, u, p, 0.1) - @test du == f(u, p, 0.1) - f.tgrad(g, u, p, t) - @test g == f.tgrad(u, p, t) - f.jac(J, u, p, t) - @test J == f.jac(u, p, t) -end - - -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y*t, - D(z) ~ x*y - β*z] -de = ODESystem(eqs) # This is broken -ModelingToolkit.calculate_tgrad(de) +de = complete(de) +f = ODEFunction(de, [x, y, z], [σ, ρ, β], tgrad = true, jac = true) +# system +@test f.sys === de -tgrad_oop, tgrad_iip = eval.(ModelingToolkit.generate_tgrad(de)) +# iip +du = zeros(3) +u = collect(1:3) +p = ModelingToolkit.MTKParameters(de, [σ, ρ, β] .=> 4.0:6.0) +f.f(du, u, p, 0.1) +@test du == [4, 0, -16] -u = SVector(1:3...) -p = SVector(4:6...) -@test tgrad_oop(u,p,t) == [0.0,-u[2],0.0] +# oop +du = @SArray zeros(3) +u = SVector(1:3...) +p = ModelingToolkit.MTKParameters(de, SVector{3}([σ, ρ, β] .=> 4.0:6.0)) +@test f.f(u, p, 0.1) === @SArray [4.0, 0.0, -16.0] + +# iip vs oop du = zeros(3) -tgrad_iip(du,u,p,t) -@test du == [0.0,-u[2],0.0] - -@testset "time-varying parameters" begin - @parameters σ′(t-1) - eqs = [D(x) ~ σ′*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - de = ODESystem(eqs) - test_diffeq_inference("global iv-varying", de, t, (x, y, z), (σ′, ρ, β)) - @test begin - f = eval(generate_function(de, [x,y,z], [σ′,ρ,β])[2]) - du = [0.0,0.0,0.0] - f(du, [1.0,2.0,3.0], [x->x+7,2,3], 5.0) - du ≈ [11, -3, -7] - end +g = similar(du) +J = zeros(3, 3) +u = collect(1:3) +p = ModelingToolkit.MTKParameters(de, [σ, ρ, β] .=> 4.0:6.0) +f.f(du, u, p, 0.1) +@test du == f(u, p, 0.1) +f.tgrad(g, u, p, t) +@test g == f.tgrad(u, p, t) +f.jac(J, u, p, t) +@test J == f.jac(u, p, t) - @parameters σ(..) - eqs = [D(x) ~ σ(t-1)*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - de = ODESystem(eqs) - test_diffeq_inference("single internal iv-varying", de, t, (x, y, z), (σ(t-1), ρ, β)) - @test begin - f = eval(generate_function(de, [x,y,z], [σ,ρ,β])[2]) - du = [0.0,0.0,0.0] - f(du, [1.0,2.0,3.0], [x->x+7,2,3], 5.0) - du ≈ [11, -3, -7] - end +#check iip_config +f = ODEFunction(de, [x, y, z], [σ, ρ, β], iip_config = (false, true)) +du = zeros(3) +u = collect(1:3) +p = ModelingToolkit.MTKParameters(de, [σ, ρ, β] .=> 4.0:6.0) +f.f(du, u, p, 0.1) +@test du == [4, 0, -16] +@test_throws ArgumentError f.f(u, p, 0.1) + +#check iip +f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β])) +f2 = ODEFunction(de, [x, y, z], [σ, ρ, β]) +@test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) +@test SciMLBase.specialization(f) === SciMLBase.specialization(f2) +for iip in (true, false) + f = eval(ODEFunctionExpr{iip}(de, [x, y, z], [σ, ρ, β])) + f2 = ODEFunction{iip}(de, [x, y, z], [σ, ρ, β]) + @test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) === iip + @test SciMLBase.specialization(f) === SciMLBase.specialization(f2) - eqs = [D(x) ~ x + 10σ(t-1) + 100σ(t-2) + 1000σ(t^2)] - de = ODESystem(eqs) - test_diffeq_inference("many internal iv-varying", de, t, (x,), (σ(t-2),σ(t^2), σ(t-1))) - @test begin - f = eval(generate_function(de, [x], [σ])[2]) - du = [0.0] - f(du, [1.0], [t -> t + 2], 5.0) - du ≈ [27561] + for specialize in (SciMLBase.AutoSpecialize, SciMLBase.FullSpecialize) + f = eval(ODEFunctionExpr{iip, specialize}(de, [x, y, z], [σ, ρ, β])) + f2 = ODEFunction{iip, specialize}(de, [x, y, z], [σ, ρ, β]) + @test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) === iip + @test SciMLBase.specialization(f) === SciMLBase.specialization(f2) === specialize end end +#check sparsity +f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β], sparsity = true)) +@test f.sparsity == ModelingToolkit.jacobian_sparsity(de) + +f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β], sparsity = false)) +@test isnothing(f.sparsity) + +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y * t, + D(z) ~ x * y - β * z * κ] +@named de = ODESystem(eqs, t) +de = complete(de) +ModelingToolkit.calculate_tgrad(de) + +tgrad_oop, tgrad_iip = eval.(ModelingToolkit.generate_tgrad(de)) + +u = SVector(1:3...) +p = ModelingToolkit.MTKParameters(de, SVector{3}([σ, ρ, β] .=> 4.0:6.0)) +@test tgrad_oop(u, p, t) == [0.0, -u[2], 0.0] +du = zeros(3) +tgrad_iip(du, u, p, t) +@test du == [0.0, -u[2], 0.0] + +@parameters σ′(t - 1) +eqs = [D(x) ~ σ′ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z * κ] +@named de = ODESystem(eqs, t) +test_diffeq_inference("global iv-varying", de, t, (x, y, z), (σ′, ρ, β)) + +f = generate_function(de, [x, y, z], [σ′, ρ, β], expression = Val{false})[2] +du = [0.0, 0.0, 0.0] +f(du, [1.0, 2.0, 3.0], [x -> x + 7, 2, 3], 5.0) +@test du ≈ [11, -3, -7] + +@parameters σ(..) +eqs = [D(x) ~ σ(t - 1) * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z * κ] +@named de = ODESystem(eqs, t) +test_diffeq_inference("single internal iv-varying", de, t, (x, y, z), (σ, ρ, β)) +f = generate_function(de, [x, y, z], [σ, ρ, β], expression = Val{false})[2] +du = [0.0, 0.0, 0.0] +f(du, [1.0, 2.0, 3.0], [x -> x + 7, 2, 3], 5.0) +@test du ≈ [11, -3, -7] + +eqs = [D(x) ~ x + 10σ(t - 1) + 100σ(t - 2) + 1000σ(t^2)] +@named de = ODESystem(eqs, t) +test_diffeq_inference("many internal iv-varying", de, t, (x,), (σ,)) +f = generate_function(de, [x], [σ], expression = Val{false})[2] +du = [0.0] +f(du, [1.0], [t -> t + 2], 5.0) +@test du ≈ [27561] + # Conversion to first-order ODEs #17 -D3 = Differential(t)^3 -D2 = Differential(t)^2 +D3 = D^3 +D2 = D^2 @variables u(t) uˍtt(t) uˍt(t) xˍt(t) eqs = [D3(u) ~ 2(D2(u)) + D(u) + D(x) + 1 D2(x) ~ D(x) + 2] -de = ODESystem(eqs) +@named de = ODESystem(eqs, t) de1 = ode_order_lowering(de) lowered_eqs = [D(uˍtt) ~ 2uˍtt + uˍt + xˍt + 1 - D(xˍt) ~ xˍt + 2 - D(uˍt) ~ uˍtt - D(u) ~ uˍt - D(x) ~ xˍt] + D(xˍt) ~ xˍt + 2 + D(uˍt) ~ uˍtt + D(u) ~ uˍt + D(x) ~ xˍt] #@test de1 == ODESystem(lowered_eqs) # issue #219 -@test all(isequal.([ModelingToolkit.var_from_nested_derivative(eq.lhs)[1] for eq in equations(de1)], states(ODESystem(lowered_eqs)))) +@test all(isequal.( + [ModelingToolkit.var_from_nested_derivative(eq.lhs)[1] + for eq in equations(de1)], + unknowns(@named lowered = ODESystem(lowered_eqs, t)))) test_diffeq_inference("first-order transform", de1, t, [uˍtt, xˍt, uˍt, u, x], []) du = zeros(5) -ODEFunction(de1, [uˍtt, xˍt, uˍt, u, x], [])(du, ones(5), nothing, 0.1) +ODEFunction(complete(de1), [uˍtt, xˍt, uˍt, u, x], [])(du, ones(5), nothing, 0.1) @test du == [5.0, 3.0, 1.0, 1.0, 1.0] # Internal calculations +@parameters σ a = y - x -eqs = [D(x) ~ σ*a, - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] -de = ODESystem(eqs) -generate_function(de, [x,y,z], [σ,ρ,β]) +eqs = [D(x) ~ σ * a, + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z * κ] +@named de = ODESystem(eqs, t) +generate_function(de, [x, y, z], [σ, ρ, β]) jac = calculate_jacobian(de) @test ModelingToolkit.jacobian_sparsity(de).colptr == sparse(jac).colptr @test ModelingToolkit.jacobian_sparsity(de).rowval == sparse(jac).rowval -f = ODEFunction(de, [x,y,z], [σ,ρ,β]) +f = ODEFunction(complete(de), [x, y, z], [σ, ρ, β]) -D = Differential(t) @parameters A B C _x = y / C -eqs = [D(x) ~ -A*x, - D(y) ~ A*x - B*_x] -de = ODESystem(eqs) +eqs = [D(x) ~ -A * x, + D(y) ~ A * x - B * _x] +@named de = ODESystem(eqs, t) @test begin local f, du - f = eval(generate_function(de, [x,y], [A,B,C])[2]) - du = [0.0,0.0] - f(du, [1.0,2.0], [1,2,3], 0.0) - du ≈ [-1, -1/3] - f = eval(generate_function(de, [x,y], [A,B,C])[1]) - du ≈ f([1.0,2.0], [1,2,3], 0.0) + f = generate_function(de, [x, y], [A, B, C], expression = Val{false})[2] + du = [0.0, 0.0] + f(du, [1.0, 2.0], [1, 2, 3], 0.0) + du ≈ [-1, -1 / 3] + f = generate_function(de, [x, y], [A, B, C], expression = Val{false})[1] + du ≈ f([1.0, 2.0], [1, 2, 3], 0.0) end -function lotka(u,p,t) - x = u[1] - y = u[2] - [p[1]*x - p[2]*x*y, - -p[3]*y + p[4]*x*y] +function lotka(u, p, t) + x = u[1] + y = u[2] + [p[1] * x - p[2] * x * y, + -p[3] * y + p[4] * x * y] end -prob = ODEProblem(ODEFunction{false}(lotka),[1.0,1.0],(0.0,1.0),[1.5,1.0,3.0,1.0]) -de = modelingtoolkitize(prob) +prob = ODEProblem(ODEFunction{false}(lotka), [1.0, 1.0], (0.0, 1.0), [1.5, 1.0, 3.0, 1.0]) +de = complete(modelingtoolkitize(prob)) ODEFunction(de)(similar(prob.u0), prob.u0, prob.p, 0.1) -function lotka(du,u,p,t) - x = u[1] - y = u[2] - du[1] = p[1]*x - p[2]*x*y - du[2] = -p[3]*y + p[4]*x*y +function lotka(du, u, p, t) + x = u[1] + y = u[2] + du[1] = p[1] * x - p[2] * x * y + du[2] = -p[3] * y + p[4] * x * y end -prob = ODEProblem(lotka,[1.0,1.0],(0.0,1.0),[1.5,1.0,3.0,1.0]) +prob = ODEProblem(lotka, [1.0, 1.0], (0.0, 1.0), [1.5, 1.0, 3.0, 1.0]) -de = modelingtoolkitize(prob) +de = complete(modelingtoolkitize(prob)) ODEFunction(de)(similar(prob.u0), prob.u0, prob.p, 0.1) -# automatic state detection for DAEs -@parameters t k₁ k₂ k₃ +# automatic unknown detection for DAEs +@parameters k₁ k₂ k₃ @variables y₁(t) y₂(t) y₃(t) -D = Differential(t) # reorder the system just to be a little spicier -eqs = [D(y₁) ~ -k₁*y₁+k₃*y₂*y₃, - 0 ~ y₁ + y₂ + y₃ - 1, - D(y₂) ~ k₁*y₁-k₂*y₂^2-k₃*y₂*y₃] -sys = ODESystem(eqs, defaults=[k₁ => 100, k₂ => 3e7, y₁ => 1.0]) +eqs = [D(y₁) ~ -k₁ * y₁ + k₃ * y₂ * y₃, + 0 ~ y₁ + y₂ + y₃ - 1, + D(y₂) ~ k₁ * y₁ - k₂ * y₂^2 - k₃ * y₂ * y₃ * κ] +@named sys = ODESystem(eqs, t, defaults = [k₁ => 100, k₂ => 3e7, y₁ => 1.0]) +sys = complete(sys) u0 = Pair[] push!(u0, y₂ => 0.0) push!(u0, y₃ => 0.0) -p = [k₁ => 0.04, - k₃ => 1e4] +p = [k₁ => 0.04, + k₃ => 1e4] p2 = (k₁ => 0.04, - k₂ => 3e7, - k₃ => 1e4) -tspan = (0.0,100000.0) -prob1 = ODEProblem(sys,u0,tspan,p) -prob12 = ODEProblem(sys,u0,tspan,[0.04,3e7,1e4]) -prob13 = ODEProblem(sys,u0,tspan,(0.04,3e7,1e4)) -prob14 = ODEProblem(sys,u0,tspan,p2) + k₂ => 3e7, + k₃ => 1e4) +tspan = (0.0, 100000.0) +prob1 = ODEProblem(sys, u0, tspan, p) +@test prob1.f.sys == sys +prob12 = ODEProblem(sys, u0, tspan, [k₁ => 0.04, k₂ => 3e7, k₃ => 1e4]) +prob13 = ODEProblem(sys, u0, tspan, (k₁ => 0.04, k₂ => 3e7, k₃ => 1e4)) +prob14 = ODEProblem(sys, u0, tspan, p2) for p in [prob1, prob14] - @test Set(Num.(parameters(sys)) .=> p.p) == Set([k₁=>0.04, k₂=>3e7, k₃=>1e4]) - @test Set(Num.(states(sys)) .=> p.u0) == Set([y₁=>1, y₂=>0, y₃=>0]) + @test p.p isa MTKParameters + p.ps[k₁] ≈ 0.04 + p.ps[k₂] ≈ 3e7 + p.ps[k₃] ≈ 1e-4 + @test Set(Num.(unknowns(sys)) .=> p.u0) == Set([y₁ => 1, y₂ => 0, y₃ => 0]) +end +# test remake with symbols +p3 = [k₁ => 0.05, + k₂ => 2e7, + k₃ => 1.1e4] +u01 = [y₁ => 1, y₂ => 1, y₃ => 1] +prob_pmap = remake(prob14; p = p3, u0 = u01) +prob_dpmap = remake(prob14; p = Dict(p3), u0 = Dict(u01)) +for p in [prob_pmap, prob_dpmap] + @test p.p isa MTKParameters + p.ps[k₁] ≈ 0.05 + p.ps[k₂] ≈ 2e7 + p.ps[k₃] ≈ 1.1e-4 + @test Set(Num.(unknowns(sys)) .=> p.u0) == Set([y₁ => 1, y₂ => 1, y₃ => 1]) +end +sol_pmap = solve(prob_pmap, Rodas5()) +sol_dpmap = solve(prob_dpmap, Rodas5()) +@test all(isequal(0.05), sol_pmap.(0:10:100, idxs = k₁)) + +@test sol_pmap.u ≈ sol_dpmap.u + +@testset "symbolic remake with nested system" begin + function makesys(name) + @parameters a = 1.0 + @variables x(t) = 0.0 + ODESystem([D(x) ~ -a * x], t; name) + end + + function makecombinedsys() + sys1 = makesys(:sys1) + sys2 = makesys(:sys2) + @parameters b = 1.0 + complete(ODESystem(Equation[], t, [], [b]; systems = [sys1, sys2], name = :foo)) + end + + sys = makecombinedsys() + @unpack sys1, b = sys + prob = ODEProblem(sys, Pair[]) + prob_new = SciMLBase.remake(prob, p = Dict(sys1.a => 3.0, b => 4.0), + u0 = Dict(sys1.x => 1.0)) + @test prob_new.p isa MTKParameters + @test prob_new.ps[b] ≈ 4.0 + @test prob_new.ps[sys1.a] ≈ 3.0 + @test prob_new.ps[sys.sys2.a] ≈ 1.0 + @test prob_new.u0 == [1.0, 0.0] end -prob2 = ODEProblem(sys,u0,tspan,p,jac=true) -prob3 = ODEProblem(sys,u0,tspan,p,jac=true,sparse=true) -@test_throws ArgumentError ODEProblem(sys,zeros(5),tspan,p) + +# test kwargs +prob2 = ODEProblem(sys, u0, tspan, p, jac = true) +prob3 = ODEProblem(sys, u0, tspan, p, jac = true, sparse = true) #SparseMatrixCSC need to handle +@test prob3.f.jac_prototype isa SparseMatrixCSC +prob3 = ODEProblem(sys, u0, tspan, p, jac = true, sparsity = true) +@test prob3.f.sparsity isa SparseMatrixCSC +@test_throws ArgumentError ODEProblem(sys, zeros(5), tspan, p) for (prob, atol) in [(prob1, 1e-12), (prob2, 1e-12), (prob3, 1e-12)] local sol sol = solve(prob, Rodas5()) - @test all(x->≈(sum(x), 1.0, atol=atol), sol.u) + @test all(x -> ≈(sum(x), 1.0, atol = atol), sol.u) end -du0 = [ - D(y₁) => -0.04 +du0 = [D(y₁) => -0.04 D(y₂) => 0.04 - D(y₃) => 0.0 - ] + D(y₃) => 0.0] prob4 = DAEProblem(sys, du0, u0, tspan, p2) prob5 = eval(DAEProblemExpr(sys, du0, u0, tspan, p2)) for prob in [prob4, prob5] local sol @test prob.differential_vars == [true, true, false] sol = solve(prob, IDA()) - @test all(x->≈(sum(x), 1.0, atol=1e-12), sol.u) + @test all(x -> ≈(sum(x), 1.0, atol = 1e-12), sol.u) end -@parameters t σ β +@parameters σ β @variables x(t) y(t) z(t) -D = Differential(t) -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x-β*y, - x + z ~ y] -sys = ODESystem(eqs) -@test all(isequal.(states(sys), [x, y, z])) +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x - β * y, + x + z ~ y] +@named sys = ODESystem(eqs, t) +@test all(isequal.(unknowns(sys), [x, y, z])) @test all(isequal.(parameters(sys), [σ, β])) @test equations(sys) == eqs @test ModelingToolkit.isautonomous(sys) # issue 701 using ModelingToolkit -@parameters t a +@parameters a @variables x(t) -D = Differential(t) -sys = ODESystem([D(x) ~ a]) -@test equations(sys)[1].rhs isa Sym +@named sys = ODESystem([D(x) ~ a], t) +@test issym(equations(sys)[1].rhs) # issue 708 -@parameters t a +@parameters a @variables x(t) y(t) z(t) -D = Differential(t) -sys = ODESystem([D(x) ~ y, 0 ~ x + z, 0 ~ x - y], t, [z, y, x], []) +@named sys = ODESystem([D(x) ~ y, 0 ~ x + z, 0 ~ x - y], t, [z, y, x], []) +asys = add_accumulations(sys) +@variables accumulation_x(t) accumulation_y(t) accumulation_z(t) +eqs = [0 ~ x + z + 0 ~ x - y + D(accumulation_x) ~ x + D(accumulation_y) ~ y + D(accumulation_z) ~ z + D(x) ~ y] +@test ssort(equations(asys)) == ssort(eqs) +@variables ac(t) +asys = add_accumulations(sys, [ac => (x + y)^2]) +eqs = [0 ~ x + z + 0 ~ x - y + D(ac) ~ (x + y)^2 + D(x) ~ y] +@test ssort(equations(asys)) == ssort(eqs) + sys2 = ode_order_lowering(sys) M = ModelingToolkit.calculate_massmatrix(sys2) @test M == Diagonal([1, 0, 0]) # issue #609 -@variables t x1(t) x2(t) -D = Differential(t) +@variables x1(t) x2(t) eqs = [ - D(x1) ~ -x1, - 0 ~ x1 - x2, + D(x1) ~ -x1, + 0 ~ x1 - x2 ] -sys = ODESystem(eqs, t) +@named sys = ODESystem(eqs, t) @test isequal(ModelingToolkit.get_iv(sys), t) -@test isequal(states(sys), [x1, x2]) +@test isequal(unknowns(sys), [x1, x2]) @test isempty(parameters(sys)) # one equation ODESystem test -@parameters t r +@parameters r @variables x(t) -D = Differential(t) -eq = D(x) ~ r*x -ode = ODESystem(eq) +eq = D(x) ~ r * x +@named ode = ODESystem(eq, t) @test equations(ode) == [eq] # issue #808 @testset "Combined system name collisions" begin function makesys(name) - @parameters t a + @parameters a @variables x(t) f(t) - D = Differential(t) - ODESystem([D(x) ~ -a*x + f], name=name) + ODESystem([D(x) ~ -a * x + f], t; name) end function issue808() sys1 = makesys(:sys1) sys2 = makesys(:sys1) - @parameters t - D = Differential(t) - @test_throws ArgumentError ODESystem([sys2.f ~ sys1.x, D(sys1.f) ~ 0], t, systems=[sys1, sys2]) + @test_throws ArgumentError ODESystem([sys2.f ~ sys1.x, D(sys1.f) ~ 0], t, + systems = [sys1, sys2], name = :foo) end issue808() - end +#Issue 998 +pars = [] +vars = @variables((u1,)) +eqs = [ + D(u1) ~ 1 +] +@test_throws ArgumentError ODESystem(eqs, t, vars, pars, name = :foo) + +#Issue 1063/998 +pars = [t] +vars = @variables((u1(t),)) +@test_throws ArgumentError ODESystem(eqs, t, vars, pars, name = :foo) + +@parameters w +der = Differential(w) +eqs = [ + der(u1) ~ t +] +@test_throws ArgumentError ModelingToolkit.ODESystem(eqs, t, vars, pars, name = :foo) + @variables x(t) -D = Differential(t) @parameters M b k -eqs = [D(D(x)) ~ -b/M*D(x) - k/M*x] +eqs = [D(D(x)) ~ -b / M * D(x) - k / M * x] ps = [M, b, k] default_u0 = [D(x) => 0.0, x => 10.0] default_p = [M => 1.0, b => 1.0, k => 1.0] -@named sys = ODESystem(eqs, t, [x], ps, defaults=[default_u0; default_p]) +@named sys = ODESystem(eqs, t, [x], ps; defaults = [default_u0; default_p], tspan) sys = ode_order_lowering(sys) -prob = ODEProblem(sys, [], tspan) +sys = complete(sys) +prob = ODEProblem(sys) +sol = solve(prob, Tsit5()) +@test sol.t[end] == tspan[end] +@test sum(abs, sol.u[end]) < 1 +prob = ODEProblem{false}(sys; u0_constructor = x -> SVector(x...)) +@test prob.u0 isa SVector + +# check_eqs_u0 kwarg test +@variables x1(t) x2(t) +eqs = [D(x1) ~ -x1] +@named sys = ODESystem(eqs, t, [x1, x2], []) +sys = complete(sys) +@test_throws ArgumentError ODEProblem(sys, [1.0, 1.0], (0.0, 1.0)) +@test_nowarn ODEProblem(sys, [1.0, 1.0], (0.0, 1.0), check_length = false) + +# check inputs +let + @parameters f k d + @variables x(t) ẋ(t) + δ = D + + eqs = [δ(x) ~ ẋ, δ(ẋ) ~ f - k * x - d * ẋ] + @named sys = ODESystem(eqs, t, [x, ẋ], [f, d, k]; controls = [f]) + + calculate_control_jacobian(sys) + + @test isequal(calculate_control_jacobian(sys), + reshape(Num[0, 1], 2, 1)) +end + +# issue 1109 +let + @variables x(t)[1:3, 1:3] + @named sys = ODESystem(D.(x) .~ x, t) + @test_nowarn structural_simplify(sys) +end + +# Array vars +using Symbolics: unwrap, wrap +using LinearAlgebra +sts = @variables x(t)[1:3]=[1, 2, 3.0] y(t)=1.0 +ps = @parameters p[1:3] = [1, 2, 3] +eqs = [collect(D.(x) .~ x) + D(y) ~ norm(collect(x)) * y - x[1]] +@named sys = ODESystem(eqs, t, sts, ps) +sys = structural_simplify(sys) +@test isequal(@nonamespace(sys.x), x) +@test isequal(@nonamespace(sys.y), y) +@test isequal(@nonamespace(sys.p), p) +@test_nowarn sys.x, sys.y, sys.p +@test all(x -> x isa Symbolics.Arr, (sys.x, sys.p)) +@test all(x -> x isa Symbolics.Arr, @nonamespace (sys.x, sys.p)) +@test ModelingToolkit.isvariable(Symbolics.unwrap(x[1])) +prob = ODEProblem(sys, [], (0, 1.0)) sol = solve(prob, Tsit5()) -@test sum(abs, sol[end]) < 1 +@test sol[2x[1] + 3x[3] + norm(x)] ≈ + 2sol[x[1]] + 3sol[x[3]] + sol[norm(x)] +@test sol[x .+ [y, 2y, 3y]] ≈ map((x...) -> [x...], + map((x, y) -> x[1] .+ y, sol[x], sol[y]), + map((x, y) -> x[2] .+ 2y, sol[x], sol[y]), + map((x, y) -> x[3] .+ 3y, sol[x], sol[y])) + +using ModelingToolkit + +function submodel(; name) + @variables y(t) + @parameters A[1:5] + A = collect(A) + ODESystem(D(y) ~ sum(A) * y, t; name = name) +end + +# Build system +@named sys1 = submodel() +@named sys2 = submodel() + +@named sys = ODESystem([0 ~ sys1.y + sys2.y], t; systems = [sys1, sys2]) + +# DelayDiffEq +using ModelingToolkit: hist +@variables x(t) y(t) +xₜ₋₁ = hist(x, t - 1) +eqs = [D(x) ~ x * y + D(y) ~ y * x - xₜ₋₁] +@named sys = ODESystem(eqs, t) + +# register +using StaticArrays +using SymbolicUtils: term +using SymbolicUtils.Code +using Symbolics: unwrap, wrap, @register_symbolic +foo(a, ms::AbstractVector) = a + sum(ms) +@register_symbolic foo(a, ms::AbstractVector) +@variables x(t) ms(t)[1:3] +eqs = [D(x) ~ foo(x, ms); D(ms) ~ ones(3)] +@named sys = ODESystem(eqs, t, [x; ms], []) +@named emptysys = ODESystem(Equation[], t) +@mtkbuild outersys = compose(emptysys, sys) +prob = ODEProblem( + outersys, [outersys.sys.x => 1.0; collect(outersys.sys.ms .=> 1:3)], (0, 1.0)) +@test_nowarn solve(prob, Tsit5()) + +# array equations +bar(x, p) = p * x +@register_array_symbolic bar(x::AbstractVector, p::AbstractMatrix) begin + size = size(x) + eltype = promote_type(eltype(x), eltype(p)) +end +@parameters p[1:3, 1:3] +eqs = [D(x) ~ foo(x, ms); D(ms) ~ bar(ms, p)] +@named sys = ODESystem(eqs, t) +@named emptysys = ODESystem(Equation[], t) +@mtkbuild outersys = compose(emptysys, sys) +prob = ODEProblem( + outersys, [sys.x => 1.0, sys.ms => 1:3], (0.0, 1.0), [sys.p => ones(3, 3)]) +@test_nowarn solve(prob, Tsit5()) +obsfn = ModelingToolkit.build_explicit_observed_function( + outersys, bar(3outersys.sys.ms, 3outersys.sys.p)) +@test_nowarn obsfn(sol.u[1], prob.p, sol.t[1]) + +# x/x +@variables x(t) +@named sys = ODESystem([D(x) ~ x / x], t) +@test equations(alias_elimination(sys)) == [D(x) ~ 1] + +# observed variable handling +@variables x(t) RHS(t) +@parameters τ +@named fol = ODESystem([D(x) ~ (1 - x) / τ], t; observed = [RHS ~ (1 - x) / τ]) +@test isequal(RHS, @nonamespace fol.RHS) +RHS2 = RHS +@unpack RHS = fol +@test isequal(RHS, RHS2) + +#1413 and 1389 +@parameters α β +@variables x(t) y(t) z(t) + +eqs = [ + D(x) ~ 0.1x + 0.9y, + D(y) ~ 0.5x + 0.5y, + z ~ α * x - β * y +] + +@named sys = ODESystem(eqs, t, [x, y, z], [α, β]) +sys = complete(sys) +@test_throws Any ODEFunction(sys) + +eqs = copy(eqs) +eqs[end] = D(D(z)) ~ α * x - β * y +@named sys = ODESystem(eqs, t, [x, y, z], [α, β]) +sys = complete(sys) +@test_throws Any ODEFunction(sys) + +@testset "Preface tests" begin + using OrdinaryDiffEq + using Symbolics + using DiffEqBase: isinplace + using ModelingToolkit + using SymbolicUtils.Code + using SymbolicUtils: Sym + + c = [0] + function f(c, du::AbstractVector{Float64}, u::AbstractVector{Float64}, p, t::Float64) + c .= [c[1] + 1] + du .= randn(length(u)) + nothing + end + + dummy_identity(x, _) = x + @register_symbolic dummy_identity(x, y) + + u0 = ones(5) + p0 = Float64[] + syms = [Symbol(:a, i) for i in 1:5] + syms_p = Symbol[] + + @assert isinplace(f, 5) + wf = let buffer = similar(u0), u = similar(u0), p = similar(p0), c = c + t -> (f(c, buffer, u, p, t); buffer) + end + + num = hash(f) ⊻ length(u0) ⊻ length(p0) + buffername = Symbol(:fmi_buffer_, num) + + D = Differential(t) + us = map(s -> (@variables $s(t))[1], syms) + ps = map(s -> (@variables $s(t))[1], syms_p) + buffer, = @variables $buffername[1:length(u0)] + dummy_var = Sym{Any}(:_) # this is safe because _ cannot be a rvalue in Julia + + ss = Iterators.flatten((us, ps)) + vv = Iterators.flatten((u0, p0)) + defs = Dict{Any, Any}(s => v for (s, v) in zip(ss, vv)) + + preface = [Assignment(dummy_var, SetArray(true, term(getfield, wf, Meta.quot(:u)), us)) + Assignment(dummy_var, SetArray(true, term(getfield, wf, Meta.quot(:p)), ps)) + Assignment(buffer, term(wf, t))] + eqs = map(1:length(us)) do i + D(us[i]) ~ dummy_identity(buffer[i], us[i]) + end + + @named sys = ODESystem(eqs, t, us, ps; defaults = defs, preface = preface) + sys = complete(sys) + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob, Euler(); dt = 0.1) + + @test c[1] == length(sol) +end + +let + x = map(xx -> xx(t), Symbolics.variables(:x, 1:2, T = SymbolicUtils.FnType)) + @variables y(t) = 0 + @parameters k = 1 + eqs = [D(x[1]) ~ x[2] + D(x[2]) ~ -x[1] - 0.5 * x[2] + k + y ~ 0.9 * x[1] + x[2]] + @named sys = ODESystem(eqs, t, vcat(x, [y]), [k], defaults = Dict(x .=> 0)) + sys = structural_simplify(sys) + + u0 = [0.5, 0] + du0 = 0 .* copy(u0) + prob = DAEProblem(sys, du0, u0, (0, 50)) + @test prob.u0 ≈ u0 + @test prob.du0 ≈ du0 + @test prob.p isa MTKParameters + @test prob.ps[k] ≈ 1 + sol = solve(prob, IDA()) + @test sol[y] ≈ 0.9 * sol[x[1]] + sol[x[2]] + @test isapprox(sol[x[1]][end], 1, atol = 1e-3) + + prob = DAEProblem(sys, [D(y) => 0, D(x[1]) => 0, D(x[2]) => 0], Pair[x[1] => 0.5], + (0, 50)) + @test prob.u0 ≈ [0.5, 0] + @test prob.du0 ≈ [0, 0] + @test prob.p isa MTKParameters + @test prob.ps[k] ≈ 1 + sol = solve(prob, IDA()) + @test isapprox(sol[x[1]][end], 1, atol = 1e-3) + + prob = DAEProblem(sys, [D(y) => 0, D(x[1]) => 0, D(x[2]) => 0], Pair[x[1] => 0.5], + (0, 50), [k => 2]) + @test prob.u0 ≈ [0.5, 0] + @test prob.du0 ≈ [0, 0] + @test prob.p isa MTKParameters + @test prob.ps[k] ≈ 2 + sol = solve(prob, IDA()) + @test isapprox(sol[x[1]][end], 2, atol = 1e-3) + + # no initial conditions for D(x[1]) and D(x[2]) provided + @test_throws ModelingToolkit.MissingVariablesError prob=DAEProblem( + sys, Pair[], Pair[], (0, 50)) + + prob = ODEProblem(sys, Pair[x[1] => 0], (0, 50)) + sol = solve(prob, Rosenbrock23()) + @test isapprox(sol[x[1]][end], 1, atol = 1e-3) +end + +#issue 1475 (mixed numeric type for parameters) +let + @parameters k1 k2::Int + @variables A(t) + eqs = [D(A) ~ -k1 * k2 * A] + @named sys = ODESystem(eqs, t) + sys = complete(sys) + u0map = [A => 1.0] + pmap = (k1 => 1.0, k2 => 1) + tspan = (0.0, 1.0) + prob = ODEProblem(sys, u0map, tspan, pmap; tofloat = false) + @test prob.p isa MTKParameters + @test prob.ps[k1] ≈ 1.0 + @test prob.ps[k2] == 1 && prob.ps[k2] isa Int + + prob = ODEProblem(sys, u0map, tspan, pmap) + @test vcat(prob.p...) isa Vector{Float64} + + pmap = [k1 => 1, k2 => 1] + tspan = (0.0, 1.0) + prob = ODEProblem(sys, u0map, tspan, pmap) + @test eltype(vcat(prob.p...)) === Float64 + + prob = ODEProblem(sys, u0map, tspan, pmap) + @test vcat(prob.p...) isa Vector{Float64} + + # No longer supported, Tuple used instead + # pmap = Pair{Any, Union{Int, Float64}}[k1 => 1, k2 => 1.0] + # tspan = (0.0, 1.0) + # prob = ODEProblem(sys, u0map, tspan, pmap) + # @test eltype(prob.p) === Union{Float64, Int} +end + +let + @parameters C L R + @variables q(t) p(t) F(t) + + eqs = [D(q) ~ -p / L - F + D(p) ~ q / C + 0 ~ q / C - R * F] + + @named sys = ODESystem(eqs, t) + @test length(equations(structural_simplify(sys))) == 2 +end + +let + eq_to_lhs(eq) = eq.lhs - eq.rhs ~ 0 + eqs_to_lhs(eqs) = eq_to_lhs.(eqs) + + @parameters σ=10 ρ=28 β=8 / 3 sigma rho beta + @variables x(t)=1 y(t)=0 z(t)=0 x2(t)=1 y2(t)=0 z2(t)=0 u(t)[1:3] + + eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + eqs2 = [ + D(y2) ~ x2 * (rho - z2) - y2, + D(x2) ~ sigma * (y2 - x2), + D(z2) ~ x2 * y2 - beta * z2 + ] + + # array u + eqs3 = [D(u[1]) ~ sigma * (u[2] - u[1]), + D(u[2]) ~ u[1] * (rho - u[3]) - u[2], + D(u[3]) ~ u[1] * u[2] - beta * u[3]] + eqs3 = eqs_to_lhs(eqs3) + + eqs4 = [ + D(y2) ~ x2 * (rho - z2) - y2, + D(x2) ~ sigma * (y2 - x2), + D(z2) ~ y2 - beta * z2 # missing x2 term + ] + + @named sys1 = ODESystem(eqs, t) + @named sys2 = ODESystem(eqs2, t) + @named sys3 = ODESystem(eqs3, t) + ssys3 = structural_simplify(sys3) + @named sys4 = ODESystem(eqs4, t) + + @test ModelingToolkit.isisomorphic(sys1, sys2) + @test !ModelingToolkit.isisomorphic(sys1, sys3) + @test ModelingToolkit.isisomorphic(sys1, ssys3) # I don't call structural_simplify in isisomorphic + @test !ModelingToolkit.isisomorphic(sys1, sys4) + + # 1281 + iv2 = only(independent_variables(sys2)) + @test isequal(only(independent_variables(convert_system(ODESystem, sys1, iv2))), iv2) +end + +let + vars = @variables sP(t) spP(t) spm(t) sph(t) + pars = @parameters a b + eqs = [sP ~ 1 + spP ~ sP + spm ~ a + sph ~ b + spm ~ 0 + sph ~ a] + @named sys = ODESystem(eqs, t, vars, pars) + @test_throws ModelingToolkit.ExtraEquationsSystemException structural_simplify(sys) +end + +# 1561 +let + vars = @variables x y + arr = ModelingToolkit.varmap_to_vars([x => 0.0, y => [0.0, 1.0]], vars) #error + sol = Union{Float64, Vector{Float64}}[0.0, [0.0, 1.0]] + @test arr == sol + @test typeof(arr) == typeof(sol) +end + +let + u = collect(first(@variables u(t)[1:4])) + Dt = D + + eqs = [Differential(t)(u[2]) - 1.1u[1] ~ 0 + Differential(t)(u[3]) - 1.1u[2] ~ 0 + u[1] ~ 0.0 + u[4] ~ 0.0] + + ps = [] + + @named sys = ODESystem(eqs, t, u, ps) + @test_nowarn simpsys = structural_simplify(sys) + + sys = structural_simplify(sys) + + u0 = ModelingToolkit.missing_variable_defaults(sys) + u0_expected = Pair[s => 0.0 for s in unknowns(sys)] + @test string(u0) == string(u0_expected) + + u0 = ModelingToolkit.missing_variable_defaults(sys, [1, 2]) + u0_expected = Pair[s => i for (i, s) in enumerate(unknowns(sys))] + @test string(u0) == string(u0_expected) + + @test_nowarn ODEProblem(sys, u0, (0, 1)) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/1583 +let + @parameters k + @variables A(t) + eqs = [D(A) ~ -k * A] + @named osys = ODESystem(eqs, t) + osys = complete(osys) + oprob = ODEProblem(osys, [A => 1.0], (0.0, 10.0), [k => 1.0]; check_length = false) + @test_nowarn sol = solve(oprob, Tsit5()) +end + +let + function sys1(; name) + vars = @variables x(t)=0.0 dx(t)=0.0 + + ODESystem([D(x) ~ dx], t, vars, []; name, defaults = [D(x) => x]) + end + + function sys2(; name) + @named s1 = sys1() + + ODESystem(Equation[], t, [], []; systems = [s1], name) + end + + s1′ = sys1(; name = :s1) + @named s2 = sys2() + @unpack s1 = s2 + @test isequal(s1, s1′) + + defs = Dict(s1.dx => 0.0, D(s1.x) => s1.x, s1.x => 0.0) + @test isequal(ModelingToolkit.defaults(s2), defs) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/1705 +let + x0 = 0.0 + v0 = 1.0 + + kx = -1.0 + kv = -1.0 + + tf = 10.0 + + ## controller + + function pd_ctrl(; name) + @parameters kx kv + @variables u(t) x(t) v(t) + + eqs = [u ~ kx * x + kv * v] + ODESystem(eqs, t; name) + end + + @named ctrl = pd_ctrl() + + ## double integrator + + function double_int(; name) + @variables u(t) x(t) v(t) + + eqs = [D(x) ~ v, D(v) ~ u] + ODESystem(eqs, t; name) + end + + @named sys = double_int() + + ## connections + + connections = [sys.u ~ ctrl.u, ctrl.x ~ sys.x, ctrl.v ~ sys.v] + + @named connected = ODESystem(connections, t) + @named sys_con = compose(connected, sys, ctrl) + + sys_simp = structural_simplify(sys_con) + true_eqs = [D(sys.x) ~ sys.v + D(sys.v) ~ ctrl.kv * sys.v + ctrl.kx * sys.x] + @test isequal(full_equations(sys_simp), true_eqs) +end + +let + @variables x(t) = 1 + @variables y(t) = 1 + @parameters pp = -1 + @named sys4 = ODESystem([D(x) ~ -y; D(y) ~ 1 + pp * y + x], t) + sys4s = structural_simplify(sys4) + prob = ODEProblem(sys4s, [x => 1.0, D(x) => 1.0], (0, 1.0)) + @test string.(unknowns(prob.f.sys)) == ["x(t)", "y(t)"] + @test string.(parameters(prob.f.sys)) == ["pp"] + @test string.(independent_variables(prob.f.sys)) == ["t"] +end + +let + @parameters P(t) Q(t) + ∂t = D + eqs = [∂t(Q) ~ 0.2P + ∂t(P) ~ -80.0sin(Q)] + @test_throws ArgumentError @named sys = ODESystem(eqs, t) +end + +@parameters C L R +@variables q(t) p(t) F(t) + +eqs = [D(q) ~ -p / L - F + D(p) ~ q / C + 0 ~ q / C - R * F] +testdict = Dict([:name => "test"]) +@named sys = ODESystem(eqs, t, metadata = testdict) +@test get_metadata(sys) == testdict + +@variables P(t)=NaN Q(t)=NaN +eqs = [D(Q) ~ 1 / sin(P), D(P) ~ log(-cos(Q))] +@named sys = ODESystem(eqs, t, [P, Q], []) +sys = complete(debug_system(sys)) +prob = ODEProblem(sys, [], (0.0, 1.0)) +@test_throws "log(-cos(Q(t))) errors" prob.f([1, 0], prob.p, 0.0) +@test_throws "/(1, sin(P(t))) output non-finite value" prob.f([0, 2], prob.p, 0.0) + +let + @variables x(t) = 1 + @variables y(t) = 1 + @parameters pp = -1 + der = Differential(t) + @named sys4 = ODESystem([der(x) ~ -y; der(y) ~ 1 + pp * y + x], t) + sys4s = structural_simplify(sys4) + prob = ODEProblem(sys4s, [x => 1.0, D(x) => 1.0], (0, 1.0)) + @test !isnothing(prob.f.sys) +end + +# SYS 1: +vars_sub1 = @variables s1(t) +@named sub = ODESystem(Equation[], t, vars_sub1, []) + +vars1 = @variables x1(t) +@named sys1 = ODESystem(Equation[], t, vars1, [], systems = [sub]) +@named sys2 = ODESystem(Equation[], t, vars1, [], systems = [sys1, sub]) + +# SYS 2: Extension to SYS 1 +vars_sub2 = @variables s2(t) +@named partial_sub = ODESystem(Equation[], t, vars_sub2, []) +@named sub = extend(partial_sub, sub) + +# no warnings for systems without events +new_sys2 = @test_nowarn complete(substitute(sys2, Dict(:sub => sub))) +Set(unknowns(new_sys2)) == Set([new_sys2.x1, new_sys2.sys1.x1, + new_sys2.sys1.sub.s1, new_sys2.sys1.sub.s2, + new_sys2.sub.s1, new_sys2.sub.s2]) + +let # Issue https://github.com/SciML/ModelingToolkit.jl/issues/2322 + @parameters a=10 b=a / 10 c=a / 20 + + Dt = D + + @variables x(t)=1 z(t) + + eqs = [Dt(x) ~ -b * (x - z), + 0 ~ z - c * x] + + sys = ODESystem(eqs, t; name = :kjshdf) + + sys_simp = structural_simplify(sys) + + @test a ∈ keys(ModelingToolkit.defaults(sys_simp)) + + tspan = (0.0, 1) + prob = ODEProblem(sys_simp, [], tspan) + sol = solve(prob, Rodas4()) + @test sol(1)[]≈0.6065307685451087 rtol=1e-4 +end + +# Issue#2599 +@variables x(t) y(t) +eqs = [D(x) ~ x * t, y ~ 2x] +@mtkbuild sys = ODESystem(eqs, t; continuous_events = [[y ~ 3] => [x ~ 2]]) +prob = ODEProblem(sys, [x => 1.0], (0.0, 10.0)) +@test_nowarn solve(prob, Tsit5()) + +# Issue#2383 +@variables x(t)[1:3] +@parameters p[1:3, 1:3] +eqs = [ + D(x) ~ p * x +] +@mtkbuild sys = ODESystem(eqs, t; continuous_events = [[norm(x) ~ 3.0] => [x ~ ones(3)]]) +# array affect equations used to not work +prob1 = @test_nowarn ODEProblem(sys, [x => ones(3)], (0.0, 10.0), [p => ones(3, 3)]) +sol1 = @test_nowarn solve(prob1, Tsit5()) + +# array condition equations also used to not work +@mtkbuild sys = ODESystem( + eqs, t; continuous_events = [[x ~ sqrt(3) * ones(3)] => [x ~ ones(3)]]) +# array affect equations used to not work +prob2 = @test_nowarn ODEProblem(sys, [x => ones(3)], (0.0, 10.0), [p => ones(3, 3)]) +sol2 = @test_nowarn solve(prob2, Tsit5()) + +@test sol1 ≈ sol2 + +# Requires fix in symbolics for `linear_expansion(p * x, D(y))` +@test_skip begin + @variables x(t)[1:3] y(t) + @parameters p[1:3, 1:3] + @test_nowarn @mtkbuild sys = ODESystem([D(x) ~ p * x, D(y) ~ x' * p * x], t) + @test_nowarn ODEProblem(sys, [x => ones(3), y => 2], (0.0, 10.0), [p => ones(3, 3)]) +end + +@parameters g L +@variables q₁(t) q₂(t) λ(t) θ(t) + +eqs = [D(D(q₁)) ~ -λ * q₁, + D(D(q₂)) ~ -λ * q₂ - g, + q₁ ~ L * sin(θ), + q₂ ~ L * cos(θ)] + +@named pend = ODESystem(eqs, t) +@test_nowarn generate_initializesystem( + pend, u0map = [q₁ => 1.0, q₂ => 0.0], guesses = [λ => 1]) + +# https://github.com/SciML/ModelingToolkit.jl/issues/2618 +@parameters σ ρ β +@variables x(t) y(t) z(t) + +eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@mtkbuild sys = ODESystem(eqs, t) + +u0 = [D(x) => 2.0, + x => 1.0, + y => 0.0, + z => 0.0] + +p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +prob = SteadyStateProblem(sys, u0, p) +@test prob isa SteadyStateProblem +prob = SteadyStateProblem(ODEProblem(sys, u0, (0.0, 10.0), p)) +@test prob isa SteadyStateProblem + +# Issue#2344 +using ModelingToolkitStandardLibrary.Blocks + +function FML2(; name) + @parameters begin + k2[1:1] = [1.0] + end + systems = @named begin + constant = Constant(k = k2[1]) + end + @variables begin + x(t) = 0 + end + eqs = [ + D(x) ~ constant.output.u + k2[1] + ] + ODESystem(eqs, t; systems, name) +end + +@mtkbuild model = FML2() + +@test isequal(ModelingToolkit.defaults(model)[model.constant.k], model.k2[1]) +@test_nowarn ODEProblem(model, [], (0.0, 10.0)) + +# Issue#2477 +function RealExpression(; name, y) + vars = @variables begin + u(t) + end + eqns = [ + u ~ y + ] + sys = ODESystem(eqns, t, vars, []; name) +end + +function RealExpressionSystem(; name) + vars = @variables begin + x(t) + z(t)[1:1] + end # doing a collect on z doesn't work either. + @named e1 = RealExpression(y = x) # This works perfectly. + @named e2 = RealExpression(y = z[1]) # This bugs. However, `full_equations(e2)` works as expected. + systems = [e1, e2] + ODESystem(Equation[], t, Iterators.flatten(vars), []; systems, name) +end + +@named sys = RealExpressionSystem() +sys = complete(sys) +@test Set(equations(sys)) == Set([sys.e1.u ~ sys.x, sys.e2.u ~ sys.z[1]]) +tearing_state = TearingState(expand_connections(sys)) +ts_vars = tearing_state.fullvars +orig_vars = unknowns(sys) +@test isempty(setdiff(ts_vars, orig_vars)) + +# Guesses in hierarchical systems +@variables x(t) y(t) +@named sys = ODESystem(Equation[], t, [x], []; guesses = [x => 1.0]) +@named outer = ODESystem( + [D(y) ~ sys.x + t, 0 ~ t + y - sys.x * y], t, [y], []; systems = [sys]) +@test ModelingToolkit.guesses(outer)[sys.x] == 1.0 +outer = structural_simplify(outer) +@test ModelingToolkit.get_guesses(outer)[sys.x] == 1.0 +prob = ODEProblem(outer, [outer.y => 2.0], (0.0, 10.0)) +int = init(prob, Rodas4()) +@test int[outer.sys.x] == 1.0 + +# Ensure indexes of array symbolics are cached appropriately +@variables x(t)[1:2] +@named sys = ODESystem(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = ODESystem(Equation[], t, [x...], []) +sys2 = complete(sys) +for sys in [sys1, sys2] + for (sym, idx) in [(x, 1:2), (x[1], 1), (x[2], 2)] + @test is_variable(sys, sym) + @test variable_index(sys, sym) == idx + end +end + +@variables x(t)[1:2, 1:2] +@named sys = ODESystem(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = ODESystem(Equation[], t, [x...], []) +sys2 = complete(sys) +for sys in [sys1, sys2] + @test is_variable(sys, x) + @test variable_index(sys, x) == [1 3; 2 4] + for i in eachindex(x) + @test is_variable(sys, x[i]) + @test variable_index(sys, x[i]) == variable_index(sys, x)[i] + end +end + +@testset "Non-1-indexed variable array (issue #2670)" begin + @variables x(t)[0:1] # 0-indexed variable array + @named sys = ODESystem([x[0] ~ 0.0, D(x[1]) ~ x[0]], t, [x], []) + @test_nowarn sys = structural_simplify(sys) + @test equations(sys) == [D(x[1]) ~ 0.0] +end + +# Namespacing of array variables +@variables x(t)[1:2] +@named sys = ODESystem(Equation[], t) +@test getname(unknowns(sys, x)) == :sys₊x +@test size(unknowns(sys, x)) == size(x) + +# Issue#2667 and Issue#2953 +@testset "ForwardDiff through ODEProblem constructor" begin + @parameters P + @variables x(t) + sys = structural_simplify(ODESystem([D(x) ~ P], t, [x], [P]; name = :sys)) + + function x_at_1(P) + prob = ODEProblem(sys, [x => P], (0.0, 1.0), [sys.P => P]) + return solve(prob, Tsit5())(1.0) + end + + @test_nowarn ForwardDiff.derivative(P -> x_at_1(P), 1.0) +end + +@testset "Inplace observed functions" begin + @parameters P + @variables x(t) + sys = structural_simplify(ODESystem([D(x) ~ P], t, [x], [P]; name = :sys)) + obsfn = ModelingToolkit.build_explicit_observed_function( + sys, [x + 1, x + P, x + t], return_inplace = true)[2] + ps = ModelingToolkit.MTKParameters(sys, [P => 2.0]) + buffer = zeros(3) + @test_nowarn obsfn(buffer, [1.0], ps, 3.0) + @test buffer ≈ [2.0, 3.0, 4.0] +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2818 +@testset "Custom independent variable" begin + @independent_variables x + @variables y(x) + @test_nowarn @named sys = ODESystem([y ~ 0], x) + + # the same, but with @mtkmodel + @independent_variables x + @mtkmodel MyModel begin + @variables begin + y(x) + end + @equations begin + y ~ 0 + end + end + @test_nowarn @mtkbuild sys = MyModel() + + @variables x y(x) + @test_logs (:warn,) @named sys = ODESystem([y ~ 0], x) + + @parameters T + D = Differential(T) + @variables x(T) + eqs = [D(x) ~ 0.0] + initialization_eqs = [x ~ T] + guesses = [x => 0.0] + @named sys2 = ODESystem(eqs, T; initialization_eqs, guesses) + prob2 = ODEProblem(structural_simplify(sys2), [], (1.0, 2.0), []) + sol2 = solve(prob2) + @test all(sol2[x] .== 1.0) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2502 +@testset "Extend systems with a field that can be nothing" begin + A = Dict(:a => 1) + B = Dict(:b => 2) + @named A1 = ODESystem(Equation[], t, [], []) + @named B1 = ODESystem(Equation[], t, [], []) + @named A2 = ODESystem(Equation[], t, [], []; metadata = A) + @named B2 = ODESystem(Equation[], t, [], []; metadata = B) + @test ModelingToolkit.get_metadata(extend(A1, B1)) == nothing + @test ModelingToolkit.get_metadata(extend(A1, B2)) == B + @test ModelingToolkit.get_metadata(extend(A2, B1)) == A + @test Set(ModelingToolkit.get_metadata(extend(A2, B2))) == Set(A ∪ B) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2859 +@testset "Initialization with defaults from observed equations (edge case)" begin + @variables x(t) y(t) z(t) + eqs = [D(x) ~ 0, y ~ x, D(z) ~ 0] + defaults = [x => 1, z => y] + @named sys = ODESystem(eqs, t; defaults) + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [], (0.0, 1.0), []) + @test prob[x] == prob[y] == prob[z] == 1.0 + + @parameters y0 + @variables x(t) y(t) z(t) + eqs = [D(x) ~ 0, y ~ y0 / x, D(z) ~ y] + defaults = [y0 => 1, x => 1, z => y] + @named sys = ODESystem(eqs, t; defaults) + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [], (0.0, 1.0), []) + @test prob[x] == prob[y] == prob[z] == 1.0 +end + +@testset "Scalarized parameters in array functions" begin + @variables u(t)[1:2] x(t)[1:2] o(t)[1:2] + @parameters p[1:2, 1:2] [tunable = false] + @named sys = ODESystem( + [D(u) ~ (sum(u) + sum(x) + sum(p) + sum(o)) * x, o ~ prod(u) * x], + t, [u..., x..., o...], [p...]) + sys1, = structural_simplify(sys, ([x...], [])) + fn1, = ModelingToolkit.generate_function(sys1; expression = Val{false}) + ps = MTKParameters(sys1, [x => 2ones(2), p => 3ones(2, 2)]) + @test_nowarn fn1(ones(4), ps, 4.0) + sys2, = structural_simplify(sys, ([x...], []); split = false) + fn2, = ModelingToolkit.generate_function(sys2; expression = Val{false}) + ps = zeros(8) + setp(sys2, x)(ps, 2ones(2)) + setp(sys2, p)(ps, 2ones(2, 2)) + @test_nowarn fn2(ones(4), 2ones(14), 4.0) +end + +# https://github.com/SciML/ModelingToolkit.jl/issues/2969 +@testset "Constant substitution" begin + make_model = function (c_a, c_b; name = nothing) + @mtkmodel ModelA begin + @constants begin + a = c_a + end + @variables begin + x(t) + end + @equations begin + D(x) ~ -a * x + end + end + + @mtkmodel ModelB begin + @constants begin + b = c_b + end + @variables begin + y(t) + end + @components begin + modela = ModelA() + end + @equations begin + D(y) ~ -b * y + end + end + return ModelB(; name = name) + end + c_a, c_b = 1.234, 5.578 + @named sys = make_model(c_a, c_b) + sys = complete(sys) + + u0 = [sys.y => -1.0, sys.modela.x => -1.0] + p = defaults(sys) + prob = ODEProblem(sys, u0, (0.0, 1.0), p) + + # evaluate + u0_v, p_v, _ = ModelingToolkit.get_u0_p(sys, u0, p) + @test prob.f(u0_v, p_v, 0.0) == [c_b, c_a] +end + +@testset "Independent variable as system property" begin + @variables x(t) + @named sys = ODESystem([x ~ t], t) + @named sys = compose(sys, sys) # nest into a hierarchical system + @test t === sys.t === sys.sys.t +end + +@testset "Substituting preserves parameter dependencies, defaults, guesses" begin + @parameters p1 p2 + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ y + p2], t; parameter_dependencies = [p2 ~ 2p1], + defaults = [p1 => 1.0, p2 => 2.0], guesses = [p1 => 2.0, p2 => 3.0]) + @parameters p3 + sys2 = substitute(sys, [p1 => p3]) + @test length(parameters(sys2)) == 1 + @test is_parameter(sys2, p3) + @test !is_parameter(sys2, p1) + @test length(ModelingToolkit.defaults(sys2)) == 2 + @test ModelingToolkit.defaults(sys2)[p3] == 1.0 + @test length(ModelingToolkit.guesses(sys2)) == 2 + @test ModelingToolkit.guesses(sys2)[p3] == 2.0 +end + +@testset "Substituting with nested systems" begin + @parameters p1 p2 + @variables x(t) y(t) + @named innersys = ODESystem([D(x) ~ y + p2], t; parameter_dependencies = [p2 ~ 2p1], + defaults = [p1 => 1.0, p2 => 2.0], guesses = [p1 => 2.0, p2 => 3.0]) + @parameters p3 p4 + @named outersys = ODESystem( + [D(innersys.y) ~ innersys.y + p4], t; parameter_dependencies = [p4 ~ 3p3], + defaults = [p3 => 3.0, p4 => 9.0], guesses = [p4 => 10.0], systems = [innersys]) + @test_nowarn structural_simplify(outersys) + @parameters p5 + sys2 = substitute(outersys, [p4 => p5]) + @test_nowarn structural_simplify(sys2) + @test length(equations(sys2)) == 2 + @test length(parameters(sys2)) == 2 + @test length(full_parameters(sys2)) == 4 + @test all(!isequal(p4), full_parameters(sys2)) + @test any(isequal(p5), full_parameters(sys2)) + @test length(ModelingToolkit.defaults(sys2)) == 4 + @test ModelingToolkit.defaults(sys2)[p5] == 9.0 + @test length(ModelingToolkit.guesses(sys2)) == 3 + @test ModelingToolkit.guesses(sys2)[p5] == 10.0 +end + +@testset "Observed with inputs" begin + @variables u(t)[1:2] x(t)[1:2] o(t)[1:2] + @parameters p[1:4] + + eqs = [D(u[1]) ~ p[1] * u[1] - p[2] * u[1] * u[2] + x[1] + 0.1 + D(u[2]) ~ p[4] * u[1] * u[2] - p[3] * u[2] - x[2] + o[1] ~ sum(p) * sum(u) + o[2] ~ sum(p) * sum(x)] + + @named sys = ODESystem(eqs, t, [u..., x..., o], [p...]) + sys1, = structural_simplify(sys, ([x...], [o...]), split = false) + + @test_nowarn ModelingToolkit.build_explicit_observed_function(sys1, u; inputs = [x...]) + + obsfn = ModelingToolkit.build_explicit_observed_function( + sys1, u + x + p[1:2]; inputs = [x...]) + + @test obsfn(ones(2), 2ones(2), 3ones(12), 4.0) == 6ones(2) +end + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + @mtkbuild sys = ODESystem(D(x) ~ t, t) + prob = @test_nowarn ODEProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob) +end + +@testset "ODEs are not DDEs" begin + @variables x(t) + @named sys = ODESystem(D(x) ~ x, t) + @test !ModelingToolkit.is_dde(sys) + @test is_markovian(sys) + @named sys2 = ODESystem(Equation[], t; systems = [sys]) + @test !ModelingToolkit.is_dde(sys) + @test is_markovian(sys) +end + +@testset "Issue #2597" begin + @variables x(t)[1:2]=ones(2) y(t)=1.0 + + for eqs in [D(x) ~ x, collect(D(x) .~ x)] + for dvs in [[x], collect(x)] + @named sys = ODESystem(eqs, t, dvs, []) + sys = complete(sys) + if eqs isa Vector && length(eqs) == 2 && length(dvs) == 2 + @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + else + @test_throws [ + r"array (equations|unknowns)", "structural_simplify", "scalarize"] ODEProblem( + sys, [], (0.0, 1.0)) + end + end + end + for eqs in [[D(x) ~ x, D(y) ~ y], [collect(D(x) .~ x); D(y) ~ y]] + for dvs in [[x, y], [x..., y]] + @named sys = ODESystem(eqs, t, dvs, []) + sys = complete(sys) + if eqs isa Vector && length(eqs) == 3 && length(dvs) == 3 + @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + else + @test_throws [ + r"array (equations|unknowns)", "structural_simplify", "scalarize"] ODEProblem( + sys, [], (0.0, 1.0)) + end + end + end +end + +@testset "Parameter dependencies with constant RHS" begin + @parameters p + @test_nowarn ODESystem(Equation[], t; parameter_dependencies = [p ~ 1.0], name = :a) +end + +@testset "Variable discovery in arrays of `Num` inside callable symbolic" begin + @variables x(t) y(t) + @parameters foo(::AbstractVector) + sys = @test_nowarn ODESystem(D(x) ~ foo([x, 2y]), t; name = :sys) + @test length(unknowns(sys)) == 2 + @test any(isequal(y), unknowns(sys)) +end + +@testset "Inplace observed" begin + @variables x(t) + @parameters p[1:2] q + @mtkbuild sys = ODESystem(D(x) ~ sum(p) * x + q * t, t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => ones(2), q => 2]) + obsfn = ModelingToolkit.build_explicit_observed_function( + sys, [p..., q], return_inplace = true)[2] + buf = zeros(3) + obsfn(buf, prob.u0, prob.p, 0.0) + @test buf ≈ [1.0, 1.0, 2.0] +end + +@testset "`complete` expands connections" begin + using ModelingToolkitStandardLibrary.Electrical + @mtkmodel RC begin + @parameters begin + R = 1.0 + C = 1.0 + V = 1.0 + end + @components begin + resistor = Resistor(R = R) + capacitor = Capacitor(C = C, v = 0.0) + source = Voltage() + constant = Constant(k = V) + ground = Ground() + end + @equations begin + connect(constant.output, source.V) + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + end + end + @named sys = RC() + total_eqs = length(equations(expand_connections(sys))) + sys2 = complete(sys) + @test length(equations(sys2)) == total_eqs +end + +@testset "`complete` with `split = false` removes the index cache" begin + @variables x(t) + @parameters p + @mtkbuild sys = ODESystem(D(x) ~ p * t, t) + @test ModelingToolkit.get_index_cache(sys) !== nothing + sys2 = complete(sys; split = false) + @test ModelingToolkit.get_index_cache(sys2) === nothing +end + +# https://github.com/SciML/SciMLBase.jl/issues/786 +@testset "Observed variables dependent on discrete parameters" begin + @variables x(t) obs(t) + @parameters c(t) + @mtkbuild sys = ODESystem( + [D(x) ~ c * cos(x), obs ~ c], t, [x], [c]; discrete_events = [1.0 => [c ~ c + 1]]) + prob = ODEProblem(sys, [x => 0.0], (0.0, 2pi), [c => 1.0]) + sol = solve(prob, Tsit5()) + @test sol[obs] ≈ 1:7 +end + +@testset "DAEProblem with array parameters" begin + @variables x(t)=1.0 y(t) [guess = 1.0] + @parameters p[1:2] = [1.0, 2.0] + @mtkbuild sys = ODESystem([D(x) ~ x, y^2 ~ x + sum(p)], t) + prob = DAEProblem(sys, [D(x) => x, D(y) => D(x) / 2y], [], (0.0, 1.0)) + sol = solve(prob, DFBDF(), abstol = 1e-8, reltol = 1e-8) + @test sol[x]≈sol[y^2 - sum(p)] atol=1e-5 +end + +@testset "Symbolic tstops" begin + @variables x(t) = 1.0 + @parameters p=0.15 q=0.25 r[1:2]=[0.35, 0.45] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * t + sum(r)], t; tstops = [0.5p, [0.1, 0.2], [p + 2q], r]) + prob = ODEProblem(sys, [], (0.0, 5.0)) + sol = solve(prob) + expected_tstops = unique!(sort!(vcat(0.0:0.075:5.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol.t), expected_tstops) + prob2 = remake(prob; tspan = (0.0, 10.0)) + sol2 = solve(prob2) + expected_tstops = unique!(sort!(vcat(0.0:0.075:10.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol2.t), expected_tstops) + + @variables y(t) [guess = 1.0] + @mtkbuild sys = ODESystem([D(x) ~ p * x + q * t + sum(r), y^3 ~ 2x + 1], + t; tstops = [0.5p, [0.1, 0.2], [p + 2q], r]) + prob = DAEProblem( + sys, [D(y) => 2D(x) / 3y^2, D(x) => p * x + q * t + sum(r)], [], (0.0, 5.0)) + sol = solve(prob, DImplicitEuler()) + expected_tstops = unique!(sort!(vcat(0.0:0.075:5.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol.t), expected_tstops) + prob2 = remake(prob; tspan = (0.0, 10.0)) + sol2 = solve(prob2, DImplicitEuler()) + expected_tstops = unique!(sort!(vcat(0.0:0.075:10.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol2.t), expected_tstops) +end + +@testset "Validate input types" begin + @parameters p d + @variables X(t)::Int64 + eq = D(X) ~ p - d * X + @test_throws ArgumentError @mtkbuild osys = ODESystem([eq], t) + @variables Y(t)[1:3]::String + eq = D(Y) ~ [p, p, p] + @test_throws ArgumentError @mtkbuild osys = ODESystem([eq], t) + + @variables X(t)::Complex + eq = D(X) ~ p - d * X + @test_nowarn @named osys = ODESystem([eq], t) +end + +# Test `isequal` +@testset "`isequal`" begin + @variables X(t) + @parameters p d + eq = D(X) ~ p - d * X + + osys1 = complete(ODESystem([eq], t; name = :osys)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 == osys2 # true + + continuous_events = [[X ~ 1.0] => [X ~ X + 5.0]] + discrete_events = [5.0 => [d ~ d / 2.0]] + + osys1 = complete(ODESystem([eq], t; name = :osys, continuous_events)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 !== osys2 + + osys1 = complete(ODESystem([eq], t; name = :osys, discrete_events)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 !== osys2 + + osys1 = complete(ODESystem([eq], t; name = :osys, continuous_events)) + osys2 = complete(ODESystem([eq], t; name = :osys, discrete_events)) + @test osys1 !== osys2 +end + +@testset "dae_order_lowering basic test" begin + @parameters a + @variables x(t) y(t) z(t) + @named dae_sys = ODESystem([ + D(x) ~ y, + 0 ~ x + z, + 0 ~ x - y + z + ], t, [z, y, x], []) + + lowered_dae_sys = dae_order_lowering(dae_sys) + @variables x1(t) y1(t) z1(t) + expected_eqs = [ + 0 ~ x + z, + 0 ~ x - y + z, + Differential(t)(x) ~ y + ] + lowered_eqs = equations(lowered_dae_sys) + sorted_lowered_eqs = sort(lowered_eqs, by = string) + sorted_expected_eqs = sort(expected_eqs, by = string) + @test sorted_lowered_eqs == sorted_expected_eqs + + expected_vars = Set([z, y, x]) + lowered_vars = Set(unknowns(lowered_dae_sys)) + @test lowered_vars == expected_vars +end + +@testset "dae_order_lowering test with structural_simplify" begin + @variables x(t) y(t) z(t) + @parameters M b k + eqs = [ + D(D(x)) ~ -b / M * D(x) - k / M * x, + 0 ~ y - D(x), + 0 ~ z - x + ] + ps = [M, b, k] + default_u0 = [ + D(x) => 0.0, x => 10.0, y => 0.0, z => 10.0 + ] + default_p = [M => 1.0, b => 1.0, k => 1.0] + @named dae_sys = ODESystem(eqs, t, [x, y, z], ps; defaults = [default_u0; default_p]) + + simplified_dae_sys = structural_simplify(dae_sys) + + lowered_dae_sys = dae_order_lowering(simplified_dae_sys) + lowered_dae_sys = complete(lowered_dae_sys) + + tspan = (0.0, 10.0) + prob = ODEProblem(lowered_dae_sys, nothing, tspan) + sol = solve(prob, Tsit5()) + + @test sol.t[end] == tspan[end] + @test sum(abs, sol.u[end]) < 1 + + prob = ODEProblem{false}(lowered_dae_sys; u0_constructor = x -> SVector(x...)) + @test prob.u0 isa SVector +end + +@testset "Constraint system construction" begin + @variables x(..) y(..) z(..) + @parameters a b c d e + eqs = [D(x(t)) ~ 3 * a * y(t), D(y(t)) ~ x(t) - z(t), D(z(t)) ~ e * x(t)^2] + cons = [x(0.3) ~ c * d, y(0.7) ~ 3] + + # Test variables + parameters infer correctly. + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [a, c, d, e]) + @test issetequal(unknowns(sys), [x(t), y(t), z(t)]) + + @parameters t_c + cons = [x(t_c) ~ 3] + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [a, e, t_c]) + + @parameters g(..) h i + cons = [g(h, i) * x(3) ~ c] + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [g, h, i, a, e, c]) + + # Test that bad constraints throw errors. + cons = [x(3, 4) ~ 3] # unknowns cannot have multiple args. + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + + cons = [x(y(t)) ~ 2] # unknown arg must be parameter, value, or t + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + + @variables u(t) v + cons = [x(t) * u ~ 3] + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + cons = [x(t) * v ~ 3] + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) # Need time argument. + + # Test array variables + @variables x(..)[1:5] + mat = [1 2 0 3 2 + 0 0 3 2 0 + 0 1 3 0 4 + 2 0 0 2 1 + 0 0 2 0 5] + eqs = D(x(t)) ~ mat * x(t) + cons = [x(3) ~ [2, 3, 3, 5, 4]] + @mtkbuild ode = ODESystem(D(x(t)) ~ mat * x(t), t; constraints = cons) + @test length(constraints(ModelingToolkit.get_constraintsystem(ode))) == 5 +end + +@testset "`build_explicit_observed_function` with `expression = true` returns `Expr`" begin + @variables x(t) + @mtkbuild sys = ODESystem(D(x) ~ 2x, t) + obsfn_expr = ModelingToolkit.build_explicit_observed_function( + sys, 2x + 1, expression = true) + @test obsfn_expr isa Expr + obsfn_expr_oop, obsfn_expr_iip = ModelingToolkit.build_explicit_observed_function( + sys, [x + 1, x + 2, x + t], return_inplace = true, expression = true) + @test obsfn_expr_oop isa Expr + @test obsfn_expr_iip isa Expr +end diff --git a/test/optimal_control.jl b/test/optimal_control.jl new file mode 100644 index 0000000000..f32ba471d8 --- /dev/null +++ b/test/optimal_control.jl @@ -0,0 +1,311 @@ +### TODO: update when BoundaryValueDiffEqAscher is updated to use the normal boundary condition conventions +using OrdinaryDiffEq +using BoundaryValueDiffEqMIRK, BoundaryValueDiffEqAscher +using BenchmarkTools +using ModelingToolkit +using SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D + +### Test Collocation solvers on simple problems +solvers = [MIRK4] +daesolvers = [Ascher2, Ascher4, Ascher6] + +@testset "Lotka-Volterra" begin + @parameters α=7.5 β=4.0 γ=8.0 δ=5.0 + @variables x(t)=1.0 y(t)=2.0 + + eqs = [D(x) ~ α * x - β * x * y, + D(y) ~ -γ * y + δ * x * y] + + u0map = [x => 1.0, y => 2.0] + parammap = [α => 7.5, β => 4, γ => 8.0, δ => 5.0] + tspan = (0.0, 10.0) + + @mtkbuild lotkavolterra = ODESystem(eqs, t) + op = ODEProblem(lotkavolterra, u0map, tspan, parammap) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lotkavolterra, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [1.0, 2.0] + end + + # Test out of place + bvp2 = SciMLBase.BVProblem{false, SciMLBase.AutoSpecialize}( + lotkavolterra, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp2, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [1.0, 2.0] + end +end + +### Testing on pendulum +@testset "Pendulum" begin + @parameters g=9.81 L=1.0 + @variables θ(t)=π / 2 θ_t(t) + + eqs = [D(θ) ~ θ_t + D(θ_t) ~ -(g / L) * sin(θ)] + + @mtkbuild pend = ODESystem(eqs, t) + + u0map = [θ => π / 2, θ_t => π / 2] + parammap = [:L => 1.0, :g => 9.81] + tspan = (0.0, 6.0) + + op = ODEProblem(pend, u0map, tspan, parammap) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap) + for solver in solvers + sol = solve(bvp, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [π / 2, π / 2] + end + + # Test out-of-place + bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + pend, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp2, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [π / 2, π / 2] + end +end + +################################################################## +### ODESystem with constraint equations, DAEs with constraints ### +################################################################## + +# Test generation of boundary condition function using `generate_function_bc`. Compare solutions to manually written boundary conditions +@testset "Boundary Condition Compilation" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + tspan = (0.0, 1.0) + @mtkbuild lksys = ODESystem(eqs, t) + + function lotkavolterra!(du, u, p, t) + du[1] = p[1] * u[1] - p[2] * u[1] * u[2] + du[2] = -p[4] * u[2] + p[3] * u[1] * u[2] + end + + function lotkavolterra(u, p, t) + [p[1] * u[1] - p[2] * u[1] * u[2], -p[4] * u[2] + p[3] * u[1] * u[2]] + end + + # Test with a constraint. + constr = [y(0.5) ~ 2.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + + function bc!(resid, u, p, t) + resid[1] = u(0.0)[1] - 1.0 + resid[2] = u(0.5)[2] - 2.0 + end + function bc(u, p, t) + [u(0.0)[1] - 1.0, u(0.5)[2] - 2.0] + end + + u0 = [1.0, 1.0] + tspan = (0.0, 1.0) + p = [1.5, 1.0, 1.0, 3.0] + bvpi1 = SciMLBase.BVProblem(lotkavolterra!, bc!, u0, tspan, p) + bvpi2 = SciMLBase.BVProblem(lotkavolterra, bc, u0, tspan, p) + bvpi3 = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, [x(t) => 1.0], tspan; guesses = [y(t) => 1.0]) + bvpi4 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + lksys, [x(t) => 1.0], tspan; guesses = [y(t) => 1.0]) + + sol1 = @btime solve($bvpi1, MIRK4(), dt = 0.01) + sol2 = @btime solve($bvpi2, MIRK4(), dt = 0.01) + sol3 = @btime solve($bvpi3, MIRK4(), dt = 0.01) + sol4 = @btime solve($bvpi4, MIRK4(), dt = 0.01) + @test sol1 ≈ sol2 ≈ sol3 ≈ sol4 # don't get true equality here, not sure why +end + +function test_solvers( + solvers, prob, u0map, constraints, equations = []; dt = 0.05, atol = 1e-2) + for solver in solvers + println("Solver: $solver") + sol = @btime solve($prob, $solver(), dt = $dt, abstol = $atol) + @test SciMLBase.successful_retcode(sol.retcode) + p = prob.p + t = sol.t + bc = prob.f.bc + ns = length(prob.u0) + if isinplace(prob.f) + resid = zeros(ns) + bc(resid, sol, p, t) + @test isapprox(zeros(ns), resid; atol) + @show resid + else + @test isapprox(zeros(ns), bc(sol, p, t); atol) + @show bc(sol, p, t) + end + + for (k, v) in u0map + @test sol[k][1] == v + end + + # for cons in constraints + # @test sol[cons.rhs - cons.lhs] ≈ 0 + # end + + for eq in equations + @test sol[eq] ≈ 0 + end + end +end + +# Simple ODESystem with BVP constraints. +@testset "ODE with constraints" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + u0map = [] + tspan = (0.0, 1.0) + guess = [x(t) => 4.0, y(t) => 2.0] + constr = [x(0.6) ~ 3.5, x(0.3) ~ 7.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr; dt = 0.05) + + # Testing that more complicated constraints give correct solutions. + constr = [y(0.2) + x(0.8) ~ 3.0, y(0.3) ~ 2.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + bvp = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr; dt = 0.05) + + constr = [α * β - x(0.6) ~ 0.0, y(0.2) ~ 3.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr) +end + +# Cartesian pendulum from the docs. +# DAE IVP solved using BoundaryValueDiffEq solvers. +# let +# @parameters g +# @variables x(t) y(t) [state_priority = 10] λ(t) +# eqs = [D(D(x)) ~ λ * x +# D(D(y)) ~ λ * y - g +# x^2 + y^2 ~ 1] +# @mtkbuild pend = ODESystem(eqs, t) +# +# tspan = (0.0, 1.5) +# u0map = [x => 1, y => 0] +# pmap = [g => 1] +# guess = [λ => 1] +# +# prob = ODEProblem(pend, u0map, tspan, pmap; guesses = guess) +# osol = solve(prob, Rodas5P()) +# +# zeta = [0., 0., 0., 0., 0.] +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses = guess) +# +# for solver in solvers +# sol = solve(bvp, solver(zeta), dt = 0.001) +# @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) +# conditions = getfield.(equations(pend)[3:end], :rhs) +# @test isapprox([sol[conditions][1]; sol[x][1] - 1; sol[y][1]], zeros(5), atol = 0.001) +# end +# +# bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}(pend, u0map, tspan, parammap) +# for solver in solvers +# sol = solve(bvp, solver(zeta), dt = 0.01) +# @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) +# conditions = getfield.(equations(pend)[3:end], :rhs) +# @test [sol[conditions][1]; sol[x][1] - 1; sol[y][1]] ≈ 0 +# end +# end + +# Adding a midpoint boundary constraint. +# Solve using BVDAE solvers. +# let +# @parameters g +# @variables x(..) y(t) [state_priority = 10] λ(t) +# eqs = [D(D(x(t))) ~ λ * x(t) +# D(D(y)) ~ λ * y - g +# x(t)^2 + y^2 ~ 1] +# constr = [x(0.5) ~ 1] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# +# tspan = (0.0, 1.5) +# u0map = [x(t) => 0.6, y => 0.8] +# parammap = [g => 1] +# guesses = [λ => 1] +# +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr) +# +# bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}(pend, u0map, tspan, parammap) +# test_solvers(daesolvers, bvp2, u0map, constr, get_alg_eqs(pend)) +# +# # More complicated constr. +# u0map = [x(t) => 0.6] +# guesses = [λ => 1, y(t) => 0.8] +# +# constr = [x(0.5) ~ 1, +# x(0.3)^3 + y(0.6)^2 ~ 0.5] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr, get_alg_eqs(pend)) +# +# constr = [x(0.4) * g ~ y(0.2), +# y(0.7) ~ 0.3] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr, get_alg_eqs(pend)) +# end + +@testset "Cost function compilation" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + t = ModelingToolkit.t_nounits + + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + tspan = (0.0, 1.0) + u0map = [x(t) => 4.0, y(t) => 2.0] + parammap = [α => 7.5, β => 4, γ => 8.0, δ => 5.0] + costs = [x(0.6), x(0.3)^2] + consolidate(u) = (u[1] + 3)^2 + u[2] + @mtkbuild lksys = ODESystem(eqs, t; costs, consolidate) + @test_throws ErrorException @mtkbuild lksys2 = ODESystem(eqs, t; costs) + + @test_throws ErrorException ODEProblem(lksys, u0map, tspan, parammap) + prob = ODEProblem(lksys, u0map, tspan, parammap; allow_cost = true) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost_function(lksys) + _t = tspan[2] + @test costfn(sol, prob.p, _t) ≈ (sol(0.6)[1] + 3)^2 + sol(0.3)[1]^2 + + ### With a parameter + @parameters t_c + costs = [y(t_c) + x(0.0), x(0.4)^2] + consolidate(u) = log(u[1]) - u[2] + @mtkbuild lksys = ODESystem(eqs, t; costs, consolidate) + @test t_c ∈ Set(parameters(lksys)) + push!(parammap, t_c => 0.56) + prob = ODEProblem(lksys, u0map, tspan, parammap; allow_cost = true) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost_function(lksys) + @test costfn(sol, prob.p, _t) ≈ log(sol(0.56)[2] + sol(0.0)[1]) - sol(0.4)[1]^2 +end diff --git a/test/optimizationsystem.jl b/test/optimizationsystem.jl index 052c21177f..2ec9516721 100644 --- a/test/optimizationsystem.jl +++ b/test/optimizationsystem.jl @@ -1,56 +1,411 @@ -using ModelingToolkit, SparseArrays, Test, GalacticOptim, Optim - -@variables x y -@parameters a b -loss = (a - x)^2 + b * (y - x^2)^2 -sys1 = OptimizationSystem(loss,[x,y],[a,b],name=:sys1) -sys2 = OptimizationSystem(loss,[x,y],[a,b],name=:sys2) - -@variables z -@parameters β -loss2 = sys1.x - sys2.y + z*β -combinedsys = OptimizationSystem(loss2,[z],[β],systems=[sys1,sys2],name=:combinedsys) - -equations(combinedsys) -states(combinedsys) -parameters(combinedsys) - -calculate_gradient(combinedsys) -calculate_hessian(combinedsys) -generate_function(combinedsys) -generate_gradient(combinedsys) -generate_hessian(combinedsys) -ModelingToolkit.hessian_sparsity(combinedsys) - -u0 = [ - sys1.x=>1.0 - sys1.y=>2.0 - sys2.x=>3.0 - sys2.y=>4.0 - z=>5.0 -] -p = [ - sys1.a => 6.0 - sys1.b => 7.0 - sys2.a => 8.0 - sys2.b => 9.0 - β => 10.0 -] - -prob = OptimizationProblem(combinedsys,u0,p,grad=true) -sol = solve(prob,NelderMead()) -@test sol.minimum < -1e5 - -prob2 = remake(prob,u0=sol.minimizer) -sol = solve(prob,BFGS(initial_stepnorm=0.0001),allow_f_increases=true) -@test sol.minimum < -1e8 -sol = solve(prob2,BFGS(initial_stepnorm=0.0001),allow_f_increases=true) -@test sol.minimum < -1e9 - -rosenbrock(x, p) = (p[1] - x[1])^2 + p[2] * (x[2] - x[1]^2)^2 -x0 = zeros(2) -_p = [1.0, 100.0] - -f = OptimizationFunction(rosenbrock,ModelingToolkit.AutoModelingToolkit(),x0,_p,grad=true,hess=true) -prob = OptimizationProblem(f,x0,_p) -sol = solve(prob,Optim.Newton()) +using ModelingToolkit, SparseArrays, Test, Optimization, OptimizationOptimJL, + OptimizationMOI, Ipopt, AmplNLWriter, Ipopt_jll, SymbolicIndexingInterface, + LinearAlgebra +using ModelingToolkit: get_metadata + +@testset "basic" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + sys1 = OptimizationSystem(loss, [x, y], [a, b], name = :sys1) + + cons2 = [x^2 + y^2 ~ 0, y * sin(x) - x ~ 0] + sys2 = OptimizationSystem(loss, [x, y], [a, b], name = :sys2, constraints = cons2) + + @variables z + @parameters β + loss2 = sys1.x - sys2.y + z * β + combinedsys = complete(OptimizationSystem(loss2, [z], [β], systems = [sys1, sys2], + name = :combinedsys)) + + equations(combinedsys) + unknowns(combinedsys) + parameters(combinedsys) + + calculate_gradient(combinedsys) + calculate_hessian(combinedsys) + generate_function(combinedsys) + generate_gradient(combinedsys) + generate_hessian(combinedsys) + hess_sparsity = ModelingToolkit.hessian_sparsity(sys1) + sparse_prob = OptimizationProblem(complete(sys1), + [x, y], + [a => 0.0, b => 0.0], + grad = true, + sparse = true) + @test sparse_prob.f.hess_prototype.rowval == hess_sparsity.rowval + @test sparse_prob.f.hess_prototype.colptr == hess_sparsity.colptr + + u0 = [sys1.x => 1.0 + sys1.y => 2.0 + sys2.x => 3.0 + sys2.y => 4.0 + z => 5.0] + p = [sys1.a => 6.0 + sys1.b => 7.0 + sys2.a => 8.0 + sys2.b => 9.0 + β => 10.0] + + prob = OptimizationProblem(combinedsys, u0, p, grad = true, hess = true, cons_j = true, + cons_h = true) + @test prob.f.sys === combinedsys + sol = solve(prob, Ipopt.Optimizer(); print_level = 0) + @test sol.objective < -1e5 +end + +@testset "inequality constraint" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + cons = [ + x^2 + y^2 ≲ 1.0 + ] + @named sys = OptimizationSystem(loss, [x, y], [a, b], constraints = cons) + sys = complete(sys) + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0], [a => 1.0, b => 1.0], + grad = true, hess = true, cons_j = true, cons_h = true) + @test prob.f.sys === sys + sol = solve(prob, IPNewton()) + @test sol.objective < 1.0 + sol = solve(prob, Ipopt.Optimizer(); print_level = 0) + @test sol.objective < 1.0 + + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0], [a => 1.0, b => 1.0], + grad = false, hess = false, cons_j = false, cons_h = false) + sol = solve(prob, AmplNLWriter.Optimizer(Ipopt_jll.amplexe)) + @test_skip sol.objective < 1.0 +end + +@testset "equality constraint" begin + @variables x y z + @parameters a b + loss = (a - x)^2 + b * z^2 + cons = [1.0 ~ x^2 + y^2 + z ~ y - x^2 + z^2 + y^2 ≲ 1.0] + @named sys = OptimizationSystem(loss, [x, y, z], [a, b], constraints = cons) + sys = structural_simplify(sys) + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0, z => 0.0], [a => 1.0, b => 1.0], + grad = true, hess = true, cons_j = true, cons_h = true) + sol = solve(prob, IPNewton()) + @test sol.objective < 1.0 + @test sol.u≈[0.808, -0.064] atol=1e-3 + @test sol[x]^2 + sol[y]^2 ≈ 1.0 + sol = solve(prob, Ipopt.Optimizer(); print_level = 0) + @test sol.objective < 1.0 + @test sol.u≈[0.808, -0.064] atol=1e-3 + @test sol[x]^2 + sol[y]^2 ≈ 1.0 + + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0, z => 0.0], [a => 1.0, b => 1.0], + grad = false, hess = false, cons_j = false, cons_h = false) + sol = solve(prob, AmplNLWriter.Optimizer(Ipopt_jll.amplexe)) + @test_skip sol.objective < 1.0 + @test_skip sol.u≈[0.808, -0.064] atol=1e-3 + @test_skip sol[x]^2 + sol[y]^2 ≈ 1.0 +end + +@testset "rosenbrock" begin + rosenbrock(x, p) = (p[1] - x[1])^2 + p[2] * (x[2] - x[1]^2)^2 + x0 = zeros(2) + p = [1.0, 100.0] + f = OptimizationFunction(rosenbrock, Optimization.AutoSymbolics()) + prob = OptimizationProblem(f, x0, p) + sol = solve(prob, Newton()) + @test sol.u ≈ [1.0, 1.0] +end + +# issue #819 +@testset "Combined system name collisions" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + sys1 = OptimizationSystem(loss, [x, y], [a, b], name = :sys1) + sys2 = OptimizationSystem(loss, [x, y], [a, b], name = :sys1) + @variables z + @parameters β + loss2 = sys1.x - sys2.y + z * β + @test_throws ArgumentError OptimizationSystem(loss2, [z], [β], systems = [sys1, sys2]) +end + +@testset "observed variable handling" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + @variables OBS + @named sys2 = OptimizationSystem(loss, [x, y], [a, b]; observed = [OBS ~ x + y]) + OBS2 = OBS + @test isequal(OBS2, @nonamespace sys2.OBS) + @unpack OBS = sys2 + @test isequal(OBS2, OBS) +end + +# nested constraints +@testset "nested systems" begin + @variables x y + @parameters a = 1 + o1 = (x - a)^2 + o2 = (y - 1 / 2)^2 + c1 = [ + x ~ 1 + ] + c2 = [ + y ~ 1 + ] + sys1 = OptimizationSystem(o1, [x], [a], name = :sys1, constraints = c1) + sys2 = OptimizationSystem(o2, [y], [], name = :sys2, constraints = c2) + sys = complete(OptimizationSystem(0, [], []; name = :sys, systems = [sys1, sys2], + constraints = [sys1.x + sys2.y ~ 2], checks = false)) + prob = OptimizationProblem(sys, [0.0, 0.0]) + @test isequal(constraints(sys), vcat(sys1.x + sys2.y ~ 2, sys1.x ~ 1, sys2.y ~ 1)) + @test isequal(equations(sys), (sys1.x - sys1.a)^2 + (sys2.y - 1 / 2)^2) + @test isequal(unknowns(sys), [sys1.x, sys2.y]) + + prob_ = remake(prob, u0 = [1.0, 0.0], p = [2.0]) + @test isequal(prob_.u0, [1.0, 0.0]) + @test isequal(prob_.p, [2.0]) + + prob_ = remake(prob, u0 = Dict(sys1.x => 1.0), p = Dict(sys1.a => 2.0)) + @test isequal(prob_.u0, [1.0, 0.0]) + @test isequal((prob_.p...,)[1], [2.0]) +end + +@testset "nested systems" begin + @variables x1 x2 x3 x4 + @named sys1 = OptimizationSystem(x1, [x1], []) + @named sys2 = OptimizationSystem(x2, [x2], [], systems = [sys1]) + @named sys3 = OptimizationSystem(x3, [x3], [], systems = [sys2]) + @named sys4 = OptimizationSystem(x4, [x4], [], systems = [sys3]) + + @test isequal(equations(sys4), sys3.sys2.sys1.x1 + sys3.sys2.x2 + sys3.x3 + x4) +end + +@testset "time dependent var" begin + @independent_variables t + @variables x(t) y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + sys1 = OptimizationSystem(loss, [x, y], [a, b], name = :sys1) + + cons = [ + x^2 + y^2 ≲ 1.0 + ] + sys2 = OptimizationSystem(loss, [x, y], [a, b], name = :sys2, constraints = cons) + + @variables z + @parameters β + loss2 = sys1.x - sys2.y + z * β + combinedsys = complete(OptimizationSystem(loss2, [z], [β], systems = [sys1, sys2], + name = :combinedsys)) + + u0 = [sys1.x => 1.0 + sys1.y => 2.0 + sys2.x => 3.0 + sys2.y => 4.0 + z => 5.0] + p = [sys1.a => 6.0 + sys1.b => 7.0 + sys2.a => 8.0 + sys2.b => 9.0 + β => 10.0] + + prob = OptimizationProblem(combinedsys, u0, p, grad = true, hess = true, cons_j = true, + cons_h = true) + @test prob.f.sys === combinedsys + @test_broken SciMLBase.successful_retcode(solve(prob, + Ipopt.Optimizer(); + print_level = 0)) + #= + @test sol.objective < -1e5 + + prob = OptimizationProblem(sys2, [x => 0.0, y => 0.0], [a => 1.0, b => 100.0], + grad = true, hess = true, cons_j = true, cons_h = true) + @test prob.f.sys === sys2 + sol = solve(prob, IPNewton()) + @test sol.objective < 1.0 + sol = solve(prob, Ipopt.Optimizer(); print_level = 0) + @test sol.objective < 1.0 + =# +end + +@testset "metadata" begin + @variables x + o1 = (x - 1)^2 + c1 = [ + x ~ 1 + ] + testdict = Dict(["test" => 1]) + sys1 = OptimizationSystem(o1, [x], [], name = :sys1, constraints = c1, + metadata = testdict) + @test get_metadata(sys1) == testdict +end + +@testset "non-convex problem with inequalities" begin + @variables x[1:2] [bounds = (0.0, Inf)] + @named sys = OptimizationSystem(x[1] + x[2], [x...], []; + constraints = [ + 1.0 ≲ x[1]^2 + x[2]^2, + x[1]^2 + x[2]^2 ≲ 2.0 + ]) + + prob = OptimizationProblem(complete(sys), [x[1] => 2.0, x[2] => 0.0], [], grad = true, + hess = true, cons_j = true, cons_h = true) + sol = Optimization.solve(prob, Ipopt.Optimizer(); print_level = 0) + @test sol.u ≈ [1, 0] + @test prob.lb == [0.0, 0.0] + @test prob.ub == [Inf, Inf] +end + +@testset "parameter bounds" begin + @parameters c = 0.0 + @variables x y [bounds = (c, Inf)] + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + @named sys = OptimizationSystem(loss, [x, y], [a, b, c]) + prob = OptimizationProblem(complete(sys), [x => 0.0, y => 0.0], [a => 1.0, b => 100.0]) + @test prob.lb == [-Inf, 0.0] + @test prob.ub == [Inf, Inf] +end + +@testset "modelingtoolkitize" begin + @variables x₁ x₂ + @parameters α₁ α₂ + loss = (α₁ - x₁)^2 + α₂ * (x₂ - x₁^2)^2 + cons = [ + x₁^2 + x₂^2 ≲ 1.0 + ] + sys1 = complete(OptimizationSystem(loss, + [x₁, x₂], + [α₁, α₂], + name = :sys1, + constraints = cons)) + + prob1 = OptimizationProblem(sys1, [x₁ => 0.0, x₂ => 0.0], [α₁ => 1.0, α₂ => 100.0], + grad = true, hess = true, cons_j = true, cons_h = true) + + sys2 = complete(modelingtoolkitize(prob1)) + prob2 = OptimizationProblem(sys2, [x₁ => 0.0, x₂ => 0.0], [α₁ => 1.0, α₂ => 100.0], + grad = true, hess = true, cons_j = true, cons_h = true) + + sol1 = Optimization.solve(prob1, Ipopt.Optimizer()) + sol2 = Optimization.solve(prob2, Ipopt.Optimizer()) + + @test sol1.u ≈ sol2.u +end + +@testset "#2323 keep symbolic expressions and xor condition on constraint bounds" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + @named sys = OptimizationSystem(loss, [x, y], [a, b], constraints = [x^2 + y^2 ≲ 0.0]) + sys = complete(sys) + @test_throws Any OptimizationProblem(sys, + [x => 0.0, y => 0.0], + [a => 1.0, b => 100.0], + lcons = [0.0]) + @test_throws Any OptimizationProblem(sys, + [x => 0.0, y => 0.0], + [a => 1.0, b => 100.0], + ucons = [0.0]) + + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0], [a => 1.0, b => 100.0]) + @test prob.f.expr isa Symbolics.Symbolic + @test all(prob.f.cons_expr[i].lhs isa Symbolics.Symbolic + for i in 1:length(prob.f.cons_expr)) +end + +@testset "Derivatives, iip and oop" begin + @variables x y + @parameters a b + loss = (a - x)^2 + b * (y - x^2)^2 + cons2 = [x^2 + y^2 ~ 0, y * sin(x) - x ~ 0] + sys = complete(OptimizationSystem( + loss, [x, y], [a, b], name = :sys2, constraints = cons2)) + prob = OptimizationProblem(sys, [x => 0.0, y => 0.0], [a => 1.0, b => 100.0], + grad = true, hess = true, cons_j = true, cons_h = true) + + G1 = Array{Float64}(undef, 2) + H1 = Array{Float64}(undef, 2, 2) + J = Array{Float64}(undef, 2, 2) + H3 = [Array{Float64}(undef, 2, 2), Array{Float64}(undef, 2, 2)] + + prob.f.grad(G1, [1.0, 1.0], [1.0, 100.0]) + @test prob.f.grad([1.0, 1.0], [1.0, 100.0]) == G1 + + prob.f.hess(H1, [1.0, 1.0], [1.0, 100.0]) + @test prob.f.hess([1.0, 1.0], [1.0, 100.0]) == H1 + + prob.f.cons_j(J, [1.0, 1.0], [1.0, 100.0]) + @test prob.f.cons_j([1.0, 1.0], [1.0, 100.0]) == J + + prob.f.cons_h(H3, [1.0, 1.0], [1.0, 100.0]) + @test prob.f.cons_h([1.0, 1.0], [1.0, 100.0]) == H3 +end + +@testset "Passing `nothing` to `u0`" begin + @variables x = 1.0 + @mtkbuild sys = OptimizationSystem((x - 3)^2, [x], []) + prob = @test_nowarn OptimizationProblem(sys, nothing) + @test_nowarn solve(prob, NelderMead()) +end + +@testset "Bounded unknowns are irreducible" begin + @variables x + @variables y [bounds = (-Inf, Inf)] + @variables z [bounds = (1.0, 2.0)] + obj = x^2 + y^2 + z^2 + cons = [y ~ 2x + z ~ 2y] + @mtkbuild sys = OptimizationSystem(obj, [x, y, z], []; constraints = cons) + @test is_variable(sys, z) + @test !is_variable(sys, y) + + @variables x[1:3] [bounds = ([-Inf, -1.0, -2.0], [Inf, 1.0, 2.0])] + obj = x[1]^2 + x[2]^2 + x[3]^2 + cons = [x[2] ~ 2x[1] + 3, x[3] ~ x[1] + x[2]] + @mtkbuild sys = OptimizationSystem(obj, [x], []; constraints = cons) + @test length(unknowns(sys)) == 2 + @test !is_variable(sys, x[1]) + @test is_variable(sys, x[2]) + @test is_variable(sys, x[3]) +end + +@testset "Constraints work with nonnumeric parameters" begin + @variables x + @parameters p f(::Real) + @mtkbuild sys = OptimizationSystem( + x^2 + f(x) * p, [x], [f, p]; constraints = [2.0 ≲ f(x) + p]) + prob = OptimizationProblem(sys, [x => 1.0], [p => 1.0, f => (x -> 2x)]) + @test abs(prob.f.cons(prob.u0, prob.p)[1]) ≈ 1.0 +end + +@testset "Variable discovery" begin + @variables x1 x2 + @parameters p1 p2 + @named sys1 = OptimizationSystem(x1^2; constraints = [p1 * x1 ≲ 2.0]) + @named sys2 = OptimizationSystem(x2^2; constraints = [p2 * x2 ≲ 2.0], systems = [sys1]) + @test isequal(only(unknowns(sys1)), x1) + @test isequal(only(parameters(sys1)), p1) + @test all(y -> any(x -> isequal(x, y), unknowns(sys2)), [x2, sys1.x1]) + @test all(y -> any(x -> isequal(x, y), parameters(sys2)), [p2, sys1.p1]) +end + +function myeigvals_1(A::AbstractMatrix) + eigvals(A)[1] +end + +@register_symbolic myeigvals_1(A::AbstractMatrix) + +@testset "Issue#3473: Registered array function in objective, no irreducible variables" begin + p_free = @variables begin + p1, [bounds = (0, 1)] + p2, [bounds = (0, 1)] + p3, [bounds = (0, 1)] + p4, [bounds = (0, 1)] + end + + m = diagm(p_free) + + obj = myeigvals_1(m) + @test_nowarn OptimizationSystem(obj, p_free, []; name = :osys) +end diff --git a/test/parameter_dependencies.jl b/test/parameter_dependencies.jl new file mode 100644 index 0000000000..31881e1ca8 --- /dev/null +++ b/test/parameter_dependencies.jl @@ -0,0 +1,388 @@ +using ModelingToolkit +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D +using OrdinaryDiffEq +using StochasticDiffEq +using JumpProcesses +using StableRNGs +using SciMLStructures: canonicalize, Tunable, replace, replace! +using SymbolicIndexingInterface +using NonlinearSolve + +@testset "ODESystem with callbacks" begin + @parameters p1=1.0 p2 + @variables x(t) + cb1 = [x ~ 2.0] => [p1 ~ 2.0] # triggers at t=-2+√6 + function affect1!(integ, u, p, ctx) + integ.ps[p[1]] = integ.ps[p[2]] + end + cb2 = [x ~ 4.0] => (affect1!, [], [p1, p2], [p1]) # triggers at t=-2+√7 + cb3 = [1.0] => [p1 ~ 5.0] + + @mtkbuild sys = ODESystem( + [D(x) ~ p1 * t + p2], + t; + parameter_dependencies = [p2 => 2p1], + continuous_events = [cb1, cb2], + discrete_events = [cb3] + ) + @test !(p2 in Set(parameters(sys))) + @test p2 in Set(full_parameters(sys)) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.5), jac = true) + @test prob.ps[p1] == 1.0 + @test prob.ps[p2] == 2.0 + @test SciMLBase.successful_retcode(solve(prob, Tsit5())) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.5), [p1 => 1.0], jac = true) + @test prob.ps[p1] == 1.0 + @test prob.ps[p2] == 2.0 + integ = init(prob, Tsit5()) + @test integ.ps[p1] == 1.0 + @test integ.ps[p2] == 2.0 + step!(integ, 0.5, true) # after cb1, before cb2 + @test integ.ps[p1] == 2.0 + @test integ.ps[p2] == 4.0 + step!(integ, 0.4, true) # after cb2, before cb3 + @test integ.ps[p1] == 4.0 + @test integ.ps[p2] == 8.0 + step!(integ, 0.2, true) # after cb3 + @test integ.ps[p1] == 5.0 + @test integ.ps[p2] == 10.0 +end + +@testset "vector parameter deps" begin + @parameters p1[1:2]=[1.0, 2.0] p2[1:2]=[0.0, 0.0] + @variables x(t) = 0 + + @named sys = ODESystem( + [D(x) ~ sum(p1) * t + sum(p2)], + t; + parameter_dependencies = [p2 => 2p1] + ) + prob = ODEProblem(complete(sys)) + setp1! = setp(prob, p1) + get_p1 = getp(prob, p1) + get_p2 = getp(prob, p2) + setp1!(prob, [1.5, 2.5]) + + @test get_p1(prob) == [1.5, 2.5] + @test get_p2(prob) == [3.0, 5.0] +end + +@testset "extend" begin + @parameters p1=1.0 p2=1.0 + @variables x(t) = 0 + + @mtkbuild sys1 = ODESystem( + [D(x) ~ p1 * t + p2], + t + ) + @named sys2 = ODESystem( + [], + t; + parameter_dependencies = [p2 => 2p1] + ) + sys = extend(sys2, sys1) + @test !(p2 in Set(parameters(sys))) + @test p2 in Set(full_parameters(sys)) + prob = ODEProblem(complete(sys)) + get_dep = getu(prob, 2p2) + @test get_dep(prob) == 4 +end + +@testset "getu with parameter deps" begin + @parameters p1=1.0 p2=1.0 + @variables x(t) = 0 + + @named sys = ODESystem( + [D(x) ~ p1 * t + p2], + t; + parameter_dependencies = [p2 => 2p1] + ) + prob = ODEProblem(complete(sys)) + get_dep = getu(prob, 2p2) + @test get_dep(prob) == 4 +end + +@testset "getu with vector parameter deps" begin + @parameters p1[1:2]=[1.0, 2.0] p2[1:2]=[0.0, 0.0] + @variables x(t) = 0 + + @named sys = ODESystem( + [D(x) ~ sum(p1) * t + sum(p2)], + t; + parameter_dependencies = [p2 => 2p1] + ) + prob = ODEProblem(complete(sys)) + get_dep = getu(prob, 2p1) + @test get_dep(prob) == [2.0, 4.0] +end + +@testset "composing systems with parameter deps" begin + @parameters p1=1.0 p2=2.0 + @variables x(t) = 0 + + @named sys1 = ODESystem( + [D(x) ~ p1 * t + p2], + t + ) + @named sys2 = ODESystem( + [D(x) ~ p1 * t - p2], + t; + parameter_dependencies = [p2 => 2p1] + ) + sys = complete(ODESystem([], t, systems = [sys1, sys2], name = :sys)) + + prob = ODEProblem(sys) + v1 = sys.sys2.p2 + v2 = 2 * v1 + @test is_observed(prob, v1) + @test is_observed(prob, v2) + get_v1 = getu(prob, v1) + get_v2 = getu(prob, v2) + @test get_v1(prob) == 2 + @test get_v2(prob) == 4 + + setp1! = setp(prob, sys2.p1) + setp1!(prob, 2.5) + @test prob.ps[sys2.p2] == 5.0 + + new_prob = remake(prob, p = [sys2.p1 => 1.5]) + + @test !isempty(ModelingToolkit.parameter_dependencies(sys2)) + @test new_prob.ps[sys2.p1] == 1.5 + @test new_prob.ps[sys2.p2] == 3.0 +end + +@testset "parameter dependencies across model hierarchy" begin + sys2 = let name = :sys2 + @parameters p2 + @variables x(t) = 1.0 + eqs = [D(x) ~ p2] + ODESystem(eqs, t, [x], [p2]; name) + end + + @parameters p1 = 1.0 + parameter_dependencies = [sys2.p2 ~ p1 * 2.0] + sys1 = ODESystem( + Equation[], t, [], [p1]; parameter_dependencies, name = :sys1, systems = [sys2]) + + # ensure that parameter_dependencies is type stable + # (https://github.com/SciML/ModelingToolkit.jl/pull/2978) + @inferred ModelingToolkit.parameter_dependencies(sys1) + + sys = structural_simplify(sys1) + + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) +end + +struct CallableFoo + p::Any +end + +@register_symbolic CallableFoo(x) + +(f::CallableFoo)(x) = f.p + x + +@testset "callable parameters" begin + @variables y(t) = 1 + @parameters p=2 (i::CallableFoo)(..) + + eqs = [D(y) ~ i(t) + p] + @named model = ODESystem(eqs, t, [y], [p, i]; + parameter_dependencies = [i ~ CallableFoo(p)]) + sys = structural_simplify(model) + + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob, Tsit5()) + + @test SciMLBase.successful_retcode(sol) +end + +@testset "Clock system" begin + dt = 0.1 + @variables x(t) y(t) u(t) yd(t) ud(t) r(t) z(t) + @parameters kp kq + d = Clock(dt) + k = ShiftIndex(d) + + eqs = [yd ~ Sample(dt)(y) + ud ~ kp * (r - yd) + kq * z + r ~ 1.0 + u ~ Hold(ud) + D(x) ~ -x + u + y ~ x + z(k) ~ z(k - 2) + yd(k - 2)] + @test_throws ModelingToolkit.HybridSystemNotSupportedException @mtkbuild sys = ODESystem( + eqs, t; parameter_dependencies = [kq => 2kp]) + + @test_skip begin + Tf = 1.0 + prob = ODEProblem(sys, [x => 0.0, y => 0.0], (0.0, Tf), + [kp => 1.0; z(k - 1) => 3.0; yd(k - 1) => 0.0; z(k - 2) => 4.0; + yd(k - 2) => 2.0]) + @test_nowarn solve(prob, Tsit5()) + + @mtkbuild sys = ODESystem(eqs, t; parameter_dependencies = [kq => 2kp], + discrete_events = [[0.5] => [kp ~ 2.0]]) + prob = ODEProblem(sys, [x => 0.0, y => 0.0], (0.0, Tf), + [kp => 1.0; z(k - 1) => 3.0; yd(k - 1) => 0.0; z(k - 2) => 4.0; + yd(k - 2) => 2.0]) + @test prob.ps[kp] == 1.0 + @test prob.ps[kq] == 2.0 + @test_nowarn solve(prob, Tsit5()) + prob = ODEProblem(sys, [x => 0.0, y => 0.0], (0.0, Tf), + [kp => 1.0; z(k - 1) => 3.0; yd(k - 1) => 0.0; z(k - 2) => 4.0; + yd(k - 2) => 2.0]) + integ = init(prob, Tsit5()) + @test integ.ps[kp] == 1.0 + @test integ.ps[kq] == 2.0 + step!(integ, 0.6) + @test integ.ps[kp] == 2.0 + @test integ.ps[kq] == 4.0 + end +end + +@testset "SDESystem" begin + @parameters σ ρ β + @variables x(t) y(t) z(t) + + eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + noiseeqs = [0.1 * x, + 0.1 * y, + 0.1 * z] + + @named sys = ODESystem(eqs, t) + @named sdesys = SDESystem(sys, noiseeqs; parameter_dependencies = [ρ => 2σ]) + sdesys = complete(sdesys) + @test !(ρ in Set(parameters(sdesys))) + @test ρ in Set(full_parameters(sdesys)) + + prob = SDEProblem( + sdesys, [x => 1.0, y => 0.0, z => 0.0], (0.0, 100.0), [σ => 10.0, β => 2.33]) + @test prob.ps[ρ] == 2prob.ps[σ] + @test_nowarn solve(prob, SRIW1()) + + @named sys = ODESystem(eqs, t) + @named sdesys = SDESystem(sys, noiseeqs; parameter_dependencies = [ρ => 2σ], + discrete_events = [[10.0] => [σ ~ 15.0]]) + sdesys = complete(sdesys) + prob = SDEProblem( + sdesys, [x => 1.0, y => 0.0, z => 0.0], (0.0, 100.0), [σ => 10.0, β => 2.33]) + integ = init(prob, SRIW1()) + @test integ.ps[σ] == 10.0 + @test integ.ps[ρ] == 20.0 + step!(integ, 11.0) + @test integ.ps[σ] == 15.0 + @test integ.ps[ρ] == 30.0 +end + +@testset "JumpSystem" begin + rng = StableRNG(12345) + @parameters β γ + @constants h = 1 + @variables S(t) I(t) R(t) + rate₁ = β * S * I * h + affect₁ = [S ~ S - 1 * h, I ~ I + 1] + rate₃ = γ * I * h + affect₃ = [I ~ I * h - 1, R ~ R + 1] + j₁ = ConstantRateJump(rate₁, affect₁) + j₃ = ConstantRateJump(rate₃, affect₃) + @named js2 = JumpSystem( + [j₁, j₃], t, [S, I, R], [γ]; parameter_dependencies = [β => 0.01γ]) + @test isequal(only(parameters(js2)), γ) + @test Set(full_parameters(js2)) == Set([γ, β]) + js2 = complete(js2) + tspan = (0.0, 250.0) + u₀map = [S => 999, I => 1, R => 0] + parammap = [γ => 0.01] + dprob = DiscreteProblem(js2, u₀map, tspan, parammap) + jprob = JumpProblem(js2, dprob, Direct(), save_positions = (false, false), rng = rng) + @test jprob.ps[γ] == 0.01 + @test jprob.ps[β] == 0.0001 + @test_nowarn solve(jprob, SSAStepper()) + + @named js2 = JumpSystem( + [j₁, j₃], t, [S, I, R], [γ]; parameter_dependencies = [β => 0.01γ], + discrete_events = [[10.0] => [γ ~ 0.02]]) + js2 = complete(js2) + dprob = DiscreteProblem(js2, u₀map, tspan, parammap) + jprob = JumpProblem(js2, dprob, Direct(), save_positions = (false, false), rng = rng) + integ = init(jprob, SSAStepper()) + @test integ.ps[γ] == 0.01 + @test integ.ps[β] == 0.0001 + step!(integ, 11.0) + @test integ.ps[γ] == 0.02 + @test integ.ps[β] == 0.0002 +end + +@testset "NonlinearSystem" begin + @parameters p1=1.0 p2=1.0 + @variables x(t) + eqs = [0 ~ p1 * x * exp(x) + p2] + @mtkbuild sys = NonlinearSystem(eqs; parameter_dependencies = [p2 => 2p1]) + @test isequal(only(parameters(sys)), p1) + @test Set(full_parameters(sys)) == Set([p1, p2, Initial(p2), Initial(x)]) + prob = NonlinearProblem(sys, [x => 1.0]) + @test prob.ps[p1] == 1.0 + @test prob.ps[p2] == 2.0 + @test_nowarn solve(prob, NewtonRaphson()) + prob = NonlinearProblem(sys, [x => 1.0], [p1 => 2.0]) + @test prob.ps[p1] == 2.0 + @test prob.ps[p2] == 4.0 +end + +@testset "SciMLStructures interface" begin + @parameters p1=1.0 p2=1.0 + @variables x(t) + cb1 = [x ~ 2.0] => [p1 ~ 2.0] # triggers at t=-2+√6 + function affect1!(integ, u, p, ctx) + integ.ps[p[1]] = integ.ps[p[2]] + end + cb2 = [x ~ 4.0] => (affect1!, [], [p1, p2], [p1]) # triggers at t=-2+√7 + cb3 = [1.0] => [p1 ~ 5.0] + + @mtkbuild sys = ODESystem( + [D(x) ~ p1 * t + p2], + t; + parameter_dependencies = [p2 => 2p1] + ) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.5), [p1 => 1.0], jac = true) + prob.ps[p1] = 3.0 + @test prob.ps[p1] == 3.0 + @test prob.ps[p2] == 6.0 + + ps = prob.p + buffer, repack, _ = canonicalize(Tunable(), ps) + idx = parameter_index(sys, p1) + @test buffer[idx.idx] == 3.0 + buffer[idx.idx] = 4.0 + ps = repack(buffer) + @test getp(sys, p1)(ps) == 4.0 + @test getp(sys, p2)(ps) == 8.0 + + replace!(Tunable(), ps, ones(length(ps.tunable))) + @test getp(sys, p1)(ps) == 1.0 + @test getp(sys, p2)(ps) == 2.0 + + ps2 = replace(Tunable(), ps, 2 .* ps.tunable) + @test getp(sys, p1)(ps2) == 2.0 + @test getp(sys, p2)(ps2) == 4.0 +end + +@testset "Discovery of parameters from dependencies" begin + @parameters p1 p2 + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ y + p2], t; parameter_dependencies = [p2 ~ 2p1]) + @test is_parameter(sys, p1) + @named sys = NonlinearSystem([x * y^2 ~ y + p2]; parameter_dependencies = [p2 ~ 2p1]) + @test is_parameter(sys, p1) + k = ShiftIndex(t) + @named sys = DiscreteSystem( + [x(k - 1) ~ x(k) + y(k) + p2], t; parameter_dependencies = [p2 ~ 2p1]) + @test is_parameter(sys, p1) +end diff --git a/test/pde.jl b/test/pde.jl deleted file mode 100644 index d4a22f04a9..0000000000 --- a/test/pde.jl +++ /dev/null @@ -1,15 +0,0 @@ -using ModelingToolkit, DiffEqBase, LinearAlgebra - -# Define some variables -@parameters t x -@variables u(..) -Dt = Differential(t) -Dxx = Differential(x)^2 -eq = Dt(u(t,x)) ~ Dxx(u(t,x)) -bcs = [u(0,x) ~ - x * (x-1) * sin(x), - u(t,0) ~ 0, u(t,1) ~ 0] - -domains = [t ∈ IntervalDomain(0.0,1.0), - x ∈ IntervalDomain(0.0,1.0)] - -pdesys = PDESystem(eq,bcs,domains,[t,x],[u]) diff --git a/test/pdesystem.jl b/test/pdesystem.jl new file mode 100644 index 0000000000..531816cbbb --- /dev/null +++ b/test/pdesystem.jl @@ -0,0 +1,29 @@ +using ModelingToolkit, DiffEqBase, LinearAlgebra, Test +using ModelingToolkit: t_nounits as t, D_nounits as Dt + +# Define some variables +@parameters x +@constants h = 1 +@variables u(..) +Dxx = Differential(x)^2 +eq = Dt(u(t, x)) ~ h * Dxx(u(t, x)) +bcs = [u(0, x) ~ -h * x * (x - 1) * sin(x), + u(t, 0) ~ 0, u(t, 1) ~ 0] + +domains = [t ∈ (0.0, 1.0), + x ∈ (0.0, 1.0)] + +analytic = [u(t, x) ~ -h * x * (x - 1) * sin(x) * exp(-2 * h * t)] +analytic_function = (ps, t, x) -> -ps[1] * x * (x - 1) * sin(x) * exp(-2 * ps[1] * t) + +@named pdesys = PDESystem(eq, bcs, domains, [t, x], [u], [h], analytic = analytic) +@show pdesys + +@test all(isequal.(independent_variables(pdesys), [t, x])) + +dx = 0:0.1:1 +dt = 0:0.1:1 + +# Test generated analytic_func +@test all(pdesys.analytic_func[u(t, x)]([2], disct, discx) ≈ + analytic_function([2], disct, discx) for disct in dt, discx in dx) diff --git a/test/precompile_test.jl b/test/precompile_test.jl index e12b5e7277..38051d9d49 100644 --- a/test/precompile_test.jl +++ b/test/precompile_test.jl @@ -8,18 +8,33 @@ using Distributed using ODEPrecompileTest -u = collect(1:3) -p = collect(4:6) +u = collect(1:3) +p = ModelingToolkit.MTKParameters(ODEPrecompileTest.f_noeval_good.sys, + [:σ, :ρ, :β] .=> collect(4:6)) # These cases do not work, because they get defined in the ModelingToolkit's RGF cache. @test parentmodule(typeof(ODEPrecompileTest.f_bad.f.f_iip).parameters[2]) == ModelingToolkit @test parentmodule(typeof(ODEPrecompileTest.f_bad.f.f_oop).parameters[2]) == ModelingToolkit -@test parentmodule(typeof(ODEPrecompileTest.f_noeval_bad.f.f_iip).parameters[2]) == ModelingToolkit -@test parentmodule(typeof(ODEPrecompileTest.f_noeval_bad.f.f_oop).parameters[2]) == ModelingToolkit -@test_throws KeyError ODEPrecompileTest.f_bad(u, p, 0.1) -@test_throws KeyError ODEPrecompileTest.f_noeval_bad(u, p, 0.1) +@test parentmodule(typeof(ODEPrecompileTest.f_noeval_bad.f.f_iip).parameters[2]) == + ModelingToolkit +@test parentmodule(typeof(ODEPrecompileTest.f_noeval_bad.f.f_oop).parameters[2]) == + ModelingToolkit +@test_skip begin + @test_throws KeyError ODEPrecompileTest.f_bad(u, p, 0.1) + @test_throws KeyError ODEPrecompileTest.f_noeval_bad(u, p, 0.1) +end # This case works, because it gets defined with the appropriate cache and context tags. -@test parentmodule(typeof(ODEPrecompileTest.f_noeval_good.f.f_iip).parameters[2]) == ODEPrecompileTest -@test parentmodule(typeof(ODEPrecompileTest.f_noeval_good.f.f_oop).parameters[2]) == ODEPrecompileTest +@test parentmodule(typeof(ODEPrecompileTest.f_noeval_good.f.f_iip).parameters[2]) == + ODEPrecompileTest +@test parentmodule(typeof(ODEPrecompileTest.f_noeval_good.f.f_oop).parameters[2]) == + ODEPrecompileTest @test ODEPrecompileTest.f_noeval_good(u, p, 0.1) == [4, 0, -16] + +ODEPrecompileTest.f_eval_bad(u, p, 0.1) + +@test parentmodule(typeof(ODEPrecompileTest.f_eval_good.f.f_iip)) == + ODEPrecompileTest +@test parentmodule(typeof(ODEPrecompileTest.f_eval_good.f.f_oop)) == + ODEPrecompileTest +@test ODEPrecompileTest.f_eval_good(u, p, 0.1) == [4, 0, -16] diff --git a/test/precompile_test/ModelParsingPrecompile.jl b/test/precompile_test/ModelParsingPrecompile.jl new file mode 100644 index 0000000000..87177d519f --- /dev/null +++ b/test/precompile_test/ModelParsingPrecompile.jl @@ -0,0 +1,15 @@ +module ModelParsingPrecompile + +using ModelingToolkit, Unitful +using ModelingToolkit: t + +@mtkmodel ModelWithComponentArray begin + @constants begin + k = 1, [description = "Default val of R"] + end + @parameters begin + r(t)[1:3] = k, [description = "Parameter array", unit = u"Ω"] + end +end + +end diff --git a/test/precompile_test/ODEPrecompileTest.jl b/test/precompile_test/ODEPrecompileTest.jl index 6af615e75a..81187c4075 100644 --- a/test/precompile_test/ODEPrecompileTest.jl +++ b/test/precompile_test/ODEPrecompileTest.jl @@ -1,29 +1,39 @@ module ODEPrecompileTest - using ModelingToolkit +using ModelingToolkit - function system(; kwargs...) - # Define some variables - @parameters t σ ρ β - @variables x(t) y(t) z(t) - D = Differential(t) +function system(; kwargs...) + # Define some variables + @independent_variables t + @parameters σ ρ β + @variables x(t) y(t) z(t) + D = Differential(t) - # Define a differential equation - eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] + # Define a differential equation + eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] - de = ODESystem(eqs) - return ODEFunction(de, [x,y,z], [σ,ρ,β]; kwargs...) - end + @named de = ODESystem(eqs, t) + de = complete(de) + return ODEFunction(de, [x, y, z], [σ, ρ, β]; kwargs...) +end + +# Build an ODEFunction as part of the module's precompilation. These cases +# will not work, because the generated RGFs are put into the ModelingToolkit cache. +const f_bad = system() +const f_noeval_bad = system(; eval_expression = false) + +# Setting eval_expression=false and eval_module=[this module] will ensure +# the RGFs are put into our own cache, initialised below. +using RuntimeGeneratedFunctions +RuntimeGeneratedFunctions.init(@__MODULE__) +const f_noeval_good = system(; eval_expression = false, eval_module = @__MODULE__) - # Build an ODEFunction as part of the module's precompilation. These cases - # will not work, because the generated RGFs are put into the ModelingToolkit cache. - const f_bad = system() - const f_noeval_bad = system(; eval_expression=false) +# Eval the expression but into MTK's module, which means it won't be properly cached by +# the package image +const f_eval_bad = system(; eval_expression = true, eval_module = @__MODULE__) - # Setting eval_expression=false and eval_module=[this module] will ensure - # the RGFs are put into our own cache, initialised below. - using RuntimeGeneratedFunctions - RuntimeGeneratedFunctions.init(@__MODULE__) - const f_noeval_good = system(; eval_expression=false, eval_module=@__MODULE__) +# Change the module the eval'd function is eval'd into to be the containing module, +# which should make it be in the package image +const f_eval_good = system(; eval_expression = true, eval_module = @__MODULE__) end diff --git a/test/print_tree.jl b/test/print_tree.jl index cae9082988..7584a22c60 100644 --- a/test/print_tree.jl +++ b/test/print_tree.jl @@ -5,18 +5,17 @@ include("../examples/rc_model.jl") io = IOBuffer() print_tree(io, rc_model) ser = String(take!(io)) -str = -"""rc_model -├─ resistor -│ ├─ p -│ └─ n -├─ capacitor -│ ├─ p -│ └─ n -├─ source -│ ├─ p -│ └─ n -└─ ground - └─ g -""" +str = """rc_model + ├─ resistor + │ ├─ p + │ └─ n + ├─ capacitor + │ ├─ p + │ └─ n + ├─ source + │ ├─ p + │ └─ n + └─ ground + └─ g + """ @test strip(ser) == strip(str) diff --git a/test/problem_validation.jl b/test/problem_validation.jl new file mode 100644 index 0000000000..a0a7afaf3c --- /dev/null +++ b/test/problem_validation.jl @@ -0,0 +1,34 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Input map validation" begin + import ModelingToolkit: InvalidKeyError, MissingParametersError + @variables X(t) + @parameters p d + eqs = [D(X) ~ p - d * X] + @mtkbuild osys = ODESystem(eqs, t) + + p = "I accidentally renamed p" + u0 = [X => 1.0] + ps = [p => 1.0, d => 0.5] + @test_throws MissingParametersError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + @parameters p d + ps = [p => 1.0, d => 0.5, "Random stuff" => 3.0] + @test_throws InvalidKeyError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + u0 = [:X => 1.0, "random" => 3.0] + @test_throws InvalidKeyError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + @variables x(t) y(t) z(t) + @parameters a b c d + eqs = [D(x) ~ x * a, D(y) ~ y * c, D(z) ~ b + d] + @mtkbuild sys = ODESystem(eqs, t) + pmap = [a => 1, b => 2, c => 3, d => 4, "b" => 2] + u0map = [x => 1, y => 2, z => 3] + @test_throws InvalidKeyError ODEProblem(sys, u0map, (0.0, 1.0), pmap) + + pmap = [a => 1, b => 2, c => 3, d => 4] + u0map = [x => 1, y => 2, z => 3, :0 => 3] + @test_throws InvalidKeyError ODEProblem(sys, u0map, (0.0, 1.0), pmap) +end diff --git a/test/reactionsystem.jl b/test/reactionsystem.jl deleted file mode 100644 index f9a03e22a3..0000000000 --- a/test/reactionsystem.jl +++ /dev/null @@ -1,241 +0,0 @@ -using ModelingToolkit, LinearAlgebra, DiffEqJump, Test -MT = ModelingToolkit - -@parameters t k[1:20] -@variables A(t) B(t) C(t) D(t) -rxs = [Reaction(k[1], nothing, [A]), # 0 -> A - Reaction(k[2], [B], nothing), # B -> 0 - Reaction(k[3],[A],[C]), # A -> C - Reaction(k[4], [C], [A,B]), # C -> A + B - Reaction(k[5], [C], [A], [1], [2]), # C -> A + A - Reaction(k[6], [A,B], [C]), # A + B -> C - Reaction(k[7], [B], [A], [2], [1]), # 2B -> A - Reaction(k[8], [A,B], [A,C]), # A + B -> A + C - Reaction(k[9], [A,B], [C,D]), # A + B -> C + D - Reaction(k[10], [A], [C,D], [2], [1,1]), # 2A -> C + D - Reaction(k[11], [A], [A,B], [2], [1,1]), # 2A -> A + B - Reaction(k[12], [A,B,C], [C,D], [1,3,4], [2, 3]), # A+3B+4C -> 2C + 3D - Reaction(k[13], [A,B], nothing, [3,1], nothing), # 3A+B -> 0 - Reaction(k[14], nothing, [A], nothing, [2]), # 0 -> 2A - Reaction(k[15]*A/(2+A), [A], nothing; only_use_rate=true), # A -> 0 with custom rate - Reaction(k[16], [A], [B]; only_use_rate=true), # A -> B with custom rate. - Reaction(k[17]*A*exp(B), [C], [D], [2], [1]), # 2C -> D with non constant rate. - Reaction(k[18]*B, nothing, [B], nothing, [2]), # 0 -> 2B with non constant rate. - Reaction(k[19]*t, [A], [B]), # A -> B with non constant rate. - Reaction(k[20]*t*A, [B,C], [D],[2,1],[2]) # 2A +B -> 2C with non constant rate. - ] -rs = ReactionSystem(rxs,t,[A,B,C,D],k) -odesys = convert(ODESystem,rs) -sdesys = convert(SDESystem,rs) - -# test show -io = IOBuffer() -show(io, rs) -str = String(take!(io)) -@test count(isequal('\n'), str) < 30 - -# hard coded ODE rhs -function oderhs(u,k,t) - A = u[1]; B = u[2]; C = u[3]; D = u[4]; - du = zeros(eltype(u),4) - du[1] = k[1] - k[3]*A + k[4]*C + 2*k[5]*C - k[6]*A*B + k[7]*B^2/2 - k[9]*A*B - k[10]*A^2 - k[11]*A^2/2 - k[12]*A*B^3*C^4/144 - 3*k[13]*A^3*B/6 + 2*k[14] - k[15]*A/(2+A) - k[16] - k[19]*t*A - du[2] = -k[2]*B + k[4]*C - k[6]*A*B - k[7]*B^2 - k[8]*A*B - k[9]*A*B + k[11]*A^2/2 - 3*k[12]*A*B^3*C^4/144 - k[13]*A^3*B/6 + k[16] + 2*k[18]*B + k[19]*t*A - 2*k[20]*t*A*B^2*C - du[3] = k[3]*A - k[4]*C - k[5]*C + k[6]*A*B + k[8]*A*B + k[9]*A*B + k[10]*A^2/2 - 2*k[12]*A*B^3*C^4/144 - 2*k[17]*A*exp(B)*C^2/2 - k[20]*t*A*B^2*C - du[4] = k[9]*A*B + k[10]*A^2/2 + 3*k[12]*A*B^3*C^4/144 + k[17]*A*exp(B)*C^2/2 + 2*k[20]*t*A*B^2*C - du -end - -# sde noise coefs -function sdenoise(u,k,t) - A = u[1]; B = u[2]; C = u[3]; D = u[4]; - G = zeros(eltype(u),length(k),length(u)) - z = zero(eltype(u)) - - G = [sqrt(k[1]) z z z; - z -sqrt(k[2]*B) z z; - -sqrt(k[3]*A) z sqrt(k[3]*A) z; - sqrt(k[4]*C) sqrt(k[4]*C) -sqrt(k[4]*C) z; - 2*sqrt(k[5]*C) z -sqrt(k[5]*C) z; - -sqrt(k[6]*A*B) -sqrt(k[6]*A*B) sqrt(k[6]*A*B) z; - sqrt(k[7]*B^2/2) -2*sqrt(k[7]*B^2/2) z z; - z -sqrt(k[8]*A*B) sqrt(k[8]*A*B) z; - -sqrt(k[9]*A*B) -sqrt(k[9]*A*B) sqrt(k[9]*A*B) sqrt(k[9]*A*B); - -2*sqrt(k[10]*A^2/2) z sqrt(k[10]*A^2/2) sqrt(k[10]*A^2/2); - -sqrt(k[11]*A^2/2) sqrt(k[11]*A^2/2) z z; - -sqrt(k[12]*A*B^3*C^4/144) -3*sqrt(k[12]*A*B^3*C^4/144) -2*sqrt(k[12]*A*B^3*C^4/144) 3*sqrt(k[12]*A*B^3*C^4/144); - -3*sqrt(k[13]*A^3*B/6) -sqrt(k[13]*A^3*B/6) z z; - 2*sqrt(k[14]) z z z; - -sqrt(k[15]*A/(2+A)) z z z; - -sqrt(k[16]) sqrt(k[16]) z z; - z z -2*sqrt(k[17]*A*exp(B)*C^2/2) sqrt(k[17]*A*exp(B)*C^2/2); - z 2*sqrt(k[18]*B) z z; - -sqrt(k[19]*t*A) sqrt(k[19]*t*A) z z; - z -2*sqrt(k[20]*t*A*B^2*C) -sqrt(k[20]*t*A*B^2*C) +2*sqrt(k[20]*t*A*B^2*C)]' - return G -end - -# test by evaluating drift and diffusion terms -p = rand(length(k)) -u = rand(length(k)) -t = 0. -du = oderhs(u,p,t) -G = sdenoise(u,p,t) -sdesys = convert(SDESystem,rs) -sf = SDEFunction{false}(sdesys, states(rs), parameters(rs)) -du2 = sf.f(u,p,t) -@test norm(du-du2) < 100*eps() -G2 = sf.g(u,p,t) -@test norm(G-G2) < 100*eps() - -# test conversion to NonlinearSystem -ns = convert(NonlinearSystem,rs) -fnl = eval(generate_function(ns)[2]) -dunl = similar(du) -fnl(dunl,u,p) -@test norm(du-dunl) < 100*eps() - -# tests the noise_scaling argument. -p = rand(length(k)+1) -u = rand(length(k)) -t = 0. -G = p[21]*sdenoise(u,p,t) -@variables η -sdesys_noise_scaling = convert(SDESystem,rs;noise_scaling=η) -sf = SDEFunction{false}(sdesys_noise_scaling, states(rs), parameters(sdesys_noise_scaling)) -G2 = sf.g(u,p,t) -@test norm(G-G2) < 100*eps() - -# tests the noise_scaling vector argument. -p = rand(length(k)+3) -u = rand(length(k)) -t = 0. -G = vcat(fill(p[21],8),fill(p[22],3),fill(p[23],9))' .* sdenoise(u,p,t) -@variables η[1:3] -sdesys_noise_scaling = convert(SDESystem,rs;noise_scaling=vcat(fill(η[1],8),fill(η[2],3),fill(η[3],9))) -sf = SDEFunction{false}(sdesys_noise_scaling, states(rs), parameters(sdesys_noise_scaling)) -G2 = sf.g(u,p,t) -@test norm(G-G2) < 100*eps() - -# tests using previous parameter for noise scaling -p = rand(length(k)) -u = rand(length(k)) -t = 0. -G = [p p p p]' .* sdenoise(u,p,t) -sdesys_noise_scaling = convert(SDESystem,rs;noise_scaling=k) -sf = SDEFunction{false}(sdesys_noise_scaling, states(rs), parameters(sdesys_noise_scaling)) -G2 = sf.g(u,p,t) -@test norm(G-G2) < 100*eps() - -# test with JumpSystem -js = convert(JumpSystem, rs) - -midxs = 1:14 -cidxs = 15:18 -vidxs = 19:20 -@test all(map(i -> typeof(equations(js)[i]) <: DiffEqJump.MassActionJump, midxs)) -@test all(map(i -> typeof(equations(js)[i]) <: DiffEqJump.ConstantRateJump, cidxs)) -@test all(map(i -> typeof(equations(js)[i]) <: DiffEqJump.VariableRateJump, vidxs)) - -pars = rand(length(k)); u0 = rand(1:10,4); ttt = rand(); -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], [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[15] = ConstantRateJump((u,p,t) -> p[15]*u[1]/(2+u[1]), integrator -> (integrator.u[1] -= 1)) -jumps[16] = ConstantRateJump((u,p,t) -> p[16], integrator -> (integrator.u[1] -= 1; integrator.u[2] += 1;)) -jumps[17] = ConstantRateJump((u,p,t) -> p[17]*u[1]*exp(u[2])*binomial(u[3],2), integrator -> (integrator.u[3] -= 2; integrator.u[4] += 1)) -jumps[18] = ConstantRateJump((u,p,t) -> p[18]*u[2], integrator -> (integrator.u[2] += 2)) - -jumps[19] = VariableRateJump((u,p,t) -> p[19]*u[1]*t, integrator -> (integrator.u[1] -= 1; integrator.u[2] += 1)) -jumps[20] = VariableRateJump((u,p,t) -> p[20]*t*u[1]*binomial(u[2],2)*u[3], integrator -> (integrator.u[2] -= 2; integrator.u[3] -= 1; integrator.u[4] += 2)) - -statetoid = Dict(state => i for (i,state) in enumerate(states(js))) -parammap = map((x,y)->Pair(x,y),parameters(js),pars) -for i in midxs - maj = MT.assemble_maj(equations(js)[i], statetoid, ModelingToolkit.substituter(parammap),eltype(pars)) - @test abs(jumps[i].scaled_rates - maj.scaled_rates) < 100*eps() - @test jumps[i].reactant_stoch == maj.reactant_stoch - @test jumps[i].net_stoch == maj.net_stoch -end -for i in cidxs - crj = MT.assemble_crj(js, equations(js)[i], statetoid) - @test isapprox(crj.rate(u0,p,ttt), jumps[i].rate(u0,p,ttt)) - fake_integrator1 = (u=zeros(4),p=p,t=0); fake_integrator2 = deepcopy(fake_integrator1); - crj.affect!(fake_integrator1); - jumps[i].affect!(fake_integrator2); - @test fake_integrator1 == fake_integrator2 -end -for i in vidxs - crj = MT.assemble_vrj(js, equations(js)[i], statetoid) - @test isapprox(crj.rate(u0,p,ttt), jumps[i].rate(u0,p,ttt)) - fake_integrator1 = (u=zeros(4),p=p,t=0.); fake_integrator2 = deepcopy(fake_integrator1); - crj.affect!(fake_integrator1); jumps[i].affect!(fake_integrator2); - @test fake_integrator1 == fake_integrator2 -end - - -# test for https://github.com/SciML/ModelingToolkit.jl/issues/436 -@parameters t -@variables S I -rxs = [Reaction(1,[S],[I]), Reaction(1.1,[S],[I])] -rs = ReactionSystem(rxs, t, [S,I], []) -js = convert(JumpSystem, rs) -dprob = DiscreteProblem(js, [S => 1, I => 1], (0.0,10.0)) -jprob = JumpProblem(js, dprob, Direct()) -sol = solve(jprob, SSAStepper()) - -@parameters k1 k2 -@variables R -rxs = [Reaction(k1*S, [S,I], [I], [2,3], [2]), - Reaction(k2*R, [I], [R]) ] -rs = ReactionSystem(rxs, t, [S,I,R], [k1,k2]) -@test isequal(ModelingToolkit.oderatelaw(equations(rs)[1]), k1*S*S^2*I^3/(factorial(2)*factorial(3))) -@test_skip isequal(ModelingToolkit.jumpratelaw(equations(eqs)[1]), k1*S*binomial(S,2)*binomial(I,3)) -dep = Set() -ModelingToolkit.get_variables!(dep, rxs[2], Set(states(rs))) -dep2 = Set([R,I]) -@test dep == dep2 -dep = Set() -ModelingToolkit.modified_states!(dep, rxs[2], Set(states(rs))) -@test dep == Set([R,I]) - -isequal2(a,b) = isequal(simplify(a), simplify(b)) - -@test isequal2(ModelingToolkit.jumpratelaw(rxs[1]), k1*S*S*(S-1)*I*(I-1)*(I-2)/12) -@test isequal2(ModelingToolkit.jumpratelaw(rxs[1]; combinatoric_ratelaw=false), k1*S*S*(S-1)*I*(I-1)*(I-2)) -@test isequal2(ModelingToolkit.oderatelaw(rxs[1]), k1*S*S^2*I^3/12) -@test isequal2(ModelingToolkit.oderatelaw(rxs[1]; combinatoric_ratelaw=false), k1*S*S^2*I^3) - -#test ODE scaling: -os = convert(ODESystem,rs) -@test isequal2(equations(os)[1].rhs, -2*k1*S*S^2*I^3/12) -os = convert(ODESystem,rs; combinatoric_ratelaws=false) -@test isequal2(equations(os)[1].rhs, -2*k1*S*S^2*I^3) - -# test ConstantRateJump rate scaling -js = convert(JumpSystem,rs) -@test isequal2(equations(js)[1].rate, k1*S*S*(S-1)*I*(I-1)*(I-2)/12) -js = convert(JumpSystem,rs;combinatoric_ratelaws=false) -@test isequal2(equations(js)[1].rate, k1*S*S*(S-1)*I*(I-1)*(I-2)) - -# test MassActionJump rate scaling -rxs = [Reaction(k1, [S,I], [I], [2,3], [2]), - Reaction(k2, [I], [R]) ] -rs = ReactionSystem(rxs, t, [S,I,R], [k1,k2]) -js = convert(JumpSystem, rs) -@test isequal2(equations(js)[1].scaled_rates, k1/12) -js = convert(JumpSystem,rs; combinatoric_ratelaws=false) -@test isequal2(equations(js)[1].scaled_rates, k1) diff --git a/test/reactionsystem_components.jl b/test/reactionsystem_components.jl deleted file mode 100644 index ef41b8ff3a..0000000000 --- a/test/reactionsystem_components.jl +++ /dev/null @@ -1,87 +0,0 @@ -using ModelingToolkit, LinearAlgebra, OrdinaryDiffEq, Test -MT = ModelingToolkit - -# Repressilator model -@parameters t α₀ α K n δ β μ -@variables m(t) P(t) R(t) -rxs = [ - Reaction(α₀, nothing, [m]), - Reaction(α / (1 + (R/K)^n), nothing, [m]), - Reaction(δ, [m], nothing), - Reaction(β, [m], [m,P]), - Reaction(μ, [P], nothing) - ] - -specs = [m,P,R] -pars = [α₀,α,K,n,δ,β,μ] -@named rs = ReactionSystem(rxs, t, specs, pars) - -# using ODESystem components -@named os₁ = convert(ODESystem, rs) -@named os₂ = convert(ODESystem, rs) -@named os₃ = convert(ODESystem, rs) -connections = [os₁.R ~ os₃.P, - os₂.R ~ os₁.P, - os₃.R ~ os₂.P] -@named connected = ODESystem(connections, t, [], [], systems=[os₁,os₂,os₃]) -oderepressilator = structural_simplify(connected) - -pvals = [os₁.α₀ => 5e-4, - os₁.α => .5, - os₁.K => 40.0, - os₁.n => 2, - os₁.δ => (log(2)/120), - os₁.β => (20*log(2)/120), - os₁.μ => (log(2)/600), - os₂.α₀ => 5e-4, - os₂.α => .5, - os₂.K => 40.0, - os₂.n => 2, - os₂.δ => (log(2)/120), - os₂.β => (20*log(2)/120), - os₂.μ => (log(2)/600), - os₃.α₀ => 5e-4, - os₃.α => .5, - os₃.K => 40.0, - os₃.n => 2, - os₃.δ => (log(2)/120), - os₃.β => (20*log(2)/120), - os₃.μ => (log(2)/600)] -u₀ = [os₁.m => 0.0, os₁.P => 20.0, os₂.m => 0.0, os₂.P => 0.0, os₃.m => 0.0, os₃.P => 0.0] -tspan = (0.0, 100000.0) -oprob = ODEProblem(oderepressilator, u₀, tspan, pvals) -sol = solve(oprob, Tsit5()) - -# hardcoded network -function repress!(f, y, p, t) - α = p.α; α₀ = p.α₀; β = p.β; δ = p.δ; μ = p.μ; K = p.K; n = p.n - f[1] = α / (1 + (y[6] / K)^n) - δ * y[1] + α₀ - f[2] = α / (1 + (y[4] / K)^n) - δ * y[2] + α₀ - f[3] = α / (1 + (y[5] / K)^n) - δ * y[3] + α₀ - f[4] = β * y[1] - μ * y[4] - f[5] = β * y[2] - μ * y[5] - f[6] = β * y[3] - μ * y[6] - nothing -end -ps = (α₀=5e-4, α=.5, K=40.0, n=2, δ=(log(2)/120), β=(20*log(2)/120), μ=(log(2)/600)) -u0 = [0.0,0.0,0.0,20.0,0.0,0.0] -oprob2 = ODEProblem(repress!, u0, tspan, ps) -sol2 = solve(oprob2, Tsit5()) -tvs = 0:1:tspan[end] - -indexof(sym,syms) = findfirst(isequal(sym),syms) -i = indexof(os₁.P, states(oderepressilator)) -@test all(isapprox(u[1],u[2],atol=1e-4) for u in zip(sol(tvs, idxs=2), sol2(tvs, idxs=4))) - -# using ReactionSystem components - -# @named rs₁ = ReactionSystem(rxs, t, specs, pars) -# @named rs₂ = ReactionSystem(rxs, t, specs, pars) -# @named rs₃ = ReactionSystem(rxs, t, specs, pars) -# connections = [rs₁.R ~ rs₃.P, -# rs₂.R ~ rs₁.P, -# rs₃.R ~ rs₂.P] -# @named csys = ODESystem(connections, t, [], []) -# @named repressilator = ReactionSystem(t; systems=[csys,rs₁,rs₂,rs₃]) -# @named oderepressilator2 = convert(ODESystem, repressilator) -# sys2 = structural_simplify(oderepressilator2) # FAILS currently diff --git a/test/reduction.jl b/test/reduction.jl index 18abcd768d..fa9029a652 100644 --- a/test/reduction.jl +++ b/test/reduction.jl @@ -1,260 +1,209 @@ -using ModelingToolkit, OrdinaryDiffEq, Test, NonlinearSolve -using ModelingToolkit: topsort_equations +using ModelingToolkit, OrdinaryDiffEq, Test, NonlinearSolve, LinearAlgebra +using ModelingToolkit: topsort_equations, t_nounits as t, D_nounits as D -@variables t x(t) y(t) z(t) k(t) -eqs = [ - x ~ y + z +@variables x(t) y(t) z(t) k(t) +eqs = [x ~ y + z z ~ 2 - y ~ 2z + k - ] + y ~ 2z + k] sorted_eq = topsort_equations(eqs, [x, y, z, k]) -ref_eq = [ - z ~ 2 +ref_eq = [z ~ 2 y ~ 2z + k - x ~ y + z - ] + x ~ y + z] @test ref_eq == sorted_eq -@test_throws ArgumentError topsort_equations([ - x ~ y + z - z ~ 2 - y ~ 2z + x - ], [x, y, z, k]) +@test_throws ArgumentError topsort_equations([x ~ y + z + z ~ 2 + y ~ 2z + x], [x, y, z, k]) -@parameters t σ ρ β +@parameters σ ρ β @variables x(t) y(t) z(t) a(t) u(t) F(t) -D = Differential(t) -test_equal(a, b) = @test isequal(simplify(a, polynorm=true), simplify(b, polynorm=true)) +test_equal(a, b) = @test isequal(a, b) || isequal(simplify(a), simplify(b)) -eqs = [ - D(x) ~ σ*(y-x) - D(y) ~ x*(ρ-z)-y + β +eqs = [D(x) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + β 0 ~ z - x + y 0 ~ a + z - u ~ z + a - ] + u ~ z + a] -lorenz1 = ODESystem(eqs,t,name=:lorenz1) +lorenz1 = ODESystem(eqs, t, name = :lorenz1) lorenz1_aliased = structural_simplify(lorenz1) -io = IOBuffer(); show(io, MIME("text/plain"), lorenz1_aliased); str = String(take!(io)) -@test all(s->occursin(s, str), ["lorenz1", "States (2)", "Parameters (3)"]) -reduced_eqs = [ - D(x) ~ σ*(y - x) - D(y) ~ β + x*(ρ - (x - y)) - y - ] -test_equal.(equations(lorenz1_aliased), reduced_eqs) -@test isempty(setdiff(states(lorenz1_aliased), [x, y, z])) -test_equal.(observed(lorenz1_aliased), [ - u ~ 0 - z ~ x - y - a ~ -z - ]) +io = IOBuffer(); +show(io, MIME("text/plain"), lorenz1_aliased); +str = String(take!(io)); +@test all(s -> occursin(s, str), ["lorenz1", "Unknowns (2)", "Parameters (3)"]) +reduced_eqs = [D(x) ~ σ * (y - x) + D(y) ~ β + (ρ - z) * x - y] +#test_equal.(equations(lorenz1_aliased), reduced_eqs) +@test isempty(setdiff(unknowns(lorenz1_aliased), [x, y, z])) +#test_equal.(observed(lorenz1_aliased), [u ~ 0 +# z ~ x - y +# a ~ -z]) # Multi-System Reduction @variables s(t) eqs1 = [ - D(x) ~ σ*(y-x) + F, - D(y) ~ x*(ρ-z)-u, - D(z) ~ x*y - β*z, - u ~ x + y - z, - ] + D(x) ~ σ * (y - x) + F, + D(y) ~ x * (ρ - z) - u, + D(z) ~ x * y - β * z, + u ~ x + y - z +] -lorenz = name -> ODESystem(eqs1,t,name=name) +lorenz = name -> ODESystem(eqs1, t, name = name) lorenz1 = lorenz(:lorenz1) -ss = ModelingToolkit.get_structure(initialize_system_structure(lorenz1)) -@test isempty(setdiff(ss.fullvars, [D(x), F, y, x, D(y), u, z, D(z)])) +state = TearingState(lorenz1) +@test isempty(setdiff(state.fullvars, [D(x), F, y, x, D(y), u, z, D(z)])) lorenz2 = lorenz(:lorenz2) -connected = ODESystem([s ~ a + lorenz1.x - lorenz2.y ~ s - lorenz1.F ~ lorenz2.u - lorenz2.F ~ lorenz1.u],t,systems=[lorenz1,lorenz2]) -@test length(Base.propertynames(connected)) == 10 +@named connected = ODESystem( + [s ~ a + lorenz1.x + lorenz2.y ~ s + lorenz1.u ~ lorenz2.F + lorenz2.u ~ lorenz1.F], + t, systems = [lorenz1, lorenz2]) +@test length(Base.propertynames(connected)) == 10 + 1 # + 1 for independent variable @test isequal((@nonamespace connected.lorenz1.x), x) +__x = x +@unpack lorenz1 = connected +@unpack x = lorenz1 +@test isequal(x, __x) # Reduced Flattened System reduced_system = structural_simplify(connected) -reduced_system2 = structural_simplify(structural_simplify(structural_simplify(connected))) +reduced_system2 = structural_simplify(tearing_substitution(structural_simplify(tearing_substitution(structural_simplify(connected))))) -@test isempty(setdiff(states(reduced_system), states(reduced_system2))) -@test isequal(equations(reduced_system), equations(reduced_system2)) +@test isempty(setdiff(unknowns(reduced_system), unknowns(reduced_system2))) +@test isequal(equations(tearing_substitution(reduced_system)), equations(reduced_system2)) @test isequal(observed(reduced_system), observed(reduced_system2)) -@test setdiff(states(reduced_system), [ - s - a - lorenz1.x - lorenz1.y - lorenz1.z - lorenz1.u - lorenz2.x - lorenz2.y - lorenz2.z - lorenz2.u - ]) |> isempty - -@test setdiff(parameters(reduced_system), [ - lorenz1.σ - lorenz1.ρ - lorenz1.β - lorenz2.σ - lorenz2.ρ - lorenz2.β - ]) |> isempty - -reduced_eqs = [ - D(lorenz1.x) ~ lorenz1.σ*((lorenz1.y) - (lorenz1.x)) - ((lorenz2.z) - (lorenz2.x) - (lorenz2.y)) - D(lorenz1.y) ~ lorenz1.z + lorenz1.x*(lorenz1.ρ - (lorenz1.z)) - (lorenz1.x) - (lorenz1.y) - D(lorenz1.z) ~ lorenz1.x*lorenz1.y - (lorenz1.β*(lorenz1.z)) - D(lorenz2.x) ~ lorenz2.σ*((lorenz2.y) - (lorenz2.x)) - ((lorenz1.z) - (lorenz1.x) - (lorenz1.y)) - D(lorenz2.y) ~ lorenz2.z + lorenz2.x*(lorenz2.ρ - (lorenz2.z)) - (lorenz2.x) - (lorenz2.y) - D(lorenz2.z) ~ lorenz2.x*lorenz2.y - (lorenz2.β*(lorenz2.z)) - ] - -test_equal.(equations(reduced_system), reduced_eqs) - -observed_eqs = [ - s ~ lorenz2.y - a ~ lorenz2.y - lorenz1.x - lorenz1.F ~ -((lorenz2.z) - (lorenz2.x) - (lorenz2.y)) - lorenz2.F ~ -((lorenz1.z) - (lorenz1.x) - (lorenz1.y)) - lorenz2.u ~ lorenz1.F - lorenz1.u ~ lorenz2.F - ] -test_equal.(observed(reduced_system), observed_eqs) - -pp = [ - lorenz1.σ => 10 +@test setdiff(unknowns(reduced_system), + [s + a + lorenz1.x + lorenz1.y + lorenz1.z + lorenz1.u + lorenz2.x + lorenz2.y + lorenz2.z + lorenz2.u]) |> isempty + +@test setdiff(parameters(reduced_system), + [lorenz1.σ + lorenz1.ρ + lorenz1.β + lorenz2.σ + lorenz2.ρ + lorenz2.β]) |> isempty + +@test length(equations(reduced_system)) == 6 + +pp = [lorenz1.σ => 10 lorenz1.ρ => 28 - lorenz1.β => 8/3 + lorenz1.β => 8 / 3 lorenz2.σ => 10 lorenz2.ρ => 28 - lorenz2.β => 8/3 - ] -u0 = [ - lorenz1.x => 1.0 + lorenz2.β => 8 / 3] +u0 = [lorenz1.x => 1.0 lorenz1.y => 0.0 lorenz1.z => 0.0 + s => 0.0 lorenz2.x => 1.0 lorenz2.y => 0.0 - lorenz2.z => 0.0 - ] + lorenz2.z => 0.0] prob1 = ODEProblem(reduced_system, u0, (0.0, 100.0), pp) solve(prob1, Rodas5()) prob2 = SteadyStateProblem(reduced_system, u0, pp) -@test prob2.f.observed(lorenz2.u, prob2.u0, pp) === 1.0 - +@test prob2.f.observed(lorenz2.u, prob2.u0, prob2.p) === 1.0 # issue #724 and #716 let - @parameters t - D = Differential(t) @variables x(t) u(t) y(t) @parameters a b c d - ol = ODESystem([D(x) ~ a * x + b * u; y ~ c * x + d * u], t, name=:ol) + ol = ODESystem([D(x) ~ a * x + b * u; y ~ c * x + d * u], t, name = :ol) @variables u_c(t) y_c(t) @parameters k_P - pc = ODESystem(Equation[u_c ~ k_P * y_c], t, name=:pc) - connections = [ - ol.u ~ pc.u_c - pc.y_c ~ ol.y - ] - connected = ODESystem(connections, t, systems=[ol, pc]) + pc = ODESystem(Equation[u_c ~ k_P * y_c], t, name = :pc) + connections = [pc.u_c ~ ol.u + pc.y_c ~ ol.y] + @named connected = ODESystem(connections, t, systems = [ol, pc]) @test equations(connected) isa Vector{Equation} reduced_sys = structural_simplify(connected) - ref_eqs = [ - D(ol.x) ~ ol.a*ol.x + ol.b*ol.u - 0 ~ pc.k_P*(ol.c*ol.x + ol.d*ol.u) - ol.u - ] - @test ref_eqs == equations(reduced_sys) + ref_eqs = [D(ol.x) ~ ol.a * ol.x + ol.b * ol.u + 0 ~ pc.k_P * ol.y - ol.u] + #@test ref_eqs == equations(reduced_sys) end # issue #889 let - @parameters t - D = Differential(t) @variables x(t) @named sys = ODESystem([0 ~ D(x) + x], t, [x], []) + #@test_throws ModelingToolkit.InvalidSystemException ODEProblem(sys, [1.0], (0, 10.0)) sys = structural_simplify(sys) - @test_throws ModelingToolkit.InvalidSystemException ODEProblem(sys, [1.0], (0, 10.0)) + #@test_nowarn ODEProblem(sys, [1.0], (0, 10.0)) end # NonlinearSystem -@parameters t @variables u1(t) u2(t) u3(t) @parameters p -eqs = [ - u1 ~ u2 +eqs = [u1 ~ u2 u3 ~ u1 + u2 + p - u3 ~ hypot(u1, u2) * p - ] -sys = NonlinearSystem(eqs, [u1, u2, u3], [p]) + u3 ~ hypot(u1, u2) * p] +@named sys = NonlinearSystem(eqs, [u1, u2, u3], [p]) reducedsys = structural_simplify(sys) -@test observed(reducedsys) == [u1 ~ 0.5(u3 - p); u2 ~ u1] +@test length(observed(reducedsys)) == 2 -u0 = [ - u1 => 1 - u2 => 1 - u3 => 0.3 - ] +u0 = [u2 => 1] pp = [2] -nlprob = NonlinearProblem(reducedsys, u0, pp) +nlprob = NonlinearProblem(reducedsys, u0, [p => pp[1]]) reducedsol = solve(nlprob, NewtonRaphson()) -residual = fill(100.0, length(states(reducedsys))) +residual = fill(100.0, length(unknowns(reducedsys))) nlprob.f(residual, reducedsol.u, pp) -@test hypot(nlprob.f.observed(u2, reducedsol.u, pp), nlprob.f.observed(u1, reducedsol.u, pp)) * pp ≈ reducedsol.u atol=1e-9 +@test hypot(nlprob.f.observed(u2, reducedsol.u, pp), + nlprob.f.observed(u1, reducedsol.u, pp)) * + pp[1]≈nlprob.f.observed(u3, reducedsol.u, pp) atol=1e-9 -@test all(x->abs(x) < 1e-5, residual) +@test all(x -> abs(x) < 1e-5, residual) N = 5 @variables xs[1:N] -A = reshape(1:N^2, N, N) -eqs = xs .~ A * xs -sys′ = NonlinearSystem(eqs, xs, []) +A = reshape(1:(N^2), N, N) +eqs = xs ~ A * xs +@named sys′ = NonlinearSystem(eqs, [xs], []) sys = structural_simplify(sys′) +@test length(equations(sys)) == 3 && length(observed(sys)) == 2 # issue 958 -@parameters t k₁ k₂ k₋₁ E₀ +@parameters k₁ k₂ k₋₁ E₀ @variables E(t) C(t) S(t) P(t) -D = Differential(t) -eqs = [ - D(E) ~ k₋₁ * C - k₁ * E * S +eqs = [D(E) ~ k₋₁ * C - k₁ * E * S D(C) ~ k₁ * E * S - k₋₁ * C - k₂ * C D(S) ~ k₋₁ * C - k₁ * E * S D(P) ~ k₂ * C - E₀ ~ E + C - ] + E₀ ~ E + C] @named sys = ODESystem(eqs, t, [E, C, S, P], [k₁, k₂, k₋₁, E₀]) -@test_throws ModelingToolkit.InvalidSystemException structural_simplify(sys) +@test_throws ModelingToolkit.ExtraEquationsSystemException structural_simplify(sys) # Example 5 from Pantelides' original paper -@parameters t params = collect(@parameters y1(t) y2(t)) sts = collect(@variables x(t) u1(t) u2(t)) -D = Differential(t) -eqs = [ - 0 ~ x + sin(u1 + u2) +eqs = [0 ~ x + sin(u1 + u2) D(x) ~ x + y1 - cos(x) ~ sin(y2) - ] + cos(x) ~ sin(y2)] @named sys = ODESystem(eqs, t, sts, params) @test_throws ModelingToolkit.InvalidSystemException structural_simplify(sys) # issue #963 -@parameters t -D = Differential(t) @variables v47(t) v57(t) v66(t) v25(t) i74(t) i75(t) i64(t) i71(t) v1(t) v2(t) -eq = [ - v47 ~ v1 +eq = [v47 ~ v1 v47 ~ sin(10t) v57 ~ v1 - v2 v57 ~ 10.0i64 @@ -265,15 +214,76 @@ eq = [ 0 ~ i74 + i75 - i64 0 ~ i64 + i71] - -sys0 = ODESystem(eq, t) +@named sys0 = ODESystem(eq, t) sys = structural_simplify(sys0) @test length(equations(sys)) == 1 -eq = equations(sys)[1] -@test isequal(eq.lhs, 0) -dv25 = ModelingToolkit.value(ModelingToolkit.derivative(eq.rhs, v25)) -ddv25 = ModelingToolkit.value(ModelingToolkit.derivative(eq.rhs, D(v25))) -dt = ModelingToolkit.value(ModelingToolkit.derivative(eq.rhs, sin(10t))) -@test dv25 ≈ 0.3 -@test ddv25 == 0.005 -@test dt == -0.1 +eq = equations(tearing_substitution(sys))[1] +vv = only(unknowns(sys)) +@test isequal(eq.lhs, D(vv)) +dvv = ModelingToolkit.value(ModelingToolkit.derivative(eq.rhs, vv)) +@test dvv ≈ -60 + +# Don't reduce inputs +@parameters σ ρ β +@variables x(t) y(t) z(t) [input = true] a(t) u(t) F(t) + +eqs = [D(x) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + β + 0 ~ a + z + u ~ z + a] + +lorenz1 = ODESystem(eqs, t, name = :lorenz1) +lorenz1_reduced, _ = structural_simplify(lorenz1, ([z], [])) +@test z in Set(parameters(lorenz1_reduced)) + +# #2064 +vars = @variables x(t) y(t) z(t) +eqs = [D(x) ~ x + D(y) ~ y + D(z) ~ t] +@named model = ODESystem(eqs, t) +sys = structural_simplify(model) +Js = ModelingToolkit.jacobian_sparsity(sys) +@test size(Js) == (3, 3) +@test Js == Diagonal([1, 1, 0]) + +# MWE for #1722 +vars = @variables a(t) w(t) phi(t) +eqs = [a ~ D(w) + w ~ D(phi) + w ~ sin(t)] +@named sys = ODESystem(eqs, t, vars, []) +ss = alias_elimination(sys) +@test isempty(observed(ss)) + +@variables x(t) y(t) +@named sys = ODESystem([D(x) ~ 1 - x, + D(y) + D(x) ~ 0], t) +new_sys = alias_elimination(sys) +@test isempty(observed(new_sys)) + +@named sys = ODESystem([D(x) ~ x, + D(y) + D(x) ~ 0], t) +new_sys = alias_elimination(sys) +@test isempty(observed(new_sys)) + +@named sys = ODESystem([D(x) ~ 1 - x, + y + D(x) ~ 0], t) +new_sys = alias_elimination(sys) +@test isempty(observed(new_sys)) + +eqs = [x ~ 0 + D(x) ~ x + y] +@named sys = ODESystem(eqs, t, [x, y], []) +ss = structural_simplify(sys) +@test isempty(equations(ss)) +@test sort(string.(observed(ss))) == ["x(t) ~ 0.0" + "xˍt(t) ~ 0.0" + "y(t) ~ xˍt(t) - x(t)"] + +eqs = [D(D(x)) ~ -x] +@named sys = ODESystem(eqs, t, [x], []) +ss = alias_elimination(sys) +@test length(equations(ss)) == length(unknowns(ss)) == 1 +ss = structural_simplify(sys) +@test length(equations(ss)) == length(unknowns(ss)) == 2 diff --git a/test/runtests.jl b/test/runtests.jl index ee6fd60a1d..37c738eec9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,41 +1,145 @@ -using SafeTestsets, Test - -@safetestset "Varialbe scope tests" begin include("variable_scope.jl") end -@safetestset "Symbolic parameters test" begin include("symbolic_parameters.jl") end -@safetestset "Parsing Test" begin include("variable_parsing.jl") end -@safetestset "Simplify Test" begin include("simplify.jl") end -@safetestset "Direct Usage Test" begin include("direct.jl") end -@safetestset "System Linearity Test" begin include("linearity.jl") end -@safetestset "DiscreteSystem Test" begin include("discretesystem.jl") end -@safetestset "ODESystem Test" begin include("odesystem.jl") end -@safetestset "LabelledArrays Test" begin include("labelledarrays.jl") end -@safetestset "Mass Matrix Test" begin include("mass_matrix.jl") end -@safetestset "SteadyStateSystem Test" begin include("steadystatesystems.jl") end -@safetestset "SDESystem Test" begin include("sdesystem.jl") end -@safetestset "NonlinearSystem Test" begin include("nonlinearsystem.jl") end -@safetestset "OptimizationSystem Test" begin include("optimizationsystem.jl") end -@safetestset "ReactionSystem Test" begin include("reactionsystem.jl") end -@safetestset "ReactionSystem Test" begin include("reactionsystem_components.jl") end -@safetestset "JumpSystem Test" begin include("jumpsystem.jl") end -@safetestset "ControlSystem Test" begin include("controlsystem.jl") end -@safetestset "Domain Test" begin include("domains.jl") end -@safetestset "Modelingtoolkitize Test" begin include("modelingtoolkitize.jl") end -@safetestset "Constraints Test" begin include("constraints.jl") end -@safetestset "Reduction Test" begin include("reduction.jl") end -@safetestset "Components Test" begin include("components.jl") end -@safetestset "PDE Construction Test" begin include("pde.jl") end -@safetestset "Lowering Integration Test" begin include("lowering_solving.jl") end -@safetestset "Test Big System Usage" begin include("bigsystem.jl") end -@safetestset "Depdendency Graph Test" begin include("dep_graphs.jl") end -@safetestset "Function Registration Test" begin include("function_registration.jl") end -@safetestset "Precompiled Modules Test" begin include("precompile_test.jl") end -@testset "Distributed Test" begin include("distributed.jl") end -@safetestset "Variable Utils Test" begin include("variable_utils.jl") end -@safetestset "Jacobian Sparsity" begin include("jacobiansparsity.jl") end -println("Last test requires gcc available in the path!") -@safetestset "C Compilation Test" begin include("ccompile.jl") end -@safetestset "Latexify recipes Test" begin include("latexify.jl") end -@safetestset "StructuralTransformations" begin include("structural_transformation/runtests.jl") end -@testset "Serialization" begin include("serialization.jl") end -@safetestset "print_tree" begin include("print_tree.jl") end -@safetestset "connectors" begin include("connectors.jl") end +using SafeTestsets, Pkg, Test +# https://github.com/JuliaLang/julia/issues/54664 +import REPL + +const GROUP = get(ENV, "GROUP", "All") + +function activate_fmi_env() + Pkg.activate("fmi") + Pkg.develop(PackageSpec(path = dirname(@__DIR__))) + Pkg.instantiate() +end + +function activate_extensions_env() + Pkg.activate("extensions") + Pkg.develop(PackageSpec(path = dirname(@__DIR__))) + Pkg.instantiate() +end + +function activate_downstream_env() + Pkg.activate("downstream") + Pkg.develop(PackageSpec(path = dirname(@__DIR__))) + Pkg.instantiate() +end + +@time begin + if GROUP == "All" || GROUP == "InterfaceI" + @testset "InterfaceI" begin + @safetestset "Linear Algebra Test" include("linalg.jl") + @safetestset "AbstractSystem Test" include("abstractsystem.jl") + @safetestset "Variable Scope Tests" include("variable_scope.jl") + @safetestset "Symbolic Parameters Test" include("symbolic_parameters.jl") + @safetestset "Parsing Test" include("variable_parsing.jl") + @safetestset "Simplify Test" include("simplify.jl") + @safetestset "Direct Usage Test" include("direct.jl") + @safetestset "System Linearity Test" include("linearity.jl") + @safetestset "Input Output Test" include("input_output_handling.jl") + @safetestset "Clock Test" include("clock.jl") + @safetestset "ODESystem Test" include("odesystem.jl") + @safetestset "Dynamic Quantities Test" include("dq_units.jl") + @safetestset "Unitful Quantities Test" include("units.jl") + @safetestset "Mass Matrix Test" include("mass_matrix.jl") + @safetestset "Reduction Test" include("reduction.jl") + @safetestset "Split Parameters Test" include("split_parameters.jl") + @safetestset "StaticArrays Test" include("static_arrays.jl") + @safetestset "Components Test" include("components.jl") + @safetestset "Model Parsing Test" include("model_parsing.jl") + @safetestset "Error Handling" include("error_handling.jl") + @safetestset "StructuralTransformations" include("structural_transformation/runtests.jl") + @safetestset "Basic transformations" include("basic_transformations.jl") + @safetestset "State Selection Test" include("state_selection.jl") + @safetestset "Symbolic Event Test" include("symbolic_events.jl") + @safetestset "Stream Connect Test" include("stream_connectors.jl") + @safetestset "Domain Connect Test" include("domain_connectors.jl") + @safetestset "Lowering Integration Test" include("lowering_solving.jl") + @safetestset "Dependency Graph Test" include("dep_graphs.jl") + @safetestset "Function Registration Test" include("function_registration.jl") + @safetestset "Precompiled Modules Test" include("precompile_test.jl") + @safetestset "DAE Jacobians Test" include("dae_jacobian.jl") + @safetestset "Jacobian Sparsity" include("jacobiansparsity.jl") + @safetestset "Modelingtoolkitize Test" include("modelingtoolkitize.jl") + @safetestset "FuncAffect Test" include("funcaffect.jl") + @safetestset "Constants Test" include("constants.jl") + @safetestset "Parameter Dependency Test" include("parameter_dependencies.jl") + @safetestset "Equation Type Accessors Test" include("equation_type_accessors.jl") + @safetestset "System Accessor Functions Test" include("accessor_functions.jl") + @safetestset "Equations with complex values" include("complex.jl") + end + end + + if GROUP == "All" || GROUP == "Initialization" + @safetestset "Guess Propagation" include("guess_propagation.jl") + @safetestset "Hierarchical Initialization Equations" include("hierarchical_initialization_eqs.jl") + @safetestset "InitializationSystem Test" include("initializationsystem.jl") + @safetestset "Initial Values Test" include("initial_values.jl") + end + + if GROUP == "All" || GROUP == "InterfaceII" + @testset "InterfaceII" begin + @safetestset "Code Generation Test" include("code_generation.jl") + @safetestset "IndexCache Test" include("index_cache.jl") + @safetestset "Variable Utils Test" include("variable_utils.jl") + @safetestset "Variable Metadata Test" include("test_variable_metadata.jl") + @safetestset "OptimizationSystem Test" include("optimizationsystem.jl") + @safetestset "Discrete System" include("discrete_system.jl") + @safetestset "Implicit Discrete System" include("implicit_discrete_system.jl") + @safetestset "SteadyStateSystem Test" include("steadystatesystems.jl") + @safetestset "SDESystem Test" include("sdesystem.jl") + @safetestset "DDESystem Test" include("dde.jl") + @safetestset "NonlinearSystem Test" include("nonlinearsystem.jl") + @safetestset "SCCNonlinearProblem Test" include("scc_nonlinear_problem.jl") + @safetestset "PDE Construction Test" include("pdesystem.jl") + @safetestset "JumpSystem Test" include("jumpsystem.jl") + @safetestset "Optimal Control + Constraints Tests" include("optimal_control.jl") + @safetestset "print_tree" include("print_tree.jl") + @safetestset "Constraints Test" include("constraints.jl") + @safetestset "IfLifting Test" include("if_lifting.jl") + @safetestset "Analysis Points Test" include("analysis_points.jl") + @safetestset "Causal Variables Connection Test" include("causal_variables_connection.jl") + @safetestset "Debugging Test" include("debugging.jl") + @safetestset "Namespacing test" include("namespacing.jl") + @safetestset "Subsystem replacement" include("substitute_component.jl") + end + end + + if GROUP == "All" || GROUP == "SymbolicIndexingInterface" + @safetestset "SymbolicIndexingInterface test" include("symbolic_indexing_interface.jl") + @safetestset "SciML Problem Input Test" include("sciml_problem_inputs.jl") + @safetestset "MTKParameters Test" include("mtkparameters.jl") + end + + if GROUP == "All" || GROUP == "Extended" + @safetestset "Test Big System Usage" include("bigsystem.jl") + println("C compilation test requires gcc available in the path!") + @safetestset "C Compilation Test" include("ccompile.jl") + @testset "Distributed Test" include("distributed.jl") + @testset "Serialization" include("serialization.jl") + end + + if GROUP == "All" || GROUP == "RegressionI" + @safetestset "Latexify recipes Test" include("latexify.jl") + end + + if GROUP == "All" || GROUP == "Downstream" + activate_downstream_env() + @safetestset "Linearization Tests" include("downstream/linearize.jl") + @safetestset "Linearization Dummy Derivative Tests" include("downstream/linearization_dd.jl") + @safetestset "Inverse Models Test" include("downstream/inversemodel.jl") + @safetestset "Analysis Points Test" include("downstream/analysis_points.jl") + @safetestset "Analysis Points Test" include("downstream/test_disturbance_model.jl") + end + + if GROUP == "All" || GROUP == "FMI" + activate_fmi_env() + @safetestset "FMI Extension Test" include("fmi/fmi.jl") + end + + if GROUP == "All" || GROUP == "Extensions" + activate_extensions_env() + @safetestset "HomotopyContinuation Extension Test" include("extensions/homotopy_continuation.jl") + @safetestset "Auto Differentiation Test" include("extensions/ad.jl") + @safetestset "LabelledArrays Test" include("labelledarrays.jl") + @safetestset "BifurcationKit Extension Test" include("extensions/bifurcationkit.jl") + @safetestset "InfiniteOpt Extension Test" include("extensions/test_infiniteopt.jl") + end +end diff --git a/test/scc_nonlinear_problem.jl b/test/scc_nonlinear_problem.jl new file mode 100644 index 0000000000..b2b326d090 --- /dev/null +++ b/test/scc_nonlinear_problem.jl @@ -0,0 +1,293 @@ +using ModelingToolkit +using NonlinearSolve, SCCNonlinearSolve +using OrdinaryDiffEq +using SciMLBase, Symbolics +using LinearAlgebra, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Trivial case" begin + function f!(du, u, p) + du[1] = cos(u[2]) - u[1] + du[2] = sin(u[1] + u[2]) + u[2] + du[3] = 2u[4] + u[3] + 1.0 + du[4] = u[5]^2 + u[4] + du[5] = u[3]^2 + u[5] + du[6] = u[1] + u[2] + u[3] + u[4] + u[5] + 2.0u[6] + 2.5u[7] + 1.5u[8] + du[7] = u[1] + u[2] + u[3] + 2.0u[4] + u[5] + 4.0u[6] - 1.5u[7] + 1.5u[8] + du[8] = u[1] + 2.0u[2] + 3.0u[3] + 5.0u[4] + 6.0u[5] + u[6] - u[7] - u[8] + end + @variables u[1:8] [irreducible = true] + eqs = Any[0 for _ in 1:8] + f!(eqs, u, nothing) + eqs = 0 .~ eqs + @named model = NonlinearSystem(eqs) + @test_throws ["simplified", "required"] SCCNonlinearProblem(model, []) + _model = structural_simplify(model; split = false) + @test_throws ["not compatible"] SCCNonlinearProblem(_model, []) + model = structural_simplify(model) + prob = NonlinearProblem(model, [u => zeros(8)]) + sccprob = SCCNonlinearProblem(model, [u => zeros(8)]) + sol1 = solve(prob, NewtonRaphson()) + sol2 = solve(sccprob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sol1) + @test SciMLBase.successful_retcode(sol2) + @test sol1[u] ≈ sol2[u] +end + +@testset "With parameters" begin + function f!(du, u, (p1, p2), t) + x = (*)(p1[4], u[1]) + y = (*)(p1[4], (+)(0.1016, (*)(-1, u[1]))) + z1 = ifelse((<)(p2[1], 0), + (*)((*)(457896.07999999996, p1[2]), sqrt((*)(1.1686468413521012e-5, p1[3]))), + 0) + z2 = ifelse((>)(p2[1], 0), + (*)((*)((*)(0.58, p1[2]), sqrt((*)(1 // 86100, p1[3]))), u[4]), + 0) + z3 = ifelse((>)(p2[1], 0), + (*)((*)(457896.07999999996, p1[2]), sqrt((*)(1.1686468413521012e-5, p1[3]))), + 0) + z4 = ifelse((<)(p2[1], 0), + (*)((*)((*)(0.58, p1[2]), sqrt((*)(1 // 86100, p1[3]))), u[5]), + 0) + du[1] = p2[1] + du[2] = (+)(z1, (*)(-1, z2)) + du[3] = (+)(z3, (*)(-1, z4)) + du[4] = (+)((*)(-1, u[2]), (*)((*)(1 // 86100, y), u[4])) + du[5] = (+)((*)(-1, u[3]), (*)((*)(1 // 86100, x), u[5])) + end + p = ( + [0.04864391799335977, 7.853981633974484e-5, 1.4034843205574914, + 0.018241469247509915, 300237.05, 9.226186337232914], + [0.0508]) + u0 = [0.0, 0.0, 0.0, 789476.0, 101325.0] + tspan = (0.0, 1.0) + mass_matrix = [1.0 0.0 0.0 0.0 0.0; 0.0 1.0 0.0 0.0 0.0; 0.0 0.0 1.0 0.0 0.0; + 0.0 0.0 0.0 0.0 0.0; 0.0 0.0 0.0 0.0 0.0] + dt = 1e-3 + function nlf(u1, (u0, p)) + resid = Any[0 for _ in u0] + f!(resid, u1, p, 0.0) + return mass_matrix * (u1 - u0) - dt * resid + end + + prob = NonlinearProblem(nlf, u0, (u0, p)) + @test_throws Exception solve(prob, SimpleNewtonRaphson(), abstol = 1e-9) + sol = solve(prob, TrustRegion(); abstol = 1e-9) + + @variables u[1:5] [irreducible = true] + @parameters p1[1:6] p2 + eqs = 0 .~ collect(nlf(u, (u0, (p1, p2)))) + @mtkbuild sys = NonlinearSystem(eqs, [u], [p1, p2]) + sccprob = SCCNonlinearProblem(sys, [u => u0], [p1 => p[1], p2 => p[2][]]) + sccsol = solve(sccprob, SimpleNewtonRaphson(); abstol = 1e-9) + @test SciMLBase.successful_retcode(sccsol) + @test norm(sccsol.resid) < norm(sol.resid) +end + +@testset "Transistor amplifier" begin + C = [k * 1e-6 for k in 1:5] + Ub = 6 + UF = 0.026 + α = 0.99 + β = 1e-6 + R0 = 1000 + R = 9000 + Ue(t) = 0.1 * sin(200 * π * t) + + function transamp(out, du, u, p, t) + g(x) = 1e-6 * (exp(x / 0.026) - 1) + y1, y2, y3, y4, y5, y6, y7, y8 = u + out[1] = -Ue(t) / R0 + y1 / R0 + C[1] * du[1] - C[1] * du[2] + out[2] = -Ub / R + y2 * 2 / R - (α - 1) * g(y2 - y3) - C[1] * du[1] + C[1] * du[2] + out[3] = -g(y2 - y3) + y3 / R + C[2] * du[3] + out[4] = -Ub / R + y4 / R + α * g(y2 - y3) + C[3] * du[4] - C[3] * du[5] + out[5] = -Ub / R + y5 * 2 / R - (α - 1) * g(y5 - y6) - C[3] * du[4] + C[3] * du[5] + out[6] = -g(y5 - y6) + y6 / R + C[4] * du[6] + out[7] = -Ub / R + y7 / R + α * g(y5 - y6) + C[5] * du[7] - C[5] * du[8] + out[8] = y8 / R - C[5] * du[7] + C[5] * du[8] + end + + u0 = [0, Ub / 2, Ub / 2, Ub, Ub / 2, Ub / 2, Ub, 0] + du0 = [ + 51.338775, + 51.338775, + -Ub / (2 * (C[2] * R)), + -24.9757667, + -24.9757667, + -Ub / (2 * (C[4] * R)), + -10.00564453, + -10.00564453 + ] + daeprob = DAEProblem(transamp, du0, u0, (0.0, 0.1)) + daesol = solve(daeprob, DImplicitEuler()) + + t0 = daesol.t[5] + t1 = daesol.t[6] + u0 = daesol.u[5] + u1 = daesol.u[6] + dt = t1 - t0 + + @variables y(t)[1:8] + eqs = Any[0 for _ in 1:8] + transamp(eqs, collect(D(y)), y, nothing, t) + eqs = 0 .~ eqs + subrules = Dict(Symbolics.unwrap(D(y[i])) => ((y[i] - u0[i]) / dt) for i in 1:8) + eqs = substitute.(eqs, (subrules,)) + @mtkbuild sys = NonlinearSystem(eqs) + prob = NonlinearProblem(sys, [y => u0], [t => t0]) + sol = solve(prob, NewtonRaphson(); abstol = 1e-12) + + sccprob = SCCNonlinearProblem(sys, [y => u0], [t => t0]) + sccsol = solve(sccprob, NewtonRaphson(); abstol = 1e-12) + + @test sol.u≈sccsol.u atol=1e-10 +end + +@testset "Expression caching" begin + @variables x[1:4] = rand(4) + val = Ref(0) + function func(x, y) + val[] += 1 + x + y + end + @register_symbolic func(x, y) + @mtkbuild sys = NonlinearSystem([0 ~ x[1]^3 + x[2]^3 - 5 + 0 ~ sin(x[1] - x[2]) - 0.5 + 0 ~ func(x[1], x[2]) * exp(x[3]) - x[4]^3 - 5 + 0 ~ func(x[1], x[2]) * exp(x[4]) - x[3]^3 - 4]) + sccprob = SCCNonlinearProblem(sys, []) + sccsol = solve(sccprob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sccsol) + @test val[] == 1 +end + +import ModelingToolkitStandardLibrary.Blocks as B +import ModelingToolkitStandardLibrary.Mechanical.Translational as T +import ModelingToolkitStandardLibrary.Hydraulic.IsothermalCompressible as IC + +@testset "Caching of subexpressions of different types" begin + liquid_pressure(rho, rho_0, bulk) = (rho / rho_0 - 1) * bulk + gas_pressure(rho, rho_0, p_gas, rho_gas) = rho * ((0 - p_gas) / (rho_0 - rho_gas)) + full_pressure(rho, rho_0, bulk, p_gas, rho_gas) = ifelse( + rho >= rho_0, liquid_pressure(rho, rho_0, bulk), + gas_pressure(rho, rho_0, p_gas, rho_gas)) + + @component function Volume(; + #parameters + area, + direction = +1, + x_int, + name) + pars = @parameters begin + area = area + x_int = x_int + rho_0 = 1000 + bulk = 1e9 + p_gas = -1000 + rho_gas = 1 + end + + vars = @variables begin + x(t) = x_int + dx(t), [guess = 0] + p(t), [guess = 0] + f(t), [guess = 0] + rho(t), [guess = 0] + m(t), [guess = 0] + dm(t), [guess = 0] + end + + systems = @named begin + port = IC.HydraulicPort() + flange = T.MechanicalPort() + end + + eqs = [ + # connectors + port.p ~ p + port.dm ~ dm + flange.v * direction ~ dx + flange.f * direction ~ -f + + # differentials + D(x) ~ dx + D(m) ~ dm + + # physics + p ~ full_pressure(rho, rho_0, bulk, p_gas, rho_gas) + f ~ p * area + m ~ rho * x * area] + + return ODESystem(eqs, t, vars, pars; name, systems) + end + + systems = @named begin + fluid = IC.HydraulicFluid(; bulk_modulus = 1e9) + + src1 = IC.Pressure(;) + src2 = IC.Pressure(;) + + vol1 = Volume(; area = 0.01, direction = +1, x_int = 0.1) + vol2 = Volume(; area = 0.01, direction = +1, x_int = 0.1) + + mass = T.Mass(; m = 10) + + sin1 = B.Sine(; frequency = 0.5, amplitude = +0.5e5, offset = 10e5) + sin2 = B.Sine(; frequency = 0.5, amplitude = -0.5e5, offset = 10e5) + end + + eqs = [connect(fluid, src1.port) + connect(fluid, src2.port) + connect(src1.port, vol1.port) + connect(src2.port, vol2.port) + connect(vol1.flange, mass.flange, vol2.flange) + connect(src1.p, sin1.output) + connect(src2.p, sin2.output)] + + initialization_eqs = [mass.s ~ 0.0 + mass.v ~ 0.0] + + @mtkbuild sys = ODESystem(eqs, t, [], []; systems, initialization_eqs) + prob = ODEProblem(sys, [], (0, 5)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Array variables split across SCCs" begin + @variables x[1:3] + @parameters (f::Function)(..) + @mtkbuild sys = NonlinearSystem([ + 0 ~ x[1]^2 - 9, x[2] ~ 2x[1], 0 ~ x[3]^2 - x[1]^2 + f(x)]) + prob = SCCNonlinearProblem(sys, [x => ones(3)], [f => sum]) + sol = solve(prob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "SCCNonlinearProblem retains parameter order" begin + @variables x y z + @parameters σ β ρ + @mtkbuild fullsys = NonlinearSystem( + [0 ~ x^3 * β + y^3 * ρ - σ, 0 ~ x^2 + 2x * y + y^2, 0 ~ z^2 - 4z + 4], + [x, y, z], [σ, β, ρ]) + + u0 = [x => 1.0, + y => 0.0, + z => 0.0] + + p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + + sccprob = SCCNonlinearProblem(fullsys, u0, p) + @test isequal(parameters(fullsys), parameters(sccprob.f.sys)) +end + +@testset "Vector parameters in function arguments" begin + @variables x y + @parameters p[1:2] (f::Function)(..) + + @mtkbuild sys = NonlinearSystem([x^2 - p[1]^2 ~ 0, y^2 ~ f(p)]) + prob = SCCNonlinearProblem(sys, [x => 1.0, y => 1.0], [p => ones(2), f => sum]) + @test_nowarn solve(prob, NewtonRaphson()) +end diff --git a/test/sciml_problem_inputs.jl b/test/sciml_problem_inputs.jl new file mode 100644 index 0000000000..bfa560cda3 --- /dev/null +++ b/test/sciml_problem_inputs.jl @@ -0,0 +1,148 @@ +### Prepares Tests ### + +# Fetch packages +using ModelingToolkit, JumpProcesses, NonlinearSolve, OrdinaryDiffEq, StaticArrays, + SteadyStateDiffEq, StochasticDiffEq, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +# Sets rnd number. +using StableRNGs +rng = StableRNG(12345) +seed = rand(rng, 1:100) + +### Basic Tests ### + +# Prepares a models and initial conditions/parameters (of different forms) to be used as problem inputs. +begin + # Prepare system components. + @parameters kp kd k1 k2=0.5 Z0 + @variables X(t) Y(t) Z(t)=Z0 + alg_eqs = [ + 0 ~ kp - k1 * X + k2 * Y - kd * X, + 0 ~ -k1 * Y + k1 * X - k2 * Y + k2 * Z, + 0 ~ k1 * Y - k2 * Z + ] + diff_eqs = [ + D(X) ~ kp - k1 * X + k2 * Y - kd * X, + D(Y) ~ -k1 * Y + k1 * X - k2 * Y + k2 * Z, + D(Z) ~ k1 * Y - k2 * Z + ] + noise_eqs = fill(0.01, 3, 6) + jumps = [ + MassActionJump(kp, Pair{Symbolics.BasicSymbolic{Real}, Int64}[], [X => 1]), + MassActionJump(kd, [X => 1], [X => -1]), + MassActionJump(k1, [X => 1], [X => -1, Y => 1]), + MassActionJump(k2, [Y => 1], [X => 1, Y => -1]), + MassActionJump(k1, [Y => 1], [Y => -1, Z => 1]), + MassActionJump(k2, [Z => 1], [Y => 1, Z => -1]) + ] + + # Create systems (without structural_simplify, since that might modify systems to affect intended tests). + osys = complete(ODESystem(diff_eqs, t; name = :osys)) + ssys = complete(SDESystem( + diff_eqs, noise_eqs, t, [X, Y, Z], [kp, kd, k1, k2]; name = :ssys)) + jsys = complete(JumpSystem(jumps, t, [X, Y, Z], [kp, kd, k1, k2]; name = :jsys)) + nsys = complete(NonlinearSystem(alg_eqs; name = :nsys)) + + u0_alts = [ + # Vectors not providing default values. + [X => 4, Y => 5], + [osys.X => 4, osys.Y => 5], + # Vectors providing default values. + [X => 4, Y => 5, Z => 10], + [osys.X => 4, osys.Y => 5, osys.Z => 10], + # Static vectors not providing default values. + SA[X => 4, Y => 5], + SA[osys.X => 4, osys.Y => 5], + # Static vectors providing default values. + SA[X => 4, Y => 5, Z => 10], + SA[osys.X => 4, osys.Y => 5, osys.Z => 10], + # Dicts not providing default values. + Dict([X => 4, Y => 5]), + Dict([osys.X => 4, osys.Y => 5]), + # Dicts providing default values. + Dict([X => 4, Y => 5, Z => 10]), + Dict([osys.X => 4, osys.Y => 5, osys.Z => 10]), + # Tuples not providing default values. + (X => 4, Y => 5), + (osys.X => 4, osys.Y => 5), + # Tuples providing default values. + (X => 4, Y => 5, Z => 10), + (osys.X => 4, osys.Y => 5, osys.Z => 10) + ] + tspan = (0.0, 10.0) + p_alts = [ + # Vectors not providing default values. + [kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10], + [osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.Z0 => 10], + # Vectors providing default values. + [kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10], + [osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.k2 => 0.5, osys.Z0 => 10], + # Static vectors not providing default values. + SA[kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10], + SA[osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.Z0 => 10], + # Static vectors providing default values. + SA[kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10], + SA[osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.k2 => 0.5, osys.Z0 => 10], + # Dicts not providing default values. + Dict([kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10]), + Dict([osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.Z0 => 10]), + # Dicts providing default values. + Dict([kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10]), + Dict([osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, + osys.k2 => 0.5, osys.Z0 => 10]), + # Tuples not providing default values. + (kp => 1.0, kd => 0.1, k1 => 0.25, Z0 => 10), + (osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.Z0 => 10), + # Tuples providing default values. + (kp => 1.0, kd => 0.1, k1 => 0.25, k2 => 0.5, Z0 => 10), + (osys.kp => 1.0, osys.kd => 0.1, osys.k1 => 0.25, osys.k2 => 0.5, osys.Z0 => 10) + ] +end + +# Perform ODE simulations (singular and ensemble). +let + # Creates normal and ensemble problems. + base_oprob = ODEProblem(osys, u0_alts[1], tspan, p_alts[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 failure. + for u0 in u0_alts, p in p_alts + 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 + +# Solves a nonlinear problem (EnsembleProblems are not possible for these). +let + base_nlprob = NonlinearProblem(nsys, u0_alts[1], p_alts[1]) + base_sol = solve(base_nlprob, NewtonRaphson()) + # Solves problems for all input types, checking that identical solutions are found. + for u0 in u0_alts, p in p_alts + 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(osys, u0_alts[1], p_alts[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 failure. + for u0 in u0_alts, p in p_alts + 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 diff --git a/test/sdesystem.jl b/test/sdesystem.jl index 6a6306606f..b031a2f5ab 100644 --- a/test/sdesystem.jl +++ b/test/sdesystem.jl @@ -1,434 +1,968 @@ -using ModelingToolkit, StaticArrays, LinearAlgebra -using StochasticDiffEq, SparseArrays -using Random,Test - -# Define some variables -@parameters t σ ρ β -@variables x(t) y(t) z(t) -D = Differential(t) - -eqs = [D(x) ~ σ*(y-x), - D(y) ~ x*(ρ-z)-y, - D(z) ~ x*y - β*z] - -noiseeqs = [0.1*x, - 0.1*y, - 0.1*z] - -de = SDESystem(eqs,noiseeqs,t,[x,y,z],[σ,ρ,β]) -f = eval(generate_diffusion_function(de)[1]) -@test f(ones(3),rand(3),nothing) == 0.1ones(3) - -f = SDEFunction(de) -prob = SDEProblem(SDEFunction(de),f.g,[1.0,0.0,0.0],(0.0,100.0),(10.0,26.0,2.33)) -sol = solve(prob,SRIW1(),seed=1) - -probexpr = SDEProblem(SDEFunction(de),f.g,[1.0,0.0,0.0],(0.0,100.0),(10.0,26.0,2.33)) -solexpr = solve(eval(probexpr),SRIW1(),seed=1) - -@test all(x->x==0,Array(sol-solexpr)) - -# Test no error -@test_nowarn SDEProblem(de,nothing,(0, 10.0)) - -noiseeqs_nd = [0.01*x 0.01*x*y 0.02*x*z - σ 0.01*y 0.02*x*z - ρ β 0.01*z ] -de = SDESystem(eqs,noiseeqs_nd,t,[x,y,z],[σ,ρ,β]) -f = eval(generate_diffusion_function(de)[1]) -@test f([1,2,3.0],[0.1,0.2,0.3],nothing) == [0.01*1 0.01*1*2 0.02*1*3 - 0.1 0.01*2 0.02*1*3 - 0.2 0.3 0.01*3 ] - -f = eval(generate_diffusion_function(de)[2]) -du = ones(3,3) -f(du,[1,2,3.0],[0.1,0.2,0.3],nothing) -@test du == [0.01*1 0.01*1*2 0.02*1*3 - 0.1 0.01*2 0.02*1*3 - 0.2 0.3 0.01*3 ] - -f = SDEFunction(de) -prob = SDEProblem(SDEFunction(de),f.g,[1.0,0.0,0.0],(0.0,100.0),(10.0,26.0,2.33), - noise_rate_prototype = zeros(3,3)) -sol = solve(prob,EM(),dt=0.001) - -u0map = [ - x => 1.0, - y => 0.0, - z => 0.0 -] - -parammap = [ - σ => 10.0, - β => 26.0, - ρ => 2.33 -] - -prob = SDEProblem(de,u0map,(0.0,100.0),parammap) -@test size(prob.noise_rate_prototype) == (3,3) -@test prob.noise_rate_prototype isa Matrix -sol = solve(prob,EM(),dt=0.001) - -prob = SDEProblem(de,u0map,(0.0,100.0),parammap,sparsenoise=true) -@test size(prob.noise_rate_prototype) == (3,3) -@test prob.noise_rate_prototype isa SparseMatrixCSC -sol = solve(prob,EM(),dt=0.001) - -# Test eval_expression=false -function test_SDEFunction_no_eval() - # Need to test within a function scope to trigger world age issues - f = SDEFunction(de, eval_expression=false) - @test f([1.0,0.0,0.0], (10.0,26.0,2.33), (0.0,100.0)) ≈ [-10.0, 26.0, 0.0] -end -test_SDEFunction_no_eval() - - -# modelingtoolkitize and Ito <-> Stratonovich sense -seed = 10 -Random.seed!(seed) - - -# simple 2D diagonal noise -u0 = rand(2) -t = randn() -trange = (0.0,100.0) -p = [1.01,0.87] -f1!(du,u,p,t) = (du .= p[1]*u) -σ1!(du,u,p,t) = (du .= p[2]*u) - -prob = SDEProblem(f1!,σ1!,u0,trange,p) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == p[1]*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == p[1]*u0 - 1//2*p[2]^2*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 - 1//2*p[2]^2*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == p[1]*u0 + 1//2*p[2]^2*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 + 1//2*p[2]^2*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - -# somewhat complicated 1D without explicit parameters but with explicit time-dependence -f2!(du,u,p,t) = (du[1] = sin(t) + cos(u[1])) -σ2!(du,u,p,t) = (du[1] = pi + atan(u[1])) - -u0 = rand(1) -prob = SDEProblem(f2!,σ2!,u0,trange) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == @. sin(t) + cos(u0) -@test fdif(u0,p,t) == pi .+ atan.(u0) -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == @. sin(t) + cos(u0) -fdif!(du,u0,p,t) -@test du == pi .+ atan.(u0) - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == @. sin(t) + cos(u0) - 1//2*1/(1 + u0^2)*(pi + atan(u0)) -@test fdif(u0,p,t) == pi .+ atan.(u0) -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == @. sin(t) + cos(u0) - 1//2*1/(1 + u0^2)*(pi + atan(u0)) -fdif!(du,u0,p,t) -@test du == pi .+ atan.(u0) - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) ≈ @. sin(t) + cos(u0) + 1//2*1/(1 + u0^2)*(pi + atan(u0)) -@test fdif(u0,p,t) == pi .+ atan.(u0) -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du ≈ @. sin(t) + cos(u0) + 1//2*1/(1 + u0^2)*(pi + atan(u0)) -fdif!(du,u0,p,t) -@test du == pi .+ atan.(u0) - - -# 2D diagonal noise with mixing terms (no parameters, no time-dependence) -u0 = rand(2) -t = randn() -function f3!(du,u,p,t) - du[1] = u[1]/2 - du[2] = u[2]/2 - return nothing -end -function σ3!(du,u,p,t) - du[1] = u[2] - du[2] = u[1] - return nothing -end - -prob = SDEProblem(f3!,σ3!,u0,trange,p) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == u0/2 -@test fdif(u0,p,t) == reverse(u0) -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == u0/2 -fdif!(du,u0,p,t) -@test du == reverse(u0) - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == u0*0 -@test fdif(u0,p,t) == reverse(u0) -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == u0*0 -fdif!(du,u0,p,t) -@test du == reverse(u0) - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == u0 -@test fdif(u0,p,t) == reverse(u0) -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == u0 -fdif!(du,u0,p,t) -@test du == reverse(u0) - - - -# simple 2D diagonal noise oop -u0 = rand(2) -t = randn() -p = [1.01,0.87] -f1(u,p,t) = p[1]*u -σ1(u,p,t) = p[2]*u - -prob = SDEProblem(f1,σ1,u0,trange,p) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == p[1]*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == p[1]*u0 - 1//2*p[2]^2*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 - 1//2*p[2]^2*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == p[1]*u0 + 1//2*p[2]^2*u0 -@test fdif(u0,p,t) == p[2]*u0 -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 + 1//2*p[2]^2*u0 -fdif!(du,u0,p,t) -@test du == p[2]*u0 - - -# non-diagonal noise -u0 = rand(2) -t = randn() -p = [1.01,0.3,0.6,1.2,0.2] -f4!(du,u,p,t) = du .= p[1]*u -function g4!(du,u,p,t) - du[1,1] = p[2]*u[1] - du[1,2] = p[3]*u[1] - du[2,1] = p[4]*u[1] - du[2,2] = p[5]*u[2] - return nothing -end - -prob = SDEProblem(f4!,g4!,u0,trange,noise_rate_prototype=zeros(2,2),p) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == p[1]*u0 -@test fdif(u0,p,t) == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == p[1]*u0 -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) ≈ [p[1]*u0[1] - 1//2*(p[2]^2*u0[1]+p[3]^2*u0[1]), p[1]*u0[2] - 1//2*(p[2]*p[4]*u0[1]+p[5]^2*u0[2])] -@test fdif(u0,p,t) == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du ≈ [p[1]*u0[1] - 1//2*(p[2]^2*u0[1]+p[3]^2*u0[1]), p[1]*u0[2] - 1//2*(p[2]*p[4]*u0[1]+p[5]^2*u0[2])] -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) ≈ [p[1]*u0[1] + 1//2*(p[2]^2*u0[1]+p[3]^2*u0[1]), p[1]*u0[2] + 1//2*(p[2]*p[4]*u0[1]+p[5]^2*u0[2])] -@test fdif(u0,p,t) == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du ≈ [p[1]*u0[1] + 1//2*(p[2]^2*u0[1]+p[3]^2*u0[1]), p[1]*u0[2] + 1//2*(p[2]*p[4]*u0[1]+p[5]^2*u0[2])] -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [p[2]*u0[1] p[3]*u0[1] - p[4]*u0[1] p[5]*u0[2] ] - - -# non-diagonal noise: Torus -- Strat and Ito are identical -u0 = rand(2) -t = randn() -p = rand(1) -f5!(du,u,p,t) = du .= false -function g5!(du,u,p,t) - du[1,1] = cos(p[1])*sin(u[1]) - du[1,2] = cos(p[1])*cos(u[1]) - du[1,3] = -sin(p[1])*sin(u[2]) - du[1,4] = -sin(p[1])*cos(u[2]) - du[2,1] = sin(p[1])*sin(u[1]) - du[2,2] = sin(p[1])*cos(u[1]) - du[2,3] = cos(p[1])*sin(u[2]) - du[2,4] = cos(p[1])*cos(u[2]) - return nothing -end - -prob = SDEProblem(f5!,g5!,u0,trange,noise_rate_prototype=zeros(2,4),p) -# no correction -sys = modelingtoolkitize(prob) -fdrift = eval(generate_function(sys)[1]) -fdif = eval(generate_diffusion_function(sys)[1]) -@test fdrift(u0,p,t) == 0*u0 -@test fdif(u0,p,t) == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] -fdrift! = eval(generate_function(sys)[2]) -fdif! = eval(generate_diffusion_function(sys)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == 0*u0 -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] - -# Ito -> Strat -sys2 = stochastic_integral_transform(sys,-1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == 0*u0 -@test fdif(u0,p,t) == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == 0*u0 -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] - -# Strat -> Ito -sys2 = stochastic_integral_transform(sys,1//2) -fdrift = eval(generate_function(sys2)[1]) -fdif = eval(generate_diffusion_function(sys2)[1]) -@test fdrift(u0,p,t) == 0*u0 -@test fdif(u0,p,t) == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] -fdrift! = eval(generate_function(sys2)[2]) -fdif! = eval(generate_diffusion_function(sys2)[2]) -du = similar(u0) -fdrift!(du,u0,p,t) -@test du == 0*u0 -du = similar(u0, size(prob.noise_rate_prototype)) -fdif!(du,u0,p,t) -@test du == [ cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) - sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] +using ModelingToolkit, StaticArrays, LinearAlgebra +using StochasticDiffEq, OrdinaryDiffEq, SparseArrays +using Random, Test +using Statistics +# imported as tt because `t` is used extensively below +using ModelingToolkit: t_nounits as tt, D_nounits as D, MTKParameters + +# Define some variables +@parameters σ ρ β +@variables x(tt) y(tt) z(tt) + +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +noiseeqs = [0.1 * x, + 0.1 * y, + 0.1 * z] + +# ODESystem -> SDESystem shorthand constructor +@named sys = ODESystem(eqs, tt, [x, y, z], [σ, ρ, β]) +@test SDESystem(sys, noiseeqs, name = :foo) isa SDESystem + +@named de = SDESystem(eqs, noiseeqs, tt, [x, y, z], [σ, ρ, β], tspan = (0.0, 10.0)) +de = complete(de) +f = eval(generate_diffusion_function(de)[1]) +@test f(ones(3), rand(3), nothing) == 0.1ones(3) + +f = SDEFunction(de) +prob = SDEProblem(de, [1.0, 0.0, 0.0], (0.0, 100.0), [10.0, 26.0, 2.33]) +sol = solve(prob, SRIW1(), seed = 1) + +probexpr = SDEProblem(de, [1.0, 0.0, 0.0], (0.0, 100.0), + [10.0, 26.0, 2.33]) +solexpr = solve(eval(probexpr), SRIW1(), seed = 1) + +@test all(x -> x == 0, Array(sol - solexpr)) + +noiseeqs_nd = [0.01*x 0.01*x*y 0.02*x*z + σ 0.01*y 0.02*x*z + ρ β 0.01*z] +@named de = SDESystem(eqs, noiseeqs_nd, tt, [x, y, z], [σ, ρ, β]) +de = complete(de) +f = eval(generate_diffusion_function(de)[1]) +p = MTKParameters(de, [σ => 0.1, ρ => 0.2, β => 0.3]) +@test f([1, 2, 3.0], p, nothing) == [0.01*1 0.01*1*2 0.02*1*3 + 0.1 0.01*2 0.02*1*3 + 0.2 0.3 0.01*3] + +f = eval(generate_diffusion_function(de)[2]) +du = ones(3, 3) +f(du, [1, 2, 3.0], p, nothing) +@test du == [0.01*1 0.01*1*2 0.02*1*3 + 0.1 0.01*2 0.02*1*3 + 0.2 0.3 0.01*3] + +prob = SDEProblem(de, [1.0, 0.0, 0.0], (0.0, 100.0), (σ => 10.0, ρ => 26.0, β => 2.33), + noise_rate_prototype = zeros(3, 3)) +sol = solve(prob, EM(), dt = 0.001) + +u0map = [ + x => 1.0, + y => 0.0, + z => 0.0 +] + +parammap = [ + σ => 10.0, + β => 26.0, + ρ => 2.33 +] + +prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) +@test prob.f.sys === de +@test size(prob.noise_rate_prototype) == (3, 3) +@test prob.noise_rate_prototype isa Matrix +sol = solve(prob, EM(), dt = 0.001) + +prob = SDEProblem(de, u0map, (0.0, 100.0), parammap, sparsenoise = true) +@test size(prob.noise_rate_prototype) == (3, 3) +@test prob.noise_rate_prototype isa SparseMatrixCSC +sol = solve(prob, EM(), dt = 0.001) + +# Test eval_expression=false +function test_SDEFunction_no_eval() + # Need to test within a function scope to trigger world age issues + f = SDEFunction(de, eval_expression = false) + p = MTKParameters(de, [σ => 10.0, ρ => 26.0, β => 2.33]) + @test f([1.0, 0.0, 0.0], p, (0.0, 100.0)) ≈ [-10.0, 26.0, 0.0] +end +test_SDEFunction_no_eval() + +# modelingtoolkitize and Ito <-> Stratonovich sense +seed = 10 +Random.seed!(seed) + +# simple 2D diagonal noise +u0 = rand(2) +t = randn() +trange = (0.0, 100.0) +p = [1.01, 0.87] +f1!(du, u, p, t) = (du .= p[1] * u) +σ1!(du, u, p, t) = (du .= p[2] * u) + +prob = SDEProblem(f1!, σ1!, u0, trange, p) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == p[1] * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == p[1] * u0 - 1 // 2 * p[2]^2 * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 - 1 // 2 * p[2]^2 * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == p[1] * u0 + 1 // 2 * p[2]^2 * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 + 1 // 2 * p[2]^2 * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# somewhat complicated 1D without explicit parameters but with explicit time-dependence +f2!(du, u, p, t) = (du[1] = sin(t) + cos(u[1])) +σ2!(du, u, p, t) = (du[1] = pi + atan(u[1])) + +u0 = rand(1) +prob = SDEProblem(f2!, σ2!, u0, trange) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == @. sin(t) + cos(u0) +@test fdif(u0, p, t) == pi .+ atan.(u0) +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == @. sin(t) + cos(u0) +fdif!(du, u0, p, t) +@test du == pi .+ atan.(u0) + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == @. sin(t) + cos(u0) - 1 // 2 * 1 / (1 + u0^2) * (pi + atan(u0)) +@test fdif(u0, p, t) == pi .+ atan.(u0) +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == @. sin(t) + cos(u0) - 1 // 2 * 1 / (1 + u0^2) * (pi + atan(u0)) +fdif!(du, u0, p, t) +@test du == pi .+ atan.(u0) + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) ≈ @. sin(t) + cos(u0) + 1 // 2 * 1 / (1 + u0^2) * (pi + atan(u0)) +@test fdif(u0, p, t) == pi .+ atan.(u0) +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du ≈ @. sin(t) + cos(u0) + 1 // 2 * 1 / (1 + u0^2) * (pi + atan(u0)) +fdif!(du, u0, p, t) +@test du == pi .+ atan.(u0) + +# 2D diagonal noise with mixing terms (no parameters, no time-dependence) +u0 = rand(2) +t = randn() +function f3!(du, u, p, t) + du[1] = u[1] / 2 + du[2] = u[2] / 2 + return nothing +end +function σ3!(du, u, p, t) + du[1] = u[2] + du[2] = u[1] + return nothing +end + +prob = SDEProblem(f3!, σ3!, u0, trange, p) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == u0 / 2 +@test fdif(u0, p, t) == reverse(u0) +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == u0 / 2 +fdif!(du, u0, p, t) +@test du == reverse(u0) + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == u0 * 0 +@test fdif(u0, p, t) == reverse(u0) +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == u0 * 0 +fdif!(du, u0, p, t) +@test du == reverse(u0) + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == u0 +@test fdif(u0, p, t) == reverse(u0) +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == u0 +fdif!(du, u0, p, t) +@test du == reverse(u0) + +# simple 2D diagonal noise oop +u0 = rand(2) +t = randn() +p = [1.01, 0.87] +f1(u, p, t) = p[1] * u +σ1(u, p, t) = p[2] * u + +prob = SDEProblem(f1, σ1, u0, trange, p) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == p[1] * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == p[1] * u0 - 1 // 2 * p[2]^2 * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 - 1 // 2 * p[2]^2 * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == p[1] * u0 + 1 // 2 * p[2]^2 * u0 +@test fdif(u0, p, t) == p[2] * u0 +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 + 1 // 2 * p[2]^2 * u0 +fdif!(du, u0, p, t) +@test du == p[2] * u0 + +# non-diagonal noise +u0 = rand(2) +t = randn() +p = [1.01, 0.3, 0.6, 1.2, 0.2] +f4!(du, u, p, t) = du .= p[1] * u +function g4!(du, u, p, t) + du[1, 1] = p[2] * u[1] + du[1, 2] = p[3] * u[1] + du[2, 1] = p[4] * u[1] + du[2, 2] = p[5] * u[2] + return nothing +end + +prob = SDEProblem(f4!, g4!, u0, trange, noise_rate_prototype = zeros(2, 2), p) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == p[1] * u0 +@test fdif(u0, p, t) == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == p[1] * u0 +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) ≈ [ + p[1] * u0[1] - 1 // 2 * (p[2]^2 * u0[1] + p[3]^2 * u0[1]), + p[1] * u0[2] - 1 // 2 * (p[2] * p[4] * u0[1] + p[5]^2 * u0[2]) +] +@test fdif(u0, p, t) == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du ≈ [ + p[1] * u0[1] - 1 // 2 * (p[2]^2 * u0[1] + p[3]^2 * u0[1]), + p[1] * u0[2] - 1 // 2 * (p[2] * p[4] * u0[1] + p[5]^2 * u0[2]) +] +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) ≈ [ + p[1] * u0[1] + 1 // 2 * (p[2]^2 * u0[1] + p[3]^2 * u0[1]), + p[1] * u0[2] + 1 // 2 * (p[2] * p[4] * u0[1] + p[5]^2 * u0[2]) +] +@test fdif(u0, p, t) == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du ≈ [ + p[1] * u0[1] + 1 // 2 * (p[2]^2 * u0[1] + p[3]^2 * u0[1]), + p[1] * u0[2] + 1 // 2 * (p[2] * p[4] * u0[1] + p[5]^2 * u0[2]) +] +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == [p[2]*u0[1] p[3]*u0[1] + p[4]*u0[1] p[5]*u0[2]] + +# non-diagonal noise: Torus -- Strat and Ito are identical +u0 = rand(2) +t = randn() +p = rand(1) +f5!(du, u, p, t) = du .= false +function g5!(du, u, p, t) + du[1, 1] = cos(p[1]) * sin(u[1]) + du[1, 2] = cos(p[1]) * cos(u[1]) + du[1, 3] = -sin(p[1]) * sin(u[2]) + du[1, 4] = -sin(p[1]) * cos(u[2]) + du[2, 1] = sin(p[1]) * sin(u[1]) + du[2, 2] = sin(p[1]) * cos(u[1]) + du[2, 3] = cos(p[1]) * sin(u[2]) + du[2, 4] = cos(p[1]) * cos(u[2]) + return nothing +end + +prob = SDEProblem(f5!, g5!, u0, trange, noise_rate_prototype = zeros(2, 4), p) +# no correction +sys = modelingtoolkitize(prob) +fdrift = eval(generate_function(sys)[1]) +fdif = eval(generate_diffusion_function(sys)[1]) +@test fdrift(u0, p, t) == 0 * u0 +@test fdif(u0, p, t) == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] +fdrift! = eval(generate_function(sys)[2]) +fdif! = eval(generate_diffusion_function(sys)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == 0 * u0 +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] + +# Ito -> Strat +sys2 = stochastic_integral_transform(sys, -1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == 0 * u0 +@test fdif(u0, p, t) == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == 0 * u0 +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] + +# Strat -> Ito +sys2 = stochastic_integral_transform(sys, 1 // 2) +fdrift = eval(generate_function(sys2)[1]) +fdif = eval(generate_diffusion_function(sys2)[1]) +@test fdrift(u0, p, t) == 0 * u0 +@test fdif(u0, p, t) == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] +fdrift! = eval(generate_function(sys2)[2]) +fdif! = eval(generate_diffusion_function(sys2)[2]) +du = similar(u0) +fdrift!(du, u0, p, t) +@test du == 0 * u0 +du = similar(u0, size(prob.noise_rate_prototype)) +fdif!(du, u0, p, t) +@test du == + [cos(p[1])*sin(u0[1]) cos(p[1])*cos(u0[1]) -sin(p[1])*sin(u0[2]) -sin(p[1])*cos(u0[2]) + sin(p[1])*sin(u0[1]) sin(p[1])*cos(u0[1]) cos(p[1])*sin(u0[2]) cos(p[1])*cos(u0[2])] + +# issue #819 +@testset "Combined system name collisions" begin + @independent_variables t + D = Differential(t) + eqs_short = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y + ] + noise_eqs = [y - x + x - y] + sys1 = SDESystem(eqs_short, noise_eqs, t, [x, y, z], [σ, ρ, β], name = :sys1) + sys2 = SDESystem(eqs_short, noise_eqs, t, [x, y, z], [σ, ρ, β], name = :sys1) + @test_throws ArgumentError SDESystem([sys2.y ~ sys1.z], [sys2.y], t, [], [], + systems = [sys1, sys2], name = :foo) +end + +# observed variable handling +@variables x(tt) RHS(tt) +@parameters τ +@named fol = SDESystem([D(x) ~ (1 - x) / τ], [x], tt, [x], [τ]; + observed = [RHS ~ (1 - x) / τ]) +@test isequal(RHS, @nonamespace fol.RHS) +RHS2 = RHS +@unpack RHS = fol +@test isequal(RHS, RHS2) + +# issue #1644 +using ModelingToolkit: rename +eqs = [D(x) ~ x] +noiseeqs = [0.1 * x] +@named de = SDESystem(eqs, noiseeqs, tt, [x], []) +@test nameof(rename(de, :newname)) == :newname + +@testset "observed functionality" begin + @parameters α β + @variables x(tt) y(tt) z(tt) + @variables weight(tt) + + eqs = [D(x) ~ α * x] + noiseeqs = [β * x] + dt = 1 // 2^(7) + x0 = 0.1 + + u0map = [ + x => x0 + ] + + parammap = [ + α => 1.5, + β => 1.0 + ] + + @named de = SDESystem(eqs, noiseeqs, tt, [x], [α, β], observed = [weight ~ x * 10]) + de = complete(de) + prob = SDEProblem(de, u0map, (0.0, 1.0), parammap) + sol = solve(prob, EM(), dt = dt) + @test observed(de) == [weight ~ x * 10] + @test sol[weight] == 10 * sol[x] + + @named ode = ODESystem(eqs, tt, [x], [α, β], observed = [weight ~ x * 10]) + ode = complete(ode) + odeprob = ODEProblem(ode, u0map, (0.0, 1.0), parammap) + solode = solve(odeprob, Tsit5()) + @test observed(ode) == [weight ~ x * 10] + @test solode[weight] == 10 * solode[x] +end + +@testset "Measure Transformation for variance reduction" begin + @parameters α β + @variables x(tt) y(tt) z(tt) + + # Evaluate Exp [(X_T)^2] + # SDE: X_t = x + \int_0^t α X_z dz + \int_0^t b X_z dW_z + eqs = [D(x) ~ α * x] + noiseeqs = [β * x] + + @named de = SDESystem(eqs, noiseeqs, tt, [x], [α, β]) + de = complete(de) + g(x) = x[1]^2 + dt = 1 // 2^(7) + x0 = 0.1 + + ## Standard approach + # EM with 1`000 trajectories for stepsize 2^-7 + u0map = [ + x => x0 + ] + + parammap = [ + α => 1.5, + β => 1.0 + ] + + prob = SDEProblem(de, u0map, (0.0, 1.0), parammap) + + function prob_func(prob, i, repeat) + remake(prob, seed = seeds[i]) + end + numtraj = Int(1e3) + seed = 100 + Random.seed!(seed) + seeds = rand(UInt, numtraj) + + ensemble_prob = EnsembleProblem(prob; + output_func = (sol, i) -> (g(sol.u[end]), false), + prob_func = prob_func) + + sim = solve(ensemble_prob, EM(), dt = dt, trajectories = numtraj) + μ = mean(sim) + σ = std(sim) / sqrt(numtraj) + + ## Variance reduction method + u = x + demod = complete(ModelingToolkit.Girsanov_transform(de, u; θ0 = 0.1)) + + probmod = SDEProblem(demod, u0map, (0.0, 1.0), parammap) + + ensemble_probmod = EnsembleProblem(probmod; + output_func = (sol, i) -> (g(sol[x, end]) * + sol[demod.weight, end], + false), + prob_func = prob_func) + + simmod = solve(ensemble_probmod, EM(), dt = dt, trajectories = numtraj) + μmod = mean(simmod) + σmod = std(simmod) / sqrt(numtraj) + + display("μ = $(round(μ, digits=2)) ± $(round(σ, digits=2))") + display("μmod = $(round(μmod, digits=2)) ± $(round(σmod, digits=2))") + + @test μ≈μmod atol=2σ + @test σ > σmod +end + +sts = @variables x(tt) y(tt) z(tt) +ps = @parameters σ ρ +@brownian β η +s = 0.001 +β *= s +η *= s + +eqs = [D(x) ~ σ * (y - x) + x * β, + D(y) ~ x * (ρ - z) - y + y * β + x * η, + D(z) ~ x * y - β * z + (x * z) * β] +@named sys1 = System(eqs, tt) +sys1 = structural_simplify(sys1) + +drift_eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y] + +diffusion_eqs = [s*x 0 + s*y s*x + (s * x * z)-s * z 0] + +sys2 = SDESystem(drift_eqs, diffusion_eqs, tt, sts, ps, name = :sys1) +sys2 = complete(sys2) +@test sys1 == sys2 + +prob = SDEProblem(sys1, sts .=> [1.0, 0.0, 0.0], + (0.0, 100.0), ps .=> (10.0, 26.0)) +solve(prob, LambaEulerHeun(), seed = 1) + +# Test ill-formed due to more equations than states in noise equations + +@independent_variables t +@parameters p d +@variables X(t) +eqs = [D(X) ~ p - d * X] +noise_eqs = [sqrt(p), -sqrt(d * X)] +@test_throws ArgumentError SDESystem(eqs, noise_eqs, t, [X], [p, d]; name = :ssys) + +noise_eqs = reshape([sqrt(p), -sqrt(d * X)], 1, 2) +ssys = SDESystem(eqs, noise_eqs, t, [X], [p, d]; name = :ssys) + +# SDEProblem construction with StaticArrays +# Issue#2814 +@parameters p d +@variables x(tt) +@brownian a +eqs = [D(x) ~ p - d * x + a * sqrt(p)] +@mtkbuild sys = System(eqs, tt) +u0 = @SVector[x => 10.0] +tspan = (0.0, 10.0) +ps = @SVector[p => 5.0, d => 0.5] +sprob = SDEProblem(sys, u0, tspan, ps) +@test !isinplace(sprob) +@test !isinplace(sprob.f) +@test_nowarn solve(sprob, ImplicitEM()) + +# Ensure diagonal noise generates vector noise function +@variables y(tt) +@brownian b +eqs = [D(x) ~ p - d * x + a * sqrt(p) + D(y) ~ p - d * y + b * sqrt(d)] +@mtkbuild sys = System(eqs, tt) +u0 = @SVector[x => 10.0, y => 20.0] +tspan = (0.0, 10.0) +ps = @SVector[p => 5.0, d => 0.5] +sprob = SDEProblem(sys, u0, tspan, ps) +@test sprob.f.g(sprob.u0, sprob.p, sprob.tspan[1]) isa SVector{2, Float64} +@test_nowarn solve(sprob, ImplicitEM()) + +let + @parameters σ ρ β + @variables x(t) y(t) z(t) + @brownian a + eqs = [D(x) ~ σ * (y - x) + 0.1a * x, + D(y) ~ x * (ρ - z) - y + 0.1a * y, + D(z) ~ x * y - β * z + 0.1a * z] + + @mtkbuild de = System(eqs, t) + + u0map = [ + x => 1.0, + y => 0.0, + z => 0.0 + ] + + parammap = [ + σ => 10.0, + β => 26.0, + ρ => 2.33 + ] + prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) + # TODO: re-enable this when we support scalar noise + @test solve(prob, SOSRI()).retcode == ReturnCode.Success +end + +let # test to make sure that scalar noise always receive the same kicks + @variables x(t) y(t) + @brownian a + eqs = [D(x) ~ a, + D(y) ~ a] + + @mtkbuild de = System(eqs, t) + prob = SDEProblem(de, [x => 0, y => 0], (0.0, 10.0), []) + sol = solve(prob, SOSRI()) + @test sol.u[end][1] == sol.u[end][2] +end + +let # test that diagonal noise is correctly handled + @parameters σ ρ β + @variables x(t) y(t) z(t) + @brownian a b c + eqs = [D(x) ~ σ * (y - x) + 0.1a * x, + D(y) ~ x * (ρ - z) - y + 0.1b * y, + D(z) ~ x * y - β * z + 0.1c * z] + + @mtkbuild de = System(eqs, t) + + u0map = [ + x => 1.0, + y => 0.0, + z => 0.0 + ] + + parammap = [ + σ => 10.0, + β => 26.0, + ρ => 2.33 + ] + + prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) + # SOSRI only works for diagonal and scalar noise + @test solve(prob, SOSRI()).retcode == ReturnCode.Success +end + +@testset "Non-diagonal noise check" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownian a b c d e f + eqs = [D(x) ~ σ * (y - x) + 0.1a * x + d, + D(y) ~ x * (ρ - z) - y + 0.1b * y + e, + D(z) ~ x * y - β * z + 0.1c * z + f] + @mtkbuild de = System(eqs, tt) + + u0map = [ + x => 1.0, + y => 0.0, + z => 0.0 + ] + + parammap = [ + σ => 10.0, + β => 26.0, + ρ => 2.33 + ] + + prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) + # SOSRI only works for diagonal and scalar noise + @test_throws ErrorException solve(prob, SOSRI()).retcode==ReturnCode.Success + # ImplicitEM does work for non-diagonal noise + @test solve(prob, ImplicitEM()).retcode == ReturnCode.Success + @test size(ModelingToolkit.get_noiseeqs(de)) == (3, 6) +end + +@testset "Diagonal noise, less brownians than equations" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownian a b + eqs = [D(x) ~ σ * (y - x) + 0.1a * x, # One brownian + D(y) ~ x * (ρ - z) - y + 0.1b * y, # Another brownian + D(z) ~ x * y - β * z] # no brownians -- still diagonal + @mtkbuild de = System(eqs, tt) + + u0map = [ + x => 1.0, + y => 0.0, + z => 0.0 + ] + + parammap = [ + σ => 10.0, + β => 26.0, + ρ => 2.33 + ] + + prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) + @test solve(prob, SOSRI()).retcode == ReturnCode.Success +end + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + @brownian b + @mtkbuild sys = System([D(x) ~ x + b], t) + prob = @test_nowarn SDEProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob, ImplicitEM()) +end + +@testset "Issue#3212: Noise dependent on observed" begin + sts = @variables begin + x(t) = 1.0 + input(t) + [input = true] + end + ps = @parameters a = 2 + @brownian η + + eqs = [D(x) ~ -a * x + (input + 1) * η + input ~ 0.0] + + sys = System(eqs, t, sts, ps; name = :name) + sys = structural_simplify(sys) + @test ModelingToolkit.get_noiseeqs(sys) ≈ [1.0] + prob = SDEProblem(sys, [], (0.0, 1.0), []) + @test_nowarn solve(prob, RKMil()) +end + +@testset "Observed variables retained after `structural_simplify`" begin + @variables x(t) y(t) z(t) + @brownian a + @mtkbuild sys = System([D(x) ~ x + a, D(y) ~ y + a, z ~ x + y], t) + @test sys isa SDESystem + @test length(observed(sys)) == 1 + prob = SDEProblem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test prob[z] ≈ 2.0 +end + +@testset "SDESystem to ODESystem" begin + @variables x(t) y(t) z(t) + @testset "Scalar noise" begin + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, z ~ x + y], [x, y, 3], + t, [x, y, z], [], is_scalar_noise = true) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 3 + for eq in equations(odesys) + ModelingToolkit.isdiffeq(eq) || continue + @test length(arguments(eq.rhs)) == 4 + end + end + + @testset "Non-scalar vector noise" begin + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, z ~ x + y], [x, y, 0], + t, [x, y, z], [], is_scalar_noise = false) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 1 + for eq in equations(odesys) + ModelingToolkit.isdiffeq(eq) || continue + @test length(arguments(eq.rhs)) == 2 + end + end + + @testset "Matrix noise" begin + noiseeqs = [x+y y+z z+x + 2y 2z 2x + z+1 x+1 y+1] + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, D(z) ~ z], noiseeqs, t, [x, y, z], []) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 3 + for eq in equations(odesys) + @test length(arguments(eq.rhs)) == 4 + end + end +end + +@testset "`structural_simplify(::SDESystem)`" begin + @variables x(t) y(t) + @mtkbuild sys = SDESystem( + [D(x) ~ x, y ~ 2x], [x, 0], t, [x, y], []; is_scalar_noise = true) + @test sys isa SDESystem + @test length(equations(sys)) == 1 + @test length(ModelingToolkit.get_noiseeqs(sys)) == 1 + @test length(observed(sys)) == 1 +end + +# Test validating types of states +@testset "Validate input types" begin + @parameters p d + @variables X(t)::Int64 + @brownian z + eq2 = D(X) ~ p - d * X + z + @test_throws ArgumentError @mtkbuild ssys = System([eq2], t) + noiseeq = [1] + @test_throws ArgumentError @named ssys = SDESystem([eq2], [noiseeq], t) +end + +@testset "SDEFunctionExpr" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + + eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + noiseeqs = [0.1 * x, + 0.1 * y, + 0.1 * z] + + @named sys = ODESystem(eqs, tt, [x, y, z], [σ, ρ, β]) + + @named de = SDESystem(eqs, noiseeqs, tt, [x, y, z], [σ, ρ, β], tspan = (0.0, 10.0)) + de = complete(de) + + f = SDEFunctionExpr(de) + @test f isa Expr + + @testset "Configuration Tests" begin + # Test with `tgrad` + f_tgrad = SDEFunctionExpr(de; tgrad = true) + @test f_tgrad isa Expr + + # Test with `jac` + f_jac = SDEFunctionExpr(de; jac = true) + @test f_jac isa Expr + + # Test with sparse Jacobian + f_sparse = SDEFunctionExpr(de; sparse = true) + @test f_sparse isa Expr + end + + @testset "Ordering Tests" begin + dvs = [z, y, x] + ps = [β, ρ, σ] + f_order = SDEFunctionExpr(de, dvs, ps) + @test f_order isa Expr + end +end + +@testset "SDESystem Equality with events" begin + @variables X(t) + @parameters p d + @brownian a + seq = D(X) ~ p - d * X + a + @mtkbuild ssys1 = System([seq], t; name = :ssys) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 == ssys2 # true + + continuous_events = [[X ~ 1.0] => [X ~ X + 5.0]] + discrete_events = [5.0 => [d ~ d / 2.0]] + + @mtkbuild ssys1 = System([seq], t; name = :ssys, continuous_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 !== ssys2 + + @mtkbuild ssys1 = System([seq], t; name = :ssys, discrete_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 !== ssys2 + + @mtkbuild ssys1 = System([seq], t; name = :ssys, continuous_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys, discrete_events) + @test ssys1 !== ssys2 +end + +@testset "Error when constructing SDESystem without `structural_simplify`" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownian a + eqs = [D(x) ~ σ * (y - x) + 0.1a * x, + D(y) ~ x * (ρ - z) - y + 0.1a * y, + D(z) ~ x * y - β * z + 0.1a * z] + + @named de = System(eqs, t) + de = complete(de) + + u0map = [x => 1.0, y => 0.0, z => 0.0] + parammap = [σ => 10.0, β => 26.0, ρ => 2.33] + + @test_throws ErrorException("SDESystem constructed by defining Brownian variables with @brownian must be simplified by calling `structural_simplify` before a SDEProblem can be constructed.") SDEProblem( + de, u0map, (0.0, 100.0), parammap) + de = structural_simplify(de) + @test SDEProblem(de, u0map, (0.0, 100.0), parammap) isa SDEProblem +end diff --git a/test/serialization.jl b/test/serialization.jl index ac8968cb8c..e10de51299 100644 --- a/test/serialization.jl +++ b/test/serialization.jl @@ -1,13 +1,15 @@ -using ModelingToolkit, SciMLBase, Serialization +using ModelingToolkit, SciMLBase, Serialization, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters t @variables x(t) -D = Differential(t) -sys = ODESystem([D(x) ~ -0.5*x], defaults=Dict(x=>1.0)) +@named sys = ODESystem([D(x) ~ -0.5 * x], t, defaults = Dict(x => 1.0)) +sys = complete(sys) for prob in [ - eval(ModelingToolkit.ODEProblem{false}(sys, nothing, nothing, SciMLBase.NullParameters())), - eval(ModelingToolkit.ODEProblemExpr{false}(sys, nothing, nothing, SciMLBase.NullParameters())) + eval(ModelingToolkit.ODEProblem{false}(sys, nothing, nothing, + SciMLBase.NullParameters())), + eval(ModelingToolkit.ODEProblemExpr{false}(sys, nothing, nothing, + SciMLBase.NullParameters())) ] _fn = tempname() @@ -23,5 +25,47 @@ end include("../examples/rc_model.jl") io = IOBuffer() write(io, rc_model) -sys = include_string(@__MODULE__, String(take!(io))) -@test sys == flatten(rc_model) +str = String(take!(io)) +sys = include_string(@__MODULE__, str) +@test sys == flatten(rc_model) # this actually kind of works, but the variables would have different identities. + +# check answer +ss = structural_simplify(rc_model) +all_obs = [o.lhs for o in observed(ss)] +prob = ODEProblem(ss, [capacitor.v => 0.0], (0, 0.1)) +sol = solve(prob, ImplicitEuler()) + +## Check ODESystem with Observables ---------- +ss_exp = ModelingToolkit.toexpr(ss) +ss_ = complete(eval(ss_exp)) +prob_ = ODEProblem(ss_, [capacitor.v => 0.0], (0, 0.1)) +sol_ = solve(prob_, ImplicitEuler()) +@test sol[all_obs] == sol_[all_obs] + +## Check ODEProblemExpr with Observables ----------- + +# build the observable function expression +obs_exps = [] +for var in all_obs + f = ModelingToolkit.build_explicit_observed_function(ss, var; expression = true) + sym = ModelingToolkit.getname(var) |> string + ex = :(if name == Symbol($sym) + return $f(u0, p, t) + end) + push!(obs_exps, ex) +end +# observedfun expression for ODEFunctionExpr +observedfun_exp = :(function obs(var, u0, p, t) + if var isa AbstractArray + return obs.(var, (u0,), (p,), (t,)) + end + name = ModelingToolkit.getname(var) + $(obs_exps...) +end) + +# ODEProblemExpr with observedfun_exp included +probexpr = ODEProblemExpr{true}(ss, [capacitor.v => 0.0], (0, 0.1); observedfun_exp); +prob_obs = eval(probexpr) +sol_obs = solve(prob_obs, ImplicitEuler()) +@show all_obs +@test sol_obs[all_obs] == sol[all_obs] diff --git a/test/simplify.jl b/test/simplify.jl index 8239cc6eb7..4252e3262e 100644 --- a/test/simplify.jl +++ b/test/simplify.jl @@ -2,16 +2,16 @@ using ModelingToolkit using ModelingToolkit: value using Test -@parameters t +@independent_variables t @variables x(t) y(t) z(t) -null_op = 0*t +null_op = 0 * t @test isequal(simplify(null_op), 0) -one_op = 1*t +one_op = 1 * t @test isequal(simplify(one_op), t) -identity_op = Num(Term(identity,[x.val])) +identity_op = Num(Term(identity, [value(x)])) @test isequal(simplify(identity_op), x) minus_op = -x @@ -20,9 +20,10 @@ simplify(minus_op) @variables x -@test toexpr(expand_derivatives(Differential(x)((x-2)^2))) == :($(+)(-4, $(*)(2, x))) -@test toexpr(expand_derivatives(Differential(x)((x-2)^3))) == :($(*)(3, $(^)($(+)(-2, x), 2))) -@test toexpr(simplify(x+2+3)) == :($(+)(5, x)) +@test toexpr(expand_derivatives(Differential(x)((x - 2)^2))) == :($(*)(2, $(+)(-2, x))) +@test toexpr(expand_derivatives(Differential(x)((x - 2)^3))) == + :($(*)(3, $(^)($(+)(-2, x), 2))) +@test toexpr(simplify(x + 2 + 3)) == :($(+)(5, x)) d1 = Differential(x)((-2 + x)^2) d2 = Differential(x)(d1) @@ -36,13 +37,14 @@ d3 = Differential(x)(d2) # 699 using SymbolicUtils: substitute -@parameters t a(t) b(t) +@independent_variables t +@parameters a(t) b(t) # back and forth substitution does not work for parameters with dependencies term = value(a) -term2 = substitute(term, a=>b) +term2 = substitute(term, a => b) @test ModelingToolkit.isparameter(term2) @test isequal(term2, b) -term3 = substitute(term2, b=>a) +term3 = substitute(term2, b => a) @test ModelingToolkit.isparameter(term3) @test isequal(term3, a) diff --git a/test/split_parameters.jl b/test/split_parameters.jl new file mode 100644 index 0000000000..18fdb49a48 --- /dev/null +++ b/test/split_parameters.jl @@ -0,0 +1,310 @@ +using ModelingToolkit, Test +using ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEq +using DataInterpolations +using BlockArrays: BlockedArray +using ModelingToolkit: t_nounits as t, D_nounits as D +using ModelingToolkit: MTKParameters, ParameterIndex, NONNUMERIC_PORTION +using SciMLStructures: Tunable, Discrete, Constants, Initials +using StaticArrays: SizedVector +using SymbolicIndexingInterface: is_parameter, getp + +x = [1, 2.0, false, [1, 2, 3], Parameter(1.0)] + +y = ModelingToolkit.promote_to_concrete(x) +@test eltype(y) == Union{Float64, Parameter{Float64}, Vector{Int64}} + +y = ModelingToolkit.promote_to_concrete(x; tofloat = false) +@test eltype(y) == Union{Bool, Float64, Int64, Parameter{Float64}, Vector{Int64}} + +x = [1, 2.0, false, [1, 2, 3]] +y = ModelingToolkit.promote_to_concrete(x) +@test eltype(y) == Union{Float64, Vector{Int64}} + +x = Any[1, 2.0, false] +y = ModelingToolkit.promote_to_concrete(x; tofloat = false) +@test eltype(y) == Union{Bool, Float64, Int64} + +y = ModelingToolkit.promote_to_concrete(x; use_union = false) +@test eltype(y) == Float64 + +x = Float16[1.0, 2.0, 3.0] +y = ModelingToolkit.promote_to_concrete(x) +@test eltype(y) == Float16 + +# ------------------------ Mixed Single Values and Vector + +dt = 4e-4 +t_end = 10.0 +time = 0:dt:t_end +x = @. time^2 + 1.0 + +struct Interpolator + data::Vector{Float64} + dt::Float64 +end + +function (i::Interpolator)(t) + return i.data[round(Int, t / i.dt + 1)] +end +@register_symbolic (i::Interpolator)(t) + +get_value(interp::Interpolator, t) = interp(t) +@register_symbolic get_value(interp::Interpolator, t) + +Symbolics.derivative(::typeof(get_value), args::NTuple{2, Any}, ::Val{2}) = 0 + +function Sampled(; name, interp = Interpolator(Float64[], 0.0)) + pars = @parameters begin + interpolator::Interpolator = interp + end + + vars = [] + systems = @named begin + output = RealOutput() + end + + eqs = [ + output.u ~ get_value(interpolator, t) + ] + + return ODESystem(eqs, t, vars, [interpolator]; name, systems) +end + +vars = @variables y(t) dy(t) ddy(t) +@named src = Sampled(; interp = Interpolator(x, dt)) +@named int = Integrator() + +eqs = [y ~ src.output.u + D(y) ~ dy + D(dy) ~ ddy + connect(src.output, int.input)] + +@named sys = ODESystem(eqs, t, vars, []; systems = [int, src]) +s = complete(sys) +sys = structural_simplify(sys) +prob = ODEProblem( + sys, [], (0.0, t_end), [s.src.interpolator => Interpolator(x, dt)]; + tofloat = false) +sol = solve(prob, ImplicitEuler()); +@test sol.retcode == ReturnCode.Success +@test sol[y][end] == x[end] + +#TODO: remake becomes more complicated now, how to improve? +defs = ModelingToolkit.defaults(sys) +defs[s.src.interpolator] = Interpolator(2x, dt) +p′ = ModelingToolkit.MTKParameters(sys, defs) +prob′ = remake(prob; p = p′) +sol = solve(prob′, ImplicitEuler()); +@test sol.retcode == ReturnCode.Success +@test sol[y][end] == 2x[end] + +# ------------------------ Mixed Type Converted to float (default behavior) + +vars = @variables y(t)=1 dy(t)=0 ddy(t)=0 +pars = @parameters a=1.0 b=2.0 c=3 +eqs = [D(y) ~ dy * a + D(dy) ~ ddy * b + ddy ~ sin(t) * c] + +@named model = ODESystem(eqs, t, vars, pars) +sys = structural_simplify(model; split = false) + +tspan = (0.0, t_end) +prob = ODEProblem(sys, [], tspan, []; build_initializeprob = false) + +@test prob.p isa Vector{Float64} +sol = solve(prob, ImplicitEuler()); +@test sol.retcode == ReturnCode.Success + +# ------------------------ Mixed Type Conserved + +prob = ODEProblem( + sys, [], tspan, []; tofloat = false, build_initializeprob = false) + +sol = solve(prob, ImplicitEuler()); +@test sol.retcode == ReturnCode.Success + +# ------------------------- Bug +using ModelingToolkit, LinearAlgebra +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkitStandardLibrary.Blocks: t +using ModelingToolkit: connect + +"A wrapper function to make symbolic indexing easier" +function wr(sys) + ODESystem(Equation[], ModelingToolkit.get_iv(sys), systems = [sys], name = :a_wrapper) +end +indexof(sym, syms) = findfirst(isequal(sym), syms) + +# Parameters +m1 = 1.0 +m2 = 1.0 +k = 10.0 # Spring stiffness +c = 3.0 # Damping coefficient + +@named inertia1 = Inertia(; J = m1) +@named inertia2 = Inertia(; J = m2) +@named spring = Spring(; c = k) +@named damper = Damper(; d = c) +@named torque = Torque(use_support = false) + +function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return @named model = ODESystem(eqs, + t; + systems = [torque, inertia1, inertia2, spring, damper, u]) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name, guesses = [spring.flange_a.phi => 0.0]) +end + +model = SystemModel() # Model with load disturbance +@named d = Step(start_time = 1.0, duration = 10.0, offset = 0.0, height = 1.0) # Disturbance +model_outputs = [model.inertia1.w, model.inertia2.w, model.inertia1.phi, model.inertia2.phi] # This is the state realization we want to control +inputs = [model.torque.tau.u] +op = [model.torque.tau.u => 0.0] +matrices, ssys = ModelingToolkit.linearize( + wr(model), inputs, model_outputs; op) + +# Design state-feedback gain using LQR +# Define cost matrices +x_costs = [model.inertia1.w => 1.0 + model.inertia2.w => 1.0 + model.inertia1.phi => 1.0 + model.inertia2.phi => 1.0] +L = randn(1, 4) # Post-multiply by `C` to get the correct input to the controller + +# This old definition of MatrixGain will work because the parameter space does not include K (an Array term) +# @component function MatrixGainAlt(K::AbstractArray; name) +# nout, nin = size(K, 1), size(K, 2) +# @named input = RealInput(; nin = nin) +# @named output = RealOutput(; nout = nout) +# eqs = [output.u[i] ~ sum(K[i, j] * input.u[j] for j in 1:nin) for i in 1:nout] +# compose(ODESystem(eqs, t, [], []; name = name), [input, output]) +# end + +@named state_feedback = MatrixGain(K = -L) # Build negative feedback into the feedback matrix +@named add = Add(; k1 = 1.0, k2 = 1.0) # To add the control signal and the disturbance + +connections = [[state_feedback.input.u[i] ~ model_outputs[i] for i in 1:4] + connect(d.output, :d, add.input1) + connect(add.input2, state_feedback.output) + connect(add.output, :u, model.torque.tau)] +@named closed_loop = ODESystem(connections, t, systems = [model, state_feedback, add, d]) +S = get_sensitivity(closed_loop, :u) + +@testset "Indexing MTKParameters with ParameterIndex" begin + ps = MTKParameters(collect(1.0:10.0), collect(11.0:20.0), + (BlockedArray([true, false, false, true], [2, 2]), + BlockedArray([[1 2; 3 4], [2 4; 6 8]], [1, 1])), + # (BlockedArray([[true, false], [false, true]]), BlockedArray([[[1 2; 3 4]], [[2 4; 6 8]]])), + ([5, 6],), + (["hi", "bye"], [:lie, :die]), ()) + @test ps[ParameterIndex(Tunable(), 1)] == 1.0 + @test ps[ParameterIndex(Tunable(), 2:4)] == collect(2.0:4.0) + @test ps[ParameterIndex(Tunable(), reshape(4:7, 2, 2))] == reshape(4.0:7.0, 2, 2) + @test ps[ParameterIndex(Initials(), 1)] == 11.0 + @test ps[ParameterIndex(Initials(), 2:4)] == collect(12.0:14.0) + @test ps[ParameterIndex(Initials(), reshape(4:7, 2, 2))] == reshape(14.0:17.0, 2, 2) + @test ps[ParameterIndex(Discrete(), (2, 1, 2, 2))] == 4 + @test ps[ParameterIndex(Discrete(), (2, 2))] == [2 4; 6 8] + @test ps[ParameterIndex(Constants(), (1, 1))] == 5 + @test ps[ParameterIndex(NONNUMERIC_PORTION, (2, 2))] == :die + + ps[ParameterIndex(Tunable(), 1)] = 1.5 + ps[ParameterIndex(Tunable(), 2:4)] = [2.5, 3.5, 4.5] + ps[ParameterIndex(Tunable(), reshape(5:8, 2, 2))] = [5.5 7.5; 6.5 8.5] + ps[ParameterIndex(Initials(), 1)] = 11.5 + ps[ParameterIndex(Initials(), 2:4)] = [12.5, 13.5, 14.5] + ps[ParameterIndex(Initials(), reshape(5:8, 2, 2))] = [15.5 17.5; 16.5 18.5] + ps[ParameterIndex(Discrete(), (2, 1, 2, 2))] = 5 + @test ps[ParameterIndex(Tunable(), 1:8)] == collect(1.0:8.0) .+ 0.5 + @test ps[ParameterIndex(Initials(), 1:8)] == collect(11.0:18.0) .+ 0.5 + @test ps[ParameterIndex(Discrete(), (2, 1, 2, 2))] == 5 +end + +@testset "Callable parameters" begin + @testset "As FunctionWrapper" begin + _f1(x) = 2x + struct Foo end + (::Foo)(x) = 3x + @variables x(t) + @parameters fn(::Real) = _f1 + @mtkbuild sys = ODESystem(D(x) ~ fn(t), t) + @test is_parameter(sys, fn) + @test ModelingToolkit.defaults(sys)[fn] == _f1 + + getter = getp(sys, fn) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + @inferred getter(prob) + # cannot be inferred better since `FunctionWrapper` is only known to return `Real` + @inferred Vector{<:Real} prob.f(prob.u0, prob.p, prob.tspan[1]) + sol = solve(prob, Tsit5(); abstol = 1e-10, reltol = 1e-10) + @test sol.u[end][] ≈ 2.0 + + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [fn => Foo()]) + @inferred getter(prob) + @inferred Vector{<:Real} prob.f(prob.u0, prob.p, prob.tspan[1]) + sol = solve(prob; abstol = 1e-10, reltol = 1e-10) + @test sol.u[end][] ≈ 2.5 + end + + @testset "Concrete function type" begin + ts = 0.0:0.1:1.0 + interp = LinearInterpolation(ts .^ 2, ts; extrapolate = true) + @variables x(t) + @parameters (fn::typeof(interp))(..) + @mtkbuild sys = ODESystem(D(x) ~ fn(x), t) + @test is_parameter(sys, fn) + getter = getp(sys, fn) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [fn => interp]) + @inferred getter(prob) + @inferred prob.f(prob.u0, prob.p, prob.tspan[1]) + @test_nowarn sol = solve(prob, Tsit5()) + @test_nowarn prob.ps[fn] = LinearInterpolation(ts .^ 3, ts; extrapolate = true) + @test_nowarn sol = solve(prob) + end +end + +@testset "" begin + @mtkmodel SubSystem begin + @parameters begin + c = 1 + end + @variables begin + x(t) + end + @equations begin + D(x) ~ c * x + end + end + + @mtkmodel ApexSystem begin + @components begin + subsys = SubSystem() + end + @parameters begin + k = 1 + end + @variables begin + y(t) + end + @equations begin + D(y) ~ k * y + subsys.x + end + end + + @named sys = ApexSystem() + sysref = complete(sys) + sys2 = complete(sys; split = true, flatten = false) + ps = Set(full_parameters(sys2)) + @test sysref.k in ps + @test sysref.subsys.c in ps + @test length(ps) == 2 +end diff --git a/test/state_selection.jl b/test/state_selection.jl new file mode 100644 index 0000000000..b8404d1f26 --- /dev/null +++ b/test/state_selection.jl @@ -0,0 +1,280 @@ +using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +sts = @variables x1(t) x2(t) x3(t) x4(t) +params = @parameters u1(t) u2(t) u3(t) u4(t) +eqs = [x1 + x2 + u1 ~ 0 + x1 + x2 + x3 + u2 ~ 0 + x1 + D(x3) + x4 + u3 ~ 0 + 2 * D(D(x1)) + D(D(x2)) + D(D(x3)) + D(x4) + u4 ~ 0] +@named sys = ODESystem(eqs, t) + +let dd = dummy_derivative(sys) + has_dx1 = has_dx2 = false + for eq in equations(dd) + vars = ModelingToolkit.vars(eq) + has_dx1 |= D(x1) in vars || D(D(x1)) in vars + has_dx2 |= D(x2) in vars || D(D(x2)) in vars + end + @test has_dx1 ⊻ has_dx2 # only one of x1 and x2 can be a dummy derivative + @test length(unknowns(dd)) == length(equations(dd)) < 9 +end + +@test_skip let pss = partial_state_selection(sys) + @test length(equations(pss)) == 1 + @test length(unknowns(pss)) == 2 + @test length(equations(ode_order_lowering(pss))) == 2 +end + +@parameters σ ρ β +@variables x(t) y(t) z(t) a(t) u(t) F(t) + +eqs = [D(x) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + β + 0 ~ z - x + y + 0 ~ a + z + u ~ z + a] + +lorenz1 = ODESystem(eqs, t, name = :lorenz1) +let al1 = alias_elimination(lorenz1) + let lss = partial_state_selection(al1) + @test length(equations(lss)) == 2 + end +end + +# 1516 +let + @connector function Fluid_port(; name, p = 101325.0, m = 0.0, T = 293.15) + sts = @variables p(t) [guess = p] m(t) [guess = m, connect = Flow] T(t) [ + guess = T, connect = Stream] + ODESystem(Equation[], t, sts, []; name = name) + end + + #this one is for latter + @connector function Heat_port(; name, Q = 0.0, T = 293.15) + sts = @variables T(t) [guess = T] Q(t) [guess = Q, connect = Flow] + ODESystem(Equation[], t, sts, []; name = name) + end + + # like ground but for fluid systems (fluid_port.m is expected to be zero in closed loop) + function Compensator(; name, p = 101325.0, T_back = 273.15) + @named fluid_port = Fluid_port() + ps = @parameters p=p T_back=T_back + eqs = [fluid_port.p ~ p + fluid_port.T ~ T_back] + compose(ODESystem(eqs, t, [], ps; name = name), fluid_port) + end + + function Source(; name, delta_p = 100, T_feed = 293.15) + @named supply_port = Fluid_port() # expected to feed connected pipe -> m<0 + @named return_port = Fluid_port() # expected to receive from connected pipe -> m>0 + ps = @parameters delta_p=delta_p T_feed=T_feed + eqs = [supply_port.m ~ -return_port.m + supply_port.p ~ return_port.p + delta_p + supply_port.T ~ instream(supply_port.T) + return_port.T ~ T_feed] + compose(ODESystem(eqs, t, [], ps; name = name), [supply_port, return_port]) + end + + function Substation(; name, T_return = 343.15) + @named supply_port = Fluid_port() # expected to receive from connected pipe -> m>0 + @named return_port = Fluid_port() # expected to feed connected pipe -> m<0 + ps = @parameters T_return = T_return + eqs = [supply_port.m ~ -return_port.m + supply_port.p ~ return_port.p # zero pressure loss for now + supply_port.T ~ instream(supply_port.T) + return_port.T ~ T_return] + compose(ODESystem(eqs, t, [], ps; name = name), [supply_port, return_port]) + end + + function Pipe(; name, L = 1000, d = 0.1, N = 100, rho = 1000, f = 1) + @named fluid_port_a = Fluid_port() + @named fluid_port_b = Fluid_port() + ps = @parameters L=L d=d rho=rho f=f N=N + sts = @variables v(t) [guess = 0.0] dp_z(t) [guess = 0.0] + eqs = [fluid_port_a.m ~ -fluid_port_b.m + fluid_port_a.T ~ instream(fluid_port_a.T) + fluid_port_b.T ~ fluid_port_a.T + v * pi * d^2 / 4 * rho ~ fluid_port_a.m + dp_z ~ abs(v) * v * 0.5 * rho * L / d * f # pressure loss + D(v) * rho * L ~ (fluid_port_a.p - fluid_port_b.p - dp_z)] + compose(ODESystem(eqs, t, sts, ps; name = name), [fluid_port_a, fluid_port_b]) + end + function System(; name, L = 10.0) + @named compensator = Compensator() + @named source = Source() + @named substation = Substation() + @named supply_pipe = Pipe(L = L) + @named return_pipe = Pipe(L = L) + subs = [compensator, source, substation, supply_pipe, return_pipe] + ps = @parameters L = L + eqs = [connect(compensator.fluid_port, source.supply_port) + connect(source.supply_port, supply_pipe.fluid_port_a) + connect(supply_pipe.fluid_port_b, substation.supply_port) + connect(substation.return_port, return_pipe.fluid_port_b) + connect(return_pipe.fluid_port_a, source.return_port)] + compose(ODESystem(eqs, t, [], ps; name = name), subs) + end + + @named system = System(L = 10) + @unpack supply_pipe, return_pipe = system + sys = structural_simplify(system) + u0 = [ + sys.supply_pipe.v => 0.1, sys.return_pipe.v => 0.1, D(supply_pipe.v) => 0.0, + D(return_pipe.fluid_port_a.m) => 0.0, + D(supply_pipe.fluid_port_a.m) => 0.0] + prob1 = ODEProblem(sys, [], (0.0, 10.0), [], guesses = u0) + prob2 = ODEProblem(sys, [], (0.0, 10.0), [], guesses = u0) + prob3 = DAEProblem(sys, D.(unknowns(sys)) .=> 0.0, [], (0.0, 10.0), guesses = u0) + @test solve(prob1, FBDF()).retcode == ReturnCode.Success + #@test solve(prob2, FBDF()).retcode == ReturnCode.Success + @test solve(prob3, DFBDF()).retcode == ReturnCode.Success +end + +# 1537 +let + @variables begin + p_1(t) + p_2(t) + rho_1(t) + rho_2(t) + rho_3(t) + u_1(t) + u_2(t) + u_3(t) + mo_1(t) + mo_2(t) + mo_3(t) + Ek_1(t) + Ek_2(t) + Ek_3(t) + end + + @parameters dx=100 f=0.3 pipe_D=0.4 + + eqs = [p_1 ~ 1.2e5 + p_2 ~ 1e5 + u_1 ~ 10 + mo_1 ~ u_1 * rho_1 + mo_2 ~ u_2 * rho_2 + mo_3 ~ u_3 * rho_3 + Ek_1 ~ rho_1 * u_1 * u_1 + Ek_2 ~ rho_2 * u_2 * u_2 + Ek_3 ~ rho_3 * u_3 * u_3 + rho_1 ~ p_1 / 273.11 / 300 + rho_2 ~ (p_1 + p_2) * 0.5 / 273.11 / 300 + rho_3 ~ p_2 / 273.11 / 300 + D(rho_2) ~ (mo_1 - mo_3) / dx + D(mo_2) ~ (Ek_1 - Ek_3 + p_1 - p_2) / dx - f / 2 / pipe_D * u_2 * u_2] + + @named trans = ODESystem(eqs, t) + + sys = structural_simplify(trans) + + n = 3 + u = 0 * ones(n) + rho = 1.2 * ones(n) + + u0 = [p_1 => 1.2e5 + p_2 => 1e5 + u_1 => 0 + u_2 => 0.1 + u_3 => 0.2 + rho_1 => 1.1 + rho_2 => 1.2 + rho_3 => 1.3 + mo_1 => 0 + mo_2 => 1 + mo_3 => 2 + Ek_2 => 2 + Ek_3 => 3] + prob1 = ODEProblem(sys, [], (0.0, 0.1), guesses = u0) + prob2 = ODEProblem(sys, [], (0.0, 0.1), guesses = u0) + @test solve(prob1, FBDF()).retcode == ReturnCode.Success + @test solve(prob2, FBDF()).retcode == ReturnCode.Success +end + +let + # constant parameters ---------------------------------------------------- + A_1f = 0.0908 + A_2f = 0.036 + p_1f_0 = 1.8e6 + p_2f_0 = p_1f_0 * A_1f / A_2f + m_total = 3245 + K1 = 4.60425e-5 + K2 = 0.346725 + K3 = 0 + density = 876 + bulk = 1.2e9 + l_1f = 0.7 + x_f_fullscale = 0.025 + p_s = 200e5 + # -------------------------------------------------------------------------- + + # modelingtoolkit setup ---------------------------------------------------- + params = @parameters l_2f=0.7 damp=1e3 + vars = @variables begin + p1(t) + p2(t) + dp1(t) = 0 + dp2(t) = 0 + xf(t) = 0 + rho1(t) + rho2(t) + drho1(t) = 0 + drho2(t) = 0 + V1(t) + V2(t) + dV1(t) = 0 + dV2(t) = 0 + w(t) = 0 + dw(t) = 0 + ddw(t) = 0 + end + + defs = [p1 => p_1f_0 + p2 => p_2f_0 + rho1 => density * (1 + p_1f_0 / bulk) + rho2 => density * (1 + p_2f_0 / bulk) + V1 => l_1f * A_1f + V2 => l_2f * A_2f + D(p1) => dp1 + D(p2) => dp2 + D(w) => dw + D(dw) => ddw] + + # equations ------------------------------------------------------------------ + # sqrt -> log as a hack + flow(x, dp) = K1 * abs(dp) * abs(x) + K2 * log(abs(dp)) * abs(x) + K3 * abs(dp) * x^2 + xm = xf / x_f_fullscale + Δp1 = p_s - p1 + Δp2 = p2 + + eqs = [+flow(xm, Δp1) ~ rho1 * dV1 + drho1 * V1 + 0 ~ ifelse(w > 0.5, + (0) - (rho2 * dV2 + drho2 * V2), + (-flow(xm, Δp2)) - (rho2 * dV2 + drho2 * V2)) + V1 ~ (l_1f + w) * A_1f + V2 ~ (l_2f - w) * A_2f + dV1 ~ +dw * A_1f + dV2 ~ -dw * A_2f + rho1 ~ density * (1.0 + p1 / bulk) + rho2 ~ density * (1.0 + p2 / bulk) + drho1 ~ density * (dp1 / bulk) + drho2 ~ density * (dp2 / bulk) + D(p1) ~ dp1 + D(p2) ~ dp2 + D(w) ~ dw + D(dw) ~ ddw + xf ~ 20e-3 * (1 - cos(2 * π * 5 * t)) + 0 ~ ifelse(w > 0.5, + (m_total * ddw) - (p1 * A_1f - p2 * A_2f - damp * dw), + (m_total * ddw) - (p1 * A_1f - p2 * A_2f))] + # ---------------------------------------------------------------------------- + + # solution ------------------------------------------------------------------- + @named catapult = ODESystem(eqs, t, vars, params, defaults = defs) + sys = structural_simplify(catapult) + prob = ODEProblem(sys, [], (0.0, 0.1), [l_2f => 0.55, damp => 1e7]; jac = true) + @test solve(prob, Rodas4()).retcode == ReturnCode.Success +end diff --git a/test/static_arrays.jl b/test/static_arrays.jl new file mode 100644 index 0000000000..61177e5ab2 --- /dev/null +++ b/test/static_arrays.jl @@ -0,0 +1,27 @@ +using ModelingToolkit, SciMLBase, StaticArrays, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +@parameters σ ρ β +@variables x(t) y(t) z(t) + +eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + +@named sys = ODESystem(eqs, t) +sys = structural_simplify(sys) + +u0 = @SVector [D(x) => 2.0, + x => 1.0, + y => 0.0, + z => 0.0] + +p = @SVector [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +tspan = (0.0, 100.0) +prob_mtk = ODEProblem(sys, u0, tspan, p) + +@test !SciMLBase.isinplace(prob_mtk) +@test prob_mtk.u0 isa SArray diff --git a/test/steadystatesystems.jl b/test/steadystatesystems.jl index 06fc0453b0..4f1b5ed063 100644 --- a/test/steadystatesystems.jl +++ b/test/steadystatesystems.jl @@ -1,20 +1,23 @@ using ModelingToolkit using SteadyStateDiffEq using Test +using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters t r +@parameters r @variables x(t) -D = Differential(t) -eqs = [D(x) ~ x^2-r] -de = ODESystem(eqs) +eqs = [D(x) ~ x^2 - r] +@named de = ODESystem(eqs, t) +de = complete(de) -for factor in [1e-1, 1e0, 1e10], u0_p in [(2.34,2.676),(22.34,1.632),(.3,15.676),(0.3,0.006)] - u0 = [x => factor*u0_p[1]] - p = [r => factor*u0_p[2]] - ss_prob = SteadyStateProblem(de,u0,p) - sol = solve(ss_prob,SSRootfind()).u[1] - @test abs(sol^2 - factor*u0_p[2]) < 1e-8 - ss_prob = SteadyStateProblemExpr(de,u0,p) - sol_expr = solve(eval(ss_prob),SSRootfind()).u[1] - @test all(x->x==0,sol-sol_expr) +for factor in [1e-1, 1e0, 1e10], + u0_p in [(2.34, 2.676), (22.34, 1.632), (0.3, 15.676), (0.3, 0.006)] + + u0 = [x => factor * u0_p[1]] + p = [r => factor * u0_p[2]] + ss_prob = SteadyStateProblem(de, u0, p) + sol = solve(ss_prob, SSRootfind()).u[1] + @test abs(sol^2 - factor * u0_p[2]) < 1e-8 + ss_prob = SteadyStateProblem(de, u0, p) + sol_expr = solve(ss_prob, SSRootfind()).u[1] + @test all(x -> x == 0, sol - sol_expr) end diff --git a/test/stream_connectors.jl b/test/stream_connectors.jl new file mode 100644 index 0000000000..834ebce1a7 --- /dev/null +++ b/test/stream_connectors.jl @@ -0,0 +1,513 @@ +using Test +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@connector function TwoPhaseFluidPort(; name, P = 0.0, m_flow = 0.0, h_outflow = 0.0) + pars = @parameters begin + rho + bulk + viscosity + end + + vars = @variables begin + (h_outflow(t) = h_outflow), [connect = Stream] + (m_flow(t) = m_flow), [connect = Flow] + P(t) = P + end + + ODESystem(Equation[], t, vars, pars; name = name) +end + +@connector function TwoPhaseFluid(; name, R, B, V) + pars = @parameters begin + rho = R + bulk = B + viscosity = V + end + + vars = @variables begin + m_flow(t), [connect = Flow] + end + + # equations --------------------------- + eqs = Equation[m_flow ~ 0] + + ODESystem(eqs, t, vars, pars; name) +end + +function MassFlowSource_h(; name, + h_in = 420e3, + m_flow_in = -0.01) + pars = @parameters begin + h_in = h_in + m_flow_in = m_flow_in + end + + vars = @variables begin + P(t) + end + + @named port = TwoPhaseFluidPort() + + subs = [port] + + eqns = Equation[] + + push!(eqns, port.P ~ P) + push!(eqns, port.m_flow ~ -m_flow_in) + push!(eqns, port.h_outflow ~ h_in) + + compose(ODESystem(eqns, t, vars, pars; name = name), subs) +end + +# Simplified components. +function AdiabaticStraightPipe(; name, + kwargs...) + vars = [] + pars = [] + + @named port_a = TwoPhaseFluidPort() + @named port_b = TwoPhaseFluidPort() + + subs = [port_a; port_b] + + eqns = Equation[] + + push!(eqns, connect(port_a, port_b)) + sys = ODESystem(eqns, t, vars, pars; name = name) + sys = compose(sys, subs) +end + +function SmallBoundary_Ph(; name, + P_in = 1e6, + h_in = 400e3) + vars = [] + + pars = @parameters begin + P = P_in + h = h_in + end + + @named port1 = TwoPhaseFluidPort() + + subs = [port1] + + eqns = Equation[] + + push!(eqns, port1.P ~ P) + push!(eqns, port1.h_outflow ~ h) + + compose(ODESystem(eqns, t, vars, pars; name = name), subs) +end + +# N1M1 model and test code. +function N1M1(; name, + P_in = 1e6, + h_in = 400e3, + kwargs...) + @named port_a = TwoPhaseFluidPort() + @named source = SmallBoundary_Ph(P_in = P_in, h_in = h_in) + + subs = [port_a; source] + + eqns = Equation[] + + push!(eqns, connect(source.port1, port_a)) + + sys = ODESystem(eqns, t, [], [], name = name) + sys = compose(sys, subs) +end + +@named fluid = TwoPhaseFluid(; R = 876, B = 1.2e9, V = 0.034) +@named n1m1 = N1M1() +@named pipe = AdiabaticStraightPipe() +@named sink = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) + +eqns = [connect(n1m1.port_a, pipe.port_a) + connect(pipe.port_b, sink.port)] + +@named sys = ODESystem(eqns, t) + +eqns = [domain_connect(fluid, n1m1.port_a) + connect(n1m1.port_a, pipe.port_a) + connect(pipe.port_b, sink.port)] + +@named n1m1Test = ODESystem(eqns, t, [], []; systems = [fluid, n1m1, pipe, sink]) + +@test_nowarn structural_simplify(n1m1Test) +@unpack source, port_a = n1m1 +ssort(eqs) = sort(eqs, by = string) +@test ssort(equations(expand_connections(n1m1))) == ssort([0 ~ port_a.m_flow + 0 ~ source.port1.m_flow - port_a.m_flow + source.port1.P ~ port_a.P + source.port1.P ~ source.P + source.port1.h_outflow ~ port_a.h_outflow + source.port1.h_outflow ~ source.h]) +@unpack port_a, port_b = pipe +@test ssort(equations(expand_connections(pipe))) == + ssort([0 ~ -port_a.m_flow - port_b.m_flow + 0 ~ port_a.m_flow + 0 ~ port_b.m_flow + port_a.P ~ port_b.P + port_a.h_outflow ~ instream(port_b.h_outflow) + port_b.h_outflow ~ instream(port_a.h_outflow)]) +@test ssort(equations(expand_connections(sys))) == + ssort([0 ~ n1m1.port_a.m_flow + pipe.port_a.m_flow + 0 ~ pipe.port_b.m_flow + sink.port.m_flow + n1m1.port_a.P ~ pipe.port_a.P + pipe.port_b.P ~ sink.port.P]) +@test ssort(equations(expand_connections(n1m1Test))) == + ssort([0 ~ -pipe.port_a.m_flow - pipe.port_b.m_flow + 0 ~ n1m1.source.port1.m_flow - n1m1.port_a.m_flow + 0 ~ n1m1.port_a.m_flow + pipe.port_a.m_flow + 0 ~ pipe.port_b.m_flow + sink.port.m_flow + fluid.m_flow ~ 0 + n1m1.port_a.P ~ pipe.port_a.P + n1m1.source.port1.P ~ n1m1.port_a.P + n1m1.source.port1.P ~ n1m1.source.P + n1m1.source.port1.h_outflow ~ n1m1.port_a.h_outflow + n1m1.source.port1.h_outflow ~ n1m1.source.h + pipe.port_a.P ~ pipe.port_b.P + pipe.port_a.h_outflow ~ sink.port.h_outflow + pipe.port_b.P ~ sink.port.P + pipe.port_b.h_outflow ~ n1m1.port_a.h_outflow + sink.port.P ~ sink.P + sink.port.h_outflow ~ sink.h_in + sink.port.m_flow ~ -sink.m_flow_in]) + +# N1M2 model and test code. +function N1M2(; name, + P_in = 1e6, + h_in = 400e3, + kwargs...) + @named port_a = TwoPhaseFluidPort() + @named port_b = TwoPhaseFluidPort() + + @named source = SmallBoundary_Ph(P_in = P_in, h_in = h_in) + + subs = [port_a; port_b; source] + + eqns = Equation[] + + push!(eqns, connect(source.port1, port_a)) + push!(eqns, connect(source.port1, port_b)) + + sys = ODESystem(eqns, t, [], [], name = name) + sys = compose(sys, subs) +end + +@named n1m2 = N1M2() +@named sink1 = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) +@named sink2 = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) + +eqns = [connect(n1m2.port_a, sink1.port) + connect(n1m2.port_b, sink2.port)] + +@named sys = ODESystem(eqns, t) +@named n1m2Test = compose(sys, n1m2, sink1, sink2) +@test_nowarn structural_simplify(n1m2Test) + +@named n1m2 = N1M2() +@named pipe1 = AdiabaticStraightPipe() +@named pipe2 = AdiabaticStraightPipe() +@named sink1 = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) +@named sink2 = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) + +eqns = [connect(n1m2.port_a, pipe1.port_a) + connect(pipe1.port_b, sink1.port) + connect(n1m2.port_b, pipe2.port_a) + connect(pipe2.port_b, sink2.port)] + +@named sys = ODESystem(eqns, t) +@named n1m2AltTest = compose(sys, n1m2, pipe1, pipe2, sink1, sink2) +@test_nowarn structural_simplify(n1m2AltTest) + +# N2M2 model and test code. +function N2M2(; name, + kwargs...) + @named port_a = TwoPhaseFluidPort() + @named port_b = TwoPhaseFluidPort() + @named pipe = AdiabaticStraightPipe() + + subs = [port_a; port_b; pipe] + + eqns = Equation[] + + push!(eqns, connect(port_a, pipe.port_a)) + push!(eqns, connect(pipe.port_b, port_b)) + + sys = ODESystem(eqns, t, [], [], name = name) + sys = compose(sys, subs) +end + +@named n2m2 = N2M2() +@named source = MassFlowSource_h(m_flow_in = -0.01, h_in = 400e3) +@named sink = SmallBoundary_Ph(P_in = 1e6, h_in = 400e3) + +eqns = [connect(source.port, n2m2.port_a) + connect(n2m2.port_b, sink.port1)] + +@named sys = ODESystem(eqns, t) +@named n2m2Test = compose(sys, n2m2, source, sink) +@test_nowarn structural_simplify(n2m2Test) + +# stream var +@named sp1 = TwoPhaseFluidPort() +@named sp2 = TwoPhaseFluidPort() +@named sys = ODESystem([connect(sp1, sp2)], t) +sys_exp = expand_connections(compose(sys, [sp1, sp2])) +@test ssort(equations(sys_exp)) == ssort([0 ~ -sp1.m_flow - sp2.m_flow + 0 ~ sp1.m_flow + 0 ~ sp2.m_flow + sp1.P ~ sp2.P + sp1.h_outflow ~ ModelingToolkit.instream(sp2.h_outflow) + sp2.h_outflow ~ ModelingToolkit.instream(sp1.h_outflow)]) + +# array var +@connector function VecPin(; name) + sts = @variables v(t)[1:2]=[1.0, 0.0] i(t)[1:2]=1.0 [connect = Flow] + ODESystem(Equation[], t, [sts...;], []; name = name) +end + +@named vp1 = VecPin() +@named vp2 = VecPin() +@named vp3 = VecPin() + +@named simple = ODESystem([connect(vp1, vp2, vp3)], t) +sys = expand_connections(compose(simple, [vp1, vp2, vp3])) +@test ssort(equations(sys)) == ssort([0 .~ collect(vp1.i) + 0 .~ collect(vp2.i) + 0 .~ collect(vp3.i) + vp1.v[1] ~ vp2.v[1] + vp1.v[2] ~ vp2.v[2] + vp1.v[1] ~ vp3.v[1] + vp1.v[2] ~ vp3.v[2] + 0 ~ -vp1.i[1] - vp2.i[1] - vp3.i[1] + 0 ~ -vp1.i[2] - vp2.i[2] - vp3.i[2]]) + +@connector function VectorHeatPort(; name, N = 100, T0 = 0.0, Q0 = 0.0) + @variables (T(t))[1:N]=T0 (Q(t))[1:N]=Q0 [connect = Flow] + ODESystem(Equation[], t, [T; Q], []; name = name) +end + +@test_nowarn @named a = VectorHeatPort() + +# -------------------------------------------------- +# Test the new Domain feature + +sys_ = expand_connections(n1m1Test) +sys_defs = ModelingToolkit.defaults(sys_) +csys = complete(n1m1Test) +@test Symbol(sys_defs[csys.pipe.port_a.rho]) == Symbol(csys.fluid.rho) +@test Symbol(sys_defs[csys.pipe.port_b.rho]) == Symbol(csys.fluid.rho) + +# Testing the domain feature with non-stream system... + +@connector function HydraulicPort(; P, name) + pars = @parameters begin + p_int = P + rho + bulk + viscosity + end + + vars = @variables begin + p(t) = p_int + dm(t), [connect = Flow] + end + + # equations --------------------------- + eqs = Equation[] + + ODESystem(eqs, t, vars, pars; name, defaults = [dm => 0]) +end + +@connector function Fluid(; name, R, B, V) + pars = @parameters begin + rho = R + bulk = B + viscosity = V + end + + vars = @variables begin + dm(t), [connect = Flow] + end + + # equations --------------------------- + eqs = [ + dm ~ 0 + ] + + ODESystem(eqs, t, vars, pars; name) +end + +function StepSource(; P, name) + pars = @parameters begin + p_int = P + end + + vars = [] + + # nodes ------------------------------- + systems = @named begin + H = HydraulicPort(; P = p_int) + end + + # equations --------------------------- + eqs = [ + H.p ~ p_int * (t > 0.01) + ] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function StaticVolume(; P, V, name) + pars = @parameters begin + p_int = P + vol = V + end + + vars = @variables begin + p(t) = p_int + vrho(t) + drho(t) = 0 + end + + # nodes ------------------------------- + systems = @named begin + H = HydraulicPort(; P = p_int) + end + + # fluid props ------------------------ + rho_0 = H.rho + + # equations --------------------------- + eqs = [D(vrho) ~ drho + vrho ~ rho_0 * (1 + p / H.bulk) + H.p ~ p + H.dm ~ drho * V] + + ODESystem(eqs, t, vars, pars; name, systems, + defaults = [vrho => rho_0 * (1 + p_int / H.bulk)]) +end + +function PipeBase(; P, R, name) + pars = @parameters begin + p_int = P + resistance = R + end + + vars = [] + + # nodes ------------------------------- + systems = @named begin + HA = HydraulicPort(; P = p_int) + HB = HydraulicPort(; P = p_int) + end + + # equations --------------------------- + eqs = [HA.p - HB.p ~ HA.dm * resistance / HA.viscosity + 0 ~ HA.dm + HB.dm + domain_connect(HA, HB)] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function Pipe(; P, R, name) + pars = @parameters begin + p_int = P + resistance = R + end + + vars = [] + + systems = @named begin + HA = HydraulicPort(; P = p_int) + HB = HydraulicPort(; P = p_int) + p12 = PipeBase(; P = p_int, R = resistance) + v1 = StaticVolume(; P = p_int, V = 0.01) + v2 = StaticVolume(; P = p_int, V = 0.01) + end + + eqs = [connect(v1.H, p12.HA, HA) + connect(v2.H, p12.HB, HB)] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +function TwoFluidSystem(; name) + pars = [] + vars = [] + + # nodes ------------------------------- + systems = @named begin + fluid_a = Fluid(; R = 876, B = 1.2e9, V = 0.034) + source_a = StepSource(; P = 10e5) + pipe_a = Pipe(; P = 0, R = 1e6) + volume_a = StaticVolume(; P = 0, V = 0.1) + + fluid_b = Fluid(; R = 1000, B = 2.5e9, V = 0.00034) + source_b = StepSource(; P = 10e5) + pipe_b = Pipe(; P = 0, R = 1e6) + volume_b = StaticVolume(; P = 0, V = 0.1) + end + + # equations --------------------------- + eqs = [connect(fluid_a, source_a.H) + connect(source_a.H, pipe_a.HA) + connect(pipe_a.HB, volume_a.H) + connect(fluid_b, source_b.H) + connect(source_b.H, pipe_b.HA) + connect(pipe_b.HB, volume_b.H)] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +@named two_fluid_system = TwoFluidSystem() +sys = expand_connections(two_fluid_system) + +sys_defs = ModelingToolkit.defaults(sys) +csys = complete(two_fluid_system) + +@test Symbol(sys_defs[csys.volume_a.H.rho]) == Symbol(csys.fluid_a.rho) +@test Symbol(sys_defs[csys.volume_b.H.rho]) == Symbol(csys.fluid_b.rho) + +@test_nowarn structural_simplify(two_fluid_system) + +function OneFluidSystem(; name) + pars = [] + vars = [] + + # nodes ------------------------------- + systems = @named begin + fluid = Fluid(; R = 876, B = 1.2e9, V = 0.034) + + source_a = StepSource(; P = 10e5) + pipe_a = Pipe(; P = 0, R = 1e6) + volume_a = StaticVolume(; P = 0, V = 0.1) + + source_b = StepSource(; P = 20e5) + pipe_b = Pipe(; P = 0, R = 1e6) + volume_b = StaticVolume(; P = 0, V = 0.1) + end + + # equations --------------------------- + eqs = [connect(fluid, source_a.H, source_b.H) + connect(source_a.H, pipe_a.HA) + connect(pipe_a.HB, volume_a.H) + connect(source_b.H, pipe_b.HA) + connect(pipe_b.HB, volume_b.H)] + + ODESystem(eqs, t, vars, pars; name, systems) +end + +@named one_fluid_system = OneFluidSystem() +sys = expand_connections(one_fluid_system) + +sys_defs = ModelingToolkit.defaults(sys) +csys = complete(one_fluid_system) + +@test Symbol(sys_defs[csys.volume_a.H.rho]) == Symbol(csys.fluid.rho) +@test Symbol(sys_defs[csys.volume_b.H.rho]) == Symbol(csys.fluid.rho) + +@test_nowarn structural_simplify(one_fluid_system) diff --git a/test/structural_transformation/bareiss.jl b/test/structural_transformation/bareiss.jl new file mode 100644 index 0000000000..5d0a10c30c --- /dev/null +++ b/test/structural_transformation/bareiss.jl @@ -0,0 +1,28 @@ +using SparseArrays +using ModelingToolkit +import ModelingToolkit: bareiss!, find_pivot_col, bareiss_update!, swaprows! +import Base: swapcols! + +function det_bareiss!(M) + parity = 1 + _swaprows!(M, i, j) = (i != j && (parity = -parity); swaprows!(M, i, j)) + _swapcols!(M, i, j) = (i != j && (parity = -parity); swapcols!(M, i, j)) + # We only look at the last entry, so we don't care that the sub-diagonals are + # garbage. + zero!(M, i, j) = nothing + rank = bareiss!(M, (_swapcols!, _swaprows!, bareiss_update!, zero!); + find_pivot = find_pivot_col) + return parity * M[end, end] +end + +@testset "bareiss tests" begin + # copy gives a dense matrix + @testset "bareiss tests: $T" for T in (copy, sparse) + # matrix determinant pairs + for (M, d) in ((BigInt[9 1 8 0; 0 0 8 7; 7 6 8 3; 2 9 7 7], -1), + (BigInt[1 big(2)^65+1; 3 4], 4 - 3 * (big(2)^65 + 1))) + # test that the determinant was correctly computed + @test det_bareiss!(T(M)) == d + end + end +end diff --git a/test/structural_transformation/index_reduction.jl b/test/structural_transformation/index_reduction.jl index 65ea3ae68f..d7f19e1fa2 100644 --- a/test/structural_transformation/index_reduction.jl +++ b/test/structural_transformation/index_reduction.jl @@ -1,136 +1,109 @@ using ModelingToolkit -using LightGraphs +using Graphs using DiffEqBase using Test using UnPack +using ModelingToolkit: t_nounits as t, D_nounits as D # Define some variables -@parameters t L g +@parameters L g @variables x(t) y(t) w(t) z(t) T(t) xˍt(t) yˍt(t) xˍˍt(t) yˍˍt(t) -D = Differential(t) -eqs2 = [D(D(x)) ~ T*x, - D(D(y)) ~ T*y - g, - 0 ~ x^2 + y^2 - L^2] -pendulum2 = ODESystem(eqs2, t, [x, y, T], [L, g], name=:pendulum) +eqs2 = [D(D(x)) ~ T * x, + D(D(y)) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum2 = ODESystem(eqs2, t, [x, y, T], [L, g], name = :pendulum) lowered_sys = ModelingToolkit.ode_order_lowering(pendulum2) -lowered_eqs = [D(xˍt) ~ T*x, - D(yˍt) ~ T*y - g, - D(x) ~ xˍt, - D(y) ~ yˍt, - 0 ~ x^2 + y^2 - L^2,] -@test ODESystem(lowered_eqs, t, [xˍt, yˍt, x, y, T], [L, g]) == lowered_sys +lowered_eqs = [D(xˍt) ~ T * x, + D(yˍt) ~ T * y - g, + D(x) ~ xˍt, + D(y) ~ yˍt, + 0 ~ x^2 + y^2 - L^2] +@test ODESystem(lowered_eqs, t, [xˍt, yˍt, x, y, T], [L, g], name = :pendulum) == + lowered_sys @test isequal(equations(lowered_sys), lowered_eqs) # Simple pendulum in cartesian coordinates eqs = [D(x) ~ w, - D(y) ~ z, - D(w) ~ T*x, - D(z) ~ T*y - g, - 0 ~ x^2 + y^2 - L^2] -pendulum = ODESystem(eqs, t, [x, y, w, z, T], [L, g], name=:pendulum) - -pendulum = initialize_system_structure(pendulum) -sss = structure(pendulum) -@unpack graph, fullvars, varassoc = sss -@test StructuralTransformations.matching(sss, varassoc .== 0) == map(x -> x == 0 ? StructuralTransformations.UNASSIGNED : x, [1, 2, 3, 4, 0, 0, 0, 0, 0]) - -sys, assign, eqassoc = StructuralTransformations.pantelides!(pendulum) -sss = structure(sys) -@unpack graph, fullvars, varassoc = sss -scc = StructuralTransformations.find_scc(graph, assign) -@test sort(sort.(scc)) == [ - [1], - [2], - [3, 4, 7, 8, 9], - [5], - [6], - ] - -@test graph.fadjlist == [[1, 7], [2, 8], [3, 5, 9], [4, 6, 9], [5, 6], [1, 2, 5, 6], [1, 3, 7, 10], [2, 4, 8, 11], [1, 2, 5, 6, 10, 11]] -@test varassoc == [10, 11, 0, 0, 1, 2, 3, 4, 0, 0, 0] -#1: D(x) ~ w -#2: D(y) ~ z -#3: D(w) ~ T*x -#4: D(z) ~ T*y - g -#5: 0 ~ x^2 + y^2 - L^2 -# ---- -#6: D(eq:5) -> 0 ~ 2xx'+ 2yy' -#7: D(eq:1) -> D(D(x)) ~ D(w) -> D(xˍt) ~ D(w) -> D(xˍt) ~ T*x -#8: D(eq:2) -> D(D(y)) ~ D(z) -> D(y_t) ~ T*y - g -#9: D(eq:6) -> 0 ~ 2xx'' + 2x'x' + 2yy'' + 2y'y' -# [1, 2, 3, 4, 5, 6, 7, 8, 9] -@test eqassoc == [7, 8, 0, 0, 6, 9, 0, 0, 0] + D(y) ~ z, + D(w) ~ T * x, + D(z) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum = ODESystem(eqs, t, [x, y, w, z, T], [L, g], name = :pendulum) + +state = TearingState(pendulum) +@unpack graph, var_to_diff = state.structure +@test StructuralTransformations.maximal_matching(graph, eq -> true, + v -> var_to_diff[v] === nothing) == + map(x -> x == 0 ? StructuralTransformations.unassigned : x, + [1, 2, 3, 4, 0, 0, 0, 0, 0]) using ModelingToolkit -@parameters t L g +@parameters L g @variables x(t) y(t) w(t) z(t) T(t) xˍt(t) yˍt(t) -D = Differential(t) idx1_pendulum = [D(x) ~ w, - D(y) ~ z, - #0 ~ x^2 + y^2 - L^2, - D(w) ~ T*x, - D(z) ~ T*y - g, - # intermediate 1: 0 ~ 2x*D(x) + 2y*D(y) - 0, - # intermediate 2(a): 0 ~ 2x*w + 2y*z - 0, (substitute D(x) and D(y)) - #0 ~ 2x*w + 2y*z, - # D(D(x)) ~ D(w) and substitute the rhs - D(xˍt) ~ T*x, - # D(D(y)) ~ D(z) and substitute the rhs - D(yˍt) ~ T*y - g, - # 2x*D(D(x)) + 2*D(x)*D(x) + 2y*D(D(y)) + 2*D(y)*D(y) and - # substitute the rhs - 0 ~ 2x*(T*x) + 2*xˍt*xˍt + 2y*(T*y - g) + 2*yˍt*yˍt] -idx1_pendulum = ODESystem(idx1_pendulum, t, [x, y, w, z, xˍt, yˍt, T], [L, g]) -first_order_idx1_pendulum = ode_order_lowering(idx1_pendulum) + D(y) ~ z, + #0 ~ x^2 + y^2 - L^2, + D(w) ~ T * x, + D(z) ~ T * y - g, + # intermediate 1: 0 ~ 2x*D(x) + 2y*D(y) - 0, + # intermediate 2(a): 0 ~ 2x*w + 2y*z - 0, (substitute D(x) and D(y)) + #0 ~ 2x*w + 2y*z, + # D(D(x)) ~ D(w) and substitute the rhs + D(xˍt) ~ T * x, + # D(D(y)) ~ D(z) and substitute the rhs + D(yˍt) ~ T * y - g, + # 2x*D(D(x)) + 2*D(x)*D(x) + 2y*D(D(y)) + 2*D(y)*D(y) and + # substitute the rhs + 0 ~ 2x * (T * x) + 2 * xˍt * xˍt + 2y * (T * y - g) + 2 * yˍt * yˍt] +@named idx1_pendulum = ODESystem(idx1_pendulum, t, [x, y, w, z, xˍt, yˍt, T], [L, g]) +first_order_idx1_pendulum = complete(ode_order_lowering(idx1_pendulum)) using OrdinaryDiffEq using LinearAlgebra -prob = ODEProblem(ODEFunction(first_order_idx1_pendulum), - # [x, y, w, z, xˍt, yˍt, T] - [1, 0, 0, 0, 0, 0, 0.0],# 0, 0, 0, 0], - (0, 10.0), - [1, 9.8], - mass_matrix=calculate_massmatrix(first_order_idx1_pendulum)) +prob = ODEProblem(first_order_idx1_pendulum, + # [x, y, w, z, xˍt, yˍt, T] + [1, 0, 0, 0, 0, 0, 0.0],# 0, 0, 0, 0], + (0, 10.0), + [1, 9.8]) sol = solve(prob, Rodas5()); -#plot(sol, vars=(1, 2)) +#plot(sol, idxs=(1, 2)) -new_sys = dae_index_lowering(ModelingToolkit.ode_order_lowering(pendulum2)) +new_sys = complete(dae_index_lowering(ModelingToolkit.ode_order_lowering(pendulum2))) prob_auto = ODEProblem(new_sys, - [D(x)=>0, - D(y)=>0, - x=>1, - y=>0, - T=>0.0], - (0, 100.0), - [1, 9.8]) + [D(x) => 0, + D(y) => 0, + x => 1, + y => 0, + T => 0.0], + (0, 100.0), + [L => 1, g => 9.8]) sol = solve(prob_auto, Rodas5()); -#plot(sol, vars=(x, y)) +#plot(sol, idxs=(x, y)) # Define some variables -@parameters t L g +@parameters L g @variables x(t) y(t) T(t) -D = Differential(t) -eqs2 = [D(D(x)) ~ T*x, - D(D(y)) ~ T*y - g, - 0 ~ x^2 + y^2 - L^2] -pendulum2 = ODESystem(eqs2, t, [x, y, T], [L, g], name=:pendulum) +eqs2 = [D(D(x)) ~ T * x, + D(D(y)) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum2 = ODESystem(eqs2, t, [x, y, T], [L, g], name = :pendulum) # Turn into a first order differential equation system first_order_sys = ModelingToolkit.ode_order_lowering(pendulum2) # Perform index reduction to get an Index 1 DAE -new_sys = dae_index_lowering(first_order_sys) +new_sys = complete(dae_index_lowering(first_order_sys)) u0 = [ - D(x) => 0.0, - D(y) => 0.0, - x => 1.0, - y => 0.0, - T => 0.0 + D(x) => 0.0, + D(y) => 0.0, + x => 1.0, + y => 0.0, + T => 0.0 ] p = [ @@ -138,32 +111,59 @@ p = [ g => 9.8 ] -prob_auto = ODEProblem(new_sys,u0,(0.0,10.0),p) +prob_auto = ODEProblem(new_sys, u0, (0.0, 10.0), p) sol = solve(prob_auto, Rodas5()); -#plot(sol, vars=(D(x), y)) - -### -### More BLT/SCC tests -### - -# Test Tarjan (1972) Fig. 3 -g = [ - [2], - [3,8], - [4,7], - [5], - [3,6], - Int[], - [4,6], - [1,7], +#plot(sol, idxs=(D(x), y)) + +@test_skip begin + let pss_pendulum2 = partial_state_selection(pendulum2) + length(equations(pss_pendulum2)) <= 6 + end +end + +eqs = [D(x) ~ w, + D(y) ~ z, + D(w) ~ T * x, + D(z) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum = ODESystem(eqs, t, [x, y, w, z, T], [L, g], name = :pendulum) + +let pss_pendulum = partial_state_selection(pendulum) + # This currently selects `T` rather than `x` at top level. Needs tearing priorities to fix. + @test_broken length(equations(pss_pendulum)) == 3 +end + +let sys = structural_simplify(pendulum2) + @test length(equations(sys)) == 5 + @test length(unknowns(sys)) == 5 + + u0 = [ + x => sqrt(2) / 2, + y => sqrt(2) / 2 + ] + p = [ + L => 1.0, + g => 9.8 ] -graph = StructuralTransformations.BipartiteGraph(8, 8) -for (eq, vars) in enumerate(g), var in vars - add_edge!(graph, eq, var) + + prob_auto = ODEProblem(sys, u0, (0.0, 0.5), p, guesses = [T => 0.0]) + sol = solve(prob_auto, FBDF()) + @test sol.retcode == ReturnCode.Success + @test norm(sol[x] .^ 2 + sol[y] .^ 2 .- 1) < 1e-2 +end + +let + @parameters g + @variables x(t) [state_priority = 10] y(t) λ(t) + + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @named pend = ODESystem(eqs, t) + sys = complete(structural_simplify(pend; dummy_derivative = false)) + prob = ODEProblem( + sys, [x => 1, y => 0, D(x) => 0.0], (0.0, 10.0), [g => 1], guesses = [λ => 0.0]) + sol = solve(prob, Rodas5P()) + @test SciMLBase.successful_retcode(sol) + @test sol[x^2 + y^2][end] < 1.1 end -scc = StructuralTransformations.find_scc(graph) -@test scc == [ - [6], - [3, 4, 5, 7], - [1, 2, 8], - ] diff --git a/test/structural_transformation/runtests.jl b/test/structural_transformation/runtests.jl index b21025b737..316026c92a 100644 --- a/test/structural_transformation/runtests.jl +++ b/test/structural_transformation/runtests.jl @@ -1,5 +1,14 @@ using SafeTestsets -@safetestset "Utilities" begin include("utils.jl") end -@safetestset "Index Reduction & SCC" begin include("index_reduction.jl") end -@safetestset "Tearing" begin include("tearing.jl") end +@safetestset "Utilities" begin + include("utils.jl") +end +@safetestset "Index Reduction & SCC" begin + include("index_reduction.jl") +end +@safetestset "Tearing" begin + include("tearing.jl") +end +@safetestset "Bareiss" begin + include("bareiss.jl") +end diff --git a/test/structural_transformation/tearing.jl b/test/structural_transformation/tearing.jl index 93d81a7b69..e9cd92ec94 100644 --- a/test/structural_transformation/tearing.jl +++ b/test/structural_transformation/tearing.jl @@ -5,55 +5,64 @@ using ModelingToolkit.StructuralTransformations: SystemStructure, find_solvables using NonlinearSolve using LinearAlgebra using UnPack - +using ModelingToolkit: t_nounits as t, D_nounits as D ### ### Nonlinear system ### -@parameters t +@constants h = 1 @variables u1(t) u2(t) u3(t) u4(t) u5(t) eqs = [ - 0 ~ u1 - sin(u5), - 0 ~ u2 - cos(u1), - 0 ~ u3 - hypot(u1, u2), - 0 ~ u4 - hypot(u2, u3), - 0 ~ u5 - hypot(u4, u1), + 0 ~ u1 - sin(u5) * h, + 0 ~ u2 - cos(u1), + 0 ~ u3 - hypot(u1, u2), + 0 ~ u4 - hypot(u2, u3), + 0 ~ u5 - hypot(u4, u1) ] -sys = NonlinearSystem(eqs, [u1, u2, u3, u4, u5], []) -sys = initialize_system_structure(sys) -StructuralTransformations.find_solvables!(sys) -sss = structure(sys) -@unpack graph, solvable_graph, fullvars = sss +@named sys = NonlinearSystem(eqs, [u1, u2, u3, u4, u5], []) +state = TearingState(sys) +StructuralTransformations.find_solvables!(state) io = IOBuffer() -show(io, sss) +show(io, MIME"text/plain"(), state.structure) prt = String(take!(io)) -if VERSION >= v"1.6" -@test prt == "Incidence matrix: - × × ⋅ ⋅ ⋅ - × ⋅ × ⋅ ⋅ - × ⋅ × × ⋅ - ⋅ ⋅ × × × - × × ⋅ ⋅ ×" -end +@test occursin("Incidence matrix:", prt) +@test occursin("×", prt) +@test occursin("⋅", prt) + +buff = IOBuffer() +io = IOContext(buff, :mtk_limit => false) +show(io, MIME"text/plain"(), state.structure) +prt = String(take!(buff)) +@test occursin("SystemStructure", prt) # u1 = f1(u5) # u2 = f2(u1) # u3 = f3(u1, u2) # u4 = f4(u2, u3) # u5 = f5(u4, u1) -sys = initialize_system_structure(sys) -find_solvables!(sys) -sss = structure(sys) -@unpack graph, solvable_graph, assign, partitions = sss -@test graph.fadjlist == [[1, 2], [1, 3], [1, 3, 4], [3, 4, 5], [1, 2, 5]] -@test solvable_graph.fadjlist == map(x->[x], [1, 3, 4, 5, 2]) - -tornsys = tearing(sys) -sss = structure(tornsys) -@unpack graph, solvable_graph, assign, partitions = sss -@test graph.fadjlist == [[1]] -@test partitions == [StructuralTransformations.SystemPartition([], [], [1], [1])] +state = TearingState(sys) +find_solvables!(state) +@unpack structure, fullvars = state +@unpack graph, solvable_graph = state.structure +int2var = Dict(eachindex(fullvars) .=> fullvars) +graph2vars(graph) = map(is -> Set(map(i -> int2var[i], is)), graph.fadjlist) +@test graph2vars(graph) == [Set([u1, u5]) + Set([u1, u2]) + Set([u1, u3, u2]) + Set([u4, u3, u2]) + Set([u4, u1, u5])] +@test graph2vars(solvable_graph) == [Set([u1]) + Set([u2]) + Set([u3]) + Set([u4]) + Set([u5])] + +state = TearingState(tearing(sys)) +let sss = state.structure + @unpack graph = sss + @test graph2vars(graph) == [Set([u1, u2, u5])] +end # Before: # u1 u2 u3 u4 u5 @@ -88,15 +97,15 @@ sss = structure(tornsys) # --------------------|----- # e5 [ 1 1 | 1 ] -sys = StructuralTransformations.tear_graph(StructuralTransformations.algebraic_equations_scc(sys)) -sss = structure(sys) -@unpack partitions = sss -S = StructuralTransformations.reordered_matrix(sys, partitions) -@test S == [1 0 0 0 1 - 1 1 0 0 0 - 1 1 1 0 0 - 0 1 1 1 0 - 1 0 0 1 1] +let state = TearingState(sys) + torn_matching, = tearing(state) + S = StructuralTransformations.reordered_matrix(sys, torn_matching) + @test S == [1 0 0 0 1 + 1 1 0 0 0 + 1 1 1 0 0 + 0 1 1 1 0 + 1 0 0 1 1] +end # unknowns: u5 # u1 := sin(u5) @@ -109,8 +118,8 @@ S = StructuralTransformations.reordered_matrix(sys, partitions) # unknowns: u5 # solve for # 0 = u5 - hypot(sin(u5), hypot(cos(sin(u5)), hypot(sin(u5), cos(sin(u5))))) -tornsys = tearing(sys) -@test isequal(equations(tornsys), [0 ~ u5 + (-1 * hypot(hypot(cos(sin(u5)), hypot(sin(u5), cos(sin(u5)))), sin(u5)))]) +tornsys = complete(tearing(sys)) +@test isequal(equations(tornsys), [0 ~ u5 - hypot(u4, u1)]) prob = NonlinearProblem(tornsys, ones(1)) sol = solve(prob, NewtonRaphson()) @test norm(prob.f(sol.u, sol.prob.p)) < 1e-10 @@ -118,55 +127,78 @@ sol = solve(prob, NewtonRaphson()) ### ### Simple test (edge case) ### -@parameters t @variables x(t) y(t) z(t) eqs = [ - 0 ~ x - y, - 0 ~ z + y, - 0 ~ x + z, - ] -nlsys = NonlinearSystem(eqs, [x, y, z], []) + 0 ~ x - y, + 0 ~ z + y, + 0 ~ x + z +] +@named nlsys = NonlinearSystem(eqs, [x, y, z], []) + newsys = tearing(nlsys) -@test equations(newsys) == [0 ~ z] -@test isequal(states(newsys), [z]) +@test length(equations(newsys)) <= 1 ### ### DAE system ### using ModelingToolkit, OrdinaryDiffEq, BenchmarkTools -@parameters t p +@parameters p @variables x(t) y(t) z(t) -D = Differential(t) -eqs = [ - D(x) ~ z +eqs = [D(x) ~ z * h 0 ~ x - y - 0 ~ sin(z) + y - p*t - ] -daesys = ODESystem(eqs, t) -newdaesys = tearing(daesys) -@test equations(newdaesys) == [D(x) ~ z; 0 ~ x + sin(z) - p*t] -@test isequal(states(newdaesys), [x, z]) -prob = ODAEProblem(newdaesys, [x=>1.0], (0, 1.0), [p=>0.2]) -du = [0.0]; u = [1.0]; pr = 0.2; tt = 0.1 + 0 ~ sin(z) + y - p * t] +@named daesys = ODESystem(eqs, t) +newdaesys = structural_simplify(daesys) +@test equations(newdaesys) == [D(x) ~ z; 0 ~ y + sin(z) - p * t] +@test equations(tearing_substitution(newdaesys)) == [D(x) ~ z; 0 ~ x + sin(z) - p * t] +@test isequal(unknowns(newdaesys), [x, z]) +@test isequal(unknowns(newdaesys), [x, z]) +@test_deprecated ODAEProblem(newdaesys, [x => 1.0, z => -0.5π], (0, 1.0), [p => 0.2]) +prob = ODEProblem(newdaesys, [x => 1.0, z => -0.5π], (0, 1.0), [p => 0.2]) +du = [0.0, 0.0]; +u = [1.0, -0.5π]; +pr = 0.2; +tt = 0.1; @test (@ballocated $(prob.f)($du, $u, $pr, $tt)) == 0 -@test du ≈ [-asin(u[1] - pr * tt)] atol=1e-5 +prob.f(du, u, pr, tt) +@test du≈[u[2], u[1] + sin(u[2]) - pr * tt] atol=1e-5 # test the initial guess is respected -infprob = ODAEProblem(tearing(ODESystem(eqs, t, defaults=Dict(z=>Inf))), [x=>1.0], (0, 1.0), [p=>0.2]) -@test_throws DomainError infprob.f(du, u, pr, tt) - -sol1 = solve(prob, Tsit5()) -sol2 = solve(ODEProblem{false}( - (u,p,t) -> [-asin(u[1] - pr*t)], - [1.0], - (0, 1.0), - 0.2, - ), Tsit5(), tstops=sol1.t, adaptive=false) -@test Array(sol1) ≈ Array(sol2) atol=1e-5 - -obs = build_observed_function(newdaesys, [z, y]) -@test map(u -> u[2], obs.(sol1.u, pr, sol1.t)) == first.(sol1.u) -@test map(u -> sin(u[1]), obs.(sol1.u, pr, sol1.t)) + first.(sol1.u) ≈ pr[1]*sol1.t atol=1e-5 - -@test sol1[y, :] == sol1[x, :] -@test (@. sin(sol1[z, :]) + sol1[y, :]) ≈ pr * sol1.t atol=1e-5 +@named sys = ODESystem(eqs, t, defaults = Dict(z => NaN)) +infprob = ODEProblem(structural_simplify(sys), [x => 1.0], (0, 1.0), [p => 0.2]) +infprob.f(du, infprob.u0, pr, tt) +@test any(isnan, du) + +# 1426 +function Translational_Mass(; name, m = 1.0) + sts = @variables s(t) v(t) a(t) + ps = @parameters m = m + D = Differential(t) + eqs = [D(s) ~ v + D(v) ~ a + m * a ~ 0.0] + ODESystem(eqs, t, sts, ps; name = name) +end + +m = 1.0 +@named mass = Translational_Mass(m = m) + +ms_eqs = [] + +@named _ms_model = ODESystem(ms_eqs, t) +@named ms_model = compose(_ms_model, + [mass]) + +calculate_jacobian(ms_model) +calculate_tgrad(ms_model) + +# Mass starts with velocity = 1 +u0 = [mass.s => 0.0 + mass.v => 1.0] + +sys = structural_simplify(ms_model) +@test ModelingToolkit.get_jac(sys)[] === ModelingToolkit.EMPTY_JAC +@test ModelingToolkit.get_tgrad(sys)[] === ModelingToolkit.EMPTY_TGRAD +prob_complex = ODEProblem(sys, u0, (0, 1.0)) +sol = solve(prob_complex, Tsit5()) +@test all(sol[mass.v] .== 1) diff --git a/test/structural_transformation/utils.jl b/test/structural_transformation/utils.jl index 11e9923534..6dfc107cc9 100644 --- a/test/structural_transformation/utils.jl +++ b/test/structural_transformation/utils.jl @@ -1,35 +1,284 @@ using Test using ModelingToolkit -using LightGraphs +using Graphs using SparseArrays using UnPack +using ModelingToolkit: t_nounits as t, D_nounits as D, default_toterm +using Symbolics: unwrap +const ST = StructuralTransformations # Define some variables -@parameters t L g +@parameters L g @variables x(t) y(t) w(t) z(t) T(t) -D = Differential(t) # Simple pendulum in cartesian coordinates eqs = [D(x) ~ w, - D(y) ~ z, - D(w) ~ T*x, - D(z) ~ T*y - g, - 0 ~ x^2 + y^2 - L^2] -pendulum = ODESystem(eqs, t, [x, y, w, z, T], [L, g], name=:pendulum) -sys = initialize_system_structure(pendulum) -StructuralTransformations.find_solvables!(sys) -sss = structure(sys) -@unpack graph, solvable_graph, fullvars, varassoc = sss -@test isequal(fullvars, [D(x), D(y), D(w), D(z), x, y, w, z, T]) + D(y) ~ z, + D(w) ~ T * x, + D(z) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum = ODESystem(eqs, t, [x, y, w, z, T], [L, g], name = :pendulum) +state = TearingState(pendulum) +StructuralTransformations.find_solvables!(state) +sss = state.structure +@unpack graph, solvable_graph, var_to_diff = sss @test graph.fadjlist == [[1, 7], [2, 8], [3, 5, 9], [4, 6, 9], [5, 6]] -@test graph.badjlist == 9 == length(fullvars) +@test length(graph.badjlist) == 9 @test ne(graph) == nnz(incidence_matrix(graph)) == 12 @test nv(solvable_graph) == 9 + 5 -@test varassoc == [0, 0, 0, 0, 1, 2, 3, 4, 0] +let N = nothing + @test var_to_diff == [N, N, N, N, 1, 2, 3, 4, N] +end -se = collect(StructuralTransformations.𝑠edges(graph)) +se = collect(StructuralTransformations.edges(graph)) @test se == mapreduce(vcat, enumerate(graph.fadjlist)) do (s, d) StructuralTransformations.BipartiteEdge.(s, d) end -@test_throws ArgumentError collect(StructuralTransformations.𝑑edges(graph)) -@test_throws ArgumentError collect(StructuralTransformations.edges(graph)) + +@testset "observed2graph handles unknowns inside callable parameters" begin + @variables x(t) y(t) + @parameters p(..) + g, _ = ModelingToolkit.observed2graph([y ~ p(x), x ~ 0], [y, x]) + @test ModelingToolkit.𝑠neighbors(g, 1) == [2] + @test ModelingToolkit.𝑑neighbors(g, 2) == [1] +end + +@testset "array observed used unscalarized in another observed" begin + @variables x(t) y(t)[1:2] z(t)[1:2] + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @mtkbuild sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1], y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + @test length(equations(sys)) == 1 + @test length(observed(sys)) == 7 + @test any(eq -> isequal(eq.lhs, y), observed(sys)) + @test any(eq -> isequal(eq.lhs, z), observed(sys)) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [foo => _tmp_fn]) + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 5 + @test length(equations(isys)) == 4 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end +end + +@testset "scalarized array observed calling same function multiple times" begin + @variables x(t) y(t)[1:2] + @parameters foo(::Real)[1:2] + val = Ref(0) + function _tmp_fn2(x) + val[] += 1 + return [x, 2x] + end + @mtkbuild sys = ODESystem([D(x) ~ y[1] + y[2], y ~ foo(x)], t) + @test length(equations(sys)) == 1 + @test length(observed(sys)) == 3 + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [foo => _tmp_fn2]) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 1 + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 3 + @test length(equations(isys)) == 2 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end + + @testset "CSE hack in equations(sys)" begin + val[] = 0 + @variables z(t)[1:2] + @mtkbuild sys = ODESystem( + [D(y) ~ foo(x), D(x) ~ sum(y), zeros(2) ~ foo(prod(z))], t) + @test length(equations(sys)) == 5 + @test length(observed(sys)) == 2 + prob = ODEProblem( + sys, [y => ones(2), z => 2ones(2), x => 3.0], (0.0, 1.0), [foo => _tmp_fn2]) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 2 + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 5 + @test length(equations(isys)) == 2 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && + operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end + end +end + +@testset "array and cse hacks can be disabled" begin + @testset "fully_determined = true" begin + @variables x(t) y(t)[1:2] z(t)[1:2] + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @named sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1], y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys1 = structural_simplify(sys; cse_hack = false) + @test length(observed(sys1)) == 6 + @test !any(observed(sys1)) do eq + iscall(eq.rhs) && + operation(eq.rhs) == StructuralTransformations.getindex_wrapper + end + + sys2 = structural_simplify(sys; array_hack = false) + @test length(observed(sys2)) == 5 + @test !any(observed(sys2)) do eq + iscall(eq.rhs) && operation(eq.rhs) == StructuralTransformations.change_origin + end + end + + @testset "fully_determined = false" begin + @variables x(t) y(t)[1:2] z(t)[1:2] w(t) + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @named sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1] + w, y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys1 = structural_simplify(sys; cse_hack = false, fully_determined = false) + @test length(observed(sys1)) == 6 + @test !any(observed(sys1)) do eq + iscall(eq.rhs) && + operation(eq.rhs) == StructuralTransformations.getindex_wrapper + end + + sys2 = structural_simplify(sys; array_hack = false, fully_determined = false) + @test length(observed(sys2)) == 5 + @test !any(observed(sys2)) do eq + iscall(eq.rhs) && operation(eq.rhs) == StructuralTransformations.change_origin + end + end +end + +@testset "additional passes" begin + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ x, y ~ x + t], t) + value = Ref(0) + pass(sys; kwargs...) = (value[] += 1; return sys) + structural_simplify(sys; additional_passes = [pass]) + @test value[] == 1 +end + +@testset "Distribute shifts" begin + @variables x(t) y(t) z(t) + @parameters a b c + k = ShiftIndex(t) + + # Expand shifts + @test isequal( + ST.distribute_shift(Shift(t, -1)(x + y)), Shift(t, -1)(x) + Shift(t, -1)(y)) + + expr = a * Shift(t, -2)(x) + Shift(t, 2)(y) + b + @test isequal(ST.simplify_shifts(ST.distribute_shift(Shift(t, 2)(expr))), + a * x + Shift(t, 4)(y) + b) + @test isequal(ST.distribute_shift(Shift(t, 2)(exp(z))), exp(Shift(t, 2)(z))) + @test isequal(ST.distribute_shift(Shift(t, 2)(exp(a) + b)), exp(a) + b) + + expr = a^x - log(b * y) + z * x + @test isequal(ST.distribute_shift(Shift(t, -3)(expr)), + a^(Shift(t, -3)(x)) - log(b * Shift(t, -3)(y)) + Shift(t, -3)(z) * Shift(t, -3)(x)) + + expr = x(k + 1) ~ x + x(k - 1) + @test isequal(ST.distribute_shift(Shift(t, -1)(expr)), x ~ x(k - 1) + x(k - 2)) +end + +@testset "`map_variables_to_equations`" begin + @testset "Not supported for systems without `.tearing_state`" begin + @variables x + @mtkbuild sys = OptimizationSystem(x^2) + @test_throws ArgumentError map_variables_to_equations(sys) + end + @testset "Requires simplified system" begin + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ x, y ~ 2x], t) + sys = complete(sys) + @test_throws ArgumentError map_variables_to_equations(sys) + end + @testset "`ODESystem`" begin + @variables x(t) y(t) z(t) + @mtkbuild sys = ODESystem([D(x) ~ 2x + y, y ~ x + z, z^3 + x^3 ~ 12], t) + mapping = map_variables_to_equations(sys) + @test mapping[x] == (D(x) ~ 2x + y) + @test mapping[y] == (y ~ x + z) + @test mapping[z] == (0 ~ 12 - z^3 - x^3) + @test length(mapping) == 3 + + @testset "With dummy derivatives" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild sys = ODESystem(eqs, t) + mapping = map_variables_to_equations(sys) + + yt = default_toterm(unwrap(D(y))) + xt = default_toterm(unwrap(D(x))) + xtt = default_toterm(unwrap(D(D(x)))) + @test mapping[x] == (0 ~ 1 - x^2 - y^2) + @test mapping[y] == (D(y) ~ yt) + @test mapping[D(y)] == (D(yt) ~ -g + y * λ) + @test mapping[D(x)] == (0 ~ -2xt * x - 2yt * y) + @test mapping[D(D(x))] == (xtt ~ x * λ) + @test length(mapping) == 5 + + @testset "`rename_dummy_derivatives = false`" begin + mapping = map_variables_to_equations(sys; rename_dummy_derivatives = false) + + @test mapping[x] == (0 ~ 1 - x^2 - y^2) + @test mapping[y] == (D(y) ~ yt) + @test mapping[yt] == (D(yt) ~ -g + y * λ) + @test mapping[xt] == (0 ~ -2xt * x - 2yt * y) + @test mapping[xtt] == (xtt ~ x * λ) + @test length(mapping) == 5 + end + end + @testset "DDEs" begin + function oscillator(; name, k = 1.0, τ = 0.01) + @parameters k=k τ=τ + @variables x(..)=0.1 y(t)=0.1 jcn(t)=0.0 delx(t) + eqs = [D(x(t)) ~ y, + D(y) ~ -k * x(t - τ) + jcn, + delx ~ x(t - τ)] + return System(eqs, t; name = name) + end + + systems = @named begin + osc1 = oscillator(k = 1.0, τ = 0.01) + osc2 = oscillator(k = 2.0, τ = 0.04) + end + eqs = [osc1.jcn ~ osc2.delx, + osc2.jcn ~ osc1.delx] + @named coupledOsc = System(eqs, t) + @mtkbuild sys = compose(coupledOsc, systems) + mapping = map_variables_to_equations(sys) + x1 = operation(unwrap(osc1.x)) + x2 = operation(unwrap(osc2.x)) + @test mapping[osc1.x] == (D(osc1.x) ~ osc1.y) + @test mapping[osc1.y] == (D(osc1.y) ~ osc1.jcn - osc1.k * x1(t - osc1.τ)) + @test mapping[osc1.delx] == (osc1.delx ~ x1(t - osc1.τ)) + @test mapping[osc1.jcn] == (osc1.jcn ~ osc2.delx) + @test mapping[osc2.x] == (D(osc2.x) ~ osc2.y) + @test mapping[osc2.y] == (D(osc2.y) ~ osc2.jcn - osc2.k * x2(t - osc2.τ)) + @test mapping[osc2.delx] == (osc2.delx ~ x2(t - osc2.τ)) + @test mapping[osc2.jcn] == (osc2.jcn ~ osc1.delx) + @test length(mapping) == 8 + end + end + @testset "`NonlinearSystem`" begin + @variables x y z + @mtkbuild sys = NonlinearSystem([x^2 ~ 2y^2 + 1, sin(z) ~ y, z^3 + 4z + 1 ~ 0]) + mapping = map_variables_to_equations(sys) + @test mapping[x] == (0 ~ 2y^2 + 1 - x^2) + @test mapping[y] == (y ~ sin(z)) + @test mapping[z] == (0 ~ -1 - 4z - z^3) + @test length(mapping) == 3 + end +end diff --git a/test/substitute_component.jl b/test/substitute_component.jl new file mode 100644 index 0000000000..9fb254136b --- /dev/null +++ b/test/substitute_component.jl @@ -0,0 +1,273 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary, Test +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkitStandardLibrary.Electrical +using OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D, renamespace, + NAMESPACE_SEPARATOR as NS + +@mtkmodel SignalInterface begin + @components begin + output = RealOutput() + end +end + +@mtkmodel TwoComponent begin + @components begin + component1 = OnePort() + component2 = OnePort() + source = Voltage() + signal = SignalInterface() + ground = Ground() + end + @equations begin + connect(signal.output.u, source.V.u) + connect(source.p, component1.p) + connect(component1.n, component2.p) + connect(component2.n, source.n, ground.g) + end +end + +@mtkmodel RC begin + @parameters begin + R = 1.0 + C = 1.0 + V = 1.0 + end + @components begin + component1 = Resistor(R = R) + component2 = Capacitor(C = C, v = 0.0) + source = Voltage() + constant = Constant(k = V) + ground = Ground() + end + @equations begin + connect(constant.output, source.V) + connect(source.p, component1.p) + connect(component1.n, component2.p) + connect(component2.n, source.n, ground.g) + end +end + +@testset "Replacement with connections works" begin + @named templated = TwoComponent() + @named component1 = Resistor(R = 1.0) + @named component2 = Capacitor(C = 1.0, v = 0.0) + @named signal = Constant(k = 1.0) + rsys = substitute_component(templated, templated.component1 => component1) + rcsys = substitute_component(rsys, rsys.component2 => component2) + rcsys = substitute_component(rcsys, rcsys.signal => signal) + + @named reference = RC() + + sys1 = structural_simplify(rcsys) + sys2 = structural_simplify(reference) + @test isequal(unknowns(sys1), unknowns(sys2)) + @test isequal(equations(sys1), equations(sys2)) + + prob1 = ODEProblem(sys1, [], (0.0, 10.0)) + prob2 = ODEProblem(sys2, [], (0.0, 10.0)) + + sol1 = solve(prob1, Tsit5()) + sol2 = solve(prob2, Tsit5(); saveat = sol1.t) + @test sol1.u≈sol2.u atol=1e-8 +end + +@mtkmodel BadOnePort1 begin + @components begin + p = Pin() + n = Pin() + end + @variables begin + i(t) + end + @equations begin + 0 ~ p.i + n.i + i ~ p.i + end +end + +@connector BadPin1 begin + v(t) +end + +@mtkmodel BadOnePort2 begin + @components begin + p = BadPin1() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin2 begin + v(t) + i(t) +end + +@mtkmodel BadOnePort3 begin + @components begin + p = BadPin2() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin3 begin + v(t), [input = true] + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort4 begin + @components begin + p = BadPin3() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin4 begin + v(t), [output = true] + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort5 begin + @components begin + p = BadPin4() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@mtkmodel BadPin5 begin + @variables begin + v(t) + i(t), [connect = Flow] + end +end + +@mtkmodel BadOnePort6 begin + @components begin + p = BadPin5() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin6 begin + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort7 begin + @components begin + p = BadPin6() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.i + n.i + i ~ p.i + end +end + +@mtkmodel BadOnePort8 begin + @components begin + n = Pin() + end + @variables begin + v(t) + i(t) + end +end + +@testset "Error checking" begin + @named templated = TwoComponent() + @named component1 = Resistor(R = 1.0) + @named component2 = Capacitor(C = 1.0, v = 0.0) + @test_throws ["LHS", "cannot be completed"] substitute_component( + templated, complete(templated.component1) => component1) + @test_throws ["RHS", "cannot be completed"] substitute_component( + templated, templated.component1 => complete(component1)) + @test_throws ["RHS", "not be namespaced"] substitute_component( + templated, templated.component1 => renamespace(templated, component1)) + @named resistor = Resistor(R = 1.0) + @test_throws ["RHS", "same name"] substitute_component( + templated, templated.component1 => resistor) + + @testset "Different indepvar" begin + @independent_variables tt + @named empty = ODESystem(Equation[], t) + @named outer = ODESystem(Equation[], t; systems = [empty]) + @named empty = ODESystem(Equation[], tt) + @test_throws ["independent variable"] substitute_component( + outer, outer.empty => empty) + end + + @named component1 = BadOnePort1() + @test_throws ["RHS", "unknown", "v(t)"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort2() + @test_throws ["component1$(NS)p", "i(t)"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort3() + @test_throws ["component1$(NS)p$(NS)i", "Flow"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort4() + @test_throws ["component1$(NS)p$(NS)v", "differing causality", "input"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort5() + @test_throws ["component1$(NS)p$(NS)v", "differing causality", "output"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort6() + @test_throws ["templated$(NS)component1$(NS)p", "not a connector"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort7() + @test_throws ["templated$(NS)component1$(NS)p", "DomainConnector", "RegularConnector"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort8() + @test_throws ["templated$(NS)component1", "subsystem p"] substitute_component( + templated, templated.component1 => component1) +end diff --git a/test/symbolic_events.jl b/test/symbolic_events.jl new file mode 100644 index 0000000000..804432408b --- /dev/null +++ b/test/symbolic_events.jl @@ -0,0 +1,1436 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, JumpProcesses, Test +using SciMLStructures: canonicalize, Discrete +using ModelingToolkit: SymbolicContinuousCallback, + SymbolicContinuousCallbacks, NULL_AFFECT, + get_callback, + t_nounits as t, + D_nounits as D +using StableRNGs +import SciMLBase +using SymbolicIndexingInterface +using Setfield +rng = StableRNG(12345) + +@variables x(t) = 0 + +eqs = [D(x) ~ 1] +affect = [x ~ 0] +affect_neg = [x ~ 1] + +## Test SymbolicContinuousCallback +@testset "SymbolicContinuousCallback constructors" begin + e = SymbolicContinuousCallback(eqs[]) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, NULL_AFFECT) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[], NULL_AFFECT) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs => NULL_AFFECT) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[] => NULL_AFFECT) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == NULL_AFFECT + @test e.affect_neg == NULL_AFFECT + @test e.rootfind == SciMLBase.LeftRootFind + + ## With affect + + e = SymbolicContinuousCallback(eqs[], affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[], affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs => affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[] => affect) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect + @test e.rootfind == SciMLBase.LeftRootFind + + # with only positive edge affect + + e = SymbolicContinuousCallback(eqs[], affect, affect_neg = nothing) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test isnothing(e.affect_neg) + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect, affect_neg = nothing) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test isnothing(e.affect_neg) + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect, affect_neg = nothing) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test isnothing(e.affect_neg) + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[], affect, affect_neg = nothing) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test isnothing(e.affect_neg) + @test e.rootfind == SciMLBase.LeftRootFind + + # with explicit edge affects + + e = SymbolicContinuousCallback(eqs[], affect, affect_neg = affect_neg) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect, affect_neg = affect_neg) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, affect, affect_neg = affect_neg) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[], affect, affect_neg = affect_neg) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.LeftRootFind + + # with different root finding ops + + e = SymbolicContinuousCallback( + eqs[], affect, affect_neg = affect_neg, rootfind = SciMLBase.LeftRootFind) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback( + eqs[], affect, affect_neg = affect_neg, rootfind = SciMLBase.RightRootFind) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.RightRootFind + + e = SymbolicContinuousCallback( + eqs[], affect, affect_neg = affect_neg, rootfind = SciMLBase.NoRootFind) + @test e isa SymbolicContinuousCallback + @test isequal(e.eqs, eqs) + @test e.affect == affect + @test e.affect_neg == affect_neg + @test e.rootfind == SciMLBase.NoRootFind + # test plural constructor + + e = SymbolicContinuousCallbacks(eqs[]) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == NULL_AFFECT + + e = SymbolicContinuousCallbacks(eqs) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == NULL_AFFECT + + e = SymbolicContinuousCallbacks(eqs[] => affect) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == affect + + e = SymbolicContinuousCallbacks(eqs => affect) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == affect + + e = SymbolicContinuousCallbacks([eqs[] => affect]) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == affect + + e = SymbolicContinuousCallbacks([eqs => affect]) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == affect + + e = SymbolicContinuousCallbacks(SymbolicContinuousCallbacks([eqs => affect])) + @test e isa Vector{SymbolicContinuousCallback} + @test isequal(e[].eqs, eqs) + @test e[].affect == affect +end + +@testset "ImperativeAffect constructors" begin + fmfa(o, x, i, c) = nothing + m = ModelingToolkit.ImperativeAffect(fmfa) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test m.obs == [] + @test m.obs_syms == [] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (;)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test m.obs == [] + @test m.obs_syms == [] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; observed = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; modified = (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; modified = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x), (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:x] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; y = x), (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect( + fmfa; modified = (; y = x), observed = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect( + fmfa; modified = (; y = x), observed = (; y = x), ctx = 3) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === 3 + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x), (; x), 3) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:x] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === 3 +end + +## + +@named sys = ODESystem(eqs, t, continuous_events = [x ~ 1]) +@test getfield(sys, :continuous_events)[] == + SymbolicContinuousCallback(Equation[x ~ 1], NULL_AFFECT) +@test isequal(equations(getfield(sys, :continuous_events))[], x ~ 1) +fsys = flatten(sys) +@test isequal(equations(getfield(fsys, :continuous_events))[], x ~ 1) + +@named sys2 = ODESystem([D(x) ~ 1], t, continuous_events = [x ~ 2], systems = [sys]) +@test getfield(sys2, :continuous_events)[] == + SymbolicContinuousCallback(Equation[x ~ 2], NULL_AFFECT) +@test all(ModelingToolkit.continuous_events(sys2) .== [ + SymbolicContinuousCallback(Equation[x ~ 2], NULL_AFFECT), + SymbolicContinuousCallback(Equation[sys.x ~ 1], NULL_AFFECT) +]) + +@test isequal(equations(getfield(sys2, :continuous_events))[1], x ~ 2) +@test length(ModelingToolkit.continuous_events(sys2)) == 2 +@test isequal(ModelingToolkit.continuous_events(sys2)[1].eqs[], x ~ 2) +@test isequal(ModelingToolkit.continuous_events(sys2)[2].eqs[], sys.x ~ 1) + +sys = complete(sys) +sys_nosplit = complete(sys; split = false) +sys2 = complete(sys2) +# Functions should be generated for root-finding equations +prob = ODEProblem(sys, Pair[], (0.0, 2.0)) +p0 = 0 +t0 = 0 +@test get_callback(prob) isa ModelingToolkit.DiffEqCallbacks.ContinuousCallback +cb = ModelingToolkit.generate_rootfinding_callback(sys) +cond = cb.condition +out = [0.0] +cond.rf_ip(out, [0], p0, t0) +@test out[] ≈ -1 # signature is u,p,t +cond.rf_ip(out, [1], p0, t0) +@test out[] ≈ 0 # signature is u,p,t +cond.rf_ip(out, [2], p0, t0) +@test out[] ≈ 1 # signature is u,p,t + +prob = ODEProblem(sys, Pair[], (0.0, 2.0)) +prob_nosplit = ODEProblem(sys_nosplit, Pair[], (0.0, 2.0)) +sol = solve(prob, Tsit5()) +sol_nosplit = solve(prob_nosplit, Tsit5()) +@test minimum(t -> abs(t - 1), sol.t) < 1e-10 # test that the solver stepped at the root +@test minimum(t -> abs(t - 1), sol_nosplit.t) < 1e-10 # test that the solver stepped at the root + +# Test that a user provided callback is respected +test_callback = DiscreteCallback(x -> x, x -> x) +prob = ODEProblem(sys, Pair[], (0.0, 2.0), callback = test_callback) +prob_nosplit = ODEProblem(sys_nosplit, Pair[], (0.0, 2.0), callback = test_callback) +cbs = get_callback(prob) +cbs_nosplit = get_callback(prob_nosplit) +@test cbs isa CallbackSet +@test cbs.discrete_callbacks[1] == test_callback +@test cbs_nosplit isa CallbackSet +@test cbs_nosplit.discrete_callbacks[1] == test_callback + +prob = ODEProblem(sys2, Pair[], (0.0, 3.0)) +cb = get_callback(prob) +@test cb isa ModelingToolkit.DiffEqCallbacks.VectorContinuousCallback + +cond = cb.condition +out = [0.0, 0.0] +# the root to find is 2 +cond.rf_ip(out, [0, 0], p0, t0) +@test out[1] ≈ -2 # signature is u,p,t +cond.rf_ip(out, [1, 0], p0, t0) +@test out[1] ≈ -1 # signature is u,p,t +cond.rf_ip(out, [2, 0], p0, t0) # this should return 0 +@test out[1] ≈ 0 # signature is u,p,t + +# the root to find is 1 +out = [0.0, 0.0] +cond.rf_ip(out, [0, 0], p0, t0) +@test out[2] ≈ -1 # signature is u,p,t +cond.rf_ip(out, [0, 1], p0, t0) # this should return 0 +@test out[2] ≈ 0 # signature is u,p,t +cond.rf_ip(out, [0, 2], p0, t0) +@test out[2] ≈ 1 # signature is u,p,t + +sol = solve(prob, Tsit5(); abstol = 1e-14, reltol = 1e-14) +@test minimum(t -> abs(t - 1), sol.t) < 1e-10 # test that the solver stepped at the first root +@test minimum(t -> abs(t - 2), sol.t) < 1e-10 # test that the solver stepped at the second root + +@named sys = ODESystem(eqs, t, continuous_events = [x ~ 1, x ~ 2]) # two root eqs using the same unknown +sys = complete(sys) +prob = ODEProblem(sys, Pair[], (0.0, 3.0)) +@test get_callback(prob) isa ModelingToolkit.DiffEqCallbacks.VectorContinuousCallback +sol = solve(prob, Tsit5(); abstol = 1e-14, reltol = 1e-14) +@test minimum(t -> abs(t - 1), sol.t) < 1e-10 # test that the solver stepped at the first root +@test minimum(t -> abs(t - 2), sol.t) < 1e-10 # test that the solver stepped at the second root + +## Test bouncing ball with equation affect +@variables x(t)=1 v(t)=0 + +root_eqs = [x ~ 0] +affect = [v ~ -v] + +@named ball = ODESystem([D(x) ~ v + D(v) ~ -9.8], t, continuous_events = root_eqs => affect) + +@test getfield(ball, :continuous_events)[] == + SymbolicContinuousCallback(Equation[x ~ 0], Equation[v ~ -v]) +ball = structural_simplify(ball) + +@test length(ModelingToolkit.continuous_events(ball)) == 1 + +tspan = (0.0, 5.0) +prob = ODEProblem(ball, Pair[], tspan) +sol = solve(prob, Tsit5()) +@test 0 <= minimum(sol[x]) <= 1e-10 # the ball never went through the floor but got very close +# plot(sol) + +## Test bouncing ball in 2D with walls +@variables x(t)=1 y(t)=0 vx(t)=0 vy(t)=1 + +continuous_events = [[x ~ 0] => [vx ~ -vx] + [y ~ -1.5, y ~ 1.5] => [vy ~ -vy]] + +@named ball = ODESystem( + [D(x) ~ vx + D(y) ~ vy + D(vx) ~ -9.8 + D(vy) ~ -0.01vy], t; continuous_events) + +_ball = ball +ball = structural_simplify(_ball) +ball_nosplit = structural_simplify(_ball; split = false) + +tspan = (0.0, 5.0) +prob = ODEProblem(ball, Pair[], tspan) +prob_nosplit = ODEProblem(ball_nosplit, Pair[], tspan) + +cb = get_callback(prob) +@test cb isa ModelingToolkit.DiffEqCallbacks.VectorContinuousCallback +@test getfield(ball, :continuous_events)[1] == + SymbolicContinuousCallback(Equation[x ~ 0], Equation[vx ~ -vx]) +@test getfield(ball, :continuous_events)[2] == + SymbolicContinuousCallback(Equation[y ~ -1.5, y ~ 1.5], Equation[vy ~ -vy]) +cond = cb.condition +out = [0.0, 0.0, 0.0] +cond.rf_ip(out, [0, 0, 0, 0], p0, t0) +@test out ≈ [0, 1.5, -1.5] + +sol = solve(prob, Tsit5()) +sol_nosplit = solve(prob_nosplit, Tsit5()) +@test 0 <= minimum(sol[x]) <= 1e-10 # the ball never went through the floor but got very close +@test minimum(sol[y]) ≈ -1.5 # check wall conditions +@test maximum(sol[y]) ≈ 1.5 # check wall conditions +@test 0 <= minimum(sol_nosplit[x]) <= 1e-10 # the ball never went through the floor but got very close +@test minimum(sol_nosplit[y]) ≈ -1.5 # check wall conditions +@test maximum(sol_nosplit[y]) ≈ 1.5 # check wall conditions + +# tv = sort([LinRange(0, 5, 200); sol.t]) +# plot(sol(tv)[y], sol(tv)[x], line_z=tv) +# vline!([-1.5, 1.5], l=(:black, 5), primary=false) +# hline!([0], l=(:black, 5), primary=false) + +## Test multi-variable affect +# in this test, there are two variables affected by a single event. +continuous_events = [ + [x ~ 0] => [vx ~ -vx, vy ~ -vy] +] + +@named ball = ODESystem([D(x) ~ vx + D(y) ~ vy + D(vx) ~ -1 + D(vy) ~ 0], t; continuous_events) + +ball_nosplit = structural_simplify(ball) +ball = structural_simplify(ball) + +tspan = (0.0, 5.0) +prob = ODEProblem(ball, Pair[], tspan) +prob_nosplit = ODEProblem(ball_nosplit, Pair[], tspan) +sol = solve(prob, Tsit5()) +sol_nosplit = solve(prob_nosplit, Tsit5()) +@test 0 <= minimum(sol[x]) <= 1e-10 # the ball never went through the floor but got very close +@test -minimum(sol[y]) ≈ maximum(sol[y]) ≈ sqrt(2) # the ball will never go further than √2 in either direction (gravity was changed to 1 to get this particular number) +@test 0 <= minimum(sol_nosplit[x]) <= 1e-10 # the ball never went through the floor but got very close +@test -minimum(sol_nosplit[y]) ≈ maximum(sol_nosplit[y]) ≈ sqrt(2) # the ball will never go further than √2 in either direction (gravity was changed to 1 to get this particular number) + +# tv = sort([LinRange(0, 5, 200); sol.t]) +# plot(sol(tv)[y], sol(tv)[x], line_z=tv) +# vline!([-1.5, 1.5], l=(:black, 5), primary=false) +# hline!([0], l=(:black, 5), primary=false) + +# issue https://github.com/SciML/ModelingToolkit.jl/issues/1386 +# tests that it works for ODAESystem +@variables vs(t) v(t) vmeasured(t) +eq = [vs ~ sin(2pi * t) + D(v) ~ vs - v + D(vmeasured) ~ 0.0] +ev = [sin(20pi * t) ~ 0.0] => [vmeasured ~ v] +@named sys = ODESystem(eq, t, continuous_events = ev) +sys = structural_simplify(sys) +prob = ODEProblem(sys, zeros(2), (0.0, 5.1)) +sol = solve(prob, Tsit5()) +@test all(minimum((0:0.05:5) .- sol.t', dims = 2) .< 0.0001) # test that the solver stepped every 0.05s as dictated by event +@test sol([0.25 - eps()])[vmeasured][] == sol([0.23])[vmeasured][] # test the hold property + +## https://github.com/SciML/ModelingToolkit.jl/issues/1528 +Dₜ = D + +@parameters u(t) [input = true] # Indicate that this is a controlled input +@parameters y(t) [output = true] # Indicate that this is a measured output + +function Mass(; name, m = 1.0, p = 0, v = 0) + ps = @parameters m = m + sts = @variables pos(t)=p vel(t)=v + eqs = Dₜ(pos) ~ vel + ODESystem(eqs, t, [pos, vel], ps; name) +end +function Spring(; name, k = 1e4) + ps = @parameters k = k + @variables x(t) = 0 # Spring deflection + ODESystem(Equation[], t, [x], ps; name) +end +function Damper(; name, c = 10) + ps = @parameters c = c + @variables vel(t) = 0 + ODESystem(Equation[], t, [vel], ps; name) +end +function SpringDamper(; name, k = false, c = false) + spring = Spring(; name = :spring, k) + damper = Damper(; name = :damper, c) + compose(ODESystem(Equation[], t; name), + spring, damper) +end +connect_sd(sd, m1, m2) = [sd.spring.x ~ m1.pos - m2.pos, sd.damper.vel ~ m1.vel - m2.vel] +sd_force(sd) = -sd.spring.k * sd.spring.x - sd.damper.c * sd.damper.vel +@named mass1 = Mass(; m = 1) +@named mass2 = Mass(; m = 1) +@named sd = SpringDamper(; k = 1000, c = 10) +function Model(u, d = 0) + eqs = [connect_sd(sd, mass1, mass2) + Dₜ(mass1.vel) ~ (sd_force(sd) + u) / mass1.m + Dₜ(mass2.vel) ~ (-sd_force(sd) + d) / mass2.m] + @named _model = ODESystem(eqs, t; observed = [y ~ mass2.pos]) + @named model = compose(_model, mass1, mass2, sd) +end +model = Model(sin(30t)) +sys = structural_simplify(model) +@test isempty(ModelingToolkit.continuous_events(sys)) + +let + function testsol(osys, u0, p, tspan; tstops = Float64[], paramtotest = nothing, + kwargs...) + oprob = ODEProblem(complete(osys), u0, tspan, p; kwargs...) + sol = solve(oprob, Tsit5(); tstops = tstops, abstol = 1e-10, reltol = 1e-10) + @test isapprox(sol(1.0000000001)[1] - sol(0.999999999)[1], 1.0; rtol = 1e-6) + paramtotest === nothing || (@test sol.ps[paramtotest] == 1.0) + @test isapprox(sol(4.0)[1], 2 * exp(-2.0)) + sol + end + + @parameters k t1 t2 + @variables A(t) B(t) + + cond1 = (t == t1) + affect1 = [A ~ A + 1] + cb1 = cond1 => affect1 + cond2 = (t == t2) + affect2 = [k ~ 1.0] + cb2 = cond2 => affect2 + + ∂ₜ = D + eqs = [∂ₜ(A) ~ -k * A] + @named osys = ODESystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2]) + u0 = [A => 1.0] + p = [k => 0.0, t1 => 1.0, t2 => 2.0] + tspan = (0.0, 4.0) + testsol(osys, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + cond1a = (t == t1) + affect1a = [A ~ A + 1, B ~ A] + cb1a = cond1a => affect1a + @named osys1 = ODESystem(eqs, t, [A, B], [k, t1, t2], discrete_events = [cb1a, cb2]) + u0′ = [A => 1.0, B => 0.0] + sol = testsol( + osys1, u0′, p, tspan; tstops = [1.0, 2.0], check_length = false, paramtotest = k) + @test sol(1.0000001, idxs = B) == 2.0 + + # same as above - but with set-time event syntax + cb1‵ = [1.0] => affect1 # needs to be a Vector for the event to happen only once + cb2‵ = [2.0] => affect2 + @named osys‵ = ODESystem(eqs, t, [A], [k], discrete_events = [cb1‵, cb2‵]) + testsol(osys‵, u0, p, tspan; paramtotest = k) + + # mixing discrete affects + @named osys3 = ODESystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵]) + testsol(osys3, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with a func affect + function affect!(integrator, u, p, ctx) + integrator.ps[p.k] = 1.0 + nothing + end + cb2‵‵ = [2.0] => (affect!, [], [k], [k], nothing) + @named osys4 = ODESystem(eqs, t, [A], [k, t1], discrete_events = [cb1, cb2‵‵]) + oprob4 = ODEProblem(complete(osys4), u0, tspan, p) + testsol(osys4, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with symbolic condition in the func affect + cb2‵‵‵ = (t == t2) => (affect!, [], [k], [k], nothing) + @named osys5 = ODESystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵‵‵]) + testsol(osys5, u0, p, tspan; tstops = [1.0, 2.0]) + @named osys6 = ODESystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb2‵‵‵, cb1]) + testsol(osys6, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + # mix a continuous event too + cond3 = A ~ 0.1 + affect3 = [k ~ 0.0] + cb3 = cond3 => affect3 + @named osys7 = ODESystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵‵‵], + continuous_events = [cb3]) + sol = testsol(osys7, u0, p, (0.0, 10.0); tstops = [1.0, 2.0]) + @test isapprox(sol(10.0)[1], 0.1; atol = 1e-10, rtol = 1e-10) +end + +let + function testsol(ssys, u0, p, tspan; tstops = Float64[], paramtotest = nothing, + kwargs...) + sprob = SDEProblem(complete(ssys), u0, tspan, p; kwargs...) + sol = solve(sprob, RI5(); tstops = tstops, abstol = 1e-10, reltol = 1e-10) + @test isapprox(sol(1.0000000001)[1] - sol(0.999999999)[1], 1.0; rtol = 1e-4) + paramtotest === nothing || (@test sol.ps[paramtotest] == 1.0) + @test isapprox(sol(4.0)[1], 2 * exp(-2.0), atol = 1e-4) + sol + end + + @parameters k t1 t2 + @variables A(t) B(t) + + cond1 = (t == t1) + affect1 = [A ~ A + 1] + cb1 = cond1 => affect1 + cond2 = (t == t2) + affect2 = [k ~ 1.0] + cb2 = cond2 => affect2 + + ∂ₜ = D + eqs = [∂ₜ(A) ~ -k * A] + @named ssys = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2]) + u0 = [A => 1.0] + p = [k => 0.0, t1 => 1.0, t2 => 2.0] + tspan = (0.0, 4.0) + testsol(ssys, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + cond1a = (t == t1) + affect1a = [A ~ A + 1, B ~ A] + cb1a = cond1a => affect1a + @named ssys1 = SDESystem(eqs, [0.0], t, [A, B], [k, t1, t2], + discrete_events = [cb1a, cb2]) + u0′ = [A => 1.0, B => 0.0] + sol = testsol( + ssys1, u0′, p, tspan; tstops = [1.0, 2.0], check_length = false, paramtotest = k) + @test sol(1.0000001, idxs = 2) == 2.0 + + # same as above - but with set-time event syntax + cb1‵ = [1.0] => affect1 # needs to be a Vector for the event to happen only once + cb2‵ = [2.0] => affect2 + @named ssys‵ = SDESystem(eqs, [0.0], t, [A], [k], discrete_events = [cb1‵, cb2‵]) + testsol(ssys‵, u0, p, tspan; paramtotest = k) + + # mixing discrete affects + @named ssys3 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵]) + testsol(ssys3, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with a func affect + function affect!(integrator, u, p, ctx) + setp(integrator, p.k)(integrator, 1.0) + nothing + end + cb2‵‵ = [2.0] => (affect!, [], [k], [k], nothing) + @named ssys4 = SDESystem(eqs, [0.0], t, [A], [k, t1], + discrete_events = [cb1, cb2‵‵]) + testsol(ssys4, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with symbolic condition in the func affect + cb2‵‵‵ = (t == t2) => (affect!, [], [k], [k], nothing) + @named ssys5 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵‵‵]) + testsol(ssys5, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + @named ssys6 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb2‵‵‵, cb1]) + testsol(ssys6, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + # mix a continuous event too + cond3 = A ~ 0.1 + affect3 = [k ~ 0.0] + cb3 = cond3 => affect3 + @named ssys7 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵‵‵], + continuous_events = [cb3]) + sol = testsol(ssys7, u0, p, (0.0, 10.0); tstops = [1.0, 2.0]) + @test isapprox(sol(10.0)[1], 0.1; atol = 1e-10, rtol = 1e-10) +end + +let rng = rng + function testsol(jsys, u0, p, tspan; tstops = Float64[], paramtotest = nothing, + N = 40000, kwargs...) + jsys = complete(jsys) + dprob = DiscreteProblem(jsys, u0, tspan, p) + jprob = JumpProblem(jsys, dprob, Direct(); kwargs...) + sol = solve(jprob, SSAStepper(); tstops = tstops) + @test (sol(1.000000000001)[1] - sol(0.99999999999)[1]) == 1 + paramtotest === nothing || (@test sol.ps[paramtotest] == 1.0) + @test sol(40.0)[1] == 0 + sol + end + + @parameters k t1 t2 + @variables A(t) B(t) + + cond1 = (t == t1) + affect1 = [A ~ A + 1] + cb1 = cond1 => affect1 + cond2 = (t == t2) + affect2 = [k ~ 1.0] + cb2 = cond2 => affect2 + + eqs = [MassActionJump(k, [A => 1], [A => -1])] + @named jsys = JumpSystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2]) + u0 = [A => 1] + p = [k => 0.0, t1 => 1.0, t2 => 2.0] + tspan = (0.0, 40.0) + testsol(jsys, u0, p, tspan; tstops = [1.0, 2.0], rng, paramtotest = k) + + cond1a = (t == t1) + affect1a = [A ~ A + 1, B ~ A] + cb1a = cond1a => affect1a + @named jsys1 = JumpSystem(eqs, t, [A, B], [k, t1, t2], discrete_events = [cb1a, cb2]) + u0′ = [A => 1, B => 0] + sol = testsol(jsys1, u0′, p, tspan; tstops = [1.0, 2.0], + check_length = false, rng, paramtotest = k) + @test sol(1.000000001, idxs = B) == 2 + + # same as above - but with set-time event syntax + cb1‵ = [1.0] => affect1 # needs to be a Vector for the event to happen only once + cb2‵ = [2.0] => affect2 + @named jsys‵ = JumpSystem(eqs, t, [A], [k], discrete_events = [cb1‵, cb2‵]) + testsol(jsys‵, u0, [p[1]], tspan; rng, paramtotest = k) + + # mixing discrete affects + @named jsys3 = JumpSystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵]) + testsol(jsys3, u0, p, tspan; tstops = [1.0], rng, paramtotest = k) + + # mixing with a func affect + function affect!(integrator, u, p, ctx) + integrator.ps[p.k] = 1.0 + reset_aggregated_jumps!(integrator) + nothing + end + cb2‵‵ = [2.0] => (affect!, [], [k], [k], nothing) + @named jsys4 = JumpSystem(eqs, t, [A], [k, t1], discrete_events = [cb1, cb2‵‵]) + testsol(jsys4, u0, p, tspan; tstops = [1.0], rng, paramtotest = k) + + # mixing with symbolic condition in the func affect + cb2‵‵‵ = (t == t2) => (affect!, [], [k], [k], nothing) + @named jsys5 = JumpSystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵‵‵]) + testsol(jsys5, u0, p, tspan; tstops = [1.0, 2.0], rng, paramtotest = k) + @named jsys6 = JumpSystem(eqs, t, [A], [k, t1, t2], discrete_events = [cb2‵‵‵, cb1]) + testsol(jsys6, u0, p, tspan; tstops = [1.0, 2.0], rng, paramtotest = k) +end + +let + function oscillator_ce(k = 1.0; name) + sts = @variables x(t)=1.0 v(t)=0.0 F(t) + ps = @parameters k=k Θ=0.5 + eqs = [D(x) ~ v, D(v) ~ -k * x + F] + ev = [x ~ Θ] => [x ~ 1.0, v ~ 0.0] + ODESystem(eqs, t, sts, ps, continuous_events = [ev]; name) + end + + @named oscce = oscillator_ce() + eqs = [oscce.F ~ 0] + @named eqs_sys = ODESystem(eqs, t) + @named oneosc_ce = compose(eqs_sys, oscce) + oneosc_ce_simpl = structural_simplify(oneosc_ce) + + prob = ODEProblem(oneosc_ce_simpl, [], (0.0, 2.0), []) + sol = solve(prob, Tsit5(), saveat = 0.1) + + @test typeof(oneosc_ce_simpl) == ODESystem + @test sol[1, 6] < 1.0 # test whether x(t) decreases over time + @test sol[1, 18] > 0.5 # test whether event happened +end + +@testset "Additional SymbolicContinuousCallback options" begin + # baseline affect (pos + neg + left root find) + @variables c1(t)=1.0 c2(t)=1.0 # c1 = cos(t), c2 = cos(3t) + eqs = [D(c1) ~ -sin(t); D(c2) ~ -3 * sin(3 * t)] + record_crossings(i, u, _, c) = push!(c, i.t => i.u[u.v]) + cr1 = [] + cr2 = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1)) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2)) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5()) + required_crossings_c1 = [π / 2, 3 * π / 2] + required_crossings_c2 = [π / 6, π / 2, 5 * π / 6, 7 * π / 6, 3 * π / 2, 11 * π / 6] + @test maximum(abs.(first.(cr1) .- required_crossings_c1)) < 1e-4 + @test maximum(abs.(first.(cr2) .- required_crossings_c2)) < 1e-4 + @test sign.(cos.(required_crossings_c1 .- 1e-6)) == sign.(last.(cr1)) + @test sign.(cos.(3 * (required_crossings_c2 .- 1e-6))) == sign.(last.(cr2)) + + # with neg affect (pos * neg + left root find) + cr1p = [] + cr2p = [] + cr1n = [] + cr2n = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1p); + affect_neg = (record_crossings, [c1 => :v], [], [], cr1n)) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2p); + affect_neg = (record_crossings, [c2 => :v], [], [], cr2n)) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + c1_pc = filter((<=)(0) ∘ sin, required_crossings_c1) + c1_nc = filter((>=)(0) ∘ sin, required_crossings_c1) + c2_pc = filter(c -> -sin(3c) > 0, required_crossings_c2) + c2_nc = filter(c -> -sin(3c) < 0, required_crossings_c2) + @test maximum(abs.(c1_pc .- first.(cr1p))) < 1e-5 + @test maximum(abs.(c1_nc .- first.(cr1n))) < 1e-5 + @test maximum(abs.(c2_pc .- first.(cr2p))) < 1e-5 + @test maximum(abs.(c2_nc .- first.(cr2n))) < 1e-5 + @test sign.(cos.(c1_pc .- 1e-6)) == sign.(last.(cr1p)) + @test sign.(cos.(c1_nc .- 1e-6)) == sign.(last.(cr1n)) + @test sign.(cos.(3 * (c2_pc .- 1e-6))) == sign.(last.(cr2p)) + @test sign.(cos.(3 * (c2_nc .- 1e-6))) == sign.(last.(cr2n)) + + # with nothing neg affect (pos * neg + left root find) + cr1p = [] + cr2p = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1p); affect_neg = nothing) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2p); affect_neg = nothing) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test maximum(abs.(c1_pc .- first.(cr1p))) < 1e-5 + @test maximum(abs.(c2_pc .- first.(cr2p))) < 1e-5 + @test sign.(cos.(c1_pc .- 1e-6)) == sign.(last.(cr1p)) + @test sign.(cos.(3 * (c2_pc .- 1e-6))) == sign.(last.(cr2p)) + + #mixed + cr1p = [] + cr2p = [] + cr1n = [] + cr2n = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1p); affect_neg = nothing) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2p); + affect_neg = (record_crossings, [c2 => :v], [], [], cr2n)) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + c1_pc = filter((<=)(0) ∘ sin, required_crossings_c1) + c2_pc = filter(c -> -sin(3c) > 0, required_crossings_c2) + c2_nc = filter(c -> -sin(3c) < 0, required_crossings_c2) + @test maximum(abs.(c1_pc .- first.(cr1p))) < 1e-5 + @test maximum(abs.(c2_pc .- first.(cr2p))) < 1e-5 + @test maximum(abs.(c2_nc .- first.(cr2n))) < 1e-5 + @test sign.(cos.(c1_pc .- 1e-6)) == sign.(last.(cr1p)) + @test sign.(cos.(3 * (c2_pc .- 1e-6))) == sign.(last.(cr2p)) + @test sign.(cos.(3 * (c2_nc .- 1e-6))) == sign.(last.(cr2n)) + + # baseline affect w/ right rootfind (pos + neg + right root find) + @variables c1(t)=1.0 c2(t)=1.0 # c1 = cos(t), c2 = cos(3t) + cr1 = [] + cr2 = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1); + rootfind = SciMLBase.RightRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + required_crossings_c1 = [π / 2, 3 * π / 2] + required_crossings_c2 = [π / 6, π / 2, 5 * π / 6, 7 * π / 6, 3 * π / 2, 11 * π / 6] + @test maximum(abs.(first.(cr1) .- required_crossings_c1)) < 1e-4 + @test maximum(abs.(first.(cr2) .- required_crossings_c2)) < 1e-4 + @test sign.(cos.(required_crossings_c1 .+ 1e-6)) == sign.(last.(cr1)) + @test sign.(cos.(3 * (required_crossings_c2 .+ 1e-6))) == sign.(last.(cr2)) + + # baseline affect w/ mixed rootfind (pos + neg + right root find) + cr1 = [] + cr2 = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1); + rootfind = SciMLBase.LeftRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5()) + @test maximum(abs.(first.(cr1) .- required_crossings_c1)) < 1e-4 + @test maximum(abs.(first.(cr2) .- required_crossings_c2)) < 1e-4 + @test sign.(cos.(required_crossings_c1 .- 1e-6)) == sign.(last.(cr1)) + @test sign.(cos.(3 * (required_crossings_c2 .+ 1e-6))) == sign.(last.(cr2)) + + #flip order and ensure results are okay + cr1 = [] + cr2 = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (record_crossings, [c1 => :v], [], [], cr1); + rootfind = SciMLBase.LeftRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (record_crossings, [c2 => :v], [], [], cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = ODESystem(eqs, t; continuous_events = [evt2, evt1]) + trigsys_ss = structural_simplify(trigsys) + prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) + sol = solve(prob, Tsit5()) + @test maximum(abs.(first.(cr1) .- required_crossings_c1)) < 1e-4 + @test maximum(abs.(first.(cr2) .- required_crossings_c2)) < 1e-4 + @test sign.(cos.(required_crossings_c1 .- 1e-6)) == sign.(last.(cr1)) + @test sign.(cos.(3 * (required_crossings_c2 .+ 1e-6))) == sign.(last.(cr2)) +end + +@testset "Discrete event reinitialization (#3142)" begin + @connector LiquidPort begin + p(t)::Float64, [description = "Set pressure in bar", + guess = 1.01325] + Vdot(t)::Float64, + [description = "Volume flow rate in L/min", + guess = 0.0, + connect = Flow] + end + + @mtkmodel PressureSource begin + @components begin + port = LiquidPort() + end + @parameters begin + p_set::Float64 = 1.01325, [description = "Set pressure in bar"] + end + @equations begin + port.p ~ p_set + end + end + + @mtkmodel BinaryValve begin + @constants begin + p_ref::Float64 = 1.0, [description = "Reference pressure drop in bar"] + ρ_ref::Float64 = 1000.0, [description = "Reference density in kg/m^3"] + end + @components begin + port_in = LiquidPort() + port_out = LiquidPort() + end + @parameters begin + k_V::Float64 = 1.0, [description = "Valve coefficient in L/min/bar"] + k_leakage::Float64 = 1e-08, [description = "Leakage coefficient in L/min/bar"] + ρ::Float64 = 1000.0, [description = "Density in kg/m^3"] + end + @variables begin + S(t)::Float64, [description = "Valve state", guess = 1.0, irreducible = true] + Δp(t)::Float64, [description = "Pressure difference in bar", guess = 1.0] + Vdot(t)::Float64, [description = "Volume flow rate in L/min", guess = 1.0] + end + @equations begin + # Port handling + port_in.Vdot ~ -Vdot + port_out.Vdot ~ Vdot + Δp ~ port_in.p - port_out.p + # System behavior + D(S) ~ 0.0 + Vdot ~ S * k_V * sign(Δp) * sqrt(abs(Δp) / p_ref * ρ_ref / ρ) + k_leakage * Δp # softplus alpha function to avoid negative values under the sqrt + end + end + + # Test System + @mtkmodel TestSystem begin + @components begin + pressure_source_1 = PressureSource(p_set = 2.0) + binary_valve_1 = BinaryValve(S = 1.0, k_leakage = 0.0) + binary_valve_2 = BinaryValve(S = 1.0, k_leakage = 0.0) + pressure_source_2 = PressureSource(p_set = 1.0) + end + @equations begin + connect(pressure_source_1.port, binary_valve_1.port_in) + connect(binary_valve_1.port_out, binary_valve_2.port_in) + connect(binary_valve_2.port_out, pressure_source_2.port) + end + @discrete_events begin + [30] => [binary_valve_1.S ~ 0.0, binary_valve_2.Δp ~ 0.0] + [60] => [ + binary_valve_1.S ~ 1.0, binary_valve_2.S ~ 0.0, binary_valve_2.Δp ~ 1.0] + [120] => [binary_valve_1.S ~ 0.0, binary_valve_2.Δp ~ 0.0] + end + end + + # Test Simulation + @mtkbuild sys = TestSystem() + + # Test Simulation + prob = ODEProblem(sys, [], (0.0, 150.0)) + sol = solve(prob) + @test sol[end] == [0.0, 0.0, 0.0] +end + +@testset "Discrete variable timeseries" begin + @variables x(t) + @parameters a(t) b(t) c(t) + cb1 = [x ~ 1.0] => [a ~ -a] + function save_affect!(integ, u, p, ctx) + integ.ps[p.b] = 5.0 + end + cb2 = [x ~ 0.5] => (save_affect!, [], [b], [b], nothing) + cb3 = 1.0 => [c ~ t] + + @mtkbuild sys = ODESystem(D(x) ~ cos(t), t, [x], [a, b, c]; + continuous_events = [cb1, cb2], discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2pi), [a => 1.0, b => 2.0, c => 0.0]) + @test sort(canonicalize(Discrete(), prob.p)[1]) == [0.0, 1.0, 2.0] + sol = solve(prob, Tsit5()) + + @test sol[a] == [1.0, -1.0] + @test sol[b] == [2.0, 5.0, 5.0] + @test sol[c] == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] +end +@testset "Heater" begin + @variables temp(t) + params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on::Bool=false + eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage + ] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, i, c + @set! x.furnace_on = false + end) + furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, i, c + @set! x.furnace_on = true + end) + @named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test all(sol[temp][sol.t .> 1.0] .<= 0.79) && all(sol[temp][sol.t .> 1.0] .>= 0.49) + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end; initialize = ModelingToolkit.ImperativeAffect(modified = (; + temp)) do x, o, c, i + @set! x.temp = 0.2 + end) + furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = true + end) + @named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test all(sol[temp][sol.t .> 1.0] .<= 0.79) && all(sol[temp][sol.t .> 1.0] .>= 0.49) + @test all(sol[temp][sol.t .!= 0.0] .<= 0.79) && all(sol[temp][sol.t .!= 0.0] .>= 0.2) +end + +@testset "ImperativeAffect errors and warnings" begin + @variables temp(t) + params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on::Bool=false + eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage + ] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect( + modified = (; furnace_on), observed = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem(eqs, t, [temp], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_logs (:warn, + "The symbols Any[:furnace_on] are declared as both observed and modified; this is a code smell because it becomes easy to confuse them and assign/not assign a value.") prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + @variables tempsq(t) # trivially eliminated + eqs = [tempsq ~ temp^2 + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect( + modified = (; furnace_on, tempsq), observed = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_throws "refers to missing variable(s)" prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + @parameters not_actually_here + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on), + observed = (; furnace_on, not_actually_here)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_throws "refers to missing variable(s)" prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on), + observed = (; furnace_on)) do x, o, c, i + return (; fictional2 = false) + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + prob = ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + @test_throws "Tried to write back to" solve(prob, Tsit5()) +end + +@testset "Quadrature" begin + @variables theta(t) omega(t) + params = @parameters qA=0 qB=0 hA=0 hB=0 cnt::Int=0 + eqs = [D(theta) ~ omega + omega ~ 1.0] + function decoder(oldA, oldB, newA, newB) + state = (oldA, oldB, newA, newB) + if state == (0, 0, 1, 0) || state == (1, 0, 1, 1) || state == (1, 1, 0, 1) || + state == (0, 1, 0, 0) + return 1 + elseif state == (0, 0, 0, 1) || state == (0, 1, 1, 1) || state == (1, 1, 1, 0) || + state == (1, 0, 0, 0) + return -1 + elseif state == (0, 0, 0, 0) || state == (0, 1, 0, 1) || state == (1, 0, 1, 0) || + state == (1, 1, 1, 1) + return 0 + else + return 0 # err is interpreted as no movement + end + end + qAevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta) ~ 0], + ModelingToolkit.ImperativeAffect((; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 1 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 0 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end; rootfind = SciMLBase.RightRootFind) + qBevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta - π / 2) ~ 0], + ModelingToolkit.ImperativeAffect((; qB, hA, hB, cnt), (; qA)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = 1 + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qB, hA, hB, cnt), (; qA)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = 0 + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end; rootfind = SciMLBase.RightRootFind) + @named sys = ODESystem( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [theta => 1e-5], (0.0, pi)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test getp(sol, cnt)(sol) == 198 # we get 2 pulses per phase cycle (cos 0 crossing) and we go to 100 cycles; we miss a few due to the initial state +end + +@testset "Initialization" begin + @variables x(t) + seen = false + f = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> seen = true, sts = [], pars = [], discretes = []) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], Equation[], initialize = [x ~ 1.5], finalize = f) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; continuous_events = [cb1]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test sol[x][1] ≈ 1.0 + @test sol[x][2] ≈ 1.5 # the initialize affect has been applied + @test seen == true + + @variables x(t) + seen = false + f = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> seen = true, sts = [], pars = [], discretes = []) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], Equation[], initialize = [x ~ 1.5], finalize = f) + inited = false + finaled = false + a = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> inited = true, sts = [], pars = [], discretes = []) + b = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> finaled = true, sts = [], pars = [], discretes = []) + cb2 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0.1], Equation[], initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; continuous_events = [cb1, cb2]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test sol[x][1] ≈ 1.0 + @test sol[x][2] ≈ 1.5 # the initialize affect has been applied + @test seen == true + @test inited == true + @test finaled == true + + #periodic + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback( + 1.0, [x ~ 2], initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test inited == true + @test finaled == true + @test isapprox(sol[x][3], 0.0, atol = 1e-9) + @test sol[x][4] ≈ 2.0 + @test sol[x][5] ≈ 1.0 + + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback(1.0, f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test seen == true + @test inited == true + + #preset + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback([1.0], f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test seen == true + @test inited == true + @test finaled == true + + #equational + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback( + t == 1.0, f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5(); tstops = 1.0) + @test seen == true + @test inited == true + @test finaled == true +end + +@testset "Bump" begin + @variables x(t) [irreducible = true] y(t) [irreducible = true] + eqs = [x ~ y, D(x) ~ -1] + cb = [x ~ 0.0] => [x ~ 0, y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test_throws "DAE initialization failed" solve(prob, Rodas5()) + + cb = [x ~ 0.0] => [y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test_broken !SciMLBase.successful_retcode(solve(prob, Rodas5())) + + cb = [x ~ 0.0] => [x ~ 1, y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test all(≈(0.0; atol = 1e-9), solve(prob, Rodas5())[[x, y]][end]) +end + +@testset "Issue#3154 Array variable in discrete condition" begin + @mtkmodel DECAY begin + @parameters begin + unrelated[1:2] = zeros(2) + k = 0.0 + end + @variables begin + x(t) = 10.0 + end + @equations begin + D(x) ~ -k * x + end + @discrete_events begin + (t == 1.0) => [k ~ 1.0] + end + end + @mtkbuild decay = DECAY() + prob = ODEProblem(decay, [], (0.0, 10.0), []) + @test_nowarn solve(prob, Tsit5(), tstops = [1.0]) +end + +@testset "Array parameter updates in ImperativeEffect" begin + function weird1(max_time; name) + params = @parameters begin + θ(t) = 0.0 + end + vars = @variables begin + x(t) = 0.0 + end + eqs = reduce(vcat, Symbolics.scalarize.([ + D(x) ~ 1.0 + ])) + reset = ModelingToolkit.ImperativeAffect( + modified = (; x, θ)) do m, o, _, i + @set! m.θ = 0.0 + @set! m.x = 0.0 + return m + end + return ODESystem(eqs, t, vars, params; name = name, + continuous_events = [[x ~ max_time] => reset]) + end + + function weird2(max_time; name) + params = @parameters begin + θ(t) = 0.0 + end + vars = @variables begin + x(t) = 0.0 + end + eqs = reduce(vcat, Symbolics.scalarize.([ + D(x) ~ 1.0 + ])) + return ODESystem(eqs, t, vars, params; name = name) # note no event + end + + @named wd1 = weird1(0.021) + @named wd2 = weird2(0.021) + + sys1 = structural_simplify(ODESystem([], t; name = :parent, + discrete_events = [0.01 => ModelingToolkit.ImperativeAffect( + modified = (; θs = reduce(vcat, [[wd1.θ]])), ctx = [1]) do m, o, c, i + @set! m.θs[1] = c[] += 1 + end], + systems = [wd1])) + sys2 = structural_simplify(ODESystem([], t; name = :parent, + discrete_events = [0.01 => ModelingToolkit.ImperativeAffect( + modified = (; θs = reduce(vcat, [[wd2.θ]])), ctx = [1]) do m, o, c, i + @set! m.θs[1] = c[] += 1 + end], + systems = [wd2])) + + sol1 = solve(ODEProblem(sys1, [], (0.0, 1.0)), Tsit5()) + @test 100.0 ∈ sol1[sys1.wd1.θ] + + sol2 = solve(ODEProblem(sys2, [], (0.0, 1.0)), Tsit5()) + @test 100.0 ∈ sol2[sys2.wd2.θ] +end diff --git a/test/symbolic_indexing_interface.jl b/test/symbolic_indexing_interface.jl new file mode 100644 index 0000000000..8b3da5fd72 --- /dev/null +++ b/test/symbolic_indexing_interface.jl @@ -0,0 +1,239 @@ +using ModelingToolkit, SymbolicIndexingInterface, SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D, ParameterIndex +using SciMLStructures: Tunable + +@testset "ODESystem" begin + @parameters a b + @variables x(t)=1.0 y(t)=2.0 xy(t) + eqs = [D(x) ~ a * y + t, D(y) ~ b * t] + @named odesys = ODESystem(eqs, t, [x, y], [a, b]; observed = [xy ~ x + y]) + odesys = complete(odesys) + @test SymbolicIndexingInterface.supports_tuple_observed(odesys) + @test all(is_variable.((odesys,), [x, y, 1, 2, :x, :y])) + @test all(.!is_variable.((odesys,), [a, b, t, 3, 0, :a, :b])) + @test variable_index.((odesys,), [x, y, a, b, t, 1, 2, :x, :y, :a, :b]) == + [1, 2, nothing, nothing, nothing, 1, 2, 1, 2, nothing, nothing] + @test isequal(variable_symbols(odesys), [x, y]) + @test all(is_parameter.((odesys,), [a, b, ParameterIndex(Tunable(), 1), :a, :b])) + @test all(.!is_parameter.((odesys,), [x, y, t, 3, 0, :x, :y])) + @test parameter_index(odesys, a) == parameter_index(odesys, :a) + @test parameter_index(odesys, a) isa ParameterIndex{Tunable, Int} + @test parameter_index(odesys, b) == parameter_index(odesys, :b) + @test parameter_index(odesys, b) isa ParameterIndex{Tunable, Int} + @test parameter_index.( + (odesys,), [x, y, t, ParameterIndex(Tunable(), 1), :x, :y]) == + [nothing, nothing, nothing, ParameterIndex(Tunable(), 1), nothing, nothing] + @test isequal( + Set(parameter_symbols(odesys)), Set([a, b, Initial(x), Initial(y), Initial(xy), + Initial(D(x)), Initial(D(y)), Initial(D(xy))])) + @test all(is_independent_variable.((odesys,), [t, :t])) + @test all(.!is_independent_variable.((odesys,), [x, y, a, :x, :y, :a])) + @test isequal(independent_variable_symbols(odesys), [t]) + @test is_time_dependent(odesys) + @test constant_structure(odesys) + @test !isempty(default_values(odesys)) + @test default_values(odesys)[x] == 1.0 + @test default_values(odesys)[y] == 2.0 + @test isequal(default_values(odesys)[xy], x + y) + + prob = ODEProblem(odesys, [], (0.0, 1.0), [a => 1.0, b => 2.0]) + getter = getu(odesys, (x + 1, x + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) + getter = getp(odesys, (a + 1, a + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) + + @named odesys = ODESystem( + eqs, t, [x, y], [a, b]; defaults = [xy => 3.0], observed = [xy ~ x + y]) + odesys = complete(odesys) + @test default_values(odesys)[xy] == 3.0 + pobs = parameter_observed(odesys, a + b) + @test isempty(get_all_timeseries_indexes(odesys, a + b)) + @test pobs( + ModelingToolkit.MTKParameters(odesys, [a => 1.0, b => 2.0]), 0.0) ≈ 3.0 + pobs = parameter_observed(odesys, [a + b, a - b]) + @test isempty(get_all_timeseries_indexes(odesys, [a + b, a - b])) + @test pobs( + ModelingToolkit.MTKParameters(odesys, [a => 1.0, b => 2.0]), 0.0) ≈ [3.0, -1.0] +end + +# @testset "Clock system" begin +# dt = 0.1 +# dt2 = 0.2 +# @variables x(t)=0 y(t)=0 u(t)=0 yd1(t)=0 ud1(t)=0 yd2(t)=0 ud2(t)=0 +# @parameters kp=1 r=1 + +# eqs = [ +# # controller (time discrete part `dt=0.1`) +# yd1 ~ Sample(t, dt)(y) +# ud1 ~ kp * (r - yd1) +# # controller (time discrete part `dt=0.2`) +# yd2 ~ Sample(t, dt2)(y) +# ud2 ~ kp * (r - yd2) + +# # plant (time continuous part) +# u ~ Hold(ud1) + Hold(ud2) +# D(x) ~ -x + u +# y ~ x] + +# @mtkbuild cl = ODESystem(eqs, t) +# partition1_params = [Hold(ud1), Sample(t, dt)(y), ud1, yd1] +# partition2_params = [Hold(ud2), Sample(t, dt2)(y), ud2, yd2] +# @test all( +# Base.Fix1(is_timeseries_parameter, cl), vcat(partition1_params, partition2_params)) +# @test allequal(timeseries_parameter_index(cl, p).timeseries_idx +# for p in partition1_params) +# @test allequal(timeseries_parameter_index(cl, p).timeseries_idx +# for p in partition2_params) +# tsidx1 = timeseries_parameter_index(cl, partition1_params[1]).timeseries_idx +# tsidx2 = timeseries_parameter_index(cl, partition2_params[1]).timeseries_idx +# @test tsidx1 != tsidx2 +# ps = ModelingToolkit.MTKParameters(cl, [kp => 1.0, Sample(t, dt)(y) => 1.0]) +# pobs = parameter_observed(cl, Shift(t, 1)(yd1)) +# @test pobs.timeseries_idx == tsidx1 +# @test pobs.observed_fn(ps, 0.0) == 1.0 +# pobs = parameter_observed(cl, [Shift(t, 1)(yd1), Shift(t, 1)(ud1)]) +# @test pobs.timeseries_idx == tsidx1 +# @test pobs.observed_fn(ps, 0.0) == [1.0, 0.0] +# pobs = parameter_observed(cl, [Shift(t, 1)(yd1), Shift(t, 1)(ud2)]) +# @test pobs.timeseries_idx === nothing +# @test pobs.observed_fn(ps, 0.0) == [1.0, 1.0] +# end + +@testset "Nonlinear system" begin + @variables x y z + @parameters σ ρ β + + eqs = [0 ~ σ * (y - x), + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] + @named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) + ns = complete(ns) + @test SymbolicIndexingInterface.supports_tuple_observed(ns) + @test !is_time_dependent(ns) + ps = ModelingToolkit.MTKParameters(ns, [σ => 1.0, ρ => 2.0, β => 3.0]) + pobs = parameter_observed(ns, σ + ρ) + @test isempty(get_all_timeseries_indexes(ns, σ + ρ)) + @test pobs(ps) == 3.0 + pobs = parameter_observed(ns, [σ + ρ, ρ + β]) + @test isempty(get_all_timeseries_indexes(ns, [σ + ρ, ρ + β])) + @test pobs(ps) == [3.0, 5.0] + + prob = NonlinearProblem( + ns, [x => 1.0, y => 2.0, z => 3.0], [σ => 1.0, ρ => 2.0, β => 3.0]) + getter = getu(ns, (x + 1, x + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) + getter = getp(ns, (σ + 1, σ + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) +end + +@testset "PDESystem" begin + @parameters x + @variables u(..) + Dxx = Differential(x)^2 + Dtt = Differential(t)^2 + Dt = D + + #2D PDE + C = 1 + eq = Dtt(u(t, x)) ~ C^2 * Dxx(u(t, x)) + + # Initial and boundary conditions + bcs = [u(t, 0) ~ 0.0,# for all t > 0 + u(t, 1) ~ 0.0,# for all t > 0 + u(0, x) ~ x * (1.0 - x), #for all 0 < x < 1 + Dt(u(0, x)) ~ 0.0] #for all 0 < x < 1] + + # Space and time domains + domains = [t ∈ (0.0, 1.0), + x ∈ (0.0, 1.0)] + + @named pde_system = PDESystem(eq, bcs, domains, [t, x], [u]) + + @test pde_system.ps == SciMLBase.NullParameters() + @test parameter_symbols(pde_system) == [] + + @parameters x + @constants h = 1 + @variables u(..) + Dt = D + Dxx = Differential(x)^2 + eq = Dt(u(t, x)) ~ h * Dxx(u(t, x)) + bcs = [u(0, x) ~ -h * x * (x - 1) * sin(x), + u(t, 0) ~ 0, u(t, 1) ~ 0] + + domains = [t ∈ (0.0, 1.0), + x ∈ (0.0, 1.0)] + + analytic = [u(t, x) ~ -h * x * (x - 1) * sin(x) * exp(-2 * h * t)] + analytic_function = (ps, t, x) -> -ps[1] * x * (x - 1) * sin(x) * exp(-2 * ps[1] * t) + + @named pdesys = PDESystem(eq, bcs, domains, [t, x], [u], [h], analytic = analytic) + + @test isequal(pdesys.ps, [h]) + @test isequal(parameter_symbols(pdesys), [h]) + @test isequal(parameters(pdesys), [h]) +end + +# Issue#2767 +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +using SymbolicIndexingInterface + +@parameters p1[1:2]=[1.0, 2.0] p2[1:2]=[0.0, 0.0] +@variables x(t) = 0 + +@named sys = ODESystem( + [D(x) ~ sum(p1) * t + sum(p2)], + t; +) +prob = ODEProblem(complete(sys)) +get_dep = @test_nowarn getu(prob, 2p1) +@test get_dep(prob) == [2.0, 4.0] + +@testset "Observed functions with variables as `Symbol`s" begin + @variables x(t) y(t) z(t)[1:2] + @parameters p1 p2[1:2, 1:2] + @mtkbuild sys = ODESystem([D(x) ~ x * t + p1, y ~ 2x, D(z) ~ p2 * z], t) + prob = ODEProblem( + sys, [x => 1.0, z => ones(2)], (0.0, 1.0), [p1 => 2.0, p2 => ones(2, 2)]) + @test getu(prob, x)(prob) == getu(prob, :x)(prob) + @test getu(prob, [x, y])(prob) == getu(prob, [:x, :y])(prob) + @test getu(prob, z)(prob) == getu(prob, :z)(prob) + @test getu(prob, p1)(prob) == getu(prob, :p1)(prob) + @test getu(prob, p2)(prob) == getu(prob, :p2)(prob) +end + +@testset "Parameter dependencies as symbols" begin + @variables x(t) = 1.0 + @parameters a=1 b + @named model = ODESystem(D(x) ~ x + a - b, t, parameter_dependencies = [b ~ a + 1]) + sys = complete(model) + prob = ODEProblem(sys, [], (0.0, 1.0)) + @test prob.ps[b] == prob.ps[:b] +end + +@testset "`get_all_timeseries_indexes` with non-split systems" begin + @variables x(t) y(t) z(t) + @parameters a + @named sys = ODESystem([D(x) ~ a * x, y ~ 2x, z ~ 0.0], t) + sys = structural_simplify(sys, split = false) + for sym in [x, y, z, x + y, x + a, y / x] + @test only(get_all_timeseries_indexes(sys, sym)) == ContinuousTimeseries() + end + @test isempty(get_all_timeseries_indexes(sys, a)) +end + +@testset "`timeseries_parameter_index` on unwrapped scalarized timeseries parameter" begin + @variables x(t)[1:2] + @parameters p(t)[1:2, 1:2] + ev = [x[1] ~ 2.0] => [p ~ -ones(2, 2)] + @mtkbuild sys = ODESystem(D(x) ~ p * x, t; continuous_events = [ev]) + p = ModelingToolkit.unwrap(p) + @test timeseries_parameter_index(sys, p) === ParameterTimeseriesIndex(1, (1, 1)) + @test timeseries_parameter_index(sys, p[1, 1]) === + ParameterTimeseriesIndex(1, (1, 1, 1, 1)) +end diff --git a/test/symbolic_parameters.jl b/test/symbolic_parameters.jl index 1458e1661e..a29090912c 100644 --- a/test/symbolic_parameters.jl +++ b/test/symbolic_parameters.jl @@ -1,47 +1,72 @@ using ModelingToolkit using NonlinearSolve using Test +using ModelingToolkit: t_nounits as t, D_nounits as D @variables x y z u @parameters σ ρ β -eqs = [0 ~ σ*(y-x), - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] +eqs = [0 ~ σ * (y - x), + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] par = [ σ => 1, - ρ => 0.1+σ, - β => ρ*1.1 + ρ => 0.1 + σ, + β => ρ * 1.1 ] -u0 = Pair{Num, Any}[ +u0 = [ x => u, y => σ, # default u0 from default p - z => u-0.1, + z => u - 0.1 ] -ns = NonlinearSystem(eqs, [x,y,z],[σ,ρ,β], name=:ns, defaults=[par; u0]) -ns.y = u*1.1 -resolved = ModelingToolkit.varmap_to_vars(Dict(), parameters(ns), defaults=ModelingToolkit.defaults(ns)) -@test resolved == [1, 0.1+1, (0.1+1)*1.1] +ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β], name = :ns, defaults = [par; u0]) +ns.y = u * 1.1 +resolved = ModelingToolkit.varmap_to_vars(Dict(), parameters(ns), + defaults = ModelingToolkit.defaults(ns)) +@test resolved == [1, 0.1 + 1, (0.1 + 1) * 1.1] -prob = NonlinearProblem(ns, [u=>1.0], Pair[]) +prob = NonlinearProblem(complete(ns), [u => 1.0], Pair[]) @test prob.u0 == [1.0, 1.1, 0.9] -@show sol = solve(prob,NewtonRaphson()) +@show sol = solve(prob, NewtonRaphson()) @variables a @parameters b -top = NonlinearSystem([0 ~ -a + ns.x+b], [a], [b], systems=[ns], name=:top) -top.b = ns.σ*0.5 -top.ns.x = u*0.5 +top = NonlinearSystem([0 ~ -a + ns.x + b], [a], [b], systems = [ns], name = :top) +top.b = ns.σ * 0.5 +top.ns.x = u * 0.5 -res = ModelingToolkit.varmap_to_vars(Dict(), parameters(top), defaults=ModelingToolkit.defaults(top)) -@test res == [0.5, 1, 0.1+1, (0.1+1)*1.1] +res = ModelingToolkit.varmap_to_vars(Dict(), parameters(top), + defaults = ModelingToolkit.defaults(top)) +@test res == [0.5, 1, 0.1 + 1, (0.1 + 1) * 1.1] -prob = NonlinearProblem(top, [states(ns, u)=>1.0, a=>1.0], Pair[]) +top = complete(top) +prob = NonlinearProblem(top, [unknowns(ns, u) => 1.0, a => 1.0], []) @test prob.u0 == [1.0, 0.5, 1.1, 0.9] -@show sol = solve(prob,NewtonRaphson()) +@show sol = solve(prob, NewtonRaphson()) # test NullParameters+defaults -prob = NonlinearProblem(top, [states(ns, u)=>1.0, a=>1.0]) +prob = NonlinearProblem(top, [unknowns(ns, u) => 1.0, a => 1.0]) @test prob.u0 == [1.0, 0.5, 1.1, 0.9] -@show sol = solve(prob,NewtonRaphson()) +@show sol = solve(prob, NewtonRaphson()) + +# test initial conditions and parameters at the problem level +pars = @parameters(begin + x0 +end) +vars = @variables(begin + x(ModelingToolkit.t_nounits) +end) +der = Differential(t) +eqs = [der(x) ~ x] +@named sys = ODESystem(eqs, t, vars, [x0]) +sys = complete(sys) +pars = [ + x0 => 10.0 +] +initialValues = [ + x => x0 +] +tspan = (0.0, 1.0) +problem = ODEProblem(sys, initialValues, tspan, pars) +@test problem.u0 isa Vector{Float64} diff --git a/test/test_variable_metadata.jl b/test/test_variable_metadata.jl new file mode 100644 index 0000000000..aaf6addb59 --- /dev/null +++ b/test/test_variable_metadata.jl @@ -0,0 +1,227 @@ +using ModelingToolkit +using DynamicQuantities + +# Bounds +@variables u [bounds = (-1, 1)] +@test getbounds(u) == (-1, 1) +@test hasbounds(u) +@test ModelingToolkit.dump_variable_metadata(u).bounds == (-1, 1) + +@variables y +@test !hasbounds(y) +@test !haskey(ModelingToolkit.dump_variable_metadata(y), :bounds) + +@variables y[1:3] +@test !hasbounds(y) +@test getbounds(y)[1] == [-Inf, -Inf, -Inf] +@test getbounds(y)[2] == [Inf, Inf, Inf] +for i in eachindex(y) + @test !hasbounds(y[i]) + b = getbounds(y[i]) + @test b[1] == -Inf && b[2] == Inf +end + +@variables y[1:3] [bounds = (-1, 1)] +@test hasbounds(y) +@test getbounds(y)[1] == -ones(3) +@test getbounds(y)[2] == ones(3) +for i in eachindex(y) + @test hasbounds(y[i]) + b = getbounds(y[i]) + @test b[1] == -1.0 && b[2] == 1.0 +end +@test getbounds(y[1:2])[1] == -ones(2) +@test getbounds(y[1:2])[2] == ones(2) + +@variables y[1:2, 1:2] [bounds = (-1, [1.0 Inf; 2.0 3.0])] +@test hasbounds(y) +@test getbounds(y)[1] == [-1 -1; -1 -1] +@test getbounds(y)[2] == [1.0 Inf; 2.0 3.0] +for i in eachindex(y) + @test hasbounds(y[i]) + b = getbounds(y[i]) + @test b[1] == -1 && b[2] == [1.0 Inf; 2.0 3.0][i] +end + +@variables y[1:2] [bounds = (-Inf, [1.0, Inf])] +@test hasbounds(y) +@test getbounds(y)[1] == [-Inf, -Inf] +@test getbounds(y)[2] == [1.0, Inf] +@test hasbounds(y[1]) +@test getbounds(y[1]) == (-Inf, 1.0) +@test !hasbounds(y[2]) +@test getbounds(y[2]) == (-Inf, Inf) + +# Guess +@variables y [guess = 0] +@test getguess(y) == 0 +@test hasguess(y) == true +@test ModelingToolkit.dump_variable_metadata(y).guess == 0 + +# Default +@variables y = 0 +@test ModelingToolkit.getdefault(y) == 0 +@test ModelingToolkit.hasdefault(y) == true +@test ModelingToolkit.dump_variable_metadata(y).default == 0 + +# Issue#2653 +@variables y[1:3] [guess = ones(3)] +@test getguess(y) == ones(3) +@test hasguess(y) == true +@test ModelingToolkit.dump_variable_metadata(y).guess == ones(3) + +for i in 1:3 + @test getguess(y[i]) == 1.0 + @test hasguess(y[i]) == true + @test ModelingToolkit.dump_variable_metadata(y[i]).guess == 1.0 +end + +@variables y +@test hasguess(y) == false +@test !haskey(ModelingToolkit.dump_variable_metadata(y), :guess) + +# Disturbance +@variables u [disturbance = true] +@test isdisturbance(u) +@test ModelingToolkit.dump_variable_metadata(u).disturbance + +@variables y +@test !isdisturbance(y) +@test !haskey(ModelingToolkit.dump_variable_metadata(y), :disturbance) + +# Tunable +@parameters u [tunable = true] +@test istunable(u) +@test ModelingToolkit.dump_variable_metadata(u).tunable + +@parameters u2 [tunable = false] +@test !istunable(u2) +@test !ModelingToolkit.dump_variable_metadata(u2).tunable + +@parameters y +@test istunable(y) +@test ModelingToolkit.dump_variable_metadata(y).tunable + +# Distributions +struct FakeNormal end +d = FakeNormal() +@parameters u [dist = d] +@test hasdist(u) +@test getdist(u) == d +@test ModelingToolkit.dump_variable_metadata(u).dist == d + +@parameters y +@test !hasdist(y) +@test !haskey(ModelingToolkit.dump_variable_metadata(y), :dist) + +## System interface +@independent_variables t +Dₜ = Differential(t) +@variables x(t)=0 [bounds = (-10, 10)] u(t)=0 [input = true] y(t)=0 [output = true] +@parameters T [bounds = (0, Inf)] +@parameters k [tunable = true, bounds = (0, Inf)] +@parameters k2 [tunable = false] +eqs = [Dₜ(x) ~ (-k2 * x + k * u) / T + y ~ x] +sys = ODESystem(eqs, t, name = :tunable_first_order) +unk_meta = ModelingToolkit.dump_unknowns(sys) +@test length(unk_meta) == 3 +@test all(iszero, meta.default for meta in unk_meta) +param_meta = ModelingToolkit.dump_parameters(sys) +@test length(param_meta) == 3 +@test all(!haskey(meta, :default) for meta in param_meta) + +p = tunable_parameters(sys) +sp = Set(p) +@test k ∈ sp +@test T ∈ sp +@test k2 ∉ sp +@test length(p) == 2 + +lb, ub = getbounds(p) +@test lb == [0, 0] +@test ub == [Inf, Inf] + +b = getbounds(sys) +@test b[T] == (0, Inf) + +b = getbounds(sys, unknowns(sys)) +@test b[x] == (-10, 10) + +p = tunable_parameters(sys, default = false) +sp = Set(p) +@test k ∈ sp +@test T ∉ sp +@test k2 ∉ sp +@test length(p) == 1 + +## Descriptions +@variables u [description = "This is my input"] +@test getdescription(u) == "This is my input" +@test hasdescription(u) +@test ModelingToolkit.dump_variable_metadata(u).desc == "This is my input" + +@variables u +@test getdescription(u) == "" +@test !hasdescription(u) +@test !haskey(ModelingToolkit.dump_variable_metadata(u), :desc) + +@independent_variables t +@variables u(t) [description = "A short description of u"] +@parameters p [description = "A description of p"] +@named sys = ODESystem([u ~ p], t) + +@test_nowarn show(stdout, "text/plain", sys) + +# Defaults, guesses overridden by system, parameter dependencies +@variables x(t)=1.0 y(t) [guess = 1.0] +@parameters p=2.0 q +@named sys = ODESystem(Equation[], t, [x, y], [p]; defaults = Dict(x => 2.0, p => 3.0), + guesses = Dict(y => 2.0), parameter_dependencies = [q => 2p]) +unks_meta = ModelingToolkit.dump_unknowns(sys) +unks_meta = Dict([ModelingToolkit.getname(meta.var) => meta for meta in unks_meta]) +@test unks_meta[:x].default == 2.0 +@test unks_meta[:y].guess == 2.0 +params_meta = ModelingToolkit.dump_parameters(sys) +params_meta = Dict([ModelingToolkit.getname(meta.var) => meta for meta in params_meta]) +@test params_meta[:p].default == 3.0 +@test isequal(params_meta[:q].dependency, 2p) + +# Connect +@variables x [connect = Flow] +@test hasconnect(x) +@test getconnect(x) == Flow +@test ModelingToolkit.dump_variable_metadata(x).connect == Flow +x = ModelingToolkit.setconnect(x, ModelingToolkit.Stream) +@test getconnect(x) == ModelingToolkit.Stream + +struct BadConnect end +@test_throws Exception ModelingToolkit.setconnect(x, BadConnect) + +# Unit +@variables x [unit = u"s"] +@test hasunit(x) +@test getunit(x) == u"s" +@test ModelingToolkit.dump_variable_metadata(x).unit == u"s" + +# Misc data +@variables x [misc = [:good]] +@test hasmisc(x) +@test getmisc(x) == [:good] +x = ModelingToolkit.setmisc(x, "okay") +@test getmisc(x) == "okay" + +# Variable Type +@variables x +@test ModelingToolkit.getvariabletype(x) == ModelingToolkit.VARIABLE +@test ModelingToolkit.dump_variable_metadata(x).variable_type == ModelingToolkit.VARIABLE +@test ModelingToolkit.dump_variable_metadata(x).variable_source == :variables +x = ModelingToolkit.toparam(x) +@test ModelingToolkit.getvariabletype(x) == ModelingToolkit.PARAMETER +@test ModelingToolkit.dump_variable_metadata(x).variable_source == :variables + +@parameters y +@test ModelingToolkit.getvariabletype(y) == ModelingToolkit.PARAMETER + +@brownian z +@test ModelingToolkit.getvariabletype(z) == ModelingToolkit.BROWNIAN diff --git a/test/units.jl b/test/units.jl index 99d3d7b2e1..ff0cd42ac3 100644 --- a/test/units.jl +++ b/test/units.jl @@ -1,25 +1,242 @@ -using ModelingToolkit, Unitful -using Test - -t = Variable{u"s"}(:t)() -x = Variable{u"kg"}(:x)(t) -y = Variable{u"kg"}(:y)(t) -D = Differential(t) - -eq1 = x ~ y*t -eq2 = x*10u"s" ~ y*t - -@test ModelingToolkit.instantiate(t) == 1u"s" -@test ModelingToolkit.instantiate(x) == 1u"kg" -@test ModelingToolkit.instantiate(y) == 1u"kg" - -@test !ModelingToolkit.validate(eq1) -@test ModelingToolkit.validate(eq2) - -eqs = [ - D(x) ~ y/t - D(y) ~ (x*y)/(t*10u"kg") -] - -sys = ODESystem(eqs,t,[x,y],[]) -@test ModelingToolkit.validate(sys) +using ModelingToolkit, OrdinaryDiffEq, JumpProcesses, Unitful +using Test +MT = ModelingToolkit +UMT = ModelingToolkit.UnitfulUnitCheck +@independent_variables t [unit = u"ms"] +@parameters τ [unit = u"ms"] γ +@variables E(t) [unit = u"kJ"] P(t) [unit = u"MW"] +D = Differential(t) + +#This is how equivalent works: +@test UMT.equivalent(u"MW", u"kJ/ms") +@test !UMT.equivalent(u"m", u"cm") +@test UMT.equivalent(UMT.get_unit(P^γ), UMT.get_unit((E / τ)^γ)) + +# Basic access +@test UMT.get_unit(t) == u"ms" +@test UMT.get_unit(E) == u"kJ" +@test UMT.get_unit(τ) == u"ms" +@test UMT.get_unit(γ) == UMT.unitless +@test UMT.get_unit(0.5) == UMT.unitless +@test UMT.get_unit(UMT.SciMLBase.NullParameters()) == UMT.unitless + +# Prohibited unit types +@parameters β [unit = u"°"] α [unit = u"°C"] γ [unit = 1u"s"] +@test_throws UMT.ValidationError UMT.get_unit(β) +@test_throws UMT.ValidationError UMT.get_unit(α) +@test_throws UMT.ValidationError UMT.get_unit(γ) + +# Non-trivial equivalence & operators +@test UMT.get_unit(τ^-1) == u"ms^-1" +@test UMT.equivalent(UMT.get_unit(D(E)), u"MW") +@test UMT.equivalent(UMT.get_unit(E / τ), u"MW") +@test UMT.get_unit(2 * P) == u"MW" +@test UMT.get_unit(t / τ) == UMT.unitless +@test UMT.equivalent(UMT.get_unit(P - E / τ), u"MW") +@test UMT.equivalent(UMT.get_unit(D(D(E))), u"MW/ms") +@test UMT.get_unit(ifelse(t > t, P, E / τ)) == u"MW" +@test UMT.get_unit(1.0^(t / τ)) == UMT.unitless +@test UMT.get_unit(exp(t / τ)) == UMT.unitless +@test UMT.get_unit(sin(t / τ)) == UMT.unitless +@test UMT.get_unit(sin(1 * u"rad")) == UMT.unitless +@test UMT.get_unit(t^2) == u"ms^2" + +eqs = [D(E) ~ P - E / τ + 0 ~ P] +@test UMT.validate(eqs) +@named sys = ODESystem(eqs, t) + +@test !UMT.validate(D(D(E)) ~ P) +@test !UMT.validate(0 ~ P + E * τ) + +# Disabling unit validation/checks selectively +@test_throws MT.ArgumentError ODESystem(eqs, t, [E, P, t], [τ], name = :sys) +ODESystem(eqs, t, [E, P, t], [τ], name = :sys, checks = MT.CheckUnits) +eqs = [D(E) ~ P - E / τ + 0 ~ P + E * τ] +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, checks = MT.CheckAll) +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, checks = true) +ODESystem(eqs, t, name = :sys, checks = MT.CheckNone) +ODESystem(eqs, t, name = :sys, checks = false) +@test_throws MT.ValidationError ODESystem(eqs, t, name = :sys, + checks = MT.CheckComponents | MT.CheckUnits) +@named sys = ODESystem(eqs, t, checks = MT.CheckComponents) +@test_throws MT.ValidationError ODESystem(eqs, t, [E, P, t], [τ], name = :sys, + checks = MT.CheckUnits) + +# connection validation +@connector function Pin(; name) + sts = @variables(v(t)=1.0, [unit = u"V"], + i(t)=1.0, [unit = u"A", connect = Flow]) + ODESystem(Equation[], t, sts, []; name = name) +end +@connector function OtherPin(; name) + sts = @variables(v(t)=1.0, [unit = u"mV"], + i(t)=1.0, [unit = u"mA", connect = Flow]) + ODESystem(Equation[], t, sts, []; name = name) +end +@connector function LongPin(; name) + sts = @variables(v(t)=1.0, [unit = u"V"], + i(t)=1.0, [unit = u"A", connect = Flow], + x(t)=1.0, [unit = NoUnits]) + ODESystem(Equation[], t, sts, []; name = name) +end +@named p1 = Pin() +@named p2 = Pin() +@named op = OtherPin() +@named lp = LongPin() +good_eqs = [connect(p1, p2)] +bad_eqs = [connect(p1, p2, op)] +bad_length_eqs = [connect(op, lp)] +@test UMT.validate(good_eqs) +@test !UMT.validate(bad_eqs) +@test !UMT.validate(bad_length_eqs) +@named sys = ODESystem(good_eqs, t, [], []) +@test_throws MT.ValidationError ODESystem(bad_eqs, t, [], []; name = :sys) + +# Array variables +@independent_variables t [unit = u"s"] +@parameters v[1:3]=[1, 2, 3] [unit = u"m/s"] +@variables x(t)[1:3] [unit = u"m"] +D = Differential(t) +eqs = D.(x) .~ v +ODESystem(eqs, t, name = :sys) + +# Nonlinear system +@parameters a [unit = u"kg"^-1] +@variables x [unit = u"kg"] +eqs = [ + 0 ~ a * x +] +@named nls = NonlinearSystem(eqs, [x], [a]) + +# SDE test w/ noise vector +@independent_variables t [unit = u"ms"] +@parameters τ [unit = u"ms"] Q [unit = u"MW"] +@variables E(t) [unit = u"kJ"] P(t) [unit = u"MW"] +D = Differential(t) +eqs = [D(E) ~ P - E / τ + P ~ Q] + +noiseeqs = [0.1u"MW", + 0.1u"MW"] +@named sys = SDESystem(eqs, noiseeqs, t, [P, E], [τ, Q]) + +# With noise matrix +noiseeqs = [0.1u"MW" 0.1u"MW" + 0.1u"MW" 0.1u"MW"] +@named sys = SDESystem(eqs, noiseeqs, t, [P, E], [τ, Q]) + +# Invalid noise matrix +noiseeqs = [0.1u"MW" 0.1u"MW" + 0.1u"MW" 0.1u"s"] +@test !UMT.validate(eqs, noiseeqs) + +# Non-trivial simplifications +@independent_variables t [unit = u"s"] +@parameters v [unit = u"m/s"] r [unit = u"m"^3 / u"s"] +@variables V(t) [unit = u"m"^3] L(t) [unit = u"m"] +D = Differential(t) +eqs = [D(L) ~ v, + V ~ L^3] +@named sys = ODESystem(eqs, t) +sys_simple = structural_simplify(sys) + +eqs = [D(V) ~ r, + V ~ L^3] +@named sys = ODESystem(eqs, t) +sys_simple = structural_simplify(sys) + +@variables V [unit = u"m"^3] L [unit = u"m"] +@parameters v [unit = u"m/s"] r [unit = u"m"^3 / u"s"] t [unit = u"s"] +eqs = [V ~ r * t, + V ~ L^3] +@named sys = NonlinearSystem(eqs, [V, L], [t, r]) +sys_simple = structural_simplify(sys) + +eqs = [L ~ v * t, + V ~ L^3] +@named sys = NonlinearSystem(eqs, [V, L], [t, r]) +sys_simple = structural_simplify(sys) + +#Jump System +@parameters β [unit = u"(mol^2*s)^-1"] γ [unit = u"(mol*s)^-1"] t [unit = u"s"] jumpmol [ + unit = u"mol" +] +@variables S(t) [unit = u"mol"] I(t) [unit = u"mol"] R(t) [unit = u"mol"] +rate₁ = β * S * I +affect₁ = [S ~ S - 1 * jumpmol, I ~ I + 1 * jumpmol] +rate₂ = γ * I +affect₂ = [I ~ I - 1 * jumpmol, R ~ R + 1 * jumpmol] +j₁ = ConstantRateJump(rate₁, affect₁) +j₂ = VariableRateJump(rate₂, affect₂) +js = JumpSystem([j₁, j₂], t, [S, I, R], [β, γ], name = :sys) + +affect_wrong = [S ~ S - jumpmol, I ~ I + 1] +j_wrong = ConstantRateJump(rate₁, affect_wrong) +@test_throws MT.ValidationError JumpSystem([j_wrong, j₂], t, [S, I, R], [β, γ], name = :sys) + +rate_wrong = γ^2 * I +j_wrong = ConstantRateJump(rate_wrong, affect₂) +@test_throws MT.ValidationError JumpSystem([j₁, j_wrong], t, [S, I, R], [β, γ], name = :sys) + +# mass action jump tests for SIR model +maj1 = MassActionJump(2 * β / 2, [S => 1, I => 1], [S => -1, I => 1]) +maj2 = MassActionJump(γ, [I => 1], [I => -1, R => 1]) +@named js3 = JumpSystem([maj1, maj2], t, [S, I, R], [β, γ]) + +#Test unusual jump system +@parameters β γ t +@variables S(t) I(t) R(t) + +maj1 = MassActionJump(2.0, [0 => 1], [S => 1]) +maj2 = MassActionJump(γ, [S => 1], [S => -1]) +@named js4 = JumpSystem([maj1, maj2], t, [S], [β, γ]) + +@mtkmodel ParamTest begin + @parameters begin + a, [unit = u"m"] + end + @variables begin + b(t), [unit = u"kg"] + end +end + +@named sys = ParamTest() + +@named sys = ParamTest(a = 3.0u"cm") +@test ModelingToolkit.getdefault(sys.a) ≈ 0.03 + +@test_throws ErrorException ParamTest(; name = :t, a = 1.0) +@test_throws ErrorException ParamTest(; name = :t, a = 1.0u"s") + +@mtkmodel ArrayParamTest begin + @parameters begin + a[1:2], [unit = u"m"] + end +end + +@named sys = ArrayParamTest() + +@named sys = ArrayParamTest(a = [1.0, 3.0]u"cm") +@test ModelingToolkit.getdefault(sys.a) ≈ [0.01, 0.03] + +@variables x(t) +@test ModelingToolkit.get_unit(sin(x)) == ModelingToolkit.unitless + +@mtkmodel ExpressionParametersTest begin + @parameters begin + v = 1.0, [unit = u"m/s"] + τ = 1.0, [unit = u"s"] + end + @components begin + pt = ParamTest(; a = v * τ) + end +end + +@named sys = ExpressionParametersTest(; v = 2.0u"m/s", τ = 3.0u"s") +sys = complete(sys) +# TODO: Is there a way to evaluate this expression and compare to 6.0? +@test isequal(ModelingToolkit.getdefault(sys.pt.a), sys.v * sys.τ) +@test ModelingToolkit.getdefault(sys.v) ≈ 2.0 +@test ModelingToolkit.getdefault(sys.τ) ≈ 3.0 diff --git a/test/variable_parsing.jl b/test/variable_parsing.jl index 17900e703b..1ea366d045 100644 --- a/test/variable_parsing.jl +++ b/test/variable_parsing.jl @@ -1,10 +1,10 @@ using ModelingToolkit using Test -using ModelingToolkit: value +using ModelingToolkit: value, Flow using SymbolicUtils: FnType -@parameters t +@independent_variables t @variables x(t) y(t) # test multi-arg @variables z(t) # test single-arg @@ -27,64 +27,59 @@ s1 = Num(Sym{Real}(:s)) σ1 = Num(Sym{FnType{Tuple, Real}}(:σ)) @test isequal(t1, t) @test isequal(s1, s) -@test isequal(σ1, σ) +@test isequal(σ1(t), σ(t)) @test ModelingToolkit.isparameter(t) @test ModelingToolkit.isparameter(s) @test ModelingToolkit.isparameter(σ) -@derivatives D'~t -D1 = Differential(t) -@test D1 == D - @test @macroexpand(@parameters x, y, z(t)) == @macroexpand(@parameters x y z(t)) @test @macroexpand(@variables x, y, z(t)) == @macroexpand(@variables x y z(t)) # Test array expressions @parameters begin t[1:2] - s[1:2:4,1:2] + s[1:4, 1:2] end -@parameters σ[1:2](..) - -@test all(ModelingToolkit.isparameter, t) -@test all(ModelingToolkit.isparameter, s) -@test all(ModelingToolkit.isparameter, σ) - -fntype(n, T) = FnType{NTuple{n, Any}, T} -t1 = Num[Variable{Real}(:t, 1), Variable{Real}(:t, 2)] -s1 = Num[Variable{Real}(:s, 1, 1) Variable{Real}(:s, 1, 2); - Variable{Real}(:s, 3, 1) Variable{Real}(:s, 3, 2)] -σ1 = [Num(Variable{fntype(1, Real)}(:σ, 1)), Num(Variable{fntype(1, Real)}(:σ, 2))] -@test isequal(t1, t) -@test isequal(s1, s) -@test isequal(σ1, σ) - -@parameters t -@variables x[1:2](t) -x1 = Num[Variable{FnType{Tuple{Any}, Real}}(:x, 1)(t.val), - Variable{FnType{Tuple{Any}, Real}}(:x, 2)(t.val)] - -@test isequal(x1, x) - -@variables a[1:11,1:2] +@parameters σ(..)[1:2] + +@test all(ModelingToolkit.isparameter, collect(t)) +@test all(ModelingToolkit.isparameter, collect(s)) +@test all(ModelingToolkit.isparameter, Any[σ(t)[1], σ(t)[2]]) + +# fntype(n, T) = FnType{NTuple{n, Any}, T} +# t1 = Num[Variable{Real}(:t, 1), Variable{Real}(:t, 2)] +# s1 = Num[Variable{Real}(:s, 1, 1) Variable{Real}(:s, 1, 2); +# Variable{Real}(:s, 3, 1) Variable{Real}(:s, 3, 2)] +# σ1 = [Num(Variable{fntype(1, Real)}(:σ, 1)), Num(Variable{fntype(1, Real)}(:σ, 2))] +# @test isequal(t1, collect(t)) +# @test isequal(s1, collect(s)) +# @test isequal(σ1, σ) + +#@independent_variables t +#@variables x[1:2](t) +#x1 = Num[Variable{FnType{Tuple{Any}, Real}}(:x, 1)(t.val), +# Variable{FnType{Tuple{Any}, Real}}(:x, 2)(t.val)] +# +#@test isequal(x1, x) + +@variables a[1:11, 1:2] @variables a() using Symbolics: value, VariableDefaultValue using ModelingToolkit: VariableConnectType, VariableUnit, rename using Unitful -vals = [1,2,3,4] +vals = [1, 2, 3, 4] @variables x=1 xs[1:4]=vals ys[1:5]=1 @test getmetadata(x, VariableDefaultValue) === 1 -@test getmetadata.(xs, (VariableDefaultValue,)) == vals -@test getmetadata.(ys, (VariableDefaultValue,)) == ones(Int, 5) +@test getmetadata.(collect(xs), (VariableDefaultValue,)) == vals +@test getmetadata.(collect(ys), (VariableDefaultValue,)) == ones(Int, 5) -struct Flow end u = u"m^3/s" @variables begin - x = [1, 2], [connect=Flow,unit=u] + x = [1, 2], [connect = Flow, unit = u] y = 2 end @@ -93,11 +88,19 @@ end @test getmetadata(x, VariableUnit) == u @test getmetadata(y, VariableDefaultValue) === 2 +@variables x=[1, 2] [connect = Flow, unit = u] y=2 + +@test getmetadata(x, VariableDefaultValue) == [1, 2] +@test getmetadata(x, VariableConnectType) == Flow +@test getmetadata(x, VariableUnit) == u +@test getmetadata(y, VariableDefaultValue) === 2 + @variables begin - x, [connect=Flow,unit=u] - y = 2, [connect=Flow] + x, [connect = Flow, unit = u] + y = 2, [connect = Flow] end +@test_throws ErrorException ModelingToolkit.getdefault(x) @test !hasmetadata(x, VariableDefaultValue) @test getmetadata(x, VariableConnectType) == Flow @test getmetadata(x, VariableUnit) == u @@ -105,11 +108,13 @@ end @test getmetadata(y, VariableConnectType) == Flow a = rename(value(x), :a) -@test !hasmetadata(x, VariableDefaultValue) -@test getmetadata(x, VariableConnectType) == Flow -@test getmetadata(x, VariableUnit) == u +@test_throws ErrorException ModelingToolkit.getdefault(a) +@test !hasmetadata(a, VariableDefaultValue) +@test getmetadata(a, VariableConnectType) == Flow +@test getmetadata(a, VariableUnit) == u -@variables t x(t)=1 [connect=Flow,unit=u] +@independent_variables t +@variables x(t)=1 [connect = Flow, unit = u] @test getmetadata(x, VariableDefaultValue) == 1 @test getmetadata(x, VariableConnectType) == Flow @@ -120,10 +125,10 @@ a = rename(value(x), :a) @test getmetadata(a, VariableConnectType) == Flow @test getmetadata(a, VariableUnit) == u -@parameters p=2 [unit=u"m",] +@parameters p=2 [unit = u"m"] @test getmetadata(p, VariableDefaultValue) == 2 @test !hasmetadata(p, VariableConnectType) @test getmetadata(p, VariableUnit) == u"m" @test ModelingToolkit.isparameter(p) -@test_throws Any (@macroexpand @parameters p=2 [unit=u"m",abc=2]) +@test_throws Any (@macroexpand @parameters p=2 [unit = u"m", abc = 2]) diff --git a/test/variable_scope.jl b/test/variable_scope.jl index 04954a9905..bd1d3cb0cf 100644 --- a/test/variable_scope.jl +++ b/test/variable_scope.jl @@ -1,35 +1,141 @@ using ModelingToolkit +using ModelingToolkit: SymScope, t_nounits as t, D_nounits as D +using Symbolics: arguments, value, getname using Test -@parameters t -@variables a b(t) c d +@variables a b(t) c d e(t) b = ParentScope(b) c = ParentScope(ParentScope(c)) d = GlobalScope(d) +@test all(x -> x isa Num, [b, c, d]) -renamed(nss, sym) = ModelingToolkit.getname(foldr(ModelingToolkit.renamespace, nss, init=sym)) +# ensure it works on Term too +LocalScope(e.val) +ParentScope(e.val) +GlobalScope(e.val) -@test renamed([:foo :bar :baz], a) == :foo₊bar₊baz₊a -@test renamed([:foo :bar :baz], b) == :foo₊bar₊b -@test renamed([:foo :bar :baz], c) == :foo₊c -@test renamed([:foo :bar :baz], d) == :d +ie = ParentScope(1 / e) +@test getmetadata(arguments(value(ie))[2], SymScope) === ParentScope(LocalScope()) -eqs = [ - 0 ~ a - 0 ~ b - 0 ~ c - 0 ~ d -] +eqs = [0 ~ a + 0 ~ b + 0 ~ c + 0 ~ d] @named sub4 = NonlinearSystem(eqs, [a, b, c, d], []) @named sub3 = NonlinearSystem(eqs, [a, b, c, d], []) -@named sub2 = NonlinearSystem([], [], [], systems=[sub3, sub4]) -@named sub1 = NonlinearSystem([], [], [], systems=[sub2]) -@named sys = NonlinearSystem([], [], [], systems=[sub1]) +@named sub2 = NonlinearSystem([], [], [], systems = [sub3, sub4]) +@named sub1 = NonlinearSystem([], [], [], systems = [sub2]) +@named sys = NonlinearSystem([], [], [], systems = [sub1]) -names = ModelingToolkit.getname.(states(sys)) +names = ModelingToolkit.getname.(unknowns(sys)) @test :d in names -@test :sub1₊c in names -@test :sub1₊sub2₊b in names -@test :sub1₊sub2₊sub3₊a in names -@test :sub1₊sub2₊sub4₊a in names \ No newline at end of file +@test Symbol("sub1₊c") in names +@test Symbol("sub1₊sub2₊b") in names +@test Symbol("sub1₊sub2₊sub3₊a") in names +@test Symbol("sub1₊sub2₊sub4₊a") in names + +@named foo = NonlinearSystem(eqs, [a, b, c, d], []) +@named bar = NonlinearSystem(eqs, [a, b, c, d], []) +@test ModelingToolkit.getname(ModelingToolkit.namespace_expr( + ModelingToolkit.namespace_expr(b, + foo), + bar)) == Symbol("bar₊b") + +function renamed(nss, sym) + ModelingToolkit.getname(foldr(ModelingToolkit.renamespace, nss, init = sym)) +end + +@test renamed([:foo :bar :baz], a) == Symbol("foo₊bar₊baz₊a") +@test renamed([:foo :bar :baz], b) == Symbol("foo₊bar₊b") +@test renamed([:foo :bar :baz], c) == Symbol("foo₊c") +@test renamed([:foo :bar :baz], d) == :d + +@parameters a b c d e f +p = [a + ParentScope(b) + ParentScope(ParentScope(c)) + DelayParentScope(d) + DelayParentScope(e, 2) + GlobalScope(f)] + +level0 = ODESystem(Equation[], t, [], p; name = :level0) +level1 = ODESystem(Equation[], t, [], []; name = :level1) ∘ level0 +level2 = ODESystem(Equation[], t, [], []; name = :level2) ∘ level1 +level3 = ODESystem(Equation[], t, [], []; name = :level3) ∘ level2 + +ps = ModelingToolkit.getname.(parameters(level3)) + +@test isequal(ps[1], :level2₊level1₊level0₊a) +@test isequal(ps[2], :level2₊level1₊b) +@test isequal(ps[3], :level2₊c) +@test isequal(ps[4], :level2₊level0₊d) +@test isequal(ps[5], :level1₊level0₊e) +@test isequal(ps[6], :f) + +# Issue@2252 +# Tests from PR#2354 +@parameters xx[1:2] +arr_p = [ParentScope(xx[1]), xx[2]] +arr0 = ODESystem(Equation[], t, [], arr_p; name = :arr0) +arr1 = ODESystem(Equation[], t, [], []; name = :arr1) ∘ arr0 +arr_ps = ModelingToolkit.getname.(parameters(arr1)) +@test isequal(arr_ps[1], Symbol("xx")) +@test isequal(arr_ps[2], Symbol("arr0₊xx")) + +function Foo(; name, p = 1) + @parameters p = p + @variables x(t) + return ODESystem(D(x) ~ p, t; name) +end +function Bar(; name, p = 2) + @parameters p = p + @variables x(t) + @named foo = Foo(; p) + return ODESystem(D(x) ~ p + t, t; systems = [foo], name) +end +@named bar = Bar() +bar = complete(bar) +@test length(parameters(bar)) == 2 +@test sort(getname.(parameters(bar))) == [:foo₊p, :p] +defs = ModelingToolkit.defaults(bar) +@test defs[bar.p] == 2 +@test isequal(defs[bar.foo.p], bar.p) + +# Issue#3101 +@variables x1(t) x2(t) x3(t) x4(t) x5(t) +x2 = ParentScope(x2) +x3 = ParentScope(ParentScope(x3)) +x4 = DelayParentScope(x4) +x5 = GlobalScope(x5) +@parameters p1 p2 p3 p4 p5 +p2 = ParentScope(p2) +p3 = ParentScope(ParentScope(p3)) +p4 = DelayParentScope(p4) +p5 = GlobalScope(p5) + +@named sys1 = ODESystem([D(x1) ~ p1, D(x2) ~ p2, D(x3) ~ p3, D(x4) ~ p4, D(x5) ~ p5], t) +@test isequal(x1, only(unknowns(sys1))) +@test isequal(p1, only(parameters(sys1))) +@named sys2 = ODESystem(Equation[], t; systems = [sys1]) +@test length(unknowns(sys2)) == 2 +@test any(isequal(x2), unknowns(sys2)) +@test length(parameters(sys2)) == 2 +@test any(isequal(p2), parameters(sys2)) +@named sys3 = ODESystem(Equation[], t) +sys3 = sys3 ∘ sys2 +@test length(unknowns(sys3)) == 4 +@test any(isequal(x3), unknowns(sys3)) +@test any(isequal(ModelingToolkit.renamespace(sys1, x4)), unknowns(sys3)) +@test length(parameters(sys3)) == 4 +@test any(isequal(p3), parameters(sys3)) +@test any(isequal(ModelingToolkit.renamespace(sys1, p4)), parameters(sys3)) +sys4 = complete(sys3) +@test length(unknowns(sys3)) == 4 +@test length(parameters(sys4)) == 5 +@test any(isequal(p5), parameters(sys4)) +sys5 = structural_simplify(sys3) +@test length(unknowns(sys5)) == 5 +@test any(isequal(x5), unknowns(sys5)) +@test length(parameters(sys5)) == 5 +@test any(isequal(p5), parameters(sys5)) diff --git a/test/variable_utils.jl b/test/variable_utils.jl index a39bc2e98f..3204d28836 100644 --- a/test/variable_utils.jl +++ b/test/variable_utils.jl @@ -1,27 +1,160 @@ -using ModelingToolkit, Test -using ModelingToolkit: value -using SymbolicUtils: <ₑ -@parameters α β δ -expr = (((1 / β - 1) + δ) / α) ^ (1 / (α - 1)) -ref = sort([β, δ, α], lt = <ₑ) -sol = sort(Num.(ModelingToolkit.get_variables(expr)), lt = <ₑ) -@test all(x->x isa Num, sol[i] == ref[i] for i in 1:3) -@test all(simplify∘value, sol[i] == ref[i] for i in 1:3) - -@parameters γ -s = α => γ -expr = (((1 / β - 1) + δ) / α) ^ (1 / (α - 1)) -sol = ModelingToolkit.substitute(expr, s) -new = (((1 / β - 1) + δ) / γ) ^ (1 / (γ - 1)) -@test iszero(sol - new) - -# test namespace_expr -@parameters t a p(t) -pterm = p.val -pnsp = ModelingToolkit.namespace_expr(pterm, :namespace, :t) -@test typeof(pterm) == typeof(pnsp) -@test ModelingToolkit.getname(pnsp) == Symbol("namespace₊p") -asym = a.val -ansp = ModelingToolkit.namespace_expr(asym, :namespace, :t) -@test typeof(asym) == typeof(ansp) -@test ModelingToolkit.getname(ansp) == Symbol("namespace₊a") +using ModelingToolkit, Test +using ModelingToolkit: value, vars, parse_variable +using SymbolicUtils: <ₑ + +@parameters α β δ +expr = (((1 / β - 1) + δ) / α)^(1 / (α - 1)) +ref = sort([β, δ, α], lt = <ₑ) +sol = sort(Num.(ModelingToolkit.get_variables(expr)), lt = <ₑ) +@test all(x -> x isa Num, sol[i] == ref[i] for i in 1:3) +@test all(simplify ∘ value, sol[i] == ref[i] for i in 1:3) + +@parameters γ +s = α => γ +expr = (((1 / β - 1) + δ) / α)^(1 / (α - 1)) +sol = ModelingToolkit.substitute(expr, s) +new = (((1 / β - 1) + δ) / γ)^(1 / (γ - 1)) +@test iszero(sol - new) + +# Continuous +using ModelingToolkit: isdifferential, vars, collect_differential_variables, + collect_ivs +@independent_variables t +@variables u(t) y(t) +D = Differential(t) +eq = D(y) ~ u +v = vars(eq) +@test v == Set([D(y), u]) + +ov = collect_differential_variables(eq) +@test ov == Set(Any[y]) + +aov = ModelingToolkit.collect_applied_operators(eq, Differential) +@test aov == Set(Any[D(y)]) + +ts = collect_ivs([eq]) +@test ts == Set([t]) + +@testset "vars searching through array of symbolics" begin + fn(x, y) = sum(x) + y + @register_symbolic fn(x::AbstractArray, y) + @variables x y z + res = vars(fn([x, y], z)) + @test length(res) == 3 +end + +@testset "parse_variable with iv: $iv" for iv in [t, only(@independent_variables tt)] + D = Differential(iv) + function Lorenz(; name) + @variables begin + x(iv) + y(iv) + z(iv) + end + @parameters begin + σ + ρ + β + end + sys = ODESystem( + [D(D(x)) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + D(z) ~ x * y - β * z], iv; name) + end + function ArrSys(; name) + @variables begin + x(iv)[1:2] + end + @parameters begin + p[1:2, 1:2] + end + sys = ODESystem([D(D(x)) ~ p * x], iv; name) + end + function Outer(; name) + @named 😄 = Lorenz() + @named arr = ArrSys() + sys = ODESystem(Equation[], iv; name, systems = [😄, arr]) + end + + @mtkbuild sys = Outer() + for (str, var) in [ + # unicode system, scalar variable + ("😄.x", sys.😄.x), + ("😄.x($iv)", sys.😄.x), + ("😄₊x", sys.😄.x), + ("😄₊x($iv)", sys.😄.x), + # derivative + ("D(😄.x)", D(sys.😄.x)), + ("D(😄.x($iv))", D(sys.😄.x)), + ("D(😄₊x)", D(sys.😄.x)), + ("D(😄₊x($iv))", D(sys.😄.x)), + ("Differential($iv)(😄.x)", D(sys.😄.x)), + ("Differential($iv)(😄.x($iv))", D(sys.😄.x)), + ("Differential($iv)(😄₊x)", D(sys.😄.x)), + ("Differential($iv)(😄₊x($iv))", D(sys.😄.x)), + # other derivative + ("😄.xˍ$iv", D(sys.😄.x)), + ("😄.x($iv)ˍ$iv", D(sys.😄.x)), + ("😄₊xˍ$iv", D(sys.😄.x)), + ("😄₊x($iv)ˍ$iv", D(sys.😄.x)), + # scalar parameter + ("😄.σ", sys.😄.σ), + ("😄₊σ", sys.😄.σ), + # array variable + ("arr.x", sys.arr.x), + ("arr₊x", sys.arr.x), + ("arr.x($iv)", sys.arr.x), + ("arr₊x($iv)", sys.arr.x), + # getindex + ("arr.x[1]", sys.arr.x[1]), + ("arr₊x[1]", sys.arr.x[1]), + ("arr.x($iv)[1]", sys.arr.x[1]), + ("arr₊x($iv)[1]", sys.arr.x[1]), + # derivative + ("D(arr.x($iv))", D(sys.arr.x)), + ("D(arr₊x($iv))", D(sys.arr.x)), + ("D(arr.x[1])", D(sys.arr.x[1])), + ("D(arr₊x[1])", D(sys.arr.x[1])), + ("D(arr.x($iv)[1])", D(sys.arr.x[1])), + ("D(arr₊x($iv)[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr.x($iv))", D(sys.arr.x)), + ("Differential($iv)(arr₊x($iv))", D(sys.arr.x)), + ("Differential($iv)(arr.x[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr₊x[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr.x($iv)[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr₊x($iv)[1])", D(sys.arr.x[1])), + # other derivative + ("arr.xˍ$iv", D(sys.arr.x)), + ("arr₊xˍ$iv", D(sys.arr.x)), + ("arr.xˍ$iv($iv)", D(sys.arr.x)), + ("arr₊xˍ$iv($iv)", D(sys.arr.x)), + ("arr.xˍ$iv[1]", D(sys.arr.x[1])), + ("arr₊xˍ$iv[1]", D(sys.arr.x[1])), + ("arr.xˍ$iv($iv)[1]", D(sys.arr.x[1])), + ("arr₊xˍ$iv($iv)[1]", D(sys.arr.x[1])), + ("arr.x($iv)ˍ$iv", D(sys.arr.x)), + ("arr₊x($iv)ˍ$iv", D(sys.arr.x)), + ("arr.x($iv)ˍ$iv[1]", D(sys.arr.x[1])), + ("arr₊x($iv)ˍ$iv[1]", D(sys.arr.x[1])), + # array parameter + ("arr.p", sys.arr.p), + ("arr₊p", sys.arr.p), + ("arr.p[1, 2]", sys.arr.p[1, 2]), + ("arr₊p[1, 2]", sys.arr.p[1, 2]) + ] + @test isequal(parse_variable(sys, str), var) + end +end + +@testset "isinitial" begin + t = ModelingToolkit.t_nounits + @variables x(t) z(t)[1:5] + @parameters a b c[1:4] + @test isinitial(Initial(z)) + @test isinitial(Initial(x)) + @test isinitial(Initial(a)) + @test isinitial(Initial(z[1])) + @test isinitial(Initial(c[4])) + @test !isinitial(c) + @test !isinitial(x) +end