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..e696b42380 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -4,21 +4,36 @@ on: push: branches: - master + - v10 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..ecfbcb584b 100644 --- a/.github/workflows/Downstream.yml +++ b/.github/workflows/Downstream.yml @@ -4,10 +4,19 @@ on: branches: [master] tags: [v*] pull_request: + paths-ignore: + - 'docs/**' + - 'benchmark/**' + +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 +26,32 @@ jobs: julia-version: [1] os: [ubuntu-latest] package: + - {user: SciML, repo: SciMLBase.jl, group: Downstream} + - {user: SciML, repo: SciMLBase.jl, group: SymbolicIndexingInterface} - {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} + - {user: SciML, repo: SciMLSensitivity.jl, group: Core8} + - {user: Neuroblox, repo: Neuroblox.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 +63,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 +72,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..0d3052b969 --- /dev/null +++ b/.github/workflows/FormatCheck.yml @@ -0,0 +1,14 @@ +name: "Format Check" + +on: + push: + branches: + - 'master' + - v10 + 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/ReleaseTest.yml b/.github/workflows/ReleaseTest.yml new file mode 100644 index 0000000000..a9e1ee1821 --- /dev/null +++ b/.github/workflows/ReleaseTest.yml @@ -0,0 +1,63 @@ +name: ReleaseTest +on: + push: + branches: [master] + tags: [v*] + pull_request: + paths-ignore: + - 'docs/**' + - 'benchmark/**' + +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.package }}/${{ matrix.package.group }}/${{ matrix.julia-version }} + runs-on: ${{ matrix.os }} + env: + GROUP: ${{ matrix.package.group }} + strategy: + fail-fast: false + matrix: + julia-version: [1] + os: [ubuntu-latest] + package: + - {package: Catalyst, group: All} + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.julia-version }} + arch: x64 + - uses: julia-actions/julia-buildpkg@latest + - name: Create test directory + run: mkdir downstream + - name: Load this and run the downstream tests + shell: julia --color=yes --project=downstream {0} + run: | + using Pkg + try + Pkg.activate("downstream") + # force it to use this PR's version of the package + Pkg.develop(PackageSpec(path=".")) # resolver may fail with main deps + Pkg.add("${{ matrix.package.package }}") + Pkg.update() + Pkg.test("Catalyst"; 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 + # It means we marked this as a breaking change, so we don't need to worry about + # Mistakenly introducing a breaking change, as we have intentionally made one + @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/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..5b2bca6289 --- /dev/null +++ b/.github/workflows/Tests.yml @@ -0,0 +1,48 @@ +name: "Tests" + +on: + pull_request: + branches: + - master + - 'release-' + - v10 + paths-ignore: + - 'docs/**' + push: + branches: + - master + paths-ignore: + - 'docs/**' + - 'benchmark/**' + +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/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000000..6fa719dcdb --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,26 @@ +name: Benchmark this PR +on: + pull_request: + branches: + - master + paths-ignore: + - 'docs/**' + +permissions: + pull-requests: write # needed to post comments + +jobs: + bench: + name: "Benchmarks" + strategy: + matrix: + version: + - "1" + - "lts" + runs-on: ubuntu-latest + steps: + - uses: MilesCranmer/AirspeedVelocity.jl@action-v1 + with: + julia-version: "${{ matrix.version }}" + script: "benchmark/benchmarks.jl" + extra-pkgs: "ModelingToolkitStandardLibrary,OrdinaryDiffEqDefault" 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..9193389ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ Manifest.toml .vscode .vscode/* +docs/src/assets/Project.toml +docs/src/assets/Manifest.toml +.CondaPkg 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..726bf1628f --- /dev/null +++ b/NEWS.md @@ -0,0 +1,191 @@ +# ModelingToolkit v10 Release Notes + +## Callbacks + +Callback semantics have changed. + + - There is a new `Pre` operator that is used to specify which values are before the callback. + For example, the affect `A ~ A + 1` should now be written as `A ~ Pre(A) + 1`. This is + **required** to be specified - `A ~ A + 1` will now be interpreted as an equation to be + satisfied after the callback (and will thus error since it is unsatisfiable). + + - All parameters that are changed by a callback must be declared as discrete parameters to + the callback constructor, using the `discrete_parameters` keyword argument. + +```julia +event = SymbolicDiscreteCallback( + [t == 1] => [p ~ Pre(p) + 1], discrete_parameters = [p]) +``` + +## New `mtkcompile` and `@mtkcompile` + +`structural_simplify` is now renamed to `mtkcompile`. `@mtkbuild` is renamed to +`@mtkcompile`. Their functionality remains the same. However, instead of a second +positional argument `structural_simplify(sys, (inputs, outputs))` the inputs and outputs +should be specified via keyword arguments as `mtkcompile(sys; inputs, outputs, disturbance_inputs)`. + +## Reduce reliance on metadata in `mtkcompile` + +Previously, `mtkcompile` (formerly `structural_simplify`) used to rely on the metadata of +symbolic variables to identify variables/parameters/brownians. This was regardless of +what the system expected the variable to be. Now, it respects the information in the system. + +## Unified `System` type + +There is now a single common `System` type for all types of models except PDEs, for which +`PDESystem` still exists. It follows the same syntax as `ODESystem` and `NonlinearSystem` +did. `System(equations, t[, vars, pars])` will construct a time-dependent system. +`System(equations[, vars, pars])` will construct a time-independent system. Refer to the +docstring for `System` for further information. + +Utility constructors are defined for: + + - `NonlinearSystem(sys)` to convert a time-dependent system to a time-independent one for + its steady state. + - `SDESystem(sys, noise_eqs)` to add noise to a system + - `JumpSystem(jumps, ...)` to define a system with jumps. Note that normal equations can + also be passed to `jumps`. + - `OptimizationSystem(cost, ...)` to define a system for optimization. + +All problem constructors validate that the system matches the expected structure for +that problem. + +## No more `parameter_dependencies` + +The `parameter_dependencies` keyword is deprecated. All equations previously passed here +should now be provided as part of the standard equations of the system. If passing parameters +explicitly to the `System` constructor, the dependent parameters (on the left hand side of +parameter dependencies) should also be provided. These will be separated out when calling +`complete` or `mtkcompile`. Calling `parameter_dependencies` or `dependent_parameters` now +requires that the system is completed. The new `SDESystem` constructor still retains the +`parameter_dependencies` keyword argument since the number of equations has to match the +number of columns in `noise_eqs`. + +ModelingToolkit now has discretion of what parameters are eliminated using the parameter +equations during `complete` or `mtkcompile`. + +## New problem and constructors + +Instead of `XProblem(sys, u0map, tspan, pmap)` for time-dependent problems and +`XProblem(sys, u0map, pmap)` for time-independent problems, the syntax has changed to +`XProblem(sys, op, tspan)` and `XProblem(sys, op)` respectively. `op` refers to the +operating point, and is a variable-value mapping containing both unknowns and parameters. + +`XFunction` constructors also no longer accept the list of unknowns and parameters as +positional arguments. + +## Removed `DelayParentScope` + +The outdated `DelayParentScope` has been removed. + +## Removed `XProblemExpr` and `XFunctionExpr` + +The old `XProblemExpr` and `XFunctionExpr` constructors used to build an `Expr` that +constructs `XProblem` and `XFunction` respectively are now removed. This functionality +is now available by passing `expression = Val{true}` to any problem or function constructor. + +## Renaming of `generate_*` and `calculate_*` methods + +Several `generate_*` methods have been renamed, along with some `calculate_*` methods. +The `generate_*` methods also no longer accept a list of unknowns and/or parameters. Refer +to the documentation for more information. + +## New behavior of `getproperty` and `setproperty!` + +Using `getproperty` to access fields of a system has been deprecated for a long time, and +this functionality is now removed. `setproperty!` previously used to update the default +of the accessed symbolic variable. This is not supported anymore. Defaults can be updated by +mutating `ModelingToolkit.get_defaults(sys)`. + +## New behavior of `@constants` + +`@constants` now creates parameters with the `tunable = false` metadata by default. + +## Removed `FunctionalAffect` + +`FunctionalAffect` is now removed in favor of the new `ImperativeAffect`. Refer to the +documentation for more information. + +## Improved system metadata + +Instead of an empty field that can contain arbitrary data, the `System` type stores metadata +identically to `SymbolicUtils.BasicSymbolic`. Metadata is stored in an immutable dictionary +keyed by a user-provided `DataType` and containing arbitrary values. `System` supports the +same `SymbolicUtils.getmetadata` and `SymbolicUtils.setmetadata` API as symbolic variables. +Refer to the documentation of `System` and the aforementioned functions for more information. + +## Moved `connect` and `Connector` to ModelingToolkit + +Previously ModelingToolkit used the `connect` function and `Connector` type defined in +Symbolics.jl. These have now been moved to ModelingToolkit along with the experimental +state machine API. If you imported them from Symbolics.jl, it is recommended to import from +ModelingToolkit instead. + +## Always wrap with `ParentScope` in `@named` + +When creating a system using `@named`, any symbolic quantities passed as keyword arguments +to the subsystem are wrapped in `ParentScope`. Previously, this would only happen if the +variable wasn't already wrapped in a `ParentScope`. However, the old behavior had issues +when passing symbolic quantities down multiple levels of the hierarchy. The `@named` macro +now always performs this wrapping. + +# 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..d92500f7e2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,90 +1,210 @@ name = "ModelingToolkit" uuid = "961ee093-0014-501f-94e3-6117800e7a78" -authors = ["Chris Rackauckas "] -version = "5.16.0" +authors = ["Yingbo Ma ", "Chris Rackauckas and contributors"] +version = "10.10.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" +ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +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" +ImplicitDiscreteSolve = "3263718b-31ed-49cf-8a0f-35a466e8af96" +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" +OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8" +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" +SciMLPublic = "431bcebd-1456-4ced-9d72-93c2757fff0b" +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" +CasADi = "c49709b8-5c63-11e9-2fb2-69db5844192f" +DeepDiffs = "ab62b9b5-e342-54a8-a765-a90f495de1a6" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" +Pyomo = "0e8e1daf-01b5-4eba-a626-3897743a3816" + +[extensions] +MTKBifurcationKitExt = "BifurcationKit" +MTKCasADiDynamicOptExt = "CasADi" +MTKDeepDiffsExt = "DeepDiffs" +MTKFMIExt = "FMI" +MTKInfiniteOptExt = "InfiniteOpt" +MTKLabelledArraysExt = "LabelledArrays" +MTKPyomoDynamicOptExt = "Pyomo" + [compat] -AbstractTrees = "0.3" -ArrayInterface = "2.8, 3.0" +ADTypes = "1.14.0" +AbstractTrees = "0.3, 0.4" +ArrayInterface = "6, 7" +BifurcationKit = "0.4, 0.5" +BlockArrays = "1.1" +BoundaryValueDiffEqAscher = "1.6.0" +BoundaryValueDiffEqMIRK = "1.7.0" +CasADi = "1.0.7" +ChainRulesCore = "1" +Combinatorics = "1" +CommonSolve = "0.2.4" +Compat = "3.42, 4" ConstructionBase = "1" +DataInterpolations = "7, 8" DataStructures = "0.17, 0.18" -DiffEqBase = "6.54.0" -DiffEqJump = "6.7.5" +DeepDiffs = "1" +DelayDiffEq = "5.50" +DiffEqBase = "6.170.1" +DiffEqCallbacks = "2.16, 3, 4" +DiffEqNoiseProcess = "5" DiffRules = "0.1, 1.0" +DifferentiationInterface = "0.6.47, 0.7" +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" +ImplicitDiscreteSolve = "0.1.2" +InfiniteOpt = "0.5" +InteractiveUtils = "1" +JuliaFormatter = "1.0.47, 2" +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" +LinearSolve = "3" +Logging = "1" +MLStyle = "0.4.17" +ModelingToolkitStandardLibrary = "2.20" +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" +Pyomo = "0.1.0" +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.100.0" +SciMLPublic = "1.0.0" +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.10" +StochasticDiffEq = "6.72.1" +SymbolicIndexingInterface = "0.3.39" +SymbolicUtils = "3.26.1" +Symbolics = "6.40" +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" +LinearSolve = "7ed4a6bd-45f5-4d41-b270-4a48e9bafcae" +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" +OptimizationBase = "bca83a33-5cc9-4baa-983d-23429ab6bcbb" +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", "OptimizationBase", "LinearSolve"] 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/benchmark/benchmarks.jl b/benchmark/benchmarks.jl new file mode 100644 index 0000000000..ae62f6ea0a --- /dev/null +++ b/benchmark/benchmarks.jl @@ -0,0 +1,78 @@ +using ModelingToolkit, BenchmarkTools +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Electrical +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEqDefault +using ModelingToolkit: t_nounits as t, D_nounits as D + +const SUITE = BenchmarkGroup() + +@mtkmodel DCMotor begin + @structural_parameters begin + R = 0.5 + L = 4.5e-3 + k = 0.5 + J = 0.02 + f = 0.01 + V_step = 10 + tau_L_step = -3 + end + @components begin + ground = Ground() + source = Voltage() + voltage_step = Blocks.Step(height = V_step, start_time = 0) + R1 = Resistor(R = R) + L1 = Inductor(L = L, i = 0.0) + emf = EMF(k = k) + fixed = Fixed() + load = Torque() + load_step = Blocks.Step(height = tau_L_step, start_time = 3) + inertia = Inertia(J = J) + friction = Damper(d = f) + end + @equations begin + connect(fixed.flange, emf.support, friction.flange_b) + connect(emf.flange, friction.flange_a, inertia.flange_a) + connect(inertia.flange_b, load.flange) + connect(load_step.output, load.tau) + connect(voltage_step.output, source.V) + connect(source.p, R1.p) + connect(R1.n, L1.p) + connect(L1.n, emf.p) + connect(emf.n, source.n, ground.g) + end +end + +@named model = DCMotor() + +# first call +mtkcompile(model) +SUITE["mtkcompile"] = @benchmarkable mtkcompile($model) + +model = mtkcompile(model) +u0 = unknowns(model) .=> 0.0 +tspan = (0.0, 6.0) + +prob = ODEProblem(model, u0, tspan) +SUITE["ODEProblem"] = @benchmarkable ODEProblem($model, $u0, $tspan) + +# first call +init(prob) +SUITE["init"] = @benchmarkable init($prob) + +large_param_init = SUITE["large_parameter_init"] = BenchmarkGroup() + +N = 25 +@variables x(t)[1:N] +@parameters A[1:N, 1:N] + +defval = collect(x) * collect(x)' +@mtkcompile model = System( + [D(x) ~ x], t, [x], [A]; defaults = [A => defval], guesses = [A => fill(NaN, N, N)]) + +u0 = [x => rand(N)] +prob = ODEProblem(model, u0, tspan) +large_param_init["ODEProblem"] = @benchmarkable ODEProblem($model, $u0, $tspan) + +large_param_init["init"] = @benchmarkable init($prob) diff --git a/demo.jl b/demo.jl new file mode 100644 index 0000000000..0a1bea54db --- /dev/null +++ b/demo.jl @@ -0,0 +1,107 @@ +macro mtkcompile(ex...) + quote + @mtkbuild $(ex...) + end +end + +function mtkcompile(args...; kwargs...) + structural_simplify(args...; kwargs...) +end + +################################# + +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +## ODEs + +@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 = System(eqs, t) +prob = ODEProblem(pend, [x => -1, y => 0], (0.0, 10.0), [g => 1], guesses = [λ => 1]) + +sol = solve(prob, FBDF()) + +## SDEs and unified `System` + +@variables x(t) y(t) z(t) +@parameters σ ρ β +@brownian a + +eqs = [ + D(x) ~ σ * (y - x) + 0.1x * a, + D(y) ~ x * (ρ - z) - y + 0.1y * a, + D(z) ~ x * y - β * z + 0.1z * a +] + +@mtkbuild sys1 = System(eqs, 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;;] + +@mtkbuild sys2 = SDESystem(eqs, noiseeqs, t) + +u0 = [ + x => 1.0, + y => 0.0, + z => 0.0] + +p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +sdeprob = SDEProblem(sys1, u0, (0.0, 10.0), p) +sdesol = solve(sdeprob, ImplicitEM()) + +odeprob = ODEProblem(sys1, u0, (0.0, 10.0), p) # error! +odeprob = ODEProblem(sys1, u0, (0.0, 10.0), p; check_compatibility = false) + +@variables x y z +@parameters σ ρ β + +# Define a nonlinear system +eqs = [0 ~ σ * (y - x), + y ~ x * (ρ - z), + β * z ~ x * y] +@mtkbuild sys = System(eqs) + +## ImplicitDiscrete Affects + +@parameters g +@variables x(t) y(t) λ(t) +eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] +c_evt = [t ~ 5.0] => [x ~ Pre(x) + 0.1] +@mtkbuild pend = System(eqs, t, continuous_events = c_evt) +prob = ODEProblem(pend, [x => -1, y => 0], (0.0, 10.0), [g => 1], guesses = [λ => 1]) + +sol = solve(prob, FBDF()) + +## `@named` and `ParentScope` + +function SysA(; name, var1) + @variables x(t) + return System([D(x) ~ var1], t; name) +end +function SysB(; name, var1) + @variables x(t) + @named subsys = SysA(; var1) + return System([D(x) ~ x], t; systems = [subsys], name) +end +function SysC(; name) + @variables x(t) + @named subsys = SysB(; var1 = x) + return System([D(x) ~ x], t; systems = [subsys], name) +end +@mtkbuild sys = SysC() diff --git a/docs/Project.toml b/docs/Project.toml index 1948117ebd..b51530c9d0 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +1,63 @@ [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" +CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" +ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" +DiffEqDevTools = "f3b72e0c-5b89-59e1-b016-84e28bfd966d" +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" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" +JumpProcesses = "ccbc3e58-028d-4f4c-8cd5-9ae44345cda5" +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" +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, 0.5" +CairoMakie = "0.13, 0.15" +CommonSolve = "0.2" +DataInterpolations = "6.5, 8" +DiffEqDevTools = "2" +Distributions = "0.25" +Documenter = "1" +DynamicQuantities = "^0.11.2, 0.12, 1" +FMI = "0.14" +FMIZoo = "1" +InfiniteOpt = "0.5" +Ipopt = "1" +JumpProcesses = "9" +ModelingToolkit = "10" +ModelingToolkitStandardLibrary = "2.19" +NonlinearSolve = "3, 4" +Optim = "1.7" +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..248b9e35bc 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,54 +1,48 @@ using Documenter, ModelingToolkit +using ModelingToolkit: SciMLBase +# To load docstring from extension +import FMI, CommonSolve, JumpProcesses -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", - ] -) +MTKFMIExt = Base.get_extension(ModelingToolkit, :MTKFMIExt) -deploydocs( - repo = "github.com/SciML/ModelingToolkit.jl.git"; - push_preview = true -) +# Make sure that plots don't throw a bunch of warnings / errors! +ENV["GKSwstype"] = "100" +using Plots + +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, MTKFMIExt], + 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"), + # This page gets especially big with all the problem docstrings + size_threshold_ignore = ["API/problems.md"]), + 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..e280834cfe --- /dev/null +++ b/docs/pages.jl @@ -0,0 +1,51 @@ +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/dynamic_optimization.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"]], + "API" => Any["API/System.md", + "API/variables.md", + "API/model_building.md", + "API/problems.md", + "API/dynamic_opt.md", + "API/codegen.md", + "API/PDESystem.md"], + "Basics" => Any[ + "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"], + "comparison.md", + "internals.md" +] diff --git a/docs/src/systems/PDESystem.md b/docs/src/API/PDESystem.md similarity index 60% rename from docs/src/systems/PDESystem.md rename to docs/src/API/PDESystem.md index e0790f46b8..fa78229cd3 100644 --- a/docs/src/systems/PDESystem.md +++ b/docs/src/API/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/API/System.md b/docs/src/API/System.md new file mode 100644 index 0000000000..379e7fa19b --- /dev/null +++ b/docs/src/API/System.md @@ -0,0 +1,187 @@ +# [The `System` type](@id System_type) + +ModelingToolkit.jl uses `System` to symbolically represent all types of numerical problems. +Users create `System`s representing the problem they want to solve and `mtkcompile` transforms +them into a format ModelingToolkit.jl can generate code for (alongside performing other +optimizations). + +```@docs +System +``` + +## Utility constructors + +Several utility constructors also exist to easily construct alternative system formulations. + +```@docs +NonlinearSystem +SDESystem +JumpSystem +OptimizationSystem +``` + +## Accessor functions + +Several accessor functions exist to query systems for the information they contain. In general, +for every field `x` there exists a `has_x` function which checks if the system contains the +field and a `get_x` function for obtaining the value in the field. Note that fields of a system +cannot be accessed via `getproperty` - that is reserved for accessing variables, subsystems +or analysis points of the hierarchical system. + +```@docs +ModelingToolkit.has_eqs +ModelingToolkit.get_eqs +equations +ModelingToolkit.equations_toplevel +full_equations +ModelingToolkit.has_noise_eqs +ModelingToolkit.get_noise_eqs +ModelingToolkit.has_jumps +ModelingToolkit.get_jumps +jumps +ModelingToolkit.has_constraints +ModelingToolkit.get_constraints +constraints +ModelingToolkit.has_costs +ModelingToolkit.get_costs +cost +ModelingToolkit.has_consolidate +ModelingToolkit.get_consolidate +ModelingToolkit.has_unknowns +ModelingToolkit.get_unknowns +unknowns +ModelingToolkit.unknowns_toplevel +ModelingToolkit.has_ps +ModelingToolkit.get_ps +parameters +ModelingToolkit.parameters_toplevel +tunable_parameters +ModelingToolkit.has_brownians +ModelingToolkit.get_brownians +brownians +ModelingToolkit.has_iv +ModelingToolkit.get_iv +ModelingToolkit.has_observed +ModelingToolkit.get_observed +observed +observables +ModelingToolkit.has_name +ModelingToolkit.get_name +nameof +ModelingToolkit.has_description +ModelingToolkit.get_description +ModelingToolkit.description +ModelingToolkit.has_defaults +ModelingToolkit.get_defaults +defaults +ModelingToolkit.has_guesses +ModelingToolkit.get_guesses +guesses +ModelingToolkit.get_systems +ModelingToolkit.has_initialization_eqs +ModelingToolkit.get_initialization_eqs +initialization_equations +ModelingToolkit.has_continuous_events +ModelingToolkit.get_continuous_events +continuous_events +ModelingToolkit.continuous_events_toplevel +ModelingToolkit.has_discrete_events +ModelingToolkit.get_discrete_events +ModelingToolkit.discrete_events_toplevel +ModelingToolkit.has_assertions +ModelingToolkit.get_assertions +ModelingToolkit.assertions +ModelingToolkit.has_metadata +ModelingToolkit.get_metadata +SymbolicUtils.getmetadata(::ModelingToolkit.AbstractSystem, ::DataType, ::Any) +SymbolicUtils.setmetadata(::ModelingToolkit.AbstractSystem, ::DataType, ::Any) +ModelingToolkit.has_is_dde +ModelingToolkit.get_is_dde +ModelingToolkit.is_dde +ModelingToolkit.has_tstops +ModelingToolkit.get_tstops +ModelingToolkit.symbolic_tstops +ModelingToolkit.has_tearing_state +ModelingToolkit.get_tearing_state +ModelingToolkit.does_namespacing +toggle_namespacing +ModelingToolkit.iscomplete +ModelingToolkit.has_preface +ModelingToolkit.get_preface +ModelingToolkit.preface +ModelingToolkit.has_parent +ModelingToolkit.get_parent +ModelingToolkit.has_initializesystem +ModelingToolkit.get_initializesystem +ModelingToolkit.is_initializesystem +``` + +## `getproperty` syntax + +ModelingToolkit allows obtaining in a system using `getproperty`. For a system `sys` with a +subcomponent `inner` containing variable `var`, `sys.inner.var` will obtain the appropriately +namespaced version of `var`. Note that this can also be used to access subsystems (`sys.inner`) +or analysis points. + +!!! note + + By default, top-level systems not marked as `complete` will apply their namespace. Systems + marked as `complete` will not do this namespacing. This namespacing behavior can be toggled + independently of whether the system is completed using [`toggle_namespacing`](@ref) and the + current namespacing behavior can be queried via [`ModelingToolkit.does_namespacing`](@ref). + +```@docs +Base.getproperty(::ModelingToolkit.AbstractSystem, ::Symbol) +``` + +## Functions for querying system equations + +```@docs +has_diff_eqs +has_alg_eqs +get_diff_eqs +get_alg_eqs +has_diff_equations +has_alg_equations +diff_equations +alg_equations +ModelingToolkit.is_alg_equation +ModelingToolkit.is_diff_equation +``` + +## String parsing + +ModelingToolkit can parse system variables from strings. + +```@docs +ModelingToolkit.parse_variable +``` + +## Dumping system data + +```@docs +ModelingToolkit.dump_unknowns +ModelingToolkit.dump_parameters +``` + +```@docs; canonical = false +ModelingToolkit.dump_variable_metadata +``` + +## Inputs and outputs + +```@docs +ModelingToolkit.inputs +ModelingToolkit.outputs +ModelingToolkit.bound_inputs +ModelingToolkit.unbound_inputs +ModelingToolkit.bound_outputs +ModelingToolkit.unbound_outputs +ModelingToolkit.is_bound +``` + +## Debugging utilities + +```@docs +debug_system +``` diff --git a/docs/src/API/codegen.md b/docs/src/API/codegen.md new file mode 100644 index 0000000000..4f31405174 --- /dev/null +++ b/docs/src/API/codegen.md @@ -0,0 +1,54 @@ +# Code generation utilities + +These are lower-level functions that ModelingToolkit leverages to generate code for +building numerical problems. + +```@docs +ModelingToolkit.generate_rhs +ModelingToolkit.generate_diffusion_function +ModelingToolkit.generate_jacobian +ModelingToolkit.generate_tgrad +ModelingToolkit.generate_W +ModelingToolkit.generate_dae_jacobian +ModelingToolkit.generate_history +ModelingToolkit.generate_boundary_conditions +ModelingToolkit.generate_cost +ModelingToolkit.generate_cost_gradient +ModelingToolkit.generate_cost_hessian +ModelingToolkit.generate_cons +ModelingToolkit.generate_constraint_jacobian +ModelingToolkit.generate_constraint_hessian +ModelingToolkit.generate_control_jacobian +ModelingToolkit.build_explicit_observed_function +ModelingToolkit.generate_control_function +ModelingToolkit.generate_update_A +ModelingToolkit.generate_update_b +``` + +For functions such as jacobian calculation which require symbolic computation, there +are `calculate_*` equivalents to obtain the symbolic result without building a function. + +```@docs +ModelingToolkit.calculate_tgrad +ModelingToolkit.calculate_jacobian +ModelingToolkit.jacobian_sparsity +ModelingToolkit.jacobian_dae_sparsity +ModelingToolkit.calculate_hessian +ModelingToolkit.hessian_sparsity +ModelingToolkit.calculate_massmatrix +ModelingToolkit.W_sparsity +ModelingToolkit.calculate_W_prototype +ModelingToolkit.calculate_cost_gradient +ModelingToolkit.calculate_cost_hessian +ModelingToolkit.cost_hessian_sparsity +ModelingToolkit.calculate_constraint_jacobian +ModelingToolkit.calculate_constraint_hessian +ModelingToolkit.calculate_control_jacobian +ModelingToolkit.calculate_A_b +``` + +All code generation eventually calls `build_function_wrapper`. + +```@docs +build_function_wrapper +``` diff --git a/docs/src/API/dynamic_opt.md b/docs/src/API/dynamic_opt.md new file mode 100644 index 0000000000..1d94bc2108 --- /dev/null +++ b/docs/src/API/dynamic_opt.md @@ -0,0 +1,41 @@ +# [Dynamic Optimization Solvers](@id dynamic_opt_api) + +Currently 4 backends are exposed for solving dynamic optimization problems using collocation: JuMP, InfiniteOpt, CasADi, and Pyomo. + +Please note that there are differences in how to construct the collocation solver for the different cases. For example, the Python based ones, CasADi and Pyomo, expect the solver to be passed in as a string (CasADi and Pyomo come pre-loaded with Ipopt, but other solvers may need to be manually installed using `pip` or `conda`), while JuMP/InfiniteOpt expect the optimizer object to be passed in directly: + +``` +JuMPCollocation(Ipopt.Optimizer, constructRK4()) +CasADiCollocation("ipopt", constructRK4()) +``` + +**JuMP** and **CasADi** collocation require an ODE tableau to be passed in. These can be constructed by calling the `constructX()` functions from DiffEqDevTools. The list of tableaus can be found [here](https://docs.sciml.ai/DiffEqDevDocs/dev/internals/tableaus/). If none is passed in, both solvers will default to using Radau second-order with five collocation points. + +**Pyomo** and **InfiniteOpt** each have their own built-in collocation methods. + + 1. **InfiniteOpt**: The list of InfiniteOpt collocation methods can be found [in the table on this page](https://infiniteopt.github.io/InfiniteOpt.jl/stable/guide/derivative/). If none is passed in, the solver defaults to `FiniteDifference(Backward())`, which is effectively implicit Euler. + 2. **Pyomo**: The list of Pyomo collocation methods can be found [at the bottom of this page](https://github.com/SciML/Pyomo.jl). If none is passed in, the solver defaults to a `LagrangeRadau(3)`. + +Some examples of the latter two collocations: + +```julia +PyomoCollocation("ipopt", LagrangeRadau(2)) +InfiniteOptCollocation(Ipopt.Optimizer, OrthogonalCollocation(3)) +``` + +```@docs; canonical = false +JuMPCollocation +InfiniteOptCollocation +CasADiCollocation +PyomoCollocation +CommonSolve.solve(::AbstractDynamicOptProblem) +``` + +### Problem constructors + +```@docs; canonical = false +JuMPDynamicOptProblem +InfiniteOptDynamicOptProblem +CasADiDynamicOptProblem +PyomoDynamicOptProblem +``` diff --git a/docs/src/API/model_building.md b/docs/src/API/model_building.md new file mode 100644 index 0000000000..cbdf72edae --- /dev/null +++ b/docs/src/API/model_building.md @@ -0,0 +1,253 @@ +# [Model building reference](@id model_building_api) + +This page lists functionality and utilities related to building hierarchical models. It is +recommended to read the page on the [`System`](@ref System_type) before this. + +## Hierarchical model composition + +The `System` data structure can represent a tree-like hierarchy of systems for building models +from composable blocks. The [`ModelingToolkit.get_systems`](@ref) function can be used for +querying the subsystems of a system. The `@component` macro should be used when writing +building blocks for model composition. + +```@docs +@component +``` + +Every constructor function should build either a component or a connector. Components define +the dynamics of the system. Connectors are used to connect components together and propagate +information between them. See also [`@connector`](@ref). + +### Scoping of variables + +When building hierarchical systems, is is often necessary to pass variables from a parent system +to the subsystems. If done naively, this will result in the child system assuming it "owns" the +variables passed to it and any occurrences of those variables in the child system will be +namespaced. To prevent this, ModelingToolkit has the concept of variable scope. The scope allows +specifying which system a variable belongs to relative to the system in which it is used. + +```@docs +LocalScope +ParentScope +GlobalScope +``` + +Note that the scopes must be applied to _individual variables_ and not expressions. For example, +`ParentScope(x + y)` is incorrect. Instead, `ParentScope(x) + ParentScope(y)` is the correct usage. +Applying the same scope (more generally, the same function) to all variables in an expression is a +common task, and ModelingToolkit exposes a utility for the same: + +```@docs +ModelingToolkit.apply_to_variables +``` + +It is still tedious to manually use `apply_to_variables` on any symbolic expression passed to a +subsystem. The `@named` macro automatically wraps all symbolic arguments in `ParentScope` and +uses the identifier being assigned as the name of the system. + +```@docs +@named +``` + +### Exploring the tree structure + +The `System` type implements the `AbstractTrees` interface. This can be used to explore the +hierarchical structure. + +```@docs +hierarchy +``` + +### [Connection semantics](@id connect_semantics) + +ModelingToolkit implements connection semantics similar to those in the [Modelica specification](https://specification.modelica.org/maint/3.6/connectors-and-connections.html). +We do not support the concept of `inner` and `outer` elements or `expandable` connectors. +Connectors in ModelingToolkit are systems with the appropriate metadata added via the `@connector` +macro. + +```@docs +connect +domain_connect +@connector +``` + +Connections can be expanded using `expand_connections`. + +```@docs +expand_connections +``` + +Similar to the `stream` and `flow` keyword arguments in the specification, ModelingToolkit +allows specifying how variables in a connector behave in a connection. + +```@docs +ModelingToolkit.Equality +Flow +Stream +``` + +These are specified using the `connect` metadata. ModelingToolkit also supports `instream`. +Refer to the Modelica specification on [Stream connectors](https://specification.modelica.org/maint/3.6/stream-connectors.html) +for more information. + +```@docs +instream +``` + +### System composition utilities + +```@docs +extend +compose +substitute_component +``` + +### Flattening systems + +The hierarchical structure can be flattened. This operation is performed during simplification. + +```@docs +flatten +``` + +## System simplification + +`System`s can be simplified to reformulate them in a way that enables it to be solved numerically, +and also perform other optimizations. This is done via the `mtkcompile` function. Connection expansion +and flattening are preprocessing steps of simplification. + +```@docs +mtkcompile +@mtkcompile +``` + +It is also possible (though not always advisable) to build numerical problems from systems without +passing them through `mtkcompile`. To do this, the system must first be marked as "complete" via +the `complete` function. This process is used to indicate that a system will not be modified +further and allows ModelingToolkit to perform any necessary preprocessing to it. `mtkcompile` +calls `complete` internally. + +```@docs +complete +``` + +### Exploring the results of simplification + +Similar to how [`full_equations`](@ref) returns the equations of a system with all variables +eliminated during `mtkcompile` substituted, we can perform this substitution on an arbitrary +expression. + +```@docs +ModelingToolkit.substitute_observed +ModelingToolkit.empty_substitutions +ModelingToolkit.get_substitutions +``` + +### Experimental simplification + +ModelingToolkit may have a variety of experimental simplification passes. These are not +enabled by default, but can be used by passing to the `additional_passes` keyword argument +of `mtkcompile`. + +```@docs +ModelingToolkit.IfLifting +``` + +## Event handling + +Time-dependent systems may have several events. These are used to trigger discontinuities +in the model. They compile to standard callbacks from `DiffEqCallbacks.jl`. + +```@docs +ModelingToolkit.SymbolicContinuousCallback +ModelingToolkit.SymbolicDiscreteCallback +``` + +The affect functions for the above callbacks can be symbolic or user-defined functions. +Symbolic affects are handled using equations as described in the [Events](@ref events) +section of the documentation. User-defined functions can be used via `ImperativeAffect`. + +```@docs +ModelingToolkit.ImperativeAffect +``` + +## Modelingtoolkitize + +ModelingToolkit can take some numerical problems created non-symbolically and build a +symbolic representation from them. + +```@docs +modelingtoolkitize +``` + +## Using FMUs + +ModelingToolkit is capable of importing FMUs as black-box symbolic models. Currently only +a subset of FMU features are supported. This functionality requires importing `FMI.jl`. + +```@docs +ModelingToolkit.FMIComponent +``` + +## Model transformations + +ModelingToolkit exposes a variety of transformations that can be applied to models to aid in +symbolic analysis. + +```@docs +liouville_transform +change_of_variables +stochastic_integral_transform +Girsanov_transform +change_independent_variable +add_accumulations +noise_to_brownians +convert_system_indepvar +``` + +## Hybrid systems + +Hybrid systems are dynamical systems involving one or more discrete-time subsystems. These +discrete time systems follow clock semantics - they are synchronous systems and the relevant +variables are only defined at points where the clock ticks. + +While ModelingToolkit is unable to simplify, compile and solve such systems on its own, it +has the ability to represent them. Compilation strategies can be implemented independently +on top of [`mtkcompile`](@ref) using the `additional_passes` functionality. + +!!! warn + + These operators are considered experimental API. + +```@docs; canonical = false +Sample +Hold +SampleTime +``` + +ModelingToolkit uses the clock definition in SciMLBase + +```@docs +SciMLBase.TimeDomain +SciMLBase.Clock +SciMLBase.SolverStepClock +SciMLBase.Continuous +``` + +### State machines + +While ModelingToolkit has the capability to represent state machines, it lacks the ability +to compile and simulate them. + +!!! warn + + This functionality is considered experimental API + +```@docs +initial_state +transition +activeState +entry +ticksInState +timeInState +``` diff --git a/docs/src/API/problems.md b/docs/src/API/problems.md new file mode 100644 index 0000000000..72147a7e09 --- /dev/null +++ b/docs/src/API/problems.md @@ -0,0 +1,126 @@ +```@meta +CollapsedDocStrings = true +``` + +# Building and solving numerical problems + +Systems are numerically solved by building and solving the appropriate problem type. +Numerical solvers expect to receive functions taking a predefeined set of arguments +and returning specific values. This format of argument and return value depends on +the function and the problem. ModelingToolkit is capable of compiling and generating +code for a variety of such numerical problems. + +## Dynamical systems + +```@docs +SciMLBase.ODEFunction +SciMLBase.ODEProblem +SciMLBase.DAEFunction +SciMLBase.DAEProblem +SciMLBase.SDEFunction +SciMLBase.SDEProblem +SciMLBase.DDEFunction +SciMLBase.DDEProblem +SciMLBase.SDDEFunction +SciMLBase.SDDEProblem +JumpProcesses.JumpProblem +SciMLBase.BVProblem +SciMLBase.DiscreteProblem +SciMLBase.ImplicitDiscreteProblem +``` + +## Linear and Nonlinear systems + +```@docs +SciMLBase.NonlinearFunction +SciMLBase.NonlinearProblem +SciMLBase.SCCNonlinearProblem +SciMLBase.NonlinearLeastSquaresProblem +SciMLBase.SteadyStateProblem +SciMLBase.IntervalNonlinearFunction +SciMLBase.IntervalNonlinearProblem +ModelingToolkit.HomotopyContinuationProblem +SciMLBase.HomotopyNonlinearFunction +SciMLBase.LinearProblem +``` + +## Optimization and optimal control + +```@docs +SciMLBase.OptimizationFunction +SciMLBase.OptimizationProblem +SciMLBase.ODEInputFunction +ModelingToolkit.JuMPDynamicOptProblem +ModelingToolkit.InfiniteOptDynamicOptProblem +ModelingToolkit.CasADiDynamicOptProblem +ModelingToolkit.DynamicOptSolution +``` + +## The state vector and parameter object + +Typically the unknowns of the system are present as a `Vector` of the appropriate length +in the numerical problem. The state vector can also be constructed manually without building +a problem. + +```@docs +ModelingToolkit.get_u0 +ModelingToolkit.varmap_to_vars +``` + +By default, the parameters of the system are stored in a custom data structure called +`MTKParameters`. The internals of this data structure are undocumented, and it should +only be interacted with through defined public API. SymbolicIndexingInterface.jl contains +functionality useful for this purpose. + +```@docs +MTKParameters +ModelingToolkit.get_p +``` + +The following functions are useful when working with `MTKParameters` objects, and especially +the `Tunables` portion. For more information about the "portions" of `MTKParameters`, refer +to the [`SciMLStructures.jl`](https://docs.sciml.ai/SciMLStructures/stable/) documentation. + +```@docs +reorder_dimension_by_tunables! +reorder_dimension_by_tunables +``` + +## Initialization + +```@docs +generate_initializesystem +InitializationProblem +``` + +## Linear analysis + +```@docs +linearization_function +LinearizationProblem +linearize +CommonSolve.solve(::LinearizationProblem) +linearize_symbolic +``` + +There are also utilities for manipulating the results of these analyses in a symbolic context. + +```@docs +ModelingToolkit.similarity_transform +ModelingToolkit.reorder_unknowns +``` + +### Analysis point transformations + +Linear analysis can also be done using analysis points to perform several common +workflows. + +```@docs +get_sensitivity_function +get_sensitivity +get_comp_sensitivity_function +get_comp_sensitivity +get_looptransfer_function +get_looptransfer +open_loop +``` diff --git a/docs/src/API/variables.md b/docs/src/API/variables.md new file mode 100644 index 0000000000..e4db36d316 --- /dev/null +++ b/docs/src/API/variables.md @@ -0,0 +1,364 @@ +# [Symbolic variables and variable metadata](@id symbolic_metadata) + +ModelingToolkit uses [Symbolics.jl](https://docs.sciml.ai/Symbolics/stable/) for the symbolic +manipulation infrastructure. In fact, the `@variables` macro is defined in Symbolics.jl. In +addition to `@variables`, ModelingToolkit defines `@parameters`, `@independent_variables`, +`@constants` and `@brownians`. These macros function identically to `@variables` but allow +ModelingToolkit to attach additional metadata. + +```@docs +Symbolics.@variables +@independent_variables +@parameters +@constants +@brownians +``` + +Symbolic variables can have metadata attached to them. The defaults and guesses assigned +at variable construction time are examples of this metadata. ModelingToolkit also defines +additional types of metadata. + +## 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 = System([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) +``` + +```@docs +hasdescription +getdescription +``` + +## 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) +``` + +```@docs +hasconnect +getconnect +``` + +```@docs; canonical=false +Flow +Stream +``` + +## 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) +``` + +```@docs +isinput +isoutput +ModelingToolkit.setinput +ModelingToolkit.setoutput +``` + +## Bounds + +Bounds are useful when parameters are to be optimized, or to express intervals of uncertainty. + +```@repl metadata +@variables u [bounds = (-1, 1)]; +hasbounds(u) +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. + +```@repl metadata +@variables x[1:2, 1:2] [bounds = (-1, 1)]; +hasbounds(x) +getbounds(x) +getbounds(x[1, 1]) +getbounds(x[1:2, 1]) +@variables x[1:2] [bounds = (-Inf, [1.0, Inf])]; +hasbounds(x) +getbounds(x) +getbounds(x[2]) +hasbounds(x[2]) +``` + +```@docs +hasbounds +getbounds +``` + +## Guess + +Specify an initial guess for variables of a `System`. This is used when building the +[`InitializationProblem`](@ref). + +```@repl metadata +@variables u [guess = 1]; +hasguess(u) +getguess(u) +``` + +```@docs +hasguess +getguess +``` + +When a system is constructed, the guesses of the involved variables are stored in a `Dict` +in the system. After this point, the guess metadata of the variable is irrelevant. + +```@docs; canonical=false +guesses +``` + +## 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) +``` + +```@docs +isdisturbance +``` + +## 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) +``` + +```@docs +istunable +ModelingToolkit.isconstant +``` + +!!! note + + [`@constants`](@ref) is a convenient way to create `@parameters` with `tunable = false` + metadata + +## 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. + +```@repl metadata +using Distributions; +d = Normal(10, 1); +@parameters m [dist = d]; +hasdist(m) +getdist(m) +``` + +```@docs +hasdist +getdist +``` + +## 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) +``` + +```@docs +isirreducible +``` + +## 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) +``` + +```@docs +state_priority +``` + +## 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. + +```@repl metadata +using DynamicQuantities; +@variables speed [unit = u"m/s"]; +hasunit(speed) +getunit(speed) +``` + +```@docs +hasunit +getunit +``` + +## Miscellaneous metadata + +User-defined metadata can be added using the `misc` metadata. This can be queried +using the `hasmisc` and `getmisc` functions. + +```@repl metadata +@variables u [misc = :conserved_parameter] y [misc = [2, 4, 6]]; +hasmisc(u) +getmisc(y) +``` + +```@docs +hasmisc +getmisc +``` + +## Dumping metadata + +ModelingToolkit allows dumping the metadata of a variable as a `NamedTuple`. + +```@docs +ModelingToolkit.dump_variable_metadata +``` + +## 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 = System(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: + +```@docs; canonical=false +tunable_parameters +ModelingToolkit.dump_unknowns +ModelingToolkit.dump_parameters +``` + +## Symbolic operators + +ModelingToolkit makes heavy use of "operators". These are custom functions that are applied +to symbolic variables. The most common operator is the `Differential` operator, defined in +Symbolics.jl. + +```@docs +Symbolics.Differential +``` + +ModelingToolkit also defines a plethora of custom operators. + +```@docs +Pre +Initial +Shift +``` + +While not an operator, `ShiftIndex` is commonly used to use `Shift` operators in a more +convenient way when writing discrete systems. + +```@docs +ShiftIndex +``` + +### Sampled time operators + +The following operators are used in hybrid ODE systems, where part of the dynamics of the +system happen at discrete intervals on a clock. While ModelingToolkit cannot yet simulate +such systems, it has the capability to represent them. + +!!! warn + + These operators are considered experimental API. + +```@docs +Sample +Hold +SampleTime +sampletime +``` diff --git a/docs/src/basics/AbstractSystem.md b/docs/src/basics/AbstractSystem.md deleted file mode 100644 index 8214a6ddcc..0000000000 --- a/docs/src/basics/AbstractSystem.md +++ /dev/null @@ -1,136 +0,0 @@ -# The AbstractSystem Interface - -## Overview - -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 -model manipulation and compilation. - -## Constructors and Naming - -The `AbstractSystem` interface has a consistent method for constructing systems. -Generally it follows the order of: - -1. Equations -2. Independent Variables -3. Dependent Variables (or States) -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. - -## 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 -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. - -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. - -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. - -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. - -## Transformations - -Transformations are functions which send a valid `AbstractSystem` definition to -another `AbstractSystem`. These are passes, like optimizations (e.g., Block-Lower -Triangle transformations), or changes to the representation, which allow for -alternative numerical methods to be utilized on the model (e.g., DAE index reduction). - -## Analyses - -Analyses are functions on a system which return information about the corresponding -properties, like whether its parameters are structurally identifiable, or whether -it's linear. - -## Function Calculation and Generation - -The calculation and generation functions allow for calculating additional -quantities to enhance the numerical methods applied to the resulting system. -The calculations, like `calculate_jacobian`, generate ModelingToolkit IR for -the Jacobian of the system, while the generations, like `generate_jacobian`, -generate compiled output for the numerical solvers by applying `build_function` -to the generated code. Additionally, many systems have function-type outputs, -which cobble together the generation functionality for a system, for example, -`ODEFunction` can be used to generate a DifferentialEquations-based `ODEFunction` -with compiled version of the ODE itself, the Jacobian, the mass matrix, etc. - -Below are the possible calculation and generation functions: - -```@docs -calculate_tgrad -calculate_gradient -calculate_jacobian -calculate_factorized_W -calculate_hessian -generate_tgrad -generate_gradient -generate_jacobian -generate_factorized_W -generate_hessian -``` - -Additionally, `jacobian_sparsity(sys)` and `hessian_sparsity(sys)` -exist on the appropriate systems for fast generation of the sparsity -patterns via an abstract interpretation without requiring differentiation. - -## Problem Constructors - -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 -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 -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: - -``` -u0 = [ - lorenz1.x => 2.0 - lorenz2.x => lorenz1.x * lorenz1.p -] -``` - -## Default Value Handling - -The `AbstractSystem` types allow for specifying default values, for example -`defaults` inside of them. At problem construction time, these values are merged -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. diff --git a/docs/src/basics/Composition.md b/docs/src/basics/Composition.md index 0cdec84a03..3b8b59937a 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) + System([ + 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( + System([decay2.f ~ decay1.x + D(decay1.f) ~ 0], t; name = :connected), decay1, decay2) equations(connected) @@ -45,30 +42,21 @@ equations(connected) # Differential(t)(decay1₊x(t)) ~ decay1₊f(t) - (decay1₊a*(decay1₊x(t))) # Differential(t)(decay2₊x(t)) ~ decay2₊f(t) - (decay2₊a*(decay2₊x(t))) -simplified_sys = structural_simplify(connected) +simplified_sys = mtkcompile(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,22 +69,22 @@ 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(System(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 -like `structural_simplify` which will be described later. +like `mtkcompile` which will be described later. ### Numerics with Composed Models @@ -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,142 +115,157 @@ 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. ```julia # ... -sys = ODESystem( - # ... - # directly in the defauls argument - defaults=Pair{Num, Any}[ - x => u, +sys = System( +# ... +# 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 # 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 = GlobalScope(d) + +p = [a, b, c, d] + +level0 = System(Equation[], t, [], p; name = :level0) +level1 = System(Equation[], t, [], []; name = :level1) ∘ level0 +parameters(level1) +#level0₊a +#b +#c +#d +level2 = System(Equation[], t, [], []; name = :level2) ∘ level1 +parameters(level2) +#level1₊level0₊a +#level1₊b +#c +#d +level3 = System(Equation[], t, [], []; name = :level3) ∘ level2 +parameters(level3) +#level2₊level1₊level0₊a +#level2₊level1₊b +#level2₊c +#d ``` ## Structural Simplify In many cases, the nicest way to build a model may leave a lot of unnecessary variables. Thus one may want to remove these equations -before numerically solving. The `structural_simplify` function removes +before numerically solving. The `mtkcompile` 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 = System([D(S) ~ -β * S * I / N], t) +@named ieqn = System([D(I) ~ β * S * I / N - γ * I], t) +@named reqn = System([D(R) ~ γ * I], t) + +sir = compose( + System( + [ + 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 -sireqn_simple = structural_simplify(sir) +```@example compose +sireqn_simple = mtkcompile(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 -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 +Some system types (specifically `NonlinearSystem`) can be further +reduced if `mtkcompile` has already been applied to them. This is done +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 [`System`](@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..ccf1de263a --- /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 = System(eqs, t; defaults) +sys = mtkcompile(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 +@mtkcompile sys = System(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; canonical = false +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..bfeac35526 --- /dev/null +++ b/docs/src/basics/Events.md @@ -0,0 +1,663 @@ +# [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. + +[`System`](@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. + +## Symbolic Callback Semantics + +In callbacks, there is a distinction between values of the unknowns and parameters +*before* the callback, and the desired values *after* the callback. In MTK, this +is provided by the `Pre` operator. For example, if we would like to add 1 to an +unknown `x` in a callback, the equation would look like the following: + +```julia +x ~ Pre(x) + 1 +``` + +Non `Pre`-d values will be interpreted as values *after* the callback. As such, +writing + +```julia +x ~ x + 1 +``` + +will be interpreted as an algebraic equation to be satisfied after the callback. +Since this equation obviously cannot be satisfied, an error will result. + +Callbacks must maintain the consistency of DAEs, meaning that they must satisfy +all the algebraic equations of the system after their update. However, the affect +equations often do not fully specify which unknowns/parameters should be modified +to maintain consistency. To make this clear, MTK uses the following rules: + + 1. All unknowns are treated as modifiable by the callback. In order to enforce that an unknown `x` remains the same, one can add `x ~ Pre(x)` to the affect equations. + 2. All parameters are treated as un-modifiable, *unless* they are declared as `discrete_parameters` to the callback. In order to be a discrete parameter, the parameter must be time-dependent (the terminology *discretes* here means [discrete variables](@ref save_discretes)). + +For example, consider the following system. + +```julia +@variables x(t) y(t) +@parameters p(t) +@mtkcompile sys = System([x * y ~ p, D(x) ~ 0], t) +event = [t == 1] => [x ~ Pre(x) + 1] +``` + +By default what will happen is that `x` will increase by 1, `p` will remain constant, +and `y` will change in order to compensate the increase in `x`. But what if we +wanted to keep `y` constant and change `p` instead? We could use the callback +constructor as follows: + +```julia +event = SymbolicDiscreteCallback( + [t == 1] => [x ~ Pre(x) + 1, y ~ Pre(y)], discrete_parameters = [p]) +``` + +This way, we enforce that `y` will remain the same, and `p` will change. + +!!! warning + + Symbolic affects come with the guarantee that the state after the callback + will be consistent. However, when using [general functional affects](@ref func_affects) + or [imperative affects](@ref imp_affects) one must be more careful. In + particular, one can pass in `reinitializealg` as a keyword arg to the + callback constructor to re-initialize the system. This will default to + `SciMLBase.NoInit()` in the case of symbolic affects and `SciMLBase.CheckInit()` + in the case of functional affects. This keyword should *not* be provided + if the affect is purely symbolic. + +## 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)] + System(eqs, t; continuous_events = [v ~ 0], name) # when v = 0 there is a discontinuity +end +@mtkcompile 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 ~ -Pre(v)] # the effect is that the velocity changes sign + +@mtkcompile ball = System( + [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 ~ -Pre(vx)] + [y ~ -1.5, y ~ 1.5] => [vy ~ -Pre(vy)]] + +@mtkcompile ball = System( + [ + 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!(mod, obs, integ, ctx) + return (; v = -mod.v) +end + +reflect = [x ~ 0] => (bb_affect!, (; v)) + +@mtkcompile bb_sys = System(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 + +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 α(t) +@variables N(t) +Dₜ = Differential(t) +eqs = [Dₜ(N) ~ α - N] + +# at time tinject we inject M cells +injection = (t == tinject) => [N ~ Pre(N) + M] + +u0 = [N => 0.0] +tspan = (0.0, 20.0) +p = [α => 100.0, tinject => 10.0, M => 50] +@mtkcompile osys = System(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 ~ Pre(N) + M] + +@mtkcompile osys = System(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`. Since this is a parameter we must explicitly +set it as `discrete_parameters`. + +```@example events +@parameters tkill + +# we reset the first event to just occur at tinject +injection = (t == tinject) => [N ~ Pre(N) + M] + +# at time tkill we turn off production of cells +killing = ModelingToolkit.SymbolicDiscreteCallback( + (t == tkill) => [α ~ 0.0]; discrete_parameters = α, iv = t) + +tspan = (0.0, 30.0) +p = [α => 100.0, tinject => 10.0, M => 50, tkill => 20.0] +@mtkcompile osys = System(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 ~ -Pre(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 ~ Pre(N) + M] +killing = ModelingToolkit.SymbolicDiscreteCallback( + [20.0] => [α ~ 0.0], discrete_parameters = α, iv = t) + +p = [α => 100.0, M => 50] +@mtkcompile osys = System(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 ~ -Pre(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 ~ -Pre(v)]] +``` + +## [Saving discrete values](@id save_discretes) + +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) + +ev = ModelingToolkit.SymbolicDiscreteCallback( + 1.0 => [c ~ Pre(c) + 1], discrete_parameters = c, iv = t) +@mtkcompile sys = System( + D(x) ~ c * cos(x), t, [x], [c]; discrete_events = [ev]) + +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 that are explicitly passed as `discrete_parameters` +will be saved. If we repeat the above example with `c` not a `discrete_parameter`: + +```@example events +@variables x(t) +@parameters c(t) + +@mtkcompile sys = System( + D(x) ~ c * cos(x), t, [x], [c]; discrete_events = [1.0 => [c ~ Pre(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 = System( + eqs, t, [temp], params; continuous_events = [furnace_disable, furnace_enable]) +ss = mtkcompile(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 = System( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) +ss = mtkcompile(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..3f09ab8b13 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_index(sys, sym)` will return a `ParameterIndex` object if `sys` has been + `complete`d (through `mtkcompile`, `complete` or `@mtkcompile`). + - `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; canonical = false +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 `mtkcompile` 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 = System(eqs, t) +sys = mtkcompile(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] +@mtkcompile sys = System(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 = System([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; canonical = false +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; canonical = false +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 = System(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..b1eb2905df --- /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., `mtkcompile` or `@mtkcompile` should not be called on the system before passing it into this function. `generate_control_function` calls a special version of `mtkcompile` 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 = System(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 [`ModelingToolkit.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; canonical=false +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..0d219d35a5 --- /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 = System(eqs, t) # Do not call @mtkcompile 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 `System` 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., `mtkcompile` or `@mtkcompile` 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 = System(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; canonical = false +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..b15eec126f --- /dev/null +++ b/docs/src/basics/MTKLanguage.md @@ -0,0 +1,543 @@ +# [ModelingToolkit Language: Modeling with `@mtkmodel`, `@connectors` and `@mtkcompile`](@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 (`System` 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> @mtkcompile 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 + +@mtkcompile 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. + - Discrete parameters and other keyword arguments should be specified in a vector, as seen below. + +```@example mtkmodel-example +using ModelingToolkit +using ModelingToolkit: t + +@mtkmodel M begin + @parameters begin + k(t) + 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] + [t ~ 5] => [k ~ 3], [discrete_parameters = k] + 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. + - Discrete parameters and other keyword arguments should be specified in a vector, as seen below. + +```@example mtkmodel-example +using ModelingToolkit + +@mtkmodel M begin + @parameters begin + k(t) + 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 ~ Pre(x) + 5, y ~ 5] + (t == 2.5) => [k ~ Pre(k) * 2], [discrete_parameters = k] + 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 System. Different types of system can be +defined with the following syntax: + +``` +@mtkmodel ModelName::SystemType begin + ... +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 (`System` 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: + +`@mtkcompile` builds an instance of a component and returns a structurally simplied system. + +```julia +@mtkcompile sys = CustomModel() +``` + +This is equivalent to: + +```julia +@named model = CustomModel() +sys = mtkcompile(model) +``` + +Pass keyword arguments to `mtkcompile` using the following syntax: + +```julia +@mtkcompile sys=CustomModel() fully_determined=false +``` + +This is equivalent to: + +```julia +@named model = CustomModel() +sys = mtkcompile(model; fully_determined = false) +``` diff --git a/docs/src/basics/Precompilation.md b/docs/src/basics/Precompilation.md new file mode 100644 index 0000000000..3bac7fcc31 --- /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 = System([ModelingToolkit.D_nounits(x) ~ -x + 1], ModelingToolkit.t_nounits) +prob = ODEProblem(mtkcompile(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..3f36a06e5e 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 = System( + eqs, t, [sts...;], [ps...;], name = :sys, checks = ~ModelingToolkit.CheckUnits) +sys_simple = mtkcompile(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/comparison.md b/docs/src/comparison.md index 446f0afebc..6691b0023a 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 `mtkcompile` + 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 + `mtkcompile` 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..e8dda823e0 --- /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 +`mtkcompile`, 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 +@mtkcompile 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 `System` of first order components. +We do this by calling `mtkcompile`: + +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 63% rename from docs/src/mtkitize_tutorials/modelingtoolkitize_index_reduction.md rename to docs/src/examples/modelingtoolkitize_index_reduction.md index f5bc98169a..9759dc2081 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)) +pendulum_sys = mtkcompile(dae_index_lowering(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)) +pendulum_sys = mtkcompile(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..b80016a43f --- /dev/null +++ b/docs/src/examples/perturbation.md @@ -0,0 +1,107 @@ +# [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 `System`, which automatically inserts dummy derivatives for the velocities: + +```@example perturbation +@mtkcompile sys = System(eqs_pert, t) +``` + +To solve the `System`, 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 `System`: + +```@example perturbation +eq_pert = substitute(eq, x => x_series) +eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2) +@mtkcompile sys = System(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 = [y[0] => 0.0, y[1] => 0.0, y[2] => 0.0, D(y[0]) => 1.0, D(y[1]) => 0.0, D(y[2]) => 0.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) +@assert isapprox( + sol(π/2; idxs = substitute(x_series, ϵ => 0.1)), x_exact(π/2, 0.1); atol = 1e-2) # compare around 1st peak # hide +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..b42315304e --- /dev/null +++ b/docs/src/examples/remake.md @@ -0,0 +1,147 @@ +```@meta +Draft = true +``` + +# 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] +@mtkcompile odesys = System(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, α => 1.5, β => 1.0, γ => 3.0, δ => 1.0], (0.0, 10.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..a87f824d8d --- /dev/null +++ b/docs/src/examples/sparse_jacobians.md @@ -0,0 +1,94 @@ +# 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 +@mtkcompile 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..8d42592796 --- /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) + System(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] + System(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 = System(eqs, t, [spring.x; spring.dir; mass.pos], []) +@named model = compose(_model, mass, spring) +sys = mtkcompile(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 `System`. 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) + System(eqs, t, [pos..., v...], ps; name) +end +``` + +Note that this is an incompletely specified `System`. 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] + System(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 = System(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 `mtkcompile` eliminates unnecessary variables from the model to give the leanest numerical representation of the system. + +```@example component +sys = mtkcompile(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, `mtkcompile` 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..924102eff0 --- /dev/null +++ b/docs/src/examples/tearing_parallelism.md @@ -0,0 +1,179 @@ +# Exposing More Parallelism By Tearing Algebraic Equations in Systems + +Sometimes it can be very non-trivial to parallelize a system. In this tutorial, +we will demonstrate how to make use of `mtkcompile` 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] + System(Equation[], t, [v, i], [], name = name) +end + +function Ground(; name) + @named g = Pin() + eqs = [g.v ~ 0] + compose(System(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(System(eqs, t, [], [V], name = name), p, n) +end + +@connector function HeatPort(; name) + @variables T(t)=293.15 Q_flow(t)=0.0 [connect=Flow] + System(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(System(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(System(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(System(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(System(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 = System(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 = mtkcompile(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 `mtkcompile` is so important +to that process. diff --git a/docs/src/getting_started/odes.md b/docs/src/getting_started/odes.md new file mode 100644 index 0000000000..7f51accc16 --- /dev/null +++ b/docs/src/getting_started/odes.md @@ -0,0 +1,145 @@ +# [Building ODEs and DAEs with ModelingToolkit.jl](@id getting_started_ode) + +This is an introductory tutorial for ModelingToolkit.jl (MTK). We will demonstrate the +basics of the package by demontrating how to build systems of Ordinary Differential +Equations (ODEs) and Differential-Algebraic Equations (DAEs). + +## Installing ModelingToolkit + +To install ModelingToolkit, use the Julia package manager. This can be done as follows: + +```julia +using Pkg +Pkg.add("ModelingToolkit") +``` + +## The end goal + +TODO + +## Basics of MTK + +ModelingToolkit.jl is a symbolic-numeric system. This means it allows specifying a model +(such as an ODE) in a similar way to how it would be written on paper. Let's start with a +simple example. The system to be modeled 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. + +For simplicity, we will start off by setting the forcing function to a constant `1`. Every +ODE has a single independent variable. MTK has a common definition for time `t` and the +derivative with respect to it. + +```@example ode2 +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +``` + +Next, we declare the (dependent) variables and the parameters of our model: + +```@example ode2 +@variables x(t) +@parameters τ +``` + +Note the syntax `x(t)`. We must declare that the variable `x` is a function of the independent +variable `t`. Next, we define the equations of the system: + +```@example ode2 +eqs = [D(x) ~ (1 - x) / τ] +``` + +Since `=` is reserved as the assignment operator, MTK uses `~` to denote equality between +expressions. Now we must consolidate all of this information about our system of ODEs into +ModelingToolkit's `System` type. + +```@example ode2 +sys = System(eqs, t, [x], [τ]; name = :sys) +``` + +The `System` constructor accepts a `Vector{Equation}` as the first argument, followed by the +independent variable, a list of dependent variables, and a list of parameters. Every system +must be given a name via the `name` keyword argument. Most of the time, we want to name our +system the same as the variable it is assigned to. The `@named` macro helps with this: + +```@example ode2 +@named sys = System(eqs, t, [x], [τ]) +``` + +Additionally, it may become inconvenient to specify all variables and parameters every time +a system is created. MTK allows omitting these arguments, and will automatically infer them +from the equations. + +```@example ode2 +@named sys = System(eqs, t) +``` + +Our system is not quite ready for simulation yet. First, we must use the `mtkcompile` +function which transforms the system into a form that MTK can handle. For our trivial +system, this does not do much. + +```@example ode2 +simp_sys = mtkcompile(sys) +``` + +Since building and simplifying a system is a common workflow, MTK provides the `@mtkcompile` +macro for convenience. + +```@example ode2 +@mtkcompile sys = System(eqs, t) +``` + +We can now build an `ODEProblem` from the system. ModelingToolkit generates the necessary +code for numerical ODE solvers to solve this system. We need to provide an initial +value for the variable `x` and a value for the parameter `p`, as well as the time span +for which to simulate the system. + +```@example ode2 +prob = ODEProblem(sys, [x => 0.0, τ => 3.0], (0.0, 10.0)) +``` + +Here, we are saying that `x` should start at `0.0`, `τ` should be `3.0` and the system +should be simulated from `t = 0.0` to `t = 10.0`. To solve the system, we must import a +solver. + +```@example ode2 +using OrdinaryDiffEq + +sol = solve(prob) +``` + +[OrdinaryDiffEq.jl](https://docs.sciml.ai/DiffEqDocs/stable/) contains a large number of +numerical solvers. It also comes with a default solver which is used when calling +`solve(prob)` and is capable of handling a large variety of systems. + +We can obtain the timeseries of `x` by indexing the solution with the symbolic variable: + +```@example ode2 +sol[x] +``` + +We can even obtain timeseries of complicated expressions involving the symbolic variables in +the model + +```@example ode2 +sol[(1 - x) / τ] +``` + +Perhaps more interesting is a plot of the solution. This can easily be achieved using Plots.jl. + +```@example ode2 +using Plots + +plot(sol) +``` + +Similarly, we can plot different expressions: + +```@example ode2 +plot(sol; idxs = (1 - x) / τ) +``` diff --git a/docs/src/index.md b/docs/src/index.md index df01d1476b..77eb4d5423 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 @@ -36,6 +36,12 @@ If you use ModelingToolkit in your work, please cite the following: ## Feature Summary +!!! danger "ModelingToolkit version 10" + + ModelingToolkit version 10 just released. Please refer to the [changelog](https://github.com/SciML/ModelingToolkit.jl/blob/master/NEWS.md) + for a summary of the changes. Some documentation pages may be broken while downstram + packages update to the new version. + ModelingToolkit.jl is a symbolic-numeric modeling package. Thus it combines some of the features from symbolic computing packages like SymPy or Mathematica with the ideas of equation-based modeling systems like the causal Simulink and the @@ -46,106 +52,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 Systems 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 +model and performing symbolic manipulations, an `System` 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 `System`, + `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..409ca51571 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -2,17 +2,3 @@ This is a page for detailing some of the inner workings to help future 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 -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. 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/JumpSystem.md b/docs/src/systems/JumpSystem.md deleted file mode 100644 index 980ec24db4..0000000000 --- a/docs/src/systems/JumpSystem.md +++ /dev/null @@ -1,29 +0,0 @@ -# JumpSystem - -## System Constructors - -```@docs -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. - -## Transformations - -```@docs -structural_simplify -``` - -## Analyses - -## Problem Constructors - -```@docs -DiscreteProblem -JumpProblem -``` diff --git a/docs/src/systems/NonlinearSystem.md b/docs/src/systems/NonlinearSystem.md deleted file mode 100644 index 3147ecb0ee..0000000000 --- a/docs/src/systems/NonlinearSystem.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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 -``` diff --git a/docs/src/systems/ODESystem.md b/docs/src/systems/ODESystem.md deleted file mode 100644 index 9adf55c5b2..0000000000 --- a/docs/src/systems/ODESystem.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 -``` diff --git a/docs/src/systems/OptimizationSystem.md b/docs/src/systems/OptimizationSystem.md deleted file mode 100644 index c1823b19df..0000000000 --- a/docs/src/systems/OptimizationSystem.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 -``` 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 deleted file mode 100644 index 7026de7f3f..0000000000 --- a/docs/src/systems/SDESystem.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 -``` diff --git a/docs/src/tutorials/SampledData.md b/docs/src/tutorials/SampledData.md new file mode 100644 index 0000000000..9f3f340f46 --- /dev/null +++ b/docs/src/tutorials/SampledData.md @@ -0,0 +1,197 @@ +# 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 + eqs = [D(x) ~ -x + u + y ~ x] + System(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] + System(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)] + System(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 = System(connections, t, systems = [f, c, p]) +``` + +```@docs; canonical = false +Sample +Hold +ShiftIndex +Clock +``` diff --git a/docs/src/tutorials/acausal_components.md b/docs/src/tutorials/acausal_components.md index e0fe79c714..46da36caa4 100644 --- a/docs/src/tutorials/acausal_components.md +++ b/docs/src/tutorials/acausal_components.md @@ -1,437 +1,355 @@ -# 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) +@mtkcompile 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 `System`. +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 +Note that this is an incompletely specified System: it cannot be simulated +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: +values. +One can then construct a `Pin` using the `@named` helper macro: -```julia -Pin(name=:mypin1) -``` - -or equivalently 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 -this component, we generate an `ODESystem` with a `Pin` subcomponent and specify +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 `System` 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 +@mtkcompile 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: +The observed equations are: -```julia -sys = structural_simplify(rc_model) -equations(sys) - -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) - -2-element Vector{Any}: - capacitor₊v(t) - capacitor₊p₊i(t) -``` +## Solving this System -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) - -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: +```@example acausal +u0 = [rc_model.capacitor.v => 0.0] -```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! - +By default, this plots only the unknown variables that had to be solved for. 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 `mtkcompile` 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]) +``` + +Although it may be more confusing than helpful here, we can of course also plot all unknown and observed variables together: + +```@example acausal +plot(sol, idxs = [unknowns(rc_model); observables(rc_model)]) ``` diff --git a/docs/src/tutorials/attractors.md b/docs/src/tutorials/attractors.md new file mode 100644 index 0000000000..8b5fecbef9 --- /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 `System` and cast it in an `ODEProblem` as in the [`Systems` tutorial](@ref programmatically). Since all state variables and parameters have a default value we can immediately write + +```@example Attractors +@named modlorenz = System(eqs, t) +ssys = mtkcompile(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..3ceb5474ab --- /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 `System`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] +@mtkcompile nsys = System(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 `System` inputs + +It is also possible to use `System`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)] +@mtkcompile osys = System(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..2500c27015 --- /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 `System` 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)(..) + +@mtkcompile sys = System(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, interp => spline], (0.0, 1.0)) +solve(prob) +``` + +Note that the the following will not work: + +```julia +ODEProblem( + sys; [x => 0.0, interp => LinearInterpolation(0.0:0.1:1.0, 0.0:0.1:1.0)], (0.0, 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..b5b548a73b --- /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 = System(eqs, t; initialization_eqs, name = :M) +M1s = mtkcompile(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 = mtkcompile(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, v => 8.0], [0.0, 10.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 System(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 = System(eqs, t, [Ω, a], []; initialization_eqs, name = :M) +M1 = compose(M1, r, m, Λ) +M1s = mtkcompile(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 = mtkcompile(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..d070156076 --- /dev/null +++ b/docs/src/tutorials/discrete_system.md @@ -0,0 +1,50 @@ +# (Experimental) Modeling Discrete Systems + +In this example, we will use the [`System`](@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] +@mtkcompile sys = System(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, vcat(u0, p), tspan) +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 +@mtkcompile sys = System([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..e4b980a707 --- /dev/null +++ b/docs/src/tutorials/disturbance_modeling.md @@ -0,0 +1,238 @@ +```@meta +Draft = true +``` + +# 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 = mtkcompile(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 `@mtkcompile` or the lower-level function `mtkcompile`. + +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, [`ModelingToolkit.generate_control_function`](@ref) and [`ModelingToolkit.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 = mtkcompile(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(io_sys, 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; canonical = false +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..f9cd3040d2 --- /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 + + System(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 + ] + + System(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] + + System(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] + + System(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 HydraulicSystem(; 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)] + + System(eqs, t, [], []; systems, name) +end + +@named odesys = HydraulicSystem() +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 `mtkcompile()` 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 = mtkcompile(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] + + System(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)] + + System(eqs, t, [], []; systems, name) +end + +@named actsys2 = ActuatorSystem2() +nothing #hide +``` + +After running `mtkcompile()` 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)] + + System(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] + + System(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)] + + System(eqs, t, [], []; systems, name) +end + +@mtkcompile ressys = RestrictorSystem() +nothing #hide +``` + +When `mtkcompile()` 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] + + System(eqs, t, [], pars; systems, name) +end + +@mtkcompile 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/dynamic_optimization.md b/docs/src/tutorials/dynamic_optimization.md new file mode 100644 index 0000000000..ff1ffab3dc --- /dev/null +++ b/docs/src/tutorials/dynamic_optimization.md @@ -0,0 +1,128 @@ +# Solving Dynamic Optimization Problems + +Systems in ModelingToolkit.jl can be directly converted to dynamic optimization or optimal control problems. In such systems, one has one or more input variables that are externally controlled to control the dynamics of the system. A dynamic optimization solves for the optimal time trajectory of the input variables in order to maximize or minimize a desired objective function. For example, a car driver might like to know how to step on the accelerator if the goal is to finish a race while using the least gas. + +To begin, let us take a rocket launch example. The input variable here is the thrust exerted by the engine. The rocket state is described by its current height, mass, and velocity. The mass decreases as the rocket loses fuel while thrusting. + +```@example dynamic_opt +using ModelingToolkit +t = ModelingToolkit.t_nounits +D = ModelingToolkit.D_nounits + +@parameters h_c m₀ h₀ g₀ D_c c Tₘ m_c +@variables begin + h(..) + v(..) + m(..), [bounds = (m_c, 1)] + T(..), [input = true, bounds = (0, Tₘ)] +end + +drag(h, v) = D_c * v^2 * exp(-h_c * (h - h₀) / h₀) +gravity(h) = g₀ * (h₀ / h) + +eqs = [D(h(t)) ~ v(t), + D(v(t)) ~ (T(t) - drag(h(t), v(t))) / m(t) - gravity(h(t)), + D(m(t)) ~ -T(t) / c] + +(ts, te) = (0.0, 0.2) +costs = [-h(te)] +cons = [T(te) ~ 0, m(te) ~ m_c] + +@named rocket = System(eqs, t; costs, constraints = cons) +rocket = mtkcompile(rocket, inputs = [T(t)]) + +u0map = [h(t) => h₀, m(t) => m₀, v(t) => 0] +pmap = [ + g₀ => 1, m₀ => 1.0, h_c => 500, c => 0.5 * √(g₀ * h₀), D_c => 0.5 * 620 * m₀ / g₀, + Tₘ => 3.5 * g₀ * m₀, T(t) => 0.0, h₀ => 1, m_c => 0.6] +``` + +What we would like to optimize here is the final height of the rocket. We do this by providing a vector of expressions corresponding to the costs. By default, the sense of the optimization is to minimize the provided cost. So to maximize the rocket height at the final time, we write `-h(te)` as the cost. + +Now we can construct a problem and solve it. Let us use JuMP as our backend here. Note that the package trigger is actually [InfiniteOpt](https://infiniteopt.github.io/InfiniteOpt.jl/stable/), and not JuMP - this package includes JuMP but is designed for optimization on function spaces. Additionally we need to load the solver package - we will use [Ipopt](https://github.com/jump-dev/Ipopt.jl) here (a good choice in general). + +Here we have also loaded DiffEqDevTools because we will need to construct the ODE tableau. This is only needed if one desires a custom ODE tableau for the collocation - by default the solver will use RadauIIA5. + +```@example dynamic_opt +using InfiniteOpt, Ipopt, DiffEqDevTools +jprob = JuMPDynamicOptProblem(rocket, [u0map; pmap], (ts, te); dt = 0.001) +jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRadauIIA5())); +``` + +The solution has three fields: `jsol.sol` is the ODE solution for the states, `jsol.input_sol` is the ODE solution for the inputs, and `jsol.model` is the wrapped model that we can use to query things like objective and constraint residuals. + +Let's plot the final solution and the controller here: + +```@example dynamic_opt +using CairoMakie +fig = Figure(resolution = (800, 400)) +ax1 = Axis(fig[1, 1], title = "Rocket trajectory", xlabel = "Time") +ax2 = Axis(fig[1, 2], title = "Control trajectory", xlabel = "Time") + +for u in unknowns(rocket) + lines!(ax1, jsol.sol.t, jsol.sol[u], label = string(u)) +end +lines!(ax2, jsol.input_sol, label = "Thrust") +axislegend(ax1) +axislegend(ax2) +fig +``` + +### Free final time problems + +There are additionally a class of dynamic optimization problems where we would like to know how to control our system to achieve something in the least time. Such problems are called free final time problems, since the final time is unknown. To model these problems in ModelingToolkit, we declare the final time as a parameter. + +Below we have a model system called the double integrator. We control the acceleration of a block in order to reach a desired destination in the least time. + +```@example dynamic_opt +@variables begin + x(..) + v(..) + u(..), [bounds = (-1.0, 1.0), input = true] +end + +@parameters tf + +constr = [v(tf) ~ 0, x(tf) ~ 0] +cost = [tf] # Minimize time + +@named block = System( + [D(x(t)) ~ v(t), D(v(t)) ~ u(t)], t; costs = cost, constraints = constr) + +block = mtkcompile(block; inputs = [u(t)]) + +u0map = [x(t) => 1.0, v(t) => 0.0] +tspan = (0.0, tf) +parammap = [u(t) => 0.0, tf => 1.0] +``` + +The `tf` mapping in the parameter map is treated as an initial guess. + +Please note that, at the moment, free final time problems cannot support constraints defined at definite time values, like `x(3) ~ 2`. + +!!! warning + + The Pyomo collocation methods (LagrangeRadau, LagrangeLegendre) currently are bugged for free final time problems. Strongly suggest using BackwardEuler() for such problems when using Pyomo as the backend. + +When declaring the problem in this case we need to provide the number of steps, since dt can't be known in advanced. Let's solve plot our final solution and the controller for the block, using InfiniteOpt as the backend: + +```@example dynamic_opt +iprob = InfiniteOptDynamicOptProblem(block, [u0map; parammap], tspan; steps = 100) +isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)); +``` + +Let's plot the final solution and the controller here: + +```@example dynamic_opt +fig = Figure(resolution = (800, 400)) +ax1 = Axis(fig[1, 1], title = "Block trajectory", xlabel = "Time") +ax2 = Axis(fig[1, 2], title = "Control trajectory", xlabel = "Time") + +for u in unknowns(block) + lines!(ax1, isol.sol.t, isol.sol[u], label = string(u)) +end +lines!(ax2, isol.input_sol, label = "Acceleration") +axislegend(ax1) +axislegend(ax2) +fig +``` diff --git a/docs/src/tutorials/fmi.md b/docs/src/tutorials/fmi.md new file mode 100644 index 0000000000..a468f30f8f --- /dev/null +++ b/docs/src/tutorials/fmi.md @@ -0,0 +1,230 @@ +# 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 = mtkcompile(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, + reinitializealg = BrownFullBasicInit()) +``` + +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 `mtkcompile` will lead +to an `System` with no unknowns. + +```@example fmi +mtkcompile(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 +@mtkcompile sys = System([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, reinitializealg = BrownFullBasicInit()); +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]; +@mtkcompile sys = System( + [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, sys.adder.value => 2.0], + (0.0, 1.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()) +@mtkcompile sys = System( + [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, sys.adder.value => 2.0], + (0.0, 1.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..a804ca1b3e --- /dev/null +++ b/docs/src/tutorials/initialization.md @@ -0,0 +1,549 @@ +# [Initialization of Systems](@id initialization) + +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 System 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] +@mtkcompile pend = System(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, g => 1], (0.0, 1.5), 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 3.0); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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, g => 1], (0.0, 1.5); 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 +@mtkcompile sys = System([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; op = [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 = mtkcompile(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; op = [x => 1, y => 0.0, D(y) => 2.0, λ => 1], guesses = [λ => 1]) +``` + +```@example init +isys = mtkcompile(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 = System(eqs, t) +simpsys = mtkcompile(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..5317b45fc9 --- /dev/null +++ b/docs/src/tutorials/linear_analysis.md @@ -0,0 +1,154 @@ +# 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 +using ModelingToolkit: t_nounits as t + +@named P = FirstOrder(k = 1, T = 1) # A first-order system with pole in -1 +@named C = Gain(-1) # A P controller + +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 = System(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; canonical = false +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..8cd8015630 --- /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 `System`: + +```@example mtkize +@mtkcompile 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..8342eb788b 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 Systems 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] +@mtkcompile ns = System(eqs) + +guesses = [x => 1.0, y => 0.0, z => 0.0] +ps = [σ => 10.0, ρ => 26.0, β => 8 / 3] + +prob = NonlinearProblem(ns, vcat(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, vcat(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..6da2524140 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 +@mtkcompile 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 + +@mtkcompile fol = FOL() +``` + +Note that equations in MTK use the tilde character (`~`) as equality sign. + +`@mtkcompile` 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 +@mtkcompile 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.x => 0.5, fol.τ => 1 / 3], (0.0, 10.0)) +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 + +@mtkcompile 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 + +@mtkcompile 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 + +@mtkcompile 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 +@mtkcompile 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 +[System](@ref System) 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 `System`s. If that's the case, check out + the [Programmatically Generating and Scripting Systems 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..89237e01e6 100644 --- a/docs/src/tutorials/optimization.md +++ b/docs/src/tutorials/optimization.md @@ -1,26 +1,120 @@ +```@meta +Draft = true +``` + # 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 Systems 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 +@mtkcompile 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, vcat(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 ] +@mtkcompile 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..1731d5795f --- /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 `System` 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 `System` 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 +@mtkcompile 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 + +@mtkcompile 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..93a9543818 --- /dev/null +++ b/docs/src/tutorials/programmatically_generating.md @@ -0,0 +1,78 @@ +# [Programmatically Generating and Scripting Systems](@id programmatically) + +In the following tutorial, we will discuss how to programmatically generate `System`s. +This is useful for functions that generate `System`s, for example +when you implement a reader that parses some file format, such as SBML, to generate an `System`. +It is also useful for functions that transform an `System`, for example +when you write a function that log-transforms a variable in an `System`. + +## 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 +@independent_variables t +@variables 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 System + +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 = System(eqs, t) + +# Perform the standard transformations and mark the model complete +# Note: Complete models cannot be subsystems of other models! +fol = mtkcompile(model) +prob = ODEProblem(fol, [], (0.0, 10.0), []) +using OrdinaryDiffEq +sol = solve(prob) + +using Plots +plot(sol) +``` + +As you can see, generating an `System` is as simple as creating an array of equations +and passing it to the `System` constructor. + +`@named` automatically gives a name to the `System`, and is shorthand for + +```@example scripting +fol_model = System(eqs, t; name = :fol_model) # @named fol_model = System(eqs, t) +``` + +Thus, if we had read a name from a file and wish to populate an `System` with said name, we could do: + +```@example scripting +namesym = :name_from_file +fol_model = System(eqs, t; name = namesym) +``` + +## Warning About Mutation + +Be advsied that it's never a good idea to mutate an `System`, or any `AbstractSystem`. diff --git a/docs/src/tutorials/stochastic_diffeq.md b/docs/src/tutorials/stochastic_diffeq.md index 07cf9d88e3..d7326c36c6 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 `System`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 Systems 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, `@brownians` 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 +@brownians 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] +@mtkcompile 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 `@brownians` variables have to be declared. -prob = SDEProblem(de,u0map,(0.0,100.0),parammap) -sol = solve(prob,SOSRI()) +```@example SDE +@brownians 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] +@mtkcompile 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 deleted file mode 100644 index 9d9f15abf0..0000000000 --- a/examples/electrical_components.jl +++ /dev/null @@ -1,84 +0,0 @@ -using Test -using ModelingToolkit, OrdinaryDiffEq - -# 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]) -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) - @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 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) -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) -end - -function Inductor(; name, L = 1.0) - val = L - @named p = Pin() - @named n = Pin() - @variables v(t) i(t) - @parameters L - D = Differential(t) - eqs = [ - 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) -end diff --git a/examples/rc_model.jl b/examples/rc_model.jl deleted file mode 100644 index 094197f714..0000000000 --- a/examples/rc_model.jl +++ /dev/null @@ -1,17 +0,0 @@ -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 ground = Ground() - -rc_eqs = [ - connect(source.p, resistor.p) - connect(resistor.n, capacitor.p) - connect(capacitor.n, source.n, ground.g) - ] - -@named rc_model = ODESystem(rc_eqs, t, systems=[resistor, capacitor, source, ground]) diff --git a/examples/serial_inductor.jl b/examples/serial_inductor.jl deleted file mode 100644 index 4f49cf48b7..0000000000 --- a/examples/serial_inductor.jl +++ /dev/null @@ -1,16 +0,0 @@ -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 ground = Ground() - -eqs = [ - connect(source.p, resistor.p) - connect(resistor.n, inductor1.p) - connect(inductor1.n, inductor2.p) - connect(source.n, inductor2.n, ground.g) - ] - -@named ll_model = ODESystem(eqs, t, systems=[source, resistor, inductor1, inductor2, ground]) diff --git a/ext/MTKBifurcationKitExt.jl b/ext/MTKBifurcationKitExt.jl new file mode 100644 index 0000000000..6a85fe66cd --- /dev/null +++ b/ext/MTKBifurcationKitExt.jl @@ -0,0 +1,156 @@ +module MTKBifurcationKitExt + +### Preparations ### + +# Imports +using ModelingToolkit, Setfield +import BifurcationKit +using SymbolicIndexingInterface: is_time_dependent + +### 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::System, + 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::System, + 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 `System` is required. Call `complete` or `structural_simplify` on the system before creating a `BifurcationProblem`") + end + if is_time_dependent(nsys) + nsys = System([0 ~ eq.rhs for eq in full_equations(nsys)], + unknowns(nsys), + parameters(nsys); + observed = observed(nsys), + name = nameof(nsys)) + nsys = complete(nsys) + 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 = ModelingToolkit.to_varmap(u0_bif, unknowns(nsys)) + u0_buf = merge(ModelingToolkit.get_defaults(nsys), u0_bif) + u0_bif_vals = ModelingToolkit.varmap_to_vars(u0_bif, unknowns(nsys)) + ps = ModelingToolkit.to_varmap(ps, parameters(nsys)) + ps = merge(ModelingToolkit.get_defaults(nsys), ps) + p_vals = ModelingToolkit.varmap_to_vars(ps, parameters(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 + +end # module diff --git a/ext/MTKCasADiDynamicOptExt.jl b/ext/MTKCasADiDynamicOptExt.jl new file mode 100644 index 0000000000..addc478d98 --- /dev/null +++ b/ext/MTKCasADiDynamicOptExt.jl @@ -0,0 +1,244 @@ +module MTKCasADiDynamicOptExt +using ModelingToolkit +using CasADi +using DiffEqBase +using UnPack +using NaNMath +const MTK = ModelingToolkit + +for ff in [acos, log1p, acosh, log2, asin, tan, atanh, cos, log, sin, log10, sqrt] + f = nameof(ff) + @eval NaNMath.$f(x::CasadiSymbolicObject) = Base.$f(x) +end + +# Default linear interpolation for MX objects, likely to change down the line when we support interpolation with the collocation polynomial. +struct MXLinearInterpolation + u::MX + t::Vector{Float64} + dt::Float64 +end +function Base.getindex(m::MXLinearInterpolation, i...) + length(i) == length(size(m.u)) ? m.u[i...] : m.u[i..., :] +end + +mutable struct CasADiModel + model::Opti + U::MXLinearInterpolation + V::MXLinearInterpolation + tₛ::MX + is_free_final::Bool + solver_opti::Union{Nothing, Opti} + + function CasADiModel(opti, U, V, tₛ, is_free_final, solver_opti = nothing) + new(opti, U, V, tₛ, is_free_final, solver_opti) + end +end + +struct CasADiDynamicOptProblem{uType, tType, isinplace, P, F, K} <: + AbstractDynamicOptProblem{uType, tType, isinplace} + f::F + u0::uType + tspan::tType + p::P + wrapped_model::CasADiModel + kwargs::K + + function CasADiDynamicOptProblem(f, u0, tspan, p, model, kwargs...) + new{typeof(u0), typeof(tspan), SciMLBase.isinplace(f, 5), + typeof(p), typeof(f), typeof(kwargs)}(f, u0, tspan, p, model, kwargs) + end +end + +function (M::MXLinearInterpolation)(τ) + nt = (τ - M.t[1]) / M.dt + i = 1 + floor(Int, nt) + Δ = nt - i + 1 + + (i > length(M.t) || i < 1) && error("Cannot extrapolate past the tspan.") + colons = ntuple(_ -> (:), length(size(M.u)) - 1) + if i < length(M.t) + M.u[colons..., i] + Δ*(M.u[colons..., i + 1] - M.u[colons..., i]) + else + M.u[colons..., i] + end +end + +function MTK.CasADiDynamicOptProblem(sys::System, op, tspan; + dt = nothing, + steps = nothing, + guesses = Dict(), kwargs...) + prob, + _ = MTK.process_DynamicOptProblem( + CasADiDynamicOptProblem, CasADiModel, sys, op, tspan; dt, steps, guesses, kwargs...) + prob +end + +MTK.generate_internal_model(::Type{CasADiModel}) = CasADi.Opti() +MTK.generate_time_variable!(opti::Opti, args...) = nothing + +function MTK.generate_state_variable!(model::Opti, u0, ns, tsteps) + nt = length(tsteps) + U = CasADi.variable!(model, ns, nt) + set_initial!(model, U, DM(repeat(u0, 1, nt))) + MXLinearInterpolation(U, tsteps, tsteps[2] - tsteps[1]) +end + +function MTK.generate_input_variable!(model::Opti, c0, nc, tsteps) + nt = length(tsteps) + V = CasADi.variable!(model, nc, nt) + !isempty(c0) && set_initial!(model, V, DM(repeat(c0, 1, nt))) + MXLinearInterpolation(V, tsteps, tsteps[2] - tsteps[1]) +end + +function MTK.generate_timescale!(model::Opti, guess, is_free_t) + if is_free_t + tₛ = variable!(model) + set_initial!(model, tₛ, guess) + subject_to!(model, tₛ >= 0) + tₛ + else + MX(1) + end +end + +function MTK.add_constraint!(m::CasADiModel, expr) + if expr isa Equation + subject_to!(m.model, expr.lhs - expr.rhs == 0) + elseif expr.relational_op === Symbolics.geq + subject_to!(m.model, expr.lhs - expr.rhs ≥ 0) + else + subject_to!(m.model, expr.lhs - expr.rhs ≤ 0) + end +end + +MTK.set_objective!(m::CasADiModel, expr) = minimize!(m.model, MX(expr)) + +function MTK.add_initial_constraints!(m::CasADiModel, u0, u0_idxs, args...) + @unpack model, U = m + for i in u0_idxs + subject_to!(model, U.u[i, 1] == u0[i]) + end +end + +function MTK.lowered_var(m::CasADiModel, uv, i, t) + X = getfield(m, uv) + t isa Union{Num, Symbolics.Symbolic} ? X.u[i, :] : X(t)[i] +end + +function MTK.lowered_integral(model::CasADiModel, expr, lo, hi) + total = MX(0) + dt = model.U.t[2] - model.U.t[1] + for (i, t) in enumerate(model.U.t) + if lo < t < hi + Δt = min(dt, t - lo) + total += (0.5*Δt*(expr[i] + expr[i - 1])) + elseif t >= hi && (t - dt < hi) + Δt = hi - t + dt + total += (0.5*Δt*(expr[i] + expr[i - 1])) + end + end + model.tₛ * total +end + +function add_solve_constraints!(prob::CasADiDynamicOptProblem, tableau) + @unpack A, α, c = tableau + @unpack wrapped_model, f, p = prob + @unpack model, U, V, tₛ = wrapped_model + solver_opti = copy(model) + + tsteps = U.t + dt = tsteps[2] - tsteps[1] + + nᵤ = size(U.u, 1) + nᵥ = size(V.u, 1) + + if MTK.is_explicit(tableau) + K = MX[] + for k in 1:(length(tsteps) - 1) + τ = tsteps[k] + for (i, h) in enumerate(c) + ΔU = sum([A[i, j] * K[j] for j in 1:(i - 1)], init = MX(zeros(nᵤ))) + Uₙ = U.u[:, k] + ΔU * dt + Vₙ = V.u[:, k] + Kₙ = tₛ * f(Uₙ, Vₙ, p, τ + h * dt) # scale the time + push!(K, Kₙ) + end + ΔU = dt * sum([α[i] * K[i] for i in 1:length(α)]) + subject_to!(solver_opti, U.u[:, k] + ΔU == U.u[:, k + 1]) + empty!(K) + end + else + for k in 1:(length(tsteps) - 1) + τ = tsteps[k] + Kᵢ = variable!(solver_opti, nᵤ, length(α)) + ΔUs = A * Kᵢ' + for (i, h) in enumerate(c) + ΔU = ΔUs[i, :]' + Uₙ = U.u[:, k] + ΔU * dt + Vₙ = V.u[:, k] + subject_to!(solver_opti, Kᵢ[:, i] == tₛ * f(Uₙ, Vₙ, p, τ + h * dt)) + end + ΔU_tot = dt * (Kᵢ * α) + subject_to!(solver_opti, U.u[:, k] + ΔU_tot == U.u[:, k + 1]) + end + end + solver_opti +end + +struct CasADiCollocation <: AbstractCollocation + solver::Union{String, Symbol} + tableau::DiffEqBase.ODERKTableau +end + +function MTK.CasADiCollocation(solver, tableau = MTK.constructDefault()) + CasADiCollocation(solver, tableau) +end + +function MTK.prepare_and_optimize!( + prob::CasADiDynamicOptProblem, solver::CasADiCollocation; verbose = false, + solver_options = Dict(), plugin_options = Dict(), kwargs...) + solver_opti = add_solve_constraints!(prob, solver.tableau) + verbose || (solver_options["print_level"] = 0) + solver!(solver_opti, "$(solver.solver)", plugin_options, solver_options) + try + CasADi.solve!(solver_opti) + catch ErrorException + end + prob.wrapped_model.solver_opti = solver_opti + prob.wrapped_model +end + +function MTK.get_U_values(model::CasADiModel) + value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value + (nu, nt) = size(model.U.u) + U_vals = value_getter(model.solver_opti, model.U.u) + size(U_vals, 2) == 1 && (U_vals = U_vals') + U_vals = [[U_vals[i, j] for i in 1:nu] for j in 1:nt] +end + +function MTK.get_V_values(model::CasADiModel) + value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value + (nu, nt) = size(model.V.u) + if nu*nt != 0 + V_vals = value_getter(model.solver_opti, model.V.u) + size(V_vals, 2) == 1 && (V_vals = V_vals') + V_vals = [[V_vals[i, j] for i in 1:nu] for j in 1:nt] + else + nothing + end +end + +function MTK.get_t_values(model::CasADiModel) + value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value + ts = value_getter(model.solver_opti, model.tₛ) .* model.U.t +end +function MTK.objective_value(model::CasADiModel) + CasADi.pyconvert(Float64, model.solver_opti.py.value(model.solver_opti.py.f)) +end + +function MTK.successful_solve(m::CasADiModel) + isnothing(m.solver_opti) && return false + retcode = CasADi.return_status(m.solver_opti) + retcode == "Solve_Succeeded" || retcode == "Solved_To_Acceptable_Level" +end +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..87ed6662d4 --- /dev/null +++ b/ext/MTKFMIExt.jl @@ -0,0 +1,932 @@ +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 = nothing, 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.ImperativeAffect(fmiFinalize!; observed = (; wrapper)) + step_affect = MTK.ImperativeAffect(Returns((;))) + instance_management_callback = MTK.SymbolicDiscreteCallback( + (t == t - 1), step_affect; finalize = finalize_affect, reinitializealg = SciMLBase.NoInit()) + + 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.ImperativeAffect(fmiFinalize!; observed = (; 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) + + # 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 System(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 an `ImperativeAffect`. 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!(m, o, ctx, integrator) + wrapper = o.wrapper + reset_instance!(wrapper) + return (;) +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..e0f02c0436 --- /dev/null +++ b/ext/MTKInfiniteOptExt.jl @@ -0,0 +1,268 @@ +module MTKInfiniteOptExt +using ModelingToolkit +using InfiniteOpt +using DiffEqBase +using LinearAlgebra +using StaticArrays +using UnPack +import SymbolicUtils +import NaNMath +const MTK = ModelingToolkit + +struct InfiniteOptModel + model::InfiniteModel + U::Vector{<:AbstractVariableRef} + V::Vector{<:AbstractVariableRef} + tₛ::AbstractVariableRef + is_free_final::Bool +end + +struct JuMPDynamicOptProblem{uType, tType, isinplace, P, F, K} <: + AbstractDynamicOptProblem{uType, tType, isinplace} + f::F + u0::uType + tspan::tType + p::P + wrapped_model::InfiniteOptModel + kwargs::K + + function JuMPDynamicOptProblem(f, u0, tspan, p, model, kwargs...) + new{typeof(u0), typeof(tspan), SciMLBase.isinplace(f, 5), + typeof(p), typeof(f), typeof(kwargs)}(f, u0, tspan, p, model, kwargs) + end +end + +struct InfiniteOptDynamicOptProblem{uType, tType, isinplace, P, F, K} <: + AbstractDynamicOptProblem{uType, tType, isinplace} + f::F + u0::uType + tspan::tType + p::P + wrapped_model::InfiniteOptModel + kwargs::K + + function InfiniteOptDynamicOptProblem(f, u0, tspan, p, model, kwargs...) + new{typeof(u0), typeof(tspan), SciMLBase.isinplace(f), + typeof(p), typeof(f), typeof(kwargs)}(f, u0, tspan, p, model, kwargs) + end +end + +MTK.generate_internal_model(m::Type{InfiniteOptModel}) = InfiniteModel() +function MTK.generate_time_variable!(m::InfiniteModel, tspan, tsteps) + @infinite_parameter(m, t in [tspan[1], tspan[2]], num_supports = length(tsteps)) +end +function MTK.generate_state_variable!(m::InfiniteModel, u0::Vector, ns, ts) + @variable(m, U[i = 1:ns], Infinite(m[:t]), start=u0[i]) +end +function MTK.generate_input_variable!(m::InfiniteModel, c0, nc, ts) + @variable(m, V[i = 1:nc], Infinite(m[:t]), start=c0[i]) +end + +function MTK.generate_timescale!(m::InfiniteModel, guess, is_free_t) + @variable(m, tₛ ≥ 0, start = guess) + if !is_free_t + fix(tₛ, 1, force = true) + set_start_value(tₛ, 1) + end + tₛ +end + +function MTK.add_constraint!(m::InfiniteOptModel, expr::Union{Equation, Inequality}) + if expr isa Equation + @constraint(m.model, expr.lhs - expr.rhs == 0) + elseif expr.relational_op === Symbolics.geq + @constraint(m.model, expr.lhs - expr.rhs ≥ 0) + else + @constraint(m.model, expr.lhs - expr.rhs ≤ 0) + end +end +MTK.set_objective!(m::InfiniteOptModel, expr) = @objective(m.model, Min, expr) + +function MTK.JuMPDynamicOptProblem(sys::System, op, tspan; + dt = nothing, + steps = nothing, + guesses = Dict(), kwargs...) + prob, + _ = MTK.process_DynamicOptProblem(JuMPDynamicOptProblem, InfiniteOptModel, sys, + op, tspan; dt, steps, guesses, kwargs...) + prob +end + +function MTK.InfiniteOptDynamicOptProblem(sys::System, op, tspan; + dt = nothing, + steps = nothing, + guesses = Dict(), kwargs...) + prob, + pmap = MTK.process_DynamicOptProblem(InfiniteOptDynamicOptProblem, InfiniteOptModel, + sys, op, tspan; dt, steps, guesses, kwargs...) + MTK.add_equational_constraints!(prob.wrapped_model, sys, pmap, tspan) + prob +end + +function MTK.lowered_integral(model::InfiniteOptModel, expr, lo, hi) + model.tₛ * InfiniteOpt.∫(expr, model.model[:t], lo, hi) +end +MTK.lowered_derivative(model::InfiniteOptModel, i) = ∂(model.U[i], model.model[:t]) + +function MTK.process_integral_bounds(model::InfiniteOptModel, integral_span, tspan) + if MTK.is_free_final(model) && isequal(integral_span, tspan) + integral_span = (0, 1) + elseif MTK.is_free_final(model) + error("Free final time problems cannot handle partial timespans.") + else + integral_span + end +end + +function MTK.add_initial_constraints!(m::InfiniteOptModel, u0, u0_idxs, ts) + for i in u0_idxs + fix(m.U[i](0), u0[i], force = true) + end +end + +function MTK.lowered_var(m::InfiniteOptModel, uv, i, t) + X = getfield(m, uv) + t isa Union{Num, Symbolics.Symbolic} ? X[i] : X[i](t) +end + +function add_solve_constraints!(prob::JuMPDynamicOptProblem, tableau) + @unpack A, α, c = tableau + @unpack wrapped_model, f, p = prob + @unpack tₛ, U, V, model = wrapped_model + t = model[:t] + tsteps = supports(t) + dt = tsteps[2] - tsteps[1] + + nᵤ = length(U) + nᵥ = length(V) + if MTK.is_explicit(tableau) + K = Any[] + for τ in tsteps[1:(end - 1)] + for (i, h) in enumerate(c) + ΔU = sum([A[i, j] * K[j] for j in 1:(i - 1)], init = zeros(nᵤ)) + Uₙ = [U[i](τ) + ΔU[i] * dt for i in 1:nᵤ] + Vₙ = [V[i](τ) for i in 1:nᵥ] + Kₙ = tₛ * f(Uₙ, Vₙ, p, τ + h * dt) + push!(K, Kₙ) + end + ΔU = dt * sum([α[i] * K[i] for i in 1:length(α)]) + @constraint(model, [n = 1:nᵤ], U[n](τ) + ΔU[n]==U[n](τ + dt), + base_name="solve_time_$τ") + empty!(K) + end + else + K = @variable(model, K[1:length(α), 1:nᵤ], Infinite(model[:t])) + ΔUs = A * K + ΔU_tot = dt * (K' * α) + for τ in tsteps[1:(end - 1)] + for (i, h) in enumerate(c) + ΔU = @view ΔUs[i, :] + Uₙ = U + ΔU * dt + @constraint(model, [j = 1:nᵤ], K[i, j]==(tₛ * f(Uₙ, V, p, τ + h * dt)[j]), + DomainRestrictions(t => τ), base_name="solve_K$i($τ)") + end + @constraint(model, + [n = 1:nᵤ], U[n](τ) + ΔU_tot[n]==U[n](min(τ + dt, tsteps[end])), + DomainRestrictions(t => τ), base_name="solve_U($τ)") + end + end +end + +struct JuMPCollocation <: AbstractCollocation + solver::Any + tableau::DiffEqBase.ODERKTableau +end +function MTK.JuMPCollocation(solver, tableau = MTK.constructDefault()) + JuMPCollocation(solver, tableau) +end + +struct InfiniteOptCollocation <: AbstractCollocation + solver::Any + derivative_method::InfiniteOpt.AbstractDerivativeMethod +end +function MTK.InfiniteOptCollocation( + solver, derivative_method = InfiniteOpt.FiniteDifference(InfiniteOpt.Backward())) + InfiniteOptCollocation(solver, derivative_method) +end + +function MTK.prepare_and_optimize!( + prob::JuMPDynamicOptProblem, solver::JuMPCollocation; verbose = false, kwargs...) + model = prob.wrapped_model.model + verbose || set_silent(model) + # Unregister current solver constraints + for con in all_constraints(model) + if occursin("solve", JuMP.name(con)) + unregister(model, Symbol(JuMP.name(con))) + delete(model, con) + end + end + unregister(model, :K) + for var in all_variables(model) + if occursin("K", JuMP.name(var)) + unregister(model, Symbol(JuMP.name(var))) + delete(model, var) + end + end + add_solve_constraints!(prob, solver.tableau) + set_optimizer(model, solver.solver) + optimize!(model) + model +end + +function MTK.prepare_and_optimize!(prob::InfiniteOptDynamicOptProblem, + solver::InfiniteOptCollocation; verbose = false, kwargs...) + model = prob.wrapped_model.model + verbose || set_silent(model) + set_derivative_method(model[:t], solver.derivative_method) + set_optimizer(model, solver.solver) + optimize!(model) + model +end + +function MTK.get_V_values(m::InfiniteModel) + nt = length(supports(m[:t])) + if !isempty(m[:V]) + V_vals = value.(m[:V]) + V_vals = [[V_vals[i][j] for i in 1:length(V_vals)] for j in 1:nt] + else + nothing + end +end +function MTK.get_U_values(m::InfiniteModel) + nt = length(supports(m[:t])) + U_vals = value.(m[:U]) + U_vals = [[U_vals[i][j] for i in 1:length(U_vals)] for j in 1:nt] +end +MTK.get_t_values(m::InfiniteModel) = value(m[:tₛ]) * supports(m[:t]) +MTK.objective_value(m::InfiniteModel) = InfiniteOpt.objective_value(m) + +function MTK.successful_solve(model::InfiniteModel) + tstatus = termination_status(model) + pstatus = primal_status(model) + !has_values(model) && + error("Model not solvable; please report this to github.com/SciML/ModelingToolkit.jl with a MWE.") + + pstatus === FEASIBLE_POINT && + (tstatus === OPTIMAL || tstatus === LOCALLY_SOLVED || tstatus === ALMOST_OPTIMAL || + tstatus === ALMOST_LOCALLY_SOLVED) +end + +import InfiniteOpt: JuMP, GeneralVariableRef + +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..c10400b109 --- /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}, t, 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/ext/MTKPyomoDynamicOptExt.jl b/ext/MTKPyomoDynamicOptExt.jl new file mode 100644 index 0000000000..5b4e9e7a1c --- /dev/null +++ b/ext/MTKPyomoDynamicOptExt.jl @@ -0,0 +1,231 @@ +module MTKPyomoDynamicOptExt +using ModelingToolkit +using Pyomo +using DiffEqBase +using UnPack +using NaNMath +using Setfield +const MTK = ModelingToolkit + +const SPECIAL_FUNCTIONS_DICT = Dict([acos => Pyomo.py_acos, + acosh => Pyomo.py_acosh, + asin => Pyomo.py_asin, + tan => Pyomo.py_tan, + atanh => Pyomo.py_atanh, + cos => Pyomo.py_cos, + log => Pyomo.py_log, + sin => Pyomo.py_sin, + sqrt => Pyomo.py_sqrt, + exp => Pyomo.py_exp]) + +struct PyomoDynamicOptModel + model::ConcreteModel + U::PyomoVar + V::PyomoVar + tₛ::PyomoVar + is_free_final::Bool + solver_model::Union{Nothing, ConcreteModel} + dU::PyomoVar + model_sym::Union{Num, Symbolics.BasicSymbolic} + t_sym::Union{Num, Symbolics.BasicSymbolic} + dummy_sym::Union{Num, Symbolics.BasicSymbolic} + + function PyomoDynamicOptModel(model, U, V, tₛ, is_free_final) + @variables MODEL_SYM::Symbolics.symstruct(ConcreteModel) T_SYM DUMMY_SYM + model.dU = dae.DerivativeVar(U, wrt = model.t, initialize = 0) + new(model, U, V, tₛ, is_free_final, nothing, + PyomoVar(model.dU), MODEL_SYM, T_SYM, DUMMY_SYM) + end +end + +struct PyomoDynamicOptProblem{uType, tType, isinplace, P, F, K} <: + AbstractDynamicOptProblem{uType, tType, isinplace} + f::F + u0::uType + tspan::tType + p::P + wrapped_model::PyomoDynamicOptModel + kwargs::K + + function PyomoDynamicOptProblem(f, u0, tspan, p, model, kwargs...) + new{typeof(u0), typeof(tspan), SciMLBase.isinplace(f, 5), + typeof(p), typeof(f), typeof(kwargs)}(f, u0, tspan, p, model, kwargs) + end +end + +function pysym_getproperty(s::Union{Num, Symbolics.Symbolic}, name::Symbol) + Symbolics.wrap(SymbolicUtils.term( + _getproperty, Symbolics.unwrap(s), Val{name}(), type = Symbolics.Struct{PyomoVar})) +end +_getproperty(s, name::Val{fieldname}) where {fieldname} = getproperty(s, fieldname) + +function MTK.PyomoDynamicOptProblem(sys::System, op, tspan; + dt = nothing, steps = nothing, + guesses = Dict(), kwargs...) + prob, + pmap = MTK.process_DynamicOptProblem(PyomoDynamicOptProblem, PyomoDynamicOptModel, + sys, op, tspan; dt, steps, guesses, kwargs...) + conc_model = prob.wrapped_model.model + MTK.add_equational_constraints!(prob.wrapped_model, sys, pmap, tspan) + prob +end + +function MTK.generate_internal_model(m::Type{PyomoDynamicOptModel}) + ConcreteModel(pyomo.ConcreteModel()) +end + +function MTK.generate_time_variable!(m::ConcreteModel, tspan, tsteps) + m.steps = length(tsteps) + m.t = dae.ContinuousSet(initialize = tsteps, bounds = tspan) + m.time = pyomo.Var(m.t) +end + +function MTK.generate_state_variable!(m::ConcreteModel, u0, ns, ts) + m.u_idxs = pyomo.RangeSet(1, ns) + init_f = Pyomo.pyfunc((m, i, t) -> (u0[Pyomo.pyconvert(Int, i)])) + m.U = pyomo.Var(m.u_idxs, m.t, initialize = init_f) + PyomoVar(m.U) +end + +function MTK.generate_input_variable!(m::ConcreteModel, c0, nc, ts) + m.v_idxs = pyomo.RangeSet(1, nc) + init_f = Pyomo.pyfunc((m, i, t) -> (c0[Pyomo.pyconvert(Int, i)])) + m.V = pyomo.Var(m.v_idxs, m.t, initialize = init_f) + PyomoVar(m.V) +end + +function MTK.generate_timescale!(m::ConcreteModel, guess, is_free_t) + m.tₛ = is_free_t ? pyomo.Var(initialize = guess, bounds = (0, Inf)) : Pyomo.Py(1) + PyomoVar(m.tₛ) +end + +function MTK.add_constraint!(pmodel::PyomoDynamicOptModel, cons; n_idxs = 1) + @unpack model, model_sym, t_sym, dummy_sym = pmodel + expr = if cons isa Equation + cons.lhs - cons.rhs == 0 + elseif cons.relational_op === Symbolics.geq + cons.lhs - cons.rhs ≥ 0 + else + cons.lhs - cons.rhs ≤ 0 + end + expr = Symbolics.substitute( + Symbolics.unwrap(expr), SPECIAL_FUNCTIONS_DICT, fold = false) + + cons_sym = Symbol("cons", hash(cons)) + if occursin(Symbolics.unwrap(t_sym), expr) + f = eval(Symbolics.build_function(expr, model_sym, t_sym)) + setproperty!(model, cons_sym, pyomo.Constraint(model.t, rule = Pyomo.pyfunc(f))) + else + f = eval(Symbolics.build_function(expr, model_sym, dummy_sym)) + setproperty!(model, cons_sym, pyomo.Constraint(rule = Pyomo.pyfunc(f))) + end +end + +function MTK.set_objective!(pmodel::PyomoDynamicOptModel, expr) + @unpack model, model_sym, t_sym, dummy_sym = pmodel + expr = Symbolics.substitute(expr, SPECIAL_FUNCTIONS_DICT, fold = false) + if occursin(Symbolics.unwrap(t_sym), expr) + f = eval(Symbolics.build_function(expr, model_sym, t_sym)) + model.obj = pyomo.Objective(model.t, rule = Pyomo.pyfunc(f)) + else + f = eval(Symbolics.build_function(expr, model_sym, dummy_sym)) + model.obj = pyomo.Objective(rule = Pyomo.pyfunc(f)) + end +end + +function MTK.add_initial_constraints!(model::PyomoDynamicOptModel, u0, u0_idxs, ts) + for i in u0_idxs + model.U[i, 0].fix(u0[i]) + end +end + +function MTK.lowered_integral(m::PyomoDynamicOptModel, arg, lo, hi) + @unpack model, model_sym, t_sym, dummy_sym = m + total = 0 + dt = Pyomo.pyconvert(Float64, (model.t.at(-1) - model.t.at(1))/(model.steps - 1)) + f = Symbolics.build_function(arg, model_sym, t_sym, expression = false) + for (i, t) in enumerate(model.t) + if Bool(lo < t) && Bool(t < hi) + t_p = model.t.at(i-1) + Δt = min(t - lo, t - t_p) + total += 0.5*Δt*(f(model, t) + f(model, t_p)) + elseif Bool(t >= hi) && Bool(t - dt < hi) + t_p = model.t.at(i-1) + Δt = hi - t + dt + total += 0.5*Δt*(f(model, t) + f(model, t_p)) + end + end + PyomoVar(model.tₛ * total) +end + +function MTK.lowered_derivative(m::PyomoDynamicOptModel, i) + mdU = Symbolics.value(pysym_getproperty(m.model_sym, :dU)) + Symbolics.unwrap(mdU[i, m.t_sym]) +end + +function MTK.lowered_var(m::PyomoDynamicOptModel, uv, i, t) + X = Symbolics.value(pysym_getproperty(m.model_sym, uv)) + var = t isa Union{Num, Symbolics.Symbolic} ? X[i, m.t_sym] : X[i, t] + Symbolics.unwrap(var) +end + +struct PyomoCollocation <: AbstractCollocation + solver::Union{String, Symbol} + derivative_method::Pyomo.DiscretizationMethod +end + +function MTK.PyomoCollocation(solver, derivative_method = LagrangeRadau(5)) + PyomoCollocation(solver, derivative_method) +end + +function MTK.prepare_and_optimize!( + prob::PyomoDynamicOptProblem, collocation; verbose, kwargs...) + solver_m = prob.wrapped_model.model.clone() + dm = collocation.derivative_method + discretizer = TransformationFactory(dm) + if MTK.is_free_final(prob.wrapped_model) && !Pyomo.is_finite_difference(dm) + error("The Lagrange-Radau and Lagrange-Legendre collocations currently cannot be used for free final problems.") + end + ncp = Pyomo.is_finite_difference(dm) ? 1 : dm.np + discretizer.apply_to(solver_m, wrt = solver_m.t, nfe = solver_m.steps - 1, + scheme = Pyomo.scheme_string(dm)) + + solver = SolverFactory(string(collocation.solver)) + results = solver.solve(solver_m, tee = true) + PyomoOutput(results, solver_m) +end + +struct PyomoOutput + result::Pyomo.Py + model::Pyomo.Py +end + +function MTK.get_U_values(output::PyomoOutput) + m = output.model + [[Pyomo.pyconvert(Float64, pyomo.value(m.U[i, t])) for i in m.u_idxs] for t in m.t] +end +function MTK.get_V_values(output::PyomoOutput) + m = output.model + [[Pyomo.pyconvert(Float64, pyomo.value(m.V[i, t])) for i in m.v_idxs] for t in m.t] +end +function MTK.get_t_values(output::PyomoOutput) + m = output.model + Pyomo.pyconvert(Float64, pyomo.value(m.tₛ)) * [Pyomo.pyconvert(Float64, t) for t in m.t] +end + +function MTK.objective_value(output::PyomoOutput) + Pyomo.pyconvert(Float64, pyomo.value(output.model.obj)) +end + +function MTK.successful_solve(output::PyomoOutput) + r = output.result + ss = r.solver.status + tc = r.solver.termination_condition + if Bool(ss == opt.SolverStatus.ok) && (Bool(tc == opt.TerminationCondition.optimal) || + Bool(tc == opt.TerminationCondition.locallyOptimal)) + return true + else + return false + end +end +end diff --git a/src/ModelingToolkit.jl b/src/ModelingToolkit.jl index 00cbb38cac..c81774946d 100644 --- a/src/ModelingToolkit.jl +++ b/src/ModelingToolkit.jl @@ -2,188 +2,380 @@ $(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 ImplicitDiscreteSolve 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 +import ChainRulesCore +import ChainRulesCore: Tangent, ZeroTangent, NoTangent, zero_tangent, unthunk + +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, + NAMESPACE_SEPARATOR, set_scalar_metadata, setdefaultval, + hasnode, 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, observables, parameters, full_parameters, + continuous_events, discrete_events +@reexport using Symbolics +@reexport using UnPack +RuntimeGeneratedFunctions.init(@__MODULE__) -import LightGraphs: SimpleDiGraph, add_edge! +import DynamicQuantities, Unitful +const DQ = DynamicQuantities -using Requires +import DifferentiationInterface as DI +using ADTypes: AutoForwardDiff +import SciMLPublic: @public + +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 end +const INTERNAL_FIELD_WARNING = """ +This field is internal API. It may be removed or changed without notice in a non-breaking \ +release. Usage of this field is not advised. """ -$(TYPEDEF) - -TODO -""" -abstract type AbstractSystem end -abstract type AbstractODESystem <: AbstractSystem end +const INTERNAL_ARGS_WARNING = """ +The following arguments are internal API. They may be removed or changed without notice \ +in a non-breaking release. Usage of these arguments is not advised. """ -$(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 +$(TYPEDEF) +TODO """ -$(TYPEDSIGNATURES) +abstract type AbstractSystem end +# Solely so that `ODESystem` can be deprecated and still act as a valid type. +# See `deprecations.jl`. +abstract type IntermediateDeprecationSystem <: AbstractSystem end -Get the set of parameters variables for the given system. -""" -function parameters 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 +export EvalAt 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/diffeqs/odesystem.jl") -include("systems/diffeqs/sdesystem.jl") -include("systems/diffeqs/abstractodesystem.jl") -include("systems/diffeqs/first_order_transform.jl") -include("systems/diffeqs/modelingtoolkitize.jl") -include("systems/diffeqs/validation.jl") +include("systems/model_parsing.jl") +include("systems/connectiongraph.jl") +include("systems/connectors.jl") +include("systems/state_machines.jl") +include("systems/analysis_points.jl") +include("systems/imperative_affect.jl") +include("systems/callbacks.jl") +include("systems/system.jl") +include("systems/codegen_utils.jl") +include("problems/docs.jl") +include("systems/codegen.jl") +include("systems/problem_utils.jl") +include("linearization.jl") + +include("problems/compatibility.jl") +include("problems/odeproblem.jl") +include("problems/ddeproblem.jl") +include("problems/daeproblem.jl") +include("problems/sdeproblem.jl") +include("problems/sddeproblem.jl") +include("problems/nonlinearproblem.jl") +include("problems/intervalnonlinearproblem.jl") +include("problems/implicitdiscreteproblem.jl") +include("problems/discreteproblem.jl") +include("problems/optimizationproblem.jl") +include("problems/jumpproblem.jl") +include("problems/initializationproblem.jl") +include("problems/sccnonlinearproblem.jl") +include("problems/bvproblem.jl") +include("problems/linearproblem.jl") + +include("modelingtoolkitize/common.jl") +include("modelingtoolkitize/odeproblem.jl") +include("modelingtoolkitize/sdeproblem.jl") +include("modelingtoolkitize/optimizationproblem.jl") +include("modelingtoolkitize/nonlinearproblem.jl") + +include("systems/nonlinear/homotopy_continuation.jl") +include("systems/nonlinear/initializesystem.jl") include("systems/diffeqs/basic_transformations.jl") -include("systems/jumps/jumpsystem.jl") - -include("systems/nonlinear/nonlinearsystem.jl") - -include("systems/optimization/optimizationsystem.jl") - -include("systems/control/controlsystem.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") + +include("adjoints.jl") +include("deprecations.jl") + +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 -export ODESystem, ODEFunction, ODEFunctionExpr, ODEProblemExpr, convert_system -export DAEFunctionExpr, DAEProblemExpr -export SDESystem, SDEFunction, SDEFunctionExpr, SDESystemExpr +const D_nounits = Differential(t_nounits) +const D_unitful = Differential(t_unitful) +const D = Differential(t) + +export ODEFunction, convert_system_indepvar, + System, OptimizationSystem, JumpSystem, SDESystem, NonlinearSystem, ODESystem +export SDEFunction export SystemStructure -export JumpSystem +export DiscreteProblem, DiscreteFunction +export ImplicitDiscreteProblem, ImplicitDiscreteFunction export ODEProblem, SDEProblem -export NonlinearProblem, NonlinearProblemExpr -export OptimizationProblem, OptimizationProblemExpr -export AutoModelingToolkit -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 NonlinearFunction +export NonlinearProblem +export IntervalNonlinearFunction +export IntervalNonlinearProblem +export OptimizationProblem, constraints +export SteadyStateProblem +export JumpProblem +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, @mtkcompile, @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 liouville_transform, change_independent_variable, substitute_component, + add_accumulations, noise_to_brownians, Girsanov_transform, change_of_variables 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 independent_variable, equations, observed, full_equations, jumps, cost, + brownians +export initialization_equations, guesses, defaults, parameter_dependencies, hierarchy +export mtkcompile, expand_connections, linearize, linearization_function, + LinearizationProblem, linearization_ap_transform, structural_simplify +export solve +export Pre + +export calculate_jacobian, generate_jacobian, generate_rhs, generate_custom_function, + generate_W, calculate_hessian +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 generate_cost, calculate_cost_gradient, generate_cost_gradient +export calculate_cost_hessian, generate_cost_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, InitializationProblem + +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, @brownians, @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 + +include("systems/optimal_control_interface.jl") +export AbstractDynamicOptProblem, JuMPDynamicOptProblem, InfiniteOptDynamicOptProblem, + CasADiDynamicOptProblem, PyomoDynamicOptProblem +export AbstractCollocation, JuMPCollocation, InfiniteOptCollocation, + CasADiCollocation, PyomoCollocation +export DynamicOptSolution + +@public apply_to_variables, equations_toplevel, unknowns_toplevel, parameters_toplevel +@public continuous_events_toplevel, discrete_events_toplevel, assertions, is_alg_equation +@public is_diff_equation, Equality, linearize_symbolic, reorder_unknowns +@public similarity_transform, inputs, outputs, bound_inputs, unbound_inputs, bound_outputs +@public unbound_outputs, is_bound + +for prop in [SYS_PROPS; [:continuous_events, :discrete_events]] + getter = Symbol(:get_, prop) + hasfn = Symbol(:has_, prop) + @eval @public $getter, $hasfn +end + +PrecompileTools.@compile_workload begin + using ModelingToolkit + @variables x(ModelingToolkit.t_nounits) + @named sys = System([ModelingToolkit.D_nounits(x) ~ -x], ModelingToolkit.t_nounits) + prob = ODEProblem(mtkcompile(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 end # module diff --git a/src/adjoints.jl b/src/adjoints.jl new file mode 100644 index 0000000000..98266de938 --- /dev/null +++ b/src/adjoints.jl @@ -0,0 +1,106 @@ +function ChainRulesCore.rrule(::Type{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 + 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(remake_buffer), indp, oldbuf::MTKParameters, idxs, vals) + if idxs isa AbstractSet + idxs = collect(idxs) + end + idxs = map(idxs) do i + i isa ParameterIndex ? i : parameter_index(indp, i) + end + newbuf = remake_buffer(indp, oldbuf, idxs, vals) + tunable_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa SciMLStructures.Tunable); + init = Union{Int, AbstractVector{Int}}[]) + initials_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa SciMLStructures.Initials); + init = Union{Int, AbstractVector{Int}}[]) + disc_idxs = subset_idxs(idxs, SciMLStructures.Discrete(), oldbuf.discrete) + const_idxs = subset_idxs(idxs, SciMLStructures.Constants(), oldbuf.constant) + nn_idxs = subset_idxs(idxs, 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 -> _ducktyped_parameter_values(buf′, i), idxs) + return f′, indp′, oldbuf′, idxs′, vals′ + end + end + newbuf, pullback +end + +ChainRulesCore.@non_differentiable Base.getproperty(sys::AbstractSystem, x::Symbol) diff --git a/src/bipartite_graph.jl b/src/bipartite_graph.jl index 8e7a63ba48..6e4f359617 100644 --- a/src/bipartite_graph.jl +++ b/src/bipartite_graph.jl @@ -1,33 +1,139 @@ 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) + +#U=> :Unassigned =# +struct Matching{U, V <: AbstractVector} <: AbstractVector{Union{U, Int}} + 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 +172,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 +334,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 +357,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 +469,124 @@ 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{I}, srcs; rm_verts = false) where {I} + for s in srcs + set_neighbors!(g, s, ()) + end + if rm_verts + old_to_new_idxs = collect(one(I):I(nsrcs(g))) + for s in srcs + old_to_new_idxs[s] = zero(I) + end + offset = zero(I) + for i in eachindex(old_to_new_idxs) + if iszero(old_to_new_idxs[i]) + offset += one(I) + continue + end + old_to_new_idxs[i] -= offset + end + + if g.badjlist isa AbstractVector + for i in 1:ndsts(g) + for j in eachindex(g.badjlist[i]) + g.badjlist[i][j] = old_to_new_idxs[g.badjlist[i][j]] + end + filter!(!iszero, g.badjlist[i]) + end + end + deleteat!(g.fadjlist, srcs) + end + g +end +function delete_dsts!(g::BipartiteGraph, srcs; rm_verts = false) + delete_srcs!(invview(g), srcs; rm_verts) end ### ### 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 +607,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,32 +628,236 @@ 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) + push!(I, i) push!(J, n) end 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..4113287ad4 --- /dev/null +++ b/src/constants.jl @@ -0,0 +1,33 @@ +""" +Test whether `x` is a constant-type Sym. +""" +function isconstant(x) + x = unwrap(x) + x isa Symbolic && !getmetadata(x, VariableTunable, true) +end + +""" + toconstant(s) + +Maps the parameter to a constant. The parameter must have a default. +""" +function toconstant(s) + s = toparam(s) + setmetadata(s, VariableTunable, false) +end + +toconstant(s::Union{Num, Symbolics.Arr}) = 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 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/deprecations.jl b/src/deprecations.jl new file mode 100644 index 0000000000..6e8ea23b1c --- /dev/null +++ b/src/deprecations.jl @@ -0,0 +1,192 @@ +@deprecate structural_simplify(sys; kwargs...) mtkcompile(sys; kwargs...) +@deprecate structural_simplify(sys, io; kwargs...) mtkcompile( + sys; inputs = io[1], outputs = io[2], kwargs...) + +macro mtkbuild(exprs...) + return quote + Base.depwarn("`@mtkbuild` is deprecated. Use `@mtkcompile` instead.", :mtkbuild) + @mtkcompile $(exprs...) + end |> esc +end + +const ODESystem = IntermediateDeprecationSystem + +function IntermediateDeprecationSystem(args...; kwargs...) + Base.depwarn( + "`ODESystem(args...; kwargs...)` is deprecated. Use `System(args...; kwargs...) instead`.", + :ODESystem) + + return System(args...; kwargs...) +end + +for T in [:NonlinearSystem, :DiscreteSystem, :ImplicitDiscreteSystem] + @eval @deprecate $T(args...; kwargs...) System(args...; kwargs...) +end + +for T in [:ODEProblem, :DDEProblem, :SDEProblem, :SDDEProblem, :DAEProblem, + :BVProblem, :DiscreteProblem, :ImplicitDiscreteProblem] + for (pType, pCanonical) in [ + (AbstractDict, :p), + (AbstractArray{<:Pair}, :(Dict(p))), + (AbstractArray, :(isempty(p) ? Dict() : Dict(parameters(sys) .=> p))) + ], + (uType, uCanonical) in [ + (Nothing, :(Dict())), + (AbstractDict, :u0), + (AbstractArray{<:Pair}, :(Dict(u0))), + (AbstractArray, :(isempty(u0) ? Dict() : Dict(unknowns(sys) .=> u0))) + ] + + @eval function SciMLBase.$T(sys::System, u0::$uType, tspan, p::$pType; kw...) + ctor = string($T) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, tspan, p; kw...)` is deprecated. Use + `$ctor(sys, merge($uCan, $pCan), tspan)` instead. + """ + SciMLBase.$T(sys, merge($uCanonical, $pCanonical), tspan; kw...) + end + @eval function SciMLBase.$T{iip}( + sys::System, u0::$uType, tspan, p::$pType; kw...) where {iip} + ctor = string($T{iip}) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, tspan, p; kw...)` is deprecated. Use + `$ctor(sys, merge($uCan, $pCan), tspan)` instead. + """ + return SciMLBase.$T{iip}(sys, merge($uCanonical, $pCanonical), tspan; kw...) + end + @eval function SciMLBase.$T{iip, spec}( + sys::System, u0::$uType, tspan, p::$pType; kw...) where {iip, spec} + ctor = string($T{iip, spec}) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, tspan, p; kw...)` is deprecated. Use + `$ctor(sys, merge($uCan, $pCan), tspan)` instead. + """ + return $T{iip, spec}(sys, merge($uCanonical, $pCanonical), tspan; kw...) + end + end + + for pType in [SciMLBase.NullParameters, Nothing], uType in [Any, Nothing] + + @eval function SciMLBase.$T(sys::System, u0::$uType, tspan, p::$pType; kw...) + ctor = string($T) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, tspan, p::$pT; kw...)` is deprecated. Use + `$ctor(sys, u0, tspan)` instead. + """ + $T(sys, u0, tspan; kw...) + end + @eval function SciMLBase.$T{iip}( + sys::System, u0::$uType, tspan, p::$pType; kw...) where {iip} + ctor = string($T{iip}) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, tspan, p::$pT; kw...)` is deprecated. Use + `$ctor(sys, u0, tspan)` instead. + """ + return $T{iip}(sys, u0, tspan; kw...) + end + @eval function SciMLBase.$T{iip, spec}( + sys::System, u0::$uType, tspan, p::$pType; kw...) where {iip, spec} + ctor = string($T{iip, spec}) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, tspan, p::$pT; kw...)` is deprecated. Use + `$ctor(sys, u0, tspan)` instead. + """ + return $T{iip, spec}(sys, u0, tspan; kw...) + end + end +end + +for T in [:NonlinearProblem, :NonlinearLeastSquaresProblem, + :SCCNonlinearProblem, :OptimizationProblem, :SteadyStateProblem] + for (pType, pCanonical) in [ + (AbstractDict, :p), + (AbstractArray{<:Pair}, :(Dict(p))), + (AbstractArray, :(isempty(p) ? Dict() : Dict(parameters(sys) .=> p))) + ], + (uType, uCanonical) in [ + (Nothing, :(Dict())), + (AbstractDict, :u0), + (AbstractArray{<:Pair}, :(Dict(u0))), + (AbstractArray, :(isempty(u0) ? Dict() : Dict(unknowns(sys) .=> u0))) + ] + + @eval function SciMLBase.$T(sys::System, u0::$uType, p::$pType; kw...) + ctor = string($T) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, p; kw...)` is deprecated. Use `$ctor(sys, merge($uCan, $pCan))` + instead. + """ + $T(sys, merge($uCanonical, $pCanonical); kw...) + end + @eval function SciMLBase.$T{iip}( + sys::System, u0::$uType, p::$pType; kw...) where {iip} + ctor = string($T{iip}) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, p; kw...)` is deprecated. Use `$ctor(sys, merge($uCan, $pCan))` + instead. + """ + return $T{iip}(sys, merge($uCanonical, $pCanonical); kw...) + end + @eval function SciMLBase.$T{iip, spec}( + sys::System, u0::$uType, p::$pType; kw...) where {iip, spec} + ctor = string($T{iip, spec}) + uCan = string($(QuoteNode(uCanonical))) + pCan = string($(QuoteNode(pCanonical))) + @warn """ + `$ctor(sys, u0, p; kw...)` is deprecated. Use `$ctor(sys, merge($uCan, $pCan))` + instead. + """ + return $T{iip, spec}(sys, merge($uCanonical, $pCanonical); kw...) + end + end + for pType in [SciMLBase.NullParameters, Nothing], uType in [Any, Nothing] + + @eval function SciMLBase.$T(sys::System, u0::$uType, p::$pType; kw...) + ctor = string($T) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, p::$pT; kw...)` is deprecated. Use `$ctor(sys, u0)` instead + """ + $T(sys, u0; kw...) + end + @eval function SciMLBase.$T{iip}( + sys::System, u0::$uType, p::$pType; kw...) where {iip} + ctor = string($T{iip}) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, p::$pT; kw...)` is deprecated. Use `$ctor(sys, u0)` instead + """ + return $T{iip}(sys, u0; kw...) + end + @eval function SciMLBase.$T{iip, spec}( + sys::System, u0::$uType, p::$pType; kw...) where {iip, spec} + ctor = string($T{iip, spec}) + pT = string($(QuoteNode(pType))) + @warn """ + `$ctor(sys, u0, p::$pT; kw...)` is deprecated. Use `$ctor(sys, u0)` instead + """ + return $T{iip, spec}(sys, u0; kw...) + end + end +end + +macro brownian(xs...) + return quote + Base.depwarn( + "`@brownian` is deprecated. Use `@brownians` instead", :brownian_macro) + $(@__MODULE__).@brownians $(xs...) + end |> esc +end diff --git a/src/discretedomain.jl b/src/discretedomain.jl new file mode 100644 index 0000000000..da8417de4e --- /dev/null +++ b/src/discretedomain.jl @@ -0,0 +1,367 @@ +using Symbolics: Operator, Num, Term, value, recursive_hasoperator + +""" + $(TYPEDSIGNATURES) + +Trait to be implemented for operators which determines whether application of the operator +generates a semantically different variable or not. For example, `Differential` and `Shift` +are not transparent but `Sample` and `Hold` are. Defaults to `false` if not implemented. +""" +is_transparent_operator(x) = is_transparent_operator(typeof(x)) +is_transparent_operator(::Type) = false + +""" + function SampleTime() + +`SampleTime()` can be used in the equations of a hybrid system to represent time sampled +at the inferred clock for that equation. +""" +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 + +function validate_operator(op::SampleTime, args, iv; context = nothing) end + +# 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 + if Symbolics.isarraysymbolic(x) + Symbolics.array_term(D, x) + else + term(D, x) + end +end +function (D::Shift)(x::Union{Num, Symbolics.Arr}, 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 wrap(newsteps == 0 ? arg : Shift(D.t, newsteps)(arg)) + end + end + end + wrap(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) + +function validate_operator(op::Shift, args, iv; context = nothing) + isequal(op.t, iv) || throw(OperatorIndepvarMismatchError(op, iv, context)) + op.steps <= 0 || error(""" + Only non-positive shifts are allowed. Found shift of $(op.steps) in $context. + """) +end + +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 + +is_transparent_operator(::Type{Sample}) = true + +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)) + +function validate_operator(op::Sample, args, iv; context = nothing) + arg = unwrap(only(args)) + if !is_variable_floatingpoint(arg) + throw(ContinuousOperatorDiscreteArgumentError(op, arg, context)) + end + if isparameter(arg) + throw(ArgumentError(""" + Expected argument of $op to be an unknown, found $arg which is a parameter. + """)) + end +end + +""" + 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 + +is_transparent_operator(::Type{Hold}) = true + +(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) + +function validate_operator(op::Hold, args, iv; context = nothing) + # TODO: maybe validate `VariableTimeDomain`? + return nothing +end + +""" + 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 = ModelingToolkit.vars(x) + if 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.") + end + var = only(vars) + if !iscall(var) + throw(ArgumentError("Cannot shift time-independent variable $var")) + end + if operation(var) == getindex + var = first(arguments(var)) + end + if length(arguments(var)) != 1 + error("Cannot shift an expression with multiple independent variables $x.") + end + + # 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 + +function (xn::Symbolics.Arr)(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 = ModelingToolkit.vars(x) + if 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.") + end + var = only(vars) + if !iscall(var) + throw(ArgumentError("Cannot shift time-independent variable $var")) + end + if length(arguments(var)) != 1 + error("Cannot shift an expression with multiple independent variables $x.") + end + + # 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 = wrap(setmetadata(unwrap(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 deleted file mode 100644 index e2b0317b72..0000000000 --- a/src/domains.jl +++ /dev/null @@ -1,26 +0,0 @@ -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 diff --git a/src/independent_variables.jl b/src/independent_variables.jl new file mode 100644 index 0000000000..d1f2ab4210 --- /dev/null +++ b/src/independent_variables.jl @@ -0,0 +1,18 @@ +""" + @independent_variables t₁ t₂ ... + +Define one or more independent variables. For example: + + @independent_variables t + @variables x(t) +""" +macro independent_variables(ts...) + Symbolics._parse_vars(:independent_variables, + Real, + ts, + toiv) |> esc +end + +toiv(s::Symbolic) = GlobalScope(setmetadata(s, MTKVariableTypeCtx, PARAMETER)) +toiv(s::Symbolics.Arr) = wrap(toiv(value(s))) +toiv(s::Num) = Num(toiv(value(s))) diff --git a/src/inputoutput.jl b/src/inputoutput.jl new file mode 100644 index 0000000000..6563ac678f --- /dev/null +++ b/src/inputoutput.jl @@ -0,0 +1,423 @@ +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::System, + inputs = unbound_inputs(sys), + disturbance_inputs = disturbances(sys); + implicit_dae = false, + simplify = false, + ) + +For a system `sys` with inputs (as determined by [`unbound_inputs`](@ref) or user specified), generate functions with additional input argument `u` + +The returned functions are the out-of-place (`f_oop`) and in-place (`f_ip`) forms: +``` +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`. + +# 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::AbstractSystem, 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 !isscheduled(sys) + sys = mtkcompile(sys; inputs, disturbance_inputs) + end + if disturbance_inputs !== nothing + # add to inputs for the purposes of io processing + inputs = [inputs; disturbance_inputs] + end + + 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(value, inputs) + disturbance_inputs = unwrap.(disturbance_inputs) + + eqs = [eq for eq in full_equations(sys)] + + 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) + + 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, kwargs...) + f = eval_or_rgf.(f; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + 3 + implicit_dae, length(args) - length(p) + 1, is_split(sys))}(f...) + ps = setdiff(parameters(sys), inputs, disturbance_inputs) + (; f = (f, f), dvs, ps, io_sys = sys) +end + +""" +Turn input variables into parameters of the system. +""" +function inputs_to_parameters!(state::TransformationState, inputsyms) + check_bound = inputsyms === 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 + + 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) + + @set! sys.ps = [ps; new_parameters] + @set! state.sys = sys + @set! state.fullvars = Vector{BasicSymbolic}(new_fullvars) + @set! state.structure = structure + return state +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 a `System`, but type that implements [`ModelingToolkit.get_disturbance_system`](@ref)`(dist::DisturbanceModel) -> ::System` 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{System}) + dist.model +end + +""" + (f_oop, f_ip), augmented_sys, dvs, p = add_input_disturbance(sys, dist::DisturbanceModel, inputs = Any[]) + +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 = System(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 = Any[]; 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 isempty(inputs) + 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 = System(eqs, t, systems = [dsys], name = gensym(:outer)) + augmented_sys = extend(augmented_sys, sys) + ssys = mtkcompile(augmented_sys, inputs = all_inputs, disturbance_inputs = [d]) + + f, dvs, p, io_sys = generate_control_function(ssys, all_inputs, + [d]; kwargs...) + f, augmented_sys, dvs, p, io_sys +end diff --git a/src/linearization.jl b/src/linearization.jl new file mode 100644 index 0000000000..dc6eeedaaa --- /dev/null +++ b/src/linearization.jl @@ -0,0 +1,858 @@ +""" + 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 [`mtkcompile`](@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`: A [`System`](@ref) of ODEs. 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, + t = 0.0, + 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 = mtkcompile(sys; inputs, outputs, simplify, kwargs...) + diff_idxs, alge_idxs = eq_idxs(ssys) + 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, merge(op, anydict(p)), (t, t); 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 = [prob.ps[i] for i in inputs] + + hp_fun = let fun = h, setter = setp_oop(sys, inputs) + 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, inputs) + 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, inputs, 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 + +""" +Return the set of indexes of differential equations and algebraic equations in the simplified system. +""" +function eq_idxs(sys::AbstractSystem) + eqs = equations(sys) + alge_idxs = findall(!isdiffeq, eqs) + diff_idxs = setdiff(1:length(eqs), alge_idxs) + + diff_idxs, alge_idxs +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...; strict = Val(false)) + 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...; strict = Val(false)) + 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}, I, 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. + """ + inputs::I + """ + 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 `(unknowns, 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 + input_vals = [linfun.prob.ps[i] for i in linfun.inputs] + 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, fun, 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 `unknowns = $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(input_vals, + 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.inputs)) + end + h_u = linfun.hp_jac(input_vals, + 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, + x = u, + p, + t) +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, F, C, O} <: SciMLBase.DEIntegrator{Nothing, iip, U, T} + """ + The state vector. + """ + u::U + """ + The parameter object. + """ + p::P + """ + The current time. + """ + t::T + """ + The wrapped `SciMLFunction`. + """ + f::F + """ + The integrator cache. + """ + cache::C + """ + Integrator "options" for `CheckInit`. + """ + opts::O +end + +function MockIntegrator{iip}( + u::U, p::P, t::T, f::F, cache::C, opts::O) where {iip, U, P, T, F, C, O} + return MockIntegrator{iip, U, P, T, F, C, O}(u, p, t, f, 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, x, p, t = 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), (; x, p, t) +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 = mtkcompile(sys; inputs, outputs, simplify, kwargs...) + diff_idxs, alge_idxs = eq_idxs(sys) + sts = unknowns(sys) + t = get_iv(sys) + ps = parameters(sys; initial_parameters = true) + p = reorder_parameters(sys, ps) + + fun_expr = generate_rhs(sys; 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 + +struct IONotFoundError <: Exception + variant::String + sysname::Symbol + not_found::Any +end + +function Base.showerror(io::IO, err::IONotFoundError) + println(io, + "The following $(err.variant) provided to `mtkcompile` were not found in the system:") + maybe_namespace_issue = false + for var in err.not_found + println(io, " ", var) + if hasname(var) && startswith(string(getname(var)), string(err.sysname)) + maybe_namespace_issue = true + end + end + if maybe_namespace_issue + println(io, """ + Some of the missing variables are namespaced with the name of the system \ + `$(err.sysname)` passed to `mtkcompile`. This may be indicative of a namespacing \ + issue. `mtkcompile` requires that the $(err.variant) provided are not namespaced \ + with the name of the root system. This issue can occur when using `getproperty` \ + to access the variables passed as $(err.variant). For example: + + ```julia + @named sys = MyModel() + inputs = [sys.input_var] + mtkcompile(sys; inputs) + ``` + + Here, `mtkcompile` expects the input to be named `input_var`, but since `sys` + performs namespacing, it will be named `sys$(NAMESPACE_SEPARATOR)input_var`. To \ + fix this issue, namespacing can be temporarily disabled: + + ```julia + @named sys = MyModel() + sys_nns = toggle_namespacing(sys, false) + inputs = [sys_nns.input_var] + mtkcompile(sys; inputs) + ``` + """) + end +end + +""" +Modify the variable metadata of system variables to indicate which ones are inputs, outputs, and disturbances. Needed for `inputs`, `outputs`, `disturbances`, `unbound_inputs`, `unbound_outputs` to return the proper subsets. +""" +function markio!(state, orig_inputs, inputs, outputs, disturbances; 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) + disturbanceset = Dict{Any, Bool}(d => false for d in disturbances) + 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 + + if v in keys(disturbanceset) + v = setio(v, true, false) + v = setdisturbance(v, true) + disturbanceset[v] = true + fullvars[i] = v + end + end + if check + ikeys = keys(filter(!last, inputset)) + if !isempty(ikeys) + throw(IONotFoundError("inputs", nameof(state.sys), ikeys)) + end + dkeys = keys(filter(!last, disturbanceset)) + if !isempty(dkeys) + throw(IONotFoundError("disturbance inputs", nameof(state.sys), ikeys)) + end + okeys = keys(filter(!last, outputset)) + if !isempty(okeys) + throw(IONotFoundError("outputs", nameof(state.sys), okeys)) + end + end + state, orig_inputs +end + +""" + (; A, B, C, D), simplified_sys, extras = linearize(sys, inputs, outputs; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false, kwargs...) + (; A, B, C, D), extras = 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. + +The return value `extras` is a NamedTuple `(; x, p, t)` containing the result of the initialization problem that was solved to determine the operating point. + +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] + System(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] + System(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), + ] + System(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 = System(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, t, + kwargs...) + mats, extras = linearize(ssys, lin_fun; op, t, allow_input_derivatives) + mats, ssys, extras +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/modelingtoolkitize/common.jl b/src/modelingtoolkitize/common.jl new file mode 100644 index 0000000000..ffddca2f4c --- /dev/null +++ b/src/modelingtoolkitize/common.jl @@ -0,0 +1,397 @@ + +""" + $(TYPEDSIGNATURES) + +Check if the length of variables `vars` matches the number of names for those variables, +given by `names`. `is_unknowns` denotes whether the variable are unknowns or parameters. +""" +function varnames_length_check(vars, names; is_unknowns = false) + length(names) == length(vars) && return + throw(ArgumentError(""" + Number of $(is_unknowns ? "unknowns" : "parameters") ($(length(vars))) \ + does not match number of names ($(length(names))). + """)) +end + +""" + $(TYPEDSIGNATURES) + +Define a subscripted time-dependent variable with name `x` and subscript `i`. Equivalent +to `@variables \$name(..)`. `T` is the desired symtype of the variable when called with +the independent variable. +""" +_defvaridx(x, i; T = Real) = variable(x, i, T = SymbolicUtils.FnType{Tuple, T}) +""" + $(TYPEDSIGNATURES) + +Define a time-dependent variable with name `x`. Equivalent to `@variables \$x(..)`. +`T` is the desired symtype of the variable when called with the independent variable. +""" +_defvar(x; T = Real) = variable(x, T = SymbolicUtils.FnType{Tuple, T}) + +""" + $(TYPEDSIGNATURES) + +Define an array of symbolic unknowns of the appropriate type and size for `u` with +independent variable `t`. +""" +function define_vars(u, t) + [_defvaridx(:x, i)(t) for i in eachindex(u)] +end + +function define_vars(u, ::Nothing) + [variable(:x, i) for i in eachindex(u)] +end + +""" + $(TYPEDSIGNATURES) + +Return a symbolic state for the given problem `prob.`. `t` is the independent variable. +`u_names` optionally contains the names to use for the created symbolic variables. +""" +function construct_vars(prob, t, u_names = nothing) + if prob.u0 === nothing + return [] + end + # construct `_vars`, AbstractSciMLFunction, AbstractSciMLFunction, a list of MTK variables for `prob.u0`. + if u_names !== nothing + # explicitly provided names + varnames_length_check(state_values(prob), u_names; is_unknowns = true) + if t === nothing + _vars = [variable(name) for name in u_names] + else + _vars = [_defvar(name)(t) for name in u_names] + end + elseif SciMLBase.has_sys(prob.f) + # get names from the system + varnames = getname.(variable_symbols(prob.f.sys)) + varidxs = variable_index.((prob.f.sys,), varnames) + invpermute!(varnames, varidxs) + if t === nothing + _vars = [variable(name) for name in varnames] + else + _vars = [_defvar(name)(t) for name in varnames] + end + if prob.f.sys isa System + 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 + # auto-generate names + _vars = define_vars(state_values(prob), t) + end + + # Handle different types of arrays + return prob.u0 isa Number ? _vars : ArrayInterface.restructure(prob.u0, _vars) +end + +""" + $(METHODLIST) + +Define symbolic names for each value in parameter object `p`. `t` is the independent +variable of the system. `names` is a collection mapping indexes of `p` to their +names, or `nothing` to automatically generate names. + +The returned value has the same structure as `p`, but symbolic variables instead of +values. +""" +function define_params(p, t, _ = nothing) + throw(ModelingtoolkitizeParametersNotSupportedError(typeof(p))) +end + +function define_params(p::AbstractArray, t, 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, t, 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, t, 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, t, 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, t, 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, t, names = nothing) + if names === nothing + ps = [] + i = 1 + # tunables are all treated as scalar reals + for x in p.tunable + push!(ps, toparam(variable(:α, i))) + i += 1 + end + # ignore initials + # discretes should be time-dependent + for buf in p.discrete + T = eltype(buf) + for val in buf + # respect array sizes + shape = val isa AbstractArray ? axes(val) : nothing + push!(ps, declare_timevarying_parameter(:α, i, t; T, shape)) + i += 1 + end + end + # handle constants + for buf in p.constant + T = eltype(buf) + for val in buf + # respect array sizes + shape = val isa AbstractArray ? axes(val) : nothing + push!(ps, declare_parameter(:α, i; T, shape)) + i += 1 + end + end + # handle nonnumerics + for buf in p.nonnumeric + T = eltype(buf) + for val in buf + # respect array sizes + shape = val isa AbstractArray ? axes(val) : nothing + push!(ps, declare_parameter(:α, i; T, shape)) + i += 1 + end + end + return identity.(ps) + else + new_p = as_any_buffer(p) + @set! new_p.initials = [] + for (k, v) in names + val = p[k] + shape = val isa AbstractArray ? axes(val) : nothing + T = typeof(val) + if k.portion == SciMLStructures.Initials() + continue + end + if k.portion == SciMLStructures.Tunable() + T = Real + end + if k.portion == SciMLStructures.Discrete() + var = declare_timevarying_parameter(getname(v), nothing, t; T, shape) + else + var = declare_parameter(getname(v), nothing; T, shape) + end + new_p[k] = var + end + return new_p + end +end + +""" + $(TYPEDSIGNATURES) + +Given a parameter object `p` containing symbolic variables instead of values, return +a vector of the symbolic variables. +""" +function to_paramvec(p) + vec(collect(values(p))) +end + +function to_paramvec(p::MTKParameters) + reduce(vcat, collect(p); init = []) +end + +""" + $(TYPEDSIGNATURES) + +Create a time-varying parameter with name `x`, subscript `i`, independent variable `t` +which stores values of type `T`. `shape` denotes the shape of array values, or `nothing` +for scalars. + +To ignore the subscript, pass `nothing` for `i`. +""" +function declare_timevarying_parameter(x::Symbol, i, t; T, shape = nothing) + # turn specific floating point numbers to `Real` + if T <: Union{AbstractFloat, ForwardDiff.Dual} + T = Real + end + if T <: Array{<:Union{AbstractFloat, ForwardDiff.Dual}, N} where {N} + T = Array{Real, ndims(T)} + end + + if i === nothing + var = _defvar(x; T) + else + var = _defvaridx(x, i; T) + end + var = toparam(unwrap(var(t))) + if shape !== nothing + var = setmetadata(var, Symbolics.ArrayShapeCtx, shape) + end + return var +end + +""" + $(TYPEDSIGNATURES) + +Create a time-varying parameter with name `x` and subscript `i`, which stores values of +type `T`. `shape` denotes the shape of array values, or `nothing` for scalars. + +To ignore the subscript, pass `nothing` for `i`. +""" +function declare_parameter(x::Symbol, i; T, shape = nothing) + # turn specific floating point numbers to `Real` + if T <: Union{AbstractFloat, ForwardDiff.Dual} + T = Real + end + if T <: Array{<:Union{AbstractFloat, ForwardDiff.Dual}, N} where {N} + T = Array{Real, ndims(T)} + end + + i = i === nothing ? () : (i,) + var = toparam(unwrap(variable(x, i...; T))) + if shape !== nothing + var = setmetadata(var, Symbolics.ArrayShapeCtx, shape) + end + return var +end + +""" + $(TYPEDSIGNATURES) + +Return a symbolic parameter object for the given problem `prob.`. `t` is the independent +variable. `p_names` optionally contains the names to use for the created symbolic +variables. +""" +function construct_params(prob, t, p_names = nothing) + p = parameter_values(prob) + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + # Get names of parameters + if has_p + if p_names === nothing && SciMLBase.has_sys(prob.f) + # get names from the system + p_names = Dict(parameter_index(prob.f.sys, sym) => sym + for sym in parameter_symbols(prob.f.sys)) + end + params = define_params(p, t, p_names) + if p isa Number + params = params[1] + elseif p isa AbstractArray + params = ArrayInterface.restructure(p, params) + end + else + params = [] + end + + return params +end + +""" + $(TYPEDSIGNATURES) + +Given the differential operator `D`, mass matrix `mm` and ordered list of unknowns `vars`, +return the list of +""" +function lhs_from_mass_matrix(D, mm, vars) + var_set = Set(vars) + # calculate equation LHS from 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 + return lhs +end + +""" + $(TYPEDSIGNATURES) + +Given a problem `prob`, the symbolic unknowns and params and the independent variable, +trace through `prob.f` and return the resultant expression. +""" +function trace_rhs(prob, vars, params, t; prototype = nothing) + args = (vars, params) + if t !== nothing + args = (args..., t) + end + # trace prob.f to get equation RHS + if SciMLBase.isinplace(prob.f) + if prototype === nothing + rhs = ArrayInterface.restructure(prob.u0, similar(vars, Num)) + else + rhs = similar(prototype, Num) + end + fill!(rhs, 0) + if prob.f isa SciMLBase.AbstractSciMLFunction && + prob.f.f isa FunctionWrappersWrappers.FunctionWrappersWrapper + prob.f.f.fw[1].obj[](rhs, args...) + else + prob.f(rhs, args...) + end + else + rhs = prob.f(args...) + end + return rhs +end + +""" + $(TYPEDSIGNATURES) + +Obtain default values for unknowns `vars` and parameters `paramvec` +given the problem `prob` and symbolic parameter object `paramobj`. +""" +function defaults_from_u0_p(prob, vars, paramobj, paramvec) + u0 = state_values(prob) + p = parameter_values(prob) + defaults = Dict{Any, Any}(vec(vars) .=> vec(collect(u0))) + if !(p isa Union{SciMLBase.NullParameters, Nothing}) + if p isa Union{NamedTuple, AbstractDict} + merge!(defaults, Dict(v => p[k] for (k, v) in pairs(paramobj))) + elseif p isa MTKParameters + pvals = [p.tunable; reduce(vcat, p.discrete; init = []); + reduce(vcat, p.constant; init = []); + reduce(vcat, p.nonnumeric; init = [])] + merge!(defaults, Dict(paramvec .=> pvals)) + else + merge!(defaults, Dict(paramvec .=> vec(collect(p)))) + end + end + return defaults +end diff --git a/src/modelingtoolkitize/nonlinearproblem.jl b/src/modelingtoolkitize/nonlinearproblem.jl new file mode 100644 index 0000000000..92425be373 --- /dev/null +++ b/src/modelingtoolkitize/nonlinearproblem.jl @@ -0,0 +1,48 @@ +""" + $(TYPEDSIGNATURES) + +Convert a `NonlinearProblem` or `NonlinearLeastSquaresProblem` to a +`ModelingToolkit.System`. + +# Keyword arguments + +- `u_names`: An array of names of the same size as `prob.u0` to use as the names of the + unknowns of the system. The names should be given as `Symbol`s. +- `p_names`: A collection of names to use for parameters of the system. The collection + should have keys corresponding to indexes of `prob.p`. For example, if `prob.p` is an + associative container like `NamedTuple`, then `p_names` should map keys of `prob.p` to + the name that the corresponding parameter should have in the returned system. The names + should be given as `Symbol`s. + +All other keyword arguments are forwarded to the created `System`. +""" +function modelingtoolkitize( + prob::Union{NonlinearProblem, NonlinearLeastSquaresProblem}; + u_names = nothing, p_names = nothing, kwargs...) + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + vars = construct_vars(prob, nothing, u_names) + params = construct_params(prob, nothing, p_names) + + rhs = trace_rhs(prob, vars, params, nothing; prototype = prob.f.resid_prototype) + eqs = vcat([0 ~ rhs[i] for i in eachindex(rhs)]...) + + sts = vec(collect(vars)) + + # turn `params` into a list of symbolic variables as opposed to + # a parameter object containing symbolic variables. + _params = params + params = to_paramvec(params) + + defaults = defaults_from_u0_p(prob, vars, _params, params) + # In case initials crept in, specifically from when we constructed parameters + # using prob.f.sys + filter!(x -> !iscall(x) || !(operation(x) isa Initial), params) + filter!(x -> !iscall(x[1]) || !(operation(x[1]) isa Initial), defaults) + + return System(eqs, sts, params; + defaults, + name = gensym(:MTKizedNonlin), + kwargs...) +end diff --git a/src/modelingtoolkitize/odeproblem.jl b/src/modelingtoolkitize/odeproblem.jl new file mode 100644 index 0000000000..3bc74d8887 --- /dev/null +++ b/src/modelingtoolkitize/odeproblem.jl @@ -0,0 +1,59 @@ +""" + $(TYPEDSIGNATURES) + +Convert an `ODEProblem` to a `ModelingToolkit.System`. + +# Keyword arguments + +- `u_names`: An array of names of the same size as `prob.u0` to use as the names of the + unknowns of the system. The names should be given as `Symbol`s. +- `p_names`: A collection of names to use for parameters of the system. The collection + should have keys corresponding to indexes of `prob.p`. For example, if `prob.p` is an + associative container like `NamedTuple`, then `p_names` should map keys of `prob.p` to + the name that the corresponding parameter should have in the returned system. The names + should be given as `Symbol`s. +- INTERNAL `return_symbolic_u0_p`: Also return the symbolic state and parameter objects. + +All other keyword arguments are forwarded to the created `System`. +""" +function modelingtoolkitize(prob::ODEProblem; u_names = nothing, p_names = nothing, + return_symbolic_u0_p = false, kwargs...) + if prob.f isa DiffEqBase.AbstractParameterizedFunction + return prob.f.sys + end + + t = t_nounits + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + vars = construct_vars(prob, t, u_names) + params = construct_params(prob, t, p_names) + + lhs = lhs_from_mass_matrix(D_nounits, prob.f.mass_matrix, vars) + rhs = trace_rhs(prob, vars, params, t) + eqs = vcat([lhs[i] ~ rhs[i] for i in eachindex(prob.u0)]...) + + sts = vec(collect(vars)) + + # turn `params` into a list of symbolic variables as opposed to + # a parameter object containing symbolic variables. + _params = params + params = to_paramvec(params) + + defaults = defaults_from_u0_p(prob, vars, _params, params) + # In case initials crept in, specifically from when we constructed parameters + # using prob.f.sys + filter!(x -> !iscall(x) || !(operation(x) isa Initial), params) + filter!(x -> !iscall(x[1]) || !(operation(x[1]) isa Initial), defaults) + + sys = System(eqs, t, sts, params; + defaults, + name = gensym(:MTKizedODE), + kwargs...) + + if return_symbolic_u0_p + return sys, vars, _params + else + return sys + end +end diff --git a/src/modelingtoolkitize/optimizationproblem.jl b/src/modelingtoolkitize/optimizationproblem.jl new file mode 100644 index 0000000000..26d557152a --- /dev/null +++ b/src/modelingtoolkitize/optimizationproblem.jl @@ -0,0 +1,95 @@ +""" + $(TYPEDSIGNATURES) + +Convert an `OptimizationProblem` to a `ModelingToolkit.System`. + +# Keyword arguments + +- `u_names`: An array of names of the same size as `prob.u0` to use as the names of the + unknowns of the system. The names should be given as `Symbol`s. +- `p_names`: A collection of names to use for parameters of the system. The collection + should have keys corresponding to indexes of `prob.p`. For example, if `prob.p` is an + associative container like `NamedTuple`, then `p_names` should map keys of `prob.p` to + the name that the corresponding parameter should have in the returned system. The names + should be given as `Symbol`s. + +All other keyword arguments are forwarded to the created `System`. +""" +function modelingtoolkitize( + prob::OptimizationProblem; u_names = nothing, p_names = nothing, + kwargs...) + num_cons = isnothing(prob.lcons) ? 0 : length(prob.lcons) + p = prob.p + has_p = !(p isa Union{DiffEqBase.NullParameters, Nothing}) + + vars = construct_vars(prob, nothing, u_names) + 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 = construct_params(prob, nothing, p_names) + + objective = prob.f(vars, params) + + if prob.f.cons === nothing + cons = [] + else + if DiffEqBase.isinplace(prob.f) + lhs = Array{Num}(undef, num_cons) + prob.f.cons(lhs, vars, params) + else + lhs = prob.f.cons(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 + end + + # turn `params` into a list of symbolic variables as opposed to + # a parameter object containing symbolic variables. + _params = params + params = to_paramvec(params) + + defaults = defaults_from_u0_p(prob, vars, _params, params) + # In case initials crept in, specifically from when we constructed parameters + # using prob.f.sys + filter!(x -> !iscall(x) || !(operation(x) isa Initial), params) + filter!(x -> !iscall(x[1]) || !(operation(x[1]) isa Initial), defaults) + + sts = vec(collect(vars)) + sys = OptimizationSystem(objective, sts, params; + defaults, + constraints = cons, + name = gensym(:MTKizedOpt), + kwargs...) +end diff --git a/src/modelingtoolkitize/sdeproblem.jl b/src/modelingtoolkitize/sdeproblem.jl new file mode 100644 index 0000000000..0f63a35fc1 --- /dev/null +++ b/src/modelingtoolkitize/sdeproblem.jl @@ -0,0 +1,54 @@ +""" + $(TYPEDSIGNATURES) + +Convert an `SDEProblem` to a `ModelingToolkit.System`. + +# Keyword arguments + +- `u_names`: an array of names of the same size as `prob.u0` to use as the names of the + unknowns of the system. The names should be given as `Symbol`s. +- `p_names`: a collection of names to use for parameters of the system. The collection + should have keys corresponding to indexes of `prob.p`. For example, if `prob.p` is an + associative container like `NamedTuple`, then `p_names` should map keys of `prob.p` to + the name that the corresponding parameter should have in the returned system. The names + should be given as `Symbol`s. + +All other keyword arguments are forwarded to the created `System`. +""" +function modelingtoolkitize( + prob::SDEProblem; u_names = nothing, p_names = nothing, kwargs...) + if prob.f isa DiffEqBase.AbstractParameterizedFunction + return prob.f.sys + end + + # just create the equivalent ODEProblem, `modelingtoolkitize` that + # and add on the noise + odefn = ODEFunction{SciMLBase.isinplace(prob)}( + prob.f.f; mass_matrix = prob.f.mass_matrix, sys = prob.f.sys) + odeprob = ODEProblem(odefn, prob.u0, prob.tspan, prob.p) + sys, vars, + params = modelingtoolkitize( + odeprob; u_names, p_names, return_symbolic_u0_p = true, + name = gensym(:MTKizedSDE), kwargs...) + t = get_iv(sys) + + if SciMLBase.isinplace(prob) + if SciMLBase.is_diagonal_noise(prob) + neqs = similar(vars, Any) + prob.g(neqs, vars, params, t) + else + neqs = similar(prob.noise_rate_prototype, Any) + prob.g(neqs, vars, params, t) + end + else + if SciMLBase.is_diagonal_noise(prob) + neqs = prob.g(vars, params, t) + else + neqs = prob.g(vars, params, t) + end + end + + @set! sys.noise_eqs = neqs + + return sys +end diff --git a/src/parameters.jl b/src/parameters.jl index f8ebfe4dd3..de7a722af3 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::Union{Num, Symbolics.Arr}) = wrap(tovar(unwrap(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/problems/bvproblem.jl b/src/problems/bvproblem.jl new file mode 100644 index 0000000000..1c193bca51 --- /dev/null +++ b/src/problems/bvproblem.jl @@ -0,0 +1,51 @@ +@fallback_iip_specialize function SciMLBase.BVProblem{iip, spec}( + sys::System, op, tspan; + check_compatibility = true, cse = true, + checkbounds = false, eval_expression = false, eval_module = @__MODULE__, + expression = Val{false}, guesses = Dict(), callback = nothing, + kwargs...) where {iip, spec} + check_complete(sys, BVProblem) + check_compatibility && check_compatible_system(BVProblem, sys) + isnothing(callback) || error("BVP solvers do not support callbacks.") + + dvs = unknowns(sys) + op = to_varmap(op, dvs) + # Systems without algebraic equations should use both fixed values + guesses + # for initialization. + _op = has_alg_eqs(sys) ? op : merge(Dict(op), Dict(guesses)) + + fode, u0, + p = process_SciMLProblem( + ODEFunction{iip, spec}, sys, _op; guesses, + t = tspan !== nothing ? tspan[1] : tspan, check_compatibility = false, cse, + checkbounds, time_dependent_init = false, expression, kwargs...) + + stidxmap = Dict([v => i for (i, v) in enumerate(dvs)]) + u0_idxs = has_alg_eqs(sys) ? collect(1:length(dvs)) : + [stidxmap[k] for (k, v) in op if haskey(stidxmap, k)] + fbc = generate_boundary_conditions( + sys, u0, u0_idxs, tspan[1]; expression = Val{false}, + wrap_gfw = Val{true}, cse, checkbounds) + + if (length(constraints(sys)) + length(op) > length(dvs)) + @warn "The BVProblem is overdetermined. The total number of conditions (# constraints + # fixed initial values given by op) exceeds the total number of states. The BVP solvers will default to doing a nonlinear least-squares optimization." + end + + kwargs = process_kwargs(sys; expression, kwargs...) + args = (; fode, fbc, u0, tspan, p) + + return maybe_codegen_scimlproblem(expression, BVProblem{iip}, args; kwargs...) +end + +function check_compatible_system(T::Type{BVProblem}, sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_is_continuous(sys, T) + + if !isempty(discrete_events(sys)) || !isempty(continuous_events(sys)) + throw(SystemCompatibilityError("BVP solvers do not support events.")) + end +end diff --git a/src/problems/compatibility.jl b/src/problems/compatibility.jl new file mode 100644 index 0000000000..9d5abf926e --- /dev/null +++ b/src/problems/compatibility.jl @@ -0,0 +1,180 @@ +""" + function check_compatible_system(T::Type, sys::System) + +Check if `sys` can be used to construct a problem/function of type `T`. +""" +function check_compatible_system end + +struct SystemCompatibilityError <: Exception + msg::String +end + +function Base.showerror(io::IO, err::SystemCompatibilityError) + println(io, err.msg) + println(io) + print(io, "To disable this check, pass `check_compatibility = false`.") +end + +function check_time_dependent(sys::System, T) + if !is_time_dependent(sys) + throw(SystemCompatibilityError(""" + `$T` requires a time-dependent system. + """)) + end +end + +function check_time_independent(sys::System, T) + if is_time_dependent(sys) + throw(SystemCompatibilityError(""" + `$T` requires a time-independent system. + """)) + end +end + +function check_is_dde(sys::System) + altT = get_noise_eqs(sys) === nothing ? ODEProblem : SDEProblem + if !is_dde(sys) + throw(SystemCompatibilityError(""" + The system does not have delays. Consider an `$altT` instead. + """)) + end +end + +function check_not_dde(sys::System) + altT = get_noise_eqs(sys) === nothing ? DDEProblem : SDDEProblem + if is_dde(sys) + throw(SystemCompatibilityError(""" + The system has delays. Consider a `$altT` instead. + """)) + end +end + +function check_no_cost(sys::System, T) + cost = ModelingToolkit.cost(sys) + if !_iszero(cost) + throw(SystemCompatibilityError(""" + `$T` will not optimize solutions of systems that have associated cost \ + functions. Solvers for optimal control problems are forthcoming. + """)) + end +end + +function check_has_cost(sys::System, T) + cost = ModelingToolkit.cost(sys) + if _iszero(cost) + throw(SystemCompatibilityError(""" + A system without cost cannot be used to construct a `$T`. + """)) + end +end + +function check_no_constraints(sys::System, T) + if !isempty(constraints(sys)) + throw(SystemCompatibilityError(""" + A system with constraints cannot be used to construct a `$T`. + """)) + end +end + +function check_has_constraints(sys::System, T) + if isempty(constraints(sys)) + throw(SystemCompatibilityError(""" + A system without constraints cannot be used to construct a `$T`. Consider an \ + `ODEProblem` instead. + """)) + end +end + +function check_no_jumps(sys::System, T) + if !isempty(jumps(sys)) + throw(SystemCompatibilityError(""" + A system with jumps cannot be used to construct a `$T`. Consider a \ + `JumpProblem` instead. + """)) + end +end + +function check_has_jumps(sys::System, T) + if isempty(jumps(sys)) + throw(SystemCompatibilityError("`$T` requires a system with jumps.")) + end +end + +function check_no_noise(sys::System, T) + altT = is_dde(sys) ? SDDEProblem : SDEProblem + if get_noise_eqs(sys) !== nothing + throw(SystemCompatibilityError(""" + A system with noise cannot be used to construct a `$T`. Consider an \ + `$altT` instead. + """)) + end +end + +function check_has_noise(sys::System, T) + altT = is_dde(sys) ? DDEProblem : ODEProblem + if get_noise_eqs(sys) === nothing + msg = """ + A system without noise cannot be used to construct a `$T`. Consider an \ + `$altT` instead. + """ + if !isempty(brownians(sys)) + msg = """ + Systems constructed by defining Brownian variables with `@brownians` must be \ + simplified by calling `mtkcompile` before a `$T` can be constructed. + """ + end + throw(SystemCompatibilityError(msg)) + end +end + +function check_is_discrete(sys::System, T) + if !is_discrete_system(sys) + throw(SystemCompatibilityError(""" + `$T` expects a discrete system. Consider an `ODEProblem` instead. If your system \ + is discrete, ensure `mtkcompile` has been run on it. + """)) + end +end + +function check_is_continuous(sys::System, T) + altT = has_alg_equations(sys) ? ImplicitDiscreteProblem : DiscreteProblem + if is_discrete_system(sys) + throw(SystemCompatibilityError(""" + A discrete system cannot be used to construct a `$T`. Consider a `$altT` instead. + """)) + end +end + +function check_is_explicit(sys::System, T, altT) + if has_alg_equations(sys) + throw(SystemCompatibilityError(""" + `$T` expects an explicit system. Consider a `$altT` instead. + """)) + end +end + +function check_is_implicit(sys::System, T, altT) + if !has_alg_equations(sys) + throw(SystemCompatibilityError(""" + `$T` expects an implicit system. Consider a `$altT` instead. + """)) + end +end + +function check_no_equations(sys::System, T) + if !isempty(equations(sys)) + throw(SystemCompatibilityError(""" + A system with equations cannot be used to construct a `$T`. Consider turning the + equations into constraints instead. + """)) + end +end + +function check_affine(sys::System, T) + if !isaffine(sys) + throw(SystemCompatibilityError(""" + A non-affine system cannot be used to construct a `$T`. Consider a + `NonlinearProblem` instead. + """)) + end +end diff --git a/src/problems/daeproblem.jl b/src/problems/daeproblem.jl new file mode 100644 index 0000000000..e2de96b7f5 --- /dev/null +++ b/src/problems/daeproblem.jl @@ -0,0 +1,87 @@ +@fallback_iip_specialize function SciMLBase.DAEFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, tgrad = false, jac = false, + t = nothing, eval_expression = false, eval_module = @__MODULE__, sparse = false, + steady_state = false, checkbounds = false, sparsity = false, analytic = nothing, + simplify = false, cse = true, initialization_data = nothing, + expression = Val{false}, check_compatibility = true, kwargs...) where {iip, spec} + check_complete(sys, DAEFunction) + check_compatibility && check_compatible_system(DAEFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + implicit_dae = true, eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on ODEFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, u0, p, t)) + end + end + + if jac + _jac = generate_dae_jacobian(sys; expression, + wrap_gfw = Val{true}, simplify, sparse, cse, eval_expression, eval_module, + checkbounds, kwargs...) + else + _jac = nothing + end + + observedfun = ObservedFunctionCache( + sys; expression, steady_state, eval_expression, eval_module, checkbounds, 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 + + kwargs = (; + sys = sys, + jac = _jac, + jac_prototype = jac_prototype, + observed = observedfun, + analytic = analytic, + initialization_data) + args = (; f) + + return maybe_codegen_scimlfn(expression, DAEFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.DAEProblem{iip, spec}( + sys::System, op, tspan; + callback = nothing, check_length = true, eval_expression = false, + eval_module = @__MODULE__, check_compatibility = true, + expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, DAEProblem) + check_compatibility && check_compatible_system(DAEProblem, sys) + + f, du0, + u0, + p = process_SciMLProblem(DAEFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_length, eval_expression, + eval_module, check_compatibility, implicit_dae = true, expression, kwargs...) + + kwargs = process_kwargs(sys; expression, callback, eval_expression, eval_module, + op, kwargs...) + + diffvars = collect_differential_variables(sys) + sts = unknowns(sys) + differential_vars = map(Base.Fix2(in, diffvars), sts) + + args = (; f, du0, u0, tspan, p) + kwargs = (; differential_vars, kwargs...) + + return maybe_codegen_scimlproblem(expression, DAEProblem{iip}, args; kwargs...) +end diff --git a/src/problems/ddeproblem.jl b/src/problems/ddeproblem.jl new file mode 100644 index 0000000000..fb4634f418 --- /dev/null +++ b/src/problems/ddeproblem.jl @@ -0,0 +1,84 @@ +@fallback_iip_specialize function SciMLBase.DDEFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, eval_expression = false, + eval_module = @__MODULE__, expression = Val{false}, checkbounds = false, + initialization_data = nothing, cse = true, check_compatibility = true, + sparse = false, simplify = false, analytic = nothing, kwargs...) where {iip, spec} + check_complete(sys, DDEFunction) + check_compatibility && check_compatible_system(DDEFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on DDEFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + end + + M = calculate_massmatrix(sys) + _M = concrete_massmatrix(M; sparse, u0) + + observedfun = ObservedFunctionCache( + sys; expression, eval_expression, eval_module, checkbounds, cse) + + kwargs = (; + sys = sys, + mass_matrix = _M, + observed = observedfun, + analytic = analytic, + initialization_data) + args = (; f) + + return maybe_codegen_scimlfn(expression, DDEFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.DDEProblem{iip, spec}( + sys::System, op, tspan; + callback = nothing, check_length = true, cse = true, checkbounds = false, + eval_expression = false, eval_module = @__MODULE__, check_compatibility = true, + u0_constructor = identity, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, DDEProblem) + check_compatibility && check_compatible_system(DDEProblem, sys) + + f, u0, + p = process_SciMLProblem(DDEFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_length, cse, checkbounds, + eval_expression, eval_module, check_compatibility, symbolic_u0 = true, + expression, u0_constructor, kwargs...) + + h = generate_history( + sys, u0; expression, wrap_gfw = Val{true}, cse, eval_expression, eval_module, + checkbounds) + + if expression == Val{true} + if u0 !== nothing + u0 = :($u0_constructor($map($float, h(p, tspan[1])))) + end + else + if u0 !== nothing + u0 = u0_constructor(float.(h(p, tspan[1]))) + end + end + + kwargs = process_kwargs( + sys; expression, callback, eval_expression, eval_module, op, kwargs...) + args = (; f, u0, h, tspan, p) + + return maybe_codegen_scimlproblem(expression, DDEProblem{iip}, args; kwargs...) +end + +function check_compatible_system(T::Union{Type{DDEFunction}, Type{DDEProblem}}, sys::System) + check_time_dependent(sys, T) + check_is_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_is_continuous(sys, T) +end diff --git a/src/problems/discreteproblem.jl b/src/problems/discreteproblem.jl new file mode 100644 index 0000000000..820819d282 --- /dev/null +++ b/src/problems/discreteproblem.jl @@ -0,0 +1,103 @@ +@fallback_iip_specialize function SciMLBase.DiscreteFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, t = nothing, + eval_expression = false, eval_module = @__MODULE__, expression = Val{false}, + checkbounds = false, analytic = nothing, simplify = false, cse = true, + initialization_data = nothing, check_compatibility = true, + kwargs...) where {iip, spec} + check_complete(sys, DiscreteFunction) + check_compatibility && check_compatible_system(DiscreteFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on DiscreteFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + end + + observedfun = ObservedFunctionCache( + sys; steady_state = false, expression, eval_expression, eval_module, checkbounds, + cse) + + kwargs = (; + sys = sys, + observed = observedfun, + analytic = analytic, + initialization_data) + args = (; f) + + return maybe_codegen_scimlfn(expression, DiscreteFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.DiscreteProblem{iip, spec}( + sys::System, op, tspan; + check_compatibility = true, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, DiscreteProblem) + check_compatibility && check_compatible_system(DiscreteProblem, sys) + + dvs = unknowns(sys) + op = to_varmap(op, dvs) + add_toterms!(op; replace = true) + f, u0, + p = process_SciMLProblem(DiscreteFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_compatibility, expression, + kwargs...) + + if expression == Val{true} + u0 = :(f($u0, p, tspan[1])) + else + u0 = f(u0, p, tspan[1]) + end + + kwargs = process_kwargs(sys; kwargs...) + args = (; f, u0, tspan, p) + + return maybe_codegen_scimlproblem(expression, DiscreteProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{DiscreteFunction}, Type{DiscreteProblem}}, sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_is_discrete(sys, T) + check_is_explicit(sys, T, ImplicitDiscreteProblem) +end + +function shift_u0map_forward(sys::System, 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 diff --git a/src/problems/docs.jl b/src/problems/docs.jl new file mode 100644 index 0000000000..17bc2c83c6 --- /dev/null +++ b/src/problems/docs.jl @@ -0,0 +1,422 @@ +const U0_P_DOCS = """ +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. +The type of `op` will be used to determine the type of the containers. For example, if +given as an `SArray` of key-value pairs, `u0` will be an appropriately sized `SVector` +and the parameter object will be an `MTKParameters` object with `SArray`s inside. +""" + +const EVAL_EXPR_MOD_KWARGS = """ +- `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`. +""" + +const INITIALIZEPROB_KWARGS = """ +- `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. +- `fully_determined`: Override whether the initialization system is fully determined. +- `use_scc`: Whether to use `SCCNonlinearProblem` for initialization if the system is fully + determined. +""" + +const PROBLEM_KWARGS = """ +$EVAL_EXPR_MOD_KWARGS +$INITIALIZEPROB_KWARGS +- `check_initialization_units`: Enable or disable unit checks when constructing the + initialization problem. +- `tofloat`: Passed to [`varmap_to_vars`](@ref) when building the parameter vector of + a non-split system. +- `u0_eltype`: The `eltype` of the `u0` vector. If `nothing`, finds the promoted floating point + type from `op`. +- `u0_constructor`: A function to apply to the `u0` value returned from + [`varmap_to_vars`](@ref). + to construct the final `u0` value. +- `p_constructor`: A function to apply to each array buffer created when constructing the + parameter object. +- `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. +""" + +const TIME_DEPENDENT_PROBLEM_KWARGS = """ +- `callback`: An extra callback or `CallbackSet` to add to the problem, in addition to the + ones defined symbolically in the system. +""" + +const PROBLEM_INTERNALS_HEADER = """ +# Extended docs + +The following API is internal and may change or be removed without notice. Its usage is +highly discouraged. +""" + +const INTERNAL_INITIALIZEPROB_KWARGS = """ +- `time_dependent_init`: Whether to build a time-dependent initialization for the problem. A + time-dependent initialization solves for a consistent `u0`, whereas a time-independent one + only runs parameter initialization. +- `algebraic_only`: Whether to build the initialization problem using only algebraic equations. +- `allow_incomplete`: Whether to allow incomplete initialization problems. +""" + +const PROBLEM_INTERNAL_KWARGS = """ +- `build_initializeprob`: If `false`, avoids building the initialization problem. +- `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`. +$INTERNAL_INITIALIZEPROB_KWARGS +""" + +function problem_ctors(prob, istd) + if istd + """ + SciMLBase.$prob(sys::System, op, tspan::NTuple{2}; kwargs...) + SciMLBase.$prob{iip}(sys::System, op, tspan::NTuple{2}; kwargs...) + SciMLBase.$prob{iip, specialize}(sys::System, op, tspan::NTuple{2}; kwargs...) + """ + else + """ + SciMLBase.$prob(sys::System, op; kwargs...) + SciMLBase.$prob{iip}(sys::System, op; kwargs...) + SciMLBase.$prob{iip, specialize}(sys::System, op; kwargs...) + """ + end +end + +function prob_fun_common_kwargs(T, istd) + return """ + - `check_compatibility`: Whether to check if the given system `sys` contains all the + information necessary to create a `$T` and no more. If disabled, assumes that `sys` + at least contains the necessary information. + - `expression`: `Val{true}` to return an `Expr` that constructs the corresponding + problem instead of the problem itself. `Val{false}` otherwise. + $(istd ? " Constructing the expression does not support callbacks" : "") + """ +end + +function problem_docstring(prob, func, istd; init = true, extra_body = "") + if func isa DataType + func = "`$func`" + end + return """ + $(problem_ctors(prob, istd)) + + Build a `$prob` given a system `sys` and operating point `op` + $(istd ? " and timespan `tspan`" : ""). `iip` is a boolean indicating whether the + problem should be in-place. `specialization` is a `SciMLBase.AbstractSpecalize` subtype + indicating the level of specialization of the $func. The operating point should be an + iterable collection of key-value pairs mapping variables/parameters in the system to the + (initial) values they should take in `$prob`. Any values not provided will fallback to + the corresponding default (if present). + + $(init ? istd ? TIME_DEPENDENT_INIT : TIME_INDEPENDENT_INIT : "") + + $extra_body + + # Keyword arguments + + $PROBLEM_KWARGS + $(istd ? TIME_DEPENDENT_PROBLEM_KWARGS : "") + $(prob_fun_common_kwargs(prob, istd)) + + All other keyword arguments are forwarded to the $func constructor. + + $PROBLEM_INTERNALS_HEADER + + $PROBLEM_INTERNAL_KWARGS + """ +end + +const TIME_DEPENDENT_INIT = """ +ModelingToolkit will build an initialization problem where all initial values for +unknowns or observables of `sys` (either explicitly provided or in defaults) will +be constraints. To remove an initial condition in the defaults (without providing +a replacement) give the corresponding variable a value of `nothing` in the operating +point. The initialization problem will also run parameter initialization. See the +[Initialization](@ref initialization) documentation for more information. +""" + +const TIME_INDEPENDENT_INIT = """ +ModelingToolkit will build an initialization problem that will run parameter +initialization. Since it does not solve for initial values of unknowns, observed +equations will not be initialization constraints. If an initialization equation +of the system must involve the initial value of an unknown `x`, it must be used as +`Initial(x)` in the equation. For example, an equation to be used to solve for parameter +`p` in terms of unknowns `x` and `y` must be provided as `Initial(x) + Initial(y) ~ p` +instead of `x + y ~ p`. See the [Initialization](@ref initialization) documentation +for more information. +""" + +const BV_EXTRA_BODY = """ +Boundary value conditions are supplied to Systems in the form of a list of constraints. +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 +`System`. + +If a `System` 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] + @mtkcompile pend = System(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 `System` has algebraic equations, like `x(t)^2 + y(t)^2`, the resulting +`BVProblem` must be solved using BVDAE solvers, such as Ascher. +""" + +for (mod, prob, func, istd, kws) in [ + (SciMLBase, :ODEProblem, ODEFunction, true, (;)), + (SciMLBase, :SteadyStateProblem, ODEFunction, false, (;)), + (SciMLBase, :BVProblem, ODEFunction, true, + (; init = false, extra_body = BV_EXTRA_BODY)), + (SciMLBase, :DAEProblem, DAEFunction, true, (;)), + (SciMLBase, :DDEProblem, DDEFunction, true, (;)), + (SciMLBase, :SDEProblem, SDEFunction, true, (;)), + (SciMLBase, :SDDEProblem, SDDEFunction, true, (;)), + (JumpProcesses, :JumpProblem, "inner SciMLFunction", true, (; init = false)), + (SciMLBase, :DiscreteProblem, DiscreteFunction, true, (;)), + (SciMLBase, :ImplicitDiscreteProblem, ImplicitDiscreteFunction, true, (;)), + (SciMLBase, :NonlinearProblem, NonlinearFunction, false, (;)), + (SciMLBase, :NonlinearLeastSquaresProblem, NonlinearFunction, false, (;)), + (SciMLBase, :SCCNonlinearProblem, NonlinearFunction, false, (; init = false)), + (SciMLBase, :OptimizationProblem, OptimizationFunction, false, (; init = false)) +] + @eval @doc problem_docstring($mod.$prob, $func, $istd) $mod.$prob +end + +function function_docstring(func, istd, optionals) + return """ + $func(sys::System; kwargs...) + $func{iip}(sys::System; kwargs...) + $func{iip, specialize}(sys::System; kwargs...) + + Create a `$func` from the given `sys`. `iip` is a boolean indicating whether the + function should be in-place. `specialization` is a `SciMLBase.AbstractSpecalize` + subtype indicating the level of specialization of the $func. + + # Keyword arguments + + - `u0`: The `u0` vector for the corresponding problem, if available. Can be obtained + using [`ModelingToolkit.get_u0`](@ref). + - `p`: The parameter object for the corresponding problem, if available. Can be obtained + using [`ModelingToolkit.get_p`](@ref). + $(istd ? TIME_DEPENDENT_FUNCTION_KWARGS : "") + $EVAL_EXPR_MOD_KWARGS + - `checkbounds`: Whether to enable bounds checking in the generated code. + - `simplify`: Whether to `simplify` any symbolically computed jacobians/hessians/etc. + - `cse`: Whether to enable Common Subexpression Elimination (CSE) on the generated code. + This typically improves performance of the generated code but reduces readability. + - `sparse`: Whether to generate jacobian/hessian/etc. functions that return/operate on + sparse matrices. Also controls whether the mass matrix is sparse, wherever applicable. + $(prob_fun_common_kwargs(func, istd)) + $(process_optional_function_kwargs(optionals)) + + All other keyword arguments are forwarded to the `$func` struct constructor. + """ +end + +const TIME_DEPENDENT_FUNCTION_KWARGS = """ +- `t`: The initial time for the corresponding problem, if available. +""" + +const JAC_KWARGS = """ +- `jac`: Whether to symbolically compute and generate code for the jacobian function. +""" + +const TGRAD_KWARGS = """ +- `tgrad`: Whether to symbolically compute and generate code for the `tgrad` function. +""" + +const SPARSITY_KWARGS = """ +- `sparsity`: Whether to provide symbolically compute and provide sparsity patterns for the + jacobian/hessian/etc. +""" + +const RESID_PROTOTYPE_KWARGS = """ +- `resid_prototype`: The prototype of the residual function `f` for a problem involving a + nonlinear solve where the residual and `u0` have different sizes. +""" + +const GRAD_KWARGS = """ +- `grad`: Whether the symbolically compute and generate code for the gradient of the cost + function with respect to unknowns. +""" + +const HESS_KWARGS = """ +- `hess`: Whether to symbolically compute and generate code for the hessian function. +""" + +const CONSH_KWARGS = """ +- `cons_h`: Whether to symbolically compute and generate code for the hessian function of + constraints. Since the constraint function is vector-valued, the hessian is a vector + of hessian matrices. +""" + +const CONSJ_KWARGS = """ +- `cons_j`: Whether to symbolically compute and generate code for the jacobian function of + constraints. +""" + +const CONSSPARSE_KWARGS = """ +- `cons_sparse`: Identical to the `sparse` keyword, but specifically for jacobian/hessian + functions of the constraints. +""" + +const INPUTFN_KWARGS = """ +- `inputs`: The variables in the input vector. The system must have been simplified using + `mtkcompile` with these variables passed as `inputs`. +- `disturbance_inputs`: The disturbance input variables. The system must have been + simplified using `mtkcompile` with these variables passed as `disturbance_inputs`. +""" + +const CONTROLJAC_KWARGS = """ +- `controljac`: Whether to symbolically compute and generate code for the jacobian of + the ODE with respect to the inputs. +""" + +const OPTIONAL_FN_KWARGS_DICT = Dict( + :jac => JAC_KWARGS, + :tgrad => TGRAD_KWARGS, + :sparsity => SPARSITY_KWARGS, + :resid_prototype => RESID_PROTOTYPE_KWARGS, + :grad => GRAD_KWARGS, + :hess => HESS_KWARGS, + :cons_h => CONSH_KWARGS, + :cons_j => CONSJ_KWARGS, + :cons_sparse => CONSSPARSE_KWARGS, + :inputfn => INPUTFN_KWARGS, + :controljac => CONTROLJAC_KWARGS +) + +const SPARSITY_OPTIONALS = Set([:jac, :hess, :cons_h, :cons_j, :controljac]) + +const CONS_SPARSITY_OPTIONALS = Set([:cons_h, :cons_j]) + +function process_optional_function_kwargs(choices::Vector{Symbol}) + if !isdisjoint(choices, SPARSITY_OPTIONALS) + push!(choices, :sparsity) + end + if !isdisjoint(choices, CONS_SPARSITY_OPTIONALS) + push!(choices, :cons_sparse) + end + join(map(Base.Fix1(getindex, OPTIONAL_FN_KWARGS_DICT), choices), "\n") +end + +for (mod, func, istd, optionals) in [ + (SciMLBase, :ODEFunction, true, [:jac, :tgrad]), + (SciMLBase, :ODEInputFunction, true, [:inputfn, :jac, :tgrad, :controljac]), + (SciMLBase, :DAEFunction, true, [:jac, :tgrad]), + (SciMLBase, :DDEFunction, true, Symbol[]), + (SciMLBase, :SDEFunction, true, [:jac, :tgrad]), + (SciMLBase, :SDDEFunction, true, Symbol[]), + (SciMLBase, :DiscreteFunction, true, Symbol[]), + (SciMLBase, :ImplicitDiscreteFunction, true, Symbol[]), + (SciMLBase, :NonlinearFunction, false, [:resid_prototype, :jac]), + (SciMLBase, :IntervalNonlinearFunction, false, Symbol[]), + (SciMLBase, :OptimizationFunction, false, [:jac, :grad, :hess, :cons_h, :cons_j]) +] + @eval @doc function_docstring($mod.$func, $istd, $optionals) $mod.$func +end + +@doc """ + SciMLBase.HomotopyNonlinearFunction(sys::System; kwargs...) + SciMLBase.HomotopyNonlinearFunction{iip}(sys::System; kwargs...) + SciMLBase.HomotopyNonlinearFunction{iip, specialize}(sys::System; kwargs...) + +Create a `HomotopyNonlinearFunction` from the given `sys`. `iip` is a boolean indicating +whether the function should be in-place. `specialization` is a `SciMLBase.AbstractSpecalize` +subtype indicating the level of specialization of the $func. + +# Keyword arguments + +- `u0`: The `u0` vector for the corresponding problem, if available. Can be obtained + using [`ModelingToolkit.get_u0`](@ref). +- `p`: The parameter object for the corresponding problem, if available. Can be obtained + using [`ModelingToolkit.get_p`](@ref). +$EVAL_EXPR_MOD_KWARGS +- `checkbounds`: Whether to enable bounds checking in the generated code. +- `simplify`: Whether to `simplify` any symbolically computed jacobians/hessians/etc. +- `cse`: Whether to enable Common Subexpression Elimination (CSE) on the generated code. + This typically improves performance of the generated code but reduces readability. +- `fraction_cancel_fn`: The function to use to simplify fractions in the polynomial + expression. A more powerful function can increase processing time but be able to + eliminate more rational functions, thus improving solve time. Should be a function that + takes a symbolic expression containing zero or more fraction expressions and returns the + simplified expression. While this defaults to `SymbolicUtils.simplify_fractions`, a viable + alternative is `SymbolicUtils.quick_cancel` + +All keyword arguments are forwarded to the wrapped `NonlinearFunction` constructor. +""" SciMLBase.HomotopyNonlinearFunction + +@doc """ + SciMLBase.IntervalNonlinearProblem(sys::System, uspan::NTuple{2}, parammap = SciMLBase.NullParameters(); kwargs...) + +Create an `IntervalNonlinearProblem` from the given `sys`. This is only valid for a system +of nonlinear equations with a single equation and unknown. `uspan` is the interval in which +the root is to be found, and `parammap` is an iterable collection of key-value pairs +providing values for the parameters in the system. + +$TIME_INDEPENDENT_INIT + +# Keyword arguments + +$PROBLEM_KWARGS +$(prob_fun_common_kwargs(IntervalNonlinearProblem, false)) + +All other keyword arguments are forwarded to the `IntervalNonlinearFunction` constructor. + +$PROBLEM_INTERNALS_HEADER + +$PROBLEM_INTERNAL_KWARGS +""" SciMLBase.IntervalNonlinearProblem + +@doc """ + SciMLBase.LinearProblem(sys::System, op; kwargs...) + SciMLBase.LinearProblem{iip}(sys::System, op; kwargs...) + +Build a `LinearProblem` given a system `sys` and operating point `op`. `iip` is a boolean +indicating whether the problem should be in-place. The operating point should be an +iterable collection of key-value pairs mapping variables/parameters in the system to the +(initial) values they should take in `LinearProblem`. Any values not provided will +fallback to the corresponding default (if present). + +Note that since `u0` is optional for `LinearProblem`, values of unknowns do not need to be +specified in `op` to create a `LinearProblem`. In such a case, `prob.u0` will be `nothing` +and attempting to symbolically index the problem with an unknown, observable, or expression +depending on unknowns/observables will error. + +Updating the parameters automatically updates the `A` and `b` arrays. + +# Keyword arguments + +$PROBLEM_KWARGS +$(prob_fun_common_kwargs(LinearProblem, false)) + +All other keyword arguments are forwarded to the $func constructor. + +$PROBLEM_INTERNALS_HEADER + +$PROBLEM_INTERNAL_KWARGS +""" SciMLBase.LinearProblem diff --git a/src/problems/implicitdiscreteproblem.jl b/src/problems/implicitdiscreteproblem.jl new file mode 100644 index 0000000000..9c85153c4f --- /dev/null +++ b/src/problems/implicitdiscreteproblem.jl @@ -0,0 +1,74 @@ +@fallback_iip_specialize function SciMLBase.ImplicitDiscreteFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, t = nothing, eval_expression = false, + eval_module = @__MODULE__, expression = Val{false}, + checkbounds = false, analytic = nothing, simplify = false, cse = true, + initialization_data = nothing, check_compatibility = true, kwargs...) where { + iip, spec} + check_complete(sys, ImplicitDiscreteFunction) + check_compatibility && check_compatible_system(ImplicitDiscreteFunction, sys) + + iv = get_iv(sys) + dvs = unknowns(sys) + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + implicit_dae = true, eval_expression, eval_module, checkbounds = checkbounds, cse, + override_discrete = true, kwargs...) + + if spec === 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, u0, p, t)) + end + + if length(dvs) == length(equations(sys)) + resid_prototype = nothing + else + resid_prototype = calculate_resid_prototype(length(equations(sys)), u0, p) + end + + observedfun = ObservedFunctionCache( + sys; steady_state = false, expression, eval_expression, eval_module, checkbounds, cse) + + args = (; f) + kwargs = (; + sys = sys, + observed = observedfun, + analytic = analytic, + initialization_data, + resid_prototype) + + return maybe_codegen_scimlfn( + expression, ImplicitDiscreteFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.ImplicitDiscreteProblem{iip, spec}( + sys::System, op, tspan; + check_compatibility = true, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, ImplicitDiscreteProblem) + check_compatibility && check_compatible_system(ImplicitDiscreteProblem, sys) + + dvs = unknowns(sys) + op = to_varmap(op, dvs) + add_toterms!(op; replace = true) + f, u0, + p = process_SciMLProblem( + ImplicitDiscreteFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_compatibility, + expression, kwargs...) + + kwargs = process_kwargs(sys; kwargs...) + args = (; f, u0, tspan, p) + return maybe_codegen_scimlproblem( + expression, ImplicitDiscreteProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{ImplicitDiscreteFunction}, Type{ImplicitDiscreteProblem}}, sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_is_discrete(sys, T) +end diff --git a/src/problems/initializationproblem.jl b/src/problems/initializationproblem.jl new file mode 100644 index 0000000000..b379215e39 --- /dev/null +++ b/src/problems/initializationproblem.jl @@ -0,0 +1,154 @@ +struct InitializationProblem{iip, specialization} end + +@doc """ + InitializationProblem(sys::AbstractSystem, t, op = Dict(); kwargs...) + InitializationProblem{iip}(sys::AbstractSystem, t, op = Dict(); kwargs...) + InitializationProblem{iip, specialize}(sys::AbstractSystem, t, op = Dict(); kwargs...) + +Generate a `NonlinearProblem`, `SCCNonlinearProblem` or `NonlinearLeastSquaresProblem` to +represent a consistent initialization of `sys` given the initial time `t` and operating +point `op`. The initial time can be `nothing` for time-independent systems. + +# Keyword arguments + +$INITIALIZEPROB_KWARGS +$INTERNAL_INITIALIZEPROB_KWARGS + +All other keyword arguments are forwarded to the wrapped nonlinear problem constructor. +""" InitializationProblem + +@fallback_iip_specialize function InitializationProblem{iip, specialize}( + sys::AbstractSystem, + t, op = Dict(); + guesses = [], + check_length = true, + warn_initialize_determined = true, + initialization_eqs = [], + fully_determined = nothing, + check_units = true, + use_scc = true, + allow_incomplete = false, + algebraic_only = false, + time_dependent_init = is_time_dependent(sys), + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `mtkcompile` on the system before creating an `ODEProblem`") + end + has_u0_ics = false + op = copy(anydict(op)) + for k in keys(op) + has_u0_ics |= is_variable(sys, k) || isdifferential(k) || + symbolic_type(k) == ArraySymbolic() && + is_sized_array_symbolic(k) && is_variable(sys, unwrap(first(wrap(k)))) + end + if !has_u0_ics && get_initializesystem(sys) !== nothing + isys = get_initializesystem(sys; initialization_eqs, check_units) + simplify_system = false + elseif !has_u0_ics && get_initializesystem(sys) === nothing + isys = generate_initializesystem( + sys; initialization_eqs, check_units, op, guesses, algebraic_only) + simplify_system = true + else + isys = generate_initializesystem( + sys; op, initialization_eqs, check_units, time_dependent_init, + guesses, algebraic_only) + simplify_system = true + end + + # useful for `SteadyStateProblem` since `f` has to be autonomous and the + # initialization should be too + if !time_dependent_init + idx = findfirst(isequal(get_iv(sys)), get_ps(isys)) + idx === nothing || deleteat!(get_ps(isys), idx) + end + + if simplify_system + isys = mtkcompile(isys; fully_determined, split = is_split(sys)) + 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), observables(isys)) + + # TODO: throw on uninitialized arrays + filter!(x -> !(x isa Symbolics.Arr), uninit) + if time_dependent_init && !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 + + if t !== nothing + op[get_iv(sys)] = t + end + filter!(kvp -> kvp[2] !== missing, op) + + if isempty(guesses) + guesses = Dict() + end + + filter_missing_values!(op) + op = merge(ModelingToolkit.guesses(sys), todict(guesses), op) + + 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{iip}(isys, op; kwargs..., build_initializeprob = false, is_initializeprob = true) +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 diff --git a/src/problems/intervalnonlinearproblem.jl b/src/problems/intervalnonlinearproblem.jl new file mode 100644 index 0000000000..d7221632d3 --- /dev/null +++ b/src/problems/intervalnonlinearproblem.jl @@ -0,0 +1,63 @@ +function SciMLBase.IntervalNonlinearFunction( + sys::System; u0 = nothing, p = nothing, eval_expression = false, + eval_module = @__MODULE__, expression = Val{false}, checkbounds = false, + analytic = nothing, cse = true, initialization_data = nothing, + check_compatibility = true, kwargs...) + check_complete(sys, IntervalNonlinearFunction) + check_compatibility && check_compatible_system(IntervalNonlinearFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + scalar = true, eval_expression, eval_module, checkbounds, cse, kwargs...) + + observedfun = ObservedFunctionCache( + sys; steady_state = false, expression, eval_expression, eval_module, checkbounds, + cse) + + args = (; f) + kwargs = (; + sys = sys, + observed = observedfun, + analytic = analytic, + initialization_data) + + return maybe_codegen_scimlfn( + expression, IntervalNonlinearFunction{false}, args; kwargs...) +end + +function SciMLBase.IntervalNonlinearProblem( + sys::System, uspan::NTuple{2}, parammap = SciMLBase.NullParameters(); + check_compatibility = true, expression = Val{false}, kwargs...) + check_complete(sys, IntervalNonlinearProblem) + check_compatibility && check_compatible_system(IntervalNonlinearProblem, sys) + + u0map = unknowns(sys) .=> uspan[1] + op = anydict([unknowns(sys)[1] => uspan[1]]) + merge!(op, to_varmap(parammap, parameters(sys))) + f, u0, + p = process_SciMLProblem(IntervalNonlinearFunction, sys, op; + check_compatibility, expression, kwargs...) + + kwargs = process_kwargs(sys; kwargs...) + args = (; f, uspan, p) + return maybe_codegen_scimlproblem(expression, IntervalNonlinearProblem, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{IntervalNonlinearFunction}, Type{IntervalNonlinearProblem}}, sys::System) + check_time_independent(sys, T) + if !isone(length(unknowns(sys))) + throw(SystemCompatibilityError(""" + `$T` requires a system with a single unknown. Found `$(unknowns(sys))`. + """)) + end + if !isone(length(equations(sys))) + throw(SystemCompatibilityError(""" + `$T` requires a system with a single equation. Found `$(equations(sys))`. + """)) + end + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) +end diff --git a/src/problems/jumpproblem.jl b/src/problems/jumpproblem.jl new file mode 100644 index 0000000000..3bc0e11f61 --- /dev/null +++ b/src/problems/jumpproblem.jl @@ -0,0 +1,226 @@ +@fallback_iip_specialize function JumpProcesses.JumpProblem{iip, spec}( + sys::System, op, tspan::Union{Tuple, Nothing}; + check_compatibility = true, eval_expression = false, eval_module = @__MODULE__, + checkbounds = false, cse = true, aggregator = JumpProcesses.NullAggregator(), + callback = nothing, rng = nothing, kwargs...) where {iip, spec} + check_complete(sys, JumpProblem) + check_compatibility && check_compatible_system(JumpProblem, sys) + + has_vrjs = any(x -> x isa VariableRateJump, jumps(sys)) + has_eqs = !isempty(equations(sys)) + has_noise = get_noise_eqs(sys) !== nothing + + if (has_vrjs || has_eqs) + if has_eqs && has_noise + prob = SDEProblem{iip, spec}( + sys, op, tspan; check_compatibility = false, + build_initializeprob = false, checkbounds, cse, check_length = false, + kwargs...) + elseif has_eqs + prob = ODEProblem{iip, spec}( + sys, op, tspan; check_compatibility = false, + build_initializeprob = false, checkbounds, cse, check_length = false, + kwargs...) + else + _, u0, + p = process_SciMLProblem(EmptySciMLFunction{iip}, sys, op; + t = tspan === nothing ? nothing : tspan[1], + check_length = false, build_initializeprob = false, kwargs...) + observedfun = ObservedFunctionCache(sys; eval_expression, eval_module, + checkbounds, cse) + f = (du, u, p, t) -> (du .= 0; nothing) + df = ODEFunction{true, spec}(f; sys, observed = observedfun) + prob = ODEProblem{true}(df, u0, tspan, p; kwargs...) + end + else + _f, u0, + p = process_SciMLProblem(EmptySciMLFunction{iip}, sys, op; + t = tspan === nothing ? nothing : tspan[1], check_length = false, build_initializeprob = false, cse, kwargs...) + f = DiffEqBase.DISCRETE_INPLACE_DEFAULT + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds, cse) + + df = DiscreteFunction{true, true}(f; sys = sys, observed = observedfun, + initialization_data = get(_f.kwargs, :initialization_data, nothing)) + prob = DiscreteProblem(df, u0, tspan, p; kwargs...) + end + + dvs = unknowns(sys) + unknowntoid = Dict(value(unknown) => i for (i, unknown) in enumerate(dvs)) + js = jumps(sys) + invttype = prob.tspan[1] === nothing ? Float64 : typeof(1 / prob.tspan[2]) + + # handling parameter substitution and empty param vecs + p = (prob.p isa DiffEqBase.NullParameters || prob.p === nothing) ? Num[] : prob.p + + majpmapper = JumpSysMajParamMapper(sys, p; jseqs = js, rateconsttype = invttype) + _majs = Vector{MassActionJump}(filter(x -> x isa MassActionJump, js)) + _crjs = Vector{ConstantRateJump}(filter(x -> x isa ConstantRateJump, js)) + vrjs = Vector{VariableRateJump}(filter(x -> x isa VariableRateJump, js)) + majs = isempty(_majs) ? nothing : assemble_maj(_majs, unknowntoid, majpmapper) + crjs = ConstantRateJump[assemble_crj(sys, j, unknowntoid; eval_expression, eval_module) + for j in _crjs] + vrjs = VariableRateJump[assemble_vrj(sys, j, unknowntoid; eval_expression, eval_module) + for j in vrjs] + jset = JumpSet(Tuple(vrjs), Tuple(crjs), nothing, majs) + + # dep graphs are only for constant rate jumps + nonvrjs = ArrayPartition(_majs, _crjs) + if needs_vartojumps_map(aggregator) || needs_depgraph(aggregator) || + (aggregator isa JumpProcesses.NullAggregator) + jdeps = asgraph(sys; eqs = nonvrjs) + vdeps = variable_dependencies(sys; eqs = nonvrjs) + vtoj = jdeps.badjlist + jtov = vdeps.badjlist + jtoj = needs_depgraph(aggregator) ? eqeq_dependencies(jdeps, vdeps).fadjlist : + nothing + else + vtoj = nothing + jtov = nothing + jtoj = nothing + end + + # handle events, making sure to reset aggregators in the generated affect functions + cbs = process_events( + sys; callback, eval_expression, eval_module, op, reset_jumps = true) + + if rng !== nothing + kwargs = (; kwargs..., rng) + end + return JumpProblem(prob, aggregator, jset; dep_graph = jtoj, vartojumps_map = vtoj, + jumptovars_map = jtov, scale_rates = false, nocopy = true, + callback = cbs, kwargs...) +end + +function check_compatible_system(T::Union{Type{JumpProblem}}, sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_has_jumps(sys, T) + check_is_continuous(sys, T) +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::System, p; jseqs = nothing, rateconsttype = Float64) + eqs = (jseqs === nothing) ? jumps(js) : jseqs + majs = MassActionJump[x for x in eqs if x isa MassActionJump] + paramexprs = [maj.scaled_rates for maj in majs] + 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 + +# 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 + +# 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 + +##### MTK dispatches for Symbolic jumps ##### +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 + +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 + +### 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 + +function get_variables!(dep, jump::MassActionJump, variables) + sr = value(jump.scaled_rates) + (sr isa Symbolic) && get_variables!(dep, sr, variables) + for varasop in jump.reactant_stoch + any(isequal(varasop[1]), variables) && push!(dep, varasop[1]) + end + dep +end + +### 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!(munknowns, st) + end + munknowns +end + +function modified_unknowns!(munknowns, jump::MassActionJump, sts) + for (unknown, stoich) in jump.net_stoch + any(isequal(unknown), sts) && push!(munknowns, unknown) + end + munknowns +end diff --git a/src/problems/linearproblem.jl b/src/problems/linearproblem.jl new file mode 100644 index 0000000000..4244e462c6 --- /dev/null +++ b/src/problems/linearproblem.jl @@ -0,0 +1,99 @@ +function SciMLBase.LinearProblem(sys::System, op; kwargs...) + SciMLBase.LinearProblem{true}(sys, op; kwargs...) +end + +function SciMLBase.LinearProblem(sys::System, op::StaticArray; kwargs...) + SciMLBase.LinearProblem{false}(sys, op; kwargs...) +end + +function SciMLBase.LinearProblem{iip}( + sys::System, op; check_length = true, expression = Val{false}, + check_compatibility = true, sparse = false, eval_expression = false, + eval_module = @__MODULE__, checkbounds = false, cse = true, + u0_constructor = identity, u0_eltype = nothing, kwargs...) where {iip} + check_complete(sys, LinearProblem) + check_compatibility && check_compatible_system(LinearProblem, sys) + + _, u0, + p = process_SciMLProblem( + EmptySciMLFunction{iip}, sys, op; check_length, expression, + build_initializeprob = false, symbolic_u0 = true, u0_constructor, u0_eltype, + kwargs...) + + if any(x -> symbolic_type(x) != NotSymbolic(), u0) + u0 = nothing + end + + u0Type = typeof(op) + floatT = if u0 === nothing + calculate_float_type(op, u0Type) + else + eltype(u0) + end + u0_eltype = something(u0_eltype, floatT) + + u0_constructor = get_p_constructor(u0_constructor, u0Type, u0_eltype) + + A, b = calculate_A_b(sys; sparse) + update_A = generate_update_A(sys, A; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, kwargs...) + update_b = generate_update_b(sys, b; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, kwargs...) + observedfun = ObservedFunctionCache( + sys; steady_state = false, expression, eval_expression, eval_module, checkbounds, + cse) + + if expression == Val{true} + symbolic_interface = quote + update_A = $update_A + update_b = $update_b + sys = $sys + observedfun = $observedfun + $(SciMLBase.SymbolicLinearInterface)( + update_A, update_b, sys, observedfun, nothing) + end + get_A = build_explicit_observed_function( + sys, A; param_only = true, eval_expression, eval_module) + if sparse + get_A = SparseArrays.sparse ∘ get_A + end + get_b = build_explicit_observed_function( + sys, b; param_only = true, eval_expression, eval_module) + A = u0_constructor(get_A(p)) + b = u0_constructor(get_b(p)) + else + symbolic_interface = SciMLBase.SymbolicLinearInterface( + update_A, update_b, sys, observedfun, nothing) + A = u0_constructor(update_A(p)) + b = u0_constructor(update_b(p)) + end + + kwargs = (; u0, process_kwargs(sys; kwargs...)..., f = symbolic_interface) + args = (; A, b, p) + + return maybe_codegen_scimlproblem(expression, LinearProblem{iip}, args; kwargs...) +end + +# For remake +function SciMLBase.get_new_A_b( + sys::AbstractSystem, f::SciMLBase.SymbolicLinearInterface, p, A, b; kw...) + if ArrayInterface.ismutable(A) + f.update_A!(A, p) + f.update_b!(b, p) + else + # The generated function has both IIP and OOP variants + A = StaticArraysCore.similar_type(A)(f.update_A!(p)) + b = StaticArraysCore.similar_type(b)(f.update_b!(p)) + end + return A, b +end + +function check_compatible_system(T::Type{LinearProblem}, sys::System) + check_time_independent(sys, T) + check_affine(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) +end diff --git a/src/problems/nonlinearproblem.jl b/src/problems/nonlinearproblem.jl new file mode 100644 index 0000000000..093e8db762 --- /dev/null +++ b/src/problems/nonlinearproblem.jl @@ -0,0 +1,113 @@ +@fallback_iip_specialize function SciMLBase.NonlinearFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, jac = false, + eval_expression = false, eval_module = @__MODULE__, sparse = false, + checkbounds = false, sparsity = false, analytic = nothing, + simplify = false, cse = true, initialization_data = nothing, + resid_prototype = nothing, check_compatibility = true, expression = Val{false}, + kwargs...) where {iip, spec} + check_complete(sys, NonlinearFunction) + check_compatibility && check_compatible_system(NonlinearFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing + error("u0, and p must be specified for FunctionWrapperSpecialize on NonlinearFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p)) + end + end + + if jac + _jac = generate_jacobian(sys; expression, + wrap_gfw = Val{true}, simplify, sparse, cse, eval_expression, eval_module, + checkbounds, kwargs...) + else + _jac = nothing + end + + observedfun = ObservedFunctionCache( + sys; steady_state = false, expression, eval_expression, eval_module, checkbounds, + cse) + + if sparse + jac_prototype = similar(calculate_jacobian(sys; sparse), eltype(u0)) + else + jac_prototype = nothing + end + + kwargs = (; + sys = sys, + jac = _jac, + observed = observedfun, + analytic = analytic, + jac_prototype, + resid_prototype, + initialization_data) + args = (; f) + + return maybe_codegen_scimlfn(expression, NonlinearFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.NonlinearProblem{iip, spec}( + sys::System, op; expression = Val{false}, + check_length = true, check_compatibility = true, kwargs...) where {iip, spec} + check_complete(sys, NonlinearProblem) + if is_time_dependent(sys) + sys = NonlinearSystem(sys) + end + check_compatibility && check_compatible_system(NonlinearProblem, sys) + + f, u0, + p = process_SciMLProblem(NonlinearFunction{iip, spec}, sys, op; + check_length, check_compatibility, expression, kwargs...) + + kwargs = process_kwargs(sys; kwargs...) + ptype = getmetadata(sys, ProblemTypeCtx, StandardNonlinearProblem()) + args = (; f, u0, p, ptype) + + return maybe_codegen_scimlproblem(expression, NonlinearProblem{iip}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.NonlinearLeastSquaresProblem{iip, spec}( + sys::System, op; check_length = false, + check_compatibility = true, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, NonlinearLeastSquaresProblem) + check_compatibility && check_compatible_system(NonlinearLeastSquaresProblem, sys) + + f, u0, + p = process_SciMLProblem(NonlinearFunction{iip}, sys, op; + check_length, expression, kwargs...) + + kwargs = process_kwargs(sys; kwargs...) + args = (; f, u0, p) + + return maybe_codegen_scimlproblem( + expression, NonlinearLeastSquaresProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{NonlinearFunction}, Type{NonlinearProblem}, + Type{NonlinearLeastSquaresProblem}}, sys::System) + check_time_independent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) +end + +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 diff --git a/src/problems/odeproblem.jl b/src/problems/odeproblem.jl new file mode 100644 index 0000000000..da33963be6 --- /dev/null +++ b/src/problems/odeproblem.jl @@ -0,0 +1,114 @@ +@fallback_iip_specialize function SciMLBase.ODEFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, tgrad = false, jac = false, + t = nothing, eval_expression = false, eval_module = @__MODULE__, sparse = false, + steady_state = false, checkbounds = false, sparsity = false, analytic = nothing, + simplify = false, cse = true, initialization_data = nothing, expression = Val{false}, + check_compatibility = true, kwargs...) where {iip, spec} + check_complete(sys, ODEFunction) + check_compatibility && check_compatible_system(ODEFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on ODEFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + end + + if tgrad + _tgrad = generate_tgrad( + sys; expression, wrap_gfw = Val{true}, + simplify, cse, eval_expression, eval_module, checkbounds, kwargs...) + else + _tgrad = nothing + end + + if jac + _jac = generate_jacobian( + sys; expression, wrap_gfw = Val{true}, + simplify, sparse, cse, eval_expression, eval_module, checkbounds, kwargs...) + else + _jac = nothing + end + + M = calculate_massmatrix(sys) + _M = concrete_massmatrix(M; sparse, u0) + + observedfun = ObservedFunctionCache( + sys; expression, steady_state, eval_expression, eval_module, checkbounds, cse) + + _W_sparsity = W_sparsity(sys) + W_prototype = calculate_W_prototype(_W_sparsity; u0, sparse) + + args = (; f) + kwargs = (; + sys = sys, + jac = _jac, + tgrad = _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + observed = observedfun, + sparsity = sparsity ? _W_sparsity : nothing, + analytic = analytic, + initialization_data) + + maybe_codegen_scimlfn(expression, ODEFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.ODEProblem{iip, spec}( + sys::System, op, tspan; + callback = nothing, check_length = true, eval_expression = false, + expression = Val{false}, eval_module = @__MODULE__, check_compatibility = true, + kwargs...) where {iip, spec} + check_complete(sys, ODEProblem) + check_compatibility && check_compatible_system(ODEProblem, sys) + + f, u0, + p = process_SciMLProblem(ODEFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_length, eval_expression, + eval_module, expression, check_compatibility, kwargs...) + + kwargs = process_kwargs( + sys; expression, callback, eval_expression, eval_module, op, kwargs...) + + ptype = getmetadata(sys, ProblemTypeCtx, StandardODEProblem()) + args = (; f, u0, tspan, p, ptype) + maybe_codegen_scimlproblem(expression, ODEProblem{iip}, args; kwargs...) +end + +@fallback_iip_specialize function DiffEqBase.SteadyStateProblem{iip, spec}( + sys::System, op; check_length = true, check_compatibility = true, + expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, SteadyStateProblem) + check_compatibility && check_compatible_system(SteadyStateProblem, sys) + + f, u0, + p = process_SciMLProblem(ODEFunction{iip}, sys, op; + steady_state = true, check_length, check_compatibility, expression, + time_dependent_init = false, kwargs...) + + kwargs = process_kwargs(sys; expression, kwargs...) + args = (; f, u0, p) + + maybe_codegen_scimlproblem(expression, SteadyStateProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{ODEFunction}, Type{ODEProblem}, Type{DAEFunction}, + Type{DAEProblem}, Type{SteadyStateProblem}}, + sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_is_continuous(sys, T) +end diff --git a/src/problems/optimizationproblem.jl b/src/problems/optimizationproblem.jl new file mode 100644 index 0000000000..978f973d83 --- /dev/null +++ b/src/problems/optimizationproblem.jl @@ -0,0 +1,157 @@ +function SciMLBase.OptimizationFunction(sys::System, args...; kwargs...) + return OptimizationFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.OptimizationFunction{iip}(sys::System; + u0 = nothing, p = nothing, grad = false, hess = false, + sparse = false, cons_j = false, cons_h = false, cons_sparse = false, + linenumbers = true, eval_expression = false, eval_module = @__MODULE__, + simplify = false, check_compatibility = true, checkbounds = false, cse = true, + expression = Val{false}, kwargs...) where {iip} + check_complete(sys, OptimizationFunction) + check_compatibility && check_compatible_system(OptimizationFunction, sys) + + cstr = constraints(sys) + + f = generate_cost(sys; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, kwargs...) + + if grad + _grad = generate_cost_gradient(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds, cse, kwargs...) + else + _grad = nothing + end + if hess + _hess, + hess_prototype = generate_cost_hessian( + sys; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, sparse, simplify, return_sparsity = true, + kwargs...) + else + _hess = hess_prototype = nothing + if sparse + hess_prototype = cost_hessian_sparsity(sys) + end + end + if isempty(cstr) + cons = _cons_j = cons_jac_prototype = _cons_h = nothing + cons_hess_prototype = cons_expr = nothing + else + cons = generate_cons(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds, cse, kwargs...) + if cons_j + _cons_j, + cons_jac_prototype = generate_constraint_jacobian( + sys; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, simplify, sparse = cons_sparse, + return_sparsity = true, kwargs...) + else + _cons_j = cons_jac_prototype = nothing + end + if cons_h + _cons_h, + cons_hess_prototype = generate_constraint_hessian( + sys; expression, wrap_gfw = Val{true}, eval_expression, + eval_module, checkbounds, cse, simplify, sparse = cons_sparse, + return_sparsity = true, kwargs...) + else + _cons_h = cons_hess_prototype = nothing + end + cons_expr = cstr + end + + obj_expr = cost(sys) + + observedfun = ObservedFunctionCache( + sys; expression, eval_expression, eval_module, checkbounds, cse) + + args = (; f, ad = SciMLBase.NoAD()) + kwargs = (; + sys = sys, + grad = _grad, + hess = _hess, + hess_prototype = hess_prototype, + cons = cons, + cons_j = _cons_j, + cons_jac_prototype = cons_jac_prototype, + cons_h = _cons_h, + cons_hess_prototype = cons_hess_prototype, + cons_expr = cons_expr, + expr = obj_expr, + observed = observedfun) + + return maybe_codegen_scimlfn(expression, OptimizationFunction{iip}, args; kwargs...) +end + +function SciMLBase.OptimizationProblem(sys::System, args...; kwargs...) + return OptimizationProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.OptimizationProblem{iip}( + sys::System, op; lb = nothing, + ub = nothing, check_compatibility = true, expression = Val{false}, + kwargs...) where {iip} + check_complete(sys, OptimizationProblem) + check_compatibility && check_compatible_system(OptimizationProblem, sys) + + f, u0, + p = process_SciMLProblem(OptimizationFunction{iip}, sys, op; + check_compatibility, tofloat = false, check_length = false, expression, kwargs...) + + dvs = unknowns(sys) + int = symtype.(unwrap.(dvs)) .<: Integer + if lb === nothing && ub === nothing + lb = first.(getbounds.(dvs)) + ub = last.(getbounds.(dvs)) + isboolean = symtype.(unwrap.(dvs)) .<: Bool + lb[isboolean] .= 0 + ub[isboolean] .= 1 + else + 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 + + ps = parameters(sys) + defs = defaults(sys) + op = to_varmap(op, dvs) + lbmap = merge(op, AnyDict(dvs .=> lb)) + _, _ = build_operating_point!(sys, lbmap, Dict(), Dict(), defs, dvs, ps) + lb = varmap_to_vars(lbmap, dvs; tofloat = false) + ubmap = merge(op, AnyDict(dvs .=> ub)) + _, _ = build_operating_point!(sys, ubmap, Dict(), Dict(), defs, dvs, ps) + ub = varmap_to_vars(ubmap, dvs; tofloat = false) + + if !isnothing(lb) && all(lb .== -Inf) && !isnothing(ub) && all(ub .== Inf) + lb = nothing + ub = nothing + end + + cstr = constraints(sys) + if isempty(cstr) + lcons = ucons = nothing + else + lcons = fill(-Inf, length(cstr)) + ucons = zeros(length(cstr)) + lcons[findall(Base.Fix2(isa, Equation), cstr)] .= 0.0 + end + + kwargs = process_kwargs(sys; kwargs...) + kwargs = (; lb, ub, int, lcons, ucons, kwargs...) + args = (; f, u0, p) + return maybe_codegen_scimlproblem(expression, OptimizationProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{OptimizationFunction}, Type{OptimizationProblem}}, sys::System) + check_time_independent(sys, T) + check_not_dde(sys) + check_has_cost(sys, T) + check_no_jumps(sys, T) + check_no_noise(sys, T) + check_no_equations(sys, T) +end diff --git a/src/problems/sccnonlinearproblem.jl b/src/problems/sccnonlinearproblem.jl new file mode 100644 index 0000000000..2a44e3de4e --- /dev/null +++ b/src/problems/sccnonlinearproblem.jl @@ -0,0 +1,259 @@ +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::System, _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 = System(_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::System, args...; kwargs...) + SCCNonlinearProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.SCCNonlinearProblem{iip}(sys::System, op; eval_expression = false, + eval_module = @__MODULE__, cse = true, kwargs...) where {iip} + if !iscomplete(sys) || get_tearing_state(sys) === nothing + error("A simplified `System` is required. Call `mtkcompile` 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 `mtkcompile` to use `SCCNonlinearProblem`.") + end + + ts = get_tearing_state(sys) + sched = get_schedule(sys) + if sched === nothing + @warn "System is simplified but does not have a schedule. This should not happen." + var_eq_matching, var_sccs = StructuralTransformations.algebraic_variables_scc(ts) + 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) + else + var_sccs = sched.var_sccs + # Equations are already in the order of SCCs + eq_sccs = length.(var_sccs) + cumsum!(eq_sccs, eq_sccs) + eq_sccs = map(enumerate(eq_sccs)) do (i, lasti) + i == 1 ? (1:lasti) : ((eq_sccs[i - 1] + 1):lasti) + end + end + + if length(var_sccs) == 1 + return NonlinearProblem{iip}( + sys, op; eval_expression, eval_module, kwargs...) + end + + dvs = unknowns(sys) + ps = parameters(sys) + eqs = equations(sys) + obs = observed(sys) + + _, u0, + p = process_SciMLProblem( + EmptySciMLFunction{iip}, sys, op; 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) + _u0 = SymbolicUtils.Code.create_array( + typeof(u0), eltype(u0), Val(1), Val(length(vscc)), u0[vscc]...) + prob = NonlinearProblem(f, _u0, 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 diff --git a/src/problems/sddeproblem.jl b/src/problems/sddeproblem.jl new file mode 100644 index 0000000000..f0e8e354aa --- /dev/null +++ b/src/problems/sddeproblem.jl @@ -0,0 +1,94 @@ +@fallback_iip_specialize function SciMLBase.SDDEFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, expression = Val{false}, + eval_expression = false, eval_module = @__MODULE__, checkbounds = false, + initialization_data = nothing, cse = true, check_compatibility = true, + sparse = false, simplify = false, analytic = nothing, kwargs...) where {iip, spec} + check_complete(sys, SDDEFunction) + check_compatibility && check_compatible_system(SDDEFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, kwargs...) + g = generate_diffusion_function(sys; expression, + wrap_gfw = Val{true}, eval_expression, eval_module, checkbounds, cse, kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on SDDEFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + end + + M = calculate_massmatrix(sys) + _M = concrete_massmatrix(M; sparse, u0) + + observedfun = ObservedFunctionCache( + sys; expression, eval_expression, eval_module, checkbounds, cse) + + kwargs = (; + sys = sys, + mass_matrix = _M, + observed = observedfun, + analytic = analytic, + initialization_data) + args = (; f, g) + + return maybe_codegen_scimlfn(expression, SDDEFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.SDDEProblem{iip, spec}( + sys::System, op, tspan; + callback = nothing, check_length = true, cse = true, checkbounds = false, + eval_expression = false, eval_module = @__MODULE__, check_compatibility = true, + u0_constructor = identity, sparse = false, sparsenoise = sparse, + expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, SDDEProblem) + check_compatibility && check_compatible_system(SDDEProblem, sys) + + f, u0, + p = process_SciMLProblem(SDDEFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_length, cse, checkbounds, + eval_expression, eval_module, check_compatibility, sparse, symbolic_u0 = true, + expression, u0_constructor, kwargs...) + + h = generate_history( + sys, u0; expression, wrap_gfw = Val{true}, cse, eval_expression, eval_module, + checkbounds) + + if expression == Val{true} + if u0 !== nothing + u0 = :($u0_constructor($map($float, h(p, tspan[1])))) + end + else + if u0 !== nothing + u0 = u0_constructor(float.(h(p, tspan[1]))) + end + end + + noise, noise_rate_prototype = calculate_noise_and_rate_prototype(sys, u0; sparsenoise) + kwargs = process_kwargs(sys; callback, eval_expression, eval_module, op, kwargs...) + + if expression == Val{true} + g = :(f.g) + else + g = f.g + end + args = (; f, g, u0, h, tspan, p) + kwargs = (; noise, noise_rate_prototype, kwargs...) + + return maybe_codegen_scimlproblem(expression, SDDEProblem{iip}, args; kwargs...) +end + +function check_compatible_system( + T::Union{Type{SDDEFunction}, Type{SDDEProblem}}, sys::System) + check_time_dependent(sys, T) + check_is_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_has_noise(sys, T) + check_is_continuous(sys, T) +end diff --git a/src/problems/sdeproblem.jl b/src/problems/sdeproblem.jl new file mode 100644 index 0000000000..e322775d2b --- /dev/null +++ b/src/problems/sdeproblem.jl @@ -0,0 +1,119 @@ +@fallback_iip_specialize function SciMLBase.SDEFunction{iip, spec}( + sys::System; u0 = nothing, p = nothing, tgrad = false, jac = false, + t = nothing, eval_expression = false, eval_module = @__MODULE__, sparse = false, + steady_state = false, checkbounds = false, sparsity = false, analytic = nothing, + simplify = false, cse = true, initialization_data = nothing, + check_compatibility = true, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, SDEFunction) + check_compatibility && check_compatible_system(SDEFunction, sys) + + f = generate_rhs(sys; expression, wrap_gfw = Val{true}, + eval_expression, eval_module, checkbounds = checkbounds, cse, + kwargs...) + g = generate_diffusion_function(sys; expression, + wrap_gfw = Val{true}, eval_expression, eval_module, checkbounds, cse, kwargs...) + + if spec === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on SDEFunction.") + end + if expression == Val{true} + f = :($(SciMLBase.wrapfun_iip)($f, ($u0, $u0, $p, $t))) + else + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + end + + if tgrad + _tgrad = generate_tgrad(sys; expression, + wrap_gfw = Val{true}, simplify, cse, eval_expression, eval_module, checkbounds, + kwargs...) + else + _tgrad = nothing + end + + if jac + _jac = generate_jacobian(sys; expression, + wrap_gfw = Val{true}, simplify, sparse, cse, eval_expression, eval_module, + checkbounds, kwargs...) + else + _jac = nothing + end + + M = calculate_massmatrix(sys) + _M = concrete_massmatrix(M; sparse, u0) + + observedfun = ObservedFunctionCache( + sys; expression, steady_state, eval_expression, eval_module, checkbounds, cse) + + _W_sparsity = W_sparsity(sys) + W_prototype = calculate_W_prototype(_W_sparsity; u0, sparse) + + kwargs = (; + sys = sys, + jac = _jac, + tgrad = _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + observed = observedfun, + sparsity = sparsity ? _W_sparsity : nothing, + analytic = analytic, + initialization_data) + args = (; f, g) + + return maybe_codegen_scimlfn(expression, SDEFunction{iip, spec}, args; kwargs...) +end + +@fallback_iip_specialize function SciMLBase.SDEProblem{iip, spec}( + sys::System, op, tspan; + callback = nothing, check_length = true, eval_expression = false, + eval_module = @__MODULE__, check_compatibility = true, sparse = false, + sparsenoise = sparse, expression = Val{false}, kwargs...) where {iip, spec} + check_complete(sys, SDEProblem) + check_compatibility && check_compatible_system(SDEProblem, sys) + + f, u0, + p = process_SciMLProblem(SDEFunction{iip, spec}, sys, op; + t = tspan !== nothing ? tspan[1] : tspan, check_length, eval_expression, + eval_module, check_compatibility, sparse, expression, kwargs...) + + noise, noise_rate_prototype = calculate_noise_and_rate_prototype(sys, u0; sparsenoise) + kwargs = process_kwargs(sys; expression, callback, eval_expression, eval_module, + op, kwargs...) + + args = (; f, u0, tspan, p) + kwargs = (; noise, noise_rate_prototype, kwargs...) + + return maybe_codegen_scimlproblem(expression, SDEProblem{iip}, args; kwargs...) +end + +function check_compatible_system(T::Union{Type{SDEFunction}, Type{SDEProblem}}, sys::System) + check_time_dependent(sys, T) + check_not_dde(sys) + check_no_cost(sys, T) + check_no_constraints(sys, T) + check_no_jumps(sys, T) + check_has_noise(sys, T) + check_is_continuous(sys, T) +end + +function calculate_noise_and_rate_prototype(sys::System, u0; sparsenoise = false) + noiseeqs = get_noise_eqs(sys) + if noiseeqs isa AbstractVector + # diagonal noise + noise_rate_prototype = nothing + noise = nothing + elseif size(noiseeqs, 2) == 1 + # scalar noise + noise_rate_prototype = nothing + noise = WienerProcess(0.0, 0.0, 0.0) + 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 + return noise, noise_rate_prototype +end diff --git a/src/structural_transformation/StructuralTransformations.jl b/src/structural_transformation/StructuralTransformations.jl index 38c0961446..681025cb81 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: System, 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, + vars!, invalidate_cache!, + vars!, invalidate_cache!, Shift, + IncrementalCycleTracker, add_edge_checked!, topological_sort, + filter_kwargs, lower_varname_with_unit, + lower_shift_varname_with_unit, setio, SparseMatrixCLIL, + get_fullvars, has_equations, observed, + Schedule, schedule, iscomplete, get_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, + mtkcompile!, + isdiffvar, isdervar, isalgvar, isdiffeq, algeqs, is_only_discrete, + dervars_range, diffvars_range, algvars_range, + DiffGraph, complete!, + get_fullvars, system_subset +using SymbolicIndexingInterface: symbolic_type, ArraySymbolic, NotSymbolic 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 build_torn_function, build_observed_function, ODAEProblem -export sorted_incidence_matrix +export dummy_derivative +export sorted_incidence_matrix, + pantelides!, pantelides_reassemble, tearing_reassemble, find_solvables!, + linear_subsys_adjmat! +export 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..602f656c27 --- /dev/null +++ b/src/structural_transformation/bareiss.jl @@ -0,0 +1,358 @@ +# 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..37a5b380ac 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 + +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,79 @@ 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}() - 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 - end - end + var_rename = ones(Int64, ndsts(graph)) + nlsolve_vars = Int[] + for i in nlsolve_scc_idxs, c in var_sccs[i] - dvrange = diffvars_range(s) - dvar2idx = Dict(v=>i for (i, v) in enumerate(dvrange)) - I = Int[]; J = Int[] - eqidx = 0 - for ieq in 𝑠vertices(graph) - isalgeq(s, ieq) && continue - eqidx += 1 - 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]) - end - end + append!(nlsolve_vars, c) + for v in c + var_rename[v] = 0 end end - sparse(I, J, true) -end - -""" - partitions_dag(s::SystemStructure) + masked_cumsum!(var_rename) -Return a DAG (sparse matrix) of partitions to use for parallelism. -""" -function partitions_dag(s::SystemStructure) - @unpack partitions, graph = s + dig = DiCMOBiGraph{true}(graph, var_eq_matching) - # `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)) - end - ipartvars + 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 - 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) + 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 - 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) - - u0map = defaults(sys) - # splatting to tighten the type - u0 = [map(var->get(u0map, var, 1e-3), vars)...] - # specialize on the scalar case - isscalar = length(u0) == 1 - u0 = isscalar ? u0[1] : SVector(u0...) - - fname = gensym("fun") - f = Func( - [ - DestructuredArgs(vars, inbounds=!checkbounds) - DestructuredArgs(params, inbounds=!checkbounds) - ], - [], - isscalar ? rhss[1] : MakeArray(rhss, SVector) - ) |> SymbolicUtils.Code.toexpr - - solver_call = LiteralExpr(quote - $numerical_nlsolve( - $fname, - # initial guess - $u0, - # "captured variables" - ($(params...),) - ) - end) - - [ - fname ← @RuntimeGeneratedFunction(f) - DestructuredArgs(vars, inbounds=!checkbounds) ← solver_call - ] -end - -function get_torn_eqs_vars(sys) - s = structure(sys) - partitions = s.partitions - vars = s.fullvars - eqs = equations(sys) - - 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 - -function build_torn_function( - sys; - expression=false, - jacobian_sparsity=true, - checkbounds=false, - kw... - ) - - rhss = [] - for eq in equations(sys) - isdiffeq(eq) && push!(rhss, eq.rhs) - end - - out = Sym{Any}(gensym("out")) - odefunbody = SetArray( - checkbounds, - out, - rhss - ) + 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)) - s = structure(sys) - states = map(i->s.fullvars[i], diffvars_range(s)) - syms = map(Symbol, states) - - 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 - ) - ) - ) - if expression - expr - else - observedfun = let sys = sys, dict = Dict() - function generated_observed(obsvar, u, p, t) - obs = get!(dict, value(obsvar)) do - build_observed_function(sys, obsvar, checkbounds=checkbounds) + I = Int[] + J = Int[] + s = state.structure + for ieq in 𝑠vertices(graph) + nieq = get(eqs2idx, ieq, 0) + nieq == 0 && continue + for ivar in 𝑠neighbors(graph, ieq) + 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 - obs(u, p, t) end end - - ODEFunction{true}( - @RuntimeGeneratedFunction(expr), - sparsity = torn_system_jacobian_sparsity(sys), - syms = syms, - observed = observedfun, - ) end + sparse(I, J, true, length(eqs_idxs), length(states_idxs)) 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′) - end -end - -function build_observed_function( - sys, syms; - expression=false, - output_type=Array, - checkbounds=true - ) - - if (isscalar = !(syms isa Vector)) - syms = [syms] - 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)) - obs = observed(sys) - observed_idx = Dict(map(x->x.lhs, obs) .=> 1:length(obs)) - # FIXME: this is a rather rough estimate of dependencies. - maxidx = 0 - for (i, s) in enumerate(syms) - idx = get(observed_idx, s, nothing) - idx === nothing && continue - idx > maxidx && (maxidx = idx) - end - for idx in 1:maxidx - vs = vars(obs[idx].rhs) - union!(required_algvars, intersect(algvars, vs)) - end - - varidxs = findall(x->x in required_algvars, fullvars) - subset = find_solve_sequence(partitions, 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) - else - solves = [] + return find_solve_sequence(sccs, vars′) end - - output = map(syms) do sym - if sym in required_algvars - sym - else - obs[observed_idx[sym]].rhs - end - 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} -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) - - ODEProblem{iip}(build_torn_function(sys; kw...), u0, tspan, p; kw...) end diff --git a/src/structural_transformation/pantelides.jl b/src/structural_transformation/pantelides.jl index 9684805caf..871bd99ef4 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,100 @@ 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) + rhs = fast_substitute(rhs, state.param_derivative_map) + 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 +156,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::System; 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::System; kwargs...) -> System 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 [`mtkcompile`](@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) +function dae_index_lowering(sys::System; kwargs...) + 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..ab8e7f0f3d --- /dev/null +++ b/src/structural_transformation/partial_state_selection.jl @@ -0,0 +1,230 @@ +struct SelectedState 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)) + (ret..., DummyDerivativeSummary(var_dummy_scc, var_state_priority)) +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..8f87f6d31f --- /dev/null +++ b/src/structural_transformation/symbolics_tearing.jl @@ -0,0 +1,1361 @@ +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, 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, ieq::Int; kwargs...) + s = ts.structure + + eq_diff = eq_derivative_graph!(s, ieq) + + sys = ts.sys + eq = equations(ts)[ieq] + eq = 0 ~ fast_substitute( + ModelingToolkit.derivative( + eq.rhs - eq.lhs, get_iv(sys); throw_no_derivative = true), ts.param_derivative_map) + + vs = ModelingToolkit.vars(eq.rhs) + for v in vs + # parameters with unknown derivatives have a value of `nothing` in the map, + # so use `missing` as the default. + get(ts.param_derivative_map, v, missing) === nothing || continue + _original_eq = equations(ts)[ieq] + error(""" + Encountered derivative of discrete variable `$(only(arguments(v)))` when \ + differentiating equation `$(_original_eq)`. This may indicate a model error or a \ + missing equation of the form `$v ~ ...` that defines this derivative. + """) + end + + 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_substitution(sys::AbstractSystem; kwargs...) + neweqs = full_equations(sys::AbstractSystem; kwargs...) + @set! sys.eqs = neweqs + # @set! sys.substitutions = nothing + @set! sys.schedule = nothing +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: mark the new equation as solvable for `D(x)` +- var_eq_matching: match D(x) to the added identity equation `D(x) ~ x_t` +- full_var_eq_matching: match `x_t` to the equation that `D(x)` used to match to, and + match `D(x)` to `D(x) ~ x_t` +- var_sccs: Replace `D(x)` in its SCC by `x_t`, and add `D(x)` in its own SCC. Return + the new list of SCCs. +""" +function generate_derivative_variables!( + ts::TearingState, neweqs, var_eq_matching, full_var_eq_matching, + var_sccs; mm, 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)) + + # We need the inverse mapping of `var_sccs` to update it efficiently later. + v_to_scc = Vector{NTuple{2, Int}}(undef, ndsts(graph)) + for (i, scc) in enumerate(var_sccs), (j, v) in enumerate(scc) + + v_to_scc[v] = (i, j) + end + # Pairs of `(x_t, dx)` added below + v_t_dvs = NTuple{2, Int}[] + + # 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] + # if the variable is not differentiated, there is nothing to do + dv isa Int || continue + # if we will solve for the differentiated variable, there is nothing to do + 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 dd === nothing + # there is no such pre-existing equation + # generate the dummy derivative variable + 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) + push!(full_var_eq_matching, unassigned) + + # We also need to substitute all occurrences of `D(x)` with `x_t` in all equations + # except `dummy_eq`, but that is handled in `generate_system_equations!` since + # we will solve for `D(x) ~ x_t` and add it to the substitution map. + dd = dummy_eq, v_t + end + # there is a duplicate `D(x) ~ x_t` equation + # `dummy_eq` is the index of the equation + # `v_t` is the dummy derivative variable + dummy_eq, v_t = dd + var_to_diff[v_t] = var_to_diff[dv] + old_matched_eq = full_var_eq_matching[dv] + full_var_eq_matching[dv] = var_eq_matching[dv] = dummy_eq + full_var_eq_matching[v_t] = old_matched_eq + eq_var_matching[dummy_eq] = dv + push!(v_t_dvs, (v_t, dv)) + end + + # tuples of (index, scc) indicating that `scc` has to be inserted at + # index `index` in `var_sccs`. Same length as `v_t_dvs` because we will + # have one new SCC per new variable. + sccs_to_insert = similar(v_t_dvs, Tuple{Int, Vector{Int}}) + # mapping of SCC index to indexes in the SCC to delete + idxs_to_remove = Dict{Int, Vector{Int}}() + for (k, (v_t, dv)) in enumerate(v_t_dvs) + # replace `dv` with `v_t` + i, j = v_to_scc[dv] + var_sccs[i][j] = v_t + if v_t <= length(v_to_scc) + # v_t wasn't added by this process, it was already present. Which + # means we need to remove it from whatever SCC it is in, since it is + # now in this one + i_, j_ = v_to_scc[v_t] + scc_del_idxs = get!(() -> Int[], idxs_to_remove, i_) + push!(scc_del_idxs, j_) + end + # `dv` still needs to be present in some SCC. Since we solve for `dv` from + # `0 ~ D(x) - x_t`, it is in its own SCC. This new singleton SCC is solved + # immediately before the one that `dv` used to be in (`i`) + sccs_to_insert[k] = (i, [dv]) + end + sort!(sccs_to_insert, by = first) + # remove the idxs we need to remove + for (i, idxs) in idxs_to_remove + deleteat!(var_sccs[i], idxs) + end + new_sccs = insert_sccs(var_sccs, sccs_to_insert) + + if mm !== nothing + @set! mm.ncols = ndsts(graph) + end + + return new_sccs +end + +""" + $(TYPEDSIGNATURES) + +Given a list of SCCs and a list of SCCs to insert at specific indices, insert them and +return the new SCC vector. +""" +function insert_sccs( + var_sccs::Vector{Vector{Int}}, sccs_to_insert::Vector{Tuple{Int, Vector{Int}}}) + # insert the new SCCs, accounting for the fact that we might have multiple entries + # in `sccs_to_insert` to be inserted at the same index. + old_idx = 1 + insert_idx = 1 + new_sccs = similar(var_sccs, length(var_sccs) + length(sccs_to_insert)) + for i in eachindex(new_sccs) + # if we have SCCs to insert, and the index we have to insert them at is the current + # one in the old list of SCCs + if insert_idx <= length(sccs_to_insert) && sccs_to_insert[insert_idx][1] == old_idx + # insert it + new_sccs[i] = sccs_to_insert[insert_idx][2] + insert_idx += 1 + else + # otherwise, insert the old SCC + new_sccs[i] = copy(var_sccs[old_idx]) + old_idx += 1 + end + end + + filter!(!isempty, new_sccs) + return new_sccs +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 in the BLT sorted form. + +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, + full_var_eq_matching, var_sccs, extra_eqs_vars; + 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) + full_eq_var_matching = invview(full_var_eq_matching) + diff_to_var = invview(var_to_diff) + extra_eqs, extra_vars = extra_eqs_vars + + 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 + + eq_generator = EquationGenerator(state, total_sub, D, iv) + + # We need to solve extra equations before everything to repsect + # topological order. + for eq in extra_eqs + var = eq_var_matching[eq] + var isa Int || continue + codegen_equation!(eq_generator, neweqs[eq], eq, var; simplify) + end + + # if the variable is present in the equations either as-is or differentiated + 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 + + digraph = DiCMOBiGraph{false}(graph, var_eq_matching) + idep = iv + for (i, scc) in enumerate(var_sccs) + # note that the `vscc <-> escc` relation is a set-to-set mapping, and not + # point-to-point. + vscc, escc = get_sorted_scc(digraph, full_var_eq_matching, var_eq_matching, scc) + var_sccs[i] = vscc + + if length(escc) != length(vscc) + isempty(escc) && continue + escc = setdiff(escc, extra_eqs) + isempty(escc) && continue + vscc = setdiff(vscc, extra_vars) + isempty(vscc) && continue + end + + offset = 1 + for ieq in escc + iv = eq_var_matching[ieq] + eq = neweqs[ieq] + codegen_equation!(eq_generator, neweqs[ieq], ieq, iv; simplify) + end + end + + for eq in extra_eqs + var = eq_var_matching[eq] + var isa Int && continue + codegen_equation!(eq_generator, neweqs[eq], eq, var; simplify) + end + + @unpack neweqs′, eq_ordering, var_ordering, solved_eqs, solved_vars = eq_generator + + is_diff_eq = .!iszero.(var_ordering) + # Generate new equations and orderings + diff_vars = var_ordering[is_diff_eq] + 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) + # We filled zeros for algebraic variables, so fill them properly here + offset = 1 + for (i, v) in enumerate(var_ordering) + v == 0 || continue + # find the next variable which is not differential or solved, is not the + # derivative of another variable and is present in the equations + index = findnext(1:ndsts(graph), offset) do j + !(j in diff_vars_set || j in solved_vars_set) && diff_to_var[j] === nothing && + ispresent(j) + end + # in case of overdetermined systems, this may not be present + index === nothing && break + var_ordering[i] = index + offset = index + 1 + end + filter!(!iszero, var_ordering) + var_ordering = [var_ordering; setdiff(1:ndsts(graph), var_ordering, solved_vars_set)] + neweqs = neweqs′ + return neweqs, solved_eqs, eq_ordering, var_ordering, length(solved_vars), + length(solved_vars_set) +end + +""" + $(TYPEDSIGNATURES) + +Sort the provided SCC `scc`, given the `digraph` of the system constructed using +`var_eq_matching` along with both the matchings of the system. +""" +function get_sorted_scc( + digraph::DiCMOBiGraph, full_var_eq_matching::Matching, var_eq_matching::Matching, scc::Vector{Int}) + eq_var_matching = invview(var_eq_matching) + full_eq_var_matching = invview(full_var_eq_matching) + # obtain the matched equations in the SCC + scc_eqs = Int[full_var_eq_matching[v] for v in scc if full_var_eq_matching[v] isa Int] + # obtain the equations in the SCC that are linearly solvable + scc_solved_eqs = Int[var_eq_matching[v] for v in scc if var_eq_matching[v] isa Int] + # obtain the subgraph of the contracted graph involving the solved equations + subgraph, varmap = Graphs.induced_subgraph(digraph, scc_solved_eqs) + # topologically sort the solved equations and append the remainder + scc_eqs = [varmap[reverse(topological_sort(subgraph))]; + setdiff(scc_eqs, scc_solved_eqs)] + # the variables of the SCC are obtained by inverse mapping the sorted equations + # and appending the rest + scc_vars = [eq_var_matching[e] for e in scc_eqs if eq_var_matching[e] isa Int] + append!(scc_vars, setdiff(scc, scc_vars)) + return scc_vars, scc_eqs +end + +""" + $(TYPEDSIGNATURES) + +Struct containing the information required to generate equations of a system, as well as +the generated equations and associated metadata. +""" +struct EquationGenerator{S, D, I} + """ + `TearingState` of the system. + """ + state::S + """ + Substitutions to perform in all subsequent equations. For each differential equation + `D(x) ~ f(..)`, the substitution `D(x) => f(..)` is added to the rules. + """ + total_sub::Dict{Any, Any} + """ + The differential operator, or `nothing` if not applicable. + """ + D::D + """ + The independent variable, or `nothing` if not applicable. + """ + idep::I + """ + The new generated equations of the system. + """ + neweqs′::Vector{Equation} + """ + `eq_ordering[i]` is the index `neweqs′[i]` was originally at in the untorn equations of + the system. This is used to permute the state of the system into BLT sorted form. + """ + eq_ordering::Vector{Int} + """ + `var_ordering[i]` is the index in `state.fullvars` of the variable at the `i`th index in + the BLT sorted form. + """ + var_ordering::Vector{Int} + """ + List of linearly solved (observed) equations. + """ + solved_eqs::Vector{Equation} + """ + `eq_ordering` for `solved_eqs`. + """ + solved_vars::Vector{Int} +end + +function EquationGenerator(state, total_sub, D, idep) + EquationGenerator( + state, total_sub, D, idep, Equation[], Int[], Int[], Equation[], Int[]) +end + +""" + $(TYPEDSIGNATURES) + +Check if equation at index `ieq` is linearly solvable for variable at index `iv`. +""" +function is_solvable(eg::EquationGenerator, ieq, iv) + solvable_graph = eg.state.structure.solvable_graph + return ieq isa Int && iv isa Int && BipartiteEdge(ieq, iv) in solvable_graph +end + +""" + $(TYPEDSIGNATURES) + + If `iv` is like D(x) or Shift(t, 1)(x) +""" +function is_dervar(eg::EquationGenerator, iv::Int) + diff_to_var = invview(eg.state.structure.var_to_diff) + diff_to_var[iv] !== nothing +end + +""" + $(TYPEDSIGNATURES) + +Appropriately codegen the given equation `eq`, which occurs at index `ieq` in the untorn +list of equations and is matched to variable at index `iv`. +""" +function codegen_equation!(eg::EquationGenerator, + eq::Equation, ieq::Int, iv::Union{Int, Unassigned}; simplify = false) + # We generate equations ordered by the matched variables + # 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. + @unpack state, total_sub, neweqs′, eq_ordering, var_ordering = eg + @unpack solved_eqs, solved_vars, D, idep = eg + @unpack fullvars, sys, structure = state + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + diff_to_var = invview(var_to_diff) + + issolvable = is_solvable(eg, ieq, iv) + isdervar = issolvable && is_dervar(eg, iv) + isdisc = is_only_discrete(structure) + # The variable is derivative variable and the "most differentiated" + # This is only used for discrete systems, and basically refers to + # `Shift(t, 1)(x(k))` in `Shift(t, 1)(x(k)) ~ x(k) + x(k-1)`. As illustrated in + # the docstring for `add_additional_history!`, this is an exception and needs to be + # treated like a solved equation rather than a differential equation. + is_highest_diff = iv isa Int && isdervar && var_to_diff[iv] === nothing + if issolvable && isdervar && (!isdisc || !is_highest_diff) + 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!(neweqs′, neweq) + push!(eq_ordering, ieq) + push!(var_ordering, diff_to_var[iv]) + elseif issolvable + var = fullvars[iv] + neweq = make_solved_equation(var, eq, total_sub; simplify) + if neweq !== nothing + # backshift solved equations to calculate the value of the variable at the + # current time. This works because we added one additional history element + # in `add_additional_history!`. + if isdisc + neweq = backshift_expr(neweq, idep) + end + push!(solved_eqs, neweq) + push!(solved_vars, iv) + end + else + neweq = make_algebraic_equation(eq, total_sub) + # For the same reason as solved equations (they are effectively the same) + if isdisc + neweq = backshift_expr(neweq, idep) + end + push!(neweqs′, neweq) + push!(eq_ordering, ieq) + # we push a dummy to `var_ordering` here because `iv` is `unassigned` + push!(var_ordering, 0) + end +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. +""" +function reorder_vars!(state::TearingState, var_eq_matching, var_sccs, 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 the SCCs + var_ordering_set = BitSet(var_ordering) + for scc in var_sccs + # Map variables to their new indices + map!(v -> varsperm[v], scc, scc) + # Remove variables not in the reduced set + filter!(!iszero, scc) + end + # Remove empty SCCs + filter!(!isempty, var_sccs) + + # 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_sccs, extra_unknowns; + array_hack = true, D = nothing, iv = nothing) + @unpack fullvars, structure = state + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + diff_to_var = invview(var_to_diff) + # Since we solved the highest order derivative variable in discrete systems, + # we make a list of the solved variables and avoid including them in the + # unknowns. + solved_vars = Set() + if is_only_discrete(structure) + for eq in solved_eqs + var = eq.lhs + if isequal(eq.lhs, eq.rhs) + var = lower_shift_varname_with_unit(D(eq.lhs), iv) + end + push!(solved_vars, var) + end + filter!(eq -> !isequal(eq.lhs, eq.rhs), solved_eqs) + end + + 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; + fast_substitute(state.additional_observed, obs_sub)] + + unknown_idxs = filter( + i -> diff_to_var[i] === nothing && ispresent(i) && !(fullvars[i] in solved_vars), eachindex(state.fullvars)) + unknowns = state.fullvars[unknown_idxs] + unknowns = [unknowns; extra_unknowns] + if is_only_discrete(structure) + # Algebraic variables are shifted forward by one, so we backshift them. + unknowns = map(enumerate(unknowns)) do (i, var) + if iscall(var) && operation(var) isa Shift && operation(var).steps == 1 + backshift_expr(var, iv) + else + var + end + end + end + @set! sys.unknowns = unknowns + + obs = tearing_hacks(sys, obs, unknowns, neweqs; array = array_hack) + + @set! sys.eqs = neweqs + @set! sys.observed = obs + + # Only makes sense for time-dependent + if ModelingToolkit.has_schedule(sys) + unknowns_set = BitSet(unknown_idxs) + for scc in var_sccs + intersect!(scc, unknowns_set) + end + filter!(!isempty, var_sccs) + @set! sys.schedule = Schedule(var_sccs, dummy_sub) + end + if ModelingToolkit.has_isscheduled(sys) + @set! sys.isscheduled = true + end + return 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. + +# Arguments + +- `state`: The `TearingState` of the system. +- `var_eq_matching`: The maximal matching after state selection. +- `full_var_eq_matching`: The maximal matching prior to state selection. +- `var_sccs`: The topologically sorted strongly connected components of the system + according to `full_var_eq_matching`. +""" +function tearing_reassemble(state::TearingState, var_eq_matching::Matching, + full_var_eq_matching::Matching, var_sccs::Vector{Vector{Int}}; simplify = false, mm, + array_hack = true, fully_determined = true) + extra_eqs_vars = get_extra_eqs_vars( + state, var_eq_matching, full_var_eq_matching, fully_determined) + 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 + + extra_unknowns = state.fullvars[extra_eqs_vars[2]] + if is_only_discrete(state.structure) + var_sccs = add_additional_history!( + state, neweqs, var_eq_matching, full_var_eq_matching, var_sccs; iv, D) + end + + # Structural simplification + substitute_derivatives_algevars!(state, neweqs, var_eq_matching, dummy_sub; iv, D) + + var_sccs = generate_derivative_variables!( + state, neweqs, var_eq_matching, full_var_eq_matching, var_sccs; mm, iv, D) + + neweqs, solved_eqs, + eq_ordering, + var_ordering, + nelim_eq, + nelim_var = generate_system_equations!( + state, neweqs, var_eq_matching, full_var_eq_matching, + var_sccs, extra_eqs_vars; simplify, iv, D) + + state = reorder_vars!( + state, var_eq_matching, var_sccs, eq_ordering, var_ordering, nelim_eq, nelim_var) + # var_eq_matching and full_var_eq_matching are now invalidated + + sys = update_simplified_system!(state, neweqs, solved_eqs, dummy_sub, var_sccs, + extra_unknowns; array_hack, iv, D) + + @set! state.sys = sys + @set! sys.tearing_state = state + return invalidate_cache!(sys) +end + +""" + $(TYPEDSIGNATURES) + +Add one more history equation for discrete systems. For example, if we have + +```julia +Shift(t, 1)(x(k-1)) ~ x(k) +Shift(t, 1)(x(k)) ~ x(k) + x(k-1) +``` + +This turns it into + +```julia +Shift(t, 1)(x(k-2)) ~ x(k-1) +Shift(t, 1)(x(k-1)) ~ x(k) +Shift(t, 1)(x(k)) ~ x(k) + x(k-1) +``` + +Thus adding an additional unknown as well. Later, the highest derivative equation will +be backshifted by one and turned into an observed equation, resulting in: + +```julia +Shift(t, 1)(x(k-2)) ~ x(k-1) +Shift(t, 1)(x(k-1)) ~ x(k) + +x(k) ~ x(k-1) + x(k-2) +``` + +Where the last equation is the observed equation. +""" +function add_additional_history!( + state::TearingState, neweqs::Vector, var_eq_matching::Matching, + full_var_eq_matching::Matching, var_sccs::Vector{Vector{Int}}; iv, D) + @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) + is_discrete = is_only_discrete(structure) + digraph = DiCMOBiGraph{false}(graph, var_eq_matching) + + # We need the inverse mapping of `var_sccs` to update it efficiently later. + v_to_scc = Vector{NTuple{2, Int}}(undef, ndsts(graph)) + for (i, scc) in enumerate(var_sccs), (j, v) in enumerate(scc) + + v_to_scc[v] = (i, j) + end + + vars_to_backshift = BitSet() + eqs_to_backshift = BitSet() + # add history for differential variables + for ivar in 1:length(fullvars) + ieq = var_eq_matching[ivar] + # the variable to backshift is a state variable which is not the + # derivative of any other one. + ieq isa SelectedState || continue + diff_to_var[ivar] === nothing || continue + push!(vars_to_backshift, ivar) + end + + inserts = Tuple{Int, Vector{Int}}[] + + for var in vars_to_backshift + add_backshifted_var!(state, var, iv) + # all backshifted vars are differential vars, hence SelectedState + push!(var_eq_matching, SelectedState()) + push!(full_var_eq_matching, unassigned) + # add to the SCCs right before the variable that was backshifted + push!(inserts, (v_to_scc[var][1], [length(fullvars)])) + end + + sort!(inserts, by = first) + new_sccs = insert_sccs(var_sccs, inserts) + return new_sccs +end + +""" + $(TYPEDSIGNATURES) + +Add the backshifted version of variable `ivar` to the system. +""" +function add_backshifted_var!(state::TearingState, ivar::Int, iv) + @unpack fullvars, structure = state + @unpack var_to_diff, graph, solvable_graph = structure + + var = fullvars[ivar] + newvar = simplify_shifts(Shift(iv, -1)(var)) + push!(fullvars, newvar) + inewvar = add_vertex!(var_to_diff) + add_edge!(var_to_diff, inewvar, ivar) + add_vertex!(graph, DST) + add_vertex!(solvable_graph, DST) + return inewvar +end + +""" + $(TYPEDSIGNATURES) + +Backshift the given expression `ex`. +""" +function backshift_expr(ex, iv) + ex isa Symbolic || return ex + return descend_lower_shift_varname_with_unit( + simplify_shifts(distribute_shift(Shift(iv, -1)(ex))), iv) +end + +function backshift_expr(ex::Equation, iv) + return backshift_expr(ex.lhs, iv) ~ backshift_expr(ex.rhs, iv) +end + +""" + $(TYPEDSIGNATURES) + +Return a 2-tuple of integer vectors containing indices of extra equations and variables +respectively. For fully-determined systems, both of these are empty. Overdetermined systems +have extra equations, and underdetermined systems have extra variables. +""" +function get_extra_eqs_vars( + state::TearingState, var_eq_matching::Matching, full_var_eq_matching::Matching, fully_determined::Bool) + fully_determined && return Int[], Int[] + + extra_eqs = Int[] + extra_vars = Int[] + full_eq_var_matching = invview(full_var_eq_matching) + + for v in 𝑑vertices(state.structure.graph) + eq = full_var_eq_matching[v] + eq isa Int && continue + # Only if the variable is also unmatched in `var_eq_matching`. + # Otherwise, `SelectedState` differential variables from order lowering + # are also considered "extra" + var_eq_matching[v] === unassigned || continue + push!(extra_vars, v) + end + for eq in 𝑠vertices(state.structure.graph) + v = full_eq_var_matching[eq] + v isa Int && continue + push!(extra_eqs, eq) + end + + return extra_eqs, extra_vars +end + +""" +# HACK + +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 tearing_hacks(sys, obs, unknowns, neweqs; array = true) + # map of array observed variable (unscalarized) to number of its + # scalarized terms that appear in observed equations + arr_obs_occurrences = Dict() + for (i, eq) in enumerate(obs) + lhs = eq.lhs + rhs = eq.rhs + + 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 + + # 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 + + obs_arr_eqs = Equation[] + for (arrvar, cnt) in arr_obs_occurrences + cnt == length(arrvar) || continue + # firstindex returns 1 for multidimensional array symbolics + firstind = Tuple(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 = change_origin(firstind, scal) + push!(obs_arr_eqs, arrvar ~ rhs) + end + append!(obs, obs_arr_eqs) + + return obs +end + +# PART OF HACK +function change_origin(origin, arr) + if all(isone, 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 [`mtkcompile`](@ref) +instead, which calls this function internally. +""" +function tearing(sys::AbstractSystem, state = TearingState(sys); mm = nothing, + simplify = false, array_hack = true, fully_determined = true, kwargs...) + var_eq_matching, full_var_eq_matching, var_sccs, can_eliminate = tearing(state) + invalidate_cache!(tearing_reassemble( + state, var_eq_matching, full_var_eq_matching, var_sccs; mm, + simplify, array_hack, fully_determined)) +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, array_hack = true, fully_determined = 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, full_var_eq_matching, var_sccs, + can_eliminate, summary = dummy_derivative_graph!( + state, jac; state_priority, + kwargs...) + tearing_reassemble(state, var_eq_matching, full_var_eq_matching, var_sccs; + simplify, mm, array_hack, fully_determined) +end diff --git a/src/structural_transformation/tearing.jl b/src/structural_transformation/tearing.jl index cb209fa85f..67933ffe0e 100644 --- a/src/structural_transformation/tearing.jl +++ b/src/structural_transformation/tearing.jl @@ -1,225 +1,85 @@ -""" - 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), ndsts(graph)) + var_sccs = find_var_sccs(complete(graph), var_eq_matching) + + return var_eq_matching, var_sccs end -""" - tearing(sys; simplify=false) +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 -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) + 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..032006b006 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,38 @@ 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 -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 +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) + cgraph = MatchedCondensationGraph(cmog, sccs) + toporder = topological_sort(cgraph) + permute!(sccs, toporder) + foreach(sort!, sccs) + return sccs 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 +191,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 +203,223 @@ 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 + +""" + $(TYPEDSIGNATURES) + +Obtain the incidence matrix of the system sorted by the SCCs. Requires that the system is +simplified and has a `schedule`. +""" +function sorted_incidence_matrix(sys::AbstractSystem) + if !iscomplete(sys) || get_tearing_state(sys) === nothing || + get_schedule(sys) === nothing + error("A simplified `System` is required. Call `mtkcompile` on the system before creating an `SCCNonlinearProblem`.") + end + sched = get_schedule(sys) + var_sccs = sched.var_sccs + + ts = get_tearing_state(sys) + imat = Graphs.incidence_matrix(ts.structure.graph) + buffer = similar(imat) + permute!(buffer, imat, 1:size(imat, 2), reduce(vcat, var_sccs)) + buffer 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) + if a isa Symbolic + all_int_vars = false + if !allow_symbolic + if allow_parameter + # if any of the variables in `a` are present in fullvars (taking into account arrays) + if any( + v -> any(isequal(v), fullvars) || + symbolic_type(v) == ArraySymbolic() && + Symbolics.shape(v) != Symbolics.Unknown() && + any(x -> any(isequal(x), fullvars), collect(v)), + vars(a)) + continue + end + 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 + for j in to_rm + rem_edge!(graph, ieq, j) end - s + 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 -# debugging use -function reordered_matrix(sys, partitions=structure(sys).partitions) - s = structure(sys) - @unpack graph = s +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) + reverse!(vordering) + mm[[var_eq_matching[v] for v in vordering if var_eq_matching[v] isa Int], vordering], bb +end + +""" + $(TYPEDSIGNATURES) + +Given a system `sys` and torn variable-equation matching `torn_matching`, return the sparse +incidence matrix of the system with SCCs grouped together, and each SCC sorted to contain +the analytically solved equations/variables before the unsolved ones. +""" +function reordered_matrix(sys::System, 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 +428,187 @@ 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 +### Misc ### -@noinline nlsolve_failure(rc) = error("The nonlinear solver failed with the return code $rc.") +""" +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 + +function descend_lower_shift_varname_with_unit(var, iv) + symbolic_type(var) == NotSymbolic() && return var + ModelingToolkit._with_unit(descend_lower_shift_varname, var, iv, iv) +end +function descend_lower_shift_varname(var, iv) + iscall(var) || return var + op = operation(var) + if op isa Shift + return shift2term(var) + else + args = arguments(var) + args = map(Base.Fix2(descend_lower_shift_varname, iv), args) + return maketerm(typeof(var), op, args, Symbolics.metadata(var)) + end +end + +""" +Rename a Shift variable with negative shift, Shift(t, k)(x(t)) to xₜ₋ₖ(t). +""" +function shift2term(var) + iscall(var) || return var + op = operation(var) + op isa Shift || return var + iv = op.t + arg = only(arguments(var)) + if operation(arg) === getindex + idxs = arguments(arg)[2:end] + newvar = shift2term(op(first(arguments(arg))))[idxs...] + unshifted = ModelingToolkit.getunshifted(newvar)[idxs...] + newvar = setmetadata(newvar, ModelingToolkit.VariableUnshifted, unshifted) + return newvar + end + 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 numerical_nlsolve(f, u0, p) - prob = NonlinearProblem{false}(f, u0, p) - sol = solve(prob, NewtonRaphson()) - rc = sol.retcode - rc === :DEFAULT || nlsolve_failure(rc) - # TODO: robust initial guess, better debugging info, and residual check - sol.u +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) + (op isa Union{Pre, Initial, Sample, Hold}) && return expr + args = arguments(expr) + + if ModelingToolkit.isvariable(expr) && operation(expr) !== getindex && + !ModelingToolkit.iscalledparameter(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..e40fa9a017 100644 --- a/src/systems/abstractsystem.jl +++ b/src/systems/abstractsystem.jl @@ -1,744 +1,3362 @@ -""" -```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 +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), +[`mtkcompile`](@ref) or [`@mtkcompile`](@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 `mtkcompile` 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___) + +""" + $(TYPEDSIGNATURES) + +Obtain the name of `sys`. +""" +Base.nameof(sys::AbstractSystem) = getfield(sys, :name) +""" + $(TYPEDSIGNATURES) + +Obtain the description associated with `sys` if present, and an empty +string otherwise. +""" +description(sys::AbstractSystem) = has_description(sys) ? get_description(sys) : "" + +""" +$(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) + if isdefined(sys, :iv) && getfield(sys, :iv) !== nothing + 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 + +function SymbolicIndexingInterface.variable_symbols(sys::AbstractSystem) + return 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 + +""" + $(TYPEDSIGNATURES) + +Check if the system `sys` contains an observed equation with LHS `sym`. +""" +function has_observed_with_lhs(sys::AbstractSystem, 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), observables(sys)) + end +end + +""" + $(TYPEDSIGNATURES) + +Check if the system `sys` contains a parameter dependency equation with LHS `sym`. +""" +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 get_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)) && + length(arguments(s)) == 1 && 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.(observables(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_markovian(sys::AbstractSystem) = !is_dde(sys) + +SymbolicIndexingInterface.constant_structure(::AbstractSystem) = true + +function SymbolicIndexingInterface.all_variable_symbols(sys::AbstractSystem) + syms = variable_symbols(sys) + obs = observables(sys) + 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) + +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 Union{Differential, Shift} + 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, 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(arr), arguments(x)[2:end]...) + else + term(f, 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) && !is_discrete_system(sys) + D = Differential(get_iv(sys)) + union!(all_initialvars, [D(v) for v in all_initialvars if iscall(v)]) + end + for eq in get_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) + +Find [`GlobalScope`](@ref)d variables in `sys` and add them to the unknowns/parameters. +""" +function discover_globalscoped(sys::AbstractSystem) + newunknowns = OrderedSet() + newparams = OrderedSet() + iv = has_iv(sys) ? get_iv(sys) : nothing + collect_scoped_vars!(newunknowns, newparams, sys, iv; depth = -1) + setdiff!(newunknowns, observables(sys)) + @set! sys.ps = unique!(vcat(get_ps(sys), collect(newparams))) + @set! sys.unknowns = unique!(vcat(get_unknowns(sys), collect(newunknowns))) + return sys +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)`. + +This namespacing functionality can also be toggled independently of `complete` +using [`toggle_namespacing`](@ref). +""" +function complete( + sys::AbstractSystem; split = true, flatten = true, add_initial_parameters = true) + sys = discover_globalscoped(sys) + + 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 + sys = process_parameter_equations(sys) + 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) + # inputs have to be maintained in a specific order + input_vars = inputs(sys) + 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)[])) + if isscheduled(sys) + # ensure inputs are sorted + input_idxs = findfirst.(isequal.(input_vars), (ordered_ps,)) + @assert all(!isnothing, input_idxs) + @assert issorted(input_idxs) + end + @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 + +const SYS_PROPS = [:eqs + :tag + :noise_eqs + :iv + :unknowns + :ps + :tspan + :brownians + :jumps + :name + :description + :var_to_name + :defaults + :guesses + :observed + :systems + :constraints + :bcs + :domain + :ivs + :dvs + :connector_type + :preface + :initializesystem + :initialization_eqs + :schedule + :tearing_state + :metadata + :gui_metadata + :is_initializesystem + :is_discrete + :parameter_dependencies + :assertions + :ignored_connections + :parent + :is_dde + :tstops + :index_cache + :isscheduled + :costs + :consolidate] + +for prop in SYS_PROPS + 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. + + 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 + +""" + $(TYPEDSIGNATURES) + +Invalidate cached jacobians, etc. +""" +function invalidate_cache!(sys::AbstractSystem) + has_metadata(sys) || return sys + empty!(getmetadata(sys, MutableCacheKey, nothing)) + return sys +end + +# `::MetadataT` but that is defined later +function refreshed_metadata(meta::Base.ImmutableDict) + newmeta = MetadataT() + for (k, v) in meta + if k === MutableCacheKey + v = MutableCacheT() + end + newmeta = Base.ImmutableDict(newmeta, k => v) + end + if !haskey(newmeta, MutableCacheKey) + newmeta = Base.ImmutableDict(newmeta, MutableCacheKey => MutableCacheT()) + end + return newmeta +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) + elseif fn == :metadata + :($refreshed_metadata(getfield(obj, $(Meta.quot(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 + +Symbolics.rename(x::AbstractSystem, 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 + +""" + Base.getproperty(sys::AbstractSystem, name::Symbol) + +Access the subsystem, variable or analysis point of `sys` named `name`. To check if `sys` +will namespace the returned value, use `ModelingToolkit.does_namespacing(sys)`. + +See also: [`ModelingToolkit.does_namespacing`](@ref). +""" +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 !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) + error(""" + `setproperty!` on systems is invalid. Systems are immutable data structures, and \ + modifications to fields should be made by constructing a new system. This can be done \ + easily using packages such as Setfield.jl. + + If you are looking for the old behavior of updating the default of a variable via \ + `setproperty!`, this should now be done by mutating `ModelingToolkit.get_defaults(sys)`. + """) +end + +""" + $(TYPEDSIGNATURES) + +Apply function `f` to each variable in expression `ex`. `f` should be a function that takes +a variable and returns the replacement to use. A "variable" in this context refers to a +symbolic quantity created directly from a variable creation macro such as +[`Symbolics.@variables`](@ref), [`@independent_variables`](@ref), [`@parameters`](@ref), +[`@constants`](@ref) or [`@brownians`](@ref). +""" +apply_to_variables(f, ex) = _apply_to_variables(f, ex) +apply_to_variables(f, ex::Num) = wrap(_apply_to_variables(f, unwrap(ex))) +apply_to_variables(f, ex::Symbolics.Arr) = 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 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 `mtkcompile`. +""" +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 + 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)) + +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_jump(j::ConstantRateJump, sys) + return ConstantRateJump(namespace_expr(j.rate, sys), namespace_expr(j.affect!, sys)) +end + +function namespace_jump(j::VariableRateJump, sys) + return VariableRateJump(namespace_expr(j.rate, sys), namespace_expr(j.affect!, sys)) +end + +function namespace_jump(j::MassActionJump, sys) + return MassActionJump(namespace_expr(j.scaled_rates, sys), + [namespace_expr(k, sys) => namespace_expr(v, sys) for (k, v) in j.reactant_stoch], + [namespace_expr(k, sys) => namespace_expr(v, sys) for (k, v) in j.net_stoch]) +end + +function namespace_jumps(sys::AbstractSystem) + return [namespace_jump(j, sys) for j in get_jumps(sys)] +end + +function namespace_brownians(sys::AbstractSystem) + return [renamespace(sys, b) for b in brownians(sys)] +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 = (sys === nothing ? nothing : nameof(sys)); + ivs = sys === nothing ? nothing : independent_variables(sys)) + sys === nothing && return O + 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. +These must be explicitly solved for, unlike `observables(sys)`. + +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) + if !iscomplete(sys) + throw(ArgumentError(""" + `dependent_parameters` requires that the system is marked as complete. Call + `complete` or `mtkcompile` on the system. + """)) + end + 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. Requires that the +system is `complete`d. +""" +function parameter_dependencies(sys::AbstractSystem) + if !iscomplete(sys) + throw(ArgumentError(""" + `parameter_dependencies` requires that the system is marked as complete. Call \ + `complete` or `mtkcompile` on the system. + """)) + end + if !has_parameter_dependencies(sys) + return Equation[] + end + get_parameter_dependencies(sys) +end + +""" + $(TYPEDSIGNATURES) + +Return all of the parameters of the system, including hidden initial parameters and ones +eliminated via `parameter_dependencies`. +""" +function full_parameters(sys::AbstractSystem) + dep_ps = [eq.lhs for eq in get_parameter_dependencies(sys)] + vcat(parameters(sys; initial_parameters = true), dep_ps) +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 + +""" +$(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(_) = [] + +""" +$(TYPEDSIGNATURES) + +Get the observed equations of the system `sys` and its subsystems. +These can be expressed in terms of `unknowns(sys)`, and do not have to be explicitly solved for. + +See also [`observables`](@ref) and [`ModelingToolkit.get_observed()`](@ref). +""" +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 + +""" +$(TYPEDSIGNATURES) + +Get the observed variables of the system `sys` and its subsystems. +These can be expressed in terms of `unknowns(sys)`, and do not have to be explicitly solved for. +It is equivalent to all left hand sides of `observed(sys)`. + +See also [`observed`](@ref). +""" +function observables(sys::AbstractSystem) + return map(eq -> eq.lhs, observed(sys)) +end + +""" +$(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) 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(System(...; 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) = namespace_expr(v, sys) +for vType in [Symbolics.Arr, Symbolics.Symbolic{<:AbstractArray}] + @eval unknowns(sys::AbstractSystem, v::$vType) = namespace_expr(v, sys) + @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) + +Recursively substitute `dict` into `expr`. Use `Symbolics.simplify` on the expression +if `simplify == true`. +""" +function substitute_and_simplify(expr, dict::AbstractDict, simplify::Bool) + expr = Symbolics.fixpoint_sub(expr, dict; operator = ModelingToolkit.Initial) + simplify ? Symbolics.simplify(expr) : expr +end + +""" + $(TYPEDSIGNATURES) + +Recursively substitute the observed equations of `sys` into `expr`. If `simplify`, call +`Symbolics.simplify` on the resultant expression. +""" +function substitute_observed(sys::AbstractSystem, expr; simplify = false) + empty_substitutions(sys) && return expr + substitutions = get_substitutions(sys) + return substitute_and_simplify(expr, substitutions, simplify) +end + +""" +$(TYPEDSIGNATURES) + +Like `equations(sys)`, but also substitutes the observed equations eliminated from the +equations during `mtkcompile`. 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) + subs = get_substitutions(sys) + neweqs = map(equations(sys)) do eq + if iscall(eq.lhs) && operation(eq.lhs) isa Union{Shift, Differential} + return substitute_and_simplify(eq.lhs, subs, simplify) ~ + substitute_and_simplify( + eq.rhs, subs, + simplify) + else + if !_iszero(eq.lhs) + eq = 0 ~ eq.rhs - eq.lhs + end + rhs = substitute_and_simplify(eq.rhs, subs, simplify) + return 0 ~ rhs + end + eq + end + return neweqs +end + +""" + $(TYPEDSIGNATURES) + +Get the flattened jumps of the system. In other words, obtain all of the jumps in `sys` and +all the subsystems of `sys` (appropriately namespaced). +""" +function jumps(sys::AbstractSystem) + js = get_jumps(sys) + systems = get_systems(sys) + if isempty(systems) + return js + end + return [js; reduce(vcat, namespace_jumps.(systems); init = [])] +end + +""" + $(TYPEDSIGNATURES) + +Get all of the brownian variables involved in the system `sys` and all subsystems, +appropriately namespaced. +""" +function brownians(sys::AbstractSystem) + bs = get_brownians(sys) + systems = get_systems(sys) + if isempty(systems) + return bs + end + return [bs; reduce(vcat, namespace_brownians.(systems); init = [])] +end + +""" + $(TYPEDSIGNATURES) + +Recursively consolidate the cost vector of `sys` and all subsystems of `sys`, returning the +final scalar cost function. +""" +function cost(sys::AbstractSystem) + cs = get_costs(sys) + consolidate = get_consolidate(sys) + systems = get_systems(sys) + if isempty(systems) + return consolidate(cs, Float64[]) + end + subcosts = [namespace_expr(cost(subsys), subsys) for subsys in systems] + return consolidate(cs, subcosts) +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 + +""" + $(TYPEDSIGNATURES) + +Get all constraints in the system `sys` and all of its subsystems, appropriately namespaced. +""" +function constraints(sys::AbstractSystem) + cs = get_constraints(sys) + systems = get_systems(sys) + isempty(systems) ? cs : [cs; reduce(vcat, namespace_constraints.(systems))] +end + +""" +$(TYPEDSIGNATURES) + +Get the initialization equations of the system `sys` and its subsystems. + +See also [`guesses`](@ref), [`defaults`](@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 + +""" + $(TYPEDSIGNATURES) + +Get the tstops present in `sys` and its subsystems, appropriately namespaced. +""" +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 + +""" + $(TYPEDSIGNATURES) + +Obtain the preface associated with `sys` and all of its subsystems, appropriately +namespaced. +""" +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 full_equations(sys)] + + all(islinear(r, unknowns(sys)) for r in rhs) +end + +function isaffine(sys::AbstractSystem) + rhs = [eq.rhs for eq in full_equations(sys)] + + all(isaffine(r, unknowns(sys)) for r in rhs) +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; expression = Val{false}, steady_state = false, eval_expression = false, + eval_module = @__MODULE__, checkbounds = true, cse = true) + if expression == Val{true} + :($ObservedFunctionCache($sys, Dict(), $steady_state, $eval_expression, + $eval_module, $checkbounds, $cse)) + else + ObservedFunctionCache( + sys, Dict(), steady_state, eval_expression, eval_module, checkbounds, cse) + end +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; name = :defs) + defs_name = gensym(name) + 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)) + filtered_guesses = filter( + kvp -> !(iscall(kvp[1]) && operation(kvp[1]) isa Initial), guesses(sys)) + defs_name = push_defaults!(stmt, filtered_defs, var2name) + guesses_name = push_defaults!(stmt, filtered_guesses, var2name; name = :guesses) + obs_name = push_eqs!(stmt, obs, var2name) + + iv = get_iv(sys) + if iv === nothing + ivname = nothing + else + ivname = gensym(:iv) + push!(stmt, :($ivname = (@variables $(getname(iv)))[1])) + end + push!(stmt, + :($System($eqs_name, $ivname, $stsname, $psname; defaults = $defs_name, + guesses = $guesses_name, observed = $obs_name, + name = $name, checks = false))) + + 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))) + +""" + 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) + sys, (csets, _) = generate_connection_set(sys) + + n_extras = 0 + for cset in csets + rep = cset[1] + if rep.type <: Union{InputVar, OutputVar, Equality} + n_extras += length(cset) - 1 + elseif rep.type == Flow + n_extras += 1 + elseif rep.type == Stream + n_extras += count(x -> x.isouter, cset) + end + end + return n_extras +end + +Base.show(io::IO, sys::AbstractSystem; kws...) = show(io, MIME"text/plain"(), sys; kws...) + +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(get_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 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 setname(x, name) + @set x.name = name +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 -> $setname($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 + ParentScope(sym) + 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 + +""" + $(TYPEDSIGNATURES) + +Mark a system constructor function as building a component. For example, + +```julia +@component function AddOne(; name) + @variables in(t) out(t) + eqs = [out ~ in + 1] + return System(eqs, t, [in, out], []; name) +end +``` + +ModelingToolkit systems are either components or connectors. Components define dynamics of +the model. Connectors are used to connect components together. See the +[Model building reference](@ref model_building_api) section of the documentation for more +information. + +See also: [`@connector`](@ref). +""" +macro component(expr) + esc(component_post_processing(expr, false)) +end + +""" + $(TYPEDSIGNATURES) + +Macro shorthand for building and compiling a system in one step. + +```julia +@mtkcompile sys = Constructor(args...; kwargs....) +``` + +Is shorthand for + +```julia +@named sys = Constructor(args...; kwargs...) +sys = mtkcompile(sys) +``` +""" +macro mtkcompile(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, mtkcompile, 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 mtkcompile(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 + +@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. + +# Keyword arguments + +- `describe`: Whether to also print the description of each subsystem, if present. +- `bold`: Whether to print the name of the system in **bold** font. +""" +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 `mtkcompile` to handle such equations or scalarize them manually.")) + end + if any(x -> Symbolics.isarraysymbolic(x), dvs) + throw(ArgumentError("The system has array unknowns. Call `mtkcompile` 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 +### +""" +$(TYPEDSIGNATURES) + +Extend `basesys` with `sys`. This can be thought of as the `merge` operation on systems. +Values in `sys` take priority over duplicates in `basesys` (for example, defaults). + +By default, the resulting system inherits `sys`'s name and description. + +The `&` operator can also be used for this purpose. `sys & basesys` is equivalent to +`extend(sys, basesys)`. + +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(get_parameter_dependencies(basesys), get_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 = MetadataT() + for kvp in get_metadata(basesys) + kvp[1] == MutableCacheKey && continue + meta = Base.ImmutableDict(meta, kvp) + end + for kvp in get_metadata(sys) + kvp[1] == MutableCacheKey && continue + meta = Base.ImmutableDict(meta, kvp) + end + syss = union(get_systems(basesys), get_systems(sys)) + args = length(ivs) == 0 ? (eqs, sts, ps) : (eqs, ivs[1], sts, ps) + kwargs = (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 + 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)) + + if has_assertions(basesys) + kwargs = merge( + kwargs, (; assertions = merge(get_assertions(basesys), get_assertions(sys)))) + end + + newsys = T(args...; kwargs...) + @set! newsys.parameter_dependencies = dep_ps + + return newsys +end + +""" + $(TYPEDSIGNATURES) + +Extend `sys` with all systems in `basesys` in order. +""" +function extend(sys, basesys::Vector{T}) where {T <: AbstractSystem} + foldl(extend, basesys, init = sys) +end + +""" + $(TYPEDSIGNATURES) + +Syntactic sugar for `extend(sys, basesys)`. + +See also: [`extend`](@ref). +""" +function Base.:(&)(sys::AbstractSystem, basesys::AbstractSystem; kwargs...) + extend(sys, basesys; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Syntactic sugar for `extend(sys, basesys)`. +""" +function Base.:(&)( + sys::AbstractSystem, basesys::Vector{T}; kwargs...) where {T <: AbstractSystem} + extend(sys, basesys; kwargs...) +end + +""" +$(SIGNATURES) + +Compose multiple systems together. This adds all of `systems` as subsystems of `sys`. +The resulting system inherits the name of `sys` by default. + +The `∘` operator can also be used for this purpose. `sys ∘ basesys` is equivalent to +`compose(sys, basesys)`. + +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 +""" + $(TYPEDSIGNATURES) + +Syntactic sugar for adding all systems in `syss` as the subsystems of `first(syss)`. +""" +function compose(syss...; name = nameof(first(syss))) + compose(first(syss), collect(syss[2:end]); name = name) +end + +""" + $(TYPEDSIGNATURES) + +Syntactic sugar for `compose(sys1, sys2)`. + +See also: [`compose`](@ref). +""" +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 System + rules = todict(map(r -> Symbolics.unwrap(r[1]) => Symbolics.unwrap(r[2]), + collect(rules))) + newsys = @set sys.eqs = fast_substitute(get_eqs(sys), rules) + @set! newsys.unknowns = map(get_unknowns(sys)) do var + get(rules, var, var) + end + @set! newsys.ps = map(get_ps(sys)) do var + get(rules, var, var) + end + @set! newsys.parameter_dependencies = fast_substitute( + get_parameter_dependencies(sys), rules) + @set! newsys.defaults = Dict(fast_substitute(k, rules) => fast_substitute(v, rules) + for (k, v) in get_defaults(sys)) + @set! newsys.guesses = Dict(fast_substitute(k, rules) => fast_substitute(v, rules) + for (k, v) in get_guesses(sys)) + @set! newsys.noise_eqs = fast_substitute(get_noise_eqs(sys), rules) + @set! newsys.costs = Vector{Union{Real, BasicSymbolic}}(fast_substitute( + get_costs(sys), rules)) + @set! newsys.observed = fast_substitute(get_observed(sys), rules) + @set! newsys.initialization_eqs = fast_substitute( + get_initialization_eqs(sys), rules) + @set! newsys.constraints = fast_substitute(get_constraints(sys), rules) + @set! newsys.systems = map(s -> substitute(s, rules), get_systems(sys)) + else + error("substituting symbols is not supported for $(typeof(sys))") + end +end + +""" + $(TYPEDSIGNATURES) + +Find equations of `sys` involving only parameters and separate them out into the +`parameter_dependencies` field. Relative ordering of equations is maintained. +Parameter-only equations are validated to be explicit and sorted topologically. All such +explicitly determined parameters are removed from the parameters of `sys`. Return the new +system. +""" +function process_parameter_equations(sys::AbstractSystem) + if !isempty(get_systems(sys)) + throw(ArgumentError("Expected flattened system")) + end + varsbuf = Set() + pareq_idxs = Int[] + eqs = equations(sys) + for (i, eq) in enumerate(eqs) + empty!(varsbuf) + vars!(varsbuf, eq; op = Union{Differential, Initial, Pre}) + # singular equations + isempty(varsbuf) && continue + if all(varsbuf) do sym + is_parameter(sys, sym) || + symbolic_type(sym) == ArraySymbolic() && + is_sized_array_symbolic(sym) && + all(Base.Fix1(is_parameter, sys), collect(sym)) + end + # Everything in `varsbuf` is a parameter, so this is a cheap `is_parameter` + # check. + if !(eq.lhs in varsbuf) + throw(ArgumentError(""" + LHS of parameter dependency equation must be a single parameter. Found \ + $(eq.lhs). + """)) + end + push!(pareq_idxs, i) + end + end + + pareqs = [get_parameter_dependencies(sys); eqs[pareq_idxs]] + explicitpars = [eq.lhs for eq in pareqs] + pareqs = topsort_equations(pareqs, explicitpars) + + eqs = eqs[setdiff(eachindex(eqs), pareq_idxs)] + + @set! sys.eqs = eqs + @set! sys.parameter_dependencies = pareqs + @set! sys.ps = setdiff(get_ps(sys), explicitpars) + return sys +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 = System(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 = get_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 = System(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 = add_toterms(defaults(sys)) + gs = add_toterms(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) && recursive_hasoperator(Union{Differential, Shift}, eq.lhs) && + (return true) + isdefined(eq, :rhs) && recursive_hasoperator(Union{Differential, Shift}, 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 = System([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 = System([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 = System([eq1], t) +@named osys2 = System([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 = System([eq1], t) +@named osys2 = System([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 = ([eq1], t) +@named osys2 = ([eq2], t) +osys12 = compose(sys1, [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 = tem([eq1], t) +@named osys2 = tem([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 = System([eq1], t) +@named osys2 = System([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 = tem([eq1], t) +@named osys2 = tem([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..f24a2562fe 100644 --- a/src/systems/alias_elimination.jl +++ b/src/systems/alias_elimination.jl @@ -1,363 +1,391 @@ 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; kwargs...) + 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) - 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 + 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 - # 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) + + # 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. -function getcoeff(vars, coeffs, var) - for (vj, v) in enumerate(vars) - v == var && return coeffs[vj] + 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 - return 0, 0 + 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 + 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; + fully_determined = true, kwargs...) + @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) + + if fully_determined == true + ## 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 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 +393,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 +411,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 +430,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 +448,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 +469,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..a5a612b9ca --- /dev/null +++ b/src/systems/analysis_points.jl @@ -0,0 +1,1097 @@ +""" + $(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 = System(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 + +Symbolics.hide_lhs(::AnalysisPoint) = true + +@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 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 connect(in::AbstractSystem, name::Symbol, out, outs...; verbose = true) + return AnalysisPoint() ~ AnalysisPoint(in, name, [out; collect(outs)]; verbose) +end + +function 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`. +""" +function namespace_hierarchy(name::Symbol) + map( + Symbol, split(string(name), ('.', NAMESPACE_SEPARATOR))) +end + +""" + $(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. +""" +function modify_nested_subsystem(fn, root::AbstractSystem, target::AbstractSystem) + modify_nested_subsystem( + fn, root, nameof(target)) +end +""" + $(TYPEDSIGNATURES) + +Apply the modification to the system containing the namespaced analysis point `target`. +""" +function modify_nested_subsystem(fn, root::AbstractSystem, target::AnalysisPoint) + modify_nested_subsystem( + fn, root, @view namespace_hierarchy(nameof(target))[1:(end - 1)]) +end +""" + $(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. +""" +function modify_nested_subsystem(fn, root::AbstractSystem, target::Symbol) + modify_nested_subsystem( + fn, root, namespace_hierarchy(target)) +end + +""" + $(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(""" + Invalid analysis point name `$(join(hierarchy, NAMESPACE_SEPARATOR))`. The name + must include the name of the root system `$(nameof(root))`. This typically happens + when using an analysis point obtained by calling `getproperty` on a system marked + as `complete` to linearize a system that is not marked as `complete`. + """) + 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)`. +""" +function analysis_point_index(sys::AbstractSystem, ap::AnalysisPoint) + analysis_point_index( + sys, nameof(ap)) +end +""" + $(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 = Connection[] + else + ignored = copy(ignored) + end + if ap.outputs === nothing + error("Empty analysis point") + end + + push!(ignored, Connection([unwrap(ap.input); unwrap.(ap.outputs)])) + + 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 + """ + Whether the default of the added input variable should be the input of `ap`. Only + applicable if `add_input == true`. + """ + default_outputs_to_input::Bool + """ + Whether the added input is a parameter. Only applicable if `add_input == true`. + """ + added_input_is_param::Bool +end + +""" + $(TYPEDSIGNATURES) + +`Break` the given analysis point `ap`. +""" +function Break(ap::AnalysisPoint, add_input::Bool = false, default_outputs_to_input = false) + Break(ap, add_input, default_outputs_to_input, false) +end + +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] = if tf.default_outputs_to_input + ap_ivar + else + new_def + end + @set! breaksys.defaults = defs + if tf.added_input_is_param + ps = copy(get_ps(breaksys)) + push!(ps, new_var) + @set! breaksys.ps = ps + else + unks = copy(get_unknowns(breaksys)) + push!(unks, new_var) + @set! breaksys.unknowns = unks + end + + 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, (d_v,) = apply_transformation(Break(ap, true, true, true), sys) + guesses = copy(get_guesses(sys)) + guesses[d_v] = if symbolic_type(d_v) == ArraySymbolic() + fill(NaN, size(d_v)) + else + NaN + end + @set! sys.guesses = guesses + 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 the input 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...) + dus = [] + us = [] + sys = handle_loop_openings(sys, loop_openings) + aps = canonicalize_ap(sys, aps) + 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, + allow_input_derivatives = true, kwargs...) + lin_fun, + ssys = $(utility_fun)( + sys, ap, args...; loop_openings, system_modifier, kwargs...) + mats, extras = ModelingToolkit.linearize(ssys, lin_fun; allow_input_derivatives) + mats, ssys, extras + 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 + +""" + sys, input_vars, output_vars = $(TYPEDSIGNATURES) + +Apply analysis-point transformations to prepare a system for linearization. + +Returns +- `sys`: The transformed system. +- `input_vars`: A vector of input variables corresponding to the input analysis points. +- `output_vars`: A vector of output variables corresponding to the output analysis points. +""" +function linearization_ap_transform(sys, + inputs::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + outputs, loop_openings) + 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, 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) + @set! sys.eqs = [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 sys, input_vars, output_vars +end + +function linearization_function(sys::AbstractSystem, + inputs::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + outputs; loop_openings = [], system_modifier = identity, kwargs...) + sys, input_vars, + output_vars = linearization_ap_transform( + sys, inputs, outputs, 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.AbstractSystem, 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.AbstractSystem, 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..37e4c2c19a --- /dev/null +++ b/src/systems/callbacks.jl @@ -0,0 +1,1071 @@ +abstract type AbstractCallback end + +function has_functional_affect(cb) + affects(cb) isa ImperativeAffect +end + +struct AffectSystem + """The internal implicit discrete system whose equations are solved to obtain values after the affect.""" + system::AbstractSystem + """Unknowns of the parent ODESystem whose values are modified or accessed by the affect.""" + unknowns::Vector + """Parameters of the parent ODESystem whose values are accessed by the affect.""" + parameters::Vector + """Parameters of the parent ODESystem whose values are modified by the affect.""" + discretes::Vector +end + +system(a::AffectSystem) = a.system +discretes(a::AffectSystem) = a.discretes +unknowns(a::AffectSystem) = a.unknowns +parameters(a::AffectSystem) = a.parameters +all_equations(a::AffectSystem) = vcat(equations(system(a)), observed(system(a))) + +function Base.show(iio::IO, aff::AffectSystem) + println(iio, "Affect system defined by equations:") + eqs = all_equations(aff) + show(iio, eqs) +end + +function Base.:(==)(a1::AffectSystem, a2::AffectSystem) + isequal(system(a1), system(a2)) && + isequal(discretes(a1), discretes(a2)) && + isequal(unknowns(a1), unknowns(a2)) && + isequal(parameters(a1), parameters(a2)) +end + +function Base.hash(a::AffectSystem, s::UInt) + s = hash(system(a), s) + s = hash(unknowns(a), s) + s = hash(parameters(a), s) + hash(discretes(a), s) +end + +function vars!(vars, aff::AffectSystem; op = Differential) + for var in Iterators.flatten((unknowns(aff), parameters(aff), discretes(aff))) + vars!(vars, var) + end + vars +end + +""" + Pre(x) + +The `Pre` operator. Used by the callback system to indicate the value of a parameter or variable +before the callback is triggered. +""" +struct Pre <: Symbolics.Operator end +Pre(x) = Pre()(x) +SymbolicUtils.promote_symtype(::Type{Pre}, T) = T +SymbolicUtils.isbinop(::Pre) = false +Base.nameof(::Pre) = :Pre +Base.show(io::IO, x::Pre) = print(io, "Pre") +input_timedomain(::Pre, _ = nothing) = ContinuousClock() +output_timedomain(::Pre, _ = nothing) = ContinuousClock() +unPre(x::Num) = unPre(unwrap(x)) +unPre(x::Symbolics.Arr) = unPre(unwrap(x)) +unPre(x::Symbolic) = (iscall(x) && operation(x) isa Pre) ? only(arguments(x)) : x + +function (p::Pre)(x) + 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 Pre && return x + result = if symbolic_type(x) == ArraySymbolic() + # create an array for `Pre(array)` + Symbolics.array_term(p, x) + elseif iscall(x) && operation(x) == getindex + # instead of `Pre(x[1])` create `Pre(x)[1]` + # which allows parameter indexing to handle this case automatically. + arr = arguments(x)[1] + term(getindex, p(arr), arguments(x)[2:end]...) + else + term(p, x) + end + # the result should be a parameter + result = toparam(result) + if iw + result = wrap(result) + end + return result +end +haspre(eq::Equation) = haspre(eq.lhs) || haspre(eq.rhs) +haspre(O) = recursive_hasoperator(Pre, O) + +function validate_operator(op::Pre, args, iv; context = nothing) +end + +############################### +###### Continuous events ###### +############################### +const Affect = Union{AffectSystem, ImperativeAffect} + +""" + SymbolicContinuousCallback(eqs::Vector{Equation}, affect = nothing, iv = nothing; + affect_neg = affect, initialize = nothing, finalize = nothing, rootfind = SciMLBase.LeftRootFind, alg_eqs = Equation[]) + +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. + +`reinitializealg` is used to set how the system will be reinitialized after the callback. +- Symbolic affects have reinitialization built in. In this case the algorithm will default to SciMLBase.NoInit(), and should **not** be provided. +- Functional and imperative affects will default to SciMLBase.CheckInit(), which will error if the system is not properly reinitialized after the callback. If your system is a DAE, pass in an algorithm like SciMLBase.BrownBasicFullInit() to properly re-initialize. + +Initial and final affects can also be 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 <: AbstractCallback + conditions::Vector{Equation} + affect::Union{Affect, Nothing} + affect_neg::Union{Affect, Nothing} + initialize::Union{Affect, Nothing} + finalize::Union{Affect, Nothing} + rootfind::Union{Nothing, SciMLBase.RootfindOpt} + reinitializealg::SciMLBase.DAEInitializationAlgorithm + + function SymbolicContinuousCallback( + conditions::Union{Equation, Vector{Equation}}, + affect = nothing; + affect_neg = affect, + initialize = nothing, + finalize = nothing, + rootfind = SciMLBase.LeftRootFind, + reinitializealg = nothing, + kwargs...) + conditions = (conditions isa AbstractVector) ? conditions : [conditions] + + if isnothing(reinitializealg) + if any(a -> a isa ImperativeAffect, + [affect, affect_neg, initialize, finalize]) + reinitializealg = SciMLBase.CheckInit() + else + reinitializealg = SciMLBase.NoInit() + end + end + + new(conditions, make_affect(affect; kwargs...), + make_affect(affect_neg; kwargs...), + make_affect(initialize; kwargs...), make_affect( + finalize; kwargs...), + rootfind, reinitializealg) + end # Default affect to nothing +end + +function SymbolicContinuousCallback(p::Pair, args...; kwargs...) + SymbolicContinuousCallback(p[1], p[2], args...; kwargs...) +end +SymbolicContinuousCallback(cb::SymbolicContinuousCallback, args...; kwargs...) = cb +SymbolicContinuousCallback(cb::Nothing, args...; kwargs...) = nothing +function SymbolicContinuousCallback(cb::Tuple, args...; kwargs...) + if length(cb) == 2 + SymbolicContinuousCallback(cb[1]; kwargs..., cb[2]...) + else + error("Malformed tuple specifying callback. Should be a condition => affect pair, followed by a vector of kwargs.") + end +end + +make_affect(affect::Nothing; kwargs...) = nothing +make_affect(affect::Tuple; kwargs...) = ImperativeAffect(affect...) +make_affect(affect::NamedTuple; kwargs...) = ImperativeAffect(; affect...) +make_affect(affect::Affect; kwargs...) = affect + +function make_affect(affect::Vector{Equation}; discrete_parameters = Any[], + iv = nothing, alg_eqs::Vector{Equation} = Equation[], warn_no_algebraic = true, kwargs...) + isempty(affect) && return nothing + if isnothing(iv) + iv = t_nounits + @warn "No independent variable specified. Defaulting to t_nounits." + end + + discrete_parameters isa AbstractVector || (discrete_parameters = [discrete_parameters]) + discrete_parameters = unwrap.(discrete_parameters) + + for p in discrete_parameters + occursin(unwrap(iv), unwrap(p)) || + error("Non-time dependent parameter $p passed in as a discrete. Must be declared as @parameters $p(t).") + end + + dvs = OrderedSet() + params = OrderedSet() + _varsbuf = Set() + for eq in affect + if !haspre(eq) && !(symbolic_type(eq.rhs) === NotSymbolic() || + symbolic_type(eq.lhs) === NotSymbolic()) + @warn "Affect equation $eq has no `Pre` operator. As such it will be interpreted as an algebraic equation to be satisfied after the callback. If you intended to use the value of a variable x before the affect, use Pre(x). Errors may be thrown if there is no `Pre` and the algebraic equation is unsatisfiable, such as X ~ X + 1." + end + collect_vars!(dvs, params, eq, iv; op = Pre) + empty!(_varsbuf) + vars!(_varsbuf, eq; op = Pre) + filter!(x -> iscall(x) && operation(x) isa Pre, _varsbuf) + union!(params, _varsbuf) + diffvs = collect_applied_operators(eq, Differential) + union!(dvs, diffvs) + end + for eq in alg_eqs + collect_vars!(dvs, params, eq, iv) + end + pre_params = filter(haspre ∘ value, params) + sys_params = collect(setdiff(params, union(discrete_parameters, pre_params))) + discretes = map(tovar, discrete_parameters) + dvs = collect(dvs) + _dvs = map(default_toterm, dvs) + + rev_map = Dict(zip(discrete_parameters, discretes)) + subs = merge(rev_map, Dict(zip(dvs, _dvs))) + affect = Symbolics.fast_substitute(affect, subs) + alg_eqs = Symbolics.fast_substitute(alg_eqs, subs) + + @named affectsys = System( + vcat(affect, alg_eqs), iv, collect(union(_dvs, discretes)), + collect(union(pre_params, sys_params)); is_discrete = true) + affectsys = mtkcompile(affectsys; fully_determined = nothing) + # get accessed parameters p from Pre(p) in the callback parameters + accessed_params = Vector{Any}(filter(isparameter, map(unPre, collect(pre_params)))) + union!(accessed_params, sys_params) + + # add scalarized unknowns to the map. + _dvs = reduce(vcat, map(scalarize, _dvs), init = Any[]) + + AffectSystem(affectsys, collect(_dvs), collect(accessed_params), + collect(discrete_parameters)) +end + +function make_affect(affect; kwargs...) + error("Malformed affect $(affect). This should be a vector of equations or a tuple specifying a functional affect.") +end + +function Base.show(io::IO, cb::AbstractCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + is_discrete(cb) ? print(io, "SymbolicDiscreteCallback(") : + print(io, "SymbolicContinuousCallback(") + print(iio, "Conditions:") + show(iio, equations(cb)) + print(iio, "; ") + if affects(cb) != nothing + print(iio, "Affect:") + show(iio, affects(cb)) + print(iio, ", ") + end + if !is_discrete(cb) && affect_negs(cb) != nothing + print(iio, "Negative-edge affect:") + show(iio, affect_negs(cb)) + print(iio, ", ") + end + if initialize_affects(cb) != nothing + print(iio, "Initialization affect:") + show(iio, initialize_affects(cb)) + print(iio, ", ") + end + if finalize_affects(cb) != nothing + print(iio, "Finalization affect:") + show(iio, finalize_affects(cb)) + end + print(iio, ")") +end + +function Base.show(io::IO, mime::MIME"text/plain", cb::AbstractCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + is_discrete(cb) ? println(io, "SymbolicDiscreteCallback:") : + println(io, "SymbolicContinuousCallback:") + println(iio, "Conditions:") + show(iio, mime, equations(cb)) + print(iio, "\n") + if affects(cb) != nothing + println(iio, "Affect:") + show(iio, mime, affects(cb)) + print(iio, "\n") + end + if !is_discrete(cb) && affect_negs(cb) != nothing + print(iio, "Negative-edge affect:\n") + show(iio, mime, affect_negs(cb)) + print(iio, "\n") + end + if initialize_affects(cb) != nothing + println(iio, "Initialization affect:") + show(iio, mime, initialize_affects(cb)) + print(iio, "\n") + end + if finalize_affects(cb) != nothing + println(iio, "Finalization affect:") + show(iio, mime, finalize_affects(cb)) + print(iio, "\n") + end +end + +function vars!(vars, cb::AbstractCallback; op = Differential) + if symbolic_type(conditions(cb)) == NotSymbolic + if conditions(cb) isa AbstractArray + for eq in conditions(cb) + vars!(vars, eq; op) + end + end + else + vars!(vars, conditions(cb); op) + end + for aff in (affects(cb), initialize_affects(cb), finalize_affects(cb)) + isnothing(aff) || vars!(vars, aff; op) + end + !is_discrete(cb) && vars!(vars, affect_negs(cb); op) + return vars +end + +################################ +######## Discrete events ####### +################################ +""" + SymbolicDiscreteCallback(conditions::Vector{Equation}, affect = nothing, iv = nothing; + initialize = nothing, finalize = nothing, alg_eqs = Equation[]) + +A callback that triggers at the first timestep that the conditions are satisfied. + +The condition can be one of: +- Δt::Real - periodic events with period Δt +- ts::Vector{Real} - events trigger at these preset times given by `ts` +- eqs::Vector{Symbolic} - events trigger when the condition evaluates to true + +Arguments: +- iv: The independent variable of the system. This must be specified if the independent variable appears in one of the equations explicitly, as in x ~ t + 1. +- alg_eqs: Algebraic equations of the system that must be satisfied after the callback occurs. +""" +struct SymbolicDiscreteCallback <: AbstractCallback + conditions::Union{Number, Vector{<:Number}, Symbolic{Bool}} + affect::Union{Affect, Nothing} + initialize::Union{Affect, Nothing} + finalize::Union{Affect, Nothing} + reinitializealg::SciMLBase.DAEInitializationAlgorithm + + function SymbolicDiscreteCallback( + condition::Union{Symbolic{Bool}, Number, Vector{<:Number}}, affect = nothing; + initialize = nothing, finalize = nothing, + reinitializealg = nothing, kwargs...) + c = is_timed_condition(condition) ? condition : value(scalarize(condition)) + + if isnothing(reinitializealg) + if any(a -> a isa ImperativeAffect, + [affect, initialize, finalize]) + reinitializealg = SciMLBase.CheckInit() + else + reinitializealg = SciMLBase.NoInit() + end + end + new(c, make_affect(affect; kwargs...), + make_affect(initialize; kwargs...), + make_affect(finalize; kwargs...), reinitializealg) + end # Default affect to nothing +end + +function SymbolicDiscreteCallback(p::Pair, args...; kwargs...) + SymbolicDiscreteCallback(p[1], p[2], args...; kwargs...) +end +SymbolicDiscreteCallback(cb::SymbolicDiscreteCallback, args...; kwargs...) = cb +SymbolicDiscreteCallback(cb::Nothing, args...; kwargs...) = nothing +function SymbolicDiscreteCallback(cb::Tuple, args...; kwargs...) + if length(cb) == 2 + SymbolicDiscreteCallback(cb[1]; cb[2]...) + else + error("Malformed tuple specifying callback. Should be a condition => affect pair, followed by a vector of kwargs.") + end +end + +function is_timed_condition(condition::T) where {T} + if T === Num + false + elseif T <: Real + true + elseif T <: AbstractVector + eltype(condition) <: Real + else + false + end +end + +to_cb_vector(cbs::Vector{<:AbstractCallback}; kwargs...) = cbs +to_cb_vector(cbs::Union{Nothing, Vector{Nothing}}; kwargs...) = AbstractCallback[] +to_cb_vector(cb::AbstractCallback; kwargs...) = [cb] +function to_cb_vector(cbs; CB_TYPE = SymbolicContinuousCallback, kwargs...) + if cbs isa Pair + [CB_TYPE(cbs; kwargs...)] + else + Vector{CB_TYPE}([CB_TYPE(cb; kwargs...) for cb in cbs]) + end +end + +############################################ +########## Namespacing Utilities ########### +############################################ +function namespace_affects(affect::AffectSystem, s) + affsys = system(affect) + old_ts = get_tearing_state(affsys) + # if we just `renamespace` the system, it updates the name. However, this doesn't + # namespace the returned values from `equations(affsys)`, etc. which we need. So we + # need to manually namespace everything. This is done by renaming the system to the + # namespace, putting it as a subsystem of an empty system called `affectsys`, and then + # flatten the system. The resultant system has everything namespaced, and is still + # called `affectsys` for further namespacing + affsys = rename(affsys, nameof(s)) + affsys = toggle_namespacing(affsys, true) + affsys = System(Equation[], get_iv(affsys); systems = [affsys], name = :affectsys) + affsys = complete(affsys) + @set! affsys.tearing_state = old_ts + AffectSystem(affsys, + renamespace.((s,), unknowns(affect)), + renamespace.((s,), parameters(affect)), + renamespace.((s,), discretes(affect))) +end +namespace_affects(af::Nothing, s) = nothing + +function namespace_callback(cb::SymbolicContinuousCallback, s)::SymbolicContinuousCallback + SymbolicContinuousCallback( + namespace_equation.(equations(cb), (s,)), + 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, reinitializealg = cb.reinitializealg) +end + +function namespace_conditions(condition, s) + is_timed_condition(condition) ? condition : namespace_expr(condition, s) +end + +function namespace_callback(cb::SymbolicDiscreteCallback, s)::SymbolicDiscreteCallback + SymbolicDiscreteCallback( + namespace_conditions(conditions(cb), s), + namespace_affects(affects(cb), s), + initialize = namespace_affects(initialize_affects(cb), s), + finalize = namespace_affects(finalize_affects(cb), s), reinitializealg = cb.reinitializealg) +end + +function Base.hash(cb::AbstractCallback, s::UInt) + s = conditions(cb) isa AbstractVector ? foldr(hash, conditions(cb), init = s) : + hash(conditions(cb), s) + s = hash(affects(cb), s) + !is_discrete(cb) && (s = hash(affect_negs(cb), s)) + s = hash(initialize_affects(cb), s) + s = hash(finalize_affects(cb), s) + !is_discrete(cb) && (s = hash(cb.rootfind, s)) + hash(cb.reinitializealg, s) +end + +########################### +######### Helpers ######### +########################### + +conditions(cb::AbstractCallback) = cb.conditions +function conditions(cbs::Vector{<:AbstractCallback}) + reduce(vcat, conditions(cb) for cb in cbs; init = []) +end +equations(cb::AbstractCallback) = conditions(cb) +equations(cb::Vector{<:AbstractCallback}) = conditions(cb) + +affects(cb::AbstractCallback) = cb.affect +function affects(cbs::Vector{<:AbstractCallback}) + reduce(vcat, affects(cb) for cb in cbs; init = []) +end + +affect_negs(cb::SymbolicContinuousCallback) = cb.affect_neg +function affect_negs(cbs::Vector{SymbolicContinuousCallback}) + reduce(vcat, affect_negs(cb) for cb in cbs; init = []) +end + +initialize_affects(cb::AbstractCallback) = cb.initialize +function initialize_affects(cbs::Vector{<:AbstractCallback}) + reduce(initialize_affects, vcat, cbs; init = []) +end + +finalize_affects(cb::AbstractCallback) = cb.finalize +function finalize_affects(cbs::Vector{<:AbstractCallback}) + reduce(finalize_affects, vcat, cbs; init = []) +end + +function Base.:(==)(e1::AbstractCallback, e2::AbstractCallback) + (is_discrete(e1) === is_discrete(e2)) || return false + (isequal(e1.conditions, e2.conditions) && isequal(e1.affect, e2.affect) && + isequal(e1.initialize, e2.initialize) && isequal(e1.finalize, e2.finalize)) && + isequal(e1.reinitializealg, e2.reinitializealg) || + return false + is_discrete(e1) || + (isequal(e1.affect_neg, e2.affect_neg) && isequal(e1.rootfind, e2.rootfind)) +end + +Base.isempty(cb::AbstractCallback) = isempty(cb.conditions) + +#################################### +####### Compilation functions ###### +#################################### + +struct CompiledCondition{IsDiscrete, F} + f::F +end + +function CompiledCondition{ID}(f::F) where {ID, F} + return CompiledCondition{ID, F}(f) +end + +function (cc::CompiledCondition)(out, u, t, integ) + cc.f(out, u, parameter_values(integ), t) +end + +function (cc::CompiledCondition{false})(u, t, integ) + if DiffEqBase.isinplace(SciMLBase.get_sol(integ).prob) + tmp, = DiffEqBase.get_tmp_cache(integ) + cc.f(tmp, u, parameter_values(integ), t) + tmp[1] + else + cc.f(u, parameter_values(integ), t) + end +end + +function (cc::CompiledCondition{true})(u, t, integ) + cc.f(u, parameter_values(integ), t) +end + +""" + compile_condition(cb::AbstractCallback, sys, dvs, ps; expression, kwargs...) + +Returns a function `condition(u,t,integrator)`, condition(out,u,t,integrator)` returning the `condition(cb)`. +""" +function compile_condition( + cbs::Union{AbstractCallback, Vector{<:AbstractCallback}}, sys, dvs, ps; + eval_expression = false, eval_module = @__MODULE__, kwargs...) + u = map(value, dvs) + p = map.(value, reorder_parameters(sys, ps)) + t = get_iv(sys) + condit = conditions(cbs) + + if !is_discrete(cbs) + condit = reduce(vcat, flatten_equations(Vector{Equation}(condit))) + condit = condit isa AbstractVector ? [c.lhs - c.rhs for c in condit] : + [condit.lhs - condit.rhs] + end + + fs = build_function_wrapper( + sys, condit, u, p..., t; kwargs..., cse = false) + if is_discrete(cbs) + fs = (fs, nothing) + end + fs = GeneratedFunctionWrapper{(2, 3, is_split(sys))}( + Val{false}, fs...; eval_expression, eval_module) + return CompiledCondition{is_discrete(cbs)}(fs) +end + +is_discrete(cb::AbstractCallback) = cb isa SymbolicDiscreteCallback +is_discrete(cb::Vector{<:AbstractCallback}) = eltype(cb) isa SymbolicDiscreteCallback + +function generate_continuous_callbacks(sys::AbstractSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) + cbs = continuous_events(sys) + isempty(cbs) && return nothing + cb_classes = Dict{Tuple{SciMLBase.RootfindOpt, SciMLBase.DAEInitializationAlgorithm}, + Vector{SymbolicContinuousCallback}}() + + # Sort the callbacks by their rootfinding method + for cb in cbs + _cbs = get!(() -> SymbolicContinuousCallback[], + cb_classes, (cb.rootfind, cb.reinitializealg)) + push!(_cbs, cb) + end + sort!(OrderedDict(cb_classes), by = cb -> cb[1]) + compiled_callbacks = [generate_callback(cb, sys; kwargs...) + for ((rf, reinit), cb) in cb_classes] + if length(compiled_callbacks) == 1 + return only(compiled_callbacks) + else + return CallbackSet(compiled_callbacks...) + end +end + +function generate_discrete_callbacks(sys::AbstractSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) + dbs = discrete_events(sys) + isempty(dbs) && return nothing + [generate_callback(db, sys; kwargs...) for db in dbs] +end + +EMPTY_AFFECT(args...) = nothing + +""" +Codegen a DifferentialEquations callback. A (set of) continuous callback with multiple equations becomes a VectorContinuousCallback. +Continuous callbacks with only one equation will become a ContinuousCallback. +Individual discrete callbacks become DiscreteCallback, PresetTimeCallback, PeriodicCallback depending on the case. +""" +function generate_callback(cbs::Vector{SymbolicContinuousCallback}, sys; kwargs...) + eqs = map(cb -> flatten_equations(equations(cb)), cbs) + num_eqs = length.(eqs) + (isempty(eqs) || sum(num_eqs) == 0) && return nothing + if sum(num_eqs) == 1 + cb_ind = findfirst(>(0), num_eqs) + return generate_callback(cbs[cb_ind], sys; kwargs...) + end + + trigger = compile_condition( + cbs, sys, unknowns(sys), parameters(sys; initial_parameters = true); kwargs...) + affects = [] + affect_negs = [] + inits = [] + finals = [] + for cb in cbs + affect = compile_affect(cb.affect, cb, sys; default = EMPTY_AFFECT, kwargs...) + push!(affects, affect) + affect_neg = (cb.affect_neg === cb.affect) ? affect : + compile_affect( + cb.affect_neg, cb, sys; default = EMPTY_AFFECT, kwargs...) + push!(affect_negs, affect_neg) + push!(inits, + compile_affect( + cb.initialize, cb, sys; default = nothing, is_init = true, kwargs...)) + push!(finals, compile_affect(cb.finalize, cb, sys; default = nothing, kwargs...)) + 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 + eq2affect = reduce(vcat, + [fill(i, num_eqs[i]) for i in eachindex(affects)]) + eqs = reduce(vcat, eqs) + + affect = let eq2affect = eq2affect, affects = affects + function (integ, idx) + affects[eq2affect[idx]](integ) + end + end + affect_neg = let eq2affect = eq2affect, affect_negs = affect_negs + function (integ, idx) + f = affect_negs[eq2affect[idx]] + isnothing(f) && return + f(integ) + end + end + initialize = wrap_vector_optional_affect(inits, SciMLBase.INITIALIZE_DEFAULT) + finalize = wrap_vector_optional_affect(finals, SciMLBase.FINALIZE_DEFAULT) + + return VectorContinuousCallback( + trigger, affect, affect_neg, length(eqs); initialize, finalize, + rootfind = cbs[1].rootfind, initializealg = cbs[1].reinitializealg) +end + +function generate_callback(cb, sys; kwargs...) + is_timed = is_timed_condition(conditions(cb)) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + + trigger = is_timed ? conditions(cb) : compile_condition(cb, sys, dvs, ps; kwargs...) + affect = compile_affect(cb.affect, cb, sys; default = EMPTY_AFFECT, kwargs...) + affect_neg = if is_discrete(cb) + nothing + else + (cb.affect === cb.affect_neg) ? affect : + compile_affect(cb.affect_neg, cb, sys; default = EMPTY_AFFECT, kwargs...) + end + init = compile_affect(cb.initialize, cb, sys; default = SciMLBase.INITIALIZE_DEFAULT, + is_init = true, kwargs...) + final = compile_affect( + cb.finalize, cb, sys; default = SciMLBase.FINALIZE_DEFAULT, kwargs...) + + initialize = isnothing(cb.initialize) ? init : ((c, u, t, i) -> init(i)) + finalize = isnothing(cb.finalize) ? final : ((c, u, t, i) -> final(i)) + + if is_discrete(cb) + if is_timed && conditions(cb) isa AbstractVector + return PresetTimeCallback(trigger, affect; initialize, + finalize, initializealg = cb.reinitializealg) + elseif is_timed + return PeriodicCallback( + affect, trigger; initialize, finalize, initializealg = cb.reinitializealg) + else + return DiscreteCallback(trigger, affect; initialize, + finalize, initializealg = cb.reinitializealg) + end + else + return ContinuousCallback(trigger, affect, affect_neg; initialize, finalize, + rootfind = cb.rootfind, initializealg = cb.reinitializealg) + end +end + +""" + compile_affect(cb::AbstractCallback, sys::AbstractSystem, dvs, ps; expression, outputidxs, 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 + - `kwargs` are passed through to `Symbolics.build_function`. +""" +function compile_affect( + aff::Union{Nothing, Affect}, cb::AbstractCallback, sys::AbstractSystem; + default = nothing, is_init = false, kwargs...) + save_idxs = if !(has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing) + Int[] + else + get(ic.callback_to_clocks, cb, Int[]) + end + + if isnothing(aff) + is_init ? wrap_save_discretes(default, save_idxs) : default + elseif aff isa AffectSystem + f = compile_equational_affect(aff, sys; kwargs...) + wrap_save_discretes(f, save_idxs) + elseif aff isa ImperativeAffect + f = compile_functional_affect(aff, sys; kwargs...) + wrap_save_discretes(f, save_idxs) + end +end + +function wrap_save_discretes(f, save_idxs) + let save_idxs = save_idxs, f = f + if f === SciMLBase.INITIALIZE_DEFAULT + (c, u, t, i) -> begin + f(c, u, t, i) + for idx in save_idxs + SciMLBase.save_discretes!(i, idx) + end + end + else + (i) -> begin + isnothing(f) || f(i) + for idx in save_idxs + SciMLBase.save_discretes!(i, idx) + end + end + end + end +end + +""" +Initialize and finalize for VectorContinuousCallback. +""" +function wrap_vector_optional_affect(funs, default) + all(isnothing, funs) && return default + return let funs = funs + function (cb, u, t, integ) + for func in funs + isnothing(func) ? continue : func(integ) + end + end + end +end + +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 default_operating_point(affsys::AffectSystem) + sys = system(affsys) + + op = Dict(unknowns(sys) .=> 0.0) + for p in parameters(sys) + T = symtype(p) + if T <: Number + op[p] = false + elseif T <: Array{<:Real} && is_sized_array_symbolic(p) + op[p] = zeros(size(p)) + end + end + return op +end + +""" +Compile an affect defined by a set of equations. Systems with algebraic equations will solve implicit discrete problems to obtain their next state. Systems without will generate functions that perform explicit updates. +""" +function compile_equational_affect( + aff::Union{AffectSystem, Vector{Equation}}, sys; reset_jumps = false, + eval_expression = false, eval_module = @__MODULE__, op = nothing, kwargs...) + if aff isa AbstractVector + aff = make_affect( + aff; iv = get_iv(sys), warn_no_algebraic = false) + end + if op === nothing + op = default_operating_point(aff) + end + affsys = system(aff) + ps_to_update = discretes(aff) + dvs_to_update = setdiff(unknowns(aff), getfield.(observed(sys), :lhs)) + + obseqs, eqs = unhack_observed(observed(affsys), equations(affsys)) + if isempty(equations(affsys)) + update_eqs = Symbolics.fast_substitute( + obseqs, Dict([p => unPre(p) for p in parameters(affsys)])) + rhss = map(x -> x.rhs, update_eqs) + lhss = map(x -> x.lhs, update_eqs) + is_p = [lhs in Set(ps_to_update) for lhs in lhss] + is_u = [lhs in Set(dvs_to_update) for lhs in lhss] + dvs = unknowns(sys) + ps = parameters(sys) + t = get_iv(sys) + + u_idxs = indexin((@view lhss[is_u]), dvs) + + wrap_mtkparameters = has_index_cache(sys) && (get_index_cache(sys) !== nothing) + p_idxs = if wrap_mtkparameters + [parameter_index(sys, p) for (i, p) in enumerate(lhss) + if is_p[i]] + else + indexin((@view lhss[is_p]), ps) + end + _ps = reorder_parameters(sys, ps) + integ = gensym(:MTKIntegrator) + + u_up, + u_up! = build_function_wrapper(sys, (@view rhss[is_u]), dvs, _ps..., t; + wrap_code = add_integrator_header(sys, integ, :u), expression = Val{false}, + outputidxs = u_idxs, wrap_mtkparameters, cse = false, eval_expression, + eval_module) + p_up, + p_up! = build_function_wrapper(sys, (@view rhss[is_p]), dvs, _ps..., t; + wrap_code = add_integrator_header(sys, integ, :p), expression = Val{false}, + outputidxs = p_idxs, wrap_mtkparameters, cse = false, eval_expression, + eval_module) + + return let dvs_to_update = dvs_to_update, ps_to_update = ps_to_update, + reset_jumps = reset_jumps, u_up! = u_up!, p_up! = p_up! + + function explicit_affect!(integ) + isempty(dvs_to_update) || u_up!(integ) + isempty(ps_to_update) || p_up!(integ) + reset_jumps && reset_aggregated_jumps!(integ) + end + end + else + return let dvs_to_update = dvs_to_update, affsys = affsys, + ps_to_update = ps_to_update, aff = aff, sys = sys, reset_jumps = reset_jumps + + dvs_to_access = unknowns(affsys) + ps_to_access = [unPre(p) for p in parameters(affsys)] + + affu_getter = getsym(sys, dvs_to_access) + affp_getter = getsym(sys, ps_to_access) + affu_setter! = setsym(affsys, unknowns(affsys)) + affp_setter! = setsym(affsys, parameters(affsys)) + u_setter! = setsym(sys, dvs_to_update) + p_setter! = setsym(sys, ps_to_update) + u_getter = getsym(affsys, dvs_to_update) + p_getter = getsym(affsys, ps_to_update) + + affprob = ImplicitDiscreteProblem( + affsys, op, + (0, 0); + build_initializeprob = false, check_length = false, eval_expression, + eval_module, check_compatibility = false, kwargs...) + + function implicit_affect!(integ) + new_u0 = affu_getter(integ) + affu_setter!(affprob, new_u0) + new_ps = affp_getter(integ) + affp_setter!(affprob, new_ps) + + affprob = remake( + affprob, tspan = (integ.t, integ.t)) + affsol = init(affprob, IDSolve()) + (check_error(affsol) === ReturnCode.InitialFailure) && + throw(UnsolvableCallbackError(all_equations(aff))) + + u_setter!(integ, u_getter(affsol)) + p_setter!(integ, p_getter(affsol)) + + reset_jumps && reset_aggregated_jumps!(integ) + end + end + end +end + +struct UnsolvableCallbackError + eqs::Vector{Equation} +end + +function Base.showerror(io::IO, err::UnsolvableCallbackError) + println(io, + "The callback defined by the following equations:\n\n$(join(err.eqs, "\n"))\n\nis not solvable. Please check that the algebraic equations and affect equations are correct, and that all parameters intended to be changed are passed in as `discrete_parameters`.") +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) + +""" +Generate the CallbackSet for a ODESystem or SDESystem. +""" +function process_events(sys; callback = nothing, kwargs...) + contin_cbs = generate_continuous_callbacks(sys; kwargs...) + discrete_cbs = generate_discrete_callbacks(sys; kwargs...) + cb = merge_cb(contin_cbs, callback) + (discrete_cbs === nothing) ? cb : CallbackSet(contin_cbs, discrete_cbs...) +end + +""" + 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`. + +See also `get_discrete_events`, which only returns the events of the top-level system. +""" +function discrete_events(sys::AbstractSystem) + obs = get_discrete_events(sys) + systems = get_systems(sys) + cbs = [obs; + reduce(vcat, + (map(cb -> namespace_callback(cb, s), discrete_events(s)) for s in systems), + init = SymbolicDiscreteCallback[])] + cbs +end + +""" + $(TYPEDSIGNATURES) + +Returns whether the system `sys` has the internal field `discrete_events`. + +See also [`get_discrete_events`](@ref). +""" +has_discrete_events(sys::AbstractSystem) = isdefined(sys, :discrete_events) +""" + $(TYPEDSIGNATURES) + +Get the internal field `discrete_events` of a system `sys`. +It only includes `discrete_events` local to `sys`; not those of its subsystems, +like `unknowns(sys)`, `parameters(sys)` and `equations(sys)` does. + +See also [`has_discrete_events`](@ref). +""" +function get_discrete_events(sys::AbstractSystem) + has_discrete_events(sys) || return SymbolicDiscreteCallback[] + getfield(sys, :discrete_events) +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 + +""" + 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`. + +See also `get_continuous_events`, which only returns the events of the top-level system. +""" +function continuous_events(sys::AbstractSystem) + obs = get_continuous_events(sys) + filter(!isempty, obs) + + systems = get_systems(sys) + cbs = [obs; + reduce(vcat, + (map(o -> namespace_callback(o, s), continuous_events(s)) for s in systems), + init = SymbolicContinuousCallback[])] + filter(!isempty, cbs) +end + +""" + $(TYPEDSIGNATURES) + +Returns whether the system `sys` has the internal field `continuous_events`. + +See also [`get_continuous_events`](@ref). +""" +has_continuous_events(sys::AbstractSystem) = isdefined(sys, :continuous_events) +""" + $(TYPEDSIGNATURES) + +Get the internal field `continuous_events` of a system `sys`. +It only includes `continuous_events` local to `sys`; not those of its subsystems, +like `unknowns(sys)`, `parameters(sys)` and `equations(sys)` does. + +See also [`has_continuous_events`](@ref). +""" +function get_continuous_events(sys::AbstractSystem) + has_continuous_events(sys) || return SymbolicContinuousCallback[] + getfield(sys, :continuous_events) +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 + +""" +Process the symbolic events of a system. +""" +function create_symbolic_events(cont_events, disc_events, sys_eqs, iv) + alg_eqs = filter(eq -> eq.lhs isa Union{Symbolic, Number} && !is_diff_equation(eq), + sys_eqs) + cont_callbacks = to_cb_vector(cont_events; CB_TYPE = SymbolicContinuousCallback, + iv = iv, alg_eqs = alg_eqs, warn_no_algebraic = false) + disc_callbacks = to_cb_vector(disc_events; CB_TYPE = SymbolicDiscreteCallback, + iv = iv, alg_eqs = alg_eqs, warn_no_algebraic = false) + cont_callbacks, disc_callbacks +end diff --git a/src/systems/clock_inference.jl b/src/systems/clock_inference.jl new file mode 100644 index 0000000000..97b6be27ab --- /dev/null +++ b/src/systems/clock_inference.jl @@ -0,0 +1,211 @@ +struct ClockInference{S} + """Tearing state.""" + ts::S + """The time domain (discrete clock, continuous) of each equation.""" + eq_domain::Vector{TimeDomain} + """The output time domain (discrete clock, continuous) of each variable.""" + var_domain::Vector{TimeDomain} + """The set of variables with concrete domains.""" + 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 + +""" +Update the equation-to-time domain mapping by inferring the time domain from the variables. +""" +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 + +""" +For multi-clock systems, create a separate system for each clock in the system, along with associated equations. Return the updated tearing state, and the sets of clocked variables associated with each time domain. +""" +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 = number of clocks + 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 + + # Fill the clock_to_id dict as you go, + # ContinuousClock() => 1, ... + 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.jl b/src/systems/codegen.jl new file mode 100644 index 0000000000..490e2892c1 --- /dev/null +++ b/src/systems/codegen.jl @@ -0,0 +1,1221 @@ +const GENERATE_X_KWARGS = """ +- `expression`: `Val{true}` if this should return an `Expr` (or tuple of `Expr`s) of the + generated code. `Val{false}` otherwise. +- `wrap_gfw`: `Val{true}` if the returned functions should be wrapped in a callable + struct to make them callable using the expected syntax. The callable struct itself is + internal API. If `expression == Val{true}`, the returned expression will construct the + callable struct. If this function returns a tuple of functions/expressions, both will + be identical if `wrap_gfw == Val{true}`. +$EVAL_EXPR_MOD_KWARGS +""" + +""" + $(TYPEDSIGNATURES) + +Generate the RHS function for the [`equations`](@ref) of a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `implicit_dae`: Whether the generated function should be in the implicit form. Applicable + only for ODEs/DAEs or discrete systems. Instead of `f(u, p, t)` (`f(du, u, p, t)` for the + in-place form) the function is `f(du, u, p, t)` (respectively `f(resid, du, u, p, t)`). +- `override_discrete`: Whether to assume the system is discrete regardless of + `is_discrete_system(sys)`. +- `scalar`: Whether to generate a single-out-of-place function that returns a scalar for + the only equation in the system. + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_rhs(sys::System; implicit_dae = false, + scalar = false, expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, override_discrete = false, + kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + eqs = equations(sys) + obs = observed(sys) + u = dvs + p = reorder_parameters(sys, ps) + t = get_iv(sys) + ddvs = nothing + extra_assignments = Assignment[] + + # used for DAEProblem and ImplicitDiscreteProblem + if implicit_dae + if override_discrete || is_discrete_system(sys) + # ImplicitDiscrete case + D = Shift(t, 1) + rhss = map(eqs) do eq + # Algebraic equations get shifted forward 1, to match with differential + # equations + _iszero(eq.lhs) ? distribute_shift(D(eq.rhs)) : (eq.rhs - eq.lhs) + end + # Handle observables in algebraic equations, since they are shifted + shifted_obs = Equation[distribute_shift(D(eq)) for eq in obs] + obsidxs = observed_equations_used_by(sys, rhss; obs = shifted_obs) + ddvs = map(D, dvs) + + append!(extra_assignments, + [Assignment(shifted_obs[i].lhs, shifted_obs[i].rhs) + for i in obsidxs]) + else + D = Differential(t) + ddvs = map(D, dvs) + rhss = [_iszero(eq.lhs) ? eq.rhs : eq.rhs - eq.lhs for eq in eqs] + end + else + if !override_discrete && !is_discrete_system(sys) + check_operator_variables(eqs, Differential) + check_lhs(eqs, Differential, Set(dvs)) + end + rhss = [eq.rhs for eq in eqs] + end + + if !isempty(assertions(sys)) + rhss[end] += unwrap(get_assertions_expr(sys)) + end + + # TODO: add an optional check on the ordering of observed equations + if scalar + rhss = only(rhss) + u = only(u) + end + + args = (u, p...) + p_start = 2 + if t !== nothing + args = (args..., t) + end + if implicit_dae + args = (ddvs, args...) + p_start += 1 + end + + res = build_function_wrapper(sys, rhss, args...; p_start, extra_assignments, + expression = Val{true}, expression_module = eval_module, kwargs...) + nargs = length(args) - length(p) + 1 + if is_dde(sys) + p_start += 1 + nargs += 1 + end + return maybe_compile_function( + expression, wrap_gfw, (p_start, nargs, is_split(sys)), + res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Generate the diffusion function for the noise equations of a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_diffusion_function(sys::System; expression = Val{true}, + wrap_gfw = Val{false}, eval_expression = false, + eval_module = @__MODULE__, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + eqs = get_noise_eqs(sys) + if ndims(eqs) == 2 && size(eqs, 2) == 1 + # scalar noise + eqs = vec(eqs) + end + p = reorder_parameters(sys, ps) + res = build_function_wrapper(sys, eqs, dvs, p..., get_iv(sys); kwargs...) + if expression == Val{true} + return res + end + f_oop, f_iip = eval_or_rgf.(res; eval_expression, eval_module) + p_start = 2 + nargs = 3 + if is_dde(sys) + p_start += 1 + nargs += 1 + end + return maybe_compile_function( + expression, wrap_gfw, (p_start, nargs, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the gradient of the equations of `sys` with respect to the independent variable. +`simplify` is forwarded to `Symbolics.expand_derivatives`. +""" +function calculate_tgrad(sys::System; simplify = false) + # 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))) + return tgrad +end + +""" + $(TYPEDSIGNATURES) + +Calculate the jacobian of the equations of `sys`. + +# Keyword arguments + +- `simplify`, `sparse`: Forwarded to `Symbolics.jacobian`. +- `dvs`: The variables with respect to which the jacobian should be computed. +""" +function calculate_jacobian(sys::System; + sparse = false, simplify = false, dvs = unknowns(sys)) + obs = Dict(eq.lhs => eq.rhs for eq in observed(sys)) + rhs = map(eq -> fixpoint_sub(eq.rhs - eq.lhs, obs), equations(sys)) + + if sparse + jac = sparsejacobian(rhs, dvs; simplify) + if get_iv(sys) !== nothing + 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 + end + else + jac = jacobian(rhs, dvs; simplify) + end + + return jac +end + +""" + $(TYPEDSIGNATURES) + +Generate the jacobian function for the equations of a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_jacobian`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_jacobian(sys::System; + simplify = false, sparse = false, eval_expression = false, + eval_module = @__MODULE__, expression = Val{true}, wrap_gfw = Val{false}, + kwargs...) + dvs = unknowns(sys) + jac = calculate_jacobian(sys; simplify, sparse, dvs) + p = reorder_parameters(sys) + t = get_iv(sys) + if t === nothing + wrap_code = (identity, identity) + else + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity) + end + args = (dvs, p...) + nargs = 2 + if is_time_dependent(sys) + args = (args..., t) + nargs = 3 + end + res = build_function_wrapper(sys, jac, args...; wrap_code, expression = Val{true}, + expression_module = eval_module, kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, nargs, is_split(sys)), res; eval_expression, eval_module) +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 + +""" + $(TYPEDSIGNATURES) + +Generate the tgrad function for the equations of a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`: Forwarded to [`calculate_tgrad`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_tgrad( + sys::System; + simplify = false, eval_expression = false, eval_module = @__MODULE__, + expression = Val{true}, wrap_gfw = Val{false}, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + tgrad = calculate_tgrad(sys, simplify = simplify) + p = reorder_parameters(sys, ps) + res = build_function_wrapper(sys, tgrad, + dvs, + p..., + get_iv(sys); + expression = Val{true}, + expression_module = eval_module, + kwargs...) + + return maybe_compile_function( + expression, wrap_gfw, (2, 3, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Return an array of symbolic hessians corresponding to the equations of the system. + +# Keyword Arguments + +- `sparse`: Controls whether the symbolic hessians are sparse matrices +- `simplify`: Forwarded to `Symbolics.hessian` +""" +function calculate_hessian(sys::System; simplify = false, sparse = false) + rhs = [eq.rhs - eq.lhs for eq in full_equations(sys)] + dvs = unknowns(sys) + if sparse + hess = map(rhs) do expr + Symbolics.sparsehessian(expr, dvs; simplify)::AbstractSparseArray + end + else + hess = [Symbolics.hessian(expr, dvs; simplify) for expr in rhs] + end + + return hess +end + +""" + $(TYPEDSIGNATURES) + +Return the sparsity pattern of the hessian of the equations of `sys`. +""" +function Symbolics.hessian_sparsity(sys::System) + hess = calculate_hessian(sys; sparse = true) + return similar.(hess, Float64) +end + +const W_GAMMA = only(@variables ˍ₋gamma) + +""" + $(TYPEDSIGNATURES) + +Generate the `W = γ * M + J` function for the equations of a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_jacobian`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_W(sys::System; + simplify = false, sparse = false, expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + M = calculate_massmatrix(sys; simplify) + if sparse + M = SparseArrays.sparse(M) + end + J = calculate_jacobian(sys; simplify, sparse, dvs) + W = W_GAMMA * M + J + t = get_iv(sys) + if t !== nothing + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity) + end + + p = reorder_parameters(sys, ps) + res = build_function_wrapper(sys, W, dvs, p..., W_GAMMA, t; wrap_code, + p_end = 1 + length(p), kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, 4, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Generate the DAE jacobian `γ * J′ + J` function for the equations of a [`System`](@ref). +`J′` is the jacobian of the equations with respect to the `du` vector, and `J` is the +standard jacobian. + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_jacobian`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_dae_jacobian(sys::System; simplify = false, sparse = false, + expression = Val{true}, wrap_gfw = Val{false}, eval_expression = false, + eval_module = @__MODULE__, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + jac_u = calculate_jacobian(sys; simplify = simplify, sparse = sparse) + t = get_iv(sys) + derivatives = Differential(t).(unknowns(sys)) + jac_du = calculate_jacobian(sys; simplify = simplify, sparse = sparse, + dvs = derivatives) + dvs = unknowns(sys) + jac = W_GAMMA * jac_du + jac_u + p = reorder_parameters(sys, ps) + res = build_function_wrapper(sys, jac, derivatives, dvs, p..., W_GAMMA, t; + p_start = 3, p_end = 2 + length(p), kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (3, 5, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Generate the history function for a [`System`](@ref), given a symbolic representation of +the `u0` vector prior to the initial time. + +# Keyword Arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_history(sys::System, u0; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + p = reorder_parameters(sys) + res = build_function_wrapper(sys, u0, p..., get_iv(sys); expression = Val{true}, + expression_module = eval_module, p_start = 1, p_end = length(p), + similarto = typeof(u0), wrap_delays = false, kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (1, 2, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the mass matrix of `sys`. `simplify` controls whether `Symbolics.simplify` is +applied to the symbolic mass matrix. Returns a `Diagonal` or `LinearAlgebra.I` wherever +possible. +""" +function calculate_massmatrix(sys::System; 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 ? Symbolics.simplify.(M) : M + if isdiag(M) + M = Diagonal(M) + end + # M should only contain concrete numbers + M == I ? I : M +end + +""" + $(TYPEDSIGNATURES) + +Return a modified version of mass matrix `M` which is of a similar type to `u0`. `sparse` +controls whether the mass matrix should be a sparse matrix. +""" +function concrete_massmatrix(M; sparse = false, u0 = nothing) + if sparse && !(u0 === nothing || M === I) + SparseArrays.sparse(M) + elseif u0 === nothing || M === I + M + elseif M isa Diagonal + Diagonal(ArrayInterface.restructure(u0, diag(M))) + else + ArrayInterface.restructure(u0 .* u0', M) + end +end + +""" + $(TYPEDSIGNATURES) + +Return the sparsity pattern of the jacobian of `sys` as a matrix. +""" +function jacobian_sparsity(sys::System) + sparsity = torn_system_jacobian_sparsity(sys) + sparsity === nothing || return sparsity + + Symbolics.jacobian_sparsity([eq.rhs for eq in full_equations(sys)], + [dv for dv in unknowns(sys)]) +end + +""" + $(TYPEDSIGNATURES) + +Return the sparsity pattern of the DAE jacobian of `sys` as a matrix. + +See also: [`generate_dae_jacobian`](@ref). +""" +function jacobian_dae_sparsity(sys::System) + 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 + +""" + $(TYPEDSIGNATURES) + +Return the sparsity pattern of the `W` matrix of `sys`. + +See also: [`generate_W`](@ref). +""" +function W_sparsity(sys::System) + 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 + +""" + $(TYPEDSIGNATURES) + +Return the matrix to use as the jacobian prototype given the W-sparsity matrix of the +system. This is not the same as the jacobian sparsity pattern. + +# Keyword arguments + +- `u0`: The `u0` vector for the problem. +- `sparse`: The prototype is `nothing` for non-sparse matrices. +""" +function calculate_W_prototype(W_sparsity; u0 = nothing, sparse = false) + sparse || return nothing + uElType = u0 === nothing ? Float64 : eltype(u0) + return similar(W_sparsity, uElType) +end + +function isautonomous(sys::System) + tgrad = calculate_tgrad(sys; simplify = true) + all(iszero, tgrad) +end + +function get_bv_solution_symbol(ns) + only(@variables BV_SOLUTION(..)[1:ns]) +end + +function get_constraint_unknown_subs!(subs::Dict, cons::Vector, stidxmap::Dict, iv, sol) + vs = vars(cons) + for v in vs + iscall(v) || continue + op = operation(v) + args = arguments(v) + issym(op) && length(args) == 1 || continue + newv = op(iv) + haskey(stidxmap, newv) || continue + subs[v] = sol(args[1])[stidxmap[newv]] + end +end + +""" + $(TYPEDSIGNATURES) + +Generate the boundary condition function for a [`System`](@ref) given the state vector `u0`, +the indexes of `u0` to consider as hard constraints `u0_idxs` and the initial time `t0`. + +# Keyword Arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_boundary_conditions(sys::System, u0, u0_idxs, t0; expression = Val{true}, + wrap_gfw = Val{false}, eval_expression = false, eval_module = @__MODULE__, + 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)]) + + # sol = get_bv_solution_symbol(ns) + + cons = [con.lhs - con.rhs for con in constraints(sys)] + # conssubs = Dict() + # get_constraint_unknown_subs!(conssubs, cons, stidxmap, iv, sol) + # cons = map(x -> fast_substitute(x, conssubs), cons) + + init_conds = Any[] + for i in u0_idxs + expr = BVP_SOLUTION(t0)[i] - u0[i] + push!(init_conds, expr) + end + + exprs = vcat(init_conds, cons) + _p = reorder_parameters(sys, ps) + + res = build_function_wrapper(sys, exprs, _p..., iv; output_type = Array, + p_start = 1, histfn = (p, t) -> BVP_SOLUTION(t), + histfn_symbolic = BVP_SOLUTION, wrap_delays = true, kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, 3, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Generate the cost function for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_cost(sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + obj = cost(sys) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + + if is_time_dependent(sys) + wrap_delays = true + p_start = 1 + p_end = length(ps) + args = (ps..., get_iv(sys)) + nargs = 3 + else + wrap_delays = false + p_start = 2 + p_end = length(ps) + 1 + args = (dvs, ps...) + nargs = 2 + end + res = build_function_wrapper( + sys, obj, args...; expression = Val{true}, p_start, p_end, wrap_delays, + histfn = (p, t) -> BVP_SOLUTION(t), histfn_symbolic = BVP_SOLUTION, kwargs...) + if expression == Val{true} + return res + end + f_oop = eval_or_rgf(res; eval_expression, eval_module) + return maybe_compile_function( + expression, wrap_gfw, (2, nargs, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the gradient of the consolidated cost of `sys` with respect to the unknowns. +`simplify` is forwarded to `Symbolics.gradient`. +""" +function calculate_cost_gradient(sys::System; simplify = false) + obj = cost(sys) + dvs = unknowns(sys) + return Symbolics.gradient(obj, dvs; simplify) +end + +""" + $(TYPEDSIGNATURES) + +Generate the gradient of the cost function with respect to unknowns for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`: Forwarded to [`calculate_cost_gradient`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_cost_gradient( + sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, simplify = false, kwargs...) + obj = cost(sys) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + exprs = calculate_cost_gradient(sys; simplify) + res = build_function_wrapper(sys, exprs, dvs, ps...; expression = Val{true}, kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, 2, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the hessian of the consolidated cost of `sys` with respect to the unknowns. +`simplify` is forwarded to `Symbolics.hessian`. `sparse` controls whether a sparse +matrix is returned. +""" +function calculate_cost_hessian(sys::System; sparse = false, simplify = false) + obj = cost(sys) + dvs = unknowns(sys) + if sparse + exprs = Symbolics.sparsehessian(obj, dvs; simplify)::AbstractSparseArray + sparsity = similar(exprs, Float64) + else + exprs = Symbolics.hessian(obj, dvs; simplify) + end +end + +""" + $(TYPEDSIGNATURES) + +Return the sparsity pattern for the hessian of the cost function of `sys`. +""" +function cost_hessian_sparsity(sys::System) + return similar(calculate_cost_hessian(sys; sparse = true), Float64) +end + +""" + $(TYPEDSIGNATURES) + +Generate the hessian of the cost function for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_cost_hessian`](@ref). +- `return_sparsity`: Whether to also return the sparsity pattern of the hessian as the + second return value. + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_cost_hessian( + sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, simplify = false, + sparse = false, return_sparsity = false, kwargs...) + obj = cost(sys) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + sparsity = nothing + exprs = calculate_cost_hessian(sys; sparse, simplify) + if sparse + sparsity = similar(exprs, Float64) + end + res = build_function_wrapper(sys, exprs, dvs, ps...; expression = Val{true}, kwargs...) + fn = maybe_compile_function( + expression, wrap_gfw, (2, 2, is_split(sys)), res; eval_expression, eval_module) + + return return_sparsity ? (fn, sparsity) : fn +end + +function canonical_constraints(sys::System) + return map(constraints(sys)) do cstr + Symbolics.canonical_form(cstr).lhs + end +end + +""" + $(TYPEDSIGNATURES) + +Generate the constraint function for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_cons(sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + cons = canonical_constraints(sys) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + res = build_function_wrapper(sys, cons, dvs, ps...; expression = Val{true}, kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, 2, is_split(sys)), res; eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Return the jacobian of the constraints of `sys` with respect to unknowns. + +# Keyword arguments + +- `simplify`, `sparse`: Forwarded to `Symbolics.jacobian`. +- `return_sparsity`: Whether to also return the sparsity pattern of the jacobian. +""" +function calculate_constraint_jacobian(sys::System; simplify = false, sparse = false, + return_sparsity = false) + cons = canonical_constraints(sys) + dvs = unknowns(sys) + sparsity = nothing + if sparse + jac = Symbolics.sparsejacobian(cons, dvs; simplify)::AbstractSparseArray + sparsity = similar(jac, Float64) + else + jac = Symbolics.jacobian(cons, dvs; simplify) + end + return return_sparsity ? (jac, sparsity) : jac +end + +""" + $(TYPEDSIGNATURES) + +Generate the jacobian of the constraint function for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_constraint_jacobian`](@ref). +- `return_sparsity`: Whether to also return the sparsity pattern of the jacobian as the + second return value. + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_constraint_jacobian( + sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, return_sparsity = false, + simplify = false, sparse = false, kwargs...) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + jac, + sparsity = calculate_constraint_jacobian( + sys; simplify, sparse, return_sparsity = true) + res = build_function_wrapper(sys, jac, dvs, ps...; expression = Val{true}, kwargs...) + fn = maybe_compile_function( + expression, wrap_gfw, (2, 2, is_split(sys)), res; eval_expression, eval_module) + return return_sparsity ? (fn, sparsity) : fn +end + +""" + $(TYPEDSIGNATURES) + +Return the hessian of the constraints of `sys` with respect to unknowns. + +# Keyword arguments + +- `simplify`, `sparse`: Forwarded to `Symbolics.hessian`. +- `return_sparsity`: Whether to also return the sparsity pattern of the hessian. +""" +function calculate_constraint_hessian( + sys::System; simplify = false, sparse = false, return_sparsity = false) + cons = canonical_constraints(sys) + dvs = unknowns(sys) + sparsity = nothing + if sparse + hess = map(cons) do cstr + Symbolics.sparsehessian(cstr, dvs; simplify)::AbstractSparseArray + end + sparsity = similar.(hess, Float64) + else + hess = [Symbolics.hessian(cstr, dvs; simplify) for cstr in cons] + end + return return_sparsity ? (hess, sparsity) : hess +end + +""" + $(TYPEDSIGNATURES) + +Generate the hessian of the constraint function for a [`System`](@ref). + +# Keyword Arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_constraint_hessian`](@ref). +- `return_sparsity`: Whether to also return the sparsity pattern of the hessian as the + second return value. + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_constraint_hessian( + sys::System; expression = Val{true}, wrap_gfw = Val{false}, + eval_expression = false, eval_module = @__MODULE__, return_sparsity = false, + simplify = false, sparse = false, kwargs...) + dvs = unknowns(sys) + ps = reorder_parameters(sys) + hess, + sparsity = calculate_constraint_hessian( + sys; simplify, sparse, return_sparsity = true) + res = build_function_wrapper(sys, hess, dvs, ps...; expression = Val{true}, kwargs...) + fn = maybe_compile_function( + expression, wrap_gfw, (2, 2, is_split(sys)), res; eval_expression, eval_module) + return return_sparsity ? (fn, sparsity) : fn +end + +""" + $(TYPEDSIGNATURES) + +Calculate the jacobian of the equations of `sys` with respect to the inputs. + +# Keyword arguments + +- `simplify`, `sparse`: Forwarded to `Symbolics.jacobian`. +""" +function calculate_control_jacobian(sys::AbstractSystem; + sparse = false, simplify = false) + rhs = [eq.rhs for eq in full_equations(sys)] + ctrls = unbound_inputs(sys) + + if sparse + jac = sparsejacobian(rhs, ctrls, simplify = simplify) + else + jac = jacobian(rhs, ctrls, simplify = simplify) + end + + return jac +end + +""" + $(TYPEDSIGNATURES) + +Generate the jacobian function of the equations of `sys` with respect to the inputs. + +# Keyword arguments + +$GENERATE_X_KWARGS +- `simplify`, `sparse`: Forwarded to [`calculate_constraint_hessian`](@ref). + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_control_jacobian(sys::AbstractSystem; + expression = Val{true}, wrap_gfw = Val{false}, eval_expression = false, + eval_module = @__MODULE__, simplify = false, sparse = false, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + jac = calculate_control_jacobian(sys; simplify = simplify, sparse = sparse) + p = reorder_parameters(sys, ps) + res = build_function_wrapper(sys, jac, dvs, p..., get_iv(sys); kwargs...) + return maybe_compile_function( + expression, wrap_gfw, (2, 3, is_split(sys)), res; eval_expression, eval_module) +end + +function generate_rate_function(js::System, rate) + p = reorder_parameters(js) + build_function_wrapper(js, rate, unknowns(js), p..., + get_iv(js), + expression = Val{true}) +end + +function generate_affect_function(js::System, affect; kwargs...) + compile_equational_affect(affect, js; checkvars = false, kwargs...) +end + +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 = [unknowntoid[var] for var in outputvars] + affect = generate_affect_function(js, vrj.affect!; eval_expression, eval_module) + VariableRateJump(rate, affect; save_positions = vrj.save_positions) +end + +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 = [unknowntoid[var] for var in outputvars] + affect = generate_affect_function(js, crj.affect!; eval_expression, eval_module) + ConstantRateJump(rate, affect) +end + +# 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 + +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, unknowntoid[spec] => stoich) + end + end + sort!(rs) + rs +end + +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 + +""" + 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. +- `wrap_delays = is_dde(sys)`: Whether to add an argument for the history function and use + it to calculate all delayed variables. + +## 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 systems of nonlinear equations 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, 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, + wrap_delays = is_dde(sys)) + # TODO: cleanup + 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, + wrap_delays) + 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 + wrap_delays, length(args) - length(ps) + 1 + wrap_delays, 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 + wrap_delays, length(args) - length(ps) + 1 + wrap_delays, is_split(sys))}( + f, nothing) + return f + end +end + +""" + $(TYPEDSIGNATURES) + +Return matrix `A` and vector `b` such that the system `sys` can be represented as +`A * x = b` where `x` is `unknowns(sys)`. Errors if the system is not affine. + +# Keyword arguments + +- `sparse`: return a sparse `A`. +""" +function calculate_A_b(sys::System; sparse = false) + rhss = [eq.rhs for eq in full_equations(sys)] + dvs = unknowns(sys) + + A = Matrix{Any}(undef, length(rhss), length(dvs)) + b = Vector{Any}(undef, length(rhss)) + for (i, rhs) in enumerate(rhss) + # mtkcompile makes this `0 ~ rhs` which typically ends up giving + # unknowns negative coefficients. If given the equations `A * x ~ b` + # it will simplify to `0 ~ b - A * x`. Thus this negation usually leads + # to more comprehensible user API. + resid = -rhs + for (j, var) in enumerate(dvs) + p, q, islinear = Symbolics.linear_expansion(resid, var) + if !islinear + throw(ArgumentError("System is not linear. Equation $((0 ~ rhs)) is not linear in unknown $var.")) + end + A[i, j] = p + resid = q + end + # negate beucause `resid` is the residual on the LHS + b[i] = -resid + end + + @assert all(Base.Fix1(isassigned, A), eachindex(A)) + @assert all(Base.Fix1(isassigned, A), eachindex(b)) + + if sparse + A = SparseArrays.sparse(A) + end + return A, b +end + +""" + $(TYPEDSIGNATURES) + +Given a system `sys` and the `A` from [`calculate_A_b`](@ref) generate the function that +updates `A` given the parameter object. + +# Keyword arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_update_A(sys::System, A::AbstractMatrix; expression = Val{true}, + wrap_gfw = Val{false}, eval_expression = false, eval_module = @__MODULE__, kwargs...) + ps = reorder_parameters(sys) + + res = build_function_wrapper(sys, A, ps...; p_start = 1, expression = Val{true}, + similarto = typeof(A), kwargs...) + return maybe_compile_function(expression, wrap_gfw, (1, 1, is_split(sys)), res; + eval_expression, eval_module) +end + +""" + $(TYPEDSIGNATURES) + +Given a system `sys` and the `b` from [`calculate_A_b`](@ref) generate the function that +updates `b` given the parameter object. + +# Keyword arguments + +$GENERATE_X_KWARGS + +All other keyword arguments are forwarded to [`build_function_wrapper`](@ref). +""" +function generate_update_b(sys::System, b::AbstractVector; expression = Val{true}, + wrap_gfw = Val{false}, eval_expression = false, eval_module = @__MODULE__, kwargs...) + ps = reorder_parameters(sys) + + res = build_function_wrapper(sys, b, ps...; p_start = 1, expression = Val{true}, + similarto = typeof(b), kwargs...) + return maybe_compile_function(expression, wrap_gfw, (1, 1, is_split(sys)), res; + eval_expression, eval_module) +end diff --git a/src/systems/codegen_utils.jl b/src/systems/codegen_utils.jl new file mode 100644 index 0000000000..dbbd7f85a8 --- /dev/null +++ b/src/systems/codegen_utils.jl @@ -0,0 +1,419 @@ +""" + $(TYPEDSIGNATURES) + +Given a function expression `expr`, return a callable version of it. + +# Keyword arguments +- `eval_expression`: Whether to use `eval` to make `expr` callable. If `false`, uses + RuntimeGeneratedFunctions.jl. +- `eval_module`: The module to `eval` the expression `expr` in. If `!eval_expression`, + this is the cache and context module for the `RuntimeGeneratedFunction`. +""" +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 + +eval_or_rgf(::Nothing; kws...) = nothing + +""" + $(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) + +Check if the variable `var` is a delayed variable, where `iv` is the independent +variable. +""" +function isdelay(var, iv) + iv === nothing && return false + if iscall(var) && ModelingToolkit.isoperator(var, Differential) + return isdelay(arguments(var)[1], iv) + end + isvariable(var) || return false + isparameter(var) && return false + if iscall(var) && !ModelingToolkit.isoperator(var, Symbolics.Operator) + args = arguments(var) + length(args) == 1 || return false + arg = args[1] + isequal(arg, iv) && return false + iscall(arg) || return true + issym(operation(arg)) && !iscalledparameter(arg) && return false + return true + end + return false +end + +""" +The argument of generated functions corresponding to the history function. +""" +const DDE_HISTORY_FUN = Sym{Symbolics.FnType{Tuple{Any, <:Real}, Vector{Real}}}(:___history___) +const BVP_SOLUTION = Sym{Symbolics.FnType{Tuple{<:Real}, Vector{Real}}}(:__sol__) + +""" + $(TYPEDSIGNATURES) + +Turn delayed unknowns in `eqs` into calls to `DDE_HISTORY_FUNCTION`. + +# Arguments + +- `sys`: The system of DDEs. +- `eqs`: The equations to convert. + +# Keyword Arguments + +- `param_arg`: The name of the variable containing the parameter object. +- `histfn`: The history function to use for codegen, called as `histfn(p, t)` +""" +function delay_to_function( + sys::AbstractSystem, eqs = full_equations(sys); param_arg = MTKPARAMETERS_ARG, histfn = DDE_HISTORY_FUN) + delay_to_function(eqs, + get_iv(sys), + Dict{Any, Int}(operation(s) => i for (i, s) in enumerate(unknowns(sys))), + parameters(sys), + histfn; param_arg) +end +function delay_to_function(eqs::Vector, iv, sts, ps, h; param_arg = MTKPARAMETERS_ARG) + delay_to_function.(eqs, (iv,), (sts,), (ps,), (h,); param_arg) +end +function delay_to_function(eq::Equation, iv, sts, ps, h; param_arg = MTKPARAMETERS_ARG) + delay_to_function(eq.lhs, iv, sts, ps, h; param_arg) ~ delay_to_function( + eq.rhs, iv, sts, ps, h; param_arg) +end +function delay_to_function(expr, iv, sts, ps, h; param_arg = MTKPARAMETERS_ARG) + if isdelay(expr, iv) + v = operation(expr) + time = arguments(expr)[1] + idx = sts[v] + return term(getindex, h(param_arg, time), idx, type = Real) + elseif iscall(expr) + return maketerm(typeof(expr), + operation(expr), + map(x -> delay_to_function(x, iv, sts, ps, h; param_arg), arguments(expr)), + metadata(expr)) + else + return expr + end +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`. +- `histfn`: The history function to use for transforming delayed terms. For any delayed + term `x(expr)`, this is called as `histfn(p, expr)` where `p` is the parameter object. +- `histfn_symbolic`: The symbolic history function variable to add as an argument to the + generated function. +- `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), histfn = DDE_HISTORY_FUN, histfn_symbolic = histfn, 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 + param_arg = is_split(sys) ? MTKPARAMETERS_ARG : generated_argument_name(p_start) + obs = map(obs) do eq + delay_to_function(sys, eq; param_arg, histfn) + end + expr = delay_to_function(sys, expr; param_arg, histfn) + # add extra argument + args = (args[1:(p_start - 1)]..., histfn_symbolic, args[p_start:end]...) + p_start += 1 + p_end += 1 + end + pdeps = get_parameter_dependencies(sys) + + # 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((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] + # 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 GeneratedFunctionWrapper{P}(::Type{Val{true}}, foop, fiip; kwargs...) where {P} + :($(GeneratedFunctionWrapper{P})($foop, $fiip)) +end + +function GeneratedFunctionWrapper{P}(::Type{Val{false}}, foop, fiip; kws...) where {P} + GeneratedFunctionWrapper{P}(eval_or_rgf(foop; kws...), eval_or_rgf(fiip; kws...)) +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 + +""" + $(TYPEDSIGNATURES) + +Optionally compile a method and optionally wrap it in a `GeneratedFunctionWrapper` on the +basis of `expression` `wrap_gfw`, both of type `Union{Type{Val{true}}, Type{Val{false}}}`. +`gfw_args` is the first type parameter of `GeneratedFunctionWrapper`. `f` is a tuple of +function expressions of the form `(oop, iip)` or a single out-of-place function expression. +Keyword arguments are forwarded to `eval_or_rgf`. +""" +function maybe_compile_function(expression, wrap_gfw::Type{Val{true}}, + gfw_args::Tuple{Int, Int, Bool}, f::NTuple{2, Expr}; kwargs...) + GeneratedFunctionWrapper{gfw_args}(expression, f...; kwargs...) +end + +function maybe_compile_function(expression::Type{Val{false}}, wrap_gfw::Type{Val{false}}, + gfw_args::Tuple{Int, Int, Bool}, f::NTuple{2, Expr}; kwargs...) + eval_or_rgf.(f; kwargs...) +end + +function maybe_compile_function(expression::Type{Val{true}}, wrap_gfw::Type{Val{false}}, + gfw_args::Tuple{Int, Int, Bool}, f::Union{Expr, NTuple{2, Expr}}; kwargs...) + return f +end + +function maybe_compile_function(expression, wrap_gfw::Type{Val{true}}, + gfw_args::Tuple{Int, Int, Bool}, f::Expr; kwargs...) + GeneratedFunctionWrapper{gfw_args}(expression, f, nothing; kwargs...) +end + +function maybe_compile_function(expression::Type{Val{false}}, wrap_gfw::Type{Val{false}}, + gfw_args::Tuple{Int, Int, Bool}, f::Expr; kwargs...) + eval_or_rgf(f; kwargs...) +end diff --git a/src/systems/connectiongraph.jl b/src/systems/connectiongraph.jl new file mode 100644 index 0000000000..5c5e8716c6 --- /dev/null +++ b/src/systems/connectiongraph.jl @@ -0,0 +1,489 @@ +""" + $(TYPEDEF) + +A vertex in the connection hypergraph. + +## Fields + +$(TYPEDFIELDS) +""" +struct ConnectionVertex + """ + The name of the variable or subsystem represented by this connection vertex. Stored as + a list of names denoting the path from the root system to this variable/subsystem. The + name of the root system is not included. + """ + name::Vector{Symbol} + """ + Boolean indicating whether this is an outside connector. + """ + isouter::Bool + """ + A type indicating what kind of connector it is. One of: + - `Stream` + - `Equality` + - `Flow` + - `InputVar` + - `OutputVar` + """ + type::DataType + """ + The cached hash value of this struct. Should never be passed manually. + """ + hash::UInt +end + +""" + $(TYPEDSIGNATURES) + +Create a `ConnectionVertex` given +- `namespace`: the path from the root to the variable/subsystem. Does not include the root + system. +- `var`: the variable/subsystem. + +`isouter` is the same as the struct field. Uses `get_connection_type` to find the type to +use for this connection. +""" +function ConnectionVertex( + namespace::Vector{Symbol}, var::Union{BasicSymbolic, AbstractSystem}, isouter::Bool) + if var isa BasicSymbolic + name = getname(var) + else + name = nameof(var) + end + var_ns = namespace_hierarchy(name) + type = get_connection_type(var) + name = vcat(namespace, var_ns) + return ConnectionVertex(name, isouter, type; alias = true) +end + +""" + $(TYPEDSIGNATURES) + +Create a connection vertex for the given path. Typically used for domain connection graphs, +where the type of connection doesn't matter. Uses `isouter = true` and `type = Flow`. +""" +function ConnectionVertex(name::Vector{Symbol}) + return ConnectionVertex(name, true, Flow) +end + +""" + $(TYPEDSIGNATURES) + +Create a connection vertex for the given path `name` using the provided `isouter` and +`type`. `alias` denotes whether `name` can be stored by this vertex without copying. +""" +function ConnectionVertex( + name::Vector{Symbol}, isouter::Bool, type::DataType; alias = false) + if !alias + name = copy(name) + end + h = foldr(hash, name; init = zero(UInt)) + h = hash(type, h) + h = hash(isouter, h) + return ConnectionVertex(name, isouter, type, h) +end + +Base.hash(x::ConnectionVertex, h::UInt) = h ⊻ x.hash + +function Base.:(==)(a::ConnectionVertex, b::ConnectionVertex) + length(a.name) == length(b.name) || return false + for (x, y) in zip(a.name, b.name) + x == y || return false + end + a.isouter == b.isouter || return false + a.type == b.type || return false + if a.hash != b.hash + error(""" + This should never happen. Please open an issue in ModelingToolkit.jl with an MWE. + """) + end + return true +end + +function Base.show(io::IO, vert::ConnectionVertex) + for name in @view(vert.name[1:(end - 1)]) + print(io, name, ".") + end + print(io, vert.name[end], "::", vert.isouter ? "outer" : "inner") +end + +""" + $(TYPEDEF) + +A hypergraph used to represent the connection sets in a system. Vertices of this graph are +of type `ConnectionVertex`. The connected components of a connection graph are the merged +connection sets. + +## Fields + +$(TYPEDFIELDS) +""" +struct ConnectionGraph + """ + Mapping from vertices to their integer ID. + """ + labels::Dict{ConnectionVertex, Int} + """ + Reverse mapping from integer ID to vertices. + """ + invmap::Vector{ConnectionVertex} + """ + Core data structure for storing the hypergraph. Each hyperedge is a source vertex and + has bipartite edges to the connection vertices it is incident on. + """ + graph::BipartiteGraph{Int, Nothing} +end + +""" + $(TYPEDSIGNATURES) + +Create an empty `ConnectionGraph`. +""" +function ConnectionGraph() + graph = BipartiteGraph(0, 0, Val(true)) + return ConnectionGraph(Dict{ConnectionVertex, Int}(), ConnectionVertex[], graph) +end + +function Base.show(io::IO, graph::ConnectionGraph) + printstyled(io, get(io, :cgraph_name, "ConnectionGraph"); color = :blue, bold = true) + println(io, " with ", length(graph.labels), + " vertices and ", nsrcs(graph.graph), " hyperedges") + compact = get(io, :compact, false) + for edge_i in 𝑠vertices(graph.graph) + if compact && edge_i > 5 + println(io, "⋮") + break + end + edge_idxs = 𝑠neighbors(graph.graph, edge_i) + type = graph.invmap[edge_idxs[1]].type + if type <: Union{InputVar, OutputVar} + type = "Causal" + elseif type == Equality + # otherwise it prints `ModelingToolkit.Equality` + type = "Equality" + end + printstyled(io, " ", type; bold = true, color = :yellow) + print(io, "<") + for vi in @view(edge_idxs[1:(end - 1)]) + print(io, graph.invmap[vi], ", ") + end + println(io, graph.invmap[edge_idxs[end]], ">") + end +end + +""" + $(TYPEDSIGNATURES) + +Add the given vertex to the connection graph. Return the integer ID of the added vertex. +No-op if the vertex already exists. +""" +function Graphs.add_vertex!(graph::ConnectionGraph, dst::ConnectionVertex) + j = get(graph.labels, dst, 0) + iszero(j) || return j + j = Graphs.add_vertex!(graph.graph, DST) + push!(graph.invmap, dst) + @assert length(graph.invmap) == j + graph.labels[dst] = j + return j +end + +const ConnectionGraphEdge = Union{Vector{ConnectionVertex}, Tuple{Vararg{ConnectionVertex}}} + +""" + $(TYPEDSIGNATURES) + +Add the given hyperedge to the connection graph. Adds all vertices in the given edge if +they do not exist. Returns the integer ID of the added edge. +""" +function Graphs.add_edge!(graph::ConnectionGraph, src::ConnectionGraphEdge) + i = Graphs.add_vertex!(graph.graph, SRC) + for vert in src + j = Graphs.add_vertex!(graph, vert) + Graphs.add_edge!(graph.graph, i, j) + end + return i +end + +""" + $(TYPEDEF) + +A connection state is a combination of two `ConnectionGraph`s, one for the connection sets +and the other for the domain network. The domain network is a graph of connected +subsystems. The connected components of the domain network denote connected domains that +share properties. +""" +abstract type AbstractConnectionState end + +""" + $(TYPEDEF) + +The most trivial `AbstractConnectionState`. + +## Fields + +$(TYPEDFIELDS) +""" +struct ConnectionState <: AbstractConnectionState + """ + The connection graph for connection sets. + """ + connection_graph::ConnectionGraph + """ + The connection graph for the domain network. + """ + domain_connection_graph::ConnectionGraph +end + +""" + $(TYPEDSIGNATURES) + +Create an empty `ConnectionState` with empty graphs. +""" +ConnectionState() = ConnectionState(ConnectionGraph(), ConnectionGraph()) + +function Base.show(io::IO, state::AbstractConnectionState) + printstyled(io, typeof(state); bold = true, color = :green) + println(io, " comprising of") + ctx1 = IOContext(io, :cgraph_name => "Connection Network", :compact => true) + show(ctx1, state.connection_graph) + println(io) + println(io, "And") + println(io) + ctx2 = IOContext(io, :cgraph_name => "Domain Network", :compact => true) + show(ctx2, state.domain_connection_graph) +end + +""" + $(TYPEDSIGNATURES) + +Add the given edge to the connection network. Does not affect the domain network. +""" +function add_connection_edge!(state::ConnectionState, edge::ConnectionGraphEdge) + Graphs.add_edge!(state.connection_graph, edge) + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Add the given edge to the domain network. Does not affect the connection network. +""" +function add_domain_connection_edge!(state::ConnectionState, edge::ConnectionGraphEdge) + Graphs.add_edge!(state.domain_connection_graph, edge) + return nothing +end + +""" + $(TYPEDSIGNATURES) + +An `AbstractConnectionState` that is used to remove edges from the main connection state. +Transformed analysis points add to the list of removed connections, and the list of removed +connections builds this connection state. This allows ensuring that the removed connections +are not present in the final network even if they are connected multiple times. This state +also tracks which vertex in each hyperedge is the input, since the removed connections are +causal. + +## Fields + +$(TYPEDFIELDS) +""" +struct NegativeConnectionState <: AbstractConnectionState + """ + The connection graph for connection sets. + """ + connection_graph::ConnectionGraph + """ + The connection graph for the domain network. + """ + domain_connection_graph::ConnectionGraph + """ + Mapping from the integer ID of each hyperedge in `connection_graph` to the integer ID + of the "input" in that hyperedge. + """ + connection_hyperedge_inputs::Vector{Int} + """ + Mapping from the integer ID of each hyperedge in `domain_connection_graph` to the + integer ID of the "input" in that hyperedge. + """ + domain_hyperedge_inputs::Vector{Int} +end + +""" + $(TYPEDSIGNATURES) + +Create an empty `NegativeConnectionState` with empty graphs. +""" +function NegativeConnectionState() + NegativeConnectionState(ConnectionGraph(), ConnectionGraph(), Int[], Int[]) +end + +""" + $(TYPEDSIGNATURES) + +Add the given edge to the connection network. Does not affect the domain network. Assumes +that the first vertex in `edge` is the input. +""" +function add_connection_edge!(state::NegativeConnectionState, edge::ConnectionGraphEdge) + i = Graphs.add_edge!(state.connection_graph, edge) + j = state.connection_graph.labels[first(edge)] + push!(state.connection_hyperedge_inputs, j) + @assert length(state.connection_hyperedge_inputs) == i + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Add the given edge to the domain network. Does not affect the connection network. Assumes +that the first vertex in `edge` is the input. +""" +function add_domain_connection_edge!( + state::NegativeConnectionState, edge::ConnectionGraphEdge) + i = Graphs.add_edge!(state.domain_connection_graph, edge) + j = state.domain_connection_graph.labels[first(edge)] + push!(state.domain_hyperedge_inputs, j) + @assert length(state.domain_hyperedge_inputs) == i + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Modify `graph` such that no hyperedge is a superset of any (causal) hyerpedge in `neg_graph`. + +For each "negative" hyperedge in `neg_graph` with integer ID `neg_edge_id`, +`neg_edge_inputs[neg_edge_id]` denotes the vertex the negative hyperedge is incident on +which is considered the input of the negative hyperedge. If any hyperedge in `graph` +contains this input as well as at least one other vertex in the negative hyperedge, all +vertices common between the hyperedge and negative hyperedge are removed from the hyperedge. + +`graph` is modified in-place. Note that `graph` and `neg_graph` may not have the same +ordering of vertices, and thus all comparisons should be done by comparing the +`ConnectionVertex`. +""" +function remove_negative_connections!( + graph::ConnectionGraph, neg_graph::ConnectionGraph, neg_edge_inputs::Vector{Int}) + # _i means index in neg_graph + # _j means index in graph + + # get all edges in `graph` as bitsets + graph_hyperedgesets = map(𝑠vertices(graph.graph)) do edge_j + hyperedge_jdxs = 𝑠neighbors(graph.graph, edge_j) + return BitSet(hyperedge_jdxs) + end + + # indexes in each hyperedge to remove + idxs_to_rm = [BitSet() for _ in graph_hyperedgesets] + # iterate over negative edges and the corresponding input vertex in each edge + for (input_i, edge_i) in zip(neg_edge_inputs, 𝑠vertices(neg_graph.graph)) + # get the hyperedge as integer indexes in `neg_graph` + neg_hyperedge_idxs = 𝑠neighbors(neg_graph.graph, edge_i) + # the hyperedge as `ConnectionVar`s + neg_hyperedge = map(Base.Fix1(getindex, neg_graph.invmap), neg_hyperedge_idxs) + # The hyperedge as integer indexes in `graph` + # *j*dxs. See what I did there? + neg_hyperedge_jdxs = map(cvar -> get(graph.labels, cvar, 0), neg_hyperedge) + # the edge to remove is between variables that aren't connected, so ignore it + if any(iszero, neg_hyperedge_jdxs) + continue + end + + # The input vertex as a `ConnectionVar` + input_v = neg_graph.invmap[input_i] + # The input vertex as an index in `graph` + input_j = graph.labels[input_v] + # Iterate over hyperedges in `graph` + for edge_j in 𝑠vertices(graph.graph) + # The bitset of nodes this edge is incident on + edgeset = graph_hyperedgesets[edge_j] + # the input must be in this hyperedge + input_j in edgeset || continue + # now, if any other vertex apart from this input is also in the hyperedge + # we remove all the indices in `neg_hyperedge_jdxs` also present in this + # hyperedge + + # should_rm tracks if any other vertex apart from `input_j` is in the hyperedge + should_rm = false + # iterate over the negative hyperedge + for var_j in neg_hyperedge_jdxs + var_j == input_j && continue + # check if the variable which is not `input_j` is in the hyperedge + should_rm |= var_j in edgeset + should_rm || continue + # if there is any other variable, start removing + push!(idxs_to_rm[edge_j], var_j) + end + end + end + + # for each edge and list of vertices to remove from the edge + for (edge_j, neg_vertices) in enumerate(idxs_to_rm) + for vert_j in neg_vertices + # remove those vertices + Graphs.rem_edge!(graph.graph, edge_j, vert_j) + end + end +end + +""" + $(TYPEDSIGNATURES) + +Remove negative hyperedges given by `neg_state` from the connection and domain networks of +`state`. +""" +function remove_negative_connections!( + state::ConnectionState, neg_state::NegativeConnectionState) + remove_negative_connections!(state.connection_graph, neg_state.connection_graph, + neg_state.connection_hyperedge_inputs) + remove_negative_connections!( + state.domain_connection_graph, neg_state.domain_connection_graph, + neg_state.domain_hyperedge_inputs) +end + +""" + $(TYPEDSIGNATURES) + +Return the merged connection sets in `graph` as a `Vector{Vector{ConnectionVertex}}`. These +are equivalent to the connected components of `graph`. +""" +function connectionsets(graph::ConnectionGraph) + bigraph = graph.graph + invmap = graph.invmap + + # union all of the hyperedges + disjoint_sets = IntDisjointSets(length(invmap)) + for edge_i in 𝑠vertices(bigraph) + hyperedge = 𝑠neighbors(bigraph, edge_i) + isempty(hyperedge) && continue + root, rest = Iterators.peel(hyperedge) + for vert in rest + union!(disjoint_sets, root, vert) + end + end + + # maps the root of a vertex in `disjoint_sets` to the index of the corresponding set + # in `vertex_sets` + root_to_set = Dict{Int, Int}() + vertex_sets = Vector{ConnectionVertex}[] + for (vert_i, vert) in enumerate(invmap) + root = find_root!(disjoint_sets, vert_i) + set_i = get!(root_to_set, root) do + push!(vertex_sets, ConnectionVertex[]) + return length(vertex_sets) + end + push!(vertex_sets[set_i], vert) + end + + return vertex_sets +end + +""" + $(TYPEDSIGNATURES) + +Return the connection sets of the connection graph and domain network. +""" +function connectionsets(state::ConnectionState) + return connectionsets(state.connection_graph), + connectionsets(state.domain_connection_graph) +end diff --git a/src/systems/connectors.jl b/src/systems/connectors.jl new file mode 100644 index 0000000000..f63c10e8a1 --- /dev/null +++ b/src/systems/connectors.jl @@ -0,0 +1,1094 @@ +""" + $(TYPEDEF) + +Struct used to represent a connection equation. A connection equation is an `Equation` +where the LHS is an empty `Connection(nothing)` and the RHS is a `Connection` containing +the connected connectors. + +For special types of connections, the LHS `Connection` can contain relevant metadata. +""" +struct Connection + systems::Any +end + +Base.broadcastable(x::Connection) = Ref(x) +Connection() = Connection(nothing) +Base.hash(c::Connection, seed::UInt) = hash(c.systems, (0xc80093537bdc1311 % UInt) ⊻ seed) +Symbolics.hide_lhs(_::Connection) = true + +""" + $(TYPEDSIGNATURES) + +Connect multiple connectors created via `@connector`. All connected connectors +must be unique. +""" +function connect(sys1::AbstractSystem, sys2::AbstractSystem, syss::AbstractSystem...) + syss = (sys1, sys2, syss...) + length(unique(nameof, syss)) == length(syss) || error("connect takes distinct systems!") + Equation(Connection(), Connection(syss)) # the RHS are connected systems +end + +const _debug_mode = Base.JLOptions().check_bounds == 1 + +function Base.show(io::IO, c::Connection) + print(io, "connect(") + if c.systems isa AbstractArray || c.systems isa Tuple + n = length(c.systems) + for (i, s) in enumerate(c.systems) + str = join(split(string(nameof(s)), NAMESPACE_SEPARATOR), '.') + print(io, str) + i != n && print(io, ", ") + end + end + print(io, ")") +end + +@latexrecipe function f(c::Connection) + index --> :subscript + return Expr(:call, :connect, map(nameof, c.systems)...) +end + +function Base.show(io::IO, ::MIME"text/latex", ap::Connection) + print(io, latexify(ap)) +end + +isconnection(_) = false +isconnection(_::Connection) = true + +""" + $(TYPEDSIGNATURES) + +Adds a domain only connection equation, through and across state equations are not generated. +""" +function domain_connect(sys1::AbstractSystem, sys2::AbstractSystem, syss::AbstractSystem...) + 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 + +""" + $(TYPEDSIGNATURES) + +Get the connection type of symbolic variable `s` from the `VariableConnectType` metadata. +Defaults to `Equality` if not present. +""" +function get_connection_type(s::Symbolic) + s = unwrap(s) + if iscall(s) && operation(s) === getindex + s = arguments(s)[1] + end + getmetadata(s, VariableConnectType, Equality) +end + +""" + $(TYPEDSIGNATURES) + +Mark a system constructor function as building a connector. For example, + +```julia +@connector function ElectricalPin(; name, v = nothing, i = nothing) + @variables begin + v(t) = v, [description = "Potential at the pin [V]"] + i(t) = i, [connect = Flow, description = "Current flowing into the pin [A]"] + end + return System(Equation[], t, [v, i], []; name) +end +``` + +Since connectors only declare variables, the equivalent shorthand syntax can also be used: + +```julia +@connector Pin begin + v(t), [description = "Potential at the pin [V]"] + i(t), [connect = Flow, description = "Current flowing into the pin [A]"] +end +``` + +ModelingToolkit systems are either components or connectors. Components define dynamics of +the model. Connectors are used to connect components together. See the +[Model building reference](@ref model_building_api) section of the documentation for more +information. + +See also: [`@component`](@ref). +""" +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 + +""" + $(TYPEDSIGNATURES) + +Return an `AbstractConnectorType` denoting the type of connector that `sys` is. +Domain connectors have a single `Flow` unknown. Stream connectors have a single +`Flow` variable and multiple `Stream` variables. Any other type of connector is +a "regular" connector. +""" +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 + +""" + $(TYPEDSIGNATURES) + +`instream` is used when modeling stream connections. It is only allowed to be used on +`Stream` variables. + +Refer to the [Connection semantics](@ref connect_semantics) section of the docs for more +information. +""" +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} + +function NonCausalVariableError(vars) + names = join(map(var -> " " * string(var), vars), "\n") + ArgumentError(""" + Only causal variables can be used in a `connect` statement. Each variable must be \ + either an input or an output. Mark a variable as input using the `[input = true]` \ + variable metadata or as an output using the `[output = true]` variable metadata. + + The following variables were found to be non-causal: + $names + """) +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 + non_causal_variables = filter(allvars) do var + !isinput(var) && !isoutput(var) + end + isempty(non_causal_variables) || throw(NonCausalVariableError(non_causal_variables)) +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 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 + +""" + $(METHODLIST) + +Add all `instream(..)` expressions to `set`. +""" +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) + +""" + $(TYPEDSIGNATURES) + +Return a function which checks whether the connector (system) passed to it is an outside +connector of `sys`. The function can also be given the name of a system as a `Symbol`. +""" +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)]) + isouter(parent_name) + end + function isouter(name::Symbol)::Bool + return name in outer_connectors + end + end +end + +@noinline function connection_error(ss) + error("Different types of connectors are in one connection statement: <$(map(nameof, ss))>") +end + +abstract type IsFrame end + +"Return true if the system is a 3D multibody frame, otherwise return false." +function isframe(sys) + getmetadata(sys, IsFrame, false) +end + +abstract type FrameOrientation end + +"Return orientation object of a multibody frame." +function ori(sys) + val = getmetadata(sys, FrameOrientation, nothing) + if val === nothing + error("System $(sys.name) does not have an orientation object.") + end +end + +""" +Connection type used in `ConnectionVertex` for a causal input variable. `I` is an object +that can be passed to `getindex` as an index denoting the index in the variable for +causal array variables. For non-array variables this should be `1`. +""" +abstract type InputVar{I} end +""" +Connection type used in `ConnectionVertex` for a causal output variable. `I` is an object +that can be passed to `getindex` as an index denoting the index in the variable for +causal array variables. For non-array variables this should be `1`. +""" +abstract type OutputVar{I} end + +""" + $(METHODLIST) + +Get the contained index in an `InputVar` or `OutputVar` type. +""" +index_from_type(::Type{InputVar{I}}) where {I} = I +index_from_type(::Type{OutputVar{I}}) where {I} = I + +""" + $(TYPEDSIGNATURES) + +Chain `getproperty` calls on sys in the order given by `names` and return the unwrapped +result. +""" +function iterative_getproperty(sys::AbstractSystem, names::AbstractVector{Symbol}) + # we don't want to namespace the first time + result = toggle_namespacing(sys, false) + for name in names + result = getproperty(result, name) + end + return unwrap(result) +end + +""" + $(TYPEDSIGNATURES) + +Return the variable/subsystem of `sys` referred to by vertex `vert`. +""" +function variable_from_vertex(sys::AbstractSystem, vert::ConnectionVertex) + value = iterative_getproperty(sys, vert.name) + value isa AbstractSystem && return value + vert.type <: Union{InputVar, OutputVar} || return value + # index possibly array causal variable + unwrap(wrap(value)[index_from_type(vert.type)]) +end + +""" + $(TYPEDSIGNATURES) + +Given `connected`, the list of connected variables/systems, generate the appropriate +connection sets and add them to `connection_state`. Update both the connection network and +domain network as necessary. `namespace` is the path from the root system to the system in +which the [`connect`](@ref) equation containing `connected` is located. `isouter` is the +function returned from [`generate_isouter`](@ref) for the system referred to by +`namespace`. + +`namespace` must not contain the name of the root system. +""" +function generate_connectionsets!(connection_state::AbstractConnectionState, + namespace::Vector{Symbol}, connected, isouter) + initial_len = length(namespace) + _generate_connectionsets!(connection_state, namespace, connected, isouter) + # Enforce postcondition as a sanity check that the namespacing is implemented correctly + length(namespace) == initial_len || throw(NotPossibleError()) + return nothing +end + +function _generate_connectionsets!(connection_state::AbstractConnectionState, + namespace::Vector{Symbol}, + connected_vars::Union{ + AbstractVector{SymbolicWithNameof}, Tuple{Vararg{SymbolicWithNameof}}}, + isouter) + # unwrap the `SymbolicWithNameof` into the contained symbolic variables. + connected_vars = map(x -> x.var, connected_vars) + _generate_connectionsets!(connection_state, namespace, connected_vars, isouter) +end + +function _generate_connectionsets!(connection_state::AbstractConnectionState, + namespace::Vector{Symbol}, + connected_vars::Union{ + AbstractVector{<:BasicSymbolic}, Tuple{Vararg{BasicSymbolic}}}, + isouter) + # NOTE: variable connections don't populate the domain network + + # wrap to be able to call `eachindex` on a non-array variable + representative = wrap(first(connected_vars)) + # all of them have the same size, but may have different axes/shape + # so we iterate over `eachindex(eachindex(..))` since that is identical for all + for sz_i in eachindex(eachindex(representative)) + hyperedge = map(connected_vars) do var + var = unwrap(var) + var_ns = namespace_hierarchy(getname(var)) + i = eachindex(wrap(var))[sz_i] + + is_input = isinput(var) + is_output = isoutput(var) + if is_input && is_output + names = join(string.(connected_vars), ", ") + throw(ArgumentError(""" + Variable $var in connection `connect($names)` is both input and output. + """)) + elseif is_input + type = InputVar{i} + elseif is_output + type = OutputVar{i} + else + names = join(string.(connected_vars), ", ") + throw(ArgumentError(""" + Variable $var in connection `connect($names)` is neither input nor output. + """)) + end + + return ConnectionVertex( + [namespace; var_ns], length(var_ns) == 1 || isouter(var_ns[1]), type) + end + add_connection_edge!(connection_state, hyperedge) + + # Removed analysis points generate causal connections in the negative graph. These + # should also remove `Equality` connections involving the same variables, so also + # add an `Equality` variant of the edge. + if connection_state isa NegativeConnectionState + hyperedge = map(hyperedge) do cvert + ConnectionVertex(cvert.name, cvert.isouter, Equality) + end + add_connection_edge!(connection_state, hyperedge) + end + end +end + +function _generate_connectionsets!(connection_state::AbstractConnectionState, + namespace::Vector{Symbol}, + systems::Union{AbstractVector{<:AbstractSystem}, Tuple{Vararg{AbstractSystem}}}, + isouter) + regular_systems = System[] + domain_system = nothing + for s in systems + if is_domain_connector(s) + if domain_system === nothing + domain_system = s + else + names = join(map(string ∘ nameof, systems), ",") + error("connect($names) contains multiple source domain connectors. There can only be one!") + end + else + push!(regular_systems, s) + end + end + + @assert !isempty(regular_systems) + + systems = regular_systems + # There is a domain being connected here. In such a case, we only connect the + # flow variable common between the domain setter and all other connectors in the + # normal connection graph. The domain graph connects all these subsystems. + if domain_system !== nothing + hyperedge = ConnectionVertex[] + domain_hyperedge = ConnectionVertex[] + sizehint!(hyperedge, length(systems) + 1) + sizehint!(domain_hyperedge, length(systems) + 1) + + dv = only(unknowns(domain_system)) + push!(namespace, nameof(domain_system)) + dv_vertex = ConnectionVertex(namespace, dv, false) + domain_vertex = ConnectionVertex(namespace) + pop!(namespace) + + push!(domain_hyperedge, domain_vertex) + push!(hyperedge, dv_vertex) + + for (i, sys) in enumerate(systems) + sts = unknowns(sys) + sys_is_outer = isouter(sys) + + # add this system to the namespace so all vertices created from its unknowns + # are properly namespaced + sysname = nameof(sys) + sys_ns = namespace_hierarchy(sysname) + append!(namespace, sys_ns) + for v in sts + vtype = get_connection_type(v) + # ignore all non-flow vertices in connectors + (vtype === Flow && isequal(v, dv)) || continue + + vertex = ConnectionVertex(namespace, v, sys_is_outer) + # vertices in the domain graph are systems with isouter=true and type=Flow + sys_vertex = ConnectionVertex(namespace) + push!(hyperedge, vertex) + push!(domain_hyperedge, sys_vertex) + end + # remember to remove the added namespace! + foreach(_ -> pop!(namespace), sys_ns) + end + @assert length(hyperedge) > 1 + @assert length(domain_hyperedge) == length(hyperedge) + + add_connection_edge!(connection_state, hyperedge) + add_domain_connection_edge!(connection_state, domain_hyperedge) + return + end + sys1 = first(systems) + sys1_dvs = unknowns(sys1) + # Add 9 orientation variables if connection is between multibody frames + if isframe(sys1) # Multibody + O = ori(sys1) + orientation_vars = Symbolics.unwrap.(collect(vec(O.R))) + sys1_dvs = [sys1_dvs; orientation_vars] + end + sys1_dvs_set = Set(sys1_dvs) + num_unknowns = length(sys1_dvs) + + # We first build sets of all vertices that are connected together + var_sets = [ConnectionVertex[] for _ in 1:num_unknowns] + domain_hyperedge = ConnectionVertex[] + for (i, sys) in enumerate(systems) + unknown_vars = unknowns(sys) + # Add 9 orientation variables if connection is between multibody frames + if isframe(sys) # Multibody + O = ori(sys) + orientation_vars = Symbolics.unwrap.(vec(O.R)) + unknown_vars = [unknown_vars; orientation_vars] + end + # Error if any subsequent systems do not have the same number of unknowns + # or have unknowns not in the others. + if i != 1 && + (num_unknowns != length(unknown_vars) || any(!in(sys1_dvs_set), unknown_vars)) + connection_error(systems) + end + # add this system to the namespace so all vertices created from its unknowns + # are properly namespaced + sysname = nameof(sys) + sys_ns = namespace_hierarchy(sysname) + append!(namespace, sys_ns) + sys_is_outer = isouter(sys) + for (j, v) in enumerate(unknown_vars) + push!(var_sets[j], ConnectionVertex(namespace, v, sys_is_outer)) + end + domain_vertex = ConnectionVertex(namespace) + push!(domain_hyperedge, domain_vertex) + # remember to remove the added namespace! + foreach(_ -> pop!(namespace), sys_ns) + end + for var_set in var_sets + # all connected variables should have the same type + if !allequal(Iterators.map(cvert -> cvert.type, var_set)) + connection_error(systems) + end + # add edges + add_connection_edge!(connection_state, var_set) + end + add_domain_connection_edge!(connection_state, domain_hyperedge) +end + +""" + $(TYPEDSIGNATURES) + +Generate the merged connection sets and connected domain sets for system `sys`. Also +removes all `connect` equations in `sys`. Return the modified system and a tuple of the +connection sets and domain sets. Also scalarizes array equations in the system. +""" +function generate_connection_set(sys::AbstractSystem) + # generate the states + connection_state = ConnectionState() + negative_connection_state = NegativeConnectionState() + # the root system isn't added to the namespace, which we handle by not namespacing it + sys = toggle_namespacing(sys, false) + sys = generate_connection_set!( + connection_state, negative_connection_state, sys, Symbol[]) + remove_negative_connections!(connection_state, negative_connection_state) + + return sys, connectionsets(connection_state) +end + +""" + $(TYPEDSIGNATURES) + +Appropriately handle the equation `eq` depending on whether it is a normal or connection +equation. For normal equations, it is expected that `eqs` is a buffer to which the equation +can be pushed, unmodified. Connection equations update the given `state`. The equation is +present at the path in the hierarchical system given by `namespace`. `isouter` is the +function returned from `generate_isouter`. +""" +function handle_maybe_connect_equation!(eqs, state::AbstractConnectionState, + eq::Equation, namespace::Vector{Symbol}, isouter) + lhs = eq.lhs + rhs = eq.rhs + + if !(lhs isa Connection) + # split connections and equations + if eq.lhs isa AbstractArray || eq.rhs isa AbstractArray + append!(eqs, Symbolics.scalarize(eq)) + else + push!(eqs, eq) + end + return + end + + if get_systems(lhs) === :domain + # This is a domain connection, so we only update the domain connection graph + hyperedge = map(get_systems(rhs)) do sys + sys isa AbstractSystem || error("Domain connections can only connect systems!") + sysname = nameof(sys) + sys_ns = namespace_hierarchy(sysname) + append!(namespace, sys_ns) + vertex = ConnectionVertex(namespace) + foreach(_ -> pop!(namespace), sys_ns) + return vertex + end + add_domain_connection_edge!(state, hyperedge) + else + connected_systems = get_systems(rhs) + generate_connectionsets!(state, namespace, connected_systems, isouter) + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Generate the appropriate connection sets from `connect` equations present in the +hierarchical system `sys`. This is a recursive function that descends the hierarchy. If +`sys` is the root system, then `does_namespacing(sys)` must be `false` and `namespace` +should be empty. It is essential that the traversal is preorder. + +## Arguments + +- `connection_state`: The connection state keeping track of the connection network and the + domain network. +- `negative_connection_state`: The connection state that tracks connections removed by + analysis point transformations. These removed connections are stored in the + `ignored_connections` field of the system. +- `namespace`: The path of names from the root system to the current system. This should + not include the name of the root system. +""" +function generate_connection_set!(connection_state::ConnectionState, + negative_connection_state::NegativeConnectionState, + sys::AbstractSystem, namespace::Vector{Symbol}) + initial_len = length(namespace) + res = _generate_connection_set!( + connection_state, negative_connection_state, sys, namespace) + # Enforce postcondition as a sanity check that the recursion is implemented correctly + length(namespace) == initial_len || throw(NotPossibleError()) + return res +end + +function _generate_connection_set!(connection_state::ConnectionState, + negative_connection_state::NegativeConnectionState, + sys::AbstractSystem, namespace::Vector{Symbol}) + # This function recurses down the system tree. Each system adds its name and pops + # it before returning. We don't add the root system, which is handled by assuming + # it doesn't do namespacing. + does_namespacing(sys) && push!(namespace, nameof(sys)) + subsys = get_systems(sys) + + isouter = generate_isouter(sys) + eqs′ = get_eqs(sys) + eqs = Equation[] + + # generate connection equations and separate out non-connection equations + for eq in eqs′ + handle_maybe_connect_equation!(eqs, connection_state, eq, namespace, isouter) + end + + # go through the removed connections and update the negative graph + for conn in something(get_ignored_connections(sys), ()) + eq = Equation(Connection(), conn) + # there won't be any standard equations, so we can pass `nothing` instead of + # `eqs`. + handle_maybe_connect_equation!( + nothing, negative_connection_state, eq, namespace, isouter) + end + + # all connectors are eventually inside connectors, and all flow variables + # need at least a singleton connectionset (hyperedge) with the inside variant + for s in subsys + isconnector(s) || continue + is_domain_connector(s) && continue + push!(namespace, nameof(s)) + for v in unknowns(s) + Flow === get_connection_type(v) || continue + add_connection_edge!(connection_state, (ConnectionVertex(namespace, v, false),)) + end + pop!(namespace) + end + + # recurse down the hierarchy + @set! sys.systems = map(subsys) do s + generate_connection_set!(connection_state, negative_connection_state, s, namespace) + end + @set! sys.eqs = eqs + # Remember to pop the name at the end! + does_namespacing(sys) && pop!(namespace) + return sys +end + +""" + $(TYPEDSIGNATURES) + +Generate connection equations for the connection sets given by `csets`. This does not +handle stream connections. Return the generated equations and the stream connection sets. +""" +function generate_connection_equations_and_stream_connections( + sys::AbstractSystem, csets::Vector{Vector{ConnectionVertex}}) + eqs = Equation[] + stream_connections = Vector{ConnectionVertex}[] + + for cset in csets + cvert = cset[1] + var = variable_from_vertex(sys, cvert)::BasicSymbolic + vtype = cvert.type + if vtype <: Union{InputVar, OutputVar} + inner_output = nothing + outer_input = nothing + for cvert in cset + if cvert.isouter && cvert.type <: InputVar + if outer_input !== nothing + error(""" + Found two outer input connectors `$outer_input` and `$cvert` in the + same connection set. + """) + end + outer_input = cvert + elseif !cvert.isouter && cvert.type <: OutputVar + if inner_output !== nothing + error(""" + Found two inner output connectors `$inner_output` and `$cvert` in + the same connection set. + """) + end + inner_output = cvert + end + end + root, rest = Iterators.peel(cset) + root_var = variable_from_vertex(sys, root) + for cvert in rest + var = variable_from_vertex(sys, cvert) + push!(eqs, root_var ~ var) + end + elseif vtype === Stream + push!(stream_connections, cset) + elseif vtype === Flow + # arrays have to be broadcasted to be added/subtracted/negated which leads + # to bad-looking equations. Just generate scalar equations instead since + # mtkcompile will scalarize anyway. + representative = variable_from_vertex(sys, cset[1]) + # each variable can have different axes, but they all have the same size + for sz_i in eachindex(eachindex(wrap(representative))) + rhs = 0 + for cvert in cset + # all of this wrapping/unwrapping is necessary because the relevant + # methods are defined on `Arr/Num` and not `BasicSymbolic`. + v = variable_from_vertex(sys, cvert)::BasicSymbolic + idxs = eachindex(wrap(v)) + v = unwrap(wrap(v)[idxs[sz_i]]) + rhs += cvert.isouter ? unwrap(-wrap(v)) : v + end + push!(eqs, 0 ~ rhs) + end + else # Equality + base = variable_from_vertex(sys, cset[1]) + for i in 2:length(cset) + v = variable_from_vertex(sys, cset[i]) + push!(eqs, base ~ v) + end + end + end + eqs, stream_connections +end + +""" + $(TYPEDSIGNATURES) + +Generate the defaults for parameters in the domain sets given by `domain_csets`. +""" +function domain_defaults( + sys::AbstractSystem, domain_csets::Vector{Vector{ConnectionVertex}}) + defs = Dict() + for cset in domain_csets + systems = map(Base.Fix1(variable_from_vertex, sys), cset) + @assert all(x -> x isa AbstractSystem, systems) + idx = findfirst(is_domain_connector, systems) + idx === nothing && continue + domain_sys = systems[idx] + # note that these will not be namespaced with `domain_sys`. + domain_defs = defaults(domain_sys) + for (j, csys) in enumerate(systems) + j == idx && continue + if is_domain_connector(csys) + throw(ArgumentError(""" + Domain sources $(nameof(domain_sys)) and $(nameof(csys)) are connected! + """)) + end + for par in parameters(csys) + defval = get(domain_defs, par, nothing) + defval === nothing && continue + defs[parameters(csys, par)] = parameters(domain_sys, par) + end + end + end + return defs +end + +""" + $(TYPEDSIGNATURES) + +Given a hierarchical system with [`connect`](@ref) equations, expand the connection +equations and return the new system. `tol` is the tolerance for handling the singularities +in stream connection equations that happen when a flow variable approaches zero. +""" +function expand_connections(sys::AbstractSystem; tol = 1e-10) + # turn analysis points into standard connection equations + sys = remove_analysis_points(sys) + # generate the connection sets + sys, (csets, domain_csets) = generate_connection_set(sys) + # generate equations, and stream equations + ceqs, instream_csets = generate_connection_equations_and_stream_connections(sys, csets) + stream_eqs, instream_subs = expand_instream(instream_csets, sys; tol = tol) + + eqs = [equations(sys); ceqs; stream_eqs] + # substitute `instream(..)` expressions with their new values + for i in eachindex(eqs) + eqs[i] = fixpoint_sub( + eqs[i], instream_subs; maxiters = max(length(instream_subs), 10)) + end + # get the defaults for domain networks + d_defs = domain_defaults(sys, domain_csets) + # build the new system + sys = flatten(sys, true) + @set! sys.eqs = eqs + @set! sys.defaults = merge(get_defaults(sys), d_defs) +end + +""" + $(TYPEDSIGNATURES) + +Given a connection vertex `cvert` referring to a variable in a connector in `sys`, return +the flow variable in that connector. +""" +function get_flowvar(sys::AbstractSystem, cvert::ConnectionVertex) + parent_names = @view cvert.name[1:(end - 1)] + parent_sys = iterative_getproperty(sys, parent_names) + for var in unknowns(parent_sys) + type = get_connection_type(var) + type == Flow || continue + return unwrap(unknowns(parent_sys, var)) + end + throw(ArgumentError("There is no flow variable in system `$(nameof(parent_sys))`")) +end + +""" + $(TYPEDSIGNATURES) + +Given connection sets of stream variables in `sys`, return the additional equations to add +to the system and the substitutions to make to handle `instream(..)` expressions. `tol` is +the tolerance for handling singularities in stream connection equations when the flow +variable approaches zero. +""" +function expand_instream(csets::Vector{Vector{ConnectionVertex}}, sys::AbstractSystem; + tol = 1e-8) + eqs = equations(sys) + # collect all `instream` terms in the equations + instream_exprs = Set{BasicSymbolic}() + for eq in eqs + collect_instream!(instream_exprs, eq) + end + + # specifically substitute `instream(x[i]) => instream(x)[i]` + instream_subs = Dict{BasicSymbolic, BasicSymbolic}() + for expr in instream_exprs + stream_var = only(arguments(expr)) + iscall(stream_var) && operation(stream_var) === getindex || continue + args = arguments(stream_var) + new_expr = Symbolics.array_term( + instream, args[1]; size = size(args[1]), ndims = ndims(args[1]))[args[2:end]...] + instream_subs[expr] = new_expr + end + + # for all the newly added `instream(x)[i]`, add `instream(x)` to `instream_exprs` + # also remove all `instream(x[i])` + for (k, v) in instream_subs + push!(instream_exprs, arguments(v)[1]) + delete!(instream_exprs, k) + end + + # This is an implementation of the modelica spec + # https://specification.modelica.org/maint/3.6/stream-connectors.html + additional_eqs = Equation[] + for cset in csets + n_outer = count(cvert -> cvert.isouter, cset) + n_inner = length(cset) - n_outer + if n_inner == 1 && n_outer == 0 + cvert = only(cset) + stream_var = variable_from_vertex(sys, cvert)::BasicSymbolic + instream_subs[instream(stream_var)] = stream_var + elseif n_inner == 2 && n_outer == 0 + cvert1, cvert2 = cset + stream_var1 = variable_from_vertex(sys, cvert1)::BasicSymbolic + stream_var2 = variable_from_vertex(sys, cvert2)::BasicSymbolic + instream_subs[instream(stream_var1)] = stream_var2 + instream_subs[instream(stream_var2)] = stream_var1 + elseif n_inner == 1 && n_outer == 1 + cvert_inner, cvert_outer = cset + if cvert_inner.isouter + cvert_inner, cvert_outer = cvert_outer, cvert_inner + end + streamvar_inner = variable_from_vertex(sys, cvert_inner)::BasicSymbolic + streamvar_outer = variable_from_vertex(sys, cvert_outer)::BasicSymbolic + instream_subs[instream(streamvar_inner)] = instream(streamvar_outer) + push!(additional_eqs, (streamvar_outer ~ streamvar_inner)) + elseif n_inner == 0 && n_outer == 2 + cvert1, cvert2 = cset + stream_var1 = variable_from_vertex(sys, cvert1)::BasicSymbolic + stream_var2 = variable_from_vertex(sys, cvert2)::BasicSymbolic + push!(additional_eqs, (stream_var1 ~ instream(stream_var2)), + (stream_var2 ~ instream(stream_var1))) + else + # Currently just implements the "else" case for `instream(..)` in the suggested + # implementation of stream connectors in the Modelica spec v3.6 section 15.2. + # https://specification.modelica.org/maint/3.6/stream-connectors.html#instream-and-connection-equations + # We could implement the "if" case using variable bounds? It would be nice to + # move that metadata to the system (storing it similar to `defaults`). + outer_cverts = filter(cvert -> cvert.isouter, cset) + inner_cverts = filter(cvert -> !cvert.isouter, cset) + + outer_streamvars = map(Base.Fix1(variable_from_vertex, sys), outer_cverts) + inner_streamvars = map(Base.Fix1(variable_from_vertex, sys), inner_cverts) + + outer_flowvars = map(Base.Fix1(get_flowvar, sys), outer_cverts) + inner_flowvars = map(Base.Fix1(get_flowvar, sys), inner_cverts) + + mask = trues(length(inner_cverts)) + for inner_i in eachindex(inner_cverts) + # mask out the current variable + mask[inner_i] = false + svar = inner_streamvars[inner_i] + instream_subs[instream(svar)] = term( + instream_rt, Val(n_inner - 1), Val(n_outer), inner_flowvars[mask]..., + inner_streamvars[mask]..., outer_flowvars..., outer_streamvars...) + # make sure to reset the mask + mask[inner_i] = true + end + + for q in 1:n_outer + sq = mapreduce(+, inner_flowvars) do fvar + max(-fvar, 0) + end + sq += mapreduce(+, enumerate(outer_flowvars)) do (outer_i, fvar) + outer_i == q && return 0 + max(fvar, 0) + end + # sanity check to make sure it isn't going to codegen a `mapreduce` + @assert operation(sq) == (+) + + num = mapreduce(+, inner_flowvars, inner_streamvars) do fvar, svar + positivemax(-fvar, sq; tol) * svar + end + num += mapreduce( + +, enumerate(outer_flowvars), outer_streamvars) do (outer_i, fvar), svar + outer_i == q && return 0 + positivemax(fvar, sq; tol) * instream(svar) + end + @assert operation(num) == (+) + + den = mapreduce(+, inner_flowvars) do fvar + positivemax(-fvar, sq; tol) + end + den += mapreduce(+, enumerate(outer_flowvars)) do (outer_i, fvar) + outer_i == q && return 0 + positivemax(fvar, sq; tol) + end + + push!(additional_eqs, (outer_streamvars[q] ~ num / den)) + end + end + end + return additional_eqs, instream_subs +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 deleted file mode 100644 index 08811da467..0000000000 --- a/src/systems/diffeqs/abstractodesystem.jl +++ /dev/null @@ -1,670 +0,0 @@ -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) diff --git a/src/systems/diffeqs/basic_transformations.jl b/src/systems/diffeqs/basic_transformations.jl index 1478a968b8..b8268e884e 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 = System(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,669 @@ 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::System; kwargs...) + t = get_iv(sys) + @variables trJ + D = Differential(t) + neweq = D(trJ) ~ trJ * -tr(calculate_jacobian(sys)) + neweqs = [equations(sys); neweq] + vars = [unknowns(sys); trJ] + System( + neweqs, t, vars, parameters(sys); + checks = false, name = nameof(sys), kwargs... + ) +end + +""" +$(TYPEDSIGNATURES) + +Generates the set of ODEs after change of variables. + + +Example: + +```julia +using ModelingToolkit, OrdinaryDiffEq, Test + +# Change of variables: z = log(x) +# (this implies that x = exp(z) is automatically non-negative) + +@independent_variables t +@parameters α +@variables x(t) +D = Differential(t) +eqs = [D(x) ~ α*x] + +tspan = (0., 1.) +def = [x => 1.0, α => -0.5] + +@mtkcompile sys = System(eqs, t;defaults=def) +prob = ODEProblem(sys, [], tspan) +sol = solve(prob, Tsit5()) + +@variables z(t) +forward_subs = [log(x) => z] +backward_subs = [x => exp(z)] +new_sys = change_of_variables(sys, t, forward_subs, backward_subs) +@test equations(new_sys)[1] == (D(z) ~ α) + +new_prob = ODEProblem(new_sys, [], tspan) +new_sol = solve(new_prob, Tsit5()) + +@test isapprox(new_sol[x][end], sol[x][end], atol=1e-4) +``` + +""" +function change_of_variables( + sys::System, iv, forward_subs, backward_subs; + simplify = true, t0 = missing, isSDE = false +) + t = iv + + old_vars = first.(backward_subs) + new_vars = last.(forward_subs) + + # use: f = Y(t, X) + # use: dY = (∂f/∂t + μ∂f/∂x + (1/2)*σ^2*∂2f/∂x2)dt + σ∂f/∂xdW + old_eqs = equations(sys) + neqs = get_noise_eqs(sys) + brownvars = brownians(sys) + + if neqs === nothing && length(brownvars) === 0 + neqs = ones(1, length(old_eqs)) + elseif neqs !== nothing + isSDE = true + neqs = [neqs[i, :] for i in 1:size(neqs, 1)] + + brownvars = map([Symbol(:B, :_, i) for i in 1:length(neqs[1])]) do name + unwrap(only(@brownians $name)) + end + else + isSDE = true + neqs = Vector{Any}[] + for (i, eq) in enumerate(old_eqs) + neq = Any[] + right = eq.rhs + for Bv in brownvars + lin_exp = linear_expansion(right, Bv) + right = lin_exp[2] + push!(neq, lin_exp[1]) + end + push!(neqs, neq) + old_eqs[i] = eq.lhs ~ right + end + end + + # df/dt = ∂f/∂x dx/dt + ∂f/∂t + dfdt = Symbolics.derivative(first.(forward_subs), t) + ∂f∂x = [Symbolics.derivative(first(f_sub), old_var) + for (f_sub, old_var) in zip(forward_subs, old_vars)] + ∂2f∂x2 = Symbolics.derivative.(∂f∂x, old_vars) + new_eqs = Equation[] + + for (new_var, ex, first, second) in zip(new_vars, dfdt, ∂f∂x, ∂2f∂x2) + for (eqs, neq) in zip(old_eqs, neqs) + if occursin(value(eqs.lhs), value(ex)) + ex = substitute(ex, eqs.lhs => eqs.rhs) + if isSDE + for (noise, B) in zip(neq, brownvars) + ex = ex + 1/2 * noise^2 * second + noise*first*B + end + end + end + end + ex = substitute(ex, Dict(forward_subs)) + ex = substitute(ex, Dict(backward_subs)) + if simplify + ex = Symbolics.simplify(ex, expand = true) + end + push!(new_eqs, Differential(t)(new_var) ~ ex) + end + + defs = get_defaults(sys) + new_defs = Dict() + for f_sub in forward_subs + ex = substitute(first(f_sub), defs) + if !ismissing(t0) + ex = substitute(ex, t => t0) + end + new_defs[last(f_sub)] = ex + end + for para in parameters(sys) + if haskey(defs, para) + new_defs[para] = defs[para] + end + end + + @named new_sys = System( + vcat(new_eqs, first.(backward_subs) .~ last.(backward_subs)), t; + defaults = new_defs, + observed = observed(sys) + ) + if simplify + return mtkcompile(new_sys) + end + return new_sys +end + +""" + change_independent_variable( + sys::System, 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 `mtkcompile`, 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 = System([D(D(y)) ~ -9.81, D(D(x)) ~ 0.0], t); + +julia> M = change_independent_variable(M, x); + +julia> M = mtkcompile(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::System, 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 + D1 = Differential(iv1) # e.g. d/d(t) + + # construct new terms, e.g: + # iv2 -> u + # iv1_of_iv2 -> t(u), (inverse, global because iv1 has no namespacing in sys) + # div2_of_iv1 -> uˍt(t) + iv2_unit = getmetadata(iv2_of_iv1, VariableUnit, nothing) + if isnothing(iv2_unit) + iv2, = @independent_variables $iv2name + iv1_of_iv2, = GlobalScope.(@variables $iv1name(iv2)) + div2_of_iv1 = GlobalScope(default_toterm(D1(iv2_of_iv1))) + else + iv2, = @independent_variables $iv2name [unit = iv2_unit] + iv1_of_iv2, = GlobalScope.(@variables $iv1name(iv2) [unit = get_unit(iv1)]) + div2_of_iv1 = GlobalScope(diff2term_with_unit(D1(iv2_of_iv1), iv1)) + end + + 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) + + # A utility function that returns whether var (e.g. f(t)) is a function of iv (e.g. t) + function is_function_of(var, iv) + # Peel off outer calls to find the argument of the function of + if iscall(var) && operation(var) === getindex # handle array variables + var = arguments(var)[1] # (f(t))[1] -> f(t) + end + if iscall(var) + var = only(arguments(var)) # e.g. f(t) -> t + return isequal(var, iv) + end + return false + end + + # 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::T) where {T} + # 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) + if is_function_of(var, iv1) && !isequal(var, iv2_of_iv1) # of the form f(t)? but 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::T + end + + # overload to specifically handle equations, which can be an equation or a connection + function transform(eq::Equation, systems_map) + if eq.rhs isa Connection + eq = connect((systems_map[nameof(s)] for s in eq.rhs.systems)...) + else + eq = transform(eq) + end + return eq::Equation + end + + # Use the utility function to transform everything in the system! + function transform(sys::System) + systems = map(transform, get_systems(sys)) # recurse through subsystems + # transform equations and connections + systems_map = Dict(get_name(s) => s for s in systems) + eqs = map(eq -> transform(eq, systems_map)::Equation, 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)) + connector_type = get_connector_type(sys) + assertions = Dict(transform(ass) => msg for (ass, msg) in get_assertions(sys)) + wascomplete = iscomplete(sys) # save before reconstructing system + wassplit = is_split(sys) + wasflat = isempty(systems) + sys = typeof(sys)( # recreate system with transformed fields + eqs, iv2, unknowns, ps; observed, initialization_eqs, + defaults, guesses, connector_type, + assertions, name = nameof(sys), description = description(sys) + ) + sys = compose(sys, systems) # rebuild hierarchical system + if wascomplete + sys = complete(sys; split = wassplit, flatten = wasflat) # complete output if input was complete + @set! sys.parameter_dependencies = parameter_dependencies + end + return sys + end + return transform(sys) +end + +""" +$(TYPEDSIGNATURES) + +Choose correction_factor=-1//2 (1//2) to convert Ito -> Stratonovich (Stratonovich->Ito). +""" +function stochastic_integral_transform(sys::System, correction_factor) + if !isempty(get_systems(sys)) + throw(ArgumentError("The system must be flattened.")) + end + if get_noise_eqs(sys) === nothing + throw(ArgumentError(""" + `$stochastic_integral_transform` expects a system with noise_eqs. If your \ + noise is specified using brownian variables, consider calling \ + `mtkcompile`. + """)) + end + name = nameof(sys) + noise_eqs = get_noise_eqs(sys) + eqs = equations(sys) + dvs = unknowns(sys) + ps = parameters(sys) + # use the general interface + if noise_eqs isa Vector + _eqs = reduce(vcat, [eqs[i].lhs ~ noise_eqs[i] for i in eachindex(dvs)]) + de = System(_eqs, get_iv(sys), dvs, ps, name = name, checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = simplify.(jac * noise_eqs) + else + dimunknowns, m = size(noise_eqs) + _eqs = reduce(vcat, [eqs[i].lhs ~ noise_eqs[i] for i in eachindex(dvs)]) + de = System(_eqs, get_iv(sys), dvs, ps, name = name, checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = simplify.(jac * noise_eqs[:, 1]) + for k in 2:m + __eqs = reduce(vcat, + [eqs[i].lhs ~ noise_eqs[Int(i + (k - 1) * dimunknowns)] + for i in eachindex(dvs)]) + de = System(__eqs, get_iv(sys), dvs, dvs, name = name, checks = false) + + jac = calculate_jacobian(de, sparse = false, simplify = false) + ∇σσ′ = ∇σσ′ + simplify.(jac * noise_eqs[:, k]) + end + end + deqs = reduce(vcat, + [eqs[i].lhs ~ eqs[i].rhs + correction_factor * ∇σσ′[i] for i in eachindex(dvs)]) + + # reduce(vcat, [1]) == 1 for some reason + if deqs isa Equation + deqs = [deqs] + end + return @set sys.eqs = deqs +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 SDE System 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 = System(eqs,t,[x],[α,β]; noise_eqs = noiseeqs) + +# 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::System, 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 = copy(get_noise_eqs(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 + + eqs = equations(sys) + dvs = unknowns(sys) + # transformation adds additional unknowns θ: newX = (X,θ) + # drift function for unknowns is modified + # θ has zero drift + deqs = reduce( + vcat, [eqs[i].lhs ~ eqs[i].rhs - drift_correction[i] for i in eachindex(dvs)]) + if deqs isa Equation + deqs = [deqs] + end + 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(wrap.(noiseeqs))); noiseqsθ'] + end + else + noiseeqs = [Array(noiseeqs); noiseqsθ'] + end + + unknown_vars = [dvs; θ] + + # return modified SDE System + @set! sys.eqs = deqs + @set! sys.noise_eqs = noiseeqs + @set! sys.unknowns = unknown_vars + get_defaults(sys)[θ] = θ0 + obs = observed(sys) + @set! sys.observed = [weight ~ θ / θ0; obs] + if get_parent(sys) !== nothing + @set! sys.parent.unknowns = [get_unknowns(get_parent(sys)); [θ, weight]] + end + return sys +end + +""" + $(TYPEDSIGNATURES) + +Add accumulation variables for `vars`. For every unknown `x` in `vars`, add +`D(accumulation_x) ~ x` as an equation. +""" +function add_accumulations(sys::System, vars = unknowns(sys)) + avars = [rename(v, Symbol(:accumulation_, getname(v))) for v in vars] + return add_accumulations(sys, avars .=> vars) +end + +""" + $(TYPEDSIGNATURES) + +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`. + +All accumulation variables have a default of zero. +""" +function add_accumulations(sys::System, vars::Vector{<:Pair}) + eqs = get_eqs(sys) + avars = map(first, vars) + ints = intersect(avars, unknowns(sys)) + if !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)) + return sys +end + +""" + $(TYPEDSIGNATURES) + +Given a system with noise in the form of noise equation (`get_noise_eqs(sys) !== nothing`) +return an equivalent system which represents the noise using brownian variables. + +# Keyword Arguments + +- `names`: The name(s) to use for the brownian variables. If this is a `Symbol`, variables + with the given name and successive numeric `_i` suffixes will be used. If a `Vector`, + this must have appropriate length for the noise equations of the system. The + corresponding number of brownian variables are created with the given names. +""" +function noise_to_brownians(sys::System; names::Union{Symbol, Vector{Symbol}} = :α) + neqs = get_noise_eqs(sys) + if neqs === nothing + throw(ArgumentError("Expected a system with `noise_eqs`.")) + end + if !isempty(get_systems(sys)) + throw(ArgumentError("The system must be flattened.")) + end + # vector means diagonal noise + nbrownians = ndims(neqs) == 1 ? length(neqs) : size(neqs, 2) + if names isa Symbol + names = [Symbol(names, :_, i) for i in 1:nbrownians] + end + if length(names) != nbrownians + throw(ArgumentError(""" + The system has $nbrownians brownian variables. Received $(length(names)) names \ + for the brownian variables. Provide $nbrownians names or a single `Symbol` to use \ + an array variable of the appropriately length. + """)) + end + brownvars = map(names) do name + unwrap(only(@brownians $name)) + end + + terms = if ndims(neqs) == 1 + neqs .* brownvars + else + neqs * brownvars + end + + eqs = map(get_eqs(sys), terms) do eq, term + eq.lhs ~ eq.rhs + term + end + + @set! sys.eqs = eqs + @set! sys.brownians = brownvars + @set! sys.noise_eqs = nothing + + return sys +end + +""" + $(TYPEDSIGNATURES) + +Function which takes a system `sys` and an independent variable `t` and changes the +independent variable of `sys` to `t`. This is different from +[`change_independent_variable`](@ref) since this function only does a symbolic substitution +of the independent variable. `sys` must not be a reduced system (`observed(sys)` must be +empty). If `sys` is time-independent, this can be used to turn it into a time-dependent +system. + +# Keyword arguments + +- `name`: The name of the returned system. +""" +function convert_system_indepvar(sys::System, t; name = nameof(sys)) + isempty(observed(sys)) || + throw(ArgumentError(""" + `convert_system_indepvar` cannot handle reduced model (i.e. observed(sys) is non-\ + empty). + """)) + t = value(t) + varmap = Dict() + sts = unknowns(sys) + newsts = similar(sts, Any) + for (i, s) in enumerate(sts) + if iscall(s) + args = arguments(s) + 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 = maketerm(typeof(s), operation(s), Any[t], + SymbolicUtils.metadata(s)) + newsts[i] = ns + varmap[s] = ns + else + ns = variable(getname(s); T = FnType)(t) + newsts[i] = ns + varmap[s] = ns + end + end + sub = Base.Fix2(substitute, varmap) + if is_time_dependent(sys) + 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)) + neqs = get_noise_eqs(sys) + if neqs !== nothing + neqs = map(sub, neqs) + end + cstrs = map(sub, get_constraints(sys)) + costs = Vector{Union{Real, BasicSymbolic}}(map(sub, get_costs(sys))) + @set! sys.eqs = neweqs + @set! sys.iv = t + @set! sys.unknowns = newsts + @set! sys.defaults = defs + @set! sys.name = name + @set! sys.noise_eqs = neqs + @set! sys.constraints = cstrs + @set! sys.costs = costs + + var_to_name = Dict(k => get(varmap, v, v) for (k, v) in get_var_to_name(sys)) + @set! sys.var_to_name = var_to_name + return sys end diff --git a/src/systems/diffeqs/first_order_transform.jl b/src/systems/diffeqs/first_order_transform.jl deleted file mode 100644 index 920a390d82..0000000000 --- a/src/systems/diffeqs/first_order_transform.jl +++ /dev/null @@ -1,50 +0,0 @@ -""" -$(TYPEDSIGNATURES) - -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)) - @set! sys.eqs = eqs_lowered - @set! sys.states = new_vars - @set! sys.structure = nothing - return sys -end - -function ode_order_lowering(eqs, iv, states) - var_order = OrderedDict{Any,Int}() - D = Differential(iv) - diff_eqs = Equation[] - diff_vars = [] - alge_eqs = Equation[] - - for (i, eq) ∈ 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) - 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) - rvar = lower_varname(var, iv, o) - push!(diff_vars, lvar) - - rhs = rvar - eq = Differential(iv)(lvar) ~ rhs - push!(diff_eqs, eq) - end - 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))) -end diff --git a/src/systems/diffeqs/modelingtoolkitize.jl b/src/systems/diffeqs/modelingtoolkitize.jl deleted file mode 100644 index f9b99374e5..0000000000 --- a/src/systems/diffeqs/modelingtoolkitize.jl +++ /dev/null @@ -1,146 +0,0 @@ -""" -$(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 diff --git a/src/systems/diffeqs/odesystem.jl b/src/systems/diffeqs/odesystem.jl deleted file mode 100644 index 913041e41e..0000000000 --- a/src/systems/diffeqs/odesystem.jl +++ /dev/null @@ -1,341 +0,0 @@ -""" -$(TYPEDEF) - -A system of ordinary 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] - -de = ODESystem(eqs,t,[x,y,z],[σ,ρ,β]) -``` -""" -struct ODESystem <: AbstractODESystem - """The ODEs defining the system.""" - eqs::Vector{Equation} - """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Parameter variables.""" - ps::Vector - observed::Vector{Equation} - """ - Time-derivative matrix. Note: this field will not be defined until - [`calculate_tgrad`](@ref) is called on the system. - """ - tgrad::RefValue{Vector{Num}} - """ - Jacobian matrix. Note: this field will not be defined until - [`calculate_jacobian`](@ref) is called on the system. - """ - jac::RefValue{Any} - """ - `Wfact` matrix. 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 - [`generate_factorized_W`](@ref) is called on the system. - """ - Wfact_t::RefValue{Matrix{Num}} - """ - Name: the name of the system - """ - name::Symbol - """ - systems: The internal systems. These are required to have unique names. - """ - systems::Vector{ODESystem} - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict - """ - structure: structural information of the system - """ - structure::Any - """ - type: type of the system - """ - connection_type::Any -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, - ) - 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.", :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)) - 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 - -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 - - operation(O) isa Differential && return push!(vars, O) - - operation(O) isa Sym && push!(vars, O) - for arg in arguments(O) - vars!(vars, arg) - end - - return vars -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) - 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 - end - 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) - 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) - end - end - return nothing -end - -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 -end - -# NOTE: equality does not check cached Jacobian -function Base.:(==)(sys1::ODESystem, sys2::ODESystem) - iv1 = independent_variable(sys1) - iv2 = independent_variable(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))) -end - -function flatten(sys::ODESystem) - 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), - ) - 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. -""" -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) -end - -function _eq_unordered(a, b) - length(a) === length(b) || return false - n = length(a) - idxs = Set(1:n) - for x ∈ a - idx = findfirst(isequal(x), b) - 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. -""" -$(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).")) - t = value(t) - varmap = Dict() - sts = states(sys) - newsts = similar(sts, Any) - for (i, s) in enumerate(sts) - if istree(s) - args = arguments(s) - length(args) == 1 || throw(InvalidSystemException("Illegal state: $s. The state 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) - newsts[i] = ns - varmap[s] = ns - else - ns = indepvar2depvar(s, t) - newsts[i] = ns - varmap[s] = ns - end - end - sub = Base.Fix2(substitute, varmap) - 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) -end diff --git a/src/systems/diffeqs/sdesystem.jl b/src/systems/diffeqs/sdesystem.jl deleted file mode 100644 index d804bc2ca2..0000000000 --- a/src/systems/diffeqs/sdesystem.jl +++ /dev/null @@ -1,391 +0,0 @@ -""" -$(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 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 deleted file mode 100644 index c47ccb6889..0000000000 --- a/src/systems/discrete_system/discrete_system.jl +++ /dev/null @@ -1,107 +0,0 @@ -""" -$(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],[σ,ρ,β]) -``` -""" -struct DiscreteSystem <: AbstractSystem - """The differential equations defining the discrete system.""" - eqs::Vector{Equation} - """Independent variable.""" - iv::Sym - """Dependent (state) variables.""" - states::Vector - """Parameter variables.""" - ps::Vector - observed::Vector{Equation} - """ - Name: the name of the system - """ - name::Symbol - """ - systems: 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`. - """ - default_u0::Dict - """ - default_p: The default parameters to use when parameters are not supplied - in `DiscreteSystem`. - """ - default_p::Dict -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(), - ) - iv′ = value(iv) - dvs′ = value.(dvs) - ps′ = value.(ps) - - 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)) - - 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) -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) - 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...) -end diff --git a/src/systems/if_lifting.jl b/src/systems/if_lifting.jl new file mode 100644 index 0000000000..aba9dbdcbf --- /dev/null +++ b/src/systems/if_lifting.jl @@ -0,0 +1,555 @@ +""" + 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), + ctrue & truea | cfalse & trueb, + ctrue & falsea | 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 + +!!! warn + + This is an experimental simplification pass. It may have bugs. Please open issues with + MWEs for any bugs encountered while using this. +""" +function IfLifting(sys::System) + if !is_time_dependent(sys) + throw(ArgumentError("IfLifting is only supported for time-dependent systems.")) + end + 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..7b1a9fb286 --- /dev/null +++ b/src/systems/imperative_affect.jl @@ -0,0 +1,289 @@ + +""" + 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. +""" +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 ImperativeAffect(; f, kwargs...) + ImperativeAffect(f; kwargs...) +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) + rmn = [] + for modded in modified(affect) + if symbolic_type(modded) == NotSymbolic() && modded isa AbstractArray + res = [] + for m in modded + push!(res, renamespace(s, m)) + end + push!(rmn, res) + else + push!(rmn, renamespace(s, modded)) + end + end + ImperativeAffect(func(affect), + namespace_expr.(observed(affect), (s,)), + observed_syms(affect), + rmn, + modified_syms(affect), + context(affect), + affect.skip_checks) +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_functional_affect( + affect::ImperativeAffect, sys; reset_jumps = false, 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 + + dvs = unknowns(sys) + ps = parameters(sys) + + 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))...,)) + + let user_affect = func(affect), ctx = context(affect), reset_jumps = reset_jumps + @inline 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) + + reset_jumps && reset_aggregated_jumps!(integ) + 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..b57412bf2e --- /dev/null +++ b/src/systems/index_cache.jl @@ -0,0 +1,718 @@ +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 = 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_pars = BasicSymbolic[] + initial_pars = 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 + function insert_by_type!(buffers::Vector{BasicSymbolic}, sym, ctype) + sym = unwrap(sym) + push!(buffers, 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 AffectSystem || affect isa ImperativeAffect + union!(discs, unwrap.(discretes(affect))) + elseif isnothing(affect) + continue + 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_pars + else + tunable_pars + 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 + if is_initializesystem(sys) + append!(tunable_pars, initial_pars) + empty!(initial_pars) + end + for p in tunable_pars + 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 + + initials_idxs = TunableIndexMap() + initials_buffer_size = 0 + for p in initial_pars + 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 + + 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 get_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(Number, tunable_buffer_size), + BufferTemplate(Number, 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, flatten = true) + 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 + + param_buf = broadcast.(unwrap, param_buf) + initials_buf = broadcast.(unwrap, initials_buf) + disc_buf = broadcast.(unwrap, disc_buf) + const_buf = broadcast.(unwrap, const_buf) + nonnumeric_buf = broadcast.(unwrap, nonnumeric_buf) + + if drop_missing + filterer = !isequal(unwrap(variable(:DEF))) + param_buf = filter.(filterer, param_buf) + initials_buf = filter.(filterer, initials_buf) + disc_buf = filter.(filterer, disc_buf) + const_buf = filter.(filterer, const_buf) + nonnumeric_buf = filter.(filterer, nonnumeric_buf) + end + + if flatten + result = ( + param_buf..., initials_buf..., disc_buf..., const_buf..., nonnumeric_buf...) + if all(isempty, result) + return () + end + return result + else + if isempty(param_buf) + param_buf = ((),) + end + if isempty(initials_buf) + initials_buf = ((),) + end + return (param_buf..., initials_buf..., disc_buf, const_buf, nonnumeric_buf) + end +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 `mtkcompile` 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 `mtkcompile` 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 deleted file mode 100644 index 460191f1bf..0000000000 --- a/src/systems/jumps/jumpsystem.jl +++ /dev/null @@ -1,323 +0,0 @@ -JumpType = Union{VariableRateJump, ConstantRateJump, MassActionJump} - -""" -$(TYPEDEF) - -A system of jump processes. - -# Fields -$(FIELDS) - -# Example - -```julia -using ModelingToolkit - -@parameters β γ t -@variables S I R -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]) -js = JumpSystem([j₁,j₂,j₃], t, [S,I,R], [β,γ]) -``` -""" -struct JumpSystem{U <: ArrayPartition} <: AbstractSystem - """ - The jumps of the system. Allowable types are `ConstantRateJump`, - `VariableRateJump`, `MassActionJump`. - """ - 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.""" - ps::Vector - observed::Vector{Equation} - """The name of the system.""" - name::Symbol - """The internal systems.""" - systems::Vector{JumpSystem} - """ - 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 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[]) - for eq in eqs - if eq isa MassActionJump - push!(ap.x[1], eq) - elseif eq isa ConstantRateJump - push!(ap.x[2], eq) - elseif eq isa VariableRateJump - push!(ap.x[3], eq) - else - error("JumpSystem equations must contain MassActionJumps, ConstantRateJumps, or VariableRateJumps.") - 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) -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}) -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) -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] -end - -function assemble_vrj(js, vrj, statetoid) - rate = @RuntimeGeneratedFunction(generate_rate_function(js, vrj.rate)) - 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) -end - -function assemble_vrj_expr(js, vrj, statetoid) - rate = generate_rate_function(js, vrj.rate) - outputvars = (value(affect.lhs) for affect in vrj.affect!) - outputidxs = ((statetoid[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)) - 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)) - ConstantRateJump(rate, affect) -end - -function assemble_crj_expr(js, crj, statetoid) - rate = generate_rate_function(js, crj.rate) - outputvars = (value(affect.lhs) for affect in crj.affect!) - outputidxs = ((statetoid[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) - push!(rs, 0 => stoich) - else - push!(rs, statetoid[value(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) - 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 -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...) -``` - -Generates a blank DiscreteProblem 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. - -Continuing the example from the [`JumpSystem`](@ref) definition: -```julia -using DiffEqBase, DiffEqJump -u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] -tspan = (0.0, 250.0) -dprob = DiscreteProblem(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))) - DiscreteProblem(df, u0, tspan, p; kwargs...) -end - -""" -```julia -function DiffEqBase.DiscreteProblemExpr(sys::JumpSystem, u0map, tspan, - parammap=DiffEqBase.NullParameters; kwargs...) -``` - -Generates a blank DiscreteProblem for a JumpSystem to utilize as its -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 -u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] -tspan = (0.0, 250.0) -dprob = DiscreteProblem(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) - # identity function to make syms works - quote - f = DiffEqBase.DISCRETE_INPLACE_DEFAULT - u0 = $u0 - p = $p - tspan = $tspan - df = DiscreteFunction{true,true}(f, syms=$(Symbol.(states(sys)))) - DiscreteProblem(df, u0, tspan, p) - end -end - -""" -```julia -function 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()) -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]) - - # handling parameter substition 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) - vtoj = jdeps.badjlist - jtov = vdeps.badjlist - jtoj = needs_depgraph(aggregator) ? eqeq_dependencies(jdeps, vdeps).fadjlist : nothing - else - vtoj = nothing; jtov = nothing; jtoj = nothing - end - - JumpProblem(prob, aggregator, jset; dep_graph=jtoj, vartojumps_map=vtoj, jumptovars_map=jtov, 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) - dep -end - -function get_variables!(dep, jump::MassActionJump, variables) - sr = value(jump.scaled_rates) - (sr isa Symbolic) && get_variables!(dep, sr, variables) - for varasop in jump.reactant_stoch - any(isequal(varasop[1]), variables) && push!(dep, varasop[1]) - end - dep -end - -### Functions to determine which states are modified by a given jump -function modified_states!(mstates, jump::Union{ConstantRateJump,VariableRateJump}, sts) - for eq in jump.affect! - st = eq.lhs - any(isequal(st), sts) && push!(mstates, st) - end -end - -function modified_states!(mstates, jump::MassActionJump, sts) - for (state,stoich) in jump.net_stoch - any(isequal(state), sts) && push!(mstates, state) - end -end diff --git a/src/systems/model_parsing.jl b/src/systems/model_parsing.jl new file mode 100644 index 0000000000..c24c063ee0 --- /dev/null +++ b/src/systems/model_parsing.jl @@ -0,0 +1,1551 @@ +""" +$(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}( + :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 = [] + cons = [] + costs = [] + kwargs = OrderedCollections.OrderedSet() + where_types = Union{Symbol, Expr}[] + + push!(exprs.args, :(variables = [])) + push!(exprs.args, :(parameters = [])) + # We build `System` by default + 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, cons, costs, 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)) + + consolidate = get(dict, :consolidate, default_consolidate) + description = get(dict, :description, "") + + @inline pop_structure_dict!.( + Ref(dict), [:defaults, :kwargs, :structural_parameters]) + + sys = :($type($(flatten_equations)(equations), $iv, variables, parameters; + name, description = $description, systems, gui_metadata = $gui_metadata, + continuous_events = [$(c_evts...)], discrete_events = [$(d_evts...)], + defaults, costs = [$(costs...)], constraints = [$(cons...)], consolidate = $consolidate)) + + 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___")))) + + 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)) + elseif varclass == :constants + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@constants ($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...))) + elseif varclass == :constants + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $val : $varname; + $varname = $first(@constants ($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)) + elseif varclass == :constants + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $def_n_meta : $varname; + $varname = $first(@constants $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 == :constants + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@constants $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 == :constants + var = toconstant(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) + elseif varclass == :constants + var = toconstant(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, + cons, costs, 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_variables!(exprs, ps, dict, mod, body, :constants, kwargs, where_types) + 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) + elseif mname == Symbol("@constraints") + parse_constraints!(cons, dict, body) + elseif mname == Symbol("@costs") + parse_costs!(costs, dict, body) + elseif mname == Symbol("@consolidate") + parse_consolidate!(body, dict) + else + error("$mname is not handled.") + 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}}()], + :constants => 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, :constants] + 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, :constants] + 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}}()], + :constants => 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 line in body.args + if length(line.args) == 3 && line.args[1] == :(=>) + push!(c_evts, :(($line, ()))) + elseif length(line.args) == 2 + event = line.args[1] + kwargs = parse_event_kwargs(line.args[2]) + push!(c_evts, :(($event, $kwargs))) + else + error("Malformed continuous event $line.") + end + 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 line in body.args + if length(line.args) == 3 && line.args[1] == :(=>) + push!(d_evts, :(($line, ()))) + elseif length(line.args) == 2 + event = line.args[1] + kwargs = parse_event_kwargs(line.args[2]) + push!(d_evts, :(($event, $kwargs))) + else + error("Malformed discrete event $line.") + end + push!(dict[:discrete_events], readable_code.(d_evts)...) + end +end + +function parse_event_kwargs(disc_expr) + kwargs = :([]) + for arg in disc_expr.args + (arg.head != :(=)) && error("Malformed event kwarg $arg.") + (arg.args[1] isa Symbol) || error("Invalid keyword argument name $(arg.args[1]).") + push!(kwargs.args, :($(QuoteNode(arg.args[1])) => $(arg.args[2]))) + end + kwargs +end + +function parse_constraints!(cons, dict, body) + dict[:constraints] = [] + Base.remove_linenums!(body) + for arg in body.args + push!(cons, arg) + push!(dict[:constraints], readable_code(arg)) + end +end + +function parse_costs!(costs, dict, body) + dict[:costs] = [] + Base.remove_linenums!(body) + for arg in body.args + push!(costs, arg) + push!(dict[:costs], readable_code(arg)) + end +end + +function parse_consolidate!(body, dict) + if !(occursin("->", string(body)) || occursin("=", string(body))) + error("Consolidate must be a function definition.") + else + dict[:consolidate] = body + end +end + +function parse_icon!(body::String, dict, icon, mod) + icon_dir = get(ENV, "MTK_ICONS_DIR", joinpath(DEPOT_PATH[1], "mtk_icons")) + iconpath = abspath(joinpath(icon_dir, body)) + _body = lstrip(body) + dict[:icon] = icon[] = if isfile(body) + URI("file:///" * abspath(body)) + elseif isfile(iconpath) + URI("file:///" * abspath(iconpath)) + elseif try + Base.isvalid(URI(body)) + catch e + false + end + URI(body) + elseif 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..96c00411ad --- /dev/null +++ b/src/systems/nonlinear/homotopy_continuation.jl @@ -0,0 +1,552 @@ +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 `System` 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::System) + # 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::System + 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::System, 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[] + 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 + +@fallback_iip_specialize function SciMLBase.HomotopyNonlinearFunction{iip, specialize}( + sys::System; 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 `System` is required. Call `complete` or `mtkcompile` 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; p, 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 + +@doc problem_docstring( + HomotopyContinuationProblem, HomotopyNonlinearFunction, false; init = false) HomotopyContinuationProblem + +function HomotopyContinuationProblem(sys::System, args...; kwargs...) + HomotopyContinuationProblem{true}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem(sys::System, t, + u0map::StaticArray, + args...; + kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}( + sys, t, u0map, args...; kwargs...) +end + +function HomotopyContinuationProblem{true}(sys::System, args...; kwargs...) + HomotopyContinuationProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{false}(sys::System, args...; kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{iip, spec}( + sys::System, op; + kwargs...) where {iip, spec} + if !iscomplete(sys) + error("A completed `System` is required. Call `complete` or `mtkcompile` on the system before creating a `HomotopyContinuationProblem`") + end + f, u0, + p = process_SciMLProblem( + HomotopyNonlinearFunction{iip, spec}, sys, op; 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..f377f0202f --- /dev/null +++ b/src/systems/nonlinear/initializesystem.jl @@ -0,0 +1,861 @@ +""" + $(TYPEDSIGNATURES) + +Generate the initialization system for `sys`. The initialization system is a system of +nonlinear equations that solve for the full set of initial conditions of `sys` given +specified constraints. + +The initialization system can be of two types: time-dependent and time-independent. +Time-dependent initialization systems solve for the initial values of unknowns as well as +the values of solvable parameters of the system. Time-independent initialization systems +only solve for solvable parameters of the system. + +# Keyword arguments + +- `time_dependent_init`: Whether to create an initialization system for a time-dependent + system. A time-dependent initialization requires a time-dependent `sys`, but a time- + independent initialization can be created regardless. +- `op`: The operating point of user-specified initial conditions of variables in `sys`. +- `initialization_eqs`: Additional initialization equations to use apart from those in + `initialization_equations(sys)`. +- `guesses`: Additional guesses to use apart from those in `guesses(sys)`. +- `default_dd_guess`: Default guess for dummy derivative variables in time-dependent + initialization. +- `algebraic_only`: If `false`, does not use initialization equations (provided via the + keyword or part of the system) to construct initialization. +- `check_defguess`: Whether to error when a variable does not have a default or guess + despite ModelingToolkit expecting it to. +- `name`: The name of the initialization system. + +All other keyword arguments are forwarded to the [`System`](@ref) constructor. +""" +function generate_initializesystem( + sys::AbstractSystem; time_dependent_init = is_time_dependent(sys), kwargs...) + if time_dependent_init + generate_initializesystem_timevarying(sys; kwargs...) + else + generate_initializesystem_timeindependent(sys; kwargs...) + end +end + +""" +$(TYPEDSIGNATURES) + +Generate `System` of nonlinear equations which initializes a problem from specified initial conditions of a time-dependent `AbstractSystem`. +""" +function generate_initializesystem_timevarying(sys::AbstractSystem; + op = Dict(), + initialization_eqs = [], + guesses = Dict(), + default_dd_guess = Bool(0), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), 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 + arrvars = Set() + for var in vars + if iscall(var) && operation(var) === getindex + push!(arrvars, first(arguments(var))) + end + end + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + guesses = merge(get_guesses(sys), additional_guesses) + idxs_diff = isdiffeq.(eqs) + + # PREPROCESSING + op = anydict(op) + if isempty(op) + op = copy(defs) + end + scalarize_vars_in_varmap!(op, arrvars) + u0map = anydict() + pmap = anydict() + build_operating_point!(sys, op, u0map, pmap, Dict(), unknowns(sys), + parameters(sys; initial_parameters = true)) + for (k, v) in op + if has_parameter_dependency_with_lhs(sys, k) && is_variable_floatingpoint(k) + pmap[k] = v + end + end + 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) + # FIXME: DAEs provide initial conditions that require reducing the system + # to index zero. If `isdifferential(y)`, an initial condition was given for an + # algebraic variable, so ignore it. Otherwise, the initialization system + # gets a `D(y) ~ ...` equation and errors. This is the same behavior as v9. + if isdifferential(y) + return + end + # 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 + # TODO: Check if this is still necessary + # 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(op) + 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 + solved_params = 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, solved_params, 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) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + !in(solved_params), parameters(sys; initial_parameters = true))) + push!(pars, get_iv(sys)) + + # 8) use observed equations for guesses of observed variables if not provided + guessed = Set(keys(defs)) # x(t), D(x(t)), ... + guessed = union(guessed, Set(default_toterm.(guessed))) # x(t), D(x(t)), xˍt(t), ... + for eq in trueobs + if !(eq.lhs in guessed) + defs[eq.lhs] = eq.rhs + #push!(guessed, eq.lhs) # should not encounter eq.lhs twice, so don't need to track it + end + end + append!(eqs_ics, trueobs) + + vars = [vars; collect(solved_params)] + + initials = Dict(k => v for (k, v) in pmap if isinitial(k)) + merge!(defs, initials) + isys = System(Vector{Equation}(eqs_ics), + vars, + pars; + defaults = defs, + checks = check_units, + name, + is_initializesystem = true, + kwargs...) + @set isys.parameter_dependencies = new_parameter_deps +end + +""" +$(TYPEDSIGNATURES) + +Generate `System` of nonlinear equations which initializes a problem from specified initial conditions of a time-independent `AbstractSystem`. +""" +function generate_initializesystem_timeindependent(sys::AbstractSystem; + op = Dict(), + initialization_eqs = [], + guesses = Dict(), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), kwargs...) + eqs = equations(sys) + trueobs, eqs = unhack_observed(observed(sys), eqs) + vars = unique([unknowns(sys); getfield.(trueobs, :lhs)]) + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + guesses = merge(get_guesses(sys), additional_guesses) + + # PREPROCESSING + op = anydict(op) + u0map = anydict() + pmap = anydict() + build_operating_point!(sys, op, u0map, pmap, Dict(), unknowns(sys), + parameters(sys; initial_parameters = true)) + for (k, v) in op + if has_parameter_dependency_with_lhs(sys, k) && is_variable_floatingpoint(k) + pmap[k] = v + end + end + 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) + allpars = full_parameters(sys) + for p in allpars + if symbolic_type(p) == ArraySymbolic() && + Symbolics.shape(p) != Symbolics.Unknown() + append!(allpars, Symbolics.scalarize(p)) + end + end + allpars = Set(allpars) + non_params = filter(!in(allpars), 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 + solved_params = 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, solved_params, eqs_ics, defs, guesses) + + # handle values provided for dependent parameters similar to values for observed variables + handle_dependent_parameter_constraints!(sys, pmap, eqs_ics) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + !in(solved_params), parameters(sys; initial_parameters = true))) + vars = collect(solved_params) + + initials = Dict(k => v for (k, v) in pmap if isinitial(k)) + merge!(defs, initials) + isys = System(Vector{Equation}(eqs_ics), + vars, + pars; + defaults = defs, + checks = check_units, + name, + is_initializesystem = true, + kwargs...) + @set isys.parameter_dependencies = new_parameter_deps +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) + solved_params = Set() + 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) + push!(solved_params, p) + # 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 solved_params +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, solved_params::AbstractSet, + 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) + push!(solved_params, eq.lhs) + 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}) + for (k, v) in merge(defaults(sys), pmap) + if is_variable_floatingpoint(k) && has_parameter_dependency_with_lhs(sys, k) + push!(eqs_ics, 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 + +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 + + oldinitdata = odefn.initialization_data + + # We _always_ build initialization now. So if we didn't build it before, don't do + # it now + oldinitdata === nothing && return nothing + + if !(eltype(u0) <: Pair) && !(eltype(p) <: Pair) + oldinitdata === nothing && return nothing + + oldinitprob = oldinitdata.initializeprob + oldinitprob === nothing && return nothing + + meta = oldinitdata.metadata + meta isa InitializationMetadata || return oldinitdata + + reconstruct_fn = meta.oop_reconstruct_u0_p + # 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 = remake(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 @set oldinitdata.initializeprob = initprob + 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) + use_scc = true + initialization_eqs = Equation[] + op = anydict() + + if oldinitdata !== nothing && oldinitdata.metadata isa InitializationMetadata + meta = oldinitdata.metadata + op = copy(meta.op) + merge!(guesses, meta.guesses) + use_scc = meta.use_scc + initialization_eqs = meta.additional_initialization_eqs + time_dependent_init = meta.time_dependent_init + 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 + merge!(op, u0map, pmap) + filter_missing_values!(op) + + u0map = anydict() + pmap = anydict() + missing_unknowns, + missing_pars = build_operating_point!(sys, op, + u0map, pmap, defs, dvs, ps) + floatT = float_type_from_varmap(op) + u0_constructor = p_constructor = identity + if newu0 isa StaticArray + u0_constructor = vals -> SymbolicUtils.Code.create_array( + typeof(newu0), floatT, Val(1), Val(length(vals)), vals...) + end + if newp isa StaticArray || newp isa MTKParameters && newp.initials isa StaticArray + p_constructor = vals -> SymbolicUtils.Code.create_array( + typeof(newp.initials), floatT, Val(1), Val(length(vals)), vals...) + end + kws = maybe_build_initialization_problem( + sys, SciMLBase.isinplace(odefn), op, t0, defs, guesses, + missing_unknowns; time_dependent_init, use_scc, initialization_eqs, floatT, + u0_constructor, p_constructor, allow_incomplete = true, check_units = false) + + odefn = remake(odefn; kws...) + return SciMLBase.remake_initialization_data(sys, odefn, newu0, t0, newp, newu0, newp) +end + +promote_type_with_nothing(::Type{T}, ::Nothing) where {T} = T +promote_type_with_nothing(::Type{T}, ::SizedVector{0}) where {T} = T +function promote_type_with_nothing(::Type{T}, ::AbstractArray{T2}) where {T, T2} + promote_type(T, T2) +end +function promote_type_with_nothing(::Type{T}, p::MTKParameters) where {T} + promote_type_with_nothing(promote_type_with_nothing(T, p.tunable), p.initials) +end + +promote_with_nothing(::Type, ::Nothing) = nothing +promote_with_nothing(::Type, x::SizedVector{0}) = x +promote_with_nothing(::Type{T}, x::AbstractArray{T}) where {T} = x +function promote_with_nothing(::Type{T}, x::AbstractArray{T2}) where {T, T2} + if ArrayInterface.ismutable(x) + y = similar(x, T) + copyto!(y, x) + return y + else + yT = similar_type(x, T) + return yT(x) + end +end +function promote_with_nothing(::Type{T}, p::MTKParameters) where {T} + tunables = promote_with_nothing(T, p.tunable) + p = SciMLStructures.replace(SciMLStructures.Tunable(), p, tunables) + initials = promote_with_nothing(T, p.initials) + p = SciMLStructures.replace(SciMLStructures.Initials(), p, initials) + return p +end + +function promote_u0_p(u0, p, t0) + T = Union{} + T = promote_type_with_nothing(T, u0) + T = promote_type_with_nothing(T, p) + + u0 = promote_with_nothing(T, u0) + p = promote_with_nothing(T, p) + return u0, p +end + +function SciMLBase.late_binding_update_u0_p( + prob, sys::AbstractSystem, u0, p, t0, newu0, newp) + supports_initialization(sys) || return newu0, newp + prob isa IntervalNonlinearProblem && return newu0, newp + prob isa LinearProblem && return newu0, newp + + initdata = prob.f.initialization_data + meta = initdata === nothing ? nothing : initdata.metadata + + newu0, newp = promote_u0_p(newu0, newp, t0) + + # non-symbolic u0 updates initials... + if eltype(u0) <: Pair + syms = [] + vals = [] + 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 + push!(syms, Initial(k)) + push!(vals, v) + end + newp = setp_oop(sys, syms)(newp, vals) + else + allsyms = nothing + # if `p` is not provided or is symbolic + p === missing || eltype(p) <: Pair || return newu0, newp + (newu0 === nothing || isempty(newu0)) && return newu0, newp + initdata === nothing && return newu0, newp + meta = initdata.metadata + meta isa InitializationMetadata || return newu0, newp + newp = p === missing ? copy(newp) : newp + + if length(newu0) != length(prob.u0) + throw(ArgumentError("Expected `newu0` to be of same length as unknowns ($(length(prob.u0))). Got $(typeof(newu0)) of length $(length(newu0))")) + end + newp = meta.set_initial_unknowns!(newp, newu0) + end + + if eltype(p) <: Pair + syms = [] + vals = [] + if allsyms === nothing + allsyms = all_symbols(sys) + end + for (k, v) in p + 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 + push!(syms, Initial(k)) + push!(vals, v) + end + newp = setp_oop(sys, syms)(newp, vals) + end + + return newu0, newp +end + +function DiffEqBase.get_updated_symbolic_problem( + sys::AbstractSystem, prob; u0 = state_values(prob), + p = parameter_values(prob), kw...) + supports_initialization(sys) || return prob + initdata = prob.f.initialization_data + initdata isa SciMLBase.OverrideInitData || return prob + meta = initdata.metadata + meta isa InitializationMetadata || return prob + meta.get_updated_u0 === nothing && return prob + + u0 === nothing && return remake(prob; p) + + t0 = is_time_dependent(prob) ? current_time(prob) : nothing + + if p isa MTKParameters + buffer = p.initials + else + buffer = p + end + + u0 = DiffEqBase.promote_u0(u0, buffer, t0) + + if ArrayInterface.ismutable(u0) + T = typeof(u0) + else + T = StaticArrays.similar_type(u0) + end + + return remake(prob; u0 = T(meta.get_updated_u0(prob, initdata.initializeprob)), p) +end + +""" + $(TYPEDSIGNATURES) + +Check if the given system is an initialization system. +""" +function is_initializesystem(sys::AbstractSystem) + has_is_initializesystem(sys) && get_is_initializesystem(sys) +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 + 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/nonlinearsystem.jl b/src/systems/nonlinear/nonlinearsystem.jl deleted file mode 100644 index 40f1230ccd..0000000000 --- a/src/systems/nonlinear/nonlinearsystem.jl +++ /dev/null @@ -1,317 +0,0 @@ -""" -$(TYPEDEF) - -A nonlinear system of equations. - -# Fields -$(FIELDS) - -# Examples - -```julia -@variables x y z -@parameters σ ρ β - -eqs = [0 ~ σ*(y-x), - 0 ~ x*(ρ-z)-y, - 0 ~ x*y - β*z] -ns = NonlinearSystem(eqs, [x,y,z],[σ,ρ,β]) -``` -""" -struct NonlinearSystem <: AbstractSystem - """Vector of equations defining the system.""" - eqs::Vector{Equation} - """Unknown variables.""" - states::Vector - """Parameters.""" - ps::Vector - observed::Vector{Equation} - """ - Name: the name of the system - """ - name::Symbol - """ - systems: The internal systems - """ - systems::Vector{NonlinearSystem} - """ - defaults: The default values to use when initial conditions and/or - parameters are not supplied in `ODEProblem`. - """ - defaults::Dict - """ - structure: structural information of the system - """ - structure::Any - """ - type: type of the system - """ - connection_type::Any -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) - 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) -end - -function calculate_jacobian(sys::NonlinearSystem;sparse=false,simplify=false) - rhs = [eq.rhs for eq ∈ equations(sys)] - vals = [dv for dv in states(sys)] - if sparse - jac = sparsejacobian(rhs, vals, simplify=simplify) - else - jac = jacobian(rhs, vals, simplify=simplify) - end - 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...) -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)) - - 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) - - dvs′ = fulldvs′[1:length(dvs)] - ps′ = makesym.(value.(ps), states=()) - return build_function(rhss, dvs′, ps′; - conv = AbstractSysToExpr(sys), kwargs...) -end - -jacobian_sparsity(sys::NonlinearSystem) = - jacobian_sparsity([eq.rhs for eq ∈ equations(sys)], - states(sys)) - -function DiffEqBase.NonlinearFunction(sys::NonlinearSystem, args...; kwargs...) - NonlinearFunction{true}(sys, args...; kwargs...) -end - -""" -```julia -function DiffEqBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, - jac = false, - sparse = false, - kwargs...) where {iip} -``` - -Create an `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} - - 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) - - 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) - 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 - end - - NonlinearFunction{iip}(f, - jac = _jac === nothing ? nothing : _jac, - jac_prototype = sparse ? similar(sys.jac[],Float64) : nothing, - syms = Symbol.(states(sys)), observed = observedfun) -end - -""" -```julia -function DiffEqBase.NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = states(sys), - ps = parameters(sys); - version = nothing, - 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 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] - - if jac - _jac = generate_jacobian(sys, dvs, ps; - sparse=sparse, simplify=simplify, - expression=Val{true}, kwargs...)[idx] - else - _jac = :nothing - end - - jp_expr = sparse ? :(similar($(sys.jac[]),Float64)) : :nothing - - ex = quote - f = $f - jac = $_jac - NonlinearFunction{$iip}(f, - jac = jac, - jac_prototype = $jp_expr, - syms = $(Symbol.(states(sys)))) - end - !linenumbers ? striplines(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) - - check_eqs_u0(eqs, dvs, u0) - - 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 -end - -function DiffEqBase.NonlinearProblem(sys::NonlinearSystem, args...; kwargs...) - NonlinearProblem{true}(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 -``` - -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...) -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 -``` - -Generates a Julia expression for a NonlinearProblem from a -NonlinearSystem and allows for automatically symbolically calculating -numerical enhancements. -""" -struct NonlinearProblemExpr{iip} end - -function NonlinearProblemExpr(sys::NonlinearSystem, args...; kwargs...) - NonlinearProblemExpr{true}(sys, args...; kwargs...) -end - -function NonlinearProblemExpr{iip}(sys::NonlinearSystem,u0map, - parammap=DiffEqBase.NullParameters(); - kwargs...) where iip - - f, u0, p = process_NonlinearProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; kwargs...) - linenumbers = get(kwargs, :linenumbers, true) - - ex = quote - f = $f - u0 = $u0 - p = $p - NonlinearProblem(f,u0,p;$(kwargs...)) - end - !linenumbers ? striplines(ex) : ex -end - -function flatten(sys::NonlinearSystem) - 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), - ) - 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))) -end diff --git a/src/systems/optimal_control_interface.jl b/src/systems/optimal_control_interface.jl new file mode 100644 index 0000000000..5a0ddbf8d5 --- /dev/null +++ b/src/systems/optimal_control_interface.jl @@ -0,0 +1,496 @@ +abstract type AbstractDynamicOptProblem{uType, tType, isinplace} <: + SciMLBase.AbstractODEProblem{uType, tType, isinplace} end + +abstract type AbstractCollocation end + +struct DynamicOptSolution + model::Any + sol::ODESolution + input_sol::Union{Nothing, ODESolution} +end + +function Base.show(io::IO, sol::DynamicOptSolution) + println("retcode: ", sol.sol.retcode, "\n") + + println("Optimal control solution for following model:\n") + show(sol.model) + + print("\n\nPlease query the model using sol.model, the solution trajectory for the system using sol.sol, or the solution trajectory for the controllers using sol.input_sol.") +end + +""" + JuMPDynamicOptProblem(sys::System, op, tspan; dt, steps, guesses, kwargs...) + +Convert an System representing an optimal control system into a JuMP model +for solving using optimization. Must provide either `dt`, the timestep between collocation +points (which, along with the timespan, determines the number of points), or directly +provide the number of points as `steps`. + +To construct the problem, please load InfiniteOpt along with ModelingToolkit. +""" +function JuMPDynamicOptProblem end +""" + InfiniteOptDynamicOptProblem(sys::System, op, tspan; dt) + +Convert an System representing an optimal control system into a InfiniteOpt model +for solving using optimization. Must provide `dt` for determining the length +of the interpolation arrays. + +Related to `JuMPDynamicOptProblem`, but directly adds the differential equations +of the system as derivative constraints, rather than using a solver tableau. + +To construct the problem, please load InfiniteOpt along with ModelingToolkit. +""" +function InfiniteOptDynamicOptProblem end +""" + CasADiDynamicOptProblem(sys::System, op, tspan; dt, steps, guesses, kwargs...) + +Convert an System representing an optimal control system into a CasADi model +for solving using optimization. Must provide either `dt`, the timestep between collocation +points (which, along with the timespan, determines the number of points), or directly +provide the number of points as `steps`. + +To construct the problem, please load CasADi along with ModelingToolkit. +""" +function CasADiDynamicOptProblem end +""" + PyomoDynamicOptProblem(sys::System, op, tspan; dt, steps) + +Convert an System representing an optimal control system into a Pyomo model +for solving using optimization. Must provide either `dt`, the timestep between collocation +points (which, along with the timespan, determines the number of points), or directly +provide the number of points as `steps`. + +To construct the problem, please load Pyomo along with ModelingToolkit. +""" +function PyomoDynamicOptProblem end + +### Collocations +""" +JuMP Collocation solver. Takes two arguments: +- `solver`: a optimization solver such as Ipopt +- `tableau`: An ODE RK tableau. Load a tableau by calling a function like `constructRK4` and may be found at https://docs.sciml.ai/DiffEqDevDocs/stable/internals/tableaus/. If this argument is not passed in, the solver will default to Radau second order. +""" +function JuMPCollocation end +""" +InfiniteOpt Collocation solver. +- `solver`: an optimization solver such as Ipopt +- `derivative_method`: the method used by InfiniteOpt to compute derivatives. The list of possible options can be found at https://infiniteopt.github.io/InfiniteOpt.jl/stable/guide/derivative/. Defaults to FiniteDifference(Backward()). +""" +function InfiniteOptCollocation end +""" +CasADi Collocation solver. +- `solver`: an optimization solver such as Ipopt. Should be given as a string or symbol in all lowercase, e.g. "ipopt" +- `tableau`: An ODE RK tableau. Load a tableau by calling a function like `constructRK4` and may be found at https://docs.sciml.ai/DiffEqDevDocs/stable/internals/tableaus/. If this argument is not passed in, the solver will default to Radau second order. +""" +function CasADiCollocation end +""" +Pyomo Collocation solver. +- `solver`: an optimization solver such as Ipopt. Should be given as a string or symbol in all lowercase, e.g. "ipopt" +- `derivative_method`: a derivative method from Pyomo. The choices here are ForwardEuler, BackwardEuler, MidpointEuler, LagrangeRadau, or LagrangeLegendre. The last two should additionally have a number indicating the number of collocation points per timestep, e.g. PyomoCollocation("ipopt", LagrangeRadau(3)). Defaults to LagrangeRadau(5). +""" +function PyomoCollocation end + +function warn_overdetermined(sys, op) + cstrs = constraints(sys) + init_conds = filter(x -> value(x) ∈ Set(unknowns(sys)), [k for (k, v) in op]) + if !isempty(cstrs) + (length(cstrs) + length(init_conds) > length(unknowns(sys))) && + @warn "The control problem is overdetermined. The total number of conditions (# constraints + # fixed initial values given by op) exceeds the total number of states. The solvers will default to doing a nonlinear least-squares optimization." + end +end + +""" +Default ODE Tableau: RadauIIA5 +""" +function constructDefault(T::Type = Float64) + sq6 = sqrt(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) + + DiffEqBase.ImplicitRKTableau(A, c, α, 5) +end + +is_explicit(tableau) = tableau isa DiffEqBase.ExplicitRKTableau + +@fallback_iip_specialize function SciMLBase.ODEInputFunction{iip, specialize}(sys::System; + inputs = unbound_inputs(sys), + disturbance_inputs = disturbances(sys), + u0 = nothing, tgrad = false, + jac = false, controljac = false, + p = nothing, t = nothing, + eval_expression = false, + sparse = false, simplify = false, + eval_module = @__MODULE__, + steady_state = false, + checkbounds = false, + sparsity = false, + analytic = nothing, + initialization_data = nothing, + cse = true, + kwargs...) where {iip, specialize} + f, _, + _ = generate_control_function( + sys, inputs, disturbance_inputs; eval_module, cse, kwargs...) + f = f[1] + + if tgrad + _tgrad = generate_tgrad(sys; + simplify = simplify, + expression = Val{true}, + wrap_gfw = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + else + _tgrad = nothing + end + + if jac + _jac = generate_jacobian(sys; + simplify = simplify, sparse = sparse, + expression = Val{true}, + wrap_gfw = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + else + _jac = nothing + end + + if controljac + _cjac = generate_control_jacobian(sys; + simplify = simplify, sparse = sparse, + expression = Val{true}, wrap_gfw = Val{true}, + expression_module = eval_module, cse, + checkbounds = checkbounds, kwargs...) + else + _cjac = nothing + end + + M = calculate_massmatrix(sys) + _M = concrete_massmatrix(M; sparse, u0) + + observedfun = ObservedFunctionCache( + sys; steady_state, eval_expression, eval_module, checkbounds, cse) + + _W_sparsity = W_sparsity(sys) + W_prototype = calculate_W_prototype(_W_sparsity; u0, sparse) + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + controljac_prototype = similar(calculate_control_jacobian(sys), uElType) + else + controljac_prototype = nothing + end + + ODEInputFunction{iip, specialize}(f; + sys = sys, + jac = _jac === nothing ? nothing : _jac, + controljac = _cjac === nothing ? nothing : _cjac, + tgrad = _tgrad === nothing ? nothing : _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + controljac_prototype = controljac_prototype, + observed = observedfun, + sparsity = sparsity ? _W_sparsity : nothing, + analytic = analytic, + initialization_data) +end + +# returns the JuMP timespan, the number of steps, and whether it is a free time problem. +function process_tspan(tspan, dt, steps) + is_free_time = false + symbolic_type(tspan[1]) !== NotSymbolic() && + error("Free initial time problems are not currently supported by the collocation solvers.") + + if isnothing(dt) && isnothing(steps) + error("Must provide either the dt or the number of intervals to the collocation solvers (JuMP, InfiniteOpt, CasADi).") + elseif symbolic_type(tspan[1]) === ScalarSymbolic() || + symbolic_type(tspan[2]) === ScalarSymbolic() + isnothing(steps) && + error("Free final time problems require specifying the number of steps using the keyword arg `steps`, rather than dt.") + isnothing(dt) || + @warn "Specified dt for free final time problem. This will be ignored; dt will be determined by the number of timesteps." + + return (0, 1), steps, true + else + isnothing(steps) || + @warn "Specified number of steps for problem with concrete tspan. This will be ignored; number of steps will be determined by dt." + + return tspan, length(tspan[1]:dt:tspan[2]), false + end +end + +########################## +### MODEL CONSTRUCTION ### +########################## +function process_DynamicOptProblem( + prob_type::Type{<:AbstractDynamicOptProblem}, model_type, sys::System, op, tspan; + dt = nothing, + steps = nothing, + guesses = Dict(), kwargs...) + warn_overdetermined(sys, op) + ctrls = unbound_inputs(sys) + states = unknowns(sys) + + stidxmap = Dict([v => i for (i, v) in enumerate(states)]) + op = Dict([default_toterm(value(k)) => v for (k, v) in op]) + u0_idxs = has_alg_eqs(sys) ? collect(1:length(states)) : + [stidxmap[default_toterm(k)] for (k, v) in op if haskey(stidxmap, k)] + + _op = has_alg_eqs(sys) ? op : merge(Dict(op), Dict(guesses)) + f, u0, + p = process_SciMLProblem(ODEInputFunction, sys, _op; + t = tspan !== nothing ? tspan[1] : tspan, kwargs...) + model_tspan, steps, is_free_t = process_tspan(tspan, dt, steps) + warn_overdetermined(sys, op) + + pmap = filter(p -> (first(p) ∉ Set(unknowns(sys))), op) + pmap = recursive_unwrap(AnyDict(pmap)) + evaluate_varmap!(pmap, keys(pmap)) + c0 = value.([pmap[c] for c in ctrls]) + + tsteps = LinRange(model_tspan[1], model_tspan[2], steps) + model = generate_internal_model(model_type) + generate_time_variable!(model, model_tspan, tsteps) + U = generate_state_variable!(model, u0, length(states), tsteps) + V = generate_input_variable!(model, c0, length(ctrls), tsteps) + tₛ = generate_timescale!(model, get(pmap, tspan[2], tspan[2]), is_free_t) + fullmodel = model_type(model, U, V, tₛ, is_free_t) + + set_variable_bounds!(fullmodel, sys, pmap, tspan[2]) + add_cost_function!(fullmodel, sys, tspan, pmap) + add_user_constraints!(fullmodel, sys, tspan, pmap) + add_initial_constraints!(fullmodel, u0, u0_idxs, model_tspan[1]) + + prob_type(f, u0, tspan, p, fullmodel, kwargs...), pmap +end + +function generate_time_variable! end +function generate_internal_model end +function generate_state_variable! end +function generate_input_variable! end +function generate_timescale! end +function add_initial_constraints! end +function add_constraint! end + +function set_variable_bounds!(m, sys, pmap, tf) + @unpack model, U, V, tₛ = m + t = get_iv(sys) + for (i, u) in enumerate(unknowns(sys)) + var = lowered_var(m, :U, i, t) + if hasbounds(u) + lo, hi = getbounds(u) + add_constraint!(m, var ≳ Symbolics.fixpoint_sub(lo, pmap)) + add_constraint!(m, var ≲ Symbolics.fixpoint_sub(hi, pmap)) + end + end + for (i, v) in enumerate(unbound_inputs(sys)) + var = lowered_var(m, :V, i, t) + if hasbounds(v) + lo, hi = getbounds(v) + add_constraint!(m, var ≳ Symbolics.fixpoint_sub(lo, pmap)) + add_constraint!(m, var ≲ Symbolics.fixpoint_sub(hi, pmap)) + end + end + if symbolic_type(tf) === ScalarSymbolic() && hasbounds(tf) + lo, hi = getbounds(tf) + set_lower_bound(tₛ, Symbolics.fixpoint_sub(lo, pmap)) + set_upper_bound(tₛ, Symbolics.fixpoint_sub(hi, pmap)) + end +end + +is_free_final(model) = model.is_free_final + +function add_cost_function!(model, sys, tspan, pmap) + jcosts = cost(sys) + if Symbolics._iszero(jcosts) + set_objective!(model, 0) + return + end + + jcosts = substitute_model_vars(model, sys, [jcosts], tspan) + jcosts = substitute_params(pmap, jcosts) + jcosts = substitute_integral(model, only(jcosts), tspan) + set_objective!(model, value(jcosts)) +end + +""" +Substitute integrals. For an integral from (ts, te): +- Free final time problems should transcribe this to (0, 1) in the case that (ts, te) is the original timespan. Free final time problems cannot handle partial timespans. +- CasADi cannot handle partial timespans, even for non-free-final time problems. +time problems and unchanged otherwise. +""" +function substitute_integral(model, expr, tspan) + intmap = Dict() + for int in collect_applied_operators(expr, Symbolics.Integral) + op = operation(int) + arg = only(arguments(value(int))) + lo, hi = value.((op.domain.domain.left, op.domain.domain.right)) + lo, hi = process_integral_bounds(model, (lo, hi), tspan) + intmap[int] = lowered_integral(model, arg, lo, hi) + end + Symbolics.substitute(expr, intmap) +end + +function process_integral_bounds(model, integral_span, tspan) + if is_free_final(model) && isequal(integral_span, tspan) + integral_span = (0, 1) + elseif is_free_final(model) + error("Free final time problems cannot handle partial timespans.") + else + (lo, hi) = integral_span + (lo < tspan[1] || hi > tspan[2]) && + error("Integral bounds are beyond the timespan.") + integral_span + end +end + +"""Substitute variables like x(1.5), x(t), etc. with the corresponding model variables.""" +function substitute_model_vars(model, sys, exprs, tspan) + x_ops = [operation(unwrap(st)) for st in unknowns(sys)] + c_ops = [operation(unwrap(ct)) for ct in unbound_inputs(sys)] + t = get_iv(sys) + + exprs = map( + c -> Symbolics.fast_substitute(c, whole_t_map(model, t, x_ops, c_ops)), exprs) + + (ti, tf) = tspan + if symbolic_type(tf) === ScalarSymbolic() + _tf = model.tₛ + ti + exprs = map( + c -> Symbolics.fast_substitute(c, free_t_map(model, tf, x_ops, c_ops)), exprs) + exprs = map(c -> Symbolics.fast_substitute(c, Dict(tf => _tf)), exprs) + end + exprs = map(c -> Symbolics.fast_substitute(c, fixed_t_map(model, x_ops, c_ops)), exprs) + exprs +end + +"""Mappings for variables that depend on the final time parameter, x(tf).""" +function free_t_map(m, tf, x_ops, c_ops) + Dict([[x(tf) => lowered_var(m, :U, i, 1) for (i, x) in enumerate(x_ops)]; + [c(tf) => lowered_var(m, :V, i, 1) for (i, c) in enumerate(c_ops)]]) +end + +"""Mappings for variables that cover the whole timespan, x(t).""" +function whole_t_map(m, t, x_ops, c_ops) + Dict([[v(t) => lowered_var(m, :U, i, t) for (i, v) in enumerate(x_ops)]; + [v(t) => lowered_var(m, :V, i, t) for (i, v) in enumerate(c_ops)]]) +end + +"""Mappings for variables that cover the whole timespan, x(t).""" +function fixed_t_map(m, x_ops, c_ops) + Dict([[v => (t -> lowered_var(m, :U, i, t)) for (i, v) in enumerate(x_ops)]; + [v => (t -> lowered_var(m, :V, i, t)) for (i, v) in enumerate(c_ops)]]) +end + +function process_integral_bounds end +function lowered_integral end +function lowered_derivative end +function lowered_var end +function fixed_t_map end + +function add_user_constraints!(model, sys, tspan, pmap) + jconstraints = get_constraints(sys) + (isnothing(jconstraints) || isempty(jconstraints)) && return nothing + cons_dvs, + cons_ps = process_constraint_system( + jconstraints, Set(unknowns(sys)), parameters(sys), get_iv(sys); validate = false) + + is_free_final(model) && check_constraint_vars(cons_dvs) + + jconstraints = substitute_toterm(cons_dvs, jconstraints) + jconstraints = substitute_model_vars(model, sys, jconstraints, tspan) + jconstraints = substitute_params(pmap, jconstraints) + + for c in jconstraints + add_constraint!(model, c) + end +end + +function add_equational_constraints!(model, sys, pmap, tspan) + diff_eqs = substitute_model_vars(model, sys, diff_equations(sys), tspan) + diff_eqs = substitute_params(pmap, diff_eqs) + diff_eqs = substitute_differentials(model, sys, diff_eqs) + for eq in diff_eqs + add_constraint!(model, eq.lhs ~ eq.rhs * model.tₛ) + end + + alg_eqs = substitute_model_vars(model, sys, alg_equations(sys), tspan) + alg_eqs = substitute_params(pmap, alg_eqs) + for eq in alg_eqs + add_constraint!(model, eq.lhs ~ eq.rhs) + end +end + +function set_objective! end +objective_value(sol::DynamicOptSolution) = objective_value(sol.model) + +function substitute_differentials(model, sys, eqs) + t = get_iv(sys) + D = Differential(t) + diffsubmap = Dict([D(lowered_var(model, :U, i, t)) => lowered_derivative(model, i) + for i in 1:length(unknowns(sys))]) + eqs = map(c -> Symbolics.substitute(c, diffsubmap), eqs) +end + +function substitute_toterm(vars, exprs) + toterm_map = Dict([u => default_toterm(value(u)) for u in vars]) + exprs = map(c -> Symbolics.fast_substitute(c, toterm_map), exprs) +end + +function substitute_params(pmap, exprs) + exprs = map(c -> Symbolics.fixpoint_sub(c, Dict(pmap)), exprs) +end + +function check_constraint_vars(vars) + for u in vars + x = operation(u) + t = only(arguments(u)) + if (symbolic_type(t) === NotSymbolic()) + error("Provided specific time constraint in a free final time problem. This is not supported by the collocation solvers at the moment. The offending variable is $u. Specific-time user constraints can only be specified at the end of the timespan.") + end + end +end + +######################## +### SOLVER UTILITIES ### +######################## +""" +Add the solve constraints, set the solver (Ipopt, e.g.) and solver options, optimize the model. +""" +function prepare_and_optimize! end +function get_t_values end +function get_U_values end +function get_V_values end +function successful_solve end + +""" + solve(prob::AbstractDynamicOptProblem, solver::AbstractCollocation; verbose = false, kwargs...) + +- kwargs are used for other options. For example, the `plugin_options` and `solver_options` will propagated to the Opti object in CasADi. +""" +function DiffEqBase.solve(prob::AbstractDynamicOptProblem, + solver::AbstractCollocation; verbose = false, kwargs...) + solved_model = prepare_and_optimize!(prob, solver; verbose, kwargs...) + + ts = get_t_values(solved_model) + Us = get_U_values(solved_model) + Vs = get_V_values(solved_model) + is_free_final(prob.wrapped_model) && (ts .+ prob.tspan[1]) + + ode_sol = DiffEqBase.build_solution(prob, solver, ts, Us) + input_sol = isnothing(Vs) ? nothing : DiffEqBase.build_solution(prob, solver, ts, Vs) + + if !successful_solve(solved_model) + ode_sol = SciMLBase.solution_new_retcode( + ode_sol, SciMLBase.ReturnCode.ConvergenceFailure) + !isnothing(input_sol) && (input_sol = SciMLBase.solution_new_retcode( + input_sol, SciMLBase.ReturnCode.ConvergenceFailure)) + end + DynamicOptSolution(solved_model, ode_sol, input_sol) +end diff --git a/src/systems/optimization/optimizationsystem.jl b/src/systems/optimization/optimizationsystem.jl deleted file mode 100644 index 30380ca93c..0000000000 --- a/src/systems/optimization/optimizationsystem.jl +++ /dev/null @@ -1,242 +0,0 @@ -""" -$(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 diff --git a/src/systems/parameter_buffer.jl b/src/systems/parameter_buffer.jl new file mode 100644 index 0000000000..637ed674ae --- /dev/null +++ b/src/systems/parameter_buffer.jl @@ -0,0 +1,961 @@ +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 +`mtkcompile` or `@mtkcompile`) and the keyword `split = true` was passed (which is +the default behavior). +""" +function MTKParameters( + sys::AbstractSystem, op; tofloat = false, + t0 = nothing, substitution_limit = 1000, floatT = nothing, + p_constructor = identity, fast_path = false) + 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) + op = to_varmap(op, ps) + symbols_to_symbolics!(sys, op) + defs = add_toterms(recursive_unwrap(defaults(sys))) + + is_time_dependent(sys) && add_observed!(sys, op) + add_parameter_dependencies!(sys, op) + + u0map = anydict() + pmap = anydict() + if fast_path + missing_pars = missingvars(op, ps) + else + _, missing_pars = build_operating_point!(sys, op, + u0map, pmap, defs, dvs, ps) + end + 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; p_constructor) + if isempty(tunable_buffer) + tunable_buffer = SizedVector{0, Float64}() + end + initials_buffer = narrow_buffer_type(initials_buffer; p_constructor) + if isempty(initials_buffer) + initials_buffer = SizedVector{0, Float64}() + end + disc_buffer = narrow_buffer_type.(disc_buffer; p_constructor) + const_buffer = narrow_buffer_type.(const_buffer; p_constructor) + # Don't narrow nonnumeric types + if !isempty(nonnumeric_buffer) + nonnumeric_buffer = map(p_constructor, nonnumeric_buffer) + end + + 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; p_constructor = identity) + type = Union{} + for x in buffer + type = promote_type(type, typeof(x)) + end + return p_constructor(type.(buffer)) +end + +function narrow_buffer_type( + buffer::AbstractArray{<:AbstractArray}; p_constructor = identity) + type = Union{} + for arr in buffer + for x in arr + type = promote_type(type, typeof(x)) + end + end + buffer = map(buffer) do buf + p_constructor(type.(buf)) + end + return p_constructor(buffer) +end + +function narrow_buffer_type(buffer::BlockedArray; p_constructor = identity) + if eltype(buffer) <: AbstractArray + buffer = narrow_buffer_type.(buffer; p_constructor) + end + type = Union{} + for x in buffer + type = promote_type(type, typeof(x)) + end + tmp = p_constructor(type.(buffer)) + blocks = ntuple(Val(ndims(buffer))) do i + bsizes = blocksizes(buffer, i) + p_constructor(Int.(bsizes)) + end + return BlockedArray(tmp, blocks...) +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 = isempty(p.nonnumeric) ? p.nonnumeric : copy.(p.nonnumeric) + caches = isempty(p.caches) ? p.caches : copy.(p.caches) + return MTKParameters( + tunable, + initials, + discrete, + constant, + nonnumeric, + caches + ) +end + +function ArrayInterface.ismutable(::Type{MTKParameters{ + T, I, D, C, N, H}}) where {T, I, D, C, N, H} + ArrayInterface.ismutable(T) || ArrayInterface.ismutable(I) || + any(ArrayInterface.ismutable, fieldtypes(D)) || + any(ArrayInterface.ismutable, fieldtypes(C)) || + any(ArrayInterface.ismutable, fieldtypes(N)) +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))...) + caches = copy.(oldbuf.caches) + newbuf = MTKParameters( + tunables, initials, discretes, constants, nonnumerics, 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 + if !ArrayInterface.ismutable(oldbuf) + push!(expr.args, :(tunables = $similar_type($T, $tunablesT)(tunables))) + push!(expr.args, :(initials = $similar_type($I, $initialsT)(initials))) + push!(expr.args, + :(discretes = $(Expr(:tuple, + (:($similar_type($(fieldtype(D, i)), $(discretesT[i]))(discretes[$i])) + for i in 1:length(discretesT))...)))) + push!(expr.args, + :(constants = $(Expr(:tuple, + (:($similar_type($(fieldtype(C, i)), $(constantsT[i]))(constants[$i])) + for i in 1:length(constantsT))...)))) + push!(expr.args, + :(nonnumerics = $(Expr(:tuple, + (:($similar_type($(fieldtype(C, i)), $(nonnumericT[i]))(nonnumerics[$i])) + for i in 1:length(nonnumericT))...)))) + push!(expr.args, + :(newbuf = MTKParameters( + tunables, initials, discretes, constants, nonnumerics, caches))) + 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) + if !ArrayInterface.ismutable(oldbuf) + @set! newbuf.tunable = similar_type(oldbuf.tunable, eltype(newbuf.tunable))(newbuf.tunable) + @set! newbuf.initials = similar_type(oldbuf.initials, eltype(newbuf.initials))(newbuf.initials) + @set! newbuf.discrete = ntuple(Val(length(newbuf.discrete))) do i + similar_type.(oldbuf.discrete[i], eltype(newbuf.discrete[i]))(newbuf.discrete[i]) + end + @set! newbuf.constant = ntuple(Val(length(newbuf.constant))) do i + similar_type.(oldbuf.constant[i], eltype(newbuf.constant[i]))(newbuf.constant[i]) + end + @set! newbuf.nonnumeric = ntuple(Val(length(newbuf.nonnumeric))) do i + similar_type.(oldbuf.nonnumeric[i], eltype(newbuf.nonnumeric[i]))(newbuf.nonnumeric[i]) + end + end + 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..d44a9cbd3f 100644 --- a/src/systems/pde/pdesystem.jl +++ b/src/systems/pde/pdesystem.jl @@ -1,81 +1,168 @@ -""" -$(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 <: AbstractSystem + "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 diff --git a/src/systems/problem_utils.jl b/src/systems/problem_utils.jl new file mode 100644 index 0000000000..7a9bc2617e --- /dev/null +++ b/src/systems/problem_utils.jl @@ -0,0 +1,1812 @@ +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. `replace` controls whether +the old value should be removed. +""" +function add_toterms!(varmap::AbstractDict; toterm = default_toterm, replace = false) + for k in collect(keys(varmap)) + ttk = toterm(unwrap(k)) + haskey(varmap, ttk) && continue + varmap[ttk] = varmap[k] + !isequal(k, ttk) && replace && delete!(varmap, k) + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Out-of-place version of [`add_toterms!`](@ref). +""" +function add_toterms(varmap::AbstractDict; kwargs...) + cp = copy(varmap) + add_toterms!(cp; kwargs...) + 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 + +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 + +""" + $(TYPEDSIGNATURES) + +Return an array of values where the `i`th element corresponds to the value of `vars[i]` +in `varmap`. Will mutate `varmap` by symbolically substituting it into itself. + +Keyword arguments: +- `container_type`: The type of the returned container. +- `allow_symbolic`: Whether the returned container of values can have symbolic expressions. +- `buffer_eltype`: The `eltype` of the returned container if `!allow_symbolic`. If + `Nothing`, automatically promotes the values in the container to a common `eltype`. +- `tofloat`: Whether to promote values to floating point numbers if + `buffer_eltype == Nothing`. +- `use_union`: Whether to allow using a `Union` as the `eltype` if + `buffer_eltype == Nothing`. +- `toterm`: The `toterm` function for canonicalizing keys of `varmap`. A value of `nothing` + disables this process. +- `check`: Whether to check if all of `vars` are keys of `varmap`. +- `is_initializeprob`: Whether an initialization problem is being constructed. Used for + better error messages. +- `substitution_limit`: The maximum number of times to recursively substitute `varmap` into + itself to get a numeric value for each variable in `vars`. +""" +function varmap_to_vars(varmap::AbstractDict, vars::Vector; + tofloat = true, use_union = false, container_type = Array, buffer_eltype = Nothing, + toterm = default_toterm, check = true, allow_symbolic = false, + is_initializeprob = false, substitution_limit = 100) + isempty(vars) && return nothing + + varmap = recursive_unwrap(varmap) + if toterm !== nothing + add_toterms!(varmap; toterm) + end + if check && !allow_symbolic + missing_vars = missingvars(varmap, vars; toterm) + if !isempty(missing_vars) + if is_initializeprob + throw(MissingGuessError(collect(missing_vars), collect(missing_vars))) + else + throw(MissingVariablesError(missing_vars)) + end + end + end + evaluate_varmap!(varmap, vars; limit = substitution_limit) + vals = map(x -> get(varmap, x, 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 buffer_eltype == Nothing + vals = promote_to_concrete(vals; tofloat, use_union) + else + vals = Vector{buffer_eltype}(vals) + end + end + + if container_type <: Union{AbstractDict, Nothing, SciMLBase.NullParameters} + container_type = Array + 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 + v = get(varmap, k, nothing) + v === nothing && continue + symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v) && continue + haskey(varmap, k) || continue + varmap[k] = fixpoint_sub(v, 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 + +""" + $(TYPEDSIGNATURES) + +For each array variable in `vars`, scalarize the corresponding entry in `varmap`. +If a scalarized entry already exists, it is not overridden. +""" +function scalarize_vars_in_varmap!(varmap::AbstractDict, vars) + for var in vars + symbolic_type(var) == ArraySymbolic() || continue + is_sized_array_symbolic(var) || continue + haskey(varmap, var) || continue + for i in eachindex(var) + haskey(varmap, var[i]) && continue + varmap[var[i]] = varmap[var][i] + end + end +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{iip, A, K} <: SciMLBase.AbstractSciMLFunction{iip} + args::A + kwargs::K +end + +function EmptySciMLFunction{iip}(args...; kwargs...) where {iip} + return EmptySciMLFunction{iip, 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`, 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, + op::AbstractDict, u0map::AbstractDict, pmap::AbstractDict, defs::AbstractDict, dvs, ps) + add_toterms!(op) + 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) + + 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 + + if !isempty(neithermap) + for (k, v) in u0map + symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v) && continue + v = fixpoint_sub(v, neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + u0map[k] = v + end + for (k, v) in pmap + symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v) && continue + v = fixpoint_sub(v, neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + pmap[k] = v + end + end + + return missing_unknowns, missing_pars +end + +""" + $(TYPEDEF) + +A callable struct used to reconstruct the `u0` and `p` of the initialization problem +with promoted types. + +# Fields + +$(TYPEDFIELDS) +""" +struct ReconstructInitializeprob{GP, GU} + """ + A function which when given the original problem and initialization problem, returns + the parameter object of the initialization problem with values copied from the + original. + """ + pgetter::GP + """ + Given the original problem, return the `u0` of the initialization problem. + """ + ugetter::GU +end + +""" + $(TYPEDEF) + +A wrapper over an observed function which allows calling it on a problem-like object. +`TD` determines whether the getter function is `(u, p, t)` (if `true`) or `(u, p)` (if +`false`). +""" +struct ObservedWrapper{TD, F} + f::F +end + +ObservedWrapper{TD}(f::F) where {TD, F} = ObservedWrapper{TD, F}(f) + +function (ow::ObservedWrapper{true})(prob) + ow.f(state_values(prob), parameter_values(prob), current_time(prob)) +end + +function (ow::ObservedWrapper{false})(prob) + ow.f(state_values(prob), parameter_values(prob)) +end + +""" + $(TYPEDSIGNATURES) + +Given an index provider `indp` and a vector of symbols `syms` return a type-stable getter +function. + +Note that the getter ONLY works for problem-like objects, since it generates an observed +function. It does NOT work for solutions. +""" +Base.@nospecializeinfer function concrete_getu(indp, syms::AbstractVector) + @nospecialize + obsfn = build_explicit_observed_function(indp, syms; wrap_delays = false) + return ObservedWrapper{is_time_dependent(indp)}(obsfn) +end + +""" + $(TYPEDEF) + +A callable struct which applies `p_constructor` to possibly nested arrays. It also +ensures that views (including nested ones) are concretized. This is implemented manually +of using `narrow_buffer_type` to preserve type-stability. +""" +struct PConstructorApplicator{F} + p_constructor::F +end + +function (pca::PConstructorApplicator)(x::AbstractArray) + pca.p_constructor(x) +end + +function (pca::PConstructorApplicator)(x::AbstractArray{Bool}) + pca.p_constructor(BitArray(x)) +end + +function (pca::PConstructorApplicator{typeof(identity)})(x::SubArray) + collect(x) +end + +function (pca::PConstructorApplicator{typeof(identity)})(x::SubArray{Bool}) + BitArray(x) +end + +function (pca::PConstructorApplicator{typeof(identity)})(x::SubArray{<:AbstractArray}) + collect(pca.(x)) +end + +function (pca::PConstructorApplicator)(x::AbstractArray{<:AbstractArray}) + pca.p_constructor(pca.(x)) +end + +""" + $(TYPEDSIGNATURES) + +Given a source system `srcsys` and destination system `dstsys`, return a function that +takes a value provider of `srcsys` and a value provider of `dstsys` and returns the +`MTKParameters` object of the latter with values from the former. + +# Keyword Arguments +- `initials`: Whether to include the `Initial` parameters of `dstsys` among the values + to be transferred. +- `unwrap_initials`: Whether initials in `dstsys` corresponding to unknowns in `srcsys` are + unwrapped. +- `p_constructor`: The `p_constructor` argument to `process_SciMLProblem`. +""" +function get_mtkparameters_reconstructor(srcsys::AbstractSystem, dstsys::AbstractSystem; + initials = false, unwrap_initials = false, p_constructor = identity) + _p_constructor = p_constructor + p_constructor = PConstructorApplicator(p_constructor) + # if we call `getu` on this (and it were able to handle empty tuples) we get the + # fields of `MTKParameters` except caches. + syms = reorder_parameters( + dstsys, parameters(dstsys; initial_parameters = initials); flatten = false) + # `dstsys` is an initialization system, do basically everything is a tunable + # and tunables are a mix of different types in `srcsys`. No initials. Constants + # are going to be constants in `srcsys`, as are `nonnumeric`. + + # `syms[1]` is always the tunables because `srcsys` will have initials. + tunable_syms = syms[1] + tunable_getter = if isempty(tunable_syms) + Returns(SizedVector{0, Float64}()) + else + p_constructor ∘ concrete_getu(srcsys, tunable_syms) + end + initials_getter = if initials && !isempty(syms[2]) + initsyms = Vector{Any}(syms[2]) + allsyms = Set(variable_symbols(srcsys)) + if unwrap_initials + for i in eachindex(initsyms) + sym = initsyms[i] + innersym = if operation(sym) === getindex + sym, idxs... = arguments(sym) + only(arguments(sym))[idxs...] + else + only(arguments(sym)) + end + if innersym in allsyms + initsyms[i] = innersym + end + end + end + p_constructor ∘ concrete_getu(srcsys, initsyms) + else + Returns(SizedVector{0, Float64}()) + end + discs_getter = if isempty(syms[3]) + Returns(()) + else + ic = get_index_cache(dstsys) + blockarrsizes = Tuple(map(ic.discrete_buffer_sizes) do bufsizes + p_constructor(map(x -> x.length, bufsizes)) + end) + # discretes need to be blocked arrays + # the `getu` returns a tuple of arrays corresponding to `p.discretes` + # `Base.Fix1(...)` applies `p_constructor` to each of the arrays in the tuple + # `Base.Fix2(...)` does `BlockedArray.(tuple_of_arrs, blockarrsizes)` returning a + # tuple of `BlockedArray`s + Base.Fix2(Broadcast.BroadcastFunction(BlockedArray), blockarrsizes) ∘ + Base.Fix1(broadcast, p_constructor) ∘ + getu(srcsys, syms[3]) + end + const_getter = if syms[4] == () + Returns(()) + else + Base.Fix1(broadcast, p_constructor) ∘ getu(srcsys, syms[4]) + end + nonnumeric_getter = if syms[5] == () + Returns(()) + else + ic = get_index_cache(dstsys) + buftypes = Tuple(map(ic.nonnumeric_buffer_sizes) do bufsize + Vector{bufsize.type} + end) + # nonnumerics retain the assigned buffer type without narrowing + Base.Fix1(broadcast, _p_constructor) ∘ + Base.Fix1(Broadcast.BroadcastFunction(call), buftypes) ∘ getu(srcsys, syms[5]) + end + getters = ( + tunable_getter, initials_getter, discs_getter, const_getter, nonnumeric_getter) + getter = let getters = getters + function _getter(valp, initprob) + oldcache = parameter_values(initprob).caches + MTKParameters(getters[1](valp), getters[2](valp), getters[3](valp), + getters[4](valp), getters[5](valp), oldcache isa Tuple{} ? () : + copy.(oldcache)) + end + end + + return getter +end + +function call(f, args...) + f(args...) +end + +""" + $(TYPEDSIGNATURES) + +Construct a `ReconstructInitializeprob` which reconstructs the `u0` and `p` of `dstsys` +with values from `srcsys`. +""" +function ReconstructInitializeprob( + srcsys::AbstractSystem, dstsys::AbstractSystem; u0_constructor = identity, p_constructor = identity) + @assert is_initializesystem(dstsys) + ugetter = u0_constructor ∘ getu(srcsys, unknowns(dstsys)) + if is_split(dstsys) + pgetter = get_mtkparameters_reconstructor(srcsys, dstsys; p_constructor) + else + syms = parameters(dstsys) + pgetter = let inner = concrete_getu(srcsys, syms), p_constructor = p_constructor + function _getter2(valp, initprob) + p_constructor(inner(valp)) + end + end + end + return ReconstructInitializeprob(pgetter, ugetter) +end + +""" + $(TYPEDSIGNATURES) + +Copy values from `srcvalp` to `dstvalp`. Returns the new `u0` and `p`. +""" +function (rip::ReconstructInitializeprob)(srcvalp, dstvalp) + # copy parameters + newp = rip.pgetter(srcvalp, dstvalp) + # no `u0`, so no type-promotion + if state_values(dstvalp) === nothing + return nothing, newp + end + # the `eltype` of the `u0` of the source + srcu0 = state_values(srcvalp) + T = srcu0 === nothing ? Union{} : eltype(srcu0) + # promote with the tunable eltype + 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 + u0 = rip.ugetter(srcvalp) + # and the eltype of the destination u0 + if T != eltype(u0) && T != Union{} + u0 = T.(u0) + end + # apply the promotion to tunables portion + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Tunable(), newp) + if eltype(buf) != T + # only do a copy if the eltype doesn't match + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + if newp isa MTKParameters + # and initials portion + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Initials(), newp) + if eltype(buf) != T + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + end + return u0, newp +end + +""" + $(TYPEDSIGNATURES) + +Given `sys` and its corresponding initialization system `initsys`, return the +`initializeprobpmap` function in `OverrideInitData` for the systems. +""" +function construct_initializeprobpmap( + sys::AbstractSystem, initsys::AbstractSystem; p_constructor = identity) + @assert is_initializesystem(initsys) + if is_split(sys) + return let getter = get_mtkparameters_reconstructor( + initsys, sys; initials = true, unwrap_initials = true, p_constructor) + function initprobpmap_split(prob, initsol) + getter(initsol, prob) + end + end + else + return let getter = getu(initsys, parameters(sys; initial_parameters = true)), + p_constructor = p_constructor + + function initprobpmap_nosplit(prob, initsol) + return p_constructor(getter(initsol)) + end + end + end +end + +function get_scimlfn(valp) + valp isa SciMLBase.AbstractSciMLFunction && return valp + if hasmethod(symbolic_container, Tuple{typeof(valp)}) && + (sc = symbolic_container(valp)) !== valp + return get_scimlfn(sc) + end + throw(ArgumentError("SciMLFunction not found. This should never happen.")) +end + +""" + $(TYPEDSIGNATURES) + +A function to be used as `update_initializeprob!` in `OverrideInitData`. Requires +`is_update_oop = Val(true)` to be passed to `update_initializeprob!`. +""" +function update_initializeprob!(initprob, prob) + pgetter = ChainRulesCore.@ignore_derivatives get_scimlfn(prob).initialization_data.metadata.oop_reconstruct_u0_p.pgetter + p = pgetter(prob, initprob) + return remake(initprob; p) +end + +""" + $(TYPEDEF) + +Metadata attached to `OverrideInitData` used in `remake` hooks for handling initialization +properly. + +# Fields + +$(TYPEDFIELDS) +""" +struct InitializationMetadata{R <: ReconstructInitializeprob, GUU, SIU} + """ + The operating point used to construct the initialization. + """ + op::Dict{Any, Any} + """ + The `guesses` used to construct the initialization. + """ + guesses::Dict{Any, Any} + """ + The `initialization_eqs` in addition to those of the system that were used to construct + the initialization. + """ + additional_initialization_eqs::Vector{Equation} + """ + Whether to use `SCCNonlinearProblem` if possible. + """ + use_scc::Bool + """ + Whether the initialization uses the independent variable. + """ + time_dependent_init::Bool + """ + `ReconstructInitializeprob` for this initialization problem. + """ + oop_reconstruct_u0_p::R + """ + A function which takes `(prob, initializeprob)` and return the `u0` to use for the problem. + """ + get_updated_u0::GUU + """ + A function which takes parameter object and `u0` of the problem and sets + `Initial.(unknowns(sys))` in the former, returning the updated parameter object. + """ + set_initial_unknowns!::SIU +end + +""" + $(TYPEDEF) + +A callable struct to use as the `get_updated_u0` field of `InitializationMetadata`. +Returns the value to use for the `u0` of the problem. + +# Fields + +$(TYPEDFIELDS) +""" +struct GetUpdatedU0{GG, GIU} + """ + Mask with length `length(unknowns(sys))` denoting indices of variables which should + take the guess value from `initializeprob`. + """ + guessvars::BitVector + """ + Function which returns the values of variables in `initializeprob` for which + `guessvars` is `true`, in the order they occur in `unknowns(sys)`. + """ + get_guessvars::GG + """ + Function which returns `Initial.(unknowns(sys))` as a `Vector`. + """ + get_initial_unknowns::GIU +end + +function GetUpdatedU0(sys::AbstractSystem, initsys::AbstractSystem, op::AbstractDict) + dvs = unknowns(sys) + eqs = equations(sys) + guessvars = trues(length(dvs)) + for (i, var) in enumerate(dvs) + guessvars[i] = !isequal(get(op, var, nothing), Initial(var)) + end + get_guessvars = getu(initsys, dvs[guessvars]) + get_initial_unknowns = getu(sys, Initial.(dvs)) + return GetUpdatedU0(guessvars, get_guessvars, get_initial_unknowns) +end + +function (guu::GetUpdatedU0)(prob, initprob) + buffer = guu.get_initial_unknowns(prob) + algebuf = view(buffer, guu.guessvars) + copyto!(algebuf, guu.get_guessvars(initprob)) + return buffer +end + +struct SetInitialUnknowns{S} + setter!::S +end + +function SetInitialUnknowns(sys::AbstractSystem) + return SetInitialUnknowns(setu(sys, Initial.(unknowns(sys)))) +end + +function (siu::SetInitialUnknowns)(p::MTKParameters, u0) + if ArrayInterface.ismutable(p.initials) + siu.setter!(p, u0) + else + originalT = similar_type(p.initials) + @set! p.initials = MVector{length(p.initials), eltype(p.initials)}(p.initials) + siu.setter!(p, u0) + @set! p.initials = originalT(p.initials) + end + return p +end + +function (siu::SetInitialUnknowns)(p::AbstractVector, u0) + if ArrayInterface.ismutable(p) + siu.setter!(p, u0) + else + originalT = similar_type(p) + p = MVector{length(p), eltype(p)}(p) + siu.setter!(p, u0) + p = originalT(p) + end + return p +end + +safe_float(x) = x +safe_float(x::AbstractArray) = isempty(x) ? x : float(x) + +""" + $(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`, 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, iip, op::AbstractDict, t, defs, + guesses, missing_unknowns; implicit_dae = false, + time_dependent_init = is_time_dependent(sys), u0_constructor = identity, + p_constructor = identity, floatT = Float64, initialization_eqs = [], + use_scc = true, kwargs...) + guesses = merge(ModelingToolkit.guesses(sys), todict(guesses)) + + if t === nothing && is_time_dependent(sys) + t = zero(floatT) + end + + initializeprob = ModelingToolkit.InitializationProblem{iip}( + sys, t, op; guesses, time_dependent_init, initialization_eqs, + use_scc, u0_constructor, p_constructor, kwargs...) + if state_values(initializeprob) !== nothing + _u0 = state_values(initializeprob) + if ArrayInterface.ismutable(_u0) + _u0 = floatT.(_u0) + else + _u0 = similar_type(_u0, floatT)(_u0) + end + initializeprob = remake(initializeprob; u0 = _u0) + 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 + if ArrayInterface.ismutable(initp) + initp′ = similar(initp, floatT) + copyto!(initp′, initp) + initp = initp′ + else + initp = similar_type(initp, floatT)(initp) + end + end + initializeprob = remake(initializeprob; p = initp) + + get_initial_unknowns = if time_dependent_init + GetUpdatedU0(sys, initializeprob.f.sys, op) + else + nothing + end + meta = InitializationMetadata( + copy(op), copy(guesses), Vector{Equation}(initialization_eqs), + use_scc, time_dependent_init, + ReconstructInitializeprob( + sys, initializeprob.f.sys; u0_constructor, p_constructor), + get_initial_unknowns, SetInitialUnknowns(sys)) + + if time_dependent_init + all_init_syms = Set(all_symbols(initializeprob)) + solved_unknowns = filter(var -> var in all_init_syms, unknowns(sys)) + initializeprobmap = u0_constructor ∘ safe_float ∘ + getu(initializeprob, solved_unknowns) + else + initializeprobmap = nothing + end + + punknowns = [p + for p in all_variable_symbols(initializeprob) + if is_parameter(sys, p)] + if initializeprobmap === nothing && isempty(punknowns) + initializeprobpmap = nothing + else + initializeprobpmap = construct_initializeprobpmap( + sys, initializeprob.f.sys; p_constructor) + 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! = ModelingToolkit.update_initializeprob! + end + + filter!(punknowns) do p + is_parameter_solvable(p, op, defs, guesses) && get(op, p, missing) === missing + end + pvals = getu(initializeprob, punknowns)(initializeprob) + for (p, pval) in zip(punknowns, pvals) + p = unwrap(p) + op[p] = pval + if iscall(p) && operation(p) === getindex + arrp = arguments(p)[1] + get(op, arrp, nothing) !== missing && continue + op[arrp] = collect(arrp) + end + end + + if time_dependent_init + uvals = getu(initializeprob, collect(missing_unknowns))(initializeprob) + for (v, val) in zip(missing_unknowns, uvals) + op[v] = val + end + empty!(missing_unknowns) + end + + return (; + initialization_data = SciMLBase.OverrideInitData( + initializeprob, update_initializeprob!, initializeprobmap, + initializeprobpmap; metadata = meta, is_update_oop = Val(true))) +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 Number + floatT = promote_type(floatT, typeof(v)) + end + end + return float(floatT) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the floating point type to use from the given `varmap` by looking at variables +with a constant value. `u0Type` takes priority if it is a real-valued array type. +""" +function calculate_float_type(varmap, u0Type::Type, floatT = Bool) + if u0Type <: AbstractArray && eltype(u0Type) <: Real && eltype(u0Type) != Union{} + return float(eltype(u0Type)) + else + return float_type_from_varmap(varmap, floatT) + end +end + +""" + $(TYPEDSIGNATURES) + +Calculate the `resid_prototype` for a `NonlinearFunction` with `N` equations and the +provided `u0` and `p`. +""" +function calculate_resid_prototype(N::Int, 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 + +""" + $(TYPEDSIGNATURES) + +Given the user-provided value of `u0_constructor`, the container type of user-provided +`op`, the desired floating point type and whether a symbolic `u0` is allowed, return the +updated `u0_constructor`. +""" +function get_u0_constructor(u0_constructor, u0Type::Type, floatT::Type, symbolic_u0::Bool) + u0_constructor === identity || return u0_constructor + u0Type <: StaticArray || return u0_constructor + return function (vals) + elT = if symbolic_u0 && any(x -> symbolic_type(x) != NotSymbolic(), vals) + nothing + else + floatT + end + SymbolicUtils.Code.create_array(u0Type, elT, Val(1), Val(length(vals)), vals...) + end +end + +""" + $(TYPEDSIGNATURES) + +Given the user-provided value of `p_constructor`, the container type of user-provided `op`, +ans the desired floating point type, return the updated `p_constructor`. +""" +function get_p_constructor(p_constructor, pType::Type, floatT::Type) + p_constructor === identity || return p_constructor + pType <: StaticArray || return p_constructor + return function (vals) + SymbolicUtils.Code.create_array( + pType, floatT, Val(ndims(vals)), Val(size(vals)), vals...) + end +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). + +$U0_P_DOCS + +This will also build the initialization problem and related objects and pass them to the +SciMLFunction as keyword arguments. + +Keyword arguments: +$PROBLEM_KWARGS +$PROBLEM_INTERNAL_KWARGS +- `t`: The initial time of the `SciMLProblem`. This does not need to be provided for time- + independent problems. If not provided for time-dependent problems, will be assumed as + zero. +- `implicit_dae`: Also build a mapping of derivatives of states to values for implicit DAEs. + Changes the return value of this function to `(f, du0, u0, p)` instead of `(f, u0, p)`. +- `symbolic_u0` allows the returned `u0` to be an array of symbolics. + +All other keyword arguments are passed as-is to `constructor`. +""" +function process_SciMLProblem( + constructor, sys::AbstractSystem, op; + build_initializeprob = supports_initialization(sys), + 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, u0_eltype = nothing, tofloat = true, + u0_constructor = identity, p_constructor = identity, + 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, time_dependent_init = is_time_dependent(sys), + 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 = pType = typeof(op) + + op = to_varmap(op, dvs) + symbols_to_symbolics!(sys, op) + + check_inputmap_keys(sys, op) + + defs = add_toterms(recursive_unwrap(defaults(sys)); replace = is_discrete_system(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 + + u0map = anydict() + pmap = anydict() + missing_unknowns, + missing_pars = build_operating_point!(sys, op, + u0map, pmap, defs, dvs, ps) + + floatT = calculate_float_type(op, u0Type) + u0_eltype = something(u0_eltype, floatT) + + if !is_time_dependent(sys) || is_initializesystem(sys) + add_observed_equations!(op, obs) + end + + u0_constructor = get_u0_constructor(u0_constructor, u0Type, u0_eltype, symbolic_u0) + p_constructor = get_p_constructor(p_constructor, pType, floatT) + + if build_initializeprob + kws = maybe_build_initialization_problem( + sys, constructor <: SciMLBase.AbstractSciMLFunction{true}, + op, 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, + algebraic_only, allow_incomplete, u0_constructor, p_constructor, floatT, + time_dependent_init) + + 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 + + u0 = varmap_to_vars( + op, dvs; buffer_eltype = u0_eltype, container_type = u0Type, + allow_symbolic = symbolic_u0, is_initializeprob, substitution_limit) + + 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 + + if is_split(sys) + # `pType` is usually `Dict` when the user passes key-value pairs. + if !(pType <: AbstractArray) + pType = Array + end + p = MTKParameters(sys, op; floatT = floatT, p_constructor, fast_path = true) + else + p = p_constructor(varmap_to_vars(op, ps; tofloat, container_type = pType)) + end + + if implicit_dae + ddvs = map(Differential(iv), dvs) + du0 = varmap_to_vars(op, ddvs; toterm = default_toterm, + 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( + sys, kwargs, u0, t0, p, u0, p) + kwargs = merge(kwargs, (; initialization_data)) + end + + if constructor <: NonlinearFunction && length(dvs) != length(eqs) + kwargs = merge(kwargs, + (; + resid_prototype = u0_constructor(calculate_resid_prototype( + length(eqs), u0, p)))) + end + + f = constructor(sys; u0 = 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, op) + badvarkeys = Any[] + for k in keys(op) + if symbolic_type(k) === NotSymbolic() + push!(badvarkeys, k) + end + end + + if !isempty(badvarkeys) + throw(InvalidKeyError(collect(badvarkeys))) + end +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 +end + +function Base.showerror(io::IO, e::InvalidKeyError) + println(io, BAD_KEY_MESSAGE) + println(io, join(e.vars, ", ")) +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 + +function process_kwargs(sys::System; expression = Val{false}, callback = nothing, + eval_expression = false, eval_module = @__MODULE__, kwargs...) + kwargs = filter_kwargs(kwargs) + kwargs1 = (;) + + if is_time_dependent(sys) + if expression == Val{false} + cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) + if cbs !== nothing + kwargs1 = merge(kwargs1, (callback = cbs,)) + end + end + + tstops = SymbolicTstops(sys; expression, eval_expression, eval_module) + if tstops !== nothing + kwargs1 = merge(kwargs1, (; tstops)) + end + end + + return merge(kwargs1, kwargs) +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 + +struct SymbolicTstops{F} + fn::F +end + +function (st::SymbolicTstops)(p, tspan) + unique!(sort!(reduce(vcat, st.fn(p, tspan...)))) +end + +function SymbolicTstops( + sys::AbstractSystem; expression = Val{false}, 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 = GeneratedFunctionWrapper{(1, 3, is_split(sys))}( + expression, tstops, nothing; eval_expression, eval_module) + + if expression == Val{true} + return :($SymbolicTstops($tstops)) + else + return SymbolicTstops(tstops) + end +end + +""" + $(TYPEDSIGNATURES) + +Macro for writing problem/function constructors. Expects a function definition with type +parameters for `iip` and `specialize`. Generates fallbacks with +`specialize = SciMLBase.FullSpecialize` and `iip = true`. +""" +macro fallback_iip_specialize(ex) + @assert Meta.isexpr(ex, :function) + # fnname is ODEProblem{iip, spec}(args...) where {iip, spec} + # body is function body + fnname, body = ex.args + @assert Meta.isexpr(fnname, :where) + # fnname_call is ODEProblem{iip, spec}(args...) + # where_args are `iip, spec` + fnname_call, where_args... = fnname.args + @assert length(where_args) == 2 + iiparg, specarg = where_args + + @assert Meta.isexpr(fnname_call, :call) + # fnname_curly is ODEProblem{iip, spec} + fnname_curly, args... = fnname_call.args + # the function should have keyword arguments + @assert Meta.isexpr(args[1], :parameters) + + # arguments to call with + call_args = map(args) do arg + # keyword args are in `Expr(:parameters)` so any `Expr(:kw)` here + # are optional positional arguments. Analyze `:(f(a, b = 1; k = 1, l...))` + # to understand + Meta.isexpr(arg, :kw) && return arg.args[1] + return arg + end + call_kwargs = map(call_args[1].args) do arg + Meta.isexpr(arg, :...) && return arg + @assert Meta.isexpr(arg, :kw) + return Expr(:kw, arg.args[1], arg.args[1]) + end + call_args[1] = Expr(:parameters, call_kwargs...) + + @assert Meta.isexpr(fnname_curly, :curly) + # fnname_name is `ODEProblem` + # curly_args is `iip, spec` + fnname_name, curly_args... = fnname_curly.args + @assert curly_args == where_args + + # callexpr_iip is `ODEProblem{iip, FullSpecialize}(call_args...)` + callexpr_iip = Expr( + :call, Expr(:curly, fnname_name, curly_args[1], SciMLBase.FullSpecialize), call_args...) + # `ODEProblem{iip}` + fnname_iip = Expr(:curly, fnname_name, curly_args[1]) + # `ODEProblem{iip}(args...)` + fncall_iip = Expr(:call, fnname_iip, args...) + # ODEProblem{iip}(args...) where {iip} + fnwhere_iip = Expr(:where, fncall_iip, where_args[1]) + fn_iip = Expr(:function, fnwhere_iip, callexpr_iip) + + # `ODEProblem{true}(call_args...)` + callexpr_base = Expr(:call, Expr(:curly, fnname_name, true), call_args...) + # `ODEProblem(args...)` + fncall_base = Expr(:call, fnname_name, args...) + fn_base = Expr(:function, fncall_base, callexpr_base) + + # Handle case when this is a problem constructor and `u0map` is a `StaticArray`, + # where `iip` should default to `false`. + fn_sarr = nothing + if occursin("Problem", string(fnname_name)) + # args should at least contain an argument for the `u0map` + @assert length(args) > 2 + u0_arg = args[3] + # should not have a type-annotation + @assert !Meta.isexpr(u0_arg, :(::)) + if Meta.isexpr(u0_arg, :kw) + argname, default = u0_arg.args + u0_arg = Expr(:kw, Expr(:(::), argname, StaticArray), default) + else + u0_arg = Expr(:(::), u0_arg, StaticArray) + end + + callexpr_sarr = Expr(:call, Expr(:curly, fnname_name, false), call_args...) + fncall_sarr = Expr(:call, fnname_name, args[1], args[2], u0_arg, args[4:end]...) + fn_sarr = Expr(:function, fncall_sarr, callexpr_sarr) + end + return quote + $fn_base + $fn_sarr + $fn_iip + Base.@__doc__ $ex + end |> esc +end + +""" + $(TYPEDSIGNATURES) + +Turn key-value pairs in `kws` into assignments and append them to `block.args`. `head` is +the head of the `Expr` used to create the assignment. `filter` is a function that takes the +key and returns whether or not to include it in the assignments. +""" +function namedtuple_to_assignments!( + block, kws::NamedTuple; head = :(=), filter = Returns(true)) + for (k, v) in pairs(kws) + filter(k) || continue + push!(block.args, Expr(head, k, v)) + end +end + +""" + $(TYPEDSIGNATURES) + +Build an expression that constructs SciMLFunction `T`. `args` is a `NamedTuple` mapping +names of positional arguments to `T` to their (expression) values. `kwargs` are parsed +as keyword arguments to the constructor. +""" +function build_scimlfn_expr(T, args::NamedTuple; kwargs...) + kwargs = NamedTuple(kwargs) + let_args = Expr(:block) + namedtuple_to_assignments!(let_args, args) + + kwexpr = Expr(:parameters) + # don't include initialization data in the generated expression + filter = !isequal(:initialization_data) + namedtuple_to_assignments!(let_args, kwargs; filter = filter) + namedtuple_to_assignments!(kwexpr, kwargs; head = :kw, filter) + let_body = Expr(:call, T, kwexpr, keys(args)...) + return Expr(:let, let_args, let_body) +end + +""" + $(TYPEDSIGNATURES) + +Build an expression that constructs SciMLProblem `T`. `args` is a `NamedTuple` mapping +names of positional arguments to `T` to their (expression) values. `kwargs` are parsed +as keyword arguments to the constructor. +""" +function build_scimlproblem_expr(T, args::NamedTuple; kwargs...) + kwargs = NamedTuple(kwargs) + let_args = Expr(:block) + namedtuple_to_assignments!(let_args, args) + + kwexpr = Expr(:parameters) + namedtuple_to_assignments!(let_args, kwargs) + namedtuple_to_assignments!(kwexpr, kwargs; head = :kw) + let_body = Expr(:call, remake, Expr(:call, T, kwexpr, keys(args)...)) + return Expr(:let, let_args, let_body) +end + +""" + $(TYPEDSIGNATURES) + +Return an expression constructing SciMLFunction `T` with positional arguments `args` +and keywords `kwargs`. +""" +function maybe_codegen_scimlfn(::Type{Val{true}}, T, args::NamedTuple; kwargs...) + build_scimlfn_expr(T, args; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Construct SciMLFunction `T` with positional arguments `args` and keywords `kwargs`. +""" +function maybe_codegen_scimlfn(::Type{Val{false}}, T, args::NamedTuple; kwargs...) + T(args...; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return an expression constructing SciMLProblem `T` with positional arguments `args` +and keywords `kwargs`. +""" +function maybe_codegen_scimlproblem(::Type{Val{true}}, T, args::NamedTuple; kwargs...) + build_scimlproblem_expr(T, args; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Construct SciMLProblem `T` with positional arguments `args` and keywords `kwargs`. +""" +function maybe_codegen_scimlproblem(::Type{Val{false}}, T, args::NamedTuple; kwargs...) + # Call `remake` so it runs initialization if it is trivial + remake(T(args...; kwargs...)) +end + +""" + $(TYPEDSIGNATURES) + +Return the `u0` vector for the given system `sys` and variable-value mapping `varmap`. All +keyword arguments are forwarded to [`varmap_to_vars`](@ref). +""" +function get_u0(sys::AbstractSystem, varmap; kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + op = to_varmap(varmap, dvs) + add_observed!(sys, op) + add_parameter_dependencies!(sys, op) + missing_dvs, _ = build_operating_point!( + sys, op, Dict(), Dict(), defaults(sys), dvs, ps) + + isempty(missing_dvs) || throw(MissingVariablesError(collect(missing_dvs))) + + return varmap_to_vars(op, dvs; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the `p` object for the given system `sys` and variable-value mapping `varmap`. All +keyword arguments are forwarded to [`MTKParameters`](@ref) for split systems and +[`varmap_to_vars`](@ref) for non-split systems. +""" +function get_p(sys::AbstractSystem, varmap; split = is_split(sys), kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + op = to_varmap(varmap, dvs) + add_observed!(sys, op) + add_parameter_dependencies!(sys, op) + _, missing_ps = build_operating_point!( + sys, op, Dict(), Dict(), defaults(sys), dvs, ps) + + isempty(missing_ps) || throw(MissingParametersError(collect(missing_ps))) + + if split + MTKParameters(sys, op; kwargs...) + else + varmap_to_vars(op, ps; kwargs...) + end +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/state_machines.jl b/src/systems/state_machines.jl new file mode 100644 index 0000000000..347f92e6f8 --- /dev/null +++ b/src/systems/state_machines.jl @@ -0,0 +1,155 @@ +_nameof(s) = nameof(s) +_nameof(s::Union{Int, Symbol}) = s +abstract type StateMachineOperator end +Base.broadcastable(x::StateMachineOperator) = Ref(x) +Symbolics.hide_lhs(_::StateMachineOperator) = true +struct InitialState <: StateMachineOperator + s::Any +end +Base.show(io::IO, s::InitialState) = print(io, "initial_state(", _nameof(s.s), ")") +initial_state(s) = Equation(InitialState(nothing), InitialState(s)) + +Base.@kwdef struct Transition{A, B, C} <: StateMachineOperator + from::A = nothing + to::B = nothing + cond::C = nothing + immediate::Bool = true + reset::Bool = true + synchronize::Bool = false + priority::Int = 1 + function Transition(from, to, cond, immediate, reset, synchronize, priority) + cond = unwrap(cond) + new{typeof(from), typeof(to), typeof(cond)}(from, to, cond, immediate, + reset, synchronize, + priority) + end +end +function Base.:(==)(transition1::Transition, transition2::Transition) + transition1.from == transition2.from && + transition1.to == transition2.to && + isequal(transition1.cond, transition2.cond) && + transition1.immediate == transition2.immediate && + transition1.reset == transition2.reset && + transition1.synchronize == transition2.synchronize && + transition1.priority == transition2.priority +end + +""" + transition(from, to, cond; immediate::Bool = true, reset::Bool = true, synchronize::Bool = false, priority::Int = 1) + +Create a transition from state `from` to state `to` that is enabled when transitioncondition `cond` evaluates to `true`. + +# Arguments: +- `from`: The source state of the transition. +- `to`: The target state of the transition. +- `cond`: A transition condition that evaluates to a Bool, such as `ticksInState() >= 2`. +- `immediate`: If `true`, the transition will fire at the same tick as it becomes true, if `false`, the actions of the state are evaluated first, and the transition fires during the next tick. +- `reset`: If true, the destination state `to` is reset to its initial condition when the transition fires. +- `synchronize`: If true, the transition will only fire if all sub-state machines in the source state are in their final (terminal) state. A final state is one that has no outgoing transitions. +- `priority`: If a state has more than one outgoing transition, all outgoing transitions must have a unique priority. The transitions are evaluated in priority order, i.e., the transition with priority 1 is evaluated first. +""" +function transition(from, to, cond; + immediate::Bool = true, reset::Bool = true, synchronize::Bool = false, + priority::Int = 1) + Equation( + Transition(), Transition(; from, to, cond, immediate, reset, + synchronize, priority)) +end +function Base.show(io::IO, s::Transition) + print(io, _nameof(s.from), " → ", _nameof(s.to), " if (", s.cond, ") [") + print(io, "immediate: ", Int(s.immediate), ", ") + print(io, "reset: ", Int(s.reset), ", ") + print(io, "sync: ", Int(s.synchronize), ", ") + print(io, "prio: ", s.priority, "]") +end + +function activeState end +function entry end +function ticksInState end +function timeInState end + +for (s, T) in [(:timeInState, :Real), + (:ticksInState, :Integer), + (:entry, :Bool), + (:activeState, :Bool)] + seed = hash(s) + @eval begin + $s(x) = wrap(term($s, x)) + SymbolicUtils.promote_symtype(::typeof($s), _...) = $T + function SymbolicUtils.show_call(io, ::typeof($s), args) + if isempty(args) + print(io, $s, "()") + else + arg = only(args) + print(io, $s, "(", arg isa Number ? arg : nameof(arg), ")") + end + end + end + if s != :activeState + @eval $s() = wrap(term($s)) + end +end + +@doc """ + timeInState() + timeInState(state) + +Get the time (in seconds) spent in a state in a finite state machine. + +When used to query the time spent in the enclosing state, the method without arguments is used, i.e., +```julia +@mtkmodel FSM begin + ... + @equations begin + var(k+1) ~ timeInState() >= 2 ? 0.0 : var(k) + end +end +``` + +If used to query the residence time of another state, the state is passed as an argument. + +This operator can be used in both equations and transition conditions. + +See also [`ticksInState`](@ref) and [`entry`](@ref) +""" timeInState + +@doc """ + ticksInState() + ticksInState(state) + +Get the number of ticks spent in a state in a finite state machine. + +When used to query the number of ticks spent in the enclosing state, the method without arguments is used, i.e., +```julia +@mtkmodel FSM begin + ... + @equations begin + var(k+1) ~ ticksInState() >= 2 ? 0.0 : var(k) + end +end +``` + +If used to query the number of ticks in another state, the state is passed as an argument. + +This operator can be used in both equations and transition conditions. + +See also [`timeInState`](@ref) and [`entry`](@ref) +""" ticksInState + +@doc """ + entry() + entry(state) + +When used in a finite-state machine, this operator returns true at the first tick when the state is active, and false otherwise. + +When used to query the entry of the enclosing state, the method without arguments is used, when used to query the entry of another state, the state is passed as an argument. + +This can be used to perform a unique action when entering a state. +""" +entry + +@doc """ + activeState(state) + +When used in a finite state machine, this operator returns `true` if the queried state is active and false otherwise. +""" activeState diff --git a/src/systems/system.jl b/src/systems/system.jl new file mode 100644 index 0000000000..729e8c4219 --- /dev/null +++ b/src/systems/system.jl @@ -0,0 +1,1150 @@ +struct Schedule + var_sccs::Vector{Vector{Int}} + """ + Mapping of `Differential`s of variables to corresponding derivative expressions. + """ + dummy_sub::Dict{Any, Any} +end + +const MetadataT = Base.ImmutableDict{DataType, Any} + +abstract type MutableCacheKey end + +const MutableCacheT = Dict{DataType, Any} + +""" + $(TYPEDEF) + +A symbolic representation of a numerical system to be solved. This is a recursive +tree-like data structure - each system can contain additional subsystems. As such, +it implements the `AbstractTrees.jl` interface to enable exploring the hierarchical +structure. + +# Fields + +$(TYPEDFIELDS) +""" +struct System <: IntermediateDeprecationSystem + """ + $INTERNAL_FIELD_WARNING + A unique integer tag for the system. + """ + tag::UInt + """ + The equations of the system. + """ + eqs::Vector{Equation} + # nothing - no noise + # vector - diagonal noise + # matrix - generic form + # column matrix - scalar noise + """ + The noise terms for each equation of the system. This field is only used for flattened + systems. To represent noise in a hierarchical system, use brownians. In a system with + `N` equations and `K` independent brownian variables, this should be an `N x K` + matrix. In the special case where `N == K` and each equation has independent noise, + this noise matrix is diagonal. Diagonal noise can be specified by providing an `N` + length vector. If this field is `nothing`, the system does not have noise. + """ + noise_eqs::Union{Nothing, AbstractVector, AbstractMatrix} + """ + Jumps associated with the system. Each jump can be a `VariableRateJump`, + `ConstantRateJump` or `MassActionJump`. See `JumpProcesses.jl` for more information. + """ + jumps::Vector{JumpType} + """ + The constraints of the system. This can be used to represent the constraints in an + optimal-control problem or boundary-value differential equation, or the constraints + in a constrained optimization. + """ + constraints::Vector{Union{Equation, Inequality}} + """ + The costs of the system. This can be the cost in an optimal-control problem, or the + loss of an optimization problem. Scalar loss values must also be provided as a single- + element vector. + """ + costs::Vector{<:Union{BasicSymbolic, Real}} + """ + A function which combines costs into a scalar value. This should take two arguments, + the `costs` of this system and the consolidated costs of all subsystems in the order + they are present in the `systems` field. It should return a scalar cost that combines + all of the individual values. This defaults to a function that simply sums all cost + values. + """ + consolidate::Any + """ + The variables being solved for by this system. For example, in a differential equation + system, this contains the dependent variables. + """ + unknowns::Vector + """ + The parameters of the system. Parameters can either be variables that parameterize the + problem being solved for (e.g. the spring constant of a mass-spring system) or + additional unknowns not part of the main dynamics of the system (e.g. discrete/clocked + variables in a hybrid ODE). + """ + ps::Vector + """ + The brownian variables of the system, created via `@brownians`. Each brownian variable + represents an independent noise. A system with brownians cannot be simulated directly. + It needs to be compiled using `mtkcompile` into `noise_eqs`. + """ + brownians::Vector + """ + The independent variable for a time-dependent system, or `nothing` for a time-independent + system. + """ + iv::Union{Nothing, BasicSymbolic{Real}} + """ + Equations that compute variables of a system that have been eliminated from the set of + unknowns by `mtkcompile`. More generally, this contains all variables that can be + computed from the unknowns and parameters and do not need to be solved for. Such + variables are termed as "observables". Each equation must be of the form + `observable ~ expression` and observables cannot appear on the LHS of multiple + equations. Equations must be sorted such that every observable appears on + the left hand side of an equation before it appears on the right hand side of any other + equation. + """ + observed::Vector{Equation} + """ + $INTERNAL_FIELD_WARNING + All the explicit equations relating parameters. Equations here only contain parameters + and are in the same format as `observed`. + """ + parameter_dependencies::Vector{Equation} + """ + $INTERNAL_FIELD_WARNING + A mapping from the name of a variable to the actual symbolic variable in the system. + This is used to enable `getproperty` syntax to access variables of a system. + """ + var_to_name::Dict{Symbol, Any} + """ + The name of the system. + """ + name::Symbol + """ + An optional description for the system. + """ + description::String + """ + Default values that variables (unknowns/observables/parameters) should take when + constructing a numerical problem from the system. These values can be overridden + by initial values provided to the problem constructor. Defaults of parent systems + take priority over those in child systems. + """ + defaults::Dict + """ + Guess values for variables of a system that are solved for during initialization. + """ + guesses::Dict + """ + A list of subsystems of this system. Used for hierarchically building models. + """ + systems::Vector{System} + """ + Equations that must be satisfied during initialization of the numerical problem created + from this system. For time-dependent systems, these equations are not valid after the + initial time. + """ + initialization_eqs::Vector{Equation} + """ + Symbolic representation of continuous events in a dynamical system. See + [`SymbolicContinuousCallback`](@ref). + """ + continuous_events::Vector{SymbolicContinuousCallback} + """ + Symbolic representation of discrete events in a dynamica system. See + [`SymbolicDiscreteCallback`](@ref). + """ + discrete_events::Vector{SymbolicDiscreteCallback} + """ + $INTERNAL_FIELD_WARNING + If this system is a connector, the type of connector it is. + """ + connector_type::Any + """ + A map from expressions that must be through throughout the solution process to an + associated error message. By default these assertions cause the generated code to + output `NaN`s if violated, but can be made to error using `debug_system`. + """ + assertions::Dict{BasicSymbolic, String} + """ + The metadata associated with this system, as a `Base.ImmutableDict`. This follows + the same interface as SymbolicUtils.jl. Metadata can be queried and updated using + `SymbolicUtils.getmetadata` and `SymbolicUtils.setmetadata` respectively. + """ + metadata::MetadataT + """ + $INTERNAL_FIELD_WARNING + Metadata added by the `@mtkmodel` macro. + """ + gui_metadata::Any # ? + """ + Whether the system contains delay terms. This is inferred from the equations, but + can also be provided explicitly. + """ + is_dde::Bool + """ + Extra time points for the integrator to stop at. These can be numeric values, + or expressions of parameters and time. + """ + tstops::Vector{Any} + """ + The `TearingState` of the system post-simplification with `mtkcompile`. + """ + tearing_state::Any + """ + Whether the system namespaces variables accessed via `getproperty`. `complete`d systems + do not namespace, but this flag can be toggled independently of `complete` using + `toggle_namespacing`. + """ + namespacing::Bool + """ + Whether the system is marked as "complete". Completed systems cannot be used as + subsystems. + """ + complete::Bool + """ + $INTERNAL_FIELD_WARNING + For systems simplified or completed with `split = true` (the default) this contains an + `IndexCache` which aids in symbolic indexing. If this field is `nothing`, the system is + either not completed, or completed with `split = false`. + """ + index_cache::Union{Nothing, IndexCache} + """ + $INTERNAL_FIELD_WARNING + Connections that should be ignored because they were removed by an analysis point + transformation. The first element of the tuple contains all such "standard" connections + (ones between connector systems) and the second contains all such causal variable + connections. + """ + ignored_connections::Union{Nothing, Vector{Connection}} + """ + `SymbolicUtils.Code.Assignment`s to prepend to all code generated from this system. + """ + preface::Any + """ + After simplification with `mtkcompile`, this field contains the unsimplified system + with the hierarchical structure. There may be multiple levels of `parent`s. The root + parent is used for accessing variables via `getproperty` syntax. + """ + parent::Union{Nothing, System} + """ + A custom initialization system to use if no initial conditions are provided for the + unknowns or observables of this system. + """ + initializesystem::Union{Nothing, System} + """ + Whether the current system is an initialization system. + """ + is_initializesystem::Bool + is_discrete::Bool + """ + $INTERNAL_FIELD_WARNING + Whether the system has been simplified by `mtkcompile`. + """ + isscheduled::Bool + """ + $INTERNAL_FIELD_WARNING + The `Schedule` containing additional information about the simplified system. + """ + schedule::Union{Schedule, Nothing} + + function System( + tag, eqs, noise_eqs, jumps, constraints, costs, consolidate, unknowns, ps, + brownians, iv, observed, parameter_dependencies, var_to_name, name, description, + defaults, guesses, systems, initialization_eqs, continuous_events, discrete_events, + connector_type, assertions = Dict{BasicSymbolic, String}(), + metadata = MetadataT(), gui_metadata = nothing, + is_dde = false, tstops = [], tearing_state = nothing, namespacing = true, + complete = false, index_cache = nothing, ignored_connections = nothing, + preface = nothing, parent = nothing, initializesystem = nothing, + is_initializesystem = false, is_discrete = false, isscheduled = false, + schedule = nothing; checks::Union{Bool, Int} = true) + if is_initializesystem && iv !== nothing + throw(ArgumentError(""" + Expected initialization system to be time-independent. Found independent + variable $iv. + """)) + end + jumps = Vector{JumpType}(jumps) + if (checks == true || (checks & CheckComponents) > 0) && iv !== nothing + check_independent_variables([iv]) + check_variables(unknowns, iv) + check_parameters(ps, iv) + check_equations(eqs, iv) + if noise_eqs !== nothing && size(noise_eqs, 1) != length(eqs) + throw(IllFormedNoiseEquationsError(size(noise_eqs, 1), length(eqs))) + end + check_equations(equations(continuous_events), iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(unknowns, ps, iv) + if noise_eqs === nothing + check_units(u, eqs) + else + check_units(u, eqs, noise_eqs) + end + if iv !== nothing + check_units(u, jumps, iv) + end + isempty(constraints) || check_units(u, constraints) + end + new(tag, eqs, noise_eqs, jumps, constraints, costs, + consolidate, unknowns, ps, brownians, iv, + observed, parameter_dependencies, var_to_name, name, description, defaults, + guesses, systems, initialization_eqs, continuous_events, discrete_events, + connector_type, assertions, metadata, gui_metadata, is_dde, + tstops, tearing_state, namespacing, complete, index_cache, ignored_connections, + preface, parent, initializesystem, is_initializesystem, is_discrete, + isscheduled, schedule) + end +end + +function default_consolidate(costs, subcosts) + # `reduce` instead of `sum` because the rrule for `sum` doesn't + # handle the `init` kwarg. + return reduce(+, costs; init = 0.0) + reduce(+, subcosts; init = 0.0) +end + +""" + $(TYPEDSIGNATURES) + +Construct a system using the given equations `eqs`, independent variable `iv` (`nothing`) +for time-independent systems, unknowns `dvs`, parameters `ps` and brownian variables +`brownians`. + +## Keyword Arguments + +- `discover_from_metadata`: Whether to parse metadata of unknowns and parameters of the + system to obtain defaults and/or guesses. +- `checks`: Whether to perform sanity checks on the passed values. + +All other keyword arguments are named identically to the corresponding fields in +[`System`](@ref). +""" +function System(eqs::Vector{Equation}, iv, dvs, ps, brownians = []; + constraints = Union{Equation, Inequality}[], noise_eqs = nothing, jumps = [], + costs = BasicSymbolic[], consolidate = default_consolidate, + observed = Equation[], parameter_dependencies = Equation[], defaults = Dict(), + guesses = Dict(), systems = System[], initialization_eqs = Equation[], + continuous_events = SymbolicContinuousCallback[], discrete_events = SymbolicDiscreteCallback[], + connector_type = nothing, assertions = Dict{BasicSymbolic, String}(), + metadata = MetadataT(), gui_metadata = nothing, + is_dde = nothing, tstops = [], tearing_state = nothing, + ignored_connections = nothing, parent = nothing, + description = "", name = nothing, discover_from_metadata = true, + initializesystem = nothing, is_initializesystem = false, is_discrete = false, + preface = [], checks = true) + name === nothing && throw(NoNameError()) + if !isempty(parameter_dependencies) + @warn """ + The `parameter_dependencies` keyword argument is deprecated. Please provide all + such equations as part of the normal equations of the system. + """ + eqs = Equation[eqs; parameter_dependencies] + end + + iv = unwrap(iv) + ps = unwrap.(ps) + dvs = unwrap.(dvs) + filter!(!Base.Fix2(isdelay, iv), dvs) + brownians = unwrap.(brownians) + + if !(eqs isa AbstractArray) + eqs = [eqs] + end + + if noise_eqs !== nothing + noise_eqs = unwrap.(noise_eqs) + end + + costs = unwrap.(costs) + if isempty(costs) + costs = Union{BasicSymbolic, Real}[] + end + + defaults = anydict(defaults) + guesses = anydict(guesses) + var_to_name = anydict() + + 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 observed]) + process_variables!(var_to_name, defaults, guesses, [eq.rhs for eq in observed]) + end + filter!(!(isnothing ∘ last), defaults) + filter!(!(isnothing ∘ last), guesses) + defaults = anydict([unwrap(k) => unwrap(v) for (k, v) in defaults]) + guesses = anydict([unwrap(k) => unwrap(v) for (k, v) in guesses]) + + sysnames = nameof.(systems) + unique_sysnames = Set(sysnames) + if length(unique_sysnames) != length(sysnames) + throw(NonUniqueSubsystemsError(sysnames, unique_sysnames)) + end + continuous_events, + discrete_events = create_symbolic_events( + continuous_events, discrete_events, eqs, iv) + + if iv === nothing && (!isempty(continuous_events) || !isempty(discrete_events)) + throw(EventsInTimeIndependentSystemError(continuous_events, discrete_events)) + end + + if is_dde === nothing + is_dde = _check_if_dde(eqs, iv, systems) + end + + assertions = Dict{BasicSymbolic, String}(unwrap(k) => v for (k, v) in assertions) + + if isempty(metadata) + metadata = MetadataT() + elseif metadata isa MetadataT + metadata = metadata + else + meta = MetadataT() + for kvp in metadata + meta = Base.ImmutableDict(meta, kvp) + end + metadata = meta + end + metadata = refreshed_metadata(metadata) + System(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), eqs, noise_eqs, jumps, constraints, + costs, consolidate, dvs, ps, brownians, iv, observed, Equation[], + var_to_name, name, description, defaults, guesses, systems, initialization_eqs, + continuous_events, discrete_events, connector_type, assertions, metadata, gui_metadata, is_dde, + tstops, tearing_state, true, false, nothing, ignored_connections, preface, parent, + initializesystem, is_initializesystem, is_discrete; checks) +end + +""" + $(TYPEDSIGNATURES) + +Create a time-independent [`System`](@ref) with the given equations `eqs`, unknowns `dvs` +and parameters `ps`. +""" +function System(eqs::Vector{Equation}, dvs, ps; kwargs...) + System(eqs, nothing, dvs, ps; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Create a time-dependent system with the given equations `eqs` and independent variable `iv`. +Discover variables, parameters and brownians in the system by parsing the equations and +other symbolic expressions passed to the system. +""" +function System(eqs::Vector{Equation}, iv; kwargs...) + iv === nothing && return System(eqs; kwargs...) + + diffvars = OrderedSet() + othervars = OrderedSet() + ps = Set() + diffeqs = Equation[] + othereqs = Equation[] + for eq in eqs + if !(eq.lhs isa Union{Symbolic, Number, AbstractArray}) + push!(othereqs, eq) + continue + end + collect_vars!(othervars, ps, eq, iv) + if iscall(eq.lhs) && operation(eq.lhs) isa Differential + var, _ = var_from_nested_derivative(eq.lhs) + if var in diffvars + throw(ArgumentError(""" + The differential variable $var is not unique in the system of \ + equations. + """)) + end + # this check ensures var is correctly scoped, since `collect_vars!` won't pick + # it up if it belongs to an ancestor system. + if var in othervars + push!(diffvars, var) + end + push!(diffeqs, eq) + else + push!(othereqs, eq) + end + end + + allunknowns = union(diffvars, othervars) + eqs = [diffeqs; othereqs] + + brownians = Set() + for x in allunknowns + x = unwrap(x) + if getvariabletype(x) == BROWNIAN + push!(brownians, x) + end + end + setdiff!(allunknowns, brownians) + + for eq in get(kwargs, :parameter_dependencies, Equation[]) + collect_vars!(allunknowns, ps, eq, iv) + end + + cstrs = Vector{Union{Equation, Inequality}}(get(kwargs, :constraints, [])) + cstrunknowns, cstrps = process_constraint_system(cstrs, allunknowns, ps, iv) + union!(allunknowns, cstrunknowns) + union!(ps, cstrps) + + for ssys in get(kwargs, :systems, System[]) + collect_scoped_vars!(allunknowns, ps, ssys, iv) + end + + costs = get(kwargs, :costs, nothing) + if costs !== nothing + costunknowns, costps = process_costs(costs, allunknowns, ps, iv) + union!(allunknowns, costunknowns) + union!(ps, costps) + end + + for v in allunknowns + isdelay(v, iv) || continue + collect_vars!(allunknowns, ps, arguments(v)[1], iv) + end + + new_ps = gather_array_params(ps) + + noiseeqs = get(kwargs, :noise_eqs, nothing) + if noiseeqs !== nothing + # 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 + end + + return System( + eqs, iv, collect(allunknowns), collect(new_ps), collect(brownians); kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Create a time-independent system with the given equations `eqs`. Discover variables and +parameters in the system by parsing the equations and other symbolic expressions passed to +the system. +""" +function System(eqs::Vector{Equation}; 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[]) + collect_vars!(allunknowns, ps, eq, nothing) + end + for ssys in get(kwargs, :systems, System[]) + collect_scoped_vars!(allunknowns, ps, ssys, nothing) + end + costs = get(kwargs, :costs, []) + for val in costs + collect_vars!(allunknowns, ps, val, nothing) + end + + cstrs = Vector{Union{Equation, Inequality}}(get(kwargs, :constraints, [])) + for eq in cstrs + collect_vars!(allunknowns, ps, eq, nothing) + end + + new_ps = gather_array_params(ps) + + return System(eqs, nothing, collect(allunknowns), collect(new_ps); kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Create a `System` with a single equation `eq`. +""" +System(eq::Equation, args...; kwargs...) = System([eq], args...; kwargs...) + +function gather_array_params(ps) + 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 new_ps +end + +""" +Process variables in constraints of the (ODE) System. +""" +function process_constraint_system( + constraints::Vector{Union{Equation, Inequality}}, sts, ps, iv; validate = true) + isempty(constraints) && return Set(), Set() + + constraintsts = OrderedSet() + constraintps = OrderedSet() + for cons in constraints + collect_vars!(constraintsts, constraintps, cons, iv) + union!(constraintsts, collect_applied_operators(cons, Differential)) + end + + # Validate the states. + if validate + validate_vars_and_find_ps!(constraintsts, constraintps, sts, iv) + end + + return constraintsts, constraintps +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 (ODE) System (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 + if iscall(var) && operation(var) isa Differential + var = only(arguments(var)) + end + arg = only(arguments(var)) + operation(var)(iv) ∈ sts || + throw(ArgumentError("Variable $var is not a variable of the System. Called variables must be variables of the System.")) + + 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) && !isequal(arg, iv) && 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 + +""" + $(TYPEDSIGNATURES) + +Check if a system is a (possibly implicit) discrete system. Hybrid systems are turned into +callbacks, so checking if any LHS is shifted is sufficient. If a variable is shifted in +the input equations there _will_ be a `Shift` equation in the simplified system. +""" +function is_discrete_system(sys::System) + get_is_discrete(sys) || any(eq -> isoperator(eq.lhs, Shift), equations(sys)) +end + +SymbolicIndexingInterface.is_time_dependent(sys::System) = get_iv(sys) !== nothing + +""" + 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 + +""" + $(TYPEDSIGNATURES) + +Flatten the hierarchical structure of a system, collecting all equations, unknowns, etc. +into one top-level system after namespacing appropriately. +""" +function flatten(sys::System, noeqs = false) + systems = get_systems(sys) + isempty(systems) && return sys + costs = cost(sys) + if _iszero(costs) + costs = Union{Real, BasicSymbolic}[] + else + costs = [costs] + end + # We don't include `ignored_connections` in the flattened system, because + # connection expansion inherently requires the hierarchy structure. If the system + # is being flattened, then we no longer want to expand connections (or have already + # done so) and thus don't care about `ignored_connections`. + return System(noeqs ? Equation[] : equations(sys), get_iv(sys), unknowns(sys), + parameters(sys; initial_parameters = true), brownians(sys); + jumps = jumps(sys), constraints = constraints(sys), costs = costs, + consolidate = default_consolidate, observed = observed(sys), + defaults = defaults(sys), guesses = guesses(sys), + continuous_events = continuous_events(sys), + discrete_events = discrete_events(sys), assertions = assertions(sys), + is_dde = is_dde(sys), tstops = symbolic_tstops(sys), + initialization_eqs = initialization_equations(sys), + # 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, metadata = get_metadata(sys), + description = description(sys), name = nameof(sys)) +end + +has_massactionjumps(js::System) = any(x -> x isa MassActionJump, jumps(js)) +has_constantratejumps(js::System) = any(x -> x isa ConstantRateJump, jumps(js)) +has_variableratejumps(js::System) = any(x -> x isa VariableRateJump, jumps(js)) +# TODO: do we need this? it's kind of weird to keep +has_equations(js::System) = !isempty(equations(js)) + +function noise_equations_equal(sys1::System, sys2::System) + neqs1 = get_noise_eqs(sys1) + neqs2 = get_noise_eqs(sys2) + if neqs1 === nothing && neqs2 === nothing + return true + elseif neqs1 === nothing || neqs2 === nothing + return false + end + ndims(neqs1) == ndims(neqs2) || return false + + eqs1 = get_eqs(sys1) + eqs2 = get_eqs(sys2) + + # get the permutation vector of `eqs2` in terms of `eqs1` + # eqs1_used tracks the elements of `eqs1` already used in the permutation + eqs1_used = falses(length(eqs1)) + # the permutation of `eqs1` that gives `eqs2` + eqs2_perm = Int[] + for eq in eqs2 + # find the first unused element of `eqs1` equal to `eq` + idx = findfirst(i -> isequal(eq, eqs1[i]) && !eqs1_used[i], eachindex(eqs1)) + # none found, so noise equations are not equal + idx === nothing && return false + push!(eqs2_perm, idx) + end + + if neqs1 isa Vector + return isequal(@view(neqs1[eqs2_perm]), neqs2) + else + return isequal(@view(neqs1[eqs2_perm, :]), neqs2) + end +end + +function ignored_connections_equal(sys1::System, sys2::System) + ic1 = get_ignored_connections(sys1) + ic2 = get_ignored_connections(sys2) + if ic1 === nothing && ic2 === nothing + return true + elseif ic1 === nothing || ic2 === nothing + return false + end + return _eq_unordered(ic1[1], ic2[1]) && _eq_unordered(ic1[2], ic2[2]) +end + +""" + $(TYPEDSIGNATURES) + +Get the metadata associated with key `k` in system `sys` or `default` if it does not exist. +""" +function SymbolicUtils.getmetadata(sys::AbstractSystem, k::DataType, default) + meta = get_metadata(sys) + return get(meta, k, default) +end + +""" + $(TYPEDSIGNATURES) + +Set the metadata associated with key `k` in system `sys` to value `v`. This is an +out-of-place operation, and will return a shallow copy of `sys` with the appropriate +metadata values. +""" +function SymbolicUtils.setmetadata(sys::AbstractSystem, k::DataType, v) + meta = get_metadata(sys) + meta = Base.ImmutableDict(meta, k => v)::MetadataT + @set sys.metadata = meta +end + +function SymbolicUtils.hasmetadata(sys::AbstractSystem, k::DataType) + meta = get_metadata(sys) + haskey(meta, k) +end + +""" + $(TYPEDSIGNATURES) + +Metadata key for systems containing the `problem_type` to be passed to the problem +constructor, where applicable. For example, if `getmetadata(sys, ProblemTypeCtx, nothing)` +is `CustomType()` then `ODEProblem(sys, ...).problem_type` will be `CustomType()` instead +of `StandardODEProblem`. +""" +struct ProblemTypeCtx end + +""" + $(TYPEDSIGNATURES) +""" +function check_complete(sys::System, obj) + iscomplete(sys) || throw(SystemNotCompleteError(obj)) +end + +""" + $(TYPEDSIGNATURES) + +Given a time-dependent system `sys` of ODEs, convert it to a time-independent system of +nonlinear equations that solve for the steady-state of the unknowns. This is done by +replacing every derivative `D(x)` of an unknown `x` with zero. Note that this process +does not retain noise equations, brownian terms, jumps or costs associated with `sys`. +All other information such as defaults, guesses, observed and initialization equations +are retained. The independent variable of `sys` becomes a parameter of the returned system. + +If `sys` is hierarchical (it contains subsystems) this transformation will be applied +recursively to all subsystems. The output system will be marked as `complete` if and only +if the input system is also `complete`. This also retains the `split` flag passed to +`complete`. + +See also: [`complete`](@ref). +""" +function NonlinearSystem(sys::System) + if !is_time_dependent(sys) + throw(ArgumentError("`NonlinearSystem` constructor expects a time-dependent `System`")) + end + eqs = equations(sys) + obs = observed(sys) + subrules = Dict([D(x) => 0.0 for x in unknowns(sys)]) + for var in brownians(sys) + subrules[var] = 0.0 + end + eqs = map(eqs) do eq + fast_substitute(eq, subrules) + end + nsys = System(eqs, unknowns(sys), [parameters(sys); get_iv(sys)]; + defaults = merge(defaults(sys), Dict(get_iv(sys) => Inf)), guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), name = nameof(sys), + observed = obs, systems = map(NonlinearSystem, get_systems(sys))) + if iscomplete(sys) + nsys = complete(nsys; split = is_split(sys)) + @set! nsys.parameter_dependencies = get_parameter_dependencies(sys) + end + return nsys +end + +######## +# Utility constructors +######## + +""" + $(TYPEDSIGNATURES) + +Construct a time-independent [`System`](@ref) for optimizing the specified scalar `cost`. +The system will have no equations. + +Unknowns and parameters of the system are inferred from the cost and other values (such as +defaults) passed to it. + +All keyword arguments are the same as those of the [`System`](@ref) constructor. +""" +function OptimizationSystem(cost; kwargs...) + return System(Equation[]; costs = [cost], kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Identical to the corresponding single-argument `OptimizationSystem` constructor, except +the unknowns and parameters are specified by passing arrays of symbolic variables to `dvs` +and `ps` respectively. +""" +function OptimizationSystem(cost, dvs, ps; kwargs...) + return System(Equation[], nothing, dvs, ps; costs = [cost], kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Construct a time-independent [`System`](@ref) for optimizing the specified multi-objective +`cost`. The cost will be reduced to a scalar using the `consolidate` function. This +defaults to summing the specified cost and that of all subsystems. The system will have no +equations. + +Unknowns and parameters of the system are inferred from the cost and other values (such as +defaults) passed to it. + +All keyword arguments are the same as those of the [`System`](@ref) constructor. +""" +function OptimizationSystem(cost::Array; kwargs...) + return System(Equation[]; costs = vec(cost), kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Identical to the corresponding single-argument `OptimizationSystem` constructor, except +the unknowns and parameters are specified by passing arrays of symbolic variables to `dvs` +and `ps` respectively. +""" +function OptimizationSystem(cost::Array, dvs, ps; kwargs...) + return System(Equation[], nothing, dvs, ps; costs = vec(cost), kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Construct a [`System`](@ref) to solve a system of jump equations. `jumps` is an array of +jumps, expressed using `JumpProcesses.MassActionJump`, `JumpProcesses.ConstantRateJump` +and `JumpProcesses.VariableRateJump`. It can also include standard equations to simulate +jump-diffusion processes. `iv` should be the independent variable of the system. + +All keyword arguments are the same as those of the [`System`](@ref) constructor. +""" +function JumpSystem(jumps, iv; kwargs...) + mask = isa.(jumps, Equation) + eqs = Vector{Equation}(jumps[mask]) + jumps = jumps[.!mask] + return System(eqs, iv; jumps, kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Identical to the 2-argument `JumpSystem` constructor, but uses the explicitly provided +`dvs` and `ps` for unknowns and parameters of the system. +""" +function JumpSystem(jumps, iv, dvs, ps; kwargs...) + mask = isa.(jumps, Equation) + eqs = Vector{Equation}(jumps[mask]) + jumps = jumps[.!mask] + return System(eqs, iv, dvs, ps; jumps, kwargs...) +end + +# explicitly write the docstring to avoid mentioning `parameter_dependencies`. +""" + SDESystem(eqs::Vector{Equation}, noise, iv; is_scalar_noise = false, kwargs...) + +Construct a system of equations with associated noise terms. Instead of specifying noise +using [`@brownians`](@ref) variables, it is specified using a noise matrix `noise`. `iv` is +the independent variable of the system. + +In the general case, `noise` should be a `N x M` matrix where `N` is the number of +equations (`length(eqs)`) and `M` is the number of independent random variables. +`noise[i, j]` is the diffusion term for equation `i` and random variable `j`. If the noise +is diagonal (`N == M` and `noise[i, j] == 0` for all `i != j`) it can be specified as a +`Vector` of length `N` corresponding to the diagonal of the noise matrix. As a special +case, if all equations have the same noise then all rows of `noise` are identical. This +is known as "scalar noise". In this case, `noise` can be a `Vector` corresponding to the +repeated row and `is_scalar_noise` must be `true`. + +Note that systems created in this manner cannot be used hierarchically. This should only +be used to construct flattened systems. To use such a system hierarchically, it must be +converted to use brownian variables using [`noise_to_brownians`](@ref). [`mtkcompile`](@ref) +will automatically perform this conversion. + +All keyword arguments are the same as those of the [`System`](@ref) constructor. +""" +function SDESystem(eqs::Vector{Equation}, noise, iv; is_scalar_noise = false, + parameter_dependencies = Equation[], kwargs...) + if is_scalar_noise + if !(noise isa Vector) + throw(ArgumentError("Expected noise to be a vector if `is_scalar_noise`")) + end + noise = repeat(reshape(noise, (1, :)), length(eqs)) + end + sys = System(eqs, iv; noise_eqs = noise, kwargs...) + @set sys.parameter_dependencies = parameter_dependencies +end + +""" + SDESystem(eqs::Vector{Equation}, noise, iv, dvs, ps; is_scalar_noise = false, kwargs...) + + +Identical to the 3-argument `SDESystem` constructor, but uses the explicitly provided +`dvs` and `ps` for unknowns and parameters of the system. +""" +function SDESystem( + eqs::Vector{Equation}, noise, iv, dvs, ps; is_scalar_noise = false, + parameter_dependencies = Equation[], kwargs...) + if is_scalar_noise + if !(noise isa Vector) + throw(ArgumentError("Expected noise to be a vector if `is_scalar_noise`")) + end + noise = repeat(reshape(noise, (1, :)), length(eqs)) + end + sys = System(eqs, iv, dvs, ps; noise_eqs = noise, kwargs...) + @set sys.parameter_dependencies = parameter_dependencies +end + +""" + $(TYPEDSIGNATURES) + +Attach the given noise matrix `noise` to the system `sys`. +""" +function SDESystem(sys::System, noise; kwargs...) + SDESystem(equations(sys), noise, get_iv(sys); kwargs...) +end + +struct SystemNotCompleteError <: Exception + obj::Any +end + +function Base.showerror(io::IO, err::SystemNotCompleteError) + print(io, """ + A completed system is required. Call `complete` or `mtkcompile` on the \ + system before creating a `$(err.obj)`. + """) +end + +struct IllFormedNoiseEquationsError <: Exception + noise_eqs_rows::Int + eqs_length::Int +end + +function Base.showerror(io::IO, err::IllFormedNoiseEquationsError) + print(io, """ + Noise equations are ill-formed. The number of rows must match the number of drift \ + equations. `size(neqs, 1) == $(err.noise_eqs_rows) != length(eqs) == \ + $(err.eqs_length)`. + """) +end + +function NoNameError() + ArgumentError(""" + The `name` keyword must be provided. Please consider using the `@named` macro. + """) +end + +struct NonUniqueSubsystemsError <: Exception + names::Vector{Symbol} + uniques::Set{Symbol} +end + +function Base.showerror(io::IO, err::NonUniqueSubsystemsError) + dupes = Set{Symbol}() + for n in err.names + if !(n in err.uniques) + push!(dupes, n) + end + delete!(err.uniques, n) + end + println(io, "System names must be unique. The following system names were duplicated:") + for n in dupes + println(io, " ", n) + end +end + +struct EventsInTimeIndependentSystemError <: Exception + cevents::Vector + devents::Vector +end + +function Base.showerror(io::IO, err::EventsInTimeIndependentSystemError) + println( + io, """ +Events are not supported in time-independent systems. Provide an independent variable to \ +make the system time-dependent or remove the events. + +The following continuous events were provided: +$(err.cevents) + +The following discrete events were provided: +$(err.devents) +""") +end + +function supports_initialization(sys::System) + return isempty(jumps(sys)) && _iszero(cost(sys)) && + isempty(constraints(sys)) +end + +safe_eachrow(::Nothing) = nothing +safe_eachrow(x::AbstractArray) = eachrow(x) + +safe_issetequal(::Nothing, ::Nothing) = true +safe_issetequal(::Nothing, x) = false +safe_issetequal(x, ::Nothing) = false +safe_issetequal(x, y) = issetequal(x, y) + +""" + $(TYPEDSIGNATURES) + +Check if two systems are about equal, to the extent that ModelingToolkit.jl supports. Note +that if this returns `true`, the systems are not guaranteed to be exactly equivalent +(unless `sysa === sysb`) but are highly likely to represent a similar mathematical problem. +If this returns `false`, the systems are very likely to be different. +""" +function Base.isapprox(sysa::System, sysb::System) + sysa === sysb && return true + return nameof(sysa) == nameof(sysb) && + isequal(get_iv(sysa), get_iv(sysb)) && + issetequal(get_eqs(sysa), get_eqs(sysb)) && + safe_issetequal( + safe_eachrow(get_noise_eqs(sysa)), safe_eachrow(get_noise_eqs(sysb))) && + issetequal(get_jumps(sysa), get_jumps(sysb)) && + issetequal(get_constraints(sysa), get_constraints(sysb)) && + issetequal(get_costs(sysa), get_costs(sysb)) && + isequal(get_consolidate(sysa), get_consolidate(sysb)) && + issetequal(get_unknowns(sysa), get_unknowns(sysb)) && + issetequal(get_ps(sysa), get_ps(sysb)) && + issetequal(get_brownians(sysa), get_brownians(sysb)) && + issetequal(get_observed(sysa), get_observed(sysb)) && + issetequal(get_parameter_dependencies(sysa), get_parameter_dependencies(sysb)) && + isequal(get_description(sysa), get_description(sysb)) && + isequal(get_defaults(sysa), get_defaults(sysb)) && + isequal(get_guesses(sysa), get_guesses(sysb)) && + issetequal(get_initialization_eqs(sysa), get_initialization_eqs(sysb)) && + issetequal(get_continuous_events(sysa), get_continuous_events(sysb)) && + issetequal(get_discrete_events(sysa), get_discrete_events(sysb)) && + isequal(get_connector_type(sysa), get_connector_type(sysb)) && + isequal(get_assertions(sysa), get_assertions(sysb)) && + isequal(get_metadata(sysa), get_metadata(sysb)) && + isequal(get_is_dde(sysa), get_is_dde(sysb)) && + issetequal(get_tstops(sysa), get_tstops(sysb)) && + safe_issetequal(get_ignored_connections(sysa), get_ignored_connections(sysb)) && + isequal(get_is_initializesystem(sysa), get_is_initializesystem(sysb)) && + isequal(get_is_discrete(sysa), get_is_discrete(sysb)) && + isequal(get_isscheduled(sysa), get_isscheduled(sysb)) +end diff --git a/src/systems/systems.jl b/src/systems/systems.jl new file mode 100644 index 0000000000..891714dce6 --- /dev/null +++ b/src/systems/systems.jl @@ -0,0 +1,295 @@ +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) + +Compile the given system into a form that ModelingToolkit can generate code for. Also +performs a variety of symbolic-numeric enhancements. For ODEs, this includes processes +such as order reduction, index reduction, alias elimination and tearing. A subset of the +unknowns of the system may be eliminated as observables, eliminating the need for the +numerical solver to solve for these variables. + +Does not rely on metadata to identify variables/parameters/brownians. Instead, queries +the system for which symbolic quantites belong to which category. Any variables not +present in the equations of the system will be removed in this process. + +# 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. ++ `inputs`, `outputs` and `disturbance_inputs` are passed as keyword arguments.` All inputs` get converted to parameters and are allowed to be unconnected, allowing models where `n_unknowns = n_equations - n_inputs`. ++ `sort_eqs=true` controls whether equations are sorted lexicographically before simplification or not. +""" +function mtkcompile( + sys::AbstractSystem; additional_passes = [], simplify = false, split = true, + allow_symbolic = false, allow_parameter = true, conservative = false, fully_determined = true, + inputs = Any[], outputs = Any[], + disturbance_inputs = Any[], + kwargs...) + isscheduled(sys) && throw(RepeatedStructuralSimplificationError()) + newsys′ = __mtkcompile(sys; simplify, + allow_symbolic, allow_parameter, conservative, fully_determined, + inputs, outputs, disturbance_inputs, + kwargs...) + if newsys′ isa Tuple + @assert length(newsys′) == 2 + newsys = newsys′[1] + else + newsys = newsys′ + end + for pass in additional_passes + newsys = pass(newsys) + end + if 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 __mtkcompile(sys::AbstractSystem; simplify = false, + inputs = Any[], outputs = Any[], + disturbance_inputs = Any[], + sort_eqs = true, + kwargs...) + # TODO: convert noise_eqs to brownians for simplification + if has_noise_eqs(sys) && get_noise_eqs(sys) !== nothing + sys = noise_to_brownians(sys; names = :αₘₜₖ) + end + if !isempty(jumps(sys)) + return sys + end + if isempty(equations(sys)) && !is_time_dependent(sys) && !_iszero(cost(sys)) + return simplify_optimization_system(sys; kwargs..., sort_eqs, simplify) + end + + sys = expand_connections(sys) + state = TearingState(sys; sort_eqs) + + @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 mtkcompile!( + state; simplify, inputs, outputs, disturbance_inputs, 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] + ode_sys = mtkcompile( + sys; simplify, inputs, outputs, disturbance_inputs, 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 = reshape(sorted_g_rows[:, 1], (:, 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 = substitute_observed(ode_sys, noise_eqs) + ssys = System(Vector{Equation}(full_equations(ode_sys)), + get_iv(ode_sys), unknowns(ode_sys), parameters(ode_sys); noise_eqs, + name = nameof(ode_sys), observed = observed(ode_sys), defaults = defaults(sys), + assertions = assertions(sys), + guesses = guesses(sys), initialization_eqs = initialization_equations(sys), + continuous_events = continuous_events(sys), + discrete_events = discrete_events(sys)) + @set! ssys.parameter_dependencies = get_parameter_dependencies(sys) + return ssys + end +end + +function simplify_optimization_system(sys::System; 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 + irreducible_subs = Dict() + dvs = mapreduce(Symbolics.scalarize, vcat, unknowns(sys)) + if !(dvs isa Array) + dvs = [dvs] + end + for i in eachindex(dvs) + var = dvs[i] + if hasbounds(var) + irreducible_subs[var] = irrvar = setirreducible(var, true) + dvs[i] = irrvar + end + end + econs = fast_substitute.(econs, (irreducible_subs,)) + nlsys = System(econs, dvs, parameters(sys); name = :___tmp_nlsystem) + snlsys = mtkcompile(nlsys; kwargs..., fully_determined = false) + obs = observed(snlsys) + seqs = equations(snlsys) + trueobs, _ = unhack_observed(obs, seqs) + subs = Dict(eq.lhs => eq.rhs for eq in trueobs) + 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(dvs, keys(subs)) + @set! sys.constraints = cons_simplified + @set! sys.observed = [observed(sys); obs] + newcost = fixpoint_sub.(get_costs(sys), (subs,)) + @set! sys.costs = newcost + @set! sys.unknowns = newsts + return 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 + +""" + $(TYPEDSIGNATURES) + +Given a system that has been simplified via `mtkcompile`, 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 `mtkcompile` 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..4fdb96c789 100644 --- a/src/systems/systemstructure.jl +++ b/src/systems/systemstructure.jl @@ -1,153 +1,492 @@ -module SystemStructures - using DataStructures -using SymbolicUtils: istree, operation, arguments, Symbolic +using Symbolics: linear_expansion, unwrap +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, Connection, + independent_variables, SparseMatrixCLIL, AbstractSystem, + equations, isirreducible, input_timedomain, TimeDomain, + InferredTimeDomain, + VariableType, getvariabletype, has_equations, System 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 + +export SystemStructure, TransformationState, TearingState, mtkcompile! +export isdiffvar, isdervar, isalgvar, isdiffeq, algeqs, is_only_discrete +export dervars_range, diffvars_range, algvars_range +export DiffGraph, complete! +export get_fullvars, system_subset -eqs = [0 ~ z + x; 0 ~ y + z^2] -states = [y, z] -observed = [x ~ sin(y) + z] -struct Reduced - var - expr - idxs +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...) + +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 -@enum VariableType::Int8 DIFFERENTIAL_VARIABLE ALGEBRAIC_VARIABLE DERIVATIVE_VARIABLE +function invview(dg::DiffGraph) + require_complete(dg) + return DiffGraph(dg.diff_to_primal, dg.primal_to_diff) +end -Base.@kwdef struct SystemPartition - e_solved::Vector{Int} - v_solved::Vector{Int} - e_residual::Vector{Int} - v_residual::Vector{Int} +struct DiffChainIterator{Descend} + var_to_diff::DiffGraph + v::Int 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 +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 -Base.@kwdef struct SystemStructure - 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} +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 -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 +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 -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)) +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 -isalgeq(s::SystemStructure, eq::Integer) = s.algeqs[eq] -isdiffeq(s::SystemStructure, eq::Integer) = !isalgeq(s, eq) +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 initialize_system_structure(sys) - sys = flatten(sys) +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{BasicSymbolic} + structure::SystemStructure + extra_eqs::Vector + param_derivative_map::Dict{BasicSymbolic, Any} + original_eqs::Vector{Equation} + """ + Additional user-provided observed equations. The variables calculated here + are not used in the rest of the system. + """ + additional_observed::Vector{Equation} +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.original_eqs = ts.original_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 is_time_dependent_parameter(p, allps, iv) + return iv !== nothing && p in allps && iscall(p) && + (operation(p) === getindex && + is_time_dependent_parameter(arguments(p)[1], allps, iv) || + (args = arguments(p); length(args)) == 1 && isequal(only(args), iv)) +end + +function symbolic_contains(var, set) + var in set || + symbolic_type(var) == ArraySymbolic() && + Symbolics.shape(var) != Symbolics.Unknown() && + all(x -> x in set, Symbolics.scalarize(var)) +end + +function TearingState(sys; quick_cancel = false, check = true, sort_eqs = true) + # flatten system + sys = flatten(sys) + sys = process_parameter_equations(sys) + ivs = independent_variables(sys) + iv = length(ivs) == 1 ? ivs[1] : nothing + # flatten array equations + eqs = flatten_equations(equations(sys)) + original_eqs = copy(eqs) neqs = length(eqs) - algeqs = trues(neqs) - dervaridxs = OrderedSet{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 + param_derivative_map = Dict{BasicSymbolic, Any}() + # * Scalarize unknowns + dvs = Set{BasicSymbolic}() + fullvars = BasicSymbolic[] + for x in unknowns(sys) + push!(dvs, x) + xx = Symbolics.scalarize(x) + if xx isa AbstractArray + union!(dvs, xx) + end + end + ps = Set{Symbolic}() + for x in full_parameters(sys) + push!(ps, x) + if symbolic_type(x) == ArraySymbolic() && Symbolics.shape(x) != Symbolics.Unknown() + xx = Symbolics.scalarize(x) + union!(ps, xx) + end + end + browns = Set{BasicSymbolic}() + for x in brownians(sys) + push!(browns, x) + xx = Symbolics.scalarize(x) + if xx isa AbstractArray + union!(browns, xx) + end + end + var2idx = Dict{BasicSymbolic, Int}() + var_types = VariableType[] + addvar! = let fullvars = fullvars, dvs = dvs, var2idx = var2idx, var_types = var_types + (var, vtype) -> get!(var2idx, var) do + push!(dvs, var) + push!(fullvars, var) + push!(var_types, vtype) + return length(fullvars) end end - vars = OrderedSet() - for (i, eq′) in enumerate(eqs) - if _iszero(eq′.lhs) - eq = eq′ - else - eq = 0 ~ eq′.rhs - eq′.lhs + # build symbolic incidence + symbolic_incidence = Vector{BasicSymbolic}[] + varsbuf = Set() + eqs_to_retain = trues(length(eqs)) + for (i, eq) in enumerate(eqs) + _eq = eq + if iscall(eq.lhs) && (op = operation(eq.lhs)) isa Differential && + isequal(op.x, iv) && is_time_dependent_parameter(only(arguments(eq.lhs)), ps, iv) + # parameter derivatives are opted out by specifying `D(p) ~ missing`, but + # we want to store `nothing` in the map because that means `fast_substitute` + # will ignore the rule. We will this identify the presence of `eq′.lhs` in + # the differentiated expression and error. + param_derivative_map[eq.lhs] = coalesce(eq.rhs, nothing) + eqs_to_retain[i] = false + # change the equation if the RHS is `missing` so the rest of this loop works + eq = 0.0 ~ coalesce(eq.rhs, 0.0) end - vars!(vars, eq.rhs) + rhs = quick_cancel ? quick_cancel_expr(eq.rhs) : eq.rhs + if !_iszero(eq.lhs) + lhs = quick_cancel ? quick_cancel_expr(eq.lhs) : eq.lhs + eq = 0 ~ rhs - lhs + end + empty!(varsbuf) + vars!(varsbuf, eq; op = Symbolics.Operator) + incidence = Set{BasicSymbolic}() isalgeq = true - statevars = [] - for var in vars - isequal(var, iv) && continue - if isparameter(var) || (istree(var) && isparameter(operation(var))) + for v in varsbuf + # additionally track brownians in fullvars + if v in browns + addvar!(v, BROWNIAN) + push!(incidence, v) + end + + # TODO: Can we handle this without `isparameter`? + if symbolic_contains(v, ps) || + getmetadata(v, SymScope, LocalScope()) isa GlobalScope && isparameter(v) + if is_time_dependent_parameter(v, ps, iv) && + !haskey(param_derivative_map, Differential(iv)(v)) + # Parameter derivatives default to zero - they stay constant + # between callbacks + param_derivative_map[Differential(iv)(v)] = 0.0 + end continue end - varidx = addvar!(var) - push!(statevars, var) - - dvar = var - idx = varidx - while isdifferential(dvar) - if !(idx in dervaridxs) - push!(dervaridxs, idx) + + isequal(v, iv) && continue + isdelay(v, iv) && continue + + if !symbolic_contains(v, dvs) + isvalid = iscall(v) && + (operation(v) isa Shift || is_transparent_operator(operation(v))) + v′ = v + while !isvalid && iscall(v′) && operation(v′) isa Union{Differential, Shift} + v′ = arguments(v′)[1] + if v′ in dvs || getmetadata(v′, SymScope, LocalScope()) isa GlobalScope + isvalid = true + break + end end - isalgeq = false - dvar = arguments(dvar)[1] - idx = addvar!(dvar) + if !isvalid + throw(ArgumentError("$v is present in the system but $v′ is not an unknown.")) + end + + addvar!(v, VARIABLE) + if iscall(v) && operation(v) isa Symbolics.Operator && !isdifferential(v) && + (it = input_timedomain(v)) !== nothing + v′ = only(arguments(v)) + addvar!(setmetadata(v′, VariableTimeDomain, it), VARIABLE) + end + end + + isalgeq &= !isdifferential(v) + + if symbolic_type(v) == ArraySymbolic() + vv = collect(v) + union!(incidence, vv) + map(vv) do vi + addvar!(vi, VARIABLE) + end + else + push!(incidence, v) + addvar!(v, VARIABLE) end end - push!(symbolic_incidence, copy(statevars)) - empty!(statevars) - empty!(vars) - algeqs[i] = isalgeq + if isalgeq eqs[i] = eq + else + eqs[i] = eqs[i].lhs ~ rhs + end + push!(symbolic_incidence, collect(incidence)) + end + + dervaridxs = OrderedSet{Int}() + for (i, v) in enumerate(fullvars) + while isdifferential(v) + push!(dervaridxs, i) + v = arguments(v)[1] + i = addvar!(v, VARIABLE) + end + end + eqs = eqs[eqs_to_retain] + original_eqs = original_eqs[eqs_to_retain] + neqs = length(eqs) + symbolic_incidence = symbolic_incidence[eqs_to_retain] + + if sort_eqs + # sort equations lexicographically to reduce simplification issues + # depending on order due to NP-completeness of tearing. + sortidxs = Base.sortperm(string.(eqs)) # "by = string" creates more strings + eqs = eqs[sortidxs] + original_eqs = original_eqs[sortidxs] + symbolic_incidence = symbolic_incidence[sortidxs] + end + + # Handle shifts - find lowest shift and add intermediates with derivative edges + ### 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, VARIABLE) + 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,109 +496,420 @@ 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) + # build `var_to_diff` 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 + # build incidence graph graph = BipartiteGraph(neqs, nvars, Val(false)) for (ie, vars) in enumerate(symbolic_incidence), v in vars + jv = var2idx[v] add_edge!(graph, ie, jv) 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 + + eq_to_diff = DiffGraph(nsrcs(graph)) + + ts = TearingState(sys, fullvars, + SystemStructure(complete(var_to_diff), complete(eq_to_diff), + complete(graph), nothing, var_types, false), + Any[], param_derivative_map, original_eqs, Equation[]) + + return ts 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 +""" + $(TYPEDSIGNATURES) + +Preemptively identify observed equations in the system and tear them. This happens before +any simplification. The equations torn by this process are ones that are already given in +an explicit form in the system and where the LHS is not present in any other equation of +the system except for other such preempitvely torn equations. +""" +function trivial_tearing!(ts::TearingState) + @assert length(ts.original_eqs) == length(equations(ts)) + # equations that can be trivially torn an observed equations + trivial_idxs = BitSet() + # equations to never check + blacklist = BitSet() + torn_eqs = Equation[] + # variables that have been matched to trivially torn equations + matched_vars = BitSet() + # variable to index in fullvars + var_to_idx = Dict{Any, Int}(ts.fullvars .=> eachindex(ts.fullvars)) + + complete!(ts.structure) + var_to_diff = ts.structure.var_to_diff + graph = ts.structure.graph + while true + # track whether we added an equation to the trivial list this iteration + added_equation = false + for (i, eq) in enumerate(ts.original_eqs) + # don't check already torn equations + i in trivial_idxs && continue + i in blacklist && continue + # ensure it is an observed equation matched to a variable in fullvars + vari = get(var_to_idx, eq.lhs, 0) + iszero(vari) && continue + # don't tear irreducible variables + if isirreducible(eq.lhs) + push!(blacklist, i) + continue + end + # if a variable was the LHS of two trivial observed equations, we wouldn't have + # included it in the list. Error if somehow it made it through. + @assert !(vari in matched_vars) + # don't tear differential/shift equations (or differentiated/shifted variables) + var_to_diff[vari] === nothing || continue + invview(var_to_diff)[vari] === nothing || continue + # get the equations that the candidate matched variable is present in, except + # those equations which have already been torn as observed + eqidxs = setdiff(𝑑neighbors(graph, vari), trivial_idxs) + # it should only be present in this equation + length(eqidxs) == 1 || continue + eqi = only(eqidxs) + @assert eqi == i + + # for every variable present in this equation, make sure it isn't _only_ + # present in trivial equations + isvalid = true + for v in 𝑠neighbors(graph, eqi) + v == vari && continue + v in matched_vars && continue + # `> 1` and not `0` because one entry will be this equation (`eqi`) + isvalid &= count(!in(trivial_idxs), 𝑑neighbors(graph, v)) > 1 + isvalid || break + end + isvalid || continue + # skip if the LHS is present in the RHS, since then this isn't explicit + if occursin(eq.lhs, eq.rhs) + push!(blacklist, i) + continue end + + added_equation = true + push!(trivial_idxs, eqi) + push!(torn_eqs, eq) + push!(matched_vars, vari) + end + + # if we didn't add an equation this iteration, we won't add one next iteration + added_equation || break + end + + deleteat!(var_to_diff.primal_to_diff, matched_vars) + deleteat!(var_to_diff.diff_to_primal, matched_vars) + deleteat!(ts.structure.eq_to_diff.primal_to_diff, trivial_idxs) + deleteat!(ts.structure.eq_to_diff.diff_to_primal, trivial_idxs) + delete_srcs!(ts.structure.graph, trivial_idxs; rm_verts = true) + delete_dsts!(ts.structure.graph, matched_vars; rm_verts = true) + if ts.structure.solvable_graph !== nothing + delete_srcs!(ts.structure.solvable_graph, trivial_idxs; rm_verts = true) + delete_dsts!(ts.structure.solvable_graph, matched_vars; rm_verts = true) + end + if ts.structure.var_types !== nothing + deleteat!(ts.structure.var_types, matched_vars) + end + deleteat!(ts.fullvars, matched_vars) + deleteat!(ts.original_eqs, trivial_idxs) + ts.additional_observed = torn_eqs + sys = ts.sys + eqs = copy(get_eqs(sys)) + deleteat!(eqs, trivial_idxs) + @set! sys.eqs = eqs + ts.sys = sys + 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 + 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, Pre}) + 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, Pre})) + + for i in eachindex(fullvars) + fullvars[i] = StructuralTransformations.simplify_shifts(fast_substitute( + fullvars[i], discmap; operator = Union{Sample, Hold, Pre})) + end + for i in eachindex(eqs) + eqs[i] = StructuralTransformations.simplify_shifts(fast_substitute( + eqs[i], discmap; operator = Union{Sample, Hold, Pre})) + 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 + +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 - # 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)) +function Base.copy(ms::MatchedSystemStructure) + MatchedSystemStructure(Base.copy(ms.structure), Base.copy(ms.var_eq_matching)) +end + +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 + +function mtkcompile!(state::TearingState; simplify = false, + check_consistency = true, fully_determined = true, warn_initialize_determined = true, + inputs = Any[], outputs = Any[], + disturbance_inputs = Any[], + kwargs...) + 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, clocked_inputs, continuous_id, id_to_clock = ModelingToolkit.split_system(ci) + if length(tss) > 1 + if continuous_id == 0 + throw(HybridSystemNotSupportedException(""" + Discrete systems with multiple clocks are not supported with the standard \ + MTK compiler. + """)) else - is_linear_equations[i] = false + 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 end + if get_is_discrete(state.sys) || + continuous_id == 1 && any(Base.Fix2(isoperator, Shift), state.fullvars) + state.structure.only_discrete = true + state = shift_discrete_system(state) + sys = state.sys + @set! sys.is_discrete = true + state.sys = sys + end - return is_linear_equations, eadj, cadj + sys = _mtkcompile!(state; simplify, check_consistency, + inputs, outputs, disturbance_inputs, + fully_determined, kwargs...) + return sys +end + +function _mtkcompile!(state::TearingState; simplify = false, + check_consistency = true, fully_determined = true, warn_initialize_determined = false, + dummy_derivative = true, + inputs = Any[], outputs = Any[], + disturbance_inputs = Any[], + kwargs...) + if fully_determined isa Bool + check_consistency &= fully_determined + else + check_consistency = true + end + has_io = !isempty(inputs) || !isempty(outputs) !== nothing || + !isempty(disturbance_inputs) + orig_inputs = Set() + if has_io + ModelingToolkit.markio!(state, orig_inputs, inputs, outputs, disturbance_inputs) + state = ModelingToolkit.inputs_to_parameters!(state, [inputs; disturbance_inputs]) + end + trivial_tearing!(state) + sys, mm = ModelingToolkit.alias_elimination!(state; fully_determined, 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; fully_determined, kwargs...) + sys = ModelingToolkit.dummy_derivative( + sys, state; simplify, mm, check_consistency, fully_determined, kwargs...) + else + sys = ModelingToolkit.tearing( + sys, state; simplify, mm, check_consistency, fully_determined, kwargs...) + end + fullunknowns = [observables(sys); unknowns(sys)] + @set! sys.observed = ModelingToolkit.topsort_equations(observed(sys), fullunknowns) + + ModelingToolkit.invalidate_cache!(sys) 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) +struct DifferentiatedVariableNotUnknownError <: Exception + differentiated::Any + undifferentiated::Any end -end # module +function Base.showerror(io::IO, err::DifferentiatedVariableNotUnknownError) + undiff = err.undifferentiated + diff = err.differentiated + print(io, + "Variable $undiff occurs differentiated as $diff but is not an unknown of the system.") + scope = getmetadata(undiff, SymScope, LocalScope()) + depth = expected_scope_depth(scope) + if depth > 0 + print(io, + "\nVariable $undiff expects $depth more levels in the hierarchy to be an unknown.") + end +end diff --git a/src/systems/unit_check.jl b/src/systems/unit_check.jl new file mode 100644 index 0000000000..acf7451065 --- /dev/null +++ b/src/systems/unit_check.jl @@ -0,0 +1,320 @@ +#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::Vector{JumpType}, t::Symbolic) + labels = ["in Mass Action Jumps,", "in Constant Rate Jumps,", "in Variable Rate Jumps,"] + majs = filter(x -> x isa MassActionJump, jumps) + crjs = filter(x -> x isa ConstantRateJump, jumps) + vrjs = filter(x -> x isa VariableRateJump, jumps) + splitjumps = [majs, crjs, vrjs] + all([validate(js, t; info) for (js, info) in zip(splitjumps, labels)]) +end + +function validate(eq::Union{Inequality, 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..d416a02ea2 --- /dev/null +++ b/src/systems/validation.jl @@ -0,0 +1,287 @@ +module UnitfulUnitCheck + +using ..ModelingToolkit, Symbolics, SciMLBase, Unitful, RecursiveArrayTools +using ..ModelingToolkit: ValidationError, + ModelingToolkit, Connection, instream, JumpType, VariableUnit, + get_systems, + Conditional, Comparison +using JumpProcesses: MassActionJump, ConstantRateJump, VariableRateJump +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::Vector{JumpType}, t::Symbolic) + labels = ["in Mass Action Jumps,", "in Constant Rate Jumps,", "in Variable Rate Jumps,"] + majs = filter(x -> x isa MassActionJump, jumps) + crjs = filter(x -> x isa ConstantRateJump, jumps) + vrjs = filter(x -> x isa VariableRateJump, jumps) + splitjumps = [majs, crjs, vrjs] + all([validate(js, t; info) for (js, info) in zip(splitjumps, labels)]) +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..e96f31f533 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,90 +1,54 @@ -function make_operation(@nospecialize(op), args) - if op === (*) - args = filter(!_isone, args) - if isempty(args) - return 1 - end - elseif op === (+) - args = filter(!_iszero, args) - if isempty(args) - return 0 - end - end - return op(args...) +""" + 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 + +""" + $(TYPEDSIGNATURES) + +Turn `x(t)` into `x` +""" 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 +""" + $(TYPEDSIGNATURES) +Reverse `detime_dvs` for the given `dvs` using independent variable `iv`. +""" 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) - -macro showarr(x) - n = string(x) - quote - y = $(esc(x)) - println($n, " = ", summary(y)) - Base.print_array(stdout, y) - println() - y - end +function modified_unknowns!(munknowns, e::Equation, unknownlist = nothing) + get_variables!(munknowns, e.lhs, unknownlist) 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)) - function todict(d) eltype(d) <: Pair || throw(ArgumentError("The variable-value mapping must be a Dict.")) d isa Dict ? d : Dict(d) 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 +67,1015 @@ 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.")) + end +end + +function is_delay_var(iv, var) + if Symbolics.isarraysymbolic(var) + return is_delay_var(iv, first(collect(var))) + end + 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.")) + 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 mtkcompile 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 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 `mtkcompile` 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 `mtkcompile` or use the DAE form.\nGot $eq") + for v in tmp + v in ops && + error("The LHS operator must be unique. Please run `mtkcompile` 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 + +""" + $(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_jumps(sys) + for eq in jumps(sys) + eqtype_supports_collect_vars(eq) || continue + collect_vars!(unknowns, parameters, eq, iv; depth, op) + 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 +end + +""" + $(TYPEDSIGNATURES) + +Check whether the usage of operator `op` is valid in a system with independent variable +`iv`. If the system is time-independent, `iv` should be `nothing`. Throw an appropriate +error if `op` is invalid. `args` are the arguments to `op`. + +# Keyword arguments + +- `context`: The place where the operator occurs in the system/expression, or any other + relevant information. Useful for providing extra information in the error message. +""" +function validate_operator(op, args, iv; context = nothing) + error("`$validate_operator` is not implemented for operator `$op` in $context.") +end + +function validate_operator(op::Differential, args, iv; context = nothing) + isequal(op.x, iv) || throw(OperatorIndepvarMismatchError(op, iv, context)) + arg = unwrap(only(args)) + if !is_variable_floatingpoint(arg) + throw(ContinuousOperatorDiscreteArgumentError(op, arg, context)) + end +end + +struct ContinuousOperatorDiscreteArgumentError <: Exception + op::Any + arg::Any + context::Any +end + +function Base.showerror(io::IO, err::ContinuousOperatorDiscreteArgumentError) + print(io, """ + Operator $(err.op) expects continuous arguments, with a `symtype` such as `Number`, + `Real`, `Complex` or a subtype of `AbstractFloat`. Found $(err.arg) with a symtype of + $(symtype(err.arg))$(err.context === nothing ? "." : "in $(err.context).") + """) +end + +struct OperatorIndepvarMismatchError <: Exception + op::Any + iv::Any + context::Any +end + +function Base.showerror(io::IO, err::OperatorIndepvarMismatchError) + print(io, """ + Encountered operator `$(err.op)` which has different independent variable than the \ + one used in the system `$(err.iv)`. + """) + if err.context !== nothing + println(io) + print(io, "Context:\n$(err.context)") + end +end + +""" + $(TYPEDSIGNATURES) + +Search through `expr` for all symbolic variables present in it. Populate `dvs` with +unknowns and `ps` with parameters present. `iv` should be the independent variable of the +system or `nothing` for time-independent systems. Expressions where the operator `isa op` +go through `validate_operator`. + +`depth` is a keyword argument which indicates how many levels down `expr` is from the root +of the system hierarchy. This is used to resolve scoping operators. The scope of a variable +can be checked using `check_scope_depth`. + +This function should return `nothing`. +""" +function collect_vars!(unknowns, parameters, expr, iv; depth = 0, op = Symbolics.Operator) + if issym(expr) + return collect_var!(unknowns, parameters, expr, iv; depth) + end + for var in vars(expr; op) + while iscall(var) && operation(var) isa op + validate_operator(operation(var), arguments(var), iv; context = expr) + var = arguments(var)[1] + end + collect_var!(unknowns, parameters, var, iv; depth) + 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 = Symbolics.Operator) + 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 = Symbolics.Operator) + collect_vars!(unknowns, parameters, p[1], iv; depth, op) + collect_vars!(unknowns, parameters, p[2], iv; depth, op) + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Identify whether `var` belongs to the current system using `depth` and scoping information. +Add `var` to `unknowns` or `parameters` appropriately, and search through any expressions +in known metadata of `var` using `collect_vars!`. +""" +function collect_var!(unknowns, parameters, var, iv; depth = 0) + isequal(var, iv) && return nothing + if Symbolics.iswrapped(var) + error(""" + Internal Error. Please open an issue with an MWE. + + Encountered a wrapped value in `collect_var!`. This function should only ever \ + receive unwrapped symbolic variables. This is likely a bug in the code generating \ + an expression passed to `collect_vars!` or `collect_scoped_vars!`. A common cause \ + is using `substitute` or `fast_substitute` with rules where the values are \ + wrapped symbolic variables. + """) + end + 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) + else + 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 GlobalScope + return depth == -1 + end +end + +isarray(x) = x isa AbstractArray || x isa Symbolics.Arr + +""" + $(TYPEDSIGNATURES) + +Check if any variables were eliminated from the system as part of `mtkcompile`. +""" +function empty_substitutions(sys) + isempty(observed(sys)) +end + +""" + $(TYPEDSIGNATURES) + +Get a dictionary mapping variables eliminated from the system during `mtkcompile` to the +expressions used to calculate them. +""" +function get_substitutions(sys) + obs, _ = unhack_observed(observed(sys), equations(sys)) + Dict([eq.lhs => eq.rhs for eq in obs]) +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 + +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) + is_floatingpoint_symtype(T) +end + +""" + $(TYPEDSIGNATURES) + +Check if `T` is an appropriate symtype for a symbolic variable representing a floating +point number or array of such numbers. +""" +function is_floatingpoint_symtype(T::Type) + return T == Real || T == Number || T == Complex || T <: AbstractFloat || + T <: AbstractArray && is_floatingpoint_symtype(eltype(T)) +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 + +abstract type ObservedGraphCacheKey end + +struct ObservedGraphCache + graph::DiCMOBiGraph{false, Int, BipartiteGraph{Int, Nothing}, + Matching{Unassigned, Vector{Union{Unassigned, Int}}}} + obsvar_to_idx::Dict{Any, Int} +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, Initial}), obs = observed(sys), available_vars = []) + if iscomplete(sys) && obs == observed(sys) + cache = getmetadata(sys, MutableCacheKey, nothing) + obs_graph_cache = get!(cache, ObservedGraphCacheKey) do + obsvar_to_idx = Dict{Any, Int}([eq.lhs => i for (i, eq) in enumerate(obs)]) + graph = observed_dependency_graph(obs) + return ObservedGraphCache(graph, obsvar_to_idx) + end + @unpack obsvar_to_idx, graph = obs_graph_cache + else + obsvar_to_idx = Dict([eq.lhs => i for (i, eq) in enumerate(obs)]) + graph = observed_dependency_graph(obs) + end + + 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 = @something(get(obsvar_to_idx, sym, nothing), + get(obsvar_to_idx, arrsym, nothing), + Some(nothing)) + 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 + +""" + $(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 + +""" + $(TYPEDSIGNATURES) + +Check if `a` and `b` contain identical elements, regardless of order. This is not +equivalent to `issetequal` because the latter does not account for identical elements that +have different multiplicities in `a` and `b`. +""" +function _eq_unordered(a::AbstractArray, b::AbstractArray) + # a and b may be multidimensional + # e.g. comparing noiseeqs of SDEs + a = vec(a) + b = vec(b) + length(a) === length(b) || return false + n = length(a) + idxs = Set(1:n) + 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 + delete!(idxs, idx) + end + return true +end + +_eq_unordered(a, b) = isequal(a, b) + +""" + $(TYPEDSIGNATURES) + +Given a list of equations where some may be array equations, flatten the array equations +without scalarizing occurrences of array variables and return the new list of equations. +""" +function flatten_equations(eqs::Vector{Equation}) + 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 + +const JumpType = Union{VariableRateJump, ConstantRateJump, MassActionJump} + +struct NotPossibleError <: Exception end + +function Base.showerror(io::IO, ::NotPossibleError) + print(io, """ + This should not be possible. Please open an issue in ModelingToolkit.jl with an MWE. + """) +end diff --git a/src/variables.jl b/src/variables.jl index e09104bbef..a4f0b5532f 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -1,67 +1,616 @@ 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 """ -$(SIGNATURES) + 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 +""" + $(TYPEDEF) + +Flag which is meant to be passed to the `connect` metadata of a variable to affect how it +behaves when the connector it is in is part of a `connect` equation. `Equality` is the +default value and such variables when connected are made equal. For example, electric +potential is equated at a junction. + +For more information, refer to the [Connection semantics](@ref connect_semantics) section +of the docs. + +See also: [`connect`](@ref), [`@connector`](@ref), [`Flow`](@ref), +[`Stream`](@ref). +""" +struct Equality <: AbstractConnectType end # Equality connection +""" + $(TYPEDEF) + +Flag which is meant to be passed to the `connect` metadata of a variable to affect how it +behaves when the connector it is in is part of a `connect` equation. `Flow` denotes that +the sum of marked variable in all connectors in the connection set must sum to zero. For +example, electric current sums to zero at a junction (assuming appropriate signs are used +for current flowing in and out of the function). + +For more information, refer to the [Connection semantics](@ref connect_semantics) section +of the docs. + +See also: [`connect`](@ref), [`@connector`](@ref), [`Equality`](@ref), +[`Stream`](@ref). +""" +struct Flow <: AbstractConnectType end # sum to 0 +""" + $(TYPEDEF) + +Flag which is meant to be passed to the `connect` metadata of a variable to affect how it +behaves when the connector it is in is part of a `connect` equation. `Stream` denotes that +the variable is part of a special stream connector. + +For more information, refer to the [Connection semantics](@ref connect_semantics) section +of the docs. -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) - # Edge cases where one of the arguments is effectively empty. - 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) +See also: [`connect`](@ref), [`@connector`](@ref), [`Equality`](@ref), +[`Flow`](@ref). +""" +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 + +""" + $(TYPEDSIGNATURES) + +Set the `input` metadata of variable `x` to `v`. +""" +setinput(x, v::Bool) = setmetadata(x, VariableInput, v) +""" + $(TYPEDSIGNATURES) + +Set the `output` metadata of variable `x` to `v`. +""" +setoutput(x, v::Bool) = setmetadata(x, VariableOutput, v) +setio(x, i::Bool, o::Bool) = setoutput(setinput(x, i), o) + +""" + $(TYPEDSIGNATURES) + +Check if variable `x` is marked as an input. +""" +isinput(x) = isvarkind(VariableInput, x) +""" + $(TYPEDSIGNATURES) + +Check if variable `x` is marked as an output. +""" +isoutput(x) = isvarkind(VariableOutput, x) + +# Before the solvability check, we already have handled IO variables, so +# irreducibility is independent from IO. +""" + $(TYPEDSIGNATURES) + +Check if `x` is marked as irreducible. This prevents it from being eliminated as an +observed variable in `mtkcompile`. +""" +isirreducible(x) = isvarkind(VariableIrreducible, x) +setirreducible(x, v::Bool) = setmetadata(x, VariableIrreducible, v) +state_priority(x::Union{Num, Symbolics.Arr}) = state_priority(unwrap(x)) +""" + $(TYPEDSIGNATURES) + +Return the `state_priority` metadata of variable `x`. This influences its priority to be +chosen as a state in `mtkcompile`. +""" +state_priority(x) = convert(Float64, getmetadata(x, VariableStatePriority, 0.0))::Float64 + +normalize_to_differential(x) = x + +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 - return nothing - else - varmap = Dict() + x = normalize_to_differential(op)(arguments(x)...) end + Symbolics.diff2term(x) + else + x end +end - T = typeof(varmap) - # We respect the input type - container_type = T <: Dict ? Array : T +## Bounds ====================================================================== +struct VariableBounds end +Symbolics.option_to_metadata_type(::Val{:bounds}) = VariableBounds - 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) - else # plain array-like initialization - vals = varmap +""" + 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 + +function setbounds(x::Num, bounds) + (lb, ub) = bounds + setmetadata(x, VariableBounds, (lb, ub)) +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 + +setdisturbance(x, v) = setmetadata(x, VariableDisturbance, v) + +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 - if isempty(vals) - return nothing - elseif container_type <: Tuple - (vals...,) +## 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 + +""" + $(TYPEDSIGNATURES) + +Check if variable `x` has a non-empty attached description. +""" +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 brownians(xs...) + all( + x -> x isa Symbol || Meta.isexpr(x, :call) && x.args[1] == :$ || Meta.isexpr(x, :$), + xs) || + error("@brownians 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 - SymbolicUtils.Code.create_array(container_type, eltype(vals), Val{1}(), Val(length(vals)), vals...) + return getguess(x) 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) +## 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) + +################### +### Evaluate at ### +################### +struct EvalAt <: Symbolics.Operator + t::Union{Symbolic, Number} +end + +function (A::EvalAt)(x::Symbolic) + if symbolic_type(x) == NotSymbolic() || !iscall(x) + if x isa Symbolics.CallWithMetadata + return x(A.t) + else + return x + 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] + + if iscall(x) && operation(x) == getindex + arr = arguments(x)[1] + term(getindex, A(arr), arguments(x)[2:end]...) + elseif operation(x) isa Differential + x = default_toterm(x) + A(x) + else + length(arguments(x)) !== 1 && + error("Variable $x has too many arguments. EvalAt can only be applied to one-argument variables.") + (symbolic_type(only(arguments(x))) !== ScalarSymbolic()) && return x + return operation(x)(A.t) end - out end -@noinline throw_missingvars(vars) = throw(ArgumentError("$vars are missing from the variable map.")) +function (A::EvalAt)(x::Union{Num, Symbolics.Arr}) + wrap(A(unwrap(x))) +end +SymbolicUtils.isbinop(::EvalAt) = false + +Base.nameof(::EvalAt) = :EvalAt +Base.show(io::IO, A::EvalAt) = print(io, "EvalAt(", A.t, ")") +Base.:(==)(A1::EvalAt, A2::EvalAt) = isequal(A1.t, A2.t) +Base.hash(A::EvalAt, u::UInt) = hash(A.t, u) diff --git a/test/abstractsystem.jl b/test/abstractsystem.jl new file mode 100644 index 0000000000..09b21ea290 --- /dev/null +++ b/test/abstractsystem.jl @@ -0,0 +1,28 @@ +using ModelingToolkit +using Test +MT = ModelingToolkit + +@independent_variables t +@variables x +struct MyNLS <: MT.AbstractSystem + name::Any + systems::Any +end +tmp = independent_variables(MyNLS("sys", [])) +@test tmp == [] + +struct MyTDS <: MT.AbstractSystem + iv::Any + name::Any + systems::Any +end +iv = independent_variables(MyTDS(t, "sys", [])) +@test all(isequal.(iv, [t])) + +struct MyMVS <: MT.AbstractSystem + ivs::Any + name::Any + systems::Any +end +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..c54fb4c4ca --- /dev/null +++ b/test/accessor_functions.jl @@ -0,0 +1,164 @@ +### 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 ~ Pre(Y) + 2.0]] + devs = [(t == 2.0) => [Y ~ Pre(Y) + 2.0]] + @named sys_bot = System( + eqs_bot, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid2 = System( + eqs_mid2, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid1 = System( + eqs_mid1, t; systems = [sys_bot], continuous_events = cevs, discrete_events = devs) + @named sys_top = System(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 = mtkcompile(sys_bot) + sys_mid2_ss = mtkcompile(sys_mid2) + sys_mid1_ss = mtkcompile(sys_mid1) + sys_top_ss = mtkcompile(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 `mtkcompile` 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 single 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. + @test all_sets_equal( + continuous_events_toplevel.([sys_bot, sys_bot_comp, sys_bot_ss])...) + @test all_sets_equal( + discrete_events_toplevel.( + [sys_mid1, sys_mid1_comp, sys_mid1_ss])...) + @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..008be3a615 --- /dev/null +++ b/test/analysis_points.jl @@ -0,0 +1,686 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary.Blocks, ControlSystemsBase +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEq, LinearAlgebra +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D, AnalysisPoint, AbstractSystem +import ModelingToolkit as MTK +import ControlSystemsBase as CS +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 = System(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 = System(eqs, t, systems = [P, C], name = :hej) + sys_normal2 = @test_nowarn expand_connections(sys_normal) + + @test issetequal(equations(sys_ap2), equations(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 = System(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 = System(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 = System(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = System(Equation[], t; systems = [sys]) +nonamespace_sys = toggle_namespacing(nested_sys, false) + +@testset "simplifies and solves" begin + ssys = mtkcompile(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 = System(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = System(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 + +@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 System(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ], + name) + end + System(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 = System(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 = mtkcompile(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 = System(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 = System(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the mtkcompile works correctly + ssys = mtkcompile(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 = System(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 = System(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the mtkcompile works correctly + ssys = mtkcompile(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 = System(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 = System(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the mtkcompile works correctly + ssys = mtkcompile(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 = System( + [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, ssys = linearize(sys_inner, :u, :y, loop_openings = [:u]) + @test isequal(defaults(ssys)[ssys.d_u], ssys.feedback.output.u) + @test P_broken.A[] == -1 + P_broken, ssys = linearize(sys_inner, :u, :y, loop_openings = [:y]) + @test isequal(defaults(ssys)[ssys.d_y], ssys.P_inner.output.u) + @test P_broken.A[] == -1 + + Sinner = sminreal(ss(get_sensitivity(sys_inner, :u)[1]...)) + + @named sys_inner = System( + [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 = System( + [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 = System(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 = System(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 = System(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 = System(eqs_normal, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2_normal = [ + connect(step.output, normal_inner.back.input1) + ] + @named sys_normal = System(eqs2_normal, t; systems = [normal_inner, step]) +end + +sys_normal = normal_test_system() + +prob = ODEProblem(mtkcompile(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 = System(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 = System(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(mtkcompile(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 = System(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 = System(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(mtkcompile(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 = System(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 = System(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(mtkcompile(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 = System( + eqs, t; systems = [torque, inertia1, inertia2, spring, damper, u]) + end + System(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 = System(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/basic_transformations.jl b/test/basic_transformations.jl index 98e75b688d..bafb5cf9e2 100644 --- a/test/basic_transformations.jl +++ b/test/basic_transformations.jl @@ -1,33 +1,330 @@ -using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit, OrdinaryDiffEq, DataInterpolations, DynamicQuantities, Test +using ModelingToolkitStandardLibrary.Blocks: RealInput, RealOutput -@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 = System(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; p], tspan) + 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; p], tspan, 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 = System(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 = System(eqs, t; initialization_eqs, name = :M) + M2 = change_independent_variable(M1, s) -u0 = [x => 1.0, - y => 1.0, - trJ => 1.0] + M1 = mtkcompile(M1; allow_symbolic = true) + M2 = mtkcompile(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...) = System([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 = System(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 = mtkcompile(M1) + M2 = mtkcompile(M2; allow_symbolic = true) + M3 = mtkcompile(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 = System([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 = System([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 = mtkcompile(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; p], (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 (free fall with 2nd order horizontal equation)" begin + @variables x(t) y(t) + @parameters g = 9.81 # gravitational acceleration + Mt = System([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 = mtkcompile(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 = System(eqs, t; name = :M) + M2 = change_independent_variable(M1, x; add_old_diff = true) + @test_nowarn mtkcompile(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 = System(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 = mtkcompile(M2; allow_symbolic = true) + prob = ODEProblem(M2s, [M2s.y => 0.0, fc => _f, f => _f], (1.0, 4.0)) + 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 = System([D(x) ~ 1, v ~ x], t; name = :M) + Ms = mtkcompile(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 = System([D(x(t)) ~ x(t - 1)], t; name = :M) + @test_throws "DDE" change_independent_variable(M, x(t)) +end + +@testset "Change independent variable w/ units (free fall with 2nd order horizontal equation)" begin + @independent_variables t_units [unit = u"s"] + D_units = Differential(t_units) + @variables x(t_units) [unit = u"m"] y(t_units) [unit = u"m"] + @parameters g=9.81 [unit = u"m * s^-2"] # gravitational acceleration + Mt = System([D_units(D_units(y)) ~ -g, D_units(D_units(x)) ~ 0], t_units; 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 = mtkcompile(Mx; allow_symbolic = true) + Dx = Differential(Mx.x) + u0 = [Mx.y => 0.0, Dx(Mx.y) => 1.0, Mx.t_units => 0.0, Mx.xˍt_units => 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) + # compare to analytical solution (x(t) = v*t, y(t) = v*t - g*t^2/2) + @test all(isapprox.(sol[Mx.y], sol[Mx.x - g * (Mx.t_units) ^ 2 / 2]; atol = 1e-10)) +end + +@testset "Change independent variable, no equations" begin + # make this "look" like the standard library RealInput + @mtkmodel Input begin + @variables begin + u(t) + end + end + @named input_sys = Input() + input_sys = complete(input_sys) + # test no failures + @test change_independent_variable(input_sys, input_sys.u) isa System + + @mtkmodel NestedInput begin + @components begin + in = Input() + end + @variables begin + x(t) + end + @equations begin + D(x) ~ in.u + end + end + @named nested_input_sys = NestedInput() + nested_input_sys = complete(nested_input_sys; flatten = false) + @test change_independent_variable(nested_input_sys, nested_input_sys.x) isa System +end + +@testset "Change of variables, connections" begin + @mtkmodel ConnectSys begin + @components begin + in = RealInput() + out = RealOutput() + end + @variables begin + x(t) + y(t) + end + @equations begin + connect(in, out) + in.u ~ x + D(x) ~ -out.u + D(y) ~ 1 + end + end + @named sys = ConnectSys() + sys = complete(sys; flatten = false) + new_sys = change_independent_variable(sys, sys.y; add_old_diff = true) + ss = mtkcompile(new_sys; allow_symbolic = true) + prob = ODEProblem(ss, [ss.t => 0.0, ss.x => 1.0], (0.0, 1.0)) + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test all(isapprox.(sol[ss.t], sol[ss.y]; atol = 1e-10)) + @test all(sol[ss.x][2:end] .< sol[ss.x][1]) +end + +@testset "Change independent variable with array variables" begin + @variables x(t) y(t) z(t)[1:2] + eqs = [ + D(x) ~ 2, + z ~ ModelingToolkit.scalarize.([sin(y), cos(y)]), + D(y) ~ z[1]^2 + z[2]^2 + ] + @named sys = System(eqs, t) + sys = complete(sys) + new_sys = change_independent_variable(sys, sys.x; add_old_diff = true) + ss_new_sys = mtkcompile(new_sys; allow_symbolic = true) + u0 = [new_sys.y => 0.5, new_sys.t => 0.0] + prob = ODEProblem(ss_new_sys, u0, (0.0, 0.5)) + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test sol[new_sys.y][end] ≈ 0.75 +end + +@testset "`add_accumulations`" begin + @parameters a + @variables x(t) y(t) z(t) + @named sys = System([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 issetequal(equations(asys), 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 issetequal(equations(asys), eqs) +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/bvproblem.jl b/test/bvproblem.jl new file mode 100644 index 0000000000..d86ba251c7 --- /dev/null +++ b/test/bvproblem.jl @@ -0,0 +1,316 @@ +### TODO: update when BoundaryValueDiffEqAscher is updated to use the normal boundary condition conventions +using OrdinaryDiffEq +using BoundaryValueDiffEqMIRK, BoundaryValueDiffEqAscher +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) + + @mtkcompile lotkavolterra = System(eqs, t) + op = ODEProblem(lotkavolterra, [u0map; parammap], tspan) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lotkavolterra, [u0map; parammap], tspan) + + for solver in solvers + sol = solve(bvp, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol[[x, y], 1] == [1.0, 2.0] + end + + # Test out of place + bvp2 = SciMLBase.BVProblem{false, SciMLBase.AutoSpecialize}( + lotkavolterra, [u0map; parammap], tspan) + + for solver in solvers + sol = solve(bvp2, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol[[x, y], 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(θ)] + + @mtkcompile pend = System(eqs, t) + + u0map = [θ => π / 2, θ_t => π / 2] + parammap = [:L => 1.0, :g => 9.81] + tspan = (0.0, 6.0) + + op = ODEProblem(pend, [u0map; parammap], tspan) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + pend, [u0map; parammap], tspan) + 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; parammap], tspan) + + 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 + +################################################################## +### System 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) + @mtkcompile lksys = System(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] + @mtkcompile lksys = System(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 = solve(bvpi1, MIRK4(), dt = 0.01) + sol2 = solve(bvpi2, MIRK4(), dt = 0.01) + sol3 = solve(bvpi3, MIRK4(), dt = 0.01) + sol4 = solve(bvpi4, MIRK4(), dt = 0.01) + @test sol1.t ≈ sol2.t ≈ sol3.t ≈ sol4.t + @test sol1.u ≈ sol2.u ≈ sol3[[x(t), y(t)]] ≈ sol4[[x(t), y(t)]] + # @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 = 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 System 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] + @mtkcompile lksys = System(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] + @mtkcompile lksys = System(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] + @mtkcompile lksys = System(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] +# @mtkcompile pend = System(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] +# @mtkcompile pend = System(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] +# @mtkcompile pend = System(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] +# @mtkcompile pend = System(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, sub) = (u[1] + 3)^2 + u[2] + sum(sub; init = 0) + @mtkcompile lksys = System(eqs, t; costs, consolidate) + + @test_throws ModelingToolkit.SystemCompatibilityError ODEProblem( + lksys, [u0map; parammap], tspan) + prob = ODEProblem(lksys, [u0map; parammap], tspan; check_compatibility = false) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost( + lksys; expression = Val{false}, wrap_gfw = Val{true}) + _t = tspan[2] + @test costfn(sol, prob.p, _t) ≈ (sol(0.6; idxs = x(t)) + 3)^2 + sol(0.3; idxs = x(t))^2 + + ### With a parameter + @parameters t_c + costs = [y(t_c) + x(0.0), x(0.4)^2] + consolidate(u, sub) = log(u[1]) - u[2] + sum(sub; init = 0) + @mtkcompile lksys = System(eqs, t; costs, consolidate) + @test t_c ∈ Set(parameters(lksys)) + push!(parammap, t_c => 0.56) + prob = ODEProblem(lksys, [u0map; parammap], tspan; check_compatibility = false) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost( + lksys; expression = Val{false}, wrap_gfw = Val{true}) + @test costfn(sol, prob.p, _t) ≈ + log(sol(0.56; idxs = y(t)) + sol(0.0; idxs = x(t))) - sol(0.4; idxs = x(t))^2 +end diff --git a/test/causal_variables_connection.jl b/test/causal_variables_connection.jl new file mode 100644 index 0000000000..eb922879e1 --- /dev/null +++ b/test/causal_variables_connection.jl @@ -0,0 +1,123 @@ +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 ArgumentError connect(x, y) +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 = System(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 = System(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 = System(eqs, t, systems = [P, C], name = :hej) + @named nested_sys = System(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 + +@testset "Outside input to inside input connection" begin + @mtkmodel Inner begin + @variables begin + x(t), [input = true] + y(t), [output = true] + end + @equations begin + y ~ x + end + end + @mtkmodel Outer begin + @variables begin + u(t), [input = true] + v(t), [output = true] + end + @components begin + inner = Inner() + end + @equations begin + connect(u, inner.x) + connect(inner.y, v) + end + end + @named sys = Outer() + ss = toggle_namespacing(sys, false) + eqs = equations(expand_connections(sys)) + @test issetequal(eqs, [ss.u ~ ss.inner.x + ss.inner.y ~ ss.inner.x + ss.inner.y ~ ss.v]) +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/changeofvariables.jl b/test/changeofvariables.jl new file mode 100644 index 0000000000..1dccfaec28 --- /dev/null +++ b/test/changeofvariables.jl @@ -0,0 +1,148 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq +using Test, LinearAlgebra + +# Change of variables: z = log(x) +# (this implies that x = exp(z) is automatically non-negative) +@independent_variables t +@variables z(t)[1:2, 1:2] +D = Differential(t) +eqs = [D(D(z)) ~ ones(2, 2)] +@mtkcompile sys = System(eqs, t) +@test_nowarn ODEProblem(sys, [z => zeros(2, 2), D(z) => ones(2, 2)], (0.0, 10.0)) + +@parameters α +@variables x(t) +D = Differential(t) +eqs = [D(x) ~ α*x] + +tspan = (0.0, 1.0) +def = [x => 1.0, α => -0.5] + +@mtkcompile sys = System(eqs, t; defaults = def) +prob = ODEProblem(sys, [], tspan) +sol = solve(prob, Tsit5()) + +@variables z(t) +forward_subs = [log(x) => z] +backward_subs = [x => exp(z)] +new_sys = change_of_variables(sys, t, forward_subs, backward_subs) +@test equations(new_sys)[1] == (D(z) ~ α) + +new_prob = ODEProblem(new_sys, [], tspan) +new_sol = solve(new_prob, Tsit5()) + +@test isapprox(new_sol[x][end], sol[x][end], atol = 1e-4) + +# Riccati equation +@parameters α +@variables x(t) +D = Differential(t) +eqs = [D(x) ~ t^2 + α - x^2] +def = [x=>1.0, α => 1.0] +@mtkcompile sys = System(eqs, t; defaults = def) + +@variables z(t) +forward_subs = [t + α/(x+t) => z] +backward_subs = [x => α/(z-t) - t] + +new_sys = change_of_variables( + sys, t, forward_subs, backward_subs; simplify = true, t0 = 0.0) +# output should be equivalent to +# t^2 + α - z^2 + 2 (but this simplification is not found automatically) + +tspan = (0.0, 1.0) +prob = ODEProblem(sys, [], tspan) +new_prob = ODEProblem(new_sys, [], tspan) + +sol = solve(prob, Tsit5()) +new_sol = solve(new_prob, Tsit5()) + +@test isapprox(sol[x][end], new_sol[x][end], rtol = 1e-4) + +# Linear transformation to diagonal system +@independent_variables t +@variables x(t)[1:3] +x = reshape(x, 3, 1) +D = Differential(t) +A = [0.0 -1.0 0.0; -0.5 0.5 0.0; 0.0 0.0 -1.0] +right = A*x +eqs = vec(D.(x) .~ right) + +tspan = (0.0, 10.0) +u0 = [x[1] => 1.0, x[2] => 2.0, x[3] => -1.0] + +@mtkcompile sys = System(eqs, t; defaults = u0) +prob = ODEProblem(sys, [], tspan) +sol = solve(prob, Tsit5()) + +T = eigen(A).vectors +T_inv = inv(T) + +@variables z(t)[1:3] +z = reshape(z, 3, 1) +forward_subs = vec(T_inv*x .=> z) +backward_subs = vec(x .=> T*z) + +new_sys = change_of_variables(sys, t, forward_subs, backward_subs; simplify = true) + +new_prob = ODEProblem(new_sys, [], tspan) +new_sol = solve(new_prob, Tsit5()) + +# test RHS +new_rhs = [eq.rhs for eq in equations(new_sys)] +new_A = Symbolics.value.(Symbolics.jacobian(new_rhs, z)) +A = diagm(eigen(A).values) +A = sortslices(A, dims = 1) +new_A = sortslices(new_A, dims = 1) +@test isapprox(A, new_A, rtol = 1e-10) +@test isapprox(new_sol[x[1], end], sol[x[1], end], rtol = 1e-4) + +# Change of variables for sde +noise_eqs = ModelingToolkit.get_noise_eqs +value = ModelingToolkit.value + +@independent_variables t +@brownians B +@parameters μ σ +@variables x(t) y(t) +D = Differential(t) +eqs = [D(x) ~ μ*x + σ*x*B] + +def = [x=>0.0, μ => 2.0, σ=>1.0] +@mtkcompile sys = System(eqs, t; defaults = def) +forward_subs = [log(x) => y] +backward_subs = [x => exp(y)] +new_sys = change_of_variables(sys, t, forward_subs, backward_subs) +@test equations(new_sys)[1] == (D(y) ~ μ - 1/2*σ^2) +@test noise_eqs(new_sys)[1] === value(σ) + +#Multiple Brownian and equations +@independent_variables t +@brownians Bx By +@parameters μ σ α +@variables x(t) y(t) z(t) w(t) u(t) v(t) +D = Differential(t) +eqs = [D(x) ~ μ*x + σ*x*Bx, D(y) ~ α*By, D(u) ~ μ*u + σ*u*Bx + α*u*By] +def = [x=>0.0, y => 0.0, u=>0.0, μ => 2.0, σ=>1.0, α=>3.0] +forward_subs = [log(x) => z, y^2 => w, log(u) => v] +backward_subs = [x => exp(z), y => w^0.5, u => exp(v)] + +@mtkcompile sys = System(eqs, t; defaults = def) +new_sys = change_of_variables(sys, t, forward_subs, backward_subs) +@test equations(new_sys)[1] == (D(z) ~ μ - 1/2*σ^2) +@test equations(new_sys)[2] == (D(w) ~ α^2) +@test equations(new_sys)[3] == (D(v) ~ μ - 1/2*(α^2 + σ^2)) +@test noise_eqs(new_sys)[1, 1] === value(σ) +@test noise_eqs(new_sys)[1, 2] === value(0) +@test noise_eqs(new_sys)[2, 1] === value(0) +@test noise_eqs(new_sys)[2, 2] === value(substitute(2*α*y, backward_subs[2])) +@test noise_eqs(new_sys)[3, 1] === value(σ) +@test noise_eqs(new_sys)[3, 2] === value(α) + +# Test for Brownian instead of noise +@named sys = System(eqs, t; defaults = def) +new_sys = change_of_variables(sys, t, forward_subs, backward_subs; simplify = false) +@test simplify(equations(new_sys)[1]) == simplify((D(z) ~ μ - 1/2*σ^2 + σ*Bx)) +@test simplify(equations(new_sys)[2]) == simplify((D(w) ~ α^2 + 2*α*w^0.5*By)) +@test simplify(equations(new_sys)[3]) == + simplify((D(v) ~ μ - 1/2*(α^2 + σ^2) + σ*Bx + α*By)) diff --git a/test/clock.jl b/test/clock.jl new file mode 100644 index 0000000000..7afd7572fb --- /dev/null +++ b/test/clock.jl @@ -0,0 +1,543 @@ +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 = System(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._mtkcompile!( + deepcopy(tss[continuous_id]), inputs = inputs[continuous_id], outputs = []) +@test equations(sss) == [D(x) ~ u - x] +sss = ModelingToolkit._mtkcompile!( + deepcopy(tss[1]), inputs = inputs[1], outputs = []) +@test isempty(equations(sss)) +d = Clock(dt) +k = ShiftIndex(d) +@test issetequal(observed(sss), + [yd ~ Sample(dt)(y); r ~ 1.0; + ud ~ kp * (r - yd)]) + +canonical_eqs = map(eqs) do eq + if iscall(eq.lhs) && operation(eq.lhs) isa Differential + return eq + else + return 0 ~ eq.rhs - eq.lhs + end +end +eqs_idxs = findfirst.(isequal.(canonical_eqs), (equations(ci.ts),)) +d = Clock(dt) +# Note that TearingState reorders the equations +@test eqmap[eqs_idxs[1]] == d +@test eqmap[eqs_idxs[2]] == d +@test eqmap[eqs_idxs[3]] == d +@test eqmap[eqs_idxs[4]] == ContinuousClock() +@test eqmap[eqs_idxs[5]] == ContinuousClock() +@test eqmap[eqs_idxs[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 = System(eqs, t) +@test_throws ModelingToolkit.HybridSystemNotSupportedException ss=mtkcompile(sys); + +@test_skip begin + Tf = 1.0 + prob = ODEProblem( + ss, [x => 0.1, kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0], (0.0, Tf)) + # 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, kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0], (0.0, Tf)) # recreate problem to empty saved values + sol = solve(prob, Tsit5(), kwargshandle = KeywordArgSilent) + + ss_nosplit = mtkcompile(sys; split = false) + prob_nosplit = ODEProblem( + ss_nosplit, [x => 0.1, kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0], (0.0, Tf)) + 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, kp => 1.0; ud(k - 1) => 2.1; ud(k - 2) => 2.0], (0.0, Tf)) # 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 = System(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] + System(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] + System(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)] + System(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 = System(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 = System(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 = mtkcompile(cl) + ss_nosplit = mtkcompile(cl; split = false) + + if VERSION >= v"1.7" + prob = ODEProblem(ss, [x => 0.0, kp => 1.0], (0.0, 1.0)) + prob_nosplit = ODEProblem(ss_nosplit, [x => 0.0, kp => 1.0], (0.0, 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 = mtkcompile(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 + + @mtkcompile 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)] + @mtkcompile sys = System(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, [x => 2.0], (0.0, 10.0)) + prob = ODEProblem(sys, [x(k - 1) => 2.0], (0.0, 10.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..5fb1edb3c2 --- /dev/null +++ b/test/code_generation.jl @@ -0,0 +1,144 @@ +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(System(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(System(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)(..) + @mtkcompile sys = System(D(x) ~ p[0] * x + p[1] * t + p[2] + f(p), t) + prob = ODEProblem(sys, [x => 1.0, p => [1.0, 2.0, 3.0], f => sum], (0.0, 1.0)) + @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 = System( + [D(x[0]) ~ p[1] * x[0] + x[2], D(x[1]) ~ p[2] * f(x) + x[2]], t) + sys = mtkcompile(sys, inputs = [x[2]], outputs = []) + @test is_parameter(sys, x[2]) + prob = ODEProblem( + sys, [x[0] => 1.0, x[1] => 1.0, x[2] => 2.0, p => ones(2), f => sum], + (0.0, 1.0)) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + 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 + @mtkcompile sys = System([D(x) ~ y[1] + y[2], y ~ foo(x)], t) + @test length(equations(sys)) == 1 + @test length(ModelingToolkit.observed(sys)) == 3 + prob = ODEProblem(sys, [x => 1.0, foo => _tmp_fn2], (0.0, 1.0)) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 1 + + @testset "CSE in equations(sys)" begin + val[] = 0 + @variables z(t)[1:2] + @mtkcompile sys = System( + [D(y) ~ foo(x), D(x) ~ sum(y), zeros(2) ~ foo(prod(z))], t) + @test length(equations(sys)) == 5 + @test length(ModelingToolkit.observed(sys)) == 0 + prob = ODEProblem( + sys, [y => ones(2), z => 2ones(2), x => 3.0, foo => _tmp_fn2], (0.0, 1.0)) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 2 + end +end + +@testset "Do not codegen redundant expressions" begin + @variables v1(t) = 1 + @variables v2(t) [guess = 0] + + mutable struct Data + count::Int + end + function update!(d::Data, t) + d.count += 1 # Count the number of times the data gets updated. + end + function (d::Data)(t) + update!(d, t) + rand(1:10) + end + + @parameters (d1::Data)(..) = Data(0) + @parameters (d2::Data)(..) = Data(0) + + eqs = [ + D(v1) ~ d1(t), + v2 ~ d2(t) # Some of the data parameters are not actually needed to solve the system. + ] + + @mtkbuild sys = System(eqs, t) + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob, Tsit5()) + + @test sol.ps[d2].count == 0 +end diff --git a/test/common/rc_model.jl b/test/common/rc_model.jl new file mode 100644 index 0000000000..8eec8d048f --- /dev/null +++ b/test/common/rc_model.jl @@ -0,0 +1,26 @@ +import ModelingToolkitStandardLibrary.Electrical as El +import ModelingToolkitStandardLibrary.Blocks as Bl + +@mtkmodel RCModel begin + @parameters begin + R = 1.0 + C = 1.0 + V = 1.0 + end + @components begin + resistor = El.Resistor(R = R) + capacitor = El.Capacitor(C = C) + shape = Bl.Constant(k = V) + source = El.Voltage() + ground = El.Ground() + end + @equations begin + connect(shape.output, source.V) + 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 = RCModel() diff --git a/test/common/serial_inductor.jl b/test/common/serial_inductor.jl new file mode 100644 index 0000000000..1e930cee56 --- /dev/null +++ b/test/common/serial_inductor.jl @@ -0,0 +1,47 @@ +import ModelingToolkitStandardLibrary.Electrical as El +import ModelingToolkitStandardLibrary.Blocks as Bl + +@mtkmodel LLModel begin + @components begin + shape = Bl.Constant(k = 10.0) + source = El.Voltage() + resistor = El.Resistor(R = 1.0) + inductor1 = El.Inductor(L = 1.0e-2) + inductor2 = El.Inductor(L = 2.0e-2) + ground = El.Ground() + end + @equations begin + connect(shape.output, source.V) + connect(source.p, resistor.p) + connect(resistor.n, inductor1.p) + connect(inductor1.n, inductor2.p) + connect(source.n, inductor2.n) + connect(inductor2.n, ground.g) + end +end + +@named ll_model = LLModel() + +@mtkmodel LL2Model begin + @components begin + shape = Bl.Constant(k = 10.0) + source = El.Voltage() + resistor1 = El.Resistor(R = 1.0) + resistor2 = El.Resistor(R = 1.0) + inductor1 = El.Inductor(L = 1.0e-2) + inductor2 = El.Inductor(L = 2.0e-2) + ground = El.Ground() + end + @equations begin + connect(shape.output, source.V) + 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) + end +end + +@named ll2_model = LL2Model() diff --git a/test/complex.jl b/test/complex.jl new file mode 100644 index 0000000000..e30ebb177e --- /dev/null +++ b/test/complex.jl @@ -0,0 +1,44 @@ +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 + +@testset "Complex ODEProblem" begin + using ModelingToolkit: t_nounits as t, D_nounits as D + + vars = @variables x(t) y(t) z(t) + pars = @parameters a b + + eqs = [ + D(x) ~ y - x, + D(y) ~ -x * z + b * abs(z), + D(z) ~ x * y - a + ] + @named modlorenz = System(eqs, t) + sys = mtkcompile(modlorenz) + + ic = ModelingToolkit.get_index_cache(sys) + @test ic.tunable_buffer_size.type == Number + + u0 = ComplexF64[-4.0, 5.0, 0.0] .+ randn(ComplexF64, 3) + p = ComplexF64[5.0, 0.1] + dict = merge(Dict(unknowns(sys) .=> u0), Dict(parameters(sys) .=> p)) + prob = ODEProblem(sys, dict, (0.0, 1.0)) + + using OrdinaryDiffEq + sol = solve(prob, Tsit5(), saveat = 0.1) + + @test sol.u[1] isa Vector{ComplexF64} +end diff --git a/test/components.jl b/test/components.jl index 90df0028e3..8e5747c750 100644 --- a/test/components.jl +++ b/test/components.jl @@ -1,46 +1,337 @@ -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 +using ModelingToolkitStandardLibrary.Electrical +using ModelingToolkitStandardLibrary.Blocks +using LinearAlgebra +using ModelingToolkitStandardLibrary.Thermal +using SymbolicUtils: getmetadata +include("common/rc_model.jl") + +@testset "Basics" begin + @unpack resistor, capacitor, source = rc_model + 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(mtkcompile(rc_model, allow_parameter = false))) == 2 + sys = mtkcompile(rc_model) + @test_throws ModelingToolkit.RepeatedStructuralSimplificationError mtkcompile(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) +end + +@testset "Outer/inner connections" begin + sys = mtkcompile(rc_model) + + prob = ODEProblem(sys, [sys.capacitor.v => 0.0], (0.0, 10.0)) + sol = solve(prob, Rodas4()) + function rc_component(; name, R = 1, C = 1) + local sys + @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 = C) + eqs = [connect(p, resistor.p); + connect(resistor.n, capacitor.p); + connect(capacitor.n, n)] + @named sys = System(eqs, t, [], [R, C]) + compose(sys, [p, n, resistor, capacitor]; name = name) + end + + @named ground = Ground() + @named shape = Constant(k = 1) + @named source = Voltage() + @named rc_comp = rc_component() + eqs = [connect(shape.output, source.V) + connect(source.p, rc_comp.p) + connect(source.n, rc_comp.n) + connect(source.n, ground.g)] + @named sys′ = System(eqs, t) + @named sys_inner_outer = compose(sys′, [ground, shape, source, rc_comp]) + @test_nowarn show(IOBuffer(), MIME"text/plain"(), sys_inner_outer) + expand_connections(sys_inner_outer) + sys_inner_outer = mtkcompile(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[sys.capacitor.v] ≈ sol_inner_outer[rc_comp.capacitor.v] + + prob = ODEProblem(sys, [sys.capacitor.v => 0.0], (0, 10.0)) + sol = solve(prob, Tsit5()) + + @test sol[sys.resistor.p.i] == sol[sys.capacitor.p.i] + @test sol[sys.resistor.n.i] == -sol[sys.capacitor.p.i] + @test sol[sys.capacitor.n.i] == -sol[sys.capacitor.p.i] + @test iszero(sol[sys.ground.g.i]) + @test iszero(sol[sys.ground.g.v]) + @test sol[sys.resistor.v] == sol[sys.source.p.v] - sol[sys.capacitor.p.v] +end +#using Plots +#plot(sol) + +include("common/serial_inductor.jl") +@testset "Serial inductor" begin + sys = mtkcompile(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 = mtkcompile(ll2_model) + @test length(equations(sys2)) == 3 + u0 = [sys.inductor2.i => 0] + prob = ODEProblem(sys, u0, (0, 10.0)) + sol = solve(prob, FBDF()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Compose/extend" begin + @variables x1(t) x2(t) x3(t) x4(t) + @named sys1_inner = System([D(x1) ~ x1], t) + @named sys1_partial = compose(System([D(x2) ~ x2], t; name = :foo), sys1_inner) + @named sys1 = extend(System([D(x3) ~ x3], t; name = :foo), sys1_partial) + @named sys2 = compose(System([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 + System(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(System(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 +end + +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 = System(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 = System(eqs, t) + compose(sys, [ground, resistor, load]; name = name) +end + +@named foo = Circuit() +@test mtkcompile(foo) isa ModelingToolkit.AbstractSystem + +# BLT tests +@testset "BLT ordering" begin + function parallel_rc_model(i; name, shape, source, ground, R, C) + resistor = Resistor(name = Symbol(:resistor, i), R = R, T_dep = true) + capacitor = Capacitor(name = Symbol(:capacitor, i), C = C) + heat_capacitor = HeatCapacitor(name = Symbol(:heat_capacitor, i)) + + rc_eqs = [connect(shape.output, source.V) + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + connect(resistor.heat_port, heat_capacitor.port)] + + compose(System(rc_eqs, t, name = Symbol(name, i)), + [resistor, capacitor, source, ground, shape, heat_capacitor]) + end + V = 2.0 + @named shape = Constant(k = V) + @named source = Voltage() + @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, ground, shape, R = Rs[i], C = Cs[i]) + end + @variables E(t) = 0.0 + eqs = [ + D(E) ~ sum(((i, sys),) -> getproperty(sys, Symbol(:resistor, i)).heat_port.Q_flow, + enumerate(rc_systems)) + ] + @named _big_rc = System(eqs, t, [E], []) + @named big_rc = compose(_big_rc, rc_systems) + ts = TearingState(expand_connections(big_rc)) + # this is block upper triangular, so `istriu` needs a little leeway + @test istriu(but_ordered_incidence(ts)[1], -2) +end + +# Test using constants inside subsystems +@testset "Constants inside subsystems" begin + function FixedResistor(; name, R = 1.0) + @named oneport = OnePort() + @unpack v, i = oneport + @constants R = R + eqs = [ + v ~ i * R + ] + extend(System(eqs, t, [], [R]; name = name), oneport) + end + capacitor = Capacitor(; name = :c1, C = 1.0) + 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 = System(rc_eqs, t) + @named rc_model = compose(_rc_model, + [resistor, capacitor, ground]) + sys = mtkcompile(rc_model) + prob = ODEProblem(sys, [sys.c1.v => 0.0], (0, 10.0)) + sol = solve(prob, Tsit5()) +end + +@testset "docstrings (#1155)" begin + """ + Hey there, Pin1! + """ + @connector function Pin1(; name) + @independent_variables t + sts = @variables v(t)=1.0 i(t)=1.0 + System(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 + System(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 = mtkcompile(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 `mtkcompile(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 = mtkcompile(cbar) + @test isequal(cbar.foo.x, ss.foo.x) +end + +@testset "Issue#3275: Metadata retained on `complete`" begin + @variables x(t) y(t) + @named inner = System(D(x) ~ x, t) + @named outer = System(D(y) ~ y, t; systems = [inner], metadata = [Int => "test"]) + @test getmetadata(outer, Int, nothing) == "test" + sys = complete(outer) + @test getmetadata(sys, Int, nothing) == "test" +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..ce5c7e6e8e --- /dev/null +++ b/test/constants.jl @@ -0,0 +1,49 @@ +using ModelingToolkit, OrdinaryDiffEq, Unitful +using Test +MT = ModelingToolkit +UMT = ModelingToolkit.UnitfulUnitCheck + +@constants a = 1 +@test isconstant(a) +@test !istunable(a) + +@independent_variables t +@variables x(t) w(t) +D = Differential(t) +eqs = [D(x) ~ a] +@named sys = System(eqs, t) +prob = ODEProblem(complete(sys), [0], [0.0, 1.0]) +sol = solve(prob, Tsit5()) + +# Test mtkcompile substitutions & observed values +eqs = [D(x) ~ 1, + w ~ a] +@named sys = System(eqs, t) +# Now eliminate the constants first +simp = mtkcompile(sys) +@test equations(simp) == [D(x) ~ 1.0] + +#Constant with units +@constants β=1 [unit = u"m/s"] +UMT.get_unit(β) +@test MT.isconstant(β) +@test !MT.istunable(β) +@independent_variables t [unit = u"s"] +@variables x(t) [unit = u"m"] +D = Differential(t) +eqs = [D(x) ~ β] +@named sys = System(eqs, t) +simp = mtkcompile(sys) + +@testset "Issue#3044" begin + @constants h + @parameters τ = 0.5 * h + @variables x(MT.t_nounits) = h + eqs = [MT.D_nounits(x) ~ (h - x) / τ] + + @mtkcompile fol_model = System(eqs, MT.t_nounits) + + prob = ODEProblem(fol_model, [h => 1], (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..17b14b39e4 --- /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 = System(eqs, t) + +u0 = [u1 => 1.0, + u2 => 1.0] + +tspan = (0.0, 10.0) + +du0 = [D(u1) => 0.5, D(u2) => -2.0] + +p = [p1 => 1.5, + p2 => 3.0] + +prob = DAEProblem(complete(sys), [du0; u0; p], tspan, 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..4af5f1ad31 --- /dev/null +++ b/test/dde.jl @@ -0,0 +1,202 @@ +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)] +@mtkcompile 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[[x₀, x₁, x₂(t)]][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[[x₀, x₁, x₂(t)]][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 +@brownians η +τ = 1.0 +eqs = [D(x(t)) ~ a * x(t) + b * x(t - τ) + c + (α * x(t) + γ) * η, delx ~ x(t - τ)] +@mtkcompile 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_noise_eqs(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) 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 = mtkcompile(coupledOsc) + @test length(equations(sys)) == 4 + @test length(unknowns(sys)) == 4 +end +sys = mtkcompile(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 + + @mtkcompile 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 = mtkcompile(sys) + + prob = DDEProblem(sys, + [], + (0.0, 10.0), + constant_lags = [τ]) + + alg = MethodOfSteps(Vern7()) + @test_nowarn solve(prob, alg) + + @brownians r + eqs = [D(x(t)) ~ -w * x(t - τ) + r] + @named sys = System(eqs, t) + sys = mtkcompile(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..0ff7a0fb4d --- /dev/null +++ b/test/debugging.jl @@ -0,0 +1,55 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, SymbolicIndexingInterface +import Logging +using ModelingToolkit: t_nounits as t, D_nounits as D, ASSERTION_LOG_VARIABLE + +@variables x(t) +@brownians a +@named inner_ode = System(D(x) ~ -sqrt(x), t; assertions = [(x > 0) => "ohno"]) +@named inner_sde = System([D(x) ~ -10sqrt(x) + 0.01a], t; assertions = [(x > 0) => "ohno"]) +sys_ode = mtkcompile(inner_ode) +sys_sde = mtkcompile(inner_sde) +SEED = 42 + +@testset "assertions are present in generated `f`" begin + @testset "$(Problem)" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + kwargs = Problem == SDEProblem ? (; seed = SEED) : (;) + @test !is_parameter(sys, ASSERTION_LOG_VARIABLE) + prob = Problem(sys, [x => 0.1], (0.0, 5.0); kwargs...) + 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 "$(Problem)" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + kwargs = Problem == SDEProblem ? (; seed = SEED) : (;) + dsys = debug_system(sys; functions = []) + @test is_parameter(dsys, ASSERTION_LOG_VARIABLE) + prob = Problem(dsys, [x => 0.1], (0.0, 5.0); kwargs...) + 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 "$(Problem)" for (ctor, Problem, inner, alg) in [ + (System, ODEProblem, inner_ode, Tsit5()), + (System, SDEProblem, inner_sde, ImplicitEM())] + kwargs = Problem == SDEProblem ? (; seed = SEED) : (;) + @mtkcompile 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); kwargs...) + 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..1fa166f1b7 100644 --- a/test/dep_graphs.jl +++ b/test/dep_graphs.jl @@ -1,104 +1,172 @@ 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) -# 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]] -deps = equation_dependencies(js) -@test all(i -> isequal(Set(eq_sdeps[i]),Set(deps[i])), 1:length(rxs)) -depsbg = asgraph(js) -@test depsbg.fadjlist == eq_sidepsf -@test depsbg.badjlist == eq_sidepsb +@testset "JumpSystem" begin + @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]) + alleqs = [j₁, j₂, j₃, j₄, j₅, j₆] + @named js = JumpSystem(alleqs, t, [S, I, R], [k1, k2]) + S = value(S) + I = value(I) + R = value(R) + k1 = value(k1) + k2 = value(k2) -# 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)) -@test depsbg2.fadjlist == eq_pidepsf -@test depsbg2.badjlist == eq_pidepsb + test_case_1 = (; + eqs = jumps(js), + # 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 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]], + # 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]], + var_eq_ne = 8, + # 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_eq_ne = 6, + # var to vars that depend on them + var_vardeps = [[1, 2, 3], [1, 2, 3], [3]], + var_var_ne = 3 + ) + # testing when ignoring VariableRateJumps + test_case_2 = (; + # filter out vrjs in making graphs + eqs = filter(x -> !(x isa VariableRateJump), jumps(js)), + # 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]], + # 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]], + # var to eqs that modify them + s_eqdepsf = [[1, 2, 3], [3], [4, 5]], + s_eqdepsb = [[1], [1], [1, 2], [3], [3]], + var_eq_ne = 6, + # eq to eqs that depend on them + eq_eqdeps = [[2, 3, 4], [2, 3, 4], [2, 3, 4, 5], [4], [4], [2, 3, 4]], + eq_eq_ne = 5, + # var to vars that depend on them + var_vardeps = [[1, 2, 3], [1, 2, 3], [3]], + var_var_ne = 3 + ) -# 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) + @testset "Case $i" for (i, test_case) in enumerate([test_case_1, test_case_2]) + (; # filter out vrjs in making graphs + eqs, # eq to vars they depend on + eq_sdeps, + eq_sidepsf, + eq_sidepsb, # eq to params they depend on + eq_pdeps, + eq_pidepsf, + eq_pidepsb, # var to eqs that modify them + s_eqdepsf, + s_eqdepsb, + var_eq_ne, # eq to eqs that depend on them + eq_eqdeps, + eq_eq_ne, # var to vars that depend on them + var_vardeps, + var_var_ne + ) = test_case + deps = equation_dependencies(js; eqs) + @test length(deps) == length(eq_sdeps) + @test all([issetequal(a, b) for (a, b) in zip(eq_sdeps, deps)]) + # @test all(i -> ) + # @test all(i -> isequal(Set(eq_sdeps[i]), Set(deps[i])), 1:length(alleqs)) + depsbg = asgraph(js; eqs) + @test depsbg.fadjlist == eq_sidepsf + @test depsbg.badjlist == eq_sidepsb -# 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]] -dg = SimpleDiGraph(6) -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 + deps = equation_dependencies(js; variables = parameters(js), eqs) + @test length(deps) == length(eq_pdeps) + @test all([issetequal(a, b) for (a, b) in zip(eq_pdeps, deps)]) + depsbg2 = asgraph(js; variables = parameters(js), eqs) + @test depsbg2.fadjlist == eq_pidepsf + @test depsbg2.badjlist == eq_pidepsb -# 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) + bg = BipartiteGraph(var_eq_ne, s_eqdepsf, s_eqdepsb) + deps2 = variable_dependencies(js; eqs) + @test isequal(bg, deps2) + + dg = SimpleDiGraph(eq_eq_ne) + 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 + + dg = SimpleDiGraph(var_var_ne) + 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 end -dg4 = varvar_dependencies(depsbg,deps2) -@test dg == dg4 ##################################### # testing for ODE/SDEs ##################################### -os = convert(ODESystem, rs) -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)) -sdes = convert(SDESystem, rs) -deps = equation_dependencies(sdes) -@test all(i -> isequal(Set(eq_sdeps[i]),Set(deps[i])), 1:length(deps)) +@testset "ODEs, SDEs" begin + @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)] + @named os = System(eqs, t, [S, I, R], [k1, k2]) + deps = equation_dependencies(os) + 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)) -deps = variable_dependencies(os) -s_eqdeps = [[1],[2],[3]] -@test deps.fadjlist == s_eqdeps + noiseeqs = [S, I, R] + @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)) + + deps = variable_dependencies(os) + s_eqdeps = [[1], [2], [3]] + @test deps.fadjlist == s_eqdeps +end ##################################### # testing for nonlin sys ##################################### -@variables x y z -@parameters σ ρ β +@testset "Nonlinear" begin + @variables x y z + @parameters σ ρ β -eqs = [0 ~ σ*(y-x), - 0 ~ ρ-y, - 0 ~ y - β*z] -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)) + eqs = [0 ~ σ * (y - x), + 0 ~ ρ - y, + 0 ~ y - β * z] + @named ns = System(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)) +end diff --git a/test/direct.jl b/test/direct.jl index 77562946e4..ce2a3f5785 100644 --- a/test/direct.jl +++ b/test/direct.jl @@ -1,253 +1,299 @@ -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..ccd5b2c0a9 --- /dev/null +++ b/test/discrete_system.jl @@ -0,0 +1,294 @@ +# 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, SymbolicIndexingInterface, Test +using ModelingToolkit: t_nounits as t + +# Make sure positive shifts error +@variables x(t) +k = ShiftIndex(t) +@test_throws ErrorException @mtkcompile sys = System([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 = System(eqs, t, [S, I, R], [c, nsteps, δt, β, γ, h]) +syss = mtkcompile(sys) +@test syss == syss + +df = DiscreteFunction(syss) +# iip +du = zeros(3) +u = ModelingToolkit.varmap_to_vars( + Dict([S(k - 1) => 1, I(k - 1) => 2, R(k - 1) => 3]), unknowns(syss)) +p = MTKParameters(syss, [c, nsteps, δt, β, γ] .=> collect(1:5)) +df.f(du, u, p, 0) +reorderer = getu(syss, [S(k - 1), I(k - 1), R(k - 1)]) +@test reorderer(du) ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] + +# oop +@test reorderer(df.f(u, p, 0)) ≈ + [0.01831563888873422, 0.9816849729159067, 4.999999388195359] + +# 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( + syss, [u0; p], tspan; guesses = [S(k - 1) => 1.0, I(k - 1) => 1.0, R(k - 1) => 1.0]) +@test prob_map.f.sys === syss + +# Solution +using OrdinaryDiffEq +sol_map = solve(prob_map, FunctionMap()); +@test sol_map[S] isa Vector +@test sol_map[S(k - 1)] 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] + +@mtkcompile sys = System( + eqs2, t, [S, I, R, R2], [c, nsteps, δt, β, γ]) +@test ModelingToolkit.defaults(sys) != Dict() + +prob_map2 = DiscreteProblem(sys, [], tspan) +# prob_map2 = DiscreteProblem(sys, [S(k - 1) => S, I(k - 1) => I, R(k - 1) => R], tspan) +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 sol_map2[R2][begin:(end - 1)] == sol_map2[R(k - 1)][(begin + 1):end] == + sol_map2[R][begin:(end - 1)] +# 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 = sol_map2[[S, I, R], 1]; +p = [0.05, 10.0, 0.25, 0.1]; +prob_map = DiscreteProblem(sir_map!, u0, tspan, p); +sol_map2 = solve(prob_map, FunctionMap()); + +@test reduce(hcat, sol_map[[S, I, R]]) ≈ 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 = System(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 = System( + [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 + +# @mtkcompile sys = System(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) u(t) +eqs = [u ~ 1 + x ~ x(k - 1) + u + y ~ x + u] +@mtkcompile de = System(eqs, t) +prob = DiscreteProblem(de, [x(k - 1) => 0.0], (0, 10)) +sol = solve(prob, FunctionMap()) + +@test sol[x] == 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 System(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))] + + System(eqs, t, vars, pars; systems = [y_sys], name = name) +end + +@test_nowarn @mtkcompile sys = System(; buffer = ones(10)) + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + k = ShiftIndex() + @mtkcompile sys = System([x(k) ~ x(k - 1) + 1], t) + prob = @test_nowarn DiscreteProblem(sys, nothing, (0.0, 1.0)) + sol = solve(prob, FunctionMap()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Shifted array variables" begin + @variables x(t)[1:2] y(t)[1:2] + k = ShiftIndex(t) + eqs = [ + x(k) ~ x(k - 1) + x(k - 2), + y[1](k) ~ y[1](k - 1) + y[1](k - 2), + y[2](k) ~ y[2](k - 1) + y[2](k - 2) + ] + @mtkcompile sys = System(eqs, t) + prob = DiscreteProblem(sys, + [x(k - 1) => ones(2), x(k - 2) => zeros(2), y[1](k - 1) => 1.0, + y[1](k - 2) => 0.0, y[2](k - 1) => 1.0, y[2](k - 2) => 0.0], + (0, 4)) + @test all(isone, prob.u0) + sol = solve(prob, FunctionMap()) + @test sol[[x..., y...], end] == 8ones(4) +end + +@testset "Defaults are totermed appropriately" begin + @parameters σ ρ β q + @variables x(t) y(t) z(t) + k = ShiftIndex(t) + p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + + @mtkcompile discsys = System( + [x ~ x(k - 1) * ρ + y(k - 2), y ~ y(k - 1) * σ - z(k - 2), + z ~ z(k - 1) * β + x(k - 2)], + t; defaults = [x => 1.0, y => 1.0, z => 1.0, x(k - 1) => 1.0, + y(k - 1) => 1.0, z(k - 1) => 1.0]) + discprob = DiscreteProblem(discsys, p, (0, 10)) + sol = solve(discprob, FunctionMap()) + @test SciMLBase.successful_retcode(sol) +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..8c3ca4dcfa 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 = System(eqs, t) +@everywhere de = complete(de) + +@everywhere u0 = unknowns(de) .=> [19.0, 20.0, 50.0] +@everywhere params = parameters(de) .=> [16.0, 45.92, 4] + +@everywhere ode_prob = ODEProblem(de, [u0; params], (0.0, 10.0)) + +@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..485796d585 --- /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 + + System(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 + ] + + System(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 + ] + + System(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] + + System(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)] + + System(eqs, t, vars, pars; name, systems) +end + +function HydraulicSystem(; 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 System(eqs, t, vars, pars; systems, name) +end + +@named odesys = HydraulicSystem() +esys = ModelingToolkit.expand_connections(odesys) +@test length(equations(esys)) == length(unknowns(esys)) + +csys = complete(odesys) + +sys = mtkcompile(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..f64ed17de6 --- /dev/null +++ b/test/downstream/Project.toml @@ -0,0 +1,17 @@ +[deps] +ControlSystemsMTK = "687d7614-c7e5-45fc-bfc3-9ee385575c88" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" +OrdinaryDiffEqFIRK = "5960d6e9-dd7a-4743-88e7-cf307b64f125" +OrdinaryDiffEqNonlinearSolve = "127b3ac7-2247-4354-8eb6-78cf4e7c58e8" +OrdinaryDiffEqRosenbrock = "43230ef6-c299-4910-a778-202eb28ce4ce" +OrdinaryDiffEqSDIRK = "2d112036-d095-4a1e-ab9a-08536f3ecdbf" +OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a" +OrdinaryDiffEqVerner = "79d7bb75-1356-48c1-b8c0-6832512096c2" +SimpleDiffEq = "05bca326-078c-5bf0-a5bf-ce7c7982d7fd" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" + +[compat] +ModelingToolkitStandardLibrary = "2.19" diff --git a/test/downstream/inversemodel.jl b/test/downstream/inversemodel.jl new file mode 100644 index 0000000000..2b1e067847 --- /dev/null +++ b/test/downstream/inversemodel.jl @@ -0,0 +1,192 @@ +using ModelingToolkit +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEqRosenbrock +using OrdinaryDiffEqNonlinearSolve +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 = System(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 = mtkcompile(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 = 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, _ = 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 = 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, _ = 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 = get_sensitivity_function(model, output; op = op1) + matrices1, extras1 = linearize(ssys, lin_fun, op = op1) + matrices2, extras2 = linearize(ssys, lin_fun, op = op2) + @test extras1.x != extras2.x + S1f = ss(matrices1...) + S2f = ss(matrices2...) + @test S1f != S2f + + matrices1, ssys = get_sensitivity(model, output; op = op1) + matrices2, ssys = 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..d42e642915 --- /dev/null +++ b/test/downstream/linearization_dd.jl @@ -0,0 +1,65 @@ +## 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 = System(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) +guesses = [link1.fx1 => 0] +@info "named_ss" +G = named_ss(model, lin_inputs, lin_outputs; allow_symbolic = true, op, + allow_input_derivatives = true, zero_dummy_der = true, guesses) +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, guesses = guesses) +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/test_disturbance_model.jl b/test/downstream/test_disturbance_model.jl new file mode 100644 index 0000000000..642ff85f99 --- /dev/null +++ b/test/downstream/test_disturbance_model.jl @@ -0,0 +1,221 @@ +#= +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, OrdinaryDiffEqTsit5, 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 = mtkcompile(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) = System(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 = mtkcompile(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 = mtkcompile(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) = System(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 = mtkcompile(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 = mtkcompile(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, x_sym, +p_sym, +io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, [:u], [:d1, :d2, :dy], split = false) + +f, 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 = ModelingToolkit.get_u0(io_sys, op) +p = ModelingToolkit.get_p(io_sys, op) +x = zeros(5) +u = zeros(1) +d = zeros(3) +@test f[1](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[1](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[1](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[1](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..ea1103db57 --- /dev/null +++ b/test/dq_units.jl @@ -0,0 +1,285 @@ +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 = System(eqs, t) + +@test !MT.validate(D(D(E)) ~ P) +@test !MT.validate(0 ~ P + E * τ) + +# Disabling unit validation/checks selectively +@test_throws MT.ArgumentError System(eqs, t, [E, P, t], [τ], name = :sys) +System(eqs, t, [E, P, t], [τ], name = :sys, checks = MT.CheckUnits) +eqs = [D(E) ~ P - E / τ + 0 ~ P + E * τ] +@test_throws MT.ValidationError System(eqs, t, name = :sys, checks = MT.CheckAll) +@test_throws MT.ValidationError System(eqs, t, name = :sys, checks = true) +System(eqs, t, name = :sys, checks = MT.CheckNone) +System(eqs, t, name = :sys, checks = false) +@test_throws MT.ValidationError System(eqs, t, name = :sys, + checks = MT.CheckComponents | MT.CheckUnits) +@named sys = System(eqs, t, checks = MT.CheckComponents) +@test_throws MT.ValidationError System(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]) + System(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]) + System(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) + System(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 = System(good_eqs, t, [], []) +@named op = OtherPin() +bad_eqs = [connect(p1, op)] +@test !MT.validate(bad_eqs) +@test_throws MT.ValidationError @named sys = System(bad_eqs, t, [], []) +@named op2 = OtherPin() +good_eqs = [connect(op, op2)] +@test MT.validate(good_eqs) +@named sys = System(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] +System(eqs, t, name = :sys) + +# Nonlinear system +@parameters a [unit = u"kg"^-1] +@variables x [unit = u"kg"] +eqs = [ + 0 ~ a * x +] +@named nls = System(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 = System(eqs, t) +sys_simple = mtkcompile(sys) + +eqs = [D(V) ~ r, + V ~ L^3] +@named sys = System(eqs, t) +sys_simple = mtkcompile(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 = System(eqs, [V, L], [t, r]) +sys_simple = mtkcompile(sys) + +eqs = [L ~ v * t, + V ~ L^3] +@named sys = System(eqs, [V, L], [t, r, v]) +sys_simple = mtkcompile(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 + @mtkcompile 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; p], (0.0, 1.0); 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(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 + +@mtkcompile 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..1bf92743ac --- /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 = System(eqs1, t) +@named osys2 = System(eqs2, t) +@named osys3 = System(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..6a552ae063 --- /dev/null +++ b/test/error_handling.jl @@ -0,0 +1,54 @@ +using Test +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +import ModelingToolkit: ExtraVariablesSystemException, ExtraEquationsSystemException + +using ModelingToolkitStandardLibrary.Electrical + +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 + ] + System(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] + System(eqs, t, [], [V, I], 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 = System(rc_eqs, t, systems = [resistor, capacitor, source]) +@test_throws ModelingToolkit.ExtraVariablesSystemException mtkcompile(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 = System(rc_eqs2, t, systems = [resistor, capacitor, source2]) +@test_throws ModelingToolkit.ExtraEquationsSystemException mtkcompile(rc_model2) diff --git a/test/extensions/Project.toml b/test/extensions/Project.toml new file mode 100644 index 0000000000..a5ab9a8d0c --- /dev/null +++ b/test/extensions/Project.toml @@ -0,0 +1,33 @@ +[deps] +BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" +CasADi = "c49709b8-5c63-11e9-2fb2-69db5844192f" +ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +ChainRulesTestUtils = "cdddcdb0-9152-4a09-a978-84456f9df70a" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" +DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" +DiffEqDevTools = "f3b72e0c-5b89-59e1-b016-84e28bfd966d" +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" +OrdinaryDiffEqFIRK = "5960d6e9-dd7a-4743-88e7-cf307b64f125" +OrdinaryDiffEqNonlinearSolve = "127b3ac7-2247-4354-8eb6-78cf4e7c58e8" +OrdinaryDiffEqSDIRK = "2d112036-d095-4a1e-ab9a-08536f3ecdbf" +OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a" +OrdinaryDiffEqVerner = "79d7bb75-1356-48c1-b8c0-6832512096c2" +Pyomo = "0e8e1daf-01b5-4eba-a626-3897743a3816" +SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" +SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" +SimpleDiffEq = "05bca326-078c-5bf0-a5bf-ce7c7982d7fd" +StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" +SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" +Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" +Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" + +[compat] +CasADi = "1.0.6" diff --git a/test/extensions/ad.jl b/test/extensions/ad.jl new file mode 100644 index 0000000000..53210b66a8 --- /dev/null +++ b/test/extensions/ad.jl @@ -0,0 +1,182 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +using Zygote +using SymbolicIndexingInterface +using SciMLStructures +using OrdinaryDiffEqTsit5 +using OrdinaryDiffEqNonlinearSolve +using NonlinearSolve +using SciMLSensitivity +using ForwardDiff +using StableRNGs +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) +@mtkcompile sys = System(eqs, t) +prob = ODEProblem(sys, [u0; ps], tspan) +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 = System([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 = mtkcompile(sys) + + function x_at_0(θ) + prob = ODEProblem(sys, [sys.x => 1.0, sys.ργ0 => θ[1], sys.h => θ[2]], (0.0, 1.0)) + 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 = System( + Equation[], t, [], [a, b, c, d, e, f, g, h], + continuous_events = [ModelingToolkit.SymbolicContinuousCallback( + [a ~ 0] => [c ~ 0], discrete_parameters = c, iv = t)]) +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 = System(eqs, t; initialization_eqs) + sys = mtkcompile(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) + @mtkcompile sys = System(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 + +@testset "`p` provided to `solve` is respected" begin + @mtkmodel Linear begin + @variables begin + x(t) = 1.0, [description = "Prey"] + end + @parameters begin + α = 1.5 + end + @equations begin + D(x) ~ -α * x + end + end + + @mtkcompile linear = Linear() + problem = ODEProblem(linear, [], (0.0, 1.0)) + solution = solve(problem, Tsit5(), saveat = 0.1) + rng = StableRNG(42) + data = (; + t = solution.t, + # [[y, x], :] + measurements = Array(solution) + ) + data.measurements .+= 0.05 * randn(rng, size(data.measurements)) + + p0, repack, _ = SciMLStructures.canonicalize(SciMLStructures.Tunable(), problem.p) + + objective = let repack = repack, problem = problem + (p, data) -> begin + pnew = repack(p) + sol = solve(problem, Tsit5(), p = pnew, saveat = data.t) + sum(abs2, sol .- data.measurements) / size(data.t, 1) + end + end + + # Check 0.0031677344878386607 + @test_nowarn objective(p0, data) + + fd = ForwardDiff.gradient(Base.Fix2(objective, data), p0) + zg = Zygote.gradient(Base.Fix2(objective, data), p0) + + @test fd≈zg[1] atol=1e-6 +end diff --git a/test/extensions/bifurcationkit.jl b/test/extensions/bifurcationkit.jl new file mode 100644 index 0000000000..65c3f2eb37 --- /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 = System(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 = System(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 `mtkcompile` 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 = System(eqs, [x, y, z], [μ, p]) + nsys = mtkcompile(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 + + @mtkcompile 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/dynamic_optimization.jl b/test/extensions/dynamic_optimization.jl new file mode 100644 index 0000000000..c6b1a5c34f --- /dev/null +++ b/test/extensions/dynamic_optimization.jl @@ -0,0 +1,424 @@ +using ModelingToolkit +import InfiniteOpt +using DiffEqDevTools, DiffEqBase +using SimpleDiffEq +using OrdinaryDiffEqSDIRK, OrdinaryDiffEqVerner, OrdinaryDiffEqTsit5, OrdinaryDiffEqFIRK +using Ipopt +using DataInterpolations +using CasADi +using Pyomo + +import DiffEqBase: solve +const M = ModelingToolkit + +const ENABLE_CASADI = VERSION >= v"1.11" + +@testset "ODE Solution, no cost" begin + # Test solving without anything attached. + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + t = M.t_nounits + D = M.D_nounits + + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + @mtkcompile sys = System(eqs, t) + tspan = (0.0, 1.0) + u0map = [x(t) => 4.0, y(t) => 2.0] + parammap = [α => 1.5, β => 1.0, γ => 3.0, δ => 1.0] + + # Test explicit method. + jprob = JuMPDynamicOptProblem(sys, [u0map; parammap], tspan, dt = 0.01) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRK4())) + oprob = ODEProblem(sys, [u0map; parammap], tspan) + osol = solve(oprob, SimpleRK4(), dt = 0.01) + + @test jsol.sol.u ≈ osol.u + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(sys, [u0map; parammap], tspan, dt = 0.01) + csol = solve(cprob, CasADiCollocation("ipopt", constructRK4())) + @test csol.sol.u ≈ osol.u + end + + # Implicit method. + osol2 = solve(oprob, ImplicitEuler(), dt = 0.01, adaptive = false) + jsol2 = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructImplicitEuler())) + @test ≈(jsol2.sol.u, osol2.u, rtol = 0.001) + iprob = InfiniteOptDynamicOptProblem(sys, [u0map; parammap], tspan, dt = 0.01) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)) + @test ≈(isol.sol.u, osol2.u, rtol = 0.001) + if ENABLE_CASADI + csol2 = solve(cprob, CasADiCollocation("ipopt", constructImplicitEuler())) + @test ≈(csol2.sol.u, osol2.u, rtol = 0.001) + end + pprob = PyomoDynamicOptProblem(sys, [u0map; parammap], tspan, dt = 0.01) + psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler())) + @test all([≈(psol.sol(t), osol2(t), rtol = 1e-2) for t in 0.0:0.01:1.0]) + + # With a constraint + u0map = Pair[] + guess = [x(t) => 4.0, y(t) => 2.0] + constr = [x(0.6) ~ 3.5, x(0.3) ~ 7.0] + @mtkcompile lksys = System(eqs, t; constraints = constr) + + jprob = JuMPDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructTsitouras5())) + @test jsol.sol(0.6; idxs = x(t)) ≈ 3.5 + @test jsol.sol(0.3; idxs = x(t)) ≈ 7.0 + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + csol = solve(cprob, CasADiCollocation("ipopt", constructTsitouras5())) + @test csol.sol(0.6; idxs = x(t)) ≈ 3.5 + @test csol.sol(0.3; idxs = x(t)) ≈ 7.0 + end + + pprob = PyomoDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + psol = solve(pprob, PyomoCollocation("ipopt", LagrangeLegendre(3))) + @test psol.sol(0.6; idxs = x(t)) ≈ 3.5 + @test psol.sol(0.3; idxs = x(t)) ≈ 7.0 + + iprob = InfiniteOptDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + isol = solve(iprob, + InfiniteOptCollocation(Ipopt.Optimizer, InfiniteOpt.OrthogonalCollocation(3))) # 48.564 ms, 9.58 MiB + sol = isol.sol + @test sol(0.6; idxs = x(t)) ≈ 3.5 + @test sol(0.3; idxs = x(t)) ≈ 7.0 + + # Test whole-interval constraints + constr = [x(t) ≳ 1, y(t) ≳ 1] + @mtkcompile lksys = System(eqs, t; constraints = constr) + iprob = InfiniteOptDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + isol = solve(iprob, + InfiniteOptCollocation(Ipopt.Optimizer, InfiniteOpt.OrthogonalCollocation(3))) + @test all(u -> u > [1, 1], isol.sol.u) + + jprob = JuMPDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRadauIA3())) + @test all(u -> u > [1, 1], jsol.sol.u) + + pprob = PyomoDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + psol = solve(pprob, PyomoCollocation("ipopt", MidpointEuler())) + @test all(u -> u > [1, 1], psol.sol.u) + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem( + lksys, [u0map; parammap], tspan; guesses = guess, dt = 0.01) + csol = solve(cprob, CasADiCollocation("ipopt", constructRadauIA3())) + @test all(u -> u > [1, 1], csol.sol.u) + end +end + +function is_bangbang(input_sol, lbounds, ubounds, rtol = 1e-4) + for v in 1:(length(input_sol.u[1]) - 1) + all(i -> ≈(i[v], bounds[v]; rtol) || ≈(i[v], bounds[u]; rtol), input_sol.u) || + return false + end + true +end + +function ctrl_to_spline(inputsol, splineType) + us = reduce(vcat, inputsol.u) + ts = reduce(vcat, inputsol.t) + splineType(us, ts) +end + +@testset "Linear systems" begin + # Double integrator + t = M.t_nounits + D = M.D_nounits + @variables x(..) v(..) + @variables u(..) [bounds = (-1.0, 1.0), input = true] + constr = [v(1.0) ~ 0.0] + cost = [-x(1.0)] # Maximize the final distance. + @named block = System( + [D(x(t)) ~ v(t), D(v(t)) ~ u(t)], t; costs = cost, constraints = constr) + block = mtkcompile(block; inputs = [u(t)]) + + u0map = [x(t) => 0.0, v(t) => 0.0] + tspan = (0.0, 1.0) + parammap = [u(t) => 0.0] + jprob = JuMPDynamicOptProblem(block, [u0map; parammap], tspan; dt = 0.01) + jsol = solve( + jprob, JuMPCollocation(Ipopt.Optimizer, constructVerner8()), verbose = true) + # Linear systems have bang-bang controls + @test is_bangbang(jsol.input_sol, [-1.0], [1.0]) + # Test reached final position. + @test ≈(jsol.sol[x(t)][end], 0.25, rtol = 1e-5) + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(block, [u0map; parammap], tspan; dt = 0.01) + csol = solve(cprob, CasADiCollocation("ipopt", constructVerner8())) + @test is_bangbang(csol.input_sol, [-1.0], [1.0]) + # Test reached final position. + @test ≈(csol.sol[x(t)][end], 0.25, rtol = 1e-5) + end + + # Test dynamics + @parameters (u_interp::ConstantInterpolation)(..) + @mtkcompile block_ode = System([D(x(t)) ~ v(t), D(v(t)) ~ u_interp(t)], t) + spline = ctrl_to_spline(jsol.input_sol, ConstantInterpolation) + oprob = ODEProblem(block_ode, [u0map; [u_interp => spline]], tspan) + osol = solve(oprob, Vern8(), dt = 0.01, adaptive = false) + @test ≈(jsol.sol.u, osol.u, rtol = 0.05) + if ENABLE_CASADI + @test ≈(csol.sol.u, osol.u, rtol = 0.05) + end + + iprob = InfiniteOptDynamicOptProblem(block, [u0map; parammap], tspan; dt = 0.01) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)) + @test is_bangbang(isol.input_sol, [-1.0], [1.0]) + @test ≈(isol.sol[x(t)][end], 0.25, rtol = 1e-5) + + pprob = PyomoDynamicOptProblem(block, [u0map; parammap], tspan; dt = 0.01) + psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler())) + @test is_bangbang(psol.input_sol, [-1.0], [1.0]) + @test ≈(psol.sol[x(t)][end], 0.25, rtol = 1e-3) + + spline = ctrl_to_spline(isol.input_sol, ConstantInterpolation) + oprob = ODEProblem(block_ode, [u0map; u_interp => spline], tspan) + @test ≈(isol.sol.u, osol.u, rtol = 0.05) + @test all([≈(psol.sol(t), osol(t), rtol = 0.05) for t in 0.0:0.01:1.0]) + + ################### + ### Bee example ### + ################### + # From Lawrence Evans' notes + @variables w(..) q(..) α(t) [input = true, bounds = (0, 1)] + @parameters b c μ s ν + + tspan = (0, 4) + eqs = [D(w(t)) ~ -μ * w(t) + b * s * α * w(t), + D(q(t)) ~ -ν * q(t) + c * (1 - α) * s * w(t)] + costs = [-q(tspan[2])] + + @named beesys = System(eqs, t; costs) + beesys = mtkcompile(beesys; inputs = [α]) + u0map = [w(t) => 40, q(t) => 2] + pmap = [b => 1, c => 1, μ => 1, s => 1, ν => 1, α => 1] + + jprob = JuMPDynamicOptProblem(beesys, [u0map; pmap], tspan, dt = 0.01) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructTsitouras5())) + @test is_bangbang(jsol.input_sol, [0.0], [1.0]) + iprob = InfiniteOptDynamicOptProblem(beesys, [u0map; pmap], tspan, dt = 0.01) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)) + @test is_bangbang(isol.input_sol, [0.0], [1.0]) + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(beesys, [u0map; pmap], tspan; dt = 0.01) + csol = solve(cprob, CasADiCollocation("ipopt", constructTsitouras5())) + @test is_bangbang(csol.input_sol, [0.0], [1.0]) + end + pprob = PyomoDynamicOptProblem(beesys, [u0map; pmap], tspan, dt = 0.01) + psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler())) + @test is_bangbang(psol.input_sol, [0.0], [1.0]) + + @parameters (α_interp::LinearInterpolation)(..) + eqs = [D(w(t)) ~ -μ * w(t) + b * s * α_interp(t) * w(t), + D(q(t)) ~ -ν * q(t) + c * (1 - α_interp(t)) * s * w(t)] + @mtkcompile beesys_ode = System(eqs, t) + oprob = ODEProblem(beesys_ode, + merge(Dict(u0map), Dict(pmap), + Dict(α_interp => ctrl_to_spline(jsol.input_sol, LinearInterpolation))), + tspan) + osol = solve(oprob, Tsit5(); dt = 0.01, adaptive = false) + @test ≈(osol.u, jsol.sol.u, rtol = 0.01) + if ENABLE_CASADI + @test ≈(osol.u, csol.sol.u, rtol = 0.01) + end + osol2 = solve(oprob, ImplicitEuler(); dt = 0.01, adaptive = false) + @test ≈(osol2.u, isol.sol.u, rtol = 0.01) + @test all([≈(psol.sol(t), osol2(t), rtol = 0.01) for t in 0.0:0.01:4.0]) +end + +@testset "Rocket launch" begin + t = M.t_nounits + D = M.D_nounits + + @parameters h_c m₀ h₀ g₀ D_c c Tₘ m_c + @variables h(..) v(..) m(..) [bounds = (m_c, 1)] T(..) [input = true, bounds = (0, Tₘ)] + drag(h, v) = D_c * v^2 * exp(-h_c * (h - h₀) / h₀) + gravity(h) = g₀ * (h₀ / h) + + eqs = [D(h(t)) ~ v(t), + D(v(t)) ~ (T(t) - drag(h(t), v(t))) / m(t) - gravity(h(t)), + D(m(t)) ~ -T(t) / c] + + (ts, te) = (0.0, 0.2) + costs = [-h(te)] + cons = [T(te) ~ 0, m(te) ~ m_c] + @named rocket = System(eqs, t; costs, constraints = cons) + rocket = mtkcompile(rocket; inputs = [T(t)]) + + u0map = [h(t) => h₀, m(t) => m₀, v(t) => 0] + pmap = [ + g₀ => 1, m₀ => 1.0, h_c => 500, c => 0.5 * √(g₀ * h₀), D_c => 0.5 * 620 * m₀ / g₀, + Tₘ => 3.5 * g₀ * m₀, T(t) => 0.0, h₀ => 1, m_c => 0.6] + jprob = JuMPDynamicOptProblem(rocket, [u0map; pmap], (ts, te); dt = 0.001, cse = false) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRadauIIA5())) + @test jsol.sol[h(t)][end] > 1.012 + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem( + rocket, [u0map; pmap], (ts, te); dt = 0.001, cse = false) + csol = solve(cprob, CasADiCollocation("ipopt")) + @test csol.sol[h(t)][end] > 1.012 + end + + iprob = InfiniteOptDynamicOptProblem(rocket, [u0map; pmap], (ts, te); dt = 0.001) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)) + @test isol.sol[h(t)][end] > 1.012 + + pprob = PyomoDynamicOptProblem(rocket, [u0map; pmap], (ts, te); dt = 0.001, cse = false) + psol = solve(pprob, PyomoCollocation("ipopt", LagrangeRadau(4))) + @test psol.sol[h(t)][end] > 1.012 + + # Test solution + @parameters (T_interp::CubicSpline)(..) + eqs = [D(h(t)) ~ v(t), + D(v(t)) ~ (T_interp(t) - drag(h(t), v(t))) / m(t) - gravity(h(t)), + D(m(t)) ~ -T_interp(t) / c] + @mtkcompile rocket_ode = System(eqs, t) + interpmap = Dict(T_interp => ctrl_to_spline(jsol.input_sol, CubicSpline)) + oprob = ODEProblem(rocket_ode, merge(Dict(u0map), Dict(pmap), interpmap), (ts, te)) + osol = solve(oprob, RadauIIA5(); adaptive = false, dt = 0.001) + @test ≈(jsol.sol.u, osol.u, rtol = 0.02) + if ENABLE_CASADI + @test ≈(csol.sol.u, osol.u, rtol = 0.02) + end + + interpmap1 = Dict(T_interp => ctrl_to_spline(isol.input_sol, CubicSpline)) + oprob1 = ODEProblem(rocket_ode, merge(Dict(u0map), Dict(pmap), interpmap1), (ts, te)) + osol1 = solve(oprob1, ImplicitEuler(); adaptive = false, dt = 0.001) + @test ≈(isol.sol.u, osol1.u, rtol = 0.01) + + interpmap2 = Dict(T_interp => ctrl_to_spline(psol.input_sol, CubicSpline)) + oprob2 = ODEProblem(rocket_ode, merge(Dict(u0map), Dict(pmap), interpmap2), (ts, te)) + osol2 = solve(oprob2, RadauIIA5(); adaptive = false, dt = 0.001) + @test all([≈(psol.sol(t), osol2(t), rtol = 0.01) for t in 0:0.001:0.2]) +end + +@testset "Free final time problems" begin + t = M.t_nounits + D = M.D_nounits + + @variables x(..) u(..) [input = true, bounds = (0, 1)] + @parameters tf + eqs = [D(x(t)) ~ -2 + 0.5 * u(t)] + # Integral cost function + costs = [-Symbolics.Integral(t in (0, tf))(x(t) - u(t)), -x(tf)] + consolidate(u, sub) = u[1] + u[2] + sum(sub) + @named rocket = System(eqs, t; costs, consolidate) + rocket = mtkcompile(rocket; inputs = [u(t)]) + + u0map = [x(t) => 17.5] + pmap = [u(t) => 0.0, tf => 8] + jprob = JuMPDynamicOptProblem(rocket, [u0map; pmap], (0, tf); steps = 201) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructTsitouras5())) + @test isapprox(jsol.sol.t[end], 10.0, rtol = 1e-3) + @test ≈(M.objective_value(jsol), -92.75, atol = 0.25) + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(rocket, [u0map; pmap], (0, tf); steps = 201) + csol = solve(cprob, CasADiCollocation("ipopt", constructTsitouras5())) + @test isapprox(csol.sol.t[end], 10.0, rtol = 1e-3) + @test ≈(M.objective_value(csol), -92.75, atol = 0.25) + end + + iprob = InfiniteOptDynamicOptProblem(rocket, [u0map; pmap], (0, tf); steps = 200) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer)) + @test isapprox(isol.sol.t[end], 10.0, rtol = 1e-3) + @test ≈(M.objective_value(isol), -92.75, atol = 0.25) + + pprob = PyomoDynamicOptProblem(rocket, [u0map; pmap], (0, tf); steps = 201) + psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler())) + @test isapprox(psol.sol.t[end], 10.0, rtol = 1e-3) + @test ≈(M.objective_value(psol), -92.75, atol = 0.1) + + @variables x(..) v(..) + @variables u(..) [bounds = (-1.0, 1.0), input = true] + @parameters tf + constr = [v(tf) ~ 0, x(tf) ~ 0] + cost = [tf] # Minimize time + + @named block = System( + [D(x(t)) ~ v(t), D(v(t)) ~ u(t)], t; costs = cost, constraints = constr) + block = mtkcompile(block, inputs = [u(t)]) + + u0map = [x(t) => 1.0, v(t) => 0.0] + tspan = (0.0, tf) + parammap = [u(t) => 1.0, tf => 1.0] + jprob = JuMPDynamicOptProblem(block, [u0map; parammap], tspan; steps = 51) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructVerner8())) + @test isapprox(jsol.sol.t[end], 2.0, atol = 1e-5) + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(block, [u0map; parammap], (0, tf); steps = 51) + csol = solve(cprob, CasADiCollocation("ipopt", constructVerner8())) + @test isapprox(csol.sol.t[end], 2.0, atol = 1e-5) + end + + iprob = InfiniteOptDynamicOptProblem(block, [u0map; parammap], tspan; steps = 51) + isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer), verbose = true) + @test isapprox(isol.sol.t[end], 2.0, atol = 1e-5) + + pprob = PyomoDynamicOptProblem(block, [u0map; parammap], tspan; steps = 51) + psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler())) + @test isapprox(psol.sol.t[end], 2.0, atol = 1e-5) +end + +@testset "Cart-pole problem" begin + t = M.t_nounits + D = M.D_nounits + # gravity, length, moment of Inertia, drag coeff + @parameters g l mₚ mₖ + @variables x(..) θ(..) u(t) [input = true, bounds = (-10, 10)] + + s = sin(θ(t)) + c = cos(θ(t)) + H = [mₖ+mₚ mₚ*l*c + mₚ*l*c mₚ*l^2] + C = [0 -mₚ*D(θ(t))*l*s + 0 0] + qd = [D(x(t)), D(θ(t))] + G = [0, mₚ * g * l * s] + B = [1, 0] + + tf = 5 + rhss = -H \ Vector(C * qd + G - B * u) + eqs = [D(D(x(t))) ~ rhss[1], D(D(θ(t))) ~ rhss[2]] + cons = [θ(tf) ~ π, x(tf) ~ 0, D(θ(tf)) ~ 0, D(x(tf)) ~ 0] + costs = [Symbolics.Integral(t in (0, tf))(u^2)] + tspan = (0, tf) + + @named cartpole = System(eqs, t; costs, constraints = cons) + cartpole = mtkcompile(cartpole; inputs = [u]) + + u0map = [D(x(t)) => 0.0, D(θ(t)) => 0.0, θ(t) => 0.0, x(t) => 0.0] + pmap = [mₖ => 1.0, mₚ => 0.2, l => 0.5, g => 9.81, u => 0] + jprob = JuMPDynamicOptProblem(cartpole, [u0map; pmap], tspan; dt = 0.04) + jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRK4())) + @test jsol.sol.u[end] ≈ [π, 0, 0, 0] + + if ENABLE_CASADI + cprob = CasADiDynamicOptProblem(cartpole, [u0map; pmap], tspan; dt = 0.04) + csol = solve(cprob, CasADiCollocation("ipopt", constructRK4())) + @test csol.sol.u[end] ≈ [π, 0, 0, 0] + end + + iprob = InfiniteOptDynamicOptProblem(cartpole, [u0map; pmap], tspan; dt = 0.04) + isol = solve(iprob, + InfiniteOptCollocation(Ipopt.Optimizer, InfiniteOpt.OrthogonalCollocation(2))) + @test isol.sol.u[end] ≈ [π, 0, 0, 0] + + pprob = PyomoDynamicOptProblem(cartpole, [u0map; pmap], tspan; dt = 0.04) + psol = solve(pprob, PyomoCollocation("ipopt", LagrangeLegendre(4))) + @test psol.sol.u[end] ≈ [π, 0, 0, 0] +end diff --git a/test/extensions/homotopy_continuation.jl b/test/extensions/homotopy_continuation.jl new file mode 100644 index 0000000000..20ff262132 --- /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] + @mtkcompile sys = System(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)] + + @mtkcompile sys = System(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)) + @mtkcompile sys = System(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 + @mtkcompile sys = System([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 + @mtkcompile sys = System([x^1.5 + x^2 - 1 ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "not an integer", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkcompile sys = System([x^x - x ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "unknowns", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + @mtkcompile sys = System([((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 + @mtkcompile sys = System([x^2 + y^2 + 2 ~ 0, y ~ sin(x)]) + @test_throws ["Cannot convert", "recognized", "sin", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkcompile sys = System([x^2 + y^2 - 2 ~ 0, sin(x + y) ~ 0]) + @test_throws ["Cannot convert", "function of multiple unknowns"] HomotopyContinuationProblem( + sys, []) + + @mtkcompile sys = System([sin(x)^2 + 1 ~ 0, cos(y) - cos(x) - 1 ~ 0]) + @test_throws ["Cannot convert", "multiple non-polynomial terms", "same unknown"] HomotopyContinuationProblem( + sys, []) + + @mtkcompile sys = System([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 + @mtkcompile sys = System([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) + @mtkcompile sys = System([(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 + @mtkcompile sys = System([ + 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 = System( + [ + 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 + @mtkcompile sys = System([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 + @mtkcompile sys = System([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 + @mtkcompile sys = System([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 = System([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..fab44d0cf9 --- /dev/null +++ b/test/extensions/test_infiniteopt.jl @@ -0,0 +1,103 @@ +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.τ] +outputs = [model.y] +model = mtkcompile(model; inputs, outputs) +f, dvs, psym, io_sys = ModelingToolkit.generate_control_function( + model, split = false) + +f_obs = ModelingToolkit.build_explicit_observed_function(io_sys, outputs; 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(Dict{Any, Any}([model.θ => 2pi, model.ω => 10]), dvs) +lb = varmap_to_vars(Dict{Any, Any}([model.θ => -2pi, model.ω => -10]), dvs) +xf = varmap_to_vars(Dict{Any, Any}([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 = ModelingToolkit.get_u0(io_sys, [model.θ => 0, model.ω => 0]) +p = ModelingToolkit.get_p(io_sys, [model.L => L]; split = false, buffer_eltype = Any) + +xp = f[1](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(Dict{Any, Any}([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..de5b8a1dac --- /dev/null +++ b/test/fmi/fmi.jl @@ -0,0 +1,315 @@ +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) + @mtkcompile 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 + @mtkcompile sys = System([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) + @mtkcompile 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 + @mtkcompile sys = System([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] + @mtkcompile sys = System( + [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, sys.adder.value => 2.0], + (0.0, 1.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(abstol = 1e-7)) + @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[[sys.a, sys.b, sys.c]] 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] + @mtkcompile sys = System( + [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, sys.sspace.A => 2.0], (0.0, 1.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(abstol = 1e-7)) + @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 + @mtkcompile sys = System( + [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 + @mtkcompile sys = System([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/function_registration.jl b/test/function_registration.jl index e70a94f08b..7ab9835433 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 = System([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 = System([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 = System([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 = System([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..62a6ae9871 --- /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 = System(eqs, t; initialization_eqs) +sys = complete(mtkcompile(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 = System(eqs, t; initialization_eqs) +sys = complete(mtkcompile(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 = System(eqs, t; initialization_eqs) +sys = complete(mtkcompile(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 = System(eqs, t; initialization_eqs) +sys = complete(mtkcompile(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 +@mtkcompile sys = System([x ~ x0, D(y) ~ x], t) +prob = ODEProblem(sys, [x0 => 1.0], (0.0, 1.0)) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) +@variables y(t) = x0 +@mtkcompile sys = System([x ~ x0, D(y) ~ x], t) +prob = ODEProblem(sys, [x0 => 1.0], (0.0, 1.0)) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) +@variables y(t) = x0 +@mtkcompile sys = System([x ~ y, D(y) ~ x], t) +prob = ODEProblem(sys, [x0 => 1.0], (0.0, 1.0)) +@test prob[x] == 1.0 +@test prob[y] == 1.0 + +@parameters x0 +@variables x(t) = x0 +@variables y(t) = x +@mtkcompile sys = System([x ~ y, D(y) ~ x], t) +prob = ODEProblem(sys, [x0 => 1.0], (0.0, 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..fef9953438 --- /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 System(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 System(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 System(eqs, t, vars, params; systems, name, initialization_eqs) +end + +@component function Ground(; name) + systems = @named begin + g = Pin() + end + eqs = [ + g.v ~ 0 + ] + return System(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 System(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 System(eqs, t, [], []; systems, name, initialization_eqs) +end +"""Run model RLCModel from 0 to 10""" +function simple() + @mtkcompile 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(mtkcompile(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..1fcb5947e4 --- /dev/null +++ b/test/if_lifting.jl @@ -0,0 +1,166 @@ +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 = mtkcompile(sys) + @test length(equations(ss1)) == 1 + ss2 = mtkcompile(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 = mtkcompile(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 "`@mtkcompile` 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 @mtkcompile sys=SimpleAbs() additional_passes=[IfLifting] +end + +@testset "Nested conditions are handled properly" begin + @mtkmodel RampModel begin + @variables begin + x(t) + y(t) + end + @parameters begin + start_time = 1.0 + duration = 1.0 + height = 1.0 + end + @equations begin + y ~ ifelse(start_time < t, + ifelse(t < start_time + duration, + (t - start_time) * height / duration, height), + 0.0) + D(x) ~ y + end + end + @mtkcompile sys = RampModel() + @mtkcompile sys2=RampModel() additional_passes=[IfLifting] + prob = ODEProblem(sys, [sys.x => 1.0], (0.0, 3.0)) + prob2 = ODEProblem(sys2, [sys.x => 1.0], (0.0, 3.0)) + sol = solve(prob) + sol2 = solve(prob2) + @test sol(0.99)[1] > 1.0 + @test sol2(0.99)[1] == 1.0 + # During ramp + # D(x) ~ t - 1 + # x ~ t^2 / 2 - t + C, and `x(1) ~ 1` => `C = 3/2` + # x(1.01) ~ 1.01^2 / 2 - 1.01 + 3/2 ~ 1.00005 + @test sol2(1.01)[1] ≈ 1.00005 + @test sol2(2)[1] ≈ 1.5 + # After ramp + # D(x) ~ 1 + # x ~ t + C and `x(2) ~ 3/2` => `C = -1/2` + # x(3) ~ 3 - 1/2 + @test sol2(3)[1] ≈ 5 / 2 +end diff --git a/test/implicit_discrete_system.jl b/test/implicit_discrete_system.jl new file mode 100644 index 0000000000..57c67116d1 --- /dev/null +++ b/test/implicit_discrete_system.jl @@ -0,0 +1,80 @@ +using ModelingToolkit, SymbolicIndexingInterface, Test +using ModelingToolkit: t_nounits as t +using StableRNGs + +k = ShiftIndex(t) +rng = StableRNG(22525) + +@testset "Correct ImplicitDiscreteFunction" begin + @variables x(t) = 1 + @mtkcompile sys = System([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) + @mtkcompile sys = System([x(k) ~ x(k) * x(k - 1) - 3], t) + @test_throws ModelingToolkit.MissingGuessError 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] + @mtkcompile sys = System(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 + + reorderer = getu(sys, [x(k - 2), x(k - 1), y]) + + 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] + @mtkcompile sys = System(eqs, t) + @test length(unknowns(sys)) == length(equations(sys)) == 3 + @test occursin( + "var\"y(t)\"", string(ImplicitDiscreteFunction(sys; expression = Val{true}))) + + # 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] + @mtkcompile sys = System(eqs, t) + @test occursin("var\"Shift(t, 1)(x(t))\"", + string(ImplicitDiscreteFunction(sys; expression = Val{true}))) +end diff --git a/test/index_cache.jl b/test/index_cache.jl new file mode 100644 index 0000000000..5573563d32 --- /dev/null +++ b/test/index_cache.jl @@ -0,0 +1,122 @@ +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 = System(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = System(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 = System(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = System(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 = System(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 = System(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 = System(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!(mod, obs, ctx, integ) + p_1 = mod.p_1 + p_1.y = integ.t + return (; p_1) + end + + tp1 = typeof(ParamTest(1)) + @parameters (p_1::tp1)(..) = ParamTest(1) + @variables x(ModelingToolkit.t_nounits) = 0 + + event1 = [1.0, 2, 3] => (f = update_affect!, modified = (; p_1)) + + @named sys = System([ + 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..5412d4d58a --- /dev/null +++ b/test/initial_values.jl @@ -0,0 +1,358 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, get_u0 +using OrdinaryDiffEq +using DataInterpolations +using StaticArrays +using SymbolicIndexingInterface + +@variables x(t)[1:3]=[1.0, 2.0, 3.0] y(t) z(t)[1:2] + +@mtkcompile sys=System([D(x)~t*x], t) simplify=false +reorderer = getsym(sys, x) +@test reorderer(get_u0(sys, [])) == [1.0, 2.0, 3.0] +@test reorderer(get_u0(sys, [x => [2.0, 3.0, 4.0]])) == [2.0, 3.0, 4.0] +@test reorderer(get_u0(sys, [x[1] => 2.0, x[2] => 3.0, x[3] => 4.0])) == [2.0, 3.0, 4.0] +@test get_u0(sys, [2.0, 3.0, 4.0]) == [2.0, 3.0, 4.0] + +@mtkcompile sys=System([ + 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]])) == collect(1.0:6.0) +@test getter(get_u0(sys, [y => 4.0, z => [3y, 4y]])) == [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.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.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.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(merge(defaults, Dict(var_vals)), desired_values) +@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 +@mtkcompile osys_m = System([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; ps], tspan) + +# 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)] +@mtkcompile sys = System(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) +@mtkcompile sys = System( + [ + x1 ~ B1, + x2 ~ B2 + ], t; defaults = [ + A2 => 1 - A1, + B1 => A1, + B2 => A2 + ]) +prob = ODEProblem(sys, [A1 => 0.3], (0.0, 1.0)) +@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) + @named sys = System(Equation[], t, [x, y], [p]; defaults = [y => nothing]) + @test isempty(ModelingToolkit.defaults(sys)) +end + +# Using indepvar in initialization +# Issue#2799 +@variables x(t) +@parameters p +@mtkcompile sys = System([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) + @mtkcompile sys = System([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 + @mtkcompile sys=System(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] + @mtkcompile osys = System(eqs, t, sps, ps) + u0map = [x => 1.0] + pmap = [c1 => 5.0, c2 => 1.0, c3 => 1.2] + oprob = ODEProblem(osys, [u0map; pmap], (0.0, 10.0)) +end + +@testset "Cyclic dependency checking and substitution limits" begin + @variables x(t) y(t) + @mtkcompile sys = System( + [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 + @mtkcompile sys = System( + [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, p => 2q, q => 3p], + (0.0, 1.0); warn_cyclic_dependency = true) + catch + end + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + sys, [x => 1, y => 2, p => 2q, q => 3p], (0.0, 1.0)) +end + +@testset "`add_fallbacks!` checks scalarized array parameters correctly" begin + @variables x(t)[1:2] + @parameters p[1:2, 1:2] + @mtkcompile sys = System(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] + ] + @mtkcompile sys = System(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[x, 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] + @mtkcompile pend = System(eqs, t) + + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + pend, [x => 1, g => 1], (0, 1), guesses = [y => λ, λ => y + 1]) + ODEProblem(pend, [x => 1, g => 1], (0, 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] + @mtkcompile sys = System(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)(..) + + @mtkcompile sys = System(D(x) ~ interp(t), t) + + prob = ODEProblem(sys, [x => 0.0, interp => spline], (0.0, 1.0)) + 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 = System(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] + @mtkcompile osys = System(eqs, t) + u0 = [X => 1.0f0] + ps = [p => 1.0f0, d => 2.0f0] + oprob = ODEProblem(osys, [u0; ps], (0.0f0, 1.0f0)) + 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 + @mtkcompile sys=System([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), p => 1.0], (0.0, 1.0)) + @test prob.p isa Vector{Float64} + @test length(prob.p) == 5 +end + +@testset "Temporary values for solved variables are guesses" begin + @parameters σ ρ β=missing [guess = 8 / 3] + @variables x(t) y(t) z(t) w(t) w2(t) + + eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z, + w ~ x + y + z + 2 * β, + 0 ~ x^2 + y^2 - w2^2 + ] + + @mtkcompile sys = System(eqs, t) + + u0 = [D(x) => 2.0, + x => 1.0, + y => 0.0, + z => 0.0] + + p = [σ => 28.0, + ρ => 10.0] + + tspan = (0.0, 100.0) + prob = ODEProblem(sys, [u0; p], tspan, jac = true, guesses = [w2 => -1.0], + warn_initialize_determined = false) + @test prob[w2] ≈ -1.0 + @test prob.ps[β] ≈ 8 / 3 +end + +@testset "MTKParameters uses given `pType` for inner buffers" 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] + + @mtkcompile sys = System(eqs, t) + + u0 = SA[D(x) => 2.0f0, + x => 1.0f0, + y => 0.0f0, + z => 0.0f0] + + p = SA[σ => 28.0f0, + ρ => 10.0f0, + β => 8.0f0 / 3] + + tspan = (0.0f0, 100.0f0) + prob = ODEProblem(sys, [u0; p], tspan) + @test prob.p.tunable isa SVector + @test prob.p.initials isa SVector +end + +@testset "`p_constructor` keyword argument" begin + @parameters g = 1.0 + @variables x(t) y(t) [state_priority = 10, guess = 1.0] λ(t) [guess = 1.0] + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkcompile pend = System(eqs, t) + + u0 = [x => 1.0, D(x) => 0.0] + u0_constructor = p_constructor = vals -> SVector{length(vals)}(vals...) + tspan = (0.0, 5.0) + prob = ODEProblem(pend, u0, tspan; u0_constructor, p_constructor) + @test prob.u0 isa SVector + @test prob.p.tunable isa SVector + @test prob.p.initials isa SVector + initdata = prob.f.initialization_data + @test state_values(initdata.initializeprob) isa SVector + @test parameter_values(initdata.initializeprob).tunable isa SVector + + @mtkcompile pend=System(eqs, t) split=false + prob = ODEProblem(pend, u0, tspan; u0_constructor, p_constructor) + @test prob.p isa SVector + initdata = prob.f.initialization_data + @test state_values(initdata.initializeprob) isa SVector + @test parameter_values(initdata.initializeprob) isa SVector +end + +@testset "Type promotion of `p` works with non-dual types" begin + @variables x(t) y(t) + @mtkcompile sys = System([D(x) ~ x + y, x^3 + y^3 ~ 5], t; guesses = [y => 1.0]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + prob2 = remake(prob; u0 = BigFloat.(prob.u0)) + @test prob2.p.initials isa Vector{BigFloat} + sol = solve(prob2) + @test SciMLBase.successful_retcode(sol) +end diff --git a/test/initializationsystem.jl b/test/initializationsystem.jl new file mode 100644 index 0000000000..19209b5e46 --- /dev/null +++ b/test/initializationsystem.jl @@ -0,0 +1,1704 @@ +using ModelingToolkit, OrdinaryDiffEq, NonlinearSolve, Test +using StochasticDiffEq, DelayDiffEq, StochasticDelayDiffEq, JumpProcesses +using ForwardDiff, StaticArrays +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] +@mtkcompile pend = System(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) || + sol.retcode == SciMLBase.ReturnCode.StalledSuccess + +@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, g => 1], (0.0, 1.5), + 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, g => 1], (0.0, 1.5), + 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, g => 1], (0.0, 1.5), + 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 + +@mtkcompile 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 + +@mtkcompile 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] + +@mtkcompile sys = System(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; p], tspan, 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 System(eqs, t, vars, []; name) +end + +@mtkcompile 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 System(eqs, t, vars, []; name, initialization_eqs) +end + +@mtkcompile 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 = System(eqs, t) + sys = mtkcompile(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; p], tspan) + 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 = System(eqs, t) +simpsys = mtkcompile(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[z, 1] == 0.0 + +prob = ODEProblem(simpsys, [z => 1.0, y => 1.0], tspan, guesses = [x => 2.0]) +sol = solve(prob, Tsit5()) +@test sol[[x, y], 1] == [0.0, 1.0] + +@test_warn "underdetermined" prob = ODEProblem( + simpsys, [], tspan, guesses = [x => 2.0, y => 1.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] +@mtkcompile pend = System(eqs, t) + +prob = ODEProblem(pend, [x => 1, g => 1], (0.0, 1.5), + guesses = [λ => 0, y => 1], initialization_eqs = [y ~ 1]) + +unsimp = generate_initializesystem(pend; op = [x => 1], initialization_eqs = [y ~ 1]) +sys = mtkcompile(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 = System([D(x) ~ 0], t; initialization_eqs = [x ~ 1]) +@named sysy = System([D(y) ~ 0], t; initialization_eqs = [y^2 ~ 2], guesses = [y => 1]) +sys = complete(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 = System([x^2 + y^2 ~ 25, D(x) ~ 1], t) + ssys = mtkcompile(sys) + @test_throws ModelingToolkit.MissingGuessError 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 = System([D(x) ~ 0], t; defaults = ics1, name = :sys1) |> mtkcompile + 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, + System([D(y) ~ 0], t; initialization_eqs = [y ~ 2], name = :sys2) + ) |> mtkcompile + 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 = System( + [D(D(x)) ~ 0], t; initialization_eqs = [x ~ 0, D(x) ~ 1], name = :sys) |> + mtkcompile + @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + + sys = System( + [D(D(x)) ~ 0], t; initialization_eqs = [x ~ 0, D(D(x)) ~ 0], name = :sys) |> + mtkcompile + @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 = System( + [D(D(x)) ~ 0], t; + initialization_eqs = [D(x)^2 ~ 1, x ~ 0], guesses = [D(x) => sign], name = :sys + ) |> mtkcompile + 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] +@mtkcompile sys_1st_order = System(eqs_1st_order, t) +@mtkcompile sys_2nd_order = System(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; ps], tspan) +oprob_1st_order_2 = ODEProblem(sys_1st_order, [u0_1st_order_2; ps], tspan) +oprob_2nd_order_1 = ODEProblem(sys_2nd_order, [u0_2nd_order_1; ps], tspan) # gives sys_2nd_order +oprob_2nd_order_2 = ODEProblem(sys_2nd_order, [u0_2nd_order_2; ps], tspan) + +@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 = System([D(x) ~ x, D(y) ~ y], t; initialization_eqs = [y ~ -x]) + sys = mtkcompile(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 + @brownians a b + x = _x(t) + sarray_ctor = splat(SVector) + # `System` constructor creates appropriate type with mtkcompile + # `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)) and $ctor ctor" for ( + (Problem, alg, rhss), (ctor, expectedT)) in Iterators.product( + [ + (ODEProblem, Tsit5(), zeros(2)), + (SDEProblem, ImplicitEM(), [a, b]), + (DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ], + [(identity, Any), (sarray_ctor, SVector)]) + u0_constructor = p_constructor = ctor + if ctor !== identity + Problem = Problem{false} + end + function test_parameter(prob, sym, val) + if prob.u0 !== nothing + @test prob.u0 isa expectedT + @test init(prob, alg).ps[sym] ≈ val + end + @test prob.p.tunable isa expectedT + initprob = prob.f.initialization_data.initializeprob + if state_values(initprob) !== nothing + @test state_values(initprob) isa expectedT + end + @test parameter_values(initprob).tunable isa expectedT + @test solve(prob, alg).ps[sym] ≈ val + end + function test_initializesystem(prob, p, equation) + isys = prob.f.initialization_data.initializeprob.f.sys + @test is_variable(isys, p) || ModelingToolkit.has_observed_with_lhs(isys, p) + @test equation in [equations(isys); observed(isys)] + end + + u0map = Dict(x => 1.0, y => 1.0) + pmap = Dict() + pmap[q] = 1.0 + # `missing` default, equation from Problem + @mtkcompile 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, merge(u0map, pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, 2.0) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2 = remake(prob2; p = setp_oop(prob2, p)(prob2, 0.0)) + test_parameter(prob2, p, 2.0) + # `missing` default, provided guess + @mtkcompile 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); u0_constructor, p_constructor) + test_parameter(prob, p, 2.0) + test_initializesystem(prob, p, p ~ x + y) + prob2 = remake(prob; u0 = u0map) + prob2 = remake(prob2; p = setp_oop(prob2, p)(prob2, 0.0)) + test_parameter(prob2, p, 2.0) + + # `missing` to Problem, equation from default + @mtkcompile 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, merge(u0map, pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, 2.0) + test_initializesystem(prob, p, p ~ 2q) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2 = remake(prob2; p = setp_oop(prob2, p)(prob2, 0.0)) + test_parameter(prob2, p, 2.0) + # `missing` to Problem, provided guess + @mtkcompile sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [p => 0.0]) + prob = Problem(sys, merge(u0map, pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, 2.0) + test_initializesystem(prob, p, p ~ x + y) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2 = remake(prob2; p = setp_oop(prob2, p)(prob2, 0.0)) + test_parameter(prob2, p, 2.0) + + # No `missing`, default and guess + @mtkcompile 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, merge(u0map, pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, 2.0) + test_initializesystem(prob, p, p ~ 2q) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2 = remake(prob2; p = setp_oop(prob2, p)(prob2, 0.0)) + test_parameter(prob2, p, 2.0) + + # Default overridden by Problem, guess provided + @mtkcompile 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, merge(u0map, _pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, _pmap[q]) + test_initializesystem(prob, p, p ~ q) + # Problem dependent value with guess, no `missing` + @mtkcompile 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, merge(u0map, _pmap), (0.0, 1.0); u0_constructor, p_constructor) + test_parameter(prob, p, 3pmap[q]) + + # Should not be solved for: + # Override dependent default with direct value + @mtkcompile 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, merge(u0map, _pmap), (0.0, 1.0); u0_constructor, p_constructor) + @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 + @mtkcompile 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, merge(u0map, Dict(r => 1)), (0.0, 1.0); u0_constructor, p_constructor) + @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) + + @mtkcompile 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, p => 2.0], (0.0, 1.0); + initialization_eqs = [x^2 + y^2 ~ 3], u0_constructor, p_constructor) + @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 + @mtkcompile sys = System([x ~ x0, y ~ y0, s ~ x + y], t; guesses = [y0 => 0.0]) + prob = ODEProblem(sys, [s => 1.0, x0 => 0.3, y0 => missing], (0.0, 1.0)) + # 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) + @mtkcompile sys = System( + [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, [spring.s_rel0 => missing], (0.0, 1.0)) + # 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] + @mtkcompile ns = System(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]) + sarray_ctor = splat(SVector) + @testset "Parameter initialization with ctor $ctor" for (ctor, expectedT) in [ + (identity, Any), + (sarray_ctor, SVector) + ] + u0_constructor = p_constructor = ctor + function test_parameter(prob, alg, param, val) + if prob.u0 !== nothing + @test prob.u0 isa expectedT + end + @test prob.p.tunable isa expectedT + 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 z=3.0 + + # eqs = [0 ~ p * (y - x), + # 0 ~ x * (q - z) - y, + # 0 ~ x * y - c * z] + # specifically written this way due to + # https://github.com/SciML/NonlinearSolve.jl/issues/586 + eqs = [0 ~ -c * z + (q - z) * (x^2) + 0 ~ p * (-x + (q - z) * x)] + @named sys = System(eqs; initialization_eqs = [p^2 + q^2 + 2p * q ~ 0]) + sys = complete(sys) + # @mtkcompile 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 + if ctor != identity + probT = probT{false} + end + prob = probT(sys, []; u0_constructor, p_constructor) + @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 = remake(prob; p = setp_oop(prob, p)(prob, -2.0)) + for alg in algs + test_parameter(prob, alg, q, 2.0) + end + prob = remake(prob; p = setp_oop(prob, p)(prob, 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 + @brownians 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]) + ] + @mtkcompile 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, p => 3.0], (0.0, 1.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 + @brownians 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) + ] + @mtkcompile sys = System( + [D(x) ~ 2x + r + rhss, r ~ p + 2q, q ~ p + 3], t; + guesses = [p => 1.0]) + prob = Problem(sys, [x => 1.0, p => missing], (0.0, 1.0)) + parent_isys = ModelingToolkit.get_parent(prob.f.initialization_data.initializeprob.f.sys) + @test length(equations(parent_isys)) == 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 + @brownians a b + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for ( + Problem, alg, rhss) in [ + (ODEProblem, Tsit5(), zeros(2)), + (SDEProblem, ImplicitEM(), [a, b]), + (DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + @mtkcompile 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 + @brownians 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] + + @mtkcompile 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, p => 1.0, q => missing], (0.0, 1.0)) + @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 + @brownians 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] + @mtkcompile 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, p => 1.0], (0.0, 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, p => missing], (0.0, 1.0)) + @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 + @mtkcompile sys = System([D(x) ~ t, 0 ~ foo(x, y), foo ~ Multiplier(p, 2p)], t; + guesses = [y => -1.0]) + prob = ODEProblem(sys, [x => 1.0, p => 1.0], (0.0, 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] + @mtkcompile sys = System( + [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) + @mtkcompile sys = System([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) + @mtkcompile sys = System([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] + @mtkcompile pend = System(eqs, t) + + prob = ODEProblem(pend, [x => 1, y => 0, g => 1], (0.0, 1.5), + guesses = ModelingToolkit.missing_variable_defaults(pend)) + sol = solve(prob, Rodas5P()) + @test SciMLBase.successful_retcode(sol) + + prob2 = remake(prob, u0 = [x => 0.5, y=>nothing]) + sol2 = solve(prob2, Rodas5P()) + @test SciMLBase.successful_retcode(sol2) +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 = System(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 = mtkcompile(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] + @mtkcompile pend = System(eqs, t) + @test_warn ["structurally singular", "initialization", "Guess", "heuristic"] ODEProblem( + pend, [x => 1, y => 0, g => 1], (0.0, 1.5), 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] + @mtkcompile sys = System( + [D(x) ~ p * y + q, x^3 + y^3 ~ 5], t; initialization_eqs = [p^2 + q^3 ~ 3]) + + # FIXME: solve for du0 + prob = DAEProblem( + sys, [D(x) => cbrt(4) + cbrt(2), D(y) => -1 / cbrt(4), x => 1.0, p => 1.0], ( + 0.0, 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 + @mtkcompile sys = System([D(x) ~ x * p + q, x^3 + y^3 ~ 3], t) + prob = ODEProblem( + sys, [p => 1.0], (0.0, 1.0); guesses = [x => 1.0, y => 1.0, q => 1.0]) + @test prob[x] == 1.0 + @test prob[y] == 2.0 + @test prob.ps[p] == 1.0 + @test prob.ps[q] == 3.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) atol=1e-5 + prob2 = remake(prob; u0 = [y => 3x], p = [q => 2x]) + integ2 = init(prob2) + @test integ2[x]≈cbrt(3 / 28) atol=1e-5 + @test integ2[y]≈3cbrt(3 / 28) atol=1e-5 + @test integ2.ps[p] == 1.0 + @test integ2.ps[q]≈2cbrt(3 / 28) atol=1e-5 +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] + @mtkcompile sys = System( + [D(x) ~ p * x + q * y, y ~ 2x, q ~ 2p], t) + prob = ODEProblem(sys, [x => 1.0, p => 1.0], (0.0, 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 => 4.0]) + @test prob5.f.initialization_data !== nothing + @test init(prob5).ps[p] ≈ 2.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] + @mtkcompile sys = System( + [D(x) ~ p * x + q * y, y ~ 2x, q ~ 2p], t) + prob = ODEProblem(sys, [:x => 1.0, p => 1.0], (0.0, 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 = System([D(x) ~ 0, D(y) ~ x + a], t; initialization_eqs = [y ~ a]) + + ssys = mtkcompile(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 + ] + @mtkcompile osys = System(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; p_vals], tspan) + 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 ~ Pre(S) - 1, I ~ Pre(I) + 1] + rate₂ = γ * I + affect₂ = [I ~ Pre(I) - 1, R ~ Pre(R) + 1] + j₁ = ConstantRateJump(rate₁, affect₁) + j₂ = ConstantRateJump(rate₂, affect₂) + j₃ = MassActionJump(2 * β + γ, [R => 1], [S => 1, R => -1]) + @mtkcompile js = JumpSystem([j₁, j₂, j₃], t, [S, I, R], [β, γ, S0]) + + u0s = [I => 1, R => 0] + ps = [S0 => 999, β => 0.01, γ => 0.001] + + jprob = JumpProblem(js, [u0s; ps], (0.0, 10.0)) + 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 + @mtkcompile sys = System( + 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, q => 2.0], (0.0, 1.0)) + initsys = prob.f.initialization_data.initializeprob.f.sys + @test length(ModelingToolkit.observed(initsys)) == 4 + 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] + @mtkcompile sys = System( + [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, p => 2ones(2, 2)], (0.0, 1.0)) + 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!(mod, obs, ctx, integrator) = (terminate!(integrator); return (;)) + @named sys = System([D(x) ~ 1.0 + D(y) ~ 1.0], t; initialization_eqs = [ + y ~ 0.0 + ], + continuous_events = [ + [y ~ 0.5] => (; f = stop!) + ]) + sys = mtkcompile(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] + @mtkcompile sys = System(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) + @mtkcompile sys = System(x ~ p * t, t) + prob = @test_nowarn ODEProblem(sys, [p => 1.0], (0.0, 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] + @mtkcompile osys = System(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; ps], 1.0) + 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) + @brownians 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) + ] + @mtkcompile 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 + @mtkcompile sys = System([x^2 + y^2 ~ p1, (x - 1)^2 + (y - 1)^2 ~ p2, 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 + ] + @mtkcompile nlsys = System(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) + ] + @mtkcompile nlsys = System(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 + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, 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 + @mtkcompile sys = System([D(x) ~ -c1 * x + c2 * y, D(y) ~ c1 * x - c2 * y], t) + prob1 = ODEProblem(sys, [x => 1.0, y => 2.0, c1 => 1.0, c2 => 2.0], (0.0, 1.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 + +# https://github.com/SciML/SciMLBase.jl/issues/985 +@testset "Type-stability of `remake`" begin + @parameters α=1 β=1 γ=1 δ=1 + @variables x(t)=1 y(t)=1 + eqs = [D(x) ~ α * x - β * x * y, D(y) ~ -δ * y + γ * x * y] + @named sys = System(eqs, t) + prob = ODEProblem(complete(sys), [], (0.0, 1)) + @inferred remake(prob; u0 = 2 .* prob.u0, p = prob.p) + @inferred solve(prob) +end + +@testset "Issue#3570, #3552: `Initial`s/guesses are copied to `u0` during `solve`/`init`" begin + @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] + @mtkcompile pend = System(eqs, t) + + prob = ODEProblem( + pend, [x => (√2 / 2), D(x) => 0.0, g => 1], (0.0, 1.5), + guesses = [λ => 1, y => √2 / 2]) + sol = solve(prob) + + @testset "Guesses of initialization problem copied to algebraic variables" begin + prob.f.initialization_data.initializeprob[λ] = 1.0 + prob2 = DiffEqBase.get_updated_symbolic_problem( + pend, prob; u0 = prob.u0, p = prob.p) + @test prob2[λ] ≈ 1.0 + end + + @testset "Initial values for algebraic variables are retained" begin + prob2 = ODEProblem( + pend, [x => (√2 / 2), D(y) => 0.0, g => 1], (0.0, 1.5), + guesses = [λ => 1, y => √2 / 2]) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + prob3 = DiffEqBase.get_updated_symbolic_problem( + pend, prob2; u0 = prob2.u0, p = prob2.p) + @test prob3[D(y)] ≈ 0.0 + end + + @testset "`setsym_oop`" begin + setter = setsym_oop(prob, [Initial(x)]) + (u0, p) = setter(prob, [0.8]) + new_prob = remake(prob; u0, p, initializealg = BrownFullBasicInit()) + new_sol = solve(new_prob) + @test new_sol[x, 1] ≈ 0.8 + integ = init(new_prob) + @test integ[x] ≈ 0.8 + end + + @testset "`setsym`" begin + @test prob.ps[Initial(x)] ≈ √2 / 2 + prob.ps[Initial(x)] = 0.8 + sol = solve(prob; initializealg = BrownFullBasicInit()) + @test sol[x, 1] ≈ 0.8 + integ = init(prob; initializealg = BrownFullBasicInit()) + @test integ[x] ≈ 0.8 + end +end + +@testset "Initialization copies solved `u0` to `p`" begin + @parameters σ ρ β A[1:3] + @variables x(t) y(t) z(t) w(t) w2(t) + eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z, + w ~ x + y + z + 2 * β, + 0 ~ x^2 + y^2 - w2^2 + ] + + @mtkcompile sys = System(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) + getter = getsym(sys, Initial.(unknowns(sys))) + prob = ODEProblem(sys, [u0; p], tspan; guesses = [w2 => 3.0]) + new_u0, new_p, + _ = SciMLBase.get_initial_values( + prob, prob, prob.f, SciMLBase.OverrideInit(), Val(true); + nlsolve_alg = NewtonRaphson(), abstol = 1e-6, reltol = 1e-3) + @test getter(prob) != getter(new_p) + @test getter(new_p) == new_u0 + _prob = remake(prob, u0 = new_u0, p = new_p) + sol = solve(_prob; initializealg = CheckInit()) + @test SciMLBase.successful_retcode(sol) + @test sol.u[1] ≈ new_u0 +end + +@testset "Initialization system retains `split` kwarg of parent" 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] + @mtkcompile pend=System(eqs, t) split=false + prob = ODEProblem( + pend, [x => 1.0, D(x) => 0.0, g => 1.0], (0.0, 1.0); guesses = [y => 1.0, λ => 1.0]) + @test !ModelingToolkit.is_split(prob.f.initialization_data.initializeprob.f.sys) +end + +@testset "`InitializationProblem` retains `iip` of parent" 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] + @mtkcompile pend = System(eqs, t) + prob = ODEProblem(pend, SA[x => 1.0, D(x) => 0.0, g => 1.0], + (0.0, 1.0); guesses = [y => 1.0, λ => 1.0]) + @test !SciMLBase.isinplace(prob) + @test !SciMLBase.isinplace(prob.f.initialization_data.initializeprob) +end + +@testset "Array unknowns occurring unscalarized in initializeprobpmap" begin + @variables begin + u(t)[1:2] = 0.9ones(2) + x(t)[1:2], [guess = 0.01ones(2)] + o(t)[1:2] + end + @parameters p[1:4] = [2.0, 1.875, 2.0, 1.875] + + 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) + x[1] ~ 0.01exp(-1) + x[2] ~ 0.01cos(t)] + + @mtkbuild sys = ODESystem(eqs, t) + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Defaults removed with ` => nothing` aren't retained" begin + @variables x(t)[1:2] + @mtkbuild sys = System([D(x[1]) ~ -x[1], x[1] + x[2] ~ 3], t; defaults = [x[1] => 1]) + prob = ODEProblem(sys, [x[1] => nothing, x[2] => 1], (0.0, 1.0)) + @test SciMLBase.initialization_status(prob) == SciMLBase.FULLY_DETERMINED +end + +@testset "Nonnumerics aren't narrowed" begin + @mtkmodel Foo begin + @variables begin + x(t) = 1.0 + end + @parameters begin + p::AbstractString + r = 1.0 + end + @equations begin + D(x) ~ r * x + end + end + @mtkbuild sys = Foo(p = "a") + prob = ODEProblem(sys, [], (0.0, 1.0)) + @test prob.p.nonnumeric[1] isa Vector{AbstractString} + integ = init(prob) + @test integ.p.nonnumeric[1] isa Vector{AbstractString} +end diff --git a/test/input_output_handling.jl b/test/input_output_handling.jl new file mode 100644 index 0000000000..8b966ca814 --- /dev/null +++ b/test/input_output_handling.jl @@ -0,0 +1,472 @@ +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 = System(eqs, t) +@test_throws ExtraVariablesSystemException mtkcompile(model) +if VERSION >= v"1.8" + err = "In particular, the unset input(s) are:\n some_input(t)" + @test_throws err mtkcompile(model) +end + +# Test input handling +@variables x(t) u(t) [input = true] v(t)[1:2] [input = true] +@test isinput(u) + +@named sys = System([D(x) ~ -x + u], t) # both u and x are unbound +@named sys1 = System([D(x) ~ -x + v[1] + v[2]], t) # both v and x are unbound +@named sys2 = System([D(x) ~ -sys.x], t, systems = [sys]) # this binds sys.x in the context of sys2, sys2.x is still unbound +@named sys21 = System([D(x) ~ -sys1.x], t, systems = [sys1]) # this binds sys.x in the context of sys2, sys2.x is still unbound +@named sys3 = System([D(x) ~ -sys.x + sys.u], t, systems = [sys]) # This binds both sys.x and sys.u +@named sys31 = System([D(x) ~ -sys1.x + sys1.v[1]], t, systems = [sys1]) # This binds both sys.x and sys1.v[1] + +@named sys4 = System([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 = mtkcompile(sys, inputs = [u], outputs = []) +@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 = System([D(x) ~ -x, y ~ x], t) # both y and x are unbound +syss = mtkcompile(sys, outputs = [y]) # This makes y an observed variable + +@named sys2 = System([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 = mtkcompile(sys2, outputs = [sys.y]) + +@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 = System(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 = System(eqs, t) + f, dvs, + ps, io_sys = ModelingToolkit.generate_control_function( + sys, [u]; 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 = System(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 = System(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] + System(eqs, t, [pos, vel, y], ps; name) +end + +function MySpring(; name, k = 1e4) + ps = @parameters k = k + @variables x(t) = 0 # Spring deflection + System(Equation[], t, [x], ps; name) +end + +function MyDamper(; name, c = 10) + ps = @parameters c = c + @variables vel(t) = 0 + System(Equation[], t, [vel], ps; name) +end + +function SpringDamper(; name, k = false, c = false) + spring = MySpring(; name = :spring, k) + damper = MyDamper(; name = :damper, c) + compose(System(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 = System(eqs, t) +@named model = compose(_model, mass1, mass2, sd); + +f, dvs, ps, io_sys = ModelingToolkit.generate_control_function( + model, [u]; simplify = true) +@test length(dvs) == 4 +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 = System(eqs, t) +@test_nowarn mtkcompile(sys, inputs = [u], outputs = []) + +#= +## 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 = System(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ]) + end + System(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, 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[1](x0, u, pn, 0) +xp1 = f[1](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 = System(eqs, t) +m_inputs = [u[1], u[2]] +m_outputs = [y₂] +sys_simp = mtkcompile(sys, inputs = m_inputs, outputs = m_outputs) +@test issetequal(unknowns(sys_simp), collect(x[1:2])) +@test length(inputs(sys_simp)) == 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 = System( + [ + 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 = mtkcompile(model) +@test length(unknowns(sys)) == length(equations(sys)) == 1 + +## Disturbance models when plant has multiple inputs +using ModelingToolkit, LinearAlgebra +using ModelingToolkit: DisturbanceModel, 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, 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]) +matrices = ModelingToolkit.reorder_unknowns( + matrices, unknowns(ssys), [ssys.x[1], ssys.x[2], ssys.integrator.x[1]]) +@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 = System(eqs, t) + (; io_sys,) = ModelingToolkit.generate_control_function( + sys, [u]; 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] + @mtkcompile sys = System(eqs, t, [x], [c]) + + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys) + @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 = System(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys, [u]) + 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..5a503be9e4 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 = System(eqs, pins = [F], observed = aliases, name = :lorenz1) +lorenz2 = System(eqs, pins = [F], observed = aliases, name = :lorenz2) + +connections = [lorenz1.F ~ lorenz2.u, + lorenz2.F ~ lorenz1.u] +connected = System(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..4c8c47feab 100644 --- a/test/jacobiansparsity.jl +++ b/test/jacobiansparsity.jl @@ -1,73 +1,141 @@ -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] + @mtkcompile pend = System(eqs, t) + + u0 = [x => 1, y => 0] + prob = ODEProblem( + pend, [u0; [g => 1]], (0, 11.5), 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] + @mtkcompile pend = System(eqs, t) + prob = ODEProblem(pend, [x => 0.0, D(x) => 1.0, g => 1.0], (0.0, 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..ca3d56359f 100644 --- a/test/jumpsystem.jl +++ b/test/jumpsystem.jl @@ -1,155 +1,572 @@ -using ModelingToolkit, DiffEqBase, DiffEqJump, Test, LinearAlgebra +using ModelingToolkit, DiffEqBase, JumpProcesses, Test, LinearAlgebra +using SymbolicIndexingInterface +using Random, StableRNGs, NonlinearSolve +using OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D +using BenchmarkTools 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 -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) +@parameters β γ +@constants h = 1 +@variables S(t) I(t) R(t) +rate₁ = β * S * I * h +affect₁ = [S ~ Pre(S) - 1 * h, I ~ Pre(I) + 1] +rate₂ = γ * I + t +affect₂ = [I ~ Pre(I) - 1, R ~ Pre(R) + 1] +j₁ = ConstantRateJump(rate₁, affect₁) +j₂ = VariableRateJump(rate₂, affect₂) +@named js = JumpSystem([j₁, j₂], t, [S, I, R], [β, γ, h]) +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, 1) 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 ~ Pre(I) * h - 1, R ~ Pre(R) + 1] +j₃ = ConstantRateJump(rate₃, affect₃) +@named js2 = JumpSystem([j₁, j₃], t, [S, I, R], [β, γ, h]) +js2 = complete(js2) +u₀ = [999, 1, 0]; +tspan = (0.0, 250.0); u₀map = [S => 999, I => 1, R => 0] -parammap = [β => .1/1000, γ => .01] -dprob = DiscreteProblem(js2, u₀map, tspan, parammap) -jprob = JumpProblem(js2, dprob, Direct(), save_positions=(false,false)) +parammap = [β => 0.1 / 1000, γ => 0.01] +jprob = JumpProblem(js2, [u₀map; parammap], tspan; aggregator = Direct(), + save_positions = (false, false), rng) +p = parameter_values(jprob) +@test jprob.prob isa DiscreteProblem 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, [u₀map; parammap], tspan; 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], [β, γ, h], observed = obs) +js2b = complete(js2b) +jprob = JumpProblem(js2b, [u₀map; parammap], tspan; aggregator = Direct(), + save_positions = (false, false), rng) +@test jprob.prob isa DiscreteProblem +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, [u₀map; parammap], tspan; aggregator = 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() + +ModelingToolkit.@set! mtintegrator.p = (mtintegrator.p, (1,)) 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))) -dprob = DiscreteProblem(js3, u₀map, tspan, parammap) -jprob = JumpProblem(js3, dprob, Direct()) -m3 = getmean(jprob,Nsims) -@test abs(m-m3)/m < .01 +@named js3 = JumpSystem([maj1, maj2], t, [S, I, R], [β, γ]) +js3 = complete(js3) +jprob = JumpProblem(js3, [u₀map; parammap], tspan; aggregator = Direct(), rng) +@test jprob.prob isa DiscreteProblem +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, [u₀map; parammap], tspan; aggregator = NRM(), rng) +@test jprobb.prob isa DiscreteProblem +m4 = getmean(jprobb, Nsims) +@test abs(m - m4) / m < 0.01 +jprobc = JumpProblem(js3b, [u₀map; parammap], tspan; aggregator = RSSA(), rng) +@test jprobc.prob isa DiscreteProblem +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) +jprob = JumpProblem( + js4, [S => 999, β => 100.0, γ => 0.01], (0, 1000.0); aggregator = Direct(), rng) +@test jprob.prob isa DiscreteProblem +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) +jprob = JumpProblem( + js4, [S => 999, β => 100.0, γ => 0.01], (0, 1000.0); aggregator = Direct(), rng) +@test jprob.prob isa DiscreteProblem 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 ModelingToolkit.NonUniqueSubsystemsError JumpSystem( + [sys1.γ ~ sys2.γ], t, [], [], + systems = [sys1, sys2], name = :foo) +end + +# test if param mapper is setup correctly for callbacks +@testset "Parammapper with callbacks" begin + @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) + jprob = JumpProblem( + js5, [u₀; p], tspan; aggregator = Direct(), save_positions = (false, false), rng) + @test jprob.prob isa DiscreteProblem + @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 +@testset "Observed handling tests" begin + @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) +end + +# test to make sure dep graphs are correct +@testset "Dependency graph tests" begin + # 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; eqs = MT.jumps(js)) + vdeps = variable_dependencies(js; eqs = MT.jumps(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 ~ Pre(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] + +@test_nowarn jp1 = JumpProblem(js1, [u0; ps], tspan; aggregator = Direct()) +@test_nowarn jp2 = JumpProblem(js2, u0, tspan; aggregator = Direct()) +@test_nowarn jp3 = JumpProblem(js3, [u0; ps], tspan; aggregator = Direct()) +@test_nowarn jp4 = JumpProblem(js4, u0, tspan; aggregator = Direct()) + +# Ensure `mtkcompile` (and `@mtkcompile`) works on JumpSystem (by doing nothing) +# Issue#2558 +@parameters k +@variables X(t) +rate = k +affect = [X ~ Pre(X) - 1] + +j1 = ConstantRateJump(k, [X ~ Pre(X) - 1]) +@test_nowarn @mtkcompile js1 = JumpSystem([j1], t, [X], [k]) + +# test correct autosolver is selected, which implies appropriate dep graphs are available +@testset "Autosolver test" begin + @parameters k + @variables X(t) + rate = k + affect = [X ~ Pre(X) - 1] + j1 = ConstantRateJump(k, [X ~ Pre(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) + jprob = JumpProblem(jsys, [X => 10, k => 1], (0.0, 10.0)) + @test jprob.aggregator isa algtype + end +end + +# basic VariableRateJump test +@testset "VRJ test" begin + 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 ~ Pre(A) + 1, C ~ Pre(C) + 2]) + js = complete(JumpSystem([vrj], t, [A, C], [k]; name = :js, observed = [B ~ C * A])) + jprob = JumpProblem( + js, [A => 0, C => 0, k => 1], (0.0, 10.0); aggregator = Direct(), rng) + @test jprob.prob isa ODEProblem + 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 +@testset "`collect_vars!` for jumps" begin + @variables x1(t) x2(t) x3(t) x4(t) x5(t) + @parameters p1 p2 p3 p4 p5 + j1 = ConstantRateJump(p1, [x1 ~ Pre(x1) + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ Pre(x3) + 1, x4 ~ Pre(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 +@testset "Scoping tests" begin + @variables x1(t) x2(t) x3(t) x4(t) + x2 = ParentScope(x2) + x3 = ParentScope(ParentScope(x3)) + x4 = GlobalScope(x4) + @parameters p1 p2 p3 p4 + p2 = ParentScope(p2) + p3 = ParentScope(ParentScope(p3)) + p4 = GlobalScope(p4) + + j1 = ConstantRateJump(p1, [x1 ~ Pre(x1) + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ Pre(x3) + 1, x4 ~ Pre(x4) + 1]) + j4 = MassActionJump(p4 * p4, [x1 => 1, x4 => 1], [x1 => -1, x4 => -1, x2 => 1]) + @named js = JumpSystem([j1, j2, j3, j4], t, [x1, x2, x3, x4], [p1, p2, p3, p4]) + + 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]) + @test issetequal(ps, [p3]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = -1) + @test issetequal(us, [x4]) + @test issetequal(ps, [p4]) +end + +# PDMP test +@testset "PDMP test" begin + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters k1 k2 + vrj1 = VariableRateJump(k1 * X, [X ~ Pre(X) - 1]; save_positions = (false, false)) + vrj2 = VariableRateJump(k1, [Y ~ Pre(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) + jprob = JumpProblem(jsys, [u0; p], tspan; 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 +@testset "ODEs + Jumps + Continuous events" begin + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters α β + vrj = VariableRateJump(β * X, [X ~ Pre(X) - 1]; save_positions = (false, false)) + crj = ConstantRateJump(β * Y, [Y ~ Pre(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) + jprob = JumpProblem(jsys, [u0map; pmap], tspan; 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!(mod, obs, ctx, integ) + savevalues!(integ, true) + terminate!(integ) + (;) + end + cevents = [t ~ 0.2] => (; f = affect!) + @named jsys = JumpSystem([maj, crj, vrj, eqs[1]], t, [X, Y], [α, β]; + continuous_events = cevents) + jsys = complete(jsys) + tspan = (0.0, 200.0) + jprob = JumpProblem(jsys, [u0map; pmap], tspan; 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 ~ Pre(X) + 1] + affect2 = [X ~ Pre(X) - 1] + j1 = ConstantRateJump(rate1, affect1) + j2 = ConstantRateJump(rate2, affect2) + + # Works. + @mtkcompile js = JumpSystem([j1, j2], t, [X], [p, d]) + jprob = JumpProblem( + js, [X => 15, p => 2.0, d => 0.5], (0.0, 10.0); aggregator = Direct(), u0_eltype = Int) + sol = solve(jprob, SSAStepper()) + @test eltype(sol[X]) === Int64 +end + +@testset "Issue#3571: `remake(::JumpProblem)`" begin + @variables X(t) + @parameters a b + eq = D(X) ~ a + rate = b * X + affect = [X ~ Pre(X) - 1] + crj = ConstantRateJump(rate, affect) + @named jsys = JumpSystem([crj, eq], t, [X], [a, b]) + jsys = complete(jsys) + jprob = JumpProblem(jsys, [:X => 1.0, :a => 1.0, :b => 0.5], (0.0, 10.0)) + jprob2 = remake(jprob; u0 = [:X => 10.0]) + @test jprob2[X] ≈ 10.0 +end diff --git a/test/labelledarrays.jl b/test/labelledarrays.jl index 5122cb6183..96c9e1c32b 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 = System(eqs, t) +de = complete(de) +ff = ODEFunction(de; 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..de5c195610 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 = System(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..d472cdb087 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 = System(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 = System(eqs2, t)) + +eqs3 = [D(x) ~ σ * (y - x), + D(y) ~ -z - y, + D(z) ~ y - β * z + 1] + +@test ModelingToolkit.isaffine(@named sys = System(eqs, t)) diff --git a/test/linearize.jl b/test/linearize.jl new file mode 100644 index 0000000000..1e5fd29016 --- /dev/null +++ b/test/linearize.jl @@ -0,0 +1,344 @@ +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 = System(eqs, t) + +lsys, ssys, extras = linearize(sys, [r], [y]) +lprob = LinearizationProblem(sys, [r], [y]) +lsys2, extras2 = 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 +@test extras == extras2 + +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] + System(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] + System(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) + ] + System(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 = System(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, ssys = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) + +lsyss = ModelingToolkit.reorder_unknowns(lsyss, unknowns(ssys), [f.x, 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] + +lsyss0, +ssys2 = ModelingToolkit.linearize_symbolic(pid, [reference.u, measurement.u], + [ctr_output.u]) +lsyss = ModelingToolkit.reorder_unknowns(lsyss0, unknowns(ssys2), desired_order) + +@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, desired_order, 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 +@test_throws ["inputs provided to `mtkcompile`", "not found"] linearize(pid, + [ + pid.reference.u, + pid.measurement.u + ], [ctr_output.u]) +@test_throws ["outputs provided to `mtkcompile`", "not found"] linearize(pid, + [ + reference.u, + measurement.u + ], + [pid.ctr_output.u]) + +## 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)) + ] + System(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, 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 System(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ], + name) + end + System(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 = System(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 = System(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 = System(eqs, t; defaults = [p => 1.0]) + sys = complete(sys) + @test_throws ModelingToolkit.MissingGuessError 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/linearproblem.jl b/test/linearproblem.jl new file mode 100644 index 0000000000..7cba931ae5 --- /dev/null +++ b/test/linearproblem.jl @@ -0,0 +1,187 @@ +using ModelingToolkit +using LinearSolve +using SciMLBase +using StaticArrays +using SparseArrays +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D, SystemCompatibilityError + +@testset "Rejects non-affine systems" begin + @variables x y + @mtkbuild sys = System([0 ~ x^2 + y, 0 ~ x - y]) + @test_throws SystemCompatibilityError LinearProblem(sys, nothing) +end + +@variables x[1:3] [irreducible = true] +@parameters p[1:3, 1:3] q[1:3] + +@mtkbuild sys = System([p * x ~ q]) +# sanity check +@test length(unknowns(sys)) == length(equations(sys)) == 3 +A = Float64[1 2 3; 4 3.5 1.7; 5.2 1.8 9.7] +b = Float64[2, 5, 8] +ps = [p => A, q => b] + +@testset "Basics" begin + # Ensure it works without providing `u0` + prob = LinearProblem(sys, ps) + @test prob.u0 === nothing + @test SciMLBase.isinplace(prob) + @test prob.A ≈ A + @test prob.b ≈ b + @test eltype(prob.A) == Float64 + @test eltype(prob.b) == Float64 + + @test prob.ps[p * q] ≈ A * b + + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test prob.A * sol.u - prob.b≈zeros(3) atol=1e-10 + + A2 = rand(3, 3) + b2 = rand(3) + @testset "remake" begin + prob2 = remake(prob; p = [p => A2, q => b2]) + @test prob2.ps[p] ≈ A2 + @test prob2.ps[q] ≈ b2 + @test prob2.A ≈ A2 + @test prob2.b ≈ b2 + end + + prob.ps[p] = A2 + @test prob.A ≈ A2 + prob.ps[q] = b2 + @test prob.b ≈ b2 + A2[1, 1] = prob.ps[p[1, 1]] = 1.5 + @test prob.A ≈ A2 + b2[1] = prob.ps[q[1]] = 2.5 + @test prob.b ≈ b2 + + @testset "expression = Val{true}" begin + prob3e = LinearProblem(sys, ps; expression = Val{true}) + @test prob3e isa Expr + prob3 = eval(prob3e) + + @test prob3.u0 === nothing + @test SciMLBase.isinplace(prob3) + @test prob3.A ≈ A + @test prob3.b ≈ b + @test eltype(prob3.A) == Float64 + @test eltype(prob3.b) == Float64 + + @test prob3.ps[p * q] ≈ A * b + + sol = solve(prob3) + @test SciMLBase.successful_retcode(sol) + @test prob3.A * sol.u - prob3.b≈zeros(3) atol=1e-10 + end +end + +@testset "With `u0`" begin + prob = LinearProblem(sys, [x => ones(3); ps]) + @test prob.u0 ≈ ones(3) + @test SciMLBase.isinplace(prob) + @test eltype(prob.u0) == Float64 + + # Observed should work + @test prob[x[1] + x[2]] ≈ 2.0 + + @testset "expression = Val{true}" begin + prob3e = LinearProblem(sys, [x => ones(3); ps]; expression = Val{true}) + @test prob3e isa Expr + prob3 = eval(prob3e) + @test prob3.u0 ≈ ones(3) + @test eltype(prob3.u0) == Float64 + end +end + +@testset "SArray OOP form" begin + prob = LinearProblem(sys, SVector{2}(ps)) + @test prob.A isa SMatrix{3, 3, Float64} + @test prob.b isa SVector{3, Float64} + @test !SciMLBase.isinplace(prob) + @test prob.ps[p * q] ≈ A * b + + sol = solve(prob) + # https://github.com/SciML/LinearSolve.jl/issues/532 + @test_broken SciMLBase.successful_retcode(sol) + @test prob.A * sol.u - prob.b≈zeros(3) atol=1e-10 + + A2 = rand(3, 3) + b2 = rand(3) + @testset "remake" begin + prob2 = remake(prob; p = [p => A2, q => b2]) + # Despite passing `Array` to `remake` + @test prob2.A isa SMatrix{3, 3, Float64} + @test prob2.b isa SVector{3, Float64} + @test prob2.ps[p] ≈ A2 + @test prob2.ps[q] ≈ b2 + @test prob2.A ≈ A2 + @test prob2.b ≈ b2 + end + + @testset "expression = Val{true}" begin + prob3e = LinearProblem(sys, SVector{2}(ps); expression = Val{true}) + @test prob3e isa Expr + prob3 = eval(prob3e) + @test prob3.A isa SMatrix{3, 3, Float64} + @test prob3.b isa SVector{3, Float64} + @test !SciMLBase.isinplace(prob3) + @test prob3.ps[p * q] ≈ A * b + + sol = solve(prob3) + # https://github.com/SciML/LinearSolve.jl/issues/532 + @test_broken SciMLBase.successful_retcode(sol) + @test prob3.A * sol.u - prob3.b≈zeros(3) atol=1e-10 + end +end + +@testset "u0_constructor" begin + prob = LinearProblem{false}(sys, ps; u0_constructor = x -> SArray{Tuple{size(x)...}}(x)) + @test prob.A isa SMatrix{3, 3, Float64} + @test prob.b isa SVector{3, Float64} + @test prob.ps[p * q] ≈ A * b +end + +@testset "sparse form" begin + prob = LinearProblem(sys, ps; sparse = true) + @test issparse(prob.A) + @test !issparse(prob.b) + + sol = solve(prob) + # This might end up failing because of + # https://github.com/SciML/LinearSolve.jl/issues/532 + @test SciMLBase.successful_retcode(sol) + + A2 = rand(3, 3) + prob.ps[p] = A2 + @test prob.A ≈ A2 + b2 = rand(3) + prob.ps[q] = b2 + @test prob.b ≈ b2 + + A2 = rand(3, 3) + b2 = rand(3) + @testset "remake" begin + prob2 = remake(prob; p = [p => A2, q => b2]) + @test issparse(prob2.A) + @test !issparse(prob2.b) + @test prob2.ps[p] ≈ A2 + @test prob2.ps[q] ≈ b2 + @test prob2.A ≈ A2 + @test prob2.b ≈ b2 + end + + @testset "expression = Val{true}" begin + prob3e = LinearProblem(sys, ps; sparse = true, expression = Val{true}) + @test prob3e isa Expr + prob3 = eval(prob3e) + @test issparse(prob3.A) + @test !issparse(prob3.b) + + sol = solve(prob3) + # This might end up failing because of + # https://github.com/SciML/LinearSolve.jl/issues/532 + @test SciMLBase.successful_retcode(sol) + end +end 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 deleted file mode 100644 index e690daa082..0000000000 --- a/test/lowering_solving.jl +++ /dev/null @@ -1,76 +0,0 @@ -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))) diff --git a/test/mass_matrix.jl b/test/mass_matrix.jl index fbe9b831bb..82d1cf86a5 100644 --- a/test/mass_matrix.jl +++ b/test/mass_matrix.jl @@ -1,36 +1,62 @@ -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, StaticArrays +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 = System(eqs, t, collect(y), [k]) +sys = complete(sys) +@test_throws ModelingToolkit.OperatorIndepvarMismatchError System(eqs, y[1]) +M = calculate_massmatrix(sys) +@test M isa Diagonal +@test M == [1 0 0 + 0 1 0 + 0 0 0] + +prob_mm = ODEProblem(sys, [y => [1.0, 0.0, 0.0], k => [0.04, 3e7, 1e4]], (0.0, 1e5)) +@test prob_mm.f.mass_matrix isa Diagonal{Float64, Vector{Float64}} +sol = solve(prob_mm, Rodas5(), reltol = 1e-8, abstol = 1e-8) +prob_mm = ODEProblem(sys, SA[y => [1.0, 0.0, 0.0], k => [0.04, 3e7, 1e4]], (0.0, 1e5)) +@test prob_mm.f.mass_matrix isa Diagonal{Float64, SVector{3, Float64}} + +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 = System(eqs, t, collect(y), [k]) + +@test calculate_massmatrix(sys) === I + +@testset "Mass matrix `isa Diagonal` for `SDEProblem`" begin + 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 = System(eqs, t, collect(y), [k]) + @named sys = SDESystem(sys, [1, 1, 0]) + sys = complete(sys) + prob = SDEProblem(sys, [y => [1.0, 0.0, 0.0], k => [0.04, 3e7, 1e4]], (0.0, 1e5)) + @test prob.f.mass_matrix isa Diagonal{Float64, Vector{Float64}} + prob = SDEProblem(sys, SA[y => [1.0, 0.0, 0.0], k => [0.04, 3e7, 1e4]], (0.0, 1e5)) + @test prob.f.mass_matrix isa Diagonal{Float64, SVector{3, Float64}} +end diff --git a/test/model_parsing.jl b/test/model_parsing.jl new file mode 100644 index 0000000000..c48628b007 --- /dev/null +++ b/test/model_parsing.jl @@ -0,0 +1,1047 @@ +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"Ω" +@mtkcompile 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 ~ Pre(x) + 5, z ~ 2] + end + end + + @mtkcompile 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)) == 4 + + # 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.(get_systems(multiple_extend))) == [: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 "Constraints, costs, consolidate" begin + @mtkmodel Example begin + @variables begin + x(t) + y(t) + end + @equations begin + x ~ y + end + @constraints begin + EvalAt(0.3)(x) ~ 3 + y ≲ 4 + end + @costs begin + x + y + EvalAt(1)(y)^2 + end + @consolidate f(u, sub) = u[1]^2 + log(u[2]) + sum(sub; init = 0) + end + + @named ex = Example() + ex = complete(ex) + + costs = ModelingToolkit.get_costs(ex) + constrs = ModelingToolkit.get_constraints(ex) + @test isequal(costs[1], ex.x + ex.y) + @test isequal(costs[2], EvalAt(1)(ex.y)^2) + @test isequal(constrs[1], EvalAt(0.3)(ex.x) ~ 3) + @test isequal(constrs[2], ex.y ≲ 4) + @test ModelingToolkit.get_consolidate(ex)([1, 2], [3, 4]) ≈ 8 + log(2) + @test Example.structure[:constraints] == ["(EvalAt(0.3))(x) ~ 3", "y ≲ 4"] + @test Example.structure[:costs] == ["x + y", "(EvalAt(1))(y) ^ 2"] +end diff --git a/test/modelingtoolkitize.jl b/test/modelingtoolkitize.jl index d943e355df..30cde7c3b7 100644 --- a/test/modelingtoolkitize.jl +++ b/test/modelingtoolkitize.jl @@ -1,64 +1,87 @@ -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, [unknowns(sys) .=> x0; parameters(sys) .=> 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 +94,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 +112,75 @@ 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, [unknowns(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 +189,289 @@ 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] + +@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]) +prob = SDEProblem(complete(sys), nothing, (0.0, 1.0)) +sys = modelingtoolkitize(prob) + +@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 + @mtkcompile sys = System([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 + @mtkcompile nlsys = System([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 + @mtkcompile 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(mtkcompile(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..28ab3759ef --- /dev/null +++ b/test/mtkparameters.jl @@ -0,0 +1,370 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, MTKParameters +using SymbolicIndexingInterface, StaticArrays +using SciMLStructures: SciMLStructures, canonicalize, Tunable, Discrete, Constants +using BlockArrays: BlockedArray, BlockedVector, 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 = System( + [b ~ 2a], t, [], [a, b, c, d, e, f, g, h]; + continuous_events = [ModelingToolkit.SymbolicContinuousCallback( + [a ~ 0] => [c ~ 0], discrete_parameters = c)], 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) +ps_copy = copy(ps) +ps_field_equals = map(fieldnames(typeof(ps))) do f + getfield(ps, f) == getfield(ps_copy, f) +end +@test all(ps_field_equals) +# 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 + +@testset "`p_constructor`" begin + ps2 = MTKParameters(sys, ivs; p_constructor = x -> SArray{Tuple{size(x)...}}(x)) + @test ps2.tunable isa SVector + @test ps2.initials isa SVector + @test ps2.discrete isa Tuple{<:BlockedVector{Float64, <:SVector}} + @test ps2.constant isa Tuple{<:SVector, <:SVector, <:SVector{1, <:SMatrix}} + @test ps2.nonnumeric isa Tuple{<:SVector} +end + +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, ivs, (0.0, 1.0)) + + 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 +@mtkcompile osys = System([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 = System(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] +@mtkcompile sys = System(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; ps], tspan) +@test_nowarn ODEProblem(sys, [u0; ps; [d => 1.0]], tspan) + +# 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 + y0 ~ 2p4] + + sys = mtkcompile(complete(System( + eqs, t, name = :sys))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys, [], (0.0, 3.0)) +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 + y0 ~ 2p4] + + sys = mtkcompile(complete(System( + eqs, t, name = :sys))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys, [], (0.0, 3.0)) +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 + y0 ~ 2p4] + + sys = mtkcompile(complete(System( + eqs, t, name = :sys))) + prob = ODEProblem{true, SciMLBase.FullSpecialize}(sys, [], (0.0, 3.0)) +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] +@mtkcompile odesys = System(eqs, t) +odeprob = ODEProblem( + odesys, [x => 1.0, y => 1.0, α => 1.5, β => 1.0, γ => 3.0, δ => 1.0], (0.0, 10.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 = System(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 = System(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] + ] + @mtkcompile osys_scal = System(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; ps_scal], 1.0) + 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..4cc8ea7296 --- /dev/null +++ b/test/namespacing.jl @@ -0,0 +1,47 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, iscomplete, does_namespacing, + renamespace + +@variables x(t) +@parameters p +sys = System(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"] System( + Equation[], t; systems = [nsys], name = :a) + +@testset "Variables of variables" begin + @variables x(t) y(x) + @named inner = System([D(x) ~ x, y ~ 2x + 1], t) + @test issetequal(unknowns(inner), [x, y]) + ss = mtkcompile(inner) + @test isequal(only(unknowns(ss)), x) + @test isequal(only(observed(ss)), y ~ 2x + 1) + + @named sys = System(Equation[], t; systems = [inner]) + xx, yy = let sys = inner + xx = renamespace(sys, x) + yy = only(@variables y(xx)) + xx, renamespace(sys, yy) + end + @test issetequal(unknowns(sys), [xx, yy]) + ss = mtkcompile(sys) + @test isequal(only(unknowns(ss)), xx) + @test isequal(only(observed(ss)), yy ~ 2xx + 1) +end diff --git a/test/nonlinearsystem.jl b/test/nonlinearsystem.jl index 98923e804a..4c887f5740 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 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 = System(eqs, [x, y, z], [σ, ρ, β, h], defaults = Dict(x => 2)) +ns2 = eval(toexpr(ns)) +@test issetequal(equations(ns), equations(ns2)) +@test issetequal(unknowns(ns), unknowns(ns2)) +@test issetequal(parameters(ns), parameters(ns2)) +test_nlsys_inference("standard", ns, (x, y, z), (σ, ρ, β, h)) +@test begin + f = generate_rhs(ns, expression = Val{false})[2] + du = [0.0, 0.0, 0.0] + f(du, [1, 2, 3], [1, 2, 3, 1]) + 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 = System(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 + +# Intermediate calculations +a = y - x +# Define a nonlinear system +eqs = [0 ~ σ * a * h, + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] +@named ns = System(eqs, [x, y, z], [σ, ρ, β, h]) +ns = complete(ns) +nlsys_func = generate_rhs(ns) +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, [x => 1.0, y => 1.0, z => 1.0, σ => 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, [x => 1.0, y => 1.0, z => 1.0, σ => 1.0, ρ => 1.0, β => 1.0], jac = true) +@test_nowarn solve(prob, NewtonRaphson()) + +@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 -> System(eqs1, [x, y, z, u, F], [σ, ρ, β, h], name = name) +lorenz1 = lorenz(:lorenz1) +@test_throws ArgumentError NonlinearProblem(complete(lorenz1), zeros(4)) +lorenz2 = lorenz(:lorenz2) +@named connected = System( + [s ~ a + lorenz1.x + lorenz2.y ~ s * h + lorenz1.F ~ lorenz2.u + lorenz2.F ~ lorenz1.u], + [s, a], [h], + systems = [lorenz1, lorenz2]) +@test_nowarn alias_elimination(connected) + +# system promotion +using OrdinaryDiffEq +@independent_variables t +D = Differential(t) +@named subsys = convert_system_indepvar(lorenz1, t) +@named sys = System([D(subsys.x) ~ subsys.x + subsys.x], t, systems = [subsys]) +sys = mtkcompile(sys) +u0 = [subsys.x => 1, subsys.z => 2.0, subsys.y => 1.0] +prob = ODEProblem(sys, [u0; [subsys.σ => 1, subsys.ρ => 2, subsys.β => 3]], (0, 1.0)) +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_indepvar(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 = System(eqs, [x, y, z], [σ, ρ, β, h]) +np = NonlinearProblem( + complete(ns), [x => 0, y => 0, z => 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 + + System([0 ~ -a * x + f], [x, f], [a]; name) + end + + function issue819() + sys1 = makesys(:sys1) + sys2 = makesys(:sys1) + @test_throws ModelingToolkit.NonUniqueSubsystemsError System( + [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 = System(eqs1, [x], [a]) + @named sys2 = System(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 = System([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 = System(eq, t) +@test length(equations(mtkcompile(sys))) == 0 + +@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 = System(eqs, [x, y, z], [a, b, c, h], 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 = System(eqs, [x, y, z], []) + ns = complete(ns) + vs = [unknowns(ns); parameters(ns)] + ss_mtk = mtkcompile(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 System(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 = System(eqs, [u1, u2], [u3, u4]) +sys = mtkcompile(ns; fully_determined = false) +@test length(unknowns(sys)) == 1 + +# Conservative +@variables X(t) +alg_eqs = [1 ~ 2X] +@named ns = System(alg_eqs) +sys = mtkcompile(ns) +@test length(equations(sys)) == 0 +sys = mtkcompile(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] + @mtkcompile ns = System(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 + @mtkcompile ns = System(eqs) # solve for y with observed chain z -> y -> x + @test isequal(expand.(calculate_jacobian(ns)), [-3 // 2 - x;;]) + @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 + @mtkcompile sys = System([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 = System(A * x ~ b, [x], []) + sys = mtkcompile(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 = System([x ~ 1, x^2 - p ~ 0]) + for sys in [ + mtkcompile(sys, fully_determined = false), + mtkcompile(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 = System([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 IntervalNonlinearProblem( + sys, (0.0, 2.0), [p => 1.0]; expression = Val{true}) + end + + @variables y + @mtkcompile sys = System([0 ~ x * x - p * x + p, 0 ~ x * y + p]) + @test_throws ["single unknown"] IntervalNonlinearProblem(sys, (0.0, 1.0)) + @test_throws ["single unknown"] IntervalNonlinearFunction(sys) + @test_throws ["single unknown"] IntervalNonlinearProblem( + sys, (0.0, 1.0); expression = Val{true}) + @test_throws ["single unknown"] IntervalNonlinearFunction( + sys; expression = Val{true}) +end + +@testset "Vector parameter used unscalarized and partially scalarized" begin + @variables x y + @parameters p[1:2] (f::Function)(..) + + @mtkcompile sys = System([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 `System`" begin + @variables x(t) y(t) + @parameters p q r + @named sys = System([D(x) ~ p * x^3 + q, 0 ~ -y + q * x - r, r ~ 3p], t; + defaults = [x => 1.0, p => missing], guesses = [p => 1.0], + initialization_eqs = [p^3 + q^3 ~ 4r]) + nlsys = NonlinearSystem(sys) + nlsys = complete(nlsys) + defs = defaults(nlsys) + @test length(defs) == 6 + @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 = mtkcompile(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 = System([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 + +@testset "oop `NonlinearLeastSquaresProblem` with `u0 === nothing`" begin + @variables x y + @named sys = System([0 ~ x - y], [], []; observed = [x ~ 1.0, y ~ 1.0]) + prob = NonlinearLeastSquaresProblem{false}(complete(sys), nothing) + sol = solve(prob) + resid = sol.resid + @test resid == [0.0] + @test resid isa Vector + prob = NonlinearLeastSquaresProblem{false}( + complete(sys), nothing; u0_constructor = splat(SVector)) + sol = solve(prob) + resid = sol.resid + @test resid == [0.0] + @test resid isa SVector +end + +@testset "`ProblemTypeCtx`" begin + @variables x + @mtkcompile sys = System( + [0 ~ x^2 - 4x + 4]; metadata = [ModelingToolkit.ProblemTypeCtx => "A"]) + prob = NonlinearProblem(sys, [x => 1.0]) + @test prob.problem_type == "A" +end diff --git a/test/odesystem.jl b/test/odesystem.jl index 2ce0868b28..9d1b2fc1e8 100644 --- a/test/odesystem.jl +++ b/test/odesystem.jl @@ -1,331 +1,1616 @@ using ModelingToolkit, StaticArrays, LinearAlgebra +using ModelingToolkit: get_metadata, MTKParameters, SymbolicDiscreteCallback, + SymbolicContinuousCallback +using SymbolicIndexingInterface using OrdinaryDiffEq, Sundials using DiffEqBase, SparseArrays using StaticArrays using Test - +using SymbolicUtils.Code +using SymbolicUtils: Sym, issym +using ForwardDiff using ModelingToolkit: value +using ModelingToolkit: t_nounits as t, D_nounits as D +using Symbolics +using Symbolics: unwrap +using DiffEqBase: isinplace # 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)) -@test eval(toexpr(de)) == de +@named de = System(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] = System(eqs, t) +@test length(unique(x -> ModelingToolkit.get_tag(x), des)) == 1 -generate_function(de) +de2 = eval(toexpr(de)) +@test issetequal(equations(de2), eqs) +@test issetequal(unknowns(de2), unknowns(de)) +@test issetequal(parameters(de2), parameters(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)))) + @testset "System construction: $name" begin + @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], [σ,ρ,β]) +test_diffeq_inference("standard", de, t, [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, 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; 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(ODEFunction(de; expression = Val{true})) +f2 = ODEFunction(de) +@test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) +@test SciMLBase.specialization(f) === SciMLBase.specialization(f2) +for iip in (true, false) + f = eval(ODEFunction{iip}(de; expression = Val{true})) + f2 = ODEFunction{iip}(de) + @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(ODEFunction{iip, specialize}(de; expression = Val{true})) + f2 = ODEFunction{iip, specialize}(de) + @test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) === iip + @test SciMLBase.specialization(f) === SciMLBase.specialization(f2) === specialize end end -# Conversion to first-order ODEs #17 -D3 = Differential(t)^3 -D2 = Differential(t)^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) -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] +#check sparsity +f = eval(ODEFunction(de, sparsity = true, expression = Val{true})) +@test f.sparsity == ModelingToolkit.jacobian_sparsity(de) -#@test de1 == ODESystem(lowered_eqs) +f = eval(ODEFunction(de, sparsity = false, expression = Val{true})) +@test isnothing(f.sparsity) -# issue #219 -@test all(isequal.([ModelingToolkit.var_from_nested_derivative(eq.lhs)[1] for eq in equations(de1)], states(ODESystem(lowered_eqs)))) +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y * t, + D(z) ~ x * y - β * z * κ] +@named de = System(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] -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) -@test du == [5.0, 3.0, 1.0, 1.0, 1.0] +@parameters (σ::Function)(..) +eqs = [D(x) ~ σ(t - 1) * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z * κ] +@named de = System(eqs, t, [x, y, z], [σ, ρ, β, κ]) +test_diffeq_inference("single internal iv-varying", de, t, (x, y, z), (σ, ρ, β, κ)) +f = generate_rhs(de, expression = Val{false}, wrap_gfw = Val{true}) +du = [0.0, 0.0, 0.0] +f(du, [1.0, 2.0, 3.0], [x -> x + 7, 2, 3, 1], 5.0) +@test du ≈ [11, -3, -7] + +eqs = [D(x) ~ x + 10σ(t - 1) + 100σ(t - 2) + 1000σ(t^2)] +@named de = System(eqs, t) +test_diffeq_inference("many internal iv-varying", de, t, (x,), (σ,)) +f = generate_rhs(de, expression = Val{false}, wrap_gfw = Val{true}) +du = [0.0] +f(du, [1.0], [t -> t + 2], 5.0) +@test du ≈ [27561] # 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 = System(eqs, t) 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)) -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 = System(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_rhs(de, expression = Val{false}, wrap_gfw = Val{true}) + du = [0.0, 0.0] + f(du, [1.0, 2.0], [1, 2, 3], 0.0) + du ≈ [-1, -1 / 3] + 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 = System(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] -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) +p = [k₁ => 0.04, + k₃ => 1e4] +p2 = [k₁ => 0.04, + k₂ => 3e7, + k₃ => 1e4] +tspan = (0.0, 100000.0) +prob1 = ODEProblem(sys, [u0; p], tspan) +@test prob1.f.sys == sys +prob12 = ODEProblem(sys, [u0; [k₁ => 0.04, k₂ => 3e7, k₃ => 1e4]], tspan) +prob13 = ODEProblem(sys, [u0; [k₁ => 0.04, k₂ => 3e7, k₃ => 1e4]], tspan) +prob14 = ODEProblem(sys, [u0; p2], tspan) 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 + System([D(x) ~ -a * x], t; name) + end + + function makecombinedsys() + sys1 = makesys(:sys1) + sys2 = makesys(:sys2) + @parameters b = 1.0 + complete(System(Equation[], t, [], [b]; systems = [sys1, sys2], name = :foo)) + end + + sys = makecombinedsys() + @unpack sys1, b = sys + prob = ODEProblem(sys, Pair[], (0.0, 1.0)) + 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; p], tspan, jac = true) +prob3 = ODEProblem(sys, [u0; p], tspan, jac = true, sparse = true) #SparseMatrixCSC need to handle +@test prob3.f.jac_prototype isa SparseMatrixCSC +prob3 = ODEProblem(sys, [u0; p], tspan, jac = true, sparsity = true) +@test prob3.f.sparsity isa SparseMatrixCSC +@test_throws ArgumentError ODEProblem(sys, zeros(5), tspan) 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 - ] -prob4 = DAEProblem(sys, du0, u0, tspan, p2) -prob5 = eval(DAEProblemExpr(sys, du0, u0, tspan, p2)) + D(y₃) => 0.0] +prob4 = DAEProblem(sys, [du0; u0; p2], tspan) +prob5 = eval(DAEProblem(sys, [du0; u0; p2], tspan; expression = Val{true})) 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])) -@test all(isequal.(parameters(sys), [σ, β])) +eqs = [D(x) ~ σ * (y - x), + D(y) ~ x - β * y, + x + z ~ y] +@named sys = System(eqs, t) +@test issetequal(unknowns(sys), [x, y, z]) +@test issetequal(parameters(sys), [σ, β]) @test equations(sys) == eqs @test ModelingToolkit.isautonomous(sys) -# issue 701 -using ModelingToolkit -@parameters t a -@variables x(t) -D = Differential(t) -sys = ODESystem([D(x) ~ a]) -@test equations(sys)[1].rhs isa Sym - -# issue 708 -@parameters t 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], []) -sys2 = ode_order_lowering(sys) -M = ModelingToolkit.calculate_massmatrix(sys2) -@test M == Diagonal([1, 0, 0]) +@testset "Issue#701: `collect_vars!` handles non-call symbolics" begin + @parameters a + @variables x(t) + @named sys = System([D(x) ~ a], t) + @test issym(equations(sys)[1].rhs) +end # 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 = System(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 +# one equation System test +@parameters r @variables x(t) -D = Differential(t) -eq = D(x) ~ r*x -ode = ODESystem(eq) +eq = D(x) ~ r * x +@named ode = System(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) + System([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 ModelingToolkit.NonUniqueSubsystemsError System( + [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 System(eqs, t, vars, pars, name = :foo) + +#Issue 1063/998 +pars = [t] +vars = @variables((u1(t),)) +@test_throws ArgumentError System(eqs, t, vars, pars, name = :foo) + +@parameters w +der = Differential(w) +eqs = [ + der(u1) ~ t +] +@test_throws ArgumentError ModelingToolkit.System(eqs, t, vars, pars, name = :foo) + +# check_eqs_u0 kwarg test +@variables x1(t) x2(t) +eqs = [D(x1) ~ -x1] +@named sys = System(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) +@testset "Issue#1109" begin + @variables x(t)[1:3, 1:3] + @named sys = System(D(x) ~ x, t) + @test_nowarn mtkcompile(sys) end -@variables x(t) -D = Differential(t) -@parameters M b k -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]) -sys = ode_order_lowering(sys) -prob = ODEProblem(sys, [], tspan) +# 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 = System(eqs, t, sts, ps) +sys = mtkcompile(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) + System(D(y) ~ sum(A) * y, t; name = name) +end + +# Build system +@named sys1 = submodel() +@named sys2 = submodel() + +@named sys = System([0 ~ sys1.y + sys2.y], t; systems = [sys1, sys2]) + +# 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 = System(eqs, t, [x; ms], []) +@named emptysys = System(Equation[], t) +@mtkcompile 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 = System(eqs, t) +@named emptysys = System(Equation[], t) +@mtkcompile outersys = compose(emptysys, sys) +prob = ODEProblem( + outersys, [sys.x => 1.0, sys.ms => 1:3, sys.p => ones(3, 3)], (0.0, 1.0)) +@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 = System([D(x) ~ x / x], t) +@test equations(alias_elimination(sys)) == [D(x) ~ 1] + +# observed variable handling +@variables x(t) RHS(t) +@parameters τ +@named fol = System([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 = System(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 = System(eqs, t, [x, y, z], [α, β]) +sys = complete(sys) +@test_throws Any ODEFunction(sys) + +@testset "Preface tests" begin + 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 = System(eqs, t, us, ps; defaults = defs, preface = preface) + sys = complete(sys) + # don't build initializeprob because it will use preface in other functions and + # affect `c` + prob = ODEProblem(sys, [], (0.0, 1.0); build_initializeprob = false) + 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 = System(eqs, t, vcat(x, [y]), [k], defaults = Dict(x .=> 0)) + sys = mtkcompile(sys) + + u0 = x .=> [0.5, 0] + du0 = D.(x) .=> 0.0 + prob = DAEProblem(sys, [du0; u0], (0, 50)) + @test prob[x] ≈ [0.5, 0.0] + @test prob.du0 ≈ [0.0, 0.0] + @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, x[1] => 0.5], + (0, 50)) + + @test prob[x] ≈ [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, x[1] => 0.5, k => 2], + (0, 50)) + @test prob[x] ≈ [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[], (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 = System(eqs, t) + sys = complete(sys) + ivmap = [A => 1.0, k1 => 1.0, k2 => 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(sys, ivmap, tspan; tofloat = false) + @test prob.p isa MTKParameters + @test prob.ps[k1] ≈ 1.0 + @test prob.ps[k2] == 1 && prob.ps[k2] isa 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 = System(eqs, t) + @test length(equations(mtkcompile(sys))) == 2 +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 = System(eqs, t, vars, pars) + @test_throws ModelingToolkit.ExtraEquationsSystemException mtkcompile(sys) +end + +# 1561 +let + vars = @variables x y + arr = ModelingToolkit.varmap_to_vars( + Dict([x => 0.0, y => [0.0, 1.0]]), vars; use_union = true) #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 = System(eqs, t, u, ps) + @test_nowarn simpsys = mtkcompile(sys) + + sys = mtkcompile(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 = System(eqs, t) + osys = complete(osys) + oprob = ODEProblem(osys, [A => 1.0, k => 1.0], (0.0, 10.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 + + System([D(x) ~ dx], t, vars, []; name, defaults = [D(x) => x]) + end + + function sys2(; name) + @named s1 = sys1() + + System(Equation[], t, [], []; systems = [s1], name) + end + + s1′ = sys1(; name = :s1) + @named s2 = sys2() + @unpack s1 = s2 + @test isequal(unknowns(s1), unknowns(s1′)) + @test isequal(parameters(s1), parameters(s1′)) + @test isequal(equations(s1), equations(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] + System(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] + System(eqs, t; name) + end + + @named sys = double_int() + + ## connections + + connections = [sys.u ~ ctrl.u, ctrl.x ~ sys.x, ctrl.v ~ sys.v] + + @named connected = System(connections, t) + @named sys_con = compose(connected, sys, ctrl) + + sys_simp = mtkcompile(sys_con) + true_eqs = [D(sys.x) ~ sys.v + D(sys.v) ~ ctrl.kv * sys.v + ctrl.kx * sys.x] + @test issetequal(full_equations(sys_simp), true_eqs) +end + +let + @variables x(t) = 1 + @variables y(t) = 1 + @parameters pp = -1 + @named sys4 = System([D(x) ~ -y; D(y) ~ 1 + pp * y + x], t) + sys4s = mtkcompile(sys4) + prob = ODEProblem(sys4s, [x => 1.0, D(x) => 1.0], (0, 1.0)) + @test issetequal(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 + +@variables P(t)=NaN Q(t)=NaN +eqs = [D(Q) ~ 1 / sin(P), D(P) ~ log(-cos(Q))] +@named sys = System(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 = System([der(x) ~ -y; der(y) ~ 1 + pp * y + x], t) + sys4s = mtkcompile(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 = System(Equation[], t, vars_sub1, []) + +vars1 = @variables x1(t) +@named sys1 = System(Equation[], t, vars1, [], systems = [sub]) +@named sys2 = System(Equation[], t, vars1, [], systems = [sys1, sub]) + +# SYS 2: Extension to SYS 1 +vars_sub2 = @variables s2(t) +@named partial_sub = System(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 = System(eqs, t; name = :kjshdf) + + sys_simp = mtkcompile(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] +@mtkcompile sys = System(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 +@testset "Arrays in affect/condition equations" begin + @variables x(t)[1:3] + @parameters p[1:3, 1:3] + eqs = [ + D(x) ~ p * x + ] + @mtkcompile sys = System( + 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), p => ones(3, 3)], (0.0, 10.0)) + sol1 = @test_nowarn solve(prob1, Tsit5()) + + # array condition equations also used to not work + @mtkcompile sys = System( + 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), p => ones(3, 3)], (0.0, 10.0)) + sol2 = @test_nowarn solve(prob2, Tsit5()) + + @test sol1.u ≈ sol2.u[2:end] +end + +# 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 @mtkcompile sys = System([D(x) ~ p * x, D(y) ~ x' * p * x], t) + @test_nowarn ODEProblem(sys, [x => ones(3), y => 2, p => ones(3, 3)], (0.0, 10.0)) +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 = System(eqs, t) +pend = complete(pend) +@test_nowarn generate_initializesystem( + pend; op = [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] + +@mtkcompile sys = System(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; p], (0.0, 10.0))) +@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] + ] + System(eqs, t; systems, name) +end + +@mtkcompile 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 = System(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] + System(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 = System(Equation[], t, [x], []; guesses = [x => 1.0]) +@named outer = System( + [D(y) ~ sys.x + t, 0 ~ t + y - sys.x * y], t, [y], []; systems = [sys]) +@test ModelingToolkit.guesses(outer)[sys.x] == 1.0 +outer = mtkcompile(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 = System(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = System(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 = System(Equation[], t, [x], []) +sys1 = complete(sys) +@named sys = System(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 = System([x[0] ~ 0.0, D(x[1]) ~ x[0]], t, [x], []) + @test_nowarn sys = mtkcompile(sys) + @test equations(sys) == [D(x[1]) ~ 0.0] +end + +# Namespacing of array variables +@testset "Namespacing of array variables" begin + @variables x(t)[1:2] + @named sys = System(Equation[], t) + @test getname(unknowns(sys, x)) == :sys₊x + @test size(unknowns(sys, x)) == size(x) +end + +# Issue#2667 and Issue#2953 +@testset "ForwardDiff through ODEProblem constructor" begin + @parameters P + @variables x(t) + sys = mtkcompile(System([D(x) ~ P], t, [x], [P]; name = :sys)) + + function x_at_1(P) + prob = ODEProblem(sys, [x => P, sys.P => P], (0.0, 1.0)) + 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 = mtkcompile(System([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 = System([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 @mtkcompile sys = MyModel() + + @variables x y(x) + @test_logs (:warn,) @named sys = System([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 = System(eqs, T; initialization_eqs, guesses) + prob2 = ODEProblem(mtkcompile(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(Int => 1) + B = Dict(String => 2) + @named A1 = System(Equation[], t, [], []) + @named B1 = System(Equation[], t, [], []) + @named A2 = System(Equation[], t, [], []; metadata = A) + @named B2 = System(Equation[], t, [], []; metadata = B) + n_core_metadata = length(ModelingToolkit.get_metadata(A1)) + @test length(ModelingToolkit.get_metadata(extend(A1, B1))) == n_core_metadata + meta = ModelingToolkit.get_metadata(extend(A1, B2)) + @test length(meta) == n_core_metadata + 1 + @test meta[String] == 2 + meta = ModelingToolkit.get_metadata(extend(A2, B1)) + @test length(meta) == n_core_metadata + 1 + @test meta[Int] == 1 + meta = ModelingToolkit.get_metadata(extend(A2, B2)) + @test length(meta) == n_core_metadata + 2 + @test meta[Int] == 1 + @test meta[String] == 2 +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 = System(eqs, t; defaults) + ssys = mtkcompile(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 = System(eqs, t; defaults) + ssys = mtkcompile(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 = System( + [D(u) ~ (sum(u) + sum(x) + sum(p) + sum(o)) * x, o ~ prod(u) * x], + t, [u..., x..., o...], [p...]) + sys1 = mtkcompile(sys, inputs = [x...], outputs = []) + fn1, = ModelingToolkit.generate_rhs(sys1; expression = Val{false}) + ps = MTKParameters(sys1, [x => 2ones(2), p => 3ones(2, 2)]) + @test_nowarn fn1(ones(4), ps, 4.0) + sys2 = mtkcompile(sys, inputs = [x...], outputs = [], split = false) + fn2, = ModelingToolkit.generate_rhs(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, merge(p, Dict(u0)), (0.0, 1.0)) + + # evaluate + u0_v = prob.u0 + p_v = prob.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 = System([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 = System([D(x) ~ y + p2, p2 ~ 2p1], t; + defaults = [p1 => 1.0, p2 => 2.0], guesses = [p1 => 2.0, p2 => 3.0]) + @parameters p3 + sys2 = substitute(sys, [p1 => p3]) + sys2 = complete(sys2) + @test length(parameters(sys2)) == 1 + @test is_parameter(sys2, p3) + @test !is_parameter(sys2, p1) + @test length(ModelingToolkit.defaults(sys2)) == 7 + @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 = System([D(x) ~ y + p2; p2 ~ 2p1], t; + defaults = [p1 => 1.0, p2 => 2.0], guesses = [p1 => 2.0, p2 => 3.0]) + @parameters p3 p4 + @named outersys = System( + [D(innersys.y) ~ innersys.y + p4, p4 ~ 3p3], t; + defaults = [p3 => 3.0, p4 => 9.0], guesses = [p4 => 10.0], systems = [innersys]) + @test_nowarn mtkcompile(outersys) + @parameters p5 + sys2 = substitute(outersys, [p4 => p5]) + sys2 = complete(sys2) + @test_nowarn mtkcompile(sys2) + @test length(equations(sys2)) == 2 + @test length(parameters(sys2)) == 2 + @test length(full_parameters(sys2)) == 10 + @test all(!isequal(p4), full_parameters(sys2)) + @test any(isequal(p5), full_parameters(sys2)) + @test length(ModelingToolkit.defaults(sys2)) == 10 + @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 = System(eqs, t, [u..., x..., o], [p...]) + sys1 = mtkcompile(sys, inputs = [x...], outputs = [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 + @mtkcompile sys = System(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 = System(D(x) ~ x, t) + @test !ModelingToolkit.is_dde(sys) + @test is_markovian(sys) + @named sys2 = System(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 = System(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)", "mtkcompile", "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 = System(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)", "mtkcompile", "scalarize"] ODEProblem( + sys, [], (0.0, 1.0)) + end + end + end +end + +@testset "Parameter dependencies with constant RHS" begin + @parameters p + @test_nowarn System([p ~ 1.0], t; 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 System(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 + @mtkcompile sys = System(D(x) ~ sum(p) * x + q * t, t) + prob = ODEProblem(sys, [x => 1.0, p => ones(2), q => 2], (0.0, 1.0)) + 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 + @mtkcompile sys = System(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) + @mtkcompile sys = System([D(x) ~ c * cos(x), obs ~ c], + t, + [x, obs], + [c]; + discrete_events = [SymbolicDiscreteCallback( + 1.0 => [c ~ Pre(c) + 1], discrete_parameters = [c])]) + prob = ODEProblem(sys, [x => 0.0, c => 1.0], (0.0, 2pi)) + 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] + @mtkcompile sys = System([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] + @mtkcompile sys = System( + [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] + @mtkcompile sys = System([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 ModelingToolkit.ContinuousOperatorDiscreteArgumentError @mtkcompile osys = System( + [eq], t) + @variables Y(t)[1:3]::String + eq = D(Y) ~ [p, p, p] + @test_throws ModelingToolkit.ContinuousOperatorDiscreteArgumentError @mtkcompile osys = System( + [eq], t) + + @variables X(t)::Complex + eqs = D(X) ~ p - d * X + @test_nowarn @named osys = System(eqs, t) +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. + @mtkcompile sys = System(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] + @mtkcompile sys = System(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [a, e, t_c]) + + @parameters g(..) h i + cons = [g(h, i) * x(3) ~ c] + @mtkcompile sys = System(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 @mtkcompile sys = System(eqs, t; constraints = cons) + + cons = [x(y(t)) ~ 2] # unknown arg must be parameter, value, or t + @test_throws ArgumentError @mtkcompile sys = System(eqs, t; constraints = cons) + + @variables u(t) v + cons = [x(t) * u ~ 3] + @test_throws ArgumentError @mtkcompile sys = System(eqs, t; constraints = cons) + cons = [x(t) * v ~ 3] + @test_throws ArgumentError @mtkcompile sys = System(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]] + @mtkcompile ode = System(D(x(t)) ~ mat * x(t), t; constraints = cons) + @test length(constraints(ode)) == 1 +end + +@testset "`build_explicit_observed_function` with `expression = true` returns `Expr`" begin + @variables x(t) + @mtkcompile sys = System(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 + +@testset "Solve with `split=false` static arrays" 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] + + @mtkcompile sys=System(eqs, t) split=false + + u0 = SA[D(x) => 2.0f0, + x => 1.0f0, + y => 0.0f0, + z => 0.0f0] + + p = SA[σ => 28.0f0, + ρ => 10.0f0, + β => 8.0f0 / 3.0f0] + + tspan = (0.0f0, 100.0f0) + prob = ODEProblem{false}(sys, [u0; p], tspan) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "`@named` always wraps in `ParentScope`" begin + function SysA(; name, var1) + @variables x(t) + scope = ModelingToolkit.getmetadata(unwrap(var1), ModelingToolkit.SymScope, nothing) + @test scope isa ParentScope + @test scope.parent isa ParentScope + @test scope.parent.parent isa LocalScope + return System(D(x) ~ var1, t; name) + end + function SysB(; name, var1) + @variables x(t) + @named subsys = SysA(; var1) + return System(D(x) ~ x, t; systems = [subsys], name) + end + function SysC(; name) + @variables x(t) + @named subsys = SysB(; var1 = x) + return System(D(x) ~ x, t; systems = [subsys], name) + end + @mtkcompile sys = SysC() + @test length(unknowns(sys)) == 3 +end + +@testset "`full_equations` doesn't recurse infinitely" begin + code = """ + using ModelingToolkit + using ModelingToolkit: t_nounits as t, D_nounits as D + @variables x(t)[1:3]=[0,0,1] + @variables u1(t)=0 u2(t)=0 + y₁, y₂, y₃ = x + 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 = System(eqs, t) + + inputs = [u1, u2] + outputs = [y₁, y₂, y₃] + ss = mtkcompile(sys; inputs) + full_equations(ss) + """ + + cmd = `$(Base.julia_cmd()) --project=$(@__DIR__) -e $code` + proc = run(cmd, stdin, stdout, stderr; wait = false) + sleep(120) + @test !process_running(proc) + kill(proc, Base.SIGKILL) +end + +@testset "`ProblemTypeCtx`" begin + @variables x(t) + @mtkcompile sys = System( + [D(x) ~ x], t; metadata = [ModelingToolkit.ProblemTypeCtx => "A"]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + @test prob.problem_type == "A" +end + +@testset "`substitute` retains events and metadata" begin + @parameters p(t) = 1.0 + @variables x(t) = 0.0 + event = [0.5] => [p ~ Pre(t)] + event2 = [x ~ 0.75] => [p ~ 2 * Pre(t)] + + struct TestMeta end + + eq = [ + D(x) ~ p + ] + @named sys = System(eq, t, [x], [p], discrete_events = [event], + continuous_events = [event2], metadata = Dict(TestMeta => "test")) + + @variables x2(t) = 0.0 + sys2 = substitute(sys, [x => x2]) + + @test length(ModelingToolkit.get_discrete_events(sys)) == 1 + @test length(ModelingToolkit.get_discrete_events(sys2)) == 1 + @test length(ModelingToolkit.get_continuous_events(sys)) == 1 + @test length(ModelingToolkit.get_continuous_events(sys2)) == 1 + @test getmetadata(sys, TestMeta, nothing) == "test" + @test getmetadata(sys2, TestMeta, nothing) == "test" +end + +struct TestWrapper + sys::ODESystem +end + +@testset "`ODESystem` is a type" begin + @variables x(t) + @named sys = ODESystem(D(x) ~ x, t) + @test sys isa ODESystem + @test sys isa System + arr = ODESystem[] + @test_nowarn push!(arr, sys) + @test_nowarn TestWrapper(sys) +end diff --git a/test/optimizationsystem.jl b/test/optimizationsystem.jl index 052c21177f..e78ebfe9ee 100644 --- a/test/optimizationsystem.jl +++ b/test/optimizationsystem.jl @@ -1,56 +1,391 @@ -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 + +@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_cost_gradient(combinedsys) + calculate_cost_hessian(combinedsys) + generate_cost(combinedsys) + generate_cost_gradient(combinedsys) + generate_cost_hessian(combinedsys) + hess_sparsity = ModelingToolkit.cost_hessian_sparsity(sys1) + sparse_prob = OptimizationProblem(complete(sys1), + [x => 1, y => 1, 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 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 = mtkcompile(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[[x, z]]≈[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[[x, z]]≈[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) + @test_broken 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(cost(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(cost(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 "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) + + 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 + @mtkcompile 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] + @mtkcompile 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]] + @mtkcompile 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) + @mtkcompile 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..debdeac6e5 --- /dev/null +++ b/test/parameter_dependencies.jl @@ -0,0 +1,391 @@ +using ModelingToolkit +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D, SymbolicDiscreteCallback, + SymbolicContinuousCallback +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(t)=1.0 p2 + @variables x(t) + cb1 = SymbolicContinuousCallback([x ~ 2.0] => [p1 ~ 2.0], discrete_parameters = [p1]) # triggers at t=-2+√6 + function affect1!(mod, obs, ctx, integ) + return (; p1 = obs.p2) + end + cb2 = [x ~ 4.0] => (f = affect1!, observed = (; p2), modified = (; p1)) # triggers at t=-2+√7 + cb3 = SymbolicDiscreteCallback([1.0] => [p1 ~ 5.0], discrete_parameters = [p1]) + + @mtkcompile sys = System( + [D(x) ~ p1 * t + p2, p2 ~ 2p1], + t; + 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, p1 => 1.0], (0.0, 1.5), 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 = System( + [D(x) ~ sum(p1) * t + sum(p2), p2 ~ 2p1], + t + ) + prob = ODEProblem(complete(sys), [], (0.0, 1.0)) + 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 + + @mtkcompile sys1 = System( + [D(x) ~ p1 * t + p2], + t + ) + @named sys2 = System( + [p2 ~ 2p1], + t + ) + sys = complete(extend(sys2, sys1)) + @test !(p2 in Set(parameters(sys))) + @test p2 in Set(full_parameters(sys)) + prob = ODEProblem(complete(sys), nothing, (0.0, 1.0)) + 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 = System( + [D(x) ~ p1 * t + p2, p2 ~ 2p1], + t + ) + prob = ODEProblem(complete(sys), nothing, (0.0, 1.0)) + 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 = System( + [D(x) ~ sum(p1) * t + sum(p2), p2 ~ 2p1], + t + ) + prob = ODEProblem(complete(sys), [], (0.0, 1.0)) + 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 = System( + [D(x) ~ p1 * t + p2], + t + ) + @named sys2 = System( + [D(x) ~ p1 * t - p2, p2 ~ 2p1], + t + ) + sys = complete(System(Equation[], t, systems = [sys1, sys2], name = :sys)) + + prob = ODEProblem(sys, [], (0.0, 1.0)) + 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(sys)) + @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] + System(eqs, t, [x], [p2]; name) + end + + @parameters p1 = 1.0 + parameter_dependencies = [] + sys1 = System( + [sys2.p2 ~ p1 * 2.0], t, [], [p1]; name = :sys1, systems = [sys2]) + + # ensure that parameter_dependencies is type stable + # (https://github.com/SciML/ModelingToolkit.jl/pull/2978) + sys = complete(sys1) + @inferred ModelingToolkit.parameter_dependencies(sys) + + sys = mtkcompile(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, i ~ CallableFoo(p)] + @named model = System(eqs, t, [y], [p, i]) + sys = mtkcompile(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(t) 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) + kq ~ 2kp] + @test_throws ModelingToolkit.HybridSystemNotSupportedException @mtkcompile sys = System( + eqs, t) + + @test_skip begin + Tf = 1.0 + prob = ODEProblem(sys, + [x => 0.0, y => 0.0, kp => 1.0, z(k - 1) => 3.0, + yd(k - 1) => 0.0, z(k - 2) => 4.0, yd(k - 2) => 2.0], + (0.0, Tf)) + @test_nowarn solve(prob, Tsit5()) + + @mtkcompile sys = System(eqs, t; + discrete_events = [SymbolicDiscreteCallback( + [0.5] => [kp ~ 2.0], discrete_parameters = [kp])]) + prob = ODEProblem(sys, + [x => 0.0, y => 0.0, kp => 1.0, z(k - 1) => 3.0, + yd(k - 1) => 0.0, z(k - 2) => 4.0, yd(k - 2) => 2.0], + (0.0, Tf)) + @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, kp => 1.0, z(k - 1) => 3.0, + yd(k - 1) => 0.0, z(k - 2) => 4.0, yd(k - 2) => 2.0], + (0.0, Tf)) + 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 σ(t) ρ β + @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 = System(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, σ => 10.0, β => 2.33], (0.0, 100.0)) + @test prob.ps[ρ] == 2prob.ps[σ] + @test_nowarn solve(prob, SRIW1()) + + @named sys = System(eqs, t) + @named sdesys = SDESystem(sys, noiseeqs; parameter_dependencies = [ρ ~ 2σ], + discrete_events = [SymbolicDiscreteCallback( + [10.0] => [σ ~ 15.0], discrete_parameters = [σ])]) + sdesys = complete(sdesys) + prob = SDEProblem( + sdesys, [x => 1.0, y => 0.0, z => 0.0, σ => 10.0, β => 2.33], (0.0, 100.0)) + 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 β γ(t) + @constants h = 1 + @variables S(t) I(t) R(t) + rate₁ = β * S * I * h + affect₁ = [S ~ Pre(S) - 1 * h, I ~ Pre(I) + 1] + rate₃ = γ * I * h + affect₃ = [I ~ Pre(I) * h - 1, R ~ Pre(R) + 1] + j₁ = ConstantRateJump(rate₁, affect₁) + j₃ = ConstantRateJump(rate₃, affect₃) + @named js2 = JumpSystem( + [j₃, β ~ 0.01γ], t, [S, I, R], [β, γ, h]) + @test issetequal(parameters(js2), [β, γ, h]) + @test Set(full_parameters(js2)) == Set([γ, β, h]) + js2 = complete(js2) + @test issetequal(parameters(js2), [γ, h]) + @test Set(full_parameters(js2)) == Set([γ, β, h]) + tspan = (0.0, 250.0) + u₀map = [S => 999, I => 1, R => 0] + parammap = [γ => 0.01] + jprob = JumpProblem(js2, [u₀map; parammap], tspan; aggregator = 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₃, β ~ 0.01γ], t, [S, I, R], [β, γ, h]; + discrete_events = [SymbolicDiscreteCallback( + [10.0] => [γ ~ 0.02], discrete_parameters = [γ])]) + js2 = complete(js2) + jprob = JumpProblem(js2, [u₀map; parammap], tspan; aggregator = 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, p2 ~ 2p1] + @mtkcompile sys = System(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] + + @mtkcompile sys = System( + [D(x) ~ p1 * t + p2, p2 ~ 2p1], + t + ) + prob = ODEProblem(sys, [x => 1.0, p1 => 1.0], (0.0, 1.5), 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 = System([D(x) ~ y + p2, p2 ~ 2p1], t) + @test is_parameter(sys, p1) + @named sys = System([x * y^2 ~ y + p2, p2 ~ 2p1]) + @test is_parameter(sys, p1) + k = ShiftIndex(t) + @named sys = System( + [x(k - 1) ~ x(k) + y(k) + p2, p2 ~ 2p1], t) + @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..2111f7ba64 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 = System(eqs, t) + de = complete(de) + return ODEFunction(de; 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..351e95f612 100644 --- a/test/print_tree.jl +++ b/test/print_tree.jl @@ -1,22 +1,24 @@ using ModelingToolkit, AbstractTrees, Test -include("../examples/rc_model.jl") +include("common/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 + ├─ shape + │ └─ output + ├─ source + │ ├─ p + │ ├─ n + │ └─ V + └─ 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..e6e7022684 --- /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] + @mtkcompile osys = System(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] + @mtkcompile sys = System(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..6fee37d806 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 - ] - -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 - ]) + u ~ z + a] + +lorenz1 = System(eqs, t, name = :lorenz1) + +lorenz1_aliased = mtkcompile(lorenz1) +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 -> System(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 = System( + [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_system = mtkcompile(connected) +reduced_system2 = mtkcompile(tearing_substitution(mtkcompile(tearing_substitution(mtkcompile(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 - ] -prob1 = ODEProblem(reduced_system, u0, (0.0, 100.0), pp) + lorenz2.z => 0.0] +prob1 = ODEProblem(reduced_system, [u0; pp], (0.0, 100.0)) solve(prob1, Rodas5()) -prob2 = SteadyStateProblem(reduced_system, u0, pp) -@test prob2.f.observed(lorenz2.u, prob2.u0, pp) === 1.0 - +prob2 = SteadyStateProblem(reduced_system, [u0; pp]) +@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 = System([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 = System(Equation[u_c ~ k_P * y_c], t, name = :pc) + connections = [pc.u_c ~ ol.u + pc.y_c ~ ol.y] + @named connected = System(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) + reduced_sys = mtkcompile(connected) + 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], []) - sys = structural_simplify(sys) - @test_throws ModelingToolkit.InvalidSystemException ODEProblem(sys, [1.0], (0, 10.0)) + @named sys = System([0 ~ D(x) + x], t, [x], []) + #@test_throws ModelingToolkit.InvalidSystemException ODEProblem(sys, [1.0], (0, 10.0)) + sys = mtkcompile(sys) + #@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]) -reducedsys = structural_simplify(sys) -@test observed(reducedsys) == [u1 ~ 0.5(u3 - p); u2 ~ u1] - -u0 = [ - u1 => 1 - u2 => 1 - u3 => 0.3 - ] + u3 ~ hypot(u1, u2) * p] +@named sys = System(eqs, [u1, u2, u3], [p]) +reducedsys = mtkcompile(sys) +@test length(observed(reducedsys)) == 2 + +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, []) -sys = structural_simplify(sys′) +A = reshape(1:(N ^ 2), N, N) +eqs = xs ~ A * xs +@named sys′ = System(eqs, [xs], []) +sys = mtkcompile(sys′) +@test length(equations(sys)) == 3 && length(observed(sys)) == 3 # 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) +@named sys = System(eqs, t, [E, C, S, P], [k₁, k₂, k₋₁, E₀]) +@test_throws ModelingToolkit.ExtraEquationsSystemException mtkcompile(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) - ] -@named sys = ODESystem(eqs, t, sts, params) -@test_throws ModelingToolkit.InvalidSystemException structural_simplify(sys) + cos(x) ~ sin(y2)] +@named sys = System(eqs, t, sts, params) +@test_throws ModelingToolkit.InvalidSystemException mtkcompile(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) -sys = structural_simplify(sys0) +@named sys0 = System(eq, t) +sys = mtkcompile(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 = System(eqs, t, name = :lorenz1) +lorenz1_reduced = mtkcompile(lorenz1, inputs = [z], outputs = []) +@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 = System(eqs, t) +sys = mtkcompile(model) +Js = ModelingToolkit.jacobian_sparsity(sys) +@test size(Js) == (3, 3) +@test Js == Diagonal([0, 1, 1]) + +# MWE for #1722 +vars = @variables a(t) w(t) phi(t) +eqs = [a ~ D(w) + w ~ D(phi) + w ~ sin(t)] +@named sys = System(eqs, t, vars, []) +ss = alias_elimination(sys) +@test isempty(observed(ss)) + +@variables x(t) y(t) +@named sys = System([D(x) ~ 1 - x, + D(y) + D(x) ~ 0], t) +new_sys = alias_elimination(sys) +@test isempty(observed(new_sys)) + +@named sys = System([D(x) ~ x, + D(y) + D(x) ~ 0], t) +new_sys = alias_elimination(sys) +@test isempty(observed(new_sys)) + +@named sys = System([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 = System(eqs, t, [x, y], []) +ss = mtkcompile(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 = System(eqs, t, [x], []) +ss = alias_elimination(sys) +@test length(equations(ss)) == length(unknowns(ss)) == 1 +ss = mtkcompile(sys) +@test length(equations(ss)) == length(unknowns(ss)) == 2 diff --git a/test/runtests.jl b/test/runtests.jl index ee6fd60a1d..47230c9539 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 "Change of variables" include("changeofvariables.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 "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 "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("bvproblem.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") + @safetestset "Linearization Tests" include("linearize.jl") + @safetestset "LinearProblem Tests" include("linearproblem.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 Dummy Derivative Tests" include("downstream/linearization_dd.jl") + @safetestset "Inverse Models Test" include("downstream/inversemodel.jl") + @safetestset "Disturbance model 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 "Dynamic Optimization Collocation Solvers" include("extensions/dynamic_optimization.jl") + @safetestset "HomotopyContinuation Extension Test" include("extensions/homotopy_continuation.jl") + @safetestset "LabelledArrays Test" include("labelledarrays.jl") + @safetestset "BifurcationKit Extension Test" include("extensions/bifurcationkit.jl") + @safetestset "InfiniteOpt Extension Test" include("extensions/test_infiniteopt.jl") + @safetestset "Auto Differentiation Test" include("extensions/ad.jl") + end +end diff --git a/test/scc_nonlinear_problem.jl b/test/scc_nonlinear_problem.jl new file mode 100644 index 0000000000..70031ff228 --- /dev/null +++ b/test/scc_nonlinear_problem.jl @@ -0,0 +1,313 @@ +using ModelingToolkit +using NonlinearSolve, SCCNonlinearSolve +using OrdinaryDiffEq +using SciMLBase, Symbolics +using StaticArrays +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 = System(eqs) + @test_throws ["simplified", "required"] SCCNonlinearProblem(model, []) + _model = mtkcompile(model; split = false) + @test_throws ["not compatible"] SCCNonlinearProblem(_model, []) + model = mtkcompile(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] + + sccprob = SCCNonlinearProblem{false}(model, SA[u => zeros(8)]) + for prob in sccprob.probs + @test prob.u0 isa SVector + @test !SciMLBase.isinplace(prob) + end + + # Test BLT sorted + @test istril(StructuralTransformations.sorted_incidence_matrix(model), 2) +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)))) + @mtkcompile sys = System(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) + + # Test BLT sorted + @test istril(StructuralTransformations.sorted_incidence_matrix(sys), 1) +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,)) + @mtkcompile sys = System(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 + + # Test BLT sorted + @test istril(StructuralTransformations.sorted_incidence_matrix(sys), 1) +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) + @mtkcompile sys = System([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 System(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] + + @mtkcompile sys = System(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)(..) + @mtkcompile sys = System([ + 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 σ β ρ + @mtkcompile fullsys = System( + [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)(..) + + @mtkcompile sys = System([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..a91a8d8c7c --- /dev/null +++ b/test/sciml_problem_inputs.jl @@ -0,0 +1,235 @@ +### Prepares Tests ### + +# Fetch packages +using ModelingToolkit, JumpProcesses, NonlinearSolve, OrdinaryDiffEq, StaticArrays, + SteadyStateDiffEq, StochasticDiffEq, SciMLBase, 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 mtkcompile, since that might modify systems to affect intended tests). + osys = complete(System(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(System(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). +@testset "ODE" begin + # Creates normal and ensemble problems. + base_oprob = ODEProblem(osys, [u0_alts[1]; p_alts[1]], tspan) + 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). +@testset "Nonlinear" begin + 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). +@testset "SteadyState" begin + # 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 + +@testset "Deprecations" begin + @variables _x(..) = 1.0 + @parameters p = 1.0 + @brownians a + x = _x(t) + k = ShiftIndex(t) + + @test_deprecated ODESystem([D(x) ~ x * p], t; name = :a) + @mtkcompile odesys = System([D(x) ~ x * p], t) + @mtkcompile sdesys = System([D(x) ~ x * p + a], t) + @test_deprecated NonlinearSystem([0 ~ x^3 + p]; name = :a) + @mtkcompile nlsys = System([0 ~ x^3 + p]) + @mtkcompile ddesys = System([D(x) ~ x * p + _x(t - 0.1)], t) + @mtkcompile sddesys = System([D(x) ~ x * p + _x(t - 0.1) + a], t) + @test_deprecated DiscreteSystem([x ~ x(k - 1) + x(k - 2)], t; name = :a) + @mtkcompile discsys = System([x ~ x(k - 1) * p], t) + @test_deprecated ImplicitDiscreteSystem([x ~ x(k - 1) + x(k - 2) * p * x], t; name = :a) + @mtkcompile idiscsys = System([x ~ x(k - 1) * p * x], t) + @mtkcompile optsys = OptimizationSystem(x^2 + p) + + u0s = [ + Dict(x => 1.0), + [x => 1.0], + [1.0], + [], + nothing + ] + ps = [ + Dict(p => 1.0), + [p => 1.0], + [1.0], + [], + nothing, + SciMLBase.NullParameters() + ] + tspan = (0.0, 1.0) + + @testset "$ctor" for (sys, ctor) in [ + (odesys, ODEProblem), + (odesys, ODEProblem{true}), + (odesys, ODEProblem{true, SciMLBase.FullSpecialize}), (odesys, BVProblem), + (odesys, BVProblem{true}), + (odesys, BVProblem{true, SciMLBase.FullSpecialize}), (sdesys, SDEProblem), + (sdesys, SDEProblem{true}), + (sdesys, SDEProblem{true, SciMLBase.FullSpecialize}), (ddesys, DDEProblem), + (ddesys, DDEProblem{true}), + (ddesys, DDEProblem{true, SciMLBase.FullSpecialize}), (sddesys, SDDEProblem), + (sddesys, SDDEProblem{true}), + (sddesys, SDDEProblem{true, SciMLBase.FullSpecialize}), + + # (discsys, DiscreteProblem), + # (discsys, DiscreteProblem{true}), + # (discsys, DiscreteProblem{true, SciMLBase.FullSpecialize}), + + (idiscsys, ImplicitDiscreteProblem), + (idiscsys, ImplicitDiscreteProblem{true}), + (idiscsys, ImplicitDiscreteProblem{true, SciMLBase.FullSpecialize}) + ] + @testset "$(typeof(u0)) - $(typeof(p))" for u0 in u0s, p in ps + + if u0 isa Vector{Float64} && ctor <: ImplicitDiscreteProblem + u0 = ones(2) + end + @test_warn ["deprecated"] ctor(sys, u0, tspan, p) + end + end + @testset "$ctor" for (sys, ctor) in [ + (nlsys, NonlinearProblem), + (nlsys, NonlinearProblem{true}), + (nlsys, NonlinearProblem{true, SciMLBase.FullSpecialize}), ( + nlsys, NonlinearLeastSquaresProblem), + (nlsys, NonlinearLeastSquaresProblem{true}), + (nlsys, NonlinearLeastSquaresProblem{true, SciMLBase.FullSpecialize}), ( + nlsys, SCCNonlinearProblem), + (nlsys, SCCNonlinearProblem{true}), (optsys, OptimizationProblem), + (optsys, OptimizationProblem{true}) + ] + @testset "$(typeof(u0)) - $(typeof(p))" for u0 in u0s, p in ps + + @test_warn ["deprecated"] ctor(sys, u0, p) + end + end +end diff --git a/test/sdesystem.jl b/test/sdesystem.jl index 6a6306606f..f3bb3ba9c9 100644 --- a/test/sdesystem.jl +++ b/test/sdesystem.jl @@ -1,434 +1,955 @@ -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 Setfield +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] + +# System -> SDESystem shorthand constructor +@named sys = System(eqs, tt, [x, y, z], [σ, ρ, β]) + +@named de = SDESystem(eqs, noiseeqs, tt, [x, y, z], [σ, ρ, β]) +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, [unknowns(de) .=> [1.0, 0.0, 0.0]; parameters(de) .=> [10.0, 26.0, 2.33]], + (0.0, 100.0)) +sol = solve(prob, SRIW1(), seed = 1) + +probexpr = SDEProblem( + de, [unknowns(de) .=> [1.0, 0.0, 0.0]; parameters(de) .=> [10.0, 26.0, 2.33]], + (0.0, 100.0)) +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, [x => 1.0, y => 0.0, z => 0.0, σ => 10.0, ρ => 26.0, β => 2.33], + (0.0, 100.0), 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; parammap], (0.0, 100.0)) +@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; parammap], (0.0, 100.0), 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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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_rhs(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 ModelingToolkit.NonUniqueSubsystemsError 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; parammap], (0.0, 1.0)) + sol = solve(prob, EM(), dt = dt) + @test observed(de) == [weight ~ x * 10] + @test sol[weight] == 10 * sol[x] + + @named ode = System(eqs, tt, [x], [α, β], observed = [weight ~ x * 10]) + ode = complete(ode) + odeprob = ODEProblem(ode, [u0map; parammap], (0.0, 1.0)) + 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; parammap], (0.0, 1.0)) + + 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; parammap], (0.0, 1.0)) + + 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 σ ρ +@brownians β η +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 = mtkcompile(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 issetequal(ModelingToolkit.get_noise_eqs(sys1), ModelingToolkit.get_noise_eqs(sys2)) + +prob = SDEProblem(sys1, [sts .=> [1.0, 0.0, 0.0]; ps .=> [10.0, 26.0]], + (0.0, 100.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 ModelingToolkit.IllFormedNoiseEquationsError 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) +@brownians a +eqs = [D(x) ~ p - d * x + a * sqrt(p)] +@mtkcompile 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; ps], tspan) +@test !isinplace(sprob) +@test !isinplace(sprob.f) +@test_nowarn solve(sprob, ImplicitEM()) + +# Ensure diagonal noise generates vector noise function +@variables y(tt) +@brownians b +eqs = [D(x) ~ p - d * x + a * sqrt(p) + D(y) ~ p - d * y + b * sqrt(d)] +@mtkcompile 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; ps], tspan) +@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) + @brownians 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] + + @mtkcompile 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; parammap], (0.0, 100.0)) + # 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) + @brownians a + eqs = [D(x) ~ a, + D(y) ~ a] + + @mtkcompile 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) + @brownians 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] + + @mtkcompile 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; parammap], (0.0, 100.0)) + # 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) + @brownians 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] + @mtkcompile 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; parammap], (0.0, 100.0)) + # 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_noise_eqs(de)) == (3, 6) +end + +@testset "Diagonal noise, less brownians than equations" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownians 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 + @mtkcompile 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; parammap], (0.0, 100.0)) + @test solve(prob, SOSRI()).retcode == ReturnCode.Success +end + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + @brownians b + @mtkcompile 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 + browns = @brownians η + + eqs = [D(x) ~ -a * x + (input + 1) * η + input ~ 0.0] + + sys = System(eqs, t, sts, ps, browns; name = :name) + sys = mtkcompile(sys) + @test ModelingToolkit.get_noise_eqs(sys) ≈ [1.0] + prob = SDEProblem(sys, [], (0.0, 1.0)) + @test_nowarn solve(prob, RKMil()) +end + +@testset "Observed variables retained after `mtkcompile`" begin + @variables x(t) y(t) z(t) + @brownians a + @mtkcompile sys = System([D(x) ~ x + a, D(y) ~ y + a, z ~ x + y], t) + @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 System" 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 = noise_to_brownians(sys) + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test length(brownians(odesys)) == 3 + @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 = noise_to_brownians(sys) + 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 = noise_to_brownians(sys) + 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 "`mtkcompile(::SDESystem)`" begin + @variables x(t) y(t) + @mtkcompile sys = SDESystem( + [D(x) ~ x, y ~ 2x], [x, 0], t, [x, y], []) + @test length(equations(sys)) == 1 + @test length(ModelingToolkit.get_noise_eqs(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 + @brownians z + eq2 = D(X) ~ p - d * X + z + @test_throws ModelingToolkit.ContinuousOperatorDiscreteArgumentError @mtkcompile ssys = System( + [eq2], t) + noiseeq = [1] + @test_throws ModelingToolkit.ContinuousOperatorDiscreteArgumentError @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 = System(eqs, tt, [x, y, z], [σ, ρ, β]) + + @named de = SDESystem(eqs, noiseeqs, tt, [x, y, z], [σ, ρ, β]) + de = complete(de) + + f = SDEFunction(de; expression = Val{true}) + @test f isa Expr + + @testset "Configuration Tests" begin + # Test with `tgrad` + f_tgrad = SDEFunction(de; tgrad = true, expression = Val{true}) + @test f_tgrad isa Expr + + # Test with `jac` + f_jac = SDEFunction(de; jac = true, expression = Val{true}) + @test f_jac isa Expr + + # Test with sparse Jacobian + f_sparse = SDEFunction(de; sparse = true, expression = Val{true}) + @test f_sparse isa Expr + end + + @testset "Ordering Tests" begin + dvs = [z, y, x] + ps = [β, ρ, σ] + f_order = SDEFunction(de; expression = Val{true}) + @test f_order isa Expr + end +end + +@testset "Error when constructing SDEProblem without `mtkcompile`" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownians 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 ["Brownian", "mtkcompile"] SDEProblem( + de, [u0map; parammap], (0.0, 100.0)) + de = mtkcompile(de) + @test SDEProblem(de, [u0map; parammap], (0.0, 100.0)) isa SDEProblem +end + +@testset "`@brownian` is deprecated" begin + @test_deprecated @brownian a b c + + @brownian p q + @test ModelingToolkit.isbrownian(p) + @test ModelingToolkit.isbrownian(q) +end diff --git a/test/serialization.jl b/test/serialization.jl index ac8968cb8c..43f2cabb6e 100644 --- a/test/serialization.jl +++ b/test/serialization.jl @@ -1,13 +1,13 @@ -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 = System([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)), + eval(ModelingToolkit.ODEProblem{false}(sys, nothing, nothing; expression = Val{true})) ] _fn = tempname() @@ -20,8 +20,38 @@ for prob in [ run(`$(Base.julia_cmd()) -e $(_cmd)`) end -include("../examples/rc_model.jl") +include("common/rc_model.jl") +@unpack capacitor = rc_model io = IOBuffer() -write(io, rc_model) -sys = include_string(@__MODULE__, String(take!(io))) -@test sys == flatten(rc_model) +write(io, expand_connections(rc_model)) +str = String(take!(io)) + +sys = include_string(@__MODULE__, str) +rc2 = expand_connections(rc_model) +@test isapprox(sys, rc2) +@test issetequal(equations(sys), equations(rc2)) +@test issetequal(unknowns(sys), unknowns(rc2)) +@test issetequal(parameters(sys), parameters(rc2)) + +# check answer +ss = mtkcompile(rc_model) +all_obs = observables(ss) +prob = ODEProblem(ss, [capacitor.v => 0.0], (0, 0.1)) +sol = solve(prob, ImplicitEuler()) + +## Check System 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 +# ODEProblemExpr with observedfun_exp included +probexpr = ODEProblem{true}(ss, [capacitor.v => 0.0], (0, 0.1); expr = Val{true}); +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..a92d9f8350 --- /dev/null +++ b/test/split_parameters.jl @@ -0,0 +1,312 @@ +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 System(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 = System(eqs, t, vars, []; systems = [int, src]) +s = complete(sys) +sys = mtkcompile(sys) +prob = ODEProblem( + sys, [s.src.interpolator => Interpolator(x, dt)], (0.0, t_end); + 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 = System(eqs, t, vars, pars) +sys = mtkcompile(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) + System(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 = System(eqs, + t; + systems = [torque, inertia1, inertia2, spring, damper, u]) + end + System(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(System(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 = System(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 + @mtkcompile sys = System(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, fn => Foo()], (0.0, 1.0)) + @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; extrapolation = ExtrapolationType.Extension) + @variables x(t) + @parameters (fn::typeof(interp))(..) + @mtkcompile sys = System(D(x) ~ fn(x), t) + @test is_parameter(sys, fn) + getter = getp(sys, fn) + prob = ODEProblem(sys, [x => 1.0, fn => interp], (0.0, 1.0)) + @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; extrapolation = ExtrapolationType.Extension) + @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..fd7a840798 --- /dev/null +++ b/test/state_selection.jl @@ -0,0 +1,258 @@ +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 u2 u3 u4 +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 = System(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 + +# 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] + System(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] + System(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(System(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(System(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(System(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(System(eqs, t, sts, ps; name = name), [fluid_port_a, fluid_port_b]) + end + function HydraulicSystem(; 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(System(eqs, t, [], ps; name = name), subs) + end + + @named system = HydraulicSystem(L = 10) + @unpack supply_pipe, return_pipe = system + sys = mtkcompile(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 = System(eqs, t) + + sys = mtkcompile(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 = System(eqs, t, vars, params, defaults = defs) + sys = mtkcompile(catapult) + prob = ODEProblem(sys, [l_2f => 0.55, damp => 1e7], (0.0, 0.1); 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..a23eeddde1 --- /dev/null +++ b/test/static_arrays.jl @@ -0,0 +1,26 @@ +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 = System(eqs, t) +sys = mtkcompile(sys) + +ivs = @SVector [D(x) => 2.0, + x => 1.0, + y => 0.0, + z => 0.0, + σ => 28.0, + ρ => 10.0, + β => 8 / 3] + +tspan = (0.0, 100.0) +prob_mtk = ODEProblem(sys, ivs, tspan) + +@test !SciMLBase.isinplace(prob_mtk) +@test prob_mtk.u0 isa SArray diff --git a/test/steadystatesystems.jl b/test/steadystatesystems.jl index 06fc0453b0..505e7da890 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 = System(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..493d9996e3 --- /dev/null +++ b/test/stream_connectors.jl @@ -0,0 +1,511 @@ +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 + + System(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] + + System(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(System(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 = System(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(System(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 = System(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 = System(eqns, t; systems = [n1m1, pipe, sink]) + +eqns = [domain_connect(fluid, n1m1.port_a) + connect(n1m1.port_a, pipe.port_a) + connect(pipe.port_b, sink.port)] + +@named n1m1Test = System(eqns, t, [], []; systems = [fluid, n1m1, pipe, sink]) + +@test_nowarn mtkcompile(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 + port_a.h_outflow ~ source.port1.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 equations(expand_connections(sys)) ⊇ + [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.port_a.h_outflow ~ n1m1.source.port1.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 = System(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 = System(eqns, t) +@named n1m2Test = compose(sys, n1m2, sink1, sink2) +@test_nowarn mtkcompile(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 = System(eqns, t) +@named n1m2AltTest = compose(sys, n1m2, pipe1, pipe2, sink1, sink2) +@test_nowarn mtkcompile(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 = System(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 = System(eqns, t) +@named n2m2Test = compose(sys, n2m2, source, sink) +@test_nowarn mtkcompile(n2m2Test) + +# stream var +@named sp1 = TwoPhaseFluidPort() +@named sp2 = TwoPhaseFluidPort() +@named sys = System([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] + System(Equation[], t, [sts...;], []; name = name) +end + +@named vp1 = VecPin() +@named vp2 = VecPin() +@named vp3 = VecPin() + +@named simple = System([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 ~ vp2.v + vp1.v ~ vp3.v + 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] + System(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[] + + System(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 + ] + + System(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) + ] + + System(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] + + System(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)] + + System(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)] + + System(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)] + + System(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 mtkcompile(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)] + + System(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 mtkcompile(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..2118c8441d 100644 --- a/test/structural_transformation/index_reduction.jl +++ b/test/structural_transformation/index_reduction.jl @@ -1,169 +1,72 @@ using ModelingToolkit -using LightGraphs +using Graphs using DiffEqBase using Test using UnPack - -# Define some variables -@parameters t 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) -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 -@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] - -using ModelingToolkit -@parameters t 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) - 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)) -sol = solve(prob, Rodas5()); -#plot(sol, vars=(1, 2)) - -new_sys = 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]) -sol = solve(prob_auto, Rodas5()); -#plot(sol, vars=(x, y)) +using ModelingToolkit: t_nounits as t, D_nounits as D # Define some variables -@parameters t 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) - -# 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) - -u0 = [ - D(x) => 0.0, - D(y) => 0.0, - x => 1.0, - y => 0.0, - T => 0.0 -] +@parameters L g +@variables x(t) y(t) z(t) w(t) T(t) -p = [ - L => 1.0, - g => 9.8 -] +# 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 = System(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, + [3, 4, 2, 5, 0, 0, 0, 0, 0]) + +eqs2 = [D(D(x)) ~ T * x, + D(D(y)) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum2 = System(eqs2, t, [x, y, T], [L, g], name = :pendulum) -prob_auto = ODEProblem(new_sys,u0,(0.0,10.0),p) -sol = solve(prob_auto, Rodas5()); -#plot(sol, vars=(D(x), y)) +eqs = [D(x) ~ w, + D(y) ~ z, + D(w) ~ T * x, + D(z) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum = System(eqs, t, [x, y, w, z, T], [L, g], name = :pendulum) + +let sys = mtkcompile(pendulum2) + @test length(equations(sys)) == 5 + @test length(unknowns(sys)) == 5 + + ivs = [ + x => sqrt(2) / 2, + y => sqrt(2) / 2, + L => 1.0, + g => 9.8 + ] -### -### More BLT/SCC tests -### + prob_auto = ODEProblem(sys, ivs, (0.0, 0.5), 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 -# Test Tarjan (1972) Fig. 3 -g = [ - [2], - [3,8], - [4,7], - [5], - [3,6], - Int[], - [4,6], - [1,7], - ] -graph = StructuralTransformations.BipartiteGraph(8, 8) -for (eq, vars) in enumerate(g), var in vars - add_edge!(graph, eq, var) +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 = System(eqs, t) + sys = complete(mtkcompile(pend; dummy_derivative = false)) + prob = ODEProblem( + sys, [x => 1, y => 0, D(x) => 0.0, g => 1], (0.0, 10.0), 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..4025f7a298 100644 --- a/test/structural_transformation/tearing.jl +++ b/test/structural_transformation/tearing.jl @@ -5,55 +5,63 @@ using ModelingToolkit.StructuralTransformations: SystemStructure, find_solvables using NonlinearSolve using LinearAlgebra using UnPack - +using SymbolicIndexingInterface +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 = System(eqs, [u1, u2, u3, u4, u5], [h]) +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])] + +newsys = tearing(sys) +@test length(equations(newsys)) == 1 +@test issetequal(ModelingToolkit.vars(equations(newsys)), [u1, u4, u5]) # Before: # u1 u2 u3 u4 u5 @@ -88,15 +96,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 +117,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 +126,80 @@ 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 = System(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 = System(eqs, t) +newdaesys = mtkcompile(daesys) +@test issetequal(equations(newdaesys), [D(x) ~ h * z; 0 ~ y + sin(z) - p * t]) +@test issetequal( + equations(tearing_substitution(newdaesys)), [D(x) ~ h * z; 0 ~ x + sin(z) - p * t]) +@test issetequal(unknowns(newdaesys), [x, z]) +prob = ODEProblem(newdaesys, [x => 1.0, z => -0.5π, p => 0.2], (0, 1.0)) +du = [0.0, 0.0]; +u = [1.0, -0.5π]; +pr = prob.p; +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) +xgetter = getsym(prob, x) +zgetter = getsym(prob, z) +@test xgetter(du)≈zgetter(u) atol=1e-5 +@test zgetter(du)≈xgetter(u) + sin(zgetter(u)) - prob.ps[p] * 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 = System(eqs, t, defaults = Dict(z => NaN)) +infprob = ODEProblem(mtkcompile(sys), [x => 1.0, p => 0.2], (0, 1.0)) +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] + System(eqs, t, sts, ps; name = name) +end + +m = 1.0 +@named mass = Translational_Mass(m = m) + +ms_eqs = Equation[] + +@named _ms_model = System(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 = mtkcompile(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..4a2df411a6 100644 --- a/test/structural_transformation/utils.jl +++ b/test/structural_transformation/utils.jl @@ -1,35 +1,382 @@ 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 +using DataInterpolations +using OrdinaryDiffEq, NonlinearSolve, StochasticDiffEq +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]) -@test graph.fadjlist == [[1, 7], [2, 8], [3, 5, 9], [4, 6, 9], [5, 6]] -@test graph.badjlist == 9 == length(fullvars) + D(y) ~ z, + D(w) ~ T * x, + D(z) ~ T * y - g, + 0 ~ x^2 + y^2 - L^2] +pendulum = System(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 sort(graph.fadjlist) == [[1, 7], [2, 8], [3, 5, 9], [4, 6, 9], [5, 6]] +@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 + @mtkcompile sys = System( + [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)) == 6 + @test any(obs -> isequal(obs, y), observables(sys)) + @test any(obs -> isequal(obs, z), observables(sys)) + prob = ODEProblem(sys, [x => 1.0, foo => _tmp_fn], (0.0, 1.0)) + @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.change_origin] + end +end + +@testset "array hack 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 = System( + [D(x) ~ z[1] + z[2] + foo(z)[1], y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys2 = mtkcompile(sys; array_hack = false) + @test length(observed(sys2)) == 4 + @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 = System( + [D(x) ~ z[1] + z[2] + foo(z)[1] + w, y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys2 = mtkcompile(sys; array_hack = false, fully_determined = false) + @test length(observed(sys2)) == 4 + @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 = System([D(x) ~ x, y ~ x + t], t) + value = Ref(0) + pass(sys; kwargs...) = (value[] += 1; return sys) + mtkcompile(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 "Requires simplified system" begin + @variables x(t) y(t) + @named sys = System([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) + @mtkcompile sys = System([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] + @mtkcompile sys = System(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) + @mtkcompile 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 + @mtkcompile sys = System([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 + +@testset "Issue#3480: Derivatives of time-dependent parameters" begin + @component function FilteredInput(; name, x0 = 0, T = 0.1) + params = @parameters begin + k(t) = x0 + T = T + end + vars = @variables begin + x(t) = k + dx(t) = 0 + ddx(t) + end + systems = [] + eqs = [D(x) ~ dx + D(dx) ~ ddx + dx ~ (k - x) / T] + return System(eqs, t, vars, params; systems, name) + end + + @component function FilteredInputExplicit(; name, x0 = 0, T = 0.1) + params = @parameters begin + k(t)[1:1] = [x0] + T = T + end + vars = @variables begin + x(t) = k + dx(t) = 0 + ddx(t) + end + systems = [] + eqs = [D(x) ~ dx + D(dx) ~ ddx + D(k[1]) ~ 1.0 + dx ~ (k[1] - x) / T] + return System(eqs, t, vars, params; systems, name) + end + + @component function FilteredInputErr(; name, x0 = 0, T = 0.1) + params = @parameters begin + k(t) = x0 + T = T + end + vars = @variables begin + x(t) = k + dx(t) = 0 + ddx(t) + end + systems = [] + eqs = [D(x) ~ dx + D(dx) ~ ddx + dx ~ (k - x) / T + D(k) ~ missing] + return System(eqs, t, vars, params; systems, name) + end + + @named sys = FilteredInputErr() + @test_throws ["derivative of discrete variable", "k(t)"] mtkcompile(sys) + + @mtkcompile sys = FilteredInput() + vs = Set() + for eq in equations(sys) + ModelingToolkit.vars!(vs, eq) + end + for eq in observed(sys) + ModelingToolkit.vars!(vs, eq) + end + + @test !(D(sys.k) in vs) + + @mtkcompile sys = FilteredInputExplicit() + obsfn1 = ModelingToolkit.build_explicit_observed_function(sys, sys.ddx) + obsfn2 = ModelingToolkit.build_explicit_observed_function(sys, sys.dx) + u = [1.0] + p = MTKParameters(sys, [sys.k => [2.0], sys.T => 3.0]) + @test obsfn1(u, p, 0.0) ≈ (1 - obsfn2(u, p, 0.0)) / 3.0 + + @testset "Called parameter still has derivative" begin + @component function FilteredInput2(; name, x0 = 0, T = 0.1) + ts = collect(0.0:0.1:10.0) + spline = LinearInterpolation(ts .^ 2, ts) + params = @parameters begin + (k::LinearInterpolation)(..) = spline + T = T + end + vars = @variables begin + x(t) = k(t) + dx(t) = 0 + ddx(t) + end + systems = [] + eqs = [D(x) ~ dx + D(dx) ~ ddx + dx ~ (k(t) - x) / T] + return System(eqs, t, vars, params; systems, name) + end + + @mtkcompile sys = FilteredInput2() + vs = Set() + for eq in equations(sys) + ModelingToolkit.vars!(vs, eq) + end + for eq in observed(sys) + ModelingToolkit.vars!(vs, eq) + end + + @test D(sys.k(t)) in vs + end +end + +@testset "Don't rely on metadata" begin + @testset "ODESystem" begin + @variables x(t) p + @parameters y(t) q + @mtkcompile sys = System([D(x) ~ x * q, x^2 + y^2 ~ p], t, [x, y], + [p, q]; initialization_eqs = [p + q ~ 3], + defaults = [p => missing], guesses = [p => 1.0, y => 1.0]) + @test length(equations(sys)) == 2 + @test length(parameters(sys)) == 2 + prob = ODEProblem(sys, [x => 1.0, q => 2.0], (0.0, 1.0)) + integ = init(prob, Rodas5P(); abstol = 1e-10, reltol = 1e-8) + @test integ.ps[p]≈1.0 atol=1e-6 + @test integ[y]≈0.0 atol=1e-5 + end + + @testset "NonlinearSystem" begin + @variables x p + @parameters y q + @mtkcompile sys = System([0 ~ p * x + y, x^3 + y^3 ~ q], [x, y], + [p, q]; initialization_eqs = [p ~ q + 1], + guesses = [p => 1.0], defaults = [p => missing]) + @test length(equations(sys)) == length(unknowns(sys)) == 1 + @test length(observed(sys)) == 1 + @test observed(sys)[1].lhs in Set([x, y]) + @test length(parameters(sys)) == 2 + prob = NonlinearProblem(sys, [x => 1.0, y => 1.0, q => 1.0]) + integ = init(prob, NewtonRaphson()) + @test prob.ps[p] ≈ 2.0 + end + + @testset "SDESystem" begin + @variables x(t) p a + @parameters y(t) q b + @brownians c + @mtkcompile sys = System([D(x) ~ x + q * a, D(y) ~ y + p * b + c], t, [x, y], + [p, q], [a, b, c]; initialization_eqs = [p + q ~ 4], + guesses = [p => 1.0], defaults = [p => missing]) + @test length(equations(sys)) == 2 + @test issetequal(unknowns(sys), [x, y]) + @test issetequal(parameters(sys), [p, q]) + @test isempty(brownians(sys)) + neqs = ModelingToolkit.get_noise_eqs(sys) + @test issetequal(sum.(eachrow(neqs)), [q, 1 + p]) + prob = SDEProblem(sys, [x => 1.0, y => 1.0, q => 1.0], (0.0, 1.0)) + integ = init(prob, ImplicitEM()) + @test integ.ps[p] ≈ 3.0 + end +end + +@testset "Deprecated `mtkcompile` and `@mtkcompile`" begin + @variables x(t) + @test_deprecated @mtkbuild sys = System([D(x) ~ x], t) + @named sys = System([D(x) ~ x], t) + @test_deprecated structural_simplify(sys) +end diff --git a/test/substitute_component.jl b/test/substitute_component.jl new file mode 100644 index 0000000000..e598d86a06 --- /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 = mtkcompile(rcsys) + sys2 = mtkcompile(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 = System(Equation[], t) + @named outer = System(Equation[], t; systems = [empty]) + @named empty = System(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..53af99f1da --- /dev/null +++ b/test/symbolic_events.jl @@ -0,0 +1,1380 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, JumpProcesses, Test +using SciMLStructures: canonicalize, Discrete +using ModelingToolkit: SymbolicContinuousCallback, + SymbolicDiscreteCallback, + t_nounits as t, + D_nounits as D, + affects, affect_negs, system, observed, AffectSystem +using StableRNGs +import SciMLBase +using SymbolicIndexingInterface +using Setfield +rng = StableRNG(12345) + +function get_callback(prob) + prob.kwargs[:callback] +end + +@variables x(t) = 0 + +eqs = [D(x) ~ 1] +affect = [x ~ 0] +affect_neg = [x ~ 1] + +@testset "SymbolicContinuousCallback constructors" begin + e = SymbolicContinuousCallback(eqs[]) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs, nothing) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[], nothing) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs => nothing) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + e = SymbolicContinuousCallback(eqs[] => nothing) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.affect === nothing + @test e.affect_neg === nothing + @test e.rootfind == SciMLBase.LeftRootFind + + ## With affect + e = SymbolicContinuousCallback(eqs[], affect) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @test e.rootfind == SciMLBase.LeftRootFind + + # with only positive edge affect + e = SymbolicContinuousCallback(eqs[], affect, affect_neg = nothing) + @test e isa SymbolicContinuousCallback + @test isequal(equations(e), eqs) + @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(equations(e), eqs) + @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(equations(e), eqs) + @test e.rootfind == SciMLBase.LeftRootFind +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 + +@testset "Condition Compilation" begin + @named sys = System(eqs, t, continuous_events = [x ~ 1]) + @test getfield(sys, :continuous_events)[] == + SymbolicContinuousCallback(Equation[x ~ 1], nothing) + @test isequal(equations(getfield(sys, :continuous_events))[], x ~ 1) + fsys = flatten(sys) + @test isequal(equations(getfield(fsys, :continuous_events))[], x ~ 1) + + @named sys2 = System([D(x) ~ 1], t, continuous_events = [x ~ 2], systems = [sys]) + @test getfield(sys2, :continuous_events)[] == + SymbolicContinuousCallback(Equation[x ~ 2], nothing) + @test all(ModelingToolkit.continuous_events(sys2) .== [ + SymbolicContinuousCallback(Equation[x ~ 2], nothing), + SymbolicContinuousCallback(Equation[sys.x ~ 1], nothing) + ]) + + @test isequal(equations(getfield(sys2, :continuous_events))[1], x ~ 2) + @test length(ModelingToolkit.continuous_events(sys2)) == 2 + @test isequal(equations(ModelingToolkit.continuous_events(sys2)[1])[], x ~ 2) + @test isequal(equations(ModelingToolkit.continuous_events(sys2)[2])[], sys.x ~ 1) + + sys = complete(sys) + sys_nosplit = complete(sys; split = false) + sys2 = complete(sys2) + + # Test proper rootfinding + prob = ODEProblem(sys, Pair[], (0.0, 2.0)) + p0 = 0 + t0 = 0 + @test get_callback(prob) isa ModelingToolkit.DiffEqCallbacks.ContinuousCallback + cb = ModelingToolkit.generate_continuous_callbacks(sys) + cond = cb.condition + out = [0.0] + cond.f(out, [0], p0, t0) + @test out[] ≈ -1 # signature is u,p,t + cond.f(out, [1], p0, t0) + @test out[] ≈ 0 # signature is u,p,t + cond.f(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 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.f(out, [0, 0], p0, t0) + @test out[1] ≈ -2 # signature is u,p,t + cond.f(out, [1, 0], p0, t0) + @test out[1] ≈ -1 # signature is u,p,t + cond.f(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.f(out, [0, 0], p0, t0) + @test out[2] ≈ -1 # signature is u,p,t + cond.f(out, [0, 1], p0, t0) # this should return 0 + @test out[2] ≈ 0 # signature is u,p,t + cond.f(out, [0, 2], p0, t0) + @test out[2] ≈ 1 # signature is u,p,t + + sol = solve(prob, Tsit5()) + @test minimum(t -> abs(t - 1), sol.t) < 1e-9 # test that the solver stepped at the first root + @test minimum(t -> abs(t - 2), sol.t) < 1e-9 # test that the solver stepped at the second root + + @named sys = System(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()) + @test minimum(t -> abs(t - 1), sol.t) < 1e-9 # test that the solver stepped at the first root + @test minimum(t -> abs(t - 2), sol.t) < 1e-9 # test that the solver stepped at the second root +end + +@testset "Bouncing Ball" begin + ###### 1D Bounce + @variables x(t)=1 v(t)=0 + + root_eqs = [x ~ 0] + affect = [v ~ -Pre(v)] + + @named ball = System( + [D(x) ~ v + D(v) ~ -9.8], t, continuous_events = root_eqs => affect) + + cev = only(continuous_events(ball)) + @test isequal(only(equations(cev)), x ~ 0) + @test isequal(only(observed(cev.affect.system)), v ~ -Pre(v)) + ball = mtkcompile(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 + + ###### 2D bouncing ball + @variables x(t)=1 y(t)=0 vx(t)=0 vy(t)=1 + + events = [[x ~ 0] => [vx ~ -Pre(vx)] + [y ~ -1.5, y ~ 1.5] => [vy ~ -Pre(vy)]] + + @named ball = System( + [D(x) ~ vx + D(y) ~ vy + D(vx) ~ -9.8 + D(vy) ~ -0.01vy], t; continuous_events = events) + + _ball = ball + ball = mtkcompile(_ball) + ball_nosplit = mtkcompile(_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 + _cevs = getfield(ball, :continuous_events) + @test isequal(only(equations(_cevs[1])), x ~ 0) + @test isequal(only(observed(_cevs[1].affect.system)), vx ~ -Pre(vx)) + @test issetequal(equations(_cevs[2]), [y ~ -1.5, y ~ 1.5]) + @test isequal(only(observed(_cevs[2].affect.system)), vy ~ -Pre(vy)) + cond = cb.condition + out = [0.0, 0.0, 0.0] + p0 = 0.0 + t0 = 0.0 + cond.f(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 + + ## Test multi-variable affect + # in this test, there are two variables affected by a single event. + events = [[x ~ 0] => [vx ~ -Pre(vx), vy ~ -Pre(vy)]] + + @named ball = System( + [D(x) ~ vx + D(y) ~ vy + D(vx) ~ -1 + D(vy) ~ 0], t; continuous_events = events) + + ball_nosplit = mtkcompile(ball) + ball = mtkcompile(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) +end + +# issue https://github.com/SciML/ModelingToolkit.jl/issues/1386 +# tests that it works for ODAESystem +@testset "ODAESystem" begin + @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 ~ Pre(v)] + @named sys = System(eq, t, continuous_events = ev) + sys = mtkcompile(sys) + prob = ODEProblem(sys, zeros(2), (0.0, 5.1)) + sol = solve(prob, Tsit5()) + @test all(minimum((0:0.1:5) .- sol.t', dims = 2) .< 0.0001) # test that the solver stepped every 0.1s as dictated by event + @test sol([0.25 - eps()])[vmeasured][] == sol([0.23])[vmeasured][] # test the hold property +end + +## https://github.com/SciML/ModelingToolkit.jl/issues/1528 +@testset "Handle Empty Events" begin + 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 + System(eqs, t, [pos, vel], ps; name) + end + function Spring(; name, k = 1e4) + ps = @parameters k = k + @variables x(t) = 0 # Spring deflection + System(Equation[], t, [x], ps; name) + end + function Damper(; name, c = 10) + ps = @parameters c = c + @variables vel(t) = 0 + System(Equation[], t, [vel], ps; name) + end + function SpringDamper(; name, k = false, c = false) + spring = Spring(; name = :spring, k) + damper = Damper(; name = :damper, c) + compose(System(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 = System(eqs, t; observed = [y ~ mass2.pos]) + @named model = compose(_model, mass1, mass2, sd) + end + model = Model(sin(30t)) + sys = mtkcompile(model) + @test isempty(ModelingToolkit.continuous_events(sys)) +end + +@testset "SDE/ODESystem Discrete Callbacks" begin + function testsol( + sys, probtype, solver, u0, p, tspan; tstops = Float64[], paramtotest = nothing, + kwargs...) + prob = probtype(complete(sys), [u0; p], tspan; kwargs...) + sol = solve(prob, solver(); 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] == [0.0, 1.0]) + @test isapprox(sol(4.0)[1], 2 * exp(-2.0); rtol = 1e-6) + sol + end + + @parameters k(t) t1 t2 + @variables A(t) B(t) + + cond1 = (t == t1) + affect1 = [A ~ Pre(A) + 1] + cb1 = cond1 => affect1 + cond2 = (t == t2) + affect2 = [k ~ 1.0] + cb2 = cond2 => affect2 + cb2 = SymbolicDiscreteCallback(cb2, discrete_parameters = [k], iv = t) + + ∂ₜ = D + eqs = [∂ₜ(A) ~ -k * A] + @named osys = System(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2]) + @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(osys, ODEProblem, Tsit5, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + testsol(ssys, SDEProblem, RI5, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + cond1a = (t == t1) + affect1a = [A ~ Pre(A) + 1, B ~ A] + cb1a = cond1a => affect1a + @named osys1 = System(eqs, t, [A, B], [k, t1, t2], discrete_events = [cb1a, cb2]) + @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(osys1, ODEProblem, Tsit5, u0′, p, tspan; + tstops = [1.0, 2.0], check_length = false, paramtotest = k) + @test sol(1.0000001, idxs = B) == 2.0 + + sol = testsol(ssys1, SDEProblem, RI5, 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‵ = SymbolicDiscreteCallback([2.0] => affect2, discrete_parameters = [k], iv = t) + @named osys‵ = System(eqs, t, [A], [k], discrete_events = [cb1‵, cb2‵]) + @named ssys‵ = SDESystem(eqs, [0.0], t, [A], [k], discrete_events = [cb1‵, cb2‵]) + testsol(osys‵, ODEProblem, Tsit5, u0, p, tspan; paramtotest = k) + testsol(ssys‵, SDEProblem, RI5, u0, p, tspan; paramtotest = k) + + # mixing discrete affects + @named osys3 = System(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵]) + @named ssys3 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵]) + testsol(osys3, ODEProblem, Tsit5, u0, p, tspan; tstops = [1.0], paramtotest = k) + testsol(ssys3, SDEProblem, RI5, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with a func affect + function affect!(mod, obs, ctx, integ) + return (; k = 1.0) + end + cb2‵‵ = [2.0] => (f = affect!, modified = (; k)) + @named osys4 = System(eqs, t, [A], [k, t1], discrete_events = [cb1, cb2‵‵]) + @named ssys4 = SDESystem(eqs, [0.0], t, [A], [k, t1], + discrete_events = [cb1, cb2‵‵]) + oprob4 = ODEProblem(complete(osys4), [u0; p], tspan) + testsol(osys4, ODEProblem, Tsit5, u0, p, tspan; tstops = [1.0], paramtotest = k) + testsol(ssys4, SDEProblem, RI5, u0, p, tspan; tstops = [1.0], paramtotest = k) + + # mixing with symbolic condition in the func affect + cb2‵‵‵ = (t == t2) => (f = affect!, modified = (; k)) + @named osys5 = System(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵‵‵]) + @named ssys5 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵‵‵]) + testsol(osys5, ODEProblem, Tsit5, u0, p, tspan; tstops = [1.0, 2.0]) + testsol(ssys5, SDEProblem, RI5, u0, p, tspan; tstops = [1.0, 2.0]) + @named osys6 = System(eqs, t, [A], [k, t1, t2], discrete_events = [cb2‵‵‵, cb1]) + @named ssys6 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb2‵‵‵, cb1]) + testsol(osys6, ODEProblem, Tsit5, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + testsol(ssys6, SDEProblem, RI5, u0, p, tspan; tstops = [1.0, 2.0], paramtotest = k) + + # mix a continuous event too + cond3 = A ~ 0.1 + affect3 = [k ~ 0.0] + cb3 = SymbolicContinuousCallback(cond3 => affect3, discrete_parameters = [k], iv = t) + @named osys7 = System(eqs, t, [A], [k, t1, t2], discrete_events = [cb1, cb2‵‵‵], + continuous_events = [cb3]) + @named ssys7 = SDESystem(eqs, [0.0], t, [A], [k, t1, t2], + discrete_events = [cb1, cb2‵‵‵], + continuous_events = [cb3]) + + sol = testsol(osys7, ODEProblem, Tsit5, 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) + sol = testsol(ssys7, SDEProblem, RI5, 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 + +@testset "JumpSystem Discrete Callbacks" begin + function testsol(jsys, u0, p, tspan; tstops = Float64[], paramtotest = nothing, + N = 40000, kwargs...) + jsys = complete(jsys) + jprob = JumpProblem(jsys, [u0; p], tspan; aggregator = Direct(), kwargs...) + sol = solve(jprob, SSAStepper(); tstops = tstops) + @test (sol(1.000000000001)[1] - sol(0.99999999999)[1]) == 1 + paramtotest === nothing || (@test sol.ps[paramtotest] == [0.0, 1.0]) + @test sol(40.0)[1] == 0 + sol + end + + @parameters k(t) t1 t2 + @variables A(t) B(t) + + eqs = [MassActionJump(k, [A => 1], [A => -1])] + cond1 = (t == t1) + affect1 = [A ~ Pre(A) + 1] + cb1 = cond1 => affect1 + cond2 = (t == t2) + affect2 = [k ~ 1.0] + cb2 = cond2 => affect2 + cb2 = SymbolicDiscreteCallback(cb2, discrete_parameters = [k], iv = t) + + @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 ~ Pre(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‵ = SymbolicDiscreteCallback([2.0] => affect2, discrete_parameters = [k], iv = t) + @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!(mod, obs, ctx, integrator) + return (; k = 1.0) + end + cb2‵‵ = [2.0] => (f = affect!, modified = (; k)) + @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) => (f = affect!, modified = (; k)) + @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 + +@testset "Namespacing" begin + 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] + System(eqs, t, sts, ps, continuous_events = [ev]; name) + end + + @named oscce = oscillator_ce() + eqs = [oscce.F ~ 0] + @named eqs_sys = System(eqs, t) + @named oneosc_ce = compose(eqs_sys, oscce) + oneosc_ce_simpl = mtkcompile(oneosc_ce) + + prob = ODEProblem(oneosc_ce_simpl, [], (0.0, 2.0)) + sol = solve(prob, Tsit5(), saveat = 0.1) + + @test typeof(oneosc_ce_simpl) == System + @test sol(0.5, idxs = oscce.x) < 1.0 # test whether x(t) decreases over time + @test sol(1.5, idxs = oscce.x) > 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)] + function record_crossings(mod, obs, ctx, integ) + push!(ctx, integ.t => obs.v) + return (;) + end + cr1 = [] + cr2 = [] + evt1 = ModelingToolkit.SymbolicContinuousCallback( + [c1 ~ 0], (f = record_crossings, observed = (; v = c1), ctx = cr1)) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2)) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1p); + affect_neg = (f = record_crossings, observed = (; v = c1), ctx = cr1n)) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2p); + affect_neg = (f = record_crossings, observed = (; v = c2), ctx = cr2n)) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1p); affect_neg = nothing) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2p); affect_neg = nothing) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1p); affect_neg = nothing) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2p); + affect_neg = (f = record_crossings, observed = (; v = c2), ctx = cr2n)) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1); + rootfind = SciMLBase.RightRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1); + rootfind = SciMLBase.LeftRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = System(eqs, t; continuous_events = [evt1, evt2]) + trigsys_ss = mtkcompile(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], (f = record_crossings, observed = (; v = c1), ctx = cr1); + rootfind = SciMLBase.LeftRootFind) + evt2 = ModelingToolkit.SymbolicContinuousCallback( + [c2 ~ 0], (f = record_crossings, observed = (; v = c2), ctx = cr2); + rootfind = SciMLBase.RightRootFind) + @named trigsys = System(eqs, t; continuous_events = [evt2, evt1]) + trigsys_ss = mtkcompile(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 + @mtkcompile 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 = SymbolicContinuousCallback([x ~ 1.0] => [a ~ -Pre(a)], discrete_parameters = [a]) + function save_affect!(mod, obs, ctx, integ) + return (; b = 5.0) + end + cb2 = [x ~ 0.5] => (f = save_affect!, modified = (; b)) + cb3 = SymbolicDiscreteCallback(1.0 => [c ~ t], discrete_parameters = [c]) + + @mtkcompile sys = System(D(x) ~ cos(t), t, [x], [a, b, c]; + continuous_events = [cb1, cb2], discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0, a => 1.0, b => 2.0, c => 0.0], (0.0, 2pi)) + @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 = System( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = mtkcompile(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 = System( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = mtkcompile(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 = System(eqs, t, [temp], params; continuous_events = [furnace_off]) + ss = mtkcompile(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 = System( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = mtkcompile(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 = System( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = mtkcompile(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 = System( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = mtkcompile(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 = System( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) + ss = mtkcompile(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.ImperativeAffect(f = (m, o, ctx, int) -> (seen = true; return (;))) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], nothing, initialize = [x ~ 1.5], finalize = f) + @mtkcompile sys = System(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.ImperativeAffect(f = (m, o, ctx, int) -> (seen = true; return (;))) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], nothing, initialize = [x ~ 1.5], finalize = f) + inited = false + finaled = false + a = ModelingToolkit.ImperativeAffect(f = ( + m, o, ctx, int) -> (inited = true; return (;))) + b = ModelingToolkit.ImperativeAffect(f = ( + m, o, ctx, int) -> (finaled = true; return (;))) + cb2 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0.1], nothing, initialize = a, finalize = b) + @mtkcompile sys = System(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) + @mtkcompile sys = System(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) + @mtkcompile sys = System(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) + @mtkcompile sys = System(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) + @mtkcompile sys = System(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] + @mtkcompile pend = System(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] => [y ~ 1] + @mtkcompile pend = System(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] + @mtkcompile pend = System(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(t) = 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], [discrete_parameters = k] + end + end + @mtkcompile decay = DECAY() + prob = ODEProblem(decay, [], (0.0, 10.0)) + @test_nowarn solve(prob, Tsit5(), tstops = [1.0]) +end + +@testset "Array parameter updates in ImperativeAffect" 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 System(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 System(eqs, t, vars, params; name = name) # note no event + end + + @named wd1 = weird1(0.021) + @named wd2 = weird2(0.021) + + sys1 = mtkcompile(System(Equation[], 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 = mtkcompile(System(Equation[], 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 + +@testset "Implicit affects with Pre" begin + using ModelingToolkit: UnsolvableCallbackError + @parameters g + @variables x(t) y(t) λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + c_evt = [t ~ 5.0] => [x ~ Pre(x) + 0.1] + @mtkcompile pend = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(pend, [x => -1, y => 0, g => 1], (0.0, 10.0), guesses = [λ => 1]) + sol = solve(prob, FBDF()) + @test ≈(sol(5.000001, idxs = x) - sol(4.999999, idxs = x), 0.1, rtol = 1e-4) + @test ≈(sol(5.000001, idxs = x)^2 + sol(5.000001, idxs = y)^2, 1, rtol = 1e-4) + + # Implicit affect with Pre + c_evt = [t ~ 5.0] => [x ~ Pre(x) + y^2] + @mtkcompile pend = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(pend, [x => 1, y => 0, g => 1], (0.0, 10.0), guesses = [λ => 1]) + sol = solve(prob, FBDF()) + @test ≈(sol(5.000001, idxs = y)^2 + sol(4.999999, idxs = x), + sol(5.000001, idxs = x), rtol = 1e-4) + @test ≈(sol(5.000001, idxs = x)^2 + sol(5.000001, idxs = y)^2, 1, rtol = 1e-4) + + # Impossible affect errors + c_evt = [t ~ 5.0] => [x ~ Pre(x) + 2] + @mtkcompile pend = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(pend, [x => 1, y => 0, g => 1], (0.0, 10.0), guesses = [λ => 1]) + @test_throws UnsolvableCallbackError sol=solve(prob, FBDF()) + + # Changing both variables and parameters in the same affect. + @parameters g(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + c_evt = SymbolicContinuousCallback( + [t ~ 5.0], [x ~ Pre(x) + 0.1, g ~ Pre(g) + 1], discrete_parameters = [g], iv = t) + @mtkcompile pend = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(pend, [x => 1, y => 0, g => 1], (0.0, 10.0), guesses = [λ => 1]) + sol = solve(prob, FBDF()) + @test sol.ps[g] ≈ [1, 2] + @test ≈(sol(5.0000001, idxs = x) - sol(4.999999, idxs = x), 0.1, rtol = 1e-4) + + # Proper re-initialization after parameter change + eqs = [y ~ g^2, D(x) ~ x] + c_evt = SymbolicContinuousCallback( + [t ~ 5.0], [x ~ Pre(x) + 1, g ~ Pre(g) + 1], discrete_parameters = [g], iv = t) + @mtkcompile sys = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(sys, [x => 1.0, g => 2], (0.0, 10.0)) + sol = solve(prob, FBDF()) + @test sol.ps[g] ≈ [2.0, 3.0] + @test ≈(sol(5.00000001, idxs = x) - sol(4.9999999, idxs = x), 1; rtol = 1e-4) + @test ≈(sol(5.00000001, idxs = y), 9, rtol = 1e-4) + + # Parameters that don't appear in affects should not be mutated. + c_evt = [t ~ 5.0] => [x ~ Pre(x) + 1] + @mtkcompile sys = System(eqs, t, continuous_events = c_evt) + prob = ODEProblem(sys, [x => 0.5, g => 2], (0.0, 10.0), guesses = [y => 0]) + sol = solve(prob, FBDF()) + @test prob.ps[g] == sol.ps[g] +end + +@testset "Array parameter updates of parent components in ImperativeEffect" begin + function child(vals; name, max_time = 0.1) + vars = @variables begin + x(t) = 0.0 + end + eqs = reduce(vcat, Symbolics.scalarize.([ + D(x) ~ 1.0 + ])) + reset = ModelingToolkit.ImperativeAffect( + modified = (; vals = Symbolics.scalarize(ParentScope.(vals)), x)) do m, o, _, i + @set! m.vals = m.vals .+ 1 + @set! m.x = 0.0 + return m + end + return System(eqs, t, vars, []; name = name, + continuous_events = [[x ~ max_time] => reset]) + end + shared_pars = @parameters begin + vals(t)[1:2] = 0.0 + end + + @named sys = System(Equation[], t, [], Symbolics.scalarize(vals); + systems = [child(vals; name = :child)]) + sys = mtkcompile(sys) + sol = solve(ODEProblem(sys, [], (0.0, 1.0)), Tsit5()) +end + +@testset "non-floating-point discretes and namespaced affects" begin + function Inner(; name) + @parameters p(t)::Int + @variables x(t) + cevs = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 1.0], [p ~ Pre(p) + 1]; iv = t, discrete_parameters = [p]) + System([D(x) ~ 1], t, [x], [p]; continuous_events = [cevs], name) + end + @named inner = Inner() + @mtkcompile sys = System(Equation[], t; systems = [inner]) + prob = ODEProblem(sys, [inner.x => 0.0, inner.p => 0], (0.0, 5.0)) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + @test sol[inner.p][end] ≈ 1.0 +end + +mutable struct ParamTest + y::Any +end + +@testset "callable parameter and symbolic affect" begin + (pt::ParamTest)(x) = pt.y - x + + p1 = ParamTest(1) + tp1 = typeof(p1) + @parameters (p_1::tp1)(..) = p1 + @parameters p2(t) = 1.0 + @variables x(t) = 0.0 + @variables x2(t) + event = [0.5] => [p2 ~ Pre(t)] + + eq = [ + D(x) ~ p2, + x2 ~ p_1(x) + ] + @mtkcompile sys = System(eq, t, [x, x2], [p_1, p2], discrete_events = [event]) + + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol[x, end]≈1.0 atol=1e-6 +end diff --git a/test/symbolic_indexing_interface.jl b/test/symbolic_indexing_interface.jl new file mode 100644 index 0000000000..89b6907f4b --- /dev/null +++ b/test/symbolic_indexing_interface.jl @@ -0,0 +1,238 @@ +using ModelingToolkit, SymbolicIndexingInterface, SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D, ParameterIndex, + SymbolicContinuousCallback +using SciMLStructures: Tunable + +@testset "System" 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 = System(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, [a => 1.0, b => 2.0], (0.0, 1.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 = System( + 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] + +# @mtkcompile cl = System(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 = System(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 + +@testset "Issue#2767" begin + @parameters p1[1:2]=[1.0, 2.0] p2[1:2]=[0.0, 0.0] + @variables x(t) = 0 + + @named sys = System( + [D(x) ~ sum(p1) * t + sum(p2)], + t; + ) + prob = ODEProblem(complete(sys), [], (0.0, 1.0)) + get_dep = @test_nowarn getu(prob, 2p1) + @test get_dep(prob) == [2.0, 4.0] +end + +@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] + @mtkcompile sys = System([D(x) ~ x * t + p1, y ~ 2x, D(z) ~ p2 * z], t) + prob = ODEProblem( + sys, [x => 1.0, z => ones(2), p1 => 2.0, p2 => ones(2, 2)], (0.0, 1.0)) + @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 = System([D(x) ~ x + a - b, b ~ a + 1], t) + 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 = System([D(x) ~ a * x, y ~ 2x, z ~ 0.0], t) + sys = mtkcompile(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 = SymbolicContinuousCallback( + [x[1] ~ 2.0] => [p ~ -ones(2, 2)], discrete_parameters = [p]) + @mtkcompile sys = System(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..5a01c3d645 100644 --- a/test/symbolic_parameters.jl +++ b/test/symbolic_parameters.jl @@ -1,47 +1,66 @@ 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 = System(eqs, [x, y, z], [σ, ρ, β], name = :ns, defaults = [par; u0]) +ModelingToolkit.get_defaults(ns)[y] = u * 1.1 +resolved = ModelingToolkit.varmap_to_vars(defaults(ns), parameters(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]) @test prob.u0 == [1.0, 1.1, 0.9] -@show sol = solve(prob,NewtonRaphson()) +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 = System([0 ~ -a + ns.x + b], [a], [b], systems = [ns], name = :top) +ModelingToolkit.get_defaults(top)[b] = ns.σ * 0.5 +ModelingToolkit.get_defaults(top)[ns.x] = unknowns(ns, 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(defaults(top), parameters(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()) +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()) +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 = System(eqs, t, vars, [x0]) +sys = complete(sys) +initialValues = [x => x0 + x0 => 10.0] +tspan = (0.0, 1.0) +problem = ODEProblem(sys, initialValues, tspan) +@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..d10e0fdc17 --- /dev/null +++ b/test/test_variable_metadata.jl @@ -0,0 +1,228 @@ +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 = System(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 = System([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 = System([q ~ 2p], t, [x, y], [p, q]; defaults = Dict(x => 2.0, p => 3.0), + guesses = Dict(y => 2.0)) +sys = complete(sys) +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 + +@brownians z +@test ModelingToolkit.getvariabletype(z) == ModelingToolkit.BROWNIAN diff --git a/test/units.jl b/test/units.jl index 99d3d7b2e1..a17dd90575 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 = System(eqs, t) + +@test !UMT.validate(D(D(E)) ~ P) +@test !UMT.validate(0 ~ P + E * τ) + +# Disabling unit validation/checks selectively +@test_throws MT.ArgumentError System(eqs, t, [E, P, t], [τ], name = :sys) +System(eqs, t, [E, P, t], [τ], name = :sys, checks = MT.CheckUnits) +eqs = [D(E) ~ P - E / τ + 0 ~ P + E * τ] +@test_throws MT.ValidationError System(eqs, t, name = :sys, checks = MT.CheckAll) +@test_throws MT.ValidationError System(eqs, t, name = :sys, checks = true) +System(eqs, t, name = :sys, checks = MT.CheckNone) +System(eqs, t, name = :sys, checks = false) +@test_throws MT.ValidationError System(eqs, t, name = :sys, + checks = MT.CheckComponents | MT.CheckUnits) +@named sys = System(eqs, t, checks = MT.CheckComponents) +@test_throws MT.ValidationError System(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]) + System(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]) + System(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]) + System(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 = System(good_eqs, t, [], []) +@test_throws MT.ValidationError System(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] +System(eqs, t, name = :sys) + +# Nonlinear system +@parameters a [unit = u"kg"^-1] +@variables x [unit = u"kg"] +eqs = [ + 0 ~ a * x +] +@named nls = System(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 = System(eqs, t) +sys_simple = mtkcompile(sys) + +eqs = [D(V) ~ r, + V ~ L^3] +@named sys = System(eqs, t) +sys_simple = mtkcompile(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 = System(eqs, [V, L], [t, r]) +sys_simple = mtkcompile(sys) + +eqs = [L ~ v * t, + V ~ L^3] +@named sys = System(eqs, [V, L], [v, t, r]) +sys_simple = mtkcompile(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..60b4e24d64 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..13b813122e 100644 --- a/test/variable_scope.jl +++ b/test/variable_scope.jl @@ -1,35 +1,133 @@ 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] +@named sub4 = System(eqs, [a, b, c, d], []) +@named sub3 = System(eqs, [a, b, c, d], []) +@named sub2 = System(Equation[], [], [], systems = [sub3, sub4]) +@named sub1 = System(Equation[], [], [], systems = [sub2]) +@named sys = System(Equation[], [], [], systems = [sub1]) -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]) - -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 = System(eqs, [a, b, c, d], []) +@named bar = System(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 +p = [a + ParentScope(b) + ParentScope(ParentScope(c)) + GlobalScope(d)] + +level0 = System(Equation[], t, [], p; name = :level0) +level1 = System(Equation[], t, [], []; name = :level1) ∘ level0 +level2 = System(Equation[], t, [], []; name = :level2) ∘ level1 +level3 = System(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], :d) + +# Issue@2252 +# Tests from PR#2354 +@parameters xx[1:2] +arr_p = [ParentScope(xx[1]), xx[2]] +arr0 = System(Equation[], t, [], arr_p; name = :arr0) +arr1 = System(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 System(D(x) ~ p, t; name) +end +function Bar(; name, p = 2) + @parameters p = p + @variables x(t) + @named foo = Foo(; p) + return System(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) + +@testset "Issue#3101" begin + @variables x1(t) x2(t) x3(t) x4(t) + x2 = ParentScope(x2) + x3 = ParentScope(ParentScope(x3)) + x4 = GlobalScope(x4) + @parameters p1 p2 p3 p4 + p2 = ParentScope(p2) + p3 = ParentScope(ParentScope(p3)) + p4 = GlobalScope(p4) + + @named sys1 = System([D(x1) ~ p1, D(x2) ~ p2, D(x3) ~ p3, D(x4) ~ p4], t) + @test isequal(x1, only(unknowns(sys1))) + @test isequal(p1, only(parameters(sys1))) + @named sys2 = System(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 = System(Equation[], t) + sys3 = sys3 ∘ sys2 + @test length(unknowns(sys3)) == 3 + @test any(isequal(x3), unknowns(sys3)) + @test length(parameters(sys3)) == 3 + @test any(isequal(p3), parameters(sys3)) + sys4 = complete(sys3) + @test length(unknowns(sys4)) == 4 + @test length(parameters(sys4)) == 4 + sys5 = mtkcompile(sys3) + @test length(unknowns(sys5)) == 4 + @test any(isequal(x4), unknowns(sys5)) + @test length(parameters(sys5)) == 4 + @test any(isequal(p4), parameters(sys5)) +end diff --git a/test/variable_utils.jl b/test/variable_utils.jl index a39bc2e98f..c088481925 100644 --- a/test/variable_utils.jl +++ b/test/variable_utils.jl @@ -1,27 +1,186 @@ -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 = System( + [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 = System([D(D(x)) ~ p * x], iv; name) + end + function Outer(; name) + @named 😄 = Lorenz() + @named arr = ArrSys() + sys = System(Equation[], iv; name, systems = [😄, arr]) + end + + @mtkcompile 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 + +@testset "At" begin + @independent_variables u + @variables x(t) v(..) w(t)[1:3] + @parameters y z(u, t) r[1:3] + + @test EvalAt(1)(x) isa Num + @test isequal(EvalAt(1)(y), y) + @test_throws ErrorException EvalAt(1)(z) + @test isequal(EvalAt(1)(v), v(1)) + @test isequal(EvalAt(1)(v(t)), v(1)) + @test isequal(EvalAt(1)(v(2)), v(2)) + + arr = EvalAt(1)(w) + var = EvalAt(1)(w[1]) + @test arr isa Symbolics.Arr + @test var isa Num + + @test isequal(EvalAt(1)(r), r) + @test isequal(EvalAt(1)(r[2]), r[2]) + + _x = ModelingToolkit.unwrap(x) + @test EvalAt(1)(_x) isa Symbolics.BasicSymbolic + @test only(arguments(EvalAt(1)(_x))) == 1 + @test EvalAt(1)(D(x)) isa Num +end