Designing your own quadrature rule and strategy

In the context of multi-trace solvers, testing and trial functions with logically separate but geometrically coinciding support can interact. In general these support can be equipped with completely indepenent meshes.

For meshes of flat faceted triangular panels, BEAST.jl defines the BEAST.NonConformingIntegralOpQStrat strategy. The constructor of NonConformingIntegralOpQStrat takes another quadratue strategy ctrat that is fit to deal with pairs of mutually conforming meshes of flat faceted meshes. For a pair comprising a test triangle and a trial triangle, the appropriate quadrature rule is chosen as follows:

  • If the triangles are well-separated, cstrat is run on the pair and the resulting quadrature strategy is chosen.
  • If the triangles have a single vertex in common, the Sauter-Schwab quadrature rule for common vertices is chosen.
  • If the triangles overlap or have edges that overlap, they are both refined so that a geometrically conforming mesh is obtained. For each pair of triangles in this refinement, cstrat is run and the resulting quadrature strategy is chosen.

This quadrature strategy is powerful but requires a lot of geometric processing, resulting in significant increases in matrix assembly times.

When the user has additional knowledge about the precise geometric constellation of the interacting meshes, a more economic approach can be desirable. It is of course the user's responsibility not to use this economic approach in a context where it is not applicable.

In this tutorial we assume that the test mesh is conforming to the barycentric refinement of the trial mesh. We propose a quadrature strategy NonConfTestBaryRefOfTrialQStrat, parametrised by an underlying quadrature strategy fit for mutually conforming meshes, that implements the following algorithm:

  • If the triangles are well-separated, cstrat is run on the pair and the resulting quadrature strategy is chosen.
  • If the test and trial triangle share a vertex, the barycentric refinement of the trial triangle is constructed and the decision on the quadrature rule is deferred to cstrat.

Because of the a priori knowledge about the relative constellation of the meshes, the construction in the second case will automatically result in a pair of mutually conforming meshes that can safely be send off to cstrat.

The first step in defining a new quadrature strategy is the definition of the corresponding type:

struct NonConfTestBaryRefOfTrialQStrat{P} <: BEAST.AbstractQuadStrat
    conforming_qstrat::P
end

The semantics of the quadrature strategy are captured by the definition of a pair of methods for the functions quaddata and quadrule, respectively. The purpose of quaddata is the computation of cache data that can speed up the assembly. Typically, this involves computing and storing triangle normals, and the quadrature points and weights for the various quadrature rules that the quadrature strategy considers.

Because in the majority of cases, the computation of the interaction is deferred to the conforming quadrature strategy, the computation of the cache is forwarded to its method of quaddata, resulting simply in:

function BEAST.quaddata(a, X, Y, test_charts, trial_charts, 
    quadstrat::NonConfTestBaryRefOfTrialQStrat)

    return quaddata(a, X, Y, test_charts, trial_charts,
        quadstrat.conforming_qstrat)
end

The method of quadrule is key to the definition of the quadrature strategy and contains the the actual algorithm in charge of choosing the quadrature rule for any given pair of triangles:

function BEAST.quadrule(a, X, Y, i, test_chart, j, trial_chart, qd,
    quadstrat::NonConfTestBaryRefOfTrialQStrat)

    nh = BEAST._numhits(test_chart, trial_chart)
    nh > 0 && return TestInBaryRefOfTrialQRule(quadstrat.conforming_qstrat)
    return BEAST.quadrule(a, X, Y, i, test_chart, j, trial_chart, qd,
        quadstrat.conforming_qstrat)
end

The function body is essentially a one-to-one translation of the quadrature rule selection algorithm above to julia. The object qd passed to quadrule is the cache computed by quadrule. In our case, it is simply passed on to the underlying conforming quadrature rule in the case of well separated triangles.

Next, we define a type representing the quadrature rule that is used when test triangle and trial triangle are not well-separated:

struct TestInBaryRefOfTrialQRule{S}
    conforming_qstrat::S
end

The type TestInBaryRefOfTrialQRule refers to the quadrature rule that is responsible for the actual computation of the interactions in case of adjacency or overlap. Quadrature rules are implemented by specifying a method for the function BEAST.momintegrals!.

