diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index ca24f79d0b..14bea532b3 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -20,7 +20,7 @@ jobs: - 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: DISPLAY=:0 xvfb-run -s '-screen 0 1024x768x24' julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' @@ -31,8 +31,8 @@ jobs: 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@v4 + - uses: codecov/codecov-action@v5 with: - file: lcov.info + 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 7a7556efa3..1d1e4ce34e 100644 --- a/.github/workflows/Downstream.yml +++ b/.github/workflows/Downstream.yml @@ -27,17 +27,17 @@ jobs: package: - {user: SciML, repo: SciMLBase.jl, group: Downstream} - {user: SciML, repo: Catalyst.jl, group: All} - - {user: SciML, repo: CellMLToolkit.jl, group: All} + - {user: SciML, repo: CellMLToolkit.jl, group: Core} - {user: SciML, repo: SBMLToolkit.jl, group: All} - {user: SciML, repo: NeuralPDE.jl, group: NNPDE} - {user: SciML, repo: DataDrivenDiffEq.jl, group: Downstream} - - {user: SciML, repo: StructuralIdentifiability.jl, group: All} + - {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: ai4energy, repo: Ai4EComponentLib.jl, group: Downstream} + - {user: SciML, repo: ModelingToolkitNeuralNets.jl, group: All} steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v1 @@ -68,8 +68,8 @@ jobs: exit(0) # Exit immediately, as a success end - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v5 with: - file: lcov.info + files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false diff --git a/.github/workflows/Invalidations.yml b/.github/workflows/Invalidations.yml deleted file mode 100644 index 1e5662eb28..0000000000 --- a/.github/workflows/Invalidations.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Invalidations" - -on: - pull_request: - paths-ignore: - - 'docs/**' - -concurrency: - # Skip intermediate builds: always. - # Cancel intermediate builds: always. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - evaluate-invalidations: - name: "Evaluate Invalidations" - uses: "SciML/.github/.github/workflows/invalidations.yml@v1" diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 93db3ac518..52c5482970 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -25,15 +25,22 @@ jobs: 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/.typos.toml b/.typos.toml index b8c07088b4..a933260fb5 100644 --- a/.typos.toml +++ b/.typos.toml @@ -4,4 +4,6 @@ nd = "nd" Strat = "Strat" eles = "eles" ser = "ser" -isconnection = "isconnection" \ No newline at end of file +isconnection = "isconnection" +Ue = "Ue" +Derivate = "Derivate" diff --git a/Project.toml b/Project.toml index 39f94e545b..693a81e148 100644 --- a/Project.toml +++ b/Project.toml @@ -1,13 +1,15 @@ name = "ModelingToolkit" uuid = "961ee093-0014-501f-94e3-6117800e7a78" authors = ["Yingbo Ma ", "Chris Rackauckas and contributors"] -version = "9.39.1" +version = "9.72.0" [deps] +ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b" AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" ArrayInterface = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" BlockArrays = "8e7c35d0-a365-5155-bbbb-fb81a777f24e" Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" +CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" Compat = "34da2185-b29b-5c13-b0c7-acf172513d20" ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" @@ -15,15 +17,17 @@ DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" 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" DomainSets = "5b8099bc-c8ec-5219-889f-1d9e522a28bf" DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" +EnumX = "4e289a0a-7415-4d19-859d-a7e5c4648b56" ExprTools = "e2ba6199-217a-4e67-a87a-7c52f15ade04" -Expronicon = "6b7a57c9-7cc1-4fdf-b7f5-e857abae3636" FindFirstFunctions = "64ca27bc-2ba2-4a57-88aa-44e436879224" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +FunctionWrappers = "069b7b12-0de2-55c6-9aab-29f3d0a68a2e" FunctionWrappersWrappers = "77dc65aa-8811-40c2-897b-53d922fa7daf" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" @@ -33,13 +37,16 @@ Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316" Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078" +Moshi = "2e0e35c7-a2e4-4343-998d-7ef72827ed2d" NaNMath = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" +OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" RecursiveArrayTools = "731186ca-8d62-57ce-b412-fbd966d074cd" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" RuntimeGeneratedFunctions = "7e49a35a-f44a-4d26-94aa-eba1b4ca6b47" +SCCNonlinearSolve = "9dfe8606-65a1-4bb3-9748-cb89d1561431" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" @@ -59,40 +66,54 @@ Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" DeepDiffs = "ab62b9b5-e342-54a8-a765-a90f495de1a6" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" [extensions] MTKBifurcationKitExt = "BifurcationKit" MTKChainRulesCoreExt = "ChainRulesCore" MTKDeepDiffsExt = "DeepDiffs" +MTKFMIExt = "FMI" +MTKInfiniteOptExt = "InfiniteOpt" MTKLabelledArraysExt = "LabelledArrays" [compat] +ADTypes = "1.14.0" AbstractTrees = "0.3, 0.4" ArrayInterface = "6, 7" -BifurcationKit = "0.3" +BifurcationKit = "0.4" BlockArrays = "1.1" +BoundaryValueDiffEqAscher = "1.1.0" +BoundaryValueDiffEqMIRK = "1.4.0" ChainRulesCore = "1" Combinatorics = "1" +CommonSolve = "0.2.4" Compat = "3.42, 4" ConstructionBase = "1" +DataInterpolations = "6.4" DataStructures = "0.17, 0.18" DeepDiffs = "1" -DiffEqBase = "6.103.0" -DiffEqCallbacks = "2.16, 3" +DelayDiffEq = "5.50" +DiffEqBase = "6.165.1" +DiffEqCallbacks = "2.16, 3, 4" DiffEqNoiseProcess = "5" DiffRules = "0.1, 1.0" +DifferentiationInterface = "0.6.47" Distributed = "1" Distributions = "0.23, 0.24, 0.25" DocStringExtensions = "0.7, 0.8, 0.9" DomainSets = "0.6, 0.7" DynamicQuantities = "^0.11.2, 0.12, 0.13, 1" +EnumX = "1.0.4" ExprTools = "0.1.10" -Expronicon = "0.8" +FMI = "0.14" FindFirstFunctions = "1" ForwardDiff = "0.10.3" +FunctionWrappers = "1.1" FunctionWrappersWrappers = "0.1" Graphs = "1.5.2" +InfiniteOpt = "0.5" InteractiveUtils = "1" JuliaFormatter = "1.0.47" JumpProcesses = "9.13.1" @@ -100,26 +121,37 @@ LabelledArrays = "1.3" Latexify = "0.11, 0.12, 0.13, 0.14, 0.15, 0.16" Libdl = "1" LinearAlgebra = "1" +Logging = "1" MLStyle = "0.4.17" +ModelingToolkitStandardLibrary = "2.19" +Moshi = "0.3" NaNMath = "0.3, 1" -NonlinearSolve = "3.14" +NonlinearSolve = "4.3" +OffsetArrays = "1" OrderedCollections = "1" OrdinaryDiffEq = "6.82.0" +OrdinaryDiffEqCore = "1.15.0" +OrdinaryDiffEqDefault = "1.2" +OrdinaryDiffEqNonlinearSolve = "1.5.0" PrecompileTools = "1" +REPL = "1" RecursiveArrayTools = "3.26" Reexport = "0.2, 1" RuntimeGeneratedFunctions = "0.5.9" -SciMLBase = "2.52.1" -SciMLStructures = "1.0" +SCCNonlinearSolve = "1.0.0" +SciMLBase = "2.75" +SciMLStructures = "1.7" Serialization = "1" Setfield = "0.7, 0.8, 1" -SimpleNonlinearSolve = "0.1.0, 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" -SymbolicIndexingInterface = "0.3.29" -SymbolicUtils = "3.2" -Symbolics = "6.3" +StochasticDelayDiffEq = "1.8.1" +StochasticDiffEq = "6.72.1" +SymbolicIndexingInterface = "0.3.39" +SymbolicUtils = "3.25.1" +Symbolics = "6.37" URIs = "1" UnPack = "0.1, 1.0" Unitful = "1.1" @@ -128,20 +160,28 @@ 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" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Ipopt_jll = "9cc047cb-c261-5740-88fc-0cf96f7bdcc7" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba" OptimizationMOI = "fd9f6733-72f4-499f-8506-86b2bdd0dea1" OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" +OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8" +OrdinaryDiffEqDefault = "50262376-6c5a-4cf5-baba-aaf4f84d72d7" +OrdinaryDiffEqNonlinearSolve = "127b3ac7-2247-4354-8eb6-78cf4e7c58e8" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" ReferenceTests = "324d217c-45ce-50fc-942e-d289b448e8cf" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" @@ -154,4 +194,4 @@ Sundials = "c3572dad-4567-51f8-b174-8c6c989267f4" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["AmplNLWriter", "BenchmarkTools", "ControlSystemsBase", "DelayDiffEq", "NonlinearSolve", "ForwardDiff", "Ipopt", "Ipopt_jll", "ModelingToolkitStandardLibrary", "Optimization", "OptimizationOptimJL", "OptimizationMOI", "OrdinaryDiffEq", "Random", "ReferenceTests", "SafeTestsets", "StableRNGs", "Statistics", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials", "StochasticDelayDiffEq", "Pkg", "JET"] +test = ["AmplNLWriter", "BenchmarkTools", "BoundaryValueDiffEqMIRK", "BoundaryValueDiffEqAscher", "ControlSystemsBase", "DataInterpolations", "DelayDiffEq", "NonlinearSolve", "ForwardDiff", "Ipopt", "Ipopt_jll", "ModelingToolkitStandardLibrary", "Optimization", "OptimizationOptimJL", "OptimizationMOI", "OrdinaryDiffEq", "OrdinaryDiffEqCore", "OrdinaryDiffEqDefault", "REPL", "Random", "ReferenceTests", "SafeTestsets", "StableRNGs", "Statistics", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials", "StochasticDelayDiffEq", "Pkg", "JET", "OrdinaryDiffEqNonlinearSolve", "Logging"] diff --git a/README.md b/README.md index d93a18e3f7..12570c35a3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ lower it to a first order system, symbolically generate the Jacobian function for the numerical integrator, and solve it. ```julia -using DifferentialEquations, ModelingToolkit +using OrdinaryDiffEqDefault, ModelingToolkit using ModelingToolkit: t_nounits as t, D_nounits as D @parameters σ ρ β diff --git a/docs/Project.toml b/docs/Project.toml index 078df7d696..2f7a3c1317 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,19 +1,27 @@ [deps] +Attractors = "f3fd9213-ca85-4dba-9dfd-7fc91308fec7" BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" -DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e" +DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +FMIZoo = "724179cf-c260-40a9-bd27-cccc6fe2f195" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec" Optim = "429524aa-4258-5aef-a3af-852621145aeb" Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba" OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" +PreallocationTools = "d236fae5-4411-538c-8e31-a6e3d9e00b46" SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" +Setfield = "efcf1570-3423-57d1-acb7-fd33fddbac46" StochasticDiffEq = "789caeaf-c7a9-5a7d-9973-96adeb23e2a0" SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" SymbolicUtils = "d1185830-fcd6-423d-90d6-eec64667417b" @@ -21,20 +29,27 @@ Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [compat] +Attractors = "1.24" BenchmarkTools = "1.3" -BifurcationKit = "0.3" -DifferentialEquations = "7.6" +BifurcationKit = "0.4" +CairoMakie = "0.13" +DataInterpolations = "6.5, 8" Distributions = "0.25" Documenter = "1" DynamicQuantities = "^0.11.2, 0.12, 1" +FMI = "0.14" +FMIZoo = "1" ModelingToolkit = "8.33, 9" -NonlinearSolve = "3" +ModelingToolkitStandardLibrary = "2.19" +NonlinearSolve = "3, 4" Optim = "1.7" -Optimization = "3.9" -OptimizationOptimJL = "0.1" +Optimization = "3.9, 4" +OptimizationOptimJL = "0.1, 0.4" OrdinaryDiffEq = "6.31" Plots = "1.36" +PreallocationTools = "0.4" SciMLStructures = "1.1" +Setfield = "1" StochasticDiffEq = "6" SymbolicIndexingInterface = "0.3.1" SymbolicUtils = "3" diff --git a/docs/make.jl b/docs/make.jl index f380867e6c..36a27bf598 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -25,7 +25,12 @@ makedocs(sitename = "ModelingToolkit.jl", modules = [ModelingToolkit], clean = true, doctest = false, linkcheck = true, warnonly = [:docs_block, :missing_docs, :cross_references], - linkcheck_ignore = ["https://epubs.siam.org/doi/10.1137/0903023"], + 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, diff --git a/docs/pages.jl b/docs/pages.jl index f92f869def..97de7d8f76 100644 --- a/docs/pages.jl +++ b/docs/pages.jl @@ -10,14 +10,18 @@ pages = [ "tutorials/stochastic_diffeq.md", "tutorials/discrete_system.md", "tutorials/parameter_identifiability.md", + "tutorials/change_independent_variable.md", "tutorials/bifurcation_diagram_computation.md", + "tutorials/attractors.md", "tutorials/SampledData.md", - "tutorials/domain_connections.md"], + "tutorials/domain_connections.md", + "tutorials/callable_params.md", + "tutorials/linear_analysis.md", + "tutorials/fmi.md"], "Examples" => Any[ "Basic Examples" => Any["examples/higher_order.md", "examples/spring_mass.md", "examples/modelingtoolkitize_index_reduction.md", - "examples/parsing.md", "examples/remake.md"], "Advanced Examples" => Any["examples/tearing_parallelism.md", "examples/sparse_jacobians.md", @@ -31,6 +35,7 @@ pages = [ "basics/InputOutput.md", "basics/MTKLanguage.md", "basics/Validation.md", + "basics/Debugging.md", "basics/DependencyGraphs.md", "basics/Precompilation.md", "basics/FAQ.md"], @@ -39,7 +44,9 @@ pages = [ "systems/JumpSystem.md", "systems/NonlinearSystem.md", "systems/OptimizationSystem.md", - "systems/PDESystem.md"], + "systems/PDESystem.md", + "systems/DiscreteSystem.md", + "systems/ImplicitDiscreteSystem.md"], "comparison.md", "internals.md" ] diff --git a/docs/src/basics/AbstractSystem.md b/docs/src/basics/AbstractSystem.md index 2161613353..d1707f822f 100644 --- a/docs/src/basics/AbstractSystem.md +++ b/docs/src/basics/AbstractSystem.md @@ -63,11 +63,14 @@ Optionally, a system could have: - `get_defaults(sys)`: A `Dict` that maps variables into their default values for the current-level system. - `get_noiseeqs(sys)`: Noise equations of the current-level system. + - `get_description(sys)`: A string that describes what a system represents. - `get_metadata(sys)`: Any metadata about the system or its origin to be used by downstream packages. Note that if you know a system is an `AbstractTimeDependentSystem` you could use `get_iv` to get the unique independent variable directly, rather than using `independent_variables(sys)[1]`, which is clunky and may cause problems if `sys` is an `AbstractMultivariateSystem` because there may be more than one independent variable. `AbstractTimeIndependentSystem`s do not have a method `get_iv`, and `independent_variables(sys)` will return a size-zero result for such. For an `AbstractMultivariateSystem`, `get_ivs` is equivalent. +For the `parameters`, `unknowns`, `continuous_events`, and `discrete_events` accessors there are corresponding `parameters_toplevel`, `unknowns_toplevel`, `continuous_events_toplevel`, and `discrete_events_toplevel` accessors which work similarly, but ignore the content of subsystems. Furthermore, a `equations_toplevel` version of `equations` exists as well, however, it can only be applied to non-complete systems. + A system could also have caches: - `get_jac(sys)`: The Jacobian of a system. @@ -145,3 +148,15 @@ The `AbstractSystem` types allow for specifying default values, for example into the value maps, where for any repeats the value maps override the default. In addition, defaults of a higher level in the system override the defaults of a lower level in the system. + +## Namespacing + +By default, unsimplified systems will namespace variables accessed via `getproperty`. +Systems created via `@mtkbuild`, or ones passed through `structural_simplify` or +`complete` will not perform this namespacing. However, all of these processes modify +the system in a variety of ways. To toggle namespacing without transforming any other +property of the system, use `toggle_namespacing`. + +```@docs +toggle_namespacing +``` diff --git a/docs/src/basics/Composition.md b/docs/src/basics/Composition.md index 78dec8445b..2e5d4be831 100644 --- a/docs/src/basics/Composition.md +++ b/docs/src/basics/Composition.md @@ -56,7 +56,7 @@ x0 = [decay1.x => 1.0 p = [decay1.a => 0.1 decay2.a => 0.2] -using DifferentialEquations +using OrdinaryDiffEq prob = ODEProblem(simplified_sys, x0, (0.0, 100.0), p) sol = solve(prob, Tsit5()) sol[decay2.f] diff --git a/docs/src/basics/Debugging.md b/docs/src/basics/Debugging.md new file mode 100644 index 0000000000..6e2d471461 --- /dev/null +++ b/docs/src/basics/Debugging.md @@ -0,0 +1,73 @@ +# Debugging + +Every (mortal) modeler writes models that contain mistakes or are susceptible to numerical errors in their hunt for the perfect model. +Debugging such errors is part of the modeling process, and ModelingToolkit includes some functionality that helps with this. + +For example, consider an ODE model with "dangerous" functions (here `√`): + +```@example debug +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables u1(t) u2(t) u3(t) +eqs = [D(u1) ~ -√(u1), D(u2) ~ -√(u2), D(u3) ~ -√(u3)] +defaults = [u1 => 1.0, u2 => 2.0, u3 => 3.0] +@named sys = ODESystem(eqs, t; defaults) +sys = structural_simplify(sys) +``` + +This problem causes the ODE solver to crash: + +```@repl debug +prob = ODEProblem(sys, [], (0.0, 10.0), []); +sol = solve(prob, Tsit5()); +``` + +This suggests *that* something went wrong, but not exactly *what* went wrong and *where* it did. +In such situations, the `debug_system` function is helpful: + +```@repl debug +dsys = debug_system(sys; functions = [sqrt]); +dprob = ODEProblem(dsys, [], (0.0, 10.0), []); +dsol = solve(dprob, Tsit5()); +``` + +Now we see that it crashed because `u1` decreased so much that it became negative and outside the domain of the `√` function. +We could have figured that out ourselves, but it is not always so obvious for more complex models. + +Suppose we also want to validate that `u1 + u2 >= 2.0`. We can do this via the assertions functionality. + +```@example debug +@mtkbuild sys = ODESystem(eqs, t; defaults, assertions = [(u1 + u2 >= 2.0) => "Oh no!"]) +``` + +The assertions must be an iterable of pairs, where the first element is the symbolic condition and +the second is a message to be logged when the condition fails. All assertions are added to the +generated code and will cause the solver to reject steps that fail the assertions. For systems such +as the above where the assertion is guaranteed to eventually fail, the solver will likely exit +with a `dtmin` failure.. + +```@example debug +prob = ODEProblem(sys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +``` + +We can use `debug_system` to log the failing assertions in each call to the RHS function. + +```@repl debug +dsys = debug_system(sys; functions = []); +dprob = ODEProblem(dsys, [], (0.0, 10.0)); +dsol = solve(dprob, Tsit5()); +``` + +Note the logs containing the failed assertion and corresponding message. To temporarily disable +logging in a system returned from `debug_system`, use `ModelingToolkit.ASSERTION_LOG_VARIABLE`. + +```@repl debug +dprob[ModelingToolkit.ASSERTION_LOG_VARIABLE] = false; +solve(dprob, Tsit5()); +``` + +```@docs +debug_system +``` diff --git a/docs/src/basics/DependencyGraphs.md b/docs/src/basics/DependencyGraphs.md index 67de5ea8f1..73fbbca9be 100644 --- a/docs/src/basics/DependencyGraphs.md +++ b/docs/src/basics/DependencyGraphs.md @@ -22,3 +22,9 @@ asdigraph eqeq_dependencies varvar_dependencies ``` + +# Miscellaneous + +```@docs +map_variables_to_equations +``` diff --git a/docs/src/basics/Events.md b/docs/src/basics/Events.md index f425fdce5b..23e1e6d7d1 100644 --- a/docs/src/basics/Events.md +++ b/docs/src/basics/Events.md @@ -378,3 +378,218 @@ sol.ps[c] # sol[c] will error, since `c` is not a timeseries value ``` It can be seen that the timeseries for `c` is not saved. + +## [(Experimental) Imperative affects](@id imp_affects) + +The `ImperativeAffect` can be used as an alternative to the aforementioned functional affect form. Note +that `ImperativeAffect` is still experimental; to emphasize this, we do not export it and it should be +included as `ModelingToolkit.ImperativeAffect`. `ImperativeAffect` aims to simplify the manipulation of +system state. + +We will use two examples to describe `ImperativeAffect`: a simple heater and a quadrature encoder. +These examples will also demonstrate advanced usage of `ModelingToolkit.SymbolicContinuousCallback`, +the low-level interface of the tuple form converts into that allows control over the SciMLBase-level +event that is generated for a continuous event. + +### [Heater](@id heater_events) + +Bang-bang control of a heater connected to a leaky plant requires hysteresis in order to prevent rapid control oscillation. + +```@example events +@variables temp(t) +params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on(t)::Bool=false +eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage +] +``` + +Our plant is simple. We have a heater that's turned on and off by the time-indexed parameter `furnace_on` +which adds `furnace_power` forcing to the system when enabled. We then leak heat proportional to `leakage` +as a function of the square of the current temperature. + +We need a controller with hysteresis to control the plant. We wish the furnace to turn on when the temperature +is below `furnace_on_threshold` and off when above `furnace_off_threshold`, while maintaining its current state +in between. To do this, we create two continuous callbacks: + +```@example events +using Setfield +furnace_disable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) +furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = true + end) +``` + +We're using the explicit form of `SymbolicContinuousCallback` here, though +so far we aren't using anything that's not possible with the implicit interface. +You can also write + +```julia +[temp ~ furnace_off_threshold] => ModelingToolkit.ImperativeAffect(modified = (; + furnace_on)) do x, o, i, c + @set! x.furnace_on = false +end +``` + +and it would work the same. + +The `ImperativeAffect` is the larger change in this example. `ImperativeAffect` has the constructor signature + +```julia +ImperativeAffect(f::Function; modified::NamedTuple, observed::NamedTuple, ctx) +``` + +that accepts the function to call, a named tuple of both the names of and symbolic values representing +values in the system to be modified, a named tuple of the values that are merely observed (that is, used from +the system but not modified), and a context that's passed to the affect function. + +In our example, each event merely changes whether the furnace is on or off. Accordingly, we pass a `modified` tuple +`(; furnace_on)` (creating a `NamedTuple` equivalent to `(furnace_on = furnace_on)`). `ImperativeAffect` will then +evaluate this before calling our function to fill out all of the numerical values, then apply them back to the system +once our affect function returns. Furthermore, it will check that it is possible to do this assignment. + +The function given to `ImperativeAffect` needs to have the signature: + +```julia +f(modified::NamedTuple, observed::NamedTuple, ctx, integrator)::NamedTuple +``` + +The function `f` will be called with `observed` and `modified` `NamedTuple`s that are derived from their respective `NamedTuple` definitions. +In our example, if `furnace_on` is `false`, then the value of the `x` that's passed in as `modified` will be `(furnace_on = false)`. +The modified values should be passed out in the same format: to set `furnace_on` to `true` we need to return a tuple `(furnace_on = true)`. +The examples does this with Setfield, recreating the result tuple before returning it; the returned tuple may optionally be missing values as +well, in which case those values will not be written back to the problem. + +Accordingly, we can now interpret the `ImperativeAffect` definitions to mean that when `temp = furnace_off_threshold` we +will write `furnace_on = false` back to the system, and when `temp = furnace_on_threshold` we will write `furnace_on = true` back +to the system. + +```@example events +@named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_disable, furnace_enable]) +ss = structural_simplify(sys) +prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +plot(sol) +hline!([sol.ps[furnace_off_threshold], sol.ps[furnace_on_threshold]], + l = (:black, 1), primary = false) +``` + +Here we see exactly the desired hysteresis. The heater starts on until the temperature hits +`furnace_off_threshold`. The temperature then bleeds away until `furnace_on_threshold` at which +point the furnace turns on again until `furnace_off_threshold` and so on and so forth. The controller +is effectively regulating the temperature of the plant. + +### [Quadrature Encoder](@id quadrature) + +For a more complex application we'll look at modeling a quadrature encoder attached to a shaft spinning at a constant speed. +Traditionally, a quadrature encoder is built out of a code wheel that interrupts the sensors at constant intervals and two sensors slightly out of phase with one another. +A state machine can take the pattern of pulses produced by the two sensors and determine the number of steps that the shaft has spun. The state machine takes the new value +from each sensor and the old values and decodes them into the direction that the wheel has spun in this step. + +```@example events +@variables theta(t) omega(t) +params = @parameters qA=0 qB=0 hA=0 hB=0 cnt::Int=0 +eqs = [D(theta) ~ omega + omega ~ 1.0] +``` + +Our continuous-time system is extremely simple. We have two unknown variables `theta` for the angle of the shaft +and `omega` for the rate at which it's spinning. We then have parameters for the state machine `qA, qB, hA, hB` +(corresponding to the current quadrature of the A/B sensors and the historical ones) and a step count `cnt`. + +We'll then implement the decoder as a simple Julia function. + +```@example events +function decoder(oldA, oldB, newA, newB) + state = (oldA, oldB, newA, newB) + if state == (0, 0, 1, 0) || state == (1, 0, 1, 1) || state == (1, 1, 0, 1) || + state == (0, 1, 0, 0) + return 1 + elseif state == (0, 0, 0, 1) || state == (0, 1, 1, 1) || state == (1, 1, 1, 0) || + state == (1, 0, 0, 0) + return -1 + elseif state == (0, 0, 0, 0) || state == (0, 1, 0, 1) || state == (1, 0, 1, 0) || + state == (1, 1, 1, 1) + return 0 + else + return 0 # err is interpreted as no movement + end +end +``` + +Based on the current and old state, this function will return 1 if the wheel spun in the positive direction, +-1 if in the negative, and 0 otherwise. + +The encoder state advances when the occlusion begins or ends. We model the +code wheel as simply detecting when `cos(100*theta)` is 0; if we're at a positive +edge of the 0 crossing, then we interpret that as occlusion (so the discrete `qA` goes to 1). Otherwise, if `cos` is +going negative, we interpret that as lack of occlusion (so the discrete goes to 0). The decoder function is +then invoked to update the count with this new information. + +We can implement this in one of two ways: using edge sign detection or right root finding. For exposition, we +will implement each sensor differently. + +For sensor A, we're using the edge detection method. By providing a different affect to `SymbolicContinuousCallback`'s +`affect_neg` argument, we can specify different behaviour for the negative crossing vs. the positive crossing of the root. +In our encoder, we interpret this as occlusion or nonocclusion of the sensor, update the internal state, and tick the decoder. + +```@example events +qAevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta) ~ 0], + ModelingToolkit.ImperativeAffect((; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 1 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 0 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end) +``` + +The other way we can implement a sensor is by changing the root find. +Normally, we use left root finding; the affect will be invoked instantaneously _before_ +the root is crossed. This makes it trickier to figure out what the new state is. +Instead, we can use right root finding: + +```@example events +qBevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta - π / 2) ~ 0], + ModelingToolkit.ImperativeAffect((; qB, hA, hB, cnt), (; qA, theta)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = clamp(sign(cos(100 * o.theta - π / 2)), 0.0, 1.0) + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end; rootfind = SciMLBase.RightRootFind) +``` + +Here, sensor B is located `π / 2` behind sensor A in angular space, so we're adjusting our +trigger function accordingly. We here ask for right root finding on the callback, so we know +that the value of said function will have the "new" sign rather than the old one. Thus, we can +determine the new state of the sensor from the sign of the indicator function evaluated at the +affect activation point, with -1 mapped to 0. + +We can now simulate the encoder. + +```@example events +@named sys = ODESystem( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) +ss = structural_simplify(sys) +prob = ODEProblem(ss, [theta => 0.0], (0.0, pi)) +sol = solve(prob, Tsit5(); dtmax = 0.01) +sol.ps[cnt] +``` + +`cos(100*theta)` will have 200 crossings in the half rotation we've gone through, so the encoder would notionally count 200 steps. +Our encoder counts 198 steps (it loses one step to initialization and one step due to the final state falling squarely on an edge). diff --git a/docs/src/basics/FAQ.md b/docs/src/basics/FAQ.md index 44f97c2b25..10671299c6 100644 --- a/docs/src/basics/FAQ.md +++ b/docs/src/basics/FAQ.md @@ -82,6 +82,16 @@ parameter_index(sys, sym) Note that while the variable index will be an integer, the parameter index is a struct of type `ParameterIndex` whose internals should not be relied upon. +## Can I index with strings? + +Strings are not considered symbolic variables, and thus cannot directly be used for symbolic +indexing. However, ModelingToolkit does provide a method to parse the string representation of +a variable, given the system in which that variable exists. + +```@docs +ModelingToolkit.parse_variable +``` + ## Transforming value maps to arrays ModelingToolkit.jl allows (and recommends) input maps like `[x => 2.0, y => 3.0]` @@ -244,3 +254,62 @@ D = Differential(x) @variables y(x) @named sys = ODESystem([D(y) ~ x], x) ``` + +## Ordering of tunable parameters + +Tunable parameters are floating point parameters, not used in callbacks and not marked with `tunable = false` in their metadata. These are expected to be used with AD +and optimization libraries. As such, they are stored together in one `Vector{T}`. To obtain the ordering of tunable parameters in this buffer, use: + +```@docs +tunable_parameters +``` + +If you have an array in which a particular dimension is in the order of tunable parameters (e.g. the jacobian with respect to tunables) then that dimension of the +array can be reordered into the required permutation using the symbolic variables: + +```@docs +reorder_dimension_by_tunables! +reorder_dimension_by_tunables +``` + +For example: + +```@example reorder +using ModelingToolkit + +@parameters p q[1:3] r[1:2, 1:2] + +@named sys = ODESystem(Equation[], ModelingToolkit.t_nounits, [], [p, q, r]) +sys = complete(sys) +``` + +The canonicalized tunables portion of `MTKParameters` will be in the order of tunables: + +```@example reorder +using SciMLStructures: canonicalize, Tunable + +ps = MTKParameters(sys, [p => 1.0, q => [2.0, 3.0, 4.0], r => [5.0 6.0; 7.0 8.0]]) +arr = canonicalize(Tunable(), ps)[1] +``` + +We can reorder this to contain the value for `p`, then all values for `q`, then for `r` using: + +```@example reorder +reorder_dimension_by_tunables(sys, arr, [p, q, r]) +``` + +This also works with interleaved subarrays of symbolics: + +```@example reorder +reorder_dimension_by_tunables(sys, arr, [q[1], r[1, :], q[2], r[2, :], q[3], p]) +``` + +And arbitrary dimensions of higher dimensional arrays: + +```@example reorder +highdimarr = stack([i * arr for i in 1:5]; dims = 1) +``` + +```@example reorder +reorder_dimension_by_tunables(sys, highdimarr, [q[1:2], r[1, :], q[3], r[2, :], p]; dim = 2) +``` diff --git a/docs/src/basics/InputOutput.md b/docs/src/basics/InputOutput.md index 4f42d9dcd7..4dc5a3d50f 100644 --- a/docs/src/basics/InputOutput.md +++ b/docs/src/basics/InputOutput.md @@ -55,7 +55,6 @@ We can inspect the state realization chosen by MTK x_sym ``` -as expected, `x` is chosen as the state variable. as expected, `x` is chosen as the state variable. ```@example inputoutput diff --git a/docs/src/basics/Linearization.md b/docs/src/basics/Linearization.md index 0b29beec2f..1c06ce72d4 100644 --- a/docs/src/basics/Linearization.md +++ b/docs/src/basics/Linearization.md @@ -22,7 +22,7 @@ The `linearize` function expects the user to specify the inputs ``u`` and the ou ```@example LINEARIZE using ModelingToolkit using ModelingToolkit: t_nounits as t, D_nounits as D -@variables x(t)=0 y(t)=0 u(t)=0 r(t)=0 +@variables x(t)=0 y(t) u(t) r(t)=0 @parameters kp = 1 eqs = [u ~ kp * (r - y) # P controller @@ -43,7 +43,7 @@ using ModelingToolkit: inputs, outputs !!! 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/ModelingToolkitStandardLibrary/stable/API/linear_analysis/) for utilities that make linearization of completed models easier. + 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" @@ -57,6 +57,74 @@ The operating point to linearize around can be specified with the keyword argume If linearization is to be performed around multiple operating points, the simplification of the system has to be carried out a single time only. To facilitate this, the lower-level function [`ModelingToolkit.linearization_function`](@ref) is available. This function further allows you to obtain separate Jacobians for the differential and algebraic parts of the model. For ODE models without algebraic equations, the statespace representation above is available from the output of `linearization_function` as `A, B, C, D = f_x, f_u, h_x, h_u`. +All variables that will be fixed by an operating point _must_ be provided in the operating point to `linearization_function`. For example, if the operating points fix the value of +`x`, `y` and `z` then an operating point with constant values for these variables (e.g. `Dict(x => 1.0, y => 1.0, z => 1.0)`) must be provided. The constant values themselves +do not matter and can be changed by subsequent operating points. + +One approach to batch linearization would be to call `linearize` in a loop, providing a different operating point each time. For example: + +```@example LINEARIZE +using ModelingToolkitStandardLibrary +using ModelingToolkitStandardLibrary.Blocks + +@parameters k=10 k3=2 c=1 +@variables x(t)=0 [bounds = (-0.5, 1.5)] +@variables v(t) = 0 + +@named y = Blocks.RealOutput() +@named u = Blocks.RealInput() + +eqs = [D(x) ~ v + D(v) ~ -k * x - k3 * x^3 - c * v + 10u.u + y.u ~ x] + +@named duffing = ODESystem(eqs, t, systems = [y, u], defaults = [u.u => 0]) + +# pass a constant value for `x`, since it is the variable we will change in operating points +linfun, simplified_sys = linearization_function(duffing, [u.u], [y.u]; op = Dict(x => NaN)); + +println(linearize(simplified_sys, linfun; op = Dict(x => 1.0))) +println(linearize(simplified_sys, linfun; op = Dict(x => 0.0))) + +@time linearize(simplified_sys, linfun; op = Dict(x => 0.0)) + +nothing # hide +``` + +However, this route is still expensive since it has to repeatedly process the symbolic map provided to `op`. `linearize` is simply a wrapper for creating and solving a +[`ModelingToolkit.LinearizationProblem`](@ref). This object is symbolically indexable, and can thus integrate with SymbolicIndexingInterface.jl for fast updates. + +```@example LINEARIZE +using SymbolicIndexingInterface + +# The second argument is the value of the independent variable `t`. +linprob = LinearizationProblem(linfun, 1.0) +# It can be mutated +linprob.t = 0.0 +# create a setter function to update `x` efficiently +setter! = setu(linprob, x) + +function fast_linearize!(problem, setter!, value) + setter!(problem, value) + solve(problem) +end + +println(fast_linearize!(linprob, setter!, 1.0)) +println(fast_linearize!(linprob, setter!, 0.0)) + +@time fast_linearize!(linprob, setter!, 1.0) + +nothing # hide +``` + +Note that `linprob` above can be interacted with similar to a normal `ODEProblem`. + +```@repl LINEARIZE +prob[x] +prob[x] = 1.5 +prob[x] +``` + ## Symbolic linearization The function [`ModelingToolkit.linearize_symbolic`](@ref) works similar to [`ModelingToolkit.linearize`](@ref) but returns symbolic rather than numeric Jacobians. Symbolic linearization have several limitations and no all systems that can be linearized numerically can be linearized symbolically. @@ -75,7 +143,7 @@ If the modeled system is actually proper (but MTK failed to find a proper realiz ## Tools for linear analysis -[ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/) contains a set of [tools for more advanced linear analysis](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/API/linear_analysis/). These can be used to make it easier to work with and analyze causal models, such as control and signal-processing systems. +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. @@ -89,4 +157,5 @@ Pages = ["Linearization.md"] linearize ModelingToolkit.linearize_symbolic ModelingToolkit.linearization_function +ModelingToolkit.LinearizationProblem ``` diff --git a/docs/src/basics/MTKLanguage.md b/docs/src/basics/MTKLanguage.md index a2fb7d0870..2bcec99b6a 100644 --- a/docs/src/basics/MTKLanguage.md +++ b/docs/src/basics/MTKLanguage.md @@ -23,6 +23,7 @@ equations. `@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 ODESystem @@ -42,6 +43,7 @@ using ModelingToolkit using ModelingToolkit: t @mtkmodel ModelA begin + @description "A component with parameters `k` and `k_array`." @parameters begin k k_array[1:2] @@ -49,6 +51,7 @@ using ModelingToolkit: t 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"] @@ -56,6 +59,7 @@ 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."] @@ -63,16 +67,17 @@ 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:2, 1:3] + v_array(t)[1:N, 1:M] v_for_defaults(t) end - @extend ModelB(; p1) + @extend ModelB(p1 = 1) @components begin model_a = ModelA(; k_array) model_array_a = [ModelA(; k = i) for i in 1:N] @@ -90,6 +95,10 @@ end end ``` +#### `@description` + +A documenting `String` that summarizes and explains what the model is. + #### `@icon` An icon can be embedded in 3 ways: @@ -146,16 +155,20 @@ julia> ModelingToolkit.getdefault(model_c1.v) 2.0 ``` -#### `@extend` begin block +#### `@extend` statement - - Partial systems can be extended in a higher system as `@extend PartialSystem(; kwargs)`. - - Keyword arguments pf partial system in the `@extend` definition are added as the keyword arguments of the base system. - - Note that in above example, `p1` is promoted as an argument of `ModelC`. Users can set the value of `p1`. However, as `p2` isn't listed in the model definition, its initial guess can't be specified while creating an instance of `ModelC`. +One or more partial systems can be extended in a higher system with `@extend` statements. This can be done in two ways: -```julia -julia> @mtkbuild model_c2 = ModelC(; p1 = 2.0) + - `@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 @@ -301,14 +314,15 @@ end For more examples of usage, checkout [ModelingToolkitStandardLibrary.jl](https://github.com/SciML/ModelingToolkitStandardLibrary.jl/) -## More on `Model.structure` +## [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, name given to the base system, and name of the base system. + - `: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`. @@ -324,17 +338,57 @@ For example, the structure of `ModelC` is: 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(:type=>Real, :size=>(2, 3)), :v_for_defaults=>Dict(:type=>Real)) + :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), :N=>Dict(:value=>2), :v=>Dict{Symbol, Any}(:value=>:v_var, :type=>Real), :v_array=>Dict{Symbol, Union{Nothing, UnionAll}}(:value=>nothing, :type=>AbstractArray{Real}), :v_for_defaults=>Dict{Symbol, Union{Nothing, DataType}}(:value=>nothing, :type=>Real), :p1=>Dict(:value=>nothing)) - :structural_parameters => Dict{Symbol, Dict}(:f=>Dict(:value=>:sin), :N=>Dict(:value=>2)) - :independent_variable => t + :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 diff --git a/docs/src/basics/Variable_metadata.md b/docs/src/basics/Variable_metadata.md index 6462c3a469..43d0a7f2ea 100644 --- a/docs/src/basics/Variable_metadata.md +++ b/docs/src/basics/Variable_metadata.md @@ -54,6 +54,11 @@ using ModelingToolkit: t_nounits as t, D_nounits as D @variables i(t) [connect = Flow] @variables k(t) [connect = Stream] +hasconnect(i) +``` + +```@example connect +getconnect(k) ``` ## Input or output @@ -86,6 +91,44 @@ 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. + +```@example metadata +@variables x[1:2, 1:2] [bounds = (-1, 1)] +hasbounds(x) +``` + +```@example metadata +getbounds(x) +``` + +```@example metadata +getbounds(x[1, 1]) +``` + +```@example metadata +getbounds(x[1:2, 1]) +``` + +```@example metadata +@variables x[1:2] [bounds = (-Inf, [1.0, Inf])] +hasbounds(x) +``` + +```@example metadata +getbounds(x) +``` + +```@example metadata +getbounds(x[2]) +``` + +```@example metadata +hasbounds(x[2]) +``` + ## Guess Specify an initial guess for custom initial conditions of an `ODESystem`. @@ -139,8 +182,46 @@ 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) -```julia -@variable important_value [irreducible = true] +```@example metadata +@variables important_value [irreducible = true] +isirreducible(important_value) +``` + +## State Priority + +When a model is structurally simplified, the algorithm will try to ensure that the variables with higher state priority become states of the system. A variable's state priority is a number set using the `state_priority` metadata. + +```@example metadata +@variables important_dof [state_priority = 10] unimportant_dof [state_priority = -2] +state_priority(important_dof) +``` + +## Units + +Units for variables can be designated using symbolic metadata. For more information, please see the [model validation and units](@ref units) section of the docs. Note that `getunit` is not equivalent to `get_unit` - the former is a metadata getter for individual variables (and is provided so the same interface function for `unit` exists like other metadata), while the latter is used to handle more general symbolic expressions. + +```@example metadata +using DynamicQuantities +@variables speed [unit = u"m/s"] +hasunit(speed) +``` + +```@example metadata +getunit(speed) +``` + +## Miscellaneous metadata + +User-defined metadata can be added using the `misc` metadata. This can be queried +using the `hasmisc` and `getmisc` functions. + +```@example metadata +@variables u [misc = :conserved_parameter] y [misc = [2, 4, 6]] +hasmisc(u) +``` + +```@example metadata +getmisc(y) ``` ## Additional functions diff --git a/docs/src/comparison.md b/docs/src/comparison.md index fcf0c70afb..52d5ab2f70 100644 --- a/docs/src/comparison.md +++ b/docs/src/comparison.md @@ -23,9 +23,9 @@ - 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. + - 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 diff --git a/docs/src/examples/higher_order.md b/docs/src/examples/higher_order.md index 7dafe758dc..fac707525f 100644 --- a/docs/src/examples/higher_order.md +++ b/docs/src/examples/higher_order.md @@ -3,7 +3,7 @@ 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 +numerically solve. One simple to demonstrate transformation, is `structural_simplify`, which does a lot of tricks, one being the transformation that turns an Nth order ODE into N coupled 1st order ODEs. @@ -15,16 +15,28 @@ We utilize the derivative operator twice here to define the second order: using ModelingToolkit, OrdinaryDiffEq using ModelingToolkit: t_nounits as t, D_nounits as D -@parameters σ ρ β -@variables x(t) y(t) z(t) - -eqs = [D(D(x)) ~ σ * (y - x), - D(y) ~ x * (ρ - z) - y, - D(z) ~ x * y - β * z] - -@named sys = ODESystem(eqs, t) +@mtkmodel SECOND_ORDER begin + @parameters begin + σ = 28.0 + ρ = 10.0 + β = 8 / 3 + end + @variables begin + x(t) = 1.0 + y(t) = 0.0 + z(t) = 0.0 + end + @equations begin + D(D(x)) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + D(z) ~ x * y - β * z + end +end +@mtkbuild sys = SECOND_ORDER() ``` +The second order ODE has been automatically transformed to two first order ODEs. + Note that we could've used an alternative syntax for 2nd order, i.e. `D = Differential(t)^2` and then `D(x)` would be the second derivative, and this syntax extends to `N`-th order. Also, we can use `*` or `∘` to compose @@ -33,28 +45,17 @@ and this syntax extends to `N`-th order. Also, we can use `*` or `∘` to compos Now let's transform this into the `ODESystem` of first order components. We do this by calling `structural_simplify`: -```@example orderlowering -sys = structural_simplify(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: +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(x) => 2.0, - x => 1.0, - y => 0.0, - z => 0.0] - -p = [σ => 28.0, - ρ => 10.0, - β => 8 / 3] - +u0 = [D(sys.x) => 2.0] tspan = (0.0, 100.0) -prob = ODEProblem(sys, u0, tspan, p, jac = true) +prob = ODEProblem(sys, u0, tspan, [], jac = true) sol = solve(prob, Tsit5()) -using Plots; -plot(sol, idxs = (x, y)); +using Plots +plot(sol, idxs = (sys.x, sys.y)) ``` diff --git a/docs/src/examples/modelingtoolkitize_index_reduction.md b/docs/src/examples/modelingtoolkitize_index_reduction.md index 415d5b85ff..b19ea46701 100644 --- a/docs/src/examples/modelingtoolkitize_index_reduction.md +++ b/docs/src/examples/modelingtoolkitize_index_reduction.md @@ -51,6 +51,14 @@ 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://docs.sciml.ai/DiffEqDocs/stable/tutorials/dae_example/#Mass-Matrix-Differential-Algebraic-Equations-(DAEs)) to arrive at code for simulating the model: diff --git a/docs/src/examples/parsing.md b/docs/src/examples/parsing.md deleted file mode 100644 index 66a4e4d82f..0000000000 --- a/docs/src/examples/parsing.md +++ /dev/null @@ -1,33 +0,0 @@ -# Parsing Expressions into Solvable Systems - -Many times when creating DSLs or creating ModelingToolkit extensions to read new file formats, -it can become imperative to parse expressions. In many cases, it can be easy to use `Base.parse` -to take things to standard Julia expressions, but how can you take a `Base.Expr` and generate -symbolic forms from that? For example, say we had the following system we wanted to solve: - -```@example parsing -ex = [:(y ~ x) - :(y ~ -2x + 3 / z) - :(z ~ 2)] -``` - -We can use the function `parse_expr_to_symbolic` from Symbolics.jl to generate the symbolic -form of the expression: - -```@example parsing -using Symbolics -eqs = parse_expr_to_symbolic.(ex, (Main,)) -``` - -From there, we can use ModelingToolkit to transform the symbolic equations into a numerical -nonlinear solve: - -```@example parsing -using ModelingToolkit, SymbolicIndexingInterface, NonlinearSolve -vars = union(ModelingToolkit.vars.(eqs)...) -@mtkbuild ns = NonlinearSystem(eqs, vars, []) - -varmap = Dict(SymbolicIndexingInterface.getname.(vars) .=> vars) -prob = NonlinearProblem(ns, [varmap[:x] => 1.0, varmap[:y] => 1.0, varmap[:z] => 1.0]) -sol = solve(prob, NewtonRaphson()) -``` diff --git a/docs/src/examples/perturbation.md b/docs/src/examples/perturbation.md index f603178e37..0d1a493cb4 100644 --- a/docs/src/examples/perturbation.md +++ b/docs/src/examples/perturbation.md @@ -1,177 +1,105 @@ # [Symbolic-Numeric Perturbation Theory for ODEs](@id perturb_diff) -## Prelims +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). -In the previous tutorial, [Mixed Symbolic-Numeric Perturbation Theory](https://symbolics.juliasymbolics.org/stable/examples/perturbation/), we discussed how to solve algebraic equations using **Symbolics.jl**. Here, our goal is to extend the method to differential equations. First, we import the following helper functions that were introduced in [Mixed Symbolic/Numerical Methods for Perturbation Theory - Algebraic Equations](@ref perturb_alg): +## Free fall in a varying gravitational field -```julia -using Symbolics, SymbolicUtils - -def_taylor(x, ps) = sum([a * x^i for (i, a) in enumerate(ps)]) -def_taylor(x, ps, p₀) = p₀ + def_taylor(x, ps) - -function collect_powers(eq, x, ns; max_power = 100) - eq = substitute(expand(eq), Dict(x^j => 0 for j in (last(ns) + 1):max_power)) - - eqs = [] - for i in ns - powers = Dict(x^j => (i == j ? 1 : 0) for j in 1:last(ns)) - push!(eqs, substitute(eq, powers)) - end - eqs -end - -function solve_coef(eqs, ps) - vals = Dict() - - for i in 1:length(ps) - eq = substitute(eqs[i], vals) - vals[ps[i]] = Symbolics.symbolic_linear_solve(eq ~ 0, ps[i]) - end - vals -end -``` - -## The Trajectory of a Ball! - -In the first two examples, we applied the perturbation method to algebraic problems. However, the main power of the perturbation method is to solve differential equations (usually ODEs, but also occasionally PDEs). Surprisingly, the main procedure developed to solve algebraic problems works well for differential equations. In fact, we will use the same two helper functions, `collect_powers` and `solve_coef`. The main difference is in the way we expand the dependent variables. For algebraic problems, the coefficients of $\epsilon$ are constants; whereas, for differential equations, they are functions of the dependent variable (usually time). - -As the first ODE example, we have chosen a simple and well-behaved problem, which is a variation of a standard first-year physics problem: what is the trajectory of an object (say, a ball, or a rocket) thrown vertically at velocity $v$ from the surface of a planet? Assuming a constant acceleration of gravity, $g$, every burgeoning physicist knows the answer: $x(t) = x(0) + vt - \frac{1}{2}gt^2$. However, what happens if $g$ is not constant? Specifically, $g$ is inversely proportional to the distant from the center of the planet. If $v$ is large and the projectile travels a large fraction of the radius of the planet, the assumption of constant gravity does not hold anymore. However, unless $v$ is large compared to the escape velocity, the correction is usually small. After simplifications and change of variables to dimensionless, the problem becomes +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 - \ddot{x}(t) = -\frac{1}{(1 + \epsilon x(t))^2} -``` - -with the initial conditions $x(0) = 0$, and $\dot{x}(0) = 1$. Note that for $\epsilon = 0$, this equation transforms back to the standard one. Let's start with defining the variables - -```julia -using ModelingToolkit: t_nounits as t, D_nounits as D -n = 3 -@variables ϵ y[1:n](t) ∂∂y[1:n](t) -``` - -Next, we define $x$. - -```julia -x = def_taylor(ϵ, y[3:end], y[2]) +ẍ = -\frac{GM}{(R+x)^2} = -\frac{GM}{R^2} \frac{1}{\left(1+ϵ\frac{x}{R}\right)^2}. ``` -We need the second derivative of `x`. It may seem that we can do this using `Differential(t)`; however, this operation needs to wait for a few steps because we need to manipulate the differentials as separate variables. Instead, we define dummy variables `∂∂y` as the placeholder for the second derivatives and define - -```julia -∂∂x = def_taylor(ϵ, ∂∂y[3:end], ∂∂y[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. -as the second derivative of `x`. After rearrangement, our governing equation is $\ddot{x}(t)(1 + \epsilon x(t))^{-2} + 1 = 0$, or - -```julia -eq = ∂∂x * (1 + ϵ * x)^2 + 1 -``` +To make the problem dimensionless, we redefine $x \leftarrow x / R$ and $t \leftarrow t / \sqrt{R^3/GM}$. Then the ODE becomes -The next two steps are the same as the ones for algebraic equations (note that we pass `1:n` to `collect_powers` because the zeroth order term is needed here) - -```julia -eqs = collect_powers(eq, ϵ, 1:n) +```@example perturbation +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables ϵ x(t) +eq = D(D(x)) ~ -(1 + ϵ * x)^(-2) ``` -and, +Next, expand $x(t)$ in a series up to second order in $ϵ$: -```julia -vals = solve_coef(eqs, ∂∂y) +```@example perturbation +using Symbolics +@variables y(t)[0:2] # coefficients +x_series = series(y, ϵ) ``` -Our system of ODEs is forming. Now is the time to convert `∂∂`s to the correct **Symbolics.jl** form by substitution: +Insert this into the equation and collect perturbed equations to each order: -```julia -subs = Dict(∂∂y[i] => D(D(y[i])) for i in eachindex(y)) -eqs = [substitute(first(v), subs) ~ substitute(last(v), subs) for v in vals] +```@example perturbation +eq_pert = substitute(eq, x => x_series) +eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2) ``` -We are nearly there! From this point on, the rest is standard ODE solving procedures. Potentially, we can use a symbolic ODE solver to find a closed form solution to this problem. However, **Symbolics.jl** currently does not support this functionality. Instead, we solve the problem numerically. We form an `ODESystem`, lower the order (convert second derivatives to first), generate an `ODEProblem` (after passing the correct initial conditions), and, finally, solve it. +!!! 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. -```julia -using ModelingToolkit, DifferentialEquations +These are the ODEs we want to solve. Now construct an `ODESystem`, which automatically inserts dummy derivatives for the velocities: -@mtkbuild sys = ODESystem(eqs, t) -unknowns(sys) +```@example perturbation +@mtkbuild sys = ODESystem(eqs_pert, t) ``` -```julia -# the initial conditions -# everything is zero except the initial velocity -u0 = zeros(2n + 2) -u0[3] = 1.0 # y₀ˍt +To solve the `ODESystem`, we generate an `ODEProblem` with initial conditions $x(0) = 0$, and $ẋ(0) = 1$, and solve it: -prob = ODEProblem(sys, u0, (0, 3.0)) -sol = solve(prob; dtmax = 0.01) +```@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) ``` -Finally, we calculate the solution to the problem as a function of `ϵ` by substituting the solution to the ODE system back into the defining equation for `x`. Note that `𝜀` is a number, compared to `ϵ`, which is a symbolic variable. - -```julia -X = 𝜀 -> sum([𝜀^(i - 1) * sol[y[i]] for i in eachindex(y)]) -``` +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 $ϵ$: -Using `X`, we can plot the trajectory for a range of $𝜀$s. - -```julia +```@example perturbation using Plots - -plot(sol.t, hcat([X(𝜀) for 𝜀 in 0.0:0.1:0.5]...)) +p = plot() +for ϵᵢ in 0.0:0.1:1.0 + plot!(p, sol, idxs = substitute(x_series, ϵ => ϵᵢ), label = "ϵ = $ϵᵢ") +end +p ``` -As expected, as `𝜀` becomes larger (meaning the gravity is less with altitude), the object goes higher and stays up for a longer duration. Of course, we could have solved the problem directly using as ODE solver. One of the benefits of the perturbation method is that we need to run the ODE solver only once and then can just calculate the answer for different values of `𝜀`; whereas, if we had used the direct method, we would need to run the solver once for each value of `𝜀`. - -## A Weakly Nonlinear Oscillator +This makes sense: for larger $ϵ$, gravity weakens with altitude, and the trajectory goes higher for a fixed initial velocity. -For the next example, we have chosen a simple example from a very important class of problems, the nonlinear oscillators. As we will see, perturbation theory has difficulty providing a good solution to this problem, but the process is instructive. This example closely follows the chapter 7.6 of *Nonlinear Dynamics and Chaos* by Steven Strogatz. +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 $ϵ$. -The goal is to solve $\ddot{x} + 2\epsilon\dot{x} + x = 0$, where the dot signifies time-derivatives and the initial conditions are $x(0) = 0$ and $\dot{x}(0) = 1$. If $\epsilon = 0$, the problem reduces to the simple linear harmonic oscillator with the exact solution $x(t) = \sin(t)$. We follow the same steps as the previous example. +## Weakly nonlinear oscillator -```julia -n = 3 -@variables ϵ t y[1:n](t) ∂y[1:n] ∂∂y[1:n] -x = def_taylor(ϵ, y[3:end], y[2]) -∂x = def_taylor(ϵ, ∂y[3:end], ∂y[2]) -∂∂x = def_taylor(ϵ, ∂∂y[3:end], ∂∂y[2]) -``` +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. -This time we also need the first derivative terms. Continuing, +The goal is to solve the ODE -```julia -eq = ∂∂x + 2 * ϵ * ∂x + x -eqs = collect_powers(eq, ϵ, 0:n) -vals = solve_coef(eqs, ∂∂y) +```@example perturbation +eq = D(D(x)) + 2 * ϵ * D(x) + x ~ 0 ``` -Next, we need to replace `∂`s and `∂∂`s with their **Symbolics.jl** counterparts: - -```julia -subs1 = Dict(∂y[i] => D(y[i]) for i in eachindex(y)) -subs2 = Dict(∂∂y[i] => D(D(y[i])) for i in eachindex(y)) -subs = subs1 ∪ subs2 -eqs = [substitute(first(v), subs) ~ substitute(last(v), subs) for v in vals] -``` +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 continue with converting 'eqs' to an `ODEProblem`, solving it, and finally plot the results against the exact solution to the original problem, which is $x(t, \epsilon) = (1 - \epsilon)^{-1/2} e^{-\epsilon t} \sin((1- \epsilon^2)^{1/2}t)$, +We follow the same steps as in the previous example to construct the `ODESystem`: -```julia -@mtkbuild sys = ODESystem(eqs, t) +```@example perturbation +eq_pert = substitute(eq, x => x_series) +eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2) +@mtkbuild sys = ODESystem(eqs_pert, t) ``` -```julia -# the initial conditions -u0 = zeros(2n + 2) -u0[3] = 1.0 # y₀ˍt - -prob = ODEProblem(sys, u0, (0, 50.0)) -sol = solve(prob; dtmax = 0.01) +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: -X = 𝜀 -> sum([𝜀^(i - 1) * sol[y[i]] for i in eachindex(y)]) -T = sol.t -Y = 𝜀 -> exp.(-𝜀 * T) .* sin.(sqrt(1 - 𝜀^2) * T) / sqrt(1 - 𝜀^2) # exact solution +```@example perturbation +u0 = Dict([unknowns(sys) .=> 0.0; D(y[0]) => 1.0]) # nonzero initial velocity +prob = ODEProblem(sys, u0, (0.0, 50.0)) +sol = solve(prob) +plot(sol, idxs = substitute(x_series, ϵ => 0.1); label = "Perturbative (ϵ=0.1)") -plot(sol.t, [Y(0.1), X(0.1)]) +x_exact(t, ϵ) = exp(-ϵ * t) * sin(√(1 - ϵ^2) * t) / √(1 - ϵ^2) +plot!(sol.t, x_exact.(sol.t, 0.1); label = "Exact (ϵ=0.1)") ``` -The figure 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 perturbation method curve diverges from the true 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*. +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 index f4396ef7a7..91dba4d7ae 100644 --- a/docs/src/examples/remake.md +++ b/docs/src/examples/remake.md @@ -45,12 +45,22 @@ parameters to optimize. ```@example Remake using SymbolicIndexingInterface: parameter_values, state_values -using SciMLStructures: Tunable, replace, replace! +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 - ps = replace(Tunable(), ps, x) # create a copy with the values passed to the loss function + 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] @@ -81,49 +91,24 @@ 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), lb = 0.1zeros(4), ub = 3ones(4)) + optfn, rand(4), (odeprob, timesteps, data, setter, diffcache), + lb = 0.1zeros(4), ub = 3ones(4)) sol = solve(optprob, BFGS()) ``` -To identify which values correspond to which parameters, we can `replace!` them into the -`ODEProblem`: - -```@example Remake -replace!(Tunable(), parameter_values(odeprob), sol.u) -odeprob.ps[[α, β, γ, δ]] -``` - -`replace!` operates in-place, so the values being replaced must be of the same type as those -stored in the parameter object, or convertible to that type. For demonstration purposes, we -can construct a loss function that uses `replace!`, and calculate gradients using -`AutoFiniteDiff` rather than `AutoForwardDiff`. - -```@example Remake -function loss2(x, p) - odeprob = p[1] # ODEProblem stored as parameters to avoid using global variables - newprob = remake(odeprob) # copy the problem with `remake` - # update the parameter values in-place - replace!(Tunable(), parameter_values(newprob), x) - timesteps = p[2] - sol = solve(newprob, AutoTsit5(Rosenbrock23()); saveat = timesteps) - truth = p[3] - data = Array(sol) - return sum((truth .- data) .^ 2) / length(truth) -end - -# use finite-differencing to calculate derivatives -optfn2 = OptimizationFunction(loss2, Optimization.AutoFiniteDiff()) -optprob2 = OptimizationProblem( - optfn2, rand(4), (odeprob, timesteps, data), lb = 0.1zeros(4), ub = 3ones(4)) -sol = solve(optprob2, BFGS()) -``` - # Re-creating the problem There are multiple ways to re-create a problem with new state/parameter values. We will go diff --git a/docs/src/examples/sparse_jacobians.md b/docs/src/examples/sparse_jacobians.md index 1ed3b1733c..03bd80d432 100644 --- a/docs/src/examples/sparse_jacobians.md +++ b/docs/src/examples/sparse_jacobians.md @@ -11,7 +11,7 @@ First, let's start out with an implementation of the 2-dimensional Brusselator partial differential equation discretized using finite differences: ```@example sparsejac -using DifferentialEquations, ModelingToolkit +using OrdinaryDiffEq, ModelingToolkit const N = 32 const xyd_brusselator = range(0, stop = 1, length = N) diff --git a/docs/src/examples/spring_mass.md b/docs/src/examples/spring_mass.md index e733b11724..355e5c20b2 100644 --- a/docs/src/examples/spring_mass.md +++ b/docs/src/examples/spring_mass.md @@ -5,7 +5,7 @@ In this tutorial, we will build a simple component-based model of a spring-mass ## Copy-Paste Example ```@example component -using ModelingToolkit, Plots, DifferentialEquations, LinearAlgebra +using ModelingToolkit, Plots, OrdinaryDiffEq, LinearAlgebra using ModelingToolkit: t_nounits as t, D_nounits as D using Symbolics: scalarize diff --git a/docs/src/systems/DiscreteSystem.md b/docs/src/systems/DiscreteSystem.md new file mode 100644 index 0000000000..f8a71043ab --- /dev/null +++ b/docs/src/systems/DiscreteSystem.md @@ -0,0 +1,34 @@ +# DiscreteSystem + +## System Constructors + +```@docs +DiscreteSystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the discrete system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the discrete system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the discrete system. + - `get_iv(sys)`: The independent variable of the discrete system + - `discrete_events(sys)`: The set of discrete events in the discrete system. + +## Transformations + +```@docs; canonical=false +structural_simplify +``` + +## Problem Constructors + +```@docs; canonical=false +DiscreteProblem(sys::DiscreteSystem, u0map, tspan) +DiscreteFunction(sys::DiscreteSystem, args...) +``` + +## Discrete Domain + +```@docs; canonical=false +Shift +``` diff --git a/docs/src/systems/ImplicitDiscreteSystem.md b/docs/src/systems/ImplicitDiscreteSystem.md new file mode 100644 index 0000000000..d69f88f106 --- /dev/null +++ b/docs/src/systems/ImplicitDiscreteSystem.md @@ -0,0 +1,34 @@ +# ImplicitDiscreteSystem + +## System Constructors + +```@docs +ImplicitDiscreteSystem +``` + +## Composition and Accessor Functions + + - `get_eqs(sys)` or `equations(sys)`: The equations that define the implicit discrete system. + - `get_unknowns(sys)` or `unknowns(sys)`: The set of unknowns in the implicit discrete system. + - `get_ps(sys)` or `parameters(sys)`: The parameters of the implicit discrete system. + - `get_iv(sys)`: The independent variable of the implicit discrete system + - `discrete_events(sys)`: The set of discrete events in the implicit discrete system. + +## Transformations + +```@docs; canonical=false +structural_simplify +``` + +## Problem Constructors + +```@docs; canonical=false +ImplicitDiscreteProblem(sys::ImplicitDiscreteSystem, u0map, tspan) +ImplicitDiscreteFunction(sys::ImplicitDiscreteSystem, args...) +``` + +## Discrete Domain + +```@docs; canonical=false +Shift +``` diff --git a/docs/src/systems/ODESystem.md b/docs/src/systems/ODESystem.md index 6cc34725c4..24e2952fc5 100644 --- a/docs/src/systems/ODESystem.md +++ b/docs/src/systems/ODESystem.md @@ -30,6 +30,7 @@ ODESystem structural_simplify ode_order_lowering dae_index_lowering +change_independent_variable liouville_transform alias_elimination tearing @@ -61,6 +62,7 @@ jacobian_sparsity ODEFunction(sys::ModelingToolkit.AbstractODESystem, args...) ODEProblem(sys::ModelingToolkit.AbstractODESystem, args...) SteadyStateProblem(sys::ModelingToolkit.AbstractODESystem, args...) +DAEProblem(sys::ModelingToolkit.AbstractODESystem, args...) ``` ## Expression Constructors diff --git a/docs/src/tutorials/SampledData.md b/docs/src/tutorials/SampledData.md index a72fd1698b..c700bae5c2 100644 --- a/docs/src/tutorials/SampledData.md +++ b/docs/src/tutorials/SampledData.md @@ -25,8 +25,10 @@ The operators [`Sample`](@ref) and [`Hold`](@ref) are thus providing the interfa 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 -x(k+1) = 0.5x(k) + u(k) -y(k) = x(k) +\begin{align} + x(k+1) &= 0.5x(k) + u(k) \\ + y(k) &= x(k) +\end{align} ``` ```@example clocks @@ -187,3 +189,10 @@ connections = [r ~ sin(t) # reference signal @named cl = ODESystem(connections, t, systems = [f, c, p]) ``` + +```@docs +Sample +Hold +ShiftIndex +Clock +``` diff --git a/docs/src/tutorials/acausal_components.md b/docs/src/tutorials/acausal_components.md index 982629595c..b97500a3e9 100644 --- a/docs/src/tutorials/acausal_components.md +++ b/docs/src/tutorials/acausal_components.md @@ -20,7 +20,7 @@ equalities before solving. Let's see this in action. ## Copy-Paste Example ```@example acausal -using ModelingToolkit, Plots, DifferentialEquations +using ModelingToolkit, Plots, OrdinaryDiffEq using ModelingToolkit: t_nounits as t, D_nounits as D @connector Pin begin @@ -84,6 +84,7 @@ end end @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) @@ -251,6 +252,7 @@ make all of our parameter values 1.0. As `resistor`, `capacitor`, `source` lists ```@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) @@ -311,8 +313,7 @@ DAE solver](https://docs.sciml.ai/DiffEqDocs/stable/solvers/dae_solve/#OrdinaryD This is done as follows: ```@example acausal -u0 = [rc_model.capacitor.v => 0.0 - rc_model.capacitor.p.i => 0.0] +u0 = [rc_model.capacitor.v => 0.0] prob = ODEProblem(rc_model, u0, (0, 10.0)) sol = solve(prob) diff --git a/docs/src/tutorials/attractors.md b/docs/src/tutorials/attractors.md new file mode 100644 index 0000000000..317384b01a --- /dev/null +++ b/docs/src/tutorials/attractors.md @@ -0,0 +1,224 @@ +# [Multi- and Nonlocal- Continuation](@id attractors) + +In the tutorial on [Bifurcation Diagrams](@ref bifurcation_diagrams) we saw how one can create them by integrating ModelingToolkit.jl with BifurcationKit.jl. +This approach is also often called _continuation_ in the broader literature, +because in essence we are "continuing" the location of individual un/stable fixed points or limit cycles in a dynamical system across a parameter axis. + +Recently, an alternative continuation framework was proposed that takes a fundamentally different approach to continuation that is particularly suitable for complex systems. This framework is implemented in [Attractors.jl](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/attractors/stable/) as part of the DynamicalSystems.jl software library. +This new continuation is called _global_ continuation, while the one of BifurcationKit.jl is called _local_ continuation. + +Instead of continuing an individual fixed point or limit cycle, the global continuation finds all attractors of the dynamical system and continues all of them, in parallel, in a single continuation. It distinguishes and labels automatically the different attractors. +Hence "multi-" for multiple attractors. +Another key difference is that instead of estimating the local (or linear, or Jacobian) stability of the attractors, it estimates various measures of _nonlocal_ stability (e.g, related with the size of the basins of attraction, or the size of a perturbation that would make the dynamical system state converge to an alternative attractor). +Hence the "nonlocal-" component. +More differences and pros & cons are discussed in the documentation of Attractors.jl. + +!!! note "Attractors and basins" + + This tutorial assumes that you have some familiarity with dynamical systems, + specifically what are attractors and basins of attraction. If you don't have + this yet, we recommend Chapter 1 of the textbook + [Nonlinear Dynamics](https://link.springer.com/book/10.1007/978-3-030-91032-7). + +## Creating the `DynamicalSystem` via MTK + +Let's showcase this framework by modelling a chaotic bistable dynamical system that we define via ModelingToolkit.jl, which will the be casted into a `DynamicalSystem` type for the DynamicalSystems.jl library. The equations of our system are + +```@example Attractors +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@variables x(t)=-4.0 y(t)=5.0 z(t)=0.0 +@parameters a=5.0 b=0.1 + +eqs = [ + D(x) ~ y - x, + D(y) ~ -x * z + b * abs(z), + D(z) ~ x * y - a +] +``` + +Because our dynamical system is super simple, we will directly make an `ODESystem` and cast it in an `ODEProblem` as in the [`ODESystems` tutorial](@ref programmatically). Since all state variables and parameters have a default value we can immediately write + +```@example Attractors +@named modlorenz = ODESystem(eqs, t) +ssys = structural_simplify(modlorenz) +# The timespan given to the problem is irrelevant for DynamicalSystems.jl +prob = ODEProblem(ssys, [], (0.0, 1.0), []) +``` + +This `prob` can be turned to a dynamical system as simply as + +```@example Attractors +using Attractors # or `DynamicalSystems` +ds = CoupledODEs(prob) +``` + +DynamicalSystems.jl integrates fully with ModelingToolkit.jl out of the box and understands whether a given problem has been created via ModelingToolkit.jl. For example you can use the symbolic variables, or their `Symbol` representations, to access a system state or parameter + +```@example Attractors +observe_state(ds, x) +``` + +```@example Attractors +current_parameter(ds, :a) # or `a` directly +``` + +## Finding all attractors in the state space + +Attractors.jl provides an extensive interface for finding all (within a state space region and numerical accuracy) attractors of a dynamical system. +This interface is structured around the type `AttractorMapper` and is discussed in the Attractors.jl documentation in detail. Here we will briefly mention one of the possible approaches, the recurrences-based algorithm. It finds attractors by finding locations in the state space where the trajectory returns again and again. + +To use this technique, we first need to create a tessellation of the state space + +```@example Attractors +grid = ( + range(-15.0, 15.0; length = 150), # x + range(-20.0, 20.0; length = 150), # y + range(-20.0, 20.0; length = 150) # z +) +``` + +which we then give as input to the `AttractorsViaRecurrences` mapper along with the dynamical system + +```@example Attractors +mapper = AttractorsViaRecurrences(ds, grid; + consecutive_recurrences = 1000, + consecutive_lost_steps = 100 +) +``` + +to learn about the metaparameters of the algorithm visit the documentation of Attractors.jl. + +This `mapper` object is incredibly powerful! It can be used to map initial conditions to attractor they converge to, while ensuring that initial conditions that converge to the same attractor are given the same label. +For example, if we use the `mapper` as a function and give it an initial condition we get + +```@example Attractors +mapper([-4.0, 5, 0]) +``` + +```@example Attractors +mapper([4.0, 2, 0]) +``` + +```@example Attractors +mapper([1.0, 3, 2]) +``` + +The numbers returned are simply the unique identifiers of the attractors the initial conditions converged to. + +DynamicalSystems.jl library is the only dynamical systems software (in any language) that provides such an infrastructure for mapping initial conditions of any arbitrary dynamical system to its unique attractors. And this is only the tip of this iceberg! The rest of the functionality of Attractors.jl is all full of brand new cutting edge progress in dynamical systems research. + +The found attractors are stored in the mapper internally, to obtain them we +use the function + +```@example Attractors +attractors = extract_attractors(mapper) +``` + +This is a dictionary that maps attractor IDs to the attractor sets themselves. +`StateSpaceSet` is a wrapper of a vector of points and behaves exactly like a vector of points. We can plot them easily like + +```@example Attractors +using CairoMakie +fig = Figure() +ax = Axis(fig[1, 1]) +colors = ["#7143E0", "#191E44"] +for (id, A) in attractors + scatter!(ax, A[:, [1, 3]]; color = colors[id]) +end +fig +``` + +## Basins of attraction + +Estimating the basins of attraction of these attractors is a matter of a couple lines of code. +First we define the state space are to estimate the basins for. +Here we can re-use the `grid` we defined above. Then we only have to call + +```julia +basins = basins_of_attraction(mapper, grid) +``` + +We won't run this in this tutorial because it is a length computation (150×150×150). +We will however estimate a slice of the 3D basins of attraction. +DynamicalSystems.jl allows for a rather straightforward setting of initial conditions: + +```@example Attractors +ics = [Dict(:x => x, :y => 0, :z => z) for x in grid[1] for z in grid[3]] +``` + +now we can estimate the basins of attraction on a slice on the x-z grid + +```@example Attractors +fs, labels = basins_fractions(mapper, ics) +labels = reshape(labels, (length(grid[1]), length(grid[3]))) +``` + +and visualize them + +```@example Attractors +heatmap(grid[1], grid[3], labels; colormap = colors) +``` + +## Global continuation + +We've already outlined the principles of the global continuation, so let's just do it here! +We first have to define a global continuation algorithm, which for this tutorial, +it is just a wrapper of the existing `mapper` + +```@example Attractors +ascm = AttractorSeedContinueMatch(mapper); +``` + +we need two more ingredients to perform the global continuation. +One is a sampler of initial conditions in the state space. +Here we'll uniformly sample initial conditions within this grid we have already defined + +```@example Attractors +sampler, = statespace_sampler(grid); +``` + +the last ingredient is what parameter(s) to perform the continuation over. +In contrast to local continuation, where we can only specify a parameter range, in global continuation one can specify an exact parameter curve to continue over. +This curve can span any-dimensional parameter space, in contrast to the 1D or 2D parameter spaces supported in local continuation. +Here we will use the curve + +```@example Attractors +params(θ) = [:a => 5 + 0.5cos(θ), :b => 0.1 + 0.01sin(θ)] +θs = range(0, 2π; length = 101) +pcurve = params.(θs) +``` + +which makes an ellipsis over the parameter space. + +We put these three ingredients together to call the global continuation + +```@example Attractors +fractions_cont, attractors_cont = global_continuation(ascm, pcurve, sampler); +``` + +The output of the continuation is how the attractors and their basins fractions change over this parameter curve. We can visualize this directly using a convenience function + +```@example Attractors +fig = plot_basins_attractors_curves( + fractions_cont, attractors_cont, A -> minimum(A[:, 1]), θs +) +``` + +The top panel shows the relative basins of attractions of the attractors and the bottom panel shows their minimum x-position. The colors correspond to unique attractors. Perhaps making a video is easier to understand: + +```@example Attractors +animate_attractors_continuation( + ds, attractors_cont, fractions_cont, pcurve; + savename = "curvecont.mp4" +); +``` + +```@raw html + +``` + +To learn more about this global continuation and its various options, and more details about how it compares with local continuation, visit the documentation of [Attractors.jl](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/attractors/stable/). diff --git a/docs/src/tutorials/callable_params.md b/docs/src/tutorials/callable_params.md new file mode 100644 index 0000000000..74e7ea87fa --- /dev/null +++ b/docs/src/tutorials/callable_params.md @@ -0,0 +1,91 @@ +# Callable parameters and interpolating data + +ModelingToolkit.jl allows creating parameters that represent functions to be called. This +is especially useful for including interpolants and/or lookup tables inside ODEs. In this +tutorial we will create an `ODESystem` which employs callable parameters to interpolate data +inside an ODE and go over the various syntax options and their implications. + +## Callable parameter syntax + +The syntax for callable parameters declared via `@parameters` must be one of the following + + 1. `(fn::FType)(..)` + 2. `fn(::argType1, ::argType2, ...)` + +In the first case, the parameter is callable with any number/combination of arguments, and +has a type of `FType` (the callable must be a subtype of `FType`). In the second case, +the parameter is callable with as many arguments as declared, and all must match the +declared types. + +By default, the return type of the callable symbolic is inferred to be `Real`. To change +this, a `::retType` annotation can be added at the end. + +To declare a function that returns an array of values, the same array syntax can be used +as for normal variables: + +```julia +@parameters (foo::FType)(..)[1:3]::retType +@parameters foo(::argType1, ::argType2)[1:3]::retType +``` + +`retType` here is the `eltype` of the returned array. + +## Storage of callable parameters + +Callable parameters declared with the `::FType` syntax will be stored in a `Vector{FType}`. +Thus, if `FType` is non-concrete, the buffer will also be non-concrete. This is sometimes +necessary to allow the value of the callable to be switched out for a different type without +rebuilding the model. Typically this syntax is preferable when `FType` is concrete or +a small union. + +Callable parameters declared with the `::argType1, ...` syntax will be stored in a +`Vector{FunctionWrappers.FunctionWrapper{retType, Tuple{argType1, ...}}}`. This suffers +the small overhead of a `FunctionWrapper` and restricts the signature of the callable, +symbolic, but allows storing the parameter in a type-stable manner and swapping it out. +This is preferable when the values that the callable can take do not share a common +subtype. For example, when a callable can represent the activation of a neural network +and can be `tanh`, `sigmoid`, etc. which have a common ancestor of `Function`. + +If both `::FType` and `::argType`s are specified, `::FType` takes priority. For example, + +```julia +@parameters (p::LinearInterpolation)(::Real) +``` + +`p` will be stored in a `Vector{LinearInterpolation}`. If `::LinearInterpolation` was not +specified, it would be stored in a `Vector{FunctionWrapper{Real, Tuple{Real}}}`. + +## Example using interpolations + +```@example callable +using ModelingToolkit +using OrdinaryDiffEq +using DataInterpolations +using ModelingToolkit: t_nounits as t, D_nounits as D + +ts = collect(0.0:0.1:10.0) +spline = LinearInterpolation(ts .^ 2, ts) +Tspline = typeof(spline) +@variables x(t) +@parameters (interp::Tspline)(..) + +@mtkbuild sys = ODESystem(D(x) ~ interp(t), t) +``` + +The derivative of `x` is obtained via an interpolation from DataInterpolations.jl. Note +the parameter syntax. The `(..)` marks the parameter as callable. `(interp::Tspline)` +indicates that the parameter is of type `Tspline`. + +```@example callable +prob = ODEProblem(sys, [x => 0.0], (0.0, 1.0), [interp => spline]) +solve(prob) +``` + +Note that the the following will not work: + +```julia +ODEProblem( + sys; [x => 0.0], (0.0, 1.0), [interp => LinearInterpolation(0.0:0.1:1.0, 0.0:0.1:1.0)]) +``` + +Since the type of the spline doesn't match. diff --git a/docs/src/tutorials/change_independent_variable.md b/docs/src/tutorials/change_independent_variable.md new file mode 100644 index 0000000000..d55639a669 --- /dev/null +++ b/docs/src/tutorials/change_independent_variable.md @@ -0,0 +1,147 @@ +# Changing the independent variable of ODEs + +Ordinary differential equations describe the rate of change of some dependent variables with respect to one independent variable. +For the modeler it is often most natural to write down the equations with a particular independent variable, say time $t$. +However, in many cases there are good reasons for changing the independent variable: + + 1. One may want $y(x)$ as a function of $x$ instead of $(x(t), y(t))$ as a function of $t$ + + 2. Some differential equations vary more nicely (e.g. less stiff) with respect to one independent variable than another. + 3. It can reduce the number of equations that must be solved (e.g. $y(x)$ is one equation, while $(x(t), y(t))$ are two). + +To manually change the independent variable of an ODE, one must rewrite all equations in terms of a new variable and transform differentials with the chain rule. +This is mechanical and error-prone. +ModelingToolkit provides the utility function [`change_independent_variable`](@ref) that automates this process. + +## 1. Get one dependent variable as a function of another + +Consider a projectile shot with some initial velocity in a vertical gravitational field with constant horizontal velocity. + +```@example changeivar +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +@variables x(t) y(t) +@parameters g=9.81 v # gravitational acceleration and horizontal velocity +eqs = [D(D(y)) ~ -g, D(x) ~ v] +initialization_eqs = [D(x) ~ D(y)] # 45° initial angle +M1 = ODESystem(eqs, t; initialization_eqs, name = :M) +M1s = structural_simplify(M1) +@assert length(equations(M1s)) == 3 # hide +M1s # hide +``` + +This is the standard parametrization that arises naturally from kinematics and Newton's laws. +It expresses the position $(x(t), y(t))$ as a function of time $t$. +But suppose we want to determine whether the projectile hits a target 10 meters away. +There are at least three ways of answering this: + + - Solve the ODE for $(x(t), y(t))$ and use a callback to terminate when $x$ reaches 10 meters, and evaluate $y$ at the final time. + - Solve the ODE for $(x(t), y(t))$ and use root finding to find the time when $x$ reaches 10 meters, and evaluate $y$ at that time. + - Solve the ODE for $y(x)$ and evaluate it at 10 meters. + +We will demonstrate the last method by changing the independent variable from $t$ to $x$. +This transformation is well-defined for any non-zero horizontal velocity $v$, so $x$ and $t$ are one-to-one. + +```@example changeivar +M2 = change_independent_variable(M1, x) +M2s = structural_simplify(M2; allow_symbolic = true) +# a sanity test on the 10 x/y variables that are accessible to the user # hide +@assert allequal([x, M1s.x]) # hide +@assert allequal([M2.x, M2s.x]) # hide +@assert allequal([y, M1s.y]) # hide +@assert allunique([M1.x, M1.y, M2.y, M2s.y]) # hide +@assert length(equations(M2s)) == 2 # hide +M2s # display this # hide +``` + +The derivatives are now with respect to the new independent variable $x$, which can be accessed with `M2.x`. + +!!! info + + At this point `x`, `M1.x`, `M1s.x`, `M2.x`, `M2s.x` are *three* different variables. + Meanwhile `y`, `M1.y`, `M1s.y`, `M2.y` and `M2s.y` are *four* different variables. + It can be instructive to inspect these yourself to see their subtle differences. + +Notice how the number of equations has also decreased from three to two, as $\mathrm{d}x/\mathrm{d}t$ has been turned into an observed equation. +It is straightforward to evolve the ODE for 10 meters and plot the resulting trajectory $y(x)$: + +```@example changeivar +using OrdinaryDiffEq, Plots +prob = ODEProblem(M2s, [M2s.y => 0.0], [0.0, 10.0], [v => 8.0]) # throw 10 meters +sol = solve(prob, Tsit5()) +plot(sol; idxs = M2.y) # must index by M2.y = y(x); not M1.y = y(t)! +``` + +!!! tip "Usage tips" + + Look up the documentation of [`change_independent_variable`](@ref) for tips on how to use it. + + For example, if you also need $t(x)$, you can tell it to add a differential equation for the old independent variable in terms of the new one using the [inverse function rule](https://en.wikipedia.org/wiki/Inverse_function_rule) (here $\mathrm{d}t/\mathrm{d}x = 1 / (\mathrm{d}x/\mathrm{d}t)$). If you know an analytical expression between the independent variables (here $t = x/v$), you can also pass it directly to the function to avoid the extra differential equation. + +## 2. Alleviating stiffness by changing to logarithmic time + +In cosmology, the [Friedmann equations](https://en.wikipedia.org/wiki/Friedmann_equations) describe the expansion of the universe. +In terms of conformal time $t$, they can be written + +```@example changeivar +@variables a(t) Ω(t) +a = GlobalScope(a) # global var needed by all species +function species(w; kw...) + eqs = [D(Ω) ~ -3(1 + w) * D(a) / a * Ω] + return ODESystem(eqs, t, [Ω], []; kw...) +end +@named r = species(1 // 3) # radiation +@named m = species(0) # matter +@named Λ = species(-1) # dark energy / cosmological constant +eqs = [Ω ~ r.Ω + m.Ω + Λ.Ω, D(a) ~ √(Ω) * a^2] +initialization_eqs = [Λ.Ω + r.Ω + m.Ω ~ 1] +M1 = ODESystem(eqs, t, [Ω, a], []; initialization_eqs, name = :M) +M1 = compose(M1, r, m, Λ) +M1s = structural_simplify(M1) +``` + +Of course, we can solve this ODE as it is: + +```@example changeivar +prob = ODEProblem(M1s, [M1s.a => 1.0, M1s.r.Ω => 5e-5, M1s.m.Ω => 0.3], (0.0, -3.3), []) +sol = solve(prob, Tsit5(); reltol = 1e-5) +@assert Symbol(sol.retcode) == :Unstable # surrounding text assumes this was unstable # hide +plot(sol, idxs = [M1.a, M1.r.Ω / M1.Ω, M1.m.Ω / M1.Ω, M1.Λ.Ω / M1.Ω]) +``` + +But the solver becomes unstable due to stiffness. +Also notice the interesting dynamics taking place towards the end of the integration (in the early universe), which gets compressed into a very small time interval. +These ODEs would benefit from being defined with respect to a logarithmic "time" that better captures the evolution of the universe through *orders of magnitude* of time, rather than linear time. + +It is therefore common to write these ODEs in terms of $b = \ln a$. +To do this, we will change the independent variable in two stages; first from $t$ to $a$, and then from $a$ to $b$. +Notice that $\mathrm{d}a/\mathrm{d}t > 0$ provided that $\Omega > 0$, and $\mathrm{d}b/\mathrm{d}a > 0$, so the transformation is well-defined since $t \leftrightarrow a \leftrightarrow b$ are one-to-one. +First, we transform from $t$ to $a$: + +```@example changeivar +M2 = change_independent_variable(M1, M1.a) +@assert !ModelingToolkit.isautonomous(M2) # hide +M2 # hide +``` + +Unlike the original, notice that this system is *non-autonomous* because the independent variable $a$ appears explicitly in the equations! +This means that to change the independent variable from $a$ to $b$, we must provide not only the rate of change relation $db(a)/da = \exp(-b)$, but *also* the equation $a(b) = \exp(b)$ so $a$ can be eliminated in favor of $b$: + +```@example changeivar +a = M2.a # get independent variable of M2 +Da = Differential(a) +@variables b(a) +M3 = change_independent_variable(M2, b, [Da(b) ~ exp(-b), a ~ exp(b)]) +``` + +We can now solve and plot the ODE in terms of $b$: + +```@example changeivar +M3s = structural_simplify(M3; allow_symbolic = true) +prob = ODEProblem(M3s, [M3s.r.Ω => 5e-5, M3s.m.Ω => 0.3], (0, -15), []) +sol = solve(prob, Tsit5()) +@assert Symbol(sol.retcode) == :Success # surrounding text assumes the solution was successful # hide +plot(sol, idxs = [M3.r.Ω / M3.Ω, M3.m.Ω / M3.Ω, M3.Λ.Ω / M3.Ω]) +``` + +Notice that the variables vary "more nicely" with respect to $b$ than $t$, making the solver happier and avoiding numerical problems. diff --git a/docs/src/tutorials/fmi.md b/docs/src/tutorials/fmi.md new file mode 100644 index 0000000000..ef00477c78 --- /dev/null +++ b/docs/src/tutorials/fmi.md @@ -0,0 +1,226 @@ +# Importing FMUs + +ModelingToolkit is able to import FMUs following the [FMI Standard](https://fmi-standard.org/) versions 2 and 3. +This integration is done through [FMI.jl](https://github.com/ThummeTo/FMI.jl) and requires importing it to +enable the relevant functionality. Currently Model Exchange (ME) and CoSimulation (CS) FMUs are supported. +Events, non-floating-point variables and array variables are not supported. Additionally, calculating the +time derivatives of FMU states/outputs is not supported. + +!!! danger "Experimental" + + This functionality is currently experimental and subject to change without a breaking release of + ModelingToolkit.jl. + +## FMUs of full models + +Here, we will demonstrate the usage of an FMU of an entire model (as opposed to a single component). +First, the required libraries must be imported and the FMU loaded using FMI.jl. + +```@example fmi +using ModelingToolkit, FMI, FMIZoo, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D + +# This is a spring-pendulum FMU from FMIZoo.jl. It is a v2 FMU +# and we are importing it in ModelExchange format. +fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) +``` + +Following are the variables in the FMU (both states and parameters): + +```@example fmi +fmu.modelDescription.modelVariables +``` + +Next, [`FMIComponent`](@ref) is used to import the FMU as an MTK component. We provide the FMI +major version as a `Val` to the constructor, along with the loaded FMU and the type as keyword +arguments. + +```@example fmi +@named model = ModelingToolkit.FMIComponent(Val(2); fmu, type = :ME) +``` + +Note how hierarchical names in the FMU (e.g. `mass.m` or `spring.f`) are turned into flattened +names, with `__` being the namespace separator (`mass__m` and `spring__f`). + +!!! note + + Eventually we plan to reconstruct a hierarchical system structure mirroring the one indicated + by the variables in the FMU. This would allow accessing the above mentioned variables as + `model.mass.m` and `model.spring.f` instead of `model.mass__m` and `model.spring__f` respectively. + +Derivative variables such as `der(mass.v)` use the dummy derivative notation, and are hence transformed +into a form similar to `mass__vˍt`. However, they can still be referred to as `D(model.mass__v)`. + +```@example fmi +equations(model) +``` + +Since the FMI spec allows multiple names to alias the same quantity, ModelingToolkit.jl creates +equations to alias them. For example, it can be seen above that `der(mass.v)` and `mass.a` have the +same reference, and hence refer to the same quantity. Correspondingly, there is an equation +`mass__vˍt(t) ~ mass__a(t)` in the system. + +!!! note + + Any variables and/or parameters that are not part of the FMU should be ignored, as ModelingToolkit + creates them to manage the FMU. Unexpected usage of these variables/parameters can lead to errors. + +```@example fmi +defaults(model) +``` + +All parameters in the FMU are given a default equal to their start value, if present. Unknowns are not +assigned defaults even if a start value is present, as this would conflict with ModelingToolkit's own +initialization semantics. + +We can simulate this model like any other ModelingToolkit system. + +```@repl fmi +sys = structural_simplify(model) +prob = ODEProblem(sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 5.0)) +sol = solve(prob, Tsit5()) +``` + +We can interpolate the solution object to obtain values at arbitrary time points in the solved interval, +just like a normal solution. + +```@repl fmi +sol(0.0:0.1:1.0; idxs = sys.mass_a) +``` + +FMUs following version 3 of the specification can be simulated with almost the same process. This time, +we will create a model from a CoSimulation FMU. + +```@example fmi +fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :CS) +@named inner = ModelingToolkit.FMIComponent( + Val(3); fmu, communication_step_size = 0.001, type = :CS) +``` + +This FMU has fewer equations, partly due to missing aliasing variables and partly due to being a CS FMU. +CoSimulation FMUs are bundled with an integrator. As such, they do not function like ME FMUs. Instead, +a callback steps the FMU at periodic intervals in time and obtains the updated state. This state is held +constant until the next time the callback triggers. The periodic interval must be specified through the +`communication_step_size` keyword argument. A smaller step size typically leads to less error but is +more computationally expensive. + +This model alone does not have any differential variables, and calling `structural_simplify` will lead +to an `ODESystem` with no unknowns. + +```@example fmi +structural_simplify(inner) +``` + +Simulating this model will cause the OrdinaryDiffEq integrator to immediately finish, and will not +trigger the callback. Thus, we wrap this system in a trivial system with a differential variable. + +```@example fmi +@variables x(t) = 1.0 +@mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) +``` + +We can now simulate `sys`. + +```@example fmi +prob = ODEProblem(sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 5.0)) +sol = solve(prob, Tsit5()) +``` + +The variables of the FMU are discrete, and their timeseries can be obtained at intervals of +`communication_step_size`. + +```@example fmi +sol[sys.inner.mass__s] +``` + +## FMUs of components + +FMUs can also be imported as individual components. For this example, we will use custom FMUs used +in the test suite of ModelingToolkit.jl. + +```@example fmi +fmu = loadFMU( + joinpath(@__DIR__, "..", "..", "..", "test", "fmi", "fmus", "SimpleAdder.fmu"); + type = :ME) +fmu.modelDescription.modelVariables +``` + +This FMU is equivalent to the following model: + +```julia +@mtkmodel SimpleAdder begin + @variables begin + a(t) + b(t) + c(t) + out(t) + out2(t) + end + @parameters begin + value = 1.0 + end + @equations begin + out ~ a + b + value + D(c) ~ out + out2 ~ 2c + end +end +``` + +`a` and `b` are inputs, `c` is a state, and `out` and `out2` are outputs of the component. + +```@repl fmi +@named adder = ModelingToolkit.FMIComponent(Val(2); fmu, type = :ME); +isinput(adder.a) +isinput(adder.b) +isoutput(adder.out) +isoutput(adder.out2) +``` + +ModelingToolkit recognizes input and output variables of the component, and attaches the appropriate +metadata. We can now use this component as a subcomponent of a larger system. + +```@repl fmi +@variables a(t) b(t) c(t) [guess = 1.0]; +@mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) +equations(sys) +``` + +Note how the output `adder.out` is used in an algebraic equation of the system. We have also given +`sys.c` a guess, expecting it to be solved for by initialization. ModelingToolkit is able to use +FMUs in initialization to solve for initial states. As mentioned earlier, we cannot differentiate +through an FMU. Thus, automatic differentiation has to be disabled for the solver. + +```@example fmi +prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) +solve(prob, Rodas5P(autodiff = false)) +``` + +CoSimulation FMUs follow a nearly identical process. Since CoSimulation FMUs operate using callbacks, +after triggering the callbacks and altering the discrete state the algebraic equations may no longer +be satisfied. To resolve for the values of algebraic variables, we use the `reinitializealg` keyword +of `FMIComponent`. This is a DAE initialization algorithm to use at the end of every callback. Since +CoSimulation FMUs are not directly involved in the RHS of the system - instead operating through +callbacks - we can use a solver with automatic differentiation. + +```@example fmi +fmu = loadFMU( + joinpath(@__DIR__, "..", "..", "..", "test", "fmi", "fmus", "SimpleAdder.fmu"); + type = :CS) +@named adder = ModelingToolkit.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-3, + reinitializealg = BrownFullBasicInit()) +@mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) +prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) +solve(prob, Rodas5P()) +``` diff --git a/docs/src/tutorials/initialization.md b/docs/src/tutorials/initialization.md index f40c28991f..ba733e0bfb 100644 --- a/docs/src/tutorials/initialization.md +++ b/docs/src/tutorials/initialization.md @@ -14,8 +14,10 @@ principles of initialization of DAE systems. Take a DAE written in semi-explicit form: ```math -x' = f(x,y,t)\\ -0 = g(x,y,t) +\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. @@ -113,7 +115,7 @@ the `initialization_eqs` keyword argument, for example: ```@example init prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], guesses = [λ => 0, y => 1], - initialization_eqs = [y ~ 1]) + initialization_eqs = [y ~ 0]) sol = solve(prob, Rodas5P()) plot(sol, idxs = (x, y)) ``` @@ -201,6 +203,157 @@ long enough you will see that `λ = 0` is required for this equation, but since problem constructor. Additionally, any warning about not being fully determined can be suppressed via passing `warn_initialize_determined = false`. +## Constant constraints in initialization + +Consider the pendulum system again: + +```@repl init +equations(pend) +observed(pend) +``` + +Suppose we want to solve the same system with multiple different initial +y-velocities from a given position. + +```@example init +prob = ODEProblem( + pend, [x => 1, D(y) => 0], (0.0, 1.5), [g => 1], guesses = [λ => 0, y => 1, x => 1]) +sol1 = solve(prob, Rodas5P()) +``` + +```@example init +sol1[D(y), 1] +``` + +Repeatedly re-creating the `ODEProblem` with different values of `D(y)` and `x` or +repeatedly calling `remake` is slow. Instead, for any `variable => constant` constraint +in the `ODEProblem` initialization (whether provided to the `ODEProblem` constructor or +a default value) we can update the `constant` value. ModelingToolkit refers to these +values using the `Initial` operator. For example: + +```@example init +prob.ps[[Initial(x), Initial(D(y))]] +``` + +To solve with a different starting y-velocity, we can simply do + +```@example init +prob.ps[Initial(D(y))] = -0.1 +sol2 = solve(prob, Rodas5P()) +``` + +```@example init +sol2[D(y), 1] +``` + +Note that this _only_ applies for constant constraints for the current ODEProblem. +For example, `D(x)` does not have a constant constraint - it is solved for by +initialization. Thus, mutating `Initial(D(x))` does not have any effect: + +```@repl init +sol2[D(x), 1] +prob.ps[Initial(D(x))] = 1.0 +sol3 = solve(prob, Rodas5P()) +sol3[D(x), 1] +``` + +To enforce this constraint, we would have to `remake` the problem (or construct a new one). + +```@repl init +prob2 = remake(prob; u0 = [y => 0.0, D(x) => 0.0, x => nothing, D(y) => nothing]); +sol4 = solve(prob2, Rodas5P()) +sol4[D(x), 1] +``` + +Note the need to provide `x => nothing, D(y) => nothing` to override the previously +provided initial conditions. Since `remake` is a partial update, the constraints provided +to it are merged with the ones already present in the problem. Existing constraints can be +removed by providing a value of `nothing`. + +## Initialization of parameters + +Parameters may also be treated as unknowns in the initialization system. Doing so works +almost identically to the standard case. For a parameter to be an initialization unknown +(henceforth referred to as "solved parameter") it must represent a floating point number +(have a `symtype` of `Real` or `<:AbstractFloat`) or an array of such numbers. Additionally, +it must have a guess and one of the following conditions must be satisfied: + + 1. The value of the parameter as passed to `ODEProblem` is an expression involving other + variables/parameters. For example, if `[p => 2q + x]` is passed to `ODEProblem`. In + this case, `p ~ 2q + x` is used as an equation during initialization. + 2. The parameter has a default (and no value for it is given to `ODEProblem`, since + that is condition 1). The default will be used as an equation during initialization. + 3. The parameter has a default of `missing`. If `ODEProblem` is given a value for this + parameter, it is used as an equation during initialization (whether the value is an + expression or not). + 4. `ODEProblem` is given a value of `missing` for the parameter. If the parameter has a + default, it will be used as an equation during initialization. + +All parameter dependencies (where the dependent parameter is a floating point number or +array thereof) also become equations during initialization, and the dependent parameters +become unknowns. + +`remake` will reconstruct the initialization system and problem, given the new +constraints provided to it. The new values will be combined with the original +variable-value mapping provided to `ODEProblem` and used to construct the initialization +problem. + +The variable on the left hand side of all parameter dependencies also has an `Initial` +variant, which is used if a constant constraint is provided for the variable. + +### Parameter initialization by example + +Consider the following system, where the sum of two unknowns is a constant parameter +`total`. + +```@example paraminit +using ModelingToolkit, OrdinaryDiffEq # hidden +using ModelingToolkit: t_nounits as t, D_nounits as D # hidden + +@variables x(t) y(t) +@parameters total +@mtkbuild sys = ODESystem([D(x) ~ -x, total ~ x + y], t; + defaults = [total => missing], guesses = [total => 1.0]) +``` + +Given any two of `x`, `y` and `total` we can determine the remaining variable. + +```@example paraminit +prob = ODEProblem(sys, [x => 1.0, y => 2.0], (0.0, 1.0)) +integ = init(prob, Tsit5()) +@assert integ.ps[total] ≈ 3.0 # hide +integ.ps[total] +``` + +Suppose we want to re-create this problem, but now solve for `x` given `total` and `y`: + +```@example paraminit +prob2 = remake(prob; u0 = [y => 1.0], p = [total => 4.0]) +initsys = prob2.f.initializeprob.f.sys +``` + +The system is now overdetermined. In fact: + +```@example paraminit +[equations(initsys); observed(initsys)] +``` + +The system can never be satisfied and will always lead to an `InitialFailure`. This is +due to the aforementioned behavior of retaining the original variable-value mapping +provided to `ODEProblem`. To fix this, we pass `x => nothing` to `remake` to remove its +retained value. + +```@example paraminit +prob2 = remake(prob; u0 = [y => 1.0, x => nothing], p = [total => 4.0]) +initsys = prob2.f.initializeprob.f.sys +``` + +The system is fully determined, and the equations are solvable. + +```@example paraminit +[equations(initsys); observed(initsys)] +``` + ## Diving Deeper: Constructing the Initialization System To get a better sense of the initialization system and to help debug it, you can construct @@ -383,3 +536,14 @@ sol[α * x - β * x * y] ```@example init plot(sol) ``` + +## Summary of Initialization API + +```@docs; canonical=false +Initial +isinitial +generate_initializesystem +initialization_equations +guesses +defaults +``` diff --git a/docs/src/tutorials/linear_analysis.md b/docs/src/tutorials/linear_analysis.md new file mode 100644 index 0000000000..7fcb67f9fb --- /dev/null +++ b/docs/src/tutorials/linear_analysis.md @@ -0,0 +1,152 @@ +# Linear Analysis + +Linear analysis refers to the process of linearizing a nonlinear model and analysing the resulting linear dynamical system. To facilitate linear analysis, ModelingToolkit provides the concept of an [`AnalysisPoint`](@ref), which can be inserted in-between two causal blocks (such as those from `ModelingToolkitStandardLibrary.Blocks` sub module). Once a model containing analysis points is built, several operations are available: + + - [`get_sensitivity`](@ref) get the [sensitivity function (wiki)](https://en.wikipedia.org/wiki/Sensitivity_(control_systems)), $S(s)$, as defined in the field of control theory. + - [`get_comp_sensitivity`](@ref) get the complementary sensitivity function $T(s) : S(s)+T(s)=1$. + - [`get_looptransfer`](@ref) get the (open) loop-transfer function where the loop starts and ends in the analysis point. For a typical simple feedback connection with a plant $P(s)$ and a controller $C(s)$, the loop-transfer function at the plant output is $P(s)C(s)$. + - [`linearize`](@ref) can be called with two analysis points denoting the input and output of the linearized system. + - [`open_loop`](@ref) return a new (nonlinear) system where the loop has been broken in the analysis point, i.e., the connection the analysis point usually implies has been removed. + +An analysis point can be created explicitly using the constructor [`AnalysisPoint`](@ref), or automatically when connecting two causal components using `connect`: + +```julia +connect(comp1.output, :analysis_point_name, comp2.input) +``` + +A single output can also be connected to multiple inputs: + +```julia +connect(comp1.output, :analysis_point_name, comp2.input, comp3.input, comp4.input) +``` + +!!! warning "Causality" + + Analysis points are *causal*, i.e., they imply a directionality for the flow of information. The order of the connections in the connect statement is thus important, i.e., `connect(out, :name, in)` is different from `connect(in, :name, out)`. + +The directionality of an analysis point can be thought of as an arrow in a block diagram, where the name of the analysis point applies to the arrow itself. + +``` +┌─────┐ ┌─────┐ +│ │ name │ │ +│ out├────────►│in │ +│ │ │ │ +└─────┘ └─────┘ +``` + +This is signified by the name being the middle argument to `connect`. + +Of the above mentioned functions, all except for [`open_loop`](@ref) return the output of [`ModelingToolkit.linearize`](@ref), which is + +```julia +matrices, simplified_sys = linearize(...) +# matrices = (; A, B, C, D) +``` + +i.e., `matrices` is a named tuple containing the matrices of a linear state-space system on the form + +```math +\begin{aligned} +\dot x &= Ax + Bu\\ +y &= Cx + Du +\end{aligned} +``` + +## Example + +The following example builds a simple closed-loop system with a plant $P$ and a controller $C$. Two analysis points are inserted, one before and one after $P$. We then derive a number of sensitivity functions and show the corresponding code using the package ControlSystemBase.jl + +```@example LINEAR_ANALYSIS +using ModelingToolkitStandardLibrary.Blocks, ModelingToolkit +@named P = FirstOrder(k = 1, T = 1) # A first-order system with pole in -1 +@named C = Gain(-1) # A P controller +t = ModelingToolkit.get_iv(P) +eqs = [connect(P.output, :plant_output, C.input) # Connect with an automatically created analysis point called :plant_output + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :feedback_system) + +matrices_S = get_sensitivity(sys, :plant_input)[1] # Compute the matrices of a state-space representation of the (input)sensitivity function. +matrices_T = get_comp_sensitivity(sys, :plant_input)[1] +``` + +Continued linear analysis and design can be performed using ControlSystemsBase.jl. +We create `ControlSystemsBase.StateSpace` objects using + +```@example LINEAR_ANALYSIS +using ControlSystemsBase, Plots +S = ss(matrices_S...) +T = ss(matrices_T...) +bodeplot([S, T], lab = ["S" "" "T" ""], plot_title = "Bode plot of sensitivity functions", + margin = 5Plots.mm) +``` + +The sensitivity functions obtained this way should be equivalent to the ones obtained with the code below + +```@example LINEAR_ANALYSIS_CS +using ControlSystemsBase +P = tf(1.0, [1, 1]) |> ss +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +``` + +We may also derive the loop-transfer function $L(s) = P(s)C(s)$ using + +```@example LINEAR_ANALYSIS +matrices_L = get_looptransfer(sys, :plant_output)[1] +L = ss(matrices_L...) +``` + +which is equivalent to the following with ControlSystems + +```@example LINEAR_ANALYSIS_CS +L = P * (-C) # Add the minus sign to build the negative feedback into the controller +``` + +To obtain the transfer function between two analysis points, we call `linearize` + +```@example LINEAR_ANALYSIS +using ModelingToolkit # hide +matrices_PS = linearize(sys, :plant_input, :plant_output)[1] +``` + +this particular transfer function should be equivalent to the linear system `P(s)S(s)`, i.e., equivalent to + +```@example LINEAR_ANALYSIS_CS +feedback(P, C) +``` + +### Obtaining transfer functions + +A statespace system from [ControlSystemsBase](https://juliacontrol.github.io/ControlSystems.jl/stable/man/creating_systems/) can be converted to a transfer function using the function `tf`: + +```@example LINEAR_ANALYSIS_CS +tf(S) +``` + +## Gain and phase margins + +Further linear analysis can be performed using the [analysis methods from ControlSystemsBase](https://juliacontrol.github.io/ControlSystems.jl/stable/lib/analysis/). For example, calculating the gain and phase margins of a system can be done using + +```@example LINEAR_ANALYSIS_CS +margin(P) +``` + +(they are infinite for this system). A Nyquist plot can be produced using + +```@example LINEAR_ANALYSIS_CS +nyquistplot(P) +``` + +## Index + +```@index +Pages = ["linear_analysis.md"] +``` + +```@autodocs +Modules = [ModelingToolkit] +Pages = ["systems/analysis_points.jl"] +Order = [:function, :type] +Private = false +``` diff --git a/docs/src/tutorials/modelingtoolkitize.md b/docs/src/tutorials/modelingtoolkitize.md index e272a2237c..545879f842 100644 --- a/docs/src/tutorials/modelingtoolkitize.md +++ b/docs/src/tutorials/modelingtoolkitize.md @@ -33,10 +33,10 @@ to improve a simulation code before it's passed to the solver. ## Example Usage: Generating an Analytical Jacobian Expression for an ODE Code Take, for example, the Robertson ODE -defined as an `ODEProblem` for DifferentialEquations.jl: +defined as an `ODEProblem` for OrdinaryDiffEq.jl: ```@example mtkize -using DifferentialEquations, ModelingToolkit +using OrdinaryDiffEq, ModelingToolkit function rober(du, u, p, t) y₁, y₂, y₃ = u k₁, k₂, k₃ = p diff --git a/docs/src/tutorials/ode_modeling.md b/docs/src/tutorials/ode_modeling.md index 755e70ef46..0a4cd80803 100644 --- a/docs/src/tutorials/ode_modeling.md +++ b/docs/src/tutorials/ode_modeling.md @@ -34,7 +34,7 @@ using ModelingToolkit: t_nounits as t, D_nounits as D end end -using DifferentialEquations: solve +using OrdinaryDiffEq @mtkbuild fol = FOL() prob = ODEProblem(fol, [], (0.0, 10.0), []) sol = solve(prob) @@ -84,10 +84,10 @@ Note that equations in MTK use the tilde character (`~`) as equality sign. `@mtkbuild` creates an instance of `FOL` named as `fol`. -After construction of the ODE, you can solve it using [DifferentialEquations.jl](https://docs.sciml.ai/DiffEqDocs/stable/): +After construction of the ODE, you can solve it using [OrdinaryDiffEq.jl](https://docs.sciml.ai/DiffEqDocs/stable/): ```@example ode2 -using DifferentialEquations +using OrdinaryDiffEq using Plots prob = ODEProblem(fol, [], (0.0, 10.0), []) diff --git a/docs/src/tutorials/programmatically_generating.md b/docs/src/tutorials/programmatically_generating.md index 8b8b03027a..9fc1db1834 100644 --- a/docs/src/tutorials/programmatically_generating.md +++ b/docs/src/tutorials/programmatically_generating.md @@ -1,42 +1,44 @@ # [Programmatically Generating and Scripting ODESystems](@id programmatically) -In the following tutorial we will discuss how to programmatically generate `ODESystem`s. -This is for cases where one is writing functions that generating `ODESystem`s, for example -if implementing a reader which parses some file format to generate an `ODESystem` (for example, -SBML), or for writing functions that transform an `ODESystem` (for example, if you write a -function that log-transforms a variable in an `ODESystem`). +In the following tutorial, we will discuss how to programmatically generate `ODESystem`s. +This is useful for functions that generate `ODESystem`s, for example +when you implement a reader that parses some file format, such as SBML, to generate an `ODESystem`. +It is also useful for functions that transform an `ODESystem`, for example +when you write a function that log-transforms a variable in an `ODESystem`. ## The Representation of a ModelingToolkit System ModelingToolkit is built on [Symbolics.jl](https://symbolics.juliasymbolics.org/dev/), a symbolic Computer Algebra System (CAS) developed in Julia. As such, all CAS functionality -is available on ModelingToolkit systems, such as symbolic differentiation, Groebner basis +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 Symbolics -using ModelingToolkit: t_nounits as t, D_nounits as D - -@variables x(t) y(t) # Define variables +using ModelingToolkit # reexports Symbolics +@variables t x(t) y(t) # Define variables +D = Differential(t) eqs = [D(x) ~ y D(y) ~ x] # Define an array of equations ``` +However, ModelingToolkit has many higher-level features which will make scripting ModelingToolkit systems more convenient. +For example, as shown in the next section, defining your own independent variables and differentials is rarely needed. + ## The Non-DSL (non-`@mtkmodel`) Way of Defining an ODESystem -Using `@mtkmodel` is the preferred way of defining ODEs with MTK. However, let us -look at how we can define the same system without `@mtkmodel`. This is useful for -defining PDESystem etc. +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) # independent and dependent variables -@parameters τ # parameters +@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 @@ -45,57 +47,25 @@ eqs = [D(x) ~ (h - x) / τ] # create an array of equations # Perform the standard transformations and mark the model complete # Note: Complete models cannot be subsystems of other models! -fol_model = structural_simplify(model) -``` - -As you can see, generating an ODESystem is as simple as creating the array of equations -and passing it to the `ODESystem` constructor. - -## Understanding the Difference Between the Julia Variable and the Symbolic Variable - -In the most basic usage of ModelingToolkit and Symbolics, the name of the Julia variable -and the symbolic variable are the same. For example, when we do: - -```@example scripting -@variables a -``` +fol = structural_simplify(model) +prob = ODEProblem(fol, [], (0.0, 10.0), []) +using OrdinaryDiffEq +sol = solve(prob) -the name of the symbolic variable is `a` and same with the Julia variable. However, we can -de-couple these by setting `a` to a new symbolic variable, for example: - -```@example scripting -b = only(@variables(a)) -``` - -Now the Julia variable `b` refers to the variable named `a`. However, the downside of this current -approach is that it requires that the user writing the script knows the name `a` that they want to -place to the variable. But what if for example we needed to get the variable's name from a file? - -To do this, one can interpolate a symbol into the `@variables` macro using `$`. For example: - -```@example scripting -a = :c -b = only(@variables($a)) +using Plots +plot(sol) ``` -In this example, `@variables($a)` created a variable named `c`, and set this variable to `b`. - -Variables are not the only thing with names. For example, when you build a system, it knows its name -that name is used in the namespacing. In the standard usage, again the Julia variable and the -symbolic name are made the same via: - -```@example scripting -@named fol_model = ODESystem(eqs, t) -``` +As you can see, generating an ODESystem is as simple as creating an array of equations +and passing it to the `ODESystem` constructor. -However, one can decouple these two properties by noting that `@named` is simply shorthand for the -following: +`@named` automatically gives a name to the `ODESystem`, and is shorthand for ```@example scripting -fol_model = ODESystem(eqs, t; name = :fol_model) +fol_model = ODESystem(eqs, t; name = :fol_model) # @named fol_model = ODESystem(eqs, t) ``` -Thus if we had read a name from a file and wish to populate an `ODESystem` with said name, we could do: +Thus, if we had read a name from a file and wish to populate an `ODESystem` with said name, we could do: ```@example scripting namesym = :name_from_file diff --git a/docs/src/tutorials/stochastic_diffeq.md b/docs/src/tutorials/stochastic_diffeq.md index fd03d51810..79c71a2e8d 100644 --- a/docs/src/tutorials/stochastic_diffeq.md +++ b/docs/src/tutorials/stochastic_diffeq.md @@ -1,38 +1,94 @@ # Modeling with Stochasticity -All models with `ODESystem` are deterministic. `SDESystem` adds another element -to the model: randomness. This is a +All previous differential equations tutorials deal with deterministic `ODESystem`s. +In this tutorial, we add randomness. +In particular, we show how to represent a [stochastic differential equation](https://en.wikipedia.org/wiki/Stochastic_differential_equation) -which has a deterministic (drift) component and a stochastic (diffusion) -component. Let's take the Lorenz equation from the first tutorial and extend -it to have multiplicative noise by creating `@brownian` variables in the equations. +as a `SDESystem`. + +!!! note + + The high level `@mtkmodel` macro used in the + [getting started tutorial](@ref getting_started) + is not yet compatible with `SDESystem`. + We thus have to use a lower level interface to define stochastic differential equations. + For an introduction to this interface, read the + [programmatically generating ODESystems tutorial](@ref programmatically). + +Let's take the Lorenz equation and add noise to each of the states. +To show the flexibility of ModelingToolkit, +we do not use homogeneous noise, with constant variance, +but instead use heterogeneous noise, +where the magnitude of the noise scales with (0.3 times) the magnitude of each of the states: + +```math +\begin{aligned} +\frac{dx}{dt} &= (\sigma (y-x)) &+ 0.3x\frac{dB}{dt} \\ +\frac{dy}{dt} &= (x(\rho-z) - y) &+ 0.3y\frac{dB}{dt} \\ +\frac{dz}{dt} &= (xy - \beta z) &+ 0.3z\frac{dB}{dt} \\ +\end{aligned} +``` + +Where $B$, is standard Brownian motion, also called the +[Wiener process](https://en.wikipedia.org/wiki/Wiener_process). +We use notation similar to the +[Langevin equation](https://en.wikipedia.org/wiki/Stochastic_differential_equation#Use_in_physics), +often used in physics. +By "multiplying" the equations by $dt$, the notation used in +[probability theory](https://en.wikipedia.org/wiki/Stochastic_differential_equation#Use_in_probability_and_mathematical_finance) +can be recovered. + +We use this Langevin-like notation because it allows us to extend MTK modeling capacity from ODEs to SDEs, +using only a single new concept, `@brownian` variables, which represent $\frac{dB}{dt}$ in the above equation. ```@example SDE using ModelingToolkit, StochasticDiffEq using ModelingToolkit: t_nounits as t, D_nounits as D +using Plots -# Define some variables -@parameters σ ρ β -@variables x(t) y(t) z(t) -@brownian a -eqs = [D(x) ~ σ * (y - x) + 0.1a * x, - D(y) ~ x * (ρ - z) - y + 0.1a * y, - D(z) ~ x * y - β * z + 0.1a * z] +@parameters σ=10.0 ρ=2.33 β=26.0 +@variables x(t)=5.0 y(t)=5.0 z(t)=1.0 +@brownian B +eqs = [D(x) ~ σ * (y - x) + 0.3x * B, + D(y) ~ x * (ρ - z) - y + 0.3y * B, + D(z) ~ x * y - β * z + 0.3z * B] @mtkbuild de = System(eqs, t) +``` + +Even though we did not explicitly use `SDESystem`, ModelingToolkit can still infer this from the equations. + +```@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)]) +``` -u0map = [ - x => 1.0, - y => 0.0, - z => 0.0 -] +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`. -parammap = [ - σ => 10.0, - β => 26.0, - ρ => 2.33 -] +```@example SDE +plot(sol) +``` + +If you want uncorrelated noise for each equation, +multiple `@brownian` variables have to be declared. -prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) -sol = solve(prob, LambaEulerHeun()) +```@example SDE +@brownian Bx By Bz +eqs = [D(x) ~ σ * (y - x) + 0.3x * Bx, + D(y) ~ x * (ρ - z) - y + 0.3y * By, + D(z) ~ x * y - β * z + 0.3z * Bz] +@mtkbuild de = System(eqs, t) +prob = SDEProblem(de, [], (0.0, 100.0), []) +sol = solve(prob, SRIW1()) +plot(sol) ``` diff --git a/ext/MTKBifurcationKitExt.jl b/ext/MTKBifurcationKitExt.jl index 6bc536958f..0b9f104d9b 100644 --- a/ext/MTKBifurcationKitExt.jl +++ b/ext/MTKBifurcationKitExt.jl @@ -59,7 +59,7 @@ struct ObservableRecordFromSolution{S, T} end end # Functor function that computes the value. -function (orfs::ObservableRecordFromSolution)(x, p) +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] @@ -97,7 +97,10 @@ function BifurcationKit.BifurcationProblem(nsys::NonlinearSystem, @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 = ofun.f + 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. @@ -113,7 +116,7 @@ function BifurcationKit.BifurcationProblem(nsys::NonlinearSystem, # 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) -> x[plot_idx] + 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)) @@ -132,10 +135,11 @@ function BifurcationKit.BifurcationProblem(nsys::NonlinearSystem, return BifurcationKit.BifurcationProblem(F, u0_bif_vals, p_vals, - (@lens _[bif_idx]), + (BifurcationKit.@optic _[bif_idx]), args...; record_from_solution = record_from_solution, J = J, + inplace = true, kwargs...) end diff --git a/ext/MTKChainRulesCoreExt.jl b/ext/MTKChainRulesCoreExt.jl index e7019e25df..a2974ea2dd 100644 --- a/ext/MTKChainRulesCoreExt.jl +++ b/ext/MTKChainRulesCoreExt.jl @@ -2,14 +2,113 @@ module MTKChainRulesCoreExt import ModelingToolkit as MTK import ChainRulesCore -import ChainRulesCore: NoTangent +import ChainRulesCore: Tangent, ZeroTangent, NoTangent, zero_tangent, unthunk function ChainRulesCore.rrule(::Type{MTK.MTKParameters}, tunables, args...) function mtp_pullback(dt) - (NoTangent(), dt.tunable[1:length(tunables)], + dt = unthunk(dt) + dtunables = dt isa AbstractArray ? dt : dt.tunable + (NoTangent(), dtunables[1:length(tunables)], ntuple(_ -> NoTangent(), length(args))...) end MTK.MTKParameters(tunables, args...), mtp_pullback end +function subset_idxs(idxs, portion, template) + ntuple(Val(length(template))) do subi + result = [Base.tail(idx.idx) + for idx in idxs if idx.portion == portion && idx.idx[1] == subi] + if isempty(result) + result = [] + end + result + end +end + +selected_tangents(::NoTangent, _) = () +selected_tangents(::ZeroTangent, _) = ZeroTangent() +function selected_tangents( + tangents::AbstractArray{T}, idxs::Vector{Tuple{Int}}) where {T <: Number} + selected_tangents(tangents, map(only, idxs)) +end +function selected_tangents(tangents::AbstractArray{T}, idxs...) where {T <: Number} + newtangents = copy(tangents) + view(newtangents, idxs...) .= zero(T) + newtangents +end +function selected_tangents( + tangents::AbstractVector{T}, idxs) where {S <: Number, T <: AbstractArray{S}} + newtangents = copy(tangents) + for i in idxs + j, k... = i + if k == () + newtangents[j] = zero(newtangents[j]) + else + newtangents[j] = selected_tangents(newtangents[j], k...) + end + end + newtangents +end +function selected_tangents(tangents::AbstractVector{T}, idxs) where {T <: AbstractArray} + newtangents = similar(tangents, Union{T, NoTangent}) + copyto!(newtangents, tangents) + for i in idxs + j, k... = i + if k == () + newtangents[j] = NoTangent() + else + newtangents[j] = selected_tangents(newtangents[j], k...) + end + end + newtangents +end +function selected_tangents( + tangents::Union{Tangent{<:Tuple}, Tangent{T, <:Tuple}}, idxs) where {T} + ntuple(Val(length(tangents))) do i + selected_tangents(tangents[i], idxs[i]) + end +end + +function ChainRulesCore.rrule( + ::typeof(MTK.remake_buffer), indp, oldbuf::MTK.MTKParameters, idxs, vals) + if idxs isa AbstractSet + idxs = collect(idxs) + end + idxs = map(idxs) do i + i isa MTK.ParameterIndex ? i : MTK.parameter_index(indp, i) + end + newbuf = MTK.remake_buffer(indp, oldbuf, idxs, vals) + tunable_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa MTK.SciMLStructures.Tunable); + init = Union{Int, AbstractVector{Int}}[]) + initials_idxs = reduce( + vcat, (idx.idx for idx in idxs if idx.portion isa MTK.SciMLStructures.Initials); + init = Union{Int, AbstractVector{Int}}[]) + disc_idxs = subset_idxs(idxs, MTK.SciMLStructures.Discrete(), oldbuf.discrete) + const_idxs = subset_idxs(idxs, MTK.SciMLStructures.Constants(), oldbuf.constant) + nn_idxs = subset_idxs(idxs, MTK.NONNUMERIC_PORTION, oldbuf.nonnumeric) + + pullback = let idxs = idxs + function remake_buffer_pullback(buf′) + buf′ = unthunk(buf′) + f′ = NoTangent() + indp′ = NoTangent() + + tunable = selected_tangents(buf′.tunable, tunable_idxs) + initials = selected_tangents(buf′.initials, initials_idxs) + discrete = selected_tangents(buf′.discrete, disc_idxs) + constant = selected_tangents(buf′.constant, const_idxs) + nonnumeric = selected_tangents(buf′.nonnumeric, nn_idxs) + oldbuf′ = Tangent{typeof(oldbuf)}(; + tunable, initials, discrete, constant, nonnumeric) + idxs′ = NoTangent() + vals′ = map(i -> MTK._ducktyped_parameter_values(buf′, i), idxs) + return f′, indp′, oldbuf′, idxs′, vals′ + end + end + newbuf, pullback +end + +ChainRulesCore.@non_differentiable Base.getproperty(sys::MTK.AbstractSystem, x::Symbol) + end diff --git a/ext/MTKFMIExt.jl b/ext/MTKFMIExt.jl new file mode 100644 index 0000000000..5cfe9a82ef --- /dev/null +++ b/ext/MTKFMIExt.jl @@ -0,0 +1,933 @@ +module MTKFMIExt + +using ModelingToolkit +using SymbolicIndexingInterface +using ModelingToolkit: t_nounits as t, D_nounits as D +using DocStringExtensions +import ModelingToolkit as MTK +import SciMLBase +import FMI + +""" + $(TYPEDSIGNATURES) + +A utility macro for FMI.jl functions that return a status. Will terminate on +fatal statuses. Must be used as `@statuscheck FMI.fmiXFunction(...)` where +`X` should be `2` or `3`. Has an edge case for handling tuples for +`FMI.fmi2CompletedIntegratorStep`. +""" +macro statuscheck(expr) + @assert Meta.isexpr(expr, :call) + fn = expr.args[1] + @assert Meta.isexpr(fn, :.) + @assert fn.args[1] == :FMI + fnname = fn.args[2] + + instance = expr.args[2] + is_v2 = startswith("fmi2", string(fnname)) + + fmiTrue = is_v2 ? FMI.fmi2True : FMI.fmi3True + fmiStatusOK = is_v2 ? FMI.fmi2StatusOK : FMI.fmi3StatusOK + fmiStatusWarning = is_v2 ? FMI.fmi2StatusWarning : FMI.fmi3StatusWarning + fmiStatusFatal = is_v2 ? FMI.fmi2StatusFatal : FMI.fmi3StatusFatal + fmiTerminate = is_v2 ? FMI.fmi2Terminate : FMI.fmi3Terminate + fmiFreeInstance! = is_v2 ? FMI.fmi2FreeInstance! : FMI.fmi3FreeInstance! + return quote + status = $expr + fnname = $fnname + if status !== nothing && ((status isa Tuple && status[1] == $fmiTrue) || + (!(status isa Tuple) && status != $fmiStatusOK && + status != $fmiStatusWarning)) + if status != $fmiStatusFatal + $fmiTerminate(wrapper.instance) + end + $fmiFreeInstance!(wrapper.instance) + wrapper.instance = nothing + error("FMU Error in $fnname: status $status") + end + end |> esc +end + +@static if !hasmethod(FMI.getValueReferencesAndNames, Tuple{FMI.fmi3ModelDescription}) + """ + $(TYPEDSIGNATURES) + + This is type piracy, but FMI.jl is missing this implementation. It allows + `FMI.getStateValueReferencesAndNames` to work. + """ + function FMI.getValueReferencesAndNames( + md::FMI.fmi3ModelDescription; vrs = md.valueReferences) + dict = Dict{FMI.fmi3ValueReference, Array{String}}() + for vr in vrs + dict[vr] = FMI.valueReferenceToString(md, vr) + end + return dict + end +end + +""" + $(TYPEDSIGNATURES) + +A component that wraps an FMU loaded via FMI.jl. The FMI version (2 or 3) should be +provided as a `Val` to the function. Supports Model Exchange and CoSimulation FMUs. +All inputs, continuous variables and outputs must be `FMI.fmi2Real` or `FMI.fmi3Float64`. +Does not support events or discrete variables in the FMU. Does not support automatic +differentiation. Parameters of the FMU will have defaults corresponding to their initial +values in the FMU specification. All other variables will not have a default. Hierarchical +names in the FMU of the form `namespace.variable` are transformed into symbolic variables +with the name `namespace__variable`. + +# Keyword Arguments + +- `fmu`: The FMU loaded via `FMI.loadFMU`. +- `tolerance`: The tolerance to provide to the FMU. Not used for v3 FMUs since it is not + supported by FMI.jl. +- `communication_step_size`: The periodic interval at which communication with CoSimulation + FMUs will occur. Must be provided for CoSimulation FMU components. +- `reinitializealg`: The DAE initialization algorithm to use for the callback managing the + FMU. For CoSimulation FMUs whose states/outputs are used in algebraic equations of the + system, this needs to be an algorithm that will solve for the new algebraic variables. + For example, `OrdinaryDiffEqCore.BrownFullBasicInit()`. +- `type`: Either `:ME` or `:CS` depending on whether `fmu` is a Model Exchange or + CoSimulation FMU respectively. +- `name`: The name of the system. +""" +function MTK.FMIComponent(::Val{Ver}; fmu = nothing, tolerance = 1e-6, + communication_step_size = nothing, reinitializealg = SciMLBase.NoInit(), type, name) where {Ver} + if Ver != 2 && Ver != 3 + throw(ArgumentError("FMI Version must be `2` or `3`")) + end + if type == :CS && communication_step_size === nothing + throw(ArgumentError("`communication_step_size` must be specified for Co-Simulation FMUs.")) + end + # mapping from MTK variable to value reference + value_references = Dict() + # defaults + defs = Dict() + # unknowns of the system + states = [] + # differential variables of the system + # this is a subset of `states` in the case where the FMU has multiple names for + # the same value reference. + diffvars = [] + # variables that are derivatives of diffvars + dervars = [] + # observed equations + observed = Equation[] + # need to separate observed equations for duplicate derivative variables + # since they aren't included in CS FMUs + der_observed = Equation[] + + # parse states + fmi_variables_to_mtk_variables!(fmu, FMI.getStateValueReferencesAndNames(fmu), + value_references, diffvars, states, observed) + # create a symbolic variable __mtk_internal_u to pass to the relevant registered + # functions as the state vector + if isempty(diffvars) + # no differential variables + __mtk_internal_u = Float64[] + elseif type == :ME + # to avoid running into `structural_simplify` warnings about array variables + # and some unfortunate circular dependency issues, ME FMUs use an array of + # symbolics instead. This is also not worse off in performance + # because the former approach would allocate anyway. + # TODO: Can we avoid an allocation here using static arrays? + __mtk_internal_u = copy(diffvars) + elseif type == :CS + # CS FMUs do their own independent integration in a periodic callback, so their + # unknowns are discrete variables in the `ODESystem`. A default of `missing` allows + # them to be solved for during initialization. + @parameters __mtk_internal_u(t)[1:length(diffvars)]=missing [guess = diffvars] + push!(observed, __mtk_internal_u ~ copy(diffvars)) + end + + # parse derivatives of states + # the variables passed to `postprocess_variable` haven't been differentiated yet, so they + # should match one variable in states. That's the one this is the derivative of, and we + # keep track of this ordering + derivative_order = [] + function derivative_order_postprocess(var) + idx = findfirst(isequal(var), states) + idx === nothing || push!(derivative_order, states[idx]) + return var + end + fmi_variables_to_mtk_variables!( + fmu, FMI.getDerivateValueReferencesAndNames(fmu), value_references, dervars, + states, der_observed; postprocess_variable = derivative_order_postprocess) + @assert length(derivative_order) == length(dervars) + + # parse the inputs to the FMU + inputs = [] + fmi_variables_to_mtk_variables!(fmu, FMI.getInputValueReferencesAndNames(fmu), + value_references, inputs, states, observed; postprocess_variable = v -> MTK.setinput( + v, true)) + # create a symbolic variable for the input buffer + __mtk_internal_x = copy(inputs) + if isempty(__mtk_internal_x) + __mtk_internal_x = Float64[] + end + + # parse the outputs of the FMU + outputs = [] + fmi_variables_to_mtk_variables!(fmu, FMI.getOutputValueReferencesAndNames(fmu), + value_references, outputs, states, observed; postprocess_variable = v -> MTK.setoutput( + v, true)) + # create the output buffer. This is only required for CoSimulation to pass it to + # the callback affect + if type == :CS + if isempty(outputs) + __mtk_internal_o = Float64[] + else + @parameters __mtk_internal_o(t)[1:length(outputs)]=missing [guess = zeros(length(outputs))] + push!(observed, __mtk_internal_o ~ outputs) + end + end + + # parse the parameters + params = [] + # multiple names for the same parameter are treated as parameter dependencies. + parameter_dependencies = Equation[] + fmi_variables_to_mtk_variables!( + fmu, FMI.getParameterValueReferencesAndNames(fmu), value_references, + params, [], parameter_dependencies, defs; parameters = true) + # create a symbolic variable for the parameter buffer + __mtk_internal_p = copy(params) + if isempty(__mtk_internal_p) + __mtk_internal_p = Float64[] + end + + derivative_value_references = UInt32[value_references[var] for var in dervars] + state_value_references = UInt32[value_references[var] for var in diffvars] + output_value_references = UInt32[value_references[var] for var in outputs] + input_value_references = UInt32[value_references[var] for var in inputs] + param_value_references = UInt32[value_references[var] for var in params] + + # create a parameter for the instance wrapper + # this manages the creation and deallocation of FMU instances + buffer_length = length(diffvars) + length(outputs) + if Ver == 2 + @parameters (wrapper::FMI2InstanceWrapper)(..)[1:buffer_length] = FMI2InstanceWrapper( + fmu, derivative_value_references, state_value_references, output_value_references, + param_value_references, input_value_references, tolerance) + else + @parameters (wrapper::FMI3InstanceWrapper)(..)[1:buffer_length] = FMI3InstanceWrapper( + fmu, derivative_value_references, state_value_references, + output_value_references, param_value_references, input_value_references) + end + + # any additional initialization equations for the system + initialization_eqs = Equation[] + + if type == :ME + # the wrapper is a callable struct which returns the state derivative and + # output values + # symbolic expression for calling the wrapper + call_expr = wrapper(__mtk_internal_u, __mtk_internal_x, __mtk_internal_p, t) + + # differential and observed equations + diffeqs = Equation[] + for (i, var) in enumerate([dervars; outputs]) + push!(diffeqs, var ~ call_expr[i]) + end + for (var, dervar) in zip(derivative_order, dervars) + push!(diffeqs, D(var) ~ dervar) + end + + # instance management callback which deallocates the instance when + # necessary and notifies the FMU of completed integrator steps + finalize_affect = MTK.FunctionalAffect(fmiFinalize!, [], [wrapper], []) + step_affect = MTK.FunctionalAffect(Returns(nothing), [], [], []) + instance_management_callback = MTK.SymbolicDiscreteCallback( + (t != t - 1), step_affect; finalize = finalize_affect, reinitializealg = reinitializealg) + + push!(params, wrapper) + append!(observed, der_observed) + elseif type == :CS + _functor = if Ver == 2 + FMI2CSFunctor(state_value_references, output_value_references) + else + FMI3CSFunctor(state_value_references, output_value_references) + end + @parameters (functor::(typeof(_functor)))(..)[1:(length(__mtk_internal_u) + length(__mtk_internal_o))] = _functor + # for co-simulation, we need to ensure the output buffer is solved for + # during initialization + for (i, x) in enumerate(collect(__mtk_internal_o)) + push!(initialization_eqs, + x ~ functor( + wrapper, __mtk_internal_u, __mtk_internal_x, __mtk_internal_p, t)[i]) + end + + diffeqs = Equation[] + + # use `ImperativeAffect` for instance management here + cb_observed = (; inputs = __mtk_internal_x, params = copy(params), + t, wrapper, dt = communication_step_size) + cb_modified = (;) + # modify the outputs if present + if symbolic_type(__mtk_internal_o) != NotSymbolic() + cb_modified = (cb_modified..., outputs = __mtk_internal_o) + end + # modify the continuous state if present + if symbolic_type(__mtk_internal_u) != NotSymbolic() + cb_modified = (cb_modified..., states = __mtk_internal_u) + end + initialize_affect = MTK.ImperativeAffect(fmiCSInitialize!; observed = cb_observed, + modified = cb_modified, ctx = _functor) + finalize_affect = MTK.FunctionalAffect(fmiFinalize!, [], [wrapper], []) + # the callback affect performs the stepping + step_affect = MTK.ImperativeAffect( + fmiCSStep!; observed = cb_observed, modified = cb_modified, ctx = _functor) + instance_management_callback = MTK.SymbolicDiscreteCallback( + communication_step_size, step_affect; initialize = initialize_affect, + finalize = finalize_affect, reinitializealg = reinitializealg + ) + + # guarded in case there are no outputs/states and the variable is `[]`. + symbolic_type(__mtk_internal_o) == NotSymbolic() || push!(params, __mtk_internal_o) + symbolic_type(__mtk_internal_u) == NotSymbolic() || push!(params, __mtk_internal_u) + + push!(params, wrapper, functor) + end + + eqs = [observed; diffeqs] + return ODESystem(eqs, t, states, params; parameter_dependencies, defaults = defs, + discrete_events = [instance_management_callback], name, initialization_eqs) +end + +""" + $(TYPEDSIGNATURES) + +A utility function which accepts an FMU `fmu` and a mapping from value reference to a +list of associated names `varmap`. A symbolic variable is created for each name. The +associated value reference is kept track of in `value_references`. In case there are +multiple names for a value reference, the symbolic variable for the first name is pushed +to `truevars`. All of the created symbolic variables are pushed to `allvars`. Observed +equations equating identical variables are pushed to `obseqs`. `defs` is a dictionary of +defaults. + +# Keyword Arguments +- `parameters`: A boolean indicating whether to use `@parameters` for the symbolic + variables instead of `@variables`. +- `postprocess_variable`: A function applied to each created variable that should + return the updated variable. This is useful to add metadata to variables. +""" +function fmi_variables_to_mtk_variables!( + fmu::Union{FMI.FMU2, FMI.FMU3}, varmap::AbstractDict, + value_references::AbstractDict, truevars, allvars, + obseqs, defs = Dict(); parameters = false, postprocess_variable = identity) + for (valRef, varnames) in varmap + stateT = FMI.dataTypeForValueReference(fmu, valRef) + snames = Symbol[] + ders = Int[] + for name in varnames + sname, der = parseFMIVariableName(name) + push!(snames, sname) + push!(ders, der) + end + if parameters + vars = [postprocess_variable(MTK.unwrap(only(@parameters $sname::stateT))) + for sname in snames] + else + vars = [postprocess_variable(MTK.unwrap(only(@variables $sname(t)::stateT))) + for sname in snames] + end + for i in eachindex(vars) + der = ders[i] + vars[i] = MTK.unwrap(vars[i]) + for j in 1:der + vars[i] = D(vars[i]) + end + vars[i] = MTK.default_toterm(vars[i]) + end + for i in eachindex(vars) + if i == 1 + push!(truevars, vars[i]) + else + push!(obseqs, vars[i] ~ vars[1]) + end + value_references[vars[i]] = valRef + end + append!(allvars, vars) + defval = FMI.getStartValue(fmu, valRef) + defs[vars[1]] = defval + end +end + +""" + $(TYPEDSIGNATURES) + +Parse the string name of an FMI variable into a `Symbol` name for the corresponding +MTK variable. Return the `Symbol` name and the number of times it is differentiated. +""" +function parseFMIVariableName(name::AbstractString) + name = replace(name, "." => "__") + der = 0 + if startswith(name, "der(") + idx = findfirst(',', name) + if idx === nothing + name = @view name[5:(end - 1)] + der = 1 + else + der = parse(Int, @view name[(idx + 1):(end - 1)]) + name = @view name[5:(idx - 1)] + end + end + return Symbol(name), der +end + +""" + $(TYPEDEF) + +A struct which manages instance creation and deallocation for v2 FMUs. + +# Fields + +$(TYPEDFIELDS) +""" +mutable struct FMI2InstanceWrapper + """ + The FMU from `FMI.loadFMU`. + """ + const fmu::FMI.FMU2 + """ + The value references for derivatives of states of the FMU, in the order that the + caller expects them to be returned when calling this struct. + """ + const derivative_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references for the states of the FMU. + """ + const state_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references for outputs of the FMU, in the order that the caller expects + them to be returned when calling this struct. + """ + const output_value_references::Vector{FMI.fmi2ValueReference} + """ + The parameter value references. These should be in the same order as the parameter + vector passed to functions involving this wrapper. + """ + const param_value_references::Vector{FMI.fmi2ValueReference} + """ + The input value references. These should be in the same order as the inputs passed + to functions involving this wrapper. + """ + const input_value_references::Vector{FMI.fmi2ValueReference} + """ + The tolerance with which to setup the FMU instance. + """ + const tolerance::FMI.fmi2Real + """ + The FMU instance, if present, and `nothing` otherwise. + """ + instance::Union{FMI.FMU2Component{FMI.FMU2}, Nothing} +end + +""" + $(TYPEDSIGNATURES) + +Create an `FMI2InstanceWrapper` with no instance. +""" +function FMI2InstanceWrapper(fmu, ders, states, outputs, params, inputs, tolerance) + FMI2InstanceWrapper(fmu, ders, states, outputs, params, inputs, tolerance, nothing) +end + +Base.nameof(::FMI2InstanceWrapper) = :FMI2InstanceWrapper + +""" + $(TYPEDSIGNATURES) + +Common functionality for creating an instance of a v2 FMU. Does not check if +`wrapper.instance` is already present, and overwrites the existing value with +a new instance. `inputs` should be in the order of `wrapper.input_value_references`. +`params` should be in the order of `wrapper.param_value_references`. `t` is the current +time. Returns the created instance, which is also stored in `wrapper.instance`. +""" +function get_instance_common!(wrapper::FMI2InstanceWrapper, inputs, params, t) + wrapper.instance = FMI.fmi2Instantiate!(wrapper.fmu)::FMI.FMU2Component + if !isempty(inputs) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.input_value_references, + Csize_t(length(wrapper.param_value_references)), inputs) + end + if !isempty(params) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.param_value_references, + Csize_t(length(wrapper.param_value_references)), params) + end + @statuscheck FMI.fmi2SetupExperiment( + wrapper.instance, FMI.fmi2True, wrapper.tolerance, t, FMI.fmi2False, t) + @statuscheck FMI.fmi2EnterInitializationMode(wrapper.instance) + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a Model Exchange FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_ME!(wrapper::FMI2InstanceWrapper, inputs, params, t) + if wrapper.instance === nothing + get_instance_common!(wrapper, inputs, params, t) + @statuscheck FMI.fmi2ExitInitializationMode(wrapper.instance) + eventInfo = FMI.fmi2NewDiscreteStates(wrapper.instance) + @assert eventInfo.newDiscreteStatesNeeded == FMI.fmi2False + # TODO: Support FMU events + @statuscheck FMI.fmi2EnterContinuousTimeMode(wrapper.instance) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a CoSimulation FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_CS!(wrapper::FMI2InstanceWrapper, states, inputs, params, t) + if wrapper.instance === nothing + get_instance_common!(wrapper, inputs, params, t) + if !isempty(states) + @statuscheck FMI.fmi2SetReal(wrapper.instance, wrapper.state_value_references, + Csize_t(length(wrapper.state_value_references)), states) + end + @statuscheck FMI.fmi2ExitInitializationMode(wrapper.instance) + end + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Call `fmiXCompletedIntegratorStep` with `noSetFMUStatePriorToCurrentPoint` as false. +""" +function partiallyCompleteIntegratorStep(wrapper::FMI2InstanceWrapper) + @statuscheck FMI.fmi2CompletedIntegratorStep(wrapper.instance, FMI.fmi2False) +end + +""" + $(TYPEDSIGNATURES) + +If `wrapper.instance !== nothing`, terminate and free the instance. Also set +`wrapper.instance` to `nothing`. +""" +function reset_instance!(wrapper::FMI2InstanceWrapper) + wrapper.instance === nothing && return + FMI.fmi2Terminate(wrapper.instance) + FMI.fmi2FreeInstance!(wrapper.instance) + wrapper.instance = nothing +end + +""" + $(TYPEDEF) + +A struct which manages instance creation and deallocation for v3 FMUs. + +# Fields + +$(TYPEDFIELDS) +""" +mutable struct FMI3InstanceWrapper + """ + The FMU from `FMI.loadFMU`. + """ + const fmu::FMI.FMU3 + """ + The value references for derivatives of states of the FMU, in the order that the + caller expects them to be returned when calling this struct. + """ + const derivative_value_references::Vector{FMI.fmi3ValueReference} + const state_value_references::Vector{FMI.fmi3ValueReference} + """ + The value references for outputs of the FMU, in the order that the caller expects + them to be returned when calling this struct. + """ + const output_value_references::Vector{FMI.fmi3ValueReference} + """ + The parameter value references. These should be in the same order as the parameter + vector passed to functions involving this wrapper. + """ + const param_value_references::Vector{FMI.fmi3ValueReference} + """ + The input value references. These should be in the same order as the inputs passed + to functions involving this wrapper. + """ + const input_value_references::Vector{FMI.fmi3ValueReference} + """ + The FMU instance, if present, and `nothing` otherwise. + """ + instance::Union{FMI.FMU3Instance{FMI.FMU3}, Nothing} +end + +""" + $(TYPEDSIGNATURES) + +Create an `FMI3InstanceWrapper` with no instance. +""" +function FMI3InstanceWrapper(fmu, ders, states, outputs, params, inputs) + FMI3InstanceWrapper(fmu, ders, states, outputs, params, inputs, nothing) +end + +Base.nameof(::FMI3InstanceWrapper) = :FMI3InstanceWrapper + +""" + $(TYPEDSIGNATURES) + +Common functionality for creating an instance of a v3 FMU. Since v3 FMUs need to be +instantiated differently depending on the type, this assumes `wrapper.instance` is a +freshly instantiated FMU which needs to be initialized. `inputs` should be in the order +of `wrapper.input_value_references`. `params` should be in the order of +`wrapper.param_value_references`. `t` is the current time. Returns `wrapper.instance`. +""" +function get_instance_common!(wrapper::FMI3InstanceWrapper, inputs, params, t) + if !isempty(params) + @statuscheck FMI.fmi3SetFloat64(wrapper.instance, wrapper.param_value_references, + params) + end + @statuscheck FMI.fmi3EnterInitializationMode( + wrapper.instance, FMI.fmi3False, zero(FMI.fmi3Float64), t, FMI.fmi3False, t) + if !isempty(inputs) + @statuscheck FMI.fmi3SetFloat64( + wrapper.instance, wrapper.input_value_references, inputs) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a Model Exchange FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_ME!(wrapper::FMI3InstanceWrapper, inputs, params, t) + if wrapper.instance === nothing + wrapper.instance = FMI.fmi3InstantiateModelExchange!(wrapper.fmu)::FMI.FMU3Instance + get_instance_common!(wrapper, inputs, params, t) + @statuscheck FMI.fmi3ExitInitializationMode(wrapper.instance) + eventInfo = FMI.fmi3UpdateDiscreteStates(wrapper.instance) + @assert eventInfo[1] == FMI.fmi2False + # TODO: Support FMU events + @statuscheck FMI.fmi3EnterContinuousTimeMode(wrapper.instance) + end + + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) + +Create an instance of a CoSimulation FMU. Use the existing instance in `wrapper` if +present and create a new one otherwise. Return the instance. + +See `get_instance_common!` for a description of the arguments. +""" +function get_instance_CS!(wrapper::FMI3InstanceWrapper, states, inputs, params, t) + if wrapper.instance === nothing + wrapper.instance = FMI.fmi3InstantiateCoSimulation!( + wrapper.fmu; eventModeUsed = false)::FMI.FMU3Instance + get_instance_common!(wrapper, inputs, params, t) + if !isempty(states) + @statuscheck FMI.fmi3SetFloat64( + wrapper.instance, wrapper.state_value_references, states) + end + @statuscheck FMI.fmi3ExitInitializationMode(wrapper.instance) + end + return wrapper.instance +end + +""" + $(TYPEDSIGNATURES) +""" +function partiallyCompleteIntegratorStep(wrapper::FMI3InstanceWrapper) + enterEventMode = Ref(FMI.fmi3False) + terminateSimulation = Ref(FMI.fmi3False) + @statuscheck FMI.fmi3CompletedIntegratorStep!( + wrapper.instance, FMI.fmi3False, enterEventMode, terminateSimulation) + @assert enterEventMode[] == FMI.fmi3False + @assert terminateSimulation[] == FMI.fmi3False +end + +""" + $(TYPEDSIGNATURES) +""" +function reset_instance!(wrapper::FMI3InstanceWrapper) + wrapper.instance === nothing && return + FMI.fmi3Terminate(wrapper.instance) + FMI.fmi3FreeInstance!(wrapper.instance) + wrapper.instance = nothing +end + +@register_array_symbolic (fn::FMI2InstanceWrapper)( + states::Vector{<:Real}, inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +@register_array_symbolic (fn::FMI3InstanceWrapper)( + wrapper::FMI3InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) + +Update the internal state of the ME FMU and return a vector of updated values +for continuous state derivatives and output variables respectively. Needs to be a +callable struct to enable symbolic registration with an inferred return size. +""" +function (wrapper::Union{FMI2InstanceWrapper, FMI3InstanceWrapper})( + states, inputs, params, t) + instance = get_instance_ME!(wrapper, inputs, params, t) + + # TODO: Find a way to do this without allocating. We can't pass a view to these + # functions. + states_buffer = zeros(length(states)) + outputs_buffer = zeros(length(wrapper.output_value_references)) + # Defined in FMIBase.jl/src/eval.jl + # Doesn't seem to be documented, but somehow this is the only way to + # propagate inputs to the FMU consistently. I have no idea why. + instance(; x = states, u = inputs, u_refs = wrapper.input_value_references, + p = params, p_refs = wrapper.param_value_references, t = t) + # the spec requires completing the step before getting updated derivative/output values + partiallyCompleteIntegratorStep(wrapper) + instance(; dx = states_buffer, dx_refs = wrapper.derivative_value_references, + y = outputs_buffer, y_refs = wrapper.output_value_references) + return [states_buffer; outputs_buffer] +end + +""" + $(TYPEDSIGNATURES) + +An affect function for use inside a `FunctionalAffect`. This should be triggered at the +end of the solve, regardless of whether it succeeded or failed. Expects `p` to be a +1-length array containing the index of the instance wrapper (`FMI2InstanceWrapper` or +`FMI3InstanceWrapper`) in the parameter object. +""" +function fmiFinalize!(integrator, u, p, ctx) + wrapper_idx = p[1] + wrapper = integrator.ps[wrapper_idx] + reset_instance!(wrapper) +end + +""" + $(TYPEDEF) + +A callable struct useful for initializing v2 CoSimulation FMUs. When called, updates the +internal state of the FMU and gets updated values for output variables. + +# Fields + +$(TYPEDFIELDS) +""" +struct FMI2CSFunctor + """ + The value references of state variables in the FMU. + """ + state_value_references::Vector{FMI.fmi2ValueReference} + """ + The value references of output variables in the FMU. + """ + output_value_references::Vector{FMI.fmi2ValueReference} +end + +function (fn::FMI2CSFunctor)(wrapper::FMI2InstanceWrapper, states, inputs, params, t) + states = states isa SubArray ? copy(states) : states + inputs = inputs isa SubArray ? copy(inputs) : inputs + params = params isa SubArray ? copy(params) : params + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isempty(fn.output_value_references) + return eltype(states)[] + else + return FMI.fmi2GetReal(instance, fn.output_value_references) + end +end + +@register_array_symbolic (fn::FMI2CSFunctor)( + wrapper::FMI2InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) + +An affect function designed for use with `ImperativeAffect`. Should be triggered during +callback initialization. `m` should contain the key `:states` with the value being the +state vector if the FMU has continuous states. `m` should contain the key `:outputs` with +the value being the output vector if the FMU has output variables. `o` should contain the +`:inputs`, `:params`, `:t` and `:wrapper` where the latter contains the `FMI2InstanceWrapper`. + +Initializes the FMU. Only for use with CoSimulation FMUs. +""" +function fmiCSInitialize!(m, o, ctx::FMI2CSFunctor, integrator) + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + wrapper = o.wrapper + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isdefined(m, :states) + @statuscheck FMI.fmi2GetReal!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi2GetReal!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDSIGNATURES) + +An affect function designed for use with `ImperativeAffect`. Should be triggered +periodically to communicte with the CoSimulation FMU. Has the same requirements as +`fmiCSInitialize!` for `m` and `o`, with the addition that `o` should have a key +`:dt` with the value being the communication step size. +""" +function fmiCSStep!(m, o, ctx::FMI2CSFunctor, integrator) + wrapper = o.wrapper + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + dt = o.dt + + instance = get_instance_CS!(wrapper, states, inputs, params, integrator.t) + if !isempty(inputs) + FMI.fmi2SetReal( + instance, wrapper.input_value_references, Csize_t(length(inputs)), inputs) + end + @statuscheck FMI.fmi2DoStep(instance, integrator.t - dt, dt, FMI.fmi2True) + + if isdefined(m, :states) + @statuscheck FMI.fmi2GetReal!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi2GetReal!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDEF) + +A callable struct useful for initializing v3 CoSimulation FMUs. When called, updates the +internal state of the FMU and gets updated values for output variables. + +# Fields + +$(TYPEDFIELDS) +""" +struct FMI3CSFunctor + """ + The value references of state variables in the FMU. + """ + state_value_references::Vector{FMI.fmi3ValueReference} + """ + The value references of output variables in the FMU. + """ + output_value_references::Vector{FMI.fmi3ValueReference} +end + +function (fn::FMI3CSFunctor)(wrapper::FMI3InstanceWrapper, states, inputs, params, t) + states = states isa SubArray ? copy(states) : states + inputs = inputs isa SubArray ? copy(inputs) : inputs + params = params isa SubArray ? copy(params) : params + instance = get_instance_CS!(wrapper, states, inputs, params, t) + + if isempty(fn.output_value_references) + return eltype(states)[] + else + return FMI.fmi3GetFloat64(instance, fn.output_value_references) + end +end + +@register_array_symbolic (fn::FMI3CSFunctor)( + wrapper::FMI3InstanceWrapper, states::Vector{<:Real}, + inputs::Vector{<:Real}, params::Vector{<:Real}, t::Real) begin + size = (length(states) + length(fn.output_value_references),) + eltype = eltype(states) + ndims = 1 +end + +""" + $(TYPEDSIGNATURES) +""" +function fmiCSInitialize!(m, o, ctx::FMI3CSFunctor, integrator) + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + wrapper = o.wrapper + if wrapper.instance !== nothing + reset_instance!(wrapper) + end + instance = get_instance_CS!(wrapper, states, inputs, params, t) + if isdefined(m, :states) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +""" + $(TYPEDSIGNATURES) +""" +function fmiCSStep!(m, o, ctx::FMI3CSFunctor, integrator) + wrapper = o.wrapper + states = isdefined(m, :states) ? m.states : () + inputs = o.inputs + params = o.params + t = o.t + dt = o.dt + + instance = get_instance_CS!(wrapper, states, inputs, params, integrator.t) + if !isempty(inputs) + FMI.fmi3SetFloat64(instance, wrapper.input_value_references, inputs) + end + eventEncountered = Ref(FMI.fmi3False) + terminateSimulation = Ref(FMI.fmi3False) + earlyReturn = Ref(FMI.fmi3False) + lastSuccessfulTime = Ref(zero(FMI.fmi3Float64)) + @statuscheck FMI.fmi3DoStep!( + instance, integrator.t - dt, dt, FMI.fmi3True, eventEncountered, + terminateSimulation, earlyReturn, lastSuccessfulTime) + @assert eventEncountered[] == FMI.fmi3False + @assert terminateSimulation[] == FMI.fmi3False + @assert earlyReturn[] == FMI.fmi3False + + if isdefined(m, :states) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.state_value_references, m.states) + end + if isdefined(m, :outputs) + @statuscheck FMI.fmi3GetFloat64!(instance, ctx.output_value_references, m.outputs) + end + + return m +end + +end # module diff --git a/ext/MTKInfiniteOptExt.jl b/ext/MTKInfiniteOptExt.jl new file mode 100644 index 0000000000..3b482a1f03 --- /dev/null +++ b/ext/MTKInfiniteOptExt.jl @@ -0,0 +1,26 @@ +module MTKInfiniteOptExt +import ModelingToolkit +import SymbolicUtils +import NaNMath +import InfiniteOpt +import InfiniteOpt: JuMP, GeneralVariableRef + +# This file contains method definitions to make it possible to trace through functions generated by MTK using JuMP variables + +for ff in [acos, log1p, acosh, log2, asin, tan, atanh, cos, log, sin, log10, sqrt] + f = nameof(ff) + # These need to be defined so that JuMP can trace through functions built by Symbolics + @eval NaNMath.$f(x::GeneralVariableRef) = Base.$f(x) +end + +# JuMP variables and Symbolics variables never compare equal. When tracing through dynamics, a function argument can be either a JuMP variable or A Symbolics variable, it can never be both. +function Base.isequal(::SymbolicUtils.Symbolic, + ::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr}) + false +end +function Base.isequal( + ::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr}, + ::SymbolicUtils.Symbolic) + false +end +end diff --git a/src/ModelingToolkit.jl b/src/ModelingToolkit.jl index 13b29831e5..9f69458528 100644 --- a/src/ModelingToolkit.jl +++ b/src/ModelingToolkit.jl @@ -38,21 +38,30 @@ using Base: RefValue using Combinatorics import Distributions 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, Continuous + PeriodicClock, Clock, SolverStepClock, ContinuousClock, OverrideInit, + NoInit using Distributed import JuliaFormatter using MLStyle +import Moshi +using Moshi.Data: @data using NonlinearSolve +import SCCNonlinearSolve using Reexport using RecursiveArrayTools import Graphs: SimpleDiGraph, add_edge!, incidence_matrix -import BlockArrays: BlockedArray, Block, blocksize, blocksizes +import BlockArrays: BlockArray, BlockedArray, Block, blocksize, blocksizes, blockpush!, + undef_blocks, blocks +using OffsetArrays: Origin +import CommonSolve +import EnumX using RuntimeGeneratedFunctions using RuntimeGeneratedFunctions: drop_expr @@ -63,7 +72,8 @@ using Symbolics: _parse_vars, value, @derivatives, get_variables, VariableSource, getname, variable, Connection, connect, NAMESPACE_SEPARATOR, set_scalar_metadata, setdefaultval, initial_state, transition, activeState, entry, hasnode, - ticksInState, timeInState, fixpoint_sub, fast_substitute + ticksInState, timeInState, fixpoint_sub, fast_substitute, + CallWithMetadata, CallWithParent const NAMESPACE_SEPARATOR_SYMBOL = Symbol(NAMESPACE_SEPARATOR) import Symbolics: rename, get_variables!, _solve, hessian_sparsity, jacobian_sparsity, isaffine, islinear, _iszero, _isone, @@ -84,6 +94,9 @@ RuntimeGeneratedFunctions.init(@__MODULE__) import DynamicQuantities, Unitful const DQ = DynamicQuantities +import DifferentiationInterface as DI +using ADTypes: AutoForwardDiff + export @derivatives for fun in [:toexpr] @@ -117,6 +130,7 @@ abstract type AbstractTimeIndependentSystem <: AbstractSystem end abstract type AbstractODESystem <: AbstractTimeDependentSystem end abstract type AbstractMultivariateSystem <: AbstractSystem end abstract type AbstractOptimizationSystem <: AbstractTimeIndependentSystem end +abstract type AbstractDiscreteSystem <: AbstractTimeDependentSystem end function independent_variable end @@ -141,9 +155,19 @@ include("systems/parameter_buffer.jl") include("systems/abstractsystem.jl") include("systems/model_parsing.jl") include("systems/connectors.jl") +include("systems/analysis_points.jl") +include("systems/imperative_affect.jl") include("systems/callbacks.jl") +include("systems/codegen_utils.jl") +include("systems/problem_utils.jl") +include("linearization.jl") + +include("systems/optimization/constraints_system.jl") +include("systems/optimization/optimizationsystem.jl") +include("systems/optimization/modelingtoolkitize.jl") include("systems/nonlinear/nonlinearsystem.jl") +include("systems/nonlinear/homotopy_continuation.jl") include("systems/diffeqs/odesystem.jl") include("systems/diffeqs/sdesystem.jl") include("systems/diffeqs/abstractodesystem.jl") @@ -154,13 +178,10 @@ include("systems/diffeqs/modelingtoolkitize.jl") include("systems/diffeqs/basic_transformations.jl") include("systems/discrete_system/discrete_system.jl") +include("systems/discrete_system/implicit_discrete_system.jl") include("systems/jumps/jumpsystem.jl") -include("systems/optimization/constraints_system.jl") -include("systems/optimization/optimizationsystem.jl") -include("systems/optimization/modelingtoolkitize.jl") - include("systems/pde/pdesystem.jl") include("systems/sparsematrixclil.jl") @@ -173,6 +194,7 @@ include("discretedomain.jl") include("systems/systemstructure.jl") include("systems/clock_inference.jl") include("systems/systems.jl") +include("systems/if_lifting.jl") include("debugging.jl") include("systems/alias_elimination.jl") @@ -205,6 +227,34 @@ PrecompileTools.@compile_workload begin @variables x(ModelingToolkit.t_nounits) @named sys = ODESystem([ModelingToolkit.D_nounits(x) ~ -x], ModelingToolkit.t_nounits) prob = ODEProblem(structural_simplify(sys), [x => 30.0], (0, 100), [], jac = true) + @mtkmodel __testmod__ begin + @constants begin + c = 1.0 + end + @structural_parameters begin + structp = false + end + if structp + @variables begin + x(t) = 0.0, [description = "foo", guess = 1.0] + end + else + @variables begin + x(t) = 0.0, [description = "foo w/o structp", guess = 1.0] + end + end + @parameters begin + a = 1.0, [description = "bar"] + if structp + b = 2 * a, [description = "if"] + else + c + end + end + @equations begin + x ~ a + b + end + end end export AbstractTimeDependentSystem, @@ -218,32 +268,43 @@ export DAEFunctionExpr, DAEProblemExpr export SDESystem, SDEFunction, SDEFunctionExpr, SDEProblemExpr export SystemStructure export DiscreteSystem, DiscreteProblem, DiscreteFunction, DiscreteFunctionExpr +export ImplicitDiscreteSystem, ImplicitDiscreteProblem, ImplicitDiscreteFunction, + ImplicitDiscreteFunctionExpr export JumpSystem export ODEProblem, SDEProblem export NonlinearFunction, NonlinearFunctionExpr export NonlinearProblem, NonlinearProblemExpr +export IntervalNonlinearFunction, IntervalNonlinearFunctionExpr +export IntervalNonlinearProblem, IntervalNonlinearProblemExpr export OptimizationProblem, OptimizationProblemExpr, constraints export SteadyStateProblem, SteadyStateProblemExpr export JumpProblem export NonlinearSystem, OptimizationSystem, ConstraintsSystem export alias_elimination, flatten -export connect, domain_connect, @connector, Connection, Flow, Stream, instream +export connect, domain_connect, @connector, Connection, AnalysisPoint, Flow, Stream, + instream export initial_state, transition, activeState, entry, ticksInState, timeInState export @component, @mtkmodel, @mtkbuild export isinput, isoutput, getbounds, hasbounds, getguess, hasguess, isdisturbance, istunable, getdist, hasdist, - tunable_parameters, isirreducible, getdescription, hasdescription -export ode_order_lowering, dae_order_lowering, liouville_transform + tunable_parameters, isirreducible, getdescription, hasdescription, + hasunit, getunit, hasconnect, getconnect, + hasmisc, getmisc, state_priority +export ode_order_lowering, dae_order_lowering, liouville_transform, + change_independent_variable, substitute_component export PDESystem export Differential, expand_derivatives, @derivatives export Equation, ConstrainedEquation export Term, Sym export SymScope, LocalScope, ParentScope, DelayParentScope, GlobalScope export independent_variable, equations, controls, observed, full_equations -export initialization_equations, guesses, defaults, parameter_dependencies -export structural_simplify, expand_connections, linearize, linearization_function +export initialization_equations, guesses, defaults, parameter_dependencies, hierarchy +export structural_simplify, expand_connections, linearize, linearization_function, + LinearizationProblem +export solve -export calculate_jacobian, generate_jacobian, generate_function, generate_custom_function +export calculate_jacobian, generate_jacobian, generate_function, generate_custom_function, + generate_W export calculate_control_jacobian, generate_control_jacobian export calculate_tgrad, generate_tgrad export calculate_gradient, generate_gradient @@ -256,26 +317,34 @@ 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 generate_initializesystem +export generate_initializesystem, Initial, isinitial export alg_equations, diff_equations, has_alg_equations, has_diff_equations export get_alg_eqs, get_diff_eqs, has_alg_eqs, has_diff_eqs export @variables, @parameters, @independent_variables, @constants, @brownian -export @named, @nonamespace, @namespace, extend, compose, complete +export @named, @nonamespace, @namespace, extend, compose, complete, toggle_namespacing export debug_system -#export Continuous, Discrete, sampletime, input_timedomain, output_timedomain +#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 +export MTKParameters, reorder_dimension_by_tunables!, reorder_dimension_by_tunables + +export HomotopyContinuationProblem + +export AnalysisPoint, get_sensitivity_function, get_comp_sensitivity_function, + get_looptransfer_function, get_sensitivity, get_comp_sensitivity, get_looptransfer, + open_loop +function FMIComponent end end # module diff --git a/src/clock.jl b/src/clock.jl index 86b7296a6d..1c9ed89128 100644 --- a/src/clock.jl +++ b/src/clock.jl @@ -1,20 +1,12 @@ -module InferredClock - -export InferredTimeDomain - -using Expronicon.ADT: @adt, @match -using SciMLBase: TimeDomain - -@adt InferredTimeDomain begin +@data InferredClock begin Inferred InferredDiscrete end -Base.Broadcast.broadcastable(x::InferredTimeDomain) = Ref(x) +const InferredTimeDomain = InferredClock.Type +using .InferredClock: Inferred, InferredDiscrete -end - -using .InferredClock +Base.Broadcast.broadcastable(x::InferredTimeDomain) = Ref(x) struct VariableTimeDomain end Symbolics.option_to_metadata_type(::Val{:timedomain}) = VariableTimeDomain @@ -29,7 +21,7 @@ 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) == Continuous + issym(x) && return getmetadata(x, VariableTimeDomain, false) == ContinuousClock() !has_discrete_domain(x) && has_continuous_domain(x) end @@ -50,7 +42,7 @@ 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, Continuous, nothing) !== nothing || + # getmetadata(x, ContinuousClock, nothing) !== nothing || # getmetadata(x, Discrete, nothing) !== nothing getmetadata(x, VariableTimeDomain, nothing) !== nothing end @@ -58,8 +50,8 @@ 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) = Continuous - @eval output_timedomain(::$op, arg = nothing) = Continuous + @eval input_timedomain(::$op, arg = nothing) = ContinuousClock() + @eval output_timedomain(::$op, arg = nothing) = ContinuousClock() end """ @@ -104,8 +96,8 @@ function is_discrete_domain(x) !has_discrete_domain(x) && has_continuous_domain(x) end -sampletime(c) = @match c begin - PeriodicClock(dt, _...) => dt +sampletime(c) = Moshi.Match.@match c begin + PeriodicClock(dt) => dt _ => nothing end diff --git a/src/debugging.jl b/src/debugging.jl index 06e3edf0d8..c16b47c2e3 100644 --- a/src/debugging.jl +++ b/src/debugging.jl @@ -1,36 +1,100 @@ -const LOGGED_FUN = Set([log, sqrt, (^), /, inv]) -is_legal(::typeof(/), a, b) = is_legal(inv, b) -is_legal(::typeof(inv), a) = !iszero(a) -is_legal(::Union{typeof(log), typeof(sqrt)}, a) = a isa Complex || a >= zero(a) -is_legal(::typeof(^), a, b) = a isa Complex || b isa Complex || isinteger(b) || a >= zero(a) - +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...) - f = lf.f - symbolic_args = lf.args - if is_legal(f, args...) - f(args...) - else - args_str = join(string.(symbolic_args .=> args), ", ", ", and ") - throw(DomainError(args, "$(lf.f) errors with input(s): $args_str")) + 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...) +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), args..., type = Real) + term(LoggedFun(f, args, error_nonfinite), args..., type = Real) end -debug_sub(eq::Equation) = debug_sub(eq.lhs) ~ debug_sub(eq.rhs) -function debug_sub(ex) +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(debug_sub, arguments(ex)) - f in LOGGED_FUN ? logged_fun(f, args...) : + args = map(ex -> debug_sub(ex, funcs; kw...), arguments(ex)) + f in funcs ? logged_fun(f, args...; kw...) : maketerm(typeof(ex), f, args, metadata(ex)) end + +""" + $(TYPEDSIGNATURES) + +A function which returns `NaN` if `condition` fails, and `0.0` otherwise. +""" +function _nan_condition(condition::Bool) + condition ? 0.0 : NaN +end + +@register_symbolic _nan_condition(condition::Bool) + +""" + $(TYPEDSIGNATURES) + +A function which takes a condition `expr` and returns `NaN` if it is false, +and zero if it is true. In case the condition is false and `log == true`, +`message` will be logged as an `@error`. +""" +function _debug_assertion(expr::Bool, message::String, log::Bool) + value = _nan_condition(expr) + isnan(value) || return value + log && @error message + return value +end + +@register_symbolic _debug_assertion(expr::Bool, message::String, log::Bool) + +""" +Boolean parameter added to models returned from `debug_system` to control logging of +assertions. +""" +const ASSERTION_LOG_VARIABLE = only(@parameters __log_assertions_ₘₜₖ::Bool = false) + +""" + $(TYPEDSIGNATURES) + +Get a symbolic expression for all the assertions in `sys`. The expression returns `NaN` +if any of the assertions fail, and `0.0` otherwise. If `ASSERTION_LOG_VARIABLE` is a +parameter in the system, it will control whether the message associated with each +assertion is logged when it fails. +""" +function get_assertions_expr(sys::AbstractSystem) + asserts = assertions(sys) + term = 0 + if is_parameter(sys, ASSERTION_LOG_VARIABLE) + for (k, v) in asserts + term += _debug_assertion(k, "Assertion $k failed:\n$v", ASSERTION_LOG_VARIABLE) + end + else + for (k, v) in asserts + term += _nan_condition(k) + end + end + return term +end diff --git a/src/discretedomain.jl b/src/discretedomain.jl index 95abe02a7a..7260237053 100644 --- a/src/discretedomain.jl +++ b/src/discretedomain.jl @@ -85,7 +85,7 @@ $(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(clock::Union{TimeDomain, InferredTimeDomain} = InferredDiscrete())` `Sample(dt::Real)` `Sample(x::Num)`, with a single argument, is shorthand for `Sample()(x)`. @@ -106,7 +106,7 @@ julia> Δ = Sample(0.01) """ struct Sample <: Operator clock::Any - Sample(clock::Union{TimeDomain, InferredTimeDomain} = InferredDiscrete) = new(clock) + Sample(clock::Union{TimeDomain, InferredTimeDomain} = InferredDiscrete()) = new(clock) end function Sample(arg::Real) @@ -190,7 +190,7 @@ struct ShiftIndex clock::Union{InferredTimeDomain, TimeDomain, IntegerSequence} steps::Int function ShiftIndex( - clock::Union{TimeDomain, InferredTimeDomain, IntegerSequence} = Inferred, steps::Int = 0) + clock::Union{TimeDomain, InferredTimeDomain, IntegerSequence} = Inferred(), steps::Int = 0) new(clock, steps) end ShiftIndex(dt::Real, steps::Int = 0) = new(Clock(dt), steps) @@ -226,37 +226,37 @@ Base.:-(k::ShiftIndex, i::Int) = k + (-i) """ input_timedomain(op::Operator) -Return the time-domain type (`Continuous` or `InferredDiscrete`) that `op` operates on. +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 + InferredDiscrete() end """ output_timedomain(op::Operator) -Return the time-domain type (`Continuous` or `InferredDiscrete`) that `op` results in. +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 + InferredDiscrete() end -input_timedomain(::Sample, _ = nothing) = Continuous +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 + InferredDiscrete() # the Hold accepts any discrete end -output_timedomain(::Hold, _ = nothing) = Continuous +output_timedomain(::Hold, _ = nothing) = ContinuousClock() sampletime(op::Sample, _ = nothing) = sampletime(op.clock) sampletime(op::ShiftIndex, _ = nothing) = sampletime(op.clock) diff --git a/src/inputoutput.jl b/src/inputoutput.jl index 9ed0984050..5f9420ff3a 100644 --- a/src/inputoutput.jl +++ b/src/inputoutput.jl @@ -160,7 +160,7 @@ has_var(ex, x) = x ∈ Set(get_variables(ex)) # Build control function """ - (f_oop, f_ip), x_sym, p, io_sys = generate_control_function( + (f_oop, f_ip), x_sym, p_sym, io_sys = generate_control_function( sys::AbstractODESystem, inputs = unbound_inputs(sys), disturbance_inputs = nothing; @@ -177,8 +177,7 @@ 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 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. -See [`add_input_disturbance`](@ref) for a higher-level interface to this functionality. +If `disturbance_inputs` is an array of variables, the generated dynamics function will preserve any state and dynamics associated with disturbance inputs, but the disturbance inputs themselves will (by default) not be included as inputs to the generated function. The use case for this is to generate dynamics for state observers that estimate the influence of unmeasured disturbances, and thus require unknown variables for the disturbance model, but without disturbance inputs since the disturbances are not available for measurement. To add an input argument corresponding to the disturbance inputs, either include the disturbance inputs among the control inputs, or set `disturbance_argument=true`, in which case an additional input argument `w` is added to the generated function `(x,u,p,t,w)->rhs`. !!! note "Un-simplified system" This function expects `sys` to be un-simplified, i.e., `structural_simplify` or `@mtkbuild` should not be called on the system before passing it into this function. `generate_control_function` calls a special version of `structural_simplify` internally. @@ -196,6 +195,7 @@ f[1](x, inputs, p, t) """ function generate_control_function(sys::AbstractODESystem, inputs = unbound_inputs(sys), disturbance_inputs = disturbances(sys); + disturbance_argument = false, implicit_dae = false, simplify = false, eval_expression = false, @@ -211,7 +211,7 @@ function generate_control_function(sys::AbstractODESystem, inputs = unbound_inpu sys, _ = io_preprocessing(sys, inputs, []; simplify, kwargs...) dvs = unknowns(sys) - ps = parameters(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 @@ -219,10 +219,11 @@ function generate_control_function(sys::AbstractODESystem, inputs = unbound_inpu # ps = [ps; disturbance_inputs] end inputs = map(x -> time_varying_as_func(value(x), sys), inputs) + disturbance_inputs = unwrap.(disturbance_inputs) eqs = [eq for eq in full_equations(sys)] eqs = map(subs_constants, eqs) - if disturbance_inputs !== nothing + 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] @@ -233,23 +234,26 @@ function generate_control_function(sys::AbstractODESystem, inputs = unbound_inpu [eq.rhs for eq in eqs] # 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) + p = reorder_parameters(sys, ps) t = get_iv(sys) # pre = has_difference ? (ex -> ex) : get_postprocess_fbody(sys) - - args = (u, inputs, p, t) + 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 - process = get_postprocess_fbody(sys) - f = build_function(rhss, args...; postprocess_fbody = process, - expression = Val{true}, wrap_code = wrap_array_vars(sys, rhss; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false), - kwargs...) + f = build_function_wrapper(sys, rhss, args...; p_start = 3 + implicit_dae, + p_end = length(p) + 2 + implicit_dae) f = eval_or_rgf.(f; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + 3 + implicit_dae, length(args) - length(p) + 1, is_split(sys))}(f...) + f = f, f + ps = setdiff(parameters(sys), inputs, disturbance_inputs) (; f, dvs, ps, io_sys = sys) end @@ -426,7 +430,7 @@ function add_input_disturbance(sys, dist::DisturbanceModel, inputs = nothing; kw augmented_sys = ODESystem(eqs, t, systems = [dsys], name = gensym(:outer)) augmented_sys = extend(augmented_sys, sys) - (f_oop, f_ip), dvs, p = generate_control_function(augmented_sys, all_inputs, + (f_oop, f_ip), dvs, p, io_sys = generate_control_function(augmented_sys, all_inputs, [d]; kwargs...) - (f_oop, f_ip), augmented_sys, dvs, p + (f_oop, f_ip), augmented_sys, dvs, p, io_sys end diff --git a/src/linearization.jl b/src/linearization.jl new file mode 100644 index 0000000000..77f4422b63 --- /dev/null +++ b/src/linearization.jl @@ -0,0 +1,777 @@ +""" + lin_fun, simplified_sys = linearization_function(sys::AbstractSystem, inputs, outputs; simplify = false, initialize = true, initialization_solver_alg = TrustRegion(), kwargs...) + +Return a function that linearizes the system `sys`. The function [`linearize`](@ref) provides a higher-level and easier to use interface. + +`lin_fun` is a function `(variables, p, t) -> (; f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u)`, i.e., it returns a NamedTuple with the Jacobians of `f,g,h` for the nonlinear `sys` (technically for `simplified_sys`) on the form + +```math +\\begin{aligned} +ẋ &= f(x, z, u) \\\\ +0 &= g(x, z, u) \\\\ +y &= h(x, z, u) +\\end{aligned} +``` + +where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. To obtain a linear statespace representation, see [`linearize`](@ref). The input argument `variables` is a vector defining the operating point, corresponding to `unknowns(simplified_sys)` and `p` is a vector corresponding to the parameters of `simplified_sys`. Note: all variables in `inputs` have been converted to parameters in `simplified_sys`. + +The `simplified_sys` has undergone [`structural_simplify`](@ref) and had any occurring input or output variables replaced with the variables provided in arguments `inputs` and `outputs`. The unknowns of this system also indicate the order of the unknowns that holds for the linearized matrices. + +# Arguments: + + - `sys`: An [`ODESystem`](@ref). This function will automatically apply simplification passes on `sys` and return the resulting `simplified_sys`. + - `inputs`: A vector of variables that indicate the inputs of the linearized input-output model. + - `outputs`: A vector of variables that indicate the outputs of the linearized input-output model. + - `simplify`: Apply simplification in tearing. + - `initialize`: If true, a check is performed to ensure that the operating point is consistent (satisfies algebraic equations). If the op is not consistent, initialization is performed. + - `initialization_solver_alg`: A NonlinearSolve algorithm to use for solving for a feasible set of state and algebraic variables that satisfies the specified operating point. + - `autodiff`: An `ADType` supported by DifferentiationInterface.jl to use for calculating the necessary jacobians. Defaults to using `AutoForwardDiff()` + - `kwargs`: Are passed on to `find_solvables!` + +See also [`linearize`](@ref) which provides a higher-level interface. +""" +function linearization_function(sys::AbstractSystem, inputs, + outputs; simplify = false, + initialize = true, + initializealg = nothing, + initialization_abstol = 1e-5, + initialization_reltol = 1e-3, + op = Dict(), + p = DiffEqBase.NullParameters(), + zero_dummy_der = false, + initialization_solver_alg = TrustRegion(), + autodiff = AutoForwardDiff(), + eval_expression = false, eval_module = @__MODULE__, + warn_initialize_determined = true, + guesses = Dict(), + warn_empty_op = true, + kwargs...) + op = Dict(op) + if isempty(op) && warn_empty_op + @warn "An empty operating point was passed to `linearization_function`. An operating point containing the variables that will be changed in `linearize` should be provided. Disable this warning by passing `warn_empty_op = false`." + end + inputs isa AbstractVector || (inputs = [inputs]) + outputs isa AbstractVector || (outputs = [outputs]) + inputs = mapreduce(vcat, inputs; init = []) do var + symbolic_type(var) == ArraySymbolic() ? collect(var) : [var] + end + outputs = mapreduce(vcat, outputs; init = []) do var + symbolic_type(var) == ArraySymbolic() ? collect(var) : [var] + end + ssys, diff_idxs, alge_idxs, input_idxs = io_preprocessing(sys, inputs, outputs; + simplify, + kwargs...) + if zero_dummy_der + dummyder = setdiff(unknowns(ssys), unknowns(sys)) + defs = Dict(x => 0.0 for x in dummyder) + @set! ssys.defaults = merge(defs, defaults(ssys)) + op = merge(defs, op) + end + sys = ssys + + if initializealg === nothing + initializealg = initialize ? OverrideInit() : NoInit() + end + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, op, (nothing, nothing), p; allow_incomplete = true, + algebraic_only = true, guesses) + u0 = state_values(prob) + + ps = parameters(sys) + h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) + + initialization_kwargs = (; + abstol = initialization_abstol, reltol = initialization_reltol, + nlsolve_alg = initialization_solver_alg) + + p = parameter_values(prob) + t0 = current_time(prob) + inputvals = [p[idx] for idx in input_idxs] + + hp_fun = let fun = h, setter = setp_oop(sys, input_idxs) + function hpf(du, input, u, p, t) + p = setter(p, input) + fun(du, u, p, t) + return du + end + end + if u0 === nothing + uf_jac = h_jac = pf_jac = nothing + T = p isa MTKParameters ? eltype(p.tunable) : eltype(p) + hp_jac = PreparedJacobian{true}( + hp_fun, zeros(T, size(outputs)), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + else + uf_fun = let fun = prob.f + function uff(du, u, p, t) + SciMLBase.UJacobianWrapper(fun, t, p)(du, u) + end + end + uf_jac = PreparedJacobian{true}( + uf_fun, similar(prob.u0), autodiff, prob.u0, DI.Constant(p), DI.Constant(t0)) + # observed function is a `GeneratedFunctionWrapper` with iip component + h_jac = PreparedJacobian{true}(h, similar(prob.u0, size(outputs)), autodiff, + prob.u0, DI.Constant(p), DI.Constant(t0)) + pf_fun = let fun = prob.f, setter = setp_oop(sys, input_idxs) + function pff(du, input, u, p, t) + p = setter(p, input) + SciMLBase.ParamJacobianWrapper(fun, t, u)(du, p) + end + end + pf_jac = PreparedJacobian{true}(pf_fun, similar(prob.u0), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + hp_jac = PreparedJacobian{true}( + hp_fun, similar(prob.u0, size(outputs)), autodiff, inputvals, + DI.Constant(prob.u0), DI.Constant(p), DI.Constant(t0)) + end + + lin_fun = LinearizationFunction( + diff_idxs, alge_idxs, input_idxs, length(unknowns(sys)), + prob, h, u0 === nothing ? nothing : similar(u0), uf_jac, h_jac, pf_jac, + hp_jac, initializealg, initialization_kwargs) + return lin_fun, sys +end + +""" + $(TYPEDEF) + +Callable struct which stores a function and its prepared `DI.jacobian`. Calling with the +appropriate arguments for DI returns the jacobian. + +# Fields + +$(TYPEDFIELDS) +""" +struct PreparedJacobian{iip, P, F, B, A} + """ + The preparation object. + """ + prep::P + """ + The function whose jacobian is calculated. + """ + f::F + """ + Buffer for in-place functions. + """ + buf::B + """ + ADType to use for differentiation. + """ + autodiff::A +end + +function PreparedJacobian{true}(f, buf, autodiff, args...) + prep = DI.prepare_jacobian(f, buf, autodiff, args...) + return PreparedJacobian{true, typeof(prep), typeof(f), typeof(buf), typeof(autodiff)}( + prep, f, buf, autodiff) +end + +function PreparedJacobian{false}(f, autodiff, args...) + prep = DI.prepare_jacobian(f, autodiff, args...) + return PreparedJacobian{true, typeof(prep), typeof(f), Nothing, typeof(autodiff)}( + prep, f, nothing) +end + +function (pj::PreparedJacobian{true})(args...) + DI.jacobian(pj.f, pj.buf, pj.prep, pj.autodiff, args...) +end + +function (pj::PreparedJacobian{false})(args...) + DI.jacobian(pj.f, pj.prep, pj.autodiff, args...) +end + +""" + $(TYPEDEF) + +A callable struct which linearizes a system. + +# Fields + +$(TYPEDFIELDS) +""" +struct LinearizationFunction{ + DI <: AbstractVector{Int}, AI <: AbstractVector{Int}, II, P <: ODEProblem, + H, C, J1, J2, J3, J4, IA <: SciMLBase.DAEInitializationAlgorithm, IK} + """ + The indexes of differential equations in the linearized system. + """ + diff_idxs::DI + """ + The indexes of algebraic equations in the linearized system. + """ + alge_idxs::AI + """ + The indexes of parameters in the linearized system which represent + input variables. + """ + input_idxs::II + """ + The number of unknowns in the linearized system. + """ + num_states::Int + """ + The `ODEProblem` of the linearized system. + """ + prob::P + """ + A function which takes `(u, p, t)` and returns the outputs of the linearized system. + """ + h::H + """ + Any required cache buffers. + """ + caches::C + """ + `PreparedJacobian` for calculating jacobian of `prob.f` w.r.t. `u` + """ + uf_jac::J1 + """ + `PreparedJacobian` for calculating jacobian of `h` w.r.t. `u` + """ + h_jac::J2 + """ + `PreparedJacobian` for calculating jacobian of `prob.f` w.r.t. `p` + """ + pf_jac::J3 + """ + `PreparedJacobian` for calculating jacobian of `h` w.r.t. `p` + """ + hp_jac::J4 + """ + The initialization algorithm to use. + """ + initializealg::IA + """ + Keyword arguments to be passed to `SciMLBase.get_initial_values`. + """ + initialize_kwargs::IK +end + +SymbolicIndexingInterface.symbolic_container(f::LinearizationFunction) = f.prob +SymbolicIndexingInterface.state_values(f::LinearizationFunction) = state_values(f.prob) +function SymbolicIndexingInterface.parameter_values(f::LinearizationFunction) + parameter_values(f.prob) +end +SymbolicIndexingInterface.current_time(f::LinearizationFunction) = current_time(f.prob) + +function Base.show(io::IO, mime::MIME"text/plain", lf::LinearizationFunction) + printstyled(io, "LinearizationFunction"; bold = true, color = :blue) + println(io, " which wraps:") + show(io, mime, lf.prob) +end + +""" + $(TYPEDSIGNATURES) + +Linearize the wrapped system at the point given by `(u, p, t)`. +""" +function (linfun::LinearizationFunction)(u, p, t) + if eltype(p) <: Pair + p = todict(p) + newps = copy(parameter_values(linfun.prob)) + for (k, v) in p + if is_parameter(linfun, k) + v = fixpoint_sub(v, p) + setp(linfun, k)(newps, v) + end + end + p = newps + end + + fun = linfun.prob.f + if u !== nothing # Handle systems without unknowns + linfun.num_states == length(u) || + error("Number of unknown variables ($(linfun.num_states)) does not match the number of input unknowns ($(length(u)))") + integ_cache = (linfun.caches,) + integ = MockIntegrator{true}(u, p, t, integ_cache, nothing) + u, p, success = SciMLBase.get_initial_values( + linfun.prob, integ, fun, linfun.initializealg, Val(true); + linfun.initialize_kwargs...) + if !success + error("Initialization algorithm $(linfun.initializealg) failed with `u = $u` and `p = $p`.") + end + fg_xz = linfun.uf_jac(u, DI.Constant(p), DI.Constant(t)) + h_xz = linfun.h_jac(u, DI.Constant(p), DI.Constant(t)) + fg_u = linfun.pf_jac([p[idx] for idx in linfun.input_idxs], + DI.Constant(u), DI.Constant(p), DI.Constant(t)) + else + linfun.num_states == 0 || + error("Number of unknown variables (0) does not match the number of input unknowns ($(length(u)))") + fg_xz = zeros(0, 0) + h_xz = fg_u = zeros(0, length(linfun.input_idxs)) + end + h_u = linfun.hp_jac([p[idx] for idx in linfun.input_idxs], + DI.Constant(u), DI.Constant(p), DI.Constant(t)) + (f_x = fg_xz[linfun.diff_idxs, linfun.diff_idxs], + f_z = fg_xz[linfun.diff_idxs, linfun.alge_idxs], + g_x = fg_xz[linfun.alge_idxs, linfun.diff_idxs], + g_z = fg_xz[linfun.alge_idxs, linfun.alge_idxs], + f_u = fg_u[linfun.diff_idxs, :], + g_u = fg_u[linfun.alge_idxs, :], + h_x = h_xz[:, linfun.diff_idxs], + h_z = h_xz[:, linfun.alge_idxs], + h_u = h_u) +end + +""" + $(TYPEDEF) + +Mock `DEIntegrator` to allow using `CheckInit` without having to create a new integrator +(and consequently depend on `OrdinaryDiffEq`). + +# Fields + +$(TYPEDFIELDS) +""" +struct MockIntegrator{iip, U, P, T, C, O} <: SciMLBase.DEIntegrator{Nothing, iip, U, T} + """ + The state vector. + """ + u::U + """ + The parameter object. + """ + p::P + """ + The current time. + """ + t::T + """ + The integrator cache. + """ + cache::C + """ + Integrator "options" for `CheckInit`. + """ + opts::O +end + +function MockIntegrator{iip}(u::U, p::P, t::T, cache::C, opts::O) where {iip, U, P, T, C, O} + return MockIntegrator{iip, U, P, T, C, O}(u, p, t, cache, opts) +end + +SymbolicIndexingInterface.state_values(integ::MockIntegrator) = integ.u +SymbolicIndexingInterface.parameter_values(integ::MockIntegrator) = integ.p +SymbolicIndexingInterface.current_time(integ::MockIntegrator) = integ.t +SciMLBase.get_tmp_cache(integ::MockIntegrator) = integ.cache + +""" + $(TYPEDEF) + +A struct representing a linearization operation to be performed. Can be symbolically +indexed to efficiently update the operating point for multiple linearizations in a loop. +The value of the independent variable can be set by mutating the `.t` field of this struct. +""" +mutable struct LinearizationProblem{F <: LinearizationFunction, T} + """ + The wrapped `LinearizationFunction` + """ + const f::F + t::T +end + +function Base.show(io::IO, mime::MIME"text/plain", prob::LinearizationProblem) + printstyled(io, "LinearizationProblem"; bold = true, color = :blue) + println(io, " at time ", prob.t, " which wraps:") + show(io, mime, prob.f.prob) +end + +""" + $(TYPEDSIGNATURES) + +Construct a `LinearizationProblem` for linearizing the system `sys` with the given +`inputs` and `outputs`. + +# Keyword arguments + +- `t`: The value of the independent variable + +All other keyword arguments are forwarded to `linearization_function`. +""" +function LinearizationProblem(sys::AbstractSystem, inputs, outputs; t = 0.0, kwargs...) + linfun, _ = linearization_function(sys, inputs, outputs; kwargs...) + return LinearizationProblem(linfun, t) +end + +SymbolicIndexingInterface.symbolic_container(p::LinearizationProblem) = p.f +SymbolicIndexingInterface.state_values(p::LinearizationProblem) = state_values(p.f) +SymbolicIndexingInterface.parameter_values(p::LinearizationProblem) = parameter_values(p.f) +SymbolicIndexingInterface.current_time(p::LinearizationProblem) = p.t + +function Base.getindex(prob::LinearizationProblem, idx) + getu(prob, idx)(prob) +end + +function Base.setindex!(prob::LinearizationProblem, val, idx) + setu(prob, idx)(prob, val) +end + +function Base.getproperty(prob::LinearizationProblem, x::Symbol) + if x == :ps + return ParameterIndexingProxy(prob) + end + return getfield(prob, x) +end + +function CommonSolve.solve(prob::LinearizationProblem; allow_input_derivatives = false) + u0 = state_values(prob) + p = parameter_values(prob) + t = current_time(prob) + linres = prob.f(u0, p, t) + f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u = linres + + nx, nu = size(f_u) + nz = size(f_z, 2) + ny = size(h_x, 1) + + D = h_u + + if isempty(g_z) + A = f_x + B = f_u + C = h_x + @assert iszero(g_x) + @assert iszero(g_z) + @assert iszero(g_u) + else + gz = lu(g_z; check = false) + issuccess(gz) || + error("g_z not invertible, this indicates that the DAE is of index > 1.") + gzgx = -(gz \ g_x) + A = [f_x f_z + gzgx*f_x gzgx*f_z] + B = [f_u + gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula + + C = [h_x h_z] + Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. + if !iszero(Bs) + if !allow_input_derivatives + der_inds = findall(vec(any(!=(0), Bs, dims = 1))) + error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(inputs(prob.f.prob.f.sys)[der_inds]). Call `linearize` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") + end + B = [B [zeros(nx, nu); Bs]] + D = [D zeros(ny, nu)] + end + end + + (; A, B, C, D) +end + +""" + (; A, B, C, D), simplified_sys = linearize_symbolic(sys::AbstractSystem, inputs, outputs; simplify = false, allow_input_derivatives = false, kwargs...) + +Similar to [`linearize`](@ref), but returns symbolic matrices `A,B,C,D` rather than numeric. While `linearize` uses ForwardDiff to perform the linearization, this function uses `Symbolics.jacobian`. + +See [`linearize`](@ref) for a description of the arguments. + +# Extended help +The named tuple returned as the first argument additionally contains the jacobians `f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u` of +```math +\\begin{aligned} +ẋ &= f(x, z, u) \\\\ +0 &= g(x, z, u) \\\\ +y &= h(x, z, u) +\\end{aligned} +``` +where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. +""" +function linearize_symbolic(sys::AbstractSystem, inputs, + outputs; simplify = false, allow_input_derivatives = false, + eval_expression = false, eval_module = @__MODULE__, + kwargs...) + sys, diff_idxs, alge_idxs, input_idxs = io_preprocessing( + sys, inputs, outputs; simplify, + kwargs...) + sts = unknowns(sys) + t = get_iv(sys) + ps = parameters(sys; initial_parameters = true) + p = reorder_parameters(sys, ps) + + fun_expr = generate_function(sys, sts, ps; expression = Val{true})[1] + fun = eval_or_rgf(fun_expr; eval_expression, eval_module) + dx = fun(sts, p, t) + + h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) + y = h(sts, p, t) + + fg_xz = Symbolics.jacobian(dx, sts) + fg_u = Symbolics.jacobian(dx, inputs) + h_xz = Symbolics.jacobian(y, sts) + h_u = Symbolics.jacobian(y, inputs) + f_x = fg_xz[diff_idxs, diff_idxs] + f_z = fg_xz[diff_idxs, alge_idxs] + g_x = fg_xz[alge_idxs, diff_idxs] + g_z = fg_xz[alge_idxs, alge_idxs] + f_u = fg_u[diff_idxs, :] + g_u = fg_u[alge_idxs, :] + h_x = h_xz[:, diff_idxs] + h_z = h_xz[:, alge_idxs] + + nx, nu = size(f_u) + nz = size(f_z, 2) + ny = size(h_x, 1) + + D = h_u + + if isempty(g_z) # ODE + A = f_x + B = f_u + C = h_x + else + gz = lu(g_z; check = false) + issuccess(gz) || + error("g_z not invertible, this indicates that the DAE is of index > 1.") + gzgx = -(gz \ g_x) + A = [f_x f_z + gzgx*f_x gzgx*f_z] + B = [f_u + gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula + + C = [h_x h_z] + Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. + if !iszero(Bs) + if !allow_input_derivatives + der_inds = findall(vec(any(!iszero, Bs, dims = 1))) + error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(ModelingToolkit.inputs(sys)[der_inds]). Call `linearize_symbolic` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") + end + B = [B [zeros(nx, nu); Bs]] + D = [D zeros(ny, nu)] + end + end + + (; A, B, C, D, f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u), sys +end + +function markio!(state, orig_inputs, inputs, outputs; check = true) + fullvars = get_fullvars(state) + inputset = Dict{Any, Bool}(i => false for i in inputs) + outputset = Dict{Any, Bool}(o => false for o in outputs) + for (i, v) in enumerate(fullvars) + if v in keys(inputset) + if v in keys(outputset) + v = setio(v, true, true) + outputset[v] = true + else + v = setio(v, true, false) + end + inputset[v] = true + fullvars[i] = v + elseif v in keys(outputset) + v = setio(v, false, true) + outputset[v] = true + fullvars[i] = v + else + if isinput(v) + push!(orig_inputs, v) + end + v = setio(v, false, false) + fullvars[i] = v + end + end + if check + ikeys = keys(filter(!last, inputset)) + if !isempty(ikeys) + error( + "Some specified inputs were not found in system. The following variables were not found ", + ikeys) + end + end + check && (all(values(outputset)) || + error( + "Some specified outputs were not found in system. The following Dict indicates the found variables ", + outputset)) + state, orig_inputs +end + +""" + (; A, B, C, D), simplified_sys = linearize(sys, inputs, outputs; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false, kwargs...) + (; A, B, C, D) = linearize(simplified_sys, lin_fun; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false) + +Linearize `sys` between `inputs` and `outputs`, both vectors of variables. Return a NamedTuple with the matrices of a linear statespace representation +on the form + +```math +\\begin{aligned} +ẋ &= Ax + Bu\\\\ +y &= Cx + Du +\\end{aligned} +``` + +The first signature automatically calls [`linearization_function`](@ref) internally, +while the second signature expects the outputs of [`linearization_function`](@ref) as input. + +`op` denotes the operating point around which to linearize. If none is provided, +the default values of `sys` are used. + +If `allow_input_derivatives = false`, an error will be thrown if input derivatives (``u̇``) appear as inputs in the linearized equations. If input derivatives are allowed, the returned `B` matrix will be of double width, corresponding to the input `[u; u̇]`. + +`zero_dummy_der` can be set to automatically set the operating point to zero for all dummy derivatives. + +See also [`linearization_function`](@ref) which provides a lower-level interface, [`linearize_symbolic`](@ref) and [`ModelingToolkit.reorder_unknowns`](@ref). + +See extended help for an example. + +The implementation and notation follows that of +["Linear Analysis Approach for Modelica Models", Allain et al. 2009](https://ep.liu.se/ecp/043/075/ecp09430097.pdf) + +# Extended help + +This example builds the following feedback interconnection and linearizes it from the input of `F` to the output of `P`. + +``` + + r ┌─────┐ ┌─────┐ ┌─────┐ +───►│ ├──────►│ │ u │ │ + │ F │ │ C ├────►│ P │ y + └─────┘ ┌►│ │ │ ├─┬─► + │ └─────┘ └─────┘ │ + │ │ + └─────────────────────┘ +``` + +```julia +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D +function plant(; name) + @variables x(t) = 1 + @variables u(t)=0 y(t)=0 + eqs = [D(x) ~ -x + u + y ~ x] + ODESystem(eqs, t; name = name) +end + +function ref_filt(; name) + @variables x(t)=0 y(t)=0 + @variables u(t)=0 [input = true] + eqs = [D(x) ~ -2 * x + u + y ~ x] + ODESystem(eqs, t, name = name) +end + +function controller(kp; name) + @variables y(t)=0 r(t)=0 u(t)=0 + @parameters kp = kp + eqs = [ + u ~ kp * (r - y), + ] + ODESystem(eqs, t; name = name) +end + +@named f = ref_filt() +@named c = controller(1) +@named p = plant() + +connections = [f.y ~ c.r # filtered reference to controller reference + c.u ~ p.u # controller output to plant input + p.y ~ c.y] + +@named cl = ODESystem(connections, t, systems = [f, c, p]) + +lsys0, ssys = linearize(cl, [f.u], [p.x]) +desired_order = [f.x, p.x] +lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) + +@assert lsys.A == [-2 0; 1 -2] +@assert lsys.B == [1; 0;;] +@assert lsys.C == [0 1] +@assert lsys.D[] == 0 + +## Symbolic linearization +lsys_sym, _ = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) + +@assert substitute(lsys_sym.A, ModelingToolkit.defaults(cl)) == lsys.A +``` +""" +function linearize(sys, lin_fun::LinearizationFunction; t = 0.0, + op = Dict(), allow_input_derivatives = false, + p = DiffEqBase.NullParameters()) + prob = LinearizationProblem(lin_fun, t) + op = anydict(op) + evaluate_varmap!(op, keys(op)) + for (k, v) in op + v === nothing && continue + if symbolic_type(v) != NotSymbolic() || is_array_of_symbolics(v) + v = getu(prob, v)(prob) + end + if is_parameter(prob, Initial(k)) + setu(prob, Initial(k))(prob, v) + else + setu(prob, k)(prob, v) + end + end + p = anydict(p) + for (k, v) in p + setu(prob, k)(prob, v) + end + return solve(prob; allow_input_derivatives) +end + +function linearize(sys, inputs, outputs; op = Dict(), t = 0.0, + allow_input_derivatives = false, + zero_dummy_der = false, + kwargs...) + lin_fun, ssys = linearization_function(sys, + inputs, + outputs; + zero_dummy_der, + op, + kwargs...) + linearize(ssys, lin_fun; op, t, allow_input_derivatives), ssys +end + +""" + (; Ã, B̃, C̃, D̃) = similarity_transform(sys, T; unitary=false) + +Perform a similarity transform `T : Tx̃ = x` on linear system represented by matrices in NamedTuple `sys` such that + +``` +Ã = T⁻¹AT +B̃ = T⁻¹ B +C̃ = CT +D̃ = D +``` + +If `unitary=true`, `T` is assumed unitary and the matrix adjoint is used instead of the inverse. +""" +function similarity_transform(sys::NamedTuple, T; unitary = false) + if unitary + A = T'sys.A * T + B = T'sys.B + else + Tf = lu(T) + A = Tf \ sys.A * T + B = Tf \ sys.B + end + C = sys.C * T + D = sys.D + (; A, B, C, D) +end + +""" + reorder_unknowns(sys::NamedTuple, old, new) + +Permute the state representation of `sys` obtained from [`linearize`](@ref) so that the state unknown is changed from `old` to `new` +Example: + +``` +lsys, ssys = linearize(pid, [reference.u, measurement.u], [ctr_output.u]) +desired_order = [int.x, der.x] # Unknowns that are present in unknowns(ssys) +lsys = ModelingToolkit.reorder_unknowns(lsys, unknowns(ssys), desired_order) +``` + +See also [`ModelingToolkit.similarity_transform`](@ref) +""" +function reorder_unknowns(sys::NamedTuple, old, new) + nx = length(old) + length(new) == nx || error("old and new must have the same length") + perm = [findfirst(isequal(n), old) for n in new] + issorted(perm) && return sys # shortcut return, no reordering + P = zeros(Int, nx, nx) + for i in 1:nx # Build permutation matrix + P[i, perm[i]] = 1 + end + similarity_transform(sys, P; unitary = true) +end diff --git a/src/parameters.jl b/src/parameters.jl index fb6b0b4d6e..91121b7cbb 100644 --- a/src/parameters.jl +++ b/src/parameters.jl @@ -26,6 +26,21 @@ function isparameter(x) 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) diff --git a/src/structural_transformation/StructuralTransformations.jl b/src/structural_transformation/StructuralTransformations.jl index 1220d517cc..4adc817ef8 100644 --- a/src/structural_transformation/StructuralTransformations.jl +++ b/src/structural_transformation/StructuralTransformations.jl @@ -4,6 +4,7 @@ using Setfield: @set!, @set using UnPack: @unpack using Symbolics: unwrap, linear_expansion, fast_substitute +import Symbolics using SymbolicUtils using SymbolicUtils.Code using SymbolicUtils.Rewriters @@ -11,7 +12,8 @@ using SymbolicUtils: maketerm, iscall using ModelingToolkit using ModelingToolkit: ODESystem, AbstractSystem, var_from_nested_derivative, Differential, - unknowns, equations, vars, Symbolic, diff2term_with_unit, value, + 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, @@ -22,7 +24,8 @@ using ModelingToolkit: ODESystem, AbstractSystem, var_from_nested_derivative, Di get_postprocess_fbody, vars!, IncrementalCycleTracker, add_edge_checked!, topological_sort, invalidate_cache!, Substitutions, get_or_construct_tearing_state, - filter_kwargs, lower_varname_with_unit, setio, SparseMatrixCLIL, + filter_kwargs, lower_varname_with_unit, + lower_shift_varname_with_unit, setio, SparseMatrixCLIL, get_fullvars, has_equations, observed, Schedule, schedule @@ -63,6 +66,7 @@ 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") diff --git a/src/structural_transformation/bipartite_tearing/modia_tearing.jl b/src/structural_transformation/bipartite_tearing/modia_tearing.jl index cef2f5f6d7..5da873afdf 100644 --- a/src/structural_transformation/bipartite_tearing/modia_tearing.jl +++ b/src/structural_transformation/bipartite_tearing/modia_tearing.jl @@ -62,6 +62,15 @@ function tear_graph_block_modia!(var_eq_matching, ict, solvable_graph, eqs, vars return nothing end +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 + function tear_graph_modia(structure::SystemStructure, isder::F = nothing, ::Type{U} = Unassigned; varfilter::F2 = v -> true, @@ -78,10 +87,7 @@ function tear_graph_modia(structure::SystemStructure, isder::F = nothing, # find them here [TODO: It would be good to have an explicit example of this.] @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)) - var_eq_matching = complete(var_eq_matching, matching_len) + 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)) diff --git a/src/structural_transformation/symbolics_tearing.jl b/src/structural_transformation/symbolics_tearing.jl index 6b6dd60725..552c6d13c3 100644 --- a/src/structural_transformation/symbolics_tearing.jl +++ b/src/structural_transformation/symbolics_tearing.jl @@ -1,3 +1,5 @@ +using OffsetArrays: Origin + # N.B. assumes `slist` and `dlist` are unique function substitution_graph(graph, slist, dlist, var_eq_matching) ns = length(slist) @@ -63,7 +65,7 @@ function eq_derivative!(ts::TearingState{ODESystem}, ieq::Int; kwargs...) sys = ts.sys eq = equations(ts)[ieq] - eq = 0 ~ ModelingToolkit.derivative(eq.rhs - eq.lhs, get_iv(sys)) + eq = 0 ~ Symbolics.derivative(eq.rhs - eq.lhs, get_iv(sys); throw_no_derivative = true) push!(equations(ts), eq) # Analyze the new equation and update the graph/solvable_graph # First, copy the previous incidence and add the derivative terms. @@ -82,10 +84,18 @@ function eq_derivative!(ts::TearingState{ODESystem}, ieq::Int; kwargs...) end function tearing_sub(expr, dict, s) - expr = Symbolics.fixpoint_sub(expr, dict) + expr = Symbolics.fixpoint_sub(expr, dict; operator = ModelingToolkit.Initial) s ? simplify(expr) : expr end +function tearing_substitute_expr(sys::AbstractSystem, expr; simplify = false) + empty_substitutions(sys) && return expr + substitutions = get_substitutions(sys) + @unpack subs = substitutions + solved = Dict(eq.lhs => eq.rhs for eq in subs) + return tearing_sub(expr, solved, simplify) +end + """ $(TYPEDSIGNATURES) @@ -227,49 +237,23 @@ function check_diff_graph(var_to_diff, fullvars) end =# -function tearing_reassemble(state::TearingState, var_eq_matching, - full_var_eq_matching = nothing; simplify = false, mm = nothing) - @unpack fullvars, sys, structure = state - @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure - extra_vars = Int[] - if full_var_eq_matching !== nothing - for v in 𝑑vertices(state.structure.graph) - eq = full_var_eq_matching[v] - eq isa Int && continue - push!(extra_vars, v) - end - end +""" +Replace derivatives of non-selected unknown variables by dummy derivatives. - neweqs = collect(equations(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. - # - # Dummy derivatives may determine that some differential variables are - # algebraic variables in disguise. The derivative of such variables are - # called dummy derivatives. - - # Step 1: - # 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. - if ModelingToolkit.has_iv(state.sys) - iv = get_iv(state.sys) - if is_only_discrete(state.structure) - D = Shift(iv, 1) - else - D = Differential(iv) - end - else - iv = D = nothing - end +`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) - dummy_sub = Dict() + for var in 1:length(fullvars) dv = var_to_diff[var] dv === nothing && continue @@ -300,315 +284,459 @@ function tearing_reassemble(state::TearingState, var_eq_matching, diff_to_var[dv] = nothing end end +end - # `SelectedState` information is no longer needed past here. State selection - # is done. All non-differentiated variables are algebraic variables, and all - # variables that appear differentiated are differential variables. +#= +There are three cases where we want to generate new variables to convert +the system into first order (semi-implicit) ODEs. - ### extract partition information - is_solvable = let solvable_graph = solvable_graph - (eq, iv) -> eq isa Int && iv isa Int && BipartiteEdge(eq, iv) in solvable_graph - end +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`. - # if var is like D(x) - isdervar = let diff_to_var = diff_to_var - var -> diff_to_var[var] !== nothing - end - var_order = let diff_to_var = diff_to_var - dv -> begin - order = 0 - while (dv′ = diff_to_var[dv]) !== nothing - order += 1 - dv = dv′ - end - order, dv - end - end +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. - #retear = BitSet() - # 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. +Shift(t, -1)(x(t)) -> x\_{t-1}(t) + +Since Shift(t, -1)(x) is not a derivative, it is directly substituted in `fullvars`. +No equation or variable is added for it. + +For ODESystems D(D(D(x))) in equations is recursively substituted as D(x) ~ x_t, D(x_t) ~ x_tt, etc. +The analogue for discrete systems, Shift(t, 1)(Shift(t,1)(Shift(t,1)(Shift(t, -3)(x(t))))) +does not actually appear. So `total_sub` in generate_system_equations` is directly +initialized with all of the lowered variables `Shift(t, -3)(x) -> x_t-3(t)`, etc. +=# +""" +Generate new derivative variables for the system. + +Effects on the system structure: +- fullvars: add the new derivative variables x_t +- neweqs: add the identity equations for the new variables, D(x) ~ x_t +- graph: update graph with the new equations and variables, and their connections +- solvable_graph: +- var_eq_matching: match D(x) to the added identity equation D(x) ~ x_t +""" +function generate_derivative_variables!( + ts::TearingState, neweqs, var_eq_matching; mm = nothing, iv = nothing, D = nothing) + @unpack fullvars, sys, structure = ts + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure eq_var_matching = invview(var_eq_matching) + diff_to_var = invview(var_to_diff) + is_discrete = is_only_discrete(structure) linear_eqs = mm === nothing ? Dict{Int, Int}() : Dict(reverse(en) for en in enumerate(mm.nzrows)) + + # For variable x, make dummy derivative x_t if the + # derivative is in the system for v in 1:length(var_to_diff) dv = var_to_diff[v] dv isa Int || continue solved = var_eq_matching[dv] isa Int solved && continue - # check if there's `D(x) = x_t` already - local v_t, dummy_eq - 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 - dummy_eq = eq - @goto FOUND_DUMMY_EQ - end + + # If there's `D(x) = x_t` already, update mappings and continue without + # adding new equations/variables + dd = find_duplicate_dd(dv, solvable_graph, diff_to_var, linear_eqs, mm) + if !isnothing(dd) + dummy_eq, v_t = dd + var_to_diff[v_t] = var_to_diff[dv] + var_eq_matching[dv] = unassigned + eq_var_matching[dummy_eq] = dv + continue end + dx = fullvars[dv] - # add `x_t` - order, lv = var_order(dv) - x_t = lower_varname_withshift(fullvars[lv], iv, order) - push!(fullvars, simplify_shifts(x_t)) - v_t = length(fullvars) - v_t_idx = add_vertex!(var_to_diff) - add_vertex!(graph, DST) - # TODO: do we care about solvable_graph? We don't use them after - # `dummy_derivative_graph`. - add_vertex!(solvable_graph, DST) - # var_eq_matching is a bit odd. - # length(var_eq_matching) == length(invview(var_eq_matching)) + 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) - @assert v_t_idx == ndsts(graph) == ndsts(solvable_graph) == length(fullvars) == - length(var_eq_matching) - # add `D(x) - x_t ~ 0` - push!(neweqs, 0 ~ x_t - dx) - add_vertex!(graph, SRC) - dummy_eq = length(neweqs) - add_edge!(graph, dummy_eq, dv) - add_edge!(graph, dummy_eq, v_t) - add_vertex!(solvable_graph, SRC) - add_edge!(solvable_graph, dummy_eq, dv) - @assert nsrcs(graph) == nsrcs(solvable_graph) == dummy_eq - @label FOUND_DUMMY_EQ - var_to_diff[v_t] = var_to_diff[dv] var_eq_matching[dv] = unassigned eq_var_matching[dummy_eq] = dv end +end + +""" +Check if there's `D(x) ~ x_t` already. +""" +function find_duplicate_dd(dv, solvable_graph, diff_to_var, linear_eqs, mm) + for eq in 𝑑neighbors(solvable_graph, dv) + mi = get(linear_eqs, eq, 0) + iszero(mi) && continue + row = @view mm[mi, :] + nzs = nonzeros(row) + rvs = SparseArrays.nonzeroinds(row) + # note that `v_t` must not be differentiated + if length(nzs) == 2 && + (abs(nzs[1]) == 1 && nzs[1] == -nzs[2]) && + (v_t = rvs[1] == dv ? rvs[2] : rvs[1]; + diff_to_var[v_t] === nothing) + @assert dv in rvs + return eq, v_t + end + end + return nothing +end + +""" +Add a dummy derivative variable x_t corresponding to symbolic variable D(x) +which has index dv in `fullvars`. Return the new index of x_t. +""" +function add_dd_variable!(s::SystemStructure, fullvars, x_t, dv) + push!(fullvars, simplify_shifts(x_t)) + v_t = length(fullvars) + v_t_idx = add_vertex!(s.var_to_diff) + add_vertex!(s.graph, DST) + # TODO: do we care about solvable_graph? We don't use them after + # `dummy_derivative_graph`. + add_vertex!(s.solvable_graph, DST) + s.var_to_diff[v_t] = s.var_to_diff[dv] + v_t +end + +""" +Add the equation D(x) - x_t ~ 0 to `neweqs`. `dv` and `v_t` are the indices +of the higher-order derivative variable and the newly-introduced dummy +derivative variable. Return the index of the new equation in `neweqs`. +""" +function add_dd_equation!(s::SystemStructure, neweqs, eq, dv, v_t) + push!(neweqs, eq) + add_vertex!(s.graph, SRC) + dummy_eq = length(neweqs) + add_edge!(s.graph, dummy_eq, dv) + add_edge!(s.graph, dummy_eq, v_t) + add_vertex!(s.solvable_graph, SRC) + add_edge!(s.solvable_graph, dummy_eq, dv) + dummy_eq +end + +""" +Solve the equations in `neweqs` to obtain the final equations of the +system. + +For each equation of `neweqs`, do one of the following: + 1. If the equation is solvable for a differentiated variable D(x), + then solve for D(x), and add D(x) ~ sol as a differential equation + of the system. + 2. If the equation is solvable for an un-differentiated variable x, + solve for x and then add x ~ sol as a solved equation. These will + become observables. + 3. If the equation is not solvable, add it as an algebraic equation. + +Solved equations are added to `total_sub`. Occurrences of differential +or solved variables on the RHS of the final equations will get substituted. +The topological sort of the equations ensures that variables are solved for +before they appear in equations. + +Reorder the equations and unknowns to be: + [diffeqs; ...] + [diffvars; ...] +such that the mass matrix is: + [I 0 + 0 0]. + +Order the new equations and variables such that the differential equations +and variables come first. Return the new equations, the solved equations, +the new orderings, and the number of solved variables and equations. +""" +function generate_system_equations!(state::TearingState, neweqs, var_eq_matching; + simplify = false, iv = nothing, D = nothing) + @unpack fullvars, sys, structure = state + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = structure + eq_var_matching = invview(var_eq_matching) + diff_to_var = invview(var_to_diff) + + total_sub = Dict() + if is_only_discrete(structure) + for (i, v) in enumerate(fullvars) + op = operation(v) + op isa Shift && (op.steps < 0) && + begin + lowered = lower_shift_varname_with_unit(v, iv) + total_sub[v] = lowered + fullvars[i] = lowered + end + end + end + + # if var is like D(x) or Shift(t, 1)(x) + isdervar = let diff_to_var = diff_to_var + var -> diff_to_var[var] !== nothing + end + + # Extract partition information + is_solvable = let solvable_graph = solvable_graph + (eq, iv) -> eq isa Int && iv isa Int && BipartiteEdge(eq, iv) in solvable_graph + end - # Will reorder equations and unknowns to be: - # [diffeqs; ...] - # [diffvars; ...] - # such that the mass matrix is: - # [I 0 - # 0 0]. - diffeq_idxs = Int[] - algeeq_idxs = Int[] diff_eqs = Equation[] - alge_eqs = Equation[] + diffeq_idxs = Int[] diff_vars = Int[] - subeqs = Equation[] - solved_equations = Int[] - solved_variables = Int[] - # Solve solvable equations + alge_eqs = Equation[] + algeeq_idxs = Int[] + solved_eqs = Equation[] + solvedeq_idxs = Int[] + solved_vars = Int[] + toporder = topological_sort(DiCMOBiGraph{false}(graph, var_eq_matching)) eqs = Iterators.reverse(toporder) - total_sub = Dict() idep = iv + + # Generate equations. + # Solvable equations of differential variables D(x) become differential equations + # Solvable equations of non-differential variables become observable equations + # Non-solvable equations become algebraic equations. for ieq in eqs iv = eq_var_matching[ieq] - if is_solvable(ieq, iv) - # We don't solve differential equations, but we will need to try to - # convert it into the mass matrix form. - # We cannot solve the differential variable like D(x) - if isdervar(iv) - isnothing(D) && - 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: $(equations(sys)[ieq])") - order, lv = var_order(iv) - dx = D(simplify_shifts(lower_varname_withshift( - fullvars[lv], idep, order - 1))) - eq = dx ~ simplify_shifts(Symbolics.fixpoint_sub( - Symbolics.symbolic_linear_solve(neweqs[ieq], - fullvars[iv]), - total_sub; operator = ModelingToolkit.Shift)) - for e in 𝑑neighbors(graph, iv) - e == ieq && continue - for v in 𝑠neighbors(graph, e) - add_edge!(graph, e, v) - end - rem_edge!(graph, e, iv) - end - push!(diff_eqs, eq) - total_sub[simplify_shifts(eq.lhs)] = eq.rhs - push!(diffeq_idxs, ieq) - push!(diff_vars, diff_to_var[iv]) - continue + eq = neweqs[ieq] + + if is_solvable(ieq, iv) && isdervar(iv) + var = fullvars[iv] + isnothing(D) && throw(UnexpectedDifferentialError(equations(sys)[ieq])) + order, lv = var_order(iv, diff_to_var) + dx = D(simplify_shifts(fullvars[lv])) + + neweq = make_differential_equation(var, dx, eq, total_sub) + for e in 𝑑neighbors(graph, iv) + e == ieq && continue + rem_edge!(graph, e, iv) + end + + total_sub[simplify_shifts(neweq.lhs)] = neweq.rhs + # Substitute unshifted variables x(k), y(k) on RHS of implicit equations + if is_only_discrete(structure) + var_to_diff[iv] === nothing && (total_sub[var] = neweq.rhs) end - eq = neweqs[ieq] + push!(diff_eqs, neweq) + push!(diffeq_idxs, ieq) + push!(diff_vars, diff_to_var[iv]) + elseif is_solvable(ieq, iv) var = fullvars[iv] - 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!" - else - rhs = -b / a - neweq = var ~ Symbolics.fixpoint_sub( - simplify ? - Symbolics.simplify(rhs) : rhs, - total_sub; operator = ModelingToolkit.Shift) - push!(subeqs, neweq) - push!(solved_equations, ieq) - push!(solved_variables, iv) + neweq = make_solved_equation(var, eq, total_sub; simplify) + !isnothing(neweq) && begin + push!(solved_eqs, neweq) + push!(solvedeq_idxs, ieq) + push!(solved_vars, iv) end else - eq = neweqs[ieq] - rhs = eq.rhs - if !(eq.lhs isa Number && eq.lhs == 0) - rhs = eq.rhs - eq.lhs - end - push!(alge_eqs, 0 ~ Symbolics.fixpoint_sub(rhs, total_sub)) + neweq = make_algebraic_equation(eq, total_sub) + push!(alge_eqs, neweq) push!(algeeq_idxs, ieq) end end - # TODO: BLT sorting + + # Generate new equations and orderings neweqs = [diff_eqs; alge_eqs] - inveqsperm = [diffeq_idxs; algeeq_idxs] - eqsperm = zeros(Int, nsrcs(graph)) - for (i, v) in enumerate(inveqsperm) - eqsperm[v] = i - end + eq_ordering = [diffeq_idxs; algeeq_idxs] diff_vars_set = BitSet(diff_vars) if length(diff_vars_set) != length(diff_vars) error("Tearing internal error: lowering DAE into semi-implicit ODE failed!") end - solved_variables_set = BitSet(solved_variables) - invvarsperm = [diff_vars; - setdiff!(setdiff(1:ndsts(graph), diff_vars_set), - solved_variables_set)] + solved_vars_set = BitSet(solved_vars) + var_ordering = [diff_vars; + setdiff!(setdiff(1:ndsts(graph), diff_vars_set), + solved_vars_set)] + + return neweqs, solved_eqs, eq_ordering, var_ordering, length(solved_vars), + length(solved_vars_set) +end + +""" +Occurs when a variable D(x) occurs in a non-differential system. +""" +struct UnexpectedDifferentialError + eq::Equation +end + +function Base.showerror(io::IO, err::UnexpectedDifferentialError) + error("Differential found in a non-differential system. Likely this is a bug in the construction of an initialization system. Please report this issue with a reproducible example. Offending equation: $(err.eq)") +end + +""" +Generate a first-order differential equation whose LHS is `dx`. + +`var` and `dx` represent the same variable, but `var` may be a higher-order differential and `dx` is always first-order. For example, if `var` is D(D(x)), then `dx` would be `D(x_t)`. Solve `eq` for `var`, substitute previously solved variables, and return the differential equation. +""" +function make_differential_equation(var, dx, eq, total_sub) + dx ~ simplify_shifts(Symbolics.fixpoint_sub( + Symbolics.symbolic_linear_solve(eq, var), + total_sub; operator = ModelingToolkit.Shift)) +end + +""" +Generate an algebraic equation. Substitute solved variables into `eq` and return the equation. +""" +function make_algebraic_equation(eq, total_sub) + rhs = eq.rhs + if !(eq.lhs isa Number && eq.lhs == 0) + rhs = eq.rhs - eq.lhs + end + 0 ~ simplify_shifts(Symbolics.fixpoint_sub(rhs, total_sub)) +end + +""" +Solve equation `eq` for `var`, substitute previously solved variables, and return the solved equation. +""" +function make_solved_equation(var, eq, total_sub; simplify = false) + residual = eq.lhs - eq.rhs + a, b, islinear = linear_expansion(residual, var) + @assert islinear + # 0 ~ a * var + b + # var ~ -b/a + if ModelingToolkit._iszero(a) + @warn "Tearing: solving $eq for $var is singular!" + return nothing + else + rhs = -b / a + return var ~ simplify_shifts(Symbolics.fixpoint_sub( + simplify ? + Symbolics.simplify(rhs) : rhs, + total_sub; operator = ModelingToolkit.Shift)) + end +end + +""" +Given the ordering returned by `generate_system_equations!`, update the +tearing state to account for the new order. Permute the variables and equations. +Eliminate the solved variables and equations from the graph and permute the +graph's vertices to account for the new variable/equation ordering. +""" +# TODO: BLT sorting +function reorder_vars!(state::TearingState, var_eq_matching, eq_ordering, + var_ordering, nsolved_eq, nsolved_var) + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = state.structure + + eqsperm = zeros(Int, nsrcs(graph)) + for (i, v) in enumerate(eq_ordering) + eqsperm[v] = i + end varsperm = zeros(Int, ndsts(graph)) - for (i, v) in enumerate(invvarsperm) + for (i, v) in enumerate(var_ordering) varsperm[v] = i end - deps = Vector{Int}[i == 1 ? Int[] : collect(1:(i - 1)) - for i in 1:length(solved_equations)] # Contract the vertices in the structure graph to make the structure match # the new reality of the system we've just created. - graph = contract_variables(graph, var_eq_matching, varsperm, eqsperm, - length(solved_variables), length(solved_variables_set)) + new_graph = contract_variables(graph, var_eq_matching, varsperm, eqsperm, + nsolved_eq, nsolved_var) - # Update system - new_var_to_diff = complete(DiffGraph(length(invvarsperm))) + 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(inveqsperm))) + 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] - var_to_diff = new_var_to_diff - eq_to_diff = new_eq_to_diff + # Update system structure + @set! state.structure.graph = complete(new_graph) + @set! state.structure.var_to_diff = new_var_to_diff + @set! state.structure.eq_to_diff = new_eq_to_diff + @set! state.fullvars = new_fullvars + state +end + +""" +Update the system equations, unknowns, and observables after simplification. +""" +function update_simplified_system!( + state::TearingState, neweqs, solved_eqs, dummy_sub, var_eq_matching, extra_unknowns; + cse_hack = true, array_hack = true) + @unpack solvable_graph, var_to_diff, eq_to_diff, graph = state.structure diff_to_var = invview(var_to_diff) - old_fullvars = fullvars - @set! state.structure.graph = complete(graph) - @set! state.structure.var_to_diff = var_to_diff - @set! state.structure.eq_to_diff = eq_to_diff - @set! state.fullvars = fullvars = fullvars[invvarsperm] 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); subeqs] + obs = [fast_substitute(observed(sys), obs_sub); solved_eqs] - # HACK: Substitute non-scalarized symbolic arrays of observed variables - # E.g. if `p[1] ~ (...)` and `p[2] ~ (...)` then substitute `p => [p[1], p[2]]` in all equations - # ideally, we want to support equations such as `p ~ [p[1], p[2]]` which will then be handled - # by the topological sorting and dependency identification pieces - obs_arr_subs = Dict() + unknowns = Any[v + for (i, v) in enumerate(state.fullvars) + if diff_to_var[i] === nothing && ispresent(i)] + unknowns = [unknowns; extra_unknowns] + @set! sys.unknowns = unknowns - for eq in obs - lhs = eq.lhs - iscall(lhs) || continue - operation(lhs) === getindex || continue - Symbolics.shape(lhs) !== Symbolics.Unknown() || continue - arg1 = arguments(lhs)[1] - haskey(obs_arr_subs, arg1) && continue - obs_arr_subs[arg1] = [arg1[i] for i in eachindex(arg1)] - end - for i in eachindex(neweqs) - neweqs[i] = fast_substitute(neweqs[i], obs_arr_subs; operator = Symbolics.Operator) - end - for i in eachindex(obs) - obs[i] = fast_substitute(obs[i], obs_arr_subs; operator = Symbolics.Operator) - end - for i in eachindex(subeqs) - subeqs[i] = fast_substitute(subeqs[i], obs_arr_subs; operator = Symbolics.Operator) - end + obs, subeqs, deps = cse_and_array_hacks( + sys, obs, solved_eqs, unknowns, neweqs; cse = cse_hack, array = array_hack) @set! sys.eqs = neweqs @set! sys.observed = obs - - unknowns = Any[v - for (i, v) in enumerate(fullvars) - if diff_to_var[i] === nothing && ispresent(i)] - if !isempty(extra_vars) - for v in extra_vars - push!(unknowns, old_fullvars[v]) - end - end - @set! sys.unknowns = unknowns @set! sys.substitutions = Substitutions(subeqs, deps) # Only makes sense for time-dependent @@ -617,11 +745,253 @@ function tearing_reassemble(state::TearingState, var_eq_matching, @set! sys.schedule = Schedule(var_eq_matching, dummy_sub) end sys = schedule(sys) +end + +""" +Give the order of the variable indexed by dv. +""" +function var_order(dv, diff_to_var) + order = 0 + while (dv′ = diff_to_var[dv]) !== nothing + order += 1 + dv = dv′ + end + order, dv +end + +""" +Main internal function for structural simplification for DAE systems and discrete systems. +Generate dummy derivative variables, new equations in terms of variables, return updated +system and tearing state. + +Terminology and Definition: + +A general DAE is in the form of `F(u'(t), u(t), p, t) == 0`. We can +characterize variables in `u(t)` into two classes: differential variables +(denoted `v(t)`) and algebraic variables (denoted `z(t)`). Differential +variables are marked as `SelectedState` and they are differentiated in the +DAE system, i.e. `v'(t)` are all the variables in `u'(t)` that actually +appear in the system. Algebraic variables are variables that are not +differential variables. +""" +function tearing_reassemble(state::TearingState, var_eq_matching, + full_var_eq_matching = nothing; simplify = false, mm = nothing, cse_hack = true, array_hack = true) + extra_vars = Int[] + if full_var_eq_matching !== nothing + for v in 𝑑vertices(state.structure.graph) + eq = full_var_eq_matching[v] + eq isa Int && continue + push!(extra_vars, v) + end + end + extra_unknowns = state.fullvars[extra_vars] + neweqs = collect(equations(state)) + dummy_sub = Dict() + + if ModelingToolkit.has_iv(state.sys) + iv = get_iv(state.sys) + if !is_only_discrete(state.structure) + D = Differential(iv) + else + D = Shift(iv, 1) + end + else + iv = D = nothing + end + + # Structural simplification + substitute_derivatives_algevars!(state, neweqs, var_eq_matching, dummy_sub; iv, D) + + generate_derivative_variables!(state, neweqs, var_eq_matching; mm, iv, D) + + neweqs, solved_eqs, eq_ordering, var_ordering, nelim_eq, nelim_var = generate_system_equations!( + state, neweqs, var_eq_matching; simplify, iv, D) + + state = reorder_vars!( + state, var_eq_matching, eq_ordering, var_ordering, nelim_eq, nelim_var) + + sys = update_simplified_system!(state, neweqs, solved_eqs, dummy_sub, var_eq_matching, + extra_unknowns; cse_hack, array_hack) + @set! state.sys = sys @set! sys.tearing_state = state return invalidate_cache!(sys) end +""" +# HACK 1 + +Since we don't support array equations, any equation of the sort `x[1:n] ~ f(...)[1:n]` +gets turned into `x[1] ~ f(...)[1], x[2] ~ f(...)[2]`. Repeatedly calling `f` gets +_very_ expensive. this hack performs a limited form of CSE specifically for this case to +avoid the unnecessary cost. This and the below hack are implemented simultaneously + +# HACK 2 + +Add equations for array observed variables. If `p[i] ~ (...)` are equations, add an +equation `p ~ [p[1], p[2], ...]` allow topsort to reorder them only add the new equation +if all `p[i]` are present and the unscalarized form is used in any equation (observed or +not) we first count the number of times the scalarized form of each observed variable +occurs in observed equations (and unknowns if it's split). +""" +function cse_and_array_hacks(sys, obs, subeqs, unknowns, neweqs; cse = true, array = true) + # HACK 1 + # mapping of rhs to temporary CSE variable + # `f(...) => tmpvar` in above example + rhs_to_tempvar = Dict() + + # HACK 2 + # map of array observed variable (unscalarized) to number of its + # scalarized terms that appear in observed equations + arr_obs_occurrences = Dict() + # to check if array variables occur in unscalarized form anywhere + all_vars = Set() + for (i, eq) in enumerate(obs) + lhs = eq.lhs + rhs = eq.rhs + vars!(all_vars, rhs) + + # HACK 1 + if cse && is_getindexed_array(rhs) + rhs_arr = arguments(rhs)[1] + iscall(rhs_arr) && operation(rhs_arr) isa Symbolics.Operator && continue + if !haskey(rhs_to_tempvar, rhs_arr) + tempvar = gensym(Symbol(lhs)) + N = length(rhs_arr) + tempvar = unwrap(Symbolics.variable( + tempvar; T = Symbolics.symtype(rhs_arr))) + tempvar = setmetadata( + tempvar, Symbolics.ArrayShapeCtx, Symbolics.shape(rhs_arr)) + tempeq = tempvar ~ rhs_arr + rhs_to_tempvar[rhs_arr] = tempvar + push!(obs, tempeq) + push!(subeqs, tempeq) + end + + # getindex_wrapper is used because `observed2graph` treats `x` and `x[i]` as different, + # so it doesn't find a dependency between this equation and `tempvar ~ rhs_arr` + # which fails the topological sort + neweq = lhs ~ getindex_wrapper( + rhs_to_tempvar[rhs_arr], Tuple(arguments(rhs)[2:end])) + obs[i] = neweq + subeqi = findfirst(isequal(eq), subeqs) + if subeqi !== nothing + subeqs[subeqi] = neweq + end + end + # end HACK 1 + + array || continue + iscall(lhs) || continue + operation(lhs) === getindex || continue + Symbolics.shape(lhs) != Symbolics.Unknown() || continue + arg1 = arguments(lhs)[1] + cnt = get(arr_obs_occurrences, arg1, 0) + arr_obs_occurrences[arg1] = cnt + 1 + continue + end + + # Also do CSE for `equations(sys)` + if cse + for (i, eq) in enumerate(neweqs) + (; lhs, rhs) = eq + is_getindexed_array(rhs) || continue + rhs_arr = arguments(rhs)[1] + if !haskey(rhs_to_tempvar, rhs_arr) + tempvar = gensym(Symbol(lhs)) + N = length(rhs_arr) + tempvar = unwrap(Symbolics.variable( + tempvar; T = Symbolics.symtype(rhs_arr))) + tempvar = setmetadata( + tempvar, Symbolics.ArrayShapeCtx, Symbolics.shape(rhs_arr)) + vars!(all_vars, rhs_arr) + tempeq = tempvar ~ rhs_arr + rhs_to_tempvar[rhs_arr] = tempvar + push!(obs, tempeq) + push!(subeqs, tempeq) + end + # don't need getindex_wrapper, but do it anyway to know that this + # hack took place + neweq = lhs ~ getindex_wrapper( + rhs_to_tempvar[rhs_arr], Tuple(arguments(rhs)[2:end])) + neweqs[i] = neweq + end + end + + # count variables in unknowns if they are scalarized forms of variables + # also present as observed. e.g. if `x[1]` is an unknown and `x[2] ~ (..)` + # is an observed equation. + for sym in unknowns + iscall(sym) || continue + operation(sym) === getindex || continue + Symbolics.shape(sym) != Symbolics.Unknown() || continue + arg1 = arguments(sym)[1] + cnt = get(arr_obs_occurrences, arg1, 0) + cnt == 0 && continue + arr_obs_occurrences[arg1] = cnt + 1 + end + for eq in neweqs + vars!(all_vars, eq.rhs) + end + + # also count unscalarized variables used in callbacks + for ev in Iterators.flatten((continuous_events(sys), discrete_events(sys))) + vars!(all_vars, ev) + end + obs_arr_eqs = Equation[] + for (arrvar, cnt) in arr_obs_occurrences + cnt == length(arrvar) || continue + arrvar in all_vars || continue + # firstindex returns 1 for multidimensional array symbolics + firstind = first(eachindex(arrvar)) + scal = [arrvar[i] for i in eachindex(arrvar)] + # respect non-1-indexed arrays + # TODO: get rid of this hack together with the above hack, then remove OffsetArrays dependency + # `change_origin` is required because `Origin(firstind)(scal)` makes codegen + # try to `create_array(OffsetArray{...}, ...)` which errors. + # `term(Origin(firstind), scal)` doesn't retain the `symtype` and `size` + # of `scal`. + rhs = scal + rhs = change_origin(firstind, rhs) + push!(obs_arr_eqs, arrvar ~ rhs) + end + append!(obs, obs_arr_eqs) + append!(subeqs, obs_arr_eqs) + + # need to re-sort subeqs + subeqs = ModelingToolkit.topsort_equations(subeqs, [eq.lhs for eq in subeqs]) + + deps = Vector{Int}[i == 1 ? Int[] : collect(1:(i - 1)) + for i in 1:length(subeqs)] + + return obs, subeqs, deps +end + +function is_getindexed_array(rhs) + (!ModelingToolkit.isvariable(rhs) || ModelingToolkit.iscalledparameter(rhs)) && + iscall(rhs) && operation(rhs) === getindex && + Symbolics.shape(rhs) != Symbolics.Unknown() +end + +# PART OF HACK 1 +getindex_wrapper(x, i) = x[i...] + +@register_symbolic getindex_wrapper(x::AbstractArray, i::Tuple{Vararg{Int}}) + +# PART OF HACK 2 +function change_origin(origin, arr) + if all(isone, Tuple(origin)) + return arr + end + return Origin(origin)(arr) +end + +@register_array_symbolic change_origin(origin::Any, arr::AbstractArray) begin + size = size(arr) + eltype = eltype(arr) + ndims = ndims(arr) +end + function tearing(state::TearingState; kwargs...) state.structure.solvable_graph === nothing && find_solvables!(state; kwargs...) complete!(state.structure) @@ -636,10 +1006,10 @@ new residual equations after tearing. End users are encouraged to call [`structu instead, which calls this function internally. """ function tearing(sys::AbstractSystem, state = TearingState(sys); mm = nothing, - simplify = false, kwargs...) + simplify = false, cse_hack = true, array_hack = true, kwargs...) var_eq_matching, full_var_eq_matching = tearing(state) invalidate_cache!(tearing_reassemble( - state, var_eq_matching, full_var_eq_matching; mm, simplify)) + state, var_eq_matching, full_var_eq_matching; mm, simplify, cse_hack, array_hack)) end """ @@ -661,7 +1031,7 @@ 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, kwargs...) + mm = nothing, cse_hack = true, array_hack = true, kwargs...) jac = let state = state (eqs, vars) -> begin symeqs = EquationsView(state)[eqs] @@ -685,5 +1055,5 @@ function dummy_derivative(sys, state = TearingState(sys); simplify = false, end var_eq_matching = dummy_derivative_graph!(state, jac; state_priority, kwargs...) - tearing_reassemble(state, var_eq_matching; simplify, mm) + tearing_reassemble(state, var_eq_matching; simplify, mm, cse_hack, array_hack) end diff --git a/src/structural_transformation/utils.jl b/src/structural_transformation/utils.jl index 8b2ac1f69c..14628f2958 100644 --- a/src/structural_transformation/utils.jl +++ b/src/structural_transformation/utils.jl @@ -58,7 +58,41 @@ end ### ### Structural check ### -function check_consistency(state::TransformationState, orig_inputs) + +""" + $(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 = 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_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 @@ -72,6 +106,7 @@ function check_consistency(state::TransformationState, orig_inputs) 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 @@ -85,22 +120,12 @@ function check_consistency(state::TransformationState, orig_inputs) error_reporting(state, bad_idxs, n_highest_vars, iseqs, orig_inputs) end - # 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 = 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_var_eq_matching) - vj > nvars && break - if eq === unassigned && !isempty(𝑑neighbors(graph, vj)) - push!(unassigned_var, fullvars[vj]) - end - 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)) @@ -110,7 +135,7 @@ function check_consistency(state::TransformationState, orig_inputs) throw(InvalidSystemException(errmsg)) end - return nothing + return true end ### @@ -424,13 +449,52 @@ end ### Misc ### -function lower_varname_withshift(var, iv, order) - order == 0 && return var - if ModelingToolkit.isoperator(var, ModelingToolkit.Shift) - op = operation(var) - return Shift(op.t, order)(var) +""" +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 - return lower_varname_with_unit(var, iv, order) +end + +""" +Rename a Shift variable with negative shift, Shift(t, k)(x(t)) to xₜ₋ₖ(t). +""" +function shift2term(var) + op = operation(var) + iv = op.t + arg = only(arguments(var)) + is_lowered = !isnothing(ModelingToolkit.getunshifted(arg)) + + backshift = is_lowered ? op.steps + ModelingToolkit.getshift(arg) : op.steps + + # Char(0x208b) = ₋ (subscripted minus) + # Char(0x208a) = ₊ (subscripted plus) + pm = backshift > 0 ? Char(0x208a) : Char(0x208b) + # subscripted number, e.g. ₁ + num = join(Char(0x2080 + d) for d in reverse!(digits(abs(backshift)))) + # Char(0x209c) = ₜ + # ds = ₜ₋₁ + ds = join([Char(0x209c), pm, num]) + + O = is_lowered ? ModelingToolkit.getunshifted(arg) : arg + oldop = operation(O) + newname = backshift != 0 ? Symbol(string(nameof(oldop)), ds) : + Symbol(string(nameof(oldop))) + + newvar = maketerm(typeof(O), Symbolics.rename(oldop, newname), + Symbolics.children(O), Symbolics.metadata(O)) + newvar = setmetadata(newvar, Symbolics.VariableSource, (:variables, newname)) + newvar = setmetadata(newvar, ModelingToolkit.VariableUnshifted, O) + newvar = setmetadata(newvar, ModelingToolkit.VariableShift, backshift) + return newvar end function isdoubleshift(var) @@ -438,9 +502,13 @@ function isdoubleshift(var) 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] @@ -456,3 +524,45 @@ function simplify_shifts(var) unwrap(var).metadata) end end + +""" +Distribute a shift applied to a whole expression or equation. +Shift(t, 1)(x + y) will become Shift(t, 1)(x) + Shift(t, 1)(y). +Only shifts variables whose independent variable is the same t that appears in the Shift (i.e. constants, time-independent parameters, etc. do not get shifted). +""" +function distribute_shift(var) + var = unwrap(var) + var isa Equation && return distribute_shift(var.lhs) ~ distribute_shift(var.rhs) + + ModelingToolkit.hasshift(var) || return var + shift = operation(var) + shift isa Shift || return var + + shift = operation(var) + expr = only(arguments(var)) + if expr isa Equation + return distribute_shift(shift(expr.lhs)) ~ distribute_shift(shift(expr.rhs)) + end + shiftexpr = _distribute_shift(expr, shift) + return simplify_shifts(shiftexpr) +end + +function _distribute_shift(expr, shift) + if iscall(expr) + op = operation(expr) + args = arguments(expr) + + if ModelingToolkit.isvariable(expr) + (length(args) == 1 && isequal(shift.t, only(args))) ? (return shift(expr)) : + (return expr) + elseif op isa Shift + return shift(expr) + else + return maketerm( + typeof(expr), operation(expr), Base.Fix2(_distribute_shift, shift).(args), + unwrap(expr).metadata) + end + else + return expr + end +end diff --git a/src/systems/abstractsystem.jl b/src/systems/abstractsystem.jl index 9aa4bfc92d..29338d0722 100644 --- a/src/systems/abstractsystem.jl +++ b/src/systems/abstractsystem.jl @@ -161,46 +161,27 @@ time-independent systems. If `split=true` (the default) was passed to [`complete object. """ function generate_custom_function(sys::AbstractSystem, exprs, dvs = unknowns(sys), - ps = parameters(sys); wrap_code = nothing, postprocess_fbody = nothing, states = nothing, - expression = Val{true}, eval_expression = false, eval_module = @__MODULE__, kwargs...) + ps = parameters(sys; initial_parameters = true); + expression = Val{true}, eval_expression = false, eval_module = @__MODULE__, + cachesyms::Tuple = (), kwargs...) if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system.") end - p = reorder_parameters(sys, unwrap.(ps)) + p = (reorder_parameters(sys, unwrap.(ps))..., cachesyms...) isscalar = !(exprs isa AbstractArray) - if wrap_code === nothing - wrap_code = isscalar ? identity : (identity, identity) - end - pre, sol_states = get_substitutions_and_solved_unknowns(sys, isscalar ? [exprs] : exprs) - if postprocess_fbody === nothing - postprocess_fbody = pre - end - if states === nothing - states = sol_states - end fnexpr = if is_time_dependent(sys) - build_function(exprs, + build_function_wrapper(sys, exprs, dvs, p..., get_iv(sys); kwargs..., - postprocess_fbody, - states, - wrap_code = wrap_code .∘ wrap_mtkparameters(sys, isscalar) .∘ - wrap_array_vars(sys, exprs; dvs) .∘ - wrap_parameter_dependencies(sys, isscalar), expression = Val{true} ) else - build_function(exprs, + build_function_wrapper(sys, exprs, dvs, p...; kwargs..., - postprocess_fbody, - states, - wrap_code = wrap_code .∘ wrap_mtkparameters(sys, isscalar) .∘ - wrap_array_vars(sys, exprs; dvs) .∘ - wrap_parameter_dependencies(sys, isscalar), expression = Val{true} ) end @@ -225,230 +206,7 @@ function wrap_assignments(isscalar, assignments; let_block = false) end end -function wrap_parameter_dependencies(sys::AbstractSystem, isscalar) - wrap_assignments(isscalar, [eq.lhs ← eq.rhs for eq in parameter_dependencies(sys)]) -end - -function wrap_array_vars( - sys::AbstractSystem, exprs; dvs = unknowns(sys), ps = parameters(sys), inputs = nothing) - isscalar = !(exprs isa AbstractArray) - array_vars = Dict{Any, AbstractArray{Int}}() - if dvs !== nothing - for (j, x) in enumerate(dvs) - if iscall(x) && operation(x) == getindex - arg = arguments(x)[1] - inds = get!(() -> Int[], array_vars, arg) - push!(inds, j) - end - end - for (k, inds) in array_vars - if inds == (inds′ = inds[1]:inds[end]) - array_vars[k] = inds′ - end - end - - uind = 1 - else - uind = 0 - end - # values are (indexes, index of buffer, size of parameter) - array_parameters = Dict{Any, Tuple{AbstractArray{Int}, Int, Tuple{Vararg{Int}}}}() - # If for some reason different elements of an array parameter are in different buffers - other_array_parameters = Dict{Any, Any}() - - hasinputs = inputs !== nothing - input_vars = Dict{Any, AbstractArray{Int}}() - if hasinputs - for (j, x) in enumerate(inputs) - if iscall(x) && operation(x) == getindex - arg = arguments(x)[1] - inds = get!(() -> Int[], input_vars, arg) - push!(inds, j) - end - end - for (k, inds) in input_vars - if inds == (inds′ = inds[1]:inds[end]) - input_vars[k] = inds′ - end - end - end - if has_index_cache(sys) - ic = get_index_cache(sys) - else - ic = nothing - end - if ps isa Tuple && eltype(ps) <: AbstractArray - ps = Iterators.flatten(ps) - end - for p in ps - p = unwrap(p) - if iscall(p) && operation(p) == getindex - p = arguments(p)[1] - end - symtype(p) <: AbstractArray && Symbolics.shape(p) != Symbolics.Unknown() || continue - scal = collect(p) - # all scalarized variables are in `ps` - any(isequal(p), ps) || all(x -> any(isequal(x), ps), scal) || continue - (haskey(array_parameters, p) || haskey(other_array_parameters, p)) && continue - - idx = parameter_index(sys, p) - idx isa Int && continue - if idx isa ParameterIndex - if idx.portion != SciMLStructures.Tunable() - continue - end - array_parameters[p] = (vec(idx.idx), 1, size(idx.idx)) - else - # idx === nothing - idxs = map(Base.Fix1(parameter_index, sys), scal) - if first(idxs) isa ParameterIndex - buffer_idxs = map(Base.Fix1(iterated_buffer_index, ic), idxs) - if allequal(buffer_idxs) - buffer_idx = first(buffer_idxs) - if first(idxs).portion == SciMLStructures.Tunable() - idxs = map(x -> x.idx, idxs) - else - idxs = map(x -> x.idx[end], idxs) - end - else - other_array_parameters[p] = scal - continue - end - else - buffer_idx = 1 - end - - sz = size(idxs) - if vec(idxs) == idxs[begin]:idxs[end] - idxs = idxs[begin]:idxs[end] - elseif vec(idxs) == idxs[begin]:-1:idxs[end] - idxs = idxs[begin]:-1:idxs[end] - end - idxs = vec(idxs) - array_parameters[p] = (idxs, buffer_idx, sz) - end - end - if isscalar - function (expr) - Func( - expr.args, - [], - Let( - vcat( - [k ← :(view($(expr.args[uind].name), $v)) for (k, v) in array_vars], - [k ← :(view($(expr.args[uind + hasinputs].name), $v)) - for (k, v) in input_vars], - [k ← :(reshape( - view($(expr.args[uind + hasinputs + buffer_idx].name), $idxs), - $sz)) - for (k, (idxs, buffer_idx, sz)) in array_parameters], - [k ← Code.MakeArray(v, symtype(k)) - for (k, v) in other_array_parameters] - ), - expr.body, - false - ) - ) - end - else - function (expr) - Func( - expr.args, - [], - Let( - vcat( - [k ← :(view($(expr.args[uind].name), $v)) for (k, v) in array_vars], - [k ← :(view($(expr.args[uind + hasinputs].name), $v)) - for (k, v) in input_vars], - [k ← :(reshape( - view($(expr.args[uind + hasinputs + buffer_idx].name), $idxs), - $sz)) - for (k, (idxs, buffer_idx, sz)) in array_parameters], - [k ← Code.MakeArray(v, symtype(k)) - for (k, v) in other_array_parameters] - ), - expr.body, - false - ) - ) - end, - function (expr) - Func( - expr.args, - [], - Let( - vcat( - [k ← :(view($(expr.args[uind + 1].name), $v)) - for (k, v) in array_vars], - [k ← :(view($(expr.args[uind + hasinputs + 1].name), $v)) - for (k, v) in input_vars], - [k ← :(reshape( - view($(expr.args[uind + hasinputs + buffer_idx + 1].name), - $idxs), - $sz)) - for (k, (idxs, buffer_idx, sz)) in array_parameters], - [k ← Code.MakeArray(v, symtype(k)) - for (k, v) in other_array_parameters] - ), - expr.body, - false - ) - ) - end - end -end - -function wrap_mtkparameters(sys::AbstractSystem, isscalar::Bool) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - offset = Int(is_time_dependent(sys)) - - if isscalar - function (expr) - p = gensym(:p) - Func( - [ - expr.args[1], - DestructuredArgs( - [arg.name for arg in expr.args[2:(end - offset)]], p), - (isone(offset) ? (expr.args[end],) : ())... - ], - [], - Let(expr.args[2:(end - offset)], expr.body, false) - ) - end - else - function (expr) - p = gensym(:p) - Func( - [ - expr.args[1], - DestructuredArgs( - [arg.name for arg in expr.args[2:(end - offset)]], p), - (isone(offset) ? (expr.args[end],) : ())... - ], - [], - Let(expr.args[2:(end - offset)], expr.body, false) - ) - end, - function (expr) - p = gensym(:p) - Func( - [ - expr.args[1], - expr.args[2], - DestructuredArgs( - [arg.name for arg in expr.args[3:(end - offset)]], p), - (isone(offset) ? (expr.args[end],) : ())... - ], - [], - Let(expr.args[3:(end - offset)], expr.body, false) - ) - end - end - else - identity - end -end +const MTKPARAMETERS_ARG = Sym{Vector{Vector}}(:___mtkparameters___) mutable struct Substitutions subs::Vector{Equation} @@ -458,6 +216,7 @@ end Substitutions(subs, deps) = Substitutions(subs, deps, nothing) Base.nameof(sys::AbstractSystem) = getfield(sys, :name) +description(sys::AbstractSystem) = has_description(sys) ? get_description(sys) : "" #Deprecated function independent_variable(sys::AbstractSystem) @@ -663,47 +422,27 @@ function SymbolicIndexingInterface.timeseries_parameter_index(sys::AbstractSyste end function SymbolicIndexingInterface.parameter_observed(sys::AbstractSystem, sym) - if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing - rawobs = build_explicit_observed_function( - sys, sym; param_only = true, return_inplace = true) - if rawobs isa Tuple - if is_time_dependent(sys) - obsfn = let oop = rawobs[1], iip = rawobs[2] - f1a(p::MTKParameters, t) = oop(p..., t) - f1a(out, p::MTKParameters, t) = iip(out, p..., t) - end - else - obsfn = let oop = rawobs[1], iip = rawobs[2] - f1b(p::MTKParameters) = oop(p...) - f1b(out, p::MTKParameters) = iip(out, p...) - end - end - else - if is_time_dependent(sys) - obsfn = let rawobs = rawobs - f2a(p::MTKParameters, t) = rawobs(p..., t) - end - else - obsfn = let rawobs = rawobs - f2b(p::MTKParameters) = rawobs(p...) - end - end - end - else - obsfn = build_explicit_observed_function(sys, sym; param_only = true) - end - return obsfn + return build_explicit_observed_function(sys, sym; param_only = true) end function has_observed_with_lhs(sys, sym) has_observed(sys) || return false if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing - return any(isequal(sym), ic.observed_syms) + return haskey(ic.observed_syms_to_timeseries, sym) else return any(isequal(sym), [eq.lhs for eq in observed(sys)]) end end +function has_parameter_dependency_with_lhs(sys, sym) + has_parameter_dependencies(sys) || return false + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + return haskey(ic.dependent_pars_to_timeseries, unwrap(sym)) + else + return any(isequal(sym), [eq.lhs for eq in parameter_dependencies(sys)]) + end +end + function _all_ts_idxs!(ts_idxs, ::NotSymbolic, sys, sym) if is_variable(sys, sym) || is_independent_variable(sys, sym) push!(ts_idxs, ContinuousTimeseries()) @@ -720,11 +459,28 @@ for traitT in [ allsyms = vars(sym; op = Symbolics.Operator) for s in allsyms s = unwrap(s) - if is_variable(sys, s) || is_independent_variable(sys, s) || - has_observed_with_lhs(sys, s) + if is_variable(sys, s) || is_independent_variable(sys, s) push!(ts_idxs, ContinuousTimeseries()) elseif is_timeseries_parameter(sys, s) push!(ts_idxs, timeseries_parameter_index(sys, s).timeseries_idx) + elseif is_time_dependent(sys) && iscall(s) && issym(operation(s)) && + is_variable(sys, operation(s)(get_iv(sys))) + # DDEs case, to detect x(t - k) + push!(ts_idxs, ContinuousTimeseries()) + else + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + if (ts = get(ic.observed_syms_to_timeseries, s, nothing)) !== nothing + union!(ts_idxs, ts) + elseif (ts = get(ic.dependent_pars_to_timeseries, s, nothing)) !== + nothing + union!(ts_idxs, ts) + end + else + # for split=false systems + if has_observed_with_lhs(sys, sym) + push!(ts_idxs, ContinuousTimeseries()) + end + end end end end @@ -756,7 +512,7 @@ function SymbolicIndexingInterface.get_all_timeseries_indexes(sys::AbstractSyste end function SymbolicIndexingInterface.parameter_symbols(sys::AbstractSystem) - return parameters(sys) + return parameters(sys; initial_parameters = true) end function SymbolicIndexingInterface.is_independent_variable(sys::AbstractSystem, sym) @@ -776,8 +532,11 @@ function SymbolicIndexingInterface.is_observed(sys::AbstractSystem, sym) !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__) + 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) @@ -785,7 +544,8 @@ function SymbolicIndexingInterface.observed( throw(ArgumentError("Symbol $sym does not exist in the system")) end sym = _sym - elseif sym isa AbstractArray && symbolic_type(sym) isa NotSymbolic && + 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 @@ -799,23 +559,8 @@ function SymbolicIndexingInterface.observed( end end end - _fn = build_explicit_observed_function(sys, sym; eval_expression, eval_module) - - if is_time_dependent(sys) - return let _fn = _fn - fn1(u, p, t) = _fn(u, p, t) - fn1(u, p::MTKParameters, t) = _fn(u, p..., t) - fn1 - end - else - return let _fn = _fn - fn2(u, p) = _fn(u, p) - fn2(u, p::MTKParameters) = _fn(u, p...) - fn2(::Nothing, p) = _fn([], p) - fn2(::Nothing, p::MTKParameters) = _fn([], p...) - fn2 - end - end + return build_explicit_observed_function( + sys, sym; eval_expression, eval_module, checkbounds, cse) end function SymbolicIndexingInterface.default_values(sys::AbstractSystem) @@ -828,6 +573,8 @@ end SymbolicIndexingInterface.is_time_dependent(::AbstractTimeDependentSystem) = true SymbolicIndexingInterface.is_time_dependent(::AbstractTimeIndependentSystem) = false +SymbolicIndexingInterface.is_markovian(sys::AbstractSystem) = !is_dde(sys) + SymbolicIndexingInterface.constant_structure(::AbstractSystem) = true function SymbolicIndexingInterface.all_variable_symbols(sys::AbstractSystem) @@ -838,13 +585,30 @@ end function SymbolicIndexingInterface.all_symbols(sys::AbstractSystem) syms = all_variable_symbols(sys) - for other in (parameter_symbols(sys), independent_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) @@ -873,22 +637,243 @@ function isscheduled(sys::AbstractSystem) end end +""" + Initial(x) + +The `Initial` operator. Used by initialization to store constant constraints on variables +of a system. See the documentation section on initialization for more information. +""" +struct Initial <: Symbolics.Operator end +Initial(x) = Initial()(x) +SymbolicUtils.promote_symtype(::Type{Initial}, T) = T +SymbolicUtils.isbinop(::Initial) = false +Base.nameof(::Initial) = :Initial +Base.show(io::IO, x::Initial) = print(io, "Initial") +input_timedomain(::Initial, _ = nothing) = ContinuousClock() +output_timedomain(::Initial, _ = nothing) = ContinuousClock() + +function (f::Initial)(x) + # wrap output if wrapped input + iw = Symbolics.iswrapped(x) + x = unwrap(x) + # non-symbolic values don't change + if symbolic_type(x) == NotSymbolic() + return x + end + # differential variables are default-toterm-ed + if iscall(x) && operation(x) isa Differential + x = default_toterm(x) + end + # don't double wrap + iscall(x) && operation(x) isa Initial && return x + result = if symbolic_type(x) == ArraySymbolic() + # create an array for `Initial(array)` + Symbolics.array_term(f, toparam(x)) + elseif iscall(x) && operation(x) == getindex + # instead of `Initial(x[1])` create `Initial(x)[1]` + # which allows parameter indexing to handle this case automatically. + arr = arguments(x)[1] + term(getindex, f(toparam(arr)), arguments(x)[2:end]...) + else + term(f, toparam(x)) + end + # the result should be a parameter + result = toparam(result) + if iw + result = wrap(result) + end + return result +end + +# This is required so `fast_substitute` works +function SymbolicUtils.maketerm(::Type{<:BasicSymbolic}, ::Initial, args, meta) + val = Initial()(args...) + if symbolic_type(val) == NotSymbolic() + return val + end + return metadata(val, meta) +end + +supports_initialization(sys::AbstractSystem) = true + +function add_initialization_parameters(sys::AbstractSystem; split = true) + @assert !has_systems(sys) || isempty(get_systems(sys)) + supports_initialization(sys) || return sys + is_initializesystem(sys) && return sys + + all_initialvars = Set{BasicSymbolic}() + # time-independent systems don't initialize unknowns + # but may initialize parameters using guesses for unknowns + eqs = equations(sys) + if !(eqs isa Vector{Equation}) + eqs = Equation[x for x in eqs if x isa Equation] + end + obs, eqs = unhack_observed(observed(sys), eqs) + for x in Iterators.flatten((unknowns(sys), Iterators.map(eq -> eq.lhs, obs))) + x = unwrap(x) + if iscall(x) && operation(x) == getindex && split + push!(all_initialvars, arguments(x)[1]) + else + push!(all_initialvars, x) + end + end + + # add derivatives of all variables for steady-state initial conditions + if is_time_dependent(sys) && !(sys isa AbstractDiscreteSystem) + D = Differential(get_iv(sys)) + union!(all_initialvars, [D(v) for v in all_initialvars if iscall(v)]) + end + for eq in parameter_dependencies(sys) + is_variable_floatingpoint(eq.lhs) || continue + push!(all_initialvars, eq.lhs) + end + all_initialvars = collect(all_initialvars) + initials = map(Initial(), all_initialvars) + @set! sys.ps = unique!([get_ps(sys); initials]) + defs = copy(get_defaults(sys)) + for ivar in initials + if symbolic_type(ivar) == ScalarSymbolic() + defs[ivar] = false + else + defs[ivar] = collect(ivar) + for scal_ivar in defs[ivar] + defs[scal_ivar] = false + end + end + end + @set! sys.defaults = defs + return sys +end + +""" +Returns true if the parameter `p` is of the form `Initial(x)`. +""" +function isinitial(p) + p = unwrap(p) + return iscall(p) && (operation(p) isa Initial || + operation(p) === getindex && isinitial(arguments(p)[1])) +end + """ $(TYPEDSIGNATURES) -Mark a system as completed. If a system is complete, the system will no longer +Mark a system as completed. A completed system is a system which is done being +defined/modified and is ready for structural analysis or other transformations. +This allows for analyses and optimizations to be performed which require knowing +the global structure of the system. + +One property to note is that if a system is complete, the system will no longer namespace its subsystems or variables, i.e. `isequal(complete(sys).v.i, v.i)`. """ -function complete(sys::AbstractSystem; split = true) +function complete( + sys::AbstractSystem; split = true, flatten = true, add_initial_parameters = true) + newunknowns = OrderedSet() + newparams = OrderedSet() + iv = has_iv(sys) ? get_iv(sys) : nothing + collect_scoped_vars!(newunknowns, newparams, sys, iv; depth = -1) + # don't update unknowns to not disturb `structural_simplify` order + # `GlobalScope`d unknowns will be picked up and added there + @set! sys.ps = unique!(vcat(get_ps(sys), collect(newparams))) + + if flatten + eqs = equations(sys) + if eqs isa AbstractArray && eltype(eqs) <: Equation + newsys = expand_connections(sys) + else + newsys = sys + end + newsys = ModelingToolkit.flatten(newsys) + if has_parent(newsys) && get_parent(sys) === nothing + @set! newsys.parent = complete(sys; split = false, flatten = false) + end + sys = newsys + if add_initial_parameters + sys = add_initialization_parameters(sys; split) + end + end if split && has_index_cache(sys) @set! sys.index_cache = IndexCache(sys) + # Ideally we'd do `get_ps` but if `flatten = false` + # we don't get all of them. So we call `parameters`. + all_ps = parameters(sys; initial_parameters = true) + if !isempty(all_ps) + # reorder parameters by portions + ps_split = reorder_parameters(sys, all_ps) + # if there are tunables, they will all be in `ps_split[1]` + # and the arrays will have been scalarized + ordered_ps = eltype(all_ps)[] + # if there are no tunables, vcat them + if !isempty(get_index_cache(sys).tunable_idx) + unflatten_parameters!(ordered_ps, ps_split[1], all_ps) + ps_split = Base.tail(ps_split) + end + # unflatten initial parameters + if !isempty(get_index_cache(sys).initials_idx) + unflatten_parameters!(ordered_ps, ps_split[1], all_ps) + ps_split = Base.tail(ps_split) + end + ordered_ps = vcat( + ordered_ps, reduce(vcat, ps_split; init = eltype(ordered_ps)[])) + @set! sys.ps = ordered_ps + end + elseif has_index_cache(sys) + @set! sys.index_cache = nothing end if isdefined(sys, :initializesystem) && get_initializesystem(sys) !== nothing @set! sys.initializesystem = complete(get_initializesystem(sys); split) end + sys = toggle_namespacing(sys, false; safe = true) isdefined(sys, :complete) ? (@set! sys.complete = true) : sys end +""" + $(TYPEDSIGNATURES) + +Return a new `sys` with namespacing enabled or disabled, depending on `value`. The +keyword argument `safe` denotes whether systems that do not support such a toggle +should error or be ignored. +""" +function toggle_namespacing(sys::AbstractSystem, value::Bool; safe = false) + if !isdefined(sys, :namespacing) + safe && return sys + throw(ArgumentError("The system must define the `namespacing` flag to toggle namespacing")) + end + @set sys.namespacing = value +end + +""" + $(TYPEDSIGNATURES) + +Given a flattened array of parameters `params` and a collection of all (unscalarized) +parameters in the system `all_ps`, unscalarize the elements in `params` and append +to `buffer` in the same order as they are present in `params`. Effectively, if +`params = [p[1], p[2], p[3], q]` then this is equivalent to `push!(buffer, p, q)`. +""" +function unflatten_parameters!(buffer, params, all_ps) + i = 1 + # go through all the tunables + while i <= length(params) + sym = params[i] + # if the sym is not a scalarized array symbolic OR it was already scalarized, + # just push it as-is + if !iscall(sym) || operation(sym) != getindex || + any(isequal(sym), all_ps) + push!(buffer, sym) + i += 1 + continue + end + # the next `length(sym)` symbols should be scalarized versions of the same + # array symbolic + if !allequal(first(arguments(x)) + for x in view(params, i:(i + length(sym) - 1))) + error("This should not be possible. Please open an issue in ModelingToolkit.jl with an MWE and stacktrace.") + end + arrsym = first(arguments(sym)) + push!(buffer, arrsym) + i += length(arrsym) + end +end + for prop in [:eqs :tag :noiseeqs @@ -897,6 +882,7 @@ for prop in [:eqs :ps :tspan :name + :description :var_to_name :ctrls :defaults @@ -911,6 +897,7 @@ for prop in [:eqs :structure :op :constraints + :constraintsystem :controls :loss :bcs @@ -930,12 +917,18 @@ for prop in [:eqs :gui_metadata :discrete_subsystems :parameter_dependencies + :assertions :solved_unknowns :split_idxs + :ignored_connections :parent + :is_dde + :tstops :index_cache :is_scalar_noise - :isscheduled] + :isscheduled + :costs + :consolidate] fname_get = Symbol(:get_, prop) fname_has = Symbol(:has_, prop) @eval begin @@ -1012,6 +1005,9 @@ 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)) @@ -1020,6 +1016,7 @@ function Base.propertynames(sys::AbstractSystem; private = false) 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) @@ -1030,13 +1027,14 @@ function Base.propertynames(sys::AbstractSystem; private = false) end end -function Base.getproperty(sys::AbstractSystem, name::Symbol; namespace = !iscomplete(sys)) +function Base.getproperty( + sys::AbstractSystem, name::Symbol; namespace = does_namespacing(sys)) if has_parent(sys) && (parent = get_parent(sys); parent !== nothing) - sys = parent + return getproperty(parent, name; namespace) end wrap(getvar(sys, name; namespace = namespace)) end -function getvar(sys::AbstractSystem, name::Symbol; namespace = !iscomplete(sys)) +function getvar(sys::AbstractSystem, name::Symbol; namespace = does_namespacing(sys)) systems = get_systems(sys) if isdefined(sys, name) Base.depwarn( @@ -1054,20 +1052,6 @@ function getvar(sys::AbstractSystem, name::Symbol; namespace = !iscomplete(sys)) avs = get_var_to_name(sys) v = get(avs, name, nothing) v === nothing || return namespace ? renamespace(sys, v) : v - else - 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 end sts = get_unknowns(sys) @@ -1076,6 +1060,14 @@ function getvar(sys::AbstractSystem, name::Symbol; namespace = !iscomplete(sys)) 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) @@ -1091,6 +1083,15 @@ function getvar(sys::AbstractSystem, name::Symbol; namespace = !iscomplete(sys)) 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 @@ -1122,9 +1123,25 @@ function _apply_to_variables(f::F, ex) where {F} 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 @@ -1138,9 +1155,25 @@ function LocalScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) 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 @@ -1156,11 +1189,34 @@ function ParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) end end +""" + $(TYPEDEF) + +Denotes that a variable belongs to a system that is at least `N + 1` levels up in the +hierarchy from the system whose equations it is involved in. It is namespaced by the +first `N` parents and not namespaced by the `N+1`th parent in the hierarchy. The scope +of the variable after this point is given by `parent`. + +In other words, this scope delays applying `ParentScope` by `N` levels, and applies +`LocalScope` in the meantime. + +# Fields + +$(TYPEDFIELDS) +""" struct DelayParentScope <: SymScope parent::SymScope N::Int end + +""" + $(TYPEDSIGNATURES) + +Apply `DelayParentScope` to `sym`, with a delay of `N` and `parent` being `LocalScope`. +""" function DelayParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}, N) + Base.depwarn( + "`DelayParentScope` is deprecated and will be removed soon", :DelayParentScope) apply_to_variables(sym) do sym if iscall(sym) && operation(sym) == getindex args = arguments(sym) @@ -1174,9 +1230,29 @@ function DelayParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}, N) end end end + +""" + $(TYPEDSIGNATURES) + +Apply `DelayParentScope` to `sym`, with a delay of `1` and `parent` being `LocalScope`. +""" DelayParentScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) = DelayParentScope(sym, 1) +""" + $(TYPEDEF) + +Denotes that a variable belongs to the root system in the hierarchy, regardless of which +equations of subsystems in the hierarchy it is involved in. Variables with this scope +are never namespaced and only added to the unknowns/parameters of a system when calling +`complete` or `structural_simplify`. +""" struct GlobalScope <: SymScope end + +""" + $(TYPEDSIGNATURES) + +Apply `GlobalScope` to `sym`. +""" function GlobalScope(sym::Union{Num, Symbolic, Symbolics.Arr{Num}}) apply_to_variables(sym) do sym if iscall(sym) && operation(sym) == getindex @@ -1262,6 +1338,14 @@ function namespace_initialization_equations( 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); @@ -1277,9 +1361,21 @@ function namespace_assignment(eq::Assignment, sys) Assignment(_lhs, _rhs) end +function is_array_of_symbolics(x) + symbolic_type(x) == ArraySymbolic() && return true + symbolic_type(x) == ScalarSymbolic() && return false + x isa AbstractArray && + any(y -> symbolic_type(y) != NotSymbolic() || is_array_of_symbolics(y), x) +end + function namespace_expr( O, sys, n = nameof(sys); ivs = independent_variables(sys)) O = unwrap(O) + # Exceptions for arrays of symbolic and Ref of a symbolic, the latter + # of which shows up in broadcasts + if symbolic_type(O) == NotSymbolic() && !(O isa AbstractArray) && !(O isa Ref) + return O + end if any(isequal(O), ivs) return O elseif iscall(O) @@ -1301,7 +1397,7 @@ function namespace_expr( end elseif isvariable(O) renamespace(n, O) - elseif O isa Array + elseif O isa AbstractArray && is_array_of_symbolics(O) let sys = sys, n = n map(o -> namespace_expr(o, sys, n; ivs), O) end @@ -1338,6 +1434,18 @@ function unknowns(sys::AbstractSystem) 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) @@ -1345,7 +1453,7 @@ Get the parameters of the system `sys` and its subsystems. See also [`@parameters`](@ref) and [`ModelingToolkit.get_ps`](@ref). """ -function parameters(sys::AbstractSystem) +function parameters(sys::AbstractSystem; initial_parameters = false) ps = get_ps(sys) if ps == SciMLBase.NullParameters() return [] @@ -1354,13 +1462,34 @@ function parameters(sys::AbstractSystem) ps = first.(ps) end systems = get_systems(sys) - unique(isempty(systems) ? ps : [ps; reduce(vcat, namespace_parameters.(systems))]) + result = unique(isempty(systems) ? ps : + [ps; reduce(vcat, namespace_parameters.(systems))]) + if !initial_parameters && !is_initializesystem(sys) + filter!(result) do sym + return !(isoperator(sym, Initial) || + iscall(sym) && operation(sym) == getindex && + isoperator(arguments(sym)[1], Initial)) + end + end + return result end function dependent_parameters(sys::AbstractSystem) return map(eq -> eq.lhs, parameter_dependencies(sys)) end +""" + parameters_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `parameters`, but ignores parameters of subsystems. +""" +function parameters_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return parameters_toplevel(parent) + end + return get_ps(sys) +end + """ $(TYPEDSIGNATURES) Get the parameter dependencies of the system `sys` and its subsystems. @@ -1382,7 +1511,144 @@ function parameter_dependencies(sys::AbstractSystem) end function full_parameters(sys::AbstractSystem) - vcat(parameters(sys), dependent_parameters(sys)) + vcat(parameters(sys; initial_parameters = true), dependent_parameters(sys)) +end + +""" +$(TYPEDSIGNATURES) + +Get the assertions for a system `sys` and its subsystems. +""" +function assertions(sys::AbstractSystem) + has_assertions(sys) || return Dict{BasicSymbolic, String}() + + asserts = get_assertions(sys) + systems = get_systems(sys) + namespaced_asserts = mapreduce( + merge!, systems; init = Dict{BasicSymbolic, String}()) do subsys + Dict{BasicSymbolic, String}(namespace_expr(k, subsys) => v + for (k, v) in assertions(subsys)) + end + return merge(asserts, namespaced_asserts) +end + +""" + $(TYPEDEF) + +Information about an `AnalysisPoint` for which the corresponding connection must be +ignored during `expand_connections`, since the analysis point has been transformed. + +# Fields + +$(TYPEDFIELDS) +""" +struct IgnoredAnalysisPoint + """ + The input variable/connector. + """ + input::Union{BasicSymbolic, AbstractSystem} + """ + The output variables/connectors. + """ + outputs::Vector{Union{BasicSymbolic, AbstractSystem}} +end + +const HierarchyVariableT = Vector{Union{BasicSymbolic, Symbol}} +const HierarchySystemT = Vector{Union{AbstractSystem, Symbol}} +""" +The type returned from `analysis_point_common_hierarchy`. +""" +const HierarchyAnalysisPointT = Vector{Union{IgnoredAnalysisPoint, Symbol}} +""" +The type returned from `as_hierarchy`. +""" +const HierarchyT = Union{HierarchyVariableT, HierarchySystemT} + +""" + $(TYPEDSIGNATURES) + +The inverse operation of `as_hierarchy`. +""" +function from_hierarchy(hierarchy::HierarchyT) + namefn = hierarchy[1] isa AbstractSystem ? nameof : getname + foldl(@view hierarchy[2:end]; init = hierarchy[1]) do sys, name + rename(sys, Symbol(name, NAMESPACE_SEPARATOR, namefn(sys))) + end +end + +""" + $(TYPEDSIGNATURES) + +Represent an ignored analysis point as a namespaced hierarchy. The hierarchy is built +using the common hierarchy of all involved systems/variables. +""" +function analysis_point_common_hierarchy(ap::IgnoredAnalysisPoint)::HierarchyAnalysisPointT + isys = as_hierarchy(ap.input) + osyss = as_hierarchy.(ap.outputs) + suffix = Symbol[] + while isys[end] == osyss[1][end] && allequal(last.(osyss)) + push!(suffix, isys[end]) + pop!(isys) + pop!.(osyss) + end + isys = from_hierarchy(isys) + osyss = from_hierarchy.(osyss) + newap = IgnoredAnalysisPoint(isys, osyss) + hierarchy = HierarchyAnalysisPointT([suffix; newap]) + reverse!(hierarchy) + return hierarchy +end + +""" + $(TYPEDSIGNATURES) + +Represent a namespaced system (or variable) `sys` as a hierarchy. Return a vector, where +the first element is the unnamespaced system (variable) and subsequent elements are +`Symbol`s representing the parents of the unnamespaced system (variable) in order from +inner to outer. +""" +function as_hierarchy(sys::Union{AbstractSystem, BasicSymbolic})::HierarchyT + namefn = sys isa AbstractSystem ? nameof : getname + # get the hierarchy + hierarchy = namespace_hierarchy(namefn(sys)) + # rename the system with unnamespaced name + newsys = rename(sys, hierarchy[end]) + # and remove it from the list + pop!(hierarchy) + # reverse it to go from inner to outer + reverse!(hierarchy) + # concatenate + T = sys isa AbstractSystem ? AbstractSystem : BasicSymbolic + return Union{Symbol, T}[newsys; hierarchy] +end + +""" + $(TYPEDSIGNATURES) + +Get the analysis points to ignore for `sys` and its subsystems. The returned value is a +`Tuple` similar in structure to the `ignored_connections` field. +""" +function ignored_connections(sys::AbstractSystem) + has_ignored_connections(sys) || + return (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[]) + + ics = get_ignored_connections(sys) + if ics === nothing + ics = (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[]) + end + # turn into hierarchies + ics = (map(analysis_point_common_hierarchy, ics[1]), + map(analysis_point_common_hierarchy, ics[2])) + systems = get_systems(sys) + # for each subsystem, get its ignored connections, add the name of the subsystem + # to the hierarchy and concatenate corresponding buffers of the result + result = mapreduce(Broadcast.BroadcastFunction(vcat), systems; init = ics) do subsys + sub_ics = ignored_connections(subsys) + (map(Base.Fix2(push!, nameof(subsys)), sub_ics[1]), + map(Base.Fix2(push!, nameof(subsys)), sub_ics[2])) + end + return (Vector{HierarchyAnalysisPointT}(result[1]), + Vector{HierarchyAnalysisPointT}(result[2])) end """ @@ -1484,6 +1750,22 @@ function equations(sys::AbstractSystem) 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) @@ -1505,6 +1787,14 @@ function initialization_equations(sys::AbstractSystem) end end +function symbolic_tstops(sys::AbstractSystem) + tstops = get_tstops(sys) + systems = get_systems(sys) + isempty(systems) && return tstops + tstops = [tstops; reduce(vcat, namespace_tstops.(get_systems(sys)); init = [])] + return tstops +end + function preface(sys::AbstractSystem) has_preface(sys) || return nothing pre = get_preface(sys) @@ -1572,11 +1862,15 @@ struct ObservedFunctionCache{S} steady_state::Bool eval_expression::Bool eval_module::Module + checkbounds::Bool + cse::Bool end function ObservedFunctionCache( - sys; steady_state = false, eval_expression = false, eval_module = @__MODULE__) - return ObservedFunctionCache(sys, Dict(), steady_state, eval_expression, eval_module) + sys; steady_state = false, eval_expression = false, + eval_module = @__MODULE__, checkbounds = true, cse = true) + return ObservedFunctionCache( + sys, Dict(), steady_state, eval_expression, eval_module, checkbounds, cse) end # This is hit because ensemble problems do a deepcopy @@ -1586,7 +1880,10 @@ function Base.deepcopy_internal(ofc::ObservedFunctionCache, stackdict::IdDict) steady_state = ofc.steady_state eval_expression = ofc.eval_expression eval_module = ofc.eval_module - newofc = ObservedFunctionCache(sys, dict, steady_state, eval_expression, 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 @@ -1595,7 +1892,7 @@ 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) + eval_module = ofc.eval_module, checkbounds = ofc.checkbounds, cse = ofc.cse) end if ofc.steady_state obs = let fn = obs @@ -1715,7 +2012,9 @@ function toexpr(sys::AbstractSystem) end eqs_name = push_eqs!(stmt, full_equations(sys), var2name) - defs_name = push_defaults!(stmt, defaults(sys), var2name) + filtered_defs = filter( + kvp -> !(iscall(kvp[1]) && operation(kvp[1]) isa Initial), defaults(sys)) + defs_name = push_defaults!(stmt, filtered_defs, var2name) obs_name = push_eqs!(stmt, obs, var2name) if sys isa ODESystem @@ -1753,9 +2052,22 @@ function get_or_construct_tearing_state(sys) state end -# TODO: what about inputs? -function n_extra_equations(sys::AbstractSystem) - isconnector(sys) && return length(get_unknowns(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) + n_variable_connect_eqs = 0 + for eq in equations(sys) + is_causal_variable_connection(eq.rhs) || continue + n_variable_connect_eqs += length(get_systems(eq.rhs)) - 1 + end + sys, (csets, _) = generate_connection_set(sys) ceqs, instream_csets = generate_connection_equations_and_stream_connections(csets) n_outer_stream_variables = 0 @@ -1778,87 +2090,98 @@ function n_extra_equations(sys::AbstractSystem) # n_toplevel_unused_flows += count(x->get_connection_type(x) === Flow && !(x in toplevel_flows), get_unknowns(m)) #end - nextras = n_outer_stream_variables + length(ceqs) + nextras = n_outer_stream_variables + length(ceqs) + n_variable_connect_eqs end -function Base.show(io::IO, mime::MIME"text/plain", sys::AbstractSystem) - eqs = equations(sys) - vars = unknowns(sys) - nvars = length(vars) - if eqs isa AbstractArray && eltype(eqs) <: Equation - neqs = count(eq -> !(eq.lhs isa Connection), eqs) - Base.printstyled(io, "Model $(nameof(sys)) with $neqs "; bold = true) - nextras = n_extra_equations(sys) - if nextras > 0 - Base.printstyled(io, "("; bold = true) - Base.printstyled(io, neqs + nextras; bold = true, color = :magenta) - Base.printstyled(io, ") "; bold = true) - end - Base.printstyled(io, "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) +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 - rows = first(displaysize(io)) ÷ 5 - limit = get(io, :limit, false) + # Print name and description + desc = description(sys) + name = nameof(sys) + printstyled(io, "Model ", name, ":"; bold) + !isempty(desc) && print(io, " ", desc) - Base.printstyled(io, "Unknowns ($nvars):"; bold = true) - nrows = min(nvars, limit ? rows : nvars) - limited = nrows < length(vars) - defs = has_defaults(sys) ? defaults(sys) : nothing + # 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 - s = vars[i] - print(io, "\n ", s) - - if defs !== nothing - val = get(defs, s, nothing) - if val !== nothing - print(io, " [defaults to ") - show( - IOContext(io, :compact => true, :limit => true, - :displaysize => (1, displaysize(io)[2])), - val) - print(io, "]") - end - description = getdescription(s) - if description !== nothing && description != "" - print(io, ": ", description) + 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 && print(io, "\n⋮") - println(io) + limited = nrows < nsubs + limited && print(io, "\n ⋮") # too many to print - 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 ") - show( - IOContext(io, :compact => true, :limit => true, - :displaysize => (1, displaysize(io)[2])), - val) - print(io, "]") + # 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 - description = getdescription(s) - if description !== nothing && description != "" - print(io, ": ", description) + if !isnothing(desc) && desc != "" + print(io, ": ", desc) end end + limited = nrows < nvars + limited && printstyled(io, "\n ⋮") # too many variables to print end - limited && print(io, "\n⋮") + + # Print parameter dependencies + npdeps = has_parameter_dependencies(sys) ? length(parameter_dependencies(sys)) : 0 + npdeps > 0 && printstyled(io, "\nParameter dependencies ($npdeps):"; bold) + npdeps > 0 && hint && print(io, " see parameter_dependencies($name)") + + # Print observed + nobs = has_observed(sys) ? length(observed(sys)) : 0 + nobs > 0 && printstyled(io, "\nObserved ($nobs):"; bold) + nobs > 0 && hint && print(io, " see observed($name)") + return nothing end @@ -2136,50 +2459,68 @@ macro mtkbuild(exprs...) expr = exprs[1] named_expr = ModelingToolkit.named_expr(expr) name = named_expr.args[1] - kwargs = if length(exprs) > 1 - NamedTuple{Tuple(ex.args[1] for ex in Base.tail(exprs))}(Tuple(ex.args[2] - for ex in Base.tail(exprs))) + kwargs = Base.tail(exprs) + kwargs = map(kwargs) do ex + @assert ex.head == :(=) + Expr(:kw, ex.args[1], ex.args[2]) + end + if isempty(kwargs) + kwargs = () else - (;) + kwargs = (Expr(:parameters, kwargs...),) end + call_expr = Expr(:call, structural_simplify, kwargs..., name) esc(quote $named_expr - $name = $structural_simplify($name; $(kwargs)...) + $name = $call_expr end) end """ -$(SIGNATURES) + debug_system(sys::AbstractSystem; functions = [log, sqrt, (^), /, inv, asin, acos], error_nonfinite = true) -Replace functions with singularities with a function that errors with symbolic -information. E.g. +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(sys); - -julia> sys = complete(sys); +julia> sys = debug_system(complete(sys)) -julia> prob = ODEProblem(sys, [], (0, 1.0)); +julia> prob = ODEProblem(sys, [0.0, 2.0], (0.0, 1.0)) -julia> du = zero(prob.u0); - -julia> prob.f(du, prob.u0, prob.p, 0.0) -ERROR: DomainError with (-1.0,): -log errors with input(s): -cos(Q(t)) => -1.0 -Stacktrace: - [1] (::ModelingToolkit.LoggedFun{typeof(log)})(args::Float64) - ... +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) +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 only works on systems with no sub-systems!") + error("debug_system(sys) only works on systems with no sub-systems! Consider flattening it with flatten(sys) or structural_simplify(sys) first.") end if has_eqs(sys) - @set! sys.eqs = debug_sub.(equations(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)) + @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 @@ -2211,557 +2552,6 @@ function io_preprocessing(sys::AbstractSystem, inputs, sys, diff_idxs, alge_idxs, input_idxs end -""" - lin_fun, simplified_sys = linearization_function(sys::AbstractSystem, inputs, outputs; simplify = false, initialize = true, initialization_solver_alg = TrustRegion(), kwargs...) - -Return a function that linearizes the system `sys`. The function [`linearize`](@ref) provides a higher-level and easier to use interface. - -`lin_fun` is a function `(variables, p, t) -> (; f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u)`, i.e., it returns a NamedTuple with the Jacobians of `f,g,h` for the nonlinear `sys` (technically for `simplified_sys`) on the form - -```math -\\begin{aligned} -ẋ &= f(x, z, u) \\\\ -0 &= g(x, z, u) \\\\ -y &= h(x, z, u) -\\end{aligned} -``` - -where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. To obtain a linear statespace representation, see [`linearize`](@ref). The input argument `variables` is a vector defining the operating point, corresponding to `unknowns(simplified_sys)` and `p` is a vector corresponding to the parameters of `simplified_sys`. Note: all variables in `inputs` have been converted to parameters in `simplified_sys`. - -The `simplified_sys` has undergone [`structural_simplify`](@ref) and had any occurring input or output variables replaced with the variables provided in arguments `inputs` and `outputs`. The unknowns of this system also indicate the order of the unknowns that holds for the linearized matrices. - -# Arguments: - - - `sys`: An [`ODESystem`](@ref). This function will automatically apply simplification passes on `sys` and return the resulting `simplified_sys`. - - `inputs`: A vector of variables that indicate the inputs of the linearized input-output model. - - `outputs`: A vector of variables that indicate the outputs of the linearized input-output model. - - `simplify`: Apply simplification in tearing. - - `initialize`: If true, a check is performed to ensure that the operating point is consistent (satisfies algebraic equations). If the op is not consistent, initialization is performed. - - `initialization_solver_alg`: A NonlinearSolve algorithm to use for solving for a feasible set of state and algebraic variables that satisfies the specified operating point. - - `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, - op = Dict(), - p = DiffEqBase.NullParameters(), - zero_dummy_der = false, - initialization_solver_alg = TrustRegion(), - eval_expression = false, eval_module = @__MODULE__, - warn_initialize_determined = true, - kwargs...) - op = Dict(op) - inputs isa AbstractVector || (inputs = [inputs]) - outputs isa AbstractVector || (outputs = [outputs]) - ssys, diff_idxs, alge_idxs, input_idxs = io_preprocessing(sys, inputs, outputs; - simplify, - kwargs...) - if zero_dummy_der - dummyder = setdiff(unknowns(ssys), unknowns(sys)) - defs = Dict(x => 0.0 for x in dummyder) - @set! ssys.defaults = merge(defs, defaults(ssys)) - op = merge(defs, op) - end - sys = ssys - u0map = Dict(k => v for (k, v) in op if is_variable(ssys, k)) - initsys = structural_simplify( - generate_initializesystem( - sys, u0map = u0map, guesses = guesses(sys), algebraic_only = true), - fully_determined = false) - - # HACK: some unknowns may not be involved in any initialization equations, and are - # thus removed from the system during `structural_simplify`. - # This causes `getu(initsys, unknowns(sys))` to fail, so we add them back as parameters - # for now. - missing_unknowns = setdiff(unknowns(sys), all_symbols(initsys)) - if !isempty(missing_unknowns) - if warn_initialize_determined - @warn "Initialization system is underdetermined. No equations for $(missing_unknowns). Initialization will default to using least squares. To suppress this warning pass warn_initialize_determined = false." - end - new_parameters = [parameters(initsys); missing_unknowns] - @set! initsys.ps = new_parameters - initsys = complete(initsys) - end - - if p isa SciMLBase.NullParameters - p = Dict() - else - p = todict(p) - end - x0 = merge(defaults_and_guesses(sys), op) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - sys_ps = MTKParameters(sys, p, x0) - else - sys_ps = varmap_to_vars(p, parameters(sys); defaults = x0) - end - p[get_iv(sys)] = NaN - if has_index_cache(initsys) && get_index_cache(initsys) !== nothing - oldps = MTKParameters(initsys, p, merge(guesses(sys), defaults(sys), op)) - initsys_ps = parameters(initsys) - p_getter = build_explicit_observed_function( - sys, initsys_ps; eval_expression, eval_module) - - u_getter = isempty(unknowns(initsys)) ? (_...) -> nothing : - build_explicit_observed_function( - sys, unknowns(initsys); eval_expression, eval_module) - get_initprob_u_p = let p_getter = p_getter, - p_setter! = setp(initsys, initsys_ps), - u_getter = u_getter - - function (u, p, t) - p_setter!(oldps, p_getter(u, p..., t)) - newu = u_getter(u, p..., t) - return newu, oldps - end - end - else - get_initprob_u_p = let p_getter = getu(sys, parameters(initsys)), - u_getter = build_explicit_observed_function( - sys, unknowns(initsys); eval_expression, eval_module) - - function (u, p, t) - state = ProblemState(; u, p, t) - return u_getter(state), p_getter(state) - end - end - end - initfn = NonlinearFunction(initsys; eval_expression, eval_module) - initprobmap = build_explicit_observed_function( - initsys, unknowns(sys); eval_expression, eval_module) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - initprobmap = let inner = initprobmap - fn(u, p::MTKParameters) = inner(u, p...) - fn(u, p) = inner(u, p) - fn - end - end - ps = parameters(sys) - h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) - lin_fun = let diff_idxs = diff_idxs, - alge_idxs = alge_idxs, - input_idxs = input_idxs, - sts = unknowns(sys), - get_initprob_u_p = get_initprob_u_p, - fun = ODEFunction{true, SciMLBase.FullSpecialize}( - sys, unknowns(sys), ps; eval_expression, eval_module), - initfn = initfn, - initprobmap = initprobmap, - h = h, - chunk = ForwardDiff.Chunk(input_idxs), - sys_ps = sys_ps, - initialize = initialize, - initialization_solver_alg = initialization_solver_alg, - sys = sys - - function (u, p, t) - if !isa(p, MTKParameters) - p = todict(p) - newps = deepcopy(sys_ps) - for (k, v) in p - if is_parameter(sys, k) - setp(sys, k)(newps, v) - end - end - p = newps - end - - if u !== nothing # Handle systems without unknowns - length(sts) == length(u) || - error("Number of unknown variables ($(length(sts))) does not match the number of input unknowns ($(length(u)))") - if initialize && !isempty(alge_idxs) # This is expensive and can be omitted if the user knows that the system is already initialized - residual = fun(u, p, t) - if norm(residual[alge_idxs]) > √(eps(eltype(residual))) - initu0, initp = get_initprob_u_p(u, p, t) - initprob = NonlinearLeastSquaresProblem(initfn, initu0, initp) - nlsol = solve(initprob, initialization_solver_alg) - u = initprobmap(state_values(nlsol), parameter_values(nlsol)) - end - end - uf = SciMLBase.UJacobianWrapper(fun, t, p) - fg_xz = ForwardDiff.jacobian(uf, u) - h_xz = ForwardDiff.jacobian( - let p = p, t = t - xz -> p isa MTKParameters ? h(xz, p..., t) : h(xz, p, t) - end, u) - pf = SciMLBase.ParamJacobianWrapper(fun, t, u) - fg_u = jacobian_wrt_vars(pf, p, input_idxs, chunk) - else - length(sts) == 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(inputs)) - end - hp = let u = u, t = t - _hp(p) = h(u, p, t) - _hp(p::MTKParameters) = h(u, p..., t) - _hp - end - h_u = jacobian_wrt_vars(hp, p, input_idxs, chunk) - (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], - h_u = h_u) - end - end - return lin_fun, sys -end - -""" - (; A, B, C, D), simplified_sys = linearize_symbolic(sys::AbstractSystem, inputs, outputs; simplify = false, allow_input_derivatives = false, kwargs...) - -Similar to [`linearize`](@ref), but returns symbolic matrices `A,B,C,D` rather than numeric. While `linearize` uses ForwardDiff to perform the linearization, this function uses `Symbolics.jacobian`. - -See [`linearize`](@ref) for a description of the arguments. - -# Extended help -The named tuple returned as the first argument additionally contains the jacobians `f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u` of -```math -\\begin{aligned} -ẋ &= f(x, z, u) \\\\ -0 &= g(x, z, u) \\\\ -y &= h(x, z, u) -\\end{aligned} -``` -where `x` are differential unknown variables, `z` algebraic variables, `u` inputs and `y` outputs. -""" -function linearize_symbolic(sys::AbstractSystem, inputs, - outputs; simplify = false, allow_input_derivatives = false, - eval_expression = false, eval_module = @__MODULE__, - kwargs...) - sys, diff_idxs, alge_idxs, input_idxs = io_preprocessing( - sys, inputs, outputs; simplify, - kwargs...) - sts = unknowns(sys) - t = get_iv(sys) - ps = parameters(sys) - p = reorder_parameters(sys, ps) - - fun_expr = generate_function(sys, sts, ps; expression = Val{true})[1] - fun = eval_or_rgf(fun_expr; eval_expression, eval_module) - dx = fun(sts, p..., t) - - h = build_explicit_observed_function(sys, outputs; eval_expression, eval_module) - y = h(sts, p..., t) - - fg_xz = Symbolics.jacobian(dx, sts) - fg_u = Symbolics.jacobian(dx, inputs) - h_xz = Symbolics.jacobian(y, sts) - h_u = Symbolics.jacobian(y, inputs) - f_x = fg_xz[diff_idxs, diff_idxs] - f_z = fg_xz[diff_idxs, alge_idxs] - g_x = fg_xz[alge_idxs, diff_idxs] - g_z = fg_xz[alge_idxs, alge_idxs] - f_u = fg_u[diff_idxs, :] - g_u = fg_u[alge_idxs, :] - h_x = h_xz[:, diff_idxs] - h_z = h_xz[:, alge_idxs] - - nx, nu = size(f_u) - nz = size(f_z, 2) - ny = size(h_x, 1) - - D = h_u - - if isempty(g_z) # ODE - A = f_x - B = f_u - C = h_x - else - gz = lu(g_z; check = false) - issuccess(gz) || - error("g_z not invertible, this indicates that the DAE is of index > 1.") - gzgx = -(gz \ g_x) - A = [f_x f_z - gzgx*f_x gzgx*f_z] - B = [f_u - gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula - - C = [h_x h_z] - Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. - if !iszero(Bs) - if !allow_input_derivatives - der_inds = findall(vec(any(!iszero, Bs, dims = 1))) - @show typeof(der_inds) - error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(ModelingToolkit.inputs(sys)[der_inds]). Call `linearize_symbolic` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") - end - B = [B [zeros(nx, nu); Bs]] - D = [D zeros(ny, nu)] - end - end - - (; A, B, C, D, f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u), sys -end - -function markio!(state, orig_inputs, inputs, outputs; check = true) - fullvars = get_fullvars(state) - inputset = Dict{Any, Bool}(i => false for i in inputs) - outputset = Dict{Any, Bool}(o => false for o in outputs) - for (i, v) in enumerate(fullvars) - if v in keys(inputset) - if v in keys(outputset) - v = setio(v, true, true) - outputset[v] = true - else - v = setio(v, true, false) - end - inputset[v] = true - fullvars[i] = v - elseif v in keys(outputset) - v = setio(v, false, true) - outputset[v] = true - fullvars[i] = v - else - if isinput(v) - push!(orig_inputs, v) - end - v = setio(v, false, false) - fullvars[i] = v - end - end - if check - ikeys = keys(filter(!last, inputset)) - if !isempty(ikeys) - error( - "Some specified inputs were not found in system. The following variables were not found ", - ikeys) - end - end - check && (all(values(outputset)) || - error( - "Some specified outputs were not found in system. The following Dict indicates the found variables ", - outputset)) - state, orig_inputs -end - -""" - (; A, B, C, D), simplified_sys = linearize(sys, inputs, outputs; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false, kwargs...) - (; A, B, C, D) = linearize(simplified_sys, lin_fun; t=0.0, op = Dict(), allow_input_derivatives = false, zero_dummy_der=false) - -Linearize `sys` between `inputs` and `outputs`, both vectors of variables. Return a NamedTuple with the matrices of a linear statespace representation -on the form - -```math -\\begin{aligned} -ẋ &= Ax + Bu\\\\ -y &= Cx + Du -\\end{aligned} -``` - -The first signature automatically calls [`linearization_function`](@ref) internally, -while the second signature expects the outputs of [`linearization_function`](@ref) as input. - -`op` denotes the operating point around which to linearize. If none is provided, -the default values of `sys` are used. - -If `allow_input_derivatives = false`, an error will be thrown if input derivatives (``u̇``) appear as inputs in the linearized equations. If input derivatives are allowed, the returned `B` matrix will be of double width, corresponding to the input `[u; u̇]`. - -`zero_dummy_der` can be set to automatically set the operating point to zero for all dummy derivatives. - -See also [`linearization_function`](@ref) which provides a lower-level interface, [`linearize_symbolic`](@ref) and [`ModelingToolkit.reorder_unknowns`](@ref). - -See extended help for an example. - -The implementation and notation follows that of -["Linear Analysis Approach for Modelica Models", Allain et al. 2009](https://ep.liu.se/ecp/043/075/ecp09430097.pdf) - -# Extended help - -This example builds the following feedback interconnection and linearizes it from the input of `F` to the output of `P`. - -``` - - r ┌─────┐ ┌─────┐ ┌─────┐ -───►│ ├──────►│ │ u │ │ - │ F │ │ C ├────►│ P │ y - └─────┘ ┌►│ │ │ ├─┬─► - │ └─────┘ └─────┘ │ - │ │ - └─────────────────────┘ -``` - -```julia -using ModelingToolkit -using ModelingToolkit: t_nounits as t, D_nounits as D -function plant(; name) - @variables x(t) = 1 - @variables u(t)=0 y(t)=0 - eqs = [D(x) ~ -x + u - y ~ x] - ODESystem(eqs, t; name = name) -end - -function ref_filt(; name) - @variables x(t)=0 y(t)=0 - @variables u(t)=0 [input = true] - eqs = [D(x) ~ -2 * x + u - y ~ x] - ODESystem(eqs, t, name = name) -end - -function controller(kp; name) - @variables y(t)=0 r(t)=0 u(t)=0 - @parameters kp = kp - eqs = [ - u ~ kp * (r - y), - ] - ODESystem(eqs, t; name = name) -end - -@named f = ref_filt() -@named c = controller(1) -@named p = plant() - -connections = [f.y ~ c.r # filtered reference to controller reference - c.u ~ p.u # controller output to plant input - p.y ~ c.y] - -@named cl = ODESystem(connections, t, systems = [f, c, p]) - -lsys0, ssys = linearize(cl, [f.u], [p.x]) -desired_order = [f.x, p.x] -lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) - -@assert lsys.A == [-2 0; 1 -2] -@assert lsys.B == [1; 0;;] -@assert lsys.C == [0 1] -@assert lsys.D[] == 0 - -## Symbolic linearization -lsys_sym, _ = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) - -@assert substitute(lsys_sym.A, ModelingToolkit.defaults(cl)) == lsys.A -``` -""" -function linearize(sys, lin_fun; t = 0.0, op = Dict(), allow_input_derivatives = false, - p = DiffEqBase.NullParameters()) - x0 = merge(defaults(sys), Dict(missing_variable_defaults(sys)), op) - u0, defs = get_u0(sys, x0, p) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - if p isa SciMLBase.NullParameters - p = op - elseif p isa Dict - p = merge(p, op) - elseif p isa Vector && eltype(p) <: Pair - p = merge(Dict(p), op) - elseif p isa Vector - p = merge(Dict(parameters(sys) .=> p), op) - end - end - linres = lin_fun(u0, p, t) - f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u = linres - - nx, nu = size(f_u) - nz = size(f_z, 2) - ny = size(h_x, 1) - - D = h_u - - if isempty(g_z) - A = f_x - B = f_u - C = h_x - @assert iszero(g_x) - @assert iszero(g_z) - @assert iszero(g_u) - else - gz = lu(g_z; check = false) - issuccess(gz) || - error("g_z not invertible, this indicates that the DAE is of index > 1.") - gzgx = -(gz \ g_x) - A = [f_x f_z - gzgx*f_x gzgx*f_z] - B = [f_u - gzgx * f_u] # The cited paper has zeros in the bottom block, see derivation in https://github.com/SciML/ModelingToolkit.jl/pull/1691 for the correct formula - - C = [h_x h_z] - Bs = -(gz \ g_u) # This equation differ from the cited paper, the paper is likely wrong since their equaiton leads to a dimension mismatch. - if !iszero(Bs) - if !allow_input_derivatives - der_inds = findall(vec(any(!=(0), Bs, dims = 1))) - error("Input derivatives appeared in expressions (-g_z\\g_u != 0), the following inputs appeared differentiated: $(inputs(sys)[der_inds]). Call `linearize` with keyword argument `allow_input_derivatives = true` to allow this and have the returned `B` matrix be of double width ($(2nu)), where the last $nu inputs are the derivatives of the first $nu inputs.") - end - B = [B [zeros(nx, nu); Bs]] - D = [D zeros(ny, nu)] - end - end - - (; A, B, C, D) -end - -function linearize(sys, inputs, outputs; op = Dict(), t = 0.0, - allow_input_derivatives = false, - zero_dummy_der = false, - kwargs...) - lin_fun, ssys = linearization_function(sys, - inputs, - outputs; - zero_dummy_der, - op, - kwargs...) - linearize(ssys, lin_fun; op, t, allow_input_derivatives), ssys -end - -""" - (; Ã, B̃, C̃, D̃) = similarity_transform(sys, T; unitary=false) - -Perform a similarity transform `T : Tx̃ = x` on linear system represented by matrices in NamedTuple `sys` such that - -``` -Ã = T⁻¹AT -B̃ = T⁻¹ B -C̃ = CT -D̃ = D -``` - -If `unitary=true`, `T` is assumed unitary and the matrix adjoint is used instead of the inverse. -""" -function similarity_transform(sys::NamedTuple, T; unitary = false) - if unitary - A = T'sys.A * T - B = T'sys.B - else - Tf = lu(T) - A = Tf \ sys.A * T - B = Tf \ sys.B - end - C = sys.C * T - D = sys.D - (; A, B, C, D) -end - -""" - reorder_unknowns(sys::NamedTuple, old, new) - -Permute the state representation of `sys` obtained from [`linearize`](@ref) so that the state unknown is changed from `old` to `new` -Example: - -``` -lsys, ssys = linearize(pid, [reference.u, measurement.u], [ctr_output.u]) -desired_order = [int.x, der.x] # Unknowns that are present in unknowns(ssys) -lsys = ModelingToolkit.reorder_unknowns(lsys, unknowns(ssys), desired_order) -``` - -See also [`ModelingToolkit.similarity_transform`](@ref) -""" -function reorder_unknowns(sys::NamedTuple, old, new) - nx = length(old) - length(new) == nx || error("old and new must have the same length") - perm = [findfirst(isequal(n), old) for n in new] - issorted(perm) && return sys # shortcut return, no reordering - P = zeros(Int, nx, nx) - for i in 1:nx # Build permutation matrix - P[i, perm[i]] = 1 - end - similarity_transform(sys, P; unitary = true) -end - @latexrecipe function f(sys::AbstractSystem) return latexify(equations(sys)) end @@ -2804,12 +2594,23 @@ function Base.showerror(io::IO, e::HybridSystemNotSupportedException) print(io, "HybridSystemNotSupportedException: ", e.msg) end -function AbstractTrees.children(sys::ModelingToolkit.AbstractSystem) +function AbstractTrees.children(sys::AbstractSystem) ModelingToolkit.get_systems(sys) end -function AbstractTrees.printnode(io::IO, sys::ModelingToolkit.AbstractSystem) - print(io, nameof(sys)) +function AbstractTrees.printnode( + io::IO, sys::AbstractSystem; describe = false, bold = false) + printstyled(io, nameof(sys); bold) + describe && !isempty(description(sys)) && print(io, ": ", description(sys)) end +""" + hierarchy(sys::AbstractSystem; describe = false, bold = describe, kwargs...) + +Print a tree of a system's hierarchy of subsystems. +""" +function hierarchy(sys::AbstractSystem; describe = false, bold = describe, kwargs...) + print_tree(sys; printnode_kw = (describe = describe, bold = bold), kwargs...) +end + function Base.IteratorEltype(::Type{<:TreeIterator{ModelingToolkit.AbstractSystem}}) Base.HasEltype() end @@ -2817,6 +2618,15 @@ function Base.eltype(::Type{<:TreeIterator{ModelingToolkit.AbstractSystem}}) ModelingToolkit.AbstractSystem end +function check_array_equations_unknowns(eqs, dvs) + if any(eq -> eq isa Equation && Symbolics.isarraysymbolic(eq.lhs), eqs) + throw(ArgumentError("The system has array equations. Call `structural_simplify` to handle such equations or scalarize them manually.")) + end + if any(x -> Symbolics.isarraysymbolic(x), dvs) + throw(ArgumentError("The system has array unknowns. Call `structural_simplify` to handle this or scalarize them manually.")) + end +end + function check_eqs_u0(eqs, dvs, u0; check_length = true, kwargs...) if u0 !== nothing if check_length @@ -2855,12 +2665,13 @@ end """ $(TYPEDSIGNATURES) -Extend the `basesys` with `sys`, the resulting system would inherit `sys`'s name -by default. +Extend `basesys` with `sys`. +By default, the resulting system inherits `sys`'s name and description. See also [`compose`](@ref). """ -function extend(sys::AbstractSystem, basesys::AbstractSystem; name::Symbol = nameof(sys), +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) @@ -2888,7 +2699,7 @@ function extend(sys::AbstractSystem, basesys::AbstractSystem; name::Symbol = nam args = length(ivs) == 0 ? (eqs, sts, ps) : (eqs, ivs[1], sts, ps) kwargs = (parameter_dependencies = dep_ps, observed = obs, continuous_events = cevs, discrete_events = devs, defaults = defs, systems = syss, metadata = meta, - name = name, gui_metadata = gui_metadata) + name = name, description = description, gui_metadata = gui_metadata) # collect fields specific to some system types if basesys isa ODESystem @@ -2897,11 +2708,25 @@ function extend(sys::AbstractSystem, basesys::AbstractSystem; name::Symbol = nam kwargs = merge(kwargs, (initialization_eqs = ieqs, guesses = guesses)) end + if has_assertions(basesys) + kwargs = merge( + kwargs, (; assertions = merge(get_assertions(basesys), get_assertions(sys)))) + end + return T(args...; kwargs...) end -function Base.:(&)(sys::AbstractSystem, basesys::AbstractSystem; name::Symbol = nameof(sys)) - extend(sys, basesys; name = name) +function extend(sys, basesys::Vector{T}) where {T <: AbstractSystem} + foldl(extend, basesys, init = sys) +end + +function Base.:(&)(sys::AbstractSystem, basesys::AbstractSystem; kwargs...) + extend(sys, basesys; kwargs...) +end + +function Base.:(&)( + sys::AbstractSystem, basesys::Vector{T}; kwargs...) where {T <: AbstractSystem} + extend(sys, basesys; kwargs...) end """ @@ -2917,6 +2742,17 @@ function compose(sys::AbstractSystem, systems::AbstractArray; name = nameof(sys) nsys == 0 && return sys @set! sys.name = name @set! sys.systems = [get_systems(sys); systems] + if has_is_dde(sys) + @set! sys.is_dde = _check_if_dde(equations(sys), get_iv(sys), get_systems(sys)) + end + newunknowns = OrderedSet() + newparams = OrderedSet() + iv = has_iv(sys) ? get_iv(sys) : nothing + for ssys in systems + collect_scoped_vars!(newunknowns, newparams, ssys, iv) + end + @set! sys.unknowns = unique!(vcat(get_unknowns(sys), collect(newunknowns))) + @set! sys.ps = unique!(vcat(get_ps(sys), collect(newparams))) return sys end function compose(syss...; name = nameof(first(syss))) @@ -3017,7 +2853,7 @@ function process_parameter_dependencies(pdeps, ps) end for p in pdeps] end - lhss = BasicSymbolic[] + lhss = [] for p in pdeps if !isparameter(p.lhs) error("LHS of parameter dependency must be a single parameter. Found $(p.lhs).") @@ -3028,6 +2864,7 @@ function process_parameter_dependencies(pdeps, ps) end push!(lhss, p.lhs) end + lhss = map(identity, lhss) pdeps = topsort_equations(pdeps, union(ps, lhss)) ps = filter(ps) do p !any(isequal(p), lhss) @@ -3109,6 +2946,97 @@ function dump_unknowns(sys::AbstractSystem) 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 ### """ @@ -3346,3 +3274,188 @@ 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/analysis_points.jl b/src/systems/analysis_points.jl new file mode 100644 index 0000000000..0d1a2830cf --- /dev/null +++ b/src/systems/analysis_points.jl @@ -0,0 +1,1039 @@ +""" + $(TYPEDEF) + AnalysisPoint(input, name::Symbol, outputs::Vector) + +Create an AnalysisPoint for linear analysis. Analysis points can be created by calling + +``` +connect(out, :ap_name, in...) +``` + +Where `out` is the output being connected to the inputs `in...`. All involved +connectors (input and outputs) are required to either have an unknown named +`u` or a single unknown, all of which should have the same size. + +See also [`get_sensitivity`](@ref), [`get_comp_sensitivity`](@ref), [`get_looptransfer`](@ref), [`open_loop`](@ref) + +# Fields + +$(TYPEDFIELDS) + +# Example + +```julia +using ModelingToolkit +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: t_nounits as t + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) +t = ModelingToolkit.get_iv(P) + +eqs = [connect(P.output, C.input) + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :feedback_system) + +matrices_S, _ = get_sensitivity(sys, :plant_input) # Compute the matrices of a state-space representation of the (input) sensitivity function. +matrices_T, _ = get_comp_sensitivity(sys, :plant_input) +``` + +Continued linear analysis and design can be performed using ControlSystemsBase.jl. +Create `ControlSystemsBase.StateSpace` objects using + +```julia +using ControlSystemsBase, Plots +S = ss(matrices_S...) +T = ss(matrices_T...) +bodeplot([S, T], lab = ["S" "T"]) +``` + +The sensitivity functions obtained this way should be equivalent to the ones obtained with the code below + +```julia +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +``` +""" +struct AnalysisPoint + """ + The input to the connection. In the context of ModelingToolkitStandardLibrary.jl, + this is a `RealOutput` connector. + """ + input::Any + """ + The name of the analysis point. + """ + name::Symbol + """ + The outputs of the connection. In the context of ModelingToolkitStandardLibrary.jl, + these are all `RealInput` connectors. + """ + outputs::Union{Nothing, Vector{Any}} + + function AnalysisPoint(input, name::Symbol, outputs; verbose = true) + # input to analysis point should be an output variable + if verbose && input !== nothing + var = ap_var(input) + isoutput(var) || ap_warning(1, name, true) + end + # outputs of analysis points should be input variables + if verbose && outputs !== nothing + for (i, output) in enumerate(outputs) + var = ap_var(output) + isinput(var) || ap_warning(2 + i, name, false) + end + end + + return new(input, name, outputs) + end +end + +function ap_warning(arg::Int, name::Symbol, should_be_output) + causality = should_be_output ? "output" : "input" + @warn """ + The $(arg)-th argument to analysis point $(name) was not a $causality. This is supported in \ + order to handle inverse models, but may not be what you intended. + + If you are building a forward mode (causal), you may want to swap this argument with \ + one on the opposite side of the name of the analysis point provided to `connect`. \ + Learn more about the causality of analysis points in the docstring for `AnalysisPoint`. \ + Silence this message using `connect(out, :name, in...; warn = false)`. + """ +end + +AnalysisPoint() = AnalysisPoint(nothing, Symbol(), nothing) +""" + $(TYPEDSIGNATURES) + +Create an `AnalysisPoint` with the given name, with no input or outputs specified. +""" +AnalysisPoint(name::Symbol) = AnalysisPoint(nothing, name, nothing) + +Base.nameof(ap::AnalysisPoint) = ap.name + +Base.show(io::IO, ap::AnalysisPoint) = show(io, MIME"text/plain"(), ap) +function Base.show(io::IO, ::MIME"text/plain", ap::AnalysisPoint) + if ap.input === nothing + print(io, "0") + return + end + if get(io, :compact, false) + print(io, + "AnalysisPoint($(ap_var(ap.input)), $(ap_var.(ap.outputs)); name=$(ap.name))") + else + print(io, "AnalysisPoint(") + printstyled(io, ap.name, color = :cyan) + if ap.input !== nothing && ap.outputs !== nothing + print(io, " from ") + printstyled(io, ap_var(ap.input), color = :green) + print(io, " to ") + if length(ap.outputs) == 1 + printstyled(io, ap_var(ap.outputs[1]), color = :blue) + else + printstyled(io, "[", join(ap_var.(ap.outputs), ", "), "]", color = :blue) + end + end + print(io, ")") + end +end + +@latexrecipe function f(ap::AnalysisPoint) + index --> :subscript + snakecase --> true + ap.input === nothing && return 0 + outs = Expr(:vect) + append!(outs.args, ap_var.(ap.outputs)) + return Expr(:call, :AnalysisPoint, ap_var(ap.input), ap.name, outs) +end + +function Base.show(io::IO, ::MIME"text/latex", ap::AnalysisPoint) + print(io, latexify(ap)) +end + +""" + $(TYPEDSIGNATURES) + +Convert an `AnalysisPoint` to a standard connection. +""" +function to_connection(ap::AnalysisPoint) + return connect(ap.input, ap.outputs...) +end + +""" + $(TYPEDSIGNATURES) + +Namespace an `AnalysisPoint` by namespacing the involved systems and the name of the point. +""" +function renamespace(sys, ap::AnalysisPoint) + return AnalysisPoint( + ap.input === nothing ? nothing : renamespace(sys, ap.input), + renamespace(sys, ap.name), + ap.outputs === nothing ? nothing : map(Base.Fix1(renamespace, sys), ap.outputs) + ) +end + +# create analysis points via `connect` +function Symbolics.connect(in, ap::AnalysisPoint, outs...; verbose = true) + return AnalysisPoint() ~ AnalysisPoint(in, ap.name, collect(outs); verbose) +end + +""" + connect(output_connector, ap_name::Symbol, input_connector; verbose = true) + connect(output_connector, ap::AnalysisPoint, input_connector; verbose = true) + +Connect `output_connector` and `input_connector` with an [`AnalysisPoint`](@ref) inbetween. +The incoming connection `output_connector` is expected to be an output connector (for +example, `ModelingToolkitStandardLibrary.Blocks.RealOutput`), and vice versa. + +*PLEASE NOTE*: The connection is assumed to be *causal*, meaning that + +```julia +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) +connect(C.output, :plant_input, P.input) +``` + +is correct, whereas + +```julia +connect(P.input, :plant_input, C.output) +``` + +typically is not (unless the model is an inverse model). + +# Arguments + +- `output_connector`: An output connector +- `input_connector`: An input connector +- `ap`: An explicitly created [`AnalysisPoint`](@ref) +- `ap_name`: If a name is given, an [`AnalysisPoint`](@ref) with the given name will be + created automatically. + +# Keyword arguments + +- `verbose`: Warn if an input is connected to an output (reverse causality). Silence this + warning if you are analyzing an inverse model. +""" +function Symbolics.connect(in::AbstractSystem, name::Symbol, out, outs...; verbose = true) + return AnalysisPoint() ~ AnalysisPoint(in, name, [out; collect(outs)]; verbose) +end + +function Symbolics.connect( + in::ConnectableSymbolicT, name::Symbol, out::ConnectableSymbolicT, + outs::ConnectableSymbolicT...; verbose = true) + allvars = (in, out, outs...) + validate_causal_variables_connection(allvars) + return AnalysisPoint() ~ AnalysisPoint( + unwrap(in), name, unwrap.([out; collect(outs)]); verbose) +end + +""" + $(TYPEDSIGNATURES) + +Return all the namespaces in `name`. Namespaces should be separated by `.` or +`$NAMESPACE_SEPARATOR`. +""" +namespace_hierarchy(name::Symbol) = map( + Symbol, split(string(name), ('.', NAMESPACE_SEPARATOR))) + +""" + $(TYPEDSIGNATURES) + +Remove all `AnalysisPoint`s in `sys` and any of its subsystems, replacing them by equivalent connections. +""" +function remove_analysis_points(sys::AbstractSystem) + eqs = map(get_eqs(sys)) do eq + eq.lhs isa AnalysisPoint ? to_connection(eq.rhs) : eq + end + @set! sys.eqs = eqs + @set! sys.systems = map(remove_analysis_points, get_systems(sys)) + + return sys +end + +""" + $(TYPEDSIGNATURES) + +Given a system involved in an `AnalysisPoint`, get the variable to be used in the +connection. This is the variable named `u` if present, and otherwise the only +variable in the system. If the system does not have a variable named `u` and +contains multiple variables, throw an error. +""" +function ap_var(sys::AbstractSystem) + if hasproperty(sys, :u) + return sys.u + end + x = unknowns(sys) + length(x) == 1 && return renamespace(sys, x[1]) + error("Could not determine the analysis-point variable in system $(nameof(sys)). To use an analysis point, apply it to a connection between causal blocks which have a variable named `u` or a single unknown of the same size.") +end + +""" + $(TYPEDSIGNATURES) + +For an `AnalysisPoint` involving causal variables. Simply return the variable. +""" +function ap_var(var::ConnectableSymbolicT) + return var +end + +""" + $(TYPEDEF) + +The supertype of all transformations that can be applied to an `AnalysisPoint`. All +concrete subtypes must implement `apply_transformation`. +""" +abstract type AnalysisPointTransformation end + +""" + apply_transformation(tf::AnalysisPointTransformation, sys::AbstractSystem) + +Apply the given analysis point transformation `tf` to the system `sys`. Throw an error if +any analysis points referred to in `tf` are not present in `sys`. Return a tuple +containing the modified system as the first element, and a tuple of the additional +variables added by the transformation as the second element. +""" +function apply_transformation end + +""" + $(TYPEDSIGNATURES) + +Given a namespaced subsystem `target` of root system `root`, return a modified copy of +`root` with `target` modified according to `fn` alongside any extra variables added +by `fn`. + +`fn` is a function which takes the instance of `target` present in the hierarchy of +`root`, and returns a 2-tuple consisting of the modified version of `target` and a tuple +of the extra variables added. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::AbstractSystem) = modify_nested_subsystem( + fn, root, nameof(target)) +""" + $(TYPEDSIGNATURES) + +Apply the modification to the system containing the namespaced analysis point `target`. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::AnalysisPoint) = modify_nested_subsystem( + fn, root, @view namespace_hierarchy(nameof(target))[1:(end - 1)]) +""" + $(TYPEDSIGNATURES) + +Apply the modification to the nested subsystem of `root` whose namespaced name matches +the provided name `target`. The namespace separator in `target` should be `.` or +`$NAMESPACE_SEPARATOR`. The `target` may include `nameof(root)` as the first namespace. +""" +modify_nested_subsystem(fn, root::AbstractSystem, target::Symbol) = modify_nested_subsystem( + fn, root, namespace_hierarchy(target)) + +""" + $(TYPEDSIGNATURES) + +Apply the modification to the nested subsystem of `root` where the name of the subsystem at +each level in the hierarchy is given by elements of `hierarchy`. For example, if +`hierarchy = [:a, :b, :c]`, the system being searched for will be `root.a.b.c`. Note that +the hierarchy may include the name of the root system, in which the first element will be +ignored. For example, `hierarchy = [:root, :a, :b, :c]` also searches for `root.a.b.c`. +An empty `hierarchy` will apply the modification to `root`. +""" +function modify_nested_subsystem( + fn, root::AbstractSystem, hierarchy::AbstractVector{Symbol}) + # no hierarchy, so just apply to the root + if isempty(hierarchy) + return fn(root) + end + # ignore the name of the root + if nameof(root) != hierarchy[1] + error("The name of the root system $(nameof(root)) must be included in the name passed to `modify_nested_subsystem`") + end + hierarchy = @view hierarchy[2:end] + + # recursive helper function which does the searching and modification + function _helper(sys::AbstractSystem, i::Int) + if i > length(hierarchy) + # we reached past the end, so everything matched and + # `sys` is the system to modify. + sys, vars = fn(sys) + else + # find the subsystem with the given name and error otherwise + cur = hierarchy[i] + idx = findfirst(subsys -> nameof(subsys) == cur, get_systems(sys)) + idx === nothing && + error("System $(join([nameof(root); hierarchy[1:i-1]], '.')) does not have a subsystem named $cur.") + + # recurse into new subsystem + newsys, vars = _helper(get_systems(sys)[idx], i + 1) + # update this system with modified subsystem + @set! sys.systems[idx] = newsys + end + # only namespace variables from inner systems + if i != 1 + vars = ntuple(Val(length(vars))) do i + renamespace(sys, vars[i]) + end + end + return sys, vars + end + + return _helper(root, 1) +end + +""" + $(TYPEDSIGNATURES) + +Given a system `sys` and analysis point `ap`, return the index in `get_eqs(sys)` +containing an equation which has as it's RHS an analysis point with name `nameof(ap)`. +""" +analysis_point_index(sys::AbstractSystem, ap::AnalysisPoint) = analysis_point_index( + sys, nameof(ap)) +""" + $(TYPEDSIGNATURES) + +Search for the analysis point with the given `name` in `get_eqs(sys)`. +""" +function analysis_point_index(sys::AbstractSystem, name::Symbol) + name = namespace_hierarchy(name)[end] + findfirst(get_eqs(sys)) do eq + eq.lhs isa AnalysisPoint && nameof(eq.rhs) == name + end +end + +""" + $(TYPEDSIGNATURES) + +Create a new variable of the same `symtype` and size as `var`, using `name` as the base +name for the new variable. `iv` denotes the independent variable of the system. Prefix +`d_` to the name of the new variable if `perturb == true`. Return the new symbolic +variable and the appropriate zero value for it. +""" +function get_analysis_variable(var, name, iv; perturb = true) + var = unwrap(var) + if perturb + name = Symbol(:d_, name) + end + if symbolic_type(var) == ArraySymbolic() + T = Array{eltype(symtype(var)), ndims(var)} + pvar = unwrap(only(@variables $name(iv)::T)) + pvar = setmetadata(pvar, Symbolics.ArrayShapeCtx, Symbolics.shape(var)) + default = zeros(eltype(symtype(var)), size(var)) + else + T = symtype(var) + pvar = unwrap(only(@variables $name(iv)::T)) + default = zero(T) + end + return pvar, default +end + +function with_analysis_point_ignored(sys::AbstractSystem, ap::AnalysisPoint) + has_ignored_connections(sys) || return sys + ignored = get_ignored_connections(sys) + if ignored === nothing + ignored = (IgnoredAnalysisPoint[], IgnoredAnalysisPoint[]) + else + ignored = copy.(ignored) + end + if ap.outputs === nothing + error("Empty analysis point") + end + + if ap.input isa AbstractSystem && all(x -> x isa AbstractSystem, ap.outputs) + push!(ignored[1], IgnoredAnalysisPoint(ap.input, ap.outputs)) + else + push!(ignored[2], IgnoredAnalysisPoint(unwrap(ap.input), unwrap.(ap.outputs))) + end + + return @set sys.ignored_connections = ignored +end + +#### PRIMITIVE TRANSFORMATIONS + +const DOC_WILL_REMOVE_AP = """ + Note that this transformation will remove `ap`, causing any subsequent transformations \ + referring to it to fail.\ + """ + +const DOC_ADDED_VARIABLE = """ + The added variable(s) will have a default of zero, of the appropriate type and size.\ + """ + +""" + $(TYPEDEF) + +A transformation which breaks the connection referred to by `ap`. If `add_input == true`, +it will add a new input variable which connects to the outputs of the analysis point. +`apply_transformation` returns the new input variable (if added) as the auxiliary +information. The new input variable will have the name `Symbol(:d_, nameof(ap))`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +## Fields + +$(TYPEDFIELDS) +""" +struct Break <: AnalysisPointTransformation + """ + The analysis point to break. + """ + ap::AnalysisPoint + """ + Whether to add a new input variable connected to all the outputs of `ap`. + """ + add_input::Bool +end + +""" + $(TYPEDSIGNATURES) + +`Break` the given analysis point `ap` without adding an input. +""" +Break(ap::AnalysisPoint) = Break(ap, false) + +function apply_transformation(tf::Break, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do breaksys + # get analysis point + ap_idx = analysis_point_index(breaksys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + breaksys_eqs = copy(get_eqs(breaksys)) + @set! breaksys.eqs = breaksys_eqs + + ap = breaksys_eqs[ap_idx].rhs + deleteat!(breaksys_eqs, ap_idx) + + breaksys = with_analysis_point_ignored(breaksys, ap) + + tf.add_input || return breaksys, () + + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable(ap_ivar, nameof(ap), get_iv(sys)) + for outsys in ap.outputs + push!(breaksys_eqs, ap_var(outsys) ~ new_var) + end + defs = copy(get_defaults(breaksys)) + defs[new_var] = new_def + @set! breaksys.defaults = defs + unks = copy(get_unknowns(breaksys)) + push!(unks, new_var) + @set! breaksys.unknowns = unks + + return breaksys, (new_var,) + end +end + +""" + $(TYPEDEF) + +A transformation which returns the variable corresponding to the input of the analysis +point. Does not modify the system. + +## Fields + +$(TYPEDFIELDS) +""" +struct GetInput <: AnalysisPointTransformation + """ + The analysis point to get the input of. + """ + ap::AnalysisPoint +end + +function apply_transformation(tf::GetInput, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get the analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + # get the analysis point + ap_sys_eqs = get_eqs(ap_sys) + ap = ap_sys_eqs[ap_idx].rhs + + # input variable + ap_ivar = ap_var(ap.input) + return ap_sys, (ap_ivar,) + end +end + +""" + $(TYPEDEF) + +A transformation that creates a new input variable which is added to the input of +the analysis point before connecting to the outputs. The new variable will have the name +`Symbol(:d_, nameof(ap))`. + +If `with_output == true`, also creates an additional new variable which has the value +provided to the outputs after the above modification. This new variable has the same name +as the analysis point and will be the second variable in the tuple of new variables returned +from `apply_transformation`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +## Fields + +$(TYPEDFIELDS) +""" +struct PerturbOutput <: AnalysisPointTransformation + """ + The analysis point to modify + """ + ap::AnalysisPoint + """ + Whether to add an additional output variable. + """ + with_output::Bool +end + +""" + $(TYPEDSIGNATURES) + +Add an input without an additional output variable. +""" +PerturbOutput(ap::AnalysisPoint) = PerturbOutput(ap, false) + +function apply_transformation(tf::PerturbOutput, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + # modified equations + ap_sys_eqs = copy(get_eqs(ap_sys)) + @set! ap_sys.eqs = ap_sys_eqs + ap = ap_sys_eqs[ap_idx].rhs + # remove analysis point + deleteat!(ap_sys_eqs, ap_idx) + ap_sys = with_analysis_point_ignored(ap_sys, ap) + + # add equations involving new variable + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable(ap_ivar, nameof(ap), get_iv(sys)) + for outsys in ap.outputs + push!(ap_sys_eqs, ap_var(outsys) ~ ap_ivar + wrap(new_var)) + end + # add variable + unks = copy(get_unknowns(ap_sys)) + push!(unks, new_var) + @set! ap_sys.unknowns = unks + # add default + defs = copy(get_defaults(ap_sys)) + defs[new_var] = new_def + @set! ap_sys.defaults = defs + + tf.with_output || return ap_sys, (new_var,) + + # add output variable, equation, default + out_var, out_def = get_analysis_variable( + ap_ivar, nameof(ap), get_iv(sys); perturb = false) + push!(ap_sys_eqs, out_var ~ ap_ivar + wrap(new_var)) + push!(unks, out_var) + + return ap_sys, (new_var, out_var) + end +end + +""" + $(TYPEDEF) + +A transformation which adds a variable named `name` to the system containing the analysis +point `ap`. $DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct AddVariable <: AnalysisPointTransformation + """ + The analysis point in the system to modify, and whose input should be used as the + template for the new variable. + """ + ap::AnalysisPoint + """ + The name of the added variable. + """ + name::Symbol +end + +""" + $(TYPEDSIGNATURES) + +Add a new variable to the system containing analysis point `ap` with the same name as the +analysis point. +""" +AddVariable(ap::AnalysisPoint) = AddVariable(ap, nameof(ap)) + +function apply_transformation(tf::AddVariable, sys::AbstractSystem) + modify_nested_subsystem(sys, tf.ap) do ap_sys + # get analysis point + ap_idx = analysis_point_index(ap_sys, tf.ap) + ap_idx === nothing && + error("Analysis point $(nameof(tf.ap)) not found in system $(nameof(sys)).") + ap_sys_eqs = get_eqs(ap_sys) + ap = ap_sys_eqs[ap_idx].rhs + + # add equations involving new variable + ap_ivar = ap_var(ap.input) + new_var, new_def = get_analysis_variable( + ap_ivar, tf.name, get_iv(sys); perturb = false) + # add variable + unks = copy(get_unknowns(ap_sys)) + push!(unks, new_var) + @set! ap_sys.unknowns = unks + return ap_sys, (new_var,) + end +end + +#### DERIVED TRANSFORMATIONS + +""" + $(TYPEDSIGNATURES) + +A transformation enable calculating the sensitivity function about the analysis point `ap`. +The returned added variables are `(du, u)` where `du` is the perturbation added to the +input, and `u` is the output after perturbation. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE +""" +SensitivityTransform(ap::AnalysisPoint) = PerturbOutput(ap, true) + +""" + $(TYPEDEF) + +A transformation to enable calculating the complementary sensitivity function about the +analysis point `ap`. The returned added variables are `(du, u)` where `du` is the +perturbation added to the outputs and `u` is the input to the analysis point. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct ComplementarySensitivityTransform <: AnalysisPointTransformation + """ + The analysis point to modify. + """ + ap::AnalysisPoint +end + +function apply_transformation(cst::ComplementarySensitivityTransform, sys::AbstractSystem) + sys, (u,) = apply_transformation(GetInput(cst.ap), sys) + sys, (du,) = apply_transformation( + AddVariable( + cst.ap, Symbol(namespace_hierarchy(nameof(cst.ap))[end], :_comp_sens_du)), + sys) + sys, (_du,) = apply_transformation(PerturbOutput(cst.ap), sys) + + # `PerturbOutput` adds the equation `input + _du ~ output` + # but comp sensitivity wants `output + du ~ input`. Thus, `du ~ -_du`. + eqs = copy(get_eqs(sys)) + @set! sys.eqs = eqs + push!(eqs, du ~ -wrap(_du)) + + defs = copy(get_defaults(sys)) + @set! sys.defaults = defs + defs[du] = -wrap(_du) + return sys, (du, u) +end + +""" + $(TYPEDEF) + +A transformation to enable calculating the loop transfer function about the analysis point +`ap`. The returned added variables are `(du, u)` where `du` feeds into the outputs of `ap` +and `u` is the input of `ap`. + +$DOC_WILL_REMOVE_AP + +$DOC_ADDED_VARIABLE + +# Fields + +$(TYPEDFIELDS) +""" +struct LoopTransferTransform <: AnalysisPointTransformation + """ + The analysis point to modify. + """ + ap::AnalysisPoint +end + +function apply_transformation(tf::LoopTransferTransform, sys::AbstractSystem) + sys, (u,) = apply_transformation(GetInput(tf.ap), sys) + sys, (du,) = apply_transformation(Break(tf.ap, true), sys) + return sys, (du, u) +end + +""" + $(TYPEDSIGNATURES) + +A utility function to get the "canonical" form of a list of analysis points. Always returns +a list of values. Any value that cannot be turned into an `AnalysisPoint` (i.e. isn't +already an `AnalysisPoint` or `Symbol`) is simply wrapped in an array. `Symbol` names of +`AnalysisPoint`s are namespaced with `sys`. +""" +canonicalize_ap(sys::AbstractSystem, ap::Symbol) = [AnalysisPoint(renamespace(sys, ap))] +function canonicalize_ap(sys::AbstractSystem, ap::AnalysisPoint) + if does_namespacing(sys) + return [ap] + else + return [renamespace(sys, ap)] + end +end +canonicalize_ap(sys::AbstractSystem, ap) = [ap] +function canonicalize_ap(sys::AbstractSystem, aps::Vector) + mapreduce(Base.Fix1(canonicalize_ap, sys), vcat, aps; init = []) +end + +""" + $(TYPEDSIGNATURES) + +Given a list of analysis points, break the connection for each and set the output to zero. +""" +function handle_loop_openings(sys::AbstractSystem, aps) + for ap in canonicalize_ap(sys, aps) + sys, (outvar,) = apply_transformation(Break(ap, true), sys) + if Symbolics.isarraysymbolic(outvar) + push!(get_eqs(sys), outvar ~ zeros(size(outvar))) + else + push!(get_eqs(sys), outvar ~ 0) + end + end + return sys +end + +const DOC_LOOP_OPENINGS = """ + - `loop_openings`: A list of analysis points whose connections should be removed and + the outputs set to zero as a part of the linear analysis. +""" + +const DOC_SYS_MODIFIER = """ + - `system_modifier`: A function taking the transformed system and applying any + additional transformations, returning the modified system. The modified system + is passed to `linearization_function`. +""" +""" + $(TYPEDSIGNATURES) + +Utility function for linear analyses that apply a transformation `transform`, which +returns the added variables `(du, u)`, to each of the analysis points in `aps` and then +calls `linearization_function` with all the `du`s as inputs and `u`s as outputs. Returns +the linearization function and modified, simplified system. + +# Keyword arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_linear_analysis_function( + sys::AbstractSystem, transform, aps; system_modifier = identity, loop_openings = [], kwargs...) + sys = handle_loop_openings(sys, loop_openings) + aps = canonicalize_ap(sys, aps) + dus = [] + us = [] + for ap in aps + sys, (du, u) = apply_transformation(transform(ap), sys) + push!(dus, du) + push!(us, u) + end + linearization_function(system_modifier(sys), dus, us; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the sensitivity function for the analysis point(s) `aps`, and the modified system +simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_sensitivity_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, SensitivityTransform, aps; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the complementary sensitivity function for the analysis point(s) `aps`, and the +modified system simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_comp_sensitivity_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, ComplementarySensitivityTransform, aps; kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Return the loop-transfer function for the analysis point(s) `aps`, and the modified +system simplified with the appropriate inputs and outputs. + +# Keyword Arguments + +$DOC_LOOP_OPENINGS +$DOC_SYS_MODIFIER + +All other keyword arguments are forwarded to `linearization_function`. +""" +function get_looptransfer_function(sys::AbstractSystem, aps; kwargs...) + get_linear_analysis_function(sys, LoopTransferTransform, aps; kwargs...) +end + +for f in [:get_sensitivity, :get_comp_sensitivity, :get_looptransfer] + utility_fun = Symbol(f, :_function) + @eval function $f( + sys, ap, args...; loop_openings = [], system_modifier = identity, kwargs...) + lin_fun, ssys = $(utility_fun)( + sys, ap, args...; loop_openings, system_modifier, kwargs...) + ModelingToolkit.linearize(ssys, lin_fun), ssys + end +end + +""" + $(TYPEDSIGNATURES) + +Apply `LoopTransferTransform` to the analysis point `ap` and return the +result of `apply_transformation`. + +# Keyword Arguments + +- `system_modifier`: a function which takes the modified system and returns a new system + with any required further modifications performed. +""" +function open_loop(sys, ap::Union{Symbol, AnalysisPoint}; system_modifier = identity) + ap = only(canonicalize_ap(sys, ap)) + tf = LoopTransferTransform(ap) + sys, vars = apply_transformation(tf, sys) + return system_modifier(sys), vars +end + +function linearization_function(sys::AbstractSystem, + inputs::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + outputs; loop_openings = [], system_modifier = identity, kwargs...) + loop_openings = Set(map(nameof, canonicalize_ap(sys, loop_openings))) + inputs = canonicalize_ap(sys, inputs) + outputs = canonicalize_ap(sys, outputs) + + input_vars = [] + for input in inputs + if nameof(input) in loop_openings + delete!(loop_openings, nameof(input)) + sys, (input_var,) = apply_transformation(Break(input, true), sys) + else + sys, (input_var,) = apply_transformation(PerturbOutput(input), sys) + end + push!(input_vars, input_var) + end + output_vars = [] + for output in outputs + if output isa AnalysisPoint + sys, (output_var,) = apply_transformation(AddVariable(output), sys) + sys, (input_var,) = apply_transformation(GetInput(output), sys) + push!(get_eqs(sys), output_var ~ input_var) + else + output_var = output + end + push!(output_vars, output_var) + end + + sys = handle_loop_openings(sys, map(AnalysisPoint, collect(loop_openings))) + + return linearization_function(system_modifier(sys), input_vars, output_vars; kwargs...) +end + +@doc """ + get_sensitivity(sys, ap::AnalysisPoint; kwargs) + get_sensitivity(sys, ap_name::Symbol; kwargs) + +Compute the sensitivity function in analysis point `ap`. The sensitivity function is obtained by introducing an infinitesimal perturbation `d` at the input of `ap`, linearizing the system and computing the transfer function between `d` and the output of `ap`. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_comp_sensitivity`](@ref), [`get_looptransfer`](@ref). +""" get_sensitivity + +@doc """ + get_comp_sensitivity(sys, ap::AnalysisPoint; kwargs) + get_comp_sensitivity(sys, ap_name::Symbol; kwargs) + +Compute the complementary sensitivity function in analysis point `ap`. The complementary sensitivity function is obtained by introducing an infinitesimal perturbation `d` at the output of `ap`, linearizing the system and computing the transfer function between `d` and the input of `ap`. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_sensitivity`](@ref), [`get_looptransfer`](@ref). +""" get_comp_sensitivity + +@doc """ + get_looptransfer(sys, ap::AnalysisPoint; kwargs) + get_looptransfer(sys, ap_name::Symbol; kwargs) + +Compute the (linearized) loop-transfer function in analysis point `ap`, from `ap.out` to `ap.in`. + +!!! info "Negative feedback" + + Feedback loops often use negative feedback, and the computed loop-transfer function will in this case have the negative feedback included. Standard analysis tools often assume a loop-transfer function without the negative gain built in, and the result of this function may thus need negation before use. + +# Arguments: + + - `kwargs`: Are sent to `ModelingToolkit.linearize` + +See also [`get_sensitivity`](@ref), [`get_comp_sensitivity`](@ref), [`open_loop`](@ref). +""" get_looptransfer +# + +""" + generate_control_function(sys::ModelingToolkit.AbstractODESystem, input_ap_name::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, dist_ap_name::Union{Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}; system_modifier = identity, kwargs) + +When called with analysis points as input arguments, we assume that all analysis points corresponds to connections that should be opened (broken). The use case for this is to get rid of input signal blocks, such as `Step` or `Sine`, since these are useful for simulation but are not needed when using the plant model in a controller or state estimator. +""" +function generate_control_function( + sys::ModelingToolkit.AbstractODESystem, input_ap_name::Union{ + Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}}, + dist_ap_name::Union{ + Nothing, Symbol, Vector{Symbol}, AnalysisPoint, Vector{AnalysisPoint}} = nothing; + system_modifier = identity, + kwargs...) + input_ap_name = canonicalize_ap(sys, input_ap_name) + u = [] + for input_ap in input_ap_name + sys, (du, _) = open_loop(sys, input_ap) + push!(u, du) + end + if dist_ap_name === nothing + return ModelingToolkit.generate_control_function(system_modifier(sys), u; kwargs...) + end + + dist_ap_name = canonicalize_ap(sys, dist_ap_name) + d = [] + for dist_ap in dist_ap_name + sys, (du, _) = open_loop(sys, dist_ap) + push!(d, du) + end + + ModelingToolkit.generate_control_function(system_modifier(sys), u, d; kwargs...) +end diff --git a/src/systems/callbacks.jl b/src/systems/callbacks.jl index 86cab57634..07809bf611 100644 --- a/src/systems/callbacks.jl +++ b/src/systems/callbacks.jl @@ -1,7 +1,9 @@ #################################### system operations ##################################### -get_continuous_events(sys::AbstractSystem) = SymbolicContinuousCallback[] -get_continuous_events(sys::AbstractODESystem) = getfield(sys, :continuous_events) has_continuous_events(sys::AbstractSystem) = isdefined(sys, :continuous_events) +function get_continuous_events(sys::AbstractSystem) + has_continuous_events(sys) || return SymbolicContinuousCallback[] + getfield(sys, :continuous_events) +end has_discrete_events(sys::AbstractSystem) = isdefined(sys, :discrete_events) function get_discrete_events(sys::AbstractSystem) @@ -60,8 +62,6 @@ function Base.hash(a::FunctionalAffect, s::UInt) hash(a.ctx, s) end -has_functional_affect(cb) = affects(cb) isa FunctionalAffect - namespace_affect(affect, s) = namespace_equation(affect, s) function namespace_affect(affect::FunctionalAffect, s) FunctionalAffect(func(affect), @@ -73,6 +73,17 @@ function namespace_affect(affect::FunctionalAffect, s) context(affect)) end +function has_functional_affect(cb) + (affects(cb) isa FunctionalAffect || affects(cb) isa ImperativeAffect) +end + +function vars!(vars, aff::FunctionalAffect; op = Differential) + for var in Iterators.flatten((unknowns(aff), parameters(aff), discretes(aff))) + vars!(vars, var) + end + return vars +end + #################################### continuous events ##################################### const NULL_AFFECT = Equation[] @@ -83,21 +94,30 @@ A [`ContinuousCallback`](@ref SciMLBase.ContinuousCallback) specified symbolical 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`. +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`. -Inter-sample condition activation is not guaranteed; for example if we use the dirac delta function as `c` to insert a +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). Multiple callbacks in the same system with different `rootfind` operations will be resolved -into separate VectorContinuousCallbacks in the enumeration order of `SciMLBase.RootfindOpt`, which may cause some callbacks to not fire if several become +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. -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 `prev_sign > 0`. - 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: @@ -106,15 +126,34 @@ Affects (i.e. `affect` and `affect_neg`) can be specified as either: + `read_parameters` is a vector of the parameters that are *used* by `f!`. Their indices are passed to `f` in `p` similarly to the indices of `unknowns` passed in `u`. + `modified_parameters` is a vector of the parameters that are *modified* by `f!`. Note that a parameter will not appear in `p` if it only appears in `modified_parameters`; it must appear in both `parameters` and `modified_parameters` if it is used in the affect definition. + `ctx` is a user-defined context object passed to `f!` when invoked. This value is aliased for each problem. +* A [`ImperativeAffect`](@ref); refer to its documentation for details. + +DAEs will be reinitialized using `reinitializealg` (which defaults to `SciMLBase.CheckInit`) after callbacks are applied. +This reinitialization algorithm ensures that the DAE is satisfied after the callback runs. The default value of `CheckInit` will simply validate +that the newly-assigned values indeed satisfy the algebraic system; see the documentation on DAE initialization for a more detailed discussion of +initialization. + +Initial and final affects can also be specified with SCC, which are specified identically to positive and negative edge affects. Initialization affects +will run as soon as the solver starts, while finalization affects will be executed after termination. """ struct SymbolicContinuousCallback eqs::Vector{Equation} - affect::Union{Vector{Equation}, FunctionalAffect} - affect_neg::Union{Vector{Equation}, FunctionalAffect, Nothing} + initialize::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + finalize::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + affect::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect} + affect_neg::Union{Vector{Equation}, FunctionalAffect, ImperativeAffect, Nothing} rootfind::SciMLBase.RootfindOpt - function SymbolicContinuousCallback(; eqs::Vector{Equation}, affect = NULL_AFFECT, - affect_neg = affect, rootfind = SciMLBase.LeftRootFind) - new(eqs, make_affect(affect), make_affect(affect_neg), rootfind) + reinitializealg::SciMLBase.DAEInitializationAlgorithm + function SymbolicContinuousCallback(; + eqs::Vector{Equation}, + affect = NULL_AFFECT, + affect_neg = affect, + initialize = NULL_AFFECT, + finalize = NULL_AFFECT, + rootfind = SciMLBase.LeftRootFind, + reinitializealg = SciMLBase.CheckInit()) + new(eqs, initialize, finalize, make_affect(affect), + make_affect(affect_neg), rootfind, reinitializealg) end # Default affect to nothing end make_affect(affect) = affect @@ -123,17 +162,80 @@ make_affect(affect::NamedTuple) = FunctionalAffect(; affect...) function Base.:(==)(e1::SymbolicContinuousCallback, e2::SymbolicContinuousCallback) isequal(e1.eqs, e2.eqs) && isequal(e1.affect, e2.affect) && + isequal(e1.initialize, e2.initialize) && isequal(e1.finalize, e2.finalize) && isequal(e1.affect_neg, e2.affect_neg) && isequal(e1.rootfind, e2.rootfind) end Base.isempty(cb::SymbolicContinuousCallback) = isempty(cb.eqs) function Base.hash(cb::SymbolicContinuousCallback, s::UInt) + hash_affect(affect::AbstractVector, s) = foldr(hash, affect, init = s) + hash_affect(affect, s) = hash(affect, s) s = foldr(hash, cb.eqs, init = s) - s = cb.affect isa AbstractVector ? foldr(hash, cb.affect, init = s) : hash(cb.affect, s) - s = cb.affect_neg isa AbstractVector ? foldr(hash, cb.affect_neg, init = s) : - hash(cb.affect_neg, s) + s = hash_affect(cb.affect, s) + s = hash_affect(cb.affect_neg, s) + s = hash_affect(cb.initialize, s) + s = hash_affect(cb.finalize, s) + s = hash(cb.reinitializealg, s) hash(cb.rootfind, s) end +function Base.show(io::IO, cb::SymbolicContinuousCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + print(io, "SymbolicContinuousCallback(") + print(iio, "Equations:") + show(iio, equations(cb)) + print(iio, "; ") + if affects(cb) != NULL_AFFECT + print(iio, "Affect:") + show(iio, affects(cb)) + print(iio, ", ") + end + if affect_negs(cb) != NULL_AFFECT + print(iio, "Negative-edge affect:") + show(iio, affect_negs(cb)) + print(iio, ", ") + end + if initialize_affects(cb) != NULL_AFFECT + print(iio, "Initialization affect:") + show(iio, initialize_affects(cb)) + print(iio, ", ") + end + if finalize_affects(cb) != NULL_AFFECT + print(iio, "Finalization affect:") + show(iio, finalize_affects(cb)) + end + print(iio, ")") +end + +function Base.show(io::IO, mime::MIME"text/plain", cb::SymbolicContinuousCallback) + indent = get(io, :indent, 0) + iio = IOContext(io, :indent => indent + 1) + println(io, "SymbolicContinuousCallback:") + println(iio, "Equations:") + show(iio, mime, equations(cb)) + print(iio, "\n") + if affects(cb) != NULL_AFFECT + println(iio, "Affect:") + show(iio, mime, affects(cb)) + print(iio, "\n") + end + if affect_negs(cb) != NULL_AFFECT + println(iio, "Negative-edge affect:") + show(iio, mime, affect_negs(cb)) + print(iio, "\n") + end + if initialize_affects(cb) != NULL_AFFECT + println(iio, "Initialization affect:") + show(iio, mime, initialize_affects(cb)) + print(iio, "\n") + end + if finalize_affects(cb) != NULL_AFFECT + println(iio, "Finalization affect:") + show(iio, mime, finalize_affects(cb)) + print(iio, "\n") + end +end + to_equation_vector(eq::Equation) = [eq] to_equation_vector(eqs::Vector{Equation}) = eqs function to_equation_vector(eqs::Vector{Any}) @@ -147,14 +249,18 @@ end # wrap eq in vector SymbolicContinuousCallback(p::Pair) = SymbolicContinuousCallback(p[1], p[2]) SymbolicContinuousCallback(cb::SymbolicContinuousCallback) = cb # passthrough function SymbolicContinuousCallback(eqs::Equation, affect = NULL_AFFECT; + initialize = NULL_AFFECT, finalize = NULL_AFFECT, affect_neg = affect, rootfind = SciMLBase.LeftRootFind) SymbolicContinuousCallback( - eqs = [eqs], affect = affect, affect_neg = affect_neg, rootfind = rootfind) + eqs = [eqs], affect = affect, affect_neg = affect_neg, + initialize = initialize, finalize = finalize, rootfind = rootfind) end function SymbolicContinuousCallback(eqs::Vector{Equation}, affect = NULL_AFFECT; - affect_neg = affect, rootfind = SciMLBase.LeftRootFind) + affect_neg = affect, initialize = NULL_AFFECT, finalize = NULL_AFFECT, + rootfind = SciMLBase.LeftRootFind) SymbolicContinuousCallback( - eqs = eqs, affect = affect, affect_neg = affect_neg, rootfind = rootfind) + eqs = eqs, affect = affect, affect_neg = affect_neg, + initialize = initialize, finalize = finalize, rootfind = rootfind) end SymbolicContinuousCallbacks(cb::SymbolicContinuousCallback) = [cb] @@ -183,15 +289,34 @@ function affect_negs(cbs::Vector{SymbolicContinuousCallback}) mapreduce(affect_negs, vcat, cbs, init = Equation[]) end +reinitialization_alg(cb::SymbolicContinuousCallback) = cb.reinitializealg +function reinitialization_algs(cbs::Vector{SymbolicContinuousCallback}) + mapreduce( + reinitialization_alg, vcat, cbs, init = SciMLBase.DAEInitializationAlgorithm[]) +end + +initialize_affects(cb::SymbolicContinuousCallback) = cb.initialize +function initialize_affects(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(initialize_affects, vcat, cbs, init = Equation[]) +end + +finalize_affects(cb::SymbolicContinuousCallback) = cb.finalize +function finalize_affects(cbs::Vector{SymbolicContinuousCallback}) + mapreduce(finalize_affects, vcat, cbs, init = Equation[]) +end + namespace_affects(af::Vector, s) = Equation[namespace_affect(a, s) for a in af] namespace_affects(af::FunctionalAffect, s) = namespace_affect(af, s) namespace_affects(::Nothing, s) = nothing function namespace_callback(cb::SymbolicContinuousCallback, s)::SymbolicContinuousCallback - SymbolicContinuousCallback( - namespace_equation.(equations(cb), (s,)), - namespace_affects(affects(cb), s); - affect_neg = namespace_affects(affect_negs(cb), s)) + SymbolicContinuousCallback(; + eqs = namespace_equation.(equations(cb), (s,)), + affect = namespace_affects(affects(cb), s), + affect_neg = namespace_affects(affect_negs(cb), s), + initialize = namespace_affects(initialize_affects(cb), s), + finalize = namespace_affects(finalize_affects(cb), s), + rootfind = cb.rootfind) end """ @@ -203,18 +328,49 @@ The `SymbolicContinuousCallback`s in the returned vector are structs with two fi `eqs => affect`. """ function continuous_events(sys::AbstractSystem) - obs = get_continuous_events(sys) - filter(!isempty, obs) + cbs = get_continuous_events(sys) + filter(!isempty, cbs) systems = get_systems(sys) - cbs = [obs; + cbs = [cbs; reduce(vcat, - (map(o -> namespace_callback(o, s), continuous_events(s)) + (map(cb -> namespace_callback(cb, s), continuous_events(s)) for s in systems), init = SymbolicContinuousCallback[])] filter(!isempty, cbs) end +function vars!(vars, cb::SymbolicContinuousCallback; op = Differential) + for eq in equations(cb) + vars!(vars, eq; op) + end + for aff in (affects(cb), affect_negs(cb), initialize_affects(cb), finalize_affects(cb)) + if aff isa Vector{Equation} + for eq in aff + vars!(vars, eq; op) + end + elseif aff !== nothing + vars!(vars, aff; op) + end + end + return vars +end + +""" + continuous_events_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `continuous_events`, but ignores events of subsystems. + +Notes: +- Cannot be applied to non-complete systems. +""" +function continuous_events_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return continuous_events_toplevel(parent) + end + return get_continuous_events(sys) +end + #################################### discrete events ##################################### struct SymbolicDiscreteCallback @@ -225,11 +381,17 @@ struct SymbolicDiscreteCallback # TODO: Iterative condition::Any affects::Any + initialize::Any + finalize::Any + reinitializealg::SciMLBase.DAEInitializationAlgorithm - function SymbolicDiscreteCallback(condition, affects = NULL_AFFECT) + function SymbolicDiscreteCallback( + condition, affects = NULL_AFFECT; reinitializealg = SciMLBase.CheckInit(), + initialize = NULL_AFFECT, finalize = NULL_AFFECT) c = scalarize_condition(condition) a = scalarize_affects(affects) - new(c, a) + new(c, a, scalarize_affects(initialize), + scalarize_affects(finalize), reinitializealg) end # Default affect to nothing end @@ -257,7 +419,7 @@ SymbolicDiscreteCallback(cb::SymbolicDiscreteCallback) = cb # passthrough function Base.show(io::IO, db::SymbolicDiscreteCallback) println(io, "condition: ", db.condition) println(io, "affects:") - if db.affects isa FunctionalAffect + if db.affects isa FunctionalAffect || db.affects isa ImperativeAffect # TODO println(io, " ", db.affects) else @@ -268,11 +430,19 @@ function Base.show(io::IO, db::SymbolicDiscreteCallback) end function Base.:(==)(e1::SymbolicDiscreteCallback, e2::SymbolicDiscreteCallback) - isequal(e1.condition, e2.condition) && isequal(e1.affects, e2.affects) + isequal(e1.condition, e2.condition) && isequal(e1.affects, e2.affects) && + isequal(e1.initialize, e2.initialize) && isequal(e1.finalize, e2.finalize) end function Base.hash(cb::SymbolicDiscreteCallback, s::UInt) s = hash(cb.condition, s) - cb.affects isa AbstractVector ? foldr(hash, cb.affects, init = s) : hash(cb.affects, s) + s = cb.affects isa AbstractVector ? foldr(hash, cb.affects, init = s) : + hash(cb.affects, s) + s = cb.initialize isa AbstractVector ? foldr(hash, cb.initialize, init = s) : + hash(cb.initialize, s) + s = cb.finalize isa AbstractVector ? foldr(hash, cb.finalize, init = s) : + hash(cb.finalize, s) + s = hash(cb.reinitializealg, s) + return s end condition(cb::SymbolicDiscreteCallback) = cb.condition @@ -286,10 +456,31 @@ function affects(cbs::Vector{SymbolicDiscreteCallback}) reduce(vcat, affects(cb) for cb in cbs; init = []) end +reinitialization_alg(cb::SymbolicDiscreteCallback) = cb.reinitializealg +function reinitialization_algs(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce( + reinitialization_alg, vcat, cbs, init = SciMLBase.DAEInitializationAlgorithm[]) +end + +initialize_affects(cb::SymbolicDiscreteCallback) = cb.initialize +function initialize_affects(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce(initialize_affects, vcat, cbs, init = Equation[]) +end + +finalize_affects(cb::SymbolicDiscreteCallback) = cb.finalize +function finalize_affects(cbs::Vector{SymbolicDiscreteCallback}) + mapreduce(finalize_affects, vcat, cbs, init = Equation[]) +end + function namespace_callback(cb::SymbolicDiscreteCallback, s)::SymbolicDiscreteCallback - af = affects(cb) - af = af isa AbstractVector ? namespace_affect.(af, Ref(s)) : namespace_affect(af, s) - SymbolicDiscreteCallback(namespace_condition(condition(cb), s), af) + function namespace_affects(af) + return af isa AbstractVector ? namespace_affect.(af, Ref(s)) : + namespace_affect(af, s) + end + SymbolicDiscreteCallback( + namespace_condition(condition(cb), s), namespace_affects(affects(cb)), + reinitializealg = cb.reinitializealg, initialize = namespace_affects(initialize_affects(cb)), + finalize = namespace_affects(finalize_affects(cb))) end SymbolicDiscreteCallbacks(cb::Pair) = SymbolicDiscreteCallback[SymbolicDiscreteCallback(cb)] @@ -307,84 +498,70 @@ The `SymbolicDiscreteCallback`s in the returned vector are structs with two fiel `condition => affect`. """ function discrete_events(sys::AbstractSystem) - obs = get_discrete_events(sys) + cbs = get_discrete_events(sys) systems = get_systems(sys) - cbs = [obs; + cbs = [cbs; reduce(vcat, - (map(o -> namespace_callback(o, s), discrete_events(s)) for s in systems), + (map(cb -> namespace_callback(cb, s), discrete_events(s)) for s in systems), init = SymbolicDiscreteCallback[])] cbs end +function vars!(vars, cb::SymbolicDiscreteCallback; op = Differential) + if symbolic_type(cb.condition) == NotSymbolic + if cb.condition isa AbstractArray + for eq in cb.condition + vars!(vars, eq; op) + end + end + else + vars!(vars, cb.condition; op) + end + for aff in (cb.affects, cb.initialize, cb.finalize) + if aff isa Vector{Equation} + for eq in aff + vars!(vars, eq; op) + end + elseif aff !== nothing + vars!(vars, aff; op) + end + end + return vars +end + +""" + discrete_events_toplevel(sys::AbstractSystem) + +Replicates the behaviour of `discrete_events`, but ignores events of subsystems. + +Notes: +- Cannot be applied to non-complete systems. +""" +function discrete_events_toplevel(sys::AbstractSystem) + if has_parent(sys) && (parent = get_parent(sys)) !== nothing + return discrete_events_toplevel(parent) + end + return get_discrete_events(sys) +end + ################################# compilation functions #################################### # handles ensuring that affect! functions work with integrator arguments function add_integrator_header( sys::AbstractSystem, integrator = gensym(:MTKIntegrator), out = :u) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - function (expr) - p = gensym(:p) - Func( - [ - DestructuredArgs([expr.args[1], p, expr.args[end]], - integrator, inds = [:u, :p, :t]) - ], - [], - Let( - [DestructuredArgs([arg.name for arg in expr.args[2:(end - 1)]], p), - expr.args[2:(end - 1)]...], - expr.body, - false) - ) - end, - function (expr) - p = gensym(:p) - Func( - [ - DestructuredArgs([expr.args[1], expr.args[2], p, expr.args[end]], - integrator, inds = [out, :u, :p, :t]) - ], - [], - Let( - [DestructuredArgs([arg.name for arg in expr.args[3:(end - 1)]], p), - expr.args[3:(end - 1)]...], - expr.body, - false) - ) - end - else - 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 + expr -> Func([DestructuredArgs(expr.args, integrator, inds = [:u, :p, :t])], [], + expr.body), + expr -> Func( + [DestructuredArgs(expr.args, integrator, inds = [out, :u, :p, :t])], [], + expr.body) end function condition_header(sys::AbstractSystem, integrator = gensym(:MTKIntegrator)) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - function (expr) - p = gensym(:p) - res = Func( - [expr.args[1], expr.args[2], - DestructuredArgs([p], integrator, inds = [:p])], - [], - Let( - [ - DestructuredArgs([arg.name for arg in expr.args[3:end]], p), - expr.args[3:end]... - ], expr.body, false - ) - ) - return res - end - else - expr -> Func( - [expr.args[1], expr.args[2], - DestructuredArgs(expr.args[3:end], integrator, inds = [:p])], - [], - expr.body) - end + expr -> Func( + [expr.args[1], expr.args[2], + DestructuredArgs(expr.args[3:end], integrator, inds = [:p])], + [], + expr.body) end function callback_save_header(sys::AbstractSystem, cb) @@ -411,7 +588,7 @@ end """ compile_condition(cb::SymbolicDiscreteCallback, sys, dvs, ps; expression, kwargs...) -Returns a function `condition(u,p,t)` returning the `condition(cb)`. +Returns a function `condition(u,t,integrator)` returning the `condition(cb)`. Notes @@ -430,10 +607,10 @@ function compile_condition(cb::SymbolicDiscreteCallback, sys, dvs, ps; cmap = map(x -> x => getdefault(x), cs) condit = substitute(condit, cmap) end - expr = build_function( + expr = build_function_wrapper(sys, condit, u, t, p...; expression = Val{true}, - wrap_code = condition_header(sys) .∘ wrap_array_vars(sys, condit; dvs, ps) .∘ - wrap_parameter_dependencies(sys, !(condit isa AbstractArray)), + p_start = 3, p_end = length(p) + 2, + wrap_code = condition_header(sys), kwargs...) if expression == Val{true} return expr @@ -516,15 +693,13 @@ function compile_affect(eqs::Vector{Equation}, cb, sys, dvs, ps; outputidxs = no end t = get_iv(sys) integ = gensym(:MTKIntegrator) - pre = get_preprocess_constants(rhss) - rf_oop, rf_ip = build_function(rhss, u, p..., t; expression = Val{true}, + rf_oop, rf_ip = build_function_wrapper( + sys, rhss, u, p..., t; expression = Val{true}, wrap_code = callback_save_header(sys, cb) .∘ - add_integrator_header(sys, integ, outvar) .∘ - wrap_array_vars(sys, rhss; dvs, ps = _ps) .∘ - wrap_parameter_dependencies(sys, false), + add_integrator_header(sys, integ, outvar), outputidxs = update_inds, - postprocess_fbody = pre, - kwargs...) + create_bindings = false, + kwargs..., cse = false) # applied user-provided function to the generated expression if postprocess_affect_expr! !== nothing postprocess_affect_expr!(rf_ip, integ) @@ -536,25 +711,25 @@ function compile_affect(eqs::Vector{Equation}, cb, sys, dvs, ps; outputidxs = no end end -function generate_rootfinding_callback(sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); kwargs...) +function generate_rootfinding_callback(sys::AbstractTimeDependentSystem, + dvs = unknowns(sys), ps = parameters(sys; initial_parameters = true); kwargs...) cbs = continuous_events(sys) isempty(cbs) && return nothing generate_rootfinding_callback(cbs, sys, dvs, ps; kwargs...) end """ -Generate a single rootfinding callback; this happens if there is only one equation in `cbs` passed to +Generate a single rootfinding callback; this happens if there is only one equation in `cbs` passed to generate_rootfinding_callback and thus we can produce a ContinuousCallback instead of a VectorContinuousCallback. """ function generate_single_rootfinding_callback( - eq, cb, sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); kwargs...) + eq, cb, sys::AbstractTimeDependentSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); kwargs...) if !isequal(eq.lhs, 0) eq = 0 ~ eq.lhs - eq.rhs end rf_oop, rf_ip = generate_custom_function( - sys, [eq.rhs], dvs, ps; expression = Val{false}, kwargs...) + sys, [eq.rhs], dvs, ps; expression = Val{false}, kwargs..., cse = false) affect_function = compile_affect_fn(cb, sys, dvs, ps, kwargs) cond = function (u, t, integ) if DiffEqBase.isinplace(integ.sol.prob) @@ -565,27 +740,34 @@ function generate_single_rootfinding_callback( rf_oop(u, parameter_values(integ), t) end end - + user_initfun = isnothing(affect_function.initialize) ? SciMLBase.INITIALIZE_DEFAULT : + (c, u, t, i) -> affect_function.initialize(i) if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing initfn = let save_idxs = save_idxs function (cb, u, t, integrator) + user_initfun(cb, u, t, integrator) for idx in save_idxs SciMLBase.save_discretes!(integrator, idx) end end end else - initfn = SciMLBase.INITIALIZE_DEFAULT + initfn = user_initfun end + return ContinuousCallback( - cond, affect_function.affect, affect_function.affect_neg, - rootfind = cb.rootfind, initialize = initfn) + cond, affect_function.affect, affect_function.affect_neg, rootfind = cb.rootfind, + initialize = initfn, + finalize = isnothing(affect_function.finalize) ? SciMLBase.FINALIZE_DEFAULT : + (c, u, t, i) -> affect_function.finalize(i), + initializealg = reinitialization_alg(cb)) end function generate_vector_rootfinding_callback( - cbs, sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); rootfind = SciMLBase.RightRootFind, kwargs...) + cbs, sys::AbstractTimeDependentSystem, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); rootfind = SciMLBase.RightRootFind, + reinitialization = SciMLBase.CheckInit(), kwargs...) eqs = map(cb -> flatten_equations(cb.eqs), cbs) num_eqs = length.(eqs) # fuse equations to create VectorContinuousCallback @@ -598,15 +780,15 @@ function generate_vector_rootfinding_callback( rhss = map(x -> x.rhs, eqs) _, rf_ip = generate_custom_function( - sys, rhss, dvs, ps; expression = Val{false}, kwargs...) - - affect_functions = @NamedTuple{affect::Function, affect_neg::Union{Function, Nothing}}[compile_affect_fn( - cb, - sys, - dvs, - ps, - kwargs) - for cb in cbs] + sys, rhss, dvs, ps; expression = Val{false}, kwargs..., cse = false) + + affect_functions = @NamedTuple{ + affect::Function, + affect_neg::Union{Function, Nothing}, + initialize::Union{Function, Nothing}, + finalize::Union{Function, Nothing}}[ + compile_affect_fn(cb, sys, dvs, ps, kwargs) + for cb in cbs] cond = function (out, u, t, integ) rf_ip(out, u, parameter_values(integ), t) end @@ -632,47 +814,84 @@ function generate_vector_rootfinding_callback( affect_neg(integ) end end - if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing - save_idxs = mapreduce( - cb -> get(ic.callback_to_clocks, cb, Int[]), vcat, cbs; init = Int[]) - initfn = if isempty(save_idxs) - SciMLBase.INITIALIZE_DEFAULT + function handle_optional_setup_fn(funs, default) + if all(isnothing, funs) + return default else - let save_idxs = save_idxs - function (cb, u, t, integrator) - for idx in save_idxs - SciMLBase.save_discretes!(integrator, idx) + return let funs = funs + function (cb, u, t, integ) + for func in funs + if isnothing(func) + continue + else + func(integ) + end end end end end + end + initialize = nothing + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + initialize = handle_optional_setup_fn( + map(cbs, affect_functions) do cb, fn + if (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing + let save_idxs = save_idxs + custom_init = fn.initialize + (i) -> begin + !isnothing(custom_init) && custom_init(i) + for idx in save_idxs + SciMLBase.save_discretes!(i, idx) + end + end + end + else + fn.initialize + end + end, + SciMLBase.INITIALIZE_DEFAULT) + else - initfn = SciMLBase.INITIALIZE_DEFAULT + initialize = handle_optional_setup_fn( + map(fn -> fn.initialize, affect_functions), SciMLBase.INITIALIZE_DEFAULT) end + + finalize = handle_optional_setup_fn( + map(fn -> fn.finalize, affect_functions), SciMLBase.FINALIZE_DEFAULT) return VectorContinuousCallback( - cond, affect, affect_neg, length(eqs), rootfind = rootfind, initialize = initfn) + cond, affect, affect_neg, length(eqs), rootfind = rootfind, + initialize = initialize, finalize = finalize, initializealg = reinitialization) end """ Compile a single continuous callback affect function(s). """ -function compile_affect_fn(cb, sys::AbstractODESystem, dvs, ps, kwargs) +function compile_affect_fn(cb, sys::AbstractTimeDependentSystem, dvs, ps, kwargs) eq_aff = affects(cb) eq_neg_aff = affect_negs(cb) affect = compile_affect(eq_aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + function compile_optional_affect(aff, default = nothing) + if isnothing(aff) || aff == default + return nothing + else + return compile_affect(aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + end + end if eq_neg_aff === eq_aff affect_neg = affect - elseif isnothing(eq_neg_aff) - affect_neg = nothing else - affect_neg = compile_affect( - eq_neg_aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + affect_neg = _compile_optional_affect( + NULL_AFFECT, eq_neg_aff, cb, sys, dvs, ps; kwargs...) end - (affect = affect, affect_neg = affect_neg) + initialize = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + finalize = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) + (affect = affect, affect_neg = affect_neg, initialize = initialize, finalize = finalize) end -function generate_rootfinding_callback(cbs, sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); kwargs...) +function generate_rootfinding_callback(cbs, sys::AbstractTimeDependentSystem, + dvs = unknowns(sys), ps = parameters(sys; initial_parameters = true); kwargs...) eqs = map(cb -> flatten_equations(cb.eqs), cbs) num_eqs = length.(eqs) total_eqs = sum(num_eqs) @@ -690,10 +909,15 @@ function generate_rootfinding_callback(cbs, sys::AbstractODESystem, dvs = unknow # group the cbs by what rootfind op they use # groupby would be very useful here, but alas cb_classes = Dict{ - @NamedTuple{rootfind::SciMLBase.RootfindOpt}, Vector{SymbolicContinuousCallback}}() + @NamedTuple{ + rootfind::SciMLBase.RootfindOpt, + reinitialization::SciMLBase.DAEInitializationAlgorithm}, Vector{SymbolicContinuousCallback}}() for cb in cbs push!( - get!(() -> SymbolicContinuousCallback[], cb_classes, (rootfind = cb.rootfind,)), + get!(() -> SymbolicContinuousCallback[], cb_classes, + ( + rootfind = cb.rootfind, + reinitialization = reinitialization_alg(cb))), cb) end @@ -701,7 +925,8 @@ function generate_rootfinding_callback(cbs, sys::AbstractODESystem, dvs = unknow compiled_callbacks = map(collect(pairs(sort!( OrderedDict(cb_classes); by = p -> p.rootfind)))) do (equiv_class, cbs_in_class) return generate_vector_rootfinding_callback( - cbs_in_class, sys, dvs, ps; rootfind = equiv_class.rootfind, kwargs...) + cbs_in_class, sys, dvs, ps; rootfind = equiv_class.rootfind, + reinitialization = equiv_class.reinitialization, kwargs...) end if length(compiled_callbacks) == 1 return compiled_callbacks[] @@ -749,33 +974,97 @@ function compile_user_affect(affect::FunctionalAffect, cb, sys, dvs, ps; kwargs. end end +function invalid_variables(sys, expr) + filter(x -> !any(isequal(x), all_symbols(sys)), reduce(vcat, vars(expr); init = [])) +end +function unassignable_variables(sys, expr) + assignable_syms = reduce( + vcat, Symbolics.scalarize.(vcat( + unknowns(sys), parameters(sys; initial_parameters = true))); + init = []) + written = reduce(vcat, Symbolics.scalarize.(vars(expr)); init = []) + return filter( + x -> !any(isequal(x), assignable_syms), written) +end + +@generated function _generated_writeback(integ, setters::NamedTuple{NS1, <:Tuple}, + values::NamedTuple{NS2, <:Tuple}) where {NS1, NS2} + setter_exprs = [] + for name in NS2 + if !(name in NS1) + missing_name = "Tried to write back to $name from affect; only declared states ($NS1) may be written to." + error(missing_name) + end + push!(setter_exprs, :(setters.$name(integ, values.$name))) + end + return :(begin + $(setter_exprs...) + end) +end + +function check_assignable(sys, sym) + if symbolic_type(sym) == ScalarSymbolic() + is_variable(sys, sym) || is_parameter(sys, sym) + elseif symbolic_type(sym) == ArraySymbolic() + is_variable(sys, sym) || is_parameter(sys, sym) || + all(x -> check_assignable(sys, x), collect(sym)) + elseif sym isa Union{AbstractArray, Tuple} + all(x -> check_assignable(sys, x), sym) + else + false + end +end + function compile_affect(affect::FunctionalAffect, cb, sys, dvs, ps; kwargs...) compile_user_affect(affect, cb, sys, dvs, ps; kwargs...) end - +function _compile_optional_affect(default, aff, cb, sys, dvs, ps; kwargs...) + if isnothing(aff) || aff == default + return nothing + else + return compile_affect(aff, cb, sys, dvs, ps; expression = Val{false}, kwargs...) + end +end function generate_timed_callback(cb, sys, dvs, ps; postprocess_affect_expr! = nothing, kwargs...) cond = condition(cb) as = compile_affect(affects(cb), cb, sys, dvs, ps; expression = Val{false}, postprocess_affect_expr!, kwargs...) + + user_initfun = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + user_finfun = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing - initfn = let save_idxs = save_idxs + initfn = let + save_idxs = save_idxs + initfun = user_initfun function (cb, u, t, integrator) + if !isnothing(initfun) + initfun(integrator) + end for idx in save_idxs SciMLBase.save_discretes!(integrator, idx) end end end else - initfn = SciMLBase.INITIALIZE_DEFAULT + initfn = isnothing(user_initfun) ? SciMLBase.INITIALIZE_DEFAULT : + (_, _, _, i) -> user_initfun(i) end + finfun = isnothing(user_finfun) ? SciMLBase.FINALIZE_DEFAULT : + (_, _, _, i) -> user_finfun(i) if cond isa AbstractVector # Preset Time - return PresetTimeCallback(cond, as; initialize = initfn) + return PresetTimeCallback( + cond, as; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) else # Periodic - return PeriodicCallback(as, cond; initialize = initfn) + return PeriodicCallback( + as, cond; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) end end @@ -788,24 +1077,37 @@ function generate_discrete_callback(cb, sys, dvs, ps; postprocess_affect_expr! = c = compile_condition(cb, sys, dvs, ps; expression = Val{false}, kwargs...) as = compile_affect(affects(cb), cb, sys, dvs, ps; expression = Val{false}, postprocess_affect_expr!, kwargs...) + + user_initfun = _compile_optional_affect( + NULL_AFFECT, initialize_affects(cb), cb, sys, dvs, ps; kwargs...) + user_finfun = _compile_optional_affect( + NULL_AFFECT, finalize_affects(cb), cb, sys, dvs, ps; kwargs...) if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing && (save_idxs = get(ic.callback_to_clocks, cb, nothing)) !== nothing - initfn = let save_idxs = save_idxs + initfn = let save_idxs = save_idxs, initfun = user_initfun function (cb, u, t, integrator) + if !isnothing(initfun) + initfun(integrator) + end for idx in save_idxs SciMLBase.save_discretes!(integrator, idx) end end end else - initfn = SciMLBase.INITIALIZE_DEFAULT + initfn = isnothing(user_initfun) ? SciMLBase.INITIALIZE_DEFAULT : + (_, _, _, i) -> user_initfun(i) end - return DiscreteCallback(c, as; initialize = initfn) + finfun = isnothing(user_finfun) ? SciMLBase.FINALIZE_DEFAULT : + (_, _, _, i) -> user_finfun(i) + return DiscreteCallback( + c, as; initialize = initfn, finalize = finfun, + initializealg = reinitialization_alg(cb)) end end function generate_discrete_callbacks(sys::AbstractSystem, dvs = unknowns(sys), - ps = parameters(sys); kwargs...) + ps = parameters(sys; initial_parameters = true); kwargs...) has_discrete_events(sys) || return nothing symcbs = discrete_events(sys) isempty(symcbs) && return nothing @@ -823,12 +1125,12 @@ merge_cb(x, ::Nothing) = x merge_cb(x, y) = CallbackSet(x, y) function process_events(sys; callback = nothing, kwargs...) - if has_continuous_events(sys) + if has_continuous_events(sys) && !isempty(continuous_events(sys)) contin_cb = generate_rootfinding_callback(sys; kwargs...) else contin_cb = nothing end - if has_discrete_events(sys) + if has_discrete_events(sys) && !isempty(discrete_events(sys)) discrete_cb = generate_discrete_callbacks(sys; kwargs...) else discrete_cb = nothing diff --git a/src/systems/clock_inference.jl b/src/systems/clock_inference.jl index a92b2aa67c..b535773061 100644 --- a/src/systems/clock_inference.jl +++ b/src/systems/clock_inference.jl @@ -8,8 +8,8 @@ end function ClockInference(ts::TransformationState) @unpack structure = ts @unpack graph = structure - eq_domain = TimeDomain[Continuous for _ in 1:nsrcs(graph)] - var_domain = TimeDomain[Continuous for _ in 1:ndsts(graph)] + 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) @@ -151,7 +151,7 @@ function split_system(ci::ClockInference{S}) where {S} get!(clock_to_id, d) do cid = (cid_counter[] += 1) push!(id_to_clock, d) - if d == Continuous + if d == ContinuousClock() continuous_id[] = cid end cid diff --git a/src/systems/codegen_utils.jl b/src/systems/codegen_utils.jl new file mode 100644 index 0000000000..a3fe53b95d --- /dev/null +++ b/src/systems/codegen_utils.jl @@ -0,0 +1,294 @@ +""" + $(TYPEDSIGNATURES) + +Return the name for the `i`th argument in a function generated by `build_function_wrapper`. +""" +function generated_argument_name(i::Int) + return Symbol(:__mtk_arg_, i) +end + +""" + $(TYPEDSIGNATURES) + +Given the arguments to `build_function_wrapper`, return a list of assignments which +reconstruct array variables if they are present scalarized in `args`. + +# Keyword Arguments + +- `argument_name` a function of the form `(::Int) -> Symbol` which takes the index of + an argument to the generated function and returns the name of the argument in the + generated function. +""" +function array_variable_assignments(args...; argument_name = generated_argument_name) + # map array symbolic to an identically sized array where each element is (buffer_idx, idx_in_buffer) + var_to_arridxs = Dict{BasicSymbolic, Array{Tuple{Int, Int}}}() + for (i, arg) in enumerate(args) + # filter out non-arrays + # any element of args which is not an array is assumed to not contain a + # scalarized array symbolic. This works because the only non-array element + # is the independent variable + symbolic_type(arg) == NotSymbolic() || continue + arg isa AbstractArray || continue + + # go through symbolics + for (j, var) in enumerate(arg) + var = unwrap(var) + # filter out non-array-symbolics + iscall(var) || continue + operation(var) == getindex || continue + arrvar = arguments(var)[1] + # get and/or construct the buffer storing indexes + idxbuffer = get!( + () -> map(Returns((0, 0)), eachindex(arrvar)), var_to_arridxs, arrvar) + Origin(first.(axes(arrvar))...)(idxbuffer)[arguments(var)[2:end]...] = (i, j) + end + end + + assignments = Assignment[] + for (arrvar, idxs) in var_to_arridxs + # all elements of the array need to be present in `args` to form the + # reconstructing assignment + any(iszero ∘ first, idxs) && continue + + # if they are all in the same buffer, we can take a shortcut and `view` into it + if allequal(Iterators.map(first, idxs)) + buffer_idx = first(first(idxs)) + idxs = map(last, idxs) + # if all the elements are contiguous and ordered, turn the array of indexes into a range + # to help reduce allocations + if first(idxs) < last(idxs) && vec(idxs) == first(idxs):last(idxs) + idxs = first(idxs):last(idxs) + elseif vec(idxs) == last(idxs):-1:first(idxs) + idxs = last(idxs):-1:first(idxs) + else + # Otherwise, turn the indexes into an `SArray` so they're stack-allocated + idxs = SArray{Tuple{size(idxs)...}}(idxs) + end + # view and reshape + + expr = term(reshape, term(view, argument_name(buffer_idx), idxs), + size(arrvar)) + else + elems = map(idxs) do idx + i, j = idx + term(getindex, argument_name(i), j) + end + # use `MakeArray` syntax and generate a stack-allocated array + expr = term(SymbolicUtils.Code.create_array, SArray, nothing, + Val(ndims(arrvar)), Val(length(arrvar)), elems...) + end + if any(x -> !isone(first(x)), axes(arrvar)) + expr = term(Origin(first.(axes(arrvar))...), expr) + end + push!(assignments, arrvar ← expr) + end + + return assignments +end + +""" + $(TYPEDSIGNATURES) + +A wrapper around `build_function` which performs the necessary transformations for +code generation of all types of systems. `expr` is the expression returned from the +generated functions, and `args` are the arguments. + +# Keyword Arguments + +- `p_start`, `p_end`: Denotes the indexes in `args` where the buffers of the splatted + `MTKParameters` object are present. These are collapsed into a single argument and + destructured inside the function. `p_start` must also be provided for non-split systems + since it is used by `wrap_delays`. +- `wrap_delays`: Whether to transform delayed unknowns of `sys` present in `expr` into + calls to a history function. The history function is added to the list of arguments + right before parameters, at the index `p_start`. +- `wrap_code`: Forwarded to `build_function`. +- `add_observed`: Whether to add assignment statements for observed equations in the + generated code. +- `filter_observed`: A predicate function to filter out observed equations which should + not be added to the generated code. +- `create_bindings`: Whether to explicitly destructure arrays of symbolics present in + `args` in the generated code. If `false`, all usages of the individual symbolics will + instead call `getindex` on the relevant argument. This is useful if the generated + function writes to one of its arguments and expects subsequent code to use the new + values. Note that the collapsed `MTKParameters` argument will always be explicitly + destructured regardless of this keyword argument. +- `output_type`: The type of the output buffer. If `mkarray` (see below) is `nothing`, + this will be passed to the `similarto` argument of `build_function`. If `output_type` + is `Tuple`, `expr` will be wrapped in `SymbolicUtils.Code.MakeTuple` (regardless of + whether it is scalar or an array). +- `mkarray`: A function which accepts `expr` and `output_type` and returns a code + generation object similar to `MakeArray` or `MakeTuple` to be used to generate + code for `expr`. +- `wrap_mtkparameters`: Whether to collapse parameter buffers for a split system into a + argument. +- `extra_assignments`: Extra `Assignment` statements to prefix to `expr`, after all other + assignments. + +All other keyword arguments are forwarded to `build_function`. +""" +function build_function_wrapper(sys::AbstractSystem, expr, args...; p_start = 2, + p_end = is_time_dependent(sys) ? length(args) - 1 : length(args), + wrap_delays = is_dde(sys), wrap_code = identity, + add_observed = true, filter_observed = Returns(true), + create_bindings = false, output_type = nothing, mkarray = nothing, + wrap_mtkparameters = true, extra_assignments = Assignment[], cse = true, kwargs...) + isscalar = !(expr isa AbstractArray || symbolic_type(expr) == ArraySymbolic()) + # filter observed equations + obs = filter(filter_observed, observed(sys)) + # turn delayed unknowns into calls to the history function + if wrap_delays + history_arg = is_split(sys) ? MTKPARAMETERS_ARG : generated_argument_name(p_start) + obs = map(obs) do eq + delay_to_function(sys, eq; history_arg) + end + expr = delay_to_function(sys, expr; history_arg) + # add extra argument + args = (args[1:(p_start - 1)]..., DDE_HISTORY_FUN, args[p_start:end]...) + p_start += 1 + p_end += 1 + end + pdeps = parameter_dependencies(sys) + # get the constants to add to the code + cmap, _ = get_cmap(sys) + extra_constants = collect_constants(expr) + filter!(extra_constants) do c + !any(x -> isequal(c, x.lhs), cmap) + end + for c in extra_constants + push!(cmap, c ~ getdefault(c)) + end + # only get the necessary observed equations, avoiding extra computation + if add_observed && !isempty(obs) + obsidxs = observed_equations_used_by(sys, expr; obs) + else + obsidxs = Int[] + end + # similarly for parameter dependency equations + pdepidxs = observed_equations_used_by(sys, expr; obs = pdeps) + for i in obsidxs + union!(pdepidxs, observed_equations_used_by(sys, obs[i].rhs; obs = pdeps)) + end + # assignments for reconstructing scalarized array symbolics + assignments = array_variable_assignments(args...) + + for eq in Iterators.flatten((cmap, pdeps[pdepidxs], obs[obsidxs])) + push!(assignments, eq.lhs ← eq.rhs) + end + append!(assignments, extra_assignments) + + args = ntuple(Val(length(args))) do i + arg = args[i] + # for time-dependent systems, all arguments are passed through `time_varying_as_func` + # TODO: This is legacy behavior and a candidate for removal in v10 since we have callable + # parameters now. + if is_time_dependent(sys) + arg = if symbolic_type(arg) == NotSymbolic() + arg isa AbstractArray ? + map(x -> time_varying_as_func(unwrap(x), sys), arg) : arg + else + time_varying_as_func(unwrap(arg), sys) + end + end + # Make sure to use the proper names for arguments + if symbolic_type(arg) == NotSymbolic() && arg isa AbstractArray + DestructuredArgs(arg, generated_argument_name(i); create_bindings) + else + arg + end + end + + # wrap into a single MTKParameters argument + if is_split(sys) && wrap_mtkparameters + if p_start > p_end + # In case there are no parameter buffers, still insert an argument + args = (args[1:(p_start - 1)]..., MTKPARAMETERS_ARG, args[(p_end + 1):end]...) + else + # cannot apply `create_bindings` here since it doesn't nest + args = (args[1:(p_start - 1)]..., + DestructuredArgs(collect(args[p_start:p_end]), MTKPARAMETERS_ARG), + args[(p_end + 1):end]...) + end + end + + # add preface assignments + if has_preface(sys) && (pref = preface(sys)) !== nothing + append!(assignments, pref) + end + + wrap_code = wrap_code .∘ wrap_assignments(isscalar, assignments) + + # handling of `output_type` and `mkarray` + similarto = nothing + if output_type === Tuple + expr = MakeTuple(Tuple(expr)) + wrap_code = wrap_code[1] + elseif mkarray === nothing + similarto = output_type + else + expr = mkarray(expr, output_type) + wrap_code = wrap_code[2] + end + + # scalar `build_function` only accepts a single function for `wrap_code`. + if wrap_code isa Tuple && symbolic_type(expr) == ScalarSymbolic() + wrap_code = wrap_code[1] + end + return build_function(expr, args...; wrap_code, similarto, cse, kwargs...) +end + +""" + $(TYPEDEF) + +A wrapper around a generated in-place and out-of-place function. The type-parameter `P` +must be a 3-tuple where the first element is the index of the parameter object in the +arguments, the second is the expected number of arguments in the out-of-place variant +of the function, and the third is a boolean indicating whether the generated functions +are for a split system. For scalar functions, the inplace variant can be `nothing`. +""" +struct GeneratedFunctionWrapper{P, O, I} <: Function + f_oop::O + f_iip::I +end + +function GeneratedFunctionWrapper{P}(foop::O, fiip::I) where {P, O, I} + GeneratedFunctionWrapper{P, O, I}(foop, fiip) +end + +function (gfw::GeneratedFunctionWrapper)(args...) + _generated_call(gfw, args...) +end + +@generated function _generated_call(gfw::GeneratedFunctionWrapper{P}, args...) where {P} + paramidx, nargs, issplit = P + iip = false + # IIP case has one more argument + if length(args) == nargs + 1 + nargs += 1 + paramidx += 1 + iip = true + end + if length(args) != nargs + throw(ArgumentError("Expected $nargs arguments, got $(length(args)).")) + end + + # the function to use + f = iip ? :(gfw.f_iip) : :(gfw.f_oop) + # non-split systems just call it as-is + if !issplit + return :($f(args...)) + end + if args[paramidx] <: Union{Tuple, MTKParameters} && + !(args[paramidx] <: Tuple{Vararg{Number}}) + # for split systems, call it as-is if the parameter object is a tuple or MTKParameters + # but not if it is a tuple of numbers + return :($f(args...)) + else + # The user provided a single buffer/tuple for the parameter object, so wrap that + # one in a tuple + fargs = ntuple(Val(length(args))) do i + i == paramidx ? :((args[$i], nothing)) : :(args[$i]) + end + return :($f($(fargs...))) + end +end diff --git a/src/systems/connectors.jl b/src/systems/connectors.jl index 227b4624bf..6b0600fbb7 100644 --- a/src/systems/connectors.jl +++ b/src/systems/connectors.jl @@ -68,6 +68,93 @@ SymbolicUtils.promote_symtype(::typeof(instream), _) = Real isconnector(s::AbstractSystem) = has_connector_type(s) && get_connector_type(s) !== nothing +""" + $(TYPEDEF) + +Utility struct which wraps a symbolic variable used in a `Connection` to enable `Base.show` +to work. +""" +struct SymbolicWithNameof + var::Any +end + +function Base.nameof(x::SymbolicWithNameof) + return Symbol(x.var) +end + +is_causal_variable_connection(c) = false +function is_causal_variable_connection(c::Connection) + all(x -> x isa SymbolicWithNameof, get_systems(c)) +end + +const ConnectableSymbolicT = Union{BasicSymbolic, Num, Symbolics.Arr} + +const CAUSAL_CONNECTION_ERR = """ +Only causal variables can be used in a `connect` statement. The first argument must \ +be a single output variable and all subsequent variables must be input variables. +""" + +function VariableNotOutputError(var) + ArgumentError(""" + $CAUSAL_CONNECTION_ERR Expected $var to be marked as an output with `[output = true]` \ + in the variable metadata. + """) +end + +function VariableNotInputError(var) + ArgumentError(""" + $CAUSAL_CONNECTION_ERR Expected $var to be marked an input with `[input = true]` \ + in the variable metadata. + """) +end + +""" + $(TYPEDSIGNATURES) + +Perform validation for a connect statement involving causal variables. +""" +function validate_causal_variables_connection(allvars) + var1 = allvars[1] + var2 = allvars[2] + vars = Base.tail(Base.tail(allvars)) + for var in allvars + vtype = getvariabletype(var) + vtype === VARIABLE || + throw(ArgumentError("Expected $var to be of kind `$VARIABLE`. Got `$vtype`.")) + end + if length(unique(allvars)) !== length(allvars) + throw(ArgumentError("Expected all connection variables to be unique. Got variables $allvars which contains duplicate entries.")) + end + allsizes = map(size, allvars) + if !allequal(allsizes) + throw(ArgumentError("Expected all connection variables to have the same size. Got variables $allvars with sizes $allsizes respectively.")) + end + isoutput(var1) || throw(VariableNotOutputError(var1)) + isinput(var2) || throw(VariableNotInputError(var2)) + for var in vars + isinput(var) || throw(VariableNotInputError(var)) + end +end + +""" + $(TYPEDSIGNATURES) + +Connect multiple causal variables. The first variable must be an output, and all subsequent +variables must be inputs. The statement `connect(var1, var2, var3, ...)` expands to: + +```julia +var1 ~ var2 +var1 ~ var3 +# ... +``` +""" +function Symbolics.connect(var1::ConnectableSymbolicT, var2::ConnectableSymbolicT, + vars::ConnectableSymbolicT...) + allvars = (var1, var2, vars...) + validate_causal_variables_connection(allvars) + return Equation(Connection(), Connection(map(SymbolicWithNameof, unwrap.(allvars)))) +end + function flowvar(sys::AbstractSystem) sts = get_unknowns(sys) for s in sts @@ -228,7 +315,32 @@ function ori(sys) end end -function connection2set!(connectionsets, namespace, ss, isouter) +""" + $(TYPEDSIGNATURES) + +Populate `connectionsets` with connections between the connectors `ss`, all of which are +namespaced by `namespace`. + +# Keyword Arguments +- `ignored_connects`: A tuple of the systems and variables for which connections should be + ignored. Of the format returned from `as_hierarchy`. +- `namespaced_ignored_systems`: The `from_hierarchy` versions of entries in + `ignored_connects[1]`, purely to avoid unnecessary recomputation. +""" +function connection2set!(connectionsets, namespace, ss, isouter; + ignored_systems = HierarchySystemT[], ignored_variables = HierarchyVariableT[]) + ns_ignored_systems = from_hierarchy.(ignored_systems) + ns_ignored_variables = from_hierarchy.(ignored_variables) + # ignore specified systems + ss = filter(ss) do s + !any(x -> nameof(x) == nameof(s), ns_ignored_systems) + end + # `ignored_variables` for each `s` in `ss` + corresponding_ignored_variables = map( + Base.Fix2(ignored_systems_for_subsystem, ignored_variables), ss) + corresponding_namespaced_ignored_variables = map( + Broadcast.BroadcastFunction(from_hierarchy), corresponding_ignored_variables) + regular_ss = [] domain_ss = nothing for s in ss @@ -253,9 +365,12 @@ function connection2set!(connectionsets, namespace, ss, isouter) for (i, s) in enumerate(ss) sts = unknowns(s) io = isouter(s) - for (j, v) in enumerate(sts) + _ignored_variables = corresponding_ignored_variables[i] + _namespaced_ignored_variables = corresponding_namespaced_ignored_variables[i] + for v in sts vtype = get_connection_type(v) (vtype === Flow && isequal(v, dv)) || continue + any(isequal(v), _namespaced_ignored_variables) && continue push!(cset, T(LazyNamespace(namespace, domain_ss), dv, false)) push!(cset, T(LazyNamespace(namespace, s), v, io)) end @@ -273,6 +388,12 @@ function connection2set!(connectionsets, namespace, ss, isouter) end sts1 = Set(sts1v) num_unknowns = length(sts1) + + # we don't filter here because `csets` should include the full set of unknowns. + # not all of `ss` will have the same (or any) variables filtered so the ones + # that aren't should still go in the right cset. Since `sts1` is only used for + # validating that all systems being connected are of the same type, it has + # unfiltered entries. csets = [T[] for _ in 1:num_unknowns] # Add 9 orientation variables if connection is between multibody frames for (i, s) in enumerate(ss) unknown_vars = unknowns(s) @@ -285,7 +406,10 @@ function connection2set!(connectionsets, namespace, ss, isouter) all(Base.Fix2(in, sts1), unknown_vars)) || connection_error(ss)) io = isouter(s) + # don't `filter!` here so that `j` points to the correct cset regardless of + # which variables are filtered. for (j, v) in enumerate(unknown_vars) + any(isequal(v), corresponding_namespaced_ignored_variables[i]) && continue push!(csets[j], T(LazyNamespace(namespace, s), v, io)) end end @@ -308,16 +432,114 @@ function generate_connection_set( connectionsets = ConnectionSet[] domain_csets = ConnectionSet[] sys = generate_connection_set!( - connectionsets, domain_csets, sys, find, replace, scalarize) + connectionsets, domain_csets, sys, find, replace, scalarize, nothing, ignored_connections(sys)) csets = merge(connectionsets) domain_csets = merge([csets; domain_csets], true) sys, (csets, domain_csets) end +""" + $(TYPEDSIGNATURES) + +For a list of `systems` in a connect equation, return the subset of it to ignore (as a +list of hierarchical systems) based on `ignored_system_aps`, the analysis points to be +ignored. All analysis points in `ignored_system_aps` must contain systems (connectors) +as their input/outputs. +""" +function systems_to_ignore(ignored_system_aps::Vector{HierarchyAnalysisPointT}, + systems::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: AbstractSystem} + to_ignore = HierarchySystemT[] + for ap in ignored_system_aps + # if `systems` contains the input of the AP, ignore any outputs of the AP present in it. + isys_hierarchy = HierarchySystemT([ap[1].input; @view ap[2:end]]) + isys = from_hierarchy(isys_hierarchy) + any(x -> nameof(x) == nameof(isys), systems) || continue + + for outsys in ap[1].outputs + osys_hierarchy = HierarchySystemT([outsys; @view ap[2:end]]) + osys = from_hierarchy(osys_hierarchy) + any(x -> nameof(x) == nameof(osys), systems) || continue + push!(to_ignore, HierarchySystemT(osys_hierarchy)) + end + end + + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +For a list of `systems` in a connect equation, return the subset of their variables to +ignore (as a list of hierarchical variables) based on `ignored_system_aps`, the analysis +points to be ignored. All analysis points in `ignored_system_aps` must contain variables +as their input/outputs. +""" +function variables_to_ignore(ignored_variable_aps::Vector{HierarchyAnalysisPointT}, + systems::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: AbstractSystem} + to_ignore = HierarchyVariableT[] + for ap in ignored_variable_aps + ivar_hierarchy = HierarchyVariableT([ap[1].input; @view ap[2:end]]) + ivar = from_hierarchy(ivar_hierarchy) + any(x -> any(isequal(ivar), renamespace.((x,), unknowns(x))), systems) || continue + + for outvar in ap[1].outputs + ovar_hierarchy = HierarchyVariableT([as_hierarchy(outvar); @view ap[2:end]]) + ovar = from_hierarchy(ovar_hierarchy) + any(x -> any(isequal(ovar), renamespace.((x,), unknowns(x))), systems) || + continue + push!(to_ignore, HierarchyVariableT(ovar_hierarchy)) + end + end + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +For a list of variables `vars` in a connect equation, return the subset of them ignore +(as a list of symbolic variables) based on `ignored_system_aps`, the analysis points to +be ignored. All analysis points in `ignored_system_aps` must contain variables as their +input/outputs. +""" +function variables_to_ignore(ignored_variable_aps::Vector{HierarchyAnalysisPointT}, + vars::Union{Vector{S}, Tuple{Vararg{S}}}) where {S <: BasicSymbolic} + to_ignore = eltype(vars)[] + for ap in ignored_variable_aps + ivar_hierarchy = HierarchyVariableT([ap[1].input; @view ap[2:end]]) + ivar = from_hierarchy(ivar_hierarchy) + any(isequal(ivar), vars) || continue + + for outvar in ap[1].outputs + ovar_hierarchy = HierarchyVariableT([outvar; @view ap[2:end]]) + ovar = from_hierarchy(ovar_hierarchy) + any(isequal(ovar), vars) || continue + push!(to_ignore, ovar) + end + end + + return to_ignore +end + +""" + $(TYPEDSIGNATURES) + +Generate connection sets from `connect` equations. + +# Arguments + +- `connectionsets` is the list of connection sets to be populated by recursively + descending `sys`. +- `domain_csets` is the list of connection sets for domain connections. +- `sys` is the system whose equations are to be searched. +- `namespace` is a system representing the namespace in which `sys` exists, or `nothing` + for no namespace (if `sys` is top-level). +""" function generate_connection_set!(connectionsets, domain_csets, - sys::AbstractSystem, find, replace, scalarize, namespace = nothing) + sys::AbstractSystem, find, replace, scalarize, namespace = nothing, + ignored_connects = (HierarchyAnalysisPointT[], HierarchyAnalysisPointT[])) subsys = get_systems(sys) + ignored_system_aps, ignored_variable_aps = ignored_connects isouter = generate_isouter(sys) eqs′ = get_eqs(sys) @@ -329,6 +551,10 @@ function generate_connection_set!(connectionsets, domain_csets, for eq in eqs′ lhs = eq.lhs rhs = eq.rhs + + # causal variable connections will be expanded before we get here, + # but this guard is useful for `n_expanded_connection_equations`. + is_causal_variable_connection(rhs) && continue if find !== nothing && find(rhs, _getname(namespace)) neweq, extra_unknown = replace(rhs, _getname(namespace)) if extra_unknown isa AbstractArray @@ -339,7 +565,12 @@ function generate_connection_set!(connectionsets, domain_csets, neweq isa AbstractArray ? append!(eqs, neweq) : push!(eqs, neweq) else if lhs isa Connection && get_systems(lhs) === :domain - connection2set!(domain_csets, namespace, get_systems(rhs), isouter) + connected_systems = get_systems(rhs) + connection2set!(domain_csets, namespace, connected_systems, isouter; + ignored_systems = systems_to_ignore( + ignored_system_aps, connected_systems), + ignored_variables = variables_to_ignore( + ignored_variable_aps, connected_systems)) elseif isconnection(rhs) push!(cts, get_systems(rhs)) else @@ -355,6 +586,7 @@ function generate_connection_set!(connectionsets, domain_csets, # all connectors are eventually inside connectors. T = ConnectionElement + # only generate connection sets for systems that are not ignored for s in subsys isconnector(s) || continue is_domain_connector(s) && continue @@ -365,7 +597,9 @@ function generate_connection_set!(connectionsets, domain_csets, end for ct in cts - connection2set!(connectionsets, namespace, ct, isouter) + connection2set!(connectionsets, namespace, ct, isouter; + ignored_systems = systems_to_ignore(ignored_system_aps, ct), + ignored_variables = variables_to_ignore(ignored_variable_aps, ct)) end # pre order traversal @@ -374,12 +608,39 @@ function generate_connection_set!(connectionsets, domain_csets, end @set! sys.systems = map( s -> generate_connection_set!(connectionsets, domain_csets, s, - find, replace, scalarize, - renamespace(namespace, s)), + find, replace, scalarize, renamespace(namespace, s), + ignored_systems_for_subsystem.((s,), ignored_connects)), subsys) @set! sys.eqs = eqs end +""" + $(TYPEDSIGNATURES) + +Given a subsystem `subsys` of a parent system and a list of systems (variables) to be +ignored by `generate_connection_set!` (`expand_variable_connections`), filter +`ignored_systems` to only include those present in the subtree of `subsys` and update +their hierarchy to not include `subsys`. +""" +function ignored_systems_for_subsystem( + subsys::AbstractSystem, ignored_systems::Vector{<:Union{ + HierarchyT, HierarchyAnalysisPointT}}) + result = eltype(ignored_systems)[] + # in case `subsys` is namespaced, get its hierarchy and compare suffixes + # instead of the just the last element + suffix = reverse!(namespace_hierarchy(nameof(subsys))) + N = length(suffix) + for igsys in ignored_systems + if length(igsys) > N && igsys[(end - N + 1):end] == suffix + push!(result, copy(igsys)) + for i in 1:N + pop!(result[end]) + end + end + end + return result +end + function Base.merge(csets::AbstractVector{<:ConnectionSet}, allouter = false) ele2idx = Dict{ConnectionElement, Int}() idx2ele = ConnectionElement[] @@ -479,8 +740,49 @@ function domain_defaults(sys, domain_csets) def end +""" + $(TYPEDSIGNATURES) + +Recursively descend through the hierarchy of `sys` and expand all connection equations +of causal variables. Return the modified system. +""" +function expand_variable_connections(sys::AbstractSystem; ignored_variables = nothing) + if ignored_variables === nothing + ignored_variables = ignored_connections(sys)[2] + end + eqs = copy(get_eqs(sys)) + valid_idxs = trues(length(eqs)) + additional_eqs = Equation[] + + for (i, eq) in enumerate(eqs) + eq.lhs isa Connection || continue + connection = eq.rhs + elements = get_systems(connection) + is_causal_variable_connection(connection) || continue + + valid_idxs[i] = false + elements = map(x -> x.var, elements) + to_ignore = variables_to_ignore(ignored_variables, elements) + elements = setdiff(elements, to_ignore) + outvar = first(elements) + for invar in Iterators.drop(elements, 1) + push!(additional_eqs, outvar ~ invar) + end + end + eqs = [eqs[valid_idxs]; additional_eqs] + subsystems = map(get_systems(sys)) do subsys + expand_variable_connections(subsys; + ignored_variables = ignored_systems_for_subsystem(subsys, ignored_variables)) + end + @set! sys.eqs = eqs + @set! sys.systems = subsystems + return sys +end + function expand_connections(sys::AbstractSystem, find = nothing, replace = nothing; debug = false, tol = 1e-10, scalarize = true) + sys = remove_analysis_points(sys) + sys = expand_variable_connections(sys) sys, (csets, domain_csets) = generate_connection_set(sys, find, replace; scalarize) ceqs, instream_csets = generate_connection_equations_and_stream_connections(csets) _sys = expand_instream(instream_csets, sys; debug = debug, tol = tol) diff --git a/src/systems/diffeqs/abstractodesystem.jl b/src/systems/diffeqs/abstractodesystem.jl index d8085d7e33..62ddd12a08 100644 --- a/src/systems/diffeqs/abstractodesystem.jl +++ b/src/systems/diffeqs/abstractodesystem.jl @@ -3,6 +3,29 @@ struct Schedule dummy_sub::Any end +""" + is_dde(sys::AbstractSystem) + +Return a boolean indicating whether a system represents a set of delay +differential equations. +""" +is_dde(sys::AbstractSystem) = has_is_dde(sys) && get_is_dde(sys) + +function _check_if_dde(eqs, iv, subsystems) + is_dde = any(ModelingToolkit.is_dde, subsystems) + if !is_dde + vs = Set() + for eq in eqs + vars!(vs, eq) + is_dde = any(vs) do sym + isdelay(unwrap(sym), iv) + end + is_dde && break + end + end + return is_dde +end + function filter_kwargs(kwargs) kwargs = Dict(kwargs) for key in keys(kwargs) @@ -48,10 +71,17 @@ function calculate_jacobian(sys::AbstractODESystem; rhs = [eq.rhs - eq.lhs for eq in full_equations(sys)] #need du terms on rhs for differentiating wrt du - iv = get_iv(sys) - if sparse jac = sparsejacobian(rhs, dvs, simplify = simplify) + W_s = W_sparsity(sys) + (Is, Js, Vs) = findnz(W_s) + # Add nonzeros of W as non-structural zeros of the Jacobian (to ensure equal results for oop and iip Jacobian.) + for (i, j) in zip(Is, Js) + iszero(jac[i, j]) && begin + jac[i, j] = 1 + jac[i, j] = 0 + end + end else jac = jacobian(rhs, dvs, simplify = simplify) end @@ -71,8 +101,6 @@ function calculate_control_jacobian(sys::AbstractODESystem; end rhs = [eq.rhs for eq in full_equations(sys)] - - iv = get_iv(sys) ctrls = controls(sys) if sparse @@ -86,59 +114,71 @@ function calculate_control_jacobian(sys::AbstractODESystem; end function generate_tgrad( - sys::AbstractODESystem, dvs = unknowns(sys), ps = parameters(sys); - simplify = false, wrap_code = identity, kwargs...) + sys::AbstractODESystem, dvs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + simplify = false, kwargs...) tgrad = calculate_tgrad(sys, simplify = simplify) - pre = get_preprocess_constants(tgrad) - p = if has_index_cache(sys) && get_index_cache(sys) !== nothing - reorder_parameters(get_index_cache(sys), ps) - elseif ps isa Tuple - ps - else - (ps,) - end - wrap_code = wrap_code .∘ wrap_array_vars(sys, tgrad; dvs, ps) .∘ - wrap_parameter_dependencies(sys, !(tgrad isa AbstractArray)) - return build_function(tgrad, + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, tgrad, dvs, p..., get_iv(sys); - postprocess_fbody = pre, - wrap_code, kwargs...) end function generate_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); - simplify = false, sparse = false, wrap_code = identity, kwargs...) + ps = parameters(sys; initial_parameters = true); + simplify = false, sparse = false, kwargs...) jac = calculate_jacobian(sys; simplify = simplify, sparse = sparse) - pre = get_preprocess_constants(jac) - p = if has_index_cache(sys) && get_index_cache(sys) !== nothing - reorder_parameters(get_index_cache(sys), ps) - else - (ps,) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, + dvs, + p..., + get_iv(sys); + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity), + kwargs...) +end + +function assert_jac_length_header(sys) + W = W_sparsity(sys) + identity, + function add_header(expr) + Func(expr.args, [], expr.body, + [:(@assert $(SymbolicUtils.Code.toexpr(term(findnz, expr.args[1])))[1:2] == + $(findnz(W)[1:2]))]) end - wrap_code = wrap_code .∘ wrap_array_vars(sys, jac; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function(jac, +end + +function generate_W(sys::AbstractODESystem, γ = 1.0, dvs = unknowns(sys), + ps = parameters(sys; initial_parameters = true); + simplify = false, sparse = false, kwargs...) + @variables ˍ₋gamma + M = calculate_massmatrix(sys; simplify) + sparse && (M = SparseArrays.sparse(M)) + J = calculate_jacobian(sys; simplify, sparse, dvs) + W = ˍ₋gamma * M + J + + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, W, dvs, p..., + ˍ₋gamma, get_iv(sys); - postprocess_fbody = pre, - wrap_code, + wrap_code = sparse ? assert_jac_length_header(sys) : (identity, identity), + p_end = 1 + length(p), kwargs...) end function generate_control_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); + ps = parameters(sys; initial_parameters = true); simplify = false, sparse = false, kwargs...) jac = calculate_control_jacobian(sys; simplify = simplify, sparse = sparse) p = reorder_parameters(sys, ps) - return build_function(jac, dvs, p..., get_iv(sys); kwargs...) + return build_function_wrapper(sys, jac, dvs, p..., get_iv(sys); kwargs...) end function generate_dae_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); simplify = false, sparse = false, + ps = parameters(sys; initial_parameters = true); simplify = false, sparse = false, kwargs...) jac_u = calculate_jacobian(sys; simplify = simplify, sparse = sparse) derivatives = Differential(get_iv(sys)).(unknowns(sys)) @@ -148,69 +188,47 @@ function generate_dae_jacobian(sys::AbstractODESystem, dvs = unknowns(sys), @variables ˍ₋gamma jac = ˍ₋gamma * jac_du + jac_u pre = get_preprocess_constants(jac) - p = if has_index_cache(sys) && get_index_cache(sys) !== nothing - reorder_parameters(get_index_cache(sys), ps) - else - (ps,) - end - return build_function(jac, derivatives, dvs, p..., ˍ₋gamma, get_iv(sys); - postprocess_fbody = pre, kwargs...) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, jac, derivatives, dvs, p..., ˍ₋gamma, get_iv(sys); + p_start = 3, p_end = 2 + length(p), kwargs...) end function generate_function(sys::AbstractODESystem, dvs = unknowns(sys), - ps = parameters(sys); + ps = parameters(sys; initial_parameters = true); implicit_dae = false, ddvs = implicit_dae ? map(Differential(get_iv(sys)), dvs) : nothing, isdde = false, - wrap_code = identity, kwargs...) - if isdde - eqs = delay_to_function(sys) - else - eqs = [eq for eq in equations(sys)] - end + eqs = [eq for eq in equations(sys)] if !implicit_dae check_operator_variables(eqs, Differential) check_lhs(eqs, Differential, Set(dvs)) end - # substitute constants in - eqs = map(subs_constants, 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] + if !isempty(assertions(sys)) + rhss[end] += unwrap(get_assertions_expr(sys)) + end + # 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), reorder_parameters(sys, ps)) + u = dvs + p = reorder_parameters(sys, ps) t = get_iv(sys) - if isdde - build_function(rhss, u, DDE_HISTORY_FUN, p..., t; kwargs...) + if implicit_dae + build_function_wrapper(sys, rhss, ddvs, u, p..., t; p_start = 3, kwargs...) else - pre, sol_states = get_substitutions_and_solved_unknowns(sys) - - if implicit_dae - build_function(rhss, ddvs, u, p..., t; postprocess_fbody = pre, - states = sol_states, - wrap_code = wrap_code .∘ wrap_array_vars(sys, rhss; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false), - kwargs...) - else - build_function(rhss, u, p..., t; postprocess_fbody = pre, - states = sol_states, - wrap_code = wrap_code .∘ wrap_array_vars(sys, rhss; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false), - kwargs...) - end + build_function_wrapper(sys, rhss, u, p..., t; kwargs...) end end function isdelay(var, iv) iv === nothing && return false isvariable(var) || return false + isparameter(var) && return false if iscall(var) && !ModelingToolkit.isoperator(var, Symbolics.Operator) args = arguments(var) length(args) == 1 || return false @@ -219,29 +237,32 @@ function isdelay(var, iv) return false end const DDE_HISTORY_FUN = Sym{Symbolics.FnType{Tuple{Any, <:Real}, Vector{Real}}}(:___history___) -function delay_to_function(sys::AbstractODESystem, eqs = full_equations(sys)) +const DEFAULT_PARAMS_ARG = Sym{Any}(:ˍ₋arg3) +function delay_to_function( + sys::AbstractODESystem, eqs = full_equations(sys); history_arg = DEFAULT_PARAMS_ARG) delay_to_function(eqs, get_iv(sys), Dict{Any, Int}(operation(s) => i for (i, s) in enumerate(unknowns(sys))), parameters(sys), - DDE_HISTORY_FUN) + DDE_HISTORY_FUN; history_arg) end -function delay_to_function(eqs::Vector, iv, sts, ps, h) - delay_to_function.(eqs, (iv,), (sts,), (ps,), (h,)) +function delay_to_function(eqs::Vector, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) + delay_to_function.(eqs, (iv,), (sts,), (ps,), (h,); history_arg) end -function delay_to_function(eq::Equation, iv, sts, ps, h) - delay_to_function(eq.lhs, iv, sts, ps, h) ~ delay_to_function(eq.rhs, iv, sts, ps, h) +function delay_to_function(eq::Equation, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) + delay_to_function(eq.lhs, iv, sts, ps, h; history_arg) ~ delay_to_function( + eq.rhs, iv, sts, ps, h; history_arg) end -function delay_to_function(expr, iv, sts, ps, h) +function delay_to_function(expr, iv, sts, ps, h; history_arg = DEFAULT_PARAMS_ARG) if isdelay(expr, iv) v = operation(expr) time = arguments(expr)[1] idx = sts[v] - return term(getindex, h(Sym{Any}(:ˍ₋arg3), time), idx, type = Real) # BIG BIG HACK + return term(getindex, h(history_arg, time), idx, type = Real) # BIG BIG HACK elseif iscall(expr) return maketerm(typeof(expr), operation(expr), - map(x -> delay_to_function(x, iv, sts, ps, h), arguments(expr)), + map(x -> delay_to_function(x, iv, sts, ps, h; history_arg), arguments(expr)), metadata(expr)) else return expr @@ -283,6 +304,15 @@ function jacobian_dae_sparsity(sys::AbstractODESystem) J1 + J2 end +function W_sparsity(sys::AbstractODESystem) + jac_sparsity = jacobian_sparsity(sys) + (n, n) = size(jac_sparsity) + M = calculate_massmatrix(sys) + M_sparsity = M isa UniformScaling ? sparse(I(n)) : + SparseMatrixCSC{Bool, Int64}((!iszero).(M)) + jac_sparsity .| M_sparsity +end + function isautonomous(sys::AbstractODESystem) tgrad = calculate_tgrad(sys; simplify = true) all(iszero, tgrad) @@ -330,25 +360,17 @@ function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, sparsity = false, analytic = nothing, split_idxs = nothing, - initializeprob = nothing, - initializeprobmap = nothing, + initialization_data = nothing, + cse = true, kwargs...) where {iip, specialize} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEFunction`") end f_gen = generate_function(sys, dvs, ps; expression = Val{true}, - expression_module = eval_module, checkbounds = checkbounds, + expression_module = eval_module, checkbounds = checkbounds, cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) - - f(u, p, t) = f_oop(u, p, t) - f(du, u, p, t) = f_iip(du, u, p, t) - f(u, p::Tuple{Vararg{Number}}, t) = f_oop(u, p, t) - f(du, u, p::Tuple{Vararg{Number}}, t) = f_iip(du, u, p, t) - f(u, p::Tuple, t) = f_oop(u, p..., t) - f(du, u, p::Tuple, t) = f_iip(du, u, p..., t) - f(u, p::MTKParameters, t) = f_oop(u, p..., t) - f(du, u, p::MTKParameters, t) = f_iip(du, u, p..., t) + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) if specialize === SciMLBase.FunctionWrapperSpecialize && iip if u0 === nothing || p === nothing || t === nothing @@ -361,19 +383,10 @@ function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, tgrad_gen = generate_tgrad(sys, dvs, ps; simplify = simplify, expression = Val{true}, - expression_module = eval_module, + expression_module = eval_module, cse, checkbounds = checkbounds, kwargs...) tgrad_oop, tgrad_iip = eval_or_rgf.(tgrad_gen; eval_expression, eval_module) - - if p isa Tuple - __tgrad(u, p, t) = tgrad_oop(u, p..., t) - __tgrad(J, u, p, t) = tgrad_iip(J, u, p..., t) - _tgrad = __tgrad - else - ___tgrad(u, p, t) = tgrad_oop(u, p, t) - ___tgrad(J, u, p, t) = tgrad_iip(J, u, p, t) - _tgrad = ___tgrad - end + _tgrad = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(tgrad_oop, tgrad_iip) else _tgrad = nothing end @@ -382,18 +395,11 @@ function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, jac_gen = generate_jacobian(sys, dvs, ps; simplify = simplify, sparse = sparse, expression = Val{true}, - expression_module = eval_module, + expression_module = eval_module, cse, checkbounds = checkbounds, kwargs...) jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) - _jac(u, p, t) = jac_oop(u, p, t) - _jac(J, u, p, t) = jac_iip(J, u, p, t) - _jac(u, p::Tuple{Vararg{Number}}, t) = jac_oop(u, p, t) - _jac(J, u, p::Tuple{Vararg{Number}}, t) = jac_iip(J, u, p, t) - _jac(u, p::Tuple, t) = jac_oop(u, p..., t) - _jac(J, u, p::Tuple, t) = jac_iip(J, u, p..., t) - _jac(u, p::MTKParameters, t) = jac_oop(u, p..., t) - _jac(J, u, p::MTKParameters, t) = jac_iip(J, u, p..., t) + _jac = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(jac_oop, jac_iip) else _jac = nothing end @@ -408,17 +414,14 @@ function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, ArrayInterface.restructure(u0 .* u0', M) end - observedfun = ObservedFunctionCache(sys; steady_state, eval_expression, eval_module) + observedfun = ObservedFunctionCache( + sys; steady_state, eval_expression, eval_module, checkbounds, cse) - jac_prototype = if sparse + if sparse uElType = u0 === nothing ? Float64 : eltype(u0) - if jac - similar(calculate_jacobian(sys, sparse = sparse), uElType) - else - similar(jacobian_sparsity(sys), uElType) - end + W_prototype = similar(W_sparsity(sys), uElType) else - nothing + W_prototype = nothing end @set! sys.split_idxs = split_idxs @@ -428,12 +431,11 @@ function DiffEqBase.ODEFunction{iip, specialize}(sys::AbstractODESystem, jac = _jac === nothing ? nothing : _jac, tgrad = _tgrad === nothing ? nothing : _tgrad, mass_matrix = _M, - jac_prototype = jac_prototype, + jac_prototype = W_prototype, observed = observedfun, - sparsity = sparsity ? jacobian_sparsity(sys) : nothing, + sparsity = sparsity ? W_sparsity(sys) : nothing, analytic = analytic, - initializeprob = initializeprob, - initializeprobmap = initializeprobmap) + initialization_data) end """ @@ -463,40 +465,34 @@ function DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys) sparse = false, simplify = false, eval_module = @__MODULE__, checkbounds = false, - initializeprob = nothing, - initializeprobmap = nothing, + initialization_data = nothing, + cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEFunction`") end f_gen = generate_function(sys, dvs, ps; implicit_dae = true, - expression = Val{true}, + expression = Val{true}, cse, expression_module = eval_module, checkbounds = checkbounds, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) - f(du, u, p, t) = f_oop(du, u, p, t) - f(du, u, p::MTKParameters, t) = f_oop(du, u, p..., t) - f(out, du, u, p, t) = f_iip(out, du, u, p, t) - f(out, du, u, p::MTKParameters, t) = f_iip(out, du, u, p..., t) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) if jac jac_gen = generate_dae_jacobian(sys, dvs, ps; simplify = simplify, sparse = sparse, expression = Val{true}, - expression_module = eval_module, + expression_module = eval_module, cse, checkbounds = checkbounds, kwargs...) jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) - _jac(du, u, p, ˍ₋gamma, t) = jac_oop(du, u, p, ˍ₋gamma, t) - _jac(du, u, p::MTKParameters, ˍ₋gamma, t) = jac_oop(du, u, p..., ˍ₋gamma, t) - - _jac(J, du, u, p, ˍ₋gamma, t) = jac_iip(J, du, u, p, ˍ₋gamma, t) - _jac(J, du, u, p::MTKParameters, ˍ₋gamma, t) = jac_iip(J, du, u, p..., ˍ₋gamma, t) + _jac = GeneratedFunctionWrapper{(3, 5, is_split(sys))}(jac_oop, jac_iip) else _jac = nothing end - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) jac_prototype = if sparse uElType = u0 === nothing ? Float64 : eltype(u0) @@ -512,13 +508,12 @@ function DiffEqBase.DAEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys) nothing end - DAEFunction{iip}(f, + DAEFunction{iip}(f; sys = sys, jac = _jac === nothing ? nothing : _jac, jac_prototype = jac_prototype, observed = observedfun, - initializeprob = initializeprob, - initializeprobmap = initializeprobmap) + initialization_data) end function DiffEqBase.DDEFunction(sys::AbstractODESystem, args...; kwargs...) @@ -530,6 +525,8 @@ function DiffEqBase.DDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys) eval_expression = false, eval_module = @__MODULE__, checkbounds = false, + initialization_data = nothing, + cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `DDEFunction`") @@ -537,14 +534,11 @@ function DiffEqBase.DDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys) f_gen = generate_function(sys, dvs, ps; isdde = true, expression = Val{true}, expression_module = eval_module, checkbounds = checkbounds, - kwargs...) + cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) - f(u, h, p, t) = f_oop(u, h, p, t) - f(u, h, p::MTKParameters, t) = f_oop(u, h, p..., t) - f(du, u, h, p, t) = f_iip(du, u, h, p, t) - f(du, u, h, p::MTKParameters, t) = f_iip(du, u, h, p..., t) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) - DDEFunction{iip}(f, sys = sys) + DDEFunction{iip}(f; sys = sys, initialization_data) end function DiffEqBase.SDDEFunction(sys::AbstractODESystem, args...; kwargs...) @@ -556,6 +550,8 @@ function DiffEqBase.SDDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys eval_expression = false, eval_module = @__MODULE__, checkbounds = false, + initialization_data = nothing, + cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `SDDEFunction`") @@ -563,21 +559,16 @@ function DiffEqBase.SDDEFunction{iip}(sys::AbstractODESystem, dvs = unknowns(sys f_gen = generate_function(sys, dvs, ps; isdde = true, expression = Val{true}, expression_module = eval_module, checkbounds = checkbounds, - kwargs...) + cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(f_oop, f_iip) + g_gen = generate_diffusion_function(sys, dvs, ps; expression = Val{true}, - isdde = true, kwargs...) + isdde = true, cse, kwargs...) g_oop, g_iip = eval_or_rgf.(g_gen; eval_expression, eval_module) - f(u, h, p, t) = f_oop(u, h, p, t) - f(u, h, p::MTKParameters, t) = f_oop(u, h, p..., t) - f(du, u, h, p, t) = f_iip(du, u, h, p, t) - f(du, u, h, p::MTKParameters, t) = f_iip(du, u, h, p..., t) - g(u, h, p, t) = g_oop(u, h, p, t) - g(u, h, p::MTKParameters, t) = g_oop(u, h, p..., t) - g(du, u, h, p, t) = g_iip(du, u, h, p, t) - g(du, u, h, p::MTKParameters, t) = g_iip(du, u, h, p..., t) + g = GeneratedFunctionWrapper{(3, 4, is_split(sys))}(g_oop, g_iip) - SDDEFunction{iip}(f, g, sys = sys) + SDDEFunction{iip}(f, g; sys = sys, initialization_data) end """ @@ -594,18 +585,9 @@ 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) -(f::ODEFunctionClosure)(u, p::MTKParameters, t) = f.f_oop(u, p..., t) -(f::ODEFunctionClosure)(du, u, p::MTKParameters, t) = f.f_iip(du, u, p..., t) +struct ODEFunctionExpr{iip, specialize} end -function ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), +function ODEFunctionExpr{iip, specialize}(sys::AbstractODESystem, dvs = unknowns(sys), ps = parameters(sys), u0 = nothing; version = nothing, tgrad = false, jac = false, p = nothing, @@ -614,22 +596,21 @@ function ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), steady_state = false, sparsity = false, observedfun_exp = nothing, - kwargs...) where {iip} + kwargs...) where {iip, specialize} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEFunctionExpr`") end f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) - dict = Dict() - fsym = gensym(:f) - _f = :($fsym = $ODEFunctionClosure($f_oop, $f_iip)) + _f = :($fsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})($f_oop, $f_iip)) tgradsym = gensym(:tgrad) if tgrad tgrad_oop, tgrad_iip = generate_tgrad(sys, dvs, ps; simplify = simplify, expression = Val{true}, kwargs...) - _tgrad = :($tgradsym = $ODEFunctionClosure($tgrad_oop, $tgrad_iip)) + _tgrad = :($tgradsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})( + $tgrad_oop, $tgrad_iip)) else _tgrad = :($tgradsym = nothing) end @@ -639,255 +620,48 @@ function ODEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), jac_oop, jac_iip = generate_jacobian(sys, dvs, ps; sparse = sparse, simplify = simplify, expression = Val{true}, kwargs...) - _jac = :($jacsym = $ODEFunctionClosure($jac_oop, $jac_iip)) + _jac = :($jacsym = $(GeneratedFunctionWrapper{(2, 3, is_split(sys))})( + $jac_oop, $jac_iip)) else _jac = :($jacsym = nothing) end + Msym = gensym(:M) M = calculate_massmatrix(sys) - - _M = if sparse && !(u0 === nothing || M === I) - SparseArrays.sparse(M) + if sparse && !(u0 === nothing || M === I) + _M = :($Msym = $(SparseArrays.sparse(M))) elseif u0 === nothing || M === I - M + _M = :($Msym = $M) else - ArrayInterface.restructure(u0 .* u0', M) + _M = :($Msym = $(ArrayInterface.restructure(u0 .* u0', M))) end jp_expr = sparse ? :($similar($(get_jac(sys)[]), Float64)) : :nothing ex = quote - $_f - $_tgrad - $_jac - M = $_M - ODEFunction{$iip}($fsym, - sys = $sys, - jac = $jacsym, - tgrad = $tgradsym, - mass_matrix = M, - jac_prototype = $jp_expr, - sparsity = $(sparsity ? jacobian_sparsity(sys) : nothing), - observed = $observedfun_exp) - end - !linenumbers ? Base.remove_linenums!(ex) : ex -end - -""" - u0, p, defs = get_u0_p(sys, u0map, parammap; use_union=true, tofloat=true) - -Take dictionaries with initial conditions and parameters and convert them to numeric arrays `u0` and `p`. Also return the merged dictionary `defs` containing the entire operating point. -""" -function get_u0_p(sys, - u0map, - parammap = nothing; - t0 = nothing, - use_union = true, - tofloat = true, - symbolic_u0 = false) - dvs = unknowns(sys) - ps = parameters(sys) - - defs = defaults(sys) - if t0 !== nothing - defs[get_iv(sys)] = t0 - end - if parammap !== nothing - defs = mergedefaults(defs, parammap, ps) - end - if u0map isa Vector && eltype(u0map) <: Pair - u0map = Dict(u0map) - end - if u0map isa Dict - allobs = Set(getproperty.(observed(sys), :lhs)) - if any(in(allobs), keys(u0map)) - u0s_in_obs = filter(in(allobs), keys(u0map)) - @warn "Observed variables cannot be assigned initial values. Initial values for $u0s_in_obs will be ignored." - end - end - obs = filter!(x -> !(x[1] isa Number), map(x -> x.rhs => x.lhs, observed(sys))) - observedmap = isempty(obs) ? Dict() : todict(obs) - defs = mergedefaults(defs, observedmap, u0map, dvs) - for (k, v) in defs - if Symbolics.isarraysymbolic(k) - ks = scalarize(k) - length(ks) == length(v) || error("$k has default value $v with unmatched size") - for (kk, vv) in zip(ks, v) - if !haskey(defs, kk) - defs[kk] = vv - end - end + let $_f, $_tgrad, $_jac, $_M + ODEFunction{$iip, $specialize}($fsym, + sys = $sys, + jac = $jacsym, + tgrad = $tgradsym, + mass_matrix = $Msym, + jac_prototype = $jp_expr, + sparsity = $(sparsity ? jacobian_sparsity(sys) : nothing), + observed = $observedfun_exp) end end - - if symbolic_u0 - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false, use_union = false) - else - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = true, use_union) - end - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat, use_union) - p = p === nothing ? SciMLBase.NullParameters() : p - t0 !== nothing && delete!(defs, get_iv(sys)) - u0, p, defs + !linenumbers ? Base.remove_linenums!(ex) : ex end -function get_u0( - sys, u0map, parammap = nothing; symbolic_u0 = false, - toterm = default_toterm, t0 = nothing, use_union = true) - dvs = unknowns(sys) - ps = parameters(sys) - defs = defaults(sys) - if t0 !== nothing - defs[get_iv(sys)] = t0 - end - if parammap !== nothing - defs = mergedefaults(defs, parammap, ps) - end - - # Convert observed equations "lhs ~ rhs" into defaults. - # Use the order "lhs => rhs" by default, but flip it to "rhs => lhs" - # if "lhs" is known by other means (parameter, another default, ...) - # TODO: Is there a better way to determine which equations to flip? - obs = map(x -> x.lhs => x.rhs, observed(sys)) - obs = map(x -> x[1] in keys(defs) ? reverse(x) : x, obs) - obs = filter!(x -> !(x[1] isa Number), obs) # exclude e.g. "0 => x^2 + y^2 - 25" - obsmap = isempty(obs) ? Dict() : todict(obs) - - defs = mergedefaults(defs, obsmap, u0map, dvs) - if symbolic_u0 - u0 = varmap_to_vars( - u0map, dvs; defaults = defs, tofloat = false, use_union = false, toterm) - else - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = true, use_union, toterm) - end - t0 !== nothing && delete!(defs, get_iv(sys)) - return u0, defs +function ODEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) + ODEFunctionExpr{true}(sys, args...; kwargs...) 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 = false, - eval_module = @__MODULE__, - use_union = false, - tofloat = true, - symbolic_u0 = false, - u0_constructor = identity, - guesses = Dict(), - t = nothing, - warn_initialize_determined = true, - build_initializeprob = true, - initialization_eqs = [], - fully_determined = false, - check_units = true, - kwargs...) - eqs = equations(sys) - dvs = unknowns(sys) - ps = parameters(sys) - iv = get_iv(sys) - - # TODO: Pass already computed information to varmap_to_vars call - # in process_u0? That would just be a small optimization - varmap = u0map === nothing || isempty(u0map) || eltype(u0map) <: Number ? - defaults(sys) : - merge(defaults(sys), todict(u0map)) - varmap = canonicalize_varmap(varmap) - varlist = collect(map(unwrap, dvs)) - missingvars = setdiff(varlist, collect(keys(varmap))) - setobserved = filter(keys(varmap)) do var - has_observed_with_lhs(sys, var) || has_observed_with_lhs(sys, default_toterm(var)) - end - - if eltype(parammap) <: Pair - parammap = Dict(unwrap(k) => v for (k, v) in todict(parammap)) - elseif parammap isa AbstractArray - if isempty(parammap) - parammap = SciMLBase.NullParameters() - else - parammap = Dict(unwrap.(parameters(sys)) .=> parammap) - end - end - - # ModelingToolkit.get_tearing_state(sys) !== nothing => Requires structural_simplify first - if sys isa ODESystem && build_initializeprob && - (((implicit_dae || !isempty(missingvars) || !isempty(setobserved)) && - ModelingToolkit.get_tearing_state(sys) !== nothing) || - !isempty(initialization_equations(sys))) && t !== nothing - if eltype(u0map) <: Number - u0map = unknowns(sys) .=> u0map - end - if isempty(u0map) - u0map = Dict() - end - initializeprob = ModelingToolkit.InitializationProblem( - sys, t, u0map, parammap; guesses, warn_initialize_determined, - initialization_eqs, eval_expression, eval_module, fully_determined, check_units) - initializeprobmap = getu(initializeprob, unknowns(sys)) - - zerovars = Dict(setdiff(unknowns(sys), keys(defaults(sys))) .=> 0.0) - trueinit = collect(merge(zerovars, eltype(u0map) <: Pair ? todict(u0map) : u0map)) - u0map isa StaticArraysCore.StaticArray && - (trueinit = SVector{length(trueinit)}(trueinit)) - else - initializeprob = nothing - initializeprobmap = nothing - trueinit = u0map - end - - if has_index_cache(sys) && get_index_cache(sys) !== nothing - u0, defs = get_u0(sys, trueinit, parammap; symbolic_u0, - t0 = constructor <: Union{DDEFunction, SDDEFunction} ? nothing : t, use_union) - check_eqs_u0(eqs, dvs, u0; kwargs...) - p = if parammap === nothing || - parammap == SciMLBase.NullParameters() && isempty(defs) - nothing - else - MTKParameters(sys, parammap, trueinit; t0 = t) - end - else - u0, p, defs = get_u0_p(sys, - trueinit, - parammap; - tofloat, - use_union, - t0 = constructor <: Union{DDEFunction, SDDEFunction} ? nothing : t, - symbolic_u0) - p, split_idxs = split_parameters_by_type(p) - if p isa Tuple - ps = Base.Fix1(getindex, parameters(sys)).(split_idxs) - ps = (ps...,) #if p is Tuple, ps should be Tuple - end - end - if u0 !== nothing - u0 = u0_constructor(u0) - end - - if implicit_dae && du0map !== nothing - ddvs = map(Differential(iv), dvs) - defs = mergedefaults(defs, du0map, ddvs) - du0 = varmap_to_vars(du0map, ddvs; defaults = defs, toterm = identity, - tofloat = true) - else - du0 = nothing - ddvs = nothing - end - check_eqs_u0(eqs, dvs, u0; kwargs...) - f = constructor(sys, dvs, ps, u0; ddvs = ddvs, tgrad = tgrad, jac = jac, - checkbounds = checkbounds, p = p, - linenumbers = linenumbers, parallel = parallel, simplify = simplify, - sparse = sparse, eval_expression = eval_expression, - eval_module = eval_module, - initializeprob = initializeprob, - initializeprobmap = initializeprobmap, - kwargs...) - implicit_dae ? (f, du0, u0, p) : (f, u0, p) +function ODEFunctionExpr{true}(sys::AbstractODESystem, args...; kwargs...) + return ODEFunctionExpr{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) end -function ODEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) - ODEFunctionExpr{true}(sys, args...; kwargs...) +function ODEFunctionExpr{false}(sys::AbstractODESystem, args...; kwargs...) + return ODEFunctionExpr{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) end """ @@ -906,15 +680,6 @@ 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) -(f::DAEFunctionClosure)(du, u, p::MTKParameters, t) = f.f_oop(du, u, p..., t) -(f::DAEFunctionClosure)(out, du, u, p::MTKParameters, t) = f.f_iip(out, du, u, p..., t) - function DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), ps = parameters(sys), u0 = nothing; version = nothing, tgrad = false, @@ -928,7 +693,7 @@ function DAEFunctionExpr{iip}(sys::AbstractODESystem, dvs = unknowns(sys), 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)) + _f = :($fsym = $(GeneratedFunctionWrapper{(3, 4, is_split(sys))})($f_oop, $f_iip)) ex = quote $_f ODEFunction{$iip}($fsym) @@ -940,10 +705,44 @@ function DAEFunctionExpr(sys::AbstractODESystem, args...; kwargs...) DAEFunctionExpr{true}(sys, args...; kwargs...) end +struct SymbolicTstops{F} + fn::F +end + +function (st::SymbolicTstops)(p, tspan) + unique!(sort!(reduce(vcat, st.fn(p, tspan...)))) +end + +function SymbolicTstops( + sys::AbstractSystem; eval_expression = false, eval_module = @__MODULE__) + tstops = symbolic_tstops(sys) + isempty(tstops) && return nothing + t0 = gensym(:t0) + t1 = gensym(:t1) + tstops = map(tstops) do val + if is_array_of_symbolics(val) || val isa AbstractArray + collect(val) + else + term(:, t0, unwrap(val), t1; type = AbstractArray{Real}) + end + end + rps = reorder_parameters(sys) + tstops, _ = build_function_wrapper(sys, tstops, + rps..., + t0, + t1; + expression = Val{true}, + p_start = 1, p_end = length(rps), add_observed = false, force_SA = true) + tstops = eval_or_rgf(tstops; eval_expression, eval_module) + tstops = GeneratedFunctionWrapper{(1, 3, is_split(sys))}(tstops, nothing) + return SymbolicTstops(tstops) +end + """ ```julia DiffEqBase.ODEProblem{iip}(sys::AbstractODESystem, u0map, tspan, parammap = DiffEqBase.NullParameters(); + allow_cost = false, version = nothing, tgrad = false, jac = false, checkbounds = false, sparse = false, @@ -974,15 +773,10 @@ function DiffEqBase.ODEProblem{false}(sys::AbstractODESystem, args...; kwargs... ODEProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) end -struct DiscreteSaveAffect{F, S} <: Function - f::F - s::S -end -(d::DiscreteSaveAffect)(args...) = d.f(args..., d.s) - function DiffEqBase.ODEProblem{iip, specialize}(sys::AbstractODESystem, u0map = [], tspan = get_tspan(sys), parammap = DiffEqBase.NullParameters(); + allow_cost = false, callback = nothing, check_length = true, warn_initialize_determined = true, @@ -992,7 +786,19 @@ function DiffEqBase.ODEProblem{iip, specialize}(sys::AbstractODESystem, u0map = if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEProblem`") end - f, u0, p = process_DEProblem(ODEFunction{iip, specialize}, sys, u0map, parammap; + + if !isnothing(get_constraintsystem(sys)) + error("An ODESystem with constraints cannot be used to construct a regular ODEProblem. + Consider a BVProblem instead.") + end + + if !isempty(get_costs(sys)) && !allow_cost + error("ODEProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") + end + + f, u0, p = process_SciMLProblem(ODEFunction{iip, specialize}, sys, u0map, parammap; t = tspan !== nothing ? tspan[1] : tspan, check_length, warn_initialize_determined, eval_expression, eval_module, kwargs...) cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) @@ -1005,10 +811,180 @@ function DiffEqBase.ODEProblem{iip, specialize}(sys::AbstractODESystem, u0map = kwargs1 = merge(kwargs1, (callback = cbs,)) end - return ODEProblem{iip}(f, u0, tspan, p, pt; kwargs1..., kwargs...) + tstops = SymbolicTstops(sys; eval_expression, eval_module) + if tstops !== nothing + kwargs1 = merge(kwargs1, (; tstops)) + end + + # Call `remake` so it runs initialization if it is trivial + return remake(ODEProblem{iip}(f, u0, tspan, p, pt; kwargs1..., kwargs...)) end get_callback(prob::ODEProblem) = prob.kwargs[:callback] +""" +```julia +SciMLBase.BVProblem{iip}(sys::AbstractODESystem, u0map, tspan, + parammap = DiffEqBase.NullParameters(); + constraints = nothing, guesses = nothing, + version = nothing, tgrad = false, + jac = true, sparse = true, + simplify = false, + kwargs...) where {iip} +``` + +Create a boundary value problem from the [`ODESystem`](@ref). + +`u0map` is used to specify fixed initial values for the states. Every variable +must have either an initial guess supplied using `guesses` or a fixed initial +value specified using `u0map`. + +Boundary value conditions are supplied to ODESystems +in the form of a ConstraintsSystem. These equations +should specify values that state variables should +take at specific points, as in `x(0.5) ~ 1`). More general constraints that +should hold over the entire solution, such as `x(t)^2 + y(t)^2`, should be +specified as one of the equations used to build the `ODESystem`. + +If an ODESystem without `constraints` is specified, it will be treated as an initial value problem. + +```julia + @parameters g t_c = 0.5 + @variables x(..) y(t) λ(t) + eqs = [D(D(x(t))) ~ λ * x(t) + D(D(y)) ~ λ * y - g + x(t)^2 + y^2 ~ 1] + cstr = [x(0.5) ~ 1] + @mtkbuild pend = ODESystem(eqs, t; constraints = cstrs) + + tspan = (0.0, 1.5) + u0map = [x(t) => 0.6, y => 0.8] + parammap = [g => 1] + guesses = [λ => 1] + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +``` + +If the `ODESystem` has algebraic equations, like `x(t)^2 + y(t)^2`, the resulting +`BVProblem` must be solved using BVDAE solvers, such as Ascher. +""" +function SciMLBase.BVProblem(sys::AbstractODESystem, args...; kwargs...) + BVProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem(sys::AbstractODESystem, + u0map::StaticArray, + args...; + kwargs...) + BVProblem{false, SciMLBase.FullSpecialize}(sys, u0map, args...; kwargs...) +end + +function SciMLBase.BVProblem{true}(sys::AbstractODESystem, args...; kwargs...) + BVProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem{false}(sys::AbstractODESystem, args...; kwargs...) + BVProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.BVProblem{iip, specialize}(sys::AbstractODESystem, u0map = [], + tspan = get_tspan(sys), + parammap = DiffEqBase.NullParameters(); + guesses = Dict(), + allow_cost = false, + version = nothing, tgrad = false, + callback = nothing, + check_length = true, + warn_initialize_determined = true, + eval_expression = false, + eval_module = @__MODULE__, + cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `BVProblem`") + end + !isnothing(callback) && error("BVP solvers do not support callbacks.") + + if !isempty(get_costs(sys)) && !allow_cost + error("BVProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") + end + + has_alg_eqs(sys) && + error("The BVProblem constructor currently does not support ODESystems with algebraic equations.") # Remove this when the BVDAE solvers get updated, the codegen should work when it does. + + sts = unknowns(sys) + ps = parameters(sys) + constraintsys = get_constraintsystem(sys) + + if !isnothing(constraintsys) + (length(constraints(constraintsys)) + length(u0map) > length(sts)) && + @warn "The BVProblem is overdetermined. The total number of conditions (# constraints + # fixed initial values given by u0map) exceeds the total number of states. The BVP solvers will default to doing a nonlinear least-squares optimization." + end + + # ODESystems without algebraic equations should use both fixed values + guesses + # for initialization. + _u0map = has_alg_eqs(sys) ? u0map : merge(Dict(u0map), Dict(guesses)) + f, u0, p = process_SciMLProblem(ODEFunction{iip, specialize}, sys, _u0map, parammap; + t = tspan !== nothing ? tspan[1] : tspan, guesses, + check_length, warn_initialize_determined, eval_expression, eval_module, cse, kwargs...) + + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + u0_idxs = has_alg_eqs(sys) ? collect(1:length(sts)) : [stidxmap[k] for (k, v) in u0map] + + fns = generate_function_bc(sys, u0, u0_idxs, tspan; cse) + bc_oop, bc_iip = eval_or_rgf.(fns; eval_expression, eval_module) + bc(sol, p, t) = bc_oop(sol, p, t) + bc(resid, u, p, t) = bc_iip(resid, u, p, t) + + return BVProblem{iip}(f, bc, u0, tspan, p; kwargs...) +end + +get_callback(prob::BVProblem) = error("BVP solvers do not support callbacks.") + +""" + generate_function_bc(sys::ODESystem, u0, u0_idxs, tspan) + + Given an ODESystem with constraints, generate the boundary condition function to pass to boundary value problem solvers. + Expression uses the constraints and the provided initial conditions. +""" +function generate_function_bc(sys::ODESystem, u0, u0_idxs, tspan; kwargs...) + iv = get_iv(sys) + sts = unknowns(sys) + ps = parameters(sys) + np = length(ps) + ns = length(sts) + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + pidxmap = Dict([v => i for (i, v) in enumerate(ps)]) + + @variables sol(..)[1:ns] + + conssys = get_constraintsystem(sys) + cons = Any[] + if !isnothing(conssys) + cons = [con.lhs - con.rhs for con in constraints(conssys)] + + for st in get_unknowns(conssys) + x = operation(st) + t = only(arguments(st)) + idx = stidxmap[x(iv)] + + cons = map(c -> Symbolics.substitute(c, Dict(x(t) => sol(t)[idx])), cons) + end + end + + init_conds = Any[] + for i in u0_idxs + expr = sol(tspan[1])[i] - u0[i] + push!(init_conds, expr) + end + + exprs = vcat(init_conds, cons) + _p = reorder_parameters(sys, ps) + + build_function_wrapper(sys, exprs, sol, _p..., iv; output_type = Array, kwargs...) +end + """ ```julia DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan, @@ -1023,6 +999,10 @@ DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan, Generates a DAEProblem from an ODESystem and allows for automatically symbolically calculating numerical enhancements. + +Note: Solvers for DAEProblems like DFBDF, DImplicitEuler, DABDF2 are +generally slower than the ones for ODEProblems. We recommend trying +ODEProblem and its solvers for your problem first. """ function DiffEqBase.DAEProblem(sys::AbstractODESystem, args...; kwargs...) DAEProblem{true}(sys, args...; kwargs...) @@ -1030,12 +1010,20 @@ end function DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan, parammap = DiffEqBase.NullParameters(); + allow_cost = false, warn_initialize_determined = true, - check_length = true, kwargs...) where {iip} + check_length = true, eval_expression = false, eval_module = @__MODULE__, kwargs...) where {iip} if !iscomplete(sys) - error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEProblem`") + error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEProblem`.") + end + + if !isempty(get_costs(sys)) && !allow_cost + error("DAEProblem will not optimize solutions of ODESystems that have associated cost functions. + Solvers for optimal control problems are forthcoming. In order to bypass this error (e.g. + to check the cost of a regular solution), pass `allow_cost` = true into the constructor.") end - f, du0, u0, p = process_DEProblem(DAEFunction{iip}, sys, u0map, parammap; + + f, du0, u0, p = process_SciMLProblem(DAEFunction{iip}, sys, u0map, parammap; implicit_dae = true, du0map = du0map, check_length, t = tspan !== nothing ? tspan[1] : tspan, warn_initialize_determined, kwargs...) @@ -1044,13 +1032,24 @@ function DiffEqBase.DAEProblem{iip}(sys::AbstractODESystem, du0map, u0map, tspan differential_vars = map(Base.Fix2(in, diffvars), sts) kwargs = filter_kwargs(kwargs) - DAEProblem{iip}(f, du0, u0, tspan, p; differential_vars = differential_vars, - kwargs...) + kwargs1 = (;) + + tstops = SymbolicTstops(sys; eval_expression, eval_module) + if tstops !== nothing + kwargs1 = merge(kwargs1, (; tstops)) + end + + # Call `remake` so it runs initialization if it is trivial + return remake(DAEProblem{iip}( + f, du0, u0, tspan, p; differential_vars = differential_vars, + kwargs..., kwargs1...)) end function generate_history(sys::AbstractODESystem, u0; expression = Val{false}, kwargs...) - p = reorder_parameters(sys, parameters(sys)) - build_function(u0, p..., get_iv(sys); expression, kwargs...) + p = reorder_parameters(sys) + build_function_wrapper( + sys, u0, p..., get_iv(sys); expression, p_start = 1, p_end = length(p), + similarto = typeof(u0), wrap_delays = false, kwargs...) end function DiffEqBase.DDEProblem(sys::AbstractODESystem, args...; kwargs...) @@ -1063,19 +1062,23 @@ function DiffEqBase.DDEProblem{iip}(sys::AbstractODESystem, u0map = [], check_length = true, eval_expression = false, eval_module = @__MODULE__, + u0_constructor = identity, + cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DDEProblem`") end - f, u0, p = process_DEProblem(DDEFunction{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(DDEFunction{iip}, sys, u0map, parammap; t = tspan !== nothing ? tspan[1] : tspan, - symbolic_u0 = true, + symbolic_u0 = true, u0_constructor, cse, check_length, eval_expression, eval_module, kwargs...) - h_gen = generate_history(sys, u0; expression = Val{true}) + h_gen = generate_history(sys, u0; expression = Val{true}, cse) h_oop, h_iip = eval_or_rgf.(h_gen; eval_expression, eval_module) - h(p, t) = h_oop(p, t) - h(p::MTKParameters, t) = h_oop(p..., t) - u0 = h(p, tspan[1]) + h = h_oop + u0 = float.(h(p, tspan[1])) + if u0 !== nothing + u0 = u0_constructor(u0) + end cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) kwargs = filter_kwargs(kwargs) @@ -1084,7 +1087,8 @@ function DiffEqBase.DDEProblem{iip}(sys::AbstractODESystem, u0map = [], if cbs !== nothing kwargs1 = merge(kwargs1, (callback = cbs,)) end - DDEProblem{iip}(f, u0, h, tspan, p; kwargs1..., kwargs...) + # Call `remake` so it runs initialization if it is trivial + return remake(DDEProblem{iip}(f, u0, h, tspan, p; kwargs1..., kwargs...)) end function DiffEqBase.SDDEProblem(sys::AbstractODESystem, args...; kwargs...) @@ -1098,21 +1102,23 @@ function DiffEqBase.SDDEProblem{iip}(sys::AbstractODESystem, u0map = [], sparsenoise = nothing, eval_expression = false, eval_module = @__MODULE__, + u0_constructor = identity, + cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SDDEProblem`") end - f, u0, p = process_DEProblem(SDDEFunction{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(SDDEFunction{iip}, sys, u0map, parammap; t = tspan !== nothing ? tspan[1] : tspan, - symbolic_u0 = true, eval_expression, eval_module, - check_length, kwargs...) - h_gen = generate_history(sys, u0; expression = Val{true}) + symbolic_u0 = true, eval_expression, eval_module, u0_constructor, + check_length, cse, kwargs...) + h_gen = generate_history(sys, u0; expression = Val{true}, cse) h_oop, h_iip = eval_or_rgf.(h_gen; eval_expression, eval_module) - h(out, p, t) = h_iip(out, p, t) - h(p, t) = h_oop(p, t) - h(p::MTKParameters, t) = h_oop(p..., t) - h(out, p::MTKParameters, t) = h_iip(out, p..., t) + h = h_oop u0 = h(p, tspan[1]) + if u0 !== nothing + u0 = u0_constructor(u0) + end cbs = process_events(sys; callback, eval_expression, eval_module, kwargs...) kwargs = filter_kwargs(kwargs) @@ -1132,9 +1138,10 @@ function DiffEqBase.SDDEProblem{iip}(sys::AbstractODESystem, u0map = [], else noise_rate_prototype = zeros(eltype(u0), size(noiseeqs)) end - SDDEProblem{iip}(f, f.g, u0, h, tspan, p; + # Call `remake` so it runs initialization if it is trivial + return remake(SDDEProblem{iip}(f, f.g, u0, h, tspan, p; noise_rate_prototype = - noise_rate_prototype, kwargs1..., kwargs...) + noise_rate_prototype, kwargs1..., kwargs...)) end """ @@ -1162,7 +1169,8 @@ function ODEProblemExpr{iip}(sys::AbstractODESystem, u0map, tspan, if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `ODEProblemExpr`") end - f, u0, p = process_DEProblem(ODEFunctionExpr{iip}, sys, u0map, parammap; check_length, + f, u0, p = process_SciMLProblem( + ODEFunctionExpr{iip}, sys, u0map, parammap; check_length, t = tspan !== nothing ? tspan[1] : tspan, kwargs...) linenumbers = get(kwargs, :linenumbers, true) @@ -1208,7 +1216,7 @@ function DAEProblemExpr{iip}(sys::AbstractODESystem, du0map, u0map, tspan, if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `DAEProblemExpr`") end - f, du0, u0, p = process_DEProblem(DAEFunctionExpr{iip}, sys, u0map, parammap; + f, du0, u0, p = process_SciMLProblem(DAEFunctionExpr{iip}, sys, u0map, parammap; t = tspan !== nothing ? tspan[1] : tspan, implicit_dae = true, du0map = du0map, check_length, kwargs...) @@ -1260,9 +1268,9 @@ function DiffEqBase.SteadyStateProblem{iip}(sys::AbstractODESystem, u0map, if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SteadyStateProblem`") end - f, u0, p = process_DEProblem(ODEFunction{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(ODEFunction{iip}, sys, u0map, parammap; steady_state = true, - check_length, kwargs...) + check_length, force_initialization_time_independent = true, kwargs...) kwargs = filter_kwargs(kwargs) SteadyStateProblem{iip}(f, u0, p; kwargs...) end @@ -1292,7 +1300,7 @@ function SteadyStateProblemExpr{iip}(sys::AbstractODESystem, u0map, if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating a `SteadyStateProblemExpr`") end - f, u0, p = process_DEProblem(ODEFunctionExpr{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(ODEFunctionExpr{iip}, sys, u0map, parammap; steady_state = true, check_length, kwargs...) linenumbers = get(kwargs, :linenumbers, true) @@ -1364,7 +1372,7 @@ function flatten_equations(eqs) 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 collect(eq.lhs) .~ collect(eq.rhs) + return vec(collect(eq.lhs) .~ collect(eq.rhs)) else eq end @@ -1375,7 +1383,7 @@ struct InitializationProblem{iip, specialization} end """ ```julia -InitializationProblem{iip}(sys::AbstractODESystem, u0map, tspan, +InitializationProblem{iip}(sys::AbstractODESystem, t, u0map, parammap = DiffEqBase.NullParameters(); version = nothing, tgrad = false, jac = false, @@ -1391,11 +1399,11 @@ Generates a NonlinearProblem or NonlinearLeastSquaresProblem from an ODESystem which represents the initialization, i.e. the calculation of the consistent initial conditions for the given DAE. """ -function InitializationProblem(sys::AbstractODESystem, args...; kwargs...) +function InitializationProblem(sys::AbstractSystem, args...; kwargs...) InitializationProblem{true}(sys, args...; kwargs...) end -function InitializationProblem(sys::AbstractODESystem, t, +function InitializationProblem(sys::AbstractSystem, t, u0map::StaticArray, args...; kwargs...) @@ -1403,11 +1411,11 @@ function InitializationProblem(sys::AbstractODESystem, t, sys, t, u0map, args...; kwargs...) end -function InitializationProblem{true}(sys::AbstractODESystem, args...; kwargs...) +function InitializationProblem{true}(sys::AbstractSystem, args...; kwargs...) InitializationProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) end -function InitializationProblem{false}(sys::AbstractODESystem, args...; kwargs...) +function InitializationProblem{false}(sys::AbstractSystem, args...; kwargs...) InitializationProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) end @@ -1426,61 +1434,127 @@ function Base.showerror(io::IO, e::IncompleteInitializationError) println(io, e.uninit) end -function InitializationProblem{iip, specialize}(sys::AbstractODESystem, - t::Number, u0map = [], +function InitializationProblem{iip, specialize}(sys::AbstractSystem, + t, u0map = [], parammap = DiffEqBase.NullParameters(); guesses = [], check_length = true, warn_initialize_determined = true, initialization_eqs = [], - fully_determined = false, + fully_determined = nothing, check_units = true, + use_scc = true, + allow_incomplete = false, + force_time_independent = false, + algebraic_only = false, kwargs...) where {iip, specialize} if !iscomplete(sys) error("A completed system is required. Call `complete` or `structural_simplify` on the system before creating an `ODEProblem`") end if isempty(u0map) && get_initializesystem(sys) !== nothing isys = get_initializesystem(sys; initialization_eqs, check_units) + simplify_system = false elseif isempty(u0map) && get_initializesystem(sys) === nothing - isys = structural_simplify( - generate_initializesystem(sys; initialization_eqs, check_units); fully_determined) + isys = generate_initializesystem( + sys; initialization_eqs, check_units, pmap = parammap, + guesses, extra_metadata = (; use_scc), algebraic_only) + simplify_system = true else - isys = structural_simplify( - generate_initializesystem(sys; u0map, initialization_eqs, check_units); fully_determined) + isys = generate_initializesystem( + sys; u0map, initialization_eqs, check_units, + pmap = parammap, guesses, extra_metadata = (; use_scc), algebraic_only) + simplify_system = true + end + + # useful for `SteadyStateProblem` since `f` has to be autonomous and the + # initialization should be too + if force_time_independent + idx = findfirst(isequal(get_iv(sys)), get_ps(isys)) + idx === nothing || deleteat!(get_ps(isys), idx) + end + + if simplify_system + isys = structural_simplify(isys; fully_determined) + end + + meta = get_metadata(isys) + if meta isa InitializationSystemMetadata + @set! isys.metadata.oop_reconstruct_u0_p = ReconstructInitializeprob( + sys, isys) + end + + ts = get_tearing_state(isys) + unassigned_vars = StructuralTransformations.singular_check(ts) + if warn_initialize_determined && !isempty(unassigned_vars) + errmsg = """ + The initialization system is structurally singular. Guess values may \ + significantly affect the initial values of the ODE. The problematic variables \ + are $unassigned_vars. + + Note that the identification of problematic variables is a best-effort heuristic. + """ + @warn errmsg end uninit = setdiff(unknowns(sys), [unknowns(isys); getfield.(observed(isys), :lhs)]) # TODO: throw on uninitialized arrays filter!(x -> !(x isa Symbolics.Arr), uninit) - if !isempty(uninit) - throw(IncompleteInitializationError(uninit)) + if is_time_dependent(sys) && !isempty(uninit) + allow_incomplete || throw(IncompleteInitializationError(uninit)) + # for incomplete initialization, we will add the missing variables as parameters. + # they will be updated by `update_initializeprob!` and `initializeprobmap` will + # use them to construct the new `u0`. + newparams = map(toparam, uninit) + append!(get_ps(isys), newparams) + isys = complete(isys) end neqs = length(equations(isys)) nunknown = length(unknowns(isys)) + if use_scc + scc_message = "`SCCNonlinearProblem` can only be used for initialization of fully determined systems and hence will not be used here. " + else + scc_message = "" + end + if warn_initialize_determined && neqs > nunknown - @warn "Initialization system is overdetermined. $neqs equations for $nunknown unknowns. Initialization will default to using least squares. To suppress this warning pass warn_initialize_determined = false. To make this warning into an error, pass fully_determined = true" + @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. To suppress this warning pass warn_initialize_determined = false. To make this warning into an error, pass fully_determined = true" + @warn "Initialization system is underdetermined. $neqs equations for $nunknown unknowns. Initialization will default to using least squares. $(scc_message)To suppress this warning pass warn_initialize_determined = false. To make this warning into an error, pass fully_determined = true" end - parammap = parammap isa DiffEqBase.NullParameters || isempty(parammap) ? - [get_iv(sys) => t] : - merge(todict(parammap), Dict(get_iv(sys) => t)) - if isempty(u0map) - u0map = Dict() + parammap = recursive_unwrap(anydict(parammap)) + if t !== nothing + parammap[get_iv(sys)] = t end + filter!(kvp -> kvp[2] !== missing, parammap) + + u0map = to_varmap(u0map, unknowns(sys)) if isempty(guesses) guesses = Dict() end - u0map = merge(todict(guesses), todict(u0map)) - if neqs == nunknown - NonlinearProblem(isys, u0map, parammap; kwargs...) + filter_missing_values!(u0map) + filter_missing_values!(parammap) + u0map = merge(ModelingToolkit.guesses(sys), todict(guesses), u0map) + + TProb = if neqs == nunknown && isempty(unassigned_vars) + if use_scc && neqs > 0 + if is_split(isys) + SCCNonlinearProblem + else + @warn "`SCCNonlinearProblem` can only be used with `split = true` systems. Simplify your `ODESystem` with `split = true` or pass `use_scc = false` to disable this warning" + NonlinearProblem + end + else + NonlinearProblem + end else - NonlinearLeastSquaresProblem(isys, u0map, parammap; kwargs...) + NonlinearLeastSquaresProblem end + TProb(isys, u0map, parammap; kwargs..., + build_initializeprob = false, is_initializeprob = true) end diff --git a/src/systems/diffeqs/basic_transformations.jl b/src/systems/diffeqs/basic_transformations.jl index e2be889c5e..a08c83ffb6 100644 --- a/src/systems/diffeqs/basic_transformations.jl +++ b/src/systems/diffeqs/basic_transformations.jl @@ -14,26 +14,20 @@ the final value of `trJ` is the probability of ``u(t)``. Example: ```julia -using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit, OrdinaryDiffEq @independent_variables t @parameters α β γ δ @variables x(t) y(t) D = Differential(t) +eqs = [D(x) ~ α*x - β*x*y, D(y) ~ -δ*y + γ*x*y] +@named sys = ODESystem(eqs, t) -eqs = [D(x) ~ α*x - β*x*y, - D(y) ~ -δ*y + γ*x*y] - -sys = ODESystem(eqs) sys2 = liouville_transform(sys) -@variables trJ - -u0 = [x => 1.0, - y => 1.0, - trJ => 1.0] - -prob = ODEProblem(complete(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. @@ -46,12 +40,159 @@ Optimal Transport Approach Abhishek Halder, Kooktae Lee, and Raktim Bhattacharya https://abhishekhalder.bitbucket.io/F16ACC2013Final.pdf """ -function liouville_transform(sys::AbstractODESystem) +function liouville_transform(sys::AbstractODESystem; kwargs...) t = get_iv(sys) @variables trJ D = ModelingToolkit.Differential(t) neweq = D(trJ) ~ trJ * -tr(calculate_jacobian(sys)) neweqs = [equations(sys); neweq] vars = [unknowns(sys); trJ] - ODESystem(neweqs, t, vars, parameters(sys), checks = false) + ODESystem( + neweqs, t, vars, parameters(sys); + checks = false, name = nameof(sys), kwargs... + ) +end + +""" + change_independent_variable( + sys::AbstractODESystem, iv, eqs = []; + add_old_diff = false, simplify = true, fold = false + ) + +Transform the independent variable (e.g. ``t``) of the ODE system `sys` to a dependent variable `iv` (e.g. ``u(t)``). +The transformation is well-defined when the mapping between the new and old independent variables are one-to-one. +This is satisfied if one is a strictly increasing function of the other (e.g. ``du(t)/dt > 0`` or ``du(t)/dt < 0``). + +Any extra equations `eqs` involving the new and old independent variables will be taken into account in the transformation. + +# Keyword arguments + +- `add_old_diff`: Whether to add a differential equation for the old independent variable in terms of the new one using the inverse function rule ``dt/du = 1/(du/dt)``. +- `simplify`: Whether expanded derivative expressions are simplified. This can give a tidier transformation. +- `fold`: Whether internal substitutions will evaluate numerical expressions. + +# Usage before structural simplification + +The variable change must take place before structural simplification. +In following calls to `structural_simplify`, consider passing `allow_symbolic = true` to avoid undesired constraint equations between between dummy variables. + +# Usage with non-autonomous systems + +If `sys` is non-autonomous (i.e. ``t`` appears explicitly in its equations), consider passing an algebraic equation relating the new and old independent variables (e.g. ``t = f(u(t))``). +Otherwise the transformed system can be underdetermined. +If an algebraic relation is not known, consider using `add_old_diff` instead. + +# Usage with hierarchical systems + +It is recommended that `iv` is a non-namespaced variable in `sys`. +This means it can belong to the top-level system or be a variable in a subsystem declared with `GlobalScope`. + +# Example + +Consider a free fall with constant horizontal velocity. +Physics naturally describes position as a function of time. +By changing the independent variable, it can be reformulated for vertical position as a function of horizontal position: +```julia +julia> @variables x(t) y(t); + +julia> @named M = ODESystem([D(D(y)) ~ -9.81, D(D(x)) ~ 0.0], t); + +julia> M = change_independent_variable(M, x); + +julia> M = structural_simplify(M; allow_symbolic = true); + +julia> unknowns(M) +3-element Vector{SymbolicUtils.BasicSymbolic{Real}}: + xˍt(x) + y(x) + yˍx(x) +``` +""" +function change_independent_variable( + sys::AbstractODESystem, iv, eqs = []; + add_old_diff = false, simplify = true, fold = false +) + iv2_of_iv1 = unwrap(iv) # e.g. u(t) + iv1 = get_iv(sys) # e.g. t + + if is_dde(sys) + error("System $(nameof(sys)) contains delay differential equations (DDEs). This is currently not supported!") + elseif isscheduled(sys) + error("System $(nameof(sys)) is structurally simplified. Change independent variable before structural simplification!") + elseif !iscall(iv2_of_iv1) || !isequal(only(arguments(iv2_of_iv1)), iv1) + error("Variable $iv is not a function of the independent variable $iv1 of the system $(nameof(sys))!") + end + + # Set up intermediate and final variables for the transformation + iv1name = nameof(iv1) # e.g. :t + iv2name = nameof(operation(iv2_of_iv1)) # e.g. :u + iv2, = @independent_variables $iv2name # e.g. u + iv1_of_iv2, = GlobalScope.(@variables $iv1name(iv2)) # inverse, e.g. t(u), global because iv1 has no namespacing in sys + D1 = Differential(iv1) # e.g. d/d(t) + div2_of_iv1 = GlobalScope(default_toterm(D1(iv2_of_iv1))) # e.g. uˍt(t) + div2_of_iv2 = substitute(div2_of_iv1, iv1 => iv2) # e.g. uˍt(u) + div2_of_iv2_of_iv1 = substitute(div2_of_iv2, iv2 => iv2_of_iv1) # e.g. uˍt(u(t)) + + # If requested, add a differential equation for the old independent variable as a function of the old one + if add_old_diff + eqs = [eqs; Differential(iv2)(iv1_of_iv2) ~ 1 / div2_of_iv2] # e.g. dt(u)/du ~ 1 / uˍt(u) (https://en.wikipedia.org/wiki/Inverse_function_rule) + end + @set! sys.eqs = [get_eqs(sys); eqs] # add extra equations we derived + @set! sys.unknowns = [get_unknowns(sys); [iv1, div2_of_iv1]] # add new variables, will be transformed to e.g. t(u) and uˍt(u) + + # Create a utility that performs the chain rule on an expression, followed by insertion of the new independent variable: + # e.g. (d/dt)(f(t)) -> (d/dt)(f(u(t))) -> df(u(t))/du(t) * du(t)/dt -> df(u)/du * uˍt(u) + function transform(ex) + # 1) Replace the argument of every function; e.g. f(t) -> f(u(t)) + for var in vars(ex; op = Nothing) # loop over all variables in expression (op = Nothing prevents interpreting "D(f(t))" as one big variable) + is_function_of_iv1 = iscall(var) && isequal(only(arguments(var)), iv1) # of the form f(t)? + if is_function_of_iv1 && !isequal(var, iv2_of_iv1) # prevent e.g. u(t) -> u(u(t)) + var_of_iv1 = var # e.g. f(t) + var_of_iv2_of_iv1 = substitute(var_of_iv1, iv1 => iv2_of_iv1) # e.g. f(u(t)) + ex = substitute(ex, var_of_iv1 => var_of_iv2_of_iv1; fold) + end + end + # 2) Repeatedly expand chain rule until nothing changes anymore + orgex = nothing + while !isequal(ex, orgex) + orgex = ex # save original + ex = expand_derivatives(ex, simplify) # expand chain rule, e.g. (d/dt)(f(u(t)))) -> df(u(t))/du(t) * du(t)/dt + ex = substitute(ex, D1(iv2_of_iv1) => div2_of_iv2_of_iv1; fold) # e.g. du(t)/dt -> uˍt(u(t)) + end + # 3) Set new independent variable + ex = substitute(ex, iv2_of_iv1 => iv2; fold) # set e.g. u(t) -> u everywhere + ex = substitute(ex, iv1 => iv1_of_iv2; fold) # set e.g. t -> t(u) everywhere + return ex + end + + # Use the utility function to transform everything in the system! + function transform(sys::AbstractODESystem) + eqs = map(transform, get_eqs(sys)) + unknowns = map(transform, get_unknowns(sys)) + unknowns = filter(var -> !isequal(var, iv2), unknowns) # remove e.g. u + ps = map(transform, get_ps(sys)) + ps = filter(!isinitial, ps) # remove Initial(...) # TODO: shouldn't have to touch this + observed = map(transform, get_observed(sys)) + initialization_eqs = map(transform, get_initialization_eqs(sys)) + parameter_dependencies = map(transform, get_parameter_dependencies(sys)) + defaults = Dict(transform(var) => transform(val) + for (var, val) in get_defaults(sys)) + guesses = Dict(transform(var) => transform(val) for (var, val) in get_guesses(sys)) + assertions = Dict(transform(ass) => msg for (ass, msg) in get_assertions(sys)) + systems = get_systems(sys) # save before reconstructing system + wascomplete = iscomplete(sys) # save before reconstructing system + sys = typeof(sys)( # recreate system with transformed fields + eqs, iv2, unknowns, ps; observed, initialization_eqs, + parameter_dependencies, defaults, guesses, + assertions, name = nameof(sys), description = description(sys) + ) + systems = map(transform, systems) # recurse through subsystems + sys = compose(sys, systems) # rebuild hierarchical system + if wascomplete + wasflat = isempty(systems) + sys = complete(sys; flatten = wasflat) # complete output if input was complete + end + return sys + end + return transform(sys) end diff --git a/src/systems/diffeqs/modelingtoolkitize.jl b/src/systems/diffeqs/modelingtoolkitize.jl index 68a970d8b5..b2954f81e4 100644 --- a/src/systems/diffeqs/modelingtoolkitize.jl +++ b/src/systems/diffeqs/modelingtoolkitize.jl @@ -93,6 +93,8 @@ function modelingtoolkitize( else Dict() end + filter!(x -> !iscall(x) || !(operation(x) isa Initial), params) + filter!(x -> !iscall(x[1]) || !(operation(x[1]) isa Initial), default_p) de = ODESystem(eqs, t, sts, params, defaults = merge(default_u0, default_p); name = gensym(:MTKizedODE), @@ -207,17 +209,17 @@ function define_params(p::MTKParameters, names = nothing) for _ in buf push!( ps, - if names === nothing - toparam(variable(:α, i)) - else - toparam(variable(names[i])) - end + toparam(variable(:α, i)) ) end end return identity.(ps) else - return collect(values(names)) + new_p = as_any_buffer(p) + for (k, v) in names + new_p[k] = v + end + return reduce(vcat, new_p; init = []) end end @@ -277,10 +279,24 @@ function modelingtoolkitize(prob::DiffEqBase.SDEProblem; kwargs...) else Vector(vec(params)) end + sts = Vector(vec(vars)) + default_u0 = Dict(sts .=> vec(collect(prob.u0))) + default_p = if has_p + if prob.p isa AbstractDict + Dict(v => prob.p[k] for (k, v) in pairs(_params)) + elseif prob.p isa MTKParameters + Dict(params .=> reduce(vcat, prob.p)) + else + Dict(params .=> vec(collect(prob.p))) + end + else + Dict() + end - de = SDESystem(deqs, neqs, t, Vector(vec(vars)), params; + de = SDESystem(deqs, neqs, t, sts, params; name = gensym(:MTKizedSDE), tspan = prob.tspan, + defaults = merge(default_u0, default_p), kwargs...) de diff --git a/src/systems/diffeqs/odesystem.jl b/src/systems/diffeqs/odesystem.jl index a1fb333092..cbce569a9b 100644 --- a/src/systems/diffeqs/odesystem.jl +++ b/src/systems/diffeqs/odesystem.jl @@ -49,6 +49,12 @@ struct ODESystem <: AbstractODESystem ctrls::Vector """Observed variables.""" observed::Vector{Equation} + """System of constraints that must be satisfied by the solution to the system.""" + constraintsystem::Union{Nothing, ConstraintsSystem} + """A set of expressions defining the costs of the system for optimal control.""" + costs::Vector + """Takes the cost vector and returns a scalar for optimization.""" + consolidate::Union{Nothing, Function} """ Time-derivative matrix. Note: this field will not be defined until [`calculate_tgrad`](@ref) is called on the system. @@ -79,6 +85,10 @@ struct ODESystem <: AbstractODESystem """ name::Symbol """ + A description of the system. + """ + description::String + """ The internal systems. These are required to have unique names. """ systems::Vector{ODESystem} @@ -133,6 +143,11 @@ struct ODESystem <: AbstractODESystem """ parameter_dependencies::Vector{Equation} """ + Mapping of conditions which should be true throughout the solution process to corresponding error + messages. These will be added to the equations when calling `debug_system`. + """ + assertions::Dict{BasicSymbolic, String} + """ Metadata for the system, to be used by downstream packages. """ metadata::Any @@ -141,6 +156,15 @@ struct ODESystem <: AbstractODESystem """ gui_metadata::Union{Nothing, GUIMetadata} """ + A boolean indicating if the given `ODESystem` represents a system of DDEs. + """ + is_dde::Bool + """ + A list of points to provide to the solver as tstops. Uses the same syntax as discrete + events. + """ + tstops::Vector{Any} + """ Cache for intermediate tearing state. """ tearing_state::Any @@ -149,7 +173,11 @@ struct ODESystem <: AbstractODESystem """ substitutions::Any """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -169,46 +197,63 @@ struct ODESystem <: AbstractODESystem """ split_idxs::Union{Nothing, Vector{Vector{Int}}} """ + The analysis points removed by transformations, representing connections to be + ignored. The first element of the tuple analysis points connecting systems and + the second are ones connecting variables (for the trivial form of `connect`). + """ + ignored_connections::Union{ + Nothing, Tuple{Vector{IgnoredAnalysisPoint}, Vector{IgnoredAnalysisPoint}}} + """ The hierarchical parent system before simplification. """ parent::Any - function ODESystem(tag, deqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, tgrad, - jac, ctrl_jac, Wfact, Wfact_t, name, systems, defaults, guesses, + function ODESystem( + tag, deqs, iv, dvs, ps, tspan, var_to_name, ctrls, + observed, constraints, costs, consolidate, tgrad, + jac, ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, torn_matching, initializesystem, initialization_eqs, schedule, connector_type, preface, cevents, - devents, parameter_dependencies, - metadata = nothing, gui_metadata = nothing, - tearing_state = nothing, - substitutions = nothing, complete = false, index_cache = nothing, + devents, parameter_dependencies, assertions = Dict{BasicSymbolic, String}(), + metadata = nothing, gui_metadata = nothing, is_dde = false, + tstops = [], tearing_state = nothing, substitutions = nothing, + namespacing = true, complete = false, index_cache = nothing, discrete_subsystems = nothing, solved_unknowns = nothing, - split_idxs = nothing, parent = nothing; checks::Union{Bool, Int} = true) + split_idxs = nothing, ignored_connections = nothing, parent = nothing; + checks::Union{Bool, Int} = true) if checks == true || (checks & CheckComponents) > 0 check_independent_variables([iv]) check_variables(dvs, iv) check_parameters(ps, iv) check_equations(deqs, iv) check_equations(equations(cevents), iv) + check_subsystems(systems) end if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(dvs, ps, iv) check_units(u, deqs) end - new(tag, deqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, tgrad, jac, - ctrl_jac, Wfact, Wfact_t, name, systems, defaults, guesses, torn_matching, + new(tag, deqs, iv, dvs, ps, tspan, var_to_name, + ctrls, observed, constraints, costs, consolidate, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, torn_matching, initializesystem, initialization_eqs, schedule, connector_type, preface, - cevents, devents, parameter_dependencies, metadata, - gui_metadata, tearing_state, substitutions, complete, index_cache, - discrete_subsystems, solved_unknowns, split_idxs, parent) + cevents, devents, parameter_dependencies, assertions, metadata, + gui_metadata, is_dde, tstops, tearing_state, substitutions, namespacing, + complete, index_cache, + discrete_subsystems, solved_unknowns, split_idxs, ignored_connections, parent) end end function ODESystem(deqs::AbstractVector{<:Equation}, iv, dvs, ps; controls = Num[], observed = Equation[], + constraintsystem = nothing, + costs = Num[], + consolidate = nothing, systems = ODESystem[], tspan = nothing, name = nothing, + description = "", default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), @@ -221,9 +266,13 @@ function ODESystem(deqs::AbstractVector{<:Equation}, iv, dvs, ps; continuous_events = nothing, discrete_events = nothing, parameter_dependencies = Equation[], + assertions = Dict(), checks = true, metadata = nothing, - gui_metadata = nothing) + gui_metadata = nothing, + is_dde = nothing, + tstops = [], + discover_from_metadata = true) name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) @assert all(control -> any(isequal.(control, ps)), controls) "All controls must also be parameters." @@ -232,24 +281,30 @@ function ODESystem(deqs::AbstractVector{<:Equation}, iv, dvs, ps; ctrl′ = value.(controls) dvs′ = value.(dvs) dvs′ = filter(x -> !isdelay(x, iv), dvs′) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) if !(isempty(default_u0) && isempty(default_p)) Base.depwarn( "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :ODESystem, force = true) end - defaults = todict(defaults) - defaults = Dict{Any, Any}(value(k) => value(v) - for (k, v) in pairs(defaults) if value(v) !== nothing) + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) var_to_name = Dict() - process_variables!(var_to_name, defaults, dvs′) - process_variables!(var_to_name, defaults, ps′) - - sysguesses = [ModelingToolkit.getguess(st) for st in dvs′] - hasaguess = findall(!isnothing, sysguesses) - var_guesses = dvs′[hasaguess] .=> sysguesses[hasaguess] - sysguesses = isempty(var_guesses) ? Dict() : todict(var_guesses) - guesses = merge(sysguesses, todict(guesses)) - guesses = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(guesses)) + let defaults = discover_from_metadata ? defaults : Dict(), + guesses = discover_from_metadata ? guesses : Dict() + + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + end + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) @@ -264,66 +319,53 @@ function ODESystem(deqs::AbstractVector{<:Equation}, iv, dvs, ps; end cont_callbacks = SymbolicContinuousCallbacks(continuous_events) disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) - parameter_dependencies, ps′ = process_parameter_dependencies( - parameter_dependencies, ps′) - ODESystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), - deqs, iv′, dvs′, ps′, tspan, var_to_name, ctrl′, observed, tgrad, jac, - ctrl_jac, Wfact, Wfact_t, name, systems, defaults, guesses, nothing, initializesystem, - initialization_eqs, schedule, connector_type, preface, cont_callbacks, - disc_callbacks, parameter_dependencies, - metadata, gui_metadata, checks = checks) -end -function ODESystem(eqs, iv; kwargs...) - eqs = collect(eqs) - # NOTE: this assumes that the order of algebraic equations doesn't matter - diffvars = OrderedSet() - allunknowns = 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 + if is_dde === nothing + is_dde = _check_if_dde(deqs, iv′, systems) end - iv = value(iv) - iv === nothing && throw(ArgumentError("Please pass in independent variables.")) - compressed_eqs = Equation[] # equations that need to be expanded later, like `connect(a, b)` - for eq in eqs - eq.lhs isa Union{Symbolic, Number} || (push!(compressed_eqs, eq); continue) - collect_vars!(allunknowns, ps, eq.lhs, iv) - collect_vars!(allunknowns, 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) + + if !isempty(systems) && !isnothing(constraintsystem) + conssystems = ConstraintsSystem[] + for sys in systems + cons = get_constraintsystem(sys) + cons !== nothing && push!(conssystems, cons) end + @set! constraintsystem.systems = conssystems + end + costs = wrap.(costs) + + if length(costs) > 1 && isnothing(consolidate) + error("Must specify a consolidation function for the costs vector.") end + + assertions = Dict{BasicSymbolic, Any}(unwrap(k) => v for (k, v) in assertions) + + ODESystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + deqs, iv′, dvs′, ps′, tspan, var_to_name, ctrl′, observed, + constraintsystem, costs, consolidate, tgrad, jac, + ctrl_jac, Wfact, Wfact_t, name, description, systems, + defaults, guesses, nothing, initializesystem, + initialization_eqs, schedule, connector_type, preface, cont_callbacks, + disc_callbacks, parameter_dependencies, assertions, + metadata, gui_metadata, is_dde, tstops, checks = checks) +end + +function ODESystem(eqs, iv; constraints = Equation[], costs = Num[], kwargs...) + diffvars, allunknowns, ps, eqs = process_equations(eqs, iv) + for eq in get(kwargs, :parameter_dependencies, Equation[]) - if eq isa Pair - collect_vars!(allunknowns, ps, eq[1], iv) - collect_vars!(allunknowns, ps, eq[2], iv) - else - collect_vars!(allunknowns, ps, eq.lhs, iv) - collect_vars!(allunknowns, ps, eq.rhs, iv) - end + collect_vars!(allunknowns, ps, eq, iv) + end + + for ssys in get(kwargs, :systems, ODESystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, iv) end + for v in allunknowns isdelay(v, iv) || continue collect_vars!(allunknowns, ps, arguments(v)[1], iv) end + new_ps = OrderedSet() for p in ps if iscall(p) && operation(p) === getindex @@ -339,9 +381,31 @@ function ODESystem(eqs, iv; kwargs...) end end algevars = setdiff(allunknowns, diffvars) - # the orders here are very important! - return ODESystem(Equation[diffeq; algeeq; compressed_eqs], iv, - collect(Iterators.flatten((diffvars, algevars))), collect(new_ps); kwargs...) + + consvars = OrderedSet() + constraintsystem = nothing + if !isempty(constraints) + constraintsystem = process_constraint_system(constraints, allunknowns, new_ps, iv) + for st in get_unknowns(constraintsystem) + iscall(st) ? + !in(operation(st)(iv), allunknowns) && push!(consvars, st) : + !in(st, allunknowns) && push!(consvars, st) + end + for p in parameters(constraintsystem) + !in(p, new_ps) && push!(new_ps, p) + end + end + + if !isempty(costs) + coststs, costps = process_costs(costs, allunknowns, new_ps, iv) + for p in costps + !in(p, new_ps) && push!(new_ps, p) + end + end + costs = wrap.(costs) + + return ODESystem(eqs, iv, collect(Iterators.flatten((diffvars, algevars, consvars))), + collect(new_ps); constraintsystem, costs, kwargs...) end # NOTE: equality does not check cached Jacobian @@ -354,7 +418,11 @@ function Base.:(==)(sys1::ODESystem, sys2::ODESystem) _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && _eq_unordered(get_unknowns(sys1), get_unknowns(sys2)) && _eq_unordered(get_ps(sys1), get_ps(sys2)) && - all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) + _eq_unordered(continuous_events(sys1), continuous_events(sys2)) && + _eq_unordered(discrete_events(sys1), discrete_events(sys2)) && + all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) && + isequal(get_constraintsystem(sys1), get_constraintsystem(sys2)) && + _eq_unordered(get_costs(sys1), get_costs(sys2)) end function flatten(sys::ODESystem, noeqs = false) @@ -365,7 +433,7 @@ function flatten(sys::ODESystem, noeqs = false) return ODESystem(noeqs ? Equation[] : equations(sys), get_iv(sys), unknowns(sys), - parameters(sys), + parameters(sys; initial_parameters = true), parameter_dependencies = parameter_dependencies(sys), guesses = guesses(sys), observed = observed(sys), @@ -373,179 +441,220 @@ function flatten(sys::ODESystem, noeqs = false) discrete_events = discrete_events(sys), defaults = defaults(sys), name = nameof(sys), + description = description(sys), initialization_eqs = initialization_equations(sys), - checks = false) + assertions = assertions(sys), + is_dde = is_dde(sys), + tstops = symbolic_tstops(sys), + metadata = get_metadata(sys), + checks = false, + # without this, any defaults/guesses obtained from metadata that were + # later removed by the user will be re-added. Right now, we just want to + # retain `defaults(sys)` as-is. + discover_from_metadata = false) end end ODESystem(eq::Equation, args...; kwargs...) = ODESystem([eq], args...; kwargs...) """ -$(SIGNATURES) - -Build the observed function assuming the observed equations are all explicit, -i.e. there are no cycles. + build_explicit_observed_function(sys, ts; kwargs...) -> Function(s) + +Generates a function that computes the observed value(s) `ts` in the system `sys`, while making the assumption that there are no cycles in the equations. + +## Arguments +- `sys`: The system for which to generate the function +- `ts`: The symbolic observed values whose value should be computed + +## Keywords +- `return_inplace = false`: If true and the observed value is a vector, then return both the in place and out of place methods. +- `expression = false`: Generates a Julia `Expr`` computing the observed value if `expression` is true +- `eval_expression = false`: If true and `expression = false`, evaluates the returned function in the module `eval_module` +- `output_type = Array` the type of the array generated by a out-of-place vector-valued function +- `param_only = false` if true, only allow the generated function to access system parameters +- `inputs = nothing` additinoal symbolic variables that should be provided to the generated function +- `checkbounds = true` checks bounds if true when destructuring parameters +- `op = Operator` sets the recursion terminator for the walk done by `vars` to identify the variables that appear in `ts`. See the documentation for `vars` for more detail. +- `throw = true` if true, throw an error when generating a function for `ts` that reference variables that do not exist. +- `mkarray`: only used if the output is an array (that is, `!isscalar(ts)` and `ts` is not a tuple, in which case the result will always be a tuple). Called as `mkarray(ts, output_type)` where `ts` are the expressions to put in the array and `output_type` is the argument of the same name passed to build_explicit_observed_function. +- `cse = true`: Whether to use Common Subexpression Elimination (CSE) to generate a more efficient function. + +## Returns + +The return value will be either: +* a single function `f_oop` if the input is a scalar or if the input is a Vector but `return_inplace` is false +* the out of place and in-place functions `(f_ip, f_oop)` if `return_inplace` is true and the input is a `Vector` + +The function(s) `f_oop` (and potentially `f_ip`) will be: +* `RuntimeGeneratedFunction`s by default, +* A Julia `Expr` if `expression` is true, +* A directly evaluated Julia function in the module `eval_module` if `eval_expression` is true and `expression` is false. + +The signatures will be of the form `g(...)` with arguments: + +- `output` for in-place functions +- `unknowns` if `param_only` is `false` +- `inputs` if `inputs` is an array of symbolic inputs that should be available in `ts` +- `p...` unconditionally; note that in the case of `MTKParameters` more than one parameters argument may be present, so it must be splatted +- `t` if the system is time-dependent; for example `NonlinearSystem` will not have `t` + +For example, a function `g(op, unknowns, p..., inputs, t)` will be the in-place function generated if `return_inplace` is true, `ts` is a vector, +an array of inputs `inputs` is given, and `param_only` is false for a time-dependent system. """ function build_explicit_observed_function(sys, ts; inputs = nothing, + disturbance_inputs = nothing, + disturbance_argument = false, expression = false, eval_expression = false, eval_module = @__MODULE__, output_type = Array, checkbounds = true, - drop_expr = drop_expr, - ps = parameters(sys), + ps = parameters(sys; initial_parameters = true), return_inplace = false, param_only = false, op = Operator, - throw = true) - if (isscalar = symbolic_type(ts) !== NotSymbolic()) - ts = [ts] - end - ts = unwrap.(ts) - - vars = Set() - foreach(v -> vars!(vars, v; op), ts) - ivs = independent_variables(sys) - dep_vars = scalarize(setdiff(vars, ivs)) - - obs = param_only ? Equation[] : observed(sys) - - cs = collect_constants(obs) - if !isempty(cs) > 0 - cmap = map(x -> x => getdefault(x), cs) - obs = map(x -> x.lhs ~ substitute(x.rhs, cmap), obs) - end - - sts = param_only ? Set() : Set(unknowns(sys)) - sts = param_only ? Set() : - union(sts, - Set(arguments(st)[1] for st in sts if iscall(st) && operation(st) === getindex)) - - observed_idx = Dict(x.lhs => i for (i, x) in enumerate(obs)) - param_set = Set(full_parameters(sys)) - param_set = union(param_set, - Set(arguments(p)[1] for p in param_set if iscall(p) && operation(p) === getindex)) - param_set_ns = Set(unknowns(sys, p) for p in full_parameters(sys)) - param_set_ns = union(param_set_ns, - Set(arguments(p)[1] - for p in param_set_ns if iscall(p) && operation(p) === getindex)) - namespaced_to_obs = Dict(unknowns(sys, x.lhs) => x.lhs for x in obs) - namespaced_to_sts = param_only ? Dict() : - Dict(unknowns(sys, x) => x for x in unknowns(sys)) - - # FIXME: This is a rather rough estimate of dependencies. We assume - # the expression depends on everything before the `maxidx`. - subs = Dict() - maxidx = 0 - for s in dep_vars - if s in param_set || s in param_set_ns || - iscall(s) && - operation(s) === getindex && - (arguments(s)[1] in param_set || arguments(s)[1] in param_set_ns) - continue + throw = true, + cse = true, + mkarray = nothing) + is_tuple = ts isa Tuple + if is_tuple + ts = collect(ts) + output_type = Tuple + end + + allsyms = all_symbols(sys) + if symbolic_type(ts) == NotSymbolic() && ts isa AbstractArray + ts = map(x -> symbol_to_symbolic(sys, x; allsyms), ts) + else + ts = symbol_to_symbolic(sys, ts; allsyms) + end + + vs = ModelingToolkit.vars(ts; op) + namespace_subs = Dict() + ns_map = Dict{Any, Any}(renamespace(sys, eq.lhs) => eq.lhs for eq in observed(sys)) + for sym in unknowns(sys) + ns_map[renamespace(sys, sym)] = sym + if iscall(sym) && operation(sym) === getindex + ns_map[renamespace(sys, arguments(sym)[1])] = arguments(sym)[1] end - idx = get(observed_idx, s, nothing) - if idx !== nothing - idx > maxidx && (maxidx = idx) - else - s′ = get(namespaced_to_obs, s, nothing) - if s′ !== nothing - subs[s] = s′ - s = s′ - idx = get(observed_idx, s, nothing) - end - if idx !== nothing - idx > maxidx && (maxidx = idx) - elseif !(s in sts) - s′ = get(namespaced_to_sts, s, nothing) - if s′ !== nothing - subs[s] = s′ - continue - end - if throw - Base.throw(ArgumentError("$s is neither an observed nor an unknown variable.")) - else - # TODO: return variables that don't exist in the system. - return nothing - end - end - continue + 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 - ts = map(t -> substitute(t, subs), ts) - obsexprs = [] - for i in 1:maxidx - eq = obs[i] - lhs = eq.lhs - rhs = eq.rhs - push!(obsexprs, lhs ← rhs) + 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) - if inputs !== nothing - ps = setdiff(ps, inputs) # Inputs have been converted to parameters by io_preprocessing, remove those from the parameter list - end - _ps = ps - if ps isa Tuple - ps = DestructuredArgs.(unwrap.(ps), inbounds = !checkbounds) - elseif has_index_cache(sys) && get_index_cache(sys) !== nothing - ps = DestructuredArgs.(reorder_parameters(get_index_cache(sys), unwrap.(ps))) - if isempty(ps) && inputs !== nothing - ps = (:EMPTY,) + 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 - ps = (DestructuredArgs(unwrap.(ps), inbounds = !checkbounds),) + Returns(true) + end + dvs = if param_only + () + else + (unknowns(sys),) end - dvs = DestructuredArgs(unknowns(sys), inbounds = !checkbounds) if inputs === nothing - args = param_only ? [ps..., ivs...] : [dvs, ps..., ivs...] + inputs = () else - inputs = unwrap.(inputs) - ipts = DestructuredArgs(inputs, inbounds = !checkbounds) - args = param_only ? [ipts, ps..., ivs...] : [dvs, ipts, ps..., ivs...] + ps = setdiff(ps, inputs) # Inputs have been converted to parameters by io_preprocessing, remove those from the parameter list + inputs = (inputs,) end - pre = get_postprocess_fbody(sys) - - array_wrapper = if param_only - wrap_array_vars(sys, ts; ps = _ps, dvs = nothing, inputs) .∘ - wrap_parameter_dependencies(sys, isscalar) + 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 - wrap_array_vars(sys, ts; ps = _ps, inputs) .∘ - wrap_parameter_dependencies(sys, isscalar) - end - # Need to keep old method of building the function since it uses `output_type`, - # which can't be provided to `build_function` - oop_fn = Func(args, [], - pre(Let(obsexprs, - isscalar ? ts[1] : MakeArray(ts, output_type), - false))) |> array_wrapper[1] |> toexpr - oop_fn = expression ? oop_fn : eval_or_rgf(oop_fn; eval_expression, eval_module) - - if !isscalar - iip_fn = build_function(ts, - args...; - postprocess_fbody = pre, - wrap_code = array_wrapper .∘ wrap_assignments(isscalar, obsexprs), - expression = Val{true})[2] - if !expression - iip_fn = eval_or_rgf(iip_fn; eval_expression, eval_module) - end + disturbance_inputs = () end - if isscalar || !return_inplace - return oop_fn + ps = reorder_parameters(sys, ps) + iv = if is_time_dependent(sys) + (get_iv(sys),) else - return oop_fn, iip_fn + () + end + args = (dvs..., inputs..., ps..., iv..., disturbance_inputs...) + p_start = length(dvs) + length(inputs) + 1 + p_end = length(dvs) + length(inputs) + length(ps) + fns = build_function_wrapper( + sys, ts, args...; p_start, p_end, filter_observed = obsfilter, + output_type, mkarray, try_namespaced = true, expression = Val{true}, cse) + if fns isa Tuple + if expression + return return_inplace ? fns : fns[1] + end + oop, iip = eval_or_rgf.(fns; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + p_start + is_dde(sys), length(args) - length(ps) + 1 + is_dde(sys), is_split(sys))}( + oop, iip) + return return_inplace ? (f, f) : f + else + if expression + return fns + end + f = eval_or_rgf(fns; eval_expression, eval_module) + f = GeneratedFunctionWrapper{( + p_start + is_dde(sys), length(args) - length(ps) + 1 + is_dde(sys), is_split(sys))}( + f, nothing) + return f + end +end + +function populate_delays(delays::Set, obsexprs, histfn, sys, sym) + _vars_util = vars(sym) + for v in _vars_util + v in delays && continue + iscall(v) && issym(operation(v)) && (args = arguments(v); length(args) == 1) && + iscall(only(args)) || continue + + idx = variable_index(sys, operation(v)(get_iv(sys))) + idx === nothing && error("Delay term $v is not an unknown in the system") + push!(delays, v) + push!(obsexprs, v ← histfn(only(args))[idx]) end end function _eq_unordered(a, b) + # a and b may be multidimensional + # e.g. comparing noiseeqs of SDESystem + a = vec(a) + b = vec(b) length(a) === length(b) || return false n = length(a) idxs = Set(1:n) for x in a idx = findfirst(isequal(x), b) + # loop since there might be multiple identical entries in a/b + # and while we might have already matched the first there could + # be a second that is equal to x + while idx !== nothing && !(idx in idxs) + idx = findnext(isequal(x), b, idx + 1) + end idx === nothing && return false - idx ∈ idxs || return false delete!(idxs, idx) end return true @@ -631,3 +740,119 @@ function add_accumulations(sys::ODESystem, vars::Vector{<:Pair}) @set! sys.unknowns = [get_unknowns(sys); avars] @set! sys.defaults = merge(get_defaults(sys), Dict(a => 0.0 for a in avars)) end + +function Base.show(io::IO, mime::MIME"text/plain", sys::ODESystem; hint = true, bold = true) + # Print general AbstractSystem information + invoke(Base.show, Tuple{typeof(io), typeof(mime), AbstractSystem}, + io, mime, sys; hint, bold) + + name = nameof(sys) + + # Print initialization equations (unique to ODESystems) + nini = length(initialization_equations(sys)) + nini > 0 && printstyled(io, "\nInitialization equations ($nini):"; bold) + nini > 0 && hint && print(io, " see initialization_equations($name)") + + return nothing +end + +""" +Build the constraint system for the ODESystem. +""" +function process_constraint_system( + constraints::Vector{Equation}, sts, ps, iv; consname = :cons) + isempty(constraints) && return nothing + + constraintsts = OrderedSet() + constraintps = OrderedSet() + for cons in constraints + collect_vars!(constraintsts, constraintps, cons, iv) + end + + # Validate the states. + validate_vars_and_find_ps!(constraintsts, constraintps, sts, iv) + + ConstraintsSystem( + constraints, collect(constraintsts), collect(constraintps); name = consname) +end + +""" +Process the costs for the constraint system. +""" +function process_costs(costs::Vector, sts, ps, iv) + coststs = OrderedSet() + costps = OrderedSet() + for cost in costs + collect_vars!(coststs, costps, cost, iv) + end + + validate_vars_and_find_ps!(coststs, costps, sts, iv) + coststs, costps +end + +""" +Validate that all the variables in an auxiliary system of the ODESystem (constraint or costs) are +well-formed states or parameters. + - Callable/delay variables (e.g. of the form x(0.6) should be unknowns of the system (and have one arg, etc.) + - Callable/delay parameters should be parameters of the system + +Return the set of additional parameters found in the system, e.g. in x(p) ~ 3 then p should be added as a +parameter of the system. +""" +function validate_vars_and_find_ps!(auxvars, auxps, sysvars, iv) + sts = sysvars + + for var in auxvars + if !iscall(var) + occursin(iv, var) && (var ∈ sts || + throw(ArgumentError("Time-dependent variable $var is not an unknown of the system."))) + elseif length(arguments(var)) > 1 + throw(ArgumentError("Too many arguments for variable $var.")) + elseif length(arguments(var)) == 1 + arg = only(arguments(var)) + operation(var)(iv) ∈ sts || + throw(ArgumentError("Variable $var is not a variable of the ODESystem. Called variables must be variables of the ODESystem.")) + + isequal(arg, iv) || isparameter(arg) || arg isa Integer || + arg isa AbstractFloat || + throw(ArgumentError("Invalid argument specified for variable $var. The argument of the variable should be either $iv, a parameter, or a value specifying the time that the constraint holds.")) + + isparameter(arg) && push!(auxps, arg) + else + var ∈ sts && + @warn "Variable $var has no argument. It will be interpreted as $var($iv), and the constraint will apply to the entire interval." + end + end +end + +""" +Generate a function that takes a solution object and computes the cost function obtained by coalescing the costs vector. +""" +function generate_cost_function(sys::ODESystem, kwargs...) + costs = get_costs(sys) + consolidate = get_consolidate(sys) + iv = get_iv(sys) + + ps = parameters(sys; initial_parameters = false) + sts = unknowns(sys) + np = length(ps) + ns = length(sts) + stidxmap = Dict([v => i for (i, v) in enumerate(sts)]) + pidxmap = Dict([v => i for (i, v) in enumerate(ps)]) + + @variables sol(..)[1:ns] + for st in vars(costs) + x = operation(st) + t = only(arguments(st)) + idx = stidxmap[x(iv)] + + costs = map(c -> Symbolics.fast_substitute(c, Dict(x(t) => sol(t)[idx])), costs) + end + + _p = reorder_parameters(sys, ps) + fs = build_function_wrapper(sys, costs, sol, _p..., t; output_type = Array, kwargs...) + vc_oop, vc_iip = eval_or_rgf.(fs) + + cost(sol, p, t) = consolidate(vc_oop(sol, p, t)) + return cost +end diff --git a/src/systems/diffeqs/sdesystem.jl b/src/systems/diffeqs/sdesystem.jl index ee16728133..3fa1302630 100644 --- a/src/systems/diffeqs/sdesystem.jl +++ b/src/systems/diffeqs/sdesystem.jl @@ -80,6 +80,10 @@ struct SDESystem <: AbstractODESystem """ name::Symbol """ + A description of the system. + """ + description::String + """ The internal systems. These are required to have unique names. """ systems::Vector{SDESystem} @@ -89,6 +93,19 @@ struct SDESystem <: AbstractODESystem """ defaults::Dict """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ Type of the system. """ connector_type::Any @@ -109,6 +126,11 @@ struct SDESystem <: AbstractODESystem """ parameter_dependencies::Vector{Equation} """ + Mapping of conditions which should be true throughout the solution process to corresponding error + messages. These will be added to the equations when calling `debug_system`. + """ + assertions::Dict{BasicSymbolic, String} + """ Metadata for the system, to be used by downstream packages. """ metadata::Any @@ -117,7 +139,11 @@ struct SDESystem <: AbstractODESystem """ gui_metadata::Union{Nothing, GUIMetadata} """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -133,15 +159,23 @@ struct SDESystem <: AbstractODESystem be `true` when `noiseeqs isa Vector`. """ is_scalar_noise::Bool + """ + A boolean indicating if the given `ODESystem` represents a system of DDEs. + """ + is_dde::Bool isscheduled::Bool + tearing_state::Any function SDESystem(tag, deqs, neqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, - tgrad, - jac, - ctrl_jac, Wfact, Wfact_t, name, systems, defaults, connector_type, - cevents, devents, parameter_dependencies, metadata = nothing, gui_metadata = nothing, + tgrad, jac, ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, + guesses, initializesystem, initialization_eqs, connector_type, + cevents, devents, parameter_dependencies, assertions = Dict{ + BasicSymbolic, Nothing}, + metadata = nothing, gui_metadata = nothing, namespacing = true, complete = false, index_cache = nothing, parent = nothing, is_scalar_noise = false, - isscheduled = false; + is_dde = false, + isscheduled = false, + tearing_state = nothing; checks::Union{Bool, Int} = true) if checks == true || (checks & CheckComponents) > 0 check_independent_variables([iv]) @@ -156,16 +190,17 @@ struct SDESystem <: AbstractODESystem if is_scalar_noise && neqs isa AbstractMatrix throw(ArgumentError("Noise equations ill-formed. Received a matrix of noise equations of size $(size(neqs)), but `is_scalar_noise` was set to `true`. Scalar noise is only compatible with an `AbstractVector` of noise equations.")) end + check_subsystems(systems) end if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(dvs, ps, iv) check_units(u, deqs, neqs) end new(tag, deqs, neqs, iv, dvs, ps, tspan, var_to_name, ctrls, observed, tgrad, jac, - ctrl_jac, - Wfact, Wfact_t, name, systems, defaults, connector_type, cevents, devents, - parameter_dependencies, metadata, gui_metadata, complete, index_cache, parent, is_scalar_noise, - isscheduled) + ctrl_jac, Wfact, Wfact_t, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, connector_type, cevents, + devents, parameter_dependencies, assertions, metadata, gui_metadata, namespacing, + complete, index_cache, parent, is_scalar_noise, is_dde, isscheduled, tearing_state) end end @@ -177,24 +212,31 @@ function SDESystem(deqs::AbstractVector{<:Equation}, neqs::AbstractArray, iv, dv default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], name = nothing, + description = "", connector_type = nothing, checks = true, continuous_events = nothing, discrete_events = nothing, parameter_dependencies = Equation[], + assertions = Dict{BasicSymbolic, String}(), metadata = nothing, gui_metadata = nothing, - complete = false, index_cache = nothing, parent = nothing, - is_scalar_noise = false) + is_scalar_noise = false, + is_dde = nothing) name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) iv′ = value(iv) dvs′ = value.(dvs) ps′ = value.(ps) ctrl′ = value.(controls) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) sysnames = nameof.(systems) if length(unique(sysnames)) != length(sysnames) @@ -205,13 +247,21 @@ function SDESystem(deqs::AbstractVector{<:Equation}, neqs::AbstractArray, iv, dv "`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) if value(v) !== nothing) + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) var_to_name = Dict() - process_variables!(var_to_name, defaults, dvs′) - process_variables!(var_to_name, defaults, ps′) + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) tgrad = RefValue(EMPTY_TGRAD) @@ -221,33 +271,131 @@ function SDESystem(deqs::AbstractVector{<:Equation}, neqs::AbstractArray, iv, dv Wfact_t = RefValue(EMPTY_JAC) cont_callbacks = SymbolicContinuousCallbacks(continuous_events) disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) - parameter_dependencies, ps′ = process_parameter_dependencies( - parameter_dependencies, ps′) + if is_dde === nothing + is_dde = _check_if_dde(deqs, iv′, systems) + end + assertions = Dict{BasicSymbolic, Any}(unwrap(k) => v for (k, v) in assertions) SDESystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), deqs, neqs, iv′, dvs′, ps′, tspan, var_to_name, ctrl′, observed, tgrad, jac, - ctrl_jac, Wfact, Wfact_t, name, systems, defaults, connector_type, - cont_callbacks, disc_callbacks, parameter_dependencies, metadata, gui_metadata, - complete, index_cache, parent, is_scalar_noise; checks = checks) + ctrl_jac, Wfact, Wfact_t, name, description, systems, defaults, guesses, + initializesystem, initialization_eqs, connector_type, + cont_callbacks, disc_callbacks, parameter_dependencies, assertions, metadata, gui_metadata, + true, false, index_cache, parent, is_scalar_noise, is_dde; checks = checks) end function SDESystem(sys::ODESystem, neqs; kwargs...) SDESystem(equations(sys), neqs, get_iv(sys), unknowns(sys), parameters(sys); kwargs...) end +function SDESystem(eqs::Vector{Equation}, noiseeqs::AbstractArray, iv; kwargs...) + diffvars, allunknowns, ps, eqs = process_equations(eqs, iv) + + for eq in get(kwargs, :parameter_dependencies, Equation[]) + collect_vars!(allunknowns, ps, eq, iv) + end + + for ssys in get(kwargs, :systems, ODESystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, iv) + end + + for v in allunknowns + isdelay(v, iv) || continue + collect_vars!(allunknowns, ps, arguments(v)[1], iv) + end + + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + + # validate noise equations + noisedvs = OrderedSet() + noiseps = OrderedSet() + collect_vars!(noisedvs, noiseps, noiseeqs, iv) + for dv in noisedvs + dv ∈ allunknowns || + throw(ArgumentError("Variable $dv in noise equations is not an unknown of the system.")) + end + algevars = setdiff(allunknowns, diffvars) + return SDESystem(eqs, noiseeqs, iv, Iterators.flatten((diffvars, algevars)), + [collect(ps); collect(noiseps)]; kwargs...) +end + +function SDESystem(eq::Equation, noiseeqs::AbstractArray, args...; kwargs...) + SDESystem([eq], noiseeqs, args...; kwargs...) +end +function SDESystem(eq::Equation, noiseeq, args...; kwargs...) + SDESystem([eq], [noiseeq], args...; kwargs...) +end + function Base.:(==)(sys1::SDESystem, sys2::SDESystem) sys1 === sys2 && return true iv1 = get_iv(sys1) iv2 = get_iv(sys2) isequal(iv1, iv2) && isequal(nameof(sys1), nameof(sys2)) && - isequal(get_eqs(sys1), get_eqs(sys2)) && - isequal(get_noiseeqs(sys1), get_noiseeqs(sys2)) && + _eq_unordered(get_eqs(sys1), get_eqs(sys2)) && + _eq_unordered(get_noiseeqs(sys1), get_noiseeqs(sys2)) && isequal(get_is_scalar_noise(sys1), get_is_scalar_noise(sys2)) && _eq_unordered(get_unknowns(sys1), get_unknowns(sys2)) && _eq_unordered(get_ps(sys1), get_ps(sys2)) && + _eq_unordered(continuous_events(sys1), continuous_events(sys2)) && + _eq_unordered(discrete_events(sys1), discrete_events(sys2)) && all(s1 == s2 for (s1, s2) in zip(get_systems(sys1), get_systems(sys2))) end +""" + function ODESystem(sys::SDESystem) + +Convert an `SDESystem` to the equivalent `ODESystem` using `@brownian` variables instead +of noise equations. The returned system will not be `iscomplete` and will not have an +index cache, regardless of `iscomplete(sys)`. +""" +function ODESystem(sys::SDESystem) + neqs = get_noiseeqs(sys) + eqs = equations(sys) + is_scalar_noise = get_is_scalar_noise(sys) + nbrownian = if is_scalar_noise + length(neqs) + else + size(neqs, 2) + end + brownvars = map(1:nbrownian) do i + name = gensym(Symbol(:brown_, i)) + only(@brownian $name) + end + if is_scalar_noise + brownterms = reduce(+, neqs .* brownvars; init = 0) + neweqs = map(eqs) do eq + eq.lhs ~ eq.rhs + brownterms + end + else + if neqs isa AbstractVector + neqs = reshape(neqs, (length(neqs), 1)) + end + brownterms = neqs * brownvars + neweqs = map(eqs, brownterms) do eq, brown + eq.lhs ~ eq.rhs + brown + end + end + newsys = ODESystem(neweqs, get_iv(sys), unknowns(sys), parameters(sys); + parameter_dependencies = parameter_dependencies(sys), defaults = defaults(sys), + continuous_events = continuous_events(sys), discrete_events = discrete_events(sys), + assertions = assertions(sys), + name = nameof(sys), description = description(sys), metadata = get_metadata(sys)) + @set newsys.parent = sys +end + function __num_isdiag_noise(mat) for i in axes(mat, 1) nnz = 0 @@ -275,22 +423,10 @@ function __get_num_diag_noise(mat) end function generate_diffusion_function(sys::SDESystem, dvs = unknowns(sys), - ps = parameters(sys); isdde = false, kwargs...) + ps = parameters(sys; initial_parameters = true); isdde = false, kwargs...) eqs = get_noiseeqs(sys) - if isdde - eqs = delay_to_function(sys, eqs) - end - u = map(x -> time_varying_as_func(value(x), sys), dvs) - p = if has_index_cache(sys) && get_index_cache(sys) !== nothing - reorder_parameters(get_index_cache(sys), ps) - else - (map(x -> time_varying_as_func(value(x), sys), ps),) - end - if isdde - return build_function(eqs, u, DDE_HISTORY_FUN, p..., get_iv(sys); kwargs...) - else - return build_function(eqs, u, p..., get_iv(sys); kwargs...) - end + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, eqs, dvs, p..., get_iv(sys); kwargs...) end """ @@ -340,7 +476,8 @@ function stochastic_integral_transform(sys::SDESystem, correction_factor) end SDESystem(deqs, get_noiseeqs(sys), get_iv(sys), unknowns(sys), parameters(sys), - name = name, parameter_dependencies = parameter_dependencies(sys), checks = false) + name = name, description = description(sys), + parameter_dependencies = parameter_dependencies(sys), checks = false) end """ @@ -448,7 +585,8 @@ function Girsanov_transform(sys::SDESystem, u; θ0 = 1.0) # return modified SDE System SDESystem(deqs, noiseeqs, get_iv(sys), unknown_vars, parameters(sys); defaults = Dict(θ => θ0), observed = [weight ~ θ / θ0], - name = name, parameter_dependencies = parameter_dependencies(sys), + name = name, description = description(sys), + parameter_dependencies = parameter_dependencies(sys), checks = false) end @@ -457,86 +595,80 @@ function DiffEqBase.SDEFunction{iip, specialize}(sys::SDESystem, dvs = unknowns( u0 = nothing; version = nothing, tgrad = false, sparse = false, jac = false, Wfact = false, eval_expression = false, + sparsity = false, analytic = nothing, eval_module = @__MODULE__, - checkbounds = false, - kwargs...) where {iip, specialize} + checkbounds = false, initialization_data = nothing, + cse = true, kwargs...) where {iip, specialize} if !iscomplete(sys) error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEFunction`") end dvs = scalarize.(dvs) - f_gen = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) g_gen = generate_diffusion_function(sys, dvs, ps; expression = Val{true}, - kwargs...) + cse, kwargs...) g_oop, g_iip = eval_or_rgf.(g_gen; eval_expression, eval_module) - f(u, p, t) = f_oop(u, p, t) - f(u, p::MTKParameters, t) = f_oop(u, p..., t) - f(du, u, p, t) = f_iip(du, u, p, t) - f(du, u, p::MTKParameters, t) = f_iip(du, u, p..., t) - g(u, p, t) = g_oop(u, p, t) - g(u, p::MTKParameters, t) = g_oop(u, p..., t) - g(du, u, p, t) = g_iip(du, u, p, t) - g(du, u, p::MTKParameters, t) = g_iip(du, u, p..., t) + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) + g = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(g_oop, g_iip) if tgrad - tgrad_gen = generate_tgrad(sys, dvs, ps; expression = Val{true}, + tgrad_gen = generate_tgrad(sys, dvs, ps; expression = Val{true}, cse, kwargs...) tgrad_oop, tgrad_iip = eval_or_rgf.(tgrad_gen; eval_expression, eval_module) - - _tgrad(u, p, t) = tgrad_oop(u, p, t) - _tgrad(u, p::MTKParameters, t) = tgrad_oop(u, p..., t) - _tgrad(J, u, p, t) = tgrad_iip(J, u, p, t) - _tgrad(J, u, p::MTKParameters, t) = tgrad_iip(J, u, p..., t) + _tgrad = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(tgrad_oop, tgrad_iip) else _tgrad = nothing end if jac jac_gen = generate_jacobian(sys, dvs, ps; expression = Val{true}, - sparse = sparse, kwargs...) + sparse = sparse, cse, kwargs...) jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) - _jac(u, p, t) = jac_oop(u, p, t) - _jac(u, p::MTKParameters, t) = jac_oop(u, p..., t) - _jac(J, u, p, t) = jac_iip(J, u, p, t) - _jac(J, u, p::MTKParameters, t) = jac_iip(J, u, p..., t) + _jac = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(jac_oop, jac_iip) else _jac = nothing end if Wfact tmp_Wfact, tmp_Wfact_t = generate_factorized_W(sys, dvs, ps, true; - expression = Val{true}, kwargs...) + expression = Val{true}, cse, kwargs...) Wfact_oop, Wfact_iip = eval_or_rgf.(tmp_Wfact; eval_expression, eval_module) Wfact_oop_t, Wfact_iip_t = eval_or_rgf.(tmp_Wfact_t; eval_expression, eval_module) - _Wfact(u, p, dtgamma, t) = Wfact_oop(u, p, dtgamma, t) - _Wfact(u, p::MTKParameters, dtgamma, t) = Wfact_oop(u, p..., dtgamma, t) - _Wfact(W, u, p, dtgamma, t) = Wfact_iip(W, u, p, dtgamma, t) - _Wfact(W, u, p::MTKParameters, dtgamma, t) = Wfact_iip(W, u, p..., dtgamma, t) - _Wfact_t(u, p, dtgamma, t) = Wfact_oop_t(u, p, dtgamma, t) - _Wfact_t(u, p::MTKParameters, dtgamma, t) = Wfact_oop_t(u, p..., dtgamma, t) - _Wfact_t(W, u, p, dtgamma, t) = Wfact_iip_t(W, u, p, dtgamma, t) - _Wfact_t(W, u, p::MTKParameters, dtgamma, t) = Wfact_iip_t(W, u, p..., dtgamma, t) + _Wfact = GeneratedFunctionWrapper{(2, 4, is_split(sys))}(Wfact_oop, Wfact_iip) + _Wfact_t = GeneratedFunctionWrapper{(2, 4, is_split(sys))}(Wfact_oop_t, Wfact_iip_t) else _Wfact, _Wfact_t = nothing, nothing end M = calculate_massmatrix(sys) + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + W_prototype = similar(W_sparsity(sys), uElType) + else + W_prototype = nothing + end + _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0', M) - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) - SDEFunction{iip, specialize}(f, g, + SDEFunction{iip, specialize}(f, g; sys = sys, jac = _jac === nothing ? nothing : _jac, tgrad = _tgrad === nothing ? nothing : _tgrad, + mass_matrix = _M, + jac_prototype = W_prototype, + observed = observedfun, + sparsity = sparsity ? W_sparsity(sys) : nothing, + analytic = analytic, Wfact = _Wfact === nothing ? nothing : _Wfact, Wfact_t = _Wfact_t === nothing ? nothing : _Wfact_t, - mass_matrix = _M, - observed = observedfun) + initialization_data) end """ @@ -606,6 +738,16 @@ function SDEFunctionExpr{iip}(sys::SDESystem, dvs = unknowns(sys), _jac = :nothing end + M = calculate_massmatrix(sys) + _M = (u0 === nothing || M == I) ? M : ArrayInterface.restructure(u0 .* u0', M) + + if sparse + uElType = u0 === nothing ? Float64 : eltype(u0) + W_prototype = similar(W_sparsity(sys), uElType) + else + W_prototype = nothing + end + if Wfact tmp_Wfact, tmp_Wfact_t = generate_factorized_W( sys, dvs, ps; expression = Val{true}, @@ -616,20 +758,18 @@ function SDEFunctionExpr{iip}(sys::SDESystem, dvs = unknowns(sys), _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 + W_prototype = $W_prototype Wfact = $_Wfact Wfact_t = $_Wfact_t M = $_M SDEFunction{$iip}(f, g, jac = jac, + jac_prototype = W_prototype, tgrad = tgrad, Wfact = Wfact, Wfact_t = Wfact_t, @@ -650,9 +790,10 @@ function DiffEqBase.SDEProblem{iip, specialize}( if !iscomplete(sys) error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEProblem`") end - f, u0, p = process_DEProblem( + + f, u0, p = process_SciMLProblem( SDEFunction{iip, specialize}, sys, u0map, parammap; check_length, - kwargs...) + t = tspan === nothing ? nothing : tspan[1], kwargs...) cbs = process_events(sys; callback, kwargs...) sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) @@ -674,8 +815,19 @@ function DiffEqBase.SDEProblem{iip, specialize}( noise = nothing end - SDEProblem{iip}(f, u0, tspan, p; callback = cbs, noise, - noise_rate_prototype = noise_rate_prototype, kwargs...) + kwargs = filter_kwargs(kwargs) + + # Call `remake` so it runs initialization if it is trivial + return remake(SDEProblem{iip}(f, u0, tspan, p; callback = cbs, noise, + noise_rate_prototype = noise_rate_prototype, kwargs...)) +end + +function DiffEqBase.SDEProblem(sys::ODESystem, args...; kwargs...) + if any(ModelingToolkit.isbrownian, unknowns(sys)) + error("SDESystem constructed by defining Brownian variables with @brownian must be simplified by calling `structural_simplify` before a SDEProblem can be constructed.") + else + error("Cannot construct SDEProblem from a normal ODESystem.") + end end """ @@ -736,7 +888,8 @@ function SDEProblemExpr{iip}(sys::SDESystem, u0map, tspan, if !iscomplete(sys) error("A completed `SDESystem` is required. Call `complete` or `structural_simplify` on the system before creating an `SDEProblemExpr`") end - f, u0, p = process_DEProblem(SDEFunctionExpr{iip}, sys, u0map, parammap; check_length, + f, u0, p = process_SciMLProblem( + SDEFunctionExpr{iip}, sys, u0map, parammap; check_length, kwargs...) linenumbers = get(kwargs, :linenumbers, true) sparsenoise === nothing && (sparsenoise = get(kwargs, :sparse, false)) diff --git a/src/systems/discrete_system/discrete_system.jl b/src/systems/discrete_system/discrete_system.jl index 4a2e9bca97..5f7c986659 100644 --- a/src/systems/discrete_system/discrete_system.jl +++ b/src/systems/discrete_system/discrete_system.jl @@ -17,7 +17,7 @@ eqs = [x(k+1) ~ σ*(y-x), @named de = DiscreteSystem(eqs) ``` """ -struct DiscreteSystem <: AbstractTimeDependentSystem +struct DiscreteSystem <: AbstractDiscreteSystem """ A tag for the system. If two systems have the same tag, then they are structurally identical. @@ -42,6 +42,10 @@ struct DiscreteSystem <: AbstractTimeDependentSystem """ name::Symbol """ + A description of the system. + """ + description::String + """ The internal systems. These are required to have unique names. """ systems::Vector{DiscreteSystem} @@ -51,6 +55,19 @@ struct DiscreteSystem <: AbstractTimeDependentSystem """ defaults::Dict """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ Inject assignment statements before the evaluation of the RHS function. """ preface::Any @@ -80,7 +97,11 @@ struct DiscreteSystem <: AbstractTimeDependentSystem """ substitutions::Any """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -94,11 +115,10 @@ struct DiscreteSystem <: AbstractTimeDependentSystem isscheduled::Bool function DiscreteSystem(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, - observed, - name, - systems, defaults, preface, connector_type, parameter_dependencies = Equation[], + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, preface, connector_type, parameter_dependencies = Equation[], metadata = nothing, gui_metadata = nothing, - tearing_state = nothing, substitutions = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, complete = false, index_cache = nothing, parent = nothing, isscheduled = false; checks::Union{Bool, Int} = true) @@ -106,16 +126,17 @@ struct DiscreteSystem <: AbstractTimeDependentSystem check_independent_variables([iv]) check_variables(dvs, iv) check_parameters(ps, iv) + check_subsystems(systems) end if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(dvs, ps, iv) check_units(u, discreteEqs) end - new(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, observed, name, - systems, - defaults, + new(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, preface, connector_type, parameter_dependencies, metadata, gui_metadata, - tearing_state, substitutions, complete, index_cache, parent, isscheduled) + tearing_state, substitutions, namespacing, complete, index_cache, parent, + isscheduled) end end @@ -128,8 +149,12 @@ function DiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; systems = DiscreteSystem[], tspan = nothing, name = nothing, + description = "", default_u0 = Dict(), default_p = Dict(), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], defaults = _merge(Dict(default_u0), Dict(default_p)), preface = nothing, connector_type = nothing, @@ -150,13 +175,21 @@ function DiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :DiscreteSystem, force = true) end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) - for (k, v) in pairs(defaults) if value(v) !== nothing) + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) var_to_name = Dict() - process_variables!(var_to_name, defaults, dvs′) - process_variables!(var_to_name, defaults, ps′) + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) sysnames = nameof.(systems) @@ -164,8 +197,9 @@ function DiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; throw(ArgumentError("System names must be unique.")) end DiscreteSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), - eqs, iv′, dvs′, ps′, tspan, var_to_name, observed, name, systems, - defaults, preface, connector_type, parameter_dependencies, metadata, gui_metadata, kwargs...) + eqs, iv′, dvs′, ps′, tspan, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, preface, connector_type, + parameter_dependencies, metadata, gui_metadata, kwargs...) end function DiscreteSystem(eqs, iv; kwargs...) @@ -175,8 +209,7 @@ function DiscreteSystem(eqs, iv; kwargs...) ps = OrderedSet() iv = value(iv) for eq in eqs - collect_vars!(allunknowns, ps, eq.lhs, iv; op = Shift) - collect_vars!(allunknowns, ps, eq.rhs, iv; op = Shift) + collect_vars!(allunknowns, ps, eq, iv; op = Shift) if iscall(eq.lhs) && operation(eq.lhs) isa Shift isequal(iv, operation(eq.lhs).t) || throw(ArgumentError("A DiscreteSystem can only have one independent variable.")) @@ -187,11 +220,9 @@ function DiscreteSystem(eqs, iv; kwargs...) end for eq in get(kwargs, :parameter_dependencies, Equation[]) if eq isa Pair - collect_vars!(allunknowns, ps, eq[1], iv) - collect_vars!(allunknowns, ps, eq[2], iv) + collect_vars!(allunknowns, ps, eq, iv) else - collect_vars!(allunknowns, ps, eq.lhs, iv) - collect_vars!(allunknowns, ps, eq.rhs, iv) + collect_vars!(allunknowns, ps, eq, iv) end end new_ps = OrderedSet() @@ -212,6 +243,8 @@ function DiscreteSystem(eqs, iv; kwargs...) collect(allunknowns), collect(new_ps); kwargs...) end +DiscreteSystem(eq::Equation, args...; kwargs...) = DiscreteSystem([eq], args...; kwargs...) + function flatten(sys::DiscreteSystem, noeqs = false) systems = get_systems(sys) if isempty(systems) @@ -223,7 +256,11 @@ function flatten(sys::DiscreteSystem, noeqs = false) parameters(sys), observed = observed(sys), defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), checks = false) end end @@ -231,53 +268,35 @@ end function generate_function( sys::DiscreteSystem, dvs = unknowns(sys), ps = parameters(sys); wrap_code = identity, kwargs...) exprs = [eq.rhs for eq in equations(sys)] - wrap_code = wrap_code .∘ wrap_array_vars(sys, exprs) .∘ - wrap_parameter_dependencies(sys, false) - generate_custom_function(sys, exprs, dvs, ps; wrap_code, kwargs...) + generate_custom_function(sys, exprs, dvs, ps; kwargs...) end -function process_DiscreteProblem(constructor, sys::DiscreteSystem, u0map, parammap; - linenumbers = true, parallel = SerialForm(), - use_union = false, - tofloat = !use_union, - eval_expression = false, eval_module = @__MODULE__, - kwargs...) +function shift_u0map_forward(sys::DiscreteSystem, u0map, defs) iv = get_iv(sys) - eqs = equations(sys) - dvs = unknowns(sys) - ps = parameters(sys) - - trueu0map = Dict() - for (k, v) in u0map - k = unwrap(k) + updated = AnyDict() + for k in collect(keys(u0map)) + v = u0map[k] if !((op = operation(k)) isa Shift) - 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)).") - end - trueu0map[Shift(iv, op.steps + 1)(arguments(k)[1])] = v - end - defs = ModelingToolkit.get_defaults(sys) - for var in dvs - if (op = operation(var)) isa Shift && !haskey(trueu0map, var) - root = arguments(var)[1] - haskey(defs, root) || error("Initial condition for $var not provided.") - trueu0map[var] = defs[root] + 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 - if has_index_cache(sys) && get_index_cache(sys) !== nothing - u0, defs = get_u0(sys, trueu0map, parammap) - p = MTKParameters(sys, parammap, trueu0map) - else - u0, p, defs = get_u0_p(sys, trueu0map, parammap; tofloat, use_union) + 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 - - check_eqs_u0(eqs, dvs, u0; kwargs...) - - f = constructor(sys, dvs, ps, u0; - linenumbers = linenumbers, parallel = parallel, - syms = Symbol.(dvs), paramsyms = Symbol.(ps), - eval_expression = eval_expression, eval_module = eval_module, - kwargs...) - return f, u0, p + return updated end """ @@ -289,7 +308,6 @@ function SciMLBase.DiscreteProblem( parammap = SciMLBase.NullParameters(); eval_module = @__MODULE__, eval_expression = false, - use_union = false, kwargs... ) if !iscomplete(sys) @@ -300,8 +318,10 @@ function SciMLBase.DiscreteProblem( eqs = equations(sys) iv = get_iv(sys) - f, u0, p = process_DiscreteProblem( - DiscreteFunction, sys, u0map, parammap; eval_expression, eval_module) + u0map = to_varmap(u0map, dvs) + u0map = shift_u0map_forward(sys, u0map, defaults(sys)) + f, u0, p = process_SciMLProblem( + DiscreteFunction, sys, u0map, parammap; eval_expression, eval_module, build_initializeprob = false) u0 = f(u0, p, tspan[1]) DiscreteProblem(f, u0, tspan, p; kwargs...) end @@ -317,6 +337,19 @@ end function SciMLBase.DiscreteFunction{false}(sys::DiscreteSystem, args...; kwargs...) DiscreteFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) end + +""" +```julia +SciMLBase.DiscreteFunction{iip}(sys::DiscreteSystem, + dvs = unknowns(sys), + ps = parameters(sys); + kwargs...) where {iip} +``` + +Create an `DiscreteFunction` from the [`DiscreteSystem`](@ref). The arguments `dvs` and `ps` +are used to set the order of the dependent variable and parameter vectors, +respectively. +""" function SciMLBase.DiscreteFunction{iip, specialize}( sys::DiscreteSystem, dvs = unknowns(sys), @@ -327,16 +360,15 @@ function SciMLBase.DiscreteFunction{iip, specialize}( t = nothing, eval_expression = false, eval_module = @__MODULE__, - analytic = nothing, + analytic = nothing, cse = true, kwargs...) where {iip, specialize} if !iscomplete(sys) error("A completed `DiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") end f_gen = generate_function(sys, dvs, ps; expression = Val{true}, - expression_module = eval_module, kwargs...) + expression_module = eval_module, cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) - f(u, p, t) = f_oop(u, p, t) - f(du, u, p, t) = f_iip(du, u, p, t) + f = GeneratedFunctionWrapper{(2, 3, is_split(sys))}(f_oop, f_iip) if specialize === SciMLBase.FunctionWrapperSpecialize && iip if u0 === nothing || p === nothing || t === nothing @@ -345,7 +377,8 @@ function SciMLBase.DiscreteFunction{iip, specialize}( f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) end - observedfun = ObservedFunctionCache(sys) + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) DiscreteFunction{iip, specialize}(f; sys = sys, @@ -394,3 +427,5 @@ end function DiscreteFunctionExpr(sys::DiscreteSystem, args...; kwargs...) DiscreteFunctionExpr{true}(sys, args...; kwargs...) end + +supports_initialization(::DiscreteSystem) = false diff --git a/src/systems/discrete_system/implicit_discrete_system.jl b/src/systems/discrete_system/implicit_discrete_system.jl new file mode 100644 index 0000000000..3956c089d4 --- /dev/null +++ b/src/systems/discrete_system/implicit_discrete_system.jl @@ -0,0 +1,443 @@ +""" +$(TYPEDEF) +An implicit system of difference equations. +# Fields +$(FIELDS) +# Example +``` +using ModelingToolkit +using ModelingToolkit: t_nounits as t +@parameters σ=28.0 ρ=10.0 β=8/3 δt=0.1 +@variables x(t)=1.0 y(t)=0.0 z(t)=0.0 +k = ShiftIndex(t) +eqs = [x ~ σ*(y-x(k-1)), + y ~ x(k-1)*(ρ-z(k-1))-y, + z ~ x(k-1)*y(k-1) - β*z] +@named ide = ImplicitDiscreteSystem(eqs,t,[x,y,z],[σ,ρ,β]; tspan = (0, 1000.0)) +``` +""" +struct ImplicitDiscreteSystem <: AbstractDiscreteSystem + """ + A tag for the system. If two systems have the same tag, then they are + structurally identical. + """ + tag::UInt + """The difference equations defining the discrete system.""" + eqs::Vector{Equation} + """Independent variable.""" + iv::BasicSymbolic{Real} + """Dependent (state) variables. Must not contain the independent variable.""" + unknowns::Vector + """Parameter variables. Must not contain the independent variable.""" + ps::Vector + """Time span.""" + tspan::Union{NTuple{2, Any}, Nothing} + """Array variables.""" + var_to_name::Any + """Observed states.""" + observed::Vector{Equation} + """ + The name of the system + """ + name::Symbol + """ + A description of the system. + """ + description::String + """ + The internal systems. These are required to have unique names. + """ + systems::Vector{ImplicitDiscreteSystem} + """ + The default values to use when initial conditions and/or + parameters are not supplied in `ImplicitDiscreteProblem`. + """ + defaults::Dict + """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ + Inject assignment statements before the evaluation of the RHS function. + """ + preface::Any + """ + Type of the system. + """ + connector_type::Any + """ + Topologically sorted parameter dependency equations, where all symbols are parameters and + the LHS is a single parameter. + """ + parameter_dependencies::Vector{Equation} + """ + Metadata for the system, to be used by downstream packages. + """ + metadata::Any + """ + Metadata for MTK GUI. + """ + gui_metadata::Union{Nothing, GUIMetadata} + """ + Cache for intermediate tearing state. + """ + tearing_state::Any + """ + Substitutions generated by tearing. + """ + substitutions::Any + """ + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. + """ + complete::Bool + """ + Cached data for fast symbolic indexing. + """ + index_cache::Union{Nothing, IndexCache} + """ + The hierarchical parent system before simplification. + """ + parent::Any + isscheduled::Bool + + function ImplicitDiscreteSystem(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, preface, connector_type, parameter_dependencies = Equation[], + metadata = nothing, gui_metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, + complete = false, index_cache = nothing, parent = nothing, + isscheduled = false; + checks::Union{Bool, Int} = true) + if checks == true || (checks & CheckComponents) > 0 + check_independent_variables([iv]) + check_variables(dvs, iv) + check_parameters(ps, iv) + check_subsystems(systems) + end + if checks == true || (checks & CheckUnits) > 0 + u = __get_unit_type(dvs, ps, iv) + check_units(u, discreteEqs) + end + new(tag, discreteEqs, iv, dvs, ps, tspan, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, + preface, connector_type, parameter_dependencies, metadata, gui_metadata, + tearing_state, substitutions, namespacing, complete, index_cache, parent, + isscheduled) + end +end + +""" + $(TYPEDSIGNATURES) + +Constructs a ImplicitDiscreteSystem. +""" +function ImplicitDiscreteSystem(eqs::AbstractVector{<:Equation}, iv, dvs, ps; + observed = Num[], + systems = ImplicitDiscreteSystem[], + tspan = nothing, + name = nothing, + description = "", + default_u0 = Dict(), + default_p = Dict(), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], + defaults = _merge(Dict(default_u0), Dict(default_p)), + preface = nothing, + connector_type = nothing, + parameter_dependencies = Equation[], + metadata = nothing, + gui_metadata = nothing, + kwargs...) + name === nothing && + throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) + iv′ = value(iv) + dvs′ = value.(dvs) + ps′ = value.(ps) + if any(hasderiv, eqs) || any(hashold, eqs) || any(hassample, eqs) || any(hasdiff, eqs) + error("Equations in a `ImplicitDiscreteSystem` can only have `Shift` operators.") + end + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :ImplicitDiscreteSystem, force = true) + end + + # Copy equations to canonical form, but do not touch array expressions + eqs = [wrap(eq.lhs) isa Symbolics.Arr ? eq : 0 ~ eq.rhs - eq.lhs for eq in eqs] + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + + sysnames = nameof.(systems) + if length(unique(sysnames)) != length(sysnames) + throw(ArgumentError("System names must be unique.")) + end + ImplicitDiscreteSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), + eqs, iv′, dvs′, ps′, tspan, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, preface, connector_type, + parameter_dependencies, metadata, gui_metadata, kwargs...) +end + +function ImplicitDiscreteSystem(eqs, iv; kwargs...) + eqs = collect(eqs) + diffvars = OrderedSet() + allunknowns = OrderedSet() + ps = OrderedSet() + iv = value(iv) + for eq in eqs + collect_vars!(allunknowns, ps, eq, iv; op = Shift) + if iscall(eq.lhs) && operation(eq.lhs) isa Shift + isequal(iv, operation(eq.lhs).t) || + throw(ArgumentError("An ImplicitDiscreteSystem can only have one independent variable.")) + eq.lhs in diffvars && + throw(ArgumentError("The shift variable $(eq.lhs) is not unique in the system of equations.")) + push!(diffvars, eq.lhs) + end + end + for eq in get(kwargs, :parameter_dependencies, Equation[]) + if eq isa Pair + collect_vars!(allunknowns, ps, eq, iv) + else + collect_vars!(allunknowns, ps, eq, iv) + end + end + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + return ImplicitDiscreteSystem(eqs, iv, + collect(allunknowns), collect(new_ps); kwargs...) +end + +function ImplicitDiscreteSystem(eq::Equation, args...; kwargs...) + ImplicitDiscreteSystem([eq], args...; kwargs...) +end + +function flatten(sys::ImplicitDiscreteSystem, noeqs = false) + systems = get_systems(sys) + if isempty(systems) + return sys + else + return ImplicitDiscreteSystem(noeqs ? Equation[] : equations(sys), + get_iv(sys), + unknowns(sys), + parameters(sys), + observed = observed(sys), + defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), + name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), + checks = false) + end +end + +function generate_function( + sys::ImplicitDiscreteSystem, dvs = unknowns(sys), ps = parameters(sys); wrap_code = identity, kwargs...) + iv = get_iv(sys) + # Algebraic equations get shifted forward 1, to match with differential equations + exprs = map(equations(sys)) do eq + _iszero(eq.lhs) ? distribute_shift(Shift(iv, 1)(eq.rhs)) : (eq.rhs - eq.lhs) + end + + # Handle observables in algebraic equations, since they are shifted + obs = observed(sys) + shifted_obs = Symbolics.Equation[distribute_shift(Shift(iv, 1)(eq)) for eq in obs] + obsidxs = observed_equations_used_by(sys, exprs; obs = shifted_obs) + extra_assignments = [Assignment(shifted_obs[i].lhs, shifted_obs[i].rhs) + for i in obsidxs] + + u_next = map(Shift(iv, 1), dvs) + u = dvs + build_function_wrapper( + sys, exprs, u_next, u, ps..., iv; p_start = 3, extra_assignments, kwargs...) +end + +function shift_u0map_forward(sys::ImplicitDiscreteSystem, u0map, defs) + iv = get_iv(sys) + updated = AnyDict() + for k in collect(keys(u0map)) + v = u0map[k] + if !((op = operation(k)) isa Shift) + isnothing(getunshifted(k)) && + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(k)).") + + updated[k] = v + elseif op.steps > 0 + error("Initial conditions must be for the past state of the unknowns. Instead of providing the condition for $k, provide the condition for $(Shift(iv, -1)(only(arguments(k)))).") + else + updated[k] = v + end + end + for var in unknowns(sys) + op = operation(var) + root = getunshifted(var) + shift = getshift(var) + isnothing(root) && continue + (haskey(updated, Shift(iv, shift)(root)) || haskey(updated, var)) && continue + haskey(defs, root) || error("Initial condition for $var not provided.") + updated[var] = defs[root] + end + return updated +end + +""" + $(TYPEDSIGNATURES) +Generates an ImplicitDiscreteProblem from an ImplicitDiscreteSystem. +""" +function SciMLBase.ImplicitDiscreteProblem( + sys::ImplicitDiscreteSystem, u0map = [], tspan = get_tspan(sys), + parammap = SciMLBase.NullParameters(); + eval_module = @__MODULE__, + eval_expression = false, + kwargs... +) + if !iscomplete(sys) + error("A completed `ImplicitDiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `ImplicitDiscreteProblem`.") + end + dvs = unknowns(sys) + ps = parameters(sys) + eqs = equations(sys) + iv = get_iv(sys) + + u0map = to_varmap(u0map, dvs) + u0map = shift_u0map_forward(sys, u0map, defaults(sys)) + f, u0, p = process_SciMLProblem( + ImplicitDiscreteFunction, sys, u0map, parammap; eval_expression, eval_module, kwargs...) + + kwargs = filter_kwargs(kwargs) + ImplicitDiscreteProblem(f, u0, tspan, p; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction(sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction{true}( + sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.ImplicitDiscreteFunction{false}( + sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end +function SciMLBase.ImplicitDiscreteFunction{iip, specialize}( + sys::ImplicitDiscreteSystem, + dvs = unknowns(sys), + ps = parameters(sys), + u0 = nothing; + version = nothing, + p = nothing, + t = nothing, + eval_expression = false, + eval_module = @__MODULE__, + analytic = nothing, cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `ImplicitDiscreteSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `ImplicitDiscreteProblem`") + end + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, + expression_module = eval_module, cse, kwargs...) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f(u_next, u, p, t) = f_oop(u_next, u, p, t) + f(resid, u_next, u, p, t) = f_iip(resid, u_next, u, p, t) + + if specialize === SciMLBase.FunctionWrapperSpecialize && iip + if u0 === nothing || p === nothing || t === nothing + error("u0, p, and t must be specified for FunctionWrapperSpecialize on ImplicitDiscreteFunction.") + end + f = SciMLBase.wrapfun_iip(f, (u0, u0, p, t)) + end + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + + ImplicitDiscreteFunction{iip, specialize}(f; + sys = sys, + observed = observedfun, + analytic = analytic, + kwargs...) +end + +""" +```julia +ImplicitDiscreteFunctionExpr{iip}(sys::ImplicitDiscreteSystem, dvs = states(sys), + ps = parameters(sys); + version = nothing, + kwargs...) where {iip} +``` + +Create a Julia expression for an `ImplicitDiscreteFunction` from the [`ImplicitDiscreteSystem`](@ref). +The arguments `dvs` and `ps` are used to set the order of the dependent +variable and parameter vectors, respectively. +""" +struct ImplicitDiscreteFunctionExpr{iip} end +struct ImplicitDiscreteFunctionClosure{O, I} <: Function + f_oop::O + f_iip::I +end +(f::ImplicitDiscreteFunctionClosure)(u_next, u, p, t) = f.f_oop(u_next, u, p, t) +function (f::ImplicitDiscreteFunctionClosure)(resid, u_next, u, p, t) + f.f_iip(resid, u_next, u, p, t) +end + +function ImplicitDiscreteFunctionExpr{iip}( + sys::ImplicitDiscreteSystem, dvs = unknowns(sys), + ps = parameters(sys), u0 = nothing; + version = nothing, p = nothing, + linenumbers = false, + simplify = false, + kwargs...) where {iip} + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + + fsym = gensym(:f) + _f = :($fsym = $ImplicitDiscreteFunctionClosure($f_oop, $f_iip)) + + ex = quote + $_f + ImplicitDiscreteFunction{$iip}($fsym) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + +function ImplicitDiscreteFunctionExpr(sys::ImplicitDiscreteSystem, args...; kwargs...) + ImplicitDiscreteFunctionExpr{true}(sys, args...; kwargs...) +end diff --git a/src/systems/if_lifting.jl b/src/systems/if_lifting.jl new file mode 100644 index 0000000000..da069cc76e --- /dev/null +++ b/src/systems/if_lifting.jl @@ -0,0 +1,511 @@ +""" + struct CondRewriter + +Callable struct used to transform symbolic conditions into conditions involving discrete +variables. +""" +struct CondRewriter + """ + The independent variable which the discrete variables depend on. + """ + iv::BasicSymbolic + """ + A mapping from a discrete variables to a `NamedTuple` containing the condition + determining whether the discrete variable needs to be evaluated and the symbolic + expression the discrete variable represents. The expression is used as a rootfinding + function, and zero-crossings trigger re-evaluation of the condition (if `dependency` + is `true`). `expression < 0` is evaluated on an up-crossing and `expression <= 0` is + evaluated on a down-crossing to get the updated value of the condition variable. + """ + conditions::Dict{Any, @NamedTuple{dependency, expression}} +end + +function CondRewriter(iv) + return CondRewriter(iv, Dict()) +end + +""" + $(TYPEDSIGNATURES) + +Given a symbolic condition `expr` and the condition `dep` it depends on, update the +mapping in `cw` and generate a new discrete variable if necessary. +""" +function new_cond_sym(cw::CondRewriter, expr, dep) + if !iscall(expr) || operation(expr) != Base.:(<) || !iszero(arguments(expr)[2]) + throw(ArgumentError("`expr` passed to `new_cond_sym` must be of the form `f(args...) < 0`. Got $expr.")) + end + # check if the same expression exists in the mapping + existing_var = findfirst(p -> isequal(p.expression, expr), cw.conditions) + if existing_var !== nothing + # cache hit + (existing_dep, _) = cw.conditions[existing_var] + # update the dependency condition + cw.conditions[existing_var] = (dependency = (dep | existing_dep), expression = expr) + return existing_var + end + # generate a new condition variable + cvar = gensym("cond") + st = symtype(expr) + iv = cw.iv + cv = unwrap(first(@parameters $(cvar)(iv)::st = true)) # TODO: real init + cw.conditions[cv] = (dependency = dep, expression = expr) + return cv +end + +""" +Utility function for boolean implication. +""" +implies(a, b) = !a & b + +""" + $(TYPEDSIGNATURES) + +Recursively rewrite conditions into discrete variables. `expr` is the condition to rewrite, +`dep` is a boolean expression/value which determines when the `expr` is to be evaluated. For +example, if `expr = expr1 | expr2` and `dep = dep1`, then `expr` should only be evaluated if +`dep1` evaluates to `true`. Recursively, `expr1` should only be evaluated if `dep1` is `true`, +and `expr2` should only be evaluated if `dep & !expr1`. + +Returns a 3-tuple of the substituted expression, a condition describing when `expr` evaluates +to `true`, and a condition describing when `expr` evaluates to `false`. + +This expects that all expressions with discontinuities or with discontinuous derivatives have +been rewritten into the form of `ifelse(rootfunc(args...) < 0, left(args...), right(args...))`. +The transformation is performed via `discontinuities_to_ifelse` using `Symbolics.rootfunction` +and family. +""" +function (cw::CondRewriter)(expr, dep) + # single variable, trivial case + if issym(expr) || iscall(expr) && issym(operation(expr)) + return (expr, expr, !expr) + # literal boolean or integer + elseif expr isa Bool + return (expr, expr, !expr) + elseif expr isa Int + return (expr, true, true) + # other singleton symbolic variables + elseif !iscall(expr) + @warn "Automatic conversion of if statements to events requires use of a limited conditional grammar; see the documentation. Skipping due to $expr" + return (expr, true, true) # error case => conservative assumption is that both true and false have to be evaluated + elseif operation(expr) == Base.:(|) # OR of two conditions + a, b = arguments(expr) + (rw_conda, truea, falsea) = cw(a, dep) + # only evaluate second if first is false + (rw_condb, trueb, falseb) = cw(b, dep & falsea) + return (rw_conda | rw_condb, truea | trueb, falsea & falseb) + + elseif operation(expr) == Base.:(&) # AND of two conditions + a, b = arguments(expr) + (rw_conda, truea, falsea) = cw(a, dep) + # only evaluate second if first is true + (rw_condb, trueb, falseb) = cw(b, dep & truea) + return (rw_conda & rw_condb, truea & trueb, falsea | falseb) + elseif operation(expr) == ifelse + c, a, b = arguments(expr) + (rw_cond, ctrue, cfalse) = cw(c, dep) + # only evaluate if condition is true + (rw_conda, truea, falsea) = cw(a, dep & ctrue) + # only evaluate if condition is false + (rw_condb, trueb, falseb) = cw(b, dep & cfalse) + # expression is true if condition is true and THEN branch is true, or condition is false + # and ELSE branch is true + # similarly for expression being false + return (ifelse(rw_cond, rw_conda, rw_condb), + implies(ctrue, truea) | implies(cfalse, trueb), + implies(ctrue, falsea) | implies(cfalse, falseb)) + elseif operation(expr) == Base.:(!) # NOT of expression + (a,) = arguments(expr) + (rw, ctrue, cfalse) = cw(a, dep) + return (!rw, cfalse, ctrue) + elseif operation(expr) == Base.:(<) + if !isequal(arguments(expr)[2], 0) + throw(ArgumentError("Expected comparison to be written as `f(args...) < 0`. Found $expr.")) + end + + # if the comparison does not include time-dependent variables, + # don't create a callback for it + + # Calling `expression_is_time_dependent` is `O(d)` where `d` is the depth of the + # expression tree. We only call this in this here to avoid turning this into + # an `O(d^2)` time complexity recursion, which would happen if it were called + # at the beginning of the function. Now, it only happens near the leaves of + # the recursive tree. + if !expression_is_time_dependent(expr, cw.iv) + return (expr, expr, !expr) + end + cv = new_cond_sym(cw, expr, dep) + return (cv, cv, !cv) + elseif operation(expr) == (==) + # we don't touch equality since it's a point discontinuity. It's basically always + # false for continuous variables. In case it's an equality between discrete + # quantities, we don't need to transform it. + return (expr, expr, !expr) + elseif !expression_is_time_dependent(expr, cw.iv) + return (expr, expr, !expr) + end + error(""" + Unsupported expression form in decision variable computation $expr. If the expression + involves a registered function, declare the discontinuity using + `Symbolics.@register_discontinuity`. If this is not meant to be transformed via + `IfLifting`, wrap the parent expression in `ModelingToolkit.no_if_lift`. + """) +end + +""" + $(TYPEDSIGNATURES) + +Acts as the identity function, and prevents transformation of conditional expressions inside it. Useful +if specific `ifelse` or other functions with discontinuous derivatives shouldn't be transformed into +callbacks. +""" +no_if_lift(s) = s +@register_symbolic no_if_lift(s) + +""" + $(TYPEDEF) + +A utility struct to search through an expression specifically for `ifelse` terms, and find +all variables used in the condition of such terms. The variables are stored in a field of +the struct. +""" +struct VarsUsedInCondition + """ + Stores variables used in conditions of `ifelse` statements in the expression. + """ + vars::Set{Any} +end + +VarsUsedInCondition() = VarsUsedInCondition(Set()) + +function (v::VarsUsedInCondition)(expr) + expr = Symbolics.unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + is_array_of_symbolics(expr) || return + foreach(v, expr) + return + end + iscall(expr) || return + op = operation(expr) + + # do not search inside no_if_lift to avoid discovering + # redundant variables + op == no_if_lift && return + + args = arguments(expr) + if op == ifelse + cond, branch_a, branch_b = arguments(expr) + vars!(v.vars, cond) + v(branch_a) + v(branch_b) + end + foreach(v, args) + return +end + +""" + $(TYPEDSIGNATURES) + +Check if `expr` depends on the independent variable `iv`. Return `true` if `iv` is present +in the expression, `Differential(iv)` is in the expression, or a dependent variable such +as `@variables x(iv)` is in the expression. +""" +function expression_is_time_dependent(expr, iv) + any(vars(expr)) do sym + sym = unwrap(sym) + isequal(sym, iv) && return true + iscall(sym) || return false + op = operation(sym) + args = arguments(sym) + op isa Differential && op == Differential(iv) || + issym(op) && length(args) == 1 && expression_is_time_dependent(args[1], iv) + end +end + +""" + $(TYPEDSIGNATURES) + +Given an expression `expr` which is to be evaluated if `dep` evaluates to `true`, transform +the conditions of all all `ifelse` statements in `expr` into functions of new discrete +variables. `cw` is used to store the information relevant to these newly introduced variables. +""" +function rewrite_ifs(cw::CondRewriter, expr, dep) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + # non-symbolic expression might still be an array of symbolic expressions + is_array_of_symbolics(expr) || return expr + return map(ex -> rewrite_ifs(cw, ex, dep), expr) + end + + iscall(expr) || return expr + op = operation(expr) + args = arguments(expr) + # do not search into `no_if_lift` + op == no_if_lift && return expr + + # transform `ifelse` + if op == ifelse + cond, iftrue, iffalse = args + + (rw_cond, deptrue, depfalse) = cw(cond, dep) + rw_iftrue = rewrite_ifs(cw, iftrue, deptrue) + rw_iffalse = rewrite_ifs(cw, iffalse, depfalse) + return maketerm( + typeof(expr), ifelse, [unwrap(rw_cond), rw_iftrue, rw_iffalse], metadata(expr)) + end + + # recurse into the rest of the cases + args = map(ex -> rewrite_ifs(cw, ex, dep), args) + return maketerm(typeof(expr), op, args, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Return a modified `expr` where functions with known discontinuities or discontinuous +derivatives are transformed into `ifelse` statements. Utilizes the discontinuity API +in Symbolics. See [`Symbolics.rootfunction`](@ref), +[`Symbolics.left_continuous_function`](@ref), [`Symbolics.right_continuous_function`](@ref). + +`iv` is the independent variable of the system. Only subexpressions of `expr` which +depend on `iv` are transformed. +""" +function discontinuities_to_ifelse(expr, iv) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + # non-symbolic expression might still be an array of symbolic expressions + is_array_of_symbolics(expr) || return expr + return map(ex -> discontinuities_to_ifelse(ex, iv), expr) + end + + iscall(expr) || return expr + op = operation(expr) + args = arguments(expr) + # do not search into `no_if_lift` + op == no_if_lift && return expr + + # Case I: the operation is symbolic. + # We don't actually care if this is a callable parameter or not. + # If it is, we want to search inside and perform if-lifting there. + # If it isn't, either it's `x(t)` in which case this recursion is + # effectively a no-op OR it's `x(f(t))` for DDEs and we want to + # perform if-lifting inside. + # + # Case II: the operation is not symbolic. + # We anyway want to recursively apply the transformation. + # + # Thus, we can do this here regardless of the subsequent checks + args = map(ex -> discontinuities_to_ifelse(ex, iv), args) + + # if the operation is a known discontinuity + if hasmethod(Symbolics.rootfunction, Tuple{typeof(op)}) + rootfn = Symbolics.rootfunction(op) + leftfn = Symbolics.left_continuous_function(op) + rightfn = Symbolics.right_continuous_function(op) + rootexpr = rootfn(args...) < 0 + leftexpr = leftfn(args...) + rightexpr = rightfn(args...) + return maketerm( + typeof(expr), ifelse, [rootexpr, leftexpr, rightexpr], metadata(expr)) + end + + return maketerm(typeof(expr), op, args, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Generate the symbolic condition for discrete variable `sym`, which represents the condition +of an `ifelse` statement created through [`IfLifting`](@ref). This condition is used to +trigger a callback which updates the value of the condition appropriately. +""" +function generate_condition(cw::CondRewriter, sym) + (dep, expr) = cw.conditions[sym] + + # expr is `f(args...) < 0`, `f(args...)` is the zero-crossing expression + zero_crossing = arguments(expr)[1] + + # if we're meant to evaluate the condition, evaluate it. Otherwise, return `NaN`. + # the solvers don't treat the transition from a number to NaN or back as a zero-crossing, + # so it can be used to effectively disable the affect when the condition is not meant to + # be evaluated. + return ifelse(dep, zero_crossing, NaN) ~ 0 +end + +""" + $(TYPEDSIGNATURES) + +Generate the upcrossing and downcrossing affect functions for discrete variable `sym` involved +in `ifelse` statements that are lifted to callbacks using [`IfLifting`](@ref). `syms` is a +condition variable introduced by `cw`, and is thus a key in `cw.conditions`. `new_cond_vars` +is the list of all such new condition variables, corresponding to the order of vertices in +`new_cond_vars_graph`. `new_cond_vars_graph` is a directed graph where edges denote the +condition variables involved in the dependency expression of the source vertex. +""" +function generate_affects(cw::CondRewriter, sym, new_cond_vars, new_cond_vars_graph) + sym_idx = findfirst(isequal(sym), new_cond_vars) + if sym_idx === nothing + throw(ArgumentError("Expected variable $sym to be a condition variable in $new_cond_vars.")) + end + # use reverse direction of edges because instead of finding the variables it depends + # on, we want the variables that depend on it + parents = bfs_parents(new_cond_vars_graph, sym_idx; dir = :in) + cond_vars_to_update = [new_cond_vars[i] + for i in eachindex(parents) if !iszero(parents[i])] + update_syms = Symbol.(cond_vars_to_update) + modified = NamedTuple{(update_syms...,)}(cond_vars_to_update) + + upcrossing_update_exprs = [arguments(last(cw.conditions[sym]))[1] < 0 + for sym in cond_vars_to_update] + upcrossing = ImperativeAffect( + modified, observed = NamedTuple{(update_syms...,)}(upcrossing_update_exprs), + skip_checks = true) do x, o, c, i + return o + end + downcrossing_update_exprs = [arguments(last(cw.conditions[sym]))[1] <= 0 + for sym in cond_vars_to_update] + downcrossing = ImperativeAffect( + modified, observed = NamedTuple{(update_syms...,)}(downcrossing_update_exprs), + skip_checks = true) do x, o, c, i + return o + end + + return upcrossing, downcrossing +end + +const CONDITION_SIMPLIFIER = Rewriters.Fixpoint(Rewriters.Postwalk(Rewriters.Chain([ + # simple boolean laws + (@rule (!!(~x)) => (~x)) + (@rule ((~x) & true) => (~x)) + (@rule ((~x) & false) => false) + (@rule ((~x) | true) => true) + (@rule ((~x) | false) => (~x)) + (@rule ((~x) & !(~x)) => false) + (@rule ((~x) | !(~x)) => true) + # reversed order of the above, because it matters and `@acrule` refuses to do its job + (@rule (true & (~x)) => (~x)) + (@rule (false & (~x)) => false) + (@rule (true | (~x)) => true) + (@rule (false | (~x)) => (~x)) + (@rule (!(~x) & (~x)) => false) + (@rule (!(~x) | (~x)) => true) + # idempotent + (@rule ((~x) & (~x)) => (~x)) + (@rule ((~x) | (~x)) => (~x)) + # ifelse with determined branches + (@rule ifelse((~x), true, false) => (~x)) + (@rule ifelse((~x), false, true) => !(~x)) + # ifelse with identical branches + (@rule ifelse((~x), (~y), (~y)) => (~y)) + (@rule ifelse((~x), (~y), !(~y)) => ((~x) & + (~y))) + (@rule ifelse((~x), !(~y), (~y)) => ((~x) & + !(~y))) + # ifelse with determined condition + (@rule ifelse(true, (~x), (~y)) => (~x)) + (@rule ifelse(false, (~x), (~y)) => (~y))]))) + +""" +If lifting converts (nested) if statements into a series of continuous events + a logically equivalent if statement + parameters. + +Lifting proceeds through the following process: +* rewrite comparisons to be of the form eqn [op] 0; subtract the RHS from the LHS +* replace comparisons with generated parameters; for each comparison eqn [op] 0, generate an event (dependent on op) that sets the parameter +""" +function IfLifting(sys::ODESystem) + cw = CondRewriter(get_iv(sys)) + + eqs = copy(equations(sys)) + obs = copy(observed(sys)) + + # get variables used by `eqs` + syms = vars(eqs) + # get observed equations used by `eqs` + obs_idxs = observed_equations_used_by(sys, eqs; involved_vars = syms) + # and the variables used in those equations + for i in obs_idxs + vars!(syms, obs[i]) + end + + # get all integral variables used in conditions + # this is used when performing the transformation on observed equations + # since they are transformed differently depending on whether they are + # discrete variables involved in a condition or not + condition_vars = Set() + # searcher struct + # we can use the same one since it avoids iterating over duplicates + vars_in_condition! = VarsUsedInCondition() + for i in eachindex(eqs) + eq = eqs[i] + vars_in_condition!(eq.rhs) + # also transform the equation + eqs[i] = eq.lhs ~ rewrite_ifs(cw, discontinuities_to_ifelse(eq.rhs, cw.iv), true) + end + # also search through relevant observed equations + for i in obs_idxs + vars_in_condition!(obs[i].rhs) + end + # add to `condition_vars` after filtering out differential, parameter, independent and + # non-integral variables + for v in vars_in_condition!.vars + v = unwrap(v) + stype = symtype(v) + if isdifferential(v) || isparameter(v) || isequal(v, get_iv(sys)) + continue + end + stype <: Union{Integer, AbstractArray{Integer}} && push!(condition_vars, v) + end + # transform observed equations + for i in obs_idxs + obs[i] = if obs[i].lhs in condition_vars + obs[i].lhs ~ first(cw(discontinuities_to_ifelse(obs[i].rhs, cw.iv), true)) + else + obs[i].lhs ~ rewrite_ifs(cw, discontinuities_to_ifelse(obs[i].rhs, cw.iv), true) + end + end + + # `rewrite_ifs` and calling `cw` generate a lot of redundant code, simplify it + eqs = map(eqs) do eq + eq.lhs ~ CONDITION_SIMPLIFIER(eq.rhs) + end + obs = map(obs) do eq + eq.lhs ~ CONDITION_SIMPLIFIER(eq.rhs) + end + # also simplify dependencies + for (k, v) in cw.conditions + cw.conditions[k] = map(CONDITION_SIMPLIFIER ∘ unwrap, v) + end + + # get directed graph where nodes are the new condition variables and edges from each + # node denote the condition variables used in it's dependency expression + + # so we have an ordering for the vertices + new_cond_vars = collect(keys(cw.conditions)) + # "observed" equations + new_cond_dep_eqs = [v ~ cw.conditions[v] for v in new_cond_vars] + # construct the graph as a `DiCMOBiGraph` + new_cond_vars_graph = observed_dependency_graph(new_cond_dep_eqs) + + new_callbacks = continuous_events(sys) + new_defaults = defaults(sys) + new_ps = Vector{SymbolicParam}(parameters(sys)) + + for var in new_cond_vars + condition = generate_condition(cw, var) + up_affect, down_affect = generate_affects( + cw, var, new_cond_vars, new_cond_vars_graph) + cb = SymbolicContinuousCallback([condition], up_affect; affect_neg = down_affect, + initialize = up_affect, rootfind = SciMLBase.RightRootFind) + + push!(new_callbacks, cb) + new_defaults[var] = getdefault(var) + push!(new_ps, var) + end + + @set! sys.defaults = new_defaults + @set! sys.eqs = eqs + # do not need to topsort because we didn't modify the order + @set! sys.observed = obs + @set! sys.continuous_events = new_callbacks + @set! sys.ps = new_ps + return sys +end diff --git a/src/systems/imperative_affect.jl b/src/systems/imperative_affect.jl new file mode 100644 index 0000000000..4c9ff3d248 --- /dev/null +++ b/src/systems/imperative_affect.jl @@ -0,0 +1,240 @@ + +""" + ImperativeAffect(f::Function; modified::NamedTuple, observed::NamedTuple, ctx) + +`ImperativeAffect` is a helper for writing affect functions that will compute observed values and +ensure that modified values are correctly written back into the system. The affect function `f` needs to have +the signature + +``` + f(modified::NamedTuple, observed::NamedTuple, ctx, integrator)::NamedTuple +``` + +The function `f` will be called with `observed` and `modified` `NamedTuple`s that are derived from their respective `NamedTuple` definitions. +Each declaration`NamedTuple` should map an expression to a symbol; for example if we pass `observed=(; x = a + b)` this will alias the result of executing `a+b` in the system as `x` +so the value of `a + b` will be accessible as `observed.x` in `f`. `modified` currently restricts symbolic expressions to only bare variables, so only tuples of the form +`(; x = y)` or `(; x)` (which aliases `x` as itself) are allowed. + +The argument NamedTuples (for instance `(;x=y)`) will be populated with the declared values on function entry; if we require `(;x=y)` in `observed` and `y=2`, for example, +then the NamedTuple `(;x=2)` will be passed as `observed` to the affect function `f`. + +The NamedTuple returned from `f` includes the values to be written back to the system after `f` returns. For example, if we want to update the value of `x` to be the result of `x + y` we could write + + ImperativeAffect(observed=(; x_plus_y = x + y), modified=(; x)) do m, o + @set! m.x = o.x_plus_y + end + +Where we use Setfield to copy the tuple `m` with a new value for `x`, then return the modified value of `m`. All values updated by the tuple must have names originally declared in +`modified`; a runtime error will be produced if a value is written that does not appear in `modified`. The user can dynamically decide not to write a value back by not including it +in the returned tuple, in which case the associated field will not be updated. +""" +@kwdef struct ImperativeAffect + f::Any + obs::Vector + obs_syms::Vector{Symbol} + modified::Vector + mod_syms::Vector{Symbol} + ctx::Any + skip_checks::Bool +end + +function ImperativeAffect(f; + observed::NamedTuple = NamedTuple{()}(()), + modified::NamedTuple = NamedTuple{()}(()), + ctx = nothing, + skip_checks = false) + ImperativeAffect(f, + collect(values(observed)), collect(keys(observed)), + collect(values(modified)), collect(keys(modified)), + ctx, skip_checks) +end +function ImperativeAffect(f, modified::NamedTuple; + observed::NamedTuple = NamedTuple{()}(()), ctx = nothing, skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end +function ImperativeAffect( + f, modified::NamedTuple, observed::NamedTuple; ctx = nothing, skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end +function ImperativeAffect( + f, modified::NamedTuple, observed::NamedTuple, ctx; skip_checks = false) + ImperativeAffect( + f, observed = observed, modified = modified, ctx = ctx, skip_checks = skip_checks) +end + +function Base.show(io::IO, mfa::ImperativeAffect) + obs_vals = join(map((ob, nm) -> "$ob => $nm", mfa.obs, mfa.obs_syms), ", ") + mod_vals = join(map((md, nm) -> "$md => $nm", mfa.modified, mfa.mod_syms), ", ") + affect = mfa.f + print(io, + "ImperativeAffect(observed: [$obs_vals], modified: [$mod_vals], affect:$affect)") +end +func(f::ImperativeAffect) = f.f +context(a::ImperativeAffect) = a.ctx +observed(a::ImperativeAffect) = a.obs +observed_syms(a::ImperativeAffect) = a.obs_syms +function discretes(a::ImperativeAffect) + Iterators.filter(ModelingToolkit.isparameter, + Iterators.flatten(Iterators.map( + x -> symbolic_type(x) == NotSymbolic() && x isa AbstractArray ? x : [x], + a.modified))) +end +modified(a::ImperativeAffect) = a.modified +modified_syms(a::ImperativeAffect) = a.mod_syms + +function Base.:(==)(a1::ImperativeAffect, a2::ImperativeAffect) + isequal(a1.f, a2.f) && isequal(a1.obs, a2.obs) && isequal(a1.modified, a2.modified) && + isequal(a1.obs_syms, a2.obs_syms) && isequal(a1.mod_syms, a2.mod_syms) && + isequal(a1.ctx, a2.ctx) +end + +function Base.hash(a::ImperativeAffect, s::UInt) + s = hash(a.f, s) + s = hash(a.obs, s) + s = hash(a.obs_syms, s) + s = hash(a.modified, s) + s = hash(a.mod_syms, s) + hash(a.ctx, s) +end + +namespace_affects(af::ImperativeAffect, s) = namespace_affect(af, s) +function namespace_affect(affect::ImperativeAffect, s) + ImperativeAffect(func(affect), + namespace_expr.(observed(affect), (s,)), + observed_syms(affect), + renamespace.((s,), modified(affect)), + modified_syms(affect), + context(affect), + affect.skip_checks) +end + +function compile_affect(affect::ImperativeAffect, cb, sys, dvs, ps; kwargs...) + compile_user_affect(affect, cb, sys, dvs, ps; kwargs...) +end + +function compile_user_affect(affect::ImperativeAffect, cb, sys, dvs, ps; kwargs...) + #= + Implementation sketch: + generate observed function (oop), should save to a component array under obs_syms + do the same stuff as the normal FA for pars_syms + call the affect method + unpack and apply the resulting values + =# + function check_dups(syms, exprs) # = (syms_dedup, exprs_dedup) + seen = Set{Symbol}() + syms_dedup = [] + exprs_dedup = [] + for (sym, exp) in Iterators.zip(syms, exprs) + if !in(sym, seen) + push!(syms_dedup, sym) + push!(exprs_dedup, exp) + push!(seen, sym) + elseif !affect.skip_checks + @warn "Expression $(expr) is aliased as $sym, which has already been used. The first definition will be used." + end + end + return (syms_dedup, exprs_dedup) + end + + obs_exprs = observed(affect) + if !affect.skip_checks + for oexpr in obs_exprs + invalid_vars = invalid_variables(sys, oexpr) + if length(invalid_vars) > 0 + error("Observed equation $(oexpr) in affect refers to missing variable(s) $(invalid_vars); the variables may not have been added (e.g. if a component is missing).") + end + end + end + obs_syms = observed_syms(affect) + obs_syms, obs_exprs = check_dups(obs_syms, obs_exprs) + + mod_exprs = modified(affect) + if !affect.skip_checks + for mexpr in mod_exprs + if !check_assignable(sys, mexpr) + @warn ("Expression $mexpr cannot be assigned to; currently only unknowns and parameters may be updated by an affect.") + end + invalid_vars = unassignable_variables(sys, mexpr) + if length(invalid_vars) > 0 + error("Modified equation $(mexpr) in affect refers to missing variable(s) $(invalid_vars); the variables may not have been added (e.g. if a component is missing) or they may have been reduced away.") + end + end + end + mod_syms = modified_syms(affect) + mod_syms, mod_exprs = check_dups(mod_syms, mod_exprs) + + overlapping_syms = intersect(mod_syms, obs_syms) + if length(overlapping_syms) > 0 && !affect.skip_checks + @warn "The symbols $overlapping_syms are declared as both observed and modified; this is a code smell because it becomes easy to confuse them and assign/not assign a value." + end + + # sanity checks done! now build the data and update function for observed values + mkzero(sz) = + if sz === () + 0.0 + else + zeros(sz) + end + obs_fun = build_explicit_observed_function( + sys, Symbolics.scalarize.(obs_exprs); + mkarray = (es, _) -> MakeTuple(es)) + obs_sym_tuple = (obs_syms...,) + + # okay so now to generate the stuff to assign it back into the system + mod_pairs = mod_exprs .=> mod_syms + mod_names = (mod_syms...,) + mod_og_val_fun = build_explicit_observed_function( + sys, Symbolics.scalarize.(first.(mod_pairs)); + mkarray = (es, _) -> MakeTuple(es)) + + upd_funs = NamedTuple{mod_names}((setu.((sys,), first.(mod_pairs))...,)) + + if has_index_cache(sys) && (ic = get_index_cache(sys)) !== nothing + save_idxs = get(ic.callback_to_clocks, cb, Int[]) + else + save_idxs = Int[] + end + + let user_affect = func(affect), ctx = context(affect) + function (integ) + # update the to-be-mutated values; this ensures that if you do a no-op then nothing happens + modvals = mod_og_val_fun(integ.u, integ.p, integ.t) + upd_component_array = NamedTuple{mod_names}(modvals) + + # update the observed values + obs_component_array = NamedTuple{obs_sym_tuple}(obs_fun( + integ.u, integ.p, integ.t)) + + # let the user do their thing + upd_vals = user_affect(upd_component_array, obs_component_array, ctx, integ) + + # write the new values back to the integrator + _generated_writeback(integ, upd_funs, upd_vals) + + for idx in save_idxs + SciMLBase.save_discretes!(integ, idx) + end + end + end +end + +scalarize_affects(affects::ImperativeAffect) = affects + +function vars!(vars, aff::ImperativeAffect; op = Differential) + for var in Iterators.flatten((observed(aff), modified(aff))) + if symbolic_type(var) == NotSymbolic() + if var isa AbstractArray + for v in var + v = unwrap(v) + vars!(vars, v) + end + end + else + var = unwrap(var) + vars!(vars, var) + end + end + return vars +end diff --git a/src/systems/index_cache.jl b/src/systems/index_cache.jl index 3f2b4ddebe..47a784c00b 100644 --- a/src/systems/index_cache.jl +++ b/src/systems/index_cache.jl @@ -32,33 +32,41 @@ struct DiscreteIndex 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{BasicSymbolic, DiscreteIndex} + 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::ParamIndexMap - observed_syms::Set{BasicSymbolic} - dependent_pars::Set{BasicSymbolic} + 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, BasicSymbolic} + symbol_to_variable::Dict{Symbol, SymbolicParam} end function IndexCache(sys::AbstractSystem) unks = solved_unknowns(sys) unk_idxs = UnknownIndexMap() - symbol_to_variable = Dict{Symbol, BasicSymbolic}() + symbol_to_variable = Dict{Symbol, SymbolicParam}() let idx = 1 for sym in unks @@ -89,35 +97,21 @@ function IndexCache(sys::AbstractSystem) end end - observed_syms = Set{BasicSymbolic}() - for eq in observed(sys) - if symbolic_type(eq.lhs) != NotSymbolic() - sym = eq.lhs - ttsym = default_toterm(sym) - rsym = renamespace(sys, sym) - rttsym = renamespace(sys, ttsym) - push!(observed_syms, sym) - push!(observed_syms, ttsym) - push!(observed_syms, rsym) - push!(observed_syms, rttsym) - end - end - tunable_buffers = Dict{Any, Set{BasicSymbolic}}() + initial_param_buffers = Dict{Any, Set{BasicSymbolic}}() constant_buffers = Dict{Any, Set{BasicSymbolic}}() - nonnumeric_buffers = Dict{Any, Set{BasicSymbolic}}() + nonnumeric_buffers = Dict{Any, Set{SymbolicParam}}() - function insert_by_type!(buffers::Dict{Any, Set{BasicSymbolic}}, sym) + function insert_by_type!(buffers::Dict{Any, S}, sym, ctype) where {S} sym = unwrap(sym) - ctype = symtype(sym) - buf = get!(buffers, ctype, Set{BasicSymbolic}()) + buf = get!(buffers, ctype, S()) push!(buf, sym) end - disc_param_callbacks = Dict{BasicSymbolic, Set{Int}}() + disc_param_callbacks = Dict{SymbolicParam, Set{Int}}() events = vcat(continuous_events(sys), discrete_events(sys)) for (i, event) in enumerate(events) - discs = Set{BasicSymbolic}() + discs = Set{SymbolicParam}() affs = affects(event) if !(affs isa AbstractArray) affs = [affs] @@ -125,7 +119,7 @@ function IndexCache(sys::AbstractSystem) for affect in affs if affect isa Equation is_parameter(sys, affect.lhs) && push!(discs, affect.lhs) - elseif affect isa FunctionalAffect + elseif affect isa FunctionalAffect || affect isa ImperativeAffect union!(discs, unwrap.(discretes(affect))) else error("Unhandled affect type $(typeof(affect))") @@ -141,26 +135,32 @@ function IndexCache(sys::AbstractSystem) 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 - insert_by_type!(constant_buffers, sym) + 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 = [BasicSymbolic[] for _ in 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{BasicSymbolic}[] for _ in disc_symtypes] + 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{BasicSymbolic, DiscreteIndex}() + 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) @@ -194,30 +194,39 @@ function IndexCache(sys::AbstractSystem) [BufferTemplate(symtype, length(buf)) for buf in disc_syms_by_partition]) end - for p in parameters(sys) + 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}) - tunable_buffers + if iscall(p) && operation(p) isa Initial + initial_param_buffers + else + tunable_buffers + end else constant_buffers end else nonnumeric_buffers end, - p + p, + ctype ) end - function get_buffer_sizes_and_idxs(buffers::Dict{Any, Set{BasicSymbolic}}) - idxs = ParamIndexMap() + 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) @@ -229,58 +238,158 @@ function IndexCache(sys::AbstractSystem) 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(constant_buffers) - nonnumeric_idxs, nonnumeric_buffer_sizes = get_buffer_sizes_and_idxs(nonnumeric_buffers) + 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 - for (i, (_, buf)) in enumerate(tunable_buffers) - for (j, p) in enumerate(buf) - idx = if size(p) == () - tunable_buffer_size + 1 - else - reshape( - (tunable_buffer_size + 1):(tunable_buffer_size + length(p)), size(p)) + bufferlist = is_initializesystem(sys) ? (tunable_buffers, initial_param_buffers) : + (tunable_buffers,) + for buffers in bufferlist + for (i, (_, buf)) in enumerate(buffers) + for (j, p) in enumerate(buf) + idx = if size(p) == () + tunable_buffer_size + 1 + else + reshape( + (tunable_buffer_size + 1):(tunable_buffer_size + length(p)), size(p)) + end + tunable_buffer_size += length(p) + tunable_idxs[p] = idx + tunable_idxs[default_toterm(p)] = idx + if hasname(p) && (!iscall(p) || operation(p) !== getindex) + symbol_to_variable[getname(p)] = p + symbol_to_variable[getname(default_toterm(p))] = p + end + end + end + end + + initials_idxs = TunableIndexMap() + initials_buffer_size = 0 + if !is_initializesystem(sys) + for (i, (_, buf)) in enumerate(initial_param_buffers) + for (j, p) in enumerate(buf) + idx = if size(p) == () + initials_buffer_size + 1 + else + reshape( + (initials_buffer_size + 1):(initials_buffer_size + length(p)), size(p)) + end + initials_buffer_size += length(p) + initials_idxs[p] = idx + initials_idxs[default_toterm(p)] = idx + if hasname(p) && (!iscall(p) || operation(p) !== getindex) + symbol_to_variable[getname(p)] = p + symbol_to_variable[getname(default_toterm(p))] = p + end end - 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 + + for k in collect(keys(tunable_idxs)) + v = tunable_idxs[k] + v isa AbstractArray || continue + for (kk, vv) in zip(collect(k), v) + tunable_idxs[kk] = vv + end + end + for k in collect(keys(initials_idxs)) + v = initials_idxs[k] + v isa AbstractArray || continue + for (kk, vv) in zip(collect(k), v) + initials_idxs[kk] = vv + end + end + + dependent_pars_to_timeseries = Dict{ + Union{BasicSymbolic, CallWithMetadata}, TimeseriesSetType}() + + for eq in parameter_dependencies(sys) + sym = eq.lhs + vs = vars(eq.rhs) + timeseries = TimeseriesSetType() + if is_time_dependent(sys) + for v in vs + if (idx = get(disc_idxs, v, nothing)) !== nothing + push!(timeseries, idx.clock_idx) + end + end + end + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + dependent_pars_to_timeseries[s] = timeseries + if hasname(s) && (!iscall(s) || operation(s) != getindex) + symbol_to_variable[getname(s)] = sym + end + end + end + + observed_syms_to_timeseries = Dict{BasicSymbolic, TimeseriesSetType}() + for eq in observed(sys) + if symbolic_type(eq.lhs) != NotSymbolic() + sym = eq.lhs + vs = vars(eq.rhs; op = Nothing) + timeseries = TimeseriesSetType() + if is_time_dependent(sys) + for v in vs + if (idx = get(disc_idxs, v, nothing)) !== nothing + push!(timeseries, idx.clock_idx) + elseif iscall(v) && operation(v) === getindex && + (idx = get(disc_idxs, arguments(v)[1], nothing)) !== nothing + push!(timeseries, idx.clock_idx) + elseif haskey(observed_syms_to_timeseries, v) + union!(timeseries, observed_syms_to_timeseries[v]) + elseif haskey(dependent_pars_to_timeseries, v) + union!(timeseries, dependent_pars_to_timeseries[v]) + end + end + if isempty(timeseries) + push!(timeseries, ContinuousTimeseries()) + end + end + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + observed_syms_to_timeseries[s] = timeseries end end end for sym in Iterators.flatten((keys(unk_idxs), keys(disc_idxs), keys(tunable_idxs), keys(const_idxs), keys(nonnumeric_idxs), - observed_syms, independent_variable_symbols(sys))) + 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 - dependent_pars = Set{BasicSymbolic}() - for eq in parameter_dependencies(sys) - push!(dependent_pars, eq.lhs) - end - return IndexCache( unk_idxs, disc_idxs, callback_to_clocks, tunable_idxs, + initials_idxs, const_idxs, nonnumeric_idxs, - observed_syms, - dependent_pars, + observed_syms_to_timeseries, + dependent_pars_to_timeseries, disc_buffer_templates, BufferTemplate(Real, tunable_buffer_size), + BufferTemplate(Real, initials_buffer_size), const_buffer_sizes, nonnumeric_buffer_sizes, symbol_to_variable @@ -288,11 +397,7 @@ function IndexCache(sys::AbstractSystem) end function SymbolicIndexingInterface.is_variable(ic::IndexCache, sym) - if sym isa Symbol - sym = get(ic.symbol_to_variable, sym, nothing) - sym === nothing && return false - end - return check_index_map(ic.unknown_idx, sym) !== nothing + variable_index(ic, sym) !== nothing end function SymbolicIndexingInterface.variable_index(ic::IndexCache, sym) @@ -300,18 +405,17 @@ function SymbolicIndexingInterface.variable_index(ic::IndexCache, sym) sym = get(ic.symbol_to_variable, sym, nothing) sym === nothing && return nothing end - return check_index_map(ic.unknown_idx, sym) + 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) - if sym isa Symbol - sym = get(ic.symbol_to_variable, sym, nothing) - sym === nothing && return false - end - return check_index_map(ic.tunable_idx, sym) !== nothing || - check_index_map(ic.discrete_idx, sym) !== nothing || - check_index_map(ic.constant_idx, sym) !== nothing || - check_index_map(ic.nonnumeric_idx, sym) !== nothing + parameter_index(ic, sym) !== nothing end function SymbolicIndexingInterface.parameter_index(ic::IndexCache, sym) @@ -324,6 +428,8 @@ function SymbolicIndexingInterface.parameter_index(ic::IndexCache, sym) 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) @@ -331,17 +437,22 @@ function SymbolicIndexingInterface.parameter_index(ic::IndexCache, sym) ParameterIndex(SciMLStructures.Constants(), idx, validate_size) elseif (idx = check_index_map(ic.nonnumeric_idx, sym)) !== nothing ParameterIndex(NONNUMERIC_PORTION, idx, validate_size) - else - nothing + 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) - if sym isa Symbol - sym = get(ic.symbol_to_variable, sym, nothing) - sym === nothing && return false - end - return check_index_map(ic.discrete_idx, sym) !== nothing + timeseries_parameter_index(ic, sym) !== nothing end function SymbolicIndexingInterface.timeseries_parameter_index(ic::IndexCache, sym) @@ -349,9 +460,16 @@ function SymbolicIndexingInterface.timeseries_parameter_index(ic::IndexCache, sy 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.clock_idx, (idx.buffer_idx, idx.idx_in_clock)) + return ParameterTimeseriesIndex( + idx.timeseries_idx, (idx.parameter_idx..., args[2:end]...)) end function check_index_map(idxmap, sym) @@ -373,7 +491,8 @@ function check_index_map(idxmap, sym) end end -function reorder_parameters(sys::AbstractSystem, ps; kwargs...) +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 @@ -391,13 +510,20 @@ function reorder_parameters(ic::IndexCache, ps; drop_missing = false) (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(BasicSymbolic[unwrap(variable(:DEF)) for _ in 1:(temp.length)] + 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) @@ -411,6 +537,13 @@ function reorder_parameters(ic::IndexCache, ps; drop_missing = false) 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 @@ -423,7 +556,8 @@ function reorder_parameters(ic::IndexCache, ps; drop_missing = false) end result = broadcast.( - unwrap, (param_buf..., disc_buf..., const_buf..., nonnumeric_buf...)) + unwrap, ( + param_buf..., initials_buf..., disc_buf..., const_buf..., nonnumeric_buf...)) if drop_missing result = map(result) do buf filter(buf) do sym @@ -446,6 +580,11 @@ function iterated_buffer_index(ic::IndexCache, ind::ParameterIndex) 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) @@ -467,6 +606,8 @@ function get_buffer_template(ic::IndexCache, pidx::ParameterIndex) 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 @@ -477,3 +618,84 @@ function get_buffer_template(ic::IndexCache, pidx::ParameterIndex) error("Unhandled portion $portion") end end + +fntype_to_function_type(::Type{FnType{A, R, T}}) where {A, R, T} = T +fntype_to_function_type(::Type{FnType{A, R, Nothing}}) where {A, R} = FunctionWrapper{R, A} +fntype_to_function_type(::Type{FnType{A, R}}) where {A, R} = FunctionWrapper{R, A} + +""" + reorder_dimension_by_tunables!(dest::AbstractArray, sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + +Assuming the order of values in dimension `dim` of `arr` correspond to the order of tunable +parameters in the system, reorder them according to the order described in `syms`. `syms` must +be a permutation of `tunable_parameters(sys)`. The result is written to `dest`. The `size` of `dest` and +`arr` must be equal. Return `dest`. + +See also: [`MTKParameters`](@ref), [`tunable_parameters`](@ref), [`reorder_dimension_by_tunables`](@ref). +""" +function reorder_dimension_by_tunables!( + dest::AbstractArray, sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + if !iscomplete(sys) + throw(ArgumentError("A completed system is required. Call `complete` or `structural_simplify` on the system.")) + end + if !has_index_cache(sys) || (ic = get_index_cache(sys)) === nothing + throw(ArgumentError("The system does not have an index cache. Call `complete` or `structural_simplify` on the system with `split = true`.")) + end + if size(dest) != size(arr) + throw(ArgumentError("Source and destination arrays must have the same size. Got source array with size $(size(arr)) and destination with size $(size(dest)).")) + end + + dsti = 1 + for sym in syms + idx = parameter_index(ic, sym) + if !(idx.portion isa SciMLStructures.Tunable) + throw(ArgumentError("`syms` must be a permutation of `tunable_parameters(sys)`. Found $sym which is not a tunable parameter.")) + end + + dstidx = ntuple( + i -> i == dim ? (dsti:(dsti + length(sym) - 1)) : (:), Val(ndims(arr))) + destv = @view dest[dstidx...] + dsti += length(sym) + arridx = ntuple(i -> i == dim ? (idx.idx) : (:), Val(ndims(arr))) + srcv = @view arr[arridx...] + copyto!(destv, srcv) + end + return dest +end + +""" + reorder_dimension_by_tunables(sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + +Out-of-place version of [`reorder_dimension_by_tunables!`](@ref). +""" +function reorder_dimension_by_tunables( + sys::AbstractSystem, arr::AbstractArray, syms; dim = 1) + buffer = similar(arr) + reorder_dimension_by_tunables!(buffer, sys, arr, syms; dim) + return buffer +end + +function subset_unknowns_observed( + ic::IndexCache, sys::AbstractSystem, newunknowns, newobsvars) + unknown_idx = copy(ic.unknown_idx) + empty!(unknown_idx) + for (i, sym) in enumerate(newunknowns) + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + unknown_idx[sym] = unknown_idx[ttsym] = unknown_idx[rsym] = unknown_idx[rttsym] = i + end + observed_syms_to_timeseries = copy(ic.observed_syms_to_timeseries) + empty!(observed_syms_to_timeseries) + for sym in newobsvars + ttsym = default_toterm(sym) + rsym = renamespace(sys, sym) + rttsym = renamespace(sys, ttsym) + for s in (sym, ttsym, rsym, rttsym) + observed_syms_to_timeseries[s] = ic.observed_syms_to_timeseries[sym] + end + end + ic = @set ic.unknown_idx = unknown_idx + @set! ic.observed_syms_to_timeseries = observed_syms_to_timeseries + return ic +end diff --git a/src/systems/jumps/jumpsystem.jl b/src/systems/jumps/jumpsystem.jl index 60b061b1da..57a3aee7df 100644 --- a/src/systems/jumps/jumpsystem.jl +++ b/src/systems/jumps/jumpsystem.jl @@ -4,25 +4,14 @@ const JumpType = Union{VariableRateJump, ConstantRateJump, MassActionJump} # call reset_aggregated_jumps!(integrator). # assumes iip function _reset_aggregator!(expr, integrator) - if expr isa Symbol - error("Error, encountered a symbol. This should not happen.") + @assert Meta.isexpr(expr, :function) + body = expr.args[end] + body = quote + $body + $reset_aggregated_jumps!($integrator) end - if expr isa LineNumberNode - return - end - - if (expr.head == :function) - _reset_aggregator!(expr.args[end], integrator) - else - if expr.args[end] == :nothing - expr.args[end] = :(reset_aggregated_jumps!($integrator)) - push!(expr.args, :nothing) - else - _reset_aggregator!(expr.args[end], integrator) - end - end - - nothing + expr.args[end] = body + return nothing end """ @@ -74,6 +63,8 @@ struct JumpSystem{U <: ArrayPartition} <: AbstractTimeDependentSystem observed::Vector{Equation} """The name of the system.""" name::Symbol + """A description of the system.""" + description::String """The internal systems. These are required to have unique names.""" systems::Vector{JumpSystem} """ @@ -82,10 +73,28 @@ struct JumpSystem{U <: ArrayPartition} <: AbstractTimeDependentSystem """ defaults::Dict """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ Type of the system. """ connector_type::Any """ + A `Vector{SymbolicContinuousCallback}` that model events. + The integrator will use root finding to guarantee that it steps at each zero crossing. + """ + continuous_events::Vector{SymbolicContinuousCallback} + """ A `Vector{SymbolicDiscreteCallback}` that models events. Symbolic analog to `SciMLBase.DiscreteCallback` that executes an affect when a given condition is true at the end of an integration step. Note, one must make sure to call @@ -107,7 +116,11 @@ struct JumpSystem{U <: ArrayPartition} <: AbstractTimeDependentSystem """ gui_metadata::Union{Nothing, GUIMetadata} """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -116,24 +129,28 @@ struct JumpSystem{U <: ArrayPartition} <: AbstractTimeDependentSystem index_cache::Union{Nothing, IndexCache} isscheduled::Bool - function JumpSystem{U}(tag, ap::U, iv, unknowns, ps, var_to_name, observed, name, - systems, - defaults, connector_type, devents, parameter_dependencies, - metadata = nothing, gui_metadata = nothing, - complete = false, index_cache = nothing, isscheduled = false; + function JumpSystem{U}( + tag, ap::U, iv, unknowns, ps, var_to_name, observed, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, connector_type, + cevents, devents, + parameter_dependencies, metadata = nothing, gui_metadata = nothing, + namespacing = true, complete = false, index_cache = nothing, isscheduled = false; checks::Union{Bool, Int} = true) where {U <: ArrayPartition} if checks == true || (checks & CheckComponents) > 0 check_independent_variables([iv]) check_variables(unknowns, iv) check_parameters(ps, iv) + check_subsystems(systems) end if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(unknowns, ps, iv) check_units(u, ap, iv) end - new{U}(tag, ap, iv, unknowns, ps, var_to_name, observed, name, systems, defaults, - connector_type, devents, parameter_dependencies, metadata, gui_metadata, - complete, index_cache, isscheduled) + new{U}(tag, ap, iv, unknowns, ps, var_to_name, + observed, name, description, systems, defaults, guesses, initializesystem, + initialization_eqs, + connector_type, cevents, devents, parameter_dependencies, metadata, + gui_metadata, namespacing, complete, index_cache, isscheduled) end end function JumpSystem(tag, ap, iv, states, ps, var_to_name, args...; kwargs...) @@ -146,7 +163,11 @@ function JumpSystem(eqs, iv, unknowns, ps; default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], name = nothing, + description = "", connector_type = nothing, checks = true, continuous_events = nothing, @@ -155,14 +176,46 @@ function JumpSystem(eqs, iv, unknowns, ps; metadata = nothing, gui_metadata = nothing, kwargs...) + + # variable processing, similar to ODESystem name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) - eqs = scalarize.(eqs) + iv′ = value(iv) + us′ = value.(unknowns) + ps′ = value.(ps) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) + if !(isempty(default_u0) && isempty(default_p)) + Base.depwarn( + "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", + :JumpSystem, force = true) + end + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) + var_to_name = Dict() + process_variables!(var_to_name, defaults, guesses, us′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + #! format: off + defaults = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(defaults) if value(v) !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) for (k, v) in pairs(guesses) if v !== nothing) + #! format: on + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) + sysnames = nameof.(systems) if length(unique(sysnames)) != length(sysnames) throw(ArgumentError("System names must be unique.")) end - ap = ArrayPartition(MassActionJump[], ConstantRateJump[], VariableRateJump[]) + + # equation processing + # this and the treatment of continuous events are the only part + # unique to JumpSystems + eqs = scalarize.(eqs) + ap = ArrayPartition( + MassActionJump[], ConstantRateJump[], VariableRateJump[], Equation[]) for eq in eqs if eq isa MassActionJump push!(ap.x[1], eq) @@ -170,37 +223,52 @@ function JumpSystem(eqs, iv, unknowns, ps; push!(ap.x[2], eq) elseif eq isa VariableRateJump push!(ap.x[3], eq) + elseif eq isa Equation + push!(ap.x[4], eq) else - error("JumpSystem equations must contain MassActionJumps, ConstantRateJumps, or VariableRateJumps.") + error("JumpSystem equations must contain MassActionJumps, ConstantRateJumps, VariableRateJumps, or Equations.") end end - if !(isempty(default_u0) && isempty(default_p)) - Base.depwarn( - "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", - :JumpSystem, force = true) - end - defaults = todict(defaults) - defaults = Dict(value(k) => value(v) - for (k, v) in pairs(defaults) if value(v) !== nothing) - unknowns, ps = value.(unknowns), value.(ps) - var_to_name = Dict() - process_variables!(var_to_name, defaults, unknowns) - process_variables!(var_to_name, defaults, ps) - isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) - (continuous_events === nothing) || - error("JumpSystems currently only support discrete events.") + cont_callbacks = SymbolicContinuousCallbacks(continuous_events) disc_callbacks = SymbolicDiscreteCallbacks(discrete_events) - parameter_dependencies, ps = process_parameter_dependencies(parameter_dependencies, ps) + JumpSystem{typeof(ap)}(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), - ap, value(iv), unknowns, ps, var_to_name, observed, name, systems, - defaults, connector_type, disc_callbacks, parameter_dependencies, - metadata, gui_metadata, checks = checks) + ap, iv′, us′, ps′, var_to_name, observed, name, description, systems, + defaults, guesses, initializesystem, initialization_eqs, connector_type, + cont_callbacks, disc_callbacks, + parameter_dependencies, metadata, gui_metadata, checks = checks) +end + +##### MTK dispatches for JumpSystems ##### +eqtype_supports_collect_vars(j::MassActionJump) = true +function collect_vars!(unknowns, parameters, j::MassActionJump, iv; depth = 0, + op = Differential) + collect_vars!(unknowns, parameters, j.scaled_rates, iv; depth, op) + for field in (j.reactant_stoch, j.net_stoch) + for el in field + collect_vars!(unknowns, parameters, el, iv; depth, op) + end + end + return nothing +end + +eqtype_supports_collect_vars(j::Union{ConstantRateJump, VariableRateJump}) = true +function collect_vars!(unknowns, parameters, j::Union{ConstantRateJump, VariableRateJump}, + iv; depth = 0, op = Differential) + collect_vars!(unknowns, parameters, j.rate, iv; depth, op) + for eq in j.affect! + (eq isa Equation) && collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + return nothing end +########################################## + has_massactionjumps(js::JumpSystem) = !isempty(equations(js).x[1]) has_constantratejumps(js::JumpSystem) = !isempty(equations(js).x[2]) has_variableratejumps(js::JumpSystem) = !isempty(equations(js).x[3]) +has_equations(js::JumpSystem) = !isempty(equations(js).x[4]) function generate_rate_function(js::JumpSystem, rate) consts = collect_constants(rate) @@ -208,11 +276,9 @@ function generate_rate_function(js::JumpSystem, rate) csubs = Dict(c => getdefault(c) for c in consts) rate = substitute(rate, csubs) end - p = reorder_parameters(js, parameters(js)) - rf = build_function(rate, unknowns(js), p..., + p = reorder_parameters(js) + build_function_wrapper(js, rate, unknowns(js), p..., get_iv(js), - wrap_code = wrap_array_vars(js, rate; dvs = unknowns(js), ps = parameters(js)) .∘ - wrap_parameter_dependencies(js, !(rate isa AbstractArray)), expression = Val{true}) end @@ -229,16 +295,13 @@ 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(u, p, t) = _rate(u, p, t) - rate(u, p::MTKParameters, t) = _rate(u, p..., t) - + 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 = eval_or_rgf( - generate_affect_function(js, vrj.affect!, - outputidxs); eval_expression, eval_module) - VariableRateJump(rate, affect) + affect = eval_or_rgf(generate_affect_function(js, vrj.affect!, outputidxs); + eval_expression, eval_module) + VariableRateJump(rate, affect; save_positions = vrj.save_positions) end function assemble_vrj_expr(js, vrj, unknowntoid) @@ -247,9 +310,7 @@ function assemble_vrj_expr(js, vrj, unknowntoid) outputidxs = ((unknowntoid[var] for var in outputvars)...,) affect = generate_affect_function(js, vrj.affect!, outputidxs) quote - _rate = $rate - rate(u, p, t) = _rate(u, p, t) - rate(u, p::MTKParameters, t) = _rate(u, p..., t) + rate = $rate affect = $affect VariableRateJump(rate, affect) @@ -258,15 +319,12 @@ 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(u, p, t) = _rate(u, p, t) - rate(u, p::MTKParameters, t) = _rate(u, p..., t) - + 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 = eval_or_rgf( - generate_affect_function(js, crj.affect!, - outputidxs); eval_expression, eval_module) + affect = eval_or_rgf(generate_affect_function(js, crj.affect!, outputidxs); + eval_expression, eval_module) ConstantRateJump(rate, affect) end @@ -276,9 +334,7 @@ function assemble_crj_expr(js, crj, unknowntoid) outputidxs = ((unknowntoid[var] for var in outputvars)...,) affect = generate_affect_function(js, crj.affect!, outputidxs) quote - _rate = $rate - rate(u, p, t) = _rate(u, p, t) - rate(u, p::MTKParameters, t) = _rate(u, p..., t) + rate = $rate affect = $affect ConstantRateJump(rate, affect) @@ -321,7 +377,6 @@ end ```julia DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan, parammap = DiffEqBase.NullParameters; - use_union = true, kwargs...) ``` @@ -341,32 +396,27 @@ dprob = DiscreteProblem(complete(js), u₀map, tspan, parammap) """ function DiffEqBase.DiscreteProblem(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, parammap = DiffEqBase.NullParameters(); - use_union = true, eval_expression = false, eval_module = @__MODULE__, + cse = true, kwargs...) if !iscomplete(sys) error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") end - dvs = unknowns(sys) - ps = parameters(sys) - - defs = defaults(sys) - defs = mergedefaults(defs, parammap, ps) - defs = mergedefaults(defs, u0map, dvs) - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - p = MTKParameters(sys, parammap, u0map) - else - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false, use_union) + if has_equations(sys) || (!isempty(continuous_events(sys))) + error("The passed in JumpSystem contains `Equation`s or continuous events, please use a problem type that supports these features, such as ODEProblem.") end + _f, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, check_length = false, build_initializeprob = false, cse) f = DiffEqBase.DISCRETE_INPLACE_DEFAULT - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) - df = DiscreteFunction{true, true}(f; sys = sys, observed = observedfun) + df = DiscreteFunction{true, true}(f; sys = sys, observed = observedfun, + initialization_data = get(_f.kwargs, :initialization_data, nothing)) DiscreteProblem(df, u0, tspan, p; kwargs...) end @@ -394,21 +444,13 @@ struct DiscreteProblemExpr{iip} end function DiscreteProblemExpr{iip}(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, parammap = DiffEqBase.NullParameters(); - use_union = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblemExpr`") end - dvs = unknowns(sys) - ps = parameters(sys) - defs = defaults(sys) - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - p = MTKParameters(sys, parammap, u0map) - else - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false, use_union) - end + _, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, check_length = false) # identity function to make syms works quote f = DiffEqBase.DISCRETE_INPLACE_DEFAULT @@ -425,7 +467,6 @@ end ```julia DiffEqBase.ODEProblem(sys::JumpSystem, u0map, tspan, parammap = DiffEqBase.NullParameters; - use_union = true, kwargs...) ``` @@ -447,32 +488,33 @@ oprob = ODEProblem(complete(js), u₀map, tspan, parammap) """ function DiffEqBase.ODEProblem(sys::JumpSystem, u0map, tspan::Union{Tuple, Nothing}, parammap = DiffEqBase.NullParameters(); - use_union = false, eval_expression = false, - eval_module = @__MODULE__, + eval_module = @__MODULE__, cse = true, kwargs...) if !iscomplete(sys) error("A completed `JumpSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `DiscreteProblem`") end - dvs = unknowns(sys) - ps = parameters(sys) - - defs = defaults(sys) - defs = mergedefaults(defs, parammap, ps) - defs = mergedefaults(defs, u0map, dvs) - u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - p = MTKParameters(sys, parammap, u0map) + # forward everything to be an ODESystem but the jumps and discrete events + if has_equations(sys) + osys = ODESystem(equations(sys).x[4], get_iv(sys), unknowns(sys), parameters(sys); + observed = observed(sys), name = nameof(sys), description = description(sys), + systems = get_systems(sys), defaults = defaults(sys), guesses = guesses(sys), + parameter_dependencies = parameter_dependencies(sys), + metadata = get_metadata(sys), gui_metadata = get_gui_metadata(sys)) + osys = complete(osys; add_initial_parameters = false) + return ODEProblem(osys, u0map, tspan, parammap; check_length = false, + build_initializeprob = false, kwargs...) else - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false, use_union) + _, u0, p = process_SciMLProblem(EmptySciMLFunction, sys, u0map, parammap; + t = tspan === nothing ? nothing : tspan[1], tofloat = false, + check_length = false, build_initializeprob = false, cse) + f = (du, u, p, t) -> (du .= 0; nothing) + observedfun = ObservedFunctionCache(sys; eval_expression, eval_module, + checkbounds = get(kwargs, :checkbounds, false), cse) + df = ODEFunction(f; sys, observed = observedfun) + return ODEProblem(df, u0, tspan, p; kwargs...) end - - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) - - f = (du, u, p, t) -> (du .= 0; nothing) - df = ODEFunction(f; sys, observed = observedfun) - ODEProblem(df, u0, tspan, p; kwargs...) end """ @@ -508,8 +550,11 @@ function JumpProcesses.JumpProblem(js::JumpSystem, prob, for j in eqs.x[2]] vrjs = VariableRateJump[assemble_vrj(js, j, unknowntoid; eval_expression, eval_module) for j in eqs.x[3]] - ((prob isa DiscreteProblem) && !isempty(vrjs)) && - error("Use continuous problems such as an ODEProblem or a SDEProblem with VariableRateJumps") + if prob isa DiscreteProblem + if (!isempty(vrjs) || has_equations(js) || !isempty(continuous_events(js))) + error("Use continuous problems such as an ODEProblem or a SDEProblem with VariableRateJumps, coupled differential equations, or continuous events.") + end + end jset = JumpSet(Tuple(vrjs), Tuple(crjs), nothing, majs) # dep graphs are only for constant rate jumps @@ -579,7 +624,7 @@ end function JumpSysMajParamMapper(js::JumpSystem, p; jseqs = nothing, rateconsttype = Float64) eqs = (jseqs === nothing) ? equations(js) : jseqs paramexprs = [maj.scaled_rates for maj in eqs.x[1]] - psyms = reduce(vcat, reorder_parameters(js, parameters(js)); init = []) + 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, @@ -635,3 +680,5 @@ function (ratemap::JumpSysMajParamMapper{U, V, W})(maj::MassActionJump, newparam scale_rates && JumpProcesses.scalerates!(maj.scaled_rates, maj.reactant_stoch) nothing end + +supports_initialization(::JumpSystem) = false diff --git a/src/systems/model_parsing.jl b/src/systems/model_parsing.jl index fce2add6b7..4632c1b889 100644 --- a/src/systems/model_parsing.jl +++ b/src/systems/model_parsing.jl @@ -50,7 +50,7 @@ function _model_macro(mod, name, expr, isconnector) :structural_parameters => Dict{Symbol, Dict}() ) comps = Union{Symbol, Expr}[] - ext = Ref{Any}(nothing) + ext = [] eqs = Expr[] icon = Ref{Union{String, URI}}() ps, sps, vs, = [], [], [] @@ -109,16 +109,18 @@ function _model_macro(mod, name, expr, isconnector) gui_metadata = isassigned(icon) > 0 ? GUIMetadata(GlobalRef(mod, name), icon[]) : GUIMetadata(GlobalRef(mod, name)) + description = get(dict, :description, "") + @inline pop_structure_dict!.( Ref(dict), [:constants, :defaults, :kwargs, :structural_parameters]) sys = :($ODESystem($(flatten_equations)(equations), $iv, variables, parameters; - name, systems, gui_metadata = $gui_metadata, defaults)) + name, description = $description, systems, gui_metadata = $gui_metadata, defaults)) - if ext[] === nothing + if length(ext) == 0 push!(exprs.args, :(var"#___sys___" = $sys)) else - push!(exprs.args, :(var"#___sys___" = $extend($sys, $(ext[])))) + push!(exprs.args, :(var"#___sys___" = $extend($sys, [$(ext...)]))) end isconnector && push!(exprs.args, @@ -148,41 +150,29 @@ end pop_structure_dict!(dict, key) = length(dict[key]) == 0 && pop!(dict, key) -function update_kwargs_and_metadata!(dict, kwargs, a, def, indices, type, var, +struct NoValue end +const NO_VALUE = NoValue() + +function update_kwargs_and_metadata!(dict, kwargs, a, def, type, varclass, where_types, meta) - if indices isa Nothing - if !isnothing(meta) && haskey(meta, VariableUnit) - uvar = gensym() - push!(where_types, uvar) - push!(kwargs, Expr(:kw, :($a::Union{Nothing, $uvar}), nothing)) - else - push!(kwargs, Expr(:kw, :($a::Union{Nothing, $type}), nothing)) - end - dict[:kwargs][getname(var)] = Dict(:value => def, :type => type) + 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 - vartype = gensym(:T) push!(kwargs, - Expr(:kw, - Expr(:(::), a, - Expr(:curly, :Union, :Nothing, Expr(:curly, :AbstractArray, vartype))), - nothing)) - if !isnothing(meta) && haskey(meta, VariableUnit) - push!(where_types, vartype) - else - push!(where_types, :($vartype <: $type)) - end - dict[:kwargs][getname(var)] = Dict(:value => def, :type => AbstractArray{type}) + 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][getname(var)][:type] = AbstractArray{type} + dict[varclass][1][a][:type] = AbstractArray{type} else - dict[varclass][getname(var)][:type] = type + dict[varclass][a][:type] = type end end -function parse_variable_def!(dict, mod, arg, varclass, kwargs, where_types; - def = nothing, indices::Union{Vector{UnitRange{Int}}, Nothing} = nothing, - type::Type = Real, meta = Dict{DataType, Expr}()) +function update_readable_metadata!(varclass_dict, meta::Dict, varname) metatypes = [(:connection_type, VariableConnectType), (:description, VariableDescription), (:unit, VariableUnit), @@ -197,91 +187,265 @@ function parse_variable_def!(dict, mod, arg, varclass, kwargs, where_types; (: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; indices, type) - update_kwargs_and_metadata!(dict, kwargs, a, def, indices, type, var, + 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 - Expr(:(::), Expr(:call, a, b), type) => begin - type = getfield(mod, type) - def = _type_check!(def, a, type, varclass) - 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; indices, type) - update_kwargs_and_metadata!(dict, kwargs, a, def, indices, type, var, + var = generate_var!(dict, a, b, varclass, mod; type) + update_kwargs_and_metadata!(dict, kwargs, a, def, type, varclass, where_types, meta) return var, def, Dict() end + # Condition 1 parses: + # `(l(t)[1:2, 1:3] = 1), [description = "l is more than 1D"]` + # `(l2(t)[1:N, 1:M] = 2), [description = "l is more than 1D, with arbitrary length"]` + # `(l3(t)[1:3] = 3), [description = "l2 is 1D"]` + # `(l4(t)[1:N] = 4), [description = "l2 is 1D, with arbitrary length"]` + # + # Condition 2 parses: + # `(l5(t)[1:3]::Int = 5), [description = "l3 is 1D and has a type"]` + # `(l6(t)[1:N]::Int = 6), [description = "l3 is 1D and has a type, with arbitrary length"]` + # + # Condition 3 parses: + # `e2[1:2]::Int, [description = "e2"]` + # `h2(t)[1:2]::Int, [description = "h2(t)"]` + # + # Condition 4 parses: + # `e2[1:2], [description = "e2"]` + # `h2(t)[1:2], [description = "h2(t)"]` + Expr(:tuple, Expr(:(=), Expr(:ref, a, indices...), default_val), meta_val) || + Expr(:tuple, Expr(:(=), Expr(:(::), Expr(:ref, a, indices...), type), default_val), meta_val) || + Expr(:tuple, Expr(:(::), Expr(:ref, a, indices...), type), meta_val) || + Expr(:tuple, Expr(:ref, a, indices...), meta_val) => begin + (@isdefined type) || (type = Real) + varname = Meta.isexpr(a, :call) ? a.args[1] : a + meta = parse_metadata(mod, meta_val) + varval = (@isdefined default_val) ? default_val : + unit_handled_variable_value(meta, varname) + if varclass == :parameters + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@parameters ($a[$(indices...)]::$type = $varval), + $meta_val)) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@variables ($a[$(indices)]::$type = $varval), + $meta_val)) + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, meta, type, varclass, varname, varval, where_types) + (:($varname...), var), nothing, Dict() + end + # Condition 1 parses: + # `par7(t)[1:3, 1:3]::BigFloat = 1.0, [description = "with description"]` + # + # Condition 2 parses: + # `d2[1:2] = 2` + # `l(t)[1:2, 1:3] = 2, [description = "l is more than 1D"]` + Expr(:(=), Expr(:(::), Expr(:ref, a, indices...), type), def_n_meta) || + Expr(:(=), Expr(:ref, a, indices...), def_n_meta) => begin + (@isdefined type) || (type = Real) + varname = Meta.isexpr(a, :call) ? a.args[1] : a + if Meta.isexpr(def_n_meta, :tuple) + meta = parse_metadata(mod, def_n_meta) + varval = unit_handled_variable_value(meta, varname) + val, def_n_meta = (def_n_meta.args[1], def_n_meta.args[2:end]) + if varclass == :parameters + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $val : $varname; + $varname = $first(@parameters ($a[$(indices...)]::$type = $varval), + $(def_n_meta...))) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $val : $varname; + $varname = $first(@variables $a[$(indices...)]::$type = ( + $varval), + $(def_n_meta...))) + end + else + if varclass == :parameters + Meta.isexpr(a, :call) && + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $def_n_meta : $varname; + $varname = $first(@parameters $a[$(indices...)]::$type = $varname)) + else + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $varname === $NO_VALUE ? $def_n_meta : $varname; + $varname = $first(@variables $a[$(indices...)]::$type = $varname)) + end + varval, meta = def_n_meta, nothing + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, meta, type, varclass, varname, varval, where_types) + (:($varname...), var), nothing, Dict() + end + # Condition 1 is recursively called by: + # `par5[1:3]::BigFloat` + # `par6(t)[1:3]::BigFloat` + # + # Condition 2 parses: + # `b2(t)[1:2]` + # `a2[1:2]` + Expr(:(::), Expr(:ref, a, indices...), type) || + Expr(:ref, a, indices...) => begin + (@isdefined type) || (type = Real) + varname = a isa Expr && a.head == :call ? a.args[1] : a + if varclass == :parameters + Meta.isexpr(a, :call) && assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@parameters $a[$(indices...)]::$type = $varname)) + elseif varclass == :variables + Meta.isexpr(a, :call) || + throw("$a is not a variable of the independent variable") + assert_unique_independent_var(dict, a.args[end]) + var = :($varname = $first(@variables $a[$(indices...)]::$type = $varname)) + else + throw("Symbolic array with arbitrary length is not handled for $varclass. + Please open an issue with an example.") + end + update_array_kwargs_and_metadata!( + dict, indices, kwargs, nothing, type, varclass, varname, nothing, where_types) + (:($varname...), var), nothing, Dict() + end + # Parses: `c(t) = cval + jval` + # Parses: `d = 2` + # Parses: `f = 3, [description = "f"]` + # Parses: `i(t) = 4, [description = "i(t)"]` + # Parses: `j(t) = jval, [description = "j(t)"]` + # Parses: `k = kval, [description = "k"]` + # Parses: `par0::Bool = true` + # Parses: `par3(t)::BigFloat = 1.0` Expr(:(=), a, b) => begin Base.remove_linenums!(b) def, meta = parse_default(mod, b) var, def, _ = parse_variable_def!( dict, mod, a, varclass, kwargs, where_types; def, type, meta) - if dict[varclass] isa Vector - dict[varclass][1][getname(var)][:default] = def - else - dict[varclass][getname(var)][:default] = def - end + varclass_dict = dict[varclass] isa Vector ? Ref(dict[varclass][1]) : + Ref(dict[varclass]) + varclass_dict[][getname(var)][:default] = def if meta !== nothing - for (type, key) in metatypes - if (mt = get(meta, key, nothing)) !== nothing - key == VariableConnectType && (mt = nameof(mt)) - if dict[varclass] isa Vector - dict[varclass][1][getname(var)][type] = mt - else - dict[varclass][getname(var)][type] = mt - end - end - end + 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 - for (type, key) in metatypes - if (mt = get(meta, key, nothing)) !== nothing - key == VariableConnectType && (mt = nameof(mt)) - if dict[varclass] isa Vector - dict[varclass][1][getname(var)][type] = mt - else - dict[varclass][getname(var)][type] = mt - end - end - end + 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 - Expr(:ref, a, b...) => begin - indices = map(i -> UnitRange(i.args[2], i.args[end]), b) - parse_variable_def!(dict, mod, a, varclass, kwargs, where_types; - def, indices, type, meta) - end _ => error("$arg cannot be parsed") end end -function generate_var(a, varclass; - indices::Union{Vector{UnitRange{Int}}, Nothing} = nothing, - type = Real) - var = indices === nothing ? Symbolics.variable(a; T = type) : - first(@variables $a[indices...]::type) +function generate_var(a, varclass; type = Real) + var = Symbolics.variable(a; T = type) if varclass == :parameters var = toparam(var) elseif varclass == :independent_variables @@ -311,17 +475,25 @@ function generate_var!(dict, a, varclass; vd isa Vector && (vd = first(vd)) vd[a] = Dict{Symbol, Any}() indices !== nothing && (vd[a][:size] = Tuple(lastindex.(indices))) - generate_var(a, varclass; indices, type) + 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) - prev_iv = get!(dict, :independent_variable) do - iv - end - @assert isequal(iv, prev_iv) "Multiple independent variables are used in the model" + assert_unique_independent_var(dict, iv) check_name_uniqueness(dict, a, varclass) vd = get!(dict, varclass) do Dict{Symbol, Dict{Symbol, Any}}() @@ -329,7 +501,7 @@ function generate_var!(dict, a, b, varclass, mod; vd isa Vector && (vd = first(vd)) vd[a] = Dict{Symbol, Any}() var = if indices === nothing - Symbolics.variable(a, T = SymbolicUtils.FnType{Tuple{Any}, type})(iv) + first(@variables $a(iv)::type) else vd[a][:size] = Tuple(lastindex.(indices)) first(@variables $a(iv)[indices...]::type) @@ -380,14 +552,23 @@ function parse_default(mod, a) end end -function parse_metadata(mod, a) +function parse_metadata(mod, a::Expr) MLStyle.@match a begin - Expr(:vect, eles...) => Dict(parse_metadata(mod, e) for e in eles) + 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 @@ -421,7 +602,9 @@ function parse_model!(exprs, comps, ext, eqs, icon, vs, ps, sps, c_evts, d_evts, dict, mod, arg, kwargs, where_types) mname = arg.args[1] body = arg.args[end] - if mname == Symbol("@components") + 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) @@ -540,7 +723,7 @@ function parse_structural_parameters!(exprs, sps, dict, mod, body, kwargs) end end -function extend_args!(a, b, dict, expr, kwargs, varexpr, has_param = false) +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 @@ -561,18 +744,18 @@ function extend_args!(a, b, dict, expr, kwargs, varexpr, has_param = false) 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!(varexpr.args, :($x = $x === nothing ? $y : $x)) - push!(kwargs, Expr(:kw, x, nothing)) - dict[:kwargs][x] = Dict(:value => nothing) + 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, varexpr, has_param) + extend_args!(a, arg, dict, expr, kwargs, has_param) end _ => error("Could not parse $arg of component $a") end @@ -581,30 +764,56 @@ end const EMPTY_DICT = Dict() const EMPTY_VoVoSYMBOL = Vector{Symbol}[] +const EMPTY_VoVoVoSYMBOL = Vector{Symbol}[[]] -function Base.names(model::Model) +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, - map(first, get(model.structure, :components, EMPTY_VoVoSYMBOL))) + vars = union(vars, first(get(model.structure, :extend, EMPTY_VoVoVoSYMBOL))) collect(vars) end -function _parse_extend!(ext, a, b, dict, expr, kwargs, varexpr, vars) - extend_args!(a, b, dict, expr, kwargs, varexpr) - ext[] = a +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)) - dict[:extend] = [Symbol.(vars.args), a, b.args[1]] + 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) - varexpr = Expr(:block) - push!(exprs, varexpr) push!(exprs, expr) body = deepcopy(body) MLStyle.@match body begin @@ -615,7 +824,9 @@ function parse_extend!(exprs, ext, dict, mod, body, kwargs) error("`@extend` destructuring only takes an tuple as LHS. Got $body") end a, b = b.args - _parse_extend!(ext, a, b, dict, expr, kwargs, varexpr, vars) + # 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 @@ -626,7 +837,11 @@ function parse_extend!(exprs, ext, dict, mod, body, kwargs) if (model = getproperty(mod, b.args[1])) isa Model vars = Expr(:tuple) append!(vars.args, names(model)) - _parse_extend!(ext, a, b, dict, expr, kwargs, varexpr, vars) + 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:" * @@ -649,6 +864,8 @@ function convert_units(varunits::DynamicQuantities.Quantity, value) 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.( @@ -659,62 +876,63 @@ 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 -function convert_units(varunits::Unitful.FreeUnits, value::Num) - value -end +convert_units(::Unitful.FreeUnits, value::Num) = value -function convert_units(varunits::DynamicQuantities.Quantity, value::Num) - value -end +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) - name = getname(vv) - - varexpr = if haskey(metadata_with_exprs, VariableUnit) - unit = metadata_with_exprs[VariableUnit] - quote - $name = if $name === nothing - $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) + 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 - end - else - quote - $name = if $name === nothing - $setdefault($vv, $def) - else - $setdefault($vv, $name) + else + quote + $name = if $name === $NO_VALUE + $setdefault($vv, $def) + else + $setdefault($vv, $name) + end 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 + 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 vv isa Num ? name : :($name...), varexpr + push!(varexpr.args, metadata_expr) + return symbolic_type(vv) == ScalarSymbolic() ? name : :($name...), varexpr + else + return vv + end end function handle_conditional_vars!( @@ -924,7 +1142,7 @@ function parse_icon!(body::String, dict, icon, mod) icon_dir = get(ENV, "MTK_ICONS_DIR", joinpath(DEPOT_PATH[1], "mtk_icons")) dict[:icon] = icon[] = if isfile(body) URI("file:///" * abspath(body)) - elseif (iconpath = joinpath(icon_dir, body); isfile(iconpath)) + elseif (iconpath = abspath(joinpath(icon_dir, body)); isfile(iconpath)) URI("file:///" * abspath(iconpath)) elseif try Base.isvalid(URI(body)) @@ -935,6 +1153,7 @@ function parse_icon!(body::String, dict, icon, mod) elseif (_body = lstrip(body); startswith(_body, r"<\?xml| occursin(y, x), wrt) +end + +""" +Possible reasons why a term is not polynomial +""" +EnumX.@enumx NonPolynomialReason begin + """ + Exponent of an expression involving unknowns is not an integer. + """ + NonIntegerExponent + """ + Exponent is an expression containing unknowns. + """ + ExponentContainsUnknowns + """ + The base of an exponent is not a polynomial in the unknowns. + """ + BaseNotPolynomial + """ + An expression involves a non-polynomial operation involving unknowns. + """ + UnrecognizedOperation +end + +function display_reason(reason::NonPolynomialReason.T, sym) + if reason == NonPolynomialReason.NonIntegerExponent + pow = arguments(sym)[2] + "In $sym: Exponent $pow is not an integer" + elseif reason == NonPolynomialReason.ExponentContainsUnknowns + pow = arguments(sym)[2] + "In $sym: Exponent $pow contains unknowns of the system" + elseif reason == NonPolynomialReason.BaseNotPolynomial + base = arguments(sym)[1] + "In $sym: Base $base is not a polynomial in the unknowns" + elseif reason == NonPolynomialReason.UnrecognizedOperation + op = operation(sym) + """ + In $sym: Operation $op is not recognized. Allowed polynomial operations are \ + `*, /, +, -, ^`. + """ + else + error("This should never happen. Please open an issue in ModelingToolkit.jl.") + end +end + +""" + $(TYPEDEF) + +Information about an expression about its polynomial nature. +""" +mutable struct PolynomialData + """ + A list of all non-polynomial terms in the expression. + """ + non_polynomial_terms::Vector{BasicSymbolic} + """ + Corresponding to `non_polynomial_terms`, a list of reasons why they are + not polynomial. + """ + reasons::Vector{NonPolynomialReason.T} + """ + Whether the polynomial contains parametric exponents of unknowns. + """ + has_parametric_exponent::Bool +end + +PolynomialData() = PolynomialData(BasicSymbolic[], NonPolynomialReason.T[], false) + +abstract type PolynomialTransformationError <: Exception end + +struct MultivarTerm <: PolynomialTransformationError + term::Any + vars::Any +end + +function Base.showerror(io::IO, err::MultivarTerm) + println(io, + "Cannot convert system to polynomial: Found term $(err.term) which is a function of multiple unknowns $(err.vars).") +end + +struct MultipleTermsOfSameVar <: PolynomialTransformationError + terms::Any + var::Any +end + +function Base.showerror(io::IO, err::MultipleTermsOfSameVar) + println(io, + "Cannot convert system to polynomial: Found multiple non-polynomial terms $(err.terms) involving the same unknown $(err.var).") +end + +struct SymbolicSolveFailure <: PolynomialTransformationError + term::Any + var::Any +end + +function Base.showerror(io::IO, err::SymbolicSolveFailure) + println(io, + "Cannot convert system to polynomial: Unable to symbolically solve $(err.term) for $(err.var).") +end + +struct NemoNotLoaded <: PolynomialTransformationError end + +function Base.showerror(io::IO, err::NemoNotLoaded) + println(io, + "ModelingToolkit may be able to solve this system as a polynomial system if `Nemo` is loaded. Run `import Nemo` and try again.") +end + +struct VariablesAsPolyAndNonPoly <: PolynomialTransformationError + vars::Any +end + +function Base.showerror(io::IO, err::VariablesAsPolyAndNonPoly) + println(io, + "Cannot convert convert system to polynomial: Variables $(err.vars) occur in both polynomial and non-polynomial terms in the system.") +end + +struct NotPolynomialError <: Exception + transformation_err::Union{PolynomialTransformationError, Nothing} + eq::Vector{Equation} + data::Vector{PolynomialData} +end + +function Base.showerror(io::IO, err::NotPolynomialError) + if err.transformation_err !== nothing + Base.showerror(io, err.transformation_err) + end + for (eq, data) in zip(err.eq, err.data) + if isempty(data.non_polynomial_terms) + continue + end + println(io, + "Equation $(eq) is not a polynomial in the unknowns for the following reasons:") + for (term, reason) in zip(data.non_polynomial_terms, data.reasons) + println(io, display_reason(reason, term)) + end + end +end + +function is_polynomial!(data, y, wrt) + process_polynomial!(data, y, wrt) + isempty(data.reasons) +end + +""" +$(TYPEDSIGNATURES) + +Return information about the polynmial `x` with respect to variables in `wrt`, +writing said information to `data`. +""" +function process_polynomial!(data::PolynomialData, x, wrt) + x = unwrap(x) + symbolic_type(x) == NotSymbolic() && return true + iscall(x) || return true + contains_variable(x, wrt) || return true + any(isequal(x), wrt) && return true + + if operation(x) in (*, +, -, /) + # `map` because `all` will early exit, but we want to search + # through everything to get all the non-polynomial terms + return all(map(y -> is_polynomial!(data, y, wrt), arguments(x))) + end + if operation(x) == (^) + b, p = arguments(x) + is_pow_integer = symtype(p) <: Integer + if !is_pow_integer + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.NonIntegerExponent) + end + if symbolic_type(p) != NotSymbolic() + data.has_parametric_exponent = true + end + + exponent_has_unknowns = contains_variable(p, wrt) + if exponent_has_unknowns + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.ExponentContainsUnknowns) + end + base_polynomial = is_polynomial!(data, b, wrt) + return base_polynomial && !exponent_has_unknowns && is_pow_integer + end + push!(data.non_polynomial_terms, x) + push!(data.reasons, NonPolynomialReason.UnrecognizedOperation) + return false +end + +""" + $(TYPEDEF) + +Information about how an unknown in the system is substituted for a non-polynomial +expression to turn the system into a polynomial. Used in `PolynomialTransformation`. +""" +struct PolynomialTransformationData + """ + The new variable to use as an unknown of the transformed system. + """ + new_var::BasicSymbolic + """ + The non-polynomial expression being substituted. + """ + term::BasicSymbolic + """ + A vector of expressions corresponding to the solutions of + the non-polynomial expression `term` in terms of the new unknown `new_var`, + used to backsolve for the original unknown of the system. + """ + inv_term::Vector{BasicSymbolic} +end + +""" + $(TYPEDEF) + +Information representing how to transform a `NonlinearSystem` into a polynomial +system. +""" +struct PolynomialTransformation + """ + Substitutions mapping non-polynomial terms to temporary unknowns. The system + is a polynomial in the new unknowns. Currently, each non-polynomial term is a + function of a single unknown of the original system. + """ + substitution_rules::Dict{BasicSymbolic, BasicSymbolic} + """ + A vector of expressions involving unknowns of the transformed system, mapping + back to solutions of the original system. + """ + all_solutions::Vector{Vector{BasicSymbolic}} + """ + The new unknowns of the transformed system. + """ + new_dvs::Vector{BasicSymbolic} + """ + The polynomial data for each equation. + """ + polydata::Vector{PolynomialData} +end + +function PolynomialTransformation(sys::NonlinearSystem) + # we need to consider `full_equations` because observed also should be + # polynomials (if used in equations) and we don't know if observed is used + # in denominator. + # This is not the most efficient, and would be improved significantly with + # CSE/hashconsing. + eqs = full_equations(sys) + dvs = unknowns(sys) + + # Collect polynomial information about all equations + polydata = map(eqs) do eq + data = PolynomialData() + process_polynomial!(data, eq.lhs, dvs) + process_polynomial!(data, eq.rhs, dvs) + data + end + + # Get all unique non-polynomial terms + # NOTE: + # Is there a better way to check for uniqueness? `simplify` is relatively slow + # (maybe use the threaded version?) and `expand` can blow up expression size. + # Could metatheory help? + all_non_poly_terms = mapreduce( + d -> d.non_polynomial_terms, vcat, polydata; init = BasicSymbolic[]) + unique!(all_non_poly_terms) + + # each variable can only be replaced by one non-polynomial expression involving + # that variable. Keep track of this mapping. + var_to_nonpoly = Dict{BasicSymbolic, PolynomialTransformationData}() + + is_poly = true + transformation_err = nothing + for t in all_non_poly_terms + # if the term involves multiple unknowns, we can't invert it + dvs_in_term = map(x -> occursin(x, t), dvs) + if count(dvs_in_term) > 1 + transformation_err = MultivarTerm(t, dvs[dvs_in_term]) + is_poly = false + break + end + # we already have a substitution solving for `var` + var = dvs[findfirst(dvs_in_term)] + if haskey(var_to_nonpoly, var) && !isequal(var_to_nonpoly[var].term, t) + transformation_err = MultipleTermsOfSameVar([t, var_to_nonpoly[var].term], var) + is_poly = false + break + end + # we want to solve `term - new_var` for `var` + new_var = gensym(Symbol(var)) + new_var = unwrap(only(@variables $new_var)) + invterm = Symbolics.ia_solve( + t - new_var, var; complex_roots = false, periodic_roots = false, warns = false) + # if we can't invert it, quit + if invterm === nothing || isempty(invterm) + transformation_err = SymbolicSolveFailure(t, var) + is_poly = false + break + end + # `ia_solve` returns lazy terms i.e. `asin(1.0)` instead of `pi/2` + # this just evaluates the constant expressions + invterm = Symbolics.substitute.(invterm, (Dict(),)) + # RootsOf implies Symbolics couldn't solve the inner polynomial because + # `Nemo` wasn't loaded. + if any(x -> iscall(x) && operation(x) == Symbolics.RootsOf, invterm) + transformation_err = NemoNotLoaded() + is_poly = false + break + end + var_to_nonpoly[var] = PolynomialTransformationData(new_var, t, invterm) + end + + # return the error instead of throwing it, so the user can choose what to do + # without having to catch the exception + if !is_poly + return NotPolynomialError(transformation_err, eqs, polydata) + end + + subrules = Dict{BasicSymbolic, BasicSymbolic}() + # corresponding to each unknown in `dvs`, the list of its possible solutions + # in terms of the new unknown. + combinations = Vector{BasicSymbolic}[] + new_dvs = BasicSymbolic[] + for x in dvs + if haskey(var_to_nonpoly, x) + _data = var_to_nonpoly[x] + # map term to new unknown + subrules[_data.term] = _data.new_var + push!(combinations, _data.inv_term) + push!(new_dvs, _data.new_var) + else + push!(combinations, BasicSymbolic[x]) + push!(new_dvs, x) + end + end + all_solutions = vec(collect.(collect(Iterators.product(combinations...)))) + return PolynomialTransformation(subrules, all_solutions, new_dvs, polydata) +end + +""" + $(TYPEDEF) + +A struct containing the result of transforming a system into a polynomial system +using the appropriate `PolynomialTransformation`. Also contains the denominators +in the equations, to rule out invalid roots. +""" +struct PolynomialTransformationResult + sys::NonlinearSystem + denominators::Vector{BasicSymbolic} +end + +""" + $(TYPEDSIGNATURES) + +Transform the system `sys` with `transformation` and return a +`PolynomialTransformationResult`, or a `NotPolynomialError` if the system cannot +be transformed. +""" +function transform_system(sys::NonlinearSystem, transformation::PolynomialTransformation; + fraction_cancel_fn = simplify_fractions) + subrules = transformation.substitution_rules + dvs = unknowns(sys) + eqs = full_equations(sys) + polydata = transformation.polydata + new_dvs = transformation.new_dvs + all_solutions = transformation.all_solutions + + eqs2 = Equation[] + denoms = BasicSymbolic[] + for eq in eqs + t = eq.rhs - eq.lhs + t = Symbolics.fixpoint_sub(t, subrules; maxiters = length(dvs)) + # the substituted variable occurs outside the substituted term + poly_and_nonpoly = map(dvs) do x + all(!isequal(x), new_dvs) && occursin(x, t) + end + if any(poly_and_nonpoly) + return NotPolynomialError( + VariablesAsPolyAndNonPoly(dvs[poly_and_nonpoly]), eqs, polydata) + end + num, den = handle_rational_polynomials(t, new_dvs; fraction_cancel_fn) + # make factors different elements, otherwise the nonzero factors artificially + # inflate the error of the zero factor. + if iscall(den) && operation(den) == * + for arg in arguments(den) + # ignore constant factors + symbolic_type(arg) == NotSymbolic() && continue + push!(denoms, abs(arg)) + end + elseif symbolic_type(den) != NotSymbolic() + push!(denoms, abs(den)) + end + push!(eqs2, 0 ~ num) + end + + sys2 = @set sys.eqs = eqs2 + @set! sys2.unknowns = new_dvs + # remove observed equations to avoid adding them in codegen + @set! sys2.observed = Equation[] + @set! sys2.substitutions = nothing + return PolynomialTransformationResult(sys2, denoms) +end + +""" +$(TYPEDSIGNATURES) + +Given a `x`, a polynomial in variables in `wrt` which may contain rational functions, +express `x` as a single rational function with polynomial `num` and denominator `den`. +Return `(num, den)`. + +Keyword arguments: +- `fraction_cancel_fn`: A function which takes a fraction (`operation(expr) == /`) and returns + a simplified symbolic quantity with common factors in the numerator and denominator are + cancelled. Defaults to `SymbolicUtils.simplify_fractions`, but can be changed to + `nothing` to improve performance on large polynomials at the cost of avoiding non-trivial + cancellation. +""" +function handle_rational_polynomials(x, wrt; fraction_cancel_fn = simplify_fractions) + x = unwrap(x) + symbolic_type(x) == NotSymbolic() && return x, 1 + iscall(x) || return x, 1 + contains_variable(x, wrt) || return x, 1 + any(isequal(x), wrt) && return x, 1 + + op = operation(x) + args = arguments(x) + + if op == / + # numerator and denominator are trivial + num, den = args + n1, d1 = handle_rational_polynomials(num, wrt; fraction_cancel_fn) + n2, d2 = handle_rational_polynomials(den, wrt; fraction_cancel_fn) + num, den = n1 * d2, d1 * n2 + elseif (op == +) || (op == -) + num = 0 + den = 1 + if op == - + args[2] = -args[2] + end + for arg in args + n, d = handle_rational_polynomials(arg, wrt; fraction_cancel_fn) + num = num * d + n * den + den *= d + end + elseif op == ^ + base, pow = args + num, den = handle_rational_polynomials(base, wrt; fraction_cancel_fn) + num ^= pow + den ^= pow + elseif op == * + num = 1 + den = 1 + for arg in args + n, d = handle_rational_polynomials(arg, wrt; fraction_cancel_fn) + num *= n + den *= d + end + else + error("Unhandled operation in `handle_rational_polynomials`. This should never happen. Please open an issue in ModelingToolkit.jl with an MWE.") + end + + if fraction_cancel_fn !== nothing + expr = fraction_cancel_fn(num / den) + if iscall(expr) && operation(expr) == / + num, den = arguments(expr) + else + num, den = expr, 1 + end + end + + # if the denominator isn't a polynomial in `wrt`, better to not include it + # to reduce the size of the gcd polynomial + if !contains_variable(den, wrt) + return num / den, 1 + end + return num, den +end + +function SciMLBase.HomotopyNonlinearFunction(sys::NonlinearSystem, args...; kwargs...) + ODEFunction{true}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{true}(sys::NonlinearSystem, args...; + kwargs...) + ODEFunction{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{false}(sys::NonlinearSystem, args...; + kwargs...) + ODEFunction{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function SciMLBase.HomotopyNonlinearFunction{iip, specialize}( + sys::NonlinearSystem, args...; eval_expression = false, eval_module = @__MODULE__, + p = nothing, fraction_cancel_fn = SymbolicUtils.simplify_fractions, cse = true, + kwargs...) where {iip, specialize} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `HomotopyContinuationFunction`") + end + transformation = PolynomialTransformation(sys) + if transformation isa NotPolynomialError + throw(transformation) + end + result = transform_system(sys, transformation; fraction_cancel_fn) + if result isa NotPolynomialError + throw(result) + end + + sys2 = result.sys + denoms = result.denominators + polydata = transformation.polydata + new_dvs = transformation.new_dvs + all_solutions = transformation.all_solutions + + # we want to create f, jac etc. according to `sys2` since that will do the solving + # but the `sys` inside for symbolic indexing should be the non-polynomial system + fn = NonlinearFunction{iip}(sys2; eval_expression, eval_module, cse, kwargs...) + obsfn = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) + fn = remake(fn; sys = sys, observed = obsfn) + + denominator = build_explicit_observed_function(sys2, denoms) + unpolynomialize = build_explicit_observed_function(sys2, all_solutions) + + inv_mapping = Dict(v => k for (k, v) in transformation.substitution_rules) + polynomialize_terms = [get(inv_mapping, var, var) for var in unknowns(sys2)] + polynomialize = build_explicit_observed_function(sys, polynomialize_terms) + + return HomotopyNonlinearFunction{iip, specialize}( + fn; polynomialize, unpolynomialize, denominator) +end + +struct HomotopyContinuationProblem{iip, specialization} end + +function HomotopyContinuationProblem(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{true}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem(sys::NonlinearSystem, t, + u0map::StaticArray, + args...; + kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}( + sys, t, u0map, args...; kwargs...) +end + +function HomotopyContinuationProblem{true}(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{true, SciMLBase.AutoSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{false}(sys::NonlinearSystem, args...; kwargs...) + HomotopyContinuationProblem{false, SciMLBase.FullSpecialize}(sys, args...; kwargs...) +end + +function HomotopyContinuationProblem{iip, spec}( + sys::NonlinearSystem, u0map, pmap = SciMLBase.NullParameters(); + kwargs...) where {iip, spec} + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `HomotopyContinuationProblem`") + end + f, u0, p = process_SciMLProblem( + HomotopyNonlinearFunction{iip, spec}, sys, u0map, pmap; kwargs...) + + kwargs = filter_kwargs(kwargs) + return NonlinearProblem{iip}(f, u0, p; kwargs...) +end diff --git a/src/systems/nonlinear/initializesystem.jl b/src/systems/nonlinear/initializesystem.jl index aa1ecd9a8b..ec25b9b660 100644 --- a/src/systems/nonlinear/initializesystem.jl +++ b/src/systems/nonlinear/initializesystem.jl @@ -1,114 +1,773 @@ """ $(TYPEDSIGNATURES) -Generate `NonlinearSystem` which initializes an ODE problem from specified initial conditions of an `ODESystem`. +Generate `NonlinearSystem` which initializes a problem from specified initial conditions of an `AbstractTimeDependentSystem`. """ -function generate_initializesystem(sys::ODESystem; +function generate_initializesystem(sys::AbstractTimeDependentSystem; u0map = Dict(), - name = nameof(sys), - guesses = Dict(), check_defguess = false, - default_dd_value = 0.0, - algebraic_only = false, + pmap = Dict(), initialization_eqs = [], - check_units = true, - kwargs...) - sts, eqs = unknowns(sys), equations(sys) + guesses = Dict(), + default_dd_guess = Bool(0), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), extra_metadata = (;), kwargs...) + eqs = equations(sys) + if !(eqs isa Vector{Equation}) + eqs = Equation[x for x in eqs if x isa Equation] + end + trueobs, eqs = unhack_observed(observed(sys), eqs) + # remove any observed equations that directly or indirectly contain + # delayed unknowns + isempty(trueobs) || filter_delay_equations_variables!(sys, trueobs) + vars = unique([unknowns(sys); getfield.(trueobs, :lhs)]) + vars_set = Set(vars) # for efficient in-lookup + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + additional_initialization_eqs = Vector{Equation}(initialization_eqs) + guesses = merge(get_guesses(sys), additional_guesses) idxs_diff = isdiffeq.(eqs) - idxs_alge = .!idxs_diff - num_alge = sum(idxs_alge) - # Start the equations list with algebraic equations - eqs_ics = eqs[idxs_alge] - u0 = Vector{Pair}(undef, 0) + # PREPROCESSING + u0map = copy(anydict(u0map)) + pmap = anydict(pmap) + initsys_preprocessing!(u0map, defs) + + # 1) Use algebraic equations of system as initialization constraints + idxs_alge = .!idxs_diff + append!(eqs_ics, eqs[idxs_alge]) # start equation list with algebraic equations eqs_diff = eqs[idxs_diff] - diffmap = Dict(getfield.(eqs_diff, :lhs) .=> getfield.(eqs_diff, :rhs)) - observed_diffmap = Dict(Differential(get_iv(sys)).(getfield.((observed(sys)), :lhs)) .=> - Differential(get_iv(sys)).(getfield.((observed(sys)), :rhs))) - full_diffmap = merge(diffmap, observed_diffmap) - - full_states = unique([sts; getfield.((observed(sys)), :lhs)]) - set_full_states = Set(full_states) - guesses = todict(guesses) - schedule = getfield(sys, :schedule) - - if schedule !== nothing - guessmap = [x[1] => get(guesses, x[1], default_dd_value) - for x in schedule.dummy_sub] - dd_guess = Dict(filter(x -> !isnothing(x[1]), guessmap)) - if u0map === nothing || isempty(u0map) - filtered_u0 = u0map - else - filtered_u0 = Pair[] - for x in u0map - y = get(schedule.dummy_sub, x[1], x[1]) - y = ModelingToolkit.fixpoint_sub(y, full_diffmap) - - if y ∈ set_full_states - # defer initialization until defaults are merged below - push!(filtered_u0, y => x[2]) - elseif y isa Symbolics.Arr - # scalarize array # TODO: don't scalarize arrays - _y = collect(y) - for i in eachindex(_y) - push!(filtered_u0, _y[i] => x[2][i]) - end - elseif y isa Symbolics.BasicSymbolic - # y is a derivative expression expanded - # add to the initialization equations - push!(eqs_ics, y ~ x[2]) - 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 + D = Differential(get_iv(sys)) + diffmap = merge( + Dict(eq.lhs => eq.rhs for eq in eqs_diff), + Dict(D(eq.lhs) => D(eq.rhs) for eq in trueobs) + ) + + if has_schedule(sys) && (schedule = get_schedule(sys); !isnothing(schedule)) + # 2) process dummy derivatives and u0map into initialization system + # prepare map for dummy derivative substitution + for x in filter(x -> !isnothing(x[1]), schedule.dummy_sub) + # set dummy derivatives to default_dd_guess unless specified + push!(defs, x[1] => get(guesses, x[1], default_dd_guess)) + end + function process_u0map_with_dummysubs(y, x) + y = get(schedule.dummy_sub, y, y) + y = fixpoint_sub(y, diffmap) + # If we have `D(x) ~ x` and provide [D(x) => x, x => 1.0] to `u0map`, then + # without this condition `defs` would get `x => x` instead of retaining + # `x => 1.0`. + isequal(y, x) && return + if y ∈ vars_set + # variables specified in u0 overrides defaults + push!(defs, y => x) + elseif y isa Symbolics.Arr + # TODO: don't scalarize arrays + merge!(defs, Dict(scalarize(y .=> x))) + elseif y isa Symbolics.BasicSymbolic + # y is a derivative expression expanded; add it to the initialization equations + push!(eqs_ics, y ~ x) + else + error("Initialization expression $y is currently not supported. If its a higher order derivative expression, then only the dummy derivative expressions are supported.") + end + end + for (y, x) in u0map + if Symbolics.isarraysymbolic(y) + process_u0map_with_dummysubs.(collect(y), collect(x)) + else + process_u0map_with_dummysubs(y, x) end - filtered_u0 = todict(filtered_u0) end else - dd_guess = Dict() - filtered_u0 = todict(u0map) + # 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 - defs = merge(defaults(sys), filtered_u0) - guesses = merge(get_guesses(sys), todict(guesses), dd_guess) - - for st in full_states - if st ∈ keys(defs) - def = defs[st] - - if def isa Equation - st ∉ keys(guesses) && check_defguess && - error("Invalid setup: unknown $(st) has an initial condition equation with no guess.") - push!(eqs_ics, def) - push!(u0, st => guesses[st]) - else - push!(eqs_ics, st ~ def) - push!(u0, st => def) - end - elseif st ∈ keys(guesses) - push!(u0, st => guesses[st]) + # 3) process other variables + for var in vars + if var ∈ keys(defs) + push!(eqs_ics, var ~ defs[var]) + elseif var ∈ keys(guesses) + push!(defs, var => guesses[var]) elseif check_defguess - error("Invalid setup: unknown $(st) has no default value or initial guess") + error("Invalid setup: variable $(var) has no default value or initial guess") end end + # 4) process explicitly provided initialization equations if !algebraic_only - for eq in [get_initialization_eqs(sys); initialization_eqs] - _eq = ModelingToolkit.fixpoint_sub(eq, full_diffmap) - push!(eqs_ics, _eq) + 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 - pars = [parameters(sys); get_iv(sys)] - nleqs = [eqs_ics; observed(sys)] + # 5) process parameters as initialization unknowns + paramsubs = setup_parameter_initialization!( + sys, pmap, defs, guesses, eqs_ics; check_defguess) + + # 6) parameter dependencies become equations, their LHS become unknowns + # non-numeric dependent parameters stay as parameter dependencies + new_parameter_deps = solve_parameter_dependencies!( + sys, paramsubs, eqs_ics, defs, guesses) + + # 7) handle values provided for dependent parameters similar to values for observed variables + handle_dependent_parameter_constraints!(sys, pmap, eqs_ics, paramsubs) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + p -> !haskey(paramsubs, p), parameters(sys; initial_parameters = true))) + push!(pars, get_iv(sys)) + + # 8) use observed equations for guesses of observed variables if not provided + for eq in trueobs + haskey(defs, eq.lhs) && continue + any(x -> isequal(default_toterm(x), eq.lhs), keys(defs)) && continue + + defs[eq.lhs] = eq.rhs + end + append!(eqs_ics, trueobs) + + vars = [vars; collect(values(paramsubs))] + + # even if `p => tovar(p)` is in `paramsubs`, `isparameter(p[1]) === true` after substitution + # so add scalarized versions as well + scalarize_varmap!(paramsubs) - sys_nl = NonlinearSystem(nleqs, - full_states, + eqs_ics = Symbolics.substitute.(eqs_ics, (paramsubs,)) + for k in keys(defs) + defs[k] = substitute(defs[k], paramsubs) + end + meta = InitializationSystemMetadata( + anydict(u0map), anydict(pmap), additional_guesses, + additional_initialization_eqs, extra_metadata, nothing) + return NonlinearSystem(eqs_ics, + vars, pars; - defaults = merge(ModelingToolkit.defaults(sys), todict(u0), dd_guess), - parameter_dependencies = parameter_dependencies(sys), + defaults = defs, checks = check_units, + parameter_dependencies = new_parameter_deps, name, + metadata = meta, kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Generate `NonlinearSystem` which initializes a problem from specified initial conditions of an `AbstractTimeDependentSystem`. +""" +function generate_initializesystem(sys::AbstractTimeIndependentSystem; + u0map = Dict(), + pmap = Dict(), + initialization_eqs = [], + guesses = Dict(), + algebraic_only = false, + check_units = true, check_defguess = false, + name = nameof(sys), extra_metadata = (;), kwargs...) + eqs = equations(sys) + trueobs, eqs = unhack_observed(observed(sys), eqs) + vars = unique([unknowns(sys); getfield.(trueobs, :lhs)]) + vars_set = Set(vars) # for efficient in-lookup + + eqs_ics = Equation[] + defs = copy(defaults(sys)) # copy so we don't modify sys.defaults + additional_guesses = anydict(guesses) + additional_initialization_eqs = Vector{Equation}(initialization_eqs) + guesses = merge(get_guesses(sys), additional_guesses) + + # PREPROCESSING + u0map = copy(anydict(u0map)) + pmap = anydict(pmap) + initsys_preprocessing!(u0map, defs) + + # Calculate valid `Initial` parameters. These are unknowns for + # which constant initial values were provided. By this point, + # they have been separated into `x => Initial(x)` in `u0map` + # and `Initial(x) => val` in `pmap`. + valid_initial_parameters = Set{BasicSymbolic}() + for (k, v) in u0map + isequal(Initial(k), v) || continue + push!(valid_initial_parameters, v) + end + + # get the initialization equations + if !algebraic_only + initialization_eqs = [get_initialization_eqs(sys); initialization_eqs] + end + + # only include initialization equations where all the involved `Initial` + # parameters are valid. + vs = Set() + initialization_eqs = filter(initialization_eqs) do eq + empty!(vs) + vars!(vs, eq; op = Initial) + non_params = filter(!isparameter, vs) + # error if non-parameters are present in the initialization equations + if !isempty(non_params) + throw(UnknownsInTimeIndependentInitializationError(eq, non_params)) + end + filter!(x -> iscall(x) && isinitial(x), vs) + invalid_initials = setdiff(vs, valid_initial_parameters) + return isempty(invalid_initials) + end + + append!(eqs_ics, initialization_eqs) + + # process parameters as initialization unknowns + paramsubs = setup_parameter_initialization!( + sys, pmap, defs, guesses, eqs_ics; check_defguess) + + # parameter dependencies become equations, their LHS become unknowns + # non-numeric dependent parameters stay as parameter dependencies + new_parameter_deps = solve_parameter_dependencies!( + sys, paramsubs, eqs_ics, defs, guesses) + + # handle values provided for dependent parameters similar to values for observed variables + handle_dependent_parameter_constraints!(sys, pmap, eqs_ics, paramsubs) + + # parameters do not include ones that became initialization unknowns + pars = Vector{SymbolicParam}(filter( + p -> !haskey(paramsubs, p), parameters(sys; initial_parameters = true))) + vars = collect(values(paramsubs)) + + # even if `p => tovar(p)` is in `paramsubs`, `isparameter(p[1]) === true` after substitution + # so add scalarized versions as well + scalarize_varmap!(paramsubs) + + eqs_ics = Symbolics.substitute.(eqs_ics, (paramsubs,)) + for k in keys(defs) + defs[k] = substitute(defs[k], paramsubs) + end + meta = InitializationSystemMetadata( + anydict(u0map), anydict(pmap), additional_guesses, + additional_initialization_eqs, extra_metadata, nothing) + return NonlinearSystem(eqs_ics, + vars, + pars; + defaults = defs, + checks = check_units, + parameter_dependencies = new_parameter_deps, + name, + metadata = meta, + kwargs...) +end + +""" + $(TYPEDSIGNATURES) + +Preprocessing step for initialization. Currently removes key `k` from `defs` and `u0map` +if `k => nothing` is present in `u0map`. +""" +function initsys_preprocessing!(u0map::AbstractDict, defs::AbstractDict) + for (k, v) in u0map + v === nothing || continue + delete!(defs, k) + end + filter_missing_values!(u0map) +end + +""" + $(TYPEDSIGNATURES) + +Update `defs` and `eqs_ics` appropriately for parameter initialization. Return a dictionary +mapping solvable parameters to their `tovar` variants. +""" +function setup_parameter_initialization!( + sys::AbstractSystem, pmap::AbstractDict, defs::AbstractDict, + guesses::AbstractDict, eqs_ics::Vector{Equation}; check_defguess = false) + paramsubs = Dict() + for p in parameters(sys) + if is_parameter_solvable(p, pmap, defs, guesses) + # If either of them are `missing` the parameter is an unknown + # But if the parameter is passed a value, use that as an additional + # equation in the system + _val1 = get_possibly_array_fallback_singletons(pmap, p) + _val2 = get_possibly_array_fallback_singletons(defs, p) + _val3 = get_possibly_array_fallback_singletons(guesses, p) + varp = tovar(p) + paramsubs[p] = varp + # Has a default of `missing`, and (either an equation using the value passed to `ODEProblem` or a guess) + if _val2 === missing + if _val1 !== nothing && _val1 !== missing + push!(eqs_ics, varp ~ _val1) + push!(defs, varp => _val1) + elseif _val3 !== nothing + # assuming an equation exists (either via algebraic equations or initialization_eqs) + push!(defs, varp => _val3) + elseif check_defguess + error("Invalid setup: parameter $(p) has no default value, initial value, or guess") + end + # `missing` passed to `ODEProblem`, and (either an equation using default or a guess) + elseif _val1 === missing + if _val2 !== nothing && _val2 !== missing + push!(eqs_ics, varp ~ _val2) + push!(defs, varp => _val2) + elseif _val3 !== nothing + push!(defs, varp => _val3) + elseif check_defguess + error("Invalid setup: parameter $(p) has no default value, initial value, or guess") + end + # given a symbolic value to ODEProblem + elseif symbolic_type(_val1) != NotSymbolic() || is_array_of_symbolics(_val1) + push!(eqs_ics, varp ~ _val1) + push!(defs, varp => _val3) + # No value passed to `ODEProblem`, but a default and a guess are present + # _val2 !== missing is implied by it falling this far in the elseif chain + elseif _val1 === nothing && _val2 !== nothing + push!(eqs_ics, varp ~ _val2) + push!(defs, varp => _val3) + else + # _val1 !== missing and _val1 !== nothing, so a value was provided to ODEProblem + # This would mean `is_parameter_solvable` returned `false`, so we never end up + # here + error("This should never be reached") + end + end + end + + return paramsubs +end + +""" + $(TYPEDSIGNATURES) + +Add appropriate parameter dependencies as initialization equations. Return the new list of +parameter dependencies for the initialization system. +""" +function solve_parameter_dependencies!(sys::AbstractSystem, paramsubs::AbstractDict, + eqs_ics::Vector{Equation}, defs::AbstractDict, guesses::AbstractDict) + new_parameter_deps = Equation[] + for eq in parameter_dependencies(sys) + if !is_variable_floatingpoint(eq.lhs) + push!(new_parameter_deps, eq) + continue + end + varp = tovar(eq.lhs) + paramsubs[eq.lhs] = varp + push!(eqs_ics, eq) + guessval = get(guesses, eq.lhs, eq.rhs) + push!(defs, varp => guessval) + end + + return new_parameter_deps +end + +""" + $(TYPEDSIGNATURES) + +Turn values provided for parameter dependencies into initialization equations. +""" +function handle_dependent_parameter_constraints!(sys::AbstractSystem, pmap::AbstractDict, + eqs_ics::Vector{Equation}, paramsubs::AbstractDict) + for (k, v) in merge(defaults(sys), pmap) + if is_variable_floatingpoint(k) && has_parameter_dependency_with_lhs(sys, k) + push!(eqs_ics, paramsubs[k] ~ v) + end + end + + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Get a new symbolic variable of the same type and size as `sym`, which is a parameter. +""" +function get_initial_value_parameter(sym) + sym = default_toterm(unwrap(sym)) + name = hasname(sym) ? getname(sym) : Symbol(sym) + if iscall(sym) && operation(sym) === getindex + name = Symbol(name, :_, join(arguments(sym)[2:end], "_")) + end + name = Symbol(name, :ₘₜₖ_₀) + newvar = unwrap(similar_variable(sym, name; use_gensym = false)) + return toparam(newvar) +end + +""" + $(TYPEDSIGNATURES) + +Given `sys` and a list of observed equations `trueobs`, remove all the equations that +directly or indirectly contain a delayed unknown of `sys`. +""" +function filter_delay_equations_variables!(sys::AbstractSystem, trueobs::Vector{Equation}) + is_time_dependent(sys) || return trueobs + banned_vars = Set() + idxs_to_remove = Int[] + for (i, eq) in enumerate(trueobs) + _has_delays(sys, eq.rhs, banned_vars) || continue + push!(idxs_to_remove, i) + push!(banned_vars, eq.lhs) + end + return deleteat!(trueobs, idxs_to_remove) +end + +""" + $(TYPEDSIGNATURES) + +Check if the expression `ex` contains a delayed unknown of `sys` or a term in +`banned`. +""" +function _has_delays(sys::AbstractSystem, ex, banned) + ex = unwrap(ex) + ex in banned && return true + if symbolic_type(ex) == NotSymbolic() + if is_array_of_symbolics(ex) + return any(x -> _has_delays(sys, x, banned), ex) + end + return false + end + iscall(ex) || return false + op = operation(ex) + args = arguments(ex) + if iscalledparameter(ex) + return any(x -> _has_delays(sys, x, banned), args) + end + if issym(op) && length(args) == 1 && is_variable(sys, op(get_iv(sys))) && + iscall(args[1]) && get_iv(sys) in vars(args[1]) + return true + end + return any(x -> _has_delays(sys, x, banned), args) +end + +struct ReconstructInitializeprob + getter::Any + setter::Any +end + +function ReconstructInitializeprob( + srcsys::AbstractSystem, dstsys::AbstractSystem) + syms = reduce( + vcat, reorder_parameters(dstsys, parameters(dstsys)); + init = []) + getter = getu(srcsys, syms) + setter = setp_oop(dstsys, syms) + return ReconstructInitializeprob(getter, setter) +end + +function (rip::ReconstructInitializeprob)(srcvalp, dstvalp) + newp = rip.setter(dstvalp, rip.getter(srcvalp)) + if state_values(dstvalp) === nothing + return nothing, newp + end + srcu0 = state_values(srcvalp) + T = srcu0 === nothing || isempty(srcu0) ? Union{} : eltype(srcu0) + if parameter_values(dstvalp) isa MTKParameters + if !isempty(newp.tunable) + T = promote_type(eltype(newp.tunable), T) + end + elseif !isempty(newp) + T = promote_type(eltype(newp), T) + end + if T == eltype(state_values(dstvalp)) + u0 = state_values(dstvalp) + elseif T != Union{} + u0 = T.(state_values(dstvalp)) + end + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Tunable(), newp) + if eltype(buf) != T + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + buf, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Initials(), newp) + if eltype(buf) != T + newbuf = similar(buf, T) + copyto!(newbuf, buf) + newp = repack(newbuf) + end + return u0, newp +end + +struct InitializationSystemMetadata + u0map::Dict{Any, Any} + pmap::Dict{Any, Any} + additional_guesses::Dict{Any, Any} + additional_initialization_eqs::Vector{Equation} + extra_metadata::NamedTuple + oop_reconstruct_u0_p::Union{Nothing, ReconstructInitializeprob} +end + +function get_possibly_array_fallback_singletons(varmap, p) + if haskey(varmap, p) + return varmap[p] + end + if symbolic_type(p) == ArraySymbolic() + is_sized_array_symbolic(p) || return nothing + scal = collect(p) + if all(x -> haskey(varmap, x), scal) + res = [varmap[x] for x in scal] + if any(x -> x === nothing, res) + return nothing + elseif any(x -> x === missing, res) + return missing + end + return res + end + elseif iscall(p) && operation(p) == getindex + arrp = arguments(p)[1] + val = get_possibly_array_fallback_singletons(varmap, arrp) + if val === nothing + return nothing + elseif val === missing + return missing + else + return val + end + end + return nothing +end + +function is_parameter_solvable(p, pmap, defs, guesses) + p = unwrap(p) + is_variable_floatingpoint(p) || return false + _val1 = pmap isa AbstractDict ? get_possibly_array_fallback_singletons(pmap, p) : + nothing + _val2 = get_possibly_array_fallback_singletons(defs, p) + _val3 = get_possibly_array_fallback_singletons(guesses, p) + # either (missing is a default or was passed to the ODEProblem) or (nothing was passed to + # the ODEProblem and it has a default and a guess) + return ((_val1 === missing || _val2 === missing) || + (symbolic_type(_val1) != NotSymbolic() || is_array_of_symbolics(_val1) || + _val1 === nothing && _val2 !== nothing)) && _val3 !== nothing +end + +function SciMLBase.remake_initialization_data( + sys::AbstractSystem, odefn, u0, t0, p, newu0, newp) + if u0 === missing && p === missing + return odefn.initialization_data + end + if !(eltype(u0) <: Pair) && !(eltype(p) <: Pair) + oldinitdata = odefn.initialization_data + oldinitdata === nothing && return nothing + + oldinitprob = oldinitdata.initializeprob + oldinitprob === nothing && return nothing + if !SciMLBase.has_sys(oldinitprob.f) || !(oldinitprob.f.sys isa NonlinearSystem) + return oldinitdata + end + oldinitsys = oldinitprob.f.sys + meta = get_metadata(oldinitsys) + if meta isa InitializationSystemMetadata && meta.oop_reconstruct_u0_p !== nothing + reconstruct_fn = meta.oop_reconstruct_u0_p + else + reconstruct_fn = ReconstructInitializeprob(sys, oldinitsys) + end + # the history function doesn't matter because `reconstruct_fn` is only going to + # update the values of parameters, which aren't time dependent. The reason it + # is called is because `Initial` parameters are calculated from the corresponding + # state values. + history_fn = is_time_dependent(sys) && !is_markovian(sys) ? Returns(newu0) : nothing + new_initu0, new_initp = reconstruct_fn( + ProblemState(; u = newu0, p = newp, t = t0, h = history_fn), oldinitprob) + if oldinitprob.f.resid_prototype === nothing + newf = oldinitprob.f + else + newf = NonlinearFunction{ + SciMLBase.isinplace(oldinitprob.f), SciMLBase.specialization(oldinitprob.f)}( + oldinitprob.f; + resid_prototype = calculate_resid_prototype( + length(oldinitprob.f.resid_prototype), new_initu0, new_initp)) + end + initprob = remake(oldinitprob; f = newf, u0 = new_initu0, p = new_initp) + return SciMLBase.OverrideInitData(initprob, oldinitdata.update_initializeprob!, + oldinitdata.initializeprobmap, oldinitdata.initializeprobpmap) + end + dvs = unknowns(sys) + ps = parameters(sys) + u0map = to_varmap(u0, dvs) + symbols_to_symbolics!(sys, u0map) + add_toterms!(u0map) + pmap = to_varmap(p, ps) + symbols_to_symbolics!(sys, pmap) + guesses = Dict() + defs = defaults(sys) + cmap, cs = get_cmap(sys) + use_scc = true + initialization_eqs = Equation[] + + if SciMLBase.has_initializeprob(odefn) + oldsys = odefn.initialization_data.initializeprob.f.sys + meta = get_metadata(oldsys) + if meta isa InitializationSystemMetadata + u0map = merge(meta.u0map, u0map) + pmap = merge(meta.pmap, pmap) + merge!(guesses, meta.additional_guesses) + use_scc = get(meta.extra_metadata, :use_scc, true) + initialization_eqs = meta.additional_initialization_eqs + end + else + # there is no initializeprob, so the original problem construction + # had no solvable parameters and had the differential variables + # specified in `u0map`. + if u0 === missing + # the user didn't pass `u0` to `remake`, so they want to retain + # existing values. Fill the differential variables in `u0map`, + # initialization will either be elided or solve for the algebraic + # variables + diff_idxs = isdiffeq.(equations(sys)) + for i in eachindex(dvs) + diff_idxs[i] || continue + u0map[dvs[i]] = newu0[i] + end + end + # ensure all unknowns have guesses in case they weren't given one + # and become solvable + for i in eachindex(dvs) + haskey(guesses, dvs[i]) && continue + guesses[dvs[i]] = newu0[i] + end + if p === missing + # the user didn't pass `p` to `remake`, so they want to retain + # existing values. Fill all parameters in `pmap` so that none of + # them are solvable. + for p in ps + pmap[p] = getp(sys, p)(newp) + end + end + # all non-solvable parameters need values regardless + for p in ps + haskey(pmap, p) && continue + is_parameter_solvable(p, pmap, defs, guesses) && continue + pmap[p] = getp(sys, p)(newp) + end + end + if t0 === nothing && is_time_dependent(sys) + t0 = 0.0 + end + filter_missing_values!(u0map) + filter_missing_values!(pmap) + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0map, pmap, defs, cmap, dvs, ps) + floatT = float_type_from_varmap(op) + kws = maybe_build_initialization_problem( + sys, op, u0map, pmap, t0, defs, guesses, missing_unknowns; + use_scc, initialization_eqs, floatT, allow_incomplete = true) + + return SciMLBase.remake_initialization_data(sys, kws, newu0, t0, newp, newu0, newp) +end + +function SciMLBase.late_binding_update_u0_p( + prob, sys::AbstractSystem, u0, p, t0, newu0, newp) + supports_initialization(sys) || return newu0, newp + u0 === missing && return newu0, (p === missing ? copy(newp) : newp) + # non-symbolic u0 updates initials... + if !(eltype(u0) <: Pair) + # if `p` is not provided or is symbolic + p === missing || eltype(p) <: Pair || return newu0, newp + newu0 === nothing && return newu0, newp + all(is_parameter(sys, Initial(x)) for x in unknowns(sys)) || return newu0, newp + newp = p === missing ? copy(newp) : newp + initials, repack, alias = SciMLStructures.canonicalize( + SciMLStructures.Initials(), newp) + if eltype(initials) != eltype(newu0) + initials = DiffEqBase.promote_u0(initials, newu0, t0) + newp = repack(initials) + end + if length(newu0) != length(unknowns(sys)) + throw(ArgumentError("Expected `newu0` to be of same length as unknowns ($(length(unknowns(sys)))). Got $(typeof(newu0)) of length $(length(newu0))")) + end + setp(sys, Initial.(unknowns(sys)))(newp, newu0) + return newu0, newp + end + + newp = p === missing ? copy(newp) : newp + newu0 = DiffEqBase.promote_u0(newu0, newp, t0) + tunables, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Tunable(), newp) + if eltype(tunables) != eltype(newu0) + tunables = DiffEqBase.promote_u0(tunables, newu0, t0) + newp = repack(tunables) + end + initials, repack, alias = SciMLStructures.canonicalize(SciMLStructures.Initials(), newp) + if eltype(initials) != eltype(newu0) + initials = DiffEqBase.promote_u0(initials, newu0, t0) + newp = repack(initials) + end + + allsyms = all_symbols(sys) + for (k, v) in u0 + v === nothing && continue + (symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v)) || continue + if k isa Symbol + k2 = symbol_to_symbolic(sys, k; allsyms) + # if it is returned as-is, there is no match so skip it + k2 === k && continue + k = k2 + end + is_parameter(sys, Initial(k)) || continue + setp(sys, Initial(k))(newp, v) + end + + return newu0, newp +end + +""" + $(TYPEDSIGNATURES) + +Check if the given system is an initialization system. +""" +function is_initializesystem(sys::AbstractSystem) + sys isa NonlinearSystem && get_metadata(sys) isa InitializationSystemMetadata +end + +""" +Counteracts the CSE/array variable hacks in `symbolics_tearing.jl` so it works with +initialization. +""" +function unhack_observed(obseqs::Vector{Equation}, eqs::Vector{Equation}) + subs = Dict() + tempvars = Set() + rm_idxs = Int[] + for (i, eq) in enumerate(obseqs) + iscall(eq.rhs) || continue + if operation(eq.rhs) == StructuralTransformations.change_origin + push!(rm_idxs, i) + continue + end + if operation(eq.rhs) == StructuralTransformations.getindex_wrapper + var, idxs = arguments(eq.rhs) + subs[eq.rhs] = var[idxs...] + push!(tempvars, var) + end + end + + for (i, eq) in enumerate(eqs) + iscall(eq.rhs) || continue + if operation(eq.rhs) == StructuralTransformations.getindex_wrapper + var, idxs = arguments(eq.rhs) + subs[eq.rhs] = var[idxs...] + push!(tempvars, var) + end + end + + for (i, eq) in enumerate(obseqs) + if eq.lhs in tempvars + subs[eq.lhs] = eq.rhs + push!(rm_idxs, i) + end + end + + obseqs = obseqs[setdiff(eachindex(obseqs), rm_idxs)] + obseqs = map(obseqs) do eq + fixpoint_sub(eq.lhs, subs) ~ fixpoint_sub(eq.rhs, subs) + end + eqs = map(eqs) do eq + fixpoint_sub(eq.lhs, subs) ~ fixpoint_sub(eq.rhs, subs) + end + return obseqs, eqs +end - return sys_nl +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 index 1f36d61601..856822492b 100644 --- a/src/systems/nonlinear/nonlinearsystem.jl +++ b/src/systems/nonlinear/nonlinearsystem.jl @@ -44,6 +44,10 @@ struct NonlinearSystem <: AbstractTimeIndependentSystem """ name::Symbol """ + A description of the system. + """ + description::String + """ The internal systems. These are required to have unique names. """ systems::Vector{NonlinearSystem} @@ -53,6 +57,19 @@ struct NonlinearSystem <: AbstractTimeIndependentSystem """ defaults::Dict """ + The guesses to use as the initial conditions for the + initialization system. + """ + guesses::Dict + """ + The system for performing the initialization. + """ + initializesystem::Union{Nothing, NonlinearSystem} + """ + Extra equations to be enforced during the initialization sequence. + """ + initialization_eqs::Vector{Equation} + """ Type of the system. """ connector_type::Any @@ -78,7 +95,11 @@ struct NonlinearSystem <: AbstractTimeIndependentSystem """ substitutions::Any """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -91,29 +112,35 @@ struct NonlinearSystem <: AbstractTimeIndependentSystem parent::Any isscheduled::Bool - function NonlinearSystem(tag, eqs, unknowns, ps, var_to_name, observed, jac, name, - systems, - defaults, connector_type, parameter_dependencies = Equation[], metadata = nothing, - gui_metadata = nothing, - tearing_state = nothing, substitutions = nothing, + function NonlinearSystem( + tag, eqs, unknowns, ps, var_to_name, observed, jac, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, connector_type, + parameter_dependencies = Equation[], metadata = nothing, gui_metadata = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, complete = false, index_cache = nothing, parent = nothing, isscheduled = false; checks::Union{Bool, Int} = true) if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(unknowns, ps) check_units(u, eqs) + check_subsystems(systems) end - new(tag, eqs, unknowns, ps, var_to_name, observed, jac, name, systems, defaults, + new(tag, eqs, unknowns, ps, var_to_name, observed, jac, name, description, + systems, defaults, guesses, initializesystem, initialization_eqs, connector_type, parameter_dependencies, metadata, gui_metadata, tearing_state, - substitutions, complete, index_cache, parent, isscheduled) + substitutions, namespacing, complete, index_cache, parent, isscheduled) end end function NonlinearSystem(eqs, unknowns, ps; observed = [], name = nothing, + description = "", default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), + guesses = Dict(), + initializesystem = nothing, + initialization_eqs = Equation[], systems = NonlinearSystem[], connector_type = nothing, continuous_events = nothing, # this argument is only required for ODESystems, but is added here for the constructor to accept it without error @@ -126,41 +153,50 @@ function NonlinearSystem(eqs, unknowns, ps; throw(ArgumentError("NonlinearSystem does not accept `continuous_events`, you provided $continuous_events")) discrete_events === nothing || isempty(discrete_events) || throw(ArgumentError("NonlinearSystem does not accept `discrete_events`, you provided $discrete_events")) - name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) - # Move things over, but do not touch array expressions - # - # # we cannot scalarize in the loop because `eqs` itself might require - # scalarization - eqs = [x.lhs isa Union{Symbolic, Number} ? 0 ~ x.rhs - x.lhs : x - for x in scalarize(eqs)] - - if !(isempty(default_u0) && isempty(default_p)) + length(unique(nameof.(systems))) == length(systems) || + throw(ArgumentError("System names must be unique.")) + (isempty(default_u0) && isempty(default_p)) || Base.depwarn( "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", :NonlinearSystem, force = true) + + # Accept a single (scalar/vector) equation, but make array for consistent internal handling + if !(eqs isa AbstractArray) + eqs = [eqs] end - sysnames = nameof.(systems) - if length(unique(sysnames)) != length(sysnames) - throw(ArgumentError("System names must be unique.")) - end + + # Copy equations to canonical form, but do not touch array expressions + eqs = [wrap(eq.lhs) isa Symbolics.Arr ? eq : 0 ~ eq.rhs - eq.lhs for eq in eqs] + jac = RefValue{Any}(EMPTY_JAC) - defaults = todict(defaults) - defaults = Dict{Any, Any}(value(k) => value(v) - for (k, v) in pairs(defaults) if value(v) !== nothing) - unknowns, ps = value.(unknowns), value.(ps) + ps′ = value.(ps) + dvs′ = value.(unknowns) + parameter_dependencies, ps′ = process_parameter_dependencies( + parameter_dependencies, ps′) + + defaults = Dict{Any, Any}(todict(defaults)) + guesses = Dict{Any, Any}(todict(guesses)) var_to_name = Dict() - process_variables!(var_to_name, defaults, unknowns) - process_variables!(var_to_name, defaults, ps) + process_variables!(var_to_name, defaults, guesses, dvs′) + process_variables!(var_to_name, defaults, guesses, ps′) + process_variables!( + var_to_name, defaults, guesses, [eq.lhs for eq in parameter_dependencies]) + process_variables!( + var_to_name, defaults, guesses, [eq.rhs for eq in parameter_dependencies]) + defaults = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(defaults) if v !== nothing) + guesses = Dict{Any, Any}(value(k) => value(v) + for (k, v) in pairs(guesses) if v !== nothing) + isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) - parameter_dependencies, ps = process_parameter_dependencies( - parameter_dependencies, ps) NonlinearSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), - eqs, unknowns, ps, var_to_name, observed, jac, name, systems, defaults, - connector_type, parameter_dependencies, metadata, gui_metadata, checks = checks) + eqs, dvs′, ps′, var_to_name, observed, jac, name, description, systems, defaults, + guesses, initializesystem, initialization_eqs, connector_type, parameter_dependencies, + metadata, gui_metadata, checks = checks) end function NonlinearSystem(eqs; kwargs...) @@ -168,16 +204,13 @@ function NonlinearSystem(eqs; kwargs...) allunknowns = OrderedSet() ps = OrderedSet() for eq in eqs - collect_vars!(allunknowns, ps, eq.lhs, nothing) - collect_vars!(allunknowns, ps, eq.rhs, nothing) + collect_vars!(allunknowns, ps, eq, nothing) end for eq in get(kwargs, :parameter_dependencies, Equation[]) if eq isa Pair - collect_vars!(allunknowns, ps, eq[1], nothing) - collect_vars!(allunknowns, ps, eq[2], nothing) + collect_vars!(allunknowns, ps, eq, nothing) else - collect_vars!(allunknowns, ps, eq.lhs, nothing) - collect_vars!(allunknowns, ps, eq.rhs, nothing) + collect_vars!(allunknowns, ps, eq, nothing) end end new_ps = OrderedSet() @@ -191,6 +224,12 @@ function NonlinearSystem(eqs; kwargs...) 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 @@ -198,6 +237,32 @@ function NonlinearSystem(eqs; kwargs...) return NonlinearSystem(eqs, collect(allunknowns), collect(new_ps); kwargs...) end +""" + $(TYPEDSIGNATURES) + +Convert an `ODESystem` to a `NonlinearSystem` solving for its steady state (where derivatives are zero). +Any differential variable `D(x) ~ f(...)` will be turned into `0 ~ f(...)`. The returned system is not +simplified. If the input system is `complete`d, then so will the returned system. +""" +function NonlinearSystem(sys::AbstractODESystem) + eqs = equations(sys) + obs = observed(sys) + subrules = Dict(D(x) => 0.0 for x in unknowns(sys)) + eqs = map(eqs) do eq + fast_substitute(eq, subrules) + end + + nsys = NonlinearSystem(eqs, unknowns(sys), [parameters(sys); get_iv(sys)]; + parameter_dependencies = parameter_dependencies(sys), + defaults = merge(defaults(sys), Dict(get_iv(sys) => Inf)), guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), name = nameof(sys), + observed = obs) + if iscomplete(sys) + nsys = complete(nsys; split = is_split(sys)) + end + return nsys +end + function calculate_jacobian(sys::NonlinearSystem; sparse = false, simplify = false) cache = get_jac(sys)[] if cache isa Tuple && cache[2] == (sparse, simplify) @@ -220,15 +285,12 @@ function calculate_jacobian(sys::NonlinearSystem; sparse = false, simplify = fal end function generate_jacobian( - sys::NonlinearSystem, vs = unknowns(sys), ps = parameters(sys); - sparse = false, simplify = false, wrap_code = identity, kwargs...) + sys::NonlinearSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) jac = calculate_jacobian(sys, sparse = sparse, simplify = simplify) - pre, sol_states = get_substitutions_and_solved_unknowns(sys) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, jac; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function( - jac, vs, p...; postprocess_fbody = pre, states = sol_states, wrap_code, kwargs...) + return build_function_wrapper(sys, jac, vs, p...; kwargs...) end function calculate_hessian(sys::NonlinearSystem; sparse = false, simplify = false) @@ -244,26 +306,26 @@ function calculate_hessian(sys::NonlinearSystem; sparse = false, simplify = fals end function generate_hessian( - sys::NonlinearSystem, vs = unknowns(sys), ps = parameters(sys); - sparse = false, simplify = false, wrap_code = identity, kwargs...) + sys::NonlinearSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) hess = calculate_hessian(sys, sparse = sparse, simplify = simplify) - pre = get_preprocess_constants(hess) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, hess; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function(hess, vs, p...; postprocess_fbody = pre, wrap_code, kwargs...) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) end function generate_function( - sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys); - wrap_code = identity, kwargs...) + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + scalar = false, kwargs...) rhss = [deq.rhs for deq in equations(sys)] - pre, sol_states = get_substitutions_and_solved_unknowns(sys) - wrap_code = wrap_code .∘ wrap_array_vars(sys, rhss; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false) + dvs′ = value.(dvs) + if scalar + rhss = only(rhss) + dvs′ = only(dvs) + end p = reorder_parameters(sys, value.(ps)) - return build_function(rhss, value.(dvs), p...; postprocess_fbody = pre, - states = sol_states, wrap_code, kwargs...) + return build_function_wrapper(sys, rhss, dvs′, p...; kwargs...) end function jacobian_sparsity(sys::NonlinearSystem) @@ -276,6 +338,16 @@ function hessian_sparsity(sys::NonlinearSystem) unknowns(sys)) for eq in equations(sys)] 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 + """ ```julia SciMLBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = unknowns(sys), @@ -286,7 +358,7 @@ SciMLBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = unknowns(sys), kwargs...) where {iip} ``` -Create an `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments +Create a `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, respectively. """ @@ -295,47 +367,78 @@ function SciMLBase.NonlinearFunction(sys::NonlinearSystem, args...; kwargs...) end function SciMLBase.NonlinearFunction{iip}(sys::NonlinearSystem, dvs = unknowns(sys), - ps = parameters(sys), u0 = nothing; + ps = parameters(sys), u0 = nothing; p = nothing, version = nothing, jac = false, eval_expression = false, eval_module = @__MODULE__, sparse = false, simplify = false, + initialization_data = nothing, cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearFunction`") end - f_gen = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + f_gen = generate_function(sys, dvs, ps; expression = Val{true}, cse, kwargs...) f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) - f(u, p) = f_oop(u, p) - f(u, p::MTKParameters) = f_oop(u, p...) - f(du, u, p) = f_iip(du, u, p) - f(du, u, p::MTKParameters) = f_iip(du, u, p...) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f_oop, f_iip) if jac jac_gen = generate_jacobian(sys, dvs, ps; simplify = simplify, sparse = sparse, - expression = Val{true}, kwargs...) + expression = Val{true}, cse, kwargs...) jac_oop, jac_iip = eval_or_rgf.(jac_gen; eval_expression, eval_module) - _jac(u, p) = jac_oop(u, p) - _jac(u, p::MTKParameters) = jac_oop(u, p...) - _jac(J, u, p) = jac_iip(J, u, p) - _jac(J, u, p::MTKParameters) = jac_iip(J, u, p...) + _jac = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(jac_oop, jac_iip) else _jac = nothing end - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false), cse) - NonlinearFunction{iip}(f, + if length(dvs) == length(equations(sys)) + resid_prototype = nothing + else + resid_prototype = calculate_resid_prototype(length(equations(sys)), u0, p) + end + + NonlinearFunction{iip}(f; sys = sys, jac = _jac === nothing ? nothing : _jac, - resid_prototype = length(dvs) == length(equations(sys)) ? nothing : - zeros(length(equations(sys))), + resid_prototype = resid_prototype, jac_prototype = sparse ? similar(calculate_jacobian(sys, sparse = sparse), Float64) : nothing, - observed = observedfun) + observed = observedfun, initialization_data) +end + +""" +$(TYPEDSIGNATURES) + +Create an `IntervalNonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments +`dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, +respectively. +""" +function SciMLBase.IntervalNonlinearFunction( + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys), u0 = nothing; + p = nothing, eval_expression = false, eval_module = @__MODULE__, + initialization_data = nothing, kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearFunction`") + end + if !isone(length(dvs)) || !isone(length(equations(sys))) + error("`IntervalNonlinearFunction` only supports systems with a single equation and a single unknown.") + end + + f_gen = generate_function( + sys, dvs, ps; expression = Val{true}, scalar = true, kwargs...) + f = eval_or_rgf(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f, nothing) + + observedfun = ObservedFunctionCache( + sys; eval_expression, eval_module, checkbounds = get(kwargs, :checkbounds, false)) + + IntervalNonlinearFunction{false}( + f; observed = observedfun, sys = sys, initialization_data) end """ @@ -348,14 +451,14 @@ SciMLBase.NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), kwargs...) where {iip} ``` -Create a Julia expression for an `ODEFunction` from the [`ODESystem`](@ref). +Create a Julia expression for a `NonlinearFunction` from the [`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the dependent variable and parameter vectors, respectively. """ struct NonlinearFunctionExpr{iip} end function NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), - ps = parameters(sys), u0 = nothing; + ps = parameters(sys), u0 = nothing; p = nothing, version = nothing, tgrad = false, jac = false, linenumbers = false, @@ -364,20 +467,31 @@ function NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearFunctionExpr`") end - idx = iip ? 2 : 1 - f = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...)[idx] + f_oop, f_iip = generate_function(sys, dvs, ps; expression = Val{true}, kwargs...) + f = :($(GeneratedFunctionWrapper{(2, 2, is_split(sys))})($f_oop, $f_iip)) if jac - _jac = generate_jacobian(sys, dvs, ps; + jac_oop, jac_iip = generate_jacobian(sys, dvs, ps; sparse = sparse, simplify = simplify, - expression = Val{true}, kwargs...)[idx] + expression = Val{true}, kwargs...) + _jac = :($(GeneratedFunctionWrapper{(2, 2, is_split(sys))})($jac_oop, $jac_iip)) else _jac = :nothing end jp_expr = sparse ? :(similar($(get_jac(sys)[]), Float64)) : :nothing - resid_expr = length(dvs) == length(equations(sys)) ? :nothing : - :(zeros($(length(equations(sys))))) + if length(dvs) == length(equations(sys)) + resid_expr = :nothing + else + u0ElType = u0 === nothing ? Float64 : eltype(u0) + if SciMLStructures.isscimlstructure(p) + u0ElType = promote_type( + eltype(SciMLStructures.canonicalize(SciMLStructures.Tunable(), p)[1]), + u0ElType) + end + + resid_expr = :(zeros($u0ElType, $(length(equations(sys))))) + end ex = quote f = $f jac = $_jac @@ -389,34 +503,31 @@ function NonlinearFunctionExpr{iip}(sys::NonlinearSystem, dvs = unknowns(sys), !linenumbers ? Base.remove_linenums!(ex) : ex end -function process_NonlinearProblem(constructor, sys::NonlinearSystem, u0map, parammap; - version = nothing, - jac = false, - checkbounds = false, sparse = false, - simplify = false, - linenumbers = true, parallel = SerialForm(), - eval_expression = false, - eval_module = @__MODULE__, - use_union = false, - tofloat = !use_union, - kwargs...) - eqs = equations(sys) - dvs = unknowns(sys) - ps = parameters(sys) - if has_index_cache(sys) && get_index_cache(sys) !== nothing - u0, defs = get_u0(sys, u0map, parammap) - check_eqs_u0(eqs, dvs, u0; kwargs...) - p = MTKParameters(sys, parammap, u0map) - else - u0, p, defs = get_u0_p(sys, u0map, parammap; tofloat, use_union) - check_eqs_u0(eqs, dvs, u0; kwargs...) +""" +$(TYPEDSIGNATURES) + +Create a Julia expression for an `IntervalNonlinearFunction` from the +[`NonlinearSystem`](@ref). The arguments `dvs` and `ps` are used to set the order of the +dependent variable and parameter vectors, respectively. +""" +function IntervalNonlinearFunctionExpr( + sys::NonlinearSystem, dvs = unknowns(sys), ps = parameters(sys), + u0 = nothing; p = nothing, linenumbers = false, kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearFunctionExpr`") + end + if !isone(length(dvs)) || !isone(length(equations(sys))) + error("`IntervalNonlinearFunctionExpr` only supports systems with a single equation and a single unknown.") end - f = constructor(sys, dvs, ps, u0; jac = jac, checkbounds = checkbounds, - linenumbers = linenumbers, parallel = parallel, simplify = simplify, - sparse = sparse, eval_expression = eval_expression, eval_module = eval_module, - kwargs...) - return f, u0, p + f = generate_function(sys, dvs, ps; expression = Val{true}, scalar = true, kwargs...) + f = :($(GeneratedFunctionWrapper{2, 2, is_split(sys)})($f, nothing)) + + ex = quote + f = $f + NonlinearFunction{false}(f) + end + !linenumbers ? Base.remove_linenums!(ex) : ex end """ @@ -442,10 +553,15 @@ function DiffEqBase.NonlinearProblem{iip}(sys::NonlinearSystem, u0map, if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblem`") end - f, u0, p = process_NonlinearProblem(NonlinearFunction{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(NonlinearFunction{iip}, sys, u0map, parammap; check_length, kwargs...) pt = something(get_metadata(sys), StandardNonlinearProblem()) - NonlinearProblem{iip}(f, u0, p, pt; filter_kwargs(kwargs)...) + # Call `remake` so it runs initialization if it is trivial + return remake(NonlinearProblem{iip}(f, u0, p, pt; filter_kwargs(kwargs)...)) +end + +function DiffEqBase.NonlinearProblem(sys::AbstractODESystem, args...; kwargs...) + NonlinearProblem(NonlinearSystem(sys), args...; kwargs...) end """ @@ -471,10 +587,278 @@ function DiffEqBase.NonlinearLeastSquaresProblem{iip}(sys::NonlinearSystem, u0ma if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearLeastSquaresProblem`") end - f, u0, p = process_NonlinearProblem(NonlinearFunction{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(NonlinearFunction{iip}, sys, u0map, parammap; check_length, kwargs...) pt = something(get_metadata(sys), StandardNonlinearProblem()) - NonlinearLeastSquaresProblem{iip}(f, u0, p; filter_kwargs(kwargs)...) + # Call `remake` so it runs initialization if it is trivial + return remake(NonlinearLeastSquaresProblem{iip}(f, u0, p; filter_kwargs(kwargs)...)) +end + +const TypeT = Union{DataType, UnionAll} + +struct CacheWriter{F} + fn::F +end + +function (cw::CacheWriter)(p, sols) + cw.fn(p.caches, sols, p) +end + +function CacheWriter(sys::AbstractSystem, buffer_types::Vector{TypeT}, + exprs::Dict{TypeT, Vector{Any}}, solsyms, obseqs::Vector{Equation}; + eval_expression = false, eval_module = @__MODULE__, cse = true) + ps = parameters(sys; initial_parameters = true) + rps = reorder_parameters(sys, ps) + obs_assigns = [eq.lhs ← eq.rhs for eq in obseqs] + body = map(eachindex(buffer_types), buffer_types) do i, T + Symbol(:tmp, i) ← SetArray(true, :(out[$i]), get(exprs, T, [])) + end + + function argument_name(i::Int) + if i <= length(solsyms) + return :($(generated_argument_name(1))[$i]) + end + return generated_argument_name(i - length(solsyms)) + end + array_assignments = array_variable_assignments(solsyms...; argument_name) + fn = build_function_wrapper( + sys, nothing, :out, + DestructuredArgs(DestructuredArgs.(solsyms), generated_argument_name(1)), + rps...; p_start = 3, p_end = length(rps) + 2, + expression = Val{true}, add_observed = false, cse, + extra_assignments = [array_assignments; obs_assigns; body]) + fn = eval_or_rgf(fn; eval_expression, eval_module) + fn = GeneratedFunctionWrapper{(3, 3, is_split(sys))}(fn, nothing) + return CacheWriter(fn) +end + +struct SCCNonlinearFunction{iip} end + +function SCCNonlinearFunction{iip}( + sys::NonlinearSystem, _eqs, _dvs, _obs, cachesyms; eval_expression = false, + eval_module = @__MODULE__, cse = true, kwargs...) where {iip} + ps = parameters(sys; initial_parameters = true) + rps = reorder_parameters(sys, ps) + + obs_assignments = [eq.lhs ← eq.rhs for eq in _obs] + + rhss = [eq.rhs - eq.lhs for eq in _eqs] + f_gen = build_function_wrapper(sys, + rhss, _dvs, rps..., cachesyms...; p_start = 2, + p_end = length(rps) + length(cachesyms) + 1, add_observed = false, + extra_assignments = obs_assignments, expression = Val{true}, cse) + f_oop, f_iip = eval_or_rgf.(f_gen; eval_expression, eval_module) + f = GeneratedFunctionWrapper{(2, 2, is_split(sys))}(f_oop, f_iip) + + subsys = NonlinearSystem(_eqs, _dvs, ps; observed = _obs, + parameter_dependencies = parameter_dependencies(sys), name = nameof(sys)) + if get_index_cache(sys) !== nothing + @set! subsys.index_cache = subset_unknowns_observed( + get_index_cache(sys), sys, _dvs, getproperty.(_obs, (:lhs,))) + @set! subsys.complete = true + end + + return NonlinearFunction{iip}(f; sys = subsys) +end + +function SciMLBase.SCCNonlinearProblem(sys::NonlinearSystem, args...; kwargs...) + SCCNonlinearProblem{true}(sys, args...; kwargs...) +end + +function SciMLBase.SCCNonlinearProblem{iip}(sys::NonlinearSystem, u0map, + parammap = SciMLBase.NullParameters(); eval_expression = false, eval_module = @__MODULE__, + cse = true, kwargs...) where {iip} + if !iscomplete(sys) || get_tearing_state(sys) === nothing + error("A simplified `NonlinearSystem` is required. Call `structural_simplify` on the system before creating an `SCCNonlinearProblem`.") + end + + if !is_split(sys) + error("The system has been simplified with `split = false`. `SCCNonlinearProblem` is not compatible with this system. Pass `split = true` to `structural_simplify` to use `SCCNonlinearProblem`.") + end + + ts = get_tearing_state(sys) + var_eq_matching, var_sccs = StructuralTransformations.algebraic_variables_scc(ts) + + if length(var_sccs) == 1 + return NonlinearProblem{iip}( + sys, u0map, parammap; eval_expression, eval_module, kwargs...) + end + + condensed_graph = MatchedCondensationGraph( + DiCMOBiGraph{true}(complete(ts.structure.graph), + complete(var_eq_matching)), + var_sccs) + toporder = topological_sort_by_dfs(condensed_graph) + var_sccs = var_sccs[toporder] + eq_sccs = map(Base.Fix1(getindex, var_eq_matching), var_sccs) + + dvs = unknowns(sys) + ps = parameters(sys) + eqs = equations(sys) + obs = observed(sys) + + _, u0, p = process_SciMLProblem( + EmptySciMLFunction, sys, u0map, parammap; eval_expression, eval_module, kwargs...) + + explicitfuns = [] + nlfuns = [] + prevobsidxs = BlockArray(undef_blocks, Vector{Int}, Int[]) + # Cache buffer types and corresponding sizes. Stored as a pair of arrays instead of a + # dict to maintain a consistent order of buffers across SCCs + cachetypes = TypeT[] + cachesizes = Int[] + # explicitfun! related information for each SCC + # We need to compute buffer sizes before doing any codegen + scc_cachevars = Dict{TypeT, Vector{Any}}[] + scc_cacheexprs = Dict{TypeT, Vector{Any}}[] + scc_eqs = Vector{Equation}[] + scc_obs = Vector{Equation}[] + # variables solved in previous SCCs + available_vars = Set() + for (i, (escc, vscc)) in enumerate(zip(eq_sccs, var_sccs)) + # subset unknowns and equations + _dvs = dvs[vscc] + _eqs = eqs[escc] + # get observed equations required by this SCC + union!(available_vars, _dvs) + obsidxs = observed_equations_used_by(sys, _eqs; available_vars) + # the ones used by previous SCCs can be precomputed into the cache + setdiff!(obsidxs, prevobsidxs) + _obs = obs[obsidxs] + union!(available_vars, getproperty.(_obs, (:lhs,))) + + # get all subexpressions in the RHS which we can precompute in the cache + # precomputed subexpressions should not contain `banned_vars` + banned_vars = Set{Any}(vcat(_dvs, getproperty.(_obs, (:lhs,)))) + state = Dict() + for i in eachindex(_obs) + _obs[i] = _obs[i].lhs ~ subexpressions_not_involving_vars!( + _obs[i].rhs, banned_vars, state) + end + for i in eachindex(_eqs) + _eqs[i] = _eqs[i].lhs ~ subexpressions_not_involving_vars!( + _eqs[i].rhs, banned_vars, state) + end + + # map from symtype to cached variables and their expressions + cachevars = Dict{Union{DataType, UnionAll}, Vector{Any}}() + cacheexprs = Dict{Union{DataType, UnionAll}, Vector{Any}}() + # observed of previous SCCs are in the cache + # NOTE: When we get proper CSE, we can substitute these + # and then use `subexpressions_not_involving_vars!` + for i in prevobsidxs + T = symtype(obs[i].lhs) + buf = get!(() -> Any[], cachevars, T) + push!(buf, obs[i].lhs) + + buf = get!(() -> Any[], cacheexprs, T) + push!(buf, obs[i].lhs) + end + + for (k, v) in state + k = unwrap(k) + v = unwrap(v) + T = symtype(k) + buf = get!(() -> Any[], cachevars, T) + push!(buf, v) + buf = get!(() -> Any[], cacheexprs, T) + push!(buf, k) + end + + # update the sizes of cache buffers + for (T, buf) in cachevars + idx = findfirst(isequal(T), cachetypes) + if idx === nothing + push!(cachetypes, T) + push!(cachesizes, 0) + idx = lastindex(cachetypes) + end + cachesizes[idx] = max(cachesizes[idx], length(buf)) + end + + push!(scc_cachevars, cachevars) + push!(scc_cacheexprs, cacheexprs) + push!(scc_eqs, _eqs) + push!(scc_obs, _obs) + blockpush!(prevobsidxs, obsidxs) + end + + for (i, (escc, vscc)) in enumerate(zip(eq_sccs, var_sccs)) + _dvs = dvs[vscc] + _eqs = scc_eqs[i] + _prevobsidxs = reduce(vcat, blocks(prevobsidxs)[1:(i - 1)]; init = Int[]) + _obs = scc_obs[i] + cachevars = scc_cachevars[i] + cacheexprs = scc_cacheexprs[i] + available_vars = [dvs[reduce(vcat, var_sccs[1:(i - 1)]; init = Int[])]; + getproperty.( + reduce(vcat, scc_obs[1:(i - 1)]; init = []), (:lhs,))] + _prevobsidxs = vcat(_prevobsidxs, + observed_equations_used_by( + sys, reduce(vcat, values(cacheexprs); init = []); available_vars)) + if isempty(cachevars) + push!(explicitfuns, Returns(nothing)) + else + solsyms = getindex.((dvs,), view(var_sccs, 1:(i - 1))) + push!(explicitfuns, + CacheWriter(sys, cachetypes, cacheexprs, solsyms, obs[_prevobsidxs]; + eval_expression, eval_module, cse)) + end + + cachebufsyms = Tuple(map(cachetypes) do T + get(cachevars, T, []) + end) + f = SCCNonlinearFunction{iip}( + sys, _eqs, _dvs, _obs, cachebufsyms; eval_expression, eval_module, cse, kwargs...) + push!(nlfuns, f) + end + + if !isempty(cachetypes) + templates = map(cachetypes, cachesizes) do T, n + # Real refers to `eltype(u0)` + if T == Real + T = eltype(u0) + elseif T <: Array && eltype(T) == Real + T = Array{eltype(u0), ndims(T)} + end + BufferTemplate(T, n) + end + p = rebuild_with_caches(p, templates...) + end + + subprobs = [] + for (f, vscc) in zip(nlfuns, var_sccs) + prob = NonlinearProblem(f, u0[vscc], p) + push!(subprobs, prob) + end + + new_dvs = dvs[reduce(vcat, var_sccs)] + new_eqs = eqs[reduce(vcat, eq_sccs)] + @set! sys.unknowns = new_dvs + @set! sys.eqs = new_eqs + @set! sys.index_cache = subset_unknowns_observed( + get_index_cache(sys), sys, new_dvs, getproperty.(obs, (:lhs,))) + return SCCNonlinearProblem(subprobs, explicitfuns, p, true; sys) +end + +""" +$(TYPEDSIGNATURES) + +Generate an `IntervalNonlinearProblem` from a `NonlinearSystem` and allow for automatically +symbolically calculating numerical enhancements. +""" +function DiffEqBase.IntervalNonlinearProblem(sys::NonlinearSystem, uspan::NTuple{2}, + parammap = SciMLBase.NullParameters(); kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearProblem`") + end + if !isone(length(unknowns(sys))) || !isone(length(equations(sys))) + error("`IntervalNonlinearProblem` only supports with a single equation and a single unknown.") + end + f, u0, p = process_SciMLProblem( + IntervalNonlinearFunction, sys, unknowns(sys) .=> uspan[1], parammap; kwargs...) + + return IntervalNonlinearProblem(f, uspan, p; filter_kwargs(kwargs)...) end """ @@ -504,7 +888,7 @@ function NonlinearProblemExpr{iip}(sys::NonlinearSystem, u0map, if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblemExpr`") end - f, u0, p = process_NonlinearProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; check_length, kwargs...) linenumbers = get(kwargs, :linenumbers, true) @@ -544,7 +928,7 @@ function NonlinearLeastSquaresProblemExpr{iip}(sys::NonlinearSystem, u0map, if !iscomplete(sys) error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `NonlinearProblemExpr`") end - f, u0, p = process_NonlinearProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; + f, u0, p = process_SciMLProblem(NonlinearFunctionExpr{iip}, sys, u0map, parammap; check_length, kwargs...) linenumbers = get(kwargs, :linenumbers, true) @@ -557,6 +941,34 @@ function NonlinearLeastSquaresProblemExpr{iip}(sys::NonlinearSystem, u0map, !linenumbers ? Base.remove_linenums!(ex) : ex end +""" +$(TYPEDSIGNATURES) + +Generates a Julia expression for an IntervalNonlinearProblem from a +NonlinearSystem and allows for automatically symbolically calculating +numerical enhancements. +""" +function IntervalNonlinearProblemExpr(sys::NonlinearSystem, uspan::NTuple{2}, + parammap = SciMLBase.NullParameters(); kwargs...) + if !iscomplete(sys) + error("A completed `NonlinearSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `IntervalNonlinearProblemExpr`") + end + if !isone(length(unknowns(sys))) || !isone(length(equations(sys))) + error("`IntervalNonlinearProblemExpr` only supports with a single equation and a single unknown.") + end + f, u0, p = process_SciMLProblem( + IntervalNonlinearFunctionExpr, sys, unknowns(sys) .=> uspan[1], parammap; kwargs...) + linenumbers = get(kwargs, :linenumbers, true) + + ex = quote + f = $f + uspan = $uspan + p = $p + IntervalNonlinearProblem(f, uspan, p; $(filter_kwargs(kwargs)...)) + end + !linenumbers ? Base.remove_linenums!(ex) : ex +end + function flatten(sys::NonlinearSystem, noeqs = false) systems = get_systems(sys) if isempty(systems) @@ -567,7 +979,11 @@ function flatten(sys::NonlinearSystem, noeqs = false) parameters(sys), observed = observed(sys), defaults = defaults(sys), + guesses = guesses(sys), + initialization_eqs = initialization_equations(sys), name = nameof(sys), + description = description(sys), + metadata = get_metadata(sys), checks = false) end end diff --git a/src/systems/optimization/constraints_system.jl b/src/systems/optimization/constraints_system.jl index f9176df3a2..0f69e6d0b9 100644 --- a/src/systems/optimization/constraints_system.jl +++ b/src/systems/optimization/constraints_system.jl @@ -45,6 +45,10 @@ struct ConstraintsSystem <: AbstractTimeIndependentSystem """ name::Symbol """ + A description of the system. + """ + description::String + """ The internal systems. These are required to have unique names. """ systems::Vector{ConstraintsSystem} @@ -70,7 +74,11 @@ struct ConstraintsSystem <: AbstractTimeIndependentSystem """ substitutions::Any """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -79,19 +87,21 @@ struct ConstraintsSystem <: AbstractTimeIndependentSystem index_cache::Union{Nothing, IndexCache} function ConstraintsSystem(tag, constraints, unknowns, ps, var_to_name, observed, jac, - name, + name, description, systems, defaults, connector_type, metadata = nothing, - tearing_state = nothing, substitutions = nothing, + tearing_state = nothing, substitutions = nothing, namespacing = true, complete = false, index_cache = nothing; checks::Union{Bool, Int} = true) if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(unknowns, ps) check_units(u, constraints) + check_subsystems(systems) end - new(tag, constraints, unknowns, ps, var_to_name, observed, jac, name, systems, - defaults, - connector_type, metadata, tearing_state, substitutions, complete, index_cache) + new(tag, constraints, unknowns, ps, var_to_name, + observed, jac, name, description, systems, + defaults, connector_type, metadata, tearing_state, substitutions, + namespacing, complete, index_cache) end end @@ -100,6 +110,7 @@ equations(sys::ConstraintsSystem) = constraints(sys) # needed for Base.show function ConstraintsSystem(constraints, unknowns, ps; observed = [], name = nothing, + description = "", default_u0 = Dict(), default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), @@ -117,7 +128,7 @@ function ConstraintsSystem(constraints, unknowns, ps; name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) - cstr = value.(Symbolics.canonical_form.(scalarize(constraints))) + cstr = value.(Symbolics.canonical_form.(vcat(scalarize(constraints)...))) unknowns′ = value.(scalarize(unknowns)) ps′ = value.(ps) @@ -137,12 +148,12 @@ function ConstraintsSystem(constraints, unknowns, ps; for (k, v) in pairs(defaults) if value(v) !== nothing) var_to_name = Dict() - process_variables!(var_to_name, defaults, unknowns′) - process_variables!(var_to_name, defaults, ps′) + process_variables!(var_to_name, defaults, Dict(), unknowns′) + process_variables!(var_to_name, defaults, Dict(), ps′) isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) ConstraintsSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), - cstr, unknowns, ps, var_to_name, observed, jac, name, systems, + cstr, unknowns, ps, var_to_name, observed, jac, name, description, systems, defaults, connector_type, metadata, checks = checks) end @@ -165,13 +176,12 @@ function calculate_jacobian(sys::ConstraintsSystem; sparse = false, simplify = f end function generate_jacobian( - sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters(sys); - sparse = false, simplify = false, wrap_code = identity, kwargs...) + sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) jac = calculate_jacobian(sys, sparse = sparse, simplify = simplify) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, jac; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function(jac, vs, p...; wrap_code, kwargs...) + return build_function_wrapper(sys, jac, vs, p...; kwargs...) end function calculate_hessian(sys::ConstraintsSystem; sparse = false, simplify = false) @@ -186,26 +196,20 @@ function calculate_hessian(sys::ConstraintsSystem; sparse = false, simplify = fa end function generate_hessian( - sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters(sys); - sparse = false, simplify = false, wrap_code = identity, kwargs...) + sys::ConstraintsSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, simplify = false, kwargs...) hess = calculate_hessian(sys, sparse = sparse, simplify = simplify) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, hess; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function(hess, vs, p...; wrap_code, kwargs...) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) end function generate_function(sys::ConstraintsSystem, dvs = unknowns(sys), - ps = parameters(sys); - wrap_code = identity, + ps = parameters(sys; initial_parameters = true); kwargs...) lhss = generate_canonical_form_lhss(sys) - pre, sol_states = get_substitutions_and_solved_unknowns(sys) p = reorder_parameters(sys, value.(ps)) - wrap_code = wrap_code .∘ wrap_array_vars(sys, lhss; dvs, ps) .∘ - wrap_parameter_dependencies(sys, false) - func = build_function(lhss, value.(dvs), p...; postprocess_fbody = pre, - states = sol_states, wrap_code, kwargs...) + func = build_function_wrapper(sys, lhss, value.(dvs), p...; kwargs...) cstr = constraints(sys) lcons = fill(-Inf, length(cstr)) @@ -247,3 +251,5 @@ function get_cmap(sys::ConstraintsSystem, exprs = nothing) cmap = map(x -> x ~ getdefault(x), cs) return cmap, cs end + +supports_initialization(::ConstraintsSystem) = false diff --git a/src/systems/optimization/modelingtoolkitize.jl b/src/systems/optimization/modelingtoolkitize.jl index 1ceea795c5..27dccc251a 100644 --- a/src/systems/optimization/modelingtoolkitize.jl +++ b/src/systems/optimization/modelingtoolkitize.jl @@ -33,6 +33,15 @@ function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem; end _vars = reshape(_vars, size(prob.u0)) vars = ArrayInterface.restructure(prob.u0, _vars) + if prob.ub !== nothing # lb is also !== nothing + vars = map(vars, prob.lb, prob.ub) do sym, lb, ub + if iszero(lb) && iszero(ub) || isinf(lb) && lb < 0 && isinf(ub) && ub > 0 + sym + else + Symbolics.setmetadata(sym, VariableBounds, (lb, ub)) + end + end + end params = if has_p if p_names === nothing && SciMLBase.has_sys(prob.f) p_names = Dict(parameter_index(prob.f.sys, sym) => sym @@ -44,7 +53,7 @@ function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem; idx = parameter_index(prob, sym) old_to_new[unwrap(sym)] = unwrap(p_names[idx]) end - order = reorder_parameters(prob.f.sys, parameters(prob.f.sys)) + order = reorder_parameters(prob.f.sys) for arr in order for i in eachindex(arr) arr[i] = old_to_new[arr[i]] @@ -70,7 +79,11 @@ function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem; if DiffEqBase.isinplace(prob) && !isnothing(prob.f.cons) lhs = Array{Num}(undef, num_cons) - prob.f.cons(lhs, vars, params) + if p isa MTKParameters + prob.f.cons(lhs, vars, params...) + else + prob.f.cons(lhs, vars, params) + end cons = Union{Equation, Inequality}[] if !isnothing(prob.lcons) @@ -99,7 +112,8 @@ function modelingtoolkitize(prob::DiffEqBase.OptimizationProblem; or pass the lower and upper bounds for inequality constraints.")) end elseif !isnothing(prob.f.cons) - cons = prob.f.cons(vars, params) + cons = p isa MTKParameters ? prob.f.cons(vars, params...) : + prob.f.cons(vars, params) else cons = [] end diff --git a/src/systems/optimization/optimizationsystem.jl b/src/systems/optimization/optimizationsystem.jl index 22488e8bad..be4567aee5 100644 --- a/src/systems/optimization/optimizationsystem.jl +++ b/src/systems/optimization/optimizationsystem.jl @@ -37,6 +37,8 @@ struct OptimizationSystem <: AbstractOptimizationSystem constraints::Vector{Union{Equation, Inequality}} """The name of the system.""" name::Symbol + """A description of the system.""" + description::String """The internal systems. These are required to have unique names.""" systems::Vector{OptimizationSystem} """ @@ -53,7 +55,11 @@ struct OptimizationSystem <: AbstractOptimizationSystem """ gui_metadata::Union{Nothing, GUIMetadata} """ - If a model `sys` is complete, then `sys.x` no longer performs namespacing. + If false, then `sys.x` no longer performs namespacing. + """ + namespacing::Bool + """ + If true, denotes the model will not be modified any further. """ complete::Bool """ @@ -67,19 +73,20 @@ struct OptimizationSystem <: AbstractOptimizationSystem isscheduled::Bool function OptimizationSystem(tag, op, unknowns, ps, var_to_name, observed, - constraints, name, systems, defaults, metadata = nothing, - gui_metadata = nothing, complete = false, index_cache = nothing, parent = nothing, - isscheduled = false; + constraints, name, description, systems, defaults, metadata = nothing, + gui_metadata = nothing, namespacing = true, complete = false, + index_cache = nothing, parent = nothing, isscheduled = false; checks::Union{Bool, Int} = true) if checks == true || (checks & CheckUnits) > 0 u = __get_unit_type(unknowns, ps) unwrap(op) isa Symbolic && check_units(u, op) check_units(u, observed) check_units(u, constraints) + check_subsystems(systems) end new(tag, op, unknowns, ps, var_to_name, observed, - constraints, name, systems, defaults, metadata, gui_metadata, complete, - index_cache, parent, isscheduled) + constraints, name, description, systems, defaults, metadata, gui_metadata, + namespacing, complete, index_cache, parent, isscheduled) end end @@ -92,17 +99,29 @@ function OptimizationSystem(op, unknowns, ps; default_p = Dict(), defaults = _merge(Dict(default_u0), Dict(default_p)), name = nothing, + description = "", systems = OptimizationSystem[], checks = true, metadata = nothing, gui_metadata = nothing) name === nothing && throw(ArgumentError("The `name` keyword must be provided. Please consider using the `@named` macro")) - constraints = value.(scalarize(constraints)) - unknowns′ = value.(scalarize(unknowns)) + constraints = value.(reduce(vcat, scalarize(constraints); init = [])) + unknowns′ = value.(reduce(vcat, scalarize(unknowns); init = [])) ps′ = value.(ps) op′ = value(scalarize(op)) + irreducible_subs = Dict() + for i in eachindex(unknowns′) + var = unknowns′[i] + if hasbounds(var) + irreducible_subs[var] = irrvar = setirreducible(var, true) + unknowns′[i] = irrvar + end + end + op′ = fast_substitute(op′, irreducible_subs) + constraints = fast_substitute.(constraints, (irreducible_subs,)) + if !(isempty(default_u0) && isempty(default_p)) Base.depwarn( "`default_u0` and `default_p` are deprecated. Use `defaults` instead.", @@ -113,37 +132,77 @@ function OptimizationSystem(op, unknowns, ps; throw(ArgumentError("System names must be unique.")) end defaults = todict(defaults) - defaults = Dict(value(k) => value(v) + defaults = Dict(fast_substitute(value(k), irreducible_subs) => fast_substitute( + value(v), irreducible_subs) for (k, v) in pairs(defaults) if value(v) !== nothing) var_to_name = Dict() - process_variables!(var_to_name, defaults, unknowns′) - process_variables!(var_to_name, defaults, ps′) + process_variables!(var_to_name, defaults, Dict(), unknowns′) + process_variables!(var_to_name, defaults, Dict(), ps′) isempty(observed) || collect_var_to_name!(var_to_name, (eq.lhs for eq in observed)) OptimizationSystem(Threads.atomic_add!(SYSTEM_COUNT, UInt(1)), op′, unknowns′, ps′, var_to_name, observed, constraints, - name, systems, defaults, metadata, gui_metadata; + name, description, systems, defaults, metadata, gui_metadata; checks = checks) end +function OptimizationSystem(objective; constraints = [], kwargs...) + allunknowns = OrderedSet() + ps = OrderedSet() + collect_vars!(allunknowns, ps, objective, nothing) + for cons in constraints + collect_vars!(allunknowns, ps, cons, nothing) + end + for ssys in get(kwargs, :systems, OptimizationSystem[]) + collect_scoped_vars!(allunknowns, ps, ssys, nothing) + end + new_ps = OrderedSet() + for p in ps + if iscall(p) && operation(p) === getindex + par = arguments(p)[begin] + if Symbolics.shape(Symbolics.unwrap(par)) !== Symbolics.Unknown() && + all(par[i] in ps for i in eachindex(par)) + push!(new_ps, par) + else + push!(new_ps, p) + end + else + push!(new_ps, p) + end + end + return OptimizationSystem( + objective, collect(allunknowns), collect(new_ps); constraints, kwargs...) +end + +function flatten(sys::OptimizationSystem) + systems = get_systems(sys) + isempty(systems) && return sys + + return OptimizationSystem( + objective(sys), + unknowns(sys), + parameters(sys); + observed = observed(sys), + constraints = constraints(sys), + defaults = defaults(sys), + name = nameof(sys), + metadata = get_metadata(sys), + checks = false + ) +end + function calculate_gradient(sys::OptimizationSystem) expand_derivatives.(gradient(objective(sys), unknowns(sys))) end function generate_gradient(sys::OptimizationSystem, vs = unknowns(sys), - ps = parameters(sys); - wrap_code = identity, - kwargs...) + ps = parameters(sys; initial_parameters = true); kwargs...) grad = calculate_gradient(sys) - pre = get_preprocess_constants(grad) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, grad; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, !(grad isa AbstractArray)) - return build_function(grad, vs, p...; postprocess_fbody = pre, wrap_code, - kwargs...) + return build_function_wrapper(sys, grad, vs, p...; kwargs...) end function calculate_hessian(sys::OptimizationSystem) @@ -151,35 +210,24 @@ function calculate_hessian(sys::OptimizationSystem) end function generate_hessian( - sys::OptimizationSystem, vs = unknowns(sys), ps = parameters(sys); - sparse = false, wrap_code = identity, kwargs...) + sys::OptimizationSystem, vs = unknowns(sys), ps = parameters( + sys; initial_parameters = true); + sparse = false, kwargs...) if sparse hess = sparsehessian(objective(sys), unknowns(sys)) else hess = calculate_hessian(sys) end - pre = get_preprocess_constants(hess) p = reorder_parameters(sys, ps) - wrap_code = wrap_code .∘ wrap_array_vars(sys, hess; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, false) - return build_function(hess, vs, p...; postprocess_fbody = pre, wrap_code, - kwargs...) + return build_function_wrapper(sys, hess, vs, p...; kwargs...) end function generate_function(sys::OptimizationSystem, vs = unknowns(sys), - ps = parameters(sys); - wrap_code = identity, - kwargs...) - eqs = subs_constants(objective(sys)) - p = if has_index_cache(sys) - reorder_parameters(get_index_cache(sys), ps) - else - (ps,) - end - wrap_code = wrap_code .∘ wrap_array_vars(sys, eqs; dvs = vs, ps) .∘ - wrap_parameter_dependencies(sys, !(eqs isa AbstractArray)) - return build_function(eqs, vs, p...; wrap_code, + ps = parameters(sys; initial_parameters = true); kwargs...) + eqs = objective(sys) + p = reorder_parameters(sys, ps) + return build_function_wrapper(sys, eqs, vs, p...; kwargs...) end function namespace_objective(sys::AbstractSystem) @@ -252,7 +300,7 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, cons_sparse = false, checkbounds = false, linenumbers = true, parallel = SerialForm(), eval_expression = false, eval_module = @__MODULE__, - use_union = false, + checks = true, cse = true, kwargs...) where {iip} if !iscomplete(sys) error("A completed `OptimizationSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `OptimizationProblem`") @@ -294,10 +342,10 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, elseif has_index_cache(sys) && get_index_cache(sys) !== nothing p = MTKParameters(sys, parammap, u0map) else - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false, use_union) + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false) end - lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false, use_union) - ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false, use_union) + lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false) + ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false) if !isnothing(lb) && all(lb .== -Inf) && !isnothing(ub) && all(ub .== Inf) lb = nothing @@ -306,8 +354,8 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, f = let _f = eval_or_rgf( generate_function( - sys, checkbounds = checkbounds, linenumbers = linenumbers, - expression = Val{true}); + sys; checkbounds = checkbounds, linenumbers = linenumbers, + expression = Val{true}, wrap_mtkparameters = false, cse); eval_expression, eval_module) __f(u, p) = _f(u, p) @@ -319,9 +367,10 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, if grad _grad = let (grad_oop, grad_iip) = eval_or_rgf.( generate_gradient( - sys, checkbounds = checkbounds, + sys; checkbounds = checkbounds, linenumbers = linenumbers, - parallel = parallel, expression = Val{true}); + parallel = parallel, expression = Val{true}, + wrap_mtkparameters = false, cse); eval_expression, eval_module) _grad(u, p) = grad_oop(u, p) @@ -337,10 +386,10 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, if hess _hess = let (hess_oop, hess_iip) = eval_or_rgf.( generate_hessian( - sys, checkbounds = checkbounds, + sys; checkbounds = checkbounds, linenumbers = linenumbers, sparse = sparse, parallel = parallel, - expression = Val{true}); + expression = Val{true}, wrap_mtkparameters = false, cse); eval_expression, eval_module) _hess(u, p) = hess_oop(u, p) @@ -359,22 +408,27 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, hess_prototype = nothing end - observedfun = ObservedFunctionCache(sys; eval_expression, eval_module) + observedfun = ObservedFunctionCache(sys; eval_expression, eval_module, checkbounds, cse) if length(cstr) > 0 - @named cons_sys = ConstraintsSystem(cstr, dvs, ps) + @named cons_sys = ConstraintsSystem(cstr, dvs, ps; checks) cons_sys = complete(cons_sys) - cons, lcons_, ucons_ = generate_function(cons_sys, checkbounds = checkbounds, + cons, lcons_, ucons_ = generate_function(cons_sys; checkbounds = checkbounds, linenumbers = linenumbers, - expression = Val{true}) - cons = eval_or_rgf.(cons; eval_expression, eval_module) + expression = Val{true}, wrap_mtkparameters = false, cse) + cons = let (cons_oop, cons_iip) = eval_or_rgf.(cons; eval_expression, eval_module) + _cons(u, p) = cons_oop(u, p) + _cons(resid, u, p) = cons_iip(resid, u, p) + _cons(u, p::MTKParameters) = cons_oop(u, p...) + _cons(resid, u, p::MTKParameters) = cons_iip(resid, u, p...) + end if cons_j _cons_j = let (cons_jac_oop, cons_jac_iip) = eval_or_rgf.( generate_jacobian(cons_sys; checkbounds = checkbounds, linenumbers = linenumbers, parallel = parallel, expression = Val{true}, - sparse = cons_sparse); + sparse = cons_sparse, wrap_mtkparameters = false, cse); eval_expression, eval_module) _cons_j(u, p) = cons_jac_oop(u, p) @@ -389,10 +443,10 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, if cons_h _cons_h = let (cons_hess_oop, cons_hess_iip) = eval_or_rgf.( generate_hessian( - cons_sys, checkbounds = checkbounds, + cons_sys; checkbounds = checkbounds, linenumbers = linenumbers, sparse = cons_sparse, parallel = parallel, - expression = Val{true}); + expression = Val{true}, wrap_mtkparameters = false, cse); eval_expression, eval_module) _cons_h(u, p) = cons_hess_oop(u, p) @@ -433,7 +487,7 @@ function DiffEqBase.OptimizationProblem{iip}(sys::OptimizationSystem, u0map, grad = _grad, hess = _hess, hess_prototype = hess_prototype, - cons = cons[2], + cons = cons, cons_j = _cons_j, cons_h = _cons_h, cons_jac_prototype = cons_jac_prototype, @@ -488,7 +542,6 @@ function OptimizationProblemExpr{iip}(sys::OptimizationSystem, u0map, checkbounds = false, linenumbers = false, parallel = SerialForm(), eval_expression = false, eval_module = @__MODULE__, - use_union = false, kwargs...) where {iip} if !iscomplete(sys) error("A completed `OptimizationSystem` is required. Call `complete` or `structural_simplify` on the system before creating a `OptimizationProblemExpr`") @@ -528,10 +581,10 @@ function OptimizationProblemExpr{iip}(sys::OptimizationSystem, u0map, if has_index_cache(sys) && get_index_cache(sys) !== nothing p = MTKParameters(sys, parammap, u0map) else - p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false, use_union) + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat = false) end - lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false, use_union) - ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false, use_union) + lb = varmap_to_vars(dvs .=> lb, dvs; defaults = defs, tofloat = false) + ub = varmap_to_vars(dvs .=> ub, dvs; defaults = defs, tofloat = false) if !isnothing(lb) && all(lb .== -Inf) && !isnothing(ub) && all(ub .== Inf) lb = nothing @@ -703,3 +756,5 @@ function structural_simplify(sys::OptimizationSystem; split = true, kwargs...) sys = complete(sys; split) return sys end + +supports_initialization(::OptimizationSystem) = false diff --git a/src/systems/parameter_buffer.jl b/src/systems/parameter_buffer.jl index 95ca13adad..4d33aa0a06 100644 --- a/src/systems/parameter_buffer.jl +++ b/src/systems/parameter_buffer.jl @@ -1,13 +1,15 @@ symconvert(::Type{Symbolics.Struct{T}}, x) where {T} = convert(T, x) -symconvert(::Type{T}, x) where {T} = convert(T, x) -symconvert(::Type{Real}, x::Integer) = convert(Float64, 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, D, C, N} +struct MTKParameters{T, I, D, C, N, H} tunable::T + initials::I discrete::D constant::C nonnumeric::N + caches::H end """ @@ -25,96 +27,51 @@ This requires that `complete` has been called on the system (usually via the default behavior). """ function MTKParameters( - sys::AbstractSystem, p, u0 = Dict(); tofloat = false, use_union = false, - t0 = nothing) + sys::AbstractSystem, p, u0 = Dict(); tofloat = false, + t0 = nothing, substitution_limit = 1000, floatT = nothing) ic = if has_index_cache(sys) && get_index_cache(sys) !== nothing get_index_cache(sys) else error("Cannot create MTKParameters if system does not have index_cache") end - all_ps = Set(unwrap.(parameters(sys))) - union!(all_ps, default_toterm.(unwrap.(parameters(sys)))) - if p isa Vector && !(eltype(p) <: Pair) && !isempty(p) - ps = parameters(sys) - length(p) == length(ps) || error("Invalid parameters") - p = ps .=> p - end - if p isa SciMLBase.NullParameters || isempty(p) - p = Dict() - end - p = todict(p) - defs = Dict(default_toterm(unwrap(k)) => v for (k, v) in defaults(sys)) - if eltype(u0) <: Pair - u0 = todict(u0) - elseif u0 isa AbstractArray && !isempty(u0) - u0 = Dict(unknowns(sys) .=> vec(u0)) - elseif u0 === nothing || isempty(u0) - u0 = Dict() - end - defs = merge(defs, u0) - defs = merge(Dict(eq.lhs => eq.rhs for eq in observed(sys)), defs) - bigdefs = merge(defs, p) + all_ps = Set(unwrap.(parameters(sys; initial_parameters = true))) + union!(all_ps, default_toterm.(unwrap.(parameters(sys; initial_parameters = true)))) + + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + u0 = to_varmap(u0, dvs) + symbols_to_symbolics!(sys, u0) + p = to_varmap(p, ps) + symbols_to_symbolics!(sys, p) + defs = add_toterms(recursive_unwrap(defaults(sys))) + cmap, cs = get_cmap(sys) + + is_time_dependent(sys) && add_observed!(sys, u0) + add_parameter_dependencies!(sys, p) + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0, p, defs, cmap, dvs, ps) + if t0 !== nothing - bigdefs[get_iv(sys)] = t0 - end - p = Dict() - missing_params = Set() - pdeps = has_parameter_dependencies(sys) ? parameter_dependencies(sys) : nothing - - for sym in all_ps - ttsym = default_toterm(sym) - isarr = iscall(sym) && operation(sym) === getindex - arrparent = isarr ? arguments(sym)[1] : nothing - ttarrparent = isarr ? default_toterm(arrparent) : nothing - pname = hasname(sym) ? getname(sym) : nothing - ttpname = hasname(ttsym) ? getname(ttsym) : nothing - p[sym] = p[ttsym] = if haskey(bigdefs, sym) - bigdefs[sym] - elseif haskey(bigdefs, ttsym) - bigdefs[ttsym] - elseif haskey(bigdefs, pname) - isarr ? bigdefs[pname][arguments(sym)[2:end]...] : bigdefs[pname] - elseif haskey(bigdefs, ttpname) - isarr ? bigdefs[ttpname][arguments(sym)[2:end]...] : bigdefs[pname] - elseif isarr && haskey(bigdefs, arrparent) - bigdefs[arrparent][arguments(sym)[2:end]...] - elseif isarr && haskey(bigdefs, ttarrparent) - bigdefs[ttarrparent][arguments(sym)[2:end]...] - end - if get(p, sym, nothing) === nothing - push!(missing_params, sym) - continue - end - # We may encounter the `ttsym` version first, add it to `missing_params` - # then encounter the "normal" version of a parameter or vice versa - # Remove the old one in `missing_params` just in case - delete!(missing_params, sym) - delete!(missing_params, ttsym) - end - - if pdeps !== nothing - for eq in pdeps - sym = eq.lhs - expr = eq.rhs - sym = unwrap(sym) - ttsym = default_toterm(sym) - delete!(missing_params, sym) - delete!(missing_params, ttsym) - p[sym] = p[ttsym] = expr - end + op[get_iv(sys)] = t0 end - isempty(missing_params) || throw(MissingParametersError(collect(missing_params))) - p = Dict(unwrap(k) => (bigdefs[unwrap(k)] = fixpoint_sub(v, bigdefs)) for (k, v) in p) - for (sym, _) in p - if iscall(sym) && operation(sym) === getindex && - first(arguments(sym)) in all_ps - error("Scalarized parameter values ($sym) are not supported. Instead of `[p[1] => 1.0, p[2] => 2.0]` use `[p => [1.0, 2.0]]`") - 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)), @@ -129,6 +86,9 @@ function MTKParameters( 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 @@ -152,6 +112,12 @@ function MTKParameters( 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) @@ -171,18 +137,29 @@ function MTKParameters( if isempty(tunable_buffer) tunable_buffer = SizedVector{0, Float64}() end + initials_buffer = narrow_buffer_type(initials_buffer) + if isempty(initials_buffer) + initials_buffer = SizedVector{0, Float64}() + end disc_buffer = narrow_buffer_type.(disc_buffer) const_buffer = narrow_buffer_type.(const_buffer) # Don't narrow nonnumeric types nonnumeric_buffer = nonnumeric_buffer mtkps = MTKParameters{ - typeof(tunable_buffer), typeof(disc_buffer), typeof(const_buffer), - typeof(nonnumeric_buffer)}(tunable_buffer, disc_buffer, const_buffer, - nonnumeric_buffer) + typeof(tunable_buffer), typeof(initials_buffer), typeof(disc_buffer), + typeof(const_buffer), typeof(nonnumeric_buffer), typeof(())}(tunable_buffer, + initials_buffer, disc_buffer, const_buffer, nonnumeric_buffer, ()) return mtkps end +function rebuild_with_caches(p::MTKParameters, cache_templates::BufferTemplate...) + buffers = map(cache_templates) do template + Vector{template.type}(undef, template.length) + end + @set p.caches = buffers +end + function narrow_buffer_type(buffer::AbstractArray) type = Union{} for x in buffer @@ -276,10 +253,7 @@ function SciMLStructures.canonicalize(::SciMLStructures.Tunable, p::MTKParameter arr = p.tunable repack = let p = p function (new_val) - if new_val !== p.tunable - copyto!(p.tunable, new_val) - end - return p + return SciMLStructures.replace(SciMLStructures.Tunable(), p, new_val) end end return arr, repack, true @@ -295,17 +269,35 @@ function SciMLStructures.replace!(::SciMLStructures.Tunable, p::MTKParameters, n 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)] + (Nonnumeric, :nonnumeric, 1) + (SciMLStructures.Caches, :caches, 1)] @eval function SciMLStructures.canonicalize(::$Portion, p::MTKParameters) as_vector = buffer_to_arraypartition(p.$field) - repack = let as_vector = as_vector, p = p + repack = let p = p function (new_val) - if new_val !== as_vector - update_tuple_of_buffers(new_val, p.$field) - end - p + return SciMLStructures.replace(($Portion)(), p, new_val) end end return as_vector, repack, true @@ -324,26 +316,34 @@ end function Base.copy(p::MTKParameters) tunable = copy(p.tunable) + initials = copy(p.initials) discrete = Tuple(eltype(buf) <: Real ? copy(buf) : copy.(buf) for buf in p.discrete) constant = Tuple(eltype(buf) <: Real ? copy(buf) : copy.(buf) for buf in p.constant) nonnumeric = copy.(p.nonnumeric) + caches = copy.(p.caches) return MTKParameters( tunable, + initials, discrete, constant, - nonnumeric + nonnumeric, + caches ) end function SymbolicIndexingInterface.parameter_values(p::MTKParameters, pind::ParameterIndex) + _ducktyped_parameter_values(p, pind) +end +function _ducktyped_parameter_values(p, pind::ParameterIndex) @unpack portion, idx = pind if portion isa SciMLStructures.Tunable return idx isa Int ? p.tunable[idx] : view(p.tunable, idx) end + if portion isa SciMLStructures.Initials + return idx isa Int ? p.initials[idx] : view(p.initials, idx) + end i, j, k... = idx - if portion isa SciMLStructures.Tunable - return isempty(k) ? p.tunable[i][j] : p.tunable[i][j][k...] - elseif portion isa SciMLStructures.Discrete + 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...] @@ -362,6 +362,11 @@ function SymbolicIndexingInterface.set_parameter!( 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 @@ -435,20 +440,30 @@ function validate_parameter_type(ic::IndexCache, p, idx::ParameterIndex, 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, get_buffer_template(ic, idx).type, Symbolics.Unknown(), nothing, idx, val) + 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, stype, val)) + 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, stype, val)) + 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 @@ -465,7 +480,7 @@ function validate_parameter_type(ic::IndexCache, stype, sz, sym, index, val) # This is for duals and other complicated number types etype = SciMLBase.parameterless_type(etype) eltype(val) <: etype || throw(ParameterTypeException( - :validate_parameter_type, sym, AbstractArray{etype}, val)) + :validate_parameter_type, sym === nothing ? index : sym, AbstractArray{etype}, val)) else # Real check if stype <: Real @@ -473,7 +488,8 @@ function validate_parameter_type(ic::IndexCache, stype, sz, sym, index, val) end stype = SciMLBase.parameterless_type(stype) val isa stype || - throw(ParameterTypeException(:validate_parameter_type, sym, stype, val)) + throw(ParameterTypeException( + :validate_parameter_type, sym === nothing ? index : sym, stype, val)) end end @@ -485,16 +501,134 @@ function indp_to_system(indp) end function SymbolicIndexingInterface.remake_buffer(indp, oldbuf::MTKParameters, idxs, vals) + _remake_buffer(indp, oldbuf, idxs, vals) +end + +# For type-inference when using `SII.setp_oop` +@generated function _remake_buffer( + indp, oldbuf::MTKParameters{T, I, D, C, N, H}, + idxs::Union{Tuple{Vararg{ParameterIndex}}, AbstractArray{<:ParameterIndex{P}}}, + vals::Union{AbstractArray, Tuple}; validate = true) where {T, I, D, C, N, H, P} + + # fallback to non-generated method if values aren't type-stable + if vals <: AbstractArray && !isconcretetype(eltype(vals)) + return quote + $__remake_buffer(indp, oldbuf, collect(idxs), vals; validate) + end + end + + # given an index in idxs/vals and the current `eltype` of the buffer, + # return the promoted eltype of the buffer + function promote_valtype(i, valT) + # tuples have distinct types, arrays have a common eltype + valT′ = vals <: AbstractArray ? eltype(vals) : fieldtype(vals, i) + # if the buffer is a scalarized buffer but the variable is an array + # e.g. an array tunable, take the eltype + if valT′ <: AbstractArray && !(valT <: AbstractArray) + valT′ = eltype(valT′) + end + return promote_type(valT, valT′) + end + + # types of the idxs + idxtypes = if idxs <: AbstractArray + # if both are arrays, there is only one possible type to check + if vals <: AbstractArray + (eltype(idxs),) + else + # if `vals` is a tuple, we repeat `eltype(idxs)` to check against + # every possible type of the buffer + ntuple(Returns(eltype(idxs)), Val(fieldcount(vals))) + end + else + # `idxs` is a tuple, so we check against all buffers + fieldtypes(idxs) + end + # promote types + tunablesT = eltype(T) + for (i, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Tunable} || continue + tunablesT = promote_valtype(i, tunablesT) + end + initialsT = eltype(I) + for (i, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Initials} || continue + initialsT = promote_valtype(i, initialsT) + end + discretesT = ntuple(Val(fieldcount(D))) do i + bufT = eltype(fieldtype(D, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Discrete, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + constantsT = ntuple(Val(fieldcount(C))) do i + bufT = eltype(fieldtype(C, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{SciMLStructures.Constants, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + nonnumericT = ntuple(Val(fieldcount(N))) do i + bufT = eltype(fieldtype(N, i)) + for (j, idxT) in enumerate(idxtypes) + idxT <: ParameterIndex{Nonnumeric, i} || continue + bufT = promote_valtype(i, bufT) + end + bufT + end + + expr = quote + tunables = $similar(oldbuf.tunable, $tunablesT) + copyto!(tunables, oldbuf.tunable) + initials = $similar(oldbuf.initials, $initialsT) + copyto!(initials, oldbuf.initials) + discretes = $(Expr(:tuple, + (:($similar(oldbuf.discrete[$i], $(discretesT[i]))) for i in 1:length(discretesT))...)) + $((:($copyto!(discretes[$i], oldbuf.discrete[$i])) for i in 1:length(discretesT))...) + constants = $(Expr(:tuple, + (:($similar(oldbuf.constant[$i], $(constantsT[i]))) for i in 1:length(constantsT))...)) + $((:($copyto!(constants[$i], oldbuf.constant[$i])) for i in 1:length(constantsT))...) + nonnumerics = $(Expr(:tuple, + (:($similar(oldbuf.nonnumeric[$i], $(nonnumericT[i]))) for i in 1:length(nonnumericT))...)) + $((:($copyto!(nonnumerics[$i], oldbuf.nonnumeric[$i])) for i in 1:length(nonnumericT))...) + newbuf = MTKParameters( + tunables, initials, discretes, constants, nonnumerics, copy.(oldbuf.caches)) + end + if idxs <: AbstractArray + push!(expr.args, :(for (idx, val) in zip(idxs, vals) + $setindex!(newbuf, val, idx) + end)) + else + for i in 1:fieldcount(idxs) + push!(expr.args, :($setindex!(newbuf, vals[$i], idxs[$i]))) + end + end + push!(expr.args, :(return newbuf)) + + return expr +end + +function _remake_buffer(indp, oldbuf::MTKParameters, idxs, vals; validate = true) + return __remake_buffer(indp, oldbuf, idxs, vals; validate) +end + +function __remake_buffer(indp, oldbuf::MTKParameters, idxs, vals; validate = true) newbuf = @set oldbuf.tunable = similar(oldbuf.tunable, Any) + @set! newbuf.initials = similar(oldbuf.initials, Any) @set! newbuf.discrete = Tuple(similar(buf, Any) for buf in newbuf.discrete) @set! newbuf.constant = Tuple(similar(buf, Any) for buf in newbuf.constant) @set! newbuf.nonnumeric = Tuple(similar(buf, Any) for buf in newbuf.nonnumeric) function handle_parameter(ic, sym, idx, val) - if sym === nothing - validate_parameter_type(ic, idx, val) - else - validate_parameter_type(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 @@ -508,6 +642,9 @@ function SymbolicIndexingInterface.remake_buffer(indp, oldbuf::MTKParameters, id 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) @@ -552,6 +689,16 @@ function SymbolicIndexingInterface.remake_buffer(indp, oldbuf::MTKParameters, id @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.( @@ -561,6 +708,16 @@ function SymbolicIndexingInterface.remake_buffer(indp, oldbuf::MTKParameters, id 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 @@ -629,11 +786,14 @@ end # 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, D, C, N}, idx::Int) where {T, D, C, N} + 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 @@ -643,6 +803,9 @@ 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) @@ -652,15 +815,23 @@ end return Expr(:block, expr, :(throw(BoundsError(ps, idx)))) end -@generated function Base.length(ps::MTKParameters{T, D, C, N}) where {T, D, C, N} +@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 - len += fieldcount(D) + fieldcount(C) + fieldcount(N) + 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) @@ -675,43 +846,11 @@ function Base.iterate(buf::MTKParameters, state = 1) end function Base.:(==)(a::MTKParameters, b::MTKParameters) - return a.tunable == b.tunable && a.discrete == b.discrete && - a.constant == b.constant && a.nonnumeric == b.nonnumeric -end - -# to support linearize/linearization_function -function jacobian_wrt_vars(pf::F, p::MTKParameters, input_idxs, chunk::C) where {F, C} - tunable, _, _ = SciMLStructures.canonicalize(SciMLStructures.Tunable(), p) - T = eltype(tunable) - tag = ForwardDiff.Tag(pf, T) - dualtype = ForwardDiff.Dual{typeof(tag), T, ForwardDiff.chunksize(chunk)} - p_big = SciMLStructures.replace(SciMLStructures.Tunable(), p, dualtype.(tunable)) - p_closure = let pf = pf, - input_idxs = input_idxs, - p_big = p_big - - function (p_small_inner) - for (i, val) in zip(input_idxs, p_small_inner) - set_parameter!(p_big, val, i) - end - return if pf isa SciMLBase.ParamJacobianWrapper - buffer = Array{dualtype}(undef, size(pf.u)) - pf(buffer, p_big) - buffer - else - pf(p_big) - end - end - end - p_small = parameter_values.((p,), input_idxs) - cfg = ForwardDiff.JacobianConfig(p_closure, p_small, chunk, tag) - ForwardDiff.jacobian(p_closure, p_small, cfg, Val(false)) -end - -function as_duals(p::MTKParameters, dualtype) - tunable = dualtype.(p.tunable) - discrete = dualtype.(p.discrete) - return MTKParameters{typeof(tunable), typeof(discrete)}(tunable, discrete) + 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 = """ @@ -725,7 +864,7 @@ end function Base.showerror(io::IO, e::MissingParametersError) println(io, MISSING_PARAMETERS_MESSAGE) - println(io, e.vars) + println(io, join(e.vars, ", ")) end function InvalidParameterSizeException(param, val) diff --git a/src/systems/pde/pdesystem.jl b/src/systems/pde/pdesystem.jl index 7aa3f29191..96e6a6b276 100644 --- a/src/systems/pde/pdesystem.jl +++ b/src/systems/pde/pdesystem.jl @@ -78,6 +78,10 @@ struct PDESystem <: ModelingToolkit.AbstractMultivariateSystem """ name::Symbol """ + A description of the system. + """ + description::String + """ Metadata for the system, to be used by downstream packages. """ metadata::Any @@ -96,6 +100,7 @@ struct PDESystem <: ModelingToolkit.AbstractMultivariateSystem 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) @@ -127,7 +132,7 @@ struct PDESystem <: ModelingToolkit.AbstractMultivariateSystem end new(eqs, bcs, domain, ivs, dvs, ps, defaults, connector_type, systems, analytic, - analytic_func, name, metadata, gui_metadata) + analytic_func, name, description, metadata, gui_metadata) end end @@ -161,3 +166,5 @@ function Base.show(io::IO, ::MIME"text/plain", sys::PDESystem) print(io, "Default Parameter Values", get_defaults(sys)) return nothing end + +SymbolicIndexingInterface.is_time_dependent(::AbstractMultivariateSystem) = true diff --git a/src/systems/problem_utils.jl b/src/systems/problem_utils.jl new file mode 100644 index 0000000000..0750585905 --- /dev/null +++ b/src/systems/problem_utils.jl @@ -0,0 +1,1082 @@ +const AnyDict = Dict{Any, Any} + +""" + $(TYPEDSIGNATURES) + +If called without arguments, return `Dict{Any, Any}`. Otherwise, interpret the input +as a symbolic map and turn it into a `Dict{Any, Any}`. Handles `SciMLBase.NullParameters`, +`missing` and `nothing`. +""" +anydict() = AnyDict() +anydict(::SciMLBase.NullParameters) = AnyDict() +anydict(::Nothing) = AnyDict() +anydict(::Missing) = AnyDict() +anydict(x::AnyDict) = x +anydict(x) = AnyDict(x) + +""" + $(TYPEDSIGNATURES) + +Check if `x` is a symbolic with known size. Assumes `Symbolics.shape(unwrap(x))` +is a valid operation. +""" +is_sized_array_symbolic(x) = Symbolics.shape(unwrap(x)) != Symbolics.Unknown() + +""" + $(TYPEDSIGNATURES) + +Check if the system is in split form (has an `IndexCache`). +""" +is_split(sys::AbstractSystem) = has_index_cache(sys) && get_index_cache(sys) !== nothing + +""" + $(TYPEDSIGNATURES) + +Given a variable-value mapping, add mappings for the `toterm` of each of the keys. +""" +function add_toterms!(varmap::AbstractDict; toterm = default_toterm) + for k in collect(keys(varmap)) + varmap[toterm(k)] = varmap[k] + end + return nothing +end + +""" + $(TYPEDSIGNATURES) + +Out-of-place version of [`add_toterms!`](@ref). +""" +function add_toterms(varmap::AbstractDict; toterm = default_toterm) + cp = copy(varmap) + add_toterms!(cp; toterm) + return cp +end + +""" + $(TYPEDSIGNATURES) + +Turn any `Symbol` keys in `varmap` to the appropriate symbolic variables in `sys`. Any +symbols that cannot be converted are ignored. +""" +function symbols_to_symbolics!(sys::AbstractSystem, varmap::AbstractDict) + if is_split(sys) + ic = get_index_cache(sys) + for k in collect(keys(varmap)) + k isa Symbol || continue + newk = get(ic.symbol_to_variable, k, nothing) + newk === nothing && continue + varmap[newk] = varmap[k] + delete!(varmap, k) + end + else + syms = all_symbols(sys) + for k in collect(keys(varmap)) + k isa Symbol || continue + idx = findfirst(syms) do sym + hasname(sym) || return false + name = getname(sym) + return name == k + end + idx === nothing && continue + newk = syms[idx] + if iscall(newk) && operation(newk) === getindex + newk = arguments(newk)[1] + end + varmap[newk] = varmap[k] + delete!(varmap, k) + end + end +end + +""" + $(TYPEDSIGNATURES) + +Utility function to get the value `val` corresponding to key `var` in `varmap`, and +return `getindex(val, idx)` if it exists or `nothing` otherwise. +""" +function get_and_getindex(varmap, var, idx) + val = get(varmap, var, nothing) + val === nothing && return nothing + return val[idx] +end + +""" + $(TYPEDSIGNATURES) + +Ensure `varmap` contains entries for all variables in `vars` by using values from +`fallbacks` if they don't already exist in `varmap`. Return the set of all variables in +`vars` not present in `varmap` or `fallbacks`. If an array variable in `vars` does not +exist in `varmap` or `fallbacks`, each of its scalarized elements will be searched for. +In case none of the scalarized elements exist, the array variable will be reported as +missing. In case some of the scalarized elements exist, the missing elements will be +reported as missing. If `fallbacks` contains both the scalarized and non-scalarized forms, +the latter will take priority. + +Variables as they are specified in `vars` will take priority over their `toterm` forms. +""" +function add_fallbacks!( + varmap::AnyDict, vars::Vector, fallbacks::Dict; toterm = default_toterm) + missingvars = Set() + arrvars = Set() + for var in vars + haskey(varmap, var) && continue + ttvar = toterm(var) + haskey(varmap, ttvar) && continue + + # array symbolics with a defined size may be present in the scalarized form + if Symbolics.isarraysymbolic(var) && is_sized_array_symbolic(var) + val = map(eachindex(var)) do idx + # @something is lazy and saves from writing a massive if-elseif-else + @something(get(varmap, var[idx], nothing), + get(varmap, ttvar[idx], nothing), get_and_getindex(fallbacks, var, idx), + get_and_getindex(fallbacks, ttvar, idx), get( + fallbacks, var[idx], nothing), + get(fallbacks, ttvar[idx], nothing), Some(nothing)) + end + # only push the missing entries + mask = map(x -> x === nothing, val) + if all(mask) + push!(missingvars, var) + elseif any(mask) + for i in eachindex(var) + if mask[i] + push!(missingvars, var) + else + varmap[var[i]] = val[i] + end + end + else + varmap[var] = val + end + else + if iscall(var) && operation(var) == getindex + args = arguments(var) + arrvar = args[1] + ttarrvar = toterm(arrvar) + idxs = args[2:end] + val = @something get(varmap, arrvar, nothing) get(varmap, ttarrvar, nothing) get( + fallbacks, arrvar, nothing) get(fallbacks, ttarrvar, nothing) Some(nothing) + if val !== nothing + val = val[idxs...] + is_sized_array_symbolic(arrvar) && push!(arrvars, arrvar) + end + else + val = nothing + end + val = @something val get(fallbacks, var, nothing) get(fallbacks, ttvar, nothing) Some(nothing) + if val === nothing + push!(missingvars, var) + else + varmap[var] = val + end + end + end + + for arrvar in arrvars + varmap[arrvar] = collect(arrvar) + end + + return missingvars +end + +""" + $(TYPEDSIGNATURES) + +Return the list of variables in `varlist` not present in `varmap`. Uses the same criteria +for missing array variables and `toterm` forms as [`add_fallbacks!`](@ref). +""" +function missingvars( + varmap::AbstractDict, varlist::Vector; toterm = default_toterm) + missingvars = Set() + for var in varlist + haskey(varmap, var) && continue + ttsym = toterm(var) + haskey(varmap, ttsym) && continue + + if Symbolics.isarraysymbolic(var) && is_sized_array_symbolic(var) + mask = map(eachindex(var)) do idx + !haskey(varmap, var[idx]) && !haskey(varmap, ttsym[idx]) + end + if all(mask) + push!(missingvars, var) + else + for i in eachindex(var) + mask[i] && push!(missingvars, var[i]) + end + end + else + push!(missingvars, var) + end + end + return missingvars +end + +""" + $(TYPEDSIGNATURES) + +Attempt to interpret `vals` as a symbolic map of variables in `varlist` to values. Return +the result as a `Dict{Any, Any}`. In case `vals` is already an iterable of pairs, convert +it to a `Dict{Any, Any}` and return. If `vals` is an array (whose `eltype` is not `Pair`) +with the same length as `varlist`, assume the `i`th element of `varlist` is mapped to the +`i`th element of `vals`. Automatically `unwrap`s all keys and values in the mapping. Also +handles `SciMLBase.NullParameters` and `nothing`, both of which are interpreted as empty +maps. +""" +function to_varmap(vals, varlist::Vector) + if vals isa AbstractArray && !(eltype(vals) <: Pair) && !isempty(vals) + check_eqs_u0(varlist, varlist, vals) + vals = vec(varlist) .=> vec(vals) + end + return recursive_unwrap(anydict(vals)) +end + +""" + $(TYPEDSIGNATURES) + +Recursively call `Symbolics.unwrap` on `x`. Useful when `x` is an array of (potentially) +symbolic values, all of which need to be unwrapped. Specializes when `x isa AbstractDict` +to unwrap keys and values, returning an `AnyDict`. +""" +function recursive_unwrap(x::AbstractArray) + symbolic_type(x) == ArraySymbolic() ? unwrap(x) : recursive_unwrap.(x) +end + +recursive_unwrap(x) = unwrap(x) + +function recursive_unwrap(x::AbstractDict) + return anydict(unwrap(k) => recursive_unwrap(v) for (k, v) in x) +end + +""" + $(TYPEDSIGNATURES) + +Add equations `eqs` to `varmap`. Assumes each element in `eqs` maps a single symbolic +variable to an expression representing its value. In case `varmap` already contains an +entry for `eq.lhs`, insert the reverse mapping if `eq.rhs` is not a number. +""" +function add_observed_equations!(varmap::AbstractDict, eqs) + for eq in eqs + if var_in_varlist(eq.lhs, keys(varmap), nothing) + eq.rhs isa Number && continue + var_in_varlist(eq.rhs, keys(varmap), nothing) && continue + !iscall(eq.rhs) || issym(operation(eq.rhs)) || continue + varmap[eq.rhs] = eq.lhs + else + varmap[eq.lhs] = eq.rhs + end + end +end + +""" + $(TYPEDSIGNATURES) + +Add all equations in `observed(sys)` to `varmap` using [`add_observed_equations!`](@ref). +""" +function add_observed!(sys::AbstractSystem, varmap::AbstractDict) + add_observed_equations!(varmap, observed(sys)) +end + +""" + $(TYPEDSIGNATURES) + +Add all equations in `parameter_dependencies(sys)` to `varmap` using +[`add_observed_equations!`](@ref). +""" +function add_parameter_dependencies!(sys::AbstractSystem, varmap::AbstractDict) + has_parameter_dependencies(sys) || return nothing + add_observed_equations!(varmap, parameter_dependencies(sys)) +end + +struct UnexpectedSymbolicValueInVarmap <: Exception + sym::Any + val::Any +end + +function Base.showerror(io::IO, err::UnexpectedSymbolicValueInVarmap) + println(io, + """ + Found symbolic value $(err.val) for variable $(err.sym). You may be missing an \ + initial condition or have cyclic initial conditions. If this is intended, pass \ + `symbolic_u0 = true`. In case the initial conditions are not cyclic but \ + require more substitutions to resolve, increase `substitution_limit`. To report \ + cycles in initial conditions of unknowns/parameters, pass \ + `warn_cyclic_dependency = true`. If the cycles are still not reported, you \ + may need to pass a larger value for `circular_dependency_max_cycle_length` \ + or `circular_dependency_max_cycles`. + """) +end + +struct MissingGuessError <: Exception + syms::Vector{Any} + vals::Vector{Any} +end + +function Base.showerror(io::IO, err::MissingGuessError) + println(io, + """ + Cyclic guesses detected in the system. Symbolic values were found for the following variables/parameters in the map: \ + """) + for (sym, val) in zip(err.syms, err.vals) + println(io, "$sym => $val") + end + println(io, + """ + In order to resolve this, please provide additional numeric guesses so that the chain can be resolved to assign numeric values to each variable. """) +end + +""" + $(TYPEDSIGNATURES) + +Return an array of values where the `i`th element corresponds to the value of `vars[i]` +in `varmap`. Does not perform symbolic substitution in the values of `varmap`. + +Keyword arguments: +- `tofloat`: Convert values to floating point numbers using `float`. +- `container_type`: The type of container to use for the values. +- `toterm`: The `toterm` method to use for converting symbolics. +- `promotetoconcrete`: whether the promote to a concrete buffer (respecting + `tofloat`). Defaults to `container_type <: AbstractArray`. +- `check`: Error if any variables in `vars` do not have a mapping in `varmap`. Uses + [`missingvars`](@ref) to perform the check. +- `allow_symbolic` allows the returned array to contain symbolic values. If this is `true`, + `promotetoconcrete` is set to `false`. +- `is_initializeprob, guesses`: Used to determine whether the system is missing guesses. +""" +function better_varmap_to_vars(varmap::AbstractDict, vars::Vector; + tofloat = true, container_type = Array, floatT = Nothing, + toterm = default_toterm, promotetoconcrete = nothing, check = true, + allow_symbolic = false, is_initializeprob = false) + isempty(vars) && return nothing + + if check + missing_vars = missingvars(varmap, vars; toterm) + isempty(missing_vars) || throw(MissingVariablesError(missing_vars)) + end + vals = map(x -> varmap[x], vars) + if !allow_symbolic + missingsyms = Any[] + missingvals = Any[] + for (sym, val) in zip(vars, vals) + symbolic_type(val) == NotSymbolic() && continue + push!(missingsyms, sym) + push!(missingvals, val) + end + + if !isempty(missingsyms) + is_initializeprob ? throw(MissingGuessError(missingsyms, missingvals)) : + throw(UnexpectedSymbolicValueInVarmap(missingsyms[1], missingvals[1])) + end + if tofloat && !(floatT == Nothing) + vals = floatT.(vals) + end + end + + if container_type <: Union{AbstractDict, Tuple, Nothing, SciMLBase.NullParameters} + container_type = Array + end + + promotetoconcrete === nothing && (promotetoconcrete = container_type <: AbstractArray) + if promotetoconcrete && !allow_symbolic + vals = promote_to_concrete(vals; tofloat = tofloat, use_union = false) + end + + if isempty(vals) + return nothing + elseif container_type <: Tuple + return (vals...,) + else + return SymbolicUtils.Code.create_array(container_type, eltype(vals), Val{1}(), + Val(length(vals)), vals...) + end +end + +""" + $(TYPEDSIGNATURES) + +Check if any of the substitution rules in `varmap` lead to cycles involving +variables in `vars`. Return a vector of vectors containing all the variables +in each cycle. + +Keyword arguments: +- `max_cycle_length`: The maximum length (number of variables) of detected cycles. +- `max_cycles`: The maximum number of cycles to report. +""" +function check_substitution_cycles( + varmap::AbstractDict, vars; max_cycle_length = length(varmap), max_cycles = 10) + # ordered set so that `vars` are the first `k` in the list + allvars = OrderedSet{Any}(vars) + union!(allvars, keys(varmap)) + allvars = collect(allvars) + var_to_idx = Dict(allvars .=> eachindex(allvars)) + graph = SimpleDiGraph(length(allvars)) + + buffer = Set() + for (k, v) in varmap + kidx = var_to_idx[k] + if symbolic_type(v) != NotSymbolic() + vars!(buffer, v) + for var in buffer + haskey(var_to_idx, var) || continue + add_edge!(graph, kidx, var_to_idx[var]) + end + elseif v isa AbstractArray + for val in v + vars!(buffer, val) + end + for var in buffer + haskey(var_to_idx, var) || continue + add_edge!(graph, kidx, var_to_idx[var]) + end + end + empty!(buffer) + end + + # detect at most 100 cycles involving at most `length(varmap)` vertices + cycles = Graphs.simplecycles_limited_length(graph, max_cycle_length, max_cycles) + # only count those which contain variables in `vars` + filter!(Base.Fix1(any, <=(length(vars))), cycles) + + map(cycles) do cycle + map(Base.Fix1(getindex, allvars), cycle) + end +end + +""" + $(TYPEDSIGNATURES) + +Performs symbolic substitution on the values in `varmap` for the keys in `vars`, using +`varmap` itself as the set of substitution rules. If an entry in `vars` is not a key +in `varmap`, it is ignored. +""" +function evaluate_varmap!(varmap::AbstractDict, vars; limit = 100) + for k in vars + haskey(varmap, k) || continue + varmap[k] = fixpoint_sub(varmap[k], varmap; maxiters = limit) + end +end + +""" + $(TYPEDSIGNATURES) + +Remove keys in `varmap` whose values are `nothing`. + +If `missing_values` is not `nothing`, it is assumed to be a collection and all removed +keys will be added to it. +""" +function filter_missing_values!(varmap::AbstractDict; missing_values = nothing) + filter!(varmap) do kvp + if kvp[2] !== nothing + return true + end + if missing_values !== nothing + push!(missing_values, kvp[1]) + end + return false + end +end + +""" + $(TYPEDSIGNATURES) + +For each `k => v` in `varmap` where `k` is an array (or array symbolic) add +`k[i] => v[i]` for all `i in eachindex(k)`. Return the modified `varmap`. +""" +function scalarize_varmap!(varmap::AbstractDict) + for k in collect(keys(varmap)) + symbolic_type(k) == ArraySymbolic() || continue + for i in eachindex(k) + varmap[k[i]] = varmap[k][i] + end + end + return varmap +end + +struct GetUpdatedMTKParameters{G, S} + # `getu` functor which gets parameters that are unknowns during initialization + getpunknowns::G + # `setu` functor which returns a modified MTKParameters using those parameters + setpunknowns::S +end + +function (f::GetUpdatedMTKParameters)(prob, initializesol) + p = parameter_values(prob) + p === nothing && return nothing + mtkp = copy(p) + f.setpunknowns(mtkp, f.getpunknowns(initializesol)) + mtkp +end + +struct UpdateInitializeprob{G, S} + # `getu` functor which gets all values from prob + getvals::G + # `setu` functor which updates initializeprob with values + setvals::S +end + +function (f::UpdateInitializeprob)(initializeprob, prob) + f.setvals(initializeprob, f.getvals(prob)) +end + +function get_temporary_value(p, floatT = Float64) + stype = symtype(unwrap(p)) + return if stype == Real + zero(floatT) + elseif stype <: AbstractArray{Real} + zeros(floatT, size(p)) + elseif stype <: Real + zero(stype) + elseif stype <: AbstractArray + zeros(eltype(stype), size(p)) + else + error("Nonnumeric parameter $p with symtype $stype cannot be solved for during initialization") + end +end + +""" + $(TYPEDEF) + +A simple utility meant to be used as the `constructor` passed to `process_SciMLProblem` in +case constructing a SciMLFunction is not required. The arguments passed to it are available +in the `args` field, and the keyword arguments in the `kwargs` field. +""" +struct EmptySciMLFunction{A, K} + args::A + kwargs::K +end + +function EmptySciMLFunction(args...; kwargs...) + return EmptySciMLFunction{typeof(args), typeof(kwargs)}(args, kwargs) +end + +""" + $(TYPEDSIGNATURES) + +Construct the operating point of the system from the user-provided `u0map` and `pmap`, system +defaults `defs`, constant equations `cmap` (from `get_cmap(sys)`), unknowns `dvs` and +parameters `ps`. Return the operating point as a dictionary, the list of unknowns for which +no values can be determined, and the list of parameters for which no values can be determined. + +Also updates `u0map` and `pmap` in-place to contain all the initial conditions in `op`, split +by unknowns and parameters respectively. +""" +function build_operating_point!(sys::AbstractSystem, + u0map::AbstractDict, pmap::AbstractDict, defs::AbstractDict, cmap, dvs, ps) + op = add_toterms(u0map) + missing_unknowns = add_fallbacks!(op, dvs, defs) + for (k, v) in defs + haskey(op, k) && continue + op[k] = v + end + filter_missing_values!(op; missing_values = missing_unknowns) + + merge!(op, pmap) + missing_pars = add_fallbacks!(op, ps, defs) + filter_missing_values!(op; missing_values = missing_pars) + for eq in cmap + op[eq.lhs] = eq.rhs + end + + filter!(kvp -> kvp[2] === nothing, u0map) + filter!(kvp -> kvp[2] === nothing, pmap) + neithermap = anydict() + + for (k, v) in op + k = unwrap(k) + if is_parameter(sys, k) + pmap[k] = v + elseif has_parameter_dependency_with_lhs(sys, k) && is_variable_floatingpoint(k) && + v !== nothing && !isequal(v, Initial(k)) + op[Initial(k)] = v + pmap[Initial(k)] = v + op[k] = Initial(k) + pmap[k] = Initial(k) + elseif is_variable(sys, k) || has_observed_with_lhs(sys, k) || + iscall(k) && + operation(k) isa Differential && is_variable(sys, arguments(k)[1]) + if symbolic_type(v) == NotSymbolic() && !is_array_of_symbolics(v) && + v !== nothing + op[Initial(k)] = v + pmap[Initial(k)] = v + op[k] = Initial(k) + v = Initial(k) + end + u0map[k] = v + else + neithermap[k] = v + end + end + + for k in keys(u0map) + v = fixpoint_sub(u0map[k], neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + u0map[k] = v + end + for k in keys(pmap) + v = fixpoint_sub(pmap[k], neithermap; operator = Symbolics.Operator) + isequal(k, v) && continue + pmap[k] = v + end + + return op, missing_unknowns, missing_pars +end + +""" + $(TYPEDSIGNATURES) + +Build and return the initialization problem and associated data as a `NamedTuple` to be passed +to the `SciMLFunction` constructor. Requires the system `sys`, operating point `op`, +user-provided `u0map` and `pmap`, initial time `t`, system defaults `defs`, user-provided +`guesses`, and list of unknowns which don't have a value in `op`. The keyword `implicit_dae` +denotes whether the `SciMLProblem` being constructed is in implicit DAE form (`DAEProblem`). +All other keyword arguments are forwarded to `InitializationProblem`. +""" +function maybe_build_initialization_problem( + sys::AbstractSystem, op::AbstractDict, u0map, pmap, t, defs, + guesses, missing_unknowns; implicit_dae = false, + u0_constructor = identity, floatT = Float64, kwargs...) + guesses = merge(ModelingToolkit.guesses(sys), todict(guesses)) + + if t === nothing && is_time_dependent(sys) + t = zero(floatT) + end + + initializeprob = ModelingToolkit.InitializationProblem{true, SciMLBase.FullSpecialize}( + sys, t, u0map, pmap; guesses, kwargs...) + if state_values(initializeprob) !== nothing + initializeprob = remake(initializeprob; u0 = floatT.(state_values(initializeprob))) + end + initp = parameter_values(initializeprob) + if is_split(sys) + buffer, repack, _ = SciMLStructures.canonicalize(SciMLStructures.Tunable(), initp) + initp = repack(floatT.(buffer)) + buffer, repack, _ = SciMLStructures.canonicalize(SciMLStructures.Initials(), initp) + initp = repack(floatT.(buffer)) + elseif initp isa AbstractArray + initp′ = similar(initp, floatT) + copyto!(initp′, initp) + initp = initp′ + end + initializeprob = remake(initializeprob; p = initp) + + meta = get_metadata(initializeprob.f.sys) + + if is_time_dependent(sys) + all_init_syms = Set(all_symbols(initializeprob)) + solved_unknowns = filter(var -> var in all_init_syms, unknowns(sys)) + initializeprobmap = u0_constructor ∘ getu(initializeprob, solved_unknowns) + else + initializeprobmap = nothing + end + + punknowns = [p + for p in all_variable_symbols(initializeprob) + if is_parameter(sys, p)] + if isempty(punknowns) + initializeprobpmap = nothing + else + getpunknowns = getu(initializeprob, punknowns) + setpunknowns = setp(sys, punknowns) + initializeprobpmap = GetUpdatedMTKParameters(getpunknowns, setpunknowns) + end + + reqd_syms = parameter_symbols(initializeprob) + # we still want the `initialization_data` because it helps with `remake` + if initializeprobmap === nothing && initializeprobpmap === nothing + update_initializeprob! = nothing + else + update_initializeprob! = UpdateInitializeprob( + getu(sys, reqd_syms), setu(initializeprob, reqd_syms)) + end + + for p in punknowns + is_parameter_solvable(p, pmap, defs, guesses) || continue + get(op, p, missing) === missing || continue + p = unwrap(p) + stype = symtype(p) + op[p] = get_temporary_value(p, floatT) + if iscall(p) && operation(p) === getindex + arrp = arguments(p)[1] + op[arrp] = collect(arrp) + end + end + + if is_time_dependent(sys) + for v in missing_unknowns + op[v] = get_temporary_value(v, floatT) + end + empty!(missing_unknowns) + end + + return (; + initialization_data = SciMLBase.OverrideInitData( + initializeprob, update_initializeprob!, initializeprobmap, + initializeprobpmap)) +end + +""" + $(TYPEDSIGNATURES) + +Calculate the floating point type to use from the given `varmap` by looking at variables +with a constant value. +""" +function float_type_from_varmap(varmap, floatT = Bool) + for (k, v) in varmap + symbolic_type(v) == NotSymbolic() || continue + is_array_of_symbolics(v) && continue + + if v isa AbstractArray + floatT = promote_type(floatT, eltype(v)) + elseif v isa Real + floatT = promote_type(floatT, typeof(v)) + end + end + return float(floatT) +end + +""" + $(TYPEDSIGNATURES) + +Return the SciMLFunction created via calling `constructor`, the initial conditions `u0` +and parameter object `p` given the system `sys`, and user-provided initial values `u0map` +and `pmap`. `u0map` and `pmap` are converted into variable maps via [`to_varmap`](@ref). + +The order of unknowns is determined by `unknowns(sys)`. If the system is split +[`is_split`](@ref) create an [`MTKParameters`](@ref) object. Otherwise, a parameter vector. +Initial values provided in terms of other variables will be symbolically evaluated using +[`evaluate_varmap!`](@ref). The type of `u0map` and `pmap` will be used to determine the +type of the containers (if parameters are not in an `MTKParameters` object). `Dict`s will be +turned into `Array`s. + +If `sys isa ODESystem`, this will also build the initialization problem and related objects +and pass them to the SciMLFunction as keyword arguments. + +Keyword arguments: +- `build_initializeprob`: If `false`, avoids building the initialization problem for an + `ODESystem`. +- `t`: The initial time of the `ODEProblem`. If this is not provided, the initialization + problem cannot be built. +- `implicit_dae`: Also build a mapping of derivatives of states to values for implicit DAEs, + using `du0map`. Changes the return value of this function to `(f, du0, u0, p)` instead of + `(f, u0, p)`. +- `guesses`: The guesses for variables in the system, used as initial values for the + initialization problem. +- `warn_initialize_determined`: Warn if the initialization system is under/over-determined. +- `initialization_eqs`: Extra equations to use in the initialization problem. +- `eval_expression`: Whether to compile any functions via `eval` or `RuntimeGeneratedFunctions`. +- `eval_module`: If `eval_expression == true`, the module to `eval` into. Otherwise, the module + in which to generate the `RuntimeGeneratedFunction`. +- `fully_determined`: Override whether the initialization system is fully determined. +- `check_initialization_units`: Enable or disable unit checks when constructing the + initialization problem. +- `tofloat`, `is_initializeprob`: Passed to [`better_varmap_to_vars`](@ref) for building `u0` (and possibly `p`). +- `u0_constructor`: A function to apply to the `u0` value returned from `better_varmap_to_vars` + to construct the final `u0` value. +- `du0map`: A map of derivatives to values. See `implicit_dae`. +- `check_length`: Whether to check the number of equations along with number of unknowns and + length of `u0` vector for consistency. If `false`, do not check with equations. This is + forwarded to `check_eqs_u0` +- `symbolic_u0` allows the returned `u0` to be an array of symbolics. +- `warn_cyclic_dependency`: Whether to emit a warning listing out cycles in initial + conditions provided for unknowns and parameters. +- `circular_dependency_max_cycle_length`: Maximum length of cycle to check for. + Only applicable if `warn_cyclic_dependency == true`. +- `circular_dependency_max_cycles`: Maximum number of cycles to check for. + Only applicable if `warn_cyclic_dependency == true`. +- `substitution_limit`: The number times to substitute initial conditions into each + other to attempt to arrive at a numeric value. +- `use_scc`: Whether to use `SCCNonlinearProblem` for initialization if the system is fully + determined. +- `force_initialization_time_independent`: Whether to force the initialization to not use + the independent variable of `sys`. +- `algebraic_only`: Whether to build the initialization problem using only algebraic equations. +- `allow_incomplete`: Whether to allow incomplete initialization problems. + +All other keyword arguments are passed as-is to `constructor`. +""" +function process_SciMLProblem( + constructor, sys::AbstractSystem, u0map, pmap; build_initializeprob = true, + implicit_dae = false, t = nothing, guesses = AnyDict(), + warn_initialize_determined = true, initialization_eqs = [], + eval_expression = false, eval_module = @__MODULE__, fully_determined = nothing, + check_initialization_units = false, tofloat = true, + u0_constructor = identity, du0map = nothing, check_length = true, + symbolic_u0 = false, warn_cyclic_dependency = false, + circular_dependency_max_cycle_length = length(all_symbols(sys)), + circular_dependency_max_cycles = 10, + substitution_limit = 100, use_scc = true, + force_initialization_time_independent = false, algebraic_only = false, + allow_incomplete = false, is_initializeprob = false, kwargs...) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + iv = has_iv(sys) ? get_iv(sys) : nothing + eqs = equations(sys) + + check_array_equations_unknowns(eqs, dvs) + + u0Type = typeof(u0map) + pType = typeof(pmap) + + u0map = to_varmap(u0map, dvs) + symbols_to_symbolics!(sys, u0map) + pmap = to_varmap(pmap, parameters(sys)) + symbols_to_symbolics!(sys, pmap) + + check_inputmap_keys(sys, u0map, pmap) + + defs = add_toterms(recursive_unwrap(defaults(sys))) + cmap, cs = get_cmap(sys) + kwargs = NamedTuple(kwargs) + + if eltype(eqs) <: Equation + obs, eqs = unhack_observed(observed(sys), eqs) + else + obs, _ = unhack_observed(observed(sys), Equation[x for x in eqs if x isa Equation]) + end + + op, missing_unknowns, missing_pars = build_operating_point!(sys, + u0map, pmap, defs, cmap, dvs, ps) + + floatT = Bool + if u0Type <: AbstractArray && eltype(u0Type) <: Real + floatT = float(eltype(u0Type)) + else + floatT = float_type_from_varmap(op, floatT) + end + + if !is_time_dependent(sys) || is_initializesystem(sys) + add_observed_equations!(u0map, obs) + end + if u0_constructor === identity && u0Type <: StaticArray + u0_constructor = vals -> SymbolicUtils.Code.create_array( + u0Type, floatT, Val(1), Val(length(vals)), vals...) + end + if build_initializeprob + kws = maybe_build_initialization_problem( + sys, op, u0map, pmap, t, defs, guesses, missing_unknowns; + implicit_dae, warn_initialize_determined, initialization_eqs, + eval_expression, eval_module, fully_determined, + warn_cyclic_dependency, check_units = check_initialization_units, + circular_dependency_max_cycle_length, circular_dependency_max_cycles, use_scc, + force_time_independent = force_initialization_time_independent, algebraic_only, allow_incomplete, + u0_constructor, floatT) + + kwargs = merge(kwargs, kws) + end + + if t !== nothing && !(constructor <: Union{DDEFunction, SDDEFunction}) + op[iv] = t + end + + add_observed_equations!(op, obs) + add_parameter_dependencies!(sys, op) + + if warn_cyclic_dependency + cycles = check_substitution_cycles( + op, dvs; max_cycle_length = circular_dependency_max_cycle_length, + max_cycles = circular_dependency_max_cycles) + if !isempty(cycles) + buffer = IOBuffer() + for cycle in cycles + println(buffer, cycle) + end + msg = String(take!(buffer)) + @warn "Cycles in unknowns:\n$msg" + end + end + evaluate_varmap!(op, dvs; limit = substitution_limit) + + u0 = better_varmap_to_vars( + op, dvs; tofloat, floatT, + container_type = u0Type, allow_symbolic = symbolic_u0, is_initializeprob) + + if u0 !== nothing + u0 = u0_constructor(u0) + end + + check_eqs_u0(eqs, dvs, u0; check_length, kwargs...) + + if warn_cyclic_dependency + cycles = check_substitution_cycles( + op, ps; max_cycle_length = circular_dependency_max_cycle_length, + max_cycles = circular_dependency_max_cycles) + if !isempty(cycles) + buffer = IOBuffer() + for cycle in cycles + println(buffer, cycle) + end + msg = String(take!(buffer)) + @warn "Cycles in parameters:\n$msg" + end + end + evaluate_varmap!(op, ps; limit = substitution_limit) + if is_split(sys) + p = MTKParameters(sys, op; floatT = floatT) + else + p = better_varmap_to_vars(op, ps; tofloat, container_type = pType) + end + + if implicit_dae && du0map !== nothing + ddvs = map(Differential(iv), dvs) + du0map = to_varmap(du0map, ddvs) + merge!(op, du0map) + du0 = varmap_to_vars(op, ddvs; toterm = identity, + tofloat) + kwargs = merge(kwargs, (; ddvs)) + else + du0 = nothing + end + + if build_initializeprob + t0 = t + if is_time_dependent(sys) && t0 === nothing + t0 = zero(floatT) + end + initialization_data = SciMLBase.remake_initialization_data( + kwargs.initialization_data, kwargs, u0, t0, p, u0, p) + kwargs = merge(kwargs,) + end + + f = constructor(sys, dvs, ps, u0; p = p, + eval_expression = eval_expression, + eval_module = eval_module, + kwargs...) + implicit_dae ? (f, du0, u0, p) : (f, u0, p) +end + +# Check that the keys of a u0map or pmap are valid +# (i.e. are symbolic keys, and are defined for the system.) +function check_inputmap_keys(sys, u0map, pmap) + badvarkeys = Any[] + for k in keys(u0map) + if symbolic_type(k) === NotSymbolic() + push!(badvarkeys, k) + end + end + + badparamkeys = Any[] + for k in keys(pmap) + if symbolic_type(k) === NotSymbolic() + push!(badparamkeys, k) + end + end + (isempty(badvarkeys) && isempty(badparamkeys)) || + throw(InvalidKeyError(collect(badvarkeys), collect(badparamkeys))) +end + +const BAD_KEY_MESSAGE = """ + Undefined keys found in the parameter or initial condition maps. Check if symbolic variable names have been reassigned. + The following keys are invalid: + """ + +struct InvalidKeyError <: Exception + vars::Any + params::Any +end + +function Base.showerror(io::IO, e::InvalidKeyError) + println(io, BAD_KEY_MESSAGE) + println(io, "u0map: $(join(e.vars, ", "))") + println(io, "pmap: $(join(e.params, ", "))") +end + +function SciMLBase.detect_cycles(sys::AbstractSystem, varmap::Dict{Any, Any}, vars) + varmap = AnyDict(unwrap(k) => unwrap(v) for (k, v) in varmap) + vars = map(unwrap, vars) + cycles = check_substitution_cycles(varmap, vars) + return !isempty(cycles) +end + +############## +# Legacy functions for backward compatibility +############## + +""" + u0, p, defs = get_u0_p(sys, u0map, parammap; use_union=true, tofloat=true) + +Take dictionaries with initial conditions and parameters and convert them to numeric arrays `u0` and `p`. Also return the merged dictionary `defs` containing the entire operating point. +""" +function get_u0_p(sys, + u0map, + parammap = nothing; + t0 = nothing, + tofloat = true, + use_union = true, + symbolic_u0 = false) + dvs = unknowns(sys) + ps = parameters(sys; initial_parameters = true) + + defs = defaults(sys) + if t0 !== nothing + defs[get_iv(sys)] = t0 + end + if parammap !== nothing + defs = mergedefaults(defs, parammap, ps) + end + if u0map isa Vector && eltype(u0map) <: Pair + u0map = Dict(u0map) + end + if u0map isa Dict + allobs = Set(getproperty.(observed(sys), :lhs)) + if any(in(allobs), keys(u0map)) + u0s_in_obs = filter(in(allobs), keys(u0map)) + @warn "Observed variables cannot be assigned initial values. Initial values for $u0s_in_obs will be ignored." + end + end + obs = filter!(x -> !(x[1] isa Number), map(x -> x.rhs => x.lhs, observed(sys))) + observedmap = isempty(obs) ? Dict() : todict(obs) + defs = mergedefaults(defs, observedmap, u0map, dvs) + for (k, v) in defs + if Symbolics.isarraysymbolic(k) + ks = scalarize(k) + length(ks) == length(v) || error("$k has default value $v with unmatched size") + for (kk, vv) in zip(ks, v) + if !haskey(defs, kk) + defs[kk] = vv + end + end + end + end + + if symbolic_u0 + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = false, use_union = false) + else + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat, use_union) + end + p = varmap_to_vars(parammap, ps; defaults = defs, tofloat, use_union) + p = p === nothing ? SciMLBase.NullParameters() : p + t0 !== nothing && delete!(defs, get_iv(sys)) + u0, p, defs +end + +function get_u0( + sys, u0map, parammap = nothing; symbolic_u0 = false, + toterm = default_toterm, t0 = nothing, use_union = true) + dvs = unknowns(sys) + ps = parameters(sys) + defs = defaults(sys) + if t0 !== nothing + defs[get_iv(sys)] = t0 + end + if parammap !== nothing + defs = mergedefaults(defs, parammap, ps) + end + + # Convert observed equations "lhs ~ rhs" into defaults. + # Use the order "lhs => rhs" by default, but flip it to "rhs => lhs" + # if "lhs" is known by other means (parameter, another default, ...) + # TODO: Is there a better way to determine which equations to flip? + obs = map(x -> x.lhs => x.rhs, observed(sys)) + obs = map(x -> x[1] in keys(defs) ? reverse(x) : x, obs) + obs = filter!(x -> !(x[1] isa Number), obs) # exclude e.g. "0 => x^2 + y^2 - 25" + obsmap = isempty(obs) ? Dict() : todict(obs) + + defs = mergedefaults(defs, obsmap, u0map, dvs) + if symbolic_u0 + u0 = varmap_to_vars( + u0map, dvs; defaults = defs, tofloat = false, use_union = false, toterm) + else + u0 = varmap_to_vars(u0map, dvs; defaults = defs, tofloat = true, use_union, toterm) + end + t0 !== nothing && delete!(defs, get_iv(sys)) + return u0, defs +end diff --git a/src/systems/systems.jl b/src/systems/systems.jl index 78cc78989f..0f8633f31f 100644 --- a/src/systems/systems.jl +++ b/src/systems/systems.jl @@ -26,7 +26,7 @@ topological sort of the observed equations in `sys`. + `fully_determined=true` controls whether or not an error will be thrown if the number of equations don't match the number of inputs, outputs, and equations. """ function structural_simplify( - sys::AbstractSystem, io = nothing; simplify = false, split = true, + sys::AbstractSystem, io = nothing; additional_passes = [], simplify = false, split = true, allow_symbolic = false, allow_parameter = true, conservative = false, fully_determined = true, kwargs...) isscheduled(sys) && throw(RepeatedStructuralSimplificationError()) @@ -42,24 +42,17 @@ function structural_simplify( if newsys isa DiscreteSystem && any(eq -> symbolic_type(eq.lhs) == NotSymbolic(), equations(newsys)) error(""" - Encountered algebraic equations when simplifying discrete system. This is \ - not yet supported. + Encountered algebraic equations when simplifying discrete system. Please construct \ + an ImplicitDiscreteSystem instead. """) end + for pass in additional_passes + newsys = pass(newsys) + end if newsys isa ODESystem || has_parent(newsys) - @set! newsys.parent = complete(sys; split) + @set! newsys.parent = complete(sys; split = false, flatten = false) end newsys = complete(newsys; split) - if has_defaults(newsys) && (defs = get_defaults(newsys)) !== nothing - ks = collect(keys(defs)) # take copy to avoid mutating defs while iterating. - for k in ks - if Symbolics.isarraysymbolic(k) && Symbolics.shape(k) !== Symbolics.Unknown() - for i in eachindex(k) - defs[k[i]] = defs[k][i] - end - end - end - end if newsys′ isa Tuple idxs = [parameter_index(newsys, i) for i in io[1]] return newsys, idxs @@ -72,6 +65,10 @@ function __structural_simplify(sys::JumpSystem, args...; kwargs...) return sys end +function __structural_simplify(sys::SDESystem, args...; kwargs...) + return __structural_simplify(ODESystem(sys), args...; kwargs...) +end + function __structural_simplify(sys::AbstractSystem, io = nothing; simplify = false, kwargs...) sys = expand_connections(sys) @@ -152,8 +149,68 @@ function __structural_simplify(sys::AbstractSystem, io = nothing; simplify = fal noise_eqs = sorted_g_rows is_scalar_noise = false end - return SDESystem(full_equations(ode_sys), noise_eqs, + + noise_eqs = StructuralTransformations.tearing_substitute_expr(ode_sys, noise_eqs) + ssys = SDESystem(Vector{Equation}(full_equations(ode_sys)), noise_eqs, get_iv(ode_sys), unknowns(ode_sys), parameters(ode_sys); - name = nameof(ode_sys), is_scalar_noise) + name = nameof(ode_sys), is_scalar_noise, observed = observed(ode_sys), defaults = defaults(sys), + parameter_dependencies = parameter_dependencies(sys), assertions = assertions(sys), + guesses = guesses(sys), initialization_eqs = initialization_equations(sys)) + @set! ssys.tearing_state = get_tearing_state(ode_sys) + return ssys + end +end + +""" + $(TYPEDSIGNATURES) + +Given a system that has been simplified via `structural_simplify`, return a `Dict` mapping +variables of the system to equations that are used to solve for them. This includes +observed variables. + +# Keyword Arguments + +- `rename_dummy_derivatives`: Whether to rename dummy derivative variable keys into their + `Differential` forms. For example, this would turn the key `yˍt(t)` into + `Differential(t)(y(t))`. +""" +function map_variables_to_equations(sys::AbstractSystem; rename_dummy_derivatives = true) + if !has_tearing_state(sys) + throw(ArgumentError("$(typeof(sys)) is not supported.")) + end + ts = get_tearing_state(sys) + if ts === nothing + throw(ArgumentError("`map_variables_to_equations` requires a simplified system. Call `structural_simplify` on the system before calling this function.")) + end + + dummy_sub = Dict() + if rename_dummy_derivatives && has_schedule(sys) && (sc = get_schedule(sys)) !== nothing + dummy_sub = Dict(v => k for (k, v) in sc.dummy_sub if isequal(default_toterm(k), v)) end + + mapping = Dict{Union{Num, BasicSymbolic}, Equation}() + eqs = equations(sys) + for eq in eqs + isdifferential(eq.lhs) || continue + var = arguments(eq.lhs)[1] + var = get(dummy_sub, var, var) + mapping[var] = eq + end + + graph = ts.structure.graph + algvars = BitSet(findall( + Base.Fix1(StructuralTransformations.isalgvar, ts.structure), 1:ndsts(graph))) + algeqs = BitSet(findall(1:nsrcs(graph)) do eq + all(!Base.Fix1(isdervar, ts.structure), 𝑠neighbors(graph, eq)) + end) + alge_var_eq_matching = complete(maximal_matching(graph, in(algeqs), in(algvars))) + for (i, eq) in enumerate(alge_var_eq_matching) + eq isa Unassigned && continue + mapping[get(dummy_sub, ts.fullvars[i], ts.fullvars[i])] = eqs[eq] + end + for eq in observed(sys) + mapping[get(dummy_sub, eq.lhs, eq.lhs)] = eq + end + + return mapping end diff --git a/src/systems/systemstructure.jl b/src/systems/systemstructure.jl index 1b0b58d09a..d27e5c93a1 100644 --- a/src/systems/systemstructure.jl +++ b/src/systems/systemstructure.jl @@ -140,16 +140,20 @@ get_fullvars(ts::TransformationState) = ts.fullvars has_equations(::TransformationState) = true Base.@kwdef mutable struct SystemStructure - # Maps the (index of) a variable to the (index of) the variable describing - # its derivative. + """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 @@ -197,7 +201,9 @@ function complete!(s::SystemStructure) end mutable struct TearingState{T <: AbstractSystem} <: AbstractTearingState{T} + """The system of equations.""" sys::T + """The set of variables of the system.""" fullvars::Vector structure::SystemStructure extra_eqs::Vector @@ -293,7 +299,6 @@ function TearingState(sys; quick_cancel = false, check = true) end v = scalarize(v) if v isa AbstractArray - v = setmetadata.(v, VariableIrreducible, true) append!(varsvec, v) else push!(varsvec, v) @@ -346,6 +351,8 @@ function TearingState(sys; quick_cancel = false, check = true) eqs[i] = eqs[i].lhs ~ rhs end end + + ### Handle discrete variables lowest_shift = Dict() for var in fullvars if ModelingToolkit.isoperator(var, ModelingToolkit.Shift) @@ -430,7 +437,7 @@ function TearingState(sys; quick_cancel = false, check = true) ts = TearingState(sys, fullvars, SystemStructure(complete(var_to_diff), complete(eq_to_diff), - complete(graph), nothing, var_types, sys isa DiscreteSystem), + complete(graph), nothing, var_types, sys isa AbstractDiscreteSystem), Any[]) if sys isa DiscreteSystem ts = shift_discrete_system(ts) @@ -464,9 +471,11 @@ function shift_discrete_system(ts::TearingState) vars!(discvars, eq; op = Union{Sample, Hold}) end iv = get_iv(sys) + discmap = Dict(k => StructuralTransformations.simplify_shifts(Shift(iv, 1)(k)) for k in discvars if any(isequal(k), fullvars) && !isa(operation(k), Union{Sample, Hold})) + for i in eachindex(fullvars) fullvars[i] = StructuralTransformations.simplify_shifts(fast_substitute( fullvars[i], discmap; operator = Union{Sample, Hold})) @@ -661,7 +670,9 @@ function structural_simplify!(state::TearingState, io = nothing; simplify = fals @set! sys.defaults = merge(ModelingToolkit.defaults(sys), Dict(v => 0.0 for v in Iterators.flatten(inputs))) end - ps = [setmetadata(sym, VariableTimeDomain, get(time_domains, sym, Continuous)) + ps = [sym isa CallWithMetadata ? sym : + setmetadata( + sym, VariableTimeDomain, get(time_domains, sym, ContinuousClock())) for sym in get_ps(sys)] @set! sys.ps = ps else @@ -676,7 +687,11 @@ function _structural_simplify!(state::TearingState, io; simplify = false, check_consistency = true, fully_determined = true, warn_initialize_determined = false, dummy_derivative = true, kwargs...) - check_consistency &= fully_determined + if fully_determined isa Bool + check_consistency &= fully_determined + else + check_consistency = true + end has_io = io !== nothing orig_inputs = Set() if has_io @@ -689,7 +704,8 @@ function _structural_simplify!(state::TearingState, io; simplify = false, end sys, mm = ModelingToolkit.alias_elimination!(state; kwargs...) if check_consistency - ModelingToolkit.check_consistency(state, orig_inputs) + fully_determined = ModelingToolkit.check_consistency( + state, orig_inputs; nothrow = fully_determined === nothing) end if fully_determined && dummy_derivative sys = ModelingToolkit.dummy_derivative( diff --git a/src/utils.jl b/src/utils.jl index 095da9e247..1884a91c19 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -202,6 +202,20 @@ function check_equations(eqs, 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. """ @@ -222,7 +236,8 @@ function collect_ivs_from_nested_operator!(ivs, x, target_op) end function iv_from_nested_derivative(x, op = Differential) - if iscall(x) && operation(x) == getindex + 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) : @@ -244,6 +259,13 @@ 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) @@ -252,6 +274,7 @@ 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 @@ -260,8 +283,20 @@ function collect_defaults!(defs, vars) 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) @@ -338,7 +373,7 @@ 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) +isdiffeq(eq) = isdifferential(eq.lhs) || isoperator(eq.lhs, Shift) isvariable(x::Num)::Bool = isvariable(value(x)) function isvariable(x)::Bool @@ -371,7 +406,11 @@ 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) - foldl((x, y) -> vars!(x, unwrap(y); op = op), exprs; init = Set()) + 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) @@ -379,8 +418,31 @@ function vars!(vars, eq::Equation; op = Differential) 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) @@ -463,34 +525,120 @@ function find_derivatives!(vars, expr, f) return vars end -function collect_vars!(unknowns, parameters, expr, iv; op = Differential) +""" + $(TYPEDSIGNATURES) + +Search through equations and parameter dependencies of `sys`, where sys is at a depth of +`depth` from the root system, looking for variables scoped to the root system. Also +recursively searches through all subsystems of `sys`, increasing the depth if it is not +`-1`. A depth of `-1` indicates searching for variables with `GlobalScope`. +""" +function collect_scoped_vars!(unknowns, parameters, sys, iv; depth = 1, op = Differential) + if has_eqs(sys) + for eq in equations(sys) + eqtype_supports_collect_vars(eq) || continue + if eq isa Equation + eq.lhs isa Union{Symbolic, Number} || continue + end + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + if has_parameter_dependencies(sys) + for eq in parameter_dependencies(sys) + if eq isa Pair + collect_vars!(unknowns, parameters, eq, iv; depth, op) + else + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + end + if has_constraints(sys) + for eq in constraints(sys) + eqtype_supports_collect_vars(eq) || continue + collect_vars!(unknowns, parameters, eq, iv; depth, op) + end + end + if has_op(sys) + collect_vars!(unknowns, parameters, objective(sys), iv; depth, op) + end +end + +function collect_vars!(unknowns, parameters, expr, iv; depth = 0, op = Differential) if issym(expr) - collect_var!(unknowns, parameters, expr, iv) + collect_var!(unknowns, parameters, expr, iv; depth) else for var in vars(expr; op) if iscall(var) && operation(var) isa Differential var, _ = var_from_nested_derivative(var) end - collect_var!(unknowns, parameters, var, iv) + collect_var!(unknowns, parameters, var, iv; depth) end end return nothing end -function collect_var!(unknowns, parameters, var, iv) +""" + $(TYPEDSIGNATURES) + +Indicate whether the given equation type (Equation, Pair, etc) supports `collect_vars!`. +Can be dispatched by higher-level libraries to indicate support. +""" +eqtype_supports_collect_vars(eq) = false +eqtype_supports_collect_vars(eq::Equation) = true +eqtype_supports_collect_vars(eq::Inequality) = true +eqtype_supports_collect_vars(eq::Pair) = true + +function collect_vars!(unknowns, parameters, eq::Union{Equation, Inequality}, iv; + depth = 0, op = Differential) + collect_vars!(unknowns, parameters, eq.lhs, iv; depth, op) + collect_vars!(unknowns, parameters, eq.rhs, iv; depth, op) + return nothing +end + +function collect_vars!(unknowns, parameters, p::Pair, iv; depth = 0, op = Differential) + collect_vars!(unknowns, parameters, p[1], iv; depth, op) + collect_vars!(unknowns, parameters, p[2], iv; depth, op) + return nothing +end + +function collect_var!(unknowns, parameters, var, iv; depth = 0) isequal(var, iv) && return nothing - if isparameter(var) || (iscall(var) && isparameter(operation(var))) + check_scope_depth(getmetadata(var, SymScope, LocalScope()), depth) || return nothing + var = setmetadata(var, SymScope, LocalScope()) + if iscalledparameter(var) + callable = getcalledparameter(var) + push!(parameters, callable) + collect_vars!(unknowns, parameters, arguments(var), iv) + elseif isparameter(var) || (iscall(var) && isparameter(operation(var))) push!(parameters, var) elseif !isconstant(var) push!(unknowns, var) end # Add also any parameters that appear only as defaults in the var - if hasdefault(var) - collect_vars!(unknowns, parameters, getdefault(var), iv) + if hasdefault(var) && (def = getdefault(var)) !== missing + collect_vars!(unknowns, parameters, def, iv) end return nothing end +""" + $(TYPEDSIGNATURES) + +Check if the given `scope` is at a depth of `depth` from the root system. Only +returns `true` for `scope::GlobalScope` if `depth == -1`. +""" +function check_scope_depth(scope, depth) + if scope isa LocalScope + return depth == 0 + elseif scope isa ParentScope + return depth > 0 && check_scope_depth(scope.parent, depth - 1) + elseif scope isa DelayParentScope + return depth >= scope.N && check_scope_depth(scope.parent, depth - scope.N - 1) + elseif scope isa GlobalScope + return depth == -1 + end +end + """ Find all the symbolic constants of some equations or terms and return them as a vector. """ @@ -500,6 +648,8 @@ function collect_constants(x) return constants end +collect_constants!(::Any, ::Symbol) = nothing + function collect_constants!(constants, arr::AbstractArray) for el in arr collect_constants!(constants, el) @@ -535,6 +685,15 @@ function collect_constants!(constants, expr::Symbolic) end end +function collect_constants!(constants, expr::Union{ConstantRateJump, VariableRateJump}) + collect_constants!(constants, expr.rate) + collect_constants!(constants, expr.affect!) +end + +function collect_constants!(constants, ::MassActionJump) + return constants +end + """ Replace symbolic constants with their literal values """ @@ -592,7 +751,12 @@ end function get_cmap(sys, exprs = nothing) #Inject substitutions for constants => values - cs = collect_constants([get_eqs(sys); get_observed(sys)]) #ctrls? what else? + buffer = [] + has_eqs(sys) && append!(buffer, collect(get_eqs(sys))) + has_observed(sys) && append!(buffer, collect(get_observed(sys))) + has_op(sys) && push!(buffer, get_op(sys)) + has_constraints(sys) && append!(buffer, get_constraints(sys)) + cs = collect_constants(buffer) #ctrls? what else? if !empty_substitutions(sys) cs = [cs; collect_constants(get_substitutions(sys).subs)] end @@ -810,27 +974,6 @@ function Base.iterate(it::StatefulBFS, queue = (eltype(it)[(0, it.t)])) return (lv, t), queue end -function jacobian_wrt_vars(pf::F, p, input_idxs, chunk::C) where {F, C} - E = eltype(p) - tag = ForwardDiff.Tag(pf, E) - T = typeof(tag) - dualtype = ForwardDiff.Dual{T, E, ForwardDiff.chunksize(chunk)} - p_big = similar(p, dualtype) - copyto!(p_big, p) - p_closure = let pf = pf, - input_idxs = input_idxs, - p_big = p_big - - function (p_small_inner) - p_big[input_idxs] .= p_small_inner - pf(p_big) - end - end - p_small = p[input_idxs] - cfg = ForwardDiff.JacobianConfig(p_closure, p_small, chunk, tag) - ForwardDiff.jacobian(p_closure, p_small, cfg, Val(false)) -end - function fold_constants(ex) if iscall(ex) maketerm(typeof(ex), operation(ex), map(fold_constants, arguments(ex)), @@ -876,3 +1019,283 @@ end diff2term_with_unit(x, t) = _with_unit(diff2term, x, t) lower_varname_with_unit(var, iv, order) = _with_unit(lower_varname, var, iv, iv, order) +shift2term_with_unit(x, t) = _with_unit(shift2term, x, t) +lower_shift_varname_with_unit(var, iv) = _with_unit(lower_shift_varname, var, iv, iv) + +""" + $(TYPEDSIGNATURES) + +Check if `sym` represents a symbolic floating point number or array of such numbers. +""" +function is_variable_floatingpoint(sym) + sym = unwrap(sym) + T = symtype(sym) + return T == Real || T <: AbstractFloat || T <: AbstractArray{Real} || + T <: AbstractArray{<:AbstractFloat} +end + +""" + $(TYPEDSIGNATURES) + +Return the `DiCMOBiGraph` denoting the dependencies between observed equations `eqs`. +""" +function observed_dependency_graph(eqs::Vector{Equation}) + for eq in eqs + if symbolic_type(eq.lhs) == NotSymbolic() + error("All equations must be observed equations of the form `var ~ expr`. Got $eq") + end + end + graph, assigns = observed2graph(eqs, getproperty.(eqs, (:lhs,))) + matching = complete(Matching(Vector{Union{Unassigned, Int}}(assigns))) + return DiCMOBiGraph{false}(graph, matching) +end + +""" + $(TYPEDSIGNATURES) + +Return the indexes of observed equations of `sys` used by expression `exprs`. + +Keyword arguments: +- `involved_vars`: A collection of the variables involved in `exprs`. This is the set of + variables which will be explored to find dependencies on observed equations. Typically, + providing this keyword is not necessary and is only useful to avoid repeatedly calling + `vars(exprs)` +- `obs`: the list of observed equations. +- `available_vars`: If `exprs` involves a variable `x[1]`, this function will look for + observed equations whose LHS is `x[1]` OR `x`. Sometimes, the latter is not required + since `x[1]` might already be present elsewhere in the generated code (e.g. an argument + to the function) but other elements of `x` are part of the observed equations, thus + requiring them to be obtained from the equation for `x`. Any variable present in + `available_vars` will not be searched for in the observed equations. +""" +function observed_equations_used_by(sys::AbstractSystem, exprs; + involved_vars = vars(exprs; op = Union{Shift, Differential}), obs = observed(sys), available_vars = []) + obsvars = getproperty.(obs, :lhs) + graph = observed_dependency_graph(obs) + if !(available_vars isa Set) + available_vars = Set(available_vars) + end + + obsidxs = BitSet() + for sym in involved_vars + sym in available_vars && continue + arrsym = iscall(sym) && operation(sym) === getindex ? arguments(sym)[1] : nothing + idx = findfirst(v -> isequal(v, sym) || isequal(v, arrsym), obsvars) + idx === nothing && continue + idx in obsidxs && continue + parents = dfs_parents(graph, idx) + for i in eachindex(parents) + parents[i] == 0 && continue + push!(obsidxs, i) + end + end + + obsidxs = collect(obsidxs) + sort!(obsidxs) + return obsidxs +end + +""" + $(TYPEDSIGNATURES) + +Given an expression `expr`, return a dictionary mapping subexpressions of `expr` that do +not involve variables in `vars` to anonymous symbolic variables. Also return the modified +`expr` with the substitutions indicated by the dictionary. If `expr` is a function +of only `vars`, then all of the returned subexpressions can be precomputed. + +Note that this will only process subexpressions floating point value. Additionally, +array variables must be passed in both scalarized and non-scalarized forms in `vars`. +""" +function subexpressions_not_involving_vars(expr, vars) + expr = unwrap(expr) + vars = map(unwrap, vars) + state = Dict() + newexpr = subexpressions_not_involving_vars!(expr, vars, state) + return state, newexpr +end + +""" + $(TYPEDSIGNATURES) + +Mutating version of `subexpressions_not_involving_vars` which writes to `state`. Only +returns the modified `expr`. +""" +function subexpressions_not_involving_vars!(expr, vars, state::Dict{Any, Any}) + expr = unwrap(expr) + if symbolic_type(expr) == NotSymbolic() + if is_array_of_symbolics(expr) + return map(expr) do el + subexpressions_not_involving_vars!(el, vars, state) + end + end + return expr + end + any(isequal(expr), vars) && return expr + iscall(expr) || return expr + Symbolics.shape(expr) == Symbolics.Unknown() && return expr + haskey(state, expr) && return state[expr] + op = operation(expr) + args = arguments(expr) + # if this is a `getindex` and the getindex-ed value is a `Sym` + # or it is not a called parameter + # OR + # none of `vars` are involved in `expr` + if op === getindex && (issym(args[1]) || !iscalledparameter(args[1])) || + (vs = ModelingToolkit.vars(expr); intersect!(vs, vars); isempty(vs)) + sym = gensym(:subexpr) + stype = symtype(expr) + var = similar_variable(expr, sym) + state[expr] = var + return var + end + + if (op == (+) || op == (*)) && symbolic_type(expr) !== ArraySymbolic() + indep_args = [] + dep_args = [] + for arg in args + _vs = ModelingToolkit.vars(arg) + intersect!(_vs, vars) + if !isempty(_vs) + push!(dep_args, subexpressions_not_involving_vars!(arg, vars, state)) + else + push!(indep_args, arg) + end + end + indep_term = reduce(op, indep_args; init = Int(op == (*))) + indep_term = subexpressions_not_involving_vars!(indep_term, vars, state) + dep_term = reduce(op, dep_args; init = Int(op == (*))) + return op(indep_term, dep_term) + end + newargs = map(args) do arg + subexpressions_not_involving_vars!(arg, vars, state) + end + return maketerm(typeof(expr), op, newargs, metadata(expr)) +end + +""" + $(TYPEDSIGNATURES) + +Create an anonymous symbolic variable of the same shape, size and symtype as `var`, with +name `gensym(name)`. Does not support unsized array symbolics. + +If `use_gensym == false`, will not `gensym` the name. +""" +function similar_variable(var::BasicSymbolic, name = :anon; use_gensym = true) + if use_gensym + name = gensym(name) + end + stype = symtype(var) + sym = Symbolics.variable(name; T = stype) + if size(var) !== () + sym = setmetadata(sym, Symbolics.ArrayShapeCtx, map(Base.OneTo, size(var))) + end + return sym +end + +function guesses_from_metadata!(guesses, vars) + varguesses = [getguess(v) for v in vars] + hasaguess = findall(!isnothing, varguesses) + for i in hasaguess + haskey(guesses, vars[i]) && continue + guesses[vars[i]] = varguesses[i] + end +end + +""" + $(TYPEDSIGNATURES) + +Find all the unknowns and parameters from the equations of a SDESystem or ODESystem. Return re-ordered equations, differential variables, all variables, and parameters. +""" +function process_equations(eqs, iv) + if eltype(eqs) <: AbstractVector + eqs = reduce(vcat, eqs) + end + eqs = collect(eqs) + + diffvars = OrderedSet() + allunknowns = OrderedSet() + ps = OrderedSet() + + # NOTE: this assumes that the order of algebraic equations doesn't matter + # reorder equations such that it is in the form of `diffeq, algeeq` + diffeq = Equation[] + algeeq = Equation[] + # initial loop for finding `iv` + if iv === nothing + for eq in eqs + if !(eq.lhs isa Number) # assume eq.lhs is either Differential or Number + iv = iv_from_nested_derivative(eq.lhs) + break + end + end + end + iv = value(iv) + iv === nothing && throw(ArgumentError("Please pass in independent variables.")) + + compressed_eqs = Equation[] # equations that need to be expanded later, like `connect(a, b)` + for eq in eqs + eq.lhs isa Union{Symbolic, Number} || (push!(compressed_eqs, eq); continue) + collect_vars!(allunknowns, ps, eq, iv) + if isdiffeq(eq) + diffvar, _ = var_from_nested_derivative(eq.lhs) + if check_scope_depth(getmetadata(diffvar, SymScope, LocalScope()), 0) + isequal(iv, iv_from_nested_derivative(eq.lhs)) || + throw(ArgumentError("An ODESystem can only have one independent variable.")) + diffvar in diffvars && + throw(ArgumentError("The differential variable $diffvar is not unique in the system of equations.")) + !has_diffvar_type(diffvar) && + throw(ArgumentError("Differential variable $diffvar has type $(symtype(diffvar)). Differential variables should be of a continuous, non-concrete number type: Real, Complex, AbstractFloat, or Number.")) + push!(diffvars, diffvar) + end + push!(diffeq, eq) + else + push!(algeeq, eq) + end + end + + diffvars, allunknowns, ps, Equation[diffeq; algeeq; compressed_eqs] +end + +function has_diffvar_type(diffvar) + st = symtype(diffvar) + st === Real || eltype(st) === Real || st === Complex || eltype(st) === Complex || + st === Number || eltype(st) === Number || st === AbstractFloat || + eltype(st) === AbstractFloat +end + +""" + $(TYPEDSIGNATURES) + +If `sym isa Symbol`, try and convert it to a symbolic by matching against symbolic +variables in `allsyms`. If `sym` is not a `Symbol` or no match was found, return +`sym` as-is. +""" +function symbol_to_symbolic(sys::AbstractSystem, sym; allsyms = all_symbols(sys)) + sym isa Symbol || return sym + idx = findfirst(x -> (hasname(x) ? getname(x) : Symbol(x)) == sym, allsyms) + idx === nothing && return sym + sym = allsyms[idx] + if iscall(sym) && operation(sym) == getindex + sym = arguments(sym)[1] + end + return sym +end + +""" + $(TYPEDSIGNATURES) + +Check if `var` is present in `varlist`. `iv` is the independent variable of the system, +and should be `nothing` if not applicable. +""" +function var_in_varlist(var, varlist::AbstractSet, iv) + var = unwrap(var) + # simple case + return var in varlist || + # indexed array symbolic, unscalarized array present + (iscall(var) && operation(var) === getindex && arguments(var)[1] in varlist) || + # unscalarized sized array symbolic, all scalarized elements present + (symbolic_type(var) == ArraySymbolic() && is_sized_array_symbolic(var) && + all(x -> x in varlist, collect(var))) || + # delayed variables + (isdelay(var, iv) && var_in_varlist(operation(var)(iv), varlist, iv)) +end diff --git a/src/variables.jl b/src/variables.jl index 767dec686b..f3dd16819d 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -6,14 +6,18 @@ 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{:noise}) = VariableNoiseType Symbolics.option_to_metadata_type(::Val{:input}) = VariableInput Symbolics.option_to_metadata_type(::Val{:output}) = VariableOutput Symbolics.option_to_metadata_type(::Val{:irreducible}) = VariableIrreducible Symbolics.option_to_metadata_type(::Val{:state_priority}) = VariableStatePriority Symbolics.option_to_metadata_type(::Val{:misc}) = VariableMisc +Symbolics.option_to_metadata_type(::Val{:unshifted}) = VariableUnshifted +Symbolics.option_to_metadata_type(::Val{:shift}) = VariableShift """ dump_variable_metadata(var) @@ -29,7 +33,8 @@ ModelingToolkit.dump_variable_metadata(p) """ function dump_variable_metadata(var) uvar = unwrap(var) - vartype, name = get(uvar.metadata, VariableSource, (:unknown, :unknown)) + variable_source, name = Symbolics.getmetadata( + uvar, VariableSource, (:unknown, :unknown)) type = symtype(uvar) if type <: AbstractArray shape = Symbolics.shape(var) @@ -39,14 +44,13 @@ function dump_variable_metadata(var) else shape = nothing end - unit = get(uvar.metadata, VariableUnit, nothing) - connect = get(uvar.metadata, VariableConnectType, nothing) - noise = get(uvar.metadata, VariableNoiseType, nothing) + unit = getunit(uvar) + connect = getconnect(uvar) input = isinput(uvar) || nothing output = isoutput(uvar) || nothing - irreducible = get(uvar.metadata, VariableIrreducible, nothing) - state_priority = get(uvar.metadata, VariableStatePriority, nothing) - misc = get(uvar.metadata, VariableMisc, 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 == "" @@ -57,16 +61,16 @@ function dump_variable_metadata(var) disturbance = isdisturbance(uvar) || nothing tunable = istunable(uvar, isparameter(uvar)) dist = getdist(uvar) - type = symtype(uvar) + variable_type = getvariabletype(uvar) meta = ( var = var, - vartype, + variable_source, name, + variable_type, shape, unit, connect, - noise, input, output, irreducible, @@ -85,12 +89,31 @@ function dump_variable_metadata(var) return NamedTuple(k => v for (k, v) in pairs(meta) if v !== nothing) end +### Connect abstract type AbstractConnectType end struct Equality <: AbstractConnectType end # Equality connection struct Flow <: AbstractConnectType end # sum to 0 struct Stream <: AbstractConnectType end # special stream connector -isvarkind(m, x::Num) = isvarkind(m, value(x)) +""" + 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 @@ -98,21 +121,25 @@ function isvarkind(m, x) getmetadata(x, m, false) end -setinput(x, v) = setmetadata(x, VariableInput, v) -setoutput(x, v) = setmetadata(x, VariableOutput, v) -setio(x, i, o) = setoutput(setinput(x, i), o) +setinput(x, v::Bool) = setmetadata(x, VariableInput, v) +setoutput(x, v::Bool) = setmetadata(x, VariableOutput, v) +setio(x, i::Bool, o::Bool) = setoutput(setinput(x, i), o) + isinput(x) = isvarkind(VariableInput, x) isoutput(x) = isvarkind(VariableOutput, x) + # Before the solvability check, we already have handled IO variables, so # irreducibility is independent from IO. isirreducible(x) = isvarkind(VariableIrreducible, x) +setirreducible(x, v::Bool) = setmetadata(x, VariableIrreducible, v) +state_priority(x::Union{Num, Symbolics.Arr}) = state_priority(unwrap(x)) state_priority(x) = convert(Float64, getmetadata(x, VariableStatePriority, 0.0))::Float64 function default_toterm(x) if iscall(x) && (op = operation(x)) isa Operator if !(op isa Differential) if op isa Shift && op.steps < 0 - return x + return shift2term(x) end x = normalize_to_differential(op)(arguments(x)...) end @@ -140,7 +167,7 @@ function varmap_to_vars(varmap, varlist; defaults = Dict(), check = true, if is_incomplete_initialization || isempty(varmap) if isempty(defaults) if !is_incomplete_initialization && check - isempty(varlist) || throw_missingvars(varlist) + isempty(varlist) || throw(MissingVariablesError(varlist)) end return nothing else @@ -159,15 +186,14 @@ function varmap_to_vars(varmap, varlist; defaults = Dict(), check = true, vals = if eltype(varmap) <: Pair # `varmap` is a dict or an array of pairs varmap = todict(varmap) - _varmap_to_vars(varmap, varlist; defaults = defaults, check = check, - toterm = toterm) + _varmap_to_vars(varmap, varlist; defaults, check, toterm) else # plain array-like initialization varmap end promotetoconcrete === nothing && (promotetoconcrete = container_type <: AbstractArray) if promotetoconcrete - vals = promote_to_concrete(vals; tofloat = tofloat, use_union = use_union) + vals = promote_to_concrete(vals; tofloat, use_union) end if isempty(vals) @@ -192,7 +218,7 @@ end function Base.showerror(io::IO, e::MissingVariablesError) println(io, MISSING_VARIABLES_MESSAGE) - println(io, e.vars) + println(io, join(e.vars, ", ")) end function _varmap_to_vars(varmap::Dict, varlist; defaults = Dict(), check = false, @@ -241,7 +267,7 @@ end end struct IsHistory end -ishistory(x) = ishistory(unwrap(x)) +ishistory(x::Num) = ishistory(unwrap(x)) ishistory(x::Symbolic) = getmetadata(x, IsHistory, false) hist(x, t) = wrap(hist(unwrap(x), t)) function hist(x::Symbolic, t) @@ -253,7 +279,6 @@ end ## Bounds ====================================================================== struct VariableBounds end Symbolics.option_to_metadata_type(::Val{:bounds}) = VariableBounds -getbounds(x::Num) = getbounds(Symbolics.unwrap(x)) """ getbounds(x) @@ -265,10 +290,35 @@ Create parameters with bounds like this @parameters p [bounds=(-1, 1)] ``` """ -function getbounds(x) +function getbounds(x::Union{Num, Symbolics.Arr, SymbolicUtils.Symbolic}) + x = unwrap(x) p = Symbolics.getparent(x, nothing) - p === nothing || (x = p) - Symbolics.getmetadata(x, VariableBounds, (-Inf, Inf)) + 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 """ @@ -279,7 +329,7 @@ See also [`getbounds`](@ref). """ function hasbounds(x) b = getbounds(x) - isfinite(b[1]) || isfinite(b[2]) + any(isfinite.(b[1]) .|| isfinite.(b[2])) end ## Disturbance ================================================================= @@ -369,7 +419,7 @@ end ## System interface """ - tunable_parameters(sys, p = parameters(sys); default=true) + tunable_parameters(sys, p = parameters(sys; initial_parameters = true); default=true) Get all parameters of `sys` that are marked as `tunable`. @@ -381,9 +431,15 @@ Create a tunable parameter by @parameters u [tunable=true] ``` -See also [`getbounds`](@ref), [`istunable`](@ref) +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); default = true) +function tunable_parameters( + sys, p = parameters(sys; initial_parameters = true); default = true) filter(x -> istunable(x, default), p) end @@ -459,7 +515,9 @@ $(SIGNATURES) Define one or more Brownian variables. """ macro brownian(xs...) - all(x -> x isa Symbol || Meta.isexpr(x, :call) && x.args[1] == :$, xs) || + all( + x -> x isa Symbol || Meta.isexpr(x, :call) && x.args[1] == :$ || Meta.isexpr(x, :$), + xs) || error("@brownian only takes scalar expressions!") Symbolics._parse_vars(:brownian, Real, @@ -486,6 +544,16 @@ 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) @@ -503,3 +571,44 @@ function get_default_or_guess(x) return getguess(x) end end + +## Miscellaneous metadata ====================================================================== +""" + getmisc(x) + +Fetch any miscellaneous data associated with symbolic variable `x`. +See also [`hasmisc(x)`](@ref). +""" +getmisc(x::Num) = getmisc(unwrap(x)) +getmisc(x::Symbolic) = Symbolics.getmetadata(x, VariableMisc, nothing) +""" + hasmisc(x) + +Determine whether a symbolic variable `x` has misc +metadata associated with it. + +See also [`getmisc(x)`](@ref). +""" +hasmisc(x) = getmisc(x) !== nothing +setmisc(x, miscdata) = setmetadata(x, VariableMisc, miscdata) + +## Units ====================================================================== +""" + getunit(x) + +Fetch the unit associated with variable `x`. This function is a metadata getter for an individual variable, while `get_unit` is used for unit inference on more complicated sdymbolic expressions. +""" +getunit(x::Num) = getunit(unwrap(x)) +getunit(x::Symbolic) = Symbolics.getmetadata(x, VariableUnit, nothing) +""" + hasunit(x) + +Check if the variable `x` has a unit. +""" +hasunit(x) = getunit(x) !== nothing + +getunshifted(x::Num) = getunshifted(unwrap(x)) +getunshifted(x::Symbolic) = Symbolics.getmetadata(x, VariableUnshifted, nothing) + +getshift(x::Num) = getshift(unwrap(x)) +getshift(x::Symbolic) = Symbolics.getmetadata(x, VariableShift, 0) diff --git a/test/accessor_functions.jl b/test/accessor_functions.jl new file mode 100644 index 0000000000..7ce477155b --- /dev/null +++ b/test/accessor_functions.jl @@ -0,0 +1,171 @@ +### Preparations ### + +# Fetch packages. +using ModelingToolkit, Test +using ModelingToolkit: t_nounits as t, D_nounits as D +import ModelingToolkit: get_ps, get_unknowns, get_observed, get_eqs, get_continuous_events, + get_discrete_events, namespace_equations +import ModelingToolkit: parameters_toplevel, unknowns_toplevel, equations_toplevel, + continuous_events_toplevel, discrete_events_toplevel + +# Creates helper functions. +function all_sets_equal(args...) + for arg in args[2:end] + issetequal(args[1], arg) || return false + end + return true +end +function sym_issubset(set1, set2) + for sym1 in set1 + any(isequal(sym1, sym2) for sym2 in set2) || return false + end + return true +end + +### Basic Tests ### + +# Checks `toplevel = false` argument for various accessors (currently only for `ODESystem`s). +# Compares to `` version, and `get_` functions. +# Checks accessors for parameters, unknowns, equations, observables, and events. +# Some tests looks funny (caused by the formatter). +let + # Prepares model components. + @parameters p_top p_mid1 p_mid2 p_bot d + @variables X_top(t) X_mid1(t) X_mid2(t) X_bot(t) Y(t) O(t) + + # Creates the systems (individual and hierarchical). + eqs_top = [ + D(X_top) ~ p_top - d * X_top, + D(Y) ~ log(X_top) - Y^2 + 3.0, + O ~ (p_top + d) * X_top + Y + ] + eqs_mid1 = [ + D(X_mid1) ~ p_mid1 - d * X_mid1^2, + D(Y) ~ D(X_mid1) - Y^3, + O ~ (p_mid1 + d) * X_mid1 + Y + ] + eqs_mid2 = [ + D(X_mid2) ~ p_mid2 - d * X_mid2, + X_mid2^3 ~ log(X_mid2 + Y) - Y^2 + 3.0, + O ~ (p_mid2 + d) * X_mid2 + Y + ] + eqs_bot = [ + D(X_bot) ~ p_bot - d * X_bot, + D(Y) ~ -Y^3, + O ~ (p_bot + d) * X_bot + Y + ] + cevs = [[t ~ 1.0] => [Y ~ Y + 2.0]] + devs = [(t == 2.0) => [Y ~ Y + 2.0]] + @named sys_bot = ODESystem( + eqs_bot, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid2 = ODESystem( + eqs_mid2, t; systems = [], continuous_events = cevs, discrete_events = devs) + @named sys_mid1 = ODESystem( + eqs_mid1, t; systems = [sys_bot], continuous_events = cevs, discrete_events = devs) + @named sys_top = ODESystem(eqs_top, t; systems = [sys_mid1, sys_mid2], + continuous_events = cevs, discrete_events = devs) + sys_bot_comp = complete(sys_bot) + sys_mid2_comp = complete(sys_mid2) + sys_mid1_comp = complete(sys_mid1) + sys_top_comp = complete(sys_top) + sys_bot_ss = structural_simplify(sys_bot) + sys_mid2_ss = structural_simplify(sys_mid2) + sys_mid1_ss = structural_simplify(sys_mid1) + sys_top_ss = structural_simplify(sys_top) + + # Checks `parameters1. + @test all_sets_equal(parameters.([sys_bot, sys_bot_comp, sys_bot_ss])..., [d, p_bot]) + @test all_sets_equal(parameters.([sys_mid1, sys_mid1_comp, sys_mid1_ss])..., + [d, p_mid1, sys_bot.d, sys_bot.p_bot]) + @test all_sets_equal( + parameters.([sys_mid2, sys_mid2_comp, sys_mid2_ss])..., [d, p_mid2]) + @test all_sets_equal(parameters.([sys_top, sys_top_comp, sys_top_ss])..., + [d, p_top, sys_mid1.d, sys_mid1.p_mid1, sys_mid1.sys_bot.d, + sys_mid1.sys_bot.p_bot, sys_mid2.d, sys_mid2.p_mid2]) + + # Checks `parameters_toplevel`. Compares to known parameters and also checks that + # these are subset of what `get_ps` returns. + @test all_sets_equal( + parameters_toplevel.([sys_bot, sys_bot_comp, sys_bot_ss])..., [d, p_bot]) + @test all_sets_equal( + parameters_toplevel.([sys_mid1, sys_mid1_comp, sys_mid1_ss])..., + [d, p_mid1]) + @test all_sets_equal( + parameters_toplevel.([sys_mid2, sys_mid2_comp, sys_mid2_ss])..., + [d, p_mid2]) + @test all_sets_equal( + parameters_toplevel.([sys_top, sys_top_comp, sys_top_ss])..., [d, p_top]) + @test all(sym_issubset(parameters_toplevel(sys), get_ps(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + + # Checks `unknowns`. O(t) is eliminated by `structural_simplify` and + # must be considered separately. + @test all_sets_equal(unknowns.([sys_bot, sys_bot_comp])..., [O, Y, X_bot]) + @test all_sets_equal(unknowns.([sys_bot_ss])..., [Y, X_bot]) + @test all_sets_equal(unknowns.([sys_mid1, sys_mid1_comp])..., + [O, Y, X_mid1, sys_bot.Y, sys_bot.O, sys_bot.X_bot]) + @test all_sets_equal(unknowns.([sys_mid1_ss])..., [Y, X_mid1, sys_bot.Y, sys_bot.X_bot]) + @test all_sets_equal(unknowns.([sys_mid2, sys_mid2_comp])..., [O, Y, X_mid2]) + @test all_sets_equal(unknowns.([sys_mid2_ss])..., [Y, X_mid2]) + @test all_sets_equal(unknowns.([sys_top, sys_top_comp])..., + [O, Y, X_top, sys_mid1.O, sys_mid1.Y, sys_mid1.X_mid1, + sys_mid1.sys_bot.O, sys_mid1.sys_bot.Y, sys_mid1.sys_bot.X_bot, + sys_mid2.O, sys_mid2.Y, sys_mid2.X_mid2]) + @test all_sets_equal(unknowns.([sys_top_ss])..., + [Y, X_top, sys_mid1.Y, sys_mid1.X_mid1, sys_mid1.sys_bot.Y, + sys_mid1.sys_bot.X_bot, sys_mid2.Y, sys_mid2.X_mid2]) + + # Checks `unknowns_toplevel`. Note that O is not eliminated here (as we go back + # to original parent system). Also checks that outputs are subsets of what `get_unknowns` returns. + @test all_sets_equal( + unknowns_toplevel.([sys_bot, sys_bot_comp, sys_bot_ss])..., [O, Y, X_bot]) + @test all_sets_equal( + unknowns_toplevel.([sys_mid1, sys_mid1_comp])..., [O, Y, X_mid1]) + @test all_sets_equal( + unknowns_toplevel.([sys_mid2, sys_mid2_comp])..., [O, Y, X_mid2]) + @test all_sets_equal( + unknowns_toplevel.([sys_top, sys_top_comp])..., [O, Y, X_top]) + @test all(sym_issubset(unknowns_toplevel(sys), get_unknowns(sys)) + for sys in [sys_bot, sys_mid1, sys_mid2, sys_top]) + + # Checks `equations`. Do not check ss equations as these might potentially + # be structurally simplified to new equations. + @test all_sets_equal(equations.([sys_bot, sys_bot_comp])..., eqs_bot) + @test all_sets_equal( + equations.([sys_mid1, sys_mid1_comp])..., [eqs_mid1; namespace_equations(sys_bot)]) + @test all_sets_equal(equations.([sys_mid2, sys_mid2_comp])..., eqs_mid2) + @test all_sets_equal(equations.([sys_top, sys_top_comp])..., + [eqs_top; namespace_equations(sys_mid1); namespace_equations(sys_mid2)]) + + # Checks `equations_toplevel`. Do not check ss equations directly as these + # might potentially be structurally simplified to new equations. Do not check + @test all_sets_equal(equations_toplevel.([sys_bot])..., eqs_bot) + @test all_sets_equal( + equations_toplevel.([sys_mid1])..., eqs_mid1) + @test all_sets_equal( + equations_toplevel.([sys_mid2])..., eqs_mid2) + @test all_sets_equal(equations_toplevel.([sys_top])..., eqs_top) + @test all(sym_issubset(equations_toplevel(sys), get_eqs(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + + # Checks `continuous_events_toplevel` and `discrete_events_toplevel` (straightforward + # as I stored the same singe event in all systems). Don't check for non-toplevel cases as + # technically not needed for these tests and name spacing the events is a mess. + mtk_cev = ModelingToolkit.SymbolicContinuousCallback.(cevs)[1] + mtk_dev = ModelingToolkit.SymbolicDiscreteCallback.(devs)[1] + @test all_sets_equal( + continuous_events_toplevel.( + [sys_bot, sys_bot_comp, sys_bot_ss, sys_mid1, sys_mid1_comp, sys_mid1_ss, + sys_mid2, sys_mid2_comp, sys_mid2_ss, sys_top, sys_top_comp, sys_top_ss])..., + [mtk_cev]) + @test all_sets_equal( + discrete_events_toplevel.( + [sys_bot, sys_bot_comp, sys_bot_ss, sys_mid1, sys_mid1_comp, sys_mid1_ss, + sys_mid2, sys_mid2_comp, sys_mid2_ss, sys_top, sys_top_comp, sys_top_ss])..., + [mtk_dev]) + @test all(sym_issubset( + continuous_events_toplevel(sys), get_continuous_events(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) + @test all(sym_issubset(discrete_events_toplevel(sys), get_discrete_events(sys)) + for sys in [sys_bot, sys_mid2, sys_mid1, sys_top]) +end diff --git a/test/analysis_points.jl b/test/analysis_points.jl new file mode 100644 index 0000000000..e36afc02f7 --- /dev/null +++ b/test/analysis_points.jl @@ -0,0 +1,186 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary.Blocks +using OrdinaryDiffEq, LinearAlgebra +using Test +using ModelingToolkit: t_nounits as t, D_nounits as D, AnalysisPoint, AbstractSystem +import ModelingToolkit as MTK +using Symbolics: NAMESPACE_SEPARATOR + +@testset "AnalysisPoint is lowered to `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + eqs = [connect(P.output, C.input) + connect(C.output, ap, P.input)] + sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys_ap2 = @test_nowarn expand_connections(sys_ap) + + @test all(eq -> !(eq.lhs isa AnalysisPoint), equations(sys_ap2)) + + eqs = [connect(P.output, C.input) + connect(C.output, P.input)] + sys_normal = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys_normal2 = @test_nowarn expand_connections(sys_normal) + + @test isequal(sys_ap2, sys_normal2) +end + +@testset "Inverse causality throws a warning" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + @test_warn ["1-th argument", "plant_input", "not a output"] connect( + P.input, ap, C.output) + @test_nowarn connect(P.input, ap, C.output; verbose = false) +end + +# also tests `connect(input, name::Symbol, outputs...)` syntax +@testset "AnalysisPoint is accessible via `getproperty`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + eqs = [connect(P.output, C.input), connect(C.output, :plant_input, P.input)] + sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) + ap2 = @test_nowarn sys_ap.plant_input + @test nameof(ap2) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) + @named sys = ODESystem(Equation[], t; systems = [sys_ap]) + ap3 = @test_nowarn sys.hej.plant_input + @test nameof(ap3) == Symbol(join(["sys", "hej", "plant_input"], NAMESPACE_SEPARATOR)) + csys = complete(sys) + ap4 = csys.hej.plant_input + @test nameof(ap4) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) + nsys = toggle_namespacing(sys, false) + ap5 = nsys.hej.plant_input + @test nameof(ap4) == Symbol(join(["hej", "plant_input"], NAMESPACE_SEPARATOR)) +end + +### Ported from MTKStdlib + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) + +ap = AnalysisPoint(:plant_input) +eqs = [connect(P.output, C.input), connect(C.output, ap, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = ODESystem(Equation[], t; systems = [sys]) +nonamespace_sys = toggle_namespacing(nested_sys, false) + +@testset "simplifies and solves" begin + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [P.x => 1], (0, 10)) + sol = solve(prob, Rodas5()) + @test norm(sol.u[1]) >= 1 + @test norm(sol.u[end]) < 1e-6 # This fails without the feedback through C +end + +test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("nonamespace", nonamespace_sys, nonamespace_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) +] + +@testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 +end + +@testset "get_comp_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_comp_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive or negative + @test matrices.D[] == 0 +end + +#= +# Equivalent code using ControlSystems. This can be used to verify the expected results tested for above. +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = 1 # Negative feedback assumed in ControlSystems +S = sensitivity(P, C) # or feedback(1, P*C) +T = comp_sensitivity(P, C) # or feedback(P*C) +=# + +@testset "get_looptransfer - $name" for (name, sys, ap) in test_cases + matrices, _ = get_looptransfer(sys, ap) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 +end + +#= +# Equivalent code using ControlSystems. This can be used to verify the expected results tested for above. +using ControlSystemsBase +P = tf(1.0, [1, 1]) +C = -1 +L = P*C +=# + +@testset "open_loop - $name" for (name, sys, ap) in test_cases + open_sys, (du, u) = open_loop(sys, ap) + matrices, _ = linearize(open_sys, [du], [u]) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 +end + +# Multiple analysis points + +eqs = [connect(P.output, :plant_output, C.input) + connect(C.output, :plant_input, P.input)] +sys = ODESystem(eqs, t, systems = [P, C], name = :hej) +@named nested_sys = ODESystem(Equation[], t; systems = [sys]) + +test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) +] + +@testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 +end + +@testset "linearize - $name" for (name, sys, inputap, outputap) in [ + ("inner", sys, sys.plant_input, sys.plant_output), + ("nested", nested_sys, nested_sys.hej.plant_input, nested_sys.hej.plant_output) +] + inputname = Symbol(join( + MTK.namespace_hierarchy(nameof(inputap))[2:end], NAMESPACE_SEPARATOR)) + outputname = Symbol(join( + MTK.namespace_hierarchy(nameof(outputap))[2:end], NAMESPACE_SEPARATOR)) + @testset "input - $(typeof(input)), output - $(typeof(output))" for (input, output) in [ + (inputap, outputap), + (inputname, outputap), + (inputap, outputname), + (inputname, outputname), + (inputap, [outputap]), + (inputname, [outputap]), + (inputap, [outputname]), + (inputname, [outputname]) + ] + matrices, _ = linearize(sys, input, output) + # Result should be the same as feedpack(P, 1), i.e., the closed-loop transfer function from plant input to plant output + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive + @test matrices.D[] == 0 + end +end + +@testset "linearize - variable output - $name" for (name, sys, input, output) in [ + ("inner", sys, sys.plant_input, P.output.u), + ("nested", nested_sys, nested_sys.hej.plant_input, sys.P.output.u) +] + matrices, _ = linearize(sys, input, [output]) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive + @test matrices.D[] == 0 +end diff --git a/test/basic_transformations.jl b/test/basic_transformations.jl index d9b59408e6..544a89cd29 100644 --- a/test/basic_transformations.jl +++ b/test/basic_transformations.jl @@ -1,34 +1,217 @@ -using ModelingToolkit, OrdinaryDiffEq, Test +using ModelingToolkit, OrdinaryDiffEq, DataInterpolations, Test @independent_variables t -@parameters α β γ δ -@variables x(t) y(t) D = Differential(t) -eqs = [D(x) ~ α * x - β * x * y, - D(y) ~ -δ * y + γ * x * y] +@testset "Liouville transform" begin + @parameters α β γ δ + @variables x(t) y(t) + eqs = [D(x) ~ α * x - β * x * y, D(y) ~ -δ * y + γ * x * y] + @named sys = ODESystem(eqs, t) + sys = complete(sys) -sys = ODESystem(eqs) + u0 = [x => 1.0, y => 1.0] + p = [α => 1.5, β => 1.0, δ => 3.0, γ => 1.0] + tspan = (0.0, 10.0) + prob = ODEProblem(sys, u0, tspan, p) + sol = solve(prob, Tsit5()) -u0 = [x => 1.0, - y => 1.0] + sys2 = liouville_transform(sys) + sys2 = complete(sys2) -p = [α => 1.5, - β => 1.0, - δ => 3.0, - γ => 1.0] + u0 = [x => 1.0, y => 1.0, sys2.trJ => 1.0] + prob = ODEProblem(sys2, u0, tspan, p, jac = true) + sol = solve(prob, Tsit5()) + @test sol[end, end] ≈ 1.0742818931017244 +end -tspan = (0.0, 10.0) -prob = ODEProblem(sys, u0, tspan, p) -sol = solve(prob, Tsit5()) +@testset "Change independent variable (trivial)" begin + @variables x(t) y(t) + eqs1 = [D(D(x)) ~ D(x) + x, D(y) ~ 1] + M1 = ODESystem(eqs1, t; name = :M) + M2 = change_independent_variable(M1, y) + @variables y x(y) yˍt(y) + Dy = Differential(y) + @test Set(equations(M2)) == Set([ + yˍt^2 * (Dy^2)(x) + yˍt * Dy(yˍt) * Dy(x) ~ x + Dy(x) * yˍt, + yˍt ~ 1 + ]) +end -sys2 = liouville_transform(sys) -@variables trJ +@testset "Change independent variable" begin + @variables x(t) y(t) z(t) s(t) + eqs = [ + D(x) ~ y, + D(D(y)) ~ 2 * x * D(y), + z ~ x + D(y), + D(s) ~ 1 / (2 * s) + ] + initialization_eqs = [x ~ 1.0, y ~ 1.0, D(y) ~ 0.0] + M1 = ODESystem(eqs, t; initialization_eqs, name = :M) + M2 = change_independent_variable(M1, s) -u0 = [x => 1.0, - y => 1.0, - trJ => 1.0] + M1 = structural_simplify(M1; allow_symbolic = true) + M2 = structural_simplify(M2; allow_symbolic = true) + prob1 = ODEProblem(M1, [M1.s => 1.0], (1.0, 4.0), []) + prob2 = ODEProblem(M2, [], (1.0, 2.0), []) + sol1 = solve(prob1, Tsit5(); reltol = 1e-10, abstol = 1e-10) + sol2 = solve(prob2, Tsit5(); reltol = 1e-10, abstol = 1e-10) + ts = range(0.0, 1.0, length = 50) + ss = .√(ts) + @test all(isapprox.(sol1(ts, idxs = M1.x), sol2(ss, idxs = M2.x); atol = 1e-7)) && + all(isapprox.(sol1(ts, idxs = M1.y), sol2(ss, idxs = M2.y); atol = 1e-7)) +end -prob = ODEProblem(sys2, u0, tspan, p, jac = true) -sol = solve(prob, Tsit5()) -@test sol[end, end] ≈ 1.0742818931017244 +@testset "Change independent variable (Friedmann equation)" begin + @independent_variables t + D = Differential(t) + @variables a(t) ȧ(t) Ω(t) ϕ(t) + a, ȧ = GlobalScope.([a, ȧ]) + species(w; kw...) = ODESystem([D(Ω) ~ -3(1 + w) * D(a) / a * Ω], t, [Ω], []; kw...) + @named r = species(1 // 3) + @named m = species(0) + @named Λ = species(-1) + eqs = [ + Ω ~ r.Ω + m.Ω + Λ.Ω, + D(a) ~ ȧ, + ȧ ~ √(Ω) * a^2, + D(D(ϕ)) ~ -3 * D(a) / a * D(ϕ) + ] + M1 = ODESystem(eqs, t, [Ω, a, ȧ, ϕ], []; name = :M) + M1 = compose(M1, r, m, Λ) + + # Apply in two steps, where derivatives are defined at each step: first t -> a, then a -> b + M2 = change_independent_variable(M1, M1.a) + M2c = complete(M2) # just for the following equation comparison (without namespacing) + a, ȧ, Ω, ϕ, aˍt = M2c.a, M2c.ȧ, M2c.Ω, M2c.ϕ, M2c.aˍt + Ωr, Ωm, ΩΛ = M2c.r.Ω, M2c.m.Ω, M2c.Λ.Ω + Da = Differential(a) + @test Set(equations(M2)) == Set([ + aˍt ~ ȧ, # dummy equation + Ω ~ Ωr + Ωm + ΩΛ, + ȧ ~ √(Ω) * a^2, + Da(aˍt) * Da(ϕ) * aˍt + aˍt^2 * (Da^2)(ϕ) ~ -3 * aˍt^2 / a * Da(ϕ), + aˍt * Da(Ωr) ~ -4 * Ωr * aˍt / a, + aˍt * Da(Ωm) ~ -3 * Ωm * aˍt / a, + aˍt * Da(ΩΛ) ~ 0 + ]) + + @variables b(M2.a) + extraeqs = [Differential(M2.a)(b) ~ exp(-b), M2.a ~ exp(b)] + M3 = change_independent_variable(M2, b, extraeqs) + + M1 = structural_simplify(M1) + M2 = structural_simplify(M2; allow_symbolic = true) + M3 = structural_simplify(M3; allow_symbolic = true) + @test length(unknowns(M3)) == length(unknowns(M2)) == length(unknowns(M1)) - 1 +end + +@testset "Change independent variable (simple)" begin + @variables x(t) y1(t) # y(t)[1:1] # TODO: use array variables y(t)[1:2] when fixed: https://github.com/JuliaSymbolics/Symbolics.jl/issues/1383 + Mt = ODESystem([D(x) ~ 2 * x, D(y1) ~ y1], t; name = :M) + Mx = change_independent_variable(Mt, x) + @variables x xˍt(x) xˍtt(x) y1(x) # y(x)[1:1] # TODO: array variables + Dx = Differential(x) + @test Set(equations(Mx)) == Set([xˍt ~ 2 * x, xˍt * Dx(y1) ~ y1]) +end + +@testset "Change independent variable (free fall with 1st order horizontal equation)" begin + @variables x(t) y(t) + @parameters g=9.81 v # gravitational acceleration and constant horizontal velocity + Mt = ODESystem([D(D(y)) ~ -g, D(x) ~ v], t; name = :M) # gives (x, y) as function of t, ... + Mx = change_independent_variable(Mt, x; add_old_diff = true) # ... but we want y as a function of x + Mx = structural_simplify(Mx; allow_symbolic = true) + Dx = Differential(Mx.x) + u0 = [Mx.y => 0.0, Dx(Mx.y) => 1.0, Mx.t => 0.0] + p = [v => 10.0] + prob = ODEProblem(Mx, u0, (0.0, 20.0), p) # 1 = dy/dx = (dy/dt)/(dx/dt) means equal initial horizontal and vertical velocities + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test all(isapprox.(sol[Mx.y], sol[Mx.x - g * (Mx.t)^2 / 2]; atol = 1e-10)) # compare to analytical solution (x(t) = v*t, y(t) = v*t - g*t^2/2) +end + +@testset "Change independent variable (free fall with 2nd order horizontal equation)" begin + @variables x(t) y(t) + @parameters g = 9.81 # gravitational acceleration + Mt = ODESystem([D(D(y)) ~ -g, D(D(x)) ~ 0], t; name = :M) # gives (x, y) as function of t, ... + Mx = change_independent_variable(Mt, x; add_old_diff = true) # ... but we want y as a function of x + Mx = structural_simplify(Mx; allow_symbolic = true) + Dx = Differential(Mx.x) + u0 = [Mx.y => 0.0, Dx(Mx.y) => 1.0, Mx.t => 0.0, Mx.xˍt => 10.0] + prob = ODEProblem(Mx, u0, (0.0, 20.0), []) # 1 = dy/dx = (dy/dt)/(dx/dt) means equal initial horizontal and vertical velocities + sol = solve(prob, Tsit5(); reltol = 1e-5) + @test all(isapprox.(sol[Mx.y], sol[Mx.x - g * (Mx.t)^2 / 2]; atol = 1e-10)) # compare to analytical solution (x(t) = v*t, y(t) = v*t - g*t^2/2) +end + +@testset "Change independent variable (crazy 3rd order nonlinear system)" begin + @independent_variables t + D = Differential(t) + @variables x(t) y(t) + eqs = [ + (D^3)(y) ~ D(x)^2 + (D^2)(y^2) |> expand_derivatives, + D(x)^2 + D(y)^2 ~ x^4 + y^5 + t^6 + ] + M1 = ODESystem(eqs, t; name = :M) + M2 = change_independent_variable(M1, x; add_old_diff = true) + @test_nowarn structural_simplify(M2) + + # Compare to pen-and-paper result + @variables x xˍt(x) xˍt(x) y(x) t(x) + Dx = Differential(x) + areequivalent(eq1, eq2) = isequal(expand(eq1.lhs - eq2.lhs), 0) && + isequal(expand(eq1.rhs - eq2.rhs), 0) + eq1lhs = xˍt^3 * (Dx^3)(y) + xˍt^2 * Dx(y) * (Dx^2)(xˍt) + + xˍt * Dx(y) * (Dx(xˍt))^2 + + 3 * xˍt^2 * (Dx^2)(y) * Dx(xˍt) + eq1rhs = xˍt^2 + 2 * xˍt^2 * Dx(y)^2 + + 2 * xˍt^2 * y * (Dx^2)(y) + + 2 * y * Dx(y) * Dx(xˍt) * xˍt + eq1 = eq1lhs ~ eq1rhs + eq2 = xˍt^2 + xˍt^2 * Dx(y)^2 ~ x^4 + y^5 + t^6 + eq3 = Dx(t) ~ 1 / xˍt + @test areequivalent(equations(M2)[1], eq1) + @test areequivalent(equations(M2)[2], eq2) + @test areequivalent(equations(M2)[3], eq3) +end + +@testset "Change independent variable (registered function / callable parameter)" begin + @independent_variables t + D = Differential(t) + @variables x(t) y(t) + @parameters f::LinearInterpolation (fc::LinearInterpolation)(..) # non-callable and callable + callme(interp::LinearInterpolation, input) = interp(input) + @register_symbolic callme(interp::LinearInterpolation, input) + eqs = [ + D(x) ~ 2t, + D(y) ~ 1fc(t) + 2fc(x) + 3fc(y) + 1callme(f, t) + 2callme(f, x) + 3callme(f, y) + ] + M1 = ODESystem(eqs, t; name = :M) + + # Ensure that interpolations are called with the same variables + M2 = change_independent_variable(M1, x, [t ~ √(x)]) + @variables x xˍt(x) y(x) t(x) + Dx = Differential(x) + @test Set(equations(M2)) == Set([ + t ~ √(x), + xˍt ~ 2t, + xˍt * Dx(y) ~ 1fc(t) + 2fc(x) + 3fc(y) + + 1callme(f, t) + 2callme(f, x) + 3callme(f, y) + ]) + + _f = LinearInterpolation([1.0, 1.0], [-100.0, +100.0]) # constant value 1 + M2s = structural_simplify(M2; allow_symbolic = true) + prob = ODEProblem(M2s, [M2s.y => 0.0], (1.0, 4.0), [fc => _f, f => _f]) + sol = solve(prob, Tsit5(); abstol = 1e-5) + @test isapprox(sol(4.0, idxs = M2.y), 12.0; atol = 1e-5) # Anal solution is D(y) ~ 12 => y(t) ~ 12*t + C => y(x) ~ 12*√(x) + C. With y(x=1)=0 => 12*(√(x)-1), so y(x=4) ~ 12 +end + +@testset "Change independent variable (errors)" begin + @variables x(t) y z(y) w(t) v(t) + M = ODESystem([D(x) ~ 1, v ~ x], t; name = :M) + Ms = structural_simplify(M) + @test_throws "structurally simplified" change_independent_variable(Ms, y) + @test_throws "not a function of" change_independent_variable(M, y) + @test_throws "not a function of" change_independent_variable(M, z) + @variables x(..) # require explicit argument + M = ODESystem([D(x(t)) ~ x(t - 1)], t; name = :M) + @test_throws "DDE" change_independent_variable(M, x(t)) +end diff --git a/test/causal_variables_connection.jl b/test/causal_variables_connection.jl new file mode 100644 index 0000000000..c22e8319d4 --- /dev/null +++ b/test/causal_variables_connection.jl @@ -0,0 +1,98 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Error checking" begin + @variables begin + x(t) + y(t), [input = true] + z(t), [output = true] + w(t) + v(t), [input = true] + u(t), [output = true] + xarr(t)[1:4], [output = true] + yarr(t)[1:2, 1:2], [input = true] + end + @parameters begin + p, [input = true] + q, [output = true] + end + + @test_throws ["p", "kind", "VARIABLE", "PARAMETER"] connect(z, p) + @test_throws ["q", "kind", "VARIABLE", "PARAMETER"] connect(q, y) + @test_throws ["p", "kind", "VARIABLE", "PARAMETER"] connect(z, y, p) + + @test_throws ["unique"] connect(z, y, y) + + @test_throws ["same size"] connect(xarr, yarr) + + @test_throws ["Expected", "x", "output = true", "metadata"] connect(x, y) + @test_throws ["Expected", "y", "output = true", "metadata"] connect(y, v) + + @test_throws ["Expected", "x", "input = true", "metadata"] connect(z, x) + @test_throws ["Expected", "x", "input = true", "metadata"] connect(z, y, x) + @test_throws ["Expected", "u", "input = true", "metadata"] connect(z, u) + @test_throws ["Expected", "u", "input = true", "metadata"] connect(z, y, u) +end + +@testset "Connection expansion" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + eqs = [connect(P.output.u, C.input.u) + connect(C.output.u, P.input.u)] + sys1 = ODESystem(eqs, t, systems = [P, C], name = :hej) + sys = expand_connections(sys1) + @test any(isequal(P.output.u ~ C.input.u), equations(sys)) + @test any(isequal(C.output.u ~ P.input.u), equations(sys)) + + @named sysouter = ODESystem(Equation[], t; systems = [sys1]) + sys = expand_connections(sysouter) + @test any(isequal(sys1.P.output.u ~ sys1.C.input.u), equations(sys)) + @test any(isequal(sys1.C.output.u ~ sys1.P.input.u), equations(sys)) +end + +@testset "With Analysis Points" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = -1) + + ap = AnalysisPoint(:plant_input) + eqs = [connect(P.output, C.input), connect(C.output.u, ap, P.input.u)] + sys = ODESystem(eqs, t, systems = [P, C], name = :hej) + @named nested_sys = ODESystem(Equation[], t; systems = [sys]) + + test_cases = [ + ("inner", sys, sys.plant_input), + ("nested", nested_sys, nested_sys.hej.plant_input), + ("inner - Symbol", sys, :plant_input), + ("nested - Symbol", nested_sys, nameof(sys.plant_input)) + ] + + @testset "get_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 1 + end + + @testset "get_comp_sensitivity - $name" for (name, sys, ap) in test_cases + matrices, _ = get_comp_sensitivity(sys, ap) + @test matrices.A[] == -2 + @test matrices.B[] * matrices.C[] == 1 # both positive or negative + @test matrices.D[] == 0 + end + + @testset "get_looptransfer - $name" for (name, sys, ap) in test_cases + matrices, _ = get_looptransfer(sys, ap) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 + end + + @testset "open_loop - $name" for (name, sys, ap) in test_cases + open_sys, (du, u) = open_loop(sys, ap) + matrices, _ = linearize(open_sys, [du], [u]) + @test matrices.A[] == -1 + @test matrices.B[] * matrices.C[] == -1 # either one negative + @test matrices.D[] == 0 + end +end diff --git a/test/clock.jl b/test/clock.jl index 5bf5e917aa..961c7af181 100644 --- a/test/clock.jl +++ b/test/clock.jl @@ -1,5 +1,5 @@ using ModelingToolkit, Test, Setfield, OrdinaryDiffEq, DiffEqCallbacks -using ModelingToolkit: Continuous +using ModelingToolkit: ContinuousClock using ModelingToolkit: t_nounits as t, D_nounits as D function infer_clocks(sys) @@ -77,19 +77,19 @@ k = ShiftIndex(d) d = Clock(dt) # Note that TearingState reorders the equations -@test eqmap[1] == Continuous +@test eqmap[1] == ContinuousClock() @test eqmap[2] == d @test eqmap[3] == d @test eqmap[4] == d -@test eqmap[5] == Continuous -@test eqmap[6] == Continuous +@test eqmap[5] == ContinuousClock() +@test eqmap[6] == ContinuousClock() @test varmap[yd] == d @test varmap[ud] == d @test varmap[r] == d -@test varmap[x] == Continuous -@test varmap[y] == Continuous -@test varmap[u] == Continuous +@test varmap[x] == ContinuousClock() +@test varmap[y] == ContinuousClock() +@test varmap[u] == ContinuousClock() @info "Testing shift normalization" dt = 0.1 @@ -192,10 +192,10 @@ eqs = [yd ~ Sample(dt)(y) @test varmap[ud1] == d @test varmap[yd2] == d2 @test varmap[ud2] == d2 - @test varmap[r] == Continuous - @test varmap[x] == Continuous - @test varmap[y] == Continuous - @test varmap[u] == Continuous + @test varmap[r] == ContinuousClock() + @test varmap[x] == ContinuousClock() + @test varmap[y] == ContinuousClock() + @test varmap[u] == ContinuousClock() @info "test composed systems" @@ -241,14 +241,14 @@ eqs = [yd ~ Sample(dt)(y) ci, varmap = infer_clocks(cl) @test varmap[f.x] == Clock(0.5) - @test varmap[p.x] == Continuous - @test varmap[p.y] == Continuous + @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] == Continuous + @test varmap[c.y] == ContinuousClock() @test varmap[f.y] == Clock(0.5) @test varmap[f.u] == Clock(0.5) - @test varmap[p.u] == Continuous + @test varmap[p.u] == ContinuousClock() @test varmap[c.r] == Clock(0.5) ## Multiple clock rates @@ -281,9 +281,9 @@ eqs = [yd ~ Sample(dt)(y) @test varmap[ud1] == d @test varmap[yd2] == d2 @test varmap[ud2] == d2 - @test varmap[x] == Continuous() - @test varmap[y] == Continuous() - @test varmap[u] == Continuous() + @test varmap[x] == ContinuousClock() + @test varmap[y] == ContinuousClock() + @test varmap[u] == ContinuousClock() ss = structural_simplify(cl) ss_nosplit = structural_simplify(cl; split = false) @@ -398,13 +398,13 @@ eqs = [yd ~ Sample(dt)(y) ci, varmap = infer_clocks(expand_connections(_model)) - @test varmap[_model.plant.input.u] == Continuous() - @test varmap[_model.plant.u] == Continuous() - @test varmap[_model.plant.x] == Continuous() - @test varmap[_model.plant.y] == Continuous() - @test varmap[_model.plant.output.u] == Continuous() - @test varmap[_model.holder.output.u] == Continuous() - @test varmap[_model.sampler.input.u] == Continuous() + @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 @@ -474,7 +474,7 @@ eqs = [yd ~ Sample(dt)(y) ## Test continuous clock - c = ModelingToolkit.SolverStepClock + c = ModelingToolkit.SolverStepClock() k = ShiftIndex(c) @mtkmodel CounterSys begin @@ -514,7 +514,7 @@ eqs = [yd ~ Sample(dt)(y) @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]) + 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) diff --git a/test/code_generation.jl b/test/code_generation.jl new file mode 100644 index 0000000000..cf3d660b81 --- /dev/null +++ b/test/code_generation.jl @@ -0,0 +1,80 @@ +using ModelingToolkit, OrdinaryDiffEq, SymbolicIndexingInterface +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "`generate_custom_function`" begin + @variables x(t) y(t)[1:3] + @parameters p1=1.0 p2[1:3]=[1.0, 2.0, 3.0] p3::Int=1 p4::Bool=false + + sys = complete(ODESystem(Equation[], t, [x; y], [p1, p2, p3, p4]; name = :sys)) + u0 = [1.0, 2.0, 3.0, 4.0] + p = ModelingToolkit.MTKParameters(sys, []) + + fn1 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3 * t; expression = Val(false)) + @test fn1(u0, p, 0.0) == 5.0 + + fn2 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3 * t, [x], [p1, p2, p3]; expression = Val(false)) + @test fn1(u0, p, 0.0) == 5.0 + + fn3_oop, fn3_iip = generate_custom_function( + sys, [x + y[2], y[3] + p2[2], p1 + p3, 3t]; expression = Val(false)) + + buffer = zeros(4) + fn3_iip(buffer, u0, p, 1.0) + @test buffer == [4.0, 6.0, 2.0, 3.0] + @test fn3_oop(u0, p, 1.0) == [4.0, 6.0, 2.0, 3.0] + + fn4 = generate_custom_function(sys, ifelse(p4, p1, p2[2]); expression = Val(false)) + @test fn4(u0, p, 1.0) == 2.0 + fn5 = generate_custom_function(sys, ifelse(!p4, p1, p2[2]); expression = Val(false)) + @test fn5(u0, p, 1.0) == 1.0 + + @variables x y[1:3] + sys = complete(NonlinearSystem(Equation[], [x; y], [p1, p2, p3, p4]; name = :sys)) + p = MTKParameters(sys, []) + + fn1 = generate_custom_function(sys, x + y[1] + p1 + p2[1] + p3; expression = Val(false)) + @test fn1(u0, p) == 6.0 + + fn2 = generate_custom_function( + sys, x + y[1] + p1 + p2[1] + p3, [x], [p1, p2, p3]; expression = Val(false)) + @test fn1(u0, p) == 6.0 + + fn3_oop, fn3_iip = generate_custom_function( + sys, [x + y[2], y[3] + p2[2], p1 + p3]; expression = Val(false)) + + buffer = zeros(3) + fn3_iip(buffer, u0, p) + @test buffer == [4.0, 6.0, 2.0] + @test fn3_oop(u0, p, 1.0) == [4.0, 6.0, 2.0] + + fn4 = generate_custom_function(sys, ifelse(p4, p1, p2[2]); expression = Val(false)) + @test fn4(u0, p, 1.0) == 2.0 + fn5 = generate_custom_function(sys, ifelse(!p4, p1, p2[2]); expression = Val(false)) + @test fn5(u0, p, 1.0) == 1.0 +end + +@testset "Non-standard array variables" begin + @variables x(t) + @parameters p[0:2] (f::Function)(..) + @mtkbuild sys = ODESystem(D(x) ~ p[0] * x + p[1] * t + p[2] + f(p), t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => [1.0, 2.0, 3.0], f => sum]) + @test prob.ps[p] == [1.0, 2.0, 3.0] + @test prob.ps[p[0]] == 1.0 + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + + @testset "Array split across buffers" begin + @variables x(t)[0:2] + @parameters p[1:2] (f::Function)(..) + @named sys = ODESystem( + [D(x[0]) ~ p[1] * x[0] + x[2], D(x[1]) ~ p[2] * f(x) + x[2]], t) + sys, = structural_simplify(sys, ([x[2]], [])) + @test is_parameter(sys, x[2]) + prob = ODEProblem(sys, [x[0] => 1.0, x[1] => 1.0], (0.0, 1.0), + [p => ones(2), f => sum, x[2] => 2.0]) + sol = solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(sol) + end +end diff --git a/test/components.jl b/test/components.jl index 965578e0c5..8ac40f6fbb 100644 --- a/test/components.jl +++ b/test/components.jl @@ -41,7 +41,7 @@ end completed_rc_model = complete(rc_model) @test isequal(completed_rc_model.resistor.n.i, resistor.n.i) -@test ModelingToolkit.n_extra_equations(capacitor) == 2 +@test ModelingToolkit.n_expanded_connection_equations(capacitor) == 2 @test length(equations(structural_simplify(rc_model, allow_parameter = false))) == 2 sys = structural_simplify(rc_model) @test_throws ModelingToolkit.RepeatedStructuralSimplificationError structural_simplify(sys) @@ -314,3 +314,95 @@ sol = solve(prob, Tsit5()) end @test string(Base.doc(Pin2)) == "Hey there, Pin2!\n" end + +@testset "Issue#3016 Hierarchical indexing" begin + @mtkmodel Inner begin + @parameters begin + p + end + end + @mtkmodel Outer begin + @components begin + inner = Inner() + end + @variables begin + x(t) + end + @equations begin + x ~ inner.p + end + end + + @named outer = Outer() + simp = structural_simplify(outer) + + @test sort(propertynames(outer)) == [:inner, :t, :x] + @test propertynames(simp) == propertynames(outer) + @test sort(propertynames(outer.inner)) == [:p, :t] + @test propertynames(simp.inner) == propertynames(outer.inner) + + for sym in (:t, :x) + @test_nowarn getproperty(simp, sym) + @test_nowarn getproperty(outer, sym) + end + @test_nowarn simp.inner.p + @test_nowarn outer.inner.p + @test_throws ArgumentError simp.inner₊p + @test_throws ArgumentError outer.inner₊p +end + +@testset "`getproperty` on `structural_simplify(complete(sys))`" begin + @mtkmodel Foo begin + @variables begin + x(t) + end + end + @mtkmodel Bar begin + @components begin + foo = Foo() + end + @equations begin + D(foo.x) ~ foo.x + end + end + @named bar = Bar() + cbar = complete(bar) + ss = structural_simplify(cbar) + @test isequal(cbar.foo.x, ss.foo.x) +end + +@testset "Issue#3275: Metadata retained on `complete`" begin + @variables x(t) y(t) + @testset "ODESystem" begin + @named inner = ODESystem(D(x) ~ x, t) + @named outer = ODESystem(D(y) ~ y, t; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + @testset "NonlinearSystem" begin + @named inner = NonlinearSystem([0 ~ x^2 + 4x + 4], [x], []) + @named outer = NonlinearSystem( + [0 ~ x^3 - y^3], [x, y], []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + k = ShiftIndex(t) + @testset "DiscreteSystem" begin + @named inner = DiscreteSystem([x(k) ~ x(k - 1) + x(k - 2)], t, [x], []) + @named outer = DiscreteSystem([y(k) ~ y(k - 1) + y(k - 2)], t, [x, y], + []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end + @testset "OptimizationSystem" begin + @named inner = OptimizationSystem(x^2 + y^2 - 3, [x, y], []) + @named outer = OptimizationSystem( + x^3 - y, [x, y], []; systems = [inner], metadata = "test") + @test ModelingToolkit.get_metadata(outer) == "test" + sys = complete(outer) + @test ModelingToolkit.get_metadata(sys) == "test" + end +end diff --git a/test/constants.jl b/test/constants.jl index bfdc83bafc..f2c4fdaa86 100644 --- a/test/constants.jl +++ b/test/constants.jl @@ -37,3 +37,16 @@ eqs = [D(x) ~ β] simp = structural_simplify(sys) @test isempty(MT.collect_constants(nothing)) + +@testset "Issue#3044" begin + @constants h = 1 + @parameters τ = 0.5 * h + @variables x(MT.t_nounits) = h + eqs = [MT.D_nounits(x) ~ (h - x) / τ] + + @mtkbuild fol_model = ODESystem(eqs, MT.t_nounits) + + prob = ODEProblem(fol_model, [], (0.0, 10.0)) + @test prob[x] ≈ 1 + @test prob.ps[τ] ≈ 0.5 +end diff --git a/test/dde.jl b/test/dde.jl index 439db46fe9..c7561e6c24 100644 --- a/test/dde.jl +++ b/test/dde.jl @@ -1,4 +1,5 @@ -using ModelingToolkit, DelayDiffEq, Test +using ModelingToolkit, DelayDiffEq, StaticArrays, Test +using SymbolicIndexingInterface: is_markovian using ModelingToolkit: t_nounits as t, D_nounits as D p0 = 0.2; @@ -38,6 +39,8 @@ eqs = [D(x₀) ~ (v0 / (1 + beta0 * (x₂(t - tau)^2))) * (p0 - q0) * x₀ - d0 (v1 / (1 + beta1 * (x₂(t - tau)^2))) * (p1 - q1) * x₁ - d1 * x₁ D(x₂(t)) ~ (v1 / (1 + beta1 * (x₂(t - tau)^2))) * (1 - p1 + q1) * x₁ - d2 * x₂(t)] @mtkbuild sys = System(eqs, t) +@test ModelingToolkit.is_dde(sys) +@test !is_markovian(sys) prob = DDEProblem(sys, [x₀ => 1.0, x₁ => 1.0, x₂(t) => 1.0], tspan, @@ -50,6 +53,8 @@ prob2 = DDEProblem(sys, constant_lags = [tau]) sol2_mtk = solve(prob2, alg, reltol = 1e-7, abstol = 1e-10) @test sol2_mtk.u[end] ≈ sol2.u[end] +@test_nowarn sol2_mtk[[x₀, x₁, x₂(t)]] +@test_nowarn sol2_mtk[[x₀, x₁, x₂(t - 0.1)]] using StochasticDelayDiffEq function hayes_modelf(du, u, h, p, t) @@ -69,18 +74,25 @@ pmul = [1.0, prob = SDDEProblem(hayes_modelf, hayes_modelg, [1.0], h, tspan, pmul; constant_lags = (pmul[1],)); -sol = solve(prob, RKMil()) +sol = solve(prob, RKMil(), seed = 100) -@variables x(..) +@variables x(..) delx(t) @parameters a=-4.0 b=-2.0 c=10.0 α=-1.3 β=-1.2 γ=1.1 @brownian η τ = 1.0 -eqs = [D(x(t)) ~ a * x(t) + b * x(t - τ) + c + (α * x(t) + γ) * η] +eqs = [D(x(t)) ~ a * x(t) + b * x(t - τ) + c + (α * x(t) + γ) * η, delx ~ x(t - τ)] @mtkbuild sys = System(eqs, t) +@test ModelingToolkit.has_observed_with_lhs(sys, delx) +@test ModelingToolkit.is_dde(sys) +@test !is_markovian(sys) @test equations(sys) == [D(x(t)) ~ a * x(t) + b * x(t - τ) + c] @test isequal(ModelingToolkit.get_noiseeqs(sys), [α * x(t) + γ]) prob_mtk = SDDEProblem(sys, [x(t) => 1.0 + t], tspan; constant_lags = (τ,)); -@test_nowarn sol_mtk = solve(prob_mtk, RKMil()) +@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 @@ -101,9 +113,89 @@ eqs = [osc1.jcn ~ osc2.delx, osc2.jcn ~ osc1.delx] @named coupledOsc = System(eqs, t) @named coupledOsc = compose(coupledOsc, systems) +@test ModelingToolkit.is_dde(coupledOsc) +@test !is_markovian(coupledOsc) @named coupledOsc2 = System(eqs, t; systems) +@test ModelingToolkit.is_dde(coupledOsc2) +@test !is_markovian(coupledOsc2) for coupledOsc in [coupledOsc, coupledOsc2] local sys = structural_simplify(coupledOsc) @test length(equations(sys)) == 4 @test length(unknowns(sys)) == 4 end +sys = structural_simplify(coupledOsc) +prob = DDEProblem(sys, [], (0.0, 10.0); constant_lags = [sys.osc1.τ, sys.osc2.τ]) +sol = solve(prob, MethodOfSteps(Tsit5())) +obsfn = ModelingToolkit.build_explicit_observed_function( + sys, [sys.osc1.delx, sys.osc2.delx]) +@test_nowarn sol[[sys.osc1.delx, sys.osc2.delx]] +@test sol[sys.osc1.delx] ≈ sol(sol.t .- 0.01; idxs = sys.osc1.x).u + +prob_sa = DDEProblem(sys, [], (0.0, 10.0); constant_lags = [sys.osc1.τ, sys.osc2.τ], + u0_constructor = SVector{4}) +@test prob_sa.u0 isa SVector{4, Float64} + +@testset "DDE observed with array variables" begin + @component function valve(; name) + @parameters begin + open(t)::Bool = false + Kp = 2 + Ksnap = 1.1 + τ = 0.1 + end + @variables begin + opening(..) + lag_opening(t) + snap_opening(t) + end + eqs = [D(opening(t)) ~ Kp * (open - opening(t)) + lag_opening ~ opening(t - τ) + snap_opening ~ clamp(Ksnap * lag_opening - 1 / Ksnap, 0, 1)] + return System(eqs, t; name = name) + end + + @component function veccy(; name) + @parameters dx[1:3] = ones(3) + @variables begin + x(t)[1:3] = zeros(3) + end + return System([D(x) ~ dx], t; name = name) + end + + @mtkbuild ssys = System( + Equation[], t; systems = [valve(name = :valve), veccy(name = :vvecs)]) + prob = DDEProblem(ssys, [ssys.valve.opening => 1.0], (0.0, 1.0)) + sol = solve(prob, MethodOfSteps(Tsit5())) + obsval = @test_nowarn sol[ssys.valve.lag_opening + sum(ssys.vvecs.x)] + @test obsval ≈ + sol(sol.t .- prob.ps[ssys.valve.τ]; idxs = ssys.valve.opening).u .+ + sum.(sol[ssys.vvecs.x]) +end + +@testset "Issue#3165 DDEs with non-tunables" begin + @variables x(..) = 1.0 + @parameters w=1.0 [tunable = false] τ=0.5 + eqs = [D(x(t)) ~ -w * x(t - τ)] + + @named sys = System(eqs, t) + sys = structural_simplify(sys) + + prob = DDEProblem(sys, + [], + (0.0, 10.0), + constant_lags = [τ]) + + alg = MethodOfSteps(Vern7()) + @test_nowarn solve(prob, alg) + + @brownian r + eqs = [D(x(t)) ~ -w * x(t - τ) + r] + @named sys = System(eqs, t) + sys = structural_simplify(sys) + prob = SDDEProblem(sys, + [], + (0.0, 10.0), + constant_lags = [τ]) + + @test_nowarn solve(prob, RKMil()) +end diff --git a/test/debugging.jl b/test/debugging.jl new file mode 100644 index 0000000000..a55684737c --- /dev/null +++ b/test/debugging.jl @@ -0,0 +1,51 @@ +using ModelingToolkit, OrdinaryDiffEq, StochasticDiffEq, SymbolicIndexingInterface +import Logging +using ModelingToolkit: t_nounits as t, D_nounits as D, ASSERTION_LOG_VARIABLE + +@variables x(t) +@brownian a +@named inner_ode = ODESystem(D(x) ~ -sqrt(x), t; assertions = [(x > 0) => "ohno"]) +@named inner_sde = System([D(x) ~ -sqrt(x) + a], t; assertions = [(x > 0) => "ohno"]) +sys_ode = structural_simplify(inner_ode) +sys_sde = structural_simplify(inner_sde) + +@testset "assertions are present in generated `f`" begin + @testset "$(typeof(sys))" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + @test !is_parameter(sys, ASSERTION_LOG_VARIABLE) + prob = Problem(sys, [x => 0.1], (0.0, 5.0)) + sol = solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + @test isnan(prob.f.f([0.0], prob.p, sol.t[end])[1]) + end +end + +@testset "`debug_system` adds logging" begin + @testset "$(typeof(sys))" for (Problem, sys, alg) in [ + (ODEProblem, sys_ode, Tsit5()), (SDEProblem, sys_sde, ImplicitEM())] + dsys = debug_system(sys; functions = []) + @test is_parameter(dsys, ASSERTION_LOG_VARIABLE) + prob = Problem(dsys, [x => 0.1], (0.0, 5.0)) + sol = @test_logs (:error, r"ohno") match_mode=:any solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + prob.ps[ASSERTION_LOG_VARIABLE] = false + sol = @test_logs min_level=Logging.Error solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + end +end + +@testset "Hierarchical system" begin + @testset "$(typeof(inner))" for (ctor, Problem, inner, alg) in [ + (ODESystem, ODEProblem, inner_ode, Tsit5()), + (System, SDEProblem, inner_sde, ImplicitEM())] + @mtkbuild outer = ctor(Equation[], t; systems = [inner]) + dsys = debug_system(outer; functions = []) + @test is_parameter(dsys, ASSERTION_LOG_VARIABLE) + prob = Problem(dsys, [inner.x => 0.1], (0.0, 5.0)) + sol = @test_logs (:error, r"ohno") match_mode=:any solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + prob.ps[ASSERTION_LOG_VARIABLE] = false + sol = @test_logs min_level=Logging.Error solve(prob, alg) + @test !SciMLBase.successful_retcode(sol) + end +end diff --git a/test/discrete_system.jl b/test/discrete_system.jl index f8ed0a911f..0d215052d8 100644 --- a/test/discrete_system.jl +++ b/test/discrete_system.jl @@ -34,21 +34,16 @@ eqs = [S ~ S(k - 1) - infection * h, syss = structural_simplify(sys) @test syss == syss -for df in [ - DiscreteFunction(syss), - eval(DiscreteFunctionExpr(syss)) -] - - # iip - du = zeros(3) - u = collect(1:3) - p = MTKParameters(syss, parameters(syss) .=> collect(1:5)) - df.f(du, u, p, 0) - @test du ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] - - # oop - @test df.f(u, p, 0) ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] -end +df = DiscreteFunction(syss) +# iip +du = zeros(3) +u = collect(1:3) +p = MTKParameters(syss, [c, nsteps, δt, β, γ] .=> collect(1:5)) +df.f(du, u, p, 0) +@test du ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] + +# oop +@test df.f(u, p, 0) ≈ [0.01831563888873422, 0.9816849729159067, 4.999999388195359] # Problem u0 = [S(k - 1) => 990.0, I(k - 1) => 10.0, R(k - 1) => 0.0] @@ -83,7 +78,9 @@ prob_map2 = DiscreteProblem(sys) sol_map2 = solve(prob_map2, FunctionMap()); @test sol_map.u ≈ sol_map2.u -@test sol_map.prob.p == sol_map2.prob.p +for p in parameters(sys) + @test sol_map.prob.ps[p] ≈ sol_map2.prob.ps[p] +end @test_throws Any sol_map2[R2] @test sol_map2[R2(k + 1)][begin:(end - 1)] == sol_map2[R][(begin + 1):end] # Direct Implementation @@ -220,21 +217,6 @@ sol = solve(prob, FunctionMap()) @test reduce(vcat, sol.u) == 1:11 -# test that default values apply to the entire history -@variables x(t) = 1.0 -@mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2)], t) -prob = DiscreteProblem(de, [], (0, 10)) -@test prob[x] == 2.0 -@test prob[x(k - 1)] == 1.0 - -# must provide initial conditions for history -@test_throws ErrorException DiscreteProblem(de, [x => 2.0], (0, 10)) - -# initial values only affect _that timestep_, not the entire history -prob = DiscreteProblem(de, [x(k - 1) => 2.0], (0, 10)) -@test prob[x] == 3.0 -@test prob[x(k - 1)] == 2.0 - # Issue#2585 getdata(buffer, t) = buffer[mod1(Int(t), length(buffer))] @register_symbolic getdata(buffer::Vector, t) @@ -270,4 +252,74 @@ end @variables x(t) y(t) k = ShiftIndex(t) @named sys = DiscreteSystem([x ~ x^2 + y^2, y ~ x(k - 1) + y(k - 1)], t) -@test_throws ["algebraic equations", "not yet supported"] structural_simplify(sys) +@test_throws ["algebraic equations", "ImplicitDiscreteSystem"] structural_simplify(sys) + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + k = ShiftIndex() + @mtkbuild sys = DiscreteSystem([x(k) ~ x(k - 1) + 1], t) + prob = @test_nowarn DiscreteProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob, FunctionMap()) +end + +@testset "Initialization" begin + # test that default values apply to the entire history + @variables x(t) = 1.0 + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2)], t) + prob = DiscreteProblem(de, [], (0, 10)) + @test prob[x] == 2.0 + @test prob[x(k - 1)] == 1.0 + + # must provide initial conditions for history + @test_throws ErrorException DiscreteProblem(de, [x => 2.0], (0, 10)) + @test_throws ErrorException DiscreteProblem(de, [x(k + 1) => 2.0], (0, 10)) + + # initial values only affect _that timestep_, not the entire history + prob = DiscreteProblem(de, [x(k - 1) => 2.0], (0, 10)) + @test prob[x] == 3.0 + @test prob[x(k - 1)] == 2.0 + @variables xₜ₋₁(t) + @test prob[xₜ₋₁] == 2.0 + + # Test initial assignment with lowered variable + prob = DiscreteProblem(de, [xₜ₋₁(k - 1) => 4.0], (0, 10)) + @test prob[x(k - 1)] == prob[xₜ₋₁] == 1.0 + @test prob[x] == 5.0 + + # Test missing initial throws error + @variables x(t) + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2) * x(k - 3)], t) + @test_throws ErrorException prob=DiscreteProblem(de, [x(k - 3) => 2.0], (0, 10)) + @test_throws ErrorException prob=DiscreteProblem( + de, [x(k - 3) => 2.0, x(k - 1) => 3.0], (0, 10)) + + # Test non-assigned initials are given default value + @variables x(t) = 2.0 + @mtkbuild de = DiscreteSystem([x ~ x(k - 1) + x(k - 2) * x(k - 3)], t) + prob = DiscreteProblem(de, [x(k - 3) => 12.0], (0, 10)) + @test prob[x] == 26.0 + @test prob[x(k - 1)] == 2.0 + @test prob[x(k - 2)] == 2.0 + + # Elaborate test + @variables xₜ₋₂(t) zₜ₋₁(t) z(t) + eqs = [x ~ x(k - 1) + z(k - 2), + z ~ x(k - 2) * x(k - 3) - z(k - 1)^2] + @mtkbuild de = DiscreteSystem(eqs, t) + u0 = [x(k - 1) => 3, + xₜ₋₂(k - 1) => 4, + x(k - 2) => 1, + z(k - 1) => 5, + zₜ₋₁(k - 1) => 12] + prob = DiscreteProblem(de, u0, (0, 10)) + @test prob[x] == 15 + @test prob[z] == -21 + + import ModelingToolkit: shift2term + # unknowns(de) = xₜ₋₁, x, zₜ₋₁, xₜ₋₂, z + vars = ModelingToolkit.value.(unknowns(de)) + @test isequal(shift2term(Shift(t, 1)(vars[1])), vars[2]) + @test isequal(shift2term(Shift(t, 1)(vars[4])), vars[1]) + @test isequal(shift2term(Shift(t, -1)(vars[5])), vars[3]) + @test isequal(shift2term(Shift(t, -2)(vars[2])), vars[4]) +end diff --git a/test/distributed.jl b/test/distributed.jl index 3a53b9951e..0b75b8eeb3 100644 --- a/test/distributed.jl +++ b/test/distributed.jl @@ -15,12 +15,11 @@ addprocs(2) @everywhere @named de = ODESystem(eqs, t) @everywhere de = complete(de) -@everywhere ode_func = ODEFunction(de, [x, y, z], [σ, ρ, β]) @everywhere u0 = [19.0, 20.0, 50.0] @everywhere params = [16.0, 45.92, 4] -@everywhere ode_prob = ODEProblem(ode_func, u0, (0.0, 10.0), params) +@everywhere ode_prob = ODEProblem(de, u0, (0.0, 10.0), params) @everywhere begin using OrdinaryDiffEq diff --git a/test/downstream/Project.toml b/test/downstream/Project.toml index b8776e1e4f..ade09e797b 100644 --- a/test/downstream/Project.toml +++ b/test/downstream/Project.toml @@ -5,3 +5,6 @@ ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" + +[compat] +ModelingToolkitStandardLibrary = "2.19" diff --git a/test/downstream/analysis_points.jl b/test/downstream/analysis_points.jl new file mode 100644 index 0000000000..29b9aad512 --- /dev/null +++ b/test/downstream/analysis_points.jl @@ -0,0 +1,496 @@ +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra, ControlSystemsBase +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: connect, t_nounits as t, D_nounits as D +import ControlSystemsBase as CS + +@testset "Complicated model" begin + # Parameters + m1 = 1 + m2 = 1 + k = 1000 # Spring stiffness + c = 10 # Damping coefficient + @named inertia1 = Inertia(; J = m1) + @named inertia2 = Inertia(; J = m2) + @named spring = Spring(; c = k) + @named damper = Damper(; d = c) + @named torque = Torque() + + function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return ODESystem(eqs, t; + systems = [ + torque, + inertia1, + inertia2, + spring, + damper, + u + ], + name) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + end + + @named r = Step(start_time = 0) + model = SystemModel() + @named pid = PID(k = 100, Ti = 0.5, Td = 1) + @named filt = SecondOrder(d = 0.9, w = 10) + @named sensor = AngleSensor() + @named er = Add(k2 = -1) + + connections = [connect(r.output, :r, filt.input) + connect(filt.output, er.input1) + connect(pid.ctr_output, :u, model.torque.tau) + connect(model.inertia2.flange_b, sensor.flange) + connect(sensor.phi, :y, er.input2) + connect(er.output, :e, pid.err_input)] + + closed_loop = ODESystem(connections, t, systems = [model, pid, filt, sensor, r, er], + name = :closed_loop, defaults = [ + model.inertia1.phi => 0.0, + model.inertia2.phi => 0.0, + model.inertia1.w => 0.0, + model.inertia2.w => 0.0, + filt.x => 0.0, + filt.xd => 0.0 + ]) + + sys = structural_simplify(closed_loop) + prob = ODEProblem(sys, unknowns(sys) .=> 0.0, (0.0, 4.0)) + sol = solve(prob, Rodas5P(), reltol = 1e-6, abstol = 1e-9) + + matrices, ssys = linearize(closed_loop, :r, :y) + lsys = ss(matrices...) |> sminreal + @test lsys.nx == 8 + + stepres = ControlSystemsBase.step(c2d(lsys, 0.001), 4) + @test Array(stepres.y[:])≈Array(sol(0:0.001:4, idxs = model.inertia2.phi)) rtol=1e-4 + + matrices, ssys = get_sensitivity(closed_loop, :y) + So = ss(matrices...) + + matrices, ssys = get_sensitivity(closed_loop, :u) + Si = ss(matrices...) + + @test tf(So) ≈ tf(Si) +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - standard `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output, :plant_output, add.input2) + connect(add.output, C.input) + connect(C.output, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output, sys_inner.add.input2) + connect(sys_inner.C.output, :plant_input, sys_inner.P.input) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - causal variable `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output.u, :plant_output, add.input2.u) + connect(add.output, C.input) + connect(C.output.u, P.input.u)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output.u, sys_inner.add.input2.u) + connect(sys_inner.C.output.u, :plant_input, sys_inner.P.input.u) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "Duplicate `connect` statements across subsystems with AP transforms - mixed `connect`" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output.u, :plant_output, add.input2.u) + connect(add.output, C.input) + connect(C.output, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(sys_inner.P.output, sys_inner.add.input2) + connect(sys_inner.C.output.u, :plant_input, sys_inner.P.input.u) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + # test first that the structural_simplify works correctly + ssys = structural_simplify(sys_outer) + prob = ODEProblem(ssys, Pair[], (0, 10)) + @test_nowarn solve(prob, Rodas5()) + + matrices, _ = get_sensitivity(sys_outer, sys_outer.plant_input) + lsys = sminreal(ss(matrices...)) + @test lsys.A[] == -2 + @test lsys.B[] * lsys.C[] == -1 # either one negative + @test lsys.D[] == 1 + + matrices_So, _ = get_sensitivity(sys_outer, sys_outer.inner.plant_output) + lsyso = sminreal(ss(matrices_So...)) + @test lsys == lsyso || lsys == -1 * lsyso * (-1) # Output and input sensitivities are equal for SISO systems +end + +@testset "multilevel system with loop openings" begin + @named P_inner = FirstOrder(k = 1, T = 1) + @named feedback = Feedback() + @named ref = Step() + @named sys_inner = ODESystem( + [connect(P_inner.output, :y, feedback.input2) + connect(feedback.output, :u, P_inner.input) + connect(ref.output, :r, feedback.input1)], + t, + systems = [P_inner, feedback, ref]) + + P_not_broken, _ = linearize(sys_inner, :u, :y) + @test P_not_broken.A[] == -2 + P_broken, _ = linearize(sys_inner, :u, :y, loop_openings = [:u]) + @test P_broken.A[] == -1 + P_broken, _ = linearize(sys_inner, :u, :y, loop_openings = [:y]) + @test P_broken.A[] == -1 + + Sinner = sminreal(ss(get_sensitivity(sys_inner, :u)[1]...)) + + @named sys_inner = ODESystem( + [connect(P_inner.output, :y, feedback.input2) + connect(feedback.output, :u, P_inner.input)], + t, + systems = [P_inner, feedback]) + + @named P_outer = FirstOrder(k = rand(), T = rand()) + + @named sys_outer = ODESystem( + [connect(sys_inner.P_inner.output, :y2, P_outer.input) + connect(P_outer.output, :u2, sys_inner.feedback.input1)], + t, + systems = [P_outer, sys_inner]) + + Souter = sminreal(ss(get_sensitivity(sys_outer, sys_outer.sys_inner.u)[1]...)) + + Sinner2 = sminreal(ss(get_sensitivity( + sys_outer, sys_outer.sys_inner.u, loop_openings = [:y2])[1]...)) + + @test Sinner.nx == 1 + @test Sinner == Sinner2 + @test Souter.nx == 2 +end + +@testset "sensitivities in multivariate signals" begin + A = [-0.994 -0.0794; -0.006242 -0.0134] + B = [-0.181 -0.389; 1.1 1.12] + C = [1.74 0.72; -0.33 0.33] + D = [0.0 0.0; 0.0 0.0] + @named P = Blocks.StateSpace(A, B, C, D) + Pss = CS.ss(A, B, C, D) + + A = [-0.097;;] + B = [-0.138 -1.02] + C = [-0.076; 0.09;;] + D = [0.0 0.0; 0.0 0.0] + @named K = Blocks.StateSpace(A, B, C, D) + Kss = CS.ss(A, B, C, D) + + eqs = [connect(P.output, :plant_output, K.input) + connect(K.output, :plant_input, P.input)] + sys = ODESystem(eqs, t, systems = [P, K], name = :hej) + + matrices, _ = get_sensitivity(sys, :plant_input) + S = CS.feedback(I(2), Kss * Pss, pos_feedback = true) + + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(S) + + matrices, _ = get_comp_sensitivity(sys, :plant_input) + T = -CS.feedback(Kss * Pss, I(2), pos_feedback = true) + + # bodeplot([ss(matrices...), T]) + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(T) + + matrices, _ = get_looptransfer( + sys, :plant_input) + L = Kss * Pss + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(L) + + matrices, _ = linearize(sys, AnalysisPoint(:plant_input), :plant_output) + G = CS.feedback(Pss, Kss, pos_feedback = true) + @test CS.tf(CS.ss(matrices...)) ≈ CS.tf(G) +end + +@testset "multiple analysis points" begin + @named P = FirstOrder(k = 1, T = 1) + @named C = Gain(; k = 1) + @named add = Blocks.Add(k2 = -1) + + eqs = [connect(P.output, :plant_output, add.input2) + connect(add.output, C.input) + connect(C.output, :plant_input, P.input)] + + sys_inner = ODESystem(eqs, t, systems = [P, C, add], name = :inner) + + @named r = Constant(k = 1) + @named F = FirstOrder(k = 1, T = 3) + + eqs = [connect(r.output, F.input) + connect(F.output, sys_inner.add.input1)] + sys_outer = ODESystem(eqs, t, systems = [F, sys_inner, r], name = :outer) + + matrices, _ = get_sensitivity( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + + Ps = tf(1, [1, 1]) |> ss + Cs = tf(1) |> ss + + G = CS.ss(matrices...) |> sminreal + Si = CS.feedback(1, Cs * Ps) + @test tf(G[1, 1]) ≈ tf(Si) + + So = CS.feedback(1, Ps * Cs) + @test tf(G[2, 2]) ≈ tf(So) + @test tf(G[1, 2]) ≈ tf(-CS.feedback(Cs, Ps)) + @test tf(G[2, 1]) ≈ tf(CS.feedback(Ps, Cs)) + + matrices, _ = get_comp_sensitivity( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + + G = CS.ss(matrices...) |> sminreal + Ti = CS.feedback(Cs * Ps) + @test tf(G[1, 1]) ≈ tf(Ti) + + To = CS.feedback(Ps * Cs) + @test tf(G[2, 2]) ≈ tf(To) + @test tf(G[1, 2]) ≈ tf(CS.feedback(Cs, Ps)) # The negative sign appears in a confusing place due to negative feedback not happening through Ps + @test tf(G[2, 1]) ≈ tf(-CS.feedback(Ps, Cs)) + + # matrices, _ = get_looptransfer(sys_outer, [:inner_plant_input, :inner_plant_output]) + matrices, _ = get_looptransfer(sys_outer, sys_outer.inner.plant_input) + L = CS.ss(matrices...) |> sminreal + @test tf(L) ≈ -tf(Cs * Ps) + + matrices, _ = get_looptransfer(sys_outer, sys_outer.inner.plant_output) + L = CS.ss(matrices...) |> sminreal + @test tf(L[1, 1]) ≈ -tf(Ps * Cs) + + # Calling looptransfer like below is not the intended way, but we can work out what it should return if we did so it remains a valid test + matrices, _ = get_looptransfer( + sys_outer, [sys_outer.inner.plant_input, sys_outer.inner.plant_output]) + L = CS.ss(matrices...) |> sminreal + @test tf(L[1, 1]) ≈ tf(0) + @test tf(L[2, 2]) ≈ tf(0) + @test sminreal(L[1, 2]) ≈ ss(-1) + @test tf(L[2, 1]) ≈ tf(Ps) + + matrices, _ = linearize( + sys_outer, [sys_outer.inner.plant_input], [nameof(sys_inner.plant_output)]) + G = CS.ss(matrices...) |> sminreal + @test tf(G) ≈ tf(CS.feedback(Ps, Cs)) +end + +function normal_test_system() + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs_normal = [connect(back.output, :ap, F1.input) + connect(back.output, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named normal_inner = ODESystem(eqs_normal, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2_normal = [ + connect(step.output, normal_inner.back.input1) + ] + @named sys_normal = ODESystem(eqs2_normal, t; systems = [normal_inner, step]) +end + +sys_normal = normal_test_system() + +prob = ODEProblem(structural_simplify(sys_normal), [], (0.0, 10.0)) +@test SciMLBase.successful_retcode(solve(prob, Rodas5P())) +matrices_normal, _ = get_sensitivity(sys_normal, sys_normal.normal_inner.ap) + +@testset "Analysis point overriding part of connection - normal connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output, F1.input, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output, :ap, inner.F1.input)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Analysis point overriding part of connection - variable connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output.u, F1.input.u, F2.input.u) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output.u, :ap, inner.F1.input.u)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Analysis point overriding part of connection - mixed connect" begin + @named F1 = FirstOrder(k = 1, T = 1) + @named F2 = FirstOrder(k = 1, T = 1) + @named add = Blocks.Add(k1 = 1, k2 = 2) + @named back = Feedback() + + eqs = [connect(back.output, F1.input, F2.input) + connect(F1.output, add.input1) + connect(F2.output, add.input2) + connect(add.output, back.input2)] + @named inner = ODESystem(eqs, t; systems = [F1, F2, add, back]) + + @named step = Step() + eqs2 = [connect(step.output, inner.back.input1) + connect(inner.back.output.u, :ap, inner.F1.input.u)] + @named sys = ODESystem(eqs2, t; systems = [inner, step]) + + prob = ODEProblem(structural_simplify(sys), [], (0.0, 10.0)) + @test SciMLBase.successful_retcode(solve(prob, Rodas5P())) + + matrices, _ = get_sensitivity(sys, sys.ap) + @test matrices == matrices_normal +end + +@testset "Ignored analysis points only affect relevant connection sets" begin + m1 = 1 + m2 = 1 + k = 1000 # Spring stiffness + c = 10 # Damping coefficient + + @named inertia1 = Inertia(; J = m1, phi = 0, w = 0) + @named inertia2 = Inertia(; J = m2, phi = 0, w = 0) + + @named spring = Spring(; c = k) + @named damper = Damper(; d = c) + + @named torque = Torque(use_support = false) + + function SystemModel(u = nothing; name = :model) + eqs = [connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b)] + if u !== nothing + push!(eqs, connect(torque.tau, u.output)) + return @named model = ODESystem( + eqs, t; systems = [torque, inertia1, inertia2, spring, damper, u]) + end + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + end + + @named r = Step(start_time = 1) + @named pid = LimPID(k = 400, Ti = 0.5, Td = 1, u_max = 350) + @named filt = SecondOrder(d = 0.9, w = 10, x = 0, xd = 0) + @named sensor = AngleSensor() + @named add = Add() # To add the feedback and feedforward control signals + model = SystemModel() + @named inverse_model = SystemModel() + @named inverse_sensor = AngleSensor() + connections = [connect(r.output, :r, filt.input) # Name connection r to form an analysis point + connect(inverse_model.inertia1.flange_b, inverse_sensor.flange) # Attach the inverse sensor to the inverse model + connect(filt.output, pid.reference, inverse_sensor.phi) # the filtered reference now goes to both the PID controller and the inverse model input + connect(inverse_model.torque.tau, add.input1) + connect(pid.ctr_output, add.input2) + connect(add.output, :u, model.torque.tau) # Name connection u to form an analysis point + connect(model.inertia1.flange_b, sensor.flange) + connect(sensor.phi, :y, pid.measurement)] + closed_loop = ODESystem(connections, t, + systems = [model, inverse_model, pid, filt, sensor, inverse_sensor, r, add], + name = :closed_loop) + # just ensure the system simplifies + mats, _ = get_sensitivity(closed_loop, :y) + S = CS.ss(mats...) + fr = CS.freqrespv(S, [0.01, 1, 100]) + # https://github.com/SciML/ModelingToolkit.jl/pull/3469 + reference_fr = ComplexF64[-1.2505330104772838e-11 - 2.500062613816021e-9im, + -0.0024688370221621625 - 0.002279011866413123im, + 1.8100018764334602 + 0.3623845793211718im] + @test isapprox(fr, reference_fr) +end diff --git a/test/downstream/inversemodel.jl b/test/downstream/inversemodel.jl index 56307aec31..32d5ee87ec 100644 --- a/test/downstream/inversemodel.jl +++ b/test/downstream/inversemodel.jl @@ -27,20 +27,17 @@ rc = 0.25 # Reference concentration 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) = T0, [description = "Cooling temperature"] + xT_c(t), [description = "Cooling temperature"] end - @components begin T_c = RealInput() c = RealOutput() T = RealOutput() end - begin τ0 = 60 wk0 = k0 / c0 @@ -57,27 +54,21 @@ rc = 0.25 # Reference concentration 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])^3 + Ftf = tf(1, [(100), 1])^2 Fss = ss(Ftf) - - "Compute initial state that yields y0 as output" - function init_filter(y0) - (; A, B, C, D) = Fss - Fx0 = -A \ B * y0 - @assert C * Fx0≈[y0] "C*Fx0*y0 ≈ y0 failed, got $(C*Fx0*y0) ≈ $(y0)]" - Fx0 - end - # Create an MTK-compatible constructor - RefFilter(; y0, name) = ODESystem(Fss; name, x0 = init_filter(y0)) + function RefFilter(; name) + sys = ODESystem(Fss; name) + "Compute initial state that yields y0 as output" + empty!(ModelingToolkit.get_defaults(sys)) + return sys + end end @mtkmodel InverseControlledTank begin begin @@ -86,7 +77,6 @@ end 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 @@ -97,10 +87,10 @@ end 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 = 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(y0 = c_start) # Initialize filter states to the initial concentration + 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 @@ -108,22 +98,13 @@ 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, limiter.input) - #connect(limiter.output, :v, tank.T_c) - 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 @@ -132,11 +113,13 @@ end; ssys = structural_simplify(model) cm = complete(model) -op = Dict(D(cm.inverse_tank.xT) => 1, - cm.tank.xc => 0.65) +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; build_initializeprob = false) +prob = ODEProblem(ssys, op, tspan) sol = solve(prob, Rodas5P()) @test SciMLBase.successful_retcode(sol) @@ -146,30 +129,62 @@ sol = solve(prob, Rodas5P()) @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 -Sf, simplified_sys = Blocks.get_sensitivity_function(model, :y) # This should work without providing an operating opint containing a dummy derivative -x, _ = ModelingToolkit.get_u0_p(simplified_sys, op) -p = ModelingToolkit.MTKParameters(simplified_sys, op) +# we need to provide `op` so the initialization system knows what to hold constant +# the values don't matter +Sf, simplified_sys = Blocks.get_sensitivity_function(model, :y; op); # This should work without providing an operating opint containing a dummy derivative +x = state_values(Sf) +p = parameter_values(Sf) # If this somehow passes, mention it on # https://github.com/SciML/ModelingToolkit.jl/issues/2786 -@test_broken begin - matrices1 = Sf(x, p, 0) - matrices2, _ = Blocks.get_sensitivity(model, :y; op) # Test that we get the same result when calling the higher-level API - @test matrices1.f_x ≈ matrices2.A[1:7, 1:7] - 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 -end +matrices1 = Sf(x, p, 0) +matrices2, _ = Blocks.get_sensitivity(model, :y; op); # Test that we get the same result when calling the higher-level API +@test matrices1.f_x ≈ matrices2.A[1:6, 1:6] +nsys = get_named_sensitivity(model, :y; op) # Test that we get the same result when calling an even higher-level API +@test matrices2.A ≈ nsys.A # Test the same thing for comp sensitivities -Sf, simplified_sys = Blocks.get_comp_sensitivity_function(model, :y) # This should work without providing an operating opint containing a dummy derivative -x, _ = ModelingToolkit.get_u0_p(simplified_sys, op) -p = ModelingToolkit.MTKParameters(simplified_sys, op) +# This should work without providing an operating opint containing a dummy derivative +Sf, simplified_sys = Blocks.get_comp_sensitivity_function(model, :y; op); +x = state_values(Sf) +p = parameter_values(Sf) # If this somehow passes, mention it on # https://github.com/SciML/ModelingToolkit.jl/issues/2786 -@test_broken begin - matrices1 = Sf(x, p, 0) - matrices2, _ = Blocks.get_comp_sensitivity(model, :y; op) # Test that we get the same result when calling the higher-level API - @test matrices1.f_x ≈ matrices2.A[1:7, 1:7] - nsys = get_named_comp_sensitivity(model, :y; op) # Test that we get the same result when calling an even higher-level API - @test matrices2.A ≈ nsys.A +matrices1 = Sf(x, p, 0) +# Test that we get the same result when calling the higher-level API +matrices2, _ = Blocks.get_comp_sensitivity(model, :y; op) +@test matrices1.f_x ≈ matrices2.A[1:6, 1:6] +# Test that we get the same result when calling an even higher-level API +nsys = get_named_comp_sensitivity(model, :y; op) +@test matrices2.A ≈ nsys.A + +@testset "Issue #3319" begin + op1 = Dict( + cm.filter.y.u => 0.8 * (1 - 0.42), + cm.tank.xc => 0.65 + ) + + op2 = Dict( + cm.filter.y.u => 0.8 * (1 - 0.42), + cm.tank.xc => 0.45 + ) + + output = :y + # we need to provide `op` so the initialization system knows which + # values to hold constant + lin_fun, ssys = Blocks.get_sensitivity_function(model, output; op = op1) + matrices1 = linearize(ssys, lin_fun, op = op1) + matrices2 = linearize(ssys, lin_fun, op = op2) + S1f = ss(matrices1...) + S2f = ss(matrices2...) + @test S1f != S2f + + matrices1, ssys = Blocks.get_sensitivity(model, output; op = op1) + matrices2, ssys = Blocks.get_sensitivity(model, output; op = op2) + S1 = ss(matrices1...) + S2 = ss(matrices2...) + @test S1 != S2 + + @test S1 == S1f + @test S2 == S2f end diff --git a/test/downstream/linearization_dd.jl b/test/downstream/linearization_dd.jl index 11dc65f619..c4076b6aad 100644 --- a/test/downstream/linearization_dd.jl +++ b/test/downstream/linearization_dd.jl @@ -25,40 +25,38 @@ eqs = [connect(link1.TX1, cart.flange) connect(link1.TY1, fixed.flange)] @named model = ODESystem(eqs, t, [], []; systems = [link1, cart, force, fixed]) -def = ModelingToolkit.defaults(model) -def[cart.s] = 10 -def[cart.v] = 0 -def[link1.A] = -pi / 2 -def[link1.dA] = 0 lin_outputs = [cart.s, cart.v, link1.A, link1.dA] lin_inputs = [force.f.u] -@test_broken begin - @info "named_ss" - G = named_ss(model, lin_inputs, lin_outputs, allow_symbolic = true, op = def, - allow_input_derivatives = true, zero_dummy_der = true) - G = sminreal(G) - @info "minreal" - G = minreal(G) - @info "poles" - ps = poles(G) - - @test minimum(abs, ps) < 1e-6 - @test minimum(abs, complex(0, 1.3777260367206716) .- ps) < 1e-10 - - lsys, syss = linearize(model, lin_inputs, lin_outputs, allow_symbolic = true, op = def, - allow_input_derivatives = true, zero_dummy_der = true) - lsyss, sysss = ModelingToolkit.linearize_symbolic(model, lin_inputs, lin_outputs; - allow_input_derivatives = true) - - dummyder = setdiff(unknowns(sysss), unknowns(model)) - def = merge(ModelingToolkit.guesses(model), def, Dict(x => 0.0 for x in dummyder)) - def[link1.fy1] = -def[link1.g] * def[link1.m] - - @test substitute(lsyss.A, def) ≈ lsys.A - # We cannot pivot symbolically, so the part where a linear solve is required - # is not reliable. - @test substitute(lsyss.B, def)[1:6, 1] ≈ lsys.B[1:6, 1] - @test substitute(lsyss.C, def) ≈ lsys.C - @test substitute(lsyss.D, def) ≈ lsys.D -end +# => nothing to remove extra defaults +op = Dict(cart.s => 10, cart.v => 0, link1.A => -pi / 2, link1.dA => 0, force.f.u => 0, + link1.x1 => nothing, link1.y1 => nothing, link1.x2 => nothing, link1.x_cm => nothing) +@info "named_ss" +G = named_ss(model, lin_inputs, lin_outputs; allow_symbolic = true, op, + allow_input_derivatives = true, zero_dummy_der = true) +G = sminreal(G) +@info "minreal" +G = minreal(G) +@info "poles" +ps = poles(G) + +@test minimum(abs, ps) < 1e-6 +@test minimum(abs, complex(0, 1.3777260367206716) .- ps) < 1e-10 + +lsys, syss = linearize(model, lin_inputs, lin_outputs, allow_symbolic = true, op = op, + allow_input_derivatives = true, zero_dummy_der = true) +lsyss, sysss = ModelingToolkit.linearize_symbolic(model, lin_inputs, lin_outputs; + allow_input_derivatives = true) + +dummyder = setdiff(unknowns(sysss), unknowns(model)) +# op2 = merge(ModelingToolkit.guesses(model), op, Dict(x => 0.0 for x in dummyder)) +op2 = merge(ModelingToolkit.defaults(syss), op) +op2[link1.fy1] = -op2[link1.g] * op2[link1.m] +op2[cart.f] = 0 + +@test substitute(lsyss.A, op2) ≈ lsys.A +# We cannot pivot symbolically, so the part where a linear solve is required +# is not reliable. +@test substitute(lsyss.B, op2)[1:6, 1] ≈ lsys.B[1:6, 1] +@test substitute(lsyss.C, op2) ≈ lsys.C +@test substitute(lsyss.D, op2) ≈ lsys.D diff --git a/test/downstream/linearize.jl b/test/downstream/linearize.jl index 49b4a45629..9918f8145d 100644 --- a/test/downstream/linearize.jl +++ b/test/downstream/linearize.jl @@ -1,4 +1,5 @@ -using ModelingToolkit, Test +using ModelingToolkit, ADTypes, Test +using CommonSolve: solve # r is an input, and y is an output. @independent_variables t @@ -14,11 +15,14 @@ eqs = [u ~ kp * (r - y) @named sys = ODESystem(eqs, t) lsys, ssys = linearize(sys, [r], [y]) +lprob = LinearizationProblem(sys, [r], [y]) +lsys2 = solve(lprob) +lsys3, _ = linearize(sys, [r], [y]; autodiff = AutoFiniteDiff()) -@test lsys.A[] == -2 -@test lsys.B[] == 1 -@test lsys.C[] == 1 -@test lsys.D[] == 0 +@test lsys.A[] == lsys2.A[] == lsys3.A[] == -2 +@test lsys.B[] == lsys2.B[] == lsys3.B[] == 1 +@test lsys.C[] == lsys2.C[] == lsys3.C[] == 1 +@test lsys.D[] == lsys2.D[] == lsys3.D[] == 0 lsys, ssys = linearize(sys, [r], [r]) @@ -47,8 +51,8 @@ lsys, ssys = linearize(sys, r, r) # Test allow scalars ``` function plant(; name) - @variables x(t) = 1 - @variables u(t)=0 y(t)=0 + @variables x(t) + @variables u(t) y(t) D = Differential(t) eqs = [D(x) ~ -x + u y ~ x] @@ -56,7 +60,7 @@ function plant(; name) end function filt_(; name) - @variables x(t)=0 y(t)=0 + @variables x(t) y(t) @variables u(t)=0 [input = true] D = Differential(t) eqs = [D(x) ~ -2 * x + u @@ -65,7 +69,7 @@ function filt_(; name) end function controller(kp; name) - @variables y(t)=0 r(t)=0 u(t)=0 + @variables y(t)=0 r(t)=0 u(t) @parameters kp = kp eqs = [ u ~ kp * (r - y) @@ -86,19 +90,21 @@ connections = [f.y ~ c.r # filtered reference to controller reference 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 == [-2 0; 1 -2] -@test lsys.B == reshape([1, 0], 2, 1) -@test lsys.C == [0 1] -@test lsys.D[] == 0 +@test lsys.A == lsys2.A == [-2 0; 1 -2] +@test lsys.B == lsys2.B == reshape([1, 0], 2, 1) +@test lsys.C == lsys2.C == [0 1] +@test lsys.D[] == lsys2.D[] == 0 ## Symbolic linearization lsyss, _ = ModelingToolkit.linearize_symbolic(cl, [f.u], [p.x]) -@test substitute(lsyss.A, ModelingToolkit.defaults(cl)) == lsys.A -@test substitute(lsyss.B, ModelingToolkit.defaults(cl)) == lsys.B -@test substitute(lsyss.C, ModelingToolkit.defaults(cl)) == lsys.C -@test substitute(lsyss.D, ModelingToolkit.defaults(cl)) == lsys.D +@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 @@ -108,7 +114,8 @@ 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]) +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) @@ -121,13 +128,13 @@ lsys = ModelingToolkit.reorder_unknowns(lsys0, unknowns(ssys), desired_order) lsyss, _ = ModelingToolkit.linearize_symbolic(pid, [reference.u, measurement.u], [ctr_output.u]) -@test substitute( +@test ModelingToolkit.fixpoint_sub( lsyss.A, ModelingToolkit.defaults_and_guesses(pid)) == lsys.A -@test substitute( +@test ModelingToolkit.fixpoint_sub( lsyss.B, ModelingToolkit.defaults_and_guesses(pid)) == lsys.B -@test substitute( +@test ModelingToolkit.fixpoint_sub( lsyss.C, ModelingToolkit.defaults_and_guesses(pid)) == lsys.C -@test substitute( +@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 @@ -258,7 +265,7 @@ closed_loop = ODESystem(connections, t, systems = [model, pid, filt, sensor, r, filt.xd => 0.0 ]) -@test_nowarn linearize(closed_loop, :r, :y) +@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 @@ -307,12 +314,37 @@ 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 "Issue #2941" begin +@testset "linearization_function handles dependent values" begin + @parameters q + matrices = @test_nowarn linfun([1.0], Dict(p => 3q, q => 1.0), 1.0) + @test matrices.f_u[] == 3.0 +end + +@testset "Issue #2941 and #3400" begin @variables x(t) y(t) @parameters p eqs = [0 ~ x * log(y) - p] @named sys = ODESystem(eqs, t; defaults = [p => 1.0]) sys = complete(sys) - @test_nowarn linearize( + @test_throws ModelingToolkit.MissingVariablesError linearize( sys, [x], []; op = Dict(x => 1.0), allow_input_derivatives = true) + @test_nowarn linearize( + sys, [x], []; op = Dict(x => 1.0), guesses = Dict(y => 1.0), + allow_input_derivatives = true) +end + +@testset "Symbolic values for parameters in `linearize`" begin + @named tank_noi = Tank_noi() + @unpack md_i, h, m, ρ, A, K = tank_noi + m_ss = 2.4000000003229878 + @test_nowarn linearize( + tank_noi, [md_i], [h]; op = Dict(m => m_ss, md_i => 2, ρ => A / K, A => 5)) +end + +@testset "Warn on empty operating point" begin + @named tank_noi = Tank_noi() + @unpack md_i, h, m = tank_noi + m_ss = 2.4000000003229878 + @test_warn ["empty operating point", "warn_empty_op"] linearize( + tank_noi, [md_i], [h]; p = [md_i => 1.0]) end diff --git a/test/downstream/test_disturbance_model.jl b/test/downstream/test_disturbance_model.jl new file mode 100644 index 0000000000..10fcf9fc1f --- /dev/null +++ b/test/downstream/test_disturbance_model.jl @@ -0,0 +1,215 @@ +#= +This file implements and tests a typical workflow for state estimation with disturbance models +The primary subject of the tests is the analysis-point features and the +analysis-point specific method for `generate_control_function`. +=# +using ModelingToolkit, OrdinaryDiffEq, LinearAlgebra, Test +using ModelingToolkitStandardLibrary.Mechanical.Rotational +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkit: connect +# using Plots + +using ModelingToolkit: t_nounits as t, D_nounits as D + +indexof(sym, syms) = findfirst(isequal(sym), syms) + +## Build the system model ====================================================== +@mtkmodel SystemModel begin + @parameters begin + m1 = 1 + m2 = 1 + k = 10 # Spring stiffness + c = 3 # Damping coefficient + end + @components begin + inertia1 = Inertia(; J = m1, phi = 0, w = 0) + inertia2 = Inertia(; J = m2, phi = 0, w = 0) + spring = Spring(; c = k) + damper = Damper(; d = c) + torque = Torque(use_support = false) + end + @equations begin + connect(torque.flange, inertia1.flange_a) + connect(inertia1.flange_b, spring.flange_a, damper.flange_a) + connect(inertia2.flange_a, spring.flange_b, damper.flange_b) + end +end + +@mtkmodel ModelWithInputs 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) + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, 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 = ModelWithInputs() # Model with load disturbance +ssys = structural_simplify(model) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +# plot(sol) + +## +using ControlSystemsBase, ControlSystemsMTK +cmodel = complete(model) +P = cmodel.system_model +lsys = named_ss( + model, [:u, :d1], [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) + +## +# If we now want to add a disturbance model, we cannot do that since we have already connected a constant to the disturbance input. We have also already used the name `d` for an analysis point, but that might not be an issue since we create an outer model and get a new namespace. + +s = tf("s") +dist(; name) = ODESystem(1 / s; name) + +@mtkmodel SystemModelWithDisturbanceModel begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + disturbance_model = dist() + system_model = SystemModel() + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_model.input) + connect(disturbance_model.output, disturbance_torque1.tau) + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + end +end + +@named model_with_disturbance = SystemModelWithDisturbanceModel() +# ssys = structural_simplify(open_loop(model_with_disturbance, :d)) # Open loop worked, but it's a bit awkward that we have to use it here +# lsys2 = named_ss(model_with_disturbance, [:u, :d1], +# [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) +ssys = structural_simplify(model_with_disturbance) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +# plot(sol) + +## +# Now we only have an integrating disturbance affecting inertia1, what if we want both integrating and direct Gaussian? We'd need a "PI controller" disturbancemodel. If we add the disturbance model (s+1)/s we get the integrating and non-integrating noises being correlated which is fine, it reduces the dimensions of the sigma point by 1. + +dist3(; name) = ODESystem(ss(1 + 10 / s, balance = false); name) + +@mtkmodel SystemModelWithDisturbanceModel begin + @components begin + input_signal = Blocks.Sine(frequency = 1, amplitude = 1) + disturbance_signal1 = Blocks.Constant(k = 0) + disturbance_signal2 = Blocks.Constant(k = 0) + disturbance_torque1 = Torque(use_support = false) + disturbance_torque2 = Torque(use_support = false) + disturbance_model = dist3() + system_model = SystemModel() + + y = Blocks.Add() + angle_sensor = AngleSensor() + output_disturbance = Blocks.Constant(k = 0) + end + @equations begin + connect(input_signal.output, :u, system_model.torque.tau) + connect(disturbance_signal1.output, :d1, disturbance_model.input) + connect(disturbance_model.output, disturbance_torque1.tau) + connect(disturbance_signal2.output, :d2, disturbance_torque2.tau) + connect(disturbance_torque1.flange, system_model.inertia1.flange_b) + connect(disturbance_torque2.flange, system_model.inertia2.flange_b) + + connect(system_model.inertia1.flange_b, angle_sensor.flange) + connect(angle_sensor.phi, y.input1) + connect(output_disturbance.output, :dy, y.input2) + end +end + +@named model_with_disturbance = SystemModelWithDisturbanceModel() +# ssys = structural_simplify(open_loop(model_with_disturbance, :d)) # Open loop worked, but it's a bit awkward that we have to use it here +# lsys3 = named_ss(model_with_disturbance, [:u, :d1], +# [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]) +ssys = structural_simplify(model_with_disturbance) +prob = ODEProblem(ssys, [], (0.0, 10.0)) +sol = solve(prob, Tsit5()) +@test SciMLBase.successful_retcode(sol) +# plot(sol) + +## Generate function for an augmented Unscented Kalman Filter ===================== +# temp = open_loop(model_with_disturbance, :d) +outputs = [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w] +(f_oop1, f_ip), x_sym, p_sym, io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, [:u], [:d1, :d2, :dy], split = false) + +(f_oop2, f_ip2), x_sym, p_sym, io_sys = ModelingToolkit.generate_control_function( + model_with_disturbance, [:u], [:d1, :d2, :dy], + disturbance_argument = true, split = false) + +measurement = ModelingToolkit.build_explicit_observed_function( + io_sys, outputs, inputs = ModelingToolkit.inputs(io_sys)[1:1]) +measurement2 = ModelingToolkit.build_explicit_observed_function( + io_sys, [io_sys.y.output.u], inputs = ModelingToolkit.inputs(io_sys)[1:1], + disturbance_inputs = ModelingToolkit.inputs(io_sys)[2:end], + disturbance_argument = true) + +op = ModelingToolkit.inputs(io_sys) .=> 0 +x0, p = ModelingToolkit.get_u0_p(io_sys, op, op) +x = zeros(5) +u = zeros(1) +d = zeros(3) +@test f_oop2(x, u, p, t, d) == zeros(5) +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [0] + +# Add to the integrating disturbance input +d = [1, 0, 0] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 1, 1] # Affects disturbance state and one velocity +@test measurement2(x, u, p, 0.0, d) == [0] + +d = [0, 1, 0] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 0, 1] # Affects one velocity +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [0] + +d = [0, 0, 1] +@test sort(f_oop2(x, u, p, 0.0, d)) == [0, 0, 0, 0, 0] # Affects nothing +@test measurement(x, u, p, 0.0) == [0, 0, 0, 0] +@test measurement2(x, u, p, 0.0, d) == [1] # We have now disturbed the output + +## Further downstream tests that the functions generated above actually have the properties required to use for state estimation +# +# using LowLevelParticleFilters, SeeToDee +# Ts = 0.001 +# discrete_dynamics = SeeToDee.Rk4(f_oop2, Ts) +# nx = length(x_sym) +# nu = 1 +# nw = 2 +# ny = length(outputs) +# R1 = Diagonal([1e-5, 1e-5]) +# R2 = 0.1 * I(ny) +# op = ModelingToolkit.inputs(io_sys) .=> 0 +# x0, p = ModelingToolkit.get_u0_p(io_sys, op, op) +# d0 = LowLevelParticleFilters.SimpleMvNormal(x0, 10.0I(nx)) +# measurement_model = UKFMeasurementModel{Float64, false, false}(measurement, R2; nx, ny) +# kf = UnscentedKalmanFilter{false, false, true, false}( +# discrete_dynamics, measurement_model, R1, d0; nu, Ts, p) + +# tvec = 0:Ts:sol.t[end] +# u = vcat.(Array(sol(tvec, idxs = P.torque.tau.u))) +# y = collect.(eachcol(Array(sol(tvec, idxs = outputs)) .+ 1e-2 .* randn.())) + +# inds = 1:5805 +# res = forward_trajectory(kf, u, y) + +# plot(res, size = (1000, 1000)); +# plot!(sol, idxs = x_sym, sp = (1:nx)', l = :dash); diff --git a/test/extensions/Project.toml b/test/extensions/Project.toml index d8d3d64605..5b0de73cdf 100644 --- a/test/extensions/Project.toml +++ b/test/extensions/Project.toml @@ -1,10 +1,19 @@ [deps] BifurcationKit = "0f109fa4-8a5d-4b75-95aa-f515264e7665" +ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +ChainRulesTestUtils = "cdddcdb0-9152-4a09-a978-84456f9df70a" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +HomotopyContinuation = "f213a82b-91d6-5c5d-acf7-10f1c761b327" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" LabelledArrays = "2ee39098-c373-598a-b85f-a56591580800" ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +Nemo = "2edaba10-b0f1-5616-af89-8c11ac63239a" +NonlinearSolveHomotopyContinuation = "2ac3b008-d579-4536-8c91-a1a5998c2f8b" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" SciMLStructures = "53ae85a6-f571-4167-b2af-e1d143709226" SymbolicIndexingInterface = "2efcf032-c050-4f8e-a9bb-153293bab1f5" +Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" diff --git a/test/extensions/ad.jl b/test/extensions/ad.jl index 1946db5277..adaf6117c6 100644 --- a/test/extensions/ad.jl +++ b/test/extensions/ad.jl @@ -4,8 +4,12 @@ using Zygote using SymbolicIndexingInterface using SciMLStructures using OrdinaryDiffEq +using NonlinearSolve using SciMLSensitivity using ForwardDiff +using ChainRulesCore +using ChainRulesCore: NoTangent +using ChainRulesTestUtils: test_rrule, rand_tangent @variables x(t)[1:3] y(t) @parameters p[1:3, 1:3] q @@ -21,7 +25,7 @@ prob = ODEProblem(sys, u0, tspan, ps) sol = solve(prob, Tsit5()) mtkparams = parameter_values(prob) -new_p = rand(10) +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) @@ -51,3 +55,82 @@ end @test ForwardDiff.gradient(x_at_0, [0.3, 0.7]) == zeros(2) end + +@parameters a b[1:3] c(t) d::Integer e[1:3] f[1:3, 1:3]::Int g::Vector{AbstractFloat} h::String +@named sys = ODESystem( + Equation[], t, [], [a, b, c, d, e, f, g, h], + continuous_events = [[a ~ 0] => [c ~ 0]]) +sys = complete(sys) + +ivs = Dict(c => 3a, b => ones(3), a => 1.0, d => 4, e => [5.0, 6.0, 7.0], + f => ones(Int, 3, 3), g => [0.1, 0.2, 0.3], h => "foo") + +ps = MTKParameters(sys, ivs) + +varmap = Dict(a => 1.0f0, b => 3ones(Float32, 3), c => 2.0, + e => Float32[0.4, 0.5, 0.6], g => ones(Float32, 4)) +get_values = getp(sys, [a, b..., c, e...]) +get_g = getp(sys, g) +for (_idxs, vals) in [ + # all portions + (collect(keys(varmap)), collect(values(varmap))), + # non-arrays + (keys(varmap), values(varmap)), + # tunable only + ([a], [varmap[a]]), + ([a, b], (varmap[a], varmap[b])), + ([a, b[2]], (varmap[a], varmap[b][2])) +] + for idxs in [_idxs, map(i -> parameter_index(sys, i), collect(_idxs))] + loss = function (p) + newps = remake_buffer(sys, ps, idxs, p) + return sum(get_values(newps)) + sum(get_g(newps)) + end + + grad = Zygote.gradient(loss, vals)[1] + for (val, g) in zip(vals, grad) + @test eltype(val) == eltype(g) + if val isa Number + @test isone(g) + else + @test all(isone, g) + end + end + end +end + +idxs = (parameter_index(sys, a), parameter_index(sys, b)) +vals = (1.0f0, 3ones(Float32, 3)) +tangent = rand_tangent(ps) +fwd, back = ChainRulesCore.rrule(remake_buffer, sys, ps, idxs, vals) +@inferred back(tangent) + +@testset "Dual type promotion in remake with dummy derivatives" begin # https://github.com/SciML/ModelingToolkit.jl/issues/3336 + # Throw ball straight up into the air + @variables y(t) + eqs = [D(D(y)) ~ -9.81] + initialization_eqs = [y^2 ~ 0] # initialize y = 0 in a way that builds an initialization problem + @named sys = ODESystem(eqs, t; initialization_eqs) + sys = structural_simplify(sys) + + # Find initial throw velocity that reaches exactly 10 m after 1 s + dprob0 = ODEProblem(sys, [D(y) => NaN], (0.0, 1.0), []; guesses = [y => 0.0]) + function f(ics, _) + dprob = remake(dprob0, u0 = Dict(D(y) => ics[1])) + dsol = solve(dprob, Tsit5()) + return [dsol[y][end] - 10.0] + end + nprob = NonlinearProblem(f, [1.0]) + nsol = solve(nprob, NewtonRaphson()) + @test nsol[1] ≈ 10.0 / 1.0 + 9.81 * 1.0 / 2 # anal free fall solution is y = v0*t - g*t^2/2 -> v0 = y/t + g*t/2 +end + +@testset "`sys.var` is non-differentiable" begin + @variables x(t) + @mtkbuild sys = ODESystem(D(x) ~ x, t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + + grad = Zygote.gradient(prob) do prob + prob[sys.x] + end +end diff --git a/test/extensions/bifurcationkit.jl b/test/extensions/bifurcationkit.jl index 698dd085c8..629edf46a6 100644 --- a/test/extensions/bifurcationkit.jl +++ b/test/extensions/bifurcationkit.jl @@ -36,8 +36,8 @@ let bprob_BK = BifurcationProblem(f_BK, [1.0, 1.0], [-1.0, 1.0], - (@lens _[1]); - record_from_solution = (x, p) -> x[1]) + (BifurcationKit.@optic _[1]); + record_from_solution = (x, p; k...) -> x[1]) bif_dia_BK = bifurcationdiagram(bprob_BK, PALC(), 2, @@ -162,4 +162,12 @@ let bf = bifurcationdiagram(bp, PALC(), 2, opts_br) @test bf.γ.specialpoint[1].param≈0.1 atol=1e-4 rtol=1e-4 + + # Test with plot variable as observable + pvar = ModelingToolkit.get_var_to_name(fol)[:RHS] + bp = BifurcationProblem(fol, u0, par, bif_par; plot_var = pvar) + opts_br = ContinuationPar(p_min = -1.0, + p_max = 1.0) + bf = bifurcationdiagram(bp, PALC(), 2, opts_br) + @test bf.γ.specialpoint[1].param≈0.1 atol=1e-4 rtol=1e-4 end diff --git a/test/extensions/homotopy_continuation.jl b/test/extensions/homotopy_continuation.jl new file mode 100644 index 0000000000..65f7d765a4 --- /dev/null +++ b/test/extensions/homotopy_continuation.jl @@ -0,0 +1,252 @@ +using ModelingToolkit, NonlinearSolve, NonlinearSolveHomotopyContinuation, + SymbolicIndexingInterface +using SymbolicUtils +import ModelingToolkit as MTK +using LinearAlgebra +using Test + +allrootsalg = HomotopyContinuationJL{true}(; threading = false) +singlerootalg = HomotopyContinuationJL{false}(; threading = false) + +function test_single_root(sol; atol = 1e-10) + @test SciMLBase.successful_retcode(sol) + @test norm(sol.resid)≈0.0 atol=atol +end + +function test_all_roots(sol; atol = 1e-10) + @test sol.converged + for nlsol in sol.u + @test SciMLBase.successful_retcode(nlsol) + @test norm(nlsol.resid)≈0.0 atol=1e-10 + end +end + +function solve_allroots_closest(prob) + sol = solve(prob, allrootsalg) + return argmin(sol.u) do nlsol + return norm(nlsol.u - prob.u0) + end +end + +@testset "No parameters" begin + @variables x y z + eqs = [0 ~ x^2 + y^2 + 2x * y + 0 ~ x^2 + 4x + 4 + 0 ~ y * z + 4x^2] + @mtkbuild sys = NonlinearSystem(eqs) + u0 = [x => 1.0, y => 1.0, z => 1.0] + prob = HomotopyContinuationProblem(sys, u0) + @test prob isa NonlinearProblem + @test prob[x] == prob[y] == prob[z] == 1.0 + @test prob[x + y] == 2.0 + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +struct Wrapper + x::Matrix{Float64} +end + +@testset "Parameters" begin + wrapper(w::Wrapper) = det(w.x) + @register_symbolic wrapper(w::Wrapper) + + @variables x y z + @parameters p q::Int r::Wrapper + + eqs = [0 ~ x^2 + y^2 + p * x * y + 0 ~ x^2 + 4x + q + 0 ~ y * z + 4x^2 + wrapper(r)] + + @mtkbuild sys = NonlinearSystem(eqs) + prob = HomotopyContinuationProblem(sys, [x => 1.0, y => 1.0, z => 1.0], + [p => 2.0, q => 4, r => Wrapper([1.0 1.0; 0.0 0.0])]) + @test prob.ps[p] == 2.0 + @test prob.ps[q] == 4 + @test prob.ps[r].x == [1.0 1.0; 0.0 0.0] + @test prob.ps[p * q] == 8.0 + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +@testset "Array variables" begin + @variables x[1:3] + @parameters p[1:3] + _x = collect(x) + eqs = collect(0 .~ vec(sum(_x * _x'; dims = 2)) + collect(p)) + @mtkbuild sys = NonlinearSystem(eqs) + prob = HomotopyContinuationProblem(sys, [x => ones(3)], [p => 1:3]) + @test prob[x] == ones(3) + @test prob[p + x] == [2, 3, 4] + prob[x] = 2ones(3) + @test prob[x] == 2ones(3) + prob.ps[p] = [2, 3, 4] + @test prob.ps[p] == [2, 3, 4] + sol = @test_nowarn solve(prob, singlerootalg) + @test sol.retcode == ReturnCode.ConvergenceFailure +end + +@testset "Parametric exponents" begin + @variables x = 1.0 + @parameters n::Integer = 4 + @mtkbuild sys = NonlinearSystem([x^n + x^2 - 1 ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + sol = solve(prob, singlerootalg) + test_single_root(sol) + sol = solve(prob, allrootsalg) + test_all_roots(sol) +end + +@testset "Polynomial check and warnings" begin + @variables x = 1.0 + @mtkbuild sys = NonlinearSystem([x^1.5 + x^2 - 1 ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "not an integer", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([x^x - x ~ 0]) + @test_throws ["Cannot convert", "Unable", "symbolically solve", + "Exponent", "unknowns", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + @mtkbuild sys = NonlinearSystem([((x^2) / sin(x))^2 + x ~ 0]) + @test_throws ["Cannot convert", "both polynomial", "non-polynomial", + "recognized", "sin", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @variables y = 2.0 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 + 2 ~ 0, y ~ sin(x)]) + @test_throws ["Cannot convert", "recognized", "sin", "not a polynomial"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([x^2 + y^2 - 2 ~ 0, sin(x + y) ~ 0]) + @test_throws ["Cannot convert", "function of multiple unknowns"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([sin(x)^2 + 1 ~ 0, cos(y) - cos(x) - 1 ~ 0]) + @test_throws ["Cannot convert", "multiple non-polynomial terms", "same unknown"] HomotopyContinuationProblem( + sys, []) + + @mtkbuild sys = NonlinearSystem([sin(x^2)^2 + sin(x^2) - 1 ~ 0]) + @test_throws ["import Nemo"] HomotopyContinuationProblem(sys, []) +end + +import Nemo + +@testset "With Nemo" begin + @variables x = 2.0 + @mtkbuild sys = NonlinearSystem([sin(x^2)^2 + sin(x^2) - 1 ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + @test prob[1] ≈ 2.0 + # singlerootalg doesn't converge + sol = solve(prob, allrootsalg).u[1] + _x = sol[1] + @test sin(_x^2)^2 + sin(_x^2) - 1≈0.0 atol=1e-12 +end + +@testset "Function of polynomial" begin + @variables x=0.25 y=0.125 + a = sin(x^2 - 4x + 1) + b = cos(3log(y) + 4) + @mtkbuild sys = NonlinearSystem([(a^2 - 5a * b + 6b^2) / (a - 0.25) ~ 0 + (a^2 - 0.75a + 0.125) ~ 0]) + prob = HomotopyContinuationProblem(sys, []) + @test prob[x] ≈ 0.25 + @test prob[y] ≈ 0.125 + sol = solve(prob, allrootsalg).u[1] + @test SciMLBase.successful_retcode(sol) + @test sol[a]≈0.5 atol=1e-6 + @test isapprox(sol[b], 0.25; atol = 1e-6) || isapprox(sol[b], 0.5 / 3; atol = 1e-6) +end + +@testset "Rational functions" begin + @variables x=2.0 y=2.0 + @parameters n = 5 + @mtkbuild sys = NonlinearSystem([ + 0 ~ (x^2 - n * x + 6) * (x - 1) / (x - 2) / (x - 3) + ]) + prob = HomotopyContinuationProblem(sys, []) + sol = solve_allroots_closest(prob) + @test sol[x] ≈ 1.0 + p = parameter_values(prob) + for invalid in [2.0, 3.0] + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([invalid + err, 2.0], p)) + end + end + + @named sys = NonlinearSystem( + [ + 0 ~ (x - 2) / (x - 4) + ((x - 3) / (y - 7)) / ((x^2 - 4x + y) / (x - 2.5)), + 0 ~ ((y - 3) / (y - 4)) * (n / (y - 5)) + ((x - 1.5) / (x - 5.5))^2 + ], + [x, y], + [n]; defaults = [n => 4]) + sys = complete(sys) + prob = HomotopyContinuationProblem(sys, []) + sol = solve(prob, singlerootalg) + disallowed_x = [4, 5.5] + disallowed_y = [7, 5, 4] + @test all(!isapprox(sol[x]; atol = 1e-8), disallowed_x) + @test all(!isapprox(sol[y]; atol = 1e-8), disallowed_y) + @test abs(sol[x^2 - 4x + y]) >= 1e-8 + + p = parameter_values(prob) + for val in disallowed_x + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([val + err, 2.0], p)) + end + end + for val in disallowed_y + for err in [-9e-8, 0, 9e-8] + @test any(<=(1e-7), prob.f.denominator([2.0, val + err], p)) + end + end + @test prob.f.denominator([2.0, 4.0], p)[1] <= 1e-8 + + @testset "Rational function in observed" begin + @variables x=1 y=1 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 - 2x - 2 ~ 0, y ~ (x - 1) / (x - 2)]) + prob = HomotopyContinuationProblem(sys, []) + @test any(prob.f.denominator([2.0], parameter_values(prob)) .≈ 0.0) + @test SciMLBase.successful_retcode(solve(prob, singlerootalg)) + end + + @testset "Rational function forced to common denominators" begin + @variables x = 1 + @mtkbuild sys = NonlinearSystem([0 ~ 1 / (1 + x) - x]) + prob = HomotopyContinuationProblem(sys, []) + @test any(prob.f.denominator([-1.0], parameter_values(prob)) .≈ 0.0) + sol = solve(prob, singlerootalg) + @test SciMLBase.successful_retcode(sol) + @test 1 / (1 + sol.u[1]) - sol.u[1]≈0.0 atol=1e-10 + end +end + +@testset "Non-polynomial observed not used in equations" begin + @variables x=1 y + @mtkbuild sys = NonlinearSystem([x^2 - 2 ~ 0, y ~ sin(x)]) + prob = HomotopyContinuationProblem(sys, []) + sol = @test_nowarn solve(prob, singlerootalg) + @test sol[x] ≈ √2.0 + @test sol[y] ≈ sin(√2.0) +end + +@testset "`fraction_cancel_fn`" begin + @variables x = 1 + @named sys = NonlinearSystem([0 ~ ((x^2 - 5x + 6) / (x - 2) - 1) * (x^2 - 7x + 12) / + (x - 4)^3]) + sys = complete(sys) + + @testset "`simplify_fractions`" begin + prob = HomotopyContinuationProblem(sys, []) + @test prob.f.denominator([0.0], parameter_values(prob)) ≈ [4.0] + end + @testset "`nothing`" begin + prob = HomotopyContinuationProblem(sys, []; fraction_cancel_fn = nothing) + @test sort(prob.f.denominator([0.0], parameter_values(prob))) ≈ [2.0, 4.0^3] + end +end diff --git a/test/extensions/test_infiniteopt.jl b/test/extensions/test_infiniteopt.jl new file mode 100644 index 0000000000..e45aa0f2fd --- /dev/null +++ b/test/extensions/test_infiniteopt.jl @@ -0,0 +1,102 @@ +using ModelingToolkit, InfiniteOpt, JuMP, Ipopt +using ModelingToolkit: D_nounits as D, t_nounits as t, varmap_to_vars + +@mtkmodel Pendulum begin + @parameters begin + g = 9.8 + L = 0.4 + K = 1.2 + m = 0.3 + end + @variables begin + θ(t) # state + ω(t) # state + τ(t) = 0 # input + y(t) # output + end + @equations begin + D(θ) ~ ω + D(ω) ~ -g / L * sin(θ) - K / m * ω + τ / m / L^2 + y ~ θ * 180 / π + end +end +@named model = Pendulum() +model = complete(model) + +inputs = [model.τ] +(f_oop, f_ip), dvs, psym, io_sys = ModelingToolkit.generate_control_function( + model, inputs, split = false) + +outputs = [model.y] +f_obs = ModelingToolkit.build_explicit_observed_function(io_sys, outputs; inputs = inputs) + +expected_state_order = [model.θ, model.ω] +permutation = [findfirst(isequal(x), expected_state_order) for x in dvs] # This maps our expected state order to the actual state order + +## + +ub = varmap_to_vars([model.θ => 2pi, model.ω => 10], dvs) +lb = varmap_to_vars([model.θ => -2pi, model.ω => -10], dvs) +xf = varmap_to_vars([model.θ => pi, model.ω => 0], dvs) +nx = length(dvs) +nu = length(inputs) +ny = length(outputs) + +## +m = InfiniteModel(optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "acceptable_tol" => 1e-3, "constr_viol_tol" => 1e-5, "max_iter" => 1000, + "tol" => 1e-5, "mu_strategy" => "monotone", "nlp_scaling_method" => "gradient-based", + "alpha_for_y" => "safer-min-dual-infeas", "bound_mult_init_method" => "mu-based", "print_user_options" => "yes")); + +@infinite_parameter(m, τ in [0, 1], num_supports=51, + derivative_method=OrthogonalCollocation(4)) # Time variable +guess_xs = [t -> pi, t -> 0.1][permutation] +guess_us = [t -> 0.1] +InfiniteOpt.@variables(m, + begin + # state variables + (lb[i] <= x[i = 1:nx] <= ub[i], Infinite(τ), start = guess_xs[i]) # state variables + -10 <= u[i = 1:nu] <= 10, Infinite(τ), (start = guess_us[i]) # control variables + 0 <= tf <= 10, (start = 5) # Final time + 0.2 <= L <= 0.6, (start = 0.4) # Length parameter + end) + +# Trace the dynamics +x0, p = ModelingToolkit.get_u0_p(io_sys, [model.θ => 0, model.ω => 0], [model.L => L]) + +xp = f_oop(x, u, p, τ) +cp = f_obs(x, u, p, τ) # Test that it's possible to trace through an observed function + +@objective(m, Min, tf) +@constraint(m, [i = 1:nx], x[i](0)==x0[i]) # Initial condition +@constraint(m, [i = 1:nx], x[i](1)==xf[i]) # Terminal state + +x_scale = varmap_to_vars([model.θ => 1 + model.ω => 1], dvs) + +# Add dynamics constraints +@constraint(m, [i = 1:nx], (∂(x[i], τ) - tf * xp[i]) / x_scale[i]==0) + +optimize!(m) + +# Extract the optimal solution +opt_tf = value(tf) +opt_time = opt_tf * value(τ) +opt_x = [value(x[i]) for i in permutation] +opt_u = [value(u[i]) for i in 1:nu] +opt_L = value(L) + +# Plot the results +# using Plots +# plot(opt_time, opt_x[1], label = "θ", xlabel = "Time [s]", layout=3) +# plot!(opt_time, opt_x[2], label = "ω", sp=2) +# plot!(opt_time, opt_u[1], label = "τ", sp=3) + +using Test +@test opt_x[1][end]≈pi atol=1e-3 +@test opt_x[2][end]≈0 atol=1e-3 + +@test opt_x[1][1]≈0 atol=1e-3 +@test opt_x[2][1]≈0 atol=1e-3 + +@test opt_L≈0.2 atol=1e-3 # Smallest permissible length is optimal diff --git a/test/fmi/Project.toml b/test/fmi/Project.toml new file mode 100644 index 0000000000..59bd3c90da --- /dev/null +++ b/test/fmi/Project.toml @@ -0,0 +1,8 @@ +[deps] +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" +FMIZoo = "724179cf-c260-40a9-bd27-cccc6fe2f195" +ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" +OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" + +[compat] +FMI = "0.14" diff --git a/test/fmi/fmi.jl b/test/fmi/fmi.jl new file mode 100644 index 0000000000..98c93398ff --- /dev/null +++ b/test/fmi/fmi.jl @@ -0,0 +1,313 @@ +using ModelingToolkit, FMI, FMIZoo, OrdinaryDiffEq, NonlinearSolve, SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D +import ModelingToolkit as MTK + +const FMU_DIR = joinpath(@__DIR__, "fmus") + +@testset "Standalone pendulum model" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) + truesol = FMI.simulate( + fmu, (0.0, 8.0); saveat = 0.0:0.1:8.0, recordValues = ["mass.s", "mass.v"]) + + function test_no_inputs_outputs(sys) + for var in unknowns(sys) + @test !MTK.isinput(var) + @test !MTK.isoutput(var) + end + end + @testset "v2, ME" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :ME) + @mtkbuild sys = MTK.FMIComponent(Val(2); fmu, type = :ME) + test_no_inputs_outputs(sys) + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.mass__s, sys.mass__v]).u≈collect.(truesol.values.saveval) atol=1e-4 + # repeated solve works + @test_nowarn solve(prob, Tsit5()) + end + @testset "v2, CS" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2022x"; type = :CS) + @named inner = MTK.FMIComponent( + Val(2); fmu, communication_step_size = 1e-5, type = :CS) + @variables x(t) = 1.0 + @mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) + test_no_inputs_outputs(sys) + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.inner.mass__s, sys.inner.mass__v]).u≈collect.(truesol.values.saveval) rtol=1e-2 + end + + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :ME) + truesol = FMI.simulate( + fmu, (0.0, 8.0); saveat = 0.0:0.1:8.0, recordValues = ["mass.s", "mass.v"]) + @testset "v3, ME" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :ME) + @mtkbuild sys = MTK.FMIComponent(Val(3); fmu, type = :ME) + test_no_inputs_outputs(sys) + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.mass__s => 0.5, sys.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.mass__s, sys.mass__v]).u≈collect.(truesol.values.saveval) atol=1e-4 + # repeated solve works + @test_nowarn solve(prob, Tsit5()) + end + @testset "v3, CS" begin + fmu = loadFMU("SpringPendulum1D", "Dymola", "2023x", "3.0"; type = :CS) + @named inner = MTK.FMIComponent( + Val(3); fmu, communication_step_size = 1e-5, type = :CS) + @variables x(t) = 1.0 + @mtkbuild sys = ODESystem([D(x) ~ x], t; systems = [inner]) + test_no_inputs_outputs(sys) + + prob = ODEProblem{true, SciMLBase.FullSpecialize}( + sys, [sys.inner.mass__s => 0.5, sys.inner.mass__v => 0.0], (0.0, 8.0)) + sol = solve(prob, Tsit5(); reltol = 1e-8, abstol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test sol(0.0:0.1:8.0; + idxs = [sys.inner.mass__s, sys.inner.mass__v]).u≈collect.(truesol.values.saveval) rtol=1e-2 + end +end + +@mtkmodel SimpleAdder begin + @variables begin + a(t) + b(t) + c(t) + out(t) + out2(t) + end + @parameters begin + value = 1.0 + end + @equations begin + out ~ a + b + value + D(c) ~ out + out2 ~ 2c + end +end + +@mtkmodel StateSpace begin + @variables begin + x(t) + y(t) + u(t) + end + @parameters begin + A = 1.0 + B = 1.0 + C = 1.0 + _D = 1.0 + end + @equations begin + D(x) ~ A * x + B * u + y ~ C * x + _D * u + end +end + +@testset "IO Model" begin + function build_simple_adder(adder) + @variables a(t) b(t) c(t) [guess = 1.0] + @mtkbuild sys = ODESystem( + [adder.a ~ a, adder.b ~ b, D(a) ~ t, + D(b) ~ adder.out + adder.c, c^2 ~ adder.out + adder.value], + t; + systems = [adder]) + # c will be solved for by initialization + # this tests that initialization also works with FMUs + prob = ODEProblem(sys, [sys.adder.c => 2.0, sys.a => 1.0, sys.b => 1.0], + (0.0, 1.0), [sys.adder.value => 2.0]) + return sys, prob + end + + @named adder = SimpleAdder() + truesys, trueprob = build_simple_adder(adder) + truesol = solve(trueprob, abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v2, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :ME) + @named adder = MTK.FMIComponent(Val(2); fmu, type = :ME) + @test MTK.isinput(adder.a) + @test MTK.isinput(adder.b) + @test MTK.isoutput(adder.out) + @test MTK.isoutput(adder.out2) + @test !MTK.isinput(adder.c) && !MTK.isoutput(adder.c) + + sys, prob = build_simple_adder(adder) + sol = solve(prob, Rodas5P(autodiff = false), abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; + idxs = [truesys.a, truesys.b, truesys.c, truesys.adder.c]).u≈sol[[ + sys.a, sys.b, sys.c, sys.adder.c]] rtol=1e-7 + end + @testset "v2, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :CS) + @named adder = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-6, + reinitializealg = BrownFullBasicInit()) + @test MTK.isinput(adder.a) + @test MTK.isinput(adder.b) + @test MTK.isoutput(adder.out) + @test MTK.isoutput(adder.out2) + @test !MTK.isinput(adder.c) && !MTK.isoutput(adder.c) + + sys, prob = build_simple_adder(adder) + sol = solve(prob, Rodas5P(autodiff = false), abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; idxs = [truesys.a, truesys.b, truesys.c]).u≈sol.u rtol=4e-2 + # sys.adder.c is a discrete variable + @test truesol(sol.t; idxs = truesys.adder.c).u≈sol(sol.t; idxs = sys.adder.c).u rtol=4e-2 + end + + function build_sspace_model(sspace) + @variables u(t)=1.0 x(t)=1.0 y(t) [guess = 1.0] + @mtkbuild sys = ODESystem( + [sspace.u ~ u, D(u) ~ t, D(x) ~ sspace.x + sspace.y, y^2 ~ sspace.y + sspace.x], t; + systems = [sspace] + ) + + prob = ODEProblem( + sys, [sys.sspace.x => 1.0], (0.0, 1.0), [sys.sspace.A => 2.0]; use_scc = false) + return sys, prob + end + + @named sspace = StateSpace() + truesys, trueprob = build_sspace_model(sspace) + truesol = solve(trueprob, abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v3, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :ME) + @named sspace = MTK.FMIComponent(Val(3); fmu, type = :ME) + @test MTK.isinput(sspace.u) + @test MTK.isoutput(sspace.y) + @test !MTK.isinput(sspace.x) && !MTK.isoutput(sspace.x) + + sys, prob = build_sspace_model(sspace) + sol = solve(prob, Rodas5P(autodiff = false); abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol(sol.t; + idxs = [truesys.u, truesys.x, truesys.y, truesys.sspace.x]).u≈sol[[ + sys.u, sys.x, sys.y, sys.sspace.x]] rtol=1e-7 + end + + @testset "v3, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :CS) + @named sspace = MTK.FMIComponent( + Val(3); fmu, communication_step_size = 1e-6, type = :CS, + reinitializealg = BrownFullBasicInit()) + @test MTK.isinput(sspace.u) + @test MTK.isoutput(sspace.y) + @test !MTK.isinput(sspace.x) && !MTK.isoutput(sspace.x) + + sys, prob = build_sspace_model(sspace) + sol = solve(prob, Rodas5P(autodiff = false); abstol = 1e-8, reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + + @test truesol( + sol.t; idxs = [truesys.u, truesys.x, truesys.y]).u≈sol[[sys.u, sys.x, sys.y]] rtol=1e-2 + @test truesol(sol.t; idxs = truesys.sspace.x).u≈sol(sol.t; idxs = sys.sspace.x).u rtol=1e-2 + end +end + +@testset "FMUs in a loop" begin + function build_looped_adders(adder1, adder2) + @variables x(t) = 1 + @mtkbuild sys = ODESystem( + [D(x) ~ x, adder1.a ~ adder2.out2, + adder2.a ~ adder1.out2, adder1.b ~ 1.0, adder2.b ~ 2.0], + t; + systems = [adder1, adder2]) + prob = ODEProblem( + sys, [adder1.c => 1.0, adder2.c => 1.0, adder1.a => 2.0], + (0.0, 1.0); guesses = [adder2.a => 0.0]) + return sys, prob + end + @named adder1 = SimpleAdder() + @named adder2 = SimpleAdder() + truesys, trueprob = build_looped_adders(adder1, adder2) + truesol = solve(trueprob, Tsit5(), reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v2, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :ME) + @named adder1 = MTK.FMIComponent(Val(2); fmu, type = :ME) + @named adder2 = MTK.FMIComponent(Val(2); fmu, type = :ME) + sys, prob = build_looped_adders(adder1, adder2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.adder1.c, truesys.adder2.c]).u≈sol( + sol.t; idxs = [sys.adder1.c, sys.adder2.c]).u rtol=1e-7 + end + @testset "v2, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "SimpleAdder.fmu"); type = :CS) + @named adder1 = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-5) + @named adder2 = MTK.FMIComponent( + Val(2); fmu, type = :CS, communication_step_size = 1e-5) + sys, prob = build_looped_adders(adder1, adder2) + sol = solve(prob, + Tsit5(); + reltol = 1e-8, + initializealg = SciMLBase.OverrideInit(nlsolve = FastShortcutNLLSPolyalg(autodiff = AutoFiniteDiff()))) + @test truesol(sol.t; + idxs = [truesys.adder1.c, truesys.adder2.c]).u≈sol( + sol.t; idxs = [sys.adder1.c, sys.adder2.c]).u rtol=1e-3 + end + + function build_looped_sspace(sspace1, sspace2) + @variables x(t) = 1 + @mtkbuild sys = ODESystem([D(x) ~ x, sspace1.u ~ sspace2.x, sspace2.u ~ sspace1.y], + t; systems = [sspace1, sspace2]) + prob = ODEProblem(sys, [sspace1.x => 1.0, sspace2.x => 1.0], (0.0, 1.0)) + return sys, prob + end + @named sspace1 = StateSpace() + @named sspace2 = StateSpace() + truesys, trueprob = build_looped_sspace(sspace1, sspace2) + truesol = solve(trueprob, Rodas5P(), reltol = 1e-8) + @test SciMLBase.successful_retcode(truesol) + + @testset "v3, ME" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :ME) + @named sspace1 = MTK.FMIComponent(Val(3); fmu, type = :ME) + @named sspace2 = MTK.FMIComponent(Val(3); fmu, type = :ME) + sys, prob = build_looped_sspace(sspace1, sspace2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.sspace1.x, truesys.sspace2.x]).u≈sol( + sol.t; idxs = [sys.sspace1.x, sys.sspace2.x]).u rtol=1e-7 + end + + @testset "v3, CS" begin + fmu = loadFMU(joinpath(FMU_DIR, "StateSpace.fmu"); type = :CS) + @named sspace1 = MTK.FMIComponent( + Val(3); fmu, type = :CS, communication_step_size = 1e-5) + @named sspace2 = MTK.FMIComponent( + Val(3); fmu, type = :CS, communication_step_size = 1e-5) + sys, prob = build_looped_sspace(sspace1, sspace2) + sol = solve(prob, Rodas5P(autodiff = false); reltol = 1e-8) + @test SciMLBase.successful_retcode(sol) + @test truesol(sol.t; + idxs = [truesys.sspace1.x, truesys.sspace2.x]).u≈sol( + sol.t; idxs = [sys.sspace1.x, sys.sspace2.x]).u rtol=1e-2 + end +end diff --git a/test/fmi/fmus/SimpleAdder.fmu b/test/fmi/fmus/SimpleAdder.fmu new file mode 100644 index 0000000000..ed12961bfb Binary files /dev/null and b/test/fmi/fmus/SimpleAdder.fmu differ diff --git a/test/fmi/fmus/SimpleAdder/README.md b/test/fmi/fmus/SimpleAdder/README.md new file mode 100644 index 0000000000..b26c247bce --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/README.md @@ -0,0 +1,3 @@ +SimpleAdder.fmu is a v2 FMU built using OpenModelica. The file `SimpleAdder.mo` contains the +modelica source code for the model. The file `buildFMU.mos` is the OpenModelica script used +to build the FMU. diff --git a/test/fmi/fmus/SimpleAdder/SimpleAdder.mo b/test/fmi/fmus/SimpleAdder/SimpleAdder.mo new file mode 100644 index 0000000000..ce97f09a86 --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/SimpleAdder.mo @@ -0,0 +1,12 @@ +block SimpleAdder + parameter Real value = 1.0; + input Real a; + input Real b; + Real c(start = 1.0, fixed = true); + output Real out; + output Real out2; +equation + der(c) = out; + out = a + b + value; + out2 = 2 * c; +end SimpleAdder; diff --git a/test/fmi/fmus/SimpleAdder/buildFmu.mos b/test/fmi/fmus/SimpleAdder/buildFmu.mos new file mode 100644 index 0000000000..df2c9bd58e --- /dev/null +++ b/test/fmi/fmus/SimpleAdder/buildFmu.mos @@ -0,0 +1,12 @@ +OpenModelica.Scripting.loadFile("SimpleAdder.mo"); getErrorString(); + +installPackage(Modelica); + +setCommandLineOptions("-d=newInst"); getErrorString(); +setCommandLineOptions("-d=initialization"); getErrorString(); +setCommandLineOptions("-d=-disableDirectionalDerivatives"); getErrorString(); + +cd("output"); getErrorString(); +buildModelFMU(SimpleAdder, version = "2.0", fmuType = "me_cs"); getErrorString(); +system("unzip -l SimpleAdder.fmu | egrep -v 'sources|files' | tail -n+3 | grep -o '[A-Za-z._0-9/]*$' > BB.log") + diff --git a/test/fmi/fmus/StateSpace.fmu b/test/fmi/fmus/StateSpace.fmu new file mode 100644 index 0000000000..6138e1fdb2 Binary files /dev/null and b/test/fmi/fmus/StateSpace.fmu differ diff --git a/test/fmi/fmus/StateSpace/0001-tmp-commit.patch b/test/fmi/fmus/StateSpace/0001-tmp-commit.patch new file mode 100644 index 0000000000..951af2d4fd --- /dev/null +++ b/test/fmi/fmus/StateSpace/0001-tmp-commit.patch @@ -0,0 +1,425 @@ +From 10ac73996a7c09772ff31ccd6e030112d3bb5c43 Mon Sep 17 00:00:00 2001 +From: Aayush Sabharwal +Date: Wed, 8 Jan 2025 11:04:01 +0000 +Subject: tmp commit + +--- + StateSpace/FMI3.xml | 31 ++---- + StateSpace/config.h | 18 ++-- + StateSpace/model.c | 230 +++++++++++++------------------------------- + 3 files changed, 83 insertions(+), 196 deletions(-) + +diff --git a/StateSpace/FMI3.xml b/StateSpace/FMI3.xml +index 8d2e4d9..002fbab 100644 +--- a/StateSpace/FMI3.xml ++++ b/StateSpace/FMI3.xml +@@ -33,36 +33,23 @@ + + + +- +- +- ++ + +- +- +- ++ + +- +- +- ++ + +- +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + +- +- ++ + + + +diff --git a/StateSpace/config.h b/StateSpace/config.h +index 707e500..8b422e2 100644 +--- a/StateSpace/config.h ++++ b/StateSpace/config.h +@@ -44,15 +44,15 @@ typedef struct { + uint64_t m; + uint64_t n; + uint64_t r; +- double A[M_MAX][N_MAX]; +- double B[M_MAX][N_MAX]; +- double C[M_MAX][N_MAX]; +- double D[M_MAX][N_MAX]; +- double x0[N_MAX]; +- double u[N_MAX]; +- double y[N_MAX]; +- double x[N_MAX]; +- double der_x[N_MAX]; ++ double A; ++ double B; ++ double C; ++ double D; ++ double x0; ++ double u; ++ double y; ++ double x; ++ double der_x; + } ModelData; + + #endif /* config_h */ +diff --git a/StateSpace/model.c b/StateSpace/model.c +index 8a47e74..9c170d8 100644 +--- a/StateSpace/model.c ++++ b/StateSpace/model.c +@@ -3,77 +3,29 @@ + + + void setStartValues(ModelInstance *comp) { +- +- M(m) = 3; +- M(n) = 3; +- M(r) = 3; +- + // identity matrix +- for (int i = 0; i < M_MAX; i++) +- for (int j = 0; j < N_MAX; j++) { +- M(A)[i][j] = i == j ? 1 : 0; +- M(B)[i][j] = i == j ? 1 : 0; +- M(C)[i][j] = i == j ? 1 : 0; +- M(D)[i][j] = i == j ? 1 : 0; +- } +- +- for (int i = 0; i < M_MAX; i++) { +- M(u)[i] = i + 1; +- } +- +- for (int i = 0; i < N_MAX; i++) { +- M(y)[i] = 0; +- } +- +- for (int i = 0; i < N_MAX; i++) { +- M(x)[i] = M(x0)[i]; +- M(x)[i] = 0; +- } +- ++ M(m) = 1; ++ M(n) = 1; ++ M(r) = 1; ++ ++ M(A) = 1; ++ M(B) = 1; ++ M(C) = 1; ++ M(D) = 1; ++ ++ M(u) = 1; ++ M(y) = 0; ++ M(x) = 0; ++ M(x0) = 0; + } + + Status calculateValues(ModelInstance *comp) { +- +- // der(x) = Ax + Bu +- for (size_t i = 0; i < M(n); i++) { +- +- M(der_x)[i] = 0; +- +- for (size_t j = 0; j < M(n); j++) { +- M(der_x)[i] += M(A)[i][j] * M(x)[j]; +- } +- } +- +- for (size_t i = 0; i < M(n); i++) { +- +- for (size_t j = 0; j < M(r); j++) { +- M(der_x)[i] += M(B)[i][j] * M(u)[j]; +- } +- } +- +- +- // y = Cx + Du +- for (size_t i = 0; i < M(r); i++) { +- +- M(y)[i] = 0; +- +- for (size_t j = 0; j < M(n); j++) { +- M(y)[i] += M(C)[i][j] * M(x)[j]; +- } +- } +- +- for (size_t i = 0; i < M(r); i++) { +- +- for (size_t j = 0; j < M(m); j++) { +- M(y)[i] += M(D)[i][j] * M(u)[j]; +- } +- } +- ++ M(der_x) = M(A) * M(x) + M(B) * M(u); ++ M(y) = M(C) * M(x) + M(D) * M(u); + return OK; + } + + Status getFloat64(ModelInstance* comp, ValueReference vr, double values[], size_t nValues, size_t* index) { +- + calculateValues(comp); + + switch (vr) { +@@ -82,66 +34,40 @@ Status getFloat64(ModelInstance* comp, ValueReference vr, double values[], size_ + values[(*index)++] = comp->time; + return OK; + case vr_A: +- ASSERT_NVALUES((size_t)(M(n) * M(n))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(A)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(A); + return OK; + case vr_B: +- ASSERT_NVALUES((size_t)(M(m) * M(n))); +- for (size_t i = 0; i < M(m); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(B)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(B); + return OK; + case vr_C: +- ASSERT_NVALUES((size_t)(M(r) * M(n))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(n); j++) { +- values[(*index)++] = M(C)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(C); + return OK; + case vr_D: +- ASSERT_NVALUES((size_t)(M(r) * M(m))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(m); j++) { +- values[(*index)++] = M(D)[i][j]; +- } +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(D); + return OK; + case vr_x0: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(x0)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(x0); + return OK; + case vr_u: +- ASSERT_NVALUES((size_t)M(m)); +- for (size_t i = 0; i < M(m); i++) { +- values[(*index)++] = M(u)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(u); + return OK; + case vr_y: +- ASSERT_NVALUES((size_t)M(r)); +- for (size_t i = 0; i < M(r); i++) { +- values[(*index)++] = M(y)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(y); + return OK; + case vr_x: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(x)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(x); + return OK; + case vr_der_x: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- values[(*index)++] = M(der_x)[i]; +- } ++ ASSERT_NVALUES(1); ++ values[(*index)++] = M(der_x); + return OK; + default: + logError(comp, "Get Float64 is not allowed for value reference %u.", vr); +@@ -153,58 +79,40 @@ Status setFloat64(ModelInstance* comp, ValueReference vr, const double values[], + + switch (vr) { + case vr_A: +- ASSERT_NVALUES((size_t)(M(n) * M(n))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(n); j++) { +- M(A)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(A) = values[(*index)++]; + break; + case vr_B: +- ASSERT_NVALUES((size_t)(M(n) * M(m))); +- for (size_t i = 0; i < M(n); i++) { +- for (size_t j = 0; j < M(m); j++) { +- M(B)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(B) = values[(*index)++]; + break; + case vr_C: +- ASSERT_NVALUES((size_t)(M(r) * M(n))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(n); j++) { +- M(C)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(C) = values[(*index)++]; + break; + case vr_D: +- ASSERT_NVALUES((size_t)(M(r) * M(m))); +- for (size_t i = 0; i < M(r); i++) { +- for (size_t j = 0; j < M(m); j++) { +- M(D)[i][j] = values[(*index)++]; +- } +- } ++ ASSERT_NVALUES(1); ++ M(D) = values[(*index)++]; + break; + case vr_x0: +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- M(x0)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(x0) = values[(*index)++]; + break; + case vr_u: +- ASSERT_NVALUES((size_t)M(m)); +- for (size_t i = 0; i < M(m); i++) { +- M(u)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(u) = values[(*index)++]; ++ break; ++ case vr_y: ++ ASSERT_NVALUES(1); ++ M(y) = values[(*index)++]; + break; + case vr_x: +- if (comp->state != ContinuousTimeMode && comp->state != EventMode) { +- logError(comp, "Variable \"x\" can only be set in Continuous Time Mode and Event Mode."); +- return Error; +- } +- ASSERT_NVALUES((size_t)M(n)); +- for (size_t i = 0; i < M(n); i++) { +- M(x)[i] = values[(*index)++]; +- } ++ ASSERT_NVALUES(1); ++ M(x) = values[(*index)++]; ++ break; ++ case vr_der_x: ++ ASSERT_NVALUES(1); ++ M(der_x) = values[(*index)++]; + break; + default: + logError(comp, "Set Float64 is not allowed for value reference %u.", vr); +@@ -217,8 +125,6 @@ Status setFloat64(ModelInstance* comp, ValueReference vr, const double values[], + } + + Status getUInt64(ModelInstance* comp, ValueReference vr, uint64_t values[], size_t nValues, size_t* index) { +- +- + switch (vr) { + case vr_m: + ASSERT_NVALUES(1); +@@ -289,49 +195,43 @@ Status eventUpdate(ModelInstance *comp) { + } + + size_t getNumberOfContinuousStates(ModelInstance* comp) { +- return (size_t)M(n); ++ return M(n) == 1 ? M(n) : 1; + } + + Status getContinuousStates(ModelInstance* comp, double x[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + +- for (size_t i = 0; i < M(n); i++) { +- x[i] = M(x)[i]; +- } ++ x[0] = M(x); + + return OK; + } + + Status setContinuousStates(ModelInstance* comp, const double x[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + +- for (size_t i = 0; i < M(n); i++) { +- M(x)[i] = x[i]; +- } ++ M(x) = x[0]; + + return OK; + } + + Status getDerivatives(ModelInstance* comp, double dx[], size_t nx) { + +- if (nx != M(n)) { +- logError(comp, "Expected nx=%zu but was %zu.", M(n), nx); ++ if (nx != 1) { ++ logError(comp, "Expected nx=%zu but was %zu.", 1, nx); + return Error; + } + + calculateValues(comp); + +- for (size_t i = 0; i < M(n); i++) { +- dx[i] = M(der_x)[i]; +- } ++ dx[0] = M(der_x); + + return OK; + } +-- +2.34.1 + diff --git a/test/fmi/fmus/StateSpace/README.md b/test/fmi/fmus/StateSpace/README.md new file mode 100644 index 0000000000..108147443d --- /dev/null +++ b/test/fmi/fmus/StateSpace/README.md @@ -0,0 +1,7 @@ +StateSpace.fmu is a v3 FMU built using a modified version of the StateSpace FMU in Modelica's +Reference-FMUs. The link to the specific commit hash used is + +[https://github.com/modelica/Reference-FMUs/tree/0e1374cad0a7f0583a583ce189060ba7a67c27a7]() + +The git patch containing the changes is provided as `0001-tmp-commit.patch`. The FMU can be +built using the instructions provided in the repository's README. diff --git a/test/function_registration.jl b/test/function_registration.jl index a52eb3245a..a1d9041127 100644 --- a/test/function_registration.jl +++ b/test/function_registration.jl @@ -22,7 +22,7 @@ sys = complete(sys) fun = ODEFunction(sys) u0 = 5.0 -@test fun([0.5], [u0], 0.0) == [do_something(u0) * 2] +@test fun([0.5], u0, 0.0) == [do_something(u0) * 2] end # TEST: Function registration in a nested module. @@ -45,7 +45,7 @@ sys = complete(sys) fun = ODEFunction(sys) u0 = 3.0 -@test fun([0.5], [u0], 0.0) == [do_something_2(u0) * 2] +@test fun([0.5], u0, 0.0) == [do_something_2(u0) * 2] end end @@ -67,7 +67,7 @@ sys = complete(sys) fun = ODEFunction(sys) u0 = 7.0 -@test fun([0.5], [u0], 0.0) == [do_something_3(u0) * 2] +@test fun([0.5], u0, 0.0) == [do_something_3(u0) * 2] # TEST: Function registration works with derivatives. # --------------------------------------------------- @@ -106,7 +106,7 @@ end function run_test() fun = build_ode() u0 = 10.0 - @test fun([0.5], [u0], 0.0) == [do_something_4(u0) * 2] + @test fun([0.5], u0, 0.0) == [do_something_4(u0) * 2] end run_test() diff --git a/test/generate_custom_function.jl b/test/generate_custom_function.jl deleted file mode 100644 index 9b64b20c12..0000000000 --- a/test/generate_custom_function.jl +++ /dev/null @@ -1,52 +0,0 @@ -using ModelingToolkit -using ModelingToolkit: t_nounits as t, D_nounits as D - -@variables x(t) y(t)[1:3] -@parameters p1=1.0 p2[1:3]=[1.0, 2.0, 3.0] p3::Int=1 p4::Bool=false - -sys = complete(ODESystem(Equation[], t, [x; y], [p1, p2, p3, p4]; name = :sys)) -u0 = [1.0, 2.0, 3.0, 4.0] -p = ModelingToolkit.MTKParameters(sys, []) - -fn1 = generate_custom_function(sys, x + y[1] + p1 + p2[1] + p3 * t; expression = Val(false)) -@test fn1(u0, p, 0.0) == 5.0 - -fn2 = generate_custom_function( - sys, x + y[1] + p1 + p2[1] + p3 * t, [x], [p1, p2, p3]; expression = Val(false)) -@test fn1(u0, p, 0.0) == 5.0 - -fn3_oop, fn3_iip = generate_custom_function( - sys, [x + y[2], y[3] + p2[2], p1 + p3, 3t]; expression = Val(false)) - -buffer = zeros(4) -fn3_iip(buffer, u0, p, 1.0) -@test buffer == [4.0, 6.0, 2.0, 3.0] -@test fn3_oop(u0, p, 1.0) == [4.0, 6.0, 2.0, 3.0] - -fn4 = generate_custom_function(sys, ifelse(p4, p1, p2[2]); expression = Val(false)) -@test fn4(u0, p, 1.0) == 2.0 -fn5 = generate_custom_function(sys, ifelse(!p4, p1, p2[2]); expression = Val(false)) -@test fn5(u0, p, 1.0) == 1.0 - -@variables x y[1:3] -sys = complete(NonlinearSystem(Equation[], [x; y], [p1, p2, p3, p4]; name = :sys)) - -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 diff --git a/test/if_lifting.jl b/test/if_lifting.jl new file mode 100644 index 0000000000..9c58e676d0 --- /dev/null +++ b/test/if_lifting.jl @@ -0,0 +1,126 @@ +using ModelingToolkit, OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D, IfLifting, no_if_lift + +@testset "Simple `abs(x)`" begin + @mtkmodel SimpleAbs begin + @variables begin + x(t) + y(t) + end + @equations begin + D(x) ~ abs(y) + y ~ sin(t) + end + end + @named sys = SimpleAbs() + ss1 = structural_simplify(sys) + @test length(equations(ss1)) == 1 + ss2 = structural_simplify(sys, additional_passes = [IfLifting]) + @test length(equations(ss2)) == 1 + @test length(parameters(ss2)) == 1 + @test operation(only(equations(ss2)).rhs) === ifelse + + discvar = only(parameters(ss2)) + prob1 = ODEProblem(ss1, [ss1.x => 0.0], (0.0, 5.0)) + sol1 = solve(prob1, Tsit5()) + prob2 = ODEProblem(ss2, [ss2.x => 0.0], (0.0, 5.0)) + sol2 = solve(prob2, Tsit5()) + @test count(isapprox(pi), sol2.t) == 2 + @test any(isapprox(pi), sol2.discretes[1].t) + @test !sol2[discvar][1] + @test sol2[discvar][end] + + _t = pi + 1.0 + # x(t) = 1 - cos(t) in [0, pi) + # x(t) = 3 + cos(t) in [pi, 2pi) + _trueval = 3 + cos(_t) + @test !isapprox(sol1(_t)[1], _trueval; rtol = 1e-3) + @test isapprox(sol2(_t)[1], _trueval; rtol = 1e-3) +end + +@testset "Big test case" begin + @mtkmodel BigModel begin + @variables begin + x(t) + y(t) + z(t) + c(t)::Bool + w(t) + q(t) + r(t) + end + @parameters begin + p + end + @equations begin + # ifelse, max, min + D(x) ~ ifelse(c, max(x, y), min(x, y)) + # discrete observed + c ~ x <= y + # observed should also get if-lifting + y ~ abs(sin(t)) + # should be ignored + D(z) ~ no_if_lift(ifelse(x < y, x, y)) + # ignore time-independent ifelse + D(w) ~ ifelse(p < 3, 1.0, 2.0) + # all the boolean operators + D(q) ~ ifelse((x < 1) & ((y < 0.5) | ifelse(y > 0.8, c, !c)), 1.0, 2.0) + # don't touch time-independent condition, but modify time-dependent branches + D(r) ~ ifelse(p < 2, abs(x), max(y, 0.9)) + end + end + + @named sys = BigModel() + ss = structural_simplify(sys, additional_passes = [IfLifting]) + + ps = parameters(ss) + @test length(ps) == 9 + eqs = equations(ss) + obs = observed(ss) + + @testset "no_if_lift is untouched" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.z)), eqs) + eq = eqs[idx] + @test isequal(eq.rhs, no_if_lift(ifelse(ss.x < ss.y, ss.x, ss.y))) + end + @testset "time-independent ifelse is untouched" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.w)), eqs) + eq = eqs[idx] + @test operation(arguments(eq.rhs)[1]) === Base.:< + end + @testset "time-dependent branch of time-independent condition is modified" begin + idx = findfirst(eq -> isequal(eq.lhs, D(ss.r)), eqs) + eq = eqs[idx] + @test operation(eq.rhs) === ifelse + args = arguments(eq.rhs) + @test operation(args[1]) == Base.:< + @test operation(args[2]) === ifelse + condvars = ModelingToolkit.vars(arguments(args[2])[1]) + @test length(condvars) == 1 && any(isequal(only(condvars)), ps) + @test operation(args[3]) === ifelse + condvars = ModelingToolkit.vars(arguments(args[3])[1]) + @test length(condvars) == 1 && any(isequal(only(condvars)), ps) + end + @testset "Observed variables are modified" begin + idx = findfirst(eq -> isequal(eq.lhs, ss.c), obs) + eq = obs[idx] + @test operation(eq.rhs) === Base.:! && any(isequal(only(arguments(eq.rhs))), ps) + idx = findfirst(eq -> isequal(eq.lhs, ss.y), obs) + eq = obs[idx] + @test operation(eq.rhs) === ifelse + end +end + +@testset "`@mtkbuild` macro accepts `additional_passes`" begin + @mtkmodel SimpleAbs begin + @variables begin + x(t) + y(t) + end + @equations begin + D(x) ~ abs(y) + y ~ sin(t) + end + end + @test_nowarn @mtkbuild sys=SimpleAbs() additional_passes=[IfLifting] +end diff --git a/test/implicit_discrete_system.jl b/test/implicit_discrete_system.jl new file mode 100644 index 0000000000..932b6c6981 --- /dev/null +++ b/test/implicit_discrete_system.jl @@ -0,0 +1,75 @@ +using ModelingToolkit, Test +using ModelingToolkit: t_nounits as t +using StableRNGs + +k = ShiftIndex(t) +rng = StableRNG(22525) + +@testset "Correct ImplicitDiscreteFunction" begin + @variables x(t) = 1 + @mtkbuild sys = ImplicitDiscreteSystem([x(k) ~ x(k) * x(k - 1) - 3], t) + tspan = (0, 10) + + # u[2] - u_next[1] + # -3 - u_next[2] + u_next[2]*u_next[1] + f = ImplicitDiscreteFunction(sys) + u_next = [3.0, 1.5] + @test f(u_next, [2.0, 3.0], [], t) ≈ [0.0, 0.0] + u_next = [0.0, 0.0] + @test f(u_next, [2.0, 3.0], [], t) ≈ [3.0, -3.0] + + resid = rand(2) + f(resid, u_next, [2.0, 3.0], [], t) + @test resid ≈ [3.0, -3.0] + + prob = ImplicitDiscreteProblem(sys, [x(k - 1) => 3.0], tspan) + @test prob.u0 == [3.0, 1.0] + prob = ImplicitDiscreteProblem(sys, [], tspan) + @test prob.u0 == [1.0, 1.0] + @variables x(t) + @mtkbuild sys = ImplicitDiscreteSystem([x(k) ~ x(k) * x(k - 1) - 3], t) + @test_throws ErrorException prob=ImplicitDiscreteProblem(sys, [], tspan) +end + +@testset "System with algebraic equations" begin + @variables x(t) y(t) + eqs = [x(k) ~ x(k - 1) + x(k - 2), + x^2 ~ 1 - y^2] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + f = ImplicitDiscreteFunction(sys) + + function correct_f(u_next, u, p, t) + [u[2] - u_next[1], + u[1] + u[2] - u_next[2], + 1 - (u_next[1] + u_next[2])^2 - u_next[3]^2] + end + + for _ in 1:10 + u_next = rand(rng, 3) + u = rand(rng, 3) + @test correct_f(u_next, u, [], 0.0) ≈ f(u_next, u, [], 0.0) + end + + # Initialization is satisfied. + prob = ImplicitDiscreteProblem( + sys, [x(k - 1) => 0.3, x(k - 2) => 0.4], (0, 10), guesses = [y => 1]) + @test length(equations(prob.f.initialization_data.initializeprob.f.sys)) == 1 +end + +@testset "Handle observables in function codegen" begin + # Observable appears in differential equation + @variables x(t) y(t) z(t) + eqs = [x(k) ~ x(k - 1) + x(k - 2), + y(k) ~ x(k) + x(k - 2) * z(k - 1), + x + y + z ~ 2] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + @test length(unknowns(sys)) == length(equations(sys)) == 3 + @test occursin("var\"y(t)\"", string(ImplicitDiscreteFunctionExpr(sys))) + + # Shifted observable that appears in algebraic equation is properly handled. + eqs = [z(k) ~ x(k) + sin(x(k)), + y(k) ~ x(k - 1) + x(k - 2), + z(k) * x(k) ~ 3] + @mtkbuild sys = ImplicitDiscreteSystem(eqs, t) + @test occursin("var\"Shift(t, 1)(z(t))\"", string(ImplicitDiscreteFunctionExpr(sys))) +end diff --git a/test/index_cache.jl b/test/index_cache.jl index 3bee1db381..455203d759 100644 --- a/test/index_cache.jl +++ b/test/index_cache.jl @@ -1,4 +1,4 @@ -using ModelingToolkit, SymbolicIndexingInterface +using ModelingToolkit, SymbolicIndexingInterface, SciMLStructures using ModelingToolkit: t_nounits as t # Ensure indexes of array symbolics are cached appropriately @@ -43,3 +43,78 @@ ic = ModelingToolkit.get_index_cache(sys) @test isequal(ic.symbol_to_variable[:x], x) @test isequal(ic.symbol_to_variable[:y], y) @test isequal(ic.symbol_to_variable[:z], z) + +@testset "tunable_parameters is ordered" begin + @parameters p q[1:3] r[1:2, 1:2] s [tunable = false] + @named sys = ODESystem(Equation[], t, [], [p, q, r, s]) + sys = complete(sys) + @test all(splat(isequal), zip(tunable_parameters(sys), parameters(sys)[1:3])) + + offset = 1 + for par in tunable_parameters(sys) + idx = parameter_index(sys, par) + @test idx.portion isa SciMLStructures.Tunable + if Symbolics.isarraysymbolic(par) + @test vec(idx.idx) == offset:(offset + length(par) - 1) + else + @test idx.idx == offset + end + offset += length(par) + end +end + +@testset "reorder_dimension_by_tunables" begin + @parameters p q[1:3] r[1:2, 1:2] s [tunable = false] + @named sys = ODESystem(Equation[], t, [], [p, q, r, s]) + src = ones(8) + dst = zeros(8) + # system must be complete... + @test_throws ArgumentError reorder_dimension_by_tunables!(dst, sys, src, [p, q, r]) + @test_throws ArgumentError reorder_dimension_by_tunables(sys, src, [p, q, r]) + sys = complete(sys; split = false) + # with split = true... + @test_throws ArgumentError reorder_dimension_by_tunables!(dst, sys, src, [p, q, r]) + @test_throws ArgumentError reorder_dimension_by_tunables(sys, src, [p, q, r]) + sys = complete(sys) + # and the arrays must have matching size + @test_throws ArgumentError reorder_dimension_by_tunables!( + zeros(2, 4), sys, src, [p, q, r]) + + ps = MTKParameters(sys, [p => 1.0, q => 3ones(3), r => 4ones(2, 2), s => 0.0]) + src = ps.tunable + reorder_dimension_by_tunables!(dst, sys, src, [q, r, p]) + @test dst ≈ vcat(3ones(3), 4ones(4), 1.0) + @test reorder_dimension_by_tunables(sys, src, [r, p, q]) ≈ vcat(4ones(4), 1.0, 3ones(3)) + reorder_dimension_by_tunables!(dst, sys, src, [q[1], r[:, 1], q[2], r[:, 2], q[3], p]) + @test dst ≈ vcat(3.0, 4ones(2), 3.0, 4ones(2), 3.0, 1.0) + src = stack([copy(ps.tunable) for i in 1:5]; dims = 1) + dst = zeros(size(src)) + reorder_dimension_by_tunables!(dst, sys, src, [r, q, p]; dim = 2) + @test dst ≈ stack([vcat(4ones(4), 3ones(3), 1.0) for i in 1:5]; dims = 1) +end + +mutable struct ParamTest + y::Any +end +(pt::ParamTest)(x) = pt.y - x +@testset "Issue#3215: Callable discrete parameter" begin + function update_affect!(integ, u, p, ctx) + integ.p[p.p_1].y = integ.t + end + + tp1 = typeof(ParamTest(1)) + @parameters (p_1::tp1)(..) = ParamTest(1) + @variables x(ModelingToolkit.t_nounits) = 0 + + event1 = [1.0, 2, 3] => (update_affect!, [], [p_1], [p_1], nothing) + + @named sys = ODESystem([ + ModelingToolkit.D_nounits(x) ~ p_1(x) + ], + ModelingToolkit.t_nounits; + discrete_events = [event1] + ) + ss = @test_nowarn complete(sys) + @test length(parameters(ss)) == 1 + @test !is_timeseries_parameter(ss, p_1) +end diff --git a/test/initial_values.jl b/test/initial_values.jl index 2ff1b0c2a3..79b6b8e067 100644 --- a/test/initial_values.jl +++ b/test/initial_values.jl @@ -1,5 +1,7 @@ using ModelingToolkit using ModelingToolkit: t_nounits as t, D_nounits as D, get_u0 +using OrdinaryDiffEq +using DataInterpolations using SymbolicIndexingInterface: getu @variables x(t)[1:3]=[1.0, 2.0, 3.0] y(t) z(t)[1:2] @@ -119,3 +121,163 @@ end prob = ODEProblem(sys, [], (1.0, 2.0), []) @test prob[x] == 1.0 @test prob.ps[p] == 2.0 + +@testset "Array of symbolics is unwrapped" begin + @variables x(t)[1:2] y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t], t; defaults = [x => [y, 3.0]]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test eltype(prob.u0) <: Float64 + prob = ODEProblem(sys, [x => [y, 4.0], y => 2.0], (0.0, 1.0)) + @test eltype(prob.u0) <: Float64 +end + +@testset "split=false systems with all parameter defaults" begin + @variables x(t) = 1.0 + @parameters p=1.0 q=2.0 r=3.0 + @mtkbuild sys=ODESystem(D(x) ~ p * x + q * t + r, t) split=false + prob = @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + @test prob.p isa Vector{Float64} +end + +@testset "Issue#3153" begin + @variables x(t) y(t) + @parameters c1 c2 c3 + eqs = [D(x) ~ y, + y ~ ifelse(t < c1, 0.0, (-c1 + t)^(c3))] + sps = [x, y] + ps = [c1, c2, c3] + @mtkbuild osys = ODESystem(eqs, t, sps, ps) + u0map = [x => 1.0] + pmap = [c1 => 5.0, c2 => 1.0, c3 => 1.2] + oprob = ODEProblem(osys, u0map, (0.0, 10.0), pmap) +end + +@testset "Cyclic dependency checking and substitution limits" begin + @variables x(t) y(t) + @mtkbuild sys = ODESystem( + [D(x) ~ x, D(y) ~ y], t; initialization_eqs = [x ~ 2y + 3, y ~ 2x], + guesses = [x => 2y, y => 2x]) + @test_warn ["Cycle", "unknowns", "x", "y"] try + ODEProblem(sys, [], (0.0, 1.0), warn_cyclic_dependency = true) + catch + end + @test_throws ModelingToolkit.UnexpectedSymbolicValueInVarmap ODEProblem( + sys, [x => 2y + 1, y => 2x], (0.0, 1.0); build_initializeprob = false) + + @parameters p q + @mtkbuild sys = ODESystem( + [D(x) ~ x * p, D(y) ~ y * q], t; guesses = [p => 1.0, q => 2.0]) + # "unknowns" because they are initialization unknowns + @test_warn ["Cycle", "unknowns", "p", "q"] try + ODEProblem(sys, [x => 1, y => 2], (0.0, 1.0), + [p => 2q, q => 3p]; warn_cyclic_dependency = true) + catch + end + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + sys, [x => 1, y => 2], (0.0, 1.0), [p => 2q, q => 3p]) +end + +@testset "`add_fallbacks!` checks scalarized array parameters correctly" begin + @variables x(t)[1:2] + @parameters p[1:2, 1:2] + @mtkbuild sys = ODESystem(D(x) ~ p * x, t) + # used to throw a `MethodError` complaining about `getindex(::Nothing, ::CartesianIndex{2})` + @test_throws ModelingToolkit.MissingParametersError ODEProblem( + sys, [x => ones(2)], (0.0, 1.0)) +end + +@testset "Unscalarized default for scalarized observed variable" begin + @parameters p[1:4] = rand(4) + @variables x(t)[1:4] y(t)[1:2] + eqs = [ + D(x) ~ x, + y[1] ~ x[3], + y[2] ~ x[4] + ] + @mtkbuild sys = ODESystem(eqs, t; defaults = [x => vcat(ones(2), y), y => x[1:2] ./ 2]) + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.u[1] ≈ [1.0, 1.0, 0.5, 0.5] +end + +@testset "Missing/cyclic guesses throws error" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + + @test_throws ModelingToolkit.MissingGuessError ODEProblem( + pend, [x => 1], (0, 1), [g => 1], guesses = [y => λ, λ => y + 1]) + ODEProblem(pend, [x => 1], (0, 1), [g => 1], guesses = [y => λ, λ => 0.5]) + + # Throw multiple if multiple are missing + @variables a(t) b(t) c(t) d(t) e(t) + eqs = [D(a) ~ b, D(b) ~ c, D(c) ~ d, D(d) ~ e, D(e) ~ 1] + @mtkbuild sys = ODESystem(eqs, t) + @test_throws ["a(t)", "c(t)"] ODEProblem( + sys, [e => 2, a => b, b => a + 1, c => d, d => c + 1], (0, 1)) +end + +@testset "Issue#3490: `remake` works with callable parameters" begin + ts = collect(0.0:0.1:10.0) + spline = LinearInterpolation(ts .^ 2, ts) + Tspline = typeof(spline) + @variables x(t) + @parameters (interp::Tspline)(..) + + @mtkbuild sys = ODESystem(D(x) ~ interp(t), t) + + prob = ODEProblem(sys, [x => 0.0], (0.0, 1.0), [interp => spline]) + spline2 = LinearInterpolation(ts .^ 2, ts .^ 2) + p_new = [interp => spline2] + prob2 = remake(prob; p = p_new) + @test prob2.ps[interp] == spline2 +end + +@testset "Issue#3523: don't substitute inside initial in `build_operating_point!`" begin + @variables (X(t))[1:2] + @parameters p[1:2] + eqs = [ + 0 ~ p[1] - X[1], + 0 ~ p[2] - X[2] + ] + @named nlsys = NonlinearSystem(eqs) + nlsys = complete(nlsys) + + # Creates the `NonlinearProblem`. + u0 = [X => [1.0, 2.0]] + ps = [p => [4.0, 5.0]] + @test_nowarn NonlinearProblem(nlsys, u0, ps) +end + +@testset "Issue#3553: Retain `Float32` initial values" begin + @parameters p d + @variables X(t) + eqs = [D(X) ~ p - d * X] + @mtkbuild osys = ODESystem(eqs, t) + u0 = [X => 1.0f0] + ps = [p => 1.0f0, d => 2.0f0] + oprob = ODEProblem(osys, u0, (0.0f0, 1.0f0), ps) + sol = solve(oprob) + @test eltype(oprob.u0) == Float32 + @test eltype(eltype(sol.u)) == Float32 +end + +@testset "Array initials and scalar parameters with `split = false`" begin + @variables x(t)[1:2] + @parameters p + @mtkbuild sys=ODESystem([D(x[1]) ~ x[1], D(x[2]) ~ x[2] + p], t) split=false + ps = Set(parameters(sys; initial_parameters = true)) + @test length(ps) == 5 + for i in 1:2 + @test Initial(x[i]) in ps + @test Initial(D(x[i])) in ps + end + @test p in ps + prob = ODEProblem(sys, [x => ones(2)], (0.0, 1.0), [p => 1.0]) + @test prob.p isa Vector{Float64} + @test length(prob.p) == 5 +end diff --git a/test/initializationsystem.jl b/test/initializationsystem.jl index 08c66e85e6..0f40d4eaf1 100644 --- a/test/initializationsystem.jl +++ b/test/initializationsystem.jl @@ -1,5 +1,10 @@ using ModelingToolkit, OrdinaryDiffEq, NonlinearSolve, Test -using ModelingToolkit: t_nounits as t, D_nounits as D +using StochasticDiffEq, DelayDiffEq, StochasticDelayDiffEq, JumpProcesses +using ForwardDiff +using SymbolicIndexingInterface, SciMLStructures +using SciMLStructures: Tunable +using ModelingToolkit: t_nounits as t, D_nounits as D, observed +using DynamicQuantities @parameters g @variables x(t) y(t) [state_priority = 10] λ(t) @@ -24,10 +29,10 @@ sol = solve(initprob) initprob = ModelingToolkit.InitializationProblem(pend, 0.0, [x => 1, y => 0], [g => 1]; guesses = ModelingToolkit.missing_variable_defaults(pend)) -@test initprob isa NonlinearProblem +@test initprob isa NonlinearLeastSquaresProblem sol = solve(initprob) @test SciMLBase.successful_retcode(sol) -@test sol.u == [0.0, 0.0, 0.0, 0.0] +@test all(iszero, sol.u) @test maximum(abs.(sol[conditions])) < 1e-14 initprob = ModelingToolkit.InitializationProblem( @@ -111,7 +116,7 @@ end x′ end @variables begin - p(t) = p′ + p(t) x(t) = x′ dm(t) = 0 f(t) = p′ * A @@ -401,30 +406,35 @@ sol = solve(prob, Tsit5()) @test sol.u[1] == [1.0] # Steady state initialization - -@parameters σ ρ β -@variables x(t) y(t) z(t) - -eqs = [D(D(x)) ~ σ * (y - x), - D(y) ~ x * (ρ - z) - y, - D(z) ~ x * y - β * z] - -@named sys = ODESystem(eqs, t) -sys = structural_simplify(sys) - -u0 = [D(x) => 2.0, - x => 1.0, - D(y) => 0.0, - z => 0.0] - -p = [σ => 28.0, - ρ => 10.0, - β => 8 / 3] - -tspan = (0.0, 0.2) -prob_mtk = ODEProblem(sys, u0, tspan, p) -sol = solve(prob_mtk, Tsit5()) -@test sol[x * (ρ - z) - y][1] == 0.0 +@testset "Steady state initialization" begin + @parameters σ ρ β + @variables x(t) y(t) z(t) + + eqs = [D(D(x)) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + @named sys = ODESystem(eqs, t) + sys = structural_simplify(sys) + + u0 = [D(x) => 2.0, + x => 1.0, + D(y) => 0.0, + z => 0.0] + + p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + + tspan = (0.0, 0.2) + prob_mtk = ODEProblem(sys, u0, tspan, p) + sol = solve(prob_mtk, Tsit5()) + @test sol[x * (ρ - z) - y][1] == 0.0 + + prob_mtk.ps[Initial(D(y))] = 1.0 + sol = solve(prob_mtk, Tsit5()) + @test sol[x * (ρ - z) - y][1] == 1.0 +end @variables x(t) y(t) z(t) @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 @@ -468,7 +478,7 @@ prob = ODEProblem(pend, [x => 1], (0.0, 1.5), [g => 1], unsimp = generate_initializesystem(pend; u0map = [x => 1], initialization_eqs = [y ~ 1]) sys = structural_simplify(unsimp; fully_determined = false) -@test length(equations(sys)) == 3 +@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 @@ -484,7 +494,8 @@ sys = extend(sysx, sysy) @variables x(t) y(t) @named sys = ODESystem([x^2 + y^2 ~ 25, D(x) ~ 1], t) ssys = structural_simplify(sys) - @test_throws ArgumentError ODEProblem(ssys, [x => 3], (0, 1), []) # y should have a guess + @test_throws ModelingToolkit.MissingVariablesError ODEProblem( + ssys, [x => 3], (0, 1), []) # y should have a guess end # https://github.com/SciML/ModelingToolkit.jl/issues/3025 @@ -510,7 +521,7 @@ end end # https://github.com/SciML/ModelingToolkit.jl/issues/3029 -@testset "Derivatives in Initialization Equations" begin +@testset "Derivatives in initialization equations" begin @variables x(t) sys = ODESystem( [D(D(x)) ~ 0], t; initialization_eqs = [x ~ 0, D(x) ~ 1], name = :sys) |> @@ -523,6 +534,20 @@ end @test_nowarn ODEProblem(sys, [D(x) => 1.0], (0.0, 1.0), []) end +# https://github.com/SciML/ModelingToolkit.jl/issues/3049 +@testset "Derivatives in initialization guesses" begin + for sign in [-1.0, +1.0] + @variables x(t) + sys = ODESystem( + [D(D(x)) ~ 0], t; + initialization_eqs = [D(x)^2 ~ 1, x ~ 0], guesses = [D(x) => sign], name = :sys + ) |> structural_simplify + prob = ODEProblem(sys, [], (0.0, 1.0), []) + sol = solve(prob, Tsit5()) + @test sol(1.0, idxs = sys.x) ≈ sign # system with D(x(0)) = ±1 should solve to x(1) = ±1 + end +end + # https://github.com/SciML/ModelingToolkit.jl/issues/2619 @parameters k1 k2 ω @variables X(t) Y(t) @@ -553,3 +578,921 @@ oprob_2nd_order_2 = ODEProblem(sys_2nd_order, u0_2nd_order_2, tspan, ps) sol = solve(oprob_2nd_order_2, Rosenbrock23()) # retcode: Success @test sol[Y][1] == 2.0 @test sol[D(Y)][1] == 0.5 + +@testset "Vector in initial conditions" begin + @variables x(t)[1:5] y(t)[1:5] + @named sys = ODESystem([D(x) ~ x, D(y) ~ y], t; initialization_eqs = [y ~ -x]) + sys = structural_simplify(sys) + prob = ODEProblem(sys, [sys.x => ones(5)], (0.0, 1.0), []) + sol = solve(prob, Tsit5(), reltol = 1e-4) + @test all(sol(1.0, idxs = sys.x) .≈ +exp(1)) && all(sol(1.0, idxs = sys.y) .≈ -exp(1)) +end + +@testset "Initialization of parameters" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + # `System` constructor creates appropriate type with mtkbuild + # `Problem` and `alg` create the problem to test and allow calling `init` with + # the correct solver. + # `rhss` allows adding terms to the end of equations (only 2 equations allowed) to influence + # the system type (brownian vars to turn it into an SDE). + @testset "$Problem with $(SciMLBase.parameterless_type(alg))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + function test_parameter(prob, sym, val) + if prob.u0 !== nothing + @test init(prob, alg).ps[sym] ≈ val + end + @test solve(prob, alg).ps[sym] ≈ val + end + function test_initializesystem(sys, u0map, pmap, p, equation) + isys = ModelingToolkit.generate_initializesystem( + sys; u0map, pmap, guesses = ModelingToolkit.guesses(sys)) + @test is_variable(isys, p) + @test equation in equations(isys) || (0 ~ -equation.rhs) in equations(isys) + end + + u0map = Dict(x => 1.0, y => 1.0) + pmap = Dict() + pmap[q] = 1.0 + # `missing` default, equation from Problem + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => missing], guesses = [p => 1.0]) + pmap[p] = 2q + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + # `missing` default, provided guess + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; defaults = [p => missing], guesses = [p => 0.0]) + prob = Problem(sys, u0map, (0.0, 1.0)) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ p - x - y) + prob2 = remake(prob; u0 = u0map) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # `missing` to Problem, equation from default + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + pmap[p] = missing + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ 2q - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + # `missing` to Problem, provided guess + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [p => 0.0]) + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ x + y - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # No `missing`, default and guess + @mtkbuild sys = System( + [D(x) ~ x * q + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 0.0]) + delete!(pmap, p) + prob = Problem(sys, u0map, (0.0, 1.0), pmap) + test_parameter(prob, p, 2.0) + test_initializesystem(sys, u0map, pmap, p, 0 ~ 2q - p) + prob2 = remake(prob; u0 = u0map, p = pmap) + prob2.ps[p] = 0.0 + test_parameter(prob2, p, 2.0) + + # Default overridden by Problem, guess provided + @mtkbuild sys = System( + [D(x) ~ q * x + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + _pmap = merge(pmap, Dict(p => q)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + test_parameter(prob, p, _pmap[q]) + test_initializesystem(sys, u0map, _pmap, p, 0 ~ q - p) + # Problem dependent value with guess, no `missing` + @mtkbuild sys = System( + [D(x) ~ y * q + p + rhss[1], D(y) ~ x * p + q + rhss[2]], t; guesses = [p => 0.0]) + _pmap = merge(pmap, Dict(p => 3q)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + test_parameter(prob, p, 3pmap[q]) + + # Should not be solved for: + # Override dependent default with direct value + @mtkbuild sys = System( + [D(x) ~ q * x + rhss[1], D(y) ~ y * p + rhss[2]], t; defaults = [p => 2q], guesses = [p => 1.0]) + _pmap = merge(pmap, Dict(p => 1.0)) + prob = Problem(sys, u0map, (0.0, 1.0), _pmap) + @test prob.ps[p] ≈ 1.0 + initsys = prob.f.initialization_data.initializeprob.f.sys + @test is_parameter(initsys, p) + + # Non-floating point + @parameters r::Int s::Int + @mtkbuild sys = System( + [D(x) ~ s * x + rhss[1], D(y) ~ y * r + rhss[2]], t; defaults = [s => 2r], guesses = [s => 1.0]) + prob = Problem(sys, u0map, (0.0, 1.0), [r => 1]) + @test prob.ps[r] == 1 + @test prob.ps[s] == 2 + initsys = prob.f.initialization_data.initializeprob.f.sys + @test is_parameter(initsys, r) + @test is_parameter(initsys, s) + + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [p => 0.0]) + @test_throws ModelingToolkit.MissingParametersError Problem( + sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + + # Unsatisfiable initialization + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0), + [p => 2.0]; initialization_eqs = [x^2 + y^2 ~ 3]) + @test prob.f.initialization_data !== nothing + @test solve(prob, alg).retcode == ReturnCode.InitialFailure + cache = init(prob, alg) + @test solve!(cache).retcode == ReturnCode.InitialFailure + end + + @testset "Null system" begin + @variables x(t) y(t) s(t) + @parameters x0 y0 + @mtkbuild sys = ODESystem([x ~ x0, y ~ y0, s ~ x + y], t; guesses = [y0 => 0.0]) + prob = ODEProblem(sys, [s => 1.0], (0.0, 1.0), [x0 => 0.3, y0 => missing]) + # trivial initialization run immediately + @test prob.ps[y0] ≈ 0.7 + @test init(prob, Tsit5()).ps[y0] ≈ 0.7 + @test solve(prob, Tsit5()).ps[y0] ≈ 0.7 + end + + using ModelingToolkitStandardLibrary.Mechanical.TranslationalModelica: Fixed, Mass, + Spring, Force, + Damper + using ModelingToolkitStandardLibrary.Mechanical: TranslationalModelica as TM + using ModelingToolkitStandardLibrary.Blocks: Constant + + @named mass = TM.Mass(; m = 1.0, s = 1.0, v = 0.0, a = 0.0) + @named fixed = Fixed(; s0 = 0.0) + @named spring = Spring(; c = 2.0, s_rel0 = nothing) + @named gravity = Force() + @named constant = Constant(; k = 9.81) + @named damper = TM.Damper(; d = 0.1) + @mtkbuild sys = ODESystem( + [connect(fixed.flange, spring.flange_a), connect(spring.flange_b, mass.flange_a), + connect(mass.flange_a, gravity.flange), connect(constant.output, gravity.f), + connect(fixed.flange, damper.flange_a), connect(damper.flange_b, mass.flange_a)], + t; + systems = [fixed, spring, mass, gravity, constant, damper], + guesses = [spring.s_rel0 => 1.0]) + prob = ODEProblem(sys, [], (0.0, 1.0), [spring.s_rel0 => missing]) + # trivial initialization run immediately + @test prob.ps[spring.s_rel0] ≈ -3.905 + @test init(prob, Tsit5()).ps[spring.s_rel0] ≈ -3.905 + @test solve(prob, Tsit5()).ps[spring.s_rel0] ≈ -3.905 +end + +@testset "NonlinearSystem initialization" begin + nl_algs = [FastShortcutNonlinearPolyalg(), NewtonRaphson(), + Klement(), SimpleNewtonRaphson(), DFSane()] + nlls_algs = [FastShortcutNLLSPolyalg(), LevenbergMarquardt(), SimpleGaussNewton()] + + @testset "No initialization for variables" begin + @variables x=1.0 y=0.0 z=0.0 + @parameters σ=10.0 ρ=26.0 β=8 / 3 + + eqs = [0 ~ σ * (y - x), + 0 ~ x * (ρ - z) - y, + 0 ~ x * y - β * z] + @mtkbuild ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) + + prob = NonlinearProblem(ns, []) + @test prob.f.initialization_data.update_initializeprob! === nothing + @test prob.f.initialization_data.initializeprobmap === nothing + @test prob.f.initialization_data.initializeprobpmap === nothing + for alg in nl_algs + @test SciMLBase.successful_retcode(solve(prob, alg)) + end + + prob = NonlinearLeastSquaresProblem(ns, []) + @test prob.f.initialization_data.update_initializeprob! === nothing + @test prob.f.initialization_data.initializeprobmap === nothing + @test prob.f.initialization_data.initializeprobpmap === nothing + for alg in nlls_algs + @test SciMLBase.successful_retcode(solve(prob, alg)) + end + end + + prob_alg_combinations = zip( + [NonlinearProblem, NonlinearLeastSquaresProblem], [nl_algs, nlls_algs]) + @testset "Parameter initialization" begin + function test_parameter(prob, alg, param, val) + integ = init(prob, alg) + @test integ.ps[param]≈val rtol=1e-5 + # some algorithms are a little temperamental + sol = solve(prob, alg) + @test sol.ps[param]≈val rtol=1e-5 + @test SciMLBase.successful_retcode(sol) + end + + @parameters p=2.0 q=missing [guess = 1.0] c=1.0 + @variables x=1.0 y=2.0 z=3.0 + + eqs = [0 ~ p * (y - x), + 0 ~ x * (q - z) - y, + 0 ~ x * y - c * z] + @mtkbuild sys = NonlinearSystem(eqs; initialization_eqs = [p^2 + q^2 + 2p * q ~ 0]) + # @mtkbuild sys = NonlinearSystem( + # [p * x^2 + q * y^3 ~ 0, x - q ~ 0]; defaults = [q => missing], + # guesses = [q => 1.0], initialization_eqs = [p^2 + q^2 + 2p * q ~ 0]) + + for (probT, algs) in prob_alg_combinations + prob = probT(sys, []) + @test prob.f.initialization_data !== nothing + @test prob.f.initialization_data.initializeprobmap === nothing + for alg in algs + test_parameter(prob, alg, q, -2.0) + end + + # `update_initializeprob!` works + prob.ps[p] = -2.0 + for alg in algs + test_parameter(prob, alg, q, 2.0) + end + prob.ps[p] = 2.0 + + # `remake` works + prob2 = remake(prob; p = [p => -2.0]) + @test prob2.f.initialization_data !== nothing + @test prob2.f.initialization_data.initializeprobmap === nothing + for alg in algs + test_parameter(prob2, alg, q, 2.0) + end + + # changing types works + ps = parameter_values(prob) + newps = SciMLStructures.replace(Tunable(), ps, ForwardDiff.Dual.(ps.tunable)) + prob3 = remake(prob; p = newps) + @test prob3.f.initialization_data !== nothing + @test eltype(state_values(prob3.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob3.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + end + end +end + +@testset "Update initializeprob parameters" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; guesses = [x => 0.0, p => 0.0]) + prob = Problem(sys, [y => 1.0], (0.0, 1.0), [p => 3.0]) + @test prob.f.initialization_data.initializeprob.ps[p] ≈ 3.0 + @test init(prob, alg)[x] ≈ 2.0 + prob.ps[p] = 2.0 + @test prob.f.initialization_data.initializeprob.ps[p] ≈ 3.0 + @test init(prob, alg)[x] ≈ 1.0 + ModelingToolkit.defaults(prob.f.sys)[p] = missing + prob2 = remake(prob; u0 = [y => 1.0], p = [p => 3x]) + @test !is_variable(prob2.f.initialization_data.initializeprob, p) && + !is_parameter(prob2.f.initialization_data.initializeprob, p) + @test init(prob2, alg)[x] ≈ 0.5 + @test_nowarn solve(prob2, alg) + end +end + +@testset "Equations for dependent parameters" begin + @variables _x(..) + @parameters p q=5 r + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + @mtkbuild sys = System( + [D(x) ~ 2x + r + rhss], t; parameter_dependencies = [r ~ p + 2q, q ~ p + 3], + guesses = [p => 1.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => missing]) + @test length(equations(ModelingToolkit.get_parent(prob.f.initialization_data.initializeprob.f.sys))) == + 4 + integ = init(prob, alg) + @test integ.ps[p] ≈ 2 + end +end + +@testset "Re-creating initialization problem on remake" begin + @variables _x(..) y(t) + @parameters p q + @brownian a b + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), zeros(2)), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), [a, b]), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), [_x(t - 0.1), 0.0]), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), [_x(t - 0.1) + a, b]) + ] + @mtkbuild sys = System( + [D(x) ~ x + rhss[1], p ~ x + y + rhss[2]], t; defaults = [p => missing], guesses = [ + x => 0.0, p => 0.0]) + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test init(prob, alg).ps[p] ≈ 2.0 + # nonsensical value for y just to test that equations work + prob2 = remake(prob; u0 = [x => 1.0, y => 2x + exp(x)]) + @test init(prob2, alg).ps[p] ≈ 3 + exp(1) + # solve for `x` given `p` and `y` + prob3 = remake(prob; u0 = [x => nothing, y => 1.0], p = [p => 2x + exp(y)]) + @test init(prob3, alg)[x] ≈ 1 - exp(1) + @test_logs (:warn, r"overdetermined") remake( + prob; u0 = [x => 1.0, y => 2.0], p = [p => 4.0]) + prob4 = remake(prob; u0 = [x => 1.0, y => 2.0], p = [p => 4.0]) + @test solve(prob4, alg).retcode == ReturnCode.InitialFailure + prob5 = remake(prob) + @test init(prob, alg).ps[p] ≈ 2.0 + end +end + +@testset "`remake` changes initialization problem types" begin + @variables _x(..) y(t) z(t) + @parameters p q + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + alge_eqs = [y^2 * q + q^2 * x ~ 0, z * p - p^2 * x * z ~ 0] + + @mtkbuild sys = System( + [D(x) ~ x * p + y^2 * q + rhss; alge_eqs], + t; guesses = [x => 0.0, y => 0.0, z => 0.0, p => 0.0, q => 0.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0, q => missing]) + @test is_variable(prob.f.initialization_data.initializeprob, q) + ps = prob.p + newps = SciMLStructures.replace(Tunable(), ps, ForwardDiff.Dual.(ps.tunable)) + prob2 = remake(prob; p = newps) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = ForwardDiff.Dual.(prob.u0)) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = ForwardDiff.Dual.(prob.u0), p = newps) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + + prob2 = remake(prob; u0 = [x => ForwardDiff.Dual(1.0)], + p = [p => ForwardDiff.Dual(1.0), q => missing]) + @test eltype(state_values(prob2.f.initialization_data.initializeprob)) <: + ForwardDiff.Dual + @test eltype(prob2.f.initialization_data.initializeprob.p.tunable) <: + ForwardDiff.Dual + @test state_values(prob2.f.initialization_data.initializeprob) ≈ + state_values(prob.f.initialization_data.initializeprob) + @test eltype(prob2.p.initials) <: ForwardDiff.Dual + end +end + +@testset "`remake` preserves old u0map and pmap" begin + @variables _x(..) y(t) + @parameters p + @brownian a + x = _x(t) + + @testset "$Problem with $(SciMLBase.parameterless_type(typeof(alg)))" for (System, Problem, alg, rhss) in [ + (ModelingToolkit.System, ODEProblem, Tsit5(), 0), + (ModelingToolkit.System, SDEProblem, ImplicitEM(), a), + (ModelingToolkit.System, DDEProblem, MethodOfSteps(Tsit5()), _x(t - 0.1)), + (ModelingToolkit.System, SDDEProblem, ImplicitEM(), _x(t - 0.1) + a) + ] + alge_eqs = [y^2 + 4y * p^2 ~ x^3] + @mtkbuild sys = System( + [D(x) ~ x + p * y^2 + rhss; alge_eqs], t; guesses = [ + y => 1.0, p => 1.0]) + prob = Problem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + @test is_variable(prob.f.initialization_data.initializeprob, y) + prob2 = @test_nowarn remake(prob; p = [p => 3.0]) # ensure no over/under-determined warning + @test is_variable(prob.f.initialization_data.initializeprob, y) + + prob = Problem(sys, [y => 1.0, x => 2.0], (0.0, 1.0), [p => missing]) + @test is_variable(prob.f.initialization_data.initializeprob, p) + prob2 = @test_nowarn remake(prob; u0 = [y => 0.5]) + @test is_variable(prob.f.initialization_data.initializeprob, p) + end +end + +struct Multiplier{T} + a::T + b::T +end + +function (m::Multiplier)(x, y) + m.a * x + m.b * y +end + +@register_symbolic Multiplier(x::Real, y::Real) + +@testset "Nonnumeric parameter dependencies are retained" begin + @variables x(t) y(t) + @parameters foo(::Real, ::Real) p + @mtkbuild sys = ODESystem([D(x) ~ t, 0 ~ foo(x, y)], t; + parameter_dependencies = [foo ~ Multiplier(p, 2p)], guesses = [y => -1.0]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + integ = init(prob, Rosenbrock23()) + @test integ[y] ≈ -0.5 +end + +@testset "Use observed equations for guesses of observed variables" begin + @variables x(t) y(t) [state_priority = 100] + @mtkbuild sys = ODESystem( + [D(x) ~ x + t, y ~ 2x + 1], t; initialization_eqs = [x^3 + y^3 ~ 1]) + isys = ModelingToolkit.generate_initializesystem(sys) + @test isequal(defaults(isys)[y], 2x + 1) +end + +@testset "Create initializeprob when unknown has dependent value" begin + @variables x(t) y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t * y], t; defaults = [x => 2y]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test prob.f.initializeprob !== nothing + integ = init(prob) + @test integ[x] ≈ 2.0 + + @variables x(t)[1:2] y(t) + @mtkbuild sys = ODESystem([D(x) ~ x, D(y) ~ t], t; defaults = [x => [y, 3.0]]) + prob = ODEProblem(sys, [y => 1.0], (0.0, 1.0)) + @test prob.f.initializeprob !== nothing + integ = init(prob) + @test integ[x] ≈ [1.0, 3.0] +end + +@testset "units" begin + t = ModelingToolkit.t + D = ModelingToolkit.D + @parameters g [unit = u"m/s^2"] L=1 [unit = u"m^2"] + @variables x(t) [unit = u"m"] y(t) [unit = u"m" state_priority = 10] λ(t) [unit = u"s^-2"] + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ L] + @mtkbuild pend = ODESystem(eqs, t) + + prob = ODEProblem(pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], + guesses = ModelingToolkit.missing_variable_defaults(pend)) + sol = solve(prob, Rodas5P()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Issue#3205" begin + using ModelingToolkitStandardLibrary.Electrical + import ModelingToolkitStandardLibrary.Mechanical.Rotational as MR + using ModelingToolkitStandardLibrary.Blocks + using SciMLBase + + function dc_motor(R1 = 0.5) + R = R1 # [Ohm] armature resistance + L = 4.5e-3 # [H] armature inductance + k = 0.5 # [N.m/A] motor constant + J = 0.02 # [kg.m²] inertia + f = 0.01 # [N.m.s/rad] friction factor + tau_L_step = -0.3 # [N.m] amplitude of the load torque step + + @named ground = Ground() + @named source = Voltage() + @named ref = Blocks.Step(height = 0.2, start_time = 0) + @named pi_controller = Blocks.LimPI(k = 1.1, T = 0.035, u_max = 10, Ta = 0.035) + @named feedback = Blocks.Feedback() + @named R1 = Resistor(R = R) + @named L1 = Inductor(L = L) + @named emf = EMF(k = k) + @named fixed = MR.Fixed() + @named load = MR.Torque() + @named load_step = Blocks.Step(height = tau_L_step, start_time = 3) + @named inertia = MR.Inertia(J = J) + @named friction = MR.Damper(d = f) + @named speed_sensor = MR.SpeedSensor() + + connections = [connect(fixed.flange, emf.support, friction.flange_b) + connect(emf.flange, friction.flange_a, inertia.flange_a) + connect(inertia.flange_b, load.flange) + connect(inertia.flange_b, speed_sensor.flange) + connect(load_step.output, load.tau) + connect(ref.output, feedback.input1) + connect(speed_sensor.w, :y, feedback.input2) + connect(feedback.output, pi_controller.err_input) + connect(pi_controller.ctr_output, :u, source.V) + connect(source.p, R1.p) + connect(R1.n, L1.p) + connect(L1.n, emf.p) + connect(emf.n, source.n, ground.g)] + + @named model = ODESystem(connections, t, + systems = [ + ground, + ref, + pi_controller, + feedback, + source, + R1, + L1, + emf, + fixed, + load, + load_step, + inertia, + friction, + speed_sensor + ]) + end + + model = dc_motor() + sys = structural_simplify(model) + + prob = ODEProblem(sys, [sys.L1.i => 0.0], (0, 6.0)) + + @test_nowarn remake(prob, p = prob.p) +end + +@testset "Singular initialization prints a warning" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + @test_warn ["structurally singular", "initialization", "Guess", "heuristic"] ODEProblem( + pend, [x => 1, y => 0], (0.0, 1.5), [g => 1], guesses = [λ => 1]) +end + +@testset "DAEProblem initialization" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p=missing [guess = 1.0] q=missing [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * y + q * t, x^3 + y^3 ~ 5], t; initialization_eqs = [p^2 + q^3 ~ 3]) + + # FIXME: solve for du0 + prob = DAEProblem( + sys, [D(x) => cbrt(4), D(y) => -1 / cbrt(4)], [x => 1.0], (0.0, 1.0), [p => 1.0]) + + integ = init(prob, DImplicitEuler()) + @test integ[x] ≈ 1.0 + @test integ[y]≈cbrt(4) rtol=1e-6 + @test integ.ps[p] ≈ 1.0 + @test integ.ps[q]≈cbrt(2) rtol=1e-6 +end + +@testset "Guesses provided to `ODEProblem` are used in `remake`" begin + @variables x(t) y(t)=2x + @parameters p q=3x + @mtkbuild sys = ODESystem([D(x) ~ x * p + q, x^3 + y^3 ~ 3], t) + prob = ODEProblem( + sys, [], (0.0, 1.0), [p => 1.0]; guesses = [x => 1.0, y => 1.0, q => 1.0]) + @test prob[x] == 0.0 + @test prob[y] == 0.0 + @test prob.ps[p] == 1.0 + @test prob.ps[q] == 0.0 + integ = init(prob) + @test integ[x] ≈ 1 / cbrt(3) + @test integ[y] ≈ 2 / cbrt(3) + @test integ.ps[p] == 1.0 + @test integ.ps[q] ≈ 3 / cbrt(3) + prob2 = remake(prob; u0 = [y => 3x], p = [q => 2x]) + integ2 = init(prob2) + @test integ2[x] ≈ cbrt(3 / 28) + @test integ2[y] ≈ 3cbrt(3 / 28) + @test integ2.ps[p] == 1.0 + @test integ2.ps[q] ≈ 2cbrt(3 / 28) +end + +function test_dummy_initialization_equation(prob, var) + initsys = prob.f.initialization_data.initializeprob.f.sys + @test isempty(equations(initsys)) + idx = findfirst(eq -> isequal(var, eq.lhs), observed(initsys)) + @test idx !== nothing && is_parameter(initsys, observed(initsys)[idx].rhs) +end + +@testset "Remake problem with no initializeprob" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p [guess = 1.0] q [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * y, y ~ 2x], t; parameter_dependencies = [q ~ 2p]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => 1.0]) + test_dummy_initialization_equation(prob, x) + prob2 = remake(prob; u0 = [x => 2.0]) + @test prob2[x] == 2.0 + test_dummy_initialization_equation(prob2, x) + # otherwise we have `x ~ 2, y ~ 2` which is unsatisfiable + prob3 = remake(prob; u0 = [x => nothing, y => 2.0]) + @test prob3.f.initialization_data !== nothing + @test init(prob3)[x] ≈ 1.0 + prob4 = remake(prob; p = [p => 1.0]) + test_dummy_initialization_equation(prob4, x) + prob5 = remake(prob; p = [p => missing, q => 2.0]) + @test prob5.f.initialization_data !== nothing + @test init(prob5).ps[p] ≈ 1.0 +end + +@testset "Variables provided as symbols" begin + @variables x(t) [guess = 1.0] y(t) [guess = 1.0] + @parameters p [guess = 1.0] q [guess = 1.0] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * y, y ~ 2x], t; parameter_dependencies = [q ~ 2p]) + prob = ODEProblem(sys, [:x => 1.0], (0.0, 1.0), [p => 1.0]) + test_dummy_initialization_equation(prob, x) + prob2 = remake(prob; u0 = [:x => 2.0]) + test_dummy_initialization_equation(prob2, x) + prob3 = remake(prob; u0 = [:y => 1.0, :x => nothing]) + @test init(prob3)[x] ≈ 0.5 + @test SciMLBase.successful_retcode(solve(prob3)) +end + +@testset "Issue#3246: type promotion with parameter dependent initialization_eqs" begin + @variables x(t)=1 y(t)=1 + @parameters a = 1 + @named sys = ODESystem([D(x) ~ 0, D(y) ~ x + a], t; initialization_eqs = [y ~ a]) + + ssys = structural_simplify(sys) + prob = ODEProblem(ssys, [], (0, 1), []) + + @test SciMLBase.successful_retcode(solve(prob)) + + seta = setsym_oop(prob, [a]) + (newu0, newp) = seta(prob, ForwardDiff.Dual{ForwardDiff.Tag{:tag, Float64}}.([1.0], 1)) + newprob = remake(prob, u0 = newu0, p = newp) + + @test SciMLBase.successful_retcode(solve(newprob)) +end + +@testset "Issue#3295: Incomplete initialization of pure-ODE systems" begin + @variables X(t) Y(t) + @parameters p d + eqs = [ + D(X) ~ p - d * X, + D(Y) ~ p - d * Y + ] + @mtkbuild osys = ODESystem(eqs, t) + + # Make problem. + u0_vals = [X => 4, Y => 5.0] + tspan = (0.0, 10.0) + p_vals = [p => 1.0, d => 0.1] + oprob = ODEProblem(osys, u0_vals, tspan, p_vals) + integ = init(oprob) + @test integ[X] ≈ 4.0 + @test integ[Y] ≈ 5.0 + # Attempt to `remake`. + rp = remake(oprob; u0 = [Y => 7]) + integ = init(rp) + @test integ[X] ≈ 4.0 + @test integ[Y] ≈ 7.0 +end + +@testset "Issue#3297: `generate_initializesystem(::JumpSystem)`" begin + @parameters β γ S0 + @variables S(t)=S0 I(t) R(t) + rate₁ = β * S * I + affect₁ = [S ~ S - 1, I ~ I + 1] + rate₂ = γ * I + affect₂ = [I ~ I - 1, R ~ R + 1] + j₁ = ConstantRateJump(rate₁, affect₁) + j₂ = ConstantRateJump(rate₂, affect₂) + j₃ = MassActionJump(2 * β + γ, [R => 1], [S => 1, R => -1]) + @mtkbuild js = JumpSystem([j₁, j₂, j₃], t, [S, I, R], [β, γ, S0]) + + u0s = [I => 1, R => 0] + ps = [S0 => 999, β => 0.01, γ => 0.001] + dprob = DiscreteProblem(js, u0s, (0.0, 10.0), ps) + @test_broken dprob.f.initialization_data !== nothing + sol = solve(dprob, FunctionMap()) + @test sol[S, 1] ≈ 999 + @test SciMLBase.successful_retcode(sol) + + jprob = JumpProblem(js, dprob) + sol = solve(jprob, SSAStepper()) + @test sol[S, 1] ≈ 999 + @test SciMLBase.successful_retcode(sol) +end + +@testset "Solvable array parameters with scalarized guesses" begin + @variables x(t) + @parameters p[1:2] q + @mtkbuild sys = ODESystem( + D(x) ~ p[1] + p[2] + q, t; defaults = [p[1] => q, p[2] => 2q], + guesses = [p[1] => q, p[2] => 2q]) + @test ModelingToolkit.is_parameter_solvable(p, Dict(), defaults(sys), guesses(sys)) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [q => 2.0]) + @test length(ModelingToolkit.observed(prob.f.initialization_data.initializeprob.f.sys)) == + 3 + sol = solve(prob, Tsit5()) + @test sol.ps[p] ≈ [2.0, 4.0] +end + +@testset "Issue#3318: Mutating `Initial` parameters works" begin + @variables x(t) y(t)[1:2] [guess = ones(2)] + @parameters p[1:2, 1:2] + @mtkbuild sys = ODESystem( + [D(x) ~ x, D(y) ~ p * y], t; initialization_eqs = [x^2 + y[1]^2 + y[2]^2 ~ 4]) + prob = ODEProblem(sys, [x => 1.0, y[1] => 1], (0.0, 1.0), [p => 2ones(2, 2)]) + integ = init(prob, Tsit5()) + @test integ[x] ≈ 1.0 + @test integ[y] ≈ [1.0, sqrt(2.0)] + prob.ps[Initial(x)] = 0.5 + integ = init(prob, Tsit5()) + @test integ[x] ≈ 0.5 + @test integ[y] ≈ [1.0, sqrt(2.75)] + prob.ps[Initial(y[1])] = 0.5 + integ = init(prob, Tsit5()) + @test integ[x] ≈ 0.5 + @test integ[y]≈[0.5, sqrt(3.5)] atol=1e-6 +end + +@testset "Issue#3342" begin + @variables x(t) y(t) + stop!(integrator, _, _, _) = terminate!(integrator) + @named sys = ODESystem([D(x) ~ 1.0 + D(y) ~ 1.0], t; initialization_eqs = [ + y ~ 0.0 + ], + continuous_events = [ + [y ~ 0.5] => (stop!, [y], [], [], nothing) + ]) + sys = structural_simplify(sys) + prob0 = ODEProblem(sys, [x => NaN], (0.0, 1.0), []) + + # final_x(x0) is equivalent to x0 + 0.5 + function final_x(x0) + prob = remake(prob0; u0 = [x => x0]) + sol = solve(prob) + return sol[x][end] + end + @test final_x(0.3) ≈ 0.8 # should be 0.8 + @test ForwardDiff.derivative(final_x, 0.3) ≈ 1.0 +end + +@testset "Issue#3330: Initialization for unsimplified systems" begin + @variables x(t) [guess = 1.0] + @mtkbuild sys = ODESystem(D(x) ~ x, t; initialization_eqs = [x^2 ~ 4]) + prob = ODEProblem(sys, [], (0.0, 1.0)) + @test prob.f.initialization_data !== nothing +end + +@testset "`ReconstructInitializeprob` with `nothing` state" begin + @parameters p + @variables x(t) + @mtkbuild sys = ODESystem(x ~ p * t, t) + prob = @test_nowarn ODEProblem(sys, [], (0.0, 1.0), [p => 1.0]) + @test_nowarn remake(prob, p = [p => 1.0]) + @test_nowarn remake(prob, p = [p => ForwardDiff.Dual(1.0)]) +end + +@testset "`late_binding_update_u0_p` copies `newp`" begin + @parameters k1 k2 + @variables X1(t) X2(t) + @parameters Γ[1:1]=missing [guess = [1.0]] + eqs = [ + D(X1) ~ k1 * (Γ[1] - X1) - k2 * X1 + ] + obs = [X2 ~ Γ[1] - X1] + @mtkbuild osys = ODESystem(eqs, t, [X1, X2], [k1, k2, Γ]; observed = obs) + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + + oprob1 = ODEProblem(osys, u0, 1.0, ps) + oprob2 = remake(oprob1, u0 = [X1 => 10.0]) + integ1 = init(oprob1) + @test integ1[X1] ≈ 1.0 +end + +@testset "Trivial initialization is run on problem construction" begin + @variables _x(..) y(t) + @brownian a + @parameters tot + x = _x(t) + @testset "$Problem" for (Problem, lhs, rhs) in [ + (ODEProblem, D, 0.0), + (SDEProblem, D, a), + (DDEProblem, D, _x(t - 0.1)), + (SDDEProblem, D, _x(t - 0.1) + a) + ] + @mtkbuild sys = ModelingToolkit.System([lhs(x) ~ x + rhs, x + y ~ tot], t; + guesses = [tot => 1.0], defaults = [tot => missing]) + prob = Problem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test prob.ps[tot] ≈ 2.0 + end + @testset "$Problem" for Problem in [NonlinearProblem, NonlinearLeastSquaresProblem] + @parameters p1 p2 + @mtkbuild sys = NonlinearSystem([x^2 + y^2 ~ p1, (x - 1)^2 + (y - 1)^2 ~ p2]; + parameter_dependencies = [p2 ~ 2p1], + guesses = [p1 => 0.0], defaults = [p1 => missing]) + prob = Problem(sys, [x => 1.0, y => 1.0], [p2 => 6.0]) + @test prob.ps[p1] ≈ 3.0 + end +end + +@testset "`Initial(X)` in time-independent systems: $Problem" for Problem in [ + NonlinearProblem, NonlinearLeastSquaresProblem] + @parameters k1 k2 + @variables X1(t) X2(t) + @parameters Γ[1:1]=missing [guess = [1.0]] + eqs = [ + 0 ~ k1 * (Γ[1] - X1) - k2 * X1 + ] + initialization_eqs = [ + X2 ~ Γ[1] - X1 + ] + @mtkbuild nlsys = NonlinearSystem(eqs, [X1, X2], [k1, k2, Γ]; initialization_eqs) + + @testset "throws if initialization_eqs contain unknowns" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + @test_throws ArgumentError Problem(nlsys, u0, ps) + end + + eqs = [0 ~ k1 * (Γ[1] - X1) - k2 * X1 + X2 ~ Γ[1] - X1] + initialization_eqs = [ + Initial(X2) ~ Γ[1] - Initial(X1) + ] + @mtkbuild nlsys = NonlinearSystem(eqs, [X1, X2], [k1, k2, Γ]; initialization_eqs) + + @testset "solves initialization" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2] + prob = Problem(nlsys, u0, ps) + @test state_values(prob.f.initialization_data.initializeprob) === nothing + @test prob.ps[Γ[1]] ≈ 3.0 + end + + @testset "respects explicitly provided value" begin + u0 = [] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + @test prob.ps[Γ[1]] ≈ 5.0 + end + + @testset "fails initialization if inconsistent explicit value" begin + u0 = [X1 => 1.0, X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + sol = solve(prob) + @test sol.retcode == SciMLBase.ReturnCode.InitialFailure + end + + @testset "Ignores initial equation if given insufficient u0" begin + u0 = [X2 => 2.0] + ps = [k1 => 0.1, k2 => 0.2, Γ => [5.0]] + prob = Problem(nlsys, u0, ps) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.ps[Γ[1]] ≈ 5.0 + end +end + +@testset "Issue#3504: Update initials when `remake` called with non-symbolic `u0`" begin + @variables x(t) y(t) + @parameters c1 c2 + @mtkbuild sys = ODESystem([D(x) ~ -c1 * x + c2 * y, D(y) ~ c1 * x - c2 * y], t) + prob1 = ODEProblem(sys, [1.0, 2.0], (0.0, 1.0), [c1 => 1.0, c2 => 2.0]) + prob2 = remake(prob1, u0 = [2.0, 3.0]) + prob3 = remake(prob1, u0 = [2.0, 3.0], p = [c1 => 2.0]) + integ1 = init(prob1, Tsit5()) + integ2 = init(prob2, Tsit5()) + integ3 = init(prob3, Tsit5()) + @test integ2.u ≈ [2.0, 3.0] + @test integ3.u ≈ [2.0, 3.0] + @test integ3.ps[c1] ≈ 2.0 +end diff --git a/test/input_output_handling.jl b/test/input_output_handling.jl index 19078bc98c..7f4a3247ad 100644 --- a/test/input_output_handling.jl +++ b/test/input_output_handling.jl @@ -128,20 +128,21 @@ eqs = [connect(torque.flange, inertia1.flange_a) connect(inertia2.flange_a, spring.flange_b, damper.flange_b) y ~ inertia2.w + torque.tau.u] model = ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], - name = :name) + name = :name, guesses = [spring.flange_a.phi => 0.0]) model_outputs = [inertia1.w, inertia2.w, inertia1.phi, inertia2.phi] model_inputs = [torque.tau.u] -matrices, ssys = linearize(model, model_inputs, model_outputs); +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]) + 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], - drop_expr = identity) + inputs = [torque.tau.u]) x = randn(size(A, 1)) u = randn(size(B, 2)) p = getindex.( @@ -154,24 +155,66 @@ if VERSION >= v"1.8" # :opaque_closure not supported before end ## Code generation with unbound inputs +@testset "generate_control_function with disturbance inputs" begin + for split in [true, false] + simplify = true + + @variables x(t)=0 u(t)=0 [input = true] + eqs = [ + D(x) ~ -x + u + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys; simplify, split) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + @test f[1](x, u, p, 1) ≈ -x + u + + # With disturbance inputs + @variables x(t)=0 u(t)=0 [input = true] d(t)=0 + eqs = [ + D(x) ~ -x + u + d^2 + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function( + sys, [u], [d]; simplify, split) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + @test f[1](x, u, p, 1) ≈ -x + u + + ## With added d argument + @variables x(t)=0 u(t)=0 [input = true] d(t)=0 + eqs = [ + D(x) ~ -x + u + d^2 + ] + + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function( + sys, [u], [d]; simplify, split, disturbance_argument = true) + + @test isequal(dvs[], x) + @test isempty(ps) + + p = [rand()] + x = [rand()] + u = [rand()] + d = [rand()] + @test f[1](x, u, p, t, d) ≈ -x + u + [d[]^2] + end +end -@variables x(t)=0 u(t)=0 [input = true] -eqs = [ - D(x) ~ -x + u -] - -@named sys = ODESystem(eqs, t) -f, dvs, ps = ModelingToolkit.generate_control_function(sys, simplify = true) - -@test isequal(dvs[], x) -@test isempty(ps) - -p = [] -x = [rand()] -u = [rand()] -@test f[1](x, u, p, 1) == -x + u - -# more complicated system +## more complicated system @variables u(t) [input = true] @@ -222,10 +265,10 @@ eqs = [connect_sd(sd, mass1, mass2) @named _model = ODESystem(eqs, t) @named model = compose(_model, mass1, mass2, sd); -f, dvs, ps = ModelingToolkit.generate_control_function(model, simplify = true) +f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(model, simplify = true) @test length(dvs) == 4 @test length(ps) == length(parameters(model)) -p = ModelingToolkit.varmap_to_vars(ModelingToolkit.defaults(model), ps) +p = MTKParameters(io_sys, [io_sys.u => NaN]) x = ModelingToolkit.varmap_to_vars( merge(ModelingToolkit.defaults(model), Dict(D.(unknowns(model)) .=> 0.0)), dvs) @@ -279,7 +322,8 @@ function SystemModel(u = nothing; name = :model) u ]) end - ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name, guesses = [spring.flange_a.phi => 0.0]) end model = SystemModel() # Model with load disturbance @@ -289,7 +333,7 @@ model_outputs = [model.inertia1.w, model.inertia2.w, model.inertia1.phi, model.i @named dmodel = Blocks.StateSpace([0.0], [1.0], [1.0], [0.0]) # An integrating disturbance @named dist = ModelingToolkit.DisturbanceModel(model.torque.tau.u, dmodel) -(f_oop, f_ip), outersys, dvs, p = ModelingToolkit.add_input_disturbance(model, dist) +(f_oop, f_ip), outersys, dvs, p, io_sys = ModelingToolkit.add_input_disturbance(model, dist) @unpack u, d = outersys matrices, ssys = linearize(outersys, [u, d], model_outputs) @@ -303,7 +347,7 @@ x_add = ModelingToolkit.varmap_to_vars(merge(Dict(dvs .=> 0), Dict(dstate => 1)) x0 = randn(5) x1 = copy(x0) + x_add # add disturbance state perturbation u = randn(1) -pn = ModelingToolkit.varmap_to_vars(def, p) +pn = MTKParameters(io_sys, []) xp0 = f_oop(x0, u, pn, 0) xp1 = f_oop(x1, u, pn, 0) @@ -368,7 +412,8 @@ matrices, ssys = linearize(augmented_sys, augmented_sys.u, augmented_sys.input.u[2], augmented_sys.d - ], outs) + ], outs; + op = [augmented_sys.u => 0.0, augmented_sys.input.u[2] => 0.0, augmented_sys.d => 0.0]) @test matrices.A ≈ [A [1; 0]; zeros(1, 2) -0.001] @test matrices.B == I @test matrices.C == [C zeros(2)] @@ -389,7 +434,7 @@ matrices, ssys = linearize(augmented_sys, (; io_sys,) = ModelingToolkit.generate_control_function(sys, simplify = true) obsfn = ModelingToolkit.build_explicit_observed_function( io_sys, [x + u * t]; inputs = [u]) - @test obsfn([1.0], [2.0], nothing, 3.0) == [7.0] + @test obsfn([1.0], [2.0], MTKParameters(io_sys, []), 3.0) ≈ [7.0] end # https://github.com/SciML/ModelingToolkit.jl/issues/2896 @@ -399,6 +444,18 @@ end eqs = [D(x) ~ c * x] @named sys = ODESystem(eqs, t, [x], []) - f, dvs, ps = ModelingToolkit.generate_control_function(sys, simplify = true) - @test f[1]([0.5], nothing, nothing, 0.0) == [1.0] + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys, simplify = true) + @test f[1]([0.5], nothing, MTKParameters(io_sys, []), 0.0) ≈ [1.0] +end + +@testset "With callable symbolic" begin + @variables x(t)=0 u(t)=0 [input = true] + @parameters p(::Real) = (x -> 2x) + eqs = [D(x) ~ -x + p(u)] + @named sys = ODESystem(eqs, t) + f, dvs, ps, io_sys = ModelingToolkit.generate_control_function(sys, simplify = true) + p = MTKParameters(io_sys, []) + u = [1.0] + x = [1.0] + @test_nowarn f[1](x, u, p, 0.0) end diff --git a/test/jacobiansparsity.jl b/test/jacobiansparsity.jl index 24d47236b3..1f936bc878 100644 --- a/test/jacobiansparsity.jl +++ b/test/jacobiansparsity.jl @@ -1,17 +1,17 @@ -using OrdinaryDiffEq, ModelingToolkit, Test, SparseArrays +using ModelingToolkit, SparseArrays, OrdinaryDiffEq 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 -limit(a, N) = ModelingToolkit.ifelse(a == N + 1, 1, ModelingToolkit.ifelse(a == 0, N, a)) +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) + 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] + @@ -51,7 +51,7 @@ JP = prob.f.jac_prototype # test sparse jacobian prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = true) -@test_nowarn solve(prob, Rosenbrock23()) +#@test_nowarn solve(prob, Rosenbrock23()) @test findnz(calculate_jacobian(sys, sparse = true))[1:2] == findnz(prob.f.jac_prototype)[1:2] @@ -82,3 +82,56 @@ prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = false) prob = ODEProblem(sys, u0, (0, 11.5), sparse = true, jac = true) @test eltype(prob.f.jac_prototype) == Float32 + +@testset "W matrix sparsity" begin + t = ModelingToolkit.t_nounits + D = ModelingToolkit.D_nounits + @parameters g + @variables x(t) y(t) λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + + u0 = [x => 1, y => 0] + prob = ODEProblem( + pend, u0, (0, 11.5), [g => 1], guesses = [λ => 1], sparse = true, jac = true) + jac, jac! = generate_jacobian(pend; expression = Val{false}, sparse = true) + jac_prototype = ModelingToolkit.jacobian_sparsity(pend) + W_prototype = ModelingToolkit.W_sparsity(pend) + @test nnz(W_prototype) == nnz(jac_prototype) + 2 + + # jac_prototype should be the same as W_prototype + @test findnz(prob.f.jac_prototype)[1:2] == findnz(W_prototype)[1:2] + + u = zeros(5) + p = prob.p + t = 0.0 + @test_throws AssertionError jac!(similar(jac_prototype, Float64), u, p, t) + + W, W! = generate_W(pend; expression = Val{false}, sparse = true) + γ = 0.1 + M = sparse(calculate_massmatrix(pend)) + @test_throws AssertionError W!(similar(jac_prototype, Float64), u, p, γ, t) + @test W!(similar(W_prototype, Float64), u, p, γ, t) == + 0.1 * M + jac!(similar(W_prototype, Float64), u, p, t) +end + +@testset "Issue#3556: Numerical accuracy" begin + t = ModelingToolkit.t_nounits + D = ModelingToolkit.D_nounits + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild pend = ODESystem(eqs, t) + prob = ODEProblem(pend, [x => 0.0, D(x) => 1.0], (0.0, 1.0), [g => 1.0]; + guesses = [y => 1.0, λ => 1.0], jac = true, sparse = true) + J = deepcopy(prob.f.jac_prototype) + prob.f.jac(J, prob.u0, prob.p, 1.0) + # this currently works but may not continue to do so + # see https://github.com/SciML/ModelingToolkit.jl/pull/3556#issuecomment-2792664039 + @test J == prob.f.jac(prob.u0, prob.p, 1.0) + @test J ≈ prob.f.jac(prob.u0, prob.p, 1.0) +end diff --git a/test/jumpsystem.jl b/test/jumpsystem.jl index 24b9abbc8a..9568990e73 100644 --- a/test/jumpsystem.jl +++ b/test/jumpsystem.jl @@ -1,5 +1,5 @@ using ModelingToolkit, DiffEqBase, JumpProcesses, Test, LinearAlgebra -using Random, StableRNGs +using Random, StableRNGs, NonlinearSolve using OrdinaryDiffEq using ModelingToolkit: t_nounits as t, D_nounits as D MT = ModelingToolkit @@ -340,3 +340,214 @@ let @test all(abs.(cmean .- cmean2) .<= 0.05 .* cmean) end + +# collect_vars! tests for jumps +let + @variables x1(t) x2(t) x3(t) x4(t) x5(t) + @parameters p1 p2 p3 p4 p5 + j1 = ConstantRateJump(p1, [x1 ~ x1 + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ x3 + 1, x4 ~ x4 + 1]) + j4 = MassActionJump(p4 * p5, [x1 => 1, x5 => 1], [x1 => -1, x5 => -1, x2 => 1]) + us = Set() + ps = Set() + iv = t + + MT.collect_vars!(us, ps, j1, iv) + @test issetequal(us, [x1]) + @test issetequal(ps, [p1]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j2, iv) + @test issetequal(us, [x2, x3]) + @test issetequal(ps, [p2]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j3, iv) + @test issetequal(us, [x3, x4]) + @test issetequal(ps, [p3]) + + empty!(us) + empty!(ps) + MT.collect_vars!(us, ps, j4, iv) + @test issetequal(us, [x1, x5, x2]) + @test issetequal(ps, [p4, p5]) +end + +# scoping tests +let + @variables x1(t) x2(t) x3(t) x4(t) x5(t) + x2 = ParentScope(x2) + x3 = ParentScope(ParentScope(x3)) + x4 = DelayParentScope(x4) + x5 = GlobalScope(x5) + @parameters p1 p2 p3 p4 p5 + p2 = ParentScope(p2) + p3 = ParentScope(ParentScope(p3)) + p4 = DelayParentScope(p4) + p5 = GlobalScope(p5) + + j1 = ConstantRateJump(p1, [x1 ~ x1 + 1]) + j2 = MassActionJump(p2, [x2 => 1], [x3 => -1]) + j3 = VariableRateJump(p3, [x3 ~ x3 + 1, x4 ~ x4 + 1]) + j4 = MassActionJump(p4 * p5, [x1 => 1, x5 => 1], [x1 => -1, x5 => -1, x2 => 1]) + @named js = JumpSystem([j1, j2, j3, j4], t, [x1, x2, x3, x4, x5], [p1, p2, p3, p4, p5]) + + us = Set() + ps = Set() + iv = t + MT.collect_scoped_vars!(us, ps, js, iv) + @test issetequal(us, [x2]) + @test issetequal(ps, [p2]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 0) + @test issetequal(us, [x1]) + @test issetequal(ps, [p1]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 1) + @test issetequal(us, [x2]) + @test issetequal(ps, [p2]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = 2) + @test issetequal(us, [x3, x4]) + @test issetequal(ps, [p3, p4]) + + empty!.((us, ps)) + MT.collect_scoped_vars!(us, ps, js, iv; depth = -1) + @test issetequal(us, [x5]) + @test issetequal(ps, [p5]) +end + +# PDMP test +let + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters k1 k2 + vrj1 = VariableRateJump(k1 * X, [X ~ X - 1]; save_positions = (false, false)) + vrj2 = VariableRateJump(k1, [Y ~ Y + 1]; save_positions = (false, false)) + eqs = [D(X) ~ k2, D(Y) ~ -k2 / 10 * Y] + @named jsys = JumpSystem([vrj1, vrj2, eqs[1], eqs[2]], t, [X, Y], [k1, k2]) + jsys = complete(jsys) + X0 = 0.0 + Y0 = 3.0 + u0 = [X => X0, Y => Y0] + k1val = 1.0 + k2val = 20.0 + p = [k1 => k1val, k2 => k2val] + tspan = (0.0, 10.0) + oprob = ODEProblem(jsys, u0, tspan, p) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + + times = range(0.0, tspan[2], length = 100) + Nsims = 4000 + Xv = zeros(length(times)) + Yv = zeros(length(times)) + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = times, seed) + Xv .+= sol[1, :] + Yv .+= sol[2, :] + seed += 1 + end + Xv ./= Nsims + Yv ./= Nsims + + Xact(t) = X0 * exp(-k1val * t) + (k2val / k1val) * (1 - exp(-k1val * t)) + function Yact(t) + Y0 * exp(-k2val / 10 * t) + (k1val / (k2val / 10)) * (1 - exp(-k2val / 10 * t)) + end + @test all(abs.(Xv .- Xact.(times)) .<= 0.05 .* Xv) + @test all(abs.(Yv .- Yact.(times)) .<= 0.1 .* Yv) +end + +# that mixes ODEs and jump types, and then contin events +let + seed = 1111 + Random.seed!(rng, seed) + @variables X(t) Y(t) + @parameters α β + vrj = VariableRateJump(β * X, [X ~ X - 1]; save_positions = (false, false)) + crj = ConstantRateJump(β * Y, [Y ~ Y - 1]) + maj = MassActionJump(α, [0 => 1], [Y => 1]) + eqs = [D(X) ~ α * (1 + Y)] + @named jsys = JumpSystem([maj, crj, vrj, eqs[1]], t, [X, Y], [α, β]) + jsys = complete(jsys) + p = (α = 6.0, β = 2.0, X₀ = 2.0, Y₀ = 1.0) + u0map = [X => p.X₀, Y => p.Y₀] + pmap = [α => p.α, β => p.β] + tspan = (0.0, 20.0) + oprob = ODEProblem(jsys, u0map, tspan, pmap) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + times = range(0.0, tspan[2], length = 100) + Nsims = 4000 + Xv = zeros(length(times)) + Yv = zeros(length(times)) + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = times, seed) + Xv .+= sol[1, :] + Yv .+= sol[2, :] + seed += 1 + end + Xv ./= Nsims + Yv ./= Nsims + + function Yf(t, p) + local α, β, X₀, Y₀ = p + return (α / β) + (Y₀ - α / β) * exp(-β * t) + end + function Xf(t, p) + local α, β, X₀, Y₀ = p + return (α / β) + (α^2 / β^2) + α * (Y₀ - α / β) * t * exp(-β * t) + + (X₀ - α / β - α^2 / β^2) * exp(-β * t) + end + Xact = [Xf(t, p) for t in times] + Yact = [Yf(t, p) for t in times] + @test all(abs.(Xv .- Xact) .<= 0.05 .* Xv) + @test all(abs.(Yv .- Yact) .<= 0.05 .* Yv) + + function affect!(integ, u, p, ctx) + savevalues!(integ, true) + terminate!(integ) + nothing + end + cevents = [t ~ 0.2] => (affect!, [], [], [], nothing) + @named jsys = JumpSystem([maj, crj, vrj, eqs[1]], t, [X, Y], [α, β]; + continuous_events = cevents) + jsys = complete(jsys) + tspan = (0.0, 200.0) + oprob = ODEProblem(jsys, u0map, tspan, pmap) + jprob = JumpProblem(jsys, oprob; rng, save_positions = (false, false)) + Xsamp = 0.0 + Nsims = 4000 + for n in 1:Nsims + sol = solve(jprob, Tsit5(); saveat = tspan[2], seed) + @test sol.retcode == ReturnCode.Terminated + Xsamp += sol[1, end] + seed += 1 + end + Xsamp /= Nsims + @test abs(Xsamp - Xf(0.2, p) < 0.05 * Xf(0.2, p)) +end + +@testset "JumpProcess simulation should be Int64 valued (#3446)" begin + @parameters p d + @variables X(t) + rate1 = p + rate2 = X * d + affect1 = [X ~ X + 1] + affect2 = [X ~ X - 1] + j1 = ConstantRateJump(rate1, affect1) + j2 = ConstantRateJump(rate2, affect2) + + # Works. + @mtkbuild js = JumpSystem([j1, j2], t, [X], [p, d]) + dprob = DiscreteProblem(js, [X => 15], (0.0, 10.0), [p => 2.0, d => 0.5]) + jprob = JumpProblem(js, dprob, Direct()) + sol = solve(jprob, SSAStepper()) + @test eltype(sol[X]) === Int64 +end diff --git a/test/labelledarrays.jl b/test/labelledarrays.jl index 0ec9e64f8d..c9ee7ee50b 100644 --- a/test/labelledarrays.jl +++ b/test/labelledarrays.jl @@ -87,5 +87,6 @@ u0 = @LArray [9998.0, 1.0, 1.0, 1.0] (:S, :I, :R, :C) problem = ODEProblem(SIR!, u0, tspan, p) sys = complete(modelingtoolkitize(problem)) -@test all(isequal.(parameters(sys), getproperty.(@variables(β, η, ω, φ, σ, μ), :val))) +@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 eabaeacdd8..105a17ca6e 100644 --- a/test/latexify.jl +++ b/test/latexify.jl @@ -3,6 +3,7 @@ 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: @@ -47,3 +48,13 @@ eqs = [D(u[1]) ~ p[3] * (u[2] - u[1]), eqs = [D(x) ~ (1 + cos(t)) / (1 + 2 * x)] @test_reference "latexify/40.tex" latexify(eqs) + +@named P = FirstOrder(k = 1, T = 1) +@named C = Gain(; k = -1) + +ap = AnalysisPoint(:plant_input) +eqs = [connect(P.output, C.input) + connect(C.output, ap, P.input)] +sys_ap = ODESystem(eqs, t, systems = [P, C], name = :hej) + +@test_reference "latexify/50.tex" latexify(sys_ap) diff --git a/test/latexify/20.tex b/test/latexify/20.tex index 0af631162c..012243e981 100644 --- a/test/latexify/20.tex +++ b/test/latexify/20.tex @@ -1,5 +1,5 @@ \begin{align} -\frac{\mathrm{d} u\left( t \right)_{1}}{\mathrm{d}t} &= p_{3} \left( - u\left( t \right)_{1} + u\left( t \right)_{2} \right) \\ -0 &= - u\left( t \right)_{2} + \frac{1}{10} \left( p_{1} - u\left( t \right)_{1} \right) p_{2} p_{3} u\left( t \right)_{1} \\ -\frac{\mathrm{d} u\left( t \right)_{3}}{\mathrm{d}t} &= u\left( t \right)_{2}^{\frac{2}{3}} u\left( t \right)_{1} - p_{3} u\left( t \right)_{3} +\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 index 5cd2394374..b51b73c34b 100644 --- a/test/latexify/30.tex +++ b/test/latexify/30.tex @@ -1,5 +1,5 @@ \begin{align} -\frac{\mathrm{d} u\left( t \right)_{1}}{\mathrm{d}t} &= p_{3} \left( - u\left( t \right)_{1} + u\left( t \right)_{2} \right) \\ -\frac{\mathrm{d} u\left( t \right)_{2}}{\mathrm{d}t} &= - u\left( t \right)_{2} + \frac{1}{10} \left( p_{1} - u\left( t \right)_{1} \right) p_{2} p_{3} u\left( t \right)_{1} \\ -\frac{\mathrm{d} u\left( t \right)_{3}}{\mathrm{d}t} &= u\left( t \right)_{2}^{\frac{2}{3}} u\left( t \right)_{1} - p_{3} u\left( t \right)_{3} +\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/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/model_parsing.jl b/test/model_parsing.jl index 40fb0a13f2..62c19d2055 100644 --- a/test/model_parsing.jl +++ b/test/model_parsing.jl @@ -1,8 +1,9 @@ -using ModelingToolkit, Test +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 @@ -127,6 +128,7 @@ end end @mtkmodel RC begin + @description "An RC circuit." @structural_parameters begin R_val = 10u"Ω" C_val = 10u"F" @@ -139,7 +141,6 @@ end constant = Constant(; k = k_val) ground = MyMockModule.Ground() end - @equations begin connect(constant.output, source.V) connect(source.p, resistor.p) @@ -153,10 +154,11 @@ R_val = 20u"Ω" res__R = 100u"Ω" @mtkbuild rc = RC(; C_val, R_val, resistor.R = res__R) prob = ODEProblem(rc, [], (0, 1e9)) -sol = solve(prob, Rodas5P()) +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) @@ -259,7 +261,8 @@ end @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) == MockModel.structure[:parameters][:l][:size] == (2, 3) + @test size(model.l) == (2, 3) + @test MockModel.structure[:parameters][:l][:size] == (2, 3) model = complete(model) @test getdefault(model.cval) == 1 @@ -278,6 +281,38 @@ end @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 @@ -426,9 +461,9 @@ end @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(:value => nothing, :type => Real), - :v => Dict(:value => nothing, :type => Real)) + @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 @@ -474,7 +509,8 @@ using ModelingToolkit: getdefault, scalarize @named model_with_component_array = ModelWithComponentArray() - @test eval(ModelWithComponentArray.structure[:parameters][:r][:unit]) == eval(u"Ω") + @test eval(ModelWithComponentArray.structure[:parameters][:r][:unit]) == + eval(u"Ω") @test lastindex(parameters(model_with_component_array)) == 3 # Test the constant `k`. Manually k's value should be kept in sync here @@ -528,15 +564,15 @@ end end @named if_in_sys = InsideTheBlock() - if_in_sys = complete(if_in_sys) + if_in_sys = complete(if_in_sys; flatten = false) @named elseif_in_sys = InsideTheBlock(flag = 2) - elseif_in_sys = complete(elseif_in_sys) + elseif_in_sys = complete(elseif_in_sys; flatten = false) @named else_in_sys = InsideTheBlock(flag = 3) - else_in_sys = complete(else_in_sys) + else_in_sys = complete(else_in_sys; flatten = false) - @test getname.(parameters(if_in_sys)) == [:if_parameter, :eq] - @test getname.(parameters(elseif_in_sys)) == [:elseif_parameter, :eq] - @test getname.(parameters(else_in_sys)) == [:else_parameter, :eq] + @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 @@ -619,13 +655,13 @@ end end @named if_out_sys = OutsideTheBlock(condition = 1) - if_out_sys = complete(if_out_sys) + if_out_sys = complete(if_out_sys; flatten = false) @named elseif_out_sys = OutsideTheBlock(condition = 2) - elseif_out_sys = complete(elseif_out_sys) + elseif_out_sys = complete(elseif_out_sys; flatten = false) @named else_out_sys = OutsideTheBlock(condition = 10) - else_out_sys = complete(else_out_sys) + else_out_sys = complete(else_out_sys; flatten = false) @named ternary_out_sys = OutsideTheBlock(condition = 4) - else_out_sys = complete(else_out_sys) + 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] @@ -674,10 +710,10 @@ end end @named ternary_true = TernaryBranchingOutsideTheBlock() - ternary_true = complete(ternary_true) + ternary_true = complete(ternary_true; flatten = false) @named ternary_false = TernaryBranchingOutsideTheBlock(condition = false) - ternary_false = complete(ternary_false) + ternary_false = complete(ternary_false; flatten = false) @test getname.(parameters(ternary_true)) == [:ternary_parameter_true] @test getname.(parameters(ternary_false)) == [:ternary_parameter_false] @@ -724,7 +760,7 @@ end end @named component = Component() - component = complete(component) + component = complete(component; flatten = false) @test nameof.(ModelingToolkit.get_systems(component)) == [ :comprehension_1, @@ -784,15 +820,15 @@ end @named guess_model = GuessModel() j_guess = getguess(guess_model.j) - @test typeof(j_guess) == Num + @test symbolic_type(j_guess) == ScalarSymbolic() @test readable_code(j_guess) == "l(t) / i(t) + k(t)" i_guess = getguess(guess_model.i) - @test typeof(i_guess) == Num + @test symbolic_type(i_guess) == ScalarSymbolic() @test readable_code(i_guess) == "k(t)" l_guess = getguess(guess_model.l) - @test typeof(l_guess) == Num + @test symbolic_type(l_guess) == ScalarSymbolic() @test readable_code(l_guess) == "k(t)" end @@ -840,7 +876,7 @@ end n[1:3] = if flag [2, 2, 2] else - 1 + [1, 1, 1] end end end @@ -876,3 +912,102 @@ end end), false) end + +@mtkmodel BaseSys begin + @parameters begin + p1 + p2 + end + @variables begin + v1(t) + end +end + +@testset "Arguments of base system" begin + @mtkmodel MainSys begin + @extend BaseSys(p1 = 1) + end + + @test names(MainSys) == [:p2, :p1, :v1] + @named main_sys = MainSys(p1 = 11, p2 = 12, v1 = 13) + @test getdefault(main_sys.p1) == 11 + @test getdefault(main_sys.p2) == 12 + @test getdefault(main_sys.v1) == 13 +end + +@mtkmodel InnerModel begin + @parameters begin + p + end +end + +@mtkmodel MidModel begin + @components begin + inmodel = InnerModel() + end +end + +@mtkmodel MidModelB begin + @parameters begin + b + end + @components begin + inmodel_b = InnerModel() + end +end + +@mtkmodel OuterModel begin + @extend MidModel() + @equations begin + inmodel.p ~ 0 + end +end + +# The base system is fetched from the module while extending implicitly. This +# way of defining fails when defined inside the `@testset`. So, it is moved out. +@testset "Test unpacking of components in implicit extend" begin + @named out = OuterModel() + @test OuterModel.structure[:extend][1] == [:inmodel] +end + +@mtkmodel MultipleExtend begin + @extend MidModel() + @extend MidModelB() +end + +@testset "Multiple extend statements" begin + @named multiple_extend = MultipleExtend() + @test collect(nameof.(multiple_extend.systems)) == [:inmodel_b, :inmodel] + @test MultipleExtend.structure[:extend][1] == [:inmodel, :b, :inmodel_b] + @test tosymbol.(parameters(multiple_extend)) == [:b, :inmodel_b₊p, :inmodel₊p] +end + +struct CustomStruct end +@testset "Nonnumeric parameters" begin + @mtkmodel MyModel begin + @parameters begin + p::CustomStruct + end + end + @named sys = MyModel(p = CustomStruct()) + @test ModelingToolkit.defaults(sys)[@nonamespace sys.p] == CustomStruct() +end + +@testset "Variables are not callable symbolics" begin + @mtkmodel Example begin + @variables begin + x(t) + y(t) + end + @equations begin + x ~ y + end + end + @named ex = Example() + vars = Symbolics.get_variables(only(equations(ex))) + @test length(vars) == 2 + for u in Symbolics.unwrap.(unknowns(ex)) + @test !Symbolics.hasmetadata(u, Symbolics.CallWithParent) + @test any(isequal(u), vars) + end +end diff --git a/test/modelingtoolkitize.jl b/test/modelingtoolkitize.jl index 32a9720f47..db99cc91a4 100644 --- a/test/modelingtoolkitize.jl +++ b/test/modelingtoolkitize.jl @@ -67,6 +67,16 @@ sol = solve(prob, BFGS()) sol = solve(prob, Newton()) @test sol.objective < 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 + +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 β = 0.01# infection rate @@ -431,3 +441,31 @@ prob = NonlinearLeastSquaresProblem( sys = modelingtoolkitize(prob) @test length(equations(sys)) == 3 @test length(equations(structural_simplify(sys; fully_determined = false))) == 0 + +@testset "`modelingtoolkitize(::SDEProblem)` sets defaults" begin + function sdeg!(du, u, p, t) + du[1] = 0.3 * u[1] + du[2] = 0.3 * u[2] + du[3] = 0.3 * u[3] + end + function sdef!(du, u, p, t) + x, y, z = u + sigma, rho, beta = p + du[1] = sigma * (y - x) + du[2] = x * (rho - z) - y + du[3] = x * y - beta * z + end + u0 = [1.0, 0.0, 0.0] + tspan = (0.0, 100.0) + p = [10.0, 28.0, 2.66] + sprob = SDEProblem(sdef!, sdeg!, u0, tspan, p) + sys = complete(modelingtoolkitize(sprob)) + @test length(ModelingToolkit.defaults(sys)) == 3length(u0) + length(p) + sprob2 = SDEProblem(sys, [], tspan) + + truevals = similar(u0) + sprob.f(truevals, u0, p, tspan[1]) + mtkvals = similar(u0) + sprob2.f(mtkvals, sprob2.u0, sprob2.p, tspan[1]) + @test mtkvals ≈ truevals +end diff --git a/test/mtkparameters.jl b/test/mtkparameters.jl index e0aee8c289..22201b1988 100644 --- a/test/mtkparameters.jl +++ b/test/mtkparameters.jl @@ -53,7 +53,7 @@ for (portion, values) in [(Tunable(), [1.0, 5.0, 6.0, 7.0]) SciMLStructures.replace!(portion, ps, ones(length(buffer))) # make sure it is in-place @test all(isone, canonicalize(portion, ps)[1]) - repack(zeros(length(buffer))) + global ps = repack(zeros(length(buffer))) @test all(iszero, canonicalize(portion, ps)[1]) end @@ -91,6 +91,27 @@ end @test getp(sys, f)(newps) isa Matrix{UInt} @test getp(sys, g)(newps) isa Vector{Float32} +@testset "Type-stability of `remake_buffer`" begin + prob = ODEProblem(sys, [], (0.0, 1.0), ivs) + + idxs = (a, c, d, e, f, g, h) + vals = (1.0, 2.0, 3, ones(3), ones(Int, 3, 3), ones(2), "a") + + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) + @test_throws ErrorException @inferred setter(prob, collect(vals)) + + idxs = (a, c, e...) + vals = Float16[1.0, 2.0, 3.0, 4.0, 5.0] + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) + + idxs = [a, e] + vals = (Float16(1.0), ForwardDiff.Dual{Nothing, Float16, 0}[1.0, 2.0, 3.0]) + setter = setsym_oop(prob, idxs) + @test_nowarn @inferred setter(prob, vals) +end + ps = MTKParameters(sys, ivs) function loss(value, sys, ps) @test value isa ForwardDiff.Dual @@ -293,13 +314,13 @@ end ps_vec = [k => [2.0, 3.0, 4.0, 5.0]] ps_scal = [k[1] => 1.0, k[2] => 2.0, k[3] => 3.0, k[4] => 4.0] oprob_scal_scal = ODEProblem(osys_scal, u0, 1.0, ps_scal) - newoprob = remake(oprob_scal_scal; p = ps_vec) + 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]),), - (), ()) +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( @@ -314,9 +335,10 @@ with_updated_parameter_timeseries_values( # With multiple types and clocks ps = MTKParameters( - (), (BlockedArray([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [3, 3]), + (), (), + (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 diff --git a/test/namespacing.jl b/test/namespacing.jl new file mode 100644 index 0000000000..b6305a1776 --- /dev/null +++ b/test/namespacing.jl @@ -0,0 +1,186 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D, iscomplete, does_namespacing + +@testset "ODESystem" begin + @variables x(t) + @parameters p + sys = ODESystem(D(x) ~ p * x, t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ODESystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "SDESystem" begin + @variables x(t) + @parameters p + sys = SDESystem([D(x) ~ p * x], [x], t, [x], [p]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] SDESystem( + Equation[], [], t; systems = [nsys], name = :a) +end + +@testset "DiscreteSystem" begin + @variables x(t) + @parameters p + k = ShiftIndex(t) + sys = DiscreteSystem([x(k) ~ p * x(k - 1)], t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] DiscreteSystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "ImplicitDiscreteSystem" begin + @variables x(t) + @parameters p + k = ShiftIndex(t) + sys = ImplicitDiscreteSystem([x(k) ~ p + x(k - 1) * x(k)], t; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ImplicitDiscreteSystem( + Equation[], t; systems = [nsys], name = :a) +end + +@testset "NonlinearSystem" begin + @variables x + @parameters p + sys = NonlinearSystem([x ~ p * x^2 + 1]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] NonlinearSystem( + Equation[]; systems = [nsys], name = :a) +end + +@testset "OptimizationSystem" begin + @variables x + @parameters p + sys = OptimizationSystem(p * x^2 + 1; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] OptimizationSystem( + []; systems = [nsys], name = :a) +end + +@testset "ConstraintsSystem" begin + @variables x + @parameters p + sys = ConstraintsSystem([x^2 + p ~ 0], [x], [p]; name = :inner) + @test !iscomplete(sys) + @test does_namespacing(sys) + + csys = complete(sys) + @test iscomplete(csys) + @test !does_namespacing(csys) + + nsys = toggle_namespacing(sys, false) + @test !iscomplete(nsys) + @test !does_namespacing(nsys) + + @test isequal(x, csys.x) + @test isequal(x, nsys.x) + @test !isequal(x, sys.x) + @test isequal(p, csys.p) + @test isequal(p, nsys.p) + @test !isequal(p, sys.p) + + @test_throws ["namespacing", "inner"] ConstraintsSystem( + [], [], []; systems = [nsys], name = :a) +end diff --git a/test/nonlinearsystem.jl b/test/nonlinearsystem.jl index 841a026d0f..a315371141 100644 --- a/test/nonlinearsystem.jl +++ b/test/nonlinearsystem.jl @@ -3,6 +3,8 @@ using ModelingToolkit: get_metadata using DiffEqBase, SparseArrays using Test using NonlinearSolve +using ForwardDiff +using SymbolicIndexingInterface using ModelingToolkit: value using ModelingToolkit: get_default_or_guess, MTKParameters @@ -28,7 +30,7 @@ eqs = [0 ~ σ * (y - x) * h, @test eval(toexpr(ns)) == ns test_nlsys_inference("standard", ns, (x, y, z), (σ, ρ, β)) @test begin - f = eval(generate_function(ns, [x, y, z], [σ, ρ, β])[2]) + f = generate_function(ns, [x, y, z], [σ, ρ, β], expression = Val{false})[2] du = [0.0, 0.0, 0.0] f(du, [1, 2, 3], [1, 2, 3]) du ≈ [1, -3, -7] @@ -102,7 +104,7 @@ eqs1 = [ lorenz = name -> NonlinearSystem(eqs1, [x, y, z, u, F], [σ, ρ, β], name = name) lorenz1 = lorenz(:lorenz1) -@test_throws ArgumentError NonlinearProblem(complete(lorenz1), zeros(5)) +@test_throws ArgumentError NonlinearProblem(complete(lorenz1), zeros(5), zeros(3)) lorenz2 = lorenz(:lorenz2) @named connected = NonlinearSystem( [s ~ a + lorenz1.x @@ -237,11 +239,13 @@ testdict = Dict([:test => 1]) 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] - @test prob_.p == MTKParameters(sys, [a => 1.1, b => 1.2, c => 1.3]) + 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] - @test prob_.p == MTKParameters(sys, [a => 2.0, b => 1.0, c => 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 @@ -292,7 +296,7 @@ sys = structural_simplify(ns; conservative = true) eqs = [0 ~ σ * (y - x) 0 ~ x * (ρ - z) - y 0 ~ x * y - β * z] - guesses = [x => 1.0, y => 0.0, z => 0.0] + guesses = [x => 1.0, z => 0.0] ps = [σ => 10.0, ρ => 26.0, β => 8 / 3] @mtkbuild ns = NonlinearSystem(eqs) @@ -318,3 +322,123 @@ sys = structural_simplify(ns; conservative = true) sol = solve(prob, NewtonRaphson()) @test sol[x] ≈ sol[y] ≈ sol[z] ≈ -3 end + +@testset "Passing `nothing` to `u0`" begin + @variables x = 1 + @mtkbuild sys = NonlinearSystem([0 ~ x^2 - x^3 + 3]) + prob = @test_nowarn NonlinearProblem(sys, nothing) + @test_nowarn solve(prob) +end + +@testset "System of linear equations with vector variable" begin + # 1st example in https://en.wikipedia.org/w/index.php?title=System_of_linear_equations&oldid=1247697953 + @variables x[1:3] + A = [3 2 -1 + 2 -2 4 + -1 1/2 -1] + b = [1, -2, 0] + @named sys = NonlinearSystem(A * x ~ b, [x], []) + sys = structural_simplify(sys) + prob = NonlinearProblem(sys, unknowns(sys) .=> 0.0) + sol = solve(prob) + @test all(sol[x] .≈ A \ b) +end + +@testset "resid_prototype when system has no unknowns and an equation" begin + @variables x + @parameters p + @named sys = NonlinearSystem([x ~ 1, x^2 - p ~ 0]) + for sys in [ + structural_simplify(sys, fully_determined = false), + structural_simplify(sys, fully_determined = false, split = false) + ] + @test length(equations(sys)) == 1 + @test length(unknowns(sys)) == 0 + T = typeof(ForwardDiff.Dual(1.0)) + prob = NonlinearProblem(sys, [], [p => ForwardDiff.Dual(1.0)]; check_length = false) + @test prob.f(Float64[], prob.p) isa Vector{T} + @test prob.f.resid_prototype isa Vector{T} + @test_nowarn solve(prob) + end +end + +@testset "IntervalNonlinearProblem" begin + @variables x + @parameters p + @named nlsys = NonlinearSystem([0 ~ x * x - p]) + + for sys in [complete(nlsys), complete(nlsys; split = false)] + prob = IntervalNonlinearProblem(sys, (0.0, 2.0), [p => 1.0]) + sol = @test_nowarn solve(prob, ITP()) + @test SciMLBase.successful_retcode(sol) + @test_nowarn IntervalNonlinearProblemExpr(sys, (0.0, 2.0), [p => 1.0]) + end + + @variables y + @mtkbuild sys = NonlinearSystem([0 ~ x * x - p * x + p, 0 ~ x * y + p]) + @test_throws ["single equation", "unknown"] IntervalNonlinearProblem(sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearFunction(sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearProblemExpr( + sys, (0.0, 1.0)) + @test_throws ["single equation", "unknown"] IntervalNonlinearFunctionExpr( + sys, (0.0, 1.0)) +end + +@testset "Vector parameter used unscalarized and partially scalarized" begin + @variables x y + @parameters p[1:2] (f::Function)(..) + + @mtkbuild sys = NonlinearSystem([x^2 - p[1]^2 ~ 0, y^2 ~ f(p)]) + @test !any(isequal(p[1]), parameters(sys)) + @test is_parameter(sys, p) +end + +@testset "Can convert from `ODESystem`" begin + @variables x(t) y(t) + @parameters p q r + @named sys = ODESystem([D(x) ~ p * x^3 + q, 0 ~ -y + q * x - r], t; + defaults = [x => 1.0, p => missing], guesses = [p => 1.0], + initialization_eqs = [p^3 + q^3 ~ 4r], parameter_dependencies = [r ~ 3p]) + nlsys = NonlinearSystem(sys) + defs = defaults(nlsys) + @test length(defs) == 3 + @test defs[x] == 1.0 + @test defs[p] === missing + @test isinf(defs[t]) + @test length(guesses(nlsys)) == 1 + @test guesses(nlsys)[p] == 1.0 + @test length(initialization_equations(nlsys)) == 1 + @test length(parameter_dependencies(nlsys)) == 1 + @test length(equations(nlsys)) == 2 + @test all(iszero, [eq.lhs for eq in equations(nlsys)]) + @test nameof(nlsys) == nameof(sys) + @test !ModelingToolkit.iscomplete(nlsys) + + sys1 = complete(sys; split = false) + nlsys = NonlinearSystem(sys1) + @test ModelingToolkit.iscomplete(nlsys) + @test !ModelingToolkit.is_split(nlsys) + + sys2 = complete(sys) + nlsys = NonlinearSystem(sys2) + @test ModelingToolkit.iscomplete(nlsys) + @test ModelingToolkit.is_split(nlsys) + + sys3 = structural_simplify(sys) + nlsys = NonlinearSystem(sys3) + @test length(equations(nlsys)) == length(ModelingToolkit.observed(nlsys)) == 1 + + prob = NonlinearProblem(sys3, [q => 2.0]) + @test prob.f.initialization_data.initializeprobmap === nothing + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) + @test sol.ps[p^3 + q^3]≈sol.ps[4r] atol=1e-10 + + @testset "Differential inside expression also substituted" begin + @named sys = ODESystem([0 ~ y * D(x) + x^2 - p, 0 ~ x * D(y) + y * p], t) + nlsys = NonlinearSystem(sys) + vs = ModelingToolkit.vars(equations(nlsys)) + @test !in(D(x), vs) + @test !in(D(y), vs) + end +end diff --git a/test/odesystem.jl b/test/odesystem.jl index e39649c1c2..4b76da6e9d 100644 --- a/test/odesystem.jl +++ b/test/odesystem.jl @@ -54,42 +54,38 @@ jac = calculate_jacobian(de) jacfun = eval(jac_expr[2]) de = complete(de) -for f in [ - ODEFunction(de, [x, y, z], [σ, ρ, β], tgrad = true, jac = true), - eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β], tgrad = true, jac = true)) -] - # system - @test f.sys === 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] - - # 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) - 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) -end +f = ODEFunction(de, [x, y, z], [σ, ρ, β], tgrad = true, jac = true) +# system +@test f.sys === 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] + +# 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) +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) #check iip_config -f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β], iip_config = (false, true))) +f = ODEFunction(de, [x, y, z], [σ, ρ, β], iip_config = (false, true)) du = zeros(3) u = collect(1:3) p = ModelingToolkit.MTKParameters(de, [σ, ρ, β] .=> 4.0:6.0) @@ -97,6 +93,25 @@ f.f(du, u, p, 0.1) @test du == [4, 0, -16] @test_throws ArgumentError f.f(u, p, 0.1) +#check iip +f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β])) +f2 = ODEFunction(de, [x, y, z], [σ, ρ, β]) +@test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) +@test SciMLBase.specialization(f) === SciMLBase.specialization(f2) +for iip in (true, false) + f = eval(ODEFunctionExpr{iip}(de, [x, y, z], [σ, ρ, β])) + f2 = ODEFunction{iip}(de, [x, y, z], [σ, ρ, β]) + @test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) === iip + @test SciMLBase.specialization(f) === SciMLBase.specialization(f2) + + for specialize in (SciMLBase.AutoSpecialize, SciMLBase.FullSpecialize) + f = eval(ODEFunctionExpr{iip, specialize}(de, [x, y, z], [σ, ρ, β])) + f2 = ODEFunction{iip, specialize}(de, [x, y, z], [σ, ρ, β]) + @test SciMLBase.isinplace(f) === SciMLBase.isinplace(f2) === iip + @test SciMLBase.specialization(f) === SciMLBase.specialization(f2) === specialize + end +end + #check sparsity f = eval(ODEFunctionExpr(de, [x, y, z], [σ, ρ, β], sparsity = true)) @test f.sparsity == ModelingToolkit.jacobian_sparsity(de) @@ -127,7 +142,7 @@ eqs = [D(x) ~ σ′ * (y - x), @named de = ODESystem(eqs, t) test_diffeq_inference("global iv-varying", de, t, (x, y, z), (σ′, ρ, β)) -f = eval(generate_function(de, [x, y, z], [σ′, ρ, β])[2]) +f = generate_function(de, [x, y, z], [σ′, ρ, β], expression = Val{false})[2] du = [0.0, 0.0, 0.0] f(du, [1.0, 2.0, 3.0], [x -> x + 7, 2, 3], 5.0) @test du ≈ [11, -3, -7] @@ -137,16 +152,16 @@ eqs = [D(x) ~ σ(t - 1) * (y - x), D(y) ~ x * (ρ - z) - y, D(z) ~ x * y - β * z * κ] @named de = ODESystem(eqs, t) -test_diffeq_inference("single internal iv-varying", de, t, (x, y, z), (σ(t - 1), ρ, β)) -f = eval(generate_function(de, [x, y, z], [σ, ρ, β])[2]) +test_diffeq_inference("single internal iv-varying", de, t, (x, y, z), (σ, ρ, β)) +f = generate_function(de, [x, y, z], [σ, ρ, β], expression = Val{false})[2] du = [0.0, 0.0, 0.0] f(du, [1.0, 2.0, 3.0], [x -> x + 7, 2, 3], 5.0) @test du ≈ [11, -3, -7] eqs = [D(x) ~ x + 10σ(t - 1) + 100σ(t - 2) + 1000σ(t^2)] @named de = ODESystem(eqs, t) -test_diffeq_inference("many internal iv-varying", de, t, (x,), (σ(t - 2), σ(t^2), σ(t - 1))) -f = eval(generate_function(de, [x], [σ])[2]) +test_diffeq_inference("many internal iv-varying", de, t, (x,), (σ,)) +f = generate_function(de, [x], [σ], expression = Val{false})[2] du = [0.0] f(du, [1.0], [t -> t + 2], 5.0) @test du ≈ [27561] @@ -199,11 +214,11 @@ eqs = [D(x) ~ -A * x, @named de = ODESystem(eqs, t) @test begin local f, du - f = eval(generate_function(de, [x, y], [A, B, C])[2]) + f = generate_function(de, [x, y], [A, B, C], expression = Val{false})[2] du = [0.0, 0.0] f(du, [1.0, 2.0], [1, 2, 3], 0.0) du ≈ [-1, -1 / 3] - f = eval(generate_function(de, [x, y], [A, B, C])[1]) + f = generate_function(de, [x, y], [A, B, C], expression = Val{false})[1] du ≈ f([1.0, 2.0], [1, 2, 3], 0.0) end @@ -254,7 +269,10 @@ prob12 = ODEProblem(sys, u0, tspan, [k₁ => 0.04, k₂ => 3e7, k₃ => 1e4]) prob13 = ODEProblem(sys, u0, tspan, (k₁ => 0.04, k₂ => 3e7, k₃ => 1e4)) prob14 = ODEProblem(sys, u0, tspan, p2) for p in [prob1, prob14] - @test p.p == MTKParameters(sys, [k₁ => 0.04, k₂ => 3e7, k₃ => 1e4]) + @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 @@ -265,7 +283,10 @@ 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 == MTKParameters(sys, [k₁ => 0.05, k₂ => 2e7, k₃ => 1.1e4]) + @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()) @@ -293,7 +314,10 @@ sol_dpmap = solve(prob_dpmap, Rodas5()) prob = ODEProblem(sys, Pair[]) prob_new = SciMLBase.remake(prob, p = Dict(sys1.a => 3.0, b => 4.0), u0 = Dict(sys1.x => 1.0)) - @test prob_new.p == MTKParameters(sys, [b => 4.0, sys1.a => 3.0, sys.sys2.a => 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 @@ -548,7 +572,7 @@ prob = ODEProblem( @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]) +@test_nowarn obsfn(sol.u[1], prob.p, sol.t[1]) # x/x @variables x(t) @@ -655,7 +679,8 @@ let prob = DAEProblem(sys, du0, u0, (0, 50)) @test prob.u0 ≈ u0 @test prob.du0 ≈ du0 - @test vcat(prob.p...) ≈ [1] + @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) @@ -664,7 +689,8 @@ let (0, 50)) @test prob.u0 ≈ [0.5, 0] @test prob.du0 ≈ [0, 0] - @test vcat(prob.p...) ≈ [1] + @test prob.p isa MTKParameters + @test prob.ps[k] ≈ 1 sol = solve(prob, IDA()) @test isapprox(sol[x[1]][end], 1, atol = 1e-3) @@ -672,7 +698,8 @@ let (0, 50), [k => 2]) @test prob.u0 ≈ [0.5, 0] @test prob.du0 ≈ [0, 0] - @test vcat(prob.p...) ≈ [2] + @test prob.p isa MTKParameters + @test prob.ps[k] ≈ 2 sol = solve(prob, IDA()) @test isapprox(sol[x[1]][end], 2, atol = 1e-3) @@ -696,7 +723,9 @@ let pmap = (k1 => 1.0, k2 => 1) tspan = (0.0, 1.0) prob = ODEProblem(sys, u0map, tspan, pmap; tofloat = false) - @test (prob.p...,) == ([1], [1.0]) || (prob.p...,) == ([1.0], [1]) + @test prob.p isa MTKParameters + @test prob.ps[k1] ≈ 1.0 + @test prob.ps[k2] == 1 && prob.ps[k2] isa Int prob = ODEProblem(sys, u0map, tspan, pmap) @test vcat(prob.p...) isa Vector{Float64} @@ -712,7 +741,7 @@ let # No longer supported, Tuple used instead # pmap = Pair{Any, Union{Int, Float64}}[k1 => 1, k2 => 1.0] # tspan = (0.0, 1.0) - # prob = ODEProblem(sys, u0map, tspan, pmap, use_union = true) + # prob = ODEProblem(sys, u0map, tspan, pmap) # @test eltype(prob.p) === Union{Float64, Int} end @@ -931,22 +960,13 @@ testdict = Dict([:name => "test"]) @named sys = ODESystem(eqs, t, metadata = testdict) @test get_metadata(sys) == testdict -@variables P(t)=0 Q(t)=2 -∂t = D - -eqs = [∂t(Q) ~ 1 / sin(P) - ∂t(P) ~ log(-cos(Q))] +@variables P(t)=NaN Q(t)=NaN +eqs = [D(Q) ~ 1 / sin(P), D(P) ~ log(-cos(Q))] @named sys = ODESystem(eqs, t, [P, Q], []) -sys = complete(debug_system(sys)); -prob = ODEProblem(sys, [], (0, 1.0)); -du = zero(prob.u0); -if VERSION < v"1.8" - @test_throws DomainError prob.f(du, [1, 0], prob.p, 0.0) - @test_throws DomainError prob.f(du, [0, 2], prob.p, 0.0) -else - @test_throws "-cos(Q(t))" prob.f(du, [1, 0], prob.p, 0.0) - @test_throws "sin(P(t))" prob.f(du, [0, 2], prob.p, 0.0) -end +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 @@ -1164,6 +1184,13 @@ for sys in [sys1, sys2] end end +@testset "Non-1-indexed variable array (issue #2670)" begin + @variables x(t)[0:1] # 0-indexed variable array + @named sys = ODESystem([x[0] ~ 0.0, D(x[1]) ~ x[0]], t, [x], []) + @test_nowarn sys = structural_simplify(sys) + @test equations(sys) == [D(x[1]) ~ 0.0] +end + # Namespacing of array variables @variables x(t)[1:2] @named sys = ODESystem(Equation[], t) @@ -1177,7 +1204,7 @@ end sys = structural_simplify(ODESystem([D(x) ~ P], t, [x], [P]; name = :sys)) function x_at_1(P) - prob = ODEProblem(sys, [x => P], (0.0, 1.0), [sys.P => P], use_union = false) + prob = ODEProblem(sys, [x => P], (0.0, 1.0), [sys.P => P]) return solve(prob, Tsit5())(1.0) end @@ -1192,7 +1219,7 @@ end 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_nowarn obsfn(buffer, [1.0], ps, 3.0) @test buffer ≈ [2.0, 3.0, 4.0] end @@ -1271,10 +1298,14 @@ end t, [u..., x..., o...], [p...]) sys1, = structural_simplify(sys, ([x...], [])) fn1, = ModelingToolkit.generate_function(sys1; expression = Val{false}) - @test_nowarn fn1(ones(4), 2ones(2), 3ones(2, 2), 4.0) + ps = MTKParameters(sys1, [x => 2ones(2), p => 3ones(2, 2)]) + @test_nowarn fn1(ones(4), ps, 4.0) sys2, = structural_simplify(sys, ([x...], []); split = false) fn2, = ModelingToolkit.generate_function(sys2; expression = Val{false}) - @test_nowarn fn2(ones(4), 2ones(6), 4.0) + 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 @@ -1385,5 +1416,319 @@ end obsfn = ModelingToolkit.build_explicit_observed_function( sys1, u + x + p[1:2]; inputs = [x...]) - @test obsfn(ones(2), 2ones(2), 3ones(4), 4.0) == 6ones(2) + @test obsfn(ones(2), 2ones(2), 3ones(12), 4.0) == 6ones(2) +end + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + @mtkbuild sys = ODESystem(D(x) ~ t, t) + prob = @test_nowarn ODEProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob) +end + +@testset "ODEs are not DDEs" begin + @variables x(t) + @named sys = ODESystem(D(x) ~ x, t) + @test !ModelingToolkit.is_dde(sys) + @test is_markovian(sys) + @named sys2 = ODESystem(Equation[], t; systems = [sys]) + @test !ModelingToolkit.is_dde(sys) + @test is_markovian(sys) +end + +@testset "Issue #2597" begin + @variables x(t)[1:2]=ones(2) y(t)=1.0 + + for eqs in [D(x) ~ x, collect(D(x) .~ x)] + for dvs in [[x], collect(x)] + @named sys = ODESystem(eqs, t, dvs, []) + sys = complete(sys) + if eqs isa Vector && length(eqs) == 2 && length(dvs) == 2 + @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + else + @test_throws [ + r"array (equations|unknowns)", "structural_simplify", "scalarize"] ODEProblem( + sys, [], (0.0, 1.0)) + end + end + end + for eqs in [[D(x) ~ x, D(y) ~ y], [collect(D(x) .~ x); D(y) ~ y]] + for dvs in [[x, y], [x..., y]] + @named sys = ODESystem(eqs, t, dvs, []) + sys = complete(sys) + if eqs isa Vector && length(eqs) == 3 && length(dvs) == 3 + @test_nowarn ODEProblem(sys, [], (0.0, 1.0)) + else + @test_throws [ + r"array (equations|unknowns)", "structural_simplify", "scalarize"] ODEProblem( + sys, [], (0.0, 1.0)) + end + end + end +end + +@testset "Parameter dependencies with constant RHS" begin + @parameters p + @test_nowarn ODESystem(Equation[], t; parameter_dependencies = [p ~ 1.0], name = :a) +end + +@testset "Variable discovery in arrays of `Num` inside callable symbolic" begin + @variables x(t) y(t) + @parameters foo(::AbstractVector) + sys = @test_nowarn ODESystem(D(x) ~ foo([x, 2y]), t; name = :sys) + @test length(unknowns(sys)) == 2 + @test any(isequal(y), unknowns(sys)) +end + +@testset "Inplace observed" begin + @variables x(t) + @parameters p[1:2] q + @mtkbuild sys = ODESystem(D(x) ~ sum(p) * x + q * t, t) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [p => ones(2), q => 2]) + obsfn = ModelingToolkit.build_explicit_observed_function( + sys, [p..., q], return_inplace = true)[2] + buf = zeros(3) + obsfn(buf, prob.u0, prob.p, 0.0) + @test buf ≈ [1.0, 1.0, 2.0] +end + +@testset "`complete` expands connections" begin + using ModelingToolkitStandardLibrary.Electrical + @mtkmodel RC begin + @parameters begin + R = 1.0 + C = 1.0 + V = 1.0 + end + @components begin + resistor = Resistor(R = R) + capacitor = Capacitor(C = C, v = 0.0) + source = Voltage() + constant = Constant(k = V) + ground = Ground() + end + @equations begin + connect(constant.output, source.V) + connect(source.p, resistor.p) + connect(resistor.n, capacitor.p) + connect(capacitor.n, source.n, ground.g) + end + end + @named sys = RC() + total_eqs = length(equations(expand_connections(sys))) + sys2 = complete(sys) + @test length(equations(sys2)) == total_eqs +end + +@testset "`complete` with `split = false` removes the index cache" begin + @variables x(t) + @parameters p + @mtkbuild sys = ODESystem(D(x) ~ p * t, t) + @test ModelingToolkit.get_index_cache(sys) !== nothing + sys2 = complete(sys; split = false) + @test ModelingToolkit.get_index_cache(sys2) === nothing +end + +# https://github.com/SciML/SciMLBase.jl/issues/786 +@testset "Observed variables dependent on discrete parameters" begin + @variables x(t) obs(t) + @parameters c(t) + @mtkbuild sys = ODESystem( + [D(x) ~ c * cos(x), obs ~ c], t, [x], [c]; discrete_events = [1.0 => [c ~ c + 1]]) + prob = ODEProblem(sys, [x => 0.0], (0.0, 2pi), [c => 1.0]) + sol = solve(prob, Tsit5()) + @test sol[obs] ≈ 1:7 +end + +@testset "DAEProblem with array parameters" begin + @variables x(t)=1.0 y(t) [guess = 1.0] + @parameters p[1:2] = [1.0, 2.0] + @mtkbuild sys = ODESystem([D(x) ~ x, y^2 ~ x + sum(p)], t) + prob = DAEProblem(sys, [D(x) => x, D(y) => D(x) / 2y], [], (0.0, 1.0)) + sol = solve(prob, DFBDF(), abstol = 1e-8, reltol = 1e-8) + @test sol[x]≈sol[y^2 - sum(p)] atol=1e-5 +end + +@testset "Symbolic tstops" begin + @variables x(t) = 1.0 + @parameters p=0.15 q=0.25 r[1:2]=[0.35, 0.45] + @mtkbuild sys = ODESystem( + [D(x) ~ p * x + q * t + sum(r)], t; tstops = [0.5p, [0.1, 0.2], [p + 2q], r]) + prob = ODEProblem(sys, [], (0.0, 5.0)) + sol = solve(prob) + expected_tstops = unique!(sort!(vcat(0.0:0.075:5.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol.t), expected_tstops) + prob2 = remake(prob; tspan = (0.0, 10.0)) + sol2 = solve(prob2) + expected_tstops = unique!(sort!(vcat(0.0:0.075:10.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol2.t), expected_tstops) + + @variables y(t) [guess = 1.0] + @mtkbuild sys = ODESystem([D(x) ~ p * x + q * t + sum(r), y^3 ~ 2x + 1], + t; tstops = [0.5p, [0.1, 0.2], [p + 2q], r]) + prob = DAEProblem( + sys, [D(y) => 2D(x) / 3y^2, D(x) => p * x + q * t + sum(r)], [], (0.0, 5.0)) + sol = solve(prob, DImplicitEuler()) + expected_tstops = unique!(sort!(vcat(0.0:0.075:5.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol.t), expected_tstops) + prob2 = remake(prob; tspan = (0.0, 10.0)) + sol2 = solve(prob2, DImplicitEuler()) + expected_tstops = unique!(sort!(vcat(0.0:0.075:10.0, 0.1, 0.2, 0.65, 0.35, 0.45))) + @test all(x -> any(isapprox(x, atol = 1e-6), sol2.t), expected_tstops) +end + +@testset "Validate input types" begin + @parameters p d + @variables X(t)::Int64 + eq = D(X) ~ p - d * X + @test_throws ArgumentError @mtkbuild osys = ODESystem([eq], t) + @variables Y(t)[1:3]::String + eq = D(Y) ~ [p, p, p] + @test_throws ArgumentError @mtkbuild osys = ODESystem([eq], t) + + @variables X(t)::Complex + eq = D(X) ~ p - d * X + @test_nowarn @named osys = ODESystem([eq], t) +end + +# Test `isequal` +@testset "`isequal`" begin + @variables X(t) + @parameters p d + eq = D(X) ~ p - d * X + + osys1 = complete(ODESystem([eq], t; name = :osys)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 == osys2 # true + + continuous_events = [[X ~ 1.0] => [X ~ X + 5.0]] + discrete_events = [5.0 => [d ~ d / 2.0]] + + osys1 = complete(ODESystem([eq], t; name = :osys, continuous_events)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 !== osys2 + + osys1 = complete(ODESystem([eq], t; name = :osys, discrete_events)) + osys2 = complete(ODESystem([eq], t; name = :osys)) + @test osys1 !== osys2 + + osys1 = complete(ODESystem([eq], t; name = :osys, continuous_events)) + osys2 = complete(ODESystem([eq], t; name = :osys, discrete_events)) + @test osys1 !== osys2 +end + +@testset "dae_order_lowering basic test" begin + @parameters a + @variables x(t) y(t) z(t) + @named dae_sys = ODESystem([ + D(x) ~ y, + 0 ~ x + z, + 0 ~ x - y + z + ], t, [z, y, x], []) + + lowered_dae_sys = dae_order_lowering(dae_sys) + @variables x1(t) y1(t) z1(t) + expected_eqs = [ + 0 ~ x + z, + 0 ~ x - y + z, + Differential(t)(x) ~ y + ] + lowered_eqs = equations(lowered_dae_sys) + sorted_lowered_eqs = sort(lowered_eqs, by = string) + sorted_expected_eqs = sort(expected_eqs, by = string) + @test sorted_lowered_eqs == sorted_expected_eqs + + expected_vars = Set([z, y, x]) + lowered_vars = Set(unknowns(lowered_dae_sys)) + @test lowered_vars == expected_vars +end + +@testset "dae_order_lowering test with structural_simplify" begin + @variables x(t) y(t) z(t) + @parameters M b k + eqs = [ + D(D(x)) ~ -b / M * D(x) - k / M * x, + 0 ~ y - D(x), + 0 ~ z - x + ] + ps = [M, b, k] + default_u0 = [ + D(x) => 0.0, x => 10.0, y => 0.0, z => 10.0 + ] + default_p = [M => 1.0, b => 1.0, k => 1.0] + @named dae_sys = ODESystem(eqs, t, [x, y, z], ps; defaults = [default_u0; default_p]) + + simplified_dae_sys = structural_simplify(dae_sys) + + lowered_dae_sys = dae_order_lowering(simplified_dae_sys) + lowered_dae_sys = complete(lowered_dae_sys) + + tspan = (0.0, 10.0) + prob = ODEProblem(lowered_dae_sys, nothing, tspan) + sol = solve(prob, Tsit5()) + + @test sol.t[end] == tspan[end] + @test sum(abs, sol.u[end]) < 1 + + prob = ODEProblem{false}(lowered_dae_sys; u0_constructor = x -> SVector(x...)) + @test prob.u0 isa SVector +end + +@testset "Constraint system construction" begin + @variables x(..) y(..) z(..) + @parameters a b c d e + eqs = [D(x(t)) ~ 3 * a * y(t), D(y(t)) ~ x(t) - z(t), D(z(t)) ~ e * x(t)^2] + cons = [x(0.3) ~ c * d, y(0.7) ~ 3] + + # Test variables + parameters infer correctly. + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [a, c, d, e]) + @test issetequal(unknowns(sys), [x(t), y(t), z(t)]) + + @parameters t_c + cons = [x(t_c) ~ 3] + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [a, e, t_c]) + + @parameters g(..) h i + cons = [g(h, i) * x(3) ~ c] + @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + @test issetequal(parameters(sys), [g, h, i, a, e, c]) + + # Test that bad constraints throw errors. + cons = [x(3, 4) ~ 3] # unknowns cannot have multiple args. + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + + cons = [x(y(t)) ~ 2] # unknown arg must be parameter, value, or t + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + + @variables u(t) v + cons = [x(t) * u ~ 3] + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) + cons = [x(t) * v ~ 3] + @test_throws ArgumentError @mtkbuild sys = ODESystem(eqs, t; constraints = cons) # Need time argument. + + # Test array variables + @variables x(..)[1:5] + mat = [1 2 0 3 2 + 0 0 3 2 0 + 0 1 3 0 4 + 2 0 0 2 1 + 0 0 2 0 5] + eqs = D(x(t)) ~ mat * x(t) + cons = [x(3) ~ [2, 3, 3, 5, 4]] + @mtkbuild ode = ODESystem(D(x(t)) ~ mat * x(t), t; constraints = cons) + @test length(constraints(ModelingToolkit.get_constraintsystem(ode))) == 5 +end + +@testset "`build_explicit_observed_function` with `expression = true` returns `Expr`" begin + @variables x(t) + @mtkbuild sys = ODESystem(D(x) ~ 2x, t) + obsfn_expr = ModelingToolkit.build_explicit_observed_function( + sys, 2x + 1, expression = true) + @test obsfn_expr isa Expr + obsfn_expr_oop, obsfn_expr_iip = ModelingToolkit.build_explicit_observed_function( + sys, [x + 1, x + 2, x + t], return_inplace = true, expression = true) + @test obsfn_expr_oop isa Expr + @test obsfn_expr_iip isa Expr end diff --git a/test/optimal_control.jl b/test/optimal_control.jl new file mode 100644 index 0000000000..f32ba471d8 --- /dev/null +++ b/test/optimal_control.jl @@ -0,0 +1,311 @@ +### TODO: update when BoundaryValueDiffEqAscher is updated to use the normal boundary condition conventions +using OrdinaryDiffEq +using BoundaryValueDiffEqMIRK, BoundaryValueDiffEqAscher +using BenchmarkTools +using ModelingToolkit +using SciMLBase +using ModelingToolkit: t_nounits as t, D_nounits as D + +### Test Collocation solvers on simple problems +solvers = [MIRK4] +daesolvers = [Ascher2, Ascher4, Ascher6] + +@testset "Lotka-Volterra" begin + @parameters α=7.5 β=4.0 γ=8.0 δ=5.0 + @variables x(t)=1.0 y(t)=2.0 + + eqs = [D(x) ~ α * x - β * x * y, + D(y) ~ -γ * y + δ * x * y] + + u0map = [x => 1.0, y => 2.0] + parammap = [α => 7.5, β => 4, γ => 8.0, δ => 5.0] + tspan = (0.0, 10.0) + + @mtkbuild lotkavolterra = ODESystem(eqs, t) + op = ODEProblem(lotkavolterra, u0map, tspan, parammap) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lotkavolterra, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [1.0, 2.0] + end + + # Test out of place + bvp2 = SciMLBase.BVProblem{false, SciMLBase.AutoSpecialize}( + lotkavolterra, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp2, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [1.0, 2.0] + end +end + +### Testing on pendulum +@testset "Pendulum" begin + @parameters g=9.81 L=1.0 + @variables θ(t)=π / 2 θ_t(t) + + eqs = [D(θ) ~ θ_t + D(θ_t) ~ -(g / L) * sin(θ)] + + @mtkbuild pend = ODESystem(eqs, t) + + u0map = [θ => π / 2, θ_t => π / 2] + parammap = [:L => 1.0, :g => 9.81] + tspan = (0.0, 6.0) + + op = ODEProblem(pend, u0map, tspan, parammap) + osol = solve(op, Vern9()) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap) + for solver in solvers + sol = solve(bvp, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [π / 2, π / 2] + end + + # Test out-of-place + bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + pend, u0map, tspan, parammap) + + for solver in solvers + sol = solve(bvp2, solver(), dt = 0.01) + @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) + @test sol.u[1] == [π / 2, π / 2] + end +end + +################################################################## +### ODESystem with constraint equations, DAEs with constraints ### +################################################################## + +# Test generation of boundary condition function using `generate_function_bc`. Compare solutions to manually written boundary conditions +@testset "Boundary Condition Compilation" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + tspan = (0.0, 1.0) + @mtkbuild lksys = ODESystem(eqs, t) + + function lotkavolterra!(du, u, p, t) + du[1] = p[1] * u[1] - p[2] * u[1] * u[2] + du[2] = -p[4] * u[2] + p[3] * u[1] * u[2] + end + + function lotkavolterra(u, p, t) + [p[1] * u[1] - p[2] * u[1] * u[2], -p[4] * u[2] + p[3] * u[1] * u[2]] + end + + # Test with a constraint. + constr = [y(0.5) ~ 2.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + + function bc!(resid, u, p, t) + resid[1] = u(0.0)[1] - 1.0 + resid[2] = u(0.5)[2] - 2.0 + end + function bc(u, p, t) + [u(0.0)[1] - 1.0, u(0.5)[2] - 2.0] + end + + u0 = [1.0, 1.0] + tspan = (0.0, 1.0) + p = [1.5, 1.0, 1.0, 3.0] + bvpi1 = SciMLBase.BVProblem(lotkavolterra!, bc!, u0, tspan, p) + bvpi2 = SciMLBase.BVProblem(lotkavolterra, bc, u0, tspan, p) + bvpi3 = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, [x(t) => 1.0], tspan; guesses = [y(t) => 1.0]) + bvpi4 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + lksys, [x(t) => 1.0], tspan; guesses = [y(t) => 1.0]) + + sol1 = @btime solve($bvpi1, MIRK4(), dt = 0.01) + sol2 = @btime solve($bvpi2, MIRK4(), dt = 0.01) + sol3 = @btime solve($bvpi3, MIRK4(), dt = 0.01) + sol4 = @btime solve($bvpi4, MIRK4(), dt = 0.01) + @test sol1 ≈ sol2 ≈ sol3 ≈ sol4 # don't get true equality here, not sure why +end + +function test_solvers( + solvers, prob, u0map, constraints, equations = []; dt = 0.05, atol = 1e-2) + for solver in solvers + println("Solver: $solver") + sol = @btime solve($prob, $solver(), dt = $dt, abstol = $atol) + @test SciMLBase.successful_retcode(sol.retcode) + p = prob.p + t = sol.t + bc = prob.f.bc + ns = length(prob.u0) + if isinplace(prob.f) + resid = zeros(ns) + bc(resid, sol, p, t) + @test isapprox(zeros(ns), resid; atol) + @show resid + else + @test isapprox(zeros(ns), bc(sol, p, t); atol) + @show bc(sol, p, t) + end + + for (k, v) in u0map + @test sol[k][1] == v + end + + # for cons in constraints + # @test sol[cons.rhs - cons.lhs] ≈ 0 + # end + + for eq in equations + @test sol[eq] ≈ 0 + end + end +end + +# Simple ODESystem with BVP constraints. +@testset "ODE with constraints" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + u0map = [] + tspan = (0.0, 1.0) + guess = [x(t) => 4.0, y(t) => 2.0] + constr = [x(0.6) ~ 3.5, x(0.3) ~ 7.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr; dt = 0.05) + + # Testing that more complicated constraints give correct solutions. + constr = [y(0.2) + x(0.8) ~ 3.0, y(0.3) ~ 2.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + bvp = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr; dt = 0.05) + + constr = [α * β - x(0.6) ~ 0.0, y(0.2) ~ 3.0] + @mtkbuild lksys = ODESystem(eqs, t; constraints = constr) + bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}( + lksys, u0map, tspan; guesses = guess) + test_solvers(solvers, bvp, u0map, constr) +end + +# Cartesian pendulum from the docs. +# DAE IVP solved using BoundaryValueDiffEq solvers. +# let +# @parameters g +# @variables x(t) y(t) [state_priority = 10] λ(t) +# eqs = [D(D(x)) ~ λ * x +# D(D(y)) ~ λ * y - g +# x^2 + y^2 ~ 1] +# @mtkbuild pend = ODESystem(eqs, t) +# +# tspan = (0.0, 1.5) +# u0map = [x => 1, y => 0] +# pmap = [g => 1] +# guess = [λ => 1] +# +# prob = ODEProblem(pend, u0map, tspan, pmap; guesses = guess) +# osol = solve(prob, Rodas5P()) +# +# zeta = [0., 0., 0., 0., 0.] +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses = guess) +# +# for solver in solvers +# sol = solve(bvp, solver(zeta), dt = 0.001) +# @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) +# conditions = getfield.(equations(pend)[3:end], :rhs) +# @test isapprox([sol[conditions][1]; sol[x][1] - 1; sol[y][1]], zeros(5), atol = 0.001) +# end +# +# bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}(pend, u0map, tspan, parammap) +# for solver in solvers +# sol = solve(bvp, solver(zeta), dt = 0.01) +# @test isapprox(sol.u[end], osol.u[end]; atol = 0.01) +# conditions = getfield.(equations(pend)[3:end], :rhs) +# @test [sol[conditions][1]; sol[x][1] - 1; sol[y][1]] ≈ 0 +# end +# end + +# Adding a midpoint boundary constraint. +# Solve using BVDAE solvers. +# let +# @parameters g +# @variables x(..) y(t) [state_priority = 10] λ(t) +# eqs = [D(D(x(t))) ~ λ * x(t) +# D(D(y)) ~ λ * y - g +# x(t)^2 + y^2 ~ 1] +# constr = [x(0.5) ~ 1] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# +# tspan = (0.0, 1.5) +# u0map = [x(t) => 0.6, y => 0.8] +# parammap = [g => 1] +# guesses = [λ => 1] +# +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr) +# +# bvp2 = SciMLBase.BVProblem{false, SciMLBase.FullSpecialize}(pend, u0map, tspan, parammap) +# test_solvers(daesolvers, bvp2, u0map, constr, get_alg_eqs(pend)) +# +# # More complicated constr. +# u0map = [x(t) => 0.6] +# guesses = [λ => 1, y(t) => 0.8] +# +# constr = [x(0.5) ~ 1, +# x(0.3)^3 + y(0.6)^2 ~ 0.5] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr, get_alg_eqs(pend)) +# +# constr = [x(0.4) * g ~ y(0.2), +# y(0.7) ~ 0.3] +# @mtkbuild pend = ODESystem(eqs, t; constr) +# bvp = SciMLBase.BVProblem{true, SciMLBase.AutoSpecialize}(pend, u0map, tspan, parammap; guesses, check_length = false) +# test_solvers(daesolvers, bvp, u0map, constr, get_alg_eqs(pend)) +# end + +@testset "Cost function compilation" begin + @parameters α=1.5 β=1.0 γ=3.0 δ=1.0 + @variables x(..) y(..) + t = ModelingToolkit.t_nounits + + eqs = [D(x(t)) ~ α * x(t) - β * x(t) * y(t), + D(y(t)) ~ -γ * y(t) + δ * x(t) * y(t)] + + tspan = (0.0, 1.0) + u0map = [x(t) => 4.0, y(t) => 2.0] + parammap = [α => 7.5, β => 4, γ => 8.0, δ => 5.0] + costs = [x(0.6), x(0.3)^2] + consolidate(u) = (u[1] + 3)^2 + u[2] + @mtkbuild lksys = ODESystem(eqs, t; costs, consolidate) + @test_throws ErrorException @mtkbuild lksys2 = ODESystem(eqs, t; costs) + + @test_throws ErrorException ODEProblem(lksys, u0map, tspan, parammap) + prob = ODEProblem(lksys, u0map, tspan, parammap; allow_cost = true) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost_function(lksys) + _t = tspan[2] + @test costfn(sol, prob.p, _t) ≈ (sol(0.6)[1] + 3)^2 + sol(0.3)[1]^2 + + ### With a parameter + @parameters t_c + costs = [y(t_c) + x(0.0), x(0.4)^2] + consolidate(u) = log(u[1]) - u[2] + @mtkbuild lksys = ODESystem(eqs, t; costs, consolidate) + @test t_c ∈ Set(parameters(lksys)) + push!(parammap, t_c => 0.56) + prob = ODEProblem(lksys, u0map, tspan, parammap; allow_cost = true) + sol = solve(prob, Tsit5()) + costfn = ModelingToolkit.generate_cost_function(lksys) + @test costfn(sol, prob.p, _t) ≈ log(sol(0.56)[2] + sol(0.0)[1]) - sol(0.4)[1]^2 +end diff --git a/test/optimizationsystem.jl b/test/optimizationsystem.jl index f182e3ef87..2ec9516721 100644 --- a/test/optimizationsystem.jl +++ b/test/optimizationsystem.jl @@ -1,5 +1,6 @@ using ModelingToolkit, SparseArrays, Test, Optimization, OptimizationOptimJL, - OptimizationMOI, Ipopt, AmplNLWriter, Ipopt_jll + OptimizationMOI, Ipopt, AmplNLWriter, Ipopt_jll, SymbolicIndexingInterface, + LinearAlgebra using ModelingToolkit: get_metadata @testset "basic" begin @@ -340,3 +341,71 @@ end prob.f.cons_h(H3, [1.0, 1.0], [1.0, 100.0]) @test prob.f.cons_h([1.0, 1.0], [1.0, 100.0]) == H3 end + +@testset "Passing `nothing` to `u0`" begin + @variables x = 1.0 + @mtkbuild sys = OptimizationSystem((x - 3)^2, [x], []) + prob = @test_nowarn OptimizationProblem(sys, nothing) + @test_nowarn solve(prob, NelderMead()) +end + +@testset "Bounded unknowns are irreducible" begin + @variables x + @variables y [bounds = (-Inf, Inf)] + @variables z [bounds = (1.0, 2.0)] + obj = x^2 + y^2 + z^2 + cons = [y ~ 2x + z ~ 2y] + @mtkbuild sys = OptimizationSystem(obj, [x, y, z], []; constraints = cons) + @test is_variable(sys, z) + @test !is_variable(sys, y) + + @variables x[1:3] [bounds = ([-Inf, -1.0, -2.0], [Inf, 1.0, 2.0])] + obj = x[1]^2 + x[2]^2 + x[3]^2 + cons = [x[2] ~ 2x[1] + 3, x[3] ~ x[1] + x[2]] + @mtkbuild sys = OptimizationSystem(obj, [x], []; constraints = cons) + @test length(unknowns(sys)) == 2 + @test !is_variable(sys, x[1]) + @test is_variable(sys, x[2]) + @test is_variable(sys, x[3]) +end + +@testset "Constraints work with nonnumeric parameters" begin + @variables x + @parameters p f(::Real) + @mtkbuild sys = OptimizationSystem( + x^2 + f(x) * p, [x], [f, p]; constraints = [2.0 ≲ f(x) + p]) + prob = OptimizationProblem(sys, [x => 1.0], [p => 1.0, f => (x -> 2x)]) + @test abs(prob.f.cons(prob.u0, prob.p)[1]) ≈ 1.0 +end + +@testset "Variable discovery" begin + @variables x1 x2 + @parameters p1 p2 + @named sys1 = OptimizationSystem(x1^2; constraints = [p1 * x1 ≲ 2.0]) + @named sys2 = OptimizationSystem(x2^2; constraints = [p2 * x2 ≲ 2.0], systems = [sys1]) + @test isequal(only(unknowns(sys1)), x1) + @test isequal(only(parameters(sys1)), p1) + @test all(y -> any(x -> isequal(x, y), unknowns(sys2)), [x2, sys1.x1]) + @test all(y -> any(x -> isequal(x, y), parameters(sys2)), [p2, sys1.p1]) +end + +function myeigvals_1(A::AbstractMatrix) + eigvals(A)[1] +end + +@register_symbolic myeigvals_1(A::AbstractMatrix) + +@testset "Issue#3473: Registered array function in objective, no irreducible variables" begin + p_free = @variables begin + p1, [bounds = (0, 1)] + p2, [bounds = (0, 1)] + p3, [bounds = (0, 1)] + p4, [bounds = (0, 1)] + end + + m = diagm(p_free) + + obj = myeigvals_1(m) + @test_nowarn OptimizationSystem(obj, p_free, []; name = :osys) +end diff --git a/test/parameter_dependencies.jl b/test/parameter_dependencies.jl index 034c27041e..31881e1ca8 100644 --- a/test/parameter_dependencies.jl +++ b/test/parameter_dependencies.jl @@ -10,7 +10,7 @@ using SymbolicIndexingInterface using NonlinearSolve @testset "ODESystem with callbacks" begin - @parameters p1=1.0 p2=1.0 + @parameters p1=1.0 p2 @variables x(t) cb1 = [x ~ 2.0] => [p1 ~ 2.0] # triggers at t=-2+√6 function affect1!(integ, u, p, ctx) @@ -26,12 +26,12 @@ using NonlinearSolve continuous_events = [cb1, cb2], discrete_events = [cb3] ) - @test isequal(only(parameters(sys)), p1) - @test Set(full_parameters(sys)) == Set([p1, p2]) + @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_nowarn solve(prob, Tsit5()) + @test SciMLBase.successful_retcode(solve(prob, Tsit5())) prob = ODEProblem(sys, [x => 1.0], (0.0, 1.5), [p1 => 1.0], jac = true) @test prob.ps[p1] == 1.0 @test prob.ps[p2] == 2.0 @@ -82,8 +82,8 @@ end parameter_dependencies = [p2 => 2p1] ) sys = extend(sys2, sys1) - @test isequal(only(parameters(sys)), p1) - @test Set(full_parameters(sys)) == Set([p1, p2]) + @test !(p2 in Set(parameters(sys))) + @test p2 in Set(full_parameters(sys)) prob = ODEProblem(complete(sys)) get_dep = getu(prob, 2p2) @test get_dep(prob) == 4 @@ -121,7 +121,7 @@ end @parameters p1=1.0 p2=2.0 @variables x(t) = 0 - @mtkbuild sys1 = ODESystem( + @named sys1 = ODESystem( [D(x) ~ p1 * t + p2], t ) @@ -177,6 +177,29 @@ end @test SciMLBase.successful_retcode(sol) end +struct CallableFoo + p::Any +end + +@register_symbolic CallableFoo(x) + +(f::CallableFoo)(x) = f.p + x + +@testset "callable parameters" begin + @variables y(t) = 1 + @parameters p=2 (i::CallableFoo)(..) + + eqs = [D(y) ~ i(t) + p] + @named model = ODESystem(eqs, t, [y], [p, i]; + parameter_dependencies = [i ~ CallableFoo(p)]) + sys = structural_simplify(model) + + prob = ODEProblem(sys, [], (0.0, 1.0)) + sol = solve(prob, Tsit5()) + + @test SciMLBase.successful_retcode(sol) +end + @testset "Clock system" begin dt = 0.1 @variables x(t) y(t) u(t) yd(t) ud(t) r(t) z(t) @@ -236,8 +259,8 @@ end @named sys = ODESystem(eqs, t) @named sdesys = SDESystem(sys, noiseeqs; parameter_dependencies = [ρ => 2σ]) sdesys = complete(sdesys) - @test Set(parameters(sdesys)) == Set([σ, β]) - @test Set(full_parameters(sdesys)) == Set([σ, β, ρ]) + @test !(ρ in Set(parameters(sdesys))) + @test ρ in Set(full_parameters(sdesys)) prob = SDEProblem( sdesys, [x => 1.0, y => 0.0, z => 0.0], (0.0, 100.0), [σ => 10.0, β => 2.33]) @@ -303,7 +326,7 @@ end eqs = [0 ~ p1 * x * exp(x) + p2] @mtkbuild sys = NonlinearSystem(eqs; parameter_dependencies = [p2 => 2p1]) @test isequal(only(parameters(sys)), p1) - @test Set(full_parameters(sys)) == Set([p1, p2]) + @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 @@ -335,17 +358,18 @@ end ps = prob.p buffer, repack, _ = canonicalize(Tunable(), ps) - @test only(buffer) == 3.0 - buffer[1] = 4.0 + 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, [1.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.0]) + ps2 = replace(Tunable(), ps, 2 .* ps.tunable) @test getp(sys, p1)(ps2) == 2.0 @test getp(sys, p2)(ps2) == 4.0 end diff --git a/test/pde.jl b/test/pdesystem.jl similarity index 100% rename from test/pde.jl rename to test/pdesystem.jl diff --git a/test/precompile_test.jl b/test/precompile_test.jl index 6e31317de2..38051d9d49 100644 --- a/test/precompile_test.jl +++ b/test/precompile_test.jl @@ -10,7 +10,7 @@ using ODEPrecompileTest u = collect(1:3) p = ModelingToolkit.MTKParameters(ODEPrecompileTest.f_noeval_good.sys, - parameters(ODEPrecompileTest.f_noeval_good.sys) .=> collect(4:6)) + [:σ, :ρ, :β] .=> 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 diff --git a/test/problem_validation.jl b/test/problem_validation.jl new file mode 100644 index 0000000000..a0a7afaf3c --- /dev/null +++ b/test/problem_validation.jl @@ -0,0 +1,34 @@ +using ModelingToolkit +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Input map validation" begin + import ModelingToolkit: InvalidKeyError, MissingParametersError + @variables X(t) + @parameters p d + eqs = [D(X) ~ p - d * X] + @mtkbuild osys = ODESystem(eqs, t) + + p = "I accidentally renamed p" + u0 = [X => 1.0] + ps = [p => 1.0, d => 0.5] + @test_throws MissingParametersError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + @parameters p d + ps = [p => 1.0, d => 0.5, "Random stuff" => 3.0] + @test_throws InvalidKeyError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + u0 = [:X => 1.0, "random" => 3.0] + @test_throws InvalidKeyError oprob=ODEProblem(osys, u0, (0.0, 1.0), ps) + + @variables x(t) y(t) z(t) + @parameters a b c d + eqs = [D(x) ~ x * a, D(y) ~ y * c, D(z) ~ b + d] + @mtkbuild sys = ODESystem(eqs, t) + pmap = [a => 1, b => 2, c => 3, d => 4, "b" => 2] + u0map = [x => 1, y => 2, z => 3] + @test_throws InvalidKeyError ODEProblem(sys, u0map, (0.0, 1.0), pmap) + + pmap = [a => 1, b => 2, c => 3, d => 4] + u0map = [x => 1, y => 2, z => 3, :0 => 3] + @test_throws InvalidKeyError ODEProblem(sys, u0map, (0.0, 1.0), pmap) +end diff --git a/test/reduction.jl b/test/reduction.jl index 064a2efd81..fa9029a652 100644 --- a/test/reduction.jl +++ b/test/reduction.jl @@ -119,7 +119,7 @@ prob1 = ODEProblem(reduced_system, u0, (0.0, 100.0), pp) solve(prob1, Rodas5()) prob2 = SteadyStateProblem(reduced_system, u0, pp) -@test prob2.f.observed(lorenz2.u, prob2.u0, pp) === 1.0 +@test prob2.f.observed(lorenz2.u, prob2.u0, prob2.p) === 1.0 # issue #724 and #716 let @@ -158,9 +158,7 @@ eqs = [u1 ~ u2 reducedsys = structural_simplify(sys) @test length(observed(reducedsys)) == 2 -u0 = [u1 => 1 - u2 => 1 - u3 => 0.3] +u0 = [u2 => 1] pp = [2] nlprob = NonlinearProblem(reducedsys, u0, [p => pp[1]]) reducedsol = solve(nlprob, NewtonRaphson()) @@ -178,6 +176,7 @@ A = reshape(1:(N^2), N, N) eqs = xs ~ A * xs @named sys′ = NonlinearSystem(eqs, [xs], []) sys = structural_simplify(sys′) +@test length(equations(sys)) == 3 && length(observed(sys)) == 2 # issue 958 @parameters k₁ k₂ k₋₁ E₀ diff --git a/test/runtests.jl b/test/runtests.jl index e45e2dfe42..37c738eec9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,7 +1,15 @@ 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__))) @@ -24,7 +32,6 @@ end @safetestset "Parsing Test" include("variable_parsing.jl") @safetestset "Simplify Test" include("simplify.jl") @safetestset "Direct Usage Test" include("direct.jl") - @safetestset "IndexCache Test" include("index_cache.jl") @safetestset "System Linearity Test" include("linearity.jl") @safetestset "Input Output Test" include("input_output_handling.jl") @safetestset "Clock Test" include("clock.jl") @@ -32,9 +39,6 @@ end @safetestset "Dynamic Quantities Test" include("dq_units.jl") @safetestset "Unitful Quantities Test" include("units.jl") @safetestset "Mass Matrix Test" include("mass_matrix.jl") - @safetestset "InitializationSystem Test" include("initializationsystem.jl") - @safetestset "Guess Propagation" include("guess_propagation.jl") - @safetestset "Hierarchical Initialization Equations" include("hierarchical_initialization_eqs.jl") @safetestset "Reduction Test" include("reduction.jl") @safetestset "Split Parameters Test" include("split_parameters.jl") @safetestset "StaticArrays Test" include("static_arrays.jl") @@ -42,6 +46,7 @@ end @safetestset "Model Parsing Test" include("model_parsing.jl") @safetestset "Error Handling" include("error_handling.jl") @safetestset "StructuralTransformations" include("structural_transformation/runtests.jl") + @safetestset "Basic transformations" include("basic_transformations.jl") @safetestset "State Selection Test" include("state_selection.jl") @safetestset "Symbolic Event Test" include("symbolic_events.jl") @safetestset "Stream Connect Test" include("stream_connectors.jl") @@ -56,27 +61,44 @@ end @safetestset "FuncAffect Test" include("funcaffect.jl") @safetestset "Constants Test" include("constants.jl") @safetestset "Parameter Dependency Test" include("parameter_dependencies.jl") - @safetestset "Generate Custom Function Test" include("generate_custom_function.jl") - @safetestset "Initial Values Test" include("initial_values.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 "PDE Construction Test" include("pde.jl") + @safetestset "SCCNonlinearProblem Test" include("scc_nonlinear_problem.jl") + @safetestset "PDE Construction Test" include("pdesystem.jl") @safetestset "JumpSystem Test" include("jumpsystem.jl") + @safetestset "Optimal Control + Constraints Tests" include("optimal_control.jl") @safetestset "print_tree" include("print_tree.jl") @safetestset "Constraints Test" include("constraints.jl") + @safetestset "IfLifting Test" include("if_lifting.jl") + @safetestset "Analysis Points Test" include("analysis_points.jl") + @safetestset "Causal Variables Connection Test" include("causal_variables_connection.jl") + @safetestset "Debugging Test" include("debugging.jl") + @safetestset "Namespacing test" include("namespacing.jl") + @safetestset "Subsystem replacement" include("substitute_component.jl") end end @@ -103,12 +125,21 @@ end @safetestset "Linearization Tests" include("downstream/linearize.jl") @safetestset "Linearization Dummy Derivative Tests" include("downstream/linearization_dd.jl") @safetestset "Inverse Models Test" include("downstream/inversemodel.jl") + @safetestset "Analysis Points Test" include("downstream/analysis_points.jl") + @safetestset "Analysis Points Test" include("downstream/test_disturbance_model.jl") + end + + if GROUP == "All" || GROUP == "FMI" + activate_fmi_env() + @safetestset "FMI Extension Test" include("fmi/fmi.jl") end if GROUP == "All" || GROUP == "Extensions" activate_extensions_env() - @safetestset "BifurcationKit Extension Test" include("extensions/bifurcationkit.jl") + @safetestset "HomotopyContinuation Extension Test" include("extensions/homotopy_continuation.jl") @safetestset "Auto Differentiation Test" include("extensions/ad.jl") @safetestset "LabelledArrays Test" include("labelledarrays.jl") + @safetestset "BifurcationKit Extension Test" include("extensions/bifurcationkit.jl") + @safetestset "InfiniteOpt Extension Test" include("extensions/test_infiniteopt.jl") end end diff --git a/test/scc_nonlinear_problem.jl b/test/scc_nonlinear_problem.jl new file mode 100644 index 0000000000..b2b326d090 --- /dev/null +++ b/test/scc_nonlinear_problem.jl @@ -0,0 +1,293 @@ +using ModelingToolkit +using NonlinearSolve, SCCNonlinearSolve +using OrdinaryDiffEq +using SciMLBase, Symbolics +using LinearAlgebra, Test +using ModelingToolkit: t_nounits as t, D_nounits as D + +@testset "Trivial case" begin + function f!(du, u, p) + du[1] = cos(u[2]) - u[1] + du[2] = sin(u[1] + u[2]) + u[2] + du[3] = 2u[4] + u[3] + 1.0 + du[4] = u[5]^2 + u[4] + du[5] = u[3]^2 + u[5] + du[6] = u[1] + u[2] + u[3] + u[4] + u[5] + 2.0u[6] + 2.5u[7] + 1.5u[8] + du[7] = u[1] + u[2] + u[3] + 2.0u[4] + u[5] + 4.0u[6] - 1.5u[7] + 1.5u[8] + du[8] = u[1] + 2.0u[2] + 3.0u[3] + 5.0u[4] + 6.0u[5] + u[6] - u[7] - u[8] + end + @variables u[1:8] [irreducible = true] + eqs = Any[0 for _ in 1:8] + f!(eqs, u, nothing) + eqs = 0 .~ eqs + @named model = NonlinearSystem(eqs) + @test_throws ["simplified", "required"] SCCNonlinearProblem(model, []) + _model = structural_simplify(model; split = false) + @test_throws ["not compatible"] SCCNonlinearProblem(_model, []) + model = structural_simplify(model) + prob = NonlinearProblem(model, [u => zeros(8)]) + sccprob = SCCNonlinearProblem(model, [u => zeros(8)]) + sol1 = solve(prob, NewtonRaphson()) + sol2 = solve(sccprob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sol1) + @test SciMLBase.successful_retcode(sol2) + @test sol1[u] ≈ sol2[u] +end + +@testset "With parameters" begin + function f!(du, u, (p1, p2), t) + x = (*)(p1[4], u[1]) + y = (*)(p1[4], (+)(0.1016, (*)(-1, u[1]))) + z1 = ifelse((<)(p2[1], 0), + (*)((*)(457896.07999999996, p1[2]), sqrt((*)(1.1686468413521012e-5, p1[3]))), + 0) + z2 = ifelse((>)(p2[1], 0), + (*)((*)((*)(0.58, p1[2]), sqrt((*)(1 // 86100, p1[3]))), u[4]), + 0) + z3 = ifelse((>)(p2[1], 0), + (*)((*)(457896.07999999996, p1[2]), sqrt((*)(1.1686468413521012e-5, p1[3]))), + 0) + z4 = ifelse((<)(p2[1], 0), + (*)((*)((*)(0.58, p1[2]), sqrt((*)(1 // 86100, p1[3]))), u[5]), + 0) + du[1] = p2[1] + du[2] = (+)(z1, (*)(-1, z2)) + du[3] = (+)(z3, (*)(-1, z4)) + du[4] = (+)((*)(-1, u[2]), (*)((*)(1 // 86100, y), u[4])) + du[5] = (+)((*)(-1, u[3]), (*)((*)(1 // 86100, x), u[5])) + end + p = ( + [0.04864391799335977, 7.853981633974484e-5, 1.4034843205574914, + 0.018241469247509915, 300237.05, 9.226186337232914], + [0.0508]) + u0 = [0.0, 0.0, 0.0, 789476.0, 101325.0] + tspan = (0.0, 1.0) + mass_matrix = [1.0 0.0 0.0 0.0 0.0; 0.0 1.0 0.0 0.0 0.0; 0.0 0.0 1.0 0.0 0.0; + 0.0 0.0 0.0 0.0 0.0; 0.0 0.0 0.0 0.0 0.0] + dt = 1e-3 + function nlf(u1, (u0, p)) + resid = Any[0 for _ in u0] + f!(resid, u1, p, 0.0) + return mass_matrix * (u1 - u0) - dt * resid + end + + prob = NonlinearProblem(nlf, u0, (u0, p)) + @test_throws Exception solve(prob, SimpleNewtonRaphson(), abstol = 1e-9) + sol = solve(prob, TrustRegion(); abstol = 1e-9) + + @variables u[1:5] [irreducible = true] + @parameters p1[1:6] p2 + eqs = 0 .~ collect(nlf(u, (u0, (p1, p2)))) + @mtkbuild sys = NonlinearSystem(eqs, [u], [p1, p2]) + sccprob = SCCNonlinearProblem(sys, [u => u0], [p1 => p[1], p2 => p[2][]]) + sccsol = solve(sccprob, SimpleNewtonRaphson(); abstol = 1e-9) + @test SciMLBase.successful_retcode(sccsol) + @test norm(sccsol.resid) < norm(sol.resid) +end + +@testset "Transistor amplifier" begin + C = [k * 1e-6 for k in 1:5] + Ub = 6 + UF = 0.026 + α = 0.99 + β = 1e-6 + R0 = 1000 + R = 9000 + Ue(t) = 0.1 * sin(200 * π * t) + + function transamp(out, du, u, p, t) + g(x) = 1e-6 * (exp(x / 0.026) - 1) + y1, y2, y3, y4, y5, y6, y7, y8 = u + out[1] = -Ue(t) / R0 + y1 / R0 + C[1] * du[1] - C[1] * du[2] + out[2] = -Ub / R + y2 * 2 / R - (α - 1) * g(y2 - y3) - C[1] * du[1] + C[1] * du[2] + out[3] = -g(y2 - y3) + y3 / R + C[2] * du[3] + out[4] = -Ub / R + y4 / R + α * g(y2 - y3) + C[3] * du[4] - C[3] * du[5] + out[5] = -Ub / R + y5 * 2 / R - (α - 1) * g(y5 - y6) - C[3] * du[4] + C[3] * du[5] + out[6] = -g(y5 - y6) + y6 / R + C[4] * du[6] + out[7] = -Ub / R + y7 / R + α * g(y5 - y6) + C[5] * du[7] - C[5] * du[8] + out[8] = y8 / R - C[5] * du[7] + C[5] * du[8] + end + + u0 = [0, Ub / 2, Ub / 2, Ub, Ub / 2, Ub / 2, Ub, 0] + du0 = [ + 51.338775, + 51.338775, + -Ub / (2 * (C[2] * R)), + -24.9757667, + -24.9757667, + -Ub / (2 * (C[4] * R)), + -10.00564453, + -10.00564453 + ] + daeprob = DAEProblem(transamp, du0, u0, (0.0, 0.1)) + daesol = solve(daeprob, DImplicitEuler()) + + t0 = daesol.t[5] + t1 = daesol.t[6] + u0 = daesol.u[5] + u1 = daesol.u[6] + dt = t1 - t0 + + @variables y(t)[1:8] + eqs = Any[0 for _ in 1:8] + transamp(eqs, collect(D(y)), y, nothing, t) + eqs = 0 .~ eqs + subrules = Dict(Symbolics.unwrap(D(y[i])) => ((y[i] - u0[i]) / dt) for i in 1:8) + eqs = substitute.(eqs, (subrules,)) + @mtkbuild sys = NonlinearSystem(eqs) + prob = NonlinearProblem(sys, [y => u0], [t => t0]) + sol = solve(prob, NewtonRaphson(); abstol = 1e-12) + + sccprob = SCCNonlinearProblem(sys, [y => u0], [t => t0]) + sccsol = solve(sccprob, NewtonRaphson(); abstol = 1e-12) + + @test sol.u≈sccsol.u atol=1e-10 +end + +@testset "Expression caching" begin + @variables x[1:4] = rand(4) + val = Ref(0) + function func(x, y) + val[] += 1 + x + y + end + @register_symbolic func(x, y) + @mtkbuild sys = NonlinearSystem([0 ~ x[1]^3 + x[2]^3 - 5 + 0 ~ sin(x[1] - x[2]) - 0.5 + 0 ~ func(x[1], x[2]) * exp(x[3]) - x[4]^3 - 5 + 0 ~ func(x[1], x[2]) * exp(x[4]) - x[3]^3 - 4]) + sccprob = SCCNonlinearProblem(sys, []) + sccsol = solve(sccprob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sccsol) + @test val[] == 1 +end + +import ModelingToolkitStandardLibrary.Blocks as B +import ModelingToolkitStandardLibrary.Mechanical.Translational as T +import ModelingToolkitStandardLibrary.Hydraulic.IsothermalCompressible as IC + +@testset "Caching of subexpressions of different types" begin + liquid_pressure(rho, rho_0, bulk) = (rho / rho_0 - 1) * bulk + gas_pressure(rho, rho_0, p_gas, rho_gas) = rho * ((0 - p_gas) / (rho_0 - rho_gas)) + full_pressure(rho, rho_0, bulk, p_gas, rho_gas) = ifelse( + rho >= rho_0, liquid_pressure(rho, rho_0, bulk), + gas_pressure(rho, rho_0, p_gas, rho_gas)) + + @component function Volume(; + #parameters + area, + direction = +1, + x_int, + name) + pars = @parameters begin + area = area + x_int = x_int + rho_0 = 1000 + bulk = 1e9 + p_gas = -1000 + rho_gas = 1 + end + + vars = @variables begin + x(t) = x_int + dx(t), [guess = 0] + p(t), [guess = 0] + f(t), [guess = 0] + rho(t), [guess = 0] + m(t), [guess = 0] + dm(t), [guess = 0] + end + + systems = @named begin + port = IC.HydraulicPort() + flange = T.MechanicalPort() + end + + eqs = [ + # connectors + port.p ~ p + port.dm ~ dm + flange.v * direction ~ dx + flange.f * direction ~ -f + + # differentials + D(x) ~ dx + D(m) ~ dm + + # physics + p ~ full_pressure(rho, rho_0, bulk, p_gas, rho_gas) + f ~ p * area + m ~ rho * x * area] + + return ODESystem(eqs, t, vars, pars; name, systems) + end + + systems = @named begin + fluid = IC.HydraulicFluid(; bulk_modulus = 1e9) + + src1 = IC.Pressure(;) + src2 = IC.Pressure(;) + + vol1 = Volume(; area = 0.01, direction = +1, x_int = 0.1) + vol2 = Volume(; area = 0.01, direction = +1, x_int = 0.1) + + mass = T.Mass(; m = 10) + + sin1 = B.Sine(; frequency = 0.5, amplitude = +0.5e5, offset = 10e5) + sin2 = B.Sine(; frequency = 0.5, amplitude = -0.5e5, offset = 10e5) + end + + eqs = [connect(fluid, src1.port) + connect(fluid, src2.port) + connect(src1.port, vol1.port) + connect(src2.port, vol2.port) + connect(vol1.flange, mass.flange, vol2.flange) + connect(src1.p, sin1.output) + connect(src2.p, sin2.output)] + + initialization_eqs = [mass.s ~ 0.0 + mass.v ~ 0.0] + + @mtkbuild sys = ODESystem(eqs, t, [], []; systems, initialization_eqs) + prob = ODEProblem(sys, [], (0, 5)) + sol = solve(prob) + @test SciMLBase.successful_retcode(sol) +end + +@testset "Array variables split across SCCs" begin + @variables x[1:3] + @parameters (f::Function)(..) + @mtkbuild sys = NonlinearSystem([ + 0 ~ x[1]^2 - 9, x[2] ~ 2x[1], 0 ~ x[3]^2 - x[1]^2 + f(x)]) + prob = SCCNonlinearProblem(sys, [x => ones(3)], [f => sum]) + sol = solve(prob, NewtonRaphson()) + @test SciMLBase.successful_retcode(sol) +end + +@testset "SCCNonlinearProblem retains parameter order" begin + @variables x y z + @parameters σ β ρ + @mtkbuild fullsys = NonlinearSystem( + [0 ~ x^3 * β + y^3 * ρ - σ, 0 ~ x^2 + 2x * y + y^2, 0 ~ z^2 - 4z + 4], + [x, y, z], [σ, β, ρ]) + + u0 = [x => 1.0, + y => 0.0, + z => 0.0] + + p = [σ => 28.0, + ρ => 10.0, + β => 8 / 3] + + sccprob = SCCNonlinearProblem(fullsys, u0, p) + @test isequal(parameters(fullsys), parameters(sccprob.f.sys)) +end + +@testset "Vector parameters in function arguments" begin + @variables x y + @parameters p[1:2] (f::Function)(..) + + @mtkbuild sys = NonlinearSystem([x^2 - p[1]^2 ~ 0, y^2 ~ f(p)]) + prob = SCCNonlinearProblem(sys, [x => 1.0, y => 1.0], [p => ones(2), f => sum]) + @test_nowarn solve(prob, NewtonRaphson()) +end diff --git a/test/sdesystem.jl b/test/sdesystem.jl index 5628772041..b031a2f5ab 100644 --- a/test/sdesystem.jl +++ b/test/sdesystem.jl @@ -27,19 +27,15 @@ f = eval(generate_diffusion_function(de)[1]) @test f(ones(3), rand(3), nothing) == 0.1ones(3) f = SDEFunction(de) -prob = SDEProblem(SDEFunction(de), [1.0, 0.0, 0.0], (0.0, 100.0), (10.0, 26.0, 2.33)) +prob = SDEProblem(de, [1.0, 0.0, 0.0], (0.0, 100.0), [10.0, 26.0, 2.33]) sol = solve(prob, SRIW1(), seed = 1) -probexpr = SDEProblem(SDEFunction(de), [1.0, 0.0, 0.0], (0.0, 100.0), - (10.0, 26.0, 2.33)) +probexpr = SDEProblem(de, [1.0, 0.0, 0.0], (0.0, 100.0), + [10.0, 26.0, 2.33]) solexpr = solve(eval(probexpr), SRIW1(), seed = 1) @test all(x -> x == 0, Array(sol - solexpr)) -# Test no error -@test_nowarn SDEProblem(de, nothing, (0, 10.0)) -@test SDEProblem(de, nothing).tspan == (0.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] @@ -47,13 +43,13 @@ noiseeqs_nd = [0.01*x 0.01*x*y 0.02*x*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 +@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) +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] @@ -90,7 +86,7 @@ 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] + @test f([1.0, 0.0, 0.0], p, (0.0, 100.0)) ≈ [-10.0, 26.0, 0.0] end test_SDEFunction_no_eval() @@ -776,3 +772,197 @@ end prob = SDEProblem(de, u0map, (0.0, 100.0), parammap) @test solve(prob, SOSRI()).retcode == ReturnCode.Success end + +@testset "Passing `nothing` to `u0`" begin + @variables x(t) = 1 + @brownian b + @mtkbuild sys = System([D(x) ~ x + b], t) + prob = @test_nowarn SDEProblem(sys, nothing, (0.0, 1.0)) + @test_nowarn solve(prob, ImplicitEM()) +end + +@testset "Issue#3212: Noise dependent on observed" begin + sts = @variables begin + x(t) = 1.0 + input(t) + [input = true] + end + ps = @parameters a = 2 + @brownian η + + eqs = [D(x) ~ -a * x + (input + 1) * η + input ~ 0.0] + + sys = System(eqs, t, sts, ps; name = :name) + sys = structural_simplify(sys) + @test ModelingToolkit.get_noiseeqs(sys) ≈ [1.0] + prob = SDEProblem(sys, [], (0.0, 1.0), []) + @test_nowarn solve(prob, RKMil()) +end + +@testset "Observed variables retained after `structural_simplify`" begin + @variables x(t) y(t) z(t) + @brownian a + @mtkbuild sys = System([D(x) ~ x + a, D(y) ~ y + a, z ~ x + y], t) + @test sys isa SDESystem + @test length(observed(sys)) == 1 + prob = SDEProblem(sys, [x => 1.0, y => 1.0], (0.0, 1.0)) + @test prob[z] ≈ 2.0 +end + +@testset "SDESystem to ODESystem" begin + @variables x(t) y(t) z(t) + @testset "Scalar noise" begin + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, z ~ x + y], [x, y, 3], + t, [x, y, z], [], is_scalar_noise = true) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 3 + for eq in equations(odesys) + ModelingToolkit.isdiffeq(eq) || continue + @test length(arguments(eq.rhs)) == 4 + end + end + + @testset "Non-scalar vector noise" begin + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, z ~ x + y], [x, y, 0], + t, [x, y, z], [], is_scalar_noise = false) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 1 + for eq in equations(odesys) + ModelingToolkit.isdiffeq(eq) || continue + @test length(arguments(eq.rhs)) == 2 + end + end + + @testset "Matrix noise" begin + noiseeqs = [x+y y+z z+x + 2y 2z 2x + z+1 x+1 y+1] + @named sys = SDESystem([D(x) ~ x, D(y) ~ y, D(z) ~ z], noiseeqs, t, [x, y, z], []) + odesys = ODESystem(sys) + @test odesys isa ODESystem + vs = ModelingToolkit.vars(equations(odesys)) + nbrownian = count( + v -> ModelingToolkit.getvariabletype(v) == ModelingToolkit.BROWNIAN, vs) + @test nbrownian == 3 + for eq in equations(odesys) + @test length(arguments(eq.rhs)) == 4 + end + end +end + +@testset "`structural_simplify(::SDESystem)`" begin + @variables x(t) y(t) + @mtkbuild sys = SDESystem( + [D(x) ~ x, y ~ 2x], [x, 0], t, [x, y], []; is_scalar_noise = true) + @test sys isa SDESystem + @test length(equations(sys)) == 1 + @test length(ModelingToolkit.get_noiseeqs(sys)) == 1 + @test length(observed(sys)) == 1 +end + +# Test validating types of states +@testset "Validate input types" begin + @parameters p d + @variables X(t)::Int64 + @brownian z + eq2 = D(X) ~ p - d * X + z + @test_throws ArgumentError @mtkbuild ssys = System([eq2], t) + noiseeq = [1] + @test_throws ArgumentError @named ssys = SDESystem([eq2], [noiseeq], t) +end + +@testset "SDEFunctionExpr" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + + eqs = [D(x) ~ σ * (y - x), + D(y) ~ x * (ρ - z) - y, + D(z) ~ x * y - β * z] + + noiseeqs = [0.1 * x, + 0.1 * y, + 0.1 * z] + + @named sys = ODESystem(eqs, tt, [x, y, z], [σ, ρ, β]) + + @named de = SDESystem(eqs, noiseeqs, tt, [x, y, z], [σ, ρ, β], tspan = (0.0, 10.0)) + de = complete(de) + + f = SDEFunctionExpr(de) + @test f isa Expr + + @testset "Configuration Tests" begin + # Test with `tgrad` + f_tgrad = SDEFunctionExpr(de; tgrad = true) + @test f_tgrad isa Expr + + # Test with `jac` + f_jac = SDEFunctionExpr(de; jac = true) + @test f_jac isa Expr + + # Test with sparse Jacobian + f_sparse = SDEFunctionExpr(de; sparse = true) + @test f_sparse isa Expr + end + + @testset "Ordering Tests" begin + dvs = [z, y, x] + ps = [β, ρ, σ] + f_order = SDEFunctionExpr(de, dvs, ps) + @test f_order isa Expr + end +end + +@testset "SDESystem Equality with events" begin + @variables X(t) + @parameters p d + @brownian a + seq = D(X) ~ p - d * X + a + @mtkbuild ssys1 = System([seq], t; name = :ssys) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 == ssys2 # true + + continuous_events = [[X ~ 1.0] => [X ~ X + 5.0]] + discrete_events = [5.0 => [d ~ d / 2.0]] + + @mtkbuild ssys1 = System([seq], t; name = :ssys, continuous_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 !== ssys2 + + @mtkbuild ssys1 = System([seq], t; name = :ssys, discrete_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys) + @test ssys1 !== ssys2 + + @mtkbuild ssys1 = System([seq], t; name = :ssys, continuous_events) + @mtkbuild ssys2 = System([seq], t; name = :ssys, discrete_events) + @test ssys1 !== ssys2 +end + +@testset "Error when constructing SDESystem without `structural_simplify`" begin + @parameters σ ρ β + @variables x(tt) y(tt) z(tt) + @brownian a + eqs = [D(x) ~ σ * (y - x) + 0.1a * x, + D(y) ~ x * (ρ - z) - y + 0.1a * y, + D(z) ~ x * y - β * z + 0.1a * z] + + @named de = System(eqs, t) + de = complete(de) + + u0map = [x => 1.0, y => 0.0, z => 0.0] + parammap = [σ => 10.0, β => 26.0, ρ => 2.33] + + @test_throws ErrorException("SDESystem constructed by defining Brownian variables with @brownian must be simplified by calling `structural_simplify` before a SDEProblem can be constructed.") SDEProblem( + de, u0map, (0.0, 100.0), parammap) + de = structural_simplify(de) + @test SDEProblem(de, u0map, (0.0, 100.0), parammap) isa SDEProblem +end diff --git a/test/serialization.jl b/test/serialization.jl index 5e09055a92..e10de51299 100644 --- a/test/serialization.jl +++ b/test/serialization.jl @@ -50,7 +50,7 @@ for var in all_obs f = ModelingToolkit.build_explicit_observed_function(ss, var; expression = true) sym = ModelingToolkit.getname(var) |> string ex = :(if name == Symbol($sym) - return $f(u0, p..., t) + return $f(u0, p, t) end) push!(obs_exps, ex) end diff --git a/test/split_parameters.jl b/test/split_parameters.jl index acd98972db..1052f4ad27 100644 --- a/test/split_parameters.jl +++ b/test/split_parameters.jl @@ -1,11 +1,13 @@ 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 +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)] @@ -49,8 +51,8 @@ end get_value(interp::Interpolator, t) = interp(t) @register_symbolic get_value(interp::Interpolator, t) -# get_value(data, t, dt) = data[round(Int, t / dt + 1)] -# @register_symbolic get_value(data::Vector, t, dt) + +Symbolics.derivative(::typeof(get_value), args::NTuple{2, Any}, ::Val{2}) = 0 function Sampled(; name, interp = Interpolator(Float64[], 0.0)) pars = @parameters begin @@ -66,11 +68,10 @@ function Sampled(; name, interp = Interpolator(Float64[], 0.0)) output.u ~ get_value(interpolator, t) ] - return ODESystem(eqs, t, vars, [interpolator]; name, systems, - defaults = [output.u => interp.data[1]]) + return ODESystem(eqs, t, vars, [interpolator]; name, systems) end -vars = @variables y(t)=1 dy(t)=0 ddy(t)=0 +vars = @variables y(t) dy(t) ddy(t) @named src = Sampled(; interp = Interpolator(x, dt)) @named int = Integrator() @@ -82,11 +83,9 @@ eqs = [y ~ src.output.u @named sys = ODESystem(eqs, t, vars, []; systems = [int, src]) s = complete(sys) sys = structural_simplify(sys) -@test_broken ODEProblem( - sys, [], (0.0, t_end), [s.src.interpolator => Interpolator(x, dt)]; tofloat = false) prob = ODEProblem( sys, [], (0.0, t_end), [s.src.interpolator => Interpolator(x, dt)]; - tofloat = false, build_initializeprob = false) + tofloat = false) sol = solve(prob, ImplicitEuler()); @test sol.retcode == ReturnCode.Success @test sol[y][end] == x[end] @@ -112,7 +111,7 @@ eqs = [D(y) ~ dy * a sys = structural_simplify(model; split = false) tspan = (0.0, t_end) -prob = ODEProblem(sys, [], tspan, []) +prob = ODEProblem(sys, [], tspan, []; build_initializeprob = false) @test prob.p isa Vector{Float64} sol = solve(prob, ImplicitEuler()); @@ -120,9 +119,9 @@ sol = solve(prob, ImplicitEuler()); # ------------------------ Mixed Type Conserved -prob = ODEProblem(sys, [], tspan, []; tofloat = false, use_union = true) +prob = ODEProblem( + sys, [], tspan, []; tofloat = false, build_initializeprob = false) -@test prob.p isa Tuple{Vector{Float64}, Vector{Int64}} sol = solve(prob, ImplicitEuler()); @test sol.retcode == ReturnCode.Success @@ -161,14 +160,17 @@ function SystemModel(u = nothing; name = :model) t; systems = [torque, inertia1, inertia2, spring, damper, u]) end - ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], name) + ODESystem(eqs, t; systems = [torque, inertia1, inertia2, spring, damper], + name, guesses = [spring.flange_a.phi => 0.0]) end model = SystemModel() # Model with load disturbance @named d = Step(start_time = 1.0, duration = 10.0, offset = 0.0, height = 1.0) # Disturbance model_outputs = [model.inertia1.w, model.inertia2.w, model.inertia1.phi, model.inertia2.phi] # This is the state realization we want to control inputs = [model.torque.tau.u] -matrices, ssys = ModelingToolkit.linearize(wr(model), inputs, model_outputs) +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 @@ -198,15 +200,18 @@ connections = [[state_feedback.input.u[i] ~ model_outputs[i] for i in 1:4] S = get_sensitivity(closed_loop, :u) @testset "Indexing MTKParameters with ParameterIndex" begin - ps = MTKParameters(collect(1.0:10.0), + 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])) + (["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 @@ -215,7 +220,91 @@ S = get_sensitivity(closed_loop, :u) ps[ParameterIndex(Tunable(), 1)] = 1.5 ps[ParameterIndex(Tunable(), 2:4)] = [2.5, 3.5, 4.5] ps[ParameterIndex(Tunable(), reshape(5:8, 2, 2))] = [5.5 7.5; 6.5 8.5] + ps[ParameterIndex(Initials(), 1)] = 11.5 + ps[ParameterIndex(Initials(), 2:4)] = [12.5, 13.5, 14.5] + ps[ParameterIndex(Initials(), reshape(5:8, 2, 2))] = [15.5 17.5; 16.5 18.5] ps[ParameterIndex(Discrete(), (2, 1, 2, 2))] = 5 @test ps[ParameterIndex(Tunable(), 1:8)] == collect(1.0:8.0) .+ 0.5 + @test ps[ParameterIndex(Initials(), 1:8)] == collect(11.0:18.0) .+ 0.5 @test ps[ParameterIndex(Discrete(), (2, 1, 2, 2))] == 5 end + +@testset "Callable parameters" begin + @testset "As FunctionWrapper" begin + _f1(x) = 2x + struct Foo end + (::Foo)(x) = 3x + @variables x(t) + @parameters fn(::Real) = _f1 + @mtkbuild sys = ODESystem(D(x) ~ fn(t), t) + @test is_parameter(sys, fn) + @test ModelingToolkit.defaults(sys)[fn] == _f1 + + getter = getp(sys, fn) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0)) + @inferred getter(prob) + # cannot be inferred better since `FunctionWrapper` is only known to return `Real` + @inferred Vector{<:Real} prob.f(prob.u0, prob.p, prob.tspan[1]) + sol = solve(prob, Tsit5(); abstol = 1e-10, reltol = 1e-10) + @test sol.u[end][] ≈ 2.0 + + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [fn => Foo()]) + @inferred getter(prob) + @inferred Vector{<:Real} prob.f(prob.u0, prob.p, prob.tspan[1]) + sol = solve(prob; abstol = 1e-10, reltol = 1e-10) + @test sol.u[end][] ≈ 2.5 + end + + @testset "Concrete function type" begin + ts = 0.0:0.1:1.0 + interp = LinearInterpolation(ts .^ 2, ts; extrapolate = true) + @variables x(t) + @parameters (fn::typeof(interp))(..) + @mtkbuild sys = ODESystem(D(x) ~ fn(x), t) + @test is_parameter(sys, fn) + getter = getp(sys, fn) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [fn => interp]) + @inferred getter(prob) + @inferred prob.f(prob.u0, prob.p, prob.tspan[1]) + @test_nowarn sol = solve(prob, Tsit5()) + @test_nowarn prob.ps[fn] = LinearInterpolation(ts .^ 3, ts; extrapolate = true) + @test_nowarn sol = solve(prob) + end +end + +@testset "" begin + @mtkmodel SubSystem begin + @parameters begin + c = 1 + end + @variables begin + x(t) + end + @equations begin + D(x) ~ c * x + end + end + + @mtkmodel System 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 = System() + 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/steadystatesystems.jl b/test/steadystatesystems.jl index d29c809325..4f1b5ed063 100644 --- a/test/steadystatesystems.jl +++ b/test/steadystatesystems.jl @@ -17,7 +17,7 @@ for factor in [1e-1, 1e0, 1e10], 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] + 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/structural_transformation/index_reduction.jl b/test/structural_transformation/index_reduction.jl index b94362bedc..d7f19e1fa2 100644 --- a/test/structural_transformation/index_reduction.jl +++ b/test/structural_transformation/index_reduction.jl @@ -62,7 +62,7 @@ first_order_idx1_pendulum = complete(ode_order_lowering(idx1_pendulum)) using OrdinaryDiffEq using LinearAlgebra -prob = ODEProblem(ODEFunction(first_order_idx1_pendulum), +prob = ODEProblem(first_order_idx1_pendulum, # [x, y, w, z, xˍt, yˍt, T] [1, 0, 0, 0, 0, 0, 0.0],# 0, 0, 0, 0], (0, 10.0), diff --git a/test/structural_transformation/tearing.jl b/test/structural_transformation/tearing.jl index dea6af0b11..e9cd92ec94 100644 --- a/test/structural_transformation/tearing.jl +++ b/test/structural_transformation/tearing.jl @@ -159,7 +159,7 @@ du = [0.0, 0.0]; u = [1.0, -0.5π]; pr = 0.2; tt = 0.1; -@test_skip (@ballocated $(prob.f)($du, $u, $pr, $tt)) == 0 +@test (@ballocated $(prob.f)($du, $u, $pr, $tt)) == 0 prob.f(du, u, pr, tt) @test du≈[u[2], u[1] + sin(u[2]) - pr * tt] atol=1e-5 @@ -169,22 +169,6 @@ infprob = ODEProblem(structural_simplify(sys), [x => 1.0], (0, 1.0), [p => 0.2]) infprob.f(du, infprob.u0, pr, tt) @test any(isnan, du) -sol1 = solve(prob, RosShamp4(), reltol = 8e-7) -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[x])≈Array(sol2[1, :]) atol=1e-5 - -@test sol1[x] == first.(sol1.u) -@test sol1[y] == first.(sol1.u) -@test sin.(sol1[z]) .+ sol1[y]≈pr[1] * sol1.t atol=5e-5 -@test sol1[sin(z) + y]≈sin.(sol1[z]) .+ sol1[y] rtol=1e-12 - -@test sol1[y, :] == sol1[x, :] -@test (@. sin(sol1[z, :]) + sol1[y, :])≈pr * sol1.t atol=5e-5 - # 1426 function Translational_Mass(; name, m = 1.0) sts = @variables s(t) v(t) a(t) diff --git a/test/structural_transformation/utils.jl b/test/structural_transformation/utils.jl index 6d4a5a7506..6dfc107cc9 100644 --- a/test/structural_transformation/utils.jl +++ b/test/structural_transformation/utils.jl @@ -3,7 +3,9 @@ using ModelingToolkit using Graphs using SparseArrays using UnPack -using ModelingToolkit: t_nounits as t, D_nounits as D +using ModelingToolkit: t_nounits as t, D_nounits as D, default_toterm +using Symbolics: unwrap +const ST = StructuralTransformations # Define some variables @parameters L g @@ -32,3 +34,251 @@ se = collect(StructuralTransformations.edges(graph)) @test se == mapreduce(vcat, enumerate(graph.fadjlist)) do (s, d) StructuralTransformations.BipartiteEdge.(s, d) end + +@testset "observed2graph handles unknowns inside callable parameters" begin + @variables x(t) y(t) + @parameters p(..) + g, _ = ModelingToolkit.observed2graph([y ~ p(x), x ~ 0], [y, x]) + @test ModelingToolkit.𝑠neighbors(g, 1) == [2] + @test ModelingToolkit.𝑑neighbors(g, 2) == [1] +end + +@testset "array observed used unscalarized in another observed" begin + @variables x(t) y(t)[1:2] z(t)[1:2] + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @mtkbuild sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1], y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + @test length(equations(sys)) == 1 + @test length(observed(sys)) == 7 + @test any(eq -> isequal(eq.lhs, y), observed(sys)) + @test any(eq -> isequal(eq.lhs, z), observed(sys)) + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [foo => _tmp_fn]) + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 5 + @test length(equations(isys)) == 4 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end +end + +@testset "scalarized array observed calling same function multiple times" begin + @variables x(t) y(t)[1:2] + @parameters foo(::Real)[1:2] + val = Ref(0) + function _tmp_fn2(x) + val[] += 1 + return [x, 2x] + end + @mtkbuild sys = ODESystem([D(x) ~ y[1] + y[2], y ~ foo(x)], t) + @test length(equations(sys)) == 1 + @test length(observed(sys)) == 3 + prob = ODEProblem(sys, [x => 1.0], (0.0, 1.0), [foo => _tmp_fn2]) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 1 + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 3 + @test length(equations(isys)) == 2 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end + + @testset "CSE hack in equations(sys)" begin + val[] = 0 + @variables z(t)[1:2] + @mtkbuild sys = ODESystem( + [D(y) ~ foo(x), D(x) ~ sum(y), zeros(2) ~ foo(prod(z))], t) + @test length(equations(sys)) == 5 + @test length(observed(sys)) == 2 + prob = ODEProblem( + sys, [y => ones(2), z => 2ones(2), x => 3.0], (0.0, 1.0), [foo => _tmp_fn2]) + val[] = 0 + @test_nowarn prob.f(prob.u0, prob.p, 0.0) + @test val[] == 2 + + isys = ModelingToolkit.generate_initializesystem(sys) + @test length(unknowns(isys)) == 5 + @test length(equations(isys)) == 2 + @test !any(equations(isys)) do eq + iscall(eq.rhs) && + operation(eq.rhs) in [StructuralTransformations.getindex_wrapper, + StructuralTransformations.change_origin] + end + end +end + +@testset "array and cse hacks can be disabled" begin + @testset "fully_determined = true" begin + @variables x(t) y(t)[1:2] z(t)[1:2] + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @named sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1], y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys1 = structural_simplify(sys; cse_hack = false) + @test length(observed(sys1)) == 6 + @test !any(observed(sys1)) do eq + iscall(eq.rhs) && + operation(eq.rhs) == StructuralTransformations.getindex_wrapper + end + + sys2 = structural_simplify(sys; array_hack = false) + @test length(observed(sys2)) == 5 + @test !any(observed(sys2)) do eq + iscall(eq.rhs) && operation(eq.rhs) == StructuralTransformations.change_origin + end + end + + @testset "fully_determined = false" begin + @variables x(t) y(t)[1:2] z(t)[1:2] w(t) + @parameters foo(::AbstractVector)[1:2] + _tmp_fn(x) = 2x + @named sys = ODESystem( + [D(x) ~ z[1] + z[2] + foo(z)[1] + w, y[1] ~ 2t, y[2] ~ 3t, z ~ foo(y)], t) + + sys1 = structural_simplify(sys; cse_hack = false, fully_determined = false) + @test length(observed(sys1)) == 6 + @test !any(observed(sys1)) do eq + iscall(eq.rhs) && + operation(eq.rhs) == StructuralTransformations.getindex_wrapper + end + + sys2 = structural_simplify(sys; array_hack = false, fully_determined = false) + @test length(observed(sys2)) == 5 + @test !any(observed(sys2)) do eq + iscall(eq.rhs) && operation(eq.rhs) == StructuralTransformations.change_origin + end + end +end + +@testset "additional passes" begin + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ x, y ~ x + t], t) + value = Ref(0) + pass(sys; kwargs...) = (value[] += 1; return sys) + structural_simplify(sys; additional_passes = [pass]) + @test value[] == 1 +end + +@testset "Distribute shifts" begin + @variables x(t) y(t) z(t) + @parameters a b c + k = ShiftIndex(t) + + # Expand shifts + @test isequal( + ST.distribute_shift(Shift(t, -1)(x + y)), Shift(t, -1)(x) + Shift(t, -1)(y)) + + expr = a * Shift(t, -2)(x) + Shift(t, 2)(y) + b + @test isequal(ST.simplify_shifts(ST.distribute_shift(Shift(t, 2)(expr))), + a * x + Shift(t, 4)(y) + b) + @test isequal(ST.distribute_shift(Shift(t, 2)(exp(z))), exp(Shift(t, 2)(z))) + @test isequal(ST.distribute_shift(Shift(t, 2)(exp(a) + b)), exp(a) + b) + + expr = a^x - log(b * y) + z * x + @test isequal(ST.distribute_shift(Shift(t, -3)(expr)), + a^(Shift(t, -3)(x)) - log(b * Shift(t, -3)(y)) + Shift(t, -3)(z) * Shift(t, -3)(x)) + + expr = x(k + 1) ~ x + x(k - 1) + @test isequal(ST.distribute_shift(Shift(t, -1)(expr)), x ~ x(k - 1) + x(k - 2)) +end + +@testset "`map_variables_to_equations`" begin + @testset "Not supported for systems without `.tearing_state`" begin + @variables x + @mtkbuild sys = OptimizationSystem(x^2) + @test_throws ArgumentError map_variables_to_equations(sys) + end + @testset "Requires simplified system" begin + @variables x(t) y(t) + @named sys = ODESystem([D(x) ~ x, y ~ 2x], t) + sys = complete(sys) + @test_throws ArgumentError map_variables_to_equations(sys) + end + @testset "`ODESystem`" begin + @variables x(t) y(t) z(t) + @mtkbuild sys = ODESystem([D(x) ~ 2x + y, y ~ x + z, z^3 + x^3 ~ 12], t) + mapping = map_variables_to_equations(sys) + @test mapping[x] == (D(x) ~ 2x + y) + @test mapping[y] == (y ~ x + z) + @test mapping[z] == (0 ~ 12 - z^3 - x^3) + @test length(mapping) == 3 + + @testset "With dummy derivatives" begin + @parameters g + @variables x(t) y(t) [state_priority = 10] λ(t) + eqs = [D(D(x)) ~ λ * x + D(D(y)) ~ λ * y - g + x^2 + y^2 ~ 1] + @mtkbuild sys = ODESystem(eqs, t) + mapping = map_variables_to_equations(sys) + + yt = default_toterm(unwrap(D(y))) + xt = default_toterm(unwrap(D(x))) + xtt = default_toterm(unwrap(D(D(x)))) + @test mapping[x] == (0 ~ 1 - x^2 - y^2) + @test mapping[y] == (D(y) ~ yt) + @test mapping[D(y)] == (D(yt) ~ -g + y * λ) + @test mapping[D(x)] == (0 ~ -2xt * x - 2yt * y) + @test mapping[D(D(x))] == (xtt ~ x * λ) + @test length(mapping) == 5 + + @testset "`rename_dummy_derivatives = false`" begin + mapping = map_variables_to_equations(sys; rename_dummy_derivatives = false) + + @test mapping[x] == (0 ~ 1 - x^2 - y^2) + @test mapping[y] == (D(y) ~ yt) + @test mapping[yt] == (D(yt) ~ -g + y * λ) + @test mapping[xt] == (0 ~ -2xt * x - 2yt * y) + @test mapping[xtt] == (xtt ~ x * λ) + @test length(mapping) == 5 + end + end + @testset "DDEs" begin + function oscillator(; name, k = 1.0, τ = 0.01) + @parameters k=k τ=τ + @variables x(..)=0.1 y(t)=0.1 jcn(t)=0.0 delx(t) + eqs = [D(x(t)) ~ y, + D(y) ~ -k * x(t - τ) + jcn, + delx ~ x(t - τ)] + return System(eqs, t; name = name) + end + + systems = @named begin + osc1 = oscillator(k = 1.0, τ = 0.01) + osc2 = oscillator(k = 2.0, τ = 0.04) + end + eqs = [osc1.jcn ~ osc2.delx, + osc2.jcn ~ osc1.delx] + @named coupledOsc = System(eqs, t) + @mtkbuild sys = compose(coupledOsc, systems) + mapping = map_variables_to_equations(sys) + x1 = operation(unwrap(osc1.x)) + x2 = operation(unwrap(osc2.x)) + @test mapping[osc1.x] == (D(osc1.x) ~ osc1.y) + @test mapping[osc1.y] == (D(osc1.y) ~ osc1.jcn - osc1.k * x1(t - osc1.τ)) + @test mapping[osc1.delx] == (osc1.delx ~ x1(t - osc1.τ)) + @test mapping[osc1.jcn] == (osc1.jcn ~ osc2.delx) + @test mapping[osc2.x] == (D(osc2.x) ~ osc2.y) + @test mapping[osc2.y] == (D(osc2.y) ~ osc2.jcn - osc2.k * x2(t - osc2.τ)) + @test mapping[osc2.delx] == (osc2.delx ~ x2(t - osc2.τ)) + @test mapping[osc2.jcn] == (osc2.jcn ~ osc1.delx) + @test length(mapping) == 8 + end + end + @testset "`NonlinearSystem`" begin + @variables x y z + @mtkbuild sys = NonlinearSystem([x^2 ~ 2y^2 + 1, sin(z) ~ y, z^3 + 4z + 1 ~ 0]) + mapping = map_variables_to_equations(sys) + @test mapping[x] == (0 ~ 2y^2 + 1 - x^2) + @test mapping[y] == (y ~ sin(z)) + @test mapping[z] == (0 ~ -1 - 4z - z^3) + @test length(mapping) == 3 + end +end diff --git a/test/substitute_component.jl b/test/substitute_component.jl new file mode 100644 index 0000000000..9fb254136b --- /dev/null +++ b/test/substitute_component.jl @@ -0,0 +1,273 @@ +using ModelingToolkit, ModelingToolkitStandardLibrary, Test +using ModelingToolkitStandardLibrary.Blocks +using ModelingToolkitStandardLibrary.Electrical +using OrdinaryDiffEq +using ModelingToolkit: t_nounits as t, D_nounits as D, renamespace, + NAMESPACE_SEPARATOR as NS + +@mtkmodel SignalInterface begin + @components begin + output = RealOutput() + end +end + +@mtkmodel TwoComponent begin + @components begin + component1 = OnePort() + component2 = OnePort() + source = Voltage() + signal = SignalInterface() + ground = Ground() + end + @equations begin + connect(signal.output.u, source.V.u) + connect(source.p, component1.p) + connect(component1.n, component2.p) + connect(component2.n, source.n, ground.g) + end +end + +@mtkmodel RC begin + @parameters begin + R = 1.0 + C = 1.0 + V = 1.0 + end + @components begin + component1 = Resistor(R = R) + component2 = Capacitor(C = C, v = 0.0) + source = Voltage() + constant = Constant(k = V) + ground = Ground() + end + @equations begin + connect(constant.output, source.V) + connect(source.p, component1.p) + connect(component1.n, component2.p) + connect(component2.n, source.n, ground.g) + end +end + +@testset "Replacement with connections works" begin + @named templated = TwoComponent() + @named component1 = Resistor(R = 1.0) + @named component2 = Capacitor(C = 1.0, v = 0.0) + @named signal = Constant(k = 1.0) + rsys = substitute_component(templated, templated.component1 => component1) + rcsys = substitute_component(rsys, rsys.component2 => component2) + rcsys = substitute_component(rcsys, rcsys.signal => signal) + + @named reference = RC() + + sys1 = structural_simplify(rcsys) + sys2 = structural_simplify(reference) + @test isequal(unknowns(sys1), unknowns(sys2)) + @test isequal(equations(sys1), equations(sys2)) + + prob1 = ODEProblem(sys1, [], (0.0, 10.0)) + prob2 = ODEProblem(sys2, [], (0.0, 10.0)) + + sol1 = solve(prob1, Tsit5()) + sol2 = solve(prob2, Tsit5(); saveat = sol1.t) + @test sol1.u≈sol2.u atol=1e-8 +end + +@mtkmodel BadOnePort1 begin + @components begin + p = Pin() + n = Pin() + end + @variables begin + i(t) + end + @equations begin + 0 ~ p.i + n.i + i ~ p.i + end +end + +@connector BadPin1 begin + v(t) +end + +@mtkmodel BadOnePort2 begin + @components begin + p = BadPin1() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin2 begin + v(t) + i(t) +end + +@mtkmodel BadOnePort3 begin + @components begin + p = BadPin2() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin3 begin + v(t), [input = true] + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort4 begin + @components begin + p = BadPin3() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin4 begin + v(t), [output = true] + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort5 begin + @components begin + p = BadPin4() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@mtkmodel BadPin5 begin + @variables begin + v(t) + i(t), [connect = Flow] + end +end + +@mtkmodel BadOnePort6 begin + @components begin + p = BadPin5() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.v + n.v + v ~ p.v + end +end + +@connector BadPin6 begin + i(t), [connect = Flow] +end + +@mtkmodel BadOnePort7 begin + @components begin + p = BadPin6() + n = Pin() + end + @variables begin + v(t) + i(t) + end + @equations begin + 0 ~ p.i + n.i + i ~ p.i + end +end + +@mtkmodel BadOnePort8 begin + @components begin + n = Pin() + end + @variables begin + v(t) + i(t) + end +end + +@testset "Error checking" begin + @named templated = TwoComponent() + @named component1 = Resistor(R = 1.0) + @named component2 = Capacitor(C = 1.0, v = 0.0) + @test_throws ["LHS", "cannot be completed"] substitute_component( + templated, complete(templated.component1) => component1) + @test_throws ["RHS", "cannot be completed"] substitute_component( + templated, templated.component1 => complete(component1)) + @test_throws ["RHS", "not be namespaced"] substitute_component( + templated, templated.component1 => renamespace(templated, component1)) + @named resistor = Resistor(R = 1.0) + @test_throws ["RHS", "same name"] substitute_component( + templated, templated.component1 => resistor) + + @testset "Different indepvar" begin + @independent_variables tt + @named empty = ODESystem(Equation[], t) + @named outer = ODESystem(Equation[], t; systems = [empty]) + @named empty = ODESystem(Equation[], tt) + @test_throws ["independent variable"] substitute_component( + outer, outer.empty => empty) + end + + @named component1 = BadOnePort1() + @test_throws ["RHS", "unknown", "v(t)"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort2() + @test_throws ["component1$(NS)p", "i(t)"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort3() + @test_throws ["component1$(NS)p$(NS)i", "Flow"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort4() + @test_throws ["component1$(NS)p$(NS)v", "differing causality", "input"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort5() + @test_throws ["component1$(NS)p$(NS)v", "differing causality", "output"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort6() + @test_throws ["templated$(NS)component1$(NS)p", "not a connector"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort7() + @test_throws ["templated$(NS)component1$(NS)p", "DomainConnector", "RegularConnector"] substitute_component( + templated, templated.component1 => component1) + + @named component1 = BadOnePort8() + @test_throws ["templated$(NS)component1", "subsystem p"] substitute_component( + templated, templated.component1 => component1) +end diff --git a/test/symbolic_events.jl b/test/symbolic_events.jl index e1d12814ef..804432408b 100644 --- a/test/symbolic_events.jl +++ b/test/symbolic_events.jl @@ -8,6 +8,7 @@ using ModelingToolkit: SymbolicContinuousCallback, using StableRNGs import SciMLBase using SymbolicIndexingInterface +using Setfield rng = StableRNG(12345) @variables x(t) = 0 @@ -227,6 +228,119 @@ affect_neg = [x ~ 1] @test e[].affect == affect end +@testset "ImperativeAffect constructors" begin + fmfa(o, x, i, c) = nothing + m = ModelingToolkit.ImperativeAffect(fmfa) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test m.obs == [] + @test m.obs_syms == [] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (;)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test m.obs == [] + @test m.obs_syms == [] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; observed = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test m.modified == [] + @test m.mod_syms == [] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; modified = (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa; modified = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, []) + @test m.obs_syms == [] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x), (; x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:x] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect(fmfa, (; y = x), (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect( + fmfa; modified = (; y = x), observed = (; y = x)) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === nothing + + m = ModelingToolkit.ImperativeAffect( + fmfa; modified = (; y = x), observed = (; y = x), ctx = 3) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:y] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:y] + @test m.ctx === 3 + + m = ModelingToolkit.ImperativeAffect(fmfa, (; x), (; x), 3) + @test m isa ModelingToolkit.ImperativeAffect + @test m.f == fmfa + @test isequal(m.obs, [x]) + @test m.obs_syms == [:x] + @test isequal(m.modified, [x]) + @test m.mod_syms == [:x] + @test m.ctx === 3 +end + ## @named sys = ODESystem(eqs, t, continuous_events = [x ~ 1]) @@ -308,7 +422,7 @@ cond.rf_ip(out, [0, 1], p0, t0) # this should return 0 cond.rf_ip(out, [0, 2], p0, t0) @test out[2] ≈ 1 # signature is u,p,t -sol = solve(prob, Tsit5()) +sol = solve(prob, Tsit5(); abstol = 1e-14, reltol = 1e-14) @test minimum(t -> abs(t - 1), sol.t) < 1e-10 # test that the solver stepped at the first root @test minimum(t -> abs(t - 2), sol.t) < 1e-10 # test that the solver stepped at the second root @@ -316,7 +430,7 @@ sol = solve(prob, Tsit5()) sys = complete(sys) prob = ODEProblem(sys, Pair[], (0.0, 3.0)) @test get_callback(prob) isa ModelingToolkit.DiffEqCallbacks.VectorContinuousCallback -sol = solve(prob, Tsit5()) +sol = solve(prob, Tsit5(); abstol = 1e-14, reltol = 1e-14) @test minimum(t -> abs(t - 1), sol.t) < 1e-10 # test that the solver stepped at the first root @test minimum(t -> abs(t - 2), sol.t) < 1e-10 # test that the solver stepped at the second root @@ -426,8 +540,8 @@ ev = [sin(20pi * t) ~ 0.0] => [vmeasured ~ v] sys = structural_simplify(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])[vmeasured][] == sol([0.23])[vmeasured][] # test the hold property +@test all(minimum((0:0.05:5) .- sol.t', dims = 2) .< 0.0001) # test that the solver stepped every 0.05s as dictated by event +@test sol([0.25 - eps()])[vmeasured][] == sol([0.23])[vmeasured][] # test the hold property ## https://github.com/SciML/ModelingToolkit.jl/issues/1528 Dₜ = D @@ -822,7 +936,7 @@ end @named trigsys = ODESystem(eqs, t; continuous_events = [evt1, evt2]) trigsys_ss = structural_simplify(trigsys) prob = ODEProblem(trigsys_ss, [], (0.0, 2π)) - sol = solve(prob, Tsit5()) + 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 @@ -867,6 +981,88 @@ end @test sign.(cos.(3 * (required_crossings_c2 .+ 1e-6))) == sign.(last.(cr2)) end +@testset "Discrete event reinitialization (#3142)" begin + @connector LiquidPort begin + p(t)::Float64, [description = "Set pressure in bar", + guess = 1.01325] + Vdot(t)::Float64, + [description = "Volume flow rate in L/min", + guess = 0.0, + connect = Flow] + end + + @mtkmodel PressureSource begin + @components begin + port = LiquidPort() + end + @parameters begin + p_set::Float64 = 1.01325, [description = "Set pressure in bar"] + end + @equations begin + port.p ~ p_set + end + end + + @mtkmodel BinaryValve begin + @constants begin + p_ref::Float64 = 1.0, [description = "Reference pressure drop in bar"] + ρ_ref::Float64 = 1000.0, [description = "Reference density in kg/m^3"] + end + @components begin + port_in = LiquidPort() + port_out = LiquidPort() + end + @parameters begin + k_V::Float64 = 1.0, [description = "Valve coefficient in L/min/bar"] + k_leakage::Float64 = 1e-08, [description = "Leakage coefficient in L/min/bar"] + ρ::Float64 = 1000.0, [description = "Density in kg/m^3"] + end + @variables begin + S(t)::Float64, [description = "Valve state", guess = 1.0, irreducible = true] + Δp(t)::Float64, [description = "Pressure difference in bar", guess = 1.0] + Vdot(t)::Float64, [description = "Volume flow rate in L/min", guess = 1.0] + end + @equations begin + # Port handling + port_in.Vdot ~ -Vdot + port_out.Vdot ~ Vdot + Δp ~ port_in.p - port_out.p + # System behavior + D(S) ~ 0.0 + Vdot ~ S * k_V * sign(Δp) * sqrt(abs(Δp) / p_ref * ρ_ref / ρ) + k_leakage * Δp # softplus alpha function to avoid negative values under the sqrt + end + end + + # Test System + @mtkmodel TestSystem begin + @components begin + pressure_source_1 = PressureSource(p_set = 2.0) + binary_valve_1 = BinaryValve(S = 1.0, k_leakage = 0.0) + binary_valve_2 = BinaryValve(S = 1.0, k_leakage = 0.0) + pressure_source_2 = PressureSource(p_set = 1.0) + end + @equations begin + connect(pressure_source_1.port, binary_valve_1.port_in) + connect(binary_valve_1.port_out, binary_valve_2.port_in) + connect(binary_valve_2.port_out, pressure_source_2.port) + end + @discrete_events begin + [30] => [binary_valve_1.S ~ 0.0, binary_valve_2.Δp ~ 0.0] + [60] => [ + binary_valve_1.S ~ 1.0, binary_valve_2.S ~ 0.0, binary_valve_2.Δp ~ 1.0] + [120] => [binary_valve_1.S ~ 0.0, binary_valve_2.Δp ~ 0.0] + end + end + + # Test Simulation + @mtkbuild sys = TestSystem() + + # Test Simulation + prob = ODEProblem(sys, [], (0.0, 150.0)) + sol = solve(prob) + @test sol[end] == [0.0, 0.0, 0.0] +end + @testset "Discrete variable timeseries" begin @variables x(t) @parameters a(t) b(t) c(t) @@ -887,3 +1083,354 @@ end @test sol[b] == [2.0, 5.0, 5.0] @test sol[c] == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] end +@testset "Heater" begin + @variables temp(t) + params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on::Bool=false + eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage + ] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, i, c + @set! x.furnace_on = false + end) + furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, i, c + @set! x.furnace_on = true + end) + @named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test all(sol[temp][sol.t .> 1.0] .<= 0.79) && all(sol[temp][sol.t .> 1.0] .>= 0.49) + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end; initialize = ModelingToolkit.ImperativeAffect(modified = (; + temp)) do x, o, c, i + @set! x.temp = 0.2 + end) + furnace_enable = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_on_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = true + end) + @named sys = ODESystem( + eqs, t, [temp], params; continuous_events = [furnace_off, furnace_enable]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test all(sol[temp][sol.t .> 1.0] .<= 0.79) && all(sol[temp][sol.t .> 1.0] .>= 0.49) + @test all(sol[temp][sol.t .!= 0.0] .<= 0.79) && all(sol[temp][sol.t .!= 0.0] .>= 0.2) +end + +@testset "ImperativeAffect errors and warnings" begin + @variables temp(t) + params = @parameters furnace_on_threshold=0.5 furnace_off_threshold=0.7 furnace_power=1.0 leakage=0.1 furnace_on::Bool=false + eqs = [ + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage + ] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect( + modified = (; furnace_on), observed = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem(eqs, t, [temp], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_logs (:warn, + "The symbols Any[:furnace_on] are declared as both observed and modified; this is a code smell because it becomes easy to confuse them and assign/not assign a value.") prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + @variables tempsq(t) # trivially eliminated + eqs = [tempsq ~ temp^2 + D(temp) ~ furnace_on * furnace_power - temp^2 * leakage] + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect( + modified = (; furnace_on, tempsq), observed = (; furnace_on)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_throws "refers to missing variable(s)" prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + @parameters not_actually_here + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on), + observed = (; furnace_on, not_actually_here)) do x, o, c, i + @set! x.furnace_on = false + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + @test_throws "refers to missing variable(s)" prob=ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + + furnace_off = ModelingToolkit.SymbolicContinuousCallback( + [temp ~ furnace_off_threshold], + ModelingToolkit.ImperativeAffect(modified = (; furnace_on), + observed = (; furnace_on)) do x, o, c, i + return (; fictional2 = false) + end) + @named sys = ODESystem( + eqs, t, [temp, tempsq], params; continuous_events = [furnace_off]) + ss = structural_simplify(sys) + prob = ODEProblem( + ss, [temp => 0.0, furnace_on => true], (0.0, 100.0)) + @test_throws "Tried to write back to" solve(prob, Tsit5()) +end + +@testset "Quadrature" begin + @variables theta(t) omega(t) + params = @parameters qA=0 qB=0 hA=0 hB=0 cnt::Int=0 + eqs = [D(theta) ~ omega + omega ~ 1.0] + function decoder(oldA, oldB, newA, newB) + state = (oldA, oldB, newA, newB) + if state == (0, 0, 1, 0) || state == (1, 0, 1, 1) || state == (1, 1, 0, 1) || + state == (0, 1, 0, 0) + return 1 + elseif state == (0, 0, 0, 1) || state == (0, 1, 1, 1) || state == (1, 1, 1, 0) || + state == (1, 0, 0, 0) + return -1 + elseif state == (0, 0, 0, 0) || state == (0, 1, 0, 1) || state == (1, 0, 1, 0) || + state == (1, 1, 1, 1) + return 0 + else + return 0 # err is interpreted as no movement + end + end + qAevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta) ~ 0], + ModelingToolkit.ImperativeAffect((; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 1 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qA, hA, hB, cnt), (; qB)) do x, o, c, i + @set! x.hA = x.qA + @set! x.hB = o.qB + @set! x.qA = 0 + @set! x.cnt += decoder(x.hA, x.hB, x.qA, o.qB) + x + end; rootfind = SciMLBase.RightRootFind) + qBevt = ModelingToolkit.SymbolicContinuousCallback([cos(100 * theta - π / 2) ~ 0], + ModelingToolkit.ImperativeAffect((; qB, hA, hB, cnt), (; qA)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = 1 + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end, + affect_neg = ModelingToolkit.ImperativeAffect( + (; qB, hA, hB, cnt), (; qA)) do x, o, c, i + @set! x.hA = o.qA + @set! x.hB = x.qB + @set! x.qB = 0 + @set! x.cnt += decoder(x.hA, x.hB, o.qA, x.qB) + x + end; rootfind = SciMLBase.RightRootFind) + @named sys = ODESystem( + eqs, t, [theta, omega], params; continuous_events = [qAevt, qBevt]) + ss = structural_simplify(sys) + prob = ODEProblem(ss, [theta => 1e-5], (0.0, pi)) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test getp(sol, cnt)(sol) == 198 # we get 2 pulses per phase cycle (cos 0 crossing) and we go to 100 cycles; we miss a few due to the initial state +end + +@testset "Initialization" begin + @variables x(t) + seen = false + f = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> seen = true, sts = [], pars = [], discretes = []) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], Equation[], initialize = [x ~ 1.5], finalize = f) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; continuous_events = [cb1]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5(); dtmax = 0.01) + @test sol[x][1] ≈ 1.0 + @test sol[x][2] ≈ 1.5 # the initialize affect has been applied + @test seen == true + + @variables x(t) + seen = false + f = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> seen = true, sts = [], pars = [], discretes = []) + cb1 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0], Equation[], initialize = [x ~ 1.5], finalize = f) + inited = false + finaled = false + a = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> inited = true, sts = [], pars = [], discretes = []) + b = ModelingToolkit.FunctionalAffect( + f = (i, u, p, c) -> finaled = true, sts = [], pars = [], discretes = []) + cb2 = ModelingToolkit.SymbolicContinuousCallback( + [x ~ 0.1], Equation[], initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; continuous_events = [cb1, cb2]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test sol[x][1] ≈ 1.0 + @test sol[x][2] ≈ 1.5 # the initialize affect has been applied + @test seen == true + @test inited == true + @test finaled == true + + #periodic + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback( + 1.0, [x ~ 2], initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test inited == true + @test finaled == true + @test isapprox(sol[x][3], 0.0, atol = 1e-9) + @test sol[x][4] ≈ 2.0 + @test sol[x][5] ≈ 1.0 + + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback(1.0, f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test seen == true + @test inited == true + + #preset + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback([1.0], f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5()) + @test seen == true + @test inited == true + @test finaled == true + + #equational + seen = false + inited = false + finaled = false + cb3 = ModelingToolkit.SymbolicDiscreteCallback( + t == 1.0, f, initialize = a, finalize = b) + @mtkbuild sys = ODESystem(D(x) ~ -1, t, [x], []; discrete_events = [cb3]) + prob = ODEProblem(sys, [x => 1.0], (0.0, 2), []) + sol = solve(prob, Tsit5(); tstops = 1.0) + @test seen == true + @test inited == true + @test finaled == true +end + +@testset "Bump" begin + @variables x(t) [irreducible = true] y(t) [irreducible = true] + eqs = [x ~ y, D(x) ~ -1] + cb = [x ~ 0.0] => [x ~ 0, y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test_throws "DAE initialization failed" solve(prob, Rodas5()) + + cb = [x ~ 0.0] => [y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test_broken !SciMLBase.successful_retcode(solve(prob, Rodas5())) + + cb = [x ~ 0.0] => [x ~ 1, y ~ 1] + @mtkbuild pend = ODESystem(eqs, t; continuous_events = [cb]) + prob = ODEProblem(pend, [x => 1], (0.0, 3.0), guesses = [y => x]) + @test all(≈(0.0; atol = 1e-9), solve(prob, Rodas5())[[x, y]][end]) +end + +@testset "Issue#3154 Array variable in discrete condition" begin + @mtkmodel DECAY begin + @parameters begin + unrelated[1:2] = zeros(2) + k = 0.0 + end + @variables begin + x(t) = 10.0 + end + @equations begin + D(x) ~ -k * x + end + @discrete_events begin + (t == 1.0) => [k ~ 1.0] + end + end + @mtkbuild decay = DECAY() + prob = ODEProblem(decay, [], (0.0, 10.0), []) + @test_nowarn solve(prob, Tsit5(), tstops = [1.0]) +end + +@testset "Array parameter updates in ImperativeEffect" begin + function weird1(max_time; name) + params = @parameters begin + θ(t) = 0.0 + end + vars = @variables begin + x(t) = 0.0 + end + eqs = reduce(vcat, Symbolics.scalarize.([ + D(x) ~ 1.0 + ])) + reset = ModelingToolkit.ImperativeAffect( + modified = (; x, θ)) do m, o, _, i + @set! m.θ = 0.0 + @set! m.x = 0.0 + return m + end + return ODESystem(eqs, t, vars, params; name = name, + continuous_events = [[x ~ max_time] => reset]) + end + + function weird2(max_time; name) + params = @parameters begin + θ(t) = 0.0 + end + vars = @variables begin + x(t) = 0.0 + end + eqs = reduce(vcat, Symbolics.scalarize.([ + D(x) ~ 1.0 + ])) + return ODESystem(eqs, t, vars, params; name = name) # note no event + end + + @named wd1 = weird1(0.021) + @named wd2 = weird2(0.021) + + sys1 = structural_simplify(ODESystem([], t; name = :parent, + discrete_events = [0.01 => ModelingToolkit.ImperativeAffect( + modified = (; θs = reduce(vcat, [[wd1.θ]])), ctx = [1]) do m, o, c, i + @set! m.θs[1] = c[] += 1 + end], + systems = [wd1])) + sys2 = structural_simplify(ODESystem([], t; name = :parent, + discrete_events = [0.01 => ModelingToolkit.ImperativeAffect( + modified = (; θs = reduce(vcat, [[wd2.θ]])), ctx = [1]) do m, o, c, i + @set! m.θs[1] = c[] += 1 + end], + systems = [wd2])) + + sol1 = solve(ODEProblem(sys1, [], (0.0, 1.0)), Tsit5()) + @test 100.0 ∈ sol1[sys1.wd1.θ] + + sol2 = solve(ODEProblem(sys2, [], (0.0, 1.0)), Tsit5()) + @test 100.0 ∈ sol2[sys2.wd2.θ] +end diff --git a/test/symbolic_indexing_interface.jl b/test/symbolic_indexing_interface.jl index 2531f4eef4..8b3da5fd72 100644 --- a/test/symbolic_indexing_interface.jl +++ b/test/symbolic_indexing_interface.jl @@ -8,6 +8,7 @@ using SciMLStructures: Tunable eqs = [D(x) ~ a * y + t, D(y) ~ b * t] @named odesys = ODESystem(eqs, t, [x, y], [a, b]; observed = [xy ~ x + y]) odesys = complete(odesys) + @test SymbolicIndexingInterface.supports_tuple_observed(odesys) @test all(is_variable.((odesys,), [x, y, 1, 2, :x, :y])) @test all(.!is_variable.((odesys,), [a, b, t, 3, 0, :a, :b])) @test variable_index.((odesys,), [x, y, a, b, t, 1, 2, :x, :y, :a, :b]) == @@ -22,7 +23,9 @@ using SciMLStructures: Tunable @test parameter_index.( (odesys,), [x, y, t, ParameterIndex(Tunable(), 1), :x, :y]) == [nothing, nothing, nothing, ParameterIndex(Tunable(), 1), nothing, nothing] - @test isequal(parameter_symbols(odesys), [a, b]) + @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]) @@ -33,6 +36,14 @@ using SciMLStructures: Tunable @test default_values(odesys)[y] == 2.0 @test isequal(default_values(odesys)[xy], x + y) + prob = ODEProblem(odesys, [], (0.0, 1.0), [a => 1.0, b => 2.0]) + getter = getu(odesys, (x + 1, x + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) + getter = getp(odesys, (a + 1, a + 2)) + @test getter(prob) isa Tuple + @test_nowarn @inferred getter(prob) + @named odesys = ODESystem( eqs, t, [x, y], [a, b]; defaults = [xy => 3.0], observed = [xy ~ x + y]) odesys = complete(odesys) @@ -99,6 +110,7 @@ end 0 ~ x * y - β * z] @named ns = NonlinearSystem(eqs, [x, y, z], [σ, ρ, β]) ns = complete(ns) + @test SymbolicIndexingInterface.supports_tuple_observed(ns) @test !is_time_dependent(ns) ps = ModelingToolkit.MTKParameters(ns, [σ => 1.0, ρ => 2.0, β => 3.0]) pobs = parameter_observed(ns, σ + ρ) @@ -107,6 +119,15 @@ end 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 @@ -185,3 +206,34 @@ get_dep = @test_nowarn getu(prob, 2p1) @test getu(prob, p1)(prob) == getu(prob, :p1)(prob) @test getu(prob, p2)(prob) == getu(prob, :p2)(prob) end + +@testset "Parameter dependencies as symbols" begin + @variables x(t) = 1.0 + @parameters a=1 b + @named model = ODESystem(D(x) ~ x + a - b, t, parameter_dependencies = [b ~ a + 1]) + sys = complete(model) + prob = ODEProblem(sys, [], (0.0, 1.0)) + @test prob.ps[b] == prob.ps[:b] +end + +@testset "`get_all_timeseries_indexes` with non-split systems" begin + @variables x(t) y(t) z(t) + @parameters a + @named sys = ODESystem([D(x) ~ a * x, y ~ 2x, z ~ 0.0], t) + sys = structural_simplify(sys, split = false) + for sym in [x, y, z, x + y, x + a, y / x] + @test only(get_all_timeseries_indexes(sys, sym)) == ContinuousTimeseries() + end + @test isempty(get_all_timeseries_indexes(sys, a)) +end + +@testset "`timeseries_parameter_index` on unwrapped scalarized timeseries parameter" begin + @variables x(t)[1:2] + @parameters p(t)[1:2, 1:2] + ev = [x[1] ~ 2.0] => [p ~ -ones(2, 2)] + @mtkbuild sys = ODESystem(D(x) ~ p * x, t; continuous_events = [ev]) + p = ModelingToolkit.unwrap(p) + @test timeseries_parameter_index(sys, p) === ParameterTimeseriesIndex(1, (1, 1)) + @test timeseries_parameter_index(sys, p[1, 1]) === + ParameterTimeseriesIndex(1, (1, 1, 1, 1)) +end diff --git a/test/test_variable_metadata.jl b/test/test_variable_metadata.jl index 7f9799edb3..aaf6addb59 100644 --- a/test/test_variable_metadata.jl +++ b/test/test_variable_metadata.jl @@ -1,4 +1,5 @@ using ModelingToolkit +using DynamicQuantities # Bounds @variables u [bounds = (-1, 1)] @@ -10,32 +11,73 @@ using ModelingToolkit @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 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.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 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 hasguess(y[i]) == true @test ModelingToolkit.dump_variable_metadata(y[i]).guess == 1.0 end @variables y -@test hasguess(y) === false +@test hasguess(y) == false @test !haskey(ModelingToolkit.dump_variable_metadata(y), :guess) # Disturbance @@ -144,3 +186,42 @@ params_meta = ModelingToolkit.dump_parameters(sys) params_meta = Dict([ModelingToolkit.getname(meta.var) => meta for meta in params_meta]) @test params_meta[:p].default == 3.0 @test isequal(params_meta[:q].dependency, 2p) + +# Connect +@variables x [connect = Flow] +@test hasconnect(x) +@test getconnect(x) == Flow +@test ModelingToolkit.dump_variable_metadata(x).connect == Flow +x = ModelingToolkit.setconnect(x, ModelingToolkit.Stream) +@test getconnect(x) == ModelingToolkit.Stream + +struct BadConnect end +@test_throws Exception ModelingToolkit.setconnect(x, BadConnect) + +# Unit +@variables x [unit = u"s"] +@test hasunit(x) +@test getunit(x) == u"s" +@test ModelingToolkit.dump_variable_metadata(x).unit == u"s" + +# Misc data +@variables x [misc = [:good]] +@test hasmisc(x) +@test getmisc(x) == [:good] +x = ModelingToolkit.setmisc(x, "okay") +@test getmisc(x) == "okay" + +# Variable Type +@variables x +@test ModelingToolkit.getvariabletype(x) == ModelingToolkit.VARIABLE +@test ModelingToolkit.dump_variable_metadata(x).variable_type == ModelingToolkit.VARIABLE +@test ModelingToolkit.dump_variable_metadata(x).variable_source == :variables +x = ModelingToolkit.toparam(x) +@test ModelingToolkit.getvariabletype(x) == ModelingToolkit.PARAMETER +@test ModelingToolkit.dump_variable_metadata(x).variable_source == :variables + +@parameters y +@test ModelingToolkit.getvariabletype(y) == ModelingToolkit.PARAMETER + +@brownian z +@test ModelingToolkit.getvariabletype(z) == ModelingToolkit.BROWNIAN diff --git a/test/variable_scope.jl b/test/variable_scope.jl index 8c6e358c23..bd1d3cb0cf 100644 --- a/test/variable_scope.jl +++ b/test/variable_scope.jl @@ -1,9 +1,8 @@ using ModelingToolkit -using ModelingToolkit: SymScope -using Symbolics: arguments, value +using ModelingToolkit: SymScope, t_nounits as t, D_nounits as D +using Symbolics: arguments, value, getname using Test -@independent_variables t @variables a b(t) c d e(t) b = ParentScope(b) @@ -52,7 +51,6 @@ end @test renamed([:foo :bar :baz], c) == Symbol("foo₊c") @test renamed([:foo :bar :baz], d) == :d -@independent_variables t @parameters a b c d e f p = [a ParentScope(b) @@ -84,3 +82,60 @@ arr1 = ODESystem(Equation[], t, [], []; name = :arr1) ∘ arr0 arr_ps = ModelingToolkit.getname.(parameters(arr1)) @test isequal(arr_ps[1], Symbol("xx")) @test isequal(arr_ps[2], Symbol("arr0₊xx")) + +function Foo(; name, p = 1) + @parameters p = p + @variables x(t) + return ODESystem(D(x) ~ p, t; name) +end +function Bar(; name, p = 2) + @parameters p = p + @variables x(t) + @named foo = Foo(; p) + return ODESystem(D(x) ~ p + t, t; systems = [foo], name) +end +@named bar = Bar() +bar = complete(bar) +@test length(parameters(bar)) == 2 +@test sort(getname.(parameters(bar))) == [:foo₊p, :p] +defs = ModelingToolkit.defaults(bar) +@test defs[bar.p] == 2 +@test isequal(defs[bar.foo.p], bar.p) + +# Issue#3101 +@variables x1(t) x2(t) x3(t) x4(t) x5(t) +x2 = ParentScope(x2) +x3 = ParentScope(ParentScope(x3)) +x4 = DelayParentScope(x4) +x5 = GlobalScope(x5) +@parameters p1 p2 p3 p4 p5 +p2 = ParentScope(p2) +p3 = ParentScope(ParentScope(p3)) +p4 = DelayParentScope(p4) +p5 = GlobalScope(p5) + +@named sys1 = ODESystem([D(x1) ~ p1, D(x2) ~ p2, D(x3) ~ p3, D(x4) ~ p4, D(x5) ~ p5], t) +@test isequal(x1, only(unknowns(sys1))) +@test isequal(p1, only(parameters(sys1))) +@named sys2 = ODESystem(Equation[], t; systems = [sys1]) +@test length(unknowns(sys2)) == 2 +@test any(isequal(x2), unknowns(sys2)) +@test length(parameters(sys2)) == 2 +@test any(isequal(p2), parameters(sys2)) +@named sys3 = ODESystem(Equation[], t) +sys3 = sys3 ∘ sys2 +@test length(unknowns(sys3)) == 4 +@test any(isequal(x3), unknowns(sys3)) +@test any(isequal(ModelingToolkit.renamespace(sys1, x4)), unknowns(sys3)) +@test length(parameters(sys3)) == 4 +@test any(isequal(p3), parameters(sys3)) +@test any(isequal(ModelingToolkit.renamespace(sys1, p4)), parameters(sys3)) +sys4 = complete(sys3) +@test length(unknowns(sys3)) == 4 +@test length(parameters(sys4)) == 5 +@test any(isequal(p5), parameters(sys4)) +sys5 = structural_simplify(sys3) +@test length(unknowns(sys5)) == 5 +@test any(isequal(x5), unknowns(sys5)) +@test length(parameters(sys5)) == 5 +@test any(isequal(p5), parameters(sys5)) diff --git a/test/variable_utils.jl b/test/variable_utils.jl index 8f3178f453..3204d28836 100644 --- a/test/variable_utils.jl +++ b/test/variable_utils.jl @@ -1,6 +1,7 @@ using ModelingToolkit, Test -using ModelingToolkit: value +using ModelingToolkit: value, vars, parse_variable using SymbolicUtils: <ₑ + @parameters α β δ expr = (((1 / β - 1) + δ) / α)^(1 / (α - 1)) ref = sort([β, δ, α], lt = <ₑ) @@ -33,3 +34,127 @@ aov = ModelingToolkit.collect_applied_operators(eq, Differential) ts = collect_ivs([eq]) @test ts == Set([t]) + +@testset "vars searching through array of symbolics" begin + fn(x, y) = sum(x) + y + @register_symbolic fn(x::AbstractArray, y) + @variables x y z + res = vars(fn([x, y], z)) + @test length(res) == 3 +end + +@testset "parse_variable with iv: $iv" for iv in [t, only(@independent_variables tt)] + D = Differential(iv) + function Lorenz(; name) + @variables begin + x(iv) + y(iv) + z(iv) + end + @parameters begin + σ + ρ + β + end + sys = ODESystem( + [D(D(x)) ~ σ * (y - x) + D(y) ~ x * (ρ - z) - y + D(z) ~ x * y - β * z], iv; name) + end + function ArrSys(; name) + @variables begin + x(iv)[1:2] + end + @parameters begin + p[1:2, 1:2] + end + sys = ODESystem([D(D(x)) ~ p * x], iv; name) + end + function Outer(; name) + @named 😄 = Lorenz() + @named arr = ArrSys() + sys = ODESystem(Equation[], iv; name, systems = [😄, arr]) + end + + @mtkbuild sys = Outer() + for (str, var) in [ + # unicode system, scalar variable + ("😄.x", sys.😄.x), + ("😄.x($iv)", sys.😄.x), + ("😄₊x", sys.😄.x), + ("😄₊x($iv)", sys.😄.x), + # derivative + ("D(😄.x)", D(sys.😄.x)), + ("D(😄.x($iv))", D(sys.😄.x)), + ("D(😄₊x)", D(sys.😄.x)), + ("D(😄₊x($iv))", D(sys.😄.x)), + ("Differential($iv)(😄.x)", D(sys.😄.x)), + ("Differential($iv)(😄.x($iv))", D(sys.😄.x)), + ("Differential($iv)(😄₊x)", D(sys.😄.x)), + ("Differential($iv)(😄₊x($iv))", D(sys.😄.x)), + # other derivative + ("😄.xˍ$iv", D(sys.😄.x)), + ("😄.x($iv)ˍ$iv", D(sys.😄.x)), + ("😄₊xˍ$iv", D(sys.😄.x)), + ("😄₊x($iv)ˍ$iv", D(sys.😄.x)), + # scalar parameter + ("😄.σ", sys.😄.σ), + ("😄₊σ", sys.😄.σ), + # array variable + ("arr.x", sys.arr.x), + ("arr₊x", sys.arr.x), + ("arr.x($iv)", sys.arr.x), + ("arr₊x($iv)", sys.arr.x), + # getindex + ("arr.x[1]", sys.arr.x[1]), + ("arr₊x[1]", sys.arr.x[1]), + ("arr.x($iv)[1]", sys.arr.x[1]), + ("arr₊x($iv)[1]", sys.arr.x[1]), + # derivative + ("D(arr.x($iv))", D(sys.arr.x)), + ("D(arr₊x($iv))", D(sys.arr.x)), + ("D(arr.x[1])", D(sys.arr.x[1])), + ("D(arr₊x[1])", D(sys.arr.x[1])), + ("D(arr.x($iv)[1])", D(sys.arr.x[1])), + ("D(arr₊x($iv)[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr.x($iv))", D(sys.arr.x)), + ("Differential($iv)(arr₊x($iv))", D(sys.arr.x)), + ("Differential($iv)(arr.x[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr₊x[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr.x($iv)[1])", D(sys.arr.x[1])), + ("Differential($iv)(arr₊x($iv)[1])", D(sys.arr.x[1])), + # other derivative + ("arr.xˍ$iv", D(sys.arr.x)), + ("arr₊xˍ$iv", D(sys.arr.x)), + ("arr.xˍ$iv($iv)", D(sys.arr.x)), + ("arr₊xˍ$iv($iv)", D(sys.arr.x)), + ("arr.xˍ$iv[1]", D(sys.arr.x[1])), + ("arr₊xˍ$iv[1]", D(sys.arr.x[1])), + ("arr.xˍ$iv($iv)[1]", D(sys.arr.x[1])), + ("arr₊xˍ$iv($iv)[1]", D(sys.arr.x[1])), + ("arr.x($iv)ˍ$iv", D(sys.arr.x)), + ("arr₊x($iv)ˍ$iv", D(sys.arr.x)), + ("arr.x($iv)ˍ$iv[1]", D(sys.arr.x[1])), + ("arr₊x($iv)ˍ$iv[1]", D(sys.arr.x[1])), + # array parameter + ("arr.p", sys.arr.p), + ("arr₊p", sys.arr.p), + ("arr.p[1, 2]", sys.arr.p[1, 2]), + ("arr₊p[1, 2]", sys.arr.p[1, 2]) + ] + @test isequal(parse_variable(sys, str), var) + end +end + +@testset "isinitial" begin + t = ModelingToolkit.t_nounits + @variables x(t) z(t)[1:5] + @parameters a b c[1:4] + @test isinitial(Initial(z)) + @test isinitial(Initial(x)) + @test isinitial(Initial(a)) + @test isinitial(Initial(z[1])) + @test isinitial(Initial(c[4])) + @test !isinitial(c) + @test !isinitial(x) +end