From dc0a375ee3ac2d0618c657b65c11c08fb42ff4fc Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 10:00:25 +0200 Subject: [PATCH 01/12] Add spectrumplot and its CairoMakie package extension The singular-value or eigenvalue spectrum of a reduced basis, drawn as it is stored: an EVD spectrum from a Gram matrix and a singular-value spectrum differ by a square, and reading one as the other flatters the decay by a factor of two. CairoMakie is a weak dependency and the method lives in the package extension ReducedBasisMethodsCairoMakieExt, so the package loads with no plotting stack at all and spectrumplot raises the usual MethodError until CairoMakie is loaded. Values at or below zero -- an exact zero of a rank-deficient snapshot, or the slightly negative eigenvalue the Gram route returns -- are lifted to eps(T) * maximum(sigma) so a log-scaled axis draws the whole spectrum; every positive value is drawn as it is given. --- Project.toml | 7 ++++++ ext/ReducedBasisMethodsCairoMakieExt.jl | 28 ++++++++++++++++++++++++ src/ReducedBasisMethods.jl | 4 ++++ src/diagnostics.jl | 29 +++++++++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 ext/ReducedBasisMethodsCairoMakieExt.jl create mode 100644 src/diagnostics.jl diff --git a/Project.toml b/Project.toml index 0c8ef88..1fab589 100644 --- a/Project.toml +++ b/Project.toml @@ -11,7 +11,14 @@ MultiIndexArrays = "2f7e0951-2673-41f4-a6a8-e6772a4b2a08" ReducedComplexityModeling = "abb4722c-1d8e-48eb-a3bd-45b3116bfaba" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +[weakdeps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" + +[extensions] +ReducedBasisMethodsCairoMakieExt = "CairoMakie" + [compat] +CairoMakie = "0.15" GeometricEquations = "0.21" HDF5 = "0.16, 0.17" LinearAlgebra = "1" diff --git a/ext/ReducedBasisMethodsCairoMakieExt.jl b/ext/ReducedBasisMethodsCairoMakieExt.jl new file mode 100644 index 0000000..5692de4 --- /dev/null +++ b/ext/ReducedBasisMethodsCairoMakieExt.jl @@ -0,0 +1,28 @@ +module ReducedBasisMethodsCairoMakieExt + +using CairoMakie +using ReducedBasisMethods + +function ReducedBasisMethods.spectrumplot(σ::AbstractVector{<:Real}; + title = "", resolution = nothing) + isempty(σ) && throw(ArgumentError("no spectrum to plot")) + all(isfinite, σ) || throw(ArgumentError( + "the spectrum holds a NaN or an Inf, which a log-scaled axis cannot draw")) + + # The spectrum is drawn in its own precision, at its own floor: a `Float32` spectrum is not + # widened to `Float64` just to be drawn. + s = convert(Vector{float(eltype(σ))}, σ) + # A rank-deficient snapshot set gives a Gram matrix with exact zeros among its eigenvalues, and + # a nearly dependent one gives round-off eigenvalues slightly below zero. Both are lifted to + # the floor rather than drawn below it, and every positive value is drawn as it is given. + floor = eps(eltype(s)) * maximum(s) + y = [σᵢ ≤ 0 ? floor : σᵢ for σᵢ in s] + + fig = Figure(size = resolution === nothing ? (620, 380) : resolution) + ax = Axis(fig[1, 1]; xlabel = "i", ylabel = "σᵢ", yscale = log10) + isempty(title) || Label(fig[0, 1], title; halign = :left, fontsize = 16) + lines!(ax, 1:length(y), y) + return fig +end + +end diff --git a/src/ReducedBasisMethods.jl b/src/ReducedBasisMethods.jl index d3a41ea..7d0561a 100644 --- a/src/ReducedBasisMethods.jl +++ b/src/ReducedBasisMethods.jl @@ -39,6 +39,10 @@ include("reduced_equations.jl") export reduced_ode, reduced_pode +include("diagnostics.jl") + +export spectrumplot + export h5save, h5load include("reduced_tensor.jl") diff --git a/src/diagnostics.jl b/src/diagnostics.jl new file mode 100644 index 0000000..1935e0f --- /dev/null +++ b/src/diagnostics.jl @@ -0,0 +1,29 @@ +""" + spectrumplot(σ::AbstractVector{<:Real}; kwargs...) + +The singular-value spectrum `σ` of a reduced basis, drawn on a log-scaled axis against its index: +the diagnostic that says how many basis vectors a snapshot set admits, and where the spectrum +runs into round-off. + +The values are drawn as they are given, never squared and never rooted: an eigenvalue spectrum +from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a square, and reading +one as the other flatters the decay by a factor of two. A caller that holds the eigenvalues +plots them as they are. + +The axis is log-scaled, so a value at or below zero — an exact zero of a rank-deficient spectrum, +or the slightly negative eigenvalue the Gram route can return — is lifted to the floor +`eps(T) * maximum(σ)`, taken in the spectrum's own precision, instead of falling off the axis. +Every positive value is drawn as it is given, never rounded and never lifted: a `Float32` value is +widened to the `Float64` the plot stores, which is exact. + +An empty `σ` throws an `ArgumentError`, and so does a spectrum holding a `NaN` or an `Inf`, +which a log-scaled axis cannot draw. + +Defined in a package extension: load `CairoMakie` to make it available. + +```julia +using ReducedBasisMethods, CairoMakie +fig = spectrumplot(b.Λ) # b.Λ is the full stored spectrum +``` +""" +function spectrumplot end From 2585f4b0ba94fd84db5a9fe0a6c863d9a59f0ba1 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 10:01:28 +0200 Subject: [PATCH 02/12] Test the extension, the load without Makie, Aqua and the docs One test per check of the part's benchmark: - diagnostics.jl: the headless render of a 10-value spectrum to a PNG, with the plotted y equal to sigma exactly in the precision the plot stores, and a rank-deficient spectrum whose exact zeros and slightly negative eigenvalues are drawn at the floor. - integration/load_without_makie.jl: the package loads in a fresh temporary project that holds only it, no Makie UUID reaches Pkg.dependencies(), the extension is not loaded, and the stub raises the usual MethodError. - quality/aqua.jl: Aqua.test_all is wrapped in a testset, so the passes of its checks are reported rather than swallowed, and stale_deps, undefined_exports and the weakdeps deps_compat entry are visible. - quality/docs.jl: the words a generic package must not write, the doctest count, the entry the docs environment no longer resolves, the changelog entry, and the test environment taking its CairoMakie bound from the package alone. - quality/doctests.jl, in the slow group, as ReducedComplexityModeling does. --- test/Project.toml | 3 + test/diagnostics.jl | 110 +++++++++++++++++++++++++ test/integration/load_without_makie.jl | 54 ++++++++++++ test/quality/aqua.jl | 19 ++++- test/quality/docs.jl | 86 +++++++++++++++++++ test/quality/doctests.jl | 16 ++++ test/runtests.jl | 8 ++ 7 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 test/diagnostics.jl create mode 100644 test/integration/load_without_makie.jl create mode 100644 test/quality/docs.jl create mode 100644 test/quality/doctests.jl diff --git a/test/Project.toml b/test/Project.toml index 7226238..6f64921 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,5 +1,7 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" GeometricBrackets = "2a9ffa85-4e0d-473f-b05e-278d80d8d306" GeometricIntegrators = "dcce2d33-59f6-5b8d-9047-0defad88ae06" @@ -17,6 +19,7 @@ ReducedBasisMethods = {path = ".."} [compat] Aqua = "0.8.18" +Documenter = "1.19.0" ForwardDiff = "1.4.6" GeometricBrackets = "0.1.1, 0.2" GeometricIntegrators = "0.18.5" diff --git a/test/diagnostics.jl b/test/diagnostics.jl new file mode 100644 index 0000000..be075a5 --- /dev/null +++ b/test/diagnostics.jl @@ -0,0 +1,110 @@ +using CairoMakie +using LinearAlgebra +using Random +using ReducedBasisMethods +using ReducedComplexityModeling: Parameter, ParameterSpace +using Test + +""" +The single ``Lines`` series of a figure's axis. `CairoMakie` is loaded here, and only here, so this +file tests the extension and not the package's own load path. +""" +function series(fig) + only(filter(p -> p isa CairoMakie.Makie.Lines, + only(filter(a -> a isa CairoMakie.Makie.Axis, fig.content)).scene.plots)) +end + +"`Lines` stores its converted positional argument as one vector of points, so the element type of +those points is the precision the plot holds, and the comparison of check 2 is made in it." +plotted_y(fig) = [q[2] for q in series(fig).positions[]] +plotted_eltype(fig) = eltype(eltype(series(fig).positions[])) + +@testset "Diagnostics" begin + @testset "a full-rank spectrum renders headless, exactly as given ($T)" for T in (Float64, Float32) + # check 2: σᵢ = 10^(-i) for i = 1:10. `save` writes the PNG through Cairo, so no display + # is involved and none can be missing. + σ = T.(10.0 .^ (-(1:10))) + + fig = spectrumplot(σ) + # the values are drawn as given, never squared and never rooted: read them back in the + # precision the plot stores, which is where the comparison of `==` is made. + @test plotted_eltype(fig) == Float64 + @test plotted_y(fig) == convert(Vector{plotted_eltype(fig)}, σ) + @test plotted_y(fig) == σ + + mktempdir() do dir + path = joinpath(dir, "spectrum.png") + save(path, fig) + bytes = read(path) + @test bytes[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] + end + end + + @testset "a one-element spectrum renders" begin + @test plotted_y(spectrumplot(Float64[3.0])) == [3.0] + end + + @testset "an empty, a NaN and an Infinity spectrum are refused" begin + @test_throws ArgumentError spectrumplot(Float64[]) + @test_throws ArgumentError spectrumplot([1.0, NaN]) + @test_throws ArgumentError spectrumplot([1.0, Inf]) + @test_throws ArgumentError spectrumplot([1.0, -Inf]) + end + + @testset "a rank-deficient spectrum draws its zeros at the floor ($T)" for T in (Float64, Float32) + # check 3: the EVD route takes the eigenvalues of the Gram matrix XᵀX. A snapshot column + # that is exactly zero leaves exact zeros among them, and round-off leaves the neighbours + # of a dependent column slightly below zero. Both are lifted to `eps(T) * maximum(σ)`, so + # a log-scaled axis draws the whole spectrum and no value is silently dropped. + Random.seed!(11) + X0 = hcat(randn(T, 9, 3), zeros(T, 9, 2)) # two dead snapshot columns + Λ = reverse(eigen(Symmetric(X0' * X0)).values) + + # the precondition of the clause, asserted rather than assumed + @test count(iszero, Λ) == 2 + floor = eps(T) * maximum(Λ) + # every positive entry is far above the floor, so the two descriptions of the drawn series + # agree: `max` and the clamping of the non-positive entries alone + @test all(>(floor), Λ[Λ .> 0]) + + fig = spectrumplot(Λ) + @test plotted_eltype(fig) == Float64 + @test plotted_y(fig) == convert(Vector{plotted_eltype(fig)}, max.(Λ, floor)) + @test all(>(0), plotted_y(fig)) + end + + @testset "a slightly negative eigenvalue is drawn at the floor" begin + # the other face of the same round-off: the Gram route keeps the negative eigenvalues of a + # nearly dependent snapshot, and they must reach the axis rather than fall off it, while the + # positive entries are drawn exactly as they are given + Random.seed!(7) + X = randn(Float64, 12, 4) + Λ = reverse(eigen(Symmetric(hcat(X, X, X)' * hcat(X, X, X))).values) + @test any(<(0), Λ) + @test any(>(0), Λ) + + floor = eps(Float64) * maximum(Λ) + drawn = plotted_y(spectrumplot(Λ)) + @test drawn[Λ .> 0] == Λ[Λ .> 0] + @test drawn[Λ .≤ 0] == fill(floor, count(≤(0), Λ)) + @test all(>(0), drawn) + end + + @testset "the spectrum of a rank-deficient snapshot, as the Gram route stores it" begin + Random.seed!(7) + paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) + X = randn(Float64, 12, 4) + b = ReducedBasis(PODEVD(), hcat(X, X, X), NamedTuple(), paramspace; + k = 12) + + # the route stores the raw spectrum with its non-positive entries kept + Λ = b.Λ + @test any(≤(0), Λ) + @test issorted(Λ; rev = true) + + floor = eps(Float64) * maximum(Λ) + drawn = plotted_y(spectrumplot(Λ)) + @test drawn[Λ .> 0] == Λ[Λ .> 0] + @test drawn[Λ .≤ 0] == fill(floor, count(≤(0), Λ)) + end +end diff --git a/test/integration/load_without_makie.jl b/test/integration/load_without_makie.jl new file mode 100644 index 0000000..200f5e7 --- /dev/null +++ b/test/integration/load_without_makie.jl @@ -0,0 +1,54 @@ +using ReducedBasisMethods +using Test +using TOML + +# The package loads with no Makie present. `CairoMakie` is a weak dependency, so nothing about the +# ordinary load brings it in — which is what this file states from the inside of a fresh temporary +# project that holds only the package, run in a subprocess whose load path `@:@stdlib` cannot be +# satisfied out of the ambient `@v#.#` environment by a stray `using`. +@testset "Load without Makie" begin + root = pkgdir(ReducedBasisMethods) + project = TOML.parsefile(joinpath(root, "Project.toml")) + + # the stub is in the package and the method is not: no other dependency holds a plotter + @test !haskey(project["deps"], "Plots") + @test !haskey(project["deps"], "LaTeXStrings") + @test project["extensions"] == Dict{String, String}( + "ReducedBasisMethodsCairoMakieExt" => "CairoMakie") + @test haskey(project["weakdeps"], "CairoMakie") + @test !haskey(project["deps"], "CairoMakie") + # the generic-only skeleton of `skeleton.jl` holds the rest of the `[deps]` list + + environment = mktempdir() + code = """ + import Pkg + Pkg.activate($(repr(environment)); io = devnull) + Pkg.develop(path = $(repr(root)); io = devnull) + using ReducedBasisMethods + + dependencies = Pkg.dependencies() + uuids = keys(dependencies) + names = [dependencies[uuid].name for uuid in uuids] + println("MAKIE=", join(sort([name for name in names if occursin("Makie", name)]), ",")) + println("MAKIEWEAKDEP=", + Base.UUID("13f3f980-e62b-5c42-98c6-ff1f3baf88f0") in uuids) + println("EXTENSION=", Base.get_extension( + ReducedBasisMethods, :ReducedBasisMethodsCairoMakieExt) === nothing) + try + ReducedBasisMethods.spectrumplot(Float64[1.0, 0.5]) + println("STUB=no method missing") + catch error + println("STUB=", error isa MethodError) + end + """ + output = String(read(setenv( + `$(Base.julia_cmd()) --startup-file=no --color=no -e $code`, + "JULIA_LOAD_PATH" => "@:@stdlib"))) + reported = Dict(strip.(split(l, '='; limit = 2)) + for l in split(output, '\n') if '=' in l) + + @test reported["MAKIE"] == "" + @test reported["MAKIEWEAKDEP"] == "false" + @test reported["EXTENSION"] == "true" + @test reported["STUB"] == "true" +end diff --git a/test/quality/aqua.jl b/test/quality/aqua.jl index 3bfb97e..a2e2939 100644 --- a/test/quality/aqua.jl +++ b/test/quality/aqua.jl @@ -2,4 +2,21 @@ using Aqua using ReducedBasisMethods using Test -Aqua.test_all(ReducedBasisMethods) +# Package-level quality assurance: type piracy, method ambiguities, stale and duplicated +# dependencies, undefined exports, unbound type parameters, and a `[compat]` entry for every +# entry of `[deps]`, `[weakdeps]` and `[extras]`. These are the faults the rest of the suite is +# structurally unable to see — it exercises behaviour, and every one of them is a property of the +# package as a whole. +# +# `Aqua.test_all` is called inside a testset of its own on purpose. It adds no enclosing testset, so +# a bare call at file scope records none of the passes of the checks it runs and prints the same +# silence whichever of them goes red. +aqua = @testset "Aqua" begin + Aqua.test_all(ReducedBasisMethods) +end + +# The wrapper is not vacuous: every check `test_all` runs makes at least one `@test` of its own and +# carries it in a sub-testset, so this testset reports more than zero passed tests. Its summary +# covers `stale_deps` and `undefined_exports` among those passes, and a `CairoMakie` whose +# `[compat]` entry is missing is reported by `deps_compat` in the same summary. +@test Test.get_test_counts(aqua).cumulative_passes > 0 diff --git a/test/quality/docs.jl b/test/quality/docs.jl new file mode 100644 index 0000000..a4741b2 --- /dev/null +++ b/test/quality/docs.jl @@ -0,0 +1,86 @@ +using ReducedBasisMethods +using Test +using TOML + +# The words a generic package must not write: ReducedBasisMethods knows no model, so a physical +# word in its sources or its documentation is a physical example that does not belong to it (T7.15, +# section 3.6 of the plan). +const FORBIDDEN = r"vlasov|plasma|particle"i + +"Every file of the tree, recursively, in sorted order." +function walkfiles(dir) + isdir(dir) || return String[] + files = String[] + for (root, _, names) in walkdir(dir) + append!(files, joinpath.(root, names)) + end + return sort!(files) +end + +"Every file under `dir` whose text holds one of `FORBIDDEN`, as paths relative to `dir`." +function forbidden_files(dir) + return String[relpath(p, dir) + for p in walkfiles(dir) + if isfile(p) && occursin(FORBIDDEN, String(read(p)))] +end + +"How many `jldoctest` blocks the tree under `dir` holds." +function jldoctest_blocks(dir) + count(p -> occursin("```jldoctest", String(read(p))), walkfiles(dir)) +end + +@testset "Docs and changelog" begin + root = pkgdir(ReducedBasisMethods) + + @testset "no physical word in `src`, `docs/src` or the built docs" begin + # check 5, on the sources and the manual. `CHANGELOG.md` and `reference/` may name the + # model, and are left out on purpose. + for dir in ("src", "docs/src") + @test isempty(forbidden_files(joinpath(root, dir))) + end + # the built site is searched where it exists: `docs/build` is a build artefact, so a run + # that has built no docs searches nothing and the build itself is the evidence + build = joinpath(root, "docs", "build") + isdir(build) && @test isempty(forbidden_files(build)) + end + + @testset "the docs environment holds no Weave and no Plots" begin + # `docs/make.jl` no longer loads `Weave` and no page uses either, so the docs environment + # does not resolve them. + @test isempty(intersect(["Plots", "Weave"], + keys(TOML.parsefile(joinpath(root, "docs", "Project.toml"))["deps"]))) + make = read(joinpath(root, "docs", "make.jl"), String) + @test !occursin("Weave", make) + @test !occursin("Plots", make) + @test occursin("doctest = true", make) + end + + @testset "at least one `jldoctest` in the package" begin + # A build with zero doctests passes vacuously, so it is the count that is asserted. + n = jldoctest_blocks(joinpath(root, "src")) + + jldoctest_blocks(joinpath(root, "docs", "src")) + @test n ≥ 1 + end + + @testset "the changelog names the extension and the function" begin + # check 6: one entry under the unreleased heading. `[Unreleased]` holds the entries of the + # parts that precede this one as well, so the section is cut at the next version heading. + changelog = read(joinpath(root, "CHANGELOG.md"), String) + head = findfirst("## [Unreleased]", changelog) + @test head !== nothing + rest = SubString(changelog, nextind(changelog, last(head))) + tail = findfirst(r"^## \["m, rest) + unreleased = changelog[first(head):(tail === nothing ? lastindex(changelog) : + last(head) + first(tail) - 1)] + @test occursin("ReducedBasisMethodsCairoMakieExt", unreleased) + @test occursin("spectrumplot", unreleased) + end + + @testset "the test environment takes `CairoMakie` from the package alone" begin + # `CairoMakie` is a weak dependency, so the test environment states its own entry and takes + # the bound from the package. + test_project = TOML.parsefile(joinpath(root, "test", "Project.toml")) + @test haskey(test_project["deps"], "CairoMakie") + @test !haskey(get(test_project, "compat", Dict{String, Any}()), "CairoMakie") + end +end diff --git a/test/quality/doctests.jl b/test/quality/doctests.jl new file mode 100644 index 0000000..da817a1 --- /dev/null +++ b/test/quality/doctests.jl @@ -0,0 +1,16 @@ +using ReducedBasisMethods +using Documenter: DocMeta, doctest +using Test + +# The doctests of this package, in its docstrings and in the manual under `docs/src`, as the +# `Doctests` job of `CI.yml` runs them: `manual = false`, because the manual pages carry no +# `jldoctest` of their own and their `@meta` block is evaluated in `Main`. +# +# Documenter evaluates a page's `@meta` block in `Main`, and a `@safetestset` file runs in a module +# of its own, so `ReducedBasisMethods` is imported into `Main` first. +@eval Main import ReducedBasisMethods + +DocMeta.setdocmeta!(ReducedBasisMethods, :DocTestSetup, + :(using ReducedBasisMethods); recursive = true) + +doctest(ReducedBasisMethods; manual = false) diff --git a/test/runtests.jl b/test/runtests.jl index fdbc3f8..94989df 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,6 +4,7 @@ const GROUPS = isempty(ARGS) ? ["core", "slow"] : ARGS if "core" in GROUPS @safetestset "Aqua" include("quality/aqua.jl") + @safetestset "Docs and changelog" include("quality/docs.jl") @safetestset "Generic-only skeleton" include("integration/skeleton.jl") @safetestset "Reference spectra script" include("integration/reference_spectra.jl") @safetestset "Reduced basis" include("reducedbasis.jl") @@ -16,3 +17,10 @@ if "core" in GROUPS @safetestset "Symplectic DEIM" include("algorithms/symplectic_deim.jl") @safetestset "Reduced equations" include("reduced_equations.jl") end +if "slow" in GROUPS + @safetestset "Load without Makie" include("integration/load_without_makie.jl") + @safetestset "Diagnostics" include("diagnostics.jl") +end +if "doctests" in GROUPS + @safetestset "Doctests" include("quality/doctests.jl") +end From f960bd318650f96722ff4da2fa21f0286d9540fd Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 10:01:50 +0200 Subject: [PATCH 03/12] Add the first jldoctest, and drop Weave and Plots from the docs get_DEIM_interpolation_matrix is a generic function and its integer basis case is exact, so its docstring now carries a jldoctest: a build with zero doctests passes vacuously, and nothing else in the package asserts that the rendered output of a docstring is what Documenter will show. docs/make.jl loads Weave and docs/Project.toml resolves it, although no page uses either. Both go, and the docs build sets doctest = true so the failure is reported by the build rather than by a warning inside it. --- docs/Project.toml | 4 ---- docs/make.jl | 8 ++------ src/algorithms/deim.jl | 13 +++++++++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 14d3885..a73d4fd 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,10 +1,6 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" ReducedBasisMethods = "91da5cd7-58ae-4d67-87db-f03d1c8da121" -Weave = "44d3d7a6-8a23-5bf8-98c5-b353f8df5ec9" [compat] Documenter = "1.19.0" -Plots = "1.41.7" -Weave = "0.10.12" diff --git a/docs/make.jl b/docs/make.jl index 5a7a20f..9fdd740 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,10 +1,5 @@ using ReducedBasisMethods using Documenter -using Weave - -# weave("src/poisson.jmd", -# out_path = "src", -# doctype = "github") makedocs(; modules = [ReducedBasisMethods], @@ -19,7 +14,8 @@ makedocs(; pages = [ "Home" => "index.md", "Library" => "library.md" - ] + ], + doctest = true ) deploydocs(; diff --git a/src/algorithms/deim.jl b/src/algorithms/deim.jl index f7c58a5..e07e20e 100644 --- a/src/algorithms/deim.jl +++ b/src/algorithms/deim.jl @@ -62,6 +62,19 @@ Returns the `N × k` matrix `Π` whose column `i` is the unit vector of the row Its element type is `float(eltype(Ψ))`, so a `Float32` basis gives a `Float32` interpolation matrix. The selection, and with it the `ArgumentError` for `k > N` and for a column whose largest residual magnitude is not finite and positive, is [`_deim_indices`](@ref)'s. + +The output is exact for integer input: a basis whose columns are exact combinations of the ones +before them gives interpolation rows that are 0 or 1, so no round-off is involved and the test +reads the matrix rather than approximating it. + +```jldoctest +julia> using ReducedBasisMethods + +julia> get_DEIM_interpolation_matrix([1 1; 2 -1]) +2×2 Matrix{Float64}: + 0.0 1.0 + 1.0 0.0 +``` """ function get_DEIM_interpolation_matrix(Ψ::AbstractMatrix) N, k = size(Ψ) From a2047dfaaaf2d5f27e51e2360e7c67de09f5cfb1 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 10:02:15 +0200 Subject: [PATCH 04/12] Record the extension and the docs change under [Unreleased] The entry names the extension and the function, and corrects the two bullets this branch makes false: the docs environment no longer resolves Weave or Plots, and the Aqua call is wrapped rather than bare. No earlier bullet is touched. --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dfb2f8..ef58c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,29 @@ written. `GeometricIntegratorsBase` do not join `[deps]`: an integrator is the caller's, and the test dependencies supply one. +- **`spectrumplot` plots a reduced basis's spectrum, and `CairoMakie` joins `[weakdeps]` at + `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title, resolution)` draws a spectrum against + its index on a log-scaled axis and returns a `Makie.Figure`. It is the singular-value or + eigenvalue spectrum a `ReducedBasis` stores in `b.Λ`, so it draws the values as they are given, + never squared and never rooted: an eigenvalue spectrum from a Gram matrix `MᵀM` and a + singular-value spectrum of `M` differ by a square, and reading one as the other flatters the + decay by a factor of two. A value at or below zero — an exact zero of a rank-deficient snapshot, + or the slightly negative eigenvalue the Gram route returns — is lifted to the floor + `eps(T) * maximum(σ)`, taken in the spectrum's own precision, so a log-scaled axis draws the + whole spectrum. An empty spectrum, and one holding a `NaN` or an `Inf`, throws an + `ArgumentError`. The method lives in the package extension + `ReducedBasisMethodsCairoMakieExt`: `src/diagnostics.jl` holds the stub, so + `using ReducedBasisMethods` needs no plotting stack at all and `spectrumplot` raises the usual + `MethodError` until `CairoMakie` is loaded. + + `Aqua.test_all` now runs inside a `@testset` of its own, which is what makes the passes of its + checks visible in the suite's summary; `stale_deps`, `undefined_exports` and `deps_compat` — the + last of which now also holds `CairoMakie`'s `[compat]` entry — are among them. + + The docs build sets `doctest = true`, `get_DEIM_interpolation_matrix` carries the package's + first `jldoctest`, and `Weave` and `Plots` leave both `docs/make.jl` and `docs/Project.toml`: no + page used either. + ## [0.2.0] — 2026-10-12 ### New Features From 0518ad5863154617debb3827d8ea44425e1c1feb Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 12:57:11 +0200 Subject: [PATCH 05/12] Draw the spectrum at the Float64 floor and return a FigureAxisPlot The plot stores Float64, so the spectrum is widened to Float64 and the floor eps(Float64) * maximum(sigma) is taken in that precision: a Float32 spectrum is widened by an exact widening, and max lifts a rank-deficient spectrum's exact zeros and the Gram route's slightly negative eigenvalues onto the floor, which keeps the drawn order as well. A spectrum with no positive value is refused with an ArgumentError, because a log-scaled axis has no scale to draw it on, and the method returns a FigureAxisPlot so a test reads the plotted data. --- ext/ReducedBasisMethodsCairoMakieExt.jl | 20 ++-- src/diagnostics.jl | 21 ++-- test/diagnostics.jl | 136 ++++++++++++++++-------- 3 files changed, 117 insertions(+), 60 deletions(-) diff --git a/ext/ReducedBasisMethodsCairoMakieExt.jl b/ext/ReducedBasisMethodsCairoMakieExt.jl index 5692de4..3adb427 100644 --- a/ext/ReducedBasisMethodsCairoMakieExt.jl +++ b/ext/ReducedBasisMethodsCairoMakieExt.jl @@ -8,21 +8,23 @@ function ReducedBasisMethods.spectrumplot(σ::AbstractVector{<:Real}; isempty(σ) && throw(ArgumentError("no spectrum to plot")) all(isfinite, σ) || throw(ArgumentError( "the spectrum holds a NaN or an Inf, which a log-scaled axis cannot draw")) + any(>(0), σ) || throw(ArgumentError( + "the spectrum holds no positive value, so a log-scaled axis has no scale to draw it on")) - # The spectrum is drawn in its own precision, at its own floor: a `Float32` spectrum is not - # widened to `Float64` just to be drawn. - s = convert(Vector{float(eltype(σ))}, σ) + # The spectrum is drawn in the precision the plot stores, which is `Float64`: a `Float32` + # spectrum is widened to it, and that widening is exact. + # # A rank-deficient snapshot set gives a Gram matrix with exact zeros among its eigenvalues, and - # a nearly dependent one gives round-off eigenvalues slightly below zero. Both are lifted to - # the floor rather than drawn below it, and every positive value is drawn as it is given. - floor = eps(eltype(s)) * maximum(s) - y = [σᵢ ≤ 0 ? floor : σᵢ for σᵢ in s] + # a nearly dependent one gives round-off eigenvalues slightly below zero. `max` lifts every + # value at or below the floor onto it, which is what puts the whole spectrum on the axis + # instead of dropping the round-off tail off it. + y = max.(convert(Vector{Float64}, σ), eps(Float64) * Float64(maximum(σ))) fig = Figure(size = resolution === nothing ? (620, 380) : resolution) ax = Axis(fig[1, 1]; xlabel = "i", ylabel = "σᵢ", yscale = log10) isempty(title) || Label(fig[0, 1], title; halign = :left, fontsize = 16) - lines!(ax, 1:length(y), y) - return fig + plt = lines!(ax, 1:length(y), y) + return CairoMakie.Makie.FigureAxisPlot(fig, ax, plt) end end diff --git a/src/diagnostics.jl b/src/diagnostics.jl index 1935e0f..e4b3da8 100644 --- a/src/diagnostics.jl +++ b/src/diagnostics.jl @@ -10,20 +10,25 @@ from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a squa one as the other flatters the decay by a factor of two. A caller that holds the eigenvalues plots them as they are. -The axis is log-scaled, so a value at or below zero — an exact zero of a rank-deficient spectrum, -or the slightly negative eigenvalue the Gram route can return — is lifted to the floor -`eps(T) * maximum(σ)`, taken in the spectrum's own precision, instead of falling off the axis. -Every positive value is drawn as it is given, never rounded and never lifted: a `Float32` value is -widened to the `Float64` the plot stores, which is exact. +The axis is log-scaled, so the spectrum is lifted onto the floor `eps(Float64) * maximum(σ)` +before it is drawn: a rank-deficient snapshot set leaves exact zeros among the eigenvalues of its +Gram matrix, and a nearly dependent one leaves values slightly below zero, and both belong on the +axis rather than below it. The floor is taken in `Float64`, the precision the plot stores, and the +whole spectrum is drawn in that precision, so a `Float32` spectrum is widened by an exact +widening. -An empty `σ` throws an `ArgumentError`, and so does a spectrum holding a `NaN` or an `Inf`, -which a log-scaled axis cannot draw. +An empty `σ` throws an `ArgumentError`, and so does a spectrum holding a `NaN` or an `Inf` and one +holding no positive value at all, which leaves a log-scaled axis no scale to draw on. + +Returns a `Makie.FigureAxisPlot`, whose `plot` field holds the plotted series, and +`save(path, spectrumplot(σ))` writes it to a PNG. Defined in a package extension: load `CairoMakie` to make it available. ```julia using ReducedBasisMethods, CairoMakie -fig = spectrumplot(b.Λ) # b.Λ is the full stored spectrum +fap = spectrumplot(b.Λ) # b.Λ is the full stored spectrum +save("spectrum.png", fap) ``` """ function spectrumplot end diff --git a/test/diagnostics.jl b/test/diagnostics.jl index be075a5..b23455c 100644 --- a/test/diagnostics.jl +++ b/test/diagnostics.jl @@ -5,72 +5,91 @@ using ReducedBasisMethods using ReducedComplexityModeling: Parameter, ParameterSpace using Test -""" -The single ``Lines`` series of a figure's axis. `CairoMakie` is loaded here, and only here, so this -file tests the extension and not the package's own load path. -""" -function series(fig) - only(filter(p -> p isa CairoMakie.Makie.Lines, - only(filter(a -> a isa CairoMakie.Makie.Axis, fig.content)).scene.plots)) +"`spectrumplot` with `DISPLAY` unset: the render needs no display, so the test states that instead +of inheriting whatever the runner happens to set." +render(σ) = withenv("DISPLAY" => nothing) do + spectrumplot(σ) end -"`Lines` stores its converted positional argument as one vector of points, so the element type of -those points is the precision the plot holds, and the comparison of check 2 is made in it." -plotted_y(fig) = [q[2] for q in series(fig).positions[]] -plotted_eltype(fig) = eltype(eltype(series(fig).positions[])) +"The PNG bytes that `save` writes for a figure, also with `DISPLAY` unset." +function save_png(fap) + return withenv("DISPLAY" => nothing) do + path = joinpath(mktempdir(), "spectrum.png") + save(path, fap) + return read(path) + end +end + +"`FigureAxisPlot` keeps its series in its `plot` field, and `Lines` keeps its converted positional +argument as one vector of points: the element type of those points is the precision the plot holds, +and that is where the comparison of check 2 is made." +plotted_y(fap) = [q[2] for q in fap.plot.positions[]] +plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @testset "Diagnostics" begin + @testset "the plot is a `FigureAxisPlot`" begin + @test render([1.0, 0.1]) isa CairoMakie.Makie.FigureAxisPlot + end + @testset "a full-rank spectrum renders headless, exactly as given ($T)" for T in (Float64, Float32) # check 2: σᵢ = 10^(-i) for i = 1:10. `save` writes the PNG through Cairo, so no display # is involved and none can be missing. σ = T.(10.0 .^ (-(1:10))) - fig = spectrumplot(σ) + fap = render(σ) # the values are drawn as given, never squared and never rooted: read them back in the # precision the plot stores, which is where the comparison of `==` is made. - @test plotted_eltype(fig) == Float64 - @test plotted_y(fig) == convert(Vector{plotted_eltype(fig)}, σ) - @test plotted_y(fig) == σ - - mktempdir() do dir - path = joinpath(dir, "spectrum.png") - save(path, fig) - bytes = read(path) - @test bytes[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] - end + @test plotted_eltype(fap) == Float64 + @test plotted_y(fap) == convert(Vector{plotted_eltype(fap)}, σ) + @test plotted_y(fap) == σ + # the floor is `eps(Float64) * maximum(σ)`, and the smallest of these values sits far + # above it, so the floor lifts none of them and the comparison is an exact one + @test all(>(eps(Float64) * Float64(maximum(σ))), σ) + + @test save_png(fap)[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] end @testset "a one-element spectrum renders" begin - @test plotted_y(spectrumplot(Float64[3.0])) == [3.0] + @test plotted_y(render(Float64[3.0])) == [3.0] end - @testset "an empty, a NaN and an Infinity spectrum are refused" begin + @testset "an empty, a NaN, an Infinity and a non-positive spectrum are refused" begin @test_throws ArgumentError spectrumplot(Float64[]) @test_throws ArgumentError spectrumplot([1.0, NaN]) @test_throws ArgumentError spectrumplot([1.0, Inf]) @test_throws ArgumentError spectrumplot([1.0, -Inf]) + # no positive value at all, so a log-scaled axis has no scale to draw the spectrum on + @test_throws ArgumentError spectrumplot(zeros(3)) + @test_throws ArgumentError spectrumplot(Float32[0, 0]) + @test_throws ArgumentError spectrumplot([-1.0]) + @test_throws ArgumentError spectrumplot([0.0, -1e-17]) + # the zero snapshot set, whose eigenvalues the Gram route stores as exact zeros + paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) + Λ = ReducedBasis(PODEVD(), zeros(12, 4), NamedTuple(), paramspace; k = 1).Λ + @test all(iszero, Λ) + @test_throws ArgumentError spectrumplot(Λ) end @testset "a rank-deficient spectrum draws its zeros at the floor ($T)" for T in (Float64, Float32) # check 3: the EVD route takes the eigenvalues of the Gram matrix XᵀX. A snapshot column # that is exactly zero leaves exact zeros among them, and round-off leaves the neighbours - # of a dependent column slightly below zero. Both are lifted to `eps(T) * maximum(σ)`, so - # a log-scaled axis draws the whole spectrum and no value is silently dropped. + # of a dependent column slightly below zero. Both are lifted onto the floor + # `eps(Float64) * maximum(σ)`, so a log-scaled axis draws the whole spectrum and no value + # is silently dropped. Random.seed!(11) X0 = hcat(randn(T, 9, 3), zeros(T, 9, 2)) # two dead snapshot columns Λ = reverse(eigen(Symmetric(X0' * X0)).values) # the precondition of the clause, asserted rather than assumed @test count(iszero, Λ) == 2 - floor = eps(T) * maximum(Λ) - # every positive entry is far above the floor, so the two descriptions of the drawn series - # agree: `max` and the clamping of the non-positive entries alone - @test all(>(floor), Λ[Λ .> 0]) - - fig = spectrumplot(Λ) - @test plotted_eltype(fig) == Float64 - @test plotted_y(fig) == convert(Vector{plotted_eltype(fig)}, max.(Λ, floor)) - @test all(>(0), plotted_y(fig)) + + floor = eps(Float64) * Float64(maximum(Λ)) + fap = render(Λ) + drawn = plotted_y(fap) + @test plotted_eltype(fap) == Float64 + @test drawn == max.(Λ, floor) + @test issorted(drawn; rev = true) + @test all(>(0), drawn) end @testset "a slightly negative eigenvalue is drawn at the floor" begin @@ -83,13 +102,40 @@ plotted_eltype(fig) = eltype(eltype(series(fig).positions[])) @test any(<(0), Λ) @test any(>(0), Λ) - floor = eps(Float64) * maximum(Λ) - drawn = plotted_y(spectrumplot(Λ)) - @test drawn[Λ .> 0] == Λ[Λ .> 0] - @test drawn[Λ .≤ 0] == fill(floor, count(≤(0), Λ)) + # the floor lifts a positive round-off value along with the non-positive ones, and the + # drawn series stays in the order the spectrum is stored in + floor = eps(Float64) * Float64(maximum(Λ)) + @test any(x -> 0 < x < floor, Λ) + + drawn = plotted_y(render(Λ)) + @test drawn == max.(Λ, floor) + @test issorted(drawn; rev = true) @test all(>(0), drawn) end + @testset "the same round-off in a Float32 Gram spectrum" begin + # the floor is taken in the precision the plot stores, so a `Float32` spectrum is drawn in + # `Float64` and its non-positive eigenvalues are lifted onto that floor as well + Random.seed!(7) + X = randn(Float32, 12, 4) + Λ = reverse(eigen(Symmetric(hcat(X, X, X)' * hcat(X, X, X))).values) + @test any(<(0), Λ) + @test any(>(0), Λ) + + drawn = plotted_y(render(Λ)) + @test drawn == max.(Float64.(Λ), eps(Float64) * maximum(Λ)) + @test issorted(drawn; rev = true) + @test all(>(0), drawn) + end + + @testset "a spectrum finer than the plot stores is drawn in `Float64`" begin + # the plot stores `Float64`, and a spectrum finer than that is drawn in it: it is that + # widening which makes the comparisons above plain `Vector{Float64}` comparisons + fap = render(big.([1.0, 0.5, 0.25])) + @test plotted_eltype(fap) == Float64 + @test plotted_y(fap) == [1.0, 0.5, 0.25] + end + @testset "the spectrum of a rank-deficient snapshot, as the Gram route stores it" begin Random.seed!(7) paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) @@ -102,9 +148,13 @@ plotted_eltype(fig) = eltype(eltype(series(fig).positions[])) @test any(≤(0), Λ) @test issorted(Λ; rev = true) - floor = eps(Float64) * maximum(Λ) - drawn = plotted_y(spectrumplot(Λ)) - @test drawn[Λ .> 0] == Λ[Λ .> 0] - @test drawn[Λ .≤ 0] == fill(floor, count(≤(0), Λ)) + # the same data through the EVD route: the floor lifts its round-off tail, which holds a + # positive value below the floor as well + floor = eps(Float64) * Float64(maximum(Λ)) + @test any(x -> 0 < x < floor, Λ) + + drawn = plotted_y(render(Λ)) + @test drawn == max.(Λ, floor) + @test issorted(drawn; rev = true) end end From 2d3a14aa9cbb371062b9c043ae9a0a30ee6107b2 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 12:57:27 +0200 Subject: [PATCH 06/12] Grep the built docs, in the build and in the sources The check-5 grep of docs/build could not fail: it ran only where a build already existed, so a word that reached the site alone - through sitename, say - was invisible to a fresh clone and to every CI test job, and where a stale build existed the test searched that instead of the tree under test. The build now greps the site it has just written and errors on a match, so the Documentation workflow is red rather than silent, and the test greps the sources together with docs/make.jl, where such a word has to be written. The words and the search live in one file with two callers, as doctestsetup.jl does. --- docs/grep_physical_words.jl | 22 ++++++++++++++++ docs/make.jl | 10 ++++++++ test/quality/docs.jl | 51 ++++++++++++++----------------------- 3 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 docs/grep_physical_words.jl diff --git a/docs/grep_physical_words.jl b/docs/grep_physical_words.jl new file mode 100644 index 0000000..862fcdc --- /dev/null +++ b/docs/grep_physical_words.jl @@ -0,0 +1,22 @@ +# The words a generic package must not write: ReducedBasisMethods knows no model, so a physical +# word in its sources or its documentation is a physical example that does not belong to it (T7.15, +# section 3.6 of the plan). One definition, two callers: `docs/make.jl` greps the site it has built, +# and `test/quality/docs.jl` greps the sources, so a word fails the Documentation workflow and the +# suite alike. +const PHYSICAL_WORDS = r"vlasov|plasma|particle"i + +""" + forbidden_files(path) + +Every file under `path` whose text holds one of `PHYSICAL_WORDS`, as paths relative to `path`. A +directory is walked recursively; a file is taken as it is. The words are pure ASCII, and reading a +file as a `String` does not check its encoding, so a binary of the built site is searched too. +""" +function forbidden_files(path) + files = isdir(path) ? + sort!([joinpath(root, name) for (root, _, names) in walkdir(path) + for name in names]) : + String[path] + return [relpath(file, path) + for file in files if occursin(PHYSICAL_WORDS, read(file, String))] +end diff --git a/docs/make.jl b/docs/make.jl index 9fdd740..d509ca2 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,6 +1,8 @@ using ReducedBasisMethods using Documenter +include("grep_physical_words.jl") + makedocs(; modules = [ReducedBasisMethods], authors = "Tobias M. Blickhan, Michael Kraus, Tomasz M. Tyranoski", @@ -18,6 +20,14 @@ makedocs(; doctest = true ) +# The sources, the manual and the build's own `sitename`, `authors` and page titles carry no +# physical word, so one in the site was added by the build itself: it fails here rather than +# publishing a page that names a model. `test/quality/docs.jl` greps the sources with the same +# words. +forbidden = forbidden_files(joinpath(@__DIR__, "build")) +isempty(forbidden) || + error("the built documentation names a model: ", join(forbidden, ", ")) + deploydocs(; repo = "github.com/JuliaRCM/ReducedBasisMethods.jl", devurl = "latest", diff --git a/test/quality/docs.jl b/test/quality/docs.jl index a4741b2..143efa3 100644 --- a/test/quality/docs.jl +++ b/test/quality/docs.jl @@ -2,46 +2,34 @@ using ReducedBasisMethods using Test using TOML -# The words a generic package must not write: ReducedBasisMethods knows no model, so a physical -# word in its sources or its documentation is a physical example that does not belong to it (T7.15, -# section 3.6 of the plan). -const FORBIDDEN = r"vlasov|plasma|particle"i - -"Every file of the tree, recursively, in sorted order." -function walkfiles(dir) - isdir(dir) || return String[] - files = String[] - for (root, _, names) in walkdir(dir) - append!(files, joinpath.(root, names)) - end - return sort!(files) -end - -"Every file under `dir` whose text holds one of `FORBIDDEN`, as paths relative to `dir`." -function forbidden_files(dir) - return String[relpath(p, dir) - for p in walkfiles(dir) - if isfile(p) && occursin(FORBIDDEN, String(read(p)))] -end +# The words a generic package must not write, and the search over a tree that holds them, are +# defined once for the package and used twice: `docs/make.jl` greps the site it builds, this file +# greps the sources. Including it here keeps the two from drifting apart. +include(joinpath(pkgdir(ReducedBasisMethods), "docs", "grep_physical_words.jl")) "How many `jldoctest` blocks the tree under `dir` holds." function jldoctest_blocks(dir) - count(p -> occursin("```jldoctest", String(read(p))), walkfiles(dir)) + count(file -> occursin("```jldoctest", read(file, String)), + [joinpath(root, name) for (root, _, names) in walkdir(dir) for name in names]) end @testset "Docs and changelog" begin root = pkgdir(ReducedBasisMethods) + make = read(joinpath(root, "docs", "make.jl"), String) - @testset "no physical word in `src`, `docs/src` or the built docs" begin - # check 5, on the sources and the manual. `CHANGELOG.md` and `reference/` may name the - # model, and are left out on purpose. - for dir in ("src", "docs/src") - @test isempty(forbidden_files(joinpath(root, dir))) + @testset "no physical word in the sources or the build's configuration" begin + # check 5, on the sources, the manual and the build's own configuration: `sitename`, + # `authors` and the page titles are the words that reach the site and appear nowhere else, + # and `docs/make.jl` is where they are written. `CHANGELOG.md` and `reference/` may name + # the model, and are left out on purpose. + for path in ("src", "docs/src", "docs/make.jl") + @test isempty(forbidden_files(joinpath(root, path))) end - # the built site is searched where it exists: `docs/build` is a build artefact, so a run - # that has built no docs searches nothing and the build itself is the evidence - build = joinpath(root, "docs", "build") - isdir(build) && @test isempty(forbidden_files(build)) + # What the build itself renders is grepped by the build, once the site exists: the grep of + # its `build` runs after `makedocs` and fails the Documentation workflow, so the assertion + # is that the build holds that grep rather than a repeat of it here. + @test occursin(r"""forbidden_files\(joinpath\(@__DIR__, "build"\)\)""", make) + @test occursin(r"""isempty\(forbidden\)\s*\|\|\s*error\(""", make) end @testset "the docs environment holds no Weave and no Plots" begin @@ -49,7 +37,6 @@ end # does not resolve them. @test isempty(intersect(["Plots", "Weave"], keys(TOML.parsefile(joinpath(root, "docs", "Project.toml"))["deps"]))) - make = read(joinpath(root, "docs", "make.jl"), String) @test !occursin("Weave", make) @test !occursin("Plots", make) @test occursin("doctest = true", make) From 3d6c98df8c6ab75cd659701e932f0008cde0dd5c Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 12:58:26 +0200 Subject: [PATCH 07/12] Correct the DEIM docstring, the Aqua comment, the load path and the changelog get_DEIM_interpolation_matrix refuses a basis whose columns are exact combinations of the ones before them - its residual is zero, which is the ArgumentError _deim_indices throws - so the docstring no longer describes such an input as one it takes, and it no longer speaks of the test. A bare test_all does record its passes, so the wrapper's reason is the clause's own requirement rather than a claim the base contradicted. The load-without-Makie subprocess adds JULIA_LOAD_PATH to the environment it inherits, where the clause asks for that variable alone, instead of replacing the environment with it. The changelog states the decided rule, the return type, the refusal and the Aqua wrapper as they are. --- CHANGELOG.md | 22 +++++++++++++--------- src/algorithms/deim.jl | 7 ++++--- test/integration/load_without_makie.jl | 2 +- test/quality/aqua.jl | 5 ++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef58c1a..b7ee6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,26 +43,30 @@ written. - **`spectrumplot` plots a reduced basis's spectrum, and `CairoMakie` joins `[weakdeps]` at `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title, resolution)` draws a spectrum against - its index on a log-scaled axis and returns a `Makie.Figure`. It is the singular-value or + its index on a log-scaled axis and returns a `Makie.FigureAxisPlot`, whose `plot` field holds the + series, and `save(path, spectrumplot(σ))` writes it to a file. It is the singular-value or eigenvalue spectrum a `ReducedBasis` stores in `b.Λ`, so it draws the values as they are given, never squared and never rooted: an eigenvalue spectrum from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a square, and reading one as the other flatters the - decay by a factor of two. A value at or below zero — an exact zero of a rank-deficient snapshot, - or the slightly negative eigenvalue the Gram route returns — is lifted to the floor - `eps(T) * maximum(σ)`, taken in the spectrum's own precision, so a log-scaled axis draws the - whole spectrum. An empty spectrum, and one holding a `NaN` or an `Inf`, throws an + decay by a factor of two. The spectrum is drawn in `Float64`, the precision the plot stores, and + is lifted onto the floor `eps(Float64) * maximum(σ)` first: an exact zero of a rank-deficient + snapshot, and the slightly negative eigenvalue the Gram route returns, belong on the axis rather + than below it. An empty spectrum, one holding a `NaN` or an `Inf`, and one holding no positive + value at all — which leaves a log-scaled axis no scale to draw on — each throw an `ArgumentError`. The method lives in the package extension `ReducedBasisMethodsCairoMakieExt`: `src/diagnostics.jl` holds the stub, so `using ReducedBasisMethods` needs no plotting stack at all and `spectrumplot` raises the usual `MethodError` until `CairoMakie` is loaded. - `Aqua.test_all` now runs inside a `@testset` of its own, which is what makes the passes of its - checks visible in the suite's summary; `stale_deps`, `undefined_exports` and `deps_compat` — the - last of which now also holds `CairoMakie`'s `[compat]` entry — are among them. + `Aqua.test_all` now runs inside a `@testset` of its own, as check 4 asks, so each check carries + its own passes and failures in the suite's summary; `stale_deps`, `undefined_exports` and + `deps_compat` — the last of which now also holds `CairoMakie`'s `[compat]` entry — are among them. The docs build sets `doctest = true`, `get_DEIM_interpolation_matrix` carries the package's first `jldoctest`, and `Weave` and `Plots` leave both `docs/make.jl` and `docs/Project.toml`: no - page used either. + page used either. The build also greps its own output for a physical word and fails on a match, + so a name that reaches the site alone is red in the Documentation workflow rather than + published. ## [0.2.0] — 2026-10-12 diff --git a/src/algorithms/deim.jl b/src/algorithms/deim.jl index e07e20e..11db909 100644 --- a/src/algorithms/deim.jl +++ b/src/algorithms/deim.jl @@ -63,9 +63,10 @@ Its element type is `float(eltype(Ψ))`, so a `Float32` basis gives a `Float32` matrix. The selection, and with it the `ArgumentError` for `k > N` and for a column whose largest residual magnitude is not finite and positive, is [`_deim_indices`](@ref)'s. -The output is exact for integer input: a basis whose columns are exact combinations of the ones -before them gives interpolation rows that are 0 or 1, so no round-off is involved and the test -reads the matrix rather than approximating it. +Each column of `Π` is a unit vector, so the output is exact whatever the input: the `jldoctest` +below prints the matrix itself rather than a rounded form of it. A column of `Ψ` that is an exact +combination of the ones before it is refused instead — its residual is zero, which is the +`ArgumentError` the note above names. ```jldoctest julia> using ReducedBasisMethods diff --git a/test/integration/load_without_makie.jl b/test/integration/load_without_makie.jl index 200f5e7..73614ca 100644 --- a/test/integration/load_without_makie.jl +++ b/test/integration/load_without_makie.jl @@ -41,7 +41,7 @@ using TOML println("STUB=", error isa MethodError) end """ - output = String(read(setenv( + output = String(read(addenv( `$(Base.julia_cmd()) --startup-file=no --color=no -e $code`, "JULIA_LOAD_PATH" => "@:@stdlib"))) reported = Dict(strip.(split(l, '='; limit = 2)) diff --git a/test/quality/aqua.jl b/test/quality/aqua.jl index a2e2939..fe3e120 100644 --- a/test/quality/aqua.jl +++ b/test/quality/aqua.jl @@ -8,9 +8,8 @@ using Test # structurally unable to see — it exercises behaviour, and every one of them is a property of the # package as a whole. # -# `Aqua.test_all` is called inside a testset of its own on purpose. It adds no enclosing testset, so -# a bare call at file scope records none of the passes of the checks it runs and prints the same -# silence whichever of them goes red. +# Check 4 asks for the call inside a `@testset`, so each check's own passes and failures carry a +# name and a line in the suite's summary instead of one anonymous failure in the run's output. aqua = @testset "Aqua" begin Aqua.test_all(ReducedBasisMethods) end From 39fe7535735ed504f9ac91a7fda4bf5f315f4c8d Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 19:06:10 +0200 Subject: [PATCH 08/12] Correct four test comments the pre-PR verifier found inaccurate --- test/diagnostics.jl | 8 ++++---- test/quality/aqua.jl | 16 ++++------------ test/quality/docs.jl | 9 ++++----- test/quality/doctests.jl | 9 +++------ 4 files changed, 15 insertions(+), 27 deletions(-) diff --git a/test/diagnostics.jl b/test/diagnostics.jl index b23455c..66a795f 100644 --- a/test/diagnostics.jl +++ b/test/diagnostics.jl @@ -21,8 +21,8 @@ function save_png(fap) end "`FigureAxisPlot` keeps its series in its `plot` field, and `Lines` keeps its converted positional -argument as one vector of points: the element type of those points is the precision the plot holds, -and that is where the comparison of check 2 is made." +argument as one vector of points: these helpers expose the values and their storage precision +for exact comparison." plotted_y(fap) = [q[2] for q in fap.plot.positions[]] plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @@ -129,8 +129,8 @@ plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) end @testset "a spectrum finer than the plot stores is drawn in `Float64`" begin - # the plot stores `Float64`, and a spectrum finer than that is drawn in it: it is that - # widening which makes the comparisons above plain `Vector{Float64}` comparisons + # The BigFloat inputs are converted to the plot's Float64 storage before comparison; + # these dyadic values remain exactly representable. fap = render(big.([1.0, 0.5, 0.25])) @test plotted_eltype(fap) == Float64 @test plotted_y(fap) == [1.0, 0.5, 0.25] diff --git a/test/quality/aqua.jl b/test/quality/aqua.jl index fe3e120..6c596e3 100644 --- a/test/quality/aqua.jl +++ b/test/quality/aqua.jl @@ -2,20 +2,12 @@ using Aqua using ReducedBasisMethods using Test -# Package-level quality assurance: type piracy, method ambiguities, stale and duplicated -# dependencies, undefined exports, unbound type parameters, and a `[compat]` entry for every -# entry of `[deps]`, `[weakdeps]` and `[extras]`. These are the faults the rest of the suite is -# structurally unable to see — it exercises behaviour, and every one of them is a property of the -# package as a whole. -# -# Check 4 asks for the call inside a `@testset`, so each check's own passes and failures carry a -# name and a line in the suite's summary instead of one anonymous failure in the run's output. +# Aqua checks package-wide methods, exports, dependency usage and compatibility. +# Its checks use their own testsets; this wrapper groups their results under one +# suite heading. aqua = @testset "Aqua" begin Aqua.test_all(ReducedBasisMethods) end -# The wrapper is not vacuous: every check `test_all` runs makes at least one `@test` of its own and -# carries it in a sub-testset, so this testset reports more than zero passed tests. Its summary -# covers `stale_deps` and `undefined_exports` among those passes, and a `CairoMakie` whose -# `[compat]` entry is missing is reported by `deps_compat` in the same summary. +# Require nonzero cumulative passes so a vacuous Aqua run fails. @test Test.get_test_counts(aqua).cumulative_passes > 0 diff --git a/test/quality/docs.jl b/test/quality/docs.jl index 143efa3..d37e681 100644 --- a/test/quality/docs.jl +++ b/test/quality/docs.jl @@ -7,7 +7,7 @@ using TOML # greps the sources. Including it here keeps the two from drifting apart. include(joinpath(pkgdir(ReducedBasisMethods), "docs", "grep_physical_words.jl")) -"How many `jldoctest` blocks the tree under `dir` holds." +"How many files under `dir` contain at least one `jldoctest` block." function jldoctest_blocks(dir) count(file -> occursin("```jldoctest", read(file, String)), [joinpath(root, name) for (root, _, names) in walkdir(dir) for name in names]) @@ -33,8 +33,8 @@ end end @testset "the docs environment holds no Weave and no Plots" begin - # `docs/make.jl` no longer loads `Weave` and no page uses either, so the docs environment - # does not resolve them. + # Neither the docs build nor the manual uses Weave or Plots, so neither package + # belongs in the docs environment. @test isempty(intersect(["Plots", "Weave"], keys(TOML.parsefile(joinpath(root, "docs", "Project.toml"))["deps"]))) @test !occursin("Weave", make) @@ -50,8 +50,7 @@ end end @testset "the changelog names the extension and the function" begin - # check 6: one entry under the unreleased heading. `[Unreleased]` holds the entries of the - # parts that precede this one as well, so the section is cut at the next version heading. + # Search only the unreleased section, ending at the next version heading. changelog = read(joinpath(root, "CHANGELOG.md"), String) head = findfirst("## [Unreleased]", changelog) @test head !== nothing diff --git a/test/quality/doctests.jl b/test/quality/doctests.jl index da817a1..b22e76f 100644 --- a/test/quality/doctests.jl +++ b/test/quality/doctests.jl @@ -2,12 +2,9 @@ using ReducedBasisMethods using Documenter: DocMeta, doctest using Test -# The doctests of this package, in its docstrings and in the manual under `docs/src`, as the -# `Doctests` job of `CI.yml` runs them: `manual = false`, because the manual pages carry no -# `jldoctest` of their own and their `@meta` block is evaluated in `Main`. -# -# Documenter evaluates a page's `@meta` block in `Main`, and a `@safetestset` file runs in a module -# of its own, so `ReducedBasisMethods` is imported into `Main` first. +# Run the package's docstring doctests, matching the CI Doctests job. +# `manual = false` excludes manual doctests; `docs/make.jl` checks them during the build. +# Documenter evaluates page metadata in Main, so import the package there as well. @eval Main import ReducedBasisMethods DocMeta.setdocmeta!(ReducedBasisMethods, :DocTestSetup, From a83c9fc7b09e3925958bdd3dc445ebcd47515869 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 19:06:17 +0200 Subject: [PATCH 09/12] CHANGELOG: state the Weave and Plots docs removal accurately --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ee6a3..3cbf5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,10 @@ written. `deps_compat` — the last of which now also holds `CairoMakie`'s `[compat]` entry — are among them. The docs build sets `doctest = true`, `get_DEIM_interpolation_matrix` carries the package's - first `jldoctest`, and `Weave` and `Plots` leave both `docs/make.jl` and `docs/Project.toml`: no - page used either. The build also greps its own output for a physical word and fails on a match, - so a name that reaches the site alone is red in the Documentation workflow rather than - published. + first `jldoctest`, `Weave` leaves `docs/make.jl` and `docs/Project.toml`, and `Plots` leaves + `docs/Project.toml`: no page used either. The build also greps its own output for a physical word + and fails on a match, so a name that reaches the site alone is red in the Documentation workflow + rather than published. ## [0.2.0] — 2026-10-12 From f4366f11b0d1cec0f13bd5793da233f0e31de1d0 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 19:22:00 +0200 Subject: [PATCH 10/12] CHANGELOG: the stored spectrum is the Gram one, drawn as given --- CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbf5c2..cb860cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,12 +44,13 @@ written. - **`spectrumplot` plots a reduced basis's spectrum, and `CairoMakie` joins `[weakdeps]` at `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title, resolution)` draws a spectrum against its index on a log-scaled axis and returns a `Makie.FigureAxisPlot`, whose `plot` field holds the - series, and `save(path, spectrumplot(σ))` writes it to a file. It is the singular-value or - eigenvalue spectrum a `ReducedBasis` stores in `b.Λ`, so it draws the values as they are given, - never squared and never rooted: an eigenvalue spectrum from a Gram matrix `MᵀM` and a - singular-value spectrum of `M` differ by a square, and reading one as the other flatters the - decay by a factor of two. The spectrum is drawn in `Float64`, the precision the plot stores, and - is lifted onto the floor `eps(Float64) * maximum(σ)` first: an exact zero of a rank-deficient + series, and `save(path, spectrumplot(σ))` writes it to a file. It accepts a singular-value or + eigenvalue spectrum, including the squared-singular-value (Gram) spectrum a `ReducedBasis` + stores in `b.Λ`, and draws the values as given, never squared and never rooted: an eigenvalue + spectrum from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a square, and + reading one as the other flatters the decay by a factor of two. The spectrum is drawn in + `Float64`, the precision the plot stores, and is lifted onto the floor `eps(Float64) * + maximum(σ)` first: an exact zero of a rank-deficient snapshot, and the slightly negative eigenvalue the Gram route returns, belong on the axis rather than below it. An empty spectrum, one holding a `NaN` or an `Inf`, and one holding no positive value at all — which leaves a log-scaled axis no scale to draw on — each throw an From 2ca1428e7c5f6b57d0ca4e9def330dfac6ca4c0a Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 20:01:10 +0200 Subject: [PATCH 11/12] Fix the Windows load test and simplify the extension, its tests and the docs checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the findings of the review of PR #56: - load_without_makie.jl joins JULIA_LOAD_PATH with the platform separator, so the Windows subprocess finds Pkg, and runs its checks as @test inside the subprocess instead of printing KEY=value lines and parsing them back. - spectrumplot is a single lines() call that returns the FigureAxisPlot. It no longer labels the y axis σᵢ, because b.Λ holds squared singular values. The docstring documents the keywords and is a third of its length. - The four floor testsets that compared against the implementation's own max.(Λ, floor) are one testset with hand-written expected values. The DISPLAY wrappers, which had no effect, are gone. - The changelog test searches the whole file, so it survives a release. The physical-word grep covers ext/ too, and the shared grep file, the grep of the built site and the regex test of docs/make.jl are gone. doctest = true, Documenter's default, is dropped. The layout testset repeated test-layout.jl's compat rule and is gone. - aqua.jl is the bare Aqua.test_all again, without the non-public Test.get_test_counts. - The DEIM docstring loses a paragraph that called an exactly dependent column refused: 195 of 200 such bases are accepted in floating point. - The CHANGELOG entry states the branch as it is. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 41 +++---- docs/grep_physical_words.jl | 22 ---- docs/make.jl | 13 +-- ext/ReducedBasisMethodsCairoMakieExt.jl | 27 ++--- src/algorithms/deim.jl | 5 - src/diagnostics.jl | 33 ++---- test/diagnostics.jl | 146 +++--------------------- test/integration/load_without_makie.jl | 51 ++------- test/quality/aqua.jl | 10 +- test/quality/docs.jl | 63 +++------- test/quality/doctests.jl | 5 +- 11 files changed, 80 insertions(+), 336 deletions(-) delete mode 100644 docs/grep_physical_words.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index cb860cf..108737c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,32 +42,21 @@ written. dependencies supply one. - **`spectrumplot` plots a reduced basis's spectrum, and `CairoMakie` joins `[weakdeps]` at - `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title, resolution)` draws a spectrum against - its index on a log-scaled axis and returns a `Makie.FigureAxisPlot`, whose `plot` field holds the - series, and `save(path, spectrumplot(σ))` writes it to a file. It accepts a singular-value or - eigenvalue spectrum, including the squared-singular-value (Gram) spectrum a `ReducedBasis` - stores in `b.Λ`, and draws the values as given, never squared and never rooted: an eigenvalue - spectrum from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a square, and - reading one as the other flatters the decay by a factor of two. The spectrum is drawn in - `Float64`, the precision the plot stores, and is lifted onto the floor `eps(Float64) * - maximum(σ)` first: an exact zero of a rank-deficient - snapshot, and the slightly negative eigenvalue the Gram route returns, belong on the axis rather - than below it. An empty spectrum, one holding a `NaN` or an `Inf`, and one holding no positive - value at all — which leaves a log-scaled axis no scale to draw on — each throw an - `ArgumentError`. The method lives in the package extension - `ReducedBasisMethodsCairoMakieExt`: `src/diagnostics.jl` holds the stub, so - `using ReducedBasisMethods` needs no plotting stack at all and `spectrumplot` raises the usual - `MethodError` until `CairoMakie` is loaded. - - `Aqua.test_all` now runs inside a `@testset` of its own, as check 4 asks, so each check carries - its own passes and failures in the suite's summary; `stale_deps`, `undefined_exports` and - `deps_compat` — the last of which now also holds `CairoMakie`'s `[compat]` entry — are among them. - - The docs build sets `doctest = true`, `get_DEIM_interpolation_matrix` carries the package's - first `jldoctest`, `Weave` leaves `docs/make.jl` and `docs/Project.toml`, and `Plots` leaves - `docs/Project.toml`: no page used either. The build also greps its own output for a physical word - and fails on a match, so a name that reaches the site alone is red in the Documentation workflow - rather than published. + `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title = "", resolution = (620, 380))` draws + `σ` against its index on a log10 axis, in `Float64`, and returns a `Makie.FigureAxisPlot`; + `save("spectrum.png", spectrumplot(σ))` writes it to a file. The values are drawn as given, never + squared or rooted, so `spectrumplot(b.Λ)` plots the Gram eigenvalues and a vector of singular + values plots as singular values. Every value below `eps(Float64) * maximum(σ)` is lifted onto + that floor, so the exact zeros of a rank-deficient snapshot and the round-off negatives of a Gram + spectrum stay visible on the axis. A `NaN`, an `Inf`, an empty spectrum and a spectrum with no + positive value each throw an `ArgumentError`. The method lives in the package extension + `ReducedBasisMethodsCairoMakieExt`: `src/diagnostics.jl` holds only a stub, so + `using ReducedBasisMethods` loads no plotting stack, and `spectrumplot` raises a `MethodError` + until `CairoMakie` is loaded. + + The package's first `jldoctest` is in `get_DEIM_interpolation_matrix`. `Weave` leaves + `docs/make.jl` and `docs/Project.toml`, and `Plots` leaves `docs/Project.toml`: no page used + either. ## [0.2.0] — 2026-10-12 diff --git a/docs/grep_physical_words.jl b/docs/grep_physical_words.jl deleted file mode 100644 index 862fcdc..0000000 --- a/docs/grep_physical_words.jl +++ /dev/null @@ -1,22 +0,0 @@ -# The words a generic package must not write: ReducedBasisMethods knows no model, so a physical -# word in its sources or its documentation is a physical example that does not belong to it (T7.15, -# section 3.6 of the plan). One definition, two callers: `docs/make.jl` greps the site it has built, -# and `test/quality/docs.jl` greps the sources, so a word fails the Documentation workflow and the -# suite alike. -const PHYSICAL_WORDS = r"vlasov|plasma|particle"i - -""" - forbidden_files(path) - -Every file under `path` whose text holds one of `PHYSICAL_WORDS`, as paths relative to `path`. A -directory is walked recursively; a file is taken as it is. The words are pure ASCII, and reading a -file as a `String` does not check its encoding, so a binary of the built site is searched too. -""" -function forbidden_files(path) - files = isdir(path) ? - sort!([joinpath(root, name) for (root, _, names) in walkdir(path) - for name in names]) : - String[path] - return [relpath(file, path) - for file in files if occursin(PHYSICAL_WORDS, read(file, String))] -end diff --git a/docs/make.jl b/docs/make.jl index d509ca2..613b552 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,8 +1,6 @@ using ReducedBasisMethods using Documenter -include("grep_physical_words.jl") - makedocs(; modules = [ReducedBasisMethods], authors = "Tobias M. Blickhan, Michael Kraus, Tomasz M. Tyranoski", @@ -16,18 +14,9 @@ makedocs(; pages = [ "Home" => "index.md", "Library" => "library.md" - ], - doctest = true + ] ) -# The sources, the manual and the build's own `sitename`, `authors` and page titles carry no -# physical word, so one in the site was added by the build itself: it fails here rather than -# publishing a page that names a model. `test/quality/docs.jl` greps the sources with the same -# words. -forbidden = forbidden_files(joinpath(@__DIR__, "build")) -isempty(forbidden) || - error("the built documentation names a model: ", join(forbidden, ", ")) - deploydocs(; repo = "github.com/JuliaRCM/ReducedBasisMethods.jl", devurl = "latest", diff --git a/ext/ReducedBasisMethodsCairoMakieExt.jl b/ext/ReducedBasisMethodsCairoMakieExt.jl index 3adb427..a0ba42f 100644 --- a/ext/ReducedBasisMethodsCairoMakieExt.jl +++ b/ext/ReducedBasisMethodsCairoMakieExt.jl @@ -4,27 +4,16 @@ using CairoMakie using ReducedBasisMethods function ReducedBasisMethods.spectrumplot(σ::AbstractVector{<:Real}; - title = "", resolution = nothing) - isempty(σ) && throw(ArgumentError("no spectrum to plot")) - all(isfinite, σ) || throw(ArgumentError( - "the spectrum holds a NaN or an Inf, which a log-scaled axis cannot draw")) - any(>(0), σ) || throw(ArgumentError( - "the spectrum holds no positive value, so a log-scaled axis has no scale to draw it on")) + title = "", resolution = (620, 380)) + all(isfinite, σ) || throw(ArgumentError("the spectrum holds a NaN or an Inf")) + any(>(0), σ) || throw(ArgumentError("the spectrum holds no positive value")) - # The spectrum is drawn in the precision the plot stores, which is `Float64`: a `Float32` - # spectrum is widened to it, and that widening is exact. - # - # A rank-deficient snapshot set gives a Gram matrix with exact zeros among its eigenvalues, and - # a nearly dependent one gives round-off eigenvalues slightly below zero. `max` lifts every - # value at or below the floor onto it, which is what puts the whole spectrum on the axis - # instead of dropping the round-off tail off it. - y = max.(convert(Vector{Float64}, σ), eps(Float64) * Float64(maximum(σ))) + # A log-scaled axis cannot draw the exact zeros of a rank-deficient snapshot set or the + # round-off negatives of a Gram spectrum, so every value below the floor is lifted onto it. + y = max.(Float64.(σ), eps(Float64) * Float64(maximum(σ))) - fig = Figure(size = resolution === nothing ? (620, 380) : resolution) - ax = Axis(fig[1, 1]; xlabel = "i", ylabel = "σᵢ", yscale = log10) - isempty(title) || Label(fig[0, 1], title; halign = :left, fontsize = 16) - plt = lines!(ax, 1:length(y), y) - return CairoMakie.Makie.FigureAxisPlot(fig, ax, plt) + return lines(y; figure = (; size = resolution), + axis = (; title, titlealign = :left, xlabel = "i", yscale = log10)) end end diff --git a/src/algorithms/deim.jl b/src/algorithms/deim.jl index 11db909..3fb3f03 100644 --- a/src/algorithms/deim.jl +++ b/src/algorithms/deim.jl @@ -63,11 +63,6 @@ Its element type is `float(eltype(Ψ))`, so a `Float32` basis gives a `Float32` matrix. The selection, and with it the `ArgumentError` for `k > N` and for a column whose largest residual magnitude is not finite and positive, is [`_deim_indices`](@ref)'s. -Each column of `Π` is a unit vector, so the output is exact whatever the input: the `jldoctest` -below prints the matrix itself rather than a rounded form of it. A column of `Ψ` that is an exact -combination of the ones before it is refused instead — its residual is zero, which is the -`ArgumentError` the note above names. - ```jldoctest julia> using ReducedBasisMethods diff --git a/src/diagnostics.jl b/src/diagnostics.jl index e4b3da8..edf323d 100644 --- a/src/diagnostics.jl +++ b/src/diagnostics.jl @@ -1,34 +1,19 @@ """ - spectrumplot(σ::AbstractVector{<:Real}; kwargs...) + spectrumplot(σ::AbstractVector{<:Real}; title = "", resolution = (620, 380)) -The singular-value spectrum `σ` of a reduced basis, drawn on a log-scaled axis against its index: -the diagnostic that says how many basis vectors a snapshot set admits, and where the spectrum -runs into round-off. +Draw the spectrum `σ` of a reduced basis against its index on a log-scaled axis, in `Float64`. +The values are drawn as given: singular values, or the squared singular values (Gram eigenvalues) +that a `ReducedBasis` stores in `Λ`. -The values are drawn as they are given, never squared and never rooted: an eigenvalue spectrum -from a Gram matrix `MᵀM` and a singular-value spectrum of `M` differ by a square, and reading -one as the other flatters the decay by a factor of two. A caller that holds the eigenvalues -plots them as they are. +Every value below `eps(Float64) * maximum(σ)` is drawn at that floor, so the exact zeros and the +round-off negatives of a Gram spectrum stay on the axis. A spectrum that holds a `NaN` or an `Inf`, +or no positive value, throws an `ArgumentError`. -The axis is log-scaled, so the spectrum is lifted onto the floor `eps(Float64) * maximum(σ)` -before it is drawn: a rank-deficient snapshot set leaves exact zeros among the eigenvalues of its -Gram matrix, and a nearly dependent one leaves values slightly below zero, and both belong on the -axis rather than below it. The floor is taken in `Float64`, the precision the plot stores, and the -whole spectrum is drawn in that precision, so a `Float32` spectrum is widened by an exact -widening. - -An empty `σ` throws an `ArgumentError`, and so does a spectrum holding a `NaN` or an `Inf` and one -holding no positive value at all, which leaves a log-scaled axis no scale to draw on. - -Returns a `Makie.FigureAxisPlot`, whose `plot` field holds the plotted series, and -`save(path, spectrumplot(σ))` writes it to a PNG. - -Defined in a package extension: load `CairoMakie` to make it available. +Returns a `Makie.FigureAxisPlot`. Defined in a package extension: load `CairoMakie` to use it. ```julia using ReducedBasisMethods, CairoMakie -fap = spectrumplot(b.Λ) # b.Λ is the full stored spectrum -save("spectrum.png", fap) +save("spectrum.png", spectrumplot(b.Λ)) ``` """ function spectrumplot end diff --git a/test/diagnostics.jl b/test/diagnostics.jl index 66a795f..6383fb8 100644 --- a/test/diagnostics.jl +++ b/test/diagnostics.jl @@ -1,67 +1,43 @@ using CairoMakie -using LinearAlgebra -using Random using ReducedBasisMethods using ReducedComplexityModeling: Parameter, ParameterSpace using Test -"`spectrumplot` with `DISPLAY` unset: the render needs no display, so the test states that instead -of inheriting whatever the runner happens to set." -render(σ) = withenv("DISPLAY" => nothing) do - spectrumplot(σ) -end - -"The PNG bytes that `save` writes for a figure, also with `DISPLAY` unset." -function save_png(fap) - return withenv("DISPLAY" => nothing) do - path = joinpath(mktempdir(), "spectrum.png") - save(path, fap) - return read(path) - end -end - -"`FigureAxisPlot` keeps its series in its `plot` field, and `Lines` keeps its converted positional -argument as one vector of points: these helpers expose the values and their storage precision -for exact comparison." +"The plotted values of a `FigureAxisPlot`, and their element type." plotted_y(fap) = [q[2] for q in fap.plot.positions[]] plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @testset "Diagnostics" begin - @testset "the plot is a `FigureAxisPlot`" begin - @test render([1.0, 0.1]) isa CairoMakie.Makie.FigureAxisPlot - end - - @testset "a full-rank spectrum renders headless, exactly as given ($T)" for T in (Float64, Float32) - # check 2: σᵢ = 10^(-i) for i = 1:10. `save` writes the PNG through Cairo, so no display - # is involved and none can be missing. + @testset "a spectrum is drawn as given and saved headless ($T)" for T in (Float64, Float32) σ = T.(10.0 .^ (-(1:10))) - - fap = render(σ) - # the values are drawn as given, never squared and never rooted: read them back in the - # precision the plot stores, which is where the comparison of `==` is made. + fap = spectrumplot(σ) + @test fap isa CairoMakie.Makie.FigureAxisPlot @test plotted_eltype(fap) == Float64 - @test plotted_y(fap) == convert(Vector{plotted_eltype(fap)}, σ) @test plotted_y(fap) == σ - # the floor is `eps(Float64) * maximum(σ)`, and the smallest of these values sits far - # above it, so the floor lifts none of them and the comparison is an exact one - @test all(>(eps(Float64) * Float64(maximum(σ))), σ) - @test save_png(fap)[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] + path = joinpath(mktempdir(), "spectrum.png") + save(path, fap) + @test read(path)[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] + end + + @testset "a one-element and a BigFloat spectrum" begin + @test plotted_y(spectrumplot([3.0])) == [3.0] + @test plotted_y(spectrumplot(big.([1.0, 0.5, 0.25]))) == [1.0, 0.5, 0.25] end - @testset "a one-element spectrum renders" begin - @test plotted_y(render(Float64[3.0])) == [3.0] + @testset "values below the floor are drawn at the floor ($T)" for T in (Float64, Float32) + # the floor is eps(Float64) * maximum(σ) = eps(Float64) + σ = T[1, 1e-3, 1e-20, 0, -1e-18] + @test plotted_y(spectrumplot(σ)) == [1, Float64(T(1e-3)), eps(), eps(), eps()] end - @testset "an empty, a NaN, an Infinity and a non-positive spectrum are refused" begin + @testset "a spectrum with a NaN, an Inf or no positive value is refused" begin @test_throws ArgumentError spectrumplot(Float64[]) @test_throws ArgumentError spectrumplot([1.0, NaN]) @test_throws ArgumentError spectrumplot([1.0, Inf]) @test_throws ArgumentError spectrumplot([1.0, -Inf]) - # no positive value at all, so a log-scaled axis has no scale to draw the spectrum on @test_throws ArgumentError spectrumplot(zeros(3)) @test_throws ArgumentError spectrumplot(Float32[0, 0]) - @test_throws ArgumentError spectrumplot([-1.0]) @test_throws ArgumentError spectrumplot([0.0, -1e-17]) # the zero snapshot set, whose eigenvalues the Gram route stores as exact zeros paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) @@ -69,92 +45,4 @@ plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @test all(iszero, Λ) @test_throws ArgumentError spectrumplot(Λ) end - - @testset "a rank-deficient spectrum draws its zeros at the floor ($T)" for T in (Float64, Float32) - # check 3: the EVD route takes the eigenvalues of the Gram matrix XᵀX. A snapshot column - # that is exactly zero leaves exact zeros among them, and round-off leaves the neighbours - # of a dependent column slightly below zero. Both are lifted onto the floor - # `eps(Float64) * maximum(σ)`, so a log-scaled axis draws the whole spectrum and no value - # is silently dropped. - Random.seed!(11) - X0 = hcat(randn(T, 9, 3), zeros(T, 9, 2)) # two dead snapshot columns - Λ = reverse(eigen(Symmetric(X0' * X0)).values) - - # the precondition of the clause, asserted rather than assumed - @test count(iszero, Λ) == 2 - - floor = eps(Float64) * Float64(maximum(Λ)) - fap = render(Λ) - drawn = plotted_y(fap) - @test plotted_eltype(fap) == Float64 - @test drawn == max.(Λ, floor) - @test issorted(drawn; rev = true) - @test all(>(0), drawn) - end - - @testset "a slightly negative eigenvalue is drawn at the floor" begin - # the other face of the same round-off: the Gram route keeps the negative eigenvalues of a - # nearly dependent snapshot, and they must reach the axis rather than fall off it, while the - # positive entries are drawn exactly as they are given - Random.seed!(7) - X = randn(Float64, 12, 4) - Λ = reverse(eigen(Symmetric(hcat(X, X, X)' * hcat(X, X, X))).values) - @test any(<(0), Λ) - @test any(>(0), Λ) - - # the floor lifts a positive round-off value along with the non-positive ones, and the - # drawn series stays in the order the spectrum is stored in - floor = eps(Float64) * Float64(maximum(Λ)) - @test any(x -> 0 < x < floor, Λ) - - drawn = plotted_y(render(Λ)) - @test drawn == max.(Λ, floor) - @test issorted(drawn; rev = true) - @test all(>(0), drawn) - end - - @testset "the same round-off in a Float32 Gram spectrum" begin - # the floor is taken in the precision the plot stores, so a `Float32` spectrum is drawn in - # `Float64` and its non-positive eigenvalues are lifted onto that floor as well - Random.seed!(7) - X = randn(Float32, 12, 4) - Λ = reverse(eigen(Symmetric(hcat(X, X, X)' * hcat(X, X, X))).values) - @test any(<(0), Λ) - @test any(>(0), Λ) - - drawn = plotted_y(render(Λ)) - @test drawn == max.(Float64.(Λ), eps(Float64) * maximum(Λ)) - @test issorted(drawn; rev = true) - @test all(>(0), drawn) - end - - @testset "a spectrum finer than the plot stores is drawn in `Float64`" begin - # The BigFloat inputs are converted to the plot's Float64 storage before comparison; - # these dyadic values remain exactly representable. - fap = render(big.([1.0, 0.5, 0.25])) - @test plotted_eltype(fap) == Float64 - @test plotted_y(fap) == [1.0, 0.5, 0.25] - end - - @testset "the spectrum of a rank-deficient snapshot, as the Gram route stores it" begin - Random.seed!(7) - paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) - X = randn(Float64, 12, 4) - b = ReducedBasis(PODEVD(), hcat(X, X, X), NamedTuple(), paramspace; - k = 12) - - # the route stores the raw spectrum with its non-positive entries kept - Λ = b.Λ - @test any(≤(0), Λ) - @test issorted(Λ; rev = true) - - # the same data through the EVD route: the floor lifts its round-off tail, which holds a - # positive value below the floor as well - floor = eps(Float64) * Float64(maximum(Λ)) - @test any(x -> 0 < x < floor, Λ) - - drawn = plotted_y(render(Λ)) - @test drawn == max.(Λ, floor) - @test issorted(drawn; rev = true) - end end diff --git a/test/integration/load_without_makie.jl b/test/integration/load_without_makie.jl index 73614ca..32b1301 100644 --- a/test/integration/load_without_makie.jl +++ b/test/integration/load_without_makie.jl @@ -2,53 +2,26 @@ using ReducedBasisMethods using Test using TOML -# The package loads with no Makie present. `CairoMakie` is a weak dependency, so nothing about the -# ordinary load brings it in — which is what this file states from the inside of a fresh temporary -# project that holds only the package, run in a subprocess whose load path `@:@stdlib` cannot be -# satisfied out of the ambient `@v#.#` environment by a stray `using`. +# `CairoMakie` is a weak dependency: the package resolves and loads in a fresh project that holds +# only it, with no Makie package installed, no extension loaded, and `spectrumplot` without a method. +# The subprocess load path holds only that project and the standard library. @testset "Load without Makie" begin root = pkgdir(ReducedBasisMethods) project = TOML.parsefile(joinpath(root, "Project.toml")) - - # the stub is in the package and the method is not: no other dependency holds a plotter - @test !haskey(project["deps"], "Plots") - @test !haskey(project["deps"], "LaTeXStrings") - @test project["extensions"] == Dict{String, String}( - "ReducedBasisMethodsCairoMakieExt" => "CairoMakie") + @test project["extensions"] == Dict("ReducedBasisMethodsCairoMakieExt" => "CairoMakie") @test haskey(project["weakdeps"], "CairoMakie") @test !haskey(project["deps"], "CairoMakie") - # the generic-only skeleton of `skeleton.jl` holds the rest of the `[deps]` list - environment = mktempdir() code = """ import Pkg - Pkg.activate($(repr(environment)); io = devnull) + Pkg.activate(mktempdir(); io = devnull) Pkg.develop(path = $(repr(root)); io = devnull) - using ReducedBasisMethods - - dependencies = Pkg.dependencies() - uuids = keys(dependencies) - names = [dependencies[uuid].name for uuid in uuids] - println("MAKIE=", join(sort([name for name in names if occursin("Makie", name)]), ",")) - println("MAKIEWEAKDEP=", - Base.UUID("13f3f980-e62b-5c42-98c6-ff1f3baf88f0") in uuids) - println("EXTENSION=", Base.get_extension( - ReducedBasisMethods, :ReducedBasisMethodsCairoMakieExt) === nothing) - try - ReducedBasisMethods.spectrumplot(Float64[1.0, 0.5]) - println("STUB=no method missing") - catch error - println("STUB=", error isa MethodError) - end + using ReducedBasisMethods, Test + @test !any(dep -> occursin("Makie", dep.name), values(Pkg.dependencies())) + @test Base.get_extension(ReducedBasisMethods, :ReducedBasisMethodsCairoMakieExt) === nothing + @test_throws MethodError spectrumplot([1.0, 0.5]) """ - output = String(read(addenv( - `$(Base.julia_cmd()) --startup-file=no --color=no -e $code`, - "JULIA_LOAD_PATH" => "@:@stdlib"))) - reported = Dict(strip.(split(l, '='; limit = 2)) - for l in split(output, '\n') if '=' in l) - - @test reported["MAKIE"] == "" - @test reported["MAKIEWEAKDEP"] == "false" - @test reported["EXTENSION"] == "true" - @test reported["STUB"] == "true" + load_path = join(["@", "@stdlib"], Sys.iswindows() ? ';' : ':') + @test success(addenv(`$(Base.julia_cmd()) --startup-file=no -e $code`, + "JULIA_LOAD_PATH" => load_path)) end diff --git a/test/quality/aqua.jl b/test/quality/aqua.jl index 6c596e3..3bfb97e 100644 --- a/test/quality/aqua.jl +++ b/test/quality/aqua.jl @@ -2,12 +2,4 @@ using Aqua using ReducedBasisMethods using Test -# Aqua checks package-wide methods, exports, dependency usage and compatibility. -# Its checks use their own testsets; this wrapper groups their results under one -# suite heading. -aqua = @testset "Aqua" begin - Aqua.test_all(ReducedBasisMethods) -end - -# Require nonzero cumulative passes so a vacuous Aqua run fails. -@test Test.get_test_counts(aqua).cumulative_passes > 0 +Aqua.test_all(ReducedBasisMethods) diff --git a/test/quality/docs.jl b/test/quality/docs.jl index d37e681..ae1d755 100644 --- a/test/quality/docs.jl +++ b/test/quality/docs.jl @@ -2,71 +2,38 @@ using ReducedBasisMethods using Test using TOML -# The words a generic package must not write, and the search over a tree that holds them, are -# defined once for the package and used twice: `docs/make.jl` greps the site it builds, this file -# greps the sources. Including it here keeps the two from drifting apart. -include(joinpath(pkgdir(ReducedBasisMethods), "docs", "grep_physical_words.jl")) - -"How many files under `dir` contain at least one `jldoctest` block." -function jldoctest_blocks(dir) - count(file -> occursin("```jldoctest", read(file, String)), - [joinpath(root, name) for (root, _, names) in walkdir(dir) for name in names]) +"Every file under `dir` whose text matches `pattern`." +function files_matching(pattern, dir) + files = [joinpath(root, name) for (root, _, names) in walkdir(dir) for name in names] + return filter(file -> occursin(pattern, read(file, String)), files) end @testset "Docs and changelog" begin root = pkgdir(ReducedBasisMethods) - make = read(joinpath(root, "docs", "make.jl"), String) - @testset "no physical word in the sources or the build's configuration" begin - # check 5, on the sources, the manual and the build's own configuration: `sitename`, - # `authors` and the page titles are the words that reach the site and appear nowhere else, - # and `docs/make.jl` is where they are written. `CHANGELOG.md` and `reference/` may name - # the model, and are left out on purpose. - for path in ("src", "docs/src", "docs/make.jl") - @test isempty(forbidden_files(joinpath(root, path))) + @testset "no physical word in the code or the documentation" begin + # The package knows no model. `CHANGELOG.md` and `reference/` may name one. + for dir in ("src", "ext", "docs/src") + @test isempty(files_matching(r"vlasov|plasma|particle"i, joinpath(root, dir))) end - # What the build itself renders is grepped by the build, once the site exists: the grep of - # its `build` runs after `makedocs` and fails the Documentation workflow, so the assertion - # is that the build holds that grep rather than a repeat of it here. - @test occursin(r"""forbidden_files\(joinpath\(@__DIR__, "build"\)\)""", make) - @test occursin(r"""isempty\(forbidden\)\s*\|\|\s*error\(""", make) + @test !occursin(r"vlasov|plasma|particle"i, + read(joinpath(root, "docs", "make.jl"), String)) end @testset "the docs environment holds no Weave and no Plots" begin - # Neither the docs build nor the manual uses Weave or Plots, so neither package - # belongs in the docs environment. @test isempty(intersect(["Plots", "Weave"], keys(TOML.parsefile(joinpath(root, "docs", "Project.toml"))["deps"]))) - @test !occursin("Weave", make) - @test !occursin("Plots", make) - @test occursin("doctest = true", make) end @testset "at least one `jldoctest` in the package" begin - # A build with zero doctests passes vacuously, so it is the count that is asserted. - n = jldoctest_blocks(joinpath(root, "src")) + - jldoctest_blocks(joinpath(root, "docs", "src")) - @test n ≥ 1 + # A build with zero doctests passes vacuously. + @test !isempty(files_matching("```jldoctest", joinpath(root, "src"))) || + !isempty(files_matching("```jldoctest", joinpath(root, "docs", "src"))) end @testset "the changelog names the extension and the function" begin - # Search only the unreleased section, ending at the next version heading. changelog = read(joinpath(root, "CHANGELOG.md"), String) - head = findfirst("## [Unreleased]", changelog) - @test head !== nothing - rest = SubString(changelog, nextind(changelog, last(head))) - tail = findfirst(r"^## \["m, rest) - unreleased = changelog[first(head):(tail === nothing ? lastindex(changelog) : - last(head) + first(tail) - 1)] - @test occursin("ReducedBasisMethodsCairoMakieExt", unreleased) - @test occursin("spectrumplot", unreleased) - end - - @testset "the test environment takes `CairoMakie` from the package alone" begin - # `CairoMakie` is a weak dependency, so the test environment states its own entry and takes - # the bound from the package. - test_project = TOML.parsefile(joinpath(root, "test", "Project.toml")) - @test haskey(test_project["deps"], "CairoMakie") - @test !haskey(get(test_project, "compat", Dict{String, Any}()), "CairoMakie") + @test occursin("ReducedBasisMethodsCairoMakieExt", changelog) + @test occursin("spectrumplot", changelog) end end diff --git a/test/quality/doctests.jl b/test/quality/doctests.jl index b22e76f..e783bff 100644 --- a/test/quality/doctests.jl +++ b/test/quality/doctests.jl @@ -1,9 +1,8 @@ using ReducedBasisMethods using Documenter: DocMeta, doctest -using Test -# Run the package's docstring doctests, matching the CI Doctests job. -# `manual = false` excludes manual doctests; `docs/make.jl` checks them during the build. +# Run the package's docstring doctests. `manual = false` excludes the manual's doctests, which +# `docs/make.jl` runs during the build. # Documenter evaluates page metadata in Main, so import the package there as well. @eval Main import ReducedBasisMethods From 73ff301dde3717cfa483fe203240b7a9da99e35c Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Wed, 14 Oct 2026 22:00:05 +0200 Subject: [PATCH 12/12] Trigger spectrumplot on Makie, add ylabel and size, simplify its tests Second review round of PR 56. The extension becomes ReducedBasisMethodsMakieExt with Makie 0.24 as the weak dependency; CairoMakie is test-only. spectrumplot takes ylabel and size (Makie's name) in place of resolution. The load test runs one subprocess in the active test environment, with no Pkg resolve. docs.jl keeps only the word check, without plasma. doctests.jl drops the setup that manual = false makes dead. diagnostics.jl drops repeated refusals and the zero-snapshot input check, and pins the extension name. The CHANGELOG entry is shortened. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++++++------- Project.toml | 6 +-- ...eExt.jl => ReducedBasisMethodsMakieExt.jl} | 10 ++--- src/diagnostics.jl | 9 +++-- test/Project.toml | 1 + test/diagnostics.jl | 22 +++++------ test/integration/load_without_makie.jl | 22 ++--------- test/quality/docs.jl | 39 +++---------------- test/quality/doctests.jl | 11 +----- test/runtests.jl | 2 +- 10 files changed, 49 insertions(+), 100 deletions(-) rename ext/{ReducedBasisMethodsCairoMakieExt.jl => ReducedBasisMethodsMakieExt.jl} (69%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d529c02..0913b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,22 +17,17 @@ written. ### New Features -- **`spectrumplot` plots a reduced basis's spectrum, and `CairoMakie` joins `[weakdeps]` at - `"0.15"`.** `spectrumplot(σ::AbstractVector{<:Real}; title = "", resolution = (620, 380))` draws - `σ` against its index on a log10 axis, in `Float64`, and returns a `Makie.FigureAxisPlot`; - `save("spectrum.png", spectrumplot(σ))` writes it to a file. The values are drawn as given, never - squared or rooted, so `spectrumplot(b.Λ)` plots the Gram eigenvalues and a vector of singular - values plots as singular values. Every value below `eps(Float64) * maximum(σ)` is lifted onto - that floor, so the exact zeros of a rank-deficient snapshot and the round-off negatives of a Gram - spectrum stay visible on the axis. A `NaN`, an `Inf`, an empty spectrum and a spectrum with no - positive value each throw an `ArgumentError`. The method lives in the package extension - `ReducedBasisMethodsCairoMakieExt`: `src/diagnostics.jl` holds only a stub, so - `using ReducedBasisMethods` loads no plotting stack, and `spectrumplot` raises a `MethodError` - until `CairoMakie` is loaded. - - The package's first `jldoctest` is in `get_DEIM_interpolation_matrix`. `Weave` leaves - `docs/make.jl` and `docs/Project.toml`, and `Plots` leaves `docs/Project.toml`: no page used - either. +- **`spectrumplot` draws a reduced basis's spectrum, and `Makie` joins `[weakdeps]`, at `"0.24"`.** + `spectrumplot(σ::AbstractVector{<:Real}; title = "", ylabel = "", size = (620, 380))` draws `σ` + against its index on a log10 axis and returns a `Makie.FigureAxisPlot`. It lives in the extension + `ReducedBasisMethodsMakieExt`, which loads with `Makie`, so without `Makie` the call is a + `MethodError`; `save` needs a backend such as `CairoMakie`. + +### Changed + +- **The docs environment drops `Weave` and `Plots`, and `get_DEIM_interpolation_matrix` carries a + `jldoctest`.** No page used either package. This is the only `jldoctest` in the package; + `test/quality/doctests.jl` runs it in the `doctests` group. ## [0.2.1] — 2026-10-14 diff --git a/Project.toml b/Project.toml index a59a0e4..1fdd0c3 100644 --- a/Project.toml +++ b/Project.toml @@ -12,16 +12,16 @@ ReducedComplexityModeling = "abb4722c-1d8e-48eb-a3bd-45b3116bfaba" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" [weakdeps] -CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" [extensions] -ReducedBasisMethodsCairoMakieExt = "CairoMakie" +ReducedBasisMethodsMakieExt = "Makie" [compat] -CairoMakie = "0.15" GeometricEquations = "0.21" HDF5 = "0.16, 0.17" LinearAlgebra = "1" +Makie = "0.24" MultiIndexArrays = "0.1.2" ReducedComplexityModeling = "0.1.1" Statistics = "1" diff --git a/ext/ReducedBasisMethodsCairoMakieExt.jl b/ext/ReducedBasisMethodsMakieExt.jl similarity index 69% rename from ext/ReducedBasisMethodsCairoMakieExt.jl rename to ext/ReducedBasisMethodsMakieExt.jl index a0ba42f..de351ad 100644 --- a/ext/ReducedBasisMethodsCairoMakieExt.jl +++ b/ext/ReducedBasisMethodsMakieExt.jl @@ -1,10 +1,10 @@ -module ReducedBasisMethodsCairoMakieExt +module ReducedBasisMethodsMakieExt -using CairoMakie +using Makie using ReducedBasisMethods function ReducedBasisMethods.spectrumplot(σ::AbstractVector{<:Real}; - title = "", resolution = (620, 380)) + title = "", ylabel = "", size = (620, 380)) all(isfinite, σ) || throw(ArgumentError("the spectrum holds a NaN or an Inf")) any(>(0), σ) || throw(ArgumentError("the spectrum holds no positive value")) @@ -12,8 +12,8 @@ function ReducedBasisMethods.spectrumplot(σ::AbstractVector{<:Real}; # round-off negatives of a Gram spectrum, so every value below the floor is lifted onto it. y = max.(Float64.(σ), eps(Float64) * Float64(maximum(σ))) - return lines(y; figure = (; size = resolution), - axis = (; title, titlealign = :left, xlabel = "i", yscale = log10)) + return lines(y; figure = (; size), + axis = (; title, titlealign = :left, xlabel = "i", ylabel, yscale = log10)) end end diff --git a/src/diagnostics.jl b/src/diagnostics.jl index edf323d..10052be 100644 --- a/src/diagnostics.jl +++ b/src/diagnostics.jl @@ -1,19 +1,20 @@ """ - spectrumplot(σ::AbstractVector{<:Real}; title = "", resolution = (620, 380)) + spectrumplot(σ::AbstractVector{<:Real}; title = "", ylabel = "", size = (620, 380)) Draw the spectrum `σ` of a reduced basis against its index on a log-scaled axis, in `Float64`. The values are drawn as given: singular values, or the squared singular values (Gram eigenvalues) -that a `ReducedBasis` stores in `Λ`. +that a `ReducedBasis` stores in `Λ`. `ylabel` names which, and `size` is the figure size. Every value below `eps(Float64) * maximum(σ)` is drawn at that floor, so the exact zeros and the round-off negatives of a Gram spectrum stay on the axis. A spectrum that holds a `NaN` or an `Inf`, or no positive value, throws an `ArgumentError`. -Returns a `Makie.FigureAxisPlot`. Defined in a package extension: load `CairoMakie` to use it. +Returns a `Makie.FigureAxisPlot`. Defined in a package extension that loads with `Makie`; `save` +needs a backend such as `CairoMakie`. ```julia using ReducedBasisMethods, CairoMakie -save("spectrum.png", spectrumplot(b.Λ)) +save("spectrum.png", spectrumplot(b.Λ; ylabel = "λᵢ")) ``` """ function spectrumplot end diff --git a/test/Project.toml b/test/Project.toml index 6f64921..45fc48b 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -19,6 +19,7 @@ ReducedBasisMethods = {path = ".."} [compat] Aqua = "0.8.18" +CairoMakie = "0.15" Documenter = "1.19.0" ForwardDiff = "1.4.6" GeometricBrackets = "0.1.1, 0.2" diff --git a/test/diagnostics.jl b/test/diagnostics.jl index 6383fb8..aebbb43 100644 --- a/test/diagnostics.jl +++ b/test/diagnostics.jl @@ -1,18 +1,18 @@ using CairoMakie using ReducedBasisMethods -using ReducedComplexityModeling: Parameter, ParameterSpace using Test -"The plotted values of a `FigureAxisPlot`, and their element type." +"The plotted values of a `FigureAxisPlot`." plotted_y(fap) = [q[2] for q in fap.plot.positions[]] -plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @testset "Diagnostics" begin + @test Base.get_extension(ReducedBasisMethods, :ReducedBasisMethodsMakieExt) isa Module + @testset "a spectrum is drawn as given and saved headless ($T)" for T in (Float64, Float32) σ = T.(10.0 .^ (-(1:10))) fap = spectrumplot(σ) @test fap isa CairoMakie.Makie.FigureAxisPlot - @test plotted_eltype(fap) == Float64 + @test eltype(eltype(fap.plot.positions[])) == Float64 @test plotted_y(fap) == σ path = joinpath(mktempdir(), "spectrum.png") @@ -20,6 +20,13 @@ plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @test read(path)[1:4] == UInt8[0x89, 0x50, 0x4e, 0x47] end + @testset "the axis label and the figure size" begin + @test spectrumplot([1.0]).axis.ylabel[] == "" + fap = spectrumplot([1.0, 0.5]; ylabel = "λᵢ", size = (300, 200)) + @test fap.axis.ylabel[] == "λᵢ" + @test Base.size(fap.figure.scene) == (300, 200) + end + @testset "a one-element and a BigFloat spectrum" begin @test plotted_y(spectrumplot([3.0])) == [3.0] @test plotted_y(spectrumplot(big.([1.0, 0.5, 0.25]))) == [1.0, 0.5, 0.25] @@ -35,14 +42,7 @@ plotted_eltype(fap) = eltype(eltype(fap.plot.positions[])) @test_throws ArgumentError spectrumplot(Float64[]) @test_throws ArgumentError spectrumplot([1.0, NaN]) @test_throws ArgumentError spectrumplot([1.0, Inf]) - @test_throws ArgumentError spectrumplot([1.0, -Inf]) @test_throws ArgumentError spectrumplot(zeros(3)) - @test_throws ArgumentError spectrumplot(Float32[0, 0]) @test_throws ArgumentError spectrumplot([0.0, -1e-17]) - # the zero snapshot set, whose eigenvalues the Gram route stores as exact zeros - paramspace = ParameterSpace(Parameter(:σ, 0.0, 1.0, 2)) - Λ = ReducedBasis(PODEVD(), zeros(12, 4), NamedTuple(), paramspace; k = 1).Λ - @test all(iszero, Λ) - @test_throws ArgumentError spectrumplot(Λ) end end diff --git a/test/integration/load_without_makie.jl b/test/integration/load_without_makie.jl index 32b1301..75f1e3a 100644 --- a/test/integration/load_without_makie.jl +++ b/test/integration/load_without_makie.jl @@ -1,27 +1,13 @@ using ReducedBasisMethods using Test -using TOML -# `CairoMakie` is a weak dependency: the package resolves and loads in a fresh project that holds -# only it, with no Makie package installed, no extension loaded, and `spectrumplot` without a method. -# The subprocess load path holds only that project and the standard library. +# `Makie` is a weak dependency: loading the package, in an environment that has a Makie backend +# installed, loads no Makie module, and `spectrumplot` has no method. @testset "Load without Makie" begin - root = pkgdir(ReducedBasisMethods) - project = TOML.parsefile(joinpath(root, "Project.toml")) - @test project["extensions"] == Dict("ReducedBasisMethodsCairoMakieExt" => "CairoMakie") - @test haskey(project["weakdeps"], "CairoMakie") - @test !haskey(project["deps"], "CairoMakie") - code = """ - import Pkg - Pkg.activate(mktempdir(); io = devnull) - Pkg.develop(path = $(repr(root)); io = devnull) using ReducedBasisMethods, Test - @test !any(dep -> occursin("Makie", dep.name), values(Pkg.dependencies())) - @test Base.get_extension(ReducedBasisMethods, :ReducedBasisMethodsCairoMakieExt) === nothing + @test !any(m -> occursin("Makie", m.name), keys(Base.loaded_modules)) @test_throws MethodError spectrumplot([1.0, 0.5]) """ - load_path = join(["@", "@stdlib"], Sys.iswindows() ? ';' : ':') - @test success(addenv(`$(Base.julia_cmd()) --startup-file=no -e $code`, - "JULIA_LOAD_PATH" => load_path)) + @test success(`$(Base.julia_cmd()) --startup-file=no --project=$(Base.active_project()) -e $code`) end diff --git a/test/quality/docs.jl b/test/quality/docs.jl index ae1d755..cbe58ff 100644 --- a/test/quality/docs.jl +++ b/test/quality/docs.jl @@ -1,39 +1,12 @@ using ReducedBasisMethods using Test -using TOML -"Every file under `dir` whose text matches `pattern`." -function files_matching(pattern, dir) - files = [joinpath(root, name) for (root, _, names) in walkdir(dir) for name in names] - return filter(file -> occursin(pattern, read(file, String)), files) -end - -@testset "Docs and changelog" begin +@testset "no physical word in the code or the documentation" begin + # The package knows no model. `CHANGELOG.md` and `reference/` may name one. root = pkgdir(ReducedBasisMethods) - - @testset "no physical word in the code or the documentation" begin - # The package knows no model. `CHANGELOG.md` and `reference/` may name one. - for dir in ("src", "ext", "docs/src") - @test isempty(files_matching(r"vlasov|plasma|particle"i, joinpath(root, dir))) - end - @test !occursin(r"vlasov|plasma|particle"i, - read(joinpath(root, "docs", "make.jl"), String)) - end - - @testset "the docs environment holds no Weave and no Plots" begin - @test isempty(intersect(["Plots", "Weave"], - keys(TOML.parsefile(joinpath(root, "docs", "Project.toml"))["deps"]))) - end - - @testset "at least one `jldoctest` in the package" begin - # A build with zero doctests passes vacuously. - @test !isempty(files_matching("```jldoctest", joinpath(root, "src"))) || - !isempty(files_matching("```jldoctest", joinpath(root, "docs", "src"))) - end - - @testset "the changelog names the extension and the function" begin - changelog = read(joinpath(root, "CHANGELOG.md"), String) - @test occursin("ReducedBasisMethodsCairoMakieExt", changelog) - @test occursin("spectrumplot", changelog) + files = [joinpath(path, file) for dir in ("src", "ext", "docs/src") + for (path, _, names) in walkdir(joinpath(root, dir)) for file in names] + for file in [files; joinpath(root, "docs", "make.jl")] + @test !occursin(r"vlasov|particle"i, read(file, String)) end end diff --git a/test/quality/doctests.jl b/test/quality/doctests.jl index e783bff..ecb34f5 100644 --- a/test/quality/doctests.jl +++ b/test/quality/doctests.jl @@ -1,12 +1,5 @@ using ReducedBasisMethods -using Documenter: DocMeta, doctest - -# Run the package's docstring doctests. `manual = false` excludes the manual's doctests, which -# `docs/make.jl` runs during the build. -# Documenter evaluates page metadata in Main, so import the package there as well. -@eval Main import ReducedBasisMethods - -DocMeta.setdocmeta!(ReducedBasisMethods, :DocTestSetup, - :(using ReducedBasisMethods); recursive = true) +using Documenter: doctest +# The docstring doctests. `manual = false` leaves the manual's doctests to `docs/make.jl`. doctest(ReducedBasisMethods; manual = false) diff --git a/test/runtests.jl b/test/runtests.jl index 94989df..3f03610 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,7 +4,7 @@ const GROUPS = isempty(ARGS) ? ["core", "slow"] : ARGS if "core" in GROUPS @safetestset "Aqua" include("quality/aqua.jl") - @safetestset "Docs and changelog" include("quality/docs.jl") + @safetestset "Docs" include("quality/docs.jl") @safetestset "Generic-only skeleton" include("integration/skeleton.jl") @safetestset "Reference spectra script" include("integration/reference_spectra.jl") @safetestset "Reduced basis" include("reducedbasis.jl")