Skip to content

Commit 1e786aa

Browse files
Sébastien LoiselSébastien Loisel
authored andcommitted
Make Python optional: plotting and pyamg become package extensions; bump to 1.0.2
PyPlot and PyCall move from [deps] to [weakdeps]. The solver core now has no Python dependency at all (the dependency closure shrinks from 206 to 174 packages); plotting and the pyamg prolongator are opt-in extensions. MultiGridBarrierPyPlotExt (load PyPlot; PyCall arrives with it) carries every plot method -- 2D FEM, 1D, spectral, MGBSOL, ParabolicSOL with the matplotlib animation, and plot3d.jl wholesale with its lazy pyvista import. The methods extend PyPlot.plot and PyPlot.savefig, so gives exactly the old behavior; the parent no longer exports plot/savefig. MGB3DFigure and HTML5anim hold plain PNG/HTML data and stay in the parent. MultiGridBarrierPyAMGExt (load PyCall) supplies amg_pyamg; the parent keeps the exported stub. FFMPEG/PNGFiles/Base64 stay hard deps (not Python; the extension uses them through the parent). Docs gain Plotting and PyAMG tabs; index/zoo examples show the PyPlot load; README quickstart updated and gains a Python-optional bullet. CI configures PyCall against the system Python in a throwaway environment (the build config is depot-wide) and keeps the OpenSSL_jll 3.0 pin CI-only, in the package env, where it always lived; the test target gains PyPlot/PyCall extras and runtests.jl loads PyPlot. Verified: full Pkg.test green (all five extensions, including pyamg through the new extension against the real Python pyamg); local docs build green; Python-free load and solve confirmed in a PyCall-free environment.
1 parent fa8eb05 commit 1e786aa

23 files changed

Lines changed: 454 additions & 222 deletions

.github/workflows/CI.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,20 @@ jobs:
4848
version: ${{ matrix.version }}
4949
arch: ${{ matrix.arch }}
5050
- uses: julia-actions/cache@v3
51-
- name: Build package with system Python
51+
# PyCall/PyPlot are weak dependencies (test-only extras), so PyCall is
52+
# configured against the system Python in a throwaway environment (the
53+
# build config is depot-wide). The OpenSSL_jll pin goes into the package
54+
# env so the test env inherits it: it keeps Julia's libssl compatible
55+
# with the system Python's at runtime.
56+
- name: Configure PyCall against system Python
5257
env:
5358
PYTHON: python
54-
run: julia --project=. -e 'using Pkg; Pkg.add(name="OpenSSL_jll", version="3.0"); Pkg.instantiate(); Pkg.build("PyCall")'
59+
run: julia -e 'using Pkg; Pkg.activate(temp=true); Pkg.add(Pkg.PackageSpec(name="OpenSSL_jll", version="3.0")); Pkg.add("PyCall"); Pkg.build("PyCall")'
60+
- name: Instantiate
61+
run: julia --project=. -e 'using Pkg; Pkg.add(name="OpenSSL_jll", version="3.0"); Pkg.instantiate()'
5562
- name: Run tests with XVFB
63+
env:
64+
PYTHON: python
5665
run: xvfb-run --auto-servernum julia --project=. -e 'using Pkg; Pkg.test(coverage=true)'
5766
- uses: julia-actions/julia-processcoverage@v1
5867
- uses: codecov/codecov-action@v7

Project.toml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "MultiGridBarrier"
22
uuid = "9e2c1f1d-9131-4ad4-b32f-bd2a0b0ecd1e"
33
authors = ["Sébastien Loisel"]
4-
version = "1.0.1"
4+
version = "1.0.2"
55

66
[deps]
77
AlgebraicMultigrid = "2169fc97-5a83-5252-b627-83903c6c433c"
@@ -11,8 +11,6 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
1111
PNGFiles = "f57f5aa1-a3ce-4bc8-8ab9-96f992907883"
1212
PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
1313
ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca"
14-
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
15-
PyPlot = "d330b81b-6aea-500a-939a-2ce795aea3ee"
1614
QuadratureRules = "a08977f5-a20d-4b99-b11f-f5ea535e0575"
1715
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
1816
SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
@@ -23,11 +21,15 @@ CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
2321
CUDSS_jll = "4889d778-9329-5762-9fec-0578a5d30366"
2422
Gmsh = "705231aa-382f-11e9-3f0c-b7cb4346fdeb"
2523
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
24+
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
25+
PyPlot = "d330b81b-6aea-500a-939a-2ce795aea3ee"
2626

