5.3.1. TGDSA: Two-Grid Diffusion Synthetic Acceleration

Graphite block with a uniform fast source and vacuum boundaries

This tutorial adds two-grid diffusion synthetic acceleration (TGDSA) to a multigroup graphite calculation. Two otherwise identical solves show that TGDSA changes the convergence process without changing the transport solution.

5.3.1.1. Graphite problem

A uniform fast source is applied to a 40 cm by 40 cm graphite block with vacuum boundaries. The 168-group graphite cross sections come from test/assets/xs/xs_graphite_pure.xs, which is also used by the OpenSn regression suite. The file contains higher-order scattering data; this compact example retains the first two Legendre moments (\(P_1\)).

All 168 groups are placed in one groupset. The baseline uses ordinary Richardson source iteration with no acceleration. The second run changes only apply_tgdsa, so it uses TGDSA without WGDSA or another transport accelerator. TGDSA collapses the multigroup error to a one-group diffusion problem and projects the correction back to every group.

[ ]:
from pathlib import Path

from mpi4py import MPI
from pyopensn.aquad import GLCProductQuadrature2DXY
from pyopensn.context import Finalize
from pyopensn.mesh import KBAGraphPartitioner, OrthogonalMeshGenerator
from pyopensn.post import VolumePostprocessor
from pyopensn.solver import DiscreteOrdinatesProblem, SteadyStateSourceSolver
from pyopensn.source import VolumetricSource
from pyopensn.xs import MultiGroupXS

comm = MPI.COMM_WORLD
rank = comm.rank
tutorial_dir = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
repo_root = next(
    path for path in (tutorial_dir, *tutorial_dir.parents)
    if (path / "test/assets/xs").is_dir()
)

5.3.1.2. Build and solve both cases

The 10 by 10 mesh uses a 2 by 2 KBA partition, so run the generated script with four MPI processes. Both cases use the same classic Richardson source iteration and convergence tolerance. The Boolean argument controls only apply_tgdsa.

[ ]:
def solve(use_tgdsa):
    comm.Barrier()
    start = MPI.Wtime()
    nodes = [4.0 * i for i in range(11)]
    partitioner = KBAGraphPartitioner(
        nx=2, ny=2, xcuts=[20.0], ycuts=[20.0]
    )
    mesh = OrthogonalMeshGenerator(
        node_sets=[nodes, nodes], partitioner=partitioner
    ).Execute()
    mesh.SetOrthogonalBoundaries()
    mesh.SetUniformBlockID(0)

    graphite_xs = MultiGroupXS()
    graphite_xs.LoadFromOpenSn(
        str(repo_root / "test/assets/xs/xs_graphite_pure.xs")
    )
    num_groups = graphite_xs.num_groups
    source_strength = [0.0] * num_groups
    source_strength[0] = 1.0

    quadrature = GLCProductQuadrature2DXY(
        n_polar=2, n_azimuthal=4, scattering_order=1
    )
    groupset = {
        "groups_from_to": (0, num_groups - 1),
        "angular_quadrature": quadrature,
        "inner_linear_method": "classic_richardson",
        "l_abs_tol": 1.0e-8,
        "l_max_its": 1200,
    }
    if use_tgdsa:
        groupset.update(
            {
                "apply_tgdsa": True,
                "tgdsa_l_abs_tol": 1.0e-8,
                "tgdsa_l_max_its": 200,
                "tgdsa_solver_policy": "auto",
            }
        )

    problem = DiscreteOrdinatesProblem(
        mesh=mesh,
        num_groups=num_groups,
        groupsets=[groupset],
        xs_map=[{"block_ids": [0], "xs": graphite_xs}],
        volumetric_sources=[
            VolumetricSource(block_ids=[0], group_strength=source_strength)
        ],
        boundary_conditions=[
            {"name": name, "type": "vacuum"}
            for name in ("xmin", "xmax", "ymin", "ymax")
        ],
        options={"verbose_inner_iterations": False},
    )

    solver = SteadyStateSourceSolver(problem=problem)

    comm.Barrier()
    model_done = MPI.Wtime()
    solver.Initialize()
    comm.Barrier()
    init_done = MPI.Wtime()
    solver.Execute()
    comm.Barrier()
    end = MPI.Wtime()
    times = [
        comm.allreduce(t, op=MPI.MAX)
        for t in (model_done - start, init_done - model_done, end - init_done)
    ]

    average = VolumePostprocessor(problem=problem, value_type="avg")
    average.Execute()
    group_flux = average.GetValue()[0]
    return problem, sum(group_flux), group_flux[120], solver.GetNumSweeps(), times


_, unaccelerated_total, unaccelerated_g120, unaccelerated_sweeps, unaccelerated_times = (
    solve(False)
)
tgdsa_problem, tgdsa_total, tgdsa_g120, tgdsa_sweeps, tgdsa_times = solve(True)

5.3.1.3. Compare the solutions

