From ebc791edcecdfe00e69a70ceed4c12a9af9e0af9 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Sat, 25 Apr 2026 06:33:43 +0200 Subject: [PATCH 01/38] WIP: add interface to SciMLBase for ODEProblem --- Project.toml | 13 ++++++- ext/PETScSciMLExt.jl | 21 +++++++++++ src/string_wrappers_extra.jl | 60 ++++++++++++++++++++++++++++++ test/runtests.jl | 1 + test/sciml/runtests.jl | 17 +++++++++ test/test_string_wrappers_extra.jl | 60 ++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 ext/PETScSciMLExt.jl create mode 100644 test/sciml/runtests.jl create mode 100644 test/test_string_wrappers_extra.jl diff --git a/Project.toml b/Project.toml index 208943958..dfb87a47e 100644 --- a/Project.toml +++ b/Project.toml @@ -17,7 +17,15 @@ SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" +[weakdeps] +DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" +SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" + +[extensions] +PETScSciMLExt = ["SciMLBase", "DiffEqBase"] + [compat] +DiffEqBase = "6" ForwardDiff = "0.10, 1" Libdl = "^1.10" LinearAlgebra = "^1.10" @@ -27,6 +35,7 @@ Preferences = "1" OffsetArrays = "1.0" PETSc_jll = "3.22" Pkg = "^1.10" +SciMLBase = "2" SparseArrays = "1.10" Statistics = "^1.10" UnicodePlots = "3.0" @@ -34,13 +43,15 @@ julia = "^1.10" [extras] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" SparseDiffTools = "47a9eef4-7e08-11e9-0b38-333d64bd3804" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [targets] -test = ["ForwardDiff", "UnicodePlots", "Test", "Plots", "SparseDiffTools", "Printf", "Random", "CairoMakie"] +test = ["CairoMakie", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl new file mode 100644 index 000000000..dc16dc019 --- /dev/null +++ b/ext/PETScSciMLExt.jl @@ -0,0 +1,21 @@ +module PETScSciMLExt + +using PETSc +using SciMLBase +using DiffEqBase + +# This extension is loaded when both SciMLBase and DiffEqBase are present in +# the user's environment (loading OrdinaryDiffEq satisfies both). +# +# Implementation is staged following PLAN_INTERFACE.md: +# Step 1 — algorithm types, integrator, options, interface contract +# Step 2 — TSRK (explicit Runge-Kutta) end-to-end solve +# Step 3 — TSRosW (Rosenbrock-W) +# Step 4 — TSImplicit (BEULER / CN / Theta / BDF) +# Step 5 — TSARKIMEX (SplitODEProblem) +# Step 6 — save_everystep, saveat +# Step 7 — discrete callbacks, terminate! +# Step 8 — lifecycle (finalizers, destroy) +# Step 9 — polish + +end # module diff --git a/src/string_wrappers_extra.jl b/src/string_wrappers_extra.jl index 2a28baefd..007f744a0 100644 --- a/src/string_wrappers_extra.jl +++ b/src/string_wrappers_extra.jl @@ -33,3 +33,63 @@ function LibPETSc.TaoSetType(petsclib::LibPETSc.PetscLibType, tao, type::String) LibPETSc.TaoSetType(petsclib, tao, ptr) return nothing end + +""" + TSRKSetType(petsclib, ts, subtype::String) + +Convenience wrapper for selecting a Runge-Kutta subtype on an explicit RK +`TS` using a Julia string (e.g. `"3bs"`, `"5dp"`). + +# Example +```julia +ts = LibPETSc.TSCreate(petsclib, LibPETSc.PETSC_COMM_SELF) +LibPETSc.TSSetType(petsclib, ts, "rk") +LibPETSc.TSRKSetType(petsclib, ts, "3bs") +``` +""" +function LibPETSc.TSRKSetType(petsclib::LibPETSc.PetscLibType, ts, subtype::String) + c_str = Vector{UInt8}(subtype * "\0") + ptr = Base.unsafe_convert(LibPETSc.TSRKType, pointer(c_str)) + LibPETSc.TSRKSetType(petsclib, ts, ptr) + return nothing +end + +""" + TSRosWSetType(petsclib, ts, subtype::String) + +Convenience wrapper for selecting a Rosenbrock-W subtype on a `TS` of type +`"rosw"` using a Julia string (e.g. `"ra34pw2"`, `"rodas3"`). + +# Example +```julia +ts = LibPETSc.TSCreate(petsclib, LibPETSc.PETSC_COMM_SELF) +LibPETSc.TSSetType(petsclib, ts, "rosw") +LibPETSc.TSRosWSetType(petsclib, ts, "ra34pw2") +``` +""" +function LibPETSc.TSRosWSetType(petsclib::LibPETSc.PetscLibType, ts, subtype::String) + c_str = Vector{UInt8}(subtype * "\0") + ptr = Base.unsafe_convert(LibPETSc.TSRosWType, pointer(c_str)) + LibPETSc.TSRosWSetType(petsclib, ts, ptr) + return nothing +end + +""" + TSARKIMEXSetType(petsclib, ts, subtype::String) + +Convenience wrapper for selecting an ARK IMEX subtype on a `TS` of type +`"arkimex"` using a Julia string (e.g. `"2e"`, `"3"`). + +# Example +```julia +ts = LibPETSc.TSCreate(petsclib, LibPETSc.PETSC_COMM_SELF) +LibPETSc.TSSetType(petsclib, ts, "arkimex") +LibPETSc.TSARKIMEXSetType(petsclib, ts, "2e") +``` +""" +function LibPETSc.TSARKIMEXSetType(petsclib::LibPETSc.PetscLibType, ts, subtype::String) + c_str = Vector{UInt8}(subtype * "\0") + ptr = Base.unsafe_convert(LibPETSc.TSARKIMEXType, pointer(c_str)) + LibPETSc.TSARKIMEXSetType(petsclib, ts, ptr) + return nothing +end diff --git a/test/runtests.jl b/test/runtests.jl index 19f69e551..562198cdf 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -35,6 +35,7 @@ include("test_snes.jl") include("old_test.jl") include("low_level_viewer.jl") # Low-level viewer convenience functions include("low_level_ts.jl") # Low-level TS functions +include("test_string_wrappers_extra.jl") # String overloads for TS subtype setters include("ts_ex51.jl") # Regression test for repeated ex51 solves include("ts_ex51_implicit.jl") # Regression test for repeated implicit Gauss solves include("ts_ex16.jl") # Regression test for the van der Pol IMEX example diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl new file mode 100644 index 000000000..403851d15 --- /dev/null +++ b/test/sciml/runtests.jl @@ -0,0 +1,17 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase + +@testset "PETSc.jl SciML extension" begin + ext = Base.get_extension(PETSc, :PETScSciMLExt) + @test ext !== nothing + # Algorithm-level test files will be added in Steps 2–8: + # include("test_rk.jl") + # include("test_rosenbrock.jl") + # include("test_implicit.jl") + # include("test_imex.jl") + # include("test_output.jl") + # include("test_callbacks.jl") + # include("test_integrator.jl") +end diff --git a/test/test_string_wrappers_extra.jl b/test/test_string_wrappers_extra.jl new file mode 100644 index 000000000..af2728ec6 --- /dev/null +++ b/test/test_string_wrappers_extra.jl @@ -0,0 +1,60 @@ +using Test +using PETSc + +@testset "String wrappers for TS subtype setters" begin + petsclib = PETSc.getlib(PetscScalar = Float64) + PETSc.initialize(petsclib) + + @testset "TSRKSetType(::String) round-trips" begin + for sub in ["3bs", "5dp", "4"] + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "rk") + @test_nowarn PETSc.LibPETSc.TSRKSetType(petsclib, ts, sub) + @test PETSc.LibPETSc.TSRKGetType(petsclib, ts) == sub + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + end + + @testset "TSRKSetType(::String) rejects unknown subtype" begin + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "rk") + @test_throws Exception PETSc.LibPETSc.TSRKSetType(petsclib, ts, "this-subtype-does-not-exist") + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + + @testset "TSRosWSetType(::String) round-trips" begin + for sub in ["ra34pw2", "rodas3", "2m"] + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "rosw") + @test_nowarn PETSc.LibPETSc.TSRosWSetType(petsclib, ts, sub) + @test PETSc.LibPETSc.TSRosWGetType(petsclib, ts) == sub + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + end + + @testset "TSRosWSetType(::String) rejects unknown subtype" begin + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "rosw") + @test_throws Exception PETSc.LibPETSc.TSRosWSetType(petsclib, ts, "this-subtype-does-not-exist") + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + + @testset "TSARKIMEXSetType(::String) round-trips" begin + for sub in ["2e", "3", "4"] + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "arkimex") + @test_nowarn PETSc.LibPETSc.TSARKIMEXSetType(petsclib, ts, sub) + @test PETSc.LibPETSc.TSARKIMEXGetType(petsclib, ts) == sub + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + end + + @testset "TSARKIMEXSetType(::String) rejects unknown subtype" begin + ts = PETSc.LibPETSc.TSCreate(petsclib, PETSc.LibPETSc.PETSC_COMM_SELF) + PETSc.LibPETSc.TSSetType(petsclib, ts, "arkimex") + @test_throws Exception PETSc.LibPETSc.TSARKIMEXSetType(petsclib, ts, "this-subtype-does-not-exist") + PETSc.LibPETSc.TSDestroy(petsclib, ts) + end + + PETSc.finalize(petsclib) +end From 84ba441bc798d067a6804b29512661cfc8715883 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Sun, 26 Apr 2026 11:34:02 +0200 Subject: [PATCH 02/38] WIP --- Project.toml | 7 +- README.md | 22 +++ ext/PETScSciMLExt.jl | 23 ++- ext/sciml/algorithms.jl | 4 + ext/sciml/helpers.jl | 48 +++++ ext/sciml/ifunction_callback.jl | 51 ++++++ ext/sciml/integrator.jl | 34 ++++ ext/sciml/interface.jl | 67 +++++++ ext/sciml/options.jl | 62 +++++++ ext/sciml/retcode.jl | 40 +++++ ext/sciml/rhs_callback.jl | 44 +++++ ext/sciml/solve.jl | 304 ++++++++++++++++++++++++++++++++ src/PETSc.jl | 6 +- src/sciml_algorithms.jl | 162 +++++++++++++++++ test/sciml/test_callbacks.jl | 89 ++++++++++ test/sciml/test_imex.jl | 99 +++++++++++ test/sciml/test_implicit.jl | 96 ++++++++++ test/sciml/test_integrator.jl | 118 +++++++++++++ test/sciml/test_output.jl | 93 ++++++++++ test/sciml/test_polish.jl | 67 +++++++ test/sciml/test_rk.jl | 100 +++++++++++ test/sciml/test_rosenbrock.jl | 79 +++++++++ 22 files changed, 1599 insertions(+), 16 deletions(-) create mode 100644 ext/sciml/algorithms.jl create mode 100644 ext/sciml/helpers.jl create mode 100644 ext/sciml/ifunction_callback.jl create mode 100644 ext/sciml/integrator.jl create mode 100644 ext/sciml/interface.jl create mode 100644 ext/sciml/options.jl create mode 100644 ext/sciml/retcode.jl create mode 100644 ext/sciml/rhs_callback.jl create mode 100644 ext/sciml/solve.jl create mode 100644 src/sciml_algorithms.jl create mode 100644 test/sciml/test_callbacks.jl create mode 100644 test/sciml/test_imex.jl create mode 100644 test/sciml/test_implicit.jl create mode 100644 test/sciml/test_integrator.jl create mode 100644 test/sciml/test_output.jl create mode 100644 test/sciml/test_polish.jl create mode 100644 test/sciml/test_rk.jl create mode 100644 test/sciml/test_rosenbrock.jl diff --git a/Project.toml b/Project.toml index dfb87a47e..6ec052577 100644 --- a/Project.toml +++ b/Project.toml @@ -18,13 +18,15 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [weakdeps] +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" [extensions] -PETScSciMLExt = ["SciMLBase", "DiffEqBase"] +PETScSciMLExt = ["SciMLBase", "DiffEqBase", "DataStructures"] [compat] +DataStructures = "0.18" DiffEqBase = "6" ForwardDiff = "0.10, 1" Libdl = "^1.10" @@ -43,6 +45,7 @@ julia = "^1.10" [extras] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" @@ -54,4 +57,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [targets] -test = ["CairoMakie", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] +test = ["CairoMakie", "DataStructures", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] diff --git a/README.md b/README.md index 67a86b625..7e658a9ab 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,25 @@ The package currently does not work on windows, mainly because `MicrosoftMPI_jll Have a look at the [documentation](https://juliaparallel.org/PETSc.jl/stable/), at the [examples](./examples/) directory or at the tests in the [test](./test) directory. We do keep the tests up to date, so that is a good starting point. Note, that we do not have tests in place for the whole library at this stage. The best supported parts are `DMDA`,`DMStag`, `KSP`,`SNES`,`Vec` and `Mat` interfaces, while other parts such as `DMPlex` do not have a high-level interface or tests yet. Users will thus have to rely on the low-level interface. + +## SciML / OrdinaryDiffEq integration + +`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciMLBase / OrdinaryDiffEq interface. The extension activates automatically when both `SciMLBase` and `DiffEqBase` are loaded — typically as a side effect of `using OrdinaryDiffEq`. + +```julia +using PETSc, OrdinaryDiffEq + +f!(du, u, p, t) = (du[1] = -u[1]; nothing) +prob = ODEProblem(f!, [1.0], (0.0, 1.0)) + +sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) # explicit RK +sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) # Rosenbrock-W +sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) # BDF / theta / CN / BEuler +sol = solve(SplitODEProblem(f1!, f2!, u0, tspan), + PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) # IMEX + +integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!/solve! interface +step!(integrator); sol = solve!(integrator) +``` + +Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; continuous callbacks and `tstops` are warned about and ignored. The extension currently requires `PetscReal = Float64` and in-place ODE problems. See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl index dc16dc019..06549e763 100644 --- a/ext/PETScSciMLExt.jl +++ b/ext/PETScSciMLExt.jl @@ -3,19 +3,16 @@ module PETScSciMLExt using PETSc using SciMLBase using DiffEqBase +using DataStructures: BinaryMinHeap -# This extension is loaded when both SciMLBase and DiffEqBase are present in -# the user's environment (loading OrdinaryDiffEq satisfies both). -# -# Implementation is staged following PLAN_INTERFACE.md: -# Step 1 — algorithm types, integrator, options, interface contract -# Step 2 — TSRK (explicit Runge-Kutta) end-to-end solve -# Step 3 — TSRosW (Rosenbrock-W) -# Step 4 — TSImplicit (BEULER / CN / Theta / BDF) -# Step 5 — TSARKIMEX (SplitODEProblem) -# Step 6 — save_everystep, saveat -# Step 7 — discrete callbacks, terminate! -# Step 8 — lifecycle (finalizers, destroy) -# Step 9 — polish +include("sciml/algorithms.jl") +include("sciml/options.jl") +include("sciml/integrator.jl") +include("sciml/interface.jl") +include("sciml/retcode.jl") +include("sciml/helpers.jl") +include("sciml/rhs_callback.jl") +include("sciml/ifunction_callback.jl") +include("sciml/solve.jl") end # module diff --git a/ext/sciml/algorithms.jl b/ext/sciml/algorithms.jl new file mode 100644 index 000000000..974a4fb4d --- /dev/null +++ b/ext/sciml/algorithms.jl @@ -0,0 +1,4 @@ +# Algorithm types live in PETSc proper (`src/sciml_algorithms.jl`) so users +# can write `PETSc.TSRK("3bs")` without going through `Base.get_extension`. +# This file just imports them into the extension's namespace. +using PETSc: PETScTSAlgorithm, TSRK, TSRosW, TSImplicit, TSARKIMEX, TSGeneric diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl new file mode 100644 index 000000000..3f4a0da82 --- /dev/null +++ b/ext/sciml/helpers.jl @@ -0,0 +1,48 @@ +function _check_isinplace(prob) + SciMLBase.isinplace(prob) || throw(ArgumentError( + "PETSc.jl time-stepping wrappers only support in-place ODEProblems " * + "(f!(du, u, p, t)). Wrap your function in an in-place form or use a " * + "different solver.", + )) +end + +# The PETSc TS C callback signatures we register via `@cfunction` use +# `Float64` for the time argument. PETSc libraries built with +# `PetscReal = Float32` would pass a Float32 across the C ABI, which would +# not match. Detect this up-front so users get a clear error rather than a +# bus error when the callback fires. +function _check_petscreal(lib) + lib.PetscReal === Float64 || throw(ArgumentError( + "PETSc.jl SciML extension currently only supports PetscReal = Float64. " * + "Got PetscReal = $(lib.PetscReal). Pass `petsclib = PETSc.getlib(PetscScalar = Float64)` " * + "or use a PETSc build with PetscReal = Float64.", + )) +end + +_pick_petsclib(prob, petsclib) = petsclib +_pick_petsclib(prob, ::Nothing) = PETSc.getlib(PetscScalar = real(eltype(prob.u0))) + +function _setfromoptions!(petsclib, ts, petsc_options::AbstractVector{<:AbstractString}) + isempty(petsc_options) && return nothing + opts = PETSc.Options(petsclib; PETSc.parse_options(String.(petsc_options))...) + push!(opts) + PETSc.LibPETSc.TSSetFromOptions(petsclib, ts) + pop!(opts) + PETSc.destroy(opts) + return nothing +end +_setfromoptions!(petsclib, ts, ::Nothing) = nothing + +function _sync_petsc_to_julia!(integ::PETScTSIntegrator) + PETSc.withlocalarray!(integ.u_petsc; read = true, write = false) do arr + copyto!(integ.u, reshape(arr, integ.sizeu)) + end + return nothing +end + +function _sync_julia_to_petsc!(integ::PETScTSIntegrator) + PETSc.withlocalarray!(integ.u_petsc; read = false, write = true) do arr + copyto!(arr, vec(integ.u)) + end + return nothing +end diff --git a/ext/sciml/ifunction_callback.jl b/ext/sciml/ifunction_callback.jl new file mode 100644 index 000000000..5d174b8b9 --- /dev/null +++ b/ext/sciml/ifunction_callback.jl @@ -0,0 +1,51 @@ +mutable struct IFunctionCtx{F, P, SZ, Lib} + f::F + p::P + sizeu::SZ + petsclib::Lib +end + +function _petsc_ifunction!( + ::PETSc.LibPETSc.CTS, + t::Float64, + u_ptr::PETSc.LibPETSc.CVec, + udot_ptr::PETSc.LibPETSc.CVec, + F_ptr::PETSc.LibPETSc.CVec, + ctx_ptr::Ptr{Cvoid}, +)::PETSc.LibPETSc.PetscErrorCode + ctx = unsafe_pointer_to_objref(ctx_ptr)::IFunctionCtx + petsclib = ctx.petsclib + u = PETSc.VecPtr(petsclib, u_ptr, false) + udot = PETSc.VecPtr(petsclib, udot_ptr, false) + Fv = PETSc.VecPtr(petsclib, F_ptr, false) + PETSc.withlocalarray!( + (u, udot, Fv); + read = (true, true, false), + write = (false, false, true), + ) do u_array, udot_array, F_array + F_reshaped = reshape(F_array, ctx.sizeu) + u_reshaped = reshape(u_array, ctx.sizeu) + udot_reshaped = reshape(udot_array, ctx.sizeu) + ctx.f(F_reshaped, u_reshaped, ctx.p, t) + @. F_reshaped = udot_reshaped - F_reshaped + end + return PETSc.LibPETSc.PetscErrorCode(0) +end + +const _PETSC_IFUNCTION_PTR = Ref{Ptr{Cvoid}}(C_NULL) + +function _petsc_ifunction_ptr() + _PETSC_IFUNCTION_PTR[] == C_NULL && (_PETSC_IFUNCTION_PTR[] = @cfunction( + _petsc_ifunction!, + PETSc.LibPETSc.PetscErrorCode, + ( + PETSc.LibPETSc.CTS, + Float64, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + Ptr{Cvoid}, + ), + )) + return _PETSC_IFUNCTION_PTR[] +end diff --git a/ext/sciml/integrator.jl b/ext/sciml/integrator.jl new file mode 100644 index 000000000..159e1d857 --- /dev/null +++ b/ext/sciml/integrator.jl @@ -0,0 +1,34 @@ +mutable struct PETScTSIntegrator{ + algType <: PETScTSAlgorithm, + uType, + tType, + pType, + solType, + optType, + cbCacheType, + libType, + tsType, + vecType, + cbCtxType, +} <: SciMLBase.AbstractODEIntegrator{algType, true, uType, tType} + alg::algType + u::uType + uprev::uType + t::tType + tprev::tType + dt::tType + p::pType + opts::optType + u_modified::Bool + tdir::tType + sizeu::Tuple + sol::solType + callback_cache::cbCacheType + petsclib::libType + ts::tsType + u_petsc::vecType + cb_ctx::cbCtxType + initialized::Bool + done::Bool + retcode::SciMLBase.ReturnCode.T +end diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl new file mode 100644 index 000000000..5771ff456 --- /dev/null +++ b/ext/sciml/interface.jl @@ -0,0 +1,67 @@ +DiffEqBase.u_modified!(i::PETScTSIntegrator, val::Bool) = (i.u_modified = val) + +@static if isdefined(SciMLBase, :derivative_discontinuity!) + SciMLBase.derivative_discontinuity!(i::PETScTSIntegrator, val::Bool) = + (i.u_modified = val) +end + +function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) + integ.opts.save_on || return (false, false) + saved = false + + if integ.opts.save_everystep || force + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + saved = true + end + + # `saveat` stores tdir * t in a forward BinaryMinHeap, so first(...) is + # always the next requested time in the integration direction. + while !isempty(integ.opts.saveat) && + first(integ.opts.saveat) <= integ.tdir * integ.t + t_save = pop!(integ.opts.saveat) / integ.tdir + u_interp = similar(integ.u) + v_interp = PETSc.VecSeq(integ.petsclib, length(integ.u)) + try + PETSc.LibPETSc.TSInterpolate( + integ.petsclib, integ.ts, + integ.petsclib.PetscReal(t_save), v_interp, + ) + PETSc.withlocalarray!(v_interp; read = true, write = false) do arr + copyto!(u_interp, reshape(arr, integ.sizeu)) + end + finally + PETSc.destroy(v_interp) + end + push!(integ.sol.t, t_save) + push!(integ.sol.u, u_interp) + saved = true + end + + return (saved, !isempty(integ.sol.t) && last(integ.sol.t) == integ.t) +end + +function DiffEqBase.terminate!( + i::PETScTSIntegrator, + retcode = SciMLBase.ReturnCode.Terminated, +) + i.retcode = retcode + i.done = true + while !isempty(i.opts.tstops) + pop!(i.opts.tstops) + end + return nothing +end + +function handle_callbacks!(integ::PETScTSIntegrator) + cbs = integ.opts.callback + discrete = cbs.discrete_callbacks + saved_in_cb = false + if !isempty(discrete) + modified, saved_in_cb = + DiffEqBase.apply_discrete_callback!(integ, discrete...) + modified && (integ.u_modified = true) + end + saved_in_cb || DiffEqBase.savevalues!(integ) + return nothing +end diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl new file mode 100644 index 000000000..9bec37e9b --- /dev/null +++ b/ext/sciml/options.jl @@ -0,0 +1,62 @@ +mutable struct DEOptions{SavT, TstopsT, CType, reltolType, abstolType} + saveat::SavT + tstops::TstopsT + save_everystep::Bool + save_on::Bool + save_start::Bool + save_end::Bool + callback::CType + reltol::reltolType + abstol::abstolType + maxiters::Int + verbose::Bool +end + +_as_time_iter(::Nothing, ::Type) = () +_as_time_iter(x::Number, ::Type{T}) where {T} = (T(x),) +_as_time_iter(x, ::Type{T}) where {T} = (T(t) for t in x) + +function _build_opts( + ::Type{tType}, + saveat, + tstops, + tdir, + tspan; + save_everystep::Bool, + save_on::Bool, + save_start::Bool, + save_end::Bool, + callback, + reltol, + abstol, + maxiters::Integer, + verbose::Bool, +) where {tType} + t0 = tdir * tType(tspan[1]) + tf = tdir * tType(tspan[2]) + + saveat_data = tType[ + tdir * t for t in _as_time_iter(saveat, tType) if t0 < tdir * t <= tf + ] + tstops_data = tType[ + tdir * t for t in _as_time_iter(tstops, tType) if t0 < tdir * t <= tf + ] + push!(tstops_data, tf) + + saveat_heap = BinaryMinHeap(saveat_data) + tstops_heap = BinaryMinHeap(tstops_data) + + return DEOptions( + saveat_heap, + tstops_heap, + save_everystep, + save_on, + save_start, + save_end, + callback, + reltol, + abstol, + Int(maxiters), + verbose, + ) +end diff --git a/ext/sciml/retcode.jl b/ext/sciml/retcode.jl new file mode 100644 index 000000000..c0acac02c --- /dev/null +++ b/ext/sciml/retcode.jl @@ -0,0 +1,40 @@ +@generated function _ts_converged_reason( + petsclib::PETSc.LibPETSc.PetscLibType{ST, IT, LT}, + ts, +) where {ST, IT, LT} + libsym = PETSc.LibPETSc.petsclibs[findfirst( + l -> typeof(l) == PETSc.LibPETSc.PetscLibType{ST, IT, LT}, + PETSc.LibPETSc.petsclibs, + )].petsc_library + quote + reason_ref = Ref(PETSc.LibPETSc.TS_CONVERGED_ITERATING) + err = ccall( + (:TSGetConvergedReason, $libsym), + PETSc.LibPETSc.PetscErrorCode, + (PETSc.LibPETSc.CTS, Ptr{PETSc.LibPETSc.TSConvergedReason}), + ts, + reason_ref, + ) + iszero(err) || error("TSGetConvergedReason failed with code $err") + return reason_ref[] + end +end + +function _petsc_retcode(petsclib, ts) + reason = _ts_converged_reason(petsclib, ts) + if reason == PETSc.LibPETSc.TS_CONVERGED_TIME || + reason == PETSc.LibPETSc.TS_CONVERGED_USER || + reason == PETSc.LibPETSc.TS_CONVERGED_EVENT + return SciMLBase.ReturnCode.Success + elseif reason == PETSc.LibPETSc.TS_CONVERGED_ITS + return SciMLBase.ReturnCode.MaxIters + elseif reason == PETSc.LibPETSc.TS_CONVERGED_ITERATING + return SciMLBase.ReturnCode.Failure + elseif reason == PETSc.LibPETSc.TS_DIVERGED_NONLINEAR_SOLVE + return SciMLBase.ReturnCode.Failure + elseif reason == PETSc.LibPETSc.TS_DIVERGED_STEP_REJECTED + return SciMLBase.ReturnCode.Unstable + else + return SciMLBase.ReturnCode.Failure + end +end diff --git a/ext/sciml/rhs_callback.jl b/ext/sciml/rhs_callback.jl new file mode 100644 index 000000000..cc16c0894 --- /dev/null +++ b/ext/sciml/rhs_callback.jl @@ -0,0 +1,44 @@ +mutable struct RHSCtx{F, P, SZ, Lib} + f::F + p::P + sizeu::SZ + petsclib::Lib +end + +function _petsc_rhs!( + ::PETSc.LibPETSc.CTS, + t::Float64, + u_ptr::PETSc.LibPETSc.CVec, + f_ptr::PETSc.LibPETSc.CVec, + ctx_ptr::Ptr{Cvoid}, +)::PETSc.LibPETSc.PetscErrorCode + ctx = unsafe_pointer_to_objref(ctx_ptr)::RHSCtx + petsclib = ctx.petsclib + u = PETSc.VecPtr(petsclib, u_ptr, false) + fv = PETSc.VecPtr(petsclib, f_ptr, false) + PETSc.withlocalarray!( + (u, fv); + read = (true, false), + write = (false, true), + ) do u_array, f_array + ctx.f(reshape(f_array, ctx.sizeu), reshape(u_array, ctx.sizeu), ctx.p, t) + end + return PETSc.LibPETSc.PetscErrorCode(0) +end + +const _PETSC_RHS_PTR = Ref{Ptr{Cvoid}}(C_NULL) + +function _petsc_rhs_ptr() + _PETSC_RHS_PTR[] == C_NULL && (_PETSC_RHS_PTR[] = @cfunction( + _petsc_rhs!, + PETSc.LibPETSc.PetscErrorCode, + ( + PETSc.LibPETSc.CTS, + Float64, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + Ptr{Cvoid}, + ), + )) + return _PETSC_RHS_PTR[] +end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl new file mode 100644 index 000000000..94dceef27 --- /dev/null +++ b/ext/sciml/solve.jl @@ -0,0 +1,304 @@ +function _destroy_petsc!(integ::PETScTSIntegrator) + if integ.ts.ptr != C_NULL + PETSc.LibPETSc.TSDestroy(integ.petsclib, integ.ts) + end + if integ.u_petsc.ptr != C_NULL + PETSc.destroy(integ.u_petsc) + end + return nothing +end + +# Explicit, deterministic cleanup. Idempotent — repeated calls are no-ops. +PETSc.destroy(integ::PETScTSIntegrator) = _destroy_petsc!(integ) + +# Builds the bare PETSc TS skeleton shared by every algorithm: pick the +# library, allocate the solution vector, set time bounds and a maybe-supplied +# initial step. Algorithm-specific TS type, subtype, and callback registration +# happen in `_register_algorithm_callbacks!` afterwards. +function _common_ts_setup(prob, alg, dt, maxiters, petsclib) + _check_isinplace(prob) + lib = _pick_petsclib(prob, petsclib) + _check_petscreal(lib) + PETSc.initialized(lib) || PETSc.initialize(lib) + + u0 = copy(prob.u0) + tType = typeof(one(eltype(prob.tspan))) + t0 = tType(prob.tspan[1]) + tf = tType(prob.tspan[2]) + tdir = tType(sign(tf - t0)) + + ts = PETSc.LibPETSc.TSCreate(lib, PETSc.LibPETSc.PETSC_COMM_SELF) + u_v = PETSc.VecSeq(lib, length(u0)) + PETSc.withlocalarray!(u_v; read = false, write = true) do arr + copyto!(arr, vec(u0)) + end + PETSc.LibPETSc.TSSetSolution(lib, ts, u_v) + PETSc.LibPETSc.TSSetTime(lib, ts, lib.PetscReal(t0)) + PETSc.LibPETSc.TSSetMaxTime(lib, ts, lib.PetscReal(tf)) + PETSc.LibPETSc.TSSetMaxSteps(lib, ts, lib.PetscInt(maxiters)) + PETSc.LibPETSc.TSSetExactFinalTime( + lib, ts, PETSc.LibPETSc.TS_EXACTFINALTIME_MATCHSTEP, + ) + if dt !== nothing + PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) + end + + return (lib, ts, u_v, u0, tType, t0, tdir) +end + +function _make_integrator( + alg, u0, tType, t0, tdir, dt, prob, + opts, sol, lib, ts, u_v, cb_ctx, +) + integ = PETScTSIntegrator( + alg, + u0, + copy(u0), + t0, + t0, + tType(something(dt, zero(tType))), + prob.p, + opts, + false, + tdir, + size(u0), + sol, + nothing, + lib, + ts, + u_v, + cb_ctx, + false, + false, + SciMLBase.ReturnCode.Default, + ) + finalizer(_destroy_petsc!, integ) + return integ +end + +function _register_rhs!(lib, ts, prob, u0) + cb_ctx = RHSCtx(prob.f.f, prob.p, size(u0), lib) + PETSc.LibPETSc.TSSetRHSFunction( + lib, ts, nothing, _petsc_rhs_ptr(), pointer_from_objref(cb_ctx), + ) + return cb_ctx +end + +function _register_ifunction!(lib, ts, prob, u0) + cb_ctx = IFunctionCtx(prob.f.f, prob.p, size(u0), lib) + PETSc.LibPETSc.TSSetIFunction( + lib, ts, nothing, _petsc_ifunction_ptr(), pointer_from_objref(cb_ctx), + ) + return cb_ctx +end + +# Per-algorithm hooks. Each returns the callback context object that needs to +# stay live for the lifetime of the integrator. +function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSRK) + PETSc.LibPETSc.TSSetType(lib, ts, "rk") + PETSc.LibPETSc.TSRKSetType(lib, ts, alg.subtype) + return _register_rhs!(lib, ts, prob, u0) +end + +function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSRosW) + PETSc.LibPETSc.TSSetType(lib, ts, "rosw") + PETSc.LibPETSc.TSRosWSetType(lib, ts, alg.subtype) + PETSc.LibPETSc.TSSetProblemType(lib, ts, PETSc.LibPETSc.TS_NONLINEAR) + return _register_ifunction!(lib, ts, prob, u0) +end + +const _IMPLICIT_SUBTYPES = ("beuler", "cn", "theta", "bdf") + +function _check_implicit_subtype(s) + s in _IMPLICIT_SUBTYPES || throw(ArgumentError( + "TSImplicit subtype must be one of " * + join(_IMPLICIT_SUBTYPES, ", ") * "; got $(repr(s))", + )) +end + +function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSImplicit) + _check_implicit_subtype(alg.subtype) + PETSc.LibPETSc.TSSetType(lib, ts, alg.subtype) + if alg.subtype == "theta" + PETSc.LibPETSc.TSThetaSetTheta(lib, ts, lib.PetscReal(alg.theta)) + end + PETSc.LibPETSc.TSSetProblemType(lib, ts, PETSc.LibPETSc.TS_NONLINEAR) + return _register_ifunction!(lib, ts, prob, u0) +end + +# Per-side context registration for IMEX. The integrator field stores both +# contexts as a NamedTuple so they remain GC-rooted via the integrator. +function _register_rhs_with_f!(lib, ts, f, prob, u0) + cb_ctx = RHSCtx(f, prob.p, size(u0), lib) + PETSc.LibPETSc.TSSetRHSFunction( + lib, ts, nothing, _petsc_rhs_ptr(), pointer_from_objref(cb_ctx), + ) + return cb_ctx +end + +function _register_ifunction_with_f!(lib, ts, f, prob, u0) + cb_ctx = IFunctionCtx(f, prob.p, size(u0), lib) + PETSc.LibPETSc.TSSetIFunction( + lib, ts, nothing, _petsc_ifunction_ptr(), pointer_from_objref(cb_ctx), + ) + return cb_ctx +end + +function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSGeneric) + PETSc.LibPETSc.TSSetType(lib, ts, alg.ts_type) + PETSc.LibPETSc.TSSetProblemType(lib, ts, PETSc.LibPETSc.TS_NONLINEAR) + return _register_ifunction!(lib, ts, prob, u0) +end + +function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSARKIMEX) + PETSc.LibPETSc.TSSetType(lib, ts, "arkimex") + PETSc.LibPETSc.TSARKIMEXSetType(lib, ts, alg.subtype) + PETSc.LibPETSc.TSSetProblemType(lib, ts, PETSc.LibPETSc.TS_NONLINEAR) + + if prob.problem_type isa SciMLBase.SplitODEProblem + f1 = prob.f.f1 # implicit / stiff + f2 = prob.f.f2 # explicit / non-stiff + rhs_ctx = _register_rhs_with_f!(lib, ts, f2, prob, u0) + ifunc_ctx = _register_ifunction_with_f!(lib, ts, f1, prob, u0) + return (rhs = rhs_ctx, ifunc = ifunc_ctx) + else + # Fallback: no explicit part, treat full RHS as implicit. PETSc still + # advances correctly for ARKIMEX in this degenerate case. + return _register_ifunction!(lib, ts, prob, u0) + end +end + +function SciMLBase.__init( + prob::SciMLBase.AbstractODEProblem, + alg::PETScTSAlgorithm; + save_everystep::Bool = false, + save_on::Bool = true, + save_start::Bool = true, + save_end::Bool = true, + saveat = (), + tstops = (), + callback = nothing, + reltol = 1e-3, + abstol = 1e-6, + dt = nothing, + maxiters::Integer = Int(1e5), + petsclib = nothing, + verbose::Bool = false, + kwargs..., +) + (lib, ts, u_v, u0, tType, t0, tdir) = + _common_ts_setup(prob, alg, dt, maxiters, petsclib) + + cb_set = DiffEqBase.CallbackSet(callback) + if !isempty(cb_set.continuous_callbacks) + @warn "PETSc.jl SciML extension: ContinuousCallbacks are not yet " * + "supported and will be ignored. Use DiscreteCallback or wrap " * + "the event detection in PETSc's TSSetEventHandler manually." + end + if !isempty(_as_time_iter(tstops, Float64)) + @warn "PETSc.jl SciML extension: `tstops` is not yet honoured. " * + "PETSc adapts step sizes internally; pass `dt` and " * + "`-ts_adapt_type none` via `petsc_options` to force fixed steps." + end + + opts = _build_opts( + tType, saveat, tstops, tdir, prob.tspan; + save_everystep, save_on, save_start, save_end, + callback = cb_set, + reltol, abstol, maxiters, verbose, + ) + + sol = SciMLBase.build_solution( + prob, alg, tType[], typeof(u0)[]; + retcode = SciMLBase.ReturnCode.Default, + stats = SciMLBase.DEStats(0), + ) + + cb_ctx = _setup_petsc_algorithm!(lib, ts, prob, u0, alg) + _setfromoptions!(lib, ts, alg.petsc_options) + + integ = _make_integrator( + alg, u0, tType, t0, tdir, dt, prob, + opts, sol, lib, ts, u_v, cb_ctx, + ) + + DiffEqBase.initialize!(cb_set, u0, t0, integ) + + return integ +end + +function SciMLBase.__solve( + prob::SciMLBase.AbstractODEProblem, + alg::PETScTSAlgorithm, + args...; + kwargs..., +) + integ = SciMLBase.__init(prob, alg; kwargs...) + return SciMLBase.solve!(integ) +end + +function SciMLBase.step!(integ::PETScTSIntegrator) + integ.done && return nothing + + if !integ.initialized + PETSc.LibPETSc.TSSetUp(integ.petsclib, integ.ts) + integ.initialized = true + if integ.opts.save_start + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + end + end + + integ.uprev .= integ.u + integ.tprev = integ.t + + GC.@preserve integ begin + PETSc.LibPETSc.TSStep(integ.petsclib, integ.ts) + end + + _sync_petsc_to_julia!(integ) + integ.t = typeof(integ.t)(PETSc.LibPETSc.TSGetTime(integ.petsclib, integ.ts)) + integ.dt = typeof(integ.dt)(PETSc.LibPETSc.TSGetTimeStep(integ.petsclib, integ.ts)) + + handle_callbacks!(integ) + + if integ.u_modified + integ.u_modified = false + _sync_julia_to_petsc!(integ) + PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) + end + + if integ.done + # `terminate!` was triggered by a callback. Tell PETSc not to keep + # stepping past the current time and exit. + PETSc.LibPETSc.TSSetMaxTime( + integ.petsclib, integ.ts, integ.petsclib.PetscReal(integ.t), + ) + return nothing + end + + reason = _ts_converged_reason(integ.petsclib, integ.ts) + if reason != PETSc.LibPETSc.TS_CONVERGED_ITERATING + integ.retcode = _petsc_retcode(integ.petsclib, integ.ts) + integ.done = true + elseif integ.tdir * (integ.t - integ.sol.prob.tspan[2]) >= 0 + integ.retcode = SciMLBase.ReturnCode.Success + integ.done = true + end + + return nothing +end + +function SciMLBase.solve!(integ::PETScTSIntegrator) + while !integ.done + SciMLBase.step!(integ) + end + if integ.opts.save_end && + (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + end + integ.sol = SciMLBase.solution_new_retcode(integ.sol, integ.retcode) + _destroy_petsc!(integ) + return integ.sol +end diff --git a/src/PETSc.jl b/src/PETSc.jl index 97b613bb3..8e43df59e 100644 --- a/src/PETSc.jl +++ b/src/PETSc.jl @@ -40,9 +40,13 @@ include("dmda.jl") include("dmstag.jl") # String convenience wrappers for SetType functions -include("string_wrappers.jl") +include("string_wrappers.jl") include("string_wrappers_extra.jl") +# SciML extension algorithm types (methods live in ext/PETScSciMLExt.jl). +include("sciml_algorithms.jl") +export PETScTSAlgorithm, TSRK, TSRosW, TSImplicit, TSARKIMEX, TSGeneric + include("audit.jl") diff --git a/src/sciml_algorithms.jl b/src/sciml_algorithms.jl new file mode 100644 index 000000000..4c54afcc7 --- /dev/null +++ b/src/sciml_algorithms.jl @@ -0,0 +1,162 @@ +""" + PETScTSAlgorithm + +Abstract supertype for the PETSc TS time-integration algorithms exposed to +SciMLBase / OrdinaryDiffEq via the `PETScSciMLExt` extension. + +Concrete subtypes carry just enough information to configure a PETSc `TS` +object: a top-level `TSSetType` argument, an optional subtype string, and an +algorithm-local `petsc_options::Vector{String}` of raw PETSc CLI tokens (e.g. +`["-snes_fd", "-ts_max_steps", "100"]`). The actual `solve` / `init` / `step!` +methods live in the extension and become available once `SciMLBase` and +`DiffEqBase` are loaded (typically via `using OrdinaryDiffEq`). + +Subtypes do **not** inherit from `SciMLBase.AbstractODEAlgorithm`, so they can +be defined here without a hard `SciMLBase` dependency. Dispatch into +SciMLBase's `solve` / `init` happens on the concrete type, which is sufficient. +""" +abstract type PETScTSAlgorithm end + +""" + TSRK(subtype::String[, petsc_options]) + +PETSc explicit Runge-Kutta integrator (`TSSetType(ts, "rk")`, +`TSRKSetType(ts, subtype)`). Suitable for non-stiff ODEs. + +# Arguments +- `subtype`: PETSc RK subtype string, e.g. `"3bs"`, `"4"`, `"5dp"`, `"5bs"`. + The full list comes from `TSRKType` (see PETSc docs / `-ts_rk_type`). +- `petsc_options`: optional `Vector{String}` of raw PETSc CLI tokens applied + via `TSSetFromOptions` after the subtype is set. + +# Example +```julia +using PETSc, OrdinaryDiffEq +prob = ODEProblem(f!, u0, tspan) +sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) +``` + +# Common `petsc_options` +- `["-ts_adapt_type", "none"]` to disable PETSc's adaptive controller. +- `["-ts_max_steps", "1000"]` to cap the step count from PETSc's side. +- `["-ts_monitor"]` to print PETSc's per-step monitor (in addition to + `save_everystep` on the SciML side). +""" +struct TSRK <: PETScTSAlgorithm + subtype::String + petsc_options::Vector{String} +end +TSRK(subtype::String) = TSRK(subtype, String[]) + +""" + TSRosW(subtype::String[, petsc_options]) + +PETSc Rosenbrock-W (linearly implicit) integrator +(`TSSetType(ts, "rosw")`, `TSRosWSetType(ts, subtype)`). Suitable for stiff +ODEs without a user-supplied analytic Jacobian — pass `"-snes_fd"` in +`petsc_options` to ask PETSc to compute Jacobians by finite differences. + +# Arguments +- `subtype`: PETSc Rosenbrock-W subtype string, e.g. `"ra34pw2"`, `"rodas3"`, + `"2m"`. See PETSc docs / `-ts_rosw_type` for the full list. +- `petsc_options`: optional `Vector{String}` of raw PETSc CLI tokens. For + most stiff problems you will want at least `"-snes_fd"`. + +# Example +```julia +sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) +``` +""" +struct TSRosW <: PETScTSAlgorithm + subtype::String + petsc_options::Vector{String} +end +TSRosW(subtype::String) = TSRosW(subtype, String[]) + +""" + TSImplicit(subtype::String[, theta::Real][, petsc_options]) + +PETSc fully-implicit time integrator. The `subtype` selects the PETSc TS +type via `TSSetType(ts, subtype)`. Supported values: + +| `subtype` | PETSc method | Notes | +|:-----------|:----------------------------|:--------------------------------------------------------| +| `"beuler"` | Backward Euler | Order 1, L-stable | +| `"cn"` | Crank-Nicolson | Order 2, A-stable; PETSc's endpoint-stage variant | +| `"theta"` | Theta method | `theta` selects between BE (`1.0`) and CN (`0.5`) | +| `"bdf"` | Backward differentiation | Set order via `petsc_options = ["-ts_bdf_order", "3"]` | + +`theta` defaults to `0.5` and is consulted only when `subtype == "theta"`. + +Pass `"-snes_fd"` in `petsc_options` to let PETSc compute Jacobians via +finite differences when no user Jacobian is provided. + +# Examples +```julia +solve(prob, PETSc.TSImplicit("beuler", ["-snes_fd"]); dt = 0.01) +solve(prob, PETSc.TSImplicit("theta", 0.5, ["-snes_fd"]); dt = 0.05) +solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd", "-ts_bdf_order", "3"]); dt = 1e-3) +``` +""" +struct TSImplicit <: PETScTSAlgorithm + subtype::String + theta::Float64 + petsc_options::Vector{String} +end +TSImplicit(subtype::String) = TSImplicit(subtype, 0.5, String[]) +TSImplicit(subtype::String, theta::Real) = TSImplicit(subtype, Float64(theta), String[]) +TSImplicit(subtype::String, petsc_options::AbstractVector) = + TSImplicit(subtype, 0.5, String.(petsc_options)) +TSImplicit(subtype::String, theta::Real, petsc_options::AbstractVector) = + TSImplicit(subtype, Float64(theta), String.(petsc_options)) + +""" + TSARKIMEX(subtype::String[, petsc_options]) + +PETSc Additive Runge-Kutta IMEX integrator (`TSSetType(ts, "arkimex")`, +`TSARKIMEXSetType(ts, subtype)`). Designed to integrate `SplitODEProblem`s +of the form `u' = f1(u,p,t) + f2(u,p,t)` where `f1` is the stiff/implicit +part and `f2` is the non-stiff/explicit part. + +When a non-`SplitODEProblem` is passed, the full RHS is treated as the +implicit part and the explicit RHS is left at zero. + +# Arguments +- `subtype`: PETSc ARKIMEX subtype string, e.g. `"2e"`, `"3"`, `"4"`, + `"5"`. See PETSc docs / `-ts_arkimex_type` for the full list. +- `petsc_options`: optional `Vector{String}`. Typically include `"-snes_fd"`. + +# Example +```julia +prob = SplitODEProblem(f1!, f2!, u0, tspan) # f1 stiff/implicit, f2 explicit +sol = solve(prob, PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) +``` +""" +struct TSARKIMEX <: PETScTSAlgorithm + subtype::String + petsc_options::Vector{String} +end +TSARKIMEX(subtype::String) = TSARKIMEX(subtype, String[]) + +""" + TSGeneric(ts_type::String[, petsc_options]) + +Pass-through algorithm that calls `TSSetType(ts, ts_type)` directly without +any subtype-specific configuration. Useful for PETSc TS types that do not +have a dedicated wrapper here yet (e.g. `"euler"`, `"ssp"`, `"alpha"`, +`"glle"`, `"glee"`). + +`TSGeneric` registers an IFunction (residual `udot - f(u,p,t)`), so it +expects the resulting PETSc TS to be a fully-implicit method. Use +`TSRK`/`TSRosW`/`TSImplicit`/`TSARKIMEX` for the standard families. + +# Example +```julia +solve(prob, PETSc.TSGeneric("alpha", ["-snes_fd"]); dt = 0.01) +``` +""" +struct TSGeneric <: PETScTSAlgorithm + ts_type::String + petsc_options::Vector{String} +end +TSGeneric(ts_type::String) = TSGeneric(ts_type, String[]) diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl new file mode 100644 index 000000000..5cc08415a --- /dev/null +++ b/test/sciml/test_callbacks.jl @@ -0,0 +1,89 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK + +# u' = -u, exact: exp(-t) +function decay!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +@testset "Step 7 — Discrete callbacks and terminate!" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + + @testset "DiscreteCallback + terminate! stops the integration early" begin + condition = (u, t, integ) -> t >= 0.5 + affect! = integ -> terminate!(integ) + cb = DiscreteCallback(condition, affect!) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Terminated + @test sol.t[end] < tspan[2] + @test sol.t[end] >= 0.5 + end + + @testset "DiscreteCallback that modifies state changes the final solution" begin + # Without any callback, u(1) = exp(-1). + sol_ref = solve(prob, TSRK("3bs"); dt = 0.1) + + # Callback fires once at t >= 0.5 and resets u to 0. After firing it + # raises a flag in the closure so it does not fire again. + fired = Ref(false) + condition = (u, t, integ) -> !fired[] && t >= 0.5 + function affect!(integ) + integ.u[1] = 0.0 + fired[] = true + DiffEqBase.u_modified!(integ, true) + end + cb = DiscreteCallback(condition, affect!) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + # After the reset, u continues from ~0; final value should be near 0. + @test sol.u[end][1] < sol_ref.u[end][1] / 10 + end + + @testset "CallbackSet with two discrete callbacks" begin + seen_a = Ref(false) + seen_b = Ref(false) + cb_a = DiscreteCallback( + (u, t, integ) -> t >= 0.3, + integ -> (seen_a[] = true; nothing), + ) + cb_b = DiscreteCallback( + (u, t, integ) -> t >= 0.7, + integ -> (seen_b[] = true; nothing), + ) + cbs = CallbackSet(cb_a, cb_b) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cbs) + @test sol.retcode == ReturnCode.Success + @test seen_a[] + @test seen_b[] + end + + @testset "ContinuousCallback emits a warning and does not error" begin + cc = ContinuousCallback( + (u, t, integ) -> u[1] - 0.5, + integ -> nothing, + ) + sol = @test_logs (:warn, r"ContinuousCallback") solve( + prob, TSRK("3bs"); dt = 0.1, callback = cc, + ) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + end + + @testset "tstops kwarg emits a warning and is ignored" begin + sol = @test_logs (:warn, r"tstops") solve( + prob, TSRK("3bs"); dt = 0.1, tstops = [0.4, 0.6], + ) + @test sol.retcode == ReturnCode.Success + end +end diff --git a/test/sciml/test_imex.jl b/test/sciml/test_imex.jl new file mode 100644 index 000000000..1c161b4bb --- /dev/null +++ b/test/sciml/test_imex.jl @@ -0,0 +1,99 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSARKIMEX = ext.TSARKIMEX +const TSImplicit = ext.TSImplicit + +# ── Linear stiff IMEX problem ──────────────────────────────────────────────── +# u' = -u (implicit) + cos(t) (explicit) +# analytical: u(t) = (1/2) * (cos(t) + sin(t)) + (u0 - 1/2) * exp(-t) +function linear_implicit!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +function linear_explicit!(du, u, p, t) + du[1] = cos(t) + return nothing +end + +linear_imex_exact(t, u0) = 0.5 * (cos(t) + sin(t)) + (u0 - 0.5) * exp(-t) + +# ── Van der Pol (mu = 100) split ───────────────────────────────────────────── +# f1 (implicit, stiff): mu * ((1 - u1^2) * u2 - u1) on the second component +# f2 (explicit, non-stiff): u2 on the first component +function vdp_implicit!(du, u, p, t) + mu = p[1] + du[1] = 0.0 + du[2] = mu * ((1 - u[1]^2) * u[2] - u[1]) + return nothing +end + +function vdp_explicit!(du, u, p, t) + du[1] = u[2] + du[2] = 0.0 + return nothing +end + +@testset "Step 5 — TSARKIMEX with SplitODEProblem" begin + @testset "Linear IMEX with TSARKIMEX(\"2e\") matches analytical" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = SplitODEProblem(linear_implicit!, linear_explicit!, u0, tspan) + sol = solve(prob, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test sol.u[end][1] ≈ linear_imex_exact(tspan[2], u0[1]) atol = 1e-3 + end + + @testset "Linear IMEX with TSARKIMEX(\"3\") matches analytical" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = SplitODEProblem(linear_implicit!, linear_explicit!, u0, tspan) + sol = solve(prob, TSARKIMEX("3", ["-snes_fd"]); dt = 0.05) + @test sol.retcode == ReturnCode.Success + @test sol.u[end][1] ≈ linear_imex_exact(tspan[2], u0[1]) atol = 1e-3 + end + + @testset "Van der Pol mu=100 split with TSARKIMEX reaches tspan[2]" begin + u0 = [2.0, 0.0] + tspan = (0.0, 1.0) + prob = SplitODEProblem(vdp_implicit!, vdp_explicit!, u0, tspan, [100.0]) + sol = solve(prob, TSARKIMEX("3", ["-snes_fd"]); dt = 1e-2) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test all(isfinite, sol.u[end]) + end + + @testset "Degenerate split (f2 ≡ 0) reduces to implicit-only" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + f1!(du, u, p, t) = (du[1] = cos(t); du[2] = sin(u[2]); nothing) + f2!(du, u, p, t) = (du .= 0.0; nothing) + prob_split = SplitODEProblem(f1!, f2!, u0, tspan) + sol_split = solve(prob_split, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) + @test sol_split.retcode == ReturnCode.Success + # The split with zero explicit part should match a non-split solve of + # the same total RHS treated as implicit. + prob_full = ODEProblem(f1!, u0, tspan) + sol_full = solve(prob_full, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) + @test sol_full.retcode == ReturnCode.Success + @test sol_split.u[end] ≈ sol_full.u[end] atol = 1e-8 + end + + @testset "init / step! / solve! parity for TSARKIMEX" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = SplitODEProblem(linear_implicit!, linear_explicit!, u0, tspan) + sol_oneshot = solve(prob, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) + integ = init(prob, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) + sol_steps = solve!(integ) + @test sol_steps.retcode == ReturnCode.Success + @test sol_steps.u[end] ≈ sol_oneshot.u[end] atol = 1e-12 + end +end diff --git a/test/sciml/test_implicit.jl b/test/sciml/test_implicit.jl new file mode 100644 index 000000000..07240c489 --- /dev/null +++ b/test/sciml/test_implicit.jl @@ -0,0 +1,96 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSImplicit = ext.TSImplicit + +# ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── +function ex51_rhs!(du, u, p, t) + du[1] = cos(t) + du[2] = sin(u[2]) + return nothing +end + +ex51_exact(t) = [sin(t), 2 * atan(exp(t) * tan(0.5))] + +# ── Van der Pol oscillator (stiff for large mu) ─────────────────────────────── +function vdp!(du, u, p, t) + mu = p[1] + du[1] = u[2] + du[2] = mu * ((1 - u[1]^2) * u[2] - u[1]) + return nothing +end + +@testset "Step 4 — TSImplicit fully implicit methods" begin + @testset "TSImplicit(\"beuler\") on ex51" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSImplicit("beuler", ["-snes_fd"]); dt = 0.01) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 5e-2 + end + + @testset "TSImplicit(\"cn\") on ex51 (2nd-order)" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSImplicit("cn", ["-snes_fd"]); dt = 0.05) + @test sol.retcode == ReturnCode.Success + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-2 + end + + @testset "TSImplicit(\"theta\", 0.5) reaches second-order accuracy" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSImplicit("theta", 0.5, ["-snes_fd"]); dt = 0.05) + @test sol.retcode == ReturnCode.Success + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-2 + end + + @testset "TSImplicit(\"theta\", 1.0) matches TSImplicit(\"beuler\")" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol_theta = solve(prob, TSImplicit("theta", 1.0, ["-snes_fd"]); dt = 0.01) + sol_beuler = solve(prob, TSImplicit("beuler", ["-snes_fd"]); dt = 0.01) + @test sol_theta.retcode == ReturnCode.Success + @test sol_theta.u[end] ≈ sol_beuler.u[end] atol = 1e-8 + end + + @testset "TSImplicit(\"bdf\") on stiff Van der Pol (mu = 1000)" begin + u0 = [2.0, 0.0] + tspan = (0.0, 1.0) + prob = ODEProblem(vdp!, u0, tspan, [1000.0]) + sol = solve(prob, TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test all(isfinite, sol.u[end]) + end + + @testset "Unknown TSImplicit subtype raises ArgumentError" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + @test_throws ArgumentError solve( + prob, TSImplicit("does-not-exist", ["-snes_fd"]); dt = 0.1, + ) + end + + @testset "init / step! / solve! parity for TSImplicit" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol_oneshot = solve(prob, TSImplicit("cn", ["-snes_fd"]); dt = 0.05) + integ = init(prob, TSImplicit("cn", ["-snes_fd"]); dt = 0.05) + sol_steps = solve!(integ) + @test sol_steps.retcode == ReturnCode.Success + @test sol_steps.u[end] ≈ sol_oneshot.u[end] atol = 1e-12 + end +end diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl new file mode 100644 index 000000000..f758ea575 --- /dev/null +++ b/test/sciml/test_integrator.jl @@ -0,0 +1,118 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK + +function decay!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +@testset "Step 8 — Integrator interface lifecycle" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + + @testset "__init + solve! equals __solve" begin + sol_oneshot = solve(prob, TSRK("3bs"); dt = 0.1) + + integ = init(prob, TSRK("3bs"); dt = 0.1) + sol_steps = solve!(integ) + + @test sol_steps.retcode == sol_oneshot.retcode + @test sol_steps.t == sol_oneshot.t + @test sol_steps.u == sol_oneshot.u + end + + @testset "manual step! loop matches solve!" begin + integ_a = init(prob, TSRK("3bs"); dt = 0.1) + integ_b = init(prob, TSRK("3bs"); dt = 0.1) + + sol_a = solve!(integ_a) + + while !integ_b.done + step!(integ_b) + end + # mimic solve!'s save_end + retcode finalisation + if integ_b.opts.save_end && + (isempty(integ_b.sol.t) || last(integ_b.sol.t) != integ_b.t) + push!(integ_b.sol.t, integ_b.t) + push!(integ_b.sol.u, copy(integ_b.u)) + end + + @test integ_b.retcode == ReturnCode.Success + @test integ_b.t ≈ integ_a.t + @test integ_b.u ≈ integ_a.u atol = 1e-12 + PETSc.destroy(integ_b) + end + + @testset "manual step! loop with early break leaves a valid partial trajectory" begin + integ = init(prob, TSRK("3bs"); dt = 0.1, save_everystep = true) + for _ in 1:3 + integ.done && break + step!(integ) + end + @test integ.t > 0.0 + @test integ.t < tspan[2] + @test length(integ.sol.t) >= 3 + @test issorted(integ.sol.t) + PETSc.destroy(integ) + end + + @testset "step! after done is a no-op" begin + integ = init(prob, TSRK("3bs"); dt = 0.1) + sol = solve!(integ) + @test integ.done + # solve! has already destroyed the PETSc resources; step! must + # short-circuit on `done` before touching them. + @test step!(integ) === nothing + @test integ.t == sol.t[end] + end + + @testset "two independent integrators coexist" begin + prob2 = ODEProblem(decay!, [2.0], tspan) + integ_a = init(prob, TSRK("3bs"); dt = 0.1) + integ_b = init(prob2, TSRK("5dp"); dt = 0.1) + sol_a = solve!(integ_a) + sol_b = solve!(integ_b) + @test sol_a.retcode == ReturnCode.Success + @test sol_b.retcode == ReturnCode.Success + @test sol_a.u[end][1] ≈ exp(-1.0) atol = 1e-3 + @test sol_b.u[end][1] ≈ 2 * exp(-1.0) atol = 1e-3 + end + + @testset "PETSc.destroy(integ) is idempotent and safe after solve!" begin + integ = init(prob, TSRK("3bs"); dt = 0.1) + sol = solve!(integ) + @test sol.retcode == ReturnCode.Success + # solve! already destroyed the PETSc objects; destroy() should be a no-op. + @test PETSc.destroy(integ) === nothing + @test PETSc.destroy(integ) === nothing + end + + @testset "PETSc.destroy(integ) on an unrun integrator releases memory" begin + integ = init(prob, TSRK("3bs"); dt = 0.1) + @test integ.ts.ptr != C_NULL + @test integ.u_petsc.ptr != C_NULL + PETSc.destroy(integ) + @test integ.ts.ptr == C_NULL + @test integ.u_petsc.ptr == C_NULL + end + + @testset "GC finalizer destroys PETSc resources without error" begin + let integ = init(prob, TSRK("3bs"); dt = 0.1) + # let-block scopes integ so it can be collected + @test integ.ts.ptr != C_NULL + end + # force a GC pass; the finalizer (registered in _make_integrator) should + # have run on the now-unreachable integrator without raising. + GC.gc() + GC.gc() + @test true + end +end diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl new file mode 100644 index 000000000..cb32e2c5a --- /dev/null +++ b/test/sciml/test_output.jl @@ -0,0 +1,93 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK + +# u' = -u with analytical solution exp(-t) starting from u0 = 1. +function decay!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +@testset "Step 6 — save_everystep / saveat / save_end" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + + @testset "default: only start/end states saved" begin + sol = solve(prob, TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test length(sol.t) == 2 + @test sol.t[1] ≈ 0.0 + @test sol.t[end] ≈ 1.0 + @test sol.u[end][1] ≈ exp(-1) atol = 1e-3 + end + + @testset "save_everystep = true populates the trajectory" begin + sol = solve(prob, TSRK("3bs"); dt = 0.1, save_everystep = true) + @test sol.retcode == ReturnCode.Success + @test length(sol.t) > 2 + @test issorted(sol.t) + # spot-check a couple of intermediate values against the analytical solution + for k in 1:length(sol.t) + @test sol.u[k][1] ≈ exp(-sol.t[k]) atol = 5e-3 + end + end + + @testset "saveat (Vector) hits exactly the requested times" begin + saveat = [0.25, 0.5, 0.75] + sol = solve(prob, TSRK("3bs"); dt = 0.1, saveat) + @test sol.retcode == ReturnCode.Success + # save_start (t0=0.0) + saveat (3 entries) + save_end (tf=1.0) + @test sol.t[1] ≈ 0.0 + @test sol.t[end] ≈ 1.0 + # the requested saveat times should appear, sorted + for ts in saveat + @test any(t -> isapprox(t, ts; atol = 1e-12), sol.t) + end + for k in 1:length(sol.t) + @test sol.u[k][1] ≈ exp(-sol.t[k]) atol = 5e-3 + end + end + + @testset "saveat without save_start / save_end gives just those times" begin + saveat = [0.25, 0.5, 0.75] + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, saveat, + save_start = false, save_end = false, + ) + @test sol.retcode == ReturnCode.Success + @test length(sol.t) == length(saveat) + @test all(any(t -> isapprox(t, ts; atol = 1e-12), sol.t) for ts in saveat) + end + + @testset "backward saveat heap orders times in integration direction" begin + # PETSc TS does not support negative-dt forward integration on RK + # (TSAdaptChoose rejects it). Verify directly that the saveat heap is + # built with `tdir * t` so backward solves would dispense times in + # decreasing order — the actual mechanism under `savevalues!`. + opts = ext._build_opts( + Float64, [0.25, 0.5, 0.75], (), -1.0, (1.0, 0.0); + save_everystep = false, save_on = true, + save_start = true, save_end = true, callback = nothing, + reltol = 1e-3, abstol = 1e-6, maxiters = 1000, verbose = false, + ) + ordered = Float64[] + while !isempty(opts.saveat) + push!(ordered, pop!(opts.saveat) / -1.0) + end + @test ordered == [0.75, 0.5, 0.25] + end + + @testset "scalar saveat" begin + sol = solve(prob, TSRK("3bs"); dt = 0.1, saveat = 0.5) + @test sol.retcode == ReturnCode.Success + @test any(t -> isapprox(t, 0.5; atol = 1e-12), sol.t) + end +end diff --git a/test/sciml/test_polish.jl b/test/sciml/test_polish.jl new file mode 100644 index 000000000..a1c014742 --- /dev/null +++ b/test/sciml/test_polish.jl @@ -0,0 +1,67 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing + +function decay!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +@testset "Step 9 — Polish" begin + @testset "Algorithm types are exported from PETSc itself" begin + # Top-level access: users should be able to write `PETSc.TSRK(...)` + # without going through `Base.get_extension`. + @test PETSc.TSRK isa Type + @test PETSc.TSRosW isa Type + @test PETSc.TSImplicit isa Type + @test PETSc.TSARKIMEX isa Type + @test PETSc.TSGeneric isa Type + @test PETSc.PETScTSAlgorithm isa Type + # And every concrete type subtypes the abstract one. + for T in (PETSc.TSRK, PETSc.TSRosW, PETSc.TSImplicit, PETSc.TSARKIMEX, PETSc.TSGeneric) + @test T <: PETSc.PETScTSAlgorithm + end + end + + @testset "Algorithm docstrings exist and mention the PETSc TS type" begin + # These are user-facing public-API entry points; the docstrings should + # mention the underlying PETSc TS type so users know what they get. + @test occursin("rk", lowercase(string(@doc PETSc.TSRK))) + @test occursin("rosw", lowercase(string(@doc PETSc.TSRosW))) + @test occursin("beuler", lowercase(string(@doc PETSc.TSImplicit))) + @test occursin("arkimex", lowercase(string(@doc PETSc.TSARKIMEX))) + @test occursin("tssettype", lowercase(string(@doc PETSc.TSGeneric))) + end + + @testset "TSGeneric pass-through with implicit \"beuler\"" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + sol = solve(prob, PETSc.TSGeneric("beuler", ["-snes_fd"]); dt = 0.01) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test sol.u[end][1] ≈ exp(-1.0) atol = 5e-2 + end + + @testset "Float32 problem raises a clear ArgumentError" begin + u0 = Float32[1.0] + tspan = (0f0, 1f0) + prob = ODEProblem(decay!, u0, tspan) + @test_throws ArgumentError solve(prob, PETSc.TSRK("3bs"); dt = 0.1f0) + # Exception text should call out the constraint so the user knows what + # to pass. + err = try + solve(prob, PETSc.TSRK("3bs"); dt = 0.1f0) + nothing + catch e + e + end + @test occursin("PetscReal", err.msg) + @test occursin("Float64", err.msg) + end +end diff --git a/test/sciml/test_rk.jl b/test/sciml/test_rk.jl new file mode 100644 index 000000000..00e08ad71 --- /dev/null +++ b/test/sciml/test_rk.jl @@ -0,0 +1,100 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK + +# ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── +# u1' = cos(t), u2' = sin(u2) +# Analytical solution: +# u1(t) = sin(t), u2(t) = 2 atan(exp(t) tan(0.5)) +function ex51_rhs!(du, u, p, t) + du[1] = cos(t) + du[2] = sin(u[2]) + return nothing +end + +ex51_exact(t) = [sin(t), 2 * atan(exp(t) * tan(0.5))] + +@testset "Step 2 — TSRK explicit Runge-Kutta" begin + @testset "ex51 with TSRK(\"3bs\") matches analytical solution" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test length(sol.t) >= 2 + @test sol.t[end] ≈ tspan[2] + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-3 + end + + @testset "ex51 with TSRK(\"5dp\") matches with tighter tolerance" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSRK("5dp"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-3 + end + + @testset "petsc_options on the algorithm overrides the subtype" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol_direct = solve(prob, TSRK("5dp"); dt = 0.1) + sol_via_opts = solve( + prob, + TSRK("3bs", ["-ts_rk_type", "5dp"]); + dt = 0.1, + ) + @test sol_via_opts.retcode == ReturnCode.Success + @test sol_via_opts.u[end] ≈ sol_direct.u[end] atol = 1e-10 + end + + @testset "scalar exponential decay u' = -u" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem((du, u, p, t) -> (du[1] = -u[1]), u0, tspan) + sol = solve(prob, TSRK("3bs"); dt = 0.05) + @test sol.retcode == ReturnCode.Success + @test sol.u[end][1] ≈ exp(-1.0) atol = 1e-3 + end + + @testset "out-of-place ODEProblem is rejected with informative error" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem((u, p, t) -> -u, u0, tspan) + @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = 0.1) + end + + @testset "init / step! / solve! produce the same result as solve" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol_oneshot = solve(prob, TSRK("5dp"); dt = 0.1) + + integ = init(prob, TSRK("5dp"); dt = 0.1) + sol_steps = solve!(integ) + @test sol_steps.retcode == ReturnCode.Success + @test sol_steps.u[end] ≈ sol_oneshot.u[end] atol = 1e-12 + end + + @testset "manual step! loop reaches tspan[2]" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + integ = init(prob, TSRK("5dp"); dt = 0.1) + steps = 0 + while !integ.done && steps < 1000 + step!(integ) + steps += 1 + end + @test integ.retcode == ReturnCode.Success + @test integ.t ≈ tspan[2] + @test integ.u ≈ ex51_exact(tspan[2]) atol = 1e-3 + end +end diff --git a/test/sciml/test_rosenbrock.jl b/test/sciml/test_rosenbrock.jl new file mode 100644 index 000000000..5f5419023 --- /dev/null +++ b/test/sciml/test_rosenbrock.jl @@ -0,0 +1,79 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using DataStructures + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRosW = ext.TSRosW +const TSRK = ext.TSRK + +# ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── +function ex51_rhs!(du, u, p, t) + du[1] = cos(t) + du[2] = sin(u[2]) + return nothing +end + +ex51_exact(t) = [sin(t), 2 * atan(exp(t) * tan(0.5))] + +# ── Van der Pol oscillator (stiff for large mu) ─────────────────────────────── +function vdp!(du, u, p, t) + mu = p[1] + du[1] = u[2] + du[2] = mu * ((1 - u[1]^2) * u[2] - u[1]) + return nothing +end + +@testset "Step 3 — TSRosW Rosenbrock-W" begin + @testset "ex51 with TSRosW(\"ra34pw2\") matches analytical solution" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSRosW("ra34pw2", ["-snes_fd"]); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-3 + end + + @testset "ex51 with TSRosW(\"rodas3\") converges similarly" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol = solve(prob, TSRosW("rodas3", ["-snes_fd"]); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test sol.u[end] ≈ ex51_exact(tspan[2]) atol = 1e-2 + end + + @testset "Van der Pol stiff (mu = 1000) reaches tspan[2] with -snes_fd" begin + u0 = [2.0, 0.0] + tspan = (0.0, 1.0) + prob = ODEProblem(vdp!, u0, tspan, [1000.0]) + sol = solve(prob, TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ tspan[2] + # final state should be bounded for the limit cycle + @test all(isfinite, sol.u[end]) + @test abs(sol.u[end][1]) < 5 + end + + @testset "out-of-place ODEProblem is rejected with informative error" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem((u, p, t) -> -u, u0, tspan) + @test_throws ArgumentError solve(prob, TSRosW("ra34pw2", ["-snes_fd"]); dt = 0.1) + end + + @testset "init / step! / solve! parity with solve" begin + u0 = [0.0, 1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(ex51_rhs!, u0, tspan) + sol_oneshot = solve(prob, TSRosW("ra34pw2", ["-snes_fd"]); dt = 0.1) + + integ = init(prob, TSRosW("ra34pw2", ["-snes_fd"]); dt = 0.1) + sol_steps = solve!(integ) + @test sol_steps.retcode == ReturnCode.Success + @test sol_steps.u[end] ≈ sol_oneshot.u[end] atol = 1e-12 + end +end From 2da92835ccd391b54540cf0193da72d61e32b87d Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Sun, 26 Apr 2026 16:45:39 +0200 Subject: [PATCH 03/38] WIP --- Project.toml | 7 +- ext/PETScSciMLExt.jl | 2 +- ext/sciml/helpers.jl | 9 ++- ext/sciml/integrator.jl | 1 + ext/sciml/interface.jl | 28 ++++--- ext/sciml/options.jl | 21 ++++- ext/sciml/retcode.jl | 28 +++++++ ext/sciml/solve.jl | 134 +++++++++++++++++++++++------- src/PETSc.jl | 1 + src/sciml_algorithms.jl | 16 +++- test/runtests.jl | 1 + test/sciml/runtests.jl | 27 ++++--- test/sciml/test_callbacks.jl | 1 - test/sciml/test_imex.jl | 1 - test/sciml/test_implicit.jl | 1 - test/sciml/test_integrator.jl | 1 - test/sciml/test_output.jl | 37 ++++++++- test/sciml/test_polish.jl | 4 - test/sciml/test_review_fixes.jl | 139 ++++++++++++++++++++++++++++++++ test/sciml/test_rk.jl | 1 - test/sciml/test_rosenbrock.jl | 1 - 21 files changed, 383 insertions(+), 78 deletions(-) create mode 100644 test/sciml/test_review_fixes.jl diff --git a/Project.toml b/Project.toml index 6ec052577..cd36619b5 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ version = "0.4.9" authors = ["Boris Kaus ", "Viral B. Shah ", "Valentin Churavy ", "Erik Schnetter ", "Jeremy E. Kozdon ", "Simon Byrne "] [deps] +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -18,12 +19,11 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [weakdeps] -DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" [extensions] -PETScSciMLExt = ["SciMLBase", "DiffEqBase", "DataStructures"] +PETScSciMLExt = ["SciMLBase", "DiffEqBase"] [compat] DataStructures = "0.18" @@ -45,7 +45,6 @@ julia = "^1.10" [extras] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" -DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" @@ -57,4 +56,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [targets] -test = ["CairoMakie", "DataStructures", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] +test = ["CairoMakie", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl index 06549e763..3ccd8d6fd 100644 --- a/ext/PETScSciMLExt.jl +++ b/ext/PETScSciMLExt.jl @@ -1,9 +1,9 @@ module PETScSciMLExt using PETSc +using PETSc: BinaryMinHeap using SciMLBase using DiffEqBase -using DataStructures: BinaryMinHeap include("sciml/algorithms.jl") include("sciml/options.jl") diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index 3f4a0da82..11d595fe1 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -26,9 +26,12 @@ function _setfromoptions!(petsclib, ts, petsc_options::AbstractVector{<:Abstract isempty(petsc_options) && return nothing opts = PETSc.Options(petsclib; PETSc.parse_options(String.(petsc_options))...) push!(opts) - PETSc.LibPETSc.TSSetFromOptions(petsclib, ts) - pop!(opts) - PETSc.destroy(opts) + try + PETSc.LibPETSc.TSSetFromOptions(petsclib, ts) + finally + pop!(opts) + PETSc.destroy(opts) + end return nothing end _setfromoptions!(petsclib, ts, ::Nothing) = nothing diff --git a/ext/sciml/integrator.jl b/ext/sciml/integrator.jl index 159e1d857..7c82b881c 100644 --- a/ext/sciml/integrator.jl +++ b/ext/sciml/integrator.jl @@ -20,6 +20,7 @@ mutable struct PETScTSIntegrator{ p::pType opts::optType u_modified::Bool + derivative_discontinuity::Bool tdir::tType sizeu::Tuple sol::solType diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl index 5771ff456..371096ca2 100644 --- a/ext/sciml/interface.jl +++ b/ext/sciml/interface.jl @@ -2,24 +2,27 @@ DiffEqBase.u_modified!(i::PETScTSIntegrator, val::Bool) = (i.u_modified = val) @static if isdefined(SciMLBase, :derivative_discontinuity!) SciMLBase.derivative_discontinuity!(i::PETScTSIntegrator, val::Bool) = - (i.u_modified = val) + (i.derivative_discontinuity = val) end function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) integ.opts.save_on || return (false, false) saved = false + saved_exactly = false - if integ.opts.save_everystep || force - push!(integ.sol.t, integ.t) - push!(integ.sol.u, copy(integ.u)) - saved = true - end - - # `saveat` stores tdir * t in a forward BinaryMinHeap, so first(...) is - # always the next requested time in the integration direction. + # Drain due `saveat` times first so the trajectory stays sorted in the + # integration direction even when `save_everystep` is also enabled. + # `saveat` stores tdir*t in a forward BinaryMinHeap, so first(...) is the + # next requested time in tdir order. while !isempty(integ.opts.saveat) && first(integ.opts.saveat) <= integ.tdir * integ.t t_save = pop!(integ.opts.saveat) / integ.tdir + if t_save == integ.t + # Saveat coincides with the step endpoint: just save the current + # state below; do not interpolate or duplicate. + saved_exactly = true + continue + end u_interp = similar(integ.u) v_interp = PETSc.VecSeq(integ.petsclib, length(integ.u)) try @@ -38,6 +41,13 @@ function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) saved = true end + if (integ.opts.save_everystep || force || saved_exactly) && + (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + saved = true + end + return (saved, !isempty(integ.sol.t) && last(integ.sol.t) == integ.t) end diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index 9bec37e9b..52b32aeae 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -16,6 +16,22 @@ _as_time_iter(::Nothing, ::Type) = () _as_time_iter(x::Number, ::Type{T}) where {T} = (T(x),) _as_time_iter(x, ::Type{T}) where {T} = (T(t) for t in x) +# SciML semantics for scalar `saveat`: it is a *spacing*, not a single time. +# `saveat = 0.5` on tspan = (0.0, 2.0) saves at 0.5, 1.0, 1.5, 2.0 (subject to +# save_start / save_end handling). Expand a scalar value into the appropriate +# vector of timestamps in the integration direction. +function _expand_saveat(saveat::Number, tdir, tspan, ::Type{T}) where {T} + spacing = abs(T(saveat)) + spacing > zero(T) || return T[] + t0 = T(tspan[1]) + tf = T(tspan[2]) + return T[t0 + tdir * spacing * k + for k in 1:floor(Int, abs(tf - t0) / spacing)] +end +_expand_saveat(saveat, _tdir, _tspan, ::Type{T}) where {T} = + T[T(t) for t in saveat] +_expand_saveat(::Nothing, _tdir, _tspan, ::Type{T}) where {T} = T[] + function _build_opts( ::Type{tType}, saveat, @@ -35,9 +51,8 @@ function _build_opts( t0 = tdir * tType(tspan[1]) tf = tdir * tType(tspan[2]) - saveat_data = tType[ - tdir * t for t in _as_time_iter(saveat, tType) if t0 < tdir * t <= tf - ] + saveat_expanded = _expand_saveat(saveat, tdir, tspan, tType) + saveat_data = tType[tdir * t for t in saveat_expanded if t0 < tdir * t <= tf] tstops_data = tType[ tdir * t for t in _as_time_iter(tstops, tType) if t0 < tdir * t <= tf ] diff --git a/ext/sciml/retcode.jl b/ext/sciml/retcode.jl index c0acac02c..83c37cb31 100644 --- a/ext/sciml/retcode.jl +++ b/ext/sciml/retcode.jl @@ -20,6 +20,34 @@ end end +# `TSSetTolerances` accepts NULL vectors when only scalar tolerances are +# desired, but the autowrapped Julia signature requires a `PetscVec` for the +# vector arguments. ccall directly so we can pass `C_NULL` for the per- +# component tolerances. +@generated function _ts_set_scalar_tolerances!( + petsclib::PETSc.LibPETSc.PetscLibType{ST, IT, LT}, + ts, + abstol, + reltol, +) where {ST, IT, LT} + libsym = PETSc.LibPETSc.petsclibs[findfirst( + l -> typeof(l) == PETSc.LibPETSc.PetscLibType{ST, IT, LT}, + PETSc.LibPETSc.petsclibs, + )].petsc_library + PetscReal = ST <: Complex ? real(ST) : ST + quote + err = ccall( + (:TSSetTolerances, $libsym), + PETSc.LibPETSc.PetscErrorCode, + (PETSc.LibPETSc.CTS, $PetscReal, PETSc.LibPETSc.CVec, + $PetscReal, PETSc.LibPETSc.CVec), + ts, $PetscReal(abstol), C_NULL, $PetscReal(reltol), C_NULL, + ) + iszero(err) || error("TSSetTolerances failed with code $err") + return nothing + end +end + function _petsc_retcode(petsclib, ts) reason = _ts_converged_reason(petsclib, ts) if reason == PETSc.LibPETSc.TS_CONVERGED_TIME || diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 94dceef27..a704453f0 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -11,36 +11,71 @@ end # Explicit, deterministic cleanup. Idempotent — repeated calls are no-ops. PETSc.destroy(integ::PETScTSIntegrator) = _destroy_petsc!(integ) +# Pick the integrator's internal time type. We always promote integer `tspan`s +# to a floating-point type so PETSc-side `Float64` times do not get truncated +# back into `Int` on assignment. +_pick_tType(tspan) = float(eltype(tspan)) + +function _check_tspan(t0, tf) + isfinite(t0) && isfinite(tf) || throw(ArgumentError( + "PETSc.jl SciML extension: tspan endpoints must be finite, got ($t0, $tf).", + )) + tf == t0 && throw(ArgumentError( + "PETSc.jl SciML extension: zero-length tspan ($t0, $tf) is not supported. " * + "Build the trivial solution (the initial state) directly in user code.", + )) + tf > t0 || throw(ArgumentError( + "PETSc.jl SciML extension: backward integration (tspan = ($t0, $tf)) is " * + "not yet supported. PETSc TS adaptivity does not handle negative dt " * + "reliably; reverse the problem yourself or open an issue if you need this.", + )) + return nothing +end + # Builds the bare PETSc TS skeleton shared by every algorithm: pick the # library, allocate the solution vector, set time bounds and a maybe-supplied # initial step. Algorithm-specific TS type, subtype, and callback registration -# happen in `_register_algorithm_callbacks!` afterwards. -function _common_ts_setup(prob, alg, dt, maxiters, petsclib) +# happen in `_setup_petsc_algorithm!` afterwards. +function _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) _check_isinplace(prob) lib = _pick_petsclib(prob, petsclib) _check_petscreal(lib) PETSc.initialized(lib) || PETSc.initialize(lib) u0 = copy(prob.u0) - tType = typeof(one(eltype(prob.tspan))) + tType = _pick_tType(prob.tspan) t0 = tType(prob.tspan[1]) tf = tType(prob.tspan[2]) + _check_tspan(t0, tf) tdir = tType(sign(tf - t0)) ts = PETSc.LibPETSc.TSCreate(lib, PETSc.LibPETSc.PETSC_COMM_SELF) u_v = PETSc.VecSeq(lib, length(u0)) - PETSc.withlocalarray!(u_v; read = false, write = true) do arr - copyto!(arr, vec(u0)) - end - PETSc.LibPETSc.TSSetSolution(lib, ts, u_v) - PETSc.LibPETSc.TSSetTime(lib, ts, lib.PetscReal(t0)) - PETSc.LibPETSc.TSSetMaxTime(lib, ts, lib.PetscReal(tf)) - PETSc.LibPETSc.TSSetMaxSteps(lib, ts, lib.PetscInt(maxiters)) - PETSc.LibPETSc.TSSetExactFinalTime( - lib, ts, PETSc.LibPETSc.TS_EXACTFINALTIME_MATCHSTEP, - ) - if dt !== nothing - PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) + try + PETSc.withlocalarray!(u_v; read = false, write = true) do arr + copyto!(arr, vec(u0)) + end + PETSc.LibPETSc.TSSetSolution(lib, ts, u_v) + PETSc.LibPETSc.TSSetTime(lib, ts, lib.PetscReal(t0)) + PETSc.LibPETSc.TSSetMaxTime(lib, ts, lib.PetscReal(tf)) + PETSc.LibPETSc.TSSetMaxSteps(lib, ts, lib.PetscInt(maxiters)) + PETSc.LibPETSc.TSSetExactFinalTime( + lib, ts, PETSc.LibPETSc.TS_EXACTFINALTIME_MATCHSTEP, + ) + if dt !== nothing + PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) + end + if reltol !== nothing && abstol !== nothing + (reltol isa Real && abstol isa Real) || throw(ArgumentError( + "PETSc.jl SciML extension: only scalar `reltol` / `abstol` are " * + "supported. Got types $(typeof(reltol)) / $(typeof(abstol)).", + )) + _ts_set_scalar_tolerances!(lib, ts, abstol, reltol) + end + catch + PETSc.LibPETSc.TSDestroy(lib, ts) + PETSc.destroy(u_v) + rethrow() end return (lib, ts, u_v, u0, tType, t0, tdir) @@ -59,7 +94,8 @@ function _make_integrator( tType(something(dt, zero(tType))), prob.p, opts, - false, + false, # u_modified + false, # derivative_discontinuity tdir, size(u0), sol, @@ -162,12 +198,38 @@ function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSARKIMEX) ifunc_ctx = _register_ifunction_with_f!(lib, ts, f1, prob, u0) return (rhs = rhs_ctx, ifunc = ifunc_ctx) else - # Fallback: no explicit part, treat full RHS as implicit. PETSc still - # advances correctly for ARKIMEX in this degenerate case. return _register_ifunction!(lib, ts, prob, u0) end end +# Run callback initialization, sync any state changes back to PETSc, and honor +# initialization-time `save_positions[2]` if the callback set asks for it. +function initialize_callbacks!(integ::PETScTSIntegrator, cb_set; initialize_save = true) + DiffEqBase.initialize!(cb_set, integ.u, integ.t, integ) + if integ.u_modified + _sync_julia_to_petsc!(integ) + PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) + if initialize_save && _wants_initialize_save(cb_set) + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + end + integ.u_modified = false + end + return nothing +end + +# A CallbackSet wants an initialize-time save when any of its discrete or +# continuous callbacks has `save_positions[2] = true`. +function _wants_initialize_save(cb_set) + for cb in cb_set.discrete_callbacks + isdefined(cb, :save_positions) && cb.save_positions[2] && return true + end + for cb in cb_set.continuous_callbacks + isdefined(cb, :save_positions) && cb.save_positions[2] && return true + end + return false +end + function SciMLBase.__init( prob::SciMLBase.AbstractODEProblem, alg::PETScTSAlgorithm; @@ -178,8 +240,8 @@ function SciMLBase.__init( saveat = (), tstops = (), callback = nothing, - reltol = 1e-3, - abstol = 1e-6, + reltol = nothing, + abstol = nothing, dt = nothing, maxiters::Integer = Int(1e5), petsclib = nothing, @@ -187,7 +249,7 @@ function SciMLBase.__init( kwargs..., ) (lib, ts, u_v, u0, tType, t0, tdir) = - _common_ts_setup(prob, alg, dt, maxiters, petsclib) + _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) cb_set = DiffEqBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) @@ -214,17 +276,29 @@ function SciMLBase.__init( stats = SciMLBase.DEStats(0), ) - cb_ctx = _setup_petsc_algorithm!(lib, ts, prob, u0, alg) - _setfromoptions!(lib, ts, alg.petsc_options) + try + cb_ctx = _setup_petsc_algorithm!(lib, ts, prob, u0, alg) + _setfromoptions!(lib, ts, alg.petsc_options) - integ = _make_integrator( - alg, u0, tType, t0, tdir, dt, prob, - opts, sol, lib, ts, u_v, cb_ctx, - ) + integ = _make_integrator( + alg, u0, tType, t0, tdir, dt, prob, + opts, sol, lib, ts, u_v, cb_ctx, + ) - DiffEqBase.initialize!(cb_set, u0, t0, integ) + initialize_callbacks!(integ, cb_set) - return integ + return integ + catch + # Setup failed before _make_integrator wired up the finalizer; clean up + # the half-constructed PETSc objects so they do not leak. + if ts.ptr != C_NULL + PETSc.LibPETSc.TSDestroy(lib, ts) + end + if u_v.ptr != C_NULL + PETSc.destroy(u_v) + end + rethrow() + end end function SciMLBase.__solve( @@ -267,6 +341,7 @@ function SciMLBase.step!(integ::PETScTSIntegrator) _sync_julia_to_petsc!(integ) PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) end + integ.derivative_discontinuity = false if integ.done # `terminate!` was triggered by a callback. Tell PETSc not to keep @@ -293,6 +368,7 @@ function SciMLBase.solve!(integ::PETScTSIntegrator) while !integ.done SciMLBase.step!(integ) end + DiffEqBase.finalize!(integ.opts.callback, integ.u, integ.t, integ) if integ.opts.save_end && (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) push!(integ.sol.t, integ.t) diff --git a/src/PETSc.jl b/src/PETSc.jl index 8e43df59e..802ea7630 100644 --- a/src/PETSc.jl +++ b/src/PETSc.jl @@ -3,6 +3,7 @@ module PETSc using MPI, LinearAlgebra, SparseArrays, OffsetArrays, Preferences +using DataStructures: BinaryMinHeap MPI.Initialized() || MPI.Init() diff --git a/src/sciml_algorithms.jl b/src/sciml_algorithms.jl index 4c54afcc7..c3f99b844 100644 --- a/src/sciml_algorithms.jl +++ b/src/sciml_algorithms.jl @@ -47,6 +47,8 @@ struct TSRK <: PETScTSAlgorithm petsc_options::Vector{String} end TSRK(subtype::String) = TSRK(subtype, String[]) +TSRK(subtype::String, petsc_options) = + TSRK(subtype, String[String(s) for s in petsc_options]) """ TSRosW(subtype::String[, petsc_options]) @@ -72,6 +74,8 @@ struct TSRosW <: PETScTSAlgorithm petsc_options::Vector{String} end TSRosW(subtype::String) = TSRosW(subtype, String[]) +TSRosW(subtype::String, petsc_options) = + TSRosW(subtype, String[String(s) for s in petsc_options]) """ TSImplicit(subtype::String[, theta::Real][, petsc_options]) @@ -105,10 +109,10 @@ struct TSImplicit <: PETScTSAlgorithm end TSImplicit(subtype::String) = TSImplicit(subtype, 0.5, String[]) TSImplicit(subtype::String, theta::Real) = TSImplicit(subtype, Float64(theta), String[]) -TSImplicit(subtype::String, petsc_options::AbstractVector) = - TSImplicit(subtype, 0.5, String.(petsc_options)) -TSImplicit(subtype::String, theta::Real, petsc_options::AbstractVector) = - TSImplicit(subtype, Float64(theta), String.(petsc_options)) +TSImplicit(subtype::String, petsc_options) = + TSImplicit(subtype, 0.5, String[String(s) for s in petsc_options]) +TSImplicit(subtype::String, theta::Real, petsc_options) = + TSImplicit(subtype, Float64(theta), String[String(s) for s in petsc_options]) """ TSARKIMEX(subtype::String[, petsc_options]) @@ -137,6 +141,8 @@ struct TSARKIMEX <: PETScTSAlgorithm petsc_options::Vector{String} end TSARKIMEX(subtype::String) = TSARKIMEX(subtype, String[]) +TSARKIMEX(subtype::String, petsc_options) = + TSARKIMEX(subtype, String[String(s) for s in petsc_options]) """ TSGeneric(ts_type::String[, petsc_options]) @@ -160,3 +166,5 @@ struct TSGeneric <: PETScTSAlgorithm petsc_options::Vector{String} end TSGeneric(ts_type::String) = TSGeneric(ts_type, String[]) +TSGeneric(ts_type::String, petsc_options) = + TSGeneric(ts_type, String[String(s) for s in petsc_options]) diff --git a/test/runtests.jl b/test/runtests.jl index 562198cdf..4b4c958c0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -42,6 +42,7 @@ include("ts_ex16.jl") # Regression test for the van der Pol IMEX examp include("low_level_is.jl") # Low-level IS functions include("low_level_petscsection.jl") # Low-level PetscSection functions include("low_level_tao.jl") # Low-level Tao functions +include("sciml/runtests.jl") # SciMLBase / OrdinaryDiffEq integration include("testutils.jl") diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 403851d15..fbe537022 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -3,15 +3,20 @@ using PETSc using SciMLBase using DiffEqBase -@testset "PETSc.jl SciML extension" begin - ext = Base.get_extension(PETSc, :PETScSciMLExt) - @test ext !== nothing - # Algorithm-level test files will be added in Steps 2–8: - # include("test_rk.jl") - # include("test_rosenbrock.jl") - # include("test_implicit.jl") - # include("test_imex.jl") - # include("test_output.jl") - # include("test_callbacks.jl") - # include("test_integrator.jl") +@testset "PETSc SciML extension" begin + # Smoke test: extension activates with just SciMLBase + DiffEqBase loaded. + @testset "Extension activation" begin + ext = Base.get_extension(PETSc, :PETScSciMLExt) + @test ext !== nothing + end + + include("test_rk.jl") + include("test_rosenbrock.jl") + include("test_implicit.jl") + include("test_imex.jl") + include("test_output.jl") + include("test_callbacks.jl") + include("test_integrator.jl") + include("test_polish.jl") + include("test_review_fixes.jl") end diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index 5cc08415a..2cc1caa4b 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_imex.jl b/test/sciml/test_imex.jl index 1c161b4bb..45bd44483 100644 --- a/test/sciml/test_imex.jl +++ b/test/sciml/test_imex.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_implicit.jl b/test/sciml/test_implicit.jl index 07240c489..4db63df92 100644 --- a/test/sciml/test_implicit.jl +++ b/test/sciml/test_implicit.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl index f758ea575..cb430b700 100644 --- a/test/sciml/test_integrator.jl +++ b/test/sciml/test_integrator.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index cb32e2c5a..374883251 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing @@ -85,9 +84,39 @@ end @test ordered == [0.75, 0.5, 0.25] end - @testset "scalar saveat" begin - sol = solve(prob, TSRK("3bs"); dt = 0.1, saveat = 0.5) + @testset "scalar saveat is a spacing, not a single time (SciML semantics)" begin + # tspan = (0, 1), saveat = 0.25 should save at 0.25, 0.5, 0.75, 1.0 + sol = solve(prob, TSRK("3bs"); dt = 0.1, saveat = 0.25) @test sol.retcode == ReturnCode.Success - @test any(t -> isapprox(t, 0.5; atol = 1e-12), sol.t) + for ts in (0.25, 0.5, 0.75, 1.0) + @test any(t -> isapprox(t, ts; atol = 1e-12), sol.t) + end + end + + @testset "save_everystep + saveat keeps trajectory sorted" begin + # Forces a saveat time inside a step (saveat = 0.25 with dt = 0.4 + # means PETSc's first step ends at 0.4; without the drain-first fix + # the trajectory would record 0.4 before 0.25). + sol = solve( + prob, TSRK("3bs"); + dt = 0.4, save_everystep = true, saveat = [0.25], + ) + @test sol.retcode == ReturnCode.Success + @test issorted(sol.t) + # 0.25 (interpolated) appears between save_start (0.0) and the next + # step endpoint. + @test any(t -> isapprox(t, 0.25; atol = 1e-12), sol.t) + # and the trajectory has no duplicate timestamps. + @test length(unique(sol.t)) == length(sol.t) + end + + @testset "integer tspan is promoted to a float type internally" begin + # The integrator must not truncate PETSc's Float64 step times back + # into Int when prob.tspan has an integer eltype. + prob_i = ODEProblem(decay!, [1.0], (0, 1)) + sol = solve(prob_i, TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ 1.0 + @test sol.u[end][1] ≈ exp(-1) atol = 1e-3 end end diff --git a/test/sciml/test_polish.jl b/test/sciml/test_polish.jl index a1c014742..298da1123 100644 --- a/test/sciml/test_polish.jl +++ b/test/sciml/test_polish.jl @@ -2,10 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures - -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing function decay!(du, u, p, t) du[1] = -u[1] diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl new file mode 100644 index 000000000..69b188241 --- /dev/null +++ b/test/sciml/test_review_fixes.jl @@ -0,0 +1,139 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK +const TSImplicit = ext.TSImplicit + +function decay!(du, u, p, t) + du[1] = -u[1] + return nothing +end + +@testset "Review-driven fixes" begin + u0 = [1.0] + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + + # ── Review-1 #6 / Review-2 #11 ─────────────────────────────────────────── + @testset "Backward / zero-length tspan are rejected with clear errors" begin + prob_bw = ODEProblem(decay!, [exp(-1.0)], (1.0, 0.0)) + prob_zr = ODEProblem(decay!, u0, (0.0, 0.0)) + @test_throws ArgumentError solve(prob_bw, TSRK("3bs"); dt = 0.1) + @test_throws ArgumentError solve(prob_zr, TSRK("3bs"); dt = 0.1) + end + + # ── Review-2 #3 ───────────────────────────────────────────────────────── + @testset "Integrator exposes derivative_discontinuity field" begin + integ = init(prob, TSRK("3bs"); dt = 0.1) + @test hasfield(typeof(integ), :derivative_discontinuity) + @test integ.derivative_discontinuity == false + # u_modified! and the discontinuity field are independent. + DiffEqBase.u_modified!(integ, true) + @test integ.u_modified == true + @test integ.derivative_discontinuity == false + # Direct write should also work for any SciMLBase code path that + # touches the field. + integ.derivative_discontinuity = true + @test integ.derivative_discontinuity == true + PETSc.destroy(integ) + end + + @testset "DiscreteCallback that never fires still completes the solve" begin + # Review-2 #3 explicitly asks for this regression: DiffEqBase reads + # derivative_discontinuity in both the fires-and-doesn't-fire paths. + cb = DiscreteCallback( + (u, t, integ) -> false, # never fires + integ -> nothing, + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + @test sol.t[end] ≈ 1.0 + end + + # ── Review-1 #2 / Review-2 #4 ─────────────────────────────────────────── + @testset "Callback initialize that mutates u is propagated to PETSc" begin + # If the callback initializer rewrites u0, the first PETSc step must + # start from the rewritten value — not from the original u0. + function init_cb!(cb, u, t, integ) + u[1] = 5.0 + DiffEqBase.u_modified!(integ, true) + return nothing + end + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = init_cb!, + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + # u(t) = 5 * exp(-t) at t = 1 is 5/e ≈ 1.84. + @test sol.u[end][1] ≈ 5 * exp(-1.0) atol = 1e-2 + end + + # ── Review-1 #5 / Review-2 #6 ─────────────────────────────────────────── + @testset "DiffEqBase.finalize! is called at end of solve" begin + finalized = Ref(false) + function finalize_cb!(cb, u, t, integ) + finalized[] = true + return nothing + end + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + finalize = finalize_cb!, + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + @test finalized[] + end + + # ── Review-1 #3 / Review-2 #5 ─────────────────────────────────────────── + @testset "reltol / abstol reach PETSc adaptive controller" begin + # Same problem, two different tolerance settings: at coarse tolerance + # PETSc takes fewer adaptive steps than at fine tolerance. + prob_long = ODEProblem(decay!, u0, (0.0, 5.0)) + sol_loose = solve(prob_long, TSRK("5dp"); dt = 0.1, reltol = 1e-2, abstol = 1e-2, + save_everystep = true) + sol_tight = solve(prob_long, TSRK("5dp"); dt = 0.1, reltol = 1e-10, abstol = 1e-10, + save_everystep = true) + @test sol_loose.retcode == ReturnCode.Success + @test sol_tight.retcode == ReturnCode.Success + @test length(sol_tight.t) > length(sol_loose.t) + end + + @testset "Vector tolerances raise ArgumentError" begin + @test_throws ArgumentError solve( + prob, TSRK("5dp"); + dt = 0.1, reltol = [1e-6], abstol = [1e-6], + ) + end + + # ── Review-2 #10 ──────────────────────────────────────────────────────── + @testset "Failed __init does not leak PETSc objects (subsequent solves work)" begin + @test_throws ArgumentError solve(prob, TSImplicit("does-not-exist"); dt = 0.1) + # The failed solve allocated a TS and a Vec via _common_ts_setup, which + # the catch-block in __init must have destroyed before rethrowing. The + # next valid solve in the same Julia session should succeed. + sol = solve(prob, TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + end + + # ── Review-2 #12 ──────────────────────────────────────────────────────── + @testset "AbstractVector petsc_options constructors work for every alg" begin + # Tuples, SubStrings, generic AbstractVectors should all coerce. + @test PETSc.TSRK("3bs", ("-ts_max_steps", "100")).petsc_options == ["-ts_max_steps", "100"] + @test PETSc.TSRosW("ra34pw2", ["-snes_fd"]).petsc_options == ["-snes_fd"] + @test PETSc.TSImplicit("beuler", ("-snes_fd",)).petsc_options == ["-snes_fd"] + @test PETSc.TSImplicit("theta", 0.5, ("-snes_fd",)).petsc_options == ["-snes_fd"] + @test PETSc.TSARKIMEX("2e", ("-snes_fd",)).petsc_options == ["-snes_fd"] + @test PETSc.TSGeneric("alpha", ("-snes_fd",)).petsc_options == ["-snes_fd"] + # SubString round-trip via split. + opts_split = split("-snes_fd -ts_max_steps 100") + @test PETSc.TSRK("3bs", opts_split).petsc_options == + ["-snes_fd", "-ts_max_steps", "100"] + end +end diff --git a/test/sciml/test_rk.jl b/test/sciml/test_rk.jl index 00e08ad71..f5bf454d6 100644 --- a/test/sciml/test_rk.jl +++ b/test/sciml/test_rk.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_rosenbrock.jl b/test/sciml/test_rosenbrock.jl index 5f5419023..e654a1e5b 100644 --- a/test/sciml/test_rosenbrock.jl +++ b/test/sciml/test_rosenbrock.jl @@ -2,7 +2,6 @@ using Test using PETSc using SciMLBase using DiffEqBase -using DataStructures ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing From 4678837ddd90eb16e31fa0f59c5df500dd7ce0ae Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Sun, 26 Apr 2026 20:53:07 +0200 Subject: [PATCH 04/38] WIP --- README.md | 7 +++- ext/sciml/helpers.jl | 11 +++++- ext/sciml/solve.jl | 28 +++++----------- test/Project.toml | 16 +++++++++ test/sciml/test_review_fixes.jl | 59 +++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 test/Project.toml diff --git a/README.md b/README.md index 7e658a9ab..b0c232d14 100644 --- a/README.md +++ b/README.md @@ -73,4 +73,9 @@ integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!(integrator); sol = solve!(integrator) ``` -Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; continuous callbacks and `tstops` are warned about and ignored. The extension currently requires `PetscReal = Float64` and in-place ODE problems. See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. +Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; continuous callbacks and `tstops` are warned about and ignored. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. + +Known callback-lifecycle gaps (compared to OrdinaryDiffEq): + +- The SciML *discrete-save* hooks (`SciMLBase.save_discretes_if_enabled!`, `SciMLBase.save_final_discretes!`) are **not** invoked. Callback machinery that relies on saving observable state alongside the trajectory will not interoperate; only `affect!`-style callbacks that mutate `u` or call `terminate!` are exercised. +- `ContinuousCallback`s emit a warning and are ignored. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index 11d595fe1..f122192a7 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -20,7 +20,16 @@ function _check_petscreal(lib) end _pick_petsclib(prob, petsclib) = petsclib -_pick_petsclib(prob, ::Nothing) = PETSc.getlib(PetscScalar = real(eltype(prob.u0))) +function _pick_petsclib(prob, ::Nothing) + T = eltype(prob.u0) + T <: Complex && throw(ArgumentError( + "PETSc.jl SciML extension currently only supports real-valued ODE " * + "problems (got eltype(u0) = $T). Pass a real `u0`, or — if you have " * + "a PETSc build with a complex `PetscScalar` and the matching support " * + "in this extension lands — supply `petsclib` explicitly.", + )) + return PETSc.getlib(PetscScalar = T) +end function _setfromoptions!(petsclib, ts, petsc_options::AbstractVector{<:AbstractString}) isempty(petsc_options) && return nothing diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index a704453f0..cf55df8a3 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -202,34 +202,24 @@ function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSARKIMEX) end end -# Run callback initialization, sync any state changes back to PETSc, and honor -# initialization-time `save_positions[2]` if the callback set asks for it. -function initialize_callbacks!(integ::PETScTSIntegrator, cb_set; initialize_save = true) +# Run callback initialization and, if a callback initializer mutated `u`, push +# the modified state into the PETSc Vec so the first `TSStep` starts from the +# right initial condition. +# +# We deliberately do not push into `sol.t` / `sol.u` here. The first call to +# `step!` saves `t0` exactly once (when `save_start = true`) using the +# possibly-modified `integ.u`, which keeps the trajectory free of duplicate +# timestamps and routes start-time saving through a single code path. +function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) DiffEqBase.initialize!(cb_set, integ.u, integ.t, integ) if integ.u_modified _sync_julia_to_petsc!(integ) PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) - if initialize_save && _wants_initialize_save(cb_set) - push!(integ.sol.t, integ.t) - push!(integ.sol.u, copy(integ.u)) - end integ.u_modified = false end return nothing end -# A CallbackSet wants an initialize-time save when any of its discrete or -# continuous callbacks has `save_positions[2] = true`. -function _wants_initialize_save(cb_set) - for cb in cb_set.discrete_callbacks - isdefined(cb, :save_positions) && cb.save_positions[2] && return true - end - for cb in cb_set.continuous_callbacks - isdefined(cb, :save_positions) && cb.save_positions[2] && return true - end - return false -end - function SciMLBase.__init( prob::SciMLBase.AbstractODEProblem, alg::PETScTSAlgorithm; diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 000000000..c8f49d2ed --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,16 @@ +[deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" +ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +MPI = "da04e1cc-30fd-572f-bb4f-1f8673147195" +PETSc = "ace2c81b-2b5f-4b1e-a30d-d662738edfe0" +PETSc_jll = "8fa3689e-f0b9-5420-9873-adf6ccf46f2d" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" +SparseDiffTools = "47a9eef4-7e08-11e9-0b38-333d64bd3804" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 69b188241..d44436bbc 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -122,6 +122,65 @@ end @test sol.retcode == ReturnCode.Success end + # ── Review-3 #2 ───────────────────────────────────────────────────────── + @testset "Complex-valued ODEProblem is rejected with a clear error" begin + prob_c = ODEProblem(decay!, ComplexF64[1 + 0im], tspan) + err = try + solve(prob_c, TSRK("3bs"); dt = 0.1) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("real-valued", err.msg) + @test occursin("ComplexF64", err.msg) || occursin("Complex", err.msg) + end + + # ── Review-3 #3 ───────────────────────────────────────────────────────── + @testset "Initialize callback that mutates u does not duplicate t0" begin + function init_cb!(cb, u, t, integ) + u[1] = 5.0 + DiffEqBase.u_modified!(integ, true) + return nothing + end + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = init_cb!, + ) + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_everystep = true, # forces a full trajectory record + ) + @test sol.retcode == ReturnCode.Success + # The trajectory has no duplicate timestamps and starts from the + # mutated initial state. + @test length(unique(sol.t)) == length(sol.t) + @test sol.t[1] ≈ 0.0 + @test sol.u[1][1] ≈ 5.0 + end + + @testset "save_on = false suppresses all trajectory output even with init mutation" begin + function init_cb!(cb, u, t, integ) + u[1] = 5.0 + DiffEqBase.u_modified!(integ, true) + return nothing + end + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = init_cb!, + ) + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_on = false, save_start = false, save_end = false, + ) + @test sol.retcode == ReturnCode.Success + @test isempty(sol.t) && isempty(sol.u) + end + # ── Review-2 #12 ──────────────────────────────────────────────────────── @testset "AbstractVector petsc_options constructors work for every alg" begin # Tuples, SubStrings, generic AbstractVectors should all coerce. From 87273edad64e705de27195777dee25cfc1de9dc4 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Sun, 26 Apr 2026 21:19:22 +0200 Subject: [PATCH 05/38] WIP --- ext/sciml/solve.jl | 46 +++++++++++++++------- test/runtests.jl | 13 ++++++- test/sciml/runtests.jl | 10 +++++ test/sciml/test_callbacks.jl | 38 +++++++++++++++--- test/sciml/test_review_fixes.jl | 68 +++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 20 deletions(-) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index cf55df8a3..5a223d24b 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -202,24 +202,36 @@ function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSARKIMEX) end end -# Run callback initialization and, if a callback initializer mutated `u`, push -# the modified state into the PETSc Vec so the first `TSStep` starts from the -# right initial condition. +# Mirror the OrdinaryDiffEq initialization contract: # -# We deliberately do not push into `sol.t` / `sol.u` here. The first call to -# `step!` saves `t0` exactly once (when `save_start = true`) using the -# possibly-modified `integ.u`, which keeps the trajectory free of duplicate -# timestamps and routes start-time saving through a single code path. +# 1. Pessimistically mark `u` as modified before calling `initialize!`. A +# callback initializer that does not mutate `u` is expected to call +# `DiffEqBase.u_modified!(integ, false)`; otherwise we conservatively assume +# it did and resync the PETSc Vec. +# 2. Force an initialize-time save when any discrete callback requests +# `save_positions[2]` (the post-event side, which corresponds to "after the +# callback ran"). Duplicate suppression in `step!` keeps `t0` from being +# recorded twice when `save_start = true`. function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) + integ.u_modified = true DiffEqBase.initialize!(cb_set, integ.u, integ.t, integ) if integ.u_modified _sync_julia_to_petsc!(integ) PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) integ.u_modified = false end + + if integ.opts.save_on && _any_initialize_save(cb_set) && + (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) + push!(integ.sol.t, integ.t) + push!(integ.sol.u, copy(integ.u)) + end return nothing end +_any_initialize_save(cb_set) = + any(cb -> cb.save_positions[2], cb_set.discrete_callbacks) + function SciMLBase.__init( prob::SciMLBase.AbstractODEProblem, alg::PETScTSAlgorithm; @@ -238,15 +250,20 @@ function SciMLBase.__init( verbose::Bool = false, kwargs..., ) - (lib, ts, u_v, u0, tType, t0, tdir) = - _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) - cb_set = DiffEqBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) - @warn "PETSc.jl SciML extension: ContinuousCallbacks are not yet " * - "supported and will be ignored. Use DiscreteCallback or wrap " * - "the event detection in PETSc's TSSetEventHandler manually." + throw(ArgumentError( + "PETSc.jl SciML extension: ContinuousCallbacks are not yet " * + "supported. Their `initialize` and `finalize` hooks would still " * + "run while event detection is silently dropped, so we reject " * + "them up front. Use DiscreteCallback or wrap the event detection " * + "in PETSc's TSSetEventHandler manually.", + )) end + + (lib, ts, u_v, u0, tType, t0, tdir) = + _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) + if !isempty(_as_time_iter(tstops, Float64)) @warn "PETSc.jl SciML extension: `tstops` is not yet honoured. " * "PETSc adapts step sizes internally; pass `dt` and " * @@ -307,7 +324,8 @@ function SciMLBase.step!(integ::PETScTSIntegrator) if !integ.initialized PETSc.LibPETSc.TSSetUp(integ.petsclib, integ.ts) integ.initialized = true - if integ.opts.save_start + if integ.opts.save_start && + (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) push!(integ.sol.t, integ.t) push!(integ.sol.u, copy(integ.u)) end diff --git a/test/runtests.jl b/test/runtests.jl index 4b4c958c0..3f16532ed 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,17 @@ using Test +using Pkg + +# Make the test suite runnable both via `Pkg.test("PETSc")` (which sets up a +# merged sandbox project) and via direct script execution like +# `julia --project=. test/runtests.jl`. In the latter case the active project +# is the package itself and lacks test-only deps such as SciMLBase, so we +# switch to `test/Project.toml` before loading anything that needs them. +if !haskey(Pkg.project().dependencies, "SciMLBase") + Pkg.activate(@__DIR__) +end + using MPI: MPI, mpiexec -using PETSc, PETSc_jll, Pkg +using PETSc, PETSc_jll # Make sure that all dependencies are installed also on a clean system Pkg.instantiate() diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index fbe537022..3d5574222 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -1,4 +1,14 @@ using Test +using Pkg + +# When this file is invoked directly (`julia --project=. test/sciml/runtests.jl`) +# the active project is the package and SciMLBase is not on the load path. The +# top-level `test/runtests.jl` performs the same activation; doing it here too +# keeps the standalone script entrypoint working. +if !haskey(Pkg.project().dependencies, "SciMLBase") + Pkg.activate(joinpath(@__DIR__, "..")) +end + using PETSc using SciMLBase using DiffEqBase diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index 2cc1caa4b..88e66a30b 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -67,16 +67,44 @@ end @test seen_b[] end - @testset "ContinuousCallback emits a warning and does not error" begin + @testset "ContinuousCallback is rejected with ArgumentError" begin cc = ContinuousCallback( (u, t, integ) -> u[1] - 0.5, integ -> nothing, ) - sol = @test_logs (:warn, r"ContinuousCallback") solve( - prob, TSRK("3bs"); dt = 0.1, callback = cc, + err = try + solve(prob, TSRK("3bs"); dt = 0.1, callback = cc) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("ContinuousCallback", err.msg) + + # `initialize` / `finalize` hooks must not run when the callback is + # rejected — otherwise users get hidden side effects from an API the + # extension claims is unsupported. + init_ran = Ref(false) + finalize_ran = Ref(false) + cc_hooks = ContinuousCallback( + (u, t, integ) -> u[1] - 0.5, + integ -> nothing; + initialize = (cb, u, t, integ) -> (init_ran[] = true; nothing), + finalize = (cb, u, t, integ) -> (finalize_ran[] = true; nothing), + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, callback = cc_hooks, + ) + @test !init_ran[] + @test !finalize_ran[] + + # A CallbackSet that mixes discrete and continuous callbacks should + # also be rejected, since the continuous half cannot be honoured. + cb_d = DiscreteCallback((u, t, integ) -> false, integ -> nothing) + cbs = CallbackSet(cb_d, cc) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, callback = cbs, ) - @test sol.retcode == ReturnCode.Success - @test sol.t[end] ≈ tspan[2] end @testset "tstops kwarg emits a warning and is ignored" begin diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index d44436bbc..16fd4bcc2 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -161,6 +161,74 @@ end @test sol.u[1][1] ≈ 5.0 end + # ── Review-4 #1 ───────────────────────────────────────────────────────── + @testset "Initialize that mutates u without u_modified! still propagates" begin + # The pessimistic-modified contract: a callback that mutates `u` but + # forgets to call `DiffEqBase.u_modified!(integ, true)` must still + # affect the first PETSc step. This is what OrdinaryDiffEq does. + function init_cb!(cb, u, t, integ) + u[1] = 5.0 + return nothing + end + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = init_cb!, + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + # u(t) = 5 * exp(-t) at t = 1 is 5/e ≈ 1.84. + @test sol.u[end][1] ≈ 5 * exp(-1.0) atol = 1e-2 + end + + @testset "Initialize-time save records t0 when save_start=false but cb wants it" begin + # A DiscreteCallback with default save_positions = (true, true) and + # save_start = false, save_end = false should still record exactly one + # t0 entry, matching upstream OrdinaryDiffEq behavior. + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing, + ) + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_start = false, save_end = false, + ) + @test sol.retcode == ReturnCode.Success + @test length(sol.t) == 1 + @test sol.t[1] ≈ 0.0 + @test sol.u[1][1] ≈ 1.0 + + # And with save_positions = (false, false) the initialize-time save + # must NOT happen — the user explicitly opted out. + cb_nosave = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + save_positions = (false, false), + ) + sol2 = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb_nosave, + save_start = false, save_end = false, + ) + @test sol2.retcode == ReturnCode.Success + @test isempty(sol2.t) + end + + @testset "save_start=true with initialize-saving cb still records t0 only once" begin + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing, + ) + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_start = true, save_end = false, save_everystep = true, + ) + @test sol.retcode == ReturnCode.Success + @test count(==(0.0), sol.t) == 1 + end + @testset "save_on = false suppresses all trajectory output even with init mutation" begin function init_cb!(cb, u, t, integ) u[1] = 5.0 From d3b4af3eaaab1d19bce92109b1ef116d7af1dd69 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 08:24:00 +0200 Subject: [PATCH 06/38] WIP --- README.md | 4 +- ext/sciml/helpers.jl | 26 +++++++ ext/sciml/solve.jl | 69 ++++++++++++++++--- src/sciml_algorithms.jl | 37 +++++++--- test/runtests.jl | 14 ++-- test/sciml/runtests.jl | 11 +-- test/sciml/test_review_fixes.jl | 118 ++++++++++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index b0c232d14..62f48c35b 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!(integrator); sol = solve!(integrator) ``` -Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; continuous callbacks and `tstops` are warned about and ignored. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. +Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; `ContinuousCallback`s are rejected with `ArgumentError`, and `tstops` is warned about and ignored. Standard SciML control knobs `adaptive`, `dtmin`, and `dtmax` are mapped onto PETSc's `TSAdapt` controller; unrecognized solve keywords are rejected with a clear `ArgumentError` rather than silently dropped. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. Known callback-lifecycle gaps (compared to OrdinaryDiffEq): - The SciML *discrete-save* hooks (`SciMLBase.save_discretes_if_enabled!`, `SciMLBase.save_final_discretes!`) are **not** invoked. Callback machinery that relies on saving observable state alongside the trajectory will not interoperate; only `affect!`-style callbacks that mutate `u` or call `terminate!` are exercised. -- `ContinuousCallback`s emit a warning and are ignored. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. +- `ContinuousCallback`s are rejected with `ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index f122192a7..bb380589e 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -58,3 +58,29 @@ function _sync_julia_to_petsc!(integ::PETScTSIntegrator) end return nothing end + +# Reject SciML keywords that this extension does not actually honour. Letting +# the open-ended `kwargs...` sink swallow standard knobs like `adaptive` / +# `dtmin` / `progress` would silently break the usual SciML solver contract, +# so any unsupported key fails loudly with a clear, named error. +const _SUPPORTED_SCIML_KWARGS = ( + :save_everystep, :save_on, :save_start, :save_end, + :saveat, :tstops, :callback, + :reltol, :abstol, + :dt, :dtmin, :dtmax, :adaptive, + :maxiters, :petsclib, :verbose, +) + +function _reject_unsupported_kwargs(kwargs) + for key in keys(kwargs) + key in _SUPPORTED_SCIML_KWARGS && continue + throw(ArgumentError( + "PETSc.jl SciML extension: keyword argument `$(key)` is not " * + "supported. Supported keywords are: " * + join(_SUPPORTED_SCIML_KWARGS, ", ") * ". " * + "Pass equivalent PETSc CLI flags via the algorithm's " * + "`petsc_options` argument if a matching SciML knob is missing.", + )) + end + return nothing +end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 5a223d24b..7fd15ed09 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -32,11 +32,21 @@ function _check_tspan(t0, tf) return nothing end +# SciML default tolerances applied when the user passes only one of +# `reltol` / `abstol`. This matches OrdinaryDiffEq's defaults so a partial +# specification (e.g. `reltol = 1e-10`) actually reaches PETSc instead of +# being silently dropped. +const _SCIML_DEFAULT_RELTOL = 1e-3 +const _SCIML_DEFAULT_ABSTOL = 1e-6 + # Builds the bare PETSc TS skeleton shared by every algorithm: pick the # library, allocate the solution vector, set time bounds and a maybe-supplied # initial step. Algorithm-specific TS type, subtype, and callback registration # happen in `_setup_petsc_algorithm!` afterwards. -function _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) +function _common_ts_setup( + prob, dt, maxiters, petsclib, reltol, abstol, + adaptive, dtmin, dtmax, +) _check_isinplace(prob) lib = _pick_petsclib(prob, petsclib) _check_petscreal(lib) @@ -65,13 +75,8 @@ function _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) if dt !== nothing PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) end - if reltol !== nothing && abstol !== nothing - (reltol isa Real && abstol isa Real) || throw(ArgumentError( - "PETSc.jl SciML extension: only scalar `reltol` / `abstol` are " * - "supported. Got types $(typeof(reltol)) / $(typeof(abstol)).", - )) - _ts_set_scalar_tolerances!(lib, ts, abstol, reltol) - end + _apply_tolerances!(lib, ts, reltol, abstol) + _apply_adaptivity!(lib, ts, adaptive, dtmin, dtmax) catch PETSc.LibPETSc.TSDestroy(lib, ts) PETSc.destroy(u_v) @@ -81,6 +86,39 @@ function _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) return (lib, ts, u_v, u0, tType, t0, tdir) end +# Forward `reltol` / `abstol` to PETSc when at least one is set. Filling the +# missing side from SciML's defaults is the convention upstream wrappers use: +# `solve(prob, alg; reltol = 1e-10)` should reach the adaptive controller +# rather than be silently ignored. +function _apply_tolerances!(lib, ts, reltol, abstol) + (reltol === nothing && abstol === nothing) && return nothing + rt = reltol === nothing ? _SCIML_DEFAULT_RELTOL : reltol + at = abstol === nothing ? _SCIML_DEFAULT_ABSTOL : abstol + (rt isa Real && at isa Real) || throw(ArgumentError( + "PETSc.jl SciML extension: only scalar `reltol` / `abstol` are " * + "supported. Got types $(typeof(reltol)) / $(typeof(abstol)).", + )) + _ts_set_scalar_tolerances!(lib, ts, at, rt) + return nothing +end + +# Map SciML's `adaptive` / `dtmin` / `dtmax` knobs onto the PETSc TSAdapt +# controller. `adaptive = false` selects PETSc's "none" adapter (fixed +# `dt`); `dtmin` / `dtmax` set step limits via `TSAdaptSetStepLimits`. +function _apply_adaptivity!(lib, ts, adaptive, dtmin, dtmax) + (adaptive === true && dtmin === nothing && dtmax === nothing) && return nothing + adapt = PETSc.LibPETSc.TSGetAdapt(lib, ts) + if adaptive === false + PETSc.LibPETSc.TSAdaptSetType(lib, adapt, "none") + end + if dtmin !== nothing || dtmax !== nothing + hmin = dtmin === nothing ? zero(lib.PetscReal) : lib.PetscReal(dtmin) + hmax = dtmax === nothing ? lib.PetscReal(Inf) : lib.PetscReal(dtmax) + PETSc.LibPETSc.TSAdaptSetStepLimits(lib, adapt, hmin, hmax) + end + return nothing +end + function _make_integrator( alg, u0, tType, t0, tdir, dt, prob, opts, sol, lib, ts, u_v, cb_ctx, @@ -182,6 +220,11 @@ end function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSGeneric) PETSc.LibPETSc.TSSetType(lib, ts, alg.ts_type) + if alg.explicit + # Explicit PETSc TS types (e.g. `"euler"`, `"ssp"`) reject an + # IFunction at TSStep time — register the RHS instead. + return _register_rhs!(lib, ts, prob, u0) + end PETSc.LibPETSc.TSSetProblemType(lib, ts, PETSc.LibPETSc.TS_NONLINEAR) return _register_ifunction!(lib, ts, prob, u0) end @@ -245,11 +288,15 @@ function SciMLBase.__init( reltol = nothing, abstol = nothing, dt = nothing, + dtmin = nothing, + dtmax = nothing, + adaptive::Bool = true, maxiters::Integer = Int(1e5), petsclib = nothing, verbose::Bool = false, kwargs..., ) + _reject_unsupported_kwargs(kwargs) cb_set = DiffEqBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) throw(ArgumentError( @@ -261,8 +308,10 @@ function SciMLBase.__init( )) end - (lib, ts, u_v, u0, tType, t0, tdir) = - _common_ts_setup(prob, alg, dt, maxiters, petsclib, reltol, abstol) + (lib, ts, u_v, u0, tType, t0, tdir) = _common_ts_setup( + prob, dt, maxiters, petsclib, reltol, abstol, + adaptive, dtmin, dtmax, + ) if !isempty(_as_time_iter(tstops, Float64)) @warn "PETSc.jl SciML extension: `tstops` is not yet honoured. " * diff --git a/src/sciml_algorithms.jl b/src/sciml_algorithms.jl index c3f99b844..732dff998 100644 --- a/src/sciml_algorithms.jl +++ b/src/sciml_algorithms.jl @@ -145,26 +145,43 @@ TSARKIMEX(subtype::String, petsc_options) = TSARKIMEX(subtype, String[String(s) for s in petsc_options]) """ - TSGeneric(ts_type::String[, petsc_options]) + TSGeneric(ts_type::String[, petsc_options]; explicit::Bool = false) Pass-through algorithm that calls `TSSetType(ts, ts_type)` directly without any subtype-specific configuration. Useful for PETSc TS types that do not -have a dedicated wrapper here yet (e.g. `"euler"`, `"ssp"`, `"alpha"`, -`"glle"`, `"glee"`). +have a dedicated wrapper here yet (e.g. `"alpha"`, `"glle"`, `"glee"` for +implicit families; `"euler"`, `"ssp"` for explicit families). -`TSGeneric` registers an IFunction (residual `udot - f(u,p,t)`), so it -expects the resulting PETSc TS to be a fully-implicit method. Use -`TSRK`/`TSRosW`/`TSImplicit`/`TSARKIMEX` for the standard families. +By default `TSGeneric` registers an IFunction (residual `udot - f(u,p,t)`) +and sets `TS_NONLINEAR`, which is what implicit / Rosenbrock-style PETSc +TS types expect. Pass `explicit = true` to instead register the RHS via +`TSSetRHSFunction`, which is what explicit-only PETSc TS types like +`"euler"` and `"ssp"` require. -# Example +If you supply an `explicit = false` `TSGeneric` for a TS type that PETSc +classifies as explicit-only, `TSSetUp` / `TSStep` will fail with a clear +PETSc error indicating the residual is not consumed. In that case, retry +with `explicit = true`. + +For the standard families prefer the dedicated wrappers +`TSRK` / `TSRosW` / `TSImplicit` / `TSARKIMEX`. + +# Examples ```julia +# Implicit / Rosenbrock-style: default `explicit = false`. solve(prob, PETSc.TSGeneric("alpha", ["-snes_fd"]); dt = 0.01) + +# Explicit-only PETSc TS types: opt in via `explicit = true`. +solve(prob, PETSc.TSGeneric("euler"; explicit = true); dt = 0.01) +solve(prob, PETSc.TSGeneric("ssp"; explicit = true); dt = 0.01) ``` """ struct TSGeneric <: PETScTSAlgorithm ts_type::String + explicit::Bool petsc_options::Vector{String} end -TSGeneric(ts_type::String) = TSGeneric(ts_type, String[]) -TSGeneric(ts_type::String, petsc_options) = - TSGeneric(ts_type, String[String(s) for s in petsc_options]) +TSGeneric(ts_type::String; explicit::Bool = false) = + TSGeneric(ts_type, explicit, String[]) +TSGeneric(ts_type::String, petsc_options; explicit::Bool = false) = + TSGeneric(ts_type, explicit, String[String(s) for s in petsc_options]) diff --git a/test/runtests.jl b/test/runtests.jl index 3f16532ed..50c5361c2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,11 +1,15 @@ using Test using Pkg -# Make the test suite runnable both via `Pkg.test("PETSc")` (which sets up a -# merged sandbox project) and via direct script execution like -# `julia --project=. test/runtests.jl`. In the latter case the active project -# is the package itself and lacks test-only deps such as SciMLBase, so we -# switch to `test/Project.toml` before loading anything that needs them. +# `Pkg.test("PETSc")` sets up a merged sandbox project that already contains +# the test-only deps. When this file is `include`d from a session whose +# active project is the package itself (and so lacks SciMLBase etc.), the +# block below switches to `test/Project.toml` so those deps are reachable. +# Note: invoking this file as a standalone script +# (`julia --project=. test/runtests.jl`) is not a fully supported workflow — +# the process may exit non-zero during PETSc/MPI teardown after all tests +# pass. Prefer `Pkg.test("PETSc")` or `include("test/runtests.jl")` from a +# REPL. if !haskey(Pkg.project().dependencies, "SciMLBase") Pkg.activate(@__DIR__) end diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 3d5574222..efe6fea5b 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -1,10 +1,13 @@ using Test using Pkg -# When this file is invoked directly (`julia --project=. test/sciml/runtests.jl`) -# the active project is the package and SciMLBase is not on the load path. The -# top-level `test/runtests.jl` performs the same activation; doing it here too -# keeps the standalone script entrypoint working. +# Mirror `test/runtests.jl`'s project activation so test-only deps such as +# SciMLBase are on the load path even when this file is `include`d directly +# from a session whose active project is the package itself. Note: invoking +# the file as a standalone script (`julia --project=. test/sciml/runtests.jl`) +# is *not* a fully supported entry point — the process may exit non-zero +# during PETSc/MPI teardown. Use `Pkg.test("PETSc")` or `include` the file +# from `test/runtests.jl` instead. if !haskey(Pkg.project().dependencies, "SciMLBase") Pkg.activate(joinpath(@__DIR__, "..")) end diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 16fd4bcc2..56a3a6d9c 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -263,4 +263,122 @@ end @test PETSc.TSRK("3bs", opts_split).petsc_options == ["-snes_fd", "-ts_max_steps", "100"] end + + # ── Review-5 #1 ───────────────────────────────────────────────────────── + @testset "Single-sided reltol still reaches PETSc" begin + # `solve(...; reltol = 1e-10)` (no `abstol`) used to be silently + # ignored — the wrapper required *both* sides to be set. Now the + # missing side is filled with SciML's default and PETSc's adaptive + # controller actually responds. + prob_long = ODEProblem(decay!, u0, (0.0, 5.0)) + sol_default = solve(prob_long, TSRK("5dp"); dt = 0.1, save_everystep = true) + sol_rel = solve( + prob_long, TSRK("5dp"); + dt = 0.1, reltol = 1e-10, save_everystep = true, + ) + @test length(sol_rel.t) > length(sol_default.t) + end + + @testset "Single-sided abstol still reaches PETSc" begin + # The decay problem decays exponentially toward zero, so the + # `atol + rtol * |u|` threshold becomes dominated by `atol` once `u` + # is small. Tightening `abstol` alone (with `reltol` defaulted) + # therefore changes the adaptive step count — provided abstol is + # actually forwarded to PETSc, which used to require both sides set. + prob_decay = ODEProblem(decay!, u0, (0.0, 30.0)) + sol_default = solve( + prob_decay, TSRK("5dp"); dt = 0.1, save_everystep = true, + ) + sol_abs = solve( + prob_decay, TSRK("5dp"); + dt = 0.1, abstol = 1e-14, save_everystep = true, + ) + @test length(sol_abs.t) > length(sol_default.t) + end + + # ── Review-5 #2 ───────────────────────────────────────────────────────── + @testset "Unsupported solve keywords are rejected with ArgumentError" begin + # Anything not on the explicit allowlist should fail loudly. Pick a + # set of common SciML knobs that this extension does NOT honour. + for bad in (:progress, :progress_steps, :alias_u0, :internalnorm, + :force_dtmin, :unstable_check) + err = try + solve(prob, TSRK("3bs"); dt = 0.1, (; bad => true)...) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin(string(bad), err.msg) + end + end + + @testset "adaptive = false disables PETSc's adaptive controller" begin + # Fixed-step PETSc takes evenly-spaced dt steps; the default adaptive + # solver completes in many fewer steps for this trivial problem. + prob_long = ODEProblem(decay!, u0, (0.0, 1.0)) + sol_adapt = solve( + prob_long, TSRK("5dp"); + dt = 0.1, save_everystep = true, + ) + sol_fixed = solve( + prob_long, TSRK("5dp"); + dt = 0.1, adaptive = false, save_everystep = true, + ) + @test sol_adapt.retcode == ReturnCode.Success + @test sol_fixed.retcode == ReturnCode.Success + # Fixed-step at dt = 0.1 over [0, 1] is 10 internal steps + start/end. + @test length(sol_fixed.t) >= 10 + # And the adjacent intervals should be (almost) the same fixed dt. + diffs = diff(sol_fixed.t) + @test all(d -> isapprox(d, 0.1; atol = 1e-12), diffs[1:(end - 1)]) + end + + @testset "dtmax caps the step size of the adaptive controller" begin + prob_long = ODEProblem(decay!, u0, (0.0, 5.0)) + sol_capped = solve( + prob_long, TSRK("5dp"); + dt = 0.1, dtmax = 0.2, save_everystep = true, + ) + @test sol_capped.retcode == ReturnCode.Success + # Every internal interval must respect the cap (modulo the final + # match-step trim, which can be smaller). + for d in diff(sol_capped.t) + @test d <= 0.2 + 1e-12 + end + end + + @testset "TSGeneric explicit = true accepts euler / ssp" begin + # Previously TSGeneric always registered an IFunction, so explicit + # PETSc TS types failed at TSStep with a raw PETSc error. With + # `explicit = true` the RHS path is selected and the solve succeeds. + prob_short = ODEProblem(decay!, u0, (0.0, 1.0)) + sol_euler = solve( + prob_short, PETSc.TSGeneric("euler"; explicit = true); dt = 0.1, + ) + @test sol_euler.retcode == ReturnCode.Success + @test sol_euler.t[end] ≈ 1.0 + @test sol_euler.u[end][1] ≈ exp(-1.0) atol = 1e-1 + + sol_ssp = solve( + prob_short, PETSc.TSGeneric("ssp"; explicit = true); dt = 0.1, + ) + @test sol_ssp.retcode == ReturnCode.Success + @test sol_ssp.t[end] ≈ 1.0 + @test sol_ssp.u[end][1] ≈ exp(-1.0) atol = 1e-1 + + # Default `explicit = false` for an explicit-only TS type still + # fails — but with a PETSc-side error, not silently. Catch any + # exception (PETSc emits a plain `ErrorException` from `@chk`). + @test_throws Exception solve( + prob_short, PETSc.TSGeneric("euler"); dt = 0.1, + ) + end + + @testset "TSGeneric positional petsc_options work with explicit kwarg" begin + alg = PETSc.TSGeneric("euler", ["-ts_max_steps", "100"]; explicit = true) + @test alg.ts_type == "euler" + @test alg.explicit == true + @test alg.petsc_options == ["-ts_max_steps", "100"] + end end From 9208c0d4a4ccb6964941c2465a0dd3c1fdddbedd Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 08:35:29 +0200 Subject: [PATCH 07/38] WIP --- ext/sciml/helpers.jl | 2 +- ext/sciml/options.jl | 3 -- ext/sciml/solve.jl | 51 +++++++++++++++++++- test/sciml/test_output.jl | 2 +- test/sciml/test_review_fixes.jl | 84 ++++++++++++++++++++++++++++++--- 5 files changed, 129 insertions(+), 13 deletions(-) diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index bb380589e..14030015b 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -68,7 +68,7 @@ const _SUPPORTED_SCIML_KWARGS = ( :saveat, :tstops, :callback, :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, - :maxiters, :petsclib, :verbose, + :maxiters, :petsclib, ) function _reject_unsupported_kwargs(kwargs) diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index 52b32aeae..e0cd6fdf5 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -9,7 +9,6 @@ mutable struct DEOptions{SavT, TstopsT, CType, reltolType, abstolType} reltol::reltolType abstol::abstolType maxiters::Int - verbose::Bool end _as_time_iter(::Nothing, ::Type) = () @@ -46,7 +45,6 @@ function _build_opts( reltol, abstol, maxiters::Integer, - verbose::Bool, ) where {tType} t0 = tdir * tType(tspan[1]) tf = tdir * tType(tspan[2]) @@ -72,6 +70,5 @@ function _build_opts( reltol, abstol, Int(maxiters), - verbose, ) end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 7fd15ed09..cb833ff53 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -72,6 +72,12 @@ function _common_ts_setup( PETSc.LibPETSc.TSSetExactFinalTime( lib, ts, PETSc.LibPETSc.TS_EXACTFINALTIME_MATCHSTEP, ) + # Validate `dt` against the SciML `dtmin` / `dtmax` bounds *before* + # installing it: PETSc applies the initial step verbatim and only + # consults `TSAdaptSetStepLimits` for subsequent step proposals, so + # silently allowing `dt = 0.5, dtmax = 0.2` would let the very first + # step violate the user-supplied bound. + _check_dt_bounds(dt, dtmin, dtmax) if dt !== nothing PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) end @@ -102,6 +108,27 @@ function _apply_tolerances!(lib, ts, reltol, abstol) return nothing end +# Reject contradictory inputs where the user-supplied initial `dt` would +# violate the user-supplied `dtmin` / `dtmax` bounds. PETSc installs the +# initial step verbatim, so a clean `ArgumentError` is the only way to +# uphold the SciML "all steps respect the bound" contract from step one. +function _check_dt_bounds(dt, dtmin, dtmax) + dt === nothing && return nothing + if dtmax !== nothing && dt > dtmax + throw(ArgumentError( + "PETSc.jl SciML extension: initial `dt = $dt` exceeds `dtmax = $dtmax`. " * + "Pass `dt <= dtmax`, or omit `dt` to let PETSc choose the initial step.", + )) + end + if dtmin !== nothing && dt < dtmin + throw(ArgumentError( + "PETSc.jl SciML extension: initial `dt = $dt` is below `dtmin = $dtmin`. " * + "Pass `dt >= dtmin`, or omit `dt` to let PETSc choose the initial step.", + )) + end + return nothing +end + # Map SciML's `adaptive` / `dtmin` / `dtmax` knobs onto the PETSc TSAdapt # controller. `adaptive = false` selects PETSc's "none" adapter (fixed # `dt`); `dtmin` / `dtmax` set step limits via `TSAdaptSetStepLimits`. @@ -219,6 +246,7 @@ function _register_ifunction_with_f!(lib, ts, f, prob, u0) end function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSGeneric) + _check_tsgeneric_type(alg) PETSc.LibPETSc.TSSetType(lib, ts, alg.ts_type) if alg.explicit # Explicit PETSc TS types (e.g. `"euler"`, `"ssp"`) reject an @@ -229,6 +257,26 @@ function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSGeneric) return _register_ifunction!(lib, ts, prob, u0) end +# PETSc TS types known to require the explicit (RHS) calling convention. +# Using one of these without `explicit = true` would otherwise dump a full +# PETSc error banner at `TSSetUp` time before the wrapper rethrows; catching +# it on the Julia side gives the user a clear, actionable message instead. +const _EXPLICIT_ONLY_TS_TYPES = ("euler", "ssp") + +function _check_tsgeneric_type(alg::TSGeneric) + if !alg.explicit && alg.ts_type in _EXPLICIT_ONLY_TS_TYPES + throw(ArgumentError( + "PETSc.jl SciML extension: TSGeneric ts_type $(repr(alg.ts_type)) " * + "is an explicit-only PETSc TS family and must be constructed " * + "with `explicit = true`, e.g. " * + "`PETSc.TSGeneric($(repr(alg.ts_type)); explicit = true)`. " * + "The default `explicit = false` registers an IFunction, which " * + "PETSc rejects for explicit-only types at `TSSetUp` time.", + )) + end + return nothing +end + function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSARKIMEX) PETSc.LibPETSc.TSSetType(lib, ts, "arkimex") PETSc.LibPETSc.TSARKIMEXSetType(lib, ts, alg.subtype) @@ -293,7 +341,6 @@ function SciMLBase.__init( adaptive::Bool = true, maxiters::Integer = Int(1e5), petsclib = nothing, - verbose::Bool = false, kwargs..., ) _reject_unsupported_kwargs(kwargs) @@ -323,7 +370,7 @@ function SciMLBase.__init( tType, saveat, tstops, tdir, prob.tspan; save_everystep, save_on, save_start, save_end, callback = cb_set, - reltol, abstol, maxiters, verbose, + reltol, abstol, maxiters, ) sol = SciMLBase.build_solution( diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index 374883251..9342b93bd 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -75,7 +75,7 @@ end Float64, [0.25, 0.5, 0.75], (), -1.0, (1.0, 0.0); save_everystep = false, save_on = true, save_start = true, save_end = true, callback = nothing, - reltol = 1e-3, abstol = 1e-6, maxiters = 1000, verbose = false, + reltol = 1e-3, abstol = 1e-6, maxiters = 1000, ) ordered = Float64[] while !isempty(opts.saveat) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 56a3a6d9c..81877d31a 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -367,12 +367,20 @@ end @test sol_ssp.t[end] ≈ 1.0 @test sol_ssp.u[end][1] ≈ exp(-1.0) atol = 1e-1 - # Default `explicit = false` for an explicit-only TS type still - # fails — but with a PETSc-side error, not silently. Catch any - # exception (PETSc emits a plain `ErrorException` from `@chk`). - @test_throws Exception solve( - prob_short, PETSc.TSGeneric("euler"); dt = 0.1, - ) + # Default `explicit = false` for an explicit-only TS type fails up + # front with a clear Julia-side `ArgumentError` instead of a noisy + # PETSc banner from `TSSetUp`. + for ts_type in ("euler", "ssp") + err = try + solve(prob_short, PETSc.TSGeneric(ts_type); dt = 0.1) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("explicit = true", err.msg) + @test occursin(ts_type, err.msg) + end end @testset "TSGeneric positional petsc_options work with explicit kwarg" begin @@ -381,4 +389,68 @@ end @test alg.explicit == true @test alg.petsc_options == ["-ts_max_steps", "100"] end + + # ── Review-6 #1 ───────────────────────────────────────────────────────── + @testset "Initial dt outside [dtmin, dtmax] is rejected" begin + # PETSc installs the initial `dt` verbatim and only consults + # `TSAdaptSetStepLimits` for subsequent step proposals, so the wrapper + # has to validate `dt` against the user's bounds itself. + err = try + solve(prob, TSRK("5dp"); dt = 0.5, dtmax = 0.2) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("dtmax", err.msg) + + err = try + solve(prob, TSRK("5dp"); dt = 0.1, dtmin = 0.2) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("dtmin", err.msg) + + # The valid case — `dt` inside the allowed range — still succeeds. + sol = solve( + prob, TSRK("5dp"); + dt = 0.1, dtmin = 0.05, dtmax = 0.2, save_everystep = true, + ) + @test sol.retcode == ReturnCode.Success + for d in diff(sol.t) + @test d <= 0.2 + 1e-12 + end + end + + # ── Review-6 #2 ───────────────────────────────────────────────────────── + @testset "verbose is no longer in the supported-keyword set" begin + # Previously `verbose = true` was silently accepted but unused; the + # extension now treats it like any other unsupported keyword. + err = try + solve(prob, TSRK("3bs"); dt = 0.1, verbose = true) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("verbose", err.msg) + end + + # ── Review-6 #3 ───────────────────────────────────────────────────────── + @testset "TSGeneric without explicit = true rejects euler / ssp upfront" begin + # The Julia-side validator must fire before any PETSc setup runs, so + # the regular test output stays free of raw PETSc error banners. + for ts_type in ("euler", "ssp") + err = try + solve(prob, PETSc.TSGeneric(ts_type); dt = 0.1) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("explicit = true", err.msg) + end + end end From 11abc0adb8f670e5385805b55499b918109fdf85 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 09:16:54 +0200 Subject: [PATCH 08/38] WIP --- ext/sciml/solve.jl | 86 +++++++++++++++++++++++++-------- test/sciml/test_review_fixes.jl | 65 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 19 deletions(-) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index cb833ff53..29384456f 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -72,12 +72,11 @@ function _common_ts_setup( PETSc.LibPETSc.TSSetExactFinalTime( lib, ts, PETSc.LibPETSc.TS_EXACTFINALTIME_MATCHSTEP, ) - # Validate `dt` against the SciML `dtmin` / `dtmax` bounds *before* - # installing it: PETSc applies the initial step verbatim and only - # consults `TSAdaptSetStepLimits` for subsequent step proposals, so - # silently allowing `dt = 0.5, dtmax = 0.2` would let the very first - # step violate the user-supplied bound. - _check_dt_bounds(dt, dtmin, dtmax) + # Screen all step-control input on the Julia side before touching + # PETSc: bad `dt` / `dtmin` / `dtmax` would otherwise either be + # silently accepted (for some pathological values) or fall through + # to a raw PETSc error banner. + _validate_step_control(dt, dtmin, dtmax) if dt !== nothing PETSc.LibPETSc.TSSetTimeStep(lib, ts, lib.PetscReal(dt)) end @@ -104,26 +103,75 @@ function _apply_tolerances!(lib, ts, reltol, abstol) "PETSc.jl SciML extension: only scalar `reltol` / `abstol` are " * "supported. Got types $(typeof(reltol)) / $(typeof(abstol)).", )) + _validate_tolerance(:reltol, reltol, rt) + _validate_tolerance(:abstol, abstol, at) _ts_set_scalar_tolerances!(lib, ts, at, rt) return nothing end -# Reject contradictory inputs where the user-supplied initial `dt` would -# violate the user-supplied `dtmin` / `dtmax` bounds. PETSc installs the -# initial step verbatim, so a clean `ArgumentError` is the only way to -# uphold the SciML "all steps respect the bound" contract from step one. -function _check_dt_bounds(dt, dtmin, dtmax) - dt === nothing && return nothing - if dtmax !== nothing && dt > dtmax +# Reject scalar tolerances that PETSc's `TSSetTolerances` would refuse: +# non-finite or strictly negative values. `0` is allowed (it disables that +# side of the `atol + rtol * |u|` test) so the policy is "non-negative, +# finite, real". The `user_value` argument is `nothing` when the SciML +# default kicked in, in which case there is nothing to validate. +function _validate_tolerance(name::Symbol, user_value, applied_value) + user_value === nothing && return nothing + (isfinite(applied_value) && applied_value >= 0) || throw(ArgumentError( + "PETSc.jl SciML extension: `$(name) = $(user_value)` is not a valid " * + "tolerance. Tolerances must be finite and non-negative.", + )) + return nothing +end + +# Screen `dt`, `dtmin`, and `dtmax` against PETSc's expectations before any +# of them reach the underlying TS / TSAdapt API. Catches the common +# misuse cases (negative / non-finite / contradictory bounds) at the Julia +# boundary instead of letting them fall through to raw PETSc error banners. +function _validate_step_control(dt, dtmin, dtmax) + _validate_step_size(:dt, dt; allow_zero = false) + _validate_step_size(:dtmin, dtmin; allow_zero = true) + _validate_step_size(:dtmax, dtmax; allow_zero = false) + if dtmin !== nothing && dtmax !== nothing && dtmin > dtmax throw(ArgumentError( - "PETSc.jl SciML extension: initial `dt = $dt` exceeds `dtmax = $dtmax`. " * - "Pass `dt <= dtmax`, or omit `dt` to let PETSc choose the initial step.", + "PETSc.jl SciML extension: `dtmin = $dtmin` exceeds `dtmax = $dtmax`. " * + "Adaptive step limits must satisfy `dtmin <= dtmax`.", )) end - if dtmin !== nothing && dt < dtmin - throw(ArgumentError( - "PETSc.jl SciML extension: initial `dt = $dt` is below `dtmin = $dtmin`. " * - "Pass `dt >= dtmin`, or omit `dt` to let PETSc choose the initial step.", + if dt !== nothing + if dtmax !== nothing && dt > dtmax + throw(ArgumentError( + "PETSc.jl SciML extension: initial `dt = $dt` exceeds `dtmax = $dtmax`. " * + "Pass `dt <= dtmax`, or omit `dt` to let PETSc choose the initial step.", + )) + end + if dtmin !== nothing && dt < dtmin + throw(ArgumentError( + "PETSc.jl SciML extension: initial `dt = $dt` is below `dtmin = $dtmin`. " * + "Pass `dt >= dtmin`, or omit `dt` to let PETSc choose the initial step.", + )) + end + end + return nothing +end + +function _validate_step_size(name::Symbol, value; allow_zero::Bool) + value === nothing && return nothing + value isa Real || throw(ArgumentError( + "PETSc.jl SciML extension: `$(name) = $(value)` must be a real scalar, " * + "got type $(typeof(value)).", + )) + isfinite(value) || throw(ArgumentError( + "PETSc.jl SciML extension: `$(name) = $(value)` must be finite.", + )) + if allow_zero + value < 0 && throw(ArgumentError( + "PETSc.jl SciML extension: `$(name) = $(value)` must be non-negative.", + )) + else + value > 0 || throw(ArgumentError( + "PETSc.jl SciML extension: `$(name) = $(value)` must be strictly positive. " * + "Backward integration is rejected upstream by `_check_tspan`; pass a " * + "positive step size.", )) end return nothing diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 81877d31a..fa1d4185e 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -453,4 +453,69 @@ end @test occursin("explicit = true", err.msg) end end + + # ── Review-7 #1 ───────────────────────────────────────────────────────── + @testset "Invalid dt values are rejected with ArgumentError" begin + # Negative `dt` would otherwise let a forward solve effectively step + # backward and still report Success. + @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = -0.1) + # Zero `dt` produces a degenerate trajectory under PETSc's adaptive + # controller — also reject up front. + @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = 0.0) + # Non-finite `dt` previously fell through to a raw PETSc banner. + @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = Inf) + @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = NaN) + end + + # ── Review-7 #2 ───────────────────────────────────────────────────────── + @testset "Invalid dtmin / dtmax values are rejected with ArgumentError" begin + # Inverted bounds. + err = try + solve(prob, TSRK("5dp"); dtmin = 0.2, dtmax = 0.1) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("dtmin", err.msg) + @test occursin("dtmax", err.msg) + + # Negative bounds. + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = -0.1) + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = -0.1) + + # Non-finite bounds. + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = NaN) + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = NaN) + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = Inf) + + # Zero `dtmax` must also be rejected (no positive step would satisfy + # it). Zero `dtmin` is allowed because it is the default lower bound. + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = 0.0) + sol = solve(prob, TSRK("5dp"); dt = 0.1, dtmin = 0.0) + @test sol.retcode == ReturnCode.Success + end + + # ── Review-7 #3 ───────────────────────────────────────────────────────── + @testset "Invalid reltol / abstol values are rejected with ArgumentError" begin + @test_throws ArgumentError solve( + prob, TSRK("5dp"); dt = 0.1, reltol = -1e-3, abstol = 1e-6, + ) + @test_throws ArgumentError solve( + prob, TSRK("5dp"); dt = 0.1, reltol = 1e-3, abstol = -1e-6, + ) + @test_throws ArgumentError solve( + prob, TSRK("5dp"); dt = 0.1, reltol = -1e-3, abstol = -1e-6, + ) + @test_throws ArgumentError solve( + prob, TSRK("5dp"); dt = 0.1, reltol = NaN, + ) + @test_throws ArgumentError solve( + prob, TSRK("5dp"); dt = 0.1, abstol = Inf, + ) + # Zero tolerances are allowed (matching PETSc's policy: zero on one + # side simply disables that part of the `atol + rtol * |u|` test). + sol = solve(prob, TSRK("5dp"); dt = 0.1, reltol = 0.0, abstol = 1e-6) + @test sol.retcode == ReturnCode.Success + end end From b930a5dd98e777c2a79d236768dd1e257dfa9378 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 09:54:02 +0200 Subject: [PATCH 09/38] WIP --- ext/sciml/options.jl | 30 +++++++++++++++++++- ext/sciml/solve.jl | 7 +++++ test/sciml/test_review_fixes.jl | 50 ++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index e0cd6fdf5..5290ba3d0 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -15,13 +15,40 @@ _as_time_iter(::Nothing, ::Type) = () _as_time_iter(x::Number, ::Type{T}) where {T} = (T(x),) _as_time_iter(x, ::Type{T}) where {T} = (T(t) for t in x) +# Reject saveat input that PETSc would otherwise either silently turn into +# "save nothing" (scalar `0` / `Inf` / `NaN`) or convert to a non-finite +# timestamp (iterable element `Inf` / `NaN`). Validating here keeps the +# rest of the wrapper consistent with the `dt` / `dtmin` / `dtmax` / +# tolerance validators that fail loudly at the Julia boundary. +_validate_saveat(::Nothing) = nothing +_validate_saveat(::Tuple{}) = nothing +function _validate_saveat(saveat::Number) + saveat isa Real || throw(ArgumentError( + "PETSc.jl SciML extension: scalar `saveat = $(saveat)` must be a real " * + "number, got type $(typeof(saveat)).", + )) + (isfinite(saveat) && saveat > 0) || throw(ArgumentError( + "PETSc.jl SciML extension: scalar `saveat = $(saveat)` must be finite " * + "and strictly positive (it is the save *spacing*).", + )) + return nothing +end +function _validate_saveat(saveat) + for t in saveat + (t isa Real && isfinite(t)) || throw(ArgumentError( + "PETSc.jl SciML extension: every `saveat` entry must be a finite " * + "real number, got $(repr(t)) of type $(typeof(t)).", + )) + end + return nothing +end + # SciML semantics for scalar `saveat`: it is a *spacing*, not a single time. # `saveat = 0.5` on tspan = (0.0, 2.0) saves at 0.5, 1.0, 1.5, 2.0 (subject to # save_start / save_end handling). Expand a scalar value into the appropriate # vector of timestamps in the integration direction. function _expand_saveat(saveat::Number, tdir, tspan, ::Type{T}) where {T} spacing = abs(T(saveat)) - spacing > zero(T) || return T[] t0 = T(tspan[1]) tf = T(tspan[2]) return T[t0 + tdir * spacing * k @@ -49,6 +76,7 @@ function _build_opts( t0 = tdir * tType(tspan[1]) tf = tdir * tType(tspan[2]) + _validate_saveat(saveat) saveat_expanded = _expand_saveat(saveat, tdir, tspan, tType) saveat_data = tType[tdir * t for t in saveat_expanded if t0 < tdir * t <= tf] tstops_data = tType[ diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 29384456f..a62d965d2 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -160,6 +160,13 @@ function _validate_step_size(name::Symbol, value; allow_zero::Bool) "PETSc.jl SciML extension: `$(name) = $(value)` must be a real scalar, " * "got type $(typeof(value)).", )) + # `dtmax = Inf` is the natural SciML spelling of "do not cap the step"; + # the wrapper already maps an omitted `dtmax` to `Inf` internally, so + # accepting the explicit form keeps the public API consistent with the + # internal semantics. `dt = Inf` and `dtmin = Inf` remain rejected. + if name === :dtmax && value == Inf + return nothing + end isfinite(value) || throw(ArgumentError( "PETSc.jl SciML extension: `$(name) = $(value)` must be finite.", )) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index fa1d4185e..2370426cb 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -487,7 +487,7 @@ end # Non-finite bounds. @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = NaN) @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = NaN) - @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmax = Inf) + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = Inf) # Zero `dtmax` must also be rejected (no positive step would satisfy # it). Zero `dtmin` is allowed because it is the default lower bound. @@ -518,4 +518,52 @@ end sol = solve(prob, TSRK("5dp"); dt = 0.1, reltol = 0.0, abstol = 1e-6) @test sol.retcode == ReturnCode.Success end + + # ── Review-8 #1 ───────────────────────────────────────────────────────── + @testset "Invalid scalar saveat values are rejected" begin + # Previously these silently produced an empty save schedule with a + # `Success` retcode. Now they fail loudly at the Julia boundary. + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = 0.0, + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = -0.1, + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = Inf, + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = NaN, + ) + end + + @testset "Iterable saveat with non-finite entries is rejected" begin + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = [0.25, NaN, 0.75], + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = (0.25, Inf), + ) + end + + # ── Review-8 #2 ───────────────────────────────────────────────────────── + @testset "dtmax = Inf is accepted and behaves like an omitted dtmax" begin + # `Inf` is the natural SciML spelling of "no upper cap", and the + # wrapper already uses `Inf` as the default internally. Reject the + # spelling-asymmetry the previous validator introduced. + sol_inf = solve( + prob, TSRK("5dp"); dt = 0.1, dtmax = Inf, save_everystep = true, + ) + sol_nothing = solve( + prob, TSRK("5dp"); dt = 0.1, save_everystep = true, + ) + @test sol_inf.retcode == ReturnCode.Success + @test sol_nothing.retcode == ReturnCode.Success + @test length(sol_inf.t) == length(sol_nothing.t) + + # `dt = Inf` and `dtmin = Inf` remain rejected — only `dtmax = Inf` + # has the "no cap" meaning, so only it is special-cased. + @test_throws ArgumentError solve(prob, TSRK("5dp"); dt = Inf) + @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = Inf) + end end From 5eb16a3decebc6353289ce98793b0f700ff83ef3 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 11:46:20 +0200 Subject: [PATCH 10/38] WIP --- ext/sciml/solve.jl | 41 ++++++++++++++++++++++++++ test/sciml/test_review_fixes.jl | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index a62d965d2..75f8d3d6c 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -518,11 +518,51 @@ function SciMLBase.step!(integ::PETScTSIntegrator) elseif integ.tdir * (integ.t - integ.sol.prob.tspan[2]) >= 0 integ.retcode = SciMLBase.ReturnCode.Success integ.done = true + elseif _ts_step_count(integ) >= _effective_maxiters(integ) + # PETSc's own `TSSetMaxSteps` (and the equivalent `-ts_max_steps` + # option) is honoured inside `TSSolve`, but the extension drives + # `TSStep` directly so the cap has to be enforced in this loop. + # `_effective_maxiters` takes the min of the SciML-side `maxiters` + # and PETSc's `TSGetMaxSteps`, so an algorithm-side + # `-ts_max_steps` flag is honoured too. + integ.retcode = SciMLBase.ReturnCode.MaxIters + integ.done = true end return nothing end +# Read PETSc's accepted-step count via the autowrapped getter. Wrapped in a +# helper so the `step!` and `solve!` paths both see the same value. +_ts_step_count(integ::PETScTSIntegrator) = + Int(PETSc.LibPETSc.TSGetStepNumber(integ.petsclib, integ.ts)) + +# Combine the SciML-side `maxiters` and whatever PETSc currently has stored +# as its `TSSetMaxSteps` into a single effective cap. This is what makes +# algorithm-side `-ts_max_steps` reach the manual `TSStep` loop too. +function _effective_maxiters(integ::PETScTSIntegrator) + petsc_max = Int(PETSc.LibPETSc.TSGetMaxSteps(integ.petsclib, integ.ts)) + return min(integ.opts.maxiters, petsc_max) +end + +# Pull whatever PETSc bookkeeping is meaningful for the current TS family +# back into the SciML `DEStats` object. Counters that do not apply to the +# active family (e.g. SNES iterations on an explicit RK solve) come back as +# zero, which matches their semantic value. +function _populate_stats!(integ::PETScTSIntegrator) + stats = integ.sol.stats + stats === nothing && return nothing + naccept = _ts_step_count(integ) + nreject = Int(PETSc.LibPETSc.TSGetStepRejections(integ.petsclib, integ.ts)) + nnonliniter = Int(PETSc.LibPETSc.TSGetSNESIterations(integ.petsclib, integ.ts)) + nlinearsolve = Int(PETSc.LibPETSc.TSGetKSPIterations(integ.petsclib, integ.ts)) + stats.naccept = naccept + stats.nreject = nreject + stats.nnonliniter = nnonliniter + stats.nsolve = nlinearsolve + return nothing +end + function SciMLBase.solve!(integ::PETScTSIntegrator) while !integ.done SciMLBase.step!(integ) @@ -533,6 +573,7 @@ function SciMLBase.solve!(integ::PETScTSIntegrator) push!(integ.sol.t, integ.t) push!(integ.sol.u, copy(integ.u)) end + _populate_stats!(integ) integ.sol = SciMLBase.solution_new_retcode(integ.sol, integ.retcode) _destroy_petsc!(integ) return integ.sol diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 2370426cb..c93895a72 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -546,6 +546,57 @@ end ) end + # ── Review-9 #1 ───────────────────────────────────────────────────────── + @testset "maxiters caps the manual TSStep loop and reports MaxIters" begin + # Fixed-step integration over [0, 1] with `dt = 0.1` would otherwise + # run for 10 steps; `maxiters = 1` must stop it after one accepted + # step and surface a `ReturnCode.MaxIters`. + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, adaptive = false, maxiters = 1, save_everystep = true, + ) + @test sol.retcode == ReturnCode.MaxIters + @test sol.t[end] < 1.0 + @test sol.stats.naccept >= 1 + end + + @testset "PETSc-side -ts_max_steps is honoured by the step-count cap" begin + # The cap is enforced as `min(opts.maxiters, TSGetMaxSteps(ts))`, + # so passing `-ts_max_steps 1` through `petsc_options` produces the + # same stop-early behaviour as the SciML `maxiters` knob. + alg = PETSc.TSRK("3bs", ["-ts_max_steps", "1"]) + sol = solve(prob, alg; dt = 0.1, adaptive = false, save_everystep = true) + @test sol.retcode == ReturnCode.MaxIters + @test sol.t[end] ≈ 0.1 + @test sol.stats.naccept == 1 + end + + # ── Review-9 #2 ───────────────────────────────────────────────────────── + @testset "sol.stats reflects the actual number of steps taken" begin + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, adaptive = false, save_everystep = true, + ) + @test sol.retcode == ReturnCode.Success + # Fixed-step at dt = 0.1 over [0, 1] takes 10 accepted steps. + @test sol.stats.naccept == 10 + @test sol.stats.nreject == 0 + # Explicit RK has no SNES / KSP work. + @test sol.stats.nnonliniter == 0 + @test sol.stats.nsolve == 0 + end + + @testset "Implicit solve populates SNES iteration count in stats" begin + sol = solve( + prob, TSImplicit("beuler", ["-snes_fd"]); + dt = 0.1, adaptive = false, + ) + @test sol.retcode == ReturnCode.Success + @test sol.stats.naccept == 10 + # An implicit method must do at least one nonlinear solve per step. + @test sol.stats.nnonliniter > 0 + end + # ── Review-8 #2 ───────────────────────────────────────────────────────── @testset "dtmax = Inf is accepted and behaves like an omitted dtmax" begin # `Inf` is the natural SciML spelling of "no upper cap", and the From ebe01d5c4925897fba7b0bbac8de6ea871677fdb Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 14:40:32 +0200 Subject: [PATCH 11/38] WIP --- ext/sciml/ifunction_callback.jl | 3 ++ ext/sciml/rhs_callback.jl | 3 ++ ext/sciml/solve.jl | 54 +++++++++++++++++++++++------- test/sciml/test_review_fixes.jl | 58 +++++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/ext/sciml/ifunction_callback.jl b/ext/sciml/ifunction_callback.jl index 5d174b8b9..e373b4ded 100644 --- a/ext/sciml/ifunction_callback.jl +++ b/ext/sciml/ifunction_callback.jl @@ -3,7 +3,9 @@ mutable struct IFunctionCtx{F, P, SZ, Lib} p::P sizeu::SZ petsclib::Lib + nf::Int # cumulative count of user-RHS evaluations for `sol.stats.nf` end +IFunctionCtx(f, p, sizeu, petsclib) = IFunctionCtx(f, p, sizeu, petsclib, 0) function _petsc_ifunction!( ::PETSc.LibPETSc.CTS, @@ -29,6 +31,7 @@ function _petsc_ifunction!( ctx.f(F_reshaped, u_reshaped, ctx.p, t) @. F_reshaped = udot_reshaped - F_reshaped end + ctx.nf += 1 return PETSc.LibPETSc.PetscErrorCode(0) end diff --git a/ext/sciml/rhs_callback.jl b/ext/sciml/rhs_callback.jl index cc16c0894..efe8cb6f2 100644 --- a/ext/sciml/rhs_callback.jl +++ b/ext/sciml/rhs_callback.jl @@ -3,7 +3,9 @@ mutable struct RHSCtx{F, P, SZ, Lib} p::P sizeu::SZ petsclib::Lib + nf::Int # cumulative count of user-RHS evaluations for `sol.stats.nf` end +RHSCtx(f, p, sizeu, petsclib) = RHSCtx(f, p, sizeu, petsclib, 0) function _petsc_rhs!( ::PETSc.LibPETSc.CTS, @@ -23,6 +25,7 @@ function _petsc_rhs!( ) do u_array, f_array ctx.f(reshape(f_array, ctx.sizeu), reshape(u_array, ctx.sizeu), ctx.p, t) end + ctx.nf += 1 return PETSc.LibPETSc.PetscErrorCode(0) end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 75f8d3d6c..a2b0cb7a8 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -428,10 +428,15 @@ function SciMLBase.__init( reltol, abstol, maxiters, ) + # `DEStats()` defaults every counter to `-1`, which SciML reads as + # "unknown / not reported". `_populate_stats!` overwrites the fields + # we can map accurately (`naccept`, `nreject`, `nnonliniter`, `nf`) + # before `solve!` returns; everything else stays at the sentinel so + # users can distinguish "no work happened" from "we don't track this". sol = SciMLBase.build_solution( prob, alg, tType[], typeof(u0)[]; retcode = SciMLBase.ReturnCode.Default, - stats = SciMLBase.DEStats(0), + stats = SciMLBase.DEStats(), ) try @@ -482,6 +487,15 @@ function SciMLBase.step!(integ::PETScTSIntegrator) end end + # Enforce the step cap *before* `TSStep` so `maxiters = 0` (or + # `-ts_max_steps 0`) yields a true zero-step solve instead of + # advancing once and reporting the cap afterwards. + if _ts_step_count(integ) >= _effective_maxiters(integ) + integ.retcode = SciMLBase.ReturnCode.MaxIters + integ.done = true + return nothing + end + integ.uprev .= integ.u integ.tprev = integ.t @@ -546,23 +560,39 @@ function _effective_maxiters(integ::PETScTSIntegrator) end # Pull whatever PETSc bookkeeping is meaningful for the current TS family -# back into the SciML `DEStats` object. Counters that do not apply to the -# active family (e.g. SNES iterations on an explicit RK solve) come back as -# zero, which matches their semantic value. +# back into the SciML `DEStats` object. Only counters we can map accurately +# are written; everything else stays at its `DEStats` initialiser value +# (`-1`, the SciML "unknown" sentinel — see +# `SciMLBase.DEStats(x = -1) = DEStats(x, x, ..., 0.0)`). +# +# - `naccept` ← `TSGetStepNumber` +# - `nreject` ← `TSGetStepRejections` +# - `nnonliniter` ← `TSGetSNESIterations` (zero on explicit families) +# - `nf` ← user-callback hit count maintained on the RHS / IFunction +# contexts. PETSc itself does not expose a uniform "RHS calls" +# counter, so we tally evaluations in the C-callback. +# +# `nsolve` is intentionally left at the `DEStats` sentinel: PETSc's +# `TSGetKSPIterations` returns linear *iteration* counts, while SciML's +# `nsolve` is the number of linear *solves*, so they are not equivalent. function _populate_stats!(integ::PETScTSIntegrator) stats = integ.sol.stats stats === nothing && return nothing - naccept = _ts_step_count(integ) - nreject = Int(PETSc.LibPETSc.TSGetStepRejections(integ.petsclib, integ.ts)) - nnonliniter = Int(PETSc.LibPETSc.TSGetSNESIterations(integ.petsclib, integ.ts)) - nlinearsolve = Int(PETSc.LibPETSc.TSGetKSPIterations(integ.petsclib, integ.ts)) - stats.naccept = naccept - stats.nreject = nreject - stats.nnonliniter = nnonliniter - stats.nsolve = nlinearsolve + stats.naccept = _ts_step_count(integ) + stats.nreject = Int(PETSc.LibPETSc.TSGetStepRejections(integ.petsclib, integ.ts)) + stats.nnonliniter = Int(PETSc.LibPETSc.TSGetSNESIterations(integ.petsclib, integ.ts)) + nf_total = _accumulate_nf(integ.cb_ctx) + nf_total === nothing || (stats.nf = nf_total) return nothing end +# Sum the `nf` counters across whatever shape the callback context has. +# `TSARKIMEX` with a `SplitODEProblem` exposes a `(rhs, ifunc)` named tuple +# of contexts; everything else holds a single context object. +_accumulate_nf(ctx::Union{RHSCtx, IFunctionCtx}) = ctx.nf +_accumulate_nf(ctx::NamedTuple) = sum(_accumulate_nf, values(ctx); init = 0) +_accumulate_nf(_::Any) = nothing + function SciMLBase.solve!(integ::PETScTSIntegrator) while !integ.done SciMLBase.step!(integ) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index c93895a72..abdc879da 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -581,9 +581,15 @@ end # Fixed-step at dt = 0.1 over [0, 1] takes 10 accepted steps. @test sol.stats.naccept == 10 @test sol.stats.nreject == 0 - # Explicit RK has no SNES / KSP work. + # Explicit RK has no SNES work. @test sol.stats.nnonliniter == 0 - @test sol.stats.nsolve == 0 + # `nf` is incremented in the C-callback; an explicit RK must call + # the user RHS multiple times per step (3bs has 3 stages plus FSAL), + # so the counter must be strictly positive. + @test sol.stats.nf > sol.stats.naccept + # `nsolve` is left at the SciML "unknown" sentinel because + # `TSGetKSPIterations` reports linear iterations, not solves. + @test sol.stats.nsolve == -1 end @testset "Implicit solve populates SNES iteration count in stats" begin @@ -595,6 +601,54 @@ end @test sol.stats.naccept == 10 # An implicit method must do at least one nonlinear solve per step. @test sol.stats.nnonliniter > 0 + @test sol.stats.nf > 0 + end + + # ── Review-10 #1 ──────────────────────────────────────────────────────── + @testset "maxiters = 0 yields a zero-step solve, not one step" begin + # The cap is now checked *before* `TSStep`, so `maxiters = 0` must + # not advance time, regardless of what `dt` would otherwise produce. + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, adaptive = false, maxiters = 0, save_everystep = true, + ) + @test sol.retcode == ReturnCode.MaxIters + @test sol.t[end] == 0.0 + @test sol.stats.naccept == 0 + + # Even when `dt` is large enough that one step would land on `tf`, + # the cap must still pre-empt the step and report MaxIters. + sol_one_shot = solve( + prob, TSRK("3bs"); + dt = 1.0, adaptive = false, maxiters = 0, save_everystep = true, + ) + @test sol_one_shot.retcode == ReturnCode.MaxIters + @test sol_one_shot.t[end] == 0.0 + @test sol_one_shot.stats.naccept == 0 + end + + @testset "petsc_options -ts_max_steps 0 yields a zero-step solve" begin + alg = PETSc.TSRK("3bs", ["-ts_max_steps", "0"]) + sol = solve(prob, alg; dt = 0.1, adaptive = false, save_everystep = true) + @test sol.retcode == ReturnCode.MaxIters + @test sol.t[end] == 0.0 + @test sol.stats.naccept == 0 + end + + # ── Review-10 #2 ──────────────────────────────────────────────────────── + @testset "Unsupported DEStats fields stay at the SciML \"unknown\" sentinel" begin + sol = solve(prob, TSRK("3bs"); dt = 0.1, adaptive = false) + # We populate `naccept`, `nreject`, `nnonliniter`, and `nf`. Every + # other counter should remain at `-1` so users can distinguish + # "we don't track this" from "no work happened". + @test sol.stats.naccept == 10 + @test sol.stats.nreject == 0 + @test sol.stats.nnonliniter == 0 + @test sol.stats.nf > 0 + @test sol.stats.nsolve == -1 + @test sol.stats.nf2 == -1 + @test sol.stats.nw == -1 + @test sol.stats.njacs == -1 end # ── Review-8 #2 ───────────────────────────────────────────────────────── From f56fd46c115cfd87ba1e54b102299a7a2d4faf5b Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 20:47:31 +0200 Subject: [PATCH 12/38] WIP --- ext/sciml/solve.jl | 18 ++++++++++++++++++ test/sciml/test_review_fixes.jl | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index a2b0cb7a8..60ff5d019 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -154,6 +154,23 @@ function _validate_step_control(dt, dtmin, dtmax) return nothing end +# Reject negative SciML `maxiters`. With the manual `TSStep` loop, a +# negative cap would otherwise immediately exhaust and turn into a +# zero-step `MaxIters` solve, which mismatches PETSc's own +# `-ts_max_steps -1 = unlimited` interpretation. Keep `maxiters = 0` +# valid (it is the documented "zero-step solve" case from Review-10). +function _validate_maxiters(maxiters) + maxiters < 0 && throw(ArgumentError( + "PETSc.jl SciML extension: `maxiters = $(maxiters)` must be " * + "non-negative. PETSc's `-ts_max_steps -1` means \"unlimited\", but " * + "the SciML wrapper enforces the cap in its own loop, so a negative " * + "value would be a zero-step solve. Use `maxiters = 0` if that is " * + "what you want, or a large positive integer for an effective " * + "no-cap solve.", + )) + return nothing +end + function _validate_step_size(name::Symbol, value; allow_zero::Bool) value === nothing && return nothing value isa Real || throw(ArgumentError( @@ -399,6 +416,7 @@ function SciMLBase.__init( kwargs..., ) _reject_unsupported_kwargs(kwargs) + _validate_maxiters(maxiters) cb_set = DiffEqBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) throw(ArgumentError( diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index abdc879da..e0c4d7924 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -635,6 +635,28 @@ end @test sol.stats.naccept == 0 end + # ── Review-11 #1 ──────────────────────────────────────────────────────── + @testset "Negative maxiters is rejected with ArgumentError" begin + # Without explicit validation, a negative SciML `maxiters` would + # turn the manual `TSStep` loop into a zero-step `MaxIters` solve — + # but PETSc's `-ts_max_steps -1` means "unlimited", so the two + # spellings would disagree. Reject the negative SciML form so the + # asymmetry can never bite. + err = try + solve(prob, TSRK("3bs"); dt = 0.1, maxiters = -1) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("maxiters", err.msg) + + # `maxiters = 0` remains a valid zero-step solve (Review-10). + sol = solve(prob, TSRK("3bs"); dt = 0.1, adaptive = false, maxiters = 0) + @test sol.retcode == ReturnCode.MaxIters + @test sol.stats.naccept == 0 + end + # ── Review-10 #2 ──────────────────────────────────────────────────────── @testset "Unsupported DEStats fields stay at the SciML \"unknown\" sentinel" begin sol = solve(prob, TSRK("3bs"); dt = 0.1, adaptive = false) From f2c5f99784d186e83a821e6d69e54739f494bc8d Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 20:59:48 +0200 Subject: [PATCH 13/38] WIP --- ext/sciml/solve.jl | 33 ++++++++++++++++++++++----------- test/sciml/test_review_fixes.jl | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 60ff5d019..c59086da2 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -586,9 +586,12 @@ end # - `naccept` ← `TSGetStepNumber` # - `nreject` ← `TSGetStepRejections` # - `nnonliniter` ← `TSGetSNESIterations` (zero on explicit families) -# - `nf` ← user-callback hit count maintained on the RHS / IFunction -# contexts. PETSc itself does not expose a uniform "RHS calls" -# counter, so we tally evaluations in the C-callback. +# - `nf` / `nf2` ← user-callback hit counts maintained on the +# RHS / IFunction contexts. PETSc itself does not expose a uniform +# "RHS calls" counter, so we tally evaluations in the C-callback. +# For split IMEX problems the implicit (`ifunc`) stream lands in +# `nf` and the explicit (`rhs`) stream in `nf2`, matching SciML +# convention. # # `nsolve` is intentionally left at the `DEStats` sentinel: PETSc's # `TSGetKSPIterations` returns linear *iteration* counts, while SciML's @@ -599,17 +602,25 @@ function _populate_stats!(integ::PETScTSIntegrator) stats.naccept = _ts_step_count(integ) stats.nreject = Int(PETSc.LibPETSc.TSGetStepRejections(integ.petsclib, integ.ts)) stats.nnonliniter = Int(PETSc.LibPETSc.TSGetSNESIterations(integ.petsclib, integ.ts)) - nf_total = _accumulate_nf(integ.cb_ctx) - nf_total === nothing || (stats.nf = nf_total) + _populate_nf!(stats, integ.cb_ctx) return nothing end -# Sum the `nf` counters across whatever shape the callback context has. -# `TSARKIMEX` with a `SplitODEProblem` exposes a `(rhs, ifunc)` named tuple -# of contexts; everything else holds a single context object. -_accumulate_nf(ctx::Union{RHSCtx, IFunctionCtx}) = ctx.nf -_accumulate_nf(ctx::NamedTuple) = sum(_accumulate_nf, values(ctx); init = 0) -_accumulate_nf(_::Any) = nothing +# Single-stream callback contexts: the user RHS is the only function being +# called, so all evaluations roll up into `nf`. +_populate_nf!(stats, ctx::Union{RHSCtx, IFunctionCtx}) = (stats.nf = ctx.nf; nothing) + +# Split IMEX context: keep the implicit / explicit streams separate so +# users can distinguish stiff vs. non-stiff function work. This matches +# upstream OrdinaryDiffEq, which uses `nf` for `f1` and `nf2` for `f2` on +# `SplitODEProblem`s. +function _populate_nf!(stats, ctx::NamedTuple) + haskey(ctx, :ifunc) && (stats.nf = ctx.ifunc.nf) + haskey(ctx, :rhs) && (stats.nf2 = ctx.rhs.nf) + return nothing +end + +_populate_nf!(_, _) = nothing function SciMLBase.solve!(integ::PETScTSIntegrator) while !integ.done diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index e0c4d7924..e9c0e6065 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -7,6 +7,7 @@ ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing const TSRK = ext.TSRK const TSImplicit = ext.TSImplicit +const TSARKIMEX = ext.TSARKIMEX function decay!(du, u, p, t) du[1] = -u[1] @@ -635,6 +636,22 @@ end @test sol.stats.naccept == 0 end + # ── Review-12 #1 ──────────────────────────────────────────────────────── + @testset "TSARKIMEX with SplitODEProblem populates both nf and nf2" begin + # The implicit (`f1`) stream should land in `stats.nf`; the + # explicit (`f2`) stream should land in `stats.nf2`. Previously + # `_populate_stats!` summed both into `nf` and left `nf2` at the + # SciML "unknown" sentinel. + f1!(du, u, p, t) = (du[1] = -u[1]; nothing) # implicit / stiff + f2!(du, u, p, t) = (du[1] = cos(t); nothing) # explicit + prob_split = SplitODEProblem(f1!, f2!, [1.0], (0.0, 1.0)) + sol = solve(prob_split, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.1, adaptive = false) + @test sol.retcode == ReturnCode.Success + @test sol.stats.nf > 0 + @test sol.stats.nf2 > 0 + @test sol.stats.naccept == 10 + end + # ── Review-11 #1 ──────────────────────────────────────────────────────── @testset "Negative maxiters is rejected with ArgumentError" begin # Without explicit validation, a negative SciML `maxiters` would From 16998b6a8708648675889a1df8a14b2428753d5f Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 21:10:19 +0200 Subject: [PATCH 14/38] WIP --- README.md | 2 +- ext/sciml/options.jl | 26 +++++++++++++-- ext/sciml/solve.jl | 21 +++++++++--- test/sciml/test_callbacks.jl | 17 +++++++--- test/sciml/test_review_fixes.jl | 59 +++++++++++++++++++++++++++++---- 5 files changed, 105 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 62f48c35b..913b5b057 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!(integrator); sol = solve!(integrator) ``` -Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; `ContinuousCallback`s are rejected with `ArgumentError`, and `tstops` is warned about and ignored. Standard SciML control knobs `adaptive`, `dtmin`, and `dtmax` are mapped onto PETSc's `TSAdapt` controller; unrecognized solve keywords are rejected with a clear `ArgumentError` rather than silently dropped. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. +Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; `ContinuousCallback`s and `tstops` are rejected with `ArgumentError` so silently-skipped exact-time callbacks cannot bite users. Standard SciML control knobs `adaptive`, `dtmin`, and `dtmax` are mapped onto PETSc's `TSAdapt` controller; unrecognized solve keywords are rejected with a clear `ArgumentError` rather than silently dropped. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. Known callback-lifecycle gaps (compared to OrdinaryDiffEq): diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index 5290ba3d0..59f3c126d 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -15,6 +15,24 @@ _as_time_iter(::Nothing, ::Type) = () _as_time_iter(x::Number, ::Type{T}) where {T} = (T(x),) _as_time_iter(x, ::Type{T}) where {T} = (T(t) for t in x) +# Materialize iterable `saveat` / `tstops` input into a concrete `Vector` +# *exactly once* up front. The wrapper then validates and filters that +# materialized data, so one-shot iterators (e.g. `Iterators.Stateful`) +# survive validation instead of being consumed before +# `_expand_saveat` / `_as_time_iter` can read them again. +_materialize_times(::Nothing) = nothing +_materialize_times(x::Number) = x +_materialize_times(x::AbstractVector) = x +_materialize_times(x::Tuple) = x +_materialize_times(x) = collect(x) + +# Empty-check that works on both `Nothing` and any materialized iterable +# without consuming a stateful iterator. Use `_materialize_times` upstream +# so callers always pass a concrete container here. +_is_empty_times(::Nothing) = true +_is_empty_times(x::Number) = false +_is_empty_times(x) = isempty(x) + # Reject saveat input that PETSc would otherwise either silently turn into # "save nothing" (scalar `0` / `Inf` / `NaN`) or convert to a non-finite # timestamp (iterable element `Inf` / `NaN`). Validating here keeps the @@ -76,11 +94,13 @@ function _build_opts( t0 = tdir * tType(tspan[1]) tf = tdir * tType(tspan[2]) - _validate_saveat(saveat) - saveat_expanded = _expand_saveat(saveat, tdir, tspan, tType) + saveat_materialized = _materialize_times(saveat) + tstops_materialized = _materialize_times(tstops) + _validate_saveat(saveat_materialized) + saveat_expanded = _expand_saveat(saveat_materialized, tdir, tspan, tType) saveat_data = tType[tdir * t for t in saveat_expanded if t0 < tdir * t <= tf] tstops_data = tType[ - tdir * t for t in _as_time_iter(tstops, tType) if t0 < tdir * t <= tf + tdir * t for t in _as_time_iter(tstops_materialized, tType) if t0 < tdir * t <= tf ] push!(tstops_data, tf) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index c59086da2..17ae7e694 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -433,10 +433,23 @@ function SciMLBase.__init( adaptive, dtmin, dtmax, ) - if !isempty(_as_time_iter(tstops, Float64)) - @warn "PETSc.jl SciML extension: `tstops` is not yet honoured. " * - "PETSc adapts step sizes internally; pass `dt` and " * - "`-ts_adapt_type none` via `petsc_options` to force fixed steps." + # `tstops` is documented as a SciML contract for "the integrator must + # land exactly on these times so step-end callback logic can see them", + # which means silently ignoring it can skip exact-time discrete + # callbacks. Reject it up front until the manual `TSStep` loop honours + # it, so users get a loud error rather than a silently wrong solve. + # Materialize the iterable first so a one-shot iterator is never + # consumed by the non-empty check. + tstops_materialized = _materialize_times(tstops) + if !_is_empty_times(tstops_materialized) + throw(ArgumentError( + "PETSc.jl SciML extension: `tstops` is not yet honoured. " * + "Accepting it silently would risk skipping exact-time discrete " * + "callbacks. Either remove the `tstops` keyword, or use " * + "PETSc's `TSSetEventHandler` directly. To force fixed-step " * + "integration, pass `adaptive = false` (or " * + "`-ts_adapt_type none` via the algorithm's `petsc_options`).", + )) end opts = _build_opts( diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index 88e66a30b..5465a074f 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -107,10 +107,17 @@ end ) end - @testset "tstops kwarg emits a warning and is ignored" begin - sol = @test_logs (:warn, r"tstops") solve( - prob, TSRK("3bs"); dt = 0.1, tstops = [0.4, 0.6], - ) - @test sol.retcode == ReturnCode.Success + @testset "tstops kwarg is rejected with ArgumentError" begin + # Silently ignoring `tstops` could skip exact-time discrete + # callbacks, so the wrapper now refuses it until the manual + # `TSStep` loop can honour it. + err = try + solve(prob, TSRK("3bs"); dt = 0.1, tstops = [0.4, 0.6]) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("tstops", err.msg) end end diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index e9c0e6065..d7e83e31f 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -636,20 +636,65 @@ end @test sol.stats.naccept == 0 end + # ── Review-13 #1 ──────────────────────────────────────────────────────── + @testset "tstops kwarg is rejected with ArgumentError" begin + # `tstops` carries a strict SciML contract — the integrator must + # land on those times so step-end callback logic can see them. + # Silently ignoring it would skip exact-time discrete callbacks, + # so the wrapper rejects it up front until the manual `TSStep` + # loop honours it natively. + err = try + solve(prob, TSRK("3bs"); dt = 0.1, tstops = [0.4, 0.6]) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("tstops", err.msg) + end + + # ── Review-13 #2 ──────────────────────────────────────────────────────── + @testset "Stateful saveat iterators survive validation" begin + # `Iterators.Stateful` is a one-shot iterator: a previous version + # of the wrapper validated `saveat` by iterating it once, which + # consumed the iterator before `_expand_saveat` could read it + # again. We now materialize iterables to a `Vector` exactly once + # so this case round-trips correctly. + saveit = Iterators.Stateful([0.25, 0.5, 0.75]) + sol = solve( + prob, TSRK("3bs"); + dt = 0.1, saveat = saveit, + save_start = false, save_end = false, + ) + @test sol.retcode == ReturnCode.Success + @test all(t -> any(s -> isapprox(s, t; atol = 1e-12), sol.t), + (0.25, 0.5, 0.75)) + end + # ── Review-12 #1 ──────────────────────────────────────────────────────── @testset "TSARKIMEX with SplitODEProblem populates both nf and nf2" begin # The implicit (`f1`) stream should land in `stats.nf`; the - # explicit (`f2`) stream should land in `stats.nf2`. Previously - # `_populate_stats!` summed both into `nf` and left `nf2` at the - # SciML "unknown" sentinel. - f1!(du, u, p, t) = (du[1] = -u[1]; nothing) # implicit / stiff - f2!(du, u, p, t) = (du[1] = cos(t); nothing) # explicit + # explicit (`f2`) stream should land in `stats.nf2`. We pin the + # mapping by counting `f1` and `f2` calls in user closures and + # asserting `stats.nf == f1_calls[]` and `stats.nf2 == f2_calls[]`, + # so a future regression that swaps the two streams is caught. + f1_calls = Ref(0) + f2_calls = Ref(0) + f1!(du, u, p, t) = (f1_calls[] += 1; du[1] = -u[1]; nothing) + f2!(du, u, p, t) = (f2_calls[] += 1; du[1] = cos(t); nothing) prob_split = SplitODEProblem(f1!, f2!, [1.0], (0.0, 1.0)) sol = solve(prob_split, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.1, adaptive = false) @test sol.retcode == ReturnCode.Success - @test sol.stats.nf > 0 - @test sol.stats.nf2 > 0 @test sol.stats.naccept == 10 + @test f1_calls[] > 0 + @test f2_calls[] > 0 + # The promised mapping: `nf` <- f1 (implicit), `nf2` <- f2 (explicit). + @test sol.stats.nf == f1_calls[] + @test sol.stats.nf2 == f2_calls[] + # And the two streams must produce different totals here, so a + # broken implementation that wrote the same value into both fields + # would be caught. + @test f1_calls[] != f2_calls[] end # ── Review-11 #1 ──────────────────────────────────────────────────────── From bdda94f6bc26f285bb91c204049cbaeca470dcce Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 27 Apr 2026 21:22:22 +0200 Subject: [PATCH 15/38] WIP --- README.md | 2 +- ext/sciml/solve.jl | 70 +++++++++++++++++++++++---------- test/sciml/test_review_fixes.jl | 41 +++++++++++++++++++ 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 913b5b057..eedea64ad 100644 --- a/README.md +++ b/README.md @@ -77,5 +77,5 @@ Per-solver PETSc command-line options are passed on the algorithm itself (a `Vec Known callback-lifecycle gaps (compared to OrdinaryDiffEq): -- The SciML *discrete-save* hooks (`SciMLBase.save_discretes_if_enabled!`, `SciMLBase.save_final_discretes!`) are **not** invoked. Callback machinery that relies on saving observable state alongside the trajectory will not interoperate; only `affect!`-style callbacks that mutate `u` or call `terminate!` are exercised. +- The SciML *discrete-save* lifecycle hooks `SciMLBase.save_discretes_if_enabled!` (after `initialize!`) and `SciMLBase.save_final_discretes!` (after `finalize!`) are now invoked when the loaded SciMLBase version provides them, so `DiscreteCallback` machinery that records observable state at `t0` and at the end of the solve interoperates with the standard SciML lifecycle. Callbacks that depend on per-step `save_discretes_if_enabled!` calls *during* the solve are still not driven by the manual `TSStep` loop. - `ContinuousCallback`s are rejected with `ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 17ae7e694..cd41864e5 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -389,6 +389,14 @@ function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) push!(integ.sol.t, integ.t) push!(integ.sol.u, copy(integ.u)) end + # Forward the SciML discrete-save lifecycle hook so callbacks that + # populate observable timeseries (MTK-style `saved_clock_partitions`, + # `initialize_save_discretes`) get their `t0` snapshot. `skip_duplicates` + # mirrors what OrdinaryDiffEq passes here. The hook is only available + # in newer SciMLBase releases, so guard with `isdefined`. + if isdefined(SciMLBase, :save_discretes_if_enabled!) + SciMLBase.save_discretes_if_enabled!(integ, cb_set; skip_duplicates = true) + end return nothing end @@ -415,6 +423,11 @@ function SciMLBase.__init( petsclib = nothing, kwargs..., ) + # Pure Julia validation that does not need PETSc state runs first, so + # bad input fails before the wrapper allocates any PETSc TS / Vec + # handles. Otherwise the cleanup `try` further down would never see + # those exceptions and PETSc objects would leak (the autowrapped `TS` + # has no finalizer of its own). _reject_unsupported_kwargs(kwargs) _validate_maxiters(maxiters) cb_set = DiffEqBase.CallbackSet(callback) @@ -428,11 +441,6 @@ function SciMLBase.__init( )) end - (lib, ts, u_v, u0, tType, t0, tdir) = _common_ts_setup( - prob, dt, maxiters, petsclib, reltol, abstol, - adaptive, dtmin, dtmax, - ) - # `tstops` is documented as a SciML contract for "the integrator must # land exactly on these times so step-end callback logic can see them", # which means silently ignoring it can skip exact-time discrete @@ -452,25 +460,39 @@ function SciMLBase.__init( )) end - opts = _build_opts( - tType, saveat, tstops, tdir, prob.tspan; - save_everystep, save_on, save_start, save_end, - callback = cb_set, - reltol, abstol, maxiters, - ) + # Pre-validate `saveat` here too. `_build_opts` would otherwise throw + # on bad `saveat` only after `_common_ts_setup` had already allocated + # PETSc objects that have no finalizer of their own. Materialize the + # iterable exactly once so a stateful iterator survives both this + # validation pass *and* `_build_opts`'s own consumption. + saveat_materialized = _materialize_times(saveat) + _validate_saveat(saveat_materialized) - # `DEStats()` defaults every counter to `-1`, which SciML reads as - # "unknown / not reported". `_populate_stats!` overwrites the fields - # we can map accurately (`naccept`, `nreject`, `nnonliniter`, `nf`) - # before `solve!` returns; everything else stays at the sentinel so - # users can distinguish "no work happened" from "we don't track this". - sol = SciMLBase.build_solution( - prob, alg, tType[], typeof(u0)[]; - retcode = SciMLBase.ReturnCode.Default, - stats = SciMLBase.DEStats(), + (lib, ts, u_v, u0, tType, t0, tdir) = _common_ts_setup( + prob, dt, maxiters, petsclib, reltol, abstol, + adaptive, dtmin, dtmax, ) try + opts = _build_opts( + tType, saveat_materialized, tstops_materialized, tdir, prob.tspan; + save_everystep, save_on, save_start, save_end, + callback = cb_set, + reltol, abstol, maxiters, + ) + + # `DEStats()` defaults every counter to `-1`, which SciML reads as + # "unknown / not reported". `_populate_stats!` overwrites the + # fields we can map accurately (`naccept`, `nreject`, + # `nnonliniter`, `nf`) before `solve!` returns; everything else + # stays at the sentinel so users can distinguish "no work + # happened" from "we don't track this". + sol = SciMLBase.build_solution( + prob, alg, tType[], typeof(u0)[]; + retcode = SciMLBase.ReturnCode.Default, + stats = SciMLBase.DEStats(), + ) + cb_ctx = _setup_petsc_algorithm!(lib, ts, prob, u0, alg) _setfromoptions!(lib, ts, alg.petsc_options) @@ -645,6 +667,14 @@ function SciMLBase.solve!(integ::PETScTSIntegrator) push!(integ.sol.t, integ.t) push!(integ.sol.u, copy(integ.u)) end + # Forward the SciML discrete-save lifecycle hook so callbacks that + # populate observable timeseries get their final snapshot. Mirrors + # OrdinaryDiffEq's end-of-solve sequence: `finalize!` first, then the + # final discrete save. The hook is only available in newer SciMLBase + # releases, so guard with `isdefined`. + if isdefined(SciMLBase, :save_final_discretes!) + SciMLBase.save_final_discretes!(integ, integ.opts.callback) + end _populate_stats!(integ) integ.sol = SciMLBase.solution_new_retcode(integ.sol, integ.retcode) _destroy_petsc!(integ) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index d7e83e31f..31e4eee61 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -636,6 +636,47 @@ end @test sol.stats.naccept == 0 end + # ── Review-14 #1 ──────────────────────────────────────────────────────── + @testset "Front-end validation runs before any PETSc TS allocation" begin + # Both `tstops` and bad `saveat` should now fail before + # `_common_ts_setup` allocates a `TS` / `Vec` pair, so repeated + # invalid solves cannot accumulate live PETSc state. + for _ in 1:5 + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, tstops = [0.4], + ) + @test_throws ArgumentError solve( + prob, TSRK("3bs"); dt = 0.1, saveat = NaN, + ) + end + # Subsequent valid solve still succeeds — proves nothing leaked + # leaves the PETSc state in a bad shape. + sol = solve(prob, TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + end + + # ── Review-14 #2 ──────────────────────────────────────────────────────── + @testset "SciML discrete-save hooks fire at init and end of solve" begin + # `save_discretes_if_enabled!` and `save_final_discretes!` are now + # called around the lifecycle. Verify with an `initialize` / + # `finalize` callback pair that increments separate counters; the + # exact upstream save logic depends on MTK metadata that this + # extension does not synthesise, so we just check the lifecycle + # hooks reach the integrator. + init_runs = Ref(0) + finalize_runs = Ref(0) + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = (cb, u, t, integ) -> (init_runs[] += 1; nothing), + finalize = (cb, u, t, integ) -> (finalize_runs[] += 1; nothing), + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + @test init_runs[] == 1 + @test finalize_runs[] == 1 + end + # ── Review-13 #1 ──────────────────────────────────────────────────────── @testset "tstops kwarg is rejected with ArgumentError" begin # `tstops` carries a strict SciML contract — the integrator must From 2c500b8bda10e3363d50bf104ec5e75aea1f601b Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Tue, 28 Apr 2026 18:06:05 +0200 Subject: [PATCH 16/38] WIP --- ext/sciml/helpers.jl | 2 +- ext/sciml/solve.jl | 18 +++++--- test/sciml/test_review_fixes.jl | 78 ++++++++++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index 14030015b..417cfa92e 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -65,7 +65,7 @@ end # so any unsupported key fails loudly with a clear, named error. const _SUPPORTED_SCIML_KWARGS = ( :save_everystep, :save_on, :save_start, :save_end, - :saveat, :tstops, :callback, + :saveat, :tstops, :callback, :initialize_save, :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, :maxiters, :petsclib, diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index cd41864e5..2f0b7fa8f 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -375,7 +375,9 @@ end # `save_positions[2]` (the post-event side, which corresponds to "after the # callback ran"). Duplicate suppression in `step!` keeps `t0` from being # recorded twice when `save_start = true`. -function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) +function initialize_callbacks!( + integ::PETScTSIntegrator, cb_set, initialize_save::Bool = true, +) integ.u_modified = true DiffEqBase.initialize!(cb_set, integ.u, integ.t, integ) if integ.u_modified @@ -384,7 +386,11 @@ function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) integ.u_modified = false end - if integ.opts.save_on && _any_initialize_save(cb_set) && + # `initialize_save = false` is the upstream SciML knob for "run callback + # `initialize` hooks but do not append a post-init save record". Honour + # both that gate and `save_on` here, mirroring OrdinaryDiffEq's + # `initialize_callbacks!(integrator, initialize_save)` flow. + if initialize_save && integ.opts.save_on && _any_initialize_save(cb_set) && (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) push!(integ.sol.t, integ.t) push!(integ.sol.u, copy(integ.u)) @@ -393,8 +399,9 @@ function initialize_callbacks!(integ::PETScTSIntegrator, cb_set) # populate observable timeseries (MTK-style `saved_clock_partitions`, # `initialize_save_discretes`) get their `t0` snapshot. `skip_duplicates` # mirrors what OrdinaryDiffEq passes here. The hook is only available - # in newer SciMLBase releases, so guard with `isdefined`. - if isdefined(SciMLBase, :save_discretes_if_enabled!) + # in newer SciMLBase releases, so guard with `isdefined`. Skip it + # entirely when the user opted out via `initialize_save = false`. + if initialize_save && isdefined(SciMLBase, :save_discretes_if_enabled!) SciMLBase.save_discretes_if_enabled!(integ, cb_set; skip_duplicates = true) end return nothing @@ -413,6 +420,7 @@ function SciMLBase.__init( saveat = (), tstops = (), callback = nothing, + initialize_save::Bool = true, reltol = nothing, abstol = nothing, dt = nothing, @@ -501,7 +509,7 @@ function SciMLBase.__init( opts, sol, lib, ts, u_v, cb_ctx, ) - initialize_callbacks!(integ, cb_set) + initialize_callbacks!(integ, cb_set, initialize_save) return integ catch diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 31e4eee61..018d8358f 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -656,13 +656,10 @@ end end # ── Review-14 #2 ──────────────────────────────────────────────────────── - @testset "SciML discrete-save hooks fire at init and end of solve" begin - # `save_discretes_if_enabled!` and `save_final_discretes!` are now - # called around the lifecycle. Verify with an `initialize` / - # `finalize` callback pair that increments separate counters; the - # exact upstream save logic depends on MTK metadata that this - # extension does not synthesise, so we just check the lifecycle - # hooks reach the integrator. + @testset "Callback initialize / finalize hooks fire around the solve" begin + # The lifecycle calls (`DiffEqBase.initialize!` / + # `DiffEqBase.finalize!`) are part of the basic discrete-callback + # contract and must run regardless of SciMLBase version. init_runs = Ref(0) finalize_runs = Ref(0) cb = DiscreteCallback( @@ -677,6 +674,73 @@ end @test finalize_runs[] == 1 end + # The new discrete-save lifecycle hooks + # (`SciMLBase.save_discretes_if_enabled!` / `save_final_discretes!`) + # only exist in newer SciMLBase releases. The wrapper guards them with + # `isdefined`; mirror that gate here so the test file describes what + # is actually being verified on the loaded compat set. + if isdefined(SciMLBase, :save_discretes_if_enabled!) && + isdefined(SciMLBase, :save_final_discretes!) + @testset "SciMLBase discrete-save lifecycle hooks are forwarded" begin + init_runs = Ref(0) + finalize_runs = Ref(0) + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = (cb, u, t, integ) -> (init_runs[] += 1; nothing), + finalize = (cb, u, t, integ) -> (finalize_runs[] += 1; nothing), + ) + sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + @test sol.retcode == ReturnCode.Success + @test init_runs[] == 1 + @test finalize_runs[] == 1 + end + else + @info "Skipping `SciMLBase discrete-save lifecycle hooks are forwarded`: " * + "loaded SciMLBase does not expose `save_discretes_if_enabled!` / " * + "`save_final_discretes!`." + end + + # ── Review-15 #1 ──────────────────────────────────────────────────────── + @testset "initialize_save = false suppresses the post-init save record" begin + # `initialize_save` is the upstream SciML knob that controls + # whether the integrator appends a save record immediately after + # callback `initialize!` runs. With it `false`, the `initialize` + # hook still fires but no `t0` row is added on top of the regular + # `save_start` row. With it `true` (the default), the row is added + # exactly once even on top of `save_start = true`. + init_ran = Ref(false) + # A discrete callback with default `save_positions = (true, true)` + # asks for a post-init save row. Silence everything else so we can + # observe whether the post-init save fired. + cb = DiscreteCallback( + (u, t, integ) -> false, + integ -> nothing; + initialize = (cb, u, t, integ) -> (init_ran[] = true; nothing), + ) + sol_off = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_start = false, save_end = false, + initialize_save = false, + ) + @test sol_off.retcode == ReturnCode.Success + @test init_ran[] # the `initialize` hook still fires + @test isempty(sol_off.t) # but the post-init save was suppressed + + # Default `initialize_save = true` keeps the post-init save row. + init_ran[] = false + sol_on = solve( + prob, TSRK("3bs"); + dt = 0.1, callback = cb, + save_start = false, save_end = false, + ) + @test sol_on.retcode == ReturnCode.Success + @test init_ran[] + @test length(sol_on.t) == 1 + @test sol_on.t[1] ≈ 0.0 + end + # ── Review-13 #1 ──────────────────────────────────────────────────────── @testset "tstops kwarg is rejected with ArgumentError" begin # `tstops` carries a strict SciML contract — the integrator must From dc61f93b573ee67a2ee25bc4ab0b56ad7a6867c7 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Tue, 28 Apr 2026 20:24:22 +0200 Subject: [PATCH 17/38] WIP --- ext/sciml/helpers.jl | 2 +- ext/sciml/options.jl | 3 ++ ext/sciml/solve.jl | 3 +- test/sciml/test_review_fixes.jl | 58 ++++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index 417cfa92e..d6f6137db 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -64,7 +64,7 @@ end # `dtmin` / `progress` would silently break the usual SciML solver contract, # so any unsupported key fails loudly with a clear, named error. const _SUPPORTED_SCIML_KWARGS = ( - :save_everystep, :save_on, :save_start, :save_end, + :save_everystep, :save_on, :save_start, :save_end, :save_discretes, :saveat, :tstops, :callback, :initialize_save, :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index 59f3c126d..396555314 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -5,6 +5,7 @@ mutable struct DEOptions{SavT, TstopsT, CType, reltolType, abstolType} save_on::Bool save_start::Bool save_end::Bool + save_discretes::Bool callback::CType reltol::reltolType abstol::abstolType @@ -86,6 +87,7 @@ function _build_opts( save_on::Bool, save_start::Bool, save_end::Bool, + save_discretes::Bool, callback, reltol, abstol, @@ -114,6 +116,7 @@ function _build_opts( save_on, save_start, save_end, + save_discretes, callback, reltol, abstol, diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 2f0b7fa8f..3ee2c7e9d 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -417,6 +417,7 @@ function SciMLBase.__init( save_on::Bool = true, save_start::Bool = true, save_end::Bool = true, + save_discretes::Bool = true, saveat = (), tstops = (), callback = nothing, @@ -484,7 +485,7 @@ function SciMLBase.__init( try opts = _build_opts( tType, saveat_materialized, tstops_materialized, tdir, prob.tspan; - save_everystep, save_on, save_start, save_end, + save_everystep, save_on, save_start, save_end, save_discretes, callback = cb_set, reltol, abstol, maxiters, ) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 018d8358f..a6ec72197 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -9,6 +9,28 @@ const TSRK = ext.TSRK const TSImplicit = ext.TSImplicit const TSARKIMEX = ext.TSARKIMEX +# Counters for hook-forwarding tests. The shim methods below are more specific +# than the SciMLBase generics and shadow them for `PETScTSIntegrator`, so the +# tests can count actual invocations of the SciML hook API rather than relying +# on callback lifecycle counters that would pass even if the hooks were removed. +const _save_discretes_hook_count = Ref(0) +const _save_final_discretes_hook_count = Ref(0) +if isdefined(SciMLBase, :save_discretes_if_enabled!) && + isdefined(SciMLBase, :save_final_discretes!) + function SciMLBase.save_discretes_if_enabled!( + ::ext.PETScTSIntegrator, ::SciMLBase.CallbackSet; kw... + ) + _save_discretes_hook_count[] += 1 + return nothing + end + function SciMLBase.save_final_discretes!( + ::ext.PETScTSIntegrator, ::SciMLBase.CallbackSet; kw... + ) + _save_final_discretes_hook_count[] += 1 + return nothing + end +end + function decay!(du, u, p, t) du[1] = -u[1] return nothing @@ -682,18 +704,17 @@ end if isdefined(SciMLBase, :save_discretes_if_enabled!) && isdefined(SciMLBase, :save_final_discretes!) @testset "SciMLBase discrete-save lifecycle hooks are forwarded" begin - init_runs = Ref(0) - finalize_runs = Ref(0) - cb = DiscreteCallback( - (u, t, integ) -> false, - integ -> nothing; - initialize = (cb, u, t, integ) -> (init_runs[] += 1; nothing), - finalize = (cb, u, t, integ) -> (finalize_runs[] += 1; nothing), - ) - sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) + # Reset the shim counters defined at file scope. The shims are more + # specific than the SciMLBase generics, so they intercept the exact + # call sites in solve.jl and let us pin the forwarding path directly. + _save_discretes_hook_count[] = 0 + _save_final_discretes_hook_count[] = 0 + sol = solve(prob, TSRK("3bs"); dt = 0.1) @test sol.retcode == ReturnCode.Success - @test init_runs[] == 1 - @test finalize_runs[] == 1 + # `save_discretes_if_enabled!` is called once during initialization; + # `save_final_discretes!` is called once at the end of `solve!`. + @test _save_discretes_hook_count[] == 1 + @test _save_final_discretes_hook_count[] == 1 end else @info "Skipping `SciMLBase discrete-save lifecycle hooks are forwarded`: " * @@ -741,6 +762,21 @@ end @test sol_on.t[1] ≈ 0.0 end + # ── Review-16 #1 ──────────────────────────────────────────────────────── + @testset "save_discretes is accepted and stored in integrator opts" begin + # `save_discretes` controls whether `DiffEqBase.apply_discrete_callback!` + # records discrete observable state after a callback fires. The wrapper + # must store it in `DEOptions` so DiffEqBase can observe it through the + # standard `integrator.opts.save_discretes` path. + integ_on = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = true) + @test integ_on.opts.save_discretes == true + PETSc.destroy(integ_on) + + integ_off = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = false) + @test integ_off.opts.save_discretes == false + PETSc.destroy(integ_off) + end + # ── Review-13 #1 ──────────────────────────────────────────────────────── @testset "tstops kwarg is rejected with ArgumentError" begin # `tstops` carries a strict SciML contract — the integrator must From a490c0a462fa6aeb8f560729fc10b23a74be60bf Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Tue, 28 Apr 2026 21:02:29 +0200 Subject: [PATCH 18/38] WIP --- test/sciml/test_review_fixes.jl | 49 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index a6ec72197..fb2448dd7 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -10,24 +10,30 @@ const TSImplicit = ext.TSImplicit const TSARKIMEX = ext.TSARKIMEX # Counters for hook-forwarding tests. The shim methods below are more specific -# than the SciMLBase generics and shadow them for `PETScTSIntegrator`, so the +# than the SciMLBase generics and intercept calls for `PETScTSIntegrator`, so # tests can count actual invocations of the SciML hook API rather than relying # on callback lifecycle counters that would pass even if the hooks were removed. +# Each shim increments its counter and then `invoke`s the real SciMLBase generic +# so actual hook behavior (e.g. observable timeseries writes) still runs. const _save_discretes_hook_count = Ref(0) const _save_final_discretes_hook_count = Ref(0) if isdefined(SciMLBase, :save_discretes_if_enabled!) && isdefined(SciMLBase, :save_final_discretes!) function SciMLBase.save_discretes_if_enabled!( - ::ext.PETScTSIntegrator, ::SciMLBase.CallbackSet; kw... + integ::ext.PETScTSIntegrator, cb::SciMLBase.CallbackSet; kw... ) _save_discretes_hook_count[] += 1 - return nothing + invoke(SciMLBase.save_discretes_if_enabled!, + Tuple{SciMLBase.DEIntegrator, SciMLBase.CallbackSet}, + integ, cb; kw...) end function SciMLBase.save_final_discretes!( - ::ext.PETScTSIntegrator, ::SciMLBase.CallbackSet; kw... + integ::ext.PETScTSIntegrator, cb::SciMLBase.CallbackSet; kw... ) _save_final_discretes_hook_count[] += 1 - return nothing + invoke(SciMLBase.save_final_discretes!, + Tuple{SciMLBase.DEIntegrator, SciMLBase.CallbackSet}, + integ, cb; kw...) end end @@ -763,18 +769,27 @@ end end # ── Review-16 #1 ──────────────────────────────────────────────────────── - @testset "save_discretes is accepted and stored in integrator opts" begin - # `save_discretes` controls whether `DiffEqBase.apply_discrete_callback!` - # records discrete observable state after a callback fires. The wrapper - # must store it in `DEOptions` so DiffEqBase can observe it through the - # standard `integrator.opts.save_discretes` path. - integ_on = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = true) - @test integ_on.opts.save_discretes == true - PETSc.destroy(integ_on) - - integ_off = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = false) - @test integ_off.opts.save_discretes == false - PETSc.destroy(integ_off) + # `save_discretes` was added to SciMLBase's `allowedkeywords` whitelist in + # 2.120.0. On older stacks the kwarg is rejected by SciMLBase before it + # reaches the PETSc extension, so we gate the test on that floor. + if pkgversion(SciMLBase) >= v"2.120.0" + @testset "save_discretes is accepted and stored in integrator opts" begin + # `save_discretes` controls whether `DiffEqBase.apply_discrete_callback!` + # records discrete observable state after a callback fires. The wrapper + # must store it in `DEOptions` so DiffEqBase can observe it through the + # standard `integrator.opts.save_discretes` path. + integ_on = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = true) + @test integ_on.opts.save_discretes == true + PETSc.destroy(integ_on) + + integ_off = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = false) + @test integ_off.opts.save_discretes == false + PETSc.destroy(integ_off) + end + else + @info "Skipping `save_discretes` test: loaded SciMLBase $(pkgversion(SciMLBase)) " * + "is older than 2.120.0 (the first release to include :save_discretes in " * + "allowedkeywords)." end # ── Review-13 #1 ──────────────────────────────────────────────────────── From 34867fddb8c276c6f3ec13d9f49dd07d277b7554 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:19:37 +0200 Subject: [PATCH 19/38] WIP: examples/ex51_sciml.jl --- README.md | 26 +++++++++++------ examples/ex51_sciml.jl | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 examples/ex51_sciml.jl diff --git a/README.md b/README.md index eedea64ad..ab1a636b5 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,21 @@ [![DOI](https://zenodo.org/badge/38933145.svg)](https://doi.org/10.5281/zenodo.18274809) -`PETSc.jl` provides an interface to the Portable, Extensible Toolkit for Scientific Computation ([PETSc](https://petsc.org)) library, allowing the combination of Julia features (such as automatic differentiation) with the PETSc's infrastructure, including linear, nonlinear, and optimization solvers, timesteppers, domain management (DM), and more, in a distributed-memory (MPI) environment. +PETSc.jl provides an interface to the Portable, Extensible Toolkit for Scientific Computation ([PETSc](https://petsc.org)) library, allowing the combination of Julia features (such as automatic differentiation) with the PETSc's infrastructure, including linear, nonlinear, and optimization solvers, timesteppers, domain management (DM), and more, in a distributed-memory (MPI) environment. -This package comprises two main components: +This package comprises three main components: 1. An automatically generated, low-level interface for large parts of the PETSc API (see `PETSc.LibPETSc`). -2. A curated, high-level, more Julianic interface for selected functionality. +2. A curated, high-level, more Julian interface for selected functionality. +3. A package extension based on [SciMLBase.jl](https://github.com/SciML/SciMLBase.jl) that allows solving problems such as `ODEProblem`s with PETSc's algorithms. -The low-level interface covers nearly the entire PETSc API, but may be awkward to work with and likely requires previous experience with PETSc to use effectively. The high level interface is designed to be more familiar and convenient for Julia users, and allows, for example, to set matrix entries with `A[1,2] = 3.0`, rather than having to call `LibPETSc.MatSetValue`. It, however, exposes only a small portion of the functionality of the underlying library. +The low-level interface covers nearly the entire PETSc API, but may be awkward to work with and likely requires previous experience with PETSc to use effectively. +The high-level interface is designed to be more familiar and convenient for Julia users, and allows, for example, to set matrix entries with `A[1,2] = 3.0`, rather than having to call `LibPETSc.MatSetValue`. +However, it exposes only a small portion of the functionality of the underlying library. +The SciML package extension is work in progress and currently supports only (in-place) `ODEProblem`s. ## Installation -This package can be added with the julia command: +This package can be added with the Julia command: ```julia julia>]add PETSc ``` @@ -45,13 +49,17 @@ julia>using PETSc julia>[PETSc.petsclibs...] ``` -## Windows users -The package currently does not work on windows, mainly because `MicrosoftMPI_jll` does not function when used along with the precompiled version used in `PETSc_jll`. Windows users are therefore advised to install the [Windows Subsystem for Linux](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux) (WSL) and run PETSc through there. +## Windows users +The package currently does not work on Windows, mainly because `MicrosoftMPI_jll` does not function when used along with the precompiled version used in `PETSc_jll`. +Windows users are therefore advised to install the [Windows Subsystem for Linux](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux) (WSL) and run PETSc through there. ## Getting started -Have a look at the [documentation](https://juliaparallel.org/PETSc.jl/stable/), at the [examples](./examples/) directory or at the tests in the [test](./test) directory. We do keep the tests up to date, so that is a good starting point. +Have a look at the [documentation](https://juliaparallel.org/PETSc.jl/stable/), at the [examples](./examples/) directory or at the tests in the [test](./test) directory. +We do keep the tests up to date, so that is a good starting point. -Note, that we do not have tests in place for the whole library at this stage. The best supported parts are `DMDA`,`DMStag`, `KSP`,`SNES`,`Vec` and `Mat` interfaces, while other parts such as `DMPlex` do not have a high-level interface or tests yet. Users will thus have to rely on the low-level interface. +Note, that we do not have tests in place for the whole library at this stage. +The best supported parts are `DMDA`,`DMStag`, `KSP`,`SNES`,`Vec` and `Mat` interfaces, while other parts such as `DMPlex` do not have a high-level interface or tests yet. +Users will thus have to rely on the low-level interface. ## SciML / OrdinaryDiffEq integration diff --git a/examples/ex51_sciml.jl b/examples/ex51_sciml.jl new file mode 100644 index 000000000..47f042522 --- /dev/null +++ b/examples/ex51_sciml.jl @@ -0,0 +1,65 @@ +using PETSc, SciMLBase +using DiffEqBase # TODO: do we need this dependency? +using LinearAlgebra + +# Small ODE to test TS accuracy, see +# https://petsc.org/main/src/ts/tutorials/ex51.c.html. +# +# The ODE +# u1_t = cos(t), +# u2_t = sin(u2) +# with analytical solution +# u1(t) = sin(t), +# u2(t) = 2 * atan(exp(t) * tan(0.5)) +# is used to test the accuracy of TS schemes. +# +# This version of the example demonstrates how to use the SciML integration +# of PETSc.jl, which allows to solve `SciMLBase.ODEProblem`s with PETSc's +# time-stepping solvers (TS). + +function ex51_rhs!(du, u, p, t) + du[1] = cos(t) + du[2] = sin(u[2]) + return nothing +end + +function exact_solution!(u, t::Real) + u[1] = sin(t) + u[2] = 2 * atan(exp(t) * tan(oftype(t, 0.5))) + return nothing +end + +function solve_ex51(; + final_time::Real = 1.0, + dt::Real = 0.25, + alg = PETSc.TSRK("5dp"), + verbose::Bool = true, + kwargs... # further keyword arguements passed to the integrator/`solve` +) + # Create the ODE problem + RealT = typeof(final_time) + tspan = (zero(RealT), final_time) + u0 = zeros(RealT, 2) + exact_solution!(u0, first(tspan)) + ode = ODEProblem(ex51_rhs!, u0, tspan) + + # Solve the ODE using an interface matching (mostly) the typical SciML interface + sol = solve(ode, alg; dt, kwargs...) + + # Compute the error against the analytical solution at the achieved + # final time. + current_time = sol.t[end] + u_exact = similar(u) + exact_solution!(u_exact, current_time) + + error_norm = norm(sol.u[end] - u_exact) + + if verbose + if !(current_time ≈ final_time) + @warn "Note: prescribed `final_time` differs from `current_time`" final_time current_time + end + println("Error at final time: ", error_norm) + end + + return (final_time = current_time, error = error_norm, solution = sol.u[end]) +end From 89bf003fd668ba72324149bdab6b6da466879baa Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:24:25 +0200 Subject: [PATCH 20/38] fix --- examples/ex51_sciml.jl | 2 +- ext/sciml/helpers.jl | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/ex51_sciml.jl b/examples/ex51_sciml.jl index 47f042522..16d524fc2 100644 --- a/examples/ex51_sciml.jl +++ b/examples/ex51_sciml.jl @@ -49,7 +49,7 @@ function solve_ex51(; # Compute the error against the analytical solution at the achieved # final time. current_time = sol.t[end] - u_exact = similar(u) + u_exact = similar(sol.u[end]) exact_solution!(u_exact, current_time) error_norm = norm(sol.u[end] - u_exact) diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index d6f6137db..b5ccb38ae 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -69,6 +69,7 @@ const _SUPPORTED_SCIML_KWARGS = ( :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, :maxiters, :petsclib, + :verbose, ) function _reject_unsupported_kwargs(kwargs) From f361a7fbb87db3f52bc848e235783269da325bae Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:33:03 +0200 Subject: [PATCH 21/38] ex51_sciml.jl: adaptive --- examples/ex51_sciml.jl | 7 +++++-- test/sciml/runtests.jl | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/ex51_sciml.jl b/examples/ex51_sciml.jl index 16d524fc2..2ab5e0c5b 100644 --- a/examples/ex51_sciml.jl +++ b/examples/ex51_sciml.jl @@ -33,6 +33,7 @@ function solve_ex51(; final_time::Real = 1.0, dt::Real = 0.25, alg = PETSc.TSRK("5dp"), + adaptive::Bool = false, verbose::Bool = true, kwargs... # further keyword arguements passed to the integrator/`solve` ) @@ -43,8 +44,10 @@ function solve_ex51(; exact_solution!(u0, first(tspan)) ode = ODEProblem(ex51_rhs!, u0, tspan) - # Solve the ODE using an interface matching (mostly) the typical SciML interface - sol = solve(ode, alg; dt, kwargs...) + # Solve the ODE using an interface matching (mostly) the typical SciML interface. + # Adaptivity is off by default to match the behaviour of examples/ex51.jl, which + # forces constant time steps via `TSAdaptSetType(..., "none")`. + sol = solve(ode, alg; dt, adaptive, kwargs...) # Compute the error against the analytical solution at the achieved # final time. diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index efe6fea5b..d6a8c826f 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -24,6 +24,7 @@ using DiffEqBase end include("test_rk.jl") + include("test_ex51_sciml.jl") include("test_rosenbrock.jl") include("test_implicit.jl") include("test_imex.jl") From 55e66017073d4026a5d2c8d79050c203e62e2608 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:33:32 +0200 Subject: [PATCH 22/38] test ex51_sciml.jl --- test/sciml/test_ex51_sciml.jl | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/sciml/test_ex51_sciml.jl diff --git a/test/sciml/test_ex51_sciml.jl b/test/sciml/test_ex51_sciml.jl new file mode 100644 index 000000000..eeddfa9d4 --- /dev/null +++ b/test/sciml/test_ex51_sciml.jl @@ -0,0 +1,46 @@ +using Test +using PETSc +using SciMLBase +using DiffEqBase +using LinearAlgebra + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing + +include(joinpath(dirname(dirname(@__DIR__)), "examples", "ex51_sciml.jl")) + +@testset "ex51_sciml example" begin + @testset "default algorithm (TSRK 5dp), fixed step, reaches final time and is accurate" begin + result = solve_ex51(; verbose = false) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 5.0e-8 + end + + @testset "TSRK 3bs is less accurate than 5dp (fixed step)" begin + result_3bs = solve_ex51(; alg = PETSc.TSRK("3bs"), verbose = false) + result_5dp = solve_ex51(; alg = PETSc.TSRK("5dp"), verbose = false) + @test result_3bs.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test result_3bs.error < 5.0e-4 + @test result_5dp.error < result_3bs.error + end + + @testset "repeated solves give the same result (fixed step)" begin + result_1 = solve_ex51(; verbose = false) + result_2 = solve_ex51(; verbose = false) + @test result_1.error ≈ result_2.error rtol = 1e-12 + end + + @testset "smaller dt improves accuracy (fixed step)" begin + result_coarse = solve_ex51(; dt = 0.25, verbose = false) + result_fine = solve_ex51(; dt = 0.05, verbose = false) + @test result_fine.error < result_coarse.error + end + + @testset "adaptive = true is accepted and reaches final time" begin + result = solve_ex51(; adaptive = true, verbose = false) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 5.0e-4 + end +end From a0291270b16b94b382bf1a1c6aa2b61cdafe7b06 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:45:28 +0200 Subject: [PATCH 23/38] more SciML tests --- test/sciml/test_ex51_sciml.jl | 290 +++++++++++++++++++++++++++++++--- 1 file changed, 265 insertions(+), 25 deletions(-) diff --git a/test/sciml/test_ex51_sciml.jl b/test/sciml/test_ex51_sciml.jl index eeddfa9d4..3b8afbcf8 100644 --- a/test/sciml/test_ex51_sciml.jl +++ b/test/sciml/test_ex51_sciml.jl @@ -10,37 +10,277 @@ ext = Base.get_extension(PETSc, :PETScSciMLExt) include(joinpath(dirname(dirname(@__DIR__)), "examples", "ex51_sciml.jl")) @testset "ex51_sciml example" begin - @testset "default algorithm (TSRK 5dp), fixed step, reaches final time and is accurate" begin - result = solve_ex51(; verbose = false) - @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) - @test length(result.solution) == 2 - @test result.error < 5.0e-8 - end + @testset "TSRK explicit Runge-Kutta" begin + @testset "default algorithm (TSRK 5dp), fixed step, reaches final time and is accurate" begin + result = solve_ex51(; verbose = false) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 5.0e-8 + end + + @testset "TSRK 3bs is less accurate than 5dp (fixed step)" begin + result_3bs = solve_ex51(; alg = PETSc.TSRK("3bs"), verbose = false) + result_5dp = solve_ex51(; alg = PETSc.TSRK("5dp"), verbose = false) + @test result_3bs.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test result_3bs.error < 5.0e-4 + @test result_5dp.error < result_3bs.error + end + + @testset "repeated solves give the same result (fixed step)" begin + result_1 = solve_ex51(; verbose = false) + result_2 = solve_ex51(; verbose = false) + @test result_1.error ≈ result_2.error rtol = 1e-12 + end + + @testset "smaller dt improves accuracy (fixed step)" begin + result_coarse = solve_ex51(; dt = 0.25, verbose = false) + result_fine = solve_ex51(; dt = 0.05, verbose = false) + @test result_fine.error < result_coarse.error + end - @testset "TSRK 3bs is less accurate than 5dp (fixed step)" begin - result_3bs = solve_ex51(; alg = PETSc.TSRK("3bs"), verbose = false) - result_5dp = solve_ex51(; alg = PETSc.TSRK("5dp"), verbose = false) - @test result_3bs.final_time ≈ 1.0 atol = 100 * eps(Float64) - @test result_3bs.error < 5.0e-4 - @test result_5dp.error < result_3bs.error + @testset "adaptive = true is accepted and reaches final time" begin + result = solve_ex51(; adaptive = true, verbose = false) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 5.0e-4 + end end - @testset "repeated solves give the same result (fixed step)" begin - result_1 = solve_ex51(; verbose = false) - result_2 = solve_ex51(; verbose = false) - @test result_1.error ≈ result_2.error rtol = 1e-12 + @testset "TSRosW Rosenbrock-W" begin + @testset "TSRosW(\"ra34pw2\") reaches final time and is accurate" begin + result = solve_ex51(; + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + dt = 0.1, verbose = false, + ) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 1.0e-3 + end + + @testset "TSRosW(\"rodas3\") reaches final time" begin + result = solve_ex51(; + alg = PETSc.TSRosW("rodas3", ["-snes_fd"]), + dt = 0.1, verbose = false, + ) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 1.0e-2 + end + + @testset "smaller dt improves accuracy for TSRosW" begin + result_coarse = solve_ex51(; + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + dt = 0.2, verbose = false, + ) + result_fine = solve_ex51(; + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + @test result_fine.error < result_coarse.error + end + + @testset "repeated TSRosW solves give the same result" begin + result_1 = solve_ex51(; + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + dt = 0.1, verbose = false, + ) + result_2 = solve_ex51(; + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + dt = 0.1, verbose = false, + ) + @test result_1.error ≈ result_2.error rtol = 1e-12 + end end - @testset "smaller dt improves accuracy (fixed step)" begin - result_coarse = solve_ex51(; dt = 0.25, verbose = false) - result_fine = solve_ex51(; dt = 0.05, verbose = false) - @test result_fine.error < result_coarse.error + @testset "TSIRK implicit Runge-Kutta (Gauss)" begin + # TSIRK/Gauss requires an AIJ sparse Jacobian matrix so that PETSc can + # form its internal MATKAIJ stage operator. The SciML extension currently + # calls TSSetIFunction without TSSetIJacobian, so PETSc falls back to a + # matrix-free (MFFD) Jacobian, which TSIRK/Gauss rejects — the same + # limitation documented in examples/ex51_implicit.jl for -snes_mf. + # These tests are marked @test_broken to track the gap. Once the + # extension is extended to set up an AIJ Jacobian template and register + # it via TSSetIJacobian (matching ex51_implicit.jl's + # ex51_implicit_create_jacobian_template path), they should pass with the + # error tolerances matching test/ts_ex51_implicit.jl. + @testset "Gauss 1 stage (order 2): error < 5e-3" begin + @test_broken begin + result = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "1", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + isapprox(result.final_time, 1.0; atol = 100 * eps(Float64)) && + length(result.solution) == 2 && + result.error < 5.0e-3 + end + end + + @testset "Gauss 2 stages (order 4): error < 5e-6" begin + @test_broken begin + result = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "2", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + isapprox(result.final_time, 1.0; atol = 100 * eps(Float64)) && + length(result.solution) == 2 && + result.error < 5.0e-6 + end + end + + @testset "Gauss 3 stages (order 6): error < 5e-9" begin + @test_broken begin + result = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "3", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + isapprox(result.final_time, 1.0; atol = 100 * eps(Float64)) && + length(result.solution) == 2 && + result.error < 5.0e-9 + end + end + + @testset "Gauss 4 stages (order 8): error < 5e-10" begin + @test_broken begin + result = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "4", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + isapprox(result.final_time, 1.0; atol = 100 * eps(Float64)) && + length(result.solution) == 2 && + result.error < 5.0e-10 + end + end + + @testset "increasing stages strictly improve accuracy" begin + @test_broken begin + errors = map([1, 2, 3, 4]) do nstages + solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", string(nstages), + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ).error + end + issorted(errors; rev = true) + end + end + + @testset "repeated solves give the same result (Gauss 2 stages)" begin + @test_broken begin + r1 = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "2", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + r2 = solve_ex51(; + alg = PETSc.TSGeneric("irk", [ + "-ts_irk_type", "gauss", + "-ts_irk_nstages", "2", + "-snes_fd_color", + "-ksp_type", "gmres", + "-pc_type", "none", + ]), + dt = 0.25, adaptive = false, verbose = false, + ) + isapprox(r1.error, r2.error; rtol = 1e-12) + end + end end - @testset "adaptive = true is accepted and reaches final time" begin - result = solve_ex51(; adaptive = true, verbose = false) - @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) - @test length(result.solution) == 2 - @test result.error < 5.0e-4 + @testset "TSImplicit fully implicit methods" begin + @testset "TSImplicit(\"beuler\") reaches final time (1st-order)" begin + result = solve_ex51(; + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + dt = 0.01, verbose = false, + ) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 5.0e-2 + end + + @testset "TSImplicit(\"cn\") is more accurate than backward Euler at the same dt" begin + result_beuler = solve_ex51(; + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + result_cn = solve_ex51(; + alg = PETSc.TSImplicit("cn", ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + @test result_cn.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test result_cn.error < result_beuler.error + end + + @testset "TSImplicit(\"theta\", 0.5) is second-order accurate" begin + # theta=0.5 and "cn" are both second-order Crank-Nicolson variants but + # differ internally (endpoint vs. midpoint staging), so their errors are + # not numerically identical. Verify second-order accuracy directly by + # checking that halving dt reduces the error by roughly a factor of 4. + result_coarse = solve_ex51(; + alg = PETSc.TSImplicit("theta", 0.5, ["-snes_fd"]), + dt = 0.1, verbose = false, + ) + result_fine = solve_ex51(; + alg = PETSc.TSImplicit("theta", 0.5, ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + @test result_fine.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test result_fine.error < result_coarse.error / 2 + end + + @testset "TSImplicit(\"bdf\") reaches final time" begin + result = solve_ex51(; + alg = PETSc.TSImplicit("bdf", ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + @test result.final_time ≈ 1.0 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test result.error < 1.0e-2 + end + + @testset "smaller dt improves accuracy for TSImplicit beuler" begin + result_coarse = solve_ex51(; + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + dt = 0.05, verbose = false, + ) + result_fine = solve_ex51(; + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + dt = 0.01, verbose = false, + ) + @test result_fine.error < result_coarse.error + end end end From 9dd02cb24d73f2bf7e21c249f3f95377987b921a Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 08:55:35 +0200 Subject: [PATCH 24/38] remove DataStructures.jl --- Project.toml | 2 -- ext/PETScSciMLExt.jl | 1 - ext/sciml/interface.jl | 2 +- ext/sciml/options.jl | 6 ++++-- src/PETSc.jl | 1 - 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Project.toml b/Project.toml index cd36619b5..dfb87a47e 100644 --- a/Project.toml +++ b/Project.toml @@ -4,7 +4,6 @@ version = "0.4.9" authors = ["Boris Kaus ", "Viral B. Shah ", "Valentin Churavy ", "Erik Schnetter ", "Jeremy E. Kozdon ", "Simon Byrne "] [deps] -DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -26,7 +25,6 @@ SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" PETScSciMLExt = ["SciMLBase", "DiffEqBase"] [compat] -DataStructures = "0.18" DiffEqBase = "6" ForwardDiff = "0.10, 1" Libdl = "^1.10" diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl index 3ccd8d6fd..16aaaedf6 100644 --- a/ext/PETScSciMLExt.jl +++ b/ext/PETScSciMLExt.jl @@ -1,7 +1,6 @@ module PETScSciMLExt using PETSc -using PETSc: BinaryMinHeap using SciMLBase using DiffEqBase diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl index 371096ca2..b3de5da30 100644 --- a/ext/sciml/interface.jl +++ b/ext/sciml/interface.jl @@ -15,7 +15,7 @@ function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) # `saveat` stores tdir*t in a forward BinaryMinHeap, so first(...) is the # next requested time in tdir order. while !isempty(integ.opts.saveat) && - first(integ.opts.saveat) <= integ.tdir * integ.t + last(integ.opts.saveat) <= integ.tdir * integ.t t_save = pop!(integ.opts.saveat) / integ.tdir if t_save == integ.t # Saveat coincides with the step endpoint: just save the current diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index 396555314..aa5cd11df 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -106,8 +106,10 @@ function _build_opts( ] push!(tstops_data, tf) - saveat_heap = BinaryMinHeap(saveat_data) - tstops_heap = BinaryMinHeap(tstops_data) + sort!(saveat_data; rev = true) + sort!(tstops_data; rev = true) + saveat_heap = saveat_data + tstops_heap = tstops_data return DEOptions( saveat_heap, diff --git a/src/PETSc.jl b/src/PETSc.jl index 802ea7630..8e43df59e 100644 --- a/src/PETSc.jl +++ b/src/PETSc.jl @@ -3,7 +3,6 @@ module PETSc using MPI, LinearAlgebra, SparseArrays, OffsetArrays, Preferences -using DataStructures: BinaryMinHeap MPI.Initialized() || MPI.Init() From 6f40d76a1dbc443f92b1e346bc84ad93c686d857 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 10:03:44 +0200 Subject: [PATCH 25/38] remove DiffEqBase.jl requirement --- Project.toml | 7 +-- README.md | 10 ++-- examples/ex51_sciml.jl | 1 - ext/PETScSciMLExt.jl | 1 - ext/sciml/interface.jl | 98 ++++++++++++++++++++++++++++++--- ext/sciml/solve.jl | 33 +++++++++-- src/sciml_algorithms.jl | 3 +- test/sciml/runtests.jl | 3 +- test/sciml/test_callbacks.jl | 3 +- test/sciml/test_ex51_sciml.jl | 1 - test/sciml/test_imex.jl | 1 - test/sciml/test_implicit.jl | 1 - test/sciml/test_integrator.jl | 1 - test/sciml/test_output.jl | 1 - test/sciml/test_polish.jl | 1 - test/sciml/test_review_fixes.jl | 23 ++++---- test/sciml/test_rk.jl | 1 - test/sciml/test_rosenbrock.jl | 1 - 18 files changed, 141 insertions(+), 49 deletions(-) diff --git a/Project.toml b/Project.toml index dfb87a47e..536476135 100644 --- a/Project.toml +++ b/Project.toml @@ -18,14 +18,12 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [weakdeps] -DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" [extensions] -PETScSciMLExt = ["SciMLBase", "DiffEqBase"] +PETScSciMLExt = "SciMLBase" [compat] -DiffEqBase = "6" ForwardDiff = "0.10, 1" Libdl = "^1.10" LinearAlgebra = "^1.10" @@ -43,7 +41,6 @@ julia = "^1.10" [extras] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" -DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" @@ -54,4 +51,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [targets] -test = ["CairoMakie", "DiffEqBase", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] +test = ["CairoMakie", "ForwardDiff", "Plots", "Printf", "Random", "SciMLBase", "SparseDiffTools", "Test", "UnicodePlots"] diff --git a/README.md b/README.md index ab1a636b5..244dea626 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ Note, that we do not have tests in place for the whole library at this stage. The best supported parts are `DMDA`,`DMStag`, `KSP`,`SNES`,`Vec` and `Mat` interfaces, while other parts such as `DMPlex` do not have a high-level interface or tests yet. Users will thus have to rely on the low-level interface. -## SciML / OrdinaryDiffEq integration +## SciML integration -`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciMLBase / OrdinaryDiffEq interface. The extension activates automatically when both `SciMLBase` and `DiffEqBase` are loaded — typically as a side effect of `using OrdinaryDiffEq`. +`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciML interface. The extension activates automatically when `SciMLBase` is loaded — `DiffEqBase` and `OrdinaryDiffEq` are not required. ```julia -using PETSc, OrdinaryDiffEq +using PETSc, SciMLBase f!(du, u, p, t) = (du[1] = -u[1]; nothing) prob = ODEProblem(f!, [1.0], (0.0, 1.0)) @@ -81,9 +81,11 @@ integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!(integrator); sol = solve!(integrator) ``` +`OrdinaryDiffEq` also works as the trigger since it re-exports `SciMLBase`, but the extension only depends on `SciMLBase` — no `DiffEqBase` is needed. + Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; `ContinuousCallback`s and `tstops` are rejected with `ArgumentError` so silently-skipped exact-time callbacks cannot bite users. Standard SciML control knobs `adaptive`, `dtmin`, and `dtmax` are mapped onto PETSc's `TSAdapt` controller; unrecognized solve keywords are rejected with a clear `ArgumentError` rather than silently dropped. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. Known callback-lifecycle gaps (compared to OrdinaryDiffEq): -- The SciML *discrete-save* lifecycle hooks `SciMLBase.save_discretes_if_enabled!` (after `initialize!`) and `SciMLBase.save_final_discretes!` (after `finalize!`) are now invoked when the loaded SciMLBase version provides them, so `DiscreteCallback` machinery that records observable state at `t0` and at the end of the solve interoperates with the standard SciML lifecycle. Callbacks that depend on per-step `save_discretes_if_enabled!` calls *during* the solve are still not driven by the manual `TSStep` loop. +- The SciML *discrete-save* lifecycle hooks `SciMLBase.save_discretes_if_enabled!` (after `initialize!`) and `SciMLBase.save_final_discretes!` (after `finalize!`) are invoked when the loaded SciMLBase version provides them, so `DiscreteCallback` machinery that records observable state at `t0` and at the end of the solve interoperates with the standard SciML lifecycle. Callbacks that depend on per-step `save_discretes_if_enabled!` calls *during* the solve are still not driven by the manual `TSStep` loop. - `ContinuousCallback`s are rejected with `ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. diff --git a/examples/ex51_sciml.jl b/examples/ex51_sciml.jl index 2ab5e0c5b..ca405d77d 100644 --- a/examples/ex51_sciml.jl +++ b/examples/ex51_sciml.jl @@ -1,5 +1,4 @@ using PETSc, SciMLBase -using DiffEqBase # TODO: do we need this dependency? using LinearAlgebra # Small ODE to test TS accuracy, see diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl index 16aaaedf6..39ca8569d 100644 --- a/ext/PETScSciMLExt.jl +++ b/ext/PETScSciMLExt.jl @@ -2,7 +2,6 @@ module PETScSciMLExt using PETSc using SciMLBase -using DiffEqBase include("sciml/algorithms.jl") include("sciml/options.jl") diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl index b3de5da30..b3c0b1e46 100644 --- a/ext/sciml/interface.jl +++ b/ext/sciml/interface.jl @@ -1,19 +1,19 @@ -DiffEqBase.u_modified!(i::PETScTSIntegrator, val::Bool) = (i.u_modified = val) +SciMLBase.u_modified!(i::PETScTSIntegrator, val::Bool) = (i.u_modified = val) @static if isdefined(SciMLBase, :derivative_discontinuity!) SciMLBase.derivative_discontinuity!(i::PETScTSIntegrator, val::Bool) = (i.derivative_discontinuity = val) end -function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) +function SciMLBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) integ.opts.save_on || return (false, false) saved = false saved_exactly = false # Drain due `saveat` times first so the trajectory stays sorted in the # integration direction even when `save_everystep` is also enabled. - # `saveat` stores tdir*t in a forward BinaryMinHeap, so first(...) is the - # next requested time in tdir order. + # `saveat` stores tdir*t in a descending-sorted Vector so `last` is the + # next requested time in tdir order and `pop!` removes it in O(1). while !isempty(integ.opts.saveat) && last(integ.opts.saveat) <= integ.tdir * integ.t t_save = pop!(integ.opts.saveat) / integ.tdir @@ -51,7 +51,7 @@ function DiffEqBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) return (saved, !isempty(integ.sol.t) && last(integ.sol.t) == integ.t) end -function DiffEqBase.terminate!( +function SciMLBase.terminate!( i::PETScTSIntegrator, retcode = SciMLBase.ReturnCode.Terminated, ) @@ -63,15 +63,97 @@ function DiffEqBase.terminate!( return nothing end +# Local re-implementations of the DiffEqBase callback lifecycle helpers. +# All dependencies are in SciMLBase, so no DiffEqBase dependency is needed. +_initialize_callbacks!(cb::SciMLBase.CallbackSet{Tuple{}, Tuple{}}, u, t, integ) = false +function _initialize_callbacks!(cb::SciMLBase.CallbackSet, u, t, integ) + _initialize_callbacks!(u, t, integ, false, + cb.continuous_callbacks..., cb.discrete_callbacks...) +end +function _initialize_callbacks!(u, t, integ, any_modified, + c::SciMLBase.DECallback, cs::SciMLBase.DECallback..., +) + c.initialize(c, u, t, integ) + _initialize_callbacks!(u, t, integ, any_modified || integ.u_modified, cs...) +end +function _initialize_callbacks!(u, t, integ, any_modified, c::SciMLBase.DECallback) + c.initialize(c, u, t, integ) + any_modified || integ.u_modified +end + +_finalize_callbacks!(cb::SciMLBase.CallbackSet{Tuple{}, Tuple{}}, u, t, integ) = false +function _finalize_callbacks!(cb::SciMLBase.CallbackSet, u, t, integ) + _finalize_callbacks!(u, t, integ, false, + cb.continuous_callbacks..., cb.discrete_callbacks...) +end +function _finalize_callbacks!(u, t, integ, any_modified, + c::SciMLBase.DECallback, cs::SciMLBase.DECallback..., +) + c.finalize(c, u, t, integ) + _finalize_callbacks!(u, t, integ, any_modified || integ.u_modified, cs...) +end +function _finalize_callbacks!(u, t, integ, any_modified, c::SciMLBase.DECallback) + c.finalize(c, u, t, integ) + any_modified || integ.u_modified +end + +# Local re-implementation of the callback dispatch chain that DiffEqBase +# previously provided. All dependencies (condition, affect!, save_positions, +# initializealg, reeval_internals_due_to_modification!, savevalues!) are part +# of SciMLBase, so no DiffEqBase dependency is needed. +@inline function _apply_discrete_callback!( + integrator::PETScTSIntegrator, callback::SciMLBase.DiscreteCallback, +) + saved_in_cb = false + if callback.condition(integrator.u, integrator.t, integrator) + _, savedexactly = SciMLBase.savevalues!(integrator) + saved_in_cb = true + @inbounds if callback.save_positions[1] + savedexactly || SciMLBase.savevalues!(integrator, true) + end + integrator.u_modified = true + callback.affect!(integrator) + if integrator.u_modified + SciMLBase.reeval_internals_due_to_modification!( + integrator, false; + callback_initializealg = callback.initializealg, + ) + end + @inbounds if callback.save_positions[2] + SciMLBase.savevalues!(integrator, true) + saved_in_cb = true + end + end + integrator.u_modified, saved_in_cb +end + +@inline function _apply_discrete_callback!( + integrator::PETScTSIntegrator, + discrete_modified::Bool, saved_in_cb::Bool, + callback::SciMLBase.DiscreteCallback, +) + bool, saved2 = _apply_discrete_callback!(integrator, callback) + discrete_modified || bool, saved_in_cb || saved2 +end + +@inline function _apply_discrete_callback!( + integrator::PETScTSIntegrator, + callback::SciMLBase.DiscreteCallback, + rest::SciMLBase.DiscreteCallback..., +) + _apply_discrete_callback!( + integrator, _apply_discrete_callback!(integrator, callback)..., rest..., + ) +end + function handle_callbacks!(integ::PETScTSIntegrator) cbs = integ.opts.callback discrete = cbs.discrete_callbacks saved_in_cb = false if !isempty(discrete) - modified, saved_in_cb = - DiffEqBase.apply_discrete_callback!(integ, discrete...) + modified, saved_in_cb = _apply_discrete_callback!(integ, discrete...) modified && (integ.u_modified = true) end - saved_in_cb || DiffEqBase.savevalues!(integ) + saved_in_cb || SciMLBase.savevalues!(integ) return nothing end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 3ee2c7e9d..5f2959ea3 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -369,7 +369,7 @@ end # # 1. Pessimistically mark `u` as modified before calling `initialize!`. A # callback initializer that does not mutate `u` is expected to call -# `DiffEqBase.u_modified!(integ, false)`; otherwise we conservatively assume +# `SciMLBase.u_modified!(integ, false)`; otherwise we conservatively assume # it did and resync the PETSc Vec. # 2. Force an initialize-time save when any discrete callback requests # `save_positions[2]` (the post-event side, which corresponds to "after the @@ -379,7 +379,7 @@ function initialize_callbacks!( integ::PETScTSIntegrator, cb_set, initialize_save::Bool = true, ) integ.u_modified = true - DiffEqBase.initialize!(cb_set, integ.u, integ.t, integ) + _initialize_callbacks!(cb_set, integ.u, integ.t, integ) if integ.u_modified _sync_julia_to_petsc!(integ) PETSc.LibPETSc.TSSetSolution(integ.petsclib, integ.ts, integ.u_petsc) @@ -439,7 +439,7 @@ function SciMLBase.__init( # has no finalizer of its own). _reject_unsupported_kwargs(kwargs) _validate_maxiters(maxiters) - cb_set = DiffEqBase.CallbackSet(callback) + cb_set = SciMLBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) throw(ArgumentError( "PETSc.jl SciML extension: ContinuousCallbacks are not yet " * @@ -526,6 +526,31 @@ function SciMLBase.__init( end end +# `PETScTSAlgorithm` does not inherit from `SciMLBase.AbstractODEAlgorithm` so +# that the algorithm structs can be defined in `src/` without a hard SciMLBase +# dependency. That means SciMLBase's generic CommonSolve.solve / init dispatch +# (which requires AbstractSciMLAlgorithm) does not match. Provide thin glue +# methods here (inside the extension, where SciMLBase is already available) so +# that the standard `solve(prob, alg; kwargs...)` / `init(prob, alg; kwargs...)` +# entry points reach our `__solve` / `__init` implementations. +function SciMLBase.solve( + prob::SciMLBase.AbstractODEProblem, + alg::PETScTSAlgorithm, + args...; + kwargs..., +) + SciMLBase.__solve(prob, alg, args...; kwargs...) +end + +function SciMLBase.init( + prob::SciMLBase.AbstractODEProblem, + alg::PETScTSAlgorithm, + args...; + kwargs..., +) + SciMLBase.__init(prob, alg, args...; kwargs...) +end + function SciMLBase.__solve( prob::SciMLBase.AbstractODEProblem, alg::PETScTSAlgorithm, @@ -670,7 +695,7 @@ function SciMLBase.solve!(integ::PETScTSIntegrator) while !integ.done SciMLBase.step!(integ) end - DiffEqBase.finalize!(integ.opts.callback, integ.u, integ.t, integ) + _finalize_callbacks!(integ.opts.callback, integ.u, integ.t, integ) if integ.opts.save_end && (isempty(integ.sol.t) || last(integ.sol.t) != integ.t) push!(integ.sol.t, integ.t) diff --git a/src/sciml_algorithms.jl b/src/sciml_algorithms.jl index 732dff998..0202d9229 100644 --- a/src/sciml_algorithms.jl +++ b/src/sciml_algorithms.jl @@ -8,8 +8,7 @@ Concrete subtypes carry just enough information to configure a PETSc `TS` object: a top-level `TSSetType` argument, an optional subtype string, and an algorithm-local `petsc_options::Vector{String}` of raw PETSc CLI tokens (e.g. `["-snes_fd", "-ts_max_steps", "100"]`). The actual `solve` / `init` / `step!` -methods live in the extension and become available once `SciMLBase` and -`DiffEqBase` are loaded (typically via `using OrdinaryDiffEq`). +methods live in the extension and become available once `SciMLBase` is loaded. Subtypes do **not** inherit from `SciMLBase.AbstractODEAlgorithm`, so they can be defined here without a hard `SciMLBase` dependency. Dispatch into diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index d6a8c826f..2813ec2c2 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -14,10 +14,9 @@ end using PETSc using SciMLBase -using DiffEqBase @testset "PETSc SciML extension" begin - # Smoke test: extension activates with just SciMLBase + DiffEqBase loaded. + # Smoke test: extension activates with just SciMLBase loaded. @testset "Extension activation" begin ext = Base.get_extension(PETSc, :PETScSciMLExt) @test ext !== nothing diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index 5465a074f..9ad7d2f89 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing @@ -39,7 +38,7 @@ end function affect!(integ) integ.u[1] = 0.0 fired[] = true - DiffEqBase.u_modified!(integ, true) + SciMLBase.u_modified!(integ, true) end cb = DiscreteCallback(condition, affect!) sol = solve(prob, TSRK("3bs"); dt = 0.1, callback = cb) diff --git a/test/sciml/test_ex51_sciml.jl b/test/sciml/test_ex51_sciml.jl index 3b8afbcf8..c061c196a 100644 --- a/test/sciml/test_ex51_sciml.jl +++ b/test/sciml/test_ex51_sciml.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase using LinearAlgebra ext = Base.get_extension(PETSc, :PETScSciMLExt) diff --git a/test/sciml/test_imex.jl b/test/sciml/test_imex.jl index 45bd44483..5ab362a65 100644 --- a/test/sciml/test_imex.jl +++ b/test/sciml/test_imex.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_implicit.jl b/test/sciml/test_implicit.jl index 4db63df92..a2e316c8a 100644 --- a/test/sciml/test_implicit.jl +++ b/test/sciml/test_implicit.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl index cb430b700..739955f1b 100644 --- a/test/sciml/test_integrator.jl +++ b/test/sciml/test_integrator.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index 9342b93bd..a5e5c70b5 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_polish.jl b/test/sciml/test_polish.jl index 298da1123..a70cef24e 100644 --- a/test/sciml/test_polish.jl +++ b/test/sciml/test_polish.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase function decay!(du, u, p, t) du[1] = -u[1] diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index fb2448dd7..7774bf3cc 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing @@ -61,7 +60,7 @@ end @test hasfield(typeof(integ), :derivative_discontinuity) @test integ.derivative_discontinuity == false # u_modified! and the discontinuity field are independent. - DiffEqBase.u_modified!(integ, true) + SciMLBase.u_modified!(integ, true) @test integ.u_modified == true @test integ.derivative_discontinuity == false # Direct write should also work for any SciMLBase code path that @@ -72,7 +71,7 @@ end end @testset "DiscreteCallback that never fires still completes the solve" begin - # Review-2 #3 explicitly asks for this regression: DiffEqBase reads + # Review-2 #3 explicitly asks for this regression: SciMLBase reads # derivative_discontinuity in both the fires-and-doesn't-fire paths. cb = DiscreteCallback( (u, t, integ) -> false, # never fires @@ -89,7 +88,7 @@ end # start from the rewritten value — not from the original u0. function init_cb!(cb, u, t, integ) u[1] = 5.0 - DiffEqBase.u_modified!(integ, true) + SciMLBase.u_modified!(integ, true) return nothing end cb = DiscreteCallback( @@ -104,7 +103,7 @@ end end # ── Review-1 #5 / Review-2 #6 ─────────────────────────────────────────── - @testset "DiffEqBase.finalize! is called at end of solve" begin + @testset "SciMLBase-equivalent finalize! is called at end of solve" begin finalized = Ref(false) function finalize_cb!(cb, u, t, integ) finalized[] = true @@ -169,7 +168,7 @@ end @testset "Initialize callback that mutates u does not duplicate t0" begin function init_cb!(cb, u, t, integ) u[1] = 5.0 - DiffEqBase.u_modified!(integ, true) + SciMLBase.u_modified!(integ, true) return nothing end cb = DiscreteCallback( @@ -193,7 +192,7 @@ end # ── Review-4 #1 ───────────────────────────────────────────────────────── @testset "Initialize that mutates u without u_modified! still propagates" begin # The pessimistic-modified contract: a callback that mutates `u` but - # forgets to call `DiffEqBase.u_modified!(integ, true)` must still + # forgets to call `SciMLBase.u_modified!(integ, true)` must still # affect the first PETSc step. This is what OrdinaryDiffEq does. function init_cb!(cb, u, t, integ) u[1] = 5.0 @@ -261,7 +260,7 @@ end @testset "save_on = false suppresses all trajectory output even with init mutation" begin function init_cb!(cb, u, t, integ) u[1] = 5.0 - DiffEqBase.u_modified!(integ, true) + SciMLBase.u_modified!(integ, true) return nothing end cb = DiscreteCallback( @@ -685,8 +684,8 @@ end # ── Review-14 #2 ──────────────────────────────────────────────────────── @testset "Callback initialize / finalize hooks fire around the solve" begin - # The lifecycle calls (`DiffEqBase.initialize!` / - # `DiffEqBase.finalize!`) are part of the basic discrete-callback + # The lifecycle calls (`SciMLBase-equivalent initialize!` / + # `SciMLBase-equivalent finalize!`) are part of the basic discrete-callback # contract and must run regardless of SciMLBase version. init_runs = Ref(0) finalize_runs = Ref(0) @@ -774,9 +773,9 @@ end # reaches the PETSc extension, so we gate the test on that floor. if pkgversion(SciMLBase) >= v"2.120.0" @testset "save_discretes is accepted and stored in integrator opts" begin - # `save_discretes` controls whether `DiffEqBase.apply_discrete_callback!` + # `save_discretes` controls whether `_apply_discrete_callback!` # records discrete observable state after a callback fires. The wrapper - # must store it in `DEOptions` so DiffEqBase can observe it through the + # must store it in `DEOptions` so the integrator can observe it through the # standard `integrator.opts.save_discretes` path. integ_on = init(prob, TSRK("3bs"); dt = 0.1, save_discretes = true) @test integ_on.opts.save_discretes == true diff --git a/test/sciml/test_rk.jl b/test/sciml/test_rk.jl index f5bf454d6..f7b4a6eb1 100644 --- a/test/sciml/test_rk.jl +++ b/test/sciml/test_rk.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing diff --git a/test/sciml/test_rosenbrock.jl b/test/sciml/test_rosenbrock.jl index e654a1e5b..86f79228f 100644 --- a/test/sciml/test_rosenbrock.jl +++ b/test/sciml/test_rosenbrock.jl @@ -1,7 +1,6 @@ using Test using PETSc using SciMLBase -using DiffEqBase ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing From a8bb5ec5ef82344b3688e65d8ddee7af0ad02711 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 10:16:58 +0200 Subject: [PATCH 26/38] save_everystep = true as default --- ext/sciml/helpers.jl | 1 - ext/sciml/solve.jl | 9 +++++++-- test/sciml/test_output.jl | 33 +++++++++++++++++++++++++-------- test/sciml/test_review_fixes.jl | 8 ++++---- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index b5ccb38ae..d6f6137db 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -69,7 +69,6 @@ const _SUPPORTED_SCIML_KWARGS = ( :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, :maxiters, :petsclib, - :verbose, ) function _reject_unsupported_kwargs(kwargs) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 5f2959ea3..c214127b2 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -413,7 +413,7 @@ _any_initialize_save(cb_set) = function SciMLBase.__init( prob::SciMLBase.AbstractODEProblem, alg::PETScTSAlgorithm; - save_everystep::Bool = false, + save_everystep = nothing, # default: true when saveat is empty, false otherwise save_on::Bool = true, save_start::Bool = true, save_end::Bool = true, @@ -476,6 +476,11 @@ function SciMLBase.__init( # validation pass *and* `_build_opts`'s own consumption. saveat_materialized = _materialize_times(saveat) _validate_saveat(saveat_materialized) + # Follow the SciML convention: save_everystep defaults to true when saveat + # is empty (record every step), and false when saveat times are given (only + # the requested times matter, not every intermediate step). + _save_everystep::Bool = save_everystep === nothing ? + _is_empty_times(saveat_materialized) : Bool(save_everystep) (lib, ts, u_v, u0, tType, t0, tdir) = _common_ts_setup( prob, dt, maxiters, petsclib, reltol, abstol, @@ -485,7 +490,7 @@ function SciMLBase.__init( try opts = _build_opts( tType, saveat_materialized, tstops_materialized, tdir, prob.tspan; - save_everystep, save_on, save_start, save_end, save_discretes, + save_everystep = _save_everystep, save_on, save_start, save_end, save_discretes, callback = cb_set, reltol, abstol, maxiters, ) diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index a5e5c70b5..f1ee4bb31 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -17,23 +17,40 @@ end tspan = (0.0, 1.0) prob = ODEProblem(decay!, u0, tspan) - @testset "default: only start/end states saved" begin + @testset "default (no saveat): save_everystep = true, all steps saved" begin sol = solve(prob, TSRK("3bs"); dt = 0.1) @test sol.retcode == ReturnCode.Success + @test length(sol.t) > 2 # every step is recorded by default + @test issorted(sol.t) + @test sol.t[1] ≈ 0.0 + @test sol.t[end] ≈ 1.0 + @test sol.u[end][1] ≈ exp(-1) atol = 1e-3 + # spot-check intermediate values against the analytical solution + for k in 1:length(sol.t) + @test sol.u[k][1] ≈ exp(-sol.t[k]) atol = 5e-3 + end + end + + @testset "save_everystep = false saves only start and end" begin + sol = solve(prob, TSRK("3bs"); dt = 0.1, save_everystep = false) + @test sol.retcode == ReturnCode.Success @test length(sol.t) == 2 @test sol.t[1] ≈ 0.0 @test sol.t[end] ≈ 1.0 @test sol.u[end][1] ≈ exp(-1) atol = 1e-3 end - @testset "save_everystep = true populates the trajectory" begin - sol = solve(prob, TSRK("3bs"); dt = 0.1, save_everystep = true) + @testset "default with saveat: save_everystep = false (only requested times)" begin + saveat_times = [0.25, 0.5, 0.75] + sol = solve(prob, TSRK("3bs"); dt = 0.1, saveat = saveat_times) @test sol.retcode == ReturnCode.Success - @test length(sol.t) > 2 - @test issorted(sol.t) - # spot-check a couple of intermediate values against the analytical solution - for k in 1:length(sol.t) - @test sol.u[k][1] ≈ exp(-sol.t[k]) atol = 5e-3 + # With saveat provided, save_everystep defaults to false: + # only start (0.0) + saveat times + end (1.0) are saved, no intermediates. + @test length(sol.t) == 1 + length(saveat_times) + 1 + @test sol.t[1] ≈ 0.0 + @test sol.t[end] ≈ 1.0 + for ts in saveat_times + @test any(t -> isapprox(t, ts; atol = 1e-12), sol.t) end end diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index 7774bf3cc..c0347f785 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -220,7 +220,7 @@ end sol = solve( prob, TSRK("3bs"); dt = 0.1, callback = cb, - save_start = false, save_end = false, + save_start = false, save_end = false, save_everystep = false, ) @test sol.retcode == ReturnCode.Success @test length(sol.t) == 1 @@ -237,7 +237,7 @@ end sol2 = solve( prob, TSRK("3bs"); dt = 0.1, callback = cb_nosave, - save_start = false, save_end = false, + save_start = false, save_end = false, save_everystep = false, ) @test sol2.retcode == ReturnCode.Success @test isempty(sol2.t) @@ -747,7 +747,7 @@ end sol_off = solve( prob, TSRK("3bs"); dt = 0.1, callback = cb, - save_start = false, save_end = false, + save_start = false, save_end = false, save_everystep = false, initialize_save = false, ) @test sol_off.retcode == ReturnCode.Success @@ -759,7 +759,7 @@ end sol_on = solve( prob, TSRK("3bs"); dt = 0.1, callback = cb, - save_start = false, save_end = false, + save_start = false, save_end = false, save_everystep = false, ) @test sol_on.retcode == ReturnCode.Success @test init_ran[] From b51eb494a343b808fe0f1f210592b39691f2b049 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 10:23:19 +0200 Subject: [PATCH 27/38] docs --- README.md | 21 +---- docs/make.jl | 1 + docs/src/man/sciml.md | 181 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 docs/src/man/sciml.md diff --git a/README.md b/README.md index 244dea626..f785675b3 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Users will thus have to rely on the low-level interface. ## SciML integration -`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciML interface. The extension activates automatically when `SciMLBase` is loaded — `DiffEqBase` and `OrdinaryDiffEq` are not required. +`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciML interface. The extension activates automatically when `SciMLBase` is loaded. ```julia using PETSc, SciMLBase @@ -71,21 +71,8 @@ using PETSc, SciMLBase f!(du, u, p, t) = (du[1] = -u[1]; nothing) prob = ODEProblem(f!, [1.0], (0.0, 1.0)) -sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) # explicit RK -sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) # Rosenbrock-W -sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) # BDF / theta / CN / BEuler -sol = solve(SplitODEProblem(f1!, f2!, u0, tspan), - PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) # IMEX - -integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!/solve! interface -step!(integrator); sol = solve!(integrator) +sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) +sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) ``` -`OrdinaryDiffEq` also works as the trigger since it re-exports `SciMLBase`, but the extension only depends on `SciMLBase` — no `DiffEqBase` is needed. - -Per-solver PETSc command-line options are passed on the algorithm itself (a `Vector{String}` of raw tokens, e.g. `["-snes_fd", "-ts_max_steps", "100"]`). Discrete callbacks and `terminate!` are supported; `ContinuousCallback`s and `tstops` are rejected with `ArgumentError` so silently-skipped exact-time callbacks cannot bite users. Standard SciML control knobs `adaptive`, `dtmin`, and `dtmax` are mapped onto PETSc's `TSAdapt` controller; unrecognized solve keywords are rejected with a clear `ArgumentError` rather than silently dropped. The extension currently requires `PetscReal = Float64`, real-valued in-place ODE problems, and forward integration (`tspan[1] < tspan[2]`). See the docstrings of `PETSc.TSRK`, `PETSc.TSRosW`, `PETSc.TSImplicit`, `PETSc.TSARKIMEX`, and `PETSc.TSGeneric` for details. - -Known callback-lifecycle gaps (compared to OrdinaryDiffEq): - -- The SciML *discrete-save* lifecycle hooks `SciMLBase.save_discretes_if_enabled!` (after `initialize!`) and `SciMLBase.save_final_discretes!` (after `finalize!`) are invoked when the loaded SciMLBase version provides them, so `DiscreteCallback` machinery that records observable state at `t0` and at the end of the solve interoperates with the standard SciML lifecycle. Callbacks that depend on per-step `save_discretes_if_enabled!` calls *during* the solve are still not driven by the manual `TSStep` loop. -- `ContinuousCallback`s are rejected with `ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` directly if you need it. +See the [SciML Integration](https://juliaparallel.github.io/PETSc.jl/dev/man/sciml/) documentation page for the full API, all supported algorithm types, keyword arguments, and current limitations. diff --git a/docs/make.jl b/docs/make.jl index 17f7c94f7..38398e73c 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,6 +14,7 @@ makedocs(; "Home" => "index.md", "Installation" => "man/installation.md", "Getting Started" => "man/getting_started.md", + "SciML Integration" => "man/sciml.md", "High-level interface" => Any[ "Vec" => "man/vec.md", "Mat" => "man/mat.md", diff --git a/docs/src/man/sciml.md b/docs/src/man/sciml.md new file mode 100644 index 000000000..7e9b92c1b --- /dev/null +++ b/docs/src/man/sciml.md @@ -0,0 +1,181 @@ +# SciML Integration (ODE Time Stepping) + +`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve +in-place `ODEProblem`s with PETSc's TS time integrators through the standard +[SciML](https://sciml.ai/) interface. + +The extension activates automatically when `SciMLBase` is loaded — no +`DiffEqBase` is required: + +```julia +using PETSc, SciMLBase +``` + +`OrdinaryDiffEq` also works as the trigger since it re-exports `SciMLBase`, +but the extension itself only depends on `SciMLBase`. + +## Quick start + +```julia +using PETSc, SciMLBase + +f!(du, u, p, t) = (du[1] = -u[1]; nothing) +prob = ODEProblem(f!, [1.0], (0.0, 1.0)) + +sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) # explicit RK +sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) # Rosenbrock-W +sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) # BDF / theta / CN / BEuler + +f1!(du, u, p, t) = (du[1] = -u[1]; nothing) # stiff / implicit part +f2!(du, u, p, t) = (du[1] = cos(t); nothing) # non-stiff / explicit part +prob_split = SplitODEProblem(f1!, f2!, [1.0], (0.0, 1.0)) +sol = solve(prob_split, PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) # IMEX + +integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!/solve! interface +step!(integrator) +sol = solve!(integrator) +``` + +## Algorithm types + +All algorithm types are subtypes of `PETSc.PETScTSAlgorithm` and are +accessible from the top-level `PETSc` module. + +### `PETSc.TSRK` — Explicit Runge-Kutta + +```julia +PETSc.TSRK(subtype::String[, petsc_options]) +``` + +Calls `TSSetType(ts, "rk")` and `TSRKSetType(ts, subtype)`. Suitable for +non-stiff ODEs. + +Common subtypes: `"3bs"` (Bogacki-Shampine order 3), `"4"` (classical +4th-order RK), `"5dp"` (Dormand-Prince order 5), `"5bs"`. The full list +is available via `-ts_rk_type` in PETSc's option database. + +### `PETSc.TSRosW` — Rosenbrock-W (linearly implicit) + +```julia +PETSc.TSRosW(subtype::String[, petsc_options]) +``` + +Calls `TSSetType(ts, "rosw")` and `TSRosWSetType(ts, subtype)`. Suitable +for stiff ODEs without a user-supplied analytic Jacobian — pass `"-snes_fd"` +in `petsc_options` to ask PETSc to compute Jacobians by finite differences. + +Common subtypes: `"ra34pw2"`, `"rodas3"`, `"2m"`. See `-ts_rosw_type`. + +### `PETSc.TSImplicit` — Fully implicit + +```julia +PETSc.TSImplicit(subtype::String[, theta::Real][, petsc_options]) +``` + +Selects a fully-implicit PETSc TS type via `TSSetType(ts, subtype)`. + +| `subtype` | PETSc method | Notes | +|:-----------|:-------------------------|:-------------------------------------------------------| +| `"beuler"` | Backward Euler | Order 1, L-stable | +| `"cn"` | Crank-Nicolson | Order 2, A-stable | +| `"theta"` | Theta method | `theta` selects between BE (`1.0`) and CN (`0.5`) | +| `"bdf"` | Backward differentiation | Set order via `["-ts_bdf_order", "3"]` in options | + +`theta` defaults to `0.5` and is consulted only when `subtype == "theta"`. +Pass `"-snes_fd"` in `petsc_options` when no analytic Jacobian is available. + +### `PETSc.TSARKIMEX` — Additive Runge-Kutta IMEX + +```julia +PETSc.TSARKIMEX(subtype::String[, petsc_options]) +``` + +Calls `TSSetType(ts, "arkimex")` and `TSARKIMEXSetType(ts, subtype)`. +Designed for `SplitODEProblem`s of the form `u' = f1(u,p,t) + f2(u,p,t)` +where `f1` is the stiff/implicit part and `f2` is the non-stiff/explicit part. + +When a plain `ODEProblem` is passed, the full RHS is treated as the implicit +part and the explicit part is left at zero. + +Common subtypes: `"2e"`, `"3"`, `"4"`, `"5"`. See `-ts_arkimex_type`. + +### `PETSc.TSGeneric` — Pass-through + +```julia +PETSc.TSGeneric(ts_type::String[, petsc_options]; explicit::Bool = false) +``` + +Calls `TSSetType(ts, ts_type)` directly without any subtype-specific +configuration. Useful for PETSc TS types that do not have a dedicated wrapper +(e.g. `"alpha"`, `"glle"`, `"glee"`). + +Pass `explicit = true` for PETSc TS types that register an RHS function +rather than an IFunction (e.g. `"euler"`, `"ssp"`). + +## PETSc options + +Per-solver PETSc command-line options are passed on the algorithm object as a +`Vector{String}` of raw tokens: + +```julia +alg = PETSc.TSRK("3bs", ["-ts_monitor", "-ts_max_steps", "100"]) +alg = PETSc.TSImplicit("bdf", ["-snes_fd", "-ts_bdf_order", "3"]) +``` + +The tokens are applied via `TSSetFromOptions` after the TS type and subtype +are configured, so they can override any default. + +## Supported `solve` keywords + +| Keyword | Meaning | +|:-------------------|:----------------------------------------------------------------------| +| `dt` | Initial time step | +| `adaptive` | `true` (default) enables PETSc's `TSAdapt` controller; `false` fixes dt | +| `dtmin`, `dtmax` | Step-size bounds for the adaptive controller | +| `reltol`, `abstol` | Scalar tolerances forwarded to `TSSetTolerances` | +| `maxiters` | Maximum number of accepted steps | +| `saveat` | Times at which to save the solution (vector or scalar spacing) | +| `save_everystep` | Save at every step endpoint (default: `true` when `saveat` is empty) | +| `save_start` | Save the initial state `u(t0)` (default: `true`) | +| `save_end` | Save the final state `u(tf)` (default: `true`) | +| `save_on` | Master switch: suppress all trajectory output when `false` | +| `callback` | `DiscreteCallback` or `CallbackSet` of discrete callbacks | +| `initialize_save` | Run post-`initialize!` save record (default: `true`) | +| `petsclib` | Override the PETSc library instance to use | + +Unrecognized keywords are rejected with a clear `ArgumentError` rather than +silently dropped. + +## Callbacks + +`DiscreteCallback`s and `terminate!` are supported. Their `initialize` and +`finalize` hooks are called at the start and end of the solve, matching the +standard SciML lifecycle. + +`ContinuousCallback`s are **not** supported and are rejected with +`ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` +directly if needed. + +`tstops` is also **not** yet supported (rejected with `ArgumentError`) because +the extension drives `TSStep` directly and cannot currently guarantee exact +landing on requested times. + +## Current limitations + +- Only real-valued, in-place `ODEProblem`s (`f!(du, u, p, t)`) are supported. +- Only forward integration (`tspan[1] < tspan[2]`) is supported. +- The extension requires a PETSc library built with `PetscReal = Float64`. +- TSIRK/Gauss methods require an AIJ sparse Jacobian registered via + `TSSetIJacobian`; the extension does not yet set one up, so those methods + are not available through the SciML interface. +- `ContinuousCallback`s and `tstops` are not yet honoured (see above). + +## Algorithm docstrings + +```@docs +PETSc.TSRK +PETSc.TSRosW +PETSc.TSImplicit +PETSc.TSARKIMEX +PETSc.TSGeneric +``` From 26e3c327e303835e7b4c488214c8f91da4fa19dc Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Mon, 4 May 2026 10:47:20 +0200 Subject: [PATCH 28/38] ex16_sciml.jl --- examples/ex16_sciml.jl | 143 ++++++++++++++++++++++++++++++ examples/ex51_sciml.jl | 4 + test/sciml/runtests.jl | 1 + test/sciml/test_ex16_sciml.jl | 159 ++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 examples/ex16_sciml.jl create mode 100644 test/sciml/test_ex16_sciml.jl diff --git a/examples/ex16_sciml.jl b/examples/ex16_sciml.jl new file mode 100644 index 000000000..76c4c76e4 --- /dev/null +++ b/examples/ex16_sciml.jl @@ -0,0 +1,143 @@ +using PETSc, SciMLBase + +# Van der Pol example adapted from PETSc TS tutorial `ex16.c`, see +# https://petsc.org/main/src/ts/tutorials/ex16.c.html. +# +# The second-order ODE +# +# y'' - mu * ((1 - y^2) * y' - y) = 0 +# +# is rewritten as the first-order system +# +# u1_t = u2 +# u2_t = mu * ((1 - u1^2) * u2 - u1) +# +# In IMEX mode (`imex = true`) the right-hand side is split into a +# stiff/implicit part and a non-stiff/explicit part, matching the split used +# in examples/ex16.jl: +# +# implicit (f1): du[1] = 0, du[2] = mu * ((1 - u1^2) * u2 - u1) +# explicit (f2): du[1] = u2, du[2] = 0 +# +# In non-IMEX mode (`imex = false`) the full right-hand side is treated as a +# single implicit term, matching the upstream `beuler` / `cn` / `theta` path. +# +# This version demonstrates how to use the SciML integration of PETSc.jl. +# Compare with examples/ex16.jl for the low-level PETSc TS interface. + +# Stiff/implicit part of the van der Pol RHS (for SplitODEProblem). +# `p.mu` is the van der Pol parameter. The equation is autonomous, so `t` +# is unused. +function ex16_f1!(du, u, p, t) + mu = p.mu + du[1] = zero(u[1]) + du[2] = mu * ((1 - u[1]^2) * u[2] - u[1]) + return nothing +end + +# Non-stiff/explicit part of the van der Pol RHS (for SplitODEProblem). +# This term has no parameter or time dependence. +function ex16_f2!(du, u, p, t) + du[1] = u[2] + du[2] = zero(u[2]) + return nothing +end + +# Full right-hand side for the non-IMEX (plain ODEProblem) case. +function ex16_f!(du, u, p, t) + mu = p.mu + du[1] = u[2] + du[2] = mu * ((1 - u[1]^2) * u[2] - u[1]) + return nothing +end + +# Initial condition matching examples/ex16.jl (and upstream ex16.c). +function ex16_initial_condition(mu::Real) + return Float64[ + 2.0, + -2.0 / 3.0 + 10.0 / (81.0 * mu) - 292.0 / (2187.0 * mu^2), + ] +end + +""" + solve_ex16(; kwargs...) + +Solve the van der Pol example from PETSc TS tutorial `ex16.c` using the SciML +interface of PETSc.jl. + +In IMEX mode (`imex = true`, default) the problem is posed as a +`SplitODEProblem` and solved with `PETSc.TSARKIMEX`. In non-IMEX mode +(`imex = false`) a plain `ODEProblem` is solved with `PETSc.TSImplicit`. +Compare with `solve_ex16` in `examples/ex16.jl` for the equivalent low-level +PETSc TS interface. + +# Keyword arguments +- `mu`: van der Pol stiffness parameter. Default `1000.0`. +- `imex`: split into stiff/non-stiff parts (IMEX) when `true`, fully implicit + when `false`. Default `true`. +- `final_time`: final integration time. Default `0.5`. +- `dt`: initial time step. Default `0.01`. +- `alg`: PETSc TS algorithm. Defaults to `PETSc.TSARKIMEX("2e", ["-snes_fd"])` + when `imex = true`, `PETSc.TSImplicit("beuler", ["-snes_fd"])` when + `imex = false`. Any `PETSc.PETScTSAlgorithm` can be passed. +- `adaptive`: enable PETSc's adaptive time-step controller. Default `true`. +- `verbose`: print a summary after the solve. Default `true`. +- `kwargs...`: additional keyword arguments forwarded to `solve`. + +# Returns +A named tuple `(final_time, steps, solution, mu, imex)` where `steps` is the +number of accepted time steps and `solution` is the state vector at the final +time. +""" +function solve_ex16(; + mu::Real = 1000.0, + imex::Bool = true, + final_time::Real = 0.5, + dt::Real = 0.01, + alg = nothing, + adaptive::Bool = true, + verbose::Bool = true, + kwargs..., +) + u0 = ex16_initial_condition(mu) + tspan = (0.0, Float64(final_time)) + p = (; mu = Float64(mu)) + + if imex + effective_alg = alg === nothing ? PETSc.TSARKIMEX("2e", ["-snes_fd"]) : alg + prob = SplitODEProblem(ex16_f1!, ex16_f2!, u0, tspan, p) + else + effective_alg = alg === nothing ? PETSc.TSImplicit("beuler", ["-snes_fd"]) : alg + prob = ODEProblem(ex16_f!, u0, tspan, p) + end + + sol = solve(prob, effective_alg; dt, adaptive, kwargs...) + + current_time = sol.t[end] + solution = sol.u[end] + steps = sol.stats !== nothing ? sol.stats.naccept : length(sol.t) - 1 + + if verbose + if !(current_time ≈ final_time) + @warn "prescribed `final_time` differs from `current_time`" final_time current_time + end + println("Algorithm: ", effective_alg) + println("mu: ", mu) + println("IMEX split: ", imex) + println("Final time: ", current_time) + println("Steps: ", steps) + println("Final solution: ", solution) + end + + return ( + final_time = current_time, + steps = steps, + solution = solution, + mu = mu, + imex = imex, + ) +end + +if !isinteractive() && abspath(PROGRAM_FILE) == @__FILE__ + solve_ex16() +end diff --git a/examples/ex51_sciml.jl b/examples/ex51_sciml.jl index ca405d77d..6cf0a6960 100644 --- a/examples/ex51_sciml.jl +++ b/examples/ex51_sciml.jl @@ -65,3 +65,7 @@ function solve_ex51(; return (final_time = current_time, error = error_norm, solution = sol.u[end]) end + +if !isinteractive() && abspath(PROGRAM_FILE) == @__FILE__ + solve_ex51() +end diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 2813ec2c2..1da5403dc 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -24,6 +24,7 @@ using SciMLBase include("test_rk.jl") include("test_ex51_sciml.jl") + include("test_ex16_sciml.jl") include("test_rosenbrock.jl") include("test_implicit.jl") include("test_imex.jl") diff --git a/test/sciml/test_ex16_sciml.jl b/test/sciml/test_ex16_sciml.jl new file mode 100644 index 000000000..0e6c38a12 --- /dev/null +++ b/test/sciml/test_ex16_sciml.jl @@ -0,0 +1,159 @@ +using Test +using PETSc +using SciMLBase + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing + +include(joinpath(dirname(dirname(@__DIR__)), "examples", "ex16_sciml.jl")) + +# Van der Pol ODE (ex16) — notes on the test design +# +# The problem is stiff for large mu; mu = 1000 is the upstream default. +# In the IMEX split (`imex = true`) the stiff part is isolated in f1 and +# handled implicitly, so TSARKIMEX converges reliably with -snes_fd even at +# mu = 1000. For the fully-implicit path (`imex = false`) the entire Jacobian +# is approximated by finite differences without the benefit of the analytic +# Jacobian registered in examples/ex16.jl, so the tests there use a more +# moderate mu = 100 to ensure robust SNES convergence. +# +# All solves use adaptive = true (the ex16_sciml default) so the step-size +# controller compensates for the stiffness automatically. + +@testset "ex16_sciml example" begin + + @testset "TSARKIMEX IMEX (imex = true)" begin + @testset "default solve reaches final time with a finite solution" begin + result = solve_ex16(; verbose = false) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test result.steps > 0 + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + @test result.imex == true + end + + @testset "repeated solves give the same result" begin + r1 = solve_ex16(; verbose = false) + r2 = solve_ex16(; verbose = false) + @test r1.solution ≈ r2.solution rtol = 1e-12 + @test r1.steps == r2.steps + end + + @testset "TSARKIMEX subtypes all reach final time" begin + for subtype in ("2e", "3", "4", "5", "ars122", "ars443") + @testset "TSARKIMEX(\"$subtype\")" begin + result = solve_ex16(; + alg = PETSc.TSARKIMEX(subtype, ["-snes_fd"]), + verbose = false, + ) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test result.steps > 0 + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + end + end + end + + @testset "TSImplicit fully implicit (imex = false)" begin + # Use mu = 100 (less stiff) so the finite-difference Jacobian converges + # without the hand-coded analytical Jacobian from examples/ex16.jl. + @testset "TSImplicit(\"beuler\") reaches final time" begin + result = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + verbose = false, + ) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test result.steps > 0 + @test !result.imex + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + + @testset "TSImplicit(\"cn\") reaches final time" begin + result = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSImplicit("cn", ["-snes_fd"]), + verbose = false, + ) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + + @testset "TSImplicit(\"bdf\") reaches final time" begin + result = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSImplicit("bdf", ["-snes_fd"]), + verbose = false, + ) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + + @testset "repeated fully-implicit solves give the same result" begin + r1 = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + verbose = false, + ) + r2 = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + verbose = false, + ) + @test r1.solution ≈ r2.solution rtol = 1e-12 + end + end + + @testset "TSRosW Rosenbrock-W (imex = false)" begin + @testset "TSRosW(\"ra34pw2\") reaches final time" begin + result = solve_ex16(; + imex = false, mu = 100.0, + alg = PETSc.TSRosW("ra34pw2", ["-snes_fd"]), + verbose = false, + ) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test result.steps > 0 + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + end + + @testset "mu parameter" begin + @testset "mu = 100.0 returns the correct parameter in result" begin + result = solve_ex16(; mu = 100.0, verbose = false) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test result.mu ≈ 100.0 + @test length(result.solution) == 2 + @test all(isfinite, result.solution) + end + + @testset "less stiff problem (mu = 100) needs no more steps than stiff (mu = 1000)" begin + r_stiff = solve_ex16(; mu = 1000.0, verbose = false) + r_mild = solve_ex16(; mu = 100.0, verbose = false) + # A smaller stiffness parameter should require at most as many steps. + @test r_mild.steps <= r_stiff.steps + end + + @testset "mu = 1000.0 (default) yields a finite solution" begin + result = solve_ex16(; mu = 1000.0, verbose = false) + @test result.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test all(isfinite, result.solution) + end + end + + @testset "imex flag is reflected in the return value" begin + r_imex = solve_ex16(; imex = true, verbose = false) + r_noimex = solve_ex16(; imex = false, mu = 100.0, + alg = PETSc.TSImplicit("beuler", ["-snes_fd"]), + verbose = false) + @test r_imex.imex == true + @test r_noimex.imex == false + # Both reach the requested final time. + @test r_imex.final_time ≈ 0.5 atol = 100 * eps(Float64) + @test r_noimex.final_time ≈ 0.5 atol = 100 * eps(Float64) + end +end From b4c6f7f160a2043e1fb91acb8bad4f96f4c5487e Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Wed, 17 Jun 2026 10:41:59 +0200 Subject: [PATCH 29/38] improve --- docs/src/man/sciml.md | 9 +++++++ ext/sciml/helpers.jl | 8 ++++++ ext/sciml/ifunction_callback.jl | 43 ++++++++++++++++++++------------- ext/sciml/options.jl | 4 +++ ext/sciml/rhs_callback.jl | 36 ++++++++++++++++++--------- ext/sciml/solve.jl | 41 ++++++++++++++++++++++++++++--- test/sciml/test_output.jl | 3 ++- test/sciml/test_review_fixes.jl | 32 ++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 33 deletions(-) diff --git a/docs/src/man/sciml.md b/docs/src/man/sciml.md index 7e9b92c1b..f8a1ce9f1 100644 --- a/docs/src/man/sciml.md +++ b/docs/src/man/sciml.md @@ -146,6 +146,15 @@ are configured, so they can override any default. Unrecognized keywords are rejected with a clear `ArgumentError` rather than silently dropped. +!!! note "Default tolerances" + If you pass **neither** `reltol` nor `abstol`, the wrapper leaves PETSc's + own `TSAdapt` default tolerances in place rather than imposing SciML's + defaults — PETSc's defaults are tighter, so the bare adaptive solve is at + least as accurate. If you pass **only one** of the two, the missing side is + filled from SciML's defaults (`reltol = 1e-3`, `abstol = 1e-6`) so the + value you did pass actually reaches the controller. Pass both explicitly + for full control. + ## Callbacks `DiscreteCallback`s and `terminate!` are supported. Their `initialize` and diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index d6f6137db..fea9d8c18 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -1,3 +1,11 @@ +# Nonzero PETSc error code returned by the RHS / IFunction `@cfunction`s when +# the user function throws. 83 is `PETSC_ERR_PLIB` ("generic library error"); +# the exact value is unimportant — any nonzero code makes `@chk` in `TSStep` +# raise so `step!` can rethrow the captured Julia exception. The +# `_take_callback_error!` helpers that consume `ctx.err` live in `solve.jl`, +# after `RHSCtx` / `IFunctionCtx` are defined. +const _PETSC_CALLBACK_ERRCODE = PETSc.LibPETSc.PetscErrorCode(83) + function _check_isinplace(prob) SciMLBase.isinplace(prob) || throw(ArgumentError( "PETSc.jl time-stepping wrappers only support in-place ODEProblems " * diff --git a/ext/sciml/ifunction_callback.jl b/ext/sciml/ifunction_callback.jl index e373b4ded..bad64fae4 100644 --- a/ext/sciml/ifunction_callback.jl +++ b/ext/sciml/ifunction_callback.jl @@ -4,8 +4,9 @@ mutable struct IFunctionCtx{F, P, SZ, Lib} sizeu::SZ petsclib::Lib nf::Int # cumulative count of user-RHS evaluations for `sol.stats.nf` + err::Any # first exception thrown by the user RHS, surfaced after TSStep end -IFunctionCtx(f, p, sizeu, petsclib) = IFunctionCtx(f, p, sizeu, petsclib, 0) +IFunctionCtx(f, p, sizeu, petsclib) = IFunctionCtx(f, p, sizeu, petsclib, 0, nothing) function _petsc_ifunction!( ::PETSc.LibPETSc.CTS, @@ -16,23 +17,31 @@ function _petsc_ifunction!( ctx_ptr::Ptr{Cvoid}, )::PETSc.LibPETSc.PetscErrorCode ctx = unsafe_pointer_to_objref(ctx_ptr)::IFunctionCtx - petsclib = ctx.petsclib - u = PETSc.VecPtr(petsclib, u_ptr, false) - udot = PETSc.VecPtr(petsclib, udot_ptr, false) - Fv = PETSc.VecPtr(petsclib, F_ptr, false) - PETSc.withlocalarray!( - (u, udot, Fv); - read = (true, true, false), - write = (false, false, true), - ) do u_array, udot_array, F_array - F_reshaped = reshape(F_array, ctx.sizeu) - u_reshaped = reshape(u_array, ctx.sizeu) - udot_reshaped = reshape(udot_array, ctx.sizeu) - ctx.f(F_reshaped, u_reshaped, ctx.p, t) - @. F_reshaped = udot_reshaped - F_reshaped + # See the note in `_petsc_rhs!`: never let a Julia exception unwind across + # the C boundary. Stash the first error and return a nonzero code so the + # implicit solve / TSStep aborts and `step!` can rethrow the real error. + try + petsclib = ctx.petsclib + u = PETSc.VecPtr(petsclib, u_ptr, false) + udot = PETSc.VecPtr(petsclib, udot_ptr, false) + Fv = PETSc.VecPtr(petsclib, F_ptr, false) + PETSc.withlocalarray!( + (u, udot, Fv); + read = (true, true, false), + write = (false, false, true), + ) do u_array, udot_array, F_array + F_reshaped = reshape(F_array, ctx.sizeu) + u_reshaped = reshape(u_array, ctx.sizeu) + udot_reshaped = reshape(udot_array, ctx.sizeu) + ctx.f(F_reshaped, u_reshaped, ctx.p, t) + @. F_reshaped = udot_reshaped - F_reshaped + end + ctx.nf += 1 + return PETSc.LibPETSc.PetscErrorCode(0) + catch e + ctx.err === nothing && (ctx.err = e) + return _PETSC_CALLBACK_ERRCODE end - ctx.nf += 1 - return PETSc.LibPETSc.PetscErrorCode(0) end const _PETSC_IFUNCTION_PTR = Ref{Ptr{Cvoid}}(C_NULL) diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index aa5cd11df..e7f2d8ca7 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -101,6 +101,10 @@ function _build_opts( _validate_saveat(saveat_materialized) saveat_expanded = _expand_saveat(saveat_materialized, tdir, tspan, tType) saveat_data = tType[tdir * t for t in saveat_expanded if t0 < tdir * t <= tf] + # User `tstops` are rejected up front in `__init` (the PETSc-driven step + # loop does not yet land on them), so this collection is normally just the + # terminal `tf`. It is retained because `terminate!` drains `opts.tstops` + # and to leave the scaffolding in place for a future tstops-aware loop. tstops_data = tType[ tdir * t for t in _as_time_iter(tstops_materialized, tType) if t0 < tdir * t <= tf ] diff --git a/ext/sciml/rhs_callback.jl b/ext/sciml/rhs_callback.jl index efe8cb6f2..942737aa5 100644 --- a/ext/sciml/rhs_callback.jl +++ b/ext/sciml/rhs_callback.jl @@ -4,8 +4,9 @@ mutable struct RHSCtx{F, P, SZ, Lib} sizeu::SZ petsclib::Lib nf::Int # cumulative count of user-RHS evaluations for `sol.stats.nf` + err::Any # first exception thrown by the user RHS, surfaced after TSStep end -RHSCtx(f, p, sizeu, petsclib) = RHSCtx(f, p, sizeu, petsclib, 0) +RHSCtx(f, p, sizeu, petsclib) = RHSCtx(f, p, sizeu, petsclib, 0, nothing) function _petsc_rhs!( ::PETSc.LibPETSc.CTS, @@ -15,18 +16,29 @@ function _petsc_rhs!( ctx_ptr::Ptr{Cvoid}, )::PETSc.LibPETSc.PetscErrorCode ctx = unsafe_pointer_to_objref(ctx_ptr)::RHSCtx - petsclib = ctx.petsclib - u = PETSc.VecPtr(petsclib, u_ptr, false) - fv = PETSc.VecPtr(petsclib, f_ptr, false) - PETSc.withlocalarray!( - (u, fv); - read = (true, false), - write = (false, true), - ) do u_array, f_array - ctx.f(reshape(f_array, ctx.sizeu), reshape(u_array, ctx.sizeu), ctx.p, t) + # A Julia exception must never unwind through this `@cfunction` boundary + # back into PETSc's C stack — that is undefined behaviour and typically + # segfaults. Catch everything, stash the first error on the context, and + # return a nonzero PETSc error code so the surrounding `TSStep` aborts. + # `step!` rethrows the stored exception so the user sees their original + # error instead of an opaque PETSc one. See `_take_callback_error!`. + try + petsclib = ctx.petsclib + u = PETSc.VecPtr(petsclib, u_ptr, false) + fv = PETSc.VecPtr(petsclib, f_ptr, false) + PETSc.withlocalarray!( + (u, fv); + read = (true, false), + write = (false, true), + ) do u_array, f_array + ctx.f(reshape(f_array, ctx.sizeu), reshape(u_array, ctx.sizeu), ctx.p, t) + end + ctx.nf += 1 + return PETSc.LibPETSc.PetscErrorCode(0) + catch e + ctx.err === nothing && (ctx.err = e) + return _PETSC_CALLBACK_ERRCODE end - ctx.nf += 1 - return PETSc.LibPETSc.PetscErrorCode(0) end const _PETSC_RHS_PTR = Ref{Ptr{Cvoid}}(C_NULL) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index c214127b2..fdcb84634 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -35,7 +35,9 @@ end # SciML default tolerances applied when the user passes only one of # `reltol` / `abstol`. This matches OrdinaryDiffEq's defaults so a partial # specification (e.g. `reltol = 1e-10`) actually reaches PETSc instead of -# being silently dropped. +# being silently dropped. When the user passes *neither*, we deliberately +# leave PETSc's own (tighter) TS default tolerances in place — see +# `_apply_tolerances!`. const _SCIML_DEFAULT_RELTOL = 1e-3 const _SCIML_DEFAULT_ABSTOL = 1e-6 @@ -94,7 +96,10 @@ end # Forward `reltol` / `abstol` to PETSc when at least one is set. Filling the # missing side from SciML's defaults is the convention upstream wrappers use: # `solve(prob, alg; reltol = 1e-10)` should reach the adaptive controller -# rather than be silently ignored. +# rather than be silently ignored. When the user sets *neither*, we leave +# PETSc's own TS default tolerances untouched: they are tighter than SciML's +# defaults, so a bare adaptive `solve` is at least as accurate, and forcing +# the looser SciML values here would only degrade the default solve. function _apply_tolerances!(lib, ts, reltol, abstol) (reltol === nothing && abstol === nothing) && return nothing rt = reltol === nothing ? _SCIML_DEFAULT_RELTOL : reltol @@ -566,6 +571,21 @@ function SciMLBase.__solve( return SciMLBase.solve!(integ) end +# Pull (and clear) the first user-callback exception stashed on a context by +# `_petsc_rhs!` / `_petsc_ifunction!`. The IMEX path keeps both contexts in a +# NamedTuple, so recurse over its fields. Anything else yields `nothing`. +# Clearing on read keeps a recovered retry from re-raising a stale error. +_take_callback_error!(ctx::Union{RHSCtx, IFunctionCtx}) = + (e = ctx.err; ctx.err = nothing; e) +function _take_callback_error!(ctx::NamedTuple) + for c in ctx + e = _take_callback_error!(c) + e === nothing || return e + end + return nothing +end +_take_callback_error!(::Any) = nothing + function SciMLBase.step!(integ::PETScTSIntegrator) integ.done && return nothing @@ -592,9 +612,24 @@ function SciMLBase.step!(integ::PETScTSIntegrator) integ.tprev = integ.t GC.@preserve integ begin - PETSc.LibPETSc.TSStep(integ.petsclib, integ.ts) + try + PETSc.LibPETSc.TSStep(integ.petsclib, integ.ts) + catch + # If `TSStep` failed because a user RHS / IFunction threw, surface + # that original exception rather than the opaque PETSc error the + # nonzero callback return triggered. + user_err = _take_callback_error!(integ.cb_ctx) + user_err === nothing ? rethrow() : throw(user_err) + end end + # An implicit solve (SNES) may swallow a nonzero IFunction return as a + # "function domain error" and reject the step instead of failing `TSStep` + # outright. Check for a stashed user exception even on the success path so + # those errors are not silently turned into a step rejection / divergence. + user_err = _take_callback_error!(integ.cb_ctx) + user_err === nothing || throw(user_err) + _sync_petsc_to_julia!(integ) integ.t = typeof(integ.t)(PETSc.LibPETSc.TSGetTime(integ.petsclib, integ.ts)) integ.dt = typeof(integ.dt)(PETSc.LibPETSc.TSGetTimeStep(integ.petsclib, integ.ts)) diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index f1ee4bb31..0302e6833 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -90,7 +90,8 @@ end opts = ext._build_opts( Float64, [0.25, 0.5, 0.75], (), -1.0, (1.0, 0.0); save_everystep = false, save_on = true, - save_start = true, save_end = true, callback = nothing, + save_start = true, save_end = true, save_discretes = true, + callback = nothing, reltol = 1e-3, abstol = 1e-6, maxiters = 1000, ) ordered = Float64[] diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index c0347f785..ffb2a542d 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -8,6 +8,10 @@ const TSRK = ext.TSRK const TSImplicit = ext.TSImplicit const TSARKIMEX = ext.TSARKIMEX +# Sentinel exception for the "user RHS error surfaces" testset. Declared at top +# level because Julia forbids `struct` definitions inside a `@testset`'s scope. +struct _RHSBoom <: Exception end + # Counters for hook-forwarding tests. The shim methods below are more specific # than the SciMLBase generics and intercept calls for `PETScTSIntegrator`, so # tests can count actual invocations of the SciML hook API rather than relying @@ -910,4 +914,32 @@ end @test_throws ArgumentError solve(prob, TSRK("5dp"); dt = Inf) @test_throws ArgumentError solve(prob, TSRK("5dp"); dtmin = Inf) end + + @testset "User RHS exception surfaces as the original error, not a crash" begin + # A Julia exception thrown inside the RHS / IFunction `@cfunction` must + # not unwind across the C boundary (that segfaults). Instead it is + # captured and rethrown by `step!`, so the user sees their own error. + # (`_RHSBoom` is defined at top level — structs cannot live in the + # local scope of a `@testset`.) + boom!(du, u, p, t) = throw(_RHSBoom()) + u0 = [1.0, 0.0] + tspan = (0.0, 1.0) + + # Explicit RHS path (TSRK). + prob_rk = ODEProblem(boom!, u0, tspan) + @test_throws _RHSBoom solve( + prob_rk, TSRK("3bs"); dt = 0.1, adaptive = false, + ) + + # Implicit IFunction path (TSImplicit, evaluated inside the SNES solve). + prob_be = ODEProblem(boom!, u0, tspan) + @test_throws _RHSBoom solve( + prob_be, TSImplicit("beuler", ["-snes_fd"]); dt = 0.1, adaptive = false, + ) + + # A clean solve still succeeds afterwards (no lingering captured error). + good!(du, u, p, t) = (du .= -u) + sol = solve(ODEProblem(good!, u0, tspan), TSRK("3bs"); dt = 0.1) + @test sol.retcode == ReturnCode.Success + end end From 00881750f6c2631920563d388d98197f4eb9959e Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 15:58:32 +0200 Subject: [PATCH 30/38] clean up --- CITATION.bib | 6 ++--- Project.toml | 2 +- README.md | 5 +++-- docs/Project.toml | 1 + docs/make.jl | 8 ++++++- docs/src/man/sciml.md | 50 ++++++++++++++++++++---------------------- test/Project.toml | 16 -------------- test/runtests.jl | 19 +--------------- test/sciml/runtests.jl | 13 ----------- 9 files changed, 40 insertions(+), 80 deletions(-) delete mode 100644 test/Project.toml diff --git a/CITATION.bib b/CITATION.bib index 519b076c6..2aadd68a2 100644 --- a/CITATION.bib +++ b/CITATION.bib @@ -2,12 +2,12 @@ @software{petsc_jl author = {Kaus, Boris and Shah, Viral B. and Kozdon, Jeremy E. and - Churavy, Valentin and + Churavy, Valentin and Ranocha, Hendrik and Schnetter, Erik and Byrne, Simon}, - title = {PETSc.jl: Julia bindings for PETSc}, - year = 2026, + title = {{PETSc.jl}: {J}ulia bindings for {PETSc}}, + year = {2026}, publisher = {Zenodo}, doi = {10.5281/zenodo.18274810}, url = {https://github.com/JuliaParallel/PETSc.jl} diff --git a/Project.toml b/Project.toml index 8b41d6a3d..715ab9701 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PETSc" uuid = "ace2c81b-2b5f-4b1e-a30d-d662738edfe0" version = "0.4.10" -authors = ["Boris Kaus ", "Viral B. Shah ", "Valentin Churavy ", "Erik Schnetter ", "Jeremy E. Kozdon ", "Simon Byrne "] +authors = ["Boris Kaus ", "Viral B. Shah ", "Jeremy E. Kozdon ", "Valentin Churavy ", "Hendrik Ranocha ", "Erik Schnetter ", "Simon Byrne "] [deps] ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" diff --git a/README.md b/README.md index 4026f9d7f..5126b2dd0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ Note that we do not have tests in place for the whole library at this stage. The ## SciML integration -`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciML interface. The extension activates automatically when `SciMLBase` is loaded. +PETSc.jl ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard SciML interface. +The extension activates automatically when SciMLBase.jl is loaded, e.g., when other ODE solver packages from the SciML ecosystem are used. ```julia using PETSc, SciMLBase @@ -69,7 +70,7 @@ using PETSc, SciMLBase f!(du, u, p, t) = (du[1] = -u[1]; nothing) prob = ODEProblem(f!, [1.0], (0.0, 1.0)) -sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) +sol = solve(prob, PETSc.TSRK("3bs")) sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) ``` diff --git a/docs/Project.toml b/docs/Project.toml index ca8b47cd6..3d3f0981d 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -2,3 +2,4 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" MPI = "da04e1cc-30fd-572f-bb4f-1f8673147195" PETSc = "ace2c81b-2b5f-4b1e-a30d-d662738edfe0" +SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" diff --git a/docs/make.jl b/docs/make.jl index 4f4551b00..4067d8ce5 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -4,7 +4,13 @@ makedocs(; modules=[PETSc], sitename="PETSc.jl", checkdocs=:exports, # Only check exported functions, skip LibPETSc internals - warnonly=true, # Warn but don't error for any documentation issues + # Warn (don't error) for the documentation issues the large auto-wrapped + # LibPETSc API trips, but let `@example` / `@repl` block failures error the + # build so the executed code examples (e.g. man/sciml.md) stay functional. + warnonly=[ + :autodocs_block, :cross_references, :docs_block, :eval_block, + :footnote, :linkcheck, :linkcheck_remotes, :meta_block, :missing_docs, + ], format=Documenter.HTML(; prettyurls=get(ENV, "CI", "false") == "true", size_threshold_warn = nothing, # Disable size warnings for large low-level API pages diff --git a/docs/src/man/sciml.md b/docs/src/man/sciml.md index f8a1ce9f1..753653367 100644 --- a/docs/src/man/sciml.md +++ b/docs/src/man/sciml.md @@ -1,37 +1,36 @@ # SciML Integration (ODE Time Stepping) -`PETSc.jl` ships a package extension (`PETScSciMLExt`) that lets you solve +PETSc.jl ships a package extension (`PETScSciMLExt`) that lets you solve in-place `ODEProblem`s with PETSc's TS time integrators through the standard [SciML](https://sciml.ai/) interface. -The extension activates automatically when `SciMLBase` is loaded — no -`DiffEqBase` is required: +The extension activates automatically when SciMLBase.jl is loaded, e.g., when other ODE solver packages from the SciML ecosystem are used: -```julia +```@example sciml using PETSc, SciMLBase ``` -`OrdinaryDiffEq` also works as the trigger since it re-exports `SciMLBase`, -but the extension itself only depends on `SciMLBase`. +OrdinaryDiffEq.jl also works as the trigger since it re-exports SciMLBase.jl, +but the extension itself only depends on SciMLBase.jl. ## Quick start -```julia +```@example sciml using PETSc, SciMLBase f!(du, u, p, t) = (du[1] = -u[1]; nothing) prob = ODEProblem(f!, [1.0], (0.0, 1.0)) -sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1) # explicit RK -sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) # Rosenbrock-W -sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) # BDF / theta / CN / BEuler +sol = solve(prob, PETSc.TSRK("3bs")) # explicit RK +sol = solve(prob, PETSc.TSRosW("ra34pw2", ["-snes_fd"]); dt = 1e-3) # Rosenbrock-W +sol = solve(prob, PETSc.TSImplicit("bdf", ["-snes_fd"]); dt = 1e-3) # BDF / theta / CN / BEuler f1!(du, u, p, t) = (du[1] = -u[1]; nothing) # stiff / implicit part f2!(du, u, p, t) = (du[1] = cos(t); nothing) # non-stiff / explicit part prob_split = SplitODEProblem(f1!, f2!, [1.0], (0.0, 1.0)) -sol = solve(prob_split, PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) # IMEX +sol = solve(prob_split, PETSc.TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05) # IMEX -integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!/solve! interface +integrator = init(prob, PETSc.TSRK("5dp"); dt = 0.05) # step!/solve! interface step!(integrator) sol = solve!(integrator) ``` @@ -50,9 +49,13 @@ PETSc.TSRK(subtype::String[, petsc_options]) Calls `TSSetType(ts, "rk")` and `TSRKSetType(ts, subtype)`. Suitable for non-stiff ODEs. -Common subtypes: `"3bs"` (Bogacki-Shampine order 3), `"4"` (classical -4th-order RK), `"5dp"` (Dormand-Prince order 5), `"5bs"`. The full list -is available via `-ts_rk_type` in PETSc's option database. +Common subtypes: +- `"3bs"` (Bogacki-Shampine order 3, equivalent to `BS3()` from OrdinaryDiffEqLowOrderRK.jl) +- `"4"` (classical 4th-order RK, equivalent to `RK4()` from OrdinaryDiffEqLowOrderRK.jl), +- `"5dp"` (Dormand-Prince order 5, equivalent to `DP5()` from OrdinaryDiffEqLowOrderRK.jl) +- `"5bs"` (Bogacki-Shampine order 5, equivalent to `BS5()` from OrdinaryDiffEqLowOrderRK.jl) + +The full list is available via `-ts_rk_type` in PETSc's option database. ### `PETSc.TSRosW` — Rosenbrock-W (linearly implicit) @@ -117,7 +120,7 @@ rather than an IFunction (e.g. `"euler"`, `"ssp"`). Per-solver PETSc command-line options are passed on the algorithm object as a `Vector{String}` of raw tokens: -```julia +```@example sciml alg = PETSc.TSRK("3bs", ["-ts_monitor", "-ts_max_steps", "100"]) alg = PETSc.TSImplicit("bdf", ["-snes_fd", "-ts_bdf_order", "3"]) ``` @@ -143,9 +146,6 @@ are configured, so they can override any default. | `initialize_save` | Run post-`initialize!` save record (default: `true`) | | `petsclib` | Override the PETSc library instance to use | -Unrecognized keywords are rejected with a clear `ArgumentError` rather than -silently dropped. - !!! note "Default tolerances" If you pass **neither** `reltol` nor `abstol`, the wrapper leaves PETSc's own `TSAdapt` default tolerances in place rather than imposing SciML's @@ -161,13 +161,11 @@ silently dropped. `finalize` hooks are called at the start and end of the solve, matching the standard SciML lifecycle. -`ContinuousCallback`s are **not** supported and are rejected with -`ArgumentError`. Wrap event detection through PETSc's `TSSetEventHandler` -directly if needed. +`ContinuousCallback`s are currently **not** supported. Wrap event detection +through PETSc's `TSSetEventHandler` directly if needed (or prepare a PR to +PETSc.jl to add support). -`tstops` is also **not** yet supported (rejected with `ArgumentError`) because -the extension drives `TSStep` directly and cannot currently guarantee exact -landing on requested times. +`tstops` is also **not** yet supported. ## Current limitations @@ -177,7 +175,7 @@ landing on requested times. - TSIRK/Gauss methods require an AIJ sparse Jacobian registered via `TSSetIJacobian`; the extension does not yet set one up, so those methods are not available through the SciML interface. -- `ContinuousCallback`s and `tstops` are not yet honoured (see above). +- `ContinuousCallback`s and `tstops` are not yet honored (see above). ## Algorithm docstrings diff --git a/test/Project.toml b/test/Project.toml deleted file mode 100644 index c8f49d2ed..000000000 --- a/test/Project.toml +++ /dev/null @@ -1,16 +0,0 @@ -[deps] -CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" -DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e" -ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" -MPI = "da04e1cc-30fd-572f-bb4f-1f8673147195" -PETSc = "ace2c81b-2b5f-4b1e-a30d-d662738edfe0" -PETSc_jll = "8fa3689e-f0b9-5420-9873-adf6ccf46f2d" -Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" -Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" -SparseDiffTools = "47a9eef4-7e08-11e9-0b38-333d64bd3804" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" diff --git a/test/runtests.jl b/test/runtests.jl index 145529c55..08bfc353e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,23 +1,6 @@ using Test -using Pkg - -# `Pkg.test("PETSc")` sets up a merged sandbox project that already contains -# the test-only deps. When this file is `include`d from a session whose -# active project is the package itself (and so lacks SciMLBase etc.), the -# block below switches to `test/Project.toml` so those deps are reachable. -# Note: invoking this file as a standalone script -# (`julia --project=. test/runtests.jl`) is not a fully supported workflow — -# the process may exit non-zero during PETSc/MPI teardown after all tests -# pass. Prefer `Pkg.test("PETSc")` or `include("test/runtests.jl")` from a -# REPL. -if !haskey(Pkg.project().dependencies, "SciMLBase") - Pkg.activate(@__DIR__) -end - using MPI: MPI, mpiexec -# `Pkg` is already imported above; `PETSc_jll` is loaded conditionally below -# (only when not using a custom PETSc library). -using PETSc +using PETSc, Pkg # Make sure that all dependencies are installed also on a clean system Pkg.instantiate() diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 1da5403dc..27f31066c 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -1,17 +1,4 @@ using Test -using Pkg - -# Mirror `test/runtests.jl`'s project activation so test-only deps such as -# SciMLBase are on the load path even when this file is `include`d directly -# from a session whose active project is the package itself. Note: invoking -# the file as a standalone script (`julia --project=. test/sciml/runtests.jl`) -# is *not* a fully supported entry point — the process may exit non-zero -# during PETSc/MPI teardown. Use `Pkg.test("PETSc")` or `include` the file -# from `test/runtests.jl` instead. -if !haskey(Pkg.project().dependencies, "SciMLBase") - Pkg.activate(joinpath(@__DIR__, "..")) -end - using PETSc using SciMLBase From 3a4d5b3e36a96d806131c4f86727f0c12539db97 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 16:16:20 +0200 Subject: [PATCH 31/38] improve docs and add Float32 support --- docs/src/man/sciml.md | 73 ++++++++++++++++++++++++++++-- ext/PETScSciMLExt.jl | 5 ++- ext/sciml/algorithms.jl | 4 -- ext/sciml/helpers.jl | 23 ++++++---- ext/sciml/ifunction_callback.jl | 32 ++++++++++--- ext/sciml/rhs_callback.jl | 34 +++++++++++--- ext/sciml/solve.jl | 12 ++--- test/sciml/runtests.jl | 1 + test/sciml/test_float32.jl | 79 +++++++++++++++++++++++++++++++++ 9 files changed, 227 insertions(+), 36 deletions(-) delete mode 100644 ext/sciml/algorithms.jl create mode 100644 test/sciml/test_float32.jl diff --git a/docs/src/man/sciml.md b/docs/src/man/sciml.md index 753653367..107f6aa5f 100644 --- a/docs/src/man/sciml.md +++ b/docs/src/man/sciml.md @@ -169,13 +169,78 @@ PETSc.jl to add support). ## Current limitations -- Only real-valued, in-place `ODEProblem`s (`f!(du, u, p, t)`) are supported. -- Only forward integration (`tspan[1] < tspan[2]`) is supported. -- The extension requires a PETSc library built with `PetscReal = Float64`. +The extension is work in progress. The most important restrictions of the +current implementation are listed below, grouped by topic. + +**Problem types** + +- Only `ODEProblem` and `SplitODEProblem` are supported. The split form is + only meaningful for [`PETSc.TSARKIMEX`](@ref) (`u' = f1 + f2`, with `f1` + the stiff/implicit and `f2` the non-stiff/explicit part). +- No `DAEProblem`, no mass-matrix `ODEFunction` (`M u' = f`), no + `SecondOrderODEProblem`, no `DiscreteProblem`, and no `SDEProblem`. +- Only in-place problems (`f!(du, u, p, t)`) are supported; out-of-place + `f(u, p, t)` is rejected. +- The state `u` must be a real `AbstractArray` that flattens via + `vec` / `reshape`. Scalar-valued `u`, `ArrayPartition`, and nested or + otherwise structured states are not supported. + +**Numerics and scalar types** + +- Only real-valued problems are supported; a complex `u0` is rejected. +- `PetscReal = Float64` and `PetscReal = Float32` builds are supported (the + library is selected from `eltype(u0)`, so a `Float32` `u0` picks the + `Float32` PETSc library automatically). Extended-precision builds (e.g. + `__float128`) are not supported — no matching C callback is registered. + The PETSc library's real type (`PetscReal`) determines the working + precision: with a `Float32` `u0` the integration time is carried at + `Float32` resolution internally, even if `tspan` is given as `Float64`. +- Only forward integration (`tspan[1] < tspan[2]`) is supported; backward and + zero-length `tspan` are rejected. +- Only scalar `reltol` / `abstol` are supported; per-component (vector) + tolerances are rejected. + +**Jacobians (stiff performance)** + +- No analytic Jacobian is used. `ODEFunction`'s `jac`, `jac_prototype`, and + `sparsity` fields are ignored; stiff solves rely on PETSc's + finite-difference `-snes_fd` (or a matrix-free SNES). This is the main + performance gap for stiff problems. - TSIRK/Gauss methods require an AIJ sparse Jacobian registered via `TSSetIJacobian`; the extension does not yet set one up, so those methods are not available through the SciML interface. -- `ContinuousCallback`s and `tstops` are not yet honored (see above). + +**Parallelism and hardware** + +- Serial only: the solution vector is a `VecSeq` on `PETSC_COMM_SELF`. MPI + distributed states — PETSc's central strength — are not exposed through this + interface. +- No GPU support, despite the separate `PETScCUDAExt`; the time stepper + allocates host vectors. + +**Callbacks and events** + +- `DiscreteCallback` and `terminate!` are supported, but `ContinuousCallback` + and `VectorContinuousCallback` are rejected (see [Callbacks](@ref)). +- `tstops` is rejected, so exact landing on user-requested times is not + guaranteed. + +**Solution output** + +- No continuous/dense output: `sol(t)` interpolation is not built. Only the + saved `(t, u)` samples are available (`saveat` interpolates at save time via + `TSInterpolate`). +- No `save_idxs`: the full state is always stored. +- `sol.stats` is only partially populated. `naccept`, `nreject`, + `nnonliniter`, and `nf` are mapped from PETSc; the remaining `DEStats` + fields (e.g. `nsolve`) stay at the `-1` "unknown" sentinel. + +**Integrator interface and sensitivities** + +- No `reinit!`, so repeated solves and `EnsembleProblem` parameter sweeps + rebuild the PETSc `TS` each time rather than reusing it. +- No adjoint or forward sensitivity analysis (`sensealg`), even though PETSc + provides `TSAdjoint`. ## Algorithm docstrings diff --git a/ext/PETScSciMLExt.jl b/ext/PETScSciMLExt.jl index 39ca8569d..daa7b30b5 100644 --- a/ext/PETScSciMLExt.jl +++ b/ext/PETScSciMLExt.jl @@ -3,7 +3,10 @@ module PETScSciMLExt using PETSc using SciMLBase -include("sciml/algorithms.jl") +# Algorithm types live in PETSc proper (`src/sciml_algorithms.jl`) +# so users can write `PETSc.TSRK("3bs")`. +using PETSc: PETScTSAlgorithm, TSRK, TSRosW, TSImplicit, TSARKIMEX, TSGeneric + include("sciml/options.jl") include("sciml/integrator.jl") include("sciml/interface.jl") diff --git a/ext/sciml/algorithms.jl b/ext/sciml/algorithms.jl deleted file mode 100644 index 974a4fb4d..000000000 --- a/ext/sciml/algorithms.jl +++ /dev/null @@ -1,4 +0,0 @@ -# Algorithm types live in PETSc proper (`src/sciml_algorithms.jl`) so users -# can write `PETSc.TSRK("3bs")` without going through `Base.get_extension`. -# This file just imports them into the extension's namespace. -using PETSc: PETScTSAlgorithm, TSRK, TSRosW, TSImplicit, TSARKIMEX, TSGeneric diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index fea9d8c18..f1e800447 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -14,16 +14,21 @@ function _check_isinplace(prob) )) end -# The PETSc TS C callback signatures we register via `@cfunction` use -# `Float64` for the time argument. PETSc libraries built with -# `PetscReal = Float32` would pass a Float32 across the C ABI, which would -# not match. Detect this up-front so users get a clear error rather than a -# bus error when the callback fires. +# The PETSc TS C callback signatures we register via `@cfunction` pass the +# time argument as `PetscReal`. We build a matching `@cfunction` for each +# supported real type (`Float64` and `Float32`; see `_petsc_rhs_ptr` / +# `_petsc_ifunction_ptr`), but other reals (e.g. `__float128` extended +# precision) have no registered callback and would pass a mismatched value +# across the C ABI. Detect that up-front so users get a clear error rather +# than a bus error when the callback fires. +const _SUPPORTED_PETSCREAL = (Float64, Float32) + function _check_petscreal(lib) - lib.PetscReal === Float64 || throw(ArgumentError( - "PETSc.jl SciML extension currently only supports PetscReal = Float64. " * - "Got PetscReal = $(lib.PetscReal). Pass `petsclib = PETSc.getlib(PetscScalar = Float64)` " * - "or use a PETSc build with PetscReal = Float64.", + lib.PetscReal in _SUPPORTED_PETSCREAL || throw(ArgumentError( + "PETSc.jl SciML extension currently only supports PetscReal = Float64 " * + "or Float32. Got PetscReal = $(lib.PetscReal). Pass " * + "`petsclib = PETSc.getlib(PetscScalar = Float64)` (or `Float32`), " * + "or use a PETSc build with a supported PetscReal.", )) end diff --git a/ext/sciml/ifunction_callback.jl b/ext/sciml/ifunction_callback.jl index bad64fae4..9f2ffd04b 100644 --- a/ext/sciml/ifunction_callback.jl +++ b/ext/sciml/ifunction_callback.jl @@ -8,9 +8,12 @@ mutable struct IFunctionCtx{F, P, SZ, Lib} end IFunctionCtx(f, p, sizeu, petsclib) = IFunctionCtx(f, p, sizeu, petsclib, 0, nothing) +# `t` is typed `Real` (not a concrete `Float64`) so a single body specializes +# for whichever `PetscReal` the registered `@cfunction` is built for — see +# `_petsc_ifunction_ptr`. function _petsc_ifunction!( ::PETSc.LibPETSc.CTS, - t::Float64, + t::Real, u_ptr::PETSc.LibPETSc.CVec, udot_ptr::PETSc.LibPETSc.CVec, F_ptr::PETSc.LibPETSc.CVec, @@ -44,10 +47,13 @@ function _petsc_ifunction!( end end -const _PETSC_IFUNCTION_PTR = Ref{Ptr{Cvoid}}(C_NULL) +# See `_petsc_rhs_ptr`: one cached function pointer per `PetscReal` because +# `@cfunction` needs literal argument types and the time argument's ABI type +# must match the library's `PetscReal`. +const _PETSC_IFUNCTION_PTR = IdDict{DataType, Ptr{Cvoid}}() -function _petsc_ifunction_ptr() - _PETSC_IFUNCTION_PTR[] == C_NULL && (_PETSC_IFUNCTION_PTR[] = @cfunction( +_petsc_ifunction_ptr(::Type{Float64}) = get!(_PETSC_IFUNCTION_PTR, Float64) do + @cfunction( _petsc_ifunction!, PETSc.LibPETSc.PetscErrorCode, ( @@ -58,6 +64,20 @@ function _petsc_ifunction_ptr() PETSc.LibPETSc.CVec, Ptr{Cvoid}, ), - )) - return _PETSC_IFUNCTION_PTR[] + ) +end + +_petsc_ifunction_ptr(::Type{Float32}) = get!(_PETSC_IFUNCTION_PTR, Float32) do + @cfunction( + _petsc_ifunction!, + PETSc.LibPETSc.PetscErrorCode, + ( + PETSc.LibPETSc.CTS, + Float32, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + Ptr{Cvoid}, + ), + ) end diff --git a/ext/sciml/rhs_callback.jl b/ext/sciml/rhs_callback.jl index 942737aa5..494df6d24 100644 --- a/ext/sciml/rhs_callback.jl +++ b/ext/sciml/rhs_callback.jl @@ -8,9 +8,12 @@ mutable struct RHSCtx{F, P, SZ, Lib} end RHSCtx(f, p, sizeu, petsclib) = RHSCtx(f, p, sizeu, petsclib, 0, nothing) +# `t` is typed `Real` (not a concrete `Float64`) so a single body specializes +# for whichever `PetscReal` the registered `@cfunction` is built for — see +# `_petsc_rhs_ptr`. function _petsc_rhs!( ::PETSc.LibPETSc.CTS, - t::Float64, + t::Real, u_ptr::PETSc.LibPETSc.CVec, f_ptr::PETSc.LibPETSc.CVec, ctx_ptr::Ptr{Cvoid}, @@ -41,10 +44,16 @@ function _petsc_rhs!( end end -const _PETSC_RHS_PTR = Ref{Ptr{Cvoid}}(C_NULL) +# PETSc passes the time argument across the C ABI as `PetscReal`, which is +# `Float64` or `Float32` depending on how the library was built. `@cfunction` +# requires literal argument types, so we register (and cache) a separate +# function pointer per real type, keyed by `PetscReal`. A mismatch between the +# ABI type and the registered signature would corrupt the call, so the type is +# selected from `lib.PetscReal` at registration time. +const _PETSC_RHS_PTR = IdDict{DataType, Ptr{Cvoid}}() -function _petsc_rhs_ptr() - _PETSC_RHS_PTR[] == C_NULL && (_PETSC_RHS_PTR[] = @cfunction( +_petsc_rhs_ptr(::Type{Float64}) = get!(_PETSC_RHS_PTR, Float64) do + @cfunction( _petsc_rhs!, PETSc.LibPETSc.PetscErrorCode, ( @@ -54,6 +63,19 @@ function _petsc_rhs_ptr() PETSc.LibPETSc.CVec, Ptr{Cvoid}, ), - )) - return _PETSC_RHS_PTR[] + ) +end + +_petsc_rhs_ptr(::Type{Float32}) = get!(_PETSC_RHS_PTR, Float32) do + @cfunction( + _petsc_rhs!, + PETSc.LibPETSc.PetscErrorCode, + ( + PETSc.LibPETSc.CTS, + Float32, + PETSc.LibPETSc.CVec, + PETSc.LibPETSc.CVec, + Ptr{Cvoid}, + ), + ) end diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index fdcb84634..5fb54f3b2 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -12,8 +12,8 @@ end PETSc.destroy(integ::PETScTSIntegrator) = _destroy_petsc!(integ) # Pick the integrator's internal time type. We always promote integer `tspan`s -# to a floating-point type so PETSc-side `Float64` times do not get truncated -# back into `Int` on assignment. +# to a floating-point type so PETSc-side (`PetscReal`, i.e. `Float64` or +# `Float32`) times do not get truncated back into `Int` on assignment. _pick_tType(tspan) = float(eltype(tspan)) function _check_tspan(t0, tf) @@ -257,7 +257,7 @@ end function _register_rhs!(lib, ts, prob, u0) cb_ctx = RHSCtx(prob.f.f, prob.p, size(u0), lib) PETSc.LibPETSc.TSSetRHSFunction( - lib, ts, nothing, _petsc_rhs_ptr(), pointer_from_objref(cb_ctx), + lib, ts, nothing, _petsc_rhs_ptr(lib.PetscReal), pointer_from_objref(cb_ctx), ) return cb_ctx end @@ -265,7 +265,7 @@ end function _register_ifunction!(lib, ts, prob, u0) cb_ctx = IFunctionCtx(prob.f.f, prob.p, size(u0), lib) PETSc.LibPETSc.TSSetIFunction( - lib, ts, nothing, _petsc_ifunction_ptr(), pointer_from_objref(cb_ctx), + lib, ts, nothing, _petsc_ifunction_ptr(lib.PetscReal), pointer_from_objref(cb_ctx), ) return cb_ctx end @@ -309,7 +309,7 @@ end function _register_rhs_with_f!(lib, ts, f, prob, u0) cb_ctx = RHSCtx(f, prob.p, size(u0), lib) PETSc.LibPETSc.TSSetRHSFunction( - lib, ts, nothing, _petsc_rhs_ptr(), pointer_from_objref(cb_ctx), + lib, ts, nothing, _petsc_rhs_ptr(lib.PetscReal), pointer_from_objref(cb_ctx), ) return cb_ctx end @@ -317,7 +317,7 @@ end function _register_ifunction_with_f!(lib, ts, f, prob, u0) cb_ctx = IFunctionCtx(f, prob.p, size(u0), lib) PETSc.LibPETSc.TSSetIFunction( - lib, ts, nothing, _petsc_ifunction_ptr(), pointer_from_objref(cb_ctx), + lib, ts, nothing, _petsc_ifunction_ptr(lib.PetscReal), pointer_from_objref(cb_ctx), ) return cb_ctx end diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 27f31066c..4a77c73a4 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -10,6 +10,7 @@ using SciMLBase end include("test_rk.jl") + include("test_float32.jl") include("test_ex51_sciml.jl") include("test_ex16_sciml.jl") include("test_rosenbrock.jl") diff --git a/test/sciml/test_float32.jl b/test/sciml/test_float32.jl new file mode 100644 index 000000000..ccd22bfe4 --- /dev/null +++ b/test/sciml/test_float32.jl @@ -0,0 +1,79 @@ +using Test +using PETSc +using SciMLBase + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK +const TSImplicit = ext.TSImplicit +const TSARKIMEX = ext.TSARKIMEX + +# ── Reference problem (PETSc TS tutorial ex51), Float32 variant ────────────── +# u1' = cos(t), u2' = sin(u2) +function ex51_rhs_f32!(du, u, p, t) + du[1] = cos(t) + du[2] = sin(u[2]) + return nothing +end + +ex51_exact_f32(t) = Float32[sin(t), 2 * atan(exp(t) * tan(0.5))] + +@testset "Float32 in-place ODE problems" begin + @testset "auto-selected petsclib is the Float32 library" begin + u0 = Float32[0.0, 1.0] + tspan = (0.0f0, 1.0f0) + prob = ODEProblem(ex51_rhs_f32!, u0, tspan) + integ = init(prob, TSRK("3bs"); dt = 0.1f0) + @test integ.petsclib.PetscScalar === Float32 + @test integ.petsclib.PetscReal === Float32 + PETSc.destroy(integ) + end + + @testset "explicit RK on ex51 keeps Float32 state and matches solution" begin + u0 = Float32[0.0, 1.0] + tspan = (0.0f0, 1.0f0) + prob = ODEProblem(ex51_rhs_f32!, u0, tspan) + sol = solve(prob, TSRK("5dp"); dt = 0.1f0) + @test sol.retcode == ReturnCode.Success + @test eltype(sol.u[end]) === Float32 + @test eltype(sol.t) === Float32 + @test sol.t[end] ≈ tspan[2] + @test sol.u[end] ≈ ex51_exact_f32(1.0f0) atol = 1.0f-3 + end + + @testset "the time argument reaches the user RHS as Float32" begin + seen_T = Ref{Any}(nothing) + f!(du, u, p, t) = (seen_T[] = typeof(t); du[1] = -u[1]; nothing) + prob = ODEProblem(f!, Float32[1.0], (0.0f0, 1.0f0)) + sol = solve(prob, TSRK("3bs"); dt = 0.05f0) + @test sol.retcode == ReturnCode.Success + @test seen_T[] === Float32 + end + + @testset "scalar exponential decay u' = -u" begin + u0 = Float32[1.0] + prob = ODEProblem((du, u, p, t) -> (du[1] = -u[1]), u0, (0.0f0, 1.0f0)) + sol = solve(prob, TSRK("3bs"); dt = 0.05f0) + @test sol.retcode == ReturnCode.Success + @test eltype(sol.u[end]) === Float32 + @test sol.u[end][1] ≈ exp(-1.0f0) atol = 1.0f-3 + end + + @testset "implicit (IFunction) path with -snes_fd" begin + u0 = Float32[0.0, 1.0] + prob = ODEProblem(ex51_rhs_f32!, u0, (0.0f0, 1.0f0)) + sol = solve(prob, TSImplicit("beuler", ["-snes_fd"]); dt = 0.01f0) + @test sol.retcode == ReturnCode.Success + @test eltype(sol.u[end]) === Float32 + @test sol.u[end] ≈ ex51_exact_f32(1.0f0) atol = 5.0f-2 + end + + @testset "IMEX split problem stays Float32" begin + f1!(du, u, p, t) = (du[1] = -u[1]; nothing) # stiff / implicit + f2!(du, u, p, t) = (du[1] = cos(t); nothing) # non-stiff / explicit + prob = SplitODEProblem(f1!, f2!, Float32[1.0], (0.0f0, 1.0f0)) + sol = solve(prob, TSARKIMEX("2e", ["-snes_fd"]); dt = 0.05f0) + @test sol.retcode == ReturnCode.Success + @test eltype(sol.u[end]) === Float32 + end +end From 92f1c48c0e737b5fa3b0b89d9f6f6bb847bb03e1 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 16:35:30 +0200 Subject: [PATCH 32/38] MPI for explicit methods --- docs/src/man/sciml.md | 56 ++++++++++++++++++++++++++++++++++++--- ext/sciml/helpers.jl | 2 +- ext/sciml/interface.jl | 5 ++-- ext/sciml/solve.jl | 51 +++++++++++++++++++++++++++++++---- test/sciml/mpi_sciml.jl | 55 ++++++++++++++++++++++++++++++++++++++ test/sciml/runtests.jl | 13 +++++++++ test/sciml/test_polish.jl | 20 ++++++-------- 7 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 test/sciml/mpi_sciml.jl diff --git a/docs/src/man/sciml.md b/docs/src/man/sciml.md index 107f6aa5f..c647ebbb4 100644 --- a/docs/src/man/sciml.md +++ b/docs/src/man/sciml.md @@ -145,6 +145,7 @@ are configured, so they can override any default. | `callback` | `DiscreteCallback` or `CallbackSet` of discrete callbacks | | `initialize_save` | Run post-`initialize!` save record (default: `true`) | | `petsclib` | Override the PETSc library instance to use | +| `comm` | MPI communicator for distributed explicit integration (see [Parallel (MPI) integration](@ref)) | !!! note "Default tolerances" If you pass **neither** `reltol` nor `abstol`, the wrapper leaves PETSc's @@ -167,6 +168,52 @@ PETSc.jl to add support). `tstops` is also **not** yet supported. +## Parallel (MPI) integration + +Distributed integration is supported for **explicit** methods — `TSRK`, and +`TSGeneric(...; explicit = true)` — by passing an MPI communicator via the +`comm` keyword. The solution vector then becomes a distributed PETSc vector +(`VecCreateMPI`) instead of a serial one. + +The programming model is SPMD ("single program, multiple data"): run your +script under `mpiexec -n N julia ...`, and on **each rank** pass *that rank's +local block* of the global state as `u0`. The local size is taken from +`length(u0)`; PETSc decides the global layout. The right-hand-side callback +receives the local block, and each rank's `sol` holds only its local part of +the trajectory. + +```julia +using PETSc, SciMLBase, MPI +MPI.Initialized() || MPI.Init() +comm = MPI.COMM_WORLD + +# Each rank owns `nloc` components of the global state. +nloc = 100 +u0 = fill(1.0, nloc) # this rank's local block +decay!(du, u, p, t) = (@. du = -u; nothing) +prob = ODEProblem(decay!, u0, (0.0, 1.0)) + +sol = solve(prob, PETSc.TSRK("5dp"); dt = 0.05, comm = comm) +# sol.u[end] is this rank's local block of the final state. +``` + +!!! warning + The right-hand side must be expressible **rank-locally**: `du` on a rank + may depend only on that rank's `u` (e.g. decoupled ODEs, reaction terms, + or a problem where you perform any halo exchange yourself inside `f!`, + using data stashed in `p`). PDE semidiscretizations that need neighbour + (ghost) values are not handled automatically — a `DM`-aware layer + (`DMGlobalToLocal` ghosting via `TSSetDM`) is not implemented yet. + + Error handling is **collective**: PETSc's `TSStep` is a collective call, so + a user `f!` that throws on only some ranks can desynchronize the run. Make + failure conditions uniform across ranks. + +Implicit, IMEX, and Rosenbrock algorithms reject `comm` with an error: they +would additionally need a parallel `SNES`/preconditioner over a distributed +Jacobian, which this extension does not yet set up. Run them on a single +process (omit `comm`). + ## Current limitations The extension is work in progress. The most important restrictions of the @@ -212,9 +259,12 @@ current implementation are listed below, grouped by topic. **Parallelism and hardware** -- Serial only: the solution vector is a `VecSeq` on `PETSC_COMM_SELF`. MPI - distributed states — PETSc's central strength — are not exposed through this - interface. +- MPI is supported only for **explicit** methods (`TSRK`, + `TSGeneric(...; explicit = true)`) via the `comm` keyword, and only for + rank-local right-hand sides — see [Parallel (MPI) integration](@ref). + Without `comm` the solution vector is a serial `VecSeq` on + `PETSC_COMM_SELF`. Distributed implicit / IMEX / Rosenbrock solves, and + automatic ghost-exchange for PDE stencils, are not yet exposed. - No GPU support, despite the separate `PETScCUDAExt`; the time stepper allocates host vectors. diff --git a/ext/sciml/helpers.jl b/ext/sciml/helpers.jl index f1e800447..a64f78a48 100644 --- a/ext/sciml/helpers.jl +++ b/ext/sciml/helpers.jl @@ -81,7 +81,7 @@ const _SUPPORTED_SCIML_KWARGS = ( :saveat, :tstops, :callback, :initialize_save, :reltol, :abstol, :dt, :dtmin, :dtmax, :adaptive, - :maxiters, :petsclib, + :maxiters, :petsclib, :comm, ) function _reject_unsupported_kwargs(kwargs) diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl index b3c0b1e46..fc8cf412a 100644 --- a/ext/sciml/interface.jl +++ b/ext/sciml/interface.jl @@ -63,8 +63,7 @@ function SciMLBase.terminate!( return nothing end -# Local re-implementations of the DiffEqBase callback lifecycle helpers. -# All dependencies are in SciMLBase, so no DiffEqBase dependency is needed. +# Local re-implementations of the DiffEqBase.jl callback lifecycle helpers. _initialize_callbacks!(cb::SciMLBase.CallbackSet{Tuple{}, Tuple{}}, u, t, integ) = false function _initialize_callbacks!(cb::SciMLBase.CallbackSet, u, t, integ) _initialize_callbacks!(u, t, integ, false, @@ -100,7 +99,7 @@ end # Local re-implementation of the callback dispatch chain that DiffEqBase # previously provided. All dependencies (condition, affect!, save_positions, # initializealg, reeval_internals_due_to_modification!, savevalues!) are part -# of SciMLBase, so no DiffEqBase dependency is needed. +# of SciMLBase.jl. @inline function _apply_discrete_callback!( integrator::PETScTSIntegrator, callback::SciMLBase.DiscreteCallback, ) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index 5fb54f3b2..fa40d8c2f 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -45,9 +45,15 @@ const _SCIML_DEFAULT_ABSTOL = 1e-6 # library, allocate the solution vector, set time bounds and a maybe-supplied # initial step. Algorithm-specific TS type, subtype, and callback registration # happen in `_setup_petsc_algorithm!` afterwards. +# +# `comm === nothing` is the serial default: a `VecSeq` on `PETSC_COMM_SELF`, +# where `prob.u0` is the full state. When an MPI communicator is supplied, the +# solution vector is a distributed `VecCreateMPI` whose *local* size is +# `length(prob.u0)` (so each rank passes its own block) and whose global size +# is `PETSC_DECIDE`d by PETSc. function _common_ts_setup( prob, dt, maxiters, petsclib, reltol, abstol, - adaptive, dtmin, dtmax, + adaptive, dtmin, dtmax, comm, ) _check_isinplace(prob) lib = _pick_petsclib(prob, petsclib) @@ -61,8 +67,15 @@ function _common_ts_setup( _check_tspan(t0, tf) tdir = tType(sign(tf - t0)) - ts = PETSc.LibPETSc.TSCreate(lib, PETSc.LibPETSc.PETSC_COMM_SELF) - u_v = PETSc.VecSeq(lib, length(u0)) + ts = PETSc.LibPETSc.TSCreate( + lib, comm === nothing ? PETSc.LibPETSc.PETSC_COMM_SELF : comm, + ) + u_v = comm === nothing ? + PETSc.VecSeq(lib, length(u0)) : + PETSc.LibPETSc.VecCreateMPI( + lib, comm, lib.PetscInt(length(u0)), + lib.PetscInt(PETSc.LibPETSc.PETSC_DECIDE), + ) try PETSc.withlocalarray!(u_v; read = false, write = true) do arr copyto!(arr, vec(u0)) @@ -163,7 +176,7 @@ end # negative cap would otherwise immediately exhaust and turn into a # zero-step `MaxIters` solve, which mismatches PETSc's own # `-ts_max_steps -1 = unlimited` interpretation. Keep `maxiters = 0` -# valid (it is the documented "zero-step solve" case from Review-10). +# valid (it is the documented "zero-step solve" case). function _validate_maxiters(maxiters) maxiters < 0 && throw(ArgumentError( "PETSc.jl SciML extension: `maxiters = $(maxiters)` must be " * @@ -270,6 +283,16 @@ function _register_ifunction!(lib, ts, prob, u0) return cb_ctx end +# Whether an algorithm drives PETSc through the *explicit* RHS calling +# convention (`TSSetRHSFunction`) only — i.e. it needs no implicit SNES / +# preconditioner. MPI support (the `comm` keyword) is currently gated to these +# families: explicit TS machinery is collective-safe over any communicator +# without an assembled parallel Jacobian, which the implicit / IMEX / +# Rosenbrock paths would additionally require. +_is_explicit_algorithm(::TSRK) = true +_is_explicit_algorithm(alg::TSGeneric) = alg.explicit +_is_explicit_algorithm(::PETScTSAlgorithm) = false + # Per-algorithm hooks. Each returns the callback context object that needs to # stay live for the lifetime of the integrator. function _setup_petsc_algorithm!(lib, ts, prob, u0, alg::TSRK) @@ -435,6 +458,7 @@ function SciMLBase.__init( adaptive::Bool = true, maxiters::Integer = Int(1e5), petsclib = nothing, + comm = nothing, kwargs..., ) # Pure Julia validation that does not need PETSc state runs first, so @@ -444,6 +468,23 @@ function SciMLBase.__init( # has no finalizer of its own). _reject_unsupported_kwargs(kwargs) _validate_maxiters(maxiters) + + # MPI is currently limited to explicit time integration (see + # `_is_explicit_algorithm`). Implicit / IMEX / Rosenbrock families would + # additionally need a parallel SNES / preconditioner over the distributed + # Jacobian, which this extension does not yet set up, so reject `comm` for + # them up front rather than silently running a broken parallel solve. + if comm !== nothing && !_is_explicit_algorithm(alg) + throw(ArgumentError( + "PETSc.jl SciML extension: MPI (the `comm` keyword) is currently " * + "only supported for explicit time integration methods — `TSRK` or " * + "`TSGeneric(...; explicit = true)`. Got $(typeof(alg)). Run " * + "implicit / IMEX / Rosenbrock algorithms on a single process " * + "(omit `comm`), or open an issue if you need distributed implicit " * + "solves.", + )) + end + cb_set = SciMLBase.CallbackSet(callback) if !isempty(cb_set.continuous_callbacks) throw(ArgumentError( @@ -489,7 +530,7 @@ function SciMLBase.__init( (lib, ts, u_v, u0, tType, t0, tdir) = _common_ts_setup( prob, dt, maxiters, petsclib, reltol, abstol, - adaptive, dtmin, dtmax, + adaptive, dtmin, dtmax, comm, ) try diff --git a/test/sciml/mpi_sciml.jl b/test/sciml/mpi_sciml.jl new file mode 100644 index 000000000..3c71a8a4f --- /dev/null +++ b/test/sciml/mpi_sciml.jl @@ -0,0 +1,55 @@ +# Standalone script: run under `mpiexec -n N julia --project=... mpi_sciml.jl`. +# Exercises distributed (MPI) explicit time integration through the SciML +# extension. The RHS is purely *local* (decoupled componentwise decay), which +# is the milestone-1 contract: each rank owns its block of the global state and +# no cross-rank ghost exchange is required. +using Test +using MPI +MPI.Initialized() || MPI.Init() +using PETSc +using SciMLBase + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing +const TSRK = ext.TSRK + +const comm = MPI.COMM_WORLD +const mpisize = MPI.Comm_size(comm) +const mpirank = MPI.Comm_rank(comm) + +@testset "MPI explicit integration (rank $mpirank / $mpisize)" begin + # Each rank owns `nloc` components of the global state. Decay rates and + # initial values differ per rank so a wrong data layout would be caught. + nloc = 3 + k = fill(1.0 + mpirank, nloc) # local decay rates + u0 = Float64[mpirank + 1 + i for i in 1:nloc] # local initial block + + # Purely local RHS: du[i] = -k[i] * u[i]. No neighbour coupling. + decay!(du, u, p, t) = (@. du = -k * u; nothing) + + tspan = (0.0, 1.0) + prob = ODEProblem(decay!, u0, tspan) + + # `init` first so we can inspect the distributed solution vector before it + # is torn down by `solve!`. + integ = init(prob, TSRK("5dp"); dt = 0.05, comm = comm) + + # The solution vector must be genuinely distributed: its global length is + # the sum of the local blocks, and this rank owns exactly `nloc` of them. + @test length(integ.u_petsc) == nloc * mpisize + rng = PETSc.ownershiprange(integ.u_petsc, false) + @test length(rng) == nloc + + sol = solve!(integ) + @test sol.retcode == ReturnCode.Success + + # Each rank holds only its local block of the trajectory. + @test length(sol.u[end]) == nloc + uref = u0 .* exp.(-k .* tspan[2]) + @test sol.u[end] ≈ uref atol = 1e-4 +end + +# A failing top-level @testset throws at exit, so `success(cmd)` in the parent +# reflects the result. Make a clean run exit 0 explicitly for clarity. +MPI.Barrier(comm) +nothing diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 4a77c73a4..b33cb6dac 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -21,4 +21,17 @@ using SciMLBase include("test_integrator.jl") include("test_polish.jl") include("test_review_fixes.jl") + + # Distributed (MPI) explicit integration runs in a separate `mpiexec` + # subprocess. Launch it with the *currently active* project so the child + # inherits SciMLBase (a test-only dependency not present in PETSc's main + # `[deps]`). Skipped on Windows, where the PETSc + MPI stack is unavailable. + if !Sys.iswindows() + @testset "MPI explicit integration (2 ranks)" begin + using MPI: mpiexec + script = joinpath(@__DIR__, "mpi_sciml.jl") + cmd = `$(mpiexec()) -n 2 $(Base.julia_cmd()) --project=$(Base.active_project()) $script` + @test success(pipeline(cmd; stderr = stderr)) + end + end end diff --git a/test/sciml/test_polish.jl b/test/sciml/test_polish.jl index a70cef24e..3ccde53d4 100644 --- a/test/sciml/test_polish.jl +++ b/test/sciml/test_polish.jl @@ -43,20 +43,16 @@ end @test sol.u[end][1] ≈ exp(-1.0) atol = 5e-2 end - @testset "Float32 problem raises a clear ArgumentError" begin + @testset "Float32 problem solves on the Float32 library" begin + # Float32 problems are supported: the Float32 PETSc library is selected + # automatically from `eltype(u0)`. See `test_float32.jl` for the full + # coverage; this is a smoke test that the polish path stays green. u0 = Float32[1.0] tspan = (0f0, 1f0) prob = ODEProblem(decay!, u0, tspan) - @test_throws ArgumentError solve(prob, PETSc.TSRK("3bs"); dt = 0.1f0) - # Exception text should call out the constraint so the user knows what - # to pass. - err = try - solve(prob, PETSc.TSRK("3bs"); dt = 0.1f0) - nothing - catch e - e - end - @test occursin("PetscReal", err.msg) - @test occursin("Float64", err.msg) + sol = solve(prob, PETSc.TSRK("3bs"); dt = 0.1f0) + @test sol.retcode == ReturnCode.Success + @test eltype(sol.u[end]) === Float32 + @test sol.u[end][1] ≈ exp(-1f0) atol = 1f-3 end end From d348983d5071ec91dfad658c812a6eab069ee833 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 16:59:48 +0200 Subject: [PATCH 33/38] improve compatibility with Trixi.jl --- ext/sciml/options.jl | 9 +++++++++ ext/sciml/solve.jl | 2 +- test/sciml/test_integrator.jl | 19 +++++++++++++++++++ test/sciml/test_output.jl | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/ext/sciml/options.jl b/ext/sciml/options.jl index e7f2d8ca7..1e31e56e5 100644 --- a/ext/sciml/options.jl +++ b/ext/sciml/options.jl @@ -1,3 +1,9 @@ +# Mirrors the subset of OrdinaryDiffEq's `DEOptions` fields that downstream +# SciML packages commonly read off `integrator.opts` (e.g. `adaptive`, +# `reltol`, `abstol`). PETSc itself owns these once they are applied at setup — +# our own step loop only reads back `maxiters`, `tstops`, and the `save_*` +# fields — but they are retained here so generic SciML code that introspects +# `integrator.opts.adaptive` / `.reltol` / `.abstol` keeps working. mutable struct DEOptions{SavT, TstopsT, CType, reltolType, abstolType} saveat::SavT tstops::TstopsT @@ -7,6 +13,7 @@ mutable struct DEOptions{SavT, TstopsT, CType, reltolType, abstolType} save_end::Bool save_discretes::Bool callback::CType + adaptive::Bool reltol::reltolType abstol::abstolType maxiters::Int @@ -89,6 +96,7 @@ function _build_opts( save_end::Bool, save_discretes::Bool, callback, + adaptive::Bool, reltol, abstol, maxiters::Integer, @@ -124,6 +132,7 @@ function _build_opts( save_end, save_discretes, callback, + adaptive, reltol, abstol, Int(maxiters), diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index fa40d8c2f..e1d55f2bd 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -538,7 +538,7 @@ function SciMLBase.__init( tType, saveat_materialized, tstops_materialized, tdir, prob.tspan; save_everystep = _save_everystep, save_on, save_start, save_end, save_discretes, callback = cb_set, - reltol, abstol, maxiters, + adaptive, reltol, abstol, maxiters, ) # `DEStats()` defaults every counter to `-1`, which SciML reads as diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl index 739955f1b..8d830625a 100644 --- a/test/sciml/test_integrator.jl +++ b/test/sciml/test_integrator.jl @@ -27,6 +27,25 @@ end @test sol_steps.u == sol_oneshot.u end + @testset "opts mirrors SciML control knobs for downstream introspection" begin + # Generic SciML code reads `integrator.opts.adaptive` / `.reltol` / + # `.abstol`; make sure the wrapper populates them from the kwargs. + integ = init(prob, TSRK("3bs"); dt = 0.1, + adaptive = false, reltol = 1e-8, abstol = 1e-10) + @test integ.opts.adaptive == false + @test integ.opts.reltol == 1e-8 + @test integ.opts.abstol == 1e-10 + PETSc.destroy(integ) + + # Defaults: adaptive on, tolerances left as `nothing` (PETSc's own + # defaults are used — see `_apply_tolerances!`). + integ2 = init(prob, TSRK("3bs"); dt = 0.1) + @test integ2.opts.adaptive == true + @test integ2.opts.reltol === nothing + @test integ2.opts.abstol === nothing + PETSc.destroy(integ2) + end + @testset "manual step! loop matches solve!" begin integ_a = init(prob, TSRK("3bs"); dt = 0.1) integ_b = init(prob, TSRK("3bs"); dt = 0.1) diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index 0302e6833..b9239be79 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -92,7 +92,7 @@ end save_everystep = false, save_on = true, save_start = true, save_end = true, save_discretes = true, callback = nothing, - reltol = 1e-3, abstol = 1e-6, maxiters = 1000, + adaptive = true, reltol = 1e-3, abstol = 1e-6, maxiters = 1000, ) ordered = Float64[] while !isempty(opts.saveat) From 996222eb328a438e5931cb145a6e5f7ebb037d34 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 20:04:06 +0200 Subject: [PATCH 34/38] fix MPI --- ext/sciml/solve.jl | 35 +++++++++++++++++++++++++++-------- test/sciml/runtests.jl | 16 ++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/ext/sciml/solve.jl b/ext/sciml/solve.jl index e1d55f2bd..b4f84c094 100644 --- a/ext/sciml/solve.jl +++ b/ext/sciml/solve.jl @@ -1,10 +1,23 @@ function _destroy_petsc!(integ::PETScTSIntegrator) - if integ.ts.ptr != C_NULL - PETSc.LibPETSc.TSDestroy(integ.petsclib, integ.ts) - end - if integ.u_petsc.ptr != C_NULL - PETSc.destroy(integ.u_petsc) + # This runs both as explicit cleanup (from `solve!` / `destroy`) and as a + # GC finalizer. A finalizer can fire on any thread and at any time — + # including at process exit *after* the `atexit` `PetscFinalize` / + # `MPI_Finalize` hooks have run. PETSc's `TSDestroy` / `VecDestroy` make + # MPI calls, so calling them post-finalize fails with `PETSC_ERR_MPI` (98) + # and, under MPICH, aborts the process with a nonzero exit. Guard on + # `finalized` exactly like the high-level `destroy(::AbstractPetscDM)` so + # cleanup becomes a no-op once PETSc is gone — `PetscFinalize` has already + # reclaimed the objects. We still drop our own handles for idempotency. + if !PETSc.finalized(integ.petsclib) + if integ.ts.ptr != C_NULL + PETSc.LibPETSc.TSDestroy(integ.petsclib, integ.ts) + end + if integ.u_petsc.ptr != C_NULL + PETSc.destroy(integ.u_petsc) + end end + integ.ts.ptr = C_NULL + integ.u_petsc.ptr = C_NULL return nothing end @@ -238,7 +251,7 @@ end function _make_integrator( alg, u0, tType, t0, tdir, dt, prob, - opts, sol, lib, ts, u_v, cb_ctx, + opts, sol, lib, ts, u_v, cb_ctx, comm, ) integ = PETScTSIntegrator( alg, @@ -263,7 +276,13 @@ function _make_integrator( false, SciMLBase.ReturnCode.Default, ) - finalizer(_destroy_petsc!, integ) + # Only register a GC finalizer for serial integrators. For an MPI + # integrator (`comm` given) `TSDestroy` / `VecDestroy` are *collective*; + # running them from a GC finalizer — which fires asynchronously and on an + # arbitrary thread/rank ordering — risks an MPI deadlock or abort. Parallel + # cleanup must therefore be explicit (via `solve!` or `destroy`), matching + # the serial-only finalizer policy of `PETSc.VecPtr`. + comm === nothing && finalizer(_destroy_petsc!, integ) return integ end @@ -558,7 +577,7 @@ function SciMLBase.__init( integ = _make_integrator( alg, u0, tType, t0, tdir, dt, prob, - opts, sol, lib, ts, u_v, cb_ctx, + opts, sol, lib, ts, u_v, cb_ctx, comm, ) initialize_callbacks!(integ, cb_set, initialize_save) diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index b33cb6dac..5b7aeb95a 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -22,6 +22,22 @@ using SciMLBase include("test_polish.jl") include("test_review_fixes.jl") + # Regression guard: abandoned integrators must not crash at process exit + # when their GC finalizers fire after `PetscFinalize` (PETSC_ERR_MPI / MPICH + # abort). Run in a subprocess and assert a clean exit with no finalizer + # banners. Uses the active project so the child inherits SciMLBase. + @testset "finalizer safety at process exit" begin + script = joinpath(@__DIR__, "finalizer_safety.jl") + cmd = `$(Base.julia_cmd()) --project=$(Base.active_project()) $script` + out = IOBuffer() + ok = success(pipeline(cmd; stdout = out, stderr = out)) + log = String(take!(out)) + @test ok + @test occursin("finalizer_safety: reached clean exit", log) + @test !occursin("error in running finalizer", log) + @test !occursin("after finalizing", log) + end + # Distributed (MPI) explicit integration runs in a separate `mpiexec` # subprocess. Launch it with the *currently active* project so the child # inherits SciMLBase (a test-only dependency not present in PETSc's main From a85af58df81a6579c5e0ea4bdff8bb333d306a4e Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 20:15:02 +0200 Subject: [PATCH 35/38] add comments and fix MPI saveat --- ext/sciml/interface.jl | 15 ++++++++++++++- ext/sciml/retcode.jl | 18 ++++++++++++++++++ test/sciml/mpi_sciml.jl | 18 ++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ext/sciml/interface.jl b/ext/sciml/interface.jl index fc8cf412a..054bef293 100644 --- a/ext/sciml/interface.jl +++ b/ext/sciml/interface.jl @@ -24,7 +24,14 @@ function SciMLBase.savevalues!(integ::PETScTSIntegrator, force::Bool = false) continue end u_interp = similar(integ.u) - v_interp = PETSc.VecSeq(integ.petsclib, length(integ.u)) + # `TSInterpolate` writes into a Vec that must match the TS solution's + # layout. Duplicate the solution vector rather than allocating a + # `VecSeq`: for a serial integrator both are equivalent, but for an MPI + # integrator the solution is distributed and a serial vector of the + # *local* length would mismatch the TS (size error). `VecDuplicate` + # preserves the serial-or-distributed layout; `withlocalarray!` below + # then yields this rank's local block to copy into `u_interp`. + v_interp = PETSc.LibPETSc.VecDuplicate(integ.petsclib, integ.u_petsc) try PETSc.LibPETSc.TSInterpolate( integ.petsclib, integ.ts, @@ -110,6 +117,12 @@ end @inbounds if callback.save_positions[1] savedexactly || SciMLBase.savevalues!(integrator, true) end + # Pessimistically assume the affect! will mutate `u`, mirroring the + # OrdinaryDiffEq contract: a callback that does *not* touch `u` is + # expected to call `u_modified!(integrator, false)` itself. If the flag + # is still set afterwards we conservatively resync the PETSc Vec (via + # `reeval_internals_due_to_modification!` below). Same convention as + # `initialize_callbacks!` in solve.jl. integrator.u_modified = true callback.affect!(integrator) if integrator.u_modified diff --git a/ext/sciml/retcode.jl b/ext/sciml/retcode.jl index 83c37cb31..64434d0a7 100644 --- a/ext/sciml/retcode.jl +++ b/ext/sciml/retcode.jl @@ -48,21 +48,39 @@ end end end +# Translate PETSc's `TSConvergedReason` into the SciML `ReturnCode` the solution +# is tagged with. The mapping is not one-to-one, so the non-obvious cases are +# spelled out below. function _petsc_retcode(petsclib, ts) reason = _ts_converged_reason(petsclib, ts) if reason == PETSc.LibPETSc.TS_CONVERGED_TIME || reason == PETSc.LibPETSc.TS_CONVERGED_USER || reason == PETSc.LibPETSc.TS_CONVERGED_EVENT + # Reached the final time, or a user/event callback asked to stop on + # purpose — all of these are a successful, intended termination. return SciMLBase.ReturnCode.Success elseif reason == PETSc.LibPETSc.TS_CONVERGED_ITS + # Hit PETSc's own max-step limit before reaching `tf`. SciML's closest + # equivalent is `MaxIters` (the step budget, not the solution, ran out). return SciMLBase.ReturnCode.MaxIters elseif reason == PETSc.LibPETSc.TS_CONVERGED_ITERATING + # Still "iterating" means the integrator stopped while mid-integration + # without reaching `tf` and without any converged/diverged verdict — + # i.e. it never actually finished, so report a generic failure. return SciMLBase.ReturnCode.Failure elseif reason == PETSc.LibPETSc.TS_DIVERGED_NONLINEAR_SOLVE + # The implicit (SNES) solve diverged: this is a solver failure, not a + # statement about the ODE's dynamics, so map to the generic `Failure`. return SciMLBase.ReturnCode.Failure elseif reason == PETSc.LibPETSc.TS_DIVERGED_STEP_REJECTED + # The adaptive controller kept rejecting steps (it could not satisfy the + # error tolerance even at the minimum step). That is the classic + # signature of a stiff/blowing-up trajectory, which SciML spells + # `Unstable` rather than a bare `Failure`. return SciMLBase.ReturnCode.Unstable else + # Any other (or future) diverged reason: be conservative and report a + # generic failure rather than silently claiming success. return SciMLBase.ReturnCode.Failure end end diff --git a/test/sciml/mpi_sciml.jl b/test/sciml/mpi_sciml.jl index 3c71a8a4f..e2cdda038 100644 --- a/test/sciml/mpi_sciml.jl +++ b/test/sciml/mpi_sciml.jl @@ -47,6 +47,24 @@ const mpirank = MPI.Comm_rank(comm) @test length(sol.u[end]) == nloc uref = u0 .* exp.(-k .* tspan[2]) @test sol.u[end] ≈ uref atol = 1e-4 + + # `saveat` exercises the `TSInterpolate` path, which must duplicate the + # *distributed* solution vector (a serial `VecSeq` of the local length would + # mismatch the TS). Save times that fall strictly between steps so the + # interpolation branch — not just the step-endpoint save — is hit. + saveat = [0.1, 0.3, 0.7] + sol_sa = solve(prob, TSRK("5dp"); dt = 0.05, comm = comm, saveat = saveat) + @test sol_sa.retcode == ReturnCode.Success + for (i, ts) in enumerate(saveat) + idx = findfirst(t -> isapprox(t, ts; atol = 1e-10), sol_sa.t) + @test idx !== nothing + # Each saved sample is this rank's local block, interpolated correctly. + # `saveat` values come from `TSInterpolate` (dense output), which is a + # touch less accurate than the step-endpoint solution, so allow a looser + # tolerance than the `sol.u[end]` check above. + @test length(sol_sa.u[idx]) == nloc + @test sol_sa.u[idx] ≈ u0 .* exp.(-k .* ts) atol = 1e-3 + end end # A failing top-level @testset throws at exit, so `success(cmd)` in the parent From 038b1ed2002e6a94ea5dbfa80eb852d67ee12ec6 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 20:18:46 +0200 Subject: [PATCH 36/38] add new test file --- test/sciml/finalizer_safety.jl | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/sciml/finalizer_safety.jl diff --git a/test/sciml/finalizer_safety.jl b/test/sciml/finalizer_safety.jl new file mode 100644 index 000000000..00524775f --- /dev/null +++ b/test/sciml/finalizer_safety.jl @@ -0,0 +1,31 @@ +# Standalone script: run as a subprocess and check it exits cleanly. +# +# Regression guard for the at-exit finalizer crash: integrators created via +# `init` but never `solve!`-ed / `destroy`-ed are reclaimed by GC finalizers, +# which can fire at process exit *after* the `atexit` `PetscFinalize` / +# `MPI_Finalize` hooks. `_destroy_petsc!` must then be a no-op (guarded by +# `PETSc.finalized`), otherwise `TSDestroy` calls MPI after finalize and +# aborts under MPICH (PETSC_ERR_MPI = 98), failing the whole test run. +# +# The parent test asserts this process exits 0 and prints no +# "error in running finalizer" / "after finalizing" banners. +using PETSc +using SciMLBase + +ext = Base.get_extension(PETSc, :PETScSciMLExt) +@assert ext !== nothing + +prob = ODEProblem((du, u, p, t) -> (du[1] = -u[1]; nothing), [1.0], (0.0, 1.0)) + +# Abandon several integrators across both callback paths (explicit RHS and the +# implicit IFunction path) without cleaning them up. +for _ in 1:5 + init(prob, ext.TSRK("3bs"); dt = 0.1) + init(prob, ext.TSImplicit("beuler", ["-snes_fd"]); dt = 0.1) +end + +# Force a GC pass so finalizers are queued, then let the process exit; the +# atexit PetscFinalize must turn the remaining finalizers into no-ops. +GC.gc() +GC.gc() +println("finalizer_safety: reached clean exit") From 44d707e45c44e8c8482d922600458a6f9a11c412 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 21:46:41 +0200 Subject: [PATCH 37/38] clean up tests --- test/sciml/finalizer_safety.jl | 7 ++----- test/sciml/mpi_sciml.jl | 4 ---- test/sciml/test_callbacks.jl | 4 ---- test/sciml/test_ex16_sciml.jl | 3 --- test/sciml/test_ex51_sciml.jl | 3 --- test/sciml/test_float32.jl | 6 ------ test/sciml/test_imex.jl | 5 ----- test/sciml/test_implicit.jl | 4 ---- test/sciml/test_integrator.jl | 4 ---- test/sciml/test_output.jl | 3 ++- test/sciml/test_review_fixes.jl | 5 ++--- test/sciml/test_rk.jl | 4 ---- test/sciml/test_rosenbrock.jl | 5 ----- 13 files changed, 6 insertions(+), 51 deletions(-) diff --git a/test/sciml/finalizer_safety.jl b/test/sciml/finalizer_safety.jl index 00524775f..1d868129c 100644 --- a/test/sciml/finalizer_safety.jl +++ b/test/sciml/finalizer_safety.jl @@ -12,16 +12,13 @@ using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing - prob = ODEProblem((du, u, p, t) -> (du[1] = -u[1]; nothing), [1.0], (0.0, 1.0)) # Abandon several integrators across both callback paths (explicit RHS and the # implicit IFunction path) without cleaning them up. for _ in 1:5 - init(prob, ext.TSRK("3bs"); dt = 0.1) - init(prob, ext.TSImplicit("beuler", ["-snes_fd"]); dt = 0.1) + init(prob, PETSc.TSRK("3bs"); dt = 0.1) + init(prob, PETSc.TSImplicit("beuler", ["-snes_fd"]); dt = 0.1) end # Force a GC pass so finalizers are queued, then let the process exit; the diff --git a/test/sciml/mpi_sciml.jl b/test/sciml/mpi_sciml.jl index e2cdda038..e0007845f 100644 --- a/test/sciml/mpi_sciml.jl +++ b/test/sciml/mpi_sciml.jl @@ -9,10 +9,6 @@ MPI.Initialized() || MPI.Init() using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRK = ext.TSRK - const comm = MPI.COMM_WORLD const mpisize = MPI.Comm_size(comm) const mpirank = MPI.Comm_rank(comm) diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index 9ad7d2f89..f3a5d00dd 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -2,10 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRK = ext.TSRK - # u' = -u, exact: exp(-t) function decay!(du, u, p, t) du[1] = -u[1] diff --git a/test/sciml/test_ex16_sciml.jl b/test/sciml/test_ex16_sciml.jl index 0e6c38a12..91706f614 100644 --- a/test/sciml/test_ex16_sciml.jl +++ b/test/sciml/test_ex16_sciml.jl @@ -2,9 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing - include(joinpath(dirname(dirname(@__DIR__)), "examples", "ex16_sciml.jl")) # Van der Pol ODE (ex16) — notes on the test design diff --git a/test/sciml/test_ex51_sciml.jl b/test/sciml/test_ex51_sciml.jl index c061c196a..6eaa2cf39 100644 --- a/test/sciml/test_ex51_sciml.jl +++ b/test/sciml/test_ex51_sciml.jl @@ -3,9 +3,6 @@ using PETSc using SciMLBase using LinearAlgebra -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing - include(joinpath(dirname(dirname(@__DIR__)), "examples", "ex51_sciml.jl")) @testset "ex51_sciml example" begin diff --git a/test/sciml/test_float32.jl b/test/sciml/test_float32.jl index ccd22bfe4..79244a291 100644 --- a/test/sciml/test_float32.jl +++ b/test/sciml/test_float32.jl @@ -2,12 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRK = ext.TSRK -const TSImplicit = ext.TSImplicit -const TSARKIMEX = ext.TSARKIMEX - # ── Reference problem (PETSc TS tutorial ex51), Float32 variant ────────────── # u1' = cos(t), u2' = sin(u2) function ex51_rhs_f32!(du, u, p, t) diff --git a/test/sciml/test_imex.jl b/test/sciml/test_imex.jl index 5ab362a65..c3c100130 100644 --- a/test/sciml/test_imex.jl +++ b/test/sciml/test_imex.jl @@ -2,11 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSARKIMEX = ext.TSARKIMEX -const TSImplicit = ext.TSImplicit - # ── Linear stiff IMEX problem ──────────────────────────────────────────────── # u' = -u (implicit) + cos(t) (explicit) # analytical: u(t) = (1/2) * (cos(t) + sin(t)) + (u0 - 1/2) * exp(-t) diff --git a/test/sciml/test_implicit.jl b/test/sciml/test_implicit.jl index a2e316c8a..6c8d4eb8d 100644 --- a/test/sciml/test_implicit.jl +++ b/test/sciml/test_implicit.jl @@ -2,10 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSImplicit = ext.TSImplicit - # ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── function ex51_rhs!(du, u, p, t) du[1] = cos(t) diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl index 8d830625a..e7a0c42d6 100644 --- a/test/sciml/test_integrator.jl +++ b/test/sciml/test_integrator.jl @@ -2,10 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRK = ext.TSRK - function decay!(du, u, p, t) du[1] = -u[1] return nothing diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index b9239be79..290c61174 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -2,9 +2,10 @@ using Test using PETSc using SciMLBase +# `ext` is needed below for the internal `ext._build_opts` helper; the +# algorithm types themselves come from `using PETSc` (they are exported). ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing -const TSRK = ext.TSRK # u' = -u with analytical solution exp(-t) starting from u0 = 1. function decay!(du, u, p, t) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_review_fixes.jl index ffb2a542d..5c0e20ec2 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_review_fixes.jl @@ -2,11 +2,10 @@ using Test using PETSc using SciMLBase +# `ext` is needed below for the internal `ext.PETScTSIntegrator` type; the +# algorithm types themselves come from `using PETSc` (they are exported). ext = Base.get_extension(PETSc, :PETScSciMLExt) @assert ext !== nothing -const TSRK = ext.TSRK -const TSImplicit = ext.TSImplicit -const TSARKIMEX = ext.TSARKIMEX # Sentinel exception for the "user RHS error surfaces" testset. Declared at top # level because Julia forbids `struct` definitions inside a `@testset`'s scope. diff --git a/test/sciml/test_rk.jl b/test/sciml/test_rk.jl index f7b4a6eb1..64ab52e06 100644 --- a/test/sciml/test_rk.jl +++ b/test/sciml/test_rk.jl @@ -2,10 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRK = ext.TSRK - # ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── # u1' = cos(t), u2' = sin(u2) # Analytical solution: diff --git a/test/sciml/test_rosenbrock.jl b/test/sciml/test_rosenbrock.jl index 86f79228f..3e326e39b 100644 --- a/test/sciml/test_rosenbrock.jl +++ b/test/sciml/test_rosenbrock.jl @@ -2,11 +2,6 @@ using Test using PETSc using SciMLBase -ext = Base.get_extension(PETSc, :PETScSciMLExt) -@assert ext !== nothing -const TSRosW = ext.TSRosW -const TSRK = ext.TSRK - # ── Reference problem (PETSc TS tutorial ex51) ─────────────────────────────── function ex51_rhs!(du, u, p, t) du[1] = cos(t) From 61e778c6442e2f8a748034c3dd227b72e91d5455 Mon Sep 17 00:00:00 2001 From: Hendrik Ranocha Date: Fri, 19 Jun 2026 21:56:31 +0200 Subject: [PATCH 38/38] clean up tests --- test/sciml/runtests.jl | 4 +- test/sciml/{test_polish.jl => test_api.jl} | 4 +- test/sciml/test_callbacks.jl | 2 +- test/sciml/test_imex.jl | 2 +- test/sciml/test_implicit.jl | 2 +- test/sciml/test_integrator.jl | 2 +- test/sciml/test_output.jl | 2 +- ...st_review_fixes.jl => test_regressions.jl} | 40 ++----------------- test/sciml/test_rk.jl | 2 +- test/sciml/test_rosenbrock.jl | 2 +- 10 files changed, 15 insertions(+), 47 deletions(-) rename test/sciml/{test_polish.jl => test_api.jl} (95%) rename test/sciml/{test_review_fixes.jl => test_regressions.jl} (85%) diff --git a/test/sciml/runtests.jl b/test/sciml/runtests.jl index 5b7aeb95a..3e962f04f 100644 --- a/test/sciml/runtests.jl +++ b/test/sciml/runtests.jl @@ -19,8 +19,8 @@ using SciMLBase include("test_output.jl") include("test_callbacks.jl") include("test_integrator.jl") - include("test_polish.jl") - include("test_review_fixes.jl") + include("test_api.jl") + include("test_regressions.jl") # Regression guard: abandoned integrators must not crash at process exit # when their GC finalizers fire after `PetscFinalize` (PETSC_ERR_MPI / MPICH diff --git a/test/sciml/test_polish.jl b/test/sciml/test_api.jl similarity index 95% rename from test/sciml/test_polish.jl rename to test/sciml/test_api.jl index 3ccde53d4..f865964aa 100644 --- a/test/sciml/test_polish.jl +++ b/test/sciml/test_api.jl @@ -7,7 +7,7 @@ function decay!(du, u, p, t) return nothing end -@testset "Step 9 — Polish" begin +@testset "Public API surface" begin @testset "Algorithm types are exported from PETSc itself" begin # Top-level access: users should be able to write `PETSc.TSRK(...)` # without going through `Base.get_extension`. @@ -46,7 +46,7 @@ end @testset "Float32 problem solves on the Float32 library" begin # Float32 problems are supported: the Float32 PETSc library is selected # automatically from `eltype(u0)`. See `test_float32.jl` for the full - # coverage; this is a smoke test that the polish path stays green. + # coverage; this is a smoke test that the top-level path works. u0 = Float32[1.0] tspan = (0f0, 1f0) prob = ODEProblem(decay!, u0, tspan) diff --git a/test/sciml/test_callbacks.jl b/test/sciml/test_callbacks.jl index f3a5d00dd..f64e747ea 100644 --- a/test/sciml/test_callbacks.jl +++ b/test/sciml/test_callbacks.jl @@ -8,7 +8,7 @@ function decay!(du, u, p, t) return nothing end -@testset "Step 7 — Discrete callbacks and terminate!" begin +@testset "Discrete callbacks and terminate!" begin u0 = [1.0] tspan = (0.0, 1.0) prob = ODEProblem(decay!, u0, tspan) diff --git a/test/sciml/test_imex.jl b/test/sciml/test_imex.jl index c3c100130..95aef9378 100644 --- a/test/sciml/test_imex.jl +++ b/test/sciml/test_imex.jl @@ -33,7 +33,7 @@ function vdp_explicit!(du, u, p, t) return nothing end -@testset "Step 5 — TSARKIMEX with SplitODEProblem" begin +@testset "TSARKIMEX with SplitODEProblem" begin @testset "Linear IMEX with TSARKIMEX(\"2e\") matches analytical" begin u0 = [1.0] tspan = (0.0, 1.0) diff --git a/test/sciml/test_implicit.jl b/test/sciml/test_implicit.jl index 6c8d4eb8d..d0101fb39 100644 --- a/test/sciml/test_implicit.jl +++ b/test/sciml/test_implicit.jl @@ -19,7 +19,7 @@ function vdp!(du, u, p, t) return nothing end -@testset "Step 4 — TSImplicit fully implicit methods" begin +@testset "TSImplicit fully implicit methods" begin @testset "TSImplicit(\"beuler\") on ex51" begin u0 = [0.0, 1.0] tspan = (0.0, 1.0) diff --git a/test/sciml/test_integrator.jl b/test/sciml/test_integrator.jl index e7a0c42d6..b7217c67b 100644 --- a/test/sciml/test_integrator.jl +++ b/test/sciml/test_integrator.jl @@ -7,7 +7,7 @@ function decay!(du, u, p, t) return nothing end -@testset "Step 8 — Integrator interface lifecycle" begin +@testset "Integrator interface lifecycle" begin u0 = [1.0] tspan = (0.0, 1.0) prob = ODEProblem(decay!, u0, tspan) diff --git a/test/sciml/test_output.jl b/test/sciml/test_output.jl index 290c61174..461c6b8cb 100644 --- a/test/sciml/test_output.jl +++ b/test/sciml/test_output.jl @@ -13,7 +13,7 @@ function decay!(du, u, p, t) return nothing end -@testset "Step 6 — save_everystep / saveat / save_end" begin +@testset "save_everystep / saveat / save_end" begin u0 = [1.0] tspan = (0.0, 1.0) prob = ODEProblem(decay!, u0, tspan) diff --git a/test/sciml/test_review_fixes.jl b/test/sciml/test_regressions.jl similarity index 85% rename from test/sciml/test_review_fixes.jl rename to test/sciml/test_regressions.jl index 5c0e20ec2..26eddb780 100644 --- a/test/sciml/test_review_fixes.jl +++ b/test/sciml/test_regressions.jl @@ -44,12 +44,11 @@ function decay!(du, u, p, t) return nothing end -@testset "Review-driven fixes" begin +@testset "SciML wrapper regression tests" begin u0 = [1.0] tspan = (0.0, 1.0) prob = ODEProblem(decay!, u0, tspan) - # ── Review-1 #6 / Review-2 #11 ─────────────────────────────────────────── @testset "Backward / zero-length tspan are rejected with clear errors" begin prob_bw = ODEProblem(decay!, [exp(-1.0)], (1.0, 0.0)) prob_zr = ODEProblem(decay!, u0, (0.0, 0.0)) @@ -57,7 +56,6 @@ end @test_throws ArgumentError solve(prob_zr, TSRK("3bs"); dt = 0.1) end - # ── Review-2 #3 ───────────────────────────────────────────────────────── @testset "Integrator exposes derivative_discontinuity field" begin integ = init(prob, TSRK("3bs"); dt = 0.1) @test hasfield(typeof(integ), :derivative_discontinuity) @@ -74,8 +72,8 @@ end end @testset "DiscreteCallback that never fires still completes the solve" begin - # Review-2 #3 explicitly asks for this regression: SciMLBase reads - # derivative_discontinuity in both the fires-and-doesn't-fire paths. + # SciMLBase reads `derivative_discontinuity` in both the fires- and + # doesn't-fire paths, so a never-firing callback must still complete. cb = DiscreteCallback( (u, t, integ) -> false, # never fires integ -> nothing, @@ -85,7 +83,6 @@ end @test sol.t[end] ≈ 1.0 end - # ── Review-1 #2 / Review-2 #4 ─────────────────────────────────────────── @testset "Callback initialize that mutates u is propagated to PETSc" begin # If the callback initializer rewrites u0, the first PETSc step must # start from the rewritten value — not from the original u0. @@ -105,7 +102,6 @@ end @test sol.u[end][1] ≈ 5 * exp(-1.0) atol = 1e-2 end - # ── Review-1 #5 / Review-2 #6 ─────────────────────────────────────────── @testset "SciMLBase-equivalent finalize! is called at end of solve" begin finalized = Ref(false) function finalize_cb!(cb, u, t, integ) @@ -122,7 +118,6 @@ end @test finalized[] end - # ── Review-1 #3 / Review-2 #5 ─────────────────────────────────────────── @testset "reltol / abstol reach PETSc adaptive controller" begin # Same problem, two different tolerance settings: at coarse tolerance # PETSc takes fewer adaptive steps than at fine tolerance. @@ -143,7 +138,6 @@ end ) end - # ── Review-2 #10 ──────────────────────────────────────────────────────── @testset "Failed __init does not leak PETSc objects (subsequent solves work)" begin @test_throws ArgumentError solve(prob, TSImplicit("does-not-exist"); dt = 0.1) # The failed solve allocated a TS and a Vec via _common_ts_setup, which @@ -153,7 +147,6 @@ end @test sol.retcode == ReturnCode.Success end - # ── Review-3 #2 ───────────────────────────────────────────────────────── @testset "Complex-valued ODEProblem is rejected with a clear error" begin prob_c = ODEProblem(decay!, ComplexF64[1 + 0im], tspan) err = try @@ -167,7 +160,6 @@ end @test occursin("ComplexF64", err.msg) || occursin("Complex", err.msg) end - # ── Review-3 #3 ───────────────────────────────────────────────────────── @testset "Initialize callback that mutates u does not duplicate t0" begin function init_cb!(cb, u, t, integ) u[1] = 5.0 @@ -192,7 +184,6 @@ end @test sol.u[1][1] ≈ 5.0 end - # ── Review-4 #1 ───────────────────────────────────────────────────────── @testset "Initialize that mutates u without u_modified! still propagates" begin # The pessimistic-modified contract: a callback that mutates `u` but # forgets to call `SciMLBase.u_modified!(integ, true)` must still @@ -280,7 +271,6 @@ end @test isempty(sol.t) && isempty(sol.u) end - # ── Review-2 #12 ──────────────────────────────────────────────────────── @testset "AbstractVector petsc_options constructors work for every alg" begin # Tuples, SubStrings, generic AbstractVectors should all coerce. @test PETSc.TSRK("3bs", ("-ts_max_steps", "100")).petsc_options == ["-ts_max_steps", "100"] @@ -295,7 +285,6 @@ end ["-snes_fd", "-ts_max_steps", "100"] end - # ── Review-5 #1 ───────────────────────────────────────────────────────── @testset "Single-sided reltol still reaches PETSc" begin # `solve(...; reltol = 1e-10)` (no `abstol`) used to be silently # ignored — the wrapper required *both* sides to be set. Now the @@ -327,7 +316,6 @@ end @test length(sol_abs.t) > length(sol_default.t) end - # ── Review-5 #2 ───────────────────────────────────────────────────────── @testset "Unsupported solve keywords are rejected with ArgumentError" begin # Anything not on the explicit allowlist should fail loudly. Pick a # set of common SciML knobs that this extension does NOT honour. @@ -421,7 +409,6 @@ end @test alg.petsc_options == ["-ts_max_steps", "100"] end - # ── Review-6 #1 ───────────────────────────────────────────────────────── @testset "Initial dt outside [dtmin, dtmax] is rejected" begin # PETSc installs the initial `dt` verbatim and only consults # `TSAdaptSetStepLimits` for subsequent step proposals, so the wrapper @@ -455,7 +442,6 @@ end end end - # ── Review-6 #2 ───────────────────────────────────────────────────────── @testset "verbose is no longer in the supported-keyword set" begin # Previously `verbose = true` was silently accepted but unused; the # extension now treats it like any other unsupported keyword. @@ -469,7 +455,6 @@ end @test occursin("verbose", err.msg) end - # ── Review-6 #3 ───────────────────────────────────────────────────────── @testset "TSGeneric without explicit = true rejects euler / ssp upfront" begin # The Julia-side validator must fire before any PETSc setup runs, so # the regular test output stays free of raw PETSc error banners. @@ -485,7 +470,6 @@ end end end - # ── Review-7 #1 ───────────────────────────────────────────────────────── @testset "Invalid dt values are rejected with ArgumentError" begin # Negative `dt` would otherwise let a forward solve effectively step # backward and still report Success. @@ -498,7 +482,6 @@ end @test_throws ArgumentError solve(prob, TSRK("3bs"); dt = NaN) end - # ── Review-7 #2 ───────────────────────────────────────────────────────── @testset "Invalid dtmin / dtmax values are rejected with ArgumentError" begin # Inverted bounds. err = try @@ -527,7 +510,6 @@ end @test sol.retcode == ReturnCode.Success end - # ── Review-7 #3 ───────────────────────────────────────────────────────── @testset "Invalid reltol / abstol values are rejected with ArgumentError" begin @test_throws ArgumentError solve( prob, TSRK("5dp"); dt = 0.1, reltol = -1e-3, abstol = 1e-6, @@ -550,7 +532,6 @@ end @test sol.retcode == ReturnCode.Success end - # ── Review-8 #1 ───────────────────────────────────────────────────────── @testset "Invalid scalar saveat values are rejected" begin # Previously these silently produced an empty save schedule with a # `Success` retcode. Now they fail loudly at the Julia boundary. @@ -577,7 +558,6 @@ end ) end - # ── Review-9 #1 ───────────────────────────────────────────────────────── @testset "maxiters caps the manual TSStep loop and reports MaxIters" begin # Fixed-step integration over [0, 1] with `dt = 0.1` would otherwise # run for 10 steps; `maxiters = 1` must stop it after one accepted @@ -602,7 +582,6 @@ end @test sol.stats.naccept == 1 end - # ── Review-9 #2 ───────────────────────────────────────────────────────── @testset "sol.stats reflects the actual number of steps taken" begin sol = solve( prob, TSRK("3bs"); @@ -635,7 +614,6 @@ end @test sol.stats.nf > 0 end - # ── Review-10 #1 ──────────────────────────────────────────────────────── @testset "maxiters = 0 yields a zero-step solve, not one step" begin # The cap is now checked *before* `TSStep`, so `maxiters = 0` must # not advance time, regardless of what `dt` would otherwise produce. @@ -666,7 +644,6 @@ end @test sol.stats.naccept == 0 end - # ── Review-14 #1 ──────────────────────────────────────────────────────── @testset "Front-end validation runs before any PETSc TS allocation" begin # Both `tstops` and bad `saveat` should now fail before # `_common_ts_setup` allocates a `TS` / `Vec` pair, so repeated @@ -685,7 +662,6 @@ end @test sol.retcode == ReturnCode.Success end - # ── Review-14 #2 ──────────────────────────────────────────────────────── @testset "Callback initialize / finalize hooks fire around the solve" begin # The lifecycle calls (`SciMLBase-equivalent initialize!` / # `SciMLBase-equivalent finalize!`) are part of the basic discrete-callback @@ -730,7 +706,6 @@ end "`save_final_discretes!`." end - # ── Review-15 #1 ──────────────────────────────────────────────────────── @testset "initialize_save = false suppresses the post-init save record" begin # `initialize_save` is the upstream SciML knob that controls # whether the integrator appends a save record immediately after @@ -770,7 +745,6 @@ end @test sol_on.t[1] ≈ 0.0 end - # ── Review-16 #1 ──────────────────────────────────────────────────────── # `save_discretes` was added to SciMLBase's `allowedkeywords` whitelist in # 2.120.0. On older stacks the kwarg is rejected by SciMLBase before it # reaches the PETSc extension, so we gate the test on that floor. @@ -794,7 +768,6 @@ end "allowedkeywords)." end - # ── Review-13 #1 ──────────────────────────────────────────────────────── @testset "tstops kwarg is rejected with ArgumentError" begin # `tstops` carries a strict SciML contract — the integrator must # land on those times so step-end callback logic can see them. @@ -811,7 +784,6 @@ end @test occursin("tstops", err.msg) end - # ── Review-13 #2 ──────────────────────────────────────────────────────── @testset "Stateful saveat iterators survive validation" begin # `Iterators.Stateful` is a one-shot iterator: a previous version # of the wrapper validated `saveat` by iterating it once, which @@ -829,7 +801,6 @@ end (0.25, 0.5, 0.75)) end - # ── Review-12 #1 ──────────────────────────────────────────────────────── @testset "TSARKIMEX with SplitODEProblem populates both nf and nf2" begin # The implicit (`f1`) stream should land in `stats.nf`; the # explicit (`f2`) stream should land in `stats.nf2`. We pin the @@ -855,7 +826,6 @@ end @test f1_calls[] != f2_calls[] end - # ── Review-11 #1 ──────────────────────────────────────────────────────── @testset "Negative maxiters is rejected with ArgumentError" begin # Without explicit validation, a negative SciML `maxiters` would # turn the manual `TSStep` loop into a zero-step `MaxIters` solve — @@ -871,13 +841,12 @@ end @test err isa ArgumentError @test occursin("maxiters", err.msg) - # `maxiters = 0` remains a valid zero-step solve (Review-10). + # `maxiters = 0` remains a valid zero-step solve. sol = solve(prob, TSRK("3bs"); dt = 0.1, adaptive = false, maxiters = 0) @test sol.retcode == ReturnCode.MaxIters @test sol.stats.naccept == 0 end - # ── Review-10 #2 ──────────────────────────────────────────────────────── @testset "Unsupported DEStats fields stay at the SciML \"unknown\" sentinel" begin sol = solve(prob, TSRK("3bs"); dt = 0.1, adaptive = false) # We populate `naccept`, `nreject`, `nnonliniter`, and `nf`. Every @@ -893,7 +862,6 @@ end @test sol.stats.njacs == -1 end - # ── Review-8 #2 ───────────────────────────────────────────────────────── @testset "dtmax = Inf is accepted and behaves like an omitted dtmax" begin # `Inf` is the natural SciML spelling of "no upper cap", and the # wrapper already uses `Inf` as the default internally. Reject the diff --git a/test/sciml/test_rk.jl b/test/sciml/test_rk.jl index 64ab52e06..c4546718f 100644 --- a/test/sciml/test_rk.jl +++ b/test/sciml/test_rk.jl @@ -14,7 +14,7 @@ end ex51_exact(t) = [sin(t), 2 * atan(exp(t) * tan(0.5))] -@testset "Step 2 — TSRK explicit Runge-Kutta" begin +@testset "TSRK explicit Runge-Kutta" begin @testset "ex51 with TSRK(\"3bs\") matches analytical solution" begin u0 = [0.0, 1.0] tspan = (0.0, 1.0) diff --git a/test/sciml/test_rosenbrock.jl b/test/sciml/test_rosenbrock.jl index 3e326e39b..363bc7be9 100644 --- a/test/sciml/test_rosenbrock.jl +++ b/test/sciml/test_rosenbrock.jl @@ -19,7 +19,7 @@ function vdp!(du, u, p, t) return nothing end -@testset "Step 3 — TSRosW Rosenbrock-W" begin +@testset "TSRosW Rosenbrock-W" begin @testset "ex51 with TSRosW(\"ra34pw2\") matches analytical solution" begin u0 = [0.0, 1.0] tspan = (0.0, 1.0)