2727
[extensions]
2828
MultiGridBarrierCUDAExt = ["CUDA", "CUDSS_jll"]
2929
MultiGridBarrierGmshExt = "Gmsh"
3030
MultiGridBarrierJuMPExt = "JuMP"
31+
MultiGridBarrierPyAMGExt = "PyCall"
32+
MultiGridBarrierPyPlotExt = ["PyPlot", "PyCall"]
3133

3234
[compat]
3335
AlgebraicMultigrid = "1"
@@ -51,7 +53,10 @@ CUDSS_jll = "4889d778-9329-5762-9fec-0578a5d30366"
5153
Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
5254
Gmsh = "705231aa-382f-11e9-3f0c-b7cb4346fdeb"
5355
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
56+
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
57+
PyPlot = "d330b81b-6aea-500a-939a-2ce795aea3ee"
5458
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
5559

5660
[targets]
57-
test = ["Test", "Coverage", "CUDA", "CUDSS_jll", "JuMP", "Gmsh"]
61+
# PyPlot/PyCall activate the plotting and pyamg extensions under test.
62+
test = ["Test", "Coverage", "CUDA", "CUDSS_jll", "JuMP", "Gmsh", "PyPlot", "PyCall"]

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ Chebyshev spectral discretizations, with optional CUDA GPU acceleration.
2020
## Install and quickstart
2121

2222
```julia
23-
using Pkg; Pkg.add("MultiGridBarrier")
23+
using Pkg; Pkg.add(["MultiGridBarrier", "PyPlot"])
2424

25-
using MultiGridBarrier
25+
using MultiGridBarrier, PyPlot # PyPlot enables the plotting extension
2626
geom = fem2d_P2() # a 2D P2 triangular mesh
2727
sol = mgb_solve(assemble(amg(geom); p = 1.0)) # solve a (nonsmooth) p = 1 problem
2828
plot(sol)
@@ -38,6 +38,9 @@ plot(sol)
3838
- **Discretizations:** finite elements in 1D/2D/3D (simplicial `P1`/`P2` and tensor-product `Q_k`),
3939
plus Chebyshev spectral elements; all isoparametric.
4040
- **Solver:** an algebraic-multigrid hierarchy (`amg`) driving a barrier (interior-point) method.
41+
- **Python-optional:** the solver core has no Python dependency. Plotting (`using PyPlot`:
42+
matplotlib in 1D/2D, PyVista in 3D) and the `pyamg` prolongators (`using PyCall`) are
43+
opt-in extensions.
4144
- **JuMP modeling front end** (loads with `using JuMP`): state variational problems with
4245
`@variable`/`@constraint`/`@objective`; `optimize!` lowers them straight to the multigrid
4346
barrier pipeline.

docs/make.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ makedocs(;
2525
pages=[
2626
"Home" => "index.md",
2727
"Zoo" => "zoo.md",
28+
"Plotting" => "plotting.md",
2829
"JuMP" => "jump.md",
2930
"Gmsh" => "gmsh.md",
31+
"PyAMG" => "pyamg.md",
3032
],
3133
)
3234

docs/src/index.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ such as `max_coarse` to the underlying builder):
6767
- `amg_smoothed_aggregation(; kwargs...)` — smoothed aggregation, via the same package.
6868
- `amg_pyamg(; solver=:rootnode, kwargs...)` — the Python
6969
[`pyamg`](https://github.com/pyamg/pyamg) package (`:rootnode` energy-minimization,
70-
`:smoothed_aggregation`, or `:ruge_stuben`), imported lazily through `PyCall`.
70+
`:smoothed_aggregation`, or `:ruge_stuben`); provided by the
71+
[PyAMG extension](pyamg.md) (load PyCall).
7172

7273
```julia
7374
mgb_solve(assemble(amg(fem2d_P1(); prolongator = amg_ruge_stuben(max_coarse=4)); p=1.5); verbose=false);
@@ -77,8 +78,7 @@ mgb_solve(assemble(amg(fem2d_P1(); prolongator = amg_ruge_stuben(max_coarse=4));
7778

7879
A 1d p-Laplace problem:
7980
```@example 1
80-
using PyPlot # hide
81-
using MultiGridBarrier
81+
using MultiGridBarrier, PyPlot # PyPlot enables the plotting extension
8282
nodes = collect(range(-1.0, 1.0, length=33))
8383
geom = fem1d(; nodes)
8484
plot(mgb_solve(assemble(amg(geom); p=1.0); verbose=false));
@@ -252,6 +252,12 @@ multigrid hierarchy on the fine mesh. To refine the mesh first, compose with
252252
The legacy `geometric_mg(geom, L)` builds a geometric-subdivision hierarchy instead of
253253
AMG; it remains available for callers that specifically want geometric transfers.
254254

255+
Visualization is an opt-in extension: load PyPlot
256+
(`using MultiGridBarrier, PyPlot`) and `plot` works on every solution and
257+
geometry — matplotlib for 1D/2D, PyVista for 3D volumes, surfaces, and curves;
258+
see [Plotting](plotting.md). Without PyPlot the solver core has no Python
259+
dependency at all.
260+
255261
Prefer stating problems in an algebraic modeling language? The
256262
[JuMP front end](jump.md) accepts standard `@variable`/`@constraint`/`@objective`
257263
syntax and lowers it to this same pipeline, building the hierarchy automatically.
@@ -381,7 +387,7 @@ Private = false
381387
Modules = [MultiGridBarrier]
382388
Order = [:type]
383389
Private = false
384-
Filter = t -> !(nameof(t) in (:MGBModel, :Coef, :EpiPower, :deriv, :integral, :set_start, :mgb_solution, :solver_log, :On, :Broken, :Continuous, :Uniform, :gmsh_import))
390+
Filter = t -> !(nameof(t) in (:MGBModel, :Coef, :EpiPower, :deriv, :integral, :set_start, :mgb_solution, :solver_log, :On, :Broken, :Continuous, :Uniform, :gmsh_import, :amg_pyamg, :MGB3DFigure, :HTML5anim))
385391
```
386392

387393
# Functions reference
@@ -390,7 +396,7 @@ Filter = t -> !(nameof(t) in (:MGBModel, :Coef, :EpiPower, :deriv, :integral, :s
390396
Modules = [MultiGridBarrier]
391397
Order = [:function]
392398
Private = false
393-
Filter = t -> !(nameof(t) in (:MGBModel, :Coef, :EpiPower, :deriv, :integral, :set_start, :mgb_solution, :solver_log, :On, :Broken, :Continuous, :Uniform, :gmsh_import))
399+
Filter = t -> !(nameof(t) in (:MGBModel, :Coef, :EpiPower, :deriv, :integral, :set_start, :mgb_solution, :solver_log, :On, :Broken, :Continuous, :Uniform, :gmsh_import, :amg_pyamg, :MGB3DFigure, :HTML5anim))
394400
```
395401

396402
# Index

docs/src/plotting.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
```@meta
2+
CurrentModule = MultiGridBarrier
3+
```
4+
5+
# Plotting
6+
7+
Visualization is a package extension (`MultiGridBarrierPyPlotExt`): it loads
8+
automatically once both MultiGridBarrier and
9+
[PyPlot](https://github.com/JuliaPy/PyPlot.jl) are imported, and its methods
10+
extend `PyPlot.plot` and `PyPlot.savefig` — so `plot` is one function whether
11+
you hand it matplotlib arrays or MultiGridBarrier solutions. Without PyPlot,
12+
the solver core has no Python dependency at all.
13+
14+
!!! note "Requires PyPlot"
15+
Add PyPlot to your environment (`pkg> add PyPlot`) and load both packages:
16+
`using MultiGridBarrier, PyPlot`. PyCall arrives with PyPlot; the 3D
17+
plotters additionally import the Python `pyvista` package lazily on first
18+
use (installing it from conda-forge if necessary).
19+
20+
## What `plot` does
21+
22+
```julia
23+
plot(sol::MGBSOL, k=1; kwargs...) # k-th solution component on sol.geometry
24+
plot(sol::ParabolicSOL, k=1; kwargs...) # HTML5 animation of a time-dependent solve
25+
plot(M::Geometry, z::Vector; kwargs...) # a nodal vector on a geometry
26+
plot(M::Geometry, ts, U::Matrix; ...) # animate the columns of U at times ts
27+
```
28+
29+
The rendering is chosen by the geometry:
30+
31+
- **1D** (`fem1d`, `spectral1d`): line plot. Spectral solutions are sampled
32+
through `interpolate`; choose the points with `x = -1:0.01:1`.
33+
- **2D FEM** (`fem2d_P1`, `fem2d_P2`, `fem2d`): triangulated surface plot
34+
(`plot_trisurf`) on the mesh structure.
35+
- **2D spectral** (`spectral2d`): 3D surface plot on an evaluation grid
36+
(`x = -1:0.01:1, y = -1:0.01:1`).
37+
- **3D FEM** (`fem3d`) and **embedded manifolds** (`fem2d`/`fem1d` with
38+
`ambient = Val(3)`): PyVista renders — volume + isosurfaces + slices for
39+
volumes, colored quad meshes for surfaces, tubes for curves. These return an
40+
[`MGB3DFigure`](@ref) (PNG bytes) that displays inline in Jupyter/Documenter
41+
and saves with `savefig(fig, "out.png")`.
42+
43+
Remaining keyword arguments pass through to the underlying PyPlot/PyVista
44+
calls.
45+
46+
```@example plotting
47+
using MultiGridBarrier, PyPlot
48+
sol = mgb_solve(assemble(amg(subdivide(fem2d_P2(), 3)); p = 1.0); verbose = false)
49+
plot(sol); savefig("plotting_demo.svg"); nothing # hide
50+
close() # hide
51+
```
52+
![](plotting_demo.svg)
53+
54+
## Animations
55+
56+
Time-dependent solutions animate into an [`HTML5anim`](@ref) — an HTML5
57+
`<video>` that embeds inline in Jupyter, Pluto, and Documenter:
58+
59+
```julia
60+
sol = parabolic_solve(amg(subdivide(fem2d_P1(), 3)); h = 0.1)
61+
plot(sol) # matplotlib.animation (1D/2D)
62+
plot(sol.geometry, ts, U) # or animate any matrix of frames directly
63+
```
64+
65+
The animation advances at a fixed frame rate (`frame_time` seconds per video
66+
frame); for irregular `ts`, each video frame shows the latest data frame with
67+
timestamp ≤ the current video time. In 1D/2D the video is produced by
68+
`matplotlib.animation` (`embed_limit` caps the embedded size in megabytes, and
69+
the `printer` callback receives the Matplotlib animation object, e.g. to
70+
`anim.save("out.mp4")`). In 3D, PyVista frames are encoded by `ffmpeg`.
71+
72+
## Result types
73+
74+
```@docs
75+
MGB3DFigure
76+
HTML5anim
77+
```

docs/src/pyamg.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
```@meta
2+
CurrentModule = MultiGridBarrier
3+
```
4+
5+
# PyAMG prolongators
6+
7+
The multigrid hierarchy that `amg(geom)` builds is driven by a *prolongator*
8+
a callable mapping a stiffness matrix to its level prolongations. Two
9+
pure-Julia factories ship in the core (`amg_ruge_stuben`, the default, and
10+
`amg_smoothed_aggregation`, both via
11+
[AlgebraicMultigrid.jl](https://github.com/JuliaLinearAlgebra/AlgebraicMultigrid.jl)).
12+
A third is backed by the Python [pyamg](https://github.com/pyamg/pyamg)
13+
package and lives in the `MultiGridBarrierPyAMGExt` extension:
14+
15+
!!! note "Requires PyCall"
16+
Add PyCall to your environment (`pkg> add PyCall`) and load both packages:
17+
`using MultiGridBarrier, PyCall`. The Python `pyamg` and `scipy` packages
18+
are imported lazily on the first call, installing from conda-forge if
19+
necessary. (Loading PyPlot for [plotting](plotting.md) also loads PyCall,
20+
so the plotting setup enables this extension too.)
21+
22+
```julia
23+
using MultiGridBarrier, PyCall
24+
geom = subdivide(fem2d_P1(), 4)
25+
mg = amg(geom; prolongator = amg_pyamg(solver = :rootnode))
26+
sol = mgb_solve(assemble(mg; p = 1.0))
27+
```
28+
29+
`solver` selects pyamg's `:rootnode` (energy-minimization, the default),
30+
`:smoothed_aggregation`, or `:ruge_stuben`; remaining keyword arguments are
31+
forwarded to the pyamg solver constructor.
32+
33+
When is this worth reaching for? The pure-Julia Ruge–Stüben default is robust
34+
across the package's benchmarks. Aggregation-based coarsening —
35+
`amg_pyamg(solver = :rootnode)` in particular — is an escape hatch for highly
36+
anisotropic problems near `p = 1`, especially combined with the
37+
`auxiliary_postprocess` keyword of `amg` (see the [`amg`](@ref) docstring),
38+
where classical coarsening can blow up Newton iteration counts on the central
39+
path.
40+
41+
## API reference
42+
43+
```@docs
44+
amg_pyamg
45+
```

docs/src/zoo.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ dimension, on the coarsest mesh that still draws an interesting picture.
2020
Tolerances are loose (`tol = 1e-3`) and progress bars are suppressed.
2121

2222
```@example zoo
23-
using PyPlot # hide
24-
using MultiGridBarrier
23+
using MultiGridBarrier, PyPlot # PyPlot enables the plotting extension
2524
nodes1 = collect(range(-1.0, 1.0, length=17)) # 1D
2625
mg1 = amg(fem1d(; nodes=nodes1))
2726
mg2 = amg(subdivide(fem2d_P1(), 3)) # 2D

ext/MultiGridBarrierPyAMGExt.jl

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# MultiGridBarrierPyAMGExt — the pyamg-backed AMG prolongator, as a package
2+
# extension. Loads automatically when both MultiGridBarrier and PyCall are
3+
# imported (`using MultiGridBarrier, PyCall`); supplies the method of the
4+
# parent stub `amg_pyamg` (src/amg_prolongators.jl). The Python pyamg and scipy
5+
# packages are imported lazily on the first call, installing from conda-forge
6+
# if necessary.
7+
module MultiGridBarrierPyAMGExt
8+
9+
using PyCall
10+
using SparseArrays
11+
import MultiGridBarrier
12+
import MultiGridBarrier: amg_pyamg
13+
14+
# Convert a scipy sparse matrix (PyObject) to a Julia SparseMatrixCSC{Float64,Int}.
15+
function _scipy_to_julia(Pyobj)
16+
csc = Pyobj.tocsc()
17+
indptr = Vector{Int}(csc.indptr) # 0-based CSC column pointers
18+
indices = Vector{Int}(csc.indices) # 0-based row indices
19+
data = Vector{Float64}(csc.data)
20+
shape = csc.shape
21+
m, n = Int(shape[1]), Int(shape[2])
22+
colptr = indptr .+ 1
23+
rowval = indices .+ 1
24+
return SparseMatrixCSC{Float64,Int}(m, n, colptr, rowval, data)
25+
end
26+
27+
function MultiGridBarrier.amg_pyamg(; solver::Symbol=:rootnode, kwargs...)
28+
return function (K64::SparseMatrixCSC{Float64,Int})
29+
pyamg = pyimport_conda("pyamg", "pyamg", "conda-forge")
30+
scipy_sparse = pyimport_conda("scipy.sparse", "scipy", "conda-forge")
31+
# Build a scipy CSC matrix from the Julia CSC arrays (0-based indices).
32+
A = scipy_sparse.csc_matrix(
33+
(K64.nzval, K64.rowval .- 1, K64.colptr .- 1),
34+
shape = (size(K64, 1), size(K64, 2)))
35+
ml = if solver === :rootnode
36+
pyamg.rootnode_solver(A; kwargs...)
37+
elseif solver === :smoothed_aggregation
38+
pyamg.smoothed_aggregation_solver(A; kwargs...)
39+
elseif solver === :ruge_stuben
40+
pyamg.ruge_stuben_solver(A; kwargs...)
41+
else
42+
throw(ArgumentError("amg_pyamg: unknown solver $(solver); " *
43+
"use :rootnode, :smoothed_aggregation, or :ruge_stuben"))
44+
end
45+
# The coarsest level carries no P; take all levels but the last.
46+
levels = collect(ml.levels)
47+
return [_scipy_to_julia(lvl.P) for lvl in levels[1:end-1]]
48+
end
49+
end
50+
51+
end # module

0 commit comments

Comments
 (0)