The primary correctness metric is the relative difference in the energy-summed, volume-averaged scalar flux. Group 120 provides a second multigroup check. Transport sweeps verify that TGDSA reduces high-order work. Wall time is split into three phases, each the maximum across ranks: model setup (mesh, 168-group cross sections, transport problem, and solver), solver initialization, and the source iterations themselves. Separating the phases isolates the cost and benefit of TGDSA from the setup both cases share. Timing is illustrative rather than a regression metric because it depends on the machine.

[ ]:
total_relative_difference = (
    abs(tgdsa_total - unaccelerated_total) / abs(unaccelerated_total)
)
g120_relative_difference = (
    abs(tgdsa_g120 - unaccelerated_g120) / abs(unaccelerated_g120)
)
iteration_speedup = unaccelerated_times[2] / tgdsa_times[2]
total_speedup = sum(unaccelerated_times) / sum(tgdsa_times)

if rank == 0:
    print(f"Unaccelerated total average flux={unaccelerated_total:.12e}")
    print(f"TGDSA total average flux={tgdsa_total:.12e}")
    print(f"TGDSA total flux relative difference={total_relative_difference:.12e}")
    print(f"TGDSA group 120 relative difference={g120_relative_difference:.12e}")
    print(f"Unaccelerated transport sweeps={unaccelerated_sweeps}")
    print(f"TGDSA transport sweeps={tgdsa_sweeps}")
    for name, times, sweeps in (
        ("Unaccelerated", unaccelerated_times, unaccelerated_sweeps),
        ("TGDSA", tgdsa_times, tgdsa_sweeps),
    ):
        print(f"{name} model setup time (s)={times[0]:.6f}")
        print(f"{name} solver initialization time (s)={times[1]:.6f}")
        print(f"{name} iteration time (s)={times[2]:.6f}")
        print(f"{name} total wall time (s)={sum(times):.6f}")
        print(f"{name} time per sweep (s)={times[2] / sweeps:.6f}")
    print(f"TGDSA iteration speedup={iteration_speedup:.6f}")
    print(f"TGDSA total speedup={total_speedup:.6f}")

assert total_relative_difference < 1.0e-6
assert g120_relative_difference < 1.0e-6
assert tgdsa_sweeps < unaccelerated_sweeps

A representative four-process run gives the following solution comparison:

Solve

Energy-summed average flux

Group-120 average flux

No acceleration

42.31957467

\(1.7956985\times10^{-2}\)

TGDSA

42.31957469

\(1.7956985\times10^{-2}\)

The converged total fluxes agree to about \(3.5\times10^{-10}\) relative error. The transport sweeps, taken directly from solver.GetNumSweeps(), measure the convergence work:

Solve

Transport sweeps

No acceleration

1010

TGDSA

459

The wall-time breakdown below is the median of five four-process runs:

Solve

Model setup (s)

Solver initialization (s)

Source iterations (s)

Total (s)

Time per sweep (ms)

No acceleration

0.022

<0.001

7.966

7.988

7.9

TGDSA

0.063

<0.001

4.141

4.204

9.0

Setup is small: building the mesh, loading the 168-group cross sections, and constructing the problem take about 0.02 s without acceleration, under 1% of the total. TGDSA’s model setup is about 0.04 s longer, about 1.5% of its total, because the problem computes the two-grid energy spectrum and builds the one-group diffusion operator when it is constructed. Nearly all the time is in the source iterations, so the wall-time comparison measures the acceleration directly.

TGDSA reduces transport sweeps by about 55%. Its one-group diffusion correction adds only about 1.1 ms to each sweep (about 14%), or roughly 0.52 s over the whole solve. The resulting speedup is 1.93 for the iterations and 1.91 overall, close to the 2.2-fold reduction in sweeps. Exact sweep counts can differ by one with floating-point ordering, and wall time is machine-dependent.

5.3.1.4. Export and visualize the scalar flux

The two solutions agree, so the TGDSA result is used as the representative flux field. The following code exports the 168 group scalar fluxes to VTK. It remains in Markdown so regression tests do not create output files.

from pyopensn.fieldfunc import FieldFunctionGridBased

FieldFunctionGridBased.ExportMultipleToPVTU(
    tgdsa_problem.GetScalarFluxFieldFunction(), "Flux/TGDSA_Flux"
)

Energy-summed and group-120 scalar flux from the TGDSA solve

Scalar flux from the TGDSA solve: the sum over all 168 groups (left) and group 120 (right). Each 4 cm cell is drawn with its piecewise-linear discontinuous (PWLD) solution, the bilinear field defined by the cell’s four nodal values. The uniform source and vacuum boundaries give a symmetric distribution that peaks at the center of the block.

5.3.1.5. Finalize (for Jupyter Notebook only)

In script mode, PyOpenSn handles finalization automatically. In a Jupyter kernel, finalize OpenSn before MPI.

[ ]:
if "opensn_console" not in globals():
    from IPython import get_ipython

    if get_ipython() is not None:
        Finalize()
        MPI.Finalize()