function BEAST.momintegrals!(out, op,
    test_functions, test_cell, test_chart,
    trial_functions, trial_cell, trial_chart,
    qr::TestInBaryRefOfTrialQRule)

    test_local_space = refspace(test_functions)
    trial_local_space = refspace(trial_functions)

    num_tshapes = numfunctions(test_local_space, domain(test_chart))
    num_bshapes = numfunctions(trial_local_space, domain(trial_chart))

    T = coordtype(test_chart)
    z, u, h, t = zero(T), one(T), T(1//2), T(1//3)

    c = CompScienceMeshes.point(T, t, t)
    v = (
        CompScienceMeshes.point(T, u, z),
        CompScienceMeshes.point(T, z, u),
        CompScienceMeshes.point(T, z, z))
    e = (
        CompScienceMeshes.point(T, z, h),
        CompScienceMeshes.point(T, h, z),
        CompScienceMeshes.point(T, h, h))

    X = (
        CompScienceMeshes.simplex(v[1], e[3], c),
        CompScienceMeshes.simplex(v[2], c, e[3]),
        CompScienceMeshes.simplex(v[2], e[1], c),
        CompScienceMeshes.simplex(v[3], c, e[1]),
        CompScienceMeshes.simplex(v[3], e[2], c),
        CompScienceMeshes.simplex(v[1], c, e[2]))

    C = CompScienceMeshes.cartesian(
        CompScienceMeshes.neighborhood(trial_chart, c))
    V = CompScienceMeshes.cartesian.((
        CompScienceMeshes.neighborhood(trial_chart, v[1]),
        CompScienceMeshes.neighborhood(trial_chart, v[2]),
        CompScienceMeshes.neighborhood(trial_chart, v[3])))
    E = CompScienceMeshes.cartesian.((
        CompScienceMeshes.neighborhood(trial_chart, e[1]),
        CompScienceMeshes.neighborhood(trial_chart, e[2]),
        CompScienceMeshes.neighborhood(trial_chart, e[3])))

    trial_charts = (
        CompScienceMeshes.simplex(V[1], E[3], C),
        CompScienceMeshes.simplex(V[2], C, E[3]),
        CompScienceMeshes.simplex(V[2], E[1], C),
        CompScienceMeshes.simplex(V[3], C, E[1]),
        CompScienceMeshes.simplex(V[3], E[2], C),
        CompScienceMeshes.simplex(V[1], C, E[2]))

    quadstrat = qr.conforming_qstrat
    qd = BEAST.quaddata(op, test_local_space, trial_local_space,
        (test_chart,), trial_charts, quadstrat)

    Q = zeros(T, num_tshapes, num_bshapes)
    out1 = zero(out)
    for (q,chart) in enumerate(trial_charts)
        qr1 = BEAST.quadrule(op, test_local_space, trial_local_space,
            1, test_chart, q ,chart, qd, quadstrat)
            
        BEAST.restrict!(Q, trial_local_space, trial_chart, chart, X[q])

        fill!(out1, 0)
        BEAST.momintegrals!(out1, op,
            test_functions, nothing, test_chart,
            trial_functions, nothing, chart, qr1)

        for j in 1:num_bshapes
            for i in 1:num_tshapes
                for k in 1:size(Q, 2)
                    out[i,j] += out1[i,k] * Q[j,k]
end end end end end

The algorithm constructs the charts of the barycentric refinement of the trial chart, which either share a vertex or an edge with the test chart, or completely coincide with the test chart. Because of this, contributions from any of the refinement charts can be computed accuratey by a classic quadrature rule:

momintegrals!(outq, op,
    test_functions, nothing, test_chart,
    trial_functions, nothing, chart, qr)

This call to momintegrals! calculates interactions between the shape functions on the original test chart and the shape functions on one of the six subcharts in the refinement of the trial chart. The corresponding contribution to the interaction with the shape functions on the coarse trial chart can be calculated if we know how the restriction of the coarse shape functions to any of the subcharts can be written as linear combinations of the shape on that subchart. This information is provided by

BEAST.restrict!(Q, trial_local_space, trial_chart, chart, X[q])

For efficiency, the overlap function from the domain of chart to the domain of trial_chart has to be supplied.

Note

The quadrature strategy and related quadrature rules implemented here can be rearded as meta-strategies, and meta-rules, as they defer most of the heavy lifting to underlying strategies and rules for mutually conforming meshes.

In a primitive rule, methods of momintegrals! typically contain implementations of numerical quadrature methods.

To verify correctness of the above strategy, we can compare the results against existing routines that either provide less accurate results or similar results at reduced efficiency.

@testitem "NonConfTestBaryRefOfTrialQStrat" begin
    using CompScienceMeshes

    fnm = joinpath(dirname(pathof(BEAST)), "../test/assets/sphere45.in")
    Γ1 = BEAST.readmesh(fnm)
    Γ2 = deepcopy(Γ1)

    X = raviartthomas(Γ1)
    Y1 = buffachristiansen(Γ1)
    Y = buffachristiansen(Γ2)

    K = Maxwell3D.doublelayer(gamma=1.0)
    qs1 = BEAST.DoubleNumWiltonSauterQStrat(2, 3, 6, 7, 5, 5, 4, 3)
    qs2 = BEAST.NonConformingIntegralOpQStrat(qs1)
    qs3 = BEAST.NonConfTestBaryRefOfTrialQStrat(qs1)

    @time Kyx1 = assemble(K, Y, X; quadstrat=qs1)
    @time Kyx2 = assemble(K, Y, X; quadstrat=qs2)
    @time Kyx3 = assemble(K, Y, X; quadstrat=qs3)
    @time Kyx4 = assemble(K, Y1, X; quadstrat=qs1)

    using LinearAlgebra
    @test norm(Kyx1 - Kyx2) < 0.05
    @test norm(Kyx1 - Kyx3) < 0.05
    @test norm(Kyx2 - Kyx3) < 0.002
    @test norm(Kyx2 - Kyx4) < 0.002
    @test norm(Kyx3 - Kyx4) < 1e-12
end