From f09f8882af5ed0d44b20aebfda99e90b7168810a Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sat, 28 Mar 2026 15:09:16 +0100 Subject: [PATCH 01/12] core refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `AbstractDomain`/`FullDomain`/`NarrowBandDomain` hierarchy to `meshfield.jl`; `MeshField` gains a fourth type parameter `D <: AbstractDomain` - Add `logging.jl`: `StepRecord` and `SimulationLog` for per-step timing/history - Refactor `LevelSetEquation`: rename `levelset` → `ic`, replace `buffers` with `log::SimulationLog`, overhaul `show` output - Refactor `timestepping.jl`: add doctests, support `NarrowBandDomain` dispatch - Refactor `reinitializer.jl`: improvements to `NewtonReinitializer` and `NewtonSDF` - Various cleanups to `boundaryconditions.jl`, `meshes.jl`, `levelset.jl`, `levelsetterms.jl`, `derivatives.jl`, `interpolation.jl`, `velocityextension.jl` --- src/LevelSetMethods.jl | 33 +-- src/boundaryconditions.jl | 145 +++++++++-- src/derivatives.jl | 48 ++-- src/interpolation.jl | 197 +++++++++----- src/levelset.jl | 88 ++++--- src/levelsetequation.jl | 536 +++++++------------------------------- src/levelsetterms.jl | 57 ++-- src/logging.jl | 154 +++++++++++ src/meshes.jl | 105 +++++++- src/meshfield.jl | 261 +++++++++++++------ src/reinitializer.jl | 186 +++++++++---- src/timestepping.jl | 469 +++++++++++++++++++++++++++++++++ src/velocityextension.jl | 4 +- 13 files changed, 1486 insertions(+), 797 deletions(-) create mode 100644 src/logging.jl diff --git a/src/LevelSetMethods.jl b/src/LevelSetMethods.jl index c5d3a4d..62692ff 100644 --- a/src/LevelSetMethods.jl +++ b/src/LevelSetMethods.jl @@ -17,34 +17,35 @@ include("reinitializer.jl") include("velocityextension.jl") include("levelsetterms.jl") include("timestepping.jl") +include("logging.jl") include("levelsetequation.jl") -export CartesianGrid, - MeshField, - LevelSet, - PeriodicBC, - NeumannBC, - NeumannGradientBC, - DirichletBC, - ExtrapolationBC, - AdvectionTerm, +export AdvectionTerm, + CartesianGrid, CurvatureTerm, - NormalMotionTerm, - extend_along_normals!, + DirichletBC, EikonalReinitializationTerm, + ExtrapolationBC, ForwardEuler, + LevelSet, + LevelSetEquation, + MeshField, + NeumannBC, + LinearExtrapolationBC, + NewtonReinitializer, + NormalMotionTerm, + PeriodicBC, RK2, RK3, SemiImplicitI2OE, Upwind, WENO5, - LevelSetEquation, - NewtonReinitializer, - integrate!, current_state, current_time, - reinitialize!, - interpolate + extend_along_normals!, + integrate!, + interpolate, + reinitialize! """ diff --git a/src/boundaryconditions.jl b/src/boundaryconditions.jl index a5e3e46..41daa34 100644 --- a/src/boundaryconditions.jl +++ b/src/boundaryconditions.jl @@ -8,65 +8,82 @@ abstract type BoundaryCondition end """ struct PeriodicBC <: BoundaryCondition -Singleton type representing periodic boundary conditions. +Singleton type representing periodic boundary conditions. Ghost cells are filled by wrapping +around to the opposite side of the domain. + +```jldoctest +using LevelSetMethods +PeriodicBC() + +# output + +Periodic +``` + """ struct PeriodicBC <: BoundaryCondition end """ struct ExtrapolationBC{P} <: BoundaryCondition -P-th order one-sided polynomial extrapolation boundary condition. Uses the `P` -nearest interior cells to construct a degree `P-1` polynomial that is -extrapolated into the ghost region. +Degree-P one-sided polynomial extrapolation boundary condition. Uses the `P+1` +nearest interior cells to construct a degree-P polynomial that is extrapolated +into the ghost region. -`ExtrapolationBC{1}` (aliased as [`NeumannBC`](@ref)) gives constant extension -(∂ϕ/∂n = 0 at the boundary face). `ExtrapolationBC{2}` (aliased as -[`NeumannGradientBC`](@ref)) gives linear extrapolation (∂²ϕ/∂n² = 0). +`ExtrapolationBC{0}` (aliased as [`NeumannBC`](@ref)) gives constant extension +(∂ϕ/∂n = 0 at the boundary face). `ExtrapolationBC{1}` (aliased as +[`LinearExtrapolationBC`](@ref)) gives linear extrapolation (∂²ϕ/∂n² = 0). ```jldoctest using LevelSetMethods -ExtrapolationBC(5) +ExtrapolationBC(4) # output -ExtrapolationBC{5}() +Degree 4 extrapolation ``` """ -struct ExtrapolationBC{P} <: BoundaryCondition end +struct ExtrapolationBC{_P} <: BoundaryCondition + function ExtrapolationBC{P}() where {P} + P >= 0 || throw(ArgumentError("extrapolation order P must be at least 0")) + return new{P}() + end +end +ExtrapolationBC(p::Int) = ExtrapolationBC{p}() """ - NeumannBC = ExtrapolationBC{1} + const NeumannBC = ExtrapolationBC{0} Homogeneous Neumann boundary condition (∂ϕ/∂n = 0 at the boundary face). -Alias for [`ExtrapolationBC{1}`](@ref): ghost cells take the value of the +Alias for [`ExtrapolationBC{0}`](@ref): ghost cells take the value of the nearest boundary node (constant extension). """ -const NeumannBC = ExtrapolationBC{1} +const NeumannBC = ExtrapolationBC{0} """ - NeumannGradientBC = ExtrapolationBC{2} + LinearExtrapolationBC = ExtrapolationBC{1} -Homogeneous Neumann gradient boundary condition (∂²ϕ/∂n² = 0). -Alias for [`ExtrapolationBC{2}`](@ref): linear extrapolation into ghost cells. +Alias for [`ExtrapolationBC{1}`](@ref): linear extrapolation into ghost cells. Corresponds +to ∂²ϕ/∂n² = 0 at the boundary face. """ -const NeumannGradientBC = ExtrapolationBC{2} +const LinearExtrapolationBC = ExtrapolationBC{1} -ExtrapolationBC(p::Int) = ExtrapolationBC{p}() """ _lagrange_extrap_weight(j, k, P) Lagrange weight for the `j`-th interior node (0-indexed) when extrapolating -to a ghost point at distance `k` outside the boundary, using `P` nodes total. +to a ghost point at distance `k` outside the boundary, using a degree-P +polynomial fitted to P+1 nodes. -Nodes are at positions 0, 1, …, P-1 (relative to the boundary); +Nodes are at positions 0, 1, …, P (relative to the boundary); the ghost is at position -k. The weight is - wⱼ = ∏_{m=0, m≠j}^{P-1} (-k - m) / (j - m) + wⱼ = ∏_{m=0, m≠j}^{P} (-k - m) / (j - m) """ function _lagrange_extrap_weight(j::Int, k::Int, P::Int) w = 1.0 - for m in 0:(P - 1) + for m in 0:P m == j && continue w *= (-k - m) / (j - m) end @@ -76,11 +93,87 @@ end """ struct DirichletBC{T} <: BoundaryCondition -A Dirichlet boundary condition taking values of `f(x)` at the boundary. +A Dirichlet boundary condition taking values of `f(x, t)` at the boundary, +where `x` is the spatial coordinate and `t` is the current time. """ -struct DirichletBC{T} <: BoundaryCondition +mutable struct DirichletBC{T} <: BoundaryCondition f::T + t::Float64 # state passed to `f` for time-dependent BCs +end + +function DirichletBC(f) + isempty(methods(f)) && + throw(ArgumentError("DirichletBC requires a callable, got $(typeof(f))")) + return DirichletBC(f, 0.0) +end + +""" + update_bc!(bc::BoundaryCondition, t) + +Update the current time stored in a boundary condition. Only meaningful for +[`DirichletBC`](@ref); all other BC types are no-ops. +""" +update_bc!(bc::BoundaryCondition, _) = bc +update_bc!(bc::DirichletBC, t) = (bc.t = t; bc) + +Base.show(io::IO, ::PeriodicBC) = print(io, "Periodic") +Base.show(io::IO, ::ExtrapolationBC{P}) where {P} = print(io, "Degree $P extrapolation") +Base.show(io::IO, ::DirichletBC) = print(io, "Dirichlet") + +""" + _normalize_bc(bc, dim) + +Normalize the `bc` argument into a `dim`-tuple of `(left, right)` pairs, one per spatial +dimension, as expected by [`add_boundary_conditions`](@ref). + +- A single `BoundaryCondition` is applied to all sides. +- A length-`dim` collection applies each entry to both sides of the corresponding dimension. +- A length-`dim` collection of 2-tuples applies each entry as `(left, right)` for that + dimension. +""" +function _normalize_bc(bc, dim) + if isa(bc, BoundaryCondition) + return ntuple(_ -> (bc, bc), dim) + else + length(bc) == dim || throw(ArgumentError("invalid number of boundary conditions")) + return ntuple(dim) do i + if isa(bc[i], BoundaryCondition) + return (bc[i], bc[i]) + else + length(bc[i]) == 2 && all(isa(bc[i][n], BoundaryCondition) for n in 1:2) || + throw(ArgumentError("invalid boundary condition for dimension $i")) + # check that periodic boundary conditions are not mixed with others + if any(isa(bc[i][n], PeriodicBC) for n in 1:2) + all(isa(bc[i][n], PeriodicBC) for n in 1:2) || throw( + ArgumentError( + "periodic boundary conditions cannot be mixed with others in dimension $i", + ), + ) + end + return (bc[i][1], bc[i][2]) + end + end + end end -DirichletBC() = DirichletBC(0.0) -DirichletBC(a::Real) = DirichletBC(_ -> float(a)) +""" + _bc_str(bcs) + +Format a boundary-conditions tuple (as returned by `boundary_conditions`) into a +compact human-readable string. Each element of `bcs` is a `(left, right)` pair for +one spatial dimension. +""" +function _bc_str(bcs::Tuple) + N = length(bcs) + dim_names = N <= 3 ? ("x", "y", "z") : ntuple(i -> "d$i", N) + all_bcs = [bcs[d][s] for d in 1:N for s in 1:2] + if all(typeof(b) == typeof(all_bcs[1]) for b in all_bcs) + return "$(sprint(show, all_bcs[1])) (all)" + end + parts = ntuple(N) do d + bl, br = bcs[d] + side = typeof(bl) == typeof(br) ? sprint(show, bl) : "$(sprint(show, bl)) ↔ $(sprint(show, br))" + "$(dim_names[d]): $side" + end + return join(parts, ", ") +end diff --git a/src/derivatives.jl b/src/derivatives.jl index 1b15447..df36463 100644 --- a/src/derivatives.jl +++ b/src/derivatives.jl @@ -5,12 +5,12 @@ struct Upwind <: SpatialScheme end struct WENO5 <: SpatialScheme end """ - D⁰(ϕ::CartesianMeshField,I,dim) + D⁰(ϕ,I,dim) Centered finite difference scheme for first order derivative at grid point `I` along dimension `dim`. """ -function D⁰(ϕ::CartesianMeshField, I, dim) +function D⁰(ϕ, I, dim) h = meshsize(ϕ, dim) Im = _decrement_index(I, dim) Ip = _increment_index(I, dim) @@ -18,24 +18,24 @@ function D⁰(ϕ::CartesianMeshField, I, dim) end """ - D⁺(ϕ::CartesianMeshField,I,dim) + D⁺(ϕ,I,dim) Forward finite difference scheme for first order derivative at grid point `I` along dimension `dim`. """ -@inline function D⁺(ϕ::CartesianMeshField, I, dim) +@inline function D⁺(ϕ, I, dim) h = meshsize(ϕ, dim) Ip = _increment_index(I, dim) return (ϕ[Ip] - ϕ[I]) / h end """ - D⁺⁺(ϕ::CartesianMeshField, I, dim) + D⁺⁺(ϕ, I, dim) Second-order forward finite difference scheme for first order derivative at grid point `I` along dimension `dim`. """ -function D⁺⁺(ϕ::CartesianMeshField, I, dim) +function D⁺⁺(ϕ, I, dim) h = meshsize(ϕ, dim) Ip = _increment_index(I, dim) Ipp = _increment_index(I, dim, 2) @@ -43,24 +43,24 @@ function D⁺⁺(ϕ::CartesianMeshField, I, dim) end """ - D⁻(ϕ::CartesianMeshField,I,dim) + D⁻(ϕ,I,dim) Backward finite difference scheme for first order derivative at grid point `I` along dimension `dim`. """ -function D⁻(ϕ::CartesianMeshField, I, dim) +function D⁻(ϕ, I, dim) h = meshsize(ϕ, dim) Im = _decrement_index(I, dim) return (ϕ[I] - ϕ[Im]) / h end """ - D⁻⁻(ϕ::CartesianMeshField, I, dim) + D⁻⁻(ϕ, I, dim) Second-order backward finite difference scheme for first order derivative at grid point `I` along dimension `dim`. """ -function D⁻⁻(ϕ::CartesianMeshField, I, dim) +function D⁻⁻(ϕ, I, dim) h = meshsize(ϕ, dim) Im = _decrement_index(I, dim) Imm = _decrement_index(I, dim, 2) @@ -68,13 +68,13 @@ function D⁻⁻(ϕ::CartesianMeshField, I, dim) end """ - weno5⁻(ϕ::CartesianMeshField, I, dim) + weno5⁻(ϕ, I, dim) Fifth-order WENO (Weighted Essentially Non-Oscillatory) reconstruction of the derivative at grid point `I` along dimension `dim`, using a left-biased stencil. """ -function weno5⁻(ϕ::CartesianMeshField, I, dim) - # see section 3.4 of Osher-Fedwik +function weno5⁻(ϕ, I, dim) + # see section 3.4 of Osher-Fedkiw Im = _decrement_index(I, dim) Imm = _decrement_index(Im, dim) Ip = _increment_index(I, dim) @@ -107,13 +107,13 @@ function weno5⁻(ϕ::CartesianMeshField, I, dim) end """ - weno5⁺(ϕ::CartesianMeshField, I, dim) + weno5⁺(ϕ, I, dim) Fifth-order WENO (Weighted Essentially Non-Oscillatory) reconstruction of the derivative at grid point `I` along dimension `dim`, using a right-biased stencil. """ -function weno5⁺(ϕ::CartesianMeshField, I, dim) - # see section 3.4 of Osher-Fedwik +function weno5⁺(ϕ, I, dim) + # see section 3.4 of Osher-Fedkiw Im = _decrement_index(I, dim) Imm = _decrement_index(Im, dim) Ip = _increment_index(I, dim) @@ -146,12 +146,12 @@ function weno5⁺(ϕ::CartesianMeshField, I, dim) end """ - D2⁰(ϕ::CartesianMeshField,I,dim) + D2⁰(ϕ,I,dim) Centered finite difference scheme for second order derivative at grid point `I` along dimension `dim`. E.g. if `dim=1`, this approximates `∂ₓₓ`. """ -function D2⁰(ϕ::CartesianMeshField, I, dim) +function D2⁰(ϕ, I, dim) h = meshsize(ϕ, dim) Im = _decrement_index(I, dim) Ip = _increment_index(I, dim) @@ -159,7 +159,7 @@ function D2⁰(ϕ::CartesianMeshField, I, dim) end """ - D2(ϕ::CartesianMeshField,I,dims) + D2(ϕ,I,dims) Finite difference scheme for second order derivative at grid point `I` along the dimensions `dims`. @@ -174,12 +174,12 @@ function D2(ϕ, I, dims) end """ - D2⁺⁺(ϕ::CartesianMeshField,I,dim) + D2⁺⁺(ϕ,I,dim) Upward finite difference scheme for second order derivative at grid point `I` along dimension `dim`. E.g. if `dim=1`, this approximates `∂ₓₓ`. """ -function D2⁺⁺(ϕ::CartesianMeshField, I, dim) +function D2⁺⁺(ϕ, I, dim) h = meshsize(ϕ, dim) Ip = _increment_index(I, dim, 1) Ipp = _increment_index(I, dim, 2) @@ -187,12 +187,12 @@ function D2⁺⁺(ϕ::CartesianMeshField, I, dim) end """ - D2⁻⁺(ϕ::CartesianMeshField,I,dim) + D2⁻⁻(ϕ,I,dim) Backward finite difference scheme for second order derivative at grid point `I` along dimension `dim`. E.g. if `dim=1`, this approximates `∂ₓₓ`. """ -function D2⁻⁻(ϕ::CartesianMeshField, I, dim) +function D2⁻⁻(ϕ, I, dim) h = meshsize(ϕ, dim) Im = _decrement_index(I, dim, 1) Imm = _decrement_index(I, dim, 2) @@ -202,12 +202,10 @@ end # Helper functions function _increment_index(I::CartesianIndex, dim::Integer, nb::Integer = 1) N = length(I) - @assert 1 ≤ dim ≤ length(I) return I + CartesianIndex(ntuple(i -> i == dim ? nb : 0, N)) end function _decrement_index(I::CartesianIndex, dim::Integer, nb::Integer = 1) N = length(I) - @assert 1 ≤ dim ≤ length(I) return I + CartesianIndex(ntuple(i -> i == dim ? -nb : 0, N)) end diff --git a/src/interpolation.jl b/src/interpolation.jl index c57da29..3c028d9 100644 --- a/src/interpolation.jl +++ b/src/interpolation.jl @@ -20,6 +20,9 @@ p(x_1,\\dots,x_D)=\\sum_{i_j=0}^{d_j}c_{i_1\\dots i_D}\\prod_{j=1}^D\\binom{d_j} where ``l_j = lc[j]`` and ``r_j = hc[j]`` are the lower and upper bounds of the hyperrectangle, respectively, and ``d_j = size(c)[j] - 1`` is the degree of the polynomial in dimension `j`. + +`BernsteinPolynomial`s can be differentiated using ForwardDiff; see [`gradient`](@ref) and +[`hessian`](@ref). """ function BernsteinPolynomial(c::AbstractArray, lc, hc) N = ndims(c) @@ -81,56 +84,95 @@ end end end +""" + gradient(p, x) + +Compute the gradient of `p` at point `x` using ForwardDiff. +""" function gradient(p, x) return ForwardDiff.gradient(p, SVector{length(x)}(x)) end +""" + hessian(p, x) + +Compute the hessian of `p` at point `x` using ForwardDiff. +""" function hessian(p, x) return ForwardDiff.hessian(p, SVector{length(x)}(x)) end + function value_and_gradient(p, x) res = ForwardDiff.gradient!(DiffResults.GradientResult(x), p, x) return DiffResults.value(res), DiffResults.gradient(res) end +""" + value_gradient_hessian(p, x) + +Fused computation of the value, gradient, and hessian of `p` at point `x` using ForwardDiff. +""" function value_gradient_hessian(p, x) res = ForwardDiff.hessian!(DiffResults.HessianResult(x), p, x) return DiffResults.value(res), DiffResults.gradient(res), DiffResults.hessian(res) end """ - struct PiecewisePolynomialInterpolation{Φ, N, T} - -A piecewise polynomial interpolant for a `MeshField`. - -See [`interpolate`](@ref). + struct PiecewisePolynomialInterpolant{Φ, N, T} + +A piecewise polynomial interpolant built over a `MeshField`. + +The domain is partitioned into cells of the underlying mesh. On each cell a +`BernsteinPolynomial` is fitted to the surrounding stencil of grid values, so +that evaluating the interpolant at a point `x` amounts to: +1. locating the cell containing `x`, +2. (lazily) computing the local Bernstein coefficients, and +3. evaluating the resulting polynomial. + +Construct via [`interpolate`](@ref). The returned object `itp` supports: +- `itp(x)` — evaluate at point `x` +- [`make_interpolant`](@ref)`(itp, I)` — local `BernsteinPolynomial` for cell `I` +- [`gradient`](@ref)`(itp, x)` / [`hessian`](@ref)`(itp, x)` — via ForwardDiff +- [`cell_extrema`](@ref)`(itp, I)` — certified bounds via convex-hull property +- [`proven_empty`](@ref)`(itp, I)` — certified absence of interface or interior + +The struct is mutable because the local coefficients and stencil values are +cached in place (`coeffs`, `vals`) to avoid allocation on repeated evaluations. + +!!! warning "Not thread-safe" + The internal buffers (`coeffs`, `vals`, `temp1`, `temp2`, `Ic`) are shared + state. Concurrent evaluation from multiple threads will cause data races. + Create one interpolant per thread if parallelism is needed. """ -mutable struct PiecewisePolynomialInterpolation{Φ, N, T} - ϕ::Φ # underlying mesh field - mat::Matrix{T} # map from grid to bernstein vals in 1D - coeffs::Array{T, N} # buffer for bernstein coeffs - vals::Array{T, N} # buffer for grid values in the stencil - Ic::CartesianIndex{N} # multi-index of the currently loaded cell +mutable struct PiecewisePolynomialInterpolant{Φ, N, T} + ϕ::Φ # underlying mesh field + mat::Matrix{T} # map from grid to bernstein vals in 1D + coeffs::Array{T, N} # buffer for bernstein coeffs + vals::Array{T, N} # buffer for grid values in the stencil + Ic::CartesianIndex{N} # multi-index of the currently loaded cell + # scratch buffers for applying the N-fold kron mat to vals temp1::Vector{T} temp2::Vector{T} end -function PiecewisePolynomialInterpolation(ϕ::Φ, K::Int) where {Φ} +function PiecewisePolynomialInterpolant(ϕ::Φ, order::Int) where {Φ} grid = mesh(ϕ) - N = dimension(grid) + N = ndims(grid) T = eltype(ϕ) - # Build a 1D interpolation matrix mapping `K+1` values at equispaced nodes in [0,1] and - # returning the coefficients of the Bernstein basis defined on the interval - # [floor(K/2)/K, ceil(K/2)/K], which is symmetric around 0.5 and contains the central - # node at 0.5. When K is even, we use a stencil of size K+1 and do a least-squares fit. - stencil_K = isodd(K) ? K : K + 1 - nc, nv = K + 1, stencil_K + 1 - nodes = ntuple(i -> (i - 1) / stencil_K, Val(nv)) - a, b = (stencil_K - 1) / (2 * stencil_K), (stencil_K + 1) / (2 * stencil_K) + # Build a 1D interpolation matrix mapping `order+1` values at equispaced nodes in [0,1] + # and returning the coefficients of the Bernstein basis defined on the interval + # [floor(order/2)/order, ceil(order/2)/order], which is symmetric around 0.5 and + # contains the central node at 0.5. When order is even, we use a stencil of size + # order+1 and do a least-squares fit. This matrix is used to compute the interpolant in + # a cell given values on a super-cell around it. + stencil_order = isodd(order) ? order : order + 1 + nc, nv = order + 1, stencil_order + 1 + nodes = ntuple(i -> (i - 1) / stencil_order, Val(nv)) + a, b = (stencil_order - 1) / (2 * stencil_order), (stencil_order + 1) / (2 * stencil_order) B = (i, k, x) -> binomial(k, i) * (x^i) * ((1 - x)^(k - i)) - V = [B(j - 1, K, (nodes[i] - a) / (b - a)) for i in 1:nv, j in 1:nc] + V = [B(j - 1, order, (nodes[i] - a) / (b - a)) for i in 1:nv, j in 1:nc] mat = pinv(V) coeffs = zeros(T, ntuple(_ -> nc, N)) @@ -143,42 +185,48 @@ function PiecewisePolynomialInterpolation(ϕ::Φ, K::Int) where {Φ} Ic = CartesianIndex(ntuple(_ -> 0, Val(N))) - return PiecewisePolynomialInterpolation(ϕ, mat, coeffs, vals, Ic, temp1, temp2) + return PiecewisePolynomialInterpolant(ϕ, mat, coeffs, vals, Ic, temp1, temp2) end -Base.ndims(::PiecewisePolynomialInterpolation{Φ, N}) where {Φ, N} = N +Base.ndims(::PiecewisePolynomialInterpolant{Φ, N}) where {Φ, N} = N """ - compute_index(itp::PiecewisePolynomialInterpolation, x) + compute_index(itp::PiecewisePolynomialInterpolant, x) -Compute the multi-index of the cell containing point `x`. +Compute the multi-index of the cell containing point `x`. If `x` is outside the domain, the +index of the closest cell is returned (clamping to the boundary). """ -function compute_index(itp::PiecewisePolynomialInterpolation, x) +function compute_index(itp::PiecewisePolynomialInterpolant, x) grid = mesh(itp.ϕ) N = ndims(itp) + cell_ax = cellindices(grid) return ntuple( - d -> floor(Int, (x[d] - grid.lc[d]) / meshsize(grid)[d]) + 1, + d -> clamp( + floor(Int, (x[d] - grid.lc[d]) / meshsize(grid)[d]) + 1, + first(cell_ax.indices[d]), last(cell_ax.indices[d]) + ), N, ) |> CartesianIndex end # Batched right-multiply by Aᵀ: for r in 1:n_batch, C[:,:,r] = B[:,:,r] * Aᵀ, # where C is (m, n) and B is (m, p), all stored flat in column-major order. -# C and B are accessed via linear indexing (avoiding reshape on Array{T,N}, which allocates). -function _rmult!(C, A::Matrix{T}, B, m, p, n, n_batch = 1) where {T} - return @inbounds for r in 1:n_batch - ob = (r - 1) * m * p - oc = (r - 1) * m * n +# C and B are accessed via linear indexing +function _rmult!(C, A::Matrix{T}, B, m, p, n, n_batch) where {T} + @inbounds for r in 1:n_batch + offset_B = (r - 1) * m * p + offset_C = (r - 1) * m * n for j in 1:n for i in 1:m s = zero(T) for k in 1:p - s += A[j, k] * B[ob + (k - 1) * m + i] + s += A[j, k] * B[offset_B + (k - 1) * m + i] end - C[oc + (j - 1) * m + i] = s + C[offset_C + (j - 1) * m + i] = s end end end + return C end # Apply the N-fold Kronecker product (mat ⊗ mat ⊗ … ⊗ mat) to vals using the vec trick, @@ -208,12 +256,12 @@ function _apply_kron!( end """ - fill_coefficients!(itp::PiecewisePolynomialInterpolation, base_idxs::CartesianIndex) + fill_coefficients!(itp::PiecewisePolynomialInterpolant, base_idxs::CartesianIndex) Fill the internal buffer of `itp` with the Bernstein coefficients for the cell at `base_idxs`. """ @inline function fill_coefficients!( - itp::PiecewisePolynomialInterpolation{Φ, N, T}, + itp::PiecewisePolynomialInterpolant{Φ, N, T}, base_idxs::CartesianIndex{N}, ) where {Φ, N, T} ϕ = itp.ϕ @@ -221,52 +269,58 @@ Fill the internal buffer of `itp` with the Bernstein coefficients for the cell a nc, nn = size(mat) KS = nn - 1 # order of the interpolation stencil off = -(KS - 1) ÷ 2 - # 1. Gather grid values into vals + # Gather grid values into vals. Since the field ϕ may generate values on demand via BCs, + # we can't just copy or have a view for I in CartesianIndices(itp.vals) J = CartesianIndex(ntuple(d -> base_idxs[d] + off + I[d] - 1, N)) @inbounds itp.vals[I] = ϕ[J] end - - # The Vandermonde matrix is a Kronecker product V = V₁ ⊗ … ⊗ V₁ so we use the vec - # trick to perform the multi-dimensional interpolation with N calls to mul!. + # The Vandermonde matrix is a Kronecker product V = V₁ ⊗ … ⊗ V₁ _apply_kron!(itp.coeffs, mat, itp.vals, itp.temp1, itp.temp2) itp.Ic = base_idxs return itp end """ - make_interpolant(itp::PiecewisePolynomialInterpolation, I::CartesianIndex) + make_interpolant(itp::PiecewisePolynomialInterpolant, I::CartesianIndex) Create a `BernsteinPolynomial` for the cell at multi-index `I`. """ -function make_interpolant(itp::PiecewisePolynomialInterpolation{Φ, N}, I::CartesianIndex{N}) where {Φ, N} +function make_interpolant(itp::PiecewisePolynomialInterpolant{Φ, N}, I::CartesianIndex{N}) where {Φ, N} I == itp.Ic || fill_coefficients!(itp, I) - grid = mesh(itp.ϕ) - h = meshsize(grid) - lc = grid.lc .+ (SVector(Tuple(I)) .- 1) .* h - hc = lc .+ h - return BernsteinPolynomial(itp.coeffs, lc, hc) + cell = getcell(mesh(itp.ϕ), I) + return BernsteinPolynomial(itp.coeffs, cell.lc, cell.hc) +end + +function Base.show(io::IO, ::MIME"text/plain", itp::PiecewisePolynomialInterpolant) + order = size(itp.mat, 1) - 1 + N = ndims(itp) + println(io, "PiecewisePolynomialInterpolant") + println(io, " ├─ order: $order") + println(io, " └─ field: MeshField on CartesianGrid in ℝ$(_superscript(N))") + return _show_fields(io, itp.ϕ; prefix = " ") end @inline _evaluate(p::P, x) where {P} = p(x) -@inline function (itp::PiecewisePolynomialInterpolation)(x) +@inline function (itp::PiecewisePolynomialInterpolant)(x) I = compute_index(itp, x) p = make_interpolant(itp, I) return _evaluate(p, x) end -@inline (itp::PiecewisePolynomialInterpolation)(x::Vararg{Real}) = itp(SVector(x)) -@inline (itp::PiecewisePolynomialInterpolation)(x::Tuple) = itp(SVector(x)) +@inline (itp::PiecewisePolynomialInterpolant)(x::Vararg{Real}) = itp(SVector(x)) +@inline (itp::PiecewisePolynomialInterpolant)(x::Tuple) = itp(SVector(x)) """ interpolate(ϕ::MeshField, order::Int = 3) Create a piecewise polynomial interpolant of the given `order` for `ϕ`. -A deep copy of `ϕ` is made so that the interpolant is independent of future -modifications to `ϕ`. If `ϕ` has no boundary conditions, `ExtrapolationBC{3}` -is added automatically on all sides. +A deep copy of `ϕ` is made so that the interpolant is independent of future modifications to +`ϕ`. If `ϕ` has no boundary conditions, `ExtrapolationBC{2}` is added automatically on all +sides (this is necessary to evaluate the interpolant near the boundary, where the stencil +may require out-of-bounds values). The returned object `itp` behaves like a function and supports: - `itp(x)`: evaluate the interpolant at point `x` @@ -275,34 +329,53 @@ The returned object `itp` behaves like a function and supports: - [`hessian`](@ref)`(itp, x)`: hessian at `x` (via `make_interpolant` + ForwardDiff) - [`cell_extrema`](@ref)`(itp, I)`: lower and upper bounds of the interpolant in cell `I` +To avoid unnecessary copying, call `update!(itp, ϕ)` to update the interpolant's internal +field with new values from `ϕ` while reusing the existing interpolation matrix and scratch +buffers; see [`update!`](@ref) for details. """ -function interpolate(ϕ::MeshField, order::Int = 3) +function interpolate(ϕ, order::Int = 3) ϕ_copy = deepcopy(ϕ) if !has_boundary_conditions(ϕ_copy) - N = dimension(mesh(ϕ_copy)) - bc = ntuple(_ -> (ExtrapolationBC{3}(), ExtrapolationBC{3}()), N) + N = ndims(mesh(ϕ_copy)) + bc = ntuple(_ -> (ExtrapolationBC{2}(), ExtrapolationBC{2}()), N) ϕ_copy = add_boundary_conditions(ϕ_copy, bc) end - return PiecewisePolynomialInterpolation(ϕ_copy, order) + return PiecewisePolynomialInterpolant(ϕ_copy, order) +end + +""" + update!(itp::PiecewisePolynomialInterpolant, ϕ) + +Copy the values of `ϕ` into the interpolant's internal field and invalidate the cell +cache. This is cheaper than calling [`interpolate`](@ref) again because it reuses the +existing interpolation matrix and scratch buffers. +""" +function update!(itp::PiecewisePolynomialInterpolant{Φ, N}, ϕ) where {Φ, N} + copy!(itp.ϕ, ϕ) + itp.Ic = CartesianIndex(ntuple(_ -> 0, Val(N))) + return itp end """ - cell_extrema(itp::PiecewisePolynomialInterpolation, I::CartesianIndex) + cell_extrema(itp::PiecewisePolynomialInterpolant, I::CartesianIndex) Compute the minimum and maximum values of the interpolant in the cell `I`. """ -function cell_extrema(itp::PiecewisePolynomialInterpolation{Φ, N}, I::CartesianIndex{N}) where {Φ, N} +function cell_extrema(itp::PiecewisePolynomialInterpolant{Φ, N}, I::CartesianIndex{N}) where {Φ, N} p = make_interpolant(itp, I) return extrema(coefficients(p)) end """ - proven_empty(itp::PiecewisePolynomialInterpolation, I::CartesianIndex; surface=false) + proven_empty(itp::PiecewisePolynomialInterpolant, I::CartesianIndex; surface=false) Return `true` if the cell `I` is guaranteed to not contain the interface (if `surface=true`) or to not contain any part of the interior (if `surface=false`). + +Note that `proven_empty` being `false` does not mean the cell is non-empty, but rather that +we can't guarantee emptiness based on the convex hull property of the Bernstein basis. """ -function proven_empty(itp::PiecewisePolynomialInterpolation, I::CartesianIndex; surface = false) +function proven_empty(itp::PiecewisePolynomialInterpolant, I::CartesianIndex; surface = false) m, M = cell_extrema(itp, I) if surface return m * M > 0 diff --git a/src/levelset.jl b/src/levelset.jl index 3b5ba0f..132467f 100644 --- a/src/levelset.jl +++ b/src/levelset.jl @@ -1,16 +1,27 @@ """ - const LevelSet + const LevelSet{N, T, B} -Alias for [`MeshField`](@ref) with `vals` as an `AbstractArray` of `Real`s. +Alias for [`MeshField`](@ref) on a `CartesianGrid{N,T}` with values stored as +an `Array{T,N}` and a [`FullDomain`](@ref). `B` is the type of the boundary +conditions. """ -const LevelSet{V <: AbstractArray{<:Real}, M, B} = MeshField{V, M, B} +const LevelSet{N, T, B} = + MeshField{Array{T, N}, CartesianGrid{N, T}, B, FullDomain} -function LevelSet(f::Function, m) - vals = map(f, m) - return MeshField(vals, m, nothing) -end +LevelSet(f::Function, grid, bc = nothing) = MeshField(f, grid, bc) -current_state(ϕ::LevelSet) = ϕ +""" + _ensure_boundary_conditions(ϕ) + +Return `ϕ` unchanged if it already carries boundary conditions, otherwise wrap +it with `LinearExtrapolationBC` on every face. +""" +function _ensure_boundary_conditions(ϕ) + has_boundary_conditions(ϕ) && return ϕ + N = ndims(ϕ) + bc = _normalize_bc(LinearExtrapolationBC(), N) + return add_boundary_conditions(ϕ, bc) +end """ volume(ϕ::LevelSet) @@ -59,12 +70,7 @@ LevelSetMethods.perimeter(ϕ), S0 ``` """ function perimeter(ϕ::LevelSet) - # if no boundary conditions then we use homogenous Neumann - if !has_boundary_conditions(ϕ) - N = dimension(ϕ) - bc = _normalize_bc(NeumannGradientBC(), N) - ϕ = add_boundary_conditions(ϕ, bc) - end + ϕ = _ensure_boundary_conditions(ϕ) δ = meshsize(mesh(ϕ)) δmin = minimum(δ) vol = prod(δ) @@ -92,7 +98,7 @@ end # return a matrix with coefficients equal to 2^(-n) where n is # the number of times this index if on the borders of a dimension. function trapezoidal_coefficients(ϕ::LevelSet) - N = dimension(ϕ) + N = ndims(ϕ) ax = axes(ϕ) coeffs = zeros(size(values(ϕ))) for I in CartesianIndices(coeffs) @@ -110,8 +116,8 @@ We use the formula κ = (Δϕ |∇ϕ|^2 - ∇ϕ^T Hϕ ∇ϕ) / |∇ϕ|^3 with first order finite differences. https://en.wikipedia.org/wiki/Mean_curvature#Implicit_form_of_mean_curvature """ -function curvature(ϕ::LevelSet, I) - N = dimension(ϕ) +function curvature(ϕ, I) + N = ndims(ϕ) if N == 2 ϕx = D⁰(ϕ, I, 1) ϕy = D⁰(ϕ, I, 2) @@ -166,12 +172,8 @@ contour!(xs, ys, values(ϕ); levels = [0.0]) ``` """ function curvature(ϕ::LevelSet) - if !has_boundary_conditions(ϕ) - N = dimension(ϕ) - bc = _normalize_bc(NeumannGradientBC(), N) - ϕ = add_boundary_conditions(ϕ, bc) - end - return [curvature(ϕ, I) for I in eachindex(ϕ)] + ϕ_ = _ensure_boundary_conditions(ϕ) + return [curvature(ϕ_, I) for I in eachindex(ϕ_)] end """ @@ -180,8 +182,8 @@ end Compute the gradient vector ``∇ϕ`` at grid index `I` using centered finite differences. Returns an `SVector` (or `Vector`) of derivatives. """ -function gradient(ϕ::LevelSet, I) - N = dimension(ϕ) +function gradient(ϕ, I::CartesianIndex) + N = ndims(ϕ) return [D⁰(ϕ, I, dim) for dim in 1:N] end @@ -199,7 +201,7 @@ end Compute the unit exterior normal vector ``\\mathbf{n} = \\frac{∇ϕ}{\\|∇ϕ\\|}`` at grid index `I`. """ -function normal(ϕ::LevelSet, I) +function normal(ϕ, I) ∇ϕ = gradient(ϕ, I) return ∇ϕ ./ norm(∇ϕ) end @@ -226,13 +228,8 @@ contour!(xs, ys, values(ϕ); levels = [0.0]) ``` """ function normal(ϕ::LevelSet) - # if no boundary conditions then we use homogenous Neumann - if !has_boundary_conditions(ϕ) - N = dimension(ϕ) - bc = _normalize_bc(NeumannGradientBC(), N) - ϕ = add_boundary_conditions(ϕ, bc) - end - return [normal(ϕ, I) for I in eachindex(ϕ)] + ϕ_ = _ensure_boundary_conditions(ϕ) + return [normal(ϕ_, I) for I in eachindex(ϕ_)] end """ @@ -241,8 +238,8 @@ end Compute the Hessian matrix ``\\mathbf{H}ϕ = ∇∇ϕ`` at grid index `I` using second-order finite differences. Returns a `Symmetric` matrix. """ -function hessian(ϕ::LevelSet, I) - N = dimension(ϕ) +function hessian(ϕ, I::CartesianIndex) + N = ndims(ϕ) return Symmetric([i == j ? D2⁰(ϕ, I, i) : D2(ϕ, I, (i, j)) for i in 1:N, j in 1:N]) end @@ -255,6 +252,19 @@ function hessian(ϕ::LevelSet) return [hessian(ϕ, I) for I in eachindex(ϕ)] end +""" + grad_norm(ϕ::LevelSet) + +Compute the norm of the gradient of ϕ, i.e. `|∇ϕ|`, at all grid points. +""" +function grad_norm(ϕ::LevelSet) + msg = """level-set must have boundary conditions to compute gradient. See + `add_boundary_conditions`.""" + has_boundary_conditions(ϕ) || error(msg) + idxs = eachindex(ϕ) + return map(i -> _compute_∇_norm(sign(ϕ[i]), ϕ, i), idxs) +end + #= Predefined implicit shapes for creating level set functions. @@ -271,7 +281,7 @@ Create a 2D circle with the specified `center` and `radius` on a `grid`. Returns a [`LevelSet`](@ref) field. """ function circle(grid; center = (0, 0), radius = 1) - dimension(grid) == 2 || + ndims(grid) == 2 || throw(ArgumentError("circle shape is only available in two dimensions")) return LevelSet(x -> sqrt(sum((x .- center) .^ 2)) - radius, grid) end @@ -293,7 +303,7 @@ Create a 3D sphere with the specified `center` and `radius` on a `grid`. Returns a [`LevelSet`](@ref) field. """ function sphere(grid; center = (0, 0, 0), radius) - dimension(grid) == 3 || + ndims(grid) == 3 || throw(ArgumentError("sphere shape is only available in three dimensions")) return LevelSet(x -> sqrt(sum((x .- center) .^ 2)) - radius, grid) end @@ -305,7 +315,7 @@ Create a 2D star shape defined in polar coordinates by ``r = R(1 + d \\cos(nθ)) Returns a [`LevelSet`](@ref) field. """ function star(grid; radius = 1, deformation = 0.25, n = 5.0) - # dimension(grid) == 2 || + # ndims(grid) == 2 || # throw(ArgumentError("star shape is only available in two dimensions")) return LevelSet(grid) do x r = norm(x) @@ -334,7 +344,7 @@ Create a Zalesak disk (a circle with a rectangular slot cut out). Used for testing advection schemes. Returns a [`LevelSet`](@ref) field. """ function zalesak_disk(grid; center = (0, 0), radius = 0.5, width = 0.25, height = 1) - dimension(grid) == 2 || + ndims(grid) == 2 || throw(ArgumentError("zalesak disk shape is only available in two dimensions")) disk = circle(grid; center = center, radius = radius) rec = rectangle(grid; center = center .- (0, radius), width = (width, height)) diff --git a/src/levelsetequation.jl b/src/levelsetequation.jl index 66c8cff..d64504e 100644 --- a/src/levelsetequation.jl +++ b/src/levelsetequation.jl @@ -4,26 +4,26 @@ mutable struct LevelSetEquation state::MeshField t::Float64 reinit::Union{Nothing, NewtonReinitializer} - buffers + log::SimulationLog end """ - LevelSetEquation(; terms, levelset, bc, t = 0, integrator = RK2(), reinit = nothing) + LevelSetEquation(; terms, ic, bc, t = 0, integrator = RK2(), reinit = nothing) -Create a of a level-set equation of the form `ϕₜ + sum(terms) = 0`, where each `t ∈ terms` -is a [`LevelSetTerm`](@ref) and `levelset` is the initial [`LevelSet`](@ref). +Create a level-set equation of the form `ϕₜ + sum(terms) = 0`, where each `t ∈ terms` +is a [`LevelSetTerm`](@ref) and `ic` is the initial condition — either a [`LevelSet`](@ref) +for a full-grid discretization or a [`NarrowBandLevelSet`](@ref) for a narrow-band one. -Calling [`integrate!(ls, tf)`](@ref) will evolve the level-set equation up to time `tf`, -modifying the `current_state(eq)` and `current_time(eq)` of the object `eq` in the process -(and therefore the original `levelset`). +Calling [`integrate!(eq, tf)`](@ref) will evolve the equation up to time `tf`, modifying +`current_state(eq)` and `current_time(eq)` in place. -Boundary conditions can be specified in two ways. If a single `BoundaryCondition` is -provided, it will be applied uniformly to all boundaries of the domain. To apply different -boundary conditions to each boundary, pass a tuple of the form `(bc_x, bc_y, ...)` with as -many elements as dimensions in the domain. If `bc_x` is a `BoundaryCondition`, it will be -applied to both boundaries in the `x` direction. If `bc_x` is a tuple of two -`BoundaryCondition`s, the first will be applied to the left boundary and the second to the -right boundary. The same logic applies to the other dimensions. +Boundary conditions are specified via the required `bc` keyword. If a single +`BoundaryCondition` is provided, it will be applied uniformly to all boundaries of the +domain. To apply different boundary conditions to each boundary, pass a tuple of the form +`(bc_x, bc_y, ...)` with as many elements as dimensions in the domain. If `bc_x` is a +`BoundaryCondition`, it will be applied to both boundaries in the `x` direction. If `bc_x` +is a tuple of two `BoundaryCondition`s, the first will be applied to the left boundary and +the second to the right boundary. The same logic applies to the other dimensions. The optional parameter `t` specifies the initial time of the simulation, and `integrator` is the [`TimeIntegrator`](@ref) used to evolve the level-set equation. @@ -38,45 +38,54 @@ using LevelSetMethods, StaticArrays grid = CartesianGrid((-1, -1), (1, 1), (50, 50)) # define the grid ϕ = LevelSet(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) # initial shape 𝐮 = MeshField(x -> SVector(1, 0), grid) # advection velocity -terms = (AdvectionTerm(𝐮),) # advection and curvature terms -bc = PeriodicBC() # periodic boundary conditions -eq = LevelSetEquation(; terms, levelset = ϕ, bc) # level-set equation +terms = (AdvectionTerm(𝐮),) # advection term +bc = NeumannBC() # zero-gradient boundary conditions +eq = LevelSetEquation(; terms, ic = ϕ, bc) # level-set equation # output -Level-set equation given by - - ϕₜ + 𝐮 ⋅ ∇ ϕ = 0 - -Current time 0.0 +LevelSetEquation + ├─ equation: ϕₜ + 𝐮 ⋅ ∇ ϕ = 0 + ├─ time: 0.0 + ├─ integrator: RK2 (2nd order TVD Runge-Kutta, Heun's method) + │ └─ cfl: 0.5 + ├─ reinit: none + ├─ state: MeshField on CartesianGrid in ℝ² + │ ├─ domain: [-1.0, 1.0] × [-1.0, 1.0] + │ ├─ nodes: 50 × 50 + │ ├─ spacing: h = (0.04082, 0.04082) + │ ├─ bc: Degree 0 extrapolation (all) + │ ├─ eltype: Float64 + │ └─ values: min = -0.2492, max = 1.75 + ├─ log: SimulationLog (empty) + ╰─ ``` """ function LevelSetEquation(; terms, integrator = RK2(), - levelset, - t = 0, + ic::MeshField, bc, + t = 0, reinit = nothing, ) - N = dimension(levelset) - terms = _normalize_terms(terms, N) - bc = _normalize_bc(bc, N) + N = ndims(ic) + terms = _normalize_terms(terms) reinit = _normalize_reinit(reinit) - # append boundary conditions to the state - state = add_boundary_conditions(levelset, bc) - # create buffers for the time-integrator - nb = number_of_buffers(integrator) - buffers = ntuple(_ -> deepcopy(state), nb) - return LevelSetEquation(terms, integrator, state, t, reinit, buffers) + # bc is the authoritative source + has_boundary_conditions(ic) && + @warn "ic already has boundary conditions; these will be overwritten by bc" + state = add_boundary_conditions(ic, bc) + log = SimulationLog(t, terms) + return LevelSetEquation(terms, integrator, state, t, reinit, log) end _normalize_reinit(::Nothing) = nothing _normalize_reinit(r::NewtonReinitializer) = r _normalize_reinit(freq::Int) = NewtonReinitializer(; reinit_freq = freq) -function _normalize_terms(terms, dim) +function _normalize_terms(terms) if isa(terms, LevelSetTerm) # single term return (terms,) else @@ -91,45 +100,52 @@ function _normalize_terms(terms, dim) end end -function _normalize_bc(bc, dim) - if isa(bc, BoundaryCondition) - return ntuple(_ -> (bc, bc), dim) - else - length(bc) == dim || throw(ArgumentError("invalid number of boundary conditions")) - return ntuple(dim) do i - if isa(bc[i], BoundaryCondition) - return (bc[i], bc[i]) - else - length(bc[i]) == 2 && all(isa(bc[i][n], BoundaryCondition) for n in 1:2) || - throw(ArgumentError("invalid boundary condition for dimension $i")) - # check that periodic boundary conditions are not mixed with others - if any(isa(bc[i][n], PeriodicBC) for n in 1:2) - all(isa(bc[i][n], PeriodicBC) for n in 1:2) || throw( - ArgumentError( - "periodic boundary conditions cannot be mixed with others in dimension $i", - ), - ) - end - return (bc[i][1], bc[i][2]) - end - end +""" + _embed_show(io, label, obj; indent=" ") + +Print the `text/plain` representation of `obj` indented under `label` as a tree branch. +The first line becomes `├─ label:
`, and any remaining lines are indented with `│`. +""" +function _embed_show(io, label, obj; indent = " ") + str = sprint(show, MIME("text/plain"), obj) + parts = split(str, '\n') + println(io, "$(indent)├─ $label: $(parts[1])") + for line in @view parts[2:end] + println(io, "$(indent)│$line") end + return end -function Base.show(io::IO, eq::LevelSetEquation) - print(io, "Level-set equation given by\n") - print(io, "\n \t ϕₜ") - terms = eq.terms - for term in terms - print(io, " + ") - print(io, term) +function Base.show(io::IO, ::MIME"text/plain", eq::LevelSetEquation) + pde = "ϕₜ + " * join(sprint.(show, eq.terms), " + ") * " = 0" + println(io, "LevelSetEquation") + println(io, " ├─ equation: $pde") + println(io, " ├─ time: $(eq.t)") + _embed_show(io, "integrator", eq.integrator) + if !isnothing(eq.reinit) + _embed_show(io, "reinit", eq.reinit) + else + println(io, " ├─ reinit: none") end - print(io, " = 0") - print(io, "\n\nCurrent time $(eq.t)") + _embed_show(io, "state", eq.state) + _embed_show(io, "log", eq.log) + print(io, " ╰─") return io end -# getters +""" + reset_log!(eq::LevelSetEquation) + +Clear the simulation log of `eq`, resetting step count and all timing records. +""" +reset_log!(eq::LevelSetEquation) = reset_log!(eq.log, eq.t) + +# keep the compact (non-MIME) method for embedding in error messages etc. +function Base.show(io::IO, eq::LevelSetEquation) + pde = "ϕₜ + " * join(sprint.(show, eq.terms), " + ") * " = 0" + print(io, "LevelSetEquation($pde, t=$(eq.t))") + return io +end """ current_state(eq::LevelSetEquation) @@ -138,6 +154,9 @@ Return the current state of the level-set equation (a [`LevelSet`](@ref)). """ current_state(ls::LevelSetEquation) = ls.state +# Allow current_state on a bare MeshField so that plotting recipes work uniformly. +current_state(ϕ::MeshField) = ϕ + """ current_time(eq::LevelSetEquation) @@ -145,13 +164,6 @@ Return the current time of the simulation. """ current_time(ls::LevelSetEquation) = ls.t -""" - buffers(eq::LevelSetEquation) - -Return the internal buffers used by the time-integrator. -""" -buffers(ls::LevelSetEquation) = ls.buffers - """ time_integrator(eq::LevelSetEquation) @@ -171,7 +183,7 @@ terms(ls) = ls.terms Return the boundary conditions of the equation. """ -boundary_conditions(ls) = boundary_conditions(ls.state) +boundary_conditions(ls::LevelSetEquation) = boundary_conditions(ls.state) """ mesh(eq::LevelSetEquation) @@ -199,7 +211,7 @@ Reinitialize the current state of the level-set equation using its attached [`Ne """ function reinitialize!(eq::LevelSetEquation) r = reinitializer(eq) - isnothing(r) && error("no reinitializer specified in the equation.") + isnothing(r) && throw(ArgumentError("no reinitializer attached to the equation")) reinitialize!(current_state(eq), r) return eq end @@ -218,384 +230,14 @@ related to the `terms` and `integrator` employed. """ function integrate!(ls::LevelSetEquation, tf, Δt = Inf) tc = current_time(ls) - msg = "final time $(tf) must be larger than the initial time $(tc): - the level-set equation cannot be solved back in time" - @assert tf >= tc msg + tf >= tc || throw(ArgumentError("final time $tf must be ≥ initial time $tc: the level-set equation cannot be solved back in time")) # append boundary conditions for integration ϕ = current_state(ls) - buf = buffers(ls) integrator = time_integrator(ls) reinit = reinitializer(ls) # dynamic dispatch. Should not be a problem provided enough computation is # done inside of the function below - out = _integrate!(ϕ, buf, integrator, ls.terms, reinit, tc, tf, Δt) + _integrate!(ϕ, integrator, ls.terms, reinit, tc, tf, Δt, ls.log) ls.t = tf - # a copy may be needed if the last buffer is not the state - out === ϕ || copy!(values(ϕ), values(out)) return ls end - -number_of_buffers(fe::ForwardEuler) = 1 - -@noinline function _integrate!(ϕ, buffers, integrator::ForwardEuler, terms, reinit, tc, tf, Δt_max) - buffer = buffers[1] - α = cfl(integrator) - nsteps = 0 - while tc <= tf - eps(tc) - reinitialize!(ϕ, reinit, nsteps) - # update terms and compute an appropriate time-step - _update_terms!(terms, ϕ, tc) - Δt_cfl = α * compute_cfl(terms, ϕ, tc) - Δt = min(Δt_max, Δt_cfl, tf - tc) - for I in eachindex(ϕ) - buffer[I] = _compute_terms(terms, ϕ, I, tc) - buffer[I] = ϕ[I] - Δt * buffer[I] # muladd? - end - ϕ, buffer = buffer, ϕ # swap the roles, no copies - tc += Δt - nsteps += 1 - @debug tc, Δt - end - # @assert tc ≈ tf - return ϕ -end - -number_of_buffers(fe::RK2) = 2 - -@noinline function _integrate!(ϕ::LevelSet, buffers, integrator::RK2, terms, reinit, tc, tf, Δt_max) - # Heun's method - α = cfl(integrator) - buffer1, buffer2 = buffers[1], buffers[2] - nsteps = 0 - while tc <= tf - eps(tc) - reinitialize!(ϕ, reinit, nsteps) - - # update terms and compute an appropriate time-step - _update_terms!(terms, ϕ, tc) - Δt_cfl = α * compute_cfl(terms, ϕ, tc) - Δt = min(Δt_max, Δt_cfl, tf - tc) - - for I in eachindex(ϕ) - tmp = _compute_terms(terms, ϕ, I, tc) - buffer1[I] = ϕ[I] - Δt * tmp - buffer2[I] = ϕ[I] - 0.5 * Δt * tmp - end - _update_terms!(terms, buffer1, tc + Δt) - for I in eachindex(ϕ) - tmp = _compute_terms(terms, buffer1, I, tc + Δt) - buffer2[I] -= 0.5 * Δt * tmp - end - ϕ, buffer1, buffer2 = buffer2, ϕ, buffer1 # swap the roles, no copies - tc += Δt - nsteps += 1 - @debug tc, Δt - end - # @assert tc ≈ tf - return ϕ -end - -number_of_buffers(fe::RK3) = 2 - -function _integrate!(ϕ::LevelSet, buffers, integrator::RK3, terms, reinit, tc, tf, Δt_max) - buffer1, buffer2 = buffers - α = cfl(integrator) - nsteps = 0 - while tc <= tf - eps(tc) - reinitialize!(ϕ, reinit, nsteps) - # update terms and compute an appropriate time-step - _update_terms!(terms, ϕ, tc) - Δt_cfl = α * compute_cfl(terms, ϕ, tc) - Δt = min(Δt_max, Δt_cfl, tf - tc) - for I in eachindex(ϕ) - tmp = _compute_terms(terms, ϕ, I, tc) - buffer1[I] = ϕ[I] - Δt * tmp - end - _update_terms!(terms, buffer1, tc + Δt) - for I in eachindex(ϕ) - tmp = _compute_terms(terms, buffer1, I, tc + Δt) - buffer2[I] = buffer1[I] - Δt * tmp - buffer2[I] = 1 / 4 * buffer2[I] + 3 / 4 * ϕ[I] - end - _update_terms!(terms, buffer2, tc + 1 / 2 * Δt) - for I in eachindex(ϕ) - tmp = _compute_terms(terms, buffer2, I, tc + 1 / 2 * Δt) - buffer1[I] = buffer2[I] - Δt * tmp - ϕ[I] = 1 / 3 * ϕ[I] + 2 / 3 * buffer1[I] - end - tc += Δt - nsteps += 1 - @debug tc, Δt - end - # @assert tc ≈ tf - return ϕ -end - -number_of_buffers(::SemiImplicitI2OE) = 1 - -function _integrate!( - ϕ::LevelSet, - buffers, - integrator::SemiImplicitI2OE, - terms, - reinit, - tc, - tf, - Δt_max, - ) - _validate_i2oe_setup(ϕ, terms) - term = only(terms) - vals = values(ϕ) - old_vals = values(buffers[1]) - T = float(eltype(vals)) - N = dimension(ϕ) - velocity_components = ntuple(_ -> zeros(T, size(vals)), N) - - α = cfl(integrator) - nsteps = 0 - while tc <= tf - eps(tc) - reinitialize!(ϕ, reinit, nsteps) - _update_terms!(terms, ϕ, tc) - Δt_cfl = α * compute_cfl(terms, ϕ, tc) - Δt = min(Δt_max, Δt_cfl, tf - tc) - - copy!(old_vals, vals) - _fill_advection_velocity_components!(velocity_components, term, ϕ, tc) - _i2oe_global_step!(vals, old_vals, velocity_components, ϕ, Δt) - - tc += Δt - nsteps += 1 - @debug tc, Δt - end - return ϕ -end - -function _validate_i2oe_setup(ϕ::LevelSet, terms) - length(terms) == 1 && first(terms) isa AdvectionTerm || throw( - ArgumentError("SemiImplicitI2OE requires exactly one AdvectionTerm"), - ) - all(size(values(ϕ)) .>= 3) || throw( - ArgumentError("SemiImplicitI2OE requires at least 3 grid nodes along each dimension"), - ) - return nothing -end - -function _fill_advection_velocity_components!(out, term::AdvectionTerm{V}, ϕ, t) where {V} - vel = velocity(term) - N = dimension(ϕ) - if V <: MeshField - mesh(vel) == mesh(ϕ) || - throw(ArgumentError("advection velocity field must be defined on the same mesh")) - for I in eachindex(ϕ) - vI = vel[I] - for dim in 1:N - out[dim][I] = vI[dim] - end - end - elseif V <: Function - g = mesh(ϕ) - for I in eachindex(ϕ) - vI = vel(g[I], t) - for dim in 1:N - out[dim][I] = vI[dim] - end - end - else - error("velocity field type $V not supported") - end - return out -end - -function _i2oe_global_step!(vals, old_vals, velocity_components, ϕ, Δt) - # Coupled I2OE update: solve one global sparse system built from all neighbors. - T = eltype(vals) - Δ = meshsize(ϕ) - N = dimension(ϕ) - mₚ = prod(Δ) - fac = T(Δt / (2 * mₚ)) - bcs = boundary_conditions(ϕ) - grid = mesh(ϕ) - LI = LinearIndices(vals) - nb_nodes = length(vals) - rows = Int[] - cols = Int[] - coeffs = T[] - rhs = zeros(T, nb_nodes) - sizehint!(rows, nb_nodes * (2N + 1)) - sizehint!(cols, nb_nodes * (2N + 1)) - sizehint!(coeffs, nb_nodes * (2N + 1)) - - for I in eachindex(ϕ) - row = LI[I] - uold_p = old_vals[I] - diag = one(T) - rhsp = uold_p - for dim in 1:N - area = _i2oe_face_measure(Δ, dim) - rel_m = _i2oe_neighbor_relation(I, dim, -1, vals, bcs, grid) - rel_p = _i2oe_neighbor_relation(I, dim, +1, vals, bcs, grid) - - vface_m = _i2oe_face_velocity(velocity_components[dim], I, rel_m, dim) - vface_p = _i2oe_face_velocity(velocity_components[dim], I, rel_p, dim) - - a_m = area * vface_m - a_p = -area * vface_p - diag, rhsp = _i2oe_add_side_contrib!( - rows, - cols, - coeffs, - LI, - old_vals, - row, - rel_m, - a_m, - fac, - diag, - rhsp, - uold_p, - ) - diag, rhsp = _i2oe_add_side_contrib!( - rows, - cols, - coeffs, - LI, - old_vals, - row, - rel_p, - a_p, - fac, - diag, - rhsp, - uold_p, - ) - end - push!(rows, row) - push!(cols, row) - push!(coeffs, diag) - rhs[row] = rhsp - end - - A = sparse(rows, cols, coeffs, nb_nodes, nb_nodes) - copy!(vals, reshape(A \ rhs, size(vals))) - return vals -end - -function _i2oe_add_side_contrib!( - rows, - cols, - coeffs, - LI, - old_vals, - row, - rel, - a, - fac, - diag, - rhsp, - uold_p, - ) - ain = max(a, zero(a)) - aout = min(a, zero(a)) - - α, β, idx, γ = rel - if ain != 0 - diag += fac * ain * (1 - α) - if β != 0 - push!(rows, row) - push!(cols, LI[idx]) - push!(coeffs, -fac * ain * β) - end - rhsp += fac * ain * γ - end - - if aout != 0 - uold_q = _i2oe_neighbor_value(old_vals, uold_p, rel) - rhsp -= fac * aout * (uold_p - uold_q) - end - return diag, rhsp -end - -function _i2oe_neighbor_value(old_vals, uold_p, rel) - α, β, idx, γ = rel - uold_idx = isnothing(idx) ? zero(uold_p) : old_vals[idx] - return α * uold_p + β * uold_idx + γ -end - -function _i2oe_neighbor_relation(I, dim, side, vals, bcs, grid) - T = float(eltype(vals)) - N = ndims(vals) - ax = axes(vals, dim) - Ioff = side < 0 ? _decrement_index(I, dim) : _increment_index(I, dim) - if Ioff[dim] in ax - return (zero(T), one(T), Ioff, zero(T)) - end - - bc = side < 0 ? bcs[dim][1] : bcs[dim][2] - if bc isa PeriodicBC - Iq = _wrap_index_periodic(Ioff, ax, dim) - return (zero(T), one(T), Iq, zero(T)) - elseif bc isa NeumannBC - Iq = CartesianIndex(ntuple(s -> s == dim ? clamp(Ioff[s], first(ax), last(ax)) : Ioff[s], N)) - return (zero(T), one(T), Iq, zero(T)) - elseif bc isa NeumannGradientBC - i, a, b = Ioff[dim], first(ax), last(ax) - Ion_d, Iin_d, dist = i < a ? (a, a + 1, a - i) : (b, b - 1, i - b) - Ion = CartesianIndex(ntuple(s -> s == dim ? Ion_d : Ioff[s], N)) - Iin = CartesianIndex(ntuple(s -> s == dim ? Iin_d : Ioff[s], N)) - Ion == I || throw( - ArgumentError("SemiImplicitI2OE expected nearest ghost cell for NeumannGradientBC"), - ) - return (one(T) + T(dist), -T(dist), Iin, zero(T)) - elseif bc isa DirichletBC - xghost = _getindex(grid, Ioff) - return (zero(T), zero(T), nothing, T(bc.f(xghost))) - else - error("boundary condition $bc is not supported by SemiImplicitI2OE") - end -end - -function _i2oe_face_velocity(velcomp, I, rel, dim) - α, β, idx, _ = rel - if isnothing(idx) || α != 0 || β != 1 - return velcomp[I] - end - return 0.5 * (velcomp[I] + velcomp[idx]) -end - -function _i2oe_face_measure(Δ, dim) - N = length(Δ) - N == 1 && return one(eltype(Δ)) - return prod(Δ[d] for d in 1:N if d != dim) -end - -function _compute_terms(terms, ϕ, I, t) - return sum(terms) do term - return _compute_term(term, ϕ, I, t) - end -end - -_update_terms!(terms, ϕ, t) = foreach(term -> update_term!(term, ϕ, t), terms) - -""" - update_term!(term::LevelSetTerm, ϕ, t) - -Update the internal state of a `LevelSetTerm` before computing its contribution. -This is called at each stage of the time integration. -""" -update_term!(term::LevelSetTerm, ϕ, t) = nothing - -""" - grad_norm(ϕ::LevelSet[, I]) - -Compute the norm of the gradient of ϕ at index `I`, i.e. `|∇ϕ|`, or for all grid points -if `I` is not provided. -""" -function grad_norm(ϕ::LevelSet) - msg = """level-set must have boundary conditions to compute gradient. See - `add_boundary_conditions`.""" - has_boundary_conditions(ϕ) || error(msg) - idxs = eachindex(ϕ) - return map(i -> _compute_∇_norm(sign(ϕ[i]), ϕ, i), idxs) -end -function grad_norm(eq::LevelSetEquation) - return grad_norm(current_state(eq)) -end diff --git a/src/levelsetterms.jl b/src/levelsetterms.jl index d92ca3c..4f0b8ef 100644 --- a/src/levelsetterms.jl +++ b/src/levelsetterms.jl @@ -5,6 +5,14 @@ A typical term in a level-set evolution equation. """ abstract type LevelSetTerm end +""" + update_term!(term::LevelSetTerm, ϕ, t) + +Update the internal state of a `LevelSetTerm` before computing its contribution. This is +called at each stage of the time integration. +""" +update_term!(::LevelSetTerm, _, _) = nothing + """ compute_cfl(terms, ϕ::LevelSet, t) @@ -47,7 +55,7 @@ If passed, `update_func` will be called as `update_func(𝐮, ϕ, t)` before com at each stage of the time evolution. This can be used to update the velocity field `𝐮` depending not only on `t`, but also on the current level set `ϕ`. """ -AdvectionTerm(𝐮, scheme = WENO5(), func = (x...) -> nothing) = AdvectionTerm(𝐮, scheme, func) +AdvectionTerm(𝐮, scheme = WENO5(), func = (_...) -> nothing) = AdvectionTerm(𝐮, scheme, func) function update_term!(term::AdvectionTerm, ϕ, t) u = velocity(term) @@ -55,11 +63,11 @@ function update_term!(term::AdvectionTerm, ϕ, t) return f(u, ϕ, t) end -Base.show(io::IO, t::AdvectionTerm) = print(io, "𝐮 ⋅ ∇ ϕ") +Base.show(io::IO, _::AdvectionTerm) = print(io, "𝐮 ⋅ ∇ ϕ") -@inline function _compute_term(term::AdvectionTerm{V}, ϕ, I, t) where {V} +@inline function _compute_term(term::AdvectionTerm{V}, ϕ::MeshField, I, t) where {V} sch = scheme(term) - N = dimension(ϕ) + N = ndims(ϕ) 𝐮 = if V <: MeshField velocity(term)[I] elseif V <: Function @@ -107,7 +115,7 @@ function _compute_cfl(term::AdvectionTerm{V}, ϕ, I, t) where {V} end """ - struct CurvatureTerm{V,M} <: LevelSetTerm + struct CurvatureTerm{V} <: LevelSetTerm Level-set curvature term representing `bκ|∇ϕ|`, where `κ = ∇ ⋅ (∇ϕ/|∇ϕ|) ` is the curvature. @@ -117,10 +125,10 @@ struct CurvatureTerm{V} <: LevelSetTerm end coefficient(cterm::CurvatureTerm) = cterm.b -Base.show(io::IO, t::CurvatureTerm) = print(io, "b κ|∇ϕ|") +Base.show(io::IO, _::CurvatureTerm) = print(io, "b κ|∇ϕ|") -function _compute_term(term::CurvatureTerm, ϕ, I, t) - N = dimension(ϕ) +function _compute_term(term::CurvatureTerm, ϕ::MeshField, I, t) + N = ndims(ϕ) κ = curvature(ϕ, I) b = coefficient(term) bI = if b isa MeshField @@ -165,12 +173,12 @@ at each stage of the time evolution. """ @kwdef struct NormalMotionTerm{V, F} <: LevelSetTerm speed::V - update_func::F = (x...) -> nothing + update_func::F = (_...) -> nothing end speed(adv::NormalMotionTerm) = adv.speed update_func(term::NormalMotionTerm) = term.update_func -NormalMotionTerm(v) = NormalMotionTerm(v, (x...) -> nothing) +NormalMotionTerm(v) = NormalMotionTerm(v, (_...) -> nothing) function update_term!(term::NormalMotionTerm, ϕ, t) v = speed(term) @@ -178,10 +186,10 @@ function update_term!(term::NormalMotionTerm, ϕ, t) return f(v, ϕ, t) end -Base.show(io::IO, t::NormalMotionTerm) = print(io, "v|∇ϕ|") +Base.show(io::IO, _::NormalMotionTerm) = print(io, "v|∇ϕ|") -function _compute_term(term::NormalMotionTerm, ϕ, I, t) - N = dimension(ϕ) +function _compute_term(term::NormalMotionTerm, ϕ::MeshField, I, t) + N = ndims(ϕ) u = speed(term) v = if u isa MeshField u[I] @@ -221,21 +229,9 @@ end @inline positive(x) = x > zero(x) ? x : zero(x) @inline negative(x) = x < zero(x) ? x : zero(x) -# eq. (6.20-6.21) -function g(x, y) - tmp = zero(x) - if x > zero(x) - tmp += x * x - end - if y < zero(x) - tmp += y * y - end - return sqrt(tmp) -end - -# eq. (6.28) +# eq. (6.28): Minmod limiter — zero when signs differ (TVD), min(|x|,|y|)*sign when same sign function limiter(x, y) - x * y < zero(x) || return zero(x) + x * y > zero(x) || return zero(x) return abs(x) <= abs(y) ? x : y end @@ -284,7 +280,7 @@ function Base.show(io::IO, t::EikonalReinitializationTerm) return io end -function _compute_term(term::EikonalReinitializationTerm, ϕ, I, t) +function _compute_term(term::EikonalReinitializationTerm, ϕ, I, _t) S₀ = term.S₀ if isnothing(S₀) # equation 7.6 of Osher and Fedkiw: sign computed from current ϕ @@ -299,11 +295,10 @@ function _compute_term(term::EikonalReinitializationTerm, ϕ, I, t) return S * (norm_∇ϕ - 1.0) end -_compute_cfl(term::EikonalReinitializationTerm, ϕ, I, t) = minimum(meshsize(ϕ)) +_compute_cfl(::EikonalReinitializationTerm, ϕ, _I, _t) = minimum(meshsize(ϕ)) function _compute_∇_norm(v, ϕ, I) - # FIXME: use version from NormalTerm - N = dimension(ϕ) + N = ndims(ϕ) mA0², mB0² = sum(1:N) do dim h = meshsize(ϕ, dim) A = D⁻(ϕ, I, dim) + 0.5 * h * limiter(D2⁻⁻(ϕ, I, dim), D2⁰(ϕ, I, dim)) diff --git a/src/logging.jl b/src/logging.jl new file mode 100644 index 0000000..aaa55b4 --- /dev/null +++ b/src/logging.jl @@ -0,0 +1,154 @@ +""" + struct StepRecord + +Record of a single time step during level-set integration. + +- `step`: cumulative step number across all `integrate!` calls (1-indexed) +- `t`: simulation time at end of step +- `wall_time`: total wall-clock time for this step (seconds) +- `reinit_time`: time spent in reinitialization (0.0 if not performed) +- `did_reinit`: whether reinitialization was performed this step +- `update_times`: time per `update_term!`, summed over all RK stages (seconds) +- `compute_times`: time per `_compute_term` loop, summed over all RK stages (seconds) +- `ϕ_min`, `ϕ_max`: level-set extrema at end of step +""" +struct StepRecord + step::Int + t::Float64 + wall_time::Float64 + reinit_time::Float64 + did_reinit::Bool + update_times::Vector{Float64} + compute_times::Vector{Float64} + ϕ_min::Float64 + ϕ_max::Float64 +end + +""" + mutable struct SimulationLog + +Accumulates per-step timing and progress data for a [`LevelSetEquation`](@ref). Persists +and accumulates across multiple calls to [`integrate!`](@ref). Use [`reset_log!`](@ref) to +clear the history. +""" +mutable struct SimulationLog + t0::Float64 + term_labels::Vector{String} + records::Vector{StepRecord} +end + +SimulationLog(t0, terms) = SimulationLog(Float64(t0), [sprint(show, t) for t in terms], StepRecord[]) + +""" + reset_log!(log::SimulationLog, t0 = log.t0) + +Clear all records from `log` and optionally reset the initial time. +""" +function reset_log!(log::SimulationLog, t0 = log.t0) + log.t0 = Float64(t0) + empty!(log.records) + return log +end + +function Base.show(io::IO, ::MIME"text/plain", log::SimulationLog) + records = log.records + if isempty(records) + print(io, "SimulationLog (empty)") + return io + end + + nsteps = length(records) + t0 = log.t0 + tf = records[end].t + total_wall = sum(r.wall_time for r in records) + avg_wall_ms = total_wall / nsteps * 1.0e3 + + # Derive Δt from consecutive t values + Δts = Vector{Float64}(undef, nsteps) + Δts[1] = records[1].t - t0 + for i in 2:nsteps + Δts[i] = records[i].t - records[i - 1].t + end + + n_reinit = count(r -> r.did_reinit, records) + + println(io, "SimulationLog: $nsteps steps, t ∈ [$(round(t0; sigdigits = 4)), $(round(tf; sigdigits = 4))]") + println(io, " ├─ wall time: $(round(total_wall; sigdigits = 4)) s (avg $(round(avg_wall_ms; sigdigits = 4)) ms/step)") + + if n_reinit > 0 + total_reinit = sum(r.reinit_time for r in records) + avg_reinit_ms = total_reinit / n_reinit * 1.0e3 + reinit_pct = round(100 * total_reinit / total_wall; sigdigits = 3) + println(io, " ├─ reinit: $n_reinit calls, avg $(round(avg_reinit_ms; sigdigits = 4)) ms ($reinit_pct% of total)") + else + println(io, " ├─ reinit: none") + end + + for (i, label) in enumerate(log.term_labels) + avg_update_ms = sum(r.update_times[i] for r in records) / nsteps * 1.0e3 + avg_compute_ms = sum(r.compute_times[i] for r in records) / nsteps * 1.0e3 + println(io, " ├─ $label: avg $(round(avg_update_ms; sigdigits = 4)) ms update, $(round(avg_compute_ms; sigdigits = 4)) ms compute / step") + end + + last = records[end] + println(io, " ├─ ϕ range: [$(round(last.ϕ_min; sigdigits = 4)), $(round(last.ϕ_max; sigdigits = 4))]") + + Δt_min = minimum(Δts) + Δt_max = maximum(Δts) + Δt_avg = sum(Δts) / nsteps + print(io, " └─ Δt: min=$(round(Δt_min; sigdigits = 4)) max=$(round(Δt_max; sigdigits = 4)) avg=$(round(Δt_avg; sigdigits = 4))") + return io +end + +""" + _timed_update_terms!(terms, ϕ, t, update_times) + +Run `update_term!` for each term, accumulating the elapsed time per term into `update_times`. +""" +function _timed_update_terms!(terms, ϕ, t, update_times) + for i in eachindex(terms) + t0 = time_ns() + update_term!(terms[i], ϕ, t) + update_times[i] += (time_ns() - t0) / 1.0e9 + end + return +end + +""" + _timed_reinit!(ϕ, reinit, nsteps) -> (elapsed, did_reinit) + +Run `reinitialize!` if due at this step, returning the elapsed time and whether it ran. +""" +_timed_reinit!(_, ::Nothing, _) = (0.0, false) +function _timed_reinit!(ϕ, reinit, nsteps) + mod(nsteps, reinit.reinit_freq) == 0 || return (0.0, false) + t0 = time_ns() + reinitialize!(ϕ, reinit) + return ((time_ns() - t0) / 1.0e9, true) +end + +""" + _level_set_extrema(src) -> (ϕ_min, ϕ_max) + +Compute the extrema of the level-set values. Handles both `Array` and `Dict` storage. +""" +function _level_set_extrema(src) + v = values(src) + vals_iter = v isa AbstractDict ? Base.values(v) : v + return extrema(vals_iter) +end + +""" + _push_record!(log, tc, t_step, reinit_time, did_reinit, update_times, compute_times, ϕ_min, ϕ_max) + +Add a new `StepRecord` to `log`. +""" +function _push_record!(log, tc, t_step, reinit_time, did_reinit, update_times, compute_times, ϕ_min, ϕ_max) + wall_time = (time_ns() - t_step) / 1.0e9 + return push!( + log.records, StepRecord( + length(log.records) + 1, tc, wall_time, reinit_time, did_reinit, + copy(update_times), copy(compute_times), ϕ_min, ϕ_max, + ) + ) +end diff --git a/src/meshes.jl b/src/meshes.jl index 8fed4f7..eaeded6 100644 --- a/src/meshes.jl +++ b/src/meshes.jl @@ -28,15 +28,18 @@ grid = CartesianGrid(a, b, n) # output -CartesianGrid{2, Int64}([0, 0], [1, 1], (10, 4)) +CartesianGrid in ℝ² + ├─ domain: [0.0, 1.0] × [0.0, 1.0] + ├─ nodes: 10 × 4 + └─ spacing: h = (0.1111, 0.3333) ``` """ function CartesianGrid(lc, hc, n) length(lc) == length(hc) == length(n) || throw(ArgumentError("all arguments must have the same length")) N = length(lc) - lc_ = SVector{N}(lc) - hc_ = SVector{N}(hc) + lc_ = SVector{N}(float.(lc)) + hc_ = SVector{N}(float.(hc)) n_ = ntuple(i -> Int(n[i]), N) return CartesianGrid(promote(lc_, hc_)..., n_) end @@ -50,7 +53,7 @@ If `dim` is not provided, return a tuple of `LinRange`s for all dimensions. grid1d(g::CartesianGrid{N}) where {N} = ntuple(i -> grid1d(g, i), N) grid1d(g::CartesianGrid, dim) = LinRange(g.lc[dim], g.hc[dim], g.n[dim]) -dimension(g::CartesianGrid{N}) where {N} = N +Base.ndims(g::CartesianGrid{N}) where {N} = N xgrid(g::CartesianGrid) = grid1d(g, 1) ygrid(g::CartesianGrid) = grid1d(g, 2) @@ -78,7 +81,7 @@ Base.getindex(g::CartesianGrid, I::Int...) = g[CartesianIndex(I...)] Base.eltype(g::CartesianGrid) = typeof(g.lc) function _getindex(g::CartesianGrid, I::CartesianIndex) - N = dimension(g) + N = ndims(g) @assert N == length(I) return ntuple(N) do dim return g.lc[dim] + (I[dim] - 1) / (g.n[dim] - 1) * (g.hc[dim] - g.lc[dim]) @@ -89,16 +92,46 @@ _getindex(g::CartesianGrid, I::Int...) = _getindex(g, CartesianIndex(I...)) Base.CartesianIndices(g::CartesianGrid) = CartesianIndices(size(g)) Base.eachindex(g::CartesianGrid) = CartesianIndices(g) -# NOTE: remove? -function interior_indices(g::CartesianGrid, P) - N = dimension(g) - sz = size(g) - I = ntuple(N) do dim - return (P + 1):(sz[dim] - P) - end - return CartesianIndices(I) +""" + nodeindices(g::CartesianGrid) + +Return a `CartesianIndices` ranging over all node indices of `g`. +Nodes are indexed `1:n[d]` in each dimension `d`. +""" +nodeindices(g::CartesianGrid) = CartesianIndices(g) + +""" + cellindices(g::CartesianGrid) + +Return a `CartesianIndices` ranging over all cell indices of `g`. +Cell `I` is the hypercube bounded by nodes `I` and `I + 1` in each dimension. +Cells are indexed `1:n[d]-1` in each dimension `d`. +""" +cellindices(g::CartesianGrid{N}) where {N} = CartesianIndices(ntuple(d -> 1:(g.n[d] - 1), Val(N))) + +""" + struct CartesianCell{N, T} + +A cell of a `CartesianGrid`: the axis-aligned hypercube bounded by nodes at `lc` (lower +corner) and `hc` (upper corner). Obtain via `getcell(grid, I)` where `I` is a cell index. +""" +struct CartesianCell{N, T} + lc::SVector{N, T} + hc::SVector{N, T} +end + +""" + getcell(g::CartesianGrid, I::CartesianIndex) + +Return the `CartesianCell` with lower corner at node `I` and upper corner at node `I+1`. +`I` must be a valid cell index, i.e. `I ∈ cellindices(g)`. +""" +function getcell(g::CartesianGrid{N}, I::CartesianIndex{N}) where {N} + lc = g[I] + return CartesianCell(lc, lc .+ meshsize(g)) end + # iterate over all nodes function Base.iterate(g::CartesianGrid) i = first(CartesianIndices(g)) @@ -118,3 +151,49 @@ end # Base.IteratorSize(::Type{CartesianGrid{N}}) where {N} = Base.HasShape{N}() Base.IteratorSize(::CartesianGrid{N}) where {N} = Base.HasShape{N}() + +# --- Display --- + +""" + _superscript(n::Int) -> String + +Convert an integer to its Unicode superscript representation, e.g. `2` → `"²"`. +""" +function _superscript(n::Int) + sups = ('⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹') + return join(sups[d + 1] for d in reverse(digits(n; base = 10))) +end + +""" + _domain_str(g::CartesianGrid) -> String + +Format the domain as `"[lo, hi] × [lo, hi] × …"`. +""" +function _domain_str(g::CartesianGrid{N}) where {N} + ranges = ntuple(d -> "[$(g.lc[d]), $(g.hc[d])]", N) + return join(ranges, " × ") +end + +""" + _show_fields(io, g::CartesianGrid; prefix=" ", last=true) + +Print domain, nodes, and spacing of `g` as indented tree lines. +When `last=true` the spacing line uses `└─` (terminal); otherwise `├─` (continuing). +""" +function _show_fields(io::IO, g::CartesianGrid{N}; prefix = " ", last = true) where {N} + h = meshsize(g) + h_str = "(" * join(round.(h; sigdigits = 4), ", ") * ")" + println(io, "$(prefix)├─ domain: $(_domain_str(g))") + println(io, "$(prefix)├─ nodes: $(join(g.n, " × "))") + connector = last ? "└─" : "├─" + return if last + print(io, "$(prefix)$connector spacing: h = $h_str") + else + println(io, "$(prefix)$connector spacing: h = $h_str") + end +end + +function Base.show(io::IO, ::MIME"text/plain", g::CartesianGrid{N}) where {N} + println(io, "CartesianGrid in ℝ$(_superscript(N))") + return _show_fields(io, g) +end diff --git a/src/meshfield.jl b/src/meshfield.jl index 3ab9b8f..af5abfb 100644 --- a/src/meshfield.jl +++ b/src/meshfield.jl @@ -1,115 +1,183 @@ """ - struct MeshField{V,M,B} + abstract type AbstractDomain end + +Abstract type for the domain of a [`MeshField`](@ref). +""" +abstract type AbstractDomain end + +""" + struct FullDomain <: AbstractDomain + +Represents a field defined on the entire mesh. +""" +struct FullDomain <: AbstractDomain end + +""" + struct NarrowBandDomain{T} <: AbstractDomain + +Domain for a narrow-band level set. + +- `halfwidth`: half-width of the narrow band, typically on the order of a few grid spacings. +- `extrap_order`: polynomial order used to extrapolate values at indices inside the grid but + outside the band (default: `1`). + +Active indices are the keys of the associated values dict and need not be stored separately. +""" +struct NarrowBandDomain{T} <: AbstractDomain + halfwidth::T + extrap_order::Int +end + + +""" + struct MeshField{V,M,B,D} A field described by its discrete values on a mesh. -- `vals`: the discrete values of the field (typically an `AbstractArray`). +- `vals`: the discrete values of the field. - `mesh`: the underlying mesh (e.g. [`CartesianGrid`](@ref)). - `bcs`: boundary conditions, used for indexing outside the mesh bounds. +- `domain`: the domain on which the field is defined (e.g. [`FullDomain`](@ref)). `Base.getindex` of an `MeshField` is overloaded to handle indices that lie outside the `CartesianIndices` of its `MeshField` by using `bcs`. """ -struct MeshField{V, M, B} +struct MeshField{V, M, B, D <: AbstractDomain} vals::V mesh::M bcs::B + domain::D end # getters mesh(ϕ::MeshField) = ϕ.mesh Base.values(ϕ::MeshField) = ϕ.vals +domain(ϕ::MeshField) = ϕ.domain has_boundary_conditions(ϕ::MeshField) = !isnothing(ϕ.bcs) boundary_conditions(ϕ::MeshField) = ϕ.bcs meshsize(ϕ::MeshField, args...) = meshsize(mesh(ϕ), args...) """ - add_boundary_conditions(ϕ::MeshField, bcs) + add_boundary_conditions(ϕ::MeshField, bc) + +Return a new `MeshField` with `bc` as boundary conditions. All of the underlying data is +aliased (shared) with the original `MeshField`. +""" +function add_boundary_conditions(ϕ::MeshField, bc) + N = ndims(ϕ) + return MeshField(values(ϕ), mesh(ϕ), _normalize_bc(bc, N), domain(ϕ)) +end -Return a new `MeshField` with the given boundary conditions `bcs`. -The underlying data `values(ϕ)` is aliased (shared) with the original field. """ -add_boundary_conditions(ϕ::MeshField, bcs) = MeshField(values(ϕ), mesh(ϕ), bcs) + update_bcs!(ϕ::MeshField, t) + +Update the current time in all [`DirichletBC`](@ref) boundary conditions of `ϕ`. +Called automatically by the time-stepper at each stage. +""" +function update_bcs!(ϕ::MeshField, t) + has_boundary_conditions(ϕ) || (return ϕ) + for bc_pair in boundary_conditions(ϕ) + update_bc!(bc_pair[1], t) + update_bc!(bc_pair[2], t) + end + return ϕ +end + +""" + MeshField(vals, mesh, bcs) + +Construct a `MeshField` with explicit values, mesh, and boundary conditions. +Defaults to `FullDomain`. +""" +MeshField(vals, mesh, bcs) = MeshField(vals, mesh, bcs, FullDomain()) """ MeshField(f::Function, m) Create a `MeshField` by evaluating a function `f` on a mesh `m`. + +# Examples + +```jldoctest; output = true +using LevelSetMethods, StaticArrays +grid = CartesianGrid((-1, -1), (1, 1), (5, 5)) +# scalar field without boundary conditions +MeshField(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + +# output + +MeshField on CartesianGrid in ℝ² + ├─ domain: [-1.0, 1.0] × [-1.0, 1.0] + ├─ nodes: 5 × 5 + ├─ spacing: h = (0.5, 0.5) + ├─ eltype: Float64 + └─ values: min = -0.25, max = 1.75 +``` + +```jldoctest; output = true +using LevelSetMethods, StaticArrays +grid = CartesianGrid((-1, -1), (1, 1), (5, 5)) +# vector-valued field +MeshField(x -> SVector(x[1], x[2]), grid) + +# output + +MeshField on CartesianGrid in ℝ² + ├─ domain: [-1.0, 1.0] × [-1.0, 1.0] + ├─ nodes: 5 × 5 + ├─ spacing: h = (0.5, 0.5) + └─ eltype: SVector{2, Float64} +``` """ -function MeshField(f::Function, m) +function MeshField(f::Function, m, bc = nothing) + bc_ = isnothing(bc) ? nothing : _normalize_bc(bc, ndims(m)) vals = map(f, m) - return MeshField(vals, m, nothing) + return MeshField(vals, m, bc_, FullDomain()) end # geometric dimension -dimension(f::MeshField) = dimension(mesh(f)) +Base.ndims(f::MeshField) = ndims(mesh(f)) + +# Base.length +Base.length(ϕ::MeshField) = length(eachindex(ϕ)) # overload base methods for convenience -function Base.getindex(ϕ::MeshField, I::CartesianIndex) - return if has_boundary_conditions(ϕ) - _getindex(ϕ, I) +function Base.getindex(ϕ::MeshField, I::CartesianIndex{N}) where {N} + if has_boundary_conditions(ϕ) + return _getindexbc(ϕ, I, N) else - getindex(values(ϕ), I) + return _base_lookup(ϕ, I) end end function Base.getindex(ϕ::MeshField, I...) return ϕ[CartesianIndex(I...)] end -function _getindex(ϕ::MeshField, I::CartesianIndex{N}) where {N} - return _getindexrec(ϕ, I, N) -end - -# Ghost value at out-of-bounds index I (in dimension `dim`) by P-point Lagrange -# extrapolation. A local coordinate is set up so that both boundaries look the -# same: b is the boundary node, k ≥ 1 the distance to the ghost, and d = ±1 -# steps into the interior. The P stencil nodes then sit at positions 0,1,…,P-1 -# and the ghost at -k: -# -# ghost b b+d b+2d … b+(P-1)d -# | | | | | -# -k 0 1 2 P-1 ← local coordinate -# -# d = +1 for the left boundary, -1 for the right — the flip makes the right -# boundary look identical to the left, so the same weights apply to both. -# Node values are fetched via _getindexrec(dim-1), so BCs in other dimensions -# are applied automatically (handles corner ghost points correctly). -function _apply_extrapolation_bc(ϕ, I::CartesianIndex{N}, ::ExtrapolationBC{P}, ax, dim) where {N, P} - k = I[dim] < first(ax) ? (first(ax) - I[dim]) : (I[dim] - last(ax)) - b = I[dim] < first(ax) ? first(ax) : last(ax) - d = I[dim] < first(ax) ? 1 : -1 # direction into the interior - result = zero(float(eltype(values(ϕ)))) - for j in 0:(P - 1) - Ij = ntuple(s -> s == dim ? b + d * j : I[s], Val(N)) |> CartesianIndex - Vj = _getindexrec(ϕ, Ij, dim - 1) - result += _lagrange_extrap_weight(j, k, P) * Vj - end - return result -end - # Recursive getindex with boundary conditions, processing one dimension per # call (dim = N down to 1). If I[dim] is in-bounds, recurse; if out-of-bounds, # apply the BC for that dimension then recurse to dim-1. Base case dim=0 does # the raw array lookup. Corner ghost points (out-of-bounds in multiple # dimensions) are handled correctly because each dimension's BC is applied in # turn. -function _getindexrec(ϕ, I, dim) - dim == 0 && return getindex(values(ϕ), I) +_base_lookup(ϕ::MeshField, I) = getindex(values(ϕ), I) + +function _getindexbc(ϕ::MeshField, I, dim) + dim == 0 && return _base_lookup(ϕ, I) bcs = boundary_conditions(ϕ)[dim] ax = axes(ϕ)[dim] - (I[dim] in axes(ϕ)[dim]) && (return _getindexrec(ϕ, I, dim - 1)) + (I[dim] in axes(ϕ)[dim]) && (return _getindexbc(ϕ, I, dim - 1)) bc = I[dim] < first(ax) ? bcs[1] : bcs[2] if bc isa PeriodicBC I′ = _wrap_index_periodic(I, ax, dim) - return _getindexrec(ϕ, I′, dim - 1) + return _getindexbc(ϕ, I′, dim - 1) elseif bc isa ExtrapolationBC return _apply_extrapolation_bc(ϕ, I, bc, ax, dim) elseif bc isa DirichletBC grid = mesh(ϕ) x = _getindex(grid, I) T = eltype(ϕ) - return T(bc.f(x)) + return T(bc.f(x, bc.t)) else error("Unknown boundary condition $bc") end @@ -129,49 +197,80 @@ function _wrap_index_periodic(I::CartesianIndex{N}, ax, dim) where {N} end |> CartesianIndex end +""" + _apply_extrapolation_bc(ϕ, I, ::ExtrapolationBC{P}, ax, dim) + +Return the extrapolated value of `ϕ` at out-of-bounds index `I` in dimension +`dim` using degree-P Lagrange extrapolation (see [`_lagrange_extrap_weight`](@ref)). +The boundary node and its `P` interior neighbors are used as stencil nodes. +""" +function _apply_extrapolation_bc(ϕ, I::CartesianIndex{N}, ::ExtrapolationBC{P}, ax, dim) where {N, P} + k = I[dim] < first(ax) ? (first(ax) - I[dim]) : (I[dim] - last(ax)) + b = I[dim] < first(ax) ? first(ax) : last(ax) + # d = ±1 flips direction so both boundaries map to the same local coordinate + d = I[dim] < first(ax) ? 1 : -1 + result = zero(float(eltype(ϕ))) + for j in 0:P + Ij = ntuple(s -> s == dim ? b + d * j : I[s], Val(N)) |> CartesianIndex + Vj = _getindexbc(ϕ, Ij, dim - 1) + result += _lagrange_extrap_weight(j, k, P) * Vj + end + return result +end + Base.setindex!(ϕ::MeshField, vals, I...) = setindex!(values(ϕ), vals, I...) +Base.eltype(ϕ::MeshField) = eltype(values(ϕ)) -function _get_index(ϕ::MeshField, I::CartesianIndex) - return axs = axes(ϕ) +function Base.axes(ϕ::MeshField) + sz = size(mesh(ϕ)) + return ntuple(d -> Base.OneTo(sz[d]), Val(ndims(ϕ))) end -Base.axes(ϕ::MeshField) = axes(values(ϕ)) -Base.eltype(ϕ::MeshField) = eltype(values(ϕ)) -Base.eachindex(ϕ::MeshField) = eachindex(mesh(ϕ)) +Base.eachindex(ϕ::MeshField) = _eachindex(domain(ϕ), ϕ) +_eachindex(::FullDomain, ϕ) = eachindex(mesh(ϕ)) """ - const CartesianMeshField{V,M<:CartesianGrid} = MeshField{V,M} + Base.copy!(dest::MeshField, src::MeshField) -[`MeshField`](@ref) over a [`CartesianGrid`](@ref). +Copy the values from `src` to `dest`. The meshes, boundary conditions, and domains of the +`dest` fields are not modified. """ -const CartesianMeshField{V, M <: CartesianGrid, B} = MeshField{V, M, B} +function Base.copy!(dest::MeshField, src::MeshField) + copy!(values(dest), values(src)) + return dest +end -# Boundary conditions +""" + _show_fields(io, ϕ::MeshField; prefix=" ") -function _getindex(ϕ::CartesianMeshField, I::CartesianIndex{N}, ::PeriodicBC, d) where {N} - ax = axes(ϕ)[abs(d)] - # compute mirror index to I[d] - i = I[abs(d)] - J = ntuple(N) do dir - if dir == abs(d) - d < 0 ? (last(ax) - (first(ax) - i)) : (first(ax) + (i - last(ax))) - else - I[dir] - end +Print the fields of `ϕ` as indented tree lines: grid info (via `_show_fields` for +`CartesianGrid`), boundary conditions, narrow-band info (if applicable), element type, +and value range (for real-valued fields). +""" +function _show_fields(io::IO, ϕ::MeshField{<:Any, <:CartesianGrid}; prefix = " ") + _show_fields(io, mesh(ϕ); prefix, last = false) + if has_boundary_conditions(ϕ) + println(io, "$(prefix)├─ bc: $(_bc_str(boundary_conditions(ϕ)))") + end + dom = domain(ϕ) + if dom isa NarrowBandDomain + hw = dom.halfwidth + nlayers = round(Int, hw / minimum(meshsize(mesh(ϕ)))) + println(io, "$(prefix)├─ active: $(length(active_indices(ϕ))) nodes ($nlayers layers, halfwidth = $(round(hw; sigdigits = 4)))") + end + return if eltype(ϕ) <: Real + v = values(ϕ) + vals_iter = v isa AbstractDict ? Base.values(v) : v + vmin, vmax = extrema(vals_iter) + println(io, "$(prefix)├─ eltype: $(eltype(ϕ))") + print(io, "$(prefix)└─ values: min = $(round(vmin; sigdigits = 4)), max = $(round(vmax; sigdigits = 4))") + else + print(io, "$(prefix)└─ eltype: $(eltype(ϕ))") end - return getindex(values(ϕ), CartesianIndex(J)) end -# TODO: test this -function _getindex( - ϕ::CartesianMeshField, - I::CartesianIndex{N}, - bc::DirichletBC, - d, - ) where {N} - # Compute the closest index to I that is within the domain and return value of bc there - Iproj = clamp.(Tuple(I), axes(ϕ)) |> CartesianIndex - x = mesh(ϕ)(Iproj) - return bc.f(x) +function Base.show(io::IO, ::MIME"text/plain", ϕ::MeshField{<:Any, <:CartesianGrid}) + println(io, "MeshField on CartesianGrid in ℝ$(_superscript(ndims(ϕ)))") + return _show_fields(io, ϕ) end diff --git a/src/reinitializer.jl b/src/reinitializer.jl index f2085fa..9a3bc13 100644 --- a/src/reinitializer.jl +++ b/src/reinitializer.jl @@ -21,7 +21,9 @@ function update! end A signed distance function to the zero level set of an underlying level set function, computed using a Newton-based closest point method. -Evaluating `sdf(x)` returns the signed distance from point `x` to the interface. +Evaluating `sdf(x)` returns the signed distance from point `x` to the interface. An +optional second argument `sdf(x, s)` can be used to supply the sign directly (e.g. +`sign(ϕ(x))`) when it is already known, avoiding an extra interpolant evaluation. !!! warning This implementation is **not thread-safe** because the underlying interpolant uses @@ -45,89 +47,128 @@ Return the interface sample points used to build the KDTree of `sdf`. """ get_sample_points(sdf::NewtonSDF) = sdf.pts +function Base.show(io::IO, ::MIME"text/plain", sdf::NewtonSDF) + println(io, "NewtonSDF") + println(io, " ├─ interface points: $(length(sdf.pts))") + println(io, " ├─ upsample: $(sdf.upsample)×") + println(io, " ├─ maxiters: $(sdf.maxiters)") + println(io, " ├─ xtol: $(sdf.xtol)") + return print(io, " └─ ftol: $(sdf.ftol)") +end + """ NewtonSDF(itp; upsample=8, maxiters=20, xtol=1e-8, ftol=1e-8) -Construct a [`NewtonSDF`](@ref) from a `PiecewisePolynomialInterpolation`. +Construct a [`NewtonSDF`](@ref) from a `PiecewisePolynomialInterpolant`. The interface is sampled by projecting uniformly-spaced points in each cell onto the zero level set. A KDTree is built from these samples for fast nearest-neighbor queries. # Keyword arguments -- `upsample`: number of sample subdivisions per cell side (default: 8) -- `maxiters`: maximum Newton iterations (default: 20) -- `xtol`: tolerance on the KKT residual (default: 1e-8) -- `ftol`: tolerance on the function value (default: 1e-8) +- `upsample`: sampling density per cell side. Larger values means a denser sampling of the + interface is used to build the `KDTree`, which in turn usually means a better initial + guess for the Newton solver. +- `maxiters`: maximum Newton iterations +- `xtol`: tolerance on iterate updates for convergence of the Newton solver +- `ftol`: tolerance on the function value for convergence of the Newton solver """ -function NewtonSDF(itp::PiecewisePolynomialInterpolation; upsample = 8, maxiters = 20, xtol = 1.0e-8, ftol = 1.0e-8) +function NewtonSDF( + itp::PiecewisePolynomialInterpolant; + upsample = 2, + maxiters = 10, + xtol = 1.0e-8, + ftol = 1.0e-8 + ) grid = mesh(itp.ϕ) - safeguard_dist = maximum(meshsize(grid)) - pts = _sample_interface(grid, itp, upsample, maxiters, ftol, safeguard_dist) + pts = _sample_interface(grid, itp, _candidate_cells(itp.ϕ), upsample, maxiters, ftol) tree = KDTree(pts) return NewtonSDF(itp, tree, pts, upsample, maxiters, xtol, ftol) end """ - NewtonSDF(ϕ::LevelSet; order=3, kwargs...) + NewtonSDF(ϕ; order=3, kwargs...) -Construct a [`NewtonSDF`](@ref) from a `LevelSet` by first creating a piecewise polynomial +Construct a [`NewtonSDF`](@ref) from a level set by first creating a piecewise polynomial interpolant of the given `order`. Additional keyword arguments are forwarded to -`NewtonSDF(itp; ...)`. +`NewtonSDF(itp; ...)`. Works for both [`LevelSet`](@ref) and [`NarrowBandLevelSet`](@ref), +sampling only the relevant candidate cells in each case. """ -function NewtonSDF(ϕ::LevelSet; order = 3, kwargs...) +function NewtonSDF(ϕ; order = 3, kwargs...) itp = interpolate(ϕ, order) return NewtonSDF(itp; kwargs...) end """ - update!(sdf::NewtonSDF, ϕ::LevelSet) + update!(sdf::NewtonSDF, ϕ) -Rebuild `sdf` in place from the new level set `ϕ`, reusing the same interpolation order, -upsample density, and solver tolerances. +Rebuild `sdf` in place from the new level set `ϕ`, reusing the existing interpolant +buffers, upsample density, and solver tolerances. """ -function update!(sdf::NewtonSDF, ϕ::LevelSet) - order = size(sdf.itp.mat, 1) - 1 # recover polynomial order from the matrix size - sdf.itp = interpolate(ϕ, order) +function update!(sdf::NewtonSDF, ϕ) + update!(sdf.itp, ϕ) grid = mesh(sdf.itp.ϕ) - safeguard_dist = maximum(meshsize(grid)) - sdf.pts = _sample_interface(grid, sdf.itp, sdf.upsample, sdf.maxiters, sdf.ftol, safeguard_dist) + sdf.pts = _sample_interface(grid, sdf.itp, _candidate_cells(sdf.itp.ϕ), sdf.upsample, sdf.maxiters, sdf.ftol) sdf.tree = KDTree(sdf.pts) return sdf end -function (sdf::NewtonSDF)(x) - cp = _closest_point_on_interface(sdf, x) - return sign(sdf.itp(x)) * norm(x - cp) +function (sdf::NewtonSDF)(x, s = sign(sdf.itp(x))) + cp, _ = _closest_point_on_interface(sdf, x) + return s * norm(x - cp) end -function _closest_point_on_interface(sdf::NewtonSDF, x) +""" + _closest_point_on_interface(sdf, x) + +Find the point on the interface closest to `x` by nearest-neighbor seeding into a +local Newton-Lagrange solve. Returns `(closest_point, converged)`. + +If the first solve does not converge (e.g. because the closest point lies on a +neighbouring polynomial patch), a single retry is attempted using the best iterate +from the failed solve as a new seed on its own patch. +""" +function _closest_point_on_interface(sdf::NewtonSDF, x, max_retries = 2) safeguard_dist = maximum(meshsize(mesh(sdf.itp.ϕ))) idx, _ = nn(sdf.tree, x) - x0 = sdf.pts[idx] - base_idxs = compute_index(sdf.itp, x0) - p = make_interpolant(sdf.itp, base_idxs) - return _closest_point(p, x, x0, sdf.maxiters, sdf.xtol, sdf.ftol, safeguard_dist) + cp = sdf.pts[idx] + cell = compute_index(sdf.itp, cp) + converged = false + for _ in 1:max_retries + p = make_interpolant(sdf.itp, cell) + cp, converged = _closest_point(p, x, cp, sdf.maxiters, sdf.xtol, sdf.ftol, safeguard_dist) + new_cell = compute_index(sdf.itp, cp) + (converged || new_cell == cell) && break + cell = new_cell + end + return cp, converged end -function _sample_interface(grid, itp, upsample, maxiters, ftol, safeguard_dist) - N = dimension(grid) - T = float(eltype(eltype(grid))) # scalar floating-point type of grid coordinates - P = SVector{N, T} - pts = Vector{P}() - ξ_ranges = ntuple(_ -> 0:upsample, N) - for I in CartesianIndices(size(grid) .- 1) - # Robust screening using Bernstein convex hull - if proven_empty(itp, I; surface = true) - continue - end +""" + _candidate_cells(ϕ) - Ip = CartesianIndex(Tuple(I) .+ 1) - lc, hc = grid[I], grid[Ip] +Return the cell indices to sample when building the interface. For a full-grid level set, +this is all cells; for a narrow band, only cells adjacent to active nodes. Dispatch point +for NewtonSDF construction. +""" +_candidate_cells(ϕ) = cellindices(mesh(ϕ)) + +""" + _sample_interface(grid, itp, cells, upsample, maxiters, ftol) + +Project uniformly-spaced sample points in each candidate cell onto the interface. +Returns all converged projections; cells proven to be empty are skipped. +""" +function _sample_interface(grid::CartesianGrid{N, T}, itp, cells, upsample, maxiters, ftol) where {N, T} + pts = SVector{N, T}[] + ξ_ranges = ntuple(_ -> 0:upsample, N) + safeguard_dist = maximum(meshsize(grid)) + for I in cells + proven_empty(itp, I; surface = true) && continue + cell = getcell(grid, I) samples = ( - lc .+ (hc .- lc) .* SVector{N, T}(Tuple(ξi)) ./ upsample for + cell.lc .+ (cell.hc .- cell.lc) .* SVector{N, T}(Tuple(ξi)) ./ upsample for ξi in Iterators.product(ξ_ranges...) ) - p = make_interpolant(itp, I) for x in samples pt = _project_to_interface(itp, x, maxiters, ftol, safeguard_dist) isnothing(pt) || push!(pts, pt) @@ -136,6 +177,13 @@ function _sample_interface(grid, itp, upsample, maxiters, ftol, safeguard_dist) return pts end +""" + _project_to_interface(p, x_start, maxiters, ftol, safeguard_dist) + +Use Newton's method to project a starting point onto the zero level set of `p`. +Returns the converged point or `nothing` if Newton fails to converge or if the +iterate moves more than `safeguard_dist` from `x_start`. +""" function _project_to_interface(p, x_start, maxiters, ftol, safeguard_dist) x = x_start for _ in 1:maxiters @@ -150,10 +198,10 @@ function _project_to_interface(p, x_start, maxiters, ftol, safeguard_dist) end """ - _closest_point(p, xq, x0, maxiters, xtol, ftol, safeguard_dist) + _closest_point(p, xq, x0, maxiters, xtol, ftol, safeguard_dist) -> (x_closest, converged) -Find the point on the zero level-set of `p` closest to `xq`, starting from `x0`. -Uses a Newton-Lagrange solver on the KKT conditions of `min ||x - xq||² s.t. p(x) = 0`. +Find the point on the zero level-set of `p` closest to `xq`, starting from `x0`. Uses a +Newton-Lagrange solver on the KKT conditions of `min ||x - xq||² s.t. p(x) = 0`. """ function _closest_point(p::F, xq::SVector{N, T}, x0::SVector{N, T}, maxiters, xtol, ftol, safeguard_dist) where {F, N, T} x = x0 @@ -183,7 +231,7 @@ function _closest_point(p::F, xq::SVector{N, T}, x0::SVector{N, T}, maxiters, xt # Check for convergence if abs(px) < ftol && res_norm < xtol - return x + return x, true end # Newton step: H_L * δ = -grad_L @@ -205,11 +253,11 @@ function _closest_point(p::F, xq::SVector{N, T}, x0::SVector{N, T}, maxiters, xt # If we drift too far from the patch, return best so far if norm(x - x0) > 3 * safeguard_dist - return best_x + return best_x, false end end - return best_x + return best_x, false end """ @@ -228,9 +276,19 @@ distances. - `xtol`: tolerance on the KKT residual (default: `1e-8`) - `ftol`: tolerance on the function value (default: `1e-8`) -# Examples -```julia -eq = LevelSetEquation(; terms, levelset = ϕ, bc, reinit = NewtonReinitializer(; reinit_freq = 5)) +```jldoctest; output = true +using LevelSetMethods +NewtonReinitializer() + +# output + +NewtonReinitializer + ├─ frequency: every step + ├─ order: 3 + ├─ upsample: 8× + ├─ maxiters: 20 + ├─ xtol: 1.0e-8 + └─ ftol: 1.0e-8 ``` """ struct NewtonReinitializer @@ -253,6 +311,17 @@ function NewtonReinitializer(; return NewtonReinitializer(reinit_freq, order, upsample, maxiters, xtol, ftol) end +function Base.show(io::IO, ::MIME"text/plain", r::NewtonReinitializer) + freq_str = r.reinit_freq == 1 ? "every step" : "every $(r.reinit_freq) steps" + println(io, "NewtonReinitializer") + println(io, " ├─ frequency: $freq_str") + println(io, " ├─ order: $(r.order)") + println(io, " ├─ upsample: $(r.upsample)×") + println(io, " ├─ maxiters: $(r.maxiters)") + println(io, " ├─ xtol: $(r.xtol)") + return print(io, " └─ ftol: $(r.ftol)") +end + """ reinitialize!(ϕ::LevelSet, r::NewtonReinitializer) @@ -261,15 +330,22 @@ closest-point method. """ function reinitialize!(ϕ::LevelSet, r::NewtonReinitializer) sdf = NewtonSDF(ϕ; order = r.order, upsample = r.upsample, maxiters = r.maxiters, xtol = r.xtol, ftol = r.ftol) + nfail = 0 for I in eachindex(ϕ) - ϕ[I] = sdf(mesh(ϕ)[I]) + x = mesh(ϕ)[I] + cp, converged = _closest_point_on_interface(sdf, x) + converged || (nfail += 1) + ϕ[I] = sign(ϕ[I]) * norm(x - cp) + end + if nfail > 0 + @warn "NewtonReinitializer: closest-point solver did not converge for $nfail / $(length(ϕ)) grid points" end return ϕ end # Called at each time step with the current step count. Reinitializes only when # nsteps is a multiple of reinit_freq; the nothing method is a no-op. -reinitialize!(ϕ::LevelSet, ::Nothing, nsteps::Int) = ϕ +reinitialize!(ϕ::LevelSet, ::Nothing, _) = ϕ function reinitialize!(ϕ::LevelSet, r::NewtonReinitializer, nsteps::Int) mod(nsteps, r.reinit_freq) == 0 || return ϕ return reinitialize!(ϕ, r) diff --git a/src/timestepping.jl b/src/timestepping.jl index 7f76af2..9b176e5 100644 --- a/src/timestepping.jl +++ b/src/timestepping.jl @@ -10,6 +10,16 @@ abstract type TimeIntegrator end struct ForwardEuler First-order explicit Forward Euler time integration scheme. + +```jldoctest; output = true +using LevelSetMethods +ForwardEuler() + +# output + +ForwardEuler (1st order explicit) + └─ cfl: 0.5 +``` """ @kwdef struct ForwardEuler <: TimeIntegrator cfl::Float64 = 0.5 @@ -21,6 +31,16 @@ cfl(fe::ForwardEuler) = fe.cfl Second order total variation dimishing Runge-Kutta scheme, also known as Heun's predictor-corrector method. + +```jldoctest; output = true +using LevelSetMethods +RK2() + +# output + +RK2 (2nd order TVD Runge-Kutta, Heun's method) + └─ cfl: 0.5 +``` """ @kwdef struct RK2 <: TimeIntegrator cfl::Float64 = 0.5 @@ -31,6 +51,16 @@ cfl(rk2::RK2) = rk2.cfl struct RK3 Third order total variation dimishing Runge-Kutta scheme. + +```jldoctest; output = true +using LevelSetMethods +RK3() + +# output + +RK3 (3rd order TVD Runge-Kutta) + └─ cfl: 0.5 +``` """ @kwdef struct RK3 <: TimeIntegrator cfl::Float64 = 0.5 @@ -43,8 +73,447 @@ cfl(rk3::RK3) = rk3.cfl Semi-implicit finite-volume scheme of the I2OE family (Mikula et al.) for advection problems. + +```jldoctest; output = true +using LevelSetMethods +SemiImplicitI2OE() + +# output + +SemiImplicitI2OE (semi-implicit advection, Mikula et al.) + └─ cfl: 2.0 +``` """ @kwdef struct SemiImplicitI2OE <: TimeIntegrator cfl::Float64 = 2.0 end cfl(i2oe::SemiImplicitI2OE) = i2oe.cfl + +function Base.show(io::IO, ::MIME"text/plain", s::ForwardEuler) + println(io, "ForwardEuler (1st order explicit)") + return print(io, " └─ cfl: $(s.cfl)") +end + +function Base.show(io::IO, ::MIME"text/plain", s::RK2) + println(io, "RK2 (2nd order TVD Runge-Kutta, Heun's method)") + return print(io, " └─ cfl: $(s.cfl)") +end + +function Base.show(io::IO, ::MIME"text/plain", s::RK3) + println(io, "RK3 (3rd order TVD Runge-Kutta)") + return print(io, " └─ cfl: $(s.cfl)") +end + +function Base.show(io::IO, ::MIME"text/plain", s::SemiImplicitI2OE) + println(io, "SemiImplicitI2OE (semi-implicit advection, Mikula et al.)") + return print(io, " └─ cfl: $(s.cfl)") +end + +# common integration logic +@noinline function _integrate!(ϕ::MeshField, integrator::TimeIntegrator, terms, reinit, tc, tf, Δt_max, log) + src = ϕ + buffers = _alloc_buffers(integrator, ϕ) + α = cfl(integrator) + nsteps = 0 + nterms = length(terms) + update_times = zeros(nterms) + compute_times = zeros(nterms) + while tc <= tf - eps(tc) + t_step = time_ns() + fill!(update_times, 0.0) + fill!(compute_times, 0.0) + + reinit_time, did_reinit = _timed_reinit!(src, reinit, nsteps) + + Δt_cfl = α * compute_cfl(terms, src, tc) + Δt = min(Δt_max, Δt_cfl, tf - tc) + + src, buffers = _advance!(integrator, src, buffers, terms, tc, Δt, update_times, compute_times) + + tc += Δt + nsteps += 1 + ϕ_min, ϕ_max = _level_set_extrema(src) + _push_record!(log, tc, t_step, reinit_time, did_reinit, update_times, compute_times, ϕ_min, ϕ_max) + @debug tc, Δt + end + src === ϕ || copy!(ϕ, src) + return ϕ +end + +# --- ForwardEuler --- + +_alloc_buffers(::ForwardEuler, ϕ) = (deepcopy(ϕ),) + +function _advance!(::ForwardEuler, src, (dst,), terms, tc, Δt, update_times, compute_times) + update_bcs!(src, tc) + _clear_buffer!(dst) + for I in eachindex(src) + dst[I] = src[I] + end + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], src, tc) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + dst[I] -= Δt * _compute_term(terms[k], src, I, tc) + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + return dst, (src,) +end + +# --- RK2 (Heun's method) --- + +_alloc_buffers(::RK2, ϕ) = (deepcopy(ϕ), deepcopy(ϕ)) + +function _advance!(::RK2, src, (pred, corr), terms, tc, Δt, update_times, compute_times) + # Stage 1: predictor and half-step accumulator + update_bcs!(src, tc) + _clear_buffer!(pred) + _clear_buffer!(corr) + for I in eachindex(src) + pred[I] = src[I] + corr[I] = src[I] + end + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], src, tc) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + v = _compute_term(terms[k], src, I, tc) + pred[I] -= Δt * v + corr[I] -= 0.5 * Δt * v + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + # Stage 2: correct with slope at predictor + update_bcs!(pred, tc + Δt) + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], pred, tc + Δt) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + corr[I] -= 0.5 * Δt * _compute_term(terms[k], pred, I, tc + Δt) + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + return corr, (src, pred) +end + +# --- RK3 (Shu-Osher TVD) --- + +_alloc_buffers(::RK3, ϕ) = (deepcopy(ϕ), deepcopy(ϕ)) + +function _advance!(::RK3, src, (buf1, buf2), terms, tc, Δt, update_times, compute_times) + # Stage 1: buf1 = src - Δt*L(src) + update_bcs!(src, tc) + _clear_buffer!(buf1) + for I in eachindex(src) + buf1[I] = src[I] + end + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], src, tc) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + buf1[I] -= Δt * _compute_term(terms[k], src, I, tc) + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + # Stage 2: buf2 = 3/4*src + 1/4*(buf1 - Δt*L(buf1)) + update_bcs!(buf1, tc + Δt) + _clear_buffer!(buf2) + for I in eachindex(src) + buf2[I] = 0.75 * src[I] + 0.25 * buf1[I] + end + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], buf1, tc + Δt) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + buf2[I] -= 0.25 * Δt * _compute_term(terms[k], buf1, I, tc + Δt) + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + # Stage 3: buf1 = (1/3)*src + (2/3)*(buf2 - Δt*L(buf2)) + update_bcs!(buf2, tc + 0.5 * Δt) + _clear_buffer!(buf1) + for I in eachindex(src) + buf1[I] = (src[I] + 2 * buf2[I]) / 3 + end + for k in eachindex(terms) + t0 = time_ns() + update_term!(terms[k], buf2, tc + 0.5 * Δt) + update_times[k] += (time_ns() - t0) / 1.0e9 + t0 = time_ns() + for I in eachindex(src) + buf1[I] -= (2 / 3) * Δt * _compute_term(terms[k], buf2, I, tc + 0.5 * Δt) + end + compute_times[k] += (time_ns() - t0) / 1.0e9 + end + return buf1, (src, buf2) +end + +function _integrate!( + ϕ::MeshField, + integrator::SemiImplicitI2OE, + terms, + reinit, + tc, + tf, + Δt_max, + log, + ) + # Check domain compatibility (FullDomain only for now) + domain(ϕ) isa FullDomain || throw(ArgumentError("SemiImplicitI2OE only supports FullDomain")) + + _validate_i2oe_setup(ϕ, terms) + term = only(terms) + vals = values(ϕ) + old_vals = similar(vals) + T = float(eltype(vals)) + N = ndims(ϕ) + velocity_components = ntuple(_ -> zeros(T, size(vals)), N) + + α = cfl(integrator) + nsteps = 0 + nterms = length(terms) + update_times = zeros(nterms) + compute_times = zeros(nterms) + while tc <= tf - eps(tc) + t_step = time_ns() + fill!(update_times, 0.0) + fill!(compute_times, 0.0) + + reinit_time, did_reinit = _timed_reinit!(ϕ, reinit, nsteps) + + update_bcs!(ϕ, tc) + _timed_update_terms!(terms, ϕ, tc, update_times) + Δt_cfl = α * compute_cfl(terms, ϕ, tc) + Δt = min(Δt_max, Δt_cfl, tf - tc) + + copy!(old_vals, vals) + _fill_advection_velocity_components!(velocity_components, term, ϕ, tc) + + t0_compute = time_ns() + _i2oe_global_step!(vals, old_vals, velocity_components, ϕ, Δt) + compute_times[1] += (time_ns() - t0_compute) / 1.0e9 + + tc += Δt + nsteps += 1 + ϕ_min, ϕ_max = _level_set_extrema(ϕ) + _push_record!(log, tc, t_step, reinit_time, did_reinit, update_times, compute_times, ϕ_min, ϕ_max) + @debug tc, Δt + end + return ϕ +end + +function _validate_i2oe_setup(ϕ, terms) + length(terms) == 1 && first(terms) isa AdvectionTerm || throw( + ArgumentError("SemiImplicitI2OE requires exactly one AdvectionTerm"), + ) + all(size(values(ϕ)) .>= 3) || throw( + ArgumentError("SemiImplicitI2OE requires at least 3 grid nodes along each dimension"), + ) + return nothing +end + +function _fill_advection_velocity_components!(out, term::AdvectionTerm{<:MeshField}, ϕ::MeshField, _t) + vel = velocity(term) + N = ndims(ϕ) + mesh(vel) == mesh(ϕ) || + throw(ArgumentError("advection velocity field must be defined on the same mesh")) + for I in eachindex(ϕ) + vI = vel[I] + for dim in 1:N + out[dim][I] = vI[dim] + end + end + return out +end + +function _fill_advection_velocity_components!(out, term::AdvectionTerm{<:Function}, ϕ::MeshField, t) + vel = velocity(term) + N = ndims(ϕ) + g = mesh(ϕ) + for I in eachindex(ϕ) + vI = vel(g[I], t) + for dim in 1:N + out[dim][I] = vI[dim] + end + end + return out +end + +function _i2oe_global_step!(vals, old_vals, velocity_components, ϕ::MeshField, Δt) + # Coupled I2OE update: solve one global sparse system built from all neighbors. + T = eltype(vals) + Δ = meshsize(ϕ) + N = ndims(ϕ) + mₚ = prod(Δ) + fac = T(Δt / (2 * mₚ)) + bcs = boundary_conditions(ϕ) + grid = mesh(ϕ) + LI = LinearIndices(vals) + nb_nodes = length(vals) + rows = Int[] + cols = Int[] + coeffs = T[] + rhs = zeros(T, nb_nodes) + sizehint!(rows, nb_nodes * (2N + 1)) + sizehint!(cols, nb_nodes * (2N + 1)) + sizehint!(coeffs, nb_nodes * (2N + 1)) + + for I in eachindex(ϕ) + row = LI[I] + uold_p = old_vals[I] + diag = one(T) + rhsp = uold_p + for dim in 1:N + area = _i2oe_face_measure(Δ, dim) + rel_m = _i2oe_neighbor_relation(I, dim, -1, vals, bcs, grid) + rel_p = _i2oe_neighbor_relation(I, dim, +1, vals, bcs, grid) + + vface_m = _i2oe_face_velocity(velocity_components[dim], I, rel_m, dim) + vface_p = _i2oe_face_velocity(velocity_components[dim], I, rel_p, dim) + + a_m = area * vface_m + a_p = -area * vface_p + diag, rhsp = _i2oe_add_side_contrib!( + rows, + cols, + coeffs, + LI, + old_vals, + row, + rel_m, + a_m, + fac, + diag, + rhsp, + uold_p, + ) + diag, rhsp = _i2oe_add_side_contrib!( + rows, + cols, + coeffs, + LI, + old_vals, + row, + rel_p, + a_p, + fac, + diag, + rhsp, + uold_p, + ) + end + push!(rows, row) + push!(cols, row) + push!(coeffs, diag) + rhs[row] = rhsp + end + + A = sparse(rows, cols, coeffs, nb_nodes, nb_nodes) + copy!(vals, reshape(A \ rhs, size(vals))) + return vals +end + +function _i2oe_add_side_contrib!( + rows, + cols, + coeffs, + LI, + old_vals, + row, + rel, + a, + fac, + diag, + rhsp, + uold_p, + ) + ain = max(a, zero(a)) + aout = min(a, zero(a)) + + α, β, idx, γ = rel + if ain != 0 + diag += fac * ain * (1 - α) + if β != 0 + push!(rows, row) + push!(cols, LI[idx]) + push!(coeffs, -fac * ain * β) + end + rhsp += fac * ain * γ + end + + if aout != 0 + uold_q = _i2oe_neighbor_value(old_vals, uold_p, rel) + rhsp -= fac * aout * (uold_p - uold_q) + end + return diag, rhsp +end + +function _i2oe_neighbor_value(old_vals, uold_p, rel) + α, β, idx, γ = rel + uold_idx = isnothing(idx) ? zero(uold_p) : old_vals[idx] + return α * uold_p + β * uold_idx + γ +end + +function _i2oe_neighbor_relation(I, dim, side, vals, bcs, grid) + T = float(eltype(vals)) + N = ndims(vals) + ax = axes(vals, dim) + Ioff = side < 0 ? _decrement_index(I, dim) : _increment_index(I, dim) + if Ioff[dim] in ax + return (zero(T), one(T), Ioff, zero(T)) + end + + bc = side < 0 ? bcs[dim][1] : bcs[dim][2] + if bc isa PeriodicBC + Iq = _wrap_index_periodic(Ioff, ax, dim) + return (zero(T), one(T), Iq, zero(T)) + elseif bc isa NeumannBC + Iq = CartesianIndex(ntuple(s -> s == dim ? clamp(Ioff[s], first(ax), last(ax)) : Ioff[s], N)) + return (zero(T), one(T), Iq, zero(T)) + elseif bc isa LinearExtrapolationBC + i, a, b = Ioff[dim], first(ax), last(ax) + Ion_d, Iin_d, dist = i < a ? (a, a + 1, a - i) : (b, b - 1, i - b) + Ion = CartesianIndex(ntuple(s -> s == dim ? Ion_d : Ioff[s], N)) + Iin = CartesianIndex(ntuple(s -> s == dim ? Iin_d : Ioff[s], N)) + Ion == I || throw( + ArgumentError("SemiImplicitI2OE expected nearest ghost cell for LinearExtrapolationBC"), + ) + return (one(T) + T(dist), -T(dist), Iin, zero(T)) + elseif bc isa DirichletBC + xghost = _getindex(grid, Ioff) + return (zero(T), zero(T), nothing, T(bc.f(xghost, bc.t))) + else + error("boundary condition $bc is not supported by SemiImplicitI2OE") + end +end + +function _i2oe_face_velocity(velcomp, I, rel, _dim) + α, β, idx, _ = rel + if isnothing(idx) || α != 0 || β != 1 + return velcomp[I] + end + return 0.5 * (velcomp[I] + velcomp[idx]) +end + +function _i2oe_face_measure(Δ, dim) + N = length(Δ) + N == 1 && return one(eltype(Δ)) + return prod(Δ[d] for d in 1:N if d != dim) +end + +function _compute_terms(terms, ϕ, I, t) + return sum(terms) do term + return _compute_term(term, ϕ, I, t) + end +end diff --git a/src/velocityextension.jl b/src/velocityextension.jl index a50a2a1..0179b87 100644 --- a/src/velocityextension.jl +++ b/src/velocityextension.jl @@ -38,7 +38,7 @@ function extend_along_normals!( bc = if has_boundary_conditions(ϕ) boundary_conditions(ϕ) else - ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), N) + ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), N) end ϕw = has_boundary_conditions(ϕ) ? ϕ : add_boundary_conditions(ϕ, bc) Fw = MeshField(F, mesh(ϕ), bc) @@ -93,7 +93,7 @@ function _normalize_frozen_mask(frozen, ϕ::LevelSet, interface_band, Δ) end function _signed_normal_components(ϕ::LevelSet, Δ, min_norm) - N = dimension(ϕ) + N = ndims(ϕ) T = float(eltype(values(ϕ))) components = ntuple(_ -> Array{T}(undef, size(values(ϕ))), N) min_norm² = min_norm^2 From b922fcfe4e85a6040f28820299d26a192ccb908d Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sat, 28 Mar 2026 15:09:31 +0100 Subject: [PATCH 02/12] narrow-band level set - Add `NarrowBandLevelSet`: a `MeshField` backed by a `Dict` for sparse storage - Constructors from `LevelSet`, and from scratch via a signed-distance function - Narrow-band reinitializiation, band update (`update_narrowband!`), and extrapolation of ghost values at band boundary - Integrate narrow-band support into `LevelSetEquation` and time steppers --- src/LevelSetMethods.jl | 2 + src/narrowband.jl | 261 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/narrowband.jl diff --git a/src/LevelSetMethods.jl b/src/LevelSetMethods.jl index 62692ff..49c5dab 100644 --- a/src/LevelSetMethods.jl +++ b/src/LevelSetMethods.jl @@ -14,6 +14,7 @@ include("levelset.jl") include("derivatives.jl") include("interpolation.jl") include("reinitializer.jl") +include("narrowband.jl") include("velocityextension.jl") include("levelsetterms.jl") include("timestepping.jl") @@ -30,6 +31,7 @@ export AdvectionTerm, LevelSet, LevelSetEquation, MeshField, + NarrowBandLevelSet, NeumannBC, LinearExtrapolationBC, NewtonReinitializer, diff --git a/src/narrowband.jl b/src/narrowband.jl new file mode 100644 index 0000000..fb59c0e --- /dev/null +++ b/src/narrowband.jl @@ -0,0 +1,261 @@ +""" + const NarrowBandLevelSet{N, T, B} + +Alias for [`MeshField`](@ref) on a `CartesianGrid{N,T}` with values stored as +a `Dict{CartesianIndex{N},T}` and a [`NarrowBandDomain{T}`](@ref). `B` is +the type of the boundary conditions. +""" +const NarrowBandLevelSet{N, T, B} = + MeshField{Dict{CartesianIndex{N}, T}, CartesianGrid{N, T}, B, NarrowBandDomain{T}} + +active_indices(nb::NarrowBandLevelSet) = keys(values(nb)) +halfwidth(nb::NarrowBandLevelSet) = domain(nb).halfwidth +extrap_order(nb::NarrowBandLevelSet) = domain(nb).extrap_order +Base.eachindex(nb::NarrowBandLevelSet) = active_indices(nb) +_eachindex(::NarrowBandDomain, nb) = active_indices(nb) +Base.eltype(::NarrowBandLevelSet{N, T}) where {N, T} = T + + +""" + NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize = true, extrap_order = 1) + +Construct a `NarrowBandLevelSet` from a full-grid `LevelSet`. Active nodes are those +where `|ϕ[I]| < halfwidth`. Boundary conditions are inherited from `ϕ`. + +If `reinitialize` is `true` (the default), `ϕ` is first reinitialized to a signed +distance function using [`NewtonReinitializer`](@ref). +""" +function NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize::Bool = true, extrap_order::Int = 2) + bcs = boundary_conditions(ϕ) # preserve the caller's BCs (may be nothing) + if reinitialize + ϕ = deepcopy(ϕ) + # reinit needs BCs for gradient computation; add temporary ones if missing + if !has_boundary_conditions(ϕ) + ϕ = add_boundary_conditions(ϕ, ExtrapolationBC(2)) + end + reinitialize!(ϕ, NewtonReinitializer()) + end + grid = mesh(ϕ) + N = ndims(grid) + T = float(eltype(ϕ)) + γ = T(halfwidth) + vals_dict = Dict{CartesianIndex{N}, T}() + for I in CartesianIndices(grid) + v = T(ϕ[I]) + abs(v) < γ && (vals_dict[I] = v) + end + dom = NarrowBandDomain(γ, extrap_order) + return MeshField(vals_dict, grid, bcs, dom) +end + +""" + NarrowBandLevelSet(ϕ::LevelSet; nlayers = 8, reinitialize = true, extrap_order = 1) + +Construct a `NarrowBandLevelSet` with halfwidth automatically computed as +`nlayers * minimum(meshsize(ϕ))`. `nlayers` sets the number of cell layers +on each side of the interface included in the band. +""" +function NarrowBandLevelSet(ϕ::LevelSet; nlayers::Int = 3, reinitialize::Bool = true, extrap_order::Int = 2) + Δx = minimum(meshsize(ϕ)) + return NarrowBandLevelSet(ϕ, nlayers * Δx; reinitialize, extrap_order) +end + +""" + NarrowBandLevelSet(f, grid::CartesianGrid, halfwidth::Real; bc = nothing) + +Construct a `NarrowBandLevelSet` by evaluating `f` at each node of `grid` and +keeping only those where `|f(x)| < halfwidth`. No dense array is allocated. + +!!! warning + Since the `halfwidth` threshold is applied to the raw values of `f`, the resulting band + width in physical space will only match `halfwidth` if `f` is already a signed distance + function. Otherwise the band width will depend on the gradient of `f` near the interface + and may not correspond to a fixed number of cell layers. +""" +function NarrowBandLevelSet(f, grid::CartesianGrid, halfwidth::Real; bc = nothing, extrap_order::Int = 2) + N = ndims(grid) + T = float(eltype(eltype(grid))) + γ = T(halfwidth) + vals_dict = Dict{CartesianIndex{N}, T}() + for I in CartesianIndices(grid) + v = T(f(grid[I])) + abs(v) < γ && (vals_dict[I] = v) + end + dom = NarrowBandDomain(γ, extrap_order) + return MeshField(vals_dict, grid, bc, dom) +end + +""" + NarrowBandLevelSet(f, grid::CartesianGrid; nlayers = 8, bc = nothing) + +Construct a `NarrowBandLevelSet` with halfwidth automatically computed as +`nlayers * minimum(meshsize(grid))`. + +!!! warning + The `nlayers` interpretation is only correct if `f` is already a signed distance + function. Otherwise the band width in cell layers will not match `nlayers`. +""" +function NarrowBandLevelSet(f, grid::CartesianGrid; nlayers::Int = 8, bc = nothing, extrap_order::Int = 2) + Δx = minimum(meshsize(grid)) + return NarrowBandLevelSet(f, grid, nlayers * Δx; bc, extrap_order) +end + +""" + _base_lookup(nb::NarrowBandLevelSet, I) -> value + +Entry point for value lookup on a `NarrowBandLevelSet` at index `I`, which is +assumed to be inside the grid (out-of-grid indices are handled by `_getindexbc` +before reaching this function). + +Tries the dict first; if `I` is not stored (i.e. it is inside the grid but +outside the narrow band), falls back to [`_extrapolate_nb_rec`](@ref) to +approximate the value from nearby band nodes. Throws an error if no path to +stored values can be found. +""" +function _base_lookup(nb::NarrowBandLevelSet{N}, I) where {N} + val = get(values(nb), I, nothing) + val !== nothing && return val + val = _extrapolate_nb_rec(nb, I, N) + val !== nothing && return val + error("extrapolation failed at index $I: no resolvable path to stored values") +end + +""" + _extrapolate_nb_rec(nb::NarrowBandLevelSet, I, max_dim) -> value or nothing + +Approximate the value at an in-grid index `I` that is not stored in the band +dict, by Lagrange extrapolation from nearby band values. + +The algorithm processes dimensions `1` through `max_dim` in order. For each +dimension, it searches outward from `I` (nearest first, both sides) for an +anchor point where a stencil of `extrap_order + 1` consecutive values can be +assembled, and Lagrange-extrapolates back to `I`. + +Stencil values are resolved by calling `_extrapolate_nb_rec` recursively with +`max_dim = dim - 1`, so each stencil point can itself be extrapolated using +lower dimensions. This produces a tensor-product extrapolation that handles +indices outside the band in multiple dimensions simultaneously. For example, +in 2D, a point outside the band in both x and y is resolved by first +extrapolating each y-stencil point in x, then extrapolating in y from those. + +Returns `nothing` if no dimension yields a valid stencil, signaling the caller +to try other approaches or error. +""" +function _extrapolate_nb_rec(nb::NarrowBandLevelSet{N, T}, I::CartesianIndex{N}, max_dim) where {N, T} + haskey(values(nb), I) && return values(nb)[I] + grid_axes = axes(nb) + P = extrap_order(nb) + for dim in 1:max_dim + for k in 1:length(grid_axes[dim]) + for side in (-1, 1) + anchor = I[dim] + side * k + anchor in grid_axes[dim] || continue + val = _lagrange_extrap_from(nb, I, dim, anchor, side, k, P) + val !== nothing && return val + end + end + end + return nothing +end + +""" + _lagrange_extrap_from(nb, I, dim, anchor, side, k, P) -> value or nothing + +Attempt to evaluate a degree-`P` Lagrange extrapolant at `I[dim]` using `P+1` +consecutive stencil nodes along dimension `dim`: + + anchor, anchor + side, anchor + 2*side, …, anchor + P*side + +The stencil extends from `anchor` deeper into the band (away from `I`). The +target `I[dim]` is at distance `k` from `anchor` in the opposite direction, +corresponding to local coordinate `ξ = -k` relative to stencil nodes at +`ξ = 0, 1, …, P`. This matches the convention of `_lagrange_extrap_weight(j, k, P)`. + +Each stencil value is resolved via `_extrapolate_nb_rec(nb, Ij, dim - 1)`, +using only dimensions lower than `dim`. Returns `nothing` if any stencil point +falls outside the grid or cannot be resolved. +""" +function _lagrange_extrap_from(nb::NarrowBandLevelSet{N, T}, I, dim, anchor, side, k, P) where {N, T} + grid_axes = axes(nb) + result = zero(float(T)) + for j in 0:P + pos = anchor + side * j + pos in grid_axes[dim] || return nothing + Ij = CartesianIndex(ntuple(s -> s == dim ? pos : I[s], Val(N))) + Vj = _extrapolate_nb_rec(nb, Ij, dim - 1) + Vj === nothing && return nothing + result += _lagrange_extrap_weight(j, k, P) * Vj + end + return result +end + +""" + _candidate_cells(nb::NarrowBandLevelSet) + +Return the active node indices as candidate cells for interface sampling. +Since the band covers all nodes within `halfwidth` of the interface, all interface +cells have at least one active node. +""" +_candidate_cells(nb::NarrowBandLevelSet) = active_indices(nb) + +reinitialize!(nb::NarrowBandLevelSet, ::Nothing, _) = nb + +function reinitialize!(nb::NarrowBandLevelSet, r::NewtonReinitializer) + sdf = NewtonSDF(nb; order = r.order, upsample = r.upsample, maxiters = r.maxiters, xtol = r.xtol, ftol = r.ftol) + rebuild_band!(nb, sdf) + return nb +end + +function reinitialize!(nb::NarrowBandLevelSet, r::NewtonReinitializer, nsteps::Int) + mod(nsteps, r.reinit_freq) == 0 || return nb + return reinitialize!(nb, r) +end + +""" + rebuild_band!(nb::NarrowBandLevelSet, sdf) + +Rebuild the active node set from the signed distance function `sdf` using a breadth-first +search seeded from all previously active nodes. The BFS expands axis-aligned neighbors and +adds a node whenever `|sdf(x)| < halfwidth`, stopping a branch when a node falls outside the +band. +""" +function rebuild_band!(nb::NarrowBandLevelSet{N, T}, sdf) where {N, T} + grid = mesh(nb) + γ = halfwidth(nb) + grid_axes = axes(nb) + vals = values(nb) + + # Use a vector queue for BFS, keeping track of the head explicitly. Duplicate indices in + # a set to have O(1) membership check. + queue = collect(keys(vals)) + queue_set = Set{CartesianIndex{N}}(queue) + empty!(vals) + + head = 1 + while head <= length(queue) + I = queue[head] + head += 1 + v = sdf(grid[I]) + abs(v) >= γ && continue # outside band — don't expand further + vals[I] = v + for d in 1:N, s in (-1, 1) + J = _increment_index(I, d, s) + J ∈ queue_set && continue + all(d -> J[d] in grid_axes[d], 1:N) || continue + push!(queue_set, J) + push!(queue, J) + end + end + return nb +end + +""" + _clear_buffer!(ϕ::MeshField) + +Clear the active entries of a buffer before it is used as a write target in a +time-integration step. For a [`NarrowBandLevelSet`](@ref) this empties the +values dict so that stale entries from a previous band do not survive the +src/dst swap. For a full-domain field this is a no-op. +""" +_clear_buffer!(::MeshField) = nothing +_clear_buffer!(nb::NarrowBandLevelSet) = empty!(values(nb)) From deaf244ec2499e595b0179b00e9ea0f780d26019 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sat, 28 Mar 2026 15:09:55 +0100 Subject: [PATCH 03/12] update and expand test suite - Add tests for: meshfield, levelsetequation, levelsetterms, reinitializer, narrow-band, show output, timestepping, spiral convergence - Update existing tests to match refactored API (`ic` kwarg, new BC types, etc.) - Remove obsolete test-reinitialization.jl and test-sdf.jl (replaced by test-reinitializer.jl) --- test/test-boundaryconditions.jl | 65 +++----- test/test-derivatives.jl | 58 +++---- test/test-interpolation.jl | 7 - test/test-levelsetequation.jl | 219 +++++++++++++++++++++++++ test/test-levelsetterms.jl | 51 ++++++ test/test-meshes.jl | 3 + test/test-meshfield.jl | 117 ++++++++++++++ test/test-narrow-band.jl | 276 ++++++++++++++++++++++++++++++++ test/test-reinitialization.jl | 64 -------- test/test-reinitializer.jl | 122 ++++++++++++++ test/test-sdf.jl | 96 ----------- test/test-semi-implicit.jl | 24 +-- test/test-show.jl | 140 ++++++++++++++++ test/test-spiral.jl | 43 +++++ test/test-timestepping.jl | 46 ++++++ test/test-velocityextension.jl | 8 +- 16 files changed, 1080 insertions(+), 259 deletions(-) create mode 100644 test/test-levelsetequation.jl create mode 100644 test/test-levelsetterms.jl create mode 100644 test/test-meshfield.jl create mode 100644 test/test-narrow-band.jl delete mode 100644 test/test-reinitialization.jl create mode 100644 test/test-reinitializer.jl delete mode 100644 test/test-sdf.jl create mode 100644 test/test-show.jl create mode 100644 test/test-spiral.jl create mode 100644 test/test-timestepping.jl diff --git a/test/test-boundaryconditions.jl b/test/test-boundaryconditions.jl index ce8b5cc..48b3f6b 100644 --- a/test/test-boundaryconditions.jl +++ b/test/test-boundaryconditions.jl @@ -1,49 +1,6 @@ using Test import LevelSetMethods as LSM -@testset "PeriodicBC" begin - a = (0, 0) - b = (1, 1) - n = (10, 5) - grid = LSM.CartesianGrid(a, b, n) - vals = rand(n...) - bcs = ((LSM.PeriodicBC(), LSM.PeriodicBC()), (LSM.PeriodicBC(), LSM.PeriodicBC())) - mf = LSM.MeshField(vals, grid, bcs) - @test mf[1, 1] == vals[1, 1] - @test mf[1, 0] == vals[1, 4] - @test mf[11, 5] == mf[2, 5] -end - -@testset "ExtrapolationBC" begin - # On a 1D node-based grid [0,1] with N nodes, node i is at x=(i-1)*h. - # ExtrapolationBC{P} should reproduce degree-(P-1) polynomials exactly. - grid = LSM.CartesianGrid((0.0,), (1.0,), (10,)) - h = LSM.meshsize(grid)[1] - for P in 1:4 - f = x -> x[1]^(P - 1) - ϕ = LSM.MeshField(f, grid) - bcs = ((LSM.ExtrapolationBC(P), LSM.ExtrapolationBC(P)),) - ϕ_bc = LSM.add_boundary_conditions(ϕ, bcs) - # ghost at index 0 is at x = -h; ghost at index 11 is at x = 10h = 1+h - @test ϕ_bc[0] ≈ (-h)^(P - 1) atol = 1.0e-12 - @test ϕ_bc[11] ≈ (10h)^(P - 1) atol = 1.0e-12 - end - # ExtrapolationBC{1} == NeumannBC (constant extension) - grid2 = LSM.CartesianGrid((0.0,), (1.0,), (5,)) - f = x -> 3 * x[1] - 1.0 # arbitrary function - ϕ = LSM.MeshField(f, grid2) - ϕ_n = LSM.add_boundary_conditions(ϕ, ((LSM.NeumannBC(), LSM.NeumannBC()),)) - ϕ_e1 = LSM.add_boundary_conditions(ϕ, ((LSM.ExtrapolationBC(1), LSM.ExtrapolationBC(1)),)) - for k in (0, -1, 6, 7) - @test ϕ_n[k] ≈ ϕ_e1[k] - end - # ExtrapolationBC{2} == NeumannGradientBC on uniform node-based grids - ϕ_ng = LSM.add_boundary_conditions(ϕ, ((LSM.NeumannGradientBC(), LSM.NeumannGradientBC()),)) - ϕ_e2 = LSM.add_boundary_conditions(ϕ, ((LSM.ExtrapolationBC(2), LSM.ExtrapolationBC(2)),)) - @test ϕ_ng[0] ≈ ϕ_e2[0] - @test ϕ_ng[6] ≈ ϕ_e2[6] -end - @testset "Normalize BC" begin bcs = LSM.PeriodicBC() @test LSM._normalize_bc(bcs, 2) == @@ -54,9 +11,23 @@ end bcs = (LSM.PeriodicBC(), LSM.NeumannBC()) @test LSM._normalize_bc(bcs, 2) == ((LSM.PeriodicBC(), LSM.PeriodicBC()), (LSM.NeumannBC(), LSM.NeumannBC())) - bcs = [LSM.PeriodicBC(), (LSM.DirichletBC(), LSM.NeumannBC())] - @test LSM._normalize_bc(bcs, 2) == - ((LSM.PeriodicBC(), LSM.PeriodicBC()), (LSM.DirichletBC(), LSM.NeumannBC())) - bcs = [(LSM.PeriodicBC(), LSM.DirichletBC()), (LSM.DirichletBC(), LSM.NeumannBC())] + dbc = LSM.DirichletBC((x, t) -> 0.0) + bcs = [LSM.PeriodicBC(), (dbc, LSM.NeumannBC())] + result = LSM._normalize_bc(bcs, 2) + @test result[1] == (LSM.PeriodicBC(), LSM.PeriodicBC()) + @test result[2][1] === dbc + @test result[2][2] === LSM.NeumannBC() + bcs = [(LSM.PeriodicBC(), dbc), (dbc, LSM.NeumannBC())] @test_throws ArgumentError LSM._normalize_bc(bcs, 2) end + +@testset "update_bc!" begin + bc = LSM.DirichletBC((x, t) -> t) + @test bc.t == 0.0 + returned = LSM.update_bc!(bc, 5.0) + @test bc.t == 5.0 + @test returned === bc + # no-op for other BC types + nbc = LSM.NeumannBC() + @test LSM.update_bc!(nbc, 1.0) === nbc +end diff --git a/test/test-derivatives.jl b/test/test-derivatives.jl index 52ef4bc..37cbc35 100644 --- a/test/test-derivatives.jl +++ b/test/test-derivatives.jl @@ -1,39 +1,39 @@ using Test using LevelSetMethods -using LinearAlgebra using StaticArrays using LevelSetMethods: D⁺, D⁻, D⁰, D2⁰, D2, weno5⁻, weno5⁺ -@testset "Uniform mesh" begin - nx, ny = 100, 50 - a = (-2, -2) - b = (2, 2) - grid = CartesianGrid(a, b, (nx, ny)) - h = LevelSetMethods.meshsize(grid) - ϕ = LevelSet(grid) do (x, y) - return x^2 + y^2 - 1 - end - I = CartesianIndex(9, 7) - ∇ϕ = MeshField(grid) do (x, y) - return SVector(2x, 2y) - end - # first derivative - for op in (D⁺, D⁻, D⁰, weno5⁻, weno5⁺) - for dir in 1:2 - ee = abs(∇ϕ[I][dir] - op(ϕ, I, dir)) - @test ee < 5 * h[dir] - end +# Test on f(x,y) = x³ + xy² — non-constant second derivatives, non-zero mixed derivative +# Exact derivatives: ∂_x = 3x²+y², ∂_y = 2xy, ∂_xx = 6x, ∂_yy = 2x, ∂_xy = 2y +grid = CartesianGrid((-2.0, -2.0), (2.0, 2.0), (100, 50)) +h = LevelSetMethods.meshsize(grid) +ϕ = LevelSet(v -> v[1]^3 + v[1] * v[2]^2, grid) +I = CartesianIndex(9, 7) +x, y = grid[I] + +@testset "First derivatives" begin + exact = SVector(3x^2 + y^2, 2x * y) + for dim in 1:2 + # first-order schemes: error O(h) + @test abs(D⁺(ϕ, I, dim) - exact[dim]) < 10 * h[dim] + @test abs(D⁻(ϕ, I, dim) - exact[dim]) < 10 * h[dim] + # second-order scheme: exact on quadratics ⟹ error O(h²) + @test abs(D⁰(ϕ, I, dim) - exact[dim]) < 5 * h[dim]^2 + # WENO5: fifth-order for smooth functions + @test abs(weno5⁻(ϕ, I, dim) - exact[dim]) < 5 * h[dim]^2 + @test abs(weno5⁺(ϕ, I, dim) - exact[dim]) < 5 * h[dim]^2 end - # second derivative, same direction - for dir in 1:2 - @test abs(2 - D2⁰(ϕ, I, dir)) < 5 * h[dir] - @test abs(2 - D2(ϕ, I, (dir, dir))) < 5 * h[dir] +end + +@testset "Second derivatives" begin + exact_diag = SVector(6x, 2x) # ∂_xx, ∂_yy + exact_cross = 2y # ∂_xy = ∂_yx + for dim in 1:2 + @test abs(D2⁰(ϕ, I, dim) - exact_diag[dim]) < 5 * h[dim] + @test abs(D2(ϕ, I, (dim, dim)) - exact_diag[dim]) < 5 * h[dim] end - # second derivative, different directions - for op in (D2,) - for dims in ((1, 2), (2, 1)) - @test abs(op(ϕ, I, dims)) < 5 * h[dims[1]] * h[dims[2]] - end + for dims in ((1, 2), (2, 1)) + @test abs(D2(ϕ, I, dims) - exact_cross) < 5 * h[1] * h[2] end end diff --git a/test/test-interpolation.jl b/test/test-interpolation.jl index cb40091..49cb91c 100644 --- a/test/test-interpolation.jl +++ b/test/test-interpolation.jl @@ -51,25 +51,18 @@ using Test @testset "Least Squares Approximation (K=2)" begin grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (21, 21)) - # f(x) = x^2 + 2y^2 - 0.5 (Exactly quadratic) f(x) = x[1]^2 + 2 * x[2]^2 - 0.5 ϕ = LevelSet(f, grid) - - # Request Quadratic (K=2) on a Cubic stencil (stencil_K=3) - # Since f is quadratic, the least-squares fit should be exact! itp = interpolate(ϕ, 2) x_test = SVector(0.15, -0.25) - @test itp(x_test) ≈ f(x_test) atol = 1.0e-12 I = LevelSetMethods.compute_index(itp, x_test) p = LevelSetMethods.make_interpolant(itp, I) @test LevelSetMethods.gradient(p, x_test) ≈ SVector(2 * 0.15, 4 * (-0.25)) atol = 1.0e-12 - @test check_allocs(itp, x_test) == 0 end @testset "Mesh Interpolation (3D)" begin - grid = CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (11, 11, 11)) f(x) = x[1]^2 + x[2]^2 + x[3]^2 - 0.5 grad_f(x) = SVector(2 * x[1], 2 * x[2], 2 * x[3]) diff --git a/test/test-levelsetequation.jl b/test/test-levelsetequation.jl new file mode 100644 index 0000000..8534c51 --- /dev/null +++ b/test/test-levelsetequation.jl @@ -0,0 +1,219 @@ +using Test +using LinearAlgebra +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM + +@testset "AdvectionTerm WENO5 — convergence order (1D periodic, RK3)" begin + # 1D advection of sin(πx) with u=1 on a periodic domain. Exact solution: sin(π(x - t)). + # WENO5 is 5th-order in space; RK3 temporal error O(Δt³) = O((cfl·Δx)³) dominates + # at default cfl=0.5, so we use cfl=1e-2 to expose the spatial rate. + u, tf = 1.0, 0.5 + ϕ_exact = (x, t) -> sin(π * (x[1] - u * t)) + Ns = [20, 40, 80, 160] + errors = map(Ns) do N + grid = LSM.CartesianGrid((-1.0,), (1.0,), (N,)) + ϕ = LSM.LevelSet(x -> ϕ_exact(x, 0.0), grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.AdvectionTerm((x, t) -> SVector(u)),), + ic = ϕ, + bc = PeriodicBC(), + integrator = RK3(; cfl = 1.0e-2), + ) + integrate!(eq, tf) + ϕ_out = current_state(eq) + maximum(I -> abs(ϕ_out[I] - ϕ_exact(grid[I], tf)), CartesianIndices(LSM.mesh(ϕ_out))) + end + for i in 1:(length(Ns) - 1) + order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) + @test order ≥ 5 - 0.5 + end +end + + +@testset "NormalMotionTerm — convergence order (2D expanding circle, RK3)" begin + # ϕ₀ = ‖x‖ - r₀. The PDE ϕₜ + v|∇ϕ| = 0 with radial symmetry reduces to + # f_t + v·f_r = 0, giving the exact pointwise solution ϕ(x,t) = ‖x‖ - r₀ - v·t. + # Error is measured in a band around the interface to avoid the high-curvature + # region near r = 0. + r0, v, tf = 0.5, 0.5, 0.2 + ϕ_exact = x -> norm(x) - r0 - v * tf + Ns = [60, 120, 240] + errors = map(Ns) do N + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (N, N)) + ϕ = LSM.LevelSet(x -> norm(x) - r0, grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.NormalMotionTerm((x, t) -> v),), + ic = ϕ, + bc = ExtrapolationBC(2), + integrator = RK3(), + ) + integrate!(eq, tf) + ϕ_out = current_state(eq) + maximum(CartesianIndices(LSM.mesh(ϕ_out))) do I + x = grid[I] + (0.5 ≤ norm(x) ≤ 1.5) || return 0.0 + abs(ϕ_out[I] - ϕ_exact(x)) + end + end + for i in 1:(length(Ns) - 1) + order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) + @test 1.8 < order < 2.2 + end +end + +@testset "CurvatureTerm — convergence order (2D circle, RK3)" begin + # ϕ₀ = ‖x‖ - r₀. The 2D curvature PDE ϕₜ + b κ|∇ϕ| = 0 with κ = 1/r has + # the exact pointwise solution ϕ(x,t) = √(‖x‖² − 2bt) − r₀, obtained from + # the characteristics dr/dt = b/r. Zero set: ‖x‖ = √(r₀² + 2bt). + # Curvature discretization is 2nd order; RK3 (3rd order) keeps spatial error dominant. + # Error is measured in a band around the interface to avoid the large-curvature region + # near r = 0 (κ = 1/r → ∞) and the boundary. + r0, b, tf = 0.7, -0.1, 0.2 + ϕ_exact = x -> sqrt(norm(x)^2 - 2b * tf) - r0 + Ns = [60, 120, 240] + errors = map(Ns) do N + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (N, N)) + ϕ = LSM.LevelSet(x -> norm(x) - r0, grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.CurvatureTerm((x, t) -> b),), + ic = ϕ, + bc = ExtrapolationBC(2), + integrator = RK3(), + ) + integrate!(eq, tf) + ϕ_out = current_state(eq) + maximum(CartesianIndices(LSM.mesh(ϕ_out))) do I + x = grid[I] + (0.5 ≤ norm(x) ≤ 1.5) || return 0.0 + abs(ϕ_out[I] - ϕ_exact(x)) + end + end + for i in 1:(length(Ns) - 1) + order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) + @test order ≥ 2 - 0.5 + end +end + +@testset "Reinitialization inside LevelSetEquation" begin + @testset "with RK2" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (33, 33)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), + ic = ϕ, + bc = LSM.PeriodicBC(), + reinit = 2, + ) + LSM.integrate!(eq, 0.2) + @test eq isa LSM.LevelSetEquation + end + + @testset "with SemiImplicitI2OE" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (33, 33)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), + ic = ϕ, + bc = LSM.PeriodicBC(), + integrator = LSM.SemiImplicitI2OE(), + reinit = 2, + ) + @test LSM.integrate!(eq, 1.0e-3, 1.0e-3) isa LSM.LevelSetEquation + end +end + +@testset "NarrowBand integrate! — advection matches full grid" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (60, 60)) + r = 0.5 + ϕ = LSM.LevelSet(x -> norm(x) - r, grid) + 𝐮 = LSM.MeshField(x -> SVector(1.0, 0.0), grid) + + nb = NarrowBandLevelSet(ϕ, 0.8; reinitialize = false) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2()) + eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc = ExtrapolationBC(2), integrator = RK2()) + + integrate!(eq_full, 0.1) + integrate!(eq_nb, 0.1) + + nb_s = current_state(eq_nb) + full_s = current_state(eq_full) + inner_err = maximum(LSM.active_indices(nb_s)) do I + abs(nb_s[I]) < 0.4 || return 0.0 + abs(nb_s[I] - full_s[I]) + end + @test inner_err < 1.0e-5 +end + +@testset "NarrowBand integrate! — advection with reinitialization" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (60, 60)) + r = 0.5 + ϕ = LSM.LevelSet(x -> norm(x) - r, grid) + 𝐮 = LSM.MeshField(x -> SVector(1.0, 0.0), grid) + + nb = NarrowBandLevelSet(ϕ, 0.4) + reinit = LSM.NewtonReinitializer(; reinit_freq = 1, upsample = 4) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2(), reinit) + integrate!(eq_nb, 0.1) + + nb_s = current_state(eq_nb) + exact_shifted(x) = norm(x - SVector(0.1, 0.0)) - r + max_err = maximum(LSM.active_indices(nb_s)) do I + x = grid[I] + abs(nb_s[I]) < 0.3 || return 0.0 + abs(nb_s[I] - exact_shifted(x)) + end + @test max_err < 0.01 + @test length(LSM.active_indices(nb_s)) > 0 +end + +@testset "NarrowBand integrate! — spiral curvature flow matches full grid" begin + # Spiral with multiple closely-spaced arms; stresses the band-rebuild logic + # because inter-arm gaps can be narrower than the halfwidth. + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + r0, θ0, α = 0.5, -π / 3, π / 100 + R = [cos(α) -sin(α); sin(α) cos(α)] + M = R * [1 / 0.06^2 0; 0 1 / (4π^2)] * R' + ϕ = LSM.LevelSet(grid) do (x, y) + r, θ = sqrt(x^2 + y^2), atan(y, x) + minimum(0:4) do i + v = [r - r0; θ + (2i - 4) * π - θ0] + sqrt(v' * M * v) - 1 + end + end + reinit = LSM.NewtonReinitializer(; reinit_freq = 1) + b = (x, t) -> -0.1 + + eq_full = LevelSetEquation(; ic = deepcopy(ϕ), bc = ExtrapolationBC(2), terms = (CurvatureTerm(b),), reinit) + eq_nb = LevelSetEquation(; ic = NarrowBandLevelSet(deepcopy(ϕ); nlayers = 3), bc = ExtrapolationBC(2), terms = (CurvatureTerm(b),), reinit) + + integrate!(eq_full, 0.1) + integrate!(eq_nb, 0.1) + + ϕ_full = current_state(eq_full) + ϕ_nb = current_state(eq_nb) + max_err = maximum(I -> abs(ϕ_nb[I] - ϕ_full[I]), LSM.active_indices(ϕ_nb)) + @test max_err < 0.05 +end + +@testset "NarrowBand integrate! — full rotation with nlayers=2" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (40, 40)) + ϕ = LSM.LevelSet(x -> norm(x - SVector(0.8, 0.0)) - 0.5, grid) + 𝐮 = (x, t) -> SVector(-x[2], x[1]) + + nb = NarrowBandLevelSet(ϕ; nlayers = 2) + reinit = LSM.NewtonReinitializer(; reinit_freq = 1, upsample = 4) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2(), reinit) + eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc = ExtrapolationBC(2), integrator = RK2()) + + integrate!(eq_full, 2π) + integrate!(eq_nb, 2π) + + nb_s = current_state(eq_nb) + full_s = current_state(eq_full) + @test length(LSM.active_indices(nb_s)) > 0 + max_err = maximum(LSM.active_indices(nb_s)) do I + abs(nb_s[I] - full_s[I]) + end + @test max_err < 0.01 +end diff --git a/test/test-levelsetterms.jl b/test/test-levelsetterms.jl new file mode 100644 index 0000000..bba9ad0 --- /dev/null +++ b/test/test-levelsetterms.jl @@ -0,0 +1,51 @@ +using Test +using LinearAlgebra +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM + +@testset "AdvectionTerm CFL" begin + grid = LSM.CartesianGrid((-1.0,), (1.0,), (100,)) + ϕ = LSM.LevelSet(x -> x[1], grid) + Δx = LSM.meshsize(ϕ, 1) + term = LSM.AdvectionTerm((x, t) -> SVector(2.0)) + @test LSM.compute_cfl((term,), ϕ, 0.0) ≈ Δx / 2.0 +end + +@testset "CurvatureTerm CFL" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + ϕ = LSM.LevelSet(x -> norm(x) - 0.5, grid) + Δx = minimum(LSM.meshsize(ϕ)) + b = 0.5 + term = LSM.CurvatureTerm((x, t) -> b) + @test LSM.compute_cfl((term,), ϕ, 0.0) ≈ Δx^2 / (2b) +end + +@testset "NormalMotionTerm CFL" begin + grid = LSM.CartesianGrid((-1.0,), (1.0,), (100,)) + ϕ = LSM.LevelSet(x -> x[1], grid) + Δx = LSM.meshsize(ϕ, 1) + v = 3.0 + term = LSM.NormalMotionTerm((x, t) -> v) + @test LSM.compute_cfl((term,), ϕ, 0.0) ≈ Δx / v +end + +@testset "EikonalReinitializationTerm — drives scaled SDF toward unit gradient" begin + # ϕ = 2*(x - 0.3): correct zero set but |∇ϕ| = 2 ≠ 1. + # After pseudo-time marching it should converge to x - 0.3. + grid = LSM.CartesianGrid((-1.0,), (1.0,), (101,)) + ϕ = LSM.LevelSet(x -> 2 * (x[1] - 0.3), grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.EikonalReinitializationTerm(ϕ),), + ic = deepcopy(ϕ), + bc = LSM.LinearExtrapolationBC(), + ) + integrate!(eq, 2.0) + ϕ_out = LSM.current_state(eq) + ϕ_exact = LSM.LevelSet(x -> x[1] - 0.3, grid) + err = maximum(CartesianIndices(LSM.mesh(ϕ_out))) do I + abs(ϕ_out[I]) > 0.5 && return 0.0 + abs(ϕ_out[I] - ϕ_exact[I]) + end + @test err < 0.05 +end diff --git a/test/test-meshes.jl b/test/test-meshes.jl index b0e65aa..6ec74f2 100644 --- a/test/test-meshes.jl +++ b/test/test-meshes.jl @@ -1,5 +1,6 @@ using Test using LevelSetMethods +using StaticArrays @testset "Basic ops" begin nx, ny = 100, 50 @@ -7,4 +8,6 @@ using LevelSetMethods grid = CartesianGrid(a, b, (nx, ny)) @test size(grid) === (nx, ny) @test length(CartesianIndices(grid)) == nx * ny + @test grid[1, 1] == SVector(a[1], a[2]) + @test grid[nx, ny] == SVector(b[1], b[2]) end diff --git a/test/test-meshfield.jl b/test/test-meshfield.jl new file mode 100644 index 0000000..2102b77 --- /dev/null +++ b/test/test-meshfield.jl @@ -0,0 +1,117 @@ +using Test +using LinearAlgebra +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM + +@testset "Construction and accessors" begin + grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (10, 5)) + f = x -> x[1]^2 + x[2]^2 - 0.5 + ϕ = MeshField(f, grid) + @test LSM.mesh(ϕ) === grid + @test LSM.domain(ϕ) isa LSM.FullDomain + @test !LSM.has_boundary_conditions(ϕ) + @test ndims(ϕ) == 2 + @test size(values(ϕ)) == (10, 5) + @test ϕ[3, 2] ≈ f(grid[3, 2]) + @test LSM.meshsize(ϕ) == LSM.meshsize(grid) +end + +@testset "add_boundary_conditions" begin + grid = CartesianGrid((0.0,), (1.0,), (5,)) + ϕ = MeshField(x -> x[1], grid) + @test !LSM.has_boundary_conditions(ϕ) + ϕ_bc = LSM.add_boundary_conditions(ϕ, ((NeumannBC(), NeumannBC()),)) + @test LSM.has_boundary_conditions(ϕ_bc) + @test values(ϕ_bc) === values(ϕ) # underlying data is aliased +end + +@testset "copy!" begin + grid = CartesianGrid((0.0, 0.0), (1.0, 1.0), (5, 5)) + ϕ = LevelSet(x -> x[1] + x[2], grid) + ψ = LevelSet(x -> 0.0, grid) + copy!(ψ, ϕ) + @test values(ψ) == values(ϕ) + @test values(ψ) !== values(ϕ) # copied, not aliased +end + +@testset "update_bcs!" begin + grid = CartesianGrid((0.0,), (1.0,), (5,)) + bc = DirichletBC((x, t) -> t) + ϕ = MeshField(x -> 0.0, grid, ((bc, NeumannBC()),)) + LSM.update_bcs!(ϕ, 3.0) + @test bc.t == 3.0 +end + +@testset "Type aliases" begin + grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (5, 5)) + ϕ = LevelSet(x -> norm(x) - 0.5, grid) + @test ϕ isa LevelSet{2, Float64} + @test ϕ isa MeshField +end + +@testset "Periodic BC getindex" begin + a = (0, 0) + b = (1, 1) + n = (10, 5) + grid = CartesianGrid(a, b, n) + vals = rand(n...) + bcs = ((PeriodicBC(), PeriodicBC()), (PeriodicBC(), PeriodicBC())) + mf = MeshField(vals, grid, bcs) + @test mf[1, 1] == vals[1, 1] + @test mf[1, 0] == vals[1, 4] # wraps around in dim 2 + @test mf[11, 5] == mf[2, 5] # wraps around in dim 1 +end + +@testset "Extrapolation BC getindex" begin + # 1D: ExtrapolationBC{P} reproduces degree-P polynomials exactly, + # on a grid not aligned with [0,1], for multiple ghost layers. + a, b, n = -0.3, 1.7, 10 + grid = CartesianGrid((a,), (b,), (n,)) + h = LSM.meshsize(grid)[1] + for P in 0:5 + bcs = ((ExtrapolationBC(P), ExtrapolationBC(P)),) + for k in 0:P + f = x -> x[1]^k + ϕ_bc = LSM.add_boundary_conditions(MeshField(f, grid), bcs) + # node i is at x = a + (i-1)*h, so ghost 1-j is at x = a - j*h + # and ghost n+j is at x = b + j*h + for j in 1:(P + 1) + @test ϕ_bc[1 - j] ≈ f(a - j * h) atol = 1.0e-10 + @test ϕ_bc[n + j] ≈ f(b + j * h) atol = 1.0e-10 + end + end + end + + # 2D: dimension-by-dimension extrapolation reproduces separable polynomials + # x^j * y^k exactly for j, k ≤ P, including at corner ghost points. + grid2 = CartesianGrid((-0.3, 0.5), (1.7, 2.1), (8, 6)) + h1, h2 = LSM.meshsize(grid2) + a1, a2 = -0.3, 0.5 + b1, b2 = 1.7, 2.1 + n1, n2 = 8, 6 + for P in 1:3 + bcs2 = ((ExtrapolationBC(P), ExtrapolationBC(P)), (ExtrapolationBC(P), ExtrapolationBC(P))) + for j in 0:P, k in 0:P + f = x -> x[1]^j * x[2]^k + ϕ_bc = LSM.add_boundary_conditions(MeshField(f, grid2), bcs2) + # interior ghost in dim 1, in-bounds in dim 2 + @test ϕ_bc[0, 3] ≈ f((a1 - h1, grid2[1, 3][2])) atol = 1.0e-10 + @test ϕ_bc[n1 + 1, 3] ≈ f((b1 + h1, grid2[1, 3][2])) atol = 1.0e-10 + # corner ghost (out-of-bounds in both dims) + @test ϕ_bc[0, 0] ≈ f((a1 - h1, a2 - h2)) atol = 1.0e-10 + @test ϕ_bc[n1 + 1, n2 + 1] ≈ f((b1 + h1, b2 + h2)) atol = 1.0e-10 + end + end +end + +@testset "Dirichlet BC getindex" begin + grid = CartesianGrid((0.0,), (1.0,), (5,)) + h = LSM.meshsize(grid)[1] + bc = DirichletBC((x, t) -> x[1] + t) + bcs = ((bc, NeumannBC()),) + ϕ = MeshField(x -> 0.0, grid, bcs) + @test ϕ[0] ≈ -h + 0.0 # t = 0 initially + LSM.update_bc!(bc, 2.0) + @test ϕ[0] ≈ -h + 2.0 # t updated to 2.0 +end diff --git a/test/test-narrow-band.jl b/test/test-narrow-band.jl new file mode 100644 index 0000000..6f675c7 --- /dev/null +++ b/test/test-narrow-band.jl @@ -0,0 +1,276 @@ +using Test +using LinearAlgebra +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM +using LevelSetMethods: D⁺, D⁻, D⁰, D2⁰, D2, weno5⁻, weno5⁺ + +@testset "Construction" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (100, 100)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + halfwidth = 0.3 + nb = NarrowBandLevelSet(ϕ, halfwidth; reinitialize = false) + + @test 0 < length(LSM.active_indices(nb)) < length(grid) + @test all(I -> abs(nb[I]) < LSM.halfwidth(nb), LSM.active_indices(nb)) + @test all(I -> nb[I] ≈ ϕ[I], LSM.active_indices(nb)) + + # Check that active points satisfy requirements + active_idxs = LSM.active_indices(nb) + @test all(I -> abs(nb[I]) < halfwidth, active_idxs) + inactive_idxs = setdiff(CartesianIndices(grid), active_idxs) + @test all(I -> abs(ϕ[I]) >= halfwidth, inactive_idxs) + + # Automatic halfwidth via nlayers + nb2 = NarrowBandLevelSet(ϕ; nlayers = 8) + Δx = minimum(LSM.meshsize(grid)) + @test LSM.halfwidth(nb2) ≈ 8 * Δx + @test length(LSM.active_indices(nb2)) > 0 + +end + +@testset "Extrapolation outside of narrow band" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (100, 100)) + # Intentionally not an sdf to check exact extrapolation behavior in polynomial + # case. Requires sufficiently large extrap_order + f = x -> x[1]^4 + x[2]^4 - 0.5 + nb = NarrowBandLevelSet(f, grid, 0.3; extrap_order = 4) + active_idxs = LSM.active_indices(nb) + # compute extrema in each dimension of active idxs + Imin = map(d -> minimum(I[d] for I in active_idxs), (1, 2)) |> CartesianIndex + Imax = map(d -> maximum(I[d] for I in active_idxs), (1, 2)) |> CartesianIndex + ok = true + # Check that we can extrapolate correctly, even along diagonals + k = 5 + Ip = CartesianIndices(ntuple(d -> Imax[d]:(Imax[d] + k), 2)) + for I in Ip + @test nb[I] ≈ f(grid[I]) + end + Im = CartesianIndices(ntuple(d -> (Imin[d] - k):Imin[d], 2)) + for I in Im + @test nb[I] ≈ f(grid[I]) + end +end + + +@testset "Derivatives match full grid" begin + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (100, 100)) + ϕ = LSM.LevelSet(x -> x[1]^2 + x[2]^2 - 1, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.5; reinitialize = false) + + best_I = argmin(I -> abs(nb[I]), LSM.active_indices(nb)) + + @testset "First order" begin + for op in (D⁺, D⁻, D⁰) + for dim in 1:2 + @test op(nb, best_I, dim) ≈ op(ϕ_bc, best_I, dim) + end + end + end + + @testset "Second order" begin + for dim in 1:2 + @test D2⁰(nb, best_I, dim) ≈ D2⁰(ϕ_bc, best_I, dim) + end + for dims in ((1, 2), (2, 1)) + @test D2(nb, best_I, dims) ≈ D2(ϕ_bc, best_I, dims) + end + end + + @testset "WENO5" begin + for dim in 1:2 + @test weno5⁻(nb, best_I, dim) ≈ weno5⁻(ϕ_bc, best_I, dim) + @test weno5⁺(nb, best_I, dim) ≈ weno5⁺(ϕ_bc, best_I, dim) + end + end +end + +@testset "Interpolation" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + f(x) = x[1]^2 + 2 * x[2]^2 - 0.5 + ϕ = LSM.LevelSet(f, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.4; reinitialize = false) + + itp_nb = interpolate(nb, 3) + itp_full = interpolate(ϕ_bc, 3) + + for x in [SVector(0.5, 0.0), SVector(0.0, 0.5), SVector(0.3, 0.3)] + @test itp_nb(x) ≈ itp_full(x) + end +end + +@testset "NewtonSDF from narrow band" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + r = 0.5 + exact_sdf(x) = norm(x) - r + ϕ = LSM.LevelSet(exact_sdf, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3) + + sdf = LSM.NewtonSDF(nb; upsample = 4) + @test sdf(SVector(r, 0.0)) ≈ 0.0 atol = 2.0e-5 + @test sdf(SVector(0.0, 0.0)) ≈ -r atol = 2.0e-5 + + sdf_full = LSM.NewtonSDF(ϕ_bc; upsample = 4) + for x in [SVector(0.5, 0.0), SVector(0.3, 0.0), SVector(0.6, 0.0)] + @test sdf(x) ≈ sdf_full(x) atol = 1.0e-5 + end +end + +@testset "Band rebuild with interface motion" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + r = 0.5 + exact_sdf(x) = norm(x) - r + ϕ = LSM.LevelSet(exact_sdf, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3) + n_before = length(LSM.active_indices(nb)) + + sdf = LSM.NewtonSDF(nb; upsample = 4) + LSM.rebuild_band!(nb, sdf) + + @test length(LSM.active_indices(nb)) > 0 + max_err = maximum(LSM.active_indices(nb)) do I + x = grid[I] + abs(nb[I] - exact_sdf(x)) + end + @test max_err < 1.0e-5 + @test abs(length(LSM.active_indices(nb)) - n_before) < 0.1 * n_before +end + +@testset "Copy behavior" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + bc = LSM._normalize_bc(LSM.LinearExtrapolationBC(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + + nb1 = NarrowBandLevelSet(ϕ_bc, 0.2; reinitialize = false) + nb2 = NarrowBandLevelSet(ϕ_bc, 0.4; reinitialize = false) + + copy!(nb2, nb1) + @test LSM.active_indices(nb1) == LSM.active_indices(nb2) + @test all(I -> nb1[I] ≈ nb2[I], LSM.active_indices(nb1)) +end + +@testset "eachindex iteration" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + bc = LSM._normalize_bc(LSM.LinearExtrapolationBC(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3; reinitialize = false) + + @test collect(eachindex(nb)) == collect(LSM.active_indices(nb)) +end + +@testset "Extrapolation at band boundary" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + bc = LSM._normalize_bc(LSM.LinearExtrapolationBC(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3) + + # Find an in-grid node outside the band + non_band = findfirst( + I -> !haskey(values(nb), I) && all(s -> I[s] in axes(nb)[s], 1:2), + CartesianIndices(grid) + ) + @test non_band !== nothing + + # Should extrapolate without error + val = nb[non_band] + @test isfinite(val) +end + +@testset "Corner extrapolation (multi-dimensional)" begin + # Tests tensor-product extrapolation: a point outside band in both dimensions + grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (40, 40)) + f(x) = x[1]^2 + x[2]^2 - 1.0 + ϕ = LSM.LevelSet(f, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.5; reinitialize = false) + vals = values(nb) + + # Check all out-of-band points against full grid + max_err = 0.0 + for I in CartesianIndices(grid) + haskey(vals, I) && continue + all(d -> I[d] in axes(nb)[d], 1:2) || continue + max_err = max(max_err, abs(nb[I] - ϕ_bc[I])) + end + @test max_err < 1.0e-10 +end + +@testset "Periodic BC" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) + bc = LSM._normalize_bc(LSM.PeriodicBC(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3) + + I = CartesianIndex(0, 25) + @test isfinite(nb[I]) +end + +@testset "3D polynomial extrapolation exactness" begin + grid = LSM.CartesianGrid((-2.0, -2.0, -2.0), (2.0, 2.0, 2.0), (20, 20, 20)) + f(x) = x[1]^2 + x[2]^2 + x[3]^2 - 1.0 + ϕ = LSM.LevelSet(f, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 3) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.5; reinitialize = false) + vals = values(nb) + + max_err = 0.0 + for I in CartesianIndices(grid) + haskey(vals, I) && continue + all(d -> I[d] in axes(nb)[d], 1:3) || continue + max_err = max(max_err, abs(nb[I] - ϕ_bc[I])) + end + @test max_err < 1.0e-10 +end + +@testset "Auto-reinitialization of non-SDF input" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (100, 100)) + d = 1; r0 = 0.5; θ0 = -π / 3; α = π / 100.0 + R = [cos(α) -sin(α); sin(α) cos(α)]; M = R * [1 / 0.06^2 0; 0 1 / (4π^2)] * R' + ϕ = LSM.LevelSet(grid) do (x, y) + r = sqrt(x^2 + y^2); θ = atan(y, x); res = 1.0e30 + for i in 0:4 + θ1 = θ + (2i - 4) * π; v = [r - r0; θ1 - θ0] + res = min(res, sqrt(v' * M * v) - d) + end + res + end + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + + nb = NarrowBandLevelSet(ϕ_bc; nlayers = 6) + @test length(LSM.active_indices(nb)) > 3000 +end + +@testset "3D narrow band" begin + grid = LSM.CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (25, 25, 25)) + ϕ = LSM.LevelSet(x -> norm(x) - 0.45, grid) + bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 3) + ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) + nb = NarrowBandLevelSet(ϕ_bc, 0.3; reinitialize = false) + + @test ndims(nb) == 3 + @test length(LSM.active_indices(nb)) > 0 + @test length(LSM.active_indices(nb)) < length(grid) + + best_I = argmin(I -> abs(nb[I]), LSM.active_indices(nb)) + for dim in 1:3 + @test D⁰(nb, best_I, dim) ≈ D⁰(ϕ_bc, best_I, dim) + end + + sdf = LSM.NewtonSDF(nb; upsample = 3) + @test sdf(SVector(0.45, 0.0, 0.0)) ≈ 0.0 atol = 1.0e-3 +end diff --git a/test/test-reinitialization.jl b/test/test-reinitialization.jl deleted file mode 100644 index 888e02f..0000000 --- a/test/test-reinitialization.jl +++ /dev/null @@ -1,64 +0,0 @@ -using Test -using LinearAlgebra -using StaticArrays -import LevelSetMethods as LSM - -@testset "NewtonReinitializer 2D circle" begin - grid = LSM.CartesianGrid((-1, -1), (1, 1), (100, 100)) - exact_sdf(x) = sqrt(x[1]^2 + x[2]^2) - 0.5 - ϕ = LSM.LevelSet(x -> (x[1]^2 + x[2]^2) - 0.5^2, grid) - - @test abs(LSM.volume(ϕ) - π / 4) < 1.0e-2 - - reinit = LSM.NewtonReinitializer() - LSM.reinitialize!(ϕ, reinit) - - max_er = maximum(eachindex(grid)) do i - abs(ϕ[i] - exact_sdf(grid[i])) - end - @test max_er < 1.0e-8 - - # volume is preserved after reinitialization - @test abs(LSM.volume(ϕ) - π / 4) < 1.0e-2 -end - -@testset "NewtonReinitializer 3D sphere" begin - grid = LSM.CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (31, 31, 31)) - exact_sdf(x) = sqrt(x[1]^2 + x[2]^2 + x[3]^2) - 0.45 - ϕ = LSM.LevelSet(x -> (x[1]^2 + x[2]^2 + x[3]^2) - 0.45^2, grid) - - reinit = LSM.NewtonReinitializer(; upsample = 4) - LSM.reinitialize!(ϕ, reinit) - - max_er = maximum(eachindex(grid)) do i - abs(ϕ[i] - exact_sdf(grid[i])) - end - @test max_er < 5.0e-3 -end - -@testset "NewtonReinitializer in LevelSetEquation" begin - grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (33, 33)) - ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) - - eq = LSM.LevelSetEquation(; - terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), - levelset = ϕ, - bc = LSM.PeriodicBC(), - reinit = 2, - ) - @test LSM.integrate!(eq, 1.0e-3, 1.0e-3) isa LSM.LevelSetEquation -end - -@testset "NewtonReinitializer with SemiImplicitI2OE" begin - grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (33, 33)) - ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) - - eq = LSM.LevelSetEquation(; - terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), - levelset = ϕ, - bc = LSM.PeriodicBC(), - integrator = LSM.SemiImplicitI2OE(), - reinit = 2, - ) - @test LSM.integrate!(eq, 1.0e-3, 1.0e-3) isa LSM.LevelSetEquation -end diff --git a/test/test-reinitializer.jl b/test/test-reinitializer.jl new file mode 100644 index 0000000..b3a6b14 --- /dev/null +++ b/test/test-reinitializer.jl @@ -0,0 +1,122 @@ +using Test +using LinearAlgebra +using StaticArrays +import LevelSetMethods as LSM +using LevelSetMethods: NewtonSDF, update!, get_sample_points, interpolate + +function check_allocs(f, x) + f(x) # warmup + return @allocated f(x) +end + +@testset "NewtonSDF" begin + @testset "2D circle" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + r = 0.5 + exact_sdf(x) = norm(x) - r + ϕ = LSM.LevelSet(exact_sdf, grid) + sdf = NewtonSDF(ϕ; upsample = 4) + + @test sdf isa LSM.AbstractSignedDistanceFunction + + # spot checks: inside, on, and outside the interface + @test sdf(SVector(0.0, 0.0)) ≈ -r atol = 2.0e-5 + @test sdf(SVector(r, 0.0)) ≈ 0.0 atol = 2.0e-5 + @test sdf(SVector(1.0, 0.0)) ≈ 1 - r atol = 2.0e-5 + + # global accuracy over sampled grid points + indices = CartesianIndices(grid) + max_err = maximum(1:10:length(indices)) do k + abs(sdf(grid[indices[k]]) - exact_sdf(grid[indices[k]])) + end + @test max_err < 1.0e-5 + + @test check_allocs(sdf, SVector(0.25, 0.25)) == 0 + end + + @testset "3D sphere" begin + grid = LSM.CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (25, 25, 25)) + r = 0.45 + exact_sdf(x) = norm(x) - r + ϕ = LSM.LevelSet(exact_sdf, grid) + sdf = NewtonSDF(ϕ; upsample = 3) + + @test sdf(SVector(r, 0.0, 0.0)) ≈ 0.0 atol = 1.0e-4 + @test sdf(SVector(0.0, 0.0, 0.0)) ≈ -r atol = 1.0e-4 + + indices = CartesianIndices(grid) + max_err = maximum(1:20:length(indices)) do k + abs(sdf(grid[indices[k]]) - exact_sdf(grid[indices[k]])) + end + @test max_err < 5.0e-3 + + @test check_allocs(sdf, SVector(0.25, 0.25, 0.25)) == 0 + end + + @testset "from PiecewisePolynomialInterpolant" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) + ϕ = LSM.LevelSet(x -> norm(x) - 0.5, grid) + itp = interpolate(ϕ, 3) + sdf = NewtonSDF(itp; upsample = 4) + @test sdf(SVector(0.5, 0.0)) ≈ 0.0 atol = 2.0e-5 + end + + @testset "get_sample_points" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (20, 20)) + ϕ = LSM.LevelSet(x -> norm(x) - 0.5, grid) + sdf = NewtonSDF(ϕ; upsample = 3) + pts = get_sample_points(sdf) + @test length(pts) > 0 + @test maximum(p -> abs(sdf.itp(p)), pts) < 1.0e-6 + end + + @testset "update!" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (40, 40)) + r1, r2 = 0.5, 0.3 + sdf = NewtonSDF(LSM.LevelSet(x -> norm(x) - r1, grid); upsample = 4) + @test sdf(SVector(r1, 0.0)) ≈ 0.0 atol = 2.0e-4 + + update!(sdf, LSM.LevelSet(x -> norm(x) - r2, grid)) + @test sdf(SVector(r2, 0.0)) ≈ 0.0 atol = 2.0e-4 + @test sdf(SVector(r1, 0.0)) ≈ r1 - r2 atol = 1.0e-4 + end + + @testset "deepcopy" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) + sdf = NewtonSDF(LSM.LevelSet(x -> norm(x) - 0.5, grid); upsample = 4) + sdf2 = deepcopy(sdf) + @test sdf2(SVector(0.5, 0.0)) ≈ 0.0 atol = 2.0e-5 + @test sdf2(SVector(0.0, 0.0)) ≈ -0.5 atol = 2.0e-5 + end +end + +@testset "NewtonReinitializer" begin + @testset "2D circle" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (100, 100)) + exact_sdf(x) = sqrt(x[1]^2 + x[2]^2) - 0.5 + ϕ = LSM.LevelSet(x -> (x[1]^2 + x[2]^2) - 0.25, grid) + + @test abs(LSM.volume(ϕ) - π / 4) < 1.0e-2 + + LSM.reinitialize!(ϕ, LSM.NewtonReinitializer()) + + max_err = maximum(eachindex(grid)) do i + abs(ϕ[i] - exact_sdf(grid[i])) + end + @test max_err < 1.0e-8 + @test abs(LSM.volume(ϕ) - π / 4) < 1.0e-2 # volume preserved + end + + @testset "3D sphere" begin + grid = LSM.CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (31, 31, 31)) + exact_sdf(x) = sqrt(x[1]^2 + x[2]^2 + x[3]^2) - 0.45 + ϕ = LSM.LevelSet(x -> (x[1]^2 + x[2]^2 + x[3]^2) - 0.45^2, grid) + + LSM.reinitialize!(ϕ, LSM.NewtonReinitializer(; upsample = 4)) + + max_err = maximum(eachindex(grid)) do i + abs(ϕ[i] - exact_sdf(grid[i])) + end + @test max_err < 5.0e-3 + end +end diff --git a/test/test-sdf.jl b/test/test-sdf.jl deleted file mode 100644 index d1433e1..0000000 --- a/test/test-sdf.jl +++ /dev/null @@ -1,96 +0,0 @@ -using LevelSetMethods -using StaticArrays -using LinearAlgebra -using Test - -import LevelSetMethods: NewtonSDF, AbstractSignedDistanceFunction, update!, get_sample_points - -function check_allocs(f, x) - f(x) # warmup - return @allocated f(x) -end - -@testset "NewtonSDF 2D circle" begin - grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) - r = 0.5 - exact_sdf(x) = norm(x) - r - ϕ = LevelSet(exact_sdf, grid) - sdf = NewtonSDF(ϕ; upsample = 4) - - @test sdf isa AbstractSignedDistanceFunction - - indices = CartesianIndices(grid) - max_err = maximum(1:10:length(indices)) do k - x = grid[indices[k]] - abs(sdf(x) - exact_sdf(x)) - end - @test max_err < 1.0e-5 - - @test sdf(SVector(0.0, 0.0)) ≈ -r atol = 2.0e-5 - @test sdf(SVector(r, 0.0)) ≈ 0.0 atol = 2.0e-5 - @test sdf(SVector(1.0, 0.0)) ≈ 1 - r atol = 2.0e-5 - - @test check_allocs(sdf, SVector(0.25, 0.25)) == 0 -end - -@testset "NewtonSDF from PiecewisePolynomialInterpolation" begin - grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) - ϕ = LevelSet(x -> norm(x) - 0.5, grid) - itp = interpolate(ϕ, 3) - sdf = NewtonSDF(itp; upsample = 4) - @test sdf isa AbstractSignedDistanceFunction - @test sdf(SVector(0.5, 0.0)) ≈ 0.0 atol = 2.0e-5 -end - -@testset "get_sample_points" begin - grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (20, 20)) - ϕ = LevelSet(x -> norm(x) - 0.5, grid) - sdf = NewtonSDF(ϕ; upsample = 4) - pts = get_sample_points(sdf) - @test pts isa Vector - @test length(pts) > 0 - max_res = maximum(p -> abs(sdf.itp(p)), pts) - @test max_res < 1.0e-6 -end - -@testset "update! rebuilds from new level set" begin - grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (40, 40)) - r1, r2 = 0.5, 0.3 - ϕ = LevelSet(x -> norm(x) - r1, grid) - sdf = NewtonSDF(ϕ; upsample = 4) - @test sdf(SVector(r1, 0.0)) ≈ 0.0 atol = 2.0e-4 - - ϕ2 = LevelSet(x -> norm(x) - r2, grid) - update!(sdf, ϕ2) - @test sdf(SVector(r2, 0.0)) ≈ 0.0 atol = 2.0e-4 - @test sdf(SVector(r1, 0.0)) ≈ r1 - r2 atol = 1.0e-4 -end - -@testset "deepcopy produces independent copy" begin - grid = CartesianGrid((-1.0, -1.0), (1.0, 1.0), (30, 30)) - ϕ = LevelSet(x -> norm(x) - 0.5, grid) - sdf = NewtonSDF(ϕ; upsample = 4) - sdf2 = deepcopy(sdf) - @test sdf2(SVector(0.5, 0.0)) ≈ 0.0 atol = 2.0e-5 - @test sdf2(SVector(0.0, 0.0)) ≈ -0.5 atol = 2.0e-5 -end - -@testset "NewtonSDF 3D sphere" begin - grid = CartesianGrid((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (25, 25, 25)) - r = 0.45 - exact_sdf(x) = norm(x) - r - ϕ = LevelSet(exact_sdf, grid) - sdf = NewtonSDF(ϕ; upsample = 3) - - @test sdf(SVector(r, 0.0, 0.0)) ≈ 0.0 atol = 1.0e-4 - @test sdf(SVector(0.0, 0.0, 0.0)) ≈ -r atol = 1.0e-4 - - indices = CartesianIndices(grid) - max_err = maximum(1:20:length(indices)) do k - x = grid[indices[k]] - abs(sdf(x) - exact_sdf(x)) - end - @test max_err < 5.0e-3 - - @test check_allocs(sdf, SVector(0.25, 0.25, 0.25)) == 0 -end diff --git a/test/test-semi-implicit.jl b/test/test-semi-implicit.jl index 8959716..2d5f0d8 100644 --- a/test/test-semi-implicit.jl +++ b/test/test-semi-implicit.jl @@ -11,7 +11,7 @@ using Test eq = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = SemiImplicitI2OE(cfl = 3.0), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) @@ -35,7 +35,7 @@ end eq = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = SemiImplicitI2OE(cfl = 2.5), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) @@ -57,8 +57,8 @@ end eq_neumann = LevelSetEquation(; terms = (term_neumann,), integrator = SemiImplicitI2OE(cfl = 4.0), - levelset = deepcopy(ϕ0), - bc = NeumannGradientBC(), + ic = deepcopy(ϕ0), + bc = LinearExtrapolationBC(), ) integrate!(eq_neumann, 0.6) @test maximum(abs.(values(LevelSetMethods.current_state(eq_neumann)) .- 0.7)) < 1.0e-12 @@ -67,8 +67,8 @@ end eq_dirichlet = LevelSetEquation(; terms = (term_dirichlet,), integrator = SemiImplicitI2OE(cfl = 2.0), - levelset = LevelSet(x -> 0.0, grid), - bc = DirichletBC(0.0), + ic = LevelSet(x -> 0.0, grid), + bc = DirichletBC((x, t) -> 0.0), ) integrate!(eq_dirichlet, 0.4) vals = values(LevelSetMethods.current_state(eq_dirichlet)) @@ -85,7 +85,7 @@ end CurvatureTerm((x, t) -> -0.1), ), integrator = SemiImplicitI2OE(), - levelset = ϕ1d, + ic = ϕ1d, bc = PeriodicBC(), ) @test_throws ArgumentError integrate!(eq_multiterm, 0.1) @@ -95,7 +95,7 @@ end eq_small = LevelSetEquation(; terms = (AdvectionTerm((x, t) -> 1.0, Upwind()),), integrator = SemiImplicitI2OE(), - levelset = ϕ_small, + ic = ϕ_small, bc = NeumannBC(), ) @test_throws ArgumentError integrate!(eq_small, 0.1) @@ -112,13 +112,13 @@ end eq_semi = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = SemiImplicitI2OE(cfl = 2.0), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) eq_explicit = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = ForwardEuler(cfl = 2.0), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) @@ -152,13 +152,13 @@ end eq_semi = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = SemiImplicitI2OE(cfl = cfl_high), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) eq_explicit = LevelSetEquation(; terms = (AdvectionTerm(vel, Upwind()),), integrator = ForwardEuler(cfl = cfl_high), - levelset = deepcopy(ϕ0), + ic = deepcopy(ϕ0), bc = PeriodicBC(), ) diff --git a/test/test-show.jl b/test/test-show.jl new file mode 100644 index 0000000..1adb923 --- /dev/null +++ b/test/test-show.jl @@ -0,0 +1,140 @@ +using Test +using LevelSetMethods +using StaticArrays + +# Helper: get text/plain output of x +showstr(x) = sprint(show, MIME("text/plain"), x) + +@testset "CartesianGrid" begin + g = CartesianGrid((0, 0), (1, 1), (10, 4)) + s = showstr(g) + @test startswith(s, "CartesianGrid in ℝ²") + @test occursin("├─ domain: [0.0, 1.0] × [0.0, 1.0]", s) + @test occursin("├─ nodes: 10 × 4", s) + @test occursin("└─ spacing: h = (0.1111, 0.3333)", s) +end + +@testset "BoundaryConditions" begin + @test sprint(show, PeriodicBC()) == "Periodic" + @test sprint(show, NeumannBC()) == "Degree 0 extrapolation" + @test sprint(show, LinearExtrapolationBC()) == "Degree 1 extrapolation" + @test sprint(show, ExtrapolationBC{4}()) == "Degree 4 extrapolation" + @test sprint(show, DirichletBC((x, t) -> 0.0)) == "Dirichlet" +end + +@testset "MeshField" begin + grid = CartesianGrid((-1, -1), (1, 1), (5, 5)) + + @testset "scalar, no bc" begin + ϕ = MeshField(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + s = showstr(ϕ) + @test startswith(s, "MeshField on CartesianGrid in ℝ²") + @test occursin("├─ domain: [-1.0, 1.0] × [-1.0, 1.0]", s) + @test occursin("├─ nodes: 5 × 5", s) + @test occursin("├─ spacing: h = (0.5, 0.5)", s) + @test !occursin("bc:", s) + @test occursin("├─ eltype: Float64", s) + @test occursin("└─ values: min = -0.25, max = 1.75", s) + end + + @testset "scalar, with bc" begin + ϕ = LevelSet(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + eq = LevelSetEquation(; terms = (NormalMotionTerm(1.0),), ic = ϕ, bc = NeumannBC()) + s = showstr(current_state(eq)) + @test occursin("├─ bc: Degree 0 extrapolation (all)", s) + end + + @testset "vector-valued" begin + u = MeshField(x -> SVector(x[1], x[2]), grid) + s = showstr(u) + @test startswith(s, "MeshField on CartesianGrid in ℝ²") + @test occursin("└─ eltype: SVector{2, Float64}", s) + @test !occursin("values", s) + @test !occursin("bc:", s) + end +end + +@testset "Time integrators" begin + @test showstr(ForwardEuler()) == "ForwardEuler (1st order explicit)\n └─ cfl: 0.5" + @test showstr(RK2()) == "RK2 (2nd order TVD Runge-Kutta, Heun's method)\n └─ cfl: 0.5" + @test showstr(RK3()) == "RK3 (3rd order TVD Runge-Kutta)\n └─ cfl: 0.5" + @test showstr(SemiImplicitI2OE()) == + "SemiImplicitI2OE (semi-implicit advection, Mikula et al.)\n └─ cfl: 2.0" + @test showstr(ForwardEuler(; cfl = 0.3)) == "ForwardEuler (1st order explicit)\n └─ cfl: 0.3" +end + +@testset "NewtonReinitializer" begin + s = showstr(NewtonReinitializer()) + @test startswith(s, "NewtonReinitializer") + @test occursin("├─ frequency: every step", s) + @test occursin("├─ order: 3", s) + @test occursin("├─ upsample: 8×", s) + @test occursin("├─ maxiters: 20", s) + @test occursin("├─ xtol: 1.0e-8", s) + @test occursin("└─ ftol: 1.0e-8", s) + + s5 = showstr(NewtonReinitializer(; reinit_freq = 5)) + @test occursin("├─ frequency: every 5 steps", s5) +end + +@testset "LevelSetEquation" begin + grid = CartesianGrid((-1, -1), (1, 1), (20, 20)) + ϕ = LevelSet(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + 𝐮 = MeshField(x -> SVector(1.0, 0.0), grid) + eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = ϕ, bc = NeumannBC()) + + s = showstr(eq) + @test startswith(s, "LevelSetEquation") + @test occursin("├─ equation: ϕₜ + 𝐮 ⋅ ∇ ϕ = 0", s) + @test occursin("├─ time: 0.0", s) + @test occursin("├─ integrator: RK2", s) + @test occursin("│ └─ cfl: 0.5", s) + @test occursin("├─ reinit: none", s) + @test occursin("├─ state: MeshField on CartesianGrid in ℝ²", s) + @test occursin("│ ├─ bc: Degree 0 extrapolation (all)", s) + @test occursin("│ ├─ eltype: Float64", s) + @test occursin("├─ log: SimulationLog (empty)", s) + @test endswith(s, "╰─") + + # Compact show (no MIME) + @test sprint(show, eq) == "LevelSetEquation(ϕₜ + 𝐮 ⋅ ∇ ϕ = 0, t=0.0)" +end + +@testset "SimulationLog" begin + grid = CartesianGrid((-1, -1), (1, 1), (20, 20)) + ϕ = LevelSet(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + 𝐮 = MeshField(x -> SVector(1.0, 0.0), grid) + eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = ϕ, bc = NeumannBC()) + + @testset "empty" begin + @test showstr(eq.log) == "SimulationLog (empty)" + end + + integrate!(eq, 0.1) + + @testset "after integration" begin + s = showstr(eq.log) + @test occursin("SimulationLog:", s) + @test occursin("steps", s) + @test occursin("wall time", s) + @test occursin("reinit: none", s) + @test occursin("𝐮 ⋅ ∇ ϕ", s) + @test occursin("compute", s) + @test occursin("ϕ range", s) + @test occursin("Δt:", s) && occursin("min=", s) && occursin("max=", s) && occursin("avg=", s) + end + + @testset "with reinit" begin + ϕ2 = LevelSet(x -> x[1]^2 + x[2]^2 - 0.5^2, grid) + eq2 = LevelSetEquation(; + terms = (AdvectionTerm(𝐮),), + ic = ϕ2, + bc = NeumannBC(), + reinit = NewtonReinitializer(; reinit_freq = 1), + ) + integrate!(eq2, 0.05) + s = showstr(eq2.log) + @test occursin("reinit:", s) + @test !occursin("reinit: none", s) + end +end diff --git a/test/test-spiral.jl b/test/test-spiral.jl new file mode 100644 index 0000000..a82e347 --- /dev/null +++ b/test/test-spiral.jl @@ -0,0 +1,43 @@ +using Test +using LinearAlgebra +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM + +@testset "Spiral curvature flow — narrow band matches full grid" begin + # Spiral with multiple closely-spaced arms; stresses the band-rebuild logic + # because inter-arm gaps can be narrower than the halfwidth. + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (50, 50)) + d = 1; r0 = 0.5; θ0 = -π / 3; α = π / 100.0 + R = [cos(α) -sin(α); sin(α) cos(α)]; M = R * [1 / 0.06^2 0; 0 1 / (4π^2)] * R' + ϕ = LSM.LevelSet(grid) do (x, y) + r = sqrt(x^2 + y^2); θ = atan(y, x); res = 1.0e30 + for i in 0:4 + θ1 = θ + (2i - 4) * π; v = [r - r0; θ1 - θ0] + res = min(res, sqrt(v' * M * v) - d) + end + res + end + b = (x, t) -> -0.1 + + eq_full = LevelSetEquation(; + ic = deepcopy(ϕ), terms = (CurvatureTerm(b),), + bc = ExtrapolationBC(2), reinit = LSM.NewtonReinitializer(; reinit_freq = 1), + ) + nb = NarrowBandLevelSet(deepcopy(ϕ); nlayers = 3, reinitialize = true) + eq_nb = LevelSetEquation(; + ic = nb, terms = (CurvatureTerm(b),), + bc = ExtrapolationBC(2), reinit = LSM.NewtonReinitializer(; reinit_freq = 1), + ) + + tf = 0.1 + integrate!(eq_full, tf) + integrate!(eq_nb, tf) + + ϕ_full = current_state(eq_full) + ϕ_nb = current_state(eq_nb) + max_err = maximum(LSM.active_indices(ϕ_nb)) do I + abs(ϕ_nb[I] - ϕ_full[I]) + end + @test max_err < 0.05 +end diff --git a/test/test-timestepping.jl b/test/test-timestepping.jl new file mode 100644 index 0000000..50ea5f6 --- /dev/null +++ b/test/test-timestepping.jl @@ -0,0 +1,46 @@ +using Test +using StaticArrays +using LevelSetMethods +import LevelSetMethods as LSM + +# Run 1D periodic advection of sin(πx) with constant unit velocity and return the L∞ error +# at the final time. WENO5 (5th order spatial) is used so that temporal error dominates. +function _advection_error_1d(integrator, N; u = 1.0, tf = 0.5) + grid = LSM.CartesianGrid((-1.0,), (1.0,), (N,)) + ϕ = LSM.LevelSet(x -> sin(π * x[1]), grid) + eq = LSM.LevelSetEquation(; + terms = (LSM.AdvectionTerm((x, t) -> SVector(u)),), + ic = ϕ, + bc = LSM.PeriodicBC(), + integrator = integrator, + ) + integrate!(eq, tf) + ϕ_out = LSM.current_state(eq) + return maximum(CartesianIndices(LSM.mesh(ϕ_out))) do I + abs(ϕ_out[I] - sin(π * (grid[I][1] - u * tf))) + end +end + +@testset "ForwardEuler — 1D advection accuracy" begin + @test _advection_error_1d(ForwardEuler(), 200) < 0.05 +end + +@testset "RK2 — 1D advection accuracy" begin + @test _advection_error_1d(RK2(), 200) < 1.0e-3 +end + +@testset "RK3 — 1D advection accuracy" begin + @test _advection_error_1d(RK3(), 200) < 1.0e-5 +end + +@testset "Convergence order — 1D periodic advection" begin + Ns = [50, 100, 200, 400] + cases = [(ForwardEuler(), 1), (RK2(), 2), (RK3(), 3)] + for (integrator, expected_order) in cases + errors = [_advection_error_1d(integrator, N) for N in Ns] + for i in 1:(length(Ns) - 1) + order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) + @test order ≥ expected_order - 0.5 + end + end +end diff --git a/test/test-velocityextension.jl b/test/test-velocityextension.jl index afe3369..327d791 100644 --- a/test/test-velocityextension.jl +++ b/test/test-velocityextension.jl @@ -132,13 +132,13 @@ function _run_curvature_extension_cycle!( eq_motion = LevelSetEquation(; terms = (NormalMotionTerm(speed, update_speed),), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC(), integrator = ForwardEuler(cfl = 0.35), ) eq_reinit = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC(), integrator = ForwardEuler(cfl = 0.45), ) @@ -176,7 +176,7 @@ end ϕ; nsteps = 3, dt_motion = 1.2e-3, - dt_reinit = 0.2 * Δ, + dt_reinit = Δ, ext_iters = 30, ) @@ -274,7 +274,7 @@ end term = NormalMotionTerm(MeshField(v, grid, nothing)) eq = LevelSetEquation(; terms = (term,), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC(), integrator = ForwardEuler(cfl = 0.3), ) From a61fbba132ab3abc7c6328ca53e55d8b33ad4d66 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sat, 28 Mar 2026 15:12:51 +0100 Subject: [PATCH 04/12] update docs and Makie extension - Update docs to reflect new API (`ic` kwarg, new BC names, `NarrowBandLevelSet`) - Expand Makie extension with narrow-band plotting support - Minor updates to MMG extensions for new `LevelSetEquation` struct layout - Add *.markdown-preview.html to .gitignore --- .gitignore | 1 + docs/src/boundary-conditions.md | 18 ++--- docs/src/example-mass-conservation.md | 2 +- docs/src/example-shape-optim.md | 4 +- docs/src/example-zalesak.md | 4 +- docs/src/index.md | 4 +- docs/src/interpolation.md | 6 +- docs/src/reinitialization.md | 4 +- docs/src/terms.md | 16 ++-- ext/MMGSurfaceExt.jl | 2 +- ext/MMGVolumeExt.jl | 2 +- ext/MakieExt.jl | 111 +++++++++++++++++++++++--- 12 files changed, 133 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index dbdfbc9..13e081d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ sandbox *.msh *.sol *.mesh +*.markdown-preview.html diff --git a/docs/src/boundary-conditions.md b/docs/src/boundary-conditions.md index c6476d7..40d6fcd 100644 --- a/docs/src/boundary-conditions.md +++ b/docs/src/boundary-conditions.md @@ -7,10 +7,10 @@ The following boundary conditions are available: | [`PeriodicBC`](@ref) | Periodic (wrap-around) | | [`DirichletBC`](@ref) | Prescribed boundary value | | [`ExtrapolationBC{P}`](@ref ExtrapolationBC) | P-th order one-sided polynomial extrapolation | -| `NeumannBC` | Alias for `ExtrapolationBC{1}` (constant extension, ∂ϕ/∂n = 0) | -| `NeumannGradientBC` | Alias for `ExtrapolationBC{2}` (linear extrapolation, ∂²ϕ/∂n² = 0) | +| `NeumannBC` | Alias for `ExtrapolationBC{0}` (constant extension, ∂ϕ/∂n = 0) | +| `LinearExtrapolationBC` | Alias for `ExtrapolationBC{1}` (linear extrapolation, ∂²ϕ/∂n² = 0) | -`ExtrapolationBC{P}` uses the `P` nearest interior cells to build a degree `P-1` polynomial +`ExtrapolationBC{P}` uses the `P+1` nearest interior cells to build a degree-`P` polynomial and extrapolates it into the ghost region. Higher `P` gives smoother outflow at the cost of a wider stencil. @@ -32,7 +32,7 @@ using LevelSetMethods, GLMakie grid = CartesianGrid((-1,-1), (1,1), (100, 100)) ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) bc = PeriodicBC() -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -42,10 +42,10 @@ end fig ``` -Changing `PeriodicBC()` to `NeumannBC()` gives allows for the level-set to "leak" out of the domain: +Changing `PeriodicBC()` to `NeumannBC()` allows the level-set to "leak" out of the domain: ```@example boundary-conditions -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -59,7 +59,7 @@ To combine both boundary conditions you can use ```@example boundary-conditions bc = (NeumannBC(), PeriodicBC()) # Neumann in x, periodic in y -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -70,11 +70,11 @@ fig ``` For higher-order outflow you can use `ExtrapolationBC{P}` directly. For example, -`ExtrapolationBC{5}` fits a degree-4 polynomial through the 5 nearest interior cells: +`ExtrapolationBC{5}` fits a degree-5 polynomial through the 6 nearest interior cells: ```@example boundary-conditions bc = ExtrapolationBC(5) -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) diff --git a/docs/src/example-mass-conservation.md b/docs/src/example-mass-conservation.md index e38cb85..e154d74 100644 --- a/docs/src/example-mass-conservation.md +++ b/docs/src/example-mass-conservation.md @@ -39,7 +39,7 @@ timestamps = range(0, tf; length = nframes + 1) function track_volume(integrator) ϕ = deepcopy(ϕ₀) eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), integrator, diff --git a/docs/src/example-shape-optim.md b/docs/src/example-shape-optim.md index 1885e25..260919c 100644 --- a/docs/src/example-shape-optim.md +++ b/docs/src/example-shape-optim.md @@ -93,9 +93,9 @@ term1 = NormalMotionTerm(MeshField(X -> 0.0, grid)) term2 = CurvatureTerm(MeshField(X -> -1.0, grid)) terms = (term1, term2) -bc = NeumannGradientBC() +bc = LinearExtrapolationBC() integrator = ForwardEuler(0.5) -eq = LevelSetEquation(; terms, integrator, levelset = ϕ, t = 0, bc) +eq = LevelSetEquation(; terms, integrator, ic = ϕ, t = 0, bc) using GLMakie diff --git a/docs/src/example-zalesak.md b/docs/src/example-zalesak.md index c20b17d..2427243 100644 --- a/docs/src/example-zalesak.md +++ b/docs/src/example-zalesak.md @@ -40,7 +40,7 @@ simulates rotation: ```@example zalesak_disk_example # Define the advection equation with a rotational velocity field eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), ) @@ -97,7 +97,7 @@ rec = LevelSetMethods.rectangle( ) ϕ = setdiff(disk, rec) eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> π * SVector(x[2], -x[1], 0)), bc = NeumannBC(), ) diff --git a/docs/src/index.md b/docs/src/index.md index 228f237..7fb3df6 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -44,7 +44,7 @@ grid = CartesianGrid((-1, -1), (1, 1), (100, 100)) 𝐮 = (x,t) -> (-x[2], x[1]) eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC() ) ``` @@ -64,7 +64,7 @@ To step it in time, we can use the [`integrate!`](@ref) function: integrate!(eq, 1) ``` -This will advance the solution up to `t = 1`, modifying `ϕ` in the process: +This will advance the solution up to `t = 1`, modifying the equation's state in the process: ```@example ls-intro plot(ϕ) diff --git a/docs/src/interpolation.md b/docs/src/interpolation.md index 5bd26d4..9e16154 100644 --- a/docs/src/interpolation.md +++ b/docs/src/interpolation.md @@ -15,13 +15,13 @@ using LevelSetMethods a, b = (-2.0, -2.0), (2.0, 2.0) ϕ = LevelSetMethods.star(CartesianGrid(a, b, (50, 50))) # Add boundary conditions for safe evaluation near edges -bc = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 2) +bc = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 2) ϕ = LevelSetMethods.add_boundary_conditions(ϕ, bc) itp = interpolate(ϕ) # cubic interpolation by default (order=3) ``` -The returned object is a [`PiecewisePolynomialInterpolation`](@ref LevelSetMethods.PiecewisePolynomialInterpolation), which is callable and +The returned object is a [`PiecewisePolynomialInterpolant`](@ref LevelSetMethods.PiecewisePolynomialInterpolant), which is callable and efficient. Once constructed, the interpolant can be used to evaluate the level-set function anywhere inside (and even slightly outside, using boundary conditions) the grid: @@ -69,7 +69,7 @@ P1, P2 = (-1.0, 0.0, 0.0), (1.0, 0.0, 0.0) b = 1.05 f = (x) -> norm(x .- P1)*norm(x .- P2) - b^2 ϕ3 = LevelSet(f, grid) -bc3 = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 3) +bc3 = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 3) ϕ3 = LevelSetMethods.add_boundary_conditions(ϕ3, bc3) itp3 = interpolate(ϕ3) diff --git a/docs/src/reinitialization.md b/docs/src/reinitialization.md index b6074b1..6a4e4f0 100644 --- a/docs/src/reinitialization.md +++ b/docs/src/reinitialization.md @@ -56,7 +56,7 @@ Pass `reinit` to [`LevelSetEquation`](@ref) to reinitialize automatically every ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC(), reinit = 5, # reinitialize every 5 time steps ) @@ -69,7 +69,7 @@ directly: ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, + ic = ϕ, bc = PeriodicBC(), reinit = NewtonReinitializer(; reinit_freq = 5, upsample = 4), ) diff --git a/docs/src/terms.md b/docs/src/terms.md index c4ae21a..008798d 100644 --- a/docs/src/terms.md +++ b/docs/src/terms.md @@ -44,7 +44,7 @@ grid points. Lets construct a level-set equation with an advection term: ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), levelset = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = ϕ₀, bc = PeriodicBC()) ``` To see how the advection term affects the level-set, we can solve the equation for a few @@ -71,7 +71,7 @@ have instead a time-dependent velocity field, we could pass a function to the ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), levelset = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), ic = ϕ₀, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) # create a 2 x 2 figure for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) @@ -102,8 +102,8 @@ let us compare both schemes for a purely rotational velocity field: 𝐮 = MeshField(grid) do (x,y) SVector(-y, x) end -eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) -eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) +eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), ic = deepcopy(ϕ₀), bc = PeriodicBC()) +eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), ic = deepcopy(ϕ₀), bc = PeriodicBC()) fig = Figure(size = (1000, 400)) ax = Axis(fig[1,1], title = "Initial") plot!(ax, eq_upwind) @@ -134,7 +134,7 @@ using LevelSetMethods using GLMakie grid = CartesianGrid((-2,-2), (2,2), (100, 100)) ϕ = LevelSetMethods.star(grid) -eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), levelset = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), ic = ϕ, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -208,7 +208,7 @@ M = R * [1/0.06^2 0; 0 1/(4π^2)] * R' end return result end -eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), levelset = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), ic = ϕ, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.1, 0.2, 0.3]) integrate!(eq, t) @@ -253,7 +253,7 @@ fig We will now evolve the level-set using the reinitialization term: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), levelset = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), ic = deepcopy(ϕ), bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) @@ -274,7 +274,7 @@ Alternatively, you can use a modified reinitialization term that applies the sig To enable this behavior, simply pass a `LevelSet` object to the `EikonalReinitializationTerm`: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), levelset = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), ic = deepcopy(ϕ), bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) diff --git a/ext/MMGSurfaceExt.jl b/ext/MMGSurfaceExt.jl index d69fde9..d694547 100644 --- a/ext/MMGSurfaceExt.jl +++ b/ext/MMGSurfaceExt.jl @@ -44,7 +44,7 @@ function LSM.export_surface_mesh( hmax = nothing, hausd = nothing, ) - N = LSM.dimension(ϕ) + N = ndims(ϕ) if N != 3 throw(ArgumentError("export_mesh of $N dimensional level-set not supported.")) end diff --git a/ext/MMGVolumeExt.jl b/ext/MMGVolumeExt.jl index 63d8eb5..1b6193e 100644 --- a/ext/MMGVolumeExt.jl +++ b/ext/MMGVolumeExt.jl @@ -46,7 +46,7 @@ function LSM.export_volume_mesh( hmax = nothing, hausd = nothing, ) - N = LSM.dimension(ϕ) + N = ndims(ϕ) if N != 2 && N != 3 throw(ArgumentError("export_mesh of $N dimensional level-set not supported.")) end diff --git a/ext/MakieExt.jl b/ext/MakieExt.jl index 27ef43b..de2ffe7 100644 --- a/ext/MakieExt.jl +++ b/ext/MakieExt.jl @@ -17,6 +17,8 @@ function LSM.makie_theme() zlabel = "z", # autolimitaspect = 1, aspect = AxisAspect(1), + xgridvisible = false, + ygridvisible = false, ), fontsize = 20, ) @@ -27,7 +29,7 @@ function LSM.set_makie_theme!() end # function Makie.plottype(ϕ::LSM.LevelSet) -# N = LSM.dimension(ϕ) +# N = ndims(ϕ) # if N == 2 # return Contour # elseif N == 3 @@ -41,19 +43,41 @@ function Makie.convert_arguments( P::Union{Type{<:Contour}, Type{<:Contourf}, Type{<:Heatmap}}, ϕ::LSM.LevelSet, ) - LSM.dimension(ϕ) == 2 || + ndims(ϕ) == 2 || throw(ArgumentError("Contour plot only supported for 2D level-sets.")) return Makie.convert_arguments(P, _contour_plot(ϕ)...) end function Makie.convert_arguments(P::Type{<:Volume}, ϕ::LSM.LevelSet) - LSM.dimension(ϕ) == 3 || + ndims(ϕ) == 3 || throw(ArgumentError("Volume plot only supported for 3D level-sets.")) x, y, z, v = _volume_plot(ϕ) - # Delegate to Makie's existing conversion for arrays return Makie.convert_arguments(P, x, y, z, v) end +function Makie.convert_arguments( + P::Union{Type{<:Contour}, Type{<:Contourf}, Type{<:Heatmap}}, + nb::LSM.NarrowBandLevelSet, + ) + ndims(nb) == 2 || + throw(ArgumentError("Contour plot only supported for 2D level-sets.")) + msh = LSM.mesh(nb) + x, y = LSM.xgrid(msh), LSM.ygrid(msh) + v = _nb_to_dense(nb) + return Makie.convert_arguments(P, x, y, v) +end + +function Makie.convert_arguments(P::Type{<:Volume}, nb::LSM.NarrowBandLevelSet) + ndims(nb) == 3 || + throw(ArgumentError("Volume plot only supported for 3D level-sets.")) + msh = LSM.mesh(nb) + xlims = extrema(LSM.xgrid(msh)) + ylims = extrema(LSM.ygrid(msh)) + zlims = extrema(LSM.zgrid(msh)) + v = _nb_to_dense(nb) + return Makie.convert_arguments(P, xlims, ylims, zlims, v) +end + function _contour_plot(ϕ::LSM.LevelSet) msh = LSM.mesh(ϕ) x, y = LSM.xgrid(msh), LSM.ygrid(msh) @@ -65,23 +89,89 @@ function _volume_plot(ϕ::LSM.LevelSet) msh = LSM.mesh(ϕ) x, y, z = LSM.xgrid(msh), LSM.ygrid(msh), LSM.zgrid(msh) v = LSM.values(ϕ) - # Return extrema as tuples - Makie will handle conversion xlims, ylims, zlims = extrema(x), extrema(y), extrema(z) return xlims, ylims, zlims, v end +# Build a dense array from a NarrowBandLevelSet, with NaN outside the active band. +function _nb_to_dense(nb::LSM.NarrowBandLevelSet{N, T}) where {N, T} + msh = LSM.mesh(nb) + arr = fill(T(NaN), size(msh)) + for (I, v) in LSM.values(nb) + arr[I] = v + end + return arr +end + +# Collect active cell rectangles for 2D narrow band. +# A cell (lower-corner index I) is active if any of its 4 corners is an active node. +function _active_cell_rects(nb::LSM.NarrowBandLevelSet{2}) + grid = LSM.mesh(nb) + h = LSM.meshsize(grid) + cell_axes = LSM.cellindices(grid) + rects = Rect2f[] + seen = Set{CartesianIndex{2}}() + for J in LSM.active_indices(nb) + for di in 0:1, dj in 0:1 + I = CartesianIndex(J[1] - di, J[2] - dj) + (I ∈ cell_axes && I ∉ seen) || continue + push!(seen, I) + x, y = grid[I] + push!(rects, Rect2f(x, y, h[1], h[2])) + end + end + return rects +end + +# Build grid line segments for a 2D mesh, bounded to the domain. +function _grid_linesegments(msh::LSM.CartesianGrid{2}) + x = LSM.xgrid(msh) + y = LSM.ygrid(msh) + segs = Point2f[] + for xi in x + push!(segs, Point2f(xi, first(y))) + push!(segs, Point2f(xi, last(y))) + end + for yi in y + push!(segs, Point2f(first(x), yi)) + push!(segs, Point2f(last(x), yi)) + end + return segs +end + +# Collect active node coordinates as a vector of Points (3D only). +function _active_node_coords(nb::LSM.NarrowBandLevelSet{3}) + msh = LSM.mesh(nb) + return [Point3f(msh[I]) for I in LSM.active_indices(nb)] +end + + Makie.@recipe(LevelSetPlot, eq) do scene - return Theme() + return Attributes(; showgrid = true) end function Makie.plot!(p::LevelSetPlot) eq = p.eq ϕ = @lift LSM.current_state($eq) - N = @lift LSM.dimension($ϕ) + N = @lift ndims($ϕ) + is_nb = to_value(ϕ) isa LSM.NarrowBandLevelSet if to_value(N) == 2 - contourf!(p, ϕ; levels = [0], extendlow = (:lightgray, 0.5)) - contour!(p, ϕ; levels = [0], linewidth = 2, color = :black) + if to_value(p.showgrid) + segs = _grid_linesegments(LSM.mesh(to_value(ϕ))) + linesegments!(p, segs; color = (:black, 0.15), linewidth = 0.5) + end + if is_nb + rects = @lift _active_cell_rects($ϕ) + poly!(p, rects; color = (:steelblue, 0.2), strokewidth = 0.5, strokecolor = (:steelblue, 0.5)) + else + contourf!(p, ϕ; levels = [0], extendlow = (:lightgray, 0.5)) + end + contour!(p, ϕ; levels = [0], linewidth = 2, color = :black, overdraw = true) elseif to_value(N) == 3 + if is_nb + pts = @lift _active_node_coords($ϕ) + scatter!(p, pts; color = (:steelblue, 0.3), markersize = 4) + end volume!(p, ϕ; algorithm = :iso, isovalue = 0, alpha = 0.5) else throw(ArgumentError("Plot of $N dimensional level-set not supported.")) @@ -91,12 +181,13 @@ end Makie.plottype(::LSM.LevelSetEquation) = LevelSetPlot Makie.plottype(::LSM.LevelSet) = LevelSetPlot +Makie.plottype(::LSM.NarrowBandLevelSet) = LevelSetPlot ## Pick correct axis type based on dimension of the level-set function Makie.preferred_axis_type(p::LevelSetPlot) eq = p.eq ϕ = @lift LSM.current_state($eq) - dim = @lift LSM.dimension($ϕ) + dim = @lift ndims($ϕ) if to_value(dim) == 2 return Axis elseif to_value(dim) == 3 From 5405e196df45aec3e7d27c9b5b2223b8df2eca0c Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sat, 28 Mar 2026 15:19:37 +0100 Subject: [PATCH 05/12] move NarrowBandDomain back to narrowband.jl --- src/meshfield.jl | 16 ---------------- src/narrowband.jl | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/meshfield.jl b/src/meshfield.jl index af5abfb..cb3935a 100644 --- a/src/meshfield.jl +++ b/src/meshfield.jl @@ -12,22 +12,6 @@ Represents a field defined on the entire mesh. """ struct FullDomain <: AbstractDomain end -""" - struct NarrowBandDomain{T} <: AbstractDomain - -Domain for a narrow-band level set. - -- `halfwidth`: half-width of the narrow band, typically on the order of a few grid spacings. -- `extrap_order`: polynomial order used to extrapolate values at indices inside the grid but - outside the band (default: `1`). - -Active indices are the keys of the associated values dict and need not be stored separately. -""" -struct NarrowBandDomain{T} <: AbstractDomain - halfwidth::T - extrap_order::Int -end - """ struct MeshField{V,M,B,D} diff --git a/src/narrowband.jl b/src/narrowband.jl index fb59c0e..c549928 100644 --- a/src/narrowband.jl +++ b/src/narrowband.jl @@ -1,3 +1,19 @@ +""" + struct NarrowBandDomain{T} <: AbstractDomain + +Domain for a narrow-band level set. + +- `halfwidth`: half-width of the narrow band, typically on the order of a few grid spacings. +- `extrap_order`: polynomial order used to extrapolate values at indices inside the grid but + outside the band (default: `1`). + +Active indices are the keys of the associated values dict and need not be stored separately. +""" +struct NarrowBandDomain{T} <: AbstractDomain + halfwidth::T + extrap_order::Int +end + """ const NarrowBandLevelSet{N, T, B} From 97d652e5482ff1e29206c12f44589007ab3d5c9f Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Sun, 29 Mar 2026 15:59:49 +0200 Subject: [PATCH 06/12] docs: restructure nav, add placeholder pages --- docs/make.jl | 23 +++++++++++++++-------- docs/src/levelsetequation.md | 8 ++++++++ docs/src/levelsets.md | 8 ++++++++ docs/src/tutorial.md | 8 ++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 docs/src/levelsetequation.md create mode 100644 docs/src/levelsets.md create mode 100644 docs/src/tutorial.md diff --git a/docs/make.jl b/docs/make.jl index 91aaefa..08ad103 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -36,14 +36,21 @@ makedocs(; collapselevel = 2, ), pages = vcat( "Home" => "index.md", - "terms.md", - "interpolation.md", - "time-integrators.md", - "boundary-conditions.md", - "reinitialization.md", - "Extensions" => - ["extension-makie.md", "extension-mmg.md"], - "Examples" => ["example-zalesak.md", "example-shape-optim.md"], + "Tutorial" => "tutorial.md", + "Guides" => [ + "levelsets.md", + "terms.md", + "time-integrators.md", + "boundary-conditions.md", + "reinitialization.md", + "levelsetequation.md", + ], + "Examples" => [ + "example-zalesak.md", + "example-mass-conservation.md", + "example-shape-optim.md", + ], + "Extensions" => ["extension-makie.md", "extension-mmg.md"], numbered_pages, ), pagesonly = true, # ignore .md files not in the pages list diff --git a/docs/src/levelsetequation.md b/docs/src/levelsetequation.md new file mode 100644 index 0000000..8bee35a --- /dev/null +++ b/docs/src/levelsetequation.md @@ -0,0 +1,8 @@ +```@meta +CurrentModule = LevelSetMethods +``` + +# [Level-set equation](@id levelsetequation) + +!!! note "TODO" + This page is a work in progress. diff --git a/docs/src/levelsets.md b/docs/src/levelsets.md new file mode 100644 index 0000000..71fa141 --- /dev/null +++ b/docs/src/levelsets.md @@ -0,0 +1,8 @@ +```@meta +CurrentModule = LevelSetMethods +``` + +# [Level sets on grids](@id levelsets) + +!!! note "TODO" + This page is a work in progress. diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md new file mode 100644 index 0000000..8474708 --- /dev/null +++ b/docs/src/tutorial.md @@ -0,0 +1,8 @@ +```@meta +CurrentModule = LevelSetMethods +``` + +# Tutorial + +!!! note "TODO" + This page is a work in progress. From c5f45fabd98b66081e71d158c4454ca18855cdb6 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Tue, 31 Mar 2026 12:09:43 +0200 Subject: [PATCH 07/12] default to linear extrapolation in narrow band --- src/narrowband.jl | 77 ++++++++-------- src/reinitializer.jl | 11 ++- test/test-levelsetequation.jl | 162 +++++++++++++++------------------- test/test-narrow-band.jl | 67 +++++++------- 4 files changed, 149 insertions(+), 168 deletions(-) diff --git a/src/narrowband.jl b/src/narrowband.jl index c549928..f52d13b 100644 --- a/src/narrowband.jl +++ b/src/narrowband.jl @@ -4,14 +4,11 @@ Domain for a narrow-band level set. - `halfwidth`: half-width of the narrow band, typically on the order of a few grid spacings. -- `extrap_order`: polynomial order used to extrapolate values at indices inside the grid but - outside the band (default: `1`). Active indices are the keys of the associated values dict and need not be stored separately. """ struct NarrowBandDomain{T} <: AbstractDomain halfwidth::T - extrap_order::Int end """ @@ -26,14 +23,24 @@ const NarrowBandLevelSet{N, T, B} = active_indices(nb::NarrowBandLevelSet) = keys(values(nb)) halfwidth(nb::NarrowBandLevelSet) = domain(nb).halfwidth -extrap_order(nb::NarrowBandLevelSet) = domain(nb).extrap_order Base.eachindex(nb::NarrowBandLevelSet) = active_indices(nb) _eachindex(::NarrowBandDomain, nb) = active_indices(nb) Base.eltype(::NarrowBandLevelSet{N, T}) where {N, T} = T +# Build the active-index dict by evaluating `f_at_idx(I)` at every grid node and +# keeping only those where `|v| < γ`. `f_at_idx` is either `I -> ϕ[I]` (LevelSet path) +# or `I -> f(grid[I])` (function path). +function _nb_dict(f_at_idx, grid::CartesianGrid{N}, γ::T) where {N, T} + vals = Dict{CartesianIndex{N}, T}() + for I in CartesianIndices(grid) + v = T(f_at_idx(I)) + abs(v) < γ && (vals[I] = v) + end + return vals +end """ - NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize = true, extrap_order = 1) + NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize = true) Construct a `NarrowBandLevelSet` from a full-grid `LevelSet`. Active nodes are those where `|ϕ[I]| < halfwidth`. Boundary conditions are inherited from `ϕ`. @@ -41,7 +48,7 @@ where `|ϕ[I]| < halfwidth`. Boundary conditions are inherited from `ϕ`. If `reinitialize` is `true` (the default), `ϕ` is first reinitialized to a signed distance function using [`NewtonReinitializer`](@ref). """ -function NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize::Bool = true, extrap_order::Int = 2) +function NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize::Bool = true) bcs = boundary_conditions(ϕ) # preserve the caller's BCs (may be nothing) if reinitialize ϕ = deepcopy(ϕ) @@ -52,28 +59,21 @@ function NarrowBandLevelSet(ϕ::LevelSet, halfwidth::Real; reinitialize::Bool = reinitialize!(ϕ, NewtonReinitializer()) end grid = mesh(ϕ) - N = ndims(grid) T = float(eltype(ϕ)) γ = T(halfwidth) - vals_dict = Dict{CartesianIndex{N}, T}() - for I in CartesianIndices(grid) - v = T(ϕ[I]) - abs(v) < γ && (vals_dict[I] = v) - end - dom = NarrowBandDomain(γ, extrap_order) - return MeshField(vals_dict, grid, bcs, dom) + vals = _nb_dict(I -> ϕ[I], grid, γ) + return MeshField(vals, grid, bcs, NarrowBandDomain(γ)) end """ - NarrowBandLevelSet(ϕ::LevelSet; nlayers = 8, reinitialize = true, extrap_order = 1) + NarrowBandLevelSet(ϕ::LevelSet; nlayers = 3, reinitialize = true) Construct a `NarrowBandLevelSet` with halfwidth automatically computed as `nlayers * minimum(meshsize(ϕ))`. `nlayers` sets the number of cell layers on each side of the interface included in the band. """ -function NarrowBandLevelSet(ϕ::LevelSet; nlayers::Int = 3, reinitialize::Bool = true, extrap_order::Int = 2) - Δx = minimum(meshsize(ϕ)) - return NarrowBandLevelSet(ϕ, nlayers * Δx; reinitialize, extrap_order) +function NarrowBandLevelSet(ϕ::LevelSet; nlayers::Int = 3, reinitialize::Bool = true) + return NarrowBandLevelSet(ϕ, nlayers * minimum(meshsize(ϕ)); reinitialize) end """ @@ -88,17 +88,11 @@ keeping only those where `|f(x)| < halfwidth`. No dense array is allocated. function. Otherwise the band width will depend on the gradient of `f` near the interface and may not correspond to a fixed number of cell layers. """ -function NarrowBandLevelSet(f, grid::CartesianGrid, halfwidth::Real; bc = nothing, extrap_order::Int = 2) - N = ndims(grid) +function NarrowBandLevelSet(f, grid::CartesianGrid, halfwidth::Real; bc = nothing) T = float(eltype(eltype(grid))) γ = T(halfwidth) - vals_dict = Dict{CartesianIndex{N}, T}() - for I in CartesianIndices(grid) - v = T(f(grid[I])) - abs(v) < γ && (vals_dict[I] = v) - end - dom = NarrowBandDomain(γ, extrap_order) - return MeshField(vals_dict, grid, bc, dom) + vals = _nb_dict(I -> f(grid[I]), grid, γ) + return MeshField(vals, grid, bc, NarrowBandDomain(γ)) end """ @@ -111,9 +105,8 @@ Construct a `NarrowBandLevelSet` with halfwidth automatically computed as The `nlayers` interpretation is only correct if `f` is already a signed distance function. Otherwise the band width in cell layers will not match `nlayers`. """ -function NarrowBandLevelSet(f, grid::CartesianGrid; nlayers::Int = 8, bc = nothing, extrap_order::Int = 2) - Δx = minimum(meshsize(grid)) - return NarrowBandLevelSet(f, grid, nlayers * Δx; bc, extrap_order) +function NarrowBandLevelSet(f, grid::CartesianGrid; nlayers::Int = 8, bc = nothing) + return NarrowBandLevelSet(f, grid, nlayers * minimum(meshsize(grid)); bc) end """ @@ -140,27 +133,33 @@ end _extrapolate_nb_rec(nb::NarrowBandLevelSet, I, max_dim) -> value or nothing Approximate the value at an in-grid index `I` that is not stored in the band -dict, by Lagrange extrapolation from nearby band values. +dict, by bilinear/trilinear (degree-1) Lagrange extrapolation from nearby band values. The algorithm processes dimensions `1` through `max_dim` in order. For each dimension, it searches outward from `I` (nearest first, both sides) for an -anchor point where a stencil of `extrap_order + 1` consecutive values can be -assembled, and Lagrange-extrapolates back to `I`. +anchor point in the dict and uses the two consecutive nodes starting there +(anchor and anchor+step) as a linear stencil. Stencil values are resolved by calling `_extrapolate_nb_rec` recursively with `max_dim = dim - 1`, so each stencil point can itself be extrapolated using lower dimensions. This produces a tensor-product extrapolation that handles -indices outside the band in multiple dimensions simultaneously. For example, -in 2D, a point outside the band in both x and y is resolved by first -extrapolating each y-stencil point in x, then extrapolating in y from those. +indices outside the band in multiple dimensions simultaneously. + +Returns `nothing` if no dimension yields a valid stencil. -Returns `nothing` if no dimension yields a valid stencil, signaling the caller -to try other approaches or error. +# Why degree 1 and no higher? +Outside the band, `|ϕ| ≥ halfwidth`. The only property we need from extrapolated +ghost values is sign correctness (no spurious zeros). Linear extrapolation from a +well-conditioned SDF preserves sign as long as `halfwidth > Δx`, which any +reasonable band satisfies. Quadratic or higher extrapolation introduces polynomial +oscillations that can flip the sign, creating false interface crossings that corrupt +`NewtonSDF` and cause the band to migrate or collapse. """ function _extrapolate_nb_rec(nb::NarrowBandLevelSet{N, T}, I::CartesianIndex{N}, max_dim) where {N, T} haskey(values(nb), I) && return values(nb)[I] grid_axes = axes(nb) - P = extrap_order(nb) + # Degree-1 (linear) stencil — see docstring for why we never go higher. + P = 1 for dim in 1:max_dim for k in 1:length(grid_axes[dim]) for side in (-1, 1) diff --git a/src/reinitializer.jl b/src/reinitializer.jl index 9a3bc13..4872fb1 100644 --- a/src/reinitializer.jl +++ b/src/reinitializer.jl @@ -127,8 +127,8 @@ If the first solve does not converge (e.g. because the closest point lies on a neighbouring polynomial patch), a single retry is attempted using the best iterate from the failed solve as a new seed on its own patch. """ -function _closest_point_on_interface(sdf::NewtonSDF, x, max_retries = 2) - safeguard_dist = maximum(meshsize(mesh(sdf.itp.ϕ))) +function _closest_point_on_interface(sdf::NewtonSDF, x, max_retries = 3) + safeguard_dist = 1.5 * maximum(meshsize(mesh(sdf.itp.ϕ))) idx, _ = nn(sdf.tree, x) cp = sdf.pts[idx] cell = compute_index(sdf.itp, cp) @@ -171,7 +171,10 @@ function _sample_interface(grid::CartesianGrid{N, T}, itp, cells, upsample, maxi ) for x in samples pt = _project_to_interface(itp, x, maxiters, ftol, safeguard_dist) - isnothing(pt) || push!(pts, pt) + isnothing(pt) && continue + I_pt = compute_index(itp, pt) + I_pt in cells || continue + push!(pts, pt) end end return pts @@ -252,7 +255,7 @@ function _closest_point(p::F, xq::SVector{N, T}, x0::SVector{N, T}, maxiters, xt x, λ = x + α * δx, λ + α * δλ # If we drift too far from the patch, return best so far - if norm(x - x0) > 3 * safeguard_dist + if norm(x - x0) > safeguard_dist return best_x, false end end diff --git a/test/test-levelsetequation.jl b/test/test-levelsetequation.jl index 8534c51..3312f94 100644 --- a/test/test-levelsetequation.jl +++ b/test/test-levelsetequation.jl @@ -4,33 +4,42 @@ using StaticArrays using LevelSetMethods import LevelSetMethods as LSM +# Consecutive convergence orders from a sequence of errors on grids Ns. +function _convergence_orders(errors, Ns) + return [log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) for i in 1:(length(Ns) - 1)] +end + +# Max error between a NarrowBandLevelSet and a full-grid field, restricted to +# active nodes with |ϕ| < halfwidth/2 (near the interface where accuracy matters). +function _nb_full_error(nb_s, full_s) + γ = LSM.halfwidth(nb_s) + return maximum(LSM.active_indices(nb_s)) do I + abs(nb_s[I]) < γ / 2 || return 0.0 + abs(nb_s[I] - full_s[I]) + end +end + @testset "AdvectionTerm WENO5 — convergence order (1D periodic, RK3)" begin # 1D advection of sin(πx) with u=1 on a periodic domain. Exact solution: sin(π(x - t)). # WENO5 is 5th-order in space; RK3 temporal error O(Δt³) = O((cfl·Δx)³) dominates # at default cfl=0.5, so we use cfl=1e-2 to expose the spatial rate. u, tf = 1.0, 0.5 ϕ_exact = (x, t) -> sin(π * (x[1] - u * t)) - Ns = [20, 40, 80, 160] + Ns = [20, 40, 80] errors = map(Ns) do N grid = LSM.CartesianGrid((-1.0,), (1.0,), (N,)) ϕ = LSM.LevelSet(x -> ϕ_exact(x, 0.0), grid) eq = LSM.LevelSetEquation(; terms = (LSM.AdvectionTerm((x, t) -> SVector(u)),), - ic = ϕ, - bc = PeriodicBC(), - integrator = RK3(; cfl = 1.0e-2), + ic = ϕ, bc = PeriodicBC(), integrator = RK3(; cfl = 1.0e-2), ) integrate!(eq, tf) ϕ_out = current_state(eq) maximum(I -> abs(ϕ_out[I] - ϕ_exact(grid[I], tf)), CartesianIndices(LSM.mesh(ϕ_out))) end - for i in 1:(length(Ns) - 1) - order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) - @test order ≥ 5 - 0.5 - end + @test all(≥(4.5), _convergence_orders(errors, Ns)) end - @testset "NormalMotionTerm — convergence order (2D expanding circle, RK3)" begin # ϕ₀ = ‖x‖ - r₀. The PDE ϕₜ + v|∇ϕ| = 0 with radial symmetry reduces to # f_t + v·f_r = 0, giving the exact pointwise solution ϕ(x,t) = ‖x‖ - r₀ - v·t. @@ -38,15 +47,13 @@ end # region near r = 0. r0, v, tf = 0.5, 0.5, 0.2 ϕ_exact = x -> norm(x) - r0 - v * tf - Ns = [60, 120, 240] + Ns = [30, 60, 120] errors = map(Ns) do N grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (N, N)) ϕ = LSM.LevelSet(x -> norm(x) - r0, grid) eq = LSM.LevelSetEquation(; terms = (LSM.NormalMotionTerm((x, t) -> v),), - ic = ϕ, - bc = ExtrapolationBC(2), - integrator = RK3(), + ic = ϕ, bc = ExtrapolationBC(2), integrator = RK3(), ) integrate!(eq, tf) ϕ_out = current_state(eq) @@ -56,10 +63,7 @@ end abs(ϕ_out[I] - ϕ_exact(x)) end end - for i in 1:(length(Ns) - 1) - order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) - @test 1.8 < order < 2.2 - end + @test all(≥(1.5), _convergence_orders(errors, Ns)) end @testset "CurvatureTerm — convergence order (2D circle, RK3)" begin @@ -71,15 +75,13 @@ end # near r = 0 (κ = 1/r → ∞) and the boundary. r0, b, tf = 0.7, -0.1, 0.2 ϕ_exact = x -> sqrt(norm(x)^2 - 2b * tf) - r0 - Ns = [60, 120, 240] + Ns = [30, 60, 120] errors = map(Ns) do N grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (N, N)) ϕ = LSM.LevelSet(x -> norm(x) - r0, grid) eq = LSM.LevelSetEquation(; terms = (LSM.CurvatureTerm((x, t) -> b),), - ic = ϕ, - bc = ExtrapolationBC(2), - integrator = RK3(), + ic = ϕ, bc = ExtrapolationBC(2), integrator = RK3(), ) integrate!(eq, tf) ϕ_out = current_state(eq) @@ -89,10 +91,7 @@ end abs(ϕ_out[I] - ϕ_exact(x)) end end - for i in 1:(length(Ns) - 1) - order = log(errors[i] / errors[i + 1]) / log(Ns[i + 1] / Ns[i]) - @test order ≥ 2 - 0.5 - end + @test all(≥(1.5), _convergence_orders(errors, Ns)) end @testset "Reinitialization inside LevelSetEquation" begin @@ -101,9 +100,7 @@ end ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) eq = LSM.LevelSetEquation(; terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), - ic = ϕ, - bc = LSM.PeriodicBC(), - reinit = 2, + ic = ϕ, bc = LSM.PeriodicBC(), reinit = 2, ) LSM.integrate!(eq, 0.2) @test eq isa LSM.LevelSetEquation @@ -114,10 +111,7 @@ end ϕ = LSM.LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) eq = LSM.LevelSetEquation(; terms = (LSM.AdvectionTerm((x, t) -> @SVector [1.0, 0.0]),), - ic = ϕ, - bc = LSM.PeriodicBC(), - integrator = LSM.SemiImplicitI2OE(), - reinit = 2, + ic = ϕ, bc = LSM.PeriodicBC(), integrator = LSM.SemiImplicitI2OE(), reinit = 2, ) @test LSM.integrate!(eq, 1.0e-3, 1.0e-3) isa LSM.LevelSetEquation end @@ -125,46 +119,31 @@ end @testset "NarrowBand integrate! — advection matches full grid" begin grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (60, 60)) - r = 0.5 - ϕ = LSM.LevelSet(x -> norm(x) - r, grid) - 𝐮 = LSM.MeshField(x -> SVector(1.0, 0.0), grid) - - nb = NarrowBandLevelSet(ϕ, 0.8; reinitialize = false) - eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2()) - eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc = ExtrapolationBC(2), integrator = RK2()) - - integrate!(eq_full, 0.1) - integrate!(eq_nb, 0.1) - - nb_s = current_state(eq_nb) - full_s = current_state(eq_full) - inner_err = maximum(LSM.active_indices(nb_s)) do I - abs(nb_s[I]) < 0.4 || return 0.0 - abs(nb_s[I] - full_s[I]) - end - @test inner_err < 1.0e-5 + ϕ = LSM.LevelSet(x -> norm(x) - 0.5, grid) + 𝐮 = (x, t) -> SVector(1.0, 0.0) + bc = ExtrapolationBC(2) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = NarrowBandLevelSet(ϕ; nlayers = 5), bc, reinit = 1) + eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc, reinit = 1) + integrate!(eq_full, 0.1); integrate!(eq_nb, 0.1) + @test _nb_full_error(current_state(eq_nb), current_state(eq_full)) < 1.0e-3 end @testset "NarrowBand integrate! — advection with reinitialization" begin grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (60, 60)) - r = 0.5 - ϕ = LSM.LevelSet(x -> norm(x) - r, grid) - 𝐮 = LSM.MeshField(x -> SVector(1.0, 0.0), grid) - - nb = NarrowBandLevelSet(ϕ, 0.4) - reinit = LSM.NewtonReinitializer(; reinit_freq = 1, upsample = 4) - eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2(), reinit) + ϕ = LSM.LevelSet(x -> norm(x) - 0.5, grid) + eq_nb = LevelSetEquation(; + terms = (AdvectionTerm((x, t) -> SVector(1.0, 0.0)),), + ic = NarrowBandLevelSet(ϕ), bc = ExtrapolationBC(2), reinit = NewtonReinitializer(), + ) integrate!(eq_nb, 0.1) - nb_s = current_state(eq_nb) - exact_shifted(x) = norm(x - SVector(0.1, 0.0)) - r - max_err = maximum(LSM.active_indices(nb_s)) do I - x = grid[I] - abs(nb_s[I]) < 0.3 || return 0.0 - abs(nb_s[I] - exact_shifted(x)) - end - @test max_err < 0.01 + γ = LSM.halfwidth(nb_s) + exact_sdf = x -> norm(x - SVector(0.1, 0.0)) - 0.5 @test length(LSM.active_indices(nb_s)) > 0 + @test maximum(LSM.active_indices(nb_s)) do I + abs(nb_s[I]) < γ / 2 || return 0.0 + abs(nb_s[I] - exact_sdf(grid[I])) + end < 0.01 end @testset "NarrowBand integrate! — spiral curvature flow matches full grid" begin @@ -181,39 +160,36 @@ end sqrt(v' * M * v) - 1 end end - reinit = LSM.NewtonReinitializer(; reinit_freq = 1) - b = (x, t) -> -0.1 - - eq_full = LevelSetEquation(; ic = deepcopy(ϕ), bc = ExtrapolationBC(2), terms = (CurvatureTerm(b),), reinit) - eq_nb = LevelSetEquation(; ic = NarrowBandLevelSet(deepcopy(ϕ); nlayers = 3), bc = ExtrapolationBC(2), terms = (CurvatureTerm(b),), reinit) - - integrate!(eq_full, 0.1) - integrate!(eq_nb, 0.1) - - ϕ_full = current_state(eq_full) - ϕ_nb = current_state(eq_nb) - max_err = maximum(I -> abs(ϕ_nb[I] - ϕ_full[I]), LSM.active_indices(ϕ_nb)) - @test max_err < 0.05 + b, reinit, bc = (x, t) -> -0.1, NewtonReinitializer(), ExtrapolationBC(2) + eq_nb = LevelSetEquation(; ic = NarrowBandLevelSet(ϕ), bc, terms = (CurvatureTerm(b),), reinit) + eq_full = LevelSetEquation(; ic = deepcopy(ϕ), bc, terms = (CurvatureTerm(b),), reinit) + integrate!(eq_full, 0.1); integrate!(eq_nb, 0.1) + @test _nb_full_error(current_state(eq_nb), current_state(eq_full)) < 0.05 end -@testset "NarrowBand integrate! — full rotation with nlayers=2" begin +@testset "NarrowBand integrate! — full rotation" begin grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (40, 40)) ϕ = LSM.LevelSet(x -> norm(x - SVector(0.8, 0.0)) - 0.5, grid) - 𝐮 = (x, t) -> SVector(-x[2], x[1]) - - nb = NarrowBandLevelSet(ϕ; nlayers = 2) - reinit = LSM.NewtonReinitializer(; reinit_freq = 1, upsample = 4) - eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = nb, bc = ExtrapolationBC(2), integrator = RK2(), reinit) - eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc = ExtrapolationBC(2), integrator = RK2()) - - integrate!(eq_full, 2π) - integrate!(eq_nb, 2π) - + 𝐮, bc = (x, t) -> SVector(-x[2], x[1]), ExtrapolationBC(2) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = NarrowBandLevelSet(ϕ), bc, reinit = NewtonReinitializer()) + eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ), bc) + integrate!(eq_full, 2π); integrate!(eq_nb, 2π) nb_s = current_state(eq_nb) - full_s = current_state(eq_full) @test length(LSM.active_indices(nb_s)) > 0 - max_err = maximum(LSM.active_indices(nb_s)) do I - abs(nb_s[I] - full_s[I]) + @test _nb_full_error(nb_s, current_state(eq_full)) < 0.01 +end + +@testset "NarrowBand integrate! — star rotation" begin + grid = LSM.CartesianGrid((-1.0, -1.0), (1.0, 1.0), (40, 40)) + ϕ₀ = LevelSet(grid) do (x, y) + r, θ = sqrt(x^2 + y^2), atan(y, x) - π / 2 + r - (0.5 + 0.1 * cos(5θ)) end - @test max_err < 0.01 + 𝐮, bc = (x, t) -> SVector(-x[2], x[1]), ExtrapolationBC(2) + eq_nb = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = NarrowBandLevelSet(ϕ₀), bc, reinit = NewtonReinitializer()) + eq_full = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = deepcopy(ϕ₀), bc) + integrate!(eq_full, pi); integrate!(eq_nb, pi) + nb_s = current_state(eq_nb) + @test length(LSM.active_indices(nb_s)) > 0 + @test _nb_full_error(nb_s, current_state(eq_full)) < 0.05 end diff --git a/test/test-narrow-band.jl b/test/test-narrow-band.jl index 6f675c7..29c2027 100644 --- a/test/test-narrow-band.jl +++ b/test/test-narrow-band.jl @@ -30,25 +30,21 @@ using LevelSetMethods: D⁺, D⁻, D⁰, D2⁰, D2, weno5⁻, weno5⁺ end @testset "Extrapolation outside of narrow band" begin + # Bilinear extrapolation is exact for linear functions. grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (100, 100)) - # Intentionally not an sdf to check exact extrapolation behavior in polynomial - # case. Requires sufficiently large extrap_order - f = x -> x[1]^4 + x[2]^4 - 0.5 - nb = NarrowBandLevelSet(f, grid, 0.3; extrap_order = 4) + f = x -> 3x[1] - 2x[2] + x[1] * x[2] + 1.0 + nb = NarrowBandLevelSet(f, grid, 0.3) active_idxs = LSM.active_indices(nb) - # compute extrema in each dimension of active idxs Imin = map(d -> minimum(I[d] for I in active_idxs), (1, 2)) |> CartesianIndex Imax = map(d -> maximum(I[d] for I in active_idxs), (1, 2)) |> CartesianIndex - ok = true - # Check that we can extrapolate correctly, even along diagonals k = 5 - Ip = CartesianIndices(ntuple(d -> Imax[d]:(Imax[d] + k), 2)) - for I in Ip - @test nb[I] ≈ f(grid[I]) + for I in CartesianIndices(ntuple(d -> Imax[d]:(Imax[d] + k), 2)) + x = LevelSetMethods._getindex(grid, I) # extrapolate outside bounds + @test nb[I] ≈ f(x) end - Im = CartesianIndices(ntuple(d -> (Imin[d] - k):Imin[d], 2)) - for I in Im - @test nb[I] ≈ f(grid[I]) + for I in CartesianIndices(ntuple(d -> (Imin[d] - k):Imin[d], 2)) + x = LevelSetMethods._getindex(grid, I) # extrapolate outside bounds + @test nb[I] ≈ f(x) end end @@ -188,23 +184,26 @@ end end @testset "Corner extrapolation (multi-dimensional)" begin - # Tests tensor-product extrapolation: a point outside band in both dimensions + # For nodes outside the band in multiple dimensions simultaneously the + # tensor-product linear extrapolation must preserve sign. Accuracy is not + # guaranteed for non-linear functions, but sign correctness is (no spurious zeros). grid = LSM.CartesianGrid((-2.0, -2.0), (2.0, 2.0), (40, 40)) - f(x) = x[1]^2 + x[2]^2 - 1.0 + f(x) = norm(x) - 0.5 # circle SDF with halfwidth 0.2 → both ± nodes outside band ϕ = LSM.LevelSet(f, grid) bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 2) ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) - nb = NarrowBandLevelSet(ϕ_bc, 0.5; reinitialize = false) + nb = NarrowBandLevelSet(ϕ_bc, 0.2; reinitialize = false) vals = values(nb) - # Check all out-of-band points against full grid - max_err = 0.0 - for I in CartesianIndices(grid) - haskey(vals, I) && continue - all(d -> I[d] in axes(nb)[d], 1:2) || continue - max_err = max(max_err, abs(nb[I] - ϕ_bc[I])) + N = ndims(nb) + for I in LSM.active_indices(nb) + for d in 1:N + Im = CartesianIndex(ntuple(i -> i == d ? I[i] - 1 : I[i], N)) + Ip = CartesianIndex(ntuple(i -> i == d ? I[i] + 1 : I[i], N)) + @test sign(nb[Im]) == sign(ϕ_bc[Im]) + @test sign(nb[Ip]) == sign(ϕ_bc[Ip]) + end end - @test max_err < 1.0e-10 end @testset "Periodic BC" begin @@ -218,22 +217,26 @@ end @test isfinite(nb[I]) end -@testset "3D polynomial extrapolation exactness" begin +@testset "3D extrapolation: sign preservation" begin + # Same guarantee in 3D: linear extrapolation from a well-conditioned SDF + # preserves sign for all in-grid out-of-band nodes. grid = LSM.CartesianGrid((-2.0, -2.0, -2.0), (2.0, 2.0, 2.0), (20, 20, 20)) - f(x) = x[1]^2 + x[2]^2 + x[3]^2 - 1.0 + f(x) = norm(x) - 0.5 # sphere SDF with halfwidth 0.2 → both ± nodes outside band ϕ = LSM.LevelSet(f, grid) bc = LSM._normalize_bc(LSM.ExtrapolationBC{2}(), 3) ϕ_bc = LSM.add_boundary_conditions(ϕ, bc) - nb = NarrowBandLevelSet(ϕ_bc, 0.5; reinitialize = false) + nb = NarrowBandLevelSet(ϕ_bc, 0.2; reinitialize = false) vals = values(nb) - max_err = 0.0 - for I in CartesianIndices(grid) - haskey(vals, I) && continue - all(d -> I[d] in axes(nb)[d], 1:3) || continue - max_err = max(max_err, abs(nb[I] - ϕ_bc[I])) + N = ndims(nb) + for I in LSM.active_indices(nb) + for d in 1:N + Im = CartesianIndex(ntuple(i -> i == d ? I[i] - 1 : I[i], N)) + Ip = CartesianIndex(ntuple(i -> i == d ? I[i] + 1 : I[i], N)) + @test sign(nb[Im]) == sign(ϕ_bc[Im]) + @test sign(nb[Ip]) == sign(ϕ_bc[Ip]) + end end - @test max_err < 1.0e-10 end @testset "Auto-reinitialization of non-SDF input" begin From 1fa21cb61d2084db0ab2df1182294c0d364bb551 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Tue, 31 Mar 2026 12:20:01 +0200 Subject: [PATCH 08/12] better definition and usage of active cells --- ext/MakieExt.jl | 14 +++----------- src/levelset.jl | 2 ++ src/narrowband.jl | 28 ++++++++++++++++++++-------- src/reinitializer.jl | 14 +++++++------- 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/ext/MakieExt.jl b/ext/MakieExt.jl index de2ffe7..24d55af 100644 --- a/ext/MakieExt.jl +++ b/ext/MakieExt.jl @@ -104,21 +104,13 @@ function _nb_to_dense(nb::LSM.NarrowBandLevelSet{N, T}) where {N, T} end # Collect active cell rectangles for 2D narrow band. -# A cell (lower-corner index I) is active if any of its 4 corners is an active node. function _active_cell_rects(nb::LSM.NarrowBandLevelSet{2}) grid = LSM.mesh(nb) h = LSM.meshsize(grid) - cell_axes = LSM.cellindices(grid) rects = Rect2f[] - seen = Set{CartesianIndex{2}}() - for J in LSM.active_indices(nb) - for di in 0:1, dj in 0:1 - I = CartesianIndex(J[1] - di, J[2] - dj) - (I ∈ cell_axes && I ∉ seen) || continue - push!(seen, I) - x, y = grid[I] - push!(rects, Rect2f(x, y, h[1], h[2])) - end + for I in LSM.active_cells(nb) + x, y = grid[I] + push!(rects, Rect2f(x, y, h[1], h[2])) end return rects end diff --git a/src/levelset.jl b/src/levelset.jl index 132467f..d9d9b4f 100644 --- a/src/levelset.jl +++ b/src/levelset.jl @@ -10,6 +10,8 @@ const LevelSet{N, T, B} = LevelSet(f::Function, grid, bc = nothing) = MeshField(f, grid, bc) +active_indices(ϕ::LevelSet) = CartesianIndices(mesh(ϕ)) + """ _ensure_boundary_conditions(ϕ) diff --git a/src/narrowband.jl b/src/narrowband.jl index f52d13b..68c9bb4 100644 --- a/src/narrowband.jl +++ b/src/narrowband.jl @@ -204,14 +204,26 @@ function _lagrange_extrap_from(nb::NarrowBandLevelSet{N, T}, I, dim, anchor, sid return result end -""" - _candidate_cells(nb::NarrowBandLevelSet) - -Return the active node indices as candidate cells for interface sampling. -Since the band covers all nodes within `halfwidth` of the interface, all interface -cells have at least one active node. -""" -_candidate_cells(nb::NarrowBandLevelSet) = active_indices(nb) +function active_cells(nb::NarrowBandLevelSet) + grid = mesh(nb) + cell_axes = cellindices(grid) + active_nodes = active_indices(nb) + active_cells = Set{CartesianIndex}() + + for I in cell_axes + N = ndims(grid) + all_corners_active = true + for offset in Iterators.product(ntuple(_ -> 0:1, Val(N))...) + corner_idx = I + CartesianIndex(offset) + if !(corner_idx in active_nodes) + all_corners_active = false + break + end + end + all_corners_active && push!(active_cells, I) + end + return active_cells +end reinitialize!(nb::NarrowBandLevelSet, ::Nothing, _) = nb diff --git a/src/reinitializer.jl b/src/reinitializer.jl index 4872fb1..249fa8e 100644 --- a/src/reinitializer.jl +++ b/src/reinitializer.jl @@ -80,7 +80,7 @@ function NewtonSDF( ftol = 1.0e-8 ) grid = mesh(itp.ϕ) - pts = _sample_interface(grid, itp, _candidate_cells(itp.ϕ), upsample, maxiters, ftol) + pts = _sample_interface(grid, itp, active_cells(itp.ϕ), upsample, maxiters, ftol) tree = KDTree(pts) return NewtonSDF(itp, tree, pts, upsample, maxiters, xtol, ftol) end @@ -107,7 +107,7 @@ buffers, upsample density, and solver tolerances. function update!(sdf::NewtonSDF, ϕ) update!(sdf.itp, ϕ) grid = mesh(sdf.itp.ϕ) - sdf.pts = _sample_interface(grid, sdf.itp, _candidate_cells(sdf.itp.ϕ), sdf.upsample, sdf.maxiters, sdf.ftol) + sdf.pts = _sample_interface(grid, sdf.itp, active_cells(sdf.itp.ϕ), sdf.upsample, sdf.maxiters, sdf.ftol) sdf.tree = KDTree(sdf.pts) return sdf end @@ -144,13 +144,13 @@ function _closest_point_on_interface(sdf::NewtonSDF, x, max_retries = 3) end """ - _candidate_cells(ϕ) + active_cells(ϕ) -Return the cell indices to sample when building the interface. For a full-grid level set, -this is all cells; for a narrow band, only cells adjacent to active nodes. Dispatch point -for NewtonSDF construction. +Return the cell indices that are active for interface sampling. A cell is active if all +its corners are active indices. For a full-grid level set, this is all cells; for a narrow +band, only cells where all corners are within the band. Dispatch point for NewtonSDF construction. """ -_candidate_cells(ϕ) = cellindices(mesh(ϕ)) +active_cells(ϕ) = cellindices(mesh(ϕ)) """ _sample_interface(grid, itp, cells, upsample, maxiters, ftol) From 691a07ac14ac33643d3d3bf4275c7cd127645928 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Wed, 1 Apr 2026 10:08:58 +0200 Subject: [PATCH 09/12] revert docs to main state (stash branch changes to session patch) --- docs/make.jl | 23 ++++++++--------------- docs/src/boundary-conditions.md | 18 +++++++++--------- docs/src/example-mass-conservation.md | 2 +- docs/src/example-shape-optim.md | 4 ++-- docs/src/example-zalesak.md | 4 ++-- docs/src/index.md | 4 ++-- docs/src/interpolation.md | 6 +++--- docs/src/levelsetequation.md | 8 -------- docs/src/levelsets.md | 8 -------- docs/src/reinitialization.md | 4 ++-- docs/src/terms.md | 16 ++++++++-------- docs/src/tutorial.md | 8 -------- 12 files changed, 37 insertions(+), 68 deletions(-) delete mode 100644 docs/src/levelsetequation.md delete mode 100644 docs/src/levelsets.md delete mode 100644 docs/src/tutorial.md diff --git a/docs/make.jl b/docs/make.jl index 08ad103..91aaefa 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -36,21 +36,14 @@ makedocs(; collapselevel = 2, ), pages = vcat( "Home" => "index.md", - "Tutorial" => "tutorial.md", - "Guides" => [ - "levelsets.md", - "terms.md", - "time-integrators.md", - "boundary-conditions.md", - "reinitialization.md", - "levelsetequation.md", - ], - "Examples" => [ - "example-zalesak.md", - "example-mass-conservation.md", - "example-shape-optim.md", - ], - "Extensions" => ["extension-makie.md", "extension-mmg.md"], + "terms.md", + "interpolation.md", + "time-integrators.md", + "boundary-conditions.md", + "reinitialization.md", + "Extensions" => + ["extension-makie.md", "extension-mmg.md"], + "Examples" => ["example-zalesak.md", "example-shape-optim.md"], numbered_pages, ), pagesonly = true, # ignore .md files not in the pages list diff --git a/docs/src/boundary-conditions.md b/docs/src/boundary-conditions.md index 40d6fcd..c6476d7 100644 --- a/docs/src/boundary-conditions.md +++ b/docs/src/boundary-conditions.md @@ -7,10 +7,10 @@ The following boundary conditions are available: | [`PeriodicBC`](@ref) | Periodic (wrap-around) | | [`DirichletBC`](@ref) | Prescribed boundary value | | [`ExtrapolationBC{P}`](@ref ExtrapolationBC) | P-th order one-sided polynomial extrapolation | -| `NeumannBC` | Alias for `ExtrapolationBC{0}` (constant extension, ∂ϕ/∂n = 0) | -| `LinearExtrapolationBC` | Alias for `ExtrapolationBC{1}` (linear extrapolation, ∂²ϕ/∂n² = 0) | +| `NeumannBC` | Alias for `ExtrapolationBC{1}` (constant extension, ∂ϕ/∂n = 0) | +| `NeumannGradientBC` | Alias for `ExtrapolationBC{2}` (linear extrapolation, ∂²ϕ/∂n² = 0) | -`ExtrapolationBC{P}` uses the `P+1` nearest interior cells to build a degree-`P` polynomial +`ExtrapolationBC{P}` uses the `P` nearest interior cells to build a degree `P-1` polynomial and extrapolates it into the ghost region. Higher `P` gives smoother outflow at the cost of a wider stencil. @@ -32,7 +32,7 @@ using LevelSetMethods, GLMakie grid = CartesianGrid((-1,-1), (1,1), (100, 100)) ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) bc = PeriodicBC() -eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -42,10 +42,10 @@ end fig ``` -Changing `PeriodicBC()` to `NeumannBC()` allows the level-set to "leak" out of the domain: +Changing `PeriodicBC()` to `NeumannBC()` gives allows for the level-set to "leak" out of the domain: ```@example boundary-conditions -eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -59,7 +59,7 @@ To combine both boundary conditions you can use ```@example boundary-conditions bc = (NeumannBC(), PeriodicBC()) # Neumann in x, periodic in y -eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) +eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -70,11 +70,11 @@ fig ``` For higher-order outflow you can use `ExtrapolationBC{P}` directly. For example, -`ExtrapolationBC{5}` fits a degree-5 polynomial through the 6 nearest interior cells: +`ExtrapolationBC{5}` fits a degree-4 polynomial through the 5 nearest interior cells: ```@example boundary-conditions bc = ExtrapolationBC(5) -eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) diff --git a/docs/src/example-mass-conservation.md b/docs/src/example-mass-conservation.md index e154d74..e38cb85 100644 --- a/docs/src/example-mass-conservation.md +++ b/docs/src/example-mass-conservation.md @@ -39,7 +39,7 @@ timestamps = range(0, tf; length = nframes + 1) function track_volume(integrator) ϕ = deepcopy(ϕ₀) eq = LevelSetEquation(; - ic = ϕ, + levelset = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), integrator, diff --git a/docs/src/example-shape-optim.md b/docs/src/example-shape-optim.md index 260919c..1885e25 100644 --- a/docs/src/example-shape-optim.md +++ b/docs/src/example-shape-optim.md @@ -93,9 +93,9 @@ term1 = NormalMotionTerm(MeshField(X -> 0.0, grid)) term2 = CurvatureTerm(MeshField(X -> -1.0, grid)) terms = (term1, term2) -bc = LinearExtrapolationBC() +bc = NeumannGradientBC() integrator = ForwardEuler(0.5) -eq = LevelSetEquation(; terms, integrator, ic = ϕ, t = 0, bc) +eq = LevelSetEquation(; terms, integrator, levelset = ϕ, t = 0, bc) using GLMakie diff --git a/docs/src/example-zalesak.md b/docs/src/example-zalesak.md index 2427243..c20b17d 100644 --- a/docs/src/example-zalesak.md +++ b/docs/src/example-zalesak.md @@ -40,7 +40,7 @@ simulates rotation: ```@example zalesak_disk_example # Define the advection equation with a rotational velocity field eq = LevelSetEquation(; - ic = ϕ, + levelset = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), ) @@ -97,7 +97,7 @@ rec = LevelSetMethods.rectangle( ) ϕ = setdiff(disk, rec) eq = LevelSetEquation(; - ic = ϕ, + levelset = ϕ, terms = AdvectionTerm((x, t) -> π * SVector(x[2], -x[1], 0)), bc = NeumannBC(), ) diff --git a/docs/src/index.md b/docs/src/index.md index 7fb3df6..228f237 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -44,7 +44,7 @@ grid = CartesianGrid((-1, -1), (1, 1), (100, 100)) 𝐮 = (x,t) -> (-x[2], x[1]) eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - ic = ϕ, + levelset = ϕ, bc = PeriodicBC() ) ``` @@ -64,7 +64,7 @@ To step it in time, we can use the [`integrate!`](@ref) function: integrate!(eq, 1) ``` -This will advance the solution up to `t = 1`, modifying the equation's state in the process: +This will advance the solution up to `t = 1`, modifying `ϕ` in the process: ```@example ls-intro plot(ϕ) diff --git a/docs/src/interpolation.md b/docs/src/interpolation.md index 9e16154..5bd26d4 100644 --- a/docs/src/interpolation.md +++ b/docs/src/interpolation.md @@ -15,13 +15,13 @@ using LevelSetMethods a, b = (-2.0, -2.0), (2.0, 2.0) ϕ = LevelSetMethods.star(CartesianGrid(a, b, (50, 50))) # Add boundary conditions for safe evaluation near edges -bc = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 2) +bc = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 2) ϕ = LevelSetMethods.add_boundary_conditions(ϕ, bc) itp = interpolate(ϕ) # cubic interpolation by default (order=3) ``` -The returned object is a [`PiecewisePolynomialInterpolant`](@ref LevelSetMethods.PiecewisePolynomialInterpolant), which is callable and +The returned object is a [`PiecewisePolynomialInterpolation`](@ref LevelSetMethods.PiecewisePolynomialInterpolation), which is callable and efficient. Once constructed, the interpolant can be used to evaluate the level-set function anywhere inside (and even slightly outside, using boundary conditions) the grid: @@ -69,7 +69,7 @@ P1, P2 = (-1.0, 0.0, 0.0), (1.0, 0.0, 0.0) b = 1.05 f = (x) -> norm(x .- P1)*norm(x .- P2) - b^2 ϕ3 = LevelSet(f, grid) -bc3 = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 3) +bc3 = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 3) ϕ3 = LevelSetMethods.add_boundary_conditions(ϕ3, bc3) itp3 = interpolate(ϕ3) diff --git a/docs/src/levelsetequation.md b/docs/src/levelsetequation.md deleted file mode 100644 index 8bee35a..0000000 --- a/docs/src/levelsetequation.md +++ /dev/null @@ -1,8 +0,0 @@ -```@meta -CurrentModule = LevelSetMethods -``` - -# [Level-set equation](@id levelsetequation) - -!!! note "TODO" - This page is a work in progress. diff --git a/docs/src/levelsets.md b/docs/src/levelsets.md deleted file mode 100644 index 71fa141..0000000 --- a/docs/src/levelsets.md +++ /dev/null @@ -1,8 +0,0 @@ -```@meta -CurrentModule = LevelSetMethods -``` - -# [Level sets on grids](@id levelsets) - -!!! note "TODO" - This page is a work in progress. diff --git a/docs/src/reinitialization.md b/docs/src/reinitialization.md index 6a4e4f0..b6074b1 100644 --- a/docs/src/reinitialization.md +++ b/docs/src/reinitialization.md @@ -56,7 +56,7 @@ Pass `reinit` to [`LevelSetEquation`](@ref) to reinitialize automatically every ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - ic = ϕ, + levelset = ϕ, bc = PeriodicBC(), reinit = 5, # reinitialize every 5 time steps ) @@ -69,7 +69,7 @@ directly: ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - ic = ϕ, + levelset = ϕ, bc = PeriodicBC(), reinit = NewtonReinitializer(; reinit_freq = 5, upsample = 4), ) diff --git a/docs/src/terms.md b/docs/src/terms.md index 008798d..c4ae21a 100644 --- a/docs/src/terms.md +++ b/docs/src/terms.md @@ -44,7 +44,7 @@ grid points. Lets construct a level-set equation with an advection term: ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), levelset = ϕ₀, bc = PeriodicBC()) ``` To see how the advection term affects the level-set, we can solve the equation for a few @@ -71,7 +71,7 @@ have instead a time-dependent velocity field, we could pass a function to the ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), ic = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), levelset = ϕ₀, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) # create a 2 x 2 figure for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) @@ -102,8 +102,8 @@ let us compare both schemes for a purely rotational velocity field: 𝐮 = MeshField(grid) do (x,y) SVector(-y, x) end -eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), ic = deepcopy(ϕ₀), bc = PeriodicBC()) -eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), ic = deepcopy(ϕ₀), bc = PeriodicBC()) +eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) +eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) fig = Figure(size = (1000, 400)) ax = Axis(fig[1,1], title = "Initial") plot!(ax, eq_upwind) @@ -134,7 +134,7 @@ using LevelSetMethods using GLMakie grid = CartesianGrid((-2,-2), (2,2), (100, 100)) ϕ = LevelSetMethods.star(grid) -eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), ic = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), levelset = ϕ, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -208,7 +208,7 @@ M = R * [1/0.06^2 0; 0 1/(4π^2)] * R' end return result end -eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), ic = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), levelset = ϕ, bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.1, 0.2, 0.3]) integrate!(eq, t) @@ -253,7 +253,7 @@ fig We will now evolve the level-set using the reinitialization term: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), ic = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), levelset = deepcopy(ϕ), bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) @@ -274,7 +274,7 @@ Alternatively, you can use a modified reinitialization term that applies the sig To enable this behavior, simply pass a `LevelSet` object to the `EikonalReinitializationTerm`: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), ic = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), levelset = deepcopy(ϕ), bc = PeriodicBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md deleted file mode 100644 index 8474708..0000000 --- a/docs/src/tutorial.md +++ /dev/null @@ -1,8 +0,0 @@ -```@meta -CurrentModule = LevelSetMethods -``` - -# Tutorial - -!!! note "TODO" - This page is a work in progress. From 006417ddf6e2d686e2d8a1c3590837a8e79251b6 Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Wed, 1 Apr 2026 10:50:11 +0200 Subject: [PATCH 10/12] update docs with new changes (overall rewrite still required) --- docs/src/boundary-conditions.md | 18 +++++++++--------- docs/src/example-mass-conservation.md | 2 +- docs/src/example-shape-optim.md | 4 ++-- docs/src/example-zalesak.md | 4 ++-- docs/src/index.md | 10 +++++----- docs/src/interpolation.md | 6 +++--- docs/src/reinitialization.md | 8 ++++---- docs/src/terms.md | 18 +++++++++--------- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/src/boundary-conditions.md b/docs/src/boundary-conditions.md index c6476d7..40d6fcd 100644 --- a/docs/src/boundary-conditions.md +++ b/docs/src/boundary-conditions.md @@ -7,10 +7,10 @@ The following boundary conditions are available: | [`PeriodicBC`](@ref) | Periodic (wrap-around) | | [`DirichletBC`](@ref) | Prescribed boundary value | | [`ExtrapolationBC{P}`](@ref ExtrapolationBC) | P-th order one-sided polynomial extrapolation | -| `NeumannBC` | Alias for `ExtrapolationBC{1}` (constant extension, ∂ϕ/∂n = 0) | -| `NeumannGradientBC` | Alias for `ExtrapolationBC{2}` (linear extrapolation, ∂²ϕ/∂n² = 0) | +| `NeumannBC` | Alias for `ExtrapolationBC{0}` (constant extension, ∂ϕ/∂n = 0) | +| `LinearExtrapolationBC` | Alias for `ExtrapolationBC{1}` (linear extrapolation, ∂²ϕ/∂n² = 0) | -`ExtrapolationBC{P}` uses the `P` nearest interior cells to build a degree `P-1` polynomial +`ExtrapolationBC{P}` uses the `P+1` nearest interior cells to build a degree-`P` polynomial and extrapolates it into the ghost region. Higher `P` gives smoother outflow at the cost of a wider stencil. @@ -32,7 +32,7 @@ using LevelSetMethods, GLMakie grid = CartesianGrid((-1,-1), (1,1), (100, 100)) ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) bc = PeriodicBC() -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -42,10 +42,10 @@ end fig ``` -Changing `PeriodicBC()` to `NeumannBC()` gives allows for the level-set to "leak" out of the domain: +Changing `PeriodicBC()` to `NeumannBC()` allows the level-set to "leak" out of the domain: ```@example boundary-conditions -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc = NeumannBC(), terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -59,7 +59,7 @@ To combine both boundary conditions you can use ```@example boundary-conditions bc = (NeumannBC(), PeriodicBC()) # Neumann in x, periodic in y -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,1))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -70,11 +70,11 @@ fig ``` For higher-order outflow you can use `ExtrapolationBC{P}` directly. For example, -`ExtrapolationBC{5}` fits a degree-4 polynomial through the 5 nearest interior cells: +`ExtrapolationBC{5}` fits a degree-5 polynomial through the 6 nearest interior cells: ```@example boundary-conditions bc = ExtrapolationBC(5) -eq = LevelSetEquation(; levelset = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) +eq = LevelSetEquation(; ic = deepcopy(ϕ₀), bc, terms = AdvectionTerm((x,t) -> (1,0))) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) diff --git a/docs/src/example-mass-conservation.md b/docs/src/example-mass-conservation.md index e38cb85..e154d74 100644 --- a/docs/src/example-mass-conservation.md +++ b/docs/src/example-mass-conservation.md @@ -39,7 +39,7 @@ timestamps = range(0, tf; length = nframes + 1) function track_volume(integrator) ϕ = deepcopy(ϕ₀) eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), integrator, diff --git a/docs/src/example-shape-optim.md b/docs/src/example-shape-optim.md index 1885e25..260919c 100644 --- a/docs/src/example-shape-optim.md +++ b/docs/src/example-shape-optim.md @@ -93,9 +93,9 @@ term1 = NormalMotionTerm(MeshField(X -> 0.0, grid)) term2 = CurvatureTerm(MeshField(X -> -1.0, grid)) terms = (term1, term2) -bc = NeumannGradientBC() +bc = LinearExtrapolationBC() integrator = ForwardEuler(0.5) -eq = LevelSetEquation(; terms, integrator, levelset = ϕ, t = 0, bc) +eq = LevelSetEquation(; terms, integrator, ic = ϕ, t = 0, bc) using GLMakie diff --git a/docs/src/example-zalesak.md b/docs/src/example-zalesak.md index c20b17d..2427243 100644 --- a/docs/src/example-zalesak.md +++ b/docs/src/example-zalesak.md @@ -40,7 +40,7 @@ simulates rotation: ```@example zalesak_disk_example # Define the advection equation with a rotational velocity field eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> (-x[2], x[1])), bc = NeumannBC(), ) @@ -97,7 +97,7 @@ rec = LevelSetMethods.rectangle( ) ϕ = setdiff(disk, rec) eq = LevelSetEquation(; - levelset = ϕ, + ic = ϕ, terms = AdvectionTerm((x, t) -> π * SVector(x[2], -x[1], 0)), bc = NeumannBC(), ) diff --git a/docs/src/index.md b/docs/src/index.md index 228f237..4e05826 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -44,8 +44,8 @@ grid = CartesianGrid((-1, -1), (1, 1), (100, 100)) 𝐮 = (x,t) -> (-x[2], x[1]) eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, - bc = PeriodicBC() + ic = ϕ, + bc = NeumannBC() ) ``` @@ -99,10 +99,10 @@ Here is what the `.gif` file looks like: For more interesting applications and advanced usage, see the examples section! !!! note "Other resources" - There is an almost one-to-one correspondance between each of the [`LevelSetTerm`](@ref)s - described above and individual chapters of the book by Osher and Fedwick on level set + There is an almost one-to-one correspondence between each of the [`LevelSetTerm`](@ref)s + described above and individual chapters of the book by Osher and Fedkiw on level set methods [osher2003level](@cite), so users interested in digging deeper into the - theory/algorithms are encourage to consult that refenrence. We also drew some + theory/algorithms are encouraged to consult that reference. We also drew some inspiration from the great Matlab library `ToolboxLS` by Ian Mitchell [mitchell2007toolbox](@cite). diff --git a/docs/src/interpolation.md b/docs/src/interpolation.md index 5bd26d4..9e16154 100644 --- a/docs/src/interpolation.md +++ b/docs/src/interpolation.md @@ -15,13 +15,13 @@ using LevelSetMethods a, b = (-2.0, -2.0), (2.0, 2.0) ϕ = LevelSetMethods.star(CartesianGrid(a, b, (50, 50))) # Add boundary conditions for safe evaluation near edges -bc = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 2) +bc = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 2) ϕ = LevelSetMethods.add_boundary_conditions(ϕ, bc) itp = interpolate(ϕ) # cubic interpolation by default (order=3) ``` -The returned object is a [`PiecewisePolynomialInterpolation`](@ref LevelSetMethods.PiecewisePolynomialInterpolation), which is callable and +The returned object is a [`PiecewisePolynomialInterpolant`](@ref LevelSetMethods.PiecewisePolynomialInterpolant), which is callable and efficient. Once constructed, the interpolant can be used to evaluate the level-set function anywhere inside (and even slightly outside, using boundary conditions) the grid: @@ -69,7 +69,7 @@ P1, P2 = (-1.0, 0.0, 0.0), (1.0, 0.0, 0.0) b = 1.05 f = (x) -> norm(x .- P1)*norm(x .- P2) - b^2 ϕ3 = LevelSet(f, grid) -bc3 = ntuple(_ -> (NeumannGradientBC(), NeumannGradientBC()), 3) +bc3 = ntuple(_ -> (LinearExtrapolationBC(), LinearExtrapolationBC()), 3) ϕ3 = LevelSetMethods.add_boundary_conditions(ϕ3, bc3) itp3 = interpolate(ϕ3) diff --git a/docs/src/reinitialization.md b/docs/src/reinitialization.md index b6074b1..b0ff7e1 100644 --- a/docs/src/reinitialization.md +++ b/docs/src/reinitialization.md @@ -56,8 +56,8 @@ Pass `reinit` to [`LevelSetEquation`](@ref) to reinitialize automatically every ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, - bc = PeriodicBC(), + ic = ϕ, + bc = NeumannBC(), reinit = 5, # reinitialize every 5 time steps ) ``` @@ -69,8 +69,8 @@ directly: ```julia eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), - levelset = ϕ, - bc = PeriodicBC(), + ic = ϕ, + bc = NeumannBC(), reinit = NewtonReinitializer(; reinit_freq = 5, upsample = 4), ) ``` diff --git a/docs/src/terms.md b/docs/src/terms.md index c4ae21a..29ae025 100644 --- a/docs/src/terms.md +++ b/docs/src/terms.md @@ -44,7 +44,7 @@ grid points. Lets construct a level-set equation with an advection term: ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), levelset = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm(𝐮),), ic = ϕ₀, bc = NeumannBC()) ``` To see how the advection term affects the level-set, we can solve the equation for a few @@ -71,7 +71,7 @@ have instead a time-dependent velocity field, we could pass a function to the ```@example advection-term ϕ₀ = LevelSet(x -> sqrt(x[1]^2 + x[2]^2) - 0.5, grid) -eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), levelset = ϕ₀, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (AdvectionTerm((x,t) -> SVector(x[1]^2, 0)),), ic = ϕ₀, bc = NeumannBC()) fig = Figure(; size = (1200, 300)) # create a 2 x 2 figure for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) @@ -102,8 +102,8 @@ let us compare both schemes for a purely rotational velocity field: 𝐮 = MeshField(grid) do (x,y) SVector(-y, x) end -eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) -eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), levelset = deepcopy(ϕ₀), bc = PeriodicBC()) +eq_upwind = LevelSetEquation(; terms = AdvectionTerm(𝐮, Upwind()), ic = deepcopy(ϕ₀), bc = NeumannBC()) +eq_weno = LevelSetEquation(; terms = AdvectionTerm(𝐮), ic = deepcopy(ϕ₀), bc = NeumannBC()) fig = Figure(size = (1000, 400)) ax = Axis(fig[1,1], title = "Initial") plot!(ax, eq_upwind) @@ -134,7 +134,7 @@ using LevelSetMethods using GLMakie grid = CartesianGrid((-2,-2), (2,2), (100, 100)) ϕ = LevelSetMethods.star(grid) -eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), levelset = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (NormalMotionTerm((x,t) -> 0.5),), ic = ϕ, bc = NeumannBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.5, 0.75, 1.0]) integrate!(eq, t) @@ -185,7 +185,7 @@ where ``\kappa = \nabla \cdot (\nabla \phi / |\nabla \phi|)`` is the mean curvat coefficient ``b`` should be negative; a positive value of ``b`` would yield an ill-posed evolution problem (akin to a negative diffusion coefficient). -Here is the classic example of motion by mean curavature for a spiral-like level-set: +Here is the classic example of motion by mean curvature for a spiral-like level-set: ```@example curvature-term using LevelSetMethods, GLMakie @@ -208,7 +208,7 @@ M = R * [1/0.06^2 0; 0 1/(4π^2)] * R' end return result end -eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), levelset = ϕ, bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (CurvatureTerm((x,t) -> -0.1),), ic = ϕ, bc = NeumannBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.1, 0.2, 0.3]) integrate!(eq, t) @@ -253,7 +253,7 @@ fig We will now evolve the level-set using the reinitialization term: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), levelset = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(),), ic = deepcopy(ϕ), bc = NeumannBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) @@ -274,7 +274,7 @@ Alternatively, you can use a modified reinitialization term that applies the sig To enable this behavior, simply pass a `LevelSet` object to the `EikonalReinitializationTerm`: ```@example reinitialization-term -eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), levelset = deepcopy(ϕ), bc = PeriodicBC()) +eq = LevelSetEquation(; terms = (EikonalReinitializationTerm(ϕ),), ic = deepcopy(ϕ), bc = NeumannBC()) fig = Figure(; size = (1200, 300)) for (n,t) in enumerate([0.0, 0.25, 0.5, 0.75]) integrate!(eq, t) From 2d2806df9f3ba91514d1d40c2cfd084ae302230b Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Wed, 1 Apr 2026 11:13:17 +0200 Subject: [PATCH 11/12] fix `jldoctest` show issue --- src/meshfield.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/meshfield.jl b/src/meshfield.jl index cb3935a..6090bbf 100644 --- a/src/meshfield.jl +++ b/src/meshfield.jl @@ -100,10 +100,10 @@ MeshField on CartesianGrid in ℝ² ``` ```jldoctest; output = true -using LevelSetMethods, StaticArrays +using LevelSetMethods grid = CartesianGrid((-1, -1), (1, 1), (5, 5)) # vector-valued field -MeshField(x -> SVector(x[1], x[2]), grid) +MeshField(x -> (x[1], x[2]), grid) # output @@ -111,7 +111,7 @@ MeshField on CartesianGrid in ℝ² ├─ domain: [-1.0, 1.0] × [-1.0, 1.0] ├─ nodes: 5 × 5 ├─ spacing: h = (0.5, 0.5) - └─ eltype: SVector{2, Float64} + └─ eltype: Tuple{Float64, Float64} ``` """ function MeshField(f::Function, m, bc = nothing) From 58a3794864fde3e02be31f17f858a0618e2457dd Mon Sep 17 00:00:00 2001 From: maltezfaria Date: Wed, 1 Apr 2026 11:19:04 +0200 Subject: [PATCH 12/12] fix link issue with MMG --- docs/src/extension-mmg.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/extension-mmg.md b/docs/src/extension-mmg.md index f707133..d972bb5 100644 --- a/docs/src/extension-mmg.md +++ b/docs/src/extension-mmg.md @@ -1,6 +1,6 @@ # [MMG extension](@id extension-mmg) -This extension provides functions to generate meshes of level-set functions using [MMG](https://www.mmgtools.org/). +This extension provides functions to generate meshes of level-set functions using [MMG](https://github.com/MmgTools/mmg). It defines two methods: `export_volume_mesh` and `export_surface_mesh`. For both of them, it is possible to control the size of the generated mesh using the following optional parameters: