diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f0f630b023b09f998fb9fe2902939cb9a32ba9f..25e262d074551a616c5d65ad5874871afb936137 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,6 +52,21 @@ jobs:
                 build_py_project_in_conda_env
                 run_examples
 
+    docs:
+        name: Documentation
+        runs-on: ubuntu-latest
+        steps:
+        -   uses: actions/checkout@v2
+        -   name: "Main Script"
+            run: |
+                sudo apt-get update
+                sudo apt-get install openmpi-bin libopenmpi-dev
+                CONDA_ENVIRONMENT=.test-conda-env-py3.yml
+                curl -L -O -k https://gitlab.tiker.net/inducer/ci-support/raw/master/ci-support.sh
+                . ci-support.sh
+                build_py_project_in_conda_env
+                build_docs
+
 # vim: sw=4
 
 
diff --git a/.gitignore b/.gitignore
index 94648fab1309424c88e2836ab4034eab05049871..14be95d194bd112b5596328cf5bafcfc092e0cce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,5 @@ run-debug-*
 
 .cache
 .pytest_cache
+
+*.json
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 66fc4e491c73a020bba5baee77f85048805696be..ebe4262476c11ae643380f4111a45f0d083f304c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -128,8 +128,6 @@ Documentation:
   - ". ./build-docs.sh"
   tags:
   - python3
-  only:
-  - master
 
 Flake8:
   script:
diff --git a/doc/conf.py b/doc/conf.py
index d75d2aa2ac850052f2c0ad196affaea5b0ec9e98..dc8f0e244b6a4212b817e4751f54e348bbe9661b 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -170,7 +170,7 @@ html_sidebars = {
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+#html_static_path = ['_static']
 
 # Add any extra paths that contain custom files (such as robots.txt or
 # .htaccess) here, relative to this directory. These files are copied
@@ -327,4 +327,4 @@ intersphinx_mapping = {
     'https://documen.tician.de/meshmode/': None,
     'http://documen.tician.de/loopy/': None,
     }
-autoclass_content = "both"
+autoclass_content = "class"
diff --git a/doc/discretization.rst b/doc/discretization.rst
new file mode 100644
index 0000000000000000000000000000000000000000..414111348b5de7a8261b088bdf5b5a2c28680a24
--- /dev/null
+++ b/doc/discretization.rst
@@ -0,0 +1,12 @@
+Discretization
+==============
+
+.. module:: grudge
+
+.. automodule:: grudge.discretization
+
+Discretization with Eager Evaluation
+====================================
+
+.. automodule:: grudge.eager
+
diff --git a/doc/eager.rst b/doc/eager.rst
deleted file mode 100644
index 5d2bdd5b071a15623134a1f549589ce6d0178f13..0000000000000000000000000000000000000000
--- a/doc/eager.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-Eagerly-evaluated Operation
-===========================
-
-.. automodule:: grudge.eager
-
diff --git a/doc/index.rst b/doc/index.rst
index 6d7782bc950b12431961f3bd0b8ee30deba4d050..7b6ce79d0a108cdcc8df3d05c00af34ee7213190 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -6,9 +6,9 @@ Contents:
 .. toctree::
     :maxdepth: 2
 
-    misc
+    discretization
     symbolic
-    eager
+    misc
 
 
 Indices and Tables
diff --git a/examples/dagrt-fusion.py b/examples/dagrt-fusion.py
index 637da92e270d16ecb34881bab56521f054c1d430..bab82d545df9a7ba9bf4f6b41937dc10f45191df 100755
--- a/examples/dagrt-fusion.py
+++ b/examples/dagrt-fusion.py
@@ -29,6 +29,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
+# FIXME:
+# Results before https://github.com/inducer/grudge/pull/15 were better:
+#
+# Operator     | \parbox{1in}{\centering \% Memory Ops. Due to Scalar Assignments}
+# -------------+-------------------------------------------------------------------
+# 2D: Baseline | 51.1
+# 2D: Inlined  | 48.9
+# 3D: Baseline | 50.1
+# 3D: Inlined  | 48.6
+# INFO:__main__:Wrote '<stdout>'
+# ==== Scalar Assigment Inlining Impact ====
+# Operator     | Bytes Read | Bytes Written | Total      | \% of Baseline
+# -------------+------------+---------------+------------+----------------
+# 2D: Baseline | 9489600    | 3348000       | 12837600   | 100
+# 2D: Inlined  | 8949600    | 2808000       | 11757600   | 91.6
+# 3D: Baseline | 1745280000 | 505440000     | 2250720000 | 100
+# 3D: Inlined  | 1680480000 | 440640000     | 2121120000 | 94.2
+# INFO:__main__:Wrote '<stdout>'
+
 
 import contextlib
 import logging
@@ -413,66 +432,23 @@ class FusedRK4TimeStepper(RK4TimeStepperBase):
 
 # {{{ problem setup code
 
-def get_strong_wave_op_with_discr(actx, dims=2, order=4):
-    from meshmode.mesh.generation import generate_regular_rect_mesh
-    mesh = generate_regular_rect_mesh(
-            a=(-0.5,)*dims,
-            b=(0.5,)*dims,
-            n=(16,)*dims)
-
-    logger.debug("%d elements", mesh.nelements)
-
-    discr = DGDiscretizationWithBoundaries(actx, mesh, order=order)
-
+def _get_source_term(dims):
     source_center = np.array([0.1, 0.22, 0.33])[:dims]
     source_width = 0.05
     source_omega = 3
 
-    sym_x = sym.nodes(mesh.dim)
+    sym_x = sym.nodes(dims)
     sym_source_center_dist = sym_x - source_center
     sym_t = sym.ScalarVariable("t")
 
-    from grudge.models.wave import StrongWaveOperator
-    from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = StrongWaveOperator(-0.1, dims,
-            source_f=(
+    return (
                 sym.sin(source_omega*sym_t)
                 * sym.exp(
                     -np.dot(sym_source_center_dist, sym_source_center_dist)
-                    / source_width**2)),
-            dirichlet_tag=BTAG_NONE,
-            neumann_tag=BTAG_NONE,
-            radiation_tag=BTAG_ALL,
-            flux_type="upwind")
-
-    op.check_bc_coverage(mesh)
-
-    return (op.sym_operator(), discr)
-
-
-def dg_flux(c, tpair):
-    u = tpair[0]
-    v = tpair[1:]
-
-    dims = len(v)
-
-    normal = sym.normal(tpair.dd, dims)
-    flux_weak = flat_obj_array(
-            np.dot(v.avg, normal),
-            u.avg * normal)
-
-    flux_weak -= (1 if c > 0 else -1)*flat_obj_array(
-            0.5*(u.int-u.ext),
-            0.5*(normal * np.dot(normal, v.int-v.ext)))
-
-    flux_strong = flat_obj_array(
-            np.dot(v.int, normal),
-            u.int * normal) - flux_weak
-
-    return sym.project(tpair.dd, "all_faces")(c*flux_strong)
+                    / source_width**2))
 
 
-def get_strong_wave_op_with_discr_direct(actx, dims=2, order=4):
+def get_wave_op_with_discr(actx, dims=2, order=4):
     from meshmode.mesh.generation import generate_regular_rect_mesh
     mesh = generate_regular_rect_mesh(
             a=(-0.5,)*dims,
@@ -483,54 +459,21 @@ def get_strong_wave_op_with_discr_direct(actx, dims=2, order=4):
 
     discr = DGDiscretizationWithBoundaries(actx, mesh, order=order)
 
-    source_center = np.array([0.1, 0.22, 0.33])[:dims]
-    source_width = 0.05
-    source_omega = 3
-
-    sym_x = sym.nodes(mesh.dim)
-    sym_source_center_dist = sym_x - source_center
-    sym_t = sym.ScalarVariable("t")
-
-    from meshmode.mesh import BTAG_ALL
-
-    c = -0.1
-    sign = -1
-
-    w = sym.make_sym_array("w", dims+1)
-    u = w[0]
-    v = w[1:]
-
-    source_f = (
-                sym.sin(source_omega*sym_t)
-                * sym.exp(
-                    -np.dot(sym_source_center_dist, sym_source_center_dist)
-                    / source_width**2))
-
-    rad_normal = sym.normal(BTAG_ALL, dims)
-
-    rad_u = sym.cse(sym.project("vol", BTAG_ALL)(u))
-    rad_v = sym.cse(sym.project("vol", BTAG_ALL)(v))
-
-    rad_bc = sym.cse(flat_obj_array(
-            0.5*(rad_u - sign*np.dot(rad_normal, rad_v)),
-            0.5*rad_normal*(np.dot(rad_normal, rad_v) - sign*rad_u)
-            ), "rad_bc")
+    from grudge.models.wave import WeakWaveOperator
+    from meshmode.mesh import BTAG_ALL, BTAG_NONE
+    op = WeakWaveOperator(0.1, dims,
+            source_f=_get_source_term(dims),
+            dirichlet_tag=BTAG_NONE,
+            neumann_tag=BTAG_NONE,
+            radiation_tag=BTAG_ALL,
+            flux_type="upwind")
 
-    sym_operator = (
-            - flat_obj_array(
-                -c*np.dot(sym.nabla(dims), v) - source_f,
-                -c*(sym.nabla(dims)*u)
-                )
-            + sym.InverseMassOperator()(
-                sym.FaceMassOperator()(
-                    dg_flux(c, sym.int_tpair(w))
-                    + dg_flux(c, sym.bv_tpair(BTAG_ALL, w, rad_bc))
-                    )))
+    op.check_bc_coverage(mesh)
 
-    return (sym_operator, discr)
+    return (op.sym_operator(), discr)
 
 
-def get_strong_wave_component(state_component):
+def get_wave_component(state_component):
     return (state_component[0], state_component[1:])
 
 # }}}
@@ -545,10 +488,10 @@ def test_stepper_equivalence(ctx_factory, order=4):
 
     dims = 2
 
-    sym_operator, _ = get_strong_wave_op_with_discr(
-            actx, dims=dims, order=order)
-    sym_operator_direct, discr = get_strong_wave_op_with_discr_direct(
+    sym_operator, discr = get_wave_op_with_discr(
             actx, dims=dims, order=order)
+    #sym_operator_direct, discr = get_wave_op_with_discr_direct(
+    #        actx, dims=dims, order=order)
 
     if dims == 2:
         dt = 0.04
@@ -561,11 +504,11 @@ def test_stepper_equivalence(ctx_factory, order=4):
     bound_op = bind(discr, sym_operator)
 
     stepper = RK4TimeStepper(
-            discr, "w", bound_op, 1 + discr.dim, get_strong_wave_component)
+            discr, "w", bound_op, 1 + discr.dim, get_wave_component)
 
     fused_stepper = FusedRK4TimeStepper(
-            discr, "w", sym_operator_direct, 1 + discr.dim,
-            get_strong_wave_component)
+            discr, "w", sym_operator, 1 + discr.dim,
+            get_wave_component)
 
     t_start = 0
     t_end = 0.5
@@ -808,7 +751,7 @@ def test_assignment_memory_model(ctx_factory):
     queue = cl.CommandQueue(cl_ctx)
     actx = PyOpenCLArrayContext(queue)
 
-    _, discr = get_strong_wave_op_with_discr_direct(actx, dims=2, order=3)
+    _, discr = get_wave_op_with_discr(actx, dims=2, order=3)
 
     # Assignment instruction
     bound_op = bind(
@@ -838,7 +781,7 @@ def test_stepper_mem_ops(ctx_factory, use_fusion):
 
     dims = 2
 
-    sym_operator, discr = get_strong_wave_op_with_discr_direct(
+    sym_operator, discr = get_wave_op_with_discr(
             actx, dims=dims, order=3)
 
     t_start = 0
@@ -855,13 +798,13 @@ def test_stepper_mem_ops(ctx_factory, use_fusion):
 
         stepper = RK4TimeStepper(
                 discr, "w", bound_op, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=ExecutionMapperWithMemOpCounting)
 
     else:
         stepper = FusedRK4TimeStepper(
                 discr, "w", sym_operator, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=ExecutionMapperWithMemOpCounting)
 
     step = 0
@@ -1009,7 +952,7 @@ def test_stepper_timing(ctx_factory, use_fusion):
 
     dims = 3
 
-    sym_operator, discr = get_strong_wave_op_with_discr_direct(
+    sym_operator, discr = get_wave_op_with_discr(
             actx, dims=dims, order=3)
 
     t_start = 0
@@ -1026,13 +969,13 @@ def test_stepper_timing(ctx_factory, use_fusion):
 
         stepper = RK4TimeStepper(
                 discr, "w", bound_op, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=ExecutionMapperWithTiming)
 
     else:
         stepper = FusedRK4TimeStepper(
                 discr, "w", sym_operator, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=ExecutionMapperWithTiming)
 
     step = 0
@@ -1060,7 +1003,7 @@ def test_stepper_timing(ctx_factory, use_fusion):
 def get_example_stepper(actx, dims=2, order=3, use_fusion=True,
                         exec_mapper_factory=ExecutionMapper,
                         return_ic=False):
-    sym_operator, discr = get_strong_wave_op_with_discr_direct(
+    sym_operator, discr = get_wave_op_with_discr(
             actx, dims=dims, order=3)
 
     if not use_fusion:
@@ -1070,13 +1013,13 @@ def get_example_stepper(actx, dims=2, order=3, use_fusion=True,
 
         stepper = RK4TimeStepper(
                 discr, "w", bound_op, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=exec_mapper_factory)
 
     else:
         stepper = FusedRK4TimeStepper(
                 discr, "w", sym_operator, 1 + discr.dim,
-                get_strong_wave_component,
+                get_wave_component,
                 exec_mapper_factory=exec_mapper_factory)
 
     if return_ic:
@@ -1131,7 +1074,7 @@ def problem_stats(order=3):
     actx = PyOpenCLArrayContext(queue)
 
     with open_output_file("grudge-problem-stats.txt") as outf:
-        _, dg_discr_2d = get_strong_wave_op_with_discr_direct(
+        _, dg_discr_2d = get_wave_op_with_discr(
             actx, dims=2, order=order)
         print("Number of 2D elements:", dg_discr_2d.mesh.nelements, file=outf)
         vol_discr_2d = dg_discr_2d.discr_from_dd("vol")
@@ -1139,7 +1082,7 @@ def problem_stats(order=3):
         from pytools import one
         print("Number of DOFs per 2D element:", one(dofs_2d), file=outf)
 
-        _, dg_discr_3d = get_strong_wave_op_with_discr_direct(
+        _, dg_discr_3d = get_wave_op_with_discr(
             actx, dims=3, order=order)
         print("Number of 3D elements:", dg_discr_3d.mesh.nelements, file=outf)
         vol_discr_3d = dg_discr_3d.discr_from_dd("vol")
diff --git a/examples/wave/var-propagation-speed.py b/examples/wave/var-propagation-speed.py
index f392b420d4e06c6fb547c6461c0290726388cf29..858e72549413b8120962a8052ebd7174622fff18 100644
--- a/examples/wave/var-propagation-speed.py
+++ b/examples/wave/var-propagation-speed.py
@@ -56,7 +56,7 @@ def main(write_output=True, order=4):
     sym_t = sym.ScalarVariable("t")
     c = sym.If(sym.Comparison(
                 np.dot(sym_x, sym_x), "<", 0.15),
-                np.float32(-0.1), np.float32(-0.2))
+                np.float32(0.1), np.float32(0.2))
 
     from grudge.models.wave import VariableCoefficientWeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
diff --git a/examples/wave/wave-eager-mpi.py b/examples/wave/wave-eager-mpi.py
index 40ddbb7b1fb2049f54e173e0c5696a8a5a2d19e3..a6ca4381c9c84345d5d7eec64e470c43e05a20e1 100644
--- a/examples/wave/wave-eager-mpi.py
+++ b/examples/wave/wave-eager-mpi.py
@@ -59,9 +59,9 @@ def wave_flux(discr, c, w_tpair):
             )
 
     # upwind
-    v_jump = np.dot(normal, v.int-v.ext)
+    v_jump = np.dot(normal, v.ext-v.int)
     flux_weak += flat_obj_array(
-            0.5*(u.int-u.ext),
+            0.5*(u.ext-u.int),
             0.5*normal*scalar(v_jump),
             )
 
@@ -87,7 +87,7 @@ def wave_operator(discr, c, w):
                 discr.face_mass(
                     wave_flux(discr, c=c, w_tpair=interior_trace_pair(discr, w))
                     + wave_flux(discr, c=c, w_tpair=TracePair(
-                        BTAG_ALL, dir_bval, dir_bc))
+                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
                     + sum(
                         wave_flux(discr, c=c, w_tpair=tpair)
                         for tpair in cross_rank_trace_pairs(discr, w))
@@ -180,8 +180,6 @@ def main():
     def rhs(t, w):
         return wave_operator(discr, c=1, w=w)
 
-    rank = comm.Get_rank()
-
     t = 0
     t_final = 3
     istep = 0
@@ -190,7 +188,9 @@ def main():
 
         if istep % 10 == 0:
             print(istep, t, discr.norm(fields[0]))
-            vis.write_vtk_file("fld-wave-eager-mpi-%03d-%04d.vtu" % (rank, istep),
+            vis.write_parallel_vtk_file(
+                    comm,
+                    f"fld-wave-eager-mpi-{{rank:03d}}-{istep:04d}.vtu",
                     [
                         ("u", fields[0]),
                         ("v", fields[1:]),
diff --git a/examples/wave/wave-eager-var-velocity.py b/examples/wave/wave-eager-var-velocity.py
index 9ac8a4c4f2a4a47f46d0a62f7c9271a7dda4f140..5164562cd03453f4a544fd2feb724bc44f8ce1d6 100644
--- a/examples/wave/wave-eager-var-velocity.py
+++ b/examples/wave/wave-eager-var-velocity.py
@@ -60,10 +60,9 @@ def wave_flux(discr, c, w_tpair):
             )
 
     # upwind
-    v_jump = np.dot(normal, v.int-v.ext)
     flux_weak += flat_obj_array(
-            0.5*(u.int-u.ext),
-            0.5*normal*scalar(v_jump),
+            0.5*(u.ext-u.int),
+            0.5*normal*scalar(np.dot(normal, v.ext-v.int)),
             )
 
     # FIXME this flux is only correct for continuous c
@@ -102,7 +101,7 @@ def wave_operator(discr, c, w):
                     dd_allfaces_quad,
                     wave_flux(discr, c=c, w_tpair=interior_trace_pair(discr, w))
                     + wave_flux(discr, c=c, w_tpair=TracePair(
-                        BTAG_ALL, dir_bval, dir_bc))
+                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
                     ))
                 )
 
diff --git a/examples/wave/wave-eager.py b/examples/wave/wave-eager.py
index 52ab08244d480ccfe6d58a4476e440fe4ad0e100..21c6d73a79d32adaae116e2183016ad88e0a82b5 100644
--- a/examples/wave/wave-eager.py
+++ b/examples/wave/wave-eager.py
@@ -57,10 +57,9 @@ def wave_flux(discr, c, w_tpair):
             )
 
     # upwind
-    v_jump = np.dot(normal, v.int-v.ext)
     flux_weak += flat_obj_array(
-            0.5*(u.int-u.ext),
-            0.5*normal*scalar(v_jump),
+            0.5*(u.ext-u.int),
+            0.5*normal*scalar(np.dot(normal, v.ext-v.int)),
             )
 
     return discr.project(w_tpair.dd, "all_faces", c*flux_weak)
@@ -85,7 +84,7 @@ def wave_operator(discr, c, w):
                 discr.face_mass(
                     wave_flux(discr, c=c, w_tpair=interior_trace_pair(discr, w))
                     + wave_flux(discr, c=c, w_tpair=TracePair(
-                        BTAG_ALL, dir_bval, dir_bc))
+                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
                     ))
                 )
 
diff --git a/examples/wave/wave-min-mpi.py b/examples/wave/wave-min-mpi.py
index bca2610199ed1cf50ae0c178d28caf1579730e3c..3e0063d866942bcc921f583c9464ee6d78f33bd7 100644
--- a/examples/wave/wave-min-mpi.py
+++ b/examples/wave/wave-min-mpi.py
@@ -79,9 +79,9 @@ def main(write_output=True, order=4):
     sym_source_center_dist = sym_x - source_center
     sym_t = sym.ScalarVariable("t")
 
-    from grudge.models.wave import StrongWaveOperator
+    from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = StrongWaveOperator(-0.1, discr.dim,
+    op = WeakWaveOperator(0.1, discr.dim,
             source_f=(
                 sym.sin(source_omega*sym_t)
                 * sym.exp(
@@ -124,8 +124,6 @@ def main(write_output=True, order=4):
     from time import time
     t_last_step = time()
 
-    rank = comm.Get_rank()
-
     for event in dt_stepper.run(t_end=final_t):
         if isinstance(event, dt_stepper.StateComputed):
             assert event.component_id == "w"
@@ -135,11 +133,9 @@ def main(write_output=True, order=4):
             print(step, event.t, norm(u=event.state_component[0]),
                     time()-t_last_step)
             if step % 10 == 0:
-                vis.write_vtk_file(
-                        "fld-wave-min-mpi-%03d-%04d.vtu" % (
-                            rank,
-                            step,
-                            ),
+                vis.write_parallel_vtk_file(
+                        comm,
+                        f"fld-wave-min-mpi-{{rank:03d}}-{step:04d}.vtu",
                         [
                             ("u", event.state_component[0]),
                             ("v", event.state_component[1:]),
diff --git a/examples/wave/wave-min.py b/examples/wave/wave-min.py
index de6c9f92e75d449e6a42de80f7b6fa7270df8a15..e567251aa9631b1fe1da7e648208d91802c42ad7 100644
--- a/examples/wave/wave-min.py
+++ b/examples/wave/wave-min.py
@@ -61,9 +61,9 @@ def main(write_output=True, order=4):
     sym_source_center_dist = sym_x - source_center
     sym_t = sym.ScalarVariable("t")
 
-    from grudge.models.wave import StrongWaveOperator
+    from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = StrongWaveOperator(-0.1, discr.dim,
+    op = WeakWaveOperator(0.1, discr.dim,
             source_f=(
                 sym.sin(source_omega*sym_t)
                 * sym.exp(
diff --git a/grudge/discretization.py b/grudge/discretization.py
index 7c0573c2e5ec3515843b6f8c377ad10301256aaa..a5452a24b9e690ec14cd0b5bf455f711f3b7f2e7 100644
--- a/grudge/discretization.py
+++ b/grudge/discretization.py
@@ -30,12 +30,12 @@ import numpy as np  # noqa: F401
 from meshmode.array_context import ArrayContext
 
 
-# FIXME Naming not ideal
-class DiscretizationBase(object):
-    pass
+__doc__ = """
+.. autoclass:: DGDiscretizationWithBoundaries
+"""
 
 
-class DGDiscretizationWithBoundaries(DiscretizationBase):
+class DGDiscretizationWithBoundaries(object):
     """
     .. automethod :: discr_from_dd
     .. automethod :: connection_from_dds
@@ -53,10 +53,11 @@ class DGDiscretizationWithBoundaries(DiscretizationBase):
         """
         :param quad_tag_to_group_factory: A mapping from quadrature tags (typically
             strings--but may be any hashable/comparable object) to a
-            :class:`meshmode.discretization.ElementGroupFactory` indicating with
-            which quadrature discretization the operations are to be carried out,
-            or *None* to indicate that operations with this quadrature tag should
-            be carried out with the standard volume discretization.
+            :class:`~meshmode.discretization.poly_element.ElementGroupFactory`
+            indicating with which quadrature discretization the operations are
+            to be carried out, or *None* to indicate that operations with this
+            quadrature tag should be carried out with the standard volume
+            discretization.
         """
 
         self._setup_actx = array_context
@@ -390,7 +391,7 @@ class DGDiscretizationWithBoundaries(DiscretizationBase):
     def order(self):
         from warnings import warn
         warn("DGDiscretizationWithBoundaries.order is deprecated, "
-                "consider the orders of element groups instead. "
+                "consider using the orders of element groups instead. "
                 "'order' will go away in 2021.",
                 DeprecationWarning, stacklevel=2)
 
diff --git a/grudge/eager.py b/grudge/eager.py
index 87368161e5045b2a7c1b508b89cf60ebb336053a..38efbc02739560344b1c8084761dce5cd9bbc6f1 100644
--- a/grudge/eager.py
+++ b/grudge/eager.py
@@ -45,15 +45,28 @@ __doc__ = """
 
 class EagerDGDiscretization(DGDiscretizationWithBoundaries):
     """
+    Inherits from :class:`~grudge.discretization.DGDiscretizationWithBoundaries`.
+
+    .. automethod:: __init__
     .. automethod:: project
     .. automethod:: nodes
+
     .. automethod:: grad
+    .. automethod:: d_dx
     .. automethod:: div
+
     .. automethod:: weak_grad
+    .. automethod:: weak_d_dx
     .. automethod:: weak_div
+
     .. automethod:: normal
     .. automethod:: inverse_mass
     .. automethod:: face_mass
+
+    .. automethod:: norm
+    .. automethod:: nodal_sum
+    .. automethod:: nodal_min
+    .. automethod:: nodal_max
     """
 
     def interp(self, src, tgt, vec):
@@ -64,6 +77,14 @@ class EagerDGDiscretization(DGDiscretizationWithBoundaries):
         return self.project(src, tgt, vec)
 
     def project(self, src, tgt, vec):
+        """Project from one discretization to another, e.g. from the
+        volume to the boundary, or from the base to the an overintegrated
+        quadrature discretization.
+
+        :arg src: a :class:`~grudge.sym.DOFDesc`, or a value convertible to one
+        :arg tgt: a :class:`~grudge.sym.DOFDesc`, or a value convertible to one
+        :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+        """
         if (isinstance(vec, np.ndarray)
                 and vec.dtype.char == "O"
                 and not isinstance(vec, DOFArray)):
@@ -72,19 +93,80 @@ class EagerDGDiscretization(DGDiscretizationWithBoundaries):
 
         return self.connection_from_dds(src, tgt)(vec)
 
-    def nodes(self):
-        return self._volume_discr.nodes()
+    def nodes(self, dd=None):
+        r"""Return the nodes of a discretization.
+
+        :arg dd: a :class:`~grudge.sym.DOFDesc`, or a value convertible to one.
+            Defaults to the base volume discretization.
+        :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+        """
+        if dd is None:
+            return self._volume_discr.nodes()
+        else:
+            return self.discr_from_dd(dd).nodes()
+
+    # {{{ derivatives
 
     @memoize_method
     def _bound_grad(self):
         return bind(self, sym.nabla(self.dim) * sym.Variable("u"), local_only=True)
 
     def grad(self, vec):
+        r"""Return the gradient of the volume function represented by *vec*.
+
+        :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+        :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+        """
         return self._bound_grad()(u=vec)
 
+    @memoize_method
+    def _bound_d_dx(self, xyz_axis):
+        return bind(self, sym.nabla(self.dim)[xyz_axis] * sym.Variable("u"),
+                local_only=True)
+
+    def d_dx(self, xyz_axis, vec):
+        r"""Return the derivative along axis *xyz_axis* of the volume function
+        represented by *vec*.
+
+        :arg xyz_axis: an integer indicating the axis along which the derivative
+            is taken
+        :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+        :returns: a :class:`~meshmode.dof_array.DOFArray`\ s
+        """
+        return self._bound_d_dx(xyz_axis)(u=vec)
+
+    def _div_helper(self, diff_func, vecs):
+        if not isinstance(vecs, np.ndarray):
+            raise TypeError("argument must be an object array")
+        assert vecs.dtype == np.object
+
+        if vecs.shape[-1] != self.ambient_dim:
+            raise ValueError("last dimension of *vecs* argument must match "
+                    "ambient dimension")
+
+        if len(vecs.shape) == 1:
+            return sum(diff_func(i, vec_i) for i, vec_i in enumerate(vecs))
+        else:
+            result = np.zeros(vecs.shape[:-1], dtype=object)
+            for idx in np.ndindex(vecs.shape[:-1]):
+                result[idx] = sum(
+                        diff_func(i, vec_i) for i, vec_i in enumerate(vecs[idx]))
+            return result
+
     def div(self, vecs):
-        return sum(
-                self.grad(vec_i)[i] for i, vec_i in enumerate(vecs))
+        r"""Return the divergence of the vector volume function
+        represented by *vecs*.
+
+        :arg vec: an object array of
+            a :class:`~meshmode.dof_array.DOFArray`\ s,
+            where the last axis of the array must have length
+            matching the volume dimension.
+        :returns: a :class:`~meshmode.dof_array.DOFArray`
+        """
+
+        return self._div_helper(
+                lambda i, subvec: self.d_dx(i, subvec),
+                vecs)
 
     @memoize_method
     def _bound_weak_grad(self, dd):
@@ -93,6 +175,16 @@ class EagerDGDiscretization(DGDiscretizationWithBoundaries):
                 local_only=True)
 
     def weak_grad(self, *args):
+        r"""Return the "weak gradient" of the volume function represented by
+        *vec*.
+
+        May be called with ``(vecs)`` or ``(dd, vecs)``.
+
+        :arg dd: a :class:`~grudge.sym.DOFDesc`, or a value convertible to one.
+            Defaults to the base volume discretization if not provided.
+        :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+        :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+        """
         if len(args) == 1:
             vec, = args
             dd = sym.DOFDesc("vol", sym.QTAG_NONE)
@@ -103,7 +195,48 @@ class EagerDGDiscretization(DGDiscretizationWithBoundaries):
 
         return self._bound_weak_grad(dd)(u=vec)
 
+    @memoize_method
+    def _bound_weak_d_dx(self, dd, xyz_axis):
+        return bind(self,
+                sym.stiffness_t(self.dim, dd_in=dd)[xyz_axis]
+                * sym.Variable("u", dd),
+                local_only=True)
+
+    def weak_d_dx(self, *args):
+        r"""Return the derivative along axis *xyz_axis* of the volume function
+        represented by *vec*.
+
+        May be called with ``(xyz_axis, vecs)`` or ``(dd, xyz_axis, vecs)``.
+
+        :arg xyz_axis: an integer indicating the axis along which the derivative
+            is taken
+        :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+        :returns: a :class:`~meshmode.dof_array.DOFArray`\ s
+        """
+        if len(args) == 2:
+            xyz_axis, vec = args
+            dd = sym.DOFDesc("vol", sym.QTAG_NONE)
+        elif len(args) == 3:
+            dd, xyz_axis, vec = args
+        else:
+            raise TypeError("invalid number of arguments")
+
+        return self._bound_weak_d_dx(dd, xyz_axis)(u=vec)
+
     def weak_div(self, *args):
+        r"""Return the "weak divergence" of the vector volume function
+        represented by *vecs*.
+
+        May be called with ``(vecs)`` or ``(dd, vecs)``.
+
+        :arg dd: a :class:`~grudge.sym.DOFDesc`, or a value convertible to one.
+            Defaults to the base volume discretization if not provided.
+        :arg vec: a object array of
+            a :class:`~meshmode.dof_array.DOFArray`\ s,
+            where the last axis of the array must have length
+            matching the volume dimension.
+        :returns: a :class:`~meshmode.dof_array.DOFArray`
+        """
         if len(args) == 1:
             vecs, = args
             dd = sym.DOFDesc("vol", sym.QTAG_NONE)
@@ -112,8 +245,11 @@ class EagerDGDiscretization(DGDiscretizationWithBoundaries):
         else:
             raise TypeError("invalid number of arguments")
 
-        return sum(
-                self.weak_grad(dd, vec_i)[i] for i, vec_i in enumerate(vecs))
+        return self._div_helper(
+                lambda i, subvec: self.weak_d_dx(dd, i, subvec),
+                vecs)
+
+    # }}}
 
     @memoize_method
     def normal(self, dd):
@@ -218,9 +354,11 @@ def interior_trace_pair(discrwb, vec):
                 lambda el: discrwb.opposite_face_connection()(el),
                 i)
 
-    return TracePair("int_faces", i, e)
+    return TracePair("int_faces", interior=i, exterior=e)
 
 
+# {{{ distributed-memory functionality
+
 class _RankBoundaryCommunication:
     base_tag = 1273
 
@@ -259,8 +397,9 @@ class _RankBoundaryCommunication:
 
         self.send_req.Wait()
 
-        return TracePair(self.remote_btag, self.local_dof_array,
-                swapped_remote_dof_array)
+        return TracePair(self.remote_btag,
+                interior=self.local_dof_array,
+                exterior=swapped_remote_dof_array)
 
 
 def _cross_rank_trace_pairs_scalar_field(discrwb, vec, tag=None):
@@ -295,5 +434,7 @@ def cross_rank_trace_pairs(discrwb, vec, tag=None):
     else:
         return _cross_rank_trace_pairs_scalar_field(discrwb, vec, tag=tag)
 
+# }}}
+
 
 # vim: foldmethod=marker
diff --git a/grudge/models/em.py b/grudge/models/em.py
index 4810b4d0ebd8f023adc2eebf01dd62bc67957d6d..e21ad4994544f93a6cd135988f963ddec17c1d47 100644
--- a/grudge/models/em.py
+++ b/grudge/models/em.py
@@ -36,11 +36,9 @@ from meshmode.mesh import BTAG_ALL, BTAG_NONE
 from grudge import sym
 from pytools.obj_array import flat_obj_array, make_obj_array
 
-# TODO: Check PML
-
 
 class MaxwellOperator(HyperbolicOperator):
-    """A 3D Maxwell operator which supports fixed or variable
+    """A strong-form 3D Maxwell operator which supports fixed or variable
     isotropic, non-dispersive, positive epsilon and mu.
 
     Field order is [Ex Ey Ez Hx Hy Hz].
@@ -109,7 +107,7 @@ class MaxwellOperator(HyperbolicOperator):
         self.incident_bc_data = incident_bc
 
     def flux(self, w):
-        """The template for the numerical flux for variable coefficients.
+        """The numerical flux for variable coefficients.
 
         :param flux_type: can be in [0,1] for anything between central and upwind,
           or "lf" for Lax-Friedrichs.
@@ -136,13 +134,13 @@ class MaxwellOperator(HyperbolicOperator):
             return flat_obj_array(
                     # flux e,
                     1/2*(
-                        -self.space_cross_h(normal, h.int-h.ext)
+                        -self.space_cross_h(normal, h.ext-h.int)
                         # multiplication by epsilon undoes material divisor below
                         #-max_c*(epsilon*e.int - epsilon*e.ext)
                     ),
                     # flux h
                     1/2*(
-                        self.space_cross_e(normal, e.int-e.ext)
+                        self.space_cross_e(normal, e.ext-e.int)
                         # multiplication by mu undoes material divisor below
                         #-max_c*(mu*h.int - mu*h.ext)
                     ))
@@ -152,14 +150,14 @@ class MaxwellOperator(HyperbolicOperator):
                     # flux e,
                     (
                         -1/(Z_int+Z_ext)*self.space_cross_h(normal,
-                            Z_ext*(h.int-h.ext)
-                            - self.flux_type*self.space_cross_e(normal, e.int-e.ext))
+                            Z_ext*(h.ext-h.int)
+                            - self.flux_type*self.space_cross_e(normal, e.ext-e.int))
                         ),
                     # flux h
                     (
                         1/(Y_int + Y_ext)*self.space_cross_e(normal,
-                            Y_ext*(e.int-e.ext)
-                            + self.flux_type*self.space_cross_h(normal, h.int-h.ext))
+                            Y_ext*(e.ext-e.int)
+                            + self.flux_type*self.space_cross_h(normal, h.ext-h.int))
                         ),
                     )
         else:
diff --git a/grudge/models/wave.py b/grudge/models/wave.py
index 09a954e873573df374402c28762668573ae71882..863b0112052f71853fd746a80e29385b5ebf187f 100644
--- a/grudge/models/wave.py
+++ b/grudge/models/wave.py
@@ -35,153 +35,6 @@ from pytools.obj_array import flat_obj_array
 
 # {{{ constant-velocity
 
-class StrongWaveOperator(HyperbolicOperator):
-    r"""This operator discretizes the wave equation
-    :math:`\partial_t^2 u = c^2 \Delta u`.
-
-    To be precise, we discretize the hyperbolic system
-
-    .. math::
-
-        \partial_t u - c \\nabla \\cdot v = 0
-
-        \partial_t v - c \\nabla u = 0
-
-    The sign of :math:`v` determines whether we discretize the forward or the
-    backward wave equation.
-
-    :math:`c` is assumed to be constant across all space.
-    """
-
-    def __init__(self, c, ambient_dim, source_f=0,
-            flux_type="upwind",
-            dirichlet_tag=BTAG_ALL,
-            dirichlet_bc_f=0,
-            neumann_tag=BTAG_NONE,
-            radiation_tag=BTAG_NONE):
-        assert isinstance(ambient_dim, int)
-
-        self.c = c
-        self.ambient_dim = ambient_dim
-        self.source_f = source_f
-
-        if self.c > 0:
-            self.sign = 1
-        else:
-            self.sign = -1
-
-        self.dirichlet_tag = dirichlet_tag
-        self.neumann_tag = neumann_tag
-        self.radiation_tag = radiation_tag
-
-        self.dirichlet_bc_f = dirichlet_bc_f
-
-        self.flux_type = flux_type
-
-    def flux(self, w):
-        u = w[0]
-        v = w[1:]
-        normal = sym.normal(w.dd, self.ambient_dim)
-
-        flux_weak = flat_obj_array(
-                np.dot(v.avg, normal),
-                u.avg * normal)
-
-        if self.flux_type == "central":
-            pass
-        elif self.flux_type == "upwind":
-            # see doc/notes/grudge-notes.tm
-            flux_weak -= self.sign*flat_obj_array(
-                    0.5*(u.int-u.ext),
-                    0.5*(normal * np.dot(normal, v.int-v.ext)))
-        else:
-            raise ValueError("invalid flux type '%s'" % self.flux_type)
-
-        flux_strong = flat_obj_array(
-                np.dot(v.int, normal),
-                u.int * normal) - flux_weak
-
-        return self.c*flux_strong
-
-    def sym_operator(self):
-        d = self.ambient_dim
-
-        w = sym.make_sym_array("w", d+1)
-        u = w[0]
-        v = w[1:]
-
-        # boundary conditions -------------------------------------------------
-
-        # dirichlet BCs -------------------------------------------------------
-        dir_u = sym.cse(sym.project("vol", self.dirichlet_tag)(u))
-        dir_v = sym.cse(sym.project("vol", self.dirichlet_tag)(v))
-        if self.dirichlet_bc_f:
-            # FIXME
-            from warnings import warn
-            warn("Inhomogeneous Dirichlet conditions on the wave equation "
-                    "are still having issues.")
-
-            dir_g = sym.Field("dir_bc_u")
-            dir_bc = flat_obj_array(2*dir_g - dir_u, dir_v)
-        else:
-            dir_bc = flat_obj_array(-dir_u, dir_v)
-
-        dir_bc = sym.cse(dir_bc, "dir_bc")
-
-        # neumann BCs ---------------------------------------------------------
-        neu_u = sym.cse(sym.project("vol", self.neumann_tag)(u))
-        neu_v = sym.cse(sym.project("vol", self.neumann_tag)(v))
-        neu_bc = sym.cse(flat_obj_array(neu_u, -neu_v), "neu_bc")
-
-        # radiation BCs -------------------------------------------------------
-        rad_normal = sym.normal(self.radiation_tag, d)
-
-        rad_u = sym.cse(sym.project("vol", self.radiation_tag)(u))
-        rad_v = sym.cse(sym.project("vol", self.radiation_tag)(v))
-
-        rad_bc = sym.cse(flat_obj_array(
-                0.5*(rad_u - self.sign*np.dot(rad_normal, rad_v)),
-                0.5*rad_normal*(np.dot(rad_normal, rad_v) - self.sign*rad_u)
-                ), "rad_bc")
-
-        # entire operator -----------------------------------------------------
-        nabla = sym.nabla(d)
-
-        def flux(pair):
-            return sym.project(pair.dd, "all_faces")(
-                    self.flux(pair))
-
-        result = (
-                - flat_obj_array(
-                    -self.c*np.dot(nabla, v),
-                    -self.c*(nabla*u)
-                    )
-                +  # noqa: W504
-                sym.InverseMassOperator()(
-                    sym.FaceMassOperator()(
-                        flux(sym.int_tpair(w))
-                        + flux(sym.bv_tpair(self.dirichlet_tag, w, dir_bc))
-                        + flux(sym.bv_tpair(self.neumann_tag, w, neu_bc))
-                        + flux(sym.bv_tpair(self.radiation_tag, w, rad_bc))
-                        )
-                    )
-                )
-
-        result[0] += self.source_f
-
-        return result
-
-    def check_bc_coverage(self, mesh):
-        from meshmode.mesh import check_bc_coverage
-        check_bc_coverage(mesh, [
-            self.dirichlet_tag,
-            self.neumann_tag,
-            self.radiation_tag])
-
-    def max_eigenvalue(self, t, fields=None, discr=None):
-        return abs(self.c)
-
-
 class WeakWaveOperator(HyperbolicOperator):
     r"""This operator discretizes the wave equation
     :math:`\partial_t^2 u = c^2 \Delta u`.
@@ -230,16 +83,16 @@ class WeakWaveOperator(HyperbolicOperator):
         v = w[1:]
         normal = sym.normal(w.dd, self.ambient_dim)
 
-        flux_weak = flat_obj_array(
+        central_flux_weak = -self.c*flat_obj_array(
                 np.dot(v.avg, normal),
                 u.avg * normal)
 
         if self.flux_type == "central":
-            return -self.c*flux_weak
+            return central_flux_weak
         elif self.flux_type == "upwind":
-            return -self.c*(flux_weak + self.sign*flat_obj_array(
-                    0.5*(u.int-u.ext),
-                    0.5*(normal * np.dot(normal, v.int-v.ext))))
+            return central_flux_weak - self.c*self.sign*flat_obj_array(
+                    0.5*(u.ext-u.int),
+                    0.5*(normal * np.dot(normal, v.ext-v.int)))
         else:
             raise ValueError("invalid flux type '%s'" % self.flux_type)
 
@@ -294,7 +147,6 @@ class WeakWaveOperator(HyperbolicOperator):
                     -self.c*(sym.stiffness_t(self.ambient_dim)*u)
                     )
 
-
                 - sym.FaceMassOperator()(flux(sym.int_tpair(w))
                     + flux(sym.bv_tpair(self.dirichlet_tag, w, dir_bc))
                     + flux(sym.bv_tpair(self.neumann_tag, w, neu_bc))
@@ -370,18 +222,18 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
         v = w[2:]
         normal = sym.normal(w.dd, self.ambient_dim)
 
+        flux_central_weak = -0.5 * flat_obj_array(
+            np.dot(v.int*c.int + v.ext*c.ext, normal),
+            (u.int * c.int + u.ext*c.ext) * normal)
+
         if self.flux_type == "central":
-            return -0.5 * flat_obj_array(
-                np.dot(v.int*c.int + v.ext*c.ext, normal),
-                (u.int * c.int + u.ext*c.ext) * normal)
+            return flux_central_weak
 
         elif self.flux_type == "upwind":
-            return -0.5 * flat_obj_array(
-                    np.dot(normal, c.ext * v.ext + c.int * v.int)
-                    + c.ext*u.ext - c.int * u.int,
+            return flux_central_weak - 0.5 * flat_obj_array(
+                    c.ext*u.ext - c.int * u.int,
 
-                    normal * (np.dot(normal, c.ext * v.ext - c.int * v.int)
-                    + c.ext*u.ext + c.int * u.int))
+                    normal * (np.dot(normal, c.ext * v.ext - c.int * v.int)))
 
         else:
             raise ValueError("invalid flux type '%s'" % self.flux_type)
@@ -443,7 +295,6 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
                     -self.c*(sym.stiffness_t(self.ambient_dim)*u)
                     )
 
-
                 - sym.FaceMassOperator()(flux(sym.int_tpair(flux_w))
                     + flux(sym.bv_tpair(self.dirichlet_tag, flux_w, dir_bc))
                     + flux(sym.bv_tpair(self.neumann_tag, flux_w, neu_bc))
diff --git a/grudge/symbolic/operators.py b/grudge/symbolic/operators.py
index a8ab366b52cca01bb6bedd060fe05adf7c1295df..6a5a982ce29cf3a1168b1c3851f1c9a7195f59ec 100644
--- a/grudge/symbolic/operators.py
+++ b/grudge/symbolic/operators.py
@@ -94,12 +94,12 @@ class Operator(pymbolic.primitives.Expression):
     """
     .. attribute:: dd_in
 
-        an instance of :class:`~grudge.symbolic.primitives.DOFDesc` describing the
+        an instance of :class:`~grudge.sym.DOFDesc` describing the
         input discretization.
 
     .. attribute:: dd_out
 
-        an instance of :class:`~grudge.symbolic.primitives.DOFDesc` describing the
+        an instance of :class:`~grudge.sym.DOFDesc` describing the
         output discretization.
     """
 
@@ -477,7 +477,10 @@ class VandermondeOperator(ElementwiseLinearOperator):
 
 class MassOperatorBase(Operator):
     """
-    :attr:`dd_in` and :attr:`dd_out` may be surface or volume discretizations.
+    Inherits from :class:`Operator`.
+
+    :attr:`~Operator.dd_in` and :attr:`~Operator.dd_out` may be surface or volume
+    discretizations.
     """
 
     def __init__(self, dd_in=None, dd_out=None):
diff --git a/grudge/symbolic/primitives.py b/grudge/symbolic/primitives.py
index d706314cdd03b23fa46ff43a7df16234dce6cd70..8d7d81328db86b4f107e3efa1b915150811796c0 100644
--- a/grudge/symbolic/primitives.py
+++ b/grudge/symbolic/primitives.py
@@ -55,6 +55,8 @@ class ExpressionBase(prim.Expression):
 
 __doc__ = """
 
+.. currentmodule:: grudge.sym
+
 DOF description
 ^^^^^^^^^^^^^^^
 
@@ -863,7 +865,7 @@ class TracePair:
         an expression representing the exterior value to
         be used for the flux.
     """
-    def __init__(self, dd, interior, exterior):
+    def __init__(self, dd, *, interior, exterior):
         """
         """
 
@@ -874,8 +876,8 @@ class TracePair:
     def __getitem__(self, index):
         return TracePair(
                 self.dd,
-                self.exterior[index],
-                self.interior[index])
+                interior=self.interior[index],
+                exterior=self.exterior[index])
 
     def __len__(self):
         assert len(self.exterior) == len(self.interior)
@@ -912,7 +914,7 @@ def int_tpair(expression, qtag=None, from_dd=None):
         i = cse(project(trace_dd.with_qtag(None), trace_dd)(i))
         e = cse(project(trace_dd.with_qtag(None), trace_dd)(e))
 
-    return TracePair(trace_dd, i, e)
+    return TracePair(trace_dd, interior=i, exterior=e)
 
 
 def bdry_tpair(dd, interior, exterior):
@@ -924,7 +926,7 @@ def bdry_tpair(dd, interior, exterior):
         representing the exterior value to be used
         for the flux.
     """
-    return TracePair(dd, interior, exterior)
+    return TracePair(dd, interior=interior, exterior=exterior)
 
 
 def bv_tpair(dd, interior, exterior):
@@ -938,7 +940,7 @@ def bv_tpair(dd, interior, exterior):
     """
     from grudge.symbolic.operators import project
     interior = cse(project("vol", dd)(interior))
-    return TracePair(dd, interior, exterior)
+    return TracePair(dd, interior=interior, exterior=exterior)
 
 # }}}
 
diff --git a/test/test_grudge.py b/test/test_grudge.py
index 25707b217fed39acb7956e8a9ab6aefaccf9d358..faf4bc05c4ffb28a4cd98c0a518fca9be58c5c0b 100644
--- a/test/test_grudge.py
+++ b/test/test_grudge.py
@@ -34,8 +34,8 @@ from pytools.obj_array import flat_obj_array, make_obj_array
 from grudge import sym, bind, DGDiscretizationWithBoundaries
 
 import pytest
-from pyopencl.tools import (  # noqa
-        pytest_generate_tests_for_pyopencl
+from meshmode.array_context import (  # noqa
+        pytest_generate_tests_for_pyopencl_array_context
         as pytest_generate_tests)
 
 import logging
@@ -46,10 +46,8 @@ logger = logging.getLogger(__name__)
 # {{{ inverse metric
 
 @pytest.mark.parametrize("dim", [2, 3])
-def test_inverse_metric(ctx_factory, dim):
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+def test_inverse_metric(actx_factory, dim):
+    actx = actx_factory()
 
     from meshmode.mesh.generation import generate_regular_rect_mesh
     mesh = generate_regular_rect_mesh(a=(-0.5,)*dim, b=(0.5,)*dim,
@@ -97,13 +95,11 @@ def test_inverse_metric(ctx_factory, dim):
 
 @pytest.mark.parametrize("ambient_dim", [1, 2, 3])
 @pytest.mark.parametrize("quad_tag", [sym.QTAG_NONE, "OVSMP"])
-def test_mass_mat_trig(ctx_factory, ambient_dim, quad_tag):
+def test_mass_mat_trig(actx_factory, ambient_dim, quad_tag):
     """Check the integral of some trig functions on an interval using the mass
     matrix.
     """
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     nelements = 17
     order = 4
@@ -196,10 +192,8 @@ def _spheroid_surface_area(radius, aspect_ratio):
 @pytest.mark.parametrize("name", [
     "2-1-ellipse", "spheroid", "box2d", "box3d"
     ])
-def test_mass_surface_area(ctx_factory, name):
-    cl_ctx = cl.create_some_context()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+def test_mass_surface_area(actx_factory, name):
+    actx = actx_factory()
 
     # {{{ cases
 
@@ -266,7 +260,7 @@ def test_mass_surface_area(ctx_factory, name):
 # {{{ surface mass inverse
 
 @pytest.mark.parametrize("name", ["2-1-ellipse", "spheroid"])
-def test_surface_mass_operator_inverse(ctx_factory, name):
+def test_surface_mass_operator_inverse(actx_factory, name):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
     actx = PyOpenCLArrayContext(queue)
@@ -339,12 +333,9 @@ def _avg_face_normal(x, side=-1):
 
 
 @pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"])
-def test_face_normal_surface(ctx_factory, mesh_name):
+def test_face_normal_surface(actx_factory, mesh_name):
     """Check that face normals are orthogonal to the surface normal"""
-
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     # {{{ geometry
 
@@ -453,15 +444,13 @@ def test_face_normal_surface(ctx_factory, mesh_name):
 # {{{ diff operator
 
 @pytest.mark.parametrize("dim", [1, 2, 3])
-def test_tri_diff_mat(ctx_factory, dim, order=4):
+def test_tri_diff_mat(actx_factory, dim, order=4):
     """Check differentiation matrix along the coordinate axes on a disk
 
     Uses sines as the function to differentiate.
     """
 
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     from meshmode.mesh.generation import generate_regular_rect_mesh
 
@@ -497,7 +486,7 @@ def test_tri_diff_mat(ctx_factory, dim, order=4):
 
 # {{{ divergence theorem
 
-def test_2d_gauss_theorem(ctx_factory):
+def test_2d_gauss_theorem(actx_factory):
     """Verify Gauss's theorem explicitly on a mesh"""
 
     pytest.importorskip("meshpy")
@@ -515,9 +504,7 @@ def test_2d_gauss_theorem(ctx_factory):
     from meshmode.mesh.io import from_meshpy
     mesh = from_meshpy(mesh_info, order=1)
 
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     discr = DGDiscretizationWithBoundaries(actx, mesh, order=2)
 
@@ -541,7 +528,7 @@ def test_2d_gauss_theorem(ctx_factory):
 
 
 @pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"])
-def test_surface_divergence_theorem(ctx_factory, mesh_name, visualize=False):
+def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
     r"""Check the surface divergence theorem.
 
         .. math::
@@ -555,10 +542,7 @@ def test_surface_divergence_theorem(ctx_factory, mesh_name, visualize=False):
         face normal (which should be orthogonal to both the surface normal
         and the face tangent).
     """
-
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     # {{{ cases
 
@@ -709,13 +693,11 @@ def test_surface_divergence_theorem(ctx_factory, mesh_name, visualize=False):
 @pytest.mark.parametrize("flux_type", ["central"])
 @pytest.mark.parametrize("order", [3, 4, 5])
 # test: 'test_convergence_advec(cl._csc, "disk", [0.1, 0.05], "strong", "upwind", 3)'
-def test_convergence_advec(ctx_factory, mesh_name, mesh_pars, op_type, flux_type,
+def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_type,
         order, visualize=False):
     """Test whether 2D advection actually converges"""
 
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     from pytools.convergence import EOCRecorder
     eoc_rec = EOCRecorder()
@@ -855,12 +837,10 @@ def test_convergence_advec(ctx_factory, mesh_name, mesh_pars, op_type, flux_type
 # {{{ models: maxwell
 
 @pytest.mark.parametrize("order", [3, 4, 5])
-def test_convergence_maxwell(ctx_factory,  order):
+def test_convergence_maxwell(actx_factory,  order):
     """Test whether 3D Maxwell's actually converges"""
 
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     from pytools.convergence import EOCRecorder
     eoc_rec = EOCRecorder()
@@ -930,16 +910,14 @@ def test_convergence_maxwell(ctx_factory,  order):
 # {{{ models: variable coefficient advection oversampling
 
 @pytest.mark.parametrize("order", [2, 3, 4])
-def test_improvement_quadrature(ctx_factory, order):
+def test_improvement_quadrature(actx_factory, order):
     """Test whether quadrature improves things and converges"""
     from meshmode.mesh.generation import generate_regular_rect_mesh
     from grudge.models.advection import VariableCoefficientAdvectionOperator
     from pytools.convergence import EOCRecorder
     from meshmode.discretization.poly_element import QuadratureSimplexGroupFactory
 
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = actx_factory()
 
     dims = 2
     sym_nds = sym.nodes(dims)
@@ -1032,10 +1010,8 @@ def test_op_collector_order_determinism():
 
 # {{{ bessel
 
-def test_bessel(ctx_factory):
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+def test_bessel(actx_factory):
+    actx = actx_factory()
 
     dims = 2
 
@@ -1066,10 +1042,8 @@ def test_bessel(ctx_factory):
 
 # {{{ function symbol
 
-def test_external_call(ctx_factory):
-    cl_ctx = ctx_factory()
-    queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+def test_external_call(actx_factory):
+    actx = actx_factory()
 
     def double(queue, x):
         return 2 * x
diff --git a/test/test_mpi_communication.py b/test/test_mpi_communication.py
index a5109737106279f0a76d147a9d77a4e35d2d6d84..91656bda4d1e123cc6a69eafe0c2f1a9b4eb1fa4 100644
--- a/test/test_mpi_communication.py
+++ b/test/test_mpi_communication.py
@@ -140,9 +140,9 @@ def mpi_communication_entrypoint():
     sym_source_center_dist = sym_x - source_center
     sym_t = sym.ScalarVariable("t")
 
-    from grudge.models.wave import StrongWaveOperator
+    from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = StrongWaveOperator(-0.1, vol_discr.dim,
+    op = WeakWaveOperator(0.1, vol_discr.dim,
             source_f=(
                 sym.sin(source_omega*sym_t)
                 * sym.